diff --git a/web/frontend/index.html b/web/frontend/index.html
index 8fd9aec..6c1fa3a 100644
--- a/web/frontend/index.html
+++ b/web/frontend/index.html
@@ -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) {
+
+
`;
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 = `${esc(text)}${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