diff --git a/webui/static/discover.js b/webui/static/discover.js
index c7ba7e36..b1bee6e9 100644
--- a/webui/static/discover.js
+++ b/webui/static/discover.js
@@ -1702,45 +1702,162 @@ function _renderSyncStatusBlock(idPrefix) {
`;
}
-async function loadDecadeBrowserTabs() {
- try {
- const tabsContainer = document.getElementById('decade-tabs');
- const contentsContainer = document.getElementById('decade-tab-contents');
+// ===============================
+// TABBED BROWSER HELPER
+// ===============================
+//
+// Drives the lifecycle the decade browser ("Time Machine") and
+// genre browser ("Browse by Genre") share: fetch tab list → paint
+// tab strip + per-tab content shells → fetch + render content for
+// the active tab → handle empty / error states.
+//
+// The two browsers paint slightly different markup (different CSS
+// prefixes, different action buttons, different sync handlers) but
+// the lifecycle is identical. Each browser registers a config; the
+// helper handles the rest.
+//
+// Two-phase render:
+// Phase 1 (loadTabs) — paint tab strip + N content shells,
+// each shell containing a loading
+// spinner in its playlist container,
+// then trigger Phase 2 for first tab.
+// Phase 2 (loadTabContent) — fetch tracks for one tab, swap the
+// spinner in its playlist container
+// for the rendered track list.
+//
+// Renderers stay per-browser because action buttons + classes
+// legitimately differ. The helper owns the lifecycle, not the look.
+function createTabbedBrowserSection(config) {
+ const cfg = Object.assign({
+ // Diagnostic id used in console errors.
+ id: 'tabbed-browser',
+ // DOM IDs of the tab-strip + per-tab-contents containers.
+ tabsContainerId: null,
+ contentsContainerId: null,
+ // Async fn returning array of tab descriptors (e.g. decades).
+ fetchTabs: null,
+ // (tab) => string unique id for one tab (e.g. 'decade-1980').
+ // Used as the prefix for that tab's content + playlist + sync IDs.
+ tabId: null,
+ // (tab) => string HTML for one tab button. Receives `(tab, isActive)`.
+ renderTabButton: null,
+ // (tab) => string HTML for one tab's content shell (action
+ // buttons + sync-status block + empty playlist container).
+ // The playlist container inside MUST have id `${tabId}-playlist`
+ // so the helper can fill it during Phase 2.
+ renderTabShell: null,
+ // Async fn (tab) => array of tracks for that tab.
+ fetchTabContent: null,
+ // (tracks, tab) => string HTML for the playlist container.
+ renderTabTracks: null,
+ // Copy / messages.
+ emptyTabsMessage: 'No content available',
+ emptyContentMessage: (tab) => 'No tracks found',
+ errorTabsMessage: 'Failed to load',
+ errorContentMessage: 'Failed to load tracks',
+ // Fired after Phase 1 paints the tab strip + shells, before
+ // Phase 2 is triggered for the first tab. Useful for caching
+ // the tab list (e.g. `availableGenres = ...`).
+ onTabsRendered: null,
+ }, config || {});
- if (!tabsContainer || !contentsContainer) return;
+ async function loadTabs() {
+ try {
+ const tabsContainer = document.getElementById(cfg.tabsContainerId);
+ const contentsContainer = document.getElementById(cfg.contentsContainerId);
+ if (!tabsContainer || !contentsContainer) return;
- // Fetch available decades from backend
- const response = await fetch('/api/discover/decades/available');
- if (!response.ok) {
- throw new Error('Failed to fetch available decades');
+ const tabs = await cfg.fetchTabs();
+ if (!Array.isArray(tabs) || tabs.length === 0) {
+ tabsContainer.innerHTML = `
@@ -1770,23 +1887,69 @@ async function loadDecadeBrowserTabs() {
`;
- });
+ },
+ fetchTabContent: async (decade) => {
+ const response = await fetch(`/api/discover/decade/${decade.year}`);
+ if (!response.ok) throw new Error('Failed to fetch decade playlist');
+ const data = await response.json();
+ if (!data.success) return [];
+ // Side-effect: cache + active marker, exactly as old code did.
+ decadeTracksCache[decade.year] = data.tracks || [];
+ activeDecade = decade.year;
+ return data.tracks || [];
+ },
+ renderTabTracks: (tracks) => _renderTabbedTrackList(tracks),
+ emptyContentMessage: (decade) => `No tracks found for the ${decade.year}s`,
+ errorTabsMessage: 'Failed to load decades',
+ errorContentMessage: 'Failed to load decade tracks',
+ emptyTabsMessage: 'No decade content available yet. Run a watchlist scan to populate your discovery pool!',
+ });
+ return _decadeBrowserTabsCtrl;
+}
- tabsContainer.innerHTML = tabsHTML;
- contentsContainer.innerHTML = contentsHTML;
-
- // Load first decade's tracks
- if (data.decades.length > 0) {
- await loadDecadeTracks(data.decades[0].year);
+// Shared track-row markup for tabbed browsers. Decade + genre rows
+// have the same shape — both pull from `track_data_json` first then
+// fall back to top-level fields. Lifted so the helper-driven
+// renderers don't each carry a copy.
+function _renderTabbedTrackList(tracks) {
+ let html = '';
+ tracks.forEach((track, index) => {
+ let trackData = track;
+ if (track.track_data_json) {
+ trackData = track.track_data_json;
}
- } catch (error) {
- console.error('Error loading decade browser tabs:', error);
- const tabsContainer = document.getElementById('decade-tabs');
- if (tabsContainer) {
- tabsContainer.innerHTML = '
';
- }
- }
+ const trackName = trackData.name || trackData.track_name || track.track_name || 'Unknown Track';
+ const artistName = trackData.artists?.[0]?.name || trackData.artists?.[0] || trackData.artist_name || track.artist_name || 'Unknown Artist';
+ const albumName = trackData.album?.name || trackData.album_name || track.album_name || 'Unknown Album';
+ const coverUrl = trackData.album?.images?.[0]?.url || track.album_cover_url || '/static/placeholder-album.png';
+ const durationMs = trackData.duration_ms || track.duration_ms || 0;
+
+ const durationMin = Math.floor(durationMs / 60000);
+ const durationSec = Math.floor((durationMs % 60000) / 1000);
+ const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
+
+ html += `
+
+
${index + 1}
+
+

+
+
+
${trackName}
+
${artistName}
+
+
${albumName}
+
${duration}
+
+ `;
+ });
+ html += '
';
+ return html;
+}
+
+async function loadDecadeBrowserTabs() {
+ return _getDecadeBrowserTabsCtrl().loadTabs();
}
function switchDecadeTab(decade) {
@@ -1817,71 +1980,7 @@ function switchDecadeTab(decade) {
}
async function loadDecadeTracks(decade) {
- try {
- const playlistContainer = document.getElementById(`decade-${decade}-playlist`);
- if (!playlistContainer) return;
-
- const response = await fetch(`/api/discover/decade/${decade}`);
- if (!response.ok) {
- throw new Error('Failed to fetch decade playlist');
- }
-
- const data = await response.json();
- if (!data.success || !data.tracks || data.tracks.length === 0) {
- playlistContainer.innerHTML = '';
- data.tracks.forEach((track, index) => {
- // Extract track data from track_data_json if available
- let trackData = track;
- if (track.track_data_json) {
- trackData = track.track_data_json;
- }
-
- // Get track properties with fallbacks
- const trackName = trackData.name || trackData.track_name || track.track_name || 'Unknown Track';
- const artistName = trackData.artists?.[0]?.name || trackData.artists?.[0] || trackData.artist_name || track.artist_name || 'Unknown Artist';
- const albumName = trackData.album?.name || trackData.album_name || track.album_name || 'Unknown Album';
- const coverUrl = trackData.album?.images?.[0]?.url || track.album_cover_url || '/static/placeholder-album.png';
- const durationMs = trackData.duration_ms || track.duration_ms || 0;
-
- const durationMin = Math.floor(durationMs / 60000);
- const durationSec = Math.floor((durationMs % 60000) / 1000);
- const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
-
- html += `
-
-
${index + 1}
-
-

-
-
-
${trackName}
-
${artistName}
-
-
${albumName}
-
${duration}
-
- `;
- });
- html += '
';
-
- playlistContainer.innerHTML = html;
-
- } catch (error) {
- console.error('Error loading decade tracks:', error);
- const playlistContainer = document.getElementById(`decade-${decade}-playlist`);
- if (playlistContainer) {
- playlistContainer.innerHTML = '
+ },
+ renderTabShell: (genre, isActive) => {
+ const tabId = _genreTabId(genre.name);
+ return `
+