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 = () => {