diff --git a/web/frontend/index.html b/web/frontend/index.html index 250f06b..8fd9aec 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -278,6 +278,13 @@ img { display: block; } transition: background .15s ease; } .pl-icon:hover { background: rgba(40,40,46,.75); } +/* Eine Vorgabegröße für alle Symbole, damit sie zueinander passen; einzelne + überschreiben sie über `icon(name, size)`. */ +.pl-icon svg, .pl-round svg, .tiny svg, .hero-nav svg { width: 18px; height: 18px; } +.pl-round svg { width: 22px; height: 22px; } +.btn svg { width: 15px; height: 15px; vertical-align: -2px; margin-right: 2px; } +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; } +.tiny, .pl-icon, .pl-round, .hero-nav { display: inline-flex; align-items: center; justify-content: center; } .pl-center { display: flex; align-items: center; justify-content: center; gap: var(--s-section); } /* Dunkel statt hell durchscheinend: über einer sonnigen Szene verschwanden @@ -331,6 +338,11 @@ img { display: block; } .pl-rate { font-size: 13px; color: var(--text-2); display: flex; align-items: center; gap: var(--s-snug); } .pl-rate .pick { width: auto; padding: 6px var(--s-regular); font-size: 13px; } +/* Während geladen wird, treten die Transportknöpfe zurück: sie tun ohnehin + nichts, und der Kreisel lag vorher genau hinter dem größten von ihnen. */ +#player.loading .pl-center { opacity: 0; pointer-events: none; } +.pl-center { transition: opacity .2s ease; } + .pl-busy { position: absolute; display: none; pointer-events: none; } @@ -350,6 +362,11 @@ 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; } +.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); + border-radius: 6px; word-break: break-all; line-height: 1.5; +} @media (max-width: 600px) { .pl-top, .pl-bottom { padding-left: var(--s-loose); padding-right: var(--s-loose); } @@ -1281,7 +1298,7 @@ function buildHero(items) {
${bits.join(' · ')}
${esc(it.Overview || '')}
- +
`; body.querySelector('.btn-primary').onclick = () => openSheet(it, true); @@ -1293,12 +1310,12 @@ function buildHero(items) { if (items.length > 1) { const prev = document.createElement('button'); - prev.className = 'hero-nav prev'; prev.innerHTML = '‹'; + prev.className = 'hero-nav prev'; prev.innerHTML = icon('left', 22); prev.setAttribute('aria-label', 'Vorheriger Titel'); prev.onclick = () => step(-1); const next = document.createElement('button'); - next.className = 'hero-nav next'; next.innerHTML = '›'; + next.className = 'hero-nav next'; next.innerHTML = icon('right', 22); next.setAttribute('aria-label', 'Nächster Titel'); next.onclick = () => step(1); @@ -1397,8 +1414,8 @@ function renderRowArrangement() { ${esc(row.title)} - - `; + + `; line.querySelector('input').onchange = (e) => { const next = e.target.checked @@ -1460,7 +1477,7 @@ function openSheet(item, playNow) {
${bits.join(' · ')}

${esc(item.Overview || '')}

- ${playable ? `` : ''} + ${playable ? `` : ''}
`; @@ -1471,7 +1488,7 @@ function openSheet(item, playNow) { const start = async (button) => { if (button) { button.disabled = true; button.textContent = 'Suche Folge …'; } const target = isFolder ? await nextEpisode(item) : item; - if (button) { button.disabled = false; button.textContent = isFolder ? '▶ Weiterschauen' : '▶ Abspielen'; } + if (button) { button.disabled = false; button.innerHTML = icon('play', 15) + (isFolder ? ' Weiterschauen' : ' Abspielen'); } if (!target) { back.querySelector('.btn-row').insertAdjacentHTML('afterend', '

Diese Serie hat keine Folgen, die sich abspielen ließen.

'); @@ -1535,7 +1552,7 @@ function openPlayer(item) {
- +
${esc(item.SeriesName || item.Name)} ${item.SeriesName ? `${esc(episodeLabel(item))}` : ''} @@ -1544,7 +1561,7 @@ function openPlayer(item) {
- +
@@ -1555,11 +1572,11 @@ function openPlayer(item) { –:--
- + - +
@@ -1571,12 +1588,14 @@ function openPlayer(item) { const chrome = layer.querySelector('.pl-chrome'); const el2 = (id) => layer.querySelector('#' + id); - const fail = (satz, hinweis) => { + const fail = (satz, hinweis, adresse) => { + layer.classList.remove('loading'); el2('pl-busy').classList.remove('on'); layer.querySelector('.pl-error')?.remove(); const box = document.createElement('div'); box.className = 'pl-error'; box.innerHTML = `${esc(satz)}${hinweis ? `${esc(hinweis)}` : ''} + ${adresse ? `${esc(adresse.replace(/api_key=[^&]*/, 'api_key=…'))}` : ''} `; box.querySelector('button').onclick = close; layer.appendChild(box); @@ -1602,14 +1621,17 @@ function openPlayer(item) { v.addEventListener('click', toggle); const paint = () => { - el2('pl-toggle').textContent = v.paused ? '▶' : '❚❚'; + el2('pl-toggle').innerHTML = icon(v.paused ? 'play' : 'pause', 30); el2('pl-toggle').setAttribute('aria-label', v.paused ? 'Abspielen' : 'Anhalten'); show(); }; v.addEventListener('play', paint); v.addEventListener('pause', paint); v.addEventListener('waiting', () => el2('pl-busy').classList.add('on')); - v.addEventListener('playing', () => el2('pl-busy').classList.remove('on')); + v.addEventListener('playing', () => { + el2('pl-busy').classList.remove('on'); + layer.classList.remove('loading'); + }); // --- Fortschrittsleiste --- const track = el2('pl-track'); @@ -1653,7 +1675,7 @@ function openPlayer(item) { vol.oninput = () => { v.volume = +vol.value; v.muted = +vol.value === 0; }; el2('pl-mute').onclick = () => { v.muted = !v.muted; - el2('pl-mute').textContent = v.muted ? '🔇' : '🔊'; + el2('pl-mute').innerHTML = icon(v.muted ? 'muted' : 'sound'); }; const rate = el2('pl-rate'); @@ -1698,6 +1720,7 @@ function openPlayer(item) { el2('pl-close').onclick = close; show(); + layer.classList.add('loading'); el2('pl-busy').classList.add('on'); startStream(item, v, fail); } @@ -1716,11 +1739,14 @@ function episodeLabel(item) { 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 — der Server sollte umrechnen, tat es aber nicht.' - : `Fehlercode ${c ?? '?'}. Im Netzwerk-Tab steht, was der Server geantwortet hat.`); + (c === 4 ? 'Das Format wird nicht unterstützt. ' : `Fehlercode ${c ?? '?'}. `) + + diagnose.text, diagnose.url); }); let info; @@ -1733,13 +1759,40 @@ async function startStream(item, v, fail) { const source = info.MediaSources?.[0]; if (!source) return fail('Der Server nennt keine abspielbare Quelle.'); + // **Der Rückfall muss Umrechnung erzwingen, nicht sie umgehen.** + // + // Hier stand `static=true` — das schickt die Datei so, wie sie auf der + // Platte liegt. Bei einem MKV mit DTS ist das für einen Browser unlesbar, + // und er meldet bloß „Format nicht unterstützt". Genau dieser Rückfall war + // der Grund, warum nichts lief: er tat das Gegenteil dessen, was er sollte. + // + // `master.m3u8` verlangt dagegen ausdrücklich einen HLS-Strom. Nennt der + // Server keine eigene Adresse, ist das die richtige Bitte — nicht das + // Original. const url = source.TranscodingUrl ? new URL(source.TranscodingUrl, S.base).href - : streamURL(`/Videos/${item.Id}/stream`, { - static: 'true', mediaSourceId: source.Id || item.Id, - api_key: S.token, PlaySessionId: info.PlaySessionId || '', + : streamURL(`/Videos/${item.Id}/master.m3u8`, { + mediaSourceId: source.Id || item.Id, + api_key: S.token, + PlaySessionId: info.PlaySessionId || '', + DeviceId: S.deviceId, + VideoCodec: 'h264', AudioCodec: 'aac,mp3', + TranscodingMaxAudioChannels: '2', SegmentContainer: 'ts', + MinSegments: '1', BreakOnNonKeyFrames: 'true', + TranscodingProtocol: 'hls', }); + // Was der Server geantwortet hat — damit im Fehlerfall nicht geraten werden + // muss, sondern dasteht, woran es lag. + const auskunft = [ + source.TranscodingUrl ? 'Server nannte eine Adresse' : 'Server nannte keine Adresse (Rückfall auf HLS)', + source.Container ? `Container ${source.Container}` : null, + source.SupportsTranscoding === false ? 'Server sagt: kann nicht umrechnen' : null, + ].filter(Boolean).join(' · '); + + diagnose.text = auskunft; + diagnose.url = url; + v.addEventListener('loadedmetadata', () => { v.playbackRate = P.rate; v.play(); }, { once: true }); wireProgress(v, { id: info.PlaySessionId, sourceID: source.Id || item.Id, itemID: item.Id }); @@ -1838,6 +1891,35 @@ function streamURL(path, query) { return u.href; } +/* Die Symbole der Oberfläche. + ---------------------------------------------------------------------------- + Als Vektor, nicht als Schriftzeichen. Ein ▶ oder ein 🔊 wird auf jeder + Plattform anders gezeichnet, trägt fremde Farben, sitzt schief auf der + Grundlinie und lässt sich weder in der Strichstärke noch in der Farbe + anpassen — es sieht nach Platzhalter aus, weil es einer ist. + + `currentColor` erbt Farbe und Zustand vom Knopf darum; `stroke-width` bleibt + überall gleich, damit die Symbole zueinander passen. */ +const ICONS = { + play: '', + pause: '', + close: '', + sound: '', + muted: '', + expand: '', + left: '', + right: '', + up: '', + down: '', +}; + +/// Ein Symbol als Vektor. `size` nur, wo es von der Vorgabe abweicht. +function icon(name, size) { + return ``; +} + /* ---------- Kleinkram ---------- */ function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) =>