Phase 3: Download-Engine und Offline-Wiedergabe

Engine
- Hintergrund-URLSession: läuft weiter, wenn die App zu ist. Der ganze
  Zustand liegt in SwiftData, weil es nach einem Neustart kein anderes
  Gedächtnis gibt.
- Pausieren über cancel(byProducingResumeData:) — nur so lässt sich
  weiterladen statt neu zu beginnen. Bei 40 GB der Unterschied
  zwischen brauchbar und unbrauchbar.
- Zwei Übertragungen gleichzeitig, Folgen in Staffel-/Folgenreihenfolge.
  Alles parallel zu laden hieße: keine Folge wird fertig.
- Speicherlimit und Geräteplatz werden vor dem Annehmen geprüft, ein
  Gigabyte bleibt als Puffer frei.
- Verwaiste Dateien werden beim Start entfernt; sie entstehen, wenn die
  App genau beim Abschluss beendet wird.
- Downloads liegen in Application Support, nicht in Caches (iOS räumt
  den eigenmächtig leer) und sind von der iCloud-Sicherung ausgenommen.

Oberfläche
- Download-Knopf auf Detailseiten mit vier Zuständen in einem Knopf.
- „Alle Folgen laden" für Staffel und Serie; ein abgelehnter Titel
  stoppt den Stapel nicht.
- Downloads-Ansicht mit Belegung, WLAN-Schalter, Pausieren, Fortsetzen,
  Löschen — nach Serie gruppiert.

Offline
- Der Player bevorzugt die lokale Datei, auch ohne erreichbaren Server.
- Beim Herunterladen wird ein Abzug der Serverangaben mitgesichert:
  offline lässt sich nichts nachfragen, und der PlaybackDecider braucht
  Container und Codecs.

Ein Fehler, den die Tests aufgedeckt haben: der Koordinator hielt nur
den ModelContext, nicht den ModelContainer. Ein ModelContext hält seinen
Container nicht am Leben — wird der anderswo freigegeben, stürzt jeder
Zugriff auf einen Eintrag ab, und die Meldung zeigt auf ModelContext.reset
statt auf die Ursache. Jetzt liegt die Zuständigkeit beim Koordinator.

102 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-11 17:34:57 +02:00
co-authored by Claude Opus 5
parent 55bcfe2fbf
commit 62158303a2
14 changed files with 2175 additions and 6 deletions
+63 -2
View File
@@ -1,13 +1,20 @@
import SwiftData
import SwiftUI
import FlixyDesign
import FlixyDownloads
import FlixyPlayback
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 die
/// eingestellte Farbe zurücksetzen.
/// 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()
@@ -15,6 +22,9 @@ struct FlixyApp: App {
/// 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 downloads: DownloadCoordinator
private let modelContainer: ModelContainer
init() {
let identity = PlexClientIdentity.current(
@@ -25,6 +35,18 @@ struct FlixyApp: App {
identity: identity,
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 {
@@ -34,6 +56,7 @@ struct FlixyApp: App {
.environment(playback)
.environment(playbackSettings)
.environment(network)
.environment(downloads)
.environment(theme)
.flixyAccent(theme)
// Der eigentliche Schalter für „nur dunkel" steht in der
@@ -41,6 +64,44 @@ struct FlixyApp: App {
// Absicherung für SwiftUI-Vorschauen, die sie nicht lesen.
.preferredColorScheme(.dark)
.tint(theme.accentColor)
.task {
appDelegate.downloads = downloads
await downloads.restore()
}
}
.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)?
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
}
}
}