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 %}
-
-
-
-
-
- + {% include 'snippets/comments.j2' %}