Und dein Argument dafür war das beste: auf dem Mac kann ich selbst hinsehen. Kein Simulator-Tunnel, der mitten im Prüfen abstürzt — `screencapture` auf das Fenster der App, und zwar nur auf ihres, nicht auf den Bildschirm. **Ein eigenes Ziel, nicht ein gemeinsames.** Die Info.plist der iOS-App ist voller iOS-Angaben: Startbildschirm, erlaubte Ausrichtungen, Live Activities. Die alle bedingt zu machen hieße, dass jede spätere Änderung an der iOS-Seite über eine Stelle geht, die auch den Mac betrifft. Zwei Ziele sind ein paar Zeilen mehr und eine Sorge weniger. **Was den Unterschied ausmacht, steht an einer Stelle.** Ein neuer `PlatformShims` in FlixyDesign übersetzt, was auf beiden Seiten verschieden heißt: schmale Titelzeile, halbhohes Blatt, Eingabefelder ohne Großschreibung, die Plätze in der Werkzeugleiste, die Zwischenablage. Zwanzig Ansichten benutzen ihn, statt zwanzigmal `#if os(iOS)` zu tragen — sonst wandert die Fallunterscheidung in jede neue Datei und wird beim übernächsten Bildschirm vergessen. Wirklich verschieden sind nur fünf Stellen: die Bildfläche (`UIView` gegen `NSView`), der AirPlay-Knopf, die Ausrichtungssperre (auf dem Mac gibt es nichts zu sperren), der App-Delegat für Hintergrund-Downloads und das Anmeldefenster, das ein anderes Fenster als Anker braucht. **Das Karussell blättert jetzt mit einem einrastenden Streifen** statt mit `TabView(.page)` — den Seitenstil gibt es auf dem Mac nicht. Das ist nebenbei die bessere Lösung: das verschachtelte TabView hatte die Eigenschaft, beim Wechsel kurz zwei Seiten gleichzeitig zu zeigen. **Was auf dem Mac fehlt und warum:** VLC. Das Binärpaket ist auf iOS festgelegt; sein xcframework enthält zwar Mac-Teile, aber das ist eine eigene Runde. Solange fällt die Wiedergabe dort auf den Systemplayer zurück — MKV und DTS gehen also noch nicht. Live Activities gibt es auf dem Mac nicht, Bild-in- Bild startet von Hand statt beim Verlassen der App. Gesehen: die App startet, der Anmeldebildschirm steht, beide Wege sind da. Angemeldet habe ich mich auf dem Mac **nicht** — das ginge nur, indem ich einen Token in deinen Schlüsselbund schreibe, und dann fragt macOS dich beim ersten Zugriff nach dem Passwort. Das machst du mit einem Klick selbst. iOS baut unverändert, 263 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
154 lines
5.9 KiB
Swift
154 lines
5.9 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()
|
|
.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(iOS)
|
|
/// 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
|