import SwiftData import SwiftUI #if os(iOS) import UIKit #endif import FlixyDesign import FlixyDownloads import FlixyPlayback import JellyfinKit import MediaKit import PlexKit @main struct FlixyApp: App { #if os(iOS) /// Nimmt das Signal entgegen, mit dem iOS die App zum Abschluss von /// Hintergrund-Downloads aufweckt. Dafür gibt es in SwiftUI keinen /// eigenen Weg — nur den klassischen App-Delegaten. Auf dem Mac wird eine /// App nicht aus dem Speicher geworfen, während sie lädt; dort gibt es /// weder das Signal noch den Bedarf. @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate #endif /// Leben hier, nicht in einer View: sie gelten für die gesamte App, und /// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch laufende /// Downloads zurücksetzen. @State private var theme = FlixyTheme() @State private var session: FlixySession @State private var playback = PlaybackCoordinator() @State private var playbackSettings = PlaybackSettings() @State private var languages = LanguagePreferences() /// Beobachtet WLAN und Mobilfunk. Lebt hier, damit der Beobachter einmal /// läuft statt bei jedem Öffnen des Players neu zu starten. @State private var network = NetworkConditions() @State private var homeLayout = HomeLayoutSettings() @State private var diagnostics = PlaybackDiagnostics() @State private var downloads: DownloadCoordinator private let modelContainer: ModelContainer init() { let identity = PlexClientIdentity.current( product: Branding.plexProduct, version: Branding.version ) _session = State(initialValue: FlixySession( identity: identity, jellyfinIdentity: JellyfinIdentity.current( client: Branding.appName, version: Branding.version ), tokenStore: KeychainTokenStore(service: Branding.keychainService) )) // Scheitert das Anlegen der Datenbank, ist die App nicht sinnvoll zu // benutzen — dann lieber hier abbrechen als später bei jedem Download // stillschweigend nichts tun. let container = try! ModelContainer(for: DownloadItem.self) self.modelContainer = container let fileStore = try! DownloadFileStore() _downloads = State(initialValue: DownloadCoordinator( container: container, fileStore: fileStore )) } var body: some Scene { WindowGroup { RootView() // Eine Untergrenze, unter der die Oberfläche nicht mehr // aufgeht: schmaler als das hier klappt die Seitenleiste zu // und die Reihen zeigen anderthalb Kacheln. // // Zur Laufzeit gefragt, nicht beim Übersetzen: auf dem Mac // läuft genau dieser iOS-Bau, und `os(macOS)` ist dabei // falsch. Ohne Untergrenze öffnet das Fenster in // Notizzettelgröße, und darin fällt der Aufmacher in sich // zusammen, weil seine Abstände dehnbar sind. .frame( minWidth: FlixyPlatform.isMac ? 900 : nil, minHeight: FlixyPlatform.isMac ? 620 : nil ) // `defaultSize` gibt es hier nicht; die Größe hängt an der // Szene. .task { Self.widenWindow() } .environment(session) .environment(playback) .environment(playbackSettings) .environment(languages) .environment(network) .environment(downloads) .environment(homeLayout) .environment(diagnostics) .environment(theme) // Akzentfarbe und Tönung setzt RootView, nicht diese Stelle. // // Scene-Bodies werten Änderungen an beobachteten Objekten // nicht zuverlässig neu aus. Stand `.tint` hier, blieb nach // einem Farbwechsel ein Teil der Oberfläche auf dem alten // Wert stehen — sichtbar etwa an Picker-Werten, die weiter in // der Standardfarbe leuchteten, während die Schalter schon // umgestellt waren. // // Der eigentliche Schalter für „nur dunkel" steht in der // Info.plist (UIUserInterfaceStyle). Diese Zeile ist die // Absicherung für SwiftUI-Vorschauen, die sie nicht lesen. .preferredColorScheme(.dark) .task { #if os(iOS) appDelegate.downloads = downloads #endif await downloads.restore() // Beim Start einmal prüfen: während die App weg war, // können Folgen gesehen oder gelöscht worden sein. await DownloadRefiller( backend: session.backend, coordinator: downloads ).refill() } } .modelContainer(modelContainer) #if os(macOS) // Ohne diese Angabe öffnet macOS ein Fenster von der Größe eines // Notizzettels — und darin fällt der Anmeldebildschirm in sich // zusammen, weil seine Abstände dehnbar sind. .defaultSize(width: 1280, height: 820) // Das Fenster darf nicht kleiner werden, als der Inhalt verträgt. .windowResizability(.contentMinSize) // Keine graue Titelleiste über einem dunklen Bild: die Ampelknöpfe // schweben über dem Inhalt, wie man es von Fernseh-Apps kennt. .windowStyle(.hiddenTitleBar) #endif } } #if os(iOS) extension FlixyApp { /// Gibt dem Fenster eine vernünftige Mindestgröße. /// /// Auf dem Mac öffnet das System sonst ein Fenster von der Größe eines /// Notizzettels — und darin fällt der Aufmacher in sich zusammen, weil /// seine Abstände dehnbar sind. /// /// `sizeRestrictions` gibt es nur dort, wo Fenster sich überhaupt frei /// ziehen lassen; auf dem iPhone ist die Eigenschaft `nil`. Das /// Fragezeichen ist deshalb die ganze Fallunterscheidung — ein Aufruf /// auf einem Gerät ohne Fenster tut schlicht nichts. @MainActor static func widenWindow() { for scene in UIApplication.shared.connectedScenes { guard let windowScene = scene as? UIWindowScene else { continue } windowScene.sizeRestrictions?.minimumSize = CGSize(width: 900, height: 620) windowScene.sizeRestrictions?.maximumSize = CGSize( width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude ) } } } /// Der klassische App-Delegat, für das eine, was SwiftUI nicht abdeckt. @MainActor final class AppDelegate: NSObject, UIApplicationDelegate { weak var downloads: DownloadCoordinator? /// Nicht `@Sendable`: UIKit reicht den Block ohne diese Zusage herein, /// und er wird ohnehin nur auf dem Hauptakteur aufgerufen. Der Delegat /// selbst ist an den Hauptakteur gebunden. private var pendingCompletionHandler: (() -> Void)? /// Welche Ausrichtungen gerade erlaubt sind. /// /// Wird vom System bei jeder Drehung gefragt. Ohne diese Antwort dreht /// `requestGeometryUpdate` zwar kurz, das System dreht aber sofort zurück /// — genau das sah aus wie „wechselt nicht so richtig ins Querformat". func application( _ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow? ) -> UIInterfaceOrientationMask { MainActor.assumeIsolated { OrientationLock.shared.mask } } func application( _ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void ) { // iOS hat die App im Hintergrund gestartet, weil Downloads fertig // geworden sind. Der Abschlussblock muss aufgerufen werden, sobald // alles verarbeitet ist — sonst beendet das System die App hart und // merkt sich das für künftige Aufweckversuche negativ. guard identifier == DownloadCoordinator.sessionIdentifier else { completionHandler() return } pendingCompletionHandler = completionHandler Task { [weak self] in await self?.downloads?.restore() self?.pendingCompletionHandler?() self?.pendingCompletionHandler = nil } } } #endif