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:
russell@unturf.com 2026-02-21 11:12:02 -05:00
parent 68e716ca74
commit 5c9513daa2
6 changed files with 167 additions and 4 deletions

View file

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

View file

@ -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">&#9776;</button>'
+ '<button id="skip-next-btn" class="skip-next-btn mps-button js-only" title="Skip to next unwatched">Next &#9654;</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) {

View file

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

View file

@ -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">&#9776;</button>
<button id="skip-next-btn" class="skip-next-btn mps-button js-only" title="Skip to next unwatched">Next &#9654;</button>
</div>

View file

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

View file

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