mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
v3 library: first-hour polish — zero-states, match progress, provenance, alias search (#730)
* v3 library: first-hour polish — zero-states, match progress, provenance, alias search
Six launch-eve fixes for a brand-new user's first hour with a fresh,
being-matched library. Each is small and reuses shipped idioms.
- Invitational repertoire meter: with no practice data yet, the home meter
no longer reads "0 of N mastered" (debt framing) — it shows an empty bar
with "grows as you master songs". A count of 0 read as failure on day one.
- "Start here" starter shelf: growth_edge_suggestions() distinguishes two
empties — attempts exist but all mastered (honest empty shelf) vs nothing
attempted yet (day one) → new starter_suggestions() returns up to 8
approachable songs (90–480s, shortest first) flagged starter:true, and the
client renders a "Start here" shelf instead of a blank home.
- Library-visible match progress: while the background pass runs, a quiet
"Matching your library — X of Y" line sits by the review chip (5s poll,
single guarded interval, cleared the moment the pass stops — no leak,
no toast, silent completion).
- One-time transparency toast: the first time an install is seen matching a
real library, one fbNotify names what's contacted (MusicBrainz / Cover Art
Archive), that results are stored locally, that files aren't changed
without you, and where the switch is. localStorage-gated, wrapped so a
blocked notifier can't break the chip.
- Empty-library dead-end card: a genuinely empty local library (no songs, no
query/filter) shows "Your library is empty" + drop-files hint + Open
Settings, instead of a bare grid under dead dropdowns.
- Alias-aware search: searching a canonical name ("AC/DC") now also finds
songs whose raw tag is a merged variant ("ACDC"), via the artist_alias
table. Probe-guarded so a no-aliases library keeps the exact original
3-term query; pure predicate, keyset-safe.
- Details-drawer provenance line: matched/manual rows show "Matched:
<artist — title> (source) · Fix match" under the Identity fields — the
wrong-match escape hatch at the point of the data, wired to the same
fix-match flow the card menu uses. New read-only GET
/api/enrichment/song/{filename} backs it.
Tests: tests/test_starter_suggestions.py (starter vs normal-shelf behaviour,
length window, attempts-exist path unchanged) + alias-search cases added to
tests/test_artist_alias.py. 34 targeted pass; node --check clean; no new
Tailwind classes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* fix(v3 library): stop match-progress poll when leaving the library screen
The 5s enrichment poll (_pollTimer) was cleared on pass completion and on
fetch error, but not when the user navigated away from the library. Leaving
v3-songs mid-pass left the interval pinging /api/enrichment/status in the
background until the pass ended. Subscribe to the existing feedBack
'screen:changed' event: clear the poll when any non-v3-songs screen shows,
and refresh (re-arming if a pass is still running) on returning to v3-songs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
byrongamatos
parent
df2d660d1e
commit
8c7cde5d5c
@@ -35,9 +35,52 @@
|
||||
// ── Ambient chip + the Settings card's status line ───────────────────────
|
||||
// songs.js renders `#v3-songs-match-review` (hidden) in its toolbar and
|
||||
// calls window.__fbMatchReviewChip() after each toolbar build; review
|
||||
// actions here re-call it. The same fetch feeds the Settings status line.
|
||||
// actions here re-call it. The same fetch feeds the Settings status line
|
||||
// and, while a pass is running, a quiet toolbar progress line (below).
|
||||
// Silent on failure — surfaces just stay as they are.
|
||||
let _chipBusy = false;
|
||||
let _pollTimer = null; // 5s status poll, alive ONLY while a pass runs
|
||||
|
||||
// Quiet library-visible progress (launch polish): a plain text line next
|
||||
// to the review chip while the background pass is working through the
|
||||
// queue — "Matching your library — X of Y". No toast, no sound; it simply
|
||||
// disappears when the pass finishes (hearing-safe, design §11).
|
||||
function _setProgressLine(running, states, total) {
|
||||
let el = document.getElementById('v3-songs-match-progress');
|
||||
const unscanned = states.unscanned || 0;
|
||||
if (!running || unscanned <= 0 || total <= 0) {
|
||||
if (el) el.remove();
|
||||
return;
|
||||
}
|
||||
if (!el) {
|
||||
const chip = document.getElementById('v3-songs-match-review');
|
||||
if (!chip || !chip.parentElement) return; // songs toolbar not on screen
|
||||
el = document.createElement('span');
|
||||
el.id = 'v3-songs-match-progress';
|
||||
el.className = 'text-xs text-fb-textDim';
|
||||
chip.insertAdjacentElement('afterend', el);
|
||||
}
|
||||
el.textContent = 'Matching your library — ' + Math.max(0, total - unscanned) + ' of ' + total;
|
||||
}
|
||||
|
||||
// One-time transparency toast (launch polish): the first time this
|
||||
// install is observed actually matching a real library, say plainly what
|
||||
// is contacted, where results live, and where the switch is. Wrapped like
|
||||
// app.js's fbNotify calls so a blocked localStorage / absent notifier can
|
||||
// never break the chip.
|
||||
function _announceOnce(running, total) {
|
||||
try {
|
||||
if (!running || total <= 0) return;
|
||||
if (localStorage.getItem('fb_enrich_announce_v1')) return;
|
||||
localStorage.setItem('fb_enrich_announce_v1', '1');
|
||||
window.fbNotify?.show({
|
||||
title: 'Library matching is on',
|
||||
message: 'Song info and covers come from MusicBrainz and Cover Art Archive, stored locally. Your files are never changed unless you choose to write to them. Adjust in Settings → Library.',
|
||||
icon: '📚',
|
||||
});
|
||||
} catch (_) { /* storage/notifier unavailable — skip quietly */ }
|
||||
}
|
||||
|
||||
async function refreshChip() {
|
||||
if (_chipBusy) return;
|
||||
_chipBusy = true;
|
||||
@@ -62,7 +105,24 @@
|
||||
if (st.unscanned) parts.push(st.unscanned + ' queued');
|
||||
line.textContent = (body.running ? 'Matching… · ' : '') + parts.join(' · ');
|
||||
}
|
||||
} catch (_) { /* offline — leave as-is */ } finally {
|
||||
const running = !!body.running;
|
||||
const total = body.total_songs || 0;
|
||||
_setProgressLine(running, st, total);
|
||||
_announceOnce(running, total);
|
||||
// Poll only while a pass is actually running; a single guarded
|
||||
// interval, cleared the moment the pass stops (no leaks).
|
||||
if (running && !_pollTimer) {
|
||||
_pollTimer = setInterval(refreshChip, 5000);
|
||||
} else if (!running && _pollTimer) {
|
||||
clearInterval(_pollTimer);
|
||||
_pollTimer = null;
|
||||
}
|
||||
} catch (_) {
|
||||
// Offline — leave surfaces as they are, but stop any poll so a
|
||||
// dead server isn't pinged every 5s forever (the next toolbar
|
||||
// build / settings open restarts it if a pass is still running).
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
||||
} finally {
|
||||
_chipBusy = false;
|
||||
}
|
||||
}
|
||||
@@ -455,10 +515,34 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Stop the 5s poll when the library screen is left — the progress line and
|
||||
// chip only live in the songs toolbar, so polling off-screen is pure waste
|
||||
// (benign but tidy). Re-entering v3-songs re-arms it: songs.js re-calls
|
||||
// window.__fbMatchReviewChip() on screen enter, and we also refresh here so
|
||||
// this stays self-contained. Same single-guarded-interval invariant as
|
||||
// refreshChip — no double-interval, cleared to null.
|
||||
function wireScreenTeardown() {
|
||||
const sm = window.feedBack;
|
||||
if (!sm || typeof sm.on !== 'function') return;
|
||||
sm.on('screen:changed', (e) => {
|
||||
const id = e && e.detail && e.detail.id;
|
||||
if (id === 'v3-songs') {
|
||||
refreshChip(); // returning while a pass runs re-arms the poll
|
||||
} else if (_pollTimer) {
|
||||
clearInterval(_pollTimer);
|
||||
_pollTimer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', wireSettingsCard, { once: true });
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
wireSettingsCard();
|
||||
wireScreenTeardown();
|
||||
}, { once: true });
|
||||
} else {
|
||||
wireSettingsCard();
|
||||
wireScreenTeardown();
|
||||
}
|
||||
|
||||
window.__fbMatchReviewChip = refreshChip;
|
||||
|
||||
+88
-13
@@ -591,16 +591,34 @@
|
||||
const shelf = Array.isArray(suggestions) ? suggestions : [];
|
||||
|
||||
const { mastered, learning } = _repertoireCounts();
|
||||
const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100)));
|
||||
const meter =
|
||||
'<div class="v3-rep-meter">' +
|
||||
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
|
||||
'<span class="text-sm font-semibold text-fb-text">Repertoire</span>' +
|
||||
'<span class="text-xs text-fb-textDim">' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') +
|
||||
(learning ? ' · ' + learning + ' in progress' : '') + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' +
|
||||
'</div>';
|
||||
// Day-one zero-state (launch polish): no practice data and no real
|
||||
// growth-edge rows → an invitational meter, never "0 of N". Starter
|
||||
// rows are the server's no-attempts fallback, so they count as "no
|
||||
// practice yet" too.
|
||||
const starterShelf = shelf.length > 0 && !!shelf[0].starter;
|
||||
const invitational = (mastered + learning) === 0 && (!shelf.length || starterShelf);
|
||||
let meter;
|
||||
if (invitational) {
|
||||
meter =
|
||||
'<div class="v3-rep-meter">' +
|
||||
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
|
||||
'<span class="text-sm font-semibold text-fb-text">Repertoire</span>' +
|
||||
'<span class="text-xs text-fb-textDim">grows as you master songs</span>' +
|
||||
'</div>' +
|
||||
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:0%"></div></div>' +
|
||||
'</div>';
|
||||
} else {
|
||||
const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100)));
|
||||
meter =
|
||||
'<div class="v3-rep-meter">' +
|
||||
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
|
||||
'<span class="text-sm font-semibold text-fb-text">Repertoire</span>' +
|
||||
'<span class="text-xs text-fb-textDim">' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') +
|
||||
(learning ? ' · ' + learning + ' in progress' : '') + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
let shelfHtml = '';
|
||||
if (shelf.length) {
|
||||
@@ -613,9 +631,14 @@
|
||||
'<div class="mt-1 text-sm text-fb-text truncate">' + esc(r.title) + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim truncate">' + esc(r.artist) + '</div>' +
|
||||
'</button>').join('');
|
||||
// Starter rows → the invitational "Start here" framing; real
|
||||
// growth-edge rows → the usual "Keep practicing". Same cards.
|
||||
const header = starterShelf
|
||||
? '<h3 class="text-sm font-semibold text-fb-text">Start here</h3>' +
|
||||
'<div class="text-xs text-fb-textDim mb-2">a few approachable songs to kick things off</div>'
|
||||
: '<h3 class="text-sm font-semibold text-fb-text mb-2">Keep practicing</h3>';
|
||||
shelfHtml =
|
||||
'<section class="v3-kp-shelf mt-4">' +
|
||||
'<h3 class="text-sm font-semibold text-fb-text mb-2">Keep practicing</h3>' +
|
||||
'<section class="v3-kp-shelf mt-4">' + header +
|
||||
'<div class="v3-kp-row">' + cards + '</div>' +
|
||||
'</section>';
|
||||
}
|
||||
@@ -1816,6 +1839,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Empty-library dead-end card (launch polish): only for a genuinely empty
|
||||
// LOCAL library — a search / filter / format narrowing that merely matched
|
||||
// nothing keeps the plain blank grid (saying "empty" there would lie), and
|
||||
// remote providers own their own emptiness. The inline grid-column style
|
||||
// spans the card across the grid without a new Tailwind class.
|
||||
function _emptyLibraryHtml() {
|
||||
if (state.q || state.format || activeFilterCount() !== 0 || state.provider !== 'local') return '';
|
||||
return '<div class="flex flex-col items-center justify-center text-center py-8 gap-2" style="grid-column:1/-1">' +
|
||||
'<div class="text-lg font-semibold text-fb-text">Your library is empty</div>' +
|
||||
'<div class="text-sm text-fb-textDim max-w-md">Drop .sloppak files into your library folder, or use Upload above.</div>' +
|
||||
'<button data-lib-empty-settings class="mt-3 bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-xl text-sm font-semibold">Open Settings</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
let _winRAF = 0;
|
||||
function requestWindowRender() {
|
||||
if (_winRAF) return;
|
||||
@@ -1837,8 +1874,16 @@
|
||||
const rows = Math.ceil(total / Math.max(1, cols));
|
||||
sizer.style.height = (rows * rowH) + 'px';
|
||||
if (total === 0) {
|
||||
grid.innerHTML = ''; grid.style.top = '0px';
|
||||
grid.innerHTML = _emptyLibraryHtml(); grid.style.top = '0px';
|
||||
state.winRange = { start: 0, end: 0 };
|
||||
if (grid.innerHTML) {
|
||||
// The grid is absolutely positioned inside the sizer — give the
|
||||
// sizer the card's height so it participates in layout.
|
||||
sizer.style.height = grid.offsetHeight + 'px';
|
||||
grid.querySelector('[data-lib-empty-settings]')?.addEventListener('click', () => {
|
||||
if (window.showScreen) window.showScreen('settings');
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const sizerTop = _sizerTopInScroller(main, sizer);
|
||||
@@ -2454,6 +2499,11 @@
|
||||
try { const r = await fetch('/api/song/' + enc(fn) + '/user-meta'); if (r.ok) meta = await r.json(); } catch (_) { /* offline → row data */ }
|
||||
let vocab = [];
|
||||
try { const r = await fetch('/api/tags'); if (r.ok) vocab = (await r.json()).tags || []; } catch (_) { /* */ }
|
||||
// Match provenance (launch polish): the drawer names what this chart
|
||||
// matched, so a silently-wrong first match is visible where the
|
||||
// metadata lives. 404 (no row yet) / offline → no line.
|
||||
let enrich = null;
|
||||
try { const r = await fetch('/api/enrichment/song/' + enc(fn)); if (r.ok) enrich = await r.json(); } catch (_) { /* offline → no provenance line */ }
|
||||
if (_detailsEls) closeDetails(); // a concurrent open resolved first
|
||||
|
||||
const st = {
|
||||
@@ -2462,6 +2512,7 @@
|
||||
notes: meta.notes || '', tags: (meta.tags || []).slice(),
|
||||
fav: !!song.favorite, artDataUrl: null,
|
||||
gap: null, gapSel: null, // gap-fill (R4a): preview state + selected keys
|
||||
enrich: enrich, // match provenance for the Identity section
|
||||
};
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
@@ -2523,6 +2574,22 @@
|
||||
'<button data-gapfill-cancel class="px-3 py-1.5 bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 rounded-lg text-xs text-fb-text">Cancel</button></div></div>';
|
||||
}
|
||||
|
||||
// Match-provenance line under the Identity fields (launch polish): names
|
||||
// the canonical identity this chart matched — the invisible-first-wrong-
|
||||
// match fix — with the same Fix-match escape hatch the card menu offers.
|
||||
// Only for settled matches; pending/review/failed rows stay silent here
|
||||
// (the review chip / match facet own those states).
|
||||
function provenanceHtml(st) {
|
||||
const e = st.enrich;
|
||||
if (!e || (e.match_state !== 'matched' && e.match_state !== 'manual')) return '';
|
||||
const who = [e.canon_artist, e.canon_title].filter(Boolean).join(' — ');
|
||||
if (!who) return '';
|
||||
const src = e.match_state === 'manual' ? 'your pick' : 'MusicBrainz';
|
||||
return '<div class="flex items-baseline gap-2 text-xs text-fb-textDim">' +
|
||||
'<span class="truncate">Matched: ' + esc(who) + ' (' + esc(src) + ')</span>' +
|
||||
'<button data-det-fixmatch class="shrink-0 text-fb-primary hover:text-fb-primaryHi">Fix match</button></div>';
|
||||
}
|
||||
|
||||
function detailsHtml(song, st, vocab) {
|
||||
const art = st.artDataUrl || artUrl(song);
|
||||
const diffBtns = [1, 2, 3, 4, 5].map((n) =>
|
||||
@@ -2556,6 +2623,7 @@
|
||||
'<span class="text-[0.625rem] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700" title="These came from the song's feedpak. Editing them writes back to the file.">From pack</span></div>' +
|
||||
field('det-title', 'Title', st.t) + field('det-artist', 'Artist', st.a) + field('det-album', 'Album', st.al) +
|
||||
'<div><label for="det-year" class="text-xs text-fb-textDim mb-1 block">Year</label><input type="text" inputmode="numeric" id="det-year" value="' + esc(st.y) + '" placeholder="e.g. 2024" class="w-full bg-fb-card border border-fb-border/60 rounded-lg px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary/60"></div>' +
|
||||
provenanceHtml(st) +
|
||||
'<div data-det-gapfill>' + gapFillHtml(st) + '</div></div>' +
|
||||
|
||||
// Personal practice layer — local, never shared
|
||||
@@ -2623,6 +2691,13 @@
|
||||
|
||||
$('[data-det-save]')?.addEventListener('click', () => saveDetails(song, st));
|
||||
$('[data-det-remove]')?.addEventListener('click', () => removeFromLibrary(song));
|
||||
// Fix match → the exact flow the card ⋮ menu uses (match-review.js).
|
||||
// The drawer closes first: the match modal sits below the drawer's
|
||||
// z-index, and the fix supersedes the edit anyway.
|
||||
$('[data-det-fixmatch]')?.addEventListener('click', () => {
|
||||
closeDetails();
|
||||
if (window.__fbFixMatch) window.__fbFixMatch(song);
|
||||
});
|
||||
|
||||
// Gap-fill (R4a): user-initiated write of CONFIRMED missing info into
|
||||
// the pack file. The server recomputes proposals under its io lock, so
|
||||
|
||||
Reference in New Issue
Block a user