make_post_sell/make_post_sell/static/js/watch.js
russell@unturf.com f780f0dab6 Redesign ring header into structured 2-row grid, show full now-playing title and thumbnail
Ring header restructured from flat 1fr/auto grid into two semantic rows:
- Info row: title + badges left, progress counter right
- Controls row: Reverse and Autoplay toggles right-aligned

Now-playing row shows full title (no line-clamp truncation) and product
thumbnail. JS reads og:image meta tag for current thumbnail during SPA
navigation. SPA updates for edit button, download button, file info,
comments, and canonical link. Footer and docs updates.
2026-02-09 11:46:54 -05:00

1172 lines
42 KiB
JavaScript

(function() {
// Find the active media element (video or audio), or null for static content
var activeMedia = document.getElementById('watch-video') || document.getElementById('watch-audio');
// SPA state
var standbyMedia = null;
var preloadedData = null;
var queue = [];
var countdownTimer = null;
var crossfadeTimer = null;
var staticTimer = null;
var COUNTDOWN_SECONDS = 7;
var CROSSFADE_DURATION = 3000;
var CROSSFADE_STEPS = 60;
var STATIC_DISPLAY_SECONDS = 42;
var BUFFER_AHEAD_SECONDS = 30;
var bufferingStarted = false;
var djCrossfadeActive = false;
var djCrossfadeData = null;
var preloadingId = null;
// --- Ring state (persisted in localStorage) ---
var ringProductIds = [];
var ringPosition = 0;
var ringDirection = 1; // 1 = forward, -1 = reverse
var ringHistory = {}; // { productId: true } — set of watched IDs
var ringLoops = 0; // completed full circuits of the ring
// Load ring state from localStorage
try {
var savedRing = localStorage.getItem('watchRing');
if (savedRing) ringProductIds = JSON.parse(savedRing);
} catch (e) {}
try {
var savedPos = localStorage.getItem('watchRingPosition');
if (savedPos !== null) ringPosition = parseInt(savedPos, 10) || 0;
} catch (e) {}
try {
var savedDir = localStorage.getItem('watchRingDirection');
if (savedDir !== null) ringDirection = parseInt(savedDir, 10) || 1;
} catch (e) {}
try {
var savedHistory = localStorage.getItem('watchRingHistory');
if (savedHistory) ringHistory = JSON.parse(savedHistory);
} catch (e) {}
try {
var savedLoops = localStorage.getItem('watchRingLoops');
if (savedLoops !== null) ringLoops = parseInt(savedLoops, 10) || 0;
} catch (e) {}
function saveRingState() {
try {
localStorage.setItem('watchRing', JSON.stringify(ringProductIds));
localStorage.setItem('watchRingPosition', String(ringPosition));
localStorage.setItem('watchRingDirection', String(ringDirection));
localStorage.setItem('watchRingHistory', JSON.stringify(ringHistory));
localStorage.setItem('watchRingLoops', String(ringLoops));
} catch (e) {}
}
function markWatched(productId) {
if (!productId) return;
ringHistory[productId] = true;
// Check for complete loop — all ring items watched
if (ringProductIds.length > 0) {
var allWatched = true;
for (var i = 0; i < ringProductIds.length; i++) {
if (!ringHistory[ringProductIds[i]]) {
allWatched = false;
break;
}
}
if (allWatched) {
ringLoops++;
ringHistory = {};
// Current item starts the new loop as already watched
ringHistory[productId] = true;
}
}
saveRingState();
updateProgressDisplay();
}
function isWatched(productId) {
return !!ringHistory[productId];
}
// Sync ring position to the current product
function syncRingPosition(productId) {
if (!ringProductIds.length || !productId) return;
var idx = ringProductIds.indexOf(productId);
if (idx !== -1) {
ringPosition = idx;
saveRingState();
}
}
// Update progress display ("23 / 87") and loop badge
function updateProgressDisplay() {
var el = document.getElementById('ring-progress');
if (!el) return;
if (!ringProductIds.length) {
el.textContent = '';
return;
}
var watched = 0;
for (var id in ringHistory) {
if (ringProductIds.indexOf(id) !== -1) watched++;
}
el.textContent = watched + ' / ' + ringProductIds.length;
// Loop badge
var badge = document.getElementById('ring-loops-badge');
if (badge) {
if (ringLoops > 0) {
badge.textContent = '+' + ringLoops;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
}
}
// 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) {
if (djCrossfadeActive) cancelDjCrossfade();
cancelCountdown();
if (staticTimer) { clearTimeout(staticTimer); staticTimer = null; }
} else {
// If on a static page, restart the timer
if (!activeMedia) startStaticTimer();
}
});
}
// Restore queue from sessionStorage
try {
var savedQueue = sessionStorage.getItem('watchQueue');
if (savedQueue) queue = JSON.parse(savedQueue);
} catch (e) {}
// Mod status (shop owner/editor)
var isMod = false;
// Mark current item as watched and sync ring position
var initialProductEl = document.querySelector('[data-watch-product-id]');
if (initialProductEl) {
var initialId = initialProductEl.getAttribute('data-watch-product-id');
markWatched(initialId);
syncRingPosition(initialId);
if (initialProductEl.getAttribute('data-watch-is-mod') === '1') {
isMod = true;
}
}
// --- 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) {
var btn = document.querySelector('.unmute-overlay');
if (!btn) return;
btn.style.display = 'block';
btn.addEventListener('click', function() {
media.muted = false;
btn.style.display = 'none';
});
}
// 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 = 'metadata';
el.style.display = 'none';
var container = document.getElementById('watch-media-container');
if (container) container.appendChild(el);
return el;
}
if (activeMedia) {
standbyMedia = createStandbyMedia(currentMediaType);
}
// --- Queue management ---
function saveQueue() {
try {
sessionStorage.setItem('watchQueue', JSON.stringify(queue));
} catch (e) {}
}
function addToQueue(item) {
queue.push(item);
saveQueue();
renderQueue();
}
function removeFromQueue(index) {
queue.splice(index, 1);
saveQueue();
renderQueue();
}
function renderQueue() {
var container = document.getElementById('watch-queue');
var items = document.getElementById('queue-items');
if (!container || !items) return;
if (queue.length === 0) {
container.style.display = 'none';
return;
}
container.style.display = 'block';
items.innerHTML = '';
queue.forEach(function(item, i) {
var div = document.createElement('div');
div.className = 'queue-item';
var titleSpan = document.createElement('span');
titleSpan.className = 'queue-item-title';
titleSpan.textContent = item.title;
titleSpan.style.cursor = 'pointer';
titleSpan.addEventListener('click', function() {
var playItem = queue.splice(i, 1)[0];
saveQueue();
renderQueue();
fetchAndNavigate(playItem.id);
});
var removeBtn = document.createElement('button');
removeBtn.className = 'queue-remove-btn';
removeBtn.textContent = '\u00d7';
removeBtn.title = 'Remove from queue';
removeBtn.addEventListener('click', function() {
removeFromQueue(i);
});
div.appendChild(titleSpan);
div.appendChild(removeBtn);
items.appendChild(div);
});
}
// --- Fetch watch JSON ---
function fetchWatchData(productId) {
return fetch('/watch/' + productId + '/json', {
headers: {'X-Requested-With': 'XMLHttpRequest'}
}).then(function(r) {
if (!r.ok) throw new Error('Watch fetch failed');
return r.json();
});
}
// --- SPA navigation ---
function fetchAndNavigate(productId) {
if (djCrossfadeActive) cancelDjCrossfade();
cancelCountdown();
if (staticTimer) { clearTimeout(staticTimer); staticTimer = null; }
fetchWatchData(productId).then(function(data) {
transitionTo(data);
}).catch(function() {
// 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 isPlayable = (data.media_type === 'video' || data.media_type === 'audio');
// From a static page, always hard navigate (no media container to swap into)
if (!activeMedia) {
if (data.canonical_url) {
window.location.href = data.canonical_url;
}
return;
}
if (isPlayable && data.media_type === currentMediaType) {
crossfade(data);
} else if (isPlayable) {
hardSwap(data);
} else {
// Next item is static content — hard navigate
if (data.canonical_url) {
window.location.href = data.canonical_url;
}
}
}
// --- Crossfade (user-initiated transitions: click related, queue, Play Now) ---
function crossfade(data) {
if (crossfadeTimer) clearInterval(crossfadeTimer);
// Cancel any in-progress DJ crossfade
if (djCrossfadeActive) cancelDjCrossfade();
if (!standbyMedia || standbyMedia.tagName.toLowerCase() !== data.media_type) {
hardSwap(data);
return;
}
standbyMedia.src = data.media_url;
standbyMedia.volume = 0;
standbyMedia.currentTime = 0;
// Move standby into same container for visual overlay
var mediaContainer = activeMedia.parentNode;
if (mediaContainer && standbyMedia.parentNode !== mediaContainer) {
mediaContainer.appendChild(standbyMedia);
}
standbyMedia.className = (currentMediaType === 'video')
? 'product-main crossfade-incoming' : 'crossfade-incoming';
standbyMedia.style.display = '';
standbyMedia.style.opacity = '0';
standbyMedia.controls = false;
var standbyPlayPromise = standbyMedia.play();
if (standbyPlayPromise !== undefined) {
standbyPlayPromise.catch(function() {
hardSwap(data);
return;
});
}
var step = 0;
var interval = CROSSFADE_DURATION / CROSSFADE_STEPS;
crossfadeTimer = setInterval(function() {
step++;
var progress = step / CROSSFADE_STEPS;
try {
activeMedia.volume = Math.max(0, 1 - progress);
standbyMedia.volume = Math.min(1, progress);
} catch (e) {}
// Visual opacity crossfade
activeMedia.style.opacity = String(1 - progress);
standbyMedia.style.opacity = String(progress);
if (step === Math.floor(CROSSFADE_STEPS / 2)) {
updatePageContent(data);
}
if (step >= CROSSFADE_STEPS) {
clearInterval(crossfadeTimer);
crossfadeTimer = null;
activeMedia.pause();
activeMedia.style.display = 'none';
activeMedia.style.opacity = '1';
standbyMedia.style.opacity = '1';
standbyMedia.className = (currentMediaType === 'video') ? 'product-main' : '';
standbyMedia.controls = true;
var tmp = activeMedia;
activeMedia = standbyMedia;
standbyMedia = tmp;
activeMedia.id = currentMediaType === 'video' ? 'watch-video' : 'watch-audio';
// Move standby back to main container
var mainContainer = document.getElementById('watch-media-container');
if (mainContainer && standbyMedia.parentNode !== mainContainer) {
mainContainer.appendChild(standbyMedia);
}
standbyMedia.style.display = 'none';
setupMediaEvents();
preloadNext();
}
}, interval);
}
// --- Hard swap (different media types or fallback) ---
function hardSwap(data) {
if (activeMedia) activeMedia.pause();
var container = document.getElementById('watch-media-container');
if (!container) return;
if (standbyMedia && standbyMedia.parentNode) {
standbyMedia.parentNode.removeChild(standbyMedia);
}
var newType = data.media_type;
currentMediaType = newType;
if (newType === 'audio') {
container.innerHTML = '';
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';
audioWrap.appendChild(img);
}
var audio = document.createElement('audio');
audio.id = 'watch-audio';
audio.src = data.media_url;
audio.autoplay = true;
audio.controls = true;
audioWrap.appendChild(audio);
container.appendChild(audioWrap);
activeMedia = audio;
} else {
container.innerHTML = '';
var videoWrap = document.createElement('div');
videoWrap.className = 'watch-video-container';
var video = document.createElement('video');
video.id = 'watch-video';
video.src = data.media_url;
video.autoplay = true;
video.controls = true;
video.playsInline = true;
video.className = 'product-main';
videoWrap.appendChild(video);
var unmuteBtn = document.createElement('button');
unmuteBtn.className = 'unmute-overlay';
unmuteBtn.textContent = 'Tap to unmute';
videoWrap.appendChild(unmuteBtn);
container.appendChild(videoWrap);
activeMedia = video;
}
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);
updatePageContent(data);
setupMediaEvents();
var p = activeMedia.play();
if (p !== undefined) {
p.catch(function() {
activeMedia.muted = true;
activeMedia.play().then(function() {
showUnmuteOverlay(activeMedia);
}).catch(function() {});
});
}
preloadNext();
}
// --- Update page content (title, description, related, URL) ---
function updatePageContent(data) {
// Update ring and mod status from server response
if (data.ring && data.ring.length) {
ringProductIds = data.ring;
}
if (data.is_mod) isMod = true;
markWatched(data.product_id);
syncRingPosition(data.product_id);
document.title = data.title;
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;
}
}
var descSection = document.querySelector('.product-description section');
if (descSection) {
descSection.innerHTML = data.description_html;
}
// Update CTA edit button href for mod users
if (isMod && data.product_id) {
var editBtn = document.querySelector('.call-to-action .product-edit-button');
if (editBtn) {
var prefix = data.is_sellable ? '/p/' : '/c/';
editBtn.href = prefix + data.product_id + '/edit';
}
}
// Update download button
var dlBtn = document.querySelector('.product-download-button');
var dlWell = dlBtn ? dlBtn.closest('.well') : null;
if (data.has_product_file && data.download_url) {
if (dlBtn) {
dlBtn.href = data.download_url;
} else if (dlWell) {
dlWell.innerHTML = '<a href="' + data.download_url
+ '" class="product-download-button mps-button" target="_blank" download>&#11123 Download</a>';
}
if (dlWell) dlWell.style.display = '';
} else {
if (dlWell) dlWell.style.display = 'none';
}
// Update file type/size info
var descDiv = document.querySelector('.product-description');
if (descDiv) {
var fileInfoB = descDiv.querySelectorAll('b');
for (var i = 0; i < fileInfoB.length; i++) {
if (fileInfoB[i].textContent === 'File Type') {
// The text node after <b>File Type</b><br/> holds "content/type size"
var sibling = fileInfoB[i].nextSibling;
while (sibling && sibling.nodeName === 'BR') sibling = sibling.nextSibling;
if (sibling && sibling.nodeType === Node.TEXT_NODE) {
if (data.has_product_file && data.file_type) {
sibling.textContent = data.file_type + ' ' + (data.file_size || '');
} else {
sibling.textContent = '';
}
}
break;
}
}
}
// Update comment form product_id and clear stale comments
var commentForms = document.querySelectorAll('input[name="product_id"]');
for (var j = 0; j < commentForms.length; j++) {
commentForms[j].value = data.product_id;
}
var commentsList = document.getElementById('comments-list');
if (commentsList) commentsList.innerHTML = '';
var commentsHeading = document.querySelector('.comments-section h3');
if (commentsHeading) commentsHeading.textContent = 'Comments & Reviews';
// Update canonical link
var canonicalLink = document.querySelector('link[rel="canonical"]');
if (canonicalLink && data.canonical_url) {
canonicalLink.href = window.location.origin + data.canonical_url;
}
updateRelated(data.related);
if (data.canonical_url) {
history.pushState({productId: data.product_id}, data.title, data.canonical_url);
}
renderQueue();
}
function updateRelated(related) {
var container = document.querySelector('.related-content');
if (!container) {
var productRight = document.querySelector('.product-right');
if (!productRight) return;
container = document.createElement('div');
container.className = 'related-content';
productRight.appendChild(container);
}
var autoplayChecked = autoplayEnabled ? ' checked' : '';
var directionChecked = ringDirection === -1 ? ' checked' : '';
var modBadge = isMod ? '<span class="ring-mod-badge">mod</span>' : '';
var loopsBadge = ringLoops > 0
? '<span id="ring-loops-badge" class="ring-loops-badge">+' + ringLoops + '</span>'
: '<span id="ring-loops-badge" class="ring-loops-badge" style="display:none"></span>';
var headerHtml = '<div class="related-content-header">'
+ '<div class="ring-header-info">'
+ '<h3>Up Next</h3>'
+ modBadge
+ loopsBadge
+ '<span id="ring-progress" class="ring-progress"></span>'
+ '</div>'
+ '<div class="ring-header-controls">'
+ '<label class="watch-direction-label js-only">'
+ '<span class="direction-label-text">Reverse</span>'
+ '<input type="checkbox" id="watch-direction-toggle"' + directionChecked + ' />'
+ '<span class="autoplay-slider"></span>'
+ '</label>'
+ '<label class="watch-autoplay-label js-only">'
+ '<span class="autoplay-label-text">Autoplay</span>'
+ '<input type="checkbox" id="watch-autoplay-toggle"' + autoplayChecked + ' />'
+ '<span class="autoplay-slider"></span>'
+ '</label>'
+ '</div></div>';
if (!related || related.length === 0) {
container.innerHTML = headerHtml + '<p>No more items</p>';
rebindToggles();
return;
}
var html = headerHtml;
var currentTitle = document.title;
var insertedCurrent = false;
related.forEach(function(item) {
var offset = item.offset || 1;
var watched = isWatched(item.id);
var dimClass = watched ? ' related-content-row-watched' : '';
var prevClass = offset < 0 ? ' related-content-row-prev' : '';
// Insert "now playing" row before the first forward item
if (offset > 0 && !insertedCurrent) {
insertedCurrent = true;
var ogImg = document.querySelector('meta[property="og:image"]');
var currentThumb = ogImg ? ogImg.getAttribute('content') : '';
html += '<div class="related-content-row related-content-row-current">';
html += '<span class="related-content-index">&#9654;</span>';
html += '<span class="related-content-item related-content-now-playing">';
if (currentThumb) {
html += '<img src="' + currentThumb + '" />';
} else {
html += '<span class="related-content-no-thumb"></span>';
}
html += '<span class="related-content-title">' + escapeHtml(currentTitle) + '</span>';
html += '</span>';
html += '</div>';
}
html += '<div class="related-content-row' + dimClass + prevClass + '">';
html += '<span class="related-content-index">' + offset + '</span>';
html += '<a href="' + item.url + '" class="related-content-item" data-watch-id="' + item.id + '">';
if (item.thumbnail_url) {
html += '<img src="' + item.thumbnail_url + '" />';
} else {
html += '<span class="related-content-no-thumb"></span>';
}
html += '<span class="related-content-title">' + escapeHtml(item.title) + '</span>';
html += '</a>';
html += '<button class="queue-add-btn js-only" data-product-id="' + item.id + '" data-title="' + escapeHtml(item.title) + '" data-url="' + item.url + '" data-media-type="' + (item.media_type || '') + '" data-thumbnail="' + (item.thumbnail_url || '') + '" title="Add to queue">+</button>';
html += '</div>';
});
container.innerHTML = html;
rebindToggles();
}
function rebindToggles() {
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) {
if (djCrossfadeActive) cancelDjCrossfade();
cancelCountdown();
if (staticTimer) { clearTimeout(staticTimer); staticTimer = null; }
} else {
if (!activeMedia) startStaticTimer();
}
});
}
var directionToggle = document.getElementById('watch-direction-toggle');
if (directionToggle) {
directionToggle.checked = (ringDirection === -1);
directionToggle.addEventListener('change', function() {
ringDirection = this.checked ? -1 : 1;
saveRingState();
});
}
updateProgressDisplay();
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// --- 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
if (!ringProductIds.length) {
// No ring — fall back to first 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')
};
}
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
};
}
function showCountdownOverlay(next) {
var overlay = document.getElementById('watch-countdown');
if (!overlay) return;
var thumb = document.getElementById('countdown-thumb');
var titleEl = document.getElementById('countdown-title');
var numberEl = document.getElementById('countdown-number');
if (thumb) {
if (next.thumbnail_url) {
thumb.src = next.thumbnail_url;
thumb.style.display = '';
} else {
thumb.style.display = 'none';
}
}
if (titleEl) titleEl.textContent = 'Up next: ' + next.title;
if (numberEl) numberEl.textContent = COUNTDOWN_SECONDS;
overlay.style.display = '';
}
// --- DJ crossfade: starts 7s before end, blends audio + video ---
function startDjCrossfade() {
if (djCrossfadeActive) return;
if (!autoplayEnabled) return;
if (!activeMedia) return;
var next = getNextItem();
if (!next || !next.id) return;
// Need preloaded data for the next item
var data = (preloadedData && preloadedData.product_id === next.id) ? preloadedData : null;
if (!data) return;
// Only DJ crossfade between same media types (video→video, audio→audio)
if (data.media_type !== currentMediaType) return;
if (data.media_type !== 'video' && data.media_type !== 'audio') return;
if (!standbyMedia) return;
djCrossfadeActive = true;
djCrossfadeData = data;
preloadedData = null;
// Show countdown overlay during the crossfade
showCountdownOverlay(next);
// Prepare standby media
if (!standbyMedia.src || standbyMedia.src !== data.media_url) {
standbyMedia.src = data.media_url;
}
standbyMedia.volume = 0;
standbyMedia.currentTime = 0;
// Move standby into the same container as active for visual overlay
var mediaContainer = activeMedia.parentNode;
if (mediaContainer && standbyMedia.parentNode !== mediaContainer) {
mediaContainer.appendChild(standbyMedia);
}
// Style for visual crossfade overlay
standbyMedia.className = (currentMediaType === 'video')
? 'product-main crossfade-incoming'
: 'crossfade-incoming';
standbyMedia.style.display = '';
standbyMedia.style.opacity = '0';
standbyMedia.controls = false;
// Start playing the standby
var playPromise = standbyMedia.play();
if (playPromise !== undefined) {
playPromise.catch(function() {
// Can't play standby — abort, fall back to ended→countdown
djCrossfadeActive = false;
djCrossfadeData = null;
standbyMedia.style.display = 'none';
standbyMedia.className = '';
});
}
// Animate volume + opacity over COUNTDOWN_SECONDS
var steps = COUNTDOWN_SECONDS * 20; // 20fps for smooth animation
var step = 0;
var interval = (COUNTDOWN_SECONDS * 1000) / steps;
var startVolume = activeMedia.volume || 1;
if (crossfadeTimer) clearInterval(crossfadeTimer);
crossfadeTimer = setInterval(function() {
step++;
var progress = step / steps;
// Volume crossfade
try {
activeMedia.volume = Math.max(0, startVolume * (1 - progress));
standbyMedia.volume = Math.min(1, progress);
} catch(e) {}
// Visual crossfade (opacity)
activeMedia.style.opacity = String(1 - progress);
standbyMedia.style.opacity = String(progress);
// Update countdown number
var remaining = COUNTDOWN_SECONDS - Math.floor(step * COUNTDOWN_SECONDS / steps);
var numberEl = document.getElementById('countdown-number');
if (numberEl) numberEl.textContent = Math.max(0, remaining);
// At halfway point, update page content (URL, title, description)
if (step === Math.floor(steps / 2)) {
updatePageContent(data);
}
if (step >= steps) {
completeDjCrossfade(data);
}
}, interval);
}
function completeDjCrossfade(data) {
if (crossfadeTimer) {
clearInterval(crossfadeTimer);
crossfadeTimer = null;
}
// Finish the swap
activeMedia.pause();
activeMedia.style.display = 'none';
activeMedia.style.opacity = '1';
standbyMedia.style.opacity = '1';
standbyMedia.className = (currentMediaType === 'video') ? 'product-main' : '';
standbyMedia.controls = true;
var tmp = activeMedia;
activeMedia = standbyMedia;
standbyMedia = tmp;
activeMedia.id = (currentMediaType === 'video') ? 'watch-video' : 'watch-audio';
// Move standby back to the main container for next cycle
var mainContainer = document.getElementById('watch-media-container');
if (mainContainer && standbyMedia.parentNode !== mainContainer) {
mainContainer.appendChild(standbyMedia);
}
standbyMedia.style.display = 'none';
// Pop queue if this was a queued item (ring position already
// synced by updatePageContent → syncRingPosition at halfway mark)
if (data && queue.length > 0 && queue[0].id === data.product_id) {
queue.shift();
saveQueue();
renderQueue();
}
cancelCountdown();
djCrossfadeActive = false;
djCrossfadeData = null;
setupMediaEvents();
preloadNext();
}
function cancelDjCrossfade() {
if (!djCrossfadeActive) return;
if (crossfadeTimer) {
clearInterval(crossfadeTimer);
crossfadeTimer = null;
}
// Restore active media
activeMedia.volume = 1;
activeMedia.style.opacity = '1';
// Hide and reset standby
standbyMedia.pause();
standbyMedia.style.display = 'none';
standbyMedia.style.opacity = '0';
standbyMedia.className = '';
standbyMedia.volume = 0;
// Move standby back to main container
var mainContainer = document.getElementById('watch-media-container');
if (mainContainer && standbyMedia.parentNode !== mainContainer) {
mainContainer.appendChild(standbyMedia);
}
djCrossfadeActive = false;
djCrossfadeData = null;
cancelCountdown();
}
function startCountdown() {
if (!autoplayEnabled) return;
if (djCrossfadeActive) return;
var next = getNextItem();
if (!next) return;
showCountdownOverlay(next);
var numberEl = document.getElementById('countdown-number');
var remaining = COUNTDOWN_SECONDS;
countdownTimer = setInterval(function() {
remaining--;
if (numberEl) numberEl.textContent = remaining;
if (remaining <= 0) {
cancelCountdown();
navigateToNext(next);
}
}, 1000);
}
function cancelCountdown() {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
var overlay = document.getElementById('watch-countdown');
if (overlay) overlay.style.display = 'none';
}
function navigateToNext(next) {
if (!next || !next.id) return;
var productId = next.id;
// If this came from the queue, pop it (ring position unchanged)
if (queue.length > 0 && queue[0].id === productId) {
queue.shift();
saveQueue();
renderQueue();
} else {
// Advance ring position
if (ringProductIds.length) {
ringPosition = (ringPosition + ringDirection + ringProductIds.length) % ringProductIds.length;
saveRingState();
}
}
if (preloadedData && preloadedData.product_id === productId) {
transitionTo(preloadedData);
preloadedData = null;
} else {
fetchAndNavigate(productId);
}
}
// --- 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);
}
// --- Phase 1: fetch next item JSON (fast, no media download) ---
function preloadNext() {
var next = getNextItem();
if (!next || !next.id) return;
// Idempotent: skip if already preloading or preloaded this item
if (preloadingId === next.id) return;
if (preloadedData && preloadedData.product_id === next.id) return;
preloadingId = next.id;
bufferingStarted = false;
fetchWatchData(next.id).then(function(data) {
preloadingId = null;
preloadedData = data;
if (standbyMedia && data.media_url && (data.media_type === 'video' || data.media_type === 'audio')) {
standbyMedia.src = data.media_url;
standbyMedia.preload = 'none';
}
}).catch(function() {
preloadingId = null;
preloadedData = null;
});
}
// --- Phase 2: start buffering media ~30s before end for seamless crossfade ---
function bufferNext() {
if (bufferingStarted) return;
if (!standbyMedia || !standbyMedia.src) return;
bufferingStarted = true;
standbyMedia.preload = 'auto';
standbyMedia.load();
}
// --- Named event handlers (must be named so removeEventListener works) ---
function onMediaEnded() {
// Fallback: only start countdown if DJ crossfade didn't handle it
if (!djCrossfadeActive) {
startCountdown();
}
}
function onMediaLoadedMetadata() {
preloadNext();
}
function onMediaTimeUpdate() {
if (!activeMedia || !activeMedia.duration || !isFinite(activeMedia.duration)) return;
var remaining = activeMedia.duration - activeMedia.currentTime;
if (remaining <= BUFFER_AHEAD_SECONDS) {
bufferNext();
}
// DJ crossfade: start blending 7 seconds before end
if (remaining <= COUNTDOWN_SECONDS && remaining > 0 && !djCrossfadeActive) {
startDjCrossfade();
}
}
// --- Media event setup ---
function setupMediaEvents() {
if (!activeMedia) return;
// Remove from both elements to prevent listener accumulation after swaps
activeMedia.removeEventListener('ended', onMediaEnded);
activeMedia.removeEventListener('loadedmetadata', onMediaLoadedMetadata);
activeMedia.removeEventListener('timeupdate', onMediaTimeUpdate);
if (standbyMedia) {
standbyMedia.removeEventListener('ended', onMediaEnded);
standbyMedia.removeEventListener('loadedmetadata', onMediaLoadedMetadata);
standbyMedia.removeEventListener('timeupdate', onMediaTimeUpdate);
}
// Add fresh listeners to active element only
activeMedia.addEventListener('ended', onMediaEnded);
activeMedia.addEventListener('loadedmetadata', onMediaLoadedMetadata);
activeMedia.addEventListener('timeupdate', onMediaTimeUpdate);
}
// Initial setup
if (activeMedia) {
setupMediaEvents();
} else {
// Static content (PDF, image, etc.) — start 42-second timer
// Reset on scroll — scrolling means the user is still reading
startStaticTimer();
window.addEventListener('scroll', function() {
if (!activeMedia && autoplayEnabled) {
cancelCountdown();
startStaticTimer();
}
});
}
// --- Event delegation for SPA link interception ---
document.addEventListener('click', function(e) {
var queueBtn = e.target.closest('.queue-add-btn');
if (queueBtn) {
e.preventDefault();
e.stopPropagation();
addToQueue({
id: queueBtn.getAttribute('data-product-id'),
title: queueBtn.getAttribute('data-title'),
url: queueBtn.getAttribute('data-url'),
media_type: queueBtn.getAttribute('data-media-type'),
thumbnail_url: queueBtn.getAttribute('data-thumbnail')
});
return;
}
var link = e.target.closest('.related-content-item[data-watch-id]');
if (link) {
e.preventDefault();
var productId = link.getAttribute('data-watch-id');
fetchAndNavigate(productId);
return;
}
if (e.target.id === 'countdown-play-now' || e.target.closest('#countdown-play-now')) {
e.preventDefault();
if (djCrossfadeActive && djCrossfadeData) {
// Complete the DJ crossfade immediately
completeDjCrossfade(djCrossfadeData);
} else {
cancelCountdown();
var next = getNextItem();
if (next) navigateToNext(next);
}
return;
}
if (e.target.id === 'countdown-cancel' || e.target.closest('#countdown-cancel')) {
e.preventDefault();
if (djCrossfadeActive) {
cancelDjCrossfade();
} else {
cancelCountdown();
}
return;
}
});
// --- Browser back/forward ---
window.addEventListener('popstate', function(e) {
if (e.state && e.state.productId) {
fetchAndNavigate(e.state.productId);
}
});
// Store initial state
var initialProductId = document.querySelector('[data-watch-product-id]');
if (initialProductId) {
history.replaceState(
{productId: initialProductId.getAttribute('data-watch-product-id')},
document.title,
window.location.href
);
}
// Dim already-watched items in server-rendered related content
function dimWatchedInDOM() {
var rows = document.querySelectorAll('.related-content-row');
for (var i = 0; i < rows.length; i++) {
var link = rows[i].querySelector('.related-content-item[data-watch-id]');
if (link && isWatched(link.getAttribute('data-watch-id'))) {
rows[i].classList.add('related-content-row-watched');
}
}
}
dimWatchedInDOM();
updateProgressDisplay();
renderQueue();
preloadNext();
})();