Phase 0: Projektgerüst, Design-Tokens und Navigation

Legt das Fundament für den Plex-Client an:

- XcodeGen-Projekt nach dem Muster von Onyx (com.scarriffleservices,
  strict concurrency, Projektdatei nicht eingecheckt)
- FlixyKit mit vier Modulen: PlexKit, FlixyDesign, FlixyPlayback,
  FlixyDownloads. macOS steht als Plattform mit drin, damit die
  Logik-Tests ohne Simulator laufen.
- FlixyDesign: dunkle Farb-Tokens, Schriftskala, Poster- und
  Querformat-Kacheln, Aufmacher-Bild. Die Akzentfarbe fließt über die
  Umgebung und wird per WCAG-Rechnung lesbar gehalten.
- PlexKit: Kopfzeilen-Identität, Transport-Protokoll, Fehlerarten und
  die Medienmodelle samt nachsichtigem Decoder.
- FlixyPlayback: VideoPlayerEngine-Protokoll und PlaybackDecider — die
  eine Funktion, an der Phase 2 das Transcoding andockt.
- Ausfahrbare Seitenleiste auf dem iPhone, Split-View auf dem iPad.
- 45 Tests, alle grün.

VLCKit 4.0.0-a24 ist vorab geprüft: baut über SPM unter Swift 6 mit
strict concurrency. Die iOS-Slice ist mit 239 MB allerdings gewichtig,
deshalb bekommt sie in Phase 1 ein eigenes Target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-11 16:04:40 +02:00
co-authored by Claude Opus 5
commit 1ed64bf166
54 changed files with 3584 additions and 0 deletions
@@ -0,0 +1,25 @@
import SwiftUI
extension EnvironmentValues {
/// Die aktuelle Akzentfarbe, bereits kontrastkorrigiert.
///
/// Bewusst eine schlichte `Color` in der Umgebung statt einer Abhängigkeit
/// auf `FlixyTheme`: Bausteine wie `WatchProgressBar` sollen sich in einer
/// Vorschau mit einer beliebigen Farbe zeigen lassen, ohne dass dafür ein
/// ganzes Theme-Objekt gebaut werden muss.
@Entry public var flixyAccent: Color = AccentOption.default.color.swiftUIColor
}
extension View {
/// Setzt die Akzentfarbe für diesen Teilbaum.
///
/// Wird einmal an der Wurzel aus `FlixyTheme` gesetzt; darunter greift
/// alles automatisch darauf zu.
public func flixyAccent(_ color: Color) -> some View {
environment(\.flixyAccent, color)
}
public func flixyAccent(_ theme: FlixyTheme) -> some View {
environment(\.flixyAccent, theme.accentColor)
}
}
@@ -0,0 +1,51 @@
import Foundation
/// Eine wählbare Akzentfarbe.
///
/// Die kuratierten Werte sind so gewählt, dass sie auf `FlixyColor.canvas`
/// mindestens 4,5:1 erreichen — geprüft von `AccentPaletteTests`, damit beim
/// Nachlegen einer Farbe nicht versehentlich etwas Unlesbares hineinrutscht.
public struct AccentOption: Sendable, Identifiable {
public let id: String
public let name: LocalizedStringResource
public let color: RGB
public init(id: String, name: LocalizedStringResource, color: RGB) {
self.id = id
self.name = name
self.color = color
}
}
extension AccentOption: Equatable {
public static func == (lhs: AccentOption, rhs: AccentOption) -> Bool { lhs.id == rhs.id }
}
extension AccentOption: Hashable {
public func hash(into hasher: inout Hasher) { hasher.combine(id) }
}
extension AccentOption {
/// Die kuratierte Auswahl aus den Einstellungen.
public static let palette: [AccentOption] = [
AccentOption(id: "ember", name: "Ember", color: RGB(hex: 0xE5484D)),
AccentOption(id: "tangerine", name: "Tangerine", color: RGB(hex: 0xF76B15)),
AccentOption(id: "amber", name: "Amber", color: RGB(hex: 0xFFB224)),
AccentOption(id: "lime", name: "Lime", color: RGB(hex: 0x99D52A)),
AccentOption(id: "mint", name: "Mint", color: RGB(hex: 0x3DD68C)),
AccentOption(id: "teal", name: "Teal", color: RGB(hex: 0x12A594)),
AccentOption(id: "sky", name: "Sky", color: RGB(hex: 0x52A9FF)),
AccentOption(id: "indigo", name: "Indigo", color: RGB(hex: 0x7C77E8)),
AccentOption(id: "orchid", name: "Orchid", color: RGB(hex: 0xC04AC9)),
AccentOption(id: "rose", name: "Rose", color: RGB(hex: 0xF76190)),
]
public static let `default` = palette[0]
/// Die Kennung, unter der eine frei gewählte Farbe abgelegt wird.
public static let customIdentifier = "custom"
public static func custom(_ color: RGB) -> AccentOption {
AccentOption(id: customIdentifier, name: "Custom", color: color)
}
}
@@ -0,0 +1,89 @@
import Foundation
/// Kontrastrechnung nach WCAG 2.1.
///
/// Gebraucht wird sie für die eigene Akzentfarbe: Flixy lässt jede Farbe zu,
/// aber ein dunkles Marineblau auf fast schwarzem Grund ist als Schrift oder
/// Symbol unlesbar. `legible(_:on:)` hebt solche Farben so weit an, bis sie
/// den geforderten Kontrast erreichen — und lässt alles in Ruhe, was ihn
/// ohnehin schon hat.
public enum Contrast {
/// Der Grenzwert aus WCAG für normalen Text.
public static let minimumForText: Double = 4.5
/// Der Grenzwert für große Schrift und Bedienelemente.
public static let minimumForLargeText: Double = 3.0
/// Relative Leuchtdichte eines Farbwerts (0 = Schwarz, 1 = Weiß).
public static func relativeLuminance(_ color: RGB) -> Double {
func linear(_ channel: Double) -> Double {
// Der Knick bei 0,03928 ist kein Rundungsfehler, sondern Teil der
// sRGB-Definition: unterhalb davon ist die Kurve linear.
channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4)
}
return 0.2126 * linear(color.red)
+ 0.7152 * linear(color.green)
+ 0.0722 * linear(color.blue)
}
/// Kontrastverhältnis zweier Farben, zwischen 1 (gleich) und 21 (Schwarz zu Weiß).
public static func ratio(_ a: RGB, _ b: RGB) -> Double {
let la = relativeLuminance(a)
let lb = relativeLuminance(b)
return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
}
/// Hebt `color` gerade so weit an, dass sie auf `background` lesbar wird.
///
/// Zuerst wird die Helligkeit angehoben, weil das den Farbton erhält — eine
/// gewählte Akzentfarbe soll erkennbar dieselbe bleiben. Reicht selbst
/// volle Helligkeit nicht (etwa bei einem kräftigen Rot), wird zusätzlich
/// die Sättigung zurückgenommen; die Farbe wandert dann Richtung Weiß.
public static func legible(
_ color: RGB,
on background: RGB,
minimumRatio: Double = minimumForText
) -> RGB {
guard ratio(color, background) < minimumRatio else { return color }
let base = HSB(color)
// Beide Schritte sind monoton — mehr Helligkeit bzw. weniger Sättigung
// erhöhen die Leuchtdichte immer. Deshalb genügt eine binäre Suche,
// und sie terminiert garantiert.
if let brightened = search(
from: base.brightness, to: 1,
build: { HSB(hue: base.hue, saturation: base.saturation, brightness: $0).rgb },
satisfies: { ratio($0, background) >= minimumRatio }
) {
return brightened
}
return search(
from: base.saturation, to: 0,
build: { HSB(hue: base.hue, saturation: $0, brightness: 1).rgb },
satisfies: { ratio($0, background) >= minimumRatio }
) ?? RGB(hex: 0xFFFFFF)
}
/// Sucht den Wert nahe `from`, der die Bedingung gerade eben erfüllt.
/// `to` ist das Extrem; erfüllt selbst das die Bedingung nicht, gibt es
/// keine Lösung auf dieser Achse.
private static func search(
from: Double,
to extreme: Double,
build: (Double) -> RGB,
satisfies: (RGB) -> Bool
) -> RGB? {
guard satisfies(build(extreme)) else { return nil }
var unsatisfying = from
var satisfying = extreme
// 12 Halbierungen bringen den Wert auf unter 1/4096 genau — feiner als
// jeder Unterschied, der auf einem Bildschirm noch sichtbar wäre.
for _ in 0..<12 {
let middle = (unsatisfying + satisfying) / 2
if satisfies(build(middle)) { satisfying = middle } else { unsatisfying = middle }
}
return build(satisfying)
}
}
@@ -0,0 +1,45 @@
import SwiftUI
/// Die Flächen- und Schriftfarben der App.
///
/// Flixy kennt nur den dunklen Modus — es gibt deshalb bewusst keine
/// Hell-/Dunkel-Paare, sondern je einen festen Wert. Die einzige Farbe, die
/// sich zur Laufzeit ändert, ist der Akzent; er kommt aus `FlixyTheme`.
public enum FlixyColor {
// Flächen, von hinten nach vorn gestaffelt.
/// Der Seitenhintergrund.
public static let canvas = RGB(hex: 0x0B0B0D)
/// Karten, Seitenleiste, Kopfbereiche.
public static let surface = RGB(hex: 0x141417)
/// Blätter, Menüs, alles was über dem Inhalt schwebt.
public static let elevated = RGB(hex: 0x1D1D21)
/// Platzhalterkacheln und gedrückte Zustände.
public static let placeholder = RGB(hex: 0x26262B)
// Schrift.
public static let textPrimary = RGB(hex: 0xF5F5F7)
public static let textSecondary = RGB(hex: 0xA0A0A8)
/// Für Beiwerk wie Jahreszahlen und Laufzeiten.
public static let textMuted = RGB(hex: 0x6E6E76)
/// Rot für zerstörerische Aktionen — bewusst nicht der Akzent, damit
/// „Löschen" auch dann warnend aussieht, wenn jemand Rot als Akzent wählt.
public static let destructive = RGB(hex: 0xE5484D)
}
extension Color {
public static let flixyCanvas = FlixyColor.canvas.swiftUIColor
public static let flixySurface = FlixyColor.surface.swiftUIColor
public static let flixyElevated = FlixyColor.elevated.swiftUIColor
public static let flixyPlaceholder = FlixyColor.placeholder.swiftUIColor
public static let flixyTextPrimary = FlixyColor.textPrimary.swiftUIColor
public static let flixyTextSecondary = FlixyColor.textSecondary.swiftUIColor
public static let flixyTextMuted = FlixyColor.textMuted.swiftUIColor
public static let flixyDestructive = FlixyColor.destructive.swiftUIColor
/// Trennlinien und Ränder. Als Weiß mit Deckkraft statt als fester Wert,
/// damit sie auf jeder Flächenstufe gleich zurückhaltend wirken.
public static let flixySeparator = Color.white.opacity(0.10)
/// Die Abdunklung hinter der ausgefahrenen Seitenleiste.
public static let flixyScrim = Color.black.opacity(0.55)
}
@@ -0,0 +1,66 @@
import Foundation
/// Farbton, Sättigung, Helligkeit — jeweils 0…1.
///
/// Die Kontrastkorrektur arbeitet in diesem Raum, weil sich darin die
/// Eigenschaft „gleiche Farbe, nur heller" als einzelne Zahl ausdrücken lässt.
/// In RGB müsste man dafür alle drei Kanäle gleichzeitig verschieben.
public struct HSB: Sendable, Equatable {
public var hue: Double
public var saturation: Double
public var brightness: Double
public init(hue: Double, saturation: Double, brightness: Double) {
// Der Farbton ist zyklisch: 1,2 ist dasselbe wie 0,2. Abschneiden wäre
// hier falsch, weil Rot dabei zu Magenta würde.
self.hue = hue.truncatingRemainder(dividingBy: 1).nonNegativeHue()
self.saturation = saturation.clamped()
self.brightness = brightness.clamped()
}
public init(_ rgb: RGB) {
let maximum = max(rgb.red, rgb.green, rgb.blue)
let minimum = min(rgb.red, rgb.green, rgb.blue)
let delta = maximum - minimum
let hue: Double
if delta == 0 {
hue = 0
} else if maximum == rgb.red {
hue = ((rgb.green - rgb.blue) / delta).truncatingRemainder(dividingBy: 6) / 6
} else if maximum == rgb.green {
hue = ((rgb.blue - rgb.red) / delta + 2) / 6
} else {
hue = ((rgb.red - rgb.green) / delta + 4) / 6
}
self.init(
hue: hue,
saturation: maximum == 0 ? 0 : delta / maximum,
brightness: maximum
)
}
public var rgb: RGB {
let sector = hue * 6
let index = Int(sector) % 6
let fraction = sector - Double(Int(sector))
let p = brightness * (1 - saturation)
let q = brightness * (1 - saturation * fraction)
let t = brightness * (1 - saturation * (1 - fraction))
switch index {
case 0: return RGB(red: brightness, green: t, blue: p)
case 1: return RGB(red: q, green: brightness, blue: p)
case 2: return RGB(red: p, green: brightness, blue: t)
case 3: return RGB(red: p, green: q, blue: brightness)
case 4: return RGB(red: t, green: p, blue: brightness)
default: return RGB(red: brightness, green: p, blue: q)
}
}
}
extension Double {
fileprivate func nonNegativeHue() -> Double { self < 0 ? self + 1 : self }
}
@@ -0,0 +1,65 @@
import SwiftUI
/// Eine Farbe als drei Kanäle im Bereich 0…1, unabhängig von SwiftUI.
///
/// `SwiftUI.Color` lässt sich nicht zuverlässig wieder auslesen — es ist ein
/// undurchsichtiger Wert, der erst beim Zeichnen aufgelöst wird. Für
/// Kontrastrechnungen brauchen wir die Zahlen aber vorher, deshalb dieser
/// eigene Typ. `Color` entsteht erst am Ende, in `swiftUIColor`.
public struct RGB: Sendable, Equatable, Hashable, Codable {
public var red: Double
public var green: Double
public var blue: Double
public init(red: Double, green: Double, blue: Double) {
self.red = red.clamped()
self.green = green.clamped()
self.blue = blue.clamped()
}
/// Aus einem Hexwert der Form `0xRRGGBB`.
public init(hex: UInt32) {
self.init(
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double(hex & 0xFF) / 255
)
}
/// Aus einer Eingabe wie `#1DB954`, `1db954` oder `#1DB`.
/// Gibt `nil` zurück, wenn die Zeichenkette keine Farbe ergibt — der
/// Einstellungsdialog kann damit direkt eine Fehlermeldung zeigen.
public init?(string: String) {
var text = string.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
if text.hasPrefix("#") { text.removeFirst() }
// Kurzform: #ABC meint #AABBCC.
if text.count == 3 {
text = text.map { "\($0)\($0)" }.joined()
}
guard text.count == 6, let value = UInt32(text, radix: 16) else { return nil }
self.init(hex: value)
}
public var hexString: String {
String(
format: "#%02X%02X%02X",
Int((red * 255).rounded()),
Int((green * 255).rounded()),
Int((blue * 255).rounded())
)
}
public var swiftUIColor: Color {
// Explizit sRGB. Ohne Angabe deutet SwiftUI die Werte je nach Kontext
// unterschiedlich, und dann stimmt der gerechnete Kontrast nicht mehr
// mit dem überein, was auf dem Bildschirm landet.
Color(.sRGB, red: red, green: green, blue: blue, opacity: 1)
}
}
extension Double {
func clamped(to range: ClosedRange<Double> = 0...1) -> Double {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}
@@ -0,0 +1,44 @@
import SwiftUI
/// Ein Bild vom Server mit Lade- und Fehlerzustand.
///
/// Kapselt `AsyncImage`, damit der Austausch gegen einen eigenen, zwischen-
/// speichernden Lader später genau eine Datei betrifft. Die Startseite zeigt
/// dutzende Bilder gleichzeitig; ohne Zwischenspeicher lädt `AsyncImage` sie
/// bei jedem Erscheinen neu.
public struct FlixyArtwork: View {
private let url: URL?
private let aspectRatio: CGFloat
private let fallbackSymbol: String
public init(url: URL?, aspectRatio: CGFloat, fallbackSymbol: String = "film") {
self.url = url
self.aspectRatio = aspectRatio
self.fallbackSymbol = fallbackSymbol
}
public var body: some View {
Rectangle()
.fill(Color.flixyPlaceholder)
.aspectRatio(aspectRatio, contentMode: .fit)
.overlay {
AsyncImage(url: url, transaction: Transaction(animation: .easeOut(duration: 0.2))) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
case .failure:
// Kein Fehlertext: bei einer Reihe aus zwanzig Kacheln
// wäre das mehr Lärm als Hilfe. Das Symbol sagt genug.
Image(systemName: fallbackSymbol)
.font(.title2)
.foregroundStyle(Color.flixyTextMuted)
case .empty:
ShimmerPlaceholder()
@unknown default:
ShimmerPlaceholder()
}
}
}
.clipped()
}
}
@@ -0,0 +1,76 @@
import SwiftUI
/// Das Aufmacher-Bild oben auf der Startseite.
///
/// Die Aktionsknöpfe kommen von außen, weil ein Film „Abspielen" und
/// „Herunterladen" braucht, eine angefangene Folge dagegen „Weiterschauen".
/// Der Baustein legt nur Bild, Verlauf und Textsatz fest.
public struct HeroBanner<Actions: View>: View {
private let title: String
private let metadata: String?
private let summary: String?
private let artworkURL: URL?
private let actions: Actions
public init(
title: String,
metadata: String? = nil,
summary: String? = nil,
artworkURL: URL?,
@ViewBuilder actions: () -> Actions
) {
self.title = title
self.metadata = metadata
self.summary = summary
self.artworkURL = artworkURL
self.actions = actions()
}
public var body: some View {
FlixyArtwork(url: artworkURL, aspectRatio: 3.0 / 4.0)
.overlay(alignment: .bottom) {
// Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in
// Schwarz: sonst entsteht genau dort, wo der Aufmacher in die
// erste Reihe übergeht, eine sichtbare Kante.
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: Color.flixyCanvas.opacity(0.75), location: 0.45),
.init(color: Color.flixyCanvas, location: 1),
],
startPoint: .top,
endPoint: .bottom
)
.frame(maxHeight: .infinity, alignment: .bottom)
.allowsHitTesting(false)
}
.overlay(alignment: .bottomLeading) {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
Text(title)
.font(.flixyHero)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(2)
if let metadata {
Text(metadata)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
}
if let summary {
Text(summary)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.lineLimit(2)
}
HStack(spacing: FlixySpacing.regular) {
actions
}
.padding(.top, FlixySpacing.tight)
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.bottom, FlixySpacing.loose)
}
}
}
@@ -0,0 +1,47 @@
import SwiftUI
/// Ein Titel im Querformat — die Kachelform der Startseite.
///
/// Anders als das Poster zeigt sie ein Szenenbild und trägt den Titel unter
/// dem Bild, weil Szenenbilder selten Schrift enthalten.
public struct LandscapeCard: View {
private let title: String
private let subtitle: String?
private let artworkURL: URL?
private let progress: Double?
public static let aspectRatio: CGFloat = 16.0 / 9.0
public init(title: String, subtitle: String? = nil, artworkURL: URL?, progress: Double? = nil) {
self.title = title
self.subtitle = subtitle
self.artworkURL = artworkURL
self.progress = progress
}
public var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
FlixyArtwork(url: artworkURL, aspectRatio: Self.aspectRatio, fallbackSymbol: "play.rectangle")
.overlay(alignment: .bottom) {
if let progress {
WatchProgressBar(fraction: progress)
}
}
.clipShape(.rect(cornerRadius: FlixyRadius.card))
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(1)
if let subtitle {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.lineLimit(1)
}
}
}
.accessibilityElement(children: .combine)
}
}
@@ -0,0 +1,50 @@
import SwiftUI
/// Ein Titel im Hochformat, wie er in Bibliotheksrastern steht.
public struct PosterCard: View {
private let title: String
private let subtitle: String?
private let artworkURL: URL?
private let progress: Double?
/// Das Seitenverhältnis von Kinoplakaten. Plex liefert Poster in diesem
/// Format; ein anderer Wert würde die Bilder beschneiden.
public static let aspectRatio: CGFloat = 2.0 / 3.0
public init(title: String, subtitle: String? = nil, artworkURL: URL?, progress: Double? = nil) {
self.title = title
self.subtitle = subtitle
self.artworkURL = artworkURL
self.progress = progress
}
public var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
FlixyArtwork(url: artworkURL, aspectRatio: Self.aspectRatio)
.clipShape(.rect(cornerRadius: FlixyRadius.card))
.overlay(alignment: .bottom) {
if let progress {
WatchProgressBar(fraction: progress)
}
}
.clipShape(.rect(cornerRadius: FlixyRadius.card))
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(1)
if let subtitle {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.lineLimit(1)
}
}
}
// Ohne diese Zusammenfassung liest VoiceOver Bild, Titel und Balken als
// drei getrennte Elemente vor — bei einer Reihe aus zwanzig Kacheln
// wird das Durchwischen dadurch dreimal so lang.
.accessibilityElement(children: .combine)
}
}
@@ -0,0 +1,41 @@
import SwiftUI
/// Die Platzhalterfläche, solange ein Bild noch lädt.
///
/// Ein wandernder Lichtschein statt eines Spinners: er zeigt die endgültige
/// Form des Inhalts schon an, wodurch die Startseite beim Aufbauen nicht
/// springt.
public struct ShimmerPlaceholder: View {
@State private var phase: CGFloat = -1
/// Bewegte Flächen sind für manche Menschen unangenehm; bei aktivierter
/// Systemeinstellung bleibt der Platzhalter deshalb einfach ruhig stehen.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
public init() {}
public var body: some View {
Rectangle()
.fill(Color.flixyPlaceholder)
.overlay {
if !reduceMotion {
GeometryReader { proxy in
LinearGradient(
colors: [.clear, Color.white.opacity(0.06), .clear],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: proxy.size.width * 0.6)
.offset(x: phase * proxy.size.width * 1.6)
}
}
}
.clipped()
.onAppear {
guard !reduceMotion else { return }
withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) {
phase = 1
}
}
.accessibilityHidden(true)
}
}
@@ -0,0 +1,28 @@
import SwiftUI
/// Der dünne Balken unter angefangenen Titeln.
public struct WatchProgressBar: View {
/// 0…1. Werte außerhalb werden abgeschnitten, damit ein fehlerhafter
/// Serverwert den Balken nicht über die Kachel hinausschieben kann.
private let fraction: Double
@Environment(\.flixyAccent) private var accent
public init(fraction: Double) {
self.fraction = fraction.clamped()
}
public var body: some View {
GeometryReader { proxy in
ZStack(alignment: .leading) {
Rectangle().fill(Color.white.opacity(0.15))
Rectangle()
.fill(accent)
.frame(width: proxy.size.width * fraction)
}
}
.frame(height: 3)
.accessibilityElement()
.accessibilityLabel(Text("progress.watched", bundle: .module))
.accessibilityValue(Text(fraction.formatted(.percent.precision(.fractionLength(0)))))
}
}
@@ -0,0 +1,41 @@
import SwiftUI
/// Die Schriftskala der App.
///
/// Durchgehend die Systemschrift mit `Font.TextStyle` als Grundlage, damit
/// Dynamic Type wirkt. Die Rolle steht im Namen, nicht die Größe — sonst
/// führt jede Anpassung zu einer Suche quer durch alle Views.
extension Font {
/// Der Titel im Aufmacher-Bild.
public static let flixyHero = Font.system(.largeTitle, design: .default, weight: .bold)
/// Titel auf Detailseiten.
public static let flixyTitle = Font.system(.title2, design: .default, weight: .semibold)
/// Überschrift einer Reihe auf der Startseite.
public static let flixyRowHeader = Font.system(.headline, design: .default, weight: .semibold)
/// Titel unter einem Poster.
public static let flixyCardTitle = Font.system(.subheadline, design: .default, weight: .medium)
/// Fließtext, Beschreibungen.
public static let flixyBody = Font.system(.body)
/// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht.
public static let flixyCaption = Font.system(.caption)
}
/// Abstände.
///
/// Eine Skala statt frei gewählter Zahlen: dadurch stimmen die Ränder zwischen
/// Bildschirmen überein, ohne dass man sie einzeln nachmisst.
public enum FlixySpacing {
public static let tight: CGFloat = 4
public static let snug: CGFloat = 8
public static let regular: CGFloat = 12
public static let loose: CGFloat = 16
public static let section: CGFloat = 24
/// Der seitliche Rand des Inhalts zum Bildschirmrand.
public static let screenMargin: CGFloat = 16
}
public enum FlixyRadius {
public static let card: CGFloat = 8
public static let sheet: CGFloat = 16
public static let control: CGFloat = 10
}
@@ -0,0 +1,65 @@
import SwiftUI
/// Hält die gewählte Akzentfarbe und sorgt dafür, dass sie lesbar bleibt.
///
/// Nach außen gibt es nur `accent` — schon kontrastkorrigiert. Views sollen
/// sich nie fragen müssen, ob die eingestellte Farbe dunkel genug ist, um
/// darauf zu achten.
@MainActor
@Observable
public final class FlixyTheme {
private let defaults: UserDefaults
private enum Key {
static let selectedIdentifier = "flixy.accent.identifier"
static let customHex = "flixy.accent.customHex"
}
/// Was in den Einstellungen ausgewählt ist — unkorrigiert, damit die
/// Farbkachel genau das zeigt, was angetippt wurde.
public private(set) var selection: AccentOption
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.selection = Self.restore(from: defaults)
}
/// Die Farbe, die tatsächlich gezeichnet wird.
///
/// Bei den kuratierten Werten ändert die Korrektur nichts — sie erfüllen
/// den Grenzwert bereits. Sie greift nur bei eigenen Farben.
public var accent: RGB {
Contrast.legible(selection.color, on: FlixyColor.canvas)
}
public var accentColor: Color { accent.swiftUIColor }
/// Ob die Korrektur bei der aktuellen Auswahl eingegriffen hat. Der
/// Einstellungsdialog kann darauf hinweisen, statt den Nutzer rätseln zu
/// lassen, warum seine Farbe heller aussieht als gewählt.
public var accentWasAdjusted: Bool { accent != selection.color }
public func select(_ option: AccentOption) {
selection = option
defaults.set(option.id, forKey: Key.selectedIdentifier)
if option.id == AccentOption.customIdentifier {
defaults.set(option.color.hexString, forKey: Key.customHex)
}
}
private static func restore(from defaults: UserDefaults) -> AccentOption {
guard let identifier = defaults.string(forKey: Key.selectedIdentifier) else {
return .default
}
if identifier == AccentOption.customIdentifier {
// Fällt die gespeicherte Zeichenkette aus (etwa weil jemand die
// Voreinstellungen von Hand bearbeitet hat), ist der Standard
// besser als eine Farbe, die niemand gewählt hat.
guard let hex = defaults.string(forKey: Key.customHex),
let color = RGB(string: hex)
else { return .default }
return .custom(color)
}
return AccentOption.palette.first { $0.id == identifier } ?? .default
}
}
@@ -0,0 +1,17 @@
{
"sourceLanguage" : "en",
"strings" : {
"progress.watched" : {
"comment" : "Barrierefreiheits-Beschriftung des Fortschrittsbalkens unter angefangenen Titeln",
"localizations" : {
"de" : {
"stringUnit" : { "state" : "translated", "value" : "Fortschritt" }
},
"en" : {
"stringUnit" : { "state" : "translated", "value" : "Progress" }
}
}
}
},
"version" : "1.0"
}
@@ -0,0 +1,61 @@
import Foundation
/// Der Lebenslauf eines Downloads.
///
/// Bewusst als geschlossene Aufzählung mit einer geprüften Übergangstabelle:
/// die Engine läuft im Hintergrund weiter, während die App beendet ist, und
/// beim nächsten Start muss aus dem gespeicherten Zustand eindeutig
/// hervorgehen, was zu tun ist.
public enum DownloadState: String, Sendable, Equatable, Codable, CaseIterable {
/// Steht in der Warteschlange, hat noch keinen Platz bekommen.
case queued
/// Überträgt gerade.
case downloading
/// Vom Nutzer angehalten — der Wiederaufnahme-Datensatz liegt bereit.
case paused
/// Wartet auf Bedingungen: WLAN fehlt, Speicherlimit erreicht.
case waiting
case completed
case failed
/// Ob aus diesem Zustand heraus übertragen werden darf.
public var isTransferable: Bool {
switch self {
case .queued, .waiting: true
case .downloading, .paused, .completed, .failed: false
}
}
/// Ob der Eintrag Platz auf dem Gerät belegt.
public var occupiesStorage: Bool {
switch self {
// Angehaltene und laufende Downloads haben bereits Daten auf der
// Platte. Wer sie beim Speicherlimit nicht mitzählt, wundert sich,
// warum das Gerät trotz gesetzter Grenze volläuft.
case .downloading, .paused, .completed: true
case .queued, .waiting, .failed: false
}
}
public func canTransition(to next: DownloadState) -> Bool {
switch (self, next) {
case (.queued, .downloading), (.queued, .waiting), (.queued, .failed):
true
case (.waiting, .downloading), (.waiting, .queued), (.waiting, .failed):
true
case (.downloading, .paused), (.downloading, .completed),
(.downloading, .failed), (.downloading, .waiting):
true
case (.paused, .queued), (.paused, .downloading), (.paused, .failed):
true
// Ein gescheiterter Download darf neu in die Warteschlange — das ist
// der Wiederholen-Knopf.
case (.failed, .queued):
true
case (.completed, _):
false
default:
false
}
}
}
@@ -0,0 +1,35 @@
import Foundation
/// Was AVPlayer auf diesem Gerät nativ abspielen kann.
///
/// Diese Liste ist der Grund, warum Flixy überhaupt zwei Motoren hat. iOS
/// spielt weder Matroska-Dateien noch DTS- oder TrueHD-Ton — und genau das
/// liegt in typischen Plex-Beständen. Alles, was hier nicht steht, geht an
/// VLCKit (oder ab Phase 2 an den Transcoder).
public struct DeviceCapabilities: Sendable, Equatable {
public var containers: Set<String>
public var videoCodecs: Set<String>
public var audioCodecs: Set<String>
public init(containers: Set<String>, videoCodecs: Set<String>, audioCodecs: Set<String>) {
self.containers = containers
self.videoCodecs = videoCodecs
self.audioCodecs = audioCodecs
}
/// Der Stand für iOS 26 auf allen Geräten, die diese Version tragen
/// (A12 aufwärts).
public static let avFoundation = DeviceCapabilities(
// Kein `mkv`, kein `avi`, kein `ts`. Das ist keine Auslassung, sondern
// der Kern des Problems: Matroska ist der übliche Container für
// Remuxe, und AVFoundation öffnet ihn nicht.
containers: ["mp4", "m4v", "mov", "qt"],
// AV1 fehlt bewusst: Hardware-Dekodierung gibt es erst ab A17 Pro, und
// Software-Dekodierung von 4K-AV1 bringt auch neue Geräte an die
// Grenze. VLC macht das kontrollierter.
videoCodecs: ["h264", "avc1", "hevc", "h265", "hvc1", "dvh1", "dvhe"],
// DTS, TrueHD und Vorbis fehlen — die drei Gründe, warum ein sonst
// tadelloser MP4-Film trotzdem stumm bliebe.
audioCodecs: ["aac", "mp3", "ac3", "eac3", "alac", "pcm", "lpcm"]
)
}
@@ -0,0 +1,101 @@
import Foundation
import PlexKit
/// Entscheidet, wie ein Titel abgespielt wird.
///
/// Reine Rechnung ohne Netz und ohne Zustand — deshalb vollständig testbar.
/// Die zugehörige Testtabelle in `PlaybackDeciderTests` ist die wichtigste im
/// Projekt: sie hält fest, welche Datei auf welchem Weg landet, und schlägt
/// an, sobald jemand die Fähigkeitenliste anfasst.
public struct PlaybackDecider: Sendable {
private let capabilities: DeviceCapabilities
public init(capabilities: DeviceCapabilities = .avFoundation) {
self.capabilities = capabilities
}
public func decide(
media: PlexMedia,
part: PlexPart,
policy: PlaybackPolicy
) -> PlaybackDecision {
var rationale: [PlaybackDecision.Rationale] = []
// 1. Reicht die Leitung? Das kommt zuerst, weil es unabhängig vom
// Format ist: eine 80-Mbit-Datei über Mobilfunk ist auch dann keine
// gute Idee, wenn AVPlayer sie problemlos öffnen könnte.
if let limit = policy.maximumBitrate, let bitrate = media.bitrate, bitrate > limit {
rationale.append(.bitrateExceedsLimit(mediaBitrate: bitrate, limit: limit))
if policy.allowsTranscoding {
return PlaybackDecision(method: .transcode(maximumBitrate: limit), rationale: rationale)
}
// Ohne Transcoder bleibt nur, es trotzdem zu versuchen. Das ist
// ehrlicher als eine Fehlermeldung: im WLAN klappt es meistens,
// und der Grund steht in der Begründung.
rationale.append(.transcodingUnavailable)
}
// 2. Kennt der Server die Datei überhaupt?
let container = normalized(part.container ?? media.container)
guard !part.streams.isEmpty, let videoCodec = normalized(media.videoCodec ?? part.videoStream?.codec) else {
rationale.append(.streamsUnknown)
return fallback(policy: policy, rationale: rationale)
}
// 3. Kann AVPlayer das?
var blockers: [PlaybackDecision.Rationale] = []
if let container, !capabilities.containers.contains(container) {
blockers.append(.containerUnsupported(container))
}
if !capabilities.videoCodecs.contains(videoCodec) {
blockers.append(.videoCodecUnsupported(videoCodec))
}
// Geprüft wird die Spur, die tatsächlich klingen soll. Eine Datei mit
// deutschem AAC und englischem DTS ist für AVPlayer in Ordnung,
// solange Deutsch gewählt ist.
if let audioCodec = normalized(part.selectedAudioStream?.codec ?? media.audioCodec),
!capabilities.audioCodecs.contains(audioCodec) {
blockers.append(.audioCodecUnsupported(audioCodec))
}
guard blockers.isEmpty else {
rationale.append(contentsOf: blockers)
return fallback(policy: policy, rationale: rationale)
}
rationale.append(.nativelySupported)
return PlaybackDecision(method: .directPlay(engine: .avPlayer), rationale: rationale)
}
/// Was passiert, wenn AVPlayer nicht kann.
///
/// VLCKit hat Vorrang vor dem Transcoder: die Datei geht unverändert über
/// die Leitung, der Server muss nicht rechnen, und die Bildqualität bleibt
/// die des Originals. Umgerechnet wird erst, wenn auch VLC ausscheidet.
private func fallback(
policy: PlaybackPolicy,
rationale: [PlaybackDecision.Rationale]
) -> PlaybackDecision {
if policy.allowsFallbackEngine {
return PlaybackDecision(method: .directPlay(engine: .vlc), rationale: rationale)
}
if policy.allowsTranscoding {
return PlaybackDecision(
method: .transcode(maximumBitrate: policy.maximumBitrate),
rationale: rationale
)
}
var rationale = rationale
rationale.append(.transcodingUnavailable)
return PlaybackDecision(method: .directPlay(engine: .avPlayer), rationale: rationale)
}
/// Plex schreibt Codecs mal groß, mal klein, und hängt bei Containern
/// gelegentlich Punkte an. Ein Vergleich ohne diese Angleichung geht
/// sporadisch schief — und zwar nur bei manchen Dateien, was die Suche
/// nach dem Grund unangenehm macht.
private func normalized(_ value: String?) -> String? {
guard let value, !value.isEmpty else { return nil }
return value.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ". "))
}
}
@@ -0,0 +1,58 @@
import Foundation
/// Welcher Motor die Wiedergabe übernimmt.
public enum PlaybackEngineKind: String, Sendable, Equatable, Codable {
/// Der Systemplayer. Bringt Bild-in-Bild, AirPlay und die Steuerung im
/// Sperrbildschirm mit — deshalb hat er immer Vorrang, wenn er kann.
case avPlayer
/// VLCKit. Spielt praktisch jedes Format, kann dafür kein Bild-in-Bild.
case vlc
}
/// Wie ein Titel abgespielt werden soll.
public struct PlaybackDecision: Sendable, Equatable {
public enum Method: Sendable, Equatable {
/// Die Datei wird unverändert übertragen und lokal dekodiert.
case directPlay(engine: PlaybackEngineKind)
/// Der Server rechnet um. Ab Phase 2; `maximumBitrate` in Kilobit.
case transcode(maximumBitrate: Int?)
}
/// Warum es so entschieden wurde.
///
/// Wandert in die technischen Infos auf der Detailseite und ins Protokoll.
/// Ohne diese Begründung ist „warum ruckelt das" nicht zu beantworten.
public enum Rationale: Sendable, Equatable {
case nativelySupported
case containerUnsupported(String)
case videoCodecUnsupported(String)
case audioCodecUnsupported(String)
/// Der Server hat die Datei noch nicht analysiert.
case streamsUnknown
case bitrateExceedsLimit(mediaBitrate: Int, limit: Int)
/// Transcoding wäre passender, ist aber (noch) nicht verfügbar.
case transcodingUnavailable
}
public let method: Method
public let rationale: [Rationale]
public init(method: Method, rationale: [Rationale]) {
self.method = method
self.rationale = rationale
}
public var engine: PlaybackEngineKind? {
switch method {
case .directPlay(let engine): engine
// Der Transcoder liefert HLS — das ist genau die Kost, für die
// AVPlayer gebaut ist, inklusive automatischem Bitratenwechsel.
case .transcode: .avPlayer
}
}
public var isDirectPlay: Bool {
if case .directPlay = method { return true }
return false
}
}
@@ -0,0 +1,30 @@
import Foundation
/// Was bei der Wiedergabe erlaubt ist.
///
/// Das ist die Stellschraube, an der Phase 2 andockt: heute steht
/// `allowsTranscoding` auf `false` und der Decider kommt zwangsläufig immer
/// bei DirectPlay heraus. Sobald der Transcoder steht, wird hier umgelegt —
/// die Views merken davon nichts.
public struct PlaybackPolicy: Sendable, Equatable {
/// Darf der Server umrechnen?
public var allowsTranscoding: Bool
/// Darf VLCKit einspringen?
public var allowsFallbackEngine: Bool
/// Obergrenze in Kilobit pro Sekunde. `nil` heißt unbegrenzt — der
/// Normalfall im heimischen WLAN.
public var maximumBitrate: Int?
public init(
allowsTranscoding: Bool,
allowsFallbackEngine: Bool = true,
maximumBitrate: Int? = nil
) {
self.allowsTranscoding = allowsTranscoding
self.allowsFallbackEngine = allowsFallbackEngine
self.maximumBitrate = maximumBitrate
}
/// Phase 1: alles direkt, nichts umrechnen.
public static let directPlayOnly = PlaybackPolicy(allowsTranscoding: false)
}
@@ -0,0 +1,71 @@
import Foundation
import PlexKit
/// Eine wählbare Ton- oder Untertitelspur.
///
/// Bewusst getrennt von `PlexStream`: VLCKit und AVPlayer nummerieren ihre
/// Spuren selbst, und die Nummern stimmen nicht mit denen von Plex überein.
/// `engineIdentifier` hält deshalb fest, wie der jeweilige Motor die Spur
/// nennt, während `plexStreamID` für den Server gedacht ist.
public struct MediaTrack: Sendable, Equatable, Identifiable {
public enum Kind: Sendable, Equatable { case audio, subtitle }
public let id: String
public let kind: Kind
public let displayName: String
public let languageCode: String?
public let plexStreamID: Int?
/// Die Nummer, unter der der laufende Motor die Spur führt.
public let engineIdentifier: Int?
public let isForced: Bool
public init(
id: String,
kind: Kind,
displayName: String,
languageCode: String? = nil,
plexStreamID: Int? = nil,
engineIdentifier: Int? = nil,
isForced: Bool = false
) {
self.id = id
self.kind = kind
self.displayName = displayName
self.languageCode = languageCode
self.plexStreamID = plexStreamID
self.engineIdentifier = engineIdentifier
self.isForced = isForced
}
}
extension MediaTrack {
/// Baut eine Spur aus dem, was Plex gemeldet hat.
///
/// `displayTitle` ist das, was Plex selbst in seinen Oberflächen zeigt
/// (etwa „Deutsch (AC3 5.1)"). Fehlt es, wird aus Sprache und Codec etwas
/// Brauchbares gebaut, statt „Spur 3" anzuzeigen.
public init?(stream: PlexStream) {
guard let kind = stream.kind else { return nil }
let trackKind: Kind
switch kind {
case .audio: trackKind = .audio
case .subtitle: trackKind = .subtitle
case .video: return nil
}
let name = stream.displayTitle
?? stream.title
?? [stream.language, stream.codec?.uppercased()]
.compactMap(\.self)
.joined(separator: " · ")
self.init(
id: "plex-\(stream.id)",
kind: trackKind,
displayName: name.isEmpty ? "—" : name,
languageCode: stream.languageCode,
plexStreamID: stream.id,
isForced: stream.isForced
)
}
}
@@ -0,0 +1,46 @@
import Foundation
/// Der Zustand der Wiedergabe, unabhängig vom Motor.
public enum PlaybackState: Sendable, Equatable {
case idle
/// Quelle wird geöffnet, es ist noch nichts zu sehen.
case loading
case playing
case paused
/// Läuft eigentlich, wartet aber auf Daten.
case buffering
case ended
case failed(PlaybackFailure)
public var isActive: Bool {
switch self {
case .playing, .buffering: true
case .idle, .loading, .paused, .ended, .failed: false
}
}
}
/// Ein Fehler bei der Wiedergabe, in der Sprache der App statt in der des
/// jeweiligen Motors.
public struct PlaybackFailure: Sendable, Equatable, Error {
public enum Kind: Sendable, Equatable {
/// Die Quelle war nicht erreichbar.
case sourceUnavailable
/// Der Motor kann das Format nicht — sollte nach einer korrekten
/// Entscheidung nicht vorkommen und ist deshalb ein Hinweis darauf,
/// dass die Fähigkeitenliste nachgezogen werden muss.
case formatUnsupported
case networkInterrupted
case unknown
}
public let kind: Kind
/// Der ursprüngliche Fehlertext. Wandert nicht in die Oberfläche, aber in
/// die Protokolle — sonst ist „geht nicht" alles, was man hat.
public let underlyingDescription: String?
public init(kind: Kind, underlyingDescription: String? = nil) {
self.kind = kind
self.underlyingDescription = underlyingDescription
}
}
@@ -0,0 +1,72 @@
import Foundation
/// Woher das Bild kommt.
public struct PlaybackSource: Sendable, Equatable {
public enum Origin: Sendable, Equatable {
/// Vom Server — per DirectPlay oder als HLS-Strom des Transcoders.
case remote
/// Aus dem Download-Ordner.
case local
}
public let url: URL
public let origin: Origin
/// Ab wo gestartet werden soll. Kommt aus dem zuletzt gemeldeten
/// Fortschritt; `.zero` heißt von vorn.
public let startTime: Duration
public init(url: URL, origin: Origin, startTime: Duration = .zero) {
self.url = url
self.origin = origin
self.startTime = startTime
}
}
/// Was ein Motor von sich aus meldet.
///
/// Ein Strom statt beobachtbarer Eigenschaften: AVPlayer meldet über KVO,
/// VLCKit über Delegat-Rückrufe. Beide in dieselbe Ereignisfolge zu
/// übersetzen hält den Unterschied an genau einer Stelle je Motor — statt in
/// jeder View, die den Zustand anzeigt.
public enum PlaybackEvent: Sendable, Equatable {
case stateChanged(PlaybackState)
case timeChanged(Duration)
case durationResolved(Duration)
case tracksResolved(audio: [MediaTrack], subtitle: [MediaTrack])
case reachedEnd
}
/// Der gemeinsame Nenner von AVPlayer und VLCKit.
///
/// Alles, was die Oberfläche zum Steuern braucht — und nichts darüber hinaus.
/// Was hier nicht steht, gibt es für die Views nicht; dadurch bleibt der
/// Austausch des Motors unsichtbar.
@MainActor
public protocol VideoPlayerEngine: AnyObject {
var events: AsyncStream<PlaybackEvent> { get }
var state: PlaybackState { get }
var currentTime: Duration { get }
var duration: Duration? { get }
/// Ob dieser Motor Bild-in-Bild kann. Steuert, ob der Knopf überhaupt
/// erscheint — ein Knopf, der bei manchen Dateien nichts tut, ist
/// schlimmer als keiner.
var supportsPictureInPicture: Bool { get }
func load(_ source: PlaybackSource) async throws(PlaybackFailure)
func play()
func pause()
func seek(to time: Duration) async
func setRate(_ rate: Double)
var audioTracks: [MediaTrack] { get }
var subtitleTracks: [MediaTrack] { get }
func selectAudioTrack(_ track: MediaTrack?)
/// `nil` schaltet die Untertitel ab.
func selectSubtitleTrack(_ track: MediaTrack?)
/// Gibt Puffer und Dekoder frei. Muss beim Verlassen des Players laufen,
/// sonst behält besonders VLCKit den Speicher.
func teardown()
}
@@ -0,0 +1,100 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
/// Wer wir gegenüber Plex sind.
///
/// Plex verlangt bei jeder Anfrage eine Handvoll `X-Plex-*`-Kopfzeilen. Die
/// wichtigste ist `X-Plex-Client-Identifier`: darüber erkennt der Server das
/// Gerät wieder. Ändert sie sich, gilt die App als neues Gerät — die
/// Anmeldung ist futsch und in der Geräteliste des Kontos sammelt sich Müll
/// an. Deshalb wird sie einmal erzeugt und dann festgehalten.
public struct PlexClientIdentity: Sendable, Equatable {
public let product: String
public let version: String
public let clientIdentifier: String
public let platform: String
public let platformVersion: String
public let device: String
public let deviceName: String
public init(
product: String,
version: String,
clientIdentifier: String,
platform: String,
platformVersion: String,
device: String,
deviceName: String
) {
self.product = product
self.version = version
self.clientIdentifier = clientIdentifier
self.platform = platform
self.platformVersion = platformVersion
self.device = device
self.deviceName = deviceName
}
/// Die Kopfzeilen für eine Anfrage — ohne Token, das kommt je nach Ziel
/// (plex.tv oder Server) aus einer anderen Quelle.
public var headers: [String: String] {
[
"X-Plex-Product": product,
"X-Plex-Version": version,
"X-Plex-Client-Identifier": clientIdentifier,
"X-Plex-Platform": platform,
"X-Plex-Platform-Version": platformVersion,
"X-Plex-Device": device,
"X-Plex-Device-Name": deviceName,
// Ohne diese Zeile antwortet Plex in XML. Die Modelle hier sind auf
// JSON ausgelegt, also fragen wir es ausdrücklich an.
"Accept": "application/json",
]
}
}
extension PlexClientIdentity {
private static let storageKey = "flixy.plex.clientIdentifier"
/// Baut die Kennung für das laufende Gerät und merkt sich die Client-ID.
///
/// `@MainActor`, weil `UIDevice.current` dort beheimatet ist. Das mit
/// `assumeIsolated` zu umgehen wäre ein Absturz mit Anlauf — die Kennung
/// wird ohnehin einmal beim Start gebaut.
@MainActor
public static func current(
product: String,
version: String,
defaults: UserDefaults = .standard
) -> PlexClientIdentity {
let identifier: String
if let stored = defaults.string(forKey: storageKey) {
identifier = stored
} else {
identifier = UUID().uuidString
defaults.set(identifier, forKey: storageKey)
}
#if canImport(UIKit)
let systemVersion = UIDevice.current.systemVersion
let model = UIDevice.current.model
let name = UIDevice.current.name
#else
let systemVersion = ProcessInfo.processInfo.operatingSystemVersionString
let model = "Unknown"
let name = ProcessInfo.processInfo.hostName
#endif
return PlexClientIdentity(
product: product,
version: version,
clientIdentifier: identifier,
platform: "iOS",
platformVersion: systemVersion,
device: model,
deviceName: name
)
}
}
@@ -0,0 +1,39 @@
import Foundation
/// Was beim Sprechen mit Plex schiefgehen kann.
///
/// Bewusst grob geschnitten: die Oberfläche muss unterscheiden können, ob sie
/// zur Anmeldung zurückschicken (`unauthorized`), einen Wiederholen-Knopf
/// zeigen (`transport`) oder einen Fehler melden soll. Feinere Abstufungen
/// hätten kein anderes Verhalten zur Folge.
public enum PlexError: Error, Sendable, Equatable {
/// Token fehlt oder wurde am Server zurückgezogen.
case unauthorized
/// Angefragte Ressource gibt es nicht — etwa ein gelöschter Titel.
case notFound
/// Der Server hat geantwortet, aber mit einem Fehlerstatus.
case server(status: Int)
/// Verbindung kam nicht zustande. Der Grund steht als Text drin, damit die
/// Fehlermeldung konkret werden kann statt „Etwas ist schiefgelaufen".
case transport(description: String)
/// Antwort kam an, ließ sich aber nicht lesen. Fast immer ein Zeichen
/// dafür, dass ein Feld in einer neueren Plex-Version anders heißt.
case decoding(description: String)
/// Es war kein Weg zum Server erreichbar — weder lokal noch über plex.tv.
case noReachableConnection
/// Ob ein erneuter Versuch überhaupt Aussicht auf Erfolg hat. Steuert, ob
/// die Oberfläche einen Wiederholen-Knopf anbietet.
public var isRetryable: Bool {
switch self {
case .transport, .noReachableConnection:
true
case .server(let status):
// 5xx ist ein Serverproblem und kann vorübergehen; 4xx liegt an
// der Anfrage und wird beim zweiten Mal genauso scheitern.
status >= 500
case .unauthorized, .notFound, .decoding:
false
}
}
}
@@ -0,0 +1,38 @@
import Foundation
/// Übersetzt eine HTTP-Antwort in entweder Nutzdaten oder einen `PlexError`.
public enum PlexResponse {
public static func validate(
data: Data,
response: URLResponse
) throws(PlexError) -> Data {
guard let http = response as? HTTPURLResponse else {
throw .transport(description: "Antwort war keine HTTP-Antwort")
}
switch http.statusCode {
case 200..<300:
return data
case 401, 403:
throw .unauthorized
case 404:
throw .notFound
default:
throw .server(status: http.statusCode)
}
}
public static func decode<T: Decodable>(
_ type: T.Type,
from data: Data
) throws(PlexError) -> T {
do {
return try JSONDecoder().decode(type, from: data)
} catch {
// Der Fehlertext von `DecodingError` nennt den Schlüsselpfad. Das
// ist beim Nachziehen einer geänderten Plex-Version Gold wert,
// deshalb wird er mitgeschleppt statt verschluckt.
throw .decoding(description: String(describing: error))
}
}
}
@@ -0,0 +1,71 @@
import Foundation
/// Der schmale Spalt, durch den PlexKit ans Netz kommt.
///
/// Alles darüber kennt nur dieses Protokoll. Dadurch laufen die Tests gegen
/// aufgezeichnete Antworten statt gegen einen echten Server — und zwar ohne
/// `URLProtocol`-Verrenkungen, die bei paralleler Testausführung erfahrungs-
/// gemäß ins Rutschen kommen.
public protocol PlexTransport: Sendable {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: PlexTransport {}
/// Eine Anfrage an Plex, unabhängig davon, an welchen Host sie geht.
///
/// Plex nimmt den Token wahlweise als Kopfzeile oder als Abfrageparameter.
/// Hier steht er in der Kopfzeile — sonst landet er in jedem Protokoll und in
/// jedem Absturzbericht, der URLs mitschreibt.
public struct PlexRequest: Sendable, Equatable {
public enum Method: String, Sendable { case get = "GET", post = "POST", put = "PUT", delete = "DELETE" }
public var method: Method
public var path: String
public var query: [URLQueryItem]
public var additionalHeaders: [String: String]
public init(
method: Method = .get,
path: String,
query: [URLQueryItem] = [],
additionalHeaders: [String: String] = [:]
) {
self.method = method
self.path = path
self.query = query
self.additionalHeaders = additionalHeaders
}
/// Setzt die Anfrage gegen einen Host zusammen.
public func urlRequest(
baseURL: URL,
identity: PlexClientIdentity,
token: String?
) throws(PlexError) -> URLRequest {
guard var components = URLComponents(
url: baseURL.appending(path: path),
resolvingAgainstBaseURL: false
) else {
throw .transport(description: "Ungültige Basisadresse: \(baseURL)")
}
components.queryItems = query.isEmpty ? nil : query
guard let url = components.url else {
throw .transport(description: "Anfrage ließ sich nicht zusammensetzen: \(path)")
}
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
for (field, value) in identity.headers {
request.setValue(value, forHTTPHeaderField: field)
}
for (field, value) in additionalHeaders {
request.setValue(value, forHTTPHeaderField: field)
}
if let token {
request.setValue(token, forHTTPHeaderField: "X-Plex-Token")
}
return request
}
}
@@ -0,0 +1,188 @@
import Foundation
/// Ein Eintrag in der Mediathek — Film, Serie, Staffel oder Folge.
///
/// Plex verwendet für alle vier denselben Datensatz und unterscheidet über
/// `type`. Das ist ungewohnt, aber praktisch: Suchergebnisse und Reihen auf
/// der Startseite mischen die Arten, und ein gemeinsamer Typ erspart das
/// Aufteilen bei jedem Abruf.
public struct PlexItem: Sendable, Equatable, Codable, Identifiable {
public enum Kind: String, Sendable, Codable {
case movie, show, season, episode, clip, collection
}
/// Die Kennung, unter der der Server den Eintrag führt. Kommt als
/// Zeichenkette, obwohl es eine Zahl ist.
public let ratingKey: String
public let key: String
public let guid: String?
public let type: String
public let title: String
public let titleSort: String?
public let summary: String?
public let year: Int?
/// Laufzeit in Millisekunden.
public let duration: Int?
public let contentRating: String?
public let rating: Double?
public let audienceRating: Double?
// Bildpfade. Relativ zum Server — die vollständige Adresse entsteht erst
// beim Abruf, weil sie Token und Größenangaben braucht.
public let thumb: String?
public let art: String?
public let parentThumb: String?
public let grandparentThumb: String?
public let grandparentArt: String?
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
// Staffel, `grandparentTitle` der Serientitel.
public let index: Int?
public let parentIndex: Int?
public let parentTitle: String?
public let parentRatingKey: String?
public let grandparentTitle: String?
public let grandparentRatingKey: String?
// Fortschritt.
public let viewCount: Int?
/// Wie weit gesehen wurde, in Millisekunden.
public let viewOffset: Int?
public let lastViewedAt: Int?
public let addedAt: Int?
// Zählwerte bei Serien und Staffeln.
public let leafCount: Int?
public let viewedLeafCount: Int?
public let childCount: Int?
public let media: [PlexMedia]
private enum CodingKeys: String, CodingKey {
case ratingKey, key, guid, type, title, titleSort, summary, year, duration
case contentRating, rating, audienceRating
case thumb, art, parentThumb, grandparentThumb, grandparentArt
case index, parentIndex, parentTitle, parentRatingKey
case grandparentTitle, grandparentRatingKey
case viewCount, viewOffset, lastViewedAt, addedAt
case leafCount, viewedLeafCount, childCount
case media = "Media"
}
/// Von Hand geschrieben statt erzeugt, weil der erzeugte Decoder einen
/// `Media`-Block verlangen würde. Plex liefert Einträge aber schon aus,
/// bevor es die Dateien analysiert hat — ohne diese Nachsicht wäre eine
/// frisch hinzugefügte Staffel als Ganzes unlesbar statt nur unvollständig.
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.ratingKey = try values.decode(String.self, forKey: .ratingKey)
self.key = try values.decodeIfPresent(String.self, forKey: .key) ?? "/library/metadata/\(ratingKey)"
self.guid = try values.decodeIfPresent(String.self, forKey: .guid)
self.type = try values.decode(String.self, forKey: .type)
self.title = try values.decode(String.self, forKey: .title)
self.titleSort = try values.decodeIfPresent(String.self, forKey: .titleSort)
self.summary = try values.decodeIfPresent(String.self, forKey: .summary)
self.year = try values.decodeIfPresent(Int.self, forKey: .year)
self.duration = try values.decodeIfPresent(Int.self, forKey: .duration)
self.contentRating = try values.decodeIfPresent(String.self, forKey: .contentRating)
self.rating = try values.decodeIfPresent(Double.self, forKey: .rating)
self.audienceRating = try values.decodeIfPresent(Double.self, forKey: .audienceRating)
self.thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
self.art = try values.decodeIfPresent(String.self, forKey: .art)
self.parentThumb = try values.decodeIfPresent(String.self, forKey: .parentThumb)
self.grandparentThumb = try values.decodeIfPresent(String.self, forKey: .grandparentThumb)
self.grandparentArt = try values.decodeIfPresent(String.self, forKey: .grandparentArt)
self.index = try values.decodeIfPresent(Int.self, forKey: .index)
self.parentIndex = try values.decodeIfPresent(Int.self, forKey: .parentIndex)
self.parentTitle = try values.decodeIfPresent(String.self, forKey: .parentTitle)
self.parentRatingKey = try values.decodeIfPresent(String.self, forKey: .parentRatingKey)
self.grandparentTitle = try values.decodeIfPresent(String.self, forKey: .grandparentTitle)
self.grandparentRatingKey = try values.decodeIfPresent(String.self, forKey: .grandparentRatingKey)
self.viewCount = try values.decodeIfPresent(Int.self, forKey: .viewCount)
self.viewOffset = try values.decodeIfPresent(Int.self, forKey: .viewOffset)
self.lastViewedAt = try values.decodeIfPresent(Int.self, forKey: .lastViewedAt)
self.addedAt = try values.decodeIfPresent(Int.self, forKey: .addedAt)
self.leafCount = try values.decodeIfPresent(Int.self, forKey: .leafCount)
self.viewedLeafCount = try values.decodeIfPresent(Int.self, forKey: .viewedLeafCount)
self.childCount = try values.decodeIfPresent(Int.self, forKey: .childCount)
self.media = try values.decodeIfPresent([PlexMedia].self, forKey: .media) ?? []
}
/// `ratingKey` ist serverweit eindeutig und eignet sich deshalb direkt
/// als Kennung für `ForEach`.
public var id: String { ratingKey }
public var kind: Kind? { Kind(rawValue: type) }
/// Wie weit der Titel gesehen ist, als Anteil von 0 bis 1.
///
/// `nil`, wenn noch gar nicht angefangen wurde — die Kachel zeigt dann
/// keinen Balken statt eines leeren.
public var watchProgress: Double? {
guard let viewOffset, let duration, duration > 0, viewOffset > 0 else { return nil }
return min(Double(viewOffset) / Double(duration), 1)
}
public var isWatched: Bool { (viewCount ?? 0) > 0 }
/// Die Fassung, die abgespielt werden soll, wenn der Nutzer nichts anderes
/// wählt: die mit der höchsten Bitrate. Plex sortiert nicht zuverlässig,
/// deshalb wird hier selbst ausgewählt.
public var preferredMedia: PlexMedia? {
media.max { ($0.bitrate ?? 0) < ($1.bitrate ?? 0) }
}
/// Ob die Detailseite eine Versionsauswahl anbieten muss.
public var hasMultipleVersions: Bool { media.count > 1 }
}
/// Die Hülle, in die Plex jede Antwort packt.
///
/// Der Name des Inhaltsfelds wechselt je nach Endpunkt (`Metadata`,
/// `Directory`, `Hub`), die Hülle selbst bleibt gleich. Deshalb ist sie
/// generisch über den Inhalt.
public struct PlexMediaContainer<Content: Decodable & Sendable>: Sendable, Decodable {
public let size: Int?
/// Wie viele Einträge es insgesamt gibt — wichtig fürs seitenweise
/// Nachladen, weil `size` nur die aktuelle Seite zählt.
public let totalSize: Int?
public let offset: Int?
public let title1: String?
public let title2: String?
public let content: [Content]
private enum OuterKeys: String, CodingKey { case mediaContainer = "MediaContainer" }
private enum InnerKeys: String, CodingKey {
case size, totalSize, offset, title1, title2
case metadata = "Metadata"
case directory = "Directory"
}
public init(from decoder: any Decoder) throws {
let outer = try decoder.container(keyedBy: OuterKeys.self)
let inner = try outer.nestedContainer(keyedBy: InnerKeys.self, forKey: .mediaContainer)
self.size = try inner.decodeIfPresent(Int.self, forKey: .size)
self.totalSize = try inner.decodeIfPresent(Int.self, forKey: .totalSize)
self.offset = try inner.decodeIfPresent(Int.self, forKey: .offset)
self.title1 = try inner.decodeIfPresent(String.self, forKey: .title1)
self.title2 = try inner.decodeIfPresent(String.self, forKey: .title2)
// `Metadata` bei Titeln, `Directory` bei Bibliotheken und Staffeln.
// Eine leere Mediathek liefert gar kein Feld — das ist kein Fehler,
// sondern bedeutet schlicht null Einträge.
if let metadata = try inner.decodeIfPresent([Content].self, forKey: .metadata) {
self.content = metadata
} else if let directory = try inner.decodeIfPresent([Content].self, forKey: .directory) {
self.content = directory
} else {
self.content = []
}
}
}
@@ -0,0 +1,142 @@
import Foundation
/// Eine Datei auf der Platte des Servers.
///
/// Plex schachtelt hier zweifach: ein Titel hat mehrere `Media` (dieselbe
/// Fassung in 4K und 1080p), und jedes `Media` hat `Part` (ein Film, der auf
/// zwei Dateien verteilt ist). Für die Wiedergabe zählt der `Part` — er trägt
/// den Pfad.
public struct PlexPart: Sendable, Equatable, Codable, Identifiable {
public let id: Int
/// Der Pfad, unter dem die Datei abrufbar ist.
public let key: String
public let container: String?
public let size: Int64?
public let duration: Int?
public let file: String?
public let streams: [PlexStream]
private enum CodingKeys: String, CodingKey {
case id, key, container, size, duration, file
// Plex schreibt verschachtelte Sammlungen groß.
case streams = "Stream"
}
public init(
id: Int,
key: String,
container: String? = nil,
size: Int64? = nil,
duration: Int? = nil,
file: String? = nil,
streams: [PlexStream] = []
) {
self.id = id
self.key = key
self.container = container
self.size = size
self.duration = duration
self.file = file
self.streams = streams
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(Int.self, forKey: .id)
self.key = try container.decode(String.self, forKey: .key)
self.container = try container.decodeIfPresent(String.self, forKey: .container)
self.size = try container.decodeIfPresent(Int64.self, forKey: .size)
self.duration = try container.decodeIfPresent(Int.self, forKey: .duration)
self.file = try container.decodeIfPresent(String.self, forKey: .file)
// Fehlt die Spurenliste, ist die Datei noch nicht analysiert. Das ist
// kein Fehler — es heißt nur, dass wir nicht über DirectPlay
// entscheiden können und im Zweifel transcodieren lassen.
self.streams = try container.decodeIfPresent([PlexStream].self, forKey: .streams) ?? []
}
public var videoStream: PlexStream? { streams.first { $0.kind == .video } }
public var audioStreams: [PlexStream] { streams.filter { $0.kind == .audio } }
public var subtitleStreams: [PlexStream] { streams.filter { $0.kind == .subtitle } }
/// Die Tonspur, die der Server als aktiv meldet — sonst die erste.
public var selectedAudioStream: PlexStream? {
audioStreams.first { $0.isSelected } ?? audioStreams.first
}
}
/// Eine Fassung eines Titels.
public struct PlexMedia: Sendable, Equatable, Codable, Identifiable {
public let id: Int
public let container: String?
public let videoCodec: String?
public let audioCodec: String?
/// Plex meldet hier Werte wie `"1080"`, `"4k"` oder `"sd"` — keine Zahl.
public let videoResolution: String?
public let videoProfile: String?
/// In Kilobit pro Sekunde.
public let bitrate: Int?
public let width: Int?
public let height: Int?
public let audioChannels: Int?
public let duration: Int?
public let parts: [PlexPart]
private enum CodingKeys: String, CodingKey {
case id, container, videoCodec, audioCodec, videoResolution, videoProfile
case bitrate, width, height, audioChannels, duration
case parts = "Part"
}
public init(
id: Int,
container: String? = nil,
videoCodec: String? = nil,
audioCodec: String? = nil,
videoResolution: String? = nil,
videoProfile: String? = nil,
bitrate: Int? = nil,
width: Int? = nil,
height: Int? = nil,
audioChannels: Int? = nil,
duration: Int? = nil,
parts: [PlexPart] = []
) {
self.id = id
self.container = container
self.videoCodec = videoCodec
self.audioCodec = audioCodec
self.videoResolution = videoResolution
self.videoProfile = videoProfile
self.bitrate = bitrate
self.width = width
self.height = height
self.audioChannels = audioChannels
self.duration = duration
self.parts = parts
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(Int.self, forKey: .id)
self.container = try container.decodeIfPresent(String.self, forKey: .container)
self.videoCodec = try container.decodeIfPresent(String.self, forKey: .videoCodec)
self.audioCodec = try container.decodeIfPresent(String.self, forKey: .audioCodec)
self.videoResolution = try container.decodeIfPresent(String.self, forKey: .videoResolution)
self.videoProfile = try container.decodeIfPresent(String.self, forKey: .videoProfile)
self.bitrate = try container.decodeIfPresent(Int.self, forKey: .bitrate)
self.width = try container.decodeIfPresent(Int.self, forKey: .width)
self.height = try container.decodeIfPresent(Int.self, forKey: .height)
self.audioChannels = try container.decodeIfPresent(Int.self, forKey: .audioChannels)
self.duration = try container.decodeIfPresent(Int.self, forKey: .duration)
self.parts = try container.decodeIfPresent([PlexPart].self, forKey: .parts) ?? []
}
/// Eine kurze Beschreibung für die Versionsauswahl auf der Detailseite,
/// etwa „4K · HEVC · 38,2 Mbit/s".
public var qualityLabel: String {
var pieces: [String] = []
if let videoResolution { pieces.append(videoResolution.uppercased()) }
if let videoCodec { pieces.append(videoCodec.uppercased()) }
if let bitrate { pieces.append("\(bitrate / 1000) Mbit/s") }
return pieces.joined(separator: " · ")
}
}
@@ -0,0 +1,74 @@
import Foundation
/// Eine einzelne Spur in einer Datei: Bild, Ton oder Untertitel.
///
/// Die Feldnamen folgen der JSON-Antwort von Plex. Was dort fehlen darf, ist
/// hier optional — Plex lässt bei unvollständig analysierten Dateien ganze
/// Felder weg, und ein nicht-optionales Feld würde den gesamten Titel
/// unlesbar machen statt nur eine Angabe.
public struct PlexStream: Sendable, Equatable, Codable, Identifiable {
/// Plex unterscheidet die Spurart über eine Zahl.
public enum Kind: Int, Sendable, Codable {
case video = 1
case audio = 2
case subtitle = 3
}
public let id: Int
public let streamType: Int
public let codec: String?
public let language: String?
public let languageCode: String?
public let displayTitle: String?
public let title: String?
/// Bei Ton: die Kanalzahl. Entscheidet mit darüber, ob AVPlayer den
/// Ton annimmt — 5.1-DTS kann es nicht, 2.0-AAC schon.
public let channels: Int?
public let bitDepth: Int?
/// Nur bei Untertiteln: liegt die Spur als eigene Datei neben dem Video?
/// Solche lassen sich bei DirectPlay dazuschalten, eingebettete nicht
/// immer.
public let key: String?
private let selected: Bool?
private let `default`: Bool?
private let forced: Bool?
public var kind: Kind? { Kind(rawValue: streamType) }
public var isSelected: Bool { selected ?? false }
public var isDefault: Bool { `default` ?? false }
public var isForced: Bool { forced ?? false }
/// Untertitel, die als eigene Datei vorliegen.
public var isExternal: Bool { key != nil }
public init(
id: Int,
streamType: Int,
codec: String? = nil,
language: String? = nil,
languageCode: String? = nil,
displayTitle: String? = nil,
title: String? = nil,
channels: Int? = nil,
bitDepth: Int? = nil,
key: String? = nil,
selected: Bool? = nil,
default: Bool? = nil,
forced: Bool? = nil
) {
self.id = id
self.streamType = streamType
self.codec = codec
self.language = language
self.languageCode = languageCode
self.displayTitle = displayTitle
self.title = title
self.channels = channels
self.bitDepth = bitDepth
self.key = key
self.selected = selected
self.default = `default`
self.forced = forced
}
}