From 1ed64bf166259666ef69922acb9686f16ecf6bf5 Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 11 Sep 2026 16:04:40 +0200 Subject: [PATCH] =?UTF-8?q?Phase=200:=20Projektger=C3=BCst,=20Design-Token?= =?UTF-8?q?s=20und=20Navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 11 + Flixy/Branding.swift | 27 +++ Flixy/FlixyApp.swift | 24 +++ Flixy/Info.plist | 55 +++++ .../AppIcon.appiconset/Contents.json | 4 + Flixy/Resources/Assets.xcassets/Contents.json | 1 + .../LaunchBackground.colorset/Contents.json | 12 ++ Flixy/Resources/Localizable.xcstrings | 159 ++++++++++++++ Flixy/Views/Navigation/DrawerContainer.swift | 89 ++++++++ Flixy/Views/Navigation/RootView.swift | 81 +++++++ .../Views/Navigation/SidebarDestination.swift | 46 ++++ Flixy/Views/Navigation/SidebarView.swift | 99 +++++++++ .../Views/Placeholder/PlaceholderScreen.swift | 23 ++ Packages/FlixyKit/Package.swift | 49 +++++ .../FlixyDesign/Color/AccentEnvironment.swift | 25 +++ .../FlixyDesign/Color/AccentOption.swift | 51 +++++ .../Sources/FlixyDesign/Color/Contrast.swift | 89 ++++++++ .../FlixyDesign/Color/FlixyColor.swift | 45 ++++ .../Sources/FlixyDesign/Color/HSB.swift | 66 ++++++ .../Sources/FlixyDesign/Color/RGB.swift | 65 ++++++ .../FlixyDesign/Components/FlixyArtwork.swift | 44 ++++ .../FlixyDesign/Components/HeroBanner.swift | 76 +++++++ .../Components/LandscapeCard.swift | 47 ++++ .../FlixyDesign/Components/PosterCard.swift | 50 +++++ .../Components/ShimmerPlaceholder.swift | 41 ++++ .../Components/WatchProgressBar.swift | 28 +++ .../Sources/FlixyDesign/FlixyFont.swift | 41 ++++ .../Sources/FlixyDesign/FlixyTheme.swift | 65 ++++++ .../Sources/FlixyDesign/Localizable.xcstrings | 17 ++ .../FlixyDownloads/DownloadState.swift | 61 ++++++ .../Decision/DeviceCapabilities.swift | 35 +++ .../Decision/PlaybackDecider.swift | 101 +++++++++ .../Decision/PlaybackDecision.swift | 58 +++++ .../Decision/PlaybackPolicy.swift | 30 +++ .../FlixyPlayback/Engine/MediaTrack.swift | 71 ++++++ .../FlixyPlayback/Engine/PlaybackState.swift | 46 ++++ .../Engine/VideoPlayerEngine.swift | 72 +++++++ .../PlexKit/Client/PlexClientIdentity.swift | 100 +++++++++ .../Sources/PlexKit/Client/PlexError.swift | 39 ++++ .../Sources/PlexKit/Client/PlexResponse.swift | 38 ++++ .../PlexKit/Client/PlexTransport.swift | 71 ++++++ .../Sources/PlexKit/Models/PlexItem.swift | 188 ++++++++++++++++ .../Sources/PlexKit/Models/PlexMedia.swift | 142 ++++++++++++ .../Sources/PlexKit/Models/PlexStream.swift | 74 +++++++ .../FlixyDesignTests/ContrastTests.swift | 127 +++++++++++ .../DownloadStateTests.swift | 79 +++++++ .../PlaybackDeciderTests.swift | 173 +++++++++++++++ .../Tests/PlexKitTests/Fixtures/README.md | 16 ++ .../Fixtures/episodes-unanalyzed.json | 44 ++++ .../Fixtures/movie-with-two-versions.json | 89 ++++++++ .../PlexKitTests/PlexDecodingTests.swift | 203 ++++++++++++++++++ README.md | 79 +++++++ .../specs/2026-09-11-flixy-design.md | 135 ++++++++++++ project.yml | 83 +++++++ 54 files changed, 3584 insertions(+) create mode 100644 .gitignore create mode 100644 Flixy/Branding.swift create mode 100644 Flixy/FlixyApp.swift create mode 100644 Flixy/Info.plist create mode 100644 Flixy/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Flixy/Resources/Assets.xcassets/Contents.json create mode 100644 Flixy/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json create mode 100644 Flixy/Resources/Localizable.xcstrings create mode 100644 Flixy/Views/Navigation/DrawerContainer.swift create mode 100644 Flixy/Views/Navigation/RootView.swift create mode 100644 Flixy/Views/Navigation/SidebarDestination.swift create mode 100644 Flixy/Views/Navigation/SidebarView.swift create mode 100644 Flixy/Views/Placeholder/PlaceholderScreen.swift create mode 100644 Packages/FlixyKit/Package.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/Color/AccentEnvironment.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/Color/AccentOption.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/Color/Contrast.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/Color/FlixyColor.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/Color/HSB.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/Color/RGB.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/Components/FlixyArtwork.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/Components/LandscapeCard.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/Components/PosterCard.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/Components/ShimmerPlaceholder.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/Components/WatchProgressBar.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/FlixyTheme.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/Localizable.xcstrings create mode 100644 Packages/FlixyKit/Sources/FlixyDownloads/DownloadState.swift create mode 100644 Packages/FlixyKit/Sources/FlixyPlayback/Decision/DeviceCapabilities.swift create mode 100644 Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecider.swift create mode 100644 Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift create mode 100644 Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackPolicy.swift create mode 100644 Packages/FlixyKit/Sources/FlixyPlayback/Engine/MediaTrack.swift create mode 100644 Packages/FlixyKit/Sources/FlixyPlayback/Engine/PlaybackState.swift create mode 100644 Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift create mode 100644 Packages/FlixyKit/Sources/PlexKit/Client/PlexClientIdentity.swift create mode 100644 Packages/FlixyKit/Sources/PlexKit/Client/PlexError.swift create mode 100644 Packages/FlixyKit/Sources/PlexKit/Client/PlexResponse.swift create mode 100644 Packages/FlixyKit/Sources/PlexKit/Client/PlexTransport.swift create mode 100644 Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift create mode 100644 Packages/FlixyKit/Sources/PlexKit/Models/PlexMedia.swift create mode 100644 Packages/FlixyKit/Sources/PlexKit/Models/PlexStream.swift create mode 100644 Packages/FlixyKit/Tests/FlixyDesignTests/ContrastTests.swift create mode 100644 Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadStateTests.swift create mode 100644 Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift create mode 100644 Packages/FlixyKit/Tests/PlexKitTests/Fixtures/README.md create mode 100644 Packages/FlixyKit/Tests/PlexKitTests/Fixtures/episodes-unanalyzed.json create mode 100644 Packages/FlixyKit/Tests/PlexKitTests/Fixtures/movie-with-two-versions.json create mode 100644 Packages/FlixyKit/Tests/PlexKitTests/PlexDecodingTests.swift create mode 100644 README.md create mode 100644 docs/superpowers/specs/2026-09-11-flixy-design.md create mode 100644 project.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f82855 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +build/ +.build/ +DerivedData/ +*.xcuserstate +xcuserdata/ +*.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist + +# Von XcodeGen aus project.yml erzeugt — nicht eingecheckt, damit es keine +# Konflikte in der pbxproj gibt. Nach dem Klonen: xcodegen generate +Flixy.xcodeproj/ diff --git a/Flixy/Branding.swift b/Flixy/Branding.swift new file mode 100644 index 0000000..299711d --- /dev/null +++ b/Flixy/Branding.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Alles, was den Namen der App trägt — an einer Stelle. +/// +/// Der Name steht noch nicht fest. Damit ein späterer Wechsel nicht zur Suche +/// quer durch den Quelltext wird, greift die App nirgends auf ein hart +/// geschriebenes „Flixy" zu, sondern immer hierauf. Zu ändern sind dann genau +/// zwei Dinge: diese Datei und `project.yml` (Produktname, Bundle-Kennung, +/// Anzeigename). +/// +/// Die Typen aus FlixyKit behalten ihre Namen — sie sind Bauteile, keine Marke. +enum Branding { + /// Wie die App sich selbst nennt. + static let appName = "Flixy" + + /// Unter welchem Namen die App in der Geräteliste des Plex-Kontos steht. + /// + /// Wandert in die Kopfzeile `X-Plex-Product`. Eine Änderung hier legt beim + /// nächsten Anmelden einen neuen Eintrag an — der alte bleibt als Karteileiche + /// stehen, bis er von Hand entfernt wird. + static let plexProduct = appName + + /// Die Version aus dem Bundle, für die Kopfzeile `X-Plex-Version`. + static var version: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0" + } +} diff --git a/Flixy/FlixyApp.swift b/Flixy/FlixyApp.swift new file mode 100644 index 0000000..19079d0 --- /dev/null +++ b/Flixy/FlixyApp.swift @@ -0,0 +1,24 @@ +import SwiftUI +import FlixyDesign + +@main +struct FlixyApp: App { + /// Lebt hier, nicht in einer View: die Akzentfarbe gilt für die gesamte + /// App, und ein Neuaufbau der Wurzelansicht darf die Auswahl nicht + /// zurücksetzen. + @State private var theme = FlixyTheme() + + var body: some Scene { + WindowGroup { + RootView() + .environment(theme) + .flixyAccent(theme) + // Der eigentliche Schalter für „nur dunkel" steht in der + // Info.plist (UIUserInterfaceStyle). Diese Zeile ist die + // Absicherung für SwiftUI-Vorschauen, die die Info.plist + // nicht lesen. + .preferredColorScheme(.dark) + .tint(theme.accentColor) + } + } +} diff --git a/Flixy/Info.plist b/Flixy/Info.plist new file mode 100644 index 0000000..2a7d6f9 --- /dev/null +++ b/Flixy/Info.plist @@ -0,0 +1,55 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Flixy + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + Flixy benötigt Zugriff auf das lokale Netzwerk, um deinen Plex-Server zu finden und Medien direkt von ihm zu laden. + UIBackgroundModes + + audio + + UILaunchScreen + + UIColorName + LaunchBackground + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Dark + + diff --git a/Flixy/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Flixy/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2d72247 --- /dev/null +++ b/Flixy/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [ { "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Flixy/Resources/Assets.xcassets/Contents.json b/Flixy/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..46cba7d --- /dev/null +++ b/Flixy/Resources/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{ "info" : { "author" : "xcode", "version" : 1 } } diff --git a/Flixy/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json b/Flixy/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..135946f --- /dev/null +++ b/Flixy/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,12 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { "alpha" : "1.000", "blue" : "0x0D", "green" : "0x0B", "red" : "0x0B" } + }, + "idiom" : "universal" + } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings new file mode 100644 index 0000000..08ff262 --- /dev/null +++ b/Flixy/Resources/Localizable.xcstrings @@ -0,0 +1,159 @@ +{ + "sourceLanguage": "en", + "strings": { + "sidebar.libraries": { + "comment": "Überschrift über der Liste der Mediatheken in der Seitenleiste", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mediatheken" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Libraries" + } + } + } + }, + "sidebar.more": { + "comment": "Überschrift über Live-TV, Downloads und Einstellungen", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mehr" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "More" + } + } + } + }, + "sidebar.liveTV": { + "comment": "Eintrag in der Seitenleiste", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Live-TV" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Live TV" + } + } + } + }, + "sidebar.downloads": { + "comment": "Eintrag in der Seitenleiste", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Downloads" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloads" + } + } + } + }, + "sidebar.settings": { + "comment": "Eintrag in der Seitenleiste", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings" + } + } + } + }, + "sidebar.open": { + "comment": "Barrierefreiheits-Beschriftung des Hamburger-Knopfes", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleiste öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open sidebar" + } + } + } + }, + "sidebar.close": { + "comment": "Barrierefreiheits-Beschriftung der abgedunkelten Fläche", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleiste schließen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Close sidebar" + } + } + } + }, + "placeholder.description": { + "comment": "Text auf den Platzhalter-Bildschirmen von Phase 0", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieser Bereich entsteht in einer späteren Phase." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This area is coming in a later phase." + } + } + } + }, + "sidebar.home": { + "comment": "Eintrag der Startseite in der Seitenleiste", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Start" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Home" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Flixy/Views/Navigation/DrawerContainer.swift b/Flixy/Views/Navigation/DrawerContainer.swift new file mode 100644 index 0000000..fe95270 --- /dev/null +++ b/Flixy/Views/Navigation/DrawerContainer.swift @@ -0,0 +1,89 @@ +import SwiftUI +import FlixyDesign + +/// Die ausfahrbare Seitenleiste auf dem iPhone. +/// +/// Sie legt sich über den Inhalt und dunkelt ihn ab, statt ihn zur Seite zu +/// schieben — dadurch bleibt die Position im Inhalt erhalten und das Zumachen +/// fühlt sich an wie das Wegwischen eines Blattes. +struct DrawerContainer: View { + @Binding var isOpen: Bool + + @ViewBuilder let sidebar: Sidebar + @ViewBuilder let content: Content + + /// Breit genug für lange Mediatheksnamen, schmal genug, dass vom Inhalt + /// dahinter noch etwas zu sehen bleibt. + private let width: CGFloat = 280 + /// Ab wie weit gezogen die Leiste offen bleibt, statt zurückzuschnappen. + private let commitThreshold: CGFloat = 0.4 + + @GestureState private var drag: CGFloat = 0 + @Environment(\.layoutDirection) private var layoutDirection + + /// 0 = zu, 1 = offen. Fasst Zustand und laufende Geste zusammen, damit + /// Abdunklung und Position immer zueinander passen. + private var openness: CGFloat { + let base = isOpen ? width : 0 + return ((base + drag) / width).clamped() + } + + var body: some View { + ZStack(alignment: .leading) { + content + .accessibilityHidden(isOpen) + + if openness > 0 { + Color.flixyScrim + .opacity(openness) + .ignoresSafeArea() + .onTapGesture { setOpen(false) } + // Ohne diese Beschriftung ist die Fläche für VoiceOver ein + // stummer Block, der den Inhalt verdeckt. + .accessibilityLabel(Text("sidebar.close")) + .accessibilityAddTraits(.isButton) + .accessibilityAction { setOpen(false) } + } + + sidebar + .frame(width: width) + .frame(maxHeight: .infinity) + .background(Color.flixySurface) + .offset(x: (openness - 1) * width) + .accessibilityHidden(!isOpen) + } + .gesture(dragGesture) + .animation(.snappy(duration: 0.28), value: isOpen) + } + + private var dragGesture: some Gesture { + DragGesture(minimumDistance: 12, coordinateSpace: .global) + .updating($drag) { value, state, _ in + // Bei geschlossener Leiste zählt nur ein Zug, der am linken + // Rand beginnt. Sonst würde jedes waagerechte Wischen in einer + // Reihe die Seitenleiste hervorziehen. + if !isOpen { + guard value.startLocation.x < 24 else { return } + state = max(0, value.translation.width) + } else { + state = min(0, value.translation.width) + } + } + .onEnded { value in + guard isOpen || value.startLocation.x < 24 else { return } + let projected = openness + (value.predictedEndTranslation.width - value.translation.width) / width + setOpen(projected > commitThreshold) + } + } + + private func setOpen(_ open: Bool) { + guard open != isOpen else { return } + withAnimation(.snappy(duration: 0.28)) { isOpen = open } + } +} + +extension CGFloat { + fileprivate func clamped(to range: ClosedRange = 0...1) -> CGFloat { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift new file mode 100644 index 0000000..968f22d --- /dev/null +++ b/Flixy/Views/Navigation/RootView.swift @@ -0,0 +1,81 @@ +import SwiftUI +import FlixyDesign + +/// Die Wurzelansicht. +/// +/// Legt fest, in welcher Form die Seitenleiste erscheint: auf dem iPhone als +/// ausfahrbares Blatt, auf dem iPad dauerhaft neben dem Inhalt. Beide Wege +/// nutzen dieselbe `SidebarView` — der Unterschied liegt nur im Rahmen. +struct RootView: View { + @Environment(\.horizontalSizeClass) private var sizeClass + + @State private var selection: SidebarDestination = .home + @State private var isSidebarOpen = false + /// Kommt in Phase 1 vom Server. Solange leer, blendet die Seitenleiste den + /// Abschnitt aus, statt eine leere Überschrift zu zeigen. + @State private var libraries: [SidebarDestination] = [] + + var body: some View { + Group { + if sizeClass == .regular { + splitLayout + } else { + drawerLayout + } + } + .background(Color.flixyCanvas) + } + + /// iPad: die Seitenleiste ist Teil des Fensters und lässt sich über den + /// Systemknopf einklappen. + private var splitLayout: some View { + NavigationSplitView { + SidebarView(libraries: libraries, selection: $selection, showsHeader: false) {} + .navigationTitle(Branding.appName) + .toolbar(removing: .sidebarToggle) + } detail: { + NavigationStack { + destination + } + } + } + + /// iPhone: die Seitenleiste fährt über den Inhalt. + private var drawerLayout: some View { + DrawerContainer(isOpen: $isSidebarOpen) { + SidebarView(libraries: libraries, selection: $selection) { + isSidebarOpen = false + } + } content: { + NavigationStack { + destination + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + withAnimation(.snappy(duration: 0.28)) { isSidebarOpen = true } + } label: { + Image(systemName: "line.3.horizontal") + } + .accessibilityLabel(Text("sidebar.open")) + } + } + } + } + } + + @ViewBuilder + private var destination: some View { + switch selection { + case .home: + PlaceholderScreen(destination: .home) + case .library(let key, let title): + PlaceholderScreen(destination: .library(key: key, title: title)) + case .liveTV: + PlaceholderScreen(destination: .liveTV) + case .downloads: + PlaceholderScreen(destination: .downloads) + case .settings: + PlaceholderScreen(destination: .settings) + } + } +} diff --git a/Flixy/Views/Navigation/SidebarDestination.swift b/Flixy/Views/Navigation/SidebarDestination.swift new file mode 100644 index 0000000..06be92b --- /dev/null +++ b/Flixy/Views/Navigation/SidebarDestination.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Was in der Seitenleiste steht. +/// +/// Die Mediatheken kommen erst zur Laufzeit vom Server, deshalb trägt +/// `library` den Schlüssel des Abschnitts statt eines festen Falls. +enum SidebarDestination: Hashable { + case home + case library(key: String, title: String) + case liveTV + case downloads + case settings + + /// Der Titel in der Navigationsleiste. + /// + /// Auf der Startseite steht dort der Name der App — in der Seitenleiste + /// wäre das nur eine Dopplung der Überschrift, deshalb hat sie mit + /// `sidebarLabel` einen eigenen Text. + var title: String { + switch self { + case .home: Branding.appName + case .library(_, let title): title + case .liveTV: String(localized: "sidebar.liveTV") + case .downloads: String(localized: "sidebar.downloads") + case .settings: String(localized: "sidebar.settings") + } + } + + /// Die Beschriftung in der Seitenleiste. + var sidebarLabel: String { + switch self { + case .home: String(localized: "sidebar.home") + default: title + } + } + + var symbol: String { + switch self { + case .home: "house" + case .library: "film" + case .liveTV: "dot.radiowaves.left.and.right" + case .downloads: "arrow.down.circle" + case .settings: "gearshape" + } + } +} diff --git a/Flixy/Views/Navigation/SidebarView.swift b/Flixy/Views/Navigation/SidebarView.swift new file mode 100644 index 0000000..3442d8e --- /dev/null +++ b/Flixy/Views/Navigation/SidebarView.swift @@ -0,0 +1,99 @@ +import SwiftUI +import FlixyDesign + +/// Der Inhalt der Seitenleiste. +/// +/// Kennt keine Navigation — sie meldet nur, was ausgewählt wurde. Dadurch +/// lässt sie sich auf dem iPhone in den Drawer und auf dem iPad in die +/// `NavigationSplitView` stecken, ohne etwas zu ändern. +struct SidebarView: View { + let libraries: [SidebarDestination] + @Binding var selection: SidebarDestination + /// Auf dem iPad trägt die Navigationsleiste den Namen bereits — dort wäre + /// eine eigene Überschrift die dritte Nennung auf demselben Bildschirm. + var showsHeader: Bool = true + let onSelect: () -> Void + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: FlixySpacing.section) { + if showsHeader { header } + + section(title: nil, items: [.home]) + + if !libraries.isEmpty { + section(title: String(localized: "sidebar.libraries"), items: libraries) + } + + section( + title: String(localized: "sidebar.more"), + items: [.liveTV, .downloads, .settings] + ) + } + .padding(.horizontal, FlixySpacing.regular) + .padding(.vertical, FlixySpacing.section) + } + .scrollBounceBehavior(.basedOnSize) + .background(Color.flixySurface) + } + + private var header: some View { + Text(Branding.appName) + .font(.flixyTitle) + .foregroundStyle(Color.flixyTextPrimary) + .padding(.horizontal, FlixySpacing.snug) + } + + @ViewBuilder + private func section(title: String?, items: [SidebarDestination]) -> some View { + VStack(alignment: .leading, spacing: FlixySpacing.tight) { + if let title { + Text(title) + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + .textCase(.uppercase) + .padding(.horizontal, FlixySpacing.snug) + .padding(.bottom, FlixySpacing.tight) + } + ForEach(items, id: \.self) { item in + SidebarRow(destination: item, isSelected: item == selection) { + selection = item + onSelect() + } + } + } + } +} + +private struct SidebarRow: View { + let destination: SidebarDestination + let isSelected: Bool + let action: () -> Void + + @Environment(\.flixyAccent) private var accent + + var body: some View { + Button(action: action) { + Label { + Text(destination.sidebarLabel).font(.flixyBody) + } icon: { + Image(systemName: destination.symbol) + // Feste Breite, damit die Beschriftungen auf einer Linie + // stehen — SF-Symbole sind unterschiedlich breit. + .frame(width: 22) + } + .foregroundStyle(isSelected ? accent : Color.flixyTextSecondary) + .padding(.horizontal, FlixySpacing.snug) + .padding(.vertical, FlixySpacing.regular) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + if isSelected { + RoundedRectangle(cornerRadius: FlixyRadius.control) + .fill(accent.opacity(0.14)) + } + } + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } +} diff --git a/Flixy/Views/Placeholder/PlaceholderScreen.swift b/Flixy/Views/Placeholder/PlaceholderScreen.swift new file mode 100644 index 0000000..e3b5e37 --- /dev/null +++ b/Flixy/Views/Placeholder/PlaceholderScreen.swift @@ -0,0 +1,23 @@ +import SwiftUI +import FlixyDesign + +/// Ein noch nicht gebauter Bereich. +/// +/// Gibt es nur in Phase 0, damit die Navigation vollständig begehbar ist, +/// bevor es Inhalte gibt. Jede Phase ersetzt einen dieser Bildschirme durch +/// den echten. +struct PlaceholderScreen: View { + let destination: SidebarDestination + + var body: some View { + ContentUnavailableView { + Label(destination.title, systemImage: destination.symbol) + } description: { + Text("placeholder.description") + } + .background(Color.flixyCanvas) + .navigationTitle(destination.title) + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(Color.flixySurface, for: .navigationBar) + } +} diff --git a/Packages/FlixyKit/Package.swift b/Packages/FlixyKit/Package.swift new file mode 100644 index 0000000..45e6302 --- /dev/null +++ b/Packages/FlixyKit/Package.swift @@ -0,0 +1,49 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "FlixyKit", + // Quellsprache ist Englisch, damit die Schlüssel im Code lesbar bleiben. + // Deutsch kommt als Übersetzung dazu; die Systemsprache entscheidet. + defaultLocalization: "en", + // macOS steht hier nur, damit `swift test` die Logik-Tests ohne + // Simulator laufen lassen kann — das spart bei jedem Durchlauf Minuten. + // Die App selbst ist iOS-only; das legt project.yml fest. + platforms: [.iOS(.v26), .macOS(.v26)], + products: [ + .library(name: "PlexKit", targets: ["PlexKit"]), + .library(name: "FlixyDesign", targets: ["FlixyDesign"]), + .library(name: "FlixyPlayback", targets: ["FlixyPlayback"]), + .library(name: "FlixyDownloads", targets: ["FlixyDownloads"]), + ], + targets: [ + // Sprechen mit dem Server: Anmeldung, Endpunkte, Modelle. Kein SwiftUI, + // damit die gesamte Protokoll-Schicht ohne Oberfläche testbar bleibt. + .target(name: "PlexKit"), + .testTarget( + name: "PlexKitTests", + dependencies: ["PlexKit"], + // Echte Server-Antworten als Dateien. `copy` statt `process`, weil + // sie unverändert im Bundle liegen sollen — sonst deutet Xcode die + // JSON-Dateien um. + resources: [.copy("Fixtures")] + ), + + // Farben, Typografie, wiederkehrende Bausteine. Hängt bewusst von + // nichts ab: dadurch bleibt das Umstellen der Akzentfarbe ein + // Token-Wechsel und keine Suche quer durch die App. + .target(name: "FlixyDesign", resources: [.process("Localizable.xcstrings")]), + .testTarget(name: "FlixyDesignTests", dependencies: ["FlixyDesign"]), + + // Die Wiedergabe-Abstraktion samt AVPlayer-Motor und der Entscheidung + // DirectPlay/Transcode. VLCKit steckt bewusst NICHT hier drin, sondern + // in FlixyPlaybackVLC — sonst zöge jeder Testlauf ein 920 MB großes + // xcframework hinter sich her. + .target(name: "FlixyPlayback", dependencies: ["PlexKit"]), + .testTarget(name: "FlixyPlaybackTests", dependencies: ["FlixyPlayback"]), + + // Warteschlange, Hintergrundübertragung, Offline-Bestand. + .target(name: "FlixyDownloads", dependencies: ["PlexKit"]), + .testTarget(name: "FlixyDownloadsTests", dependencies: ["FlixyDownloads"]), + ] +) diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Color/AccentEnvironment.swift b/Packages/FlixyKit/Sources/FlixyDesign/Color/AccentEnvironment.swift new file mode 100644 index 0000000..7a963b6 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/Color/AccentEnvironment.swift @@ -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) + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Color/AccentOption.swift b/Packages/FlixyKit/Sources/FlixyDesign/Color/AccentOption.swift new file mode 100644 index 0000000..62ed59d --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/Color/AccentOption.swift @@ -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) + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Color/Contrast.swift b/Packages/FlixyKit/Sources/FlixyDesign/Color/Contrast.swift new file mode 100644 index 0000000..a513a25 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/Color/Contrast.swift @@ -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) + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Color/FlixyColor.swift b/Packages/FlixyKit/Sources/FlixyDesign/Color/FlixyColor.swift new file mode 100644 index 0000000..d55c197 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/Color/FlixyColor.swift @@ -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) +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Color/HSB.swift b/Packages/FlixyKit/Sources/FlixyDesign/Color/HSB.swift new file mode 100644 index 0000000..297ac5e --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/Color/HSB.swift @@ -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 } +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Color/RGB.swift b/Packages/FlixyKit/Sources/FlixyDesign/Color/RGB.swift new file mode 100644 index 0000000..3ddad72 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/Color/RGB.swift @@ -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 = 0...1) -> Double { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/FlixyArtwork.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/FlixyArtwork.swift new file mode 100644 index 0000000..ac42601 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/FlixyArtwork.swift @@ -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() + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift new file mode 100644 index 0000000..b383df2 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift @@ -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: 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) + } + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/LandscapeCard.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/LandscapeCard.swift new file mode 100644 index 0000000..fd68024 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/LandscapeCard.swift @@ -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) + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/PosterCard.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/PosterCard.swift new file mode 100644 index 0000000..71ba608 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/PosterCard.swift @@ -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) + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/ShimmerPlaceholder.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/ShimmerPlaceholder.swift new file mode 100644 index 0000000..09ee152 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/ShimmerPlaceholder.swift @@ -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) + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/WatchProgressBar.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/WatchProgressBar.swift new file mode 100644 index 0000000..99d13f0 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/WatchProgressBar.swift @@ -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))))) + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift b/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift new file mode 100644 index 0000000..d43ceb0 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift @@ -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 +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/FlixyTheme.swift b/Packages/FlixyKit/Sources/FlixyDesign/FlixyTheme.swift new file mode 100644 index 0000000..4520a2b --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/FlixyTheme.swift @@ -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 + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Localizable.xcstrings b/Packages/FlixyKit/Sources/FlixyDesign/Localizable.xcstrings new file mode 100644 index 0000000..a83d748 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/Localizable.xcstrings @@ -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" +} diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/DownloadState.swift b/Packages/FlixyKit/Sources/FlixyDownloads/DownloadState.swift new file mode 100644 index 0000000..180468b --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDownloads/DownloadState.swift @@ -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 + } + } +} diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DeviceCapabilities.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DeviceCapabilities.swift new file mode 100644 index 0000000..59f44aa --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DeviceCapabilities.swift @@ -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 + public var videoCodecs: Set + public var audioCodecs: Set + + public init(containers: Set, videoCodecs: Set, audioCodecs: Set) { + 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"] + ) +} diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecider.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecider.swift new file mode 100644 index 0000000..2f699ec --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecider.swift @@ -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: ". ")) + } +} diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift new file mode 100644 index 0000000..9bbfb2e --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift @@ -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 + } +} diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackPolicy.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackPolicy.swift new file mode 100644 index 0000000..3c5d61b --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackPolicy.swift @@ -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) +} diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/MediaTrack.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/MediaTrack.swift new file mode 100644 index 0000000..4e6d8c5 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/MediaTrack.swift @@ -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 + ) + } +} diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/PlaybackState.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/PlaybackState.swift new file mode 100644 index 0000000..d6830db --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/PlaybackState.swift @@ -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 + } +} diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift new file mode 100644 index 0000000..b3c986c --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift @@ -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 { 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() +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Client/PlexClientIdentity.swift b/Packages/FlixyKit/Sources/PlexKit/Client/PlexClientIdentity.swift new file mode 100644 index 0000000..b4b807c --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Client/PlexClientIdentity.swift @@ -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 + ) + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Client/PlexError.swift b/Packages/FlixyKit/Sources/PlexKit/Client/PlexError.swift new file mode 100644 index 0000000..af6b447 --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Client/PlexError.swift @@ -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 + } + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Client/PlexResponse.swift b/Packages/FlixyKit/Sources/PlexKit/Client/PlexResponse.swift new file mode 100644 index 0000000..da04e16 --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Client/PlexResponse.swift @@ -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( + _ 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)) + } + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Client/PlexTransport.swift b/Packages/FlixyKit/Sources/PlexKit/Client/PlexTransport.swift new file mode 100644 index 0000000..3516a5a --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Client/PlexTransport.swift @@ -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 + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift new file mode 100644 index 0000000..872d41a --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift @@ -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: 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 = [] + } + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexMedia.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexMedia.swift new file mode 100644 index 0000000..9542997 --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexMedia.swift @@ -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: " · ") + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexStream.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexStream.swift new file mode 100644 index 0000000..821b435 --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexStream.swift @@ -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 + } +} diff --git a/Packages/FlixyKit/Tests/FlixyDesignTests/ContrastTests.swift b/Packages/FlixyKit/Tests/FlixyDesignTests/ContrastTests.swift new file mode 100644 index 0000000..4221d56 --- /dev/null +++ b/Packages/FlixyKit/Tests/FlixyDesignTests/ContrastTests.swift @@ -0,0 +1,127 @@ +import Testing +@testable import FlixyDesign + +@Suite("Kontrastkorrektur") +struct ContrastTests { + private let canvas = FlixyColor.canvas + + @Test("Schwarz zu Weiß ergibt das Maximum von 21:1") + func extremesProduceMaximumRatio() { + let ratio = Contrast.ratio(RGB(hex: 0x000000), RGB(hex: 0xFFFFFF)) + #expect(abs(ratio - 21) < 0.01) + } + + @Test("Gleiche Farben ergeben 1:1") + func identicalColorsProduceMinimumRatio() { + #expect(abs(Contrast.ratio(canvas, canvas) - 1) < 0.0001) + } + + @Test("Farben mit genug Kontrast bleiben unangetastet") + func sufficientColorsAreLeftAlone() { + // Wichtig, weil die kuratierte Palette sonst beim Zeichnen verfälscht + // würde — die Kachel in den Einstellungen und die Farbe in der App + // müssen übereinstimmen. + let amber = RGB(hex: 0xFFB224) + #expect(Contrast.legible(amber, on: canvas) == amber) + } + + @Test( + "Zu dunkle Farben werden bis zur Lesbarkeit angehoben", + arguments: [0x00008B, 0x1A1A2E, 0x2D0A31, 0x003300, 0x4A0000, 0x111111] + ) + func darkColorsAreLifted(hex: Int) { + let original = RGB(hex: UInt32(hex)) + let corrected = Contrast.legible(original, on: canvas) + + #expect(Contrast.ratio(original, canvas) < Contrast.minimumForText) + #expect(Contrast.ratio(corrected, canvas) >= Contrast.minimumForText) + } + + @Test("Die Korrektur hebt nur so weit an wie nötig") + func correctionIsMinimal() { + // Ein Sprung auf Weiß wäre die einfache Lösung, würde aber jede + // dunkle Wunschfarbe in dieselbe Farbe verwandeln. + let corrected = Contrast.legible(RGB(hex: 0x00008B), on: canvas) + #expect(Contrast.ratio(corrected, canvas) < Contrast.minimumForText + 0.5) + #expect(corrected != RGB(hex: 0xFFFFFF)) + } + + @Test("Der Farbton überlebt die Korrektur") + func correctionPreservesHue() { + let darkBlue = RGB(hex: 0x00008B) + let corrected = Contrast.legible(darkBlue, on: canvas) + // Blau muss blau bleiben. Ein Abstand von 0,02 im Farbkreis sind rund + // sieben Grad — für das Auge dieselbe Farbe. + #expect(abs(HSB(corrected).hue - HSB(darkBlue).hue) < 0.02) + } + + @Test("Selbst Schwarz wird lesbar gemacht") + func blackIsHandled() { + // Schwarz hat keinen Farbton, an dem sich festhalten ließe — hier + // muss die Sättigungsstufe greifen, sonst käme die Suche nie an. + let corrected = Contrast.legible(RGB(hex: 0x000000), on: canvas) + #expect(Contrast.ratio(corrected, canvas) >= Contrast.minimumForText) + } +} + +@Suite("Akzentpalette") +struct AccentPaletteTests { + @Test("Jede kuratierte Farbe ist ohne Korrektur lesbar") + func curatedColorsAreLegibleAsIs() { + // Das ist der eigentliche Zweck des Kuratierens. Fällt eine neue Farbe + // hier durch, gehört sie nicht in die Palette — nicht in die + // Korrektur. + for option in AccentOption.palette { + let ratio = Contrast.ratio(option.color, FlixyColor.canvas) + #expect( + ratio >= Contrast.minimumForText, + "\(option.id) erreicht nur \(String(format: "%.2f", ratio)):1" + ) + } + } + + @Test("Die Kennungen sind eindeutig") + func identifiersAreUnique() { + // Sie dienen als Schlüssel in den Voreinstellungen; ein Duplikat + // hieße, dass eine Auswahl still eine andere überschreibt. + let identifiers = AccentOption.palette.map(\.id) + #expect(Set(identifiers).count == identifiers.count) + } + + @Test("Keine kuratierte Farbe nimmt die Kennung der eigenen Farbe ein") + func paletteDoesNotCollideWithCustom() { + #expect(!AccentOption.palette.contains { $0.id == AccentOption.customIdentifier }) + } +} + +@Suite("Farbwerte") +struct RGBTests { + @Test( + "Hex-Eingaben werden gelesen", + arguments: [ + ("#1DB954", 0x1DB954), + ("1db954", 0x1DB954), + (" #FFB224 ", 0xFFB224), + // Kurzform: #ABC meint #AABBCC. + ("#ABC", 0xAABBCC), + ] + ) + func validHexStringsParse(input: String, expected: Int) { + #expect(RGB(string: input) == RGB(hex: UInt32(expected))) + } + + @Test( + "Unbrauchbare Eingaben werden abgewiesen", + arguments: ["", "#", "xyz", "#12345", "#GGGGGG", "1234567"] + ) + func invalidHexStringsFail(input: String) { + #expect(RGB(string: input) == nil) + } + + @Test("Hex hin und zurück ergibt denselben Wert") + func hexRoundTrips() { + for option in AccentOption.palette { + #expect(RGB(string: option.color.hexString) == option.color) + } + } +} diff --git a/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadStateTests.swift b/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadStateTests.swift new file mode 100644 index 0000000..7ab5042 --- /dev/null +++ b/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadStateTests.swift @@ -0,0 +1,79 @@ +import Testing +@testable import FlixyDownloads + +@Suite("Download-Zustandsautomat") +struct DownloadStateTests { + + @Test("Ein abgeschlossener Download bleibt abgeschlossen") + func completedIsTerminal() { + // Der wichtigste Übergang, den es NICHT geben darf: wäre er erlaubt, + // könnte ein verspäteter Rückruf aus der Hintergrundsitzung eine + // fertige Datei wieder in die Warteschlange schieben — und sie damit + // ein zweites Mal herunterladen. + for next in DownloadState.allCases { + #expect(!DownloadState.completed.canTransition(to: next)) + } + } + + @Test("Gescheiterte Downloads dürfen zurück in die Warteschlange") + func failedCanBeRetried() { + #expect(DownloadState.failed.canTransition(to: .queued)) + } + + @Test("Gescheiterte Downloads springen nicht direkt ins Übertragen") + func failedCannotSkipTheQueue() { + // Sonst umgeht der Wiederholen-Knopf die Prüfungen auf WLAN und + // Speicherplatz, die beim Verlassen der Warteschlange stattfinden. + #expect(!DownloadState.failed.canTransition(to: .downloading)) + } + + @Test("Angehalten und laufend zählen beim Speicherplatz mit") + func partialTransfersOccupyStorage() { + // Beide haben bereits Daten auf der Platte. Zählte man sie nicht mit, + // liefe das Gerät trotz gesetzter Grenze voll. + #expect(DownloadState.downloading.occupiesStorage) + #expect(DownloadState.paused.occupiesStorage) + #expect(DownloadState.completed.occupiesStorage) + #expect(!DownloadState.queued.occupiesStorage) + #expect(!DownloadState.waiting.occupiesStorage) + #expect(!DownloadState.failed.occupiesStorage) + } + + @Test("Nur wartende Zustände dürfen mit dem Übertragen beginnen") + func onlyWaitingStatesAreTransferable() { + #expect(DownloadState.queued.isTransferable) + #expect(DownloadState.waiting.isTransferable) + #expect(!DownloadState.downloading.isTransferable) + #expect(!DownloadState.paused.isTransferable) + #expect(!DownloadState.completed.isTransferable) + } + + @Test("Kein Zustand geht in sich selbst über") + func noSelfTransitions() { + // Ein Übergang auf sich selbst wäre ein stilles Nichts und würde + // Zählerstände wie „Versuche" doppelt hochzählen. + for state in DownloadState.allCases { + #expect(!state.canTransition(to: state)) + } + } + + @Test("Jeder Zustand außer dem abgeschlossenen hat einen Ausgang") + func everyStateCanProgress() { + for state in DownloadState.allCases where state != .completed { + let hasExit = DownloadState.allCases.contains { state.canTransition(to: $0) } + #expect(hasExit, "\(state.rawValue) ist eine Sackgasse") + } + } + + @Test("Die gespeicherten Namen bleiben stabil") + func rawValuesAreStable() { + // Sie landen in SwiftData. Ein umbenannter Fall würde beim nächsten + // Start alle laufenden Downloads unlesbar machen. + #expect(DownloadState.queued.rawValue == "queued") + #expect(DownloadState.downloading.rawValue == "downloading") + #expect(DownloadState.paused.rawValue == "paused") + #expect(DownloadState.waiting.rawValue == "waiting") + #expect(DownloadState.completed.rawValue == "completed") + #expect(DownloadState.failed.rawValue == "failed") + } +} diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift new file mode 100644 index 0000000..2d9b6c8 --- /dev/null +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift @@ -0,0 +1,173 @@ +import Testing +import PlexKit +@testable import FlixyPlayback + +/// Die Tabelle, die festhält, welche Datei auf welchem Weg landet. +/// +/// Sie ist der Grund, warum `DeviceCapabilities` nicht beiläufig angefasst +/// werden kann: jede Änderung dort schlägt hier sichtbar durch. +@Suite("Wiedergabe-Entscheidung") +struct PlaybackDeciderTests { + + /// Baut eine Datei mit den angegebenen Eigenschaften. + private func media( + container: String, + video: String, + audio: String, + bitrate: Int? = nil + ) -> (PlexMedia, PlexPart) { + let streams = [ + PlexStream(id: 1, streamType: 1, codec: video), + PlexStream(id: 2, streamType: 2, codec: audio, selected: true), + ] + let part = PlexPart(id: 10, key: "/library/parts/10/1/file", container: container, streams: streams) + let media = PlexMedia( + id: 100, + container: container, + videoCodec: video, + audioCodec: audio, + bitrate: bitrate, + parts: [part] + ) + return (media, part) + } + + // MARK: - Was AVPlayer nativ kann + + @Test( + "Native Formate gehen an AVPlayer", + arguments: [ + ("mp4", "h264", "aac"), + ("mp4", "hevc", "eac3"), + ("mov", "h264", "ac3"), + ("m4v", "hevc", "alac"), + // Groß-/Kleinschreibung darf keinen Unterschied machen — Plex ist + // darin nicht einheitlich. + ("MP4", "H264", "AAC"), + ] + ) + func nativeFormatsUseAVPlayer(container: String, video: String, audio: String) { + let (media, part) = media(container: container, video: video, audio: audio) + let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly) + + #expect(decision.method == .directPlay(engine: .avPlayer)) + #expect(decision.rationale.contains(.nativelySupported)) + } + + // MARK: - Was VLC übernehmen muss + + @Test("Matroska geht an VLC, auch bei nativen Codecs") + func matroskaFallsBackToVLC() { + let (media, part) = media(container: "mkv", video: "h264", audio: "aac") + let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly) + + #expect(decision.method == .directPlay(engine: .vlc)) + #expect(decision.rationale.contains(.containerUnsupported("mkv"))) + } + + @Test( + "Nicht unterstützter Ton geht an VLC", + arguments: ["dts", "dca", "truehd", "vorbis", "opus"] + ) + func unsupportedAudioFallsBackToVLC(codec: String) { + let (media, part) = media(container: "mp4", video: "h264", audio: codec) + let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly) + + #expect(decision.method == .directPlay(engine: .vlc)) + #expect(decision.rationale.contains(.audioCodecUnsupported(codec))) + } + + @Test( + "Nicht unterstütztes Bild geht an VLC", + arguments: ["av1", "vp9", "mpeg2video", "vc1", "wmv3"] + ) + func unsupportedVideoFallsBackToVLC(codec: String) { + let (media, part) = media(container: "mp4", video: codec, audio: "aac") + let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly) + + #expect(decision.method == .directPlay(engine: .vlc)) + #expect(decision.rationale.contains(.videoCodecUnsupported(codec))) + } + + @Test("Die gewählte Tonspur zählt, nicht die erste beste") + func decisionFollowsSelectedAudioTrack() { + // Deutsches AAC ist ausgewählt, englisches DTS liegt daneben. Würde + // der Decider nur die erste Tonspur ansehen, ginge diese Datei + // unnötig an VLC — und damit ohne Bild-in-Bild. + let streams = [ + PlexStream(id: 1, streamType: 1, codec: "h264"), + PlexStream(id: 2, streamType: 2, codec: "dts", language: "English"), + PlexStream(id: 3, streamType: 2, codec: "aac", language: "Deutsch", selected: true), + ] + let part = PlexPart(id: 10, key: "/p", container: "mp4", streams: streams) + let media = PlexMedia(id: 100, container: "mp4", videoCodec: "h264", audioCodec: "dts", parts: [part]) + + let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly) + #expect(decision.method == .directPlay(engine: .avPlayer)) + } + + @Test("Unanalysierte Dateien gehen an den toleranteren Motor") + func unknownStreamsFallBackToVLC() { + let part = PlexPart(id: 10, key: "/p", container: "mp4", streams: []) + let media = PlexMedia(id: 100, container: "mp4", parts: [part]) + + let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly) + #expect(decision.method == .directPlay(engine: .vlc)) + #expect(decision.rationale.contains(.streamsUnknown)) + } + + // MARK: - Die Bandbreitengrenze + + @Test("Über der Grenze wird umgerechnet, sobald das erlaubt ist") + func exceedingBitrateTranscodesWhenAllowed() { + let (media, part) = media(container: "mp4", video: "h264", audio: "aac", bitrate: 80_000) + let policy = PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 8_000) + + let decision = PlaybackDecider().decide(media: media, part: part, policy: policy) + + #expect(decision.method == .transcode(maximumBitrate: 8_000)) + #expect(decision.rationale.contains(.bitrateExceedsLimit(mediaBitrate: 80_000, limit: 8_000))) + } + + @Test("Ohne Transcoder wird es trotzdem versucht — und der Grund vermerkt") + func exceedingBitrateStillPlaysInPhaseOne() { + // Das ist die Lage in Phase 1. Abbrechen wäre falsch: im WLAN läuft + // auch ein 80-Mbit-Remux, die Grenze ist nur eine Vorsichtsmaßnahme. + let (media, part) = media(container: "mp4", video: "h264", audio: "aac", bitrate: 80_000) + let policy = PlaybackPolicy(allowsTranscoding: false, maximumBitrate: 8_000) + + let decision = PlaybackDecider().decide(media: media, part: part, policy: policy) + + #expect(decision.isDirectPlay) + #expect(decision.rationale.contains(.transcodingUnavailable)) + } + + @Test("Unter der Grenze bleibt alles beim Direktabspielen") + func withinBitrateLimitPlaysDirectly() { + let (media, part) = media(container: "mp4", video: "h264", audio: "aac", bitrate: 4_000) + let policy = PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 8_000) + + let decision = PlaybackDecider().decide(media: media, part: part, policy: policy) + #expect(decision.method == .directPlay(engine: .avPlayer)) + } + + @Test("Ohne VLC übernimmt der Transcoder") + func withoutFallbackEngineTranscodingTakesOver() { + let (media, part) = media(container: "mkv", video: "h264", audio: "dts") + let policy = PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: false) + + let decision = PlaybackDecider().decide(media: media, part: part, policy: policy) + #expect(decision.method == .transcode(maximumBitrate: nil)) + } + + @Test("Der Transcode-Weg läuft über AVPlayer") + func transcodingUsesAVPlayer() { + // HLS ist genau die Kost, für die AVPlayer gebaut ist — inklusive + // automatischem Bitratenwechsel. Deshalb darf VLC hier nicht greifen. + let (media, part) = media(container: "mkv", video: "h264", audio: "dts") + let policy = PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: false) + + let decision = PlaybackDecider().decide(media: media, part: part, policy: policy) + #expect(decision.engine == .avPlayer) + } +} diff --git a/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/README.md b/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/README.md new file mode 100644 index 0000000..a09b498 --- /dev/null +++ b/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/README.md @@ -0,0 +1,16 @@ +# Antwort-Vorlagen + +Diese Dateien bilden das Antwortformat von Plex nach. Sie sind **von Hand +geschrieben**, nicht von einem laufenden Server mitgeschnitten — es stand bei +der Entstehung keiner zur Verfügung. + +Sie decken das Format ab, nicht die Wirklichkeit eines gewachsenen Bestands. +Sobald ein Server erreichbar ist, gehören sie durch echte Mitschnitte ersetzt: + + curl -H "Accept: application/json" \ + "http://:32400/library/sections/1/all?X-Plex-Token=" \ + > movies-section.json + +Erfahrungsgemäß tauchen dabei Fälle auf, die man sich nicht ausdenkt — fehlende +`Media`-Blöcke bei noch nicht analysierten Dateien, Titel ohne `year`, Folgen +ohne `grandparentThumb`. Genau dafür sind die Vorlagen da. diff --git a/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/episodes-unanalyzed.json b/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/episodes-unanalyzed.json new file mode 100644 index 0000000..59a87d2 --- /dev/null +++ b/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/episodes-unanalyzed.json @@ -0,0 +1,44 @@ +{ + "MediaContainer": { + "size": 2, + "title1": "Serien", + "title2": "Staffel 1", + "Metadata": [ + { + "ratingKey": "77801", + "key": "/library/metadata/77801", + "type": "episode", + "title": "Die erste Folge", + "index": 1, + "parentIndex": 1, + "parentTitle": "Staffel 1", + "parentRatingKey": "77800", + "grandparentTitle": "Eine Serie", + "grandparentRatingKey": "77700", + "grandparentThumb": "/library/metadata/77700/thumb/1699000000", + "duration": 2760000, + "addedAt": 1699000000, + "Media": [ + { + "id": 90001, + "container": "mkv", + "Part": [ + { "id": 95001, "key": "/library/parts/95001/1699000000/file.mkv", "container": "mkv" } + ] + } + ] + }, + { + "ratingKey": "77802", + "key": "/library/metadata/77802", + "type": "episode", + "title": "Die zweite Folge", + "index": 2, + "parentIndex": 1, + "grandparentTitle": "Eine Serie", + "duration": 2820000, + "addedAt": 1699000100 + } + ] + } +} diff --git a/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/movie-with-two-versions.json b/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/movie-with-two-versions.json new file mode 100644 index 0000000..af52790 --- /dev/null +++ b/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/movie-with-two-versions.json @@ -0,0 +1,89 @@ +{ + "MediaContainer": { + "size": 1, + "totalSize": 1, + "offset": 0, + "title1": "Filme", + "title2": "Zuletzt hinzugefügt", + "Metadata": [ + { + "ratingKey": "45231", + "key": "/library/metadata/45231", + "guid": "plex://movie/5d776b59ad5437001f79c36d", + "type": "movie", + "title": "Blade Runner 2049", + "titleSort": "Blade Runner 2049", + "summary": "Dreißig Jahre nach den Ereignissen des ersten Films.", + "year": 2017, + "duration": 9784000, + "contentRating": "de/16", + "rating": 8.0, + "audienceRating": 8.1, + "thumb": "/library/metadata/45231/thumb/1699887654", + "art": "/library/metadata/45231/art/1699887654", + "viewCount": 1, + "viewOffset": 4892000, + "lastViewedAt": 1699900000, + "addedAt": 1699887654, + "Media": [ + { + "id": 88121, + "container": "mkv", + "videoCodec": "hevc", + "audioCodec": "truehd", + "videoResolution": "4k", + "videoProfile": "main 10", + "bitrate": 58420, + "width": 3840, + "height": 2160, + "audioChannels": 8, + "duration": 9784000, + "Part": [ + { + "id": 91002, + "key": "/library/parts/91002/1699887654/file.mkv", + "container": "mkv", + "size": 71453298112, + "duration": 9784000, + "file": "/media/Filme/Blade Runner 2049 (2017)/Blade Runner 2049 2160p.mkv", + "Stream": [ + { "id": 310021, "streamType": 1, "codec": "hevc", "bitDepth": 10 }, + { "id": 310022, "streamType": 2, "codec": "truehd", "language": "Deutsch", "languageCode": "deu", "channels": 8, "displayTitle": "Deutsch (TrueHD 7.1)", "selected": true }, + { "id": 310023, "streamType": 2, "codec": "eac3", "language": "English", "languageCode": "eng", "channels": 6, "displayTitle": "English (EAC3 5.1)" }, + { "id": 310024, "streamType": 3, "codec": "subrip", "language": "Deutsch", "languageCode": "deu", "key": "/library/streams/310024", "displayTitle": "Deutsch (SRT)" }, + { "id": 310025, "streamType": 3, "codec": "pgs", "language": "English", "languageCode": "eng", "forced": true, "displayTitle": "English (Forced)" } + ] + } + ] + }, + { + "id": 88122, + "container": "mp4", + "videoCodec": "h264", + "audioCodec": "aac", + "videoResolution": "1080", + "bitrate": 8240, + "width": 1920, + "height": 1080, + "audioChannels": 2, + "duration": 9784000, + "Part": [ + { + "id": 91003, + "key": "/library/parts/91003/1699887655/file.mp4", + "container": "mp4", + "size": 10083446784, + "duration": 9784000, + "file": "/media/Filme/Blade Runner 2049 (2017)/Blade Runner 2049 1080p.mp4", + "Stream": [ + { "id": 310031, "streamType": 1, "codec": "h264" }, + { "id": 310032, "streamType": 2, "codec": "aac", "language": "Deutsch", "languageCode": "deu", "channels": 2, "selected": true } + ] + } + ] + } + ] + } + ] + } +} diff --git a/Packages/FlixyKit/Tests/PlexKitTests/PlexDecodingTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/PlexDecodingTests.swift new file mode 100644 index 0000000..71b22be --- /dev/null +++ b/Packages/FlixyKit/Tests/PlexKitTests/PlexDecodingTests.swift @@ -0,0 +1,203 @@ +import Foundation +import Testing +@testable import PlexKit + +/// Lädt eine Antwort-Vorlage aus dem Test-Bundle. +private func fixture(_ name: String) throws -> Data { + let url = try #require( + Bundle.module.url(forResource: "Fixtures/\(name)", withExtension: "json"), + "Vorlage \(name).json nicht im Bundle — steht sie in Package.swift unter resources?" + ) + return try Data(contentsOf: url) +} + +@Suite("Antworten lesen") +struct PlexDecodingTests { + + @Test("Ein Film mit zwei Fassungen wird vollständig gelesen") + func decodesMovieWithTwoVersions() throws { + let container = try PlexResponse.decode( + PlexMediaContainer.self, + from: try fixture("movie-with-two-versions") + ) + + let movie = try #require(container.content.first) + #expect(movie.ratingKey == "45231") + #expect(movie.kind == .movie) + #expect(movie.title == "Blade Runner 2049") + #expect(movie.year == 2017) + #expect(movie.hasMultipleVersions) + #expect(movie.media.count == 2) + } + + @Test("Die Fassung mit der höchsten Bitrate wird vorgewählt") + func prefersHighestBitrateVersion() throws { + let container = try PlexResponse.decode( + PlexMediaContainer.self, + from: try fixture("movie-with-two-versions") + ) + let movie = try #require(container.content.first) + let preferred = try #require(movie.preferredMedia) + + #expect(preferred.videoResolution == "4k") + #expect(preferred.bitrate == 58420) + } + + @Test("Der Fortschritt wird als Anteil berechnet") + func computesWatchProgress() throws { + let container = try PlexResponse.decode( + PlexMediaContainer.self, + from: try fixture("movie-with-two-versions") + ) + let movie = try #require(container.content.first) + let progress = try #require(movie.watchProgress) + + // 4 892 000 von 9 784 000 Millisekunden — genau die Hälfte. + #expect(abs(progress - 0.5) < 0.001) + } + + @Test("Spuren werden nach Art getrennt") + func separatesStreamsByKind() throws { + let container = try PlexResponse.decode( + PlexMediaContainer.self, + from: try fixture("movie-with-two-versions") + ) + let part = try #require(container.content.first?.preferredMedia?.parts.first) + + #expect(part.videoStream?.codec == "hevc") + #expect(part.audioStreams.count == 2) + #expect(part.subtitleStreams.count == 2) + #expect(part.selectedAudioStream?.language == "Deutsch") + } + + @Test("Untertitel als eigene Datei werden erkannt") + func detectsExternalSubtitles() throws { + let container = try PlexResponse.decode( + PlexMediaContainer.self, + from: try fixture("movie-with-two-versions") + ) + let part = try #require(container.content.first?.preferredMedia?.parts.first) + let subtitles = part.subtitleStreams + + // Die SRT-Spur liegt als Datei daneben und lässt sich deshalb auch bei + // DirectPlay dazuschalten; die PGS-Spur steckt im Container. + #expect(subtitles.first { $0.codec == "subrip" }?.isExternal == true) + #expect(subtitles.first { $0.codec == "pgs" }?.isExternal == false) + #expect(subtitles.first { $0.codec == "pgs" }?.isForced == true) + } + + @Test("Folgen ohne analysierte Datei brechen das Lesen nicht ab") + func toleratesUnanalyzedEpisodes() throws { + // Der wichtigste Fall aus der Praxis: Plex liefert Einträge aus, bevor + // es die Dateien durchgesehen hat. Ein nicht-optionales Feld würde + // hier die ganze Staffel unlesbar machen statt nur eine Angabe. + let container = try PlexResponse.decode( + PlexMediaContainer.self, + from: try fixture("episodes-unanalyzed") + ) + + #expect(container.content.count == 2) + + let first = try #require(container.content.first) + #expect(first.media.first?.parts.first?.streams.isEmpty == true) + + // Die zweite Folge hat gar keinen `Media`-Block. + let second = try #require(container.content.last) + #expect(second.media.isEmpty) + #expect(second.title == "Die zweite Folge") + #expect(second.index == 2) + } + + @Test("Serienzugehörigkeit von Folgen wird gelesen") + func decodesEpisodeHierarchy() throws { + let container = try PlexResponse.decode( + PlexMediaContainer.self, + from: try fixture("episodes-unanalyzed") + ) + let episode = try #require(container.content.first) + + #expect(episode.kind == .episode) + #expect(episode.index == 1) + #expect(episode.parentIndex == 1) + #expect(episode.grandparentTitle == "Eine Serie") + #expect(episode.grandparentRatingKey == "77700") + } + + @Test("Ungesehene Titel haben keinen Fortschritt") + func unwatchedItemsHaveNoProgress() throws { + let container = try PlexResponse.decode( + PlexMediaContainer.self, + from: try fixture("episodes-unanalyzed") + ) + // Ohne diese Unterscheidung bekäme jede ungesehene Kachel einen + // Fortschrittsbalken der Breite null — sichtbarer Strich, keine Aussage. + #expect(container.content.allSatisfy { $0.watchProgress == nil }) + #expect(container.content.allSatisfy { !$0.isWatched }) + } + + @Test("Kaputte Antworten melden, woran es lag") + func decodingErrorsCarryDetail() { + let garbage = Data(#"{"MediaContainer":{"Metadata":[{"title":"ohne ratingKey"}]}}"#.utf8) + + #expect(throws: PlexError.self) { + try PlexResponse.decode(PlexMediaContainer.self, from: garbage) + } + + do { + _ = try PlexResponse.decode(PlexMediaContainer.self, from: garbage) + Issue.record("Hätte scheitern müssen") + } catch let error as PlexError { + guard case .decoding(let description) = error else { + Issue.record("Falsche Fehlerart: \(error)") + return + } + // Der Schlüsselpfad muss drinstehen — sonst ist beim Nachziehen + // einer geänderten Plex-Version nicht zu erkennen, welches Feld + // gewandert ist. + #expect(description.contains("ratingKey")) + } catch { + Issue.record("Unerwarteter Fehler: \(error)") + } + } +} + +@Suite("HTTP-Status übersetzen") +struct PlexResponseValidationTests { + private func response(_ status: Int) -> HTTPURLResponse { + HTTPURLResponse( + url: URL(string: "http://server:32400/library/sections")!, + statusCode: status, + httpVersion: nil, + headerFields: nil + )! + } + + @Test("Erfolgsstatus geben die Daten durch", arguments: [200, 201, 204]) + func successPassesDataThrough(status: Int) throws { + let payload = Data("inhalt".utf8) + #expect(try PlexResponse.validate(data: payload, response: response(status)) == payload) + } + + @Test("401 und 403 führen zurück zur Anmeldung", arguments: [401, 403]) + func authenticationFailuresMapToUnauthorized(status: Int) { + #expect(throws: PlexError.unauthorized) { + try PlexResponse.validate(data: Data(), response: response(status)) + } + } + + @Test("404 ist ein fehlender Eintrag") + func notFoundMaps() { + #expect(throws: PlexError.notFound) { + try PlexResponse.validate(data: Data(), response: response(404)) + } + } + + @Test("Serverfehler dürfen wiederholt werden, Anfragefehler nicht") + func retryabilityFollowsStatusClass() { + #expect(PlexError.server(status: 503).isRetryable) + #expect(PlexError.server(status: 500).isRetryable) + #expect(!PlexError.server(status: 400).isRetryable) + #expect(!PlexError.unauthorized.isRetryable) + #expect(PlexError.transport(description: "Zeitüberschreitung").isRetryable) + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e0b7e2 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# Flixy + +> Ein nativer Plex-Client für iPhone und iPad — dunkel, mit ausfahrbarer +> Seitenleiste und Offline-Downloads. + +Der Name ist ein Platzhalter. Siehe [`Flixy/Branding.swift`](Flixy/Branding.swift). + +## Stand + +Phase 0 (Fundament) steht. Die App startet, die Navigation ist begehbar, die +Inhalte folgen in Phase 1. + +Der Fahrplan steht in +[`docs/superpowers/specs/2026-09-11-flixy-design.md`](docs/superpowers/specs/2026-09-11-flixy-design.md). + +| Phase | Inhalt | Stand | +|---|---|---| +| 0 | Projektgerüst, Design-Tokens, Navigation | ✅ | +| 1 | Anmeldung, Bibliothek, DirectPlay | offen | +| 2 | Transcoding, adaptive Bitrate, Spuren, Sync | offen | +| 3 | Download-Engine und Offline-Wiedergabe | offen | +| 4 | Download-Intelligenz | offen | +| 5 | Feinschliff und Personalisierung | offen | +| 6 | Live-TV mit EPG | offen | + +## Aufbau + +Die Xcode-Projektdatei wird aus `project.yml` erzeugt und ist **nicht** +eingecheckt — das erspart Konflikte in der `pbxproj`. + +```bash +xcodegen generate +open Flixy.xcodeproj +``` + +Der Code liegt zum größten Teil in lokalen Packages unter `Packages/FlixyKit`. +Das App-Target enthält nur Oberfläche und Navigation. + +| Modul | Aufgabe | Hängt ab von | +|---|---|---| +| `PlexKit` | Anmeldung, Endpunkte, Modelle. Kein SwiftUI. | — | +| `FlixyDesign` | Farben, Typografie, wiederkehrende Bausteine. | — | +| `FlixyPlayback` | Wiedergabe-Abstraktion und die Entscheidung DirectPlay/Transcode. | `PlexKit` | +| `FlixyDownloads` | Warteschlange, Hintergrundübertragung, Offline-Bestand. | `PlexKit` | + +### Warum zwei Player + +iOS spielt weder Matroska-Dateien noch DTS- oder TrueHD-Ton — und genau das +liegt in typischen Plex-Beständen. `PlaybackDecider` prüft deshalb pro Datei, +ob AVPlayer sie nativ öffnen kann (dann gibt es Bild-in-Bild und AirPlay +gratis), und schickt sie sonst an VLCKit. + +Ab Phase 2 kommt der dritte Weg dazu: Server-Transcoding. Dafür ist genau eine +Funktion zu erweitern — `PlaybackDecider.decide(media:part:policy:)`. Die +Oberfläche bleibt unberührt. + +## Tests + +Die Logik-Tests laufen ohne Simulator, deshalb in Sekunden: + +```bash +cd Packages/FlixyKit && swift test +``` + +Die wichtigste Testdatei ist `PlaybackDeciderTests` — sie hält fest, welche +Datei auf welchem Weg landet, und schlägt an, sobald jemand +`DeviceCapabilities` anfasst. + +Die Antwort-Vorlagen unter `Tests/PlexKitTests/Fixtures` sind von Hand +geschrieben und gehören durch echte Mitschnitte ersetzt, sobald ein Server +erreichbar ist — siehe das README dort. + +Die Oberfläche wird von Hand im Simulator geprüft. + +## Voraussetzungen + +- Xcode 26 oder neuer +- iOS 26.0 auf dem Zielgerät +- XcodeGen (`brew install xcodegen`) diff --git a/docs/superpowers/specs/2026-09-11-flixy-design.md b/docs/superpowers/specs/2026-09-11-flixy-design.md new file mode 100644 index 0000000..f11fb84 --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-flixy-design.md @@ -0,0 +1,135 @@ +# Flixy — nativer Plex-Client für iPhone & iPad + +## Kontext + +Der Ordner `/Users/scarriffle/Projects_Coding/Flixy` ist leer. Es entsteht ein nativer Plex-Client, weil die aktuelle offizielle Plex-App gestalterisch nicht gefällt — gewünscht ist etwas, das näher an der alten Plex-Handy-App liegt, sich aber wertig anfühlt wie eine große Streaming-App: ein-/ausblendbare Seitenleiste für die Mediatheken, ein Hero-geführter Startbildschirm, durchgängig dunkel, mit einstellbarer Akzentfarbe. + +Zwei Subsysteme tragen das Projekt: eine **Wiedergabe-Schicht**, die zunächst nur DirectPlay macht, aber so gebaut ist, dass Server-Transcoding und adaptive Bitrate reine Ergänzungen sind — und eine **Download-Engine** für Offline-Nutzung von einzelnen Folgen bis zu ganzen Serien. + +Vorbild und Referenz im eigenen Bestand: **Nook** (`../ABS-Client`) — derselbe Aufbau für Audiobookshelf, inkl. Hintergrund-Downloads, Live Activities und DE/EN-Lokalisierung. **Onyx** (`../Onyx`) liefert das Muster für XcodeGen + lokale Packages. + +Der Name „Flixy" ist ein Platzhalter und soll später leicht änderbar sein. + +## Entscheidungen (mit dem Nutzer abgestimmt) + +| Thema | Entscheidung | +|---|---| +| Plattformen | iPhone + iPad (kein macOS, kein tvOS) | +| Mindest-iOS | 26.0 | +| Anmeldung | plex.tv PIN-Flow, ein Server, ein Benutzer | +| Player | Hybrid — AVPlayer wenn nativ möglich, sonst VLCKit | +| Medien | Filme, Serien, Live-TV mit EPG (kein Musik/Fotos) | +| Plex Pass | vorhanden | +| Struktur | XcodeGen + lokale Swift Packages | +| Persistenz | SwiftData | +| Tests | Swift Testing für Logik, UI manuell im Simulator | +| Sprachen | Deutsch + Englisch (String Catalogs) | +| Design | nur Dark Mode, Layout A (Hero + Querformat-Reihen), Overlay-Drawer-Seitenleiste | +| Akzentfarbe | kuratierte Palette + eigene Farbe mit Kontrastkorrektur | + +## Architektur + +Vier lokale Swift Packages unter `Packages/`, das App-Target enthält nur noch SwiftUI-Views und Navigation. Abhängigkeiten fließen strikt in eine Richtung: + +``` +App ──► FlixyDesign (Farb-Tokens, Poster/Hero-Komponenten, Platzhalter — keine Abhängigkeiten) + │ + ├──────► PlexKit (HTTP, Auth, Modelle, Endpunkte — kein UI, kein SwiftUI) + │ + ├──────► FlixyPlayback ──► PlexKit + │ + └──────► FlixyDownloads ─► PlexKit +``` + +**PlexKit** — `PlexClient` (Actor) kapselt Header (`X-Plex-Token`, `X-Plex-Client-Identifier`, Produkt/Version), Verbindungsauswahl und Dekodierung. Endpunkte als kleine, einzeln testbare Typen; Modelle (`PlexItem`, `PlexMedia`, `PlexPart`, `PlexStream`, `PlexMarker`) als `Codable` gegen echte Server-Antworten, die als JSON-Fixtures im Test-Bundle liegen. + +**FlixyPlayback** — das Herzstück der Nachrüstbarkeit: + +```swift +protocol VideoPlayerEngine: AnyObject { + func load(_ source: PlaybackSource) async throws + func play(); func pause(); func seek(to: Duration) + var state: AsyncStream { get } + var tracks: [MediaTrack] { get } + func select(_ track: MediaTrack) + var supportsPictureInPicture: Bool { get } +} +``` + +`AVPlayerEngine` und `VLCPlayerEngine` implementieren es. Darüber entscheidet `PlaybackDecider` anhand von Streams + Gerätefähigkeiten (+ ab Phase 2 Netzwerk) zwischen `.directPlay(engine:)` und `.transcode(url:maxBitrate:)`. **In Phase 1 gibt der Decider nur DirectPlay-Fälle zurück** — Phase 2 erweitert genau diese eine Funktion, ohne die View-Schicht anzufassen. Das ist die konkrete Form von „Transcoding vorbereiten". + +**FlixyDownloads** — `URLSession` mit Background-Konfiguration, ein `DownloadCoordinator`-Actor als Zustandsautomat (queued → downloading → paused → completed/failed), SwiftData-Modelle `DownloadItem` und `OfflineMetadata` (Titel, Beschreibung, Marker, Spuren, Cover-Pfad — alles, was die Detailseite und der Player offline brauchen). + +**Branding-Isolation** — App-Name, Bundle-ID, Icon und Standard-Akzentfarbe liegen ausschließlich in `project.yml` und `Branding.swift`. Umbenennen später = zwei Dateien. + +## Phasen + +### Phase 0 — Fundament +- `project.yml` (XcodeGen), Packages-Gerüst, `.gitignore`, Git-Repo initialisieren +- Dieses Dokument als Spec nach `docs/superpowers/specs/2026-09-11-flixy-design.md` ins Repo übernehmen und committen +- App-Target: `.preferredColorScheme(.dark)`, String Catalogs DE/EN, `Branding.swift` +- `FlixyDesign`: Farb-Tokens (Surface-Stufen, Text, Akzent als austauschbarer Wert), Typo-Skala, `PosterCard`, `LandscapeCard`, `HeroBanner`, Shimmer-Platzhalter +- Test-Setup mit Swift Testing in allen Packages + +### Phase 1 — Anmeldung, Bibliothek, DirectPlay +*Ziel: App ist real benutzbar.* +- **Auth:** `POST /api/v2/pins` → Browser-Auth → Polling → Token im Keychain. Server über `GET /api/v2/resources` auflisten; Verbindungen (lokal / plex.direct / Relay) parallel anpingen, schnellste erreichbare gewinnt und wird gemerkt. +- **Seitenleiste:** Overlay-Drawer mit Abdunklung, per Wischgeste und Hamburger; auf dem iPad dauerhaft im Split-View, einklappbar. Einträge: Start, je Bibliothek, Downloads, Einstellungen. +- **Startseite (Layout A):** Hero-Karussell oben, darunter Reihen aus `/hubs` — Weiterschauen, Als Nächstes, Zuletzt hinzugefügt je Bibliothek, Watchlist, Sammlungen. +- **Bibliothek:** Grid und Liste umschaltbar, Filter (Genre, Jahr, Bewertung, Auflösung), Sortierung, „Nur Ungesehene"-Schalter, Paginierung über `X-Plex-Container-Start/Size`. +- **Detailseiten:** Film und Serie → Staffel → Folge, Besetzung mit Porträts (tippbar), Ähnliche Titel, aufklappbare technische Infos (Codec, Bitrate, HDR, Tonspuren, Größe), Versionsauswahl bei mehreren Media-Einträgen. +- **Suche:** Live-Ergebnisse über alle Bibliotheken, gruppiert, letzte Suchen gemerkt. +- **Player:** `PlaybackDecider` + beide Engines, DirectPlay über `{Part.key}?X-Plex-Token=…`, eigenes Chrome (Scrubber, ±10 s, Titel/Folgenanzeige), PiP wenn AVPlayer aktiv ist. + +**Schlüsseldateien:** `Packages/PlexKit/Sources/{PlexClient,Auth,Endpoints,Models}`, `Packages/FlixyPlayback/Sources/{PlaybackDecider,AVPlayerEngine,VLCPlayerEngine}`, `App/Views/{Sidebar,Home,Library,Detail,Player}` + +### Phase 2 — Transcoding, adaptive Bitrate, Spuren, Sync +- `PlaybackDecider` um Transcode-Zweig erweitern: `/video/:/transcode/universal/decision` fragen, bei Bedarf `start.m3u8` mit Client-Profil und `maxVideoBitrate` bauen +- Echtes ABR über den HLS-Stream, dazu Obergrenzen-Regler getrennt für WLAN und Mobilfunk +- Untertitel- und Tonspurwahl (sidecar bei DirectPlay, eingebrannt bei Transcode), Untertitel-Styling, bevorzugte Sprache merken +- Intro-/Abspann-Marker aus `includeMarkers=1`: Skip-Button, optionales Auto-Skip, Autoplay der nächsten Folge +- Fortschritt an den Server melden (`/:/timeline`), gesehen/ungesehen markieren (`/:/scrobble`, `/:/unscrobble`), ungesendete Updates offline puffern und nachreichen + +### Phase 3 — Download-Engine & Offline +- Hintergrund-`URLSession`, Zustandsautomat, Wiederaufnahme nach App-Neustart +- Einzeldownload (Film/Folge) und Batch (Staffel/Serie) mit Gesamtfortschritt und Abbruch +- Download in Originalqualität über `?download=1` +- Offline-Metadaten und Cover mit sichern, Offline-Wiedergabe aus der lokalen Datei +- Downloads-Ansicht in der Seitenleiste: laufend, offline verfügbar, Belegung, pausieren/fortsetzen/löschen +- Nur-WLAN-Schutz, Live Activity mit Fortschritt in Sperrbildschirm und Dynamic Island + +### Phase 4 — Download-Intelligenz +- Qualitätswahl pro Download (Original oder server-komprimiert) — **vorab ein Spike**: Plex-Sync-API (`/sync/items`) gegen HLS-Segment-Download abwägen +- Einstellbares Speicherlimit mit Warnung +- Automatisches Löschen nach dem Ansehen (global oder pro Serie) +- Regel-Engine „halte N ungesehene Folgen offline" — lädt beim Weiterschauen nach + +### Phase 5 — Feinschliff & Personalisierung +- Akzentfarben-Einstellung: kuratierte Kacheln plus eigene Farbe mit automatischer Helligkeits-/Kontrastkorrektur gegen die dunklen Flächen +- Startseiten-Reihen ein-/ausblenden und per Drag umsortieren +- Watchlist verwalten, Sterne-Bewertung, Bibliotheks-Scan anstoßen (hinter Bestätigung) +- Animationen, leere Zustände, Fehlerzustände, iPad-Feinschliff, Lokalisierungs-Durchgang + +### Phase 6 — Live-TV & EPG +- **Spike zuerst:** DVR-/EPG-Endpunkte sind bei Plex am schlechtesten dokumentiert — `/media/providers`, Sendergitter, `/livetv/sessions` +- Sender-Liste, EPG-Raster mit Zeitachse, Umschalten auf laufende Sendung (immer transcodiert, baut auf Phase 2 auf) + +### Später / bewusst nicht geplant +Mehrere Server und Plex-Home-Profile (Datenschicht ist dafür vorbereitet), macOS, tvOS, Musik- und Fotobibliotheken, Chromecast, Trailer/Extras, Löschen von Serverdateien, Home-Screen-Widget, Siri/Spotlight. + +## Risiken, die früh geprüft werden müssen + +1. **VLCKit auf iOS 26 + Swift 6** — Verfügbarkeit über SPM, Concurrency-Verhalten, Binärgröße (~60–100 MB). Spike in Phase 0/1, bevor die Player-Abstraktion festgezurrt wird. +2. **Komprimierte Downloads** — die Plex-Sync-API ist umständlich und schlecht dokumentiert. Spike vor Phase 4; Original-Download in Phase 3 ist der sichere Boden. +3. **Sehr große Dateien im Hintergrund** — iOS drosselt Background-Transfers. Verhalten mit einem 40-GB-Remux real prüfen, `isDiscretionary` bewusst setzen. +4. **Live-TV** — kann sich als deutlich größer herausstellen als geplant; deshalb bewusst am Ende. + +## Verifikation + +- **Automatisiert:** `swift test` je Package — PlexKit gegen aufgezeichnete Server-Antworten, `PlaybackDecider` gegen eine Matrix aus Codec/Container/Gerät (die wichtigste Testtabelle im Projekt), Download-Zustandsautomat inkl. Pausieren/Fortsetzen/Abbruch. +- **Manuell je Phase im Simulator und auf dem Gerät:** + - P1: Anmelden, Server finden, Bibliothek durchblättern, MP4 mit AVPlayer und MKV mit VLCKit abspielen + - P2: Datei erzwungen transcodieren, Netzwerk drosseln (Network Link Conditioner) und Bitratenwechsel beobachten, Fortschritt auf einem zweiten Plex-Client gegenprüfen + - P3: Serie herunterladen, App killen, Flugmodus, offline abspielen; Live Activity auf einem echten Gerät prüfen + - P5: Akzentfarbe durchschalten und Kontraste gegenprüfen, DE/EN umschalten +- **Am Ende jeder Phase:** ein lauffähiger Build, keine offenen Compiler-Warnungen, Screenshot-Durchlauf der Kernbildschirme auf iPhone und iPad. diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..7645300 --- /dev/null +++ b/project.yml @@ -0,0 +1,83 @@ +name: Flixy + +options: + bundleIdPrefix: com.scarriffleservices + deploymentTarget: + iOS: "26.0" + createIntermediateGroups: true + groupSortPosition: top + developmentLanguage: en + +settings: + base: + DEVELOPMENT_TEAM: PP34X97WS3 + SWIFT_VERSION: "6.0" + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" + SWIFT_STRICT_CONCURRENCY: complete + +packages: + FlixyKit: + path: Packages/FlixyKit + +targets: + Flixy: + type: application + platform: iOS + sources: + - path: Flixy + dependencies: + - package: FlixyKit + product: PlexKit + - package: FlixyKit + product: FlixyDesign + - package: FlixyKit + product: FlixyPlayback + - package: FlixyKit + product: FlixyDownloads + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.flixy + PRODUCT_NAME: Flixy + # iPhone und iPad aus demselben Target. + TARGETED_DEVICE_FAMILY: "1,2" + CODE_SIGN_STYLE: Automatic + info: + path: Flixy/Info.plist + properties: + CFBundleDisplayName: Flixy + UILaunchScreen: + UIColorName: LaunchBackground + + # Der harte Schalter für „nur dunkel". Eine Ansicht mit + # .preferredColorScheme(.dark) allein reicht nicht: Systemdialoge, + # Statusleiste und Tastatur richten sich danach nicht. + UIUserInterfaceStyle: Dark + + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + + # Ohne diesen Modus hält iOS die Wiedergabe an, sobald die App in den + # Hintergrund geht — und Bild-in-Bild gäbe es gar nicht. + UIBackgroundModes: + - audio + + NSAppTransportSecurity: + # Plex-Server im Heimnetz laufen überwiegend auf blankem HTTP an + # Port 32400. Diese Ausnahme erlaubt genau das, ohne die + # Transportsicherheit für das offene Netz abzuschalten. + NSAllowsLocalNetworking: true + + # Seit iOS 14 verlangt jeder Zugriff auf Adressen im eigenen Netz eine + # Freigabe. Fehlt der Text, verweigert das System sie kommentarlos und + # die Serversuche findet dauerhaft nichts. + NSLocalNetworkUsageDescription: >- + Flixy benötigt Zugriff auf das lokale Netzwerk, um deinen + Plex-Server zu finden und Medien direkt von ihm zu laden.