diff --git a/static/v3/songs.js b/static/v3/songs.js index 5b64135..8f650a8 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -1567,6 +1567,7 @@ '' + '' + '' + + '' + '' + '' + // Practice-aware library home: a repertoire progress meter + a @@ -1624,6 +1625,16 @@ if (legacy) { legacy.click(); watchUploadScan(); } }); byId('v3-songs-select').addEventListener('click', () => setSelectMode(!state.selectMode)); + byId('v3-songs-refresh')?.addEventListener('click', refreshLibrary); + // Reflect a scan already in progress (Settings button or a background + // pass) on the Refresh button, so its state isn't just tied to clicks here. + (async () => { + try { + const r = await fetch('/api/scan-status'); + const sd = r.ok ? await r.json() : null; + if (sd && sd.running) { _setRefreshState(sd); _watchScan({ announce: false }); } + } catch (e) { /* */ } + })(); // Bulletproof multi-select: in select mode, a capture-phase click on the // grid toggles the card and STOPS the event, so nothing (a per-card @@ -1753,6 +1764,73 @@ // legacy screens, so without this newly-uploaded songs wouldn't appear in // v3 until a manual refresh. Bounded so a no-op upload can't poll forever. let _uploadScanTimer = null; + // ── Library refresh (rescan) from the Songs toolbar ─────────────────────── + // The tester ask: a media-server-style "I dropped files in my folder, hit + // refresh" button, with live progress. Reuses the SAME machinery the Settings + // Rescan buttons drive (/api/rescan + /api/scan-status) and emits + // library:changed so the grid reloads. A scan already running (Settings or a + // background pass) is reflected on the button too. + let _refreshPoll = null; + function _setRefreshState(sd) { + const btn = document.getElementById('v3-songs-refresh'); + if (!btn) return; + if (sd && sd.running) { + // Determinate count only exists in the 'scanning' stage; 'listing' is + // indeterminate (total 0), so show a plain "Scanning…" then. + const det = (sd.stage === 'scanning' && sd.total) ? ' ' + sd.done + '/' + sd.total : ''; + btn.textContent = '⟳ Scanning' + det + '…'; + btn.disabled = true; + btn.classList.add('opacity-70'); + const pct = sd.total ? Math.round((sd.done / sd.total) * 100) + '% · ' : ''; + btn.title = 'Scanning new/changed songs… ' + pct + (sd.current || ''); + } else { + btn.textContent = '⟳ Refresh'; + btn.disabled = false; + btn.classList.remove('opacity-70'); + btn.title = 'Refresh library (scan for new songs)'; + } + } + // Show a completion toast (reuses the shared fbNotify surface), suppressed + // while in a song. Honest + never-punishing copy; the precise "N added" count + // arrives with the background-scan delta work — until then this is a generic, + // truthful confirmation. + function _scanCompleteToast(sd) { + if (document.querySelector('.screen.active') && document.querySelector('.screen.active').id === 'player') return; + if (!window.fbNotify) return; + const msg = (sd && sd.error) ? 'Scan finished with an error' : 'Your library is up to date'; + try { window.fbNotify.show({ title: 'Library scan complete', message: msg, icon: '🔄', accent: '#22C55E' }); } catch (e) { /* */ } + } + // Poll scan-status until the scan finishes, driving the button state. On + // completion, emit library:changed (grid reloads via the listener below) and, + // for a user-initiated refresh (announce), show the toast. announce:false is + // used when we only attached to a scan we didn't start. + function _watchScan(opts) { + if (_refreshPoll) return; + const announce = !opts || opts.announce !== false; + let sawRunning = false, ticks = 0; + _refreshPoll = setInterval(async () => { + ticks++; + let sd = null; + try { const r = await fetch('/api/scan-status'); if (r.ok) sd = await r.json(); } catch (e) { /* */ } + _setRefreshState(sd); + if (sd && sd.running) sawRunning = true; + // A user-initiated refresh that never saw a running scan = nothing to + // do (already up to date); give prompt feedback instead of waiting. + const noopDone = announce && !sawRunning && ticks >= 3; + if ((sawRunning && sd && !sd.running) || noopDone || ticks >= 180) { + clearInterval(_refreshPoll); _refreshPoll = null; + _setRefreshState(null); + if (sawRunning && window.feedBack) { try { window.feedBack.emit('library:changed', { reason: 'rescan' }); } catch (e) { /* */ } } + if (announce) _scanCompleteToast(sd); + } + }, 1000); + } + async function refreshLibrary() { + if (_refreshPoll) return; // a scan is already in progress + try { await fetch('/api/rescan', { method: 'POST' }); } catch (e) { /* */ } + _watchScan({ announce: true }); + } + function watchUploadScan() { if (_uploadScanTimer) clearInterval(_uploadScanTimer); let sawRunning = false, ticks = 0;