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:
co-authored by
Claude Opus 5
parent
66b963f2c5
commit
9da446b295
+161
-15
@@ -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: '' };
|
||||
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);
|
||||
});
|
||||
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 ?? '?'}. `)
|
||||
+ 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"/>',
|
||||
|
||||
Reference in New Issue
Block a user