Qualität, Tonspur und Untertitel im Player

Sie standen bisher nur in den Einstellungen — also dort, wo man sie nicht
braucht. Man merkt beim Schauen, dass der Ton die falsche Sprache hat,
nicht vorher.

Jetzt ein Menü über der Bedienleiste, nicht als Blatt: man stellt hier
etwas ein, das man am Bild beurteilen will, und ein Blatt verdeckt genau
das. Die Auswahl kommt vom Server — welche Spuren es gibt, weiß man erst
nach dem Aushandeln, und seine Vorauswahl gilt beim ersten Lauf.

Anders als die App, die im Lauf umschaltet, muss der Browser neu
aushandeln: bei einem umgerechneten HLS-Strom bestimmt der Server beim
Aushandeln, was drin ist. Also wird neu geladen — und die Stelle
mitgenommen, sonst begänne jeder Sprachwechsel den Film von vorn.
Nachgemessen: bei 27:30 gewechselt, bei 27:30 weiter.

Dabei zwei Dinge mitgenommen, die sonst später weh getan hätten: der
Fehler-Horcher wird nur einmal angehängt (sonst meldete sich nach zwei
Wechseln ein einziger Fehler dreimal), und ein früherer HLS-Strom wird
abgeräumt, bevor der nächste anläuft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 17:47:31 +02:00
co-authored by Claude Opus 5
parent 66b963f2c5
commit 9da446b295
+156 -10
View File
@@ -362,6 +362,33 @@ img { display: block; }
.pl-error strong { color: #F08A8D; font-size: 16px; font-weight: 600; }
.pl-error span { color: var(--text-2); font-size: 14px; }
.pl-error .btn { align-self: flex-start; }
/* Das Menü für Qualität und Spuren — über der Leiste, nicht als Blatt.
Ein Blatt würde das Bild verdecken, und man stellt hier etwas ein, das man
am Bild beurteilen will. */
.pl-menu {
position: absolute; right: var(--s-section); bottom: 96px; z-index: 3;
min-width: 232px; max-height: 60vh; overflow-y: auto;
background: rgba(20,20,23,.92); border: 1px solid var(--separator);
border-radius: var(--r-sheet); padding: var(--s-snug);
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
box-shadow: 0 16px 48px rgba(0,0,0,.6);
}
.pl-group {
font-size: 11px; font-weight: 600; color: var(--text-3);
text-transform: uppercase; letter-spacing: .06em;
padding: var(--s-regular) var(--s-regular) var(--s-tight);
}
.pl-group:first-child { padding-top: var(--s-snug); }
.pl-item {
display: flex; align-items: center; justify-content: space-between; gap: var(--s-regular);
width: 100%; padding: 9px var(--s-regular); border-radius: var(--r-control);
background: none; color: var(--text); font-size: 14px; text-align: left;
}
.pl-item:hover { background: rgba(255,255,255,.08); }
.pl-item.on { color: var(--accent); }
.pl-item svg { width: 16px; height: 16px; flex: 0 0 auto; }
.pl-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pl-error code {
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px;
color: var(--text-3); background: rgba(0,0,0,.4); padding: var(--s-snug);
@@ -1576,10 +1603,12 @@ function openPlayer(item) {
<input type="range" id="pl-vol" min="0" max="1" step="0.05" value="1" aria-label="Lautstärke">
<span class="pl-spacer"></span>
<label class="pl-rate">Tempo <select class="pick" id="pl-rate"></select></label>
<button class="pl-icon" id="pl-more" aria-label="Qualität und Spuren">${icon('sliders')}</button>
<button class="pl-icon" id="pl-full" aria-label="Vollbild">${icon('expand')}</button>
</div>
</div>
</div>
<div class="pl-menu" id="pl-menu" hidden></div>
<div class="pl-busy" id="pl-busy"><i></i></div>`;
document.body.appendChild(layer);
document.body.style.overflow = 'hidden';
@@ -1711,6 +1740,7 @@ function openPlayer(item) {
removeEventListener('keydown', keys);
clearTimeout(hideTimer);
if (document.fullscreenElement) document.exitFullscreen?.();
v.__hls?.destroy(); v.__hls = null;
v.pause(); v.removeAttribute('src'); v.load();
layer.remove();
document.body.style.overflow = '';
@@ -1719,10 +1749,92 @@ function openPlayer(item) {
}
el2('pl-close').onclick = close;
// Womit gerade gespielt wird — und was die Auswahl hergibt.
const lauf = { quality: P.quality, audio: null, subtitle: null, streams: [], stelle: 0 };
/* Lädt mit geänderter Wahl neu, an derselben Stelle.
--------------------------------------------------------------------------
Im Browser lässt sich die Tonspur nicht im Lauf umschalten, wie es die App
über die Engine tut: bei einem umgerechneten HLS-Strom bestimmt der Server
beim Aushandeln, was drin ist. Also wird neu ausgehandelt — und die Stelle
mitgenommen, damit es nicht wie ein Neuanfang aussieht. */
const neuLaden = () => {
lauf.stelle = v.currentTime || 0;
layer.querySelector('.pl-error')?.remove();
layer.classList.add('loading');
el2('pl-busy').classList.add('on');
closeMenu();
startStream(item, v, fail, lauf);
};
const menu = el2('pl-menu');
const closeMenu = () => { menu.hidden = true; };
el2('pl-more').onclick = () => (menu.hidden ? openMenu() : closeMenu());
layer.addEventListener('click', (e) => {
if (!menu.hidden && !menu.contains(e.target) && e.target.closest('#pl-more') === null) closeMenu();
});
function openMenu() {
const spuren = (art) => lauf.streams.filter((x) => x.Type === art);
const eintrag = (text, gewaehlt, tun) => {
const b = document.createElement('button');
b.className = 'pl-item' + (gewaehlt ? ' on' : '');
b.innerHTML = `<span>${esc(text)}</span>${gewaehlt ? icon('check', 16) : ''}`;
b.onclick = tun;
return b;
};
menu.innerHTML = '';
const block = (titel) => {
const h = document.createElement('div');
h.className = 'pl-group'; h.textContent = titel;
menu.appendChild(h);
};
block('Qualität');
QUALITIES.forEach(([wert, text]) => {
menu.appendChild(eintrag(text, lauf.quality === wert, () => {
if (lauf.quality === wert) return closeMenu();
lauf.quality = wert; neuLaden();
}));
});
const ton = spuren('Audio');
if (ton.length > 1) {
block('Ton');
ton.forEach((t) => {
menu.appendChild(eintrag(t.DisplayTitle || t.Language || `Spur ${t.Index}`,
lauf.audio === t.Index, () => {
if (lauf.audio === t.Index) return closeMenu();
lauf.audio = t.Index; neuLaden();
}));
});
}
const untertitel = spuren('Subtitle');
if (untertitel.length) {
block('Untertitel');
menu.appendChild(eintrag('Aus', lauf.subtitle === -1 || lauf.subtitle === null, () => {
if (lauf.subtitle === -1) return closeMenu();
lauf.subtitle = -1; neuLaden();
}));
untertitel.forEach((t) => {
menu.appendChild(eintrag(t.DisplayTitle || t.Language || `Spur ${t.Index}`,
lauf.subtitle === t.Index, () => {
if (lauf.subtitle === t.Index) return closeMenu();
lauf.subtitle = t.Index; neuLaden();
}));
});
}
menu.hidden = false;
show();
}
show();
layer.classList.add('loading');
el2('pl-busy').classList.add('on');
startStream(item, v, fail);
startStream(item, v, fail, lauf);
}
/* Der Folgentitel in der Kopfzeile — „S2:E4 · Der Fall" statt nur „Der Fall". */
@@ -1738,20 +1850,30 @@ function episodeLabel(item) {
Antwort wählen, die Adresse des Servers nehmen, die Sitzungskennung
mitführen. Die Adresse selbst zusammenzubauen wäre eine Vermutung darüber,
was der Server kopieren kann und was er rechnen muss. */
async function startStream(item, v, fail) {
// Erst nach dem Aushandeln bekannt — deshalb hier eine Schachtel, die
// später gefüllt wird.
const diagnose = { text: '', url: '' };
async function startStream(item, v, fail, lauf) {
// Ein früherer Strom liegt vielleicht noch an.
v.__hls?.destroy();
v.__hls = null;
// Erst nach dem Aushandeln bekannt — deshalb eine Schachtel, die später
// gefüllt wird. Sie hängt am Element, damit ein zweiter Aufruf denselben
// Horcher weiterbenutzt: sonst käme bei jedem Spurwechsel ein weiterer
// dazu und ein einziger Fehler meldete sich dreimal.
v.__diagnose = v.__diagnose || { text: '', url: '' };
if (!v.__fehlerHorcht) {
v.__fehlerHorcht = true;
v.addEventListener('error', () => {
const c = v.error?.code;
fail('Der Browser kann diesen Strom nicht abspielen.',
(c === 4 ? 'Das Format wird nicht unterstützt. ' : `Fehlercode ${c ?? '?'}. `)
+ diagnose.text, diagnose.url);
+ v.__diagnose.text, v.__diagnose.url);
});
}
const diagnose = v.__diagnose;
let info;
try {
info = await playbackInfo(item);
info = await playbackInfo(item, lauf);
} catch (e) {
return fail('Der Server hat die Wiedergabe abgelehnt.', e.message);
}
@@ -1759,6 +1881,15 @@ async function startStream(item, v, fail) {
const source = info.MediaSources?.[0];
if (!source) return fail('Der Server nennt keine abspielbare Quelle.');
// Was zur Auswahl steht, weiß man erst jetzt — der Server nennt die Spuren
// in seiner Antwort. Beim ersten Lauf gilt seine Vorauswahl.
lauf.streams = source.MediaStreams || [];
if (lauf.audio == null) {
lauf.audio = source.DefaultAudioStreamIndex
?? lauf.streams.find((x) => x.Type === 'Audio')?.Index ?? null;
}
if (lauf.subtitle == null) lauf.subtitle = source.DefaultSubtitleStreamIndex ?? -1;
// **Der Rückfall muss Umrechnung erzwingen, nicht sie umgehen.**
//
// Hier stand `static=true` — das schickt die Datei so, wie sie auf der
@@ -1780,6 +1911,9 @@ async function startStream(item, v, fail) {
TranscodingMaxAudioChannels: '2', SegmentContainer: 'ts',
MinSegments: '1', BreakOnNonKeyFrames: 'true',
TranscodingProtocol: 'hls',
...(lauf.audio != null ? { AudioStreamIndex: lauf.audio } : {}),
...(lauf.subtitle >= 0 ? { SubtitleStreamIndex: lauf.subtitle } : {}),
...(lauf.quality > 0 ? { MaxStreamingBitrate: lauf.quality * 1000 } : {}),
});
// Was der Server geantwortet hat — damit im Fehlerfall nicht geraten werden
@@ -1793,13 +1927,20 @@ async function startStream(item, v, fail) {
diagnose.text = auskunft;
diagnose.url = url;
v.addEventListener('loadedmetadata', () => { v.playbackRate = P.rate; v.play(); }, { once: true });
v.addEventListener('loadedmetadata', () => {
v.playbackRate = P.rate;
// Zurück an die Stelle, an der umgeschaltet wurde. Ohne das begänne jeder
// Wechsel der Tonspur den Film von vorn.
if (lauf.stelle > 1) v.currentTime = lauf.stelle;
v.play();
}, { once: true });
wireProgress(v, { id: info.PlaySessionId, sourceID: source.Id || item.Id, itemID: item.Id });
if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = url; return; }
const boot = () => {
const h = new Hls();
v.__hls = h;
h.on(Hls.Events.ERROR, (_, data) => {
if (!data.fatal) return;
fail('Die Wiedergabe ist abgebrochen.',
@@ -1824,9 +1965,10 @@ async function startStream(item, v, fail) {
folgt. Leere DirectPlayProfiles heißt: nichts geht unverändert, rechne um.
Im Browser ist das die ehrliche Auskunft — ein <video>-Element kann kein
MKV und kein DTS. */
async function playbackInfo(item) {
async function playbackInfo(item, lauf = {}) {
const grenze = (lauf.quality ?? P.quality) > 0 ? (lauf.quality ?? P.quality) : 20000;
const profile = {
MaxStreamingBitrate: (P.quality > 0 ? P.quality : 20000) * 1000,
MaxStreamingBitrate: grenze * 1000,
DirectPlayProfiles: [],
TranscodingProfiles: [{
Type: 'Video', Container: 'ts', Protocol: 'hls', VideoCodec: 'h264',
@@ -1843,6 +1985,8 @@ async function playbackInfo(item) {
MaxStreamingBitrate: profile.MaxStreamingBitrate,
EnableDirectPlay: false, EnableDirectStream: false,
EnableTranscoding: true, AutoOpenLiveStream: true,
...(lauf.audio != null ? { AudioStreamIndex: lauf.audio } : {}),
...(lauf.subtitle >= 0 ? { SubtitleStreamIndex: lauf.subtitle } : {}),
}),
});
}
@@ -1902,6 +2046,8 @@ function streamURL(path, query) {
überall gleich, damit die Symbole zueinander passen. */
const ICONS = {
play: '<path d="M8 5.5v13l11-6.5z" fill="currentColor" stroke="none"/>',
sliders: '<path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/>',
check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
pause: '<path d="M9 5.5v13M15 5.5v13"/>',
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
sound: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4z" fill="currentColor"/><path d="M16 9a4 4 0 010 6"/><path d="M18.5 6.5a7.5 7.5 0 010 11"/>',