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.
1172 lines
42 KiB
JavaScript
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>⭳ 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">▶</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();
|
|
})();
|