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