diff --git a/web/frontend/index.html b/web/frontend/index.html index 100277a..250f06b 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -237,21 +237,126 @@ img { display: block; } #sheet h2 { margin: 0 0 var(--s-snug); font-size: 24px; letter-spacing: -.02em; } #sheet .meta { color: var(--text-2); font-size: 13px; margin-bottom: var(--s-regular); } #sheet p { color: var(--text-2); font-size: 14px; } -#sheet video { width: 100%; border-radius: var(--r-card); background: #000; margin-top: var(--s-regular); } -.playerbar { +/* ---- Der Player ---------------------------------------------------------- + Vollbild und schwarz. Ein Film nimmt den Bildschirm ein — alles andere ist + ein Vorschaufenster. */ +#player { + position: fixed; inset: 0; z-index: 100; background: #000; + display: grid; place-items: center; overflow: hidden; +} +#player video { width: 100%; height: 100%; object-fit: contain; background: #000; cursor: pointer; } + +.pl-chrome { + position: absolute; inset: 0; opacity: 0; pointer-events: none; + transition: opacity .25s ease; + display: grid; grid-template-rows: auto 1fr auto; +} +.pl-chrome.on { opacity: 1; pointer-events: auto; } + +/* Zwei Verläufe statt einer Fläche: die Mitte bleibt frei, und genau dort + ist das Bild. */ +.pl-top, .pl-bottom { padding: var(--s-loose) var(--s-section); } +.pl-top { + display: flex; align-items: center; gap: var(--s-loose); + background: linear-gradient(to bottom, rgba(0,0,0,.75), transparent); + padding-top: calc(var(--s-loose) + env(safe-area-inset-top)); +} +.pl-bottom { + background: linear-gradient(to top, rgba(0,0,0,.85), transparent); + padding-bottom: calc(var(--s-loose) + env(safe-area-inset-bottom)); +} + +.pl-title { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.pl-title strong { font-size: 17px; font-weight: 600; letter-spacing: -.01em; } +.pl-title span { font-size: 13px; color: var(--text-2); } + +.pl-icon { + width: 38px; height: 38px; border-radius: 999px; flex: 0 0 auto; + background: rgba(20,20,23,.55); color: var(--text); font-size: 15px; + border: 1px solid rgba(255,255,255,.14); + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + transition: background .15s ease; +} +.pl-icon:hover { background: rgba(40,40,46,.75); } + +.pl-center { display: flex; align-items: center; justify-content: center; gap: var(--s-section); } +/* Dunkel statt hell durchscheinend: über einer sonnigen Szene verschwanden + helle Knöpfe vollständig. Ein Schatten hebt sie zusätzlich ab. */ +.pl-round { + position: relative; width: 58px; height: 58px; border-radius: 999px; + background: rgba(20,20,23,.55); color: var(--text); font-size: 22px; + border: 1px solid rgba(255,255,255,.14); + box-shadow: 0 4px 16px rgba(0,0,0,.45); + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + transition: background .15s ease, transform .15s ease; +} +.pl-round:hover { background: rgba(40,40,46,.8); transform: scale(1.06); } +.pl-round.pl-play { width: 76px; height: 76px; font-size: 27px; } +/* Die Sekundenzahl als Text, nicht als Ziffer im Pfeil: übereinander war + beides unlesbar, und die Zahl ist die eigentliche Auskunft. */ +#pl-back, #pl-fwd { font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; } + +.pl-scrub { display: flex; align-items: center; gap: var(--s-regular); } +.pl-scrub span { + font-size: 13px; color: var(--text-2); font-variant-numeric: tabular-nums; + min-width: 52px; +} +.pl-scrub span:last-child { text-align: right; } + +.pl-track { + position: relative; flex: 1; height: 22px; cursor: pointer; + display: flex; align-items: center; +} +.pl-track::before { + content: ''; position: absolute; left: 0; right: 0; height: 4px; + background: rgba(0,0,0,.45); border-radius: 999px; + box-shadow: inset 0 0 0 1px rgba(255,255,255,.14); +} +.pl-buf, .pl-fill { position: absolute; left: 0; height: 4px; border-radius: 999px; } +.pl-buf { background: rgba(255,255,255,.3); width: 0; } +.pl-fill { background: var(--accent); width: 0; } +.pl-track i { + position: absolute; width: 13px; height: 13px; border-radius: 999px; + background: var(--accent); transform: translateX(-50%); + box-shadow: 0 1px 4px rgba(0,0,0,.6); transition: transform .12s ease; +} +.pl-track:hover i { transform: translateX(-50%) scale(1.25); } + +.pl-tools { display: flex; align-items: center; gap: var(--s-regular); - margin-top: var(--s-regular); flex-wrap: wrap; + margin-top: var(--s-snug); } -.playerbar .btn { padding: 8px var(--s-loose); font-size: 14px; } -.playerbar label { font-size: 13px; color: var(--text-2); display: flex; align-items: center; gap: var(--s-snug); } -.playerbar .pick { width: auto; padding: 7px var(--s-regular); } -.playerror { - margin-top: var(--s-regular); padding: var(--s-loose); - background: rgba(229,72,77,.12); border: 1px solid rgba(229,72,77,.35); - border-radius: var(--r-control); display: flex; flex-direction: column; gap: 6px; +.pl-spacer { flex: 1; } +.pl-tools input[type=range] { width: 92px; accent-color: var(--accent); } +.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; } + +.pl-busy { + position: absolute; display: none; pointer-events: none; +} +.pl-busy.on { display: block; } +.pl-busy i { + display: block; width: 44px; height: 44px; border-radius: 999px; + border: 3px solid rgba(255,255,255,.2); border-top-color: var(--accent); + animation: spin .8s linear infinite; +} + +.pl-error { + position: absolute; max-width: 460px; padding: var(--s-section); + background: var(--elevated); border: 1px solid rgba(229,72,77,.35); + border-radius: var(--r-sheet); display: flex; flex-direction: column; gap: var(--s-regular); + box-shadow: 0 20px 60px rgba(0,0,0,.6); +} +.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; } + +@media (max-width: 600px) { + .pl-top, .pl-bottom { padding-left: var(--s-loose); padding-right: var(--s-loose); } + .pl-round { width: 50px; height: 50px; } + .pl-round.pl-play { width: 64px; height: 64px; } + .pl-rate, .pl-tools input[type=range] { display: none; } } -.playerror strong { color: #F08A8D; font-weight: 600; font-size: 14px; } -.playerror span { color: var(--text-2); font-size: 13px; } /* ---- Die Navigation an der Seite ----------------------------------------- Dieselbe Wahl wie in der App: die Leiste sitzt oben als Insel oder links @@ -1344,9 +1449,10 @@ async function renderLibrary(id) { function openSheet(item, playNow) { const back = document.createElement('div'); back.id = 'sheet'; - // Serien und Staffeln sind Ordner. `/Videos/{serienId}` zeigt auf nichts - // Abspielbares — ein Knopf dafür verspricht etwas, das nicht eingelöst wird. - const playable = !['Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder'].includes(item.Type); + // Serien und Staffeln sind Ordner — abgespielt wird trotzdem. Nur eben + // nicht der Ordner, sondern die Folge, die als Nächstes dran ist. + const isFolder = ['Series', 'Season'].includes(item.Type); + const playable = isFolder || !['BoxSet', 'Folder', 'CollectionFolder'].includes(item.Type); const bits = [item.ProductionYear, runtime(item), item.OfficialRating].filter(Boolean); back.innerHTML = `
@@ -1354,56 +1460,262 @@ function openSheet(item, playNow) {
${bits.join(' · ')}

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

- ${playable ? '' : ''} + ${playable ? `` : ''}
- ${playable ? '' : '

Eine Serie spielt man nicht ab — öffne eine Folge.

'}
`; back.onclick = (e) => { if (e.target === back) back.remove(); }; document.body.appendChild(back); back.querySelector('#sheet-close').onclick = () => back.remove(); if (playable) { - back.querySelector('#sheet-play').onclick = () => play(item, back.querySelector('.panel')); - if (playNow) play(item, back.querySelector('.panel')); + 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 (!target) { + back.querySelector('.btn-row').insertAdjacentHTML('afterend', + '

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

'); + return; + } + openPlayer(target); + back.remove(); + }; + const button = back.querySelector('#sheet-play'); + button.onclick = () => start(button); + if (playNow) start(button); } } -/* Spielt einen Titel ab — auf dem Weg, den der Server vorgibt. +/* Welche Folge einer Serie oder Staffel gemeint ist. ---------------------------------------------------------------------------- - **Vier Schritte, und der erste ist der wichtigste.** Vorher stand hier nur - der dritte: eine selbst zusammengebaute `master.m3u8`-Adresse. Die App sagt - dazu seit jeher das Richtige — „die Adresse kommt vom Server, nicht von - uns. Er weiß, welche Spuren er kopieren kann und welche er neu rechnen - muss; eine selbst zusammengebaute Adresse wäre eine Vermutung darüber." + Erst „Als Nächstes": wer eine Serie anfängt und zurückkommt, meint die + Folge, bei der er stehengeblieben ist — nicht die erste. Das ist dieselbe + Frage, die die Startseite mit `/Shows/NextUp` beantwortet. - Ohne den Aushandlungsschritt fehlen `PlaySessionId`, die ausgehandelte - `MediaSourceId` und die Adresse selbst. Der Server bekam eine Anfrage ohne - Sitzung und ohne Gerät — und antwortete entsprechend. + Erst wenn es die nicht gibt (nichts angefangen, oder alles gesehen), wird + es die erste Folge. Eine Serie gar nicht abzuspielen war die falsche + Antwort — die offensichtliche Erwartung ist, dass es losgeht. */ +async function nextEpisode(item) { + const seriesID = item.Type === 'Season' ? (item.SeriesId || item.Id) : item.Id; - 1. `POST /Items/{id}/PlaybackInfo` mit einem Geräteprofil - 2. Die Quelle aus der Antwort wählen, nicht die Eintragskennung annehmen - 3. Die Adresse des Servers nehmen - 4. Die Sitzungskennung mitführen: für den Fortschritt und fürs Aufräumen */ -async function play(item, panel) { - panel.querySelector('video')?.remove(); - panel.querySelector('.playerbar')?.remove(); - panel.querySelector('.playerror')?.remove(); + try { + const weiter = await api('/Shows/NextUp?' + new URLSearchParams({ + userId: S.userId, seriesId: seriesID, Limit: 1, Fields: FIELDS, + })); + const folge = weiter.Items?.[0]; + // Bei einer Staffel muss die Folge auch zu ihr gehören. + if (folge && (item.Type !== 'Season' || folge.SeasonId === item.Id)) return folge; + } catch { /* dann eben die erste */ } + + try { + const query = { userId: S.userId, Limit: 1, Fields: FIELDS }; + if (item.Type === 'Season') query.seasonId = item.Id; + const alle = await api(`/Shows/${seriesID}/Episodes?` + new URLSearchParams(query)); + return alle.Items?.[0] || null; + } catch { + return null; + } +} + +/* Der Player. + ---------------------------------------------------------------------------- + Vollbild und eigene Bedienung. Vorher stand hier das nackte +