Files
Flaro/Packages/FlixyKit/Sources/MediaKit/EpisodeSelection.swift
T
ScarriffleandClaude Opus 5 a1ff5cb7bb Von der Kachel direkt abspielen, auch bei Serien
Wer weiss, was er sehen will, klickte bisher auf die Kachel, wartete auf
die Detailseite und drueckte dort „Abspielen\". Zwei Schritte fuer eine
Absicht. Jetzt gibt es oben rechts einen Knopf — auf dem Mac beim
Darueberfahren, auf iPad und iPhone ueber langes Druecken.

Bei einer Serie ist die Absicht nicht „spiel die Serie\", sondern „mach da
weiter, wo ich war\". Welche Folge das ist, entscheidet `EpisodeSelection`:
die aelteste ungesehene, fortgesetzt falls angefangen, und von vorn, wenn
alles gesehen ist. Ein reiner Typ mit achtzehn Tests — im App-Ziel gibt es
kein Unit-Test-Ziel, und „er hat die falsche Folge gestartet\" ist sonst ein
Befund, den man nicht nachstellen kann.

Ausdruecklich die aelteste ungesehene, nicht die am weitesten
fortgeschrittene: wer sich eine Folge aufgehoben hat, soll sie nicht
dadurch verlieren, dass er weiter vorn hineingeschaut hat.

Dazu `episodes(inSeries:)` im Vertrag — bei Jellyfin eine Anfrage statt
dreizehn, fuer alle anderen die Vorgabe ueber `children(of:)`. Nicht
`/Shows/NextUp`: der braucht eine Seriennummer, die eine Serie selbst nicht
hat, versteckt vollstaendig gesehene Serien und bringt eine eigene
Zeitgrenze mit. Welche Folge dran ist, soll eine Regel entscheiden, nicht
der Server.

Nebenbei behoben: der grosse Abspielknopf im Aufmacher scheiterte bisher
mit „keine abspielbare Datei\", sobald dort eine Serie stand — und Jellyfins
„Zuletzt hinzugefuegt\" liefert neue Folgen als ihre Serie.

Der Knopf liegt als Ueberzug auf dem NavigationLink, nicht darin: ein
Button im Label eines Links loest auf iOS nie aus, und `.plain` macht das
Versagen lautlos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 21:18:39 +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
}
}