Seamless Spotify rate limit UX — replace intrusive modal with ambient indicators

This commit is contained in:
Broque Thomas 2026-03-12 12:45:59 -07:00
parent 66e9457d0e
commit a3bf858558
4 changed files with 83 additions and 117 deletions

View file

@ -3587,7 +3587,7 @@ def get_status():
if is_rate_limited or cooldown_remaining > 0:
# During rate limit or post-ban cooldown, skip the auth probe entirely.
# Probing Spotify here would reset the rate limit timer.
music_source = 'spotify'
music_source = 'itunes' # App uses iTunes during ban — reflect truth
spotify_response_time = 0
else:
spotify_start = time.time()
@ -3600,7 +3600,8 @@ def get_status():
'response_time': round(spotify_response_time, 1),
'source': music_source,
'rate_limited': is_rate_limited,
'rate_limit': rate_limit_info
'rate_limit': rate_limit_info,
'post_ban_cooldown': cooldown_remaining if cooldown_remaining > 0 else None
}
_status_cache_timestamps['spotify'] = current_time
# else: use cached value

View file

@ -4760,42 +4760,6 @@
</div>
</div>
<!-- Spotify Rate Limit Modal -->
<div class="modal-overlay hidden" id="spotify-rate-limit-overlay" onclick="closeSpotifyRateLimitModal()">
<div class="support-modal" onclick="event.stopPropagation()" style="max-width: 480px;">
<div class="support-modal-header">
<h2>Spotify Rate Limited</h2>
<button class="support-modal-close" onclick="closeSpotifyRateLimitModal()">&times;</button>
</div>
<div class="support-modal-body" style="padding: 20px;">
<p style="margin: 0 0 12px; color: var(--text-secondary); line-height: 1.5;">
Spotify has temporarily blocked API access. All Spotify features (search, enrichment, playlists) are paused until the ban expires.
</p>
<div style="background: var(--bg-tertiary); border-radius: 8px; padding: 16px; margin-bottom: 16px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
<span style="color: var(--text-secondary);">Time remaining</span>
<span id="rate-limit-remaining" style="color: var(--accent); font-weight: 600;">--</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
<span style="color: var(--text-secondary);">Original ban duration</span>
<span id="rate-limit-duration" style="color: var(--text-primary);">--</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span style="color: var(--text-secondary);">Triggered by</span>
<span id="rate-limit-endpoint" style="color: var(--text-primary); font-family: monospace; font-size: 0.85em;">--</span>
</div>
</div>
<p style="margin: 0 0 16px; color: var(--text-tertiary); font-size: 0.85em; line-height: 1.5;">
You can wait for the ban to expire, or disconnect Spotify entirely and switch to Apple Music/iTunes for metadata.
</p>
<div style="display: flex; gap: 10px;">
<button onclick="closeSpotifyRateLimitModal()" style="flex: 1; padding: 10px; border-radius: 8px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary); cursor: pointer;">Wait It Out</button>
<button onclick="disconnectSpotifyFromRateLimitModal()" style="flex: 1; padding: 10px; border-radius: 8px; border: none; background: var(--danger, #e74c3c); color: white; cursor: pointer; font-weight: 600;">Disconnect Spotify</button>
</div>
</div>
</div>
</div>
<!-- Add to Wishlist Modal -->
<div class="modal-overlay hidden" id="add-to-wishlist-modal-overlay">
<div class="add-to-wishlist-modal" id="add-to-wishlist-modal">

View file

@ -285,11 +285,23 @@ function handleServiceStatusUpdate(data) {
updateSidebarServiceStatus('media-server', data.media_server);
updateSidebarServiceStatus('soulseek', data.soulseek);
// Check for Spotify rate limit
if (data.spotify && data.spotify.rate_limited && data.spotify.rate_limit) {
// Spotify rate limit / cooldown / recovery
if (data.spotify?.rate_limited && data.spotify.rate_limit) {
handleSpotifyRateLimit(data.spotify.rate_limit);
} else if (_spotifyRateLimitShown) {
handleSpotifyRateLimit(null);
_spotifyInCooldown = false;
} else if (data.spotify?.post_ban_cooldown > 0) {
if (_spotifyRateLimitShown && !_spotifyInCooldown) {
_spotifyRateLimitShown = false;
_spotifyInCooldown = true;
showToast('Spotify ban expired \u2014 recovering shortly', 'info');
}
} else {
if (_spotifyInCooldown) {
_spotifyInCooldown = false;
showToast('Spotify access restored', 'success');
} else if (_spotifyRateLimitShown) {
handleSpotifyRateLimit(null);
}
}
}
@ -5782,54 +5794,23 @@ async function disconnectSpotify() {
}
}
// ── Spotify Rate Limit Modal ──────────────────────────────────────────────
// ── Spotify Rate Limit Handling ───────────────────────────────────────────
let _spotifyRateLimitShown = false;
let _rateLimitCountdownInterval = null;
let _rateLimitRemainingSeconds = 0; // Local countdown, synced from server updates
let _spotifyInCooldown = false;
function handleSpotifyRateLimit(rateLimitInfo) {
if (!rateLimitInfo || !rateLimitInfo.active) {
// Rate limit cleared — hide modal if showing
if (_spotifyRateLimitShown) {
_spotifyRateLimitShown = false;
closeSpotifyRateLimitModal();
showToast('Spotify rate limit expired — API access restored', 'success');
showToast('Spotify access restored', 'success');
}
return;
}
// Sync local countdown from server's authoritative remaining_seconds
_rateLimitRemainingSeconds = rateLimitInfo.remaining_seconds;
// Update static fields (only change on new ban events)
const durationEl = document.getElementById('rate-limit-duration');
const endpointEl = document.getElementById('rate-limit-endpoint');
if (durationEl) durationEl.textContent = formatRateLimitDuration(rateLimitInfo.retry_after);
if (endpointEl) endpointEl.textContent = rateLimitInfo.endpoint || 'unknown';
// Update remaining display
const remainingEl = document.getElementById('rate-limit-remaining');
if (remainingEl) remainingEl.textContent = formatRateLimitDuration(_rateLimitRemainingSeconds);
// Show modal and start countdown once per rate limit event
if (!_spotifyRateLimitShown) {
_spotifyRateLimitShown = true;
const overlay = document.getElementById('spotify-rate-limit-overlay');
if (overlay) overlay.classList.remove('hidden');
// Start local 1s countdown for smooth display between server updates
if (_rateLimitCountdownInterval) clearInterval(_rateLimitCountdownInterval);
_rateLimitCountdownInterval = setInterval(() => {
_rateLimitRemainingSeconds--;
if (_rateLimitRemainingSeconds <= 0) {
_spotifyRateLimitShown = false;
closeSpotifyRateLimitModal();
showToast('Spotify rate limit expired — API access restored', 'success');
return;
}
const el = document.getElementById('rate-limit-remaining');
if (el) el.textContent = formatRateLimitDuration(_rateLimitRemainingSeconds);
}, 1000);
_spotifyInCooldown = false;
const duration = formatRateLimitDuration(rateLimitInfo.remaining_seconds);
showToast(`Spotify rate limited (${duration}) \u2014 using Apple Music`, 'warning');
}
}
@ -5843,36 +5824,6 @@ function formatRateLimitDuration(seconds) {
return `${s}s`;
}
function closeSpotifyRateLimitModal() {
const overlay = document.getElementById('spotify-rate-limit-overlay');
if (overlay) overlay.classList.add('hidden');
if (_rateLimitCountdownInterval) {
clearInterval(_rateLimitCountdownInterval);
_rateLimitCountdownInterval = null;
}
}
async function disconnectSpotifyFromRateLimitModal() {
closeSpotifyRateLimitModal();
_spotifyRateLimitShown = false;
try {
showLoadingOverlay('Disconnecting Spotify...');
const response = await fetch('/api/spotify/disconnect', { method: 'POST' });
const data = await response.json();
if (data.success) {
showToast('Spotify disconnected. Now using Apple Music/iTunes.', 'success');
await fetchAndUpdateServiceStatus();
} else {
showToast(`Failed to disconnect: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error disconnecting Spotify:', error);
showToast('Failed to disconnect Spotify', 'error');
} finally {
hideLoadingOverlay();
}
}
async function authenticateTidal() {
try {
showLoadingOverlay('Starting Tidal authentication...');
@ -30131,11 +30082,14 @@ function updateServiceStatus(service, statusData) {
const statusText = document.getElementById(`${service}-status-text`);
if (indicator && statusText) {
if (service === 'spotify' && statusData.rate_limited) {
indicator.className = 'service-card-indicator disconnected';
const remaining = statusData.rate_limit ? formatRateLimitDuration(statusData.rate_limit.remaining_seconds) : '';
statusText.textContent = `Rate Limited${remaining ? ' (' + remaining + ')' : ''}`;
statusText.className = 'service-card-status-text disconnected';
if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) {
indicator.className = 'service-card-indicator rate-limited';
const remaining = statusData.rate_limited
? formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)
: formatRateLimitDuration(statusData.post_ban_cooldown);
const phase = statusData.rate_limited ? 'paused' : 'recovering';
statusText.textContent = `Apple Music (Spotify ${phase} \u2014 ${remaining})`;
statusText.className = 'service-card-status-text rate-limited';
} else if (statusData.connected) {
indicator.className = 'service-card-indicator connected';
statusText.textContent = `Connected (${statusData.response_time}ms)`;
@ -30180,9 +30134,11 @@ function updateSidebarServiceStatus(service, statusData) {
const nameElement = indicator.querySelector('.status-name');
if (dot) {
if (service === 'spotify' && statusData.rate_limited) {
dot.className = 'status-dot disconnected';
dot.title = 'Rate Limited';
if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) {
dot.className = 'status-dot rate-limited';
dot.title = statusData.rate_limited
? `Spotify paused \u2014 ${formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)} remaining`
: `Spotify recovering \u2014 ${formatRateLimitDuration(statusData.post_ban_cooldown)} cooldown`;
} else if (statusData.connected) {
dot.className = 'status-dot connected';
dot.title = '';

View file

@ -1394,6 +1394,32 @@ body {
content: '';
}
.status-dot.rate-limited {
background: rgba(250, 204, 21, 0.8);
border: none;
box-shadow: 0 0 6px rgba(250, 204, 21, 0.4);
animation: status-pulse-amber 3s ease-in-out infinite;
}
.status-dot.rate-limited::before {
content: '';
position: absolute;
inset: -3px;
border-radius: 50%;
border: 1.5px solid rgba(250, 204, 21, 0.3);
animation: status-ring-pulse 3s ease-in-out infinite;
}
@keyframes status-pulse-amber {
0%, 100% { box-shadow: 0 0 6px rgba(250, 204, 21, 0.4), 0 0 12px rgba(250, 204, 21, 0.2); }
50% { box-shadow: 0 0 8px rgba(250, 204, 21, 0.6), 0 0 20px rgba(250, 204, 21, 0.3); }
}
.status-indicator:has(.rate-limited) .status-name {
color: rgba(250, 204, 21, 0.9);
font-weight: 500;
}
.status-name {
font-family: 'SF Pro Text', -apple-system, sans-serif;
font-size: 11px;
@ -3558,6 +3584,16 @@ body {
background: linear-gradient(135deg, rgba(255, 107, 107, 0.1) 0%, rgba(26, 26, 26, 0.95) 100%);
}
.toast.warning {
border-color: rgba(250, 204, 21, 0.5);
background: linear-gradient(135deg, rgba(250, 204, 21, 0.1) 0%, rgba(26, 26, 26, 0.95) 100%);
}
.toast.info {
border-color: rgba(59, 130, 246, 0.5);
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(26, 26, 26, 0.95) 100%);
}
@keyframes slideIn {
from {
transform: translateY(100%);
@ -5551,6 +5587,15 @@ body {
text-shadow: 0 0 8px rgba(255, 68, 68, 0.5), 0 0 16px rgba(255, 68, 68, 0.2);
}
.service-card-indicator.rate-limited {
color: #facc15;
text-shadow: 0 0 8px rgba(250, 204, 21, 0.5);
}
.service-card-status-text.rate-limited {
color: rgba(250, 204, 21, 0.9);
}
@keyframes service-pulse-green {
0%, 100% { text-shadow: 0 0 8px rgba(var(--accent-rgb), 0.6), 0 0 16px rgba(var(--accent-rgb), 0.3); }
50% { text-shadow: 0 0 12px rgba(var(--accent-rgb), 0.8), 0 0 24px rgba(var(--accent-rgb), 0.5); }