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 ? '▶ Abspielen ' : ''}
+ ${playable ? `▶ ${isFolder ? 'Weiterschauen' : 'Abspielen'} ` : ''}
Schließen
- ${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
+ 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 = `
+
+
+
+
✕
+
+ ${esc(item.SeriesName || item.Name)}
+ ${item.SeriesName ? `${esc(episodeLabel(item))} ` : ''}
+
+
+
+
+ −${P.skipBackward}
+ ▶
+ +${P.skipForward}
+
+
+
+
+
+ 🔊
+
+
+ Tempo
+ ⛶
+
+
+
+
`;
+ 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 = `${esc(satz)} ${hinweis ? `${esc(hinweis)} ` : ''}`;
- panel.appendChild(box);
+ box.className = 'pl-error';
+ box.innerHTML = `${esc(satz)} ${hinweis ? `${esc(hinweis)} ` : ''}
+ Schließen `;
+ 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 -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 -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 nicht gibt. */
-function playerBar(video) {
- const bar = document.createElement('div');
- bar.className = 'playerbar';
- bar.innerHTML = `
- −${P.skipBackward}s
- +${P.skipForward}s
- Tempo `;
-
- 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 -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;
}