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
+71 -2
View File
@@ -1,5 +1,6 @@
import Foundation
import Observation
import FlixyDownloads
import FlixyPlayback
import FlixyVLC
import PlexKit
@@ -41,9 +42,13 @@ final class PlayerModel {
/// damit sie am Ende ohne Wartezeit bereitsteht.
private(set) var nextEpisode: PlexItem?
private(set) var hasReachedEnd = false
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
private(set) var isOffline = false
private let request: PlaybackRequest
private let server: PlexServer?
private let downloads: DownloadCoordinator
private let policy: PlaybackPolicy
private let settings: PlaybackSettings
private let isLocalConnection: Bool
@@ -60,12 +65,14 @@ final class PlayerModel {
init(
request: PlaybackRequest,
server: PlexServer?,
downloads: DownloadCoordinator,
policy: PlaybackPolicy,
settings: PlaybackSettings,
isLocalConnection: Bool
) {
self.request = request
self.server = server
self.downloads = downloads
self.policy = policy
self.settings = settings
self.isLocalConnection = isLocalConnection
@@ -75,6 +82,14 @@ final class PlayerModel {
// MARK: - Start
func start() async {
// Die lokale Datei hat immer Vorrang. Sie ist schneller, kostet kein
// Datenvolumen und funktioniert auch dann, wenn der Server gerade
// nicht erreichbar ist — der eigentliche Sinn des Herunterladens.
if let localURL = downloads.localURL(forRatingKey: request.ratingKey) {
await startLocal(url: localURL)
return
}
guard let server else {
phase = .failed(message: String(localized: "error.serverUnreachable"))
return
@@ -105,6 +120,59 @@ final class PlayerModel {
return
}
await load(source, decision: decision)
preloadNextEpisode()
}
/// Wiedergabe aus dem Download-Ordner.
///
/// Ohne Server und ohne Transcoding: die Datei liegt so vor, wie sie auf
/// dem Server lag. Welcher Motor sie öffnen kann, steht im Abzug, der beim
/// Herunterladen mitgesichert wurde — offline lässt sich nichts nachfragen.
private func startLocal(url: URL) async {
let offline = downloads.offlineMedia(forRatingKey: request.ratingKey)
let decision = PlaybackDecider().decide(
media: PlexMedia(
id: 0,
container: offline?.container,
videoCodec: offline?.videoCodec,
audioCodec: offline?.audioCodec,
videoResolution: offline?.videoResolution,
bitrate: offline?.bitrate
),
part: PlexPart(
id: 0,
key: url.lastPathComponent,
container: offline?.container,
streams: offline.map(localStreams) ?? []
),
// Lokal gibt es nichts umzurechnen und keine Bandbreitengrenze.
policy: .directPlayOnly
)
self.decision = decision
isOffline = true
await load(
PlaybackSource(url: url, origin: .local, startTime: request.startTime),
decision: decision
)
}
/// Baut künstliche Spuren aus dem Offline-Abzug.
///
/// Der `PlaybackDecider` erwartet Spuren, um überhaupt zu entscheiden —
/// eine leere Liste hieße „noch nicht analysiert" und schickte jede
/// heruntergeladene Datei vorsichtshalber an VLC.
private func localStreams(from offline: OfflineMedia) -> [PlexStream] {
var streams = [PlexStream(id: 1, streamType: 1, codec: offline.videoCodec)]
if let audioCodec = offline.audioCodec {
streams.append(PlexStream(id: 2, streamType: 2, codec: audioCodec, selected: true))
}
return streams
}
private func load(_ source: PlaybackSource, decision: PlaybackDecision) async {
let engine = makeEngine(for: decision)
self.engine = engine
observe(engine)
@@ -113,8 +181,7 @@ final class PlayerModel {
try await engine.load(source)
engine.play()
phase = .playing
startReporting()
preloadNextEpisode()
if !isOffline { startReporting() }
} catch {
phase = .failed(message: String(localized: "player.loadFailed"))
}
@@ -314,6 +381,8 @@ final class PlayerModel {
case .reachedEnd:
isPlaying = false
hasReachedEnd = true
// Grundlage fürs automatische Löschen nach dem Ansehen.
downloads.markWatched(ratingKey: request.ratingKey)
case .tracksResolved(let audio, let subtitle):
audioTracks = audio
subtitleTracks = subtitle
+3
View File
@@ -1,5 +1,6 @@
import SwiftUI
import FlixyDesign
import FlixyDownloads
import FlixyPlayback
import PlexKit
@@ -11,6 +12,7 @@ struct PlayerView: View {
@Environment(PlaybackCoordinator.self) private var playback
@Environment(PlaybackSettings.self) private var settings
@Environment(NetworkConditions.self) private var network
@Environment(DownloadCoordinator.self) private var downloads
@Environment(\.flixyAccent) private var accent
@State private var model: PlayerModel?
@@ -68,6 +70,7 @@ struct PlayerView: View {
let model = PlayerModel(
request: request,
server: session.server,
downloads: downloads,
policy: settings.policy(for: network),
settings: settings,
isLocalConnection: session.server?.connection.local ?? false