From d3047a7c0c3db98dc969dcb6e1bc382a4125e2a9 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Wed, 21 Jan 2026 10:06:06 -0500 Subject: [PATCH] Add pop-out media player with autoplay and navigation controls Enables an immersive viewing experience for video, audio, and images through a dedicated player window. The player automatically resizes to match media dimensions, supports keyboard navigation, and includes toggles for always-on-top and auto-resize behavior. Features include: - Pop-out window with autoplay for video/audio/images - Dynamic window sizing based on media aspect ratio (320px to HD) - Navigation controls for browsing shop's public media - Always-on-top toggle (persisted to localStorage) - Auto-resize toggle (persisted to localStorage) - Fullscreen support - Keyboard shortcuts (arrows, ESC, F, T, R) - Feature flag for easy enable/disable (default: enabled) - Products use preview file, content uses product file - Only public products (visibility == 1) Co-Authored-By: Claude Sonnet 4.5 --- development.ini | 3 + make_post_sell/models/product.py | 19 ++ make_post_sell/request_methods.py | 14 ++ make_post_sell/routes.py | 3 + make_post_sell/static/js/player.js | 305 ++++++++++++++++++++++++++++ make_post_sell/templates/content.j2 | 37 +++- make_post_sell/templates/player.j2 | 182 +++++++++++++++++ make_post_sell/templates/product.j2 | 41 +++- make_post_sell/tests/test_player.py | 103 ++++++++++ make_post_sell/views/player.py | 115 +++++++++++ 10 files changed, 810 insertions(+), 12 deletions(-) create mode 100644 make_post_sell/static/js/player.js create mode 100644 make_post_sell/templates/player.j2 create mode 100644 make_post_sell/tests/test_player.py create mode 100644 make_post_sell/views/player.py diff --git a/development.ini b/development.ini index f5c0cbf..bcc9917 100644 --- a/development.ini +++ b/development.ini @@ -75,6 +75,9 @@ app.payments.monero.enabled = ${MPS_PAYMENTS_MONERO_ENABLED:-False} app.payments.dogecoin.enabled = ${MPS_PAYMENTS_DOGECOIN_ENABLED:-False} app.payments.adyen.enabled = ${MPS_PAYMENTS_ADYEN_ENABLED:-True} +# Feature toggles +app.features.popout_player.enabled = ${MPS_FEATURES_POPOUT_PLAYER_ENABLED:-True} + # Adyen Configuration (test mode for development) app.adyen.test_mode = ${MPS_ADYEN_TEST_MODE:-True} diff --git a/make_post_sell/models/product.py b/make_post_sell/models/product.py index 19b2c58..ed00b19 100644 --- a/make_post_sell/models/product.py +++ b/make_post_sell/models/product.py @@ -58,6 +58,25 @@ DEFAULT_BUNDLE_METADATA = unicode( """ ) +# Media type detection for pop-out player +VIDEO_EXTENSIONS = ["mp4", "webm", "mkv", "avi", "mov", "wmv", "flv", "m4v", "ogv"] +AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "wma", "opus"] +IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"] + + +def get_media_type(extension): + """Returns 'video', 'audio', 'image', or None based on file extension.""" + if not extension: + return None + ext = extension.lower() + if ext in VIDEO_EXTENSIONS: + return "video" + elif ext in AUDIO_EXTENSIONS: + return "audio" + elif ext in IMAGE_EXTENSIONS: + return "image" + return None + def sizeof_fmt(num, suffix="B"): for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]: diff --git a/make_post_sell/request_methods.py b/make_post_sell/request_methods.py index 7a06047..162fed7 100644 --- a/make_post_sell/request_methods.py +++ b/make_post_sell/request_methods.py @@ -386,6 +386,20 @@ def includeme(config): ) config.add_request_method(add_dogecoin_synced, "dogecoin_synced", reify=True) + def add_popout_player_enabled(request): + """Check if pop-out media player feature is enabled globally.""" + val = request.app.get("features.popout_player.enabled") + if isinstance(val, str): + return val.strip().lower() in ("1", "true", "yes", "on") + elif isinstance(val, bool): + return val + return True # Default enabled + + # Feature toggles + config.add_request_method( + add_popout_player_enabled, "popout_player_enabled", reify=True + ) + def add_has_xmr_refund_address(request): """Check if the current user has an XMR refund address configured.""" if not request.user or not request.shop: diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index 3c010d0..9b9a4bb 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -161,6 +161,9 @@ def includeme(config): config.add_route("product_edit2", "/p/{product_id}/{slug:.*}/edit") config.add_route("product_slug", "/p/{product_id}/{slug:.*}") + # media player route + config.add_route("player", "/player/{product_id}") + # comment routes. config.add_route("comment_new", "/comments/new") config.add_route("comment_reply", "/comments/{comment_id}/reply") diff --git a/make_post_sell/static/js/player.js b/make_post_sell/static/js/player.js new file mode 100644 index 0000000..e95bfa5 --- /dev/null +++ b/make_post_sell/static/js/player.js @@ -0,0 +1,305 @@ +/** + * Pop-out Media Player + * Handles navigation, keyboard shortcuts, fullscreen, always-on-top, and dynamic window sizing + */ + +(function() { + 'use strict'; + + // State management + let focusInterval = null; + const loadTime = Date.now(); + const EXPIRATION_TIME = 900000; // 15 minutes in ms + const WARNING_TIME = 780000; // 13 minutes (warn 2 min before expiration) + + // Get media element + const media = document.getElementById('media'); + const alwaysOnTopBtn = document.getElementById('alwaysOnTopBtn'); + const fullscreenBtn = document.getElementById('fullscreenBtn'); + const autoResizeBtn = document.getElementById('autoResizeBtn'); + + /** + * Navigate to another product + */ + window.navigate = function(productId) { + if (productId) { + window.location.href = '/player/' + productId; + } + }; + + /** + * Toggle fullscreen mode + */ + window.toggleFullscreen = function() { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen().catch(err => { + console.error('Error attempting to enable fullscreen:', err); + }); + } else { + document.exitFullscreen(); + } + }; + + /** + * Toggle always-on-top behavior + */ + window.toggleAlwaysOnTop = function() { + const current = localStorage.getItem('player_always_on_top') === 'true'; + const newValue = !current; + localStorage.setItem('player_always_on_top', newValue.toString()); + + if (newValue) { + startAlwaysOnTop(); + } else { + stopAlwaysOnTop(); + } + + updateAlwaysOnTopButton(newValue); + }; + + /** + * Toggle auto-resize behavior + */ + window.toggleAutoResize = function() { + const current = localStorage.getItem('player_auto_resize') !== 'false'; // Default true + const newValue = !current; + localStorage.setItem('player_auto_resize', newValue.toString()); + updateAutoResizeButton(newValue); + + // If turning on, resize now + if (newValue) { + resizeWindowToMedia(); + } + }; + + /** + * Start always-on-top behavior (window.focus() on interval) + */ + function startAlwaysOnTop() { + if (focusInterval) { + clearInterval(focusInterval); + } + // Focus window every 500ms to keep it on top + focusInterval = setInterval(() => { + window.focus(); + }, 500); + } + + /** + * Stop always-on-top behavior + */ + function stopAlwaysOnTop() { + if (focusInterval) { + clearInterval(focusInterval); + focusInterval = null; + } + } + + /** + * Update always-on-top button appearance + */ + function updateAlwaysOnTopButton(isActive) { + if (alwaysOnTopBtn) { + if (isActive) { + alwaysOnTopBtn.classList.add('active'); + alwaysOnTopBtn.textContent = '📌 Always on Top (On)'; + } else { + alwaysOnTopBtn.classList.remove('active'); + alwaysOnTopBtn.textContent = '📌 Always on Top (Off)'; + } + } + } + + /** + * Update fullscreen button appearance + */ + function updateFullscreenButton() { + if (fullscreenBtn) { + if (document.fullscreenElement) { + fullscreenBtn.textContent = '⛶ Exit Fullscreen'; + } else { + fullscreenBtn.textContent = '⛶ Fullscreen'; + } + } + } + + /** + * Update auto-resize button appearance + */ + function updateAutoResizeButton(isActive) { + if (autoResizeBtn) { + if (isActive) { + autoResizeBtn.classList.add('active'); + autoResizeBtn.textContent = '↔ Auto-Resize (On)'; + } else { + autoResizeBtn.classList.remove('active'); + autoResizeBtn.textContent = '↔ Auto-Resize (Off)'; + } + } + } + + /** + * Resize window to match media aspect ratio + */ + function resizeWindowToMedia() { + // Check if auto-resize is enabled (default true) + const autoResize = localStorage.getItem('player_auto_resize') !== 'false'; + if (!autoResize) { + return; + } + + let width, height; + + if (media.videoWidth && media.videoHeight) { + // Video dimensions + width = media.videoWidth; + height = media.videoHeight; + } else if (media.naturalWidth && media.naturalHeight) { + // Image dimensions + width = media.naturalWidth; + height = media.naturalHeight; + } else { + // Unknown dimensions, skip resize + return; + } + + // Constrain to screen size (90% of available space) + const maxWidth = screen.width * 0.9; + const maxHeight = screen.height * 0.9; + + let finalWidth = width; + let finalHeight = height; + + // Scale down if too large + if (width > maxWidth) { + finalWidth = maxWidth; + finalHeight = (height * maxWidth) / width; + } + + if (finalHeight > maxHeight) { + finalHeight = maxHeight; + finalWidth = (width * maxHeight) / height; + } + + // Add extra space for controls overlay (~100px) + const controlsHeight = 100; + + // Resize window (some browsers may restrict this) + try { + window.resizeTo( + Math.round(finalWidth), + Math.round(finalHeight + controlsHeight) + ); + } catch (err) { + console.warn('Could not resize window:', err); + } + } + + /** + * Keyboard event handler + */ + function handleKeydown(e) { + switch(e.key) { + case 'Escape': + window.close(); + break; + case 'ArrowLeft': + e.preventDefault(); + if (window.playerData && window.playerData.prevProductId) { + navigate(window.playerData.prevProductId); + } + break; + case 'ArrowRight': + e.preventDefault(); + if (window.playerData && window.playerData.nextProductId) { + navigate(window.playerData.nextProductId); + } + break; + case 'f': + case 'F': + e.preventDefault(); + toggleFullscreen(); + break; + case 't': + case 'T': + e.preventDefault(); + toggleAlwaysOnTop(); + break; + case 'r': + case 'R': + e.preventDefault(); + toggleAutoResize(); + break; + } + } + + /** + * Handle URL expiration warning + */ + function setupExpirationWarning() { + // Show warning 2 minutes before expiration + setTimeout(() => { + if (confirm('Media URL will expire soon. Reload page to continue?')) { + window.location.reload(); + } + }, WARNING_TIME); + + // Handle media error events (URL might have expired) + if (media) { + media.addEventListener('error', () => { + const elapsed = Date.now() - loadTime; + if (elapsed > WARNING_TIME) { + alert('Media URL expired. Reloading...'); + window.location.reload(); + } + }); + } + } + + /** + * Initialize player on page load + */ + function init() { + // Check localStorage for always-on-top setting + const alwaysOnTop = localStorage.getItem('player_always_on_top') === 'true'; + if (alwaysOnTop) { + startAlwaysOnTop(); + } + updateAlwaysOnTopButton(alwaysOnTop); + + // Check localStorage for auto-resize setting (default true) + const autoResize = localStorage.getItem('player_auto_resize') !== 'false'; + updateAutoResizeButton(autoResize); + + // Set up keyboard event listener + document.addEventListener('keydown', handleKeydown); + + // Set up fullscreen change listener + document.addEventListener('fullscreenchange', updateFullscreenButton); + + // Set up expiration warning + setupExpirationWarning(); + + // Resize window when media loads (for video and images) + if (media) { + if (media.tagName === 'VIDEO') { + media.addEventListener('loadedmetadata', resizeWindowToMedia); + } else if (media.tagName === 'IMG') { + media.addEventListener('load', resizeWindowToMedia); + } + } + + // Cleanup on window close + window.addEventListener('beforeunload', () => { + stopAlwaysOnTop(); + }); + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + +})(); diff --git a/make_post_sell/templates/content.j2 b/make_post_sell/templates/content.j2 index bf0de7a..197a36b 100644 --- a/make_post_sell/templates/content.j2 +++ b/make_post_sell/templates/content.j2 @@ -36,11 +36,19 @@ {% if "thumbnail1" in product.extensions %} {% set video_extensions = ["mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", "m4v"] %} {% if product.extensions.get("product") in video_extensions %} - - -
-
-
click ▶ to play
+ {% if request.popout_player_enabled and product.visibility == 1 %} + + +
+
+
click ▶ to play in pop-out player
+ {% else %} + + +
+
+
click ▶ to play
+ {% endif %} {% else %} {% endif %} @@ -80,12 +88,29 @@


- + {% include 'snippets/comments.j2' %}
+ {%- endblock -%} diff --git a/make_post_sell/templates/player.j2 b/make_post_sell/templates/player.j2 new file mode 100644 index 0000000..4d7d0ee --- /dev/null +++ b/make_post_sell/templates/player.j2 @@ -0,0 +1,182 @@ + + + + + + {{ product.title }} - Media Player + + + + +
+ {% if media_type == 'video' %} + + {% elif media_type == 'audio' %} + + {% elif media_type == 'image' %} + {{ product.title }} + {% endif %} + +
{{ product.title }}
+ +
+ {% if prev_product_id %} + + {% else %} + + {% endif %} + +
+ + + +
+ + {% if next_product_id %} + + {% else %} + + {% endif %} +
+
+ + + + + diff --git a/make_post_sell/templates/product.j2 b/make_post_sell/templates/product.j2 index 95bbdbb..64eb6ff 100644 --- a/make_post_sell/templates/product.j2 +++ b/make_post_sell/templates/product.j2 @@ -36,11 +36,19 @@ {% 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 %} - - -
-
-
click ▶ to play
+ {% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %} + + +
+
+
click ▶ to play in pop-out player
+ {% else %} + + +
+
+
click ▶ to play
+ {% endif %} {% else %} {% endif %} @@ -104,7 +112,11 @@ {% endif %} {% if "preview" in product.extensions %} + {% if request.popout_player_enabled and product.visibility == 1 %} + â–¶ Play Preview + {% else %} View Preview + {% endif %}
{% endif %} @@ -151,7 +163,7 @@


- + {% include 'snippets/comments.j2' %} @@ -161,5 +173,22 @@ + {%- endblock -%} diff --git a/make_post_sell/tests/test_player.py b/make_post_sell/tests/test_player.py new file mode 100644 index 0000000..1c98422 --- /dev/null +++ b/make_post_sell/tests/test_player.py @@ -0,0 +1,103 @@ +import unittest +from pyramid import testing + +from ..models.product import get_media_type, VIDEO_EXTENSIONS, AUDIO_EXTENSIONS, IMAGE_EXTENSIONS + + +class TestMediaTypeDetection(unittest.TestCase): + """Test media type detection functions.""" + + def test_video_extensions(self): + """Test that video extensions are correctly identified.""" + for ext in VIDEO_EXTENSIONS: + self.assertEqual(get_media_type(ext), "video") + # Test case insensitivity + self.assertEqual(get_media_type(ext.upper()), "video") + + def test_audio_extensions(self): + """Test that audio extensions are correctly identified.""" + for ext in AUDIO_EXTENSIONS: + self.assertEqual(get_media_type(ext), "audio") + # Test case insensitivity + self.assertEqual(get_media_type(ext.upper()), "audio") + + def test_image_extensions(self): + """Test that image extensions are correctly identified.""" + for ext in IMAGE_EXTENSIONS: + self.assertEqual(get_media_type(ext), "image") + # Test case insensitivity + self.assertEqual(get_media_type(ext.upper()), "image") + + def test_unsupported_extensions(self): + """Test that unsupported extensions return None.""" + unsupported = ["pdf", "zip", "doc", "txt", "exe"] + for ext in unsupported: + self.assertIsNone(get_media_type(ext)) + + def test_none_extension(self): + """Test that None extension returns None.""" + self.assertIsNone(get_media_type(None)) + + def test_empty_extension(self): + """Test that empty string extension returns None.""" + self.assertIsNone(get_media_type("")) + + +class TestPlayerView(unittest.TestCase): + """Test player view controller.""" + + def setUp(self): + self.config = testing.setUp() + from ..models.meta import Base + from sqlalchemy import create_engine + + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + + from sqlalchemy.orm import sessionmaker + + Session = sessionmaker(bind=engine) + self.session = Session() + + def tearDown(self): + self.session.close() + testing.tearDown() + + def test_player_requires_feature_flag(self): + """Test that player route requires feature flag to be enabled.""" + from ..views.player import player + from pyramid.httpexceptions import HTTPNotFound + + request = testing.DummyRequest() + request.popout_player_enabled = False + request.product = None + + result = player(request) + self.assertIsInstance(result, HTTPNotFound) + + def test_player_requires_product(self): + """Test that player route requires a valid product.""" + from ..views.player import player + from pyramid.httpexceptions import HTTPNotFound + + request = testing.DummyRequest() + request.popout_player_enabled = True + request.product = None + + result = player(request) + self.assertIsInstance(result, HTTPNotFound) + + def test_media_type_constants_are_lowercase(self): + """Test that all media type extension constants are lowercase.""" + for ext in VIDEO_EXTENSIONS: + self.assertEqual(ext, ext.lower(), f"VIDEO_EXTENSIONS contains non-lowercase: {ext}") + + for ext in AUDIO_EXTENSIONS: + self.assertEqual(ext, ext.lower(), f"AUDIO_EXTENSIONS contains non-lowercase: {ext}") + + for ext in IMAGE_EXTENSIONS: + self.assertEqual(ext, ext.lower(), f"IMAGE_EXTENSIONS contains non-lowercase: {ext}") + + +if __name__ == "__main__": + unittest.main() diff --git a/make_post_sell/views/player.py b/make_post_sell/views/player.py new file mode 100644 index 0000000..73181b0 --- /dev/null +++ b/make_post_sell/views/player.py @@ -0,0 +1,115 @@ +from pyramid.view import view_config +from pyramid.httpexceptions import HTTPFound, HTTPNotFound, HTTPForbidden, HTTPBadRequest + +from . import get_referer_or_home + +from ..models.product import get_media_type, get_products_from_a_shop + + +@view_config(route_name="player", renderer="player.j2") +def player(request): + """Pop-out media player for video, audio, and images.""" + + # Check if feature is enabled + if not request.popout_player_enabled: + return HTTPNotFound() + + product = request.product + + if not product: + return HTTPNotFound() + + # Only allow public products (visibility == 1) + if product.visibility != 1: + return HTTPForbidden("This content is not publicly available") + + # Determine which file to use + if product.is_sellable: + # For products (sellable), use preview file + file_key = "preview" + if file_key not in product.extensions: + return HTTPForbidden("No preview available for this product") + else: + # For content (not sellable), use product file + file_key = "product" + if file_key not in product.extensions: + return HTTPNotFound("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: + return HTTPBadRequest("Not a supported media file") + + # Generate presigned URL + bucket_name = request.app["bucket.secure_uploads"] + s3_key = f"{product.s3_path}/{file_key}" + + params = { + "Bucket": bucket_name, + "Key": s3_key, + } + + # Set content disposition for inline display + 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 + + presigned_url = request.secure_uploads_client.generate_presigned_url( + ClientMethod="get_object", + Params=params, + ExpiresIn=900, # 15 minutes + ) + + # Query shop products for navigation + # Get all public products from the same shop + shop_products = get_products_from_a_shop(product.shop, visibility=1) + + # Filter to only media items with valid file for this file_key + media_products = [] + for p in shop_products: + # Check if product has the appropriate file + if product.is_sellable: + # For sellable products, check for preview file + if "preview" in p.extensions: + ext = p.extensions.get("preview") + if get_media_type(ext): + media_products.append(p) + else: + # For content, check for product file + if "product" in p.extensions: + ext = p.extensions.get("product") + if get_media_type(ext): + media_products.append(p) + + # Find current product index + current_index = None + for i, p in enumerate(media_products): + if p.id == product.id: + current_index = i + break + + # Get next/prev product IDs + prev_product_id = None + next_product_id = None + + if current_index is not None: + if current_index > 0: + prev_product_id = str(media_products[current_index - 1].id) + if current_index < len(media_products) - 1: + next_product_id = str(media_products[current_index + 1].id) + + return { + "product": product, + "presigned_url": presigned_url, + "media_type": media_type, + "extension": extension, + "prev_product_id": prev_product_id, + "next_product_id": next_product_id, + }