fix: 3 watch mode defects — crossfade race, filter-aware next, countdown position

1. Crossfade race condition: when a DJ crossfade is active and the user
   clicks a new song, cancelDjCrossfade resets djCrossfadeActive but the
   old song is still near its end — timeupdate immediately re-triggers
   startDjCrossfade, racing with the in-flight fetch. Added
   navigationPending flag to block DJ crossfade and ended handler while
   a user-initiated navigation is in progress.

2. getNextItem/getNextUnwatchedItem now respect media type filters.
   Previously, filtering to "video only" still showed an image in the
   countdown because the ring walked by position without checking
   data-media-type. Now skips filtered items.

3. Countdown overlay moved from position:absolute inside the video/audio
   container (covering native controls) to a flow-positioned element
   between the media and title. Removed watch-countdown-static class
   since all countdown instances now use the same in-flow layout.
This commit is contained in:
russell@unturf.com 2026-03-11 13:03:18 -04:00
parent a71a3d4c6e
commit 5e0d263b7c
4 changed files with 97 additions and 92 deletions

View file

@ -3187,26 +3187,22 @@ textarea {
/* Watch countdown overlay — compact bottom bar with frosted glass */
.watch-countdown {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
position: relative;
display: grid;
grid-template-columns: 60px 1fr auto auto;
gap: 10px;
padding: 10px 14px;
align-items: center;
text-align: left;
z-index: 30;
border-top: 1px solid var(--border-color, #dee2e6);
background: var(--surface-secondary, #f6f8fa);
border: 1px solid var(--border-color, #dee2e6);
border-radius: var(--radius-md, 8px);
margin-top: 8px;
}
[data-theme="dark"] .watch-countdown {
background: rgba(13, 17, 23, 0.80);
border-top: 1px solid var(--border-color, #7ab9ff);
background: var(--surface-secondary, #161b22);
border-color: var(--border-color, #7ab9ff);
}
.countdown-thumb {
@ -3249,15 +3245,6 @@ textarea {
grid-column: 4;
}
/* Static content countdown: flows in-place, not overlaid */
.watch-countdown-static {
position: relative;
border-top: none;
border: 1px solid var(--border-color, #dee2e6);
border-radius: 6px;
margin-top: 8px;
}
/* Queue UI */
#watch-queue {
margin-bottom: 16px;

View file

@ -29,6 +29,7 @@
var PROGRESS_KEY = 'mps_watch_progress';
var PROGRESS_SAVE_INTERVAL = 7000;
var lastProgressSave = 0;
var navigationPending = false;
// --- Karaoke state ---
var karaokeMode = 0; // 0=original, 1=instrumentals, 2=vocals
@ -448,9 +449,12 @@
if (djCrossfadeActive) cancelDjCrossfade();
cancelCountdown();
if (staticTimer) { clearTimeout(staticTimer); staticTimer = null; }
navigationPending = true;
fetchWatchData(productId).then(function(data) {
navigationPending = false;
transitionTo(data);
}).catch(function() {
navigationPending = false;
// Fallback: hard navigate to the canonical URL
// Try to find the link in the related items
var link = document.querySelector('[data-watch-id="' + productId + '"]');
@ -637,10 +641,8 @@
standbyMedia = createStandbyMedia(newType);
// Place countdown inside the positioned wrapper (video or audio container)
var countdown = document.getElementById('watch-countdown');
var positionedWrap = container.querySelector('.watch-video-container, .watch-audio-container');
if (countdown && positionedWrap) positionedWrap.appendChild(countdown);
// Countdown lives outside the media container (between media and title)
// — no need to move it during hard swaps.
updatePageContent(data);
setupMediaEvents();
@ -1046,55 +1048,69 @@
return div.innerHTML;
}
// --- Helper: check if a product's media type is filtered out ---
function isItemFiltered(productId) {
var el = document.querySelector('[data-watch-id="' + productId + '"]');
if (!el) return false; // unknown — don't filter
var row = el.closest('.related-content-row');
if (!row) return false;
var type = row.getAttribute('data-media-type');
return type ? !mediaFilters[type] : false;
}
function itemFromElement(el, id) {
return {
id: id || el.getAttribute('data-watch-id'),
title: el.querySelector('span') ? el.querySelector('span').textContent : '',
thumbnail_url: el.querySelector('img') ? el.querySelector('img').src : null,
url: el.getAttribute('href') || ('/c/' + (id || el.getAttribute('data-watch-id')))
};
}
// --- Ring-based getNextItem ---
function getNextItem() {
// Queue items splice into current position — play queue first
if (queue.length > 0) return queue[0];
// Advance ring position by direction, return that item
// Advance ring position by direction, skip filtered items
if (!ringProductIds.length) {
// No ring — fall back to first related item in DOM
// No ring — fall back to first visible related item in DOM
var allRelated = document.querySelectorAll('.related-content-item[data-watch-id]');
if (allRelated.length > 0) {
var el = allRelated[0];
return {
id: el.getAttribute('data-watch-id'),
title: el.querySelector('span') ? el.querySelector('span').textContent : '',
thumbnail_url: el.querySelector('img') ? el.querySelector('img').src : null,
url: el.getAttribute('href')
};
for (var i = 0; i < allRelated.length; i++) {
var el = allRelated[i];
var row = el.closest('.related-content-row');
var mtype = row ? row.getAttribute('data-media-type') : null;
if (mtype && !mediaFilters[mtype]) continue;
return itemFromElement(el);
}
return null;
}
var nextPos = (ringPosition + ringDirection + ringProductIds.length) % ringProductIds.length;
var nextId = ringProductIds[nextPos];
// Find the related item in the DOM for title/thumbnail
var el = document.querySelector('[data-watch-id="' + nextId + '"]');
return {
id: nextId,
title: el ? (el.querySelector('span') ? el.querySelector('span').textContent : '') : '',
thumbnail_url: el ? (el.querySelector('img') ? el.querySelector('img').src : null) : null,
url: el ? el.getAttribute('href') : '/c/' + nextId
};
// Walk ring in travel direction, skipping filtered items
for (var i = 1; i <= ringProductIds.length; i++) {
var nextPos = (ringPosition + i * ringDirection + ringProductIds.length) % ringProductIds.length;
var nextId = ringProductIds[nextPos];
if (isItemFiltered(nextId)) continue;
var el = document.querySelector('[data-watch-id="' + nextId + '"]');
return el ? itemFromElement(el, nextId) : {
id: nextId, title: '', thumbnail_url: null, url: '/c/' + nextId
};
}
return null; // all items filtered
}
// --- Skip to next unwatched ring item ---
function getNextUnwatchedItem() {
if (!ringProductIds.length) return getNextItem();
// Walk forward through the ring, skipping watched items
// Walk forward through the ring, skipping watched and filtered items
for (var i = 1; i < ringProductIds.length; i++) {
var pos = (ringPosition + i * ringDirection + ringProductIds.length * ringProductIds.length) % ringProductIds.length;
var id = ringProductIds[pos];
if (!isWatched(id)) {
if (!isWatched(id) && !isItemFiltered(id)) {
var el = document.querySelector('[data-watch-id="' + id + '"]');
return {
id: id,
title: el ? (el.querySelector('span') ? el.querySelector('span').textContent : '') : '',
thumbnail_url: el ? (el.querySelector('img') ? el.querySelector('img').src : null) : null,
url: el ? el.getAttribute('href') : '/c/' + id
return el ? itemFromElement(el, id) : {
id: id, title: '', thumbnail_url: null, url: '/c/' + id
};
}
}
@ -1631,7 +1647,8 @@
function onMediaEnded() {
clearProgress();
// Fallback: only start countdown if DJ crossfade didn't handle it
if (!djCrossfadeActive) {
// Skip if user navigation is in-flight — the new song will take over
if (!djCrossfadeActive && !navigationPending) {
// Short clips: instant hard-cut to next if preloaded, no countdown
if (activeMedia && activeMedia.duration <= COUNTDOWN_SECONDS
&& preloadedData && autoplayEnabled) {
@ -1656,8 +1673,9 @@
}
// DJ crossfade: start blending 7 seconds before end
// Skip for short clips — they play fully then hard-cut on ended
// Skip when user navigation is in-flight to prevent re-triggering
if (remaining <= COUNTDOWN_SECONDS && remaining > 0 && !djCrossfadeActive
&& activeMedia.duration > COUNTDOWN_SECONDS) {
&& !navigationPending && activeMedia.duration > COUNTDOWN_SECONDS) {
startDjCrossfade();
}
// Save progress every 7 seconds

View file

@ -44,17 +44,17 @@
<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 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">7</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>
</div>
</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">7</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>
<noscript>
<a href="{{ watch_video_url }}" target="_blank">Open video</a>
</noscript>
@ -67,17 +67,17 @@
<img src="{{ request.shop_cdn_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 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">7</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>
</div>
</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">7</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>
<noscript>
<a href="{{ watch_audio_url }}" target="_blank">Listen</a>
</noscript>
@ -102,7 +102,7 @@
{% 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">
<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>

View file

@ -48,17 +48,17 @@
<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 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">7</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>
</div>
</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">7</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>
<noscript>
<a href="{{ watch_video_url }}" target="_blank">Open video</a>
</noscript>
@ -75,17 +75,17 @@
<img src="{{ request.shop_cdn_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 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">7</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>
</div>
</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">7</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>
<noscript>
<a href="{{ watch_audio_url }}" target="_blank">Listen</a>
</noscript>
@ -114,7 +114,7 @@
{% 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">
<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>