Compare commits

...
Author SHA1 Message Date
Byron GamatosandGitHub 62df3aec2e Merge branch 'main' into perf/highway-sustain-glow-no-shadowblur 2026-07-06 09:45:05 +02:00
612b1f2e0d feat(v3): choose handedness in the instrument selector + onboarding (give lefties a break) (#793)
ship-ci / ci (push) Waiting to run
* feat(v3): add a handedness (left-handed) choice to the instrument selector + onboarding

Left-handed players could already mirror the highway, but only via a buried
Settings toggle they had to find AFTER setup -- so a lefty went through the tour,
the tuner and calibration all right-handed first (community callout).

Add a "Handedness: Right / Left" row to the v3 instrument badge popover, alongside
Instrument / Strings / Tuning (all player-orientation choices). It writes the same
lefty preference -- highway.setLefty when a live highway exists (flips it
immediately + persists), else the 'lefty' localStorage key the highway reads on
init -- and keeps the Settings "Left-handed" checkbox in sync. The first-run
tour's "Choose your instrument" step, which runs before the tuner/audio-
calibration steps, now calls it out so lefties flip it up front.

Frontend-only, additive. Full core JS suite green (938). Tests:
tests/js/badges_handedness.test.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UR2Cr7GEu3yMY7SrfxH6c1

* docs: split the spliced Handedness/Colorblind CHANGELOG entries

A rebase pasted the Handedness bullet over the Colorblind preset entry's bold
lead, merging two unrelated Added entries into one run-on bullet. Restore them
as two separate bullets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-05 23:50:30 +02:00
ChrisBeWithYouandGitHub 4f6dc233f1 feat(player): seed editor region handoff state (#762)
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
2026-07-05 23:37:39 +02:00
ChrisBeWithYouandGitHub 5be70939e4 feat(v3 library): searchable Cover Art Archive picker in Change-cover (#783)
The cover picker only offered CAA covers from a song's MATCHED release, so an
unmatched song (the city-pop pile) got nothing but Current/Pack/Upload/URL. Add
a search box: GET /api/song/{fn}/art/cover-search?q= searches MusicBrainz
release-groups and returns each album's CAA front-250 thumb; the picker renders
them as pickable tiles (same apply→/art/url path; covers with no CAA art
self-hide). Pre-filled from the song's artist + album/title (romaji fallback), so
a blank-artist pack pre-fills "Junko Yagami …". Reuses the throttled _mb_http_get.
2026-07-05 23:36:49 +02:00
Jafz2001andClaude Fable 5 326001d618 perf(highway): draw the held-sustain glow without ctx.shadowBlur
shadowBlur cost scales with the blurred device-pixel area. The lit-sustain
trail can span half the canvas, the canvas is DPR-scaled (4x pixels on a
2x Mac), and the blur ran on every frame exactly while a sustain is HELD
— i.e. at the moment the player most notices a hitch. Sustain-heavy songs
(e.g. fingerpicked acoustic charts) hit this constantly.

Replace the blur with three inflated low-alpha fills of the same trail
quad: reads as the same soft shimmering glow (the shimmer LUT still
drives per-frame flicker, feedBack#254 intent preserved) at a flat,
area-independent cost. Also drops the now-dead shadowBlur reset in the
crackle pass; no shadowBlur uses remain in highway.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:00:49 -04:00
11 changed files with 282 additions and 25 deletions
+1
View File
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Auto-sync: DTW step constraint — riff-based songs no longer produce garbage sync points.** `librosa.sequence.dtw`'s default step pattern allows unbounded horizontal/vertical path runs, and on music with long self-similar chroma stretches (riff-driven stoner/doom, drone sections) the flat cost surface let the warping path collapse — minutes of score mapped onto a single audio frame, so the per-bar warp imported charts wildly out of sync while reporting success (observed on a real 138 BPM tab: effective displayed tempo 159 BPM, three sync points sharing one audio timestamp). `_dtw_align` now uses the standard music-sync slope-constrained step pattern (`[[1,1],[1,2],[2,1]]`, local tempo ratio bounded to 0.5x2x), which makes the degenerate path impossible, with a fallback to unconstrained steps when the global length ratio makes the constrained pattern infeasible (e.g. a tab aligned against a full-concert video). Validated on the failing song: coarse points track the recording 1:1, refined downbeats land on onset peaks at 3.3x background energy.
### Added
- **Handedness (left-handed) is now a first-class choice in the instrument selector — and surfaced during onboarding.** Left-handed players could already mirror the highway, but only via a buried Settings toggle they had to find *after* setup — so a lefty hit the tour, the tuner and calibration all right-handed first. The v3 instrument badge popover now has a **Handedness: Right / Left** row alongside Instrument / Strings / Tuning (all player-orientation choices), writing the same `lefty` preference (`highway.setLefty` when a live highway exists, else the `lefty` localStorage key it reads on init; the Settings checkbox stays in sync). The first-run tour's "Choose your instrument" step — which runs **before** the tuner/audio-calibration steps — now calls it out so lefties flip it up front. Frontend-only, additive: `static/v3/badges.js`, `static/v3/onboarding-tour.js`. Tests: `tests/js/badges_handedness.test.js`.
- **"Colorblind (deuteranope)" highway string-color preset.** Adds a one-click preset to the shared "Highway String Colors" picker, sitting next to the existing OkabeIto "Colorblind-friendly" preset — contributed by a deuteranopic player who found the OkabeIto set still hard to separate. It retunes the six main strings (red / yellow-green / blue / orange / teal / deep-purple) and keeps that set's 7/8-string colors, and applies to **both** the 2D and 3D highways via the shared picker. Frontend-only, additive: `static/app.js` (`HWC_PRESETS`).
- **`lib/gp_autosync.py`: piecewise time-warp helpers + a working `refine_sync()`.** `auto_sync()` has always computed per-bar sync points (DTW), but consumers could only apply the scalar bar-1 `audio_offset`, so any tempo difference between the recording and the tab's authored tempo accumulated audibly over the song. New librosa-free helpers expose the full mapping: `bar_start_times(gp_path)` (per-bar score times on the same axis as the sync points — GPIF bar-resolution map for `.gp`/`.gpx`, per-tick integration for GP3/4/5), `build_warp_anchors(points, bar_starts)` (strictly-monotonic `(score, audio)` anchor pairs), `warp_time(t, anchors)` (piecewise-linear map with edge-slope extrapolation for count-ins/tails), `warp_song_times(song, warp)` (retimes a `lib.song.Song` in place: notes + sustains, chords, beats, sections, anchors, handshapes, per-phrase difficulty levels, tone changes, tempo overrides), and `gp_has_expandable_repeats(gp_path)` (detects GP3/4/5 repeat/volta/direction markup whose playback expansion the as-written sync points cannot map — callers fall back to offset-only sync). Also implements `refine_sync()`, which the editor plugin's refine-sync endpoint has imported since the snapshot but which never existed in core (the Refine button 500'd): it densifies the coarse DTW points to every Nth bar and re-times each with a local onset phase sweep (sweep radius clamped under half a beat so periodic material can't lock a full beat off; short scoring grid + median residual snap). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117123 BPM recordings of a 120 BPM tab. Tests: `tests/test_gp_autosync_warp.py`.
- **Playlist shuffle.** The v3 playlist detail page gains a crossing-arrows shuffle toggle next to Play all / Play album. When on, `playQueue.start` Fisher-Yates-shuffles the queue once at start (on a copy — the stored playlist order is untouched), swapping any per-slot album arrangements in lockstep so each slot keeps its pinned arrangement. The preference is global and persists in `localStorage` (`v3PlaylistShuffle`). Tests: `tests/js/play_queue_shuffle.test.js`.
+50
View File
@@ -6547,6 +6547,36 @@ def _mb_search_recordings(artist, title, limit: int = 12) -> list[dict]:
return cands
def _mb_search_release_groups(query: str, limit: int = 8) -> list[dict]:
"""Text search /release-group for the Change-cover picker: albums matching a
free query, each mapped to its Cover Art Archive front thumb. One request;
tiles whose CAA art is missing self-hide client-side (front-250 404s). Lets a
cover be found even for a song with no metadata match (the city-pop pile)."""
q = (query or "").strip()
if not q:
return []
body = _mb_http_get("release-group", {"query": q, "limit": limit})
out: list[dict] = []
for rg in ((body or {}).get("release-groups") or []):
rid = rg.get("id")
if not rid:
continue
# artist-credit is a list of {name, joinphrase, artist} (joinphrase glues
# collaborations) — reconstruct the credited name.
artist = "".join(
(c.get("name", "") + c.get("joinphrase", "")) if isinstance(c, dict) else str(c)
for c in (rg.get("artist-credit") or [])
).strip()
title = rg.get("title") or ""
year = (rg.get("first-release-date") or "")[:4]
out.append({
"id": rid,
"label": " · ".join(x for x in (title, artist, year) if x) or title or "Cover",
"thumb_url": f"https://coverartarchive.org/release-group/{rid}/front-250",
})
return out
# ── AcoustID audio fingerprinting (content-based identification) ──────────────
# Optional path: requires the Chromaprint `fpcalc` binary AND an AcoustID API
# key ($ACOUSTID_API_KEY). Both absent ⇒ graceful no-op; the text matcher runs.
@@ -12249,6 +12279,26 @@ async def get_song_art(filename: str, request: Request = None, source: str = "")
_ART_PICKER_MAX_CAA = 12
@app.get("/api/song/{filename:path}/art/cover-search")
def api_art_cover_search(filename: str, q: str = ""):
"""Search Cover Art Archive (via MusicBrainz release-groups) for album covers
powers the Change-cover picker's search box, so a cover can be found even
for a song with no metadata match (the unmatched city-pop pile, where
/art/candidates is empty). `q` defaults to the song's own artist + album/
title (romaji fallback applied). Read-only; the picker renders the thumbs and
applies a pick through the existing /art/url route."""
query = (q or "").strip()
if not query:
pack = meta_db.pack_fields(meta_db._canonical_song_filename(filename))
query = " ".join(x for x in (pack.get("artist"), pack.get("album") or pack.get("title")) if x).strip()
if not query:
return {"query": "", "covers": []}
try:
return {"query": query, "covers": _mb_search_release_groups(query, limit=8)}
except EnrichTransportError:
return {"query": query, "covers": [], "error": "unavailable"}
@app.get("/api/song/{filename:path}/art/candidates")
def get_song_art_candidates(filename: str):
"""Everything the cover picker can offer for one song, without fetching a
+27 -10
View File
@@ -8100,6 +8100,26 @@ function _resolveEditRegion() {
return { a: Math.max(0, t - 4), b: t + 4 };
}
/* @pure:editor-pending-view:start */
function _buildEditorPendingViewPure(filename, arrangement, region, opts) {
const options = opts || {};
const view = {
filename,
arrangement: Number.isFinite(arrangement) && arrangement >= 0 ? arrangement : 0,
barSel: region ? { startTime: region.a, endTime: region.b } : null,
};
if (options.returnToHighway) view.returnToHighway = true;
if (typeof options.cursorTime === 'number') {
view.cursorTime = options.cursorTime;
} else if (region && typeof region.a === 'number') {
view.cursorTime = region.a;
}
if (typeof options.scrollX === 'number') view.scrollX = Math.max(0, options.scrollX);
if (typeof options.zoom === 'number' && options.zoom > 0) view.zoom = options.zoom;
return view;
}
/* @pure:editor-pending-view:end */
// Enable "Edit region" whenever the editor plugin is present and a song is
// loaded; show "↩ Editor" only while a return context is pending.
function _updateEditRegionBtn() {
@@ -8126,12 +8146,9 @@ function editRegionInEditor() {
arrangement = si.arrangement_index;
}
} catch (_) { /* default to 0 */ }
window._editorPendingView = {
filename: currentFilename,
arrangement,
barSel: { startTime: region.a, endTime: region.b },
window._editorPendingView = _buildEditorPendingViewPure(currentFilename, arrangement, region, {
returnToHighway: true,
};
});
window.editSong(currentFilename);
}
window.editRegionInEditor = editRegionInEditor;
@@ -8143,14 +8160,14 @@ function returnToEditorFromHighway() {
const ctx = window._highwayReturnCtx;
if (!ctx || typeof window.editSong !== 'function') return;
window._highwayReturnCtx = null;
window._editorPendingView = {
filename: ctx.filename,
arrangement: ctx.arrangement,
const region = ctx.barSel
? { a: ctx.barSel.startTime, b: ctx.barSel.endTime }
: null;
window._editorPendingView = _buildEditorPendingViewPure(ctx.filename, ctx.arrangement, region, {
scrollX: ctx.scrollX,
zoom: ctx.zoom,
cursorTime: ctx.cursorTime,
barSel: ctx.barSel,
};
});
window.editSong(ctx.filename);
}
window.returnToEditorFromHighway = returnToEditorFromHighway;
+24 -10
View File
@@ -2004,18 +2004,32 @@ function createHighway() {
const seedBase = (_frameIdx + n.s + ((n.t * 60) | 0)) | 0;
ctx.save();
ctx.fillStyle = col;
ctx.shadowColor = col;
ctx.shadowBlur = (8 + 6 * _shimmerNoise(seedBase)) * a; // shimmering glow
ctx.globalAlpha = (0.45 + 0.45 * a) * (0.78 + 0.22 * _shimmerNoise(seedBase + 17));
ctx.beginPath();
ctx.moveTo(x0 - sw0, y0);
ctx.lineTo(x0 + sw0, y0);
ctx.lineTo(x1 + sw1, y1);
ctx.lineTo(x1 - sw1, y1);
ctx.fill();
// Shimmering glow WITHOUT ctx.shadowBlur: blur cost scales with
// the blurred DEVICE-pixel area, and a held sustain's trail can
// span half the (DPR-scaled) canvas — profiling the "stutters
// while playing" report put this per-frame blur pass at the top
// exactly while a sustain is held. Three inflated low-alpha
// fills of the same quad read as the same soft glow at a flat,
// area-independent cost. The shimmer LUT still drives the
// per-frame size/brightness flicker (feedBack#254 intent).
const glowPx = (8 + 6 * _shimmerNoise(seedBase)) * a;
const baseA = (0.45 + 0.45 * a) * (0.78 + 0.22 * _shimmerNoise(seedBase + 17));
const fillTrail = (inflate) => {
ctx.beginPath();
ctx.moveTo(x0 - sw0 - inflate, y0);
ctx.lineTo(x0 + sw0 + inflate, y0);
ctx.lineTo(x1 + sw1 + inflate, y1);
ctx.lineTo(x1 - sw1 - inflate, y1);
ctx.fill();
};
ctx.globalAlpha = baseA * 0.22;
fillTrail(glowPx);
ctx.globalAlpha = baseA * 0.4;
fillTrail(glowPx * 0.45);
ctx.globalAlpha = baseA;
fillTrail(0);
// Crackling "current" — a jittery white core line down
// the trail, re-randomised each frame.
ctx.shadowBlur = 0;
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = a * (0.55 + 0.45 * _shimmerNoise(seedBase + 31));
ctx.strokeStyle = '#ffffff';
+26
View File
@@ -451,6 +451,12 @@
pill('inst', v, v[0].toUpperCase() + v.slice(1), settings.instrument === v)).join('')) +
instRow('Strings', STRING_COUNTS[settings.instrument].map((v) =>
pill('strings', v, v + '', settings.string_count === v)).join('')) +
// Handedness — a left-hander flips the whole highway (frets mirror).
// Lives with the other player-orientation choices so it's part of the
// same "Choose your instrument" step the onboarding tour spotlights —
// i.e. set before you ever tune up or calibrate.
instRow('Handedness', pill('hand', 'right', 'Right', !_leftyPref()) +
pill('hand', 'left', 'Left', _leftyPref())) +
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Tuning</div>' +
'<select data-inst-tuning class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-fb-text outline-none focus:border-fb-primary">' +
// An offset-array tuning has no named option — surface it as a
@@ -513,6 +519,10 @@
setWorkingInstrument(settings.instrument, newSc);
renderInstrument(); keepOpen();
}));
menu.querySelectorAll('[data-pill="hand"]').forEach((b) => b.addEventListener('click', () => {
_setLeftyPref(b.getAttribute('data-val') === 'left');
renderInstrument(); keepOpen(); // reflect the active pill; keep the menu open
}));
menu.querySelector('[data-inst-tuning]').addEventListener('change', (e) => saveSettings({ tuning: e.target.value }));
menu.querySelector('[data-inst-pathway]').addEventListener('change', (e) => saveSettings({ pathway: e.target.value }));
const ref = menu.querySelector('[data-inst-ref]');
@@ -528,6 +538,22 @@
function instRow(label, inner) {
return '<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">' + label + '</div><div class="flex flex-wrap gap-1">' + inner + '</div></div>';
}
// Handedness (left-handed) preference. The canonical store is the highway's
// `lefty` localStorage key; when a live highway exists, setLefty() also flips
// it immediately. Feature-detected so it works on the dashboard before any
// highway has been created (the value is read on the highway's next init).
function _leftyPref() {
try { if (window.highway && typeof window.highway.getLefty === 'function') return !!window.highway.getLefty(); } catch (_) { /* */ }
try { return localStorage.getItem('lefty') === '1'; } catch (_) { return false; }
}
function _setLeftyPref(on) {
try {
if (window.highway && typeof window.highway.setLefty === 'function') window.highway.setLefty(!!on);
else localStorage.setItem('lefty', on ? '1' : '0');
} catch (_) { /* storage blocked — the pill still reflects the choice via re-render */ }
// Keep the Settings "Left-handed" checkbox in sync when it's mounted.
try { const cb = document.getElementById('setting-lefty'); if (cb) cb.checked = !!on; } catch (_) { /* */ }
}
function pill(group, val, label, active) {
return '<button type="button" data-pill="' + group + '" data-val="' + val + '" class="px-2 py-1 rounded-md text-xs ' +
(active ? 'bg-fb-primary text-white' : 'bg-gray-800/50 text-fb-textDim hover:text-fb-text') + '">' + esc(label) + '</button>';
+56 -1
View File
@@ -138,6 +138,17 @@
'<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + SKELETON_TILE + '</div>' +
'<div class="text-xs text-fb-textDim pt-2">Fetching covers… the source is rate-limited.</div>' +
'</div>' +
// Search Cover Art Archive — find an album cover even when the song has
// no match (the auto candidates above are empty then). Pre-filled from
// the song's artist + album/title; the source is rate-limited.
'<div class="space-y-2 pt-1">' +
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Search covers</div>' +
'<div class="flex gap-2">' +
'<input data-ip-search-input type="text" value="' + esc(_cur.query || '') + '" placeholder="artist album" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary">' +
'<button data-ip-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button>' +
'</div>' +
'<div data-ip-search-results class="flex flex-wrap gap-3"></div>' +
'</div>' +
'<div data-ip-status class="hidden text-xs text-fb-accent"></div>' +
'</div></div>' +
'<input type="file" accept="image/*" data-ip-file class="hidden">';
@@ -185,9 +196,47 @@
}
});
});
const searchInput = panel.querySelector('[data-ip-search-input]');
const runSearch = () => coverSearch(panel, (searchInput && searchInput.value) || '');
panel.querySelector('[data-ip-search-go]')?.addEventListener('click', runSearch);
searchInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); runSearch(); } });
panel.querySelector('[data-ip-close]')?.focus();
}
// Search Cover Art Archive (via the song-scoped cover-search endpoint) and
// render the album covers as pickable tiles — the same apply('url') path as
// the auto candidates. Covers with no CAA art self-hide (img onerror).
async function coverSearch(panel, query) {
const out = panel.querySelector('[data-ip-search-results]');
const fn = _cur && _cur.filename;
if (!out || !fn) return;
out.innerHTML = '<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + '</div>';
let body = null;
try {
const r = await fetch('/api/song/' + enc(fn) + '/art/cover-search?q=' + enc(String(query).trim()));
if (r.ok) body = await r.json();
} catch (_) { /* falls through to the empty state */ }
if (!_cur || _cur.filename !== fn) return; // closed / changed song while searching
const covers = (body && body.covers) || [];
if (!covers.length) {
out.innerHTML = '<div class="text-xs text-fb-textDim">' +
((body && body.error) ? 'Cover search is unavailable right now.' : 'No covers found — try a different search.') +
'</div>';
return;
}
out.innerHTML = covers.map((c, i) =>
tileHtml('data-ip-cover="' + i + '"', imgFace(c.thumb_url), c.label || 'Cover')).join('');
out.querySelectorAll('[data-ip-cover]').forEach((btn) => {
const img = btn.querySelector('img');
if (img) img.onerror = () => btn.classList.add('hidden'); // no CAA art for this album → hide
btn.addEventListener('click', () => {
if (_busy) return;
const c = covers[Number(btn.getAttribute('data-ip-cover'))];
if (c) apply('url', c.thumb_url);
});
});
}
// The one candidates fetch, cancelled if the modal closes first. Failure
// (offline, demo mode, aborted) is silent: the skeletons just clear and
// the instant tiles remain — never an error wall.
@@ -281,7 +330,13 @@
const filename = opts && opts.filename;
if (!filename) return;
_lastFocus = document.activeElement;
_cur = { filename: filename, title: (opts && opts.title) || filename };
const title = (opts && opts.title) || filename;
const artist = (opts && opts.artist) || '';
const album = (opts && opts.album) || '';
// Pre-fill the cover search: "artist album" when the album is known, else
// just the artist, else the title — the server default backs it up.
const query = [artist, album].filter(Boolean).join(' ').trim() || title;
_cur = { filename: filename, title: title, query: query };
_busy = false;
const m = ensureModal();
const panel = document.getElementById('v3-imgpick-panel');
+1 -1
View File
@@ -696,7 +696,7 @@
'</div>';
body.querySelector('[data-cover-open]')?.addEventListener('click', () => {
if (window.__fbOpenImagePicker) {
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename });
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
}
});
}
+1 -1
View File
@@ -42,7 +42,7 @@
id: 'instrument', shape: 'spotlight', position: 'bottom',
selector: '#v3-instrument-wrap', waitFor: '#v3-instrument-wrap',
title: 'Choose your instrument',
content: 'Set your instrument, string count and tuning here. The highway, tuner and scoring all adapt to this selection.',
content: 'Set your instrument, string count and tuning here — and if you play left-handed, flip Handedness to Left so the whole highway mirrors. The highway, tuner and scoring all adapt to this selection.',
},
{
id: 'tuner', shape: 'spotlight', position: 'bottom',
+2 -2
View File
@@ -1007,7 +1007,7 @@
// the group's work_key/chart_count and pre-ticks the shown chart.)
if (id === '__fixmatch') { if (window.__fbFixMatch) window.__fbFixMatch(playTarget); return; }
if (id === '__cover') {
if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename });
if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename, artist: playTarget.artist, album: playTarget.album });
return;
}
if (id === '__refreshmeta') {
@@ -3126,7 +3126,7 @@
// when image-picker.js isn't loaded.
artWrap.addEventListener('click', () => {
if (window.__fbOpenImagePicker) {
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename });
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
} else {
artFile.click();
}
+47
View File
@@ -0,0 +1,47 @@
// Pins the onboarding handedness control: a Right/Left choice lives in the
// instrument selector (the "Choose your instrument" onboarding step, which the
// tour spotlights BEFORE the tuner/audio-calibration steps) and writes the
// highway 'lefty' preference. Source-level, matching the other tests/js/
// browser-heavy regression guards (the runtime path is DOM/WebGL-heavy).
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 BADGES = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'badges.js'), 'utf8');
const TOUR = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'onboarding-tour.js'), 'utf8');
test('instrument selector offers a Handedness Right/Left choice', () => {
assert.match(BADGES, /instRow\('Handedness'/, 'a Handedness row must be in the instrument menu');
assert.match(BADGES, /pill\('hand',\s*'right'/, 'Right handedness pill');
assert.match(BADGES, /pill\('hand',\s*'left'/, 'Left handedness pill');
});
test('clicking a handedness pill writes the lefty preference from its value', () => {
assert.match(
BADGES,
/\[data-pill="hand"\][\s\S]*?_setLeftyPref\(\s*b\.getAttribute\('data-val'\)\s*===\s*'left'\s*\)/,
'the handedness click handler sets lefty from the pill value');
});
test('_setLeftyPref prefers highway.setLefty and falls back to the lefty localStorage key', () => {
const setter = BADGES.match(/function _setLeftyPref\(on\)\s*\{[\s\S]*?\n \}/);
assert.ok(setter, '_setLeftyPref must exist');
assert.match(setter[0], /highway\.setLefty/, 'prefers highway.setLefty (flips a live highway + persists)');
assert.match(setter[0], /localStorage\.setItem\('lefty'/, 'falls back to the lefty localStorage key the highway reads on init');
});
test('_leftyPref reads highway.getLefty with a localStorage fallback', () => {
assert.match(
BADGES,
/function _leftyPref\(\)\s*\{[\s\S]*?getLefty[\s\S]*?localStorage\.getItem\('lefty'\)/,
'_leftyPref reads the current handedness with a storage fallback');
});
test('onboarding instrument step calls out left-handed players + the Handedness control', () => {
assert.match(TOUR, /Choose your instrument/);
assert.match(TOUR, /left-handed/i, 'the instrument step must call out left-handed players');
assert.match(TOUR, /Handedness/, 'and name the Handedness control');
});
+47
View File
@@ -0,0 +1,47 @@
'use strict';
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(/\/\* @pure:editor-pending-view:start \*\/[\s\S]*?\/\* @pure:editor-pending-view:end \*\//);
if (!m) throw new Error('pending-view helper block not found');
const api = new Function('"use strict";' + m[0] + '\nreturn { _buildEditorPendingViewPure };')();
test('edit-region handoff defaults cursor to region start and marks return path', () => {
const out = api._buildEditorPendingViewPure('song.sloppak', 2, { a: 12.5, b: 20 }, { returnToHighway: true });
assert.deepStrictEqual(out, {
filename: 'song.sloppak',
arrangement: 2,
barSel: { startTime: 12.5, endTime: 20 },
returnToHighway: true,
cursorTime: 12.5,
});
});
test('return-trip handoff preserves explicit viewport state', () => {
const out = api._buildEditorPendingViewPure('song.sloppak', 1, { a: 8, b: 14 }, {
scrollX: -4,
zoom: 160,
cursorTime: 9.25,
});
assert.deepStrictEqual(out, {
filename: 'song.sloppak',
arrangement: 1,
barSel: { startTime: 8, endTime: 14 },
cursorTime: 9.25,
scrollX: 0,
zoom: 160,
});
});
test('missing region still produces a stable pending view shell', () => {
const out = api._buildEditorPendingViewPure('song.sloppak', -1, null, {});
assert.deepStrictEqual(out, {
filename: 'song.sloppak',
arrangement: 0,
barSel: null,
});
});