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:
parent
b3aa6078b2
commit
6a175dad29
4 changed files with 106 additions and 1 deletions
78
make_post_sell/static/js/shop-settings.js
Normal file
78
make_post_sell/static/js/shop-settings.js
Normal 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;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
var currentThumbnailUrl = null;
|
var currentThumbnailUrl = null;
|
||||||
var urlRefreshTimer = null;
|
var urlRefreshTimer = null;
|
||||||
var URL_REFRESH_MS = 7 * 60 * 1000; // 7 minutes
|
var URL_REFRESH_MS = 7 * 60 * 1000; // 7 minutes
|
||||||
|
var lastUrlRefreshTs = Date.now();
|
||||||
var PROGRESS_KEY = 'mps_watch_progress';
|
var PROGRESS_KEY = 'mps_watch_progress';
|
||||||
var PROGRESS_SAVE_INTERVAL = 7000;
|
var PROGRESS_SAVE_INTERVAL = 7000;
|
||||||
var lastProgressSave = 0;
|
var lastProgressSave = 0;
|
||||||
|
|
@ -660,6 +661,7 @@
|
||||||
currentThumbnailUrl = data.thumbnail_url || null;
|
currentThumbnailUrl = data.thumbnail_url || null;
|
||||||
markWatched(data.product_id);
|
markWatched(data.product_id);
|
||||||
syncRingPosition(data.product_id);
|
syncRingPosition(data.product_id);
|
||||||
|
lastUrlRefreshTs = Date.now();
|
||||||
scheduleUrlRefresh();
|
scheduleUrlRefresh();
|
||||||
|
|
||||||
// Reset karaoke state for the new product
|
// Reset karaoke state for the new product
|
||||||
|
|
@ -1529,6 +1531,10 @@
|
||||||
// Skip refresh during crossfade — the new media already has a fresh URL
|
// Skip refresh during crossfade — the new media already has a fresh URL
|
||||||
if (djCrossfadeActive) { scheduleUrlRefresh(); return; }
|
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) {
|
fetchWatchData(currentProductId).then(function(data) {
|
||||||
if (!data || !data.media_url) { scheduleUrlRefresh(); return; }
|
if (!data || !data.media_url) { scheduleUrlRefresh(); return; }
|
||||||
// Only refresh if still on the same product
|
// Only refresh if still on the same product
|
||||||
|
|
@ -1550,6 +1556,7 @@
|
||||||
else if (karaokeMode === 2 && karaokeUrls.vocals) freshSrc = karaokeUrls.vocals;
|
else if (karaokeMode === 2 && karaokeUrls.vocals) freshSrc = karaokeUrls.vocals;
|
||||||
else freshSrc = data.media_url;
|
else freshSrc = data.media_url;
|
||||||
activeMedia.src = freshSrc;
|
activeMedia.src = freshSrc;
|
||||||
|
lastUrlRefreshTs = Date.now();
|
||||||
|
|
||||||
activeMedia.addEventListener('loadeddata', function onLoaded() {
|
activeMedia.addEventListener('loadeddata', function onLoaded() {
|
||||||
activeMedia.removeEventListener('loadeddata', 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 ---
|
// --- Media event setup ---
|
||||||
function setupMediaEvents() {
|
function setupMediaEvents() {
|
||||||
if (!activeMedia) return;
|
if (!activeMedia) return;
|
||||||
|
|
@ -1659,16 +1675,19 @@
|
||||||
activeMedia.removeEventListener('ended', onMediaEnded);
|
activeMedia.removeEventListener('ended', onMediaEnded);
|
||||||
activeMedia.removeEventListener('loadedmetadata', onMediaLoadedMetadata);
|
activeMedia.removeEventListener('loadedmetadata', onMediaLoadedMetadata);
|
||||||
activeMedia.removeEventListener('timeupdate', onMediaTimeUpdate);
|
activeMedia.removeEventListener('timeupdate', onMediaTimeUpdate);
|
||||||
|
activeMedia.removeEventListener('play', onMediaPlay);
|
||||||
if (standbyMedia) {
|
if (standbyMedia) {
|
||||||
standbyMedia.removeEventListener('ended', onMediaEnded);
|
standbyMedia.removeEventListener('ended', onMediaEnded);
|
||||||
standbyMedia.removeEventListener('loadedmetadata', onMediaLoadedMetadata);
|
standbyMedia.removeEventListener('loadedmetadata', onMediaLoadedMetadata);
|
||||||
standbyMedia.removeEventListener('timeupdate', onMediaTimeUpdate);
|
standbyMedia.removeEventListener('timeupdate', onMediaTimeUpdate);
|
||||||
|
standbyMedia.removeEventListener('play', onMediaPlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add fresh listeners to active element only
|
// Add fresh listeners to active element only
|
||||||
activeMedia.addEventListener('ended', onMediaEnded);
|
activeMedia.addEventListener('ended', onMediaEnded);
|
||||||
activeMedia.addEventListener('loadedmetadata', onMediaLoadedMetadata);
|
activeMedia.addEventListener('loadedmetadata', onMediaLoadedMetadata);
|
||||||
activeMedia.addEventListener('timeupdate', onMediaTimeUpdate);
|
activeMedia.addEventListener('timeupdate', onMediaTimeUpdate);
|
||||||
|
activeMedia.addEventListener('play', onMediaPlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial setup
|
// Initial setup
|
||||||
|
|
|
||||||
|
|
@ -1156,5 +1156,6 @@ function toggleCryptoWallets() {
|
||||||
wallets.style.display = checkbox.checked ? 'block' : 'none';
|
wallets.style.display = checkbox.checked ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/static/js/shop-settings.js"></script>
|
||||||
|
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ from ..lib.phone_numbers import is_phone_number_valid
|
||||||
from ..lib.currency import dollars_to_cents, cents_to_dollars
|
from ..lib.currency import dollars_to_cents, cents_to_dollars
|
||||||
|
|
||||||
from pyramid.httpexceptions import HTTPFound
|
from pyramid.httpexceptions import HTTPFound
|
||||||
|
from pyramid.response import Response
|
||||||
|
|
||||||
# feel free to come up with a better plan, GPT-4 made this regex.
|
# feel free to come up with a better plan, GPT-4 made this regex.
|
||||||
DOMAIN_NAME_REGEX = re.compile(
|
DOMAIN_NAME_REGEX = re.compile(
|
||||||
|
|
@ -1021,8 +1022,14 @@ def shop_settings(request):
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
request.session.flash(("Invalid high risk threshold", "error"))
|
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 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")
|
return HTTPFound(f"/s/{shop.id}/settings")
|
||||||
|
|
||||||
# TODO: Dry out this block, it's a copy pasta from views/product.py
|
# TODO: Dry out this block, it's a copy pasta from views/product.py
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue