From 55583c1db3a9e193cc0ecd4de4f4d7205cdae686 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 14:32:48 -0700 Subject: [PATCH] LB Sync tab cards: live updates parity with Tidal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial LB Sync tab (a7053a60 + df31d42b) rendered cards once and never updated them as the discovery / sync flow progressed — phase text stayed "Ready to discover", action button kept saying "Discover", no progress counts. Tidal's cards by contrast update phase + button + progress live throughout the entire flow because Tidal's polling code calls ``updateTidalCardPhase`` / ``updateTidalCardProgress`` at every state transition. Rather than patch the existing LB polling in sync-services.js with parallel update hooks at every transition (4–6 injection points across discovery + sync paths), this commit takes the lighter route: a single 500ms refresh loop that reads the canonical ``listenbrainzPlaylistStates`` dict the polling code already owns and updates the on-screen cards from it. The loop only ticks while the LB tab is the active Sync tab — auto-stops the moment the user switches away. - ``_refreshOneLbSyncCard(card)`` — updates phase text + color (via the shared ``getPhaseText`` / ``getPhaseColor`` helpers), action button label (via ``getActionButtonText``), and the per-card progress text in the same shape Tidal uses: ``♪ / ✓ / ✗ / %``. Switches to the sync-progress payload during syncing / sync_complete. - ``_startLbSyncCardRefreshLoop`` — idempotent; kicked on tab activation (in ``initializeSyncPage``) and right after the initial ``renderListenBrainzSyncPlaylists`` render if the tab is already visible. - Added the ``.playlist-card-progress`` slot to the LB card template; hidden initially when phase=fresh, populated by the refresh loop once discovery/sync begins. --- webui/static/sync-listenbrainz.js | 101 ++++++++++++++++++++++++++++++ webui/static/sync-services.js | 16 +++-- 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/webui/static/sync-listenbrainz.js b/webui/static/sync-listenbrainz.js index bf440422..fd3b1045 100644 --- a/webui/static/sync-listenbrainz.js +++ b/webui/static/sync-listenbrainz.js @@ -130,6 +130,7 @@ function renderListenBrainzSyncPlaylists() { ${phaseText} +
`; @@ -143,6 +144,14 @@ function renderListenBrainzSyncPlaylists() { handleListenBrainzSyncCardClick(mbid, title); }); }); + + // If the tab is currently visible, kick the refresh loop so cards + // start showing live state immediately. ``_startLbSyncCardRefreshLoop`` + // is idempotent + self-stops when the tab loses focus. + const tab = document.getElementById('listenbrainz-tab-content'); + if (tab && tab.classList.contains('active')) { + _startLbSyncCardRefreshLoop(); + } } async function handleListenBrainzSyncCardClick(playlistMbid, playlistTitle) { @@ -206,6 +215,98 @@ async function handleListenBrainzSyncCardClick(playlistMbid, playlistTitle) { } } +// Live card refresh — keeps the Sync-tab cards in sync with the +// canonical ``listenbrainzPlaylistStates`` dict that the discovery / +// sync polling loops own. Tidal does this via explicit +// ``updateTidalCardPhase`` / ``updateTidalCardProgress`` calls +// sprinkled through its polling code; we get the same UX with a +// single 500ms tick that reads the shared state. The loop only runs +// while the LB tab is the active Sync tab so it's cheap. + +let _lbSyncCardRefreshInterval = null; + +function _refreshOneLbSyncCard(card) { + const mbid = card.dataset.lbMbid; + if (!mbid) return; + const state = (typeof listenbrainzPlaylistStates !== 'undefined') + ? listenbrainzPlaylistStates[mbid] : null; + if (!state) return; + + const phase = state.phase || 'fresh'; + const phaseEl = card.querySelector('.playlist-card-phase-text'); + if (phaseEl) { + const text = (typeof getPhaseText === 'function') ? getPhaseText(phase) : phase; + const color = (typeof getPhaseColor === 'function') ? getPhaseColor(phase) : ''; + if (phaseEl.textContent !== text) phaseEl.textContent = text; + if (color) phaseEl.style.color = color; + } + + const btnEl = card.querySelector('.playlist-card-action-btn'); + if (btnEl) { + const btnText = (typeof getActionButtonText === 'function') + ? getActionButtonText(phase) : btnEl.textContent; + if (btnEl.textContent !== btnText) btnEl.textContent = btnText; + } + + // Discovery progress mirrors Tidal's per-card text: + // "♪ / ✓ / ✗ / %". + // During sync, swap to the sync progress payload the LB sync poller + // writes into state.lastSyncProgress (same shape Tidal uses). + const progEl = card.querySelector('.playlist-card-progress'); + if (!progEl) return; + if (phase === 'fresh') { + progEl.classList.add('hidden'); + progEl.textContent = ''; + return; + } + + if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) { + const sp = state.lastSyncProgress; + const matched = sp.matched_tracks || sp.spotify_matches || 0; + const total = sp.total_tracks || sp.spotify_total || 0; + const failed = (sp.failed_tracks !== undefined) + ? sp.failed_tracks : Math.max(0, total - matched); + const pct = total > 0 ? Math.round((matched / total) * 100) : 0; + progEl.textContent = `♪ ${total} / ✓ ${matched} / ✗ ${failed} / ${pct}%`; + progEl.classList.remove('hidden'); + return; + } + + const total = state.spotify_total || state.spotifyTotal || 0; + const matched = state.spotify_matches || state.spotifyMatches || 0; + const failed = Math.max(0, total - matched); + const pct = total > 0 ? Math.round((matched / total) * 100) + : (state.discovery_progress || state.discoveryProgress || 0); + progEl.textContent = `♪ ${total} / ✓ ${matched} / ✗ ${failed} / ${pct}%`; + progEl.classList.remove('hidden'); +} + +function _refreshAllLbSyncCards() { + document.querySelectorAll('#listenbrainz-tab-content .listenbrainz-playlist-card') + .forEach(_refreshOneLbSyncCard); +} + +function _startLbSyncCardRefreshLoop() { + if (_lbSyncCardRefreshInterval) return; + _lbSyncCardRefreshInterval = setInterval(() => { + const tab = document.getElementById('listenbrainz-tab-content'); + if (!tab || !tab.classList.contains('active')) { + _stopLbSyncCardRefreshLoop(); + return; + } + _refreshAllLbSyncCards(); + }, 500); + // Initial tick so the user doesn't wait 500ms for the first update. + _refreshAllLbSyncCards(); +} + +function _stopLbSyncCardRefreshLoop() { + if (_lbSyncCardRefreshInterval) { + clearInterval(_lbSyncCardRefreshInterval); + _lbSyncCardRefreshInterval = null; + } +} + // Sub-tab switching (For You / My Playlists / Collaborative). function _initListenBrainzSyncSubTabs() { const subTabContainer = document.querySelector('#listenbrainz-tab-content .listenbrainz-sub-tabs'); diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index e1ab023f..58113d05 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -3735,10 +3735,18 @@ function initializeSyncPage() { // Auto-load ListenBrainz Sync-tab playlists on first activation. // Reuses the LB discovery + sync flow already wired up for the // Discover page — the tab is purely a Sync-page entry point. - if (tabId === 'listenbrainz' && typeof loadListenBrainzSyncPlaylists === 'function' - && !window._listenbrainzSyncTabLoaded) { - window._listenbrainzSyncTabLoaded = true; - loadListenBrainzSyncPlaylists(); + if (tabId === 'listenbrainz') { + if (typeof loadListenBrainzSyncPlaylists === 'function' + && !window._listenbrainzSyncTabLoaded) { + window._listenbrainzSyncTabLoaded = true; + loadListenBrainzSyncPlaylists(); + } + // Cards mirror canonical listenbrainzPlaylistStates via a + // 500ms refresh loop that auto-stops when the tab loses + // active state — gives parity with Tidal/Qobuz live updates. + if (typeof _startLbSyncCardRefreshLoop === 'function') { + _startLbSyncCardRefreshLoop(); + } } }); });