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
+16
View File
@@ -4237,6 +4237,22 @@
} }
} }
}, },
"player.previousEpisode" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Vorige Folge"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Previous episode"
}
}
}
},
"player.quality" : { "player.quality" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
+29 -4
View File
@@ -64,6 +64,9 @@ final class PlayerModel {
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt, /// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
/// damit sie am Ende ohne Wartezeit bereitsteht. /// damit sie am Ende ohne Wartezeit bereitsteht.
private(set) var nextEpisode: MediaItem? 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 private(set) var hasReachedEnd = false
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen /// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen. /// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
@@ -1823,21 +1826,43 @@ final class PlayerModel {
if shouldSkip { skipActiveMarker() } 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() { private func preloadNextEpisode() {
guard let item, item.kind == .episode, let backend else { return } guard let item, item.kind == .episode, let backend else { return }
Task { [weak self] in Task { [weak self] in
let next = try? await backend.nextEpisode(after: item) let next = try? await backend.nextEpisode(after: item)
self?.nextEpisode = next 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 /// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den
/// Koordinator weiter. /// Koordinator weiter.
func nextEpisodeRequest() -> PlaybackRequest? { func nextEpisodeRequest() -> PlaybackRequest? { wunsch(für: nextEpisode) }
guard let nextEpisode else { return nil }
/// 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( return PlaybackRequest(
ratingKey: nextEpisode.id, ratingKey: folge.id,
title: nextEpisode.playbackTitle, title: folge.playbackTitle,
mediaIndex: 0, mediaIndex: 0,
startTime: .zero startTime: .zero
) )
+28 -1
View File
@@ -615,7 +615,25 @@ struct PlayerView: View {
} }
private func transport(for model: PlayerModel) -> some 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 { Button {
model.skip(by: .seconds(-settings.rewindSeconds)) model.skip(by: .seconds(-settings.rewindSeconds))
scheduleControlsHide() scheduleControlsHide()
@@ -659,6 +677,15 @@ struct PlayerView: View {
Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle) Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle)
} }
.accessibilityLabel(Text("player.forward10")) .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) .foregroundStyle(.white)
} }
@@ -239,9 +239,20 @@ public actor FlaroBackend: MediaBackend {
} }
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? { 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 } 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)/\(weg)"))
let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/next"))
guard !daten.isEmpty else { return nil } guard !daten.isEmpty else { return nil }
guard let folge = try? JSONDecoder().decode(Wire.Episode.self, from: daten) else { return nil } guard let folge = try? JSONDecoder().decode(Wire.Episode.self, from: daten) else { return nil }
return folge.neutral() return folge.neutral()
@@ -348,6 +348,20 @@ public func isInLocalNetwork() async -> Bool {
return next.first { $0.id != item.id } 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 // MARK: - Bilder
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? { public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
@@ -88,6 +88,14 @@ public protocol MediaBackend: Sendable, Fortschrittsempfaenger {
/// Die nächste Folge nach dieser, sofern es eine gibt. /// Die nächste Folge nach dieser, sofern es eine gibt.
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? 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 // MARK: - Bilder
/// Ein Bild in der gewünschten Größe. /// Ein Bild in der gewünschten Größe.
@@ -229,6 +237,9 @@ public struct MediaPage: Sendable, Equatable {
} }
public extension MediaBackend { public extension MediaBackend {
/// Vorgabe: kein Weg zurück.
func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? { nil }
/// Vorgabe: der Server kann es nicht. /// Vorgabe: der Server kann es nicht.
/// ///
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht /// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
@@ -77,6 +77,65 @@ public static class LibraryContentEndpoints
/// Je Datei einzeln zu fragen hiesse: fünfzig Titel, fünfzig Abfragen — /// Je Datei einzeln zu fragen hiesse: fünfzig Titel, fünfzig Abfragen —
/// und SQLite führt die nacheinander aus. /// und SQLite führt die nacheinander aus.
/// </remarks> /// </remarks>
/// <summary>Die Folge vor oder nach dieser.</summary>
///
/// <remarks>
/// <b>Eine Rechnung, zwei Wege.</b> Vorwärts und rückwärts unterscheiden
/// sich in zwei Zeichen — als zwei Endpunkte abgeschrieben liefen sie
/// auseinander, sobald einer von beiden etwas dazubekommt.
/// </remarks>
private static async Task<IResult> 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<ILookup<Guid, MediaStreamResponse>> SpurenZu(FlaroContext db, List<Guid> dateien) private static async Task<ILookup<Guid, MediaStreamResponse>> SpurenZu(FlaroContext db, List<Guid> dateien)
{ {
if (dateien.Count == 0) return Array.Empty<(Guid, MediaStreamResponse)>().ToLookup(x => x.Item1, x => x.Item2); 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)); SeriesId: folge.SeriesId));
}).RequireAuthorization(); }).RequireAuthorization();
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann // **Die Nachbarfolge — vorwärts wie rückwärts.** Reihenfolge ist
// Folge — nicht die Kennung: die ist zeitlich geordnet und sagt, wann // Staffel, dann Folge; nicht die Kennung: die ist zeitlich geordnet
// der Scan sie fand, nicht wann sie läuft. // 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) => // **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
var diese = await db.Episodes.AsNoTracking() // die Serienseite zu gehen.
.Where(f => f.Id == id) app.MapGet("/api/v1/episodes/{id:guid}/next", (Guid id, ClaimsPrincipal wer, FlaroContext db) =>
.Select(f => new { f.SeriesId, f.SeasonNumber, f.EpisodeNumber }) Nachbarfolge(id, wer, db, vorwaerts: true)).RequireAuthorization();
.FirstOrDefaultAsync();
if (diese is null) return Results.NotFound();
var naechste = await db.Episodes.AsNoTracking() app.MapGet("/api/v1/episodes/{id:guid}/previous", (Guid id, ClaimsPrincipal wer, FlaroContext db) =>
.Where(f => f.SeriesId == diese.SeriesId) Nachbarfolge(id, wer, db, vorwaerts: false)).RequireAuthorization();
.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();
// **Welche Sprachen eine Serie durchgehend hat.** // **Welche Sprachen eine Serie durchgehend hat.**
// //
@@ -739,6 +739,44 @@ public class PlaybackTests
}); });
} }
/// <summary>Und derselbe Weg rückwärts.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
[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<Page<SeriesResponse>>("/api/v1/series");
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
$"/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<EpisodeResponse>(
$"/api/v1/episodes/{geordnet[1].Id}/previous");
// Über die Staffelgrenze: vor S02E01 steht S01E02.
var ueberDieGrenze = await client.GetFromJsonAsync<EpisodeResponse>(
$"/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] [Test]
public async Task Die_erste_ungesehene_Folge_steht_unter_Naechste_Folge() public async Task Die_erste_ungesehene_Folge_steht_unter_Naechste_Folge()
{ {
+19
View File
@@ -117,6 +117,8 @@ export type Folge = {
id: string; season: number; episode: number; id: string; season: number; episode: number;
title: string | null; overview: string | null; stillImage: string | null; files: Datei[]; title: string | null; overview: string | null; stillImage: string | null; files: Datei[];
state: Stand | null; 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. */ /** Wann sie freigegeben ist. Liegt das in der Zukunft, gibt es sie noch nicht. */
publishAt?: string | null; publishAt?: string | null;
}; };
@@ -623,6 +625,23 @@ export const weiterschauen = (take = 20) =>
export const naechsteFolgen = (take = 20) => export const naechsteFolgen = (take = 20) =>
anfrage<Seite<Angefangen>>(`/home/next${abfrage({ take })}`); 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. */
/** 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"/>'), + '<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"/>'), 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), 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"/>'), 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"/>'), 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. // Kreisbogen mit Pfeilspitze — das gewohnte Zeichen fürs Neuholen.
+76 -3
View File
@@ -1,9 +1,10 @@
import type HlsTyp from 'hls.js'; import type HlsTyp from 'hls.js';
import { import {
stromStarten, stromBeenden, untertitelHolen, fortschritt, stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge,
type Stromauskunft, type Spurauskunft, type Stromauskunft, type Spurauskunft, type Folge,
} from './api'; } from './api';
import { dauer, knopf, meldung, hinweisZeigen } from './util'; import { dauer, knopf, meldung, hinweisZeigen } from './util';
import { serienton, serientext, spurFuer } from './sprachwahl';
import { import {
GROESSE_GROESST, GROESSE_KLEINST, GROESSE_SCHRITT, HINTERGRUENDE, SCHRIFTFARBEN, GROESSE_GROESST, GROESSE_KLEINST, GROESSE_SCHRITT, HINTERGRUENDE, SCHRIFTFARBEN,
stilAnwenden, stilGeaendert, stilLesen, stilSchreiben, stilVorgabe, 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 // Jetzt stehen sie dort, wo man hinsieht: links und rechts vom Knopf, den
// man drückt. // 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'); const zeitmitte = document.createElement('div');
zeitmitte.className = 'pl-mitte'; zeitmitte.className = 'pl-mitte';
zeitmitte.append(stand, abspielknopf, rest); zeitmitte.append(stand, vorigeKnopf, abspielknopf, naechsteKnopf, rest);
const rechts = document.createElement('div'); const rechts = document.createElement('div');
rechts.className = 'pl-gruppe'; rechts.className = 'pl-gruppe';
@@ -610,6 +629,60 @@ export function spieler(anfrage: Spieleranfrage): void {
bedienung.append(zeitleiste, unten); 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 ---------- */ /* ---------- Verhalten ---------- */
video.onloadedmetadata = () => { video.onloadedmetadata = () => {