Redesign countdown overlay as compact frosted-glass bottom bar

Countdown preview now anchors to bottom of media container instead of
covering the entire video. Uses backdrop-filter blur with theme-aware
semi-transparent background (light/dark). Compact single-row grid layout
with 60px thumb, title+number, and action buttons inline.

Also completes the stalled watch mode v2 changes: static content timer,
autoplay toggle persistence, audio container wrapping in hardSwap, and
watch_json endpoint accepting non-playable media types.
This commit is contained in:
russell@unturf.com 2026-02-08 10:36:03 -05:00
parent 91fb6f7916
commit 52e2ffc06c
5 changed files with 170 additions and 114 deletions

View file

@ -1 +1 @@
1b704f5
d03178b

View file

@ -1,7 +1,6 @@
(function() {
// Find the active media element (video or audio)
// Find the active media element (video or audio), or null for static content
var activeMedia = document.getElementById('watch-video') || document.getElementById('watch-audio');
if (!activeMedia) return;
// SPA state
var standbyMedia = null;
@ -9,9 +8,35 @@
var queue = [];
var countdownTimer = null;
var crossfadeTimer = null;
var staticTimer = null;
var COUNTDOWN_SECONDS = 14;
var CROSSFADE_DURATION = 3000;
var CROSSFADE_STEPS = 60;
var STATIC_DISPLAY_SECONDS = 42;
// Autoplay preference (persisted in localStorage)
var autoplayEnabled = true;
try {
var saved = localStorage.getItem('watchAutoplay');
if (saved !== null) autoplayEnabled = saved === '1';
} catch (e) {}
// Set up autoplay toggle
var autoplayToggle = document.getElementById('watch-autoplay-toggle');
if (autoplayToggle) {
autoplayToggle.checked = autoplayEnabled;
autoplayToggle.addEventListener('change', function() {
autoplayEnabled = this.checked;
try { localStorage.setItem('watchAutoplay', autoplayEnabled ? '1' : '0'); } catch (e) {}
if (!autoplayEnabled) {
cancelCountdown();
if (staticTimer) { clearTimeout(staticTimer); staticTimer = null; }
} else {
// If on a static page, restart the timer
if (!activeMedia) startStaticTimer();
}
});
}
// Restore queue from sessionStorage
try {
@ -19,15 +44,17 @@
if (savedQueue) queue = JSON.parse(savedQueue);
} catch (e) {}
// --- Autoplay with sound (existing behavior) ---
var playPromise = activeMedia.play();
if (playPromise !== undefined) {
playPromise.catch(function() {
activeMedia.muted = true;
activeMedia.play().then(function() {
showUnmuteOverlay(activeMedia);
}).catch(function() {});
});
// --- Autoplay with sound (for video/audio) ---
if (activeMedia) {
var playPromise = activeMedia.play();
if (playPromise !== undefined) {
playPromise.catch(function() {
activeMedia.muted = true;
activeMedia.play().then(function() {
showUnmuteOverlay(activeMedia);
}).catch(function() {});
});
}
}
function showUnmuteOverlay(media) {
@ -40,8 +67,12 @@
});
}
// Determine current media type
var currentMediaType = activeMedia ? activeMedia.tagName.toLowerCase() : 'static';
// --- Create standby media element ---
function createStandbyMedia(type) {
if (type === 'static') return null;
var el = document.createElement(type === 'audio' ? 'audio' : 'video');
el.preload = 'auto';
el.style.display = 'none';
@ -50,9 +81,9 @@
return el;
}
// Determine current media type from the active element
var currentMediaType = activeMedia.tagName.toLowerCase();
standbyMedia = createStandbyMedia(currentMediaType);
if (activeMedia) {
standbyMedia = createStandbyMedia(currentMediaType);
}
// --- Queue management ---
function saveQueue() {
@ -95,7 +126,6 @@
titleSpan.textContent = item.title;
titleSpan.style.cursor = 'pointer';
titleSpan.addEventListener('click', function() {
// Play this item immediately, remove from queue
var playItem = queue.splice(i, 1)[0];
saveQueue();
renderQueue();
@ -129,21 +159,31 @@
// --- SPA navigation ---
function fetchAndNavigate(productId) {
cancelCountdown();
if (staticTimer) { clearTimeout(staticTimer); staticTimer = null; }
fetchWatchData(productId).then(function(data) {
transitionTo(data);
}).catch(function() {
// Fallback: hard navigate
window.location.href = '/watch/' + productId + '/json';
// Fallback: hard navigate to the canonical URL
// Try to find the link in the related items
var link = document.querySelector('[data-watch-id="' + productId + '"]');
if (link) {
window.location.href = link.getAttribute('href');
}
});
}
function transitionTo(data) {
var sameType = (data.media_type === currentMediaType);
var isPlayable = (data.media_type === 'video' || data.media_type === 'audio');
if (sameType) {
if (isPlayable && activeMedia && data.media_type === currentMediaType) {
crossfade(data);
} else {
} else if (isPlayable) {
hardSwap(data);
} else {
// Static content (PDF, image, etc.) — hard navigate
if (data.canonical_url) {
window.location.href = data.canonical_url;
}
}
}
@ -151,8 +191,7 @@
function crossfade(data) {
if (crossfadeTimer) clearInterval(crossfadeTimer);
// Ensure standby matches media type
if (standbyMedia.tagName.toLowerCase() !== data.media_type) {
if (!standbyMedia || standbyMedia.tagName.toLowerCase() !== data.media_type) {
hardSwap(data);
return;
}
@ -164,7 +203,6 @@
var standbyPlayPromise = standbyMedia.play();
if (standbyPlayPromise !== undefined) {
standbyPlayPromise.catch(function() {
// Crossfade failed, hard swap
hardSwap(data);
return;
});
@ -191,12 +229,10 @@
activeMedia.style.display = 'none';
standbyMedia.style.display = '';
// Swap references
var tmp = activeMedia;
activeMedia = standbyMedia;
standbyMedia = tmp;
// Apply correct attributes
activeMedia.id = currentMediaType === 'video' ? 'watch-video' : 'watch-audio';
activeMedia.controls = true;
activeMedia.className = currentMediaType === 'video' ? 'product-main' : '';
@ -209,30 +245,29 @@
// --- Hard swap (different media types or fallback) ---
function hardSwap(data) {
activeMedia.pause();
if (activeMedia) activeMedia.pause();
var container = document.getElementById('watch-media-container');
if (!container) return;
// Remove old standby
if (standbyMedia && standbyMedia.parentNode) {
standbyMedia.parentNode.removeChild(standbyMedia);
}
// Determine new media type
var newType = data.media_type;
currentMediaType = newType;
if (newType === 'audio') {
// Replace with audio container
container.innerHTML = '';
// Show album art if available
var audioWrap = document.createElement('div');
audioWrap.className = 'watch-audio-container';
if (data.thumbnail_url) {
var img = document.createElement('img');
img.src = data.thumbnail_url;
img.className = 'product-main audio-cover';
container.appendChild(img);
audioWrap.appendChild(img);
}
var audio = document.createElement('audio');
@ -240,11 +275,11 @@
audio.src = data.media_url;
audio.autoplay = true;
audio.controls = true;
container.appendChild(audio);
audioWrap.appendChild(audio);
container.appendChild(audioWrap);
activeMedia = audio;
} else {
// Replace with video
container.innerHTML = '';
var videoWrap = document.createElement('div');
@ -268,17 +303,16 @@
activeMedia = video;
}
// Create new standby
standbyMedia = createStandbyMedia(newType);
// Re-add countdown overlay
// Place countdown inside the positioned wrapper (video or audio container)
var countdown = document.getElementById('watch-countdown');
if (countdown) container.appendChild(countdown);
var positionedWrap = container.querySelector('.watch-video-container, .watch-audio-container');
if (countdown && positionedWrap) positionedWrap.appendChild(countdown);
updatePageContent(data);
setupMediaEvents();
// Try autoplay
var p = activeMedia.play();
if (p !== undefined) {
p.catch(function() {
@ -294,45 +328,34 @@
// --- Update page content (title, description, related, URL) ---
function updatePageContent(data) {
// Update page title
document.title = data.title;
// Update the heading
var h1 = document.querySelector('.product-images h1, .watch-audio-container + h1, .product-images + h1');
if (!h1) {
// Try content.j2 layout where h1 is inside product-images
h1 = document.querySelector('h1');
}
// We only update the text node of the title, preserving the subtitle span
var h1 = document.querySelector('.product-images h1');
if (!h1) h1 = document.querySelector('h1');
if (h1) {
var titleNode = h1.firstChild;
if (titleNode && titleNode.nodeType === Node.TEXT_NODE) {
titleNode.textContent = data.title + ' ';
titleNode.textContent = data.title;
}
}
// Update description
var descSection = document.querySelector('.product-description section');
if (descSection) {
descSection.innerHTML = data.description_html;
}
// Update related content sidebar
updateRelated(data.related);
// Update URL
if (data.canonical_url) {
history.pushState({productId: data.product_id}, data.title, data.canonical_url);
}
// Re-render queue
renderQueue();
}
function updateRelated(related) {
var container = document.querySelector('.related-content');
if (!container) {
// Create if it doesn't exist
var productRight = document.querySelector('.product-right');
if (!productRight) return;
container = document.createElement('div');
@ -373,7 +396,6 @@
// --- Countdown timer ---
function getNextItem() {
if (queue.length > 0) return queue[0];
// Get first related item
var firstRelated = document.querySelector('.related-content-item[data-watch-id]');
if (firstRelated) {
return {
@ -387,6 +409,8 @@
}
function startCountdown() {
if (!autoplayEnabled) return;
var next = getNextItem();
if (!next) return;
@ -431,20 +455,15 @@
}
function navigateToNext(next) {
var productId;
if (next && next.id) {
productId = next.id;
// If from queue, remove it
if (queue.length > 0 && queue[0].id === productId) {
queue.shift();
saveQueue();
renderQueue();
}
} else {
return;
if (!next || !next.id) return;
var productId = next.id;
if (queue.length > 0 && queue[0].id === productId) {
queue.shift();
saveQueue();
renderQueue();
}
// Use preloaded data if available and matches
if (preloadedData && preloadedData.product_id === productId) {
transitionTo(preloadedData);
preloadedData = null;
@ -453,6 +472,16 @@
}
}
// --- Static content timer (PDF, images: 42 seconds then advance) ---
function startStaticTimer() {
if (!autoplayEnabled) return;
if (staticTimer) clearTimeout(staticTimer);
staticTimer = setTimeout(function() {
staticTimer = null;
startCountdown();
}, STATIC_DISPLAY_SECONDS * 1000);
}
// --- Preload next media ---
function preloadNext() {
var next = getNextItem();
@ -460,7 +489,7 @@
fetchWatchData(next.id).then(function(data) {
preloadedData = data;
if (standbyMedia && data.media_url) {
if (standbyMedia && data.media_url && (data.media_type === 'video' || data.media_type === 'audio')) {
standbyMedia.src = data.media_url;
standbyMedia.preload = 'auto';
}
@ -471,6 +500,7 @@
// --- Media event setup ---
function setupMediaEvents() {
if (!activeMedia) return;
activeMedia.addEventListener('ended', function() {
startCountdown();
});
@ -480,12 +510,16 @@
});
}
// Initial event setup
setupMediaEvents();
// Initial setup
if (activeMedia) {
setupMediaEvents();
} else {
// Static content (PDF, image, etc.) — start 42-second timer
startStaticTimer();
}
// --- Event delegation for SPA link interception ---
document.addEventListener('click', function(e) {
// Queue add button
var queueBtn = e.target.closest('.queue-add-btn');
if (queueBtn) {
e.preventDefault();
@ -500,7 +534,6 @@
return;
}
// Related content links — SPA intercept
var link = e.target.closest('.related-content-item[data-watch-id]');
if (link) {
e.preventDefault();
@ -509,9 +542,6 @@
return;
}
// Queue item clicks are handled in renderQueue
// Countdown play now
if (e.target.id === 'countdown-play-now' || e.target.closest('#countdown-play-now')) {
e.preventDefault();
cancelCountdown();
@ -520,7 +550,6 @@
return;
}
// Countdown cancel
if (e.target.id === 'countdown-cancel' || e.target.closest('#countdown-cancel')) {
e.preventDefault();
cancelCountdown();
@ -545,9 +574,6 @@
);
}
// Render queue on load
renderQueue();
// Preload next on initial load
preloadNext();
})();

View file

@ -44,15 +44,15 @@
<div class="watch-video-container">
<video id="watch-video" src="{{ watch_video_url }}" autoplay controls playsinline class="product-main"></video>
<button class="unmute-overlay">Tap to unmute</button>
</div>
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
<img id="countdown-thumb" class="countdown-thumb" />
<div class="countdown-text">
<span id="countdown-title">Up next: ...</span>
<span id="countdown-number">14</span>
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
<img id="countdown-thumb" class="countdown-thumb" />
<div class="countdown-text">
<span id="countdown-title">Up next: ...</span>
<span id="countdown-number">14</span>
</div>
<button id="countdown-play-now" class="mps-button mps-button-blue">Play Now</button>
<button id="countdown-cancel" class="mps-button">Cancel</button>
</div>
<button id="countdown-play-now" class="mps-button mps-button-blue">Play Now</button>
<button id="countdown-cancel" class="mps-button">Cancel</button>
</div>
</div>
<noscript>
@ -67,15 +67,15 @@
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />
{% endif %}
<audio id="watch-audio" src="{{ watch_audio_url }}" autoplay controls></audio>
</div>
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
<img id="countdown-thumb" class="countdown-thumb" />
<div class="countdown-text">
<span id="countdown-title">Up next: ...</span>
<span id="countdown-number">14</span>
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
<img id="countdown-thumb" class="countdown-thumb" />
<div class="countdown-text">
<span id="countdown-title">Up next: ...</span>
<span id="countdown-number">14</span>
</div>
<button id="countdown-play-now" class="mps-button mps-button-blue">Play Now</button>
<button id="countdown-cancel" class="mps-button">Cancel</button>
</div>
<button id="countdown-play-now" class="mps-button mps-button-blue">Play Now</button>
<button id="countdown-cancel" class="mps-button">Cancel</button>
</div>
</div>
<noscript>
@ -100,6 +100,18 @@
</a>
{% endif %}
{% endif %}
{% if request.shop.watch_mode_enabled and product.extensions.get("product") not in video_extensions and product.extensions.get("product") not in audio_extensions %}
{# Countdown overlay for static content (PDFs, images) #}
<div id="watch-countdown" class="watch-countdown watch-countdown-static js-only" style="display:none">
<img id="countdown-thumb" class="countdown-thumb" />
<div class="countdown-text">
<span id="countdown-title">Up next: ...</span>
<span id="countdown-number">14</span>
</div>
<button id="countdown-play-now" class="mps-button mps-button-blue">Play Now</button>
<button id="countdown-cancel" class="mps-button">Cancel</button>
</div>
{% endif %}
<br/>
<h1>{{ product.title }}<br/><span class="subtitle-text">uploaded to <a href="{{ product.shop.absolute_about_url(request) }}" rel="nofollow" class="shop-theme-link-color">{{ product.shop.name }}</a></span></h1>
@ -146,6 +158,11 @@
{% endif %}
</div>
{% endif %}
{% if request.shop.watch_mode_enabled %}
<label class="watch-autoplay-label js-only">
<input type="checkbox" id="watch-autoplay-toggle" checked /> Autoplay
</label>
{% endif %}
<div id="watch-queue" class="js-only" style="display:none">
<h4>Queue</h4>
<div id="queue-items"></div>

View file

@ -48,15 +48,15 @@
<div class="watch-video-container">
<video id="watch-video" src="{{ watch_video_url }}" autoplay controls playsinline class="product-main"></video>
<button class="unmute-overlay">Tap to unmute</button>
</div>
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
<img id="countdown-thumb" class="countdown-thumb" />
<div class="countdown-text">
<span id="countdown-title">Up next: ...</span>
<span id="countdown-number">14</span>
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
<img id="countdown-thumb" class="countdown-thumb" />
<div class="countdown-text">
<span id="countdown-title">Up next: ...</span>
<span id="countdown-number">14</span>
</div>
<button id="countdown-play-now" class="mps-button mps-button-blue">Play Now</button>
<button id="countdown-cancel" class="mps-button">Cancel</button>
</div>
<button id="countdown-play-now" class="mps-button mps-button-blue">Play Now</button>
<button id="countdown-cancel" class="mps-button">Cancel</button>
</div>
</div>
<noscript>
@ -75,15 +75,15 @@
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />
{% endif %}
<audio id="watch-audio" src="{{ watch_audio_url }}" autoplay controls></audio>
</div>
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
<img id="countdown-thumb" class="countdown-thumb" />
<div class="countdown-text">
<span id="countdown-title">Up next: ...</span>
<span id="countdown-number">14</span>
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
<img id="countdown-thumb" class="countdown-thumb" />
<div class="countdown-text">
<span id="countdown-title">Up next: ...</span>
<span id="countdown-number">14</span>
</div>
<button id="countdown-play-now" class="mps-button mps-button-blue">Play Now</button>
<button id="countdown-cancel" class="mps-button">Cancel</button>
</div>
<button id="countdown-play-now" class="mps-button mps-button-blue">Play Now</button>
<button id="countdown-cancel" class="mps-button">Cancel</button>
</div>
</div>
<noscript>
@ -112,6 +112,18 @@
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
{% endif %}
{% endif %}
{% if request.shop.watch_mode_enabled and product.extensions.get("product") not in video_extensions and product.extensions.get("product") not in audio_extensions %}
{# Countdown overlay for static content (PDFs, images) #}
<div id="watch-countdown" class="watch-countdown watch-countdown-static js-only" style="display:none">
<img id="countdown-thumb" class="countdown-thumb" />
<div class="countdown-text">
<span id="countdown-title">Up next: ...</span>
<span id="countdown-number">14</span>
</div>
<button id="countdown-play-now" class="mps-button mps-button-blue">Play Now</button>
<button id="countdown-cancel" class="mps-button">Cancel</button>
</div>
{% endif %}
<br/>
{% for file_key in product.file_thumbnail_keys %}
@ -232,6 +244,11 @@
{% endif %}
<div>
{% if request.shop.watch_mode_enabled %}
<label class="watch-autoplay-label js-only">
<input type="checkbox" id="watch-autoplay-toggle" checked /> Autoplay
</label>
{% endif %}
<div id="watch-queue" class="js-only" style="display:none">
<h4>Queue</h4>
<div id="queue-items"></div>

View file

@ -36,11 +36,7 @@ def watch_json(request):
# Get extension and detect media type
extension = product.extensions.get(file_key)
media_type = get_media_type(extension)
if not media_type or media_type not in ("video", "audio"):
request.response.status_int = 400
return {"error": "Not a playable media file"}
media_type = get_media_type(extension) or "other"
# Generate presigned URL for media
bucket_name = request.app["bucket.secure_uploads"]