Merge remote-tracking branch 'origin/main' into fix/v3-library-scroll-recycle

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
byrongamatos
2026-07-05 00:19:07 +02:00
97 changed files with 5905 additions and 437 deletions
+72 -23
View File
@@ -2758,6 +2758,12 @@ function goFavTreePage(p) {
// ── Settings ─────────────────────────────────────────────────────────────
let _defaultArrangement = '';
const INSTRUMENT_PATHWAYS = ['songs', 'practice', 'learn', 'studio'];
function _normalizeInstrumentPathway(value) {
return INSTRUMENT_PATHWAYS.includes(value) ? value : 'songs';
}
function _syncDefaultArrangementSelect(value) {
const sel = document.getElementById('default-arrangement');
if (!sel) return;
@@ -3410,6 +3416,8 @@ async function loadSettings() {
if (dlcEl) dlcEl.value = data.dlc_dir || '';
_defaultArrangement = data.default_arrangement || '';
_syncDefaultArrangementSelect(_defaultArrangement);
const pathwayEl = document.getElementById('setting-instrument-pathway');
if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway);
const demucsEl = document.getElementById('demucs-server-url');
if (demucsEl) demucsEl.value = data.demucs_server_url || '';
const leftyEl = document.getElementById('setting-lefty');
@@ -3901,6 +3909,18 @@ function persistSetting(key, value) {
_settingSaveChain = next.catch(() => {});
return next;
}
function setInstrumentPathway(value) {
const pathway = _normalizeInstrumentPathway(value);
const el = document.getElementById('setting-instrument-pathway');
if (el) el.value = pathway;
persistSetting('pathway', pathway).then(() => {
if (window.v3Badges && typeof window.v3Badges.reload === 'function') {
try { window.v3Badges.reload(); } catch (_) { /* noop */ }
}
});
}
async function _postSetting(key, value) {
const status = document.getElementById('settings-status');
try {
@@ -6195,7 +6215,7 @@ window.feedBack.on('song:ready', () => {
setSpeed(pend.speed);
}
} catch (_) { /* speed restore is best-effort */ }
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'resume'))
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'session-resume'))
.then(() => { if (_autoplayExitEnabled() && !isPlaying) return togglePlay(); })
.catch((err) => console.warn('[app] resume failed:', err));
});
@@ -6761,7 +6781,16 @@ window.feedBack.playQueue = (function () {
if (!files.length) return false;
list = files.slice(); idx = 0;
source = (opts && opts.source) || '';
arrangements = (opts && opts.arrangements) || null;
arrangements = (opts && opts.arrangements) ? opts.arrangements.slice() : null;
if (opts && opts.shuffle && list.length > 1) {
// Fisher-Yates, once at start. Swap arrangements in lockstep so an
// album slot's pinned arrangement stays glued to its file (#685).
for (let i = list.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[list[i], list[j]] = [list[j], list[i]];
if (arrangements) [arrangements[i], arrangements[j]] = [arrangements[j], arrangements[i]];
}
}
if (window.fbNotify) {
try { window.fbNotify.show({ title: 'Playing ' + (source || 'queue'), message: files.length + ' songs', icon: '▶' }); } catch (e) { /* */ }
}
@@ -10968,20 +10997,19 @@ async function loadPlugins() {
const nameDelta = String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
return nameDelta || String(a.id || '').localeCompare(String(b.id || ''));
});
const livePluginIds = new Set(plugins.map((plugin) => plugin.id));
for (const [pluginId, contributions] of _pluginUiContributions) {
if (livePluginIds.has(pluginId)) continue;
const stalePlugin = { id: pluginId };
for (const contribution of contributions) {
await _commandUiDomain(contribution.domain, 'unmount', stalePlugin, contribution);
}
try {
window.feedBack?.capabilities?.unregisterParticipant?.(pluginId);
} catch (e) {
console.warn(`capability participant unregister failed for ${pluginId}:`, e);
}
_pluginUiContributions.delete(pluginId);
}
// NOTE deliberately NO stale-contribution sweep for plugins absent
// from this response. Absent ≠ uninstalled: the backend clears its
// plugin registry at the start of load_plugins() and repopulates it
// incrementally while HTTP stays up, so every backend restart serves a
// window of partial (even empty) responses. The old sweep unmounted UI
// contributions and unregistered capability participants on mere
// absence, permanently breaking still-loaded plugins — their scripts
// don't re-run (loadedScripts guard below), so nothing ever
// re-registered. A genuine mid-session uninstall now leaves the
// (already-evaluated, un-unloadable) script's contributions in place
// until reload; its nav entry still disappears because nav is rebuilt
// from the response each round. Same invariant as the settings/screen
// DOM wipe and _reconcilePluginStyles below.
console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins');
try {
@@ -11123,17 +11151,23 @@ async function loadPlugins() {
loadedStyles.set(plugin.id, wantedVersion);
};
const _reconcilePluginStyles = (currentPlugins) => {
// Drop stylesheets for plugins that vanished from /api/plugins or are
// no longer ready+styled this round. _injectPluginStyles below only
// visits plugins still returned by the API, so an uninstalled or
// newly-not-ready plugin would otherwise keep its <link> applying.
// Drop stylesheets for plugins the response KNOWS about but that
// are no longer ready+styled this round. _injectPluginStyles below
// only visits plugins still returned by the API, so a newly-not-
// ready or unstyled plugin would otherwise keep its <link>
// applying. Plugins merely ABSENT from the response keep their
// stylesheet — a transient partial response during a backend
// restart is not an uninstall (same invariant as the screen/
// settings wipe below), and stripping the <link> would leave a
// still-loaded plugin visible but unstyled.
const responded = new Set(currentPlugins.map((p) => p.id));
const styled = new Set(
currentPlugins
.filter((p) => (p.status || 'ready') === 'ready' && p.has_styles && p.styles)
.map((p) => p.id),
);
for (const id of Array.from(loadedStyles.keys())) {
if (!styled.has(id)) {
if (responded.has(id) && !styled.has(id)) {
_removePluginStyleTags(id);
loadedStyles.delete(id);
}
@@ -11146,6 +11180,18 @@ async function loadPlugins() {
if (pid) existingSettingsByPluginId.set(pid, child);
}
}
// Plugins named in THIS response. A plugin can be transiently absent
// from /api/plugins — the backend clears its registry at the start of
// load_plugins() and repopulates it incrementally while HTTP stays up,
// so every backend restart serves a window of partial (even empty)
// responses. The wipe loops below must never treat that absence as an
// uninstall: stripping a still-loaded plugin's DOM while keeping its
// loadedScripts entry made the NEXT refetch fail the DOM check and
// re-evaluate its screen.js mid-session — which duplicated the desktop
// audio_engine's native signal chain (its init re-ran against the
// surviving engine chain). Absent plugins keep their DOM and script;
// they're re-reconciled when they reappear in a later response.
const respondedIds = new Set(plugins.map((p) => p.id));
const alreadyHydrated = new Set();
for (const p of plugins) {
if (!p.has_script) continue;
@@ -11173,7 +11219,10 @@ async function loadPlugins() {
for (const container of _pluginSettingsContainers()) {
[...container.children].forEach((el) => {
const pid = el.dataset ? el.dataset.pluginId : null;
if (!pid || !alreadyHydrated.has(pid)) el.remove();
// Remove junk (no plugin id) and plugins the response KNOWS
// about but that failed hydration; leave plugins absent from
// the response untouched (see respondedIds above).
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
});
}
document.querySelectorAll('.screen[id^="plugin-"]').forEach((el) => {
@@ -11182,7 +11231,7 @@ async function loadPlugins() {
// change shipped — both forms strip a single leading "plugin-".
const pid = (el.dataset && el.dataset.pluginId)
|| el.id.replace(/^plugin-/, '');
if (!alreadyHydrated.has(pid)) el.remove();
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
});
// Plugin settings area hosts both "Plugin Updates" and per-plugin
+1 -1
View File
@@ -305,7 +305,7 @@
return fetch('/api/tunings')
.then(function (r) { return r && r.ok ? r.json() : null; })
.then(function (t) {
const byName = t && t[key];
const byName = t && ((t.tunings && t.tunings[key]) || t[key]);
commit(byName ? _offsetsFromFreqs(byName[s.tuning], byName.Standard) : null);
})
.catch(function () { commit(null); });
+1 -1
View File
File diff suppressed because one or more lines are too long
+53 -5
View File
@@ -21,7 +21,13 @@
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5] };
const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5, 6] };
const PATHWAY_OPTIONS = [
{ id: 'songs', label: 'Songs' },
{ id: 'practice', label: 'Practice' },
{ id: 'learn', label: 'Learn' },
{ id: 'studio', label: 'Studio' },
];
// Tuning names per instrument key (e.g. 'guitar-6', 'bass-4'), loaded from
// GET /api/tunings. Falls back to empty arrays until the fetch resolves.
let _tuningsByKey = {};
@@ -106,7 +112,7 @@
}
}
let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440 };
let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440, pathway: 'songs', instrument_profiles: {}, active_instrument_profile: 'guitar-lead' };
async function loadTunings() {
try {
@@ -126,6 +132,15 @@
} catch (_) { /* non-fatal — TUNINGS falls back to empty, dropdown shows nothing */ }
}
function pathwayForProfile(profiles, profileId, fallback) {
const p = profiles && profiles[profileId];
return p && PATHWAY_OPTIONS.some((o) => o.id === p.pathway) ? p.pathway : (fallback || 'songs');
}
function profileIdForInstrument(inst) {
return inst === 'bass' ? 'bass' : 'guitar-lead';
}
async function loadSettings() {
try {
const r = await fetch('/api/settings');
@@ -150,16 +165,34 @@
if (typeof s.tuning === 'string') tuning = tunings.includes(s.tuning) ? s.tuning : (tunings[0] || 'Standard');
else if (Array.isArray(s.tuning)) tuning = s.tuning;
else tuning = tunings[0] || 'Standard';
const profiles = s.instrument_profiles && typeof s.instrument_profiles === 'object' ? s.instrument_profiles : {};
const pathway = PATHWAY_OPTIONS.some((o) => o.id === s.pathway) ? s.pathway : 'songs';
settings = {
instrument: instrument,
string_count: scValid,
tuning: tuning,
reference_pitch: Math.min(450, Math.max(430, ref)),
pathway: pathway,
instrument_profiles: profiles,
active_instrument_profile: typeof s.active_instrument_profile === 'string' ? s.active_instrument_profile : profileIdForInstrument(instrument),
};
}
} catch (e) { /* settings endpoint always present */ }
}
function syncLocalProfilePatch(patch) {
const profileId = profileIdForInstrument(patch.instrument || settings.instrument);
if (!settings.instrument_profiles || typeof settings.instrument_profiles !== 'object') settings.instrument_profiles = {};
if (patch.instrument) settings.active_instrument_profile = profileId;
const profile = Object.assign({}, settings.instrument_profiles[profileId] || {});
let changed = false;
if (patch.instrument) { profile.instrument = patch.instrument; changed = true; }
if (patch.string_count != null) { profile.string_count = patch.string_count; changed = true; }
if (patch.tuning != null) { profile.tuning = patch.tuning; changed = true; }
if (patch.reference_pitch != null) { profile.reference_pitch = patch.reference_pitch; changed = true; }
if (patch.pathway != null) { profile.pathway = patch.pathway; changed = true; }
if (changed) settings.instrument_profiles[profileId] = profile;
}
async function saveSettings(patch) {
// Only adopt the patch once the server accepts it. /api/settings returns
// {error: ...} with HTTP 200 on a validation failure, so a rejected
@@ -177,8 +210,9 @@
} catch (e) { /* non-fatal — leave settings unchanged */ }
if (!accepted) return false;
Object.assign(settings, patch);
syncLocalProfilePatch(patch);
if (sm && sm.emit) sm.emit('instrument:changed', {
instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning,
instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning, pathway: settings.pathway,
});
pushToTuner();
renderTuner(); // reflect new tuning on the tuner card
@@ -424,6 +458,9 @@
// (picking a named tuning still works and replaces the custom one).
(typeof settings.tuning === 'string' ? '' : '<option selected disabled>Custom</option>') +
_tuningsForInstrument(settings.instrument, settings.string_count).map((t) => '<option' + (t === settings.tuning ? ' selected' : '') + '>' + esc(t) + '</option>').join('') + '</select></div>' +
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Pathway</div>' +
'<select data-inst-pathway 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">' +
PATHWAY_OPTIONS.map((p) => '<option value="' + esc(p.id) + '"' + (p.id === settings.pathway ? ' selected' : '') + '>' + esc(p.label) + '</option>').join('') + '</select></div>' +
'<div><div class="flex justify-between text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1"><span>Reference pitch</span><span data-ref-val>' + settings.reference_pitch + ' Hz</span></div>' +
'<input data-inst-ref type="range" min="430" max="450" step="1" value="' + settings.reference_pitch + '" class="w-full slider-input"></div>' +
'</div></div>';
@@ -454,6 +491,7 @@
instrument: v,
string_count: newSc,
tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning),
pathway: pathwayForProfile(settings.instrument_profiles, profileIdForInstrument(v), settings.pathway),
});
// Only move the working-tuning context once the switch was actually persisted —
// otherwise the selector stays on the old instrument while the card shows the
@@ -462,11 +500,21 @@
renderInstrument(); keepOpen();
}));
menu.querySelectorAll('[data-pill="strings"]').forEach((b) => b.addEventListener('click', async () => {
await saveSettings({ string_count: Number(b.getAttribute('data-val')) });
setWorkingInstrument(settings.instrument, settings.string_count);
const newSc = Number(b.getAttribute('data-val'));
// Clamp the tuning to one valid for the new string count and post it
// alongside string_count — otherwise the backend silently resets a
// now-invalid tuning to Standard while this UI keeps showing the old
// one (settings/tuner desync). Mirrors the instrument-switch clamp.
const tunings = _tuningsForInstrument(settings.instrument, newSc);
await saveSettings({
string_count: newSc,
tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning),
});
setWorkingInstrument(settings.instrument, newSc);
renderInstrument(); keepOpen();
}));
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]');
ref.addEventListener('input', (e) => { menu.querySelector('[data-ref-val]').textContent = e.target.value + ' Hz'; });
ref.addEventListener('change', (e) => saveSettings({ reference_pitch: Number(e.target.value) }));
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

+295
View File
@@ -0,0 +1,295 @@
// Cover-art picker (PR-C — multi-candidate "change cover", media-server
// style). ONE component: window.__fbOpenImagePicker({filename, title}),
// reached from the Details drawer's art click and the card ⋮ "Change cover…".
//
// Anatomy mirrors match-review.js (body-appended singleton: overlay +
// centred panel, light focus trap, Esc closes, overlay click closes) but
// layers at z-[200] — the songs.js centered-modal tier — because one of its
// openers is the details drawer (z-[61]), which sits above match-review's
// z-40/50 pair.
//
// The design's key trick (§7-§9/§11 of the launch charrette): a pick never
// grows a new write path. Choosing a CAA candidate POSTs its thumb URL to
// the EXISTING …/art/url route (the override lane: never evicted, survives
// a re-match); "Pack original" DELETEs the override; Upload POSTs the
// existing …/art/upload (GIF stays upload-only + local-only; the server's
// 10MB / http(s) guards apply to URLs). Success is silent (hearing-safe,
// like the match layer): the modal just closes and the art refreshes.
(function () {
'use strict';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
// Provenance badge text — same vocabulary as the match layer.
const PROV_LABEL = { yours: 'Yours', pack: 'Pack', matched: 'Matched' };
let _cur = null; // {filename, title} while the picker is open
let _abort = null; // in-flight candidates fetch — cancelled on close
let _busy = false; // an apply is running — ignore further tile clicks
let _lastFocus = null;
const artBase = (fn) => '/api/song/' + enc(fn) + '/art';
// Post-apply refresh — the grid's cache-buster idiom (`?v=`): re-src
// every rendered <img> pointing at this song's art with a fresh v so the
// new pick paints everywhere it's currently shown (grid card, drawer
// preview, list row) without a full reload.
function refreshArt(fn) {
const base = artBase(fn);
document.querySelectorAll('img').forEach((img) => {
const src = img.getAttribute('src') || '';
if (src.split('?')[0] === base) {
img.src = base + '?v=' + Date.now();
img.style.visibility = 'visible';
}
});
}
function ensureModal() {
let m = document.getElementById('v3-imgpick-modal');
if (m) return m;
const overlay = document.createElement('div');
overlay.id = 'v3-imgpick-overlay';
overlay.className = 'fixed inset-0 bg-black/60 z-[200] hidden';
overlay.addEventListener('click', close);
document.body.appendChild(overlay);
m = document.createElement('div');
m.id = 'v3-imgpick-modal';
// Appended after the overlay: same z tier, DOM order paints it above.
m.className = 'fixed inset-0 z-[200] hidden flex items-center justify-center p-4 pointer-events-none';
m.innerHTML = '<div id="v3-imgpick-panel" class="pointer-events-auto w-full max-w-2xl max-h-[85vh] bg-fb-sidebar border border-fb-border/50 rounded-xl shadow-2xl flex flex-col" role="dialog" aria-label="Change cover"></div>';
m.addEventListener('keydown', onKeydown);
document.body.appendChild(m);
return m;
}
function onKeydown(e) {
if (e.key === 'Escape') { e.stopPropagation(); close(); return; }
if (e.key !== 'Tab') return;
// Light focus trap: cycle within the panel (mirrors match-review).
const panel = document.getElementById('v3-imgpick-panel');
if (!panel) return;
// Only trap VISIBLE focusables: hidden tiles (?source=pack 404 →
// onerror .hidden, unloadable candidates, .hidden buttons) must never
// catch a Tab. offsetParent is null for display:none / .hidden.
const foci = Array.from(
panel.querySelectorAll('button:not(.hidden), input:not(.hidden), [tabindex="0"]'),
).filter((el) => el.offsetParent !== null);
if (!foci.length) return;
const first = foci[0], last = foci[foci.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
function close() {
if (_abort) { try { _abort.abort(); } catch (_) { /* already done */ } _abort = null; }
document.getElementById('v3-imgpick-modal')?.classList.add('hidden');
document.getElementById('v3-imgpick-overlay')?.classList.add('hidden');
_cur = null;
_busy = false;
if (_lastFocus && _lastFocus.isConnected) { try { _lastFocus.focus(); } catch (_) { /* */ } }
_lastFocus = null;
}
// One tile: a 6rem square art/icon face + a caption underneath.
function tileHtml(attrs, face, label, hidden) {
return '<button ' + attrs + ' class="group w-24 shrink-0 text-center' + (hidden ? ' hidden' : '') + '">' +
'<span class="w-24 h-24 rounded-lg overflow-hidden bg-fb-card border border-fb-border/50 hover:border-fb-primary/60 flex items-center justify-center">' + face + '</span>' +
'<span class="block text-xs text-fb-textDim group-hover:text-fb-text truncate pt-1">' + esc(label) + '</span></button>';
}
const imgFace = (src) => '<img src="' + esc(src) + '" alt="" loading="lazy" class="w-full h-full object-cover">';
const iconFace = (glyph) => '<span class="text-2xl text-fb-textDim">' + glyph + '</span>';
const SKELETON_TILE = '<span class="w-24 h-24 rounded-lg bg-fb-card animate-pulse shrink-0"></span>';
function render(panel) {
const fn = _cur.filename;
// Fresh ?v so a reopened picker never shows a stale "current".
const curSrc = artBase(fn) + '?v=' + Date.now();
panel.innerHTML =
'<div class="flex items-center justify-between gap-3 p-5 pb-3 border-b border-fb-border/40 shrink-0">' +
'<div class="min-w-0"><h3 class="text-lg font-semibold text-fb-text">Change cover</h3>' +
'<div class="text-xs text-fb-textDim truncate">' + esc(_cur.title || fn) + '</div></div>' +
'<button data-ip-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>' +
'<div class="p-5 flex flex-col sm:flex-row items-start gap-5 overflow-y-auto v3-scroll">' +
// Left: the current cover + its provenance.
'<div class="shrink-0">' +
'<img data-ip-current src="' + esc(curSrc) + '" alt="" class="w-24 h-24 rounded-lg object-cover bg-fb-card" onerror="this.style.visibility=\'hidden\'">' +
'<div class="pt-1 flex items-center gap-1.5">' +
'<span class="text-xs text-fb-textDim">Current</span>' +
'<span data-ip-prov class="hidden text-[0.625rem] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700"></span>' +
'</div></div>' +
// Right: the candidate tiles. First row acts instantly; CAA
// candidates land behind the one /art/candidates fetch.
'<div class="min-w-0 flex-1 space-y-3">' +
'<div class="flex flex-wrap gap-3">' +
tileHtml('data-ip-act="keep"', imgFace(curSrc), 'Current') +
// Pack tile renders instantly and self-hides when the song ships
// no art of its own (?source=pack 404s → img onerror); the
// candidates response reconciles it either way.
tileHtml('data-ip-act="pack"', imgFace(artBase(fn) + '?source=pack'), 'Pack original') +
tileHtml('data-ip-act="upload"', iconFace('⤒'), 'Upload') +
tileHtml('data-ip-act="url"', iconFace('🔗'), 'Paste URL') +
'</div>' +
'<div data-ip-caa>' +
'<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>' +
'<div data-ip-status class="hidden text-xs text-fb-accent"></div>' +
'</div></div>' +
'<input type="file" accept="image/*" data-ip-file class="hidden">';
wire(panel);
}
function wire(panel) {
panel.querySelector('[data-ip-close]')?.addEventListener('click', close);
// The pack tile self-hides when there is no pack art to show.
const packTile = panel.querySelector('[data-ip-act="pack"]');
const packImg = packTile ? packTile.querySelector('img') : null;
if (packImg) packImg.onerror = () => packTile.classList.add('hidden');
const file = panel.querySelector('[data-ip-file]');
file?.addEventListener('change', () => {
const f = file.files && file.files[0];
if (!f) return;
const rd = new FileReader();
rd.onload = (e) => apply('upload', e.target.result);
rd.readAsDataURL(f);
});
panel.querySelectorAll('[data-ip-act]').forEach((btn) => {
btn.addEventListener('click', async () => {
if (_busy) return;
const act = btn.getAttribute('data-ip-act');
if (act === 'keep') { close(); return; }
if (act === 'pack') { apply('pack'); return; }
if (act === 'upload') { file?.click(); return; }
if (act === 'url') {
// window.prompt is a silent no-op in Electron — use the
// project's injection-safe async modal; fall back to prompt
// only if it isn't loaded (mirrors other v3 callers' guard).
const ask = (typeof window.uiPrompt === 'function')
? window.uiPrompt({
title: 'Paste URL',
label: 'Paste an image link (http or https)',
okLabel: 'Set cover',
placeholder: 'https://…',
})
: Promise.resolve(window.prompt('Paste an image link (http or https)'));
const u = String((await ask) || '').trim();
if (u) apply('url', u);
}
});
});
panel.querySelector('[data-ip-close]')?.focus();
}
// 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.
function loadCandidates(panel) {
const fn = _cur.filename;
// Reopening without an intervening close() can leave a prior fetch in
// flight — cancel it so only the newest request settles the tiles.
if (_abort) { try { _abort.abort(); } catch (_) { /* already done */ } }
_abort = new AbortController();
fetch('/api/song/' + enc(fn) + '/art/candidates', { signal: _abort.signal })
.then((r) => (r.ok ? r.json() : null))
.then((body) => { if (_cur && _cur.filename === fn) patchCandidates(panel, body); })
.catch(() => { if (_cur && _cur.filename === fn) patchCandidates(panel, null); });
}
function patchCandidates(panel, body) {
const wrap = panel.querySelector('[data-ip-caa]');
if (!wrap) return;
const list = (body && body.candidates) || [];
// Reconcile the instant tiles with what the server actually knows.
const cur = list.find((c) => c.kind === 'current');
const badge = panel.querySelector('[data-ip-prov]');
if (badge && cur && PROV_LABEL[cur.provenance]) {
badge.textContent = PROV_LABEL[cur.provenance];
badge.classList.remove('hidden');
}
const packTile = panel.querySelector('[data-ip-act="pack"]');
if (packTile) packTile.classList.toggle('hidden', !list.some((c) => c.kind === 'pack'));
const caa = list.filter((c) => c.kind === 'caa' && c.thumb_url);
if (!caa.length) { wrap.innerHTML = ''; return; }
wrap.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim pb-2">Online covers</div>' +
'<div class="flex flex-wrap gap-3">' +
caa.map((c, i) => tileHtml(
'data-ip-cand="' + i + '"',
imgFace(c.thumb_url),
c.label || 'Cover')).join('') +
'</div>';
wrap.querySelectorAll('[data-ip-cand]').forEach((btn) => {
// A candidate whose thumb can't load isn't offerable — hide it
// rather than let a click apply an image nobody saw.
const img = btn.querySelector('img');
if (img) img.onerror = () => btn.classList.add('hidden');
btn.addEventListener('click', () => {
if (_busy) return;
const c = caa[Number(btn.getAttribute('data-ip-cand'))];
if (c) apply('url', c.thumb_url);
});
});
}
// Apply a pick through the EXISTING routes; silent on success (close +
// cache-busted refresh), inline note on failure (the modal stays open so
// another tile can be tried).
async function apply(kind, arg) {
const fn = _cur && _cur.filename;
if (!fn || _busy) return;
_busy = true;
let ok = false;
try {
let r = null;
if (kind === 'url') {
r = await fetch('/api/song/' + enc(fn) + '/art/url', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: arg }),
});
} else if (kind === 'upload') {
r = await fetch('/api/song/' + enc(fn) + '/art/upload', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: arg }),
});
} else if (kind === 'pack') {
r = await fetch('/api/art/' + enc(fn) + '/override', { method: 'DELETE' });
}
if (r && r.ok) {
// The art routes report soft failures as {error} bodies.
const body = await r.json().catch(() => ({}));
ok = !body.error;
}
} catch (_) { ok = false; }
_busy = false;
if (ok) { close(); refreshArt(fn); return; }
const status = document.querySelector('#v3-imgpick-panel [data-ip-status]');
if (status) {
status.textContent = 'Couldn’t set that cover — try another image.';
status.classList.remove('hidden');
}
}
function openImagePicker(opts) {
const filename = opts && opts.filename;
if (!filename) return;
_lastFocus = document.activeElement;
_cur = { filename: filename, title: (opts && opts.title) || filename };
_busy = false;
const m = ensureModal();
const panel = document.getElementById('v3-imgpick-panel');
render(panel);
m.classList.remove('hidden');
document.getElementById('v3-imgpick-overlay')?.classList.remove('hidden');
loadCandidates(panel);
}
window.__fbOpenImagePicker = openImagePicker;
})();
+41 -1
View File
@@ -122,7 +122,7 @@
the inline brand is the no-JS fallback. -->
<aside id="v3-sidebar" class="w-64 border-r border-fb-border/50 flex-col shrink-0 hidden md:flex">
<div id="v3-brand" class="p-6">
<span class="font-extrabold tracking-tight text-fb-text text-xl">fee<span class="text-fb-primary">[dB]</span>ack</span>
<img src="/static/v3/brand/feedback-logo-light.png" alt="fee[dB]ack" style="width:100%;height:auto;display:block">
</div>
<nav id="v3-nav" class="flex-1 overflow-y-auto px-3 pb-6 space-y-6" aria-label="Primary"></nav>
</aside>
@@ -429,6 +429,23 @@
</select>
</div>
</div>
<!-- Instrument pathway -->
<div class="fb-srow">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">Instrument pathway</div>
<div class="fb-srow-desc">Preferred path for the selected instrument. This is remembered per instrument profile.</div>
</div>
<div class="fb-srow-control">
<select id="setting-instrument-pathway" onchange="setInstrumentPathway(this.value)"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="songs">Songs</option>
<option value="practice">Practice</option>
<option value="learn">Learn</option>
<option value="studio">Studio</option>
</select>
</div>
</div>
<!-- Arrangement routes (naming mode) -->
<div class="fb-srow">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
@@ -771,6 +788,13 @@
</div>
<div class="text-[11px] text-gray-600 mt-1">What a confident match may fill in on its own — matches you confirm in the review queue always apply in full.</div>
</div>
<!-- Audio fingerprint (AcoustID) — opt-in, default OFF. Wired by match-review.js. -->
<div class="fb-srow-wide mb-1">
<div class="text-[10px] uppercase tracking-wide text-gray-500 mb-1">Audio fingerprint (AcoustID)</div>
<label class="flex items-center gap-2 text-xs text-gray-400 mb-1"><input type="checkbox" id="acoustid-enabled" class="rounded border-gray-600 bg-dark-700 text-accent"> Identify by audio — reads the recording itself for the exact version (studio vs live/extended)</label>
<input type="text" id="acoustid-api-key" placeholder="AcoustID application key" class="w-full bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
<div class="text-[11px] text-gray-600 mt-1">Opt-in. Get a free key at acoustid.org/new-application; the fpcalc (Chromaprint) binary must be on the server's PATH.</div>
</div>
<div class="grid grid-cols-2 gap-2 mb-1 text-xs text-gray-400 fb-srow-wide">
<label class="flex items-center gap-2">Review queue order
<select id="enrich-review-order" class="bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
@@ -785,6 +809,19 @@
<span id="enrich-status" class="text-xs text-gray-500"></span>
</div>
</div>
<!-- Artist pages (PR-B — wired by static/v3/match-review.js). Sits
beside the Metadata matching card; the Settings→Library tab
regroup is a separate PR. -->
<div class="fb-srow fb-srow-stack">
<div class="fb-srow-main">
<div class="fb-srow-title">Artist pages</div>
<div class="fb-srow-desc">A page for every artist in your library — their songs, albums and your practice progress, built entirely from your local collection. External links (official site, tour dates, videos, social) come from one MusicBrainz lookup per matched artist and always open in your browser — nothing plays in-app, and they stay off until you opt in.</div>
</div>
<div class="grid grid-cols-2 gap-2 mb-1 text-xs text-gray-400 fb-srow-wide">
<label class="flex items-center gap-2"><input type="checkbox" id="artist-pages-enabled" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Artist pages</label>
<label class="flex items-center gap-2"><input type="checkbox" id="artist-external-links" class="rounded border-gray-600 bg-dark-700 text-accent"> Show external links (opens your browser)</label>
</div>
</div>
<!-- Backup -->
<div class="fb-srow fb-srow-stack">
<div class="fb-srow-main">
@@ -1225,6 +1262,9 @@
<!-- Before songs.js: the songs toolbar calls the match-review chip hook
on build, so the module must already be registered. -->
<script src="/static/v3/match-review.js"></script>
<!-- Before songs.js: the drawer art click + card ⋮ "Change cover…" open
the cover picker (window.__fbOpenImagePicker). -->
<script src="/static/v3/image-picker.js"></script>
<script src="/static/v3/songs.js"></script>
<script src="/static/v3/lessons.js"></script>
<script src="/static/v3/dashboard.js"></script>
+152 -6
View File
@@ -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;
}
}
@@ -286,7 +346,8 @@
'<div class="flex items-center justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
'<div class="flex items-center gap-3">' +
(_single ? '' : '<button data-mr-reject class="text-sm text-fb-textDim hover:text-fb-text">Not a match</button>') +
'<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button></div>' +
'<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button>' +
'<button data-mr-identify class="text-sm text-fb-primary hover:text-fb-primaryHi" title="Fingerprint this song\'s audio to find the exact recording">Identify by audio</button></div>' +
'<div class="flex items-center gap-2">' +
(_single ? '' : '<button data-mr-skip class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Skip</button>') +
((song.candidates || []).length ? '<button data-mr-accept class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Use selected</button>' : '') +
@@ -335,6 +396,7 @@
const go = () => runSearch(panel, song);
panel.querySelector('[data-mr-search-go]')?.addEventListener('click', go);
input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } });
panel.querySelector('[data-mr-identify]')?.addEventListener('click', () => runIdentify(panel, song));
}
// Silent-on-success: the chart just leaves the queue and the next one
@@ -387,6 +449,52 @@
});
}
// "Identify by audio" — fingerprint the song's OWN master audio (AcoustID)
// and render the hits into the same search-results area. The reliable path
// when text search can't tell the studio take from live/comp versions.
async function runIdentify(panel, song) {
const out = panel.querySelector('[data-mr-search-results]');
const sp = panel.querySelector('[data-mr-search-panel]');
if (!out) return;
sp?.classList.remove('hidden'); // give the results somewhere to render
out.innerHTML = '<p class="text-xs text-fb-textDim">Fingerprinting audio…</p>';
let body = null, status = 0;
try {
const r = await fetch('/api/enrichment/identify/' + enc(song.filename), { method: 'POST' });
status = r.status;
body = await r.json().catch(() => null);
} catch (_) { /* falls through to the no-results line */ }
// Honest states — never a fake hit.
if (status === 412 || (body && body.needs_setup)) {
out.innerHTML = '<p class="text-xs text-fb-textDim">Audio identification is off — enable AcoustID and add a free API key to use it.</p>';
return;
}
if (status === 404) {
out.innerHTML = "<p class=\"text-xs text-fb-textDim\">No full-mix audio to fingerprint for this song.</p>";
return;
}
if (status === 503) {
out.innerHTML = '<p class="text-xs text-fb-textDim">Audio identification is unavailable right now — try again.</p>';
return;
}
const cands = (body && body.candidates) || [];
if (!cands.length) {
out.innerHTML = '<p class="text-xs text-fb-textDim">No fingerprint match — try text search.</p>';
return;
}
out.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Fingerprint matches (AcoustID)</div>' +
cands.map((c, i) => candRowHtml(song, c, i, false)).join('');
out.querySelectorAll('[data-mr-cand]').forEach((btn) => {
btn.addEventListener('click', async () => {
const cand = cands[Number(btn.getAttribute('data-mr-cand'))];
if (!cand) return;
await post('/api/enrichment/review/' + enc(song.filename) + '/pick',
{ candidate: cand });
settle(song);
});
});
}
async function post(url, payload) {
try {
await fetch(url, {
@@ -415,14 +523,27 @@
['enrich-apply-year', 'enrich_apply_year'],
['enrich-apply-genres', 'enrich_apply_genres'],
['enrich-apply-art', 'enrich_apply_art'],
// Artist pages (PR-B): the page itself — local-only, default ON.
['artist-pages-enabled', 'artist_pages_enabled'],
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
if (!toggles.length && !sel && !btn) return;
// Default-OFF toggles load with the opposite absent-key semantic
// (checked only when explicitly true): the external-links row is
// opt-IN per the dev-chat thread.
const optInToggles = [
['artist-external-links', 'artist_external_links'],
// Audio fingerprinting is opt-in (needs a key + fpcalc), default OFF.
['acoustid-enabled', 'acoustid_enabled'],
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
const acoustidKeyEl = document.getElementById('acoustid-api-key');
if (!toggles.length && !optInToggles.length && !sel && !btn) return;
(async () => {
try {
const r = await fetch('/api/settings');
if (r.ok) {
const cfg = await r.json();
for (const [el, key] of toggles) el.checked = cfg[key] !== false;
for (const [el, key] of optInToggles) el.checked = cfg[key] === true;
if (acoustidKeyEl) acoustidKeyEl.value = cfg.acoustid_api_key || '';
if (sel) {
const t = Number(cfg.enrich_auto_threshold);
const want = Number.isFinite(t) ? t : 0.9;
@@ -442,11 +563,12 @@
refreshChip(); // also fills #enrich-status
})();
const save = (key, value) => post('/api/settings', { [key]: value });
for (const [el, key] of toggles) {
for (const [el, key] of toggles.concat(optInToggles)) {
el.addEventListener('change', () => save(key, !!el.checked));
}
sel?.addEventListener('change', () => save('enrich_auto_threshold', Number(sel.value)));
order?.addEventListener('change', () => save('enrich_review_order', order.value));
acoustidKeyEl?.addEventListener('change', () => save('acoustid_api_key', acoustidKeyEl.value.trim()));
btn?.addEventListener('click', async () => {
await post('/api/enrichment/kick');
const line = document.getElementById('enrich-status');
@@ -455,10 +577,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;
+28 -3
View File
@@ -211,7 +211,12 @@
'<div class="flex items-center justify-between mb-6 gap-3">' +
'<h2 class="text-3xl font-bold text-fb-text truncate">' + (isAlbum ? '💿 ' : '') + esc(pl.name) + '</h2>' +
'<div class="flex gap-2 shrink-0 items-center">' +
(pl.songs.length ? '<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play ' + (isAlbum ? 'album' : 'all') + '</button>' : '') +
(pl.songs.length
? '<button id="v3-pl-shuffle" class="px-2 py-2 rounded-md" aria-pressed="false">' +
'<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/></svg>' +
'</button>' +
'<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play ' + (isAlbum ? 'album' : 'all') + '</button>'
: '') +
(isSystem ? '' :
'<button id="v3-pl-cover" class="text-sm text-fb-textDim hover:text-fb-text px-2">Cover</button>' +
(pl.cover_url ? '<button id="v3-pl-cover-rm" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Remove cover</button>' : '') +
@@ -226,6 +231,26 @@
: '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
'</div>';
root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists);
// Shuffle toggle (crossing arrows, next to Play). Persisted globally —
// one preference, not per playlist. The queue is shuffled once when
// Play starts (playQueue.start's shuffle opt); the stored playlist
// order is never touched.
const shuffleBtn = root.querySelector('#v3-pl-shuffle');
const shuffleOn = () => { try { return localStorage.getItem('v3PlaylistShuffle') === '1'; } catch (_) { return false; } };
const paintShuffle = () => {
if (!shuffleBtn) return;
const on = shuffleOn();
shuffleBtn.className = on
? 'px-2 py-2 rounded-md border border-fb-primary bg-fb-primary hover:bg-fb-primaryHi text-white'
: 'px-2 py-2 rounded-md border border-fb-border text-fb-textDim hover:text-fb-text';
shuffleBtn.title = on ? 'Shuffle: on' : 'Shuffle: off';
shuffleBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
};
paintShuffle();
shuffleBtn?.addEventListener('click', () => {
try { localStorage.setItem('v3PlaylistShuffle', shuffleOn() ? '0' : '1'); } catch (_) { /* private mode */ }
paintShuffle();
});
// Play all: start the play-queue with this playlist's songs (auto-advances
// track to track). Falls back to playing the first song on an older core
// without the queue, so the button always does something. An ALBUM plays
@@ -244,8 +269,8 @@
if (!files.length) return;
if (window.feedBack && window.feedBack.playQueue) {
window.feedBack.playQueue.start(files, isAlbum
? { source: pl.name, arrangements: arrs }
: { source: pl.name });
? { source: pl.name, arrangements: arrs, shuffle: shuffleOn() }
: { source: pl.name, shuffle: shuffleOn() });
} else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
});
const listEl = root.querySelector('#v3-pl-songs');
+1 -1
View File
@@ -28,7 +28,7 @@
var RESET_MAP = {
gameplay: {
server: ['master_difficulty', 'av_offset_ms', 'miss_penalty',
'fail_behavior', 'countdown_before_song', 'default_arrangement'],
'fail_behavior', 'countdown_before_song', 'default_arrangement', 'pathway'],
local: ['lefty', 'autoplayExit', 'showUpNext', 'confirmExitSong', 'arrangementNamingMode', 'countdownBeforeSong'],
after: function () {
// Left-handed is held on the highway object, not re-derived
+11 -1
View File
@@ -192,6 +192,9 @@
}
// ── Topbar ───────────────────────────────────────────────────────────---
// Funding cleared to come back online 2026-06-30 (offending functionality
// removed). feedBack-branded Patreon page.
const PATREON_URL = 'https://patreon.com/got_feedback';
function renderTopbar() {
const bar = document.getElementById('v3-topbar');
if (!bar) return;
@@ -209,6 +212,12 @@
'<input id="v3-search" type="search" placeholder="Search songs…" aria-label="Search songs" ' +
'class="w-full bg-gray-800/50 border border-gray-700 rounded-md pl-10 pr-4 py-2 text-sm ' +
'text-fb-text placeholder-fb-textDim focus:border-fb-primary focus:ring-1 focus:ring-fb-primary outline-none"></div>' +
// Support Us! — stays on this top utility row (NOT the title row),
// pushed to the right with ml-auto; hidden on the smallest widths.
'<a href="' + PATREON_URL + '" target="_blank" rel="noopener" class="ml-auto ' +
'hidden sm:inline-flex items-center gap-2 bg-fb-accent hover:bg-red-600 text-white text-sm font-medium px-4 py-2 rounded-md shadow-lg shadow-fb-accent/20 transition-colors">' +
'<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M14.8 3c-3 0-5.4 2.4-5.4 5.4S11.8 13.9 14.8 13.9 20.2 11.5 20.2 8.4 17.8 3 14.8 3zM3.8 3h3.4v18H3.8z"/></svg>' +
'Support Us!</a>' +
'</div>' +
// Row 2 — page header: title + ONLY the tuner/instrument/profile
// badge cluster on the same line as the header.
@@ -329,7 +338,8 @@
// ── Boot ────────────────────────────────────────────────────────────────
async function boot() {
if (window.fbBrand) window.fbBrand.renderWordmark(document.getElementById('v3-brand'), { size: 'text-xl' });
var _v3brand = document.getElementById('v3-brand');
if (_v3brand) _v3brand.innerHTML = '<img src="/static/v3/brand/feedback-logo-light.png" alt="fee[dB]ack" style="width:100%;height:auto;display:block">';
renderSidebar();
renderTopbar();
ensureBackdrop();
+759 -78
View File
File diff suppressed because it is too large Load Diff