Files
Flaro/Flixy/Views/Player/PlayerModel.swift
T
GuidoandClaude Opus 5 62158303a2 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>
2026-09-11 17:34:57 +02:00

431 lines
16 KiB
Swift

import Foundation
import Observation
import FlixyDownloads
import FlixyPlayback
import FlixyVLC
import PlexKit
/// Führt einen Abspielwunsch aus.
///
/// Holt die Metadaten, lässt den `PlaybackDecider` den Weg bestimmen, baut den
/// passenden Motor und hält dessen Zustand für die Ansicht bereit. Die Ansicht
/// weiß nicht, welcher Motor läuft — bis auf die eine Stelle, an der die
/// Bildfläche gebaut wird.
@MainActor
@Observable
final class PlayerModel {
enum Phase: Equatable {
case preparing
case playing
case failed(message: String)
}
private(set) var phase: Phase = .preparing
private(set) var engine: (any VideoPlayerEngine)?
private(set) var decision: PlaybackDecision?
private(set) var title: String
private(set) var subtitle: String?
private(set) var currentTime: Duration = .zero
private(set) var duration: Duration?
private(set) var isPlaying = false
private(set) var audioTracks: [MediaTrack] = []
private(set) var subtitleTracks: [MediaTrack] = []
private(set) var selectedAudioTrack: MediaTrack?
private(set) var selectedSubtitleTrack: MediaTrack?
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
/// Überspringen-Knopf.
private(set) var activeMarker: PlexMarker?
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
/// 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
private var item: PlexItem?
private var eventTask: Task<Void, Never>?
private var reportTask: Task<Void, Never>?
private var transcodeSession: String?
/// Marken, die bereits automatisch übersprungen wurden. Ohne dieses
/// Gedächtnis würde ein Rücksprung ins Intro es sofort wieder wegspringen
/// lassen — und man käme nie zurück.
private var skippedMarkers: Set<Int> = []
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
self.title = request.title
}
// 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
}
let item: PlexItem
do {
item = try await server.metadata(ratingKey: request.ratingKey)
} catch {
phase = .failed(message: PlexErrorText.describe(error))
return
}
self.item = item
title = item.rowTitle
subtitle = playerSubtitle(for: item)
guard let media = version(of: item), let part = media.parts.first else {
phase = .failed(message: String(localized: "player.noPlayableFile"))
return
}
let decision = PlaybackDecider().decide(media: media, part: part, policy: policy)
self.decision = decision
guard let source = await source(for: decision, item: item, media: media, part: part, server: server) else {
phase = .failed(message: String(localized: "player.noPlayableFile"))
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)
do {
try await engine.load(source)
engine.play()
phase = .playing
if !isOffline { startReporting() }
} catch {
phase = .failed(message: String(localized: "player.loadFailed"))
}
}
/// Baut die Quelle — entweder die Datei selbst oder den HLS-Strom.
private func source(
for decision: PlaybackDecision,
item: PlexItem,
media: PlexMedia,
part: PlexPart,
server: PlexServer
) async -> PlaybackSource? {
switch decision.method {
case .directPlay:
guard let url = server.directPlayURL(part: part) else { return nil }
return PlaybackSource(url: url, origin: .remote, startTime: request.startTime)
case .transcode(let maximumBitrate):
let transcode = TranscodeRequest(
ratingKey: item.ratingKey,
mediaIndex: request.mediaIndex,
maximumBitrate: maximumBitrate,
isLocal: isLocalConnection
)
transcodeSession = transcode.sessionIdentifier
// Vorher fragen, ob der Server überhaupt mitspielt. Sagt er hier
// schon nein, steht der Grund im Klartext da — statt eines
// schwarzen Bildes nach zwanzig Sekunden Warten.
if let decision = try? await server.transcodeDecision(for: transcode),
!decision.isAllowed {
phase = .failed(message: decision.explanation ?? String(localized: "player.transcodeRefused"))
return nil
}
guard let url = server.transcodeURL(for: transcode) else { return nil }
return PlaybackSource(url: url, origin: .remote, startTime: request.startTime)
}
}
// MARK: - Steuerung
func togglePlayPause() {
guard let engine else { return }
if isPlaying { engine.pause() } else { engine.play() }
}
func seek(to time: Duration) {
guard let engine else { return }
Task { await engine.seek(to: time) }
}
/// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe.
func skip(by offset: Duration) {
guard let duration else { return }
seek(to: max(.zero, min(currentTime + offset, duration)))
}
/// Springt ans Ende der laufenden Marke.
func skipActiveMarker() {
guard let marker = activeMarker else { return }
skippedMarkers.insert(marker.id)
seek(to: .milliseconds(marker.endTimeOffset))
}
func selectAudioTrack(_ track: MediaTrack) {
engine?.selectAudioTrack(track)
selectedAudioTrack = track
}
/// `nil` schaltet die Untertitel ab.
func selectSubtitleTrack(_ track: MediaTrack?) {
engine?.selectSubtitleTrack(track)
selectedSubtitleTrack = track
}
func stop() async {
eventTask?.cancel()
reportTask?.cancel()
// Letzte Meldung vor dem Verlassen: erst sie nimmt den Titel auf dem
// Server aus „wird gerade abgespielt" heraus.
await report(state: .stopped)
// Den Transcoder anhalten. Ohne dieses Signal rechnet der Server
// minutenlang weiter, obwohl niemand mehr zusieht.
if let transcodeSession, let server {
await server.stopTranscode(sessionIdentifier: transcodeSession)
}
engine?.teardown()
engine = nil
}
// MARK: - Fortschritt melden
private func startReporting() {
reportTask?.cancel()
reportTask = Task { [weak self] in
while !Task.isCancelled {
// Alle zehn Sekunden — so oft melden es auch die Apps von Plex
// selbst. Häufiger brächte nichts, seltener verlöre beim
// Abwürgen der App zu viel.
try? await Task.sleep(for: .seconds(10))
guard let self, !Task.isCancelled else { return }
await report(state: isPlaying ? .playing : .paused)
}
}
}
private func report(state: PlaybackReportState) async {
guard let server, let item, let duration, currentTime > .zero else { return }
await server.reportProgress(
ratingKey: item.ratingKey,
state: state,
time: currentTime,
duration: duration
)
}
// MARK: - Marken und nächste Folge
private func updateActiveMarker() {
guard let item else { return }
let milliseconds = currentTime.milliseconds
activeMarker = item.markers.first { marker in
guard milliseconds >= marker.startTimeOffset, milliseconds < marker.endTimeOffset else {
return false
}
// Der Abspann zählt nur als Marke, wenn danach noch etwas kommt —
// bei einem Film ist „Abspann überspringen" gleichbedeutend mit
// „Film beenden".
if marker.isCredits && item.kind != .episode { return false }
return true
}
guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return }
let shouldSkip = (marker.isIntro && settings.autoSkipIntro)
|| (marker.isCredits && settings.autoSkipCredits)
if shouldSkip { skipActiveMarker() }
}
private func preloadNextEpisode() {
guard let item, item.kind == .episode, let server else { return }
Task { [weak self] in
let next = try? await server.nextEpisode(after: item)
self?.nextEpisode = next
}
}
/// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den
/// Koordinator weiter.
func nextEpisodeRequest() -> PlaybackRequest? {
guard let nextEpisode else { return nil }
return PlaybackRequest(
ratingKey: nextEpisode.ratingKey,
title: nextEpisode.playbackTitle,
mediaIndex: 0,
startTime: .zero
)
}
// MARK: - Innereien
private func version(of item: PlexItem) -> PlexMedia? {
guard item.media.indices.contains(request.mediaIndex) else { return item.preferredMedia }
return item.media[request.mediaIndex]
}
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
switch decision.engine {
case .vlc: VLCPlayerEngine()
// Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS, und
// genau darin wechselt AVPlayer die Bitrate von selbst.
case .avPlayer, .none: AVPlayerEngine()
}
}
private func observe(_ engine: any VideoPlayerEngine) {
eventTask?.cancel()
eventTask = Task { [weak self] in
for await event in engine.events {
guard let self else { return }
switch event {
case .stateChanged(let state):
isPlaying = state == .playing
if case .failed = state {
phase = .failed(message: String(localized: "player.playbackFailed"))
}
case .timeChanged(let time):
currentTime = time
updateActiveMarker()
case .durationResolved(let value):
duration = value
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
applyPreferredLanguages()
}
}
}
}
/// Wählt die bevorzugten Sprachen, sofern es sie gibt.
///
/// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf
/// eine später gemeldete Spurliste das nicht zurücksetzen.
private func applyPreferredLanguages() {
if selectedAudioTrack == nil, !settings.preferredAudioLanguage.isEmpty,
let match = audioTracks.first(where: { $0.matches(language: settings.preferredAudioLanguage) }) {
selectAudioTrack(match)
}
if selectedSubtitleTrack == nil, !settings.preferredSubtitleLanguage.isEmpty,
let match = subtitleTracks.first(where: { $0.matches(language: settings.preferredSubtitleLanguage) }) {
selectSubtitleTrack(match)
}
}
private func playerSubtitle(for item: PlexItem) -> String? {
guard item.kind == .episode else { return nil }
if let season = item.parentIndex, let episode = item.index {
return "\(String(localized: "episode.short \(season) \(episode)")) · \(item.title)"
}
return item.title
}
}
extension MediaTrack {
/// Ob die Spur zu einem Sprachcode passt.
///
/// Verglichen wird nachsichtig: Plex meldet mal `de`, mal `deu`, mal
/// `ger`, und AVFoundation liefert manchmal `de-DE`. Ein strenger
/// Vergleich fände die Spur in der Hälfte der Fälle nicht.
func matches(language code: String) -> Bool {
guard let languageCode, !code.isEmpty else { return false }
let normalized = languageCode.lowercased().prefix(2)
return normalized == code.lowercased().prefix(2)
}
}