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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user