/* ===================================================================== DAS LAUFENDE VIDEO ALS BROWSER-QUELLE (28.09.2026) Dieselbe Nachführung wie in der Reaction-Seite: Der Host schickt seinen Spielstand, diese Quelle folgt ihm. Übertragen wird nur Kennung, läuft/pausiert und Sekunde — das Video selbst lädt jede Seite bei YouTube, und das muss auch so sein (die Begründung steht in reaktion-tabellen.js). --------------------------------------------------------------------- DREI DINGE, DIE EINE QUELLE BRAUCHT UND EINE SEITE NICHT STUMM. Der Ton kommt bei OBS aus dem Mischpult, nicht aus dem Browser — sonst läge er doppelt im Stream, einmal davon verzögert. Und ohne `mute` würde der Player gar nicht erst anlaufen; das verbietet jeder Browser. KEINE BEDIENUNG. Was hier zu sehen ist, geht in den Stream. Ein Fortschrittsbalken oder ein YouTube-Logo, das beim Überfahren erscheint, wäre für alle sichtbar. EINE WACHE. Eine Quelle läuft im Hintergrund; bleibt sie stehen, fällt es erst auf, wenn das Bild nicht mehr passt — mitten in der Sendung. Kommt 70 Sekunden nichts, wird neu verbunden. ===================================================================== */ (() => { 'use strict'; const schluessel = new URLSearchParams(location.search).get('s') || ''; function sagFehler(text) { let k = document.querySelector('.obs-fehler'); if (!k) { k = document.createElement('div'); k.className = 'obs-fehler'; document.body.append(k); } k.textContent = text; } if (!schluessel) { sagFehler('Diese Adresse ist unvollständig — der Schlüssel fehlt. ' + 'Hol sie dir im Regiepult unter „Bühne".'); return; } let spieler = null; let bereit = false; let letztesVideo = ''; let stand = null; let letztes = Date.now(); function api() { if (window.YT && window.YT.Player) { bauen(); return; } if (document.getElementById('yt-api')) return; const s = document.createElement('script'); s.id = 'yt-api'; s.src = 'https://www.youtube.com/iframe_api'; window.onYouTubeIframeAPIReady = () => bauen(); document.head.append(s); } function bauen() { if (spieler || !stand || !stand.video) return; const halter = document.createElement('div'); halter.id = 'yt'; document.getElementById('platz').append(halter); letztesVideo = stand.video; spieler = new window.YT.Player('yt', { videoId: stand.video, host: 'https://www.youtube-nocookie.com', playerVars: { rel: 0, modestbranding: 1, playsinline: 1, /* STUMM. Der Ton kommt aus dem Mischpult -- und ohne `mute` würde der Player ohnehin nicht anlaufen. */ mute: 1, /* KEINE BEDIENUNG. Was hier zu sehen ist, geht in den Stream. */ controls: 0, disablekb: 1, fs: 0, iv_load_policy: 3, }, events: { onReady: () => { bereit = true; folgen(stand); }, onStateChange: () => { /* nichts zu melden -- diese Quelle sendet nicht */ }, }, }); } /* DIE GESCHWINDIGKEIT MUSS MIT -- sonst liefe der Stream anders als der Saal. Stellt die Regie auf 1,5, wäre die Quelle nach fünf Minuten zweieinhalb Minuten hinterher: Filipe kommentiert eine Stelle, die die Zuschauer noch gar nicht gesehen haben. Und das Nachspringen könnte es nicht auffangen, es würde nur alle paar Sekunden sichtbar ruckeln. */ function tempoNachziehen(wert) { if (!bereit || !wert) return; try { const kann = spieler.getAvailablePlaybackRates?.() || []; if (kann.length && !kann.includes(wert)) return; if (Math.abs((spieler.getPlaybackRate() || 1) - wert) > 0.01) { spieler.setPlaybackRate(wert); } } catch { /* ein Spieler, der noch lädt, kann das noch nicht */ } } /* ==== DIE ANORDNUNG ==== Nur „nur Kamera" schaltet das Bild ab. „Kameras groß" lässt das Video klein stehen — wie klein und wo, bestimmt Filipe in OBS selbst, das ist nicht Sache dieser Quelle. */ function bildStellen(s) { document.body.dataset.bild = (s && s.layout === 'nur_kamera') ? 'aus' : 'an'; } /** Ein neuer Stand — an EINER Stelle. * * Vorher stand dasselbe Dreierlei zweimal da: merken, Spieler * bauen oder nachziehen. Als die Anordnung dazukam, wäre sie an * einer der beiden Stellen vergessen worden. Das ist keine * Vermutung: Am selben Tag haben im selben Haus zwei Regeln für * dieselbe Frage der Regieleiste null Pixel gegeben. */ /* ==== DIE WERBEEINBLENDUNG ================================ Sie ist der Grund, warum es sie gibt: Ein Band, das nur auf der Seite laeuft, erreicht die Leute nicht, die ueber TikTok oder YouTube zusehen -- und das sind die meisten. Eingehaengt wird sie EINMAL, an den Koerper. Ein Band, das bei jedem Stand neu entstuende, finge mitten im Satz von vorn an -- und der Stand kommt jede Sekunde. */ let werbung = null; function werbungStellen(s) { if (!window.Werbung) return; if (!werbung) werbung = window.Werbung.einhaengen(document.body); werbung.setzen(s && s.werbung); } function standSetzen(neu) { stand = neu; letztes = Date.now(); bildStellen(stand); werbungStellen(stand); if (!spieler) api(); else folgen(stand); } function folgen(s) { if (!bereit || !s) return; if (s.video && s.video !== letztesVideo) { letztesVideo = s.video; spieler.loadVideoById(s.video, s.sekunde || 0); spieler.mute(); /* YouTube setzt die Geschwindigkeit beim Laden auf 1 zurück. */ if (s.tempo) setTimeout(() => tempoNachziehen(s.tempo), 900); return; } if (!s.video) return; if (s.tempo) tempoNachziehen(s.tempo); /* Die Laufzeit der Meldung wird dazugerechnet -- sonst wäre die Quelle genau so weit hinten, wie das Netz langsam ist. */ const unterwegs = s.gesendet ? Math.max(0, (Date.now() - s.gesendet) / 1000) : 0; const soll = (s.sekunde || 0) + (s.laeuft ? unterwegs : 0); try { const ist = spieler.getCurrentTime(); /* ERST AB ANDERTHALB SEKUNDEN SPRINGEN. Darunter merkt es niemand, darüber merkt es jeder -- und ein Player, dem man jede Sekunde eine neue Stelle gibt, ruckelt sichtbar. */ if (Math.abs(ist - soll) > 1.5) spieler.seekTo(soll, true); if (s.laeuft && spieler.getPlayerState() !== 1) spieler.playVideo(); if (!s.laeuft && spieler.getPlayerState() === 1) spieler.pauseVideo(); } catch { /* der nächste Takt richtet es */ } } async function lageHolen() { const a = await fetch(`/workspace/api/buehne/lage?s=${encodeURIComponent(schluessel)}`); if (a.status === 404) { sagFehler('Der Schlüssel stimmt nicht mehr. ' + 'Hol dir die Adresse neu im Regiepult unter „Bühne".'); return false; } if (!a.ok) return false; standSetzen(await a.json()); return true; } let strom = null; function verbinden() { try { if (strom) strom.close(); } catch { /* war schon zu */ } strom = new EventSource( `/workspace/api/buehne/strom?s=${encodeURIComponent(schluessel)}`); strom.addEventListener('open', () => { letztes = Date.now(); const k = document.querySelector('.obs-fehler'); if (k) k.remove(); }); strom.addEventListener('message', () => { letztes = Date.now(); }); strom.addEventListener('video', (e) => { letztes = Date.now(); try { standSetzen({ ...(stand || {}), ...JSON.parse(e.data) }); } catch { /* eine kaputte Meldung darf die Quelle nicht reissen */ } }); /* Beim Beenden oder bei einem Videowechsel kommt die ganze Lage. Sie wird frisch geholt statt aus der Meldung gelesen: Die Meldung an die angemeldeten Seiten enthält Dinge, die hier nichts zu suchen haben. */ strom.addEventListener('reaktion', () => { letztes = Date.now(); lageHolen(); }); strom.addEventListener('error', () => { if (strom.readyState === EventSource.CLOSED) setTimeout(verbinden, 3000); }); } lageHolen().then((ok) => { if (ok) verbinden(); }); setInterval(() => { if (Date.now() - letztes > 70_000) { letztes = Date.now(); verbinden(); lageHolen(); } }, 10_000); document.addEventListener('visibilitychange', () => { if (!document.hidden && Date.now() - letztes > 30_000) { verbinden(); lageHolen(); } }); })();