/* * fee[dB]ack v0.3.0 — Dashboard / Home (#v3-home). * * Composes data from the backends built in other prompts: profile (15), * song-stats/recent (14), continue (16), library stats + plugins. Each widget * fetches + renders independently and DEGRADES GRACEFULLY — a missing/empty * endpoint shows a placeholder, never blocks first paint (design/05 §1). * Vanilla JS, fb-* tokens (constitution P-II). */ (function () { 'use strict'; const sm = window.feedBack; const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const enc = encodeURIComponent; const jget = async (u) => { try { const r = await fetch(u); return r.ok ? r.json() : null; } catch (e) { return null; } }; function libArtUrl(song) { const v = song.mtime ? ('?v=' + Math.floor(song.mtime)) : ''; return '/api/song/' + enc(song.filename) + '/art' + v; } // A random library song (for the "Pick a song" card when nothing's been played). async function randomSong() { const stats = await jget('/api/library/stats'); const total = (stats && (stats.total_songs ?? stats.total)) || 0; if (!total) return null; const idx = Math.floor(Math.random() * total); const data = await jget('/api/library?size=1&page=' + idx); return data && data.songs && data.songs[0] ? data.songs[0] : null; } // Accuracy badge ramp (design/04-badges.md §C): ≥90% good, 50–89% mid, <50% low. function accuracyBadge(acc) { if (acc == null) return ''; const pct = Math.round(acc * 100); const color = acc >= 0.9 ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low'); const text = acc >= 0.5 && acc < 0.9 ? 'text-black' : 'text-white'; return '' + '' + pct + '%'; } function songArt(url, extra) { return ''; } function tuningChip(name, cls) { if (!name) return ''; return '' + esc(name) + ''; } // Source format of a song — prefer the server's `format`, fall back to the // filename extension. '' = unknown. function fmtName(song) { let f = ((song && song.format) || '').toLowerCase(); if (!f) { const fn = ((song && song.filename) || '').toLowerCase(); f = (fn.endsWith('.feedpak') || fn.endsWith('.sloppak')) ? 'sloppak' : ''; } return f === 'sloppak' ? 'FEEDPAK' : f === 'loose' ? 'FOLDER' : ''; } // Corner badge for art-thumbnail cards (sloppak accented, others muted). function fmtBadge(song) { const l = fmtName(song); if (!l) return ''; const c = l === 'FEEDPAK' ? 'bg-fb-primary text-white' : 'bg-black/70 text-fb-textDim'; return '' + l + ''; } // Inline pill for the hero (Pick/Continue) card, where the art is text-overlaid // and a corner badge would collide — sits next to the card's label instead. function fmtTag(song) { const l = fmtName(song); if (!l) return ''; const c = l === 'FEEDPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card/80 text-fb-textDim'; return '' + l + ''; } // ── Continue-Playing resume ──────────────────────────────────────────--- function resume(filename, lastPosition, arrangement) { if (typeof window.playSong !== 'function') return; // playSong expects an encoded filename (it decodeURIComponent()s it for // the highway WS) and takes the arrangement index as its 2nd arg — pass // both so resume reopens the exact arrangement the user left, not the // default. It has no start-time arg, so play then best-effort seek once // the session is up (constitution: highway WS). Promise.resolve(window.playSong(enc(filename), arrangement)).then(() => { if (Number.isFinite(lastPosition) && lastPosition > 0 && sm && typeof sm.seek === 'function') { setTimeout(() => { try { sm.seek(lastPosition, 'continue-playing'); } catch (e) { /* */ } }, 800); } }); } // ── Render ───────────────────────────────────────────────────────────--- async function render() { const root = document.getElementById('v3-home'); if (!root) return; // Kick off independent fetches. const [profile, version, cont, libStats, plugins, recent] = await Promise.all([ jget('/api/profile'), jget('/api/version'), jget('/api/session/continue'), jget('/api/library/stats'), jget('/api/plugins'), jget('/api/stats/recent?limit=6'), ]); const name = (profile && profile.display_name) || 'there'; const ver = (version && version.version) || ''; const changelogUrl = ((version && version.source_url) || 'https://github.com/got-feedback/feedBack') + '/blob/main/CHANGELOG.md'; const songCount = (libStats && (libStats.total_songs ?? libStats.total)) || 0; const pluginCount = Array.isArray(plugins) ? plugins.filter((p) => (p && (p.status || 'ready') === 'ready')).length : 0; // "Jump back in": scored recents, else fall back to recently-added songs // so the section is never empty on a fresh profile. let recentList = Array.isArray(recent) ? recent : []; if (!recentList.length) { const lib = await jget('/api/library?sort=recent&size=6&page=0'); recentList = ((lib && lib.songs) || []).map((s) => ({ filename: s.filename, title: s.title, artist: s.artist, art_url: libArtUrl(s), best_accuracy: null, })); } // When nothing's been played, the Continue card becomes a random // pick-a-song that plays on click. const pick = (cont && cont.filename) ? null : await randomSong(); // Continue card. let continueCard; if (cont && cont.filename) { const dur = cont.duration || 0; const segs = 4; const filled = dur > 0 ? Math.round((cont.last_position / dur) * segs) : 0; const bars = Array.from({ length: segs }, (_, i) => '').join(''); continueCard = ''; } else if (pick) { continueCard = ''; } else { continueCard = '
' + '
Pick a song to get started
' + '
'; } // Recently played. let recentSection; if (recentList.length) { const cards = recentList.map((r) => '').join(''); recentSection = '

Jump back in!

' + '
' + cards + '
'; } else { recentSection = '

Jump back in!

' + '

Play a song to see it here.

'; } root.innerHTML = '
' + // (Title "Welcome back, {name}!" lives in the topbar header row.) (ver ? '

Have you read the latest changes in the ' + 'Patch Notes for ' + esc(ver) + '?

' : '') + // Featured grid: hero + continue '
' + '
' + // Hero artwork (neon note-highway), right-anchored. Placeholder // cropped from the design mock — swap static/v3/brand/hero.png for // the designer's high-res original (same path) when available. '' + // 135° gradient overlay: solid navy on the left for text legibility, // fading to transparent so the artwork shows through on the right. '
' + '
' + '

Turn any song into practice.

' + '

Play along to your library, track your accuracy, and rank up.

' + '
' + '' + '' + '
' + continueCard + '
' + // Stats row '
' + audioRoutingCard() + statCard(String(songCount), 'songs', 'text-fb-gold') + statCard(String(pluginCount), 'active', 'text-fb-good') + '
' + recentSection + '
'; // Wire interactions. const startBtn = root.querySelector('#v3-start'); if (startBtn) startBtn.addEventListener('click', () => { if (cont && cont.filename) resume(cont.filename, cont.last_position, cont.arrangement); else if (pick) window.playSong && window.playSong(enc(pick.filename)); else window.showScreen && window.showScreen('v3-songs'); }); root.querySelector('#v3-continue')?.addEventListener('click', () => resume(cont.filename, cont.last_position, cont.arrangement)); root.querySelector('#v3-pick')?.addEventListener('click', () => window.playSong && window.playSong(enc(pick.filename))); root.querySelector('#v3-continue-pick')?.addEventListener('click', () => window.showScreen && window.showScreen('v3-songs')); root.querySelector('#v3-lessons-btn')?.addEventListener('click', () => window.showScreen && window.showScreen('v3-lessons')); root.querySelectorAll('[data-recent]').forEach((b) => b.addEventListener('click', () => { if (!window.playSong) return; // /api/stats/recent rows are arrangement-specific — reopen the // arrangement that was actually played, not the default. const arr = b.getAttribute('data-arr'); window.playSong(enc(b.getAttribute('data-recent')), arr === '' || arr == null ? undefined : Number(arr)); })); // Let prompt 18 enhance the audio-routing card once it exists. if (window.v3AudioRouting && typeof window.v3AudioRouting.render === 'function') { try { window.v3AudioRouting.render(document.getElementById('v3-audio-routing')); } catch (e) { /* */ } } // Signal that #v3-home has been (re)built, so the first-run onboarding // tour can wait for the real cards instead of attaching to nodes from a // prior render that this innerHTML swap just replaced. try { document.dispatchEvent(new CustomEvent('v3:dashboard-rendered')); } catch (e) { /* older runtimes */ } } function statCard(value, unit, unitColor) { return '
' + '
' + esc(value) + ' ' + esc(unit) + '
'; } // Audio-routing widget placeholder (prompt 18 replaces #v3-audio-routing's // body via window.v3AudioRouting). Until then: "Not Connected". function audioRoutingCard() { return '
' + '
Audio Routing
' + '
' + 'Input' + '' + 'VST/NAM/IR' + 'Output
' + '
Not Connected
'; } window.v3Dashboard = { render: render }; if (sm && typeof sm.on === 'function') { sm.on('screen:changed', (e) => { if (e && e.detail && e.detail.id === 'v3-home') render(); }); sm.on('v3:profile-updated', () => render()); } function boot() { render(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true }); else boot(); })();