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