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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user