diff --git a/Flixy/FlixyApp.swift b/Flixy/FlixyApp.swift index 4150fd7..bc5d521 100644 --- a/Flixy/FlixyApp.swift +++ b/Flixy/FlixyApp.swift @@ -29,6 +29,7 @@ struct FlixyApp: App { @State private var playback = PlaybackCoordinator() @State private var playbackSettings = PlaybackSettings() @State private var languages = LanguagePreferences() + @State private var quickPlay = QuickPlay() /// Beobachtet WLAN und Mobilfunk. Lebt hier, damit der Beobachter einmal /// läuft statt bei jedem Öffnen des Players neu zu starten. @State private var network = NetworkConditions() @@ -88,6 +89,7 @@ struct FlixyApp: App { .environment(playback) .environment(playbackSettings) .environment(languages) + .environment(quickPlay) .environment(network) .environment(downloads) .environment(homeLayout) diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 88afaf7..7f95909 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -4519,6 +4519,57 @@ } } }, + "quickPlay.failed": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Abspielen nicht möglich" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Can’t play this" + } + } + } + }, + "quickPlay.noServer": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Verbindung zum Server." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Not connected to the server." + } + } + } + }, + "quickPlay.nothingToPlay": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hier ist keine Folge zu finden, die sich abspielen lässt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "There’s no episode here that could be played." + } + } + } + }, "runtime.hoursMinutes %lld %lld": { "comment": "Laufzeit mit Stunden", "extractionState": "stale", @@ -6821,6 +6872,23 @@ } } } + }, + "tile.play": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Abspielen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Play" + } + } + } } }, "version": "1.1" diff --git a/Flixy/Session/QuickPlay.swift b/Flixy/Session/QuickPlay.swift new file mode 100644 index 0000000..e8a86da --- /dev/null +++ b/Flixy/Session/QuickPlay.swift @@ -0,0 +1,106 @@ +import Foundation +import Observation +import MediaKit + +/// „Spiel das jetzt" — von einer Kachel aus, ohne Umweg über die Detailseite. +/// +/// **Warum ein eigener Typ.** Bei einem Film ist nichts nachzuschlagen: er +/// läuft sofort. Bei einer Serie schon — es muss erst feststehen, welche +/// Folge dran ist, und das ist eine Serveranfrage. Diese Anfrage gehört +/// weder in `PlaybackCoordinator`, der keinen Serverzugang hat und dessen +/// Aufgabe die Darstellung ist, noch in die Kacheln, wo sie dreimal stünde. +/// +/// **Die Regel selbst steht nicht hier**, sondern in `EpisodeSelection` — als +/// reiner Typ mit Tests. Hier steht nur, woher die Liste kommt. +@MainActor +@Observable +final class QuickPlay { + /// Welche Kachel gerade nachschlägt. + /// + /// Eine Kennung, kein Wahrheitswert: der Kreisel soll an **dieser** einen + /// Kachel stehen und nicht an allen zwanzig. + private(set) var resolvingID: String? + + /// Warum es nicht ging — einmal für die ganze App. + /// + /// Ein Hinweis je Kachel wäre bei einem Serverausfall ein Stapel aus + /// zwanzig identischen Meldungen. + var failure: String? + + private var task: Task? + + /// Startet, was hinter dieser Kachel steckt. + /// + /// `backend` und `coordinator` kommen als Argumente statt als gespeicherte + /// Werte: so bleibt der Typ ohne Abhängigkeiten baubar, und die Sitzung + /// kann den Server wechseln, ohne dass hier etwas veraltet. Dasselbe + /// Muster wie bei `DownloadRefiller`. + func start( + _ item: MediaItem, + backend: (any MediaBackend)?, + coordinator: PlaybackCoordinator + ) { + // Der zweite Klick gewinnt. Sonst liefen zwei Abfragen, und die + // langsamere überschriebe am Ende die gewollte. + task?.cancel() + + switch item.kind { + case .show, .season: + resolve(item, backend: backend, coordinator: coordinator) + default: + // **Sofort, ohne Aufgabe und ohne Kreisel.** Der häufige Fall + // soll nicht aufblitzen, nur weil der seltene eine Anfrage + // braucht. + resolvingID = nil + coordinator.play(item) + } + } + + func cancel() { + task?.cancel() + task = nil + resolvingID = nil + } + + private func resolve( + _ item: MediaItem, + backend: (any MediaBackend)?, + coordinator: PlaybackCoordinator + ) { + guard let backend else { + failure = String(localized: "quickPlay.noServer") + return + } + + resolvingID = item.id + task = Task { [weak self] in + let folgen: [MediaItem] + let bereich: EpisodeSelection.Scope + do { + if item.kind == .show { + folgen = try await backend.episodes(inSeries: item.id) + bereich = .series + } else { + // Eine Staffel ist schon eine Anfrage — `children(of:)` + // genügt, und der Server muss nichts Besonderes können. + folgen = try await backend.children(of: item.id) + bereich = .season + } + } catch { + guard !Task.isCancelled, let self else { return } + resolvingID = nil + failure = ErrorText.describe(error) + return + } + + guard !Task.isCancelled, let self else { return } + resolvingID = nil + + guard let wahl = EpisodeSelection.nextUp(in: folgen, scope: bereich) else { + failure = String(localized: "quickPlay.nothingToPlay") + return + } + coordinator.play(wahl.episode, resuming: wahl.resumes) + } + } +} diff --git a/Flixy/Views/Components/QuickPlayTile.swift b/Flixy/Views/Components/QuickPlayTile.swift new file mode 100644 index 0000000..41452a0 --- /dev/null +++ b/Flixy/Views/Components/QuickPlayTile.swift @@ -0,0 +1,98 @@ +import SwiftUI +import FlixyDesign +import MediaKit + +/// Eine Kachel, die zur Detailseite führt — mit einem Knopf, der sofort +/// startet. +/// +/// **Der Umweg, den es spart.** Wer weiß, was er sehen will, klickte bisher +/// auf die Kachel, wartete auf die Detailseite und drückte dort „Abspielen". +/// Zwei Schritte für eine Absicht. +/// +/// **Warum eine Hülle und keine Änderung an der Karte.** `PosterCard` liegt +/// in `FlixyDesign`, und das Paket ist ohne Abhängigkeiten deklariert — es +/// kennt weder `MediaItem` noch `PlaybackCoordinator`. Ein Überzug **in** der +/// Karte fiele ausserdem unter ihr `.accessibilityElement(children: .combine)` +/// und wäre für VoiceOver gar nicht mehr da. +struct QuickPlayTile: View { + let item: MediaItem + @ViewBuilder let card: () -> Card + + @Environment(FlixySession.self) private var session + @Environment(PlaybackCoordinator.self) private var playback + @Environment(QuickPlay.self) private var quickPlay + @Environment(\.flixyAccent) private var accent + + @State private var isPointerInside = false + + private var isMac: Bool { FlixyPlatform.isMac } + + /// Ob es überhaupt etwas zu starten gibt. + /// + /// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung. + private var isPlayable: Bool { item.kind != .collection } + + var body: some View { + NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) { + card() + } + .buttonStyle(.plain) + // **Als Überzug auf dem Link, nicht als Kind darin.** + // + // Ein `Button` im *Label* eines `NavigationLink` löst auf iOS nie aus: + // der Link macht sein Label zu einer einzigen Fläche, verschachtelte + // Bedienelemente werden nicht eigens getroffen — und `.plain` macht + // das Versagen lautlos. Als Geschwister darüber gewinnt er. + .overlay(alignment: .topTrailing) { + if isPlayable, isMac { playButton } + } + .onContinuousHover { phase in + // Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist + // der iOS-Bau und läuft trotzdem auf dem Mac. + guard isMac else { return } + if case .active = phase { isPointerInside = true } else { isPointerInside = false } + } + .animation(.easeOut(duration: 0.15), value: isPointerInside) + // Der Weg ohne Zeiger: langes Drücken auf dem iPad, Rechtsklick auf + // dem Mac. Später der natürliche Ort für „Als gesehen markieren". + .contextMenu { + if isPlayable { + Button("tile.play", systemImage: "play.fill") { trigger() } + } + } + // Für VoiceOver eine **Aktion**, kein zweites Element: die Kachel + // bleibt einer, und im Aktionen-Rotor steht „Abspielen". Zwanzig + // Kacheln mit je zwei Elementen wären sechzig Wischer. + .accessibilityAction(named: Text("tile.play")) { trigger() } + } + + /// Oben rechts, weil der Fortschrittsbalken unten liegt. + private var playButton: some View { + Button(action: trigger) { + Group { + if quickPlay.resolvingID == item.id { + ProgressView().controlSize(.small).tint(.white) + } else { + Image(systemName: "play.fill") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + } + } + // Feste Grösse, damit der Kreisel den Knopf nicht wachsen lässt + // und nichts springt, wenn nachgeschlagen wird. + .frame(width: 28, height: 28) + .background(accent.opacity(0.92), in: .circle) + .shadow(color: .black.opacity(0.35), radius: 3, y: 1) + } + .buttonStyle(.plain) + .padding(FlixySpacing.snug) + .opacity(isPointerInside ? 1 : 0) + // Er ist eine Zeigergeste; für VoiceOver gibt es die benannte Aktion + // oben. Beides anzubieten hiesse, jede Kachel zu verdoppeln. + .accessibilityHidden(true) + } + + private func trigger() { + quickPlay.start(item, backend: session.backend, coordinator: playback) + } +} diff --git a/Flixy/Views/Home/HeroSection.swift b/Flixy/Views/Home/HeroSection.swift index bcdebc4..d4df1f3 100644 --- a/Flixy/Views/Home/HeroSection.swift +++ b/Flixy/Views/Home/HeroSection.swift @@ -8,6 +8,7 @@ struct HeroSection: View { @Environment(FlixySession.self) private var session @Environment(PlaybackCoordinator.self) private var playback + @Environment(QuickPlay.self) private var quickPlay @Environment(\.flixyAccent) private var accent @Environment(\.horizontalSizeClass) private var sizeClass @@ -30,7 +31,12 @@ struct HeroSection: View { fillsAvailableSpace: sizeClass != .compact ) { Button { - playback.play(item) + // **Über QuickPlay, nicht unmittelbar.** Steht im Aufmacher + // eine Serie — und Jellyfins „Zuletzt hinzugefügt" liefert + // neue Folgen als ihre Serie —, schickte der direkte Weg + // deren eigene Kennung an den Player, und der scheiterte mit + // „keine abspielbare Datei". Eine Serie hat keine Fassungen. + quickPlay.start(item, backend: session.backend, coordinator: playback) } label: { Label(playLabel, systemImage: "play.fill") .font(.flixyCardTitle) diff --git a/Flixy/Views/Home/MediaRow.swift b/Flixy/Views/Home/MediaRow.swift index 6ba7684..84d31a5 100644 --- a/Flixy/Views/Home/MediaRow.swift +++ b/Flixy/Views/Home/MediaRow.swift @@ -30,10 +30,7 @@ struct MediaRow: View { ScrollView(.horizontal) { LazyHStack(spacing: FlixySpacing.regular) { ForEach(row.items) { item in - NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) { - card(for: item) - } - .buttonStyle(.plain) + QuickPlayTile(item: item) { card(for: item) } } } .padding(.horizontal, FlixySpacing.screenMargin) diff --git a/Flixy/Views/Library/LibraryView.swift b/Flixy/Views/Library/LibraryView.swift index b843cff..6f1f7cd 100644 --- a/Flixy/Views/Library/LibraryView.swift +++ b/Flixy/Views/Library/LibraryView.swift @@ -99,7 +99,7 @@ struct LibraryView: View { spacing: FlixySpacing.loose ) { ForEach(items) { item in - NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) { + QuickPlayTile(item: item) { PosterCard( title: item.title, subtitle: item.cardSubtitle, @@ -107,7 +107,6 @@ struct LibraryView: View { progress: item.watchProgress ) } - .buttonStyle(.plain) .task { await loadMoreIfNeeded(reaching: item) } } diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index b0028cf..5f2159a 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -16,6 +16,7 @@ struct RootView: View { @Environment(PlaybackCoordinator.self) private var playback @Environment(PlaybackSettings.self) private var settings @Environment(LanguagePreferences.self) private var languages + @Environment(QuickPlay.self) private var quickPlay @Environment(NetworkConditions.self) private var network @Environment(DownloadCoordinator.self) private var downloads @Environment(PlaybackDiagnostics.self) private var diagnostics @@ -121,6 +122,19 @@ struct RootView: View { // zuverlässig neu aus, ein Scene-Body nicht. .flixyAccent(theme) .tint(theme.accentColor) + // **Einer für alle Kacheln.** Einer je Kachel wäre bei einem + // Serverausfall ein Stapel aus zwanzig gleichen Meldungen. + .alert( + Text("quickPlay.failed"), + isPresented: Binding( + get: { quickPlay.failure != nil }, + set: { if !$0 { quickPlay.failure = nil } } + ) + ) { + Button("action.close", role: .cancel) { quickPlay.failure = nil } + } message: { + if let grund = quickPlay.failure { Text(grund) } + } // Einmal an der Wurzel statt in jedem Bildschirm, von dem aus sich // etwas starten lässt — sonst gäbe es vier Kopien desselben Übergangs. // Kein `fullScreenCover` mehr: das ist eine eigene Hierarchie, und diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift index 8d2c6b8..52ded2f 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift @@ -252,6 +252,24 @@ public func isInLocalNetwork() async -> Bool { return item.asMediaItem(imageBase: images) } + /// Alle Folgen einer Serie in **einer** Anfrage. + /// + /// **Nicht `/Shows/NextUp`.** Der braucht `seriesID`, das bei einer Serie + /// selbst `nil` ist, versteckt vollständig gesehene Serien und bringt + /// eine eigene Zeitgrenze mit. Dann antworteten Plex und Jellyfin + /// verschieden auf dieselbe Frage — und welche Folge dran ist, soll eine + /// Regel entscheiden, nicht der Server. + /// + /// `EnableUserData` ausdrücklich: ohne den Sehfortschritt wäre jede Folge + /// ungesehen, und es begänne immer bei der ersten. + public func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] { + try await rowItems(path: "/Shows/\(id)/Episodes", query: [ + URLQueryItem(name: "userId", value: account.userID), + URLQueryItem(name: "EnableUserData", value: "true"), + URLQueryItem(name: "Fields", value: Self.fields), + ]) + } + public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] { try await rowItems(path: "/Users/\(account.userID)/Items", query: [ URLQueryItem(name: "ParentId", value: id), diff --git a/Packages/FlixyKit/Sources/MediaKit/EpisodeSelection.swift b/Packages/FlixyKit/Sources/MediaKit/EpisodeSelection.swift new file mode 100644 index 0000000..a8939ad --- /dev/null +++ b/Packages/FlixyKit/Sources/MediaKit/EpisodeSelection.swift @@ -0,0 +1,110 @@ +import Foundation + +/// Welche Folge dran ist, wenn jemand eine Serie oder Staffel startet. +/// +/// **Warum als reiner Typ.** Liste hinein, eine Folge heraus. Kein Netz, keine +/// Einstellungen, keine Ansicht — dieselbe Bauform wie `HeroSelection` und +/// `AudioTrackChooser`, und aus demselben Grund: im App-Ziel gibt es kein +/// Unit-Test-Ziel, also muss alles, worin eine Entscheidung steckt, hierher. +/// „Er hat die falsche Folge gestartet" ist sonst ein Befund, den man nicht +/// nachstellen kann. +/// +/// **Die Regel in einem Satz:** die älteste Folge, die noch nicht als gesehen +/// gilt; wurde sie angefangen, dort weiter; ist alles gesehen, von vorn. +public enum EpisodeSelection { + /// Ob die Liste eine ganze Serie ist oder genau eine Staffel. + /// + /// Sie entscheidet nur zweierlei: ob Specials wegfallen und was „von vorn" + /// bedeutet. Alles Übrige ist gleich. + public enum Scope: Sendable, Equatable { + case series + case season + } + + /// Was gespielt werden soll — und ob an der gemerkten Stelle. + public struct Choice: Sendable, Equatable { + public let episode: MediaItem + /// Ob an der gemerkten Stelle fortgesetzt wird. + public let resumes: Bool + + public init(episode: MediaItem, resumes: Bool) { + self.episode = episode + self.resumes = resumes + } + } + + /// Ab wo eine angefangene Folge **nicht** mehr fortgesetzt wird. + /// + /// Ein Server, der einen Fortschritt vier Sekunden vor Schluss noch nicht + /// gelöscht hat, öffnete den Player sonst mitten im Abspann — und das + /// sieht aus wie „er hat die Folge übersprungen". + static let resumeCeiling = 0.95 + + /// Die Folge, mit der es weitergeht. + /// + /// `nil`, wenn nach dem Aussortieren nichts übrig ist. Dann sagt der + /// Aufrufer das, statt lautlos nichts zu tun. + public static func nextUp(in items: [MediaItem], scope: Scope) -> Choice? { + let sortiert = ordered(items, scope: scope) + guard !sortiert.isEmpty else { return nil } + + // **Die älteste ungesehene, nicht die am weitesten fortgeschrittene.** + // + // Bei S1E1 gesehen, S1E2 unberührt und S1E5 halb gesehen kommt S1E2. + // Wer sich eine Folge für später aufgehoben hat, soll sie nicht + // dadurch verlieren, dass er irgendwo weiter vorn hineingeschaut hat. + guard let offen = sortiert.first(where: { !$0.isWatched }) else { + // Alles gesehen: von vorn. Bei einer Serie ist das die erste Folge + // der ersten Staffel, bei einer Staffel deren erste — derselbe + // Code, weil `scope` längst entschieden hat, was in der Liste + // liegt. + return Choice(episode: sortiert[0], resumes: false) + } + + let angefangen = (offen.watchProgress ?? 0) > 0 + && (offen.watchProgress ?? 0) < resumeCeiling + return Choice(episode: offen, resumes: angefangen) + } + + /// Die Sendereihenfolge — **nie** die des Servers. + /// + /// Jellyfins `children(of:)` sortiert nach `SortName`, und das stellt + /// „Folge 10" vor „Folge 2". Sich darauf zu verlassen ginge genau so + /// lange gut, bis eine Staffel zehn Folgen hat. + static func ordered(_ items: [MediaItem], scope: Scope) -> [MediaItem] { + var übrig = items.filter { eintrag in + switch eintrag.kind { + // Behälter sind keine Folgen. `nil` bleibt dagegen: Jellyfin + // liefert in Listen nicht immer eine brauchbare Art, und + // Wegwerfen leerte die Liste. + case .season, .show, .collection: false + default: true + } + } + + // **Specials nur bei der Serie aussortieren.** Staffel 0 vorzuziehen + // wäre falsch — sie steht vor Staffel 1 und ist trotzdem nicht der + // Anfang. Wer dagegen die Specials-Kachel angeklickt hat, meint die + // Specials. + if scope == .series { + übrig = übrig.filter { ($0.parentIndex ?? 1) != 0 } + } + + // Fehlt eine Nummer, ist das keine Null, sondern „der Server hat + // nichts gesagt". Solche Einträge gehören ans Ende, nicht an den + // Anfang. Die Kennung zuletzt, damit dieselbe Eingabe dasselbe ergibt + // — sonst ist ein Fehler nicht wiederholbar. + übrig.sort { links, rechts in + let staffelL = links.parentIndex ?? .max + let staffelR = rechts.parentIndex ?? .max + if staffelL != staffelR { return staffelL < staffelR } + + let folgeL = links.index ?? .max + let folgeR = rechts.index ?? .max + if folgeL != folgeR { return folgeL < folgeR } + + return links.id < rechts.id + } + return übrig + } +} diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift index dff3dba..9d7ba7b 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift @@ -53,6 +53,13 @@ public protocol MediaBackend: Sendable { inLibrary library: MediaLibrary, field: MediaQuery.Field ) async throws(MediaBackendError) -> [MediaFilterOption] + /// Alle Folgen einer Serie, über alle Staffeln hinweg. + /// + /// **Als eigene Frage, weil der allgemeine Weg zu teuer ist.** Staffeln + /// holen und dann jede Staffel einzeln kostet bei zwölf Staffeln + /// dreizehn Anfragen — für einen Klick auf eine Kachel ist das zu viel. + /// Ein Server, der es in einer Anfrage kann, soll das dürfen. + func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] /// Die nächste Folge nach dieser, sofern es eine gibt. func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? @@ -159,6 +166,22 @@ public struct MediaPage: Sendable, Equatable { } public extension MediaBackend { + /// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen. + /// + /// Langsam, aber überall gültig — wer es besser kann, überschreibt es. + /// + /// **Ohne Sortieren und ohne Filtern.** Beides ist Sache von + /// `EpisodeSelection`, und es zweimal zu tun hiesse, dass die beiden + /// irgendwann auseinanderlaufen. + func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] { + let staffeln = try await children(of: id) + var folgen: [MediaItem] = [] + for staffel in staffeln { + folgen += (try? await children(of: staffel.id)) ?? [] + } + return folgen + } + /// Ohne eigene Antwort keine Mediathek. /// /// Plex beantwortet das heute nicht, und eine falsche Antwort wäre diff --git a/Packages/FlixyKit/Tests/MediaKitTests/EpisodeSelectionTests.swift b/Packages/FlixyKit/Tests/MediaKitTests/EpisodeSelectionTests.swift new file mode 100644 index 0000000..f012a21 --- /dev/null +++ b/Packages/FlixyKit/Tests/MediaKitTests/EpisodeSelectionTests.swift @@ -0,0 +1,193 @@ +import Foundation +import Testing +@testable import MediaKit + +@Suite("Welche Folge dran ist") +struct EpisodeSelectionTests { + + /// Eine Folge, so knapp wie möglich. `duration` steht dabei, weil + /// `watchProgress` ohne sie immer `nil` ist — genau die Falle, die einen + /// Test lautlos wirkungslos macht. + private func folge( + _ staffel: Int?, + _ nummer: Int?, + gesehen: Bool = false, + fortschritt: Double? = nil, + id: String? = nil + ) -> MediaItem { + MediaItem( + id: id ?? "s\(staffel.map(String.init) ?? "x")e\(nummer.map(String.init) ?? "x")", + kind: .episode, + title: "Folge \(nummer.map(String.init) ?? "?")", + duration: 1_000, + index: nummer, + parentIndex: staffel, + playCount: gesehen ? 1 : 0, + resumeMilliseconds: fortschritt.map { Int($0 * 1_000) } + ) + } + + // MARK: - Der Normalfall + + /// Der Fall aus der Beschreibung: vier Folgen gesehen, die fünfte ist + /// dran. + @Test("Die älteste ungesehene Folge") + func firstUnwatched() { + let staffel = (1...6).map { folge(1, $0, gesehen: $0 <= 4) } + let wahl = EpisodeSelection.nextUp(in: staffel, scope: .season) + + #expect(wahl?.episode.id == "s1e5") + #expect(wahl?.resumes == false) + } + + @Test("Über Staffeln hinweg gilt dieselbe Regel") + func crossesSeasons() { + let serie = (1...3).map { folge(1, $0, gesehen: true) } + + (1...3).map { folge(2, $0, gesehen: $0 == 1) } + let wahl = EpisodeSelection.nextUp(in: serie, scope: .series) + + #expect(wahl?.episode.id == "s2e2") + } + + /// Drei Staffeln durch, in der vierten bei Folge zwei — der zweite Fall, + /// den der Nutzer ausdrücklich genannt hat. + @Test("Drei Staffeln durch, in der vierten weiter") + func deepIntoASeries() { + var serie: [MediaItem] = [] + for staffel in 1...3 { serie += (1...8).map { folge(staffel, $0, gesehen: true) } } + serie += [folge(4, 1, gesehen: true)] + (2...8).map { folge(4, $0) } + serie += (1...8).map { folge(5, $0) } + + #expect(EpisodeSelection.nextUp(in: serie, scope: .series)?.episode.id == "s4e2") + } + + // MARK: - Fortsetzen + + @Test("Eine angefangene Folge wird fortgesetzt") + func resumesPartial() { + let staffel = [folge(1, 1, gesehen: true), folge(1, 2, fortschritt: 0.4)] + let wahl = EpisodeSelection.nextUp(in: staffel, scope: .season) + + #expect(wahl?.episode.id == "s1e2") + #expect(wahl?.resumes == true) + } + + /// Ein Fortschritt, den der Server kurz vor Schluss noch nicht gelöscht + /// hat, öffnete den Player sonst mitten im Abspann — und das sieht aus, + /// als hätte er die Folge übersprungen. + @Test("Kurz vor Schluss wird nicht fortgesetzt") + func doesNotResumeAtTheVeryEnd() { + let staffel = [folge(1, 1, fortschritt: 0.98)] + #expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.resumes == false) + } + + @Test("Ohne Fortschritt wird von vorn begonnen") + func startsFromZero() { + #expect(EpisodeSelection.nextUp(in: [folge(1, 1)], scope: .season)?.resumes == false) + } + + /// **Die älteste ungesehene, nicht die am weitesten fortgeschrittene.** + /// Wer sich eine Folge aufgehoben hat, soll sie nicht dadurch verlieren, + /// dass er weiter vorn hineingeschaut hat. + @Test("Eine spätere angefangene Folge zieht nicht vor") + func laterProgressDoesNotWin() { + let staffel = [ + folge(1, 1, gesehen: true), + folge(1, 2), + folge(1, 5, fortschritt: 0.5), + ] + #expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "s1e2") + } + + // MARK: - Alles gesehen + + @Test("Ist alles gesehen, beginnt die Serie von vorn") + func watchedSeriesRestarts() { + let serie = (1...3).map { folge(1, $0, gesehen: true) } + + (1...3).map { folge(2, $0, gesehen: true) } + let wahl = EpisodeSelection.nextUp(in: serie, scope: .series) + + #expect(wahl?.episode.id == "s1e1") + #expect(wahl?.resumes == false) + } + + @Test("Ist die Staffel gesehen, beginnt sie von vorn") + func watchedSeasonRestarts() { + let staffel = (1...3).map { folge(4, $0, gesehen: true) } + #expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "s4e1") + } + + // MARK: - Reihenfolge + + /// Jellyfin sortiert `children(of:)` nach dem Namen, und das stellt + /// „Folge 10" vor „Folge 2". Sich darauf zu verlassen ginge genau so + /// lange gut, bis eine Staffel zehn Folgen hat. + @Test("Die Reihenfolge des Servers zählt nicht") + func ignoresServerOrder() { + let durcheinander = [folge(1, 10), folge(1, 2), folge(1, 1)] + #expect(EpisodeSelection.nextUp(in: durcheinander, scope: .season)?.episode.id == "s1e1") + } + + @Test("Lücken in der Nummerierung ändern nichts") + func gapsDoNotMatter() { + let staffel = [folge(1, 1, gesehen: true), folge(1, 7), folge(1, 9)] + #expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "s1e7") + } + + /// Eine fehlende Nummer ist keine Null, sondern „der Server hat nichts + /// gesagt". Solche Einträge gehören ans Ende. + @Test("Folgen ohne Nummer stehen hinten") + func unnumberedGoLast() { + let staffel = [folge(1, nil, id: "ohne"), folge(1, 1)] + #expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "s1e1") + } + + @Test("Zweimal dieselbe Eingabe ergibt zweimal dasselbe") + func isRepeatable() { + let staffel = [folge(1, nil, id: "b"), folge(1, nil, id: "a")] + #expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "a") + } + + // MARK: - Specials + + /// Staffel 0 steht vor Staffel 1 und ist trotzdem nicht der Anfang. + @Test("Specials werden bei der Serie übergangen") + func skipsSpecialsInSeries() { + let serie = [folge(0, 1), folge(1, 1)] + #expect(EpisodeSelection.nextUp(in: serie, scope: .series)?.episode.id == "s1e1") + } + + /// Wer die Specials-Kachel angeklickt hat, meint die Specials. + @Test("Bei der Staffel bleiben sie stehen") + func keepsSpecialsInSeason() { + let specials = [folge(0, 1), folge(0, 2)] + #expect(EpisodeSelection.nextUp(in: specials, scope: .season)?.episode.id == "s0e1") + } + + /// Gäbe es **nur** Specials, dürfte die Serie nicht wortlos nichts tun. + @Test("Eine Serie aus lauter Specials ergibt nichts") + func onlySpecialsIsNothing() { + #expect(EpisodeSelection.nextUp(in: [folge(0, 1)], scope: .series) == nil) + } + + // MARK: - Was keine Folge ist + + /// Jellyfin liefert in Listen nicht immer eine brauchbare Art. Sie + /// wegzuwerfen leerte die Liste — also bleibt `nil` stehen. + @Test("Behälter fliegen raus, Unbekanntes bleibt") + func dropsContainersKeepsUnknown() { + let gemischt = [ + MediaItem(id: "staffel", kind: .season, title: "Staffel 1", index: 1), + MediaItem(id: "serie", kind: .show, title: "Serie"), + MediaItem(id: "unbekannt", kind: nil, title: "?", index: 1, parentIndex: 1), + ] + let wahl = EpisodeSelection.nextUp(in: gemischt, scope: .series) + #expect(wahl?.episode.id == "unbekannt") + } + + @Test("Eine leere Liste ergibt nichts") + func emptyIsNothing() { + #expect(EpisodeSelection.nextUp(in: [], scope: .series) == nil) + #expect(EpisodeSelection.nextUp(in: [], scope: .season) == nil) + } +}