mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-23 05:11:34 +00:00
The LIBRARY group's main entry now reads "Song Library" in the sidebar (and in the topbar page title, which mirrors nav labels). The nav key and screen id are unchanged, so routing, saved hashes and promoted- plugin anchors are untouched. Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
398 lines
24 KiB
JavaScript
398 lines
24 KiB
JavaScript
/*
|
|
* fee[dB]ack v0.3.0 — app shell: sidebar, topbar, client routing.
|
|
*
|
|
* Vanilla JS, no framework/bundler (constitution P-II). Reuses the engine
|
|
* from app.js: navigation is the shared window.showScreen across both the new
|
|
* #v3-* screens and the reused legacy/#plugin-* screens. UI placement is a
|
|
* DEFERRED capability domain, so plugin nav/screens are consumed via the
|
|
* legacy plugin loader + /api/plugins, NOT via capability dispatch
|
|
* (design/05-capability-pipelines.md). All v3 UI state uses the `v3:` prefix.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
// HTML-escape untrusted strings (plugin manifest id/label) before they go
|
|
// into innerHTML, so a hostile/buggy manifest can't inject markup or event
|
|
// attributes into the sidebar plugin nav.
|
|
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
|
|
// Plugin ids whose manifest declared `"fullscreen": true`. Populated from
|
|
// /api/plugins in renderPromotedNav(); read by syncActive() to toggle the
|
|
// immersive (chrome-collapsed) shell whenever such a plugin's screen is the
|
|
// active one. Empty until plugins resolve — the worst case is one extra
|
|
// syncActive() once the fetch lands, which re-applies the class.
|
|
const FULLSCREEN_PLUGIN_IDS = new Set();
|
|
|
|
// ── Navigation registry ────────────────────────────────────────────────
|
|
// Each entry maps a stable hash key → a screen id (showScreen target) and a
|
|
// label. Legacy screens are reused: "Songs" = #home (library), "Favorites"
|
|
// = #favorites, "Settings" = #settings. New screens use #v3-* ids.
|
|
const NAV = [
|
|
{ key: 'home', screen: 'v3-home', label: 'Home', group: 'HOME', icon: 'home' },
|
|
{ key: 'progress', screen: 'v3-progress', label: 'Progress', group: 'HOME', icon: 'trophy' },
|
|
{ key: 'shop', screen: 'v3-shop', label: 'Unlockables', group: 'HOME', icon: 'tag' },
|
|
{ key: 'feedbarcade', screen: 'v3-feedbarcade', label: 'FeedBarcade', group: 'HOME', icon: 'arcade' },
|
|
{ key: 'plugins', screen: 'v3-plugins', label: 'Plugins', group: 'HOME', icon: 'plug' },
|
|
{ key: 'settings', screen: 'settings', label: 'Settings', group: 'HOME', icon: 'gear' },
|
|
{ key: 'playlists', screen: 'v3-playlists', label: 'Playlists', group: 'LIBRARY', icon: 'list' },
|
|
{ key: 'songs', screen: 'v3-songs', label: 'Song Library', group: 'LIBRARY', icon: 'disc' },
|
|
{ key: 'lessons', screen: 'v3-lessons', label: 'Lessons', group: 'LIBRARY', icon: 'lessons' },
|
|
{ key: 'favorites', screen: 'favorites', label: 'Favorites', group: 'LIBRARY', icon: 'star' },
|
|
{ key: 'saved', screen: 'v3-saved', label: 'Saved for Later', group: 'LIBRARY', icon: 'bookmark' },
|
|
// Promoted plugins (group: null) — bundled plugins given their own
|
|
// first-class sidebar entry instead of the generic plugin gallery. They
|
|
// are placed by PROMOTED_PLUGINS below and their slots are filled by
|
|
// renderPromotedNav() only when the plugin is actually installed. All
|
|
// other plugins are reached solely via the single "Plugins" entry
|
|
// above. Screens are injected async by the plugin loader, so go()'s
|
|
// plugin- guard applies.
|
|
{ key: 'virtuoso', screen: 'plugin-virtuoso', label: 'Virtuoso - Practice', group: null, icon: 'target' },
|
|
{ key: 'rig_builder', screen: 'plugin-rig_builder', label: 'Rig Builder', group: null, icon: 'amp' },
|
|
{ key: 'editor', screen: 'plugin-editor', label: 'Song Editor', group: null, icon: 'edit' },
|
|
{ key: 'audio_engine', screen: 'plugin-audio_engine', label: 'Audio', group: null, icon: 'amp' },
|
|
// Not in the sidebar groups, but routable (profile badge → here).
|
|
{ key: 'profile', screen: 'v3-profile', label: 'Profile', group: null, icon: 'user' },
|
|
];
|
|
// Bundled plugins promoted to dedicated sidebar entries. `anchorAfter` is
|
|
// the nav key the slot is rendered immediately below (within that key's
|
|
// group); a key that's the last item of the last group lands right after
|
|
// that group. Each is gated on the plugin actually being installed.
|
|
const PROMOTED_PLUGINS = [
|
|
{ navKey: 'virtuoso', pluginId: 'virtuoso', slotId: 'v3-nav-virtuoso', anchorAfter: 'feedbarcade' },
|
|
{ navKey: 'rig_builder', pluginId: 'rig_builder', slotId: 'v3-nav-rig-builder', anchorAfter: 'saved' },
|
|
{ navKey: 'editor', pluginId: 'editor', slotId: 'v3-nav-editor', anchorAfter: 'songs' },
|
|
{ navKey: 'audio_engine', pluginId: 'audio_engine', slotId: 'v3-nav-audio-engine', anchorAfter: 'settings' },
|
|
];
|
|
const TOPBAR_KEYS = ['home', 'songs', 'plugins', 'settings'];
|
|
const SIDEBAR_GROUPS = ['HOME', 'LIBRARY'];
|
|
|
|
// Minimal inline icon set (currentColor 24x24 stroke paths).
|
|
const ICONS = {
|
|
home: 'M3 11l9-8 9 8M5 10v10h5v-6h4v6h5V10',
|
|
plug: 'M9 7V3m6 4V3M7 7h10v4a5 5 0 01-10 0V7zm5 9v5',
|
|
gear: 'M12 9a3 3 0 100 6 3 3 0 000-6zm8.4 3a8.4 8.4 0 00-.1-1.3l2-1.6-2-3.4-2.4 1a8 8 0 00-2.2-1.3l-.4-2.6H9.7l-.4 2.6A8 8 0 007.1 6l-2.4-1-2 3.4 2 1.6a8.4 8.4 0 000 2.6l-2 1.6 2 3.4 2.4-1a8 8 0 002.2 1.3l.4 2.6h4.6l.4-2.6a8 8 0 002.2-1.3l2.4 1 2-3.4-2-1.6c.1-.4.1-.9.1-1.3z',
|
|
list: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
|
|
disc: 'M12 3a9 9 0 100 18 9 9 0 000-18zm0 6a3 3 0 100 6 3 3 0 000-6z',
|
|
star: 'M12 3l2.9 6 6.6.9-4.8 4.6 1.1 6.5L12 18.8 6.2 21l1.1-6.5L2.5 9.9 9.1 9 12 3z',
|
|
bookmark: 'M6 3h12a1 1 0 011 1v17l-7-4-7 4V4a1 1 0 011-1z',
|
|
user: 'M12 12a4 4 0 100-8 4 4 0 000 8zm-7 8a7 7 0 0114 0',
|
|
arcade: 'M7 8h10a4 4 0 014 4 4 4 0 01-4 4H7a4 4 0 01-4-4 4 4 0 014-4zm0 4h3m-1.5-1.5v3M15 11h.01M17.5 13h.01',
|
|
lessons: 'M12 4L2 9l10 5 10-5-10-5zM6 11.5V16c0 1 2.7 2.5 6 2.5s6-1.5 6-2.5v-4.5',
|
|
trophy: 'M8 21h8m-4-4v4m-6-17h12v5a6 6 0 01-12 0V4zm12 2h2a2 2 0 01-2 4M6 6H4a2 2 0 002 4',
|
|
tag: 'M20.6 13.4l-7.2 7.2a2 2 0 01-2.8 0l-7-7V4h9.6l7.4 7.4a2 2 0 010 2zM7.5 7.5h.01',
|
|
amp: 'M4 5h16a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V6a1 1 0 011-1zm11 4a3 3 0 100 6 3 3 0 000-6zM6.5 8.5h.01M9 8.5h.01',
|
|
target: 'M12 3a9 9 0 100 18 9 9 0 000-18zm0 4a5 5 0 100 10 5 5 0 000-10zm0 4a1 1 0 100 2 1 1 0 000-2z',
|
|
edit: 'M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7M18.5 2.5a2.1 2.1 0 013 3L12 15l-4 1 1-4 9.5-9.5z',
|
|
};
|
|
function iconSvg(name) {
|
|
const d = ICONS[name] || ICONS.disc;
|
|
return '<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" ' +
|
|
'viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="' + d + '"/></svg>';
|
|
}
|
|
|
|
const byKey = (k) => NAV.find((n) => n.key === k);
|
|
const byScreen = (s) => NAV.find((n) => n.screen === s);
|
|
const currentScreenId = () => (document.querySelector('.screen.active') || {}).id || 'v3-home';
|
|
|
|
// The topbar IS each page's header row, so its title mirrors the screen.
|
|
function titleFor(screenId) {
|
|
if (screenId === 'v3-home') {
|
|
const p = (window.v3Profile && window.v3Profile.get && window.v3Profile.get()) || null;
|
|
return 'Welcome back, ' + ((p && p.display_name) || 'there') + '!';
|
|
}
|
|
const entry = byScreen(screenId);
|
|
if (entry) return entry.label;
|
|
if (screenId && screenId.indexOf('plugin-') === 0) return 'Plugins';
|
|
return '';
|
|
}
|
|
function setTopbarTitle(text) {
|
|
const el = document.getElementById('v3-topbar-title');
|
|
if (el) el.textContent = text || ''; // textContent → no escaping needed
|
|
}
|
|
|
|
// ── Active-state sync ───────────────────────────────────────────────────
|
|
function syncActive(screenId) {
|
|
const entry = byScreen(screenId);
|
|
document.querySelectorAll('[data-v3-nav]').forEach((el) => {
|
|
const on = entry && el.getAttribute('data-v3-nav') === entry.key;
|
|
el.classList.toggle('bg-fb-card', on);
|
|
el.classList.toggle('text-fb-text', on);
|
|
el.classList.toggle('text-fb-textDim', !on);
|
|
});
|
|
setTopbarTitle(titleFor(screenId));
|
|
// Immersive (full-screen) plugin screens: when the active screen belongs
|
|
// to a plugin that opted in via `"fullscreen": true`, collapse the host
|
|
// chrome (topbar hidden, sidebar → icon rail; see v3.css) and let the
|
|
// plugin own the content area. Toggled here so it tracks every
|
|
// navigation, including deep-link load and programmatic showScreen().
|
|
const fsPluginId = (screenId && screenId.indexOf('plugin-') === 0)
|
|
? screenId.slice('plugin-'.length) : null;
|
|
const immersive = !!(fsPluginId && FULLSCREEN_PLUGIN_IDS.has(fsPluginId));
|
|
document.documentElement.classList.toggle('fb-immersive', immersive);
|
|
// Show the song search only on the library screen. Everywhere else the
|
|
// box is irrelevant (and would silently no-op against v3Songs.search).
|
|
const searchWrap = document.getElementById('v3-search-wrap');
|
|
if (searchWrap) searchWrap.classList.toggle('hidden', screenId !== 'v3-songs');
|
|
// NOTE: we deliberately do NOT reflect the screen into location.hash on
|
|
// every navigation. app.js's audio 'error' handler suppresses empty-src
|
|
// errors only when `audio.src === window.location.href`; a `#/...`
|
|
// fragment makes href differ from the fragment-less resolved empty src,
|
|
// so screen-switch audio cleanup would log a spurious media error (and
|
|
// pollute the diagnostics console capture). Deep-linking IN is still
|
|
// supported on load (see boot()); live reflection OUT is intentionally
|
|
// omitted — it's optional per the prompt, console cleanliness is not.
|
|
}
|
|
|
|
// ── Navigation ──────────────────────────────────────────────────────────
|
|
function go(screenId) {
|
|
if (typeof window.showScreen !== 'function') return;
|
|
// Guard plugin screens that may not be injected yet (loadPlugins runs
|
|
// async at app.js boot). showScreen() throws on a missing element.
|
|
if (screenId.indexOf('plugin-') === 0 && !document.getElementById(screenId)) {
|
|
window.showScreen('v3-plugins');
|
|
return;
|
|
}
|
|
window.showScreen(screenId); // wrapper below re-syncs active state
|
|
closeMobileSidebar();
|
|
}
|
|
|
|
// ── Sidebar ───────────────────────────────────────────────────────────--
|
|
function navItemHTML(entry, labelOverride) {
|
|
return '<a href="#/' + entry.key + '" data-v3-nav="' + entry.key + '" ' +
|
|
'class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-fb-textDim ' +
|
|
'hover:text-fb-text hover:bg-fb-card/50 transition-colors">' +
|
|
iconSvg(entry.icon) + '<span class="truncate v3-nav-label">' + esc(labelOverride != null ? labelOverride : entry.label) + '</span></a>';
|
|
}
|
|
// Empty slot for a promoted plugin, anchored after a nav item. Filled by
|
|
// renderPromotedNav() only when the plugin is installed, so an absent
|
|
// bundle shows nothing rather than a dead entry that bounces to Plugins.
|
|
const promotedSlotHTML = (key) => PROMOTED_PLUGINS
|
|
.filter((p) => p.anchorAfter === key)
|
|
.map((p) => '<div id="' + p.slotId + '"></div>')
|
|
.join('');
|
|
function renderSidebar() {
|
|
const nav = document.getElementById('v3-nav');
|
|
if (!nav) return;
|
|
let html = '';
|
|
for (const group of SIDEBAR_GROUPS) {
|
|
const items = NAV.filter((n) => n.group === group);
|
|
if (!items.length) continue;
|
|
const itemsHTML = items.map((it) => navItemHTML(it) + promotedSlotHTML(it.key)).join('');
|
|
html += '<div><div class="v3-nav-group px-3 mb-1 text-[0.625rem] uppercase tracking-wider font-semibold text-fb-textDim/70">' +
|
|
group + '</div><div class="space-y-0.5">' + itemsHTML + '</div></div>';
|
|
}
|
|
nav.innerHTML = html;
|
|
nav.querySelectorAll('a[data-v3-nav]').forEach((a) => {
|
|
a.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
go(byKey(a.getAttribute('data-v3-nav')).screen);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Topbar ───────────────────────────────────────────────────────────---
|
|
function renderTopbar() {
|
|
const bar = document.getElementById('v3-topbar');
|
|
if (!bar) return;
|
|
bar.className = 'sticky top-0 z-30 bg-fb-sidebar/80 backdrop-blur';
|
|
bar.innerHTML =
|
|
// Row 1 — top utility bar: search.
|
|
'<div class="flex items-center gap-4 px-4 md:px-8 pt-4">' +
|
|
'<button id="v3-hamburger" class="md:hidden text-fb-textDim hover:text-fb-text shrink-0" aria-label="Menu">' +
|
|
'<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/></svg></button>' +
|
|
// Search is scoped to the library — syncActive() toggles `hidden` on
|
|
// this wrapper so it only shows on the v3-songs screen. It lives in
|
|
// the sticky topbar, so it stays put while the song grid scrolls.
|
|
'<div id="v3-search-wrap" class="flex-1 max-w-md relative hidden">' +
|
|
'<svg class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-fb-textDim" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path stroke-linecap="round" d="M21 21l-4-4"/></svg>' +
|
|
'<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>' +
|
|
'</div>' +
|
|
// Row 2 — page header: title + ONLY the tuner/instrument/profile
|
|
// badge cluster on the same line as the header.
|
|
'<div class="flex items-center gap-3 px-4 md:px-8 pt-2 pb-4">' +
|
|
'<h1 id="v3-topbar-title" class="text-2xl md:text-3xl font-bold text-fb-text truncate min-w-0 flex-1"></h1>' +
|
|
'<div class="flex items-center gap-2 shrink-0">' +
|
|
'<span id="v3-badge-tuner" class="contents"></span>' +
|
|
'<span id="v3-badge-instrument" class="contents"></span>' +
|
|
'<span id="v3-badge-profile" class="contents"></span>' +
|
|
'</div></div>';
|
|
|
|
const burger = document.getElementById('v3-hamburger');
|
|
if (burger) burger.addEventListener('click', toggleMobileSidebar);
|
|
setTopbarTitle(titleFor(currentScreenId()));
|
|
|
|
// Search drives the native Songs screen (prompt 21). Debounced so we
|
|
// don't refetch on every keystroke. Degrades silently if v3Songs isn't
|
|
// loaded yet.
|
|
const search = document.getElementById('v3-search');
|
|
if (search) {
|
|
let t = 0;
|
|
search.addEventListener('input', () => {
|
|
if (t) clearTimeout(t);
|
|
t = setTimeout(() => {
|
|
if (window.v3Songs && typeof window.v3Songs.search === 'function') window.v3Songs.search(search.value);
|
|
}, 250);
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Responsive sidebar ───────────────────────────────────────────────---
|
|
function ensureBackdrop() {
|
|
let bd = document.getElementById('v3-sidebar-backdrop');
|
|
if (!bd) {
|
|
bd = document.createElement('div');
|
|
bd.id = 'v3-sidebar-backdrop';
|
|
bd.className = 'fixed inset-0 bg-black/60 z-40 md:hidden hidden';
|
|
bd.addEventListener('click', closeMobileSidebar);
|
|
document.body.appendChild(bd);
|
|
}
|
|
return bd;
|
|
}
|
|
function toggleMobileSidebar() {
|
|
const sb = document.getElementById('v3-sidebar');
|
|
const bd = ensureBackdrop();
|
|
if (!sb) return;
|
|
const opening = sb.classList.contains('hidden');
|
|
sb.classList.toggle('hidden', !opening);
|
|
// On mobile, float the sidebar over content.
|
|
sb.classList.toggle('flex', opening);
|
|
sb.classList.toggle('fixed', opening);
|
|
sb.classList.toggle('inset-y-0', opening);
|
|
sb.classList.toggle('left-0', opening);
|
|
sb.classList.toggle('z-50', opening);
|
|
bd.classList.toggle('hidden', !opening);
|
|
}
|
|
function closeMobileSidebar() {
|
|
if (window.matchMedia && window.matchMedia('(min-width: 768px)').matches) return;
|
|
const sb = document.getElementById('v3-sidebar');
|
|
const bd = document.getElementById('v3-sidebar-backdrop');
|
|
if (sb) {
|
|
sb.classList.add('hidden');
|
|
sb.classList.remove('flex', 'fixed', 'inset-y-0', 'left-0', 'z-50');
|
|
}
|
|
if (bd) bd.classList.add('hidden');
|
|
}
|
|
|
|
// ── Promoted-plugin nav (legacy loader is the source; UI domain deferred) ─
|
|
// Individual plugins are NOT listed in the sidebar — the single "Plugins"
|
|
// entry (HOME group) is the one entry point to the plugin gallery. The
|
|
// PROMOTED_PLUGINS get their own first-class slots, each filled here only
|
|
// when that plugin is actually installed.
|
|
async function renderPromotedNav() {
|
|
let plugins = [];
|
|
try {
|
|
const res = await fetch('/api/plugins');
|
|
if (res.ok) plugins = await res.json();
|
|
} catch (e) { return; } // degrade: no promoted slots
|
|
const list = Array.isArray(plugins) ? plugins : [];
|
|
// Record which installed plugins requested immersive (full-screen)
|
|
// screens, then re-sync the active screen so the chrome collapses even
|
|
// if we navigated to a fullscreen plugin before /api/plugins resolved.
|
|
FULLSCREEN_PLUGIN_IDS.clear();
|
|
for (const p of list) { if (p && p.fullscreen && p.id) FULLSCREEN_PLUGIN_IDS.add(p.id); }
|
|
try { syncActive(currentScreenId()); } catch (e) { /* non-fatal */ }
|
|
for (const promo of PROMOTED_PLUGINS) {
|
|
const host = document.getElementById(promo.slotId);
|
|
const entry = byKey(promo.navKey);
|
|
if (!host || !entry) continue;
|
|
const plugin = list.find((p) => p && p.id === promo.pluginId);
|
|
if (!plugin) continue; // not installed → empty slot
|
|
// Use the plugin's own nav label (manifest), falling back to the
|
|
// static NAV label. navItemHTML escapes it.
|
|
const label = (plugin.nav && plugin.nav.label) || plugin.name || entry.label;
|
|
host.innerHTML = '<div class="space-y-0.5">' + navItemHTML(entry, label) + '</div>';
|
|
const a = host.querySelector('a[data-v3-nav]');
|
|
if (a) a.addEventListener('click', (e) => { e.preventDefault(); go(entry.screen); });
|
|
}
|
|
}
|
|
|
|
// ── showScreen wrapper (idempotent rehydration — design/05 §Rehydration) ─
|
|
function installShowScreenHook() {
|
|
const hooks = window.__feedBackV3ShellHooks || (window.__feedBackV3ShellHooks = {});
|
|
hooks.syncActive = syncActive; // always point at the latest impl
|
|
if (hooks.installed) return;
|
|
hooks.installed = true;
|
|
hooks.baseShowScreen = window.showScreen;
|
|
window.showScreen = function (id) {
|
|
// Route every "go to the library" navigation to the v3 native Songs
|
|
// screen instead of the legacy #home library, so player-close,
|
|
// settings-back, the hidden legacy navbar, etc. all stay in v3.
|
|
const target = (id === 'home') ? 'v3-songs' : id;
|
|
const r = hooks.baseShowScreen ? hooks.baseShowScreen.call(this, target) : undefined;
|
|
try { hooks.syncActive && hooks.syncActive(target); } catch (e) { /* non-fatal */ }
|
|
return r;
|
|
};
|
|
}
|
|
|
|
// ── Boot ────────────────────────────────────────────────────────────────
|
|
async function boot() {
|
|
if (window.fbBrand) window.fbBrand.renderWordmark(document.getElementById('v3-brand'), { size: 'text-xl' });
|
|
renderSidebar();
|
|
renderTopbar();
|
|
ensureBackdrop();
|
|
installShowScreenHook();
|
|
renderPromotedNav(); // async, non-blocking
|
|
|
|
// First-run gate: onboarding overlay is owned by prompt 15. Until it
|
|
// exists, degrade gracefully and go straight to the dashboard.
|
|
let profile = null;
|
|
try {
|
|
const res = await fetch('/api/profile');
|
|
if (res.ok) profile = await res.json();
|
|
} catch (e) { /* profile endpoint lands in prompt 15 */ }
|
|
if (profile && profile.onboarded === false && window.v3Onboarding && typeof window.v3Onboarding.show === 'function') {
|
|
try { window.v3Onboarding.show(profile); } catch (e) { /* fall through */ }
|
|
}
|
|
|
|
// Splitscreen pop-out windows (`?ssFollower=1`) get sent to
|
|
// showScreen('player') by app.js's bootstrap (app.js:9716) before
|
|
// this runs, exactly so the library doesn't flash on the popup.
|
|
// Don't undo that here — the splitscreen IIFE loads next and takes
|
|
// the popup the rest of the way into follower mode. Without this
|
|
// bail, the default 'v3-home' target below would re-activate the
|
|
// library screen and bring the flash back.
|
|
let isFollowerWindow = false;
|
|
try { isFollowerWindow = new URLSearchParams(location.search).get('ssFollower') === '1'; }
|
|
catch (_) { /* file:// or sandboxed iframe — fall through */ }
|
|
if (isFollowerWindow) {
|
|
syncActive('player');
|
|
} else {
|
|
// Deep-link IN on load: honor a #/key fragment, then strip it so
|
|
// subsequent screen-switch audio cleanup doesn't trip app.js's
|
|
// href-based empty-src guard (see syncActive). #v3-home is already
|
|
// `.active` in the HTML, so when that's the target we only sync the
|
|
// chrome — calling showScreen() redundantly would run its non-player
|
|
// teardown branch and log a spurious "Empty src" media error.
|
|
const m = (location.hash || '').match(/^#\/([\w-]+)/);
|
|
const entry = m && byKey(m[1]);
|
|
if (location.hash) {
|
|
try { history.replaceState(null, '', location.pathname + location.search); } catch (e) { /* file:// */ }
|
|
}
|
|
const target = entry ? entry.screen : 'v3-home';
|
|
const el = document.getElementById(target);
|
|
if (el && el.classList.contains('active')) {
|
|
syncActive(target);
|
|
} else {
|
|
go(target);
|
|
}
|
|
}
|
|
|
|
// The "Welcome back, {name}!" title needs the profile, which loads
|
|
// async — refresh once it's in (and whenever it changes).
|
|
function refreshHomeTitle() { if (currentScreenId() === 'v3-home') setTopbarTitle(titleFor('v3-home')); }
|
|
if (window.feedBack && typeof window.feedBack.on === 'function') {
|
|
window.feedBack.on('v3:profile-updated', refreshHomeTitle);
|
|
}
|
|
setTimeout(refreshHomeTitle, 700);
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
|
} else {
|
|
boot();
|
|
}
|
|
})();
|