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
+401
View File
@@ -2176,6 +2176,407 @@
}
}
}
},
"downloads.empty.title": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nichts offline"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Nothing offline"
}
}
}
},
"downloads.empty.description": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Lade Filme und Folgen herunter, um sie ohne Verbindung anzusehen."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Download movies and episodes to watch them without a connection."
}
}
}
},
"downloads.active": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Läuft gerade"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "In progress"
}
}
}
},
"downloads.movies": {
"comment": "Überschrift für heruntergeladene Filme",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Filme"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Movies"
}
}
}
},
"downloads.used": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Belegt"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Used"
}
}
}
},
"downloads.wifiOnly": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nur über WLAN laden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Download over Wi-Fi only"
}
}
}
},
"downloads.pause": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Anhalten"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Pause"
}
}
}
},
"downloads.resume": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Fortsetzen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Resume"
}
}
}
},
"downloads.cancel": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Download abbrechen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Cancel download"
}
}
}
},
"downloads.download": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Herunterladen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Download"
}
}
}
},
"downloads.available": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Offline verfügbar"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Available offline"
}
}
}
},
"downloads.queued": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wartet"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Queued"
}
}
}
},
"downloads.paused": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Angehalten"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Paused"
}
}
}
},
"downloads.failed": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Fehlgeschlagen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Failed"
}
}
}
},
"downloads.removeConfirm": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Download entfernen?"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Remove download?"
}
}
}
},
"downloads.allEpisodes": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Alle Folgen laden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Download all episodes"
}
}
}
},
"downloads.season": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Staffel laden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Download season"
}
}
}
},
"downloads.batch.title": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Downloads"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Downloads"
}
}
}
},
"downloads.batch.queued %lld": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "%lld Folgen in die Warteschlange gelegt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Queued %lld episodes."
}
}
}
},
"downloads.batch.unavailable %lld": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "%lld Folgen haben keine abspielbare Datei auf dem Server."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "%lld episodes have no playable file on the server."
}
}
}
},
"downloads.refused.title": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Download nicht möglich"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Can't download"
}
}
}
},
"downloads.refused.exists": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Dieser Titel steht bereits in der Warteschlange."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "This title is already queued."
}
}
}
},
"downloads.refused.limit %@ %@": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Das Speicherlimit ist erreicht: %@ von %@ belegt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Storage limit reached: %@ of %@ used."
}
}
}
},
"downloads.refused.space %@ %@": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Zu wenig Platz auf dem Gerät: %@ nötig, %@ frei."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Not enough space on this device: %@ needed, %@ free."
}
}
}
},
"action.delete": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Löschen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Delete"
}
}
}
}
},
"version": "1.1"