video: autoplay billboard trailer (opt-in setting)

After a couple seconds on a detail page, a muted trailer plays behind the hero
(Netflix/Disney+ style) with mute/unmute + stop controls; the backdrop fades back
when stopped. Stops on navigate-away/modal-open (no orphaned audio).

Gated by a 'Autoplay trailers in the billboard' toggle in video Settings →
Detail Pages (default on). Backed by billboard_autoplay in video_settings, read
via a lightweight /api/video/prefs. Tests updated for the new config field.
This commit is contained in:
BoulderBadgeDad 2026-06-15 11:57:29 -07:00
parent be54fccc63
commit 2f3e4b128b
6 changed files with 122 additions and 3 deletions

View file

@ -46,8 +46,16 @@ def register_routes(bp):
"tmdb_api_key": db.get_setting("tmdb_api_key") or "",
"tvdb_api_key": db.get_setting("tvdb_api_key") or "",
"omdb_api_key": db.get_setting("omdb_api_key") or "",
"billboard_autoplay": (db.get_setting("billboard_autoplay") or "1") == "1",
})
@bp.route("/prefs", methods=["GET"])
def video_prefs():
# Lightweight UI prefs for the detail page (no API keys).
from . import get_video_db
db = get_video_db()
return jsonify({"billboard_autoplay": (db.get_setting("billboard_autoplay") or "1") == "1"})
@bp.route("/enrichment/config", methods=["POST"])
def video_enrichment_config_save():
from . import get_video_db
@ -57,6 +65,8 @@ def register_routes(bp):
db.set_setting("tmdb_api_key", body.get("tmdb_api_key") or "")
if "tvdb_api_key" in body:
db.set_setting("tvdb_api_key", body.get("tvdb_api_key") or "")
if "billboard_autoplay" in body:
db.set_setting("billboard_autoplay", "1" if body.get("billboard_autoplay") else "0")
if "omdb_api_key" in body:
new_key = body.get("omdb_api_key") or ""
changed = new_key != (db.get_setting("omdb_api_key") or "")

View file

@ -267,12 +267,14 @@ def test_enrichment_config_save_load(tmp_path, monkeypatch):
client = app.test_client()
try:
assert client.get("/api/video/enrichment/config").get_json() == {
"tmdb_api_key": "", "tvdb_api_key": "", "omdb_api_key": ""}
"tmdb_api_key": "", "tvdb_api_key": "", "omdb_api_key": "", "billboard_autoplay": True}
client.post("/api/video/enrichment/config",
json={"tmdb_api_key": "abc", "tvdb_api_key": "xyz", "omdb_api_key": "om"})
json={"tmdb_api_key": "abc", "tvdb_api_key": "xyz", "omdb_api_key": "om",
"billboard_autoplay": False})
assert client.get("/api/video/enrichment/config").get_json() == {
"tmdb_api_key": "abc", "tvdb_api_key": "xyz", "omdb_api_key": "om"}
"tmdb_api_key": "abc", "tvdb_api_key": "xyz", "omdb_api_key": "om", "billboard_autoplay": False}
assert db.get_setting("tmdb_api_key") == "abc" and db.get_setting("omdb_api_key") == "om"
assert client.get("/api/video/prefs").get_json() == {"billboard_autoplay": False}
finally:
videoapi._video_db = None

View file

@ -5191,6 +5191,22 @@
<button class="test-button" type="button" data-video-test-service="omdb">Test OMDb</button>
</div>
</div>
<div class="api-service-frame stg-service" data-video-service="prefs">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #38bdf8;"></span>
<h4 class="service-title">Detail Pages</h4>
<span class="stg-service-chevron"></span>
</div>
<div class="stg-service-body">
<label class="vid-pref-row">
<input type="checkbox" id="video-billboard-autoplay" data-video-pref="billboard_autoplay">
<span>Autoplay trailers in the billboard</span>
</label>
<div class="callback-info">
<div class="callback-help">Plays a muted trailer behind the hero after a moment (uses more bandwidth).</div>
</div>
</div>
</div>
</div>
<!-- Server Connections -->

View file

@ -375,6 +375,7 @@
var n = q(s); if (n) n.hidden = true;
});
galleryImages = [];
stopBillboardTrailer();
}
function loadExtras(kind, id) {
fetch(DETAIL_URL + kind + '/' + id + '/extras', { headers: { 'Accept': 'application/json' } })
@ -471,6 +472,7 @@
renderVideos(ex.videos);
renderGallery(ex.gallery);
renderReview(ex.review);
maybeAutoplayBillboard();
}
function renderReview(review) {
@ -641,6 +643,7 @@
// ── trailer modal (YouTube embed) ─────────────────────────────────────────
function openTrailer(key) {
if (!key) return;
stopBillboardTrailer(); // don't double up audio with the billboard
var ov = document.getElementById('vd-trailer-overlay');
if (!ov) {
ov = document.createElement('div');
@ -662,6 +665,56 @@
if (ov) { ov.classList.remove('vd-trailer-overlay--open'); ov.innerHTML = ''; }
}
// ── billboard autoplay trailer (opt-in setting) ───────────────────────────
var prefs = null, bbTrailerTimer = null, bbMuted = true;
function loadPrefs(cb) {
if (prefs) { cb(prefs); return; }
fetch('/api/video/prefs', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { prefs = d || {}; cb(prefs); })
.catch(function () { prefs = {}; cb(prefs); });
}
function maybeAutoplayBillboard() {
stopBillboardTrailer();
if (!data || !data.trailer || !data.trailer.key) return;
var key = data.trailer.key, id = currentId, kind = currentKind;
loadPrefs(function (p) {
if (!p || !p.billboard_autoplay || currentId !== id || currentKind !== kind) return;
bbTrailerTimer = setTimeout(function () {
if (currentId === id && currentKind === kind) startBillboardTrailer(key);
}, 2600);
});
}
function startBillboardTrailer(key) {
var bb = q('.vd-billboard'); if (!bb || bb.querySelector('[data-vd-bb-trailer]')) return;
bbMuted = true;
var wrap = document.createElement('div');
wrap.className = 'vd-bb-trailer'; wrap.setAttribute('data-vd-bb-trailer', '');
wrap.innerHTML = '<iframe allow="autoplay; encrypted-media" frameborder="0" ' +
'src="https://www.youtube.com/embed/' + encodeURIComponent(key) +
'?autoplay=1&mute=1&controls=0&modestbranding=1&rel=0&playsinline=1&enablejsapi=1"></iframe>' +
'<div class="vd-bb-tctrls"><button class="vd-bb-tbtn" type="button" data-vd-bb-mute aria-label="Unmute">🔇</button>' +
'<button class="vd-bb-tbtn" type="button" data-vd-bb-stop aria-label="Stop">✕</button></div>';
bb.appendChild(wrap);
bb.classList.add('vd-billboard--trailer');
}
function stopBillboardTrailer() {
clearTimeout(bbTrailerTimer); bbTrailerTimer = null;
var ws = document.querySelectorAll('[data-vd-bb-trailer]');
for (var i = 0; i < ws.length; i++) ws[i].remove();
var bbs = document.querySelectorAll('.vd-billboard--trailer');
for (var j = 0; j < bbs.length; j++) bbs[j].classList.remove('vd-billboard--trailer');
}
function toggleBillboardMute(btn) {
bbMuted = !bbMuted;
var iframe = document.querySelector('[data-vd-bb-trailer] iframe');
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage(JSON.stringify(
{ event: 'command', func: bbMuted ? 'mute' : 'unMute', args: [] }), '*');
}
btn.textContent = bbMuted ? '🔇' : '🔊';
}
// ── season selector (4 views) ─────────────────────────────────────────────
function renderViewToggle() {
var host = q('[data-vd-view-toggle]');
@ -990,6 +1043,10 @@
}
function onClick(e) {
var muteBtn = e.target.closest('[data-vd-bb-mute]');
if (muteBtn) { toggleBillboardMute(muteBtn); return; }
var stopBtn = e.target.closest('[data-vd-bb-stop]');
if (stopBtn) { stopBillboardTrailer(); return; }
var r = root(); if (!r) return;
// In-app drill-ins (real <a> links → modified clicks open new tabs).
var sim = e.target.closest('[data-vd-sim]');
@ -1051,6 +1108,10 @@
function init() {
document.addEventListener('soulsync:video-open-detail', onOpen);
document.addEventListener('click', onClick);
// Kill the billboard trailer (audio!) when navigating to a non-detail page.
document.addEventListener('soulsync:video-page-shown', function (e) {
if (e && e.detail !== 'video-movie-detail' && e.detail !== 'video-show-detail') stopBillboardTrailer();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeTrailer(); closeLightbox(); closeCastModal(); }
else if (lightboxOpen()) {

View file

@ -75,10 +75,20 @@
if (t && d.tmdb_api_key != null) t.value = d.tmdb_api_key;
if (v && d.tvdb_api_key != null) v.value = d.tvdb_api_key;
if (o && d.omdb_api_key != null) o.value = d.omdb_api_key;
var ap = document.getElementById('video-billboard-autoplay');
if (ap && d.billboard_autoplay != null) ap.checked = !!d.billboard_autoplay;
})
.catch(function () { /* ignore */ });
}
function saveAutoplay() {
var ap = document.getElementById('video-billboard-autoplay');
fetch(CONFIG_URL, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ billboard_autoplay: ap ? ap.checked : true })
}).catch(function () { /* ignore */ });
}
function saveKeys() {
var t = document.getElementById('tmdb-api-key');
var v = document.getElementById('tvdb-api-key');
@ -129,6 +139,8 @@
var el = document.getElementById(id);
if (el) el.addEventListener('change', saveKeys);
});
var autoplay = document.getElementById('video-billboard-autoplay');
if (autoplay) autoplay.addEventListener('change', saveAutoplay);
// Per-connection Test buttons (same behaviour as music's testConnection).
var testBtns = document.querySelectorAll('[data-video-test-service]');
for (var k = 0; k < testBtns.length; k++) {

View file

@ -1395,3 +1395,21 @@ a.vd-prov:hover img, a.vd-prov:hover .vd-prov-ph { border-color: rgba(var(--vd-a
background: none; border: 0; color: rgb(var(--vd-accent-rgb)); font-weight: 700; font-size: 13.5px;
cursor: pointer; padding: 8px 0 0;
}
/* ── billboard autoplay trailer ──────────────────────────────────────────── */
.vd-bb-trailer { position: absolute; inset: 0; z-index: 0; overflow: hidden; background: #000; animation: vd-fade 0.6s ease; }
.vd-bb-trailer iframe {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 100vw; height: 56.25vw; min-height: 100%; min-width: 177.78vh; border: 0; pointer-events: none;
}
.vd-billboard--trailer .vd-bb-bg { opacity: 0; transition: opacity 0.6s ease; }
.vd-bb-tctrls { position: absolute; bottom: 20px; right: 24px; z-index: 4; display: flex; gap: 8px; }
.vd-bb-tbtn {
width: 38px; height: 38px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.25); cursor: pointer;
background: rgba(0, 0, 0, 0.5); color: #fff; font-size: 15px; backdrop-filter: blur(8px); transition: all 0.2s ease;
}
.vd-bb-tbtn:hover { background: rgba(0, 0, 0, 0.78); transform: translateY(-1px); }
/* settings: detail-pages preference toggle */
.vid-pref-row { display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 14px; color: var(--text-primary, #fff); }
.vid-pref-row input { width: 16px; height: 16px; cursor: pointer; accent-color: #38bdf8; }