Auf deinem vierten Bild steht „Ziehe Objekte zur Seiten- oder Tableiste, um sie anzupassen". Das ist kein Text, den jemand geschrieben hat, sondern einer von Apple. JellyTV benutzt dort `TabView` mit `sidebarAdaptable` — und damit ist die Frage, wie die das hinbekommen, beantwortet: gar nicht, das System macht es. Meine drei selbstgebauten Formen fliegen dafür raus: das ausfahrbare Blatt, die geteilte Ansicht und die Reihe oben, die ich vor einer Stunde noch selbst gebaut habe. Zusammen konnten sie weniger als das eine Bauteil und mussten dreimal gepflegt werden. Was jetzt vom System kommt: - die Seitenleiste aus Glas, mit dem Inhalt **dahinter** statt daneben - der Knopf in ihrer Kopfzeile, der zwischen Seitenleiste und Leiste **oben** umschaltet — genau der, den du auf deinen Bildern siehst - „Bearbeiten" mit Ziehen, Ein- und Ausblenden, für beide Formen gleichzeitig - auf dem iPhone die gewohnte Leiste unten statt eines Hamburger-Menüs Die Einstellung „seitlich oder oben" entfällt damit: sie säße neben einem Systemknopf, der dasselbe besser kann. Was du umstellst, merkt sich das System selbst; wir geben ihm nur einen Ort zum Aufbewahren. Die Mediatheken stehen in einem eigenen Abschnitt, der sich in der Seitenleiste zuklappen lässt. Die Suche bekommt ihre eigene Rolle und wird deshalb getrennt von den übrigen Einträgen dargestellt. Angesehen auf iPad (Seitenleiste, Leiste oben, Bearbeiten-Dialog) und iPhone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
143 lines
5.7 KiB
Swift
143 lines
5.7 KiB
Swift
import SwiftData
|
|
import SwiftUI
|
|
import FlixyDesign
|
|
import FlixyDownloads
|
|
import FlixyPlayback
|
|
import JellyfinKit
|
|
import MediaKit
|
|
import PlexKit
|
|
|
|
@main
|
|
struct FlixyApp: App {
|
|
/// 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.
|
|
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
|
|
|
/// 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 {
|
|
appDelegate.downloads = downloads
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
}
|