feedBack/static/index.html
Matthew Harris Glover fadaa154e9
feat(library): sort and badge by personal difficulty rating (#810)
* feat(library): sort and badge by personal difficulty rating

Adds sort=difficulty/difficulty-desc to the library API (correlated
subquery over song_user_meta.user_difficulty, unrated songs pushed to
the bottom either direction, same pattern as the existing mastery
sort) and surfaces the rating as a badge on library cards in both the
v2 grid/tree views and the v3 grid. The rating itself already existed
(song_user_meta) — this just makes it sortable and visible, so it's
no longer only readable in the per-song edit drawer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(library): escape difficulty badge, wire tree view, add changelog+tests

- Wrap song.user_difficulty in esc() at both badge call sites
  (static/app.js ~2082 and ~2283) for XSS-consistency with the
  sibling tuning badge, which already uses esc().
- server.py: query_artists (the classic tree view's data source, used
  by /api/library/artists) never batch-attached user_difficulty the
  way query_page does for the grid, so the tree-view difficulty badge
  added in 75673c3 was unreachable dead code (song.user_difficulty was
  always undefined there). Now attaches it via the existing
  user_meta_map() helper, same pattern as query_page.
- Add an [Unreleased] CHANGELOG.md entry for the difficulty sort +
  badge feature, matching the repo's existing entry format.
- Add tests/test_library_filters.py::test_difficulty_sort_pushes_unrated_to_bottom
  asserting unrated songs sort to the bottom in both sort=difficulty
  and sort=difficulty-desc directions, and
  ::test_tree_view_songs_carry_user_difficulty covering the
  query_artists fix above.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(library): chunk user_meta_map + rebuild stale tailwind css

Address review-bot findings on the difficulty sort/badge:
- user_meta_map now chunks filenames into 400-row batches (like
  overrides_map) before the IN (...) query. query_artists (tree view)
  passes every song across up to 50 artists, which could push the
  placeholder count past SQLite's older variable limit; query_page's
  small pages are unaffected. (CodeRabbit: Stability & Availability)
- Rebuild static/tailwind.min.css: the ◆N difficulty badge introduced
  bg-blue-900/30 + text-blue-300, which were never compiled into the
  committed stylesheet, failing the tailwind-fresh CI gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-07 23:57:20 +02:00

622 lines
51 KiB
HTML

<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FeedBack</title>
<!-- Placeholder favicon — emoji SVG data URI. Swap for a real logo later. See #55. -->
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='14' font-size='14'%3E%F0%9F%8E%B8%3C/text%3E%3C/svg%3E">
<!-- Tailwind utility classes are served from a prebuilt static
stylesheet (regenerated by scripts/build-tailwind.sh). The old
Play CDN (cdn.tailwindcss.com) JIT scanned the DOM ~1.8x/sec
on the main thread, dropping ~26% of frames with the 3D
highway running — see feedBack-desktop#110. Theme extensions
(dark/accent/gold colors, Inter font) live in tailwind.config.js. -->
<link rel="stylesheet" href="/static/tailwind.min.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/vendor/shepherd.css">
<link rel="stylesheet" href="/static/tour-engine.css">
<!-- Diagnostics console capture must wrap console.* before any other
script logs anything; load it as early as possible. See
docs/diagnostics-bundle-spec.md (feedBack#166). -->
<script src="/static/diagnostics.js"></script>
<script src="/static/capabilities.js"></script>
<script src="/static/capabilities/library.js"></script>
<script src="/static/capabilities/tuning.js"></script>
<script src="/static/capabilities/working-tuning.js"></script>
<script src="/static/capabilities/audio-session.js"></script>
<script src="/static/capabilities/audio-effects.js"></script>
<script src="/static/capabilities/playback.js"></script>
<!-- fee[dB]ack v0.3.0: ui.library-card-injection capability (plugin card actions). -->
<script src="/static/capabilities/library-card-actions.js"></script>
<script src="/static/capabilities/visualization.js"></script>
<script src="/static/capabilities/note-detection.js"></script>
<script src="/static/capabilities/midi-input.js"></script>
</head>
<body class="bg-dark-900 text-gray-200 font-display">
<!-- Navigation -->
<nav id="navbar" class="fixed top-0 w-full z-50 transition-all duration-300">
<div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
<div class="flex items-end gap-1.5">
<a href="#" onclick="showScreen('home');return false" class="text-xl font-bold bg-gradient-to-r from-accent-light to-purple-400 bg-clip-text text-transparent">
FeedBack
</a>
<span id="app-version" class="text-xs text-gray-600 mb-0.5"></span>
</div>
<div class="hidden md:flex items-center gap-8">
<a href="#" onclick="showScreen('home');return false" class="text-sm text-gray-400 hover:text-white transition">Library</a>
<a href="#" onclick="showScreen('favorites');return false" class="text-sm text-gray-400 hover:text-white transition">Favorites</a>
<a href="#" onclick="document.getElementById('upload-songs-file').click();return false" class="text-sm text-gray-400 hover:text-white transition">Upload</a>
<span id="nav-plugins" class="contents"></span>
<a href="#" onclick="showScreen('settings');return false" class="text-sm text-gray-400 hover:text-white transition">Settings</a>
</div>
<!-- Mobile menu -->
<button onclick="document.getElementById('mobile-menu').classList.toggle('hidden')" class="md:hidden text-gray-400">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
</div>
<div id="mobile-menu" class="hidden md:hidden bg-dark-800/95 backdrop-blur border-t border-gray-800">
<div class="px-6 py-4 flex flex-col gap-3">
<a href="#" onclick="showScreen('home');this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Library</a>
<a href="#" onclick="showScreen('favorites');this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Favorites</a>
<a href="#" onclick="document.getElementById('upload-songs-file').click();this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Upload</a>
<span id="mobile-nav-plugins" class="flex flex-col gap-2 border-t border-b border-gray-800 py-2 my-1">
<span class="text-xs text-gray-600 uppercase tracking-wider">Plugins</span>
</span>
<a href="#" onclick="showScreen('settings');this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Settings</a>
</div>
</div>
</nav>
<!-- Hidden file input shared by the navbar "Upload" link. Kept at body
level so it stays reachable regardless of which screen is active. -->
<input type="file" id="upload-songs-file" accept=".feedpak,.sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
<!-- ══ HOME (Hero + Library) ══════════════════════════════════════════ -->
<div id="home" class="screen active">
<!-- Library -->
<section id="library-section" class="max-w-7xl mx-auto px-6 pt-24 pb-16">
<div id="alpha-warning-banner" class="hidden mb-6 px-4 py-3 bg-amber-900/30 border border-amber-500/30 rounded-xl flex items-start gap-3" role="status">
<span class="text-amber-400 text-lg leading-none mt-0.5" aria-hidden="true"></span>
<div class="text-sm text-amber-100">
<strong class="text-amber-300">Heads up — this is an alpha build.</strong>
Some things may be broken or change without warning. If you hit a bug, please file an issue. Thanks for trying it out!
</div>
</div>
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-10">
<div>
<h2 id="lib-title" class="text-3xl font-bold text-white">Your Library</h2>
<p class="text-gray-500 mt-1" id="lib-count"></p>
</div>
<div class="flex gap-3 w-full md:w-auto flex-wrap">
<select id="lib-provider" onchange="setLibraryProvider(this.value)"
aria-label="Library source"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none" title="Library source">
<option value="local">My Library</option>
</select>
<!-- View toggle -->
<div class="flex bg-dark-700 border border-gray-800 rounded-xl overflow-hidden">
<button id="view-grid-btn" onclick="setLibView('grid')" class="px-3 py-2.5 text-sm transition" title="Grid view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
</button>
<button id="view-tree-btn" onclick="setLibView('tree')" class="px-3 py-2.5 text-sm transition" title="Artist/Album view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="3" rx="1"/><rect x="3" y="6" width="12" height="3" rx="1"/><rect x="3" y="11" width="12" height="3" rx="1"/></svg>
</button>
<button id="view-folder-btn" onclick="setLibView('folder')" class="px-3 py-2.5 text-sm transition" title="Folder view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><path d="M1 3.5A1.5 1.5 0 012.5 2h3.086a1.5 1.5 0 011.06.44l.915.914H13.5A1.5 1.5 0 0115 4.914V12.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5v-9z"/></svg>
</button>
</div>
<!-- Grid controls -->
<select id="lib-sort" onchange="sortLibrary()"
class="lib-nontree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="artist">Artist A-Z</option>
<option value="artist-desc">Artist Z-A</option>
<option value="title">Title A-Z</option>
<option value="title-desc">Title Z-A</option>
<option value="recent">Recently Added</option>
<option value="year-desc">Year (newest)</option>
<option value="year">Year (oldest)</option>
<option value="tuning">Tuning</option>
<option value="difficulty">Difficulty (easiest first)</option>
<option value="difficulty-desc">Difficulty (hardest first)</option>
</select>
<!-- Format filter (shared) -->
<select id="lib-format" onchange="sortLibrary()"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none" title="Filter by format">
<option value="">All formats</option>
<option value="sloppak">Feedpak</option>
<option value="loose">Folder</option>
</select>
<!-- Tree controls -->
<button onclick="toggleAllArtists(true)" class="lib-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Expand All</button>
<button onclick="toggleAllArtists(false)" class="lib-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Collapse All</button>
<!-- Filters drawer toggle (feedBack#129) -->
<button onclick="toggleLibFilters()" id="btn-lib-filters"
class="bg-dark-700 border border-gray-800 hover:border-accent/40 rounded-xl px-4 py-2.5 text-sm text-gray-300 transition flex items-center gap-2"
title="Filter by parts, tuning, lyrics">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4h18M6 12h12M10 20h4"/></svg>
<span>Filters</span>
<span id="lib-filters-count" class="hidden bg-accent/30 text-accent-light text-xs font-semibold rounded-full px-1.5 py-0.5 min-w-[1.25rem] text-center">0</span>
</button>
<!-- Shared -->
<input type="text" id="lib-filter" placeholder="Search songs..." oninput="filterLibrary()"
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 focus:ring-1 focus:ring-accent/30 outline-none flex-1 md:w-60 transition">
</div>
</div>
<!-- Active-filter chip row (only visible when filters are set, feedBack#129) -->
<div id="lib-filter-chips" class="hidden flex flex-wrap gap-2 mb-5"></div>
<div id="lib-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
<!-- Cards populated by JS -->
</div>
<div id="lib-tree" class="space-y-2 hidden">
<!-- Tree populated by JS -->
</div>
<div id="lib-folder-tree" class="space-y-1 hidden">
<!-- Folder tree populated by JS when Folders source is active -->
</div>
</section>
<!-- ══ Filters drawer (feedBack#129/#69/#22) ═════════════════════ -->
<div id="lib-filter-overlay" class="fixed inset-0 bg-black/40 z-40 hidden"
onclick="toggleLibFilters(false)"></div>
<aside id="lib-filter-drawer"
class="fixed top-0 right-0 h-full w-full sm:w-96 bg-dark-800 border-l border-gray-800 z-50 transform translate-x-full transition-transform duration-200 overflow-y-auto">
<div class="p-6 space-y-6">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-white">Filters</h3>
<button onclick="toggleLibFilters(false)" class="text-gray-500 hover:text-white" title="Close">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<section>
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">Arrangements</div>
<p class="text-xs text-gray-600 mb-3">Click cycles: any → require → exclude</p>
<div id="filter-arrangements" class="flex flex-wrap gap-2"></div>
</section>
<section id="filter-stems-section">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">Stems <span class="text-gray-600 normal-case font-normal">(sloppak)</span></div>
<p class="text-xs text-gray-600 mb-3">Click cycles: any → require → exclude</p>
<div id="filter-stems" class="flex flex-wrap gap-2"></div>
</section>
<section>
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">Lyrics</div>
<div id="filter-lyrics" class="flex flex-wrap gap-2"></div>
</section>
<section>
<details>
<summary class="cursor-pointer flex items-center justify-between text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">
<span>Tuning</span>
<span id="filter-tunings-summary" class="text-gray-600 normal-case font-normal text-xs">All tunings</span>
</summary>
<div id="filter-tunings" class="mt-3 space-y-1 max-h-64 overflow-y-auto pr-1"></div>
</details>
</section>
<div class="flex items-center justify-between pt-4 border-t border-gray-800">
<button onclick="clearLibFilters()" class="text-sm text-gray-400 hover:text-white transition">Clear all</button>
<button onclick="toggleLibFilters(false)" class="bg-accent hover:bg-accent-light px-4 py-2 rounded-lg text-sm font-medium text-white transition">Done</button>
</div>
</div>
</aside>
</div>
<!-- ══ FAVORITES ════════════════════════════════════════════════════ -->
<div id="favorites" class="screen">
<section class="max-w-7xl mx-auto px-6 pt-24 pb-16">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-10">
<div>
<h2 class="text-3xl font-bold text-white">Favorites</h2>
<p class="text-gray-500 mt-1" id="fav-count"></p>
</div>
<div class="flex gap-3 w-full md:w-auto flex-wrap">
<div class="flex bg-dark-700 border border-gray-800 rounded-xl overflow-hidden">
<button id="fav-view-grid-btn" onclick="setFavView('grid')" class="px-3 py-2.5 text-sm transition" title="Grid view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
</button>
<button id="fav-view-tree-btn" onclick="setFavView('tree')" class="px-3 py-2.5 text-sm transition" title="Artist/Album view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="3" rx="1"/><rect x="3" y="6" width="12" height="3" rx="1"/><rect x="3" y="11" width="12" height="3" rx="1"/></svg>
</button>
</div>
<select id="fav-sort" onchange="sortFavorites()"
class="fav-grid-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="artist">Artist A-Z</option>
<option value="artist-desc">Artist Z-A</option>
<option value="title">Title A-Z</option>
<option value="title-desc">Title Z-A</option>
<option value="recent">Recently Added</option>
<option value="tuning">Tuning</option>
</select>
<button onclick="toggleAllFavoriteArtists(true)" class="fav-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Expand All</button>
<button onclick="toggleAllFavoriteArtists(false)" class="fav-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Collapse All</button>
<input type="text" id="fav-filter" placeholder="Search favorites..." oninput="filterFavorites()"
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 focus:ring-1 focus:ring-accent/30 outline-none flex-1 md:w-60 transition">
</div>
</div>
<div id="fav-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
</div>
<div id="fav-tree" class="space-y-2 hidden">
</div>
</section>
</div>
<!-- ══ Plugin screens injected dynamically by loadPlugins() ══════════ -->
<!-- ══ SETTINGS ═══════════════════════════════════════════════════════ -->
<div id="settings" class="screen">
<div class="max-w-2xl mx-auto px-6 pt-24 pb-16">
<button onclick="showScreen('home')" class="text-gray-500 hover:text-white text-sm mb-6 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> Back
</button>
<h2 class="text-3xl font-bold text-white mb-8">Settings</h2>
<div class="space-y-10">
<!-- App Updates — Velopack auto-update, desktop only. Stays
hidden in the plain web app; setupAppUpdates() unhides
this block when window.feedBackDesktop.update exists,
and shows a disabled "not available on Linux" fallback
when running on Linux. -->
<div id="app-updates-block" class="hidden border border-gray-800 rounded-xl bg-dark-800/40 p-5">
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">App Updates</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block" for="app-update-channel">Update channel</label>
<select id="app-update-channel"
class="w-full bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="stable">Stable</option>
<option value="rc">Release candidate</option>
<option value="beta">Beta</option>
<option value="alpha">Alpha</option>
</select>
</div>
<div class="flex items-end">
<button id="app-update-check-now"
class="bg-accent hover:bg-accent-light px-4 py-2.5 rounded-xl text-sm font-medium text-white transition disabled:opacity-50">
Check for updates
</button>
</div>
</div>
<p id="app-update-status" class="text-xs text-gray-500 mt-3">Loading updater status…</p>
<p id="app-update-linux-note" class="hidden text-xs text-yellow-300 mt-2">
Auto-update is not available on Linux —
<a href="https://github.com/got-feedback/feedBack-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download new versions from GitHub Releases</a>.
</p>
</div>
<!-- ── Core FeedBack settings ─────────────────────────────── -->
<section>
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">FeedBack</h3>
<div class="space-y-6">
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Library Folder Path</label>
<div class="flex gap-3">
<input type="text" id="dlc-path" placeholder="/path/to/your/library"
class="flex-1 bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
<button onclick="pickDlcFolder()" id="btn-pick-dlc" class="hidden bg-dark-600 hover:bg-dark-500 px-4 py-2.5 rounded-xl text-sm text-gray-300 transition whitespace-nowrap">📂 Browse</button>
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
</div>
</div>
<div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" id="setting-lefty" onchange="highway.setLefty(this.checked)"
class="rounded border-gray-600 bg-dark-700 text-accent focus:ring-accent/40">
<span class="text-sm text-gray-300">Left-handed <span class="text-gray-500">(invert frets on the note highway)</span></span>
</label>
</div>
<div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" id="setting-autoplay-exit" checked onchange="setAutoplayExit(this.checked)"
class="rounded border-gray-600 bg-dark-700 text-accent focus:ring-accent/40">
<span class="text-sm text-gray-300">Autoplay &amp; auto-exit <span class="text-gray-500">(start songs/lessons automatically and return to the menu when the score screen closes)</span></span>
</label>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Default Arrangement</label>
<select id="default-arrangement"
onchange="persistSetting('default_arrangement', 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="">Most notes (auto)</option>
<option value="Lead">Lead</option>
<option value="Rhythm">Rhythm</option>
<option value="Bass">Bass</option>
</select>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Arrangement Names</label>
<select id="arrangement-naming-mode"
onchange="_onNamingModeChange(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="smart">Smart (Lead, Alt. Lead, Rhythm, Bass…)</option>
<option value="legacy">Legacy (Combo, Bass)</option>
</select>
</div>
<div>
<label for="setting-av-offset" class="text-sm font-medium text-gray-400 mb-2 block">
A/V Sync Offset: <span id="setting-av-offset-val">0</span> ms
</label>
<input type="range" id="setting-av-offset" min="-1000" max="1000" step="1" value="0"
oninput="setAvOffsetMs(this.value)"
class="w-full slider-input">
<p class="text-xs text-gray-600 mt-1">Positive = audio plays ahead of visual notes; raise this value to catch the highway up. Adjust live with the [ and ] keys (Shift for ±50 ms). Auto-saves on every change.</p>
</div>
<div>
<label for="demucs-server-url" class="text-sm font-medium text-gray-400 mb-2 block">Demucs Server (for stem separation)</label>
<div class="flex gap-3">
<input type="text" id="demucs-server-url" placeholder="http://192.168.1.100:7865"
class="flex-1 bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
</div>
<p class="text-xs text-gray-600 mt-1">Optional. Run <a href="https://github.com/got-feedBack/feedBack-demucs-server" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">feedBack-demucs-server</a> on a machine with a GPU to offload stem splitting and avoid resource exhaustion on the host running FeedBack.</p>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Library</label>
<div class="flex items-center gap-3">
<button onclick="rescanLibrary()" id="btn-rescan" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Rescan Library</button>
<button onclick="fullRescanLibrary()" id="btn-full-rescan" class="bg-dark-600 hover:bg-red-900/30 px-5 py-2.5 rounded-xl text-sm text-gray-400 transition">Full Rescan</button>
<span id="rescan-status" class="text-xs text-gray-500"></span>
</div>
<p class="text-xs text-gray-600 mt-1">Rescan checks for new songs. Full Rescan clears the cache and re-imports everything.</p>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Backup</label>
<div class="flex items-center gap-3">
<button onclick="exportSettings()" id="btn-export-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Settings</button>
<button onclick="document.getElementById('import-settings-file').click()" id="btn-import-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Import Settings</button>
<input type="file" id="import-settings-file" accept="application/json,.json" class="hidden" onchange="importSettings(this.files[0]); this.value=''">
<span id="backup-status" class="text-xs text-gray-500"></span>
</div>
<p class="text-xs text-gray-600 mt-1">Export bundles server config, browser preferences, and opted-in plugin data into one JSON file. Import overwrites current settings and reloads.</p>
</div>
<!-- ── Diagnostics (feedBack#166) ────────────────────── -->
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Diagnostics</label>
<div class="grid grid-cols-2 gap-2 mb-3 text-xs text-gray-400">
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-system" checked class="rounded border-gray-600 bg-dark-700 text-accent"> System info</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-hardware" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Hardware (CPU/GPU/RAM)</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-logs" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Server logs (last 5 MB)</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-console" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Browser console + errors</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-plugins" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Plugin diagnostics</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-redact" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Redact paths &amp; song names</label>
</div>
<div class="flex items-center gap-3">
<button onclick="previewDiagnostics()" id="btn-diag-preview" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Preview Bundle</button>
<button onclick="exportDiagnostics()" id="btn-diag-export" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Diagnostics</button>
<span id="diag-status" class="text-xs text-gray-500"></span>
</div>
<p class="text-xs text-gray-600 mt-1">Bundles server logs, hardware info, plugin inventory, and the browser console transcript into one zip for bug reports. Redaction strips DLC paths, song filenames, and IP addresses by default. Attach to GitHub issues; AI agents can parse the included <code>manifest.json</code>.</p>
<div id="diag-preview" class="hidden mt-3 bg-dark-700 border border-gray-800 rounded-xl p-3 text-xs text-gray-400 max-h-96 overflow-auto"></div>
</div>
<div id="settings-status" class="text-sm text-gray-500"></div>
</div>
</section>
<!-- ── Plugin settings ─────────────────────────────────────── -->
<section id="plugin-settings-area" class="hidden">
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">Plugins</h3>
<div class="space-y-6">
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Plugin Updates</label>
<div class="flex items-center gap-3 mb-2">
<button onclick="checkPluginUpdates()" id="btn-check-updates" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Check for Updates</button>
<span id="updates-status" class="text-xs text-gray-500"></span>
</div>
<div id="plugin-updates-list" class="space-y-2"></div>
</div>
<!-- Per-plugin collapsible sections injected here -->
<div id="plugin-settings" class="space-y-3"></div>
</div>
</section>
<!-- ── About / Source / License (AGPL §13 disclosure) ──────── -->
<section>
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">About</h3>
<div class="space-y-2 text-sm text-gray-400">
<div>FeedBack <span id="app-version-about" class="text-gray-500"></span></div>
<div>Licensed under <a id="about-license-link" href="https://github.com/got-feedback/feedBack/blob/main/LICENSE" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">GNU AGPL v3.0</a>.</div>
<div><a id="about-source-link" href="https://github.com/got-feedback/feedBack" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">Source code repository</a></div>
<p class="text-xs text-gray-600 mt-2">FeedBack is free software. You can redistribute it and modify it under the terms of the AGPL. If you run a modified version that interacts with users over a network, you must make the modified source available to those users.</p>
</div>
</section>
</div>
</div>
</div>
<!-- Global audio element (outside player so it's always accessible) -->
<audio id="audio" preload="auto"></audio>
<script>
// Web Audio API fallback for iOS WKWebView which can't play WAV via <audio>
(function() {
var _waCtx = null, _waSource = null, _waStartTime = 0, _waBuffer = null, _waPlaying = false, _waLoading = false;
var audioEl = document.getElementById('audio');
window._webAudioFallback = {
load: function(url, cb) {
if (!url || _waLoading) return;
_waLoading = true;
if (!_waCtx) _waCtx = new (window.AudioContext || window.webkitAudioContext)();
console.log('[WebAudio] Loading: ' + url);
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'arraybuffer';
xhr.onload = function() {
_waCtx.decodeAudioData(xhr.response, function(decoded) {
_waBuffer = decoded;
_waLoading = false;
console.log('[WebAudio] Decoded: ' + decoded.duration.toFixed(1) + 's');
if (cb) cb();
}, function(e) {
_waLoading = false;
console.error('[WebAudio] Decode error:', e);
});
};
xhr.onerror = function() { _waLoading = false; };
xhr.send();
},
play: function() {
if (!_waBuffer || !_waCtx) return false;
this.stop();
if (_waCtx.state === 'suspended') _waCtx.resume();
_waSource = _waCtx.createBufferSource();
_waSource.buffer = _waBuffer;
// AudioBufferSourceNode has no preservesPitch equivalent so changing playbackRate here also changes pitch
_waSource.playbackRate.value = audioEl.playbackRate || 1;
_waSource.connect(_waCtx.destination);
_waStartTime = _waCtx.currentTime;
_waSource.start(0);
_waPlaying = true;
console.log('[WebAudio] Playing');
return true;
},
stop: function() {
if (_waSource) { try { _waSource.stop(); } catch(e){} _waSource = null; }
_waPlaying = false;
},
getTime: function() {
if (!_waPlaying || !_waCtx) return 0;
return _waCtx.currentTime - _waStartTime;
},
isActive: function() { return _waPlaying; },
isReady: function() { return !!_waBuffer; },
getDuration: function() { return _waBuffer ? _waBuffer.duration : 0; }
};
})();
</script>
<!-- ══ PLAYER ═════════════════════════════════════════════════════════ -->
<div id="player" class="screen">
<canvas id="highway"></canvas>
<div id="player-hud" class="absolute top-0 left-0 right-0 flex justify-between px-4 py-3 pointer-events-none z-10">
<div class="text-sm">
<span id="hud-artist" class="text-gray-300"></span><span id="hud-title" class="text-white font-semibold"></span>
<br><span id="hud-arrangement" class="text-gray-500 text-xs"></span>
<br><span id="hud-tuning" class="text-gray-500 text-xs"></span>
<br><span id="hud-tuning-targets" class="text-gray-500 text-xs"></span>
</div>
<div class="text-right">
<div id="hud-time" class="text-sm text-gray-400"></div>
<div id="hud-avoffset" class="text-xs text-gray-500 tabular-nums hidden" title="A/V offset — [ and ] to adjust, Shift for ±50 ms">A/V 0 ms</div>
</div>
</div>
<!-- #section-practice-bar lives OUTSIDE #player-controls on purpose: its
nested chip <button>s would otherwise be matched by plugins' legacy
`#player-controls`-scoped `button:last-child` injector anchor, making
insertBefore throw (the node isn't a direct child) and aborting the
shared playSong wrapper chain. The #player-footer wrapper keeps it
visually directly above the transport row; margin-top:auto moves here
from #player-controls so the whole footer still pins to the bottom. -->
<div id="player-footer">
<!-- Section Practice is collapsed behind a single pill; the multi-row bar
is a popover opened from the pill (toggleSectionPracticePopover). The
pill + popover live in #player-footer (NOT #player-controls) so the
popover's nested chip <button>s can't be matched by a plugin's
`#player-controls > button:last-child` injector anchor. -->
<div id="section-practice-control" class="section-practice-control section-practice-control--hidden">
<button type="button" id="section-practice-pill" class="section-practice-pill"
aria-haspopup="dialog" aria-expanded="false" aria-controls="section-practice-bar"
aria-label="Section practice"
onclick="toggleSectionPracticePopover()" title="Section practice">
<span class="section-practice-pill-icon" aria-hidden="true">🎯</span>
<span class="section-practice-pill-text">Practice</span>
<span class="section-practice-pill-caret" aria-hidden="true"></span>
</button>
<div id="section-practice-bar" class="section-practice-bar" role="dialog" aria-label="Section practice">
<div class="section-practice-row">
<label class="section-practice-mode-wrap" title="Loop the selected section until turned off">
<input type="checkbox" id="section-practice-mode" onchange="onSectionPracticeModeChange()">
<span class="section-practice-mode-text">Practice Section</span>
</label>
<span class="section-practice-label">Sections:</span>
<div id="section-practice-scroll" class="section-practice-scroll" role="toolbar" aria-label="Section selection"></div>
</div>
</div>
</div>
<div id="player-controls" class="flex items-center gap-2 px-4 py-2.5 bg-dark-800 border-t border-gray-800/50 flex-wrap">
<button onclick="seekBy(-5)" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Seek Back 5s" aria-label="Seek Back 5s"><img src="/static/svg/rw.svg" class="button-icon-svg" alt="" aria-hidden="true" /> 5s</button>
<button type="button" onclick="restartCurrentSong()" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Restart song" aria-label="Restart song"></button>
<button onclick="togglePlay()" id="btn-play" class="px-4 py-1.5 bg-accent hover:bg-accent-light rounded-lg text-xs font-semibold text-white transition" aria-label="Play" title="Play" aria-pressed="false"><img src="/static/svg/play.svg" class="button-icon-svg" alt="" aria-hidden="true" /></button>
<button onclick="seekBy(5)" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Seek Forward 5s" aria-label="Seek Forward 5s">5s <img src="/static/svg/ff.svg" class="button-icon-svg" alt="" aria-hidden="true" /></button>
<select id="arr-select" onchange="changeArrangement(this.value)" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none max-w-[130px]"></select>
<button id="arr-default-pin" type="button" onclick="pinCurrentArrangementDefault()" aria-pressed="false" aria-label="Select an arrangement to make it the default" class="w-8 h-8 inline-flex items-center justify-center bg-dark-600 border border-gray-700 hover:bg-dark-500 rounded-lg text-xs text-gray-400 transition" title="Select an arrangement to make it the default"></button>
<input type="range" id="speed-slider" min="15" max="150" value="100" step="5" oninput="setSpeed(this.value/100)" class="w-20 accent-accent slider-input">
<span id="speed-label" class="text-xs text-gray-500 w-10">1.0x</span>
<span id="mastery-slider-label" class="text-xs text-gray-500 ml-1">Difficulty</span>
<input type="range" id="mastery-slider" min="0" max="100" value="100" step="5" oninput="setMastery(this.value)" class="w-20 accent-accent slider-input" title="Master difficulty — low = simpler chart, high = full" aria-labelledby="mastery-slider-label">
<span id="mastery-label" class="text-xs text-gray-500 w-10">100%</span>
<span id="player-av-offset-slider-label" class="text-xs text-gray-500 ml-1">A/V sync offset (ms)</span>
<input type="range" id="player-av-offset-slider" min="-1000" max="1000" value="0" step="1" oninput="setAvOffsetMs(this.value)" class="w-20 accent-accent slider-input" title="A/V sync offset (ms) — positive = audio plays ahead of visuals. [ and ] adjust ±10 ms (Shift = ±50). Double-click to reset." ondblclick="setAvOffsetMs(0)" aria-labelledby="player-av-offset-slider-label">
<span id="player-av-offset-label" class="text-xs text-gray-500 w-12 tabular-nums">+0ms</span>
<div id="mixer-control">
<div id="mixer-anchor" class="relative">
<button id="btn-mixer" type="button" onclick="window.feedBack.audio.toggleMixer()" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" aria-haspopup="true" aria-expanded="false" aria-controls="mixer-popover" title="Audio mixer">Mixer ▾</button>
<div id="mixer-popover" class="hidden absolute right-0 bottom-full mb-2 z-50 bg-dark-700 border border-gray-800 rounded-xl shadow-xl" role="group" aria-label="Audio mixer"></div>
</div>
</div>
<button onclick="highway.toggleLyrics()" id="btn-lyrics" class="px-3 py-1.5 bg-purple-900/40 hover:bg-purple-900/60 rounded-lg text-xs text-purple-300 transition">Lyrics ✓</button>
<select id="quality-select" onchange="highway.setRenderScale(parseFloat(this.value))" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none">
<option value="1">HD</option>
<option value="0.75">Medium</option>
<option value="0.5">Low</option>
</select>
<select id="min-scale-select" aria-label="Minimum auto resolution" onchange="highway.setMinRenderScale && highway.setMinRenderScale(parseFloat(this.value))" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none" title="Minimum auto resolution — how far the highway may lower its resolution to hold the frame rate on heavy scenes. 'Full' disables auto-downscaling, but the Quality selector still caps the maximum (so it's only full resolution at Quality = HD).">
<option value="0.25">Min res: 25%</option>
<option value="0.5">Min res: 50%</option>
<option value="0.75">Min res: 75%</option>
<option value="1">Min res: Full</option>
</select>
<span id="viz-picker-label" class="text-xs text-gray-500 ml-1 sr-only">Visualization</span>
<select id="viz-picker" onchange="setViz(this.value)" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none" aria-labelledby="viz-picker-label" title="Visualization">
<option value="auto">Auto (match arrangement)</option>
<option value="default">Classic 2D Highway</option>
<!-- Additional entries populated on load from /api/plugins (feedBack#36).
The bundled 3D Highway plugin (plugins/highway_3d/) registers as
`highway_3d` and is the default selection on fresh installs — see
_populateVizPicker() in app.js. -->
</select>
<span class="text-gray-700 mx-1">|</span>
<button onclick="setLoopStart()" id="btn-loop-a" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Set loop start at current time">A</button>
<button onclick="setLoopEnd()" id="btn-loop-b" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Set loop end at current time">B</button>
<button onclick="saveCurrentLoop()" id="btn-loop-save" class="px-3 py-1.5 bg-dark-600 hover:bg-green-900/50 rounded-lg text-xs text-gray-300 transition hidden" title="Save this loop">Save</button>
<button onclick="clearLoop()" id="btn-loop-clear" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-500 transition hidden" title="Clear loop"></button>
<span id="loop-label" class="text-xs text-gray-600"></span>
<select id="saved-loops" onchange="loadSavedLoop(this.value)" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none max-w-[160px] hidden">
<option value="">Saved Loops</option>
</select>
<button onclick="deleteSelectedLoop()" id="btn-loop-delete" class="px-2 py-1.5 bg-dark-600 hover:bg-red-900/50 rounded-lg text-xs text-gray-500 hover:text-red-400 transition hidden" title="Delete selected loop"></button>
<!-- Editor ⇄ 3D Highway round-trip. "Edit region" opens the Song Editor
scrolled to the active loop (or the section at the playhead).
"↩ Editor" returns to the editing position you came from; it only
appears after a Loop-in-3D handoff. Both are hidden when the editor
plugin isn't loaded (state managed by _updateEditRegionBtn). -->
<button onclick="editRegionInEditor()" id="btn-edit-region" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition hidden" title="Edit this region in the Song Editor">✎ Edit region</button>
<button onclick="returnToEditorFromHighway()" id="btn-return-editor" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition hidden" title="Return to the editor where you left off">↩ Editor</button>
<button onclick="showScreen('home')" class="ml-auto px-3 py-1.5 bg-dark-600 hover:bg-red-900/50 rounded-lg text-xs text-gray-400 hover:text-red-400 transition">✕ Close</button>
</div>
</div>
</div>
<script src="/static/highway.js"></script>
<script src="/static/vendor/lottie.min.js"></script>
<script src="/static/lottie-api.js"></script>
<script src="/static/app.js"></script>
<script src="/static/audio-mixer.js"></script>
<script src="/static/vendor/shepherd.min.js"></script>
<script src="/static/tour-engine.js"></script>
<script>
// Navbar scroll effect
window.addEventListener('scroll', () => {
const nav = document.getElementById('navbar');
if (window.scrollY > 50) {
nav.classList.add('bg-dark-900/80', 'backdrop-blur-lg', 'border-b', 'border-gray-800/50');
} else {
nav.classList.remove('bg-dark-900/80', 'backdrop-blur-lg', 'border-b', 'border-gray-800/50');
}
});
</script>
</body>
</html>