diff --git a/Flaro/Resources/Localizable.xcstrings b/Flaro/Resources/Localizable.xcstrings
index 48ad665..b48e8c9 100644
--- a/Flaro/Resources/Localizable.xcstrings
+++ b/Flaro/Resources/Localizable.xcstrings
@@ -4237,6 +4237,22 @@
}
}
},
+ "player.previousEpisode" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vorige Folge"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Previous episode"
+ }
+ }
+ }
+ },
"player.quality" : {
"localizations" : {
"de" : {
diff --git a/Flaro/Views/Player/PlayerModel.swift b/Flaro/Views/Player/PlayerModel.swift
index 8b79b09..e831ae1 100644
--- a/Flaro/Views/Player/PlayerModel.swift
+++ b/Flaro/Views/Player/PlayerModel.swift
@@ -64,6 +64,9 @@ final class PlayerModel {
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
/// damit sie am Ende ohne Wartezeit bereitsteht.
private(set) var nextEpisode: MediaItem?
+ /// Die Folge davor — für den Rückwärtsknopf. `nil` heisst: es ist die
+ /// erste, oder es ist gar keine Folge.
+ private(set) var previousEpisode: MediaItem?
private(set) var hasReachedEnd = false
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
@@ -1823,21 +1826,43 @@ final class PlayerModel {
if shouldSkip { skipActiveMarker() }
}
+ /// **Beide Nachbarn, nicht nur der nächste.**
+ ///
+ /// Die nächste Folge wurde schon immer vorgeladen — für den Knopf am
+ /// Ende. Seit es im Spieler Knöpfe zum Blättern gibt, gilt dasselbe für
+ /// die vorige: wer eine zu weit gesprungen ist, soll zurück können, ohne
+ /// den Spieler zu verlassen.
private func preloadNextEpisode() {
guard let item, item.kind == .episode, let backend else { return }
Task { [weak self] in
let next = try? await backend.nextEpisode(after: item)
self?.nextEpisode = next
}
+ Task { [weak self] in
+ let vorige = try? await backend.previousEpisode(before: item)
+ self?.previousEpisode = vorige
+ }
}
/// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den
/// Koordinator weiter.
- func nextEpisodeRequest() -> PlaybackRequest? {
- guard let nextEpisode else { return nil }
+ func nextEpisodeRequest() -> PlaybackRequest? { wunsch(für: nextEpisode) }
+
+ /// Dasselbe rückwärts.
+ func previousEpisodeRequest() -> PlaybackRequest? { wunsch(für: previousEpisode) }
+
+ /// **Von vorn, nicht von der gemerkten Stelle.**
+ ///
+ /// Wer blättert, will die Folge sehen. Beim Vorwärtsblättern ist sie
+ /// ohnehin meist ungesehen; beim Zurückblättern stünde man sonst im
+ /// Abspann der Folge, die man gerade noch einmal anfangen wollte. Wer
+ /// fortsetzen will, nimmt den Weg über die Serienseite — dort steht die
+ /// Stelle dabei.
+ private func wunsch(für folge: MediaItem?) -> PlaybackRequest? {
+ guard let folge else { return nil }
return PlaybackRequest(
- ratingKey: nextEpisode.id,
- title: nextEpisode.playbackTitle,
+ ratingKey: folge.id,
+ title: folge.playbackTitle,
mediaIndex: 0,
startTime: .zero
)
diff --git a/Flaro/Views/Player/PlayerView.swift b/Flaro/Views/Player/PlayerView.swift
index e072bd0..8992e70 100644
--- a/Flaro/Views/Player/PlayerView.swift
+++ b/Flaro/Views/Player/PlayerView.swift
@@ -615,7 +615,25 @@ struct PlayerView: View {
}
private func transport(for model: PlayerModel) -> some View {
- HStack(spacing: FlaroSpacing.section * 2) {
+ // **Enger, seit zwei Knöpfe dazukommen.** Bei fünf Knöpfen in einer
+ // Reihe wird der alte Abstand auf einem Telefon im Hochformat zu
+ // breit; bei drei bleibt er, wie er war.
+ let folgen = model.previousEpisode != nil || model.nextEpisode != nil
+
+ return HStack(spacing: folgen ? FlaroSpacing.section : FlaroSpacing.section * 2) {
+ // **Eine Folge zurück — nur bei Serien, nur wenn es eine gibt.**
+ //
+ // Bei einem Film bleibt die Reihe, wie sie war: ein Knopf, der
+ // nie etwas tut, ist schlechter als keiner.
+ if model.previousEpisode != nil {
+ Button {
+ if let wunsch = model.previousEpisodeRequest() { playback.play(wunsch) }
+ } label: {
+ Image(systemName: "backward.end.fill").font(.title2)
+ }
+ .accessibilityLabel(Text("player.previousEpisode"))
+ }
+
Button {
model.skip(by: .seconds(-settings.rewindSeconds))
scheduleControlsHide()
@@ -659,6 +677,15 @@ struct PlayerView: View {
Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle)
}
.accessibilityLabel(Text("player.forward10"))
+
+ if model.nextEpisode != nil {
+ Button {
+ if let wunsch = model.nextEpisodeRequest() { playback.play(wunsch) }
+ } label: {
+ Image(systemName: "forward.end.fill").font(.title2)
+ }
+ .accessibilityLabel(Text("player.nextEpisode"))
+ }
}
.foregroundStyle(.white)
}
diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift
index 787aaf1..2924beb 100644
--- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift
+++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift
@@ -239,9 +239,20 @@ public actor FlaroBackend: MediaBackend {
}
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
+ try await nachbar(von: item, weg: "next")
+ }
+
+ public func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
+ try await nachbar(von: item, weg: "previous")
+ }
+
+ /// Die Folge vor oder nach dieser.
+ ///
+ /// 204 heisst: es gibt keine. Kein Fehler, nur das Ende der Serie —
+ /// oder ihr Anfang.
+ private func nachbar(von item: MediaItem, weg: String) async throws(MediaBackendError) -> MediaItem? {
guard item.kind == .episode else { return nil }
- // 204 heisst: es war die letzte. Kein Fehler, nur kein Nachfolger.
- let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/next"))
+ let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/\(weg)"))
guard !daten.isEmpty else { return nil }
guard let folge = try? JSONDecoder().decode(Wire.Episode.self, from: daten) else { return nil }
return folge.neutral()
diff --git a/Packages/FlaroKit/Sources/JellyfinKit/JellyfinBackend.swift b/Packages/FlaroKit/Sources/JellyfinKit/JellyfinBackend.swift
index c8f3c0d..36063d3 100644
--- a/Packages/FlaroKit/Sources/JellyfinKit/JellyfinBackend.swift
+++ b/Packages/FlaroKit/Sources/JellyfinKit/JellyfinBackend.swift
@@ -348,6 +348,20 @@ public func isInLocalNetwork() async -> Bool {
return next.first { $0.id != item.id }
}
+ /// Die Folge davor.
+ ///
+ /// **Über die Folgenliste der Serie, nicht über einen eigenen
+ /// Endpunkt.** Jellyfin hat ein „NextUp", aber kein Gegenstück dazu —
+ /// und die Liste gibt es ohnehin, weil die Serienseite sie zeigt. Eine
+ /// Anfrage für einen Knopf, der selten gedrückt wird, ist der bessere
+ /// Tausch gegenüber gar keinem Knopf.
+ public func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
+ guard let seriesID = item.seriesID else { return nil }
+ let folgen = try await episodes(inSeries: seriesID)
+ guard let stelle = folgen.firstIndex(where: { $0.id == item.id }), stelle > 0 else { return nil }
+ return folgen[stelle - 1]
+ }
+
// MARK: - Bilder
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
diff --git a/Packages/FlaroKit/Sources/MediaKit/MediaBackend.swift b/Packages/FlaroKit/Sources/MediaKit/MediaBackend.swift
index 4d5b5de..7043814 100644
--- a/Packages/FlaroKit/Sources/MediaKit/MediaBackend.swift
+++ b/Packages/FlaroKit/Sources/MediaKit/MediaBackend.swift
@@ -88,6 +88,14 @@ public protocol MediaBackend: Sendable, Fortschrittsempfaenger {
/// Die nächste Folge nach dieser, sofern es eine gibt.
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
+ /// Die Folge davor — für den Rückwärtsknopf im Spieler.
+ ///
+ /// **Mit Vorgabe, anders als die nächste.** Die nächste Folge braucht
+ /// jeder Server: ohne sie gibt es kein Weiterlaufen am Ende. Die vorige
+ /// ist eine Bequemlichkeit, und ein Server, der sie nicht kennt, soll
+ /// deshalb nicht unbaubar werden.
+ func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem?
+
// MARK: - Bilder
/// Ein Bild in der gewünschten Größe.
@@ -229,6 +237,9 @@ public struct MediaPage: Sendable, Equatable {
}
public extension MediaBackend {
+ /// Vorgabe: kein Weg zurück.
+ func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? { nil }
+
/// Vorgabe: der Server kann es nicht.
///
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
index 20042be..80e29e9 100644
--- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
@@ -77,6 +77,65 @@ public static class LibraryContentEndpoints
/// Je Datei einzeln zu fragen hiesse: fünfzig Titel, fünfzig Abfragen —
/// und SQLite führt die nacheinander aus.
///
+ /// Die Folge vor oder nach dieser.
+ ///
+ ///
+ /// Eine Rechnung, zwei Wege. Vorwärts und rückwärts unterscheiden
+ /// sich in zwei Zeichen — als zwei Endpunkte abgeschrieben liefen sie
+ /// auseinander, sobald einer von beiden etwas dazubekommt.
+ ///
+ private static async Task Nachbarfolge(
+ Guid id, ClaimsPrincipal wer, FlaroContext db, bool vorwaerts)
+ {
+ var diese = await db.Episodes.AsNoTracking()
+ .Where(f => f.Id == id)
+ .Select(f => new { f.SeriesId, f.SeasonNumber, f.EpisodeNumber })
+ .FirstOrDefaultAsync();
+ if (diese is null) return Results.NotFound();
+
+ var geschwister = db.Episodes.AsNoTracking().Where(f => f.SeriesId == diese.SeriesId);
+
+ var gefiltert = vorwaerts
+ ? geschwister
+ .Where(f => f.SeasonNumber > diese.SeasonNumber
+ || (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber))
+ .OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
+ : geschwister
+ .Where(f => f.SeasonNumber < diese.SeasonNumber
+ || (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber < diese.EpisodeNumber))
+ .OrderByDescending(f => f.SeasonNumber).ThenByDescending(f => f.EpisodeNumber);
+
+ var nachbar = await gefiltert
+ .Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.PublishAt })
+ .FirstOrDefaultAsync();
+
+ // Keine weitere Folge ist kein Fehler — es war die erste oder die letzte.
+ if (nachbar is null) return Results.NoContent();
+
+ var dateien = await db.MediaFiles.AsNoTracking()
+ .Where(d => d.EpisodeId == nachbar.Id)
+ .Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.VersionLabel })
+ .ToListAsync();
+ var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
+ var stände = await ZuständeZu(db, wer, folgen: [nachbar.Id]);
+
+ // **Ohne eigenes Bild das der Serie.** Eine graue Fläche sagt
+ // nichts; das Plakat der Serie sagt wenigstens, worum es geht.
+ // TMDB liefert Folgenbilder nur für einen Teil der Folgen, und bei
+ // kleineren Serien für gar keine.
+ var serienBild = await db.Series.AsNoTracking()
+ .Where(x => x.Id == diese.SeriesId)
+ .Select(x => x.PosterImage).FirstOrDefaultAsync();
+
+ return Results.Ok(new EpisodeResponse(
+ nachbar.Id, nachbar.SeasonNumber, nachbar.EpisodeNumber,
+ nachbar.Title, nachbar.Overview, nachbar.StillImage ?? serienBild,
+ dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
+ stände.GetValueOrDefault(nachbar.Id),
+ nachbar.PublishAt,
+ SeriesId: diese.SeriesId));
+ }
+
private static async Task> SpurenZu(FlaroContext db, List dateien)
{
if (dateien.Count == 0) return Array.Empty<(Guid, MediaStreamResponse)>().ToLookup(x => x.Item1, x => x.Item2);
@@ -747,52 +806,18 @@ public static class LibraryContentEndpoints
SeriesId: folge.SeriesId));
}).RequireAuthorization();
- // **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
- // Folge — nicht die Kennung: die ist zeitlich geordnet und sagt, wann
- // der Scan sie fand, nicht wann sie läuft.
- app.MapGet("/api/v1/episodes/{id:guid}/next", async (
- Guid id, ClaimsPrincipal wer, FlaroContext db) =>
- {
- var diese = await db.Episodes.AsNoTracking()
- .Where(f => f.Id == id)
- .Select(f => new { f.SeriesId, f.SeasonNumber, f.EpisodeNumber })
- .FirstOrDefaultAsync();
- if (diese is null) return Results.NotFound();
+ // **Die Nachbarfolge — vorwärts wie rückwärts.** Reihenfolge ist
+ // Staffel, dann Folge; nicht die Kennung: die ist zeitlich geordnet
+ // und sagt, wann der Scan sie fand, nicht wann sie läuft.
+ //
+ // **Beide Richtungen, seit es im Spieler Knöpfe dafür gibt.** Wer
+ // eine Folge zu weit gesprungen ist, soll zurück können, ohne über
+ // die Serienseite zu gehen.
+ app.MapGet("/api/v1/episodes/{id:guid}/next", (Guid id, ClaimsPrincipal wer, FlaroContext db) =>
+ Nachbarfolge(id, wer, db, vorwaerts: true)).RequireAuthorization();
- var naechste = await db.Episodes.AsNoTracking()
- .Where(f => f.SeriesId == diese.SeriesId)
- .Where(f => f.SeasonNumber > diese.SeasonNumber
- || (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber))
- .OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
- .Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.PublishAt })
- .FirstOrDefaultAsync();
-
- // Keine weitere Folge ist kein Fehler — es war die letzte.
- if (naechste is null) return Results.NoContent();
-
- var dateien = await db.MediaFiles.AsNoTracking()
- .Where(d => d.EpisodeId == naechste.Id)
- .Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.VersionLabel })
- .ToListAsync();
- var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
- var stände = await ZuständeZu(db, wer, folgen: [naechste.Id]);
-
- // **Ohne eigenes Bild das der Serie.** Eine graue Fläche sagt
- // nichts; das Plakat der Serie sagt wenigstens, worum es geht.
- // TMDB liefert Folgenbilder nur für einen Teil der Folgen, und
- // bei kleineren Serien für gar keine.
- var serienBild = await db.Series.AsNoTracking()
- .Where(x => x.Id == diese.SeriesId)
- .Select(x => x.PosterImage).FirstOrDefaultAsync();
-
- return Results.Ok(new EpisodeResponse(
- naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber,
- naechste.Title, naechste.Overview, naechste.StillImage ?? serienBild,
- dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
- stände.GetValueOrDefault(naechste.Id),
- naechste.PublishAt,
- SeriesId: diese.SeriesId));
- }).RequireAuthorization();
+ app.MapGet("/api/v1/episodes/{id:guid}/previous", (Guid id, ClaimsPrincipal wer, FlaroContext db) =>
+ Nachbarfolge(id, wer, db, vorwaerts: false)).RequireAuthorization();
// **Welche Sprachen eine Serie durchgehend hat.**
//
diff --git a/server/tests/Flaro.Api.Tests/PlaybackTests.cs b/server/tests/Flaro.Api.Tests/PlaybackTests.cs
index 6f27d86..f4049d6 100644
--- a/server/tests/Flaro.Api.Tests/PlaybackTests.cs
+++ b/server/tests/Flaro.Api.Tests/PlaybackTests.cs
@@ -739,6 +739,44 @@ public class PlaybackTests
});
}
+ /// Und derselbe Weg rückwärts.
+ ///
+ ///
+ /// Für die Knöpfe im Spieler: wer eine Folge zu weit gesprungen ist,
+ /// soll zurück können, ohne über die Serienseite zu gehen.
+ ///
+ [Test]
+ public async Task Die_vorige_Folge_gibt_es_auch()
+ {
+ Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
+ Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
+ Datei("Dark/Season 02/Dark - S02E01 - Dunkel.mkv");
+
+ using var server = new FlaroServer();
+ var client = await Eingelesen(server, "Shows");
+
+ var serien = await client.GetFromJsonAsync>("/api/v1/series");
+ var folgen = await client.GetFromJsonAsync>(
+ $"/api/v1/series/{serien!.Items[0].Id}/episodes");
+ var geordnet = folgen!.Items.OrderBy(f => f.Season).ThenBy(f => f.Episode).ToList();
+
+ var vorige = await client.GetFromJsonAsync(
+ $"/api/v1/episodes/{geordnet[1].Id}/previous");
+ // Über die Staffelgrenze: vor S02E01 steht S01E02.
+ var ueberDieGrenze = await client.GetFromJsonAsync(
+ $"/api/v1/episodes/{geordnet[2].Id}/previous");
+ var vorDerErsten = await client.GetAsync($"/api/v1/episodes/{geordnet[0].Id}/previous");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(vorige!.Episode, Is.EqualTo(1));
+ Assert.That(ueberDieGrenze!.Season, Is.EqualTo(1));
+ Assert.That(ueberDieGrenze.Episode, Is.EqualTo(2));
+ Assert.That(vorDerErsten.StatusCode, Is.EqualTo(HttpStatusCode.NoContent),
+ "vor der ersten Folge steht keine");
+ });
+ }
+
[Test]
public async Task Die_erste_ungesehene_Folge_steht_unter_Naechste_Folge()
{
diff --git a/web/app/src/api.ts b/web/app/src/api.ts
index b830f79..898205d 100644
--- a/web/app/src/api.ts
+++ b/web/app/src/api.ts
@@ -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>(`/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 {
+ try {
+ return (await anfrage(`/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.
*
diff --git a/web/app/src/icons.ts b/web/app/src/icons.ts
index cbd3fef..bee15ce 100644
--- a/web/app/src/icons.ts
+++ b/web/app/src/icons.ts
@@ -37,6 +37,11 @@ export const icons = {
+ ''),
suche: rahmen(''),
abspielen: rahmen('', 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('', true),
+ folgeZurueck: rahmen('', true),
haken: rahmen(''),
hakenKreis: rahmen(''),
// Kreisbogen mit Pfeilspitze — das gewohnte Zeichen fürs Neuholen.
diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts
index 0643afe..2354a29 100644
--- a/web/app/src/spieler.ts
+++ b/web/app/src/spieler.ts
@@ -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 = () => {