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 } }