feat: skip presigned URL refresh when paused + SPA shop settings

watch.js: presigned URL refresh now skips src swap when media is paused.
Tracks lastUrlRefreshTs so that when the user resumes playback after a
long pause, the play listener detects the stale URL and refreshes before
continuing. No more re-buffering or disruption for idle media.

shop.py: detect X-Requested-With header on settings POST and return JSON
with flash messages instead of redirect, enabling SPA behavior.

shop-settings.js: new progressive enhancement script intercepts settings
form submits via fetch, renders flash messages inline into #alerts div.
Falls back to normal POST + redirect when JS is disabled or on error.
This commit is contained in:
russell@unturf.com 2026-03-04 13:28:50 -05:00
parent b3aa6078b2
commit 6a175dad29
4 changed files with 106 additions and 1 deletions

View file

@ -0,0 +1,78 @@
// AJAX shop settings — progressive enhancement.
// When JS is available, intercepts settings form POSTs and submits
// via fetch so the page does not reload. Flash messages are rendered
// inline into the #alerts div. When JS is disabled, forms fall back
// to the normal POST + redirect flow.
(function() {
var forms = document.querySelectorAll('form[action*="/settings"]');
if (!forms.length) return;
forms.forEach(function(form) {
// Skip file upload forms — they POST directly to S3, not /settings
if (form.querySelector('input[type="file"]')) return;
form.addEventListener('submit', function(e) {
e.preventDefault();
var formData = new FormData(form);
var submitBtn = form.querySelector('input[type="submit"], button[type="submit"]');
if (!submitBtn) return;
var originalLabel = submitBtn.value;
submitBtn.disabled = true;
submitBtn.value = 'Saving...';
fetch(form.action, {
method: 'POST',
body: formData,
redirect: 'manual',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(response) {
if (!response.ok) {
// Auth redirect or server error — fall back to regular form submit
submitBtn.disabled = false;
submitBtn.value = originalLabel;
form.submit();
return;
}
return response.json();
})
.then(function(data) {
if (!data) return;
showFlashMessages(data.messages);
})
.catch(function() {
// Network error — fall back to regular form submit
submitBtn.disabled = false;
submitBtn.value = originalLabel;
form.submit();
})
.finally(function() {
submitBtn.disabled = false;
submitBtn.value = originalLabel;
});
});
});
function showFlashMessages(messages) {
var alerts = document.getElementById('alerts');
if (!alerts) return;
alerts.innerHTML = '';
if (!messages || !messages.length) return;
messages.forEach(function(msg) {
var text = msg[0], level = msg[1];
var div = document.createElement('div');
div.className = 'alert alert-' + level;
div.setAttribute('onclick', "this.style.display='none'");
div.innerHTML = '<p class="alert-message">' + escapeHtml(text) + '</p>'
+ '<label class="close" alt="dismiss" title="Mark as read">X</label>';
alerts.appendChild(div);
});
alerts.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
})();

View file

@ -25,6 +25,7 @@
var currentThumbnailUrl = null;
var urlRefreshTimer = null;
var URL_REFRESH_MS = 7 * 60 * 1000; // 7 minutes
var lastUrlRefreshTs = Date.now();
var PROGRESS_KEY = 'mps_watch_progress';
var PROGRESS_SAVE_INTERVAL = 7000;
var lastProgressSave = 0;
@ -660,6 +661,7 @@
currentThumbnailUrl = data.thumbnail_url || null;
markWatched(data.product_id);
syncRingPosition(data.product_id);
lastUrlRefreshTs = Date.now();
scheduleUrlRefresh();
// Reset karaoke state for the new product
@ -1529,6 +1531,10 @@
// Skip refresh during crossfade — the new media already has a fresh URL
if (djCrossfadeActive) { scheduleUrlRefresh(); return; }
// Skip refresh while paused — no point re-buffering idle media.
// The play listener will refresh stale URLs when the user resumes.
if (activeMedia.paused) { scheduleUrlRefresh(); return; }
fetchWatchData(currentProductId).then(function(data) {
if (!data || !data.media_url) { scheduleUrlRefresh(); return; }
// Only refresh if still on the same product
@ -1550,6 +1556,7 @@
else if (karaokeMode === 2 && karaokeUrls.vocals) freshSrc = karaokeUrls.vocals;
else freshSrc = data.media_url;
activeMedia.src = freshSrc;
lastUrlRefreshTs = Date.now();
activeMedia.addEventListener('loadeddata', function onLoaded() {
activeMedia.removeEventListener('loadeddata', onLoaded);
@ -1651,6 +1658,15 @@
}
}
// --- Stale URL check on play: if paused long enough for the presigned URL
// to expire, refresh before resuming playback ---
function onMediaPlay() {
if (Date.now() - lastUrlRefreshTs > URL_REFRESH_MS) {
activeMedia.pause();
refreshMediaUrl();
}
}
// --- Media event setup ---
function setupMediaEvents() {
if (!activeMedia) return;
@ -1659,16 +1675,19 @@
activeMedia.removeEventListener('ended', onMediaEnded);
activeMedia.removeEventListener('loadedmetadata', onMediaLoadedMetadata);
activeMedia.removeEventListener('timeupdate', onMediaTimeUpdate);
activeMedia.removeEventListener('play', onMediaPlay);
if (standbyMedia) {
standbyMedia.removeEventListener('ended', onMediaEnded);
standbyMedia.removeEventListener('loadedmetadata', onMediaLoadedMetadata);
standbyMedia.removeEventListener('timeupdate', onMediaTimeUpdate);
standbyMedia.removeEventListener('play', onMediaPlay);
}
// Add fresh listeners to active element only
activeMedia.addEventListener('ended', onMediaEnded);
activeMedia.addEventListener('loadedmetadata', onMediaLoadedMetadata);
activeMedia.addEventListener('timeupdate', onMediaTimeUpdate);
activeMedia.addEventListener('play', onMediaPlay);
}
// Initial setup

View file

@ -1156,5 +1156,6 @@ function toggleCryptoWallets() {
wallets.style.display = checkbox.checked ? 'block' : 'none';
}
</script>
<script src="/static/js/shop-settings.js"></script>
{%- endblock -%}

View file

@ -41,6 +41,7 @@ from ..lib.phone_numbers import is_phone_number_valid
from ..lib.currency import dollars_to_cents, cents_to_dollars
from pyramid.httpexceptions import HTTPFound
from pyramid.response import Response
# feel free to come up with a better plan, GPT-4 made this regex.
DOMAIN_NAME_REGEX = re.compile(
@ -1021,8 +1022,14 @@ def shop_settings(request):
except (ValueError, TypeError):
request.session.flash(("Invalid high risk threshold", "error"))
# If we processed any form submission, redirect to prevent re-submission
# If we processed any form submission, respond accordingly
if form_section:
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
messages = list(request.session.pop_flash())
return Response(
json={"status": "ok", "messages": messages},
content_type="application/json",
)
return HTTPFound(f"/s/{shop.id}/settings")
# TODO: Dry out this block, it's a copy pasta from views/product.py