Wer weiss, was er sehen will, klickte bisher auf die Kachel, wartete auf die Detailseite und drueckte dort „Abspielen\". Zwei Schritte fuer eine Absicht. Jetzt gibt es oben rechts einen Knopf — auf dem Mac beim Darueberfahren, auf iPad und iPhone ueber langes Druecken. Bei einer Serie ist die Absicht nicht „spiel die Serie\", sondern „mach da weiter, wo ich war\". Welche Folge das ist, entscheidet `EpisodeSelection`: die aelteste ungesehene, fortgesetzt falls angefangen, und von vorn, wenn alles gesehen ist. Ein reiner Typ mit achtzehn Tests — im App-Ziel gibt es kein Unit-Test-Ziel, und „er hat die falsche Folge gestartet\" ist sonst ein Befund, den man nicht nachstellen kann. Ausdruecklich die aelteste ungesehene, nicht die am weitesten fortgeschrittene: wer sich eine Folge aufgehoben hat, soll sie nicht dadurch verlieren, dass er weiter vorn hineingeschaut hat. Dazu `episodes(inSeries:)` im Vertrag — bei Jellyfin eine Anfrage statt dreizehn, fuer alle anderen die Vorgabe ueber `children(of:)`. Nicht `/Shows/NextUp`: der braucht eine Seriennummer, die eine Serie selbst nicht hat, versteckt vollstaendig gesehene Serien und bringt eine eigene Zeitgrenze mit. Welche Folge dran ist, soll eine Regel entscheiden, nicht der Server. Nebenbei behoben: der grosse Abspielknopf im Aufmacher scheiterte bisher mit „keine abspielbare Datei\", sobald dort eine Serie stand — und Jellyfins „Zuletzt hinzugefuegt\" liefert neue Folgen als ihre Serie. Der Knopf liegt als Ueberzug auf dem NavigationLink, nicht darin: ein Button im Label eines Links loest auf iOS nie aus, und `.plain` macht das Versagen lautlos. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
8.6 KiB
Swift
209 lines
8.6 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()
|
|
@State private var languages = LanguagePreferences()
|
|
@State private var quickPlay = QuickPlay()
|
|
/// 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(quickPlay)
|
|
.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
|