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:
Scarriffle
2026-09-14 17:38:53 +02:00
co-authored by Claude Opus 5
parent ecfa454136
commit 1db70379d7
+382 -110
View File
@@ -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 = `
<div class="panel">
@@ -1354,56 +1460,262 @@ function openSheet(item, playNow) {
<div class="meta">${bits.join(' · ')}</div>
<p>${esc(item.Overview || '')}</p>
<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>
</div>
${playable ? '' : '<p class="hint">Eine Serie spielt man nicht ab — öffne eine Folge.</p>'}
</div>`;
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',
'<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
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
<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) => {
panel.querySelector('video')?.remove();
el2('pl-busy').classList.remove('on');
layer.querySelector('.pl-error')?.remove();
const box = document.createElement('div');
box.className = 'playerror';
box.innerHTML = `<strong>${esc(satz)}</strong>${hinweis ? `<span>${esc(hinweis)}</span>` : ''}`;
panel.appendChild(box);
box.className = 'pl-error';
box.innerHTML = `<strong>${esc(satz)}</strong>${hinweis ? `<span>${esc(hinweis)}</span>` : ''}
<button class="btn">Schließen</button>`;
box.querySelector('button').onclick = close;
layer.appendChild(box);
};
const v = document.createElement('video');
v.controls = true; v.autoplay = true; v.playsInline = true;
panel.appendChild(v);
// --- Bedienung ein- und ausblenden ---
let hideTimer;
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
// stummen schwarzen Kasten: kein Ereignis abgehört, keine Meldung. Ein 401
// sah aus wie ein 404 und wie ein Netzproblem.
// --- Transport ---
const toggle = () => (v.paused ? v.play() : v.pause());
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', () => {
const c = v.error?.code;
fail('Der Browser kann diesen Strom nicht abspielen.',
@@ -1419,26 +1731,17 @@ async function play(item, panel) {
}
const source = info.MediaSources?.[0];
if (!source) {
return fail('Der Server nennt keine abspielbare Quelle.',
'Bei einer Serie oder Staffel ist das richtig — dort gibt es nur Folgen zum Abspielen.');
}
if (!source) return fail('Der Server nennt keine abspielbare Quelle.');
// Die Adresse des Servers, nicht unsere. Sie trägt DeviceId, PlaySessionId
// und die ausgehandelten Codec-Angaben bereits in sich.
let url = source.TranscodingUrl
const url = source.TranscodingUrl
? 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,
api_key: S.token, PlaySessionId: info.PlaySessionId || '',
});
v.addEventListener('loadedmetadata', () => { v.playbackRate = P.rate; }, { once: true });
panel.appendChild(playerBar(v));
// 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);
v.addEventListener('loadedmetadata', () => { v.playbackRate = P.rate; 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; }
@@ -1465,10 +1768,9 @@ async function play(item, panel) {
/* Handelt mit dem Server aus, wie abgespielt wird.
----------------------------------------------------------------------------
Das Geräteprofil sagt, was dieses Ende kann; die Antwort sagt, was daraus
folgt. Die Werte entsprechen denen der App (`playbackInfo` in
JellyfinBackend+Playback.swift) — leere DirectPlayProfiles heißt: nichts
geht unverändert, rechne um. Im Browser ist das die ehrliche Auskunft, denn
ein <video>-Element kann kein MKV und kein DTS. */
folgt. Leere DirectPlayProfiles heißt: nichts geht unverändert, rechne um.
Im Browser ist das die ehrliche Auskunft — ein <video>-Element kann kein
MKV und kein DTS. */
async function playbackInfo(item) {
const profile = {
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' }],
};
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 }), {
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.
----------------------------------------------------------------------------
Ohne das erscheint der Titel dort nie als „läuft gerade", und der
Fortschritt wird nie gespeichert — „Weiterschauen" wäre im Web eine leere
Reihe. Alle zehn Sekunden genügt; häufiger zu melden ändert nichts außer
der Last. */
Fortschritt wird nie gespeichert — „Weiterschauen" wäre im Web für immer
eine leere Reihe. Alle zehn Sekunden genügt. */
function wireProgress(video, session) {
const ticks = () => Math.round(video.currentTime * 10_000_000);
const send = (path, extra = {}) => {
// Absichtlich ohne await und ohne catch: eine verlorene Meldung kostet ein
// paar Sekunden Fortschritt. Dafür mitten im Film einen Fehler zu zeigen
// wäre die schlechtere Hälfte des Tauschs.
// Absichtlich ohne await: eine verlorene Meldung kostet ein paar Sekunden
// Fortschritt. Dafür mitten im Film einen Fehler zu zeigen wäre die
// schlechtere Hälfte des Tauschs.
api(path, {
method: 'POST',
body: JSON.stringify({
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(() => {});
};
@@ -1532,37 +1828,13 @@ function wireProgress(video, session) {
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.
----------------------------------------------------------------------------
Für alles, was nicht `api()` abruft, sondern das <video>-Element selbst:
es schickt keine Kopfzeilen mit. Genau denselben Umweg geht die App. */
function client_url(path, query) {
es schickt keine Kopfzeilen mit. Denselben Umweg geht die App. */
function streamURL(path, query) {
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;
}