Add YouTube-style watch mode with sticky video, autoplay, and related content

Shop owners can enable watch mode in settings to get: direct video autoplay
with muted fallback, sticky video player while scrolling, and a stemming-powered
"Up Next" related content sidebar. Degrades gracefully per capability.
This commit is contained in:
russell@unturf.com 2026-02-07 14:57:37 -05:00
parent 68089dff5b
commit bc4858c58a
15 changed files with 526 additions and 4 deletions

37
docs/tickets/mps-1.md Normal file
View file

@ -0,0 +1,37 @@
# MPS-1: YouTube-Style Watch Experience
## Summary
Add a YouTube-like watch experience to product/content pages: sticky video player, autoplay with sound, stemming-powered "Up Next" related content sidebar, and a shop settings toggle to opt in.
## Features
- **Sticky Video**: `position: sticky; top: 0` on `.product-images` keeps video visible while scrolling
- **Autoplay**: Direct `<video autoplay controls>` render (no thumbnail gate) with play() promise fallback to muted + unmute overlay
- **Related Content**: Custom suffix-stripping stemmer scores title+description overlap to find related shop content (max 8 items)
- **Shop Setting**: Radio button toggle in shop settings, off by default
## Degradation
| Capability | Experience |
|-----------|-----------|
| Full JS + autoplay | Video autoplays with sound, sticky, AJAX comments, related sidebar |
| JS + autoplay blocked | Muted autoplay + unmute button |
| No JS | `<noscript>` link, form POST comments, server-rendered sidebar |
| No CSS sticky | Video scrolls normally |
| Watch mode off | Existing click-to-play thumbnail behavior |
## Files Changed
- `models/shop.py` - `watch_mode_enabled` column
- `models/product.py` - `stem_word()`, `tokenize_and_stem()`, `get_related_products()`
- `views/product.py`, `views/content.py` - pass `related_products` to template
- `views/shop.py` - handle `watch_mode` setting
- `templates/shop_settings.j2` - watch mode radio button
- `templates/product.j2`, `templates/content.j2` - sticky container, direct video render
- `templates/snippets/related_content.j2` - "Up Next" sidebar snippet
- `static/js/watch.js` - autoplay promise handler
- `static/css/common.css` - sticky video, unmute overlay, related content styles
- `scripts/alembic/versions/` - migration for `watch_mode_enabled`
- `tests/test_models.py` - stemmer + related products tests
- `tests/test_functional.py` - watch mode settings tests

View file

@ -1,3 +1,4 @@
import re
import uuid
import json
@ -673,3 +674,49 @@ def get_products_by_keywords(dbsession, keywords, shop=None):
# return a list of objects in sorted order by score.
return [hits[_id] for _id in score_sorted_ids]
_STEM_SUFFIXES = [
'tion', 'ment', 'ness', 'able', 'ible', 'less',
'ful', 'ous', 'ive', 'ing', 'ed', 'ly', 'er', 'est', 'es', 's',
]
def stem_word(word):
"""Strip common English suffixes for fuzzy matching."""
word = word.lower()
for suffix in _STEM_SUFFIXES:
if word.endswith(suffix) and len(word) - len(suffix) >= 3:
return word[:-len(suffix)]
return word
def tokenize_and_stem(text):
"""Split text into a set of stemmed words."""
words = re.findall(r'[a-zA-Z]{3,}', text.lower())
return {stem_word(w) for w in words}
def get_related_products(product, limit=8):
"""Find related products in same shop by stemmed title+description overlap."""
current_stems = tokenize_and_stem(
(product.title or '') + ' ' + (product.description or '')
)
if not current_stems:
return []
candidates = []
for other in product.shop.products:
if other.id == product.id:
continue
if other.visibility != 1:
continue
other_stems = tokenize_and_stem(
(other.title or '') + ' ' + (other.description or '')
)
score = len(current_stems & other_stems)
if score > 0:
candidates.append((score, other))
candidates.sort(key=lambda x: x[0], reverse=True)
return [p for _, p in candidates[:limit]]

View file

@ -127,6 +127,9 @@ class Shop(RBase, Base):
# Show created/updated dates on product and content pages
show_dates = Column(Boolean, default=True)
# Watch mode: sticky video, autoplay, related content sidebar
watch_mode_enabled = Column(Boolean, default=False)
# many to many uses association_proxy.
users = association_proxy("shop_users", "user", creator=lambda u: UserShop(user=u))

View file

@ -0,0 +1,29 @@
"""add watch_mode_enabled to shop
Revision ID: abe5eb8acb97
Revises: ecb51cffd213
Create Date: 2026-02-07 13:22:12.663420
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'abe5eb8acb97'
down_revision = 'ecb51cffd213'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
op.add_column(
"mps_shop",
sa.Column("watch_mode_enabled", sa.Boolean(), nullable=False, server_default="0"),
)
def downgrade():
op.drop_column("mps_shop", "watch_mode_enabled")

View file

@ -2526,3 +2526,65 @@ textarea {
.theme-toggle-icon {
margin-right: 5px;
}
/* Sticky video — watch mode only */
.watch-mode .product-images {
position: sticky;
top: 0;
z-index: 10;
background: inherit;
}
/* Watch video container for unmute overlay positioning */
.watch-video-container {
position: relative;
}
.watch-video-container video.product-main {
max-width: 100%;
width: 100%;
height: auto;
}
/* Unmute overlay — shown by JS when autoplay-with-sound is blocked */
.unmute-overlay {
position: absolute;
top: 10px;
right: 10px;
z-index: 20;
background: rgba(0, 0, 0, 0.7);
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
display: none;
}
/* Related content sidebar */
.related-content {
margin-top: 20px;
}
.related-content h3 {
margin-bottom: 10px;
}
.related-content-item {
display: grid;
grid-template-columns: 120px 1fr;
gap: 10px;
margin-bottom: 10px;
text-decoration: none;
color: inherit;
}
.related-content-item img {
width: 120px;
height: auto;
border-radius: 4px;
}
.related-content-item span {
align-self: center;
}

View file

@ -0,0 +1,29 @@
(function() {
var video = document.getElementById('watch-video');
if (!video) return;
// Try autoplay with sound (works when user navigated here via click)
var playPromise = video.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
});
});
}
function showUnmuteOverlay(video) {
var btn = document.querySelector('.unmute-overlay');
if (!btn) return;
btn.style.display = 'block';
btn.addEventListener('click', function() {
video.muted = false;
btn.style.display = 'none';
});
}
})();

View file

@ -30,12 +30,22 @@
{% block content -%}
<section class="two-column">
<section class="two-column{% if request.shop.watch_mode_enabled %} watch-mode{% endif %}">
<div class="product-images">
{% if "thumbnail1" in product.extensions %}
{% set video_extensions = ["mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", "m4v"] %}
{% if product.extensions.get("product") in video_extensions %}
{% 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>
<noscript>
<a href="{{ watch_video_url }}" target="_blank">Open video</a>
</noscript>
{% elif 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 }}')">
@ -66,6 +76,7 @@
{% endif %}
</div>
{% endif %}
{% include 'snippets/related_content.j2' %}
</section>
<div class="product-description">
@ -118,5 +129,8 @@ function playInline(container, videoUrl) {
container.appendChild(video);
}
</script>
{% if request.shop.watch_mode_enabled %}
<script src="/static/js/watch.js"></script>
{% endif %}
{%- endblock -%}

View file

@ -28,14 +28,28 @@
{% block content -%}
<section class="two-column">
<section class="two-column{% if request.shop.watch_mode_enabled %} watch-mode{% 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"] %}
{% if signed_get_object_url and product.extensions.get("product") in video_extensions %}
{% 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 %}
{% set watch_video_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview" %}
{% 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>
<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 %}
{% 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 }}')">
@ -128,6 +142,8 @@
{% endif %}
<div>
{% include 'snippets/related_content.j2' %}
</section>
<div class="product-description">
@ -196,5 +212,8 @@ function playInline(container, videoUrl) {
container.appendChild(video);
}
</script>
{% if request.shop.watch_mode_enabled %}
<script src="/static/js/watch.js"></script>
{% endif %}
{%- endblock -%}

View file

@ -740,6 +740,21 @@
<br />
<br />
<label>Watch Mode</label>
<br />
<input type="radio" name="watch_mode" id="watch_mode_on" value="1"
{% if request.shop.watch_mode_enabled %}checked{% endif %} />
<label for="watch_mode_on" class="inline-label">Enable watch mode (sticky video, autoplay, related content sidebar)</label>
<br />
<input type="radio" name="watch_mode" id="watch_mode_off" value="0"
{% if not request.shop.watch_mode_enabled %}checked{% endif %} />
<label for="watch_mode_off" class="inline-label">Disable watch mode</label>
<br />
<small class="note-text">When enabled, video content pages autoplay, the video stays visible while scrolling, and a related content sidebar appears.</small>
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
<br />

View file

@ -0,0 +1,13 @@
{% if related_products %}
<div class="related-content">
<h3>Up Next</h3>
{% for related in related_products %}
<a href="{{ related.absolute_url(request) }}" class="related-content-item">
{% 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>
{% endfor %}
</div>
{% endif %}

View file

@ -2364,3 +2364,53 @@ class AuthenticatedFunctionalTests(FunctionalTests):
data = res.json
self.assertFalse(data["approved"])
def test_watch_mode_setting_default_off(self):
"""Test that new shops have watch mode disabled by default."""
shop = self._create_shop_helper(
user_creds=self.user1_creds, shop_params=self.shop1_params
)
# New shops should have watch mode disabled
self.dbsession.refresh(shop)
self.assertFalse(shop.watch_mode_enabled)
def test_watch_mode_setting_toggle(self):
"""Test enabling and disabling watch mode via settings form."""
shop = self._create_shop_helper(
user_creds=self.user1_creds, shop_params=self.shop1_params
)
# Enable watch mode (watch_mode is in the ribbon-settings form section)
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "ribbon-settings",
"watch_mode": "1",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
self.assertIn("Watch mode is now enabled", res.text)
# Verify it was saved
self.dbsession.expire(shop)
self.assertTrue(shop.watch_mode_enabled)
# Disable watch mode
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "ribbon-settings",
"watch_mode": "0",
"submit": "Save Settings",
},
status=302,
)
res = res.follow()
self.assertIn("Watch mode is now disabled", res.text)
# Verify it was saved
self.dbsession.expire(shop)
self.assertFalse(shop.watch_mode_enabled)

View file

@ -1852,3 +1852,181 @@ class TestAdyen(unittest.TestCase):
invoice.shop = self.shop
invoice.adyen_psp_reference = "882619391893263J"
self.assertEqual(invoice.adyen_psp_reference, "882619391893263J")
class TestStemmer(unittest.TestCase):
"""Test the stemmer and related products functions."""
def test_stem_word_basic(self):
"""Test common suffixes stripped correctly."""
from ..models.product import stem_word
self.assertEqual(stem_word("running"), "runn")
self.assertEqual(stem_word("played"), "play")
self.assertEqual(stem_word("quickly"), "quick")
self.assertEqual(stem_word("cats"), "cat")
self.assertEqual(stem_word("boxes"), "box")
self.assertEqual(stem_word("creation"), "crea")
self.assertEqual(stem_word("movement"), "move")
self.assertEqual(stem_word("darkness"), "dark")
self.assertEqual(stem_word("readable"), "read")
self.assertEqual(stem_word("hopeful"), "hope")
self.assertEqual(stem_word("careless"), "care")
self.assertEqual(stem_word("dangerous"), "danger")
self.assertEqual(stem_word("creative"), "creat")
self.assertEqual(stem_word("bigger"), "bigg")
self.assertEqual(stem_word("biggest"), "bigg")
def test_stem_word_short_preserved(self):
"""Test words too short to stem are unchanged."""
from ..models.product import stem_word
# Words where remaining stem would be < 3 chars should not be stripped
self.assertEqual(stem_word("it"), "it")
self.assertEqual(stem_word("is"), "is")
self.assertEqual(stem_word("an"), "an")
self.assertEqual(stem_word("the"), "the")
self.assertEqual(stem_word("bed"), "bed")
def test_stem_word_case_insensitive(self):
"""Test stemming is case insensitive."""
from ..models.product import stem_word
self.assertEqual(stem_word("Running"), stem_word("running"))
self.assertEqual(stem_word("PLAYED"), stem_word("played"))
def test_tokenize_and_stem(self):
"""Test sentence -> set of stems."""
from ..models.product import tokenize_and_stem
result = tokenize_and_stem("The cats are running quickly")
# "the" -> "the" (3 chars, no suffix stripped)
# "cats" -> "cat", "are" -> "are", "running" -> "runn", "quickly" -> "quick"
self.assertIn("cat", result)
self.assertIn("runn", result)
self.assertIn("quick", result)
self.assertIn("are", result)
self.assertIn("the", result)
# Short words (<3 chars) are excluded by the regex
self.assertNotIn("is", result)
self.assertNotIn("a", result)
def test_tokenize_and_stem_empty(self):
"""Test empty string returns empty set."""
from ..models.product import tokenize_and_stem
self.assertEqual(tokenize_and_stem(""), set())
self.assertEqual(tokenize_and_stem("12 34"), set())
def test_get_related_products(self):
"""Test returns ranked matches, excludes self and non-public."""
from ..models.product import Product, get_related_products
shop = Shop(
"test-shop",
"555-555-5555",
"123 Test St",
"Test shop description",
)
# Create products with related titles
product1 = Product("Blues Guitar Lessons", "Learn acoustic guitar blues riffs")
product1.shop = shop
product1.visibility = 1
# shares: guitar, learn = 2 stems
product2 = Product("Jazz Guitar Tutorial", "Learn jazz techniques")
product2.shop = shop
product2.visibility = 1
# shares: learn = 1 stem (no guitar/blues overlap)
product3 = Product("Piano for Beginners", "Learn piano basics")
product3.shop = shop
product3.visibility = 1
# shares: guitar, blues, learn, riff = 4 stems (most overlap)
product4 = Product("Guitar Blues Collection", "Learn blues guitar riffs and licks")
product4.shop = shop
product4.visibility = 1
product5_private = Product("Secret Guitar Video", "Private guitar content")
product5_private.shop = shop
product5_private.visibility = 0 # private
# Mock shop.products to return our test products
all_products = [product1, product2, product3, product4, product5_private]
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 not include product1 itself
self.assertNotIn(product1, related)
# Should not include private product
self.assertNotIn(product5_private, related)
# Should include related products
related_titles = [p.title for p in related]
self.assertIn("Guitar Blues Collection", related_titles)
self.assertIn("Jazz Guitar Tutorial", related_titles)
self.assertIn("Piano for Beginners", related_titles)
# product4 shares most stems with product1
# so it should be ranked higher (earlier index) than product3
self.assertLess(related.index(product4), related.index(product3))
def test_get_related_products_empty_description(self):
"""Test with empty title/description."""
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
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
mock_products.return_value = []
related = get_related_products(product)
self.assertEqual(related, [])
def test_get_related_products_limit(self):
"""Test that limit parameter is respected."""
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")
product1.shop = shop
product1.visibility = 1
# Create many related products
other_products = []
for i in range(15):
p = Product(f"Guitar Video {i}", f"Guitar lesson {i}")
p.shop = shop
p.visibility = 1
other_products.append(p)
all_products = [product1] + other_products
with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
mock_products.return_value = all_products
# Default limit is 8
related = get_related_products(product1)
self.assertLessEqual(len(related), 8)
# Custom limit
related = get_related_products(product1, limit=3)
self.assertLessEqual(len(related), 3)

View file

@ -72,10 +72,16 @@ def content(request):
request.dbsession, product.id, shop=product.shop, user=request.user
)
related_products = []
if product.shop.watch_mode_enabled:
from ..models.product import get_related_products
related_products = get_related_products(product)
return {
"product": product,
"product_size": product_size,
"signed_get_object_url": signed_get_object_url,
"comments": comments,
"shop": product.shop,
"related_products": related_products,
}

View file

@ -91,12 +91,18 @@ def product(request):
request.dbsession, product.id, shop=product.shop, user=request.user
)
related_products = []
if product.shop.watch_mode_enabled:
from ..models.product import get_related_products
related_products = get_related_products(product)
return {
"product": product,
"product_size": product_size,
"signed_get_object_url": signed_get_object_url,
"comments": comments,
"shop": product.shop,
"related_products": related_products,
}

View file

@ -606,6 +606,20 @@ def shop_settings(request):
)
)
# Handle watch mode setting
watch_mode_value = request.params.get("watch_mode")
if watch_mode_value is not None:
watch_mode = int(watch_mode_value) == 1
if shop.watch_mode_enabled != watch_mode:
shop.watch_mode_enabled = watch_mode
status = "enabled" if watch_mode else "disabled"
request.session.flash(
(
f"Watch mode is now {status}",
"success",
)
)
# Handle stripe settings form
if form_section == "stripe-settings":
# Handle disable action