Files
Flaro/Packages/HaloKit/Sources/MediaKit/EpisodeSelection.swift
T
ScarriffleandClaude Opus 5 4b9a1e7b67 Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit,
damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind.

712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25
Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit
`HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`,
`JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben.

`docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind
ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben
hiesse, die Vergangenheit zu glätten.

**Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis:
`com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte
Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container;
Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu
laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen.

**Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der
Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr
heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter
`…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur
anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim
Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor
dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet.

Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht
statt umbenannt — sie steht ohnehin nicht im Repo.

Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist
ein Handgriff ausserhalb des Projekts.

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 11:12:32 +02:00

111 lines
4.7 KiB
Swift

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