Das Web spielt jetzt ab — und sagt, wenn es nicht geht
Es fehlte der erste Schritt. Die Stream-Adresse wurde selbst
zusammengebaut, und damit fehlten PlaySessionId, die ausgehandelte
MediaSourceId und die Adresse, die der Server eigentlich liefert. Im
Swift-Code steht seit jeher, warum das nicht geht: "eine selbst
zusammengebaute Adresse wäre eine Vermutung darüber."
Jetzt vier Schritte wie in der App: PlaybackInfo mit Geräteprofil, die
Quelle aus der Antwort wählen, die Adresse des Servers nehmen, die
Sitzungskennung mitführen.
Vor allem aber sind Fehler nicht mehr unsichtbar. Vorher endete jeder
Fehlschlag in einem stummen schwarzen Kasten — ein 401 sah aus wie ein
404 und wie ein Netzproblem. Geprüft: Ablehnung, fehlende Quelle und
nicht abspielbares Format nennen jetzt alle drei Ross und Reiter.
Serien und Staffeln haben keinen Abspielknopf mehr. Sie sind Ordner;
/Videos/{serienId} zeigt auf nichts. Wer über eine Serienkachel getestet
hat, konnte gar nichts starten.
Dazu Fortschrittsmeldung an /Sessions/Playing — ohne die erscheint der
Titel auf dem Server nie als "läuft gerade", und "Weiterschauen" bliebe
im Web für immer leer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
14e1fdbe3f
commit
ecfa454136
+298
-18
@@ -238,6 +238,20 @@ img { display: block; }
|
||||
#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 {
|
||||
display: flex; align-items: center; gap: var(--s-regular);
|
||||
margin-top: var(--s-regular); flex-wrap: wrap;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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
|
||||
@@ -318,6 +332,15 @@ img { display: block; }
|
||||
}
|
||||
.tiny:hover:not(:disabled) { color: var(--text); }
|
||||
.tiny:disabled { opacity: .3; cursor: default; }
|
||||
.pick {
|
||||
background: var(--elevated); border: 1px solid var(--separator);
|
||||
border-radius: var(--r-control); color: var(--text);
|
||||
padding: 9px var(--s-regular); font-family: inherit; font-size: 14px; width: 100%;
|
||||
}
|
||||
.pick:focus { outline: none; border-color: var(--accent); }
|
||||
.pickrow { display: flex; gap: var(--s-regular); flex-wrap: wrap; }
|
||||
.pickrow label { flex: 1; min-width: 160px; font-size: 13px; color: var(--text-2); }
|
||||
.pickrow .pick { margin-top: 6px; }
|
||||
.setting {
|
||||
background: var(--surface); border: 1px solid var(--separator);
|
||||
border-radius: var(--r-sheet); padding: var(--s-loose); margin-bottom: var(--s-regular);
|
||||
@@ -533,8 +556,31 @@ const P = {
|
||||
// steht — dieselbe Aufteilung wie `Arrangement` in der App.
|
||||
hiddenRows: JSON.parse(localStorage.getItem('flixy.hiddenRows') || '[]'),
|
||||
rowOrder: JSON.parse(localStorage.getItem('flixy.rowOrder') || '[]'),
|
||||
// Wiedergabe — dieselben Stufen wie in der App, damit derselbe Begriff
|
||||
// überall dasselbe heißt.
|
||||
rate: +(localStorage.getItem('flixy.rate') || 1.0),
|
||||
skipForward: +(localStorage.getItem('flixy.skipForward') || 30),
|
||||
skipBackward: +(localStorage.getItem('flixy.skipBackward') || 10),
|
||||
quality: +(localStorage.getItem('flixy.quality') || 0), // 0 = Original
|
||||
// Sprachen. Leer heißt: was der Server vorgibt.
|
||||
audioLanguage: localStorage.getItem('flixy.audioLanguage') || '',
|
||||
subtitleLanguage: localStorage.getItem('flixy.subtitleLanguage') || '',
|
||||
};
|
||||
|
||||
/// Die Stufen der App, damit die Begriffe sich decken.
|
||||
const RATES = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
|
||||
const SKIPS = [5, 10, 15, 30, 45];
|
||||
const QUALITIES = [
|
||||
[0, 'Original'], [12000, 'Hoch — 12 Mbit/s'], [8000, 'Mittel — 8 Mbit/s'],
|
||||
[4000, 'Niedrig — 4 Mbit/s'], [2000, 'Sparsam — 2 Mbit/s'],
|
||||
];
|
||||
/// Sprachen als ISO-639-2, wie Jellyfin sie führt.
|
||||
const LANGUAGES = [
|
||||
['', 'Serverstandard'], ['ger', 'Deutsch'], ['eng', 'Englisch'],
|
||||
['fre', 'Französisch'], ['spa', 'Spanisch'], ['ita', 'Italienisch'],
|
||||
['jpn', 'Japanisch'], ['kor', 'Koreanisch'],
|
||||
];
|
||||
|
||||
function applyPrefs() {
|
||||
const r = document.documentElement.style;
|
||||
r.setProperty('--accent', P.accent);
|
||||
@@ -860,6 +906,43 @@ function renderSettings() {
|
||||
<div id="set-rows"></div>
|
||||
</div>
|
||||
|
||||
<h3 class="group">Wiedergabe</h3>
|
||||
|
||||
<div class="setting">
|
||||
<h4>Geschwindigkeit</h4>
|
||||
<p>Womit ein Titel beginnt. Im Player lässt sie sich jederzeit ändern.</p>
|
||||
<select id="set-rate" class="pick"></select>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<h4>Spulweite</h4>
|
||||
<p>Wie weit die beiden Knöpfe im Player springen.</p>
|
||||
<div class="pickrow">
|
||||
<label>Zurück <select id="set-skipback" class="pick"></select></label>
|
||||
<label>Vor <select id="set-skipfwd" class="pick"></select></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<h4>Qualität</h4>
|
||||
<p>„Original“ schickt die Datei unverändert, alles andere lässt den
|
||||
Server umrechnen. Die App unterscheidet hier WLAN und Mobilfunk —
|
||||
im Browser lässt sich das nicht verlässlich feststellen, deshalb
|
||||
gibt es eine Stufe.</p>
|
||||
<select id="set-quality" class="pick"></select>
|
||||
</div>
|
||||
|
||||
<h3 class="group">Sprachen</h3>
|
||||
|
||||
<div class="setting">
|
||||
<h4>Bevorzugte Sprachen</h4>
|
||||
<p>Welche Spur beim Start gewählt wird, sofern der Titel sie mitbringt.</p>
|
||||
<div class="pickrow">
|
||||
<label>Ton <select id="set-audio" class="pick"></select></label>
|
||||
<label>Untertitel <select id="set-subs" class="pick"></select></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="group">Server</h3>
|
||||
|
||||
<div class="setting">
|
||||
@@ -906,6 +989,25 @@ function renderSettings() {
|
||||
|
||||
renderRowArrangement();
|
||||
|
||||
// Auswahlfelder, die alle gleich funktionieren.
|
||||
const choose = (id, key, options) => {
|
||||
const sel = el(id);
|
||||
sel.innerHTML = '';
|
||||
options.forEach(([value, text]) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = value; o.textContent = text;
|
||||
o.selected = String(value) === String(P[key]);
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.onchange = () => setPref(key, typeof P[key] === 'number' ? +sel.value : sel.value);
|
||||
};
|
||||
choose('set-rate', 'rate', RATES.map((r) => [r, r === 1 ? 'Normal' : r + '×']));
|
||||
choose('set-skipback', 'skipBackward', SKIPS.map((n) => [n, n + ' s']));
|
||||
choose('set-skipfwd', 'skipForward', SKIPS.map((n) => [n, n + ' s']));
|
||||
choose('set-quality', 'quality', QUALITIES);
|
||||
choose('set-audio', 'audioLanguage', LANGUAGES);
|
||||
choose('set-subs', 'subtitleLanguage', LANGUAGES);
|
||||
|
||||
const z = el('set-zoom'), zo = el('set-zoom-out');
|
||||
z.value = P.cardW; zo.textContent = P.cardW + ' px';
|
||||
z.oninput = () => { zo.textContent = z.value + ' px'; setPref('cardW', +z.value); };
|
||||
@@ -1242,6 +1344,9 @@ 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);
|
||||
const bits = [item.ProductionYear, runtime(item), item.OfficialRating].filter(Boolean);
|
||||
back.innerHTML = `
|
||||
<div class="panel">
|
||||
@@ -1249,41 +1354,216 @@ function openSheet(item, playNow) {
|
||||
<div class="meta">${bits.join(' · ')}</div>
|
||||
<p>${esc(item.Overview || '')}</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-primary" id="sheet-play">▶ Abspielen</button>
|
||||
${playable ? '<button class="btn btn-primary" id="sheet-play">▶ 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();
|
||||
back.querySelector('#sheet-play').onclick = () => play(item, back.querySelector('.panel'));
|
||||
if (playNow) play(item, back.querySelector('.panel'));
|
||||
if (playable) {
|
||||
back.querySelector('#sheet-play').onclick = () => play(item, back.querySelector('.panel'));
|
||||
if (playNow) play(item, back.querySelector('.panel'));
|
||||
}
|
||||
}
|
||||
|
||||
/* HLS, weil ein Live-Remux zuverlässiger ist als die Hoffnung, dass der
|
||||
Browser den Container auf der Platte versteht. Safari kann HLS von Haus
|
||||
aus; alle anderen bekommen hls.js. */
|
||||
function play(item, panel) {
|
||||
/* Spielt einen Titel ab — auf dem Weg, den der Server vorgibt.
|
||||
----------------------------------------------------------------------------
|
||||
**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."
|
||||
|
||||
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.
|
||||
|
||||
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();
|
||||
|
||||
const fail = (satz, hinweis) => {
|
||||
panel.querySelector('video')?.remove();
|
||||
const box = document.createElement('div');
|
||||
box.className = 'playerror';
|
||||
box.innerHTML = `<strong>${esc(satz)}</strong>${hinweis ? `<span>${esc(hinweis)}</span>` : ''}`;
|
||||
panel.appendChild(box);
|
||||
};
|
||||
|
||||
const v = document.createElement('video');
|
||||
v.controls = true; v.autoplay = true; v.playsInline = true;
|
||||
panel.appendChild(v);
|
||||
|
||||
const url = `${S.base}/Videos/${item.Id}/master.m3u8?` + new URLSearchParams({
|
||||
MediaSourceId: item.Id, api_key: S.token, VideoCodec: 'h264',
|
||||
AudioCodec: 'aac,mp3', TranscodingMaxAudioChannels: '2',
|
||||
SegmentContainer: 'ts', MinSegments: '1', BreakOnNonKeyFrames: '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.
|
||||
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.`);
|
||||
});
|
||||
|
||||
let info;
|
||||
try {
|
||||
info = await playbackInfo(item);
|
||||
} catch (e) {
|
||||
return fail('Der Server hat die Wiedergabe abgelehnt.', e.message);
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
// Die Adresse des Servers, nicht unsere. Sie trägt DeviceId, PlaySessionId
|
||||
// und die ausgehandelten Codec-Angaben bereits in sich.
|
||||
let url = source.TranscodingUrl
|
||||
? new URL(source.TranscodingUrl, S.base).href
|
||||
: client_url(`/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);
|
||||
|
||||
if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = url; return; }
|
||||
const boot = () => { const h = new Hls(); h.loadSource(url); h.attachMedia(v); };
|
||||
|
||||
const boot = () => {
|
||||
const h = new Hls();
|
||||
h.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (!data.fatal) return;
|
||||
fail('Die Wiedergabe ist abgebrochen.',
|
||||
`${data.type}: ${data.details}` + (data.response?.code ? ` (HTTP ${data.response.code})` : ''));
|
||||
h.destroy();
|
||||
});
|
||||
h.loadSource(url); h.attachMedia(v);
|
||||
};
|
||||
if (window.Hls) return boot();
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.13/hls.min.js';
|
||||
s.onload = boot;
|
||||
s.onerror = () => { v.replaceWith(Object.assign(document.createElement('p'),
|
||||
{ textContent: 'Wiedergabe braucht hls.js — und das ließ sich nicht laden.' })); };
|
||||
document.head.appendChild(s);
|
||||
|
||||
const tag = document.createElement('script');
|
||||
tag.src = 'https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.13/hls.min.js';
|
||||
tag.onload = boot;
|
||||
tag.onerror = () => fail('Die Wiedergabe braucht hls.js.',
|
||||
'Das Skript ließ sich nicht laden — ohne Internetzugang oder mit einem Blocker davor geht es nicht.');
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
async function playbackInfo(item) {
|
||||
const profile = {
|
||||
MaxStreamingBitrate: (P.quality > 0 ? P.quality : 20000) * 1000,
|
||||
DirectPlayProfiles: [],
|
||||
TranscodingProfiles: [{
|
||||
Type: 'Video', Container: 'ts', Protocol: 'hls', VideoCodec: 'h264',
|
||||
AudioCodec: 'aac,mp3', Context: 'Streaming', MaxAudioChannels: '2',
|
||||
MinSegments: 1, BreakOnNonKeyFrames: true,
|
||||
}],
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
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.
|
||||
api(path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
ItemId: session.itemID, PlaySessionId: session.id,
|
||||
MediaSourceId: session.sourceID, PositionTicks: ticks(), ...extra,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
video.addEventListener('playing', () => send('/Sessions/Playing'), { once: true });
|
||||
|
||||
let last = 0;
|
||||
video.addEventListener('timeupdate', () => {
|
||||
if (video.currentTime - last < 10) return;
|
||||
last = video.currentTime;
|
||||
send('/Sessions/Playing/Progress', { IsPaused: video.paused });
|
||||
});
|
||||
|
||||
const stop = () => send('/Sessions/Playing/Stopped');
|
||||
video.addEventListener('ended', 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.
|
||||
----------------------------------------------------------------------------
|
||||
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) {
|
||||
const u = new URL(S.base + path);
|
||||
Object.entries(query).forEach(([k, v]) => v !== '' && u.searchParams.set(k, v));
|
||||
return u.href;
|
||||
}
|
||||
|
||||
/* ---------- Kleinkram ---------- */
|
||||
|
||||
Reference in New Issue
Block a user