Files
horror/frontend/src/components/StreamTab.vue
2026-09-08 23:39:04 +02:00

385 lines
9.4 KiB
Vue

<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import Hls from 'hls.js'
// Alle Zuschauer halten dieselbe Distanz zur Live-Kante. Damit laufen sie
// synchron, ohne dass ein Server sie taktet. Ausgeglichen wird nur über
// Sprünge, nicht über die Geschwindigkeit — siehe maxLiveSyncPlaybackRate.
const ZIEL_LATENZ = 4
const video = ref(null)
const zustand = ref('lade')
const fehler = ref('')
// Stumm starten, sonst verweigert der Browser die automatische Wiedergabe.
// Den Ton schaltet der Zuschauer selbst ein — das gilt als Geste.
const stumm = ref(true)
const lautstaerke = ref(1)
const vollbild = ref(false)
let hls = null
let neustart = null
let wache = null
// Mehrere Fehler kurz hintereinander sollen nicht mehrere Timer anlegen
function spaeterNeuLaden() {
if (neustart) return
neustart = window.setTimeout(() => {
neustart = null
hls?.startLoad()
}, 3000)
}
// hls.js meldet nicht jeden Hänger als Fehler. Manchmal steht die Playlist,
// aber es kommt kein Segment — dann bleibt der Player einfach stehen, ohne
// dass irgendetwas davon erfährt. Wer die Seite offen hat, bevor gesendet
// wird, sitzt sonst vor einem toten Bild.
function wacheStarten() {
let letzte = -1
let stillstand = 0
wache = window.setInterval(() => {
const v = video.value
if (!v || v.paused || zustand.value === 'bereit') return
if (v.currentTime === letzte) stillstand++
else stillstand = 0
letzte = v.currentTime
if (stillstand >= 5) {
stillstand = 0
hls?.startLoad()
}
}, 1000)
}
// Was der Server sendet, läuft. Wer anhält, fällt aus dem Gleichlauf und
// müsste den Rückstand hinterher mit erhöhtem Tempo aufholen. Also lassen
// wir das Anhalten gar nicht erst zu.
function weiterlaufen() {
const v = video.value
if (!v || zustand.value !== 'laeuft') return
v.play().catch(() => {
// Nur wenn der Browser sich quer stellt, fragen wir den Zuschauer
zustand.value = 'bereit'
})
}
function tonAn() {
stumm.value = false
video.value.muted = false
video.value.volume = lautstaerke.value
weiterlaufen()
}
function lautstaerkeSetzen(wert) {
lautstaerke.value = wert
video.value.volume = wert
if (wert > 0 && stumm.value) tonAn()
}
function vollbildUmschalten() {
const halter = video.value?.parentElement
if (document.fullscreenElement) document.exitFullscreen()
else halter?.requestFullscreen?.()
}
// Die Leertaste hält sonst das Video an, sobald es den Fokus hat
function tasteAbfangen(e) {
if (e.code === 'Space' && e.target === video.value) e.preventDefault()
}
function verbinden() {
const quelle = '/stream/filmabend/index.m3u8'
if (!Hls.isSupported()) {
// Safari spielt HLS selbst ab, dann übernimmt der Browser die Regelung
if (video.value.canPlayType('application/vnd.apple.mpegurl')) {
video.value.src = quelle
zustand.value = 'laeuft'
return
}
fehler.value = 'Dieser Browser kann den Stream nicht abspielen.'
zustand.value = 'fehler'
return
}
hls = new Hls({
lowLatencyMode: true,
liveSyncDuration: ZIEL_LATENZ,
// Ab hier springt hls.js selbst auf die Zielposition zurück, statt
// ewig mit 1,5-fachem Tempo aufzuholen. Zu dicht an ZIEL_LATENZ
// führt laut Doku zu Stockern.
liveMaxLatencyDuration: ZIEL_LATENZ + 3,
// Kein Aufholen über die Geschwindigkeit. Mit 1,1 wechselte der Player
// gemessene 112-mal in 104 Sekunden das Tempo und lief 37 % der Zeit
// schneller als normal — jeder Wechsel ist ein Übergang im Zeitdehner
// und knackst hörbar. Der Puffer lief dabei nie leer (nie unter 2 s),
// die Regelung war also gar nicht nötig. Bleibt der Rückstand doch
// einmal groß, springt liveMaxLatencyDuration.
maxLiveSyncPlaybackRate: 1,
backBufferLength: 30,
})
hls.on(Hls.Events.ERROR, (_, daten) => {
if (!daten.fatal) return
if (daten.type === Hls.ErrorTypes.NETWORK_ERROR) {
zustand.value = 'wartet'
// Nicht sofort neu anfragen — sonst hämmert der Browser den Server
spaeterNeuLaden()
} else if (daten.type === Hls.ErrorTypes.MEDIA_ERROR) {
hls.recoverMediaError()
} else {
fehler.value = 'Stream nicht erreichbar.'
zustand.value = 'fehler'
}
})
// Kommen wieder Daten, ist die Wartemeldung überholt
hls.on(Hls.Events.FRAG_BUFFERED, () => {
if (zustand.value === 'wartet') zustand.value = 'laeuft'
})
hls.on(Hls.Events.MANIFEST_PARSED, () => {
zustand.value = 'laeuft'
video.value.play().catch(() => {
// Autoplay ohne Ton ist erlaubt, mit Ton nicht — dann wartet es
// auf einen Klick
zustand.value = 'bereit'
})
})
hls.loadSource(quelle)
hls.attachMedia(video.value)
// Für Diagnose im Betrieb: Latenzwerte in der Konsole nachsehen
window.__stream = hls
}
function starten() {
zustand.value = 'laeuft'
video.value?.play()
}
function vollbildGewechselt() {
vollbild.value = !!document.fullscreenElement
}
onMounted(() => {
verbinden()
wacheStarten()
document.addEventListener('fullscreenchange', vollbildGewechselt)
window.addEventListener('keydown', tasteAbfangen)
})
onUnmounted(() => {
document.removeEventListener('fullscreenchange', vollbildGewechselt)
window.removeEventListener('keydown', tasteAbfangen)
window.clearInterval(wache)
window.clearTimeout(neustart)
hls?.destroy()
hls = null
})
</script>
<template>
<div class="stream-tab">
<div class="buehne">
<!-- Ohne Stream zeigt der Browser nur seinen eigenen Ladekringel und
verrät nicht, warum nichts kommt -->
<div v-show="zustand === 'laeuft' || zustand === 'bereit'" class="rahmen">
<video
ref="video"
class="player"
playsinline
muted
@pause="weiterlaufen"
@click.prevent
></video>
<div class="leiste">
<button
type="button"
class="knopf"
:title="stumm ? 'Ton einschalten' : 'Stumm schalten'"
@click="stumm ? tonAn() : (stumm = true, video.muted = true)"
>
{{ stumm ? '🔇' : '🔊' }}
</button>
<input
type="range"
class="regler"
min="0"
max="1"
step="0.02"
:value="stumm ? 0 : lautstaerke"
@input="lautstaerkeSetzen(Number($event.target.value))"
/>
<span class="live">live</span>
<button
type="button"
class="knopf"
:title="vollbild ? 'Vollbild beenden' : 'Vollbild'"
@click="vollbildUmschalten"
>
{{ vollbild ? '⤡' : '⤢' }}
</button>
</div>
</div>
<div v-if="zustand === 'wartet' || zustand === 'lade'" class="platzhalter">
<p class="gross">
{{ zustand === 'lade' ? 'Verbinde…' : 'Es wird gerade nichts gesendet.' }}
</p>
</div>
<button v-if="zustand === 'bereit'" class="start" type="button" @click="starten">
Wiedergabe starten
</button>
<button v-else-if="stumm && zustand === 'laeuft'" class="start" type="button" @click="tonAn">
Ton einschalten
</button>
<p v-if="fehler" class="note error">{{ fehler }}</p>
</div>
</div>
</template>
<style scoped>
.stream-tab {
min-width: 0;
}
.buehne {
position: relative;
min-width: 0;
}
.rahmen {
position: relative;
/* Sonst ragt das Bild unter den Fensterrand und die Leiste mit ihm */
max-height: calc(100vh - 5.5rem);
aspect-ratio: 16 / 9;
margin: 0 auto;
background: #000;
border-radius: 12px;
}
/* Im Vollbild füllt der Rahmen den Schirm, das Video bleibt im Format */
.rahmen:fullscreen {
max-height: none;
width: 100vw;
height: 100vh;
border-radius: 0;
}
.rahmen:fullscreen .player {
border: 0;
border-radius: 0;
}
.leiste {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: flex;
gap: 0.6rem;
align-items: center;
padding: 0.5rem 0.8rem;
opacity: 0;
transition: opacity 0.15s;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.75));
border-radius: 0 0 10px 10px;
}
.rahmen:hover .leiste,
.leiste:focus-within {
opacity: 1;
}
.knopf {
padding: 0.1rem 0.3rem;
font-size: 1.05rem;
line-height: 1;
}
.regler {
width: 7rem;
accent-color: var(--accent);
}
.live {
margin-left: auto;
color: #fff;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.live::before {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
margin-right: 0.4rem;
background: var(--accent);
border-radius: 50%;
content: '';
}
.player {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
border: 1px solid var(--line);
border-radius: 12px;
}
.platzhalter {
display: flex;
flex-direction: column;
gap: 0.5rem;
justify-content: center;
align-items: center;
aspect-ratio: 16 / 9;
padding: 2rem;
text-align: center;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
}
.gross {
color: var(--text);
font-size: 1.05rem;
}
.start {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 0.8rem 1.8rem;
color: #fff;
font-size: 1rem;
font-weight: 600;
background: var(--accent);
border-radius: 8px;
}
.note {
padding: 1rem 0;
color: var(--muted);
text-align: center;
}
.note.error {
color: var(--accent);
}
</style>