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:
parent
82327c1d0b
commit
d3047a7c0c
10 changed files with 810 additions and 12 deletions
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
305
make_post_sell/static/js/player.js
Normal file
305
make_post_sell/static/js/player.js
Normal 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();
|
||||
}
|
||||
|
||||
})();
|
||||
|
|
@ -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 ▶ 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 ▶ 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 ▶ 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 -%}
|
||||
|
|
|
|||
182
make_post_sell/templates/player.j2
Normal file
182
make_post_sell/templates/player.j2
Normal 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>
|
||||
|
|
@ -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 ▶ 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 ▶ 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 ▶ 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 -%}
|
||||
|
|
|
|||
103
make_post_sell/tests/test_player.py
Normal file
103
make_post_sell/tests/test_player.py
Normal 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()
|
||||
115
make_post_sell/views/player.py
Normal file
115
make_post_sell/views/player.py
Normal 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,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue