Files
Flaro/Flixy/FlixyApp.swift
T
ScarriffleandClaude Opus 5 5dd2daf3ed FlixyMac ist weg
VLCKit liefert Teile für iOS, macOS, tvOS und die Simulatoren — aber
keinen für Catalyst. Deshalb hing `FlixyVLC` gar nicht erst am Mac-Ziel,
und deshalb musste dort jedes MKV und jeder DTS-Ton vom Server
umgerechnet werden. Bei einem Medienclient ist das nicht ein Detail am
Rand, sondern der Zweck der Übung.

„Für iPad angepasst" ist derselbe iOS-Bau und lädt denselben iOS-Teil von
VLCKit. Damit läuft auf dem Mac, was auf dem iPad läuft.

Was Catalyst dafür konnte — eine `.app` zum Weitergeben, Intel-Macs, die
Fensterleiste über AppKit — wiegt hier nichts auf: ein Nutzer, ein
Apple-Silicon-Mac.

Die Fenstergröße hing an `#if targetEnvironment(macCatalyst)` und fragt
jetzt `FlixyPlatform.isMac`. `sizeRestrictions` ist auf dem iPhone `nil`,
das Fragezeichen ist die ganze Fallunterscheidung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:00:44 +02:00

205 lines
8.5 KiB
Swift

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()
/// 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(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