feat: add karaoke toggle to watch mode ring controls
Moves vocal isolation track switching from the pop-out player into the ring header controls so users can cycle Original/Instrumentals/Vocals while watching. Preserves playback position on track switch, resets to Original on SPA navigation, and adds K keyboard shortcut.
This commit is contained in:
parent
68e716ca74
commit
5c9513daa2
6 changed files with 167 additions and 4 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
+ '<input type="checkbox" id="watch-autoplay-toggle"' + autoplayChecked + ' />'
|
||||
+ '<span class="autoplay-slider"></span>'
|
||||
+ '</label>'
|
||||
+ '<button id="karaoke-toggle-btn" class="ring-karaoke-btn mps-button js-only" style="display:' + (karaokeUrls.instrumentals || karaokeUrls.vocals ? '' : 'none') + '" title="Cycle: Original / Instrumentals / Vocals">' + KARAOKE_LABELS[karaokeMode] + '</button>'
|
||||
+ '<button id="skip-random-btn" class="skip-next-btn mps-button js-only" title="Jump to random item">☰</button>'
|
||||
+ '<button id="skip-next-btn" class="skip-next-btn mps-button js-only" title="Skip to next unwatched">Next ▶</button>'
|
||||
+ '</div>'
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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" %}
|
||||
<div id="watch-media-container">
|
||||
<div id="watch-media-container"{% if instrumentals_url %} data-instrumentals-url="{{ instrumentals_url }}"{% endif %}{% if vocals_url %} data-vocals-url="{{ vocals_url }}"{% endif %}>
|
||||
<div class="watch-video-container">
|
||||
<video id="watch-video" src="{{ watch_video_url }}" autoplay controls playsinline class="product-main"></video>
|
||||
<button class="unmute-overlay">Tap to unmute</button>
|
||||
|
|
@ -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" %}
|
||||
<div id="watch-media-container">
|
||||
<div id="watch-media-container"{% if instrumentals_url %} data-instrumentals-url="{{ instrumentals_url }}"{% endif %}{% if vocals_url %} data-vocals-url="{{ vocals_url }}"{% endif %}>
|
||||
<div class="watch-audio-container">
|
||||
{% if "thumbnail1" in product.extensions %}
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
<input type="checkbox" id="watch-autoplay-toggle" checked />
|
||||
<span class="autoplay-slider"></span>
|
||||
</label>
|
||||
<button id="karaoke-toggle-btn" class="ring-karaoke-btn mps-button js-only" style="display:none" title="Cycle: Original / Instrumentals / Vocals">Original</button>
|
||||
<button id="skip-random-btn" class="skip-next-btn mps-button js-only" title="Jump to random item">☰</button>
|
||||
<button id="skip-next-btn" class="skip-next-btn mps-button js-only" title="Skip to next unwatched">Next ▶</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue