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
+161 -15
View File
@@ -362,6 +362,33 @@ img { display: block; }
.pl-error strong { color: #F08A8D; font-size: 16px; font-weight: 600; } .pl-error strong { color: #F08A8D; font-size: 16px; font-weight: 600; }
.pl-error span { color: var(--text-2); font-size: 14px; } .pl-error span { color: var(--text-2); font-size: 14px; }
.pl-error .btn { align-self: flex-start; } .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 { .pl-error code {
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px; 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); 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"> <input type="range" id="pl-vol" min="0" max="1" step="0.05" value="1" aria-label="Lautstärke">
<span class="pl-spacer"></span> <span class="pl-spacer"></span>
<label class="pl-rate">Tempo <select class="pick" id="pl-rate"></select></label> <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> <button class="pl-icon" id="pl-full" aria-label="Vollbild">${icon('expand')}</button>
</div> </div>
</div> </div>
</div> </div>
<div class="pl-menu" id="pl-menu" hidden></div>
<div class="pl-busy" id="pl-busy"><i></i></div>`; <div class="pl-busy" id="pl-busy"><i></i></div>`;
document.body.appendChild(layer); document.body.appendChild(layer);
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
@@ -1711,6 +1740,7 @@ function openPlayer(item) {
removeEventListener('keydown', keys); removeEventListener('keydown', keys);
clearTimeout(hideTimer); clearTimeout(hideTimer);
if (document.fullscreenElement) document.exitFullscreen?.(); if (document.fullscreenElement) document.exitFullscreen?.();
v.__hls?.destroy(); v.__hls = null;
v.pause(); v.removeAttribute('src'); v.load(); v.pause(); v.removeAttribute('src'); v.load();
layer.remove(); layer.remove();
document.body.style.overflow = ''; document.body.style.overflow = '';
@@ -1719,10 +1749,92 @@ function openPlayer(item) {
} }
el2('pl-close').onclick = close; 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(); show();
layer.classList.add('loading'); layer.classList.add('loading');
el2('pl-busy').classList.add('on'); 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". */ /* 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 Antwort wählen, die Adresse des Servers nehmen, die Sitzungskennung
mitführen. Die Adresse selbst zusammenzubauen wäre eine Vermutung darüber, mitführen. Die Adresse selbst zusammenzubauen wäre eine Vermutung darüber,
was der Server kopieren kann und was er rechnen muss. */ was der Server kopieren kann und was er rechnen muss. */
async function startStream(item, v, fail) { async function startStream(item, v, fail, lauf) {
// Erst nach dem Aushandeln bekannt — deshalb hier eine Schachtel, die // Ein früherer Strom liegt vielleicht noch an.
// später gefüllt wird. v.__hls?.destroy();
const diagnose = { text: '', url: '' }; v.__hls = null;
v.addEventListener('error', () => {
const c = v.error?.code; // Erst nach dem Aushandeln bekannt — deshalb eine Schachtel, die später
fail('Der Browser kann diesen Strom nicht abspielen.', // gefüllt wird. Sie hängt am Element, damit ein zweiter Aufruf denselben
(c === 4 ? 'Das Format wird nicht unterstützt. ' : `Fehlercode ${c ?? '?'}. `) // Horcher weiterbenutzt: sonst käme bei jedem Spurwechsel ein weiterer
+ diagnose.text, diagnose.url); // 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 ?? '?'}. `)
+ v.__diagnose.text, v.__diagnose.url);
});
}
const diagnose = v.__diagnose;
let info; let info;
try { try {
info = await playbackInfo(item); info = await playbackInfo(item, lauf);
} catch (e) { } catch (e) {
return fail('Der Server hat die Wiedergabe abgelehnt.', e.message); 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]; const source = info.MediaSources?.[0];
if (!source) return fail('Der Server nennt keine abspielbare Quelle.'); 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.** // **Der Rückfall muss Umrechnung erzwingen, nicht sie umgehen.**
// //
// Hier stand `static=true` — das schickt die Datei so, wie sie auf der // 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', TranscodingMaxAudioChannels: '2', SegmentContainer: 'ts',
MinSegments: '1', BreakOnNonKeyFrames: 'true', MinSegments: '1', BreakOnNonKeyFrames: 'true',
TranscodingProtocol: 'hls', 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 // 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.text = auskunft;
diagnose.url = url; 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 }); wireProgress(v, { id: info.PlaySessionId, sourceID: source.Id || item.Id, itemID: item.Id });
if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = url; return; } if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = url; return; }
const boot = () => { const boot = () => {
const h = new Hls(); const h = new Hls();
v.__hls = h;
h.on(Hls.Events.ERROR, (_, data) => { h.on(Hls.Events.ERROR, (_, data) => {
if (!data.fatal) return; if (!data.fatal) return;
fail('Die Wiedergabe ist abgebrochen.', 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. folgt. Leere DirectPlayProfiles heißt: nichts geht unverändert, rechne um.
Im Browser ist das die ehrliche Auskunft — ein <video>-Element kann kein Im Browser ist das die ehrliche Auskunft — ein <video>-Element kann kein
MKV und kein DTS. */ 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 = { const profile = {
MaxStreamingBitrate: (P.quality > 0 ? P.quality : 20000) * 1000, MaxStreamingBitrate: grenze * 1000,
DirectPlayProfiles: [], DirectPlayProfiles: [],
TranscodingProfiles: [{ TranscodingProfiles: [{
Type: 'Video', Container: 'ts', Protocol: 'hls', VideoCodec: 'h264', Type: 'Video', Container: 'ts', Protocol: 'hls', VideoCodec: 'h264',
@@ -1843,6 +1985,8 @@ async function playbackInfo(item) {
MaxStreamingBitrate: profile.MaxStreamingBitrate, MaxStreamingBitrate: profile.MaxStreamingBitrate,
EnableDirectPlay: false, EnableDirectStream: false, EnableDirectPlay: false, EnableDirectStream: false,
EnableTranscoding: true, AutoOpenLiveStream: true, 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. */ überall gleich, damit die Symbole zueinander passen. */
const ICONS = { const ICONS = {
play: '<path d="M8 5.5v13l11-6.5z" fill="currentColor" stroke="none"/>', 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"/>', pause: '<path d="M9 5.5v13M15 5.5v13"/>',
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>', 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"/>', 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"/>',