Watch mode v2: continuous playback with crossfade and queue

Rewrite watch mode from basic autoplay into a full SPA media controller:

- Fix get_related_products() with 3-tier fallback (stem overlap, same
  media type, recency) so orphan products still show recommendations
- Add /watch/{id}/json endpoint for SPA navigation without page refresh
- Rewrite watch.js: SPA fetch+pushState, 14-second countdown timer,
  DJ crossfade with dual media elements, queue system with sessionStorage
- Add audio support in watch mode (album art + audio player)
- Add queue "+" buttons to related content sidebar
- Add noscript/js-only progressive enhancement pattern
- Preload next media for instant transitions
This commit is contained in:
russell@unturf.com 2026-02-08 08:19:07 -05:00
parent fd33376cb7
commit 12f738d480
10 changed files with 979 additions and 41 deletions

View file

@ -698,14 +698,18 @@ def tokenize_and_stem(text):
def get_related_products(product, limit=8):
"""Find related products in same shop by stemmed title+description overlap."""
"""Find related products in same shop using tiered fallback:
1. Stem overlap (highest score first)
2. Same media type (newest first)
3. Any remaining public product (newest first)
"""
current_stems = tokenize_and_stem(
(product.title or '') + ' ' + (product.description or '')
)
if not current_stems:
return []
current_ext = product.extensions.get("product", "")
current_media = get_media_type(current_ext)
candidates = []
scored = []
for other in product.shop.products:
if other.id == product.id:
continue
@ -714,9 +718,36 @@ def get_related_products(product, limit=8):
other_stems = tokenize_and_stem(
(other.title or '') + ' ' + (other.description or '')
)
score = len(current_stems & other_stems)
if score > 0:
candidates.append((score, other))
score = len(current_stems & other_stems) if current_stems else 0
scored.append((score, other))
candidates.sort(key=lambda x: x[0], reverse=True)
return [p for _, p in candidates[:limit]]
# Deterministic sort: highest score, newest, then id tiebreak
scored.sort(key=lambda x: (-x[0], -x[1].created_timestamp, str(x[1].id)))
results = []
result_ids = set()
# Tier 1: stem matches (score > 0)
for s, p in scored:
if s > 0 and len(results) < limit:
results.append(p)
result_ids.add(p.id)
# Tier 2: same media type
if current_media and len(results) < limit:
for _, p in scored:
if p.id not in result_ids and get_media_type(p.extensions.get("product", "")) == current_media:
results.append(p)
result_ids.add(p.id)
if len(results) >= limit:
break
# Tier 3: any remaining public product
for _, p in scored:
if p.id not in result_ids:
results.append(p)
result_ids.add(p.id)
if len(results) >= limit:
break
return results[:limit]

View file

@ -170,6 +170,7 @@ def includeme(config):
# media player routes
config.add_route("player", "/player/{product_id}")
config.add_route("player_json", "/player/{product_id}/json")
config.add_route("watch_json", "/watch/{product_id}/json")
config.add_route("random", "/random")
config.add_route("tv", "/tv")

View file

@ -1,29 +1,548 @@
(function() {
var video = document.getElementById('watch-video');
if (!video) return;
// Find the active media element (video or audio)
var activeMedia = document.getElementById('watch-video') || document.getElementById('watch-audio');
if (!activeMedia) return;
// Try autoplay with sound (works when user navigated here via click)
var playPromise = video.play();
// SPA state
var standbyMedia = null;
var preloadedData = null;
var queue = [];
var countdownTimer = null;
var crossfadeTimer = null;
var COUNTDOWN_SECONDS = 14;
var CROSSFADE_DURATION = 3000;
var CROSSFADE_STEPS = 60;
// Restore queue from sessionStorage
try {
var savedQueue = sessionStorage.getItem('watchQueue');
if (savedQueue) queue = JSON.parse(savedQueue);
} catch (e) {}
// --- Autoplay with sound (existing behavior) ---
var playPromise = activeMedia.play();
if (playPromise !== undefined) {
playPromise.catch(function() {
// Browser blocked autoplay with sound — try muted
video.muted = true;
video.play().then(function() {
// Playing muted — show unmute button
showUnmuteOverlay(video);
}).catch(function() {
// Even muted autoplay blocked — user needs to click play
});
activeMedia.muted = true;
activeMedia.play().then(function() {
showUnmuteOverlay(activeMedia);
}).catch(function() {});
});
}
function showUnmuteOverlay(video) {
function showUnmuteOverlay(media) {
var btn = document.querySelector('.unmute-overlay');
if (!btn) return;
btn.style.display = 'block';
btn.addEventListener('click', function() {
video.muted = false;
media.muted = false;
btn.style.display = 'none';
});
}
// --- Create standby media element ---
function createStandbyMedia(type) {
var el = document.createElement(type === 'audio' ? 'audio' : 'video');
el.preload = 'auto';
el.style.display = 'none';
var container = document.getElementById('watch-media-container');
if (container) container.appendChild(el);
return el;
}
// Determine current media type from the active element
var currentMediaType = activeMedia.tagName.toLowerCase();
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() {
// Play this item immediately, remove from queue
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) {
cancelCountdown();
fetchWatchData(productId).then(function(data) {
transitionTo(data);
}).catch(function() {
// Fallback: hard navigate
window.location.href = '/watch/' + productId + '/json';
});
}
function transitionTo(data) {
var sameType = (data.media_type === currentMediaType);
if (sameType) {
crossfade(data);
} else {
hardSwap(data);
}
}
// --- Crossfade ---
function crossfade(data) {
if (crossfadeTimer) clearInterval(crossfadeTimer);
// Ensure standby matches media type
if (standbyMedia.tagName.toLowerCase() !== data.media_type) {
hardSwap(data);
return;
}
standbyMedia.src = data.media_url;
standbyMedia.volume = 0;
standbyMedia.currentTime = 0;
var standbyPlayPromise = standbyMedia.play();
if (standbyPlayPromise !== undefined) {
standbyPlayPromise.catch(function() {
// Crossfade failed, hard swap
hardSwap(data);
return;
});
}
var step = 0;
var interval = CROSSFADE_DURATION / CROSSFADE_STEPS;
crossfadeTimer = setInterval(function() {
step++;
try {
activeMedia.volume = Math.max(0, 1 - step / CROSSFADE_STEPS);
standbyMedia.volume = Math.min(1, step / CROSSFADE_STEPS);
} catch (e) {}
if (step === Math.floor(CROSSFADE_STEPS / 2)) {
updatePageContent(data);
}
if (step >= CROSSFADE_STEPS) {
clearInterval(crossfadeTimer);
crossfadeTimer = null;
activeMedia.pause();
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' : '';
setupMediaEvents();
preloadNext();
}
}, interval);
}
// --- Hard swap (different media types or fallback) ---
function hardSwap(data) {
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
if (data.thumbnail_url) {
var img = document.createElement('img');
img.src = data.thumbnail_url;
img.className = 'product-main audio-cover';
container.appendChild(img);
}
var audio = document.createElement('audio');
audio.id = 'watch-audio';
audio.src = data.media_url;
audio.autoplay = true;
audio.controls = true;
container.appendChild(audio);
activeMedia = audio;
} else {
// Replace with video
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;
}
// Create new standby
standbyMedia = createStandbyMedia(newType);
// Re-add countdown overlay
var countdown = document.getElementById('watch-countdown');
if (countdown) container.appendChild(countdown);
updatePageContent(data);
setupMediaEvents();
// Try autoplay
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 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
if (h1) {
var titleNode = h1.firstChild;
if (titleNode && titleNode.nodeType === Node.TEXT_NODE) {
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');
container.className = 'related-content';
productRight.appendChild(container);
}
if (!related || related.length === 0) {
container.innerHTML = '<h3>Up Next</h3><p>No more items</p>';
return;
}
var html = '<h3>Up Next</h3>';
related.forEach(function(item) {
html += '<a href="' + item.url + '" class="related-content-item" data-watch-id="' + item.id + '">';
if (item.thumbnail_url) {
html += '<img src="' + item.thumbnail_url + '" />';
}
html += '<span>' + 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 || '') + '">+</button>';
});
container.innerHTML = html;
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// --- 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 {
id: firstRelated.getAttribute('data-watch-id'),
title: firstRelated.querySelector('span') ? firstRelated.querySelector('span').textContent : '',
thumbnail_url: firstRelated.querySelector('img') ? firstRelated.querySelector('img').src : null,
url: firstRelated.getAttribute('href')
};
}
return null;
}
function startCountdown() {
var next = getNextItem();
if (!next) return;
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 = '';
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) {
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;
}
// Use preloaded data if available and matches
if (preloadedData && preloadedData.product_id === productId) {
transitionTo(preloadedData);
preloadedData = null;
} else {
fetchAndNavigate(productId);
}
}
// --- Preload next media ---
function preloadNext() {
var next = getNextItem();
if (!next || !next.id) return;
fetchWatchData(next.id).then(function(data) {
preloadedData = data;
if (standbyMedia && data.media_url) {
standbyMedia.src = data.media_url;
standbyMedia.preload = 'auto';
}
}).catch(function() {
preloadedData = null;
});
}
// --- Media event setup ---
function setupMediaEvents() {
activeMedia.addEventListener('ended', function() {
startCountdown();
});
activeMedia.addEventListener('loadedmetadata', function() {
preloadNext();
});
}
// Initial event setup
setupMediaEvents();
// --- 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();
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;
}
// Related content links — SPA intercept
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;
}
// 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();
var next = getNextItem();
if (next) navigateToNext(next);
return;
}
// Countdown cancel
if (e.target.id === 'countdown-cancel' || e.target.closest('#countdown-cancel')) {
e.preventDefault();
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
);
}
// Render queue on load
renderQueue();
// Preload next on initial load
preloadNext();
})();

View file

@ -82,6 +82,9 @@
</head>
<body>
<noscript>
<style>.js-only {display: none;}</style>
</noscript>
{% include 'snippets/ribbon.j2' %}
<section class="main">

View file

@ -32,24 +32,59 @@
<section class="two-column{% if request.shop.watch_mode_enabled %} watch-mode{% endif %}">
{% if request.shop.watch_mode_enabled %}<div class="watch-left">{% endif %}
{% if request.shop.watch_mode_enabled %}<div class="watch-left" data-watch-product-id="{{ product.id }}">{% endif %}
<div class="product-images">
<h1>{{ product.title }} <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>
{% if "thumbnail1" in product.extensions %}
{% set video_extensions = ["mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", "m4v"] %}
{% set audio_extensions = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "opus"] %}
{% if request.shop.watch_mode_enabled and product.extensions.get("product") in video_extensions %}
{# Watch mode: direct video render with autoplay #}
{% set watch_video_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %}
<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-media-container">
<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>
<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>
<a href="{{ watch_video_url }}" target="_blank">Open video</a>
</noscript>
{% elif product.extensions.get("product") in video_extensions %}
{% elif request.shop.watch_mode_enabled and product.extensions.get("product") in audio_extensions %}
{# Watch mode: audio with album art #}
{% set watch_audio_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %}
<div id="watch-media-container">
<div class="watch-audio-container">
{% if "thumbnail1" in product.extensions %}
<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>
<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>
<a href="{{ watch_audio_url }}" target="_blank">Listen</a>
</noscript>
{% elif "thumbnail1" in product.extensions %}
{% if product.extensions.get("product") in video_extensions %}
{# Video: play button overlay, click to play inline #}
{% set video_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %}
<div id="video-container-{{ product.id }}" class="video-thumbnail-container" onclick="playInline(this, '{{ video_url }}')">
@ -60,12 +95,12 @@
<a href="{{ video_url }}" target="_blank">Open video</a>
</noscript>
<div class="video-click-to-play">click ▶ to play</div>
{% else %}
{% else %}
{# Non-video: click to open in new window #}
<a target="_blank" href="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/product?ts={{ product.updated_timestamp }}">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
</a>
{% endif %}
{% endif %}
{% endif %}
<br/>
</div>
@ -111,6 +146,10 @@
{% endif %}
</div>
{% endif %}
<div id="watch-queue" class="js-only" style="display:none">
<h4>Queue</h4>
<div id="queue-items"></div>
</div>
{% include 'snippets/related_content.j2' %}
</section>

View file

@ -30,13 +30,13 @@
<section class="two-column{% if request.shop.watch_mode_enabled %} watch-mode{% endif %}">
{% if request.shop.watch_mode_enabled %}<div class="watch-left">{% endif %}
{% if request.shop.watch_mode_enabled %}<div class="watch-left" data-watch-product-id="{{ product.id }}">{% endif %}
<div class="product-images">
<h1>{{ product.title }} <span class="subtitle-text">sold by <a href="{{ product.shop.absolute_about_url(request) }}" rel="nofollow" class="shop-theme-link-color">{{ product.shop.name }}</a></span></h1>
{% if "thumbnail1" in product.extensions %}
{% set video_extensions = ["mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", "m4v"] %}
{% set audio_extensions = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "opus"] %}
{% if request.shop.watch_mode_enabled and signed_get_object_url and product.extensions.get("product") in video_extensions %}
{# Watch mode: direct video render with autoplay #}
{% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %}
@ -44,14 +44,53 @@
{% else %}
{% set watch_video_url = signed_get_object_url %}
{% endif %}
<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-media-container">
<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>
<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>
<a href="{{ watch_video_url }}" target="_blank">Open video</a>
</noscript>
{% elif signed_get_object_url and product.extensions.get("product") in video_extensions %}
{% elif request.shop.watch_mode_enabled and signed_get_object_url and product.extensions.get("product") in audio_extensions %}
{# Watch mode: audio with album art #}
{% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %}
{% set watch_audio_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview" %}
{% else %}
{% set watch_audio_url = signed_get_object_url %}
{% endif %}
<div id="watch-media-container">
<div class="watch-audio-container">
{% if "thumbnail1" in product.extensions %}
<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>
<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>
<a href="{{ watch_audio_url }}" target="_blank">Listen</a>
</noscript>
{% elif "thumbnail1" in product.extensions %}
{% if signed_get_object_url and product.extensions.get("product") in video_extensions %}
{% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %}
{% set preview_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview" %}
<div id="video-container-{{ product.id }}" class="video-thumbnail-container" onclick="playInline(this, '{{ preview_url }}')">
@ -69,11 +108,11 @@
</a>
<div class="video-click-to-play">click &#9654; to play</div>
{% endif %}
{% else %}
{% else %}
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
{% endif %}
{% endif %}
<br/>
{% endif %}
{% for file_key in product.file_thumbnail_keys %}
{% if file_key in product.s3_key_thumbnails %}
@ -193,6 +232,10 @@
{% endif %}
<div>
<div id="watch-queue" class="js-only" style="display:none">
<h4>Queue</h4>
<div id="queue-items"></div>
</div>
{% include 'snippets/related_content.j2' %}
</section>

View file

@ -2,12 +2,17 @@
<div class="related-content">
<h3>Up Next</h3>
{% for related in related_products %}
<a href="{{ related.absolute_url(request) }}" class="related-content-item">
<a href="{{ related.absolute_url(request) }}" class="related-content-item" data-watch-id="{{ related.id }}">
{% if "thumbnail1" in related.extensions %}
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ related.s3_path }}/thumbnail1?ts={{ related.updated_timestamp }}" />
{% endif %}
<span>{{ related.title }}</span>
</a>
<button class="queue-add-btn js-only"
data-product-id="{{ related.id }}"
data-title="{{ related.title }}"
data-url="{{ related.absolute_url(request) }}"
data-thumbnail="{% if 'thumbnail1' in related.extensions %}{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ related.s3_path }}/thumbnail1?ts={{ related.updated_timestamp }}{% endif %}">+</button>
{% endfor %}
</div>
{% endif %}

View file

@ -2564,6 +2564,62 @@ class AuthenticatedFunctionalTests(FunctionalTests):
res = res.follow()
self.assertIn(b"Invalid unsubscribe link", res.body)
def test_watch_json_requires_watch_mode(self):
"""Test that /watch/{id}/json returns 404 when watch mode is disabled."""
shop = self._create_shop_helper(
shop_params={
**self.shop1_params,
"name": "watch-json-shop",
}
)
# Create a product
redirect_res = self.testapp.post(
f"/p/new?shop_id={shop.id}", self.product1_params
)
res = redirect_res.follow()
self.assertIn("Great, next you may upload files.", res.body.decode())
products = get_all_products(self.dbsession).all()
product = products[0]
# Watch mode is off by default — should get 404
res = self.testapp.get(f"/watch/{product.id}/json", status=404)
self.assertIn("Watch mode not enabled", res.json["error"])
def test_watch_json_with_watch_mode_enabled(self):
"""Test that /watch/{id}/json returns error for product without media file."""
shop = self._create_shop_helper(
shop_params={
**self.shop1_params,
"name": "watch-json-enabled-shop",
}
)
# Enable watch mode
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "ribbon-settings",
"watch_mode": "1",
"submit": "Save Settings",
},
status=302,
)
# Create a product (no media file uploaded)
redirect_res = self.testapp.post(
f"/p/new?shop_id={shop.id}", self.product1_params
)
res = redirect_res.follow()
products = get_all_products(self.dbsession).all()
product = products[0]
# Product has no media file — should get 403 (no preview available)
res = self.testapp.get(f"/watch/{product.id}/json", status=403)
self.assertIn("No preview available", res.json["error"])
def test_rss_autodiscovery_links(self):
"""Test that RSS and Atom autodiscovery links are present in shop pages."""
shop = self._create_shop_helper(

View file

@ -1977,7 +1977,7 @@ class TestStemmer(unittest.TestCase):
self.assertLess(related.index(product4), related.index(product3))
def test_get_related_products_empty_description(self):
"""Test with empty title/description."""
"""Test with empty title/description still returns results via fallback."""
from ..models.product import Product, get_related_products
shop = Shop(
@ -1996,6 +1996,41 @@ class TestStemmer(unittest.TestCase):
related = get_related_products(product)
self.assertEqual(related, [])
def test_get_related_products_empty_description_with_others(self):
"""Test orphan product (no stems) gets fallback results."""
from ..models.product import Product, get_related_products
shop = Shop(
"test-shop",
"555-555-5555",
"123 Test St",
"Test shop description",
)
product = Product("", "")
product.shop = shop
product.visibility = 1
product.created_timestamp = 100
other1 = Product("Song One", "A great song")
other1.shop = shop
other1.visibility = 1
other1.created_timestamp = 200
other2 = Product("Song Two", "Another song")
other2.shop = shop
other2.visibility = 1
other2.created_timestamp = 300
all_products = [product, other1, other2]
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
mock_products.return_value = all_products
related = get_related_products(product)
# Should return fallback results (tier 3: any public product)
self.assertEqual(len(related), 2)
self.assertIn(other1, related)
self.assertIn(other2, related)
def test_get_related_products_limit(self):
"""Test that limit parameter is respected."""
from ..models.product import Product, get_related_products
@ -2031,6 +2066,98 @@ class TestStemmer(unittest.TestCase):
related = get_related_products(product1, limit=3)
self.assertLessEqual(len(related), 3)
def test_get_related_products_same_media_type_fallback(self):
"""Test tier 2: same media type fills remaining slots."""
from ..models.product import Product, get_related_products
shop = Shop(
"test-shop",
"555-555-5555",
"123 Test St",
"Test shop description",
)
def _make_product(title, desc, timestamp, ext=None):
p = Product(title, desc)
p.shop = shop
p.visibility = 1
p.created_timestamp = timestamp
if ext:
p._file_metadata = {"extensions": {"product": ext}, "originals": {}}
return p
# Product with no stem overlap to anything
product1 = _make_product("Xylophone Jam", "Unique xylophone music", 100, "mp3")
# Same media type (audio) but no stem overlap
product2 = _make_product("Drums Solo", "Percussion performance", 200, "mp3")
# Different media type (video) and no stem overlap
product3 = _make_product("Cat Video", "Funny cat footage", 300, "mp4")
# Another audio with no stem overlap
product4 = _make_product("Bass Line", "Groovy bass track", 400, "wav")
all_products = [product1, product2, product3, product4]
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
mock_products.return_value = all_products
related = get_related_products(product1)
# Should include all 3 others
self.assertEqual(len(related), 3)
# Same media type (audio) should come before different type (video)
audio_indices = []
video_indices = []
for i, p in enumerate(related):
ext = p.extensions.get("product", "")
if ext in ("mp3", "wav"):
audio_indices.append(i)
elif ext == "mp4":
video_indices.append(i)
# All audio items should come before video items
if audio_indices and video_indices:
self.assertLess(max(audio_indices), min(video_indices))
def test_get_related_products_deterministic(self):
"""Test that results are deterministic (consistent ordering)."""
from ..models.product import Product, get_related_products
shop = Shop(
"test-shop",
"555-555-5555",
"123 Test St",
"Test shop description",
)
product1 = Product("Guitar Music", "Guitar tutorial video")
product1.shop = shop
product1.visibility = 1
product1.created_timestamp = 100
product2 = Product("Guitar Songs", "Guitar song collection")
product2.shop = shop
product2.visibility = 1
product2.created_timestamp = 200
product3 = Product("Guitar Chords", "Guitar chord lessons")
product3.shop = shop
product3.visibility = 1
product3.created_timestamp = 300
all_products = [product1, product2, product3]
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
mock_products.return_value = all_products
# Run multiple times to verify determinism
related1 = get_related_products(product1)
related2 = get_related_products(product1)
self.assertEqual(
[p.title for p in related1],
[p.title for p in related2],
)
class TestShopSubscription(unittest.TestCase):
"""Test ShopSubscription model."""

View file

@ -0,0 +1,114 @@
from pyramid.view import view_config
from ..models.product import get_media_type, get_related_products
@view_config(route_name="watch_json", renderer="json")
def watch_json(request):
"""JSON endpoint for watch mode SPA navigation."""
product = request.product
if not product:
request.response.status_int = 404
return {"error": "Product not found"}
if not request.shop or not request.shop.watch_mode_enabled:
request.response.status_int = 404
return {"error": "Watch mode not enabled"}
# Only allow public products (visibility == 1)
if product.visibility != 1:
request.response.status_int = 403
return {"error": "Not publicly available"}
# Determine which file to use
if product.is_sellable:
file_key = "preview"
if file_key not in product.extensions:
request.response.status_int = 403
return {"error": "No preview available"}
else:
file_key = "product"
if file_key not in product.extensions:
request.response.status_int = 404
return {"error": "No media file available"}
# 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"}
# Generate presigned URL for media
bucket_name = request.app["bucket.secure_uploads"]
s3_key = f"{product.s3_path}/{file_key}"
params = {
"Bucket": bucket_name,
"Key": s3_key,
}
content_disposition = f'inline; filename="{product.title}.{extension}"'
content_type = product.get_content_type(file_key)
if content_disposition:
params["ResponseContentDisposition"] = content_disposition
if content_type:
params["ResponseContentType"] = content_type
media_url = request.secure_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
ExpiresIn=900,
)
# Thumbnail URL
thumbnail_url = None
if "thumbnail1" in product.extensions:
thumbnail_url = (
f"{request.app['bucket.secure_uploads.get_endpoint']}"
f"/{product.s3_path}/thumbnail1"
f"?ts={product.updated_timestamp}"
)
# Canonical URL (relative path)
p_or_c = "p" if product.is_sellable else "c"
canonical_url = f"/{p_or_c}/{product.id}/{product.slug}"
# Related products
related = get_related_products(product)
related_data = []
for r in related:
r_ext = r.extensions.get("product", "")
r_media = get_media_type(r_ext)
r_thumb = None
if "thumbnail1" in r.extensions:
r_thumb = (
f"{request.app['bucket.secure_uploads.get_endpoint']}"
f"/{r.s3_path}/thumbnail1"
f"?ts={r.updated_timestamp}"
)
r_p_or_c = "p" if r.is_sellable else "c"
related_data.append({
"id": str(r.id),
"title": r.title,
"thumbnail_url": r_thumb,
"url": f"/{r_p_or_c}/{r.id}/{r.slug}",
"media_type": r_media,
})
return {
"product_id": str(product.id),
"title": product.title,
"media_url": media_url,
"media_type": media_type,
"thumbnail_url": thumbnail_url,
"description_html": product.description_html,
"canonical_url": canonical_url,
"is_sellable": product.is_sellable,
"related": related_data,
}