Eine Folge weiter, eine zurück — ohne den Spieler zu verlassen

Wer eine Serie schaut, will blättern können. Bisher gab es genau einen
Knopf dafür, und der erschien erst am Ende: „Nächste Folge", wenn der
Abspann durchgelaufen war. Zurück ging gar nicht — nur über die
Serienseite, und die beendet die Wiedergabe.

Jetzt stehen beide Knöpfe in der Bedienleiste, in der App wie im Web,
links und rechts vom Abspielknopf. Nur bei Serien: bei einem Film
antwortet der Server mit 404, bei der letzten Folge mit 204, und beides
heisst hier „kein Knopf". Sie erscheinen also von selbst nur dort, wo
sie etwas tun.

Der Server kannte nur den Weg vorwärts. Jetzt gibt es `/previous`
daneben, und beide Richtungen kommen aus derselben Rechnung — als zwei
Endpunkte abgeschrieben liefen sie auseinander, sobald einer etwas
dazubekommt. Jellyfin hat kein Gegenstück zu „NextUp"; dort entsteht
die vorige Folge aus der Folgenliste der Serie.

Beim Wechsel gilt weiter, was für die Serie an Sprache eingestellt ist.
Ohne das spränge die nächste Folge auf die erste Tonspur der Datei
zurück — bei deutschen Veröffentlichungen also auf Deutsch, mitten in
einer Serie, die man im Original schaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-12 21:11:09 +02:00
co-authored by Claude Opus 5
parent bcbe5557d7
commit acba683c94
11 changed files with 319 additions and 55 deletions
+19
View File
@@ -117,6 +117,8 @@ export type Folge = {
id: string; season: number; episode: number;
title: string | null; overview: string | null; stillImage: string | null; files: Datei[];
state: Stand | null;
/** Zu welcher Serie sie gehört — für die Sprachwahl der Serie. */
seriesId?: string | null;
/** Wann sie freigegeben ist. Liegt das in der Zukunft, gibt es sie noch nicht. */
publishAt?: string | null;
};
@@ -623,6 +625,23 @@ export const weiterschauen = (take = 20) =>
export const naechsteFolgen = (take = 20) =>
anfrage<Seite<Angefangen>>(`/home/next${abfrage({ take })}`);
/** Die Folge vor oder nach dieser — oder keine.
*
* **Alles, was schiefgehen kann, heisst hier „keine".** Der Aufrufer zeigt
* daraufhin einen Knopf nicht an; mehr hängt nicht daran. Eine 404 heisst
* „das ist eine Folge nicht" (ein Film etwa), eine 204 „es gibt keine
* weitere", und ein Aussetzer im Netz soll keinen Fehlerkasten über einem
* laufenden Film öffnen. */
export async function nachbarfolge(
folgeId: string, richtung: 'next' | 'previous',
): Promise<Folge | null> {
try {
return (await anfrage<Folge | undefined>(`/episodes/${folgeId}/${richtung}`)) ?? null;
} catch {
return null;
}
}
/** Meldet, wo man steht. Jede Meldung zählt — es gibt keine zu kleine. */
/** Meldet, wo man steht. Jede Meldung zählt — es gibt keine zu kleine.
*
+5
View File
@@ -37,6 +37,11 @@ export const icons = {
+ '<path d="M19.4 14.5a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-1.8-.3 1.6 1.6 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-1-1.5 1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0 .3-1.8 1.6 1.6 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.5-1 1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 1.8.3H9a1.6 1.6 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 1 1.5 1.6 1.6 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8V9a1.6 1.6 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1z"/>'),
suche: rahmen('<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>'),
abspielen: rahmen('<path d="M8 5.5v13l11-6.5z"/>', true),
// Dieselbe Form wie „abspielen", mit einem Balken davor beziehungsweise
// dahinter: das ist das Zeichen, das jeder Spieler für „eine weiter"
// benutzt, und es muss nicht erklärt werden.
folgeVor: rahmen('<path d="M6 5.5v13l10-6.5z"/><rect x="17" y="5.5" width="2.6" height="13" rx="1"/>', true),
folgeZurueck: rahmen('<path d="M18 5.5v13L8 12z"/><rect x="4.4" y="5.5" width="2.6" height="13" rx="1"/>', true),
haken: rahmen('<path d="m5 12.5 4.5 4.5L19 7"/>'),
hakenKreis: rahmen('<circle cx="12" cy="12" r="9"/><path d="m8 12.3 2.8 2.7L16 9.6"/>'),
// Kreisbogen mit Pfeilspitze — das gewohnte Zeichen fürs Neuholen.
+76 -3
View File
@@ -1,9 +1,10 @@
import type HlsTyp from 'hls.js';
import {
stromStarten, stromBeenden, untertitelHolen, fortschritt,
type Stromauskunft, type Spurauskunft,
stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge,
type Stromauskunft, type Spurauskunft, type Folge,
} from './api';
import { dauer, knopf, meldung, hinweisZeigen } from './util';
import { serienton, serientext, spurFuer } from './sprachwahl';
import {
GROESSE_GROESST, GROESSE_KLEINST, GROESSE_SCHRITT, HINTERGRUENDE, SCHRIFTFARBEN,
stilAnwenden, stilGeaendert, stilLesen, stilSchreiben, stilVorgabe,
@@ -596,9 +597,27 @@ export function spieler(anfrage: Spieleranfrage): void {
//
// Jetzt stehen sie dort, wo man hinsieht: links und rechts vom Knopf, den
// man drückt.
/** Die Nachbarfolgen, sobald der Server sie genannt hat. */
let vorige: Folge | null = null;
let naechste: Folge | null = null;
// **Eine Folge weiter, eine zurück.**
//
// Nur bei Serien, und nur wo es eine gibt: bei einem Film fragt der Server
// mit 404, bei der letzten Folge mit 204 — beides heisst hier „kein
// Knopf". Erscheinen tun sie also von selbst nur dort, wo sie etwas tun.
//
// Die Knöpfe werden schon jetzt gebaut und bloss versteckt: sie
// nachträglich zwischen die bestehenden zu schieben hiesse, sich auf eine
// Reihenfolge zu verlassen, die anderswo geändert wird.
const vorigeKnopf = rundknopf('folgeZurueck', 'Vorige Folge', () => void folgeWechseln(vorige));
const naechsteKnopf = rundknopf('folgeVor', 'Nächste Folge', () => void folgeWechseln(naechste));
vorigeKnopf.classList.add('hidden');
naechsteKnopf.classList.add('hidden');
const zeitmitte = document.createElement('div');
zeitmitte.className = 'pl-mitte';
zeitmitte.append(stand, abspielknopf, rest);
zeitmitte.append(stand, vorigeKnopf, abspielknopf, naechsteKnopf, rest);
const rechts = document.createElement('div');
rechts.className = 'pl-gruppe';
@@ -610,6 +629,60 @@ export function spieler(anfrage: Spieleranfrage): void {
bedienung.append(zeitleiste, unten);
// **Beide Nachbarn gleich zu Beginn holen.** Zwei kleine Anfragen, einmal
// je Wiedergabe. Erst beim Antippen zu fragen hiesse, einen Knopf zu
// zeigen, von dem noch niemand weiss, ob er etwas tut.
void (async () => {
[vorige, naechste] = await Promise.all([
nachbarfolge(anfrage.titelId, 'previous'),
nachbarfolge(anfrage.titelId, 'next'),
]);
if (geschlossen) return;
vorigeKnopf.classList.toggle('hidden', vorige?.files[0] == null);
naechsteKnopf.classList.toggle('hidden', naechste?.files[0] == null);
})();
/** Wechselt auf eine Nachbarfolge.
*
* **Ein neuer Spieler, kein Umbau des alten.** `spieler()` schliesst den
* offenen zuerst — Strom beenden, Stand melden, aufräumen —, und das ist
* genau, was beim Folgenwechsel passieren soll. Die Datei im laufenden
* Spieler auszutauschen hiesse, dieselbe Abfolge ein zweites Mal zu
* schreiben, nur unvollständig.
*/
function folgeWechseln(ziel: Folge | null): void {
const datei = ziel?.files[0];
if (!ziel || !datei) return;
// Angefangen heisst weiterschauen, sonst von vorn: wer vorblättert,
// will die Folge sehen, nicht ihren Abspann.
const stelle = ziel.state?.inProgress ? (ziel.state.positionSeconds ?? 0) : 0;
// **Die Sprache der Serie gilt weiter.** Die Spurnummern unterscheiden
// sich von Folge zu Folge; gemerkt ist die Sprache, und hier wird sie
// zur Nummer dieser Datei. Ohne das spränge die nächste Folge auf die
// erste Tonspur der Datei zurück — bei deutschen Veröffentlichungen
// also auf Deutsch, mitten in einer Serie, die man im Original schaut.
const serie = ziel.seriesId ?? null;
const ton = serie
? spurFuer(datei.streams.filter(s => s.kind === 'Audio'), serienton(serie))
: null;
const text = serie
? spurFuer(datei.streams.filter(s => s.kind === 'Subtitle'), serientext(serie))
: null;
spieler({
dateiId: datei.id,
titelId: ziel.id,
name: `S${ziel.season} E${ziel.episode}${ziel.title ? ` · ${ziel.title}` : ''}`,
ab: stelle,
laenge: datei.durationSeconds,
tonspur: ton,
untertitelspur: text,
danach: anfrage.danach,
});
}
/* ---------- Verhalten ---------- */
video.onloadedmetadata = () => {