/*
* fee[dB]ack v0.3.0 — player profile: first-run onboarding overlay, the topbar
* profile badge, and the #v3-profile screen.
*
* Vanilla JS (constitution P-II). Reads /api/profile + /api/profile/progress
* (one call for the whole badge). Degrades gracefully when stats endpoints
* (prompt 14) aren't present yet. window.v3Onboarding is defined synchronously
* so the shell boot (shell.js) can call it regardless of script order.
*/
(function () {
'use strict';
let _profile = null; // {display_name, avatar_url, player_hash, onboarded}
let _progress = null; // {level, xp, xp_in_level, xp_to_next, current_streak, best_streak}
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
function avatarImg(url, sizeCls, shape) {
shape = shape || 'rounded-full';
if (url) {
return ' ';
}
// Neutral fallback glyph.
return '' +
'' +
' ';
}
// ── API ──────────────────────────────────────────────────────────────────
async function fetchProfile() {
try { const r = await fetch('/api/profile'); if (r.ok) _profile = await r.json(); } catch (e) { /* P15 */ }
return _profile;
}
async function fetchProgress() {
try { const r = await fetch('/api/profile/progress'); if (r.ok) _progress = await r.json(); } catch (e) { /* P15 */ }
return _progress;
}
// ── Topbar profile badge ───────────────────────────────────────────────--
function renderBadge() {
const host = document.getElementById('v3-badge-profile');
if (!host) return;
if (!_profile || !_profile.onboarded) { host.innerHTML = ''; return; }
const p = _progress || { current_streak: 0 };
// Progression (spec 010): the badge shows Mastery Rank + Decibels.
// Layout still matches the Google Stitch "Profile Card Component"
// (dark rounded card, white avatar tile, flame + "N DAYS"), but the
// 6-bar equalizer now meters CURRENT CHALLENGE-SET progress (completed
// challenges across all paths' active sets / required total) and the
// big number is the Mastery Rank, with the spendable dB balance beside.
const prog = (window.v3Progression && window.v3Progression.get()) || null;
const rank = prog ? prog.mastery_rank : 0;
const balance = prog && prog.wallet ? prog.wallet.balance : 0;
let challengesDone = 0, challengesRequired = 0;
((prog && prog.paths) || []).forEach((path) => {
if (path.next) {
challengesDone += Math.min(path.next.completed, path.next.required);
challengesRequired += path.next.required;
}
});
const flame = '' +
' ';
const HEIGHTS = ['h-2', 'h-4', 'h-8', 'h-6', 'h-10', 'h-12'];
const FILL = ['#3B82F6', '#22C55E', '#FACC15', '#F97316', '#D1D5DB', '#22C55E'];
const filled = challengesRequired > 0
? Math.max(0, Math.min(6, Math.round((challengesDone / challengesRequired) * 6))) : 0;
const bars = HEIGHTS.map((h, i) =>
'
').join('');
host.innerHTML =
'' +
'' +
(_profile.avatar_url
? '
'
: '
') +
'
' +
'' +
'
' + flame +
'
' +
'
' +
'
' +
'Rank: ' +
'' + rank + '
' +
'
' + bars + '
' +
'
' + Number(balance).toLocaleString() + ' dB ' +
'
';
// Equipped avatar frame (spec 010 cosmetics).
if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') {
window.v3Theme.applyFrame(host.querySelector('[data-v3-avatar-tile]'));
}
const btn = host.querySelector('[data-v3-open-profile]');
if (btn) btn.addEventListener('click', () => window.showScreen && window.showScreen('v3-profile'));
}
// ── Profile screen (#v3-profile) ────────────────────────────────────────-
// The screen is tabbed exactly like the v3 Settings page (.fb-tabbar /
// .fb-tab[data-tab] / .fb-tabpanel[data-tab]; achievements epic): a
// **Profile** (main) tab and an **Achievements** tab. Core only ships the
// shell + two mount points; the bundled achievements plugin renders the
// Feats trophy shelf (#v3-profile-feats-slot, earned-only) and the full
// competency catalogue (#v3-profile-achievements-mount) into them on the
// `v3:profile-rendered` event below. Active tab persists in localStorage,
// mirroring settings.js.
var PROFILE_TAB_KEY = 'v3-profile-tab';
var PROFILE_DEFAULT_TAB = 'profile';
function activateProfileTab(tab) {
var root = document.getElementById('v3-profile');
if (!root) return;
var bar = root.querySelector('#v3-profile-tabbar');
var tabs = [];
if (bar) bar.querySelectorAll('.fb-tab').forEach(function (b) { if (b.dataset.tab) tabs.push(b.dataset.tab); });
if (tabs.indexOf(tab) === -1) tab = tabs.length ? tabs[0] : PROFILE_DEFAULT_TAB;
root.querySelectorAll('#v3-profile-tabbar .fb-tab').forEach(function (b) {
b.classList.toggle('active', b.dataset.tab === tab);
});
root.querySelectorAll('.fb-tabpanel').forEach(function (p) {
p.classList.toggle('active', p.dataset.tab === tab);
});
try { localStorage.setItem(PROFILE_TAB_KEY, tab); } catch (_) { /* private mode */ }
}
function wireProfileTabs() {
var bar = document.getElementById('v3-profile-tabbar');
if (bar && bar.dataset.wired !== '1') {
bar.dataset.wired = '1';
bar.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('.fb-tab') : null;
if (btn && btn.dataset.tab) activateProfileTab(btn.dataset.tab);
});
}
var saved = PROFILE_DEFAULT_TAB;
try { saved = localStorage.getItem(PROFILE_TAB_KEY) || PROFILE_DEFAULT_TAB; } catch (_) { /* noop */ }
activateProfileTab(saved);
}
function renderProfileScreen() {
const root = document.getElementById('v3-profile');
if (!root) return;
const p = _progress || { current_streak: 0, best_streak: 0 };
const name = (_profile && _profile.display_name) || 'Player';
// Progression (spec 010): the profile header shows Mastery Rank,
// per-path levels, and Decibels (balance + lifetime) — the old
// XP-level meter is replaced by the rank/challenge system.
const prog = (window.v3Progression && window.v3Progression.get()) || null;
const rank = prog ? prog.mastery_rank : 0;
const wallet = (prog && prog.wallet) || { balance: 0, lifetime_db: 0 };
const pathChips = ((prog && prog.paths) || []).map((path) =>
'' +
esc(path.name) + ' Lv ' + path.level + ' ').join(' ');
// Header card (Profile main tab).
const headerCard =
'' +
'
' +
avatarImg(_profile && _profile.avatar_url, 'w-24 h-24') + ' ' +
'
' +
'
' + esc(name) + ' ' +
'
' +
'Mastery Rank ' + rank + ' ' +
'' + Number(wallet.balance).toLocaleString() + ' dB ' +
'' + Number(wallet.lifetime_db).toLocaleString() + ' dB lifetime ' +
'🔥 ' + (p.current_streak || 0) + '-day streak ' +
'Best: ' + (p.best_streak || 0) + '
' +
(pathChips ? '
' + pathChips + '
' : '') +
'
' +
'Edit name & avatar ' +
'View challenges & quests → ' +
'
';
// Per-song bests — top scored songs from /api/stats/top, filled by
// renderBests() after innerHTML is set. The placeholder text shows
// during load and when nothing's been scored yet.
const bestsCard =
'' +
'
Your best scores ' +
'
Play a song to start tracking your accuracy and best scores.
' +
'
';
const playerIdFooter = (_profile && _profile.player_hash
? 'player id ' + esc(_profile.player_hash.slice(0, 12)) + '
'
: '');
root.innerHTML =
'' +
'
' +
'Profile ' +
'Achievements ' +
'
' +
// ── Profile (main) panel ──────────────────────────────────────────
'
' +
'
' +
headerCard +
bestsCard +
// Passport wall — rendered by the career plugin on
// v3:profile-rendered (absent-not-empty: nothing shows until a
// passport exists).
'
' +
// Feats of Power trophy shelf — rendered by the achievements plugin
// (earned Feats only; hidden-until-earned, so empty when none).
'
' +
playerIdFooter +
'
' +
// ── Achievements panel ────────────────────────────────────────────
'
' +
'
' +
'
Install the Achievements plugin to track your skill milestones.
' +
'
' +
'
';
const edit = root.querySelector('[data-v3-edit-profile]');
if (edit) edit.addEventListener('click', () => show(_profile, { editing: true }));
const openProgress = root.querySelector('[data-v3-open-progress]');
if (openProgress) openProgress.addEventListener('click', () => window.showScreen && window.showScreen('v3-progress'));
if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') {
window.v3Theme.applyFrame(root.querySelector('[data-v3-avatar-frame]'));
}
wireProfileTabs();
renderBests();
// Tell the achievements plugin (or any profile consumer) the shell +
// mount points exist now, so it can (re)inject on every profile entry —
// innerHTML above wipes prior injected content. Mirrors
// `v3:settings-rendered`. Harmless if no listener is attached.
try { document.dispatchEvent(new CustomEvent('v3:profile-rendered')); } catch (_) { /* noop */ }
}
// Fill the "Your best scores" panel from /api/stats/top (top scored songs,
// best first). Leaves the placeholder text in place on error / no scores so
// a fresh profile still reads sensibly. Accuracy is 0–1 (matches the library
// grid + dashboard badges).
async function renderBests() {
const host = document.getElementById('v3-profile-bests');
if (!host) return;
let rows = [];
try { const r = await fetch('/api/stats/top?limit=5'); if (r.ok) rows = await r.json(); } catch (e) { /* P15 — keep placeholder */ }
if (!Array.isArray(rows) || !rows.length) return; // keep the placeholder text
const accColor = (a) => (a >= 0.9 ? 'text-fb-good' : a >= 0.5 ? 'text-fb-mid' : 'text-fb-low');
host.innerHTML =
'' + rows.map((s, i) => {
const acc = Number(s.best_accuracy) || 0;
const pct = Math.floor(acc * 100);
const score = Number(s.best_score) || 0;
return '' +
'' + (i + 1) + ' ' +
'' +
'' + esc(s.title || s.filename) + ' ' +
(s.artist ? '' + esc(s.artist) + ' ' : '') +
' ' +
'' +
'' + pct + '% ' +
'' + score.toLocaleString() + ' pts ' +
' ';
}).join('') + ' ';
// Click a row to play that song (default arrangement).
host.querySelectorAll('[data-fn]').forEach((li) => {
li.addEventListener('click', () => {
const fn = li.getAttribute('data-fn');
if (fn && typeof window.playSong === 'function') window.playSong(encodeURIComponent(fn));
});
});
}
// ── First-run onboarding (and edit) overlay ───────────────────────────────
// First-run is a 3-step flow (spec 010): 1) name + avatar, 2) pick one or
// more instrument paths, 3) the calibration challenge offer (play the
// diagnostic sloppak at 100% — or skip and reach Mastery Rank 1 anyway).
// The profile POST always lands before the step-3 choice so onboarded=1 is
// never blocked by the calibration decision. Editing keeps the single form.
// Run the input-device setup wizard (the input_setup plugin's
// `input-calibration` domain) for the chosen instrument paths — BETWEEN path
// selection and the calibration challenge — so the diagnostic runs against a
// calibrated input. Capability-idiomatic: dispatch `run` (fire-and-launch)
// and await the `calibration-done` event. Degrades gracefully when the
// plugin/runtime is absent so onboarding can never be stranded.
// Wait (bounded) for the bundled input_setup plugin to finish registering
// its input-calibration owner. Plugins load asynchronously, so onboarding
// can reach this step before the plugin is ready — without this wait the
// mandatory wizard is skipped by a load-order race (it dispatches, gets a
// no-owner outcome, and falls through to the calibration challenge). The
// public global is set at the end of the plugin's screen.js, after the
// owner is registered, so it is a reliable readiness signal.
function waitForInputSetup(timeoutMs) {
const ready = () => !!(window.feedBackInputSetup && typeof window.feedBackInputSetup.launch === 'function');
return new Promise((resolve) => {
if (ready()) { resolve(true); return; }
const t0 = Date.now();
const iv = setInterval(() => {
if (ready()) { clearInterval(iv); resolve(true); }
else if (Date.now() - t0 >= timeoutMs) { clearInterval(iv); resolve(false); }
}, 100);
});
}
async function runInputSetup(paths) {
const instruments = (Array.isArray(paths) ? paths : []).map((p) => String(p).toLowerCase());
if (!instruments.length) return;
// Don't let a plugin-load race skip the mandatory input-setup step.
if (!(await waitForInputSetup(8000))) return;
// Hide this onboarding modal while the input-setup wizard (its own
// full-screen overlay) runs on top. Otherwise both stay stacked, and when
// the note-detect calibration wizard minimizes to expose the Tuner the
// onboarding modal shows through behind the tuner. Restored in `finally`
// before we advance to the calibration-challenge step.
const ob = document.getElementById('v3-onboarding');
const obPrevDisplay = ob ? ob.style.display : '';
if (ob) ob.style.display = 'none';
const restoreOnboarding = () => {
const o = document.getElementById('v3-onboarding');
if (o) o.style.display = obPrevDisplay;
};
try {
const caps = window.feedBack && window.feedBack.capabilities;
if (!caps || typeof caps.command !== 'function') {
try { await window.feedBackInputSetup.launch(instruments); } catch (e) { /* proceed */ }
return;
}
await new Promise((resolve) => {
let settled = false;
let unsub = null;
const done = () => { if (settled) return; settled = true; try { unsub && unsub(); } catch (e) { /* noop */ } resolve(); };
try { unsub = typeof caps.subscribe === 'function' ? caps.subscribe('input-calibration:calibration-done', done) : null; } catch (e) { unsub = null; }
// `run` is fire-and-launch; completion arrives via the event above.
// A non-handled outcome (no owner / plugin absent / error) means
// nothing was launched, so proceed immediately.
caps.command('input-calibration', 'run', { requester: 'onboarding', payload: { instruments } })
.then((r) => { if (!r || r.outcome !== 'handled') done(); })
.catch(() => done());
});
} finally {
restoreOnboarding();
}
}
function show(profile, opts) {
opts = opts || {};
const editing = !!opts.editing;
document.getElementById('v3-onboarding')?.remove();
// The amp-sim opt-in step (step 5) only exists in the desktop app — the
// pure-web build has no native amp sims to monitor through, so the step
// is skipped there (calibration is the last step at index 5 on web, 6 on
// desktop). See feedBack-desktop#46.
const isDesktop = !!window.feedBackDesktop;
const lastStep = isDesktop ? 6 : 5;
const stepDots = editing ? '' :
'' +
Array.from({ length: lastStep }, (_, i) => i + 1).map((n) => ' ').join('') +
'
';
const overlay = document.createElement('div');
overlay.id = 'v3-onboarding';
overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
overlay.innerHTML =
'' +
'
' +
'
' + (window.fbBrand ? window.fbBrand.wordmarkHTML({ size: 'text-2xl' }) : 'fee[dB]ack') + '
' +
'
' + (editing ? 'Edit your player profile' : 'Set up your player profile') + '
' +
stepDots + '
' +
// Step 1 — name + avatar (the original form, unchanged DOM).
'
' +
'
Display name ' +
'
' +
'
Avatar ' +
'
' +
'
' +
'Upload your own ' +
' ' +
'
' +
// Step 2 — song directory (where the user's songs live).
'
' +
'
Song directory ' +
'
Choose the folder where your songs are stored. We’ll scan it to build your library. You can change this later in Settings.
' +
'
' +
' ' +
'Browse… ' +
'
' +
// Step 3 — Achievements wall opt-in (first-run only; default OFF).
'
' +
'
Feats of Power ' +
'
As you practise you’ll earn rare Feats of Power — silly, bombastic activity trophies. Want to show them off on the public Feats wall ?
' +
'
' +
' ' +
'Share my Feats on the wall' +
'Publishes only your display name and the Feats you earn — never songs, skills, or scores. You can change this any time in Settings, and remove yourself with one click. ' +
' ' +
'
Leave it unticked to keep everything private. This is off by default.
' +
// Step 4 — instrument paths (first-run only; tiles filled on entry).
'
' +
'
Pick your instrument path(s) ' +
'
Each path levels up by completing challenges — together they make up your Mastery Rank. You can add more later.
' +
'
' +
// Step 5 — amp-sim opt-in (DESKTOP ONLY; default OFF / own-rig first).
// Hidden div is always present in the DOM; setStep only navigates to
// it on desktop. See feedBack-desktop#46.
'
' +
'
How do you want to hear yourself? ' +
'
fee[dB]ack can run your guitar through built-in amp simulations (NAM / IRs / plugins) so you hear a processed tone. If you already play through your own amp or rig , leave this off — you’ll get clean, silent monitoring and never an idle buzz.
' +
'
' +
' ' +
'Use in-app amp simulations' +
'Loads your saved tone chain for monitoring. You can change this any time in the desktop Audio settings. ' +
' ' +
'
Leave it unticked if you monitor through your own gear. This is off by default.
' +
// Step 6 — calibration offer (first-run only).
'
' +
'
Calibration challenge ' +
'
Prove your setup: play the fee[dB]ack Diagnostic with note detection and finish at 100% accuracy to reach Mastery Rank 1 .
' +
'
Not ready? Skip it and you’ll start at Rank 1 anyway — you can still play it later from the Progress screen.
' +
'
' +
'
' +
(editing ? 'Cancel ' : '') +
'Skip for now ' +
'' +
(editing ? 'Save' : 'Next') + '
';
document.body.appendChild(overlay);
const nameEl = overlay.querySelector('#v3-ob-name');
const grid = overlay.querySelector('#v3-ob-avatars');
const submit = overlay.querySelector('#v3-ob-submit');
const errEl = overlay.querySelector('#v3-ob-error');
const fileEl = overlay.querySelector('#v3-ob-upload');
const preview = overlay.querySelector('#v3-ob-preview');
const skipBtn = overlay.querySelector('#v3-ob-skip');
let selected = null; // { type:'default', value } | { type:'upload', value:url }
let step = 1; // first-run wizard step (editing stays on 1)
let songDir = ''; // step-2 song directory pick
let selectedPaths = []; // step-3 picks
let pathsAvailable = false; // any tiles rendered? (false → don't strand the user)
let diagnosticFilename = null; // from /api/progression (step-4 "Play it now")
const songDirEl = overlay.querySelector('#v3-ob-songdir');
const songDirBrowse = overlay.querySelector('#v3-ob-songdir-browse');
if (editing && profile) {
nameEl.value = profile.display_name || '';
}
function refreshSubmit() {
if (editing || step === 1) {
// First-run onboarding requires picking an avatar. When editing an
// existing profile, a name-only change is allowed: leaving `selected`
// null omits `avatar` from the POST, and the server keeps the current
// one — including a custom upload that isn't in the bundled grid (so
// Save no longer stays disabled for those).
const haveAvatar = !!selected || (editing && profile && !!profile.avatar_url);
submit.disabled = !(nameEl.value.trim().length >= 1 && haveAvatar);
} else if (step === 2) {
// Song directory — require a non-empty path to proceed; "Skip
// for now" is available for users who'll set it later.
submit.disabled = !songDir.trim();
} else if (step === 3) {
// Achievements opt-in — either choice is valid; always enabled.
submit.disabled = false;
} else if (step === 4) {
// ≥1 path required — unless none could be offered (offline /
// empty content), where blocking would strand onboarding.
submit.disabled = pathsAvailable && selectedPaths.length < 1;
} else {
submit.disabled = false;
}
}
function setStep(n) {
step = n;
errEl.classList.add('hidden');
for (let i = 1; i <= 6; i++) {
overlay.querySelector('#v3-ob-step' + i).classList.toggle('hidden', i !== n);
}
overlay.querySelectorAll('#v3-ob-dots [data-dot]').forEach((d) => {
d.classList.toggle('bg-fb-primary', Number(d.getAttribute('data-dot')) <= n);
d.classList.toggle('bg-fb-border', Number(d.getAttribute('data-dot')) > n);
});
const subtitle = overlay.querySelector('#v3-ob-subtitle');
if (subtitle) {
subtitle.textContent = n === 1 ? 'Set up your player profile'
: n === 2 ? 'Point us at your songs'
: n === 3 ? 'Feats of Power (optional)'
: n === 4 ? 'Choose your instrument paths'
: n === 5 ? 'How do you want to monitor?'
: 'One last thing — calibrate your setup';
}
submit.textContent = n === 6 ? 'Play it now' : 'Next';
// Skip is offered on the song-directory step (configure later) and
// the calibration challenge (the last step).
skipBtn.classList.toggle('hidden', !(n === 2 || n === 6));
refreshSubmit();
}
async function loadPathTiles() {
const host = overlay.querySelector('#v3-ob-paths');
let available = [];
try {
const r = await fetch('/api/progression');
if (r.ok) {
const data = await r.json();
diagnosticFilename = (data.onboarding || {}).diagnostic_filename || null;
available = (data.available_paths || []).concat(
(data.paths || []).map((p) => ({ id: p.id, name: p.name })));
}
} catch (e) { /* offline — leave empty, Next stays disabled */ }
pathsAvailable = available.length > 0;
host.innerHTML = available.map((p) =>
'' + esc(p.name) + ' ').join('') ||
'Couldn’t load instrument paths — you can pick them later on the Progress screen.
';
refreshSubmit();
host.querySelectorAll('[data-ob-path]').forEach((b) => {
b.addEventListener('click', () => {
const id = b.getAttribute('data-ob-path');
const idx = selectedPaths.indexOf(id);
if (idx >= 0) selectedPaths.splice(idx, 1); else selectedPaths.push(id);
b.classList.toggle('ring-2', idx < 0);
b.classList.toggle('ring-fb-primary', idx < 0);
refreshSubmit();
});
});
}
nameEl.addEventListener('input', refreshSubmit);
// Reflect the pre-filled name + existing avatar immediately so an edit
// with no changes can still Save (button doesn't stay disabled until
// the user touches the form).
refreshSubmit();
function selectTile(el, choice) {
selected = choice;
grid.querySelectorAll('[data-av]').forEach((t) => t.classList.remove('ring-2', 'ring-fb-primary'));
if (el) el.classList.add('ring-2', 'ring-fb-primary');
refreshSubmit();
}
// Load bundled defaults. The default avatar's stored value is its
// bundled FILENAME (e.g. "pick.svg"), which the server validates
// against the bundled set.
fetch('/api/profile/avatars').then((r) => r.ok ? r.json() : []).then((list) => {
grid.innerHTML = (list || []).map((a) =>
'' +
' ').join('');
grid.querySelectorAll('[data-av]').forEach((b) => {
b.addEventListener('click', () => selectTile(b, { type: 'default', value: b.dataset.name }));
});
// Pre-select the current avatar when editing.
if (editing && profile && profile.avatar_url) {
const match = Array.from(grid.querySelectorAll('[data-av]')).find((b) => b.dataset.url === profile.avatar_url);
if (match) selectTile(match, { type: 'default', value: match.dataset.name });
}
}).catch(() => { /* offline / server restart — leave the avatar grid empty rather than throw */ });
// Upload handler — base64 to /api/profile/avatar (mirrors art upload).
overlay.querySelector('#v3-ob-upload-btn').addEventListener('click', () => fileEl.click());
fileEl.addEventListener('change', () => {
const f = fileEl.files && fileEl.files[0];
if (!f) return;
if (f.size > 6 * 1024 * 1024) { showErr('Image too large (max 6 MB).'); return; }
const reader = new FileReader();
reader.onload = async () => {
try {
const res = await fetch('/api/profile/avatar', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: reader.result }),
});
const body = await res.json();
if (!res.ok) { showErr(body.error || 'Upload failed.'); return; }
preview.innerHTML = ' ';
grid.querySelectorAll('[data-av]').forEach((t) => t.classList.remove('ring-2', 'ring-fb-primary'));
selected = { type: 'upload', value: body.url };
refreshSubmit();
} catch (e) { showErr('Upload failed.'); }
};
reader.readAsDataURL(f);
});
function showErr(msg) { errEl.textContent = msg; errEl.classList.remove('hidden'); }
if (editing) overlay.querySelector('#v3-ob-cancel')?.addEventListener('click', () => overlay.remove());
// ── Song directory (step 2) ──────────────────────────────────────────
if (songDirEl) {
songDirEl.addEventListener('input', () => { songDir = songDirEl.value.trim(); refreshSubmit(); });
}
// Native folder picker on desktop; web users type/paste the path.
const _desktop = window.feedBackDesktop;
if (songDirBrowse && _desktop && typeof _desktop.pickDirectory === 'function') {
songDirBrowse.classList.remove('hidden');
songDirBrowse.addEventListener('click', async () => {
try {
const picked = await _desktop.pickDirectory();
if (picked) { songDirEl.value = picked; songDir = picked; refreshSubmit(); }
} catch (e) { /* user cancelled / unavailable */ }
});
}
// Save the song directory to settings + kick a library scan so the
// user's songs appear. Throws (with a message) on an invalid folder.
async function saveSongDir() {
const dir = ((songDirEl && songDirEl.value) || '').trim();
if (!dir) return; // skipped — leave unconfigured (settable later)
const res = await fetch('/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dlc_dir: dir }),
});
// /api/settings reports an invalid folder as a 200 with an `error`
// field (a bare dict return, not a non-2xx status), so a res.ok-only
// check would treat the failure as success and advance without saving.
// Inspect the body too.
let data = null;
try { data = await res.json(); } catch (e) { /* non-JSON body */ }
if (!res.ok || (data && data.error)) {
throw new Error((data && data.error) || 'That folder couldn’t be set — check the path and try again.');
}
// Non-fatal: scan kicks off the library build in the background.
try { await fetch('/api/rescan', { method: 'POST' }); } catch (e) { /* best-effort */ }
}
async function postProfile() {
const res = await fetch('/api/profile', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: nameEl.value.trim(), avatar: selected || undefined }),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error || 'Could not save profile.');
return body;
}
async function finish(finishOpts) {
finishOpts = finishOpts || {};
overlay.remove();
await fetchProgress();
if (window.v3Progression && typeof window.v3Progression.refresh === 'function') {
await window.v3Progression.refresh();
}
renderBadge();
renderProfileScreen();
if (window.feedBack && window.feedBack.emit) window.feedBack.emit('v3:profile-updated', _profile);
// First-run only: after a genuine onboarding completion (not a
// profile edit), kick off the one-time home tour — but NOT when we're
// about to launch the diagnostic ("Play it now"), which navigates to
// the player; the tour would otherwise spotlight hidden home elements
// and steal focus. The Skip path stays on home, so it runs there.
// The engine's seen/dismissed state keeps it once; replayable from "?".
if (!editing && !finishOpts.launchingSong &&
window.v3OnboardingTour && typeof window.v3OnboardingTour.startFirstRun === 'function') {
try { window.v3OnboardingTour.startFirstRun(); } catch (e) { /* never block onboarding */ }
} else if (!editing && finishOpts.launchingSong &&
window.v3OnboardingTour && typeof window.v3OnboardingTour.armPendingFirstRun === 'function') {
// "Play it now" navigates straight to the player, so the home
// tour can't run now — arm it to fire the first time the user
// returns to home, instead of silently never showing.
try { window.v3OnboardingTour.armPendingFirstRun(); } catch (e) { /* never block onboarding */ }
}
}
submit.addEventListener('click', async () => {
errEl.classList.add('hidden');
if (editing) {
submit.disabled = true;
try {
_profile = await postProfile();
await finish();
} catch (e) { showErr(e.message || 'Could not save profile.'); submit.disabled = false; }
return;
}
if (step === 1) {
setStep(2);
setTimeout(() => { try { songDirEl && songDirEl.focus(); } catch (e) { /* noop */ } }, 50);
return;
}
if (step === 2) {
// Save the song directory + kick a library scan, then continue
// to the achievements opt-in. "Skip for now" leaves it unconfigured.
submit.disabled = true;
try {
await saveSongDir();
setStep(3);
} catch (e) { showErr(e.message || 'Could not set the song directory.'); refreshSubmit(); }
return;
}
if (step === 3) {
// Persist the wall opt-in choice (default OFF) then continue to
// instrument paths. Best-effort — a failed write must not block
// onboarding; the user can still set it later in Settings.
submit.disabled = true;
try {
const optEl = overlay.querySelector('#v3-ob-optin');
const optedIn = !!(optEl && optEl.checked);
try {
await fetch('/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ achievements_enabled: optedIn }),
});
try { localStorage.setItem('achievementsEnabled', optedIn ? '1' : '0'); } catch (_) { /* noop */ }
} catch (e) { /* best-effort — settable later */ }
setStep(4);
loadPathTiles();
} finally { refreshSubmit(); }
return;
}
if (step === 4) {
// Create the profile (onboarded=1) BEFORE the calibration choice
// so closing the overlay at the challenge can never lose the profile.
submit.disabled = true;
try {
_profile = await postProfile();
if (selectedPaths.length) {
// A failed path save must NOT advance — step 4's skip
// requires ≥1 selected path (spec invariant) and would
// otherwise leave a pathless rank-1 profile.
const res = await fetch('/api/progression/paths', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ add: selectedPaths }),
});
if (!res.ok) {
let msg = 'Could not save your instrument paths — try again.';
try { msg = (await res.json()).error || msg; } catch (e) { /* keep default */ }
throw new Error(msg);
}
}
// New step: input-device selection + calibration, between
// path selection and the note-detect calibration challenge.
await runInputSetup(selectedPaths);
setStep(isDesktop ? 5 : 6);
} catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); }
return;
}
if (step === 5) {
// Step 5 (desktop only) — persist the amp-sim opt-in (default OFF
// / own-rig). Best-effort: a failed write must not block onboarding;
// it's settable later from the desktop Audio settings.
submit.disabled = true;
try {
const ampEl = overlay.querySelector('#v3-ob-ampsims');
const useAmpSims = !!(ampEl && ampEl.checked);
try {
await fetch('/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ use_amp_sims: useAmpSims }),
});
} catch (e) { /* best-effort — settable later */ }
setStep(6);
} finally { refreshSubmit(); }
return;
}
// Step 6 — "Play it now": leave calibration pending (it completes
// through the normal scored-stats path) and launch the diagnostic.
const target = diagnosticFilename;
await finish({ launchingSong: !!target });
if (target && typeof window.playSong === 'function') window.playSong(target);
});
skipBtn.addEventListener('click', async () => {
// Step 2 — skip the song directory (the user can set it later in
// Settings). Proceed to the achievements opt-in.
if (step === 2) {
setStep(3);
return;
}
// Calibration step (last) — skip: Mastery Rank 1 immediately,
// calibration stays replayable from the Progress screen.
skipBtn.disabled = true;
try {
const res = await fetch('/api/progression/onboarding', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'skip' }),
});
if (!res.ok) { skipBtn.disabled = false; return; }
} catch (e) { /* offline — skippable later from Progress */ }
await finish();
});
if (!editing) setStep(1);
setTimeout(() => nameEl.focus(), 50);
}
window.v3Onboarding = { show: show };
window.v3Profile = {
refresh: async function () { await fetchProfile(); await fetchProgress(); renderBadge(); renderProfileScreen(); },
get: () => _profile,
};
async function boot() {
await fetchProfile();
await fetchProgress();
renderBadge();
renderProfileScreen();
// Rank / dB / frames re-render whenever progression state or equipped
// cosmetics move (progression-core.js / theme-core.js own the fetches).
if (window.feedBack && typeof window.feedBack.on === 'function') {
window.feedBack.on('progression:updated', () => { renderBadge(); renderProfileScreen(); });
window.feedBack.on('v3:cosmetics-applied', () => { renderBadge(); renderProfileScreen(); });
// Re-render on every Profile entry so the Feats shelf + Achievements
// catalogue refresh (and `v3:profile-rendered` re-fires for the
// plugin) — the plugin may have loaded after the initial boot render.
window.feedBack.on('screen:changed', (e) => {
if (e && e.detail && e.detail.id === 'v3-profile') renderProfileScreen();
});
}
}
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})();