/*
* 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 =
'
Play a song to see it here.
Have you read the latest changes in the ' + 'Patch Notes for ' + esc(ver) + '?
' : '') + // Featured grid: hero + continue 'Play along to your library, track your accuracy, and rank up.
' + '