diff --git a/api/video/enrichment.py b/api/video/enrichment.py index e3e50bde..59f6b8bd 100644 --- a/api/video/enrichment.py +++ b/api/video/enrichment.py @@ -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 "") diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 1d8504b6..043a5135 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -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 diff --git a/webui/index.html b/webui/index.html index 1d22df30..76b458e2 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5191,6 +5191,22 @@ +
+
+ +

Detail Pages

+ +
+
+ +
+
Plays a muted trailer behind the hero after a moment (uses more bandwidth).
+
+
+
diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index 8fd95bed..516dc182 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -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 = '' + + '
' + + '
'; + 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 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()) { diff --git a/webui/static/video/video-settings.js b/webui/static/video/video-settings.js index 5ecc6c45..9883ec20 100644 --- a/webui/static/video/video-settings.js +++ b/webui/static/video/video-settings.js @@ -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++) { diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index eb938392..fa9ba6ad 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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; }