Seamless Spotify rate limit UX — replace intrusive modal with ambient indicators
This commit is contained in:
parent
66e9457d0e
commit
a3bf858558
4 changed files with 83 additions and 117 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()">×</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">
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue