diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index dfc8163..e77564c 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -2740,7 +2740,8 @@ textarea { .ring-header-controls { display: grid; - grid-template-columns: auto auto auto auto; + grid-auto-flow: column; + grid-auto-columns: auto; gap: 16px; justify-content: end; align-items: center; @@ -2884,6 +2885,21 @@ textarea { color: white; border-color: var(--blue-color, #5871ad); } +.ring-karaoke-btn { + font-size: 0.75em; + padding: 2px 10px; + border: 1px solid var(--border-color, #ccc); + border-radius: 12px; + background: transparent; + color: var(--text-muted, #999); + cursor: pointer; + white-space: nowrap; +} +.ring-karaoke-btn.active { + background: var(--blue-color, #5871ad); + color: white; + border-color: var(--blue-color, #5871ad); +} .related-content-row-current { background-color: var(--blue-color, #5871ad); color: white; diff --git a/make_post_sell/static/js/watch.js b/make_post_sell/static/js/watch.js index e217c99..aff5da7 100644 --- a/make_post_sell/static/js/watch.js +++ b/make_post_sell/static/js/watch.js @@ -29,6 +29,11 @@ var PROGRESS_SAVE_INTERVAL = 7000; var lastProgressSave = 0; + // --- Karaoke state --- + var karaokeMode = 0; // 0=original, 1=instrumentals, 2=vocals + var KARAOKE_LABELS = ['Original', 'Instrumentals', 'Vocals']; + var karaokeUrls = {original: null, instrumentals: null, vocals: null}; + // --- Ring state (persisted in localStorage) --- var ringProductIds = []; var ringPosition = 0; @@ -217,6 +222,69 @@ } } + // --- Karaoke: read initial URLs from data attributes --- + function initKaraokeFromDom() { + var container = document.getElementById('watch-media-container'); + karaokeUrls.instrumentals = container ? container.getAttribute('data-instrumentals-url') : null; + karaokeUrls.vocals = container ? container.getAttribute('data-vocals-url') : null; + karaokeUrls.original = activeMedia ? activeMedia.src : null; + karaokeMode = 0; + updateKaraokeButton(); + } + + function updateKaraokeButton() { + var btn = document.getElementById('karaoke-toggle-btn'); + if (!btn) return; + var hasKaraoke = !!(karaokeUrls.instrumentals || karaokeUrls.vocals); + btn.style.display = hasKaraoke ? '' : 'none'; + btn.textContent = KARAOKE_LABELS[karaokeMode]; + if (karaokeMode === 0) { + btn.classList.remove('active'); + } else { + btn.classList.add('active'); + } + } + + function getNextKaraokeMode() { + // Build list of available modes + var modes = [0]; // original is always available + if (karaokeUrls.instrumentals) modes.push(1); + if (karaokeUrls.vocals) modes.push(2); + if (modes.length <= 1) return 0; + var idx = modes.indexOf(karaokeMode); + return modes[(idx + 1) % modes.length]; + } + + function cycleKaraoke() { + if (!activeMedia) return; + var hasKaraoke = !!(karaokeUrls.instrumentals || karaokeUrls.vocals); + if (!hasKaraoke) return; + + var nextMode = getNextKaraokeMode(); + if (nextMode === karaokeMode) return; + + var savedTime = activeMedia.currentTime; + var wasPlaying = !activeMedia.paused; + + var newSrc; + if (nextMode === 1) newSrc = karaokeUrls.instrumentals; + else if (nextMode === 2) newSrc = karaokeUrls.vocals; + else newSrc = karaokeUrls.original; + + karaokeMode = nextMode; + activeMedia.src = newSrc; + + activeMedia.addEventListener('loadedmetadata', function onKLoad() { + activeMedia.removeEventListener('loadedmetadata', onKLoad); + activeMedia.currentTime = savedTime; + if (wasPlaying) activeMedia.play().catch(function() {}); + }); + + updateKaraokeButton(); + } + + initKaraokeFromDom(); + // --- Autoplay with sound (for video/audio) --- if (activeMedia) { // Restore saved progress on reload (seek + volume fade-in) @@ -585,6 +653,13 @@ syncRingPosition(data.product_id); scheduleUrlRefresh(); + // Reset karaoke state for the new product + karaokeMode = 0; + karaokeUrls.original = data.media_url || null; + karaokeUrls.instrumentals = data.instrumentals_url || null; + karaokeUrls.vocals = data.vocals_url || null; + updateKaraokeButton(); + document.title = data.title; var h1 = document.querySelector('.product-images h1'); @@ -786,6 +861,7 @@ + '' + '' + '' + + '' + '' + '' + '' @@ -920,6 +996,7 @@ } updateProgressDisplay(); + updateKaraokeButton(); } function escapeHtml(text) { @@ -1428,12 +1505,22 @@ // Only refresh if still on the same product if (data.product_id !== currentProductId) { scheduleUrlRefresh(); return; } + // Refresh karaoke URLs from the fresh response + karaokeUrls.original = data.media_url; + if (data.instrumentals_url) karaokeUrls.instrumentals = data.instrumentals_url; + if (data.vocals_url) karaokeUrls.vocals = data.vocals_url; + var wasPlaying = !activeMedia.paused; var savedTime = activeMedia.currentTime; var savedVolume = activeMedia.volume; var savedMuted = activeMedia.muted; - activeMedia.src = data.media_url; + // Use the URL matching current karaoke mode + var freshSrc; + if (karaokeMode === 1 && karaokeUrls.instrumentals) freshSrc = karaokeUrls.instrumentals; + else if (karaokeMode === 2 && karaokeUrls.vocals) freshSrc = karaokeUrls.vocals; + else freshSrc = data.media_url; + activeMedia.src = freshSrc; activeMedia.addEventListener('loadeddata', function onLoaded() { activeMedia.removeEventListener('loadeddata', onLoaded); @@ -1593,6 +1680,12 @@ return; } + if (e.target.id === 'karaoke-toggle-btn' || e.target.closest('#karaoke-toggle-btn')) { + e.preventDefault(); + cycleKaraoke(); + return; + } + if (e.target.id === 'skip-random-btn' || e.target.closest('#skip-random-btn')) { e.preventDefault(); skipToRandom(); @@ -1640,6 +1733,14 @@ } }); + // --- Keyboard shortcut: K to toggle karaoke --- + document.addEventListener('keydown', function(e) { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return; + if (e.key === 'k' || e.key === 'K') { + cycleKaraoke(); + } + }); + // --- Browser back/forward --- window.addEventListener('popstate', function(e) { if (e.state && e.state.productId) { diff --git a/make_post_sell/templates/content.j2 b/make_post_sell/templates/content.j2 index cab5390..c993fbc 100644 --- a/make_post_sell/templates/content.j2 +++ b/make_post_sell/templates/content.j2 @@ -40,7 +40,7 @@ {% 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" %} -
+
@@ -61,7 +61,7 @@ {% elif request.shop.watch_mode_enabled and product.extensions.get("product") in audio_extensions %} {# Watch mode: audio with album art #} {% set watch_audio_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %} -
+
{% if "thumbnail1" in product.extensions %} diff --git a/make_post_sell/templates/snippets/related_content.j2 b/make_post_sell/templates/snippets/related_content.j2 index 44f861c..124df67 100644 --- a/make_post_sell/templates/snippets/related_content.j2 +++ b/make_post_sell/templates/snippets/related_content.j2 @@ -29,6 +29,7 @@ +
diff --git a/make_post_sell/views/content.py b/make_post_sell/views/content.py index 2413afb..56c0a03 100644 --- a/make_post_sell/views/content.py +++ b/make_post_sell/views/content.py @@ -1,6 +1,7 @@ from pyramid.view import view_config from . import get_referer_or_home +from ..models.product import get_media_type from pyramid.httpexceptions import HTTPFound @@ -81,6 +82,21 @@ def content(request): else: related_products = get_related_products(product) + # Generate CDN URLs for karaoke tracks if available + instrumentals_url = None + vocals_url = None + extension = product.extensions.get("product") + media_type = get_media_type(extension) if extension else None + if media_type in ("video", "audio"): + cdn_base = request.app["bucket.secure_uploads.get_endpoint"] + for track_name in ("instrumentals", "vocals"): + if track_name in product.extensions: + url = f"{cdn_base}/{product.s3_path}/{track_name}" + if track_name == "instrumentals": + instrumentals_url = url + else: + vocals_url = url + return { "product": product, "product_size": product_size, @@ -88,4 +104,6 @@ def content(request): "comments": comments, "shop": product.shop, "related_products": related_products, + "instrumentals_url": instrumentals_url, + "vocals_url": vocals_url, } diff --git a/make_post_sell/views/watch.py b/make_post_sell/views/watch.py index 0b1b2c9..e519108 100644 --- a/make_post_sell/views/watch.py +++ b/make_post_sell/views/watch.py @@ -65,6 +65,31 @@ def watch_json(request): ExpiresIn=900, ) + # Generate presigned URLs for karaoke tracks if available + instrumentals_url = None + vocals_url = None + if media_type in ("video", "audio"): + for track_name in ("instrumentals", "vocals"): + if track_name in product.extensions: + track_ext = product.extensions.get(track_name) + track_ct = product.get_content_type(track_name) or ( + "video/mp4" if media_type == "video" else "audio/wav" + ) + url = request.secure_uploads_client.generate_presigned_url( + ClientMethod="get_object", + Params={ + "Bucket": bucket_name, + "Key": f"{product.s3_path}/{track_name}", + "ResponseContentDisposition": f'inline; filename="{product.title} ({track_name.title()}).{track_ext}"', + "ResponseContentType": track_ct, + }, + ExpiresIn=900, + ) + if track_name == "instrumentals": + instrumentals_url = url + else: + vocals_url = url + # Thumbnail URL thumbnail_url = None if "thumbnail1" in product.extensions: @@ -184,6 +209,8 @@ def watch_json(request): "is_mod": is_mod, "comment_count": product.public_comment_count, "comments_enabled": request.shop.comments_enabled, + "instrumentals_url": instrumentals_url, + "vocals_url": vocals_url, } if price_history: