Ein Player, und Serien starten die nächste Folge
Zwei berechtigte Beschwerden auf einmal. Das war kein Player, sondern das nackte <video controls> des Browsers in einem Blatt — ein eingebettetes Vorschaufenster. Jetzt Vollbild mit eigener Bedienung: Titel und Folge oben, Spulen und Anhalten in der Mitte, Fortschritt mit Restzeit unten, dazu Lautstärke, Tempo und Vollbild. Die Bedienung blendet nach drei Sekunden Ruhe aus und kommt bei jeder Bewegung zurück; steht das Bild, bleibt sie stehen — sie auszublenden, während niemand spielt, versteckt den einzigen Knopf, der weiterhilft. Tastatur: Leertaste, Pfeile, Escape. Dunkle Knöpfe statt hell durchscheinender: über einer sonnigen Szene verschwanden helle vollständig. Und die Sekundenzahl steht als Text, statt im Spulpfeil zu stecken — übereinander war beides unlesbar. Und bei einer Serie "spielt man nicht ab" zu antworten war Unsinn. Ich hatte den Knopf entfernt, statt ihn richtig zu machen. Jetzt heißt er "Weiterschauen" und startet die Folge, bei der man stehengeblieben ist — über dieselbe Frage, die auch die Startseite stellt. Gibt es keine, wird es die erste. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ecfa454136
commit
1db70379d7
+382
-110
@@ -237,21 +237,126 @@ img { display: block; }
|
|||||||
#sheet h2 { margin: 0 0 var(--s-snug); font-size: 24px; letter-spacing: -.02em; }
|
#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 .meta { color: var(--text-2); font-size: 13px; margin-bottom: var(--s-regular); }
|
||||||
#sheet p { color: var(--text-2); font-size: 14px; }
|
#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); }
|
/* ---- Der Player ----------------------------------------------------------
|
||||||
.playerbar {
|
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);
|
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; }
|
.pl-spacer { flex: 1; }
|
||||||
.playerbar label { font-size: 13px; color: var(--text-2); display: flex; align-items: center; gap: var(--s-snug); }
|
.pl-tools input[type=range] { width: 92px; accent-color: var(--accent); }
|
||||||
.playerbar .pick { width: auto; padding: 7px var(--s-regular); }
|
.pl-rate { font-size: 13px; color: var(--text-2); display: flex; align-items: center; gap: var(--s-snug); }
|
||||||
.playerror {
|
.pl-rate .pick { width: auto; padding: 6px var(--s-regular); font-size: 13px; }
|
||||||
margin-top: var(--s-regular); padding: var(--s-loose);
|
|
||||||
background: rgba(229,72,77,.12); border: 1px solid rgba(229,72,77,.35);
|
.pl-busy {
|
||||||
border-radius: var(--r-control); display: flex; flex-direction: column; gap: 6px;
|
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 -----------------------------------------
|
/* ---- Die Navigation an der Seite -----------------------------------------
|
||||||
Dieselbe Wahl wie in der App: die Leiste sitzt oben als Insel oder links
|
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) {
|
function openSheet(item, playNow) {
|
||||||
const back = document.createElement('div');
|
const back = document.createElement('div');
|
||||||
back.id = 'sheet';
|
back.id = 'sheet';
|
||||||
// Serien und Staffeln sind Ordner. `/Videos/{serienId}` zeigt auf nichts
|
// Serien und Staffeln sind Ordner — abgespielt wird trotzdem. Nur eben
|
||||||
// Abspielbares — ein Knopf dafür verspricht etwas, das nicht eingelöst wird.
|
// nicht der Ordner, sondern die Folge, die als Nächstes dran ist.
|
||||||
const playable = !['Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder'].includes(item.Type);
|
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);
|
const bits = [item.ProductionYear, runtime(item), item.OfficialRating].filter(Boolean);
|
||||||
back.innerHTML = `
|
back.innerHTML = `
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -1354,56 +1460,262 @@ function openSheet(item, playNow) {
|
|||||||
<div class="meta">${bits.join(' · ')}</div>
|
<div class="meta">${bits.join(' · ')}</div>
|
||||||
<p>${esc(item.Overview || '')}</p>
|
<p>${esc(item.Overview || '')}</p>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
${playable ? '<button class="btn btn-primary" id="sheet-play">▶ Abspielen</button>' : ''}
|
${playable ? `<button class="btn btn-primary" id="sheet-play">▶ ${isFolder ? 'Weiterschauen' : 'Abspielen'}</button>` : ''}
|
||||||
<button class="btn" id="sheet-close">Schließen</button>
|
<button class="btn" id="sheet-close">Schließen</button>
|
||||||
</div>
|
</div>
|
||||||
${playable ? '' : '<p class="hint">Eine Serie spielt man nicht ab — öffne eine Folge.</p>'}
|
|
||||||
</div>`;
|
</div>`;
|
||||||
back.onclick = (e) => { if (e.target === back) back.remove(); };
|
back.onclick = (e) => { if (e.target === back) back.remove(); };
|
||||||
document.body.appendChild(back);
|
document.body.appendChild(back);
|
||||||
back.querySelector('#sheet-close').onclick = () => back.remove();
|
back.querySelector('#sheet-close').onclick = () => back.remove();
|
||||||
if (playable) {
|
if (playable) {
|
||||||
back.querySelector('#sheet-play').onclick = () => play(item, back.querySelector('.panel'));
|
const start = async (button) => {
|
||||||
if (playNow) play(item, back.querySelector('.panel'));
|
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',
|
||||||
|
'<p class="hint">Diese Serie hat keine Folgen, die sich abspielen ließen.</p>');
|
||||||
|
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
|
Erst „Als Nächstes": wer eine Serie anfängt und zurückkommt, meint die
|
||||||
der dritte: eine selbst zusammengebaute `master.m3u8`-Adresse. Die App sagt
|
Folge, bei der er stehengeblieben ist — nicht die erste. Das ist dieselbe
|
||||||
dazu seit jeher das Richtige — „die Adresse kommt vom Server, nicht von
|
Frage, die die Startseite mit `/Shows/NextUp` beantwortet.
|
||||||
uns. Er weiß, welche Spuren er kopieren kann und welche er neu rechnen
|
|
||||||
muss; eine selbst zusammengebaute Adresse wäre eine Vermutung darüber."
|
|
||||||
|
|
||||||
Ohne den Aushandlungsschritt fehlen `PlaySessionId`, die ausgehandelte
|
Erst wenn es die nicht gibt (nichts angefangen, oder alles gesehen), wird
|
||||||
`MediaSourceId` und die Adresse selbst. Der Server bekam eine Anfrage ohne
|
es die erste Folge. Eine Serie gar nicht abzuspielen war die falsche
|
||||||
Sitzung und ohne Gerät — und antwortete entsprechend.
|
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
|
try {
|
||||||
2. Die Quelle aus der Antwort wählen, nicht die Eintragskennung annehmen
|
const weiter = await api('/Shows/NextUp?' + new URLSearchParams({
|
||||||
3. Die Adresse des Servers nehmen
|
userId: S.userId, seriesId: seriesID, Limit: 1, Fields: FIELDS,
|
||||||
4. Die Sitzungskennung mitführen: für den Fortschritt und fürs Aufräumen */
|
}));
|
||||||
async function play(item, panel) {
|
const folge = weiter.Items?.[0];
|
||||||
panel.querySelector('video')?.remove();
|
// Bei einer Staffel muss die Folge auch zu ihr gehören.
|
||||||
panel.querySelector('.playerbar')?.remove();
|
if (folge && (item.Type !== 'Season' || folge.SeasonId === item.Id)) return folge;
|
||||||
panel.querySelector('.playerror')?.remove();
|
} 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
|
||||||
|
<video controls> des Browsers in einem Blatt — das ist kein Player, das ist
|
||||||
|
ein eingebettetes Vorschaufenster. Ein Film nimmt den Bildschirm ein.
|
||||||
|
|
||||||
|
Die Bedienung blendet nach drei Sekunden Ruhe aus und kommt bei jeder
|
||||||
|
Bewegung zurück, wie in der App. Tastatur: Leertaste hält an, Pfeile
|
||||||
|
spulen, Escape beendet. */
|
||||||
|
function openPlayer(item) {
|
||||||
|
document.getElementById('player')?.remove();
|
||||||
|
|
||||||
|
const layer = document.createElement('div');
|
||||||
|
layer.id = 'player';
|
||||||
|
layer.innerHTML = `
|
||||||
|
<video playsinline></video>
|
||||||
|
<div class="pl-chrome">
|
||||||
|
<div class="pl-top">
|
||||||
|
<button class="pl-icon" id="pl-close" aria-label="Schließen">✕</button>
|
||||||
|
<div class="pl-title">
|
||||||
|
<strong>${esc(item.SeriesName || item.Name)}</strong>
|
||||||
|
${item.SeriesName ? `<span>${esc(episodeLabel(item))}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pl-center">
|
||||||
|
<button class="pl-round" id="pl-back" aria-label="Zurückspulen">−${P.skipBackward}</button>
|
||||||
|
<button class="pl-round pl-play" id="pl-toggle" aria-label="Abspielen">▶</button>
|
||||||
|
<button class="pl-round" id="pl-fwd" aria-label="Vorspulen">+${P.skipForward}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pl-bottom">
|
||||||
|
<div class="pl-scrub">
|
||||||
|
<span id="pl-now">0:00</span>
|
||||||
|
<div class="pl-track" id="pl-track"><div class="pl-buf"></div><div class="pl-fill"></div><i></i></div>
|
||||||
|
<span id="pl-left">–:--</span>
|
||||||
|
</div>
|
||||||
|
<div class="pl-tools">
|
||||||
|
<button class="pl-icon" id="pl-mute" aria-label="Ton aus">🔊</button>
|
||||||
|
<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-full" aria-label="Vollbild">⛶</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pl-busy" id="pl-busy"><i></i></div>`;
|
||||||
|
document.body.appendChild(layer);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
const v = layer.querySelector('video');
|
||||||
|
const chrome = layer.querySelector('.pl-chrome');
|
||||||
|
const el2 = (id) => layer.querySelector('#' + id);
|
||||||
|
|
||||||
const fail = (satz, hinweis) => {
|
const fail = (satz, hinweis) => {
|
||||||
panel.querySelector('video')?.remove();
|
el2('pl-busy').classList.remove('on');
|
||||||
|
layer.querySelector('.pl-error')?.remove();
|
||||||
const box = document.createElement('div');
|
const box = document.createElement('div');
|
||||||
box.className = 'playerror';
|
box.className = 'pl-error';
|
||||||
box.innerHTML = `<strong>${esc(satz)}</strong>${hinweis ? `<span>${esc(hinweis)}</span>` : ''}`;
|
box.innerHTML = `<strong>${esc(satz)}</strong>${hinweis ? `<span>${esc(hinweis)}</span>` : ''}
|
||||||
panel.appendChild(box);
|
<button class="btn">Schließen</button>`;
|
||||||
|
box.querySelector('button').onclick = close;
|
||||||
|
layer.appendChild(box);
|
||||||
};
|
};
|
||||||
|
|
||||||
const v = document.createElement('video');
|
// --- Bedienung ein- und ausblenden ---
|
||||||
v.controls = true; v.autoplay = true; v.playsInline = true;
|
let hideTimer;
|
||||||
panel.appendChild(v);
|
const show = () => {
|
||||||
|
chrome.classList.add('on');
|
||||||
|
clearTimeout(hideTimer);
|
||||||
|
// Solange etwas steht, bleibt die Bedienung: sie auszublenden, während
|
||||||
|
// niemand spielt, versteckt den einzigen Knopf, der weiterhilft.
|
||||||
|
if (!v.paused) hideTimer = setTimeout(() => chrome.classList.remove('on'), 3000);
|
||||||
|
};
|
||||||
|
layer.addEventListener('mousemove', show);
|
||||||
|
layer.addEventListener('touchstart', show, { passive: true });
|
||||||
|
|
||||||
// **Fehler sichtbar machen.** Vorher endete jeder Fehlschlag in einem
|
// --- Transport ---
|
||||||
// stummen schwarzen Kasten: kein Ereignis abgehört, keine Meldung. Ein 401
|
const toggle = () => (v.paused ? v.play() : v.pause());
|
||||||
// sah aus wie ein 404 und wie ein Netzproblem.
|
el2('pl-toggle').onclick = toggle;
|
||||||
|
el2('pl-back').onclick = () => { v.currentTime = Math.max(0, v.currentTime - P.skipBackward); show(); };
|
||||||
|
el2('pl-fwd').onclick = () => { v.currentTime = Math.min(v.duration || 1e9, v.currentTime + P.skipForward); show(); };
|
||||||
|
v.addEventListener('click', toggle);
|
||||||
|
|
||||||
|
const paint = () => {
|
||||||
|
el2('pl-toggle').textContent = v.paused ? '▶' : '❚❚';
|
||||||
|
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'));
|
||||||
|
|
||||||
|
// --- Fortschrittsleiste ---
|
||||||
|
const track = el2('pl-track');
|
||||||
|
const clock = (sec) => {
|
||||||
|
if (!isFinite(sec)) return '–:--';
|
||||||
|
const s = Math.max(0, Math.floor(sec)), h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60), r = s % 60;
|
||||||
|
return (h ? `${h}:${String(m).padStart(2, '0')}` : `${m}`) + ':' + String(r).padStart(2, '0');
|
||||||
|
};
|
||||||
|
v.addEventListener('timeupdate', () => {
|
||||||
|
const teil = v.duration ? v.currentTime / v.duration : 0;
|
||||||
|
track.querySelector('.pl-fill').style.width = (teil * 100) + '%';
|
||||||
|
track.querySelector('i').style.left = (teil * 100) + '%';
|
||||||
|
el2('pl-now').textContent = clock(v.currentTime);
|
||||||
|
// Restzeit statt Gesamtlänge: „noch 42 Minuten" beantwortet die Frage,
|
||||||
|
// die man sich beim Schauen tatsächlich stellt.
|
||||||
|
el2('pl-left').textContent = '−' + clock((v.duration || 0) - v.currentTime);
|
||||||
|
});
|
||||||
|
v.addEventListener('progress', () => {
|
||||||
|
if (!v.buffered.length || !v.duration) return;
|
||||||
|
track.querySelector('.pl-buf').style.width =
|
||||||
|
(v.buffered.end(v.buffered.length - 1) / v.duration * 100) + '%';
|
||||||
|
});
|
||||||
|
|
||||||
|
const seekTo = (e) => {
|
||||||
|
const r = track.getBoundingClientRect();
|
||||||
|
const x = ((e.clientX ?? e.touches?.[0]?.clientX) - r.left) / r.width;
|
||||||
|
if (v.duration) v.currentTime = Math.min(Math.max(x, 0), 1) * v.duration;
|
||||||
|
show();
|
||||||
|
};
|
||||||
|
track.onclick = seekTo;
|
||||||
|
track.onmousedown = (e) => {
|
||||||
|
seekTo(e);
|
||||||
|
const move = (ev) => seekTo(ev);
|
||||||
|
const up = () => { removeEventListener('mousemove', move); removeEventListener('mouseup', up); };
|
||||||
|
addEventListener('mousemove', move); addEventListener('mouseup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Ton, Tempo, Vollbild ---
|
||||||
|
const vol = el2('pl-vol');
|
||||||
|
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 ? '🔇' : '🔊';
|
||||||
|
};
|
||||||
|
|
||||||
|
const rate = el2('pl-rate');
|
||||||
|
RATES.forEach((r) => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = r; o.textContent = r === 1 ? 'Normal' : r + '×'; o.selected = r === P.rate;
|
||||||
|
rate.appendChild(o);
|
||||||
|
});
|
||||||
|
rate.onchange = () => { v.playbackRate = +rate.value; };
|
||||||
|
|
||||||
|
el2('pl-full').onclick = () =>
|
||||||
|
document.fullscreenElement ? document.exitFullscreen() : layer.requestFullscreen?.();
|
||||||
|
|
||||||
|
// --- Tastatur ---
|
||||||
|
const keys = (e) => {
|
||||||
|
if (e.target.tagName === 'SELECT' || e.target.tagName === 'INPUT') return;
|
||||||
|
const map = {
|
||||||
|
' ': () => toggle(), k: () => toggle(),
|
||||||
|
ArrowLeft: () => { v.currentTime -= P.skipBackward; },
|
||||||
|
ArrowRight: () => { v.currentTime += P.skipForward; },
|
||||||
|
ArrowUp: () => { v.volume = Math.min(1, v.volume + 0.1); vol.value = v.volume; },
|
||||||
|
ArrowDown: () => { v.volume = Math.max(0, v.volume - 0.1); vol.value = v.volume; },
|
||||||
|
f: () => el2('pl-full').click(),
|
||||||
|
Escape: () => close(),
|
||||||
|
};
|
||||||
|
if (!map[e.key]) return;
|
||||||
|
e.preventDefault(); map[e.key](); show();
|
||||||
|
};
|
||||||
|
addEventListener('keydown', keys);
|
||||||
|
|
||||||
|
// --- Schließen ---
|
||||||
|
function close() {
|
||||||
|
removeEventListener('keydown', keys);
|
||||||
|
clearTimeout(hideTimer);
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen?.();
|
||||||
|
v.pause(); v.removeAttribute('src'); v.load();
|
||||||
|
layer.remove();
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
// Die Startseite kennt den neuen Fortschritt noch nicht.
|
||||||
|
if (current === 'home') renderHome();
|
||||||
|
}
|
||||||
|
el2('pl-close').onclick = close;
|
||||||
|
|
||||||
|
show();
|
||||||
|
el2('pl-busy').classList.add('on');
|
||||||
|
startStream(item, v, fail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Folgentitel in der Kopfzeile — „S2:E4 · Der Fall" statt nur „Der Fall". */
|
||||||
|
function episodeLabel(item) {
|
||||||
|
const nummer = item.ParentIndexNumber != null && item.IndexNumber != null
|
||||||
|
? `S${item.ParentIndexNumber}:E${item.IndexNumber}` : '';
|
||||||
|
return [nummer, item.Name].filter(Boolean).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handelt die Wiedergabe aus und hängt den Strom ins Element.
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Vier Schritte wie in der App: PlaybackInfo mit Geräteprofil, Quelle aus der
|
||||||
|
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) {
|
||||||
v.addEventListener('error', () => {
|
v.addEventListener('error', () => {
|
||||||
const c = v.error?.code;
|
const c = v.error?.code;
|
||||||
fail('Der Browser kann diesen Strom nicht abspielen.',
|
fail('Der Browser kann diesen Strom nicht abspielen.',
|
||||||
@@ -1419,26 +1731,17 @@ async function play(item, panel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const source = info.MediaSources?.[0];
|
const source = info.MediaSources?.[0];
|
||||||
if (!source) {
|
if (!source) return fail('Der Server nennt keine abspielbare Quelle.');
|
||||||
return fail('Der Server nennt keine abspielbare Quelle.',
|
|
||||||
'Bei einer Serie oder Staffel ist das richtig — dort gibt es nur Folgen zum Abspielen.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Die Adresse des Servers, nicht unsere. Sie trägt DeviceId, PlaySessionId
|
const url = source.TranscodingUrl
|
||||||
// und die ausgehandelten Codec-Angaben bereits in sich.
|
|
||||||
let url = source.TranscodingUrl
|
|
||||||
? new URL(source.TranscodingUrl, S.base).href
|
? new URL(source.TranscodingUrl, S.base).href
|
||||||
: client_url(`/Videos/${item.Id}/stream`, {
|
: streamURL(`/Videos/${item.Id}/stream`, {
|
||||||
static: 'true', mediaSourceId: source.Id || item.Id,
|
static: 'true', mediaSourceId: source.Id || item.Id,
|
||||||
api_key: S.token, PlaySessionId: info.PlaySessionId || '',
|
api_key: S.token, PlaySessionId: info.PlaySessionId || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
v.addEventListener('loadedmetadata', () => { v.playbackRate = P.rate; }, { once: true });
|
v.addEventListener('loadedmetadata', () => { v.playbackRate = P.rate; v.play(); }, { once: true });
|
||||||
panel.appendChild(playerBar(v));
|
wireProgress(v, { id: info.PlaySessionId, sourceID: source.Id || item.Id, itemID: item.Id });
|
||||||
|
|
||||||
// Fortschritt melden, damit „Weiterschauen" auch im Web etwas bedeutet.
|
|
||||||
const session = { id: info.PlaySessionId, sourceID: source.Id || item.Id, itemID: item.Id };
|
|
||||||
wireProgress(v, session);
|
|
||||||
|
|
||||||
if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = url; return; }
|
if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = url; return; }
|
||||||
|
|
||||||
@@ -1465,10 +1768,9 @@ async function play(item, panel) {
|
|||||||
/* Handelt mit dem Server aus, wie abgespielt wird.
|
/* Handelt mit dem Server aus, wie abgespielt wird.
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
Das Geräteprofil sagt, was dieses Ende kann; die Antwort sagt, was daraus
|
Das Geräteprofil sagt, was dieses Ende kann; die Antwort sagt, was daraus
|
||||||
folgt. Die Werte entsprechen denen der App (`playbackInfo` in
|
folgt. Leere DirectPlayProfiles heißt: nichts geht unverändert, rechne um.
|
||||||
JellyfinBackend+Playback.swift) — leere DirectPlayProfiles heißt: nichts
|
Im Browser ist das die ehrliche Auskunft — ein <video>-Element kann kein
|
||||||
geht unverändert, rechne um. Im Browser ist das die ehrliche Auskunft, denn
|
MKV und kein DTS. */
|
||||||
ein <video>-Element kann kein MKV und kein DTS. */
|
|
||||||
async function playbackInfo(item) {
|
async function playbackInfo(item) {
|
||||||
const profile = {
|
const profile = {
|
||||||
MaxStreamingBitrate: (P.quality > 0 ? P.quality : 20000) * 1000,
|
MaxStreamingBitrate: (P.quality > 0 ? P.quality : 20000) * 1000,
|
||||||
@@ -1481,39 +1783,33 @@ async function playbackInfo(item) {
|
|||||||
SubtitleProfiles: [{ Format: 'vtt', Method: 'Hls' }, { Format: 'srt', Method: 'External' }],
|
SubtitleProfiles: [{ Format: 'vtt', Method: 'Hls' }, { Format: 'srt', Method: 'External' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = {
|
|
||||||
DeviceProfile: profile,
|
|
||||||
UserId: S.userId,
|
|
||||||
MaxStreamingBitrate: profile.MaxStreamingBitrate,
|
|
||||||
EnableDirectPlay: false,
|
|
||||||
EnableDirectStream: false,
|
|
||||||
EnableTranscoding: true,
|
|
||||||
AutoOpenLiveStream: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
return api(`/Items/${item.Id}/PlaybackInfo?` + new URLSearchParams({ userId: S.userId }), {
|
return api(`/Items/${item.Id}/PlaybackInfo?` + new URLSearchParams({ userId: S.userId }), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify({
|
||||||
|
DeviceProfile: profile, UserId: S.userId,
|
||||||
|
MaxStreamingBitrate: profile.MaxStreamingBitrate,
|
||||||
|
EnableDirectPlay: false, EnableDirectStream: false,
|
||||||
|
EnableTranscoding: true, AutoOpenLiveStream: true,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Meldet dem Server, wo man steht.
|
/* Meldet dem Server, wo man steht.
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
Ohne das erscheint der Titel dort nie als „läuft gerade", und der
|
Ohne das erscheint der Titel dort nie als „läuft gerade", und der
|
||||||
Fortschritt wird nie gespeichert — „Weiterschauen" wäre im Web eine leere
|
Fortschritt wird nie gespeichert — „Weiterschauen" wäre im Web für immer
|
||||||
Reihe. Alle zehn Sekunden genügt; häufiger zu melden ändert nichts außer
|
eine leere Reihe. Alle zehn Sekunden genügt. */
|
||||||
der Last. */
|
|
||||||
function wireProgress(video, session) {
|
function wireProgress(video, session) {
|
||||||
const ticks = () => Math.round(video.currentTime * 10_000_000);
|
|
||||||
const send = (path, extra = {}) => {
|
const send = (path, extra = {}) => {
|
||||||
// Absichtlich ohne await und ohne catch: eine verlorene Meldung kostet ein
|
// Absichtlich ohne await: eine verlorene Meldung kostet ein paar Sekunden
|
||||||
// paar Sekunden Fortschritt. Dafür mitten im Film einen Fehler zu zeigen
|
// Fortschritt. Dafür mitten im Film einen Fehler zu zeigen wäre die
|
||||||
// wäre die schlechtere Hälfte des Tauschs.
|
// schlechtere Hälfte des Tauschs.
|
||||||
api(path, {
|
api(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
ItemId: session.itemID, PlaySessionId: session.id,
|
ItemId: session.itemID, PlaySessionId: session.id,
|
||||||
MediaSourceId: session.sourceID, PositionTicks: ticks(), ...extra,
|
MediaSourceId: session.sourceID,
|
||||||
|
PositionTicks: Math.round(video.currentTime * 10_000_000), ...extra,
|
||||||
}),
|
}),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
};
|
};
|
||||||
@@ -1532,37 +1828,13 @@ function wireProgress(video, session) {
|
|||||||
video.addEventListener('emptied', stop);
|
video.addEventListener('emptied', stop);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Die Spulknöpfe und die Tempowahl, die es im nackten <video> nicht gibt. */
|
|
||||||
function playerBar(video) {
|
|
||||||
const bar = document.createElement('div');
|
|
||||||
bar.className = 'playerbar';
|
|
||||||
bar.innerHTML = `
|
|
||||||
<button class="btn">−${P.skipBackward}s</button>
|
|
||||||
<button class="btn">+${P.skipForward}s</button>
|
|
||||||
<label>Tempo <select class="pick"></select></label>`;
|
|
||||||
|
|
||||||
const [back, fwd] = bar.querySelectorAll('button');
|
|
||||||
back.onclick = () => { video.currentTime = Math.max(0, video.currentTime - P.skipBackward); };
|
|
||||||
fwd.onclick = () => { video.currentTime = Math.min(video.duration || 1e9, video.currentTime + P.skipForward); };
|
|
||||||
|
|
||||||
const rate = bar.querySelector('select');
|
|
||||||
RATES.forEach((r) => {
|
|
||||||
const o = document.createElement('option');
|
|
||||||
o.value = r; o.textContent = r === 1 ? 'Normal' : r + '×'; o.selected = r === P.rate;
|
|
||||||
rate.appendChild(o);
|
|
||||||
});
|
|
||||||
// Nur für diese Wiedergabe — die Voreinstellung bleibt, was sie ist.
|
|
||||||
rate.onchange = () => { video.playbackRate = +rate.value; };
|
|
||||||
return bar;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Eine Adresse mit Anmeldung im Anhang.
|
/* Eine Adresse mit Anmeldung im Anhang.
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
Für alles, was nicht `api()` abruft, sondern das <video>-Element selbst:
|
Für alles, was nicht `api()` abruft, sondern das <video>-Element selbst:
|
||||||
es schickt keine Kopfzeilen mit. Genau denselben Umweg geht die App. */
|
es schickt keine Kopfzeilen mit. Denselben Umweg geht die App. */
|
||||||
function client_url(path, query) {
|
function streamURL(path, query) {
|
||||||
const u = new URL(S.base + path);
|
const u = new URL(S.base + path);
|
||||||
Object.entries(query).forEach(([k, v]) => v !== '' && u.searchParams.set(k, v));
|
Object.entries(query).forEach(([k, val]) => val !== '' && u.searchParams.set(k, val));
|
||||||
return u.href;
|
return u.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user