385 lines
9.4 KiB
Vue
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>
|