From 23735ef91020b9bc7370451a6db25edb5b1abfae Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 20 Jun 2026 08:20:43 -0700 Subject: [PATCH 01/99] fix: 'feed[dB]ack' -> 'fee[dB]ack' in v3 guitar tone source labels Both the v3 index.html and live-guitar-tone-source.js had an extra 'd' in the brand name ('feed[dB]ack' instead of 'fee[dB]ack') in the guitar tone source selector labels and help text. --- static/v3/index.html | 6 +++--- static/v3/live-guitar-tone-source.js | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/static/v3/index.html b/static/v3/index.html index 535d11e..c0647e0 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -435,11 +435,11 @@ -

Choose External/Spark if your guitar tone comes from hardware like Spark LIVE. feed[dB]ack will still score your playing but won’t warn that no internal amp tone is loaded.

+

Choose External/Spark if your guitar tone comes from hardware like Spark LIVE. fee[dB]ack will still score your playing but won’t warn that no internal amp tone is loaded.

@@ -736,7 +736,7 @@ Guitar tone diff --git a/static/v3/live-guitar-tone-source.js b/static/v3/live-guitar-tone-source.js index 90a499c..dd2c9ac 100644 --- a/static/v3/live-guitar-tone-source.js +++ b/static/v3/live-guitar-tone-source.js @@ -19,14 +19,14 @@ const DEFAULT = SOURCES.INTERNAL; const LABELS = Object.freeze({ - [SOURCES.INTERNAL]: 'feed[dB]ack internal tone', + [SOURCES.INTERNAL]: 'fee[dB]ack internal tone', [SOURCES.EXTERNAL_HARDWARE]: 'External amp / hardware pedalboard', [SOURCES.SPARK_CONTROL_X]: 'Spark LIVE + Spark Control X', }); const HELP_TEXT = 'Choose External/Spark if your guitar tone comes from hardware like Spark LIVE. ' - + 'feed[dB]ack will still score your playing but won\u2019t warn that no internal amp tone is loaded.'; + + 'fee[dB]ack will still score your playing but won\u2019t warn that no internal amp tone is loaded.'; function normalize(value) { if (value === SOURCES.EXTERNAL_HARDWARE || value === SOURCES.SPARK_CONTROL_X) return value; From e33df9a720b6ac0fd94632ac3766347b7bf3c56b Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sat, 20 Jun 2026 23:17:39 +0200 Subject: [PATCH 02/99] =?UTF-8?q?feat(core):=20per-note=20bend=20shape=20(?= =?UTF-8?q?bt=20+=20bnv)=20=E2=80=94=20wire=20+=20GP=20import=20(#531)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements feedpak spec §6.2.1 (feedpak 1.4.0) per-note bend shape on the core side: - `bn` stays the bend's peak magnitude in semitones (unchanged). - `bt` — bend intent (0 up, 1 release, 2 pre-bend, 3 pre-bend-release, 4 round-trip), default 0, default-omitted on the wire. - `bnv` — time-stamped bend curve [{t: seconds-from-onset, v: semitones}], authoritative when present; default-omitted. Older readers ignore both. Wire (lib/song.py): Note.bend_intent/bend_values; note_to_wire emits bt/bnv only when set; note_from_wire reads them via _sanitize_bend_curve (drops malformed entries, empty -> None never []). _parse_note reads them from the GP-import XML (bendIntent attr + bendValues JSON) so GP curves survive import -> XML -> wire -> highway. GP5 (lib/gp2rs.py): _gp_bend_shape maps pyguitarpro BendPoints to a bnv curve — semitones = value/2.0 (consistent with the existing scalar bn), t = position/12 * duration — and _bend_intent_from_values derives bt from the shape. Emitted for and via the shared _build_xml. GP8 (lib/gp2rs_gpx.py): _gpx_bend_shape builds a 3-point curve from the GPIF origin/middle/destination value+offset Properties (value/divisor semitones, offset/100 * sustain seconds), reusing the shared _build_xml. GPIF offset Property names should be confirmed against a real GP8 export. Tests cover wire round-trip + default-omit + sanitization, GP5 unit/time mapping + intent classification end-to-end through the XML, and the GP8 curve builder. Part of got-feedback/feedback#334 Co-authored-by: Claude Opus 4.8 (1M context) --- lib/gp2rs.py | 120 +++++++++++++++++++++++++++++++--------- lib/gp2rs_gpx.py | 76 ++++++++++++++++++++----- lib/song.py | 60 ++++++++++++++++++++ tests/test_gp2rs.py | 76 +++++++++++++++++++++++++ tests/test_gp2rs_gpx.py | 45 +++++++++++++++ tests/test_song.py | 79 ++++++++++++++++++++++++++ 6 files changed, 417 insertions(+), 39 deletions(-) diff --git a/lib/gp2rs.py b/lib/gp2rs.py index 486d833..9b233e9 100644 --- a/lib/gp2rs.py +++ b/lib/gp2rs.py @@ -1,5 +1,6 @@ """Convert Guitar Pro files (.gp5/.gp4/.gp3) to arrangement XML.""" +import json import logging import re import xml.etree.ElementTree as ET @@ -56,6 +57,8 @@ class RsNote: fret: int sustain: float = 0.0 bend: float = 0.0 + bend_intent: int = 0 + bend_values: list | None = None slide_to: int = -1 slide_unpitch_to: int = -1 hammer_on: bool = False @@ -191,6 +194,67 @@ def _duration_to_seconds(duration: guitarpro.Duration, tempo: float) -> float: return beats * (60.0 / tempo) +# pyguitarpro models bend-point x-positions on 0..BendEffect.maxPosition (12) +# across the note's duration; y-values are half-quarter-tone units where 12 = 6 +# semitones, so semitones = value / 2.0 (matches the scalar `bend` derivation). +_GP_BEND_MAX_POSITION = 12 + + +def _bend_intent_from_values(values: list[float]) -> int: + """Classify a bend gesture (§6.2.1) from its time-ordered semitone values: + 0 up, 1 release, 2 pre-bend, 3 pre-bend-and-release, 4 round-trip.""" + if not values: + return 0 + eps = 0.05 + first, last, peak = values[0], values[-1], max(values) + if first > eps: + if last <= eps: + return 3 # pre-bent, then released to pitch + if last < first - eps: + return 1 # held bend let down + return 2 # pre-bend held + if peak > eps and last <= eps: + return 4 # bend up and back down + return 0 # plain bend up + + +def _gp_bend_shape(bend, duration_secs: float): + """From a pyguitarpro ``BendEffect``, return ``(peak, intent, curve)``. + + ``peak`` is the bend's peak in semitones (the scalar ``bn``); ``intent`` is + the §6.2.1 ``bt`` code; ``curve`` is the time-stamped ``bnv`` list + (``[{t: seconds-from-onset, v: semitones}]``) or ``None`` when there's no + usable shape (no points, or a zero-length note collapsing every point to + ``t=0``).""" + pts = sorted(bend.points or [], key=lambda p: p.position) + if not pts: + return 0.0, 0, None + values = [round(p.value / 2.0, 1) for p in pts] + peak = round(max(values), 1) + intent = _bend_intent_from_values(values) + curve = None + if duration_secs > 0 and len(pts) >= 2: + curve = [ + {"t": round(duration_secs * (p.position / _GP_BEND_MAX_POSITION), 3), + "v": v} + for p, v in zip(pts, values) + ] + return peak, intent, curve + + +def _bend_shape_xml_attrs(n: "RsNote") -> dict: + """Optional bend-shape XML attributes for a /, default- + omitted: `bendIntent` only when non-zero, `bendValues` (a JSON-encoded + [{t,v}] curve) only when present. `_parse_note` (lib/song.py) reads these + back so a GP-imported bend curve survives import → wire → highway.""" + attrs: dict = {} + if n.bend_intent: + attrs["bendIntent"] = str(int(n.bend_intent)) + if n.bend_values: + attrs["bendValues"] = json.dumps(n.bend_values, separators=(",", ":")) + return attrs + + def _tempo_at_tick(tick: int, tempo_map: list[TempoEvent]) -> float: """Get the tempo at a given tick.""" result = tempo_map[0].tempo @@ -735,12 +799,13 @@ def convert_track( # Techniques eff = note.effect if eff.bend and eff.bend.points: - # pyguitarpro bend point values are in quarter-tones - # (maxValue 12 = 3 whole tones = 6 semitones), so - # semitones = value / 2. The old /100.0 made every bend - # round to 0 (a whole-tone bend is value 4 -> 0.04). - max_bend = max(p.value for p in eff.bend.points) - rn.bend = round(max_bend / 2.0, 1) + # `bn` is the peak; `bnv`/`bt` describe the shape over + # time (§6.2.1). semitones = value / 2 (maxValue 12 = 6 + # semitones); the old /100.0 made every bend round to 0. + peak, intent, curve = _gp_bend_shape(eff.bend, dur) + rn.bend = peak + rn.bend_intent = intent + rn.bend_values = curve if eff.hammer: # HO vs PO from pitch direction off the prior note on the @@ -1098,6 +1163,7 @@ def _build_xml( "tap": "1" if n.tap else "0", "ignore": "0", } + attrs.update(_bend_shape_xml_attrs(n)) ET.SubElement(notes_el, "note", **attrs) # Chords @@ -1108,25 +1174,29 @@ def _build_xml( chordId=str(ch.template_idx), highDensity="0", strum="down") for cn in ch.notes: - ET.SubElement(chord_el, "chordNote", - time=f"{cn.time:.3f}", - string=str(cn.string), - fret=str(cn.fret), - sustain=f"{cn.sustain:.3f}", - bend=f"{cn.bend:.1f}" if cn.bend else "0", - hammerOn="1" if cn.hammer_on else "0", - pullOff="1" if cn.pull_off else "0", - slideTo=str(cn.slide_to), - slideUnpitchTo=str(cn.slide_unpitch_to), - harmonic="1" if cn.harmonic else "0", - harmonicPinch="1" if cn.harmonic_pinch else "0", - palmMute="1" if cn.palm_mute else "0", - mute="1" if cn.mute else "0", - vibrato="1" if cn.vibrato else "0", - tremolo="1" if cn.tremolo else "0", - accent="1" if cn.accent else "0", - linkNext="1" if cn.link_next else "0", - tap="1" if cn.tap else "0", ignore="0") + cn_attrs = { + "time": f"{cn.time:.3f}", + "string": str(cn.string), + "fret": str(cn.fret), + "sustain": f"{cn.sustain:.3f}", + "bend": f"{cn.bend:.1f}" if cn.bend else "0", + "hammerOn": "1" if cn.hammer_on else "0", + "pullOff": "1" if cn.pull_off else "0", + "slideTo": str(cn.slide_to), + "slideUnpitchTo": str(cn.slide_unpitch_to), + "harmonic": "1" if cn.harmonic else "0", + "harmonicPinch": "1" if cn.harmonic_pinch else "0", + "palmMute": "1" if cn.palm_mute else "0", + "mute": "1" if cn.mute else "0", + "vibrato": "1" if cn.vibrato else "0", + "tremolo": "1" if cn.tremolo else "0", + "accent": "1" if cn.accent else "0", + "linkNext": "1" if cn.link_next else "0", + "tap": "1" if cn.tap else "0", + "ignore": "0", + } + cn_attrs.update(_bend_shape_xml_attrs(cn)) + ET.SubElement(chord_el, "chordNote", **cn_attrs) # Anchors anchors_el = ET.SubElement(level, "anchors", count=str(len(anchors))) diff --git a/lib/gp2rs_gpx.py b/lib/gp2rs_gpx.py index 9588b35..ecd6a93 100644 --- a/lib/gp2rs_gpx.py +++ b/lib/gp2rs_gpx.py @@ -1147,6 +1147,59 @@ def _gpx_bend_scale(root: ET.Element) -> float: return 50.0 if peak <= 400 else 2500.0 +def _gpx_bend_float(tp: dict, name: str): + """Read a GPIF bend `` value from the property map, or None.""" + el = tp.get(name) + if el is None: + return None + try: + return float(el.findtext('Float') or 0) + except (ValueError, TypeError): + return None + + +def _gpx_bend_shape(tp: dict, divisor: float, sustain: float): + """Build ``(peak, intent, curve)`` from a GPIF note's bend Properties (§6.2.1). + + GPIF describes a bend as origin / middle / destination value+offset pairs; + `value / divisor` is semitones (divisor auto-detected per file) and the + `*Offset` Properties are 0..100 (percent of the note's duration). Produces a + bnv curve of up to three points (mapping each offset to seconds-from-onset), + or ``None`` when there's no usable shape (no points, flat-zero, or a + zero-length note). When an offset Property is absent the stage falls back to + an evenly-spaced default (origin 0%, middle 50%, destination 100%). + + NOTE: offset Property names should be confirmed against a real GP8 export; + the value path matches the existing scalar-bend extraction either way.""" + from gp2rs import _bend_intent_from_values # lazy: gp2rs<->gpx circular + stages = ( + ('BendOriginValue', 'BendOriginOffset', 0.0), + ('BendMiddleValue', 'BendMiddleOffset1', 50.0), + ('BendDestinationValue', 'BendDestinationOffset', 100.0), + ) + pts = [] + for vkey, okey, default_off in stages: + v = _gpx_bend_float(tp, vkey) + if v is None: + continue + off = _gpx_bend_float(tp, okey) + if off is None: + off = default_off + off = max(0.0, min(100.0, off)) + pts.append((off, round(v / divisor, 1))) + if not pts: + return 0.0, 0, None + pts.sort(key=lambda p: p[0]) + values = [v for _, v in pts] + peak = round(max(values), 1) + intent = _bend_intent_from_values(values) + curve = None + if peak > 0 and sustain > 0 and len(pts) >= 2: + curve = [{"t": round(sustain * (off / 100.0), 3), "v": v} + for off, v in pts] + return peak, intent, curve + + def _resolve_pending_slides(rs_notes, rs_chords, pending_slides): """Resolve GP slide flags collected during the beat loop into RS slide fields, now that every note on each string is known. @@ -1564,21 +1617,16 @@ def convert_file( rn.pull_off = True else: rn.hammer_on = True - # Bend: peak amount (GPIF bend value → semitones, - # scale auto-detected per file in _bend_divisor). + # Bend: `bn` is the peak; `bnv`/`bt` capture + # the shape over time (§6.2.1). value/divisor + # = semitones (scale auto-detected per file). if 'Bended' in _tp: - _bv = 0.0 - for _bk in ('BendDestinationValue', - 'BendMiddleValue', 'BendOriginValue'): - _be = _tp.get(_bk) - if _be is not None: - try: - _bv = max(_bv, float( - _be.findtext('Float') or 0)) - except (ValueError, TypeError): - pass - if _bv > 0: - rn.bend = round(_bv / _bend_divisor, 1) + _peak, _intent, _curve = _gpx_bend_shape( + _tp, _bend_divisor, rn.sustain) + if _peak > 0: + rn.bend = _peak + rn.bend_intent = _intent + rn.bend_values = _curve # Slide flags: 1/2 = pitched slide to the next # note; 4 = slide out down, 8 = out up. Resolved # post-loop (needs the next note on the string). diff --git a/lib/song.py b/lib/song.py index b58f5b6..779c743 100644 --- a/lib/song.py +++ b/lib/song.py @@ -20,6 +20,13 @@ class Note: slide_to: int = -1 slide_unpitch_to: int = -1 bend: float = 0.0 + # Bend shape (§6.2.1, feedpak 1.4.0). `bend` stays the peak magnitude; + # `bend_intent` is the gesture (0 up, 1 release, 2 pre-bend, + # 3 pre-bend-release, 4 round-trip) and `bend_values` is the optional + # time-stamped curve [{t: seconds-from-onset, v: semitones}], authoritative + # when present. Both default-omitted on the wire; older readers ignore them. + bend_intent: int = 0 + bend_values: list | None = None hammer_on: bool = False pull_off: bool = False harmonic: bool = False @@ -221,6 +228,16 @@ def note_to_wire(n: Note) -> dict: out["pkd"] = n.pick_direction if n.ignore: out["ig"] = True + # Bend shape (§6.2.1) — default-omitted: `bt` only when non-zero, `bnv` + # only when a curve is present. Mirrors the spec's "omit fields equal to + # their default" so a plain bend stays a single `bn` scalar on the wire. + if n.bend_intent: + out["bt"] = int(n.bend_intent) + if n.bend_values: + out["bnv"] = [ + {"t": round(p["t"], 3), "v": round(p["v"], 1)} + for p in n.bend_values + ] return out @@ -283,6 +300,33 @@ def _wire_int_optional(v, default=-1): return default +def _sanitize_bend_curve(raw): + """Clean a time-stamped bend curve (``[{t, v}]``, §6.2.1): keep entries with + a finite, non-bool numeric ``t`` and ``v``, coerced to float and sorted by + ``t``. Non-list / absent / all-invalid input -> ``None`` so an empty curve + round-trips as *omitted*, never ``[]``. ``t`` is seconds from the note + onset; ``v`` is semitones (same scale as the scalar ``bn`` peak).""" + if not isinstance(raw, list): + return None + out: list[dict] = [] + for p in raw: + if not isinstance(p, dict): + continue + t = p.get("t") + v = p.get("v") + if (not isinstance(t, (int, float)) or isinstance(t, bool) + or not math.isfinite(t)): + continue + if (not isinstance(v, (int, float)) or isinstance(v, bool) + or not math.isfinite(v)): + continue + out.append({"t": float(t), "v": float(v)}) + if not out: + return None + out.sort(key=lambda e: e["t"]) + return out + + def note_from_wire(d: dict, time: float | None = None) -> Note: return Note( time=float(d.get("t", time if time is not None else 0.0)), @@ -292,6 +336,8 @@ def note_from_wire(d: dict, time: float | None = None) -> Note: slide_to=int(d.get("sl", -1)), slide_unpitch_to=int(d.get("slu", -1)), bend=float(d.get("bn", 0.0)), + bend_intent=_wire_int_optional(d.get("bt"), 0), + bend_values=_sanitize_bend_curve(d.get("bnv")), hammer_on=bool(d.get("ho", False)), pull_off=bool(d.get("po", False)), harmonic=bool(d.get("hm", False)), @@ -768,6 +814,18 @@ def _chord_high_density(elem: ET.Element) -> bool: return False +def _parse_bend_values(n): + """Read a `bendValues` JSON attribute (GP import emits it; §6.2.1) and + sanitize it into a [{t,v}] curve, or None when absent/malformed.""" + raw = n.get("bendValues") + if not raw: + return None + try: + return _sanitize_bend_curve(json.loads(raw)) + except (ValueError, TypeError): + return None + + def _parse_note(n) -> Note: return Note( time=_float(n, "time"), @@ -777,6 +835,8 @@ def _parse_note(n) -> Note: slide_to=_int(n, "slideTo", -1), slide_unpitch_to=_int(n, "slideUnpitchTo", -1), bend=_float(n, "bend"), + bend_intent=_int(n, "bendIntent", 0), + bend_values=_parse_bend_values(n), hammer_on=_bool(n, "hammerOn"), pull_off=_bool(n, "pullOff"), harmonic=_bool(n, "harmonic"), diff --git a/tests/test_gp2rs.py b/tests/test_gp2rs.py index 54ef197..9d86705 100644 --- a/tests/test_gp2rs.py +++ b/tests/test_gp2rs.py @@ -20,9 +20,11 @@ import pytest from gp2rs import ( GP_TICKS_PER_QUARTER, TempoEvent, + _bend_intent_from_values, _build_playback_schedule, _compute_tuning, _extract_year, + _gp_bend_shape, _gp_string_to_rs, _is_bass_track, _standard_tuning_for, @@ -864,6 +866,80 @@ def test_tied_note_without_predecessor_is_silently_dropped(): assert len(notes) == 0 +# ── convert_track: bend shape (bn / bt / bnv, §6.2.1) ──────────────────────── + +def _ct_bend(points): + """A pyguitarpro-shaped BendEffect: points are (position 0..12, value) + pairs where value is half-quarter-tone units (12 = 6 semitones).""" + return SimpleNamespace( + points=[SimpleNamespace(position=p, value=v) for p, v in points], + ) + + +def test_bend_intent_classifier(): + assert _bend_intent_from_values([0.0, 1.0, 2.0]) == 0 # up + assert _bend_intent_from_values([2.0, 1.0, 0.0]) == 3 # pre-bend+release + assert _bend_intent_from_values([2.0, 2.0]) == 2 # pre-bend held + assert _bend_intent_from_values([2.0, 1.0]) == 1 # release (let down) + assert _bend_intent_from_values([0.0, 2.0, 0.0]) == 4 # round-trip + assert _bend_intent_from_values([]) == 0 + + +def test_gp_bend_shape_units_and_time(): + """value/2 = semitones; position/12 * duration = seconds-from-onset.""" + # 0.5 s note, up-bend 0 → value 4 (2 semitones) at the end. + peak, intent, curve = _gp_bend_shape(_ct_bend([(0, 0), (12, 4)]), 0.5) + assert peak == 2.0 + assert intent == 0 + assert curve == [{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}] + # Zero-length note collapses every point to t=0 → no usable curve. + _, _, curve0 = _gp_bend_shape(_ct_bend([(0, 0), (12, 4)]), 0.0) + assert curve0 is None + # A single point carries only the peak, no curve. + _, _, curve1 = _gp_bend_shape(_ct_bend([(6, 4)]), 0.5) + assert curve1 is None + + +def test_bent_note_imports_with_curve_through_wire(): + """A GP up-bend imports with bn (peak) + bt + bnv, and survives + convert_track XML → _parse_note → note_to_wire.""" + from song import _parse_note, note_to_wire + note = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=7) + # quarter @ 120 BPM = 0.5 s; round-trip bend 0 → 2 → 0 semitones. + note.effect.bend = _ct_bend([(0, 0), (6, 4), (12, 0)]) + beat = _ct_beat(tick=0, dur_value=4, notes=[note]) + + root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314 + xn = root.findall(".//notes/note")[0] + assert xn.get("bend") == "2.0" + assert xn.get("bendIntent") == "4" # round-trip + import json + assert json.loads(xn.get("bendValues")) == [ + {"t": 0.0, "v": 0.0}, {"t": 0.25, "v": 2.0}, {"t": 0.5, "v": 0.0}] + + wire = note_to_wire(_parse_note(xn)) + assert wire["bn"] == 2.0 + assert wire["bt"] == 4 + assert wire["bnv"] == [ + {"t": 0.0, "v": 0.0}, {"t": 0.25, "v": 2.0}, {"t": 0.5, "v": 0.0}] + + +def test_non_bent_note_has_no_curve(): + note = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=5) # bend=None + beat = _ct_beat(tick=0, dur_value=4, notes=[note]) + root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314 + xn = root.findall(".//notes/note")[0] + assert xn.get("bend") == "0" + assert xn.get("bendIntent") is None + assert xn.get("bendValues") is None + + from song import _parse_note + n = _parse_note(xn) + assert n.bend == 0.0 + assert n.bend_intent == 0 + assert n.bend_values is None + + def _ct_multivoice_song(voices_beats): """Multi-voice variant of _ct_song. `voices_beats` is a list of beat-lists, one per voice, all on the same single measure.""" diff --git a/tests/test_gp2rs_gpx.py b/tests/test_gp2rs_gpx.py index a003847..d177cb9 100644 --- a/tests/test_gp2rs_gpx.py +++ b/tests/test_gp2rs_gpx.py @@ -31,6 +31,7 @@ from gp2rs_gpx import ( _collect_tone_events, _inject_tones, _resolve_pending_slides, + _gpx_bend_shape, ) from gp2rs import RsNote @@ -55,6 +56,50 @@ def test_safe_filename_stem(name, expected): assert ".." not in out +# ── _gpx_bend_shape (bn / bt / bnv, §6.2.1) ───────────────────────────────── + +def _bend_props(**vals): + """Build a GPIF property map {name: element} for the given + bend Float values, e.g. _bend_props(BendOriginValue=0, BendMiddleValue=100).""" + tp = {} + for name, num in vals.items(): + tp[name] = ET.fromstring( + f'{num}') + return tp + + +def test_gpx_bend_shape_round_trip_curve(): + """origin/middle/destination value+offset → 3-point bnv; value/divisor=semis.""" + tp = _bend_props( + BendOriginValue=0, BendOriginOffset=0, + BendMiddleValue=100, BendMiddleOffset1=50, # 100/50 = 2 semitones + BendDestinationValue=0, BendDestinationOffset=100, + ) + peak, intent, curve = _gpx_bend_shape(tp, divisor=50.0, sustain=1.0) + assert peak == 2.0 + assert intent == 4 # round-trip (up then back down) + assert curve == [ + {"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}, {"t": 1.0, "v": 0.0}] + + +def test_gpx_bend_shape_falls_back_to_even_spacing_without_offsets(): + tp = _bend_props(BendOriginValue=0, BendDestinationValue=100) # no offsets + peak, intent, curve = _gpx_bend_shape(tp, divisor=50.0, sustain=1.0) + assert peak == 2.0 + assert intent == 0 # plain up + # origin defaults to 0%, destination to 100%. + assert curve == [{"t": 0.0, "v": 0.0}, {"t": 1.0, "v": 2.0}] + + +def test_gpx_bend_shape_no_props_and_zero_length(): + assert _gpx_bend_shape({}, divisor=50.0, sustain=1.0) == (0.0, 0, None) + # Peak + intent still derived for a zero-length note, but no curve. + peak, intent, curve = _gpx_bend_shape( + _bend_props(BendOriginValue=0, BendDestinationValue=100), + divisor=50.0, sustain=0.0) + assert peak == 2.0 and intent == 0 and curve is None + + # ── _decompress_bcfz / _parse_bcfs input guards ───────────────────────────── def test_decompress_bcfz_rejects_bad_magic(): diff --git a/tests/test_song.py b/tests/test_song.py index 73217fc..0116309 100644 --- a/tests/test_song.py +++ b/tests/test_song.py @@ -169,6 +169,85 @@ def test_note_bend_nonzero_rounded_to_one_decimal(): assert note_to_wire(n)["bn"] == 1.8 +# ── Bend shape (bt / bnv, §6.2.1) ──────────────────────────────────────────── + +def test_note_bend_shape_round_trip(): + """A note with bend intent + a time-stamped curve survives the wire.""" + n = Note( + time=0.5, string=0, fret=7, sustain=1.0, + bend=2.0, + bend_intent=4, # round-trip + bend_values=[ + {"t": 0.0, "v": 0.0}, + {"t": 0.25, "v": 2.0}, + {"t": 0.5, "v": 0.0}, + ], + ) + wire = note_to_wire(n) + assert wire["bt"] == 4 + assert wire["bnv"] == [ + {"t": 0.0, "v": 0.0}, + {"t": 0.25, "v": 2.0}, + {"t": 0.5, "v": 0.0}, + ] + assert note_from_wire(wire) == n + + +def test_note_bend_shape_omitted_when_default(): + """`bt`/`bnv` are default-omitted; absence decodes to 0 / None (not 0-present + / not []).""" + wire = note_to_wire(Note(time=0.0, string=0, fret=0, bend=1.0)) + assert "bt" not in wire + assert "bnv" not in wire + decoded = note_from_wire(wire) + assert decoded.bend_intent == 0 + assert decoded.bend_values is None + + +def test_note_bend_values_rounded_on_wire(): + """`bnv` rounds `t` to 3 and `v` to 1, matching the scalar `bn` precision.""" + n = Note( + time=0.0, string=0, fret=0, bend=1.0, bend_intent=1, + bend_values=[{"t": 0.123456, "v": 1.749}], + ) + assert note_to_wire(n)["bnv"] == [{"t": 0.123, "v": 1.7}] + + +def test_note_bend_values_sanitized_from_wire(): + """Malformed `bnv` entries are dropped; bad/empty -> None; result sorted by t.""" + # NaN / non-dict / non-numeric entries dropped, remaining sorted by t. + n = note_from_wire({ + "t": 0.0, "s": 0, "f": 0, "bn": 2.0, + "bnv": [ + {"t": 0.5, "v": 2.0}, + {"t": 0.0, "v": 0.0}, + {"t": "x", "v": 1.0}, # non-numeric t -> dropped + {"t": 0.25, "v": float("nan")}, # non-finite v -> dropped + "garbage", # non-dict -> dropped + ], + }) + assert n.bend_values == [{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}] + # Empty / non-list / all-invalid collapse to None (never []). + for bad in (None, [], "nope", [{"t": "a", "v": "b"}], [42]): + assert note_from_wire( + {"t": 0.0, "s": 0, "f": 0, "bnv": bad}).bend_values is None + + +def test_chord_note_carries_bend_shape(): + """Chord member notes inherit bt/bnv through chord_note_to_wire/chord_from_wire.""" + c = Chord( + time=2.0, chord_id=0, + notes=[Note( + time=2.0, string=1, fret=5, bend=1.0, bend_intent=2, + bend_values=[{"t": 0.0, "v": 1.0}, {"t": 0.3, "v": 0.0}], + )], + ) + decoded = chord_from_wire(chord_to_wire(c)) + cn = decoded.notes[0] + assert cn.bend_intent == 2 + assert cn.bend_values == [{"t": 0.0, "v": 1.0}, {"t": 0.3, "v": 0.0}] + + # ── Chord round-trip ───────────────────────────────────────────────────────── def test_chord_with_multiple_notes_round_trip(): From 351b273ab5a2e0af05f10813ea0276b12ecd408b Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sat, 20 Jun 2026 23:34:50 +0200 Subject: [PATCH 03/99] feat(highway): render per-note bend curve (bnv) on 2D + 3D (#532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-B of the bend-shape feature (feedpak §6.2.1). Both highways drew a bend from the scalar `bn` only; now they trace the authoritative `bnv` curve ([{t, v}]) when present and fall back to the `bn` arc/envelope otherwise. 2D (static/highway.js drawNote): when a note carries `bnv`, draw the real shape as a contour above the gem (round-trip rises then falls, pre-bend starts high, release descends — `bt` is implicit in the point shape), with an arrowhead only when the gesture ends rising. `bnvNormalizedPoints` maps {t,v} to a 0..1 x span. The scalar-arrow path is preserved unchanged as the fallback; the peak label is unchanged. 3D (plugins/highway_3d/screen.js): `bnvSampleAt` linearly interpolates the curve (clamped to its endpoints) and `bendSemisAtTime` samples it when present, else keeps the synthetic rise→hold→release envelope from `bn`. The chevron count still comes from the peak. Fixed a stale-scratch hazard: the reused `_scrChordNote` now resets `bnv`/`bt` (omit-when-default) after Object.assign, mirroring the existing `fhm` reset, so a chord note without a curve can't inherit the previous note's contour. Render-only — no wire/schema change. Pure helpers covered by tests/js/highway_bend_curve.test.js (interp, clamping, round-trip, degenerate/empty); node --check passes on both files; full tests/js green. Part of got-feedback/feedback#334 Co-authored-by: Claude Opus 4.8 (1M context) --- plugins/highway_3d/screen.js | 44 ++++++++++++++--- static/highway.js | 74 +++++++++++++++++++++------ tests/js/highway_bend_curve.test.js | 77 +++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 tests/js/highway_bend_curve.test.js diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 0bb5431..29d7b11 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -9823,6 +9823,13 @@ // so Object.assign leaves a stale `true` from a previous // muted chord note untouched. Reset it explicitly here. _scrChordNote.fhm = cn.fhm || false; + // Same stale-scratch hazard for the bend shape: + // `bnv`/`bt` are omit-when-default on the wire, so a + // chord note without them would otherwise inherit the + // previous note's curve (and bendSemisAtTime would + // apply the wrong contour). Reset explicitly. + _scrChordNote.bnv = Array.isArray(cn.bnv) ? cn.bnv : undefined; + _scrChordNote.bt = cn.bt || 0; drawNote( _scrChordNote, now, @@ -11309,15 +11316,40 @@ return visualIdx >= (nStr - 1) * 0.5 ? -1 : 1; } + function bnvSampleAt(bnv, t) { + // Linear interpolation of a bend curve [{t, v}] (§6.2.1; t is + // seconds from the note onset) at elapsed time t. Clamps to the + // endpoints; returns 0 for an empty/invalid curve. + if (!Array.isArray(bnv) || bnv.length === 0) return 0; + if (t <= bnv[0].t) return bnv[0].v; + const last = bnv[bnv.length - 1]; + if (t >= last.t) return last.v; + for (let i = 1; i < bnv.length; i++) { + const a = bnv[i - 1], b = bnv[i]; + if (t <= b.t) { + const span = b.t - a.t; + return span > 0 ? a.v + (b.v - a.v) * ((t - a.t) / span) : b.v; + } + } + return last.v; + } + function bendSemisAtTime(n, chartTime) { + if (!(n?.sus > 0)) return 0; + // When the note carries an authoritative bend curve (§6.2.1), + // sample its real shape at the elapsed time so the gem's Y gesture + // and sustain ribbon follow the actual bend (pre-bend, round-trip, + // release, …). Negative samples clamp to 0 (upward-only Y offset). + if (Array.isArray(n.bnv) && n.bnv.length) { + return Math.max(0, bnvSampleAt(n.bnv, chartTime - n.t)); + } const bn = Number(n?.bn) || 0; - if (!(bn > 0) || !(n?.sus > 0)) return 0; + if (!(bn > 0)) return 0; const p = Math.max(0, Math.min(1, (chartTime - n.t) / Math.max(n.sus, 1e-6))); - // rise → hold → release: ramp up over the first ~35 %, hold, then - // release back down over the last ~30 %. Depicts the bend gesture - // (up and back down) rather than a monotone climb that only ever - // showed the bend going up. Drives both the sustain ribbon's Y - // contour and the gem's techniqueYNow offset. + // Fallback: synthesize rise → hold → release from the scalar peak. + // Ramp up over the first ~35 %, hold, then release over the last + // ~30 % — the bend gesture rather than a monotone climb. Drives both + // the sustain ribbon's Y contour and the gem's techniqueYNow offset. const RISE = BEND_ENV_RISE_FRAC, REL = BEND_ENV_RELEASE_FRAC; let env; if (p < RISE) env = p / RISE; diff --git a/static/highway.js b/static/highway.js index 4ad74d8..4f40136 100644 --- a/static/highway.js +++ b/static/highway.js @@ -468,6 +468,16 @@ function createHighway() { return w / 2 - hw + margin + t * usable; } + /** Map a bend curve [{t, v}] (§6.2.1) to [{x, v}] with x normalized to + * 0..1 across the curve's time span (0 when the span is degenerate). + * Pure — drives the 2D bend-shape glyph. */ + function bnvNormalizedPoints(bnv) { + if (!Array.isArray(bnv) || bnv.length === 0) return []; + const t0 = bnv[0].t; + const span = bnv[bnv.length - 1].t - t0; + return bnv.map(p => ({ x: span > 0 ? (p.t - t0) / span : 0, v: p.v })); + } + /** Call while lefty mirror transform is active; keeps glyphs readable. */ function fillTextReadable(text, x, y) { // ctx may be null when the 2D context was never acquired @@ -1601,27 +1611,59 @@ function createHighway() { // Bend notation if (bend && bend > 0 && sz >= 12) { const lw = Math.max(2, sz / 10); - const arrowH = sz * 0.55 * Math.min(bend, 2); // taller for bigger bends const ay = y - half - 4; - const tipY = ay - arrowH; + // px above the gem for a bend of `v` semitones (shared by the + // curve contour and the scalar-arrow fallback). + const hOf = (v) => sz * 0.55 * Math.min(Math.max(v, 0), 2); + const bnv = Array.isArray(opts?.bnv) ? opts.bnv : null; ctx.strokeStyle = '#fff'; ctx.lineWidth = lw; - // Curved arrow - ctx.beginPath(); - ctx.moveTo(x, ay); - ctx.quadraticCurveTo(x + sz * 0.2, ay - arrowH * 0.5, x, tipY); - ctx.stroke(); + let labelTopY; // y of the highest drawn point, for the label + if (bnv && bnv.length >= 2) { + // Bend curve (§6.2.1): trace the real shape as a contour above + // the gem (round-trip rises then falls, pre-bend starts high, + // release descends, …) — `bt` is implicit in the point shape. + const pts = bnvNormalizedPoints(bnv); + const gw = sz * 0.6; + const x0 = x - gw / 2; + ctx.beginPath(); + pts.forEach((pt, i) => { + const px = x0 + pt.x * gw; + const py = ay - hOf(pt.v); + if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); + }); + ctx.stroke(); + // Arrowhead only when the gesture ends rising (plain bend / + // pre-bend); round-trip and release finish heading down. + const a = pts[pts.length - 2], b = pts[pts.length - 1]; + if (b.v > a.v + 0.05) { + const tipX = x0 + b.x * gw, tipY = ay - hOf(b.v); + ctx.beginPath(); + ctx.moveTo(tipX - sz * 0.1, tipY + sz * 0.12); + ctx.lineTo(tipX, tipY); + ctx.lineTo(tipX + sz * 0.1, tipY + sz * 0.12); + ctx.stroke(); + } + labelTopY = ay - hOf(Math.max(...pts.map(p => p.v))); + } else { + // Fallback: single curved arrow up to the scalar peak. + const arrowH = hOf(bend); // taller for bigger bends + const tipY = ay - arrowH; + ctx.beginPath(); + ctx.moveTo(x, ay); + ctx.quadraticCurveTo(x + sz * 0.2, ay - arrowH * 0.5, x, tipY); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x - sz * 0.12, tipY + sz * 0.12); + ctx.lineTo(x, tipY); + ctx.lineTo(x + sz * 0.12, tipY + sz * 0.12); + ctx.stroke(); + labelTopY = tipY; + } - // Arrowhead - ctx.beginPath(); - ctx.moveTo(x - sz * 0.12, tipY + sz * 0.12); - ctx.lineTo(x, tipY); - ctx.lineTo(x + sz * 0.12, tipY + sz * 0.12); - ctx.stroke(); - - // Bend label: "full", "1/2", "1 1/2", "2" + // Bend label: peak magnitude — "full", "1/2", "1 1/2", "2" let label; if (bend === 0.5) label = '½'; else if (bend === 1) label = 'full'; @@ -1633,7 +1675,7 @@ function createHighway() { ctx.font = `bold ${Math.max(9, sz * 0.28) | 0}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; - fillTextReadable(label, x, tipY - 2); + fillTextReadable(label, x, labelTopY - 2); } if (sz < 14) return; // Skip small technique labels diff --git a/tests/js/highway_bend_curve.test.js b/tests/js/highway_bend_curve.test.js new file mode 100644 index 0000000..a20e164 --- /dev/null +++ b/tests/js/highway_bend_curve.test.js @@ -0,0 +1,77 @@ +// Behavioural tests for the per-note bend-curve (bnv, §6.2.1) render helpers: +// `bnvNormalizedPoints` (static/highway.js, 2D glyph) and `bnvSampleAt` +// (plugins/highway_3d/screen.js, 3D Y gesture). Both are pure, so we extract +// the function source by brace-matching and eval it in isolation. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +function extractFn(src, name) { + const start = src.indexOf('function ' + name); + assert.ok(start >= 0, `function ${name} must exist`); + const open = src.indexOf('{', start); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1); + } + throw new Error(`unbalanced braces extracting ${name}`); +} + +function loadFn(file, name) { + const src = fs.readFileSync(path.join(__dirname, '..', '..', file), 'utf8'); + return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)(); +} + +const bnvNormalizedPoints = loadFn('static/highway.js', 'bnvNormalizedPoints'); +const bnvSampleAt = loadFn('plugins/highway_3d/screen.js', 'bnvSampleAt'); + +// ── bnvNormalizedPoints (2D) ───────────────────────────────────────────────── + +test('bnvNormalizedPoints normalizes t to 0..1 across the span', () => { + const pts = bnvNormalizedPoints([ + { t: 0.5, v: 0 }, { t: 1.0, v: 2 }, { t: 1.5, v: 0 }]); + assert.deepEqual(pts, [ + { x: 0, v: 0 }, { x: 0.5, v: 2 }, { x: 1, v: 0 }]); +}); + +test('bnvNormalizedPoints handles degenerate/empty input', () => { + assert.deepEqual(bnvNormalizedPoints([]), []); + assert.deepEqual(bnvNormalizedPoints(null), []); + // All-same-t span collapses x to 0 (no divide-by-zero). + assert.deepEqual(bnvNormalizedPoints([{ t: 1, v: 1 }, { t: 1, v: 2 }]), + [{ x: 0, v: 1 }, { x: 0, v: 2 }]); +}); + +// ── bnvSampleAt (3D) ───────────────────────────────────────────────────────── + +test('bnvSampleAt linearly interpolates between points', () => { + const bnv = [{ t: 0, v: 0 }, { t: 1, v: 2 }]; + assert.equal(bnvSampleAt(bnv, 0.5), 1); // midpoint + assert.equal(bnvSampleAt(bnv, 0.25), 0.5); +}); + +test('bnvSampleAt clamps to the endpoints', () => { + const bnv = [{ t: 0.2, v: 1 }, { t: 0.8, v: 3 }]; + assert.equal(bnvSampleAt(bnv, 0), 1); // before first + assert.equal(bnvSampleAt(bnv, 5), 3); // after last +}); + +test('bnvSampleAt traces a round-trip curve up then back down', () => { + const bnv = [{ t: 0, v: 0 }, { t: 0.5, v: 2 }, { t: 1, v: 0 }]; + assert.equal(bnvSampleAt(bnv, 0.25), 1); // rising + assert.equal(bnvSampleAt(bnv, 0.5), 2); // peak + assert.equal(bnvSampleAt(bnv, 0.75), 1); // falling +}); + +test('bnvSampleAt returns 0 for an empty/invalid curve', () => { + assert.equal(bnvSampleAt([], 0.5), 0); + assert.equal(bnvSampleAt(null, 0.5), 0); +}); + +test('bnvSampleAt tolerates a zero-width segment (duplicate t)', () => { + const bnv = [{ t: 0, v: 0 }, { t: 0.5, v: 1 }, { t: 0.5, v: 2 }, { t: 1, v: 2 }]; + assert.equal(bnvSampleAt(bnv, 0.5), 1); // first matching segment wins +}); From a858617d711e4e02b582dbdeaf88d9da47fdaa9a Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sun, 21 Jun 2026 00:46:36 +0200 Subject: [PATCH 04/99] fix(bend): GP8 short-bend curve loss + 2D curve timing + 3D bnv gating (#535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge Codex review of the bend-curve PRs (#531/#532) surfaced edge cases: - GP8 (#531 P2): bnv timing used rn.sustain, which is zeroed for notes <= 0.2s, so short GP8 bends kept the scalar bn but lost bt/bnv. Use the beat duration `dur` (matching the GP5 path) so the curve survives. - 2D highway (#532 P2): bnvNormalizedPoints mapped x over the curve's own t-range [first,last] instead of the note span, so curves not starting at 0 / ending at sus were time-distorted. Now maps over [0, sus] (clamped), with a curve-span fallback when sus<=0 (existing no-sus callers unaffected). - 3D highway (#532 P3): the sustain ribbon + bend chevron were gated on bn>0, so a note carrying an authoritative bnv with bn==0 drew no ribbon/marker. Both now also fire on bnv presence; chevron steps derived from max(bn, bnv peak). Codex-reviewed: clean (no findings). +1 JS test (sus-relative mapping + fallback). JS 8/8, 250 core GP/song tests pass. NB: GP8's short-bend path still lacks a dedicated synthetic-GPIF fixture (same gap as the GP8 offset-prop-names P3) — _gpx_bend_shape units cover the function; the fix is the one-line caller change. Part of got-feedback/feedback#334. Co-authored-by: Claude Opus 4.8 (1M context) --- lib/gp2rs_gpx.py | 7 ++++++- plugins/highway_3d/screen.js | 11 +++++++++-- static/highway.js | 11 +++++++++-- tests/js/highway_bend_curve.test.js | 15 ++++++++++++++- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/lib/gp2rs_gpx.py b/lib/gp2rs_gpx.py index ecd6a93..a161aea 100644 --- a/lib/gp2rs_gpx.py +++ b/lib/gp2rs_gpx.py @@ -1621,8 +1621,13 @@ def convert_file( # the shape over time (§6.2.1). value/divisor # = semitones (scale auto-detected per file). if 'Bended' in _tp: + # Use the beat duration `dur`, not + # `rn.sustain` (zeroed for notes <= 0.2s), + # so short bends keep their bnv curve — + # matching the GP5 path, which maps over + # the raw note duration. _peak, _intent, _curve = _gpx_bend_shape( - _tp, _bend_divisor, rn.sustain) + _tp, _bend_divisor, dur) if _peak > 0: rn.bend = _peak rn.bend_intent = _intent diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 29d7b11..99a23b1 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -11949,6 +11949,7 @@ const ribbonSusTrail = !!( (slideSt && n.f > 0 && (n.sus || 0) > 1e-4) || (Number(n.bn) > 0) + || (Array.isArray(n.bnv) && n.bnv.length > 0) || n.tr || hasTechniqueVibrato ); @@ -12119,11 +12120,17 @@ arrow.material.opacity = 1; } } - if (n.bn > 0) { + // Derive the peak from bn OR the bnv curve: a note may carry an + // authoritative curve with bn left at 0 (bn SHOULD be the peak + // whenever bnv exists — this is the robustness fallback). + const _bnvPeak = (Array.isArray(n.bnv) && n.bnv.length) + ? n.bnv.reduce((m, p) => Math.max(m, Number(p.v) || 0), 0) : 0; + const _bendPeak = Math.max(Number(n.bn) || 0, _bnvPeak); + if (_bendPeak > 0) { // Bend chevron stack — PlaneGeometry mesh so it tilts with // the gem (approachRot). Fixed world size so it perspective- // shrinks naturally without distFactor compensation. - const steps = Math.max(1, Math.min(4, Math.round(n.bn))); + const steps = Math.max(1, Math.min(4, Math.round(_bendPeak))); const bendSm = bendChevronMat(steps, activePalette[s] || 0xffffff); const l = pTechPlane.get(); l.material = _spriteMat2MeshMat(l, bendSm); diff --git a/static/highway.js b/static/highway.js index 4f40136..fd8871a 100644 --- a/static/highway.js +++ b/static/highway.js @@ -471,8 +471,15 @@ function createHighway() { /** Map a bend curve [{t, v}] (§6.2.1) to [{x, v}] with x normalized to * 0..1 across the curve's time span (0 when the span is degenerate). * Pure — drives the 2D bend-shape glyph. */ - function bnvNormalizedPoints(bnv) { + function bnvNormalizedPoints(bnv, sus) { if (!Array.isArray(bnv) || bnv.length === 0) return []; + // Map each point's time over the NOTE's span [0, sus] so it sits at its + // real fraction of the note (a bend that completes before the note ends + // draws short of the glyph's right edge). Fall back to the curve's own + // t-range only when the note has no usable sustain. + if (Number.isFinite(sus) && sus > 0) { + return bnv.map(p => ({ x: Math.min(Math.max(p.t / sus, 0), 1), v: p.v })); + } const t0 = bnv[0].t; const span = bnv[bnv.length - 1].t - t0; return bnv.map(p => ({ x: span > 0 ? (p.t - t0) / span : 0, v: p.v })); @@ -1625,7 +1632,7 @@ function createHighway() { // Bend curve (§6.2.1): trace the real shape as a contour above // the gem (round-trip rises then falls, pre-bend starts high, // release descends, …) — `bt` is implicit in the point shape. - const pts = bnvNormalizedPoints(bnv); + const pts = bnvNormalizedPoints(bnv, opts?.sus); const gw = sz * 0.6; const x0 = x - gw / 2; ctx.beginPath(); diff --git a/tests/js/highway_bend_curve.test.js b/tests/js/highway_bend_curve.test.js index a20e164..5b051e9 100644 --- a/tests/js/highway_bend_curve.test.js +++ b/tests/js/highway_bend_curve.test.js @@ -30,13 +30,26 @@ const bnvSampleAt = loadFn('plugins/highway_3d/screen.js', 'bnvSampleAt'); // ── bnvNormalizedPoints (2D) ───────────────────────────────────────────────── -test('bnvNormalizedPoints normalizes t to 0..1 across the span', () => { +test('bnvNormalizedPoints normalizes t to 0..1 across the curve span (no sus)', () => { const pts = bnvNormalizedPoints([ { t: 0.5, v: 0 }, { t: 1.0, v: 2 }, { t: 1.5, v: 0 }]); assert.deepEqual(pts, [ { x: 0, v: 0 }, { x: 0.5, v: 2 }, { x: 1, v: 0 }]); }); +test('bnvNormalizedPoints maps t over the note sus span when given', () => { + // A bend that completes at t=0.4 of a 0.5s note draws to x=0.8, not x=1 — + // i.e. it stops short of the glyph's right edge (correct timing shape). + assert.deepEqual( + bnvNormalizedPoints([{ t: 0, v: 0 }, { t: 0.25, v: 1 }, { t: 0.4, v: 0 }], 0.5), + [{ x: 0, v: 0 }, { x: 0.5, v: 1 }, { x: 0.8, v: 0 }]); + // Points beyond sus clamp to 1; sus<=0 falls back to curve-span mapping. + assert.deepEqual(bnvNormalizedPoints([{ t: 0, v: 0 }, { t: 1, v: 2 }], 0.5), + [{ x: 0, v: 0 }, { x: 1, v: 2 }]); + assert.deepEqual(bnvNormalizedPoints([{ t: 0, v: 0 }, { t: 1, v: 2 }], 0), + [{ x: 0, v: 0 }, { x: 1, v: 2 }]); +}); + test('bnvNormalizedPoints handles degenerate/empty input', () => { assert.deepEqual(bnvNormalizedPoints([]), []); assert.deepEqual(bnvNormalizedPoints(null), []); From 6ee5da3d8bbebf59abef32e95ad30594d78db694 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sun, 21 Jun 2026 07:57:50 +0200 Subject: [PATCH 05/99] =?UTF-8?q?feat(core):=20teaching=20marks=20fg/ch/sd?= =?UTF-8?q?=20=E2=80=94=20wire=20+=20GP=20import=20+=20sd=20derivation=20(?= =?UTF-8?q?=C2=A76.2.2)=20(#536)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the three OPTIONAL per-note feedpak 1.5.0 teaching marks — fg (fret-hand finger), ch (strum-group key), sd (scale degree) — to the Note model and wire format, mirroring the bend-shape work (#531). These are DISPLAY/TEACHING ONLY: nothing in the scoring / note-verification path reads them. - lib/song.py: Note.fret_finger / strum_group / scale_degree, default-omitted on the wire (fg/ch/sd) and decoded via _wire_int_optional; _parse_note reads the GP-written fretFinger XML attr. Pure helpers key_to_tonic_pc (§7.7 key name -> tonic pitch class) + scale_degree_for_pitch, plus base_open_string_midis / pitch_from_base / note_pitch_midi (tuning offsets + capo + fret -> MIDI, mirroring app.js _TUNING_BASE_MIDI). - lib/gp2rs.py: GP5 note.effect.leftHandFinger -> fg (RsNote field + fretFinger XML attr), reusing the chord Fingering value convention. - lib/gp2rs_gpx.py: GP8/GPIF per-note (p-i-m-a-c letter codes, verified against real GP8 exports) -> fg. - server.py highway_ws: derive sd for notes + chord notes from the active keys.json key + sounding pitch when the author didn't author one (author value wins); base hoisted out of the per-note loop. Tests: round-trip + omit-when-default + malformed-tolerance for fg/ch/sd; key_to_tonic_pc + scale_degree_for_pitch + note_pitch_midi (standard/drop-D/ capo/bass) units; GP5 leftHandFinger and GP8 import. Part of got-feedback/feedback#334 Co-authored-by: Claude Opus 4.8 (1M context) --- lib/gp2rs.py | 33 +++++++++++ lib/gp2rs_gpx.py | 30 ++++++++++ lib/song.py | 123 ++++++++++++++++++++++++++++++++++++++++ server.py | 54 +++++++++++++++++- tests/test_gp2rs.py | 27 +++++++++ tests/test_gp2rs_gpx.py | 27 +++++++++ tests/test_song.py | 107 ++++++++++++++++++++++++++++++++++ 7 files changed, 399 insertions(+), 2 deletions(-) diff --git a/lib/gp2rs.py b/lib/gp2rs.py index 9b233e9..b5fbcb4 100644 --- a/lib/gp2rs.py +++ b/lib/gp2rs.py @@ -72,6 +72,9 @@ class RsNote: tremolo: bool = False tap: bool = False link_next: bool = False + # Teaching mark (§6.2.2): fret-hand finger (-1 unset, 0 thumb..4 pinky). + # Display only — never used for grading. + fret_finger: int = -1 @dataclass @@ -255,6 +258,16 @@ def _bend_shape_xml_attrs(n: "RsNote") -> dict: return attrs +def _finger_xml_attrs(n: "RsNote") -> dict: + """Optional teaching-mark XML attribute for a /: `fretFinger` + only when set (!= -1). `_parse_note` (lib/song.py) reads it back so a + GP-imported fret-hand finger survives import → wire → highway. Display only; + never used for grading (§6.2.2).""" + if getattr(n, "fret_finger", -1) != -1: + return {"fretFinger": str(int(n.fret_finger))} + return {} + + def _tempo_at_tick(tick: int, tempo_map: list[TempoEvent]) -> float: """Get the tempo at a given tick.""" result = tempo_map[0].tempo @@ -524,6 +537,19 @@ def _gp_string_to_rs(gp_string: int, num_strings: int) -> int: return num_strings - gp_string +def _gp_finger_to_rs(fingering) -> int: + """Coerce a pyguitarpro ``Fingering`` enum to an RS fret-hand finger int. + + Fingering values are ``unknown=-2, open=-1, thumb=0, index=1, middle=2, + annular=3, little=4`` — already the RS finger integers for 0..4. Anything + open/unknown/out-of-range collapses to ``-1`` (unset), so we never invent a + finger. Teaching mark only (§6.2.2); never used for grading.""" + val = getattr(fingering, "value", fingering) + if not isinstance(val, int) or val < 0 or val > 4: + return -1 + return val + + def _chord_fingers(chord, frets: list[int], num_strings: int) -> list[int]: """Per-string fingering for a chord template, in RS string order. @@ -853,6 +879,11 @@ def convert_track( if eff.tremoloPicking: rn.tremolo = True + # Fret-hand fingering -> fg teaching mark (§6.2.2). Same + # Fingering enum + value convention as the chord path. + rn.fret_finger = _gp_finger_to_rs( + getattr(eff, "leftHandFinger", None)) + # Whammy / tremolo bar (beat-level dive/raise). RS has no # whammy attribute, so approximate the pitch movement as an # unpitched slide: a dive slides down, a raise slides up, by @@ -1164,6 +1195,7 @@ def _build_xml( "ignore": "0", } attrs.update(_bend_shape_xml_attrs(n)) + attrs.update(_finger_xml_attrs(n)) ET.SubElement(notes_el, "note", **attrs) # Chords @@ -1196,6 +1228,7 @@ def _build_xml( "ignore": "0", } cn_attrs.update(_bend_shape_xml_attrs(cn)) + cn_attrs.update(_finger_xml_attrs(cn)) ET.SubElement(chord_el, "chordNote", **cn_attrs) # Anchors diff --git a/lib/gp2rs_gpx.py b/lib/gp2rs_gpx.py index a161aea..cc41020 100644 --- a/lib/gp2rs_gpx.py +++ b/lib/gp2rs_gpx.py @@ -457,6 +457,31 @@ _GPIF_FINGER_MAP = { 'pinky': 4, 'little': 4, } +# Per-note teaching mark (§6.2.2). Unlike the chord-diagram +# path above, GPIF stores a single note's fret-hand +# finger as a direct child element with the classical p-i-m-a-c letter +# codes (verified against GP8 exports), mapped to the same RS finger integers +# (open = -1, thumb = 0, index = 1, middle = 2, annular/ring = 3, little = 4). +_GPIF_LEFT_FINGERING_MAP = { + 'open': -1, 'none': -1, '': -1, + 'p': 0, 'thumb': 0, + 'i': 1, 'index': 1, + 'm': 2, 'middle': 2, + 'a': 3, 'annular': 3, 'ring': 3, + 'c': 4, 'little': 4, 'pinky': 4, +} + + +def _gpif_left_fingering(note_el) -> int: + """Read a GPIF 's fret-hand finger () -> RS finger int. + + Returns -1 (unset) when absent or unrecognised — never fabricates a finger. + Teaching mark only (§6.2.2); never used for grading.""" + raw = (note_el.findtext('LeftFingering') or '').strip().lower() + if not raw: + return -1 + return _GPIF_LEFT_FINGERING_MAP.get(raw, -1) + def _rs_string_order(string_pitches: list[int]) -> dict[int, int]: """Map each GPIF string index → RS string index (0 = lowest pitch). @@ -1600,6 +1625,11 @@ def convert_file( rn.vibrato = True if 'LeftHandTapping' in _tp or 'Tapped' in _tp: rn.tap = True + # Fret-hand fingering -> fg teaching mark + # (§6.2.2). is a direct + # child, not a , so read it off + # note_el rather than the property map. + rn.fret_finger = _gpif_left_fingering(note_el) if 'HarmonicType' in _tp: _ht = (_tp['HarmonicType'].findtext('HType') or '').strip().lower() diff --git a/lib/song.py b/lib/song.py index 779c743..284c194 100644 --- a/lib/song.py +++ b/lib/song.py @@ -43,6 +43,18 @@ class Note: slap: bool = False right_hand: int = -1 pick_direction: int = -1 + # Teaching marks (§6.2.2, feedpak 1.5.0) — display/teaching only; a grader + # MUST NEVER use these to judge whether a note was played correctly. + # `fret_finger` is the fret-hand finger (-1 unset, 0 thumb, 1..4 + # index/middle/ring/pinky — same convention as a chord template's fingers); + # `strum_group` is a strum/rake key (>= -1, default -1; notes sharing a value + # >= 0 are one gesture, with `pick_direction` giving its direction); + # `scale_degree` is the note's pitch class as a chromatic offset 0..11 above + # the active key's tonic (default -1, MAY be derived from keys.json). All + # three default-omitted on the wire; older readers ignore them. + fret_finger: int = -1 + strum_group: int = -1 + scale_degree: int = -1 ignore: bool = False @@ -238,6 +250,13 @@ def note_to_wire(n: Note) -> dict: {"t": round(p["t"], 3), "v": round(p["v"], 1)} for p in n.bend_values ] + # Teaching marks (§6.2.2) — default-omitted, mirroring rh/pkd above. + if n.fret_finger != -1: + out["fg"] = n.fret_finger + if n.strum_group != -1: + out["ch"] = n.strum_group + if n.scale_degree != -1: + out["sd"] = n.scale_degree return out @@ -327,6 +346,102 @@ def _sanitize_bend_curve(raw): return out +# Natural-note letter -> pitch class (0 = C). Used to parse a keys.json key +# name's tonic for scale-degree derivation (§6.2.2 / §7.7). +_KEY_LETTER_PC = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11} + + +def key_to_tonic_pc(key) -> int | None: + """Parse a keys.json key name (§7.7) to its tonic pitch class 0..11. + + Reads only the leading note letter plus optional accidentals — e.g. ``"E"``, + ``"Em"``, ``"A#m"``, ``"Bb"``, ``"F#"`` -> 4, 4, 10, 10, 6. The mode/quality + suffix (``m``/``maj``/``min``/scale name) is irrelevant to the tonic and is + ignored. Returns ``None`` for anything not starting with a valid note letter, + so callers can leave ``sd`` unset rather than guess. Used only for teaching + marks; never for grading.""" + if not isinstance(key, str): + return None + s = key.strip() + if not s: + return None + pc = _KEY_LETTER_PC.get(s[0].upper()) + if pc is None: + return None + # Consume any run of accidentals directly after the letter (``#``/``b``/ + # unicode ♯/♭); stop at the first non-accidental (start of the mode suffix). + for ch in s[1:]: + if ch in ("#", "♯"): + pc += 1 + elif ch in ("b", "♭"): + pc -= 1 + else: + break + return pc % 12 + + +def scale_degree_for_pitch(midi_pitch: int, tonic_pc: int) -> int: + """Chromatic scale degree 0..11 of ``midi_pitch`` above tonic ``tonic_pc`` + (§6.2.2): the pitch class distance in semitones, 0 = tonic, 7 = fifth. + Display/teaching only — MUST NEVER feed a grader.""" + return (int(midi_pitch) - int(tonic_pc)) % 12 + + +# Open-string base MIDI per string count, index 0 = lowest string. Mirrors +# app.js `_TUNING_BASE_MIDI` / highway_3d `_baseOpenStringMidis` so a derived +# scale degree agrees with the tuner + open-string labels. `arr.tuning` carries +# per-string OFFSETS from standard (not absolute pitch), so the sounding open +# pitch is `base + offset (+ capo)` — see `note_pitch_midi`. +_TUNING_BASE_MIDI = { + 4: [28, 33, 38, 43], + 5: [23, 28, 33, 38, 43], + 6: [40, 45, 50, 55, 59, 64], + 7: [35, 40, 45, 50, 55, 59, 64], + 8: [30, 35, 40, 45, 50, 55, 59, 64], +} + + +def base_open_string_midis(string_count: int, is_bass: bool) -> list[int]: + """Standard open-string base MIDI list for an arrangement, index 0 = lowest. + + Mirrors app.js `_tuningOffsetsToFreqs`: a 4/5-string *bass* uses its own low + base, while a 4/5-string non-bass (a guitar voicing) borrows the low strings + of the 6-string base; 6/7/8 use their own. Unknown counts fall back to the + 6-string base.""" + n = int(string_count) + if n in (4, 5): + return _TUNING_BASE_MIDI[n] if is_bass else _TUNING_BASE_MIDI[6] + return _TUNING_BASE_MIDI.get(n, _TUNING_BASE_MIDI[6]) + + +def pitch_from_base(base: list[int], capo: int, tuning: list[int], + string: int, fret: int) -> int | None: + """Absolute sounding MIDI for one string+fret, given a precomputed open-string + ``base`` (from :func:`base_open_string_midis`) and the arrangement's tuning + OFFSETS + capo. None when ``string`` has no tuning entry. Single source of the + pitch formula so the per-note hot path can hoist ``base`` out of the loop.""" + if not (0 <= string < len(tuning)) or not base: + return None + root = base[string] if string < len(base) else base[-1] + return root + int(tuning[string]) + int(capo) + int(fret) + + +def note_pitch_midi(arr: "Arrangement", note: "Note") -> int | None: + """Absolute sounding MIDI pitch of ``note`` on arrangement ``arr``, or None + when its string index has no tuning entry. + + Pitch = standard base for the string + the arrangement's per-string tuning + OFFSET + capo + fret, matching the client's open-string/tuner math. Used to + derive the ``sd`` teaching mark (§6.2.2); display only, never grading. + O(notes) via ``arrangement_string_count`` — for a whole arrangement, hoist + the base with :func:`base_open_string_midis` and call :func:`pitch_from_base` + per note instead.""" + is_bass = "bass" in (arr.name or "").lower() + base = base_open_string_midis(arrangement_string_count(arr), is_bass) + return pitch_from_base(base, int(getattr(arr, "capo", 0) or 0), + arr.tuning or [], note.string, note.fret) + + def note_from_wire(d: dict, time: float | None = None) -> Note: return Note( time=float(d.get("t", time if time is not None else 0.0)), @@ -356,6 +471,10 @@ def note_from_wire(d: dict, time: float | None = None) -> Note: # the XML side's `_int_optional`. right_hand=_wire_int_optional(d.get("rh"), -1), pick_direction=_wire_int_optional(d.get("pkd"), -1), + # Teaching marks (§6.2.2) — display only, never used for grading. + fret_finger=_wire_int_optional(d.get("fg"), -1), + strum_group=_wire_int_optional(d.get("ch"), -1), + scale_degree=_wire_int_optional(d.get("sd"), -1), ignore=bool(d.get("ig", False)), ) @@ -853,6 +972,10 @@ def _parse_note(n) -> Note: slap=_bool(n, "slap"), right_hand=_int_optional(n, "rightHand", -1), pick_direction=_int_optional(n, "pickDirection", -1), + # Teaching mark (§6.2.2): GP import writes `fretFinger`; strum_group / + # scale_degree are authored downstream (editor / derived), not in chart + # XML, so they have no attribute to read here. + fret_finger=_int_optional(n, "fretFinger", -1), ignore=_bool(n, "ignore"), ) diff --git a/server.py b/server.py index 6d36a95..d387986 100644 --- a/server.py +++ b/server.py @@ -1,6 +1,7 @@ """Slopsmith — FastAPI backend serving highway viewer + library.""" import asyncio +import bisect import hashlib import json import logging @@ -27,13 +28,17 @@ from safepath import safe_join from song import ( anchor_to_wire, arrangement_string_count, + base_open_string_midis, compute_smart_names, chord_template_to_wire, chord_to_wire, hand_shape_to_wire, + key_to_tonic_pc, load_song, note_to_wire, phrase_to_wire, + pitch_from_base, + scale_degree_for_pitch, ) from audio import find_wem_files, convert_wem from tunings import tuning_name, DEFAULT_TUNINGS, DEFAULT_REFERENCE_PITCH, apply_reference_pitch @@ -7362,8 +7367,48 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, "data": [], }) + # Teaching mark sd (§6.2.2): derive each note's scale degree from the + # active key (keys.json §7.7) + its sounding pitch (tuning[string] + + # fret), only when the author didn't author one. Display/teaching only — + # NEVER feeds grading. Notes whose string/fret has no tuning entry, or + # that have no active key, or whose key name is unparseable, stay unset. + _key_events = ( + (loaded_slop.keys.get("events") or []) + if (is_slop and loaded_slop is not None and loaded_slop.keys is not None) + else [] + ) + _key_times = [e["t"] for e in _key_events] + _key_tonics = [key_to_tonic_pc(e.get("key")) for e in _key_events] + _tuning = arr.tuning or [] + # Hoist the open-string base out of the per-note loop: arr.tuning holds + # per-string OFFSETS from standard, so the sounding pitch is + # base[string] + offset + capo + fret (matches the tuner / open-string + # labels). arrangement_string_count is O(notes), so compute once here. + _base = base_open_string_midis( + arrangement_string_count(arr), "bass" in (arr.name or "").lower()) + _capo = int(getattr(arr, "capo", 0) or 0) + + def _fill_scale_degree(wire: dict, n, t: float) -> None: + # Author-provided sd wins — note_to_wire already emitted it. + if "sd" in wire or not _key_times: + return + idx = bisect.bisect_right(_key_times, t) - 1 + if idx < 0: + return + tonic = _key_tonics[idx] + if tonic is None: + return + midi = pitch_from_base(_base, _capo, _tuning, n.string, n.fret) + if midi is None: + return + wire["sd"] = scale_degree_for_pitch(midi, tonic) + # Send notes in chunks - notes = [note_to_wire(n) for n in arr.notes] + notes = [] + for n in arr.notes: + w = note_to_wire(n) + _fill_scale_degree(w, n, n.time) + notes.append(w) # Send in chunks of 500 for i in range(0, len(notes), 500): await websocket.send_json({ @@ -7373,7 +7418,12 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, }) # Send chords - chords = [chord_to_wire(c) for c in arr.chords] + chords = [] + for c in arr.chords: + cw = chord_to_wire(c) + for cn, cnw in zip(c.notes, cw.get("notes", [])): + _fill_scale_degree(cnw, cn, c.time) + chords.append(cw) for i in range(0, len(chords), 500): await websocket.send_json({ "type": "chords", diff --git a/tests/test_gp2rs.py b/tests/test_gp2rs.py index 9d86705..0fd5c60 100644 --- a/tests/test_gp2rs.py +++ b/tests/test_gp2rs.py @@ -1220,6 +1220,33 @@ def test_chord_diagram_fingers_extracted(): assert [ct.get(f"finger{i}") for i in range(0, 4)] == ["-1"] * 4 +def test_single_note_left_hand_finger_imports_as_fg(): + """A GP single note's leftHandFinger imports as the `fg` teaching mark and + survives convert_track XML → _parse_note → note_to_wire (§6.2.2).""" + from song import _parse_note, note_to_wire + note = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=5) + note.effect.leftHandFinger = guitarpro.Fingering.middle # -> 2 + beat = _ct_beat(tick=0, dur_value=4, notes=[note]) + + root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314 + xn = root.findall(".//notes/note")[0] + assert xn.get("fretFinger") == "2" + assert note_to_wire(_parse_note(xn))["fg"] == 2 + + +def test_single_note_open_finger_omits_fg(): + """Open/unset leftHandFinger leaves fg unset — no fabricated finger.""" + from song import _parse_note, note_to_wire + note = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=5) + note.effect.leftHandFinger = guitarpro.Fingering.open # -1 -> unset + beat = _ct_beat(tick=0, dur_value=4, notes=[note]) + + root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314 + xn = root.findall(".//notes/note")[0] + assert xn.get("fretFinger") is None + assert "fg" not in note_to_wire(_parse_note(xn)) + + def test_chord_without_diagram_has_blank_fingers(): # A plain two-note chord (effect.chord is None) is unchanged: blank name, # all-(-1) fingers — no regression for diagram-less charts. diff --git a/tests/test_gp2rs_gpx.py b/tests/test_gp2rs_gpx.py index d177cb9..4a8ae15 100644 --- a/tests/test_gp2rs_gpx.py +++ b/tests/test_gp2rs_gpx.py @@ -32,6 +32,7 @@ from gp2rs_gpx import ( _inject_tones, _resolve_pending_slides, _gpx_bend_shape, + _gpif_left_fingering, ) from gp2rs import RsNote @@ -731,6 +732,32 @@ def test_note_vibrato_ignores_whammy_trembar_property(): assert _note_has_vibrato(n, tp) is False +# ── _gpif_left_fingering (GP7/GP8 per-note fret-hand finger -> fg) ─────────── +# GPIF stores a single note's fret-hand finger as a direct +# child of (NOT a ), with classical p-i-m-a-c letter codes — +# verified against real GP8 exports (Open / I / M observed). Maps to the same +# RS finger integers as the chord-diagram path (§6.2.2). Teaching mark only. + +@pytest.mark.parametrize("code, expected", [ + ("Open", -1), ("P", 0), ("I", 1), ("M", 2), ("A", 3), ("C", 4), + ("i", 1), ("m", 2), # case-insensitive + ("index", 1), ("ring", 3), # word forms also accepted +]) +def test_gpif_left_fingering_letter_codes(code, expected): + n = ET.fromstring(f'{code}' + '') + assert _gpif_left_fingering(n) == expected + + +def test_gpif_left_fingering_absent_or_unknown_is_unset(): + # No child, or an unrecognised value -> -1 (never fabricate). + assert _gpif_left_fingering(ET.fromstring('')) == -1 + assert _gpif_left_fingering( + ET.fromstring('Z')) == -1 + assert _gpif_left_fingering( + ET.fromstring('')) == -1 + + # ── convert_file: GP8 chord-diagram name + fingering extraction (E3) ───────── # GP7/GP8 GPIF carries authored chord diagrams under a track's # Property[@name="DiagramCollection"]. Each Item gives the chord name and a diff --git a/tests/test_song.py b/tests/test_song.py index 0116309..655dfe6 100644 --- a/tests/test_song.py +++ b/tests/test_song.py @@ -20,10 +20,14 @@ from song import ( chord_to_wire, sanitize_tempos, compute_smart_names, + base_open_string_midis, + key_to_tonic_pc, note_from_wire, note_to_wire, + note_pitch_midi, phrase_from_wire, phrase_to_wire, + scale_degree_for_pitch, ) @@ -204,6 +208,109 @@ def test_note_bend_shape_omitted_when_default(): assert decoded.bend_values is None +# ── Teaching marks (§6.2.2) ────────────────────────────────────────────────── + +def test_note_teaching_marks_round_trip(): + """fg/ch/sd survive the wire under their literal keys. + + Pin the public wire keys explicitly (cross-language sloppak readers key + off the literal strings), like the rh/pkd test above. + """ + n = Note( + time=0.0, string=0, fret=0, + fret_finger=2, strum_group=5, scale_degree=7, + ) + wire = note_to_wire(n) + assert wire["fg"] == 2 + assert wire["ch"] == 5 + assert wire["sd"] == 7 + assert note_from_wire(wire) == n + + +def test_note_teaching_marks_omitted_when_default(): + """fg/ch/sd are default-omitted (-1) and decode back to -1.""" + wire = note_to_wire(Note(time=0.0, string=0, fret=0)) + for omitted in ("fg", "ch", "sd"): + assert omitted not in wire, f"{omitted!r} should be default-omitted" + decoded = note_from_wire(wire) + assert decoded.fret_finger == -1 + assert decoded.strum_group == -1 + assert decoded.scale_degree == -1 + + +def test_note_teaching_marks_tolerate_malformed_optional_ints(): + """fg/ch/sd survive null / empty / non-numeric wire values.""" + for bad in (None, "", " ", "x", "inf"): + n = note_from_wire({"t": 0.0, "s": 0, "f": 0, + "fg": bad, "ch": bad, "sd": bad}) + assert n.fret_finger == -1 + assert n.strum_group == -1 + assert n.scale_degree == -1 + + +# ── Scale-degree derivation helpers (§6.2.2 / §7.7) ────────────────────────── + +@pytest.mark.parametrize("key,pc", [ + ("C", 0), ("c", 0), + ("E", 4), ("Em", 4), ("E minor", 4), + ("G", 7), ("G major", 7), ("Gmaj", 7), + ("A#m", 10), ("Bb", 10), # enharmonic — same pitch class + ("F#", 6), ("F#m", 6), + ("Cb", 11), ("B#", 0), # accidentals wrap mod 12 +]) +def test_key_to_tonic_pc_parses_key_names(key, pc): + assert key_to_tonic_pc(key) == pc + + +@pytest.mark.parametrize("bad", [None, "", " ", "H", "xyz", "7", 5]) +def test_key_to_tonic_pc_rejects_unparseable(bad): + assert key_to_tonic_pc(bad) is None + + +def test_scale_degree_for_pitch_standard_tuning_key_of_e(): + """Tonic E (pc 4), standard tuning: low-E open -> tonic, A-string fret 2 -> fifth.""" + tonic = key_to_tonic_pc("E") + assert tonic == 4 + low_e_open = 40 # E2 + a_string_fret2 = 45 + 2 # A2 + 2 = B2 + assert scale_degree_for_pitch(low_e_open, tonic) == 0 # tonic + assert scale_degree_for_pitch(a_string_fret2, tonic) == 7 # perfect fifth + assert scale_degree_for_pitch(40 + 3, tonic) == 3 # G2 -> minor third + + +def test_note_pitch_midi_standard_tuning_offsets(): + """`arr.tuning` holds OFFSETS from standard (0 = standard), padded to 6 on + RS-XML; pitch = base + offset + capo + fret. Standard guitar: low-E open -> + 40 (E2), A-string fret 2 -> 47 (B2).""" + arr = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0]) + assert note_pitch_midi(arr, Note(time=0, string=0, fret=0)) == 40 # low E open + assert note_pitch_midi(arr, Note(time=0, string=1, fret=2)) == 47 # A + 2 = B + # Drop-D (low string offset -2): low-E string open sounds D2 = 38. + drop_d = Arrangement(name="Lead", tuning=[-2, 0, 0, 0, 0, 0]) + assert note_pitch_midi(drop_d, Note(time=0, string=0, fret=0)) == 38 + # Capo 2 raises every sounding pitch by 2 semitones. + capo2 = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0], capo=2) + assert note_pitch_midi(capo2, Note(time=0, string=0, fret=0)) == 42 + + +def test_note_pitch_midi_bass_uses_bass_base(): + """A 4-string arrangement named 'Bass' uses the bass base (low E1 = 28), + not the guitar base (40).""" + bass = Arrangement(name="Bass", tuning=[0, 0, 0, 0]) + assert note_pitch_midi(bass, Note(time=0, string=0, fret=0)) == 28 + + +def test_note_pitch_midi_out_of_range_string_is_none(): + arr = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0]) + assert note_pitch_midi(arr, Note(time=0, string=9, fret=0)) is None + + +def test_base_open_string_midis_bass_vs_guitar(): + assert base_open_string_midis(6, False)[0] == 40 # guitar low E + assert base_open_string_midis(4, True)[0] == 28 # bass low E + assert base_open_string_midis(4, False)[0] == 40 # 4-string guitar voicing + + def test_note_bend_values_rounded_on_wire(): """`bnv` rounds `t` to 3 and `v` to 1, matching the scalar `bn` precision.""" n = Note( From 0f1006972b36a979462f951087f3b12a9bd78326 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sun, 21 Jun 2026 07:58:04 +0200 Subject: [PATCH 06/99] =?UTF-8?q?feat(highway):=20render=20teaching=20mark?= =?UTF-8?q?s=20fg/ch/sd=20on=202D=20+=203D=20(=C2=A76.2.2)=20(#538)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the three per-note teaching marks on both highways, mirroring the bend-curve render (#532). Display only — no scoring / NoteVerifier coupling. - 2D static/highway.js: fg renders by default as a small finger numeral hugging the gem (T = thumb, 1..4); sd (degree label) and ch (strum bracket connecting notes that share a ch key, arrow direction from pkd) are opt-in behind a new `showTeachingMarks` toggle (exposed via toggle/get/set + the bundle's `teachingMarksVisible` flag). Pure helpers teachingFingerLabel / teachingDegreeLabel / strumGroupBuckets drive the glyphs. ch bracket is note-stream-only (chord notes already read as one gesture). - 3D plugins/highway_3d/screen.js: fg (default) + sd (opt-in, mirrors the 2D toggle via bundle.teachingMarksVisible) render next to the per-note fret label via a new pooled sprite (pTeachMarkLbl); _scrChordNote resets fg/sd so chord notes don't inherit stale marks. ch strum brackets are deferred in 3D (no cross-note batch pass in the per-note render); 2D covers ch. Tests: tests/js/highway_teaching_marks.test.js extracts the pure helpers from both files (extract-and-eval) and asserts label mapping + strum-group bucketing. Part of got-feedback/feedback#334 Co-authored-by: Claude Opus 4.8 (1M context) --- plugins/highway_3d/screen.js | 65 ++++++++++++- static/highway.js | 121 +++++++++++++++++++++++- tests/js/highway_teaching_marks.test.js | 92 ++++++++++++++++++ 3 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 tests/js/highway_teaching_marks.test.js diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 99a23b1..312b84d 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -2534,6 +2534,9 @@ let _drawRecentByString = null; /** Snapshotted in update() — drawNote() is a sibling of update(), not nested in its closure. */ let _drawChordTemplates = null; + /** Teaching marks sd/ch overlay pref (§6.2.2), mirrored from the 2D + * highway's `teachingMarksVisible` bundle flag. fg renders regardless. */ + let _drawTeachingMarks = false; let _laneTargetColor = null; let _renderScale = 1; let lyricsCanvas = null, lyricsCtx = null; @@ -3017,6 +3020,7 @@ let gPMXLines = null, pMuteXLines = null; // PM X lines combined geometry (8 segs as quads) let gFHXLines = null, pFHXLines = null; // FH X lines combined geometry let pNoteFretLabel, pConnectorLine, pDropLine, pTapChevron, pAccentHalo; + let pTeachMarkLbl; // teaching marks fg/sd label sprites (§6.2.2) let pHaloBar = null, gHaloBar = null; // gradient halo bar geometry — replaces per-shell pChordAccentHalo let gArpBracket = null; // shared 1×1×1 box geometry for pArpBracket; built once, disposed in teardown let pSusRibbon = null, pSusRibbonOl = null; @@ -6095,6 +6099,14 @@ _nfl.material.depthTest = false; return _nfl; }); + // Teaching marks fg/sd labels (§6.2.2). One pool, two get()s per note + // (finger + degree); the texture is swapped per draw via material.map. + pTeachMarkLbl = pool(lblG, () => { + const _tml = new T.Sprite(txtMat('0', '#7fd1ff', false, 'teachMark').clone()); + _tml.material.fog = false; + _tml.material.depthTest = false; + return _tml; + }); pConnectorLine = pool(noteG, () => new T.Line( new T.BufferGeometry().setFromPoints([new T.Vector3(0, 0, 0), new T.Vector3(0, 1, 0)]), new T.LineBasicMaterial({ color: 0xaaaaaa, transparent: true, opacity: 0.5, depthTest: false }), @@ -6153,6 +6165,7 @@ pSusRailBloom.warm(_WARM_CHORD); pTechPlane.warm(_WARM_CHORD); pNoteFretLabel.warm(_WARM_NOTE); + pTeachMarkLbl.warm(_WARM_NOTE); pChordFrameFill.warm(_WARM_CHORD); pChordBox.warm(_WARM_CHORD); pChordLbl.warm(_WARM_CHORD); @@ -8353,6 +8366,7 @@ if (pMuteXLines) pMuteXLines.reset(); if (pFHXLines) pFHXLines.reset(); pNoteFretLabel.reset(); pConnectorLine.reset(); pDropLine.reset(); + pTeachMarkLbl.reset(); pFretColMarker.reset(); pSusRail.reset(); pSusRailBloom.reset(); pTechPlane.reset(); // Clear per-frame queues in-place (avoid reallocating the array object). _ndLabels.length = 0; @@ -8816,6 +8830,7 @@ _drawNextByString = nextNoteByString; _drawChordTemplates = bundle.chordTemplates ?? null; + _drawTeachingMarks = !!bundle.teachingMarksVisible; // ── Recent-past event per string (for _nextAnyT deadline) ───── // Once a note/chord passes `now` it leaves _drawNextByString, @@ -9830,6 +9845,12 @@ // apply the wrong contour). Reset explicitly. _scrChordNote.bnv = Array.isArray(cn.bnv) ? cn.bnv : undefined; _scrChordNote.bt = cn.bt || 0; + // Same stale-scratch hazard for the teaching marks + // (§6.2.2): fg/sd are omit-when-default on the wire, + // so a chord note without them must reset to -1 or it + // inherits the previous note's finger/degree label. + _scrChordNote.fg = Number.isInteger(cn.fg) ? cn.fg : -1; + _scrChordNote.sd = Number.isInteger(cn.sd) ? cn.sd : -1; drawNote( _scrChordNote, now, @@ -11316,6 +11337,22 @@ return visualIdx >= (nStr - 1) * 0.5 ? -1 : 1; } + // Teaching marks (§6.2.2) — display only, never grading. Pure label + // helpers, mirroring static/highway.js so the two highways agree; + // node-tested via tests/js/highway_teaching_marks.test.js. + function teachingFingerLabel(fg) { + // fret-hand finger: '' when unset/out of range; 0 -> 'T' (thumb), + // 1..4 -> '1'..'4'. + if (!Number.isInteger(fg) || fg < 0 || fg > 4) return ''; + return fg === 0 ? 'T' : String(fg); + } + function teachingDegreeLabel(sd) { + // scale degree: chromatic 0..11 above the active key tonic; '' when + // unset/out of range. + if (!Number.isInteger(sd) || sd < 0 || sd > 11) return ''; + return String(sd); + } + function bnvSampleAt(bnv, t) { // Linear interpolation of a bend curve [{t, v}] (§6.2.1; t is // seconds from the note onset) at elapsed time t. Clamps to the @@ -12260,6 +12297,32 @@ fretLabel.scale.set(flS, flS, 1); fretLabel.material.opacity = alpha; } + + // Teaching marks (§6.2.2) — display only, never grading. The + // fret-hand finger (fg) renders by default to the right of the + // fret label; the scale degree (sd) is opt-in (mirrors the 2D + // `teachingMarksVisible` toggle) and renders to the left. + if (alpha > 0 && n.f > 0) { + const _tmS = 5.0 * K * _textSizeMul * fretLabelScaleForFret(n.f); + const _drawTeachMark = (text, colorHex, dx, cacheKey) => { + if (!text) return; + const spr = pTeachMarkLbl.get(); + const m = txtMat(text, colorHex, false, cacheKey); + if (spr.material.map !== m.map) { + spr.material.map = m.map; + spr.material.needsUpdate = true; + } + spr.position.set(x + dx, labelY, noteZ); + spr.renderOrder = renderOrderForLayerAtZ(noteZ, + _isArpNote ? 'ARP_NOTE_FRET_LABEL' : 'NOTE_FRET_LABEL'); + spr.scale.set(_tmS, _tmS, 1); + spr.material.opacity = alpha; + }; + _drawTeachMark(teachingFingerLabel(n.fg), '#7fd1ff', NW * 0.95, 'teachFg'); + if (_drawTeachingMarks) { + _drawTeachMark(teachingDegreeLabel(n.sd), '#ffcc66', -NW * 0.95, 'teachSd'); + } + } } } @@ -12973,7 +13036,7 @@ _renderScale = 1; mBeatM = mBeatQ = null; pNote = pNoteEdge = pSus = pSusOutline = pSusRibbon = pSusRibbonOl = pLbl = pBeat = pSec = null; - pFretLbl = pLane = pLaneDivider = pGhostFretLbl = pChordBox = pChordFrameFill = pChordLbl = pBarreLine = pArpBracket = pNoteFretLabel = pConnectorLine = pDropLine = pTapChevron = pAccentHalo = pHaloBar = pPMXFill = pFHXFill = pMuteXLines = pFHXLines = null; + pFretLbl = pLane = pLaneDivider = pGhostFretLbl = pChordBox = pChordFrameFill = pChordLbl = pBarreLine = pArpBracket = pNoteFretLabel = pConnectorLine = pDropLine = pTapChevron = pAccentHalo = pHaloBar = pPMXFill = pFHXFill = pMuteXLines = pFHXLines = pTeachMarkLbl = null; if (gPMXFill) { gPMXFill.dispose(); gPMXFill = null; } if (gFHXFill) { gFHXFill.dispose(); gFHXFill = null; } if (gPMXLines) { gPMXLines.dispose(); gPMXLines = null; } diff --git a/static/highway.js b/static/highway.js index fd8871a..1feb9d1 100644 --- a/static/highway.js +++ b/static/highway.js @@ -205,6 +205,11 @@ function createHighway() { // have any. let _phrasesHaveHandShapes = false; let showLyrics = localStorage.getItem('showLyrics') !== 'false'; + // Teaching marks (§6.2.2): the fret-hand finger numeral (fg) renders by + // default (small, on the gem), but the scale-degree (sd) + strum-group (ch) + // overlays are opt-in so the default highway stays uncluttered. Display only + // — never used for grading. + let _showTeachingMarks = localStorage.getItem('showTeachingMarks') === 'true'; let _drawHooks = []; // plugin draw callbacks: fn(ctx, W, H) // slopsmith#254 — per-note judgment overlay. A plugin (note_detect) // registers fn(note, chartTime) -> 'hit' | 'active' | 'miss' | null @@ -485,6 +490,37 @@ function createHighway() { return bnv.map(p => ({ x: span > 0 ? (p.t - t0) / span : 0, v: p.v })); } + /** Teaching mark (§6.2.2): fret-hand-finger label for a note's `fg`. + * '' when unset/out of range; 0 → 'T' (thumb), 1..4 → '1'..'4'. Pure. */ + function teachingFingerLabel(fg) { + if (!Number.isInteger(fg) || fg < 0 || fg > 4) return ''; + return fg === 0 ? 'T' : String(fg); + } + + /** Teaching mark (§6.2.2): scale-degree label for a note's `sd` (chromatic + * 0..11 above the active key tonic). '' when unset/out of range. Pure. */ + function teachingDegreeLabel(sd) { + if (!Number.isInteger(sd) || sd < 0 || sd > 11) return ''; + return String(sd); + } + + /** Teaching mark (§6.2.2): bucket drawn notes by their strum-group key `ch`. + * Returns the groups (in first-seen order) for each ch value >= 0 that has + * at least two members — a lone note is not a strum gesture. Pure; drives + * the strum-bracket overlay and is node-tested. */ + function strumGroupBuckets(items) { + if (!Array.isArray(items)) return []; + const order = []; + const byKey = new Map(); + for (const it of items) { + const ch = it && Number.isInteger(it.ch) ? it.ch : -1; + if (ch < 0) continue; + if (!byKey.has(ch)) { byKey.set(ch, []); order.push(ch); } + byKey.get(ch).push(it); + } + return order.map(k => byKey.get(k)).filter(g => g.length >= 2); + } + /** Call while lefty mirror transform is active; keeps glyphs readable. */ function fillTextReadable(text, x, y) { // ctx may be null when the 2D context was never acquired @@ -737,6 +773,9 @@ function createHighway() { lefty: _lefty, renderScale: _effectiveRenderScale(), lyricsVisible: showLyrics, + // Teaching marks sd/ch overlay pref (§6.2.2) so custom renderers + // (e.g. the 3D highway) can mirror the 2D opt-in toggle. + teachingMarksVisible: _showTeachingMarks, // 2D-style helpers (renderers that don't need these can ignore). // `fillTextUnmirrored` is deliberately NOT exposed here — @@ -1687,6 +1726,29 @@ function createHighway() { if (sz < 14) return; // Skip small technique labels + // Teaching marks (§6.2.2) — display only, never grading. The fret-hand + // finger (fg) renders by default as a small numeral hugging the gem's + // right edge (T = thumb, 1..4); the scale degree (sd) is opt-in and sits + // on the left edge so the two never collide with the centred fret number. + const fgLabel = teachingFingerLabel(opts?.fg); + if (fgLabel) { + ctx.fillStyle = '#7fd1ff'; + ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + fillTextReadable(fgLabel, x + half + 2, y + half * 0.5); + } + if (_showTeachingMarks) { + const sdLabel = teachingDegreeLabel(opts?.sd); + if (sdLabel) { + ctx.fillStyle = '#ffcc66'; + ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + fillTextReadable(sdLabel, x - half - 2, y + half * 0.5); + } + } + // Slide indicator (diagonal arrow). Pitched (sl) draws a solid arrow to // the target fret; unpitched (slu) draws a dashed diagonal with no // arrowhead (no definite target pitch). The two are mutually exclusive @@ -1879,11 +1941,56 @@ function createHighway() { const x = fretX(n.f, p.scale, W); drawNote(W, H, x, p.y * H, p.scale, n.s, n.f, n, _noteStateProvider ? _noteState(n, n.t) : null); - drawnNotes.push({ t: n.t, s: n.s, f: n.f, bn: n.bn || 0, x, y: p.y * H, scale: p.scale }); + drawnNotes.push({ + t: n.t, s: n.s, f: n.f, bn: n.bn || 0, x, y: p.y * H, scale: p.scale, + ch: Number.isInteger(n.ch) ? n.ch : -1, + pkd: Number.isInteger(n.pkd) ? n.pkd : -1, + }); } // Draw unison bend connectors drawUnisonBends(W, H, drawnNotes); + // Strum-group brackets (teaching mark ch, §6.2.2) — opt-in overlay. + // Scoped to standalone notes (the stream drawNotes renders); chord-note + // strum groups aren't bracketed (the editor authors ch over single-note + // selections, and chord notes already read as one simultaneous gesture). + if (_showTeachingMarks) drawStrumGroups(W, H, drawnNotes); + } + + function drawStrumGroups(W, H, drawnNotes) { + // Teaching mark (§6.2.2): notes sharing a `ch` key >= 0 are one + // strum/rake gesture. Connect each group's gems with a bracket and a + // single arrowhead whose direction comes from `pkd` (0 = down-strum, + // 1 = up-strum). Display only — never grading. + for (const group of strumGroupBuckets(drawnNotes)) { + const pts = group.slice().sort((a, b) => a.y - b.y || a.x - b.x); + const scale = pts[0].scale; + const sz = Math.max(12, 80 * scale * (H / 900)); + if (sz < 14) continue; + const pkd = (group.find(p => p.pkd === 0 || p.pkd === 1) || {}).pkd; + + ctx.save(); + ctx.strokeStyle = '#c89bff'; + ctx.lineWidth = Math.max(2, sz / 12); + ctx.lineJoin = 'round'; + ctx.beginPath(); + pts.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y))); + ctx.stroke(); + // Arrowhead at the gesture start: down-strum (pkd 0) points toward + // the last gem, up-strum (pkd 1) toward the first. + if (pkd === 0 || pkd === 1) { + const head = pkd === 1 ? pts[0] : pts[pts.length - 1]; + const from = pkd === 1 ? pts[1] : pts[pts.length - 2]; + const dy = Math.sign(head.y - from.y) || 1; + const a = sz * 0.18; + ctx.beginPath(); + ctx.moveTo(head.x - a, head.y - dy * a); + ctx.lineTo(head.x, head.y); + ctx.lineTo(head.x + a, head.y - dy * a); + ctx.stroke(); + } + ctx.restore(); + } } function drawUnisonBends(W, H, drawnNotes) { @@ -3675,6 +3782,18 @@ function createHighway() { }, setOnLyricsChange(fn) { _onLyricsChange = fn; }, + // Teaching marks (§6.2.2): toggle the opt-in sd/ch overlays. The fg + // numeral is unaffected (always on). Persisted to localStorage. + getTeachingMarksVisible() { return _showTeachingMarks; }, + toggleTeachingMarks() { + _showTeachingMarks = !_showTeachingMarks; + localStorage.setItem('showTeachingMarks', String(_showTeachingMarks)); + }, + setTeachingMarksVisible(v) { + _showTeachingMarks = !!v; + localStorage.setItem('showTeachingMarks', String(_showTeachingMarks)); + }, + reconnect(filename, arrangement) { // Close old WS but keep audio + animation running if (ws) { ws.close(); ws = null; } diff --git a/tests/js/highway_teaching_marks.test.js b/tests/js/highway_teaching_marks.test.js new file mode 100644 index 0000000..6e729ef --- /dev/null +++ b/tests/js/highway_teaching_marks.test.js @@ -0,0 +1,92 @@ +// Behavioural tests for the teaching-marks (§6.2.2) render helpers: +// teachingFingerLabel / teachingDegreeLabel (both highways) and +// strumGroupBuckets (2D, drives the strum bracket). All pure, so we extract +// the function source by brace-matching and eval it in isolation — same +// pattern as highway_bend_curve.test.js. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +function extractFn(src, name) { + const start = src.indexOf('function ' + name); + assert.ok(start >= 0, `function ${name} must exist`); + const open = src.indexOf('{', start); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1); + } + throw new Error(`unbalanced braces extracting ${name}`); +} + +function loadFn(file, name) { + const src = fs.readFileSync(path.join(__dirname, '..', '..', file), 'utf8'); + return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)(); +} + +const fingerLabel2D = loadFn('static/highway.js', 'teachingFingerLabel'); +const degreeLabel2D = loadFn('static/highway.js', 'teachingDegreeLabel'); +const fingerLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingFingerLabel'); +const degreeLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingDegreeLabel'); +const strumGroupBuckets = loadFn('static/highway.js', 'strumGroupBuckets'); + +// ── teachingFingerLabel (fg) ───────────────────────────────────────────────── + +for (const [name, fn] of [['2D', fingerLabel2D], ['3D', fingerLabel3D]]) { + test(`teachingFingerLabel (${name}) maps 0->T, 1..4->digit, else ''`, () => { + assert.equal(fn(0), 'T'); // thumb + assert.equal(fn(1), '1'); + assert.equal(fn(4), '4'); // pinky + assert.equal(fn(-1), ''); // unset + assert.equal(fn(5), ''); // out of range + assert.equal(fn(1.5), ''); // non-integer + assert.equal(fn(undefined), ''); + assert.equal(fn(null), ''); + }); +} + +// ── teachingDegreeLabel (sd) ───────────────────────────────────────────────── + +for (const [name, fn] of [['2D', degreeLabel2D], ['3D', degreeLabel3D]]) { + test(`teachingDegreeLabel (${name}) shows 0..11, else ''`, () => { + assert.equal(fn(0), '0'); // tonic + assert.equal(fn(7), '7'); // fifth + assert.equal(fn(11), '11'); + assert.equal(fn(-1), ''); // unset + assert.equal(fn(12), ''); // out of range + assert.equal(fn(3.2), ''); // non-integer + assert.equal(fn(undefined), ''); + }); +} + +// ── strumGroupBuckets (ch) ─────────────────────────────────────────────────── + +test('strumGroupBuckets groups notes sharing a ch >= 0, dropping lone notes', () => { + const items = [ + { id: 'a', ch: 5 }, + { id: 'b', ch: -1 }, // ungrouped + { id: 'c', ch: 5 }, + { id: 'd', ch: 7 }, // lone group (only one member) -> dropped + { id: 'e', ch: 5 }, + ]; + const groups = strumGroupBuckets(items); + assert.equal(groups.length, 1); + assert.deepEqual(groups[0].map(n => n.id), ['a', 'c', 'e']); +}); + +test('strumGroupBuckets preserves first-seen group order and handles multiple groups', () => { + const items = [ + { id: 'a', ch: 2 }, { id: 'b', ch: 9 }, + { id: 'c', ch: 2 }, { id: 'd', ch: 9 }, + ]; + const groups = strumGroupBuckets(items); + assert.deepEqual(groups.map(g => g.map(n => n.id)), [['a', 'c'], ['b', 'd']]); +}); + +test('strumGroupBuckets ignores non-integer / negative ch and bad input', () => { + assert.deepEqual(strumGroupBuckets([{ ch: -1 }, { ch: 1.5 }, { ch: null }, {}]), []); + assert.deepEqual(strumGroupBuckets([]), []); + assert.deepEqual(strumGroupBuckets(null), []); +}); From f182bd0ab76f7975b2a69db81ce18697b567a590 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sun, 21 Jun 2026 08:14:24 +0200 Subject: [PATCH 07/99] fix(highway): make fret-hand finger (fg) hints hideable (default on) (#539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge review of #538 noted the fg finger numeral rendered unconditionally on both highways and couldn't be turned off — only sd/ch sat behind the (default-off) teaching-marks toggle. A user who finds per-note numerals busy had no way to declutter. Add a SEPARATE finger-hints gate that keeps fg shown by default but makes it hideable, independent of the sd/ch opt-in (so the two defaults — fg on, sd/ch off — coexist; a single boolean can't express that): - 2D static/highway.js: _showFingerHints (localStorage 'showFingerHints' !== 'false', i.e. default on), a fingerHintsVisible bundle flag, and get/toggle/setFingerHintsVisible API; gates the fg label. - 3D plugins/highway_3d/screen.js: mirrors via bundle.fingerHintsVisible !== false (default on); gates the fg sprite. sd/ch unchanged. Default-on preserved (absent localStorage / absent bundle flag => shown); only an explicit false hides fg. Codex-reviewed: clean. Render test 7/7. Part of got-feedback/feedback#334. Co-authored-by: Claude Opus 4.8 (1M context) --- plugins/highway_3d/screen.js | 17 +++++++++++++---- static/highway.js | 30 +++++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 312b84d..216cc7e 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -2535,8 +2535,12 @@ /** Snapshotted in update() — drawNote() is a sibling of update(), not nested in its closure. */ let _drawChordTemplates = null; /** Teaching marks sd/ch overlay pref (§6.2.2), mirrored from the 2D - * highway's `teachingMarksVisible` bundle flag. fg renders regardless. */ + * highway's `teachingMarksVisible` bundle flag. */ let _drawTeachingMarks = false; + /** Fret-hand finger (fg) hint pref, mirrored from the 2D highway's + * `fingerHintsVisible` bundle flag — default on (shown unless an explicit + * false), hideable independently of the sd/ch overlays. */ + let _showFingerHints = true; let _laneTargetColor = null; let _renderScale = 1; let lyricsCanvas = null, lyricsCtx = null; @@ -8831,6 +8835,8 @@ _drawNextByString = nextNoteByString; _drawChordTemplates = bundle.chordTemplates ?? null; _drawTeachingMarks = !!bundle.teachingMarksVisible; + // Default on: only an explicit false (older bundles omit the flag) hides fg. + _showFingerHints = bundle.fingerHintsVisible !== false; // ── Recent-past event per string (for _nextAnyT deadline) ───── // Once a note/chord passes `now` it leaves _drawNextByString, @@ -12300,8 +12306,9 @@ // Teaching marks (§6.2.2) — display only, never grading. The // fret-hand finger (fg) renders by default to the right of the - // fret label; the scale degree (sd) is opt-in (mirrors the 2D - // `teachingMarksVisible` toggle) and renders to the left. + // fret label (hideable via the finger-hints toggle); the scale + // degree (sd) is opt-in (mirrors the 2D `teachingMarksVisible` + // toggle) and renders to the left. if (alpha > 0 && n.f > 0) { const _tmS = 5.0 * K * _textSizeMul * fretLabelScaleForFret(n.f); const _drawTeachMark = (text, colorHex, dx, cacheKey) => { @@ -12318,7 +12325,9 @@ spr.scale.set(_tmS, _tmS, 1); spr.material.opacity = alpha; }; - _drawTeachMark(teachingFingerLabel(n.fg), '#7fd1ff', NW * 0.95, 'teachFg'); + if (_showFingerHints) { + _drawTeachMark(teachingFingerLabel(n.fg), '#7fd1ff', NW * 0.95, 'teachFg'); + } if (_drawTeachingMarks) { _drawTeachMark(teachingDegreeLabel(n.sd), '#ffcc66', -NW * 0.95, 'teachSd'); } diff --git a/static/highway.js b/static/highway.js index 1feb9d1..13fbb1e 100644 --- a/static/highway.js +++ b/static/highway.js @@ -210,6 +210,11 @@ function createHighway() { // overlays are opt-in so the default highway stays uncluttered. Display only // — never used for grading. let _showTeachingMarks = localStorage.getItem('showTeachingMarks') === 'true'; + // Fret-hand finger (fg) hints are shown by DEFAULT (the most broadly useful + // teaching mark) but independently hideable — separate from the sd/ch opt-in + // above so the two defaults (fg on, sd/ch off) can coexist. Default-true: an + // absent key reads as on; only an explicit 'false' hides it. + let _showFingerHints = localStorage.getItem('showFingerHints') !== 'false'; let _drawHooks = []; // plugin draw callbacks: fn(ctx, W, H) // slopsmith#254 — per-note judgment overlay. A plugin (note_detect) // registers fn(note, chartTime) -> 'hit' | 'active' | 'miss' | null @@ -774,8 +779,10 @@ function createHighway() { renderScale: _effectiveRenderScale(), lyricsVisible: showLyrics, // Teaching marks sd/ch overlay pref (§6.2.2) so custom renderers - // (e.g. the 3D highway) can mirror the 2D opt-in toggle. + // (e.g. the 3D highway) can mirror the 2D opt-in toggle. The fg + // finger-hint pref rides alongside (default on, independently hideable). teachingMarksVisible: _showTeachingMarks, + fingerHintsVisible: _showFingerHints, // 2D-style helpers (renderers that don't need these can ignore). // `fillTextUnmirrored` is deliberately NOT exposed here — @@ -1728,9 +1735,10 @@ function createHighway() { // Teaching marks (§6.2.2) — display only, never grading. The fret-hand // finger (fg) renders by default as a small numeral hugging the gem's - // right edge (T = thumb, 1..4); the scale degree (sd) is opt-in and sits - // on the left edge so the two never collide with the centred fret number. - const fgLabel = teachingFingerLabel(opts?.fg); + // right edge (T = thumb, 1..4), hideable via the finger-hints toggle; the + // scale degree (sd) is opt-in and sits on the left edge so the two never + // collide with the centred fret number. + const fgLabel = _showFingerHints ? teachingFingerLabel(opts?.fg) : ''; if (fgLabel) { ctx.fillStyle = '#7fd1ff'; ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`; @@ -3783,7 +3791,7 @@ function createHighway() { setOnLyricsChange(fn) { _onLyricsChange = fn; }, // Teaching marks (§6.2.2): toggle the opt-in sd/ch overlays. The fg - // numeral is unaffected (always on). Persisted to localStorage. + // numeral has its own toggle below. Persisted to localStorage. getTeachingMarksVisible() { return _showTeachingMarks; }, toggleTeachingMarks() { _showTeachingMarks = !_showTeachingMarks; @@ -3794,6 +3802,18 @@ function createHighway() { localStorage.setItem('showTeachingMarks', String(_showTeachingMarks)); }, + // Fret-hand finger hints (§6.2.2 fg): shown by default, hideable + // independently of the sd/ch overlays. Persisted to localStorage. + getFingerHintsVisible() { return _showFingerHints; }, + toggleFingerHints() { + _showFingerHints = !_showFingerHints; + localStorage.setItem('showFingerHints', String(_showFingerHints)); + }, + setFingerHintsVisible(v) { + _showFingerHints = !!v; + localStorage.setItem('showFingerHints', String(_showFingerHints)); + }, + reconnect(filename, arrangement) { // Close old WS but keep audio + animation running if (ws) { ws.close(); ws = null; } From ea227919841c1f08646a4d467c405b0cb301fae8 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sun, 21 Jun 2026 11:02:52 +0200 Subject: [PATCH 08/99] =?UTF-8?q?feat(core):=20carry=20chord=20harmony=20f?= =?UTF-8?q?n=20+=20template=20voicing=20on=20the=20wire=20(=C2=A76.3.1,=20?= =?UTF-8?q?=C2=A76.6)=20(#540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two OPTIONAL per-chord harmony annotations (feedpak 1.7.0), mirroring the teaching-marks (fg/ch/sd) wire work: - Chord.fn (instance): {rn, q, deg} harmonic-function object, key-dependent. Validated by _validate_fn on BOTH decode and emit so a partial / out-of-range fn (which would fail the schema's required-keys rule) never rides the wire. Default-omitted, mirroring bend bnv. - ChordTemplate.voicing (template): key-independent voicing-type string ("open", "triad", "shell", "drop2", "barre", ...). Emitted only when non-empty; non-string wire values fall back to "". Display/teaching only — never fed to a grader (honesty rule). fn auto-derivation is DEFERRED (carry-only): a complete rn/q needs chord-quality analysis, and a deg-only fn would be schema-invalid, so server.py carries author-provided fn unchanged. GP import unchanged (no reliable per-chord function/voicing). Co-authored-by: Claude Opus 4.8 (1M context) --- lib/song.py | 51 ++++++++++++++++++++++++-- tests/test_song.py | 90 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 3 deletions(-) diff --git a/lib/song.py b/lib/song.py index 284c194..a1ba100 100644 --- a/lib/song.py +++ b/lib/song.py @@ -65,6 +65,9 @@ class ChordTemplate: frets: list[int] display_name: str = "" arpeggio: bool = False + # Harmony annotation (§6.6) — key-independent voicing type, e.g. "open", + # "triad", "shell", "drop2", "barre". Display/teaching only, never grading. + voicing: str = "" @dataclass @@ -73,6 +76,10 @@ class Chord: chord_id: int notes: list[Note] = field(default_factory=list) high_density: bool = False + # Harmony annotation (§6.3.1) — key-dependent harmonic function on the chord + # INSTANCE: {rn: str, q: str, deg: int 0..11}. All three keys required when + # present (see _validate_fn). Display/teaching only, never grading. + fn: dict | None = None @dataclass @@ -268,12 +275,19 @@ def chord_note_to_wire(cn: Note) -> dict: def chord_to_wire(c: Chord) -> dict: - return { + out = { "t": round(c.time, 3), "id": c.chord_id, "hd": c.high_density, "notes": [chord_note_to_wire(cn) for cn in c.notes], } + # Harmony function (§6.3.1) — default-omitted, mirroring bend `bnv`. Re-validate + # on emit (not just decode) so a directly-constructed Chord can't put a partial + # or out-of-range fn on the wire, which would fail the schema's required-keys rule. + fn = _validate_fn(c.fn) + if fn: + out["fn"] = fn + return out def anchor_to_wire(a: Anchor) -> dict: @@ -290,7 +304,7 @@ def hand_shape_to_wire(h: HandShape) -> dict: def chord_template_to_wire(ct: ChordTemplate) -> dict: - return { + out = { "name": ct.name, # ChordTemplate.display_name defaults to "" on the dataclass, but # the spec defaults displayName to name. Fall back here so @@ -302,6 +316,10 @@ def chord_template_to_wire(ct: ChordTemplate) -> dict: "fingers": list(ct.fingers), "frets": list(ct.frets), } + # Harmony voicing (§6.6) — default-omitted, only when non-empty. + if ct.voicing: + out["voicing"] = ct.voicing + return out def _wire_int_optional(v, default=-1): @@ -479,6 +497,30 @@ def note_from_wire(d: dict, time: float | None = None) -> Note: ) +def _validate_fn(raw) -> dict | None: + """Validate an optional chord harmony function (§6.3.1). + + Returns a clean ``{"rn", "q", "deg"}`` dict only when ``raw`` is an object + with a non-empty ``rn`` string, a non-empty ``q`` string, and an int ``deg`` + in 0..11. Any malformed / missing-key / out-of-range input -> ``None`` so a + partial fn (which would fail the schema's required-keys rule) never rides the + wire. Display/teaching only — MUST NEVER feed a grader. Mirrors the + drop-to-default tolerance of `_sanitize_bend_curve`.""" + if not isinstance(raw, dict): + return None + rn = raw.get("rn") + q = raw.get("q") + deg = raw.get("deg") + if not isinstance(rn, str) or not rn.strip(): + return None + if not isinstance(q, str) or not q.strip(): + return None + # bool is an int subclass — reject it so `deg=True` can't pass as 1. + if not isinstance(deg, int) or isinstance(deg, bool) or not (0 <= deg <= 11): + return None + return {"rn": rn.strip(), "q": q.strip(), "deg": deg} + + def chord_from_wire(d: dict) -> Chord: t = float(d.get("t", 0.0)) return Chord( @@ -486,6 +528,7 @@ def chord_from_wire(d: dict) -> Chord: chord_id=int(d.get("id", 0)), high_density=bool(d.get("hd", False)), notes=[note_from_wire(cn, time=t) for cn in d.get("notes", [])], + fn=_validate_fn(d.get("fn")), ) @@ -838,7 +881,9 @@ def arrangement_from_wire(d: dict) -> Arrangement: display_name=ct.get("displayName", ct.get("name", "")), arpeggio=bool(ct.get("arp", False)), fingers=list(ct.get("fingers", [-1] * 6)), - frets=list(ct.get("frets", [-1] * 6))) + frets=list(ct.get("frets", [-1] * 6)), + voicing=(ct.get("voicing") + if isinstance(ct.get("voicing"), str) else "")) for ct in d.get("templates", []) ], # `phrases` is optional — absent on single-level sources / older diff --git a/tests/test_song.py b/tests/test_song.py index 655dfe6..7f2da95 100644 --- a/tests/test_song.py +++ b/tests/test_song.py @@ -17,6 +17,7 @@ from song import ( arrangement_string_count, arrangement_to_wire, chord_from_wire, + chord_template_to_wire, chord_to_wire, sanitize_tempos, compute_smart_names, @@ -395,6 +396,95 @@ def test_chord_notes_inherit_chord_time_on_deserialization(): assert all(n.time == 3.0 for n in result.notes) +# ── Harmony annotations: chord fn (§6.3.1) + template voicing (§6.6) ────────── + +def test_chord_fn_round_trip(): + """A well-formed fn {rn, q, deg} survives the wire under its literal key.""" + c = Chord( + time=2.0, chord_id=0, + notes=[Note(time=2.0, string=0, fret=2)], + fn={"rn": "ii7", "q": "m7", "deg": 2}, + ) + wire = chord_to_wire(c) + assert wire["fn"] == {"rn": "ii7", "q": "m7", "deg": 2} + assert chord_from_wire(wire) == c + + +def test_chord_fn_omitted_when_none(): + """fn defaults to None and produces no `fn` key on the wire.""" + wire = chord_to_wire(Chord(time=0.0, chord_id=0, + notes=[Note(time=0.0, string=0, fret=0)])) + assert "fn" not in wire + assert chord_from_wire(wire).fn is None + + +@pytest.mark.parametrize("bad", [ + None, # absent / null + "ii7", # not an object + {}, # empty + {"rn": "ii7", "q": "m7"}, # missing deg + {"rn": "ii7", "deg": 2}, # missing q + {"q": "m7", "deg": 2}, # missing rn + {"rn": "", "q": "m7", "deg": 2}, # blank rn + {"rn": "ii7", "q": " ", "deg": 2}, # blank q + {"rn": "ii7", "q": "m7", "deg": 15}, # deg out of range (high) + {"rn": "ii7", "q": "m7", "deg": -1}, # deg out of range (low) + {"rn": "ii7", "q": "m7", "deg": "2"}, # deg not an int + {"rn": "ii7", "q": "m7", "deg": True}, # deg is a bool, not a real int + {"rn": 7, "q": "m7", "deg": 2}, # rn not a str +]) +def test_chord_fn_malformed_drops_to_none(bad): + """Any malformed / partial / out-of-range fn decodes to None (never partial).""" + c = chord_from_wire({"t": 1.0, "id": 0, "notes": [], "fn": bad}) + assert c.fn is None + + +@pytest.mark.parametrize("bad_fn", [ + {"rn": "ii7"}, # missing q + deg + {"rn": "ii7", "q": "m7", "deg": 15}, # deg out of range + {"rn": "", "q": "m7", "deg": 2}, # blank rn +]) +def test_chord_to_wire_drops_invalid_fn_on_emit(bad_fn): + """A directly-constructed Chord with a partial/out-of-range fn never rides the wire.""" + wire = chord_to_wire(Chord(time=1.0, chord_id=0, notes=[], fn=bad_fn)) + assert "fn" not in wire + + +def test_chord_fn_strips_whitespace_on_decode(): + c = chord_from_wire({"t": 1.0, "id": 0, "notes": [], + "fn": {"rn": " V7 ", "q": " 7 ", "deg": 7}}) + assert c.fn == {"rn": "V7", "q": "7", "deg": 7} + + +def test_template_voicing_round_trip(): + """A non-empty voicing survives the template wire + arrangement round-trip.""" + ct = ChordTemplate(name="Am", display_name="Am", fingers=[-1, 0, 2, 2, 1, 0], + frets=[-1, 0, 2, 2, 1, 0], voicing="open") + assert chord_template_to_wire(ct)["voicing"] == "open" + arr = Arrangement(name="Rhythm", chord_templates=[ct]) + assert arrangement_from_wire(arrangement_to_wire(arr)).chord_templates[0] == ct + + +def test_template_voicing_omitted_when_default(): + """An empty voicing (the default) produces no `voicing` key.""" + ct = ChordTemplate(name="Am", fingers=[-1] * 6, frets=[-1] * 6) + assert "voicing" not in chord_template_to_wire(ct) + arr = arrangement_from_wire(arrangement_to_wire( + Arrangement(name="Rhythm", chord_templates=[ct]))) + assert arr.chord_templates[0].voicing == "" + + +@pytest.mark.parametrize("bad", [None, 7, ["open"], {"v": "open"}]) +def test_template_voicing_tolerates_malformed(bad): + """A non-string voicing on the wire falls back to the empty default.""" + arr = arrangement_from_wire({ + "name": "Rhythm", + "templates": [{"name": "Am", "fingers": [-1] * 6, "frets": [-1] * 6, + "voicing": bad}], + }) + assert arr.chord_templates[0].voicing == "" + + # ── Arrangement round-trip ─────────────────────────────────────────────────── def test_arrangement_empty_round_trip(): From 3fc077cbc1f45f60654d58cec6810dc41cb1ffb2 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sun, 21 Jun 2026 11:03:15 +0200 Subject: [PATCH 09/99] =?UTF-8?q?feat(highway):=20render=20chord=20harmony?= =?UTF-8?q?=20fn.rn=20+=20voicing=20on=202D=20+=203D=20(=C2=A76.3.1,=20?= =?UTF-8?q?=C2=A76.6)=20(#541)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): carry chord harmony fn + template voicing on the wire (§6.3.1, §6.6) Add two OPTIONAL per-chord harmony annotations (feedpak 1.7.0), mirroring the teaching-marks (fg/ch/sd) wire work: - Chord.fn (instance): {rn, q, deg} harmonic-function object, key-dependent. Validated by _validate_fn on BOTH decode and emit so a partial / out-of-range fn (which would fail the schema's required-keys rule) never rides the wire. Default-omitted, mirroring bend bnv. - ChordTemplate.voicing (template): key-independent voicing-type string ("open", "triad", "shell", "drop2", "barre", ...). Emitted only when non-empty; non-string wire values fall back to "". Display/teaching only — never fed to a grader (honesty rule). fn auto-derivation is DEFERRED (carry-only): a complete rn/q needs chord-quality analysis, and a deg-only fn would be schema-invalid, so server.py carries author-provided fn unchanged. GP import unchanged (no reliable per-chord function/voicing). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(highway): render chord harmony fn.rn + voicing on 2D + 3D (§6.3.1, §6.6) Draw the chord's harmonic-function Roman numeral (instance fn.rn) and its template voicing string, stacked above the chord name on both highways. A shared pure helper chordHarmonyLabels(fn, voicing) formats the two labels (empty when absent/malformed) and is node-tested against both files. Both labels are gated behind the EXISTING teaching-marks opt-in (_showTeachingMarks / teachingMarksVisible bundle flag) — they're chord-level teaching overlays, same class as sd/ch, so they stay off the default highway. 2D guards the empty-note-chord case; 3D reuses the gold chord-label sprite style. Render only — no scoring / NoteVerifier path is touched (honesty rule). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- plugins/highway_3d/screen.js | 40 +++++++++++++++++++++ static/highway.js | 44 +++++++++++++++++++++++ tests/js/highway_chord_harmony.test.js | 49 ++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 tests/js/highway_chord_harmony.test.js diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 216cc7e..0cfd780 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -10296,6 +10296,37 @@ lbl.scale.set(lblWS, lblHS, 1); } + // Harmony annotations (§6.3.1 / §6.6) — the chord's + // function (fn.rn Roman numeral) and template voicing, + // stacked above the chord name. Gated by the + // teaching-marks opt-in (mirrors the 2D overlay). Display + // only — never grading. + if (_drawTeachingMarks && firstInShapeRun && !chordWireHighDensity(ch)) { + const _h = chordHarmonyLabels(ch.fn, bundle.chordTemplates?.[ch.id]?.voicing); + if (_h.rn || _h.voicing) { + const hlW = 24 * K * _textSizeMul; + const hlH = 9 * K * _textSizeMul; + const frameLeft = cx - width / 2; + const baseX = frameLeft - hlW / 2 + NW * 0.94; + const opacity = Math.min(1, 0.3 + fade * 0.7) * chordTailMul; + // Start one chord-name-height above the name and + // stack upward so labels never overlap the gems. + let hy = yMaxF + hlH * 1.6; + const _drawHarmony = (text, colorHex) => { + if (!text) return; + const s = pChordLbl.get(); + const m = txtMat(text, colorHex, true, 'chord'); + if (s.material.map !== m.map) { s.material.map = m.map; s.material.needsUpdate = true; } + s.material.opacity = opacity; + s.position.set(baseX, hy, z); + s.scale.set(hlW, hlH, 1); + hy += hlH; + }; + _drawHarmony(_h.rn, '#ffcc66'); // sd teaching color + _drawHarmony(_h.voicing, '#7fd1ff'); // fg teaching color + } + } + // Shape-based barre detection for the 3D indicator. // Drives off chord notes alone — independent of label // availability, so charts whose chordTemplates lack a @@ -11358,6 +11389,15 @@ if (!Number.isInteger(sd) || sd < 0 || sd > 11) return ''; return String(sd); } + /** Harmony annotations (§6.3.1 / §6.6): display labels for a chord's + * function (instance `fn.rn` Roman numeral) and template `voicing` + * string. '' for each when absent/malformed. Pure; shared with the 2D + * highway and node-tested. Display only — never grading. */ + function chordHarmonyLabels(fn, voicing) { + const rn = (fn && typeof fn.rn === 'string') ? fn.rn.trim() : ''; + const vc = (typeof voicing === 'string') ? voicing.trim() : ''; + return { rn, voicing: vc }; + } function bnvSampleAt(bnv, t) { // Linear interpolation of a bend curve [{t, v}] (§6.2.1; t is diff --git a/static/highway.js b/static/highway.js index 13fbb1e..83e4a7b 100644 --- a/static/highway.js +++ b/static/highway.js @@ -509,6 +509,17 @@ function createHighway() { return String(sd); } + /** Harmony annotations (§6.3.1 / §6.6): display labels for a chord's + * harmonic function (the instance `fn.rn` Roman numeral) and its template + * `voicing` string. Returns '' for each when absent or malformed. Pure; + * node-tested and shared by both highways. Display/teaching only — MUST + * NEVER feed a grader (honesty rule). */ + function chordHarmonyLabels(fn, voicing) { + const rn = (fn && typeof fn.rn === 'string') ? fn.rn.trim() : ''; + const vc = (typeof voicing === 'string') ? voicing.trim() : ''; + return { rn, voicing: vc }; + } + /** Teaching mark (§6.2.2): bucket drawn notes by their strum-group key `ch`. * Returns the groups (in first-seen order) for each ch value >= 0 that has * at least two members — a lone note is not a strum gesture. Pure; drives @@ -2200,6 +2211,39 @@ function createHighway() { fillTextReadable(tmpl.name, labelX, labelY); } + // Harmony annotations (§6.3.1 / §6.6) — the chord's function + // (fn.rn Roman numeral) and template voicing, stacked above the + // chord name. Gated behind the teaching-marks opt-in (same overlay + // class as sd/ch) so they don't clutter the default highway. + // Display only — never grading. + if (_showTeachingMarks && !ch.hd && p.scale > 0.15 && sorted.length > 0) { + const { rn, voicing } = chordHarmonyLabels(ch.fn, tmpl && tmpl.voicing); + if (rn || voicing) { + const hx = hasNonZero + ? (xMin + xMax) / 2 + : (sorted.length >= 2 + ? (fretX(frameLeftFret, p.scale, W) + fretX(frameRightFret, p.scale, W)) / 2 + : fretX(sorted[0].f, p.scale, W)); + // Baseline = just above where the chord name sits. + const nameY = hasNonZero + ? (p.y * H - actualTotalH / 2 - sz * 0.7 - sz * 0.4) + : (p.y * H - sz * 0.8); + ctx.font = `bold ${Math.max(10, sz * 0.32) | 0}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + let stackY = nameY - sz * 0.5; + if (rn) { + ctx.fillStyle = '#ffcc66'; // matches the sd teaching color + fillTextReadable(rn, hx, stackY); + stackY -= sz * 0.45; + } + if (voicing) { + ctx.fillStyle = '#7fd1ff'; // matches the fg teaching color + fillTextReadable(voicing, hx, stackY); + } + } + } + // Notes — wide colored bar for open strings inside a chord, // normal note glyph otherwise. // Classify into bent / unbent arrays inline (was: post-filter diff --git a/tests/js/highway_chord_harmony.test.js b/tests/js/highway_chord_harmony.test.js new file mode 100644 index 0000000..c522988 --- /dev/null +++ b/tests/js/highway_chord_harmony.test.js @@ -0,0 +1,49 @@ +// Behavioural tests for the chord harmony-annotation render helper +// chordHarmonyLabels (§6.3.1 / §6.6), shared by the 2D and 3D highways. +// Pure, so we extract the function source by brace-matching and eval it in +// isolation — same pattern as highway_teaching_marks.test.js. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +function extractFn(src, name) { + const start = src.indexOf('function ' + name); + assert.ok(start >= 0, `function ${name} must exist`); + const open = src.indexOf('{', start); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1); + } + throw new Error(`unbalanced braces extracting ${name}`); +} + +function loadFn(file, name) { + const src = fs.readFileSync(path.join(__dirname, '..', '..', file), 'utf8'); + return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)(); +} + +const labels2D = loadFn('static/highway.js', 'chordHarmonyLabels'); +const labels3D = loadFn('plugins/highway_3d/screen.js', 'chordHarmonyLabels'); + +for (const [name, fn] of [['2D', labels2D], ['3D', labels3D]]) { + test(`chordHarmonyLabels (${name}) surfaces rn + voicing`, () => { + assert.deepEqual(fn({ rn: 'ii7', q: 'm7', deg: 2 }, 'open'), + { rn: 'ii7', voicing: 'open' }); + }); + + test(`chordHarmonyLabels (${name}) trims whitespace`, () => { + assert.deepEqual(fn({ rn: ' V7 ' }, ' drop2 '), + { rn: 'V7', voicing: 'drop2' }); + }); + + test(`chordHarmonyLabels (${name}) empties absent / malformed inputs`, () => { + assert.deepEqual(fn(null, undefined), { rn: '', voicing: '' }); + assert.deepEqual(fn({}, ''), { rn: '', voicing: '' }); + assert.deepEqual(fn({ rn: 7 }, 7), { rn: '', voicing: '' }); // non-string + assert.deepEqual(fn(undefined, 'shell'), { rn: '', voicing: 'shell' }); + assert.deepEqual(fn({ rn: 'vi' }, null), { rn: 'vi', voicing: '' }); + }); +} From 4195b738770048984e74773be654bf704969891a Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sun, 21 Jun 2026 11:57:21 +0200 Subject: [PATCH 10/99] =?UTF-8?q?feat(song):=20wire=20caged=20+=20guideTon?= =?UTF-8?q?es=20chord-template=20fields=20(=C2=A76.6)=20(#544)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the voicing field for the two deferred FEP #24 harmony annotations on ChordTemplate: - caged: str ("C"/"A"/"G"/"E"/"D", "" = unset) - guideTones: list[int] (semitone offsets 0..11 above the root, [] = unset) Both are default-omitted on the wire and sanitized on decode (caged enum-guarded, guideTones filtered to in-range ints, rejecting bool) so a malformed value can't round-trip. GP import is untouched — GP carries no CAGED / guide-tone data. Teaching annotations only; never fed to a grader. Co-authored-by: Claude Opus 4.8 (1M context) --- lib/song.py | 37 ++++++++++++++++++++++++- tests/test_song.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/lib/song.py b/lib/song.py index a1ba100..5d80027 100644 --- a/lib/song.py +++ b/lib/song.py @@ -68,6 +68,14 @@ class ChordTemplate: # Harmony annotation (§6.6) — key-independent voicing type, e.g. "open", # "triad", "shell", "drop2", "barre". Display/teaching only, never grading. voicing: str = "" + # Harmony annotation (§6.6) — the CAGED shape the fingering derives from, + # one of "C"/"A"/"G"/"E"/"D" ("" = unset). Display/teaching only, never grading. + caged: str = "" + # Harmony annotation (§6.6) — chromatic semitone offsets 0..11 above the + # chord root marking the quality-defining tones (e.g. dom7 -> [4, 10]). + # snake_case attr; rides the wire as camelCase "guideTones" (like + # display_name -> "displayName"). Display/teaching only, never grading. + guide_tones: list = field(default_factory=list) @dataclass @@ -319,9 +327,34 @@ def chord_template_to_wire(ct: ChordTemplate) -> dict: # Harmony voicing (§6.6) — default-omitted, only when non-empty. if ct.voicing: out["voicing"] = ct.voicing + # CAGED shape + guide tones (§6.6) — default-omitted, mirroring voicing. + if ct.caged: + out["caged"] = ct.caged + if ct.guide_tones: + out["guideTones"] = list(ct.guide_tones) return out +# §6.6 CAGED shape enum — the only values accepted off the wire. +_CAGED_SHAPES = ("C", "A", "G", "E", "D") + + +def _sanitize_caged(val) -> str: + """A wire `caged` is kept only when it is one of the CAGED shape letters; + anything else (None, int, list, unknown string) falls back to "".""" + return val if isinstance(val, str) and val in _CAGED_SHAPES else "" + + +def _sanitize_guide_tones(val) -> list: + """A wire `guideTones` is kept only as the int entries in 0..11; non-list + input, non-ints (bool is an int subclass — rejected), and out-of-range + values are dropped so a malformed value can't round-trip.""" + if not isinstance(val, list): + return [] + return [v for v in val + if isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 11] + + def _wire_int_optional(v, default=-1): """Parse optional wire ints; fall back to default on null/blank/invalid.""" if v is None: @@ -883,7 +916,9 @@ def arrangement_from_wire(d: dict) -> Arrangement: fingers=list(ct.get("fingers", [-1] * 6)), frets=list(ct.get("frets", [-1] * 6)), voicing=(ct.get("voicing") - if isinstance(ct.get("voicing"), str) else "")) + if isinstance(ct.get("voicing"), str) else ""), + caged=_sanitize_caged(ct.get("caged")), + guide_tones=_sanitize_guide_tones(ct.get("guideTones"))) for ct in d.get("templates", []) ], # `phrases` is optional — absent on single-level sources / older diff --git a/tests/test_song.py b/tests/test_song.py index 7f2da95..92af496 100644 --- a/tests/test_song.py +++ b/tests/test_song.py @@ -485,6 +485,73 @@ def test_template_voicing_tolerates_malformed(bad): assert arr.chord_templates[0].voicing == "" +def test_template_caged_round_trip(): + """A valid CAGED shape survives the template wire + arrangement round-trip.""" + ct = ChordTemplate(name="Am", display_name="Am", fingers=[-1, 0, 2, 2, 1, 0], + frets=[-1, 0, 2, 2, 1, 0], caged="E") + assert chord_template_to_wire(ct)["caged"] == "E" + arr = Arrangement(name="Rhythm", chord_templates=[ct]) + assert arrangement_from_wire(arrangement_to_wire(arr)).chord_templates[0] == ct + + +def test_template_caged_omitted_when_default(): + """An empty caged (the default) produces no `caged` key.""" + ct = ChordTemplate(name="Am", fingers=[-1] * 6, frets=[-1] * 6) + assert "caged" not in chord_template_to_wire(ct) + arr = arrangement_from_wire(arrangement_to_wire( + Arrangement(name="Rhythm", chord_templates=[ct]))) + assert arr.chord_templates[0].caged == "" + + +@pytest.mark.parametrize("bad", [None, 7, "X", "e", "", ["E"], {"c": "E"}]) +def test_template_caged_tolerates_malformed(bad): + """A non-enum caged on the wire falls back to the empty default.""" + arr = arrangement_from_wire({ + "name": "Rhythm", + "templates": [{"name": "Am", "fingers": [-1] * 6, "frets": [-1] * 6, + "caged": bad}], + }) + assert arr.chord_templates[0].caged == "" + + +def test_template_guide_tones_round_trip(): + """A non-empty guideTones list survives the template wire + round-trip.""" + ct = ChordTemplate(name="G7", display_name="G7", fingers=[3, 2, 0, 0, 0, 1], + frets=[3, 2, 0, 0, 0, 1], guide_tones=[4, 10]) + assert chord_template_to_wire(ct)["guideTones"] == [4, 10] + arr = Arrangement(name="Rhythm", chord_templates=[ct]) + assert arrangement_from_wire(arrangement_to_wire(arr)).chord_templates[0] == ct + + +def test_template_guide_tones_omitted_when_default(): + """An empty guide_tones (the default) produces no `guideTones` key.""" + ct = ChordTemplate(name="Am", fingers=[-1] * 6, frets=[-1] * 6) + assert "guideTones" not in chord_template_to_wire(ct) + arr = arrangement_from_wire(arrangement_to_wire( + Arrangement(name="Rhythm", chord_templates=[ct]))) + assert arr.chord_templates[0].guide_tones == [] + + +@pytest.mark.parametrize("raw,expected", [ + (None, []), + (12, []), + ("4,10", []), + ([12], []), + ([-1], []), + ([True, 3], [3]), # bool is an int subclass — rejected + ([4, "x", 10, 11], [4, 10, 11]), + ([0, 11], [0, 11]), # boundary values kept +]) +def test_template_guide_tones_tolerates_malformed(raw, expected): + """Non-int / out-of-range guideTones entries are dropped off the wire.""" + arr = arrangement_from_wire({ + "name": "Rhythm", + "templates": [{"name": "Am", "fingers": [-1] * 6, "frets": [-1] * 6, + "guideTones": raw}], + }) + assert arr.chord_templates[0].guide_tones == expected + + # ── Arrangement round-trip ─────────────────────────────────────────────────── def test_arrangement_empty_round_trip(): From e518910baa7963409c1b9773bcfec0c1300d4816 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sun, 21 Jun 2026 11:58:00 +0200 Subject: [PATCH 11/99] =?UTF-8?q?feat(highway):=20render=20caged=20+=20gui?= =?UTF-8?q?deTones=20teaching=20labels=20(=C2=A76.6)=20(#545)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the voicing/fn.rn teaching-mark render for the two new chord-template fields, in both the 2D and 3D highways: - Extend the shared pure chordHarmonyLabels() helper (identical in static/highway.js and plugins/highway_3d/screen.js) to also surface caged ("CAGED: E") and guideTones ("gt 4,10"), pre-formatted and node-testable. Invalid caged enum and out-of-range / non-int guide tones are filtered out. - Draw both, stacked above the existing rn/voicing labels, in distinct colors. - Gated behind the SAME teaching-marks toggle (_showTeachingMarks 2D / teachingMarksVisible 3D) — no clutter on the default highway. Render only — no scoring / NoteVerifier coupling. Co-authored-by: Claude Opus 4.8 (1M context) --- plugins/highway_3d/screen.js | 27 +++++++++++------ static/highway.js | 30 ++++++++++++++----- tests/js/highway_chord_harmony.test.js | 41 +++++++++++++++++++------- 3 files changed, 72 insertions(+), 26 deletions(-) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 0cfd780..d533120 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -10302,8 +10302,9 @@ // teaching-marks opt-in (mirrors the 2D overlay). Display // only — never grading. if (_drawTeachingMarks && firstInShapeRun && !chordWireHighDensity(ch)) { - const _h = chordHarmonyLabels(ch.fn, bundle.chordTemplates?.[ch.id]?.voicing); - if (_h.rn || _h.voicing) { + const _tmpl = bundle.chordTemplates?.[ch.id]; + const _h = chordHarmonyLabels(ch.fn, _tmpl?.voicing, _tmpl?.caged, _tmpl?.guideTones); + if (_h.rn || _h.voicing || _h.caged || _h.guideTones) { const hlW = 24 * K * _textSizeMul; const hlH = 9 * K * _textSizeMul; const frameLeft = cx - width / 2; @@ -10322,8 +10323,10 @@ s.scale.set(hlW, hlH, 1); hy += hlH; }; - _drawHarmony(_h.rn, '#ffcc66'); // sd teaching color - _drawHarmony(_h.voicing, '#7fd1ff'); // fg teaching color + _drawHarmony(_h.rn, '#ffcc66'); // sd teaching color + _drawHarmony(_h.voicing, '#7fd1ff'); // fg teaching color + _drawHarmony(_h.caged, '#a0ffa0'); // CAGED shape teaching color + _drawHarmony(_h.guideTones, '#d0a0ff'); // guide-tone teaching color } } @@ -11390,13 +11393,19 @@ return String(sd); } /** Harmony annotations (§6.3.1 / §6.6): display labels for a chord's - * function (instance `fn.rn` Roman numeral) and template `voicing` - * string. '' for each when absent/malformed. Pure; shared with the 2D - * highway and node-tested. Display only — never grading. */ - function chordHarmonyLabels(fn, voicing) { + * function (instance `fn.rn` Roman numeral) and template `voicing`, + * `caged` shape, and `guideTones`. '' for each when absent/malformed; + * `caged`/`guideTones` come back pre-formatted ("CAGED: E" / "gt 4,10"). + * Pure; shared with the 2D highway and node-tested. Display only — never + * grading. */ + function chordHarmonyLabels(fn, voicing, caged, guideTones) { const rn = (fn && typeof fn.rn === 'string') ? fn.rn.trim() : ''; const vc = (typeof voicing === 'string') ? voicing.trim() : ''; - return { rn, voicing: vc }; + const cg = (typeof caged === 'string' && /^[CAGED]$/.test(caged.trim())) + ? 'CAGED: ' + caged.trim() : ''; + const gt = Array.isArray(guideTones) + ? guideTones.filter(n => Number.isInteger(n) && n >= 0 && n <= 11) : []; + return { rn, voicing: vc, caged: cg, guideTones: gt.length ? 'gt ' + gt.join(',') : '' }; } function bnvSampleAt(bnv, t) { diff --git a/static/highway.js b/static/highway.js index 83e4a7b..edadca4 100644 --- a/static/highway.js +++ b/static/highway.js @@ -511,13 +511,18 @@ function createHighway() { /** Harmony annotations (§6.3.1 / §6.6): display labels for a chord's * harmonic function (the instance `fn.rn` Roman numeral) and its template - * `voicing` string. Returns '' for each when absent or malformed. Pure; - * node-tested and shared by both highways. Display/teaching only — MUST - * NEVER feed a grader (honesty rule). */ - function chordHarmonyLabels(fn, voicing) { + * `voicing`, `caged` shape, and `guideTones`. Returns '' for each when + * absent or malformed; `caged`/`guideTones` come back pre-formatted + * ("CAGED: E" / "gt 4,10"). Pure; node-tested and shared by both highways. + * Display/teaching only — MUST NEVER feed a grader (honesty rule). */ + function chordHarmonyLabels(fn, voicing, caged, guideTones) { const rn = (fn && typeof fn.rn === 'string') ? fn.rn.trim() : ''; const vc = (typeof voicing === 'string') ? voicing.trim() : ''; - return { rn, voicing: vc }; + const cg = (typeof caged === 'string' && /^[CAGED]$/.test(caged.trim())) + ? 'CAGED: ' + caged.trim() : ''; + const gt = Array.isArray(guideTones) + ? guideTones.filter(n => Number.isInteger(n) && n >= 0 && n <= 11) : []; + return { rn, voicing: vc, caged: cg, guideTones: gt.length ? 'gt ' + gt.join(',') : '' }; } /** Teaching mark (§6.2.2): bucket drawn notes by their strum-group key `ch`. @@ -2217,8 +2222,9 @@ function createHighway() { // class as sd/ch) so they don't clutter the default highway. // Display only — never grading. if (_showTeachingMarks && !ch.hd && p.scale > 0.15 && sorted.length > 0) { - const { rn, voicing } = chordHarmonyLabels(ch.fn, tmpl && tmpl.voicing); - if (rn || voicing) { + const { rn, voicing, caged, guideTones } = chordHarmonyLabels( + ch.fn, tmpl && tmpl.voicing, tmpl && tmpl.caged, tmpl && tmpl.guideTones); + if (rn || voicing || caged || guideTones) { const hx = hasNonZero ? (xMin + xMax) / 2 : (sorted.length >= 2 @@ -2240,6 +2246,16 @@ function createHighway() { if (voicing) { ctx.fillStyle = '#7fd1ff'; // matches the fg teaching color fillTextReadable(voicing, hx, stackY); + stackY -= sz * 0.45; + } + if (caged) { + ctx.fillStyle = '#a0ffa0'; // CAGED shape teaching color + fillTextReadable(caged, hx, stackY); + stackY -= sz * 0.45; + } + if (guideTones) { + ctx.fillStyle = '#d0a0ff'; // guide-tone teaching color + fillTextReadable(guideTones, hx, stackY); } } } diff --git a/tests/js/highway_chord_harmony.test.js b/tests/js/highway_chord_harmony.test.js index c522988..80f1048 100644 --- a/tests/js/highway_chord_harmony.test.js +++ b/tests/js/highway_chord_harmony.test.js @@ -29,21 +29,42 @@ const labels2D = loadFn('static/highway.js', 'chordHarmonyLabels'); const labels3D = loadFn('plugins/highway_3d/screen.js', 'chordHarmonyLabels'); for (const [name, fn] of [['2D', labels2D], ['3D', labels3D]]) { - test(`chordHarmonyLabels (${name}) surfaces rn + voicing`, () => { - assert.deepEqual(fn({ rn: 'ii7', q: 'm7', deg: 2 }, 'open'), - { rn: 'ii7', voicing: 'open' }); + test(`chordHarmonyLabels (${name}) surfaces rn + voicing + caged + guideTones`, () => { + assert.deepEqual(fn({ rn: 'ii7', q: 'm7', deg: 2 }, 'open', 'E', [4, 10]), + { rn: 'ii7', voicing: 'open', caged: 'CAGED: E', guideTones: 'gt 4,10' }); }); test(`chordHarmonyLabels (${name}) trims whitespace`, () => { - assert.deepEqual(fn({ rn: ' V7 ' }, ' drop2 '), - { rn: 'V7', voicing: 'drop2' }); + assert.deepEqual(fn({ rn: ' V7 ' }, ' drop2 ', ' G ', []), + { rn: 'V7', voicing: 'drop2', caged: 'CAGED: G', guideTones: '' }); }); test(`chordHarmonyLabels (${name}) empties absent / malformed inputs`, () => { - assert.deepEqual(fn(null, undefined), { rn: '', voicing: '' }); - assert.deepEqual(fn({}, ''), { rn: '', voicing: '' }); - assert.deepEqual(fn({ rn: 7 }, 7), { rn: '', voicing: '' }); // non-string - assert.deepEqual(fn(undefined, 'shell'), { rn: '', voicing: 'shell' }); - assert.deepEqual(fn({ rn: 'vi' }, null), { rn: 'vi', voicing: '' }); + assert.deepEqual(fn(null, undefined), + { rn: '', voicing: '', caged: '', guideTones: '' }); + assert.deepEqual(fn({}, ''), + { rn: '', voicing: '', caged: '', guideTones: '' }); + assert.deepEqual(fn({ rn: 7 }, 7), // non-string + { rn: '', voicing: '', caged: '', guideTones: '' }); + assert.deepEqual(fn(undefined, 'shell'), + { rn: '', voicing: 'shell', caged: '', guideTones: '' }); + assert.deepEqual(fn({ rn: 'vi' }, null), + { rn: 'vi', voicing: '', caged: '', guideTones: '' }); + }); + + test(`chordHarmonyLabels (${name}) rejects invalid caged enum`, () => { + assert.equal(fn(null, null, 'X').caged, ''); // not a CAGED letter + assert.equal(fn(null, null, 'e').caged, ''); // lower-case rejected + assert.equal(fn(null, null, 7).caged, ''); // non-string + assert.equal(fn(null, null, ['E']).caged, ''); // non-string + assert.equal(fn(null, null, 'C').caged, 'CAGED: C'); + }); + + test(`chordHarmonyLabels (${name}) filters out-of-range / non-int guide tones`, () => { + assert.equal(fn(null, null, '', [12, -1, 3, 'x', 10]).guideTones, 'gt 3,10'); + assert.equal(fn(null, null, '', [0, 11]).guideTones, 'gt 0,11'); // boundaries kept + assert.equal(fn(null, null, '', []).guideTones, ''); + assert.equal(fn(null, null, '', '4,10').guideTones, ''); // non-array + assert.equal(fn(null, null, '', [12, -1]).guideTones, ''); // all dropped }); } From 73e3fe2226fcb323a7eb44047f21487297cf7bf2 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sun, 21 Jun 2026 12:04:08 +0200 Subject: [PATCH 12/99] fix(song): sanitize caged + guideTones on emit, not just decode (#544 follow-up) (#547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge Codex review of #544 found chord_template_to_wire emitted ct.caged and ct.guide_tones raw — so a directly-constructed ChordTemplate(caged="X") or guide_tones=[99] would write a schema-invalid value to the feedpak wire, even though the decoder guards on input. The spec constrains caged to C/A/G/E/D and guideTones to 0..11. Run the same _sanitize_caged / _sanitize_guide_tones guards on emit: caged is written only when a valid enum value, guideTones only as the in-range ints (empty result -> key omitted). +1 test (invalid caged dropped, mixed guideTones filtered to the valid in-range subset, wholly-invalid list omitted). Codex-reviewed: clean. 154 song tests pass. Part of got-feedback/feedback#334. Co-authored-by: Claude Opus 4.8 (1M context) --- lib/song.py | 13 +++++++++---- tests/test_song.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/lib/song.py b/lib/song.py index 5d80027..ce3b859 100644 --- a/lib/song.py +++ b/lib/song.py @@ -328,10 +328,15 @@ def chord_template_to_wire(ct: ChordTemplate) -> dict: if ct.voicing: out["voicing"] = ct.voicing # CAGED shape + guide tones (§6.6) — default-omitted, mirroring voicing. - if ct.caged: - out["caged"] = ct.caged - if ct.guide_tones: - out["guideTones"] = list(ct.guide_tones) + # Sanitize on EMIT too (not just on decode): a directly-constructed template + # must not be able to write a non-enum `caged` or an out-of-range `guideTone` + # to the wire (the spec constrains caged to C/A/G/E/D and guideTones to 0..11). + _caged = _sanitize_caged(ct.caged) + if _caged: + out["caged"] = _caged + _guide_tones = _sanitize_guide_tones(ct.guide_tones) + if _guide_tones: + out["guideTones"] = _guide_tones return out diff --git a/tests/test_song.py b/tests/test_song.py index 92af496..b98cae0 100644 --- a/tests/test_song.py +++ b/tests/test_song.py @@ -514,6 +514,20 @@ def test_template_caged_tolerates_malformed(bad): assert arr.chord_templates[0].caged == "" +def test_template_caged_guide_tones_sanitized_on_emit(): + """A directly-constructed template can't write an invalid caged / out-of-range + guideTone to the wire — the emitter sanitizes, not just the decoder.""" + ct = ChordTemplate(name="Am", fingers=[-1] * 6, frets=[-1] * 6, + caged="X", guide_tones=[3, 99, -1, "x", True]) + wire = chord_template_to_wire(ct) + assert "caged" not in wire # non-enum dropped, not emitted + assert wire["guideTones"] == [3] # only the valid in-range int survives + # A wholly-invalid guideTones list omits the key entirely. + ct2 = ChordTemplate(name="Am", fingers=[-1] * 6, frets=[-1] * 6, + guide_tones=[42, "nope"]) + assert "guideTones" not in chord_template_to_wire(ct2) + + def test_template_guide_tones_round_trip(): """A non-empty guideTones list survives the template wire + round-trip.""" ct = ChordTemplate(name="G7", display_name="G7", fingers=[3, 2, 0, 0, 0, 1], From 9f35fedeefa151ec3f66b689d4f0f6b96b5d6040 Mon Sep 17 00:00:00 2001 From: "K. O. A." Date: Sun, 21 Jun 2026 07:15:28 -0400 Subject: [PATCH 13/99] Promote the editor to a first-class v3 sidebar item (#546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Arrangement Editor plugin was only reachable via the generic Plugins gallery. Give it a dedicated sidebar entry (Library group, below Songs) through the existing PROMOTED_PLUGINS mechanism in shell.js — a NAV entry, a promoted slot anchored after "songs", and an edit icon. renderPromotedNav already gates each promoted slot on the plugin being present in /api/plugins, so the entry shows only when the editor is installed. The displayed label comes from the plugin manifest's nav.label. Signed-off-by: topkoa Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 8 ++++++++ static/v3/shell.js | 3 +++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d2eb62..ec82d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **"Song Editor" promoted to a first-class v3 sidebar item.** The editor + plugin (`id: editor`) now gets its own dedicated sidebar entry — under the + Library group, just below Songs — via the existing `PROMOTED_PLUGINS` + mechanism in `static/v3/shell.js`, instead of being reachable only through + the generic Plugins gallery. Gated on the plugin actually being installed + (`renderPromotedNav` checks `/api/plugins`), so it appears only when the + editor is loaded. The displayed label comes from the plugin's manifest + `nav.label`. - **Guitar Pro → notation importer (`lib/gp2notation.py`)** (slopsmith#825 WS4b, epic #828). Piano/keys tracks imported from Guitar Pro (GPIF: `.gpx` GP6 / `.gp` GP7-8) now produce real Sloppak Notation Format data (sloppak-spec §5.3) alongside the `midi = string*24 + fret` guitar wire encoding. `gp2rs_gpx.convert_file` writes a `.notation.json` sidecar next to each keys arrangement XML (best-effort — a notation bug never breaks the RS-XML conversion), and `gp2notation.attach_notation_to_sloppak()` is the assembly-side helper that renames it into `notation_.json` + adds the per-arrangement `notation:` manifest sub-key. Voice→staff routing salvages the logic from PR #703 (whose `stf` wire-field approach this supersedes): GP voice position 0 → `rh` staff (`G2`), positions ≥ 1 → `lh` (`F4`); a forced-LH track (the merged `Piano LH` partner from `_find_piano_pairs`, or a standalone track named `… LH`) routes everything to `lh` — preserving authored hand crossings instead of inferring hands from pitch. Emits measures with absolute `t` from the bar-indexed tempo map, change-only `ts`/`tempo`/`ks`, and `beat_groups` for compound/irregular meters (6/8 → `[3,3]`, 9/8 → `[3,3,3]`, 5/8 → `[2,3]`, 7/8 → `[2,2,3]` — cf. the slopsmith#261 denominator pitfalls); beats carry `dur`/`dot`/`tu`/`rest` from GP rhythms and notes carry absolute `midi` (String+Fret resolves via the string template's concert pitches, Tone+Octave via `(octave+1)*12 + step`) with `tied` continuations kept as real beats (engraving needs the tied notehead — unlike the RS-XML walk, which drops them and extends sustain). Timing reuses the `gp2rs_gpx` machinery (bar-indexed tempo map, per-beat rhythm durations, `_note_midi`) so notation lines up with the RS XML the highway plays — with one deliberate divergence: double dots advance time ×1.75 (vs. the RS-XML walk's single-dot ×1.5 approximation) so a written `dot: 2` agrees with the emitted beat times; sharing the walk itself stays tracked in slopsmith#618. Tests: `tests/test_gp2notation.py`. - **Legacy keys → notation lifter (`scripts/lift_keys_notation.py`)** (slopsmith#825 WS4c, epic #828). One-time batch converter that lifts existing **directory-form** piano/keys sloppaks from the legacy guitar wire encoding (`midi = s*24 + f`) into real Sloppak Notation Format files (sloppak-spec §5.3). Candidates are arrangements whose name matches `\b(keys|piano|keyboard|synth)\b` (case-insensitive); each gets a `notation_.json` plus the per-arrangement `notation:` manifest sub-key. Measures derive from the song-level `beats` downbeats (`measure >= 0`; `song_timeline.json` preferred, first-arrangement fallback), with per-measure tempo from downbeat spacing (emitted only on a > 1 BPM change). Durations come from the wire sustain (`sus`, legacy `l` alias) when present, else the gap to the next onset in the same hand — quantized to the nearest plain/single-dotted `{1,2,4,8,16,32}` denominator at the local tempo, floored at a 32nd. Hands are split heuristically: onsets within 10 ms form a group; a group spanning > 12 semitones splits at its largest internal interval gap (low side → `lh`), otherwise the whole group goes by mean pitch vs middle C — single-staff output when everything lands on one hand. Idempotent (arrangements already carrying `notation:` are skipped; an orphan `notation_.json` without the manifest key is refused, not overwritten) with `--dry-run` support; every payload is checked via `notation.validate_notation` before write. Honest caveat: the manifest is round-tripped through PyYAML (`safe_load` + `safe_dump(sort_keys=False)`) — key order survives, YAML comments/custom formatting do not (the script warns when comments are present). Zip-form `.sloppak` files are reported and skipped. Tests: `tests/test_lift_keys_notation.py`. - **Notation schema v1 freeze — completeness batch** (slopsmith#822, epic #828). Adds the low-hanging-fruit fields ahead of content production: top-level credits `rights`/`lyricist`/`arranger`; measure `pickup` (anacrusis); beat `arp` (arpeggiate), `ferm` (fermata), and **typed grace notes** — `grace: "a"` (acciaccatura, MusicXML `grace/@slash=yes`) / `"p"` (appoggiatura); note `stem` (`"up"`/`"down"` force). Pedal is settled as the existing `spd`/`sph`/`spu` trio with a documented MusicXML `` mapping — no separate `ped` field. A new "v1 non-features" spec subsection pins the accepted limitations (microtonal, figured bass, mid-measure key/time/clef changes, `ott`/`barline`/ornaments/`trem`/`glis`) as additive-v1.x territory. `lib/notation.py` gains the `GRACE_TYPES`, `STEM_DIRECTIONS`, and `DYNAMICS` vocabularies; the validator stays permissive by design. diff --git a/static/v3/shell.js b/static/v3/shell.js index 110c72f..f6ba206 100644 --- a/static/v3/shell.js +++ b/static/v3/shell.js @@ -42,6 +42,7 @@ // plugin- guard applies. { key: 'slopscale', screen: 'plugin-slopscale', label: 'SlopScale - Practice', group: null, icon: 'target' }, { key: 'rig_builder', screen: 'plugin-rig_builder', label: 'Rig Builder', group: null, icon: 'amp' }, + { key: 'editor', screen: 'plugin-editor', label: 'Song Editor', group: null, icon: 'edit' }, // Not in the sidebar groups, but routable (profile badge → here). { key: 'profile', screen: 'v3-profile', label: 'Profile', group: null, icon: 'user' }, ]; @@ -52,6 +53,7 @@ const PROMOTED_PLUGINS = [ { navKey: 'slopscale', pluginId: 'slopscale', slotId: 'v3-nav-slopscale', anchorAfter: 'feedbarcade' }, { navKey: 'rig_builder', pluginId: 'rig_builder', slotId: 'v3-nav-rig-builder', anchorAfter: 'saved' }, + { navKey: 'editor', pluginId: 'editor', slotId: 'v3-nav-editor', anchorAfter: 'songs' }, ]; const TOPBAR_KEYS = ['home', 'songs', 'plugins', 'settings']; const SIDEBAR_GROUPS = ['HOME', 'LIBRARY']; @@ -72,6 +74,7 @@ tag: 'M20.6 13.4l-7.2 7.2a2 2 0 01-2.8 0l-7-7V4h9.6l7.4 7.4a2 2 0 010 2zM7.5 7.5h.01', amp: 'M4 5h16a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V6a1 1 0 011-1zm11 4a3 3 0 100 6 3 3 0 000-6zM6.5 8.5h.01M9 8.5h.01', target: 'M12 3a9 9 0 100 18 9 9 0 000-18zm0 4a5 5 0 100 10 5 5 0 000-10zm0 4a1 1 0 100 2 1 1 0 000-2z', + edit: 'M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7M18.5 2.5a2.1 2.1 0 013 3L12 15l-4 1 1-4 9.5-9.5z', }; function iconSvg(name) { const d = ICONS[name] || ICONS.disc; From a0867f8bfd1b34d3f413a28619bb67c6c8d549d5 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sun, 21 Jun 2026 20:55:02 +0200 Subject: [PATCH 14/99] fix(profile): wire "Your best scores" panel to real song stats (#549) (#550) The profile card's "Your best scores" panel was a hardcoded placeholder (`#v3-profile-bests` was never filled), so it always read "Play a song to start tracking..." regardless of how many songs had been scored. The backend already records best_score/best_accuracy per song; only this panel was left unwired. - server.py: add MetadataDB.top_stats(limit) (per-song aggregate, best score first, scored songs only, dead songs skipped) + /api/stats/top route that enriches rows with title/artist/art, mirroring /api/stats/recent. Declared before the /api/stats/{filename} catch-all. - static/v3/profile.js: renderBests() fetches /api/stats/top and fills the panel (rank, title/artist, best accuracy %, score; click to play), keeping the placeholder only when nothing's been scored. - tests: cover ordering, per-song aggregation, limit, and resume-only/dead-song exclusion. Co-authored-by: Claude Opus 4.8 (1M context) --- server.py | 41 ++++++++++++++++++++++++++++++++++ static/v3/profile.js | 43 ++++++++++++++++++++++++++++++++++-- tests/test_song_stats_api.py | 34 ++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/server.py b/server.py index d387986..b93aacd 100644 --- a/server.py +++ b/server.py @@ -1180,6 +1180,25 @@ class MetadataDB: ).fetchall() return {r[0]: r[1] for r in rows if r[2] and r[2] > 0} + def top_stats(self, limit: int = 5) -> list[dict]: + """Top scored songs (best score first) for the profile 'Your best + scores' panel. Aggregated per-song across arrangements (best score, + best accuracy, total plays), only SCORED songs (plays > 0), dead songs + skipped. Mirrors best_accuracy_map's grouping; enriched with metadata + by the /api/stats/top route.""" + limit = max(1, min(50, int(limit))) + rows = self.conn.execute( + "SELECT filename, MAX(best_score), MAX(best_accuracy), SUM(plays) " + "FROM song_stats WHERE 1=1 " + self._existing_song_filter() + # skip dead songs + "GROUP BY filename HAVING SUM(plays) > 0 " + "ORDER BY MAX(best_score) DESC, MAX(best_accuracy) DESC LIMIT ?", + (limit,), + ).fetchall() + return [ + {"filename": r[0], "best_score": r[1], "best_accuracy": r[2], "plays": r[3]} + for r in rows + ] + # ── Playlists ─────────────────────────────────────────────────────────-- SAVED_KEY = "saved_for_later" @@ -5011,6 +5030,28 @@ def api_stats_best(): return meta_db.best_accuracy_map() +@app.get("/api/stats/top") +def api_top_stats(limit: int = 5): + """Top scored songs (best first), joined to song metadata, for the profile + 'Your best scores' panel (defined before the {filename} catch-all).""" + from urllib.parse import quote + out = [] + for r in meta_db.top_stats(limit): + meta = meta_db.conn.execute( + "SELECT title, artist, tuning_name FROM songs WHERE filename = ?", + (r["filename"],), + ).fetchone() + title, artist, tuning_name = meta if meta else (None, None, None) + out.append({ + **r, + "title": title or r["filename"], + "artist": artist or "", + "tuning_name": tuning_name or "", + "art_url": f"/api/song/{quote(r['filename'])}/art", + }) + return out + + @app.get("/api/stats/{filename:path}") def api_song_stats(filename: str): return meta_db.get_song_stats(filename) diff --git a/static/v3/profile.js b/static/v3/profile.js index 58bf2e7..935e0e2 100644 --- a/static/v3/profile.js +++ b/static/v3/profile.js @@ -129,10 +129,12 @@ '' + '' + '
' + - // Per-song bests (filled by prompt 14's song_stats; placeholder here) + // Per-song bests — top scored songs from /api/stats/top, filled by + // renderBests() after innerHTML is set. The placeholder text shows + // during load and when nothing's been scored yet. '
' + '

Your best scores

' + - '

Play a song to start tracking your accuracy and best scores.

' + + '
Play a song to start tracking your accuracy and best scores.
' + '
' + (_profile && _profile.player_hash ? '

player id ' + esc(_profile.player_hash.slice(0, 12)) + '

' @@ -145,6 +147,43 @@ if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') { window.v3Theme.applyFrame(root.querySelector('[data-v3-avatar-frame]')); } + renderBests(); + } + + // Fill the "Your best scores" panel from /api/stats/top (top scored songs, + // best first). Leaves the placeholder text in place on error / no scores so + // a fresh profile still reads sensibly. Accuracy is 0–1 (matches the library + // grid + dashboard badges). + async function renderBests() { + const host = document.getElementById('v3-profile-bests'); + if (!host) return; + let rows = []; + try { const r = await fetch('/api/stats/top?limit=5'); if (r.ok) rows = await r.json(); } catch (e) { /* P15 — keep placeholder */ } + if (!Array.isArray(rows) || !rows.length) return; // keep the placeholder text + const accColor = (a) => (a >= 0.9 ? 'text-fb-good' : a >= 0.5 ? 'text-fb-mid' : 'text-fb-low'); + host.innerHTML = + '
    ' + rows.map((s, i) => { + const acc = Number(s.best_accuracy) || 0; + const pct = Math.round(acc * 100); + const score = Number(s.best_score) || 0; + return '
  1. ' + + '' + (i + 1) + '' + + '' + + '' + esc(s.title || s.filename) + '' + + (s.artist ? '' + esc(s.artist) + '' : '') + + '' + + '' + + '' + pct + '%' + + '' + score.toLocaleString() + ' pts' + + '
  2. '; + }).join('') + '
'; + // Click a row to play that song (default arrangement). + host.querySelectorAll('[data-fn]').forEach((li) => { + li.addEventListener('click', () => { + const fn = li.getAttribute('data-fn'); + if (fn && typeof window.playSong === 'function') window.playSong(encodeURIComponent(fn)); + }); + }); } // ── First-run onboarding (and edit) overlay ─────────────────────────────── diff --git a/tests/test_song_stats_api.py b/tests/test_song_stats_api.py index 8d2c94f..f9690c5 100644 --- a/tests/test_song_stats_api.py +++ b/tests/test_song_stats_api.py @@ -104,6 +104,40 @@ def test_recent_orders_by_last_played(client, server): assert "art_url" in recent[0] and "title" in recent[0] +def test_top_orders_by_best_score_and_enriches(client, server): + # The profile "Your best scores" panel: top songs by best score, descending, + # joined to metadata. A worse-scored and a resume-only song must rank below / + # be excluded respectively. + server.meta_db.put("low.archive", 0, 0, {"title": "Low", "artist": "A"}) + server.meta_db.put("high.archive", 0, 0, {"title": "High", "artist": "B"}) + server.meta_db.put("resume.archive", 0, 0, {"title": "Resume"}) + client.post("/api/stats", json={"filename": "low.archive", "score": 100, "accuracy": 0.5}) + client.post("/api/stats", json={"filename": "high.archive", "score": 900, "accuracy": 0.95}) + client.post("/api/stats", json={"filename": "resume.archive", "lastPlayPosition": 12.0}) # plays==0 + top = client.get("/api/stats/top").json() + names = [r["filename"] for r in top] + assert names[:2] == ["high.archive", "low.archive"] # best score first + assert "resume.archive" not in names # unscored excluded + assert top[0]["title"] == "High" and "art_url" in top[0] + assert top[0]["best_score"] == 900 and top[0]["best_accuracy"] == pytest.approx(0.95) + + +def test_top_aggregates_arrangements_and_respects_limit(client, server): + # Per-song aggregation (best across arrangements) + the limit param. + server.meta_db.put("multi.archive", 0, 0, {"title": "Multi", + "arrangements": [{"name": "Lead"}, {"name": "Bass"}]}) + server.meta_db.put("solo.archive", 0, 0, {"title": "Solo"}) + client.post("/api/stats", json={"filename": "multi.archive", "arrangement": 0, "score": 200, "accuracy": 0.6}) + client.post("/api/stats", json={"filename": "multi.archive", "arrangement": 1, "score": 800, "accuracy": 0.9}) + client.post("/api/stats", json={"filename": "solo.archive", "score": 500, "accuracy": 0.7}) + top = client.get("/api/stats/top").json() + multi = next(r for r in top if r["filename"] == "multi.archive") + assert multi["best_score"] == 800 # best arrangement, not summed/duplicated + assert [r["filename"] for r in top].count("multi.archive") == 1 + # limit caps the list. + assert len(client.get("/api/stats/top?limit=1").json()) == 1 + + # ── Codex-preflight regressions (bad-input hardening + resume touch) ────────── def test_stats_rejects_non_finite_score_accuracy(client): From 63eb7a4ffcb7af1ad92c9e48457b8f9327a6b960 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sun, 21 Jun 2026 21:18:28 +0200 Subject: [PATCH 15/99] feat(progression): fancy notifications for quest/path progress + completion (#552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(progression): fancy notifications for quest/path progress + completion (#551) Surface achievement feedback as in-app toasts when the player advances or finishes a daily/weekly quest, and when they progress or level up an instrument path. - progression-core.js: _diff() now emits two partial-advance events — quest-progressed (a still-incomplete quest whose count rose) and path-progressed (a challenge toward the next level completed without a level-up). Both are guarded so the increment that COMPLETES a quest / the level-up itself stays a single quest-completed / path-level-up event (no double toast). Period rollovers and brand-new quest ids emit nothing. New events added to the capability owner's declared events list. - notifications.js (new): reusable window.fbNotify toast surface (stacked, animated, auto-dismiss; animation + accent via inline styles so no new Tailwind utilities) + progression wiring — subtle toasts for advances, celebratory toasts for quest completion, path level-up, and rank-up. - index.html: load notifications.js after progression-core. - tests: progression_progress_events (diff emission + guards) and progression_notifications (toast rendering + wiring) — 11 cases. No backend change. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(progression): unwrap CustomEvent .detail in notification handlers Codex P2: window.slopsmith.on delivers a CustomEvent (bus.on → addEventListener), so the progression payload is e.detail — not the raw argument. All five notifications.js handlers read the arg directly, so in the browser every field was undefined (e.g. rank-changed never toasted). Unwrap e.detail in each handler, matching every other sm.on consumer. The test harness masked this by invoking handlers with raw payloads; it now wraps them as {detail: payload} like the real bus, so the unwrap is actually exercised (the tests fail without the fix). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- static/v3/index.html | 1 + static/v3/notifications.js | 160 +++++++++++++++++++ static/v3/progression-core.js | 56 ++++++- tests/js/progression_notifications.test.js | 121 ++++++++++++++ tests/js/progression_progress_events.test.js | 115 +++++++++++++ 5 files changed, 449 insertions(+), 4 deletions(-) create mode 100644 static/v3/notifications.js create mode 100644 tests/js/progression_notifications.test.js create mode 100644 tests/js/progression_progress_events.test.js diff --git a/static/v3/index.html b/static/v3/index.html index 1238951..36c41bc 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -859,6 +859,7 @@ registers the `progression` capability owner + window.v3Progression. --> + diff --git a/static/v3/notifications.js b/static/v3/notifications.js new file mode 100644 index 0000000..21b3c0e --- /dev/null +++ b/static/v3/notifications.js @@ -0,0 +1,160 @@ +/* + * fee[dB]ack v0.3.0 — achievement notifications (toasts). + * + * A small, reusable toast surface (window.fbNotify) plus the progression wiring + * that turns progression:* lifecycle events into fancy in-app notifications: + * + * quest-progressed → subtle "Quest advanced — N/M" + * quest-completed → celebratory "Quest Complete! +N dB" + * path-progressed → subtle "{Path}: challenge done — N/M to Level X" + * path-level-up → celebratory "{Path} reached Level X!" + * rank-changed (up) → celebratory "Mastery Rank X!" + * + * Vanilla JS, no framework (constitution P-II). Animation + accent colors are + * inline styles so the prebuilt Tailwind stylesheet needs no new utilities. + * Self-contained: it subscribes through window.slopsmith.on, degrading to a + * no-op when the bus or DOM isn't present (SSR/headless safety, P15). + */ +(function () { + 'use strict'; + + const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + + function container() { + let host = document.getElementById('fb-notify-stack'); + if (!host) { + host = document.createElement('div'); + host.id = 'fb-notify-stack'; + // Bottom-right stack, newest on top; clicks pass through the gaps. + host.className = 'fixed bottom-4 right-4 z-[120] flex flex-col gap-2 items-end pointer-events-none'; + host.style.maxWidth = 'min(24rem, calc(100vw - 2rem))'; + document.body.appendChild(host); + } + return host; + } + + // opts: { title, message, icon, accent, reward, big, durationMs } + function show(opts) { + opts = opts || {}; + if (typeof document === 'undefined' || !document.body) return null; + const host = container(); + const accent = opts.accent || '#3B82F6'; + const big = !!opts.big; + + const card = document.createElement('div'); + card.className = 'pointer-events-auto bg-fb-card border border-fb-border/60 rounded-xl shadow-xl ' + + 'flex items-center gap-3 ' + (big ? 'px-4 py-3' : 'px-3 py-2'); + card.style.borderLeft = '4px solid ' + accent; + card.style.opacity = '0'; + card.style.transform = 'translateY(12px)'; + card.style.transition = 'transform .35s cubic-bezier(.2,.8,.2,1), opacity .35s'; + + const iconSize = big ? 'w-10 h-10 text-xl' : 'w-8 h-8 text-base'; + const icon = '' + esc(opts.icon || '⭐') + ''; + + const reward = (opts.reward != null && Number(opts.reward) > 0) + ? '+' + + Number(opts.reward).toLocaleString() + ' dB' + : ''; + const body = '' + + '' + esc(opts.title || '') + '' + + (opts.message ? '' + esc(opts.message) + '' : '') + + reward + ''; + + card.innerHTML = icon + body; + host.insertBefore(card, host.firstChild); // newest on top of the stack + + // Animate in on the next frame (double rAF so the initial style applies). + requestAnimationFrame(() => requestAnimationFrame(() => { + card.style.opacity = '1'; + card.style.transform = 'translateY(0)'; + })); + + const dur = opts.durationMs || (big ? 5200 : 3200); + const dismiss = () => { + if (card._done) return; + card._done = true; + clearTimeout(card._t); + card.style.opacity = '0'; + card.style.transform = 'translateY(8px)'; + setTimeout(() => { try { card.remove(); } catch (e) { /* already gone */ } }, 360); + }; + card.addEventListener('click', dismiss); + card._t = setTimeout(dismiss, dur); + return card; + } + + function clear() { + const host = document.getElementById('fb-notify-stack'); + if (host) host.innerHTML = ''; + } + + window.fbNotify = { show: show, clear: clear }; + + // ── Progression wiring ──────────────────────────────────────────────────── + const sm = window.slopsmith; + if (!sm || typeof sm.on !== 'function') return; // no bus → toasts API still usable + + const periodLabel = (p) => (p === 'weekly' ? 'Weekly Quest' : p === 'daily' ? 'Daily Quest' : 'Quest'); + const pathName = (id, fallback) => { + const prog = (window.v3Progression && window.v3Progression.get()) || null; + const hit = ((prog && prog.paths) || []).find((p) => p && p.id === id); + return (hit && hit.name) || fallback || 'Path'; + }; + + // The bus delivers a CustomEvent; the progression payload is e.detail + // (matches every other window.slopsmith.on consumer, e.g. progress.js). + sm.on('progression:quest-progressed', (e) => { + const q = e && e.detail; + if (!q) return; + fbNotify.show({ + icon: '🎯', accent: '#3B82F6', + title: periodLabel(q.period_type) + ' advanced', + message: (q.title ? q.title + ' — ' : '') + q.count + '/' + q.target, + }); + }); + + sm.on('progression:quest-completed', (e) => { + const q = e && e.detail; + if (!q) return; + fbNotify.show({ + big: true, icon: '🏆', accent: '#FACC15', + title: periodLabel(q.period_type) + ' complete!', + message: q.title || '', reward: q.reward_db, + }); + }); + + sm.on('progression:path-progressed', (e) => { + const p = e && e.detail; + if (!p) return; + fbNotify.show({ + icon: '🎸', accent: '#22C55E', + title: (p.name || 'Path') + ' progress', + message: 'Challenge done — ' + p.completed + '/' + p.required + ' to Level ' + p.next_level, + }); + }); + + sm.on('progression:path-level-up', (e) => { + const l = e && e.detail; + if (!l) return; + fbNotify.show({ + big: true, icon: '⭐', accent: '#F97316', + title: pathName(l.path_id) + ' — Level ' + l.new_level + '!', + message: 'Instrument path leveled up', + }); + }); + + sm.on('progression:rank-changed', (e) => { + const r = e && e.detail; + // Celebrate rank-UPs only (a per-source reset can lower it). + if (!r || !(Number(r.to) > Number(r.from))) return; + fbNotify.show({ + big: true, icon: '🏅', accent: '#A855F7', + title: 'Mastery Rank ' + r.to + '!', + message: 'Your overall rank went up', + }); + }); +})(); diff --git a/static/v3/progression-core.js b/static/v3/progression-core.js index 1c1319c..1a6fdf5 100644 --- a/static/v3/progression-core.js +++ b/static/v3/progression-core.js @@ -14,9 +14,11 @@ * * Lifecycle events are emitted on the capability surface and mirrored on * window.slopsmith as `progression:*` for non-capability consumers: - * challenge-completed, quest-completed, path-level-up, rank-changed, - * db-changed, calibration-completed, cosmetic-equipped (+ progression:updated - * whenever fresh state lands). + * challenge-completed, quest-completed, quest-progressed, path-level-up, + * path-progressed, rank-changed, db-changed, calibration-completed, + * cosmetic-equipped (+ progression:updated whenever fresh state lands). + * quest-progressed / path-progressed are the partial-advance counterparts to + * the *-completed / *-level-up events (the achievement-toast feed). * * Vanilla JS, no framework (constitution P-II). */ @@ -37,6 +39,19 @@ } } + // Index quests by "period:id" so a refresh can be diffed against the last + // state (period_type isn't on the per-quest payload, so carry it here). + function _questIndex(state) { + const out = {}; + const quests = (state && state.quests) || {}; + ['daily', 'weekly'].forEach((period) => { + (((quests[period] || {}).quests) || []).forEach((item) => { + if (item && item.id != null) out[period + ':' + item.id] = { period, item }; + }); + }); + return out; + } + function _diff(prev, next) { if (!prev || !next) return; if (prev.mastery_rank !== next.mastery_rank) { @@ -45,6 +60,38 @@ const before = (prev.wallet || {}).balance; const after = (next.wallet || {}).balance; if (before !== after) _emit('db-changed', { from: before, to: after, wallet: next.wallet }); + + // Quest "advance" — a still-incomplete quest whose count rose since the + // last state. The increment that COMPLETES a quest is intentionally left + // to quest-completed (emitted from notify()'s summary) so a finished + // quest surfaces once, not twice. A period rollover (count resets to 0, + // or a brand-new quest id) produces no event. + const prevQuests = _questIndex(prev); + const nextQuests = _questIndex(next); + Object.keys(nextQuests).forEach((key) => { + const pq = prevQuests[key]; + const nq = nextQuests[key]; + if (pq && !nq.item.completed && Number(nq.item.count) > Number(pq.item.count)) { + _emit('quest-progressed', Object.assign({ period_type: nq.period }, nq.item)); + } + }); + + // Path "progress" — a challenge toward the next level completed + // (next.completed rose) WITHOUT a level-up. The level-up itself is + // emitted as path-level-up from notify()'s summary, so it surfaces once. + const prevPaths = {}; + (prev.paths || []).forEach((p) => { if (p && p.id != null) prevPaths[p.id] = p; }); + (next.paths || []).forEach((np) => { + const pp = prevPaths[np && np.id]; + if (!pp || !np.next || !pp.next) return; + if (np.level === pp.level && Number(np.next.completed) > Number(pp.next.completed)) { + _emit('path-progressed', { + id: np.id, name: np.name, level: np.level, + next_level: np.next.level, + completed: np.next.completed, required: np.next.required, + }); + } + }); } async function refresh() { @@ -135,7 +182,8 @@ ownership: 'exclusive-owner', safety: 'safe', commands: ['inspect', 'record-event', 'list-shop', 'buy-item', 'equip-item'], - events: ['challenge-completed', 'quest-completed', 'path-level-up', 'rank-changed', + events: ['challenge-completed', 'quest-completed', 'quest-progressed', + 'path-level-up', 'path-progressed', 'rank-changed', 'db-changed', 'calibration-completed', 'cosmetic-equipped'], description: 'Owns player progression: mastery rank, instrument-path challenges, daily/weekly quests, the Decibels wallet, and the cosmetics shop.', handlers: { diff --git a/tests/js/progression_notifications.test.js b/tests/js/progression_notifications.test.js new file mode 100644 index 0000000..8c6a04e --- /dev/null +++ b/tests/js/progression_notifications.test.js @@ -0,0 +1,121 @@ +// Contract tests for notifications.js: the fbNotify toast surface and the +// progression:* → toast wiring (period labels, celebratory vs subtle, path +// name lookup, rank-up-only guard). + +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 { ROOT } = require('./capabilities_test_harness'); + +const SRC = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'notifications.js'), 'utf8'); + +// Minimal DOM: enough for createElement/append/insertBefore/getElementById and +// the inline-style/innerHTML the toast sets. +function fakeDom() { + function mkEl(tag) { + return { + tagName: tag, id: '', className: '', innerHTML: '', style: {}, + children: [], get firstChild() { return this.children[0] || null; }, + appendChild(c) { this.children.push(c); c.parent = this; return c; }, + insertBefore(c, ref) { + const i = ref ? this.children.indexOf(ref) : -1; + if (i < 0) this.children.push(c); else this.children.splice(i, 0, c); + c.parent = this; return c; + }, + remove() { const p = this.parent; if (p) p.children = p.children.filter((x) => x !== this); }, + addEventListener(type, fn) { (this._h || (this._h = {}))[type] = fn; }, + _text() { return (this.innerHTML || '').replace(/<[^>]*>/g, ''); }, + }; + } + const body = mkEl('body'); + const byId = (node, id) => { + if (node.id === id) return node; + for (const c of node.children) { const hit = byId(c, id); if (hit) return hit; } + return null; + }; + return { + body, + createElement: mkEl, + getElementById: (id) => byId(body, id), + }; +} + +function load(progressionState) { + const handlers = {}; + const sandbox = { + console, + setTimeout: () => 0, clearTimeout: () => {}, + requestAnimationFrame: (fn) => fn(), // run animation callbacks synchronously + }; + sandbox.window = sandbox; + sandbox.document = fakeDom(); + // Deliver a CustomEvent-like wrapper ({detail}), exactly as the real bus + // does (capabilities.js: bus.on → addEventListener, fn gets a CustomEvent). + // Test call sites pass the raw payload; the handler must unwrap e.detail. + sandbox.window.slopsmith = { on: (name, fn) => { handlers[name] = (payload) => fn({ detail: payload }); } }; + sandbox.window.v3Progression = { get: () => progressionState }; + vm.createContext(sandbox); + vm.runInContext(SRC, sandbox); + const stack = () => sandbox.document.getElementById('fb-notify-stack'); + return { sandbox, handlers, stack }; +} + +test('fbNotify.show renders a card with the title and message', () => { + const { sandbox, stack } = load(null); + assert.equal(typeof sandbox.window.fbNotify.show, 'function'); + sandbox.window.fbNotify.show({ title: 'Hello', message: 'World' }); + const cards = stack().children; + assert.equal(cards.length, 1); + assert.match(cards[0]._text(), /Hello/); + assert.match(cards[0]._text(), /World/); +}); + +test('quest-completed makes a celebratory toast with the period label and reward', () => { + const { handlers, stack } = load(null); + handlers['progression:quest-completed']({ id: 'q1', title: 'Play 3 songs', period_type: 'weekly', reward_db: 200 }); + const card = stack().children[0]; + assert.match(card._text(), /Weekly Quest complete!/); + assert.match(card._text(), /Play 3 songs/); + assert.match(card._text(), /\+200 dB/); +}); + +test('quest-progressed makes a subtle toast showing N/M and the daily label', () => { + const { handlers, stack } = load(null); + handlers['progression:quest-progressed']({ id: 'q1', title: 'Play 3 songs', period_type: 'daily', count: 2, target: 3 }); + assert.match(stack().children[0]._text(), /Daily Quest advanced/); + assert.match(stack().children[0]._text(), /2\/3/); +}); + +test('path-level-up resolves the path name from progression state', () => { + const { handlers, stack } = load({ paths: [{ id: 'guitar', name: 'Lead Guitar' }] }); + handlers['progression:path-level-up']({ path_id: 'guitar', new_level: 4 }); + assert.match(stack().children[0]._text(), /Lead Guitar — Level 4!/); +}); + +test('path-progressed shows path name and challenge count toward the next level', () => { + const { handlers, stack } = load(null); + handlers['progression:path-progressed']({ id: 'bass', name: 'Bass', completed: 2, required: 3, next_level: 2 }); + assert.match(stack().children[0]._text(), /Bass progress/); + assert.match(stack().children[0]._text(), /2\/3 to Level 2/); +}); + +test('rank-changed toasts on a rank up but not a rank drop', () => { + const up = load(null); + up.handlers['progression:rank-changed']({ from: 2, to: 3 }); + assert.equal(up.stack().children.length, 1); + assert.match(up.stack().children[0]._text(), /Mastery Rank 3!/); + + const down = load(null); + down.handlers['progression:rank-changed']({ from: 3, to: 2 }); + assert.equal(down.stack() ? down.stack().children.length : 0, 0); // no toast on a drop +}); + +test('newest toast is inserted on top of the stack', () => { + const { sandbox, stack } = load(null); + sandbox.window.fbNotify.show({ title: 'first' }); + sandbox.window.fbNotify.show({ title: 'second' }); + assert.match(stack().children[0]._text(), /second/); + assert.match(stack().children[1]._text(), /first/); +}); diff --git a/tests/js/progression_progress_events.test.js b/tests/js/progression_progress_events.test.js new file mode 100644 index 0000000..f7f1dcc --- /dev/null +++ b/tests/js/progression_progress_events.test.js @@ -0,0 +1,115 @@ +// Contract tests for progression-core's _diff(): the quest-progressed / +// path-progressed "advance" events that feed the achievement toasts, plus the +// guards that keep a completion / level-up from also firing a progress event. + +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 { ROOT } = require('./capabilities_test_harness'); + +const SRC = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'progression-core.js'), 'utf8'); + +// Load progression-core.js in a sandbox whose fetch returns `states` in order. +// Boot consumes states[0] (prev=null → no diff); each later refresh() diffs +// against the previous state. +function load(states) { + const events = []; + let i = 0; + const sandbox = { + console, + setTimeout, clearTimeout, + fetch: async () => ({ ok: true, json: async () => states[Math.min(i++, states.length - 1)] }), + }; + sandbox.window = sandbox; + sandbox.window.slopsmith = { emit: (name, detail) => events.push({ name, detail }) }; + sandbox.document = { readyState: 'complete', addEventListener: () => {} }; + vm.createContext(sandbox); + vm.runInContext(SRC, sandbox); + return { sandbox, events }; +} + +const stateA = { + mastery_rank: 2, + wallet: { balance: 100 }, + quests: { + daily: { quests: [ + { id: 'q1', title: 'Play 3 songs', count: 1, target: 3, completed: false, reward_db: 50 }, + { id: 'q2', title: 'Finish one', count: 0, target: 1, completed: false, reward_db: 20 }, + ] }, + weekly: { quests: [ + { id: 'w1', title: 'Weekly grind', count: 2, target: 10, completed: false, reward_db: 200 }, + ] }, + }, + paths: [ + { id: 'guitar', name: 'Guitar', level: 1, max_level: 10, next: { level: 2, required: 3, completed: 1 } }, + { id: 'bass', name: 'Bass', level: 0, max_level: 10, next: { level: 1, required: 2, completed: 0 } }, + ], +}; + +const stateB = { + mastery_rank: 3, // rank up + wallet: { balance: 170 }, // dB changed + quests: { + daily: { quests: [ + { id: 'q1', title: 'Play 3 songs', count: 2, target: 3, completed: false, reward_db: 50 }, // advanced + { id: 'q2', title: 'Finish one', count: 1, target: 1, completed: true, reward_db: 20 }, // COMPLETED + ] }, + weekly: { quests: [ + { id: 'w1', title: 'Weekly grind', count: 3, target: 10, completed: false, reward_db: 200 }, // advanced + ] }, + }, + paths: [ + { id: 'guitar', name: 'Guitar', level: 1, max_level: 10, next: { level: 2, required: 3, completed: 2 } }, // progressed + { id: 'bass', name: 'Bass', level: 1, max_level: 10, next: { level: 2, required: 3, completed: 0 } }, // LEVELED UP + ], +}; + +async function diffEvents() { + const { sandbox, events } = load([stateA, stateB]); + await sandbox.window.v3Progression.refresh(); // coalesces with boot → state = A + events.length = 0; // drop boot's progression:updated + await sandbox.window.v3Progression.refresh(); // state = B → _diff(A, B) + return events.filter((e) => e.name !== 'progression:updated'); +} + +test('quest advance emits quest-progressed with period_type, completion does not', async () => { + const ev = await diffEvents(); + const progressed = ev.filter((e) => e.name === 'progression:quest-progressed'); + const ids = progressed.map((e) => e.detail.id).sort(); + assert.deepEqual(ids, ['q1', 'w1']); // q2 completed → not a progress event + const q1 = progressed.find((e) => e.detail.id === 'q1').detail; + assert.equal(q1.period_type, 'daily'); + assert.equal(q1.count, 2); + assert.equal(q1.target, 3); + const w1 = progressed.find((e) => e.detail.id === 'w1').detail; + assert.equal(w1.period_type, 'weekly'); +}); + +test('path challenge progress emits path-progressed; a level-up does not', async () => { + const ev = await diffEvents(); + const progressed = ev.filter((e) => e.name === 'progression:path-progressed'); + assert.equal(progressed.length, 1); + const g = progressed[0].detail; + assert.equal(g.id, 'guitar'); + assert.equal(g.name, 'Guitar'); + assert.equal(g.completed, 2); + assert.equal(g.required, 3); + assert.equal(g.next_level, 2); + // bass leveled up (level 0 → 1) → handled by path-level-up, not path-progressed. + assert.ok(!progressed.some((e) => e.detail.id === 'bass')); +}); + +test('rank-up and dB change still emit their events', async () => { + const ev = await diffEvents(); + const rank = ev.find((e) => e.name === 'progression:rank-changed'); + assert.ok(rank && rank.detail.from === 2 && rank.detail.to === 3); + assert.ok(ev.some((e) => e.name === 'progression:db-changed')); +}); + +test('no progress events fire on the very first state (prev=null)', async () => { + const { sandbox, events } = load([stateA]); + await sandbox.window.v3Progression.refresh(); + assert.ok(!events.some((e) => /progressed|changed/.test(e.name))); +}); From f79efe25165e81fc8d00c4c3f55190ad147742e5 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Mon, 22 Jun 2026 10:07:36 +0200 Subject: [PATCH 16/99] fix(v3): pedal click opens the plugin's screen, not its settings (#556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v3 Pedalboard's settingsTarget() resolved settings-first, so a plugin that ships both a screen and a settings panel (notably the bundled Audio Engine) could only ever reach its settings from the pedalboard — its actual page was unreachable. Flip to screen-first (stompbox metaphor: step on the pedal, see the pedal), falling back to settings when there is no screen. Keep a settings fallback in openPluginSettings() when a declared screen isn't mounted yet (installing/failed) so settings-bearing plugins are never stranded on a toast. Drive the pedal aria-label off the same target so it never promises the wrong surface. Update the unit test contract to screen > settings > none. Fixes #555 Co-authored-by: Claude Opus 4.8 (1M context) --- static/v3/plugins-page.js | 25 +++++++++++++++++++------ tests/js/plugins_page.test.js | 8 ++++++-- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/static/v3/plugins-page.js b/static/v3/plugins-page.js index 93ec719..af15319 100644 --- a/static/v3/plugins-page.js +++ b/static/v3/plugins-page.js @@ -5,8 +5,8 @@ * renders as a stompbox pedal (thumbnail + name + short description). Pedals are * free-form draggable within their board (positions persist in localStorage), * and decorative patch cables (pedal-cables.js) sag/swing between them. Clicking - * (not dragging) a pedal opens that plugin's SETTINGS page; plugins with no - * settings fall back to their screen. Data comes from the enriched /api/plugins + * (not dragging) a pedal opens that plugin's own SCREEN (its page); plugins with + * no screen fall back to their settings panel. Data comes from the enriched /api/plugins * (now carrying description/category/icon — see plugins/__init__.py::_nav_entry). * The bundled Capability Inspector still owns the live capability graph; we * surface a deep-link to it rather than rebuilding it. @@ -136,11 +136,15 @@ return !!(p.nav || p.has_screen || (p.has_script && document.getElementById('plugin-' + p.id))); } - // Decide what a pedal click should open: its settings panel, else its - // screen, else nothing (toast). Pure — unit-tested. + // Decide what a pedal click should open. A plugin's own screen (its full + // "page") is the primary surface, so clicking the pedal opens it when one + // exists — matching the stompbox metaphor (step on the pedal → see the + // pedal). Plugins with no screen fall back to their settings panel; the + // settings of a screen+settings plugin (e.g. audio_engine) stay reachable + // from the main Settings screen. Pure — unit-tested. function settingsTarget(p) { - if (p && p.has_settings) return { kind: 'settings', id: p.id }; if (openable(p)) return { kind: 'screen', id: p.id }; + if (p && p.has_settings) return { kind: 'settings', id: p.id }; return { kind: 'none', id: p && p.id }; } @@ -198,8 +202,12 @@ var off = p.enabled === false; // Description shows only as a hover tooltip on the pedal, not on the face. var tip = (p.name || p.id) + (desc ? ' — ' + desc : ''); + // The action verb tracks what a click actually opens (screen-first, + // then settings) so the a11y label never promises the wrong surface. + var kind = settingsTarget(p).kind; + var action = kind === 'screen' ? ' — open' : (kind === 'settings' ? ' — open settings' : ''); return '
' + + 'aria-label="' + esc((p.name || p.id) + action) + '" title="' + esc(tip) + '"' + style + '>' + '' + (p.bundled ? 'core' : '') + '' + @@ -272,6 +280,11 @@ // failed plugin has manifest has_screen but no #plugin- div yet. if (window.showScreen && document.getElementById('plugin-' + tgt.id)) { window.showScreen('plugin-' + tgt.id); + } else if (p && p.has_settings) { + // Screen declared but not mounted yet — fall back to the + // settings panel rather than stranding the user on a toast. + if (window.showScreen) window.showScreen('settings'); + openSettingsPanel(p.id); } else { toast('This plugin is still loading — try again in a moment.'); } diff --git a/tests/js/plugins_page.test.js b/tests/js/plugins_page.test.js index 8cd9d3a..1bcb7c7 100644 --- a/tests/js/plugins_page.test.js +++ b/tests/js/plugins_page.test.js @@ -73,11 +73,15 @@ test('thumbUrl: manifest icon routes through the asset endpoint; else default', assert.equal(t.thumbUrl({ id: 'x', icon: '' }), '/static/v3/pedal-default.svg'); }); -test('settingsTarget: settings > screen > none', () => { +test('settingsTarget: screen > settings > none', () => { const { t } = loadPage(); - assert.deepEqual(t.settingsTarget({ id: 'a', has_settings: true, nav: true }), { kind: 'settings', id: 'a' }); + // A screen wins even when the plugin also has settings (e.g. audio_engine): + // the pedal opens the plugin's page, not its settings panel. + assert.deepEqual(t.settingsTarget({ id: 'a', has_settings: true, nav: true }), { kind: 'screen', id: 'a' }); assert.deepEqual(t.settingsTarget({ id: 'b', has_settings: false, nav: true }), { kind: 'screen', id: 'b' }); assert.deepEqual(t.settingsTarget({ id: 'c', has_settings: false, has_screen: true }), { kind: 'screen', id: 'c' }); + // Settings-only plugin (no screen) falls back to its settings panel. + assert.deepEqual(t.settingsTarget({ id: 'e', has_settings: true }), { kind: 'settings', id: 'e' }); assert.deepEqual(t.settingsTarget({ id: 'd' }), { kind: 'none', id: 'd' }); }); From 4dc5936712853fcae1ae9386fda8e129cf2e31dc Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Mon, 22 Jun 2026 11:04:03 +0200 Subject: [PATCH 17/99] feat(player): global autoplay & auto-exit option (songs + lessons) (#558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(v3): pedal click opens the plugin's screen, not its settings The v3 Pedalboard's settingsTarget() resolved settings-first, so a plugin that ships both a screen and a settings panel (notably the bundled Audio Engine) could only ever reach its settings from the pedalboard — its actual page was unreachable. Flip to screen-first (stompbox metaphor: step on the pedal, see the pedal), falling back to settings when there is no screen. Keep a settings fallback in openPluginSettings() when a declared screen isn't mounted yet (installing/failed) so settings-bearing plugins are never stranded on a toast. Drive the pedal aria-label off the same target so it never promises the wrong surface. Update the unit test contract to screen > settings > none. Fixes #555 Co-Authored-By: Claude Opus 4.8 (1M context) * feat(player): global autoplay & auto-exit option (songs + lessons) Single Settings toggle (autoplayExit, default ON) that auto-starts a song once it's ready and returns to the launching menu when it ends. Auto-exit defers while a results/score overlay is on top (heuristic + holdAutoExit() contract) so a scoring plugin's screen drives the exit. Player origin is now context-aware (lessons return to the lessons screen via setReturnScreen()), fixing lesson completion bouncing to the library. Core-only; songs and lessons share the playSong -> highway path. Adds a read-only window.slopsmith.autoplayExit getter + holdAutoExit()/setReturnScreen() for plugins. Unit tests for the pure helpers (_autoplayExitEnabled, _resolvePlayerOrigin, _resultsOverlayVisible). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + static/app.js | 152 ++++++++++++++++++++++++--- static/index.html | 7 ++ static/v3/index.html | 7 ++ static/v3/lessons.js | 27 +++++ tests/js/autoplay_exit.test.js | 186 +++++++++++++++++++++++++++++++++ tests/js/speed_reset.test.js | 3 + 7 files changed, 369 insertions(+), 14 deletions(-) create mode 100644 tests/js/autoplay_exit.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ec82d01..97d1bb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Autoplay & auto-exit — a global "click it, it plays; finish, you're back at the menu" option (default ON).** New single Settings toggle (`autoplayExit` in `localStorage`, surfaced in both the v3 and classic settings screens; absence of the key = enabled) that closes the friction at both ends of the play loop. **Autoplay:** `playSong()` previously loaded a chart paused, requiring a Play press; a one-shot flag armed per fresh load is now consumed by the next `song:ready` (highway.js) to auto-start via the existing `togglePlay()` path (HTML5 + `_juceMode` + count-in). Arrangement switches / seeks reuse the same `song:ready` event but never arm the flag, so they don't auto-restart. **Auto-exit:** on `song:ended`, core returns to the launching menu after a short grace delay — unless a visible full-screen results/dialog overlay is on top (detected via `[role=dialog][aria-modal]` / `.fixed.inset-0` with a `getClientRects()` visibility test that works for `position:fixed`), in which case the return is deferred so that score screen's own Close button (calling `window.closeCurrentSong()`) drives the exit. A plugin can also defer explicitly via the new `window.slopsmith.holdAutoExit()` (called synchronously from its own `song:ended` handler — core's listener runs first). Both paths mean **no external plugin PR is required** for a results screen to be respected. **Context-aware destination:** the player's remembered origin (`_playerOriginScreen`) now honours any real launch screen instead of clamping to library/home/favorites, and a one-shot `window.slopsmith.setReturnScreen(id)` override lets the lessons catalog (`static/v3/lessons.js`) send a finished lesson back to the lessons screen — not the song library — even though the external tutorials plugin owns the `playSong` call. Also exposes a read-only `window.slopsmith.autoplayExit` getter for plugins. Songs and lessons share the same `playSong` → highway path, so both inherit the behaviour. Core-only (`static/app.js`, `static/v3/lessons.js`, both `index.html`s); the end-of-song score screen itself remains a plugin. Optional polish (not required — the overlay heuristic already covers it): external scoring/note-detection plugins (e.g. SlopScale) may call `holdAutoExit()` + `closeCurrentSong()` for an exact, heuristic-free handoff. - **"Song Editor" promoted to a first-class v3 sidebar item.** The editor plugin (`id: editor`) now gets its own dedicated sidebar entry — under the Library group, just below Songs — via the existing `PROMOTED_PLUGINS` diff --git a/static/app.js b/static/app.js index 8beafcc..aaee812 100644 --- a/static/app.js +++ b/static/app.js @@ -3239,6 +3239,8 @@ async function loadSettings() { document.getElementById('demucs-server-url').value = data.demucs_server_url || ''; const leftyEl = document.getElementById('setting-lefty'); if (leftyEl) leftyEl.checked = highway.getLefty(); + const autoplayExitEl = document.getElementById('setting-autoplay-exit'); + if (autoplayExitEl) autoplayExitEl.checked = _autoplayExitEnabled(); // 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). @@ -5693,6 +5695,135 @@ document.addEventListener('visibilitychange', () => { } }); +// ── Autoplay & auto-exit (global option, default ON) ────────────────── +// One toggle (`autoplayExit` in localStorage) that (a) auto-starts a song +// once it's ready and (b) returns to the launching menu when the song +// ends. Absence of the key means enabled. The behaviour lives in core +// (app.js, shared by the v3 + classic UIs); the end-of-song *score* +// screen, when present, is a plugin and hooks the contract below. +function _autoplayExitEnabled() { + try { return localStorage.getItem('autoplayExit') !== '0'; } catch (_) { return true; } +} +// Settings checkbox setter (onchange="setAutoplayExit(this.checked)"). +window.setAutoplayExit = function (on) { + try { localStorage.setItem('autoplayExit', on ? '1' : '0'); } catch (_) { /* private mode */ } + const el = document.getElementById('setting-autoplay-exit'); + if (el && el.checked !== !!on) el.checked = !!on; +}; +// Read-only view for plugins (e.g. a scoring plugin deciding whether to +// auto-return after its results screen closes). +Object.defineProperty(window.slopsmith, 'autoplayExit', { + get: _autoplayExitEnabled, configurable: true, +}); +// One-shot launcher override for the player's return destination. +window.slopsmith.setReturnScreen = function (id) { + window.slopsmith._nextReturnScreen = id || null; +}; +// Resolve where the player should return on Esc / close / auto-exit. +// A one-shot setReturnScreen() override wins (consumed here) — used by the +// lessons catalog so a lesson returns to the lessons screen rather than the +// library, even though the external tutorials plugin owns the playSong call. +// Otherwise remember the actual launch screen; the element-exists guard +// keeps the classic v2 UI (no #v3-* ids) from being stranded on a missing +// screen, and unknown launches fall back to 'home'. The dashboard — classic +// 'home' and the v3 shell's 'v3-home' — returns to the Songs list when it +// exists (dashboard actions call playSong() directly, so its id is the +// active screen at launch). +function _resolvePlayerOrigin() { + const override = window.slopsmith && window.slopsmith._nextReturnScreen; + if (window.slopsmith) window.slopsmith._nextReturnScreen = null; + if (override && document.getElementById(override)) return override; + const launchFrom = document.querySelector('.screen.active'); + const launchId = launchFrom && launchFrom.id; + if (launchId && launchId !== 'player' && document.getElementById(launchId)) { + return ((launchId === 'home' || launchId === 'v3-home') && document.getElementById('v3-songs')) + ? 'v3-songs' : launchId; + } + return 'home'; +} + +// Autoplay: one-shot flag armed by each fresh playSong(), consumed by the +// next song:ready. song:ready also fires on arrangement switches / seeks, +// which never arm the flag, so those don't auto-restart. +let _pendingAutostart = false; +window.slopsmith.on('song:ready', () => { + if (!_pendingAutostart) return; + _pendingAutostart = false; + if (!_autoplayExitEnabled() || isPlaying) return; + // Reuse the Play button's start path (handles HTML5 + _juceMode + count-in). + Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err)); +}); + +// Auto-exit: when the song ends, return to the launching menu. A scoring +// plugin that shows an end-of-song results screen calls holdAutoExit() to +// defer this; the user closing that screen (its Close button calls +// window.closeCurrentSong()) performs the exit. With no results screen the +// grace timer returns to the menu on its own. +const AUTO_EXIT_GRACE_MS = 1500; +let _autoExitTimer = null; +let _autoExitHeld = false; +// Bumped every time the auto-exit state is reset (new song via playSong, and +// each song:ended). A hold's release() captures the generation at hold time +// and no-ops once it changes, so a plugin that drops or fires its release +// handle after the player has moved on can never navigate a fresh session — +// callers don't need to balance the handle. +let _autoExitGen = 0; +function _clearAutoExit() { + if (_autoExitTimer) { clearTimeout(_autoExitTimer); _autoExitTimer = null; } + _autoExitHeld = false; + _autoExitGen++; +} +// Heuristic safety net for score-screen plugins that don't (yet) call +// holdAutoExit(): if a visible full-screen results/dialog overlay is on top +// when the grace timer fires, defer the auto-return and let that screen's +// own close button drive the exit (its Close should call closeCurrentSong). +// getClientRects() is used for the visibility test because it reports +// position:fixed overlays correctly, unlike offsetParent. +function _resultsOverlayVisible() { + let nodes; + try { + nodes = document.querySelectorAll('[role="dialog"][aria-modal="true"], .fixed.inset-0'); + } catch (_) { return false; } + for (const el of nodes) { + if (!el || el.id === 'player') continue; // never the player itself + if (el.classList && el.classList.contains('hidden')) continue; + if (el.getClientRects && el.getClientRects().length > 0) return true; + } + return false; +} +// Plugins call this synchronously from their own song:ended handler (core +// runs first, so the timer is already pending) to claim the exit. +window.slopsmith.holdAutoExit = function () { + if (_autoExitTimer) { clearTimeout(_autoExitTimer); _autoExitTimer = null; } + _autoExitHeld = true; + const gen = _autoExitGen; + let released = false; + return function release() { + // No-op once released, or once the session has moved on (a newer + // playSong / song:ended bumped the generation) — so a stale handle + // never navigates away from a fresh song. + if (released || gen !== _autoExitGen) return; + released = true; + if (typeof window.closeCurrentSong === 'function') window.closeCurrentSong(); + }; +}; +window.slopsmith.on('song:ended', () => { + _clearAutoExit(); + if (!_autoplayExitEnabled()) return; + // Only auto-exit from the player screen (ignore stale/duplicate ends). + const active = document.querySelector('.screen.active'); + if (!active || active.id !== 'player') return; + _autoExitTimer = setTimeout(() => { + _autoExitTimer = null; + if (_autoExitHeld) return; // a plugin explicitly claimed the exit + if (_resultsOverlayVisible()) return; // a score/results overlay is up; let it drive the exit + const cur = document.querySelector('.screen.active'); + if (cur && cur.id === 'player' && typeof window.closeCurrentSong === 'function') { + window.closeCurrentSong(); + } + }, AUTO_EXIT_GRACE_MS); +}); + // Abort controller for cancelling pending requests when entering player let artAbortController = null; @@ -5750,21 +5881,14 @@ async function playSong(filename, arrangement, options) { _hideSectionPracticeBar(); currentFilename = filename; + // A fresh load arms autoplay; a pending auto-exit from the previous + // song is no longer relevant. + _pendingAutostart = true; + _clearAutoExit(); // Remember which screen the player was launched from so Esc / - // navigation back from the player returns the user there - // (slopsmith#126). Falls back to 'home' if launched from - // somewhere unexpected (settings, a plugin screen, etc.). - const _launchFrom = document.querySelector('.screen.active'); - const _launchId = _launchFrom && _launchFrom.id; - const _origin = (_launchId === 'v3-songs' || _launchId === 'home' || _launchId === 'favorites') - ? _launchId : 'home'; - // In the v3 shell, `home` launches return to the v3 Songs screen. But this - // file is shared with the classic v2 UI, where #v3-songs does not exist — - // remapping there would make Esc call showScreen('v3-songs'), which throws - // on the missing element and strands the user on a blank screen. Only remap - // when the target screen is actually present. - _playerOriginScreen = (_origin === 'home' && document.getElementById('v3-songs')) - ? 'v3-songs' : _origin; + // navigation back from the player (and auto-exit) returns the user + // there (slopsmith#126). + _playerOriginScreen = _resolvePlayerOrigin(); showScreen('player'); // Wait for previous WebSocket to fully close before opening new one diff --git a/static/index.html b/static/index.html index 152a2d9..fc17214 100644 --- a/static/index.html +++ b/static/index.html @@ -299,6 +299,13 @@ Left-handed (invert frets on the note highway)
+
+ +
+ Autoplay & auto-exit (start songs/lessons automatically and return to the menu when the score screen closes) + +
' + - '
' + + '
' + '
' + '

' + '
' + @@ -870,6 +897,8 @@ // before it tries to page deeper. await setView(state.view); bindScroll(); + positionToolbar(); + bindToolbarReflow(); updateFilterBadge(); state.built = true; } From fe8d30ce3e8fda91516ee8afadc4662139c39efb Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Mon, 22 Jun 2026 12:08:22 +0200 Subject: [PATCH 19/99] fix(highway): apply 3D fret-spacing live instead of reloading (#561) (#562) window.h3dSetFretSpacing was the only 3D-highway setting that applied via location.reload(). The SPA boots with #home as the active screen and has no restore-last-screen mechanism, so the reload ejected the user from Settings onto the home screen. Apply it live like every other 3D-highway setting: rebind the module-scope _h3dFretUniform flag (so panels mounted later this session pick up the new mode), recompute the two fretX-derived scalars baked at init (_fretLabelScaleRefW, FRET_WIDTH_MID), and broadcast a 'fretSpacing' change over the existing _bgEmitChange pub-sub so every mounted panel rebuilds its board via buildBoard(). Per-frame note geometry already reads fretX live. Settings copy updated (no longer reloads) and tests/js pin the no-reload / live-rebuild behavior. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + plugins/highway_3d/screen.js | 39 ++++++++++++++++++++++-- plugins/highway_3d/settings.html | 2 +- tests/js/highway_3d_fret_spacing.test.js | 30 ++++++++++++++++++ 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97d1bb3..be7b98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,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** (slopsmith#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 +- **Changing Settings → 3D Highway → Fret spacing no longer ejects you to the home screen.** The `highway_3d` plugin's `h3dSetFretSpacing` was the lone 3D-highway setting that called `location.reload()` to apply — and since the SPA boots with `#home` as the active screen (`index.html` `.screen.active`), the reload dropped the user out of Settings onto the homescreen. It now applies live like every other 3D-highway setting: it rebinds the module-scope `_h3dFretUniform` flag (so panels mounted later this session pick up the new mode), recomputes the two `fretX`-derived scalars that were baked at init (`_fretLabelScaleRefW` for fret-label sprite scaling, `FRET_WIDTH_MID` for camera hysteresis), and broadcasts a `fretSpacing` change over the existing `_bgEmitChange` pub-sub so every mounted panel rebuilds its board via `buildBoard()`. Per-frame note geometry already reads `fretX` live and needs no rebuild. No page reload, so the Settings screen stays put. Source-level regression tests in `tests/js/highway_3d_fret_spacing.test.js` now pin the no-reload / live-rebuild behavior. - **v3 library scroll-restore no longer breaks the classic v2 UI or drops off-screen searches** (slopsmith#857). Two regressions in the scroll-restore work above: (1) `playSong` remapped `home`-launched songs to return to the `#v3-songs` screen unconditionally, but `static/app.js` is shared with the v2 UI (served at `/v2` / `SLOPSMITH_UI=v2`) where that screen does not exist — Esc-from-player then called `showScreen('v3-songs')`, which threw on the missing element and stranded the user on a blank screen with playback still running; the remap now applies only when `#v3-songs` is present. (2) The Songs screen-entry fast-path skips reloading to preserve scroll, but the global topbar search routed through it, so once Songs had been visited, searching from another screen navigated there without applying the new query; the screen now tracks the state hash each fetch reflects and refetches when it has drifted, keeping the scroll-preserving no-op only when nothing changed. - **An active custom highway renderer is no longer starved of `draw()` when it hides the canvas** (#819). The per-frame draw gate in `static/highway.js` bailed on `if (!_lastVisible) return`, which conflated two different "hidden" states: a genuine off-screen canvas (`offsetParent === null` — navigate-away / `display:none` splitscreen panel, #246) versus a renderer-set *override-hide* (`setVisible(false)`, where an opaque overlay covers the canvas but the active renderer keeps painting its own surface). The gate now only pauses everything for the off-screen case (and still pauses the default 2D renderer on an override-hide); the **active custom renderer** keeps receiving `draw()` through its own override-hide. The `highway:visibility` event still fires before the gate, so sibling overlay renderers (e.g. 3D Highway's `.h3d-wrap`) still pause. This is the core-side root cause behind the Tab View cursor freezing in single-player (slopsmith#734; worked around plugin-side in slopsmith-plugin-tabview#25). - **Screensaver no longer kicks in during windowed-mode playback** (#686). While a song is playing, `static/app.js` now holds a [Screen Wake Lock](https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API) (`navigator.wakeLock.request('screen')`) so the OS display/screensaver stays awake even though only audio + the highway animation are active and the keyboard/mouse are idle. The lock is acquired on `song:play`/`song:resume` and released on `song:pause`/`song:ended`/`song:stop` (kept only while actually playing), and re-acquired on `visibilitychange` when the tab refocuses (the API auto-releases a lock whenever the page is hidden). Both the HTML5 `
@@ -40,7 +40,7 @@
@@ -126,7 +126,7 @@ - +
- +
@@ -149,7 +149,7 @@
- +
- +
@@ -407,10 +407,10 @@

About

-
Slopsmith
-
Licensed under GNU AGPL v3.0.
- -

Slopsmith is free software. You can redistribute it and modify it under the terms of the AGPL. If you run a modified version that interacts with users over a network, you must make the modified source available to those users.

+
FeedBack
+
Licensed under GNU AGPL v3.0.
+ +

FeedBack is free software. You can redistribute it and modify it under the terms of the AGPL. If you run a modified version that interacts with users over a network, you must make the modified source available to those users.

@@ -543,7 +543,7 @@ +0ms
- +
@@ -563,7 +563,7 @@ - @@ -762,7 +762,7 @@
diff --git a/static/v3/index.html b/static/v3/index.html index b8cadda..dc3d980 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -806,6 +806,14 @@
+ +
+ Editor + + + + +
From a43e7b13be38c600b21b08a349ef7ae49e6aa446 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Tue, 23 Jun 2026 12:51:59 +0200 Subject: [PATCH 32/99] fix(onboarding): calibration Tuner step no longer exposes the input-select overlay (#577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tester: "at the tune step, pressing the Tuner button starts a second wizard at the input-select step." Root cause is stacked full-screen overlays. During onboarding the input-setup flow runs as #input-setup-overlay (z-210) on top of the onboarding modal #v3-onboarding (z-200), and note_detect's Calibration Wizard (z-300) launches on top of that. When the player opens the Tuner, that wizard minimizes itself to transparent + pointer-events:none so the Tuner (z-1000) is usable — but the input-setup overlay underneath, still showing its "select your input" card, then shows through behind the floating tuner and reads as a second wizard. Two targeted hides so only the active surface is visible: - input_setup: hide #input-setup-overlay while launchCalibration runs; restore on its onDone/onCancel (one always fires on close), so the calibration wizard / tuner own the screen. - onboarding runInputSetup: hide #v3-onboarding for the whole input-setup phase (its own overlay replaces it visually); restore in finally before advancing to the calibration-challenge step. Co-authored-by: Claude Opus 4.8 (1M context) --- plugins/input_setup/screen.js | 19 ++++++++++++-- static/v3/profile.js | 48 +++++++++++++++++++++++------------ 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/plugins/input_setup/screen.js b/plugins/input_setup/screen.js index 8229dc6..424c65f 100644 --- a/plugins/input_setup/screen.js +++ b/plugins/input_setup/screen.js @@ -163,10 +163,25 @@ host.querySelector('[data-is-cal]').addEventListener('click', () => { if (hasDetector) { + // Hide our own full-screen overlay while note_detect's + // Calibration Wizard runs on top. That wizard goes + // transparent (pointer-events:none) when it minimizes to + // expose the Tuner; if our overlay stayed up it would show + // through — covering the tuner with the still-mounted + // "select your input" card and looking like a second + // wizard at the input step. Restore it on done/cancel + // (one of which always fires when that wizard closes). + const ov = document.getElementById('input-setup-overlay'); + const prevDisplay = ov ? ov.style.display : ''; + if (ov) ov.style.display = 'none'; + const restore = () => { + const o = document.getElementById('input-setup-overlay'); + if (o) o.style.display = prevDisplay; + }; window.noteDetect.launchCalibration({ instrument: inst, - onDone: () => advance(inst, true), - onCancel: () => { /* stay on this panel; user can skip or retry */ }, + onDone: () => { restore(); advance(inst, true); }, + onCancel: () => { restore(); /* stay on this panel; user can skip or retry */ }, }); } else { advance(inst, true); diff --git a/static/v3/profile.js b/static/v3/profile.js index 8f52249..b46440c 100644 --- a/static/v3/profile.js +++ b/static/v3/profile.js @@ -222,23 +222,39 @@ if (!instruments.length) return; // Don't let a plugin-load race skip the mandatory input-setup step. if (!(await waitForInputSetup(8000))) return; - const caps = window.feedBack && window.feedBack.capabilities; - if (!caps || typeof caps.command !== 'function') { - try { await window.feedBackInputSetup.launch(instruments); } catch (e) { /* proceed */ } - return; + // Hide this onboarding modal while the input-setup wizard (its own + // full-screen overlay) runs on top. Otherwise both stay stacked, and when + // the note-detect calibration wizard minimizes to expose the Tuner the + // onboarding modal shows through behind the tuner. Restored in `finally` + // before we advance to the calibration-challenge step. + const ob = document.getElementById('v3-onboarding'); + const obPrevDisplay = ob ? ob.style.display : ''; + if (ob) ob.style.display = 'none'; + const restoreOnboarding = () => { + const o = document.getElementById('v3-onboarding'); + if (o) o.style.display = obPrevDisplay; + }; + try { + const caps = window.feedBack && window.feedBack.capabilities; + if (!caps || typeof caps.command !== 'function') { + try { await window.feedBackInputSetup.launch(instruments); } catch (e) { /* proceed */ } + return; + } + await new Promise((resolve) => { + let settled = false; + let unsub = null; + const done = () => { if (settled) return; settled = true; try { unsub && unsub(); } catch (e) { /* noop */ } resolve(); }; + try { unsub = typeof caps.subscribe === 'function' ? caps.subscribe('input-calibration:calibration-done', done) : null; } catch (e) { unsub = null; } + // `run` is fire-and-launch; completion arrives via the event above. + // A non-handled outcome (no owner / plugin absent / error) means + // nothing was launched, so proceed immediately. + caps.command('input-calibration', 'run', { requester: 'onboarding', payload: { instruments } }) + .then((r) => { if (!r || r.outcome !== 'handled') done(); }) + .catch(() => done()); + }); + } finally { + restoreOnboarding(); } - await new Promise((resolve) => { - let settled = false; - let unsub = null; - const done = () => { if (settled) return; settled = true; try { unsub && unsub(); } catch (e) { /* noop */ } resolve(); }; - try { unsub = typeof caps.subscribe === 'function' ? caps.subscribe('input-calibration:calibration-done', done) : null; } catch (e) { unsub = null; } - // `run` is fire-and-launch; completion arrives via the event above. - // A non-handled outcome (no owner / plugin absent / error) means - // nothing was launched, so proceed immediately. - caps.command('input-calibration', 'run', { requester: 'onboarding', payload: { instruments } }) - .then((r) => { if (!r || r.outcome !== 'handled') done(); }) - .catch(() => done()); - }); } function show(profile, opts) { From b123ab3258924cd6c7d9792832893f7e05c69a7e Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Tue, 23 Jun 2026 16:30:00 +0200 Subject: [PATCH 33/99] fix(minigames): drain legacy slopsmith pending queue + alias SDK (#578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the slopsmith→feedBack rename (#537) the minigames SDK publishes window.feedBackMinigames and only drains window.__feedBackMinigamesPending. Minigame plugins that still use the pre-rename shim register against window.slopsmithMinigames and queue to window.__slopsmithMinigamesPending when the SDK isn't up yet, so their specs are stranded in the legacy queue and never register. In v3, FeedBarcade renders those games as non-launchable "Loading…" tiles that do nothing on click (the tile itself comes from the server registry, so it appears even though the JS spec never registered). Publish window.slopsmithMinigames as an alias and drain the legacy pending queue too (register() is keyed on spec.id, so double-queued specs register once). Also fire the legacy slopsmith-minigames-ready event. Bump the plugin version so the desktop renderer cache-busts the updated screen.js. This rescues every not-yet-migrated minigame plugin, including community ones we don't control. Co-authored-by: Claude Opus 4.8 (1M context) --- plugins/minigames/plugin.json | 2 +- plugins/minigames/screen.js | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/plugins/minigames/plugin.json b/plugins/minigames/plugin.json index 0bc80ad..d142040 100644 --- a/plugins/minigames/plugin.json +++ b/plugins/minigames/plugin.json @@ -1,7 +1,7 @@ { "id": "minigames", "name": "Minigames", - "version": "0.1.0", + "version": "0.1.1", "bundled": true, "private": false, "screen": "screen.html", diff --git a/plugins/minigames/screen.js b/plugins/minigames/screen.js index 5e6016c..66bbb00 100644 --- a/plugins/minigames/screen.js +++ b/plugins/minigames/screen.js @@ -961,7 +961,25 @@ // Drain queue of plugins that loaded before us. (window.__feedBackMinigamesPending || []).forEach(register); window.__feedBackMinigamesPending = null; + + // ── Back-compat for pre-rename minigame plugins ─────────────────────── + // Minigame plugins published before the slopsmith→feedBack rename (#537) + // register against `window.slopsmithMinigames` and, when the SDK isn't up + // yet, queue to `window.__slopsmithMinigamesPending`. The rename moved the + // SDK to `window.feedBackMinigames` and only drains the feedBack queue, so + // those plugins' specs — including community ones we don't control — get + // stranded in the legacy queue and never register. Their FeedBarcade tiles + // then render as dead "Loading…" placeholders that do nothing on click. + // Alias the SDK under the old name and drain the legacy pending queue too. + // register() is keyed on spec.id, so a plugin that queued under both names + // still registers exactly once. + window.slopsmithMinigames = sdk; + (window.__slopsmithMinigamesPending || []).forEach(register); + window.__slopsmithMinigamesPending = null; + window.dispatchEvent(new CustomEvent('feedBack-minigames-ready')); + // Legacy event name for any pre-rename listener still bound to it. + window.dispatchEvent(new CustomEvent('slopsmith-minigames-ready')); // ── Wire hub render to screen lifecycle ─────────────────────────────── // FeedBack mounts plugin screens with id "plugin-" and From db4a30085bf53e1e2e5abbd552940dbce0d5b7dc Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Tue, 23 Jun 2026 18:05:41 +0200 Subject: [PATCH 34/99] feat(v3 library): clickable arrangement badges in tree view (#582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v3 library tree rows showed no arrangement badges, unlike the grid/card view. Render the same clickable chips in tree rows so both views match, and clicking a specific arrangement opens THAT arrangement in the highway. Extract the grid's chip markup into a shared arrChipsHtml(song) (one ').join(''); + } + function songCard(song) { const fav = song.favorite; const key = cardKey(song); @@ -410,8 +421,7 @@ const checkbox = state.selectMode ? '' : ''; - const arrChips = (song.arrangements || []).slice(0, 4).map((a) => - '').join(''); + const arrChips = arrChipsHtml(song); // Plugin-contributed card actions placed 'inline' (in the hover action // row) or 'overlay' (centered over the art). Menu-placed actions live in // the ⋮ menu (openCardMenu); rendering these here means plugins using @@ -720,10 +730,11 @@ '' + esc(a.name) + '' + esc(a.song_count) + '' + '
' + (a.albums || []).map((al) => '
' + esc(al.name || 'Unknown') + '
' + - (al.songs || []).map((s) => { const k = cardKey(s); const fl = fmtLabel(s); return ( + (al.songs || []).map((s) => { const k = cardKey(s); const fl = fmtLabel(s); const chips = arrChipsHtml(s); return ( '
' + '' + '' + esc(s.title) + '' + + (chips ? '' : '') + (fl ? '' + fl + '' : '') + accuracyBadge(k, 'tree') + '' + From f3a5cb9ed3de75bca4e717022048b9fe9a8b7b2a Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Tue, 23 Jun 2026 18:05:45 +0200 Subject: [PATCH 35/99] feat(sloppak): expose full-mix original_audio alongside stems (#583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a .sloppak ship the single pre-separation full mixdown next to its per-instrument stems, so the player can use the pristine original when nothing is isolated (demucs recombination is lossy) and switch to separated stems only when a slider drops below unity. - lib/sloppak.py::load_song parses the optional manifest `original_audio:` key into a new LoadedSloppak.original_audio field, with the same path-traversal guard + permissive "missing → disabled" posture as the drum_tab loader. - The highway WS song_info frame additively carries original_audio_url (served by the existing /api/sloppak/{filename}/file/{rel_path} endpoint, None for stems-only packs), has_original_audio, and has_stems. - A stem-less, full-mix-only sloppak now sets audio_url to the full mix (plays natively) instead of emitting audio_error. Message shape stays a stable contract — all additions are purely additive. Tests: tests/test_sloppak_original_audio_load.py (6 passing). Closes #580 Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + lib/sloppak.py | 32 ++++++ server.py | 28 +++++ tests/test_sloppak_original_audio_load.py | 119 ++++++++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 tests/test_sloppak_original_audio_load.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b04985a..79be67f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Full-mix audio exposed alongside stems for the stem mixer's auto-switch.** `lib/sloppak.py::load_song` now parses the optional manifest `original_audio:` key (the single pre-separation mixdown, e.g. `original/full.ogg`) into a new `LoadedSloppak.original_audio` field, with the same path-traversal guard and permissive "missing → disabled" posture as the `drum_tab` loader. The highway WS `song_info` frame additively carries three new fields next to `stems`: `original_audio_url` (served by the existing `/api/sloppak/{filename}/file/{rel_path}` endpoint, `None` when the pack ships stems only), `has_original_audio`, and `has_stems` (mirroring the `has_drum_tab`/`has_keys` flag convention). The stems plugin consumes `original_audio_url` to play the untouched single file while every stem slider is at unity and switch to the separate stems the moment one drops below 100%. **Migration notes:** the `song_info` message shape is a stable contract — these are purely additive; all existing fields are unchanged. `audio_url` still points at stem[0] when stems exist (it is only the degraded native fallback); the one behavioural change is that a stem-less, full-mix-only sloppak now sets `audio_url` to the full mix instead of emitting `audio_error`, so it plays natively. - **Autoplay & auto-exit — a global "click it, it plays; finish, you're back at the menu" option (default ON).** New single Settings toggle (`autoplayExit` in `localStorage`, surfaced in both the v3 and classic settings screens; absence of the key = enabled) that closes the friction at both ends of the play loop. **Autoplay:** `playSong()` previously loaded a chart paused, requiring a Play press; a one-shot flag armed per fresh load is now consumed by the next `song:ready` (highway.js) to auto-start via the existing `togglePlay()` path (HTML5 + `_juceMode` + count-in). Arrangement switches / seeks reuse the same `song:ready` event but never arm the flag, so they don't auto-restart. **Auto-exit:** on `song:ended`, core returns to the launching menu after a short grace delay — unless a visible full-screen results/dialog overlay is on top (detected via `[role=dialog][aria-modal]` / `.fixed.inset-0` with a `getClientRects()` visibility test that works for `position:fixed`), in which case the return is deferred so that score screen's own Close button (calling `window.closeCurrentSong()`) drives the exit. A plugin can also defer explicitly via the new `window.feedBack.holdAutoExit()` (called synchronously from its own `song:ended` handler — core's listener runs first). Both paths mean **no external plugin PR is required** for a results screen to be respected. **Context-aware destination:** the player's remembered origin (`_playerOriginScreen`) now honours any real launch screen instead of clamping to library/home/favorites, and a one-shot `window.feedBack.setReturnScreen(id)` override lets the lessons catalog (`static/v3/lessons.js`) send a finished lesson back to the lessons screen — not the song library — even though the external tutorials plugin owns the `playSong` call. Also exposes a read-only `window.feedBack.autoplayExit` getter for plugins. Songs and lessons share the same `playSong` → highway path, so both inherit the behaviour. Core-only (`static/app.js`, `static/v3/lessons.js`, both `index.html`s); the end-of-song score screen itself remains a plugin. Optional polish (not required — the overlay heuristic already covers it): external scoring/note-detection plugins (e.g. SlopScale) may call `holdAutoExit()` + `closeCurrentSong()` for an exact, heuristic-free handoff. - **"Song Editor" promoted to a first-class v3 sidebar item.** The editor plugin (`id: editor`) now gets its own dedicated sidebar entry — under the diff --git a/lib/sloppak.py b/lib/sloppak.py index 8ee837a..d19c460 100644 --- a/lib/sloppak.py +++ b/lib/sloppak.py @@ -367,6 +367,14 @@ class LoadedSloppak: # song.arrangements (not to manifest["arrangements"]) — skipped entries are # absent so indexing by song.arrangements index is safe. arrangement_ids: list[str | None] = field(default_factory=list) + # Manifest-relative path to the single full-mix audio file, taken from the + # manifest `original_audio:` key (e.g. "original/full.ogg"). This is the + # pre-separation mixdown that exists alongside the per-instrument `stems`. + # None when the key is absent, points outside source_dir, or the file is + # missing on disk. Served to the front-end via the highway WS as + # `original_audio_url`; the stems plugin uses it to play the untouched mix + # when every stem slider is at unity (and the separate stems otherwise). + original_audio: str | None = None def load_song( @@ -808,6 +816,29 @@ def load_song( } _fpv = manifest.get("feedpak_version") + # Optional full-mix audio — manifest `original_audio:` key. The single + # pre-separation mixdown that ships alongside the per-instrument stems. + # Same permissive, path-traversal-guarded posture as drum_tab above: a + # missing/escaping/absent file simply leaves the full mix unavailable (the + # player falls back to the separate stems) rather than aborting the load. + # We store the manifest-relative string so server.py can build its URL the + # same way it builds stem URLs (via the /api/sloppak/.../file/ endpoint). + original_audio_data: str | None = None + original_audio_rel = manifest.get("original_audio") + if isinstance(original_audio_rel, str) and original_audio_rel.strip(): + rel = original_audio_rel.strip() + try: + oa_path = (source_dir / rel).resolve() + oa_path.relative_to(source_dir.resolve()) + except ValueError: + log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel) + oa_path = None + except OSError as e: + log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e) + oa_path = None + if oa_path is not None and oa_path.is_file(): + original_audio_data = rel + return LoadedSloppak( song=song, stems=stems, @@ -821,6 +852,7 @@ def load_song( keys=keys_data, notation_by_id=notation_by_id_data, arrangement_ids=arrangement_ids_acc, + original_audio=original_audio_data, ) diff --git a/server.py b/server.py index 2d3c266..d6b99f9 100644 --- a/server.py +++ b/server.py @@ -6923,6 +6923,11 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, audio_url = None audio_error: str | None = None # Surfaced in song_info when audio_url is None stems_payload: list[dict] = [] + # URL of the single full-mix audio (sloppak `original_audio:`), when the + # pack ships one. The stems plugin uses this to play the untouched mix + # while every stem slider is at unity; None otherwise (separate stems + # only, loose folder, or archive). + original_audio_url: str | None = None if is_loose: # Loose folder filenames are relative paths (artist/album/song). # Hash the *canonical* dlc-relative path (so two URL spellings @@ -6961,8 +6966,22 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, for s in loaded_slop.stems: url = f"/api/sloppak/{q_fn}/file/{quote(s['file'])}" stems_payload.append({"id": s["id"], "url": url, "default": s["default"]}) + # Full-mix URL (served by the same /api/sloppak/.../file/ endpoint). + if loaded_slop is not None and loaded_slop.original_audio: + original_audio_url = ( + f"/api/sloppak/{q_fn}/file/{quote(loaded_slop.original_audio)}" + ) if stems_payload: + # Stems present: keep the core
'; + // Per-song bests — top scored songs from /api/stats/top, filled by + // renderBests() after innerHTML is set. The placeholder text shows + // during load and when nothing's been scored yet. + const bestsCard = '
' + '

Your best scores

' + '
Play a song to start tracking your accuracy and best scores.
' + + '
'; + const playerIdFooter = (_profile && _profile.player_hash + ? '

player id ' + esc(_profile.player_hash.slice(0, 12)) + '

' + : ''); + root.innerHTML = + '
' + + '
' + + '' + + '' + + '
' + + // ── Profile (main) panel ────────────────────────────────────────── + '
' + + '
' + + headerCard + + bestsCard + + // Feats of Power trophy shelf — rendered by the achievements plugin + // (earned Feats only; hidden-until-earned, so empty when none). + '
' + + playerIdFooter + + '
' + + // ── Achievements panel ──────────────────────────────────────────── + '
' + + '
' + + '

Install the Achievements plugin to track your skill milestones.

' + '
' + - (_profile && _profile.player_hash - ? '

player id ' + esc(_profile.player_hash.slice(0, 12)) + '

' - : '') + '
'; const edit = root.querySelector('[data-v3-edit-profile]'); if (edit) edit.addEventListener('click', () => show(_profile, { editing: true })); @@ -147,7 +209,13 @@ if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') { window.v3Theme.applyFrame(root.querySelector('[data-v3-avatar-frame]')); } + wireProfileTabs(); renderBests(); + // Tell the achievements plugin (or any profile consumer) the shell + + // mount points exist now, so it can (re)inject on every profile entry — + // innerHTML above wipes prior injected content. Mirrors + // `v3:settings-rendered`. Harmless if no listener is attached. + try { document.dispatchEvent(new CustomEvent('v3:profile-rendered')); } catch (_) { /* noop */ } } // Fill the "Your best scores" panel from /api/stats/top (top scored songs, @@ -646,6 +714,12 @@ if (window.feedBack && typeof window.feedBack.on === 'function') { window.feedBack.on('progression:updated', () => { renderBadge(); renderProfileScreen(); }); window.feedBack.on('v3:cosmetics-applied', () => { renderBadge(); renderProfileScreen(); }); + // Re-render on every Profile entry so the Feats shelf + Achievements + // catalogue refresh (and `v3:profile-rendered` re-fires for the + // plugin) — the plugin may have loaded after the initial boot render. + window.feedBack.on('screen:changed', (e) => { + if (e && e.detail && e.detail.id === 'v3-profile') renderProfileScreen(); + }); } } if (document.readyState === 'loading') { diff --git a/tests/plugins/achievements/__init__.py b/tests/plugins/achievements/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plugins/achievements/conftest.py b/tests/plugins/achievements/conftest.py new file mode 100644 index 0000000..7dc6d96 --- /dev/null +++ b/tests/plugins/achievements/conftest.py @@ -0,0 +1,17 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'plugins' / 'achievements')) + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import routes as ach_routes + + +@pytest.fixture +def client(tmp_path): + app = FastAPI() + ach_routes.setup(app, {"config_dir": str(tmp_path)}) + return TestClient(app) diff --git a/tests/plugins/achievements/test_engine.py b/tests/plugins/achievements/test_engine.py new file mode 100644 index 0000000..4b45258 --- /dev/null +++ b/tests/plugins/achievements/test_engine.py @@ -0,0 +1,91 @@ +"""Pure-helper unit tests for the achievements engine (no IO, P-V).""" + +import engine + + +class TestTierIndexFor: + def test_below_first_tier(self): + assert engine.tier_index_for([100000, 1000000], 50000) == -1 + + def test_exact_threshold(self): + assert engine.tier_index_for([100000, 1000000], 100000) == 0 + + def test_highest_reached(self): + assert engine.tier_index_for([100000, 1000000, 10000000], 2000000) == 1 + + def test_all_tiers(self): + assert engine.tier_index_for([100, 500], 9999) == 1 + + def test_empty_tiers(self): + assert engine.tier_index_for([], 10) == -1 + + +class TestApplyActivity: + def test_cumulative_adds(self): + c = engine.apply_activity({}, {"notes": 10, "song_done": 1, "seconds": 30}) + assert c["notes_total"] == 10 + assert c["songs_done"] == 1 + assert c["time_total_seconds"] == 30 + c = engine.apply_activity(c, {"notes": 5, "song_done": 1, "seconds": 20}) + assert c["notes_total"] == 15 + assert c["songs_done"] == 2 + assert c["time_total_seconds"] == 50 + + def test_max_counters_take_maximum(self): + c = engine.apply_activity({}, {"session_notes": 100, "in_song_streak": 40}) + c = engine.apply_activity(c, {"session_notes": 60, "in_song_streak": 90}) + assert c["notes_session_max"] == 100 + assert c["streak_insong_max"] == 90 + + def test_chart_encore_only_when_present(self): + c = engine.apply_activity({}, {"notes": 1}) + assert "chart_encore_max" not in c + c = engine.apply_activity(c, {"chart_play_count": 7}) + assert c["chart_encore_max"] == 7 + + def test_is_pure(self): + before = {"notes_total": 5} + engine.apply_activity(before, {"notes": 100}) + assert before == {"notes_total": 5} # input unmutated + + +class TestEvaluateFeats: + FEATS = [ + {"id": "notes_total", "counter": "notes_total", "tiers": [100000, 1000000]}, + {"id": "songs_done", "counter": "songs_done", "tiers": [1000, 5000]}, + {"id": "secret_combo", "counter": None, "tiers": []}, + ] + + def test_unmet_omitted(self): + assert engine.evaluate_feats(self.FEATS, {"notes_total": 50000}) == {} + + def test_met_tier(self): + out = engine.evaluate_feats(self.FEATS, {"notes_total": 2000000, "songs_done": 1200}) + assert out == {"notes_total": 1, "songs_done": 0} + + def test_no_counter_feat_never_auto_unlocks(self): + out = engine.evaluate_feats(self.FEATS, {"notes_total": 99999999}) + assert "secret_combo" not in out + + +class TestDiffUnlocks: + def test_first_unlock(self): + assert engine.diff_unlocks({}, {"a": 0}) == ["a"] + + def test_tier_advance(self): + assert engine.diff_unlocks({"a": 0}, {"a": 1}) == ["a"] + + def test_no_change(self): + assert engine.diff_unlocks({"a": 1}, {"a": 1}) == [] + + +class TestConsecutiveRun: + def test_seven_consecutive(self): + dates = ["2026-06-0%d" % d for d in range(1, 8)] + assert engine.consecutive_run_length(dates) == 7 + + def test_break_resets(self): + assert engine.consecutive_run_length(["2026-06-01", "2026-06-02", "2026-06-05"]) == 2 + + def test_dedup_and_unsorted(self): + assert engine.consecutive_run_length(["2026-06-03", "2026-06-01", "2026-06-02", "2026-06-02"]) == 3 diff --git a/tests/plugins/achievements/test_routes.py b/tests/plugins/achievements/test_routes.py new file mode 100644 index 0000000..a14c5cc --- /dev/null +++ b/tests/plugins/achievements/test_routes.py @@ -0,0 +1,61 @@ +"""HTTP-level tests for the achievements engine, incl. the integration law.""" + + +def test_catalog_ships_baseline(client): + data = client.get("/api/plugins/achievements/catalog").json() + assert "baseline" in data + ids = [d["id"] for d in data["baseline"].get("global", [])] + assert "first_steps" in ids and "ascendant" in ids + + +def test_activity_unlocks_feat_and_appears_on_shelf(client): + # 100k notes in one shot crosses notes_total tier 0 (Note Hunter). + res = client.post("/api/plugins/achievements/activity", json={"notes": 100000}).json() + assert res["ok"] is True + unlocked_ids = [u["id"] for u in res["unlocked"]] + assert "notes_total" in unlocked_ids + # And it shows on the Feats shelf. + feats = client.get("/api/plugins/achievements/feats").json()["feats"] + assert any(f["id"] == "notes_total" for f in feats) + + +def test_activity_below_threshold_unlocks_nothing(client): + res = client.post("/api/plugins/achievements/activity", json={"notes": 50000}).json() + assert res["unlocked"] == [] + assert client.get("/api/plugins/achievements/feats").json()["feats"] == [] + + +def test_integration_law_competency_never_on_feat_shelf(client): + # A competency unlock reported by a source must NEVER appear among Feats. + client.post("/api/plugins/achievements/report-unlock", json={ + "id": "tempo_push", "kind": "achievement", "category": "guitar", "sourceId": "virtuoso"}) + feats = client.get("/api/plugins/achievements/feats").json()["feats"] + assert all(f["id"] != "tempo_push" for f in feats) + # But it is earned (competency class). + earned = client.get("/api/plugins/achievements/earned").json()["earned"] + rec = [e for e in earned if e["id"] == "tempo_push"] + assert rec and rec[0]["cls"] == "competency" + + +def test_report_unlock_is_idempotent_and_tier_monotonic(client): + body = {"id": "ascendant", "kind": "achievement", "category": "global", "tier": 1} + first = client.post("/api/plugins/achievements/report-unlock", json=body).json() + assert first["changed"] is True + # Same tier again → no change. + again = client.post("/api/plugins/achievements/report-unlock", json=body).json() + assert again["changed"] is False + # Lower tier → still no change (monotonic). + lower = client.post("/api/plugins/achievements/report-unlock", + json={**body, "tier": 0}).json() + assert lower["changed"] is False + # Higher tier → advances. + higher = client.post("/api/plugins/achievements/report-unlock", + json={**body, "tier": 2}).json() + assert higher["changed"] is True + + +def test_report_criterion_counts_distinct(client): + url = "/api/plugins/achievements/report-criterion" + assert client.post(url, json={"criterion_id": "x", "token": "a"}).json()["count"] == 1 + assert client.post(url, json={"criterion_id": "x", "token": "a"}).json()["count"] == 1 # dup + assert client.post(url, json={"criterion_id": "x", "token": "b"}).json()["count"] == 2 From 287c23a532309637a047b229c6e25230890e4f95 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Wed, 24 Jun 2026 17:00:30 +0200 Subject: [PATCH 41/99] feat(achievements): opt-in, privacy controls & data-min gate (epic PR2) (#591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing earned Feats on the (forthcoming) public wall is strictly opt-in, default OFF, with a binding data-minimization contract. - Onboarding (static/v3/profile.js): a new opt-in step (now a 5-step wizard) after song-directory / before paths — publishes only display name + earned Feats, never songs/skills/scores; off by default. - Settings (plugins/achievements/settings.html, System tab via settings.category): the same toggle + a "Remove me from the wall" button (POST remove-me — wipes local synced state offline + enqueues removal). - Core (server.py): achievements_enabled (bool, default false) in _default_settings + /api/settings validation + _RESETTABLE_SETTINGS_KEYS; mirrored to localStorage in app.js loadSettings(). - Data-minimization gate: engine.build_wall_payload is the single explicit-dict serializer; key-set is EXACTLY {display_name, player_hash, achievement_id, unlocked_at}, achievement_id always a Feat id. Enqueue is gated on opted-in AND profile identity (reused player_hash); competency never enqueues (integration law). Verified natively: settings round-trip + validation + remove-me; opted-in activity enqueues exactly one 4-field Feat payload; Playwright confirms the 5-step wizard + opt-in card (default unchecked), zero console errors. 29 plugin tests + new settings tests pass. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + plugins/achievements/engine.py | 20 +++++ plugins/achievements/routes.py | 67 ++++++++++++++++- plugins/achievements/settings.html | 74 +++++++++++++++++-- server.py | 12 +++ static/app.js | 4 + static/v3/profile.js | 64 ++++++++++++---- tests/plugins/achievements/test_datamin.py | 85 ++++++++++++++++++++++ tests/test_settings_api.py | 16 ++++ 9 files changed, 318 insertions(+), 25 deletions(-) create mode 100644 tests/plugins/achievements/test_datamin.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d72efe3..69d3fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Achievements wall — opt-in, privacy controls & data-minimization gate (epic PR2).** Sharing earned **Feats** on the (forthcoming) public wall is strictly opt-in. A new **onboarding step** (`static/v3/profile.js`, inserted after song-directory / before instrument paths — the wizard is now five steps) presents a plain-language card: it publishes only your display name and the Feats you earn, never songs/skills/scores, and is **off by default**. The bundled plugin's Settings panel (`plugins/achievements/settings.html`, mounted under the **System** tab via `settings.category`) carries the same toggle plus a **"Remove me from the wall"** button (`POST /api/plugins/achievements/remove-me` — wipes local synced state offline + enqueues a wall removal). Core adds `achievements_enabled` (bool, default `false`) to `_default_settings()` + the `/api/settings` validation block + `_RESETTABLE_SETTINGS_KEYS` in `server.py`, mirrored to `localStorage` in `app.js loadSettings()`. **Data-minimization contract (binding, code-enforced):** every outbound payload is built by a single explicit-dict serializer (`engine.build_wall_payload`, never `dict(row)`/`**model`) whose key-set is **exactly** `{display_name, player_hash, achievement_id, unlocked_at}` with `achievement_id` always a **Feat** id — a unit test asserts the four-field set and goes red on a fifth. Enqueue is doubly gated: it happens only when opted-in **and** a profile identity (name + the reused `player_hash`) exists; **competency unlocks never enqueue** (integration law). Tests: `tests/plugins/achievements/test_datamin.py` (key-set, opt-out/identity/competency gating) + `tests/test_settings_api.py` (flag persists/validates/resettable). - **Achievements & Feats of Power — local engine + tabbed Profile (epic PR1).** The Profile screen (`static/v3/profile.js`) becomes **tabbed** exactly like the v3 Settings page (`.fb-tabbar` / `.fb-tab[data-tab]` / `.fb-tabpanel[data-tab]`, active-tab persisted in `localStorage 'v3-profile-tab'`): a **Profile** (main) tab carrying the existing header + best-scores cards plus a new **Feats of Power** trophy shelf mount (`#v3-profile-feats-slot`, earned-only / hidden-until-earned), and an **Achievements** tab with a plugin mount (`#v3-profile-achievements-mount`) + `[data-empty-for]` empty note. Core dispatches a new **`v3:profile-rendered`** event after every render (mirrors `v3:settings-rendered`) so the plugin re-injects on each profile entry. A new bundled **`plugins/achievements/`** plugin owns the engine: SQLite under `/achievements/achievements.db` (`unlocks` / `counters` / `comp_ledger` / `sync_queue`), pure threshold/criterion math in the testable sibling `engine.py` (P-V), and routes under `/api/plugins/achievements/` (`activity`, `report-unlock`, `report-criterion`, `catalog`, `earned`, `feats`, `remove-me`). **Two surfaces, one engine, structurally separated (integration law):** **Feats** (activity/volume — Note Hunter, Marathon, Untouchable, Road Warrior, Time Served, Encore, two 🥚 secrets) read activity counters only, evaluated from a batched `song:ended` activity POST (notes only when **notedetect** is present — graceful degradation, no fake progress); **competency Achievements** (baseline: First Steps / Ascendant / Steady Hands / Renaissance + per-instrument Apprentice·Journeyman·Master / Personal Best / Challenger) are evaluated from **progression events only** and never re-derived from activity. The Achievements catalogue is always shown (locked = greyed), grouped by a secondary pill row over the **real progression paths** (Global / Guitar / Bass / Drums / Keys — auto-extends to new paths) with a per-category "X / Y earned" badge, defaulting to the player's primary path. Source plugins contribute their own competency defs and report unlocks through a versioned **`window.feedBack.achievements`** API (`register`/`registerAll`/`unlock`/`progress`), load-order-safe via the `window.__feedBackAchievementsPending` queue + an `achievements:ready` event (minigames pending-queue pattern); an absent source contributes nothing (no dead greyed rows). Opt-in publishing to a hosted Feats wall, the Settings privacy toggle, and the data-minimization gate land in epic PR2/PR3. Tests: `tests/plugins/achievements/test_engine.py` + `test_routes.py` (incl. the integration-law assertion that a competency unlock never reaches the Feats shelf). - **v3 settings page redesigned as a tabbed, card-row layout.** The single long scrolling settings screen becomes a horizontal tab bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins / System) over card rows — each a leading icon + title + description with the control (toggle/dropdown/slider) on the right, plus a per-category "Reset" action. The markup lives in `static/v3/index.html` (so existing element ids keep hydrating through the unchanged `app.js` `loadSettings()`/`persistSetting()` path); a new `static/v3/settings.js` owns tab switching + active-tab persistence (`localStorage 'v3-settings-tab'`), the per-category reset, and a read-only **Keybinds** reference built from the live shortcut registry (`window.getAllShortcuts()`); styling is plain CSS in `static/v3/v3.css` (no Tailwind rebuild). **Plugins choose their settings tab** via a new optional `settings.category` field in `plugin.json` (`plugins/__init__.py` surfaces it as `settings_category`; `app.js` mounts each plugin's `
` panel into `#plugin-settings-`, falling back to the generic Plugins tab) — `highway_3d` ships `category: "graphics"`; the out-of-repo notedetect/progression plugins should declare `"mic"` / `"progression"`. **New gameplay settings:** **Countdown before song** (a four-beat count-in before playback, wired end-to-end via the existing count-in engine + the song-start autostart path; key `countdown_before_song`, default off); **Miss penalty** (`miss_penalty`) and **Fail behavior** (`fail_behavior`) are persisted now but not yet consumed by scoring (shown with a "Not yet active" badge). "Note highway speed" surfaces the existing `master_difficulty` and stays in sync with the player-popover difficulty slider. New `POST /api/settings/reset` clears chosen keys back to defaults. Tests: `tests/test_settings_api.py` (new keys + reset), `tests/test_plugins.py::test_settings_category_parsed_from_manifest`, `tests/browser/settings-tabbed.spec.ts`. - **Full-mix audio exposed alongside stems for the stem mixer's auto-switch.** `lib/sloppak.py::load_song` now parses the optional manifest `original_audio:` key (the single pre-separation mixdown, e.g. `original/full.ogg`) into a new `LoadedSloppak.original_audio` field, with the same path-traversal guard and permissive "missing → disabled" posture as the `drum_tab` loader. The highway WS `song_info` frame additively carries three new fields next to `stems`: `original_audio_url` (served by the existing `/api/sloppak/{filename}/file/{rel_path}` endpoint, `None` when the pack ships stems only), `has_original_audio`, and `has_stems` (mirroring the `has_drum_tab`/`has_keys` flag convention). The stems plugin consumes `original_audio_url` to play the untouched single file while every stem slider is at unity and switch to the separate stems the moment one drops below 100%. **Migration notes:** the `song_info` message shape is a stable contract — these are purely additive; all existing fields are unchanged. `audio_url` still points at stem[0] when stems exist (it is only the degraded native fallback); the one behavioural change is that a stem-less, full-mix-only sloppak now sets `audio_url` to the full mix instead of emitting `audio_error`, so it plays natively. diff --git a/plugins/achievements/engine.py b/plugins/achievements/engine.py index 72fa4c6..956369a 100644 --- a/plugins/achievements/engine.py +++ b/plugins/achievements/engine.py @@ -135,6 +135,26 @@ def consecutive_run_length(dates): return best +# ── Data-minimization contract (binding, code-enforced) ────────────────────── +# The wall payload key-set is frozen here and asserted by a unit test. The +# serializer below is the ONLY way outbound data is built — never dict(row) or +# **model — so a stray field cannot leak. Adding a key makes the test go red. +WALL_PAYLOAD_KEYS = ("display_name", "player_hash", "achievement_id", "unlocked_at") + + +def build_wall_payload(display_name, player_hash, achievement_id, unlocked_at): + """Build the EXACT four-field wall payload. ``achievement_id`` must always be + a Feat id (the caller only ever invokes this for Feat unlocks — competency + never syncs). Explicit literal dict on purpose; do not refactor into a + row/model splat.""" + return { + "display_name": display_name, + "player_hash": player_hash, + "achievement_id": achievement_id, + "unlocked_at": unlocked_at, + } + + def diff_unlocks(prev_tiers, new_tiers): """Feat ids whose tier advanced (incl. first unlock). diff --git a/plugins/achievements/routes.py b/plugins/achievements/routes.py index d68f04d..7939460 100644 --- a/plugins/achievements/routes.py +++ b/plugins/achievements/routes.py @@ -40,6 +40,8 @@ _lock = threading.Lock() _state = { "db_path": None, "dir": None, # plugin directory (for catalog JSON) + "config_dir": None, # CONFIG_DIR (for reading the opt-in setting) + "meta_db": None, # MetadataDB (for the profile identity: name + hash) "log": logging.getLogger("feedBack.plugin.achievements"), "engine": None, # sibling engine.py module (pure helpers) "feat_defs": [], # parsed feats.json -> list of feat defs @@ -108,6 +110,58 @@ def _now_iso(): return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) +def _opted_in(): + """True only when the user has opted in (core setting ``achievements_enabled``). + + Read straight from CONFIG_DIR/config.json — the single source of truth the + /api/settings endpoint persists. Default OFF on any read failure: nothing + leaves the device unless explicitly enabled. + """ + try: + cfg_path = Path(_state["config_dir"]) / "config.json" + cfg = json.loads(cfg_path.read_text(encoding="utf-8")) + return bool(cfg.get("achievements_enabled") is True) + except (OSError, ValueError, TypeError): + return False + + +def _identity(): + """(display_name, player_hash) from the profile, or (None, None). + + Reused as the wall identity (server.py's documented player_hash). Sync is + skipped entirely when either is missing. + """ + db = _state["meta_db"] + if db is None or not hasattr(db, "get_profile"): + return None, None + try: + prof = db.get_profile() or {} + return (prof.get("display_name") or None), (prof.get("player_hash") or None) + except Exception: # noqa: BLE001 — identity is best-effort; never break a request + return None, None + + +def _enqueue_feat_sync(conn, feat_id, unlocked_at): + """Enqueue a wall-sync POST for a Feat unlock — opt-in gated, identity gated. + + Builds the outbound payload through the SINGLE code-gated serializer + (engine.build_wall_payload, exactly four fields). Competency unlocks never + reach this path (integration law + data-minimization contract). The drain + worker (PR3) POSTs the queued rows; here we only persist intent. + """ + if not _opted_in(): + return False + display_name, player_hash = _identity() + if not display_name or not player_hash: + return False + payload = _state["engine"].build_wall_payload(display_name, player_hash, feat_id, unlocked_at) + conn.execute( + "INSERT INTO sync_queue(kind, payload, state) VALUES ('unlock', ?, 'pending')", + (json.dumps(payload),), + ) + return True + + def _read_counters(conn): return {row["key"]: int(row["value"]) for row in conn.execute("SELECT key, value FROM counters")} @@ -218,6 +272,8 @@ def setup(app, context): base.mkdir(parents=True, exist_ok=True) _state["db_path"] = str(base / "achievements.db") _state["dir"] = str(Path(__file__).resolve().parent) + _state["config_dir"] = str(config_dir) + _state["meta_db"] = context.get("meta_db") _state["log"] = context.get("log") or _state["log"] # Pure helpers via the per-plugin sibling loader (constitution P-III), with a # plain-import fallback for pytest / standalone use. @@ -277,7 +333,9 @@ def setup(app, context): for fid in fresh: f = _feat_by_id(fid) or {} tier = new_tiers[fid] - if _record_unlock(conn, fid, "feat", f.get("category"), f.get("sourceId"), tier, _now_iso()): + at = _now_iso() + if _record_unlock(conn, fid, "feat", f.get("category"), f.get("sourceId"), tier, at): + _enqueue_feat_sync(conn, fid, at) unlocked.append(_feat_payload(fid, f, tier)) conn.commit() return {"ok": True, "unlocked": unlocked, "counters": new_counters} @@ -287,11 +345,16 @@ def setup(app, context): @app.post("/api/plugins/achievements/report-unlock") def post_report_unlock(body: UnlockIn): cls = "feat" if body.kind == "feat" else "competency" + at = body.at or _now_iso() with _lock: conn = _conn() try: changed = _record_unlock( - conn, body.id, cls, body.category, body.sourceId, body.tier, body.at) + conn, body.id, cls, body.category, body.sourceId, body.tier, at) + # Only Feats sync; competency never enqueues (integration law + + # data-minimization contract). + if changed and cls == "feat": + _enqueue_feat_sync(conn, body.id, at) conn.commit() return {"ok": True, "changed": changed, "id": body.id, "tier": body.tier} finally: diff --git a/plugins/achievements/settings.html b/plugins/achievements/settings.html index 4bb6b97..054cb60 100644 --- a/plugins/achievements/settings.html +++ b/plugins/achievements/settings.html @@ -1,8 +1,68 @@ - -
-

Your Achievements (skill milestones) and Feats of Power - (rare activity trophies) live on your Profile page. Everything here is - local and private.

-

Sharing Feats on the public wall is opt-in and arrives in a later update.

+ +
+
+

Your Achievements and Feats of Power live on your + Profile page and are local & private by default.

+
+ + + +
+ + +
+ + diff --git a/server.py b/server.py index 03033cf..d2c2d2c 100644 --- a/server.py +++ b/server.py @@ -5388,6 +5388,11 @@ def _default_settings(): "countdown_before_song": False, "miss_penalty": "none", "fail_behavior": "continue", + # Achievements epic: opt-in to publishing earned Feats (name + Feat id + # only) to the hosted wall. Default OFF — nothing leaves the device + # until the user opts in. Read by the bundled achievements plugin to + # gate its wall-sync enqueue. + "achievements_enabled": False, } @@ -5524,6 +5529,12 @@ def save_settings(data: dict): if not isinstance(raw, bool): return {"error": "countdown_before_song must be a boolean"} updates["countdown_before_song"] = raw + if "achievements_enabled" in data: + raw = data["achievements_enabled"] + if raw is not None: + if not isinstance(raw, bool): + return {"error": "achievements_enabled must be a boolean"} + updates["achievements_enabled"] = raw if "miss_penalty" in data: raw = data["miss_penalty"] if raw is not None: @@ -5614,6 +5625,7 @@ _RESETTABLE_SETTINGS_KEYS = frozenset({ "default_arrangement", "demucs_server_url", "master_difficulty", "av_offset_ms", "countdown_before_song", "miss_penalty", "fail_behavior", "reference_pitch", "instrument", "string_count", "tuning", + "achievements_enabled", }) diff --git a/static/app.js b/static/app.js index a95259e..1360887 100644 --- a/static/app.js +++ b/static/app.js @@ -3275,6 +3275,10 @@ async function loadSettings() { try { localStorage.setItem('countdownBeforeSong', countdownOn ? '1' : '0'); } catch (_) { /* private mode */ } const countdownEl = document.getElementById('setting-countdown-before-song'); if (countdownEl) countdownEl.checked = countdownOn; + // Achievements epic: mirror the opt-in flag to localStorage so the + // onboarding card + the bundled achievements plugin can read the current + // state app-wide (the plugin's own settings panel still owns the toggle). + try { localStorage.setItem('achievementsEnabled', data.achievements_enabled === true ? '1' : '0'); } catch (_) { /* private mode */ } const missEl = document.getElementById('setting-miss-penalty'); if (missEl) missEl.value = typeof data.miss_penalty === 'string' ? data.miss_penalty : 'none'; const failEl = document.getElementById('setting-fail-behavior'); diff --git a/static/v3/profile.js b/static/v3/profile.js index adeb511..60ba4b3 100644 --- a/static/v3/profile.js +++ b/static/v3/profile.js @@ -332,7 +332,7 @@ const stepDots = editing ? '' : '
' + - [1, 2, 3, 4].map((n) => '').join('') + + [1, 2, 3, 4, 5].map((n) => '').join('') + '
'; const overlay = document.createElement('div'); @@ -365,13 +365,23 @@ 'class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary focus:ring-1 focus:ring-fb-primary">' + '' + '
' + - // Step 3 — instrument paths (first-run only; tiles filled on entry). + // Step 3 — Achievements wall opt-in (first-run only; default OFF). '' + + // Step 4 — instrument paths (first-run only; tiles filled on entry). + '' + - // Step 4 — calibration offer (first-run only). - ''); }).join('') + '').join('') + '
').join(''); wireCards(host); } @@ -945,6 +964,21 @@ e.stopImmediatePropagation(); toggleSelect(card.getAttribute('data-fn'), card); }, true); + + // Same bulletproof guard for the list/tree view. Without it, clicking a + // song row (or its arrangement chip) in select mode falls through to the + // per-card play handler and starts playback instead of selecting. The + // group headers sit OUTSIDE any [data-fn], so closest() is null + // for them and their native expand/collapse is left untouched. + const treeEl = byId('v3-songs-tree'); + if (treeEl) treeEl.addEventListener('click', (e) => { + if (!state.selectMode) return; + const card = e.target.closest('[data-fn]'); + if (!card || !treeEl.contains(card)) return; + e.preventDefault(); + e.stopImmediatePropagation(); + toggleSelect(card.getAttribute('data-fn'), card); + }, true); const setView = (v) => { state.view = v; byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); diff --git a/tests/browser/v3-tree-select.spec.ts b/tests/browser/v3-tree-select.spec.ts new file mode 100644 index 0000000..f2eac4b --- /dev/null +++ b/tests/browser/v3-tree-select.spec.ts @@ -0,0 +1,118 @@ +import { test, expect } from '@playwright/test'; + +// Regression coverage for the list/tree view select-mode fix (PR #585, which +// re-lands a change that was reverted). The core bug: entering select mode +// re-renders the tree (setSelectMode -> reload -> loadTree), and the rebuild +// wiped every expanded
, collapsing the tree and making selection +// unusable. The fix captures the open artist groups before the wipe and +// restores them. We also cover: clicking a row in select mode selects instead +// of playing. +// +// Navigation uses programmatic element.click() rather than Playwright's +// actionability-gated click: this screen briefly re-renders its toolbar and +// the harness can show transient overlays, but element.click() still +// dispatches a real bubbling event through the capture-phase select handler. + +const ARTISTS = { + artists: [ + { + name: 'Alpha Band', + song_count: 2, + albums: [{ name: 'First Album', songs: [ + { filename: 'alpha/one.sloppak', title: 'Alpha One', artist: 'Alpha Band', album: 'First Album' }, + { filename: 'alpha/two.sloppak', title: 'Alpha Two', artist: 'Alpha Band', album: 'First Album' }, + ] }], + }, + { + name: 'Beta Crew', + song_count: 1, + albums: [{ name: 'Beta LP', songs: [ + { filename: 'beta/solo.sloppak', title: 'Beta Solo', artist: 'Beta Crew', album: 'Beta LP' }, + ] }], + }, + ], + total_artists: 2, +}; + +test.beforeEach(async ({ page }) => { + // Paged artists endpoint (used by both the tree and the artist catalog): + // page 0 returns data, later pages return empty so the paging loop ends. + await page.route('**/api/library/artists**', async route => { + const pageNum = Number(new URL(route.request().url()).searchParams.get('page') || '0'); + await route.fulfill({ json: pageNum === 0 ? ARTISTS : { artists: [], total_artists: 2 } }); + }); + await page.route('**/api/library/providers', route => route.fulfill({ json: { providers: [{ id: 'local', label: 'My Library' }] } })); + await page.route('**/api/library/tuning-names**', route => route.fulfill({ json: { tunings: [] } })); + await page.route('**/api/stats/best', route => route.fulfill({ json: {} })); + await page.route('**/api/library?**', route => route.fulfill({ json: { songs: [], total: 0, page: 0, size: 60 } })); +}); + +// Programmatic click — fires a real bubbling click through capture-phase +// handlers without Playwright's actionability gate. +async function clickSel(page, selector: string) { + await page.evaluate((s) => { + const el = document.querySelector(s) as HTMLElement | null; + if (!el) throw new Error('not found: ' + s); + el.click(); + }, selector); +} + +async function openTree(page) { + await page.goto('/'); + await page.waitForSelector('.screen.active', { timeout: 10000 }); + await page.evaluate(() => { + // @ts-ignore — record playback so an accidental row-click is detectable. + window.__played = 0; + // @ts-ignore + window.playSong = () => { window.__played++; return Promise.resolve(); }; + // @ts-ignore + window.showScreen('v3-songs'); + }); + await page.waitForSelector('#v3-songs-tree-btn', { state: 'attached', timeout: 8000 }); + await clickSel(page, '#v3-songs-tree-btn'); + await page.waitForSelector('#v3-songs-tree details', { state: 'attached', timeout: 8000 }); +} + +// Returns the
whose names the given artist. +function group(page, artist: string) { + return page.locator('#v3-songs-tree details', { has: page.locator('summary', { hasText: artist }) }); +} + +test('select mode keeps expanded artist groups open across the tree re-render (#585)', async ({ page }) => { + await openTree(page); + + // Expand Alpha (the precondition the bug used to destroy on re-render). + await page.evaluate(() => { + const d = [...document.querySelectorAll('#v3-songs-tree details')] + .find((el) => el.querySelector('summary')?.textContent?.includes('Alpha Band')) as HTMLDetailsElement; + d.open = true; + }); + await expect(group(page, 'Alpha Band')).toHaveAttribute('open', ''); + + // Enter select mode → triggers the full tree re-render. + await clickSel(page, '#v3-songs-select'); + await page.waitForSelector('#v3-songs-tree input[data-select]', { state: 'attached', timeout: 8000 }); + + // The bug: Alpha collapses after the rebuild. The fix restores it. + await expect(group(page, 'Alpha Band')).toHaveAttribute('open', ''); + // Beta was never opened — it must stay collapsed (no false restore). + await expect(group(page, 'Beta Crew')).not.toHaveAttribute('open', ''); +}); + +test('clicking a tree row in select mode selects it instead of playing (#585)', async ({ page }) => { + await openTree(page); + + await page.evaluate(() => { + const d = [...document.querySelectorAll('#v3-songs-tree details')] + .find((el) => el.querySelector('summary')?.textContent?.includes('Alpha Band')) as HTMLDetailsElement; + d.open = true; + }); + + await clickSel(page, '#v3-songs-select'); + await page.waitForSelector('#v3-songs-tree input[data-select]', { state: 'attached', timeout: 8000 }); + + await clickSel(page, '#v3-songs-tree [data-fn="alpha/one.sloppak"]'); + + await expect(page.locator('#v3-songs-tree [data-fn="alpha/one.sloppak"] input[data-select]')).toBeChecked(); + expect(await page.evaluate(() => (window as any).__played)).toBe(0); +}); From 4c3ec2ff6657141b1568a85998d17430bd8507a1 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Wed, 24 Jun 2026 17:02:16 -0500 Subject: [PATCH 46/99] feat(plugins): full-screen (immersive) plugin screens via manifest opt-in (#590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DAW-style plugin UIs (e.g. a practice studio) need the whole viewport, not a scrolling content page below the v3 topbar — embedded in the shell they get cut off at the bottom with excess padding up top. Add an opt-in top-level `"fullscreen": true` plugin.json field, surfaced as the `fullscreen` boolean on /api/plugins (mirrors the settings_category plumbing in plugins/__init__.py). When a fullscreen plugin's screen is active, static/v3/ shell.js toggles `html.fb-immersive` from syncActive() so it tracks every navigation incl. deep-link; static/v3/v3.css then 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. Test: tests/test_plugins.py::test_fullscreen_flag_parsed_from_manifest Claude-Session: https://claude.ai/code/session_01BmWopMsRjdZyD6RwmZAQBv Signed-off-by: ChrisBeWithYou Co-authored-by: Claude Opus 4.8 Co-authored-by: byrongamatos --- CHANGELOG.md | 1 + plugins/__init__.py | 13 +++++++++++++ static/v3/shell.js | 26 ++++++++++++++++++++++++-- static/v3/v3.css | 36 ++++++++++++++++++++++++++++++++++++ tests/test_plugins.py | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26234a7..2ac95ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Full-screen (immersive) plugin screens — opt-in via `"fullscreen": true` in `plugin.json`.** DAW-style plugin UIs (e.g. a practice studio) need the whole viewport, not a scrolling content page below the topbar — embedded in the v3 shell they get cut off at the bottom with dead space up top. A plugin can now declare a top-level `"fullscreen": true`; `plugins/__init__.py` surfaces it as the `fullscreen` boolean on `/api/plugins` (mirroring the `settings_category` plumbing). When such a plugin's screen is active, `static/v3/shell.js` toggles `html.fb-immersive` from `syncActive()` (so it tracks every navigation incl. deep-link), and `static/v3/v3.css` hides the topbar, collapses the sidebar to a functional **icon rail** (kept reachable — Escape is bound only on player/settings scopes, so a fully-hidden sidebar would trap the user), and lets the active plugin screen fill `#v3-main`. Mirrors the existing `ss-follower-pre` chrome-hide pattern. Additive + opt-in: plugins without the flag are unaffected. Tests: `tests/test_plugins.py::test_fullscreen_flag_parsed_from_manifest`. - **Achievements wall sync — background drain worker (epic PR3, client side).** The bundled `achievements` plugin gains a dead-letter sync worker that POSTs queued Feat unlocks (and removals) to the hosted **feedback-achievements** wall service (separate repo). Idle unless a wall URL is configured (`FEEDBACK_ACHIEVEMENTS_WALL_URL`); uses `requests` with the baked-in client-token header, mirroring `lib/lyrics_transcribe`'s outbound pattern (explicit timeout, no raise on non-2xx). **Dead-letter, never drop** (pure `engine.drain_decision`): network error / `429` / `5xx` → keep `pending` (retry); other `4xx` → `dead_letter` (diagnosable, replayable); `2xx` → delete on server ack. A row leaves the queue only on ack or a user opt-out. `remove-me` now enqueues a wall removal keyed by the reused `player_hash`. Verified by an end-to-end staging round-trip (earn a Feat → drains onto the wall with name + short hash → `remove-me` → wall empties) with **no IP** in tables or access logs. Tests: `tests/plugins/achievements/test_sync.py` (decision table + ack/retry/dead-letter retention + four-field payload on the wire). The hosted service itself (FastAPI + SQLite-on-disk, Feats-only, hidden-until-first-global-unlock, profanity filter, in-memory rate limit, Render blueprint, migration tool) lives in the new `feedback-achievements` repo. - **Achievements wall — opt-in, privacy controls & data-minimization gate (epic PR2).** Sharing earned **Feats** on the (forthcoming) public wall is strictly opt-in. A new **onboarding step** (`static/v3/profile.js`, inserted after song-directory / before instrument paths — the wizard is now five steps) presents a plain-language card: it publishes only your display name and the Feats you earn, never songs/skills/scores, and is **off by default**. The bundled plugin's Settings panel (`plugins/achievements/settings.html`, mounted under the **System** tab via `settings.category`) carries the same toggle plus a **"Remove me from the wall"** button (`POST /api/plugins/achievements/remove-me` — wipes local synced state offline + enqueues a wall removal). Core adds `achievements_enabled` (bool, default `false`) to `_default_settings()` + the `/api/settings` validation block + `_RESETTABLE_SETTINGS_KEYS` in `server.py`, mirrored to `localStorage` in `app.js loadSettings()`. **Data-minimization contract (binding, code-enforced):** every outbound payload is built by a single explicit-dict serializer (`engine.build_wall_payload`, never `dict(row)`/`**model`) whose key-set is **exactly** `{display_name, player_hash, achievement_id, unlocked_at}` with `achievement_id` always a **Feat** id — a unit test asserts the four-field set and goes red on a fifth. Enqueue is doubly gated: it happens only when opted-in **and** a profile identity (name + the reused `player_hash`) exists; **competency unlocks never enqueue** (integration law). Tests: `tests/plugins/achievements/test_datamin.py` (key-set, opt-out/identity/competency gating) + `tests/test_settings_api.py` (flag persists/validates/resettable). - **Achievements & Feats of Power — local engine + tabbed Profile (epic PR1).** The Profile screen (`static/v3/profile.js`) becomes **tabbed** exactly like the v3 Settings page (`.fb-tabbar` / `.fb-tab[data-tab]` / `.fb-tabpanel[data-tab]`, active-tab persisted in `localStorage 'v3-profile-tab'`): a **Profile** (main) tab carrying the existing header + best-scores cards plus a new **Feats of Power** trophy shelf mount (`#v3-profile-feats-slot`, earned-only / hidden-until-earned), and an **Achievements** tab with a plugin mount (`#v3-profile-achievements-mount`) + `[data-empty-for]` empty note. Core dispatches a new **`v3:profile-rendered`** event after every render (mirrors `v3:settings-rendered`) so the plugin re-injects on each profile entry. A new bundled **`plugins/achievements/`** plugin owns the engine: SQLite under `/achievements/achievements.db` (`unlocks` / `counters` / `comp_ledger` / `sync_queue`), pure threshold/criterion math in the testable sibling `engine.py` (P-V), and routes under `/api/plugins/achievements/` (`activity`, `report-unlock`, `report-criterion`, `catalog`, `earned`, `feats`, `remove-me`). **Two surfaces, one engine, structurally separated (integration law):** **Feats** (activity/volume — Note Hunter, Marathon, Untouchable, Road Warrior, Time Served, Encore, two 🥚 secrets) read activity counters only, evaluated from a batched `song:ended` activity POST (notes only when **notedetect** is present — graceful degradation, no fake progress); **competency Achievements** (baseline: First Steps / Ascendant / Steady Hands / Renaissance + per-instrument Apprentice·Journeyman·Master / Personal Best / Challenger) are evaluated from **progression events only** and never re-derived from activity. The Achievements catalogue is always shown (locked = greyed), grouped by a secondary pill row over the **real progression paths** (Global / Guitar / Bass / Drums / Keys — auto-extends to new paths) with a per-category "X / Y earned" badge, defaulting to the player's primary path. Source plugins contribute their own competency defs and report unlocks through a versioned **`window.feedBack.achievements`** API (`register`/`registerAll`/`unlock`/`progress`), load-order-safe via the `window.__feedBackAchievementsPending` queue + an `achievements:ready` event (minigames pending-queue pattern); an absent source contributes nothing (no dead greyed rows). Opt-in publishing to a hosted Feats wall, the Settings privacy toggle, and the data-minimization gate land in epic PR2/PR3. Tests: `tests/plugins/achievements/test_engine.py` + `test_routes.py` (incl. the integration-law assertion that a competency unlock never reaches the Feats shelf). diff --git a/plugins/__init__.py b/plugins/__init__.py index a5cef3c..a4fd348 100644 --- a/plugins/__init__.py +++ b/plugins/__init__.py @@ -1350,6 +1350,13 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N _icon = "assets/thumb.png" except OSError: _icon = None + # Immersive (full-screen) screen opt-in. A plugin that declares a + # top-level `"fullscreen": true` gets the whole content area when its + # screen is active: the v3 shell hides the topbar and collapses the + # sidebar to an icon rail (see static/v3/shell.js + v3.css). For + # DAW-style plugin UIs that need the viewport, not a scrolling content + # page. Strict `is True` so a stray truthy value can't silently opt in. + _fullscreen = manifest.get("fullscreen") is True return { "id": plugin_id, "name": manifest.get("name", plugin_id), @@ -1368,6 +1375,9 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N "has_script": bool(manifest.get("script")), "has_settings": bool(manifest.get("settings")), "settings_category": _settings_category, + # Drives the v3 shell's immersive (full-screen) mode for this + # plugin's screen. False unless the manifest declares it explicitly. + "fullscreen": _fullscreen, "has_tour": _is_valid_tour_manifest(manifest.get("tour")), # `styles` is an optional relpath (under the plugin's assets/) to a # compiled, preflight-off stylesheet the frontend injects as a @@ -2080,6 +2090,8 @@ def register_plugin_api(app: FastAPI): "has_screen": p["has_screen"], "has_script": p["has_script"], "has_settings": p["has_settings"], + # v3 immersive screen opt-in (full-screen plugin UI). + "fullscreen": p.get("fullscreen", False), # Settings-tab placement; None when the manifest's `settings` # is absent, a bare string, or omits `category`. "settings_category": p.get("settings_category"), @@ -2132,6 +2144,7 @@ def register_plugin_api(app: FastAPI): "has_script": e.get("has_script", False), "has_settings": e.get("has_settings", False), "settings_category": e.get("settings_category"), + "fullscreen": e.get("fullscreen", False), "has_tour": e.get("has_tour", False), "has_styles": e.get("has_styles", False), "styles": e.get("styles"), diff --git a/static/v3/shell.js b/static/v3/shell.js index 252ea19..1239f48 100644 --- a/static/v3/shell.js +++ b/static/v3/shell.js @@ -17,6 +17,13 @@ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + // Plugin ids whose manifest declared `"fullscreen": true`. Populated from + // /api/plugins in renderPromotedNav(); read by syncActive() to toggle the + // immersive (chrome-collapsed) shell whenever such a plugin's screen is the + // active one. Empty until plugins resolve — the worst case is one extra + // syncActive() once the fetch lands, which re-applies the class. + const FULLSCREEN_PLUGIN_IDS = new Set(); + // ── Navigation registry ──────────────────────────────────────────────── // Each entry maps a stable hash key → a screen id (showScreen target) and a // label. Legacy screens are reused: "Songs" = #home (library), "Favorites" @@ -112,6 +119,15 @@ el.classList.toggle('text-fb-textDim', !on); }); setTopbarTitle(titleFor(screenId)); + // Immersive (full-screen) plugin screens: when the active screen belongs + // to a plugin that opted in via `"fullscreen": true`, collapse the host + // chrome (topbar hidden, sidebar → icon rail; see v3.css) and let the + // plugin own the content area. Toggled here so it tracks every + // navigation, including deep-link load and programmatic showScreen(). + const fsPluginId = (screenId && screenId.indexOf('plugin-') === 0) + ? screenId.slice('plugin-'.length) : null; + const immersive = !!(fsPluginId && FULLSCREEN_PLUGIN_IDS.has(fsPluginId)); + document.documentElement.classList.toggle('fb-immersive', immersive); // Show the song search only on the library screen. Everywhere else the // box is irrelevant (and would silently no-op against v3Songs.search). const searchWrap = document.getElementById('v3-search-wrap'); @@ -144,7 +160,7 @@ return '' + - iconSvg(entry.icon) + '' + esc(labelOverride != null ? labelOverride : entry.label) + ''; + iconSvg(entry.icon) + '' + esc(labelOverride != null ? labelOverride : entry.label) + ''; } // Empty slot for a promoted plugin, anchored after a nav item. Filled by // renderPromotedNav() only when the plugin is installed, so an absent @@ -161,7 +177,7 @@ const items = NAV.filter((n) => n.group === group); if (!items.length) continue; const itemsHTML = items.map((it) => navItemHTML(it) + promotedSlotHTML(it.key)).join(''); - html += '
' + + html += '
' + group + '
' + itemsHTML + '
'; } nav.innerHTML = html; @@ -270,6 +286,12 @@ if (res.ok) plugins = await res.json(); } catch (e) { return; } // degrade: no promoted slots const list = Array.isArray(plugins) ? plugins : []; + // Record which installed plugins requested immersive (full-screen) + // screens, then re-sync the active screen so the chrome collapses even + // if we navigated to a fullscreen plugin before /api/plugins resolved. + FULLSCREEN_PLUGIN_IDS.clear(); + for (const p of list) { if (p && p.fullscreen && p.id) FULLSCREEN_PLUGIN_IDS.add(p.id); } + try { syncActive(currentScreenId()); } catch (e) { /* non-fatal */ } for (const promo of PROMOTED_PLUGINS) { const host = document.getElementById(promo.slotId); const entry = byKey(promo.navKey); diff --git a/static/v3/v3.css b/static/v3/v3.css index 3867b3c..7337fcc 100644 --- a/static/v3/v3.css +++ b/static/v3/v3.css @@ -1074,3 +1074,39 @@ body.font-display { font-family: Rubik, system-ui, sans-serif; } } .fb-settings-note { font-size: .75rem; color: #64748b; margin-top: 1rem; } .fb-tabpanel-empty { font-size: .8rem; color: #64748b; padding: .5rem 0; } + +/* ── Immersive (full-screen) plugin screens ────────────────────────────────── + A plugin that declares `"fullscreen": true` in its manifest gets the whole + content area when its screen is active. shell.js toggles `html.fb-immersive` + on navigation (see syncActive). DAW-style plugin UIs (e.g. a practice studio) + need the viewport, not a scrolling content page below the topbar — the cause + of the "cut off at the bottom / too much top padding" reports on the embedded + layout. Mirrors the proven chrome-hide pattern in static/v3/index.html + (`html.ss-follower-pre …`), but keeps the sidebar as a functional icon rail + so the user is never trapped (Escape is bound only on player/settings + scopes, not plugin screens). */ +html.fb-immersive #v3-topbar { display: none !important; } + +/* Sidebar → icon rail: narrow it, drop the wordmark + group headers + labels, + center the remaining icons. The .v3-nav-label / .v3-nav-group hooks are added + by shell.js so these rules don't depend on Tailwind utility class strings. */ +html.fb-immersive #v3-sidebar { width: 4.5rem; } +html.fb-immersive #v3-brand { display: none; } +html.fb-immersive #v3-nav { padding-left: .5rem; padding-right: .5rem; } +html.fb-immersive #v3-nav .v3-nav-group, +html.fb-immersive #v3-nav .v3-nav-label { display: none; } +html.fb-immersive #v3-nav a { + justify-content: center; + gap: 0; + padding-left: 0; + padding-right: 0; +} + +/* The active plugin screen fills the now-topbar-less content area. #v3-main is + position:relative, so pinning the screen avoids relying on the auto-height + block flow that made a 100vh plugin overflow #v3-main and force a scroll. */ +html.fb-immersive #v3-main > .screen.active { + position: absolute; + inset: 0; + overflow: hidden; +} diff --git a/tests/test_plugins.py b/tests/test_plugins.py index fc3e519..256d50c 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -4035,3 +4035,35 @@ def test_settings_category_parsed_from_manifest(tmp_path, reset_plugin_state): assert rows["plainset"]["has_settings"] is True assert rows["noset"]["settings_category"] is None assert rows["noset"]["has_settings"] is False + + +def test_fullscreen_flag_parsed_from_manifest(tmp_path, reset_plugin_state): + """A plugin manifest's top-level `fullscreen: true` surfaces as the boolean + `fullscreen` on the loaded entry (drives the v3 shell's immersive mode). + Only a strict boolean `true` opts in — absent, false, or a truthy non-bool + (e.g. the string "true") all resolve to False so a plugin can't be opted in + by accident.""" + plugins = reset_plugin_state + + def _write(pid, manifest_extra): + d = tmp_path / pid + d.mkdir() + (d / "plugin.json").write_text(json.dumps({ + "id": pid, "name": pid, "routes": "routes.py", + "screen": "screen.html", **manifest_extra, + })) + (d / "routes.py").write_text("def setup(app, ctx):\n pass\n") + (d / "screen.html").write_text("
") + + _write("immersive", {"fullscreen": True}) + _write("strflag", {"fullscreen": "true"}) # truthy non-bool → not opted in + _write("falseflag", {"fullscreen": False}) + _write("noflag", {}) # field absent + + _run_load_plugins(plugins, type("FakeApp", (), {})(), tmp_path) + + rows = {p["id"]: p for p in plugins.LOADED_PLUGINS} + assert rows["immersive"]["fullscreen"] is True + assert rows["strflag"]["fullscreen"] is False + assert rows["falseflag"]["fullscreen"] is False + assert rows["noflag"]["fullscreen"] is False From b70fde9b02ef60620f397c8908b74380b3eae820 Mon Sep 17 00:00:00 2001 From: OmikronApex <45161725+OmikronApex@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:05:52 +0200 Subject: [PATCH 47/99] fix(player): new song no longer seeks to previous song's stop position (#595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit audio.currentTime does not reset synchronously when audio.src is cleared — it only resets when audio.load() is called (later, in highway.js). The jump-fix guard (setInterval ~line 8979) held lastAudioTime at the old position and, once the new song started playing from t=0, saw a 30s+ jump and sought the new song to the previous position. If the new song was shorter, song:ended fired immediately, showing the score screen. Reset lastAudioTime = 0 in playSong() so the guard has no stale anchor. Co-authored-by: Claude Sonnet 4.6 --- static/app.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/static/app.js b/static/app.js index cf64867..8b38566 100644 --- a/static/app.js +++ b/static/app.js @@ -5958,6 +5958,10 @@ async function playSong(filename, arrangement, options) { clearLoop(); _resetSectionPracticeLog(); _hideSectionPracticeBar(); + // Reset so the jump-fix (setInterval, ~line 8979) doesn't mistake the new + // song starting at t=0 for an unexpected seek from the previous song's + // position. audio.currentTime may not reset synchronously when src is cleared. + lastAudioTime = 0; currentFilename = filename; // A fresh load arms autoplay; a pending auto-exit from the previous From 97dae88860f07061f32e00df27ce0abaf892c8ca Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Fri, 26 Jun 2026 04:05:31 -0500 Subject: [PATCH 48/99] =?UTF-8?q?feat(highway=5F3d):=20colour=20theming=20?= =?UTF-8?q?=E2=80=94=20string=20presets=20+=20Background/Highway=20scene?= =?UTF-8?q?=20themes=20(#596)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(highway_3d): add one-click string-color presets Adds 12 named string-color presets (Warm→Cool, Vivid, Colorblind-friendly, Neon, Accessible, Warm Ember, Tape Deck, CRT Green/Amber, Pitch Ramp, Sunrise) selectable from the 3D Highway settings panel. Extends the existing core HWC (highway-color) subsystem in static/app.js with HWC_PRESETS + applyHighwayStringPreset(), exposed on the existing facade as window.feedBack.highwayColors.{presets, applyPreset}. The plugin settings page renders the preset buttons from that core list and refreshes the per-string pickers on apply. Purely additive — stock behavior is unchanged. Scope: core static/app.js (the shared HWC facade both highways consume) plus the highway_3d plugin's settings.html / screen.js / CLAUDE.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq * fix(highway_3d): address review of colour-theming PR - Rebuild assets/plugin.css so the new `flex-wrap` (preset row) and `text-[10px]` (theme-dropdown helper) Tailwind classes are actually compiled, and bump plugin.json 3.26.0 -> 3.27.0 so the 's ?v= cache-buster fetches the fresh CSS (per the plugin's build rule). - Replace the mirror-at-every-read hwTheme migration with a one-time backfill (persist hwTheme := bgTheme on first load, no emit). The two scene-color axes are now genuinely independent: changing the Background dropdown no longer silently retints the Highway surface/lane, and the rendered highway can't disagree with the Highway dropdown value. - Collapse the duplicated theme id-set in settings.html (two identical
+ +
+ Quick presets +
+
@@ -36,6 +45,33 @@ if (typeof window.hwcInitSettingsUI === 'function') { try { window.hwcInitSettingsUI(); } catch (e) { console.warn('[3D-Hwy] hwcInitSettingsUI failed', e); } } + // Render the one-click preset buttons from core's preset list. + try { + const api = window.feedBack && window.feedBack.highwayColors; + const host = document.getElementById('hwc-presets'); + if (api && Array.isArray(api.presets) && host) { + host.innerHTML = ''; + // Slot order low → high for the preview swatch (bass-side first). + const order = ['lowE', 'A', 'D', 'G', 'B', 'highE']; + for (const p of api.presets) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'flex items-center gap-2 bg-dark-700 border border-gray-800 rounded-lg px-2 py-1 text-xs text-gray-300'; + const sw = document.createElement('span'); + const stops = order.map((k) => p.colors[k]).filter(Boolean).join(','); + sw.style.cssText = 'width:2.5rem;height:0.85rem;border-radius:3px;border:1px solid #0006;' + + 'background:linear-gradient(90deg,' + stops + ');'; + btn.appendChild(sw); + const txt = document.createElement('span'); + txt.textContent = p.label; + btn.appendChild(txt); + btn.addEventListener('click', function () { + try { api.applyPreset(p.id); } catch (e) { console.warn('[3D-Hwy] applyPreset failed', e); } + }); + host.appendChild(btn); + } + } + } catch (e) { console.warn('[3D-Hwy] preset render failed', e); } })();
@@ -93,6 +129,36 @@
+ +
+ + + +

+ Tints the background + distance fog. Applies to the 3D highway immediately. +

+
+
+ + + +

+ Tints the fretboard surface + the lit lane. Applies to the 3D highway immediately. +

+
+
+ +
+ +
+
Show “Up Next”
+
Display the upcoming-section pill in the top-right of the player during playback.
+
+
+ +
+
diff --git a/static/v3/player-chrome.js b/static/v3/player-chrome.js index a80c09c..0657711 100644 --- a/static/v3/player-chrome.js +++ b/static/v3/player-chrome.js @@ -172,6 +172,8 @@ function updateUpNext() { const pill = $('v3-upnext'); if (!pill) return; + // Gated by the core "Show 'Up Next'" pref (Gameplay tab, default ON). + if (window.feedBack && window.feedBack.showUpNext === false) { pill.classList.add('hidden'); return; } const hw = window.highway; const secs = (hw && typeof hw.getSections === 'function') ? hw.getSections() : null; const t = (hw && typeof hw.getTime === 'function') ? hw.getTime() : null; diff --git a/static/v3/settings.js b/static/v3/settings.js index d125fb8..40d0bd3 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', 'arrangementNamingMode', 'countdownBeforeSong'], + local: ['lefty', 'autoplayExit', 'showUpNext', 'arrangementNamingMode', 'countdownBeforeSong'], after: function () { // Left-handed is held on the highway object, not re-derived // from localStorage on load — flip it back to the default. From 3b2d83d406a82268a597767984bd5b12b421d5a8 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 27 Jun 2026 10:03:54 -0400 Subject: [PATCH 56/99] feat(folder_library): Folder Library core plugin (#610) Adds the bundled Folder Library plugin (browse the DLC library by its on-disk folder tree, in-app folder CRUD, drag-and-drop + dialog song moves, sort/filter, live search), wired into the classic v2 toolbar and the v3 Songs page. Includes the screen.js IIFE dedup (unified surface factory) and review fixes: path-traversal guard on /song/move, folder-delete data-loss fix, plural /api/plugins/ namespace, loose-folder song recognition, error-text escaping, v3 setLibView null-guard, and tests. Co-authored-by: Kyle Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + CHANGELOG.md | 1 + README.md | 1 - plugins/folder_library/CLAUDE.md | 345 ++++ plugins/folder_library/README.md | 100 ++ plugins/folder_library/plugin.json | 10 + plugins/folder_library/routes.py | 440 +++++ plugins/folder_library/screen.html | 159 ++ plugins/folder_library/screen.js | 1672 +++++++++++++++++++ static/app.js | 37 +- static/index.html | 8 +- static/v3/songs.js | 53 +- tests/plugins/folder_library/test_routes.py | 208 +++ 13 files changed, 3030 insertions(+), 7 deletions(-) create mode 100644 plugins/folder_library/CLAUDE.md create mode 100644 plugins/folder_library/README.md create mode 100644 plugins/folder_library/plugin.json create mode 100644 plugins/folder_library/routes.py create mode 100644 plugins/folder_library/screen.html create mode 100644 plugins/folder_library/screen.js create mode 100644 tests/plugins/folder_library/test_routes.py diff --git a/.gitignore b/.gitignore index c191124..2052cf8 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ plugins/achievements/__pycache__/ !plugins/highway_3d/ !plugins/highway_3d/** plugins/highway_3d/__pycache__/ +!plugins/folder_library/ +!plugins/folder_library/** +plugins/folder_library/__pycache__/ !plugins/app_tour_library/ !plugins/app_tour_library/** !plugins/app_tour_settings/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d2793..8140f6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Folder Library — a bundled core plugin (`plugins/folder_library/`) that browses the DLC library by its on-disk folder tree.** Surfaces top-level folders → subfolders → songs (root-level songs land in `(Unsorted)`), with in-app folder management (create / rename / delete nested folders), song moves via dialog or drag-and-drop, and sort/filter that mirrors the host library's filter state. Wired into both the classic (v2) library toolbar and the v3 Songs page as a third **Folders** view alongside grid/tree; the plugin's `screen.js` is loaded once by the host and reused (idempotent IIFEs). Supersedes the former standalone "Folder Organizer" community plugin (removed from the README list). Backend (`routes.py`) registers `/api/plugins/folder_library/{tree,folder/create,folder/rename,folder/delete,song/move}`; **all filesystem mutations are confined to `DLC_DIR` and validated against path traversal** (per-segment name validation plus a resolved-containment check on `song/move`), and folder deletion relocates every song — de-duplicating colliding names — so a name clash never destroys a song. A two-level cache keeps re-opening folders fast. Tests: `tests/plugins/folder_library/test_routes.py` (path-safety helpers + move-traversal and delete-no-data-loss end-to-end). - **Full-screen (immersive) plugin screens — opt-in via `"fullscreen": true` in `plugin.json`.** DAW-style plugin UIs (e.g. a practice studio) need the whole viewport, not a scrolling content page below the topbar — embedded in the v3 shell they get cut off at the bottom with dead space up top. A plugin can now declare a top-level `"fullscreen": true`; `plugins/__init__.py` surfaces it as the `fullscreen` boolean on `/api/plugins` (mirroring the `settings_category` plumbing). When such a plugin's screen is active, `static/v3/shell.js` toggles `html.fb-immersive` from `syncActive()` (so it tracks every navigation incl. deep-link), and `static/v3/v3.css` hides the topbar, collapses the sidebar to a functional **icon rail** (kept reachable — Escape is bound only on player/settings scopes, so a fully-hidden sidebar would trap the user), and lets the active plugin screen fill `#v3-main`. Mirrors the existing `ss-follower-pre` chrome-hide pattern. Additive + opt-in: plugins without the flag are unaffected. Tests: `tests/test_plugins.py::test_fullscreen_flag_parsed_from_manifest`. - **Achievements wall sync — background drain worker (epic PR3, client side).** The bundled `achievements` plugin gains a dead-letter sync worker that POSTs queued Feat unlocks (and removals) to the hosted **feedback-achievements** wall service (separate repo). Idle unless a wall URL is configured (`FEEDBACK_ACHIEVEMENTS_WALL_URL`); uses `requests` with the baked-in client-token header, mirroring `lib/lyrics_transcribe`'s outbound pattern (explicit timeout, no raise on non-2xx). **Dead-letter, never drop** (pure `engine.drain_decision`): network error / `429` / `5xx` → keep `pending` (retry); other `4xx` → `dead_letter` (diagnosable, replayable); `2xx` → delete on server ack. A row leaves the queue only on ack or a user opt-out. `remove-me` now enqueues a wall removal keyed by the reused `player_hash`. Verified by an end-to-end staging round-trip (earn a Feat → drains onto the wall with name + short hash → `remove-me` → wall empties) with **no IP** in tables or access logs. Tests: `tests/plugins/achievements/test_sync.py` (decision table + ack/retry/dead-letter retention + four-field payload on the wire). The hosted service itself (FastAPI + SQLite-on-disk, Feats-only, hidden-until-first-global-unlock, profanity filter, in-memory rate limit, Render blueprint, migration tool) lives in the new `feedback-achievements` repo. - **Achievements wall — opt-in, privacy controls & data-minimization gate (epic PR2).** Sharing earned **Feats** on the (forthcoming) public wall is strictly opt-in. A new **onboarding step** (`static/v3/profile.js`, inserted after song-directory / before instrument paths — the wizard is now five steps) presents a plain-language card: it publishes only your display name and the Feats you earn, never songs/skills/scores, and is **off by default**. The bundled plugin's Settings panel (`plugins/achievements/settings.html`, mounted under the **System** tab via `settings.category`) carries the same toggle plus a **"Remove me from the wall"** button (`POST /api/plugins/achievements/remove-me` — wipes local synced state offline + enqueues a wall removal). Core adds `achievements_enabled` (bool, default `false`) to `_default_settings()` + the `/api/settings` validation block + `_RESETTABLE_SETTINGS_KEYS` in `server.py`, mirrored to `localStorage` in `app.js loadSettings()`. **Data-minimization contract (binding, code-enforced):** every outbound payload is built by a single explicit-dict serializer (`engine.build_wall_payload`, never `dict(row)`/`**model`) whose key-set is **exactly** `{display_name, player_hash, achievement_id, unlocked_at}` with `achievement_id` always a **Feat** id — a unit test asserts the four-field set and goes red on a fifth. Enqueue is doubly gated: it happens only when opted-in **and** a profile identity (name + the reused `player_hash`) exists; **competency unlocks never enqueue** (integration law). Tests: `tests/plugins/achievements/test_datamin.py` (key-set, opt-out/identity/competency gating) + `tests/test_settings_api.py` (flag persists/validates/resettable). diff --git a/README.md b/README.md index 9ec3929..f3e7b40 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ | [Update Manager](https://github.com/masc0t/slopsmith-update-manager) | Installs, updates, and uninstalls other plugins and the feedBack core itself | `git clone ...slopsmith-update-manager.git update_manager` | | [Simplify Chords](https://github.com/bkranendonk/slopsmith-plugin-simplify-chords) | Changes complex chords on the note highway to simpler ones. Inspired by Ultimate Guitar's Simplify button. | `git clone ...slopsmith-plugin-simplify-chords.git simplify-chords` | | [Key Bindings](https://github.com/jackipicco/slopsmith-plugin-key-bindings) | Highway key bindings for keyboard and TV remote | `git clone ...slopsmith-plugin-key-bindings.git key_bindings` | -| [Folder Organizer](https://github.com/Elit3d/slopsmith-plugin-folder-organizer) | Organize your sloppak DLC songs into a folder tree view, grouped by subfolder name | `git clone ...slopsmith-plugin-folder-organizer.git folder-organizer` | | [Virtuoso](https://github.com/got-feedback/feedBack-plugin-virtuoso) | Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen. | `git clone ...feedBack-plugin-virtuoso.git virtuoso` | | [Audio Preview](https://github.com/saleemk/slopsmith-plugin-audio-preview) | Quick audio previews from library cards with configurable start time, volume, and duration | `git clone ...slopsmith-plugin-audio-preview.git audio_preview` | | [Song Mastery](https://github.com/jamesgaiser/slopsmith-plugin-song-mastery) | Auto-adjusts difficulty based on your rolling note accuracy and saves the slider position per song | `git clone ...slopsmith-plugin-song-mastery.git song_mastery` | diff --git a/plugins/folder_library/CLAUDE.md b/plugins/folder_library/CLAUDE.md new file mode 100644 index 0000000..a44ea02 --- /dev/null +++ b/plugins/folder_library/CLAUDE.md @@ -0,0 +1,345 @@ +# Folder Library — AI Agent Guide + +A FeedBack (fee[dB]ack) plugin that adds a **Folders** nav screen showing your `.sloppak` / `.feedpak` DLC songs grouped by the folder tree on disk. Create, rename, and delete folders (including **nested subfolders**) directly in the UI, move songs by drag-and-drop, and browse with sort and metadata filters. + +> The host app is **FeedBack** (formerly "Slopsmith"). The frontend talks to the host through `window.feedBack`; `window.slopsmith` is a back-compat alias the host still exposes (`window.slopsmith = window.feedBack` in `static/app.js`). New code should prefer `window.feedBack`. + +> ⚠️ **Status — bundled core plugin.** This plugin began as a standalone plugin and is now a bundled core plugin. `screen.js` has been unified into a **single surface factory** driving two entry points: the v3 library Folder view (host chrome — host search `#v3-search`/`#lib-filter`, host filter params, renders into `#lib-folder-tree`) and the classic v2 standalone Folders nav-tab (its own `#fb-search` + toolbar, renders into `#fb-tree`). **Folder search works on both surfaces** — typing in the relevant search box re-renders the tree. **Loose-folder songs** (directories with audio + an arrangement XML) are recognised as songs via the host `loosefolder.is_loose_song` predicate, so they appear in the tree alongside `.sloppak`/`.feedpak` bundles. Folder management, nested subfolders, collapsible folders + expand/collapse-all, drag-and-drop, move-song, sort, filters, and the hover metadata badges are wired on both surfaces; verify against a running build before relying on any of it. + +## File Structure + +``` +plugin.json Plugin manifest — id, name, nav entry, file declarations ("bundled": true core plugin) +routes.py FastAPI backend — recursive DLC scan, folder tree + filters, folder/song mutations, two-level cache +screen.html Plugin screen content — injected by the host into the plugin div automatically +screen.js Frontend logic — recursive folder tree, search, sort, filters, drag-and-drop, modals +README.md User-facing docs +``` + +## Architecture + +This plugin follows the standard FeedBack plugin pattern (see the repo-root `CLAUDE.md` for the full plugin system reference). + +- **Backend** (`routes.py`) — registers routes under `GET/POST /api/plugins/folder_library/`. Uses `context["get_dlc_dir"]()`, `context["extract_meta"]()`, and `context["log"]`. Scans `/sloppak/` if it exists, otherwise `/`. Recursively walks the tree and handles create/rename/delete folder and move-song operations on slash-separated folder paths. +- **Frontend** (`screen.js`) — plain vanilla JS in an IIFE. Fetches the tree from the backend on screen load, recursively renders collapsible folder sections (any depth) and song rows or cards (grid view). Uses `window.feedBack.on('screen:changed', ...)` (via the `window.slopsmith` alias) to trigger load when the user navigates here. Calls `window.playSong(filename)` on song click with the full relative path from the DLC root. +- **No dependencies** — no npm, no build step. Tailwind utility classes available globally from the host; the plugin uses only core-guaranteed utilities and inline styles, so it ships **no** `styles` manifest key. + +## Critical Layout Lessons (Hard-Won) + +These are non-obvious behaviours of the FeedBack desktop app (Electron) that took significant debugging to discover. They still apply unchanged. + +### 1. Do NOT put an outer wrapper div in screen.html +The host automatically creates `
` and injects `screen.html` content inside it. If you add your own outer div with `class="screen"`, you get a nested screen element which gets `display:none` applied, hiding all content. + +**Wrong:** +```html +
+
toolbar
+
content
+
+``` + +**Correct:** +```html + +
toolbar
+
content
+``` + +### 2. The .screen CSS class sets display:none by default +`.screen { display: none }` and `.screen.active { display: block }`. There is no height set. The screen div gets its height purely from its content. Do not try to set height via CSS classes — use inline styles or JS if needed. + +### 3. The host navbar is position:fixed with z-index:50 +The navbar sits at `top:0, z-index:50`. Plugin toolbars must use `position:fixed; top:64px; z-index:40` to sit below the navbar. Use a solid `background-color` (not Tailwind bg classes — those may not apply correctly) to prevent content showing through. + +### 4. Content must have padding-top to clear the fixed toolbar +Since the toolbar is `position:fixed`, it floats above the content. The content container needs enough `padding-top` (~120px) to ensure the first item isn't hidden behind the toolbar — the host navbar (64px) plus the plugin toolbar height (~56px). Adding more toolbar buttons increases this height, so if content is clipped, increase the padding further. + +### 5. Electron blocks window.prompt() and window.confirm() +The desktop app is built on Electron, which throws `Error: prompt() is not supported`. Use a custom inline modal instead. See `_showModal()` in `screen.js` — it returns a Promise and supports both text input and confirm modes. + +### 6. The nav plugin dropdown has z-index:50 and blocks clicks +When navigating to a plugin screen via the Plugins dropdown, the dropdown stays open and sits on top of the screen. Call `_closeDropdown()` on screen load to dismiss it. The dropdown element id is `plugin-dropdown`. + +### 7. playSong() expects a relative path from the DLC root +`window.playSong()` expects the path relative to the DLC root with forward slashes, e.g. `sloppak/CH/Artist - Title.sloppak`. Not just the filename. The backend builds this in `_meta()` via `"/".join(p.relative_to(dlc).parts)` and returns it as each song's `filename`. + +### 8. FastAPI POST routes need `from fastapi import Request` +Routes that receive a JSON body must import `Request` from fastapi explicitly and use `async def route(request: Request)` with `body = await request.json()`. Missing this import crashes the server on plugin load. + +### 9. Plugin id must be consistent everywhere +The plugin id (`folder_library`) must match in: +- `plugin.json` → `"id"` and `"nav.screen"` +- `screen.js` → `PLUGIN_ID` constant and `API` constant (`/api/plugins/folder_library`) +- `routes.py` → `APIRouter(prefix="/api/plugins/folder_library")` + +A mismatch in any of these causes silent failures (blank screen, 404 API calls). + +### 10. Use inline styles for grid layout, not Tailwind +Tailwind's `grid` and `grid-cols-*` classes may not apply reliably inside the plugin div. Use `element.style.cssText` with explicit `display:grid; grid-template-columns:...` for the grid container. + +## Key Conventions + +- **IIFE + `'use strict'`** — all frontend code wrapped in `(function(){ 'use strict'; ... })();` +- **localStorage prefixes** — plugin keys are prefixed `fo:` (e.g. `fo:view`, `fo:sort`, `fo:filters`); host-library-synced filter state uses `fo:lib:`. Open-folder state is tracked by **folder path** (so nested folders each remember their own state). +- **Safe storage access** — all `localStorage` reads/writes wrapped in try/catch +- **Logging** — backend uses `context["log"]`, never `print()` +- **Sibling imports** — use `context["load_sibling"]("name")` not bare `import name` (none needed today; keep this in mind if you add helper modules) + +## Song Formats + +The plugin treats both `.sloppak` and `.feedpak` as songs (`_is_song()` in `routes.py`). `feedpak` is the published name for the same on-disk format the codebase still calls `sloppak` internally — see the repo-root `CLAUDE.md`. Both file form (`.sloppak`/`.feedpak` zip) and directory form (`*.sloppak/` folder) are recognized. + +## Backend Routes + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/plugins/folder_library/tree` | Returns the folder tree. Accepts optional filter query params (below) applied server-side. | +| POST | `/api/plugins/folder_library/folder/create` | Body: `{name, parent?}` — creates a subfolder; `parent` (slash path) nests it inside an existing folder, omit/empty for top level | +| POST | `/api/plugins/folder_library/folder/rename` | Body: `{old, new}` — `old` is a slash path, `new` is a bare name; renames within the same parent | +| POST | `/api/plugins/folder_library/folder/delete` | Body: `{name}` (slash path) — moves all songs at any depth to the scan root, then removes the folder | +| POST | `/api/plugins/folder_library/song/move` | Body: `{filename, folder}` — moves a song to `folder` (slash path; empty = scan root / "Unsorted") | + +### `/tree` filter query params + +All optional, applied server-side over the cached full tree by `_apply_tree_filters()`. Comma-separated, case-insensitive: + +- `arrangements_has`, `arrangements_lacks` — include/exclude by arrangement name +- `stems_has`, `stems_lacks` — include/exclude by stem name +- `has_lyrics` — `""` (any), `"1"`, or `"0"` +- `tunings` — comma-separated tuning names to include + +The frontend forwards the host library's active filter params here (via `window.feedBackLibFilterParams()` when present, with `window.slopsmithLibFilterParams()` as a legacy fallback) so the Folders view can stay in sync with the main library filters, falling back to its own filter panel state otherwise. + +### Path safety + +`_safe_name()` rejects empty names, leading/trailing whitespace, the characters `\ / : * ? " < > |`, and `.`/`..`. `_safe_path()` applies `_safe_name()` to every slash-separated segment, so traversal (`..`) and absolute paths are rejected before any filesystem op. Always validate user-supplied folder paths through these before touching disk. + +## Tree Shape + +`/tree` returns: + +```json +{ + "folders": [ + { + "name": "CH", + "path": "CH", + "songs": [ /* song objects */ ], + "children": [ + { "name": "Live", "path": "CH/Live", "songs": [], "children": [] } + ] + } + ], + "root_songs": [ /* songs sitting directly in the scan root — shown as "Unsorted" */ ] +} +``` + +Folder nodes are **recursive**: each has `name`, `path` (slash-separated, relative to the scan root), `songs`, and `children`. The frontend renders any depth — `_findFolderByPath()`, `_countDeep()`, and `_countFoldersDeep()` walk the `children` arrays. + +## Song Metadata Format + +Each song object (built by `_meta()`): + +```json +{ + "filename": "sloppak/CH/Artist - Title.sloppak", + "title": "Title", + "artist": "Artist", + "album": "Album Name", + "duration": 213.5, + "year": 1993, + "tuning": "E Standard", + "added": 1748132400.0, + "arrangements": ["Lead", "Rhythm", "Bass"], + "stems": ["Drums", "Bass", "Vocals"], + "lyrics": true +} +``` + +- `filename` is the full relative path from the DLC root — pass it directly to `window.playSong()`. +- `added` is a Unix timestamp (float, seconds) from `stat().st_mtime` — convert with `new Date(added * 1000)`. Always recomputed fresh (it changes when a file moves), even on a metadata-cache hit. +- `arrangements` / `stems` are flat lists of **strings**, even though `extract_meta()` returns them as objects. + +### extract_meta returns arrangements/stems as objects, not strings + +`context["extract_meta"]()` returns arrangements as a list of objects `{index, name, notes}`, not plain strings; stems similarly. `_meta()` normalizes to `.name`: + +```python +raw_arr = raw.get("arrangements") or [] +m["arrangements"] = [ + a["name"] if isinstance(a, dict) else str(a) + for a in raw_arr + if (isinstance(a, dict) and "name" in a) or isinstance(a, str) +] +``` + +`lyrics` is coerced to a bool from several possible keys (`lyrics`, `hasLyrics`, `has_lyrics`, …). If you add new metadata fields from `extract_meta`, check the raw shape before assuming it's a plain value. + +## Two-Level Cache + +`routes.py` keeps two caches inside `setup()`: + +- **`_meta_cache`** — expensive `extract_meta()` results keyed by absolute POSIX path. **Never cleared.** When files move (rename/delete/move), the keys are rewritten in-place so the warm data survives the operation. +- **`_cache`** — the assembled tree structure (`folders` / `root_songs`). Cleared by `_invalidate()` on **every** mutation so the next `/tree` rebuilds it — but the rebuild is fast because `_meta_cache` is still warm. + +`filename` and `added` are deliberately **not** stored in `_meta_cache` (they depend on the file's current location) — they're recomputed on every `_meta()` call and merged onto the cached copy. When you add a mutation route, mirror the existing key-rewrite logic (see `rename_folder`, `delete_folder`, `move_song`) so the metadata cache stays valid. + +## Folder Scan Logic + +`routes.py` scans recursively starting at `/sloppak/` (or `/` if no `sloppak` subdir exists): +- Files/dirs matching `.sloppak` or `.feedpak` → song entries (root-level ones go to `root_songs`, shown as "Unsorted") +- Subdirectories → recursive folder nodes with their own `songs` + `children` +- Dot-prefixed entries are skipped; empty folders are still included (shown with a 0 count) + +To add more grouping options (by artist, album, etc.), build an alternative projection over the scanned songs rather than the on-disk tree. + +## Library provider (future, not implemented) + +This plugin surfaces folders as a dedicated **view** over the existing library; +it does not (yet) register itself as a selectable library **source/provider**. +If you want a "Folders" entry to appear in the host's main library-source +picker (mapping top-level folder → "artist", subfolder → "album"), implement a +provider exposing the source-aware contract (`query_page`, `query_artists`, +`query_stats`, `tuning_names`) and register it in `setup()` via +`context["register_library_provider"](...)`, unregistering on teardown. (An +earlier inert `FolderLibraryProvider` scaffold was removed — it was never wired +and only duplicated the scan logic; re-add it only alongside real registration +and tests.) + +## View Modes (List / Grid) + +The toolbar has a list/grid toggle. Current view is stored in `localStorage` under `fo:view` (`'list'` or `'grid'`). + +- **List view** — `_songRow()`, rendered inside a `ml-5 space-y-0` div +- **Grid view** — `_songCard()`, rendered inside a CSS grid div (`auto-fill, minmax(150px,1fr)`) +- Both the folder and unsorted section renderers branch on `_view` to pick the right renderer and container +- Album art is fetched via `/api/song//art` where each path segment is individually `encodeURIComponent`-encoded. On error the `` is hidden and a placeholder SVG is shown +- The collapse/expand toggle restores `display:grid` (not just `display:''`) when reopening a folder in grid mode — always check this when changing toggle logic + +### Lazy folder rendering + +Folders do **not** render their song list on initial load. The folder renderer sets a `_listPopulated` flag and only populates the list the first time a folder is opened, keeping the initial render fast with large libraries. When search is active all folders are forced open and populated immediately (search overrides lazy loading). + +## Sort System + +The toolbar has a sort select (`#fb-sort`) and a direction toggle (`#fb-sort-dir`). State is stored under `fo:sort` and `fo:sortDir`. + +- `_sort` — `'default' | 'title' | 'artist' | 'duration' | 'year' | 'tuning' | 'added'` +- `_sortDir` — `'asc' | 'desc'` +- `_sortSongs(songs)` returns a sorted copy; direction is applied by reversing after sort. Returns the array unchanged when `_sort === 'default'`. +- The sort direction button is dimmed (`opacity: 0.35`) and non-interactive when sort is `'default'`. + +## Filter System + +Client-side filters are stored under `fo:filters` as a JSON object. (The server `/tree` endpoint can also filter — see Backend Routes — used to sync with the host library.) + +### Filter state shape + +```js +_filters = { + arrangements: { Lead: 'on', Bass: 'exclude', Rhythm: 'off' }, + stems: { Drums: 'off' }, + lyrics: 'off', // 'off' | 'on' | 'exclude' + tunings: ['E Standard', 'Eb Standard'], +} +``` + +Each arrangement/stem value is `'off' | 'on' | 'exclude'`. + +### Include vs exclude logic + +`_matchFilters(song)` uses **OR logic for includes, AND logic for excludes**: + +- **Include (`'on'`)** — song passes if it has *at least one* selected arrangement/stem. More includes widens the result set. +- **Exclude (`'exclude'`)** — each excluded tag independently removes songs that have it. More excludes narrows the result set. + +This matches standard multi-select filter UX (Spotify/library style). + +### Data-driven filter panel + +All filter sections are built from the actual library data — nothing is hardcoded: + +- `_getArrangements()` — unique arrangement names sorted by frequency (most common first), then alphabetically +- `_getStems()` — same pattern for stem names +- `_getAvailableFilters()` — returns `{ arrangements, stems, lyrics, tuning }` booleans gating the lyrics/tuning sections + +Non-standard arrangement names (e.g. `"Bonus"`) appear as pills automatically — no constants to update. The stems section only appears if at least one song has stems data. + +### Split pill UI + +`_makeSplitPill(label, state, onChange)` renders a two-zone pill: +- Left zone (label) — toggles `'off' ↔ 'on'` (include, blue) +- Right zone (`✕`) — toggles `'off' ↔ 'exclude'` (exclude, red) + +The filter badge (`#fb-filter-badge`) shows the active filter count via `_activeFilterCount()`. + +## Hover Badges + +Each song row/card has two hidden hover-reveal layers, built once and toggled via CSS `max-height` + `opacity` transitions. + +### `_badge(text, active, type)` + +Renders a single metadata badge. Type controls the inactive colour: + +| type | inactive border | inactive text | +|---|---|---| +| `'arrangement'` | amber `#92400e` | amber `#fcd34d` | +| `'stem'` | violet `#5b21b6` | violet `#c4b5fd` | +| `'lyrics'` | rose `#9f1239` | rose `#fda4af` | +| `'tuning'` | teal `#0f766e` | teal `#5eead4` | + +Active state is always blue (`#1d4ed8` fill, `#3b82f6` border, white text) regardless of type. + +### `_buildSongBadges(song)` + +Builds the badge row (arrangements, stems, lyrics, tuning), deduplicating within each category. Clicking a badge toggles that filter on/off and re-renders. Returns `null` if the song has no filterable metadata. + +### `_buildSongDateInfo(song)` + +Builds a separate plain-text hover line showing `year · date added` (e.g. `1993 · 24 May 2026`), `#cbd5e1` text. Always shown on hover regardless of filter state. + +### Reveal / hide + +```js +_revealBadges(el) // max-height:120px, opacity:1, margin-top:4px +_hideBadges(el) // max-height:0, opacity:0, margin-top:0 +``` + +Both badge layers (badges + date-info) are wired to the same `mouseenter`/`mouseleave` events on the row or card element. + +## Drag-and-Drop + +Drag-and-drop uses **pointer events** (mousedown/mousemove/mouseup), not the HTML5 DnD API. HTML5 DnD blocks wheel events and gives unreliable edge positions inside Electron — pointer events give full control. + +- `_makeDraggable(el, song, folderName)` — attaches a `mousedown` listener. A drag goes "live" only after the pointer moves more than `_DRAG_THRESH` (5 px), preventing accidental drags on clicks. +- Once live, a ghost `div` follows the cursor. Auto-scroll activates when the pointer is within `_DRAG_ZONE` (150 px) of the viewport top/bottom. +- `_makeDropTarget(el, targetFolder)` — sets `data-dropFolder` so an element can receive drops. Both folder headers and song-list containers are drop targets — including **nested** folders (drop onto a subfolder header moves the song there). +- `_dragFindTarget(x, y)` — uses `document.elementsFromPoint` to find the topmost element with `data-dropFolder` under the cursor. +- **Esc to cancel** — `_onDragKeyDown` calls `_endPointerDrag()` on `Escape`, removing the ghost and clearing state without dropping. +- On a successful drop, `_executeDrop()` does an **optimistic UI update** (moves the song in the in-memory tree and re-renders) then calls `/song/move`. On API failure it reloads the full tree. +- A one-time `click` capture listener after mouseup suppresses the post-drag click so it doesn't trigger playback. + +## Modal Behaviour + +`_showModal(msg, withInput, defaultVal)` is the custom modal used for all prompts and confirms (Electron blocks `window.prompt()` / `window.confirm()`). It returns a Promise. + +- `_confirm(msg)` — resolves `true` on OK, `null` on cancel +- `_prompt(msg, default)` — resolves the trimmed input string on OK, `null` on cancel +- **Esc cancels** — resolves with `null`, same as Cancel (applies to rename, delete, create folder/subfolder, move song) +- **Enter confirms** — submits, equivalent to OK + +## Roadmap + +Implemented since the original release: **nested subfolders** (recursive tree + create-inside-folder), drag-and-drop, sort, advanced filtering, server-side tree filtering synced to the host library, and the warm metadata cache. + +Not yet implemented, in rough priority order: + +- **Auto-play on hover** — with an on/off toggle saved to localStorage. +- **Bulk move** — multi-select songs and move them all at once. +- **Thumbnail performance** — faster loading and smoother scrolling with large libraries. +- **Adjustable thumbnail/row sizes** — user-resizable song cards and list rows. +- **Custom themes** — switchable colour schemes. +- **Favoriting songs** — likely a new backend route plus a `fo:favorites` localStorage key. +- **Editing song metadata** — edit title, artist, album etc. in-plugin; needs new backend write routes. +- **Folders as a library source** — register a library provider so a "Folders" entry appears in the host's main library-source picker (see "Library provider (future)" above). diff --git a/plugins/folder_library/README.md b/plugins/folder_library/README.md new file mode 100644 index 0000000..2d102f8 --- /dev/null +++ b/plugins/folder_library/README.md @@ -0,0 +1,100 @@ +# Folder Library — FeedBack Plugin + +![Core plugin](https://img.shields.io/badge/fee%5BdB%5Dack-core%20plugin-blue) +![Platform](https://img.shields.io/badge/platform-fee%5BdB%5Dack-darkblue) + +A FeedBack (fee[dB]ack) plugin that organizes your `.sloppak` / `.feedpak` DLC songs into a folder tree, grouped by the folders on disk. Browse your whole library visually with album art, nest folders as deep as you like, switch between list and grid layouts, and manage folders without ever leaving the app. + +--- + +## Screenshots + +![Grid view](assets/grid-view.webp) +*Grid view — album art cards with title and artist* + +![Grid search](assets/grid-search.png) +*Live search filters instantly across all folders* + +![List view](assets/list-view.png) +*List view — compact rows with album art thumbnails and duration* + +![New folder](assets/new-folder.png) +*Create and manage folders directly in the UI* + +--- + +> **Status — migrating to core.** Folder Library is being reworked from a standalone plugin into a bundled core plugin, and several previously-shipped features are not currently wired up in core (see the Roadmap). The list below reflects what works today; if something here is wrong, it's because this rework is still in progress. + +## Features + +- **List & Grid views** — toggle between a compact list with thumbnails or a full album art card grid +- **Album art** — pulls art automatically for every song in both views +- **One-click playback** — click any song to start playing immediately +- **Sort options** — sort songs by title, artist, duration, year, tuning, or recently added with an asc/desc toggle +- **Advanced filters** — filter by arrangements, stems, lyrics, and tuning with include and exclude support +- **Folder management** — create, rename, and delete folders without leaving the plugin +- **Nested subfolders** — organize as deep as you want; create a subfolder inside any folder, expand/collapse a whole branch in one click +- **Collapsible folders** — expand/collapse individual folders, plus Expand All / Collapse All +- **Move songs** — reassign any song to a different folder on the fly; press `Esc` to cancel +- **Drag-and-drop** — drag songs between folders (including into nested folders) with smooth auto-scroll; press `Esc` to cancel +- **Fast with big libraries** — folder song lists render lazily and metadata is cached so reopening folders is instant + +--- + +## Installation + +Folder Library ships bundled with FeedBack as a core plugin (`"bundled": true`), so there's nothing to install — the **Folders** screen appears in the navbar under **Plugins** automatically. + +--- + +## Usage + +| Action | How | +|--------|-----| +| Switch to grid view | Click the grid icon in the toolbar | +| Switch to list view | Click the list icon in the toolbar | +| Play a song | Click any song row or card | +| Sort songs | Use the sort dropdown in the toolbar | +| Toggle sort direction | Click the arrow button next to the sort dropdown | +| Open filters | Click the filter icon in the toolbar | +| Filter by arrangement/stem | Open filters → click a pill to include; click `✕` to exclude | +| Clear all filters | Open filters → click "Clear all" | +| Create a folder | Click the folder+ icon in the toolbar | +| Create a subfolder | Hover a folder header → click the new-subfolder icon | +| Rename a folder | Hover the folder header → click the pencil icon | +| Delete a folder | Hover the folder header → click the trash icon (songs move up to Unsorted) | +| Move a song | Hover the song row → click the folder icon | +| Drag a song to a folder | Click and hold a song → drag to a folder header or body (nested folders work too) | +| Cancel a drag | Press `Esc` while holding a song | +| Cancel a move dialog | Press `Esc` in the move prompt | +| Expand / collapse a folder | Click the folder header | +| Expand / collapse all subfolders | Use the expand/collapse-children buttons on a folder with subfolders | + +--- + +## Changelog + +Folder Library started life as a standalone plugin with its own version line, but it's now a **bundled core plugin** that ships with FeedBack. Its changes are tracked alongside the app in the repo-root [CHANGELOG.md](../../CHANGELOG.md), and it versions with the app rather than on its own. The **Features** section above reflects what's in the current build. + +--- + +## Roadmap + +- [ ] Auto play song on hover (with an on/off toggle) +- [ ] Bulk move — select multiple songs and move them at once +- [ ] Thumbnail performance — faster loading and smoother scrolling with large song libraries +- [ ] Adjustable thumbnail and row sizes — resize song cards and list rows to suit your preference +- [ ] Custom themes — switch between colour schemes to match your style +- [ ] Favoriting songs +- [ ] Editing song metadata + +--- + +## Contributing + +Pull requests are welcome. For major changes please open an issue first to discuss what you'd like to change. + +1. Fork the repo +2. Create a feature branch (`git checkout -b feature/your-feature`) +3. Commit your changes +4. Push to the branch and open a pull request diff --git a/plugins/folder_library/plugin.json b/plugins/folder_library/plugin.json new file mode 100644 index 0000000..02b8a93 --- /dev/null +++ b/plugins/folder_library/plugin.json @@ -0,0 +1,10 @@ +{ + "id": "folder_library", + "name": "Folder Library", + "version": "1.8.0", + "bundled": true, + "nav": { "label": "Folders", "screen": "plugin-folder_library" }, + "screen": "screen.html", + "script": "screen.js", + "routes": "routes.py" +} diff --git a/plugins/folder_library/routes.py b/plugins/folder_library/routes.py new file mode 100644 index 0000000..6163f9c --- /dev/null +++ b/plugins/folder_library/routes.py @@ -0,0 +1,440 @@ +""" +Folder Library plugin — routes.py + +Surfaces the DLC folder structure as a navigable tree and provides in-app +folder management (create / rename / delete) and song moves. Every filesystem +mutation is confined to DLC_DIR and validated against path traversal. +""" + +from pathlib import Path +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse +import shutil +import re + + +# ── Pure, testable helpers ───────────────────────────────────────────────── + +_UNSAFE_NAME_RE = re.compile(r'[\\/:*?"<>|]') + + +def _safe_name(name: str) -> bool: + """A single path segment is safe: no separators, no traversal dot-names, + no surrounding whitespace, no characters illegal across filesystems.""" + if not name or name.strip() != name: + return False + if _UNSAFE_NAME_RE.search(name): + return False + if name in (".", ".."): + return False + return True + + +def _safe_path(path_str: str) -> bool: + """A slash-separated path is safe iff every segment is a safe name.""" + if not path_str: + return False + return all(_safe_name(p) for p in path_str.split("/")) + + +def _is_within(root: Path, candidate: Path) -> bool: + """True iff ``candidate`` resolves to a location inside ``root`` (after + normalising ``..`` and symlinks). Containment backstop for file moves so a + crafted filename can't escape DLC_DIR even past the segment validator.""" + try: + candidate.resolve().relative_to(root.resolve()) + return True + except (ValueError, OSError): + return False + + +def _path_to_dir(root: Path, folder_path: str) -> Path: + """Resolve a slash-separated folder path relative to ``root``.""" + result = root + for part in folder_path.split("/"): + result = result / part + return result + + +def _load_is_loose_song(): + """The host's authoritative loose-folder predicate (lib/loosefolder.py), + imported lazily so the plugin still loads if it's ever unavailable. A + loose-folder song is a directory carrying audio + an arrangement XML rather + than a ``.sloppak`` bundle, so the plain suffix check below misses it.""" + try: + from loosefolder import is_loose_song + return is_loose_song + except Exception: + return None + + +_IS_LOOSE_SONG = _load_is_loose_song() + + +def _is_song(p: Path) -> bool: + """A song carrier is a ``.sloppak`` / ``.feedpak`` file or directory-form + bundle (extension on the leaf name), or a host-recognised loose-folder song + directory — so loose-folder charts surface in the tree like any other song + instead of being walked into as if they were ordinary folders.""" + if p.suffix.lower() in (".sloppak", ".feedpak"): + return True + if _IS_LOOSE_SONG is not None and p.is_dir(): + try: + return bool(_IS_LOOSE_SONG(p)) + except Exception: + return False + return False + + +def setup(app, context): + log = context["log"] + router = APIRouter(prefix="/api/plugins/folder_library") + + # ── Two-level cache ──────────────────────────────────────────────── + # _meta_cache — expensive extract_meta() results keyed by abs path + # (as_posix() string). Never cleared; keys are updated + # in-place when files are moved so the data stays valid. + # _cache — tree structure ("folders" / "root_songs"). Cleared on + # every mutation so the next /tree request rebuilds it — + # but that rebuild is now fast because _meta_cache is warm. + _cache = {} # "tree" → JSONResponse-ready dict + _meta_cache = {} # abs_posix_path → extracted meta (no filename/added) + + def _invalidate(): + """Clear the tree structure cache only. _meta_cache is preserved.""" + _cache.clear() + + def _dlc_root() -> Path | None: + try: + return Path(context["get_dlc_dir"]()) + except Exception: + return None + + def _scan_root(dlc: Path) -> Path: + sloppak = dlc / "sloppak" + return sloppak if sloppak.exists() else dlc + + def _meta(p: Path, dlc: Path) -> dict: + # filename and added are always computed fresh — they change when files move. + try: + filename = "/".join(p.relative_to(dlc).parts) + except ValueError: + filename = p.name + added = None + try: + added = p.stat().st_mtime + except Exception: + pass + + # Return cached extracted metadata if available. + cache_key = p.as_posix() + if cache_key in _meta_cache: + m = dict(_meta_cache[cache_key]) # shallow copy + m["filename"] = filename + m["added"] = added + return m + + # Cache miss — run the expensive extract. + m = {"title": None, "artist": None, "album": None, "duration": None, + "year": None, "tuning": None, "arrangements": [], "stems": [], "lyrics": False} + try: + raw = context["extract_meta"](p) + if raw: + m["title"] = raw.get("title") or raw.get("name") + m["artist"] = raw.get("artist") or raw.get("artistName") + m["album"] = raw.get("album") or raw.get("albumName") + m["duration"] = raw.get("duration") + m["year"] = raw.get("year") + m["tuning"] = raw.get("tuning") + + # arrangements — objects with a "name" key e.g. [{name:"Lead",...}, ...] + raw_arr = raw.get("arrangements") or [] + if isinstance(raw_arr, (list, tuple)): + m["arrangements"] = [ + a["name"] if isinstance(a, dict) else str(a) + for a in raw_arr + if (isinstance(a, dict) and "name" in a) or isinstance(a, str) + ] + + # stems — may also be objects with a "name" key, same as arrangements + raw_stems = raw.get("stems") or [] + for _key in ("stems", "stem_types", "available_stems", "stemTypes"): + _v = raw.get(_key) + if _v: + raw_stems = _v + break + if isinstance(raw_stems, (list, tuple)): + m["stems"] = [ + a["name"] if isinstance(a, dict) else str(a) + for a in raw_stems + if (isinstance(a, dict) and "name" in a) or isinstance(a, str) + ] + + # lyrics — try common key variants + for _key in ("lyrics", "hasLyrics", "has_lyrics", "lyric", "hasLyric"): + _val = raw.get(_key) + if _val is not None: + if isinstance(_val, str): + m["lyrics"] = _val.lower() not in ("", "false", "no", "0") + else: + m["lyrics"] = bool(_val) + break + except Exception as exc: + log.debug("meta failed for %s: %s", p.name, exc) + if not m["title"]: + m["title"] = p.stem + + _meta_cache[cache_key] = m # store without filename/added + result = dict(m) + result["filename"] = filename + result["added"] = added + return result + + def _scan_dir(path: Path, root: Path, dlc: Path) -> dict: + """Recursively scan a directory and return a folder node.""" + songs = [] + children = [] + try: + for entry in sorted(path.iterdir(), key=lambda p: p.name.lower()): + if entry.name.startswith("."): + continue + if _is_song(entry): + songs.append(_meta(entry, dlc)) + elif entry.is_dir(): + children.append(_scan_dir(entry, root, dlc)) + except PermissionError: + log.warning("permission denied: %s", path) + try: + rel = path.relative_to(root) + folder_path = "/".join(rel.parts) + except ValueError: + folder_path = path.name + return { + "name": path.name, + "path": folder_path, + "songs": songs, + "children": children, + } + + def _apply_tree_filters(tree, arrangements_has="", arrangements_lacks="", + stems_has="", stems_lacks="", has_lyrics="", tunings=""): + """Filter a cached tree dict by arrangement/stem/lyrics/tuning params. + The cache always holds the full unfiltered tree; this is applied per-request.""" + def _split(s): + return [x.strip().lower() for x in s.split(",") if x.strip()] if s else [] + + arr_has = _split(arrangements_has) + arr_lacks = _split(arrangements_lacks) + st_has = _split(stems_has) + st_lacks = _split(stems_lacks) + tun_set = set(_split(tunings)) + lyr = None if has_lyrics == "" else (has_lyrics == "1") + + if not any([arr_has, arr_lacks, st_has, st_lacks, tun_set, lyr is not None]): + return tree # no filters active — return as-is + + def _song_ok(s): + arrs = [a.lower() for a in (s.get("arrangements") or [])] + stms = [x.lower() for x in (s.get("stems") or [])] + if arr_has and not any(a in arrs for a in arr_has): return False + if arr_lacks and any(a in arrs for a in arr_lacks): return False + if st_has and not any(x in stms for x in st_has): return False + if st_lacks and any(x in stms for x in st_lacks): return False + if lyr is not None and bool(s.get("lyrics")) != lyr: return False + if tun_set and (s.get("tuning") or "").lower() not in tun_set: return False + return True + + def _filter_node(node): + return { + "name": node["name"], + "path": node["path"], + "songs": [s for s in node["songs"] if _song_ok(s)], + "children": [_filter_node(c) for c in node.get("children", [])], + } + + return { + "folders": [_filter_node(f) for f in tree["folders"]], + "root_songs": [s for s in tree["root_songs"] if _song_ok(s)], + } + + @router.get("/tree") + def get_tree( + arrangements_has: str = "", + arrangements_lacks: str = "", + stems_has: str = "", + stems_lacks: str = "", + has_lyrics: str = "", + tunings: str = "", + ): + if "tree" not in _cache: + dlc = _dlc_root() + if not dlc or not dlc.exists(): + return JSONResponse({"folders": [], "root_songs": [], + "error": "DLC directory not found"}) + root = _scan_root(dlc) + log.info("folder_library: scanning %s", root) + folders = [] + root_songs = [] + try: + for entry in sorted(root.iterdir(), key=lambda p: p.name.lower()): + if entry.name.startswith("."): + continue + if _is_song(entry): + root_songs.append(_meta(entry, dlc)) + elif entry.is_dir(): + folders.append(_scan_dir(entry, root, dlc)) + except PermissionError: + return JSONResponse({"folders": [], "root_songs": [], + "error": "Permission denied"}) + _cache["tree"] = {"folders": folders, "root_songs": root_songs} + + result = _apply_tree_filters( + _cache["tree"], arrangements_has, arrangements_lacks, + stems_has, stems_lacks, has_lyrics, tunings, + ) + return JSONResponse(result) + + @router.post("/folder/create") + async def create_folder(request: Request): + body = await request.json() + name = (body.get("name") or "").strip() + parent = (body.get("parent") or "").strip() + if not _safe_name(name): + return JSONResponse({"error": "Invalid folder name"}, status_code=400) + if parent and not _safe_path(parent): + return JSONResponse({"error": "Invalid parent path"}, status_code=400) + dlc = _dlc_root() + if not dlc: + return JSONResponse({"error": "DLC dir not found"}, status_code=500) + root = _scan_root(dlc) + parent_dir = _path_to_dir(root, parent) if parent else root + if parent and not parent_dir.exists(): + return JSONResponse({"error": "Parent folder not found"}, status_code=404) + target = parent_dir / name + if target.exists(): + return JSONResponse({"error": "Folder already exists"}, status_code=400) + try: + target.mkdir(parents=False) + _invalidate() + return JSONResponse({"ok": True}) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + @router.post("/folder/rename") + async def rename_folder(request: Request): + body = await request.json() + old = (body.get("old") or "").strip() + new = (body.get("new") or "").strip() + if not _safe_path(old) or not _safe_name(new): + return JSONResponse({"error": "Invalid folder name"}, status_code=400) + dlc = _dlc_root() + if not dlc: + return JSONResponse({"error": "DLC dir not found"}, status_code=500) + root = _scan_root(dlc) + src = _path_to_dir(root, old) + dst = src.parent / new # rename within the same parent + if not src.exists(): + return JSONResponse({"error": "Folder not found"}, status_code=404) + if dst.exists(): + return JSONResponse({"error": "Name already taken"}, status_code=400) + try: + # Pre-compute meta cache key updates (keys change because the + # folder path changes — all files under src get a new prefix). + old_prefix = src.as_posix() + "/" + new_prefix = dst.as_posix() + "/" + meta_updates = { + key: new_prefix + key[len(old_prefix):] + for key in list(_meta_cache) + if key.startswith(old_prefix) + } + src.rename(dst) + _invalidate() + for old_key, new_key in meta_updates.items(): + if old_key in _meta_cache: + _meta_cache[new_key] = _meta_cache.pop(old_key) + return JSONResponse({"ok": True}) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + @router.post("/folder/delete") + async def delete_folder(request: Request): + body = await request.json() + name = (body.get("name") or "").strip() + if not _safe_path(name): + return JSONResponse({"error": "Invalid folder path"}, status_code=400) + dlc = _dlc_root() + if not dlc: + return JSONResponse({"error": "DLC dir not found"}, status_code=500) + root = _scan_root(dlc) + target = _path_to_dir(root, name) + if not target.exists(): + return JSONResponse({"error": "Folder not found"}, status_code=404) + try: + # Relocate every song (at any depth) up to the scan root BEFORE + # removing the folder. Colliding filenames are de-duplicated so a + # name clash never leaves a song behind to be destroyed by rmtree + # (the folder is advertised as "moves its songs to Unsorted"). + for song_path in sorted(target.rglob("*")): + if not song_path.exists(): + continue # a parent song-dir was already relocated + if not _is_song(song_path): + continue + old_key = song_path.as_posix() + dest = root / song_path.name + if dest.exists(): + stem, suffix = song_path.stem, song_path.suffix + n = 1 + while dest.exists(): + dest = root / f"{stem} ({n}){suffix}" + n += 1 + song_path.rename(dest) + if old_key in _meta_cache: + _meta_cache[dest.as_posix()] = _meta_cache.pop(old_key) + shutil.rmtree(target) + _invalidate() + return JSONResponse({"ok": True}) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + @router.post("/song/move") + async def move_song(request: Request): + body = await request.json() + filename = (body.get("filename") or "").strip() + dest_folder = (body.get("folder") or "").strip() + # Validate the source path like the folder ops, AND confirm it resolves + # inside DLC_DIR — without this a filename such as "../../etc/passwd" + # would be renamed (moved) into the served library and become readable. + if not filename or not _safe_path(filename): + return JSONResponse({"error": "Invalid filename"}, status_code=400) + dlc = _dlc_root() + if not dlc: + return JSONResponse({"error": "DLC dir not found"}, status_code=500) + src = dlc / Path(*filename.split("/")) + if not _is_within(dlc, src): + return JSONResponse({"error": "Invalid filename"}, status_code=400) + if not src.exists(): + return JSONResponse({"error": "Song not found"}, status_code=404) + root = _scan_root(dlc) + if dest_folder: + if not _safe_path(dest_folder): + return JSONResponse({"error": "Invalid folder path"}, status_code=400) + dst_dir = _path_to_dir(root, dest_folder) + if not dst_dir.exists(): + return JSONResponse({"error": "Destination folder not found"}, status_code=404) + else: + dst_dir = root + dst = dst_dir / src.name + if dst.exists(): + return JSONResponse({"error": "File already exists at destination"}, status_code=400) + try: + old_key = src.as_posix() + src.rename(dst) + if old_key in _meta_cache: + _meta_cache[dst.as_posix()] = _meta_cache.pop(old_key) + _invalidate() + return JSONResponse({"ok": True}) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + app.include_router(router) + log.info("folder_library routes registered") diff --git a/plugins/folder_library/screen.html b/plugins/folder_library/screen.html new file mode 100644 index 0000000..7dc794a --- /dev/null +++ b/plugins/folder_library/screen.html @@ -0,0 +1,159 @@ + + + +
+ +

Folders

+ + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + + + + + + diff --git a/plugins/folder_library/screen.js b/plugins/folder_library/screen.js new file mode 100644 index 0000000..0a9fa0c --- /dev/null +++ b/plugins/folder_library/screen.js @@ -0,0 +1,1672 @@ +/* Folder Browser — screen.js + * Plain JS, global scope, IIFE. Follows feedBack plugin conventions. + * + * ONE shared implementation (`createFolderSurface`) parameterised by a + * "surface config", consumed by two thin adapters: + * • NAV adapter — the standalone "Folders" nav-screen in the classic (v2) + * UI. Owns its own toolbar + search (#fb-*), client-side filter panel and + * local sort, renders into #fb-tree. + * • LIB adapter — the folder VIEW embedded in the v3 Songs page. Uses host + * chrome (host search #v3-search/#lib-filter, host filter params, host + * sort), renders into #lib-folder-tree, injects a toolbar into + * #lib-folder-controls, and exposes window.folderLibrary = {load, unload}. + * + * Each surface is an independent factory instance with its own closure state, + * so the two never share mutable state even when both run on the same page + * (they do in classic v2, where #lib-folder-tree also exists). + */ +(function () { +'use strict'; + +const API = '/api/plugins/folder_library'; + +// ════════════════════════════════════════════════════════════════════════ +// Shared surface factory +// ════════════════════════════════════════════════════════════════════════ +function createFolderSurface(cfg) { + + // ── Safe localStorage helpers (cfg.storePrefix keeps surfaces separate) ─ + function _store(key, val) { + try { + if (val === undefined) return localStorage.getItem(cfg.storePrefix + key); + localStorage.setItem(cfg.storePrefix + key, val); + } catch (_) { return null; } + } + function _storeJSON(key, val) { + try { + if (val === undefined) return JSON.parse(localStorage.getItem(cfg.storePrefix + key) || 'null'); + localStorage.setItem(cfg.storePrefix + key, JSON.stringify(val)); + } catch (_) { return null; } + } + + // ── State ─────────────────────────────────────────────────────────── + let _tree = null; + let _loaded = false; + let _lastFilterParams = null; // params string used for the last /tree fetch + let _openFolders = new Set(_storeJSON('open') || []); + let _unsortedOpen = _store(cfg.unsortedKey) !== 'false'; + let _view = _store('view') || 'list'; // 'list' | 'grid' + let _sort = _store('sort') || 'default'; + let _sortDir = _store('sortDir') || 'asc'; + let _toolbarDone = false; + let _hoveredFolder = null; // { wrap, hdr, btnGroup } — only innermost folder is active + + // ── Core arrangement order (pinned to top of filter panel) ────────── + const _CORE_ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo']; + + // ── Client-side filter state (nav surface only) ───────────────────── + var _filtersRaw = _storeJSON('filters') || {}; + function _normFilterGroup(g) { + var out = {}; + for (var k in (g || {})) { + var v = g[k]; + out[k] = v === 'require' ? 'on' : v === 'any' ? 'off' : v; + } + return out; + } + let _filters = { + arrangements: _normFilterGroup(_filtersRaw.arrangements), + stems: _normFilterGroup(_filtersRaw.stems), + lyrics: (_filtersRaw.lyrics === 'require' || _filtersRaw.lyrics === 'on') ? 'on' + : (_filtersRaw.lyrics === 'exclude') ? 'exclude' : 'off', + tunings: _filtersRaw.tunings || [], + }; + + // ── DOM helpers ───────────────────────────────────────────────────── + function _el(id) { return document.getElementById(id); } + function _treeEl() { return document.getElementById(cfg.treeId); } + + // ── Force screen to have height (nav screen has no height set) ────── + function _fixHeight() { + const el = _el(cfg.screenId); + const nav = document.querySelector('nav'); + const navH = nav ? nav.offsetHeight : 64; + if (el) el.style.minHeight = (window.innerHeight - navH) + 'px'; + } + + // ── Close the nav plugin dropdown (sits at z-50 and blocks clicks) ── + function _closeDropdown() { + var dd = _el('plugin-dropdown'); + if (dd) dd.classList.add('hidden'); + } + + // ── Status (nav status bar only) ──────────────────────────────────── + // Gated by cfg.ownsStatus so the library surface never mutates the nav + // screen's shared #fb-status element when both instances live on one page. + function _status(msg, isErr) { + if (!cfg.ownsStatus) return; + const el = _el('fb-status'); + if (!el) return; + el.textContent = msg || ''; + el.className = 'text-xs ml-1 ' + (isErr ? 'text-red-400' : 'text-gray-500'); + } + + // ── API helper ────────────────────────────────────────────────────── + async function _api(path, body) { + const opts = body + ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } + : {}; + const res = await fetch(cfg.apiBase + path, opts); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Request failed'); + return data; + } + + // ── Search value (read live from the surface's search input) ──────── + function _query() { + var el = cfg.getSearchEl ? cfg.getSearchEl() : null; + return el ? el.value.trim() : ''; + } + + // ── Flat list of every song in the tree (root + all nested folders) ─ + function _allSongs() { + if (!_tree) return []; + var result = _tree.root_songs.slice(); + function _collectFolder(f) { + f.songs.forEach(function (s) { result.push(s); }); + (f.children || []).forEach(_collectFolder); + } + _tree.folders.forEach(_collectFolder); + return result; + } + + // ── Dynamic arrangement / stem discovery (filter panel) ───────────── + function _getArrangements() { + var counts = {}; + _allSongs().forEach(function (s) { + (s.arrangements || []).forEach(function (a) { counts[a] = (counts[a] || 0) + 1; }); + }); + return Object.keys(counts).sort(function (a, b) { return (counts[b] - counts[a]) || a.localeCompare(b); }); + } + function _getStems() { + var counts = {}; + _allSongs().forEach(function (s) { + (s.stems || []).forEach(function (st) { counts[st] = (counts[st] || 0) + 1; }); + }); + return Object.keys(counts).sort(function (a, b) { return (counts[b] - counts[a]) || a.localeCompare(b); }); + } + function _getAvailableFilters() { + var out = { arrangements: false, stems: false, lyrics: false, tuning: false }; + _allSongs().forEach(function (s) { + if ((s.arrangements || []).length) out.arrangements = true; + if ((s.stems || []).length) out.stems = true; + if (s.lyrics) out.lyrics = true; + if (s.tuning) out.tuning = true; + }); + return out; + } + function _getTunings() { + var counts = {}; + _allSongs().forEach(function (s) { + var t = s.tuning ? String(s.tuning).trim() : ''; + if (t) counts[t] = (counts[t] || 0) + 1; + }); + return Object.keys(counts) + .sort(function (a, b) { return a.localeCompare(b); }) + .map(function (t) { return { tuning: t, count: counts[t] }; }); + } + + // ── Fetch tree ────────────────────────────────────────────────────── + async function _load(force) { + // Lib surface owns a couple of host-chrome tweaks on entry. + if (cfg.searchInputId) { + var fe = _el(cfg.searchInputId); + if (fe) fe.style.maxWidth = '320px'; + } + if (cfg.countId) { + var ce0 = _el(cfg.countId); + if (ce0) ce0.textContent = ''; + } + + var params = cfg.getFilterParams ? cfg.getFilterParams() : ''; + if (!force && _loaded && _tree && params === _lastFilterParams) { + if (cfg.injectToolbar) _injectToolbar(); + _render(); + return; + } + + _status('Loading…'); + var treeEl = _treeEl(); + if (!cfg.ownsStatus && treeEl) { + treeEl.innerHTML = '
Loading folders…
'; + } + try { + var url = '/tree' + (params ? '?' + params : ''); + var data = await _api(url); + if (data.error) { + if (cfg.ownsStatus) _status('⚠ ' + data.error, true); + else if (treeEl) { treeEl.innerHTML = ''; var _ed = document.createElement('div'); _ed.style.cssText = 'padding:48px;text-align:center;color:#ef4444;font-size:13px;'; _ed.textContent = '⚠ ' + data.error; treeEl.appendChild(_ed); } + return; + } + _tree = data; + _loaded = true; + _lastFilterParams = params; + _status(''); + // Lib auto-expands top-level folders on first visit (empty open set). + if (cfg.autoExpandTop && _openFolders.size === 0 && data.folders.length) { + data.folders.forEach(function (f) { _openFolders.add(f.path); }); + _storeJSON('open', [..._openFolders]); + } + if (cfg.injectToolbar) _injectToolbar(); + _render(); + // Rebuild filter panel if it's open so tuning list reflects new data. + if (cfg.ownsFilterPanel) { + var fp = _el('fb-filter-panel'); + if (fp && fp.style.display !== 'none') _buildFilterPanel(); + } + } catch (err) { + if (cfg.ownsStatus) _status('Load failed: ' + err.message, true); + else if (treeEl) { treeEl.innerHTML = ''; var _ed = document.createElement('div'); _ed.style.cssText = 'padding:48px;text-align:center;color:#ef4444;font-size:13px;'; _ed.textContent = '⚠ Failed to load: ' + err.message; treeEl.appendChild(_ed); } + } + } + + // ── Filtered tree ─────────────────────────────────────────────────── + // Search always applies. Nav additionally applies its client-side filter + // panel; lib additionally narrows by host artist/album (server already + // applied arrangement/stem/tuning filters via /tree params). + function _filtered() { + if (!_tree) return { folders: [], root_songs: [] }; + var q = _query().toLowerCase(); + var artist = cfg.getHostArtist ? cfg.getHostArtist() : ''; + var album = cfg.getHostAlbum ? cfg.getHostAlbum() : ''; + var hasClientFilters = cfg.ownsFilterPanel && _activeFilterCount() > 0; + if (!q && !artist && !album && !hasClientFilters) return _tree; + function _keep(s) { + if (artist && (s.artist || '') !== artist) return false; + if (album && (s.album || '') !== album) return false; + if (q && !( + (s.title || '').toLowerCase().includes(q) || + (s.artist || '').toLowerCase().includes(q) || + (s.album || '').toLowerCase().includes(q) || + s.filename.toLowerCase().includes(q) + )) return false; + if (hasClientFilters && !_matchFilters(s)) return false; + return true; + } + function _filterFolder(f) { + var songs = f.songs.filter(_keep); + var children = (f.children || []).map(_filterFolder).filter(function (c) { + return c.songs.length || (c.children || []).length; + }); + return { name: f.name, path: f.path, songs: songs, children: children }; + } + var folders = _tree.folders.map(_filterFolder).filter(function (f) { + return f.songs.length || (f.children || []).length; + }); + return { folders: folders, root_songs: _tree.root_songs.filter(_keep) }; + } + + // ── Client-side filter helpers (nav surface) ──────────────────────── + function _saveFilters() { + _storeJSON('filters', _filters); + } + function _activeFilterCount() { + var n = 0; + var arrVals = _filters.arrangements || {}; + for (var a in arrVals) { if (arrVals[a] === 'on' || arrVals[a] === 'exclude') n++; } + var stemVals = _filters.stems || {}; + for (var s in stemVals) { if (stemVals[s] === 'on' || stemVals[s] === 'exclude') n++; } + if (_filters.lyrics === 'on' || _filters.lyrics === 'exclude') n++; + n += (_filters.tunings || []).length; + return n; + } + function _matchFilters(song) { + // Arrangements — include uses OR, exclude uses AND. + var arrF = _filters.arrangements || {}; + var songArr = song.arrangements || []; + var onArr = Object.keys(arrF).filter(function (a) { return arrF[a] === 'on'; }); + if (onArr.length && !onArr.some(function (a) { return songArr.indexOf(a) !== -1; })) return false; + for (var a in arrF) { + if (arrF[a] === 'exclude' && songArr.indexOf(a) !== -1) return false; + } + // Stems — same OR-include / AND-exclude logic. + var stemsF = _filters.stems || {}; + var songStems = song.stems || []; + var onStems = Object.keys(stemsF).filter(function (s) { return stemsF[s] === 'on'; }); + if (onStems.length && !onStems.some(function (s) { return songStems.indexOf(s) !== -1; })) return false; + for (var s in stemsF) { + if (stemsF[s] === 'exclude' && songStems.indexOf(s) !== -1) return false; + } + if (_filters.lyrics === 'on' && !song.lyrics) return false; + if (_filters.lyrics === 'exclude' && song.lyrics) return false; + var tunings = _filters.tunings || []; + if (tunings.length) { + var t = (song.tuning || '').trim(); + if (!t || tunings.indexOf(t) === -1) return false; + } + return true; + } + + // Split pill: left zone = include, right zone = exclude. state: 'off'|'on'|'exclude' + function _makeSplitPill(label, state, onChange) { + var pill = document.createElement('div'); + pill.style.cssText = 'display:inline-flex; border-radius:20px; border:1px solid; overflow:hidden;'; + var incBtn = document.createElement('button'); + incBtn.style.cssText = 'padding:4px 10px; background:none; border:none; border-right:1px solid; font-size:12px; cursor:pointer; white-space:nowrap;'; + incBtn.textContent = label; + var excBtn = document.createElement('button'); + excBtn.style.cssText = 'padding:4px 8px; background:none; border:none; font-size:11px; cursor:pointer; line-height:1;'; + excBtn.title = 'Exclude'; + excBtn.textContent = '✕'; + function _apply() { + if (state === 'on') { + pill.style.borderColor = '#2563eb'; + incBtn.style.background = '#1d4ed8'; + incBtn.style.color = '#fff'; + incBtn.style.borderRightColor = '#3b82f6'; + excBtn.style.background = '#1d4ed8'; + excBtn.style.color = 'rgba(255,255,255,0.45)'; + } else if (state === 'exclude') { + pill.style.borderColor = '#991b1b'; + incBtn.style.background = 'transparent'; + incBtn.style.color = '#fca5a5'; + incBtn.style.borderRightColor = '#7f1d1d'; + excBtn.style.background = 'transparent'; + excBtn.style.color = '#ef4444'; + } else { + pill.style.borderColor = '#374151'; + incBtn.style.background = 'transparent'; + incBtn.style.color = '#6b7280'; + incBtn.style.borderRightColor = '#374151'; + excBtn.style.background = 'transparent'; + excBtn.style.color = '#4b5563'; + } + } + _apply(); + incBtn.addEventListener('click', function () { + state = (state === 'on') ? 'off' : 'on'; + _apply(); onChange(state); + }); + excBtn.addEventListener('click', function () { + state = (state === 'exclude') ? 'off' : 'exclude'; + _apply(); onChange(state); + }); + pill.appendChild(incBtn); + pill.appendChild(excBtn); + return pill; + } + + // ── Song date info (year + date added) — hover reveal (nav) ───────── + function _buildSongDateInfo(song) { + var parts = []; + if (song.year != null && song.year !== '') parts.push(String(song.year)); + if (song.added) { + var d = new Date(song.added * 1000); + parts.push(d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })); + } + if (!parts.length) return null; + var el = document.createElement('div'); + el.style.cssText = 'font-size:11px; font-weight:500; color:#cbd5e1; ' + + 'max-height:0; opacity:0; overflow:hidden; margin-top:0; ' + + 'transition:max-height 0.2s ease, opacity 0.15s, margin-top 0.15s;'; + el.textContent = parts.join(' · '); + return el; + } + + // ── Song metadata badges (visible on hover; click toggles a filter) ─ + function _badge(text, active, type) { + var b = document.createElement('span'); + var _typeColors = { + arrangement: { border: '#92400e', color: '#fcd34d' }, + stem: { border: '#5b21b6', color: '#c4b5fd' }, + lyrics: { border: '#9f1239', color: '#fda4af' }, + tuning: { border: '#0f766e', color: '#5eead4' }, + }; + var tc = (!active && type) ? (_typeColors[type] || null) : null; + b.style.cssText = 'display:inline-block; padding:1px 6px; border-radius:3px; ' + + 'font-size:10px; font-weight:500; white-space:nowrap; cursor:pointer; ' + + 'border:1px solid ' + (active ? '#3b82f6' : (tc ? tc.border : '#334155')) + '; ' + + 'background:' + (active ? '#1d4ed8' : 'transparent') + '; ' + + 'color:' + (active ? '#fff' : (tc ? tc.color : '#cbd5e1')) + ';'; + b.textContent = text; + return b; + } + function _buildSongBadges(song) { + var wrap = document.createElement('div'); + wrap.style.cssText = 'display:flex; flex-wrap:wrap; gap:3px; ' + + 'max-height:0; opacity:0; overflow:hidden; margin-top:0; ' + + 'transition:max-height 0.2s ease, opacity 0.15s, margin-top 0.15s;'; + var any = false; + var _seenArr = {}; + var _seenStem = {}; + (song.arrangements || []).forEach(function (a) { + if (_seenArr[a]) return; _seenArr[a] = true; + var active = ((_filters.arrangements || {})[a] === 'on'); + var b = _badge(a, active, 'arrangement'); + b.addEventListener('click', function (e) { + e.stopPropagation(); + if (!_filters.arrangements) _filters.arrangements = {}; + _filters.arrangements[a] = active ? 'off' : 'on'; + _saveFilters(); _updateFilterBadge(); _render(); + }); + wrap.appendChild(b); any = true; + }); + (song.stems || []).forEach(function (s) { + if (_seenStem[s]) return; _seenStem[s] = true; + var active = ((_filters.stems || {})[s] === 'on'); + var b = _badge(s, active, 'stem'); + b.addEventListener('click', function (e) { + e.stopPropagation(); + if (!_filters.stems) _filters.stems = {}; + _filters.stems[s] = active ? 'off' : 'on'; + _saveFilters(); _updateFilterBadge(); _render(); + }); + wrap.appendChild(b); any = true; + }); + if (song.lyrics) { + var lyrActive = (_filters.lyrics === 'on'); + var lb = _badge('♪ Lyrics', lyrActive, 'lyrics'); + lb.addEventListener('click', function (e) { + e.stopPropagation(); + _filters.lyrics = lyrActive ? 'off' : 'on'; + _saveFilters(); _updateFilterBadge(); _render(); + }); + wrap.appendChild(lb); any = true; + } + if (song.tuning) { + var t = song.tuning.trim(); + var tunActive = (_filters.tunings || []).indexOf(t) !== -1; + var tb = _badge(t, tunActive, 'tuning'); + tb.addEventListener('click', function (e) { + e.stopPropagation(); + if (!_filters.tunings) _filters.tunings = []; + var idx = _filters.tunings.indexOf(t); + if (idx !== -1) _filters.tunings.splice(idx, 1); + else _filters.tunings.push(t); + _saveFilters(); _updateFilterBadge(); _render(); + }); + wrap.appendChild(tb); any = true; + } + return any ? wrap : null; + } + function _revealBadges(el) { + el.style.maxHeight = '120px'; + el.style.opacity = '1'; + el.style.marginTop = '4px'; + } + function _hideBadges(el) { + el.style.maxHeight = '0'; + el.style.opacity = '0'; + el.style.marginTop = '0'; + } + function _updateFilterBadge() { + var badge = _el('fb-filter-badge'); + if (!badge) return; + var n = _activeFilterCount(); + badge.style.display = n ? 'block' : 'none'; + badge.textContent = String(n); + } + + // ── Filter panel sections (nav) ───────────────────────────────────── + function _makePillSection(sectionTitle, items, filterKey, extraItems) { + var section = document.createElement('div'); + section.style.marginBottom = '20px'; + var hdr = document.createElement('div'); + hdr.style.cssText = 'font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#6b7280; margin-bottom:8px;'; + hdr.textContent = sectionTitle; + section.appendChild(hdr); + var pills = document.createElement('div'); + pills.style.cssText = 'display:flex; flex-wrap:wrap; gap:6px;'; + function _addPill(item) { + var state = ((_filters[filterKey] || {})[item]) || 'off'; + pills.appendChild(_makeSplitPill(item, state, function (next) { + if (!_filters[filterKey]) _filters[filterKey] = {}; + _filters[filterKey][item] = next; + _saveFilters(); _updateFilterBadge(); _render(); + })); + } + items.forEach(_addPill); + if (extraItems && extraItems.length) { + var sep = document.createElement('div'); + sep.style.cssText = 'width:100%; height:1px; background:#1f2937; margin:4px 0 2px;'; + pills.appendChild(sep); + extraItems.forEach(_addPill); + } + section.appendChild(pills); + return section; + } + function _makeLyricsSection() { + var section = document.createElement('div'); + section.style.marginBottom = '20px'; + var hdr = document.createElement('div'); + hdr.style.cssText = 'font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#6b7280; margin-bottom:8px;'; + hdr.textContent = 'LYRICS'; + section.appendChild(hdr); + var state = _filters.lyrics || 'off'; + section.appendChild(_makeSplitPill('Lyrics', state, function (next) { + _filters.lyrics = next; + _saveFilters(); _updateFilterBadge(); _render(); + })); + return section; + } + function _makeTuningSection() { + var section = document.createElement('div'); + section.style.marginBottom = '20px'; + var tunings = _getTunings(); + if (!tunings.length) return section; + var titleRow = document.createElement('div'); + titleRow.style.cssText = 'display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;'; + var titleEl = document.createElement('div'); + titleEl.style.cssText = 'font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#6b7280;'; + titleEl.textContent = 'TUNING'; + var allLbl = document.createElement('span'); + allLbl.style.cssText = 'font-size:11px; color:#6b7280;'; + function _updateAllLbl() { + var n = (_filters.tunings || []).length; + allLbl.textContent = n ? n + ' selected' : 'All tunings'; + } + _updateAllLbl(); + titleRow.appendChild(titleEl); + titleRow.appendChild(allLbl); + section.appendChild(titleRow); + var list = document.createElement('div'); + list.style.cssText = 'display:flex; flex-direction:column; gap:2px;'; + tunings.forEach(function (entry) { + var row = document.createElement('label'); + row.style.cssText = 'display:flex; align-items:center; gap:8px; padding:5px 4px; cursor:pointer; border-radius:4px;'; + row.addEventListener('mouseenter', function () { row.style.background = '#111827'; }); + row.addEventListener('mouseleave', function () { row.style.background = ''; }); + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.style.cssText = 'width:14px; height:14px; accent-color:#3b82f6; cursor:pointer; flex-shrink:0;'; + cb.checked = (_filters.tunings || []).indexOf(entry.tuning) !== -1; + var lbl = document.createElement('span'); + lbl.style.cssText = 'flex:1; font-size:13px; color:#d1d5db;'; + lbl.textContent = entry.tuning; + var cnt = document.createElement('span'); + cnt.style.cssText = 'font-size:12px; color:#6b7280; font-variant-numeric:tabular-nums;'; + cnt.textContent = entry.count; + cb.addEventListener('change', function () { + if (!_filters.tunings) _filters.tunings = []; + if (cb.checked) { + if (_filters.tunings.indexOf(entry.tuning) === -1) + _filters.tunings.push(entry.tuning); + } else { + _filters.tunings = _filters.tunings.filter(function (t) { return t !== entry.tuning; }); + } + _saveFilters(); _updateAllLbl(); _updateFilterBadge(); _render(); + }); + row.appendChild(cb); + row.appendChild(lbl); + row.appendChild(cnt); + list.appendChild(row); + }); + section.appendChild(list); + return section; + } + + // ── Filter panel open / close (nav) ───────────────────────────────── + function _buildFilterPanel() { + var panel = _el('fb-filter-panel'); + if (!panel) return; + panel.innerHTML = ''; + var hdr = document.createElement('div'); + hdr.style.cssText = 'display:flex; align-items:center; justify-content:space-between; padding:14px 20px; border-bottom:1px solid #1f2937; flex-shrink:0;'; + var titleEl = document.createElement('span'); + titleEl.style.cssText = 'font-size:15px; font-weight:600; color:#e5e7eb;'; + titleEl.textContent = 'Filters'; + var closeBtn = document.createElement('button'); + closeBtn.style.cssText = 'padding:4px; color:#6b7280; background:none; border:none; cursor:pointer; border-radius:4px;'; + closeBtn.innerHTML = ''; + closeBtn.addEventListener('click', _closeFilterPanel); + hdr.appendChild(titleEl); + hdr.appendChild(closeBtn); + panel.appendChild(hdr); + var content = document.createElement('div'); + content.style.cssText = 'overflow-y:auto; flex:1; padding:16px 20px;'; + var arrangements = _getArrangements(); + var stems = _getStems(); + var avail = _getAvailableFilters(); + if (arrangements.length) { + var coreArr = _CORE_ARRANGEMENTS.filter(function (a) { return arrangements.indexOf(a) !== -1; }); + var otherArr = arrangements.filter(function (a) { return _CORE_ARRANGEMENTS.indexOf(a) === -1; }); + content.appendChild(_makePillSection('ARRANGEMENTS', + coreArr.length ? coreArr : arrangements, + 'arrangements', + coreArr.length ? otherArr : [] + )); + } + if (stems.length) content.appendChild(_makePillSection('STEMS (sloppak)', stems, 'stems')); + if (avail.lyrics) content.appendChild(_makeLyricsSection()); + if (avail.tuning) content.appendChild(_makeTuningSection()); + panel.appendChild(content); + var footer = document.createElement('div'); + footer.style.cssText = 'display:flex; align-items:center; justify-content:space-between; padding:14px 20px; border-top:1px solid #1f2937; flex-shrink:0;'; + var clearBtn = document.createElement('button'); + clearBtn.style.cssText = 'font-size:13px; color:#6b7280; background:none; border:none; cursor:pointer; padding:0;'; + clearBtn.textContent = 'Clear all'; + clearBtn.addEventListener('click', function () { + _filters = { arrangements: {}, stems: {}, lyrics: 'off', tunings: [] }; + _saveFilters(); _updateFilterBadge(); _render(); + _buildFilterPanel(); + }); + var doneBtn = document.createElement('button'); + doneBtn.style.cssText = 'padding:6px 20px; border-radius:6px; border:none; background:#3b82f6; color:#fff; font-size:13px; cursor:pointer; font-weight:500;'; + doneBtn.textContent = 'Done'; + doneBtn.addEventListener('click', _closeFilterPanel); + footer.appendChild(clearBtn); + footer.appendChild(doneBtn); + panel.appendChild(footer); + } + function _openFilterPanel() { + _buildFilterPanel(); + var panel = _el('fb-filter-panel'); + var backdrop = _el('fb-filter-backdrop'); + if (panel) panel.style.display = 'flex'; + if (backdrop) backdrop.style.display = 'block'; + } + function _closeFilterPanel() { + var panel = _el('fb-filter-panel'); + var backdrop = _el('fb-filter-backdrop'); + if (panel) panel.style.display = 'none'; + if (backdrop) backdrop.style.display = 'none'; + } + + // ── Sort helper ───────────────────────────────────────────────────── + function _sortSongs(songs) { + if (cfg.ownsSort) { + // Nav: local sort state (#fb-sort + direction toggle). + if (_sort === 'default') return songs; + var arr = songs.slice(); + if (_sort === 'title') { + arr.sort(function (a, b) { return (a.title || a.filename).localeCompare(b.title || b.filename); }); + } else if (_sort === 'artist') { + arr.sort(function (a, b) { return (a.artist || '').localeCompare(b.artist || ''); }); + } else if (_sort === 'duration') { + arr.sort(function (a, b) { return (a.duration || 0) - (b.duration || 0); }); + } else if (_sort === 'year') { + arr.sort(function (a, b) { return (a.year || 0) - (b.year || 0); }); + } else if (_sort === 'tuning') { + arr.sort(function (a, b) { return (a.tuning || '').localeCompare(b.tuning || ''); }); + } else if (_sort === 'added') { + arr.sort(function (a, b) { return (a.added || 0) - (b.added || 0); }); + } + if (_sortDir === 'desc') arr.reverse(); + return arr; + } + // Lib: read host sort vocabulary (#lib-sort / #v3-songs-sort). + var v = cfg.getHostSort ? cfg.getHostSort() : ''; + if (!v) return songs; + var larr = songs.slice(); + if (v === 'artist' || v === 'artist-desc') { + larr.sort(function (a, b) { return (a.artist || '').localeCompare(b.artist || ''); }); + if (v === 'artist-desc') larr.reverse(); + } else if (v === 'title' || v === 'title-desc') { + larr.sort(function (a, b) { return (a.title || a.filename).localeCompare(b.title || b.filename); }); + if (v === 'title-desc') larr.reverse(); + } else if (v === 'recent') { + larr.sort(function (a, b) { return (b.added || 0) - (a.added || 0); }); + } else if (v === 'year-desc') { + larr.sort(function (a, b) { return (b.year || 0) - (a.year || 0); }); + } else if (v === 'year') { + larr.sort(function (a, b) { return (a.year || 0) - (b.year || 0); }); + } else if (v === 'tuning') { + larr.sort(function (a, b) { return (a.tuning || '').localeCompare(b.tuning || ''); }); + } + return larr; + } + + // ── Custom modal (Electron blocks prompt/confirm) ─────────────────── + // Self-contained: builds its own DOM and keeps element references in + // closure (no global ids), so two surface instances never collide. + var _modalEl = null; + var _modalParts = null; + function _getModal() { + if (_modalEl && document.body.contains(_modalEl)) return _modalParts; + _modalEl = document.createElement('div'); + _modalEl.style.cssText = 'display:none; position:fixed; inset:0; z-index:9999; align-items:center; justify-content:center; background:rgba(0,0,0,0.6);'; + var box = document.createElement('div'); + box.style.cssText = 'background:#1f2937; border:1px solid #374151; border-radius:10px; padding:24px; min-width:320px; max-width:480px; box-shadow:0 8px 40px rgba(0,0,0,0.7);'; + var msgEl = document.createElement('div'); + msgEl.style.cssText = 'color:#e5e7eb; font-size:14px; white-space:pre-wrap; margin-bottom:16px; line-height:1.5;'; + var inp = document.createElement('input'); + inp.type = 'text'; + inp.style.cssText = 'display:none; width:100%; background:#111827; border:1px solid #4b5563; border-radius:6px; padding:8px 12px; color:#e5e7eb; font-size:14px; outline:none; box-sizing:border-box; margin-bottom:16px;'; + var btns = document.createElement('div'); + btns.style.cssText = 'display:flex; justify-content:flex-end; gap:8px;'; + var cancelBtn = document.createElement('button'); + cancelBtn.style.cssText = 'padding:7px 18px; border-radius:6px; border:1px solid #374151; background:transparent; color:#9ca3af; font-size:13px; cursor:pointer;'; + cancelBtn.textContent = 'Cancel'; + var okBtn = document.createElement('button'); + okBtn.style.cssText = 'padding:7px 18px; border-radius:6px; border:none; background:#3b82f6; color:#fff; font-size:13px; font-weight:500; cursor:pointer;'; + okBtn.textContent = 'OK'; + btns.appendChild(cancelBtn); btns.appendChild(okBtn); + box.appendChild(msgEl); box.appendChild(inp); box.appendChild(btns); + _modalEl.appendChild(box); + document.body.appendChild(_modalEl); + _modalParts = { modal: _modalEl, msgEl: msgEl, input: inp, okBtn: okBtn, cancel: cancelBtn }; + return _modalParts; + } + function _showModal(message, withInput, defaultVal) { + return new Promise(function (resolve) { + var p = _getModal(); + p.msgEl.textContent = message; + if (withInput) { + p.input.style.display = 'block'; + p.input.value = defaultVal || ''; + setTimeout(function () { p.input.focus(); p.input.select(); }, 50); + } else { + p.input.style.display = 'none'; + } + p.modal.style.display = 'flex'; + function _done(val) { + p.modal.style.display = 'none'; + p.okBtn.removeEventListener('click', _ok); + p.cancel.removeEventListener('click', _cxl); + p.input.removeEventListener('keydown', _key); + resolve(val); + } + function _ok() { _done(withInput ? p.input.value.trim() : true); } + function _cxl() { _done(null); } + function _key(e) { + if (e.key === 'Enter') { e.preventDefault(); _ok(); } + if (e.key === 'Escape') { e.preventDefault(); _cxl(); } + } + p.okBtn.addEventListener('click', _ok); + p.cancel.addEventListener('click', _cxl); + if (withInput) p.input.addEventListener('keydown', _key); + }); + } + function _confirm(msg) { return _showModal(msg, false, ''); } + function _prompt(msg, def) { return _showModal(msg, true, def || ''); } + + // ── Song card (grid view) ─────────────────────────────────────────── + function _songCard(song, folderName) { + var card = document.createElement('div'); + card.className = 'flex flex-col rounded-lg overflow-hidden cursor-pointer group transition-transform duration-100 hover:scale-105'; + card.style.background = '#1a1d2e'; + card.dataset.filename = song.filename; + + var artWrap = document.createElement('div'); + artWrap.style.cssText = 'position:relative; width:100%; padding-bottom:100%; background:#111827; overflow:hidden;'; + var img = document.createElement('img'); + img.style.cssText = 'position:absolute; inset:0; width:100%; height:100%; object-fit:cover;'; + img.alt = ''; img.loading = 'lazy'; + img.src = '/api/song/' + song.filename.split('/').map(encodeURIComponent).join('/') + '/art'; + var ph = document.createElement('div'); + ph.style.cssText = 'position:absolute; inset:0; display:flex; align-items:center; justify-content:center;'; + ph.innerHTML = ''; + img.addEventListener('error', function () { img.style.display = 'none'; ph.style.display = 'flex'; }); + img.addEventListener('load', function () { ph.style.display = 'none'; }); + artWrap.appendChild(ph); artWrap.appendChild(img); + + if (song.duration != null) { + var durB = document.createElement('span'); + durB.style.cssText = 'position:absolute; bottom:6px; right:6px; padding:2px 6px; border-radius:4px; font-size:11px; font-weight:600; color:#e5e7eb; background:rgba(0,0,0,0.7);'; + var m0 = Math.floor(song.duration / 60), s0 = String(Math.floor(song.duration % 60)).padStart(2, '0'); + durB.textContent = m0 + ':' + s0; + artWrap.appendChild(durB); + } + + var moveBtn = document.createElement('button'); + moveBtn.style.cssText = 'position:absolute; top:6px; right:6px; padding:4px; border-radius:4px; background:rgba(0,0,0,0.6); color:#9ca3af; border:none; cursor:pointer; display:none;'; + moveBtn.title = 'Move to folder…'; + moveBtn.innerHTML = ''; + card.addEventListener('mouseenter', function () { moveBtn.style.display = 'block'; }); + card.addEventListener('mouseleave', function () { moveBtn.style.display = 'none'; }); + moveBtn.addEventListener('click', function (e) { e.stopPropagation(); _moveSong(song, folderName); }); + artWrap.appendChild(moveBtn); + + var meta = document.createElement('div'); + meta.style.cssText = 'padding:8px 10px 10px; flex:1; min-width:0;'; + var title = document.createElement('div'); + title.style.cssText = 'font-size:13px; font-weight:600; color:#e5e7eb; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;'; + title.textContent = song.title || song.filename; + var sub = document.createElement('div'); + sub.style.cssText = 'font-size:11px; color:#6b7280; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:2px;'; + sub.textContent = [song.artist, song.album].filter(Boolean).join(' — ') || ''; + meta.appendChild(title); meta.appendChild(sub); + + if (cfg.songBadges) { + var cardBadges = _buildSongBadges(song); + if (cardBadges) { + meta.appendChild(cardBadges); + card.addEventListener('mouseenter', function () { _revealBadges(cardBadges); }); + card.addEventListener('mouseleave', function () { _hideBadges(cardBadges); }); + } + var cardDateInfo = _buildSongDateInfo(song); + if (cardDateInfo) { + meta.appendChild(cardDateInfo); + card.addEventListener('mouseenter', function () { _revealBadges(cardDateInfo); }); + card.addEventListener('mouseleave', function () { _hideBadges(cardDateInfo); }); + } + } + + card.appendChild(artWrap); card.appendChild(meta); + card.addEventListener('click', function () { + if (typeof window.playSong === 'function') window.playSong(song.filename); + }); + _makeDraggable(card, song, folderName); + return card; + } + + // ── Song row (list view) ──────────────────────────────────────────── + function _songRow(song, folderName) { + var row = document.createElement('div'); + row.className = 'flex items-center gap-3 px-3 py-2 rounded cursor-pointer hover:bg-dark-500 group transition-colors duration-100'; + row.dataset.filename = song.filename; + + var thumb = document.createElement('div'); + thumb.style.cssText = 'width:36px; height:36px; border-radius:4px; overflow:hidden; background:#111827; flex-shrink:0; position:relative;'; + var tImg = document.createElement('img'); + tImg.loading = 'lazy'; + tImg.src = '/api/song/' + song.filename.split('/').map(encodeURIComponent).join('/') + '/art'; + tImg.alt = ''; tImg.style.cssText = 'width:100%; height:100%; object-fit:cover;'; + var tPh = document.createElement('div'); + tPh.style.cssText = 'position:absolute; inset:0; display:flex; align-items:center; justify-content:center;'; + tPh.innerHTML = ''; + tImg.addEventListener('error', function () { tImg.style.display = 'none'; tPh.style.display = 'flex'; }); + tImg.addEventListener('load', function () { tPh.style.display = 'none'; }); + thumb.appendChild(tPh); thumb.appendChild(tImg); + + var meta = document.createElement('div'); + meta.className = 'flex-1 min-w-0'; + var title = document.createElement('div'); + title.className = 'text-gray-200 truncate group-hover:text-white'; + title.style.cssText = 'font-size:13px; font-weight:600;'; + title.textContent = song.title || song.filename; + var sub = document.createElement('div'); + sub.className = 'text-gray-500 truncate'; sub.style.fontSize = '11px'; + sub.textContent = [song.artist, song.album].filter(Boolean).join(' — ') || ''; + meta.appendChild(title); meta.appendChild(sub); + if (cfg.songBadges) { + var rowBadges = _buildSongBadges(song); + if (rowBadges) { + meta.appendChild(rowBadges); + row.addEventListener('mouseenter', function () { _revealBadges(rowBadges); }); + row.addEventListener('mouseleave', function () { _hideBadges(rowBadges); }); + } + var rowDateInfo = _buildSongDateInfo(song); + if (rowDateInfo) { + meta.appendChild(rowDateInfo); + row.addEventListener('mouseenter', function () { _revealBadges(rowDateInfo); }); + row.addEventListener('mouseleave', function () { _hideBadges(rowDateInfo); }); + } + } + + var icon = document.createElement('span'); + icon.className = 'shrink-0 w-4 h-4 text-dark-400 group-hover:text-blue-400 transition-colors opacity-0 group-hover:opacity-100'; + icon.innerHTML = ''; + + var dur = document.createElement('span'); + dur.className = 'shrink-0 text-xs text-gray-600 tabular-nums'; + if (song.duration != null) { + var m1 = Math.floor(song.duration / 60), s1 = String(Math.floor(song.duration % 60)).padStart(2, '0'); + dur.textContent = m1 + ':' + s1; + } + + var moveBtn = document.createElement('button'); + moveBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-white hover:bg-dark-400 opacity-0 group-hover:opacity-100 transition-opacity'; + moveBtn.title = 'Move to folder…'; + moveBtn.innerHTML = ''; + moveBtn.addEventListener('click', function (e) { e.stopPropagation(); _moveSong(song, folderName); }); + + row.appendChild(thumb); row.appendChild(meta); row.appendChild(icon); + row.appendChild(dur); row.appendChild(moveBtn); + row.addEventListener('click', function () { + if (typeof window.playSong === 'function') window.playSong(song.filename); + }); + _makeDraggable(row, song, folderName); + return row; + } + + // ── Pointer-based drag-and-drop (mousedown/mousemove/mouseup) ─────── + // HTML5 DnD blocks wheel events and gives unreliable edge positions in + // Electron — pointer events give full control over both. + var _dragState = null; + var _dragCurrentTarget = null; + var _dragRafId = null; + var _DRAG_THRESH = 5, _DRAG_ZONE = 150, _DRAG_SPEED = 50; + + function _getScrollEl() { + var el = _treeEl(); + while (el && el !== document.documentElement) { + var ov = window.getComputedStyle(el).overflowY; + if ((ov === 'auto' || ov === 'scroll' || ov === 'overlay') && el.scrollHeight > el.clientHeight) return el; + el = el.parentElement; + } + return document.scrollingElement || document.documentElement; + } + function _dragFindTarget(x, y) { + var els = document.elementsFromPoint(x, y); + for (var i = 0; i < els.length; i++) { + if ('dropFolder' in (els[i].dataset || {})) return els[i]; + } + return null; + } + function _dragHighlight(target) { + if (_dragCurrentTarget === target) return; + if (_dragCurrentTarget) _dragCurrentTarget.style.outline = ''; + _dragCurrentTarget = target; + if (target) { target.style.outline = '2px solid #3b82f6'; target.style.borderRadius = '6px'; } + } + function _dragScrollTick() { + if (!_dragState || !_dragState.live) { _dragRafId = null; return; } + var h = window.innerHeight, y = _dragState.y; + var sc = _getScrollEl(); + sc.style.scrollBehavior = 'auto'; + if (y < _DRAG_ZONE) sc.scrollTop -= _DRAG_SPEED; + else if (y > h - _DRAG_ZONE) sc.scrollTop += _DRAG_SPEED; + _dragRafId = requestAnimationFrame(_dragScrollTick); + } + function _onDragMove(e) { + if (!_dragState) return; + _dragState.x = e.clientX; _dragState.y = e.clientY; + if (!_dragState.live) { + var dx = _dragState.x - _dragState.startX, dy = _dragState.y - _dragState.startY; + if (Math.sqrt(dx * dx + dy * dy) < _DRAG_THRESH) return; + _dragState.live = true; + var ghost = document.createElement('div'); + ghost.style.cssText = 'position:fixed; pointer-events:none; z-index:9999; padding:5px 12px; background:#1e2130; border:1px solid #3b82f6; border-radius:6px; color:#e5e7eb; font-size:12px; white-space:nowrap; box-shadow:0 4px 20px rgba(0,0,0,0.5);'; + ghost.textContent = _dragState.data.label; + document.body.appendChild(ghost); + _dragState.ghost = ghost; + if (!_dragRafId) _dragRafId = requestAnimationFrame(_dragScrollTick); + } + if (_dragState.ghost) { + _dragState.ghost.style.left = (_dragState.x + 14) + 'px'; + _dragState.ghost.style.top = (_dragState.y + 14) + 'px'; + } + _dragHighlight(_dragFindTarget(_dragState.x, _dragState.y)); + } + function _onDragUp(e) { + if (!_dragState) return; + var wasDrag = _dragState.live, data = _dragState.data; + var x = e.clientX, y = e.clientY; + _endDrag(); + if (wasDrag) { + // Suppress the click that fires after mouseup so the song doesn't play. + document.addEventListener('click', function (ce) { + ce.stopPropagation(); ce.preventDefault(); + }, { capture: true, once: true }); + var target = _dragFindTarget(x, y); + if (target && data) { + var tf = target.dataset.dropFolder; + if (tf !== data.folder) _executeDrop(data, tf); + } + } + } + function _onDragKey(e) { if (e.key === 'Escape') _endDrag(); } + function _endDrag() { + if (_dragRafId) { cancelAnimationFrame(_dragRafId); _dragRafId = null; } + if (_dragState && _dragState.ghost) _dragState.ghost.remove(); + if (_dragCurrentTarget) { _dragCurrentTarget.style.outline = ''; _dragCurrentTarget = null; } + document.body.style.userSelect = ''; + _dragState = null; + document.removeEventListener('mousemove', _onDragMove); + document.removeEventListener('mouseup', _onDragUp); + document.removeEventListener('keydown', _onDragKey); + } + async function _executeDrop(data, targetFolder) { + // No optimistic tree mutation — the drag ghost gives instant visual + // feedback, and racing optimistic updates against _load() caused songs + // to snap back when dropping quickly in succession. + if (targetFolder !== '') _openFolders.add(targetFolder); + else _unsortedOpen = true; + try { + await _api('/song/move', { filename: data.filename, folder: targetFolder }); + } catch (err) { + _status('Move failed: ' + err.message, true); + } + await _load(true); + } + function _makeDraggable(el, song, folderName) { + el.style.cursor = 'grab'; + el.addEventListener('mousedown', function (e) { + if (e.button !== 0) return; + document.body.style.userSelect = 'none'; + var sel = window.getSelection(); if (sel) sel.removeAllRanges(); + _dragState = { + data: { filename: song.filename, folder: folderName || '', label: '↕ ' + (song.title || song.filename) }, + startX: e.clientX, startY: e.clientY, x: e.clientX, y: e.clientY, + live: false, ghost: null, + }; + document.addEventListener('mousemove', _onDragMove); + document.addEventListener('mouseup', _onDragUp); + document.addEventListener('keydown', _onDragKey); + }); + el.addEventListener('dragstart', function (e) { e.preventDefault(); }); + } + function _makeDropTarget(el, tf) { + el.dataset.dropFolder = (tf == null) ? '' : tf; + } + + // ── Move song dialog ──────────────────────────────────────────────── + async function _moveSong(song, currentFolderPath) { + if (!_tree) return; + var allPaths = []; + function _collect(f) { allPaths.push(f.path); (f.children || []).forEach(_collect); } + _tree.folders.forEach(_collect); + var options = ['(Unsorted)'].concat(allPaths.filter(function (p) { return p !== currentFolderPath; })); + var choice = await _prompt( + 'Move "' + (song.title || song.filename) + '" to:\n' + + options.map(function (n, i) { return i + ': ' + n; }).join('\n') + + '\n\nEnter number or folder path:', '' + ); + if (!choice && choice !== 0) return; + var dest = '', idx = parseInt(choice, 10); + if (!isNaN(idx) && idx >= 0 && idx < options.length) { + dest = idx === 0 ? '' : options[idx]; + } else { + dest = choice.trim() === '(Unsorted)' ? '' : choice.trim(); + } + try { + await _api('/song/move', { filename: song.filename, folder: dest }); + await _load(true); + } catch (err) { await _prompt('Move failed: ' + err.message, ''); } + } + + // ── Folder section ────────────────────────────────────────────────── + function _folderSection(folder, depth) { + depth = depth || 0; + var q = _query(); + var open = q ? true : _openFolders.has(folder.path); + var wrap = document.createElement('div'); + + function _countDeep(f) { + var n = f.songs.length; + (f.children || []).forEach(function (c) { n += _countDeep(c); }); + return n; + } + function _countFoldersDeep(f) { + var n = (f.children || []).length; + (f.children || []).forEach(function (c) { n += _countFoldersDeep(c); }); + return n; + } + + var hdr = document.createElement('div'); + hdr.className = 'flex items-center gap-2 px-3 py-2 rounded cursor-pointer group'; + hdr.style.transition = 'background-color 0.1s'; + + var chev = document.createElement('span'); + chev.className = 'shrink-0 w-4 h-4 text-gray-500 transition-transform duration-150'; + chev.style.transform = open ? 'rotate(90deg)' : ''; + chev.innerHTML = ''; + + var ico = document.createElement('span'); + ico.className = 'shrink-0 w-4 h-4 ' + (depth > 0 ? 'text-yellow-600' : 'text-yellow-500'); + ico.innerHTML = ''; + + var lbl = document.createElement('span'); + lbl.className = 'flex-1 truncate font-medium ' + (depth > 0 ? 'text-xs text-gray-400' : 'text-sm text-gray-200'); + lbl.textContent = folder.name; + + var cnt = document.createElement('span'); + if (cfg.deepFolderCount) { + // Lib: deep song + subfolder summary ("N songs · M subfolders"). + var _deepTotal = _countDeep(folder); + var _subCount = _countFoldersDeep(folder); + cnt.style.cssText = 'flex-shrink:0; font-size:12px; margin-right:4px; color:#6b7280;'; + var _cntText = _deepTotal + ' song' + (_deepTotal === 1 ? '' : 's'); + if (_subCount > 0) _cntText += ' · ' + _subCount + ' subfolder' + (_subCount === 1 ? '' : 's'); + cnt.textContent = _cntText; + } else { + // Nav: direct song count only. + cnt.className = 'shrink-0 text-xs text-gray-600 tabular-nums mr-1'; + cnt.textContent = String(folder.songs.length); + } + + var subBtn = document.createElement('button'); + subBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-white hover:bg-dark-400'; + subBtn.title = 'New subfolder'; + subBtn.innerHTML = ''; + subBtn.addEventListener('click', function (e) { e.stopPropagation(); _createFolder(folder.path); }); + + var renameBtn = document.createElement('button'); + renameBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-white hover:bg-dark-400'; + renameBtn.title = 'Rename folder'; + renameBtn.innerHTML = ''; + renameBtn.addEventListener('click', function (e) { e.stopPropagation(); _renameFolder(folder.path); }); + + var delBtn = document.createElement('button'); + delBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-red-400 hover:bg-dark-400'; + delBtn.title = 'Delete folder'; + delBtn.innerHTML = ''; + delBtn.addEventListener('click', function (e) { e.stopPropagation(); _deleteFolder(folder.path, _countDeep(folder), _countFoldersDeep(folder)); }); + + var expandChildBtn = document.createElement('button'); + var collapseChildBtn = document.createElement('button'); + if (folder.children && folder.children.length) { + expandChildBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-white hover:bg-dark-400'; + expandChildBtn.title = 'Expand all subfolders'; + expandChildBtn.innerHTML = ''; + expandChildBtn.addEventListener('click', function (e) { + e.stopPropagation(); + _openFolders.add(folder.path); + (folder.children || []).forEach(function (c) { _openFolders.add(c.path); }); + _storeJSON('open', [..._openFolders]); _render(); + }); + collapseChildBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-white hover:bg-dark-400'; + collapseChildBtn.title = 'Collapse all subfolders'; + collapseChildBtn.innerHTML = ''; + collapseChildBtn.addEventListener('click', function (e) { + e.stopPropagation(); + (folder.children || []).forEach(function (c) { _openFolders.delete(c.path); }); + _storeJSON('open', [..._openFolders]); _render(); + }); + } + + // Collapsing button group — 0 width when hidden, slides in on hover. + var btnGroup = document.createElement('div'); + btnGroup.style.cssText = 'display:flex; align-items:center; gap:2px; max-width:0; overflow:hidden; transition:max-width 0.2s ease;'; + if (folder.children && folder.children.length) { + btnGroup.appendChild(expandChildBtn); btnGroup.appendChild(collapseChildBtn); + } + btnGroup.appendChild(subBtn); btnGroup.appendChild(renameBtn); btnGroup.appendChild(delBtn); + + // mouseover (bubbles) + stopPropagation so only the innermost folder activates. + wrap.style.cssText = 'border-radius:6px; margin:1px 0;'; + wrap.addEventListener('mouseover', function (e) { + if (_dragState) return; + e.stopPropagation(); + if (_hoveredFolder && _hoveredFolder.wrap !== wrap) { + _hoveredFolder.hdr.style.backgroundColor = ''; + _hoveredFolder.wrap.style.backgroundColor = ''; + _hoveredFolder.btnGroup.style.maxWidth = '0'; + } + _hoveredFolder = { wrap: wrap, hdr: hdr, btnGroup: btnGroup }; + hdr.style.backgroundColor = 'rgba(55,65,81,0.5)'; + wrap.style.backgroundColor = 'rgba(55,65,81,0.12)'; + btnGroup.style.maxWidth = '160px'; + }); + wrap.addEventListener('mouseout', function (e) { + if (_dragState) return; + if (wrap.contains(e.relatedTarget)) return; + hdr.style.backgroundColor = ''; wrap.style.backgroundColor = ''; + btnGroup.style.maxWidth = '0'; + if (_hoveredFolder && _hoveredFolder.wrap === wrap) _hoveredFolder = null; + }); + + // cnt sits after btnGroup so it rests at the far right when buttons hidden. + hdr.appendChild(chev); hdr.appendChild(ico); hdr.appendChild(lbl); + hdr.appendChild(btnGroup); hdr.appendChild(cnt); + _makeDropTarget(hdr, folder.path); + + var content = document.createElement('div'); + if (!open) content.style.display = 'none'; + + var list = document.createElement('div'); + if (_view === 'grid') { + list.style.cssText = 'display:grid; grid-template-columns:repeat(auto-fill,150px); justify-content:start; gap:12px; padding:8px 4px 8px 24px;'; + } else { + list.className = 'ml-5 mt-0.5 space-y-0'; + } + _makeDropTarget(list, folder.path); + + var childrenWrap = document.createElement('div'); + // Suppress grid padding on empty song lists — prevents a blank amber stub. + if (_view === 'grid' && !folder.songs.length) list.style.padding = '0'; + + var _listPopulated = open; + function _populateList() { + _sortSongs(folder.songs).forEach(function (s) { + list.appendChild(_view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path)); + }); + (folder.children || []).forEach(function (child) { + childrenWrap.appendChild(_folderSection(child, depth + 1)); + }); + } + if (open) _populateList(); + + // depth > 0: one container with a continuous amber border-left grouping + // songs + child folders. depth == 0: children indent, root songs unbordered. + var innerWrap = null; + if (depth > 0) { + innerWrap = document.createElement('div'); + innerWrap.style.cssText = 'margin-left:32px; padding-left:10px; border-left:2px solid rgba(234,179,8,0.35);'; + innerWrap.appendChild(list); innerWrap.appendChild(childrenWrap); + content.appendChild(innerWrap); + } else { + childrenWrap.style.marginLeft = '32px'; + content.appendChild(list); content.appendChild(childrenWrap); + } + + content.addEventListener('click', function (e) { + if (_query()) return; + var bgEls = [content, list, childrenWrap]; + if (innerWrap) bgEls.push(innerWrap); + if (bgEls.indexOf(e.target) === -1) return; + if (content.style.display !== 'none') { + content.style.display = 'none'; chev.style.transform = ''; + _openFolders.delete(folder.path); _storeJSON('open', [..._openFolders]); + } + }); + + hdr.addEventListener('click', function () { + if (_query()) return; + var nowOpen = content.style.display === 'none'; + if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; } + content.style.display = nowOpen ? '' : 'none'; + chev.style.transform = nowOpen ? 'rotate(90deg)' : ''; + if (nowOpen) _openFolders.add(folder.path); + else _openFolders.delete(folder.path); + _storeJSON('open', [..._openFolders]); + }); + + wrap.appendChild(hdr); wrap.appendChild(content); + return wrap; + } + + // ── Unsorted section ──────────────────────────────────────────────── + function _unsortedSection(songs) { + var q = _query(); + if (!songs.length && q) return null; + var wrap = document.createElement('div'); + wrap.className = 'mb-1'; + + var hdr = document.createElement('div'); + hdr.className = 'flex items-center gap-2 px-3 py-2 rounded cursor-pointer hover:bg-dark-500 transition-colors duration-100'; + + var chev = document.createElement('span'); + chev.className = 'shrink-0 w-4 h-4 text-gray-600 transition-transform duration-150'; + chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : ''; + chev.innerHTML = ''; + + var ico = document.createElement('span'); + ico.className = 'shrink-0 w-4 h-4 text-gray-600'; + ico.innerHTML = ''; + + var lbl = document.createElement('span'); + lbl.className = 'flex-1 text-xs font-semibold uppercase tracking-widest text-gray-600'; + lbl.textContent = 'Unsorted'; + + var cnt = document.createElement('span'); + cnt.className = 'shrink-0 text-xs text-gray-700 tabular-nums'; + cnt.textContent = String(songs.length); + + hdr.appendChild(chev); hdr.appendChild(ico); hdr.appendChild(lbl); hdr.appendChild(cnt); + _makeDropTarget(hdr, ''); + + var list = document.createElement('div'); + if (_view === 'grid') { + list.style.cssText = 'display:grid; grid-template-columns:repeat(auto-fill,150px); justify-content:start; gap:12px; padding:8px 4px 8px 24px;'; + } else { + list.className = 'ml-5 mt-0.5 space-y-0'; + } + var _populated = _unsortedOpen; + function _populate() { + _sortSongs(songs).forEach(function (s) { + list.appendChild(_view === 'grid' ? _songCard(s, '') : _songRow(s, '')); + }); + } + if (_unsortedOpen) { _populate(); } else { list.style.display = 'none'; } + _makeDropTarget(list, ''); + + hdr.addEventListener('click', function () { + if (_query()) return; + _unsortedOpen = list.style.display === 'none'; + if (_unsortedOpen && !_populated) { _populate(); _populated = true; } + list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none'; + chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : ''; + _store(cfg.unsortedKey, String(_unsortedOpen)); + }); + + wrap.appendChild(hdr); wrap.appendChild(list); + return wrap; + } + + // ── Folder management ─────────────────────────────────────────────── + async function _createFolder(parentPath) { + var msg = parentPath ? 'New subfolder name in "' + parentPath.split('/').pop() + '":' : 'New folder name:'; + var name = await _prompt(msg); + if (!name || !name.trim()) return; + try { + var body = { name: name.trim() }; + if (parentPath) body.parent = parentPath; + await _api('/folder/create', body); + var newPath = parentPath ? parentPath + '/' + name.trim() : name.trim(); + if (parentPath) _openFolders.add(parentPath); + _openFolders.add(newPath); + await _load(true); + } catch (err) { await _prompt('Create failed: ' + err.message); } + } + async function _renameFolder(folderPath) { + var oldName = folderPath.split('/').pop(); + var newName = await _prompt('Rename "' + oldName + '" to:', oldName); + if (!newName || !newName.trim() || newName.trim() === oldName) return; + try { + await _api('/folder/rename', { old: folderPath, new: newName.trim() }); + var parts = folderPath.split('/'); + parts[parts.length - 1] = newName.trim(); + var newPath = parts.join('/'); + var updated = new Set(); + _openFolders.forEach(function (p) { + if (p === folderPath) updated.add(newPath); + else if (p.startsWith(folderPath + '/')) updated.add(newPath + p.slice(folderPath.length)); + else updated.add(p); + }); + _openFolders = updated; + _storeJSON('open', [..._openFolders]); + await _load(true); + } catch (err) { await _prompt('Rename failed: ' + err.message); } + } + async function _deleteFolder(folderPath, songCount, folderCount) { + var folderName = folderPath.split('/').pop(); + var parts = []; + if (songCount > 0) parts.push(songCount + ' song' + (songCount === 1 ? '' : 's')); + if (folderCount > 0) parts.push(folderCount + ' subfolder' + (folderCount === 1 ? '' : 's')); + var msg = parts.length + ? 'Delete "' + folderName + '"? It contains ' + parts.join(' and ') + '. Songs will be moved to Unsorted.' + : 'Delete empty folder "' + folderName + '"?'; + var ok = await _confirm(msg); + if (!ok) return; + try { + await _api('/folder/delete', { name: folderPath }); + var toDelete = []; + _openFolders.forEach(function (p) { + if (p === folderPath || p.startsWith(folderPath + '/')) toDelete.push(p); + }); + toDelete.forEach(function (p) { _openFolders.delete(p); }); + _storeJSON('open', [..._openFolders]); + await _load(true); + } catch (err) { await _prompt('Delete failed: ' + err.message); } + } + + // ── Expand / collapse all ─────────────────────────────────────────── + function _expandAll() { + if (!_tree) return; + function _addPaths(f) { _openFolders.add(f.path); (f.children || []).forEach(_addPaths); } + _tree.folders.forEach(_addPaths); + _unsortedOpen = true; + _storeJSON('open', [..._openFolders]); _store(cfg.unsortedKey, 'true'); + _render(); + } + function _collapseAll() { + _openFolders.clear(); _unsortedOpen = false; + _storeJSON('open', []); _store(cfg.unsortedKey, 'false'); + _render(); + } + + // ── Render ────────────────────────────────────────────────────────── + function _render() { + _hoveredFolder = null; // DOM is rebuilt; discard any stale reference + var treeEl = _treeEl(); + if (!treeEl) return; + var data = _filtered(); + var frag = document.createDocumentFragment(); + var unsorted = _unsortedSection(data.root_songs); + if (unsorted) frag.appendChild(unsorted); + data.folders.forEach(function (f) { frag.appendChild(_folderSection(f)); }); + if (!data.folders.length && !data.root_songs.length) { + var emp = document.createElement('div'); + emp.className = 'flex flex-col items-center justify-center py-24 gap-3 text-gray-700'; + emp.innerHTML = '' + + '

' + (_query() ? 'No songs match your search.' : 'No songs found.') + '

'; + frag.appendChild(emp); + } + treeEl.innerHTML = ''; treeEl.appendChild(frag); + + // Lib: update the library count line ("N songs · M folders"). + if (cfg.countId) { + var countEl = _el(cfg.countId); + if (countEl) { + var total = data.root_songs.length; + var folderCount = 0; + function _countDeep(f) { + total += f.songs.length; + folderCount += 1; + (f.children || []).forEach(_countDeep); + } + data.folders.forEach(_countDeep); + var songStr = total + ' song' + (total === 1 ? '' : 's'); + var folderStr = folderCount + ' folder' + (folderCount === 1 ? '' : 's'); + countEl.textContent = songStr + ' · ' + folderStr; + } + } + } + + // ── Toolbar injection (lib surface, once) ─────────────────────────── + function _injectToolbar() { + if (_toolbarDone) return; + var ctrl = _el(cfg.controlsId); + if (!ctrl) { + ctrl = document.createElement('div'); + ctrl.id = cfg.controlsId; + var treeEl = _treeEl(); + if (!treeEl) return; + treeEl.parentNode.insertBefore(ctrl, treeEl); + } + ctrl.style.cssText = 'display:flex; align-items:center; gap:8px; margin-bottom:12px;'; + ctrl.innerHTML = ''; + + var viewGroup = document.createElement('div'); + viewGroup.style.cssText = 'display:flex; background:#1f2937; border:1px solid #374151; border-radius:10px; overflow:hidden;'; + var listBtn = document.createElement('button'); + listBtn.title = 'List view'; + listBtn.style.cssText = 'padding:7px 10px; border:none; cursor:pointer; transition:background 0.1s, color 0.1s;'; + listBtn.innerHTML = ''; + var gridBtn = document.createElement('button'); + gridBtn.title = 'Grid view'; + gridBtn.style.cssText = 'padding:7px 10px; border:none; cursor:pointer; transition:background 0.1s, color 0.1s;'; + gridBtn.innerHTML = ''; + function _applyViewBtns() { + listBtn.style.background = _view === 'list' ? '#374151' : 'transparent'; + listBtn.style.color = _view === 'list' ? '#e5e7eb' : '#6b7280'; + gridBtn.style.background = _view === 'grid' ? '#374151' : 'transparent'; + gridBtn.style.color = _view === 'grid' ? '#e5e7eb' : '#6b7280'; + } + _applyViewBtns(); + listBtn.addEventListener('click', function () { + if (_view === 'list') return; + _view = 'list'; _store('view', 'list'); _applyViewBtns(); _render(); + }); + gridBtn.addEventListener('click', function () { + if (_view === 'grid') return; + _view = 'grid'; _store('view', 'grid'); _applyViewBtns(); _render(); + }); + viewGroup.appendChild(listBtn); viewGroup.appendChild(gridBtn); + + var newBtn = _makeToolbarBtn( + '', + null, 'New parent folder' + ); + newBtn.addEventListener('click', function () { _createFolder(); }); + var expBtn = _makeToolbarBtn( + '', + null, 'Expand all' + ); + expBtn.addEventListener('click', _expandAll); + var colBtn = _makeToolbarBtn( + '', + null, 'Collapse all' + ); + colBtn.addEventListener('click', _collapseAll); + + ctrl.appendChild(viewGroup); + ctrl.appendChild(newBtn); + ctrl.appendChild(expBtn); + ctrl.appendChild(colBtn); + + _toolbarDone = true; + } + function _makeToolbarBtn(iconHtml, label, title) { + var btn = document.createElement('button'); + btn.title = title || ''; + btn.style.cssText = 'display:flex; align-items:center; gap:6px; padding:7px 12px; background:#1f2937; border:1px solid #374151; border-radius:10px; color:#9ca3af; cursor:pointer; font-size:13px; white-space:nowrap; transition:color 0.1s, border-color 0.1s;'; + btn.innerHTML = iconHtml + (label ? '' + label + '' : ''); + btn.addEventListener('mouseenter', function () { btn.style.color = '#e5e7eb'; btn.style.borderColor = '#6b7280'; }); + btn.addEventListener('mouseleave', function () { btn.style.color = '#9ca3af'; btn.style.borderColor = '#374151'; }); + return btn; + } + + // ── Unload (lib surface) ──────────────────────────────────────────── + function _unload() { + if (!cfg.searchInputId) return; + var el = _el(cfg.searchInputId); + if (el) el.style.maxWidth = ''; + } + + // ── Init (nav surface) ────────────────────────────────────────────── + function _init() { + _closeDropdown(); + _fixHeight(); + window.addEventListener('resize', _fixHeight); + + var search = _el('fb-search'); + var reload = _el('fb-reload'); + var expandAll = _el('fb-expand-all'); + var collapseAll = _el('fb-collapse-all'); + var newFolder = _el('fb-new-folder'); + var filterBtn = _el('fb-filter'); + var filterBack = _el('fb-filter-backdrop'); + var viewList = _el('fb-view-list'); + var viewGrid = _el('fb-view-grid'); + + if (!search) return; + + search.style.position = 'relative'; + search.style.zIndex = '100'; + + function _updateViewButtons() { + if (!viewList || !viewGrid) return; + viewList.style.color = _view === 'list' ? '#ffffff' : ''; + viewList.style.background = _view === 'list' ? '#1f2937' : ''; + viewGrid.style.color = _view === 'grid' ? '#ffffff' : ''; + viewGrid.style.background = _view === 'grid' ? '#1f2937' : ''; + } + _updateViewButtons(); + if (viewList) viewList.addEventListener('click', function () { + if (_view === 'list') return; + _view = 'list'; _store('view', 'list'); _updateViewButtons(); _render(); + }); + if (viewGrid) viewGrid.addEventListener('click', function () { + if (_view === 'grid') return; + _view = 'grid'; _store('view', 'grid'); _updateViewButtons(); _render(); + }); + + var sortSel = _el('fb-sort'); + var sortDirBtn = _el('fb-sort-dir'); + var sortDirIco = _el('fb-sort-dir-icon'); + function _updateSortDir() { + if (!sortDirBtn) return; + var isAsc = _sortDir === 'asc'; + var active = _sort !== 'default'; + sortDirBtn.title = isAsc ? 'Ascending' : 'Descending'; + sortDirBtn.style.opacity = active ? '' : '0.35'; + sortDirBtn.style.cursor = active ? '' : 'default'; + if (sortDirIco) { + sortDirIco.innerHTML = isAsc + ? '' + : ''; + } + } + _updateSortDir(); + if (sortSel) { + sortSel.value = _sort; + sortSel.addEventListener('change', function () { + _sort = sortSel.value; _store('sort', _sort); _updateSortDir(); _render(); + }); + } + if (sortDirBtn) { + sortDirBtn.addEventListener('click', function () { + if (_sort === 'default') return; + _sortDir = _sortDir === 'asc' ? 'desc' : 'asc'; + _store('sortDir', _sortDir); _updateSortDir(); _render(); + }); + } + + search.addEventListener('input', function () { _render(); }); + search.addEventListener('click', function (e) { e.stopPropagation(); _closeDropdown(); }); + + reload.addEventListener('click', function () { _loaded = false; _load(true); }); + expandAll.addEventListener('click', _expandAll); + collapseAll.addEventListener('click', _collapseAll); + newFolder.addEventListener('click', function () { _createFolder(); }); + if (filterBtn) filterBtn.addEventListener('click', _openFilterPanel); + if (filterBack) filterBack.addEventListener('click', _closeFilterPanel); + _updateFilterBadge(); + + if (!_loaded) _load(true); + } + + // ── Screen changed (nav surface) ──────────────────────────────────── + function _onScreenChanged(ev) { + var id = ev && ev.detail && ev.detail.id; + if (id === cfg.screenId) { + _closeDropdown(); + if (!_loaded) _load(true); + } + } + + return { + load: _load, + unload: _unload, + init: _init, + onScreenChanged: _onScreenChanged, + render: _render, + }; +} + +// ════════════════════════════════════════════════════════════════════════ +// Surface configs +// ════════════════════════════════════════════════════════════════════════ +var NAV_CONFIG = { + apiBase: API, + storePrefix: 'fo:', + treeId: 'fb-tree', + screenId: 'plugin-folder_library', + unsortedKey: 'unsorted_open', + ownsStatus: true, + ownsFilterPanel:true, + ownsSort: true, + songBadges: true, + deepFolderCount:false, + autoExpandTop: false, + injectToolbar: false, + getSearchEl: function () { return document.getElementById('fb-search'); }, +}; + +var LIB_CONFIG = { + apiBase: API, + storePrefix: 'fo:lib:', + treeId: 'lib-folder-tree', + controlsId: 'lib-folder-controls', + countId: 'lib-count', + searchInputId: 'lib-filter', + unsortedKey: 'unsorted', + ownsStatus: false, + ownsFilterPanel:false, + ownsSort: false, + songBadges: false, + deepFolderCount:true, + autoExpandTop: true, + injectToolbar: true, + getSearchEl: function () { return document.getElementById('v3-search') || document.getElementById('lib-filter'); }, + getFilterParams: function () { + return (typeof window.v3Songs?.filterParams === 'function') + ? window.v3Songs.filterParams() + : (typeof window.feedBackLibFilterParams === 'function') + ? window.feedBackLibFilterParams() + : (typeof window.slopsmithLibFilterParams === 'function') + ? window.slopsmithLibFilterParams() : ''; + }, + getHostArtist: function () { return (typeof window.v3Songs?.getArtist === 'function') ? window.v3Songs.getArtist() : ''; }, + getHostAlbum: function () { return (typeof window.v3Songs?.getAlbum === 'function') ? window.v3Songs.getAlbum() : ''; }, + getHostSort: function () { + return (typeof window.v3Songs?.getSort === 'function') + ? window.v3Songs.getSort() + : (document.getElementById('lib-sort') || document.getElementById('v3-songs-sort') || {}).value || ''; + }, +}; + +// ════════════════════════════════════════════════════════════════════════ +// Adapter A — v2 nav screen (renders into #fb-tree) +// ════════════════════════════════════════════════════════════════════════ +if (!window.__folderLibraryNavLoaded) { + window.__folderLibraryNavLoaded = true; + var _nav = createFolderSurface(NAV_CONFIG); + + if (window.feedBack && typeof window.feedBack.on === 'function') { + window.feedBack.on('screen:changed', _nav.onScreenChanged); + } else if (window.slopsmith && typeof window.slopsmith.on === 'function') { + window.slopsmith.on('screen:changed', _nav.onScreenChanged); + } else { + var _deadline = performance.now() + 5000; + var _pollId = setInterval(function () { + var bus = window.feedBack || window.slopsmith; + if (bus && typeof bus.on === 'function') { + clearInterval(_pollId); + bus.on('screen:changed', _nav.onScreenChanged); + } else if (performance.now() > _deadline) { + clearInterval(_pollId); + } + }, 100); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', _nav.init, { once: true }); + } else { + _nav.init(); + } +} + +// ════════════════════════════════════════════════════════════════════════ +// Adapter B — v3 library view (window.folderLibrary, renders into #lib-folder-tree) +// ════════════════════════════════════════════════════════════════════════ +// Idempotency: the factory instance is a persistent singleton (so its in-memory +// state survives a script re-injection), but window.folderLibrary is ALWAYS +// (re)assigned on every evaluation — the host's reload path does +// `delete window.folderLibrary` and re-injects this script expecting it back. +if (!window.__folderLibraryLib) { + window.__folderLibraryLib = createFolderSurface(LIB_CONFIG); +} +(function () { + var _lib = window.__folderLibraryLib; + + window.folderLibrary = { + load: function (force) { return _lib.load(force); }, + unload: function () { _lib.unload(); }, + }; + + // Auto-load if folder view was already active when this script was injected. + // On a hard refresh, setLibView() runs before plugins load, so + // window.folderLibrary didn't exist yet and the host's load call silently + // skipped. Now that we're defined, kick off the load if #lib-folder-tree is + // currently visible. + var treeEl = document.getElementById('lib-folder-tree'); + if (treeEl && !treeEl.classList.contains('hidden')) { + _lib.load(); + } +}()); + +})(); diff --git a/static/app.js b/static/app.js index 80714c9..6c0a696 100644 --- a/static/app.js +++ b/static/app.js @@ -1082,7 +1082,7 @@ const _LIB_VIEW_KEY = 'feedBack.libView'; const _LIB_SORT_KEY = 'feedBack.libSort'; const _LIB_FORMAT_KEY = 'feedBack.libFormat'; const _LIB_PROVIDER_KEY = 'feedBack.libProvider'; -const _LIB_VIEW_VALUES = new Set(['grid', 'tree']); +const _LIB_VIEW_VALUES = new Set(['grid', 'tree', 'folder']); const _LIB_SORT_VALUES = new Set([ 'artist', 'artist-desc', 'title', 'title-desc', 'recent', 'year-desc', 'year', 'tuning', @@ -1760,8 +1760,20 @@ function setLibView(view) { document.getElementById('lib-tree').classList.toggle('hidden', view !== 'tree'); document.querySelectorAll('.lib-grid-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'grid')); document.querySelectorAll('.lib-tree-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'tree')); + document.querySelectorAll('.lib-nontree-ctrl').forEach(el => el.classList.toggle('hidden', view === 'tree')); document.getElementById('view-grid-btn').className = `px-3 py-2.5 text-sm transition ${view === 'grid' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; document.getElementById('view-tree-btn').className = `px-3 py-2.5 text-sm transition ${view === 'tree' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; + // Folder view + const folderTreeEl = document.getElementById('lib-folder-tree'); + if (folderTreeEl) folderTreeEl.classList.toggle('hidden', view !== 'folder'); + const folderCtrlEl = document.getElementById('lib-folder-controls'); + if (folderCtrlEl) folderCtrlEl.classList.toggle('hidden', view !== 'folder'); + // The folder-view toolbar button only exists in the classic (v2) markup; + // setLibView also runs at v3 startup where it's absent, so guard it (the + // grid/tree buttons above predate this and exist on both paths). + const folderBtnEl = document.getElementById('view-folder-btn'); + if (folderBtnEl) folderBtnEl.className = `px-3 py-2.5 text-sm transition ${view === 'folder' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; + if (libView === 'folder' && view !== 'folder') window.folderLibrary?.unload?.(); if (view !== 'grid') stopInfiniteScroll(); _libEpoch++; // View toggle changes which container `_libNavItems` resolves @@ -1774,11 +1786,32 @@ function setLibView(view) { async function loadLibrary(page) { if (libView === 'grid') { await loadGridPage(page !== undefined ? page : currentPage); - } else { + } else if (libView === 'tree') { await loadTreeView(); + } else if (libView === 'folder') { + if (window.folderLibrary) await window.folderLibrary.load(); + } + // v3 Songs page manages its own view state independently of libView — if + // lib-folder-tree is visible, the folder library must also react to filter changes. + if (libView !== 'folder' && window.folderLibrary) { + const treeEl = document.getElementById('lib-folder-tree'); + if (treeEl && !treeEl.classList.contains('hidden')) { + await window.folderLibrary.load(); + } } } +// ── Folder Library: filter bridge ───────────────────────────────────────── +// Serialises the active lib filter state as URL params so the plugin can pass +// them to /api/plugins/folder_library/tree — the same pattern grid and tree +// views use when sending filter params to their own backend endpoints. +window.feedBackLibFilterParams = function() { + var p = new URLSearchParams(); + _applyLibFiltersToParams(p); + return p.toString(); +}; + + async function _fetchJsonOrThrow(url) { const resp = await fetch(url); const raw = await resp.text(); diff --git a/static/index.html b/static/index.html index 6341907..e49cc8a 100644 --- a/static/index.html +++ b/static/index.html @@ -103,10 +103,13 @@ +
' + provOpts + '' : '') + '' + '' + - '
' + + '
' + '' + '' + '' + @@ -913,6 +939,8 @@ '' + '
' + '' + + '' + + '' + '
' + // Filter drawer + overlay '' + @@ -979,14 +1007,17 @@ e.stopImmediatePropagation(); toggleSelect(card.getAttribute('data-fn'), card); }, true); - const setView = (v) => { + const setView = async (v) => { state.view = v; byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); byId('v3-songs-tree-btn').className = 'px-3 py-2 text-sm ' + (v === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); + byId('v3-songs-folder-btn').className = 'px-3 py-2 text-sm ' + (v === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); + if (v === 'folder') await _ensureFolderLibrary(); return reload(); }; byId('v3-songs-grid-btn').addEventListener('click', () => setView('grid')); byId('v3-songs-tree-btn').addEventListener('click', () => setView('tree')); + byId('v3-songs-folder-btn').addEventListener('click', () => setView('folder')); // Await the initial load so a caller awaiting render() (the scroll // restore on screen re-entry) sees a populated grid + real state.total // before it tries to page deeper. @@ -1042,6 +1073,8 @@ if (state.renderedHash !== _libraryStateHash()) { reload(); return; } document.getElementById('v3-songs-grid')?.classList.toggle('hidden', state.view !== 'grid'); document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree'); + document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder'); + { const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; } return; } @@ -1093,6 +1126,20 @@ reload: reload, search: search, setQuery: (q) => { state.q = q || ''; }, + getSort: () => state.sort, + getArtist: () => state.artist, + getAlbum: () => state.album, + filterParams: () => { + const f = state.filters; + const p = new URLSearchParams(); + if (f.arr_has.length) p.set('arrangements_has', f.arr_has.join(',')); + if (f.arr_lacks.length) p.set('arrangements_lacks', f.arr_lacks.join(',')); + if (f.stem_has.length) p.set('stems_has', f.stem_has.join(',')); + if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(',')); + if (f.lyrics) p.set('has_lyrics', f.lyrics); + if (f.tunings.length) p.set('tunings', f.tunings.join(',')); + return p.toString(); + }, _scrollHelpers: { SCROLL_STATE_KEY, buildLibraryStateHash, diff --git a/tests/plugins/folder_library/test_routes.py b/tests/plugins/folder_library/test_routes.py new file mode 100644 index 0000000..6b1cbcf --- /dev/null +++ b/tests/plugins/folder_library/test_routes.py @@ -0,0 +1,208 @@ +"""Tests for the folder_library plugin backend. + +Covers the pure path-safety helpers and end-to-end behaviour of the two +filesystem-mutating endpoints whose bugs this guards against: + * /song/move must reject path traversal in `filename` (no escaping DLC_DIR). + * /folder/delete must relocate EVERY song to the root, never destroy a song + whose name collides with an existing root song. + +The plugin's routes.py is loaded under a unique module name via importlib so it +does not collide in sys.modules with other bundled plugins' routes.py. +""" + +import importlib.util +import logging +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +_ROUTES_PATH = ( + Path(__file__).resolve().parents[3] + / "plugins" / "folder_library" / "routes.py" +) +_spec = importlib.util.spec_from_file_location("folder_library_routes", _ROUTES_PATH) +fl = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(fl) + + +# ── Pure helpers ──────────────────────────────────────────────────────────── + +class TestSafeName: + @pytest.mark.parametrize("name", ["Rock", "Folder 1", "A-B_C", "über", "AC.DC"]) + def test_accepts_ordinary_names(self, name): + assert fl._safe_name(name) is True + + @pytest.mark.parametrize("name", [ + "", "..", ".", "../x", "a/b", "a\\b", "a:b", "a*b", "a?b", + 'a"b', "ab", "a|b", " lead", "lead ", + ]) + def test_rejects_unsafe_names(self, name): + assert fl._safe_name(name) is False + + +class TestSafePath: + @pytest.mark.parametrize("path", ["A", "A/B", "A/B/C", "Rock/Sub Folder"]) + def test_accepts_safe_paths(self, path): + assert fl._safe_path(path) is True + + @pytest.mark.parametrize("path", [ + "", "..", "../x", "A/../B", "A/..", "/A", "A//B", "A/b\\c", + ]) + def test_rejects_traversal_and_empty(self, path): + assert fl._safe_path(path) is False + + +class TestIsWithin: + def test_inside(self, tmp_path): + assert fl._is_within(tmp_path, tmp_path / "a" / "b") is True + + def test_traversal_escapes(self, tmp_path): + root = tmp_path / "dlc" + root.mkdir() + assert fl._is_within(root, root / ".." / "secret") is False + + def test_sibling_prefix_not_within(self, tmp_path): + root = tmp_path / "dlc" + root.mkdir() + (tmp_path / "dlc-evil").mkdir() + assert fl._is_within(root, tmp_path / "dlc-evil" / "x") is False + + +class TestIsSong: + @pytest.mark.parametrize("name", ["a.sloppak", "a.feedpak", "A.SLOPPAK"]) + def test_song_extensions(self, name, tmp_path): + assert fl._is_song(tmp_path / name) is True + + @pytest.mark.parametrize("name", ["a.txt", "a", "a.zip"]) + def test_non_song(self, name, tmp_path): + assert fl._is_song(tmp_path / name) is False + + +# ── Endpoint behaviour ────────────────────────────────────────────────────── + +@pytest.fixture +def env(tmp_path): + dlc = tmp_path / "dlc" + dlc.mkdir() + app = FastAPI() + fl.setup(app, { + "log": logging.getLogger("folder_library_test"), + "get_dlc_dir": lambda: str(dlc), + "extract_meta": lambda p: {}, + }) + return TestClient(app), dlc, tmp_path + + +def _song(path: Path, content: str): + path.write_text(content) + + +def _loose_song(folder: Path): + """Minimal valid loose-folder song: audio + an arrangement XML ( root).""" + folder.mkdir(parents=True, exist_ok=True) + (folder / "audio.wem").write_bytes(b"\x00") + (folder / "lead.xml").write_text("Loose") + + +class TestLooseFolderRecognition: + def test_is_song_detects_loose_folder_dir(self, tmp_path): + loose = tmp_path / "MyLoose" + _loose_song(loose) + assert fl._is_song(loose) is True + + def test_plain_folder_is_not_a_song(self, tmp_path): + plain = tmp_path / "Plain" + plain.mkdir() + (plain / "notes.txt").write_text("x") + assert fl._is_song(plain) is False + + def test_loose_folder_surfaces_as_song_not_child_folder(self, env): + client, dlc, _ = env + _loose_song(dlc / "Rock" / "LooseSong") + r = client.get("/api/plugins/folder_library/tree") + assert r.status_code == 200, r.text + rock = next(f for f in r.json()["folders"] if f["name"] == "Rock") + assert "LooseSong" in {s["title"] for s in rock["songs"]} + assert "LooseSong" not in {c["name"] for c in rock["children"]} + + +class TestMoveTraversal: + def test_rejects_parent_traversal_and_does_not_move(self, env): + client, dlc, tmp = env + secret = tmp / "secret.sloppak" + _song(secret, "TOP SECRET") + r = client.post("/api/plugins/folder_library/song/move", + json={"filename": "../secret.sloppak", "folder": ""}) + assert r.status_code == 400 + # The external file must NOT have been moved into the served library. + assert secret.exists() + assert not (dlc / "secret.sloppak").exists() + + def test_rejects_absolute_style_traversal(self, env): + client, dlc, tmp = env + r = client.post("/api/plugins/folder_library/song/move", + json={"filename": "../../etc/passwd", "folder": ""}) + assert r.status_code == 400 + + def test_valid_move_succeeds(self, env): + client, dlc, _ = env + _song(dlc / "A.sloppak", "a") + (dlc / "Dest").mkdir() + r = client.post("/api/plugins/folder_library/song/move", + json={"filename": "A.sloppak", "folder": "Dest"}) + assert r.status_code == 200 + assert not (dlc / "A.sloppak").exists() + assert (dlc / "Dest" / "A.sloppak").read_text() == "a" + + +class TestDeleteFolderNoDataLoss: + def test_colliding_song_is_relocated_not_destroyed(self, env): + client, dlc, _ = env + # A root song and a same-named song inside the folder being deleted. + _song(dlc / "song.sloppak", "ROOT") + (dlc / "F").mkdir() + _song(dlc / "F" / "song.sloppak", "INSIDE") + + r = client.post("/api/plugins/folder_library/folder/delete", + json={"name": "F"}) + assert r.status_code == 200, r.text + + # Folder gone, original root song intact, and the colliding song + # survived under a de-duplicated name (NOT destroyed by rmtree). + assert not (dlc / "F").exists() + assert (dlc / "song.sloppak").read_text() == "ROOT" + survivors = {p.read_text() for p in dlc.glob("*.sloppak")} + assert "INSIDE" in survivors + assert len(list(dlc.glob("*.sloppak"))) == 2 + + def test_nested_songs_all_relocated(self, env): + client, dlc, _ = env + (dlc / "F" / "Sub").mkdir(parents=True) + _song(dlc / "F" / "a.sloppak", "a") + _song(dlc / "F" / "Sub" / "b.sloppak", "b") + r = client.post("/api/plugins/folder_library/folder/delete", + json={"name": "F"}) + assert r.status_code == 200, r.text + assert not (dlc / "F").exists() + names = {p.name for p in dlc.glob("*.sloppak")} + assert names == {"a.sloppak", "b.sloppak"} + + +class TestFolderOpsValidation: + def test_create_rejects_unsafe_name(self, env): + client, _, _ = env + r = client.post("/api/plugins/folder_library/folder/create", + json={"name": "../evil"}) + assert r.status_code == 400 + + def test_create_and_rename_roundtrip(self, env): + client, dlc, _ = env + assert client.post("/api/plugins/folder_library/folder/create", + json={"name": "New"}).status_code == 200 + assert (dlc / "New").is_dir() + assert client.post("/api/plugins/folder_library/folder/rename", + json={"old": "New", "new": "Renamed"}).status_code == 200 + assert (dlc / "Renamed").is_dir() + assert not (dlc / "New").exists() From 4480ac2732b5d4bcaeb3838722d2936078a3180a Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sat, 27 Jun 2026 18:16:36 +0200 Subject: [PATCH 57/99] feat(v3): promote Audio Engine to a first-class sidebar entry (after Settings) (#613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop Audio Engine plugin (input device selection, VST hosting, pitch detection, and the new config Reset/repair UI) was reachable only via the generic Plugins gallery — per-plugin manifest nav entries aren't surfaced in the v3 sidebar unless the plugin is promoted. Add it to PROMOTED_PLUGINS anchored after Settings, plus the matching NAV registry entry so the slot resolves its label/screen. Desktop-only by construction: the slot is filled only when /api/plugins reports audio_engine installed, so the web app shows no dead entry. Co-authored-by: Claude Opus 4.8 (1M context) --- static/v3/shell.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/static/v3/shell.js b/static/v3/shell.js index 1239f48..791975e 100644 --- a/static/v3/shell.js +++ b/static/v3/shell.js @@ -50,6 +50,7 @@ { key: 'virtuoso', screen: 'plugin-virtuoso', label: 'Virtuoso - Practice', group: null, icon: 'target' }, { key: 'rig_builder', screen: 'plugin-rig_builder', label: 'Rig Builder', group: null, icon: 'amp' }, { key: 'editor', screen: 'plugin-editor', label: 'Song Editor', group: null, icon: 'edit' }, + { key: 'audio_engine', screen: 'plugin-audio_engine', label: 'Audio', group: null, icon: 'amp' }, // Not in the sidebar groups, but routable (profile badge → here). { key: 'profile', screen: 'v3-profile', label: 'Profile', group: null, icon: 'user' }, ]; @@ -61,6 +62,7 @@ { navKey: 'virtuoso', pluginId: 'virtuoso', slotId: 'v3-nav-virtuoso', anchorAfter: 'feedbarcade' }, { navKey: 'rig_builder', pluginId: 'rig_builder', slotId: 'v3-nav-rig-builder', anchorAfter: 'saved' }, { navKey: 'editor', pluginId: 'editor', slotId: 'v3-nav-editor', anchorAfter: 'songs' }, + { navKey: 'audio_engine', pluginId: 'audio_engine', slotId: 'v3-nav-audio-engine', anchorAfter: 'settings' }, ]; const TOPBAR_KEYS = ['home', 'songs', 'plugins', 'settings']; const SIDEBAR_GROUPS = ['HOME', 'LIBRARY']; From a57d0e3f85f6eddf3a08f323a2a3e7446f218897 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sat, 27 Jun 2026 20:35:30 +0200 Subject: [PATCH 58/99] fix(v3): replace broken window.prompt() in Playlists with in-app uiPrompt modal (#614) window.prompt() is a silent no-op in the Electron desktop shell, so the Playlists "New Playlist" and "Rename" buttons and the library's bulk "add selected songs to a playlist" action did nothing. Route all three through the existing window.uiPrompt() modal (resolves to the string, or null on cancel; the handlers were already async). window.confirm() works in Electron and is left as-is. Co-authored-by: Claude Opus 4.8 (1M context) --- static/v3/playlists.js | 4 ++-- static/v3/songs.js | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/static/v3/playlists.js b/static/v3/playlists.js index 1e060be..c336d65 100644 --- a/static/v3/playlists.js +++ b/static/v3/playlists.js @@ -104,7 +104,7 @@ : '

No playlists yet. Create one to group songs.

') + ''; root.querySelector('#v3-pl-new')?.addEventListener('click', async () => { - const name = (window.prompt('Playlist name?') || '').trim(); + const name = ((await window.uiPrompt({ title: 'New Playlist', label: 'Playlist name', okLabel: 'Create', placeholder: 'My Playlist' })) || '').trim(); if (!name) return; await jsend('POST', '/api/playlists', { name }); renderPlaylists(); @@ -137,7 +137,7 @@ const listEl = root.querySelector('#v3-pl-songs'); if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid)); root.querySelector('#v3-pl-rename')?.addEventListener('click', async () => { - const name = (window.prompt('Rename playlist', pl.name) || '').trim(); + const name = ((await window.uiPrompt({ title: 'Rename Playlist', label: 'Playlist name', value: pl.name, okLabel: 'Rename' })) || '').trim(); if (!name) return; await jsend('PATCH', '/api/playlists/' + pid, { name }); renderPlaylistDetail(pid); diff --git a/static/v3/songs.js b/static/v3/songs.js index 67c6e5e..fa44a95 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -595,8 +595,13 @@ async function batchAddToPlaylist() { const lists = (await jget('/api/playlists')) || []; const choices = lists.filter((p) => !p.system_key); - const labels = choices.map((p, i) => (i + 1) + '. ' + p.name).join('\n'); - const ans = (window.prompt('Add ' + state.selected.size + ' song(s) to which playlist?\n' + labels + '\n\nEnter a number, or a new playlist name:', '') || '').trim(); + const labels = choices.map((p, i) => (i + 1) + '. ' + p.name).join(' '); + const ans = ((await window.uiPrompt({ + title: 'Add ' + state.selected.size + ' song(s) to a playlist', + label: (labels ? labels + ' ' : '') + 'Type a number above, or a new playlist name:', + okLabel: 'Add', + placeholder: 'Number or new playlist name', + })) || '').trim(); if (!ans) return; let pid = null; const num = parseInt(ans, 10); From b2066331312fcc1d6f31c49e5e016b06f51b836c Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sat, 27 Jun 2026 21:07:44 +0200 Subject: [PATCH 59/99] fix(v3): keep Section Map's leftmost section clickable under the rail catcher (#617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section_map plugin pins a ~20px clickable bar (#section-map, z-index:5) to the top of #player. The v3 left-rail hover-catcher (.v3-railzone::before) is full-height at z-index:30 with pointer-events:auto, so its top-left corner swallowed every click on the section map's first section — the left-most section was never clickable on the v3 desktop (macOS/Windows) UI. Drop the catcher below the 20px bar when the section map is present, mirroring the existing #section-map ~ #player-hud special-case in static/style.css. The rail still reveals from anywhere below the bar. Adds a Playwright regression test (hit-test of the top-left corner) with a negative control that re-raises the catcher to reproduce the bug. Fixes #616 Co-authored-by: Claude Opus 4.8 (1M context) --- static/v3/v3.css | 7 ++ .../v3-section-map-leftmost-click.spec.ts | 93 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 tests/browser/v3-section-map-leftmost-click.spec.ts diff --git a/static/v3/v3.css b/static/v3/v3.css index 7337fcc..996c072 100644 --- a/static/v3/v3.css +++ b/static/v3/v3.css @@ -425,6 +425,13 @@ html[data-scoreboard="off"] #v3-live-performance-hud { display: none !important; width: 96px; pointer-events: auto; } +/* The Section Map plugin pins a ~20px clickable bar to the very top of #player + (#section-map, z-index:5). The rail catcher above is full-height at z-index:30, + so its top-left corner swallows clicks on the section map's first section. When + the bar is present, drop the catcher below it so the top strip stays clickable; + the rail still reveals from anywhere below the bar. Mirrors the core + `#section-map ~ #player-hud` special-case in static/style.css. */ +#section-map ~ #v3-railzone::before { top: 20px; } .v3-rail { position: relative; diff --git a/tests/browser/v3-section-map-leftmost-click.spec.ts b/tests/browser/v3-section-map-leftmost-click.spec.ts new file mode 100644 index 0000000..9f7a807 --- /dev/null +++ b/tests/browser/v3-section-map-leftmost-click.spec.ts @@ -0,0 +1,93 @@ +import { test, expect } from '@playwright/test'; + +// Regression coverage for the v3 Section Map "leftmost section unclickable" bug. +// +// The Section Map plugin pins a ~20px clickable bar (#section-map, z-index:5) +// to the very top of #player. The v3 chrome has a full-height invisible rail +// "catcher" (.v3-railzone::before, z-index:30, width:96px, pinned left/top:0) +// that reveals the hover rail. Because the catcher sat at top:0 and outranks +// the bar, its top-left corner swallowed every click on the section map's first +// section. Fix (static/v3/v3.css): `#section-map ~ #v3-railzone::before { top: 20px }` +// drops the catcher below the bar when the section map is present. +// +// We reproduce the plugin's bar exactly (first child of #player, the rendered +// position:relative / z-index:5 / 20px-tall state) and hit-test the top-left +// corner with elementFromPoint — that is precisely what a real click resolves +// against. A negative control re-raises the catcher to prove the test catches +// the bug. + +// A fresh profile shows the blocking onboarding overlay; onboard via the API so +// it isn't (re)created over the player. Idempotent once onboarded. +test.beforeEach(async ({ request }) => { + await request.post('/api/profile', { data: { display_name: 'Section Map Tester' } }); + await request.post('/api/progression/paths', { data: { add: ['guitar'] } }); + await request.post('/api/progression/onboarding', { data: { action: 'skip' } }); +}); + +async function openPlayerWithSectionMap(page) { + await page.goto('/'); + await page.waitForSelector('.screen.active', { timeout: 10000 }); + // The bug affects an already-onboarded user mid-song. The API skip above + // handles the common path; this persistent hide also covers a slow async + // profile render that could otherwise re-create the full-screen overlay and + // intercept the top-left hit-test (mirrors settings-tabbed.spec.ts). + await page.addStyleTag({ content: '#v3-onboarding{display:none!important;pointer-events:none!important}' }); + await page.evaluate(() => { + // @ts-ignore — show the player screen (static #v3-railzone markup lives here). + window.showScreen('player'); + const player = document.getElementById('player'); + if (!player) throw new Error('#player missing'); + + // Reproduce the section_map plugin's rendered bar: first child of #player, + // 20px tall, full width, z-index:5, position:relative (its post-_smRender + // state), with a left-edge "first section" block at left:0. + const bar = document.createElement('div'); + bar.id = 'section-map'; + bar.style.cssText = + 'position:relative;top:0;left:0;right:0;z-index:5;height:20px;background:rgba(8,8,16,0.7);cursor:pointer;'; + const block = document.createElement('div'); + block.id = 'sm-first-block'; + block.style.cssText = + 'position:absolute;left:0;width:30%;top:0;bottom:0;background:#3b82f6;'; + bar.appendChild(block); + player.insertBefore(bar, player.firstChild); + }); + await page.waitForSelector('#section-map', { state: 'attached', timeout: 5000 }); + await page.waitForSelector('#v3-railzone', { state: 'attached', timeout: 5000 }); +} + +// What element does a click at the top-left strip land on? (x within the 96px +// catcher, y within the 20px bar.) +function hitTopLeft(page, x = 10, y = 8) { + return page.evaluate(({ x, y }) => { + const el = document.elementFromPoint(x, y) as HTMLElement | null; + return el ? { id: el.id, cls: el.className, tag: el.tagName } : null; + }, { x, y }); +} + +test('top-left of the section map receives clicks, not the rail catcher (fix present)', async ({ page }) => { + await openPlayerWithSectionMap(page); + + const hit = await hitTopLeft(page); + // Click must resolve to the section map (the bar or its first-section block), + // never the rail hover-zone. + expect(hit).not.toBeNull(); + expect(hit!.id).not.toBe('v3-railzone'); + expect(['section-map', 'sm-first-block']).toContain(hit!.id); +}); + +test('negative control: re-raising the catcher to top:0 reproduces the bug', async ({ page }) => { + await openPlayerWithSectionMap(page); + + // Undo the fix at runtime (highest-specificity inline-ish override) so the + // catcher again covers the bar's top-left — this is the pre-fix layout. + await page.evaluate(() => { + const style = document.createElement('style'); + style.textContent = '#section-map ~ #v3-railzone::before { top: 0 !important; }'; + document.head.appendChild(style); + }); + + const hit = await hitTopLeft(page); + // Without the fix, the rail catcher swallows the click. + expect(hit!.id).toBe('v3-railzone'); +}); From 290783b80b496fa7341321b437b976e44cc0d9c2 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sun, 28 Jun 2026 04:53:48 -0500 Subject: [PATCH 60/99] feat(highway_3d): hit-feedback juice + Hit-sparks toggle (#618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(highway_3d): hit-feedback juice — cinematic lighting, strike line, sparks, intensity dial Charrette wave 1 (additive, default-tasteful, all behind settings): - #8 Hit-feedback settings: hitFx (0..1), cinematic, verdictMarks, timingFx, streakFx in BG_DEFAULTS + h3dBgSet* setters + settings.html (intensity slider + cinematic toggle). hitFx=0 → colour verdict only. - #2 Cinematic lighting: ambient 0.85→0.35 + stronger key light when cinematic on, so emissive gems have a dark surround to pop against. Live-toggleable. - #1 Strike line: a glowing bar at the hit line (Z=0) that flashes green on a verified hit / red on a miss, eased from the per-frame verdict alpha. - #3 Hit sparks: a pooled additive Points burst at the gem on a verified hit (deduped one burst per note), scaled by hitFx; disposed on teardown. Staged for wave 2 (after dogfooding): bloom+ACES (#4), colorblind verdict glyphs (#6), early/late timing tint (#5), streak heat + clean-bar (#7), gem scale-punch. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq * feat(highway_3d): wave 2 — gem scale-punch, streak heat, colorblind verdict marks - #3 (completion) gem scale-punch: the hit gem briefly grows (1 + 0.22·hitFx·alpha), biggest at the strike and easing with the verdict — the per-gem impulse. - #7 streak heat: a renderer-side consecutive-hit counter eases a 0..1 "heat" (plateau at 16) that grows the spark burst + warms the strike-line idle glow; a miss eases it back down. Behind the Streak-feedback toggle. - #6 colorblind verdict marks: a redundant ✓ (hit) / ✗ (miss) glyph on the verdict via the existing 2D label overlay, so the green/red pair isn't the only signal — notably also covers the provider path (where the timing labels don't show). - settings.html: Streak-feedback + Accessible-marks toggles. Deferred: #4 bloom+ACES (needs the Three.js postprocessing addons vendored into core static/vendor/three/ — not present; warrants its own infra change), and #5's timing tint (the early/late ±ms labels already render on the event path; surfacing them on the provider path needs a notedetect verdict field — a cross-plugin item). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq * feat(highway_3d): #4 bloom + ACES — vendored Three.js postprocessing, perf-gated The single biggest fidelity lever from the charrette. Core had only three.module.min.js (no postprocessing addons), so this vendors the r170 EffectComposer/RenderPass/UnrealBloomPass/OutputPass + their shader deps into static/vendor/three/addons/, with every `from 'three'` rewritten to the SAME vendored three (../../three.module.min.js) so the addons share the plugin's three instance (a CDN copy would be a second, non-interoperable module). highway_3d wiring: - Lazy-loads the addons only when the new `bloom` setting is on (dynamic import), builds EffectComposer(RenderPass → UnrealBloomPass(strength .65/radius .5/ threshold .82 — high so only emissive gems + the hit flash bloom) → OutputPass). - Render loop uses composer.render() with ACES tone-mapping when bloom is active, else the unchanged direct ren.render() with NoToneMapping (bloom-off = today's look). - Perf-gated: OFF in splitscreen; graceful fallback to direct render if the modules or composer fail; composer.setSize on canvas resize; disposed on teardown. - settings.html: "Glow bloom" toggle (default on). Verified the import chain resolves + renders via a same-origin module-load test (EffectComposer built + a bloom frame rendered, three r170). Charrette status: 7/8 (only #5's early/late timing tint remains — a notedetect verdict-field change, outside the highway). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq * feat(highway_3d): #5 early/late timing — colour the hit feedback by timing Surfaces the detector's timing on every hit (the charrette's last item), fully highway-side: notedetect already dispatches the judgment (timingState/timingError) on notedetect:hit/miss, so we carry timingState onto the event mark and tint the hit's spark burst + the ✓ verdict glyph by it — on-time green, early cyan, late amber. Gracefully falls back to green when no timing is known (pure-provider path), so it never invents data. Behind the new "Timing feedback" toggle (default on). Charrette: 8/8 complete. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq * feat(highway_3d): add a "Hit sparks" on/off toggle (note-hit particles) The on-hit spark burst (the particle effect that fires the instant note_detect confirms a hit) could previously only be removed by dragging Hit-feedback intensity to 0 — which also kills the strike-line flash and the scale-punch. Add a dedicated "Hit sparks" toggle (default on) under 3D Highway settings, in the hit-feedback group beside the intensity slider, that gates ONLY the spark particles; the strike flash and colour verdict are unaffected. Wired the same way as the sibling juice toggles: a `sparks` boolean in BG_DEFAULTS, in _BG_BOOL_KEYS, a window.h3dBgSetSparks setter, the per-instance _sparks state + settings re-read, and a guard on the _sparkBurst spawn. Reuses existing Tailwind utility classes, so assets/plugin.css is unchanged; plugin.json version bumped to 3.28.0. Co-Authored-By: Claude Opus 4.8 * feat(highway_3d): act on tester charrette — strike line, fog readability, AA Addresses the alpha-tester 3D-highway feedback thread via the design panel's recommendations: - Strike line (panel rec 1a): now a HIT-ONLY faint "now" line — flashes green on a confirmed hit, no red miss branch (misses already show at the gem: red wash + ✗). Moved off the bottom edge to the vertical CENTRE of the string field, which was the "incorrectly placed" complaint (it read as the board's lower border and fused with open-string gems on a miss). Added a "Strike line" on/off toggle (`strikeLine`, default on). - Horizon readability (#2): the note gems + their outlines are now fog-exempt (`material.fog = false` on mStr/mGlow/mStrHitOutline/mHitBright/mWhiteOutline/ mMissOutline), so upcoming notes punch through the distance fog and stay legible as they render in — the board, lane, sustains and scenery keep their atmospheric fog, so depth is preserved. - Cinematic lighting softened: cinematic ambient 0.35 -> 0.45 so the dark stage doesn't crush note/fret legibility. - Anti-aliasing under bloom (perf rec): give the bloom EffectComposer a multisampled (WebGL2 MSAA x4) HalfFloat render target. The default target had no `samples`, so bloom-on bypassed MSAA — the "too HD / jagged on Windows, fine on Mac" report (Mac only won via Retina supersampling). This is the highest-value, smallest fix for the jaggies. plugin.json -> 3.29.0. The renderScale quality-oscillation is core (static/highway.js) and will be a separate feedBack PR. Co-Authored-By: Claude Opus 4.8 * feat(highway_3d): remove the strike line; sparks-only hit feedback, subtler Second tester-charrette pass. The strike line (even hit-only/centred from the last pass) was still too distracting/confusing on a hit, so it's removed entirely — strings + fret markers already orient the player, and the hit is fully carried at the gem (bright outline + scale-punch + spark burst) with the timing-coloured ✓/✗ verdict as the knowledge-of-results channel. - Deleted the strike-line mesh, its per-frame update, the `strikeLine` setting (BG_DEFAULTS / _BG_BOOL_KEYS / setter / settings-load), the settings.html toggle, and the now-dead `_strikeLine`/`_ndHitFlash`/`_ndMissFlash` state + their verdict-block feeds. - Made the spark burst subtler now that it's the sole celebration: point size 1.7→1.0·K, opacity 0.95→0.8, burst count (7+13·hitFx)→(4+7·hitFx), radial speed (7+r·20)→(5+r·12)·K, life (0.40+r·0.28)→(0.30+r·0.16)s. - Toggles for Hit sparks and the ✓/✗ verdict marks already exist in settings (kept). Minimal hit-feedback set now: gem bright + subtle spark (celebration) + timing-coloured ✓/✗ (the KR) + ambient streak heat. plugin.json -> 3.30.0. Co-Authored-By: Claude Opus 4.8 * fix(highway_3d): hydrate hit-feedback settings controls from saved state The 7 new juice controls (Hit sparks, Cinematic, Streak, Verdict marks, Bloom, Timing, Hit-feedback intensity) were hard-coded to their default markup and never read back from localStorage when the settings panel reopened — so a saved non-default (e.g. Hit sparks off) showed as the default (checked) even though the renderer correctly honored it. The sibling controls in the same panel were already hydrated; this restores that pattern for the new ones. Reads h3d_bg_* directly; defaults mirror BG_DEFAULTS (all bools on, hitFx 0.70) and the _bgCoerceBool 'true'/'1' vs 'false'/'0' coercion. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: byrongamatos --- plugins/highway_3d/plugin.json | 2 +- plugins/highway_3d/screen.js | 176 +++++++- plugins/highway_3d/settings.html | 103 +++++ .../addons/postprocessing/EffectComposer.js | 231 ++++++++++ .../three/addons/postprocessing/MaskPass.js | 104 +++++ .../three/addons/postprocessing/OutputPass.js | 97 ++++ .../three/addons/postprocessing/Pass.js | 95 ++++ .../three/addons/postprocessing/RenderPass.js | 99 +++++ .../three/addons/postprocessing/ShaderPass.js | 77 ++++ .../addons/postprocessing/UnrealBloomPass.js | 415 ++++++++++++++++++ .../vendor/three/addons/shaders/CopyShader.js | 45 ++ .../shaders/LuminosityHighPassShader.js | 64 +++ .../three/addons/shaders/OutputShader.js | 85 ++++ 13 files changed, 1587 insertions(+), 6 deletions(-) create mode 100644 static/vendor/three/addons/postprocessing/EffectComposer.js create mode 100644 static/vendor/three/addons/postprocessing/MaskPass.js create mode 100644 static/vendor/three/addons/postprocessing/OutputPass.js create mode 100644 static/vendor/three/addons/postprocessing/Pass.js create mode 100644 static/vendor/three/addons/postprocessing/RenderPass.js create mode 100644 static/vendor/three/addons/postprocessing/ShaderPass.js create mode 100644 static/vendor/three/addons/postprocessing/UnrealBloomPass.js create mode 100644 static/vendor/three/addons/shaders/CopyShader.js create mode 100644 static/vendor/three/addons/shaders/LuminosityHighPassShader.js create mode 100644 static/vendor/three/addons/shaders/OutputShader.js diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index 0ddd9f6..f2005bc 100644 --- a/plugins/highway_3d/plugin.json +++ b/plugins/highway_3d/plugin.json @@ -1,7 +1,7 @@ { "id": "highway_3d", "name": "3D Highway", - "version": "3.27.0", + "version": "3.30.0", "type": "visualization", "bundled": true, "script": "screen.js", diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 766abff..b0a8101 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1768,7 +1768,7 @@ return _bgBandsCache; } - const BG_DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', bgTheme: 'default', hwTheme: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true }; + const BG_DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', bgTheme: 'default', hwTheme: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true, hitFx: 0.7, sparks: true, cinematic: true, verdictMarks: true, timingFx: true, streakFx: true, bloom: true }; // User-selectable, persistable bg styles — must mirror settings.html's // VALID_STYLES. 'venue' is deliberately NOT here: it is an internal effective // style reached only via _venueSceneOverride (the viz-picker Venue flow), so @@ -2115,7 +2115,7 @@ // means (fall back to default rather than silently flipping to // false). Add new boolean keys to BG_DEFAULTS and they pick this // up via the dispatch below. - const _BG_BOOL_KEYS = new Set(['reactive', 'showFretOnNote', 'cameraLockLow', 'inlayLabelsVisible', 'sectionLabelsOnHighway', 'sectionHudVisible', 'nutHeadstockVisible', 'tuningLabelsVisible', 'projectionVisible', 'chordDiagramVisible', 'fpsVisible', 'toneHudVisible', 'fretDividersVisible', 'slideArrowApproachVisible', 'slideArrowNeckVisible', 'slideArrowChainPreviewVisible']); + const _BG_BOOL_KEYS = new Set(['reactive', 'showFretOnNote', 'cameraLockLow', 'inlayLabelsVisible', 'sectionLabelsOnHighway', 'sectionHudVisible', 'nutHeadstockVisible', 'tuningLabelsVisible', 'projectionVisible', 'chordDiagramVisible', 'fpsVisible', 'toneHudVisible', 'fretDividersVisible', 'slideArrowApproachVisible', 'slideArrowNeckVisible', 'slideArrowChainPreviewVisible', 'sparks', 'cinematic', 'verdictMarks', 'timingFx', 'streakFx', 'bloom']); function _bgCoerceBool(val, fallback) { if (val === 'true' || val === '1') return true; if (val === 'false' || val === '0') return false; @@ -2125,7 +2125,7 @@ // hysteresis; zoomSmoothing the zoom dead zone; tiltSmoothing the // vertical-tilt deadband + correction strength. All three slider- // shaped settings share the same parse + clamp behaviour. - const _BG_FLOAT_KEYS = new Set(['intensity', 'cameraSmoothing', 'zoomSmoothing', 'tiltSmoothing', 'cameraLockZoom', 'textSize', 'vibrancy', 'glow', 'chordDiagramSize', 'sectionHudSize', 'toneHudSize']); + const _BG_FLOAT_KEYS = new Set(['intensity', 'cameraSmoothing', 'zoomSmoothing', 'tiltSmoothing', 'cameraLockZoom', 'textSize', 'vibrancy', 'glow', 'chordDiagramSize', 'sectionHudSize', 'toneHudSize', 'hitFx']); function _bgCoerce(key, val) { if (_BG_FLOAT_KEYS.has(key)) { const n = parseFloat(val); @@ -2259,6 +2259,13 @@ window.h3dBgSetTextSize = (v) => _bgWriteGlobal('textSize', v); window.h3dBgSetVibrancy = (v) => _bgWriteGlobal('vibrancy', v); window.h3dBgSetGlow = (v) => _bgWriteGlobal('glow', v); + window.h3dBgSetHitFx = (v) => _bgWriteGlobal('hitFx', v); + window.h3dBgSetSparks = (v) => _bgWriteGlobal('sparks', !!v); + window.h3dBgSetCinematic = (v) => _bgWriteGlobal('cinematic', !!v); + window.h3dBgSetVerdictMarks = (v) => _bgWriteGlobal('verdictMarks', !!v); + window.h3dBgSetTimingFx = (v) => _bgWriteGlobal('timingFx', !!v); + window.h3dBgSetStreakFx = (v) => _bgWriteGlobal('streakFx', !!v); + window.h3dBgSetBloom = (v) => _bgWriteGlobal('bloom', !!v); window.h3dBgSetToneHudVisible = (v) => _bgWriteGlobal('toneHudVisible', !!v); window.h3dBgSetToneHudPosition = (v) => _bgWriteGlobal('toneHudPosition', v); window.h3dBgSetToneHudSize = (v) => _bgWriteGlobal('toneHudSize', v); @@ -3552,6 +3559,19 @@ // linear blend every frame. let vibrancy = BG_DEFAULTS.vibrancy; let glowMul = BG_DEFAULTS.glow; + let _hitFx = BG_DEFAULTS.hitFx; + let _sparks = BG_DEFAULTS.sparks; + let _cinematic = BG_DEFAULTS.cinematic; + let _verdictMarks = BG_DEFAULTS.verdictMarks; + let _timingFx = BG_DEFAULTS.timingFx; + let _streakFx = BG_DEFAULTS.streakFx; + let _bloom = BG_DEFAULTS.bloom; + let _composer = null, _bloomPass = null, _bloomLoad = null, _bloomW = 0, _bloomH = 0; + let _sparkPts = null, _sparkPos = null, _sparkCol = null, _sparkVel = null, _sparkLife = null; + const _SPARK_N = 256; + const _sparkSeen = new Map(); // note-key -> expiry; one burst per hit + let _juiceLastT = 0; // frame-dt clock for the juice layer + let _streakHits = 0, _streakHeat = 0; // #7 consecutive-hit escalation let fpsVisible = BG_DEFAULTS.fpsVisible; let fretDividersVisible = BG_DEFAULTS.fretDividersVisible; let chordDiagramVisible = BG_DEFAULTS.chordDiagramVisible; @@ -5912,10 +5932,23 @@ dirLight = new T.DirectionalLight(0xffffff, 0.8); dirLight.position.set(40 * K, 120 * K, 80 * K); scene.add(dirLight); + _applyCinematic(); fretG = new T.Group(); scene.add(fretG); tuningLblG = new T.Group(); scene.add(tuningLblG); noteG = new T.Group(); scene.add(noteG); + // Hit sparks (#3): a pooled additive Points cloud; a small burst fires at a + // gem on a verified hit (spawned in the verdict block, advanced in the render loop). + _sparkPos = new Float32Array(_SPARK_N * 3); _sparkCol = new Float32Array(_SPARK_N * 3); + _sparkVel = new Float32Array(_SPARK_N * 3); _sparkLife = new Float32Array(_SPARK_N); + { + const sg = new T.BufferGeometry(); + sg.setAttribute('position', new T.BufferAttribute(_sparkPos, 3).setUsage(T.DynamicDrawUsage)); + sg.setAttribute('color', new T.BufferAttribute(_sparkCol, 3).setUsage(T.DynamicDrawUsage)); + const sm = new T.PointsMaterial({ size: 1.0 * K, vertexColors: true, transparent: true, opacity: 0.8, depthWrite: false, blending: T.AdditiveBlending, sizeAttenuation: true }); + _sparkPts = new T.Points(sg, sm); _sparkPts.frustumCulled = false; _sparkPts.renderOrder = 8; + scene.add(_sparkPts); + } beatG = new T.Group(); scene.add(beatG); lblG = new T.Group(); scene.add(lblG); @@ -6134,6 +6167,13 @@ transparent: true, opacity: 1.0, depthWrite: false, })); mHitBrightArrays = mHitBright.map(m => [m, m, m, m, mEdgeTransparent, mEdgeTransparent]); + // Readability (#2 / charrette): the note gems + their outlines punch THROUGH + // the distance fog so upcoming notes stay legible as they render in at the + // horizon. The board, lane, sustains and background scenery keep their + // atmospheric fog — only the note-defining materials are exempted, so the + // highway still reads as deep while the notes never dissolve into the haze. + [mWhiteOutline, mMissOutline].forEach(m => { if (m) m.fog = false; }); + [mStr, mGlow, mStrHitOutline, mHitBright].forEach(arr => arr && arr.forEach(m => { if (m) m.fog = false; })); // Outline materials render at a lower renderOrder than the body. // The body is rendered on top with opacity:1 on hit/miss, which // fully covers the outline center — only the fringe that extends @@ -7191,7 +7231,7 @@ color: '#66c7ff', }); } - return { s: note.s, f: note.f, noteTime: d.noteTime, labels }; + return { s: note.s, f: note.f, noteTime: d.noteTime, labels, timingState: d.timingState || null }; }; const _ndPushMark = (arr, d) => { const mark = _ndNormalizeMark(d); @@ -7347,6 +7387,14 @@ textSize = _bgReadSetting(panelKey, 'textSize'); vibrancy = _bgReadSetting(panelKey, 'vibrancy'); glowMul = _bgReadSetting(panelKey, 'glow'); + _hitFx = _bgReadSetting(panelKey, 'hitFx'); + _sparks = _bgReadSetting(panelKey, 'sparks'); + _cinematic = _bgReadSetting(panelKey, 'cinematic'); + _verdictMarks = _bgReadSetting(panelKey, 'verdictMarks'); + _timingFx = _bgReadSetting(panelKey, 'timingFx'); + _streakFx = _bgReadSetting(panelKey, 'streakFx'); + _bloom = _bgReadSetting(panelKey, 'bloom'); + _applyCinematic(); fpsVisible = _bgReadSetting(panelKey, 'fpsVisible'); fretDividersVisible = _bgReadSetting(panelKey, 'fretDividersVisible'); chordDiagramVisible = _bgReadSetting(panelKey, 'chordDiagramVisible'); @@ -7785,6 +7833,85 @@ : d; return parseInt(s.slice(1), 16); } + // Cinematic lighting (#2): darken ambient so emissive gems have a dark + // surround to pop against; strengthen the key light for modelling. + // Toggle via the 'cinematic' setting so it's directly comparable. + function _applyCinematic() { + if (!ambLight || !dirLight) return; + ambLight.intensity = _cinematic ? 0.45 : 0.85; + dirLight.intensity = _cinematic ? 1.15 : 0.8; + } + // #5 early/late: tint the hit feedback by timing — on-time green, early cyan, + // late amber. Falls back to green when timing is unknown (pure-provider path). + function _timingHex(ts) { + if (!_timingFx || !ts || ts === 'OK') return 0x22ff88; + if (ts === 'EARLY') return 0x35d6ff; + if (ts === 'LATE') return 0xffb84d; + return 0x22ff88; + } + function _sparkBurst(x, y, z, hex, count) { + if (!_sparkPts || count <= 0) return; + const r = ((hex >> 16) & 255) / 255, g = ((hex >> 8) & 255) / 255, b = (hex & 255) / 255; + let made = 0; + for (let i = 0; i < _SPARK_N && made < count; i++) { + if (_sparkLife[i] > 0) continue; + const j = i * 3, ang = Math.random() * Math.PI * 2, sp = (5 + Math.random() * 12) * K; + _sparkPos[j] = x; _sparkPos[j + 1] = y; _sparkPos[j + 2] = z; + _sparkVel[j] = Math.cos(ang) * sp; _sparkVel[j + 1] = (12 + Math.random() * 24) * K; _sparkVel[j + 2] = Math.sin(ang) * sp * 0.55; + _sparkCol[j] = r; _sparkCol[j + 1] = g; _sparkCol[j + 2] = b; + _sparkLife[i] = 0.30 + Math.random() * 0.16; made++; + } + } + function _sparkUpdate(dt) { + if (!_sparkPts) return; + const grav = 55 * K; let any = false; + for (let i = 0; i < _SPARK_N; i++) { + if (_sparkLife[i] <= 0) continue; + const j = i * 3; + _sparkLife[i] -= dt; + if (_sparkLife[i] <= 0) { _sparkCol[j] = _sparkCol[j + 1] = _sparkCol[j + 2] = 0; continue; } + any = true; + _sparkVel[j + 1] -= grav * dt; + _sparkPos[j] += _sparkVel[j] * dt; _sparkPos[j + 1] += _sparkVel[j + 1] * dt; _sparkPos[j + 2] += _sparkVel[j + 2] * dt; + const fade = 1 - Math.min(1, dt * 3.2); + _sparkCol[j] *= fade; _sparkCol[j + 1] *= fade; _sparkCol[j + 2] *= fade; + } + _sparkPts.geometry.attributes.position.needsUpdate = true; + _sparkPts.geometry.attributes.color.needsUpdate = true; + _sparkPts.visible = any; + } + // #4 Bloom: lazy-load the vendored postprocessing addons and build an + // EffectComposer (RenderPass -> UnrealBloomPass -> OutputPass/ACES). Returns + // the composer once ready, or null (caller falls back to a direct render). + function _bloomEnsure() { + if (_composer) return _composer; + if (_bloomLoad || !ren || !scene || !cam) return null; + const A = '/static/vendor/three/addons/'; + _bloomLoad = Promise.all([ + import(A + 'postprocessing/EffectComposer.js'), + import(A + 'postprocessing/RenderPass.js'), + import(A + 'postprocessing/UnrealBloomPass.js'), + import(A + 'postprocessing/OutputPass.js'), + ]).then(([EC, RP, UB, OP]) => { + try { + const sz = canvasSize(highwayCanvas) || { w: 1280, h: 720 }; + const w = Math.max(2, sz.w | 0), h = Math.max(2, sz.h | 0); + // Multisampled (WebGL2 MSAA) HalfFloat target so anti-aliasing + // survives the bloom path — EffectComposer's default target has no + // `samples`, which is why bloom-on looked jagged (worst on non-Retina + // DPR1 displays that have no supersampling cushion). + const _bloomRT = new T.WebGLRenderTarget(w, h, { type: T.HalfFloatType, samples: 4 }); + const comp = new EC.EffectComposer(ren, _bloomRT); + comp.addPass(new RP.RenderPass(scene, cam)); + _bloomPass = new UB.UnrealBloomPass(new T.Vector2(w, h), 0.65, 0.5, 0.82); // strength, radius, threshold (high → only emissive blooms) + comp.addPass(_bloomPass); + comp.addPass(new OP.OutputPass()); + comp.setSize(w, h); + _bloomW = w; _bloomH = h; _composer = comp; + } catch (e) { console.warn('[3D-Hwy] bloom init failed', e); _composer = null; } + }).catch((e) => console.warn('[3D-Hwy] bloom modules failed', e)); + return null; + } function buildBoard() { // Dispose before clearing (traverse: nut/headstock may live in a Group). while (fretG.children.length) { @@ -12585,6 +12712,7 @@ // blocks, so _showHit can be a const and _ndGood is available for the // sustain trail (which renders even when skipBody=true for slide targets). let _ndGood = false; // true when provider confirms hit/active + let _hitPunch = 1; // #3 per-gem scale-punch on a fresh hit let _ndState = null; // 'hit'|'active'|'miss'|null; null → fall back to proximity heuristic let _ndCs = null; // raw provider response — truthy when provider returned a verdict let _ndCsIsObj = false; // typeof _ndCs === 'object' @@ -12760,12 +12888,27 @@ // hit/active → green outline (mHitBright[s]) + green lateral faces; // miss → magenta-red outline (mMissOutline) + dark lateral faces; front/back stay transparent. if (_ndCs) { + const _vAlpha = (_ndCsIsObj && typeof _ndCs.alpha === 'number') ? _ndCs.alpha : 1; if (_ndState === 'miss') { _ndOutline = mMissOutline; _ndFaceMat = mMissEdgeArrays; + _streakHits = 0; // #7 break the streak (heat eases down) + if (_verdictMarks) _ndLabels.push({ x, y: y + NH * 1.7, z: noteZ + 0.02, labels: [{ text: '✗', color: '#ff5a7a' }] }); // #6 } else if (_ndGood) { _ndOutline = mHitBright[s] ?? mGlow[s]; _ndFaceMat = mHitBrightArrays[s] ?? null; + _hitPunch = 1 + 0.22 * _hitFx * _vAlpha; // #3 scale-punch (biggest at strike, eases) + if (_verdictMarks) { const _tc = _timingHex(_ndMatchedMark && _ndMatchedMark.timingState); _ndLabels.push({ x, y: y + NH * 1.7, z: noteZ + 0.02, labels: [{ text: '✓', color: '#' + _tc.toString(16).padStart(6, '0') }] }); } // #6 + #5 + if (_sparks && _hitFx > 0 && _vAlpha > 0.5) { + const _spk = s + '|' + n.f + '|' + n.t.toFixed(2); + if (!(_sparkSeen.get(_spk) > now)) { + _sparkSeen.set(_spk, now + 1.0); + if (_sparkSeen.size > 600) _sparkSeen.clear(); + _streakHits++; + const _heatMul = _streakFx ? (1 + 0.85 * _streakHeat) : 1; // #7 escalate + _sparkBurst(x, y, noteZ, _timingHex(_ndMatchedMark && _ndMatchedMark.timingState), Math.round((4 + 7 * _hitFx) * _heatMul)); + } + } } } @@ -12878,6 +13021,7 @@ } else { core.scale.set(rimXY, rimXY, 2.5 * rimZ); } + if (_hitPunch !== 1) core.scale.multiplyScalar(_hitPunch); // #3 hit scale-punch // Fret digits on fretted (n.f > 0) flying notes deliberately // omitted: the showFretOnNote setting and its UI helper text // promise digits on the fretboard ghost only, never on the @@ -14012,6 +14156,8 @@ for (const g of _ownedSharedGeos) g?.dispose?.(); _ownedSharedGeos.length = 0; txtCache = {}; + if (_sparkPts) { try { _sparkPts.geometry.dispose(); _sparkPts.material.dispose(); } catch (e) {} _sparkPts = null; } + if (_composer) { try { _composer.dispose(); if (_bloomPass && _bloomPass.dispose) _bloomPass.dispose(); } catch (e) {} _composer = null; _bloomPass = null; } if (ren) { ren.dispose(); ren = null; } scene = cam = noteG = beatG = lblG = fretG = tuningLblG = null; ambLight = dirLight = null; @@ -14337,7 +14483,27 @@ } bcCtrl.render(); } - pbBeg(6); ren.render(scene, cam); pbEnd(6); + { + const _jNow = performance.now(); + const _jdt = _juiceLastT === 0 ? 1 / 60 : Math.min(0.05, (_jNow - _juiceLastT) / 1000); + _juiceLastT = _jNow; + _sparkUpdate(_jdt); + _streakHeat += (Math.min(1, _streakHits / 16) - _streakHeat) * 0.08; // #7 ease heat + } + { + const comp = (_bloom && !_ssActive()) ? _bloomEnsure() : null; + if (comp) { + const bsz = canvasSize(highwayCanvas); + if (bsz && bsz.w > 0 && bsz.h > 0 && (bsz.w !== _bloomW || bsz.h !== _bloomH)) { + comp.setSize(bsz.w | 0, bsz.h | 0); _bloomW = bsz.w | 0; _bloomH = bsz.h | 0; + } + if (ren.toneMapping !== T.ACESFilmicToneMapping) ren.toneMapping = T.ACESFilmicToneMapping; + pbBeg(6); comp.render(); pbEnd(6); + } else { + if (ren.toneMapping !== T.NoToneMapping) ren.toneMapping = T.NoToneMapping; + pbBeg(6); ren.render(scene, cam); pbEnd(6); + } + } if (lyricsCtx && lyricsCanvas) { lyricsCtx.clearRect(0, 0, lyricsCanvas.width, lyricsCanvas.height); // Capture the actual lyrics-banner bottom so overlay cards diff --git a/plugins/highway_3d/settings.html b/plugins/highway_3d/settings.html index e24c3c2..cf6a57d 100644 --- a/plugins/highway_3d/settings.html +++ b/plugins/highway_3d/settings.html @@ -577,6 +577,80 @@ Glowy + +
+ + +

+ How much "juice" a nailed note gets — the strike-line flash and the + spark burst at the hit line. 0 = colour verdict only (no sparks). +

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
@@ -1299,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 diff --git a/static/vendor/three/addons/postprocessing/EffectComposer.js b/static/vendor/three/addons/postprocessing/EffectComposer.js new file mode 100644 index 0000000..8953c56 --- /dev/null +++ b/static/vendor/three/addons/postprocessing/EffectComposer.js @@ -0,0 +1,231 @@ +import { + Clock, + HalfFloatType, + NoBlending, + Vector2, + WebGLRenderTarget +} from '../../three.module.min.js'; +import { CopyShader } from '../shaders/CopyShader.js'; +import { ShaderPass } from './ShaderPass.js'; +import { MaskPass } from './MaskPass.js'; +import { ClearMaskPass } from './MaskPass.js'; + +class EffectComposer { + + constructor( renderer, renderTarget ) { + + this.renderer = renderer; + + this._pixelRatio = renderer.getPixelRatio(); + + if ( renderTarget === undefined ) { + + const size = renderer.getSize( new Vector2() ); + this._width = size.width; + this._height = size.height; + + renderTarget = new WebGLRenderTarget( this._width * this._pixelRatio, this._height * this._pixelRatio, { type: HalfFloatType } ); + renderTarget.texture.name = 'EffectComposer.rt1'; + + } else { + + this._width = renderTarget.width; + this._height = renderTarget.height; + + } + + this.renderTarget1 = renderTarget; + this.renderTarget2 = renderTarget.clone(); + this.renderTarget2.texture.name = 'EffectComposer.rt2'; + + this.writeBuffer = this.renderTarget1; + this.readBuffer = this.renderTarget2; + + this.renderToScreen = true; + + this.passes = []; + + this.copyPass = new ShaderPass( CopyShader ); + this.copyPass.material.blending = NoBlending; + + this.clock = new Clock(); + + } + + swapBuffers() { + + const tmp = this.readBuffer; + this.readBuffer = this.writeBuffer; + this.writeBuffer = tmp; + + } + + addPass( pass ) { + + this.passes.push( pass ); + pass.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio ); + + } + + insertPass( pass, index ) { + + this.passes.splice( index, 0, pass ); + pass.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio ); + + } + + removePass( pass ) { + + const index = this.passes.indexOf( pass ); + + if ( index !== - 1 ) { + + this.passes.splice( index, 1 ); + + } + + } + + isLastEnabledPass( passIndex ) { + + for ( let i = passIndex + 1; i < this.passes.length; i ++ ) { + + if ( this.passes[ i ].enabled ) { + + return false; + + } + + } + + return true; + + } + + render( deltaTime ) { + + // deltaTime value is in seconds + + if ( deltaTime === undefined ) { + + deltaTime = this.clock.getDelta(); + + } + + const currentRenderTarget = this.renderer.getRenderTarget(); + + let maskActive = false; + + for ( let i = 0, il = this.passes.length; i < il; i ++ ) { + + const pass = this.passes[ i ]; + + if ( pass.enabled === false ) continue; + + pass.renderToScreen = ( this.renderToScreen && this.isLastEnabledPass( i ) ); + pass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime, maskActive ); + + if ( pass.needsSwap ) { + + if ( maskActive ) { + + const context = this.renderer.getContext(); + const stencil = this.renderer.state.buffers.stencil; + + //context.stencilFunc( context.NOTEQUAL, 1, 0xffffffff ); + stencil.setFunc( context.NOTEQUAL, 1, 0xffffffff ); + + this.copyPass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime ); + + //context.stencilFunc( context.EQUAL, 1, 0xffffffff ); + stencil.setFunc( context.EQUAL, 1, 0xffffffff ); + + } + + this.swapBuffers(); + + } + + if ( MaskPass !== undefined ) { + + if ( pass instanceof MaskPass ) { + + maskActive = true; + + } else if ( pass instanceof ClearMaskPass ) { + + maskActive = false; + + } + + } + + } + + this.renderer.setRenderTarget( currentRenderTarget ); + + } + + reset( renderTarget ) { + + if ( renderTarget === undefined ) { + + const size = this.renderer.getSize( new Vector2() ); + this._pixelRatio = this.renderer.getPixelRatio(); + this._width = size.width; + this._height = size.height; + + renderTarget = this.renderTarget1.clone(); + renderTarget.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio ); + + } + + this.renderTarget1.dispose(); + this.renderTarget2.dispose(); + this.renderTarget1 = renderTarget; + this.renderTarget2 = renderTarget.clone(); + + this.writeBuffer = this.renderTarget1; + this.readBuffer = this.renderTarget2; + + } + + setSize( width, height ) { + + this._width = width; + this._height = height; + + const effectiveWidth = this._width * this._pixelRatio; + const effectiveHeight = this._height * this._pixelRatio; + + this.renderTarget1.setSize( effectiveWidth, effectiveHeight ); + this.renderTarget2.setSize( effectiveWidth, effectiveHeight ); + + for ( let i = 0; i < this.passes.length; i ++ ) { + + this.passes[ i ].setSize( effectiveWidth, effectiveHeight ); + + } + + } + + setPixelRatio( pixelRatio ) { + + this._pixelRatio = pixelRatio; + + this.setSize( this._width, this._height ); + + } + + dispose() { + + this.renderTarget1.dispose(); + this.renderTarget2.dispose(); + + this.copyPass.dispose(); + + } + +} + +export { EffectComposer }; diff --git a/static/vendor/three/addons/postprocessing/MaskPass.js b/static/vendor/three/addons/postprocessing/MaskPass.js new file mode 100644 index 0000000..b30811c --- /dev/null +++ b/static/vendor/three/addons/postprocessing/MaskPass.js @@ -0,0 +1,104 @@ +import { Pass } from './Pass.js'; + +class MaskPass extends Pass { + + constructor( scene, camera ) { + + super(); + + this.scene = scene; + this.camera = camera; + + this.clear = true; + this.needsSwap = false; + + this.inverse = false; + + } + + render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) { + + const context = renderer.getContext(); + const state = renderer.state; + + // don't update color or depth + + state.buffers.color.setMask( false ); + state.buffers.depth.setMask( false ); + + // lock buffers + + state.buffers.color.setLocked( true ); + state.buffers.depth.setLocked( true ); + + // set up stencil + + let writeValue, clearValue; + + if ( this.inverse ) { + + writeValue = 0; + clearValue = 1; + + } else { + + writeValue = 1; + clearValue = 0; + + } + + state.buffers.stencil.setTest( true ); + state.buffers.stencil.setOp( context.REPLACE, context.REPLACE, context.REPLACE ); + state.buffers.stencil.setFunc( context.ALWAYS, writeValue, 0xffffffff ); + state.buffers.stencil.setClear( clearValue ); + state.buffers.stencil.setLocked( true ); + + // draw into the stencil buffer + + renderer.setRenderTarget( readBuffer ); + if ( this.clear ) renderer.clear(); + renderer.render( this.scene, this.camera ); + + renderer.setRenderTarget( writeBuffer ); + if ( this.clear ) renderer.clear(); + renderer.render( this.scene, this.camera ); + + // unlock color and depth buffer and make them writable for subsequent rendering/clearing + + state.buffers.color.setLocked( false ); + state.buffers.depth.setLocked( false ); + + state.buffers.color.setMask( true ); + state.buffers.depth.setMask( true ); + + // only render where stencil is set to 1 + + state.buffers.stencil.setLocked( false ); + state.buffers.stencil.setFunc( context.EQUAL, 1, 0xffffffff ); // draw if == 1 + state.buffers.stencil.setOp( context.KEEP, context.KEEP, context.KEEP ); + state.buffers.stencil.setLocked( true ); + + } + +} + +class ClearMaskPass extends Pass { + + constructor() { + + super(); + + this.needsSwap = false; + + } + + render( renderer /*, writeBuffer, readBuffer, deltaTime, maskActive */ ) { + + renderer.state.buffers.stencil.setLocked( false ); + renderer.state.buffers.stencil.setTest( false ); + + } + +} + +export { MaskPass, ClearMaskPass }; diff --git a/static/vendor/three/addons/postprocessing/OutputPass.js b/static/vendor/three/addons/postprocessing/OutputPass.js new file mode 100644 index 0000000..313d745 --- /dev/null +++ b/static/vendor/three/addons/postprocessing/OutputPass.js @@ -0,0 +1,97 @@ +import { + ColorManagement, + RawShaderMaterial, + UniformsUtils, + LinearToneMapping, + ReinhardToneMapping, + CineonToneMapping, + AgXToneMapping, + ACESFilmicToneMapping, + NeutralToneMapping, + SRGBTransfer +} from '../../three.module.min.js'; +import { Pass, FullScreenQuad } from './Pass.js'; +import { OutputShader } from '../shaders/OutputShader.js'; + +class OutputPass extends Pass { + + constructor() { + + super(); + + // + + const shader = OutputShader; + + this.uniforms = UniformsUtils.clone( shader.uniforms ); + + this.material = new RawShaderMaterial( { + name: shader.name, + uniforms: this.uniforms, + vertexShader: shader.vertexShader, + fragmentShader: shader.fragmentShader + } ); + + this.fsQuad = new FullScreenQuad( this.material ); + + // internal cache + + this._outputColorSpace = null; + this._toneMapping = null; + + } + + render( renderer, writeBuffer, readBuffer/*, deltaTime, maskActive */ ) { + + this.uniforms[ 'tDiffuse' ].value = readBuffer.texture; + this.uniforms[ 'toneMappingExposure' ].value = renderer.toneMappingExposure; + + // rebuild defines if required + + if ( this._outputColorSpace !== renderer.outputColorSpace || this._toneMapping !== renderer.toneMapping ) { + + this._outputColorSpace = renderer.outputColorSpace; + this._toneMapping = renderer.toneMapping; + + this.material.defines = {}; + + if ( ColorManagement.getTransfer( this._outputColorSpace ) === SRGBTransfer ) this.material.defines.SRGB_TRANSFER = ''; + + if ( this._toneMapping === LinearToneMapping ) this.material.defines.LINEAR_TONE_MAPPING = ''; + else if ( this._toneMapping === ReinhardToneMapping ) this.material.defines.REINHARD_TONE_MAPPING = ''; + else if ( this._toneMapping === CineonToneMapping ) this.material.defines.CINEON_TONE_MAPPING = ''; + else if ( this._toneMapping === ACESFilmicToneMapping ) this.material.defines.ACES_FILMIC_TONE_MAPPING = ''; + else if ( this._toneMapping === AgXToneMapping ) this.material.defines.AGX_TONE_MAPPING = ''; + else if ( this._toneMapping === NeutralToneMapping ) this.material.defines.NEUTRAL_TONE_MAPPING = ''; + + this.material.needsUpdate = true; + + } + + // + + if ( this.renderToScreen === true ) { + + renderer.setRenderTarget( null ); + this.fsQuad.render( renderer ); + + } else { + + renderer.setRenderTarget( writeBuffer ); + if ( this.clear ) renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil ); + this.fsQuad.render( renderer ); + + } + + } + + dispose() { + + this.material.dispose(); + this.fsQuad.dispose(); + + } + +} + +export { OutputPass }; diff --git a/static/vendor/three/addons/postprocessing/Pass.js b/static/vendor/three/addons/postprocessing/Pass.js new file mode 100644 index 0000000..fc1db1f --- /dev/null +++ b/static/vendor/three/addons/postprocessing/Pass.js @@ -0,0 +1,95 @@ +import { + BufferGeometry, + Float32BufferAttribute, + OrthographicCamera, + Mesh +} from '../../three.module.min.js'; + +class Pass { + + constructor() { + + this.isPass = true; + + // if set to true, the pass is processed by the composer + this.enabled = true; + + // if set to true, the pass indicates to swap read and write buffer after rendering + this.needsSwap = true; + + // if set to true, the pass clears its buffer before rendering + this.clear = false; + + // if set to true, the result of the pass is rendered to screen. This is set automatically by EffectComposer. + this.renderToScreen = false; + + } + + setSize( /* width, height */ ) {} + + render( /* renderer, writeBuffer, readBuffer, deltaTime, maskActive */ ) { + + console.error( 'THREE.Pass: .render() must be implemented in derived pass.' ); + + } + + dispose() {} + +} + +// Helper for passes that need to fill the viewport with a single quad. + +const _camera = new OrthographicCamera( - 1, 1, 1, - 1, 0, 1 ); + +// https://github.com/mrdoob/three.js/pull/21358 + +class FullscreenTriangleGeometry extends BufferGeometry { + + constructor() { + + super(); + + this.setAttribute( 'position', new Float32BufferAttribute( [ - 1, 3, 0, - 1, - 1, 0, 3, - 1, 0 ], 3 ) ); + this.setAttribute( 'uv', new Float32BufferAttribute( [ 0, 2, 0, 0, 2, 0 ], 2 ) ); + + } + +} + +const _geometry = new FullscreenTriangleGeometry(); + +class FullScreenQuad { + + constructor( material ) { + + this._mesh = new Mesh( _geometry, material ); + + } + + dispose() { + + this._mesh.geometry.dispose(); + + } + + render( renderer ) { + + renderer.render( this._mesh, _camera ); + + } + + get material() { + + return this._mesh.material; + + } + + set material( value ) { + + this._mesh.material = value; + + } + +} + +export { Pass, FullScreenQuad }; diff --git a/static/vendor/three/addons/postprocessing/RenderPass.js b/static/vendor/three/addons/postprocessing/RenderPass.js new file mode 100644 index 0000000..29ec553 --- /dev/null +++ b/static/vendor/three/addons/postprocessing/RenderPass.js @@ -0,0 +1,99 @@ +import { + Color +} from '../../three.module.min.js'; +import { Pass } from './Pass.js'; + +class RenderPass extends Pass { + + constructor( scene, camera, overrideMaterial = null, clearColor = null, clearAlpha = null ) { + + super(); + + this.scene = scene; + this.camera = camera; + + this.overrideMaterial = overrideMaterial; + + this.clearColor = clearColor; + this.clearAlpha = clearAlpha; + + this.clear = true; + this.clearDepth = false; + this.needsSwap = false; + this._oldClearColor = new Color(); + + } + + render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) { + + const oldAutoClear = renderer.autoClear; + renderer.autoClear = false; + + let oldClearAlpha, oldOverrideMaterial; + + if ( this.overrideMaterial !== null ) { + + oldOverrideMaterial = this.scene.overrideMaterial; + + this.scene.overrideMaterial = this.overrideMaterial; + + } + + if ( this.clearColor !== null ) { + + renderer.getClearColor( this._oldClearColor ); + renderer.setClearColor( this.clearColor, renderer.getClearAlpha() ); + + } + + if ( this.clearAlpha !== null ) { + + oldClearAlpha = renderer.getClearAlpha(); + renderer.setClearAlpha( this.clearAlpha ); + + } + + if ( this.clearDepth == true ) { + + renderer.clearDepth(); + + } + + renderer.setRenderTarget( this.renderToScreen ? null : readBuffer ); + + if ( this.clear === true ) { + + // TODO: Avoid using autoClear properties, see https://github.com/mrdoob/three.js/pull/15571#issuecomment-465669600 + renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil ); + + } + + renderer.render( this.scene, this.camera ); + + // restore + + if ( this.clearColor !== null ) { + + renderer.setClearColor( this._oldClearColor ); + + } + + if ( this.clearAlpha !== null ) { + + renderer.setClearAlpha( oldClearAlpha ); + + } + + if ( this.overrideMaterial !== null ) { + + this.scene.overrideMaterial = oldOverrideMaterial; + + } + + renderer.autoClear = oldAutoClear; + + } + +} + +export { RenderPass }; diff --git a/static/vendor/three/addons/postprocessing/ShaderPass.js b/static/vendor/three/addons/postprocessing/ShaderPass.js new file mode 100644 index 0000000..8c49b7e --- /dev/null +++ b/static/vendor/three/addons/postprocessing/ShaderPass.js @@ -0,0 +1,77 @@ +import { + ShaderMaterial, + UniformsUtils +} from '../../three.module.min.js'; +import { Pass, FullScreenQuad } from './Pass.js'; + +class ShaderPass extends Pass { + + constructor( shader, textureID ) { + + super(); + + this.textureID = ( textureID !== undefined ) ? textureID : 'tDiffuse'; + + if ( shader instanceof ShaderMaterial ) { + + this.uniforms = shader.uniforms; + + this.material = shader; + + } else if ( shader ) { + + this.uniforms = UniformsUtils.clone( shader.uniforms ); + + this.material = new ShaderMaterial( { + + name: ( shader.name !== undefined ) ? shader.name : 'unspecified', + defines: Object.assign( {}, shader.defines ), + uniforms: this.uniforms, + vertexShader: shader.vertexShader, + fragmentShader: shader.fragmentShader + + } ); + + } + + this.fsQuad = new FullScreenQuad( this.material ); + + } + + render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) { + + if ( this.uniforms[ this.textureID ] ) { + + this.uniforms[ this.textureID ].value = readBuffer.texture; + + } + + this.fsQuad.material = this.material; + + if ( this.renderToScreen ) { + + renderer.setRenderTarget( null ); + this.fsQuad.render( renderer ); + + } else { + + renderer.setRenderTarget( writeBuffer ); + // TODO: Avoid using autoClear properties, see https://github.com/mrdoob/three.js/pull/15571#issuecomment-465669600 + if ( this.clear ) renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil ); + this.fsQuad.render( renderer ); + + } + + } + + dispose() { + + this.material.dispose(); + + this.fsQuad.dispose(); + + } + +} + +export { ShaderPass }; diff --git a/static/vendor/three/addons/postprocessing/UnrealBloomPass.js b/static/vendor/three/addons/postprocessing/UnrealBloomPass.js new file mode 100644 index 0000000..111c661 --- /dev/null +++ b/static/vendor/three/addons/postprocessing/UnrealBloomPass.js @@ -0,0 +1,415 @@ +import { + AdditiveBlending, + Color, + HalfFloatType, + MeshBasicMaterial, + ShaderMaterial, + UniformsUtils, + Vector2, + Vector3, + WebGLRenderTarget +} from '../../three.module.min.js'; +import { Pass, FullScreenQuad } from './Pass.js'; +import { CopyShader } from '../shaders/CopyShader.js'; +import { LuminosityHighPassShader } from '../shaders/LuminosityHighPassShader.js'; + +/** + * UnrealBloomPass is inspired by the bloom pass of Unreal Engine. It creates a + * mip map chain of bloom textures and blurs them with different radii. Because + * of the weighted combination of mips, and because larger blurs are done on + * higher mips, this effect provides good quality and performance. + * + * Reference: + * - https://docs.unrealengine.com/latest/INT/Engine/Rendering/PostProcessEffects/Bloom/ + */ +class UnrealBloomPass extends Pass { + + constructor( resolution, strength, radius, threshold ) { + + super(); + + this.strength = ( strength !== undefined ) ? strength : 1; + this.radius = radius; + this.threshold = threshold; + this.resolution = ( resolution !== undefined ) ? new Vector2( resolution.x, resolution.y ) : new Vector2( 256, 256 ); + + // create color only once here, reuse it later inside the render function + this.clearColor = new Color( 0, 0, 0 ); + + // render targets + this.renderTargetsHorizontal = []; + this.renderTargetsVertical = []; + this.nMips = 5; + let resx = Math.round( this.resolution.x / 2 ); + let resy = Math.round( this.resolution.y / 2 ); + + this.renderTargetBright = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } ); + this.renderTargetBright.texture.name = 'UnrealBloomPass.bright'; + this.renderTargetBright.texture.generateMipmaps = false; + + for ( let i = 0; i < this.nMips; i ++ ) { + + const renderTargetHorizontal = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } ); + + renderTargetHorizontal.texture.name = 'UnrealBloomPass.h' + i; + renderTargetHorizontal.texture.generateMipmaps = false; + + this.renderTargetsHorizontal.push( renderTargetHorizontal ); + + const renderTargetVertical = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } ); + + renderTargetVertical.texture.name = 'UnrealBloomPass.v' + i; + renderTargetVertical.texture.generateMipmaps = false; + + this.renderTargetsVertical.push( renderTargetVertical ); + + resx = Math.round( resx / 2 ); + + resy = Math.round( resy / 2 ); + + } + + // luminosity high pass material + + const highPassShader = LuminosityHighPassShader; + this.highPassUniforms = UniformsUtils.clone( highPassShader.uniforms ); + + this.highPassUniforms[ 'luminosityThreshold' ].value = threshold; + this.highPassUniforms[ 'smoothWidth' ].value = 0.01; + + this.materialHighPassFilter = new ShaderMaterial( { + uniforms: this.highPassUniforms, + vertexShader: highPassShader.vertexShader, + fragmentShader: highPassShader.fragmentShader + } ); + + // gaussian blur materials + + this.separableBlurMaterials = []; + const kernelSizeArray = [ 3, 5, 7, 9, 11 ]; + resx = Math.round( this.resolution.x / 2 ); + resy = Math.round( this.resolution.y / 2 ); + + for ( let i = 0; i < this.nMips; i ++ ) { + + this.separableBlurMaterials.push( this.getSeperableBlurMaterial( kernelSizeArray[ i ] ) ); + + this.separableBlurMaterials[ i ].uniforms[ 'invSize' ].value = new Vector2( 1 / resx, 1 / resy ); + + resx = Math.round( resx / 2 ); + + resy = Math.round( resy / 2 ); + + } + + // composite material + + this.compositeMaterial = this.getCompositeMaterial( this.nMips ); + this.compositeMaterial.uniforms[ 'blurTexture1' ].value = this.renderTargetsVertical[ 0 ].texture; + this.compositeMaterial.uniforms[ 'blurTexture2' ].value = this.renderTargetsVertical[ 1 ].texture; + this.compositeMaterial.uniforms[ 'blurTexture3' ].value = this.renderTargetsVertical[ 2 ].texture; + this.compositeMaterial.uniforms[ 'blurTexture4' ].value = this.renderTargetsVertical[ 3 ].texture; + this.compositeMaterial.uniforms[ 'blurTexture5' ].value = this.renderTargetsVertical[ 4 ].texture; + this.compositeMaterial.uniforms[ 'bloomStrength' ].value = strength; + this.compositeMaterial.uniforms[ 'bloomRadius' ].value = 0.1; + + const bloomFactors = [ 1.0, 0.8, 0.6, 0.4, 0.2 ]; + this.compositeMaterial.uniforms[ 'bloomFactors' ].value = bloomFactors; + this.bloomTintColors = [ new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ) ]; + this.compositeMaterial.uniforms[ 'bloomTintColors' ].value = this.bloomTintColors; + + // blend material + + const copyShader = CopyShader; + + this.copyUniforms = UniformsUtils.clone( copyShader.uniforms ); + + this.blendMaterial = new ShaderMaterial( { + uniforms: this.copyUniforms, + vertexShader: copyShader.vertexShader, + fragmentShader: copyShader.fragmentShader, + blending: AdditiveBlending, + depthTest: false, + depthWrite: false, + transparent: true + } ); + + this.enabled = true; + this.needsSwap = false; + + this._oldClearColor = new Color(); + this.oldClearAlpha = 1; + + this.basic = new MeshBasicMaterial(); + + this.fsQuad = new FullScreenQuad( null ); + + } + + dispose() { + + for ( let i = 0; i < this.renderTargetsHorizontal.length; i ++ ) { + + this.renderTargetsHorizontal[ i ].dispose(); + + } + + for ( let i = 0; i < this.renderTargetsVertical.length; i ++ ) { + + this.renderTargetsVertical[ i ].dispose(); + + } + + this.renderTargetBright.dispose(); + + // + + for ( let i = 0; i < this.separableBlurMaterials.length; i ++ ) { + + this.separableBlurMaterials[ i ].dispose(); + + } + + this.compositeMaterial.dispose(); + this.blendMaterial.dispose(); + this.basic.dispose(); + + // + + this.fsQuad.dispose(); + + } + + setSize( width, height ) { + + let resx = Math.round( width / 2 ); + let resy = Math.round( height / 2 ); + + this.renderTargetBright.setSize( resx, resy ); + + for ( let i = 0; i < this.nMips; i ++ ) { + + this.renderTargetsHorizontal[ i ].setSize( resx, resy ); + this.renderTargetsVertical[ i ].setSize( resx, resy ); + + this.separableBlurMaterials[ i ].uniforms[ 'invSize' ].value = new Vector2( 1 / resx, 1 / resy ); + + resx = Math.round( resx / 2 ); + resy = Math.round( resy / 2 ); + + } + + } + + render( renderer, writeBuffer, readBuffer, deltaTime, maskActive ) { + + renderer.getClearColor( this._oldClearColor ); + this.oldClearAlpha = renderer.getClearAlpha(); + const oldAutoClear = renderer.autoClear; + renderer.autoClear = false; + + renderer.setClearColor( this.clearColor, 0 ); + + if ( maskActive ) renderer.state.buffers.stencil.setTest( false ); + + // Render input to screen + + if ( this.renderToScreen ) { + + this.fsQuad.material = this.basic; + this.basic.map = readBuffer.texture; + + renderer.setRenderTarget( null ); + renderer.clear(); + this.fsQuad.render( renderer ); + + } + + // 1. Extract Bright Areas + + this.highPassUniforms[ 'tDiffuse' ].value = readBuffer.texture; + this.highPassUniforms[ 'luminosityThreshold' ].value = this.threshold; + this.fsQuad.material = this.materialHighPassFilter; + + renderer.setRenderTarget( this.renderTargetBright ); + renderer.clear(); + this.fsQuad.render( renderer ); + + // 2. Blur All the mips progressively + + let inputRenderTarget = this.renderTargetBright; + + for ( let i = 0; i < this.nMips; i ++ ) { + + this.fsQuad.material = this.separableBlurMaterials[ i ]; + + this.separableBlurMaterials[ i ].uniforms[ 'colorTexture' ].value = inputRenderTarget.texture; + this.separableBlurMaterials[ i ].uniforms[ 'direction' ].value = UnrealBloomPass.BlurDirectionX; + renderer.setRenderTarget( this.renderTargetsHorizontal[ i ] ); + renderer.clear(); + this.fsQuad.render( renderer ); + + this.separableBlurMaterials[ i ].uniforms[ 'colorTexture' ].value = this.renderTargetsHorizontal[ i ].texture; + this.separableBlurMaterials[ i ].uniforms[ 'direction' ].value = UnrealBloomPass.BlurDirectionY; + renderer.setRenderTarget( this.renderTargetsVertical[ i ] ); + renderer.clear(); + this.fsQuad.render( renderer ); + + inputRenderTarget = this.renderTargetsVertical[ i ]; + + } + + // Composite All the mips + + this.fsQuad.material = this.compositeMaterial; + this.compositeMaterial.uniforms[ 'bloomStrength' ].value = this.strength; + this.compositeMaterial.uniforms[ 'bloomRadius' ].value = this.radius; + this.compositeMaterial.uniforms[ 'bloomTintColors' ].value = this.bloomTintColors; + + renderer.setRenderTarget( this.renderTargetsHorizontal[ 0 ] ); + renderer.clear(); + this.fsQuad.render( renderer ); + + // Blend it additively over the input texture + + this.fsQuad.material = this.blendMaterial; + this.copyUniforms[ 'tDiffuse' ].value = this.renderTargetsHorizontal[ 0 ].texture; + + if ( maskActive ) renderer.state.buffers.stencil.setTest( true ); + + if ( this.renderToScreen ) { + + renderer.setRenderTarget( null ); + this.fsQuad.render( renderer ); + + } else { + + renderer.setRenderTarget( readBuffer ); + this.fsQuad.render( renderer ); + + } + + // Restore renderer settings + + renderer.setClearColor( this._oldClearColor, this.oldClearAlpha ); + renderer.autoClear = oldAutoClear; + + } + + getSeperableBlurMaterial( kernelRadius ) { + + const coefficients = []; + + for ( let i = 0; i < kernelRadius; i ++ ) { + + coefficients.push( 0.39894 * Math.exp( - 0.5 * i * i / ( kernelRadius * kernelRadius ) ) / kernelRadius ); + + } + + return new ShaderMaterial( { + + defines: { + 'KERNEL_RADIUS': kernelRadius + }, + + uniforms: { + 'colorTexture': { value: null }, + 'invSize': { value: new Vector2( 0.5, 0.5 ) }, // inverse texture size + 'direction': { value: new Vector2( 0.5, 0.5 ) }, + 'gaussianCoefficients': { value: coefficients } // precomputed Gaussian coefficients + }, + + vertexShader: + `varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); + }`, + + fragmentShader: + `#include + varying vec2 vUv; + uniform sampler2D colorTexture; + uniform vec2 invSize; + uniform vec2 direction; + uniform float gaussianCoefficients[KERNEL_RADIUS]; + + void main() { + float weightSum = gaussianCoefficients[0]; + vec3 diffuseSum = texture2D( colorTexture, vUv ).rgb * weightSum; + for( int i = 1; i < KERNEL_RADIUS; i ++ ) { + float x = float(i); + float w = gaussianCoefficients[i]; + vec2 uvOffset = direction * invSize * x; + vec3 sample1 = texture2D( colorTexture, vUv + uvOffset ).rgb; + vec3 sample2 = texture2D( colorTexture, vUv - uvOffset ).rgb; + diffuseSum += (sample1 + sample2) * w; + weightSum += 2.0 * w; + } + gl_FragColor = vec4(diffuseSum/weightSum, 1.0); + }` + } ); + + } + + getCompositeMaterial( nMips ) { + + return new ShaderMaterial( { + + defines: { + 'NUM_MIPS': nMips + }, + + uniforms: { + 'blurTexture1': { value: null }, + 'blurTexture2': { value: null }, + 'blurTexture3': { value: null }, + 'blurTexture4': { value: null }, + 'blurTexture5': { value: null }, + 'bloomStrength': { value: 1.0 }, + 'bloomFactors': { value: null }, + 'bloomTintColors': { value: null }, + 'bloomRadius': { value: 0.0 } + }, + + vertexShader: + `varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); + }`, + + fragmentShader: + `varying vec2 vUv; + uniform sampler2D blurTexture1; + uniform sampler2D blurTexture2; + uniform sampler2D blurTexture3; + uniform sampler2D blurTexture4; + uniform sampler2D blurTexture5; + uniform float bloomStrength; + uniform float bloomRadius; + uniform float bloomFactors[NUM_MIPS]; + uniform vec3 bloomTintColors[NUM_MIPS]; + + float lerpBloomFactor(const in float factor) { + float mirrorFactor = 1.2 - factor; + return mix(factor, mirrorFactor, bloomRadius); + } + + void main() { + gl_FragColor = bloomStrength * ( lerpBloomFactor(bloomFactors[0]) * vec4(bloomTintColors[0], 1.0) * texture2D(blurTexture1, vUv) + + lerpBloomFactor(bloomFactors[1]) * vec4(bloomTintColors[1], 1.0) * texture2D(blurTexture2, vUv) + + lerpBloomFactor(bloomFactors[2]) * vec4(bloomTintColors[2], 1.0) * texture2D(blurTexture3, vUv) + + lerpBloomFactor(bloomFactors[3]) * vec4(bloomTintColors[3], 1.0) * texture2D(blurTexture4, vUv) + + lerpBloomFactor(bloomFactors[4]) * vec4(bloomTintColors[4], 1.0) * texture2D(blurTexture5, vUv) ); + }` + } ); + + } + +} + +UnrealBloomPass.BlurDirectionX = new Vector2( 1.0, 0.0 ); +UnrealBloomPass.BlurDirectionY = new Vector2( 0.0, 1.0 ); + +export { UnrealBloomPass }; diff --git a/static/vendor/three/addons/shaders/CopyShader.js b/static/vendor/three/addons/shaders/CopyShader.js new file mode 100644 index 0000000..8c3f2cd --- /dev/null +++ b/static/vendor/three/addons/shaders/CopyShader.js @@ -0,0 +1,45 @@ +/** + * Full-screen textured quad shader + */ + +const CopyShader = { + + name: 'CopyShader', + + uniforms: { + + 'tDiffuse': { value: null }, + 'opacity': { value: 1.0 } + + }, + + vertexShader: /* glsl */` + + varying vec2 vUv; + + void main() { + + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); + + }`, + + fragmentShader: /* glsl */` + + uniform float opacity; + + uniform sampler2D tDiffuse; + + varying vec2 vUv; + + void main() { + + vec4 texel = texture2D( tDiffuse, vUv ); + gl_FragColor = opacity * texel; + + + }` + +}; + +export { CopyShader }; diff --git a/static/vendor/three/addons/shaders/LuminosityHighPassShader.js b/static/vendor/three/addons/shaders/LuminosityHighPassShader.js new file mode 100644 index 0000000..3e542e0 --- /dev/null +++ b/static/vendor/three/addons/shaders/LuminosityHighPassShader.js @@ -0,0 +1,64 @@ +import { + Color +} from '../../three.module.min.js'; + +/** + * Luminosity + * http://en.wikipedia.org/wiki/Luminosity + */ + +const LuminosityHighPassShader = { + + name: 'LuminosityHighPassShader', + + shaderID: 'luminosityHighPass', + + uniforms: { + + 'tDiffuse': { value: null }, + 'luminosityThreshold': { value: 1.0 }, + 'smoothWidth': { value: 1.0 }, + 'defaultColor': { value: new Color( 0x000000 ) }, + 'defaultOpacity': { value: 0.0 } + + }, + + vertexShader: /* glsl */` + + varying vec2 vUv; + + void main() { + + vUv = uv; + + gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); + + }`, + + fragmentShader: /* glsl */` + + uniform sampler2D tDiffuse; + uniform vec3 defaultColor; + uniform float defaultOpacity; + uniform float luminosityThreshold; + uniform float smoothWidth; + + varying vec2 vUv; + + void main() { + + vec4 texel = texture2D( tDiffuse, vUv ); + + float v = luminance( texel.xyz ); + + vec4 outputColor = vec4( defaultColor.rgb, defaultOpacity ); + + float alpha = smoothstep( luminosityThreshold, luminosityThreshold + smoothWidth, v ); + + gl_FragColor = mix( outputColor, texel, alpha ); + + }` + +}; + +export { LuminosityHighPassShader }; diff --git a/static/vendor/three/addons/shaders/OutputShader.js b/static/vendor/three/addons/shaders/OutputShader.js new file mode 100644 index 0000000..289ac10 --- /dev/null +++ b/static/vendor/three/addons/shaders/OutputShader.js @@ -0,0 +1,85 @@ +const OutputShader = { + + name: 'OutputShader', + + uniforms: { + + 'tDiffuse': { value: null }, + 'toneMappingExposure': { value: 1 } + + }, + + vertexShader: /* glsl */` + precision highp float; + + uniform mat4 modelViewMatrix; + uniform mat4 projectionMatrix; + + attribute vec3 position; + attribute vec2 uv; + + varying vec2 vUv; + + void main() { + + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); + + }`, + + fragmentShader: /* glsl */` + + precision highp float; + + uniform sampler2D tDiffuse; + + #include + #include + + varying vec2 vUv; + + void main() { + + gl_FragColor = texture2D( tDiffuse, vUv ); + + // tone mapping + + #ifdef LINEAR_TONE_MAPPING + + gl_FragColor.rgb = LinearToneMapping( gl_FragColor.rgb ); + + #elif defined( REINHARD_TONE_MAPPING ) + + gl_FragColor.rgb = ReinhardToneMapping( gl_FragColor.rgb ); + + #elif defined( CINEON_TONE_MAPPING ) + + gl_FragColor.rgb = CineonToneMapping( gl_FragColor.rgb ); + + #elif defined( ACES_FILMIC_TONE_MAPPING ) + + gl_FragColor.rgb = ACESFilmicToneMapping( gl_FragColor.rgb ); + + #elif defined( AGX_TONE_MAPPING ) + + gl_FragColor.rgb = AgXToneMapping( gl_FragColor.rgb ); + + #elif defined( NEUTRAL_TONE_MAPPING ) + + gl_FragColor.rgb = NeutralToneMapping( gl_FragColor.rgb ); + + #endif + + // color space + + #ifdef SRGB_TRANSFER + + gl_FragColor = sRGBTransferOETF( gl_FragColor ); + + #endif + + }` + +}; + +export { OutputShader }; From fef870047b79c252fd6baac438e69aa1fc7ed2dc Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sun, 28 Jun 2026 05:32:33 -0500 Subject: [PATCH 61/99] fix(player): reliable Escape "Back" + resumable, optionally-confirmed song exit (#619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(player): make Escape a reliable Back; resumable + optionally-confirmed song exit Escape didn't always leave a song: clicking a transport 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 @@ -10161,6 +10166,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', () => { @@ -10169,17 +10184,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(); @@ -10200,6 +10232,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(), }), }); diff --git a/tests/js/edit_metadata_modal.test.js b/tests/js/edit_metadata_modal.test.js new file mode 100644 index 0000000..178013e --- /dev/null +++ b/tests/js/edit_metadata_modal.test.js @@ -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//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//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'); +}); From b103a722ceb7b7fcfdc62c9c38bf064c71714a99 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sun, 28 Jun 2026 06:52:48 -0500 Subject: [PATCH 66/99] fix(v3): refresh Songs grid after a Settings rescan / DLC-folder change (#624) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported on macOS: on a fresh install, pointing at a DLC folder in Settings and running a scan showed NO songs until an app restart. The scan itself was fine — _background_scan re-reads config.json fresh, so it scans the new folder and populates the library — but the v3 Songs 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 initiate (only its own upload path self-refreshes via watchUploadScan). So its cached, pre-DLC (empty) DOM/snapshot survived a sidebar return until a full reload (restart). Fix: the rescan handlers now emit `library:changed` (static/app.js). The v3 grid listens and reloads if it's the active screen, else sets `_libraryDirty` so the next onV3SongsScreenEnter does a full re-fetch — a short-circuit placed ahead of every cached-DOM fast-path so it can't restore the stale grid. Tests: tests/js/v3_library_refresh.test.js guards the emit + the reload/dirty wiring (DOM/event glue isn't headlessly unit-testable; end-to-end wants an in-app run of the reporter's flow: set DLC in Settings → scan → Songs populate without restart). Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF Co-authored-by: Claude Opus 4.8 Co-authored-by: byrongamatos --- CHANGELOG.md | 1 + static/app.js | 6 +++++ static/v3/songs.js | 21 +++++++++++++++ tests/js/v3_library_refresh.test.js | 40 +++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+) create mode 100644 tests/js/v3_library_refresh.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index bc9bfe0..09e0798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,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 +- **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//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. diff --git a/static/app.js b/static/app.js index 701c328..64ca346 100644 --- a/static/app.js +++ b/static/app.js @@ -4543,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); } @@ -4571,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); } diff --git a/static/v3/songs.js b/static/v3/songs.js index 82551dd..577bece 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -338,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; @@ -1049,6 +1055,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 @@ -1202,5 +1212,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; + }); } })(); diff --git a/tests/js/v3_library_refresh.test.js b/tests/js/v3_library_refresh.test.js new file mode 100644 index 0000000..31cefc0 --- /dev/null +++ b/tests/js/v3_library_refresh.test.js @@ -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'); +}); From 3d97c07b2b165adc2f613fd1a623143f7b2c953b Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sun, 28 Jun 2026 06:58:14 -0500 Subject: [PATCH 67/99] =?UTF-8?q?feat(v3):=20add=20"Add=20to=20playlist"?= =?UTF-8?q?=20to=20a=20song's=20=E2=8B=AE=20More=20menu=20(#625)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(v3): add "Add to playlist" to a song's ⋮ More menu You could only add a song to a playlist via select-mode (checkbox → batch bar). Add an "Add to playlist" row to each song card's ⋮ overflow menu that targets that one song, reusing the same picker (pick a listed number or type a new name to create the playlist). The select-mode batch flow and the single-song menu now share one extracted `addFilenamesToPlaylist(filenames)` helper; the menu is `openCardMenu`, shared by grid cards and tree rows, so both views get it. Tests: tests/js/v3_add_to_playlist_menu.test.js. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * fix(v3): don't clear the batch selection when the playlist picker is cancelled The extract-helper refactor made batchAddToPlaylist() call finishBatch() unconditionally, so cancelling (or a failed create) cleared the multi-select and reloaded the grid — a regression from the original early-return-on-cancel behaviour. addFilenamesToPlaylist() already returns null on cancel/failure; gate finishBatch() on a truthy playlist id so the selection is preserved for a retry. Adds a regression assertion. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: byrongamatos --- CHANGELOG.md | 1 + static/v3/songs.js | 30 +++++++++++++++---- tests/js/v3_add_to_playlist_menu.test.js | 37 ++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 tests/js/v3_add_to_playlist_menu.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 09e0798..bf9e025 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **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). diff --git a/static/v3/songs.js b/static/v3/songs.js index 577bece..732d113 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -495,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) => @@ -514,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); @@ -612,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() { diff --git a/tests/js/v3_add_to_playlist_menu.test.js b/tests/js/v3_add_to_playlist_menu.test.js new file mode 100644 index 0000000..712e545 --- /dev/null +++ b/tests/js/v3_add_to_playlist_menu.test.js @@ -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)'); +}); From 90fb2ee3bc59b61ce5d800c8f569a336b6c47184 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sun, 28 Jun 2026 07:10:15 -0500 Subject: [PATCH 68/99] feat(v3): content-dependent playlist covers + custom art (#626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(v3): content-dependent playlist covers + custom art upload Playlist cards were a tiny 🎵 emoji on an empty square. Now the cover reflects the playlist's contents, and you can override it with a custom image. Cover (in priority order): - custom uploaded cover, else - empty playlist -> the icon - a few songs -> the first song's album art - 4+ songs -> a 2x2 album-art mosaic Backend (server.py): - MetadataDB.list_playlists() returns each playlist's first few still-present songs' art URLs (`art_urls`) for the content cover. - GET /api/playlists and GET /api/playlists/{id} add `cover_url` when a custom cover exists. - POST/GET/DELETE /api/playlists/{id}/cover — store a small PNG thumbnail under CONFIG_DIR/playlist_covers/ (PIL-converted, mirroring song-art upload); the cover is deleted with the playlist. Cover mutators added to _MUTATING_ROUTES. Frontend (static/v3/playlists.js): playlistCoverHtml(p) renders the rules above; the playlist detail view gets "Cover" (pick an image) + "Remove cover". Tests: tests/test_playlists_api.py (art_urls + cover roundtrip / reject-non-image / delete-removes-cover — 11 pass) and tests/js/v3_playlist_cover.test.js. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * fix(playlists): 400 (not 500) on non-string cover image + bust same-second cover cache Two review follow-ups on the playlist-cover endpoints: - POST /cover did `if "," in b64` before any type check, so a non-string image (e.g. {"image": 123} / null) raised TypeError -> 500. Guard with isinstance (mirrors the avatar/song-art upload) for a clean 400. +regression test covering number/null/object/list. - The cover URL busted only on int(st_mtime) (1s granularity) and GET /cover sent no cache headers, so a same-second replace/remove/re-upload could serve a stale image. Use st_mtime_ns in the cache-bust token and add the shared no-cache header (_ART_CACHE_HEADERS), matching song art. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: byrongamatos --- CHANGELOG.md | 1 + server.py | 119 +++++++++++++++++++++++++++-- static/v3/playlists.js | 43 ++++++++++- tests/js/v3_playlist_cover.test.js | 28 +++++++ tests/test_playlists_api.py | 64 ++++++++++++++++ 5 files changed, 247 insertions(+), 8 deletions(-) create mode 100644 tests/js/v3_playlist_cover.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index bf9e025..3bd13fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ 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). diff --git a/server.py b/server.py index d2c2d2c..6625b1b 100644 --- a/server.py +++ b/server.py @@ -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.""" diff --git a/static/v3/playlists.js b/static/v3/playlists.js index c336d65..b429391 100644 --- a/static/v3/playlists.js +++ b/static/v3/playlists.js @@ -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) => ''; + if (p.cover_url) return '
' + img(p.cover_url, 'w-full h-full object-cover') + '
'; + const arts = Array.isArray(p.art_urls) ? p.art_urls : []; + if (!arts.length) { + return '
' + (p.system_key ? '🔖' : '🎵') + '
'; + } + if (arts.length < 4) return '
' + img(arts[0], 'w-full h-full object-cover') + '
'; + return '
' + + arts.slice(0, 4).map((u) => img(u, 'w-full h-full object-cover')).join('') + '
'; + } + function songRow(s, opts) { opts = opts || {}; const handle = opts.draggable @@ -96,8 +112,7 @@ (lists.length ? '
' + lists.map((p) => '').join('') + '
' @@ -126,8 +141,11 @@ '

' + esc(pl.name) + '

' + (isSystem ? '' : '
' + + '' + + (pl.cover_url ? '' : '') + '' + - '
') + + '' + + '') + '' + (pl.songs.length ? '
    ' + pl.songs.map((s) => songRow(s, { draggable: !isSystem })).join('') + '
' @@ -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 ─────────────────────────────────────────────────────────-- diff --git a/tests/js/v3_playlist_cover.test.js b/tests/js/v3_playlist_cover.test.js new file mode 100644 index 0000000..5a52c7d --- /dev/null +++ b/tests/js/v3_playlist_cover.test.js @@ -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\)/); +}); diff --git a/tests/test_playlists_api.py b/tests/test_playlists_api.py index 6f953f9..ae1caf6 100644 --- a/tests/test_playlists_api.py +++ b/tests/test_playlists_api.py @@ -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 ` 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() From 271fedda55c28410eb1ad7376350012924f36c13 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sun, 28 Jun 2026 13:25:49 -0500 Subject: [PATCH 69/99] fix(input_setup): stop collapsing audio driver-type variants in the wizard (#627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onboarding audio picker de-duped the device list by display LABEL. On Windows the engine enumerates one interface once per host API (ASIO / Windows Audio / DirectSound) with the same name, so the variants collapsed to a single choice — silently keeping whichever sorted first, often not the low-latency ASIO one the player wants. It could also drop the variant that was actually `selected`. The audio-input capability already collapses true duplicates by logicalSourceKey (_visibleInputSources), and these variants each have a DISTINCT key, so the wizard's extra label-collapse was redundant for real dupes and destructive for the variants. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to tag each source label with its driver type ("Focusrite (ASIO)" vs "(Windows Audio)") so the now-distinct entries are legible. Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 1 + plugins/input_setup/screen.js | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd13fb..ed349aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,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 +- **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. - **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//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). diff --git a/plugins/input_setup/screen.js b/plugins/input_setup/screen.js index 424c65f..718801a 100644 --- a/plugins/input_setup/screen.js +++ b/plugins/input_setup/screen.js @@ -51,15 +51,16 @@ sources = sources.filter((s) => s && !/midi/i.test(String(s.providerId || '')) && !/^midi-input/i.test(String(s.label || ''))); - // De-dupe by display label — the desktop engine enumerates the same - // device under several driver types, so the same name can repeat. - const seen = new Set(); - sources = sources.filter((s) => { - const key = String(s.label || '').toLowerCase(); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); + // No label de-dupe here. The audio-input capability already + // collapses exact duplicates by logicalSourceKey + // (_visibleInputSources), so nothing it returns shares a key. A + // device that enumerates under several driver types (ASIO / Windows + // Audio / DirectSound) has a DISTINCT key per type and is now + // labelled with its driver type (e.g. "Focusrite (ASIO)") — each is + // a real, separately-selectable input the user must be able to see. + // The old bare-label collapse also kept whichever variant sorted + // first, which could silently drop the one that was actually + // `selected` below. const selected = sources.find((s) => s && s.selected) || null; return { sources, selected }; } catch (_) { return { sources: [], selected: null }; } From 5a0b62599dcd3b5020c5fc4b88e2e29214f3b9e2 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sun, 28 Jun 2026 22:08:44 +0200 Subject: [PATCH 70/99] feat(highway): show feedpak author/editor credits on song load (#629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the feedpak manifest `authors` list (spec §5.4) on the highway: a credits card ("Charted by Azure") shown over the highway when a song loads, riding the count-in / a ~3s hold and dismissed when playback starts. Gated to fresh feedpak plays only (minigames, loose/archive, arrangement switches, seeks, replays excluded). Includes a 12s backstop so the overlay never lingers if playback fails to start. Closes #628. Reviewed by Codex (3 passes, converged). Verified locally: pytest 9/9, node --test 23/23, headless-browser end-to-end. --- server.py | 36 ++++++ static/app.js | 144 ++++++++++++++++++++++- static/highway.js | 7 ++ static/style.css | 90 +++++++++++++++ tests/js/song_credits_overlay.test.js | 133 +++++++++++++++++++++ tests/test_highway_ws_authors.py | 159 ++++++++++++++++++++++++++ 6 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 tests/js/song_credits_overlay.test.js create mode 100644 tests/test_highway_ws_authors.py diff --git a/server.py b/server.py index 6625b1b..0bd1791 100644 --- a/server.py +++ b/server.py @@ -2898,6 +2898,35 @@ def _sanitized_song_offset(song) -> float: return v if math.isfinite(v) else 0.0 +def _sanitize_authors(manifest: dict | None) -> list[dict]: + """Extract a display-safe contributor list from a feedpak manifest. + + The feedpak spec (§5.4) defines an OPTIONAL top-level `authors` list of + objects `{name (required), role?, email?, url?}`. We surface only `name` + and `role` to the highway — contact fields (email/url) are intentionally + dropped from the on-screen credits. Malformed entries (non-dict, missing / + blank name) are skipped; absent / non-list `authors` yields `[]`. + """ + if not isinstance(manifest, dict): + return [] + raw = manifest.get("authors") + if not isinstance(raw, list): + return [] + out: list[dict] = [] + for entry in raw: + if not isinstance(entry, dict): + continue + name = entry.get("name") + if not isinstance(name, str) or not name.strip(): + continue + role = entry.get("role") + out.append({ + "name": name.strip(), + "role": role.strip() if isinstance(role, str) and role.strip() else None, + }) + return out + + def _stat_for_cache(f: Path) -> tuple[float, int]: """Return (mtime, size) for cache freshness checks. @@ -7304,6 +7333,13 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, # and break the frontend's song_info parsing. "offset": _sanitized_song_offset(song) if is_loose else 0.0, "format": "sloppak" if is_slop else ("loose" if is_loose else "archive"), + # Feedpak contributor credits (manifest `authors:`, spec §5.4) — + # name + role only, shown on the highway when a song is loaded. + # Only sloppak/feedpak packs carry a manifest; loose/archive + # sources get []. The frontend uses a non-empty list as the gate + # for the credits overlay, so minigames / synthetic highway uses + # (no manifest) never trigger it. + "authors": _sanitize_authors(loaded_slop.manifest) if (is_slop and loaded_slop is not None) else [], "stems": stems_payload, # Full-mix audio (sloppak `original_audio:`) served alongside the # separate `stems`. The stems plugin plays this single file while diff --git a/static/app.js b/static/app.js index 64ca346..60031cd 100644 --- a/static/app.js +++ b/static/app.js @@ -5993,11 +5993,45 @@ let _pendingAutostart = false; window.feedBack.on('song:ready', () => { if (!_pendingAutostart) return; _pendingAutostart = false; - if (!_autoplayExitEnabled() || isPlaying) return; + if (isPlaying) return; + // Feedpak contributor credits: only real feedpak plays carry authors + // (loose/archive and minigames get []), so a non-empty list is the gate. + // Shown over the highway and dismissed the moment real playback begins + // (song:play). This fresh-load path is the only place it fires — + // arrangement switches / seeks / manual replays never arm _pendingAutostart, + // and minigames never get here. Decoupled from autoplay below so credits + // show on load even when autoplay-exit is disabled. + const authors = (window.feedBack.currentSong && window.feedBack.currentSong.authors) || []; + if (authors.length) { + showSongCreditsOverlay(authors); + _creditsHideOnPlay = () => { _creditsHideOnPlay = null; hideSongCreditsOverlay(); }; + window.feedBack.on('song:play', _creditsHideOnPlay, { once: true }); + } + // Autoplay-exit disabled: don't auto-start. Still let the credits dwell a + // couple seconds on the freshly-loaded song, then clear them (they also + // clear early if the user manually presses Play, via _creditsHideOnPlay). + if (!_autoplayExitEnabled()) { + if (authors.length) _creditsTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_HOLD_MS); + return; + } // "Countdown before song": play a 4-beat count-in, then start. Otherwise // reuse the Play button's start path directly (handles HTML5 + _juceMode). if (_countdownBeforeSongEnabled()) { + // The count-in (~2.5s) gives the credits their on-screen dwell. Promise.resolve(startSongCountIn()).catch((err) => console.warn('[app] song count-in failed:', err)); + } else if (authors.length) { + // No count-in window — hold the credits a couple seconds, then start. + // _cancelCountIn() and changeArrangement() both clear _creditsTimer, so + // a teardown / arrangement switch during the hold cancels this play. + _creditsTimer = setTimeout(() => { + _creditsTimer = null; + // If playback doesn't actually start (e.g. HTML5 autoplay rejection), + // song:play never fires — clear the credits promptly rather than + // waiting for the backstop. On success the song:play listener owns it. + Promise.resolve(togglePlay()) + .then(() => { if (!isPlaying) hideSongCreditsOverlay(); }) + .catch((err) => { console.warn('[app] autoplay failed:', err); hideSongCreditsOverlay(); }); + }, _CREDITS_HOLD_MS); } else { Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err)); } @@ -6395,6 +6429,11 @@ let _arrBusyTimeout = null; async function changeArrangement(index) { if (currentFilename) { + // Tear down any pending fresh-load credits before switching: the + // no-count-in hold timer would otherwise fire togglePlay() against the + // incoming (still-loading) arrangement. hideSongCreditsOverlay() clears + // the timer, the song:play listener, and the overlay node. + hideSongCreditsOverlay(); window.feedBack.emit('song:arrangement-changed', { filename: currentFilename, arrangement: index }); const wasPlaying = isPlaying; const time = _audioTime(); @@ -9285,10 +9324,27 @@ let _countOverlay = null; let _countInGen = 0; let _countInTimer = null; let _countInRaf = 0; +// Feedpak credits overlay (manifest `authors:`, spec §5.4): shown on the +// highway when a song is loaded, alongside the count-in. Torn down together +// with the count-in via _cancelCountIn(). +let _creditsOverlay = null; +let _creditsTimer = null; +let _creditsHideOnPlay = null; +let _creditsMaxTimer = null; +const _CREDITS_HOLD_MS = 3000; +// Backstop: the overlay's primary dismiss is song:play, but playback can fail +// to start without emitting it (HTML5 autoplay rejection, JUCE start failure, +// a count-in handoff that never plays). This hard cap guarantees the credits +// never linger over the highway. Generous enough to outlast a normal count-in. +const _CREDITS_MAX_MS = 12000; function _cancelCountIn() { _countInGen++; _countingIn = false; hideCountOverlay(); + // The credits overlay rides the count-in lifecycle (and its no-count-in + // hold timer), so a teardown — leaving the player, loading another song — + // must clear it too, or it lingers on the next screen. + hideSongCreditsOverlay(); if (_countInTimer) { clearTimeout(_countInTimer); _countInTimer = null; } if (_countInRaf) { cancelAnimationFrame(_countInRaf); _countInRaf = 0; } } @@ -9306,6 +9362,92 @@ function hideCountOverlay() { if (_countOverlay) { _countOverlay.remove(); _countOverlay = null; } } +// Map a feedpak author `role` to a friendly " by" credit line. The +// recommended vocabulary is from feedpak spec §5.4; unknown roles are +// title-cased ("foo" → "Foo by"); a missing role shows the bare name. +const _CREDIT_ROLE_VERBS = { + charter: 'Charted by', + transcriber: 'Transcribed by', + arranger: 'Arranged by', + editor: 'Edited by', + mixer: 'Mixed by', + engineer: 'Engineered by', + proofreader: 'Proofread by', +}; + +function _creditLineLabel(role) { + if (!role) return ''; + const key = String(role).trim().toLowerCase(); + if (_CREDIT_ROLE_VERBS[key]) return _CREDIT_ROLE_VERBS[key]; + return key.charAt(0).toUpperCase() + key.slice(1) + ' by'; +} + +// Show the feedpak contributor credits over the highway. `authors` is the +// sanitized [{name, role}] list from window.feedBack.currentSong.authors. +// Anchored to the lower third (bottom-center) so it never collides with the +// vertically-centered count-in number, and pointer-events-none so it never +// intercepts clicks. No-op when there are no contributors to show. +function showSongCreditsOverlay(authors) { + if (!Array.isArray(authors) || authors.length === 0) return; + if (!_creditsOverlay) { + _creditsOverlay = document.createElement('div'); + _creditsOverlay.className = 'song-credits-overlay'; + document.body.appendChild(_creditsOverlay); + } + // Build via DOM + textContent — author names are untrusted pack data and + // must never be interpolated as HTML. + _creditsOverlay.replaceChildren(); + const card = document.createElement('div'); + card.className = 'song-credits-card'; + + const eyebrow = document.createElement('div'); + eyebrow.className = 'song-credits-eyebrow'; + eyebrow.textContent = 'Credits'; + card.appendChild(eyebrow); + + const title = (window.feedBack && window.feedBack.currentSong + && window.feedBack.currentSong.title) || ''; + if (title) { + const heading = document.createElement('div'); + heading.className = 'song-credits-heading'; + heading.textContent = title; + card.appendChild(heading); + } + + for (const a of authors) { + if (!a || !a.name) continue; + const row = document.createElement('div'); + row.className = 'song-credits-line'; + const label = _creditLineLabel(a.role); + if (label) { + const lab = document.createElement('span'); + lab.className = 'song-credits-role'; + lab.textContent = label + ' '; + row.appendChild(lab); + } + const nm = document.createElement('span'); + nm.className = 'song-credits-name'; + nm.textContent = a.name; + row.appendChild(nm); + card.appendChild(row); + } + _creditsOverlay.appendChild(card); + // Arm the backstop so the overlay self-clears even if playback never starts + // / never emits song:play. song:play (or any teardown) clears it earlier. + if (_creditsMaxTimer) clearTimeout(_creditsMaxTimer); + _creditsMaxTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_MAX_MS); +} + +function hideSongCreditsOverlay() { + if (_creditsTimer) { clearTimeout(_creditsTimer); _creditsTimer = null; } + if (_creditsMaxTimer) { clearTimeout(_creditsMaxTimer); _creditsMaxTimer = null; } + if (_creditsHideOnPlay) { + window.feedBack.off('song:play', _creditsHideOnPlay); + _creditsHideOnPlay = null; + } + if (_creditsOverlay) { _creditsOverlay.remove(); _creditsOverlay = null; } +} + async function startCountIn(opts = {}) { if (_countingIn) return; _countingIn = true; diff --git a/static/highway.js b/static/highway.js index 66231e9..58e5dbc 100644 --- a/static/highway.js +++ b/static/highway.js @@ -3530,6 +3530,13 @@ function createHighway() { // matchesArrangement on this rather than the // arrangement name. hasNotation: Boolean(msg.has_notation), + // Feedpak contributor credits (manifest + // `authors:`, spec §5.4): [{name, role}]. + // Only real feedpak plays carry these; loose/ + // archive sources and synthetic highway uses + // (minigames) get []. app.js shows a credits + // overlay on song load when this is non-empty. + authors: Array.isArray(msg.authors) ? msg.authors : [], }; window.feedBack.emit('song:loaded', window.feedBack.currentSong); } diff --git a/static/style.css b/static/style.css index d7903aa..951118c 100644 --- a/static/style.css +++ b/static/style.css @@ -863,3 +863,93 @@ html { scroll-behavior: smooth; } box-shadow: 0 0 0 2px rgba(64, 128, 224, 0.7); border-radius: 0.25rem; } + +/* Feedpak contributor credits shown over the highway when a song loads + (manifest `authors:`, spec §5.4). Anchored to the upper third so it sits + ABOVE the vertically-centered count-in number; click-through. */ +.song-credits-overlay { + position: fixed; + left: 0; + right: 0; + top: 15%; + /* Above the modal layer (z-[200], incl. the "Loading audio" backdrop) and + the count-in number (z-[100]) so the credits stay prominent through the + whole load → count-in → play window. */ + z-index: 205; + display: flex; + justify-content: center; + pointer-events: none; + animation: song-credits-fade-in 0.45s cubic-bezier(0.16, 1, 0.3, 1); +} + +.song-credits-card { + position: relative; + min-width: 16rem; + max-width: min(90vw, 34rem); + padding: 1.4rem 2.5rem 1.5rem; + text-align: center; + background: + radial-gradient(120% 140% at 50% 0%, rgb(56 78 130 / 0.45) 0%, transparent 60%), + linear-gradient(165deg, rgb(23 30 48 / 0.92) 0%, rgb(11 15 26 / 0.94) 100%); + border: 1px solid rgb(129 140 248 / 0.28); + border-radius: 1rem; + box-shadow: + 0 18px 50px rgb(0 0 0 / 0.55), + 0 0 0 1px rgb(0 0 0 / 0.35), + inset 0 1px 0 rgb(255 255 255 / 0.07); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +/* Accent bar across the top edge of the card. */ +.song-credits-card::before { + content: ""; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 3.25rem; + height: 3px; + border-radius: 0 0 3px 3px; + background: linear-gradient(90deg, #38bdf8, #818cf8); + box-shadow: 0 0 12px rgb(99 102 241 / 0.7); +} + +.song-credits-eyebrow { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.22em; + text-transform: uppercase; + color: rgb(165 180 252 / 0.9); + margin-bottom: 0.4rem; +} + +.song-credits-heading { + font-size: 1.3rem; + font-weight: 800; + color: #f8fafc; + margin-bottom: 0.7rem; + letter-spacing: 0.01em; + text-shadow: 0 1px 8px rgb(0 0 0 / 0.5); +} + +.song-credits-line { + font-size: 1.1rem; + line-height: 1.55; + color: #e2e8f0; +} + +.song-credits-role { + color: rgb(148 163 184 / 0.95); + font-weight: 500; +} + +.song-credits-name { + font-weight: 700; + color: #ffffff; +} + +@keyframes song-credits-fade-in { + from { opacity: 0; transform: translateY(-12px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} diff --git a/tests/js/song_credits_overlay.test.js b/tests/js/song_credits_overlay.test.js new file mode 100644 index 0000000..e08909d --- /dev/null +++ b/tests/js/song_credits_overlay.test.js @@ -0,0 +1,133 @@ +// Verify the feedpak credits overlay helpers in app.js: +// - _creditLineLabel() role → friendly " by" label +// - showSongCreditsOverlay() builds an XSS-safe card; no-op on empty list +// - hideSongCreditsOverlay() removes the overlay element +// +// Same isolation strategy as autoplay_exit.test.js — extract the functions +// from app.js by brace-matching and run them in a vm sandbox with a fake DOM. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const { extractFunction } = require('./test_utils'); + +const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js'); +const SRC = fs.readFileSync(APP_JS, 'utf8'); + +// Minimal fake DOM element: records className, children, and textContent. +// Setting textContent clears children (matching real DOM) so we can assert +// names were set via textContent (not innerHTML) — the XSS-safety contract. +function makeEl() { + return { + className: '', + children: [], + _text: '', + set textContent(v) { this._text = String(v); this.children = []; }, + get textContent() { return this._text; }, + appendChild(c) { this.children.push(c); return c; }, + replaceChildren() { this.children = []; }, + remove() { this.removed = true; }, + }; +} + +function allText(node) { + let s = node._text || ''; + for (const c of node.children) s += allText(c); + return s; +} + +function buildSandbox(currentSong) { + const body = makeEl(); + const sandbox = { + document: { body, createElement: () => makeEl() }, + window: { feedBack: { currentSong, off() {} } }, + setTimeout: () => 1, + clearTimeout: () => {}, + }; + vm.createContext(sandbox); + const preamble = ` + let _creditsOverlay = null; + let _creditsTimer = null; + let _creditsHideOnPlay = null; + let _creditsMaxTimer = null; + const _CREDITS_MAX_MS = 12000; + const _CREDIT_ROLE_VERBS = ${JSON.stringify({ + charter: 'Charted by', transcriber: 'Transcribed by', + arranger: 'Arranged by', editor: 'Edited by', mixer: 'Mixed by', + engineer: 'Engineered by', proofreader: 'Proofread by', + })}; + `; + vm.runInContext( + preamble + + extractFunction(SRC, 'function _creditLineLabel(') + '\n' + + extractFunction(SRC, 'function showSongCreditsOverlay(') + '\n' + + extractFunction(SRC, 'function hideSongCreditsOverlay(') + '\n' + + 'globalThis._creditLineLabel = _creditLineLabel;' + + 'globalThis.showSongCreditsOverlay = showSongCreditsOverlay;' + + 'globalThis.hideSongCreditsOverlay = hideSongCreditsOverlay;' + + 'globalThis._getOverlay = () => _creditsOverlay;', + sandbox, + ); + return sandbox; +} + +test('_creditLineLabel maps known roles, title-cases unknown, blanks empty', () => { + const s = buildSandbox({}); + assert.equal(s._creditLineLabel('charter'), 'Charted by'); + assert.equal(s._creditLineLabel('Editor'), 'Edited by'); // case-insensitive + assert.equal(s._creditLineLabel('mixer'), 'Mixed by'); + assert.equal(s._creditLineLabel('luthier'), 'Luthier by'); // unknown → title-cased + assert.equal(s._creditLineLabel(null), ''); // no role → bare name + assert.equal(s._creditLineLabel(''), ''); +}); + +test('showSongCreditsOverlay builds a card with heading + credit lines', () => { + const s = buildSandbox({ title: 'My Song' }); + s.showSongCreditsOverlay([ + { name: 'Azure', role: 'charter' }, + { name: 'Bob Lee', role: 'editor' }, + { name: 'Solo', role: null }, + ]); + const overlay = s._getOverlay(); + assert.ok(overlay, 'overlay created'); + assert.equal(overlay.className, 'song-credits-overlay'); + assert.equal(s.document.body.children.length, 1); + const text = allText(overlay); + assert.match(text, /My Song/); // heading is the song title + assert.match(text, /Charted by/); + assert.match(text, /Azure/); + assert.match(text, /Edited by/); + assert.match(text, /Bob Lee/); + assert.match(text, /Solo/); // role-less entry still shows the name +}); + +test('showSongCreditsOverlay sets names via textContent (XSS-safe)', () => { + const s = buildSandbox({ title: 'T' }); + s.showSongCreditsOverlay([{ name: '', role: 'charter' }]); + const overlay = s._getOverlay(); + // The raw string survives verbatim as text — proving it was never parsed + // as HTML (no innerHTML interpolation anywhere on the path). + assert.match(allText(overlay), //); +}); + +test('showSongCreditsOverlay is a no-op for empty / non-array input', () => { + const s = buildSandbox({ title: 'T' }); + s.showSongCreditsOverlay([]); + assert.equal(s._getOverlay(), null); + s.showSongCreditsOverlay(undefined); + assert.equal(s._getOverlay(), null); + assert.equal(s.document.body.children.length, 0); +}); + +test('hideSongCreditsOverlay removes the overlay', () => { + const s = buildSandbox({ title: 'T' }); + s.showSongCreditsOverlay([{ name: 'Azure', role: 'charter' }]); + const overlay = s._getOverlay(); + assert.ok(overlay); + s.hideSongCreditsOverlay(); + assert.equal(overlay.removed, true); + assert.equal(s._getOverlay(), null); +}); diff --git a/tests/test_highway_ws_authors.py b/tests/test_highway_ws_authors.py new file mode 100644 index 0000000..3de3f2c --- /dev/null +++ b/tests/test_highway_ws_authors.py @@ -0,0 +1,159 @@ +"""Tests for feedpak contributor credits on the highway. + +Covers the `_sanitize_authors` helper (unit) and the `song_info` WebSocket +frame carrying the manifest `authors` list end-to-end (integration). The +frontend uses a non-empty `authors` list to gate a credits overlay shown when +a song loads, so loose/archive/synthetic plays must surface `[]`. +""" + +from __future__ import annotations + +import importlib +import json +import sys + +import pytest +import yaml +from fastapi.testclient import TestClient + + +# ── _sanitize_authors unit tests ──────────────────────────────────────────── + + +@pytest.fixture() +def server_mod(monkeypatch, tmp_path): + monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc")) + (tmp_path / "dlc").mkdir() + sys.modules.pop("server", None) + mod = importlib.import_module("server") + yield mod + conn = getattr(getattr(mod, "meta_db", None), "conn", None) + if conn is not None: + conn.close() + + +def test_sanitize_authors_valid(server_mod): + out = server_mod._sanitize_authors( + { + "authors": [ + {"name": "Azure", "role": "charter", "email": "a@b.c", "url": "x"}, + {"name": "Bob Lee", "role": "editor"}, + {"name": "Solo"}, + ] + } + ) + # name + role only; email/url dropped; missing role → None. + assert out == [ + {"name": "Azure", "role": "charter"}, + {"name": "Bob Lee", "role": "editor"}, + {"name": "Solo", "role": None}, + ] + + +def test_sanitize_authors_skips_malformed(server_mod): + out = server_mod._sanitize_authors( + { + "authors": [ + {"name": ""}, # blank name → skipped + {"name": " "}, # whitespace name → skipped + {"role": "mixer"}, # no name → skipped + "not-a-dict", # non-dict → skipped + {"name": " Kept ", "role": " arranger "}, # trimmed + ] + } + ) + assert out == [{"name": "Kept", "role": "arranger"}] + + +@pytest.mark.parametrize("manifest", [None, {}, {"authors": None}, {"authors": "x"}, "nope"]) +def test_sanitize_authors_absent_or_nonlist(server_mod, manifest): + assert server_mod._sanitize_authors(manifest) == [] + + +# ── song_info WS integration ──────────────────────────────────────────────── + + +def _write_sloppak(dlc_root, *, authors): + pak = dlc_root / "authortest.sloppak" + pak.mkdir() + (pak / "arrangements").mkdir() + (pak / "arrangements" / "lead.json").write_text( + json.dumps( + { + "notes": [], + "chords": [], + "anchors": [], + "handshapes": [], + "templates": [], + "beats": [{"time": 0.0, "measure": 1}], + "sections": [{"name": "intro", "number": 1, "time": 0.0}], + } + ) + ) + manifest = { + "title": "Author Test", + "artist": "Tester", + "album": "", + "year": 2026, + "duration": 10.0, + "arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}], + "stems": [], + } + if authors is not None: + manifest["authors"] = authors + (pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False)) + return pak + + +@pytest.fixture() +def make_client(tmp_path, monkeypatch): + def _make(): + monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc")) + monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1") + sys.modules.pop("server", None) + server = importlib.import_module("server") + monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None) + monkeypatch.setattr(server, "startup_scan", lambda: None) + monkeypatch.setattr(server, "SLOPPAK_CACHE_DIR", tmp_path / "cache") + return server + + (tmp_path / "dlc").mkdir() + yield _make + server = sys.modules.get("server") + conn = getattr(getattr(server, "meta_db", None), "conn", None) + if conn is not None: + conn.close() + + +def _song_info(client, path): + with client.websocket_connect(path) as ws: + for _ in range(200): + msg = ws.receive_json() + if msg.get("error"): + raise AssertionError(f"WS error frame: {msg}") + if msg.get("type") == "song_info": + return msg + if msg.get("type") == "ready": + break + raise AssertionError("no song_info frame received") + + +def test_song_info_carries_authors(make_client): + server = make_client() + _write_sloppak( + server._get_dlc_dir(), + authors=[{"name": "Azure", "role": "charter", "email": "a@b.c"}], + ) + with TestClient(server.app) as client: + info = _song_info(client, "/ws/highway/authortest.sloppak?arrangement=0") + assert info["authors"] == [{"name": "Azure", "role": "charter"}] + + +def test_song_info_authors_empty_when_absent(make_client): + server = make_client() + _write_sloppak(server._get_dlc_dir(), authors=None) + with TestClient(server.app) as client: + info = _song_info(client, "/ws/highway/authortest.sloppak?arrangement=0") + assert info["authors"] == [] From 8a2175aa1ccc1edafcbf8ca0f1eadc8833338717 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Mon, 29 Jun 2026 00:13:28 +0200 Subject: [PATCH 71/99] feat(onboarding): amp-sim opt-in choice + use_amp_sims setting (#631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of feedBack-desktop#46. The desktop app monitors through an in-app amp-sim/tone chain that, once loaded, auto-restores every launch — an idle high-gain amp on the input is a constant distorted buzz, and the dry-only monitor mute can't silence it. This adds the "own-rig first" opt-in so players using their own external amp/rig never get a processed monitor in the first place. Core changes: - New `use_amp_sims` setting (default OFF / own-rig first): GET default, POST boolean validation, and resettable key — mirroring achievements_enabled. - Onboarding wizard: a DESKTOP-ONLY step ("How do you want to hear yourself?") between instrument paths and the calibration challenge. The web build has no native amp sims, so the step is skipped there (5 steps on web, 6 on desktop) — gated on window.feedBackDesktop, dot count and setStep bounds are derived from it. Ticking "Use in-app amp simulations" POSTs use_amp_sims; default unticked. The desktop renderer consumes this setting to gate its saved-tone-chain restore (feedback-desktop PR, paired). Verified by booting core locally and walking the wizard with Playwright: web shows 5 dots/no amp step, desktop shows 6 dots, the amp step is reachable, calibration stays the final "Play it now" step, ticking the box persists use_amp_sims=true, and there are no page errors. Server-side GET default / POST validation / reset confirmed via curl. Co-authored-by: Claude Opus 4.8 (1M context) --- server.py | 17 ++++++++++++- static/v3/profile.js | 58 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/server.py b/server.py index 0bd1791..0dbf258 100644 --- a/server.py +++ b/server.py @@ -5531,6 +5531,15 @@ def _default_settings(): # until the user opts in. Read by the bundled achievements plugin to # gate its wall-sync enqueue. "achievements_enabled": False, + # Amp-sim opt-in (issue feedBack-desktop#46). Whether the desktop app may + # auto-load an in-app amp-sim / tone chain (NAM / IR / VST) for input + # monitoring. Default OFF — "own-rig first": players monitoring through + # their own external amp/rig never get a processed monitor (and never the + # idle distorted buzz) until they opt in. Set during onboarding (desktop + # only) and from the desktop Audio settings toggle; read by the desktop + # renderer to gate its saved-chain restore. Inert on the pure-web build, + # which has no native amp sims. + "use_amp_sims": False, } @@ -5673,6 +5682,12 @@ def save_settings(data: dict): if not isinstance(raw, bool): return {"error": "achievements_enabled must be a boolean"} updates["achievements_enabled"] = raw + if "use_amp_sims" in data: + raw = data["use_amp_sims"] + if raw is not None: + if not isinstance(raw, bool): + return {"error": "use_amp_sims must be a boolean"} + updates["use_amp_sims"] = raw if "miss_penalty" in data: raw = data["miss_penalty"] if raw is not None: @@ -5763,7 +5778,7 @@ _RESETTABLE_SETTINGS_KEYS = frozenset({ "default_arrangement", "demucs_server_url", "master_difficulty", "av_offset_ms", "countdown_before_song", "miss_penalty", "fail_behavior", "reference_pitch", "instrument", "string_count", "tuning", - "achievements_enabled", + "achievements_enabled", "use_amp_sims", }) diff --git a/static/v3/profile.js b/static/v3/profile.js index 60ba4b3..d8e1985 100644 --- a/static/v3/profile.js +++ b/static/v3/profile.js @@ -330,9 +330,16 @@ const editing = !!opts.editing; document.getElementById('v3-onboarding')?.remove(); + // The amp-sim opt-in step (step 5) only exists in the desktop app — the + // pure-web build has no native amp sims to monitor through, so the step + // is skipped there (calibration is the last step at index 5 on web, 6 on + // desktop). See feedBack-desktop#46. + const isDesktop = !!window.feedBackDesktop; + const lastStep = isDesktop ? 6 : 5; + const stepDots = editing ? '' : '
' + - [1, 2, 3, 4, 5].map((n) => '').join('') + + Array.from({ length: lastStep }, (_, i) => i + 1).map((n) => '').join('') + '
'; const overlay = document.createElement('div'); @@ -380,8 +387,20 @@ '' + '

Each path levels up by completing challenges — together they make up your Mastery Rank. You can add more later.

' + '
' + - // Step 5 — calibration offer (first-run only). + // Step 5 — amp-sim opt-in (DESKTOP ONLY; default OFF / own-rig first). + // Hidden div is always present in the DOM; setStep only navigates to + // it on desktop. See feedBack-desktop#46. '' + + // Step 6 — calibration offer (first-run only). + '' + @@ -441,7 +460,7 @@ function setStep(n) { step = n; errEl.classList.add('hidden'); - for (let i = 1; i <= 5; i++) { + for (let i = 1; i <= 6; i++) { overlay.querySelector('#v3-ob-step' + i).classList.toggle('hidden', i !== n); } overlay.querySelectorAll('#v3-ob-dots [data-dot]').forEach((d) => { @@ -454,12 +473,13 @@ : n === 2 ? 'Point us at your songs' : n === 3 ? 'Feats of Power (optional)' : n === 4 ? 'Choose your instrument paths' + : n === 5 ? 'How do you want to monitor?' : 'One last thing — calibrate your setup'; } - submit.textContent = n === 5 ? 'Play it now' : 'Next'; + submit.textContent = n === 6 ? 'Play it now' : 'Next'; // Skip is offered on the song-directory step (configure later) and - // the calibration challenge. - skipBtn.classList.toggle('hidden', !(n === 2 || n === 5)); + // the calibration challenge (the last step). + skipBtn.classList.toggle('hidden', !(n === 2 || n === 6)); refreshSubmit(); } @@ -695,11 +715,29 @@ // New step: input-device selection + calibration, between // path selection and the note-detect calibration challenge. await runInputSetup(selectedPaths); - setStep(5); + setStep(isDesktop ? 5 : 6); } catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); } return; } - // Step 5 — "Play it now": leave calibration pending (it completes + if (step === 5) { + // Step 5 (desktop only) — persist the amp-sim opt-in (default OFF + // / own-rig). Best-effort: a failed write must not block onboarding; + // it's settable later from the desktop Audio settings. + submit.disabled = true; + try { + const ampEl = overlay.querySelector('#v3-ob-ampsims'); + const useAmpSims = !!(ampEl && ampEl.checked); + try { + await fetch('/api/settings', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ use_amp_sims: useAmpSims }), + }); + } catch (e) { /* best-effort — settable later */ } + setStep(6); + } finally { refreshSubmit(); } + return; + } + // Step 6 — "Play it now": leave calibration pending (it completes // through the normal scored-stats path) and launch the diagnostic. const target = diagnosticFilename; await finish({ launchingSong: !!target }); @@ -713,8 +751,8 @@ setStep(3); return; } - // Step 5 — skip: Mastery Rank 1 immediately, calibration stays - // replayable from the Progress screen. + // Calibration step (last) — skip: Mastery Rank 1 immediately, + // calibration stays replayable from the Progress screen. skipBtn.disabled = true; try { const res = await fetch('/api/progression/onboarding', { From b29bab1884dbc4399556484b5aaf913a2970f91b Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sun, 28 Jun 2026 17:50:36 -0500 Subject: [PATCH 72/99] fix(highway_3d): keep the FPS counter from hiding behind the v3 "Up Next" pill (#630) The on-highway FPS readout (Settings -> Graphics -> 3D Highway -> Show FPS counter) is pinned to the top-right of the highway overlay -- the same corner the v3 player chrome stacks its persistent Up Next pill and live-performance HUD into, on a higher layer that paints over the canvas. So the readout sat behind that chrome and couldn't be read, exactly when a tester turned it on to judge performance (and because the pill is default-on it covered the counter regardless of the separate "Up Next won't turn off" report). Keep it top-right (where testers look) but drop it just below whichever of that chrome is showing: measure the lowest visible top-right v3 HUD element (#v3-upnext / #v3-live-performance-hud / #hud-time) and floor the FPS box's Y beneath it. Element refs are resolved once and cached (no per-frame querySelector, per the plugin perf rules) and only read while the counter is actually drawn; gated on window.feedBack.uiVersion === 'v3' so classic v2 is byte-for-byte unaffected. Bump plugin version 3.30.0 -> 3.30.1 (the screen.js cache-buster). Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 1 + plugins/highway_3d/plugin.json | 2 +- plugins/highway_3d/screen.js | 44 +++++++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed349aa..22fdcb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible. +- **3D Highway FPS counter no longer hides behind the v3 "Up Next" pill.** The on-highway FPS readout (Settings → Graphics → 3D Highway → Show FPS counter) is pinned to the top-right of the highway overlay — the same corner the v3 player chrome stacks its persistent **Up Next** pill and live-performance HUD into, on a higher layer that paints over the canvas. So the readout sat *behind* that chrome and couldn't be read — precisely when a tester had turned it on to judge performance (it also made the separate "Up Next won't turn off" complaint worse, since the default-on pill covered the counter regardless). The counter now stays top-right but drops just **below** whichever of that chrome is showing: `highway_3d`'s `screen.js` measures the lowest visible top-right v3 HUD element (`#v3-upnext` / `#v3-live-performance-hud` / `#hud-time`) and floors the FPS box's Y beneath it. Element refs are resolved once and cached (no per-frame `querySelector`, per the plugin perf rules) and only consulted while the counter is actually drawn; gated on `window.feedBack.uiVersion === 'v3'` so the classic (v2) UI is byte-for-byte unaffected. `plugins/highway_3d/plugin.json` version → `3.30.1` (cache-buster). (For reading raw perf numbers unobstructed, the core perf HUD — `localStorage.highwayPerfHud='1'` — still renders above all chrome and additionally shows the adaptive render-scale.) - **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//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). diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index f2005bc..13b88f5 100644 --- a/plugins/highway_3d/plugin.json +++ b/plugins/highway_3d/plugin.json @@ -1,7 +1,7 @@ { "id": "highway_3d", "name": "3D Highway", - "version": "3.30.0", + "version": "3.30.1", "type": "visualization", "bundled": true, "script": "screen.js", diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index b0a8101..fb3109a 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -3325,6 +3325,42 @@ let _fpsEma = 0; let _fpsDisplay = 0; let _fpsLastSampleT = 0; + // The FPS readout is pinned top-right of the highway overlay — the same + // corner the v3 player chrome stacks its persistent "Up Next" pill and + // live-performance HUD into, on a higher layer that paints over the + // canvas. So out of the box the readout sits *behind* that chrome and + // can't be read (exactly when you've turned it on to judge perf). Rather + // than relocate it (testers look top-right), we drop it just BELOW + // whichever of that chrome is showing. Refs are resolved once and cached + // — never a per-frame querySelector (see CLAUDE.md "never run DOM queries + // on a per-frame path") — and re-resolved only when a node detaches. + let _v3HudEls = null; + // Returns the bottom edge (in overlay-canvas px, which are 1:1 CSS px on + // this overlay) of the lowest visible top-right v3 chrome element, or 0 + // when none apply (classic v2 UI, or all hidden). Only called while the + // FPS readout is actually drawn, so the layout reads cost nothing in the + // common (counter-off) case. + function _v3TopRightChromeBottom() { + if (typeof document === 'undefined' || !highwayCanvas) return 0; + // Only the v3 chrome stacks persistent HUD elements over the canvas's + // top-right. Gate on the documented detector so this is a strict no-op + // in classic v2 (where 'hud-time' also exists but sits elsewhere). + if (!(window.feedBack && window.feedBack.uiVersion === 'v3')) return 0; + if (!_v3HudEls || _v3HudEls.some((el) => el && !el.isConnected)) { + _v3HudEls = ['v3-upnext', 'v3-live-performance-hud', 'hud-time'] + .map((id) => document.getElementById(id)); + } + const top = highwayCanvas.getBoundingClientRect().top; + let maxBottom = 0; + for (const el of _v3HudEls) { + // offsetParent === null ⇒ display:none (a `.hidden` pill/HUD) or + // not laid out — don't duck under something that isn't shown. + if (!el || el.offsetParent === null) continue; + const b = el.getBoundingClientRect().bottom - top; + if (b > maxBottom) maxBottom = b; + } + return maxBottom; + } let _diagChord = null; // Chord diagram render cache. Keys: static layout inputs joined as a // string. Values: OffscreenCanvas (or ) rendered at opacity=1 @@ -14557,7 +14593,13 @@ const _fpsBoxW = Math.ceil(_fpsMetrics.width) + _fpsPadX * 2; const _fpsBoxH = 14 + _fpsPadY * 2; const _fpsE = 8; - const _fpsBaseY = Math.round(Math.max(_fpsE + H * 0.06, lyricsBottom + _fpsE)); + // Keep it top-right but below the v3 Up Next pill / live HUD + // (whichever is showing) so the readout is never occluded. + const _fpsBaseY = Math.round(Math.max( + _fpsE + H * 0.06, + lyricsBottom + _fpsE, + _v3TopRightChromeBottom() + _fpsE, + )); const _fpsX = W - 8 - _fpsBoxW; const _fpsY = _fpsBaseY + cornerStack['tr']; lyricsCtx.fillStyle = 'rgba(0,0,0,0.55)'; From 6a71577e058a8993d376c77d4f8d201f812dcdc8 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Mon, 29 Jun 2026 01:49:01 -0500 Subject: [PATCH 73/99] =?UTF-8?q?feat(v3=20library):=20A=E2=80=93Z=20fast-?= =?UTF-8?q?scroll=20jump=20rail=20on=20the=20Songs=20grid=20(#634)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(v3 library): A–Z fast-scroll jump rail on the Songs grid Adds a vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar so you can jump the library to a starting letter — tap, drag-to-scrub with a live letter bubble, or arrow-key between letters. The classic (v2) tree already had letter selection; this brings the new v3 grid to parity (it was the gap behind the "alphabetical scroll selection next to the scrollbar" idea). It shows ONLY for the grid view + alphabetical (artist/title) sorts, and only offers letters present in the current sort AND filter set, so a tap always lands on a real card (absent letters are dimmed + non-interactive). The grid is forward-only, server-paged infinite scroll with no virtualization, so a jump pages through to the target card then scrolls to it; a token guards overlapping jumps (drag) so the newest wins. A keyset-seek + virtualized window is the scaling follow-up for very large libraries. Backend: /api/library/stats gains an optional `sort` param and an additive `sort_letters` map — songs-per-first-letter of the ACTIVE sort column (artist or title), filter-synced — so the rail's present-letters match the grid's real order. The legacy `letters` (distinct-artist) field is unchanged, so the dashboard + classic tree are unaffected. `sort` is dropped for providers whose query_stats predates it (existing kwarg-filter), so third-party library providers keep working (rail simply falls back / hides). Frontend: static/v3/songs.js (refreshRail / jumpToLetter / pointer-drag + keyboard, cards tagged data-letter), static/v3/v3.css (.v3-azrail + bubble). Tests: tests/test_library_filters.py (sort_letters artist/title, song-vs- distinct-artist counting), tests/test_library_providers.py (sort forwarded), tests/js/v3_az_rail.test.js (gating, data-letter, load-through, drag/keys). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * fix(v3 library): harden A–Z jump rail (review P2/P3) Addresses the PR #634 review findings (manual + Codex): P2 correctness - refreshRail prefers the active-sort `sort_letters`; falls back to the artist-based `letters` only on an artist sort, and hides the rail on a title sort when a legacy provider returns none (was mislabeling letters). - reload() bumps `_jumpToken` so an in-flight letter jump can't scroll a grid that's being rebuilt from page 0. - songBucket no longer trims, matching the server SQL + grid ORDER BY raw first-char bucketing (a leading-space title now buckets under '#' on both sides). P3 polish - Paging guard is total-derived (ceil(total/PAGE_SIZE)+2) instead of a magic 4000, keeping large libraries reachable while still bounded. - Roving tabindex: only the first present letter is tabbable; arrow keys move it. Removes up to 27 page tab stops. - `sort_letters` is computed only when the caller opts in (want_sort_letters / route `sort_letters=1`); the dashboard + v2 tree skip the extra GROUP BY. Added sort + want_sort_letters to the optional provider-kwargs so non-introspectable legacy providers drop them. - _railToken supersedes stale refreshRail responses; hide the rail when no letters are present instead of rendering disabled buttons. Tests updated accordingly (v3_az_rail.test.js, test_library_filters.py). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: byrongamatos --- CHANGELOG.md | 1 + server.py | 51 +++++++- static/v3/songs.js | 202 +++++++++++++++++++++++++++++++- static/v3/v3.css | 72 ++++++++++++ tests/js/v3_az_rail.test.js | 85 ++++++++++++++ tests/test_library_filters.py | 41 ++++++- tests/test_library_providers.py | 4 +- 7 files changed, 448 insertions(+), 8 deletions(-) create mode 100644 tests/js/v3_az_rail.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 22fdcb5..15e2e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **A–Z fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`. - **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`. - **v3 Songs: "Add to playlist" is now on each song's ⋮ "More" menu.** Previously a song could only be added to a playlist through select-mode (the checkbox → batch bar). The per-card overflow menu now has an **Add to playlist** row that targets that one song, reusing the same picker (choose a listed number or type a new name to create it). The select-mode batch flow and the single-song menu now share one extracted `addFilenamesToPlaylist(filenames)` helper in `static/v3/songs.js` (both grid and tree rows, since they share `openCardMenu`). Tests: `tests/js/v3_add_to_playlist_menu.test.js`. - **Resume where you left off — leaving a song now snapshots your place so an exit is recoverable, not a restart-from-zero.** Exiting the player (`showScreen()` teardown, before audio unload) writes `{song, arrangement, position, speed}` to `localStorage` (`feedBack.resumeSession`), and a non-blocking **"Resume practice"** pill offers it back on the next non-player screen (and on the next app launch). Clicking Resume re-enters the song, restores the arrangement + playback speed, and seeks to the saved position via the existing `_audioSeek` funnel; `playSong()` gains a `{ resume: {position, speed} }` option that arms a `song:ready`-consumed restore instead of the normal autostart, so the two never fight over playback. The snapshot is deliberately conservative — ignored for a song you barely started (< 3s) or had basically finished (within 5s of the end), cleared on natural song-end and once consumed, and expired after 24h. The pill is self-contained (inline-styled, body-appended, works identically in the classic and v3 shells with no Tailwind rebuild), never blocks, and a dismiss forgets the current snapshot for the session. This pairs with the Escape focus fix: now that Escape reliably leaves regardless of focus, an *accidental* exit is one tap to undo. Public surface: `window.resumeLastSession()` / `window.feedBack.resumeLastSession`. (The broader nav-state work — returning to a song after wandering into Settings → Tone Builder — is a separate, larger track; this lands the player-session slice.) Tests: `tests/browser/resume-session.spec.ts` (snapshot guards, staleness, pill show/hide/dismiss, resume consumption). diff --git a/server.py b/server.py index 0dbf258..0b25d2f 100644 --- a/server.py +++ b/server.py @@ -1995,10 +1995,24 @@ class MetadataDB: stems_lacks: list[str] | None = None, has_lyrics: int | None = None, tunings: list[str] | None = None, + sort: str = "artist", + want_sort_letters: bool = False, naming_mode: str = "legacy") -> dict: """Aggregate stats for the letter bar. Accepts the same filter params as query_page so the letter counts stay synchronized - with the grid when filters are active.""" + with the grid when filters are active. + + `sort` selects the column the v3 jump rail's `sort_letters` + breakdown keys on (artist for artist sorts, title for title + sorts) so the rail's present-letters match the grid's actual + order; other sorts fall back to artist (the rail is hidden for + them client-side anyway). The legacy `letters` field is always + the artist breakdown, unchanged, for the dashboard + classic tree. + + `sort_letters` is computed (and the key included) ONLY when + `want_sort_letters` is set — the jump rail opts in, while the + dashboard / v2 tree read only `letters` and skip the extra + per-letter aggregate scan.""" where, params = self._build_where( q=q, favorites_only=favorites_only, format_filter=format_filter, artist_filter=artist_filter, album_filter=album_filter, @@ -2029,7 +2043,30 @@ class MetadataDB: letters[key] = letters.get(key, 0) + count else: letters["#"] = letters.get("#", 0) + count - return {"total_songs": total, "total_artists": artist_count, "letters": letters} + result = {"total_songs": total, "total_artists": artist_count, "letters": letters} + # Active-sort letter buckets for the v3 jump rail. Counts SONGS (the + # grid's unit, unlike `letters` which counts distinct artists) per + # first-letter bucket of the column the active sort keys on, so a tap + # on a present letter always finds a card. Non-A–Z first chars bucket + # under '#'. Only artist/title sorts are alphabetical; anything else + # keys on artist here but the client hides the rail for it. Computed + # only when the caller opts in, so non-rail callers skip the scan. + if want_sort_letters: + sort_col = "title" if sort in ("title", "title-desc") else "artist" + sort_rows = self.conn.execute( + f"SELECT UPPER(SUBSTR(COALESCE({sort_col}, ''), 1, 1)) AS letter, COUNT(*) " + f"FROM songs {where} GROUP BY letter", params + ).fetchall() + sort_letters: dict[str, int] = {} + for letter, count in sort_rows: + count = int(count or 0) + if count <= 0: + continue + key = str(letter or "") + bucket = key if (key.isascii() and key.isalpha()) else "#" + sort_letters[bucket] = sort_letters.get(bucket, 0) + count + result["sort_letters"] = sort_letters + return result class AudioEffectsMappingDB: @@ -2602,7 +2639,7 @@ def _require_library_provider_capability(provider: object, capability: str) -> N ) -_OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode",) +_OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode", "sort", "want_sort_letters") def _filter_provider_kwargs(method: object, kwargs: dict) -> dict: @@ -4539,15 +4576,21 @@ async def library_stats(favorites: int = 0, q: str = "", format: str = "", arrangements_has: str = "", arrangements_lacks: str = "", stems_has: str = "", stems_lacks: str = "", has_lyrics: str = "", tunings: str = "", provider: str = "local", + sort: str = "artist", sort_letters: int = 0, naming_mode: str = "legacy"): """Aggregate stats for the UI. Accepts the same filter params as - /api/library so the letter bar mirrors the active grid filter set.""" + /api/library so the letter bar mirrors the active grid filter set. + `sort` selects the column the jump rail's `sort_letters` keys on; + `sort_letters=1` opts into that breakdown (the rail), so non-rail + callers skip the extra per-letter aggregate.""" library_provider = _get_library_provider(provider) _require_library_provider_capability(library_provider, "library.read") return await _call_library_provider_async( library_provider, "query_stats", naming_mode=naming_mode, + sort=sort, + want_sort_letters=bool(sort_letters), **_library_filter_args( q=q, favorites=favorites, format=format, artist=artist, album=album, diff --git a/static/v3/songs.js b/static/v3/songs.js index 732d113..aa0d730 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -51,8 +51,34 @@ artistCatalog: [], renderedHash: '', scrollBound: false, songsById: {}, selectMode: false, selected: new Set(), + railLetters: null, railJumping: false, }; + // ── A–Z jump rail ─────────────────────────────────────────────────────── + // Ordered buckets shown on the rail: '#' (non-alphabetic) first, then A–Z. + const RAIL_BUCKETS = ['#'].concat('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')); + // The rail only makes sense for the alphabetical sorts; for recent/year/ + // tuning a letter jump is meaningless, so it's hidden. Returns the column + // the active sort keys on ('artist' | 'title') or null when not alphabetical. + function railSortColumn() { + if (state.sort === 'artist' || state.sort === 'artist-desc') return 'artist'; + if (state.sort === 'title' || state.sort === 'title-desc') return 'title'; + return null; + } + // The bucket a song falls in for the active sort: first char of the sort + // column, uppercased; anything non-A–Z (digits, symbols, accents, blank) + // buckets under '#'. Mirrors the server's letter grouping in query_stats — + // which keys on raw SUBSTR(col, 1, 1) with no trim, and the grid ORDER BY + // is likewise raw, so we must NOT trim here either: a leading-space title + // sorts (and buckets) under '#' on both sides, keeping the rail consistent. + function songBucket(song) { + const col = railSortColumn(); + if (!col) return ''; + const raw = String((col === 'title' ? song.title : song.artist) || ''); + const ch = raw.charAt(0).toUpperCase(); + return (ch >= 'A' && ch <= 'Z') ? ch : '#'; + } + function activeFilterCount() { const f = state.filters; return f.arr_has.length + f.arr_lacks.length + f.stem_has.length + f.stem_lacks.length + @@ -463,7 +489,7 @@ const overlay = overlayActs.length ? '
' + overlayActs.map(actBtn).join('') + '
' : ''; - return '
' + + return '
' + '
' + '' + tuning + checkbox + accuracyBadge(key) + fmtBadge(song) + overlay + @@ -724,6 +750,169 @@ }, { passive: true }); } + // ── A–Z jump rail interaction ───────────────────────────────────────────── + // The rail jumps within the contiguous, server-paged grid. Because the grid + // is forward-only infinite scroll (no virtualization), reaching a letter that + // isn't loaded yet means paging forward until its first card exists, then + // scrolling to it — the same rows the user would have scrolled past. The rail + // only offers letters the server reports as present for the active sort+filter + // (so a tap always terminates at a real card). A keyset-seek + virtualized + // window is the scaling follow-up for very large libraries. + function railEl() { return document.getElementById('v3-songs-azrail'); } + function railBubbleEl() { return document.getElementById('v3-songs-azbubble'); } + function railVisible() { return state.view === 'grid' && !!railSortColumn(); } + + let _railToken = 0; + async function refreshRail() { + const rail = railEl(); + if (!rail) return; + if (!railVisible()) { rail.classList.add('hidden'); railBubbleEl()?.classList.add('hidden'); return; } + const col = railSortColumn(); + // A newer refresh (sort/filter/search/provider change) supersedes this + // one — a slow stats response must not repaint a rail the grid moved on. + const myToken = ++_railToken; + // Present letters for the active sort+filter (filter-synced; counts + // songs). `sort_letters=1` opts into the active-sort breakdown so the + // dashboard / v2 tree (which read only `letters`) skip the extra query. + const stats = await jget('/api/library/stats?' + queryParams({ sort_letters: 1 }).toString()); + if (_railToken !== myToken || !railVisible()) { // changed mid-fetch + if (_railToken === myToken) rail.classList.add('hidden'); + return; + } + // Prefer the active-sort breakdown. `letters` is the artist distinct- + // count, so it only matches the cards on an artist sort; a legacy/third- + // party provider that predates `sort_letters` returns none, in which + // case a title sort would advertise wrong letters — hide the rail then. + let letters = stats && stats.sort_letters; + if (!letters) { + if (col === 'artist') letters = (stats && stats.letters) || {}; + else { rail.classList.add('hidden'); railBubbleEl()?.classList.add('hidden'); return; } + } + state.railLetters = letters; + // No present letters (empty or fully-filtered grid) → nothing to jump + // to; hide the rail instead of rendering a column of disabled buttons. + if (!Object.keys(letters).length) { rail.classList.add('hidden'); railBubbleEl()?.classList.add('hidden'); return; } + const desc = state.sort.endsWith('-desc'); + const order = desc ? RAIL_BUCKETS.slice().reverse() : RAIL_BUCKETS; + // Roving tabindex: only the first present letter is in the tab order; + // the rest are reached with the arrow keys (see bindRailOnce). Avoids + // dumping up to 27 tab stops into the page. + let firstPresent = true; + rail.innerHTML = order.map((L) => { + const n = letters[L] || 0; + const present = n > 0; + const tabbable = present && firstPresent; + if (tabbable) firstPresent = false; + const name = L === '#' ? 'non-alphabetical' : L; + return ''; + }).join(''); + rail.classList.remove('hidden'); + bindRailOnce(); + } + + function _setRailActive(letter) { + railEl()?.querySelectorAll('.v3-azrail-letter').forEach((b) => { + b.classList.toggle('is-active', b.getAttribute('data-letter') === letter); + }); + } + function _showBubble(letter) { const b = railBubbleEl(); if (b) { b.textContent = letter; b.classList.remove('hidden'); } } + function _hideBubble() { railBubbleEl()?.classList.add('hidden'); } + + async function _loadNextAwait() { + if (state.loading) { await _waitForGridIdle(); return loadedCount() < state.total; } + if (loadedCount() >= state.total) return false; + state.page++; + await loadGrid(false); + return loadedCount() < state.total; + } + + let _jumpToken = 0; + async function jumpToLetter(letter) { + const grid = document.getElementById('v3-songs-grid'); + if (!grid || state.view !== 'grid' || !letter) return; + _setRailActive(letter); + const sel = '[data-letter="' + ((window.CSS && CSS.escape) ? CSS.escape(letter) : letter) + '"]'; + const myToken = ++_jumpToken; // a newer jump supersedes this one + // Page forward until the bucket's first card is loaded (or list + // exhausted). The guard is the page count the current total implies + // (+2 slack) rather than a fixed cap, so even a very large library + // stays reachable while a runaway loop is still bounded. + let guard = 0; + const maxPages = Math.ceil((state.total || 0) / PAGE_SIZE) + 2; + while (!grid.querySelector(sel) && loadedCount() < state.total + && _jumpToken === myToken && guard++ < maxPages) { + const more = await _loadNextAwait(); + if (!more) break; + } + if (_jumpToken !== myToken) return; + const target = grid.querySelector(sel); + if (!target) return; + const main = document.getElementById('v3-main'); + const toolbar = document.getElementById('v3-songs-toolbar'); + const pad = (toolbar ? toolbar.offsetHeight : 0) + 12; // clear the sticky toolbar + if (main) { + const top = target.getBoundingClientRect().top - main.getBoundingClientRect().top + main.scrollTop - pad; + main.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }); + } else { + target.scrollIntoView({ block: 'start', behavior: 'smooth' }); + } + } + + function bindRailOnce() { + const rail = railEl(); + if (!rail || rail._bound) return; + rail._bound = true; + let dragging = false, moved = false, lastDrag = null; + const letterAtY = (y) => { + const els = rail.querySelectorAll('.v3-azrail-letter'); + if (!els.length) return null; + for (const el of els) { const r = el.getBoundingClientRect(); if (y >= r.top && y <= r.bottom) return el; } + return y < els[0].getBoundingClientRect().top ? els[0] : els[els.length - 1]; // clamp past ends + }; + rail.addEventListener('click', (e) => { + const btn = e.target.closest('.v3-azrail-letter'); + if (!btn || btn.disabled) return; + if (moved) { moved = false; return; } // a drag already handled it + jumpToLetter(btn.getAttribute('data-letter')); + }); + rail.addEventListener('pointerdown', (e) => { + const btn = e.target.closest('.v3-azrail-letter'); + if (!btn) return; + dragging = true; moved = false; lastDrag = null; + try { rail.setPointerCapture(e.pointerId); } catch (_) { /* */ } + _showBubble(btn.getAttribute('data-letter')); + }); + rail.addEventListener('pointermove', (e) => { + if (!dragging) return; + const el = letterAtY(e.clientY); + if (!el || el.disabled) return; + moved = true; + const L = el.getAttribute('data-letter'); + _showBubble(L); + if (L !== lastDrag) { lastDrag = L; jumpToLetter(L); } // only on change + }); + const end = () => { dragging = false; _hideBubble(); }; + rail.addEventListener('pointerup', end); + rail.addEventListener('pointercancel', end); + rail.addEventListener('keydown', (e) => { + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; + const btns = [...rail.querySelectorAll('.v3-azrail-letter:not([disabled])')]; + const i = btns.indexOf(document.activeElement); + if (i < 0) return; + e.preventDefault(); + const next = btns[i + (e.key === 'ArrowDown' ? 1 : -1)]; + if (next) { + btns[i].setAttribute('tabindex', '-1'); // roving tabindex follows focus + next.setAttribute('tabindex', '0'); + next.focus(); + jumpToLetter(next.getAttribute('data-letter')); + } + }); + } + // Pin the sticky toolbar directly beneath the sticky topbar. Both live in // the #v3-main scroller, so without an explicit offset they share top:0 and // the toolbar covers the topbar's song search. The topbar has two responsive @@ -913,12 +1102,19 @@ // state.q) and needs a refresh rather than a scroll-preserving no-op. state.renderedHash = _libraryStateHash(); updateFilterBadge(); + // A sort/filter/search/view change rebuilds the grid from page 0, so any + // in-flight letter jump is now paging through a dataset that's about to + // be discarded — supersede it so it can't scroll the rebuilt grid. + _jumpToken++; // Keep a handle on the load so callers (notably the scroll restore on // screen re-entry) can await page-0 actually landing before paging // deeper. The visibility/scroll resets below stay synchronous. document.getElementById('v3-songs-grid')?.classList.toggle('hidden', state.view !== 'grid'); document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree'); document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder'); + // Refresh the A–Z jump rail (shows only for the grid + alphabetical + // sorts; hides itself otherwise). Independent of the grid load. + refreshRail(); { const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; } if (state.view === 'folder') { _applyMainScrollTop(0); @@ -985,6 +1181,10 @@ '' + '' + '
' + + // A–Z jump rail (grid + alphabetical sorts only; populated by + // refreshRail). The bubble shows the current letter while dragging. + '' + + '' + // Filter drawer + overlay '' + '' + diff --git a/static/v3/v3.css b/static/v3/v3.css index 996c072..7c1a70a 100644 --- a/static/v3/v3.css +++ b/static/v3/v3.css @@ -1117,3 +1117,75 @@ html.fb-immersive #v3-main > .screen.active { inset: 0; overflow: hidden; } + +/* — A–Z jump rail (v3 Songs grid; static/v3/songs.js) — */ +/* Fixed to the right edge next to the scroller's scrollbar; vertically + centered. Shown only for the grid view + alphabetical (artist/title) sorts. */ +.v3-azrail { + position: fixed; + right: 2px; + top: 50%; + transform: translateY(-50%); + z-index: 25; + display: flex; + flex-direction: column; + align-items: center; + max-height: 84vh; + padding: 4px 1px; + user-select: none; + -webkit-user-select: none; + touch-action: none; /* let a drag scrub the rail without scrolling the page */ +} +.v3-azrail-letter { + appearance: none; + -webkit-appearance: none; + background: none; + border: 0; + color: #94a3b8; /* fb-textDim */ + font-size: .62rem; + font-weight: 700; + line-height: 1.05; + padding: 1px 4px; + margin: 0; + cursor: pointer; + border-radius: 4px; +} +.v3-azrail-letter:hover:not([disabled]), +.v3-azrail-letter.is-active { + color: #0ea5e9; /* fb-primary */ +} +.v3-azrail-letter:focus-visible { + outline: 2px solid #38bdf8; /* fb-primaryHi */ + outline-offset: 1px; +} +.v3-azrail-letter[disabled] { + color: rgba(148, 163, 184, .28); + cursor: default; +} +/* Drag indicator bubble (Android fast-scroll pattern). */ +.v3-azbubble { + position: fixed; + right: 2.6rem; + top: 50%; + transform: translateY(-50%); + z-index: 26; + width: 2.6rem; + height: 2.6rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: .7rem; + background: #0ea5e9; /* fb-primary */ + color: #f8fafc; /* fb-text */ + font-size: 1.15rem; + font-weight: 800; + box-shadow: 0 6px 22px rgba(0, 0, 0, .45); + pointer-events: none; +} +.v3-azrail.hidden, +.v3-azbubble.hidden { display: none; } +/* Coarse-pointer / short viewports: the 27-letter rail can crowd a phone edge. + Tighten it; a collapse-to-anchors pass is a follow-up. */ +@media (max-height: 640px) { + .v3-azrail-letter { font-size: .55rem; padding: 0 4px; } +} diff --git a/tests/js/v3_az_rail.test.js b/tests/js/v3_az_rail.test.js new file mode 100644 index 0000000..047e725 --- /dev/null +++ b/tests/js/v3_az_rail.test.js @@ -0,0 +1,85 @@ +// Pins the v3 Songs A–Z jump rail wiring in static/v3/songs.js. +// +// The rail lets a user jump the library grid to artists/titles starting with a +// letter (Plex/Radarr/iOS-contacts pattern). Because the grid is forward-only, +// server-paged infinite scroll, the jump pages through to the target card then +// scrolls — and the rail only offers letters the server reports present for the +// active sort+filter (so a tap always terminates at a real card). It is shown +// only for the grid view + alphabetical (artist/title) sorts. +// +// Source-level only — same strategy as tests/js/highway_3d_camera_framing.test.js. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js'); +const src = fs.readFileSync(SONGS_JS, 'utf8'); + +test('the rail is context-gated to grid view + alphabetical sorts', () => { + // railSortColumn returns the active alpha column or null (recent/year/tuning). + assert.match(src, /function\s+railSortColumn\s*\(\)/); + assert.match(src, /state\.sort === 'artist'[\s\S]*?return 'artist'/); + assert.match(src, /state\.sort === 'title'[\s\S]*?return 'title'/); + assert.match( + src, + /function\s+railVisible\s*\(\)\s*\{\s*return\s+state\.view === 'grid'\s*&&\s*!!railSortColumn\(\)/, + 'the rail must be visible only for the grid view + an alphabetical sort', + ); +}); + +test('cards carry a data-letter bucket and non-A–Z buckets under #', () => { + assert.match(src, /data-letter="'\s*\+\s*esc\(songBucket\(song\)\)/, + 'each card must tag its sort-letter bucket via songBucket(song)'); + assert.match( + src, + /function\s+songBucket[\s\S]*?\(ch >= 'A' && ch <= 'Z'\)\s*\?\s*ch\s*:\s*'#'/, + 'songBucket must bucket non-A–Z first chars under "#"', + ); +}); + +test('refreshRail reads present letters from the stats endpoint (sort-aware)', () => { + assert.match(src, /\/api\/library\/stats\?'\s*\+\s*queryParams/, + 'refreshRail must query /api/library/stats with the active filter params'); + // Opts into the active-sort breakdown so non-rail callers skip the scan. + assert.match(src, /queryParams\(\{\s*sort_letters:\s*1\s*\}\)/, + 'refreshRail must request the sort_letters breakdown'); + assert.match(src, /letters\s*=\s*stats\s*&&\s*stats\.sort_letters/, + 'refreshRail must prefer the active-sort breakdown (sort_letters)'); + // The legacy artist `letters` is only a valid fallback for an artist sort; + // a title sort with no sort_letters hides the rail rather than mislabel it. + assert.match(src, /col === 'artist'[\s\S]*?stats\.letters/, + 'refreshRail must only fall back to letters for an artist sort'); + // Absent letters are disabled (non-interactive), not just dimmed. + assert.match(src, /present\s*\?\s*''\s*:\s*' disabled'/); +}); + +test('reload() refreshes the rail', () => { + assert.match(src, /function reload\s*\([\s\S]*?refreshRail\(\)/, + 'reload() must call refreshRail() so the rail tracks filter/sort/view changes'); +}); + +test('the rail + drag bubble are rendered in the Songs markup', () => { + assert.match(src, /id="v3-songs-azrail"[\s\S]*?aria-label="Jump to letter"/); + assert.match(src, /id="v3-songs-azbubble"/); +}); + +test('jumpToLetter pages through to the target then scrolls (load-through)', () => { + // Forward-paging helper used to load rows up to the target letter. + assert.match(src, /async function\s+_loadNextAwait\s*\(\)/); + assert.match( + src, + /async function\s+jumpToLetter[\s\S]*?_loadNextAwait\(\)[\s\S]*?(scrollTo|scrollIntoView)/, + 'jumpToLetter must page forward (_loadNextAwait) then scroll to the target card', + ); + // A token guards against overlapping jumps (drag scrubbing) — newest wins. + assert.match(src, /_jumpToken\s*===\s*myToken/); +}); + +test('the rail supports pointer drag-scrub + keyboard arrows', () => { + assert.match(src, /addEventListener\('pointerdown'/); + assert.match(src, /addEventListener\('pointermove'/); + assert.match(src, /ArrowUp'[\s\S]*?ArrowDown'|ArrowDown'[\s\S]*?ArrowUp'/, + 'arrow keys must move between present letters'); +}); diff --git a/tests/test_library_filters.py b/tests/test_library_filters.py index 1f09795..3205243 100644 --- a/tests/test_library_filters.py +++ b/tests/test_library_filters.py @@ -398,6 +398,39 @@ def test_query_stats_groups_non_ascii_artist_letters_under_hash(client, server_m assert stats["letters"] == {"#": 1} +def test_query_stats_sort_letters_artist_counts_songs(client, server_mod): + """The v3 jump rail's `sort_letters` counts SONGS per first-letter bucket + of the active sort column (vs `letters`, which counts distinct artists). + Two songs by the same A-artist → letters {A:1}, sort_letters {A:2}.""" + _put(server_mod, filename="a1.archive", title="Song One", artist="Abba") + _put(server_mod, filename="a2.archive", title="Song Two", artist="Abba") + _put(server_mod, filename="b1.archive", title="Another", artist="Beck") + _put(server_mod, filename="num.archive", title="Track", artist="2Pac") + + # sort_letters=1 opts into the active-sort breakdown (the jump rail path). + stats = client.get("/api/library/stats", params={"sort": "artist", "sort_letters": 1}).json() + assert stats["letters"] == {"A": 1, "B": 1, "#": 1} # distinct artists + assert stats["sort_letters"] == {"A": 2, "B": 1, "#": 1} # songs + + # Without the opt-in, the extra breakdown is not computed or returned. + plain = client.get("/api/library/stats", params={"sort": "artist"}).json() + assert "sort_letters" not in plain + assert plain["letters"] == {"A": 1, "B": 1, "#": 1} + + +def test_query_stats_sort_letters_follow_title_sort(client, server_mod): + """With a title sort, the rail buckets key on the TITLE's first letter, + not the artist's, so a tap lands on a real card in the grid's order.""" + _put(server_mod, filename="z1.archive", title="Apple", artist="Zztop") + _put(server_mod, filename="z2.archive", title="Banana", artist="Zztop") + + stats = client.get("/api/library/stats", params={"sort": "title", "sort_letters": 1}).json() + assert stats["sort_letters"] == {"A": 1, "B": 1} + # The legacy artist breakdown is unchanged regardless of sort — both songs + # share one artist, so it stays a single distinct-artist Z bucket. + assert stats["letters"] == {"Z": 1} + + def test_query_stats_ignores_null_letter_counts(server_mod): """Legacy/corrupt rows can surface as NULL-ish letter aggregate rows on some SQLite builds. The stats endpoint should ignore those @@ -429,9 +462,13 @@ def test_query_stats_ignores_null_letter_counts(server_mod): server_mod.meta_db.conn.close() server_mod.meta_db.conn = FakeConn() - stats = server_mod.meta_db.query_stats() + stats = server_mod.meta_db.query_stats(want_sort_letters=True) - assert stats == {"total_songs": 1, "total_artists": 1, "letters": {"T": 1}} + # `sort_letters` (the v3 jump-rail breakdown) shares the GROUP BY letter + # path in this fake, so it surfaces the same single live bucket when the + # caller opts in. + assert stats == {"total_songs": 1, "total_artists": 1, + "letters": {"T": 1}, "sort_letters": {"T": 1}} def test_compound_sort_with_legacy_dir_desc_doesnt_error(client, seeded): diff --git a/tests/test_library_providers.py b/tests/test_library_providers.py index 8a06874..b6e7b6f 100644 --- a/tests/test_library_providers.py +++ b/tests/test_library_providers.py @@ -213,7 +213,9 @@ def test_registered_provider_handles_library_endpoints(server_mod, client): assert stats["letters"] == {"R": 1} assert "page" not in provider.stats_kwargs assert "size" not in provider.stats_kwargs - assert "sort" not in provider.stats_kwargs + # `sort` is forwarded to query_stats now (the v3 jump rail keys its + # present-letter breakdown on the active sort column); defaults to "artist". + assert provider.stats_kwargs.get("sort") == "artist" tunings = client.get("/api/library/tuning-names", params={"provider": "remote:frodo"}).json() assert tunings["tunings"][0]["name"] == "E Standard" From 6a6efc793a642b45ec67df454c6958a8f5db4683 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Mon, 29 Jun 2026 01:56:31 -0500 Subject: [PATCH 74/99] =?UTF-8?q?feat(v3=20library):=20practice-aware=20ho?= =?UTF-8?q?me=20=E2=80=94=20Repertoire=20meter=20+=20"Keep=20practicing"?= =?UTF-8?q?=20shelf=20(#635)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(v3 library): practice-aware home — Repertoire meter + "Keep practicing" shelf The Songs page opened cold into a flat sorted grid. This adds a practice-aware front door on the unfiltered grid, built entirely from data already on hand (no new endpoints, no new stored state): - Repertoire meter — "Repertoire: N of M songs · K in progress" + a bar, counting songs at/above the same mastery threshold the green accuracy badge uses (>= 0.9 best accuracy) over the unfiltered library total. Reads state.accuracy (/api/stats/best, already loaded for the card badges) and the unfiltered /api/library/stats total. - "Keep practicing" shelf — a horizontal row of recently-played, not-yet- mastered songs (newest first, click to play). Reads /api/stats/recent. Both show ONLY on the grid view when not searching/filtering/selecting (the front-door context), refresh after a song is scored (applyScoreRefresh), and collapse on an empty library. Soft-gamification only: descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging — the practice-accuracy "continue" rail a media server can't do. Frontend-only: static/v3/songs.js (renderLibraryHome / _repertoireCounts / libHomeVisible, wired through reload() + applyScoreRefresh), static/v3/v3.css. Came out of the library design charrette (UX + gamification lenses' top pick). Stacked on the A–Z rail branch (feat/v3-library-az-rail) since both touch static/v3/songs.js; merge that PR first (or retarget). Tests: tests/js/v3_keep_practicing.test.js (threshold, front-door gating, shelf filter, denominator, render/reload/score-refresh wiring, click-to-play). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * fix(v3 library): correct practice-aware home for review P1/P2/P3 Addresses the PR #635 review findings (manual + Codex): P1 correctness - Gate the Repertoire meter + "Keep practicing" shelf to the LOCAL provider (libHomeVisible). They read local practice stats (state.accuracy / /api/stats/recent); on a remote provider they mixed a local mastered count with a remote song total (e.g. "85 of 80") and the shelf played local files while browsing a remote library. - Shelf now gates on the per-SONG best (state.accuracy[filename] = MAX across arrangements, what the green badge shows) and dedupes by filename, instead of the per-arrangement recents row — so a "keep practicing" card can no longer show a green "mastered" badge, and a song can't appear twice. P2 robustness - renderLibraryHome fetches /api/library/stats + /api/stats/recent together (Promise.all) and a _homeToken generation guard discards a stale render so a slow response can't repaint a home the grid already moved past. P3 polish - accuracyBadge references MASTERY_ACCURACY instead of a bare 0.9, so the badge and the meter/shelf can't drift from "the same mastery threshold". Tests updated (v3_keep_practicing.test.js): provider gating, per-song deduped shelf, Promise.all + token. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: byrongamatos --- CHANGELOG.md | 1 + static/v3/songs.js | 134 +++++++++++++++++++++++++++- static/v3/v3.css | 30 +++++++ tests/js/v3_keep_practicing.test.js | 74 +++++++++++++++ 4 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 tests/js/v3_keep_practicing.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 15e2e65..b230ad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Practice-aware library home — a "Repertoire" meter + a "Keep practicing" shelf on the v3 Songs page.** The library opened cold into a flat sorted grid; now the unfiltered grid front door leads with two practice-aware surfaces built entirely from data already on hand (no new endpoints or stored state). A **Repertoire meter** shows how much of your library you can actually play — *"Repertoire: 12 of 80 songs · 7 in progress"* with a progress bar — counting songs at or above the same mastery threshold the green accuracy badge uses (≥ 90% best accuracy) over the unfiltered library total. A **"Keep practicing" shelf** is a horizontal row of your recently-played-but-not-yet-mastered songs (newest first, click to play) — the practice-accuracy-driven "continue" rail a media server can't do. Both reuse `/api/stats/best` (already loaded for the card badges) + `/api/stats/recent`; they show **only** on the grid view when you aren't searching/filtering/selecting, refresh after a song is scored, and collapse to nothing on an empty library. Soft-gamification only — descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging. Frontend-only: `static/v3/songs.js` (`renderLibraryHome`/`_repertoireCounts`), `static/v3/v3.css`. Came out of the library design charrette (the UX + gamification lenses' top pick). Tests: `tests/js/v3_keep_practicing.test.js`. - **A–Z fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`. - **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`. - **v3 Songs: "Add to playlist" is now on each song's ⋮ "More" menu.** Previously a song could only be added to a playlist through select-mode (the checkbox → batch bar). The per-card overflow menu now has an **Add to playlist** row that targets that one song, reusing the same picker (choose a listed number or type a new name to create it). The select-mode batch flow and the single-song menu now share one extracted `addFilenamesToPlaylist(filenames)` helper in `static/v3/songs.js` (both grid and tree rows, since they share `openCardMenu`). Tests: `tests/js/v3_add_to_playlist_menu.test.js`. diff --git a/static/v3/songs.js b/static/v3/songs.js index aa0d730..06180d3 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -349,11 +349,11 @@ if (acc == null) return ''; const pct = Math.round(acc * 100); if (variant === 'tree') { - const color = acc >= 0.9 ? 'text-fb-good' : acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low'; + const color = acc >= MASTERY_ACCURACY ? 'text-fb-good' : acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low'; return '' + pct + '%'; } - const color = acc >= 0.9 ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low'); - const text = acc >= 0.5 && acc < 0.9 ? 'text-black' : 'text-white'; + const color = acc >= MASTERY_ACCURACY ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low'); + const text = acc >= 0.5 && acc < MASTERY_ACCURACY ? 'text-black' : 'text-white'; return '' + '' + pct + '%'; } @@ -404,6 +404,126 @@ const keys = Array.from(_dirtyScores); _dirtyScores.clear(); keys.forEach(repaintAccuracy); + // A new score shifts the repertoire meter + the keep-practicing shelf. + renderLibraryHome(); + } + + // ── Practice-aware library home (repertoire meter + "Keep practicing") ───── + // Both read data we already have: state.accuracy (/api/stats/best = + // {filename: best_accuracy}) and /api/stats/recent. A song is "in your + // repertoire" at the same threshold the green accuracy badge uses (>= 0.9); + // a started song below that is "in progress". This is descriptive + // encouragement — it never gates content, decays, or nags (the goal-gradient + // / endowed-progress idea, kept healthy). + const MASTERY_ACCURACY = 0.9; + + function _repertoireCounts() { + let mastered = 0, learning = 0; + for (const v of Object.values(state.accuracy || {})) { + if (typeof v !== 'number') continue; + if (v >= MASTERY_ACCURACY) mastered++; else learning++; + } + return { mastered, learning }; + } + + // The home block is the unfiltered "front door": shown on the grid view when + // the user isn't running a focused query (search / filter) or selecting. + // Local provider only — the meter's mastered count and the shelf both read + // local practice stats (state.accuracy / /api/stats/recent), so on a remote + // provider they'd mix local numerators with a remote song total and play + // local files while browsing a remote library. Hide it there. + function libHomeVisible() { + return state.view === 'grid' && state.provider === 'local' + && !state.selectMode && !state.q && activeFilterCount() === 0; + } + + let _homeToken = 0; + async function renderLibraryHome() { + const host = document.getElementById('v3-lib-home'); + if (!host) return; + if (!libHomeVisible()) { host.classList.add('hidden'); return; } + // A newer render (view/filter/score change) supersedes this one so a + // slow response can't repaint a home the grid already moved past. + const myToken = ++_homeToken; + // Unfiltered library size for the meter denominator (the grid's + // state.total tracks the active filter; the meter is library-wide) + + // recently-played rows for the shelf, fetched together. + const [stats, recent] = await Promise.all([ + jget('/api/library/stats?provider=' + enc(state.provider)), + jget('/api/stats/recent?limit=24'), + ]); + if (_homeToken !== myToken || !libHomeVisible()) { // changed mid-fetch + if (_homeToken === myToken) host.classList.add('hidden'); + return; + } + const total = (stats && (stats.total_songs ?? stats.total)) || 0; + if (total <= 0) { host.classList.add('hidden'); return; } // empty library + // Shelf = recently-played, not-yet-mastered songs, newest first. Mastery + // is per-SONG (state.accuracy = MAX best across arrangements, what the + // green badge shows) — recents are per-(song,arrangement), so dedupe by + // filename and gate on the song's best, keeping the shelf and its badges + // consistent (no green-badged "keep practicing" card, no dupes). + const acc = state.accuracy || {}; + const seen = new Set(); + const shelf = (Array.isArray(recent) ? recent : []) + .filter((r) => { + if (!r || seen.has(r.filename)) return false; + const best = acc[r.filename]; + if (typeof best !== 'number' || best >= MASTERY_ACCURACY) return false; + seen.add(r.filename); + return true; + }) + .slice(0, 8); + + const { mastered, learning } = _repertoireCounts(); + const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100))); + const meter = + '
' + + '
' + + 'Repertoire' + + '' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') + + (learning ? ' · ' + learning + ' in progress' : '') + '' + + '
' + + '
' + + '
'; + + let shelfHtml = ''; + if (shelf.length) { + const cards = shelf.map((r) => + '').join(''); + shelfHtml = + '
' + + '

Keep practicing

' + + '
' + cards + '
' + + '
'; + } + + host.innerHTML = meter + shelfHtml; + host.classList.remove('hidden'); + // Wire shelf cards → play (mirrors playCard's local path; recents are + // always local-library rows, so no provider sync is needed). + host.querySelectorAll('.v3-kp-card').forEach((btn) => btn.addEventListener('click', () => { + const fn = btn.getAttribute('data-kp'); + const arr = btn.getAttribute('data-arr'); + if (!fn || !window.playSong) return; + _saveLibraryScrollSnapshot(); + window.playSong(enc(fn), arr === '' ? undefined : Number(arr)); + })); + } + + // Toggle/refresh the home block on view/sort/filter/search changes. + function updateLibraryHome() { + const host = document.getElementById('v3-lib-home'); + if (!host) return; + if (!libHomeVisible()) { host.classList.add('hidden'); return; } + renderLibraryHome(); } // Source format of a song — prefer the server's `format` field, fall back @@ -1115,6 +1235,9 @@ // Refresh the A–Z jump rail (shows only for the grid + alphabetical // sorts; hides itself otherwise). Independent of the grid load. refreshRail(); + // Refresh the practice-aware home (repertoire meter + keep-practicing + // shelf); hides itself when searching/filtering/selecting or off-grid. + updateLibraryHome(); { const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; } if (state.view === 'folder') { _applyMainScrollTop(0); @@ -1176,6 +1299,11 @@ '' + '' + '
' + + // Practice-aware library home: a repertoire progress meter + a + // "Keep practicing" shelf of started-but-not-mastered songs. Shown + // only on the grid view when not searching/filtering/selecting + // (renderLibraryHome + updateLibraryHome). Empty/absent → collapses. + '' + '
' + '' + '' + diff --git a/static/v3/v3.css b/static/v3/v3.css index 7c1a70a..b13dd2c 100644 --- a/static/v3/v3.css +++ b/static/v3/v3.css @@ -1189,3 +1189,33 @@ html.fb-immersive #v3-main > .screen.active { @media (max-height: 640px) { .v3-azrail-letter { font-size: .55rem; padding: 0 4px; } } + +/* — Practice-aware library home: repertoire meter + "Keep practicing" shelf — */ +#v3-lib-home.hidden { display: none; } +.v3-rep-meter { max-width: 30rem; } +.v3-rep-track { + height: 6px; + border-radius: 999px; + background: rgba(148, 163, 184, .22); /* fb-textDim @ low alpha */ + overflow: hidden; +} +.v3-rep-fill { + height: 100%; + border-radius: 999px; + background: #0ea5e9; /* fb-primary */ + transition: width .4s ease; +} +/* Horizontal, scroll-snapping shelf of fixed-width cards. */ +.v3-kp-row { + display: flex; + gap: .75rem; + overflow-x: auto; + scroll-snap-type: x proximity; + padding-bottom: 6px; + -webkit-overflow-scrolling: touch; +} +.v3-kp-card { + flex: 0 0 8.5rem; + width: 8.5rem; + scroll-snap-align: start; +} diff --git a/tests/js/v3_keep_practicing.test.js b/tests/js/v3_keep_practicing.test.js new file mode 100644 index 0000000..9e395cc --- /dev/null +++ b/tests/js/v3_keep_practicing.test.js @@ -0,0 +1,74 @@ +// Pins the practice-aware library home in static/v3/songs.js: +// - a "Repertoire" progress meter (mastered / total library songs), and +// - a "Keep practicing" shelf (recently played, not yet mastered). +// Both reuse existing data (/api/stats/best already in state.accuracy, and +// /api/stats/recent) and are shown only on the unfiltered grid front door. +// +// Source-level only — same strategy as tests/js/v3_az_rail.test.js. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js'); +const src = fs.readFileSync(SONGS_JS, 'utf8'); + +test('repertoire uses the same mastery threshold as the green accuracy badge', () => { + assert.match(src, /const\s+MASTERY_ACCURACY\s*=\s*0\.9/); + assert.match( + src, + /function\s+_repertoireCounts[\s\S]*?v\s*>=\s*MASTERY_ACCURACY\s*\)\s*mastered\+\+;\s*else\s+learning\+\+/, + 'repertoire counts must bucket scored songs into mastered/learning at MASTERY_ACCURACY', + ); +}); + +test('the home is the unfiltered grid front door, local provider only', () => { + assert.match( + src, + /function\s+libHomeVisible[\s\S]*?state\.view === 'grid'[\s\S]*?state\.provider === 'local'[\s\S]*?!state\.selectMode[\s\S]*?!state\.q[\s\S]*?activeFilterCount\(\)\s*===\s*0/, + 'libHomeVisible must require grid view, the local provider, no select mode, no search, no active filters', + ); +}); + +test('the shelf is recently-played, not-yet-mastered songs (per-song, deduped)', () => { + assert.match(src, /\/api\/stats\/recent\?limit=/); + // Mastery is gated on the per-SONG best (state.accuracy, what the badge + // shows), not the per-arrangement recents row, and each filename appears + // once — so no green-badged "keep practicing" card and no duplicates. + assert.match( + src, + /const\s+best\s*=\s*acc\[r\.filename\][\s\S]*?best\s*>=\s*MASTERY_ACCURACY/, + 'the shelf must gate on the per-song best (state.accuracy) at MASTERY_ACCURACY', + ); + assert.match(src, /seen\.has\(r\.filename\)/, 'the shelf must dedupe recents by filename'); +}); + +test('the meter + shelf fetch together and a stale render is discarded', () => { + assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?stats\/recent/, + 'the two reads must be issued together (Promise.all), not sequentially'); + assert.match(src, /_homeToken[\s\S]*?_homeToken !== myToken/, + 'a stale render must be superseded by a newer one via a token'); +}); + +test('the repertoire denominator is the unfiltered library total', () => { + assert.match(src, /\/api\/library\/stats\?provider='/); + assert.match(src, /total_songs\s*\?\?\s*stats\.total/); + assert.match(src, /Math\.round\(\(mastered\s*\/\s*total\)\s*\*\s*100\)/); +}); + +test('the home + #v3-lib-home host are wired into render and reload', () => { + assert.match(src, /id="v3-lib-home"/, 'render() must include the #v3-lib-home host'); + assert.match(src, /function reload\s*\([\s\S]*?updateLibraryHome\(\)/, + 'reload() must refresh/toggle the home'); + assert.match(src, /function applyScoreRefresh[\s\S]*?renderLibraryHome\(\)/, + 'a new score must refresh the meter + shelf'); +}); + +test('shelf cards play the song on click', () => { + assert.match( + src, + /querySelectorAll\('\.v3-kp-card'\)[\s\S]*?window\.playSong\(enc\(fn\)/, + 'a shelf card click must call window.playSong with the recents filename', + ); +}); From 07ab902604bd9b92b0d390909c32e925cafe17f8 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Mon, 29 Jun 2026 09:34:47 +0200 Subject: [PATCH 75/99] feat(settings): back up the library DB + custom art in the export bundle (#639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the dev-ops lens's #1 finding from the library charrette (got-feedback/feedBack#636 item 1): scores, favorites, playlists, and play history — the only library state a rescan can't rebuild — were absent from the settings backup. Now GET /api/settings/export carries an additive `core_server_files` section: - a CONSISTENT snapshot of web_library.db via the SQLite online-backup API (a complete single file even while the server runs; taken under the MetadataDB write lock), base64-encoded; - custom playlist covers + avatar (CONFIG_DIR/playlist_covers, /avatars), walked with the existing _walk_export_paths machinery. Restore is DB-safe: - POST /api/settings/import STAGES the DB to web_library.db.restore (never over the live, open file); _apply_pending_db_restore swaps it in at the next startup BEFORE the connection opens, clearing stale -wal/-shm so a stale WAL can't be replayed onto the restored file. Response sets `restart_required` + a warning; custom art applies immediately. - The staged DB is integrity-checked (open + PRAGMA quick_check) at import AND again at startup before the live DB is touched — a corrupt/truncated restore is refused/discarded and the live DB is left intact, so a bad bundle can never brick startup or lose data. - Export hard-fails (500) if the snapshot can't be produced (no silent DB-less backup); a partial import disarms its own staged restore. Backward-compatible: older servers ignore the new section; a bundle without it imports as before. Known gap: custom uploaded *song* art is still commingled with the rebuildable thumbnail cache in art_cache/, so it isn't bundled yet (tracked follow-up on #636). Tests: tests/test_settings_export_library_db.py (snapshot consistency, staged-not-live restore, sidecar clearing, corrupt-DB refusal at import + startard, traversal rejection, export hard-fail, disarm-on-failure, full round-trip). Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + server.py | 219 ++++++++++++++++ tests/test_settings_export_library_db.py | 311 +++++++++++++++++++++++ 3 files changed, 531 insertions(+) create mode 100644 tests/test_settings_export_library_db.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b230ad4..ac34554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **The settings backup now includes your library database + custom art — your scores, favorites, playlists, and play history are no longer the one thing a backup can't save.** `GET /api/settings/export` gains an additive `core_server_files` section carrying a **consistent snapshot of `web_library.db`** (taken via the SQLite online-backup API, so it's a complete single file even while the server is running) plus any custom **playlist covers** and **avatar** (`CONFIG_DIR/playlist_covers/`, `CONFIG_DIR/avatars/`). On `POST /api/settings/import` the database is **staged** to `web_library.db.restore` rather than written over the live, open DB; it's swapped in at the next startup (`_apply_pending_db_restore`, before the connection opens), which also clears the old WAL sidecars so a stale `-wal` can't be replayed onto the restored file — the import response sets `restart_required: true` and warns accordingly. Custom art is written immediately. The bundle stays backward-compatible (older servers ignore the new section). Came out of the library design charrette (dev-ops lens's top "protect irreplaceable data" pick, got-feedback/feedBack#636). _Known gap:_ custom uploaded **song** art is still commingled with the rebuildable thumbnail cache in `art_cache/`, so it isn't bundled yet (a tracked follow-up). Tests: `tests/test_settings_export_library_db.py` (snapshot consistency, staged-not-live restore, sidecar clearing, traversal rejection, full round-trip). - **Practice-aware library home — a "Repertoire" meter + a "Keep practicing" shelf on the v3 Songs page.** The library opened cold into a flat sorted grid; now the unfiltered grid front door leads with two practice-aware surfaces built entirely from data already on hand (no new endpoints or stored state). A **Repertoire meter** shows how much of your library you can actually play — *"Repertoire: 12 of 80 songs · 7 in progress"* with a progress bar — counting songs at or above the same mastery threshold the green accuracy badge uses (≥ 90% best accuracy) over the unfiltered library total. A **"Keep practicing" shelf** is a horizontal row of your recently-played-but-not-yet-mastered songs (newest first, click to play) — the practice-accuracy-driven "continue" rail a media server can't do. Both reuse `/api/stats/best` (already loaded for the card badges) + `/api/stats/recent`; they show **only** on the grid view when you aren't searching/filtering/selecting, refresh after a song is scored, and collapse to nothing on an empty library. Soft-gamification only — descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging. Frontend-only: `static/v3/songs.js` (`renderLibraryHome`/`_repertoireCounts`), `static/v3/v3.css`. Came out of the library design charrette (the UX + gamification lenses' top pick). Tests: `tests/js/v3_keep_practicing.test.js`. - **A–Z fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`. - **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`. diff --git a/server.py b/server.py index 0b25d2f..ce81442 100644 --- a/server.py +++ b/server.py @@ -364,9 +364,73 @@ def _ensure_smart_names(arrangements: list[dict]) -> list[dict]: return arrangements +def _sqlite_file_integrity_ok(path: Path) -> bool: + """True if `path` is a SQLite database that opens and passes + `PRAGMA quick_check`. Used to gate a DB restore so a truncated or + corrupt snapshot can never overwrite the live library DB.""" + try: + with open(path, "rb") as f: + if f.read(16) != b"SQLite format 3\x00": # cheap header gate, no full read + return False + except OSError: + return False + conn = None + try: + conn = sqlite3.connect(str(path)) + row = conn.execute("PRAGMA quick_check").fetchone() + return bool(row) and row[0] == "ok" + except sqlite3.Error: + return False + finally: + if conn is not None: + conn.close() + # quick_check on a non-WAL file makes no sidecars, but a malformed + # file can; sweep them so a probe never litters config_dir. + for suffix in ("-wal", "-shm"): + try: + path.with_name(path.name + suffix).unlink() + except FileNotFoundError: + pass + + +def _apply_pending_db_restore(config_dir: Path) -> None: + """Swap in a library DB restored from a settings bundle, if one is + staged. A settings import writes the restored snapshot to + `web_library.db.restore` rather than over the live DB (the running + server holds the old file open, and a stale `-wal`/`-shm` could be + replayed onto a fresh main file → corruption). The swap happens here, + at startup, BEFORE the connection opens: delete the old DB and its WAL + sidecars, then rename the staged snapshot into place. The snapshot is a + fully-checkpointed single file (SQLite online-backup API), so it needs + no sidecars of its own. Idempotent and a no-op when nothing is staged. + + The staged file is re-validated here before anything is destroyed: a + restore that fails its integrity check is discarded and the live DB is + left untouched, so a bad bundle can never brick startup or lose data.""" + pending = config_dir / "web_library.db.restore" + if not pending.exists(): + return + if not _sqlite_file_integrity_ok(pending): + log.error("pending library DB restore failed its integrity check; " + "discarding it and keeping the existing database") + try: + pending.unlink() + except FileNotFoundError: + pass + return + for suffix in ("", "-wal", "-shm"): + try: + (config_dir / f"web_library.db{suffix}").unlink() + except FileNotFoundError: + pass + os.replace(pending, config_dir / "web_library.db") + log.info("applied pending library DB restore from settings import") + + class MetadataDB: def __init__(self): CONFIG_DIR.mkdir(parents=True, exist_ok=True) + _apply_pending_db_restore(CONFIG_DIR) self.db_path = str(CONFIG_DIR / "web_library.db") self.conn = sqlite3.connect(self.db_path, check_same_thread=False) self.conn.execute("PRAGMA journal_mode=WAL") @@ -6217,6 +6281,82 @@ def _atomic_write_file(target: Path, payload: bytes): raise +# Core (non-plugin) server-side state that the settings bundle backs up +# alongside config.json. The library DB is the only state a rescan can't +# rebuild (scores, favorites, playlists, play history); the art dirs hold +# custom playlist covers + the user avatar. `web_library.db` is handled +# specially (consistent snapshot on export, staged restore on import) — the +# art dirs are walked like plugin export paths. NOTE: custom uploaded +# *song* art currently lands in `art_cache/` commingled with the derived +# (rebuildable) cache, so it is intentionally NOT bundled here to avoid +# bloating the backup with regenerable thumbnails — splitting custom song +# art into its own dir is a tracked follow-up (got-feedback/feedBack#636). +_CORE_LIBRARY_DB = "web_library.db" +_CORE_EXPORT_ART_DIRS = ("playlist_covers/", "avatars/") +_CORE_IMPORT_ALLOWED = (_CORE_LIBRARY_DB,) + _CORE_EXPORT_ART_DIRS + + +def _snapshot_library_db() -> dict | None: + """A consistent, fully-checkpointed single-file copy of the live library + DB, base64-encoded for the bundle. Uses the SQLite online-backup API so + it is safe to call while the server is serving requests; the live write + lock is held for the copy so no write lands mid-snapshot. Returns None if + the DB or backup is unavailable (export proceeds without it).""" + import base64 + fd, tmp = tempfile.mkstemp(dir=str(CONFIG_DIR), prefix="._dbsnap.", suffix=".db") + os.close(fd) + try: + dst = sqlite3.connect(tmp) + try: + with meta_db._lock: + meta_db.conn.backup(dst) + finally: + dst.close() + raw = Path(tmp).read_bytes() + except (sqlite3.Error, OSError): + log.warning("library DB snapshot for settings export failed", exc_info=True) + return None + finally: + for suffix in ("", "-wal", "-shm"): + try: + Path(tmp + suffix).unlink() + except FileNotFoundError: + pass + return {"encoding": "base64", "data": base64.b64encode(raw).decode("ascii")} + + +def _sqlite_payload_integrity_ok(payload: bytes) -> bool: + """Validate decoded DB bytes by materializing them to a temp file and + running the same integrity probe used at restore time — so a corrupt or + truncated snapshot is refused at import, before it's ever staged.""" + fd, tmp = tempfile.mkstemp(dir=str(CONFIG_DIR), prefix="._dbcheck.", suffix=".db") + try: + with os.fdopen(fd, "wb") as f: + f.write(payload) + return _sqlite_file_integrity_ok(Path(tmp)) + except OSError: + return False + finally: + try: + Path(tmp).unlink() + except FileNotFoundError: + pass + + +def _core_server_files() -> dict | None: + """`{relpath: encoded_entry}` for core server-side state in the bundle: + a snapshot of the library DB plus any custom playlist covers / avatar. + Returns None if the DB snapshot could not be produced — the caller must + treat that as a hard export failure rather than silently shipping a + backup that's missing the irreplaceable library state.""" + snap = _snapshot_library_db() + if snap is None: + return None + out: dict[str, dict] = dict(_walk_export_paths(list(_CORE_EXPORT_ART_DIRS), CONFIG_DIR)) + out[_CORE_LIBRARY_DB] = snap + return out + + @app.get("/api/settings/export") def export_settings(): """Build a settings bundle covering server config + opted-in plugin @@ -6230,6 +6370,17 @@ def export_settings(): if server_config is None: server_config = _default_settings() + # Snapshot the library DB + custom art FIRST: if the irreplaceable state + # can't be captured, abort with an error rather than hand back a bundle + # that looks like a backup but silently omits it. + core_files = _core_server_files() + if core_files is None: + return JSONResponse( + {"ok": False, "error": "could not snapshot the library database; " + "export aborted to avoid an incomplete backup"}, + status_code=500, + ) + plugin_blocks: dict[str, dict] = {} with PLUGINS_LOCK: plugins_snapshot = list(LOADED_PLUGINS) @@ -6247,6 +6398,7 @@ def export_settings(): "feedBack_version": _running_version(), "server_config": server_config, "plugin_server_configs": plugin_blocks, + "core_server_files": core_files, } filename = f"feedBack-settings-{now.strftime('%Y-%m-%d')}.json" return JSONResponse( @@ -6386,6 +6538,62 @@ def import_settings(bundle: dict): if applied_for_plugin: applied_plugins.append(plugin_id) + # ── Core server-side files (library DB + custom art) ───────────── + core_blocks = bundle.get("core_server_files") or {} + if not isinstance(core_blocks, dict): + return JSONResponse( + {"ok": False, "error": "core_server_files must be an object"}, + status_code=400, + ) + db_restore_staged = False + applied_core: list[str] = [] + for relpath, file_entry in core_blocks.items(): + if not isinstance(relpath, str) or not relpath: + return JSONResponse( + {"ok": False, "error": f"core_server_files: invalid relpath key {relpath!r}"}, + status_code=400, + ) + if relpath == _CORE_LIBRARY_DB: + # Stage the DB beside the live one; the swap happens at next + # startup (_apply_pending_db_restore), so we never overwrite a DB + # the server holds open or strand a stale WAL against a fresh file. + target = CONFIG_DIR / (_CORE_LIBRARY_DB + ".restore") + db_restore_staged = True + else: + try: + target = _validate_relpath(relpath, list(_CORE_IMPORT_ALLOWED), CONFIG_DIR) + except _UndeclaredFile: + warnings.append(f"core_server_files: skipped undeclared path {relpath!r}") + continue + except ValueError as e: + return JSONResponse( + {"ok": False, "error": f"core_server_files, file {relpath!r}: {e}"}, + status_code=400, + ) + try: + payload = _decode_entry(file_entry) + except ValueError as e: + return JSONResponse( + {"ok": False, "error": f"core_server_files, file {relpath!r}: {e}"}, + status_code=400, + ) + # Guard the DB payload: a truncated/corrupt file staged as the restore + # would fail to open at startup and brick the app (after the live DB + # is already gone). Reject anything that doesn't open + pass + # quick_check before it's ever staged. + if relpath == _CORE_LIBRARY_DB and not _sqlite_payload_integrity_ok(payload): + return JSONResponse( + {"ok": False, "error": "core_server_files: web_library.db is not a valid SQLite database"}, + status_code=400, + ) + staged.append((f"core/{relpath}", target, payload)) + applied_core.append(relpath) + if db_restore_staged: + warnings.append( + "library database restored; restart FeedBack to load it " + "(scores, favorites, playlists, and play history)" + ) + # ── Phase 2: commit ────────────────────────────────────────────── written: list[str] = [] try: @@ -6412,6 +6620,15 @@ def import_settings(bundle: dict): # because we didn't snapshot them — surface what got written # (as relpaths, not absolute server paths) so the user knows # the state is partial without leaking deployment layout. + # Disarm a staged DB restore THIS request wrote: a partial import must + # NOT silently swap the library DB on the next restart. Gate on the + # write actually having happened (display key in `written`) so we don't + # delete a valid restore staged by a prior, not-yet-applied import. + if f"core/{_CORE_LIBRARY_DB}" in written: + try: + (CONFIG_DIR / (_CORE_LIBRARY_DB + ".restore")).unlink() + except FileNotFoundError: + pass return JSONResponse( { "ok": False, @@ -6427,7 +6644,9 @@ def import_settings(bundle: dict): "applied": { "server_config": True, "plugins": applied_plugins, + "core_files": applied_core, }, + "restart_required": db_restore_staged, } diff --git a/tests/test_settings_export_library_db.py b/tests/test_settings_export_library_db.py new file mode 100644 index 0000000..d6ad46c --- /dev/null +++ b/tests/test_settings_export_library_db.py @@ -0,0 +1,311 @@ +"""Tests for the library-DB + custom-art half of the settings bundle +(got-feedback/feedBack#636 item 1). + +The base bundle (config + plugin files) is covered in test_settings_export.py; +this file pins the additive `core_server_files` section: + + - the live library DB is exported as a CONSISTENT single-file snapshot + (SQLite online-backup), base64-encoded; + - custom playlist covers / avatar are walked into the bundle; + - on import the DB is STAGED to `web_library.db.restore` (never written + over the live, open DB) and swapped in at next startup, clearing stale + WAL sidecars; custom art is written immediately; + - the whole thing round-trips: export → wipe → import → restart → data back. +""" + +import base64 +import importlib +import sqlite3 +import sys +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def server_mod(tmp_path, monkeypatch): + monkeypatch.setenv("CONFIG_DIR", str(tmp_path)) + sys.modules.pop("server", None) + mod = importlib.import_module("server") + yield mod + conn = getattr(getattr(mod, "meta_db", None), "conn", None) + if conn is not None: + conn.close() + + +@pytest.fixture() +def client(server_mod): + c = TestClient(server_mod.app) + try: + yield c + finally: + c.close() + + +def _valid_db_bytes(tmp_path, name="mk.db", marker="x"): + """Bytes of a small, valid (quick_check-clean) SQLite database.""" + p = tmp_path / name + c = sqlite3.connect(str(p)) + try: + c.execute("CREATE TABLE t (x TEXT)") + c.execute("INSERT INTO t VALUES (?)", (marker,)) + c.commit() + finally: + c.close() + return p.read_bytes() + + +def _seed_song(server_mod, filename="marker.archive", title="Marker", artist="Tester"): + server_mod.meta_db.put(filename, 1.0, 1, { + "title": title, "artist": artist, "album": "LP", "year": "", + "duration": 200.0, "tuning": "E Standard", "arrangements": [], + "has_lyrics": False, "format": "archive", "stem_count": 0, + "stem_ids": [], "tuning_name": "E Standard", "tuning_sort_key": 0, + "tuning_offsets": "", + }) + + +# ── Export ────────────────────────────────────────────────────────────────── + +def test_export_includes_consistent_library_db_snapshot(client, server_mod, tmp_path): + _seed_song(server_mod, filename="snap.archive", title="SnapSong") + + bundle = client.get("/api/settings/export").json() + core = bundle["core_server_files"] + assert "web_library.db" in core + entry = core["web_library.db"] + assert entry["encoding"] == "base64" + + # The snapshot must be a complete, openable DB reflecting current data — + # written to its own file (no WAL sidecar needed) and queryable. + snap = tmp_path / "snapshot.db" + snap.write_bytes(base64.b64decode(entry["data"])) + conn = sqlite3.connect(str(snap)) + try: + rows = conn.execute( + "SELECT title FROM songs WHERE filename = ?", ("snap.archive",) + ).fetchall() + finally: + conn.close() + assert rows == [("SnapSong",)] + + +def test_export_includes_custom_art_dirs(client, tmp_path): + (tmp_path / "playlist_covers").mkdir() + (tmp_path / "playlist_covers" / "3.png").write_bytes(b"\x89PNG-cover") + (tmp_path / "avatars").mkdir() + (tmp_path / "avatars" / "me.png").write_bytes(b"\x89PNG-avatar") + + core = client.get("/api/settings/export").json()["core_server_files"] + assert core["playlist_covers/3.png"]["encoding"] == "base64" + assert base64.b64decode(core["playlist_covers/3.png"]["data"]) == b"\x89PNG-cover" + assert base64.b64decode(core["avatars/me.png"]["data"]) == b"\x89PNG-avatar" + + +# ── Import: DB is staged, never written over the live file ────────────────── + +def test_import_stages_db_restore_without_touching_live_db(client, server_mod, tmp_path): + live = tmp_path / "web_library.db" + live_bytes_before = live.read_bytes() + + payload = _valid_db_bytes(tmp_path, name="incoming.db", marker="restored") + r = client.post("/api/settings/import", json={ + "schema": server_mod.SETTINGS_BUNDLE_SCHEMA, + "server_config": {}, + "core_server_files": { + "web_library.db": {"encoding": "base64", + "data": base64.b64encode(payload).decode()}, + }, + }) + assert r.status_code == 200 + body = r.json() + assert body["ok"] is True + assert body["restart_required"] is True + assert any("restart" in w.lower() for w in body["warnings"]) + assert "web_library.db" in body["applied"]["core_files"] + + # Live DB untouched; the restore is staged beside it for next startup. + assert live.read_bytes() == live_bytes_before + assert (tmp_path / "web_library.db.restore").read_bytes() == payload + + +def test_import_rejects_corrupt_db_with_valid_magic_header(client, server_mod, tmp_path): + # The dangerous case: SQLite magic header but a corrupt body. It must be + # refused at import — otherwise startup would delete the live DB and then + # fail to open the bad restore. + corrupt = b"SQLite format 3\x00" + b"\xff" * 200 + r = client.post("/api/settings/import", json={ + "schema": server_mod.SETTINGS_BUNDLE_SCHEMA, + "server_config": {}, + "core_server_files": { + "web_library.db": {"encoding": "base64", + "data": base64.b64encode(corrupt).decode()}, + }, + }) + assert r.status_code == 400 + assert not (tmp_path / "web_library.db.restore").exists() + + +def test_import_rejects_non_sqlite_db_payload(client, server_mod, tmp_path): + # A truncated / wrong file staged as the restore would brick startup — + # reject anything lacking the SQLite magic header, before touching disk. + r = client.post("/api/settings/import", json={ + "schema": server_mod.SETTINGS_BUNDLE_SCHEMA, + "server_config": {}, + "core_server_files": { + "web_library.db": {"encoding": "base64", + "data": base64.b64encode(b"not a database").decode()}, + }, + }) + assert r.status_code == 400 + assert not (tmp_path / "web_library.db.restore").exists() + + +def test_import_writes_custom_art_immediately(client, server_mod, tmp_path): + r = client.post("/api/settings/import", json={ + "schema": server_mod.SETTINGS_BUNDLE_SCHEMA, + "server_config": {}, + "core_server_files": { + "playlist_covers/7.png": {"encoding": "base64", + "data": base64.b64encode(b"cover7").decode()}, + }, + }) + assert r.status_code == 200 + assert r.json()["restart_required"] is False + assert (tmp_path / "playlist_covers" / "7.png").read_bytes() == b"cover7" + + +def test_import_core_path_traversal_rejected(client, server_mod, tmp_path): + secret = tmp_path.parent / "escape.txt" + r = client.post("/api/settings/import", json={ + "schema": server_mod.SETTINGS_BUNDLE_SCHEMA, + "server_config": {}, + "core_server_files": { + "../escape.txt": {"encoding": "base64", + "data": base64.b64encode(b"pwned").decode()}, + }, + }) + assert r.status_code == 400 + assert not secret.exists() + + +def test_import_core_undeclared_path_skipped_not_fatal(client, server_mod, tmp_path): + # A relpath outside the core allowlist is a warn-and-skip, not a refusal — + # the rest of the bundle still applies. + r = client.post("/api/settings/import", json={ + "schema": server_mod.SETTINGS_BUNDLE_SCHEMA, + "server_config": {}, + "core_server_files": { + "audio_cache/x.ogg": {"encoding": "base64", + "data": base64.b64encode(b"nope").decode()}, + }, + }) + assert r.status_code == 200 + assert not (tmp_path / "audio_cache" / "x.ogg").exists() + assert any("undeclared" in w.lower() for w in r.json()["warnings"]) + + +# ── Startup swap ──────────────────────────────────────────────────────────── + +def test_apply_pending_db_restore_swaps_and_clears_sidecars(server_mod, tmp_path): + main = tmp_path / "web_library.db" + new_db = _valid_db_bytes(tmp_path, name="new.db", marker="new") + # Simulate a live DB with stale WAL sidecars + a (valid) staged restore. + main.write_bytes(b"OLD-DB") + (tmp_path / "web_library.db-wal").write_bytes(b"OLD-WAL") + (tmp_path / "web_library.db-shm").write_bytes(b"OLD-SHM") + (tmp_path / "web_library.db.restore").write_bytes(new_db) + + server_mod._apply_pending_db_restore(tmp_path) + + assert main.read_bytes() == new_db # swapped in + assert not (tmp_path / "web_library.db.restore").exists() + assert not (tmp_path / "web_library.db-wal").exists() # stale sidecars gone + assert not (tmp_path / "web_library.db-shm").exists() + + +def test_apply_pending_db_restore_discards_corrupt_keeps_live(server_mod, tmp_path): + # A corrupt staged restore must be thrown away WITHOUT destroying the + # live DB — never brick startup or lose data for a bad bundle. + main = tmp_path / "web_library.db" + main.write_bytes(b"LIVE-GOOD-DB") + (tmp_path / "web_library.db.restore").write_bytes(b"SQLite format 3\x00" + b"\xff" * 64) + + server_mod._apply_pending_db_restore(tmp_path) + + assert main.read_bytes() == b"LIVE-GOOD-DB" # live DB preserved + assert not (tmp_path / "web_library.db.restore").exists() # bad restore dropped + + +def test_apply_pending_db_restore_noop_without_staging(server_mod, tmp_path): + (tmp_path / "web_library.db").write_bytes(b"LIVE") + server_mod._apply_pending_db_restore(tmp_path) # nothing staged + assert (tmp_path / "web_library.db").read_bytes() == b"LIVE" + + +# ── Full round-trip ───────────────────────────────────────────────────────── + +def test_full_db_backup_restore_round_trip(client, server_mod, tmp_path): + _seed_song(server_mod, filename="keepme.archive", title="KeepMe") + bundle = client.get("/api/settings/export").json() + + # Lose the data (a song removed from the live DB after the backup). + server_mod.meta_db.conn.execute("DELETE FROM songs WHERE filename = ?", ("keepme.archive",)) + server_mod.meta_db.conn.commit() + assert server_mod.meta_db.conn.execute( + "SELECT COUNT(*) FROM songs WHERE filename = ?", ("keepme.archive",) + ).fetchone()[0] == 0 + + # Re-import the bundle → DB staged, not yet live. + r = client.post("/api/settings/import", json=bundle) + assert r.status_code == 200 and r.json()["restart_required"] is True + + # Simulate a restart: close the live conn, apply the staged restore, + # reopen — the song is back. + server_mod.meta_db.conn.close() + server_mod._apply_pending_db_restore(tmp_path) + conn = sqlite3.connect(str(tmp_path / "web_library.db")) + try: + rows = conn.execute( + "SELECT title FROM songs WHERE filename = ?", ("keepme.archive",) + ).fetchall() + finally: + conn.close() + assert rows == [("KeepMe",)] + assert not (tmp_path / "web_library.db.restore").exists() + + +# ── Failure modes ─────────────────────────────────────────────────────────── + +def test_export_fails_hard_when_db_snapshot_unavailable(client, server_mod, monkeypatch): + # A backup that silently omits the library DB is a data-loss trap — the + # export must error rather than hand back an incomplete-looking bundle. + monkeypatch.setattr(server_mod, "_snapshot_library_db", lambda: None) + r = client.get("/api/settings/export") + assert r.status_code == 500 + assert "library database" in r.json()["error"].lower() + + +def test_failed_import_disarms_staged_db_restore(client, server_mod, tmp_path, monkeypatch): + # If a later write in phase 2 fails, the request 500s — but a staged DB + # restore must NOT survive to swap in on the next restart. + payload = _valid_db_bytes(tmp_path, name="incoming.db") + real_write = server_mod._atomic_write_file + + def boom(target, data): + if target.name == "config.json": # last write of the commit + raise OSError("disk full") + return real_write(target, data) + + monkeypatch.setattr(server_mod, "_atomic_write_file", boom) + r = client.post("/api/settings/import", json={ + "schema": server_mod.SETTINGS_BUNDLE_SCHEMA, + "server_config": {}, + "core_server_files": { + "web_library.db": {"encoding": "base64", + "data": base64.b64encode(payload).decode()}, + }, + }) + assert r.status_code == 500 + assert not (tmp_path / "web_library.db.restore").exists() From 8ca7ea40025e8ced38549dd1aacd21de664bb904 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Mon, 29 Jun 2026 09:36:02 +0200 Subject: [PATCH 76/99] feat(library): persisted wishlist / "wanted" list (#640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes feedBack#636 item 4 — the *arr "Wanted/Monitored" analogue FeedBack was missing. A wishlist entry is a song the user does NOT own yet, so unlike a playlist (which references owned local songs by filename) it can't reuse the playlist subsystem; it lives in a new `wanted` table keyed by descriptive identity (artist, title, source, source_ref, note, created_at). - New table + a UNIQUE index on (artist NOCASE, title NOCASE, source, source_ref); additive + idempotent (CREATE … IF NOT EXISTS). - MetadataDB.add_wanted (INSERT OR IGNORE + re-select under the write lock, so a re-run of an ownership-diff returns the existing row, never a dup), list_wanted (newest first), remove_wanted, count_wanted. - Routes GET/POST/DELETE /api/wanted. POST requires artist or title and defaults source to "manual"; idempotent on identity so producers (the find_more ownership-diff, or a manual add) can re-post freely. This is the core persistence primitive the charrette flagged as the missing piece; the consuming UI lives in the producing plugin (find_more / the_daily). Tests: tests/test_wanted_api.py (round-trip, identity idempotency incl. case-insensitive, distinct source_ref, ordering, validation, additive schema). Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + server.py | 104 ++++++++++++++++++++++++++++++++++++ tests/test_wanted_api.py | 111 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 tests/test_wanted_api.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ac34554..780ae48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **The settings backup now includes your library database + custom art — your scores, favorites, playlists, and play history are no longer the one thing a backup can't save.** `GET /api/settings/export` gains an additive `core_server_files` section carrying a **consistent snapshot of `web_library.db`** (taken via the SQLite online-backup API, so it's a complete single file even while the server is running) plus any custom **playlist covers** and **avatar** (`CONFIG_DIR/playlist_covers/`, `CONFIG_DIR/avatars/`). On `POST /api/settings/import` the database is **staged** to `web_library.db.restore` rather than written over the live, open DB; it's swapped in at the next startup (`_apply_pending_db_restore`, before the connection opens), which also clears the old WAL sidecars so a stale `-wal` can't be replayed onto the restored file — the import response sets `restart_required: true` and warns accordingly. Custom art is written immediately. The bundle stays backward-compatible (older servers ignore the new section). Came out of the library design charrette (dev-ops lens's top "protect irreplaceable data" pick, got-feedback/feedBack#636). _Known gap:_ custom uploaded **song** art is still commingled with the rebuildable thumbnail cache in `art_cache/`, so it isn't bundled yet (a tracked follow-up). Tests: `tests/test_settings_export_library_db.py` (snapshot consistency, staged-not-live restore, sidecar clearing, traversal rejection, full round-trip). +- **A persisted wishlist — keep a list of songs you want but don't own yet.** New `wanted` table + `GET`/`POST`/`DELETE /api/wanted` give FeedBack the *arr-style "Wanted/Monitored" primitive it was missing: an entry is a *not-owned* song (artist/title/source/source_ref/note), so it lives in its own table rather than the playlist subsystem (which references owned local files). The API is idempotent on identity (case-insensitive artist+title, plus source+source_ref), so a producer — the `find_more` ownership-diff, or a manual add — can re-post without duplicating. Newest-first. Backend primitive for the charrette's wishlist finding (got-feedback/feedBack#636 item 4); the consuming UI lives in the producing plugin. Tests: `tests/test_wanted_api.py`. - **Practice-aware library home — a "Repertoire" meter + a "Keep practicing" shelf on the v3 Songs page.** The library opened cold into a flat sorted grid; now the unfiltered grid front door leads with two practice-aware surfaces built entirely from data already on hand (no new endpoints or stored state). A **Repertoire meter** shows how much of your library you can actually play — *"Repertoire: 12 of 80 songs · 7 in progress"* with a progress bar — counting songs at or above the same mastery threshold the green accuracy badge uses (≥ 90% best accuracy) over the unfiltered library total. A **"Keep practicing" shelf** is a horizontal row of your recently-played-but-not-yet-mastered songs (newest first, click to play) — the practice-accuracy-driven "continue" rail a media server can't do. Both reuse `/api/stats/best` (already loaded for the card badges) + `/api/stats/recent`; they show **only** on the grid view when you aren't searching/filtering/selecting, refresh after a song is scored, and collapse to nothing on an empty library. Soft-gamification only — descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging. Frontend-only: `static/v3/songs.js` (`renderLibraryHome`/`_repertoireCounts`), `static/v3/v3.css`. Came out of the library design charrette (the UX + gamification lenses' top pick). Tests: `tests/js/v3_keep_practicing.test.js`. - **A–Z fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`. - **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`. diff --git a/server.py b/server.py index ce81442..e14f9e6 100644 --- a/server.py +++ b/server.py @@ -576,6 +576,30 @@ class MetadataDB: ) """) self.conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_playlists_system_key ON playlists(system_key) WHERE system_key IS NOT NULL") + # Wishlist / "wanted" (feedBack#636 item 4): a persisted, actionable + # list of songs the user does NOT own yet — the *arr "Wanted/Monitored" + # analogue. Unlike playlists (which reference owned local songs by + # filename), a wanted entry has no local file, so it lives in its own + # table keyed by descriptive identity. Producers (the find_more plugin's + # ownership-diff, or a manual add) POST here; the consuming UI reads it. + # Additive + idempotent. + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS wanted ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artist TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', -- e.g. 'find_more', 'manual' + source_ref TEXT NOT NULL DEFAULT '', -- opaque id/url within that source + note TEXT NOT NULL DEFAULT '', + created_at TEXT + ) + """) + # Identity = (artist, title, source, source_ref), case-insensitive on + # the human fields, so re-running an ownership-diff doesn't duplicate. + self.conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_wanted_identity " + "ON wanted(artist COLLATE NOCASE, title COLLATE NOCASE, source, source_ref)" + ) # Progression (spec 010): instrument paths, challenges, quests, the # Decibels wallet, and the cosmetics shop. Targets/titles live in the # bundled content (data/progression/); these tables hold only player @@ -1559,6 +1583,52 @@ class MetadataDB: self.conn.commit() return new_state + # ── Wishlist / "wanted" (feedBack#636 item 4) ───────────────────────── + _WANTED_COLS = ("id", "artist", "title", "source", "source_ref", "note", "created_at") + + def add_wanted(self, artist: str, title: str, source: str = "manual", + source_ref: str = "", note: str = "") -> dict: + """Add a not-owned song to the wishlist (or return the existing row if + an entry with the same identity is already wanted — idempotent, so a + re-run of an ownership-diff doesn't duplicate). Returns the row.""" + artist = (artist or "").strip() + title = (title or "").strip() + source = (source or "manual").strip() or "manual" + source_ref = (source_ref or "").strip() + note = (note or "").strip() + with self._lock: + self.conn.execute( + "INSERT OR IGNORE INTO wanted (artist, title, source, source_ref, note, created_at) " + "VALUES (?, ?, ?, ?, ?, datetime('now'))", + (artist, title, source, source_ref, note), + ) + row = self.conn.execute( + "SELECT " + ", ".join(self._WANTED_COLS) + " FROM wanted " + "WHERE artist = ? COLLATE NOCASE AND title = ? COLLATE NOCASE " + "AND source = ? AND source_ref = ?", + (artist, title, source, source_ref), + ).fetchone() + self.conn.commit() + return dict(zip(self._WANTED_COLS, row)) if row else {} + + def list_wanted(self) -> list[dict]: + """All wishlist entries, newest first.""" + rows = self.conn.execute( + "SELECT " + ", ".join(self._WANTED_COLS) + " FROM wanted " + "ORDER BY created_at DESC, id DESC" + ).fetchall() + return [dict(zip(self._WANTED_COLS, r)) for r in rows] + + def remove_wanted(self, wanted_id: int) -> bool: + """Drop a wishlist entry by id. Returns True if a row was removed.""" + with self._lock: + cur = self.conn.execute("DELETE FROM wanted WHERE id = ?", (wanted_id,)) + self.conn.commit() + return cur.rowcount > 0 + + def count_wanted(self) -> int: + return self.conn.execute("SELECT COUNT(*) FROM wanted").fetchone()[0] + def continue_session(self) -> dict | None: """Most-recently-played song (from song_stats) + metadata, for the Continue-Playing card. Null when nothing has been played.""" @@ -5499,6 +5569,40 @@ def api_session_continue(): return meta_db.continue_session() +# ── Wishlist / "wanted" API (feedBack#636 item 4) ───────────────────────────── + +@app.get("/api/wanted") +def api_list_wanted(): + """The wishlist — songs the user wants but doesn't own yet (newest first).""" + return {"wanted": meta_db.list_wanted()} + + +@app.post("/api/wanted") +def api_add_wanted(data: dict): + """Add a not-owned song to the wishlist. `artist`/`title` are required (at + least one non-empty); `source`/`source_ref`/`note` are optional. Idempotent + on identity so producers (find_more ownership-diff, manual add) can re-post.""" + if not isinstance(data, dict): + return JSONResponse({"error": "body must be an object"}, status_code=400) + artist = _clean_str(data.get("artist")) + title = _clean_str(data.get("title")) + if not artist and not title: + return JSONResponse({"error": "artist or title required"}, status_code=400) + row = meta_db.add_wanted( + artist=artist, title=title, + source=_clean_str(data.get("source")) or "manual", + source_ref=_clean_str(data.get("source_ref")), + note=_clean_str(data.get("note")), + ) + return {"ok": True, "wanted": row} + + +@app.delete("/api/wanted/{wanted_id}") +def api_remove_wanted(wanted_id: int): + """Remove a wishlist entry by id.""" + return {"ok": meta_db.remove_wanted(wanted_id)} + + # ── Loops API ──────────────────────────────────────────────────────────────── @app.get("/api/loops") diff --git a/tests/test_wanted_api.py b/tests/test_wanted_api.py new file mode 100644 index 0000000..9009cb2 --- /dev/null +++ b/tests/test_wanted_api.py @@ -0,0 +1,111 @@ +"""Tests for the wishlist / "wanted" list (got-feedback/feedBack#636 item 4). + +A wishlist entry is a song the user does NOT own yet (the *arr Wanted/Monitored +analogue), so it lives in its own `wanted` table keyed by descriptive identity +rather than a local filename. Producers (the find_more ownership-diff, or a +manual add) POST entries; the API is idempotent on identity so a re-run of an +ownership-diff can't duplicate. +""" + +import importlib +import sys + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def server_mod(tmp_path, monkeypatch): + monkeypatch.setenv("CONFIG_DIR", str(tmp_path)) + sys.modules.pop("server", None) + mod = importlib.import_module("server") + yield mod + conn = getattr(getattr(mod, "meta_db", None), "conn", None) + if conn is not None: + conn.close() + + +@pytest.fixture() +def client(server_mod): + c = TestClient(server_mod.app) + try: + yield c + finally: + c.close() + + +def test_add_list_remove_round_trip(client): + assert client.get("/api/wanted").json() == {"wanted": []} + + r = client.post("/api/wanted", json={"artist": "Tool", "title": "Lateralus", + "source": "find_more", "source_ref": "cf:123"}) + assert r.status_code == 200 + row = r.json()["wanted"] + assert (row["artist"], row["title"], row["source"]) == ("Tool", "Lateralus", "find_more") + wid = row["id"] + + listed = client.get("/api/wanted").json()["wanted"] + assert [w["title"] for w in listed] == ["Lateralus"] + + assert client.request("DELETE", f"/api/wanted/{wid}").json() == {"ok": True} + assert client.get("/api/wanted").json() == {"wanted": []} + # Deleting an already-gone id is a no-op, not an error. + assert client.request("DELETE", f"/api/wanted/{wid}").json() == {"ok": False} + + +def test_add_is_idempotent_on_identity(client, server_mod): + payload = {"artist": "Rush", "title": "YYZ", "source": "find_more", "source_ref": "x1"} + first = client.post("/api/wanted", json=payload).json()["wanted"] + # Same identity (case-insensitive on artist/title) → no duplicate, same row. + again = client.post("/api/wanted", json={**payload, "artist": "rush", "title": "yyz"}).json()["wanted"] + assert first["id"] == again["id"] + assert server_mod.meta_db.count_wanted() == 1 + + # A different source_ref is a distinct entry. + client.post("/api/wanted", json={**payload, "source_ref": "x2"}) + assert server_mod.meta_db.count_wanted() == 2 + + +def test_newest_first_ordering(client, server_mod): + for t in ("First", "Second", "Third"): + server_mod.meta_db.add_wanted(artist="A", title=t, source="manual") + titles = [w["title"] for w in client.get("/api/wanted").json()["wanted"]] + assert titles == ["Third", "Second", "First"] + + +def test_add_requires_artist_or_title(client): + r = client.post("/api/wanted", json={"source": "manual"}) + assert r.status_code == 400 + r2 = client.post("/api/wanted", json={"artist": "", "title": " "}) + assert r2.status_code == 400 + + +def test_add_defaults_source_to_manual(client): + row = client.post("/api/wanted", json={"title": "Untitled"}).json()["wanted"] + assert row["source"] == "manual" + assert row["artist"] == "" + + +def test_non_dict_body_rejected(client): + # FastAPI's `data: dict` validation rejects a JSON array (422) before the + # handler's own defensive isinstance guard; either way it's not a 2xx. + assert client.post("/api/wanted", json=[]).status_code in (400, 422) + + +def test_table_creation_is_idempotent(server_mod): + # Re-running the CREATE TABLE / CREATE INDEX must not error or wipe rows — + # pin the additive + idempotent migration guarantee (constitution IV). + server_mod.meta_db.add_wanted(artist="Keep", title="Me") + server_mod.meta_db.conn.execute(""" + CREATE TABLE IF NOT EXISTS wanted ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artist TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', source_ref TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', created_at TEXT + ) + """) + server_mod.meta_db.conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_wanted_identity " + "ON wanted(artist COLLATE NOCASE, title COLLATE NOCASE, source, source_ref)" + ) + assert server_mod.meta_db.count_wanted() == 1 From 331857ff2aafbd82a0c501e72d8f6dcd9a33c538 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Mon, 29 Jun 2026 10:40:47 +0200 Subject: [PATCH 77/99] feat(library): keyset cursor pagination + stable sort tiebreak (#636 item 3, stage 1) (#642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3): the data layer the DOM-recycling render window will build on, plus a latent paging bug fixed on the way. - Every grid sort now appends a unique `filename` tiebreak → a TOTAL order. Without it, rows with an equal sort key (e.g. two songs by the same artist) could be skipped or duplicated across OFFSET pages. - query_page gains an opaque `after` keyset cursor: when supplied and the sort can keyset (artist[-desc], title[-desc], recent), the page is fetched with a WHERE-seek instead of OFFSET — O(page), independent of depth. The seek is NULL-aware (NULLs first in ASC / last in DESC) so it's EXACTLY OFFSET- equivalent; the legacy `dir=desc` shape is canonicalized so its cursor seeks the right direction. Unknown/compound sorts + bad cursors fall back to OFFSET. - /api/library exposes `after` + `next_cursor`. Only the true local provider is handed a cursor (a collection may pin a different sort; remote don't keyset), so both page by OFFSET safely. - Composite (artist NOCASE, filename) / (title NOCASE, filename) / (mtime, filename) indexes cover the order; `after` added to the optional provider kwargs so legacy providers drop it. Codex-reviewed; 3 findings fixed (dir=desc canonicalization, NULL-key seek, cursor only for the local provider). Tests: tests/test_library_keyset.py (keyset==OFFSET parity for 5 sorts, stable tiebreak on equal keys, dir=desc, NULL sort keys, bad-cursor + compound-sort fallback). Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + server.py | 143 +++++++++++++++++++++++++++++--- tests/test_library_keyset.py | 154 +++++++++++++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 11 deletions(-) create mode 100644 tests/test_library_keyset.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 780ae48..a72eb9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback). - **The settings backup now includes your library database + custom art — your scores, favorites, playlists, and play history are no longer the one thing a backup can't save.** `GET /api/settings/export` gains an additive `core_server_files` section carrying a **consistent snapshot of `web_library.db`** (taken via the SQLite online-backup API, so it's a complete single file even while the server is running) plus any custom **playlist covers** and **avatar** (`CONFIG_DIR/playlist_covers/`, `CONFIG_DIR/avatars/`). On `POST /api/settings/import` the database is **staged** to `web_library.db.restore` rather than written over the live, open DB; it's swapped in at the next startup (`_apply_pending_db_restore`, before the connection opens), which also clears the old WAL sidecars so a stale `-wal` can't be replayed onto the restored file — the import response sets `restart_required: true` and warns accordingly. Custom art is written immediately. The bundle stays backward-compatible (older servers ignore the new section). Came out of the library design charrette (dev-ops lens's top "protect irreplaceable data" pick, got-feedback/feedBack#636). _Known gap:_ custom uploaded **song** art is still commingled with the rebuildable thumbnail cache in `art_cache/`, so it isn't bundled yet (a tracked follow-up). Tests: `tests/test_settings_export_library_db.py` (snapshot consistency, staged-not-live restore, sidecar clearing, traversal rejection, full round-trip). - **A persisted wishlist — keep a list of songs you want but don't own yet.** New `wanted` table + `GET`/`POST`/`DELETE /api/wanted` give FeedBack the *arr-style "Wanted/Monitored" primitive it was missing: an entry is a *not-owned* song (artist/title/source/source_ref/note), so it lives in its own table rather than the playlist subsystem (which references owned local files). The API is idempotent on identity (case-insensitive artist+title, plus source+source_ref), so a producer — the `find_more` ownership-diff, or a manual add — can re-post without duplicating. Newest-first. Backend primitive for the charrette's wishlist finding (got-feedback/feedBack#636 item 4); the consuming UI lives in the producing plugin. Tests: `tests/test_wanted_api.py`. - **Practice-aware library home — a "Repertoire" meter + a "Keep practicing" shelf on the v3 Songs page.** The library opened cold into a flat sorted grid; now the unfiltered grid front door leads with two practice-aware surfaces built entirely from data already on hand (no new endpoints or stored state). A **Repertoire meter** shows how much of your library you can actually play — *"Repertoire: 12 of 80 songs · 7 in progress"* with a progress bar — counting songs at or above the same mastery threshold the green accuracy badge uses (≥ 90% best accuracy) over the unfiltered library total. A **"Keep practicing" shelf** is a horizontal row of your recently-played-but-not-yet-mastered songs (newest first, click to play) — the practice-accuracy-driven "continue" rail a media server can't do. Both reuse `/api/stats/best` (already loaded for the card badges) + `/api/stats/recent`; they show **only** on the grid view when you aren't searching/filtering/selecting, refresh after a song is scored, and collapse to nothing on an empty library. Soft-gamification only — descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging. Frontend-only: `static/v3/songs.js` (`renderLibraryHome`/`_repertoireCounts`), `static/v3/v3.css`. Came out of the library design charrette (the UX + gamification lenses' top pick). Tests: `tests/js/v3_keep_practicing.test.js`. diff --git a/server.py b/server.py index e14f9e6..c546291 100644 --- a/server.py +++ b/server.py @@ -427,6 +427,80 @@ def _apply_pending_db_restore(config_dir: Path) -> None: log.info("applied pending library DB restore from settings import") +# ── Keyset (cursor) pagination for the library grid (feedBack#636 item 3) ───── +# Forward-only, O(page) deep paging that doesn't grow with OFFSET. Only simple +# single-column sorts can keyset cleanly (the compound tuning/year sorts fall +# back to OFFSET). Every sort gets a unique `filename` tiebreak so the order is +# TOTAL — which also fixes a latent OFFSET skip/dupe across equal-key rows. +# (column, collate-clause, primary-direction) — tiebreak is always `filename` ASC. +_KEYSET_SORTS = { + "artist": ("artist", "COLLATE NOCASE", "ASC"), + "artist-desc": ("artist", "COLLATE NOCASE", "DESC"), + "title": ("title", "COLLATE NOCASE", "ASC"), + "title-desc": ("title", "COLLATE NOCASE", "DESC"), + "recent": ("mtime", "", "DESC"), +} +# Index into a query_page row tuple for each keyset column (see the SELECT in +# query_page: filename, title, artist, ... mtime at 9). +_KEYSET_ROW_IDX = {"artist": 2, "title": 1, "mtime": 9} + + +def _encode_cursor(values: list) -> str: + import base64 + return base64.urlsafe_b64encode(json.dumps(values).encode("utf-8")).decode("ascii") + + +def _decode_cursor(cursor: str): + """Decode an opaque keyset cursor to [sort_value, filename], or None if it's + malformed (a bad cursor degrades to the first page, never 500s).""" + import base64 + try: + out = json.loads(base64.urlsafe_b64decode(cursor.encode("ascii")).decode("utf-8")) + except (ValueError, TypeError): + return None + return out if isinstance(out, list) and len(out) == 2 else None + + +def _effective_keyset_sort(sort: str, direction: str) -> str: + """Fold the legacy `dir=desc` toggle into the canonical keyset sort key, so + the seek/cursor direction matches the ORDER BY that same toggle produces + (without this, `sort=artist&dir=desc` would seek with `>` against a DESC + order → gaps/dupes).""" + if direction == "desc" and sort in ("artist", "title"): + return sort + "-desc" + return sort + + +def _keyset_seek(col: str, collate: str, primary_dir: str, cv, fn: str): + """(sql, params) for 'rows strictly after (cv, fn)' in the total order + ` , filename ASC`, matching SQLite's NULL placement + (NULLs sort first in ASC, last in DESC) so keyset is exactly OFFSET- + equivalent even for NULL sort keys.""" + ce = f"{col} {collate}".strip() + if primary_dir == "ASC": # NULLs first + if cv is None: + return (f"(({col} IS NULL AND filename > ?) OR {col} IS NOT NULL)", [fn]) + return (f"({col} IS NOT NULL AND ({ce} > ? OR ({ce} = ? AND filename > ?)))", + [cv, cv, fn]) + # DESC — NULLs last + if cv is None: + return (f"({col} IS NULL AND filename > ?)", [fn]) + return (f"({col} IS NULL OR ({col} IS NOT NULL AND " + f"({ce} < ? OR ({ce} = ? AND filename > ?))))", [cv, cv, fn]) + + +def next_library_cursor(sort: str, last_song: dict | None) -> str | None: + """The cursor for the last row of a page, so the next request resumes after + it. None when the sort can't keyset or the page was empty.""" + if sort not in _KEYSET_SORTS or not last_song: + return None + col = _KEYSET_SORTS[sort][0] + key = "mtime" if col == "mtime" else col + if key not in last_song or "filename" not in last_song: + return None + return _encode_cursor([last_song[key], last_song["filename"]]) + + class MetadataDB: def __init__(self): CONFIG_DIR.mkdir(parents=True, exist_ok=True) @@ -478,6 +552,13 @@ class MetadataDB: pass self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist COLLATE NOCASE)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title COLLATE NOCASE)") + # Composite (sort col, filename) indexes cover the grid's ORDER BY + + # its unique filename tiebreak — for both the OFFSET scan and keyset + # seek (feedBack#636 item 3). idx_songs_artist/title above stay for the + # distinct-artist / letter-bar aggregates. + self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_artist_fn ON songs(artist COLLATE NOCASE, filename)") + self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_title_fn ON songs(title COLLATE NOCASE, filename)") + self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_mtime_fn ON songs(mtime, filename)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_tuning_name ON songs(tuning_name COLLATE NOCASE)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_tuning_sort_key ON songs(tuning_sort_key)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_year ON songs(year)") @@ -1943,8 +2024,14 @@ class MetadataDB: stems_lacks: list[str] | None = None, has_lyrics: int | None = None, tunings: list[str] | None = None, + after: str | None = None, naming_mode: str = "legacy") -> tuple[list[dict], int]: - """Server-side paginated search. Returns (songs, total_count).""" + """Server-side paginated search. Returns (songs, total_count). + + `after` is an opaque keyset cursor (the last row of the previous page). + When supplied and the sort can keyset, the page is fetched with a + WHERE-seek instead of OFFSET — O(page), independent of depth. Unknown + sorts / bad cursors fall back to OFFSET, so it's always safe.""" where, params = self._build_where( q=q, favorites_only=favorites_only, format_filter=format_filter, artist_filter=artist_filter, album_filter=album_filter, @@ -2002,14 +2089,33 @@ class MetadataDB: # those sorts use the explicit `-desc` sort key instead. if direction == "desc" and " ASC" not in order and " DESC" not in order: order += " DESC" + # Unique, deterministic tiebreak → a TOTAL order. Without it, rows with + # an equal sort key can reshuffle between OFFSET pages (skip/dupe); it's + # also what makes keyset seeking correct. + order += ", filename" total = self.conn.execute(f"SELECT COUNT(*) FROM songs {where}", params).fetchone()[0] - rows = self.conn.execute( - f"SELECT filename, title, artist, album, year, duration, tuning, arrangements, has_lyrics, mtime, " - f"format, stem_count, stem_ids, tuning_name, tuning_offsets " - f"FROM songs {where} ORDER BY {order} LIMIT ? OFFSET ?", - params + [size, page * size] - ).fetchall() + + cols = ("SELECT filename, title, artist, album, year, duration, tuning, " + "arrangements, has_lyrics, mtime, format, stem_count, stem_ids, " + "tuning_name, tuning_offsets FROM songs ") + cursor = _decode_cursor(after) if after else None + eff_sort = _effective_keyset_sort(sort, direction) + if cursor and eff_sort in _KEYSET_SORTS: + # Keyset seek: rows strictly after the cursor in the total order + # ` , filename ASC` (NULL-aware, so == OFFSET exactly). + col, collate, primary_dir = _KEYSET_SORTS[eff_sort] + seek, seek_params = _keyset_seek(col, collate, primary_dir, cursor[0], cursor[1]) + seek_where = where + (" AND " if where else " WHERE ") + seek + rows = self.conn.execute( + f"{cols}{seek_where} ORDER BY {order} LIMIT ?", + params + seek_params + [size], + ).fetchall() + else: + rows = self.conn.execute( + f"{cols}{where} ORDER BY {order} LIMIT ? OFFSET ?", + params + [size, page * size], + ).fetchall() estd = self._estd_set() favs = self.favorite_set() @@ -2773,7 +2879,7 @@ def _require_library_provider_capability(provider: object, capability: str) -> N ) -_OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode", "sort", "want_sort_letters") +_OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode", "sort", "want_sort_letters", "after") def _filter_provider_kwargs(method: object, kwargs: dict) -> dict: @@ -4650,11 +4756,21 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = " arrangements_has: str = "", arrangements_lacks: str = "", stems_has: str = "", stems_lacks: str = "", has_lyrics: str = "", tunings: str = "", provider: str = "local", - naming_mode: str = "legacy"): - """Paginated library search through the selected library provider.""" + after: str = "", naming_mode: str = "legacy"): + """Paginated library search through the selected library provider. + + `after` is an opaque keyset cursor (feedBack#636 item 3): pass back the + `next_cursor` from the previous response to fetch the next page with a + WHERE-seek instead of OFFSET. Providers that don't support it ignore it and + page by OFFSET, so the client can always fall back.""" size = min(size, 100) library_provider = _get_library_provider(provider) _require_library_provider_capability(library_provider, "library.read") + # Only the true local provider keysets: it's the one whose effective sort is + # exactly the request `sort`. A smart collection may pin its own sort and + # remote providers don't keyset — both must page by OFFSET, so never hand + # them a cursor (a mismatched one would mis-seek). + is_local = getattr(library_provider, "id", "") == "local" songs, total = await _call_library_provider_async( library_provider, "query_page", @@ -4662,6 +4778,7 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = " size=size, sort=sort, direction=dir, + after=((after or None) if is_local else None), naming_mode=naming_mode, **_library_filter_args( q=q, favorites=favorites, format=format, @@ -4671,7 +4788,11 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = " has_lyrics=has_lyrics, tunings=tunings, ), ) - return {"songs": songs, "total": total, "page": page, "size": size} + # The cursor to resume after this page (effective sort folds in dir=desc). + next_cursor = (next_library_cursor(_effective_keyset_sort(sort, dir), songs[-1]) + if (is_local and songs) else None) + return {"songs": songs, "total": total, "page": page, "size": size, + "next_cursor": next_cursor} @app.get("/api/library/artists") diff --git a/tests/test_library_keyset.py b/tests/test_library_keyset.py new file mode 100644 index 0000000..47fdf4b --- /dev/null +++ b/tests/test_library_keyset.py @@ -0,0 +1,154 @@ +"""Keyset (cursor) pagination for the library grid (feedBack#636 item 3, stage 1). + +Pins the data layer the virtualized grid builds on: + - every sort gets a unique `filename` tiebreak → a TOTAL order (fixes the + latent OFFSET skip/dupe across equal-key rows); + - `/api/library?after=` walks the SAME total order with a WHERE-seek, + returning exactly the OFFSET page would, with no gaps or dupes; + - bad cursors / non-keyset sorts fall back to OFFSET safely. +""" + +import importlib +import sys + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def server_mod(tmp_path, monkeypatch): + monkeypatch.setenv("CONFIG_DIR", str(tmp_path)) + sys.modules.pop("server", None) + mod = importlib.import_module("server") + yield mod + conn = getattr(getattr(mod, "meta_db", None), "conn", None) + if conn is not None: + conn.close() + + +@pytest.fixture() +def client(server_mod): + c = TestClient(server_mod.app) + try: + yield c + finally: + c.close() + + +def _seed(server_mod, n=25, *, shared_artist=False): + for i in range(n): + artist = "SameArtist" if shared_artist else f"Artist{i:02d}" + server_mod.meta_db.put(f"song{i:02d}.archive", float(i), 1, { + "title": f"Title{i:02d}", "artist": artist, "album": "LP", "year": "", + "duration": 1.0, "tuning": "E Standard", "arrangements": [], "has_lyrics": False, + "format": "archive", "stem_count": 0, "stem_ids": [], "tuning_name": "E Standard", + "tuning_sort_key": 0, "tuning_offsets": "", + }) + + +def _walk_keyset(client, sort, size, total): + """Page the whole library via the cursor and return the filename order.""" + seen, cursor, guard = [], "", 0 + while len(seen) < total and guard < total + 5: + guard += 1 + params = {"sort": sort, "size": size} + if cursor: + params["after"] = cursor + body = client.get("/api/library", params=params).json() + seen.extend(s["filename"] for s in body["songs"]) + cursor = body.get("next_cursor") + if not body["songs"] or not cursor: + break + return seen + + +def _walk_offset(client, sort, size, total): + seen, page = [], 0 + while len(seen) < total: + body = client.get("/api/library", params={"sort": sort, "size": size, "page": page}).json() + if not body["songs"]: + break + seen.extend(s["filename"] for s in body["songs"]) + page += 1 + return seen + + +@pytest.mark.parametrize("sort", ["artist", "artist-desc", "title", "title-desc", "recent"]) +def test_keyset_matches_offset_exactly(client, server_mod, sort): + _seed(server_mod, 25) + offset_order = _walk_offset(client, sort, 7, 25) + keyset_order = _walk_keyset(client, sort, 7, 25) + assert keyset_order == offset_order # same order... + assert len(keyset_order) == 25 + assert len(set(keyset_order)) == 25 # ...no gaps, no dupes + + +def test_stable_tiebreak_on_equal_keys(client, server_mod): + # 25 songs, all the SAME artist → the artist sort is decided entirely by the + # filename tiebreak. Both pagers must still cover all 25 with no dupe. + _seed(server_mod, 25, shared_artist=True) + keyset_order = _walk_keyset(client, "artist", 6, 25) + assert len(keyset_order) == 25 and len(set(keyset_order)) == 25 + assert keyset_order == sorted(keyset_order) # tiebreak is filename ASC + + +def test_first_page_has_cursor_and_no_after_is_offset(client, server_mod): + _seed(server_mod, 5) + body = client.get("/api/library", params={"sort": "artist", "size": 2}).json() + assert body["next_cursor"] # cursor offered + assert [s["filename"] for s in body["songs"]] == ["song00.archive", "song01.archive"] + + +def test_bad_cursor_falls_back_to_first_page(client, server_mod): + _seed(server_mod, 5) + body = client.get("/api/library", params={"sort": "artist", "size": 3, "after": "not-a-cursor"}).json() + assert [s["filename"] for s in body["songs"]] == ["song00.archive", "song01.archive", "song02.archive"] + + +def test_legacy_dir_desc_keysets_correctly(client, server_mod): + # The legacy `sort=artist&dir=desc` shape must keyset against a DESC order + # (canonicalized to artist-desc), not seek `>` against it → no gaps/dupes. + _seed(server_mod, 20) + offset_order, page = [], 0 + while True: + body = client.get("/api/library", params={"sort": "artist", "dir": "desc", "size": 6, "page": page}).json() + if not body["songs"]: + break + offset_order.extend(s["filename"] for s in body["songs"]) + page += 1 + keyset, cursor, guard = [], "", 0 + while len(keyset) < 20 and guard < 25: + guard += 1 + params = {"sort": "artist", "dir": "desc", "size": 6} + if cursor: + params["after"] = cursor + body = client.get("/api/library", params=params).json() + keyset.extend(s["filename"] for s in body["songs"]) + cursor = body.get("next_cursor") + if not body["songs"] or not cursor: + break + assert keyset == offset_order + assert len(set(keyset)) == 20 + + +@pytest.mark.parametrize("sort", ["artist", "artist-desc", "recent"]) +def test_keyset_handles_null_sort_keys(client, server_mod, sort): + # NULL artist/mtime (corrupt/legacy rows past put()'s '' defaults) sort + # first in ASC / last in DESC; keyset must cover them exactly like OFFSET. + _seed(server_mod, 10) + server_mod.meta_db.conn.executemany( + "INSERT INTO songs (filename, mtime, size, title, artist) VALUES (?, NULL, 1, ?, NULL)", + [("zznull1.archive", "ZZ1"), ("zznull2.archive", "ZZ2")], + ) + server_mod.meta_db.conn.commit() + offset_order = _walk_offset(client, sort, 4, 12) + keyset_order = _walk_keyset(client, sort, 4, 12) + assert keyset_order == offset_order + assert len(keyset_order) == 12 and len(set(keyset_order)) == 12 + + +def test_non_keyset_sort_offers_no_cursor(client, server_mod): + _seed(server_mod, 5) + body = client.get("/api/library", params={"sort": "tuning", "size": 2}).json() + assert body["next_cursor"] is None # compound sort → OFFSET only + assert len(body["songs"]) == 2 From 5ed6f454e75c45ab2f49cd5c7d80543aca626876 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Mon, 29 Jun 2026 10:42:41 +0200 Subject: [PATCH 78/99] feat(library): smart collections as a library provider (#641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements feedBack#636 item 2 (P1) — saved library filters that stay live, the homelab primitive FeedBack was missing (Plex smart collections / Navidrome .nsp / *arr custom filters). A collection is a saved /api/library query surfaced as a registered library provider, so it appears in the v3 source picker and inherits the whole Songs UI (paging, stats, A–Z rail, art) with no new screen. - Storage reuses the playlist subsystem: a `playlists.rules` JSON column (additive, idempotent migration). A row with rules != NULL is a smart collection; list_playlists + get_playlist filter `rules IS NULL`, so collections are excluded from the manual-playlist list and read-only to every playlist mutation that gates on get_playlist. - SmartCollectionProvider (kind="local" — matched songs are local rows, so the client's play/art paths stay on the local branch) delegates query_page/ query_stats/query_artists to the local DB with the stored rules applied; tuning_names/get_art delegate straight through. Registered via a boot scan + on create/update (replace=True) / delete. - Rules mirror the raw /api/library query params; `_sanitize_collection_rules` drops unknown keys and is applied at API ingress AND on provider load, so a hand-edited / imported bad value can't crash a query. - API: GET/POST/PUT/DELETE /api/collections. Frontend: a "+ Save as collection" action in the v3 filter drawer (local provider + active filters only) that names the current filter set and switches to it. Reviewed by Codex; 3 findings fixed (local-kind playback path, save gated to local provider, re-sanitize persisted rules). Tests: tests/test_collections_api.py (CRUD, provider filtering, restart re-registration, kind=local, corrupt-rule tolerance, playlist isolation), tests/js/v3_collections.test.js. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + server.py | 245 +++++++++++++++++++++++++++++++- static/v3/songs.js | 50 +++++++ tests/js/v3_collections.test.js | 34 +++++ tests/test_collections_api.py | 164 +++++++++++++++++++++ 5 files changed, 492 insertions(+), 2 deletions(-) create mode 100644 tests/js/v3_collections.test.js create mode 100644 tests/test_collections_api.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a72eb9d..53d8dcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback). +- **Smart collections — save a set of library filters as a live, auto-updating source.** A collection is a saved `/api/library` query (e.g. "Drop-D tunings", "sloppak only", "recently added") that stays live: it's registered as a **library provider**, so it shows up in the v3 Songs source picker and inherits the whole grid UI — paging, stats, the A–Z rail, art — for free, with **no new screen**. Storage reuses the playlist subsystem (a `playlists.rules` JSON blob = a smart collection; membership is the live filter result, not stored songs, and collections are excluded from the manual-playlist list + read-only to playlist mutations). New `GET`/`POST`/`PUT`/`DELETE /api/collections`; a per-collection `SmartCollectionProvider` delegates `query_page`/`query_stats`/`query_artists` to the local DB with the stored rules applied; providers are re-registered from a boot scan so collections survive a restart. Rules mirror the raw `/api/library` query params (unknown keys dropped, never 500). Frontend: a "+ Save as collection" action in the v3 filter drawer (shown when filters are active) names the current filter set and switches to it. The charrette's "the homelab primitive FeedBack was missing" pick (got-feedback/feedBack#636 item 2); richer rule fields (accuracy, genre, difficulty) follow as the metadata work lands. Tests: `tests/test_collections_api.py`, `tests/js/v3_collections.test.js`. - **The settings backup now includes your library database + custom art — your scores, favorites, playlists, and play history are no longer the one thing a backup can't save.** `GET /api/settings/export` gains an additive `core_server_files` section carrying a **consistent snapshot of `web_library.db`** (taken via the SQLite online-backup API, so it's a complete single file even while the server is running) plus any custom **playlist covers** and **avatar** (`CONFIG_DIR/playlist_covers/`, `CONFIG_DIR/avatars/`). On `POST /api/settings/import` the database is **staged** to `web_library.db.restore` rather than written over the live, open DB; it's swapped in at the next startup (`_apply_pending_db_restore`, before the connection opens), which also clears the old WAL sidecars so a stale `-wal` can't be replayed onto the restored file — the import response sets `restart_required: true` and warns accordingly. Custom art is written immediately. The bundle stays backward-compatible (older servers ignore the new section). Came out of the library design charrette (dev-ops lens's top "protect irreplaceable data" pick, got-feedback/feedBack#636). _Known gap:_ custom uploaded **song** art is still commingled with the rebuildable thumbnail cache in `art_cache/`, so it isn't bundled yet (a tracked follow-up). Tests: `tests/test_settings_export_library_db.py` (snapshot consistency, staged-not-live restore, sidecar clearing, traversal rejection, full round-trip). - **A persisted wishlist — keep a list of songs you want but don't own yet.** New `wanted` table + `GET`/`POST`/`DELETE /api/wanted` give FeedBack the *arr-style "Wanted/Monitored" primitive it was missing: an entry is a *not-owned* song (artist/title/source/source_ref/note), so it lives in its own table rather than the playlist subsystem (which references owned local files). The API is idempotent on identity (case-insensitive artist+title, plus source+source_ref), so a producer — the `find_more` ownership-diff, or a manual add — can re-post without duplicating. Newest-first. Backend primitive for the charrette's wishlist finding (got-feedback/feedBack#636 item 4); the consuming UI lives in the producing plugin. Tests: `tests/test_wanted_api.py`. - **Practice-aware library home — a "Repertoire" meter + a "Keep practicing" shelf on the v3 Songs page.** The library opened cold into a flat sorted grid; now the unfiltered grid front door leads with two practice-aware surfaces built entirely from data already on hand (no new endpoints or stored state). A **Repertoire meter** shows how much of your library you can actually play — *"Repertoire: 12 of 80 songs · 7 in progress"* with a progress bar — counting songs at or above the same mastery threshold the green accuracy badge uses (≥ 90% best accuracy) over the unfiltered library total. A **"Keep practicing" shelf** is a horizontal row of your recently-played-but-not-yet-mastered songs (newest first, click to play) — the practice-accuracy-driven "continue" rail a media server can't do. Both reuse `/api/stats/best` (already loaded for the card badges) + `/api/stats/recent`; they show **only** on the grid view when you aren't searching/filtering/selecting, refresh after a song is scored, and collapse to nothing on an empty library. Soft-gamification only — descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging. Frontend-only: `static/v3/songs.js` (`renderLibraryHome`/`_repertoireCounts`), `static/v3/v3.css`. Came out of the library design charrette (the UX + gamification lenses' top pick). Tests: `tests/js/v3_keep_practicing.test.js`. diff --git a/server.py b/server.py index c546291..cbb9f25 100644 --- a/server.py +++ b/server.py @@ -657,6 +657,15 @@ class MetadataDB: ) """) self.conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_playlists_system_key ON playlists(system_key) WHERE system_key IS NOT NULL") + # Smart collections (feedBack#636 item 2): a playlist row whose `rules` + # JSON is non-NULL is a smart/dynamic collection — its membership is the + # LIVE result of those library filter params, not a stored song list. + # It surfaces as a registered library provider (the v3 source picker), + # so it inherits the whole Songs UI. Additive, idempotent migration. + try: + self.conn.execute("ALTER TABLE playlists ADD COLUMN rules TEXT") + except sqlite3.OperationalError: + pass # Wishlist / "wanted" (feedBack#636 item 4): a persisted, actionable # list of songs the user does NOT own yet — the *arr "Wanted/Monitored" # analogue. Unlike playlists (which reference owned local songs by @@ -1497,6 +1506,7 @@ class MetadataDB: from urllib.parse import quote rows = self.conn.execute( "SELECT id, name, system_key, created_at, updated_at FROM playlists " + "WHERE rules IS NULL " # smart collections live in the source picker, not here "ORDER BY (system_key IS NULL), name COLLATE NOCASE" ).fetchall() out = [] @@ -1568,14 +1578,79 @@ class MetadataDB: self.conn.commit() return cur.rowcount > 0 + # ── Smart collections (feedBack#636 item 2) ─────────────────────────── + @staticmethod + def _collection_row(r) -> dict: + rules = {} + if r[3]: + try: + parsed = json.loads(r[3]) + if isinstance(parsed, dict): + rules = parsed + except (ValueError, TypeError): + rules = {} + return {"id": r[0], "name": r[1], "system_key": r[2], "rules": rules, + "created_at": r[4], "updated_at": r[5]} + + def is_collection(self, pid: int) -> bool: + row = self.conn.execute( + "SELECT rules IS NOT NULL FROM playlists WHERE id = ?", (pid,) + ).fetchone() + return bool(row and row[0]) + + def list_collections(self) -> list[dict]: + rows = self.conn.execute( + "SELECT id, name, system_key, rules, created_at, updated_at FROM playlists " + "WHERE rules IS NOT NULL ORDER BY name COLLATE NOCASE" + ).fetchall() + return [self._collection_row(r) for r in rows] + + def get_collection(self, pid: int) -> dict | None: + r = self.conn.execute( + "SELECT id, name, system_key, rules, created_at, updated_at FROM playlists " + "WHERE id = ? AND rules IS NOT NULL", (pid,) + ).fetchone() + return self._collection_row(r) if r else None + + def create_collection(self, name: str, rules: dict) -> dict: + with self._lock: + cur = self.conn.execute( + "INSERT INTO playlists (name, system_key, rules, created_at, updated_at) " + "VALUES (?, NULL, ?, datetime('now'), datetime('now'))", + (name, json.dumps(rules or {})), + ) + self.conn.commit() + pid = cur.lastrowid + return self.get_collection(pid) + + def update_collection(self, pid: int, name: str | None = None, + rules: dict | None = None) -> dict | None: + if not self.is_collection(pid): + return None + with self._lock: + if name is not None: + self.conn.execute("UPDATE playlists SET name = ? WHERE id = ?", (name, pid)) + if rules is not None: + self.conn.execute("UPDATE playlists SET rules = ? WHERE id = ?", + (json.dumps(rules or {}), pid)) + self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,)) + self.conn.commit() + return self.get_collection(pid) + def get_playlist(self, pid: int) -> dict | None: # A path-param int outside SQLite's 64-bit range raises OverflowError at # bind time (→ 500). Treat it as a miss; every mutating playlist handler # gates on this first, so the guard covers them too. if not isinstance(pid, int) or not (-(2**63) <= pid < 2**63): return None + # `rules IS NULL` excludes smart collections (#636 item 2): they share + # the playlists table but their membership is rules-based, so every + # manual-playlist mutation (add/remove/reorder/cover) that gates on + # get_playlist uniformly 404s on a collection id — collections are + # managed only through /api/collections. head = self.conn.execute( - "SELECT id, name, system_key, created_at, updated_at FROM playlists WHERE id = ?", (pid,) + "SELECT id, name, system_key, created_at, updated_at FROM playlists " + "WHERE id = ? AND rules IS NULL", (pid,) ).fetchone() if not head: return None @@ -2800,7 +2875,126 @@ class LibraryProviderRegistry: library_providers = LibraryProviderRegistry() -library_providers.register(LocalLibraryProvider(meta_db)) +_local_library_provider = LocalLibraryProvider(meta_db) +library_providers.register(_local_library_provider) + + +# Keys `_library_filter_args` (and a smart collection's stored `rules`) accept. +_LIBRARY_FILTER_PARAM_KEYS = frozenset(( + "q", "favorites", "format", "artist", "album", + "arrangements_has", "arrangements_lacks", "stems_has", "stems_lacks", + "has_lyrics", "tunings", +)) +# Rules mirror the raw /api/library query params (so the provider can feed them +# straight through `_library_filter_args`, and the frontend can build a rule from +# the same query string it already constructs). Multi-value filters are CSV +# strings; `favorites` is 0/1; the rest are plain strings. +_RULE_CSV_KEYS = frozenset(( + "tunings", "arrangements_has", "arrangements_lacks", "stems_has", "stems_lacks", +)) +_RULE_STR_KEYS = frozenset(("q", "format", "artist", "album", "has_lyrics", "sort")) + + +def _sanitize_collection_rules(raw) -> dict: + """Normalize rules to the raw query-param format, keeping only known keys. A + list for a multi-value filter is joined to CSV; `favorites` becomes 0/1. + Unknown keys are dropped so a rule survives a filter-vocab change rather than + 500-ing. Applied at API ingress AND when a provider loads a persisted row, so + a hand-edited / imported bad value (e.g. an int where a string is expected, + or a list for `sort`) can never crash a query.""" + if not isinstance(raw, dict): + return {} + out: dict = {} + for k, v in raw.items(): + if k in _RULE_CSV_KEYS: + if isinstance(v, list): + vals = [str(x) for x in v if isinstance(x, (str, int)) and not isinstance(x, bool)] + elif isinstance(v, str): + vals = [s for s in (p.strip() for p in v.split(",")) if s] + else: + continue + if vals: + out[k] = ",".join(vals) + elif k == "favorites": + if v: + out[k] = 1 + elif k in _RULE_STR_KEYS: + if isinstance(v, (str, int)) and not isinstance(v, bool): + s = str(v).strip() + if s: + out[k] = s + return out + + +class SmartCollectionProvider: + """A saved library filter, surfaced as a source (#636 item 2). Browse/stats + delegate to the local DB with the collection's stored `rules` applied — so + selecting it in the v3 source picker shows exactly that filtered slice with + the whole Songs UI (paging, stats, A–Z rail, art) for free. P1: the rules + ARE the query (live in-collection search is a P2 nicety). The matched songs + are local rows, so `kind="local"` keeps the client's play/art paths on the + local (not remote-sync) branch and art delegates straight through.""" + kind = "local" + capabilities = ("library.read", "art.read") + + def __init__(self, collection: dict, local: "LocalLibraryProvider"): + self._local = local + self.update(collection) + + def update(self, collection: dict) -> None: + self.id = f"collection:{collection['id']}" + self.collection_id = collection["id"] + self.label = collection.get("name") or "Collection" + # Re-sanitize on load: persisted JSON may predate the current vocab or + # have been hand-edited; never let a bad value reach a query. + self._rules = _sanitize_collection_rules(collection.get("rules") or {}) + + def _filter_kwargs(self) -> dict: + return _library_filter_args(**{k: v for k, v in self._rules.items() + if k in _LIBRARY_FILTER_PARAM_KEYS}) + + def _sort(self, fallback: str) -> str: + # A collection may pin its own sort (e.g. "recently added"); query_page + # falls back safely for an unknown value, so no validation needed here. + return self._rules.get("sort") or fallback + + def query_page(self, *, page=0, size=24, sort="artist", direction="asc", + naming_mode="legacy", **_ignore): + return self._local._db.query_page( + page=page, size=size, sort=self._sort(sort), direction=direction, + naming_mode=naming_mode, **self._filter_kwargs()) + + def query_artists(self, *, letter="", page=0, size=50, naming_mode="legacy", **_ignore): + return self._local._db.query_artists( + letter=letter, page=page, size=size, naming_mode=naming_mode, + **self._filter_kwargs()) + + def query_stats(self, *, sort="artist", want_sort_letters=False, + naming_mode="legacy", **_ignore): + return self._local._db.query_stats( + sort=self._sort(sort), want_sort_letters=want_sort_letters, + naming_mode=naming_mode, **self._filter_kwargs()) + + def tuning_names(self): + return self._local.tuning_names() + + async def get_art(self, song_id: str): + return await self._local.get_art(song_id) + + +def _sync_collection_provider(collection: dict) -> None: + """Register (or replace) the provider for one collection.""" + library_providers.register( + SmartCollectionProvider(collection, _local_library_provider), replace=True) + + +def _unregister_collection_provider(pid: int) -> None: + library_providers.unregister(f"collection:{pid}") + + +# Boot scan: surface every saved collection as a source. +for _c in meta_db.list_collections(): + _sync_collection_provider(_c) def register_library_provider(provider: object, *, replace: bool = False, owner_plugin_id: str | None = None) -> object: @@ -5675,6 +5869,53 @@ def api_delete_playlist_cover(pid: int): return {"ok": True} +# ── Smart collections API (feedBack#636 item 2) ─────────────────────────────── +# (rule schema + `_sanitize_collection_rules` are defined with the provider.) + +@app.get("/api/collections") +def api_list_collections(): + """Smart/dynamic collections (saved live library filters).""" + return {"collections": meta_db.list_collections()} + + +@app.post("/api/collections") +def api_create_collection(data: dict): + """Create a collection from a name + a set of library filter rules. It + immediately appears as a source in the library provider picker.""" + if not isinstance(data, dict): + return JSONResponse({"error": "body must be an object"}, status_code=400) + name = _clean_str(data.get("name")) + if not name: + return JSONResponse({"error": "name required"}, status_code=400) + col = meta_db.create_collection(name, _sanitize_collection_rules(data.get("rules"))) + _sync_collection_provider(col) + return {"ok": True, "collection": col} + + +@app.put("/api/collections/{pid}") +def api_update_collection(pid: int, data: dict): + """Rename a collection and/or replace its rules.""" + if not isinstance(data, dict): + return JSONResponse({"error": "body must be an object"}, status_code=400) + name = _clean_str(data.get("name")) or None + rules = _sanitize_collection_rules(data["rules"]) if "rules" in data else None + col = meta_db.update_collection(pid, name=name, rules=rules) + if col is None: + return JSONResponse({"error": "collection not found"}, status_code=404) + _sync_collection_provider(col) + return {"ok": True, "collection": col} + + +@app.delete("/api/collections/{pid}") +def api_delete_collection(pid: int): + """Delete a collection and unregister its provider.""" + if not meta_db.is_collection(pid): + return JSONResponse({"error": "collection not found"}, status_code=404) + meta_db.delete_playlist(pid) + _unregister_collection_provider(pid) + 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.""" diff --git a/static/v3/songs.js b/static/v3/songs.js index 06180d3..c46b3d9 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -202,6 +202,50 @@ return p; } + // The active filter set as a smart-collection rule object (raw query-param + // format the backend stores). Mirrors queryParams' filter fields, minus + // provider/page/size. Empty object → nothing worth saving as a collection. + function currentFilterRules() { + const f = state.filters, r = {}; + if (state.q) r.q = state.q; + if (state.format) r.format = state.format; + if (state.artist) r.artist = state.artist; + if (state.album) r.album = state.album; + if (f.arr_has.length) r.arrangements_has = f.arr_has.join(','); + if (f.arr_lacks.length) r.arrangements_lacks = f.arr_lacks.join(','); + if (f.stem_has.length) r.stems_has = f.stem_has.join(','); + if (f.stem_lacks.length) r.stems_lacks = f.stem_lacks.join(','); + if (f.lyrics) r.has_lyrics = f.lyrics; + if (f.tunings.length) r.tunings = f.tunings.join(','); + if (state.sort && state.sort !== 'artist') r.sort = state.sort; + return r; + } + + // Save the current filter set as a smart collection (a saved live query that + // shows up as a source in the picker). #636 item 2. + async function saveCurrentAsCollection() { + const rules = currentFilterRules(); + if (!Object.keys(rules).length) return; + const name = ((await window.uiPrompt({ + title: 'Save as collection', + label: 'A live view of the current filters, in the source picker.', + okLabel: 'Save', + placeholder: 'Collection name', + })) || '').trim(); + if (!name) return; + try { + const res = await fetch('/api/collections', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, rules }), + }); + if (!res.ok) return; + const col = (await res.json()).collection; + closeDrawer(); + if (col && col.id != null) state.provider = 'collection:' + col.id; + await render(); // rebuilds the toolbar (provider picker now lists + selects it) + } catch (e) { /* offline / aborted — leave the drawer as-is */ } + } + function albumsForArtist(name) { const a = (state.artistCatalog || []).find((x) => x.name === name); return a ? (a.albums || []) : []; @@ -1162,6 +1206,11 @@ } return triPill('tuning', val, label + ' (' + t.count + ')', f.tunings.includes(val) ? 'has' : 'any'); }).join('') || 'No tunings') + + // Collections always replay against the LOCAL library, so only offer + // "save" when browsing local with a non-empty filter set. + (state.provider === 'local' && Object.keys(currentFilterRules()).length + ? '
' + : '') + '
' + '
'; @@ -1173,6 +1222,7 @@ renderDrawer(); })); d.querySelectorAll('[data-lyrics]').forEach((b) => b.addEventListener('click', () => { f.lyrics = b.getAttribute('data-lyrics'); renderDrawer(); })); + d.querySelector('[data-drawer-save]')?.addEventListener('click', saveCurrentAsCollection); d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer); d.querySelector('[data-drawer-clear]')?.addEventListener('click', async () => { state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [] }; diff --git a/tests/js/v3_collections.test.js b/tests/js/v3_collections.test.js new file mode 100644 index 0000000..40809c3 --- /dev/null +++ b/tests/js/v3_collections.test.js @@ -0,0 +1,34 @@ +// Pins the v3 "Save as collection" wiring in static/v3/songs.js (#636 item 2). +// A smart collection is a saved live library filter, surfaced as a source in +// the provider picker; the drawer can save the current filter set as one. +// Source-level only — same strategy as tests/js/v3_az_rail.test.js. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js'); +const src = fs.readFileSync(SONGS_JS, 'utf8'); + +test('currentFilterRules builds the raw query-param rule object', () => { + assert.match(src, /function\s+currentFilterRules/); + // Multi-value filters are CSV strings (what the backend stores / re-parses). + assert.match(src, /r\.tunings\s*=\s*f\.tunings\.join\(','\)/); + assert.match(src, /r\.arrangements_has\s*=\s*f\.arr_has\.join\(','\)/); +}); + +test('saving POSTs to /api/collections with name + rules', () => { + assert.match( + src, + /fetch\('\/api\/collections',[\s\S]*?JSON\.stringify\(\{\s*name,\s*rules\s*\}\)/, + 'saveCurrentAsCollection must POST {name, rules} to /api/collections', + ); + // After save, switch the source to the new collection and rebuild the UI. + assert.match(src, /state\.provider\s*=\s*'collection:'\s*\+\s*col\.id/); +}); + +test('the drawer shows a Save-as-collection action only when filters are set', () => { + assert.match(src, /Object\.keys\(currentFilterRules\(\)\)\.length[\s\S]*?data-drawer-save/); + assert.match(src, /data-drawer-save[\s\S]*?saveCurrentAsCollection/); +}); diff --git a/tests/test_collections_api.py b/tests/test_collections_api.py new file mode 100644 index 0000000..3a494c6 --- /dev/null +++ b/tests/test_collections_api.py @@ -0,0 +1,164 @@ +"""Tests for smart/dynamic collections (got-feedback/feedBack#636 item 2). + +A collection is a saved set of library filter rules, surfaced as a registered +library provider so it inherits the v3 Songs UI. Storage reuses the playlists +table (a `rules` JSON blob → smart collection); membership is the LIVE filter +result, not stored songs. +""" + +import importlib +import sys + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def server_mod(tmp_path, monkeypatch): + monkeypatch.setenv("CONFIG_DIR", str(tmp_path)) + sys.modules.pop("server", None) + mod = importlib.import_module("server") + yield mod + conn = getattr(getattr(mod, "meta_db", None), "conn", None) + if conn is not None: + conn.close() + + +@pytest.fixture() +def client(server_mod): + c = TestClient(server_mod.app) + try: + yield c + finally: + c.close() + + +def _put(server_mod, *, filename, title, artist, tuning_name="E Standard", tuning_sort_key=0): + server_mod.meta_db.put(filename, 1.0, 1, { + "title": title, "artist": artist, "album": "LP", "year": "", "duration": 1.0, + "tuning": tuning_name, "arrangements": [], "has_lyrics": False, "format": "archive", + "stem_count": 0, "stem_ids": [], "tuning_name": tuning_name, + "tuning_sort_key": tuning_sort_key, "tuning_offsets": "", + }) + + +def _seed_mixed(server_mod): + _put(server_mod, filename="d1.archive", title="Drop One", artist="Anna", tuning_name="Drop D", tuning_sort_key=-2) + _put(server_mod, filename="d2.archive", title="Drop Two", artist="Bea", tuning_name="Drop D", tuning_sort_key=-2) + _put(server_mod, filename="e1.archive", title="Std One", artist="Cy", tuning_name="E Standard") + + +# ── CRUD ──────────────────────────────────────────────────────────────────── + +def test_create_list_delete_collection(client): + assert client.get("/api/collections").json() == {"collections": []} + + r = client.post("/api/collections", json={"name": "Drop D stuff", "rules": {"tunings": ["Drop D"]}}) + assert r.status_code == 200 + col = r.json()["collection"] + assert col["name"] == "Drop D stuff" + assert col["rules"] == {"tunings": "Drop D"} # raw query-param format + cid = col["id"] + + listed = client.get("/api/collections").json()["collections"] + assert [c["name"] for c in listed] == ["Drop D stuff"] + + assert client.request("DELETE", f"/api/collections/{cid}").json() == {"ok": True} + assert client.get("/api/collections").json() == {"collections": []} + + +def test_create_requires_name_and_sanitizes_rules(client): + assert client.post("/api/collections", json={"rules": {}}).status_code == 400 + # Unknown rule keys are dropped (never 500); known ones normalized to the + # raw query-param format (list→CSV, favorites→1). + col = client.post("/api/collections", json={ + "name": "Mix", "rules": {"tunings": ["Drop D", "Eb Standard"], "sort": "title", "bogus": "x", "favorites": True}, + }).json()["collection"] + assert col["rules"] == {"tunings": "Drop D,Eb Standard", "sort": "title", "favorites": 1} + + +def test_update_collection(client): + cid = client.post("/api/collections", json={"name": "A", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"] + r = client.put(f"/api/collections/{cid}", json={"name": "B", "rules": {"format": "sloppak"}}) + assert r.status_code == 200 + assert r.json()["collection"]["name"] == "B" + assert r.json()["collection"]["rules"] == {"format": "sloppak"} + assert client.put("/api/collections/99999", json={"name": "x"}).status_code == 404 + + +# ── Provider behaviour ────────────────────────────────────────────────────── + +def test_collection_registers_as_a_provider(client, server_mod): + _seed_mixed(server_mod) + cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"] + providers = client.get("/api/library/providers").json()["providers"] + ids = [p["id"] for p in providers] + assert f"collection:{cid}" in ids + + +def test_collection_provider_returns_only_matching_songs(client, server_mod): + _seed_mixed(server_mod) + cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"] + pid = f"collection:{cid}" + + page = client.get("/api/library", params={"provider": pid}).json() + titles = sorted(s["title"] for s in page["songs"]) + assert titles == ["Drop One", "Drop Two"] # E Standard song excluded + + stats = client.get("/api/library/stats", params={"provider": pid}).json() + assert stats["total_songs"] == 2 + + +def test_collection_provider_is_local_kind(client, server_mod): + # kind="local" keeps the client's play/art paths on the local branch (a + # collection's matched songs are local rows), not the remote-sync branch. + cid = client.post("/api/collections", json={"name": "C", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"] + prov = next(p for p in client.get("/api/library/providers").json()["providers"] + if p["id"] == f"collection:{cid}") + assert prov["kind"] == "local" + + +def test_collection_tolerates_corrupt_persisted_rules(client, server_mod): + # A hand-edited / imported bad rules row (int where a string is expected, a + # list for `sort`) must not crash the query — the provider re-sanitizes on + # load. Write the bad JSON straight past the API sanitizer. + _seed_mixed(server_mod) + cid = client.post("/api/collections", json={"name": "Bad", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"] + # `artist: []` (list for a string field) and `sort: []` (unhashable) are + # the values that would crash `.strip()` / `sort_map.get` if they reached a + # query — they must be dropped, leaving the valid `tunings` rule intact. + server_mod.meta_db.conn.execute( + "UPDATE playlists SET rules = ? WHERE id = ?", + ('{"artist": [], "sort": [], "tunings": ["Drop D"]}', cid), + ) + server_mod.meta_db.conn.commit() + server_mod._sync_collection_provider(server_mod.meta_db.get_collection(cid)) + + r = client.get("/api/library", params={"provider": f"collection:{cid}"}) + assert r.status_code == 200 # no 500/503 from bad rules + assert sorted(s["title"] for s in r.json()["songs"]) == ["Drop One", "Drop Two"] + + +def test_collection_provider_survives_restart(client, server_mod, tmp_path, monkeypatch): + _seed_mixed(server_mod) + cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"] + server_mod.meta_db.conn.close() + # Re-import the server (same CONFIG_DIR) → boot scan must re-register it. + sys.modules.pop("server", None) + mod2 = importlib.import_module("server") + try: + ids = [p["id"] for p in mod2.library_providers.list()] + assert f"collection:{cid}" in ids + finally: + mod2.meta_db.conn.close() + + +# ── Isolation from manual playlists ───────────────────────────────────────── + +def test_collections_excluded_from_playlists_and_are_read_only(client): + cid = client.post("/api/collections", json={"name": "Coll", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"] + # Not listed among manual playlists... + assert all(p["id"] != cid for p in client.get("/api/playlists").json()) + # ...and manual-playlist mutations 404 on a collection id (get_playlist gate). + assert client.post(f"/api/playlists/{cid}/songs", json={"filename": "d1.archive"}).status_code == 404 + assert client.get(f"/api/playlists/{cid}").status_code == 404 From a791a0d8fe22248a75ccf558729c8d0b631eb750 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Mon, 29 Jun 2026 12:26:29 +0200 Subject: [PATCH 79/99] feat(v3): DOM-virtualize the Songs grid (#636 item 3 stage 2) (#643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v3 Songs grid appended every scrolled page and never released nodes, so card-node count grew unbounded with scroll depth (24 → 624 → 2001 for a 2000-song library). Replace it with a windowed/recycled render: only the visible window (± overscan) is in the DOM while a #v3-songs-gridsizer element sized to ceil(total/cols)*rowH gives the scrollbar full-library geometry; #v3-songs-grid is absolutely positioned to the first visible row. - state.songs is a sparse, absolutely-indexed store filled a page at a time by ensureWindow(): the stage-1 keyset cursor for contiguous forward scroll (O(page)), OFFSET page= for jumps/restore/non-keyset providers. _loadPage shares an in-flight promise per page and an epoch guard discards a stale fetch that lands after a reset. - A–Z rail seeks directly via sort_letters cumulative counts (O(1), no page-through); bounded scan fallback for legacy providers without it. - Snapshot/restore is now scrollTop-based (geometry is stable). Select mode, accuracy badges, ⋮ menu, plugin card actions, and tree/folder coexistence survive cards recycling; renderWindow re-renders when select mode toggles. - Plugins get window.v3Songs.visibleCards() + a v3:library-window-rendered event instead of assuming all cards are present (highway-stutter lesson). Verified in a browser against a seeded 2001-song library: DOM bounded to ~60 nodes while the count reads "2001 songs", rail jump lands on the target row, selection survives recycling, scroll-restore exact. Codex-reviewed (3 findings fixed: stale-fetch epoch guard, await-in-flight page promise, select-mode resync on cached re-entry). Frontend-only. Tests: tests/browser/v3-grid-virtualization.spec.ts pins the bounded-DOM invariant + direct rail jump; tests/js/v3_az_rail.test.js and v3_songs_scroll.test.js updated to the new wiring. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + static/v3/songs.js | 481 ++++++++++++++----- static/v3/v3.css | 23 + tests/browser/v3-grid-virtualization.spec.ts | 130 +++++ tests/js/v3_az_rail.test.js | 33 +- tests/js/v3_songs_scroll.test.js | 20 +- 6 files changed, 547 insertions(+), 141 deletions(-) create mode 100644 tests/browser/v3-grid-virtualization.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 53d8dcd..e31c455 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **A–Z rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`. - **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback). - **Smart collections — save a set of library filters as a live, auto-updating source.** A collection is a saved `/api/library` query (e.g. "Drop-D tunings", "sloppak only", "recently added") that stays live: it's registered as a **library provider**, so it shows up in the v3 Songs source picker and inherits the whole grid UI — paging, stats, the A–Z rail, art — for free, with **no new screen**. Storage reuses the playlist subsystem (a `playlists.rules` JSON blob = a smart collection; membership is the live filter result, not stored songs, and collections are excluded from the manual-playlist list + read-only to playlist mutations). New `GET`/`POST`/`PUT`/`DELETE /api/collections`; a per-collection `SmartCollectionProvider` delegates `query_page`/`query_stats`/`query_artists` to the local DB with the stored rules applied; providers are re-registered from a boot scan so collections survive a restart. Rules mirror the raw `/api/library` query params (unknown keys dropped, never 500). Frontend: a "+ Save as collection" action in the v3 filter drawer (shown when filters are active) names the current filter set and switches to it. The charrette's "the homelab primitive FeedBack was missing" pick (got-feedback/feedBack#636 item 2); richer rule fields (accuracy, genre, difficulty) follow as the metadata work lands. Tests: `tests/test_collections_api.py`, `tests/js/v3_collections.test.js`. - **The settings backup now includes your library database + custom art — your scores, favorites, playlists, and play history are no longer the one thing a backup can't save.** `GET /api/settings/export` gains an additive `core_server_files` section carrying a **consistent snapshot of `web_library.db`** (taken via the SQLite online-backup API, so it's a complete single file even while the server is running) plus any custom **playlist covers** and **avatar** (`CONFIG_DIR/playlist_covers/`, `CONFIG_DIR/avatars/`). On `POST /api/settings/import` the database is **staged** to `web_library.db.restore` rather than written over the live, open DB; it's swapped in at the next startup (`_apply_pending_db_restore`, before the connection opens), which also clears the old WAL sidecars so a stale `-wal` can't be replayed onto the restored file — the import response sets `restart_required: true` and warns accordingly. Custom art is written immediately. The bundle stays backward-compatible (older servers ignore the new section). Came out of the library design charrette (dev-ops lens's top "protect irreplaceable data" pick, got-feedback/feedBack#636). _Known gap:_ custom uploaded **song** art is still commingled with the rebuildable thumbnail cache in `art_cache/`, so it isn't bundled yet (a tracked follow-up). Tests: `tests/test_settings_export_library_db.py` (snapshot consistency, staged-not-live restore, sidecar clearing, traversal rejection, full round-trip). diff --git a/static/v3/songs.js b/static/v3/songs.js index c46b3d9..5b64135 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -40,6 +40,9 @@ const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals']; const STEMS = ['guitar', 'bass', 'drums', 'vocals', 'other']; const PAGE_SIZE = 24; + // Extra rows rendered above/below the viewport so a fast scroll doesn't flash + // blank before the next window render lands. + const OVERSCAN_ROWS = 2; const SCROLL_STATE_KEY = 'v3:songs-scroll-state'; const btnCtrl = 'bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary'; @@ -51,7 +54,21 @@ artistCatalog: [], renderedHash: '', scrollBound: false, songsById: {}, selectMode: false, selected: new Set(), - railLetters: null, railJumping: false, + railLetters: null, railLettersAreSongCounts: false, railJumping: false, + // ── Windowed (virtualized) grid, stage 2 of #636 item 3 ── + // state.songs is a SPARSE array indexed by absolute library position + // (0..total-1); only the fetched pages are populated and only the visible + // window ± overscan is ever in the DOM. The sizer element gives the + // scrollbar the full-library geometry. See renderWindow / ensureWindow. + songs: [], // sparse: absoluteIndex → song row + pageCursors: {}, // pageIndex → next_cursor (keyset forward fast-path) + keysetOk: false, // did page 0 return a non-null cursor (local + keyset sort)? + pageProms: {}, // pageIndex → in-flight fetch promise (de-dupe + await) + epoch: 0, // bumped on every reset; a stale in-flight fetch checks it + geom: null, // { cols, rowH, gap } measured from the live grid + winRange: null, // { start, end } last rendered, to skip redundant renders + renderedSelectMode: null, // the selectMode the current window was rendered under + gridResizeBound: false, }; // ── A–Z jump rail ─────────────────────────────────────────────────────── @@ -112,12 +129,13 @@ function _saveLibraryScrollSnapshot() { const main = _getV3MainScroller(); + // Geometry is now stable (the sizer reserves the full scroll height + // regardless of how many cards are actually in the DOM), so the scroll + // position alone is enough to restore — no page-depth bookkeeping. const snap = { hash: _libraryStateHash(), scrollTop: main ? main.scrollTop : 0, view: state.view, - page: state.page, - loadedCount: loadedCount(), }; try { sessionStorage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap)); } catch (e) { /* quota / private mode */ } } @@ -145,9 +163,14 @@ setTimeout(apply, 0); } + // The windowed grid keeps only a slice of cards in the DOM, so "intact" can no + // longer mean "has cards" — it means the grid + sizer chrome exist and page 0 + // is loaded (state.total known, first rows present), so renderWindow() can + // repaint the right slice at any scroll position. function _gridDomIntact() { const grid = document.getElementById('v3-songs-grid'); - return !!grid && loadedCount() > 0; + const sizer = document.getElementById('v3-songs-gridsizer'); + return !!grid && !!sizer && state.total > 0 && state.songs[0] !== undefined; } function _treeDomIntact() { @@ -156,32 +179,6 @@ return !!(tree.querySelector('[data-fn]') || tree.querySelector('details')); } - // Resolve once no grid fetch is in flight. loadGrid early-returns while - // state.loading is set, so paging without waiting would silently skip a - // page (it bumps state.page but the fetch no-ops). Bounded so a wedged - // load can't hang the restore forever. - async function _waitForGridIdle(maxMs) { - const cap = (maxMs == null ? 8000 : maxMs); - let waited = 0; - while (state.loading && waited < cap) { - await new Promise((r) => setTimeout(r, 16)); - waited += 16; - } - } - - async function _ensureGridPagesThrough(targetPage) { - const goal = Math.max(0, Number(targetPage) || 0); - // The initial page-0 load (or an auto-fill) may still be settling; wait - // for the real state.total before deciding how far to page, otherwise a - // total of 0 exits the loop immediately and the depth never restores. - await _waitForGridIdle(); - while (state.page < goal && loadedCount() < state.total) { - if (state.loading) { await _waitForGridIdle(); continue; } - state.page++; - await loadGrid(false); - } - } - function queryParams(extra, opts) { const f = state.filters; const skipArtistAlbum = opts && opts.catalog; @@ -302,6 +299,11 @@ if (treeBtn) treeBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); const folderBtn = document.getElementById('v3-songs-folder-btn'); if (folderBtn) folderBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); + // Select button tracks state.selectMode — the screen-leave teardown clears + // select mode, so a cached-DOM re-entry must re-style the button (and the + // window re-renders without checkboxes via renderWindow's selectMode check). + const selBtn = document.getElementById('v3-songs-select'); + if (selBtn) selBtn.className = btnCtrl + (state.selectMode ? ' bg-fb-primary text-white' : ''); updateFilterBadge(); } @@ -551,6 +553,9 @@ host.innerHTML = meter + shelfHtml; host.classList.remove('hidden'); + // The home block sits above the grid sizer, so its height shifts where the + // window maps in scroll space — repaint the window once it's laid out. + if (state.view === 'grid') requestWindowRender(); // Wire shelf cards → play (mirrors playCard's local path; recents are // always local-library rows, so no provider sync is needed). host.querySelectorAll('.v3-kp-card').forEach((btn) => btn.addEventListener('click', () => { @@ -653,8 +658,11 @@ const overlay = overlayActs.length ? '
' + overlayActs.map(actBtn).join('') + '
' : ''; + // Recycled cards re-render from state, so a selected card must paint its + // ring on initial markup (toggleSelect only adds it to a live node). + const selRing = state.selected.has(key) ? ' ring-2 ring-fb-primary' : ''; return '
' + - '
' + + '
' + '' + tuning + checkbox + accuracyBadge(key) + fmtBadge(song) + overlay + '
' + @@ -665,7 +673,10 @@ '
' + '
' + esc(song.title) + '
' + '
' + esc(song.artist) + '
' + - (arrChips ? '
' + arrChips + '
' : '') + + // Always emit the chip row (even when empty) at a FIXED single-line + // height — uniform card height is what makes the windowed grid's + // absolute-position math exact (.v3-card-chips in v3.css). + '
' + arrChips + '
' + '
'; } @@ -852,76 +863,259 @@ try { const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); return r.ok ? r.json() : null; } catch (e) { return null; } } - // ── Grid (paged + infinite scroll) ─────────────────────────────────────-- - async function loadGrid(reset) { - // A reset requested mid-fetch (provider/sort/filter/search change) must - // not be dropped — remember it and re-run once the in-flight load - // returns, otherwise the stale response repopulates the grid. - if (state.loading) { if (reset) state.pendingReset = true; return; } - const grid = document.getElementById('v3-songs-grid'); - if (!grid) return; - // A reset wipes the grid (and any open card menu's DOM); close the menu - // first so its document-level click closer doesn't leak. - if (reset) { if (_closeCardMenu) _closeCardMenu(); state.page = 0; state.total = 0; grid.innerHTML = ''; } - state.loading = true; - const data = await jget('/api/library?' + queryParams({ page: state.page, size: PAGE_SIZE }).toString()); - state.loading = false; - if (state.pendingReset) { state.pendingReset = false; return loadGrid(true); } - if (!data) return; - state.total = data.total || 0; - (data.songs || []).forEach((s) => { state.songsById[cardKey(s)] = s; grid.insertAdjacentHTML('beforeend', songCard(s)); }); - wireCards(grid); - const countEl = document.getElementById('v3-songs-count'); - if (countEl) countEl.textContent = state.total + ' song' + (state.total === 1 ? '' : 's'); - const loaded = grid.querySelectorAll('[data-fn]').length; - const sentinel = document.getElementById('v3-songs-sentinel'); - if (sentinel) sentinel.style.display = loaded < state.total ? 'block' : 'none'; - // Auto-fill: if the grid doesn't yet overflow the scroller, keep loading - // (so a short first page still becomes scrollable without user action). - maybeFill(); - } + // ── Grid (windowed / recycled — #636 item 3 stage 2) ───────────────────-- + // Only the visible cards (± OVERSCAN_ROWS) live in the DOM; a sizer element + // sized to the FULL library gives the scrollbar its geometry. state.songs is + // a sparse array indexed by absolute position; ensureWindow() fetches the + // pages a window needs (keyset forward fast-path, else OFFSET random-access), + // and renderWindow() paints the slice the current scrollTop maps to. + // Live count of cards actually in the DOM — bounded under windowing, so it's + // the bounded-DOM invariant the tests assert (NOT a "loaded so far" signal). function loadedCount() { return document.querySelectorAll('#v3-songs-grid [data-fn]').length; } // The scroll listener lives on the SHARED #v3-main container, so guard every - // paging entry point on the Songs screen actually being active — otherwise - // scrolling another screen would keep fetching /api/library into the hidden - // grid after Songs has been visited once. + // render entry point on the Songs screen actually being active — otherwise + // scrolling another screen would keep rendering into the hidden grid after + // Songs has been visited once. function songsActive() { const el = document.getElementById('v3-songs'); return !!el && el.classList.contains('active'); } - function loadNext() { - if (state.loading || state.view !== 'grid' || !songsActive()) return; - if (loadedCount() < state.total) { state.page++; loadGrid(false); } + function _gridEl() { return document.getElementById('v3-songs-grid'); } + function _sizerEl() { return document.getElementById('v3-songs-gridsizer'); } + + // Measure columns + row pitch from the LIVE grid: cols from the computed + // grid-template-columns (tracks resolve to explicit pixel sizes), rowH from a + // rendered card's box + the grid row-gap. Cards are uniform height (aspect- + // square art + truncated text + the fixed-height .v3-card-chips row), so one + // measured card sizes every row. Falls back to a coarse estimate until the + // first card exists, then re-measures. + function measureGeom() { + const grid = _gridEl(); + if (!grid) return state.geom || { cols: 2, rowH: 240, gap: 16 }; + const cs = getComputedStyle(grid); + const tracks = (cs.gridTemplateColumns || '').trim(); + const cols = (tracks && tracks !== 'none') + ? Math.max(1, tracks.split(/\s+/).length) + : (state.geom ? state.geom.cols : 2); + const gap = parseFloat(cs.rowGap) || 0; + let rowH = state.geom && state.geom.rowH; + const card = grid.querySelector('[data-fn]') || grid.querySelector('.v3-card-skel'); + if (card) { const h = card.getBoundingClientRect().height; if (h > 0) rowH = h + gap; } + if (!rowH || rowH <= 0) rowH = 240 + gap; // estimate until a card is measured + state.geom = { cols, rowH, gap }; + return state.geom; } - function maybeFill() { - const main = document.getElementById('v3-main'); - if (!main || state.view !== 'grid' || state.loading || !songsActive()) return; - // Not tall enough to scroll yet, and more remain → pull the next page. - if (main.scrollHeight <= main.clientHeight + 80 && loadedCount() < state.total) loadNext(); + // The sizer's top edge measured in the scroller's content coordinate space + // (accounts for the practice-home block above it, sticky toolbar, etc.). + function _sizerTopInScroller(main, sizer) { + return sizer.getBoundingClientRect().top - main.getBoundingClientRect().top + main.scrollTop; } - // Robust infinite scroll: a scroll listener on the real scroll container - // (#v3-main), bound once. Avoids the IntersectionObserver "already in view - // at observe-time" race that stuck the grid on page 0. + function _windowHasHoles(start, end) { + for (let i = start; i < end; i++) if (state.songs[i] === undefined) return true; + return false; + } + + // A placeholder card with the SAME vertical structure (and therefore height) + // as a real card, shown only if a window's fetch hasn't landed yet. No + // [data-fn] → wireCards / repaintAccuracy skip it. + function _skeletonCard() { + return ''; + } + + function _renderCardsRange(start, end) { + let html = ''; + for (let i = start; i < end; i++) { + const s = state.songs[i]; + html += s ? songCard(s) : _skeletonCard(); + } + return html; + } + + // Fetch a single OFFSET page into the sparse store. Uses the stage-1 keyset + // cursor when the previous page is already loaded (cheap forward scroll); + // otherwise OFFSET page= for random access (jumps, restore, non-keyset + // providers). Records the returned next_cursor so a later contiguous page can + // chain off it. Returns a promise that callers AWAIT (so ensureWindow never + // returns with a hole still in flight); concurrent requests for the same page + // share the one promise. An `epoch` captured at launch guards against a reset + // (provider/sort/filter change) landing mid-fetch and writing stale rows into + // the new dataset. + function _loadPage(p) { + if (p < 0 || state.songs[p * PAGE_SIZE] !== undefined) return Promise.resolve(); + if (state.pageProms[p]) return state.pageProms[p]; + const epoch = state.epoch; + const prom = (async () => { + const extra = { size: PAGE_SIZE }; + const prevCursor = state.keysetOk ? state.pageCursors[p - 1] : null; + if (prevCursor) extra.after = prevCursor; else extra.page = p; + const data = await jget('/api/library?' + queryParams(extra).toString()); + if (state.epoch !== epoch || !data) return; // reset mid-fetch → discard stale + state.total = data.total || 0; + if (typeof data.next_cursor !== 'undefined') { + state.pageCursors[p] = data.next_cursor; + if (p === 0) state.keysetOk = !!data.next_cursor; + } + const base = p * PAGE_SIZE; + (data.songs || []).forEach((s, i) => { + state.songs[base + i] = s; + state.songsById[cardKey(s)] = s; + }); + })(); + state.pageProms[p] = prom; + prom.finally(() => { if (state.pageProms[p] === prom) delete state.pageProms[p]; }); + return prom; + } + + // Ensure every absolute index in [start, end) is loaded (fetch — or await an + // in-flight fetch of — the covering pages). Pages resolve in order so the + // keyset fast-path can chain off the previous page's cursor. + async function ensureWindow(start, end) { + if (end <= start) return; + const p0 = Math.floor(start / PAGE_SIZE); + const p1 = Math.floor((end - 1) / PAGE_SIZE); + for (let p = p0; p <= p1; p++) { + if (state.songs[p * PAGE_SIZE] === undefined) await _loadPage(p); + } + } + + let _winRAF = 0; + function requestWindowRender() { + if (_winRAF) return; + _winRAF = requestAnimationFrame(() => { _winRAF = 0; renderWindow(); }); + } + + // Paint the slice of cards the current scrollTop maps to. Sizes the sizer to + // the full library, computes the visible row range (± overscan), fetches any + // missing pages, then swaps the grid's innerHTML to just that slice. A token + // guards against an out-of-order fetch repainting a window the user scrolled + // past. + let _winToken = 0; + async function renderWindow() { + if (state.view !== 'grid' || !songsActive()) return; + const grid = _gridEl(), sizer = _sizerEl(), main = document.getElementById('v3-main'); + if (!grid || !sizer || !main) return; + const { cols, rowH } = measureGeom(); + const total = state.total || 0; + const rows = Math.ceil(total / Math.max(1, cols)); + sizer.style.height = (rows * rowH) + 'px'; + if (total === 0) { + grid.innerHTML = ''; grid.style.top = '0px'; + state.winRange = { start: 0, end: 0 }; + return; + } + const sizerTop = _sizerTopInScroller(main, sizer); + const viewTop = Math.max(0, main.scrollTop - sizerTop); + const viewBottom = viewTop + main.clientHeight; + const firstRow = Math.max(0, Math.floor(viewTop / rowH) - OVERSCAN_ROWS); + const lastRow = Math.min(rows - 1, Math.ceil(viewBottom / rowH) + OVERSCAN_ROWS); + const start = firstRow * cols; + const end = Math.min(total, (lastRow + 1) * cols); + // Re-render when the range changed, a card is missing, OR select mode + // toggled since the window was last painted (so checkboxes/rings on cached + // cards track state — e.g. after leaving Songs in select mode and back). + const same = state.winRange && state.winRange.start === start && state.winRange.end === end + && state.renderedSelectMode === state.selectMode; + if (same && !_windowHasHoles(start, end)) return; + const myToken = ++_winToken; + if (_windowHasHoles(start, end)) { + await ensureWindow(start, end); + if (_winToken !== myToken || state.view !== 'grid') return; // superseded + } + if (_closeCardMenu) _closeCardMenu(); // its DOM is about to be replaced + grid.style.top = (firstRow * rowH) + 'px'; + grid.innerHTML = _renderCardsRange(start, end); + wireCards(grid); + state.winRange = { start, end }; + state.renderedSelectMode = state.selectMode; + if (sm && typeof sm.emit === 'function') { + try { sm.emit('v3:library-window-rendered', { start, end, total }); } catch (e) { /* */ } + } + } + + // Reset/initial load of the grid. Clears the sparse store, fetches page 0 + // (which establishes state.total + whether the keyset fast-path is available), + // then renders the window twice — the first render lays a real card so the + // second can measure the true row height and settle the window size. + async function loadGrid(reset) { + // A reset requested mid-fetch (provider/sort/filter/search change) must + // not be dropped — remember it and re-run once the in-flight load returns. + if (state.loading) { if (reset) state.pendingReset = true; return; } + const grid = _gridEl(); + if (!grid) return; + if (reset) { + if (_closeCardMenu) _closeCardMenu(); + state.epoch++; // invalidate any in-flight page fetch from the old query + state.songs = []; + state.pageCursors = {}; + state.pageProms = {}; + state.keysetOk = false; + state.winRange = null; + state.renderedSelectMode = null; + state.geom = null; + state.total = 0; + grid.innerHTML = ''; + grid.style.top = '0px'; + const sizer = _sizerEl(); + if (sizer) sizer.style.height = '0px'; + } + state.loading = true; + await _loadPage(0); + state.loading = false; + if (state.pendingReset) { state.pendingReset = false; return loadGrid(true); } + const countEl = document.getElementById('v3-songs-count'); + if (countEl) countEl.textContent = state.total + ' song' + (state.total === 1 ? '' : 's'); + // The sentinel no longer drives loading (the sizer reserves full height); + // keep the node for coexistence but it has no visible role. + const sentinel = document.getElementById('v3-songs-sentinel'); + if (sentinel) sentinel.style.display = 'none'; + await renderWindow(); // first paint (rowH from estimate) + await renderWindow(); // re-measure rowH from a real card, settle the window + } + + // A scroll on #v3-main re-renders the window (rAF-coalesced). No more + // near-bottom paging trigger — the visible range alone decides what's shown. function bindScroll() { const main = document.getElementById('v3-main'); if (!main || state.scrollBound) return; state.scrollBound = true; main.addEventListener('scroll', () => { - if (state.view !== 'grid' || state.loading) return; - if (main.scrollTop + main.clientHeight >= main.scrollHeight - 600) loadNext(); + if (state.view !== 'grid') return; + requestWindowRender(); }, { passive: true }); } + // Re-measure + re-render when the scroller's WIDTH changes (column count and + // the aspect-square art height both track width). Height-only changes just + // need a re-render to widen/narrow the visible window. + function bindGridResize() { + if (state.gridResizeBound) return; + const main = document.getElementById('v3-main'); + if (!main || typeof ResizeObserver !== 'function') return; + state.gridResizeBound = true; + let lastW = main.clientWidth; + new ResizeObserver(() => { + if (state.view !== 'grid') return; + const w = main.clientWidth; + if (w !== lastW) { lastW = w; state.geom = null; } // force re-measure + requestWindowRender(); + }).observe(main); + } + // ── A–Z jump rail interaction ───────────────────────────────────────────── - // The rail jumps within the contiguous, server-paged grid. Because the grid - // is forward-only infinite scroll (no virtualization), reaching a letter that - // isn't loaded yet means paging forward until its first card exists, then - // scrolling to it — the same rows the user would have scrolled past. The rail - // only offers letters the server reports as present for the active sort+filter - // (so a tap always terminates at a real card). A keyset-seek + virtualized - // window is the scaling follow-up for very large libraries. + // With the windowed grid the rail seeks DIRECTLY: sort_letters gives the + // per-bucket song counts, so the first card of a letter is at the cumulative + // count of the buckets before it — convert that index to a scrollTop and let + // the scroll handler render+fetch the destination window (O(1), no page- + // through). The rail only offers letters the server reports present for the + // active sort+filter, so a tap always lands on a real card. (A legacy provider + // lacking sort_letters falls back to a bounded forward scan.) function railEl() { return document.getElementById('v3-songs-azrail'); } function railBubbleEl() { return document.getElementById('v3-songs-azbubble'); } function railVisible() { return state.view === 'grid' && !!railSortColumn(); } @@ -948,11 +1142,18 @@ // party provider that predates `sort_letters` returns none, in which // case a title sort would advertise wrong letters — hide the rail then. let letters = stats && stats.sort_letters; + // sort_letters counts SONGS per bucket of the active sort column — exactly + // the cumulative the windowed jump needs to seek to a row index. The + // `letters` fallback is a distinct-ARTIST count (legacy provider without + // sort_letters, artist sort only), which can't drive a precise seek — flag + // it so jumpToLetter does a bounded scan instead of trusting the math. + const songCounts = !!(stats && stats.sort_letters); if (!letters) { if (col === 'artist') letters = (stats && stats.letters) || {}; else { rail.classList.add('hidden'); railBubbleEl()?.classList.add('hidden'); return; } } state.railLetters = letters; + state.railLettersAreSongCounts = songCounts; // No present letters (empty or fully-filtered grid) → nothing to jump // to; hide the rail instead of rendering a column of disabled buttons. if (!Object.keys(letters).length) { rail.classList.add('hidden'); railBubbleEl()?.classList.add('hidden'); return; } @@ -985,44 +1186,61 @@ function _showBubble(letter) { const b = railBubbleEl(); if (b) { b.textContent = letter; b.classList.remove('hidden'); } } function _hideBubble() { railBubbleEl()?.classList.add('hidden'); } - async function _loadNextAwait() { - if (state.loading) { await _waitForGridIdle(); return loadedCount() < state.total; } - if (loadedCount() >= state.total) return false; - state.page++; - await loadGrid(false); - return loadedCount() < state.total; + // The absolute index of the first card in a bucket, from the sort_letters + // song-counts: sum the counts of every bucket ordered before it. O(1) — no + // page-through. Returns null when we don't have true song-counts (the legacy + // distinct-artist fallback), so the caller can scan instead. + function _letterStartIndex(letter) { + if (!state.railLettersAreSongCounts) return null; + const letters = state.railLetters || {}; + const desc = state.sort.endsWith('-desc'); + const order = desc ? RAIL_BUCKETS.slice().reverse() : RAIL_BUCKETS; + let idx = 0; + for (const b of order) { if (b === letter) return idx; idx += (letters[b] || 0); } + return idx; + } + + // Fallback for providers without sort_letters: walk the sparse store forward + // (fetching pages as needed, bounded by total) until a card's bucket matches. + async function _scanForLetter(letter, token) { + const total = state.total || 0; + for (let i = 0; i < total; i++) { + if (state.songs[i] === undefined) { + await ensureWindow(i, Math.min(total, i + PAGE_SIZE)); + if (_jumpToken !== token) return null; + } + const s = state.songs[i]; + if (s && songBucket(s) === letter) return i; + } + return null; } let _jumpToken = 0; async function jumpToLetter(letter) { - const grid = document.getElementById('v3-songs-grid'); - if (!grid || state.view !== 'grid' || !letter) return; + const grid = _gridEl(), sizer = _sizerEl(), main = document.getElementById('v3-main'); + if (!grid || !sizer || !main || state.view !== 'grid' || !letter) return; _setRailActive(letter); - const sel = '[data-letter="' + ((window.CSS && CSS.escape) ? CSS.escape(letter) : letter) + '"]'; const myToken = ++_jumpToken; // a newer jump supersedes this one - // Page forward until the bucket's first card is loaded (or list - // exhausted). The guard is the page count the current total implies - // (+2 slack) rather than a fixed cap, so even a very large library - // stays reachable while a runaway loop is still bounded. - let guard = 0; - const maxPages = Math.ceil((state.total || 0) / PAGE_SIZE) + 2; - while (!grid.querySelector(sel) && loadedCount() < state.total - && _jumpToken === myToken && guard++ < maxPages) { - const more = await _loadNextAwait(); - if (!more) break; + const { cols, rowH } = measureGeom(); + let targetIndex = _letterStartIndex(letter); + if (targetIndex == null) { + targetIndex = await _scanForLetter(letter, myToken); + if (_jumpToken !== myToken) return; + if (targetIndex == null) return; // letter not present } - if (_jumpToken !== myToken) return; - const target = grid.querySelector(sel); - if (!target) return; - const main = document.getElementById('v3-main'); + const total = state.total || 0; + if (targetIndex >= total) targetIndex = Math.max(0, total - 1); + const targetRow = Math.floor(targetIndex / Math.max(1, cols)); + // Pre-fetch the destination window so cards are present when the smooth + // scroll arrives (avoids a flash of skeletons at the landing row). + await ensureWindow(targetIndex, Math.min(total, targetIndex + cols * (OVERSCAN_ROWS * 2 + 4))); + if (_jumpToken !== myToken || state.view !== 'grid') return; + const sizerTop = _sizerTopInScroller(main, sizer); const toolbar = document.getElementById('v3-songs-toolbar'); const pad = (toolbar ? toolbar.offsetHeight : 0) + 12; // clear the sticky toolbar - if (main) { - const top = target.getBoundingClientRect().top - main.getBoundingClientRect().top + main.scrollTop - pad; - main.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }); - } else { - target.scrollIntoView({ block: 'start', behavior: 'smooth' }); - } + const top = Math.max(0, sizerTop + targetRow * rowH - pad); + main.scrollTo({ top, behavior: 'smooth' }); + requestWindowRender(); } function bindRailOnce() { @@ -1279,7 +1497,9 @@ // Keep a handle on the load so callers (notably the scroll restore on // screen re-entry) can await page-0 actually landing before paging // deeper. The visibility/scroll resets below stay synchronous. - document.getElementById('v3-songs-grid')?.classList.toggle('hidden', state.view !== 'grid'); + // Hide the SIZER (not the inner grid) for non-grid views, so its reserved + // scroll height collapses and the tree/folder content sits at the top. + document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid'); document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree'); document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder'); // Refresh the A–Z jump rail (shows only for the grid + alphabetical @@ -1354,7 +1574,12 @@ // only on the grid view when not searching/filtering/selecting // (renderLibraryHome + updateLibraryHome). Empty/absent → collapses. '' + - '
' + + // Windowed grid: the sizer reserves the full-library scroll height; + // #v3-songs-grid is absolutely positioned inside it and holds only the + // visible window's cards (.v3-grid-window in v3.css). + '
' + + '
' + + '
' + '' + '' + '' + @@ -1444,6 +1669,7 @@ // before it tries to page deeper. await setView(state.view); bindScroll(); + bindGridResize(); positionToolbar(); bindToolbarReflow(); updateFilterBadge(); @@ -1468,18 +1694,20 @@ if (snap && hashMatch && domReady && chromeOk && viewOk) { if (state.view === 'grid' && _gridDomIntact()) { - if ((snap.page || 0) > state.page || (snap.loadedCount || 0) > loadedCount()) { - await _ensureGridPagesThrough(snap.page || 0); - } - document.getElementById('v3-songs-grid')?.classList.toggle('hidden', false); + // Geometry is stable (the sizer still holds the full height from + // the prior session), so restore is just: restore scrollTop, then + // repaint the window that maps to it. No more page-through. + document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', false); document.getElementById('v3-songs-tree')?.classList.toggle('hidden', true); syncChromeFromState(); + updateLibraryHome(); // select-mode clear on leave re-shows the home block _applyMainScrollTop(snap.scrollTop || 0); + requestWindowRender(); _clearLibraryScrollSnapshot(); return; } if (state.view === 'tree' && _treeDomIntact()) { - document.getElementById('v3-songs-grid')?.classList.toggle('hidden', true); + document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', true); document.getElementById('v3-songs-tree')?.classList.toggle('hidden', false); syncChromeFromState(); _applyMainScrollTop(snap.scrollTop || 0); @@ -1496,10 +1724,14 @@ // instead of silently showing the old results. Unchanged state keeps // the scroll-preserving no-op. if (state.renderedHash !== _libraryStateHash()) { reload(); return; } - document.getElementById('v3-songs-grid')?.classList.toggle('hidden', state.view !== 'grid'); + document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid'); document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree'); document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder'); { const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; } + updateLibraryHome(); // select-mode clear on leave re-shows the home block + // Re-render in case the viewport resized while we were away (column + // count / row height may have changed) or select mode was cleared. + if (state.view === 'grid') requestWindowRender(); return; } @@ -1507,8 +1739,10 @@ if (snap && !hashMatch) _clearLibraryScrollSnapshot(); await render(); if (snapToRestore && snapToRestore.hash === _libraryStateHash()) { - if (state.view === 'grid') await _ensureGridPagesThrough(snapToRestore.page || 0); + // render() built + sized the sizer at scrollTop 0; move to the saved + // position and let the scroll handler repaint that window. _applyMainScrollTop(snapToRestore.scrollTop || 0); + if (state.view === 'grid') requestWindowRender(); } _clearLibraryScrollSnapshot(); } @@ -1554,6 +1788,11 @@ getSort: () => state.sort, getArtist: () => state.artist, getAlbum: () => state.album, + // The grid is windowed: only a slice of cards is in the DOM at any time. + // A plugin that decorates cards should read THIS (not a global + // querySelectorAll that assumes every card is present) and re-run on each + // `v3:library-window-rendered` event rather than once at load. + visibleCards: () => document.querySelectorAll('#v3-songs-grid [data-fn]'), filterParams: () => { const f = state.filters; const p = new URLSearchParams(); diff --git a/static/v3/v3.css b/static/v3/v3.css index b13dd2c..3045fdd 100644 --- a/static/v3/v3.css +++ b/static/v3/v3.css @@ -1219,3 +1219,26 @@ html.fb-immersive #v3-main > .screen.active { width: 8.5rem; scroll-snap-align: start; } + +/* — Windowed (virtualized) Songs grid (#636 item 3 stage 2) — */ +/* The grid is absolutely positioned inside #v3-songs-gridsizer, whose height is + set to the FULL library (ceil(total/cols)*rowH) so the scrollbar reflects the + whole library while only the visible window's cards are in the DOM. The inline + `top` (set by renderWindow) offsets the window to the first visible row. */ +.v3-grid-window { + position: absolute; + left: 0; + right: 0; + top: 0; +} +/* The arrangement-chip row is rendered on EVERY card (even when empty) at a fixed + single-line height — uniform card height is what makes the window's + absolute-position math exact. Extra chips are clipped rather than wrapping. */ +.v3-card-chips { + height: 1.5rem; + overflow: hidden; + flex-wrap: nowrap; +} +/* Skeleton placeholder shown only if a window's fetch hasn't landed; mirrors a + real card's vertical structure so it occupies an identical row height. */ +.v3-card-skel { pointer-events: none; } diff --git a/tests/browser/v3-grid-virtualization.spec.ts b/tests/browser/v3-grid-virtualization.spec.ts new file mode 100644 index 0000000..449f411 --- /dev/null +++ b/tests/browser/v3-grid-virtualization.spec.ts @@ -0,0 +1,130 @@ +import { test, expect } from '@playwright/test'; + +// Pins the bounded-DOM invariant of the windowed v3 Songs grid (#636 item 3 +// stage 2). Before virtualization the grid appended every scrolled page, so for +// a 2000-song library the card-node count grew unbounded (24 → 624 → 2001). +// Now only the visible window (± overscan) is ever in the DOM while a sizer +// element gives the scrollbar the full-library geometry. +// +// Route-mocked (same strategy as v3-tree-select.spec.ts) so the invariant is +// deterministic in CI without a seeded 2000-row library: /api/library serves a +// synthetic page from the page/after param with total 2001, and the keyset +// cursor is mocked as the next absolute offset. + +const TOTAL = 2001; +const PAGE_SIZE = 24; +const COLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +// Same bucketing as the seed/server: index % 26 → a first letter, so the A–Z +// rail has real buckets and a jump has somewhere to land. +function songAt(i: number) { + const letter = COLS[i % 26]; + return { + filename: `seed/${String(i).padStart(5, '0')}.sloppak`, + title: `Song ${String(i).padStart(4, '0')}`, + artist: `${letter}Band ${String(i).padStart(4, '0')}`, + album: `${letter} Album`, + format: 'sloppak', + arrangements: [{ index: 0, name: 'Lead' }, { index: 1, name: 'Rhythm' }], + }; +} + +// sort_letters song-counts per bucket for index%26 over [0, TOTAL). +function sortLetters() { + const m: Record = {}; + for (let i = 0; i < TOTAL; i++) { const L = COLS[i % 26]; m[L] = (m[L] || 0) + 1; } + return m; +} + +test.beforeEach(async ({ page }) => { + await page.route('**/api/library?**', async (route) => { + const url = new URL(route.request().url()); + const after = url.searchParams.get('after'); + const size = Number(url.searchParams.get('size') || PAGE_SIZE); + const offset = after != null ? Number(after) : Number(url.searchParams.get('page') || '0') * size; + const songs = []; + for (let i = offset; i < Math.min(TOTAL, offset + size); i++) songs.push(songAt(i)); + const nextOffset = offset + size; + await route.fulfill({ + json: { + songs, total: TOTAL, page: Math.floor(offset / size), size, + next_cursor: nextOffset < TOTAL ? String(nextOffset) : null, + }, + }); + }); + await page.route('**/api/library/stats**', (route) => { + const url = new URL(route.request().url()); + const body: any = { total_songs: TOTAL, total: TOTAL, letters: {} }; + if (url.searchParams.get('sort_letters')) body.sort_letters = sortLetters(); + return route.fulfill({ json: body }); + }); + await page.route('**/api/library/artists**', (route) => route.fulfill({ json: { artists: [], total_artists: 0 } })); + await page.route('**/api/library/providers', (route) => route.fulfill({ json: { providers: [{ id: 'local', label: 'My Library' }] } })); + await page.route('**/api/library/tuning-names**', (route) => route.fulfill({ json: { tunings: [] } })); + await page.route('**/api/stats/best', (route) => route.fulfill({ json: {} })); + await page.route('**/api/stats/recent**', (route) => route.fulfill({ json: [] })); +}); + +async function openSongs(page) { + await page.goto('/'); + await page.waitForSelector('.screen.active', { timeout: 10000 }); + await page.evaluate(() => { + // @ts-ignore — neutralize playback so a stray click can't navigate away. + window.playSong = () => Promise.resolve(); + // @ts-ignore + window.showScreen('v3-songs'); + }); + await page.waitForSelector('#v3-songs-grid [data-fn]', { state: 'attached', timeout: 10000 }); +} + +test('the grid keeps a bounded number of card nodes while scrolling a 2001-song library', async ({ page }) => { + await openSongs(page); + + // The count reflects the FULL library even though only a window is rendered. + await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs'); + + // The sizer reserves the full scroll height (so the scrollbar is library-wide). + const scrollHeight = await page.evaluate(() => document.getElementById('v3-main')!.scrollHeight); + expect(scrollHeight).toBeGreaterThan(20000); + + // Scroll the whole library; the in-DOM card count must stay bounded throughout. + const CAP = 150; + let maxNodes = await page.locator('#v3-songs-grid [data-fn]').count(); + for (let s = 0; s < 50; s++) { + await page.evaluate(() => { const m = document.getElementById('v3-main')!; m.scrollTop += m.clientHeight * 0.85; }); + await page.waitForTimeout(60); + const n = await page.locator('#v3-songs-grid [data-fn]').count(); + maxNodes = Math.max(maxNodes, n); + expect(n).toBeLessThanOrEqual(CAP); + } + // Sanity: we actually rendered a window (not zero), and stayed well under the + // unbounded 2001 the old append-everything grid would have produced. + expect(maxNodes).toBeGreaterThan(0); + expect(maxNodes).toBeLessThanOrEqual(CAP); + + // The count is still correct after scrolling to the end. + await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs'); +}); + +test('the A–Z rail jumps directly to a letter without loading every page', async ({ page }) => { + await openSongs(page); + await page.waitForSelector('.v3-azrail-letter', { state: 'attached', timeout: 10000 }); + + // Jump to 'M'; the window scrolls to the row holding the first 'M' card. + await page.evaluate(() => { + const b = [...document.querySelectorAll('.v3-azrail-letter')] + .find((x) => x.getAttribute('data-letter') === 'M' && !(x as HTMLButtonElement).disabled) as HTMLElement | undefined; + if (!b) throw new Error('no M rail letter'); b.click(); + }); + + // After the jump+window render, an 'M' card is present near the top of the + // viewport (the jump is O(1) via sort_letters, not a full page-through). + await expect.poll(async () => page.evaluate(() => { + const main = document.getElementById('v3-main')!; + const top = main.getBoundingClientRect().top + (document.getElementById('v3-songs-toolbar')?.offsetHeight || 0); + return [...document.querySelectorAll('#v3-songs-grid [data-fn]')].some((c) => { + const r = c.getBoundingClientRect(); + return c.getAttribute('data-letter') === 'M' && r.top >= top - 4 && r.top < top + 320; + }); + }), { timeout: 5000 }).toBe(true); +}); diff --git a/tests/js/v3_az_rail.test.js b/tests/js/v3_az_rail.test.js index 047e725..524c919 100644 --- a/tests/js/v3_az_rail.test.js +++ b/tests/js/v3_az_rail.test.js @@ -1,11 +1,12 @@ // Pins the v3 Songs A–Z jump rail wiring in static/v3/songs.js. // // The rail lets a user jump the library grid to artists/titles starting with a -// letter (Plex/Radarr/iOS-contacts pattern). Because the grid is forward-only, -// server-paged infinite scroll, the jump pages through to the target card then -// scrolls — and the rail only offers letters the server reports present for the -// active sort+filter (so a tap always terminates at a real card). It is shown -// only for the grid view + alphabetical (artist/title) sorts. +// letter (Plex/Radarr/iOS-contacts pattern). With the windowed grid (#636 item 3 +// stage 2) the jump seeks DIRECTLY: the sort_letters song-counts give the first +// card's absolute index (cumulative of prior buckets), which converts to a +// scrollTop — no page-through. The rail only offers letters the server reports +// present for the active sort+filter (so a tap always lands on a real card). It +// is shown only for the grid view + alphabetical (artist/title) sorts. // // Source-level only — same strategy as tests/js/highway_3d_camera_framing.test.js. @@ -65,16 +66,24 @@ test('the rail + drag bubble are rendered in the Songs markup', () => { assert.match(src, /id="v3-songs-azbubble"/); }); -test('jumpToLetter pages through to the target then scrolls (load-through)', () => { - // Forward-paging helper used to load rows up to the target letter. - assert.match(src, /async function\s+_loadNextAwait\s*\(\)/); +test('jumpToLetter seeks directly via sort_letters cumulative (no page-through)', () => { + // The cumulative-count seek: sum the song-counts of buckets ordered before + // the target to get its first row's absolute index. + assert.match(src, /function\s+_letterStartIndex\s*\(letter\)/, + 'jumpToLetter must derive the target index from sort_letters counts'); assert.match( src, - /async function\s+jumpToLetter[\s\S]*?_loadNextAwait\(\)[\s\S]*?(scrollTo|scrollIntoView)/, - 'jumpToLetter must page forward (_loadNextAwait) then scroll to the target card', + /async function\s+jumpToLetter[\s\S]*?_letterStartIndex\(letter\)[\s\S]*?scrollTo/, + 'jumpToLetter must compute the target index then scrollTo (no _loadNextAwait page-through)', ); - // A token guards against overlapping jumps (drag scrubbing) — newest wins. - assert.match(src, /_jumpToken\s*===\s*myToken/); + // It pre-fetches the destination window so cards are ready when the scroll lands. + assert.match(src, /async function\s+jumpToLetter[\s\S]*?ensureWindow\(/, + 'jumpToLetter must pre-fetch the destination window before scrolling'); + // The old forward-paging helper is gone (the seek is O(1)). + assert.doesNotMatch(src, /_loadNextAwait/, + 'the page-through helper must be removed under the windowed grid'); + // A token still guards overlapping jumps (drag scrubbing) — newest wins. + assert.match(src, /_jumpToken\s*!==\s*myToken/); }); test('the rail supports pointer drag-scrub + keyboard arrows', () => { diff --git a/tests/js/v3_songs_scroll.test.js b/tests/js/v3_songs_scroll.test.js index bc1b911..8ba04c8 100644 --- a/tests/js/v3_songs_scroll.test.js +++ b/tests/js/v3_songs_scroll.test.js @@ -36,13 +36,15 @@ function makeStore() { }; } -function saveSnapshot(storage, state, scrollTop, page, loadedCount) { +// Mirror of static/v3/songs.js _saveLibraryScrollSnapshot. Under the windowed +// grid (#636 item 3 stage 2) geometry is stable, so the snapshot is just +// {hash, scrollTop, view} — no page/loadedCount depth bookkeeping (restore sets +// scrollTop and re-renders the window that maps to it). +function saveSnapshot(storage, state, scrollTop) { const snap = { hash: buildLibraryStateHash(state), scrollTop, view: state.view, - page, - loadedCount, }; storage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap)); } @@ -88,19 +90,21 @@ test('buildLibraryStateHash is stable for equivalent filter arrays', () => { assert.strictEqual(buildLibraryStateHash(s1), buildLibraryStateHash(s2)); }); -test('snapshot stores scrollTop and page', () => { +test('snapshot stores scrollTop + view + hash (geometry-stable restore)', () => { const storage = makeStore(); - saveSnapshot(storage, baseState, 1840, 3, 96); + saveSnapshot(storage, baseState, 1840); const snap = readSnapshot(storage); assert.strictEqual(snap.scrollTop, 1840); - assert.strictEqual(snap.page, 3); - assert.strictEqual(snap.loadedCount, 96); + assert.strictEqual(snap.view, 'grid'); assert.strictEqual(snap.hash, buildLibraryStateHash(baseState)); + // Page-depth bookkeeping is gone — the windowed grid restores from scrollTop. + assert.strictEqual(snap.page, undefined); + assert.strictEqual(snap.loadedCount, undefined); }); test('stale snapshot is detected when filters change', () => { const storage = makeStore(); - saveSnapshot(storage, baseState, 500, 1, 48); + saveSnapshot(storage, baseState, 500); const snap = readSnapshot(storage); const changed = buildLibraryStateHash({ ...baseState, q: 'beatles' }); assert.notStrictEqual(snap.hash, changed); From 8fbbc761fcc8ced732097d9e081e7c6cd8c781fb Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Mon, 29 Jun 2026 07:44:56 -0500 Subject: [PATCH 80/99] fix(v3): reject accidental text-selection of UI chrome (user-select policy) (#637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(v3): reject accidental text-selection of UI chrome (user-select policy) Dragging/double-clicking across the v3 UI marquee-highlighted buttons, labels, the sidebar, transport, and the note-highway HUD — looks broken (reported Mac + Windows). Default the v3 shell to user-select:none on html, then opt CONTENT back in. Decided by a 4-lens panel (UX / a11y / dev-ops / plugin-ecosystem); their guardrails are baked in: - Form fields ALWAYS re-enabled (input/textarea/select/[contenteditable]) so the caret + IME composition never break. No `* { user-select:none }` (WebKit input bug 82692). - Plugin screens (.screen[id^="plugin-"]) stay selectable BY INHERITANCE (no `*`, so a plugin's own non-select chrome still wins) — a plugin's copyable text (lyrics, chords, results), including community/out-of-tree plugins that never adopt the class, isn't silently locked. - Core read-only content opts back in by CONTAINER via a hand-authored `.fb-selectable` (not a Tailwind utility — so runtime-installed plugins get it too): the whole Settings panel (paths, device names, version, diagnostics, About) and the now-playing song metadata. Answers the open "keep settings copyable?" question: yes, at the container. Cosmetic only — never used to lock copy-worthy text (errors/IDs/paths/versions/ metadata stay selectable; WCAG 2.2 allows copy-paste as a mechanism). v3-only (v2 unchanged; v3.css loads only on /v3); plain CSS, no Tailwind rebuild; no desktop/Electron changes (standard OS-framed window). `.fb-selectable` is documented in CLAUDE.md for plugin authors. Tests: tests/js/v3_user_select_policy.test.js (html default, form-field re-enable, plugin-screen carve without `*`, .fb-selectable, container opt-ins, and the no-`*`-rule guardrail). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * fix(v3): address review of the user-select policy (#637) Review (manual + Codex) of the v3 text-selection policy: - P1 (real bug): the now-playing HUD metadata opted into `.fb-selectable` but its `#player-hud` parent is `pointer-events: none`, so the mouse could never reach the text to select it — the opt-in was inert. Add `pointer-events-auto` to the metadata block (verified in-browser: user-select:text + pointer- events:auto, while the HUD parent stays pointer-events:none). - Coverage: the PR's a11y guardrail promised copyable text stays selectable "incl. in modals/toasts", but only Settings + the HUD were opted in. Blanket- opt the focused copyable surfaces back in by selector — `.feedBack-modal`, `[role="dialog"]`, `#fb-notify-stack`, `#v3-fb-toast`, `#scan-banner` — so errors / IDs / paths / file names in dialogs, toasts, and the scan banner stay copyable. These are focused panels, not dense card lists, so re-enabling selection there can't recreate the across-cards marquee mess. (Deliberately NOT opting in the library grid / dashboard / profile card lists: making dense card text selectable would reintroduce exactly that marquee mess on a drag — copy song metadata from the now-playing HUD / Settings instead.) - Test (P3): assert the selectable rule's selectors order-independently, cover the new modal/toast/banner surfaces, and check the HUD block carries BOTH fb-selectable and pointer-events-auto (class-order independent). Verified in a real browser (chromium): html=none, sidebar chrome=none, input= text, Settings=text, HUD meta=text+pointer-events:auto, dialog/modal=text. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: byrongamatos --- CHANGELOG.md | 1 + CLAUDE.md | 1 + static/v3/index.html | 13 ++++- static/v3/v3.css | 56 ++++++++++++++++++ tests/js/v3_user_select_policy.test.js | 80 ++++++++++++++++++++++++++ 5 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 tests/js/v3_user_select_policy.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e31c455..01a63f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,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 +- **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`. - **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible. - **3D Highway FPS counter no longer hides behind the v3 "Up Next" pill.** The on-highway FPS readout (Settings → Graphics → 3D Highway → Show FPS counter) is pinned to the top-right of the highway overlay — the same corner the v3 player chrome stacks its persistent **Up Next** pill and live-performance HUD into, on a higher layer that paints over the canvas. So the readout sat *behind* that chrome and couldn't be read — precisely when a tester had turned it on to judge performance (it also made the separate "Up Next won't turn off" complaint worse, since the default-on pill covered the counter regardless). The counter now stays top-right but drops just **below** whichever of that chrome is showing: `highway_3d`'s `screen.js` measures the lowest visible top-right v3 HUD element (`#v3-upnext` / `#v3-live-performance-hud` / `#hud-time`) and floors the FPS box's Y beneath it. Element refs are resolved once and cached (no per-frame `querySelector`, per the plugin perf rules) and only consulted while the counter is actually drawn; gated on `window.feedBack.uiVersion === 'v3'` so the classic (v2) UI is byte-for-byte unaffected. `plugins/highway_3d/plugin.json` version → `3.30.1` (cache-buster). (For reading raw perf numbers unobstructed, the core perf HUD — `localStorage.highwayPerfHud='1'` — still renders above all chrome and additionally shows the adaptive render-scale.) - **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). diff --git a/CLAUDE.md b/CLAUDE.md index 07744a6..74e6f66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -552,6 +552,7 @@ a local pointer + code map. - **Storage** — `localStorage` for all user preferences - **Styling** — Tailwind CSS utility classes, dark theme (`bg-dark-600`, `text-gray-300`, accent `#4080e0`, gold `#e8c040`). Tailwind is served as a **prebuilt** stylesheet (`static/tailwind.min.css`, regenerated by `bash scripts/build-tailwind.sh`), **never** the runtime Play CDN — the CDN's on-the-fly JIT rescanned the DOM on the main thread and dropped ~26% of frames with the 3D highway (feedBack-desktop#110). The committed CSS only contains classes the build scanner saw, so CI (`tailwind-fresh`) rebuilds and diffs it; run the build script and commit when you add new classes. A plugin that uses classes not guaranteed in core (notably arbitrary values like `w-[37px]`) MUST ship its own compiled stylesheet via the `styles` manifest key, built with `corePlugins.preflight = false` (utilities only — core ships the one base reset). Plugins MUST NOT load the Tailwind Play CDN or any runtime CSS JIT. See constitution Principle II. - **Naming** — camelCase for JS functions, kebab-case for CSS classes, snake_case for plugin IDs +- **Text selection (v3)** — the v3 UI defaults to `user-select: none` on `html` (in `static/v3/v3.css`) so accidental drag/double-click selection of chrome never looks broken. Form fields are always re-enabled, and a **plugin's mounted screen subtree (`.screen[id^="plugin-"]`) stays selectable by default**, so a plugin's copy-worthy text (lyrics, chord names, results, diagnostics) is unaffected — *unless your plugin renders copyable content OUTSIDE its `plugin-` screen* (e.g. injected into the player chrome / a HUD overlay), which inherits the non-select default. Opt such content back in with the core-served **`.fb-selectable`** class (it sets `user-select: text` on the element + descendants; works for runtime-installed plugins since it's hand-authored in core CSS, not a scanned Tailwind utility). Never use a `* { user-select: none }` rule (breaks input carets/IME), and never use `user-select: none` to "lock" text — keep errors, IDs, paths, versions, and metadata selectable. (v2 is unchanged.) - **Player layout** — `#player` is `display:flex; flex-direction:column; position:fixed; inset:0`. `#highway` is `flex:1`. `#player-controls` sits at the bottom. Hiding the highway collapses the layout — use `margin-top: auto` on controls if you need to hide it. ## Backend Conventions diff --git a/static/v3/index.html b/static/v3/index.html index 992f3b3..d25f14f 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -343,7 +343,10 @@
-
+ +
@@ -825,7 +828,13 @@
-
+ +
diff --git a/static/v3/v3.css b/static/v3/v3.css index 3045fdd..e9caf26 100644 --- a/static/v3/v3.css +++ b/static/v3/v3.css @@ -4,6 +4,62 @@ * `fb` palette in tailwind.config.js. */ +/* ── Text-selection policy (v3) ────────────────────────────────────────────── + Accidental drag/double-click selection of app chrome (sidebar, transport, the + note highway/HUD, buttons, labels) makes the UI look broken and is never + useful — so default the interface to non-selectable, then opt *content* back + in. v3-only: this sheet loads only on /v3 (v2 is unchanged). The panel's + guardrails are baked in: + - NEVER a `* { user-select:none }` rule — it breaks input carets / IME + composition on WebKit (bug 82692); we scope to `html` and re-enable below. + - This is cosmetic only; it protects nothing (DevTools defeats it) and must + never be used to "lock" copy-worthy text away (a11y: keep errors, IDs, + paths, versions, metadata, lyrics selectable — incl. in modals/toasts). */ +html { -webkit-user-select: none; user-select: none; } + +/* Form fields are ALWAYS selectable/editable — protects the caret + IME + (including CJK / dead-key composition). The default must never swallow typing. + `.fb-selectable *` forces descendants so a child element's own non-select + can't strand copy-worthy text inside a content island. */ +input, textarea, select, +[contenteditable]:not([contenteditable="false"]), +[contenteditable]:not([contenteditable="false"]) * { + -webkit-user-select: text; user-select: text; +} + +/* Plugin screens are content surfaces (editor, tabview, lyrics, theory, chord + text, …). Re-enable their mounted subtree by INHERITANCE (no `*`) so the host + policy can't silently make a plugin's copyable text un-selectable — including + community / out-of-tree plugins that never adopt `.fb-selectable`. A plugin + that wants its own chrome non-selectable still wins via its own element rule + (which this inherited value doesn't override). */ +.screen[id^="plugin-"] { -webkit-user-select: text; user-select: text; } + +/* Core read-only content opts back in by CONTAINER (lower-drift than tagging + each value — a new setting added later inherits "selectable" for free): + the Settings panel (values, paths, device names, version, diagnostics, + About) and the now-playing song metadata (both tagged `.fb-selectable`). + Plugins re-enable their own copyable regions with this same class + (documented in CLAUDE.md). + + The focused, transient surfaces below ALWAYS carry copy-worthy text (errors, + IDs, file paths, device/version strings) per the a11y guardrail, so they're + blanket-opted-in by selector rather than hand-tagged — they're single focused + panels, not dense card lists, so re-enabling selection there can't recreate + the across-cards marquee mess the policy prevents: + - modals / dialogs: `.feedBack-modal`, `[role="dialog"]` (confirm, edit-meta, + retune result/error, calibration, filter drawer); + - toasts: `#fb-notify-stack`, `#v3-fb-toast`; + - the library scan banner (`#scan-banner` — shows the current file path). + (Dense card lists — the library grid, dashboard, profile — are intentionally + left non-selectable; copy their text from the now-playing HUD / Settings.) */ +.fb-selectable, .fb-selectable *, +.feedBack-modal, .feedBack-modal *, +[role="dialog"], [role="dialog"] *, +#fb-notify-stack, #fb-notify-stack *, +#v3-fb-toast, #v3-fb-toast *, +#scan-banner, #scan-banner * { -webkit-user-select: text; user-select: text; } + /* The v3 tuner card replaces the tuner plugin's floating launcher — hide it. */ #tuner-toggle-btn { display: none !important; } diff --git a/tests/js/v3_user_select_policy.test.js b/tests/js/v3_user_select_policy.test.js new file mode 100644 index 0000000..46d54f7 --- /dev/null +++ b/tests/js/v3_user_select_policy.test.js @@ -0,0 +1,80 @@ +// Guards the v3 text-selection policy (static/v3/v3.css + static/v3/index.html): +// the UI defaults to non-selectable so accidental chrome selection can't look +// broken, while form fields, plugin screens, and core content opt back in. A +// future global reset clobbering the rule — or the content containers losing +// their .fb-selectable opt-in — should fail here. +// +// Source-level only — same strategy as the other tests/js/ files. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.join(__dirname, '..', '..'); +// Strip block comments so the policy's own explanatory prose (which quotes the +// `* { user-select:none }` anti-pattern as a warning) can't trip the assertions. +const css = fs.readFileSync(path.join(root, 'static', 'v3', 'v3.css'), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, ''); +const html = fs.readFileSync(path.join(root, 'static', 'v3', 'index.html'), 'utf8'); + +test('v3 defaults to non-selectable on html (not a universal `*` rule)', () => { + assert.match(css, /html\s*\{[^}]*user-select:\s*none/, + 'html must default user-select: none'); + // The `* { user-select: none }` anti-pattern breaks input carets / IME — must not exist. + assert.doesNotMatch(css, /\*\s*\{[^}]*user-select:\s*none/, + 'must NOT use a universal `*` user-select:none rule'); +}); + +test('form fields are always re-enabled (caret / IME safe)', () => { + assert.match( + css, + /input,\s*textarea,\s*select[\s\S]*?contenteditable[\s\S]*?user-select:\s*text/, + 'input/textarea/select/[contenteditable] must be re-enabled to user-select: text', + ); +}); + +test('plugin screen subtree stays selectable by inheritance (no `*`, respects plugin opt-outs)', () => { + assert.match( + css, + /\.screen\[id\^="plugin-"\]\s*\{[^}]*user-select:\s*text/, + 'plugin screens must be re-enabled so plugin content is not silently un-copyable', + ); + assert.doesNotMatch( + css, + /\.screen\[id\^="plugin-"\]\s*\*/, + 'the plugin carve must NOT use `*` (would override a plugin\'s own non-select chrome)', + ); +}); + +// The rule that re-enables selection on copyable content. Find the single +// declaration block whose body sets `user-select: text`, then assert each +// required selector is one of its selectors — order/format independent. +const selectableRule = (css.match(/([^{}]*)\{[^}]*user-select:\s*text[^}]*\}/g) || []) + .join('\n'); + +test('core content opts back in via .fb-selectable (element + descendants)', () => { + assert.match(selectableRule, /\.fb-selectable\b/, '.fb-selectable must set user-select: text'); + assert.match(selectableRule, /\.fb-selectable\s*\*/, '...and its descendants (.fb-selectable *)'); +}); + +test('focused copyable surfaces (modals/toasts/scan banner) opt back in', () => { + // The PR\'s a11y guardrail keeps copyable text selectable "incl. in + // modals/toasts" — these carry errors / IDs / paths the user copies. + assert.match(selectableRule, /\.feedBack-modal\b/, 'modals (.feedBack-modal) must be selectable'); + assert.match(selectableRule, /\[role="dialog"\]/, 'dialogs ([role="dialog"]) must be selectable'); + assert.match(selectableRule, /#fb-notify-stack\b/, 'toasts (#fb-notify-stack) must be selectable'); + assert.match(selectableRule, /#scan-banner\b/, 'the scan banner (#scan-banner) must be selectable'); +}); + +// Match a class="" attribute that contains ALL given tokens in any order. +const hasClasses = (...tokens) => new RegExp( + 'class="' + tokens.map((t) => '(?=[^"]*\\b' + t + '\\b)').join('') + '[^"]*"'); + +test('the Settings panel and now-playing metadata carry .fb-selectable', () => { + assert.match(html, hasClasses('fb-settings', 'fb-selectable'), + 'the Settings panel must opt back in (paths / version / diagnostics / About)'); + assert.match(html, hasClasses('fb-selectable', 'pointer-events-auto'), + 'the now-playing metadata must opt back in AND re-enable pointer-events ' + + '(its #player-hud parent is pointer-events-none, which would block mouse selection)'); +}); From 199550e5fb45d424179014ab497c5d15a820711a Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Mon, 29 Jun 2026 08:00:28 -0500 Subject: [PATCH 81/99] fix(v3): dismiss Section Practice popover when another player popover opens (#638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(v3): dismiss Section Practice popover when another player popover opens The Section Practice popover (Songs > Song > Practice pill) stayed open when the user then clicked a v3 player-rail icon (Plugins, Audio, …), leaving two popovers stacked on top of each other. Reported on 0.3.0 (macOS) and still reproducing in the 2026-06-28 build. Root cause: the popover's outside-click dismiss was bound in the bubbling phase, but the v3 rail's icon buttons call e.stopPropagation() in their click handler (player-chrome.js wireRail), which kills bubbling before the click reaches document. So the dismiss listener never fired for a rail-icon click and the popover was orphaned open. Fix: bind the outside-click dismiss in the capture phase, which runs before the target's handler so stopPropagation() can't swallow it. This mirrors the audio mixer popover (audio-mixer.js), which already dismisses outside-clicks via capture-phase listeners for exactly this reason. Esc handling stays in the bubble phase (no rail handler stops keydown propagation, and capturing it would reorder it ahead of the player's Escape-to-exit handling). Shared app.js code, so v2 is covered too; v2 has no stopPropagation rail, so its outside-click dismiss behaviour is unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * chore(#638): add CHANGELOG entry + capture-phase regression test Review follow-ups for the Section Practice popover dismiss fix: - CHANGELOG [Unreleased] → Fixed entry (repo workflow requires one). - tests/js/section_practice_dismiss.test.js pins the fix: the outside-click dismiss binds in the CAPTURE phase (so a rail icon's stopPropagation can't swallow it), exactly one capture binding (Escape keydown stays bubble-phase), and the #section-practice-control containment guard (no self-close). A revert to bubble-phase fails the test. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: byrongamatos --- CHANGELOG.md | 1 + static/app.js | 14 +++++++- tests/js/section_practice_dismiss.test.js | 43 +++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 tests/js/section_practice_dismiss.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a63f5..ba961e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,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 +- **v3 player: opening another rail popover now closes the Section Practice popover (no more two stacked popovers).** Opening the **Practice** pill's popover and then clicking a different player-rail icon (e.g. **Plugins**) left the Practice popover open underneath the new one — looked broken (reported on macOS, 0.3.0 / 2026-06-28). The rail icons call `e.stopPropagation()` in their click handler (`static/v3/player-chrome.js`), which killed bubbling before it reached the Practice popover's outside-click dismiss bound on `document`. The dismiss (`_installSectionPracticeDismiss` in `static/app.js`) now binds in the **capture phase**, which runs before the target's handler so a descendant's `stopPropagation()` can't swallow it — mirroring how the audio-mixer popover already dismisses. Esc handling stays bubble-phase (the player's Escape-to-exit ordering is unchanged). v2 shares `app.js` and is only hardened (no rail `stopPropagation` there). Tests: `tests/js/section_practice_dismiss.test.js`. - **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`. - **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible. - **3D Highway FPS counter no longer hides behind the v3 "Up Next" pill.** The on-highway FPS readout (Settings → Graphics → 3D Highway → Show FPS counter) is pinned to the top-right of the highway overlay — the same corner the v3 player chrome stacks its persistent **Up Next** pill and live-performance HUD into, on a higher layer that paints over the canvas. So the readout sat *behind* that chrome and couldn't be read — precisely when a tester had turned it on to judge performance (it also made the separate "Up Next won't turn off" complaint worse, since the default-on pill covered the counter regardless). The counter now stays top-right but drops just **below** whichever of that chrome is showing: `highway_3d`'s `screen.js` measures the lowest visible top-right v3 HUD element (`#v3-upnext` / `#v3-live-performance-hud` / `#hud-time`) and floors the FPS box's Y beneath it. Element refs are resolved once and cached (no per-frame `querySelector`, per the plugin perf rules) and only consulted while the counter is actually drawn; gated on `window.feedBack.uiVersion === 'v3'` so the classic (v2) UI is byte-for-byte unaffected. `plugins/highway_3d/plugin.json` version → `3.30.1` (cache-buster). (For reading raw perf numbers unobstructed, the core perf HUD — `localStorage.highwayPerfHud='1'` — still renders above all chrome and additionally shows the adaptive render-scale.) diff --git a/static/app.js b/static/app.js index 60031cd..6c00304 100644 --- a/static/app.js +++ b/static/app.js @@ -8714,12 +8714,24 @@ function _installSectionPracticeDismiss() { // inside #section-practice-control so it never self-closes. Listeners added // mid-dispatch don't fire for the opening click, so there's no immediate // close race. + // + // The click listener uses the CAPTURE phase: the v3 player rail's icon + // buttons call e.stopPropagation() in their click handler (player-chrome.js + // wireRail), which kills bubbling before it reaches document. A bubble-phase + // outside-click dismiss would therefore never fire when the user clicks a + // rail icon (Plugins, Audio, …) to open another popover, leaving this + // popover stranded open on top of it. Capture runs before the target's + // handler, so the stopPropagation can't swallow it. This mirrors the audio + // mixer popover (audio-mixer.js), which dismisses outside-clicks the same + // way. (Esc stays bubble-phase — no rail handler stops keydown propagation, + // so it already reaches us, and capturing it would reorder it ahead of the + // player's Escape-to-exit handling.) document.addEventListener('click', (e) => { if (!_sectionPracticePopoverOpen()) return; const ctrl = document.getElementById('section-practice-control'); if (ctrl && ctrl.contains(e.target)) return; _closeSectionPracticePopover(); - }); + }, true); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && _sectionPracticePopoverOpen()) _closeSectionPracticePopover(); }); diff --git a/tests/js/section_practice_dismiss.test.js b/tests/js/section_practice_dismiss.test.js new file mode 100644 index 0000000..8a0912c --- /dev/null +++ b/tests/js/section_practice_dismiss.test.js @@ -0,0 +1,43 @@ +// Guards the Section Practice popover's outside-click dismiss in static/app.js +// (_installSectionPracticeDismiss). The v3 player-rail icon buttons call +// e.stopPropagation() in their click handler (static/v3/player-chrome.js +// wireRail), so a BUBBLE-phase document dismiss never fires when the user clicks +// a different rail icon (Plugins, Audio, …) — leaving the Practice popover +// stranded open under the newly-opened one (feedBack#638). The dismiss must bind +// in the CAPTURE phase (runs before the target's stopPropagation can swallow it). +// Esc must stay bubble-phase so it doesn't reorder ahead of the player's +// Escape-to-exit handling. A revert to bubble-phase should fail here. +// +// Source-level only — same strategy as the other tests/js/ files. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8'); +const m = src.match(/function _installSectionPracticeDismiss\s*\(\)\s*\{[\s\S]*?\n\}/); +assert.ok(m, '_installSectionPracticeDismiss() not found in static/app.js'); +const body = m[0]; + +test('the outside-click dismiss binds in the CAPTURE phase', () => { + assert.match( + body, + /addEventListener\(\s*['"]click['"][\s\S]*?,\s*true\s*\)/, + 'the click dismiss must pass the capture flag (`, true`) so a rail icon\'s ' + + 'stopPropagation() cannot swallow it', + ); +}); + +test('only the click listener is capture (Escape keydown stays bubble-phase)', () => { + // Exactly one capture binding in the installer — the click. The keydown + // (Escape) listener must NOT be capture. + const captureBinds = body.match(/,\s*true\s*\)/g) || []; + assert.equal(captureBinds.length, 1, 'expected exactly one capture-phase binding (the click)'); +}); + +test('the dismiss ignores clicks inside the control (no self-close)', () => { + assert.match(body, /section-practice-control/, 'must scope to #section-practice-control'); + assert.match(body, /ctrl\s*&&\s*ctrl\.contains\(e\.target\)\)\s*return/, + 'a click inside the control (incl. the pill) must not dismiss the popover'); +}); From 3120ae3e718234692da8c1fcef1b2bcb5e7fb344 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Mon, 29 Jun 2026 08:31:24 -0500 Subject: [PATCH 82/99] fix(highway_3d): dolly back so the fret-number row can't clip off the bottom (#633) The heat-coloured fret-number row is drawn as a band BELOW the board (sY(lowest) - S_GAP*1.4), but camUpdate's self-correcting framing only anchors the board CENTRE to the lower third of the screen and reserves no headroom for that row. So a tight zoom on a centred active span (worst mid-neck; fine at either end of the neck) pushes the numbers past the bottom edge -- which is why testers saw it "only when centered" and "not every song." Tilt can't fix it (it would only trade a bottom clip for a top clip); the vertical-extent problem at tight zoom needs camera distance. Add a fret-row fit guard: project the row band with the final camera and, when it falls below FRET_ROW_FIT_NDC_MIN, raise a capped, hysteretic _fretRowFitBoost applied to the curDist lerp target (the span-driven tgtDist still owns zooming IN). The boost rises promptly (proportional to the deficit), relaxes lazily past a deadband, and is capped at FRET_ROW_FIT_BOOST_MAX (+60%) so the zoom can't pop or hunt. It cooperates with the tilt loop (pull-back shrinks the scene, tilt keeps the centre anchored) and yields entirely to the Camera Director free-cam. Surgical: passages where the row is already visible never trigger it, so framing is unchanged everywhere it already worked. plugin.json 3.30.0 -> 3.30.2 (screen.js cache-buster; 3.30.1 is taken by the FPS-counter PR). Tests: tests/js/highway_3d_camera_framing.test.js (guard constants, the boosted curDist lerp, the projected-row hysteresis, free-cam yield). Fixes #632 Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 1 + plugins/highway_3d/plugin.json | 2 +- plugins/highway_3d/screen.js | 53 +++++++++++++++++++++- tests/js/highway_3d_camera_framing.test.js | 53 ++++++++++++++++++++++ 4 files changed, 106 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba961e8..f40a4c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`. - **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible. - **3D Highway FPS counter no longer hides behind the v3 "Up Next" pill.** The on-highway FPS readout (Settings → Graphics → 3D Highway → Show FPS counter) is pinned to the top-right of the highway overlay — the same corner the v3 player chrome stacks its persistent **Up Next** pill and live-performance HUD into, on a higher layer that paints over the canvas. So the readout sat *behind* that chrome and couldn't be read — precisely when a tester had turned it on to judge performance (it also made the separate "Up Next won't turn off" complaint worse, since the default-on pill covered the counter regardless). The counter now stays top-right but drops just **below** whichever of that chrome is showing: `highway_3d`'s `screen.js` measures the lowest visible top-right v3 HUD element (`#v3-upnext` / `#v3-live-performance-hud` / `#hud-time`) and floors the FPS box's Y beneath it. Element refs are resolved once and cached (no per-frame `querySelector`, per the plugin perf rules) and only consulted while the counter is actually drawn; gated on `window.feedBack.uiVersion === 'v3'` so the classic (v2) UI is byte-for-byte unaffected. `plugins/highway_3d/plugin.json` version → `3.30.1` (cache-buster). (For reading raw perf numbers unobstructed, the core perf HUD — `localStorage.highwayPerfHud='1'` — still renders above all chrome and additionally shows the adaptive render-scale.) +- **3D Highway fret-number row no longer clips off the bottom edge when the camera zooms in on a centred span.** The heat-coloured fret-number row is drawn as a band *below* the board (`sY(lowest) − S_GAP*1.4`), but the camera's self-correcting framing only anchors the board **centre** to the lower third of the screen — it reserved no headroom for that row. So a tight zoom on a centred active span (worst around mid-neck; fine when the span sits at either end of the neck, which is why testers saw it "only when centered" and "not every song") dropped the numbers past the bottom edge. Tilt can't fix it there (it would only trade a bottom clip for a top clip), so `camUpdate()` now **dollies the camera back just enough to bring the row back into frame**: it projects the row band with the final camera and, when it falls below a safe NDC line (`FRET_ROW_FIT_NDC_MIN`), raises a capped, hysteretic `_fretRowFitBoost` applied to the `curDist` lerp target (the span-driven zoom still owns zooming *in*). The boost rises promptly (proportional to the deficit), relaxes lazily past a deadband, and is capped (`FRET_ROW_FIT_BOOST_MAX`, +60%) so the zoom can't pop or hunt; it cooperates with the tilt loop (pull-back shrinks the scene, tilt keeps the centre anchored) and yields entirely to the Camera Director free-cam. Surgical: passages where the row is already visible never trigger it, so framing is unchanged everywhere it already worked. `plugins/highway_3d/plugin.json` version → `3.30.2` (cache-buster). Tests: `tests/js/highway_3d_camera_framing.test.js` (guard constants, the boosted `curDist` lerp, the projected-row hysteresis, free-cam yield). - **v3 Songs grid now refreshes after a Settings rescan / DLC-folder change — no app restart needed.** On a fresh install, pointing at a DLC folder in Settings and running a scan left the Songs section empty until a restart (the scan *did* populate the library — `_background_scan` re-reads `config.json` fresh — but the v3 grid never reloaded). The Settings **Rescan / Full Rescan** handlers only refreshed the classic (v2) library via `loadLibrary()`; the v3 grid (`static/v3/songs.js`) had no listener for a scan it didn't start itself (only its own upload path self-refreshed via `watchUploadScan`), so its cached, pre-DLC (empty) DOM/snapshot survived a sidebar return until a full reload. The rescan handlers now emit a **`library:changed`** event (`static/app.js`); the v3 grid listens and **reloads if it's the active screen, else marks itself dirty** so the next entry does a full re-fetch instead of restoring the stale snapshot (a `_libraryDirty` short-circuit ahead of every cached-DOM fast-path in `onV3SongsScreenEnter`). Tests: `tests/js/v3_library_refresh.test.js` (the emit + the reload/dirty wiring). - **Edit Metadata modal: the Year is now editable.** You could set a year when authoring a pak but the Songs → Edit Metadata modal had no Year field, so it could never be changed afterward. The backend (`POST /api/song//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). diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index 13b88f5..4b5d106 100644 --- a/plugins/highway_3d/plugin.json +++ b/plugins/highway_3d/plugin.json @@ -1,7 +1,7 @@ { "id": "highway_3d", "name": "3D Highway", - "version": "3.30.1", + "version": "3.30.2", "type": "visualization", "bundled": true, "script": "screen.js", diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index fb3109a..ee17029 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1095,6 +1095,16 @@ const CAM_FRAME_H_FAR = 1.00; const CAM_FRAME_D_NEAR = 0.575; const CAM_FRAME_D_FAR = 0.60; + // Fret-row fit guard. The heat-coloured fret-number row is a band drawn + // BELOW the board (at sY(lowest) - S_GAP*1.4). The lower-third framing + // anchors the board CENTRE, not that row, so a tight zoom on a centred span + // (worst mid-neck — fine pushed to either end of the neck) drops the row off + // the bottom edge. Tilt can't add vertical room there (it would only trade a + // bottom clip for a top clip), so camUpdate dollies the camera back just + // enough to bring the row back into frame — auto-sized, capped, hysteretic. + const FRET_ROW_FIT_NDC_MIN = -0.86; // keep the row anchor at/above this NDC y (>-1 = on screen) + const FRET_ROW_FIT_DEADBAND = 0.06; // headroom past the min before the dolly relaxes (anti-hunt) + const FRET_ROW_FIT_BOOST_MAX = 1.6; // cap the pull-back so the zoom can't pop (never dolly back > +60%) // Camera-X targeting (issue #34). The visible AHEAD = 4.0 s window is // far too coarse for picking where the camera should sit — a single @@ -4091,6 +4101,11 @@ let tgtX = xFretMid(CAM_LOCK_CENTER_FRET), curX = xFretMid(CAM_LOCK_CENTER_FRET); let tgtDist = CAM_DIST_BASE, curDist = CAM_DIST_BASE; + // Dolly-back multiplier applied to the curDist lerp target by camUpdate's + // fret-row fit guard. 1 = no extra pull-back (the common case); rises + // toward FRET_ROW_FIT_BOOST_MAX only when a tight, centred zoom would push + // the fret-number row past the bottom edge, then relaxes back to 1. + let _fretRowFitBoost = 1; // Last committed lowFretBonus contribution baked into tgtDist // (see candidateDist block — bonus is applied on top of the // hysteresis-gated base). @@ -13906,7 +13921,9 @@ const lerp = CAM_LERP_BASE * Math.max(bpm, 60) / 120; curX += (tgtX - curX) * lerp; - curDist += (tgtDist - curDist) * lerp; + // The fret-row fit guard (end of camUpdate) may dolly the camera back + // via _fretRowFitBoost; the span-driven tgtDist still owns zooming IN. + curDist += (tgtDist * _fretRowFitBoost - curDist) * lerp; const dist = curDist * aspectScale; const h = CAM_H_BASE * (dist / CAM_DIST_BASE); @@ -13978,6 +13995,38 @@ } else { cam.lookAt(curX, curLookY, _lookAtZ); } + + // ── Fret-row fit guard ──────────────────────────────────────────── + // Project the fret-number-row band (just below the lowest string, at + // the play line) with the final camera. If it sits below the safe + // bottom line, dolly back (raise _fretRowFitBoost → applied to the + // curDist lerp target next frame) until it clears; relax lazily once + // there's comfortable headroom. Asymmetric + deadbanded so it + // converges without hunting, and capped so the zoom can't pop. It + // cooperates with the tilt loop above rather than fighting it: pulling + // back shrinks the scene, the tilt loop keeps the board centre anchored + // at DESIRED_NDC_Y, so only the row's bottom headroom changes. Skipped + // while the free-cam (Camera Director) owns the view. + if (_freeCam && _freeCam.enabled) { + if (_fretRowFitBoost !== 1) _fretRowFitBoost = 1; + } else { + cam.updateMatrixWorld(); + const _rowY = Math.min(sY(0), sY(nStr - 1)) - S_GAP * 1.4; + _probe.set(curX, _rowY, 0.5 * K); + _probe.project(cam); // _probe.y → NDC; < -1 = off the bottom + const _rowNdcY = _probe.y; + if (_rowNdcY < FRET_ROW_FIT_NDC_MIN) { + // Row below the safe line → pull back promptly, proportional to + // the deficit so it converges in a few frames without overshoot. + const _need = FRET_ROW_FIT_NDC_MIN - _rowNdcY; + _fretRowFitBoost = Math.min(FRET_ROW_FIT_BOOST_MAX, + _fretRowFitBoost + Math.min(0.05, _need * 0.4)); + } else if (_rowNdcY > FRET_ROW_FIT_NDC_MIN + FRET_ROW_FIT_DEADBAND + && _fretRowFitBoost > 1) { + // Comfortable headroom → relax the dolly back toward normal, lazily. + _fretRowFitBoost = Math.max(1, _fretRowFitBoost - 0.01); + } + } } /* ── Resize helper ───────────────────────────────────────────────── */ @@ -14234,7 +14283,7 @@ pFretColMarker = null; _fretMarkerWaveCache.clear(); gNote = gSus = gBeat = gTapChevron = null; - tgtX = curX = xFretMid(CAM_LOCK_CENTER_FRET); tgtDist = curDist = CAM_DIST_BASE; tgtLookY = curLookY = 0; nStr = NSTR; _oobStringWarned = false; + tgtX = curX = xFretMid(CAM_LOCK_CENTER_FRET); tgtDist = curDist = CAM_DIST_BASE; tgtLookY = curLookY = 0; _fretRowFitBoost = 1; nStr = NSTR; _oobStringWarned = false; _lookaheadCamX = xFretMid(CAM_LOCK_CENTER_FRET); _lookaheadFretSpan = DEFAULT_LOOKAHEAD_FRET_SPAN; _lookaheadCamPrevNow = null; diff --git a/tests/js/highway_3d_camera_framing.test.js b/tests/js/highway_3d_camera_framing.test.js index dd58646..48bcd61 100644 --- a/tests/js/highway_3d_camera_framing.test.js +++ b/tests/js/highway_3d_camera_framing.test.js @@ -130,6 +130,59 @@ test('measure-start cache is invalidated on song change', () => { ); }); +// ── Fret-row fit guard ────────────────────────────────────────────────────── +// Keeps the heat-coloured fret-number row from clipping off the bottom edge +// when a tight, centred zoom (worst mid-neck) drops it below the lower-third +// framing. camUpdate dollies the camera back via a capped, hysteretic boost. + +test('fret-row fit guard constants are defined', () => { + for (const name of [ + 'FRET_ROW_FIT_NDC_MIN', 'FRET_ROW_FIT_DEADBAND', 'FRET_ROW_FIT_BOOST_MAX', + ]) { + assert.match(src, new RegExp('const\\s+' + name + '\\s*='), + `${name} must be declared as a fit-guard constant`); + } +}); + +test('the curDist lerp target applies the fit-guard dolly boost', () => { + // The span-driven tgtDist still owns zooming in; the boost only pulls back. + assert.match( + src, + /curDist\s*\+=\s*\(\s*tgtDist\s*\*\s*_fretRowFitBoost\s*-\s*curDist\s*\)\s*\*\s*lerp/, + 'curDist must lerp toward tgtDist * _fretRowFitBoost', + ); +}); + +test('the guard projects the fret-row band and adjusts the boost with hysteresis', () => { + // Row band Y mirrors the render position (sY(lowest) - S_GAP * 1.4). + assert.match( + src, + /Math\.min\(\s*sY\(0\)\s*,\s*sY\(nStr\s*-\s*1\)\s*\)\s*-\s*S_GAP\s*\*\s*1\.4/, + 'the guard must probe the same row band the fret-number row is drawn at', + ); + // Prompt pull-back when below the min, capped at BOOST_MAX. + assert.match( + src, + /_rowNdcY\s*<\s*FRET_ROW_FIT_NDC_MIN[\s\S]*?Math\.min\(\s*FRET_ROW_FIT_BOOST_MAX/, + 'below the min NDC the boost rises, capped at FRET_ROW_FIT_BOOST_MAX', + ); + // Lazy relax only once past the deadband, floored at 1. + assert.match( + src, + /_rowNdcY\s*>\s*FRET_ROW_FIT_NDC_MIN\s*\+\s*FRET_ROW_FIT_DEADBAND[\s\S]*?Math\.max\(\s*1\s*,\s*_fretRowFitBoost/, + 'past the deadband the boost relaxes back toward 1', + ); +}); + +test('the fit guard yields to the free-cam (Camera Director)', () => { + // When the free-cam owns the view the auto dolly must reset to 1, not fight it. + assert.match( + src, + /if\s*\(\s*_freeCam\s*&&\s*_freeCam\.enabled\s*\)\s*\{\s*if\s*\(\s*_fretRowFitBoost\s*!==\s*1\s*\)\s*_fretRowFitBoost\s*=\s*1/, + 'with the free-cam enabled the guard must drop any auto dolly back to 1', + ); +}); + // ── Debug hook stayed removed ─────────────────────────────────────────────── test('temporary camera debug hook is not present', () => { From dfa825b4abf8c2e782f20eaa31ac4a298bb25e36 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Mon, 29 Jun 2026 08:48:35 -0500 Subject: [PATCH 83/99] docs: host theme contract proposal (#645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: host theme contract proposal (prevent features carving into one theme) Charrette output after a plugin UI feature (note_detect results card) was built against only the default skin and broke on the others — the colours adapted via tokens but the visual *devices* (glow ring, gradient) did not, because themes are design languages, not palettes, and nothing governs whether a theme does glow. Proposes a host theme contract: always-present semantic role tokens (incl. the missing on-accent + focus-ring), intent-named capability recipe slots where "off" is legal (an EMPHASIS recipe + an ACCENT-TEXT recipe), a window.feedBack.theme read/capability API + theme:changed event, a derive-surfaces-from-host reconciliation rule, accessibility baked in, and a skin-matrix verification gate. All additive + feature-detected. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * docs(host-theme-contract): make the proposal normative (review fixes) Address the review of #645 (manual + Codex) — the doc was a sound design sketch but not yet the precise *contract* it claims to be: - P1 Token contract pinned: one public namespace `--fb-*` written on :root by a host-owned contract stylesheet (present themed-or-not); `--fbv-*` explicitly demoted to internal Tailwind-override plumbing (plugins must not read it). Added a normative role table + value grammar (colour roles = `r g b` triplets consumed via `rgb(var(--fb-x))`; recipe slots = full CSS device values). The §5 example now uses `--fb-*` throughout (was bare `--accent`/`--emph-*`). - P1 Invisible-text bug removed from the spec: `--fb-acc-text-fill` is the one slot where `none` is ILLEGAL (always a real paint, defaulting to the solid accent); the example feature-detects `background-clip: text` and keeps a solid `color` base, so the accuracy number can never render transparent — honouring the DoD "a device stays legible when its slot resolves to none". - P1 capabilities() booleans removed: they contradicted "never branch on glowy?" and were too lossy for canvas. The JS API is now CSS/DOM-forbidden and exposes RESOLVED token values (`get().tokens`) for canvas/WebGL renderers only. - P1 Physical home decided: a static `theme-contract.css` (outside the prebuilt Tailwind artifact, so no tailwind-fresh CI churn) holds the :root `--fb-*` defaults + the single focus-visible + reduced-motion rules; existing v3.css focus/motion rules are a tracked reconciliation, not day-one magic. - P2 Full on-fill family (`--fb-on-accent/-good/-warn/-bad`) + good/warn/bad ↔ existing good/mid/low mapping; `theme:changed` lifecycle pinned (get() sync + valid pre-apply, event after commit + once on hydration, plugins read on mount); same-document-light-DOM scope + shadow/iframe bridge stated; prefersReducedMotion() named the single JS motion gate. Resolved open questions folded into the body; the two genuine ones (skins-as- host-themes, component-recipe bundles) remain. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: byrongamatos --- docs/host-theme-contract.md | 246 ++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 docs/host-theme-contract.md diff --git a/docs/host-theme-contract.md b/docs/host-theme-contract.md new file mode 100644 index 0000000..fc2d89a --- /dev/null +++ b/docs/host-theme-contract.md @@ -0,0 +1,246 @@ +# Host Theme Contract — design proposal + +**Status:** proposal (charrette output, 2026-06-29) · **Owner area:** core v3 + plugin UI +**Trigger:** a plugin UI feature accidentally "carved itself into a single theme." + +## 1. Problem + +A results-card feature in the `note_detect` plugin (a glow-ring hero button + a +gradient-filled accuracy number) was built and visually verified against **only the +default skin** ("neon"). On the other skins it broke: on "esports" — a deliberately +glow-less, near-monochrome design language — the glow ring and the colour gradient +simply **vanished**. The colours adapted (everything used CSS custom-property tokens), +but the **visual devices themselves did not port**, because nothing in the system says +"this theme does / doesn't do glow rings." + +### Root cause (three findings) + +1. **Themes are design *languages*, not palettes.** neon = glow + animation + gradients; + esports = no-glow, square, near-monochrome amber; metal = brushed steel + hard bevels + + drop-shadows. Tokens made *colour* portable; they never made a *device* portable. +2. **Tokens are named by *device*, not *intent*.** e.g. `--nd-glow-*` holds a glow in neon + but a **hard drop-shadow** in metal — the metal skin is already repurposing a + device-named slot to express a different language. The cure is to finish that move: + name slots by intent, with "off" (`none`) a legal value. +3. **No "text-legible-on-accent" role.** White-on-accent was hardcoded in several places; + on esports' amber accent that's a contrast failure. And `--nd-accent2` was + **double-booked** (gradient-end *and* S-grade colour), so the hero gradient resolved + amber→near-white and washed out. + +A process gap compounds it: **verification covered one skin**, so the regression was +invisible until a user switched themes. And this recurs ecosystem-wide — other plugins +ship their own independent skin systems too. + +## 2. Current state (two disconnected systems) + +| System | What it is | Limits | +| --- | --- | --- | +| **Host themes** (`static/v3/theme-core.js`, `html[data-fb-theme]`) | Cosmetic "shop" themes that recolour `fb-*` Tailwind tokens (surfaces/text/borders). | Apply-only & recolour-only. `--fbv-*` vars exist **only while a theme is equipped** (nothing to read in the default state). No read API, no capability signal, no normalized `theme:changed` event. Comment explicitly says it *leaves decorative accents (rings/shadows) at defaults* → **devices are an ownerless gap.** | +| **Plugin skins** (e.g. `note_detect` `data-nd-skin`) | Full per-plugin design languages (neon/esports/metal) as CSS-var blocks. | Each plugin reinvents the wheel; disconnected from host themes; a feature can't see both. | + +## 3. Goals / non-goals + +- **Goal:** a feature, authored once, renders correctly in **any** theme — including ones not + yet invented — and degrades **intentionally** (neon ring → esports border), never accidentally. +- **Goal:** the host owns a canonical contract so plugins consume instead of reinventing. +- **Non-goal:** forcing every plugin skin to become a host theme. Skins stay plugin-local but + **implement** the contract. +- **Non-goal:** backward-compat with pre-v3 hosts. Everything here is additive + feature-detected. + +## 4. The contract — three layers + +### Layer 1 — Semantic colour **roles** (always present) + +The host writes default `--fb-*` role tokens on `:root` **unconditionally** (not only under +`[data-fb-theme]`), seeded from the canonical `fb` palette, so `var(--fb-accent, …)` always +resolves — themed or not. Roles: + +**Namespace (normative).** The public contract lives under one prefix, **`--fb-*`**, written on +`:root` by a host-owned *contract stylesheet* (see §6 / §8) so it is present **themed or not**. +The existing `--fbv-*` vars stay **internal plumbing** — `theme-core.js` uses them only to +recolour the Tailwind `.bg-fb-*/.text-fb-*/.border-fb-*` utilities under `html[data-fb-theme]`; +they are **not** part of this contract and plugins must not read them. (Implementation may seed +`--fb-*` from the same source the `--fbv-*` overrides use, so an equipped theme moves both.) + +**Value grammar (normative).** Colour roles are a **space-separated `r g b` triplet** (matching +today's `--fbv-*` and the Tailwind utilities), consumed as `rgb(var(--fb-accent))` with optional +alpha `rgb(var(--fb-accent) / .5)`. Recipe slots (Layer 2) hold **full CSS values** for their +device (a `box-shadow`, a `border` shorthand, a length, a paint), with `none` legal **except** +where noted. + +**Normative role tokens** (all `--fb-*`, all always present): + +| Role | Token | Notes | +| --- | --- | --- | +| surface / card / border | `--fb-surface` `--fb-card` `--fb-border` | structural | +| text / dim | `--fb-text` `--fb-text-dim` | | +| accent / second hue | `--fb-accent` `--fb-accent-2` | `accent-2` is **just a second hue** — never an assumed gradient end | +| status | `--fb-good` `--fb-warn` `--fb-bad` | maps onto today's palette `good / mid / low` (mid→warn, low→bad) — implementation aliases both | +| **on-fill (new)** | `--fb-on-accent` `--fb-on-good` `--fb-on-warn` `--fb-on-bad` | **Rule: every role used as a fill behind text gets a paired `--fb-on-*`** (fixes white-on-amber). Required + contrast-linted (§6). | +| **focus (new)** | `--fb-focus-ring` | focus indicator independent of `accent`, so focus stays visible when `accent ≈ surface` | + +### Layer 2 — Capability **recipes** (intent-named slots; "off" is legal) + +A theme declares its design *language* by filling intent-named slots (all `--fb-*`-prefixed, +same namespace as the roles). A feature applies the slot bundle **unconditionally**; it never +branches on "is this theme glowy?". Atomic slots (renames-by-intent of today's tokens): +`--fb-corner-radius`, `--fb-corner-clip`, `--fb-panel-shadow`, `--fb-text-emph-shadow`, +`--fb-panel-texture`, `--fb-motion-decorative` (reduced-motion-gated). For these, `none` is legal. + +Two **composite recipes** carry the load: + +- **EMPHASIS** — how this theme makes a primary action special: + `--fb-emph-fill / --fb-emph-border / --fb-emph-halo / --fb-emph-on`. + neon → halo (glow ring); esports → border (solid accent); metal → fill + drop-shadow. + Any individual slot may be `none` — but a theme **must** emphasise *somehow* (at least one of + fill/border/halo non-`none`), so a primary action is never visually flat. +- **ACCENT-TEXT** — how this theme fills a big accent number: `--fb-acc-text-fill` + (decoupled from `accent-2`). neon/metal → a gradient; esports → a solid accent. + **`--fb-acc-text-fill` is the one slot where `none` is illegal** — it is always a valid paint + (solid colour or gradient), defaulting to `rgb(var(--fb-accent))`. Reason: the number is + rendered with `background-clip: text` + transparent text-fill, so a `none` paint would make + the digits **invisible** (transparent fill, nothing to clip) — which would violate the DoD + "a device stays legible when its slot resolves to `none`". The feature also feature-detects + `background-clip: text` and keeps a solid `color` base (see §5), so the digits are legible + even where clip-text is unsupported. + +> These generalize the interim per-skin tokens already shipped in `note_detect` +> (`--nd-hero-ring-idle/on`, `--nd-hero-border`, `--nd-acc-fill`). + +### Layer 3 — JS read API + reconciliation + +**The JS API is only for renderers that can't use CSS (canvas / WebGL), never for DOM/CSS +consumers** — those use the tokens and slots directly (§5). Critically, it exposes *resolved +token values*, **not** theme-style booleans: a `glow:false` flag can't tell a canvas whether to +draw a border, a bevel, a drop-shadow, or flat text, so there is **no** `capabilities()` of +booleans. On the existing `window.feedBack` bus: + +- `feedBack.theme.get()` → `{ id, isThemed, tokens }` where `tokens` is the **resolved** map of + every `--fb-*` role + recipe slot (the computed values, so a canvas reads the actual device, + e.g. the gradient stops for `--fb-acc-text-fill`, not a boolean). +- `feedBack.theme.prefersReducedMotion()` → boolean (host wraps `matchMedia` once). **This is the + single approved JS reduced-motion gate going forward** — existing direct `matchMedia` callers + (`venue-mood-fx.js`, `pedal-cables.js`) migrate to it; `--fb-motion-decorative` covers the + CSS-authored decorative motion. +- `theme:changed` event → `{ id, tokens }`. + +**Lifecycle (normative).** `get()` always returns the **current effective theme synchronously** +and is valid at any time — before any theme is applied it returns the default/unthemed roles +(which always exist on `:root`). Theme application is async (it follows a `/api/profile` refresh); +`theme:changed` fires **only after** the DOM vars/classes are committed, and **once on initial +hydration** so a late-mounting plugin isn't stuck on stale state. **Plugin rule:** read `get()` +on mount, then subscribe to `theme:changed` — never assume an order between your mount and the +first theme apply. + +**Reconciliation rule (ends the two-disconnected-systems problem):** a plugin skin +**derives surface/text/border from host tokens** (`--nd-bg: rgb(var(--fb-card))`, etc.) and +**owns only its accent + its devices**, selecting the device via the recipe. A host theme then +pulls plugin chrome along (one truth for surfaces), while the plugin layers identity on top and +never imposes a device the active theme neutralizes. + +**Propagation scope (normative).** The contract is **same-document light-DOM**: `:root` `--fb-*` +inheritance and the central focus/motion rules (§6) reach any normal plugin screen. A plugin that +renders into a **shadow root or iframe** is responsible for bridging — copy the resolved +`get().tokens` into its sub-root and re-subscribe to `theme:changed` (host `:root` vars don't +cross those boundaries). + +## 5. Consumption pattern (the rule for feature authors) + +> **A feature may reference a colour *role* or a recipe *slot*. It may never write a raw +> device — no literal glow `box-shadow`, no literal `linear-gradient`, no hex.** Devices live +> in slots; the theme owns the slots. + +```css +.hero-cta { + background: var(--fb-emph-fill); + border: var(--fb-emph-border); + box-shadow: var(--fb-emph-halo); /* neon→ring · esports→none · metal→drop-shadow */ + color: var(--fb-emph-on); /* never hardcoded #fff again */ + border-radius: var(--fb-corner-radius); +} +.accuracy-number { + /* Always-legible solid base; survives no-clip-text support too. */ + color: rgb(var(--fb-accent)); +} +/* Apply the clipped paint ONLY where supported — and --fb-acc-text-fill is + guaranteed a real paint (never `none`, per Layer 2), so the digits can't go + invisible. */ +@supports ((background-clip: text) or (-webkit-background-clip: text)) { + .accuracy-number { + background: var(--fb-acc-text-fill); + -webkit-background-clip: text; background-clip: text; + -webkit-text-fill-color: transparent; + } +} +``` + +**Where the contract physically lives.** A **host-owned static contract stylesheet** (e.g. +`static/v3/theme-contract.css`, hand-authored, linked from `static/v3/index.html`) holds the +always-present `:root --fb-*` defaults **plus** the two central a11y rules below. It is **not** a +Tailwind file, so it never touches the prebuilt `static/tailwind.min.css` artifact the +`tailwind-fresh` CI check diffs (and it's independent of `theme-core.js`, which keeps +runtime-injecting only the `--fbv-*` utility overrides under `[data-fb-theme]`). + +- **Reduced motion:** `--fb-motion-decorative` is the *only* place CSS decorative animation is + named; one central rule in the contract sheet sets it to `none` under + `@media (prefers-reduced-motion: reduce)`, so no theme can forget the gate. (JS-driven motion + uses `feedBack.theme.prefersReducedMotion()` — §4.3.) +- **Focus parity:** one contract-level `:focus-visible { outline: 2px solid rgb(var(--fb-focus-ring)) }` + for contract consumers; themes recolour `--fb-focus-ring` but may not author their own focus + styling. *Migration:* v3 already ships component-specific focus + reduced-motion rules in + `v3.css`; those are reconciled onto the contract token (not magically replaced) as a tracked + cleanup — "one rule" describes the end state, not day one. +- **On-fill contrast:** every `--fb-on-*` is required and **lintable** + (`contrast(on-X, X) ≥ 4.5:1`, 3:1 large) for each fill role (`accent / good / warn / bad`). + Contrast is the theme's job, computed once — not re-judged per feature. + +## 7. Verification gate (prevent recurrence) + +- A committed **render-matrix** tool, driven off the runtime skin list, that renders the key + surfaces (hero CTA, accent number, **and the canvas share-image card**) across **every skin × + key states** (rest / hover / focus / reduced-motion). +- The gate is **computed-style invariant assertions** (deterministic, CI-safe) — e.g. "emphasis + present and text legible in each theme" — **not** pixel-snapshot diffing (the animated ring + + fonts + AA make snapshots flaky); a contact-sheet montage is the human backstop. +- Triggered on the version bump that CSS changes already require; skins enumerated at runtime + + a guard test so the matrix can't silently go stale. + +**Definition-of-done for any theme-touching UI change** (the few items that would have caught this): +expressed via tokens not hardcoded values · rendered across all skins · **a new visual *device* +stays legible when its slot resolves to `none`** · reduced-motion + focus parity · on-accent contrast. + +## 8. Back-compat & rollout + +All additive: the new always-present `--fb-*` tokens (in the contract sheet, §6) + a new +`feedBack.theme` namespace + a new event with no current listeners. Existing plugins (those +reading `fb-*` Tailwind utility classes, or shipping their own skins) are untouched unless they +opt in. On a host too old to ship the contract sheet, a consumer still degrades cleanly: the +two-arg fallback `rgb(var(--fb-accent, 224 128 32))` resolves to the literal, and +`window.feedBack?.theme?.get?.()` is feature-detected — so older hosts behave exactly as today. + +**Workstream (sub-tasks):** +1. **Host minimal surface** — the contract stylesheet's always-present default `--fb-*` tokens + `feedBack.theme.{get, prefersReducedMotion}` (`get().tokens` = resolved values; no boolean `capabilities()`) + `theme:changed`. *(the smallest thing that would have prevented the incident)* +2. **note_detect refactor** — rename device tokens by intent (EMPHASIS + ACCENT-TEXT recipes), add `on-accent` + `focus-ring`, derive surfaces from host tokens. +3. **Verification gate** — commit the render-matrix + DoD checklist; add the canvas share-card surface. +4. **Ecosystem migration guide** — document the contract + the consumption rule for community plugin authors. + +## 9. Cross-apply status (already done) + +- `note_detect` results-card hero + accuracy number — fixed via per-skin device tokens + (the Layer-2 prototype) and verified across neon/esports/metal. +- The **canvas share-image card** — re-checked across all three skins: **theme-robust** + (reads per-skin colour tokens via computed style, draws skin-neutral solid devices). Minor + fidelity gap only: it uses flat `--nd-bg` and skips metal's brushed-steel *texture*. + +## 10. Open questions + +- Should plugin skins eventually become *selectable host themes* (one picker), or stay + plugin-local forever? (This proposal assumes plugin-local + contract-implementing.) +- Component-recipe **bundles** (per named component) are the richer end-state; intent-named + slots are the right seed. When/whether to graduate. + +*(Resolved during review and folded into the sections above: the token namespace + value grammar +and normative role table (§4.1); the `none`-is-illegal carve-out for `--fb-acc-text-fill` (§4.2); +JS exposes resolved tokens, not booleans (§4.3); `theme:changed` lifecycle + shadow/iframe +propagation (§4.3); the physical home of the role tokens + central focus/motion rules — a +host-owned contract stylesheet outside Tailwind (§6).)* From a732e1f9d2ceaf39a9467f52cf96f45a48ef6a1c Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Mon, 29 Jun 2026 23:12:42 +0200 Subject: [PATCH 84/99] perf(tuner): idle the always-on tuner viz rAF when there's no signal (#647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v3 home tuner card runs continuously, and every tuner visualization drove a self-rescheduling 60fps requestAnimationFrame loop that never stopped — pinning a renderer core even on a silent home screen with the needle/strobe at rest. Make each viz idle its loop once there's nothing left to animate, and re-kick it from update() on the next reading that actually moves it: - analogue-gauge: stop when the needle + drum strip have settled on their targets (|target-current| below a sub-visible epsilon); restart when a new reading moves the target. - strobe / mace-fx-iii / chef-mt3: stop when there's no live signal and the strobe drift (and glow fade) have fully decayed; restart on the next note. - toilet-tuner: stop when silent and the plunger has eased back to centre; restart on the next reading (guarded so repeated no-signal updates don't re-kick a parked loop). Active tuning is unchanged — the loop runs whenever a note is sounding or the indicator is still moving. Bumps tuner 1.3.1 -> 1.3.2. Co-authored-by: Claude Opus 4.8 (1M context) --- plugins/tuner/plugin.json | 2 +- plugins/tuner/visualization/analogue-gauge.js | 29 +++++++++++++++++++ plugins/tuner/visualization/chef-mt3.js | 13 +++++++++ plugins/tuner/visualization/mace-fx-iii.js | 10 +++++++ plugins/tuner/visualization/strobe.js | 12 ++++++++ plugins/tuner/visualization/toilet-tuner.js | 21 ++++++++++++++ 6 files changed, 86 insertions(+), 1 deletion(-) diff --git a/plugins/tuner/plugin.json b/plugins/tuner/plugin.json index 02a779b..93830da 100644 --- a/plugins/tuner/plugin.json +++ b/plugins/tuner/plugin.json @@ -1,7 +1,7 @@ { "id": "tuner", "name": "Guitar/Bass Tuner", - "version": "1.3.1", + "version": "1.3.2", "bundled": true, "private": false, "script": "screen.js", diff --git a/plugins/tuner/visualization/analogue-gauge.js b/plugins/tuner/visualization/analogue-gauge.js index 4605a42..e0783d6 100644 --- a/plugins/tuner/visualization/analogue-gauge.js +++ b/plugins/tuner/visualization/analogue-gauge.js @@ -18,6 +18,8 @@ // ── Constants ───────────────────────────────────────────────────── var _TUNER_LABEL_H = 12; // px height of each drum label var _TUNER_NEEDLE_HALF_SWEEP = 90; // degrees — ±50 cents = horizontal (180° apart) + var _SETTLE_A = 0.05; // deg — needle "settled" threshold (sub-visible) + var _SETTLE_Y = 0.1; // px — drum-strip "settled" threshold var _TUNER_IN_TUNE_THRESHOLD = 2; var _TUNER_STRIP_START_MIDI = 14; // ~18 Hz — covers 20 Hz minimum var _TUNER_STRIP_END_MIDI = 84; // ~1047 Hz C6 @@ -321,9 +323,34 @@ currentAngle += (targetAngle - currentAngle) * lf; _setNeedle(currentAngle); + // Stop once the needle has settled on its target — a static needle + // needs no repaint. update() re-kicks the loop when a new reading + // moves the target, so this idles the always-on tuner (no signal / + // steady pitch) instead of pinning a core at 60 fps forever. + if (Math.abs(targetDrumY - currentDrumY) <= _SETTLE_Y + && Math.abs(targetAngle - currentAngle) <= _SETTLE_A) { + currentDrumY = targetDrumY; currentAngle = targetAngle; + freqStrip.style.transform = 'translateY(' + currentDrumY + 'px)'; + noteStrip.style.transform = 'translateY(' + currentDrumY + 'px)'; + _setNeedle(currentAngle); + rafId = null; + return; + } rafId = requestAnimationFrame(_animate); } + // Restart the loop only when there's actually something to animate toward + // (a new target). Reset lastTime so the first frame after an idle gap + // doesn't take one big easing step. + function _kick() { + if (rafId === null + && (Math.abs(targetDrumY - currentDrumY) > _SETTLE_Y + || Math.abs(targetAngle - currentAngle) > _SETTLE_A)) { + lastTime = performance.now(); + rafId = requestAnimationFrame(_animate); + } + } + rafId = requestAnimationFrame(_animate); // ── Public API ──────────────────────────────────────────────── @@ -360,6 +387,7 @@ bulbEl.style.backgroundColor = '#2a1010'; bulbEl.style.border = '2px solid #4a2020'; bulbEl.style.boxShadow = 'none'; + _kick(); // animate back to rest, then the loop self-stops return; } @@ -376,6 +404,7 @@ bulbEl.style.border = '2px solid #4a2020'; bulbEl.style.boxShadow = 'none'; } + _kick(); // a new reading moved the target → run until it settles } function destroy() { diff --git a/plugins/tuner/visualization/chef-mt3.js b/plugins/tuner/visualization/chef-mt3.js index 19121cc..7732980 100644 --- a/plugins/tuner/visualization/chef-mt3.js +++ b/plugins/tuner/visualization/chef-mt3.js @@ -620,8 +620,20 @@ if (_mt3Mode === 'strobe') { _computeStrobeStates(); } _applyTickStates(); + + // No signal and both the glow and strobe drift have fully settled → + // idle the loop. update() re-kicks it on the next note. + if (!_mt3HasSignal && _mt3GlowOpacity < 0.004 + && Math.abs(_mt3SmoothedCents) <= 0.1) { + _mt3RafId = null; + _mt3LastTime = null; + return; + } _mt3RafId = requestAnimationFrame(_animateStrobe); } + function _kick() { + if (_mt3RafId === null) { _mt3LastTime = null; _mt3RafId = requestAnimationFrame(_animateStrobe); } + } _mt3RafId = requestAnimationFrame(_animateStrobe); // ── MODE button ─────────────────────────────────────────────── @@ -677,6 +689,7 @@ _renderNote(' '); _applyAccidental(); } + if (hasNote) { _kick(); } // new signal → restart the strobe loop if idled } // ── Public: destroy ─────────────────────────────────────────── diff --git a/plugins/tuner/visualization/mace-fx-iii.js b/plugins/tuner/visualization/mace-fx-iii.js index 8c3a23b..3ca5244 100644 --- a/plugins/tuner/visualization/mace-fx-iii.js +++ b/plugins/tuner/visualization/mace-fx-iii.js @@ -354,10 +354,19 @@ if (_smoothedCents > 0) { speed = -speed; } _strobeOffset = ((_strobeOffset + speed * dt) % _totalDash + _totalDash) % _totalDash; arcPath.setAttribute('stroke-dashoffset', String(_strobeOffset)); + } else if (_currentCents === 0) { + // Fully decelerated and no live signal → idle the loop instead of + // rescheduling forever. update() re-kicks it on the next note. + _rafId = null; + _lastTime = null; + return; } _rafId = requestAnimationFrame(_animateStrobe); } + function _kick() { + if (_rafId === null) { _lastTime = null; _rafId = requestAnimationFrame(_animateStrobe); } + } _rafId = requestAnimationFrame(_animateStrobe); // ── Helper: derive octave number from frequency ─────────────── @@ -439,6 +448,7 @@ // Strobe state — smoothed animation decelerates naturally when _currentCents → 0 _currentCents = hasNote ? cents : 0; + if (hasNote) { _kick(); } // new signal → restart the decel loop if idled } // ── Public: destroy ─────────────────────────────────────────── diff --git a/plugins/tuner/visualization/strobe.js b/plugins/tuner/visualization/strobe.js index f33340a..24e6ce2 100644 --- a/plugins/tuner/visualization/strobe.js +++ b/plugins/tuner/visualization/strobe.js @@ -142,9 +142,20 @@ window._tunerViz_strobe = function (container) { strobeEl.style.opacity = '0'; } + // Idle the loop when there's no live signal — the strobe only needs to + // paint while a note is sounding. update() re-kicks it on the next note, + // so a silent tuner stops repainting instead of spinning at 60 fps. + if (!strobeActive) { rafId = null; return; } rafId = requestAnimationFrame(_animate); } + function _kick() { + if (rafId === null) { + lastAnimateTime = performance.now(); + rafId = requestAnimationFrame(_animate); + } + } + rafId = requestAnimationFrame(_animate); // ── Public API ──────────────────────────────────────────────────── @@ -188,6 +199,7 @@ window._tunerViz_strobe = function (container) { const inTune = Math.abs(cents) < 5; strobeEl.style.opacity = inTune ? '1' : '0.6'; strobeEl.style.filter = inTune ? _STROBE_GLOW_IN_TUNE : _STROBE_GLOW_OUT; + _kick(); } function destroy() { diff --git a/plugins/tuner/visualization/toilet-tuner.js b/plugins/tuner/visualization/toilet-tuner.js index 44d5283..43180f1 100644 --- a/plugins/tuner/visualization/toilet-tuner.js +++ b/plugins/tuner/visualization/toilet-tuner.js @@ -122,6 +122,26 @@ plungerEl.style.left = _leftPct.toFixed(2) + '%'; plungerEl.style.top = _topPct.toFixed(2) + '%'; + // No live signal and the plunger has eased back to its resting centre + // → idle the loop. update() re-kicks it on the next note. + if (_currentNote === null && !_plungerDipped + && Math.abs(targetLeft - _leftPct) < 0.05) { + _leftPct = targetLeft; + plungerEl.style.left = _leftPct.toFixed(2) + '%'; + _rafId = null; + _lastTime = null; + return; + } + + _rafId = requestAnimationFrame(_animate); + } + + function _kick() { + if (_rafId !== null) return; + // Already parked at rest with no signal → nothing to animate, stay idle. + if (_currentNote === null && !_plungerDipped + && Math.abs(_TUNER_TT_CENTRE_PCT - _leftPct) < 0.05) return; + _lastTime = null; _rafId = requestAnimationFrame(_animate); } @@ -130,6 +150,7 @@ _currentNote = note; _currentCents = note === null ? 0 : cents; if (!_plungerDipped) { noteEl.textContent = note || '–'; } + _kick(); // a new reading may move the plunger → ensure the loop runs } function destroy() { From db81d7dafb8a81f5fcc4b8963fb03aea91b5f826 Mon Sep 17 00:00:00 2001 From: "K. O. A." Date: Mon, 29 Jun 2026 17:44:09 -0400 Subject: [PATCH 85/99] Add progress bar to v3 "Up Next" pill (#649) The persistent top-right "Up Next" pill showed the upcoming section name and a countdown ("in 12.3s") but no at-a-glance sense of how far through the current section the song is. Add a thin progress bar directly under the existing text that fills as the current section elapses toward the next, reaching full when the section flips. The text row is wrapped unchanged in a flex row and the pill stacks the bar beneath it; nothing else about the pill's content or styling changes. Progress is computed in updateUpNext() as the fraction elapsed between the previous section boundary (last section at/before now, else song start) and the next section. The fill uses the same gradient as the section name for visual cohesion. Signed-off-by: topkoa Co-authored-by: Claude Opus 4.8 --- static/v3/index.html | 9 ++++++--- static/v3/player-chrome.js | 13 +++++++++++++ static/v3/v3.css | 25 +++++++++++++++++++++++-- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/static/v3/index.html b/static/v3/index.html index d25f14f..18bbbe3 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -855,9 +855,12 @@
diff --git a/static/v3/player-chrome.js b/static/v3/player-chrome.js index 0657711..ce13c57 100644 --- a/static/v3/player-chrome.js +++ b/static/v3/player-chrome.js @@ -187,6 +187,19 @@ const nm = $('v3-upnext-name'), eta = $('v3-upnext-eta'); if (nm) nm.textContent = next.name || '—'; if (eta) eta.textContent = 'in ' + dt.toFixed(1) + 's'; + // Progress bar: fraction of the current section elapsed toward `next`. + // Previous boundary is the last section at/before now (else song start). + const fill = $('v3-upnext-bar-fill'); + if (fill) { + let prevT = 0; + for (let i = 0; i < secs.length; i++) { + if (typeof secs[i].time === 'number' && secs[i].time <= t) prevT = secs[i].time; + else break; + } + const span = next.time - prevT; + const prog = span > 0 ? Math.max(0, Math.min(1, (t - prevT) / span)) : 0; + fill.style.width = (prog * 100).toFixed(1) + '%'; + } pill.classList.remove('hidden'); } diff --git a/static/v3/v3.css b/static/v3/v3.css index e9caf26..8cc2336 100644 --- a/static/v3/v3.css +++ b/static/v3/v3.css @@ -340,8 +340,9 @@ input, textarea, select, /* — Up Next pill (top-right, persistent) — */ #player-hud .v3-upnext { display: flex; - align-items: center; - gap: .5rem; + flex-direction: column; + align-items: stretch; + gap: .35rem; padding: .45rem .9rem; border-radius: .75rem; background: rgba(15, 23, 42, .7); @@ -351,6 +352,26 @@ input, textarea, select, pointer-events: auto; } #player-hud .v3-upnext.hidden { display: none; } +/* Text row keeps the original inline layout untouched. */ +#player-hud .v3-upnext .v3-upnext-row { + display: flex; + align-items: center; + gap: .5rem; +} +/* Progress bar under the text — fills as the current section elapses. */ +#player-hud .v3-upnext .v3-upnext-bar { + height: 4px; + border-radius: 999px; + background: rgba(148, 163, 184, .25); + overflow: hidden; +} +#player-hud .v3-upnext .v3-upnext-bar-fill { + height: 100%; + width: 0%; + border-radius: inherit; + background: linear-gradient(90deg, #22d3ee, #a855f7, #f472b6); + transition: width .12s linear; +} /* — Live performance HUD (top-right, read-only) — */ .v3-live-performance-hud { From 3e3a98a0d0d185460d13aab1718a304a669b2283 Mon Sep 17 00:00:00 2001 From: "K. O. A." Date: Tue, 30 Jun 2026 11:36:32 -0400 Subject: [PATCH 86/99] Aspect-aware framing for ultra-wide 3D highway panes (#652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add aspect-aware framing for ultra-wide highway panes On a top/bottom 2-player split each 3D highway pane is full-width / half-height (~32:9). The camera's vertical FOV was locked at a single value, so at that aspect the horizontal cone ballooned past 130deg and squeezed the fixed-width neck into a thin central sliver with large dead margins on either side. Add a "horizontal-FOV-hold" path: past a configurable start aspect the effective vertical FOV is lowered so the horizontal cone stays roughly constant, letting the neck fill a wide pane. At/under the start aspect it is an exact no-op, so normal ~16:9 single-player and most 2x2 panes are unchanged. Optional pose nudges (height / dolly / pitch / look-depth) further flatten the view toward a low, immersive angle. Everything is driven by a runtime bridge (window.__h3dAspectTune) with a live tuner panel (Shift+A in the player) exposing every knob plus a live aspect/FOV readout, localStorage persistence, and a Copy button. Toggling the feature off restores the exact prior framing, so it doubles as an A/B control. Shipped on by default for wide panes for testing feedback. Source-pinned by tests/js/highway_3d_wide_fov.test.js. Co-Authored-By: Claude Opus 4.8 Signed-off-by: topkoa * fix(highway_3d): ship wide-pane framing default-OFF with a coherent config Review fixes for the aspect-aware framing. The first cut shipped _ASPECT_DEFAULTS = { enabled:true, baseVfov:30, blend:0, minVfovDeg:36 }, which contradicted the PR's own "default off → byte-for-byte prior behaviour" claim: - enabled:true made the tune active for everyone, and baseVfov:30 forced every pane's vertical fov from 70° to 30° (normal single-player/2x2 panes included — a drastic global zoom, not the advertised no-op). - blend:0 collapsed the Hor+ math back to base, so the actual horizontal-FOV- hold did nothing even on wide panes — the only net effect was the zoom. - minVfovDeg:36 > baseVfov:30 was an inverted floor (clamped wide panes UP to 36° rather than flooring a real reduction). New defaults: { enabled:false, baseVfov:BASE_VFOV(70), blend:1, minVfovDeg:HORPLUS_MIN_VFOV(28) }. Now: - OFF by default → camUpdate passes a null tune → effectiveVfov returns BASE_VFOV → exact no-op on every pane (verified: 70° at 16:9 and 32:9). - When a tester enables it (Shift+A), baseVfov==BASE_VFOV keeps normal/≤start panes at 70° (still a no-op there) and blend:1 makes the hold actually engage on genuinely wide panes (47.7° at 32:9, flooring toward 28° as aspect grows). - minVfovDeg < baseVfov is a real floor. Also bumps the localStorage key (h3d_aspect_tune → h3d_aspect_tune2) so a machine that persisted the old broken default gets the corrected one, and adds source-pin tests guarding default-off + the coherent base/blend/floor so this can't silently regress to default-on again. The pose-nudge values are left as the author's in-progress wide-pane look (dormant until enabled). 110/110 tests pass; node --check clean. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Signed-off-by: topkoa Co-authored-by: Claude Opus 4.8 Co-authored-by: byrongamatos --- plugins/highway_3d/screen.js | 356 ++++++++++++++++++++++++++- tests/js/highway_3d_wide_fov.test.js | 174 +++++++++++++ 2 files changed, 526 insertions(+), 4 deletions(-) create mode 100644 tests/js/highway_3d_wide_fov.test.js diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index ee17029..ae7f8a7 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1083,6 +1083,22 @@ const FOCUS_D = 600 * K; const CAM_LERP_BASE = 0.02; + // Base vertical field of view (deg). THREE's PerspectiveCamera fov is the + // VERTICAL angle; horizontal follows from the aspect ratio. At a normal + // ~16:9 pane this gives a ~102° horizontal cone. On an ultra-wide pane + // (top/bottom 2-player split → full-width/half-height → ~32:9) that + // horizontal cone balloons past 130° and squeezes the fixed-width neck into + // a central sliver. The optional horizontal-FOV-hold path below counters + // that by lowering the effective vertical fov as the pane widens. + const BASE_VFOV = 70; + // Horizontal-FOV-hold ("Hor+") defaults. At/under HORPLUS_START_ASPECT the + // effective vertical fov equals BASE_VFOV (exact no-op); past it the + // vertical fov drops to keep the horizontal cone ~constant so the neck + // fills a wide pane. HORPLUS_MIN_VFOV floors the result on pathological + // aspects. Engaged only via the window.__h3dAspectTune bridge (default off). + const HORPLUS_START_ASPECT = 16 / 9; + const HORPLUS_MIN_VFOV = 28; + // Zoom-dependent framing — height (h*) and depth (dist*) multipliers // applied to cam.position. Interpolated by `dist`: // NEAR = tight view (nut position, span<=4 -> dist~=93*K): lower/closer. @@ -1591,6 +1607,257 @@ ss.isCanvasFocused(highwayCanvas)); } + // A/B toggle for the wide-pane horizontal-FOV-hold. Flips + // window.__h3dAspectTune.enabled so the running app can switch between the + // current framing (off, the baseline) and the Hor+ framing (on) with one + // keypress, across all panes at once. Registered once per session via a + // module-level guard (it toggles a shared global, so per-instance + // registration would stack duplicate handlers and cancel itself out); it's + // a harmless debug control, so it is never unregistered. No-ops where the + // core shortcut API isn't present (older core / borrowed contexts). + let _abShortcutRegistered = false; + function _registerAspectAbShortcut() { + if (_abShortcutRegistered) return; + if (typeof window.registerShortcut !== 'function') return; + _abShortcutRegistered = true; + try { + window.registerShortcut({ + key: 'A', // uppercase e.key → produced with Shift held (Shift+A) + description: '3D Highway: toggle wide-pane framing A/B (Shift+A)', + scope: 'player', + handler: () => { + const t = _aspectTune(); + t.enabled = !t.enabled; + try { console.log('[h3d] wide-pane framing', t.enabled ? 'ON' : 'OFF'); } catch (e) {} + // Surface the live tuner panel whenever the feature is on, + // hide it when off. Built lazily on first use. + _ensureAspectPanel(); + _setAspectPanelVisible(t.enabled); + _syncAspectPanel(); + }, + }); + } catch (e) { + _abShortcutRegistered = false; // allow a later retry if it threw + } + } + + // ── Wide-pane framing: live tuner bridge + panel ────────────────────────── + // window.__h3dAspectTune is the single source of truth the renderer reads + // each frame (see effectiveVfov + camUpdate). The defaults reproduce the + // current framing exactly (enabled:false). Values persist to localStorage so + // a tuning session survives reloads; the floating panel (Shift+A) writes the + // same object live. All of this is a debug aid — none of it runs unless the + // user opts in. + // Versioned key: the first iteration shipped a broken default (enabled:true, + // baseVfov:30) and may have persisted it. Bumping the key ignores that stale + // state so the corrected default-off config actually takes effect. + const _ASPECT_LS = 'h3d_aspect_tune2'; + // Working defaults. Default OFF, so out of the box this is an exact no-op — + // every pane renders byte-for-byte as before (effectiveVfov returns + // BASE_VFOV and the pose nudges gate off). The config is also coherent when + // a tester turns it ON via Shift+A: baseVfov == BASE_VFOV so normal ~16:9 + // panes (single-player, most 2x2) stay at 70° even enabled, and only panes + // wider than startAspect (2.25) engage the Hor+ hold; blend:1 makes that + // hold actually take effect; minVfovDeg (28) sits below baseVfov so the floor + // is a real floor. The pose nudges are the in-progress wide-pane look a + // tester sees once enabled. localStorage overrides all of this per machine. + const _ASPECT_DEFAULTS = { + enabled: false, baseVfov: BASE_VFOV, startAspect: 2.25, hfovDeg: null, + blend: 1, minVfovDeg: HORPLUS_MIN_VFOV, splitOnly: false, + heightMul: 0.30, distMul: 0.95, pitchAdd: -1.5, lookDepthMul: 1, + }; + // Slider specs (numeric fields). Checkboxes (enabled/splitOnly) + the hfov + // override are handled separately in the panel builder. Ranges are wide on + // purpose — this is a tuning aid, the no-op default sits mid-range. + const _ASPECT_FIELDS = [ + { k: 'baseVfov', label: 'Base vFOV°', min: 18, max: 90, step: 1 }, + { k: 'startAspect', label: 'Start aspect', min: 1.0, max: 4.0, step: 0.05 }, + { k: 'blend', label: 'Blend', min: 0, max: 1, step: 0.05 }, + { k: 'minVfovDeg', label: 'Min vFOV°', min: 10, max: 60, step: 1 }, + { k: 'heightMul', label: 'Height ×', min: 0.1, max: 2.5, step: 0.05 }, + { k: 'distMul', label: 'Dolly ×', min: 0.2, max: 3.0, step: 0.05 }, + { k: 'pitchAdd', label: 'Pitch +', min: -40, max: 40, step: 0.5 }, + // Aims the camera further down the neck (>1) or pulls the aim back (<1). + // This is the lever that flattens the mid-distance "hump" toward a + // straight gradual recede. + { k: 'lookDepthMul', label: 'Look depth', min: 0.2, max: 3.0, step: 0.05 }, + ]; + let _aspectPanelEl = null; // the floating panel root (built once) + let _aspectPanelRO = null; // readout
+ let _aspectPanelRAF = 0; // readout poll handle + + // Get-or-create the live bridge object, seeded from defaults + localStorage. + function _aspectTune() { + let t = window.__h3dAspectTune; + if (!t || typeof t !== 'object') { + t = Object.assign({}, _ASPECT_DEFAULTS); + try { + const raw = localStorage.getItem(_ASPECT_LS); + if (raw) Object.assign(t, JSON.parse(raw)); + } catch (e) {} + window.__h3dAspectTune = t; + } + return t; + } + function _aspectPersist() { + try { + const t = _aspectTune(), out = {}; + Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t[k]; }); + localStorage.setItem(_ASPECT_LS, JSON.stringify(out)); + } catch (e) {} + } + + function _ensureAspectPanel() { + if (_aspectPanelEl || typeof document === 'undefined') return; + const t = _aspectTune(); + const wrap = document.createElement('div'); + wrap.id = 'h3d-aspect-tuner'; + wrap.style.cssText = [ + 'position:fixed', 'top:64px', 'right:12px', 'z-index:99999', + 'width:230px', 'padding:10px 12px', 'border-radius:8px', + 'background:rgba(12,18,28,0.92)', 'border:1px solid rgba(120,150,200,0.35)', + 'box-shadow:0 6px 24px rgba(0,0,0,0.5)', 'color:#cfe0f5', + 'font:11px/1.35 system-ui,sans-serif', 'user-select:none', + 'pointer-events:auto', + ].join(';'); + + const title = document.createElement('div'); + title.textContent = 'Wide-pane framing (A/B)'; + title.style.cssText = 'font-weight:700;margin-bottom:6px;color:#e8c040;'; + wrap.appendChild(title); + + // enabled + splitOnly checkboxes + [['enabled', 'Enabled (Shift+A)'], ['splitOnly', 'Split panes only']].forEach(([k, lbl]) => { + const row = document.createElement('label'); + row.style.cssText = 'display:flex;align-items:center;gap:6px;margin:2px 0;cursor:pointer;'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; cb.checked = !!t[k]; cb.dataset.k = k; + cb.addEventListener('change', () => { + _aspectTune()[k] = cb.checked; _aspectPersist(); + if (k === 'enabled') _setAspectPanelVisible(cb.checked); + }); + const span = document.createElement('span'); span.textContent = lbl; + row.appendChild(cb); row.appendChild(span); wrap.appendChild(row); + }); + + // numeric sliders + _ASPECT_FIELDS.forEach((f) => { + const row = document.createElement('div'); + row.style.cssText = 'margin:5px 0;'; + const head = document.createElement('div'); + head.style.cssText = 'display:flex;justify-content:space-between;'; + const lab = document.createElement('span'); lab.textContent = f.label; + const val = document.createElement('span'); + val.style.cssText = 'color:#8fb6ff;font-variant-numeric:tabular-nums;'; + head.appendChild(lab); head.appendChild(val); row.appendChild(head); + const sl = document.createElement('input'); + sl.type = 'range'; sl.min = f.min; sl.max = f.max; sl.step = f.step; + sl.value = Number.isFinite(t[f.k]) ? t[f.k] : _ASPECT_DEFAULTS[f.k]; + sl.dataset.k = f.k; + sl.style.cssText = 'width:100%;'; + const show = () => { val.textContent = (+sl.value).toFixed(f.step < 1 ? 2 : 0); }; + show(); + sl.addEventListener('input', () => { + _aspectTune()[f.k] = parseFloat(sl.value); show(); _aspectPersist(); + }); + row.appendChild(sl); wrap.appendChild(row); + }); + + // hfov override (checkbox enables a slider; off → hfovDeg=null = auto) + { + const row = document.createElement('div'); row.style.cssText = 'margin:5px 0;'; + const head = document.createElement('label'); + head.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; cb.checked = Number.isFinite(t.hfovDeg); + const lbl = document.createElement('span'); lbl.textContent = 'Override held hFOV°'; + head.appendChild(cb); head.appendChild(lbl); row.appendChild(head); + const sl = document.createElement('input'); + sl.type = 'range'; sl.min = 40; sl.max = 160; sl.step = 1; + sl.value = Number.isFinite(t.hfovDeg) ? t.hfovDeg : 102; + sl.disabled = !cb.checked; + sl.style.cssText = 'width:100%;'; + cb.addEventListener('change', () => { + sl.disabled = !cb.checked; + _aspectTune().hfovDeg = cb.checked ? parseFloat(sl.value) : null; + _aspectPersist(); + }); + sl.addEventListener('input', () => { + if (cb.checked) { _aspectTune().hfovDeg = parseFloat(sl.value); _aspectPersist(); } + }); + row.appendChild(sl); wrap.appendChild(row); + } + + // live readout + _aspectPanelRO = document.createElement('div'); + _aspectPanelRO.style.cssText = 'margin-top:6px;padding-top:6px;border-top:1px solid rgba(120,150,200,0.25);color:#9fb;font-variant-numeric:tabular-nums;'; + _aspectPanelRO.textContent = 'aspect — · vFOV —'; + wrap.appendChild(_aspectPanelRO); + + // buttons + const btnRow = document.createElement('div'); + btnRow.style.cssText = 'display:flex;gap:6px;margin-top:8px;'; + const mkBtn = (txt, fn) => { + const b = document.createElement('button'); + b.textContent = txt; + b.style.cssText = 'flex:1;padding:4px 0;border-radius:5px;border:1px solid rgba(120,150,200,0.4);background:rgba(40,60,90,0.6);color:#cfe0f5;cursor:pointer;font:11px system-ui;'; + b.addEventListener('click', fn); + return b; + }; + btnRow.appendChild(mkBtn('Reset', () => { + const t2 = _aspectTune(); + Object.keys(_ASPECT_DEFAULTS).forEach((k) => { t2[k] = _ASPECT_DEFAULTS[k]; }); + t2.enabled = true; // keep panel up after reset + _aspectPersist(); _syncAspectPanel(); + })); + btnRow.appendChild(mkBtn('Copy', () => { + const t2 = _aspectTune(), out = {}; + Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t2[k]; }); + const json = JSON.stringify(out, null, 2); + try { console.log('[h3d] wide-pane framing values:\n' + json); } catch (e) {} + try { if (navigator.clipboard) navigator.clipboard.writeText(json); } catch (e) {} + })); + wrap.appendChild(btnRow); + + document.body.appendChild(wrap); + _aspectPanelEl = wrap; + _aspectPanelEl.style.display = 'none'; + } + + // Push current bridge values back into the panel controls (after Reset or an + // external edit). Cheap; only runs on demand. + function _syncAspectPanel() { + if (!_aspectPanelEl) return; + const t = _aspectTune(); + _aspectPanelEl.querySelectorAll('input[type=checkbox][data-k]').forEach((cb) => { + cb.checked = !!t[cb.dataset.k]; + }); + _aspectPanelEl.querySelectorAll('input[type=range][data-k]').forEach((sl) => { + const k = sl.dataset.k; + if (Number.isFinite(t[k])) sl.value = t[k]; + sl.dispatchEvent(new Event('input')); // refresh the value label + }); + } + + function _setAspectPanelVisible(on) { + _ensureAspectPanel(); + if (!_aspectPanelEl) return; + _aspectPanelEl.style.display = on ? 'block' : 'none'; + window.__h3dAspectPanelOpen = !!on; // gates the per-frame readout publish + if (on && !_aspectPanelRAF) { + const tick = () => { + if (!window.__h3dAspectPanelOpen) { _aspectPanelRAF = 0; return; } + const ro = window.__h3dAspectReadout; + if (_aspectPanelRO && ro && Number.isFinite(ro.aspect)) { + _aspectPanelRO.textContent = + 'aspect ' + ro.aspect.toFixed(2) + ' · vFOV ' + ro.vfov.toFixed(1) + '°'; + } + _aspectPanelRAF = requestAnimationFrame(tick); + }; + _aspectPanelRAF = requestAnimationFrame(tick); + } + } + /* ====================================================================== * Background animations (issue #13) * @@ -3498,6 +3765,11 @@ // that CSS-box drift and re-frame, instead of the user having to // un/re-maximize the window. let _appliedW = 0, _appliedH = 0; + // Last pane aspect (w/h) handed to the camera, cached so camUpdate can + // recompute the horizontal-FOV-hold each frame (and react to live + // __h3dAspectTune edits) without waiting for a resize. 0 until first + // applySize(). + let _paneAspect = 0; // True once applySize() has pinned the .h3d-wrap overlay to the // highway canvas's offset box. Stays false while the canvas has no // layout yet (init() can run before #highway has a real box, where @@ -5976,7 +6248,7 @@ scene = new T.Scene(); scene.fog = new T.Fog(0x101820, FOG_START * 0.8, FOG_END * 1.2); - cam = new T.PerspectiveCamera(70, 1, 0.01, FOG_END * 3); + cam = new T.PerspectiveCamera(BASE_VFOV, 1, 0.01, FOG_END * 3); ambLight = new T.AmbientLight(0xffffff, 0.85); scene.add(ambLight); @@ -13915,11 +14187,77 @@ ctx.restore(); } + // Horizontal-FOV-hold ("Hor+"). Returns the vertical fov (deg) the + // camera should use for the given pane aspect. With the bridge off (or + // absent), or at/under the start aspect, it returns the base vertical + // fov unchanged — an exact no-op, so normal panes render identically to + // before. Past the start aspect it lowers the vertical fov to keep the + // horizontal cone ~constant, so the neck fills an ultra-wide pane + // instead of collapsing into a central sliver. Pure + finite-guarded. + function effectiveVfov(aspect, tune) { + const base = (tune && Number.isFinite(tune.baseVfov)) ? tune.baseVfov : BASE_VFOV; + if (!tune || !tune.enabled || !Number.isFinite(aspect) || aspect <= 0) return base; + const start = (Number.isFinite(tune.startAspect) && tune.startAspect > 0) + ? tune.startAspect : HORPLUS_START_ASPECT; + if (aspect <= start) return base; + const floor = Number.isFinite(tune.minVfovDeg) ? tune.minVfovDeg : HORPLUS_MIN_VFOV; + const DEG = Math.PI / 180; + // Held horizontal fov: explicit hfovDeg if given, else the horizontal + // cone the base vertical fov produces at the start aspect. + const hfov = (Number.isFinite(tune.hfovDeg) && tune.hfovDeg > 0) + ? tune.hfovDeg * DEG + : 2 * Math.atan(Math.tan(base * DEG / 2) * start); + // Vertical fov that reproduces that horizontal cone at this aspect. + let vfov = 2 * Math.atan(Math.tan(hfov / 2) / aspect) / DEG; + const blend = Number.isFinite(tune.blend) ? Math.max(0, Math.min(1, tune.blend)) : 1; + vfov = base + (vfov - base) * blend; // 0 = base, 1 = full Hor+ + if (!Number.isFinite(vfov)) return base; + return Math.max(floor, Math.min(base, vfov)); + } + /* ── Camera smooth lerp ──────────────────────────────────────────── */ function camUpdate(bundle) { const bpm = computeBPM(bundle.beats, bundle.currentTime); const lerp = CAM_LERP_BASE * Math.max(bpm, 60) / 120; + // ── Horizontal-FOV-hold + optional wide-pane pose nudges ── + // Driven by window.__h3dAspectTune (default off → exact no-op). + // _aspectTune() returns the live bridge object, seeded from defaults + // + localStorage on first read so a persisted tuning session applies + // on load without opening the panel. Every field is finite-coerced. + // When disabled (or splitOnly and not in a split) the tune is treated + // as null, so effectiveVfov returns the base vertical fov and cam.fov + // is restored to it. The fov write is guarded on an actual change so + // a steady pane costs nothing. + const _aspTune = _aspectTune(); + const _aspActive = !!(_aspTune && _aspTune.enabled + && !(_aspTune.splitOnly && !_ssActive())); + const _tune = _aspActive ? _aspTune : null; + const _vfov = effectiveVfov(_paneAspect, _tune); + if (Number.isFinite(_vfov) && Math.abs(_vfov - cam.fov) > 1e-4) { + cam.fov = _vfov; + cam.updateProjectionMatrix(); + } + // Publish a live readout for the tuner panel (only while it's open, + // so the steady path stays allocation-free). Last pane to render wins + // the slot — fine, all panes share the same aspect in a split layout. + if (window.__h3dAspectPanelOpen) { + const _ro = window.__h3dAspectReadout || (window.__h3dAspectReadout = {}); + _ro.aspect = _paneAspect; _ro.vfov = _vfov; + } + // Optional pose nudges (height / dolly / pitch) to chase a low-flat + // wide-pane look if fov alone isn't enough. Gated to wide panes and + // suppressed while the Camera Director owns the view (it wins). + const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0) + ? _tune.startAspect : HORPLUS_START_ASPECT; + const _dirActive = !!(window.__h3dCamCtl && window.__h3dCamCtl.enabled); + const _wide = !!(_tune && _paneAspect > _startAspect) && !_dirActive; + const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1; + const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1; + const _poseLookYAdd = (_wide && Number.isFinite(_tune.pitchAdd)) ? _tune.pitchAdd * K : 0; + const _poseLookZMul = (_wide && Number.isFinite(_tune.lookDepthMul) && _tune.lookDepthMul > 0) + ? _tune.lookDepthMul : 1; + curX += (tgtX - curX) * lerp; // The fret-row fit guard (end of camUpdate) may dolly the camera back // via _fretRowFitBoost; the span-driven tgtDist still owns zooming IN. @@ -13935,6 +14273,9 @@ const _dMul = CAM_FRAME_D_NEAR + (CAM_FRAME_D_FAR - CAM_FRAME_D_NEAR) * _zt; const shoulderOffset = (_leftyCached ? -1 : 1) * 10 * K; let _camX = curX + shoulderOffset, _camY = h * _hMul, _camZ = dist * _dMul; + // Optional wide-pane pose nudges (default identity → no-op). + if (_poseHMul !== 1) _camY *= _poseHMul; + if (_poseDMul !== 1) _camZ *= _poseDMul; // ── Free-camera user tweaks (orbit / height / zoom / pan) ── // Driven by the Camera Director plugin via window.__h3dCamCtl. // Layered ON TOP of the auto-framing so note tracking still works. @@ -13943,7 +14284,7 @@ // finite number before use so a malformed object can never feed NaN // into cam.position / cam.lookAt. const _freeCam = window.__h3dCamCtl; - const _lookAtZ = -FOCUS_D * 0.35; + const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul; if (_freeCam && _freeCam.enabled) { const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1; const _heightMul = Number.isFinite(_freeCam.heightMul) ? _freeCam.heightMul : 1; @@ -13964,7 +14305,7 @@ // This lets the camera adapt to any panel aspect ratio automatically. const fretMidY = (sY(0) + sY(nStr - 1)) / 2; _probe.set(curX, fretMidY, 0); // play-line fretboard centre - cam.lookAt(curX, curLookY, -FOCUS_D * 0.35); // tentative look — needed for project() + cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ); // tentative look — needed for project() cam.updateMatrixWorld(); _probe.project(cam); // _probe.y → NDC in [-1, 1] @@ -13993,7 +14334,7 @@ const _pitch = Number.isFinite(_freeCam.pitch) ? _freeCam.pitch : 0; cam.lookAt(curX + _panX * K, curLookY + (_pitch + _panY) * K, _lookAtZ); } else { - cam.lookAt(curX, curLookY, _lookAtZ); + cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ); } // ── Fret-row fit guard ──────────────────────────────────────────── @@ -14090,6 +14431,10 @@ cam.aspect = w / h; cam.updateProjectionMatrix(); aspectScale = Math.max(1, REF_ASPECT / Math.max(cam.aspect, 0.5)); + // Cache the pane aspect for the horizontal-FOV-hold in camUpdate. + // cam.fov itself is owned by camUpdate (not set here) so live + // __h3dAspectTune edits apply every frame without a resize. + _paneAspect = cam.aspect; _appliedW = w; _appliedH = h; } @@ -14333,6 +14678,7 @@ } _destroyed = _isReady = false; _isFocused = true; + _registerAspectAbShortcut(); // session-global A/B toggle (self-guarded) const myToken = ++_initToken; highwayCanvas = canvas; _invertedCached = !!(bundle && bundle.inverted); @@ -14751,6 +15097,8 @@ _destroyed = true; _isReady = false; _diagChord = null; _diagPrev = null; _diagLastKey = null; _diagRenderCache.clear(); _lastHwW = 0; _lastHwH = 0; _appliedW = 0; _appliedH = 0; + _paneAspect = 0; + if (cam && cam.fov !== BASE_VFOV) { cam.fov = BASE_VFOV; cam.updateProjectionMatrix(); } _wrapPinned = false; _unsubscribeFocus(); teardown(); highwayCanvas = null; diff --git a/tests/js/highway_3d_wide_fov.test.js b/tests/js/highway_3d_wide_fov.test.js new file mode 100644 index 0000000..936d933 --- /dev/null +++ b/tests/js/highway_3d_wide_fov.test.js @@ -0,0 +1,174 @@ +// Pins the wide-pane horizontal-FOV-hold ("Hor+") framing in +// plugins/highway_3d/screen.js. +// +// What it guards: ultra-wide panes (top/bottom 2-player split → full-width / +// half-height → ~32:9) used to render the neck as a thin central sliver because +// THREE's PerspectiveCamera fov is VERTICAL and was locked at 70°, ballooning +// the horizontal cone past 130°. The fix lets camUpdate lower the effective +// vertical fov as the pane widens (holding the horizontal cone ~constant) so the +// neck fills the pane. It is gated behind window.__h3dAspectTune (default off → +// byte-for-byte the prior behaviour) for live A/B comparison. +// +// A refactor that re-hardcodes the camera fov, drops the change-guarded cam.fov +// write, stops caching the pane aspect, or removes the no-op-at-startAspect +// guarantee would silently regress the feature (or worse, change normal-pane +// framing). These are source-level pins — same strategy as the other +// tests/js/ files (no DOM / WebGL in CI). + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); +const src = fs.readFileSync(SCREEN_JS, 'utf8'); + +// ── Constants ──────────────────────────────────────────────────────────────── + +test('BASE_VFOV is a named constant (not a literal in the camera ctor)', () => { + assert.match( + src, + /const\s+BASE_VFOV\s*=\s*70\s*;/, + 'BASE_VFOV must be declared as a constant', + ); +}); + +test('the camera is constructed with BASE_VFOV, not a bare 70', () => { + assert.match( + src, + /new\s+T\.PerspectiveCamera\(\s*BASE_VFOV\s*,/, + 'PerspectiveCamera must take BASE_VFOV as its vertical fov', + ); +}); + +test('the Hor+ start-aspect and min-vfov defaults exist', () => { + assert.match(src, /const\s+HORPLUS_START_ASPECT\s*=\s*16\s*\/\s*9\s*;/, + 'HORPLUS_START_ASPECT must default to 16/9 (no-op at/under the reference aspect)'); + assert.match(src, /const\s+HORPLUS_MIN_VFOV\s*=\s*\d+\s*;/, + 'HORPLUS_MIN_VFOV floor must be declared'); +}); + +// ── effectiveVfov: no-op guarantees ────────────────────────────────────────── + +test('effectiveVfov returns the base fov when the bridge is off/absent', () => { + // The disabled / malformed-input guard returns `base` before any Hor+ math, + // so normal panes are unaffected when __h3dAspectTune is missing or off. + assert.match( + src, + /function\s+effectiveVfov\s*\(\s*aspect\s*,\s*tune\s*\)\s*\{[\s\S]*?if\s*\(\s*!tune\s*\|\|\s*!tune\.enabled[\s\S]*?return\s+base\s*;/, + 'effectiveVfov must short-circuit to the base fov when disabled', + ); +}); + +test('effectiveVfov is a no-op at/under the start aspect', () => { + assert.match( + src, + /if\s*\(\s*aspect\s*<=\s*start\s*\)\s*return\s+base\s*;/, + 'effectiveVfov must return base when aspect <= start (no-op for normal/2x2 panes)', + ); +}); + +// ── shipped defaults: off + coherent ───────────────────────────────────────── +// The "default off → byte-for-byte prior behaviour" contract only holds if the +// shipped _ASPECT_DEFAULTS actually ship disabled with a base that matches the +// camera's constructed fov. A previous revision shipped enabled:true with +// baseVfov:30 (and blend:0), which forced every pane's fov to 30/36 and +// silently re-framed normal single-player panes. These pin against that. + +test('_ASPECT_DEFAULTS ships disabled (no-op out of the box)', () => { + assert.match( + src, + /const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\benabled\s*:\s*false\b/, + '_ASPECT_DEFAULTS.enabled must default to false so the feature is opt-in', + ); +}); + +test('the default base fov matches BASE_VFOV (enabling is still a no-op on normal panes)', () => { + // baseVfov === BASE_VFOV means even with the feature ON, a <=startAspect pane + // returns the unchanged 70° — the effect is confined to genuinely wide panes. + assert.match( + src, + /const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bbaseVfov\s*:\s*BASE_VFOV\b/, + '_ASPECT_DEFAULTS.baseVfov must default to BASE_VFOV, not a divergent literal', + ); +}); + +test('the default blend engages the hold and the floor sits below the base', () => { + // blend:1 means turning the feature on actually holds the horizontal cone + // (blend:0 would collapse effectiveVfov back to base = feature inert), and + // minVfovDeg:HORPLUS_MIN_VFOV keeps the floor below baseVfov (a real floor, + // not one that clamps the base upward). + assert.match( + src, + /const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bblend\s*:\s*1\b/, + '_ASPECT_DEFAULTS.blend must default to 1 so the Hor+ hold actually applies when enabled', + ); + assert.match( + src, + /const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bminVfovDeg\s*:\s*HORPLUS_MIN_VFOV\b/, + '_ASPECT_DEFAULTS.minVfovDeg must default to HORPLUS_MIN_VFOV (a floor below baseVfov)', + ); +}); + +// ── camUpdate: change-guarded fov write + cached aspect ─────────────────────── + +test('applySize caches the pane aspect for camUpdate', () => { + assert.match( + src, + /_paneAspect\s*=\s*cam\.aspect\s*;/, + 'applySize must cache cam.aspect into _paneAspect', + ); +}); + +test('camUpdate reads the live tune bridge and respects splitOnly', () => { + assert.match( + src, + /const\s+_aspTune\s*=\s*_aspectTune\(\)\s*;[\s\S]*?_aspTune\.splitOnly\s*&&\s*!_ssActive\(\)/, + 'camUpdate must read the bridge via _aspectTune() and gate splitOnly on _ssActive()', + ); +}); + +test('the tune bridge seeds from localStorage (persisted sessions apply on load)', () => { + assert.match( + src, + /function\s+_aspectTune\s*\(\)[\s\S]*?localStorage\.getItem\(\s*_ASPECT_LS\s*\)/, + '_aspectTune() must seed the bridge from localStorage', + ); +}); + +test('a floating tuner panel is built and toggled with the A/B state', () => { + assert.match(src, /function\s+_ensureAspectPanel\s*\(\)/, + '_ensureAspectPanel() must exist to build the live panel'); + assert.match(src, /function\s+_setAspectPanelVisible\s*\(/, + '_setAspectPanelVisible() must show/hide the panel with the feature'); +}); + +test('camUpdate only writes cam.fov when it actually changes', () => { + // Guarding the write avoids a per-frame updateProjectionMatrix on a steady + // pane and keeps the disabled path free. + assert.match( + src, + /Math\.abs\(\s*_vfov\s*-\s*cam\.fov\s*\)\s*>\s*1e-4[\s\S]*?cam\.fov\s*=\s*_vfov\s*;[\s\S]*?cam\.updateProjectionMatrix\(\)/, + 'camUpdate must guard the cam.fov write behind a change check', + ); +}); + +// ── A/B toggle + lifecycle reset ────────────────────────────────────────────── + +test('an A/B toggle shortcut flips the tune enabled flag', () => { + assert.match( + src, + /registerShortcut\(\{[\s\S]*?const\s+t\s*=\s*_aspectTune\(\)\s*;[\s\S]*?t\.enabled\s*=\s*!\s*t\.enabled/, + 'a registerShortcut handler must toggle the bridge enabled flag', + ); +}); + +test('destroy() resets the pane aspect and restores the base fov', () => { + assert.match(src, /_paneAspect\s*=\s*0\s*;/, + 'destroy() must reset _paneAspect to 0'); + assert.match( + src, + /cam\.fov\s*!==\s*BASE_VFOV[\s\S]*?cam\.fov\s*=\s*BASE_VFOV\s*;\s*cam\.updateProjectionMatrix\(\)/, + 'destroy() must restore cam.fov to BASE_VFOV for instance reuse', + ); +}); From f9607c5c94d4207eda30a79fd011ac3258222a80 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Tue, 30 Jun 2026 20:19:08 +0200 Subject: [PATCH 87/99] Port the Min res (minimum auto-resolution) selector into the v3 player (#663) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2 control bar exposes a "Min res" selector next to Quality that sets highway.setMinRenderScale — capping how far the load-adaptive resolution scaler (feedBack#654) may downscale, or disabling it entirely (Full). The v3 UI only ported the Quality selector, so v3 users had no way to stop the highway auto-downscaling to as low as quarter-res on heavy scenes / weak- GPU launches — pixelated even at Quality = HD, with no workaround (worse than v2). Add the Min res row to the v3 viz/quality rail popover, under Quality, mirroring the v2 control (same options, handler, title, aria). The handler, the setMinRenderScale/getMinRenderScale API, and the shared app.js init that syncs the selector's value (guarded by element id) all already exist — only the v3 markup was missing. Fixes #662 Co-authored-by: Claude Opus 4.8 (1M context) --- static/v3/index.html | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/static/v3/index.html b/static/v3/index.html index 18bbbe3..768d9cd 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -926,6 +926,15 @@
+
+ Min res + +
Scoreboard + let _aspectHfovCb = null; // hfov-override checkbox (synced explicitly) + let _aspectHfovSl = null; // hfov-override slider + // Which pane the panel edits. '' = all panes (writes the shared base object); + // a pane key (e.g. 'panel0') writes that pane's sparse override, so one split + // pane can be framed independently of the others. + let _aspectEditTarget = ''; + // Set when a renderer reports a pane key/label we haven't seen, so the panel + // rebuilds the Target dropdown on its next readout tick. + let _aspectPanesDirty = true; - // Get-or-create the live bridge object, seeded from defaults + localStorage. + // Get-or-create the shared bridge object, seeded from defaults + localStorage. + // May carry a sparse `__panels` map of per-pane overrides. function _aspectTune() { let t = window.__h3dAspectTune; if (!t || typeof t !== 'object') { @@ -1703,44 +1709,124 @@ try { const t = _aspectTune(), out = {}; Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t[k]; }); + if (t.__panels) out.__panels = t.__panels; localStorage.setItem(_ASPECT_LS, JSON.stringify(out)); } catch (e) {} } + // Resolve the effective tune for a pane: the shared base, with that pane's + // override keys (if any) laid on top. Called every frame per renderer. + function _resolveTuneFor(paneKey) { + const base = _aspectTune(); + const ov = base.__panels && base.__panels[paneKey]; + if (!ov) return base; + const out = {}; + Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = (k in ov) ? ov[k] : base[k]; }); + return out; + } + // Record a live pane so the Target dropdown can list it. Label prefers the + // arrangement name (e.g. "Panel 1 — Rhythm") so panes are easy to tell apart. + function _aspectRegisterPane(paneKey, arrangement) { + const reg = window.__h3dAspectPanes || (window.__h3dAspectPanes = {}); + let label; + if (paneKey === 'main') { label = 'Main'; } + else { const n = parseInt(paneKey.slice(5), 10); label = 'Panel ' + ((isFinite(n) ? n : 0) + 1); } + if (arrangement) label += ' — ' + arrangement; + if (!reg[paneKey] || reg[paneKey].label !== label) { + reg[paneKey] = { label }; + _aspectPanesDirty = true; + } + } + + // Read/write against the current edit target ('' → base, else pane override). + function _aspectReadVal(k) { + const base = _aspectTune(); + if (!_aspectEditTarget) return base[k]; + const ov = base.__panels && base.__panels[_aspectEditTarget]; + return (ov && (k in ov)) ? ov[k] : base[k]; + } + function _aspectWriteVal(k, v) { + const base = _aspectTune(); + if (!_aspectEditTarget) { base[k] = v; } + else { + const m = base.__panels || (base.__panels = {}); + (m[_aspectEditTarget] || (m[_aspectEditTarget] = {}))[k] = v; + } + _aspectPersist(); + } + + // (Re)build the Target dropdown from the live pane registry, preserving the + // current selection when it's still valid. + function _aspectBuildTargets() { + if (!_aspectTargetSel) return; + const reg = window.__h3dAspectPanes || {}; + const keys = Object.keys(reg).sort(); + _aspectTargetSel.innerHTML = ''; + const all = document.createElement('option'); + all.value = ''; all.textContent = keys.length > 1 ? 'All panes' : 'All'; + _aspectTargetSel.appendChild(all); + keys.forEach((pk) => { + const o = document.createElement('option'); + o.value = pk; o.textContent = reg[pk].label; + _aspectTargetSel.appendChild(o); + }); + if (_aspectEditTarget && !reg[_aspectEditTarget]) _aspectEditTarget = ''; + _aspectTargetSel.value = _aspectEditTarget; + _aspectPanesDirty = false; + } + function _ensureAspectPanel() { if (_aspectPanelEl || typeof document === 'undefined') return; - const t = _aspectTune(); const wrap = document.createElement('div'); wrap.id = 'h3d-aspect-tuner'; wrap.style.cssText = [ 'position:fixed', 'top:64px', 'right:12px', 'z-index:99999', - 'width:230px', 'padding:10px 12px', 'border-radius:8px', + 'width:236px', 'padding:10px 12px', 'border-radius:8px', 'background:rgba(12,18,28,0.92)', 'border:1px solid rgba(120,150,200,0.35)', 'box-shadow:0 6px 24px rgba(0,0,0,0.5)', 'color:#cfe0f5', 'font:11px/1.35 system-ui,sans-serif', 'user-select:none', 'pointer-events:auto', ].join(';'); + // Header: title + close (×). Close hides the panel; the feature keeps + // whatever enabled state it had — this is a dismiss, not an A/B toggle. + const hdr = document.createElement('div'); + hdr.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;'; const title = document.createElement('div'); - title.textContent = 'Wide-pane framing (A/B)'; - title.style.cssText = 'font-weight:700;margin-bottom:6px;color:#e8c040;'; - wrap.appendChild(title); + title.textContent = 'Wide-pane framing'; + title.style.cssText = 'font-weight:700;color:#e8c040;'; + const close = document.createElement('button'); + close.textContent = '×'; + close.title = 'Close (Shift+A)'; + close.setAttribute('aria-label', 'Close'); + close.style.cssText = 'border:none;background:transparent;color:#cfe0f5;font-size:17px;line-height:1;cursor:pointer;padding:0 2px;'; + close.addEventListener('click', () => _setAspectPanelVisible(false)); + hdr.appendChild(title); hdr.appendChild(close); wrap.appendChild(hdr); - // enabled + splitOnly checkboxes - [['enabled', 'Enabled (Shift+A)'], ['splitOnly', 'Split panes only']].forEach(([k, lbl]) => { + // Target selector — which pane the controls below edit. + const tgtRow = document.createElement('div'); tgtRow.style.cssText = 'margin:2px 0 7px;'; + const tgtLab = document.createElement('div'); + tgtLab.textContent = 'Target'; tgtLab.style.cssText = 'color:#9fb0c8;margin-bottom:2px;'; + _aspectTargetSel = document.createElement('select'); + _aspectTargetSel.style.cssText = 'width:100%;background:rgba(30,44,66,0.9);color:#cfe0f5;border:1px solid rgba(120,150,200,0.4);border-radius:4px;padding:3px;'; + _aspectTargetSel.addEventListener('change', () => { + _aspectEditTarget = _aspectTargetSel.value; _syncAspectPanel(); + }); + tgtRow.appendChild(tgtLab); tgtRow.appendChild(_aspectTargetSel); wrap.appendChild(tgtRow); + _aspectBuildTargets(); + + // enabled + splitOnly checkboxes (per-target) + [['enabled', 'Enabled'], ['splitOnly', 'Split panes only']].forEach(([k, lbl]) => { const row = document.createElement('label'); row.style.cssText = 'display:flex;align-items:center;gap:6px;margin:2px 0;cursor:pointer;'; const cb = document.createElement('input'); - cb.type = 'checkbox'; cb.checked = !!t[k]; cb.dataset.k = k; - cb.addEventListener('change', () => { - _aspectTune()[k] = cb.checked; _aspectPersist(); - if (k === 'enabled') _setAspectPanelVisible(cb.checked); - }); + cb.type = 'checkbox'; cb.checked = !!_aspectReadVal(k); cb.dataset.k = k; + cb.addEventListener('change', () => { _aspectWriteVal(k, cb.checked); }); const span = document.createElement('span'); span.textContent = lbl; row.appendChild(cb); row.appendChild(span); wrap.appendChild(row); }); - // numeric sliders + // numeric sliders (per-target) _ASPECT_FIELDS.forEach((f) => { const row = document.createElement('div'); row.style.cssText = 'margin:5px 0;'; @@ -1752,13 +1838,14 @@ head.appendChild(lab); head.appendChild(val); row.appendChild(head); const sl = document.createElement('input'); sl.type = 'range'; sl.min = f.min; sl.max = f.max; sl.step = f.step; - sl.value = Number.isFinite(t[f.k]) ? t[f.k] : _ASPECT_DEFAULTS[f.k]; + const rv = _aspectReadVal(f.k); + sl.value = Number.isFinite(rv) ? rv : _ASPECT_DEFAULTS[f.k]; sl.dataset.k = f.k; sl.style.cssText = 'width:100%;'; const show = () => { val.textContent = (+sl.value).toFixed(f.step < 1 ? 2 : 0); }; show(); sl.addEventListener('input', () => { - _aspectTune()[f.k] = parseFloat(sl.value); show(); _aspectPersist(); + _aspectWriteVal(f.k, parseFloat(sl.value)); show(); }); row.appendChild(sl); wrap.appendChild(row); }); @@ -1769,23 +1856,24 @@ const head = document.createElement('label'); head.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;'; const cb = document.createElement('input'); - cb.type = 'checkbox'; cb.checked = Number.isFinite(t.hfovDeg); + cb.type = 'checkbox'; cb.checked = Number.isFinite(_aspectReadVal('hfovDeg')); const lbl = document.createElement('span'); lbl.textContent = 'Override held hFOV°'; head.appendChild(cb); head.appendChild(lbl); row.appendChild(head); const sl = document.createElement('input'); sl.type = 'range'; sl.min = 40; sl.max = 160; sl.step = 1; - sl.value = Number.isFinite(t.hfovDeg) ? t.hfovDeg : 102; + const hv = _aspectReadVal('hfovDeg'); + sl.value = Number.isFinite(hv) ? hv : 102; sl.disabled = !cb.checked; sl.style.cssText = 'width:100%;'; cb.addEventListener('change', () => { sl.disabled = !cb.checked; - _aspectTune().hfovDeg = cb.checked ? parseFloat(sl.value) : null; - _aspectPersist(); + _aspectWriteVal('hfovDeg', cb.checked ? parseFloat(sl.value) : null); }); sl.addEventListener('input', () => { - if (cb.checked) { _aspectTune().hfovDeg = parseFloat(sl.value); _aspectPersist(); } + if (cb.checked) _aspectWriteVal('hfovDeg', parseFloat(sl.value)); }); row.appendChild(sl); wrap.appendChild(row); + _aspectHfovCb = cb; _aspectHfovSl = sl; } // live readout @@ -1804,17 +1892,25 @@ b.addEventListener('click', fn); return b; }; + // Reset: for "All" restores the shared defaults (enabled); for a pane + // clears that pane's override so it inherits the shared base again. btnRow.appendChild(mkBtn('Reset', () => { - const t2 = _aspectTune(); - Object.keys(_ASPECT_DEFAULTS).forEach((k) => { t2[k] = _ASPECT_DEFAULTS[k]; }); - t2.enabled = true; // keep panel up after reset + const base = _aspectTune(); + if (!_aspectEditTarget) { + Object.keys(_ASPECT_DEFAULTS).forEach((k) => { base[k] = _ASPECT_DEFAULTS[k]; }); + base.enabled = true; + } else if (base.__panels) { + delete base.__panels[_aspectEditTarget]; + } _aspectPersist(); _syncAspectPanel(); })); + // Copy: the resolved values for the current target, as JSON. btnRow.appendChild(mkBtn('Copy', () => { - const t2 = _aspectTune(), out = {}; - Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t2[k]; }); + const r = _aspectEditTarget ? _resolveTuneFor(_aspectEditTarget) : _aspectTune(); + const out = {}; + Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = r[k]; }); const json = JSON.stringify(out, null, 2); - try { console.log('[h3d] wide-pane framing values:\n' + json); } catch (e) {} + try { console.log('[h3d] wide-pane framing values (' + (_aspectEditTarget || 'all') + '):\n' + json); } catch (e) {} try { if (navigator.clipboard) navigator.clipboard.writeText(json); } catch (e) {} })); wrap.appendChild(btnRow); @@ -1824,19 +1920,25 @@ _aspectPanelEl.style.display = 'none'; } - // Push current bridge values back into the panel controls (after Reset or an - // external edit). Cheap; only runs on demand. + // Push the current target's values back into the panel controls (after Reset, + // a target switch, or an external edit). Cheap; only runs on demand. function _syncAspectPanel() { if (!_aspectPanelEl) return; - const t = _aspectTune(); + _aspectBuildTargets(); _aspectPanelEl.querySelectorAll('input[type=checkbox][data-k]').forEach((cb) => { - cb.checked = !!t[cb.dataset.k]; + cb.checked = !!_aspectReadVal(cb.dataset.k); }); _aspectPanelEl.querySelectorAll('input[type=range][data-k]').forEach((sl) => { - const k = sl.dataset.k; - if (Number.isFinite(t[k])) sl.value = t[k]; + const v = _aspectReadVal(sl.dataset.k); + if (Number.isFinite(v)) sl.value = v; sl.dispatchEvent(new Event('input')); // refresh the value label }); + if (_aspectHfovCb) { + const hv = _aspectReadVal('hfovDeg'); + _aspectHfovCb.checked = Number.isFinite(hv); + _aspectHfovSl.disabled = !_aspectHfovCb.checked; + if (Number.isFinite(hv)) _aspectHfovSl.value = hv; + } } function _setAspectPanelVisible(on) { @@ -1844,19 +1946,32 @@ if (!_aspectPanelEl) return; _aspectPanelEl.style.display = on ? 'block' : 'none'; window.__h3dAspectPanelOpen = !!on; // gates the per-frame readout publish + if (on) { _aspectBuildTargets(); } if (on && !_aspectPanelRAF) { const tick = () => { if (!window.__h3dAspectPanelOpen) { _aspectPanelRAF = 0; return; } + if (_aspectPanesDirty) _aspectBuildTargets(); const ro = window.__h3dAspectReadout; - if (_aspectPanelRO && ro && Number.isFinite(ro.aspect)) { - _aspectPanelRO.textContent = - 'aspect ' + ro.aspect.toFixed(2) + ' · vFOV ' + ro.vfov.toFixed(1) + '°'; + if (_aspectPanelRO && ro) { + const key = _aspectEditTarget || ro.__last; + const e = key && ro[key]; + if (e && Number.isFinite(e.aspect)) { + _aspectPanelRO.textContent = + 'aspect ' + e.aspect.toFixed(2) + ' · vFOV ' + e.vfov.toFixed(1) + '°'; + } } _aspectPanelRAF = requestAnimationFrame(tick); }; _aspectPanelRAF = requestAnimationFrame(tick); } } + // Toggle the panel open/closed (the Shift+A dismiss/reveal). + function _toggleAspectPanel() { + _ensureAspectPanel(); + const open = !(_aspectPanelEl && _aspectPanelEl.style.display !== 'none'); + _setAspectPanelVisible(open); + if (open) _syncAspectPanel(); + } /* ====================================================================== * Background animations (issue #13) @@ -14222,14 +14337,18 @@ // ── Horizontal-FOV-hold + optional wide-pane pose nudges ── // Driven by window.__h3dAspectTune (default off → exact no-op). - // _aspectTune() returns the live bridge object, seeded from defaults - // + localStorage on first read so a persisted tuning session applies - // on load without opening the panel. Every field is finite-coerced. - // When disabled (or splitOnly and not in a split) the tune is treated - // as null, so effectiveVfov returns the base vertical fov and cam.fov - // is restored to it. The fov write is guarded on an actual change so - // a steady pane costs nothing. - const _aspTune = _aspectTune(); + // _resolveTuneFor(paneKey) returns the shared base with THIS pane's + // overrides (if any) laid on top, so a single split pane can be framed + // independently. The base is seeded from defaults + localStorage on + // first read, so a persisted tuning session applies on load without + // opening the panel. Every field is finite-coerced. When disabled (or + // splitOnly and not in a split) the tune is treated as null, so + // effectiveVfov returns the base vertical fov and cam.fov is restored + // to it. The fov write is guarded on an actual change so a steady pane + // costs nothing. + const _paneKey = _bgPanelKey(highwayCanvas); + _aspectRegisterPane(_paneKey, bundle && bundle.songInfo && bundle.songInfo.arrangement); + const _aspTune = _resolveTuneFor(_paneKey); const _aspActive = !!(_aspTune && _aspTune.enabled && !(_aspTune.splitOnly && !_ssActive())); const _tune = _aspActive ? _aspTune : null; @@ -14238,12 +14357,14 @@ cam.fov = _vfov; cam.updateProjectionMatrix(); } - // Publish a live readout for the tuner panel (only while it's open, - // so the steady path stays allocation-free). Last pane to render wins - // the slot — fine, all panes share the same aspect in a split layout. + // Publish a per-pane live readout for the tuner panel (only while it's + // open, so the steady path stays allocation-free). Keyed by pane so + // the panel can show the reading for whichever target is selected. if (window.__h3dAspectPanelOpen) { const _ro = window.__h3dAspectReadout || (window.__h3dAspectReadout = {}); - _ro.aspect = _paneAspect; _ro.vfov = _vfov; + const _slot = _ro[_paneKey] || (_ro[_paneKey] = {}); + _slot.aspect = _paneAspect; _slot.vfov = _vfov; + _ro.__last = _paneKey; } // Optional pose nudges (height / dolly / pitch) to chase a low-flat // wide-pane look if fov alone isn't enough. Gated to wide panes and diff --git a/tests/js/highway_3d_wide_fov.test.js b/tests/js/highway_3d_wide_fov.test.js index 936d933..2ddbce9 100644 --- a/tests/js/highway_3d_wide_fov.test.js +++ b/tests/js/highway_3d_wide_fov.test.js @@ -120,11 +120,11 @@ test('applySize caches the pane aspect for camUpdate', () => { ); }); -test('camUpdate reads the live tune bridge and respects splitOnly', () => { +test('camUpdate resolves a per-pane tune and respects splitOnly', () => { assert.match( src, - /const\s+_aspTune\s*=\s*_aspectTune\(\)\s*;[\s\S]*?_aspTune\.splitOnly\s*&&\s*!_ssActive\(\)/, - 'camUpdate must read the bridge via _aspectTune() and gate splitOnly on _ssActive()', + /const\s+_aspTune\s*=\s*_resolveTuneFor\(\s*_paneKey\s*\)\s*;[\s\S]*?_aspTune\.splitOnly\s*&&\s*!_ssActive\(\)/, + 'camUpdate must resolve the tune per pane via _resolveTuneFor(_paneKey) and gate splitOnly', ); }); @@ -136,11 +136,50 @@ test('the tune bridge seeds from localStorage (persisted sessions apply on load) ); }); -test('a floating tuner panel is built and toggled with the A/B state', () => { +test('a floating tuner panel is built and can be shown/hidden', () => { assert.match(src, /function\s+_ensureAspectPanel\s*\(\)/, '_ensureAspectPanel() must exist to build the live panel'); assert.match(src, /function\s+_setAspectPanelVisible\s*\(/, - '_setAspectPanelVisible() must show/hide the panel with the feature'); + '_setAspectPanelVisible() must show/hide the panel'); +}); + +// ── Per-pane targeting ──────────────────────────────────────────────────────── + +test('the tune resolves per pane with a sparse override map', () => { + // _resolveTuneFor overlays a pane's __panels[key] overrides onto the base so + // one split pane can be framed independently of the others. + assert.match( + src, + /function\s+_resolveTuneFor\s*\(\s*paneKey\s*\)[\s\S]*?base\.__panels\s*&&\s*base\.__panels\[\s*paneKey\s*\]/, + '_resolveTuneFor must overlay per-pane overrides from base.__panels', + ); +}); + +test('panel writes route to the selected target (base or a pane override)', () => { + // _aspectWriteVal writes to the base when target is empty, else into the + // pane override sub-object; camUpdate consumes it via _resolveTuneFor. + assert.match( + src, + /function\s+_aspectWriteVal\s*\([\s\S]*?if\s*\(\s*!_aspectEditTarget\s*\)[\s\S]*?base\.__panels\b[\s\S]*?\[\s*_aspectEditTarget\s*\]/, + '_aspectWriteVal must target base for "all" and __panels[target] for a pane', + ); +}); + +test('a Target select and pane registry drive the per-pane picker', () => { + assert.match(src, /_aspectTargetSel\s*=\s*document\.createElement\(\s*'select'\s*\)/, + 'the panel must build a Target every frame (flicker) and listing wrong/duplicate entries. The registry also never dropped panes from a prior song or a closed split. - Key each pane by a stable per-renderer-instance id (_paneUid, assigned once in init) instead of the split panel index. - Prune panes not reported within ~1.5s (song change / split teardown). - Mark the dropdown dirty only when the pane SET changes, not on every per-frame re-report, and skip rebuilding while the + let _aspectTgtRow = null; // the Target row (hidden when only one pane) let _aspectHfovCb = null; // hfov-override checkbox (synced explicitly) let _aspectHfovSl = null; // hfov-override slider // Which pane the panel edits. '' = all panes (writes the shared base object); - // a pane key (e.g. 'panel0') writes that pane's sparse override, so one split + // a pane key (e.g. 'pane1') writes that pane's sparse override, so one split // pane can be framed independently of the others. let _aspectEditTarget = ''; - // Set when a renderer reports a pane key/label we haven't seen, so the panel - // rebuilds the Target dropdown on its next readout tick. + // Bumped when the SET of live panes changes (add/prune) so the panel rebuilds + // the Target dropdown — never on a per-frame label re-report, which would + // flicker the . + function _aspectRegisterPane(paneKey, uid, arrangement) { const reg = window.__h3dAspectPanes || (window.__h3dAspectPanes = {}); - let label; - if (paneKey === 'main') { label = 'Main'; } - else { const n = parseInt(paneKey.slice(5), 10); label = 'Panel ' + ((isFinite(n) ? n : 0) + 1); } - if (arrangement) label += ' — ' + arrangement; - if (!reg[paneKey] || reg[paneKey].label !== label) { - reg[paneKey] = { label }; - _aspectPanesDirty = true; - } + const label = _aspectPaneLabel(arrangement, uid); + let e = reg[paneKey]; + if (!e) { e = reg[paneKey] = { label, seen: 0 }; _aspectPanesDirty = true; } + else if (e.label !== label) { e.label = label; _aspectPanesDirty = true; } + e.seen = _aspectNowMs(); + } + // Drop panes not reported recently (song change, split teardown, pane close). + function _aspectPrunePanes() { + const reg = window.__h3dAspectPanes; + if (!reg) return; + const now = _aspectNowMs(); + Object.keys(reg).forEach((k) => { + if (now - (reg[k].seen || 0) > 1500) { delete reg[k]; _aspectPanesDirty = true; } + }); } // Read/write against the current edit target ('' → base, else pane override). @@ -1759,6 +1781,9 @@ // current selection when it's still valid. function _aspectBuildTargets() { if (!_aspectTargetSel) return; + // Don't yank a dropdown the user is actively interacting with — leave it + // dirty and rebuild on a later tick once it's no longer focused. + if (document.activeElement === _aspectTargetSel) return; const reg = window.__h3dAspectPanes || {}; const keys = Object.keys(reg).sort(); _aspectTargetSel.innerHTML = ''; @@ -1772,6 +1797,9 @@ }); if (_aspectEditTarget && !reg[_aspectEditTarget]) _aspectEditTarget = ''; _aspectTargetSel.value = _aspectEditTarget; + // The Target row only matters with more than one pane (a split). With a + // single pane there's nothing to disambiguate, so hide it. + if (_aspectTgtRow) _aspectTgtRow.style.display = keys.length > 1 ? '' : 'none'; _aspectPanesDirty = false; } @@ -1805,6 +1833,7 @@ // Target selector — which pane the controls below edit. const tgtRow = document.createElement('div'); tgtRow.style.cssText = 'margin:2px 0 7px;'; + _aspectTgtRow = tgtRow; const tgtLab = document.createElement('div'); tgtLab.textContent = 'Target'; tgtLab.style.cssText = 'color:#9fb0c8;margin-bottom:2px;'; _aspectTargetSel = document.createElement('select'); @@ -1950,6 +1979,7 @@ if (on && !_aspectPanelRAF) { const tick = () => { if (!window.__h3dAspectPanelOpen) { _aspectPanelRAF = 0; return; } + _aspectPrunePanes(); if (_aspectPanesDirty) _aspectBuildTargets(); const ro = window.__h3dAspectReadout; if (_aspectPanelRO && ro) { @@ -3885,6 +3915,12 @@ // __h3dAspectTune edits) without waiting for a resize. 0 until first // applySize(). let _paneAspect = 0; + // Stable per-instance id for the wide-pane tuner's Target picker. Each + // renderer instance is exactly one pane, so keying overrides + the + // readout by this (rather than the split plugin's panel index, which can + // ping-pong with focus) keeps the picker steady and unambiguous. Assigned + // once in init(); survives destroy()/init() reuse of the same instance. + let _paneUid = 0; // True once applySize() has pinned the .h3d-wrap overlay to the // highway canvas's offset box. Stays false while the canvas has no // layout yet (init() can run before #highway has a real box, where @@ -14346,8 +14382,8 @@ // effectiveVfov returns the base vertical fov and cam.fov is restored // to it. The fov write is guarded on an actual change so a steady pane // costs nothing. - const _paneKey = _bgPanelKey(highwayCanvas); - _aspectRegisterPane(_paneKey, bundle && bundle.songInfo && bundle.songInfo.arrangement); + const _paneKey = 'pane' + _paneUid; + _aspectRegisterPane(_paneKey, _paneUid, bundle && bundle.songInfo && bundle.songInfo.arrangement); const _aspTune = _resolveTuneFor(_paneKey); const _aspActive = !!(_aspTune && _aspTune.enabled && !(_aspTune.splitOnly && !_ssActive())); @@ -14799,7 +14835,8 @@ } _destroyed = _isReady = false; _isFocused = true; - _registerAspectAbShortcut(); // session-global A/B toggle (self-guarded) + if (!_paneUid) _paneUid = ++_aspectPaneCounter; // stable pane id for the tuner picker + _registerAspectAbShortcut(); // session-global tuner shortcut (self-guarded) const myToken = ++_initToken; highwayCanvas = canvas; _invertedCached = !!(bundle && bundle.inverted); diff --git a/tests/js/highway_3d_wide_fov.test.js b/tests/js/highway_3d_wide_fov.test.js index 2ddbce9..068adc5 100644 --- a/tests/js/highway_3d_wide_fov.test.js +++ b/tests/js/highway_3d_wide_fov.test.js @@ -174,6 +174,24 @@ test('a Target select and pane registry drive the per-pane picker', () => { 'camUpdate must register its pane each frame'); }); +test('panes are keyed by a stable per-instance id, not the split panel index', () => { + // Keying by a stable instance uid keeps the Target picker steady; the split + // plugin's panel index can ping-pong with focus and cause flicker/dupes. + assert.match(src, /_paneUid\s*=\s*\+\+\s*_aspectPaneCounter/, + 'each renderer instance must take a stable pane uid in init()'); + assert.match(src, /const\s+_paneKey\s*=\s*'pane'\s*\+\s*_paneUid\s*;/, + 'camUpdate must key the pane by its stable instance uid'); +}); + +test('the target dropdown prunes dead panes and does not rebuild while focused', () => { + assert.match(src, /function\s+_aspectPrunePanes\s*\(\)[\s\S]*?delete\s+reg\[k\]/, + '_aspectPrunePanes must drop panes not seen recently'); + assert.match(src, /_aspectPrunePanes\(\)\s*;[\s\S]*?if\s*\(\s*_aspectPanesDirty\s*\)\s*_aspectBuildTargets\(\)/, + 'the readout tick must prune then rebuild only when dirty'); + assert.match(src, /function\s+_aspectBuildTargets\s*\(\)[\s\S]*?document\.activeElement\s*===\s*_aspectTargetSel[\s\S]*?return/, + '_aspectBuildTargets must skip rebuilding while the select is focused'); +}); + test('the panel has a dismiss (close) control', () => { assert.match( src, From 491039a12dac701f0aa99faf0fb41e3e57bf65bf Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Wed, 1 Jul 2026 01:05:56 -0500 Subject: [PATCH 90/99] feat(core): host per-instrument workingTuning capability + read-API/event (working-tuning PR 1) (#658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): host per-instrument workingTuning capability + read-API/event (working-tuning PR 1) Introduce window.feedBack.workingTuning — the live, host-authoritative current instrument tuning (offsets + string-count + reference pitch + assumed/verified provenance), distinct from any one song's tuning and from a soft opt-in default. It's the single source of truth the highway, library, and plugins (tuner, Virtuoso, minigames) will read so a retune or instrument swap is reflected app-wide instead of being re-derived per surface. PER-INSTRUMENT: state is a map keyed by `${instrument}-${stringCount}` (e.g. guitar-6 / bass-4, the selector's key) — your guitar's tuning and your bass's are kept separately; get() returns the selected instrument's, and switching the selector surfaces that instrument's own remembered tuning. You only ever deal with the one you've picked. Modeled on the shipped `tuning` capability + the `feedBack.theme` read-API: synchronous get(instrument?), set(state,{provenance,instrument}) mutator, setCurrentInstrument(), resetToDefault(), and a `working-tuning-changed` event that fires on change and once on hydration (carrying which instrument changed). In-memory, seeded from /api/settings, reset-on-restart. Registered as a separate `working-tuning` exclusive-owner capability (tuner = sole writer, others read). Foundation only — pure plumbing, nothing writes to it yet and no behavior changes. The tuner becomes the writer (and the gate's E->C# asymmetry is fixed) in a later PR. Frontend-only: new static/capabilities/working-tuning.js, loaded from static/index.html + static/v3/index.html. Per-instrument state machine verified by a stubbed node harness (separate guitar/bass slots, selector switch, isolated writes, verified stamp, reset, defensive copies, capability registration). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * fix(working-tuning): resolve review P1/P2s + add behavioral test harness Addresses the manual + Codex review of PR 1 (working-tuning foundation). P1 — named tunings were dropped by the boot seed: /api/settings.tuning may be a name ("Drop D") OR an offsets list, but the seed only handled the list and stored offsets:null for names. The seed now resolves a name to per-string semitone offsets via /api/tunings (ratio vs Standard; reference pitch cancels). P1 — async seed could clobber state a consumer had already written: _seedFromSettings resolves after boot and used to overwrite _currentKey/_byInstrument unconditionally. It now bails when state was already _touched (and re-checks after the /api/tunings leg), so an explicit set()/setCurrentInstrument()/resetToDefault() before hydration wins. Hydration still fires. P1/P2 — shallow copy leaked live nested arrays: get() and set() now clone offsets and verifiedStrings on both ingress and egress, honouring the "readers can't mutate live state" contract. P2 — provenance/verification state machine made coherent by construction: verified <=> verifiedStrings is an array AND verifiedAt is a finite number. A tuning change invalidates prior verification unless a fresh bundle is supplied; a "verified" claim with no strings or a null/absent timestamp is repaired (assumed / stamped now). P2 — bare-instrument writes targeted a hard-coded default string count: _keyOfResolved() resolves an omitted string count against the current selection (same instrument), so set({instrument:'bass'}) / set({stringCount:5}) hit the selected bass-5, not bass-4. Test — adds tests/js/working_tuning.test.js (the harness the PR described but did not commit): 11 behavioral cases over a stubbed window — registration, per-instrument isolation + selector switch, defensive copies, the verification invariant, bare-key routing, named + offsets-list seeding, and the boot-race guard. Full tests/js suite: no new failures (the 12 pre-existing branch failures are unrelated). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: byrongamatos --- CHANGELOG.md | 1 + static/capabilities/working-tuning.js | 315 ++++++++++++++++++++++++++ static/index.html | 1 + static/v3/index.html | 1 + tests/js/working_tuning.test.js | 206 +++++++++++++++++ 5 files changed, 524 insertions(+) create mode 100644 static/capabilities/working-tuning.js create mode 100644 tests/js/working_tuning.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f40a4c2..c3d0f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Host "working tuning" — a live, app-wide record of what tuning your instrument is *actually* in right now (foundation; no behavior change yet).** Introduces `window.feedBack.workingTuning`, a host-owned, session-lived state distinct from any one song's tuning and from a soft opt-in default: the offsets + string-count + reference pitch the player's instrument is currently in, plus an `assumed`/`verified` provenance flag. It's **per-instrument** — your guitar's current tuning and your bass's are kept *separately* (keyed like the instrument selector, e.g. `guitar-6` / `bass-4`), so switching instruments surfaces that instrument's own remembered tuning and you only ever deal with the one you've selected. It exists so a retune — or an instrument swap mid-session — is reflected **everywhere** (the highway, the library/song-picker, and plugins like the tuner, Virtuoso, and the minigames) instead of being re-derived per surface or wrongly assumed from a fixed profile. Modeled on the shipped `tuning` capability + the `feedBack.theme` read-API: a **synchronous `get(instrument?)`** (returns the selected instrument's state, defaulting to the seed until known), a `set(state, {provenance, instrument})` mutator (the tuner becomes the sole writer in the next change), `setCurrentInstrument()` for the selector, `resetToDefault()`, and a `working-tuning-changed` event that fires on every change **and once on hydration** (carrying which instrument changed) so a late-mounting consumer is never stuck on stale state. State is **in-memory, seeded from `/api/settings` on boot and reset on restart** — a stale "you're in drop-A" assumption is worse than re-asking. Registered as a separate `working-tuning` **exclusive-owner** capability (tuner = writer, the rest = requesters). This is the foundation (plumbing only — nothing writes to it yet) of the working-tuning series, which fixes the tuner gate only ever prompting *away from* a fixed "home" tuning (never back) and makes the current tuning a first-class signal the whole app shares. Offsets use the same per-string semitone vocabulary as song tunings, so fully custom/extended tunings (e.g. a drop-A 8-string) are first-class. Frontend-only: new `static/capabilities/working-tuning.js`, loaded from `static/index.html` + `static/v3/index.html`. - **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **A–Z rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`. - **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback). - **Smart collections — save a set of library filters as a live, auto-updating source.** A collection is a saved `/api/library` query (e.g. "Drop-D tunings", "sloppak only", "recently added") that stays live: it's registered as a **library provider**, so it shows up in the v3 Songs source picker and inherits the whole grid UI — paging, stats, the A–Z rail, art — for free, with **no new screen**. Storage reuses the playlist subsystem (a `playlists.rules` JSON blob = a smart collection; membership is the live filter result, not stored songs, and collections are excluded from the manual-playlist list + read-only to playlist mutations). New `GET`/`POST`/`PUT`/`DELETE /api/collections`; a per-collection `SmartCollectionProvider` delegates `query_page`/`query_stats`/`query_artists` to the local DB with the stored rules applied; providers are re-registered from a boot scan so collections survive a restart. Rules mirror the raw `/api/library` query params (unknown keys dropped, never 500). Frontend: a "+ Save as collection" action in the v3 filter drawer (shown when filters are active) names the current filter set and switches to it. The charrette's "the homelab primitive FeedBack was missing" pick (got-feedback/feedBack#636 item 2); richer rule fields (accuracy, genre, difficulty) follow as the metadata work lands. Tests: `tests/test_collections_api.py`, `tests/js/v3_collections.test.js`. diff --git a/static/capabilities/working-tuning.js b/static/capabilities/working-tuning.js new file mode 100644 index 0000000..f2cac9c --- /dev/null +++ b/static/capabilities/working-tuning.js @@ -0,0 +1,315 @@ +// Core "working tuning" capability domain — the live, host-authoritative CURRENT +// instrument tuning (session state), distinct from the soft opt-in default and from +// any one song's tuning. This is the single source of truth the whole app reads: +// the highway, the library/song-picker, Virtuoso, and the minigames all consult it, +// and the tuner is the sole WRITER (it updates this when the player retunes, clears +// the gate, or switches instruments). +// +// PER-INSTRUMENT: a player has separate physical instruments, each in its OWN tuning +// ("I'm not tuning two instruments when I pick a song"). So state is a MAP keyed by +// instrument — `${instrument}-${stringCount}` (e.g. "guitar-6", "bass-4"), the same key +// the v3 instrument selector uses. `get()` returns the CURRENTLY-SELECTED instrument's +// tuning; switching the selector surfaces that instrument's own remembered tuning. You +// only ever deal with the one you've picked. +// +// Design: WORKING-TUNING-STATE-DESIGN.md (host-first PR series, PR 1 = this file). +// Pattern mirrors `capabilities/tuning.js` (capability registration) + the host theme +// read-API (`window.feedBack.theme`): a synchronous `get()` plus a `working-tuning- +// changed` event that also fires once on hydration. +// +// State is IN-MEMORY and NOT persisted — reset-to-home on restart is deliberate (a +// stale "you're in drop-A" assumption is worse than re-asking). The opt-in "default +// tuning on app open" lands later; for now we seed the selected instrument from +// /api/settings. +// +// PR 1 is PURE PLUMBING: it introduces the state + read/write surface + event, but +// nothing writes to it yet and no behavior changes. The tuner becomes the writer (and +// the gate's E->C# asymmetry is fixed) in a later PR. +(function () { + 'use strict'; + + window.feedBack = window.feedBack || {}; + const capabilities = window.feedBack.capabilities; + + const _byInstrument = {}; // key -> tuning state (the per-instrument map) + let _currentKey = null; // the selected instrument's key; cached so get() is sync + let _hydrated = false; + let _touched = false; // set once anything explicitly writes/selects; gates the async seed + + function _normInstrument(instrument) { + return instrument === 'bass' ? 'bass' : 'guitar'; + } + function _keyOf(instrument, stringCount) { + const inst = _normInstrument(instrument); + const sc = Number(stringCount) || (inst === 'bass' ? 4 : 6); + return inst + '-' + sc; + } + // Like _keyOf, but when the caller omits a string count we resolve it against the + // current selection (if it's the same instrument) before falling back to the + // per-instrument default — so `set({instrument:'bass'})` targets the selected + // bass-5, not a hard-coded bass-4. + function _keyOfResolved(instrument, stringCount) { + const inst = _normInstrument(instrument); + let sc = Number(stringCount); + if (!sc) { + if (_currentKey) { + const cur = _splitKey(_currentKey); + if (cur.instrument === inst) sc = cur.stringCount; + } + if (!sc) sc = (inst === 'bass' ? 4 : 6); + } + return inst + '-' + sc; + } + function _splitKey(key) { + const parts = (typeof key === 'string' ? key : '').split('-'); + const inst = parts[0] === 'bass' ? 'bass' : 'guitar'; + return { instrument: inst, stringCount: Number(parts[1]) || (inst === 'bass' ? 4 : 6) }; + } + + // The shape every consumer reads. `offsets` are per-string semitone offsets from + // standard (same vocabulary as song_info.tuning and /api/tunings); `instrument` + // disambiguates the open-string base so offsets resolve to real pitches. A drop-A + // 8-string is just an offsets array — fully custom tunings are first-class. + // `provenance` is the honesty flag: 'verified' means the tuner did a choreographed + // per-string mic check this session; everything else is 'assumed'. + function _defaultState(key) { + const id = _splitKey(key); + return { + offsets: null, + stringCount: id.stringCount, + instrument: id.instrument, + referencePitch: 440, + provenance: 'assumed', + verifiedStrings: null, + verifiedAt: null, + source: 'default', + }; + } + + // Resolve which instrument key a get/set targets: an explicit arg wins (a string + // key "guitar-6", a bare "guitar"/"bass", or { instrument, stringCount }); else the + // cached current selection. + function _resolveKey(instrument) { + if (instrument && typeof instrument === 'object') return _keyOfResolved(instrument.instrument, instrument.stringCount); + if (typeof instrument === 'string' && instrument) { + return instrument.indexOf('-') > 0 ? instrument : _keyOfResolved(instrument, null); + } + return _currentKey || _keyOf('guitar', 6); + } + + // Synchronous read of an instrument's current tuning (default = selected + // instrument). Returns a deep-enough copy — the object plus its mutable array + // fields (`offsets`, `verifiedStrings`) — so a reader can't mutate the live state. + function get(instrument) { + const key = _resolveKey(instrument); + const state = Object.assign(_defaultState(key), _byInstrument[key] || {}); + if (Array.isArray(state.offsets)) state.offsets = state.offsets.slice(); + if (Array.isArray(state.verifiedStrings)) state.verifiedStrings = state.verifiedStrings.slice(); + return state; + } + + function _emitChanged(key) { + if (window.feedBack && typeof window.feedBack.emit === 'function') { + window.feedBack.emit('working-tuning-changed', { key: key, instrument: _splitKey(key).instrument, tuning: get(key) }); + } + } + + // The single mutator. The tuner calls this on retune / gate-clear / swap. Writes to + // the instrument the state targets (opts.instrument, or next.instrument+stringCount, + // or the current selection) and makes that the active instrument. `opts.provenance` + // stamps 'verified' (mic-confirmed) vs the default 'assumed'. Changing the tuning + // invalidates a prior verification unless fresh verifiedStrings are supplied — fail + // toward "assumed". + function set(next, opts) { + opts = opts || {}; + next = next || {}; + // Resolve the target key. An explicit opts.instrument wins; otherwise a + // next.instrument/next.stringCount targets that slot — but a bare stringCount + // (no instrument) applies to the CURRENTLY-SELECTED instrument, not a hard-coded + // guitar, so `set({stringCount:5})` on a selected bass writes bass-5. + let key; + if (opts.instrument) { + key = _resolveKey(opts.instrument); + } else if (next.instrument || next.stringCount) { + const inst = next.instrument ? _normInstrument(next.instrument) + : (_currentKey ? _splitKey(_currentKey).instrument : 'guitar'); + key = _keyOfResolved(inst, next.stringCount); + } else { + key = _currentKey || _resolveKey(); + } + const id = _splitKey(key); + const merged = Object.assign(get(key), next); // get() gives copies, so `merged` is ours to mutate + merged.instrument = id.instrument; // keep coherent with the key + merged.stringCount = id.stringCount; // the key is authoritative for string count + const tuningChanged = ('offsets' in next) || ('stringCount' in next) || ('referencePitch' in next); + + // Provenance: explicit opts wins; a bare tuning change downgrades to 'assumed'. + if (opts.provenance) { + merged.provenance = opts.provenance; + } else if (tuningChanged) { + merged.provenance = 'assumed'; + } + + // Verification metadata is coherent by construction: a tuning change invalidates + // prior per-string verification unless the caller supplies a fresh bundle, and the + // metadata exists ONLY while provenance === 'verified'. So verified <=> we hold + // verifiedStrings — a "verified with no strings" state is impossible. + if (!('verifiedStrings' in next) && tuningChanged) { + merged.verifiedStrings = null; + } + if (merged.provenance === 'verified' && !Array.isArray(merged.verifiedStrings)) { + merged.provenance = 'assumed'; // claimed verified but no evidence — fail toward assumed + } + if (merged.provenance === 'verified') { + // verified always carries a real timestamp — a caller-supplied null/NaN/absent + // verifiedAt is stamped now, so 'verified' can never mean "at no known time". + if (typeof merged.verifiedAt !== 'number' || !isFinite(merged.verifiedAt)) { + merged.verifiedAt = Date.now(); + } + } else { + merged.verifiedStrings = null; + merged.verifiedAt = null; + } + + // Store copies of the mutable arrays so a caller can't mutate live state post-set. + if (Array.isArray(merged.offsets)) merged.offsets = merged.offsets.slice(); + if (Array.isArray(merged.verifiedStrings)) merged.verifiedStrings = merged.verifiedStrings.slice(); + _byInstrument[key] = merged; + _currentKey = key; // writing a tuning makes that instrument the active one + _touched = true; // an explicit write must not be clobbered by the async seed + _emitChanged(key); + return get(key); + } + + // Tell the host which instrument is now selected (the v3 selector calls this when + // the player switches guitar<->bass / string count) so get() returns the right + // instrument's tuning. Emits if the selection actually changed. + function setCurrentInstrument(instrument, stringCount) { + const key = (typeof instrument === 'string' && instrument.indexOf('-') > 0) ? instrument : _keyOfResolved(instrument, stringCount); + _touched = true; // an explicit selection must not be reverted by the async seed + if (key === _currentKey) return get(key); + _currentKey = key; + _emitChanged(key); + return get(key); + } + + // Reset an instrument's live tuning back to its baseline (the home/default). + function resetToDefault(instrument) { + const key = _resolveKey(instrument); + _byInstrument[key] = _defaultState(key); + _touched = true; + _emitChanged(key); + return get(key); + } + + // Per-string semitone offsets of a named tuning relative to Standard, derived from + // the /api/tunings frequency tables. The reference pitch cancels in the ratio, so + // this is pitch-independent. Returns null if either row is missing/mismatched. + function _offsetsFromFreqs(named, standard) { + if (!Array.isArray(named) || !Array.isArray(standard) || named.length !== standard.length) return null; + const out = []; + for (let i = 0; i < named.length; i++) { + const a = Number(named[i]); + const b = Number(standard[i]); + if (!(a > 0) || !(b > 0)) return null; + out.push(Math.round(12 * Math.log2(a / b))); + } + return out; + } + + // Seed the SELECTED instrument's slot from settings on boot (best-effort 'assumed' + // starting point, NOT a persisted working tuning). settings.tuning may be an offsets + // list OR a name ("Drop D") — a name is resolved to offsets via /api/tunings so a + // named tuning isn't lost. If settings can't be read we still hydrate so consumers + // aren't stuck waiting; an explicit set()/select before we resolve wins (no clobber). + function _seedFromSettings() { + fetch('/api/settings') + .then(function (r) { return r && r.ok ? r.json() : null; }) + .then(function (s) { + if (!s || _touched) return; // nothing to seed, or a consumer already wrote — don't clobber + const inst = _normInstrument(s.instrument); + const sc = Number(s.string_count) || (inst === 'bass' ? 4 : 6); + const key = _keyOf(inst, sc); + + function commit(offsets) { + if (_touched) return; // re-check: a write may have raced the /api/tunings fetch + _currentKey = key; + _byInstrument[key] = { + offsets: Array.isArray(offsets) ? offsets.slice(0, sc) : null, + stringCount: sc, + instrument: inst, + referencePitch: Number(s.reference_pitch) || 440, + provenance: 'assumed', + verifiedStrings: null, + verifiedAt: null, + source: 'settings', + }; + } + + if (Array.isArray(s.tuning)) { commit(s.tuning); return; } + if (typeof s.tuning === 'string' && s.tuning) { + return fetch('/api/tunings') + .then(function (r) { return r && r.ok ? r.json() : null; }) + .then(function (t) { + const byName = t && t[key]; + commit(byName ? _offsetsFromFreqs(byName[s.tuning], byName.Standard) : null); + }) + .catch(function () { commit(null); }); + } + commit(null); + }) + .catch(function () { /* keep defaults */ }) + .then(function () { _hydrate(); }); + } + + function _hydrate() { + if (_hydrated) return; + _hydrated = true; + _emitChanged(_currentKey || _resolveKey()); + } + + // ---- Capability registration (mirrors capabilities/tuning.js) ---------------- + if (capabilities && capabilities.version === 1 && + !(window.feedBack.workingTuning && window.feedBack.workingTuning.version === 1)) { + capabilities.registerOwner('working-tuning', { + description: 'The live, host-authoritative current instrument tuning (session state), per ' + + 'instrument: offsets + string-count + reference pitch + assumed/verified provenance. ' + + 'Written by the tuner, read by the highway/library/Virtuoso/minigames.', + operations: ['get-working-tuning', 'set-working-tuning'], + events: ['working-tuning-changed'], + kind: 'command', + ownership: 'exclusive-owner', + }); + capabilities.registerParticipant('plugin.tuner', { + 'working-tuning': { + roles: ['contributor', 'requester'], + operations: ['get-working-tuning', 'set-working-tuning'], + emits: ['working-tuning-changed'], + mode: 'active', + compatibility: 'none', + safety: 'safe', + }, + }); + capabilities.registerParticipant('core.settings.instruments', { + 'working-tuning': { + roles: ['requester'], + operations: ['get-working-tuning'], + events: ['working-tuning-changed'], + mode: 'active', + compatibility: 'none', + safety: 'safe', + }, + }); + } + + // ---- Public read/write surface (attached defensively, like feedBack.theme) ---- + window.feedBack.workingTuning = Object.freeze({ + version: 1, + get: get, + set: set, + setCurrentInstrument: setCurrentInstrument, + resetToDefault: resetToDefault, + }); + + _seedFromSettings(); +})(); diff --git a/static/index.html b/static/index.html index e49cc8a..9ee5353 100644 --- a/static/index.html +++ b/static/index.html @@ -24,6 +24,7 @@ + diff --git a/static/v3/index.html b/static/v3/index.html index 768d9cd..ca3cd88 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -87,6 +87,7 @@ + diff --git a/tests/js/working_tuning.test.js b/tests/js/working_tuning.test.js new file mode 100644 index 0000000..c31bcc9 --- /dev/null +++ b/tests/js/working_tuning.test.js @@ -0,0 +1,206 @@ +// Behavioral harness for the host `window.feedBack.workingTuning` capability +// (static/capabilities/working-tuning.js) — the per-instrument, in-memory current +// tuning. Runs the real capability in a stubbed window (same strategy as +// midi_input_domain.test.js) with a controllable fetch, and asserts the per-instrument +// state machine: isolated guitar/bass slots, selector switch, defensive copies, the +// provenance/verification invariant, unambiguous key routing, named-tuning seeding, and +// the boot-race guard. + +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 { createWindow, ROOT } = require('./capabilities_test_harness'); + +const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js'); +const WORKING_TUNING_JS = path.join(ROOT, 'static', 'capabilities', 'working-tuning.js'); + +// A /api/tunings-shaped fixture (frequencies at 440), enough to resolve names to offsets. +const TUNINGS = { + 'guitar-6': { + Standard: [82.41, 110.00, 146.83, 196.00, 246.94, 329.63], + 'Drop D': [73.42, 110.00, 146.83, 196.00, 246.94, 329.63], + }, + 'bass-5': { + Standard: [30.87, 41.20, 55.00, 73.42, 98.00], + }, +}; + +function deferred() { + let resolve; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +// `routes` maps a URL to: a plain JSON value (served as {ok:true}), a thenable that +// resolves to a full response object (for deferral/races), or nothing (served {ok:false}). +function loadWorkingTuning(routes = {}) { + const window = createWindow(); + const changes = []; + window.fetch = function (url) { + const entry = routes[url]; + if (entry && typeof entry.then === 'function') return entry; + if (entry !== undefined) return Promise.resolve({ ok: true, json: () => Promise.resolve(entry) }); + return Promise.resolve({ ok: false, json: () => Promise.resolve(null) }); + }; + const context = vm.createContext(window); + vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS }); + vm.runInContext(fs.readFileSync(WORKING_TUNING_JS, 'utf8'), context, { filename: WORKING_TUNING_JS }); + // capabilities.js replaces window.feedBack with an EventTarget bus — subscribe on it, + // not on window. Attaching after load still catches the async hydration event. + window.feedBack.on('working-tuning-changed', (ev) => changes.push(ev.detail)); + return { window, wt: window.feedBack.workingTuning, changes }; +} + +// Rebase a possibly-vm-realm array into this realm so deepStrictEqual compares by value, +// not by (cross-realm) Array.prototype identity. +const nums = (a) => (a == null ? a : Array.from(a)); + +// Drain the seed's fetch/promise chain (settings -> tunings -> hydrate). +const flush = async () => { for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r)); }; + +test('registers a working-tuning exclusive-owner capability + versioned surface', () => { + const { window, wt } = loadWorkingTuning(); + assert.equal(wt.version, 1); + const pipeline = window.feedBack.capabilities.inspect('working-tuning'); + assert.ok(pipeline, 'working-tuning pipeline exists'); + const owner = (pipeline.participants || []).find((p) => p.pluginId === 'core.working-tuning'); + assert.ok(owner, 'core.working-tuning owner registered'); + for (const op of ['get-working-tuning', 'set-working-tuning']) { + assert.ok(owner.operations.includes(op), `owner exposes ${op}`); + } +}); + +test('get() defaults to a synchronous guitar-6 assumed seed before hydration', () => { + const { wt } = loadWorkingTuning(); + const s = wt.get(); + assert.equal(s.instrument, 'guitar'); + assert.equal(s.stringCount, 6); + assert.equal(s.provenance, 'assumed'); + assert.equal(s.offsets, null); +}); + +test('per-instrument slots are isolated; the selector surfaces the right one', async () => { + const { wt } = loadWorkingTuning(); + await flush(); + wt.set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' }); + wt.set({ offsets: [0, 0, 0, 0] }, { instrument: 'bass-4' }); + assert.deepEqual(nums(wt.get('guitar-6').offsets), [-2, 0, 0, 0, 0, 0]); + assert.deepEqual(nums(wt.get('bass-4').offsets), [0, 0, 0, 0]); + // Selecting an instrument makes get() (no arg) return that instrument's own state. + wt.setCurrentInstrument('guitar', 6); + assert.deepEqual(nums(wt.get().offsets), [-2, 0, 0, 0, 0, 0]); + wt.setCurrentInstrument('bass', 4); + assert.deepEqual(nums(wt.get().offsets), [0, 0, 0, 0]); +}); + +test('defensive copies: readers and post-set callers cannot mutate live state', async () => { + const { wt } = loadWorkingTuning(); + await flush(); + const input = [-2, -2, -2, -2, -2, -2]; + wt.set({ offsets: input }, { instrument: 'guitar-6' }); + input[0] = 99; // mutate caller's array after set() + assert.deepEqual(nums(wt.get('guitar-6').offsets), [-2, -2, -2, -2, -2, -2], 'set() stored a copy'); + const read = wt.get('guitar-6'); + read.offsets[0] = 99; // mutate a returned copy + assert.deepEqual(nums(wt.get('guitar-6').offsets), [-2, -2, -2, -2, -2, -2], 'get() returned a copy'); +}); + +test('verification invariant: verified <=> we hold verifiedStrings', async () => { + const { wt } = loadWorkingTuning(); + await flush(); + // A complete verified bundle stamps verified + a timestamp. + let s = wt.set({ offsets: [0, 0, 0, 0, 0, 0], verifiedStrings: [1, 1, 1, 1, 1, 1] }, + { instrument: 'guitar-6', provenance: 'verified' }); + assert.equal(s.provenance, 'verified'); + assert.deepEqual(nums(s.verifiedStrings), [1, 1, 1, 1, 1, 1]); + assert.equal(typeof s.verifiedAt, 'number'); + + // Claiming verified on a tuning change WITHOUT fresh strings is impossible — it + // fails toward assumed and drops the metadata (no "verified with null strings"). + s = wt.set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6', provenance: 'verified' }); + assert.equal(s.provenance, 'assumed'); + assert.equal(s.verifiedStrings, null); + assert.equal(s.verifiedAt, null); + + // verified always carries a real timestamp — an explicit verifiedAt:null is stamped now. + s = wt.set({ verifiedStrings: [1, 1, 1, 1, 1, 1], verifiedAt: null }, + { instrument: 'guitar-6', provenance: 'verified' }); + assert.equal(s.provenance, 'verified'); + assert.equal(typeof s.verifiedAt, 'number'); +}); + +test('a tuning change invalidates a prior verification', async () => { + const { wt } = loadWorkingTuning(); + await flush(); + wt.set({ offsets: [0, 0, 0, 0, 0, 0], verifiedStrings: [1, 1, 1, 1, 1, 1] }, + { instrument: 'guitar-6', provenance: 'verified' }); + const s = wt.set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' }); + assert.equal(s.provenance, 'assumed'); + assert.equal(s.verifiedStrings, null); + assert.equal(s.verifiedAt, null); +}); + +test('bare-instrument writes target the current selection, not a hard-coded default', async () => { + const { wt } = loadWorkingTuning(); + await flush(); + wt.setCurrentInstrument('bass', 5); // a 5-string bass is selected + // A bare instrument string must write bass-5, not bass-4. + wt.set({ offsets: [0, 0, 0, 0, 0] }, { instrument: 'bass' }); + assert.deepEqual(nums(wt.get('bass-5').offsets), [0, 0, 0, 0, 0]); + assert.equal(wt.get('bass-4').offsets, null, 'bass-4 slot untouched'); + // A bare stringCount (no instrument) applies to the selected instrument. + const s = wt.set({ stringCount: 5, offsets: [-1, -1, -1, -1, -1] }); + assert.equal(s.instrument, 'bass'); + assert.equal(s.stringCount, 5); +}); + +test('seed resolves a NAMED tuning to offsets via /api/tunings', async () => { + const { wt, changes } = loadWorkingTuning({ + '/api/settings': { instrument: 'guitar', string_count: 6, tuning: 'Drop D', reference_pitch: 440 }, + '/api/tunings': TUNINGS, + }); + await flush(); + const s = wt.get('guitar-6'); + assert.deepEqual(nums(s.offsets), [-2, 0, 0, 0, 0, 0], 'Drop D resolved to a -2 low string'); + assert.equal(s.source, 'settings'); + assert.equal(s.provenance, 'assumed'); + // Hydration emitted once, carrying the seeded instrument. + const hydrations = changes.filter((c) => c.instrument === 'guitar'); + assert.ok(hydrations.length >= 1, 'a working-tuning-changed fired for the seeded instrument'); +}); + +test('seed accepts an offsets-list tuning directly', async () => { + const { wt } = loadWorkingTuning({ + '/api/settings': { instrument: 'bass', string_count: 4, tuning: [-2, 0, 0, 0] }, + }); + await flush(); + assert.deepEqual(nums(wt.get('bass-4').offsets), [-2, 0, 0, 0]); +}); + +test('boot race: an explicit set() before settings resolve is not clobbered by the seed', async () => { + const settings = deferred(); + const { wt } = loadWorkingTuning({ + '/api/settings': settings.promise, // held open + '/api/tunings': TUNINGS, + }); + // A consumer writes before the seed lands. + wt.set({ offsets: [-5, -5, -5, -5, -5, -5] }, { instrument: 'guitar-6' }); + // Now the seed resolves with a DIFFERENT tuning. + settings.resolve({ ok: true, json: () => Promise.resolve({ instrument: 'guitar', string_count: 6, tuning: 'Drop D' }) }); + await flush(); + assert.deepEqual(nums(wt.get('guitar-6').offsets), [-5, -5, -5, -5, -5, -5], 'explicit write survived the seed'); +}); + +test('resetToDefault clears a slot back to its baseline and emits', async () => { + const { wt, changes } = loadWorkingTuning(); + await flush(); + wt.set({ offsets: [-2, -2, -2, -2, -2, -2] }, { instrument: 'guitar-6', provenance: 'verified', verifiedStrings: [1, 1, 1, 1, 1, 1] }); + const before = changes.length; + const s = wt.resetToDefault('guitar-6'); + assert.equal(s.offsets, null); + assert.equal(s.provenance, 'assumed'); + assert.equal(s.verifiedStrings, null); + assert.ok(changes.length > before, 'reset emitted working-tuning-changed'); +}); From 64b95d6f342f128f60fb9c7dfa505e6483a5b13e Mon Sep 17 00:00:00 2001 From: topkoa Date: Wed, 1 Jul 2026 02:08:27 -0400 Subject: [PATCH 91/99] Persist per-pane framing across songs via durable slot keys Per-pane overrides were keyed by an ephemeral per-instance id, so leaving a song and opening another rebuilt the renderer with a new id and the pane's framing was lost. Key overrides by the durable split slot again ('main' | 'panel', via _bgPanelKey) so the same slot means the same pane across songs, and persist __panels to localStorage. Keep the anti-flicker fixes that were the actual cause of the earlier dropdown churn (prune stale panes, rebuild only on a pane-set change, never rebuild while the select is focused). The slot key is latched to the last real slot so a transient null from panelIndexFor during a song/layout transition can't flip it to 'main' and drop the override for a frame; it resets in destroy() for instance reuse in another slot. Co-Authored-By: Claude Opus 4.8 Signed-off-by: topkoa --- plugins/highway_3d/screen.js | 43 ++++++++++++++++------------ tests/js/highway_3d_wide_fov.test.js | 23 ++++++++++----- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index cad20cc..1d73803 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1692,14 +1692,16 @@ // the Target dropdown — never on a per-frame label re-report, which would // flicker the . - function _aspectRegisterPane(paneKey, uid, arrangement) { + function _aspectRegisterPane(paneKey, arrangement) { const reg = window.__h3dAspectPanes || (window.__h3dAspectPanes = {}); - const label = _aspectPaneLabel(arrangement, uid); + const label = _aspectPaneLabel(paneKey, arrangement); let e = reg[paneKey]; if (!e) { e = reg[paneKey] = { label, seen: 0 }; _aspectPanesDirty = true; } else if (e.label !== label) { e.label = label; _aspectPanesDirty = true; } @@ -3915,12 +3918,14 @@ // __h3dAspectTune edits) without waiting for a resize. 0 until first // applySize(). let _paneAspect = 0; - // Stable per-instance id for the wide-pane tuner's Target picker. Each - // renderer instance is exactly one pane, so keying overrides + the - // readout by this (rather than the split plugin's panel index, which can - // ping-pong with focus) keeps the picker steady and unambiguous. Assigned - // once in init(); survives destroy()/init() reuse of the same instance. - let _paneUid = 0; + // Latched split-slot key for the wide-pane tuner ('main' | 'panel'). + // Keyed by the durable split slot (via _bgPanelKey) so a pane's overrides + // persist across songs — the same slot means the same pane to the user. + // Latched to the last real slot so a transient null from panelIndexFor + // during a song/layout transition doesn't momentarily flip it to 'main' + // and drop the override for a frame. Reset in destroy() for instance + // reuse in a different slot. + let _paneKeyCached = ''; // True once applySize() has pinned the .h3d-wrap overlay to the // highway canvas's offset box. Stays false while the canvas has no // layout yet (init() can run before #highway has a real box, where @@ -14382,8 +14387,10 @@ // effectiveVfov returns the base vertical fov and cam.fov is restored // to it. The fov write is guarded on an actual change so a steady pane // costs nothing. - const _paneKey = 'pane' + _paneUid; - _aspectRegisterPane(_paneKey, _paneUid, bundle && bundle.songInfo && bundle.songInfo.arrangement); + const _pk0 = _bgPanelKey(highwayCanvas); + if (_pk0 !== 'main') _paneKeyCached = _pk0; // latch the real slot; ignore transient nulls + const _paneKey = _paneKeyCached || _pk0; + _aspectRegisterPane(_paneKey, bundle && bundle.songInfo && bundle.songInfo.arrangement); const _aspTune = _resolveTuneFor(_paneKey); const _aspActive = !!(_aspTune && _aspTune.enabled && !(_aspTune.splitOnly && !_ssActive())); @@ -14835,7 +14842,6 @@ } _destroyed = _isReady = false; _isFocused = true; - if (!_paneUid) _paneUid = ++_aspectPaneCounter; // stable pane id for the tuner picker _registerAspectAbShortcut(); // session-global tuner shortcut (self-guarded) const myToken = ++_initToken; highwayCanvas = canvas; @@ -15256,6 +15262,7 @@ _lastHwW = 0; _lastHwH = 0; _appliedW = 0; _appliedH = 0; _paneAspect = 0; + _paneKeyCached = ''; if (cam && cam.fov !== BASE_VFOV) { cam.fov = BASE_VFOV; cam.updateProjectionMatrix(); } _wrapPinned = false; _unsubscribeFocus(); teardown(); diff --git a/tests/js/highway_3d_wide_fov.test.js b/tests/js/highway_3d_wide_fov.test.js index 068adc5..060d522 100644 --- a/tests/js/highway_3d_wide_fov.test.js +++ b/tests/js/highway_3d_wide_fov.test.js @@ -174,13 +174,22 @@ test('a Target select and pane registry drive the per-pane picker', () => { 'camUpdate must register its pane each frame'); }); -test('panes are keyed by a stable per-instance id, not the split panel index', () => { - // Keying by a stable instance uid keeps the Target picker steady; the split - // plugin's panel index can ping-pong with focus and cause flicker/dupes. - assert.match(src, /_paneUid\s*=\s*\+\+\s*_aspectPaneCounter/, - 'each renderer instance must take a stable pane uid in init()'); - assert.match(src, /const\s+_paneKey\s*=\s*'pane'\s*\+\s*_paneUid\s*;/, - 'camUpdate must key the pane by its stable instance uid'); +test('panes are keyed by the durable split slot and the key is latched', () => { + // Slot keys ('main' | 'panel') persist across songs, so a pane's + // overrides carry over. The key is latched to the last real slot so a + // transient null from panelIndexFor doesn't flip it to 'main' for a frame. + assert.match(src, /const\s+_pk0\s*=\s*_bgPanelKey\(\s*highwayCanvas\s*\)\s*;/, + 'camUpdate must derive the slot key from _bgPanelKey(highwayCanvas)'); + assert.match(src, /if\s*\(\s*_pk0\s*!==\s*'main'\s*\)\s*_paneKeyCached\s*=\s*_pk0\s*;[\s\S]*?const\s+_paneKey\s*=\s*_paneKeyCached\s*\|\|\s*_pk0\s*;/, + 'camUpdate must latch the last real slot key'); +}); + +test('per-pane overrides persist to localStorage (carry across songs)', () => { + assert.match( + src, + /function\s+_aspectPersist\s*\(\)[\s\S]*?if\s*\(\s*t\.__panels\s*\)\s*out\.__panels\s*=\s*t\.__panels/, + '_aspectPersist must include __panels so per-slot overrides survive a reload / song change', + ); }); test('the target dropdown prunes dead panes and does not rebuild while focused', () => { From 5ef163e9f97c0932148231070e74a190bdafb686 Mon Sep 17 00:00:00 2001 From: topkoa Date: Wed, 1 Jul 2026 02:15:17 -0400 Subject: [PATCH 92/99] Key wide-pane overrides by arrangement, not the split panel index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Target picker disappeared in split because it keyed panes off the external splitscreen panel index (panelIndexFor), which isn't always available — both panes then collapsed to a single 'main' key and the one-pane row-hide kicked in. Key panes by arrangement name instead ('arr:Bass'): distinct between split panes AND stable across songs, with no dependency on the split plugin. A per-instance id ('pane:N') is the fallback when a pane has no arrangement. Only arr:* overrides persist to localStorage (instance-id fallback keys are session-only, so they can't leak a new key each reload). This also gives nicer semantics — a pane's framing follows its arrangement into the next song. Co-Authored-By: Claude Opus 4.8 Signed-off-by: topkoa --- plugins/highway_3d/screen.js | 69 ++++++++++++++++------------ tests/js/highway_3d_wide_fov.test.js | 34 ++++++++------ 2 files changed, 60 insertions(+), 43 deletions(-) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 1d73803..203a43a 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1692,16 +1692,25 @@ // the Target dropdown — never on a per-frame label re-report, which would // flicker the . - function _aspectRegisterPane(paneKey, arrangement) { + // by each renderer with its pane key. `seen` is refreshed each call for + // pruning; the dropdown is only marked dirty when a pane is newly added — not + // on every re-report, which would flicker the '); assert.match(src, /function\s+_aspectRegisterPane\s*\(/, '_aspectRegisterPane must record live panes for the picker'); - assert.match(src, /_aspectRegisterPane\(\s*_paneKey\s*,/, + assert.match(src, /_aspectRegisterPane\(\s*_paneKey\s*\)/, 'camUpdate must register its pane each frame'); }); -test('panes are keyed by the durable split slot and the key is latched', () => { - // Slot keys ('main' | 'panel') persist across songs, so a pane's - // overrides carry over. The key is latched to the last real slot so a - // transient null from panelIndexFor doesn't flip it to 'main' for a frame. - assert.match(src, /const\s+_pk0\s*=\s*_bgPanelKey\(\s*highwayCanvas\s*\)\s*;/, - 'camUpdate must derive the slot key from _bgPanelKey(highwayCanvas)'); - assert.match(src, /if\s*\(\s*_pk0\s*!==\s*'main'\s*\)\s*_paneKeyCached\s*=\s*_pk0\s*;[\s\S]*?const\s+_paneKey\s*=\s*_paneKeyCached\s*\|\|\s*_pk0\s*;/, - 'camUpdate must latch the last real slot key'); -}); - -test('per-pane overrides persist to localStorage (carry across songs)', () => { +test('panes are keyed by arrangement (stable across songs, no split-API dep)', () => { + // 'arr:' keys are distinct between split panes AND stable across + // songs, without depending on the external splitscreen panel index (which + // isn't always available). A per-instance id is the no-arrangement fallback. assert.match( src, - /function\s+_aspectPersist\s*\(\)[\s\S]*?if\s*\(\s*t\.__panels\s*\)\s*out\.__panels\s*=\s*t\.__panels/, - '_aspectPersist must include __panels so per-slot overrides survive a reload / song change', + /function\s+_aspectPaneKey\s*\(\s*arrangement\s*,\s*uid\s*\)[\s\S]*?'arr:'\s*\+\s*a[\s\S]*?'pane:'\s*\+\s*uid/, + '_aspectPaneKey must prefer arr: and fall back to pane:', + ); + assert.match( + src, + /const\s+_paneKey\s*=\s*_aspectPaneKey\(\s*[\s\S]*?songInfo[\s\S]*?arrangement\s*,\s*_paneUid\s*\)\s*;/, + 'camUpdate must key the pane by arrangement (with the uid fallback)', + ); +}); + +test('arrangement-keyed overrides persist; instance-id keys stay session-only', () => { + assert.match( + src, + /function\s+_aspectPersist\s*\(\)[\s\S]*?k\.slice\(0,\s*4\)\s*===\s*'arr:'[\s\S]*?out\.__panels\s*=\s*p/, + '_aspectPersist must persist only arr:* overrides so they carry across songs', ); }); From 9f914770c6e5d06adaaa310e17b774c8fa28c195 Mon Sep 17 00:00:00 2001 From: topkoa Date: Wed, 1 Jul 2026 02:27:01 -0400 Subject: [PATCH 93/99] Address review: sparse overrides, hfov clear, readout prune Three fixes from PR review of the per-pane tuner: - Sync no longer writes back. _syncAspectPanel dispatches synthetic input events to refresh slider labels; guard those with _aspectSyncing so the slider handler skips the write. Previously opening/switching a target populated a full override for every field (defeating sparse inherit) and spammed localStorage. - Unchecking "Override held hFOV" on a pane target now clears the override key (via _aspectClearVal) so the pane re-inherits the base value, instead of pinning hfovDeg:null in the override. On the base target it still sets the explicit auto (null). - _aspectPrunePanes now prunes the matching __h3dAspectReadout slot and drops a dangling __last, so the readout cache can't grow unbounded as songs and arrangements churn. Co-Authored-By: Claude Opus 4.8 Signed-off-by: topkoa --- plugins/highway_3d/screen.js | 69 +++++++++++++++++++++------- tests/js/highway_3d_wide_fov.test.js | 28 +++++++++++ 2 files changed, 80 insertions(+), 17 deletions(-) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 203a43a..c73e3e2 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1773,11 +1773,23 @@ const reg = window.__h3dAspectPanes; if (!reg) return; const now = _aspectNowMs(); + const ro = window.__h3dAspectReadout; Object.keys(reg).forEach((k) => { - if (now - (reg[k].seen || 0) > 1500) { delete reg[k]; _aspectPanesDirty = true; } + if (now - (reg[k].seen || 0) > 1500) { + delete reg[k]; + // Prune the matching readout slot so it can't grow unbounded as + // songs/arrangements churn, and drop a dangling __last pointer. + if (ro) { delete ro[k]; if (ro.__last === k) delete ro.__last; } + _aspectPanesDirty = true; + } }); } + // True while _syncAspectPanel is programmatically refreshing controls, so the + // synthetic 'input' events it dispatches to update labels don't write back + // into the tune (which would populate a full override for every field and + // spam localStorage). Real user input runs with this false. + let _aspectSyncing = false; // Read/write against the current edit target ('' → base, else pane override). function _aspectReadVal(k) { const base = _aspectTune(); @@ -1794,6 +1806,18 @@ } _aspectPersist(); } + // Clear a field: for the base target set the explicit auto value (null); for a + // pane target delete the override key so the pane re-inherits the base value + // (and drop the pane's override object once it's empty). + function _aspectClearVal(k) { + const base = _aspectTune(); + if (!_aspectEditTarget) { base[k] = null; } + else { + const m = base.__panels, ov = m && m[_aspectEditTarget]; + if (ov) { delete ov[k]; if (!Object.keys(ov).length) delete m[_aspectEditTarget]; } + } + _aspectPersist(); + } // (Re)build the Target dropdown from the live pane registry, preserving the // current selection when it's still valid. @@ -1892,7 +1916,8 @@ const show = () => { val.textContent = (+sl.value).toFixed(f.step < 1 ? 2 : 0); }; show(); sl.addEventListener('input', () => { - _aspectWriteVal(f.k, parseFloat(sl.value)); show(); + show(); // label always refreshes + if (!_aspectSyncing) _aspectWriteVal(f.k, parseFloat(sl.value)); }); row.appendChild(sl); wrap.appendChild(row); }); @@ -1913,11 +1938,13 @@ sl.disabled = !cb.checked; sl.style.cssText = 'width:100%;'; cb.addEventListener('change', () => { + if (_aspectSyncing) return; sl.disabled = !cb.checked; - _aspectWriteVal('hfovDeg', cb.checked ? parseFloat(sl.value) : null); + if (cb.checked) _aspectWriteVal('hfovDeg', parseFloat(sl.value)); + else _aspectClearVal('hfovDeg'); // base → auto (null); pane → re-inherit base }); sl.addEventListener('input', () => { - if (cb.checked) _aspectWriteVal('hfovDeg', parseFloat(sl.value)); + if (!_aspectSyncing && cb.checked) _aspectWriteVal('hfovDeg', parseFloat(sl.value)); }); row.appendChild(sl); wrap.appendChild(row); _aspectHfovCb = cb; _aspectHfovSl = sl; @@ -1972,19 +1999,27 @@ function _syncAspectPanel() { if (!_aspectPanelEl) return; _aspectBuildTargets(); - _aspectPanelEl.querySelectorAll('input[type=checkbox][data-k]').forEach((cb) => { - cb.checked = !!_aspectReadVal(cb.dataset.k); - }); - _aspectPanelEl.querySelectorAll('input[type=range][data-k]').forEach((sl) => { - const v = _aspectReadVal(sl.dataset.k); - if (Number.isFinite(v)) sl.value = v; - sl.dispatchEvent(new Event('input')); // refresh the value label - }); - if (_aspectHfovCb) { - const hv = _aspectReadVal('hfovDeg'); - _aspectHfovCb.checked = Number.isFinite(hv); - _aspectHfovSl.disabled = !_aspectHfovCb.checked; - if (Number.isFinite(hv)) _aspectHfovSl.value = hv; + // Guard so the synthetic 'input' events below only refresh labels and + // don't write the read-back values into the target (which would turn a + // sparse pane override into a full one and spam localStorage). + _aspectSyncing = true; + try { + _aspectPanelEl.querySelectorAll('input[type=checkbox][data-k]').forEach((cb) => { + cb.checked = !!_aspectReadVal(cb.dataset.k); + }); + _aspectPanelEl.querySelectorAll('input[type=range][data-k]').forEach((sl) => { + const v = _aspectReadVal(sl.dataset.k); + if (Number.isFinite(v)) sl.value = v; + sl.dispatchEvent(new Event('input')); // refresh the value label only + }); + if (_aspectHfovCb) { + const hv = _aspectReadVal('hfovDeg'); + _aspectHfovCb.checked = Number.isFinite(hv); + _aspectHfovSl.disabled = !_aspectHfovCb.checked; + if (Number.isFinite(hv)) _aspectHfovSl.value = hv; + } + } finally { + _aspectSyncing = false; } } diff --git a/tests/js/highway_3d_wide_fov.test.js b/tests/js/highway_3d_wide_fov.test.js index 57e9001..abf80df 100644 --- a/tests/js/highway_3d_wide_fov.test.js +++ b/tests/js/highway_3d_wide_fov.test.js @@ -207,6 +207,34 @@ test('the target dropdown prunes dead panes and does not rebuild while focused', '_aspectBuildTargets must skip rebuilding while the select is focused'); }); +test('programmatic sync does not write back into the tune', () => { + // _syncAspectPanel dispatches synthetic input events to refresh labels; the + // slider handler must skip the write while syncing, else opening/switching a + // target would populate a full override for every field. + assert.match(src, /_aspectSyncing\s*=\s*true[\s\S]*?finally[\s\S]*?_aspectSyncing\s*=\s*false/, + '_syncAspectPanel must set/reset the _aspectSyncing guard'); + assert.match(src, /if\s*\(\s*!_aspectSyncing\s*\)\s*_aspectWriteVal\(\s*f\.k\s*,/, + 'the slider input handler must skip the write while syncing'); +}); + +test('unchecking hfov override clears a pane override key (re-inherits base)', () => { + assert.match( + src, + /function\s+_aspectClearVal\s*\(\s*k\s*\)[\s\S]*?delete\s+ov\[k\][\s\S]*?delete\s+m\[\s*_aspectEditTarget\s*\]/, + '_aspectClearVal must delete the pane override key (and empty object)', + ); + assert.match(src, /else\s+_aspectClearVal\(\s*'hfovDeg'\s*\)/, + 'unchecking the hfov override must call _aspectClearVal'); +}); + +test('pruning drops the matching readout slot and a dangling __last', () => { + assert.match( + src, + /delete\s+reg\[k\]\s*;[\s\S]*?delete\s+ro\[k\]\s*;\s*if\s*\(\s*ro\.__last\s*===\s*k\s*\)\s*delete\s+ro\.__last/, + '_aspectPrunePanes must prune the readout cache alongside the registry', + ); +}); + test('the panel has a dismiss (close) control', () => { assert.match( src, From 817db6382b2a9f5bd4c94eb86f59b9ba663b7eef Mon Sep 17 00:00:00 2001 From: topkoa Date: Wed, 1 Jul 2026 02:32:16 -0400 Subject: [PATCH 94/99] Address review: explicit button types + Target select label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set type="button" on the × close control and the Reset/Copy buttons so they can never act as submit if the panel is ever nested in a
. - Add aria-label="Target pane" to the Target '); assert.match(src, /function\s+_aspectRegisterPane\s*\(/, '_aspectRegisterPane must record live panes for the picker'); - assert.match(src, /_aspectRegisterPane\(\s*_paneKey\s*\)/, - 'camUpdate must register its pane each frame'); + assert.match(src, /if\s*\(\s*window\.__h3dAspectPanelOpen\s*\)\s*_aspectRegisterPane\(\s*_paneKey\s*\)/, + 'camUpdate must register its pane only while the tuner panel is open'); }); test('panes are keyed by arrangement (stable across songs, no split-API dep)', () => { From c4bb58233d8a675a618b78b08432f36415a1a51f Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Wed, 1 Jul 2026 01:58:50 -0500 Subject: [PATCH 98/99] feat(core): route the highway chart to the selected instrument's part (working-tuning PR 2) (#659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a song loads without an explicit arrangement, highway_ws now reads the player's selected `instrument` from config.json (the same file it already reads for the default-arrangement preference) and picks the arrangement that matches: bass -> the Bass part. Guitar — and any unknown/future instrument (drums, keys) — falls through to the existing preference/most-notes default, which already lands on a guitar part. Previously the instrument selector only fed the tuner, so a bass player was handed the default Lead/guitar chart, and the working-tuning coverage check then compared a 4-string bass against a 6-string part (always "can't cover"). This is the instrument->chart routing the working-tuning series leans on. Server-only (every launch path flows through the WS, so no client change). An explicit arrangement request always wins, so only the default part chosen on load changes. Tests: tests/test_highway_ws_instrument_routing.py (bass->Bass, guitar->default, explicit-wins) — 3 new, existing highway WS tests still green. Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 1 + server.py | 54 +++++- tests/test_highway_ws_instrument_routing.py | 191 ++++++++++++++++++++ 3 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 tests/test_highway_ws_instrument_routing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c3d0f0c..1323cf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **The highway now loads the part that matches your selected instrument — a bass player gets the Bass arrangement, not the default Lead/guitar chart.** When you open a song without an explicit arrangement, the WebSocket handler (`server.py` `highway_ws`) reads your selected `instrument` from `config.json` (the same file it already reads for your default-arrangement preference) and routes to the matching part: **bass → the Bass arrangement**; guitar — and any unknown/future instrument (drums, keys) — falls through to the existing preference/most-notes default, which already lands on a guitar part. Previously the instrument selector only fed the tuner, so a bass player was handed a guitar chart (and a tune/coverage check then compared a 4-string bass against a 6-string part). An **explicit arrangement request always wins** (a manual arrangement switch is untouched), and a bass player's saved default-arrangement preference is still honored **within** the bass parts (so a preferred `Bass 2` / `Alt. Bass` wins over the canonical Bass), so this only changes the *default* part chosen on load. Server-only — every launch path already flows through the WS, so there's no client change. This is the instrument↔chart-routing piece the working-tuning series leans on (otherwise coverage compares across instruments). Tests: `tests/test_highway_ws_instrument_routing.py` (bass→Bass, bass-honors-pref, bass-no-bass-part→guitar, guitar→default, explicit-wins). - **Host "working tuning" — a live, app-wide record of what tuning your instrument is *actually* in right now (foundation; no behavior change yet).** Introduces `window.feedBack.workingTuning`, a host-owned, session-lived state distinct from any one song's tuning and from a soft opt-in default: the offsets + string-count + reference pitch the player's instrument is currently in, plus an `assumed`/`verified` provenance flag. It's **per-instrument** — your guitar's current tuning and your bass's are kept *separately* (keyed like the instrument selector, e.g. `guitar-6` / `bass-4`), so switching instruments surfaces that instrument's own remembered tuning and you only ever deal with the one you've selected. It exists so a retune — or an instrument swap mid-session — is reflected **everywhere** (the highway, the library/song-picker, and plugins like the tuner, Virtuoso, and the minigames) instead of being re-derived per surface or wrongly assumed from a fixed profile. Modeled on the shipped `tuning` capability + the `feedBack.theme` read-API: a **synchronous `get(instrument?)`** (returns the selected instrument's state, defaulting to the seed until known), a `set(state, {provenance, instrument})` mutator (the tuner becomes the sole writer in the next change), `setCurrentInstrument()` for the selector, `resetToDefault()`, and a `working-tuning-changed` event that fires on every change **and once on hydration** (carrying which instrument changed) so a late-mounting consumer is never stuck on stale state. State is **in-memory, seeded from `/api/settings` on boot and reset on restart** — a stale "you're in drop-A" assumption is worse than re-asking. Registered as a separate `working-tuning` **exclusive-owner** capability (tuner = writer, the rest = requesters). This is the foundation (plumbing only — nothing writes to it yet) of the working-tuning series, which fixes the tuner gate only ever prompting *away from* a fixed "home" tuning (never back) and makes the current tuning a first-class signal the whole app shares. Offsets use the same per-string semitone vocabulary as song tunings, so fully custom/extended tunings (e.g. a drop-A 8-string) are first-class. Frontend-only: new `static/capabilities/working-tuning.js`, loaded from `static/index.html` + `static/v3/index.html`. - **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **A–Z rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`. - **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback). diff --git a/server.py b/server.py index cbb9f25..e619de9 100644 --- a/server.py +++ b/server.py @@ -7842,15 +7842,63 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, if 0 <= arrangement < len(song.arrangements): best = arrangement else: - # Check user's default arrangement preference + # Read the user's config once: their selected instrument (route the chart + # to the matching part) and their default-arrangement preference. pref = "" + sel_instrument = "" config_file = CONFIG_DIR / "config.json" if config_file.exists(): try: - pref = json.loads(config_file.read_text(encoding="utf-8")).get("default_arrangement", "") + _cfg = json.loads(config_file.read_text(encoding="utf-8")) + pref = _cfg.get("default_arrangement", "") + sel_instrument = (_cfg.get("instrument", "") or "") except Exception: pass - if pref: + # Instrument routing: load the part that matches the selected instrument so + # "your instrument" and "the chart you play" line up. The default ordering + # is Lead/guitar-first, so without this a bass player gets handed a guitar + # chart (and any tune-check then compares a 4-string bass against a 6-string + # part). Currently routes bass -> a Bass arrangement; guitar — and any + # unknown/future instrument (drums, keys) — falls through to the + # preference/most-notes logic below, which already lands on a guitar part. + # Drums/keys get their own match when those arrangement types + selector + # entries land. Only applies when no explicit arrangement was requested, so + # a manual arrangement switch is always respected. + if sel_instrument.lower() == "bass": + # Candidate bass parts, preferring the structured pathBass flag; the + # normalized smart name (itself pathBass-derived) and raw name are + # fallbacks for sources without the flag. + bass_idxs = [ + i + for i, a in enumerate(song.arrangements) + if getattr(a, "path_bass", False) + or (smart_names[i] or "").lower().startswith("bass") + or "bass" in (getattr(a, "name", "") or "").lower() + ] + if bass_idxs: + # Among the bass parts: (1) honor the saved default-arrangement + # preference if it names one of them (so a bass player who prefers + # "Bass 2"/"Alt. Bass" keeps it), (2) else the canonical main "Bass", + # (3) else the first bass part in order. + pref_bass = -1 + if pref: + for i in bass_idxs: + nm = (smart_names[i] if naming_mode == "smart" and i < len(smart_names) + else getattr(song.arrangements[i], "name", "")) + if nm == pref: + pref_bass = i + break + if pref_bass >= 0: + best = pref_bass + else: + best = next( + (i for i in bass_idxs + if (smart_names[i] if i < len(smart_names) else "") == "Bass"), + bass_idxs[0], + ) + # User's default arrangement preference (only when instrument routing did not + # already resolve a part — i.e. guitar, or a bass player with no bass part). + if best < 0 and pref: if naming_mode == "smart": best = _pick_smart_arrangement(song.arrangements, smart_names, pref) else: diff --git a/tests/test_highway_ws_instrument_routing.py b/tests/test_highway_ws_instrument_routing.py new file mode 100644 index 0000000..cf445f0 --- /dev/null +++ b/tests/test_highway_ws_instrument_routing.py @@ -0,0 +1,191 @@ +"""Tests for instrument->chart arrangement routing in the highway WS. + +When no explicit arrangement is requested, the WS picks the arrangement matching +the player's selected instrument (config.json `instrument`) so a bass player gets +the Bass part instead of the default Lead/guitar chart. An explicit arrangement +request always wins. +""" + +from __future__ import annotations + +import importlib +import json +import sys + +import pytest +import yaml +from fastapi.testclient import TestClient + + +def _arr(notes): + return { + "notes": notes, + "chords": [], + "anchors": [], + "handshapes": [], + "templates": [], + "beats": [{"time": 0.0, "measure": 1}], + "sections": [{"name": "intro", "number": 1, "time": 0.0}], + } + + +def _write_multi_arr_sloppak(dlc_root): + """A song with a Lead (guitar) and a Bass arrangement, Lead first (index 0).""" + pak = dlc_root / "multi.sloppak" + pak.mkdir() + (pak / "arrangements").mkdir() + (pak / "arrangements" / "lead.json").write_text(json.dumps(_arr([]))) + (pak / "arrangements" / "bass.json").write_text(json.dumps(_arr([]))) + manifest = { + "title": "Multi", + "artist": "Tester", + "album": "", + "year": 2026, + "duration": 10.0, + "arrangements": [ + {"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}, + {"id": "bass", "name": "Bass", "file": "arrangements/bass.json"}, + ], + "stems": [], + } + (pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False)) + return pak + + +def _write_sloppak(dlc_root, name, arrangements): + """Write a .sloppak whose arrangements are (id, display-name) pairs, in order.""" + pak = dlc_root / f"{name}.sloppak" + pak.mkdir() + (pak / "arrangements").mkdir() + manifest_arrs = [] + for arr_id, arr_name in arrangements: + (pak / "arrangements" / f"{arr_id}.json").write_text(json.dumps(_arr([]))) + manifest_arrs.append( + {"id": arr_id, "name": arr_name, "file": f"arrangements/{arr_id}.json"} + ) + manifest = { + "title": name, + "artist": "Tester", + "album": "", + "year": 2026, + "duration": 10.0, + "arrangements": manifest_arrs, + "stems": [], + } + (pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False)) + return pak + + +@pytest.fixture() +def make_client(tmp_path, monkeypatch): + def _make(instrument=None, default_arrangement=None): + cfg = tmp_path / "config" + cfg.mkdir(exist_ok=True) + conf = {} + if instrument is not None: + conf["instrument"] = instrument + if default_arrangement is not None: + conf["default_arrangement"] = default_arrangement + if conf: + (cfg / "config.json").write_text(json.dumps(conf), encoding="utf-8") + monkeypatch.setenv("CONFIG_DIR", str(cfg)) + monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc")) + monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1") + sys.modules.pop("server", None) + server = importlib.import_module("server") + monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None) + monkeypatch.setattr(server, "startup_scan", lambda: None) + monkeypatch.setattr(server, "SLOPPAK_CACHE_DIR", tmp_path / "cache") + return server + + (tmp_path / "dlc").mkdir() + yield _make + server = sys.modules.get("server") + conn = getattr(getattr(server, "meta_db", None), "conn", None) + if conn is not None: + conn.close() + + +def _arr_index(client, path): + with client.websocket_connect(path) as ws: + for _ in range(200): + msg = ws.receive_json() + if msg.get("error"): + raise AssertionError(f"WS error frame: {msg}") + if msg.get("type") == "song_info": + return msg["arrangement_index"] + if msg.get("type") == "ready": + break + raise AssertionError("no song_info frame received") + + +def test_bass_instrument_routes_to_bass_arrangement(make_client): + server = make_client(instrument="bass") + _write_multi_arr_sloppak(server._get_dlc_dir()) + with TestClient(server.app) as client: + # No explicit arrangement → route to Bass (index 1), not the default Lead. + idx = _arr_index(client, "/ws/highway/multi.sloppak?naming_mode=smart") + assert idx == 1 + + +def test_guitar_instrument_keeps_default(make_client): + server = make_client(instrument="guitar") + _write_multi_arr_sloppak(server._get_dlc_dir()) + with TestClient(server.app) as client: + idx = _arr_index(client, "/ws/highway/multi.sloppak?naming_mode=smart") + assert idx == 0 # guitar falls through to the default → Lead + + +def test_explicit_arrangement_overrides_instrument(make_client): + server = make_client(instrument="bass") + _write_multi_arr_sloppak(server._get_dlc_dir()) + with TestClient(server.app) as client: + # An explicit arrangement request wins even for a bass player. + idx = _arr_index(client, "/ws/highway/multi.sloppak?arrangement=0") + assert idx == 0 + + +def test_bass_with_no_bass_part_falls_through_to_guitar(make_client): + server = make_client(instrument="bass") + # Lead + Rhythm, no bass part at all. + _write_sloppak(server._get_dlc_dir(), "gtr", [("lead", "Lead"), ("rhythm", "Rhythm")]) + with TestClient(server.app) as client: + idx = _arr_index(client, "/ws/highway/gtr.sloppak") + assert idx == 0 # no bass candidate → existing default (a guitar part) + + +def test_bass_no_pref_picks_the_primary_bass_not_an_alt(make_client): + server = make_client(instrument="bass") + # Lead + two bass parts; the canonical "Bass" should win over "Bass 2". + _write_sloppak( + server._get_dlc_dir(), "bb", + [("lead", "Lead"), ("bass", "Bass"), ("bass2", "Bass 2")], + ) + with TestClient(server.app) as client: + idx = _arr_index(client, "/ws/highway/bb.sloppak") + assert idx == 1 # the primary Bass, not the first-in-order-if-it-were-an-alt + + +def test_bass_honors_saved_pref_within_the_bass_parts(make_client): + # A bass player who saved "Bass 2" keeps it — instrument routing must not clobber + # the preference with the primary Bass. + server = make_client(instrument="bass", default_arrangement="Bass 2") + _write_sloppak( + server._get_dlc_dir(), "bb", + [("lead", "Lead"), ("bass", "Bass"), ("bass2", "Bass 2")], + ) + with TestClient(server.app) as client: + idx = _arr_index(client, "/ws/highway/bb.sloppak") + assert idx == 2 # the preferred Bass 2, not the primary Bass (index 1) + + +def test_guitar_still_honors_saved_pref(make_client): + # Guitar routing unchanged: a saved default_arrangement still applies. + server = make_client(instrument="guitar", default_arrangement="Rhythm") + _write_sloppak( + server._get_dlc_dir(), "gtr2", + [("lead", "Lead"), ("rhythm", "Rhythm"), ("bass", "Bass")], + ) + with TestClient(server.app) as client: + idx = _arr_index(client, "/ws/highway/gtr2.sloppak") + assert idx == 1 # Rhythm, per preference From 095d718b8517ab18d5daf6f97e872b56c0cebe49 Mon Sep 17 00:00:00 2001 From: topkoa Date: Wed, 1 Jul 2026 02:59:51 -0400 Subject: [PATCH 99/99] Address review: Reset on All restores defaults verbatim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Reset handler forced base.enabled = true after copying _ASPECT_DEFAULTS (where enabled is false) — a leftover from when enabled controlled panel visibility. Visibility is now independent (Shift+A / ×), so drop the override and let Reset restore the defaults exactly. Co-Authored-By: Claude Opus 4.8 Signed-off-by: topkoa --- plugins/highway_3d/screen.js | 6 +++--- tests/js/highway_3d_wide_fov.test.js | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 53c6c08..8da18ef 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1987,13 +1987,13 @@ b.addEventListener('click', fn); return b; }; - // Reset: for "All" restores the shared defaults (enabled); for a pane - // clears that pane's override so it inherits the shared base again. + // Reset: for "All" restores the shared defaults exactly; for a pane + // clears that pane's override so it inherits the shared base again. Panel + // visibility is independent (Shift+A / ×), so Reset doesn't force it open. btnRow.appendChild(mkBtn('Reset', () => { const base = _aspectTune(); if (!_aspectEditTarget) { Object.keys(_ASPECT_DEFAULTS).forEach((k) => { base[k] = _ASPECT_DEFAULTS[k]; }); - base.enabled = true; } else if (base.__panels) { delete base.__panels[_aspectEditTarget]; } diff --git a/tests/js/highway_3d_wide_fov.test.js b/tests/js/highway_3d_wide_fov.test.js index 475dc9c..d568860 100644 --- a/tests/js/highway_3d_wide_fov.test.js +++ b/tests/js/highway_3d_wide_fov.test.js @@ -268,6 +268,13 @@ test('opening the panel prunes before the first dropdown build', () => { ); }); +test('Reset on All restores defaults exactly (no forced enabled)', () => { + // Panel visibility is independent of the enabled flag now, so Reset must not + // force enabled true — it should restore _ASPECT_DEFAULTS verbatim. + assert.doesNotMatch(src, /Object\.keys\(_ASPECT_DEFAULTS\)[\s\S]*?base\.enabled\s*=\s*true/, + 'Reset must not override the default enabled state'); +}); + test('the panel has a dismiss (close) control', () => { assert.match( src,