LB Sync tab cards: live updates parity with Tidal

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:
  ``♪ <total> / ✓ <matched> / ✗ <failed> / <percent>%``. 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.
This commit is contained in:
Broque Thomas 2026-05-26 14:32:48 -07:00
parent df31d42b94
commit 55583c1db3
2 changed files with 113 additions and 4 deletions

View file

@ -130,6 +130,7 @@ function renderListenBrainzSyncPlaylists() {
<span class="playlist-card-phase-text" style="color: ${phaseColor};">${phaseText}</span>
</div>
</div>
<div class="playlist-card-progress ${phase === 'fresh' ? 'hidden' : ''}"></div>
<button class="playlist-card-action-btn">${buttonText}</button>
</div>
`;
@ -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:
// "♪ <total> / ✓ <matched> / ✗ <failed> / <percent>%".
// 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');

View file

@ -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();
}
}
});
});