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 <noreply@anthropic.com>
This commit is contained in:
russell@unturf.com 2026-01-21 10:06:06 -05:00
parent 82327c1d0b
commit d3047a7c0c
10 changed files with 810 additions and 12 deletions

View file

@ -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}

View file

@ -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"]:

View file

@ -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:

View file

@ -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")

View file

@ -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();
}
})();

View file

@ -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 %}
<a target="_blank" href="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/product?ts={{ product.updated_timestamp }}" class="video-thumbnail-container">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<div class="video-play-overlay"></div>
</a>
<div class="video-click-to-play">click &#9654; to play</div>
{% if request.popout_player_enabled and product.visibility == 1 %}
<a href="javascript:void(0)" onclick="openMediaPlayer('{{ product.id }}')" class="video-thumbnail-container">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<div class="video-play-overlay"></div>
</a>
<div class="video-click-to-play">click &#9654; to play in pop-out player</div>
{% else %}
<a target="_blank" href="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/product?ts={{ product.updated_timestamp }}" class="video-thumbnail-container">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<div class="video-play-overlay"></div>
</a>
<div class="video-click-to-play">click &#9654; to play</div>
{% endif %}
{% else %}
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
{% endif %}
@ -80,12 +88,29 @@
<div class="product-comments">
<br/>
<br/>
<!-- Comments Section -->
{% include 'snippets/comments.j2' %}
</div>
</section>
<script>
function openMediaPlayer(productId) {
const features = 'width=800,height=600,menubar=no,toolbar=no,location=no,status=no,scrollbars=no';
const playerWindow = window.open('/player/' + productId, 'mediaPlayer', features);
// Apply always-on-top if enabled in localStorage
if (localStorage.getItem('player_always_on_top') === 'true' && playerWindow) {
const focusInterval = setInterval(() => {
if (playerWindow.closed) {
clearInterval(focusInterval);
} else {
playerWindow.focus();
}
}, 500);
}
}
</script>
{%- endblock -%}

View file

@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ product.title }} - Media Player</title>
<link rel="stylesheet" href="/static/css/common.css">
<style>
/* Reset body styles for fullscreen player */
body {
margin: 0;
padding: 0;
background: #000;
overflow: hidden;
font-family: sans-serif;
}
/* Player container - CSS Grid layout */
.player-container {
display: grid;
grid-template-areas: "media";
width: 100vw;
height: 100vh;
position: relative;
}
/* Media element styles */
#media {
grid-area: media;
width: 100%;
height: 100%;
object-fit: contain;
}
/* Player controls overlay */
.player-controls {
grid-area: media;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: end;
padding: 1rem;
background: linear-gradient(transparent, rgba(0,0,0,0.7));
z-index: 10;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
}
.player-container:hover .player-controls {
opacity: 1;
}
.player-controls button {
pointer-events: auto;
cursor: pointer;
}
/* Navigation buttons */
.nav-btn {
background: rgba(255,255,255,0.2);
border: none;
color: white;
font-size: 2rem;
padding: 1rem;
border-radius: 4px;
transition: background 0.2s;
}
.nav-btn:hover {
background: rgba(255,255,255,0.4);
}
.nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Center controls */
.center-controls {
display: grid;
grid-template-columns: auto auto auto;
gap: 1rem;
justify-content: center;
}
.center-controls button {
background: rgba(255,255,255,0.2);
border: none;
color: white;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background 0.2s;
white-space: nowrap;
}
.center-controls button:hover {
background: rgba(255,255,255,0.4);
}
.center-controls button.active {
background: rgba(255,255,255,0.6);
}
/* Product title overlay */
.product-title {
position: absolute;
top: 1rem;
left: 1rem;
color: white;
background: rgba(0,0,0,0.5);
padding: 0.5rem 1rem;
border-radius: 4px;
z-index: 5;
font-size: 1.2rem;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
}
.player-container:hover .product-title {
opacity: 1;
}
/* For images, add some styling to make navigation visible */
img#media {
cursor: default;
}
/* Audio player should be centered */
audio#media {
margin: auto;
}
</style>
</head>
<body>
<div class="player-container" id="playerContainer">
{% if media_type == 'video' %}
<video id="media" src="{{ presigned_url }}" autoplay controls>
Your browser does not support the video tag.
</video>
{% elif media_type == 'audio' %}
<audio id="media" src="{{ presigned_url }}" autoplay controls>
Your browser does not support the audio tag.
</audio>
{% elif media_type == 'image' %}
<img id="media" src="{{ presigned_url }}" alt="{{ product.title }}" />
{% endif %}
<div class="product-title">{{ product.title }}</div>
<div class="player-controls">
{% if prev_product_id %}
<button class="nav-btn" id="prevBtn" onclick="navigate('{{ prev_product_id }}')" title="Previous (Left Arrow)">◀</button>
{% else %}
<button class="nav-btn" disabled>◀</button>
{% endif %}
<div class="center-controls">
<button id="fullscreenBtn" onclick="toggleFullscreen()" title="Fullscreen (F)">⛶ Fullscreen</button>
<button id="alwaysOnTopBtn" onclick="toggleAlwaysOnTop()" title="Always on Top (T)">📌 Always on Top</button>
<button id="autoResizeBtn" onclick="toggleAutoResize()" title="Auto-Resize Window (R)">↔ Auto-Resize</button>
</div>
{% if next_product_id %}
<button class="nav-btn" id="nextBtn" onclick="navigate('{{ next_product_id }}')" title="Next (Right Arrow)">▶</button>
{% else %}
<button class="nav-btn" disabled>▶</button>
{% endif %}
</div>
</div>
<script src="/static/js/player.js"></script>
<script>
// Pass navigation data to player.js
window.playerData = {
prevProductId: {{ ('"%s"' % prev_product_id) if prev_product_id else 'null' }},
nextProductId: {{ ('"%s"' % next_product_id) if next_product_id else 'null' }},
mediaType: "{{ media_type }}"
};
</script>
</body>
</html>

View file

@ -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 %}
<a href="{{ signed_get_object_url }}" target="_blank" class="video-thumbnail-container">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<div class="video-play-overlay"></div>
</a>
<div class="video-click-to-play">click &#9654; to play</div>
{% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %}
<a href="javascript:void(0)" onclick="openMediaPlayer('{{ product.id }}')" class="video-thumbnail-container">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<div class="video-play-overlay"></div>
</a>
<div class="video-click-to-play">click &#9654; to play in pop-out player</div>
{% else %}
<a href="{{ signed_get_object_url }}" target="_blank" class="video-thumbnail-container">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<div class="video-play-overlay"></div>
</a>
<div class="video-click-to-play">click &#9654; to play</div>
{% endif %}
{% else %}
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
{% endif %}
@ -104,7 +112,11 @@
{% endif %}
{% if "preview" in product.extensions %}
{% if request.popout_player_enabled and product.visibility == 1 %}
<a href="javascript:void(0)" onclick="openMediaPlayer('{{ product.id }}')" class="product-preview-button mps-button">▶ Play Preview</a>
{% else %}
<a href="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/preview?ts={{ product.updated_timestamp }}" class="product-preview-button mps-button" download>View Preview</a>
{% endif %}
<br/>
{% endif %}
@ -151,7 +163,7 @@
<div class="product-comments">
<br/>
<br/>
<!-- Comments Section -->
{% include 'snippets/comments.j2' %}
@ -161,5 +173,22 @@
</section>
<script>
function openMediaPlayer(productId) {
const features = 'width=800,height=600,menubar=no,toolbar=no,location=no,status=no,scrollbars=no';
const playerWindow = window.open('/player/' + productId, 'mediaPlayer', features);
// Apply always-on-top if enabled in localStorage
if (localStorage.getItem('player_always_on_top') === 'true' && playerWindow) {
const focusInterval = setInterval(() => {
if (playerWindow.closed) {
clearInterval(focusInterval);
} else {
playerWindow.focus();
}
}, 500);
}
}
</script>
{%- endblock -%}

View file

@ -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()

View file

@ -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,
}