Files
Flaro/Flixy/Views/Player/PlayerModel.swift
T
ScarriffleandClaude Opus 5 4576eac67c Das Bild füllt den Schirm, und die Schrift bleibt darin
Vier Dinge am iPhone-Player, die zusammengehören, weil sie alle davon
handeln, wo der Bildschirm aufhört.

**Einpassen oder füllen.** `videoGravity` stand fest auf `.resizeAspect`,
und zwar in einer Zeile, die nach jedem Neuaufbau erneut läuft — eine
Wahl hätte dort also jeden Qualitätswechsel nicht überlebt. Jetzt steht
sie im Modell, und die Zeile liest sie. Umgelegt wird mit zwei Fingern
oder über einen Knopf; stufenlos wäre falsch, weil alles zwischen den
zwei Zuständen ein Bild ist, das weder ganz zu sehen ist noch den Schirm
füllt. Auf dem Mac erscheint der Knopf nicht: dort steht der
Vollbildknopf darüber, und zwei Knöpfe, die beide „grösser" bedeuten,
sind eine Zumutung.

**Untertitel im Füllmodus.** Dort reicht das Bild über die untere
Bildschirmkante hinaus, und genau dort zeichnet AVFoundation die
Untertitel. Statt sie anzuschneiden, wird die Bildebene um den
Sicherheitsrand eingerückt — rund zwanzig Punkt Bild gegen eine Zeile,
die man lesen kann. Dafür bekommt die Bildfläche echtes Layout: eine
`autoresizingMask` hält Ränder verhältnismässig, und ein fester Einzug
von zwanzig Punkten wäre im Querformat plötzlich vierzig gewesen.

**Die Bedienleiste kannte ihre Ränder nicht mehr.** Der Player bekommt
`ignoresSafeArea()` von der Wurzel und sieht danach überall null —
Schieberegler und Zeitangaben sassen im Home-Indikator, der Zurück-Pfeil
hinter der Dynamic Island, wo ihn niemand drücken konnte. Gemessen wird
jetzt eine Ebene weiter aussen, wo die Ränder noch gelten, und über die
Umgebung nach innen gereicht. Dieselbe Bauart wie der Akzent. Die Zeile
steht ganz am Ende der Kette: stünde sie vor dem Overlay, umschlösse das
Overlay sie, und ausgerechnet der Player ginge leer aus.

**Gedreht wird einmal, gesperrt wird nicht.** Die Maske blieb auf
`.landscape` stehen — wer im Bett auf der Seite lag, konnte nicht mehr
ins Hochformat drehen. Ein Film beginnt quer, weil er quer ist; danach
gehört die Entscheidung dem Nutzer. Dazu ein Drehknopf für den Fall,
dass die Ausrichtungssperre des Systems an ist, und ein Fehlerbehandler
an `requestGeometryUpdate`: eine abgelehnte Drehung ist dann eine
Einstellung und keine Panne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 17:05:44 +02:00

2059 lines
89 KiB
Swift

import AVKit
import Foundation
import MediaKit
import Observation
import FlixyDownloads
import FlixyPlayback
#if canImport(FlixyVLC)
import FlixyVLC
#endif
import PlexKit
/// Führt einen Abspielwunsch aus.
///
/// Holt die Metadaten, lässt den `PlaybackDecider` den Weg bestimmen, baut den
/// passendie Engine und hält dessen Zustand für die Ansicht bereit. Die Ansicht
/// weiß nicht, welche Engine 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
/// Läuft eigentlich, wartet aber auf Daten.
///
/// Ohne diese Unterscheidung stand man vor einem schwarzen Bild ohne
/// jeden Hinweis: der Player galt als „spielt", nur kam noch nichts an.
private(set) var isBuffering = false
private(set) var audioTracks: [MediaTrack] = []
private(set) var subtitleTracks: [MediaTrack] = []
private(set) var selectedAudioTrack: MediaTrack?
/// Die Tonspuren, wie sie **in der Datei** stehen.
///
/// **Nicht dasselbe wie `audioTracks`.** Die kommen vom laufenden Player,
/// und bei einem umgerechneten Stream ist dort genau eine — der Server
/// hat sich längst für eine entschieden. Im Player wäre dann nichts mehr
/// umzustellen. Gewählt werden muss also aus dem, was die Datei hergibt,
/// und die Wahl muss **vor** dem Aushandeln stattfinden.
private(set) var audioStreams: [MediaStream] = []
/// Welche davon gerade klingt.
private(set) var selectedAudioStream: MediaStream?
/// Die Untertitel, wie sie **in der Datei** stehen — aus demselben Grund
/// wie beim Ton: ein umgerechneter Stream bringt genau einen mit, und der
/// heisst dann „CC".
private(set) var subtitleStreams: [MediaStream] = []
/// Welcher davon zu sehen ist. `nil` heisst: keiner.
private(set) var selectedSubtitleStream: MediaStream?
private(set) var selectedSubtitleTrack: MediaTrack?
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
/// Überspringen-Knopf.
private(set) var activeMarker: MediaMarker?
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
/// damit sie am Ende ohne Wartezeit bereitsteht.
private(set) var nextEpisode: MediaItem?
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
/// Ob ein laufender Sender abgespielt wird.
private(set) var isLive = false
/// Die Spulweiten, wie sie eingestellt sind.
///
/// Nach außen sichtbar, weil die Systembedienung — Kontrollzentrum,
/// Sperrbildschirm, Medientasten — dieselben Zahlen anbieten soll wie die
/// Knöpfe im Player. Zwei Weiten, die auseinanderlaufen, sind schlimmer
/// als eine, die nicht jedem gefällt.
var rewindSeconds: Int { settings.rewindSeconds }
var forwardSeconds: Int { settings.forwardSeconds }
/// Die im Player gewählte Stufe. `automatic` folgt den Einstellungen.
private(set) var quality: PlayerQuality = .automatic
/// Ob gerade auf eine andere Stufe umgestellt wird.
private(set) var isChangingQuality = false
/// Wohin nach dem Umstellen zurückgesprungen wird.
///
/// Solange das gesetzt ist, ist das Bild nicht die Stelle, an der man war:
/// ein frisch angeforderter Stream beginnt vorn, puffert und springt erst
/// danach. Sichtbar war in dieser Zeit der **Filmanfang** — und der
/// Eindruck, die Stelle verloren zu haben.
private(set) var resumeTarget: Duration?
/// Was gerade über die Leitung kommt. Beim Öffnen der Wahl abgefragt.
var statistics: PlaybackStatistics? { engine?.statistics }
/// Bis wohin schon geladen ist.
///
/// Für den helleren Balken hinter dem Regler. `nil`, solange nichts
/// bekannt ist — dann wird auch nichts behauptet.
///
/// **Abgelegt, nicht ausgerechnet.** Eine berechnete Eigenschaft wird nur
/// gelesen, wenn ohnehin neu gezeichnet wird, und das geschieht, weil die
/// Abspielstelle weiterläuft. Im **pausierten** Zustand läuft sie nicht —
/// und ausgerechnet da will man zusehen, wie der Vorrat wächst.
private(set) var bufferedUntil: Duration?
/// Was der eigene Vorratsspeicher gerade tut — in einem Satz.
///
/// **Damit man es ablesen kann statt zu raten.** Ob ein Speicher
/// dazwischensteht, ist von aussen nicht zu sehen: läuft er nicht, spielt
/// es trotzdem, nur eben ohne Vorsprung. Genau so sieht aber auch ein
/// Speicher aus, der läuft und nichts bekommt. Ohne diese Zeile sind die
/// beiden Fälle nicht zu unterscheiden.
private(set) var prefetchStatus: String?
/// Die Höhe der Quelldatei — danach richtet sich, welche Stufen überhaupt
/// Sinn ergeben. Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein
/// Bild dazu.
var sourceHeight: Int? { currentMedia?.height }
/// Ob das Bild gezeigt werden soll.
var showsVideo: Bool { quality.showsVideo }
/// Die eingestellte Stufe in zwei bis fünf Zeichen.
///
/// Steht auf dem Knopf im Player. „Automatisch" heißt dabei: was gerade
/// tatsächlich ankommt — bei einem HLS-Stream entscheidet der Player selbst,
/// und ihn danach zu fragen ist ehrlicher, als „Auto" hinzuschreiben und
/// die Antwort schuldig zu bleiben.
var qualityLabel: String {
switch quality {
case .automatic:
if let height = statistics?.height, height > 0 { return "\(height)p" }
return String(localized: "quality.autoShort")
case .original:
if let height = currentMedia?.pixelHeight { return "\(height)p" }
return String(localized: "quality.originalShort")
case .limited(let rung):
return "\(rung.height)p"
case .audioOnly:
return String(localized: "quality.audioShort")
}
}
/// Die Bildebene des Systemplayers.
///
/// Gehört dem Modell, nicht der Ansicht: das Modell überlebt den Wechsel
/// zwischen Kachel und Vollbild, die Ansicht nicht. Läge sie dort, entstünde
/// bei jedem Wechsel eine neue `AVPlayerLayer`, und das Bild setzte neu an.
let surface = PlayerLayerView()
/// Wie das Bild in die Fläche gelegt wird.
///
/// **Im Modell und nicht in der Ansicht**, aus demselben Grund wie die
/// Bildebene selbst: der Wechsel zwischen Kachel und Vollbild und jeder
/// Neuaufbau der Engine gehen an der Ansicht vorbei. Läge die Wahl dort,
/// spränge das Bild bei jedem Qualitätswechsel zurück auf „eingepasst".
var videoFit: VideoFit = .fit {
didSet { applyVideoGravity() }
}
/// Wie viel unten ausgespart bleibt, damit Untertitel sichtbar sind.
///
/// Im Füllmodus reicht das Bild über die untere Bildschirmkante hinaus —
/// und genau dort zeichnet AVFoundation die Untertitel. Statt sie
/// anzuschneiden, wird die **Bildebene** eingerückt: das kostet rund
/// zwanzig Punkt Bild und behält die Schrift. Im eingepassten Modus ist
/// der Wert null, weil dort ohnehin schwarze Balken liegen.
var subtitleBottomInset: CGFloat = 0
/// Wo das Bild aufhört.
public enum VideoFit: Sendable {
/// Ganz sichtbar, mit schwarzen Balken.
case fit
/// Bildfüllend, oben und unten beschnitten.
case fill
}
/// Bild-in-Bild hängt an genau dieser Ebene und wandert deshalb mit.
///
/// `@ObservationIgnored`, weil keine Ansicht darauf zeichnet — und weil
/// `@Observable` einen `lazy`-Delegaten nicht überlebt.
@ObservationIgnored private var pictureInPicture: AVPictureInPictureController?
@ObservationIgnored private var pictureInPictureDelegate: PictureInPictureBridge?
/// Was beim Beenden von Bild-in-Bild wieder ins Vollbild führen soll.
@ObservationIgnored private var restoreFromPictureInPicture: (() -> Void)?
private let request: PlaybackRequest
private let backend: (any MediaBackend)?
/// Der Plex-Server — nur noch für Live-TV.
///
/// Programmführer und Tuner sind das Einzige, was hier noch unmittelbar
/// mit Plex spricht. Ein Tuner ist ein Stück Hardware, und die fünfhundert
/// Zeilen, die es gekostet hat, ihn zu bändigen, gehören in keinen
/// gemeinsamen Vertrag — wohl aber in einen **eigenen**. Seit es den
/// gibt, steht hier kein Plex-Server mehr, sondern wer auch immer
/// Live-TV kann.
private let liveTV: (any LiveTVBackend)?
private let downloads: DownloadCoordinator
private let diagnostics: PlaybackDiagnostics
private let policy: PlaybackPolicy
private let settings: PlaybackSettings
private let languages: LanguagePreferences
private let subtitles: SubtitleAppearance
private let isLocalConnection: Bool
/// Wie die Verbindung in der Diagnose heißt.
///
/// Von außen gereicht: welcher Weg zum Server führt, weiß die Sitzung —
/// und über die Vermittlung von plex.tv ist alles gedrosselt, was die
/// Hälfte aller Ruckler erklärt, bevor man weitersucht.
private let connectionDescription: String?
/// Was zu sagen ist, wenn es keinen Server gibt, mit dem sich abspielen
/// ließe.
///
/// Als Text durchgereicht statt hier entschieden: bei Plex heißt das
/// „nicht erreichbar", bei Jellyfin „kommt als Nächstes", und welcher
/// Server angemeldet ist, weiß die Sitzung, nicht der Player.
private let unavailableMessage: String
private var item: MediaItem?
/// Die gerade gespielte Fassung und Datei.
///
/// Festgehalten, damit ein Qualitätswechsel die Quelle neu bauen kann,
/// ohne die Metadaten erneut zu holen.
private var currentMedia: MediaVersion?
private var currentPart: MediaFile?
/// In welcher Mediathek der Titel liegt — einmal nachgeschlagen.
private var libraryID: String?
/// Wohin zuletzt gesprungen wurde — bis der Player dort ankommt.
///
/// Ohne das gewönne die letzte Meldung von der alten Stelle gegen den
/// gerade ausgelösten Sprung.
private var seekTarget: Duration?
/// Ob schon einmal auf VLC ausgewichen wurde.
private var hasFallenBack = false
/// Ob der Vorratsspeicher für diese Wiedergabe aufgegeben wurde.
///
/// Einmal gesetzt, bleibt er aus: ein zweiter Anlauf mit demselben
/// Aufbau scheiterte am selben Grund.
private var prefetchGaveUp = false
/// Wie oft in dieser Wiedergabe nach oben neu aufgebaut wurde.
private var upgradesUsed = 0
private var lastUpgradeAt: Duration?
/// Die Notbremse, die eine hängengebliebene Abdeckung wegnimmt.
@ObservationIgnored private var resumeGiveUp: Task<Void, Never>?
/// Der Server, von dem der Player holt — und der Vorrat dahinter.
private var streamServer: LocalStreamServer?
private var streamCache: StreamCache?
private var eventTask: Task<Void, Never>?
private var reportTask: Task<Void, Never>?
/// Was der Server für diese Wiedergabe angelegt hat.
///
/// Zum Abräumen und zum Melden: Jellyfin ordnet den Fortschritt über
/// dieselbe Kennung zu, über die Plex den Transcoder anhält.
private var playbackSession: String?
/// Welche Fassung gerade läuft — Jellyfin will sie bei jeder Meldung
/// zurückhaben.
private var playbackVersionID: String?
/// Wann die angezeigte Sendung endet — danach stimmt der Untertitel nicht
/// mehr.
private var programmeEndsAt: Date?
/// Wacht darüber, dass aus „lädt" nicht „lädt für immer" wird.
@ObservationIgnored private var bufferWatchdog: Task<Void, Never>?
/// Wie viel Vorrat bei einem Stream über das Netz angelegt wird.
///
/// **Nur für Filme und Folgen, nicht für Sender und nicht für Dateien.**
/// Ein laufender Sender soll nah am Jetzt bleiben — ein halbminütiger
/// Vorrat hieße dort eine halbe Minute Verzug und ein Umschalten, das
/// sich zäh anfühlt. Eine Datei von der Platte braucht keinen Vorrat.
///
/// **Ein Mindestwunsch, keine Obergrenze.** AVPlayer darf mehr holen; wir
/// sagen ihm nur, dass er sich nicht mit weniger zufriedengeben soll, als
/// er von sich aus täte.
///
/// **Zwei Zahlen, weil es zwei verschiedene Lagen sind.**
///
/// Rechnet der Server um, existieren die Schnipsel noch gar nicht — ffmpeg
/// erzeugt sie gerade. Weiter voraus als bis dorthin kommt niemand, und
/// ein grösserer Wunsch liefe ins Leere. Eine Minute ist dort reichlich.
///
/// Wird die Datei dagegen unverändert durchgereicht, liegt sie fertig auf
/// der Platte und kommt über gewöhnliche Bereichsanfragen — dieselbe Lage
/// wie bei einem Video im Netz, das man beliebig weit vorausladen kann.
/// Dort bei einer Minute aufzuhören war schlicht zu schüchtern.
///
/// Der Preis ist Datenvolumen, das beim Spulen verfällt. Beim
/// Durchreichen ist das verschmerzbar: die Datei wird ohnehin als Ganzes
/// geholt, wenn man sie zu Ende sieht.
private static func forwardBuffer(for method: PlaybackDecision.Method) -> Duration {
switch method {
// Eine fortlaufende Datei über Bereichsanfragen — da lässt sich
// vorausladen, so weit man will.
case .directPlay: .seconds(600)
// Umgepackt kommt als Abspielliste, genau wie umgerechnet. Der
// Unterschied liegt darin, dass kein Bild neu gerechnet wird, nicht
// darin, wie weit sich vorausladen lässt.
case .remux, .transcode: .seconds(60)
}
}
/// Wie oft der Zustand des Streams geprüft wird.
///
/// Einmal je Sekunde: fein genug, dass ein schrumpfender Puffer auffällt,
/// bevor er leer ist, und grob genug, dass es nichts kostet.
private static let healthInterval: Duration = .seconds(1)
/// Wie lange nach einem Hinaufschalten Ruhe herrscht.
///
/// Hinauf wird nur zögerlich gegangen: ein falscher Schritt nach unten
/// kostet Schärfe, ein falscher nach oben einen Rückler.
private static let stepUpCooldown: Duration = .seconds(60)
/// Der Zustand des Streams, fortlaufend beurteilt.
@ObservationIgnored private var health = StreamHealth()
@ObservationIgnored private var healthTask: Task<Void, Never>?
/// Wann zuletzt eine Stufe höher gegangen wurde.
@ObservationIgnored private var lastStepUp: Duration?
@ObservationIgnored private var hasNotedMissingLadder = false
/// Wann zuletzt gesprungen wurde — ein Sprung leert den Puffer immer.
@ObservationIgnored private var lastSeekAt: Duration?
@ObservationIgnored private var lastDowngradeAt: Duration?
@ObservationIgnored private var downgradesUsed = 0
/// Ob die Automatik aufgegeben hat.
///
/// Dann wird es gesagt statt lautlos weiter abgesenkt: still bis zur
/// untersten Sprosse zu gehen ist schlimmer, als einmal zuzugeben, dass
/// die Leitung es nicht trägt.
private(set) var hasGivenUp = false
/// Wie oft in **dieser** Wiedergabe der teure Weg gegangen werden darf.
///
/// Bei Live-TV nur einmal. Jeder Neuaufbau stimmt dort den Tuner neu ab,
/// und davon gibt es vier Stück — ein Stottern ist der kleinere Schaden
/// als ein Sender, der gar nicht mehr angeht.
private var downgradeBudget: Int { isLive ? 1 : 2 }
/// **Womit „Auto" anfängt.**
///
/// Hier lag der Denkfehler: `automatic` hiess wörtlich „nimm, was in den
/// Einstellungen steht", und stand dort „Original", bat die App über eine
/// Zugverbindung um eine 4K-Datei. Das ist keine Automatik, das ist eine
/// Durchreiche.
///
/// Jetzt ist die Einstellung die **Obergrenze**, nicht das Ziel: „bis
/// hierhin, wenn die Leitung es trägt". Angefangen wird mit einem Wert,
/// der fast überall anläuft; von dort geht es hinauf, solange es gut
/// geht, und hinunter, sobald es hakt.
///
/// **Im eigenen Netz keine Grenze.** Dort trägt die Leitung das Original,
/// und ein Deckel machte das Bild ohne Grund schlechter. Die
/// Unterscheidung ist nicht geraten — sie steht in `isLocalConnection`.
private var startingCeiling: Int? {
guard !isLocalConnection else { return nil }
// **Drei Megabit, nicht acht.**
//
// Nicht wegen der Leitung — acht Megabit trägt fast jede. Wegen des
// **Servers**: er muss den Film erst rechnen, und wie lange das
// dauert, hängt vor allem an der Auflösung. Acht Megabit bedeuteten
// 1080p von der ersten Sekunde an, und darauf wartet man eine halbe
// Minute vor einem schwarzen Bild.
//
// Drei Megabit bedeuten 720p. Das läuft in wenigen Sekunden an, sieht
// einen Augenblick lang weicher aus — und von dort geht es hinauf,
// sobald sich zeigt, dass es trägt. Ein Film, der sofort anfängt und
// nach einer Minute schärfer wird, ist besser als einer, auf den man
// erst wartet.
return 3_000
}
/// Die Auflösung, die zur aktuellen Stufe gehört.
///
/// **Bitrate allein genügt nicht.** 4K mit zwei Megabit sieht schlechter
/// aus als 720p mit zwei Megabit — dieselbe Datenmenge, auf viermal so
/// viele Bildpunkte verteilt. Wer die Bitrate senkt, muss die Auflösung
/// mitnehmen, sonst spart er Daten und verschenkt das Bild.
private var automaticResolution: String? {
guard quality == .automatic, let ceiling = automaticCeiling else { return nil }
let sprossen = PlayerQuality.available(forSourceHeight: sourceHeight)
.compactMap { option -> PlayerQuality.Rung? in
guard case .limited(let rung) = option else { return nil }
return rung
}
// Die Sprosse, die zu dieser Bitrate gehört — oder die nächstgrössere,
// falls die Zahl von woanders kommt.
let passend = sprossen.first { $0.bitrate >= ceiling } ?? sprossen.last
guard let passend else { return nil }
return "\(passend.width)x\(passend.height)"
}
/// Die Richtlinie, wie sie **jetzt** gilt.
///
/// Die eingestellte Obergrenze, gedeckelt von dem, was die Automatik
/// unterwegs gelernt hat. Getrennt von `quality`, damit eine schlechte
/// Zugfahrt nicht als gemerkte Stufe das Wohnzimmer verschlechtert.
private var effectivePolicy: PlaybackPolicy {
let base = quality.policy(basedOn: policy)
guard quality == .automatic else { return base }
// Die Einstellung ist die Decke, die Automatik der Boden darunter.
guard let ceiling = automaticCeiling ?? startingCeiling else { return base }
return PlaybackPolicy(
allowsTranscoding: true,
// Ohne Ersatz-Engine: sonst spielte VLC die Datei unverändert ab,
// und die abgesenkte Grenze bliebe wirkungslos.
allowsFallbackEngine: false,
maximumBitrate: min(ceiling, base.maximumBitrate ?? ceiling),
forcesTranscoding: true
)
}
/// Die Obergrenze, die die Automatik gerade setzt — in Kilobit.
///
/// **Getrennt von `quality`, und das ist wesentlich.** Würde die
/// Automatik `select(quality:)` rufen, schriebe sie `rememberedQuality`
/// fort und setzte eine manuelle Stufe — womit sie sich selbst
/// abschaltete und eine schlechte Zugfahrt dauerhaft das Wohnzimmer
/// verschlechterte. `quality` bleibt deshalb `.automatic`; nur dieser
/// Wert wandert.
private(set) var automaticCeiling: Int?
/// Das Buch über die Rückler dieser Wiedergabe.
@ObservationIgnored private var stallLedger = StallLedger()
/// Seit wann tatsächlich gespielt wird — der Nullpunkt für das Buch.
///
/// Erst ab dem ersten Bild, nicht ab dem Antippen: das anfängliche
/// Puffern ist der Start und kein Rückler.
@ObservationIgnored private var playbackStartedAt: ContinuousClock.Instant?
/// Wann das laufende Stocken begann, gemessen ab `playbackStartedAt`.
@ObservationIgnored private var stallBegan: Duration?
/// Ob die Leitung diese Wiedergabe gerade nicht trägt.
///
/// Nicht „es hat einmal gehakt" — das tut jede Verbindung. Sondern: in
/// den letzten drei Minuten ging ein Viertel der Zeit fürs Warten drauf,
/// oder ein einzelner Rückler dauerte länger als fünfzehn Sekunden.
///
/// Das ist der Befund, aus dem später das Herunterschalten folgt — und
/// schon jetzt der Beleg, auf den sich ein Hinweis in den Einstellungen
/// stützen darf, statt auf eine Vermutung.
private(set) var isConnectionStruggling = false
/// 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,
backend: (any MediaBackend)?,
liveTV: (any LiveTVBackend)?,
downloads: DownloadCoordinator,
diagnostics: PlaybackDiagnostics,
policy: PlaybackPolicy,
settings: PlaybackSettings,
languages: LanguagePreferences,
subtitles: SubtitleAppearance,
isLocalConnection: Bool,
connectionDescription: String?,
unavailableMessage: String
) {
self.request = request
self.backend = backend
self.liveTV = liveTV
self.downloads = downloads
self.diagnostics = diagnostics
self.policy = policy
self.settings = settings
self.languages = languages
self.subtitles = subtitles
self.isLocalConnection = isLocalConnection
self.connectionDescription = connectionDescription
self.unavailableMessage = unavailableMessage
self.title = request.title
}
// MARK: - Start
func start() async {
diagnostics.begin(title: request.title, connection: connectionDescription)
// Beim System anmelden, bevor das erste Bild kommt: ab hier gehören
// die Medientasten diesem Titel und nicht mehr dem, was vorher lief.
NowPlaying.shared.takeOver(self)
// 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
}
if let stream = request.streamURL {
await startLiveStream(url: stream)
return
}
guard let backend else {
phase = .failed(message: unavailableMessage)
return
}
diagnostics.note("Metadaten anfragen")
let item: MediaItem
do {
item = try await backend.item(id: request.ratingKey)
diagnostics.note("Metadaten da")
} catch {
diagnostics.finish(outcome: "Metadaten fehlgeschlagen: \(ErrorText.describe(error))")
phase = .failed(message: ErrorText.describe(error))
return
}
self.item = item
title = item.rowTitle
subtitle = playerSubtitle(for: item)
guard let media = version(of: item), let part = media.files.first else {
phase = .failed(message: String(localized: "player.noPlayableFile"))
return
}
currentMedia = media
currentPart = part
await chooseAudioStream(in: part, of: item, backend: backend)
// Die zuletzt gewählte Stufe, falls sie gemerkt werden soll. **Vor**
// der Entscheidung, nicht danach: hinterher umzustellen hieße, den
// Stream zweimal aufzubauen und den Anfang zweimal zu puffern.
if let stored = settings.rememberedQuality,
let restored = PlayerQuality.restored(from: stored, forSourceHeight: media.height) {
quality = restored
}
let decision = PlaybackDecider().decide(
profile: media.profile(file: part),
policy: quality.policy(basedOn: policy)
)
self.decision = decision
guard let source = await source(for: decision, item: item, media: media, part: part, backend: backend) else {
phase = .failed(message: String(localized: "player.noPlayableFile"))
return
}
await load(source, decision: decision)
preloadNextEpisode()
}
/// Stellt auf eine andere Qualitätsstufe um.
///
/// Bedeutet zwangsläufig einen Neuaufbau: die Stufe steckt in der Adresse
/// des Streams, nicht in einer Einstellung, die sich unterwegs drehen ließe.
/// Die Stelle wird gemerkt und danach wieder angefahren — sonst begänne der
/// Film bei jedem Wechsel von vorn.
func select(quality newQuality: PlayerQuality) async {
// Nach einem Fehlschlag darf dieselbe Stufe noch einmal versucht
// werden. Sonst wäre man eingesperrt: die gewählte Stufe steht schon
// fest, ein erneutes Antippen gilt als „keine Änderung", und es
// passiert nichts mehr.
let isRetry = { if case .failed = phase { return true } else { return false } }()
guard newQuality != quality || isRetry,
!isOffline,
let backend, let item, let media = currentMedia, let part = currentPart
else { return }
quality = newQuality
// Von Hand gewählt: was die Automatik gelernt hatte, gilt nicht mehr.
automaticCeiling = nil
hasGivenUp = false
// Merken, wenn es gewünscht ist — sonst gilt die Wahl nur für diesen
// einen Film.
if settings.remembersQuality { settings.rememberedQuality = newQuality.storageValue }
await restart()
}
/// Baut den Stream mit der **aktuell geltenden** Richtlinie neu auf.
///
/// Geteilt zwischen der Wahl von Hand und der Sicherheitsleine — der
/// Ablauf ist derselbe, nur der Anlass nicht. Was sich unterscheidet,
/// steht in `effectivePolicy`, nicht hier: die Wahl setzt `quality`, die
/// Automatik setzt `automaticCeiling`, und beides fließt dort zusammen.
private func restart(forcing erzwungen: PlaybackDecision? = nil) async {
guard !isOffline,
let backend, let item, let media = currentMedia, let part = currentPart
else { return }
isChangingQuality = true
defer { isChangingQuality = false }
let resumeAt = currentTime
// Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet
// der Server zwei Streams gleichzeitig für einen Zuschauer.
if let playbackSession {
await backend.endPlayback(sessionID: playbackSession)
self.playbackSession = nil
}
eventTask?.cancel()
engine?.teardown()
engine = nil
// **Die Marke erst jetzt — nicht vorher.**
//
// Hier lag der Sprung an den Filmanfang. Die Zeile stand ganz oben,
// vor dem Abbestellen; dazwischen liegt aber ein echter Netzaufruf
// mit fünf Sekunden Zeitgrenze. In diesem Fenster meldet der **alte**
// Player viermal je Sekunde weiter seine Stelle — und die ist die
// gemerkte. Die Prüfung „sind wir angekommen" traf damit sofort zu
// und löschte die Marke, bevor der neue Stream überhaupt bestellt
// war. Danach setzte jede Meldung des anlaufenden Streams — null,
// eine halbe, eine Sekunde — ungebremst die Stelle, und der nächste
// Neuaufbau merkte sich den Filmanfang.
//
// Nach `eventTask?.cancel()` kommt keine Meldung mehr durch, und die
// Marke gilt für den neuen Stream, für den sie gedacht ist.
resumeTarget = resumeAt
// **Die Spuren des alten Streams gelten nicht mehr.**
//
// Eine frische Engine hat nichts ausgewählt. Blieben die Merker
// stehen, hielten sich beide Wähler für erledigt — und auf dem neuen
// Stream würde nie etwas eingeschaltet. Genau deshalb erschien nach
// dem ersten Untertitel nie wieder einer.
//
// `selectedSubtitleStream` bleibt: das ist die **Bestellung**, und
// die soll den Neuaufbau gerade überleben.
selectedSubtitleTrack = nil
selectedAudioTrack = nil
phase = .preparing
// Eine erzwungene Entscheidung steht über der Rechnung: sie kommt
// daher, dass der gerechnete Weg gerade **nachweislich** nicht
// funktioniert hat. Ihn noch einmal auszurechnen ergäbe denselben
// Weg und denselben Fehler.
let newDecision = erzwungen ?? PlaybackDecider().decide(
profile: media.profile(file: part),
policy: effectivePolicy
)
decision = newDecision
guard let source = await source(
for: newDecision,
item: item,
media: media,
part: part,
backend: backend,
startingAt: resumeAt
) else {
// Nur dann eine allgemeine Meldung, wenn `source` keine eigene
// hinterlassen hat. Vorher stand hier immer „keine abspielbare
// Datei", auch wenn der Server im Klartext gesagt hatte, warum er
// ablehnt — und damit war der eine brauchbare Hinweis weg.
if case .failed = phase {} else {
phase = .failed(message: String(localized: "player.noPlayableFile"))
}
return
}
await load(source, decision: newDecision)
// Notbremse: springt der Player nie an die Stelle, bliebe sonst für
// immer eine Abdeckung über dem Bild.
//
// Festgehalten und beim nächsten Neuaufbau abbestellt — sonst räumte
// der Zeitgeber eines alten Versuchs die Marke eines neuen ab.
resumeGiveUp?.cancel()
resumeGiveUp = Task { [weak self] in
try? await Task.sleep(for: .seconds(30))
guard !Task.isCancelled else { return }
self?.resumeTarget = nil
}
}
/// Wiedergabe aus dem Download-Ordner.
///
/// Ohne Server und ohne Transcoding: die Datei liegt so vor, wie sie auf
/// dem Server lag. Welcher Engine sie öffnen kann, steht im Abzug, der beim
/// Herunterladen mitgesichert wurde — offline lässt sich nichts nachfragen.
/// Live-TV: die Adresse steht schon fest.
///
/// Ohne Metadatenabruf, weil es nichts abzurufen gibt — ein laufender
/// Sender steht in keiner Mediathek. Und ohne Fortschrittsmeldung: es
/// gibt keine Stelle, an der man ihn später fortsetzen könnte.
private func startLiveStream(url: URL) async {
diagnostics.note("Live-Stream öffnen")
isLive = true
title = request.title
// Was gerade läuft, kommt mit dem Wunsch — nachzuschlagen ist es
// nicht, ein Sender steht in keiner Mediathek.
subtitle = request.subtitle
programmeEndsAt = request.subtitleEndsAt
// Die Sitzungskennung aus der Adresse übernehmen. Ohne sie bleibt der
// Tuner nach dem Schließen belegt — und beim nächsten Sender hat der
// Server keinen freien mehr.
playbackSession = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "session" }?.value
// Live kommt immer umgerechnet: der Tuner liefert einen
// Transportstrom, den kein iPhone direkt abspielt.
let decision = PlaybackDecision(method: .transcode(maximumBitrate: nil), rationale: [])
self.decision = decision
await load(PlaybackSource(url: url, origin: .remote), decision: decision)
}
private func startLocal(url: URL) async {
let offline = downloads.offlineMedia(forRatingKey: request.ratingKey)
let decision = PlaybackDecider().decide(
// Früher standen hier **erfundene** Plex-Modelle: eine Media mit
// Kennung 0, ein Part mit dem Dateinamen als Schlüssel und
// zusammengebaute Spuren — alles nur, damit die Signatur passte,
// für eine Datei, die längst auf dem Gerät lag. Genau daran war zu
// sehen, dass die Entscheidung nie etwas mit Plex zu tun hatte.
profile: MediaProfile(
container: offline?.container,
videoCodec: offline?.videoCodec,
audioCodec: offline?.audioCodec,
bitrate: offline?.bitrate,
// Was heruntergeladen wurde, ist untersucht. Ohne diese Zusage
// ginge jede Offline-Datei vorsichtshalber an VLC.
hasKnownStreams: offline?.videoCodec != nil
),
// 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
)
}
private func load(_ source: PlaybackSource, decision: PlaybackDecision) async {
diagnostics.record(decision: decision, source: source.url)
let engine = makeEngine(for: decision)
self.engine = engine
attachSurface(to: engine)
observe(engine)
// **Vor dem Öffnen.** Ein frisch angelegter Gegenstand ist
// ungestaltet, und der erste Untertitel käme sonst in der Vorgabe.
engine.applySubtitleStyle(subtitles.style)
do {
diagnostics.note("Engine öffnet Quelle")
try await engine.load(source)
engine.play()
// Erst nach dem Start: vorher hat die Engine noch keinen Gegenstand,
// dessen Geschwindigkeit sich setzen ließe.
if settings.defaultRate != 1 { engine.setRate(settings.defaultRate) }
phase = .playing
diagnostics.note("Engine gestartet")
// Weder offline noch live gibt es einen Fortschritt zu melden.
if !isOffline, !isLive { startReporting() }
// Den Zustand des Streams dagegen auch bei Live beobachten: dort
// hakt es am ehesten, und der billige Hebel kostet nichts.
startHealthWatch()
} catch {
diagnostics.finish(outcome: "Öffnen fehlgeschlagen: \(error)")
phase = .failed(message: String(localized: "player.loadFailed"))
}
}
/// Lässt den Server die Wiedergabe vorbereiten.
///
/// Was hier steht, ist für jeden Server dasselbe: die Entscheidung ist
/// gefallen, der Server soll sie ausführen. Wie er das tut — Plex' Universal
/// Transcoder, Jellyfins PlaybackInfo — steht in der jeweiligen Hülle.
private func source(
for decision: PlaybackDecision,
item: MediaItem,
media: MediaVersion,
part: MediaFile,
backend: any MediaBackend,
startingAt: Duration? = nil
) async -> PlaybackSource? {
let start = startingAt ?? request.startTime
let transcode: TranscodeSettings? = switch decision.method {
case .directPlay: nil
// Umpacken heisst: keine Grenze, keine Auflösung, keine Leiter. Die
// Ströme sollen unverändert durchgehen — jede Angabe hier wäre eine
// Aufforderung, doch zu rechnen.
case .remux:
TranscodeSettings(maximumBitrate: nil, resolution: nil, prefersRemux: true)
case .transcode(let maximumBitrate):
TranscodeSettings(
maximumBitrate: maximumBitrate,
// Bei „Auto" die Auflösung zur gewählten Stufe, sonst die der
// ausdrücklich gewählten.
resolution: quality.resolution ?? automaticResolution,
// **Nur bei „automatisch".** Eine ausdrücklich gewählte Stufe
// ist eine Ansage, keine Obergrenze: wer „720p · 3 Mbps"
// antippt, bekommt das und nicht „höchstens 3, meistens
// weniger" unter unverändertem Etikett.
prefersAdaptive: quality == .automatic
)
}
do {
let target = try await backend.preparePlayback(PlaybackIntent(
itemID: item.id,
versionIndex: request.mediaIndex,
file: part,
transcode: transcode,
startTime: start,
isLocalConnection: isLocalConnection,
// Ohne diese Angabe wählt der Server, und bei einem
// umgerechneten Stream kommt danach nur noch **eine** Spur an.
audioStreamIndex: selectedAudioStream?.index,
// `-1` statt nichts: ohne Angabe sucht der Server selbst einen
// aus, und dann steht plötzlich ein Untertitel im Bild, den
// niemand bestellt hat.
subtitleStreamIndex: selectedSubtitleStream?.index ?? -1
))
diagnostics.note(
"Bestellt — Ton \(selectedAudioStream?.index.map(String.init) ?? "Server")"
+ ", Untertitel \(selectedSubtitleStream?.index.map(String.init) ?? "keine")"
)
playbackSession = target.sessionID
playbackVersionID = target.versionID
// **Der Vorrat läuft voraus, nicht der Player.**
//
// AVPlayer entscheidet selbst, wie weit er puffert, und bei einer
// Abspielliste lässt Apple uns nicht dazwischen. Also stehen wir
// davor: ein winziger Server auf diesem Gerät, der die Schnipsel
// zügiger holt, als abgespielt wird. Der Player fragt dann uns,
// und ob die Leitung gerade steht, merkt er nicht.
//
// **Und wenn es nicht hochkommt, spielt es trotzdem.** Ein
// fehlender Vorrat ist ein Verlust an Bequemlichkeit; keine
// Wiedergabe ist ein Verlust an allem.
let lokal = await startPrefetching(from: target.url)
let adresse = lokal ?? target.url
return PlaybackSource(
url: adresse,
origin: .remote,
startTime: start,
// **Mit eigenem Vorrat ein kleiner Puffer.**
//
// Er bestimmt, wie viel AVPlayer beisammenhaben will, bevor
// er nach einem Sprung weiterspielt. Sechzig Sekunden waren
// richtig, solange er selbst vorladen sollte; mit einem
// Speicher davor sind sie das Gegenteil: springt man mitten
// in den Vorrat, liegt dahinter vielleicht noch ein halbe
// Minute — zu wenig für seine sechzig, und er wartet auf den
// Server, obwohl alles da ist.
//
// Der Abstand liegt jetzt bei uns. Seiner darf so klein sein,
// dass er sofort loslegt.
forwardBuffer: lokal == nil
? Self.forwardBuffer(for: decision.method)
: .seconds(8)
)
} catch {
// Die Worte des Servers stehen auf dem Bildschirm, nicht „keine
// abspielbare Datei". Sagt er, warum er ablehnt, ist das die einzige
// brauchbare Spur — und genau sie ging vorher verloren.
phase = .failed(message: ErrorText.describe(error))
return nil
}
}
// MARK: - Steuerung
// MARK: - Die Qualität selbst nachregeln
/// Beobachtet den Stream und passt die Obergrenze an.
///
/// Läuft nur bei „automatisch". Eine ausdrücklich gewählte Stufe ist eine
/// Ansage und wird nicht überstimmt — `automatic` ist die Erlaubnis
/// einzugreifen, alles andere nicht.
private func startHealthWatch() {
healthTask?.cancel()
guard !isOffline else { return }
healthTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: Self.healthInterval)
guard !Task.isCancelled, let self else { return }
// **Läuft immer**, auch bei einer von Hand gewählten Stufe und
// im pausierten Zustand: der Vorrat wächst weiter, und der
// Balken soll das zeigen. Vorher hing die Schleife ganz an
// „automatisch" — wer eine Stufe gewählt hatte, sah nie, wie
// weit geladen ist.
self.refreshBuffered()
// **Noch einmal nachfassen.** Bei einer Abspielliste lädt
// AVFoundation die Nebenspuren nachträglich; meldet sie beim
// ersten Mal noch keine, ginge der bestellte Untertitel sonst
// verloren. Der Aufruf prüft selbst, ob es etwas zu tun gibt.
self.showOrderedSubtitle()
// Geregelt wird dagegen nur, wo es etwas zu regeln gibt.
guard self.quality == .automatic else { continue }
self.sampleStream()
}
}
}
/// Stellt den Vorratsspeicher vor den Player.
///
/// Nur bei Abspiellisten: eine durchgereichte Datei kommt über
/// Bereichsanfragen, und dort puffert AVPlayer von sich aus weit — dort
/// wäre ein Server davor nur eine zusätzliche Fehlerquelle.
private func startPrefetching(from url: URL) async -> URL? {
guard !prefetchGaveUp, url.pathExtension.lowercased() == "m3u8" else { return nil }
await streamServer?.stop()
let speicher = StreamCache()
let server = LocalStreamServer(cache: speicher)
do {
let lokal = try await server.start(upstream: url)
streamServer = server
streamCache = speicher
prefetchStatus = String(localized: "quality.prefetch.starting")
diagnostics.note("Vorrat läuft voraus")
return lokal
} catch {
streamCache = nil
prefetchStatus = String(localized: "quality.prefetch.off")
diagnostics.note("Vorrat nicht gestartet: \(error.localizedDescription)")
return nil
}
}
private func refreshBuffered() {
Task { [weak self] in
guard let self else { return }
// **Der eigene Vorrat zählt, nicht der des Players.** Steht ein
// Speicher davor, ist dessen Vorsprung die Zahl, die stimmt: der
// Player hält davon nur einen Bruchteil, der Rest liegt schon auf
// der Platte. Ohne Speicher bleibt die Auskunft des Players.
let vorrat: Duration?
if let cache = self.streamCache {
let eigener = await cache.bufferedAhead
vorrat = eigener
self.prefetchStatus = String(
localized: "quality.prefetch.ahead \(Int(eigener.seconds))"
)
} else {
vorrat = self.engine?.statistics?.bufferedAhead
}
guard let vorrat else {
self.bufferedUntil = nil
return
}
let ende = self.currentTime + vorrat
self.bufferedUntil = self.duration.map { min(ende, $0) } ?? ende
}
}
/// Eine Probe nehmen und daraus handeln.
private func sampleStream() {
guard let engine, let startedAt = playbackStartedAt else { return }
guard let statistics = engine.statistics else { return }
let now = ContinuousClock.now - startedAt
let urteil = health.record(StreamSample(
at: now,
bufferedAhead: statistics.bufferedAhead,
isLikelyToKeepUp: statistics.isLikelyToKeepUp,
stallCount: statistics.stallCount,
indicatedBitrate: statistics.indicatedBitrate
))
// Erst der billige Hebel — er kostet kein schwarzes Bild.
if statistics.hasLadder {
adjustCeiling(for: urteil, statistics: statistics, at: now)
} else {
noteMissingLadder(urteil)
}
// Und dann die beiden teuren Wege: hinunter, wenn es hakt —
// und hinauf, wenn es trägt und die Leiter ausgereizt ist.
considerTheHardWay(urteil, statistics: statistics, at: now)
considerGoingHigher(urteil, statistics: statistics, at: now)
}
// MARK: - Die Sicherheitsleine
/// Prüft, ob der Stream neu aufgebaut werden muss — und tut es.
private func considerTheHardWay(
_ condition: StreamCondition,
statistics: PlaybackStatistics,
at now: Duration
) {
guard !hasGivenUp, !isChangingQuality, resumeTarget == nil else { return }
let unterste = statistics.variantBitrates.first
let facts = DowngradeRule.Facts(
isAutomatic: quality == .automatic,
hasLadder: statistics.hasLadder,
isAtLowestRung: unterste.map { (automaticCeiling ?? .max) <= $0 } ?? false,
condition: condition,
stallsInWindow: stallLedger.count(at: now),
longestStallInWindow: stallLedger.longest(at: now),
playedFor: now,
sinceLastSeek: lastSeekAt.map { now - $0 },
sinceLastDowngrade: lastDowngradeAt.map { now - $0 },
used: downgradesUsed,
budget: downgradeBudget
)
switch DowngradeRule.verdict(facts) {
case .downgrade:
Task { await downgradeTheHardWay(at: now) }
case .budgetSpent:
// **Hier hört es auf und wird gesagt.** Lautlos die ganze Leiter
// hinunterzugehen ist schlimmer, als einmal zuzugeben, dass die
// Leitung es gerade nicht trägt.
hasGivenUp = true
diagnostics.note("Automatik aufgegeben — die Leitung trägt es nicht")
case .notAutomatic, .cheaperPathRemains, .notBadEnough, .tooEarly, .cooling:
break
}
}
/// Prüft, ob sich ein Neuaufbau **nach oben** lohnt — und tut es.
///
/// **Warum es den überhaupt braucht.** Damit ein Film zügig anläuft,
/// beginnt er mit einer niedrigen Stufe. Der Server baut seine Leiter
/// aber aus genau dieser Bestellung: wer mit drei Megabit anfängt,
/// bekommt eine Leiter, deren oberste Sprosse drei Megabit ist. Der
/// billige Hebel bewegt sich **innerhalb** dieser Leiter und käme nie
/// darüber hinaus — aus dem schnellen Start würde ein Film, der den
/// ganzen Abend in 720p bleibt.
///
/// Höher geht es nur mit einem Neuaufbau. Die Bedingungen dafür stehen
/// in `UpgradeRule`, nicht hier.
private func considerGoingHigher(
_ condition: StreamCondition,
statistics: PlaybackStatistics,
at now: Duration
) {
guard !hasGivenUp, !isChangingQuality, resumeTarget == nil else { return }
let decke = quality.policy(basedOn: policy).maximumBitrate
let aktuell = automaticCeiling ?? startingCeiling
let facts = UpgradeRule.Facts(
isAutomatic: quality == .automatic,
condition: condition,
// Noch eine Sprosse in der **gelieferten** Leiter? Dann ist der
// billige Weg der richtige.
hasHigherRung: statistics.variantBitrates.contains { $0 > (aktuell ?? .max) },
ceilingAllowsMore: aktuell.map { jetzt in decke.map { jetzt < $0 } ?? true } ?? false,
bufferedAhead: statistics.bufferedAhead,
playedFor: now,
sinceLastStall: stallLedger.lastStall(at: now).map { now - $0 },
sinceLastSeek: lastSeekAt.map { now - $0 },
sinceLastUpgrade: lastUpgradeAt.map { now - $0 },
used: upgradesUsed,
budget: 2
)
guard UpgradeRule.verdict(facts) == .rebuildHigher else { return }
Task { await upgradeTheHardWay(at: now) }
}
/// Baut den Stream mit **grösserer** Bitrate neu auf.
private func upgradeTheHardWay(at now: Duration) async {
guard let ziel = nextHigherRung() else { return }
automaticCeiling = ziel
upgradesUsed += 1
lastUpgradeAt = now
diagnostics.note("Neuaufbau mit bis zu \(ziel) kbit — die Leitung trägt mehr")
// Bei Live-TV nicht: dort kostet jeder Neuaufbau den Tuner, und ein
// schärferes Bild ist diesen Einsatz nicht wert.
guard !isLive else { return }
await restart()
}
/// Die nächste Sprosse **oberhalb** der aktuellen — aus unserer Leiter,
/// gedeckelt von dem, was eingestellt ist.
private func nextHigherRung() -> Int? {
let aktuell = automaticCeiling ?? startingCeiling ?? 0
let decke = quality.policy(basedOn: policy).maximumBitrate
let sprossen = PlayerQuality.available(forSourceHeight: sourceHeight)
.compactMap { option -> Int? in
guard case .limited(let rung) = option else { return nil }
return rung.bitrate
}
.sorted()
// Die nächste darüber, die die Einstellung noch zulässt. Gibt es
// keine mehr, ist die Decke erreicht — dann bleibt es dabei.
return sprossen.first { sprosse in
guard sprosse > aktuell else { return false }
guard let decke else { return true }
return sprosse <= decke
}
}
/// Baut den Stream mit kleinerer Bitrate neu auf.
private func downgradeTheHardWay(at now: Duration) async {
guard let ziel = nextLowerRung() else {
hasGivenUp = true
diagnostics.note("Keine kleinere Stufe mehr übrig")
return
}
automaticCeiling = ziel
downgradesUsed += 1
lastDowngradeAt = now
diagnostics.note("Neuaufbau mit höchstens \(ziel) kbit — es hakte zu oft")
if isLive {
await retuneLive(to: ziel)
} else {
await restart()
}
}
/// Ein zweiter Versuch mit kleinerer Bitrate, bevor aufgegeben wird.
///
/// Gibt `true` zurück, wenn es einen gab. Zählt gegen dasselbe Budget wie
/// die Sicherheitsleine — sonst könnte eine tote Leitung die App endlos
/// im Kreis schicken.
private func retrySmaller() async -> Bool {
guard quality == .automatic,
downgradesUsed < downgradeBudget,
let ziel = nextLowerRung()
else { return false }
automaticCeiling = ziel
downgradesUsed += 1
diagnostics.note("Nichts kam an — neuer Versuch mit höchstens \(ziel) kbit")
if isLive {
await retuneLive(to: ziel)
} else {
await restart()
}
// Ist der Neuaufbau selbst gescheitert, steht die Meldung schon da.
if case .failed = phase { return false }
return true
}
/// Noch einmal, ohne den eigenen Vorratsspeicher.
///
/// Gibt `true` zurück, wenn es einen Versuch gab. Genau **einen**: ohne
/// Speicher bleibt nichts mehr wegzunehmen, und ein weiterer Anlauf
/// schickte die App im Kreis.
private func retryWithoutPrefetch() async -> Bool {
guard streamServer != nil, !prefetchGaveUp else { return false }
prefetchGaveUp = true
diagnostics.note("Nichts kam an — noch einmal ohne Vorrat")
prefetchStatus = String(localized: "quality.prefetch.off")
await restart()
if case .failed = phase { return false }
return true
}
/// Der Rückzug auf VLC, wenn das Umpacken nicht getragen hat.
///
/// Genau **einmal** je Wiedergabe: scheitert auch VLC, ist es ein Fehler
/// des Materials, und ein zweiter Anlauf schickte die App im Kreis.
private func fallBackToVLC() async -> Bool {
guard !hasFallenBack, policy.allowsFallbackEngine else { return false }
hasFallenBack = true
diagnostics.note("Umpacken abgelehnt — noch einmal mit VLC")
await restart(forcing: PlaybackDecision(
method: .directPlay(engine: .vlc),
rationale: (decision?.rationale ?? []) + [.transcodingUnavailable]
))
if case .failed = phase { return false }
return true
}
/// Die nächste Sprosse unterhalb der aktuellen — aus **unserer** Leiter.
///
/// Nicht aus der Playlist: die hat in diesem Fall gerade keine, sonst
/// wären wir hier nicht. Die Sprossen richten sich nach der Höhe der
/// Quelle — eine 720p-Datei auf 1080p „hochzurechnen" bringt kein Bild
/// dazu.
private func nextLowerRung() -> Int? {
let aktuell = automaticCeiling
?? statistics?.indicatedBitrate
?? policy.maximumBitrate
?? 8_000
let sprossen = PlayerQuality.available(forSourceHeight: sourceHeight)
.compactMap { option -> Int? in
guard case .limited(let rung) = option else { return nil }
return rung.bitrate
}
.sorted()
return sprossen.last { $0 < aktuell }
}
/// Stimmt einen Sender mit kleinerer Bitrate neu ab.
///
/// **Der Tuner wird zuerst freigegeben.** Er ist ein Stück Hardware, vier
/// Stück gibt es, und ein doppelt belegter ist im Alltag schlimmer als
/// ein Stottern. Erst danach wird neu abgestimmt.
///
/// Bekannte Grenze: `endLiveSession` meldet nicht, **ob** die Freigabe
/// geklappt hat — das Protokoll gibt nichts zurück. Deshalb hier nur ein
/// Versuch je Wiedergabe und jeder Schritt im Protokoll, damit ein
/// verlorener Tuner wenigstens nachvollziehbar bleibt.
private func retuneLive(to bitrate: Int) async {
guard let liveTV, let subscription = request.liveSubscriptionKey else {
hasGivenUp = true
return
}
isChangingQuality = true
defer { isChangingQuality = false }
let stelle = currentTime
eventTask?.cancel()
engine?.teardown()
engine = nil
phase = .preparing
diagnostics.note("Tuner wird freigegeben")
await liveTV.endLiveSession(subscriptionKey: subscription)
let liste = await liveTV.liveChannels()
guard let sender = liste.channels.first(where: { $0.id == request.ratingKey }) else {
diagnostics.finish(outcome: "Sender nach dem Freigeben nicht mehr gefunden")
phase = .failed(message: String(localized: "player.stalled"))
return
}
let ergebnis = await liveTV.tune(
channel: sender,
program: nil,
maximumBitrate: bitrate
)
guard let url = ergebnis.url else {
diagnostics.finish(outcome: "Neues Abstimmen gescheitert")
phase = .failed(message: String(localized: "player.stalled"))
return
}
diagnostics.note("Neu abgestimmt mit höchstens \(bitrate) kbit")
let entscheidung = PlaybackDecision(method: .transcode(maximumBitrate: bitrate), rationale: [])
self.decision = entscheidung
await load(
PlaybackSource(url: url, origin: .remote, startTime: stelle),
decision: entscheidung
)
}
/// Setzt die Obergrenze eine Sprosse tiefer oder höher.
private func adjustCeiling(
for condition: StreamCondition,
statistics: PlaybackStatistics,
at now: Duration
) {
let sprossen = statistics.variantBitrates
guard let unterste = sprossen.first, let oberste = sprossen.last else { return }
let aktuell = automaticCeiling ?? statistics.indicatedBitrate ?? oberste
let ziel: Int?
switch condition {
case .failing:
ziel = unterste
case .strained:
// **Auf eine Sprosse, die es wirklich gibt.** Ein Wert zwischen
// zweien wählt dieselbe wie die untere und macht jede Anzeige
// darüber zur Lüge.
ziel = statistics.rung(below: aktuell) ?? unterste
case .healthy:
// Hinauf nur zögerlich, und nur nach einer Ruhefrist.
guard let lastStepUp, now - lastStepUp >= Self.stepUpCooldown else {
if lastStepUp == nil { self.lastStepUp = now }
return
}
// **Bis zur eingestellten Decke, nicht bis zur Startstufe.** Wer
// „Original" eingestellt hat und eine Leitung hat, die es trägt,
// soll dort auch ankommen — das ist die andere Hälfte von
// „richtet sich nach der Verbindung".
let decke = quality.policy(basedOn: policy).maximumBitrate
guard let höher = sprossen.first(where: { $0 > aktuell }),
decke.map({ höher <= $0 }) ?? true
else { return }
ziel = höher
}
guard let ziel, ziel != automaticCeiling else { return }
automaticCeiling = ziel
if condition == .healthy { lastStepUp = now }
// Die oberste Sprosse ist keine Grenze, sondern das Fehlen einer.
engine?.setPeakBitrate(ziel >= oberste ? nil : ziel)
diagnostics.note(String(
format: "Automatik: %@ → höchstens %d kbit",
String(describing: condition),
ziel
))
}
/// Protokolliert einmalig, dass es nichts zum Umschalten gibt.
private func noteMissingLadder(_ condition: StreamCondition) {
guard condition != .healthy, !hasNotedMissingLadder else { return }
hasNotedMissingLadder = true
diagnostics.note("Es hakt, aber die Playlist hat nur eine Stufe — nichts zum Ausweichen")
}
/// Führt Buch über das Stocken — die **zweite** Uhr.
///
/// Der Watchdog darunter ist ein Totmann: vierzig Sekunden am Stück ohne
/// Daten, dann Schluss. Er setzt sich bei jedem kurzen Erholen zurück,
/// und genau das ist sein blinder Fleck: zehn Rückler à dreißig Sekunden
/// lösen ihn nie aus, obwohl die halbe Stunde unbrauchbar war.
///
/// Dieses Buch zählt stattdessen mit, wie viel Zeit im gleitenden Fenster
/// fürs Warten draufging. Es **bricht nichts ab** — es stellt nur fest.
private func noteStalling(_ state: PlaybackState, wasPlaying: Bool) {
guard let startedAt = playbackStartedAt else {
// Der Nullpunkt ist das erste Bild, nicht das Antippen.
if state == .playing { playbackStartedAt = .now }
return
}
let now = ContinuousClock.now - startedAt
if state == .buffering {
// Nur aus dem Spielen heraus. Ein Stocken direkt nach einem
// Sprung oder beim Umschalten der Stufe ist keine Auskunft über
// die Leitung, sondern über das, was wir gerade selbst getan
// haben.
if wasPlaying, stallBegan == nil { stallBegan = now }
} else if let began = stallBegan {
let episode = StallLedger.Episode(endedAt: now, length: now - began)
stallLedger.record(episode)
stallBegan = nil
judgeConnection(at: now, episode: episode)
}
}
/// Fällt das Urteil über die Leitung — und schreibt es auf.
private func judgeConnection(at now: Duration, episode: StallLedger.Episode) {
let share = stallLedger.share(at: now)
let struggling = share >= 0.25 || episode.length >= .seconds(15)
guard struggling != isConnectionStruggling else { return }
isConnectionStruggling = struggling
// Über diese Wiedergabe hinaus merken: der Hinweis in den
// Einstellungen soll sich auf einen Beleg stützen können, und dort
// ist der Player längst zu.
if struggling { settings.lastPlaybackStruggled = true }
if struggling {
diagnostics.note(String(
format: "Leitung trägt nicht: %d Rückler, %.0f %% Wartezeit",
stallLedger.count(at: now),
share * 100
))
} else {
diagnostics.note("Leitung hat sich erholt")
}
}
/// Beendet ewiges Laden mit einer Meldung statt mit einem Kreisel.
///
/// Ein Live-Stream kann sterben, ohne dass die Engine es merkt — der Tuner
/// gibt auf, der Transcoder bricht ab, und der Player wartet auf Daten,
/// die nie kommen. Ohne diese Grenze dreht sich der Kreisel bis zum
/// nächsten Antippen, und niemand weiß, ob noch etwas kommt.
private func watchBuffering(_ isBuffering: Bool) {
bufferWatchdog?.cancel()
guard isBuffering else { return }
bufferWatchdog = Task { [weak self] in
// **Der Vorrat fliegt früher raus als alles andere.**
//
// Vierzig Sekunden sind die Grenze zum Aufgeben; so lange muss
// niemand warten, um herauszufinden, ob der eigene Speicher im
// Weg steht. Ihn wegzunehmen kostet nur den Vorsprung, und wenn
// es danach läuft, war er die Ursache.
try? await Task.sleep(for: .seconds(12))
if let self, self.isBuffering, await self.retryWithoutPrefetch() { return }
try? await Task.sleep(for: .seconds(28))
guard !Task.isCancelled, let self, self.isBuffering else { return }
diagnostics.note("Seit 40 s keine Daten")
// **Auch das ist ein Beleg**, und ein stärkerer als ein paar
// Rückler: hier kam überhaupt nichts an. Ohne diese Zeile bliebe
// der Hinweis in den Einstellungen aus, weil das Rücklerbuch erst
// ab dem ersten Bild zählt — und das gab es nie.
settings.lastPlaybackStruggled = true
// **Erst kleiner versuchen, dann aufgeben.**
//
// Hier lag der eigentliche Fehler: die ganze Regelung misst ab
// dem ersten Bild. Kommt nie eines, misst sie nichts, greift
// nichts, und „Auto" hat sich als das erwiesen, was es hiess —
// „nimm, was in den Einstellungen steht". Steht dort „Original",
// bittet die App über eine Zugverbindung um eine 4K-Datei und
// wartet vierzig Sekunden auf etwas, das nie kommen kann.
// **Erst ohne den Vorratsspeicher.**
//
// Er steht zwischen Player und Server, und was dort schiefgeht,
// sieht von aussen genauso aus wie eine tote Leitung. Ihn
// wegzunehmen ist billig, kostet nur den Vorsprung — und
// unterscheidet die beiden Fälle, statt sie zu verwechseln.
if await self.retryWithoutPrefetch() { return }
if await self.retrySmaller() { return }
// Den Engine gleich stilllegen: er würde sonst weiter an einem
// toten Stream ziehen, und bei Live-TV bliebe der Tuner belegt.
silence()
// Wer ohne Grenze angefragt hat, bekommt den Grund dazu. „Der
// Stream ist abgerissen" ist wahr und führt nirgendwohin; die
// Einstellung zu nennen ist der Unterschied zwischen einer
// Meldung und einer Hilfe.
let ohneGrenze = quality == .automatic && effectivePolicy.maximumBitrate == nil
phase = .failed(message: String(
localized: ohneGrenze ? "player.stalledUnlimited" : "player.stalled"
))
Task { await self.releaseOnServer() }
}
}
/// Holt die laufende Sendung nach, falls die angezeigte vorbei ist.
///
/// Beim Einschalten reist der Name mit; ein Sender läuft aber weiter,
/// während die Sendung wechselt. Nur dann abgefragt, wenn die alte
/// wirklich abgelaufen ist — das Raster über alle Sender zu holen ist
/// nichts, was man bei jedem Antippen tun will.
func refreshLiveProgrammeIfNeeded() async {
guard isLive, let liveTV else { return }
if let programmeEndsAt, programmeEndsAt > .now { return }
let now = Date()
// Nur dieser eine Sender. Vorher ging hier das Raster über **alle**
// Sender hinaus, weil der Plex-Weg keinen schmaleren kannte — beim
// neutralen Vertrag ist die Senderliste ein Parameter.
let programs = (try? await liveTV.livePrograms(
channelIDs: [request.ratingKey],
from: now,
to: now.addingTimeInterval(2 * 3_600)
)) ?? [:]
guard let match = programs[request.ratingKey]?.first(where: { $0.isRunning() }) else {
// Lieber gar nichts als etwas Falsches.
subtitle = nil
return
}
subtitle = match.title
programmeEndsAt = match.endsAt
}
/// Anhalten oder weiterlaufen lassen.
///
/// Das Warten auf Daten zählt hier zum Laufen. Ohne `isBuffering` in
/// dieser Bedingung führte ein Tipp in genau dem Moment zu `play()` auf
/// etwas, das bereits spielte — und sichtbar geschah nichts.
func togglePlayPause() {
guard let engine else { return }
if isPlaying || isBuffering { engine.pause() } else { engine.play() }
}
func seek(to time: Duration) {
guard let engine else { return }
if let startedAt = playbackStartedAt { lastSeekAt = ContinuousClock.now - startedAt }
// **Sofort, nicht wenn es geladen ist.**
//
// Die Stelle stand bisher erst dann neu da, wenn der Player das erste
// Bild von dort hatte — bei einem Stream über das Netz Sekunden
// später. Sichtbar war: man spult, und der Regler bleibt liegen, wo er
// war. Das liest sich als „der Sprung ist nicht angekommen", und man
// spult noch einmal.
//
// Der Sprung **ist** angekommen, in dem Moment, in dem man ihn
// auslöst. Alles Weitere ist Warten auf Daten, und dafür gibt es den
// Kreisel.
currentTime = time
seekTarget = time
// Der Vorrat von vorhin gilt für die alte Stelle. Ihn stehen zu lassen
// behauptete, man könne sofort weiterspringen.
bufferedUntil = nil
NowPlaying.shared.update(from: self)
updateActiveMarker()
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.endMilliseconds))
}
func selectAudioTrack(_ track: MediaTrack) {
engine?.selectAudioTrack(track)
selectedAudioTrack = track
}
/// `nil` schaltet die Untertitel ab.
func selectSubtitleTrack(_ track: MediaTrack?) {
engine?.selectSubtitleTrack(track)
selectedSubtitleTrack = track
}
/// Legt die Engine **sofort** still. Kein Netzwerk, kein Warten.
///
/// Getrennt vom Aufräumen am Server, und das ist der ganze Punkt: früher
/// stand die Engine erst nach drei Netzwerkaufrufen still. Wurde in der
/// Zwischenzeit etwas Neues gestartet, liefen zwei Engines gleichzeitig,
/// und der alte hat der neuen Wiedergabe beim Abräumen die Audiositzung
/// weggenommen. Was man hörte: der alte Ton lief weiter — auch nachdem
/// die App im Hintergrund war —, und das Neue blieb stumm.
func silence() {
resumeTarget = nil
eventTask?.cancel()
reportTask?.cancel()
engine?.teardown()
engine = nil
// Was auf der alten Engine ausgewählt war, gilt für keine neue.
selectedSubtitleTrack = nil
selectedAudioTrack = nil
resumeGiveUp?.cancel()
// Der Vorrat gehört zu diesem Stream. Stehen zu bleiben hiesse,
// Schnipsel für einen Film zu holen, den niemand mehr sieht — und den
// Platz dafür zu behalten.
if let streamServer { Task { await streamServer.stop() } }
streamServer = nil
streamCache = nil
prefetchStatus = nil
pictureInPicture = nil
pictureInPictureDelegate = nil
surface.playerLayer.player = nil
isPlaying = false
isBuffering = false
bufferWatchdog?.cancel()
// Das Buch gehört zur Quelle. Ein neuer Titel fängt bei null an.
stallLedger.removeAll()
playbackStartedAt = nil
stallBegan = nil
isConnectionStruggling = false
healthTask?.cancel()
healthTask = nil
health.removeAll()
automaticCeiling = nil
lastStepUp = nil
hasNotedMissingLadder = false
lastSeekAt = nil
lastDowngradeAt = nil
downgradesUsed = 0
hasGivenUp = false
NowPlaying.shared.release(for: self)
}
/// Meldet dem Server, dass Schluss ist. Darf dauern.
func releaseOnServer() async {
// 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 playbackSession, let backend {
await backend.endPlayback(sessionID: playbackSession)
}
// Und bei Live-TV den Tuner freigeben. Er ist ein Stück Hardware:
// vier Stück gibt es, und ein nicht freigegebener fehlt beim nächsten
// Einschalten — bis der Server von selbst merkt, dass niemand zusieht.
if let subscription = request.liveSubscriptionKey, let server = liveTV {
await server.endLiveSession(subscriptionKey: subscription)
}
}
func stop() async {
silence()
await releaseOnServer()
}
// MARK: - Fortschritt melden
private func startReporting() {
reportTask?.cancel()
reportTask = Task { [weak self] in
// Sofort, nicht erst nach zehn Sekunden: erst diese Meldung legt
// beim Server die Sitzung an, unter der alles Weitere läuft.
await self?.report(state: .started)
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 backend, let item else { return }
// Die Laufzeit der Engine ist die genauere, aber sie steht erst ein
// paar Sekunden nach dem Öffnen fest. Für die erste Meldung tut es die
// des Servers — sonst fiele ausgerechnet der Bericht aus, der die
// Sitzung anlegt, und der Titel erschiene nirgends als „läuft gerade".
guard let total = duration ?? item.duration.map({ Duration.milliseconds($0) })
else { return }
// Beim Beginn steht die Zeit noch auf null — das ist keine fehlende
// Angabe, sondern der Anfang. Alle anderen Meldungen ohne Zeit wären
// dagegen wertlos und würden einen gemerkten Fortschritt überschreiben.
guard currentTime > .zero || state == .started else { return }
await backend.reportProgress(PlaybackReport(
itemID: item.id,
sessionID: playbackSession,
versionID: playbackVersionID,
state: state,
time: currentTime,
duration: total
))
}
// 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 marker.contains(milliseconds) 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 backend else { return }
Task { [weak self] in
let next = try? await backend.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.id,
title: nextEpisode.playbackTitle,
mediaIndex: 0,
startTime: .zero
)
}
// MARK: - Innereien
private func version(of item: MediaItem) -> MediaVersion? {
guard item.versions.indices.contains(request.mediaIndex) else { return item.preferredVersion }
return item.versions[request.mediaIndex]
}
/// Legt die gewählte Skalierung auf die Bildebene.
///
/// Eine eigene Funktion, weil es zwei Anlässe gibt: die Wahl des Nutzers
/// und jeder Engine-Wechsel. Beide müssen dasselbe tun.
private func applyVideoGravity() {
surface.playerLayer.videoGravity = videoFit == .fill ? .resizeAspectFill : .resizeAspect
}
/// Hängt die gehaltene Bildebene an den aktuellen Engine.
///
/// Nach **jedem** Engine-Wechsel nötig — also auch nach einem
/// Qualitätswechsel, der die Engine neu baut. Ohne das zeigt die Ebene
/// weiter auf einen abgeräumten Player und bleibt schwarz.
private func attachSurface(to engine: any VideoPlayerEngine) {
guard let avEngine = engine as? AVPlayerEngine else { return }
surface.playerLayer.player = avEngine.player
// **Den Zustand lesen, keine Konstante.** Hier stand fest
// `.resizeAspect` — und weil diese Zeile nach jedem Neuaufbau erneut
// läuft, fiel ein auf formatfüllend gestelltes Bild bei jedem
// Qualitätswechsel klammheimlich zurück.
applyVideoGravity()
guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
if pictureInPicture == nil {
let bridge = PictureInPictureBridge { [weak self] in
self?.restoreFromPictureInPicture?()
}
let controller = AVPictureInPictureController(playerLayer: surface.playerLayer)
#if os(iOS)
// Beim Verlassen der App ins Bild-in-Bild wechseln, statt die
// Wiedergabe anzuhalten. Auf dem Mac verschwindet eine App nicht
// in den Hintergrund — dort startet Bild-in-Bild von Hand.
controller?.canStartPictureInPictureAutomaticallyFromInline = true
#endif
controller?.delegate = bridge
pictureInPictureDelegate = bridge
pictureInPicture = controller
}
}
/// Was passieren soll, wenn der Nutzer aus Bild-in-Bild zurückkehrt.
///
/// Bisher gab es das nicht: Bild-in-Bild war eine Einbahnstraße, weil
/// niemand den Delegaten gesetzt hat.
func onPictureInPictureRestore(_ action: @escaping () -> Void) {
restoreFromPictureInPicture = action
}
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
#if canImport(FlixyVLC)
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()
}
#else
// Auf dem Mac gibt es VLC (noch) nicht: das Binärpaket ist auf iOS
// festgelegt. Was der Systemplayer nicht öffnen kann, scheitert dort
// mit einer Meldung, statt still schwarz zu bleiben.
AVPlayerEngine()
#endif
}
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):
let wasPlaying = isPlaying
isPlaying = state == .playing
isBuffering = state == .buffering || state == .loading
noteStalling(state, wasPlaying: wasPlaying)
watchBuffering(isBuffering)
NowPlaying.shared.update(from: self)
// Jeder Zustandswechsel mit Zeitstempel. Erst daran ist
// abzulesen, ob die Engine hängt oder die Leitung.
diagnostics.note("Zustand: \(state)")
if case .failed(let failure) = state {
diagnostics.note("Wiedergabe gescheitert: \(failure)")
// **Scheitert das Umpacken, bleibt VLC.**
//
// Der Server entscheidet erst beim Ausliefern, ob er
// die Ströme wirklich kopieren kann — und manche
// Antworten kann AVPlayer nicht annehmen, auch wenn
// sie formal richtig sind. Das hier vorher zu wissen
// ist nicht möglich; es hinterher aufzufangen schon.
//
// Ohne diesen Weg stünde der Nutzer vor einer
// schwarzen Wand mit einer Fehlernummer, obwohl ein
// Player danebensteht, der die Datei anstandslos
// spielt.
if case .remux = decision?.method, await fallBackToVLC() { return }
diagnostics.finish(outcome: "Wiedergabe gescheitert: \(failure)")
// Der Grund gehört auf den Bildschirm, nicht nur ins
// Protokoll: „die Wiedergabe wurde unterbrochen" allein
// ist eine Sackgasse — es sagt weder, ob der Server
// ablehnt, noch ob die Engine das Format nicht mag.
phase = .failed(message: [
String(localized: "player.playbackFailed"),
String(describing: failure),
].joined(separator: "\n\n"))
}
case .timeChanged(let time):
// **Nachzügler verwerfen.** Nach einem Sprung meldet der
// Player noch ein paar Mal die alte Stelle, bis er dort
// ankommt. Ungeprüft übernommen zöge das den Regler wieder
// zurück — einmal hin, einmal zurück, einmal hin.
if let ziel = seekTarget {
guard abs((time - ziel).seconds) < 1.5 else { break }
seekTarget = nil
}
// **Und dasselbe beim Neuaufbau.**
//
// Ein frisch geöffneter Stream meldet erst einmal null,
// eins, zwei — er springt erst danach an die gemerkte
// Stelle. Wer in diesem Augenblick `currentTime` liest,
// liest zwei Sekunden statt einundzwanzig Minuten. Und
// genau das tut ein **zweiter** Neuaufbau, wenn man zügig
// noch etwas umstellt: er merkt sich die falsche Stelle
// und landet am Filmanfang. Daher „teilweise".
if let ziel = resumeTarget, time + .seconds(2) < ziel { break }
currentTime = time
// Gedrosselt in `NowPlaying`: zwischen zwei Meldungen
// zählt das System selbst weiter, es kennt das Tempo.
NowPlaying.shared.update(from: self)
// Angekommen: ab hier zeigt das Bild wieder die Stelle,
// an der man war.
if let target = resumeTarget, time + .seconds(2) >= target {
resumeTarget = nil
}
updateActiveMarker()
case .durationResolved(let value):
duration = value
NowPlaying.shared.update(from: self)
case .reachedEnd:
isPlaying = false
hasReachedEnd = true
// Grundlage fürs automatische Löschen nach dem Ansehen —
// und der Auslöser fürs Nachladen: der Vorrat ist jetzt
// um eine Folge kleiner.
downloads.markWatched(ratingKey: request.ratingKey)
Task { [backend, downloads] in
await DownloadRefiller(backend: backend, coordinator: downloads).refill()
}
case .tracksResolved(let audio, let subtitle):
audioTracks = audio
subtitleTracks = subtitle
// **Die Zeile, die drei Runden Ratens erspart hätte.**
// Bestellt und angekommen sind zweierlei, und von aussen
// sieht „nicht geliefert" genauso aus wie „geliefert und
// nicht angezeigt".
diagnostics.note(
"Player meldet \(audio.count) Tonspuren, "
+ "\(subtitle.count) Untertitel"
)
showOrderedSubtitle()
applyPreferredLanguages()
}
}
}
}
/// Zeigt den Untertitel, den wir beim Server bestellt haben.
///
/// **Der Fehler, den das behebt:** Wer im Player einen Untertitel wählt,
/// löst einen Neuaufbau aus — der Server bekommt die Spurnummer und
/// liefert sie brav mit. Nur sagte danach niemand dem Player, dass er sie
/// **zeigen** soll. Gewählt wurde bisher allein in
/// `applyPreferredLanguages()`, und das greift nur, wenn in den
/// Einstellungen eine Untertitelsprache steht. Wer keine eingestellt
/// hatte, schaltete die Untertitel ein und sah nichts.
///
/// **Die erste ist die richtige.** Rechnet der Server um, liegt genau
/// eine im Stream — nämlich die bestellte. Es gibt nichts zu verwechseln.
private func showOrderedSubtitle() {
guard selectedSubtitleStream != nil, selectedSubtitleTrack == nil else { return }
guard let erste = subtitleTracks.first else { return }
diagnostics.note("Untertitel eingeschaltet: \(erste.displayName)")
selectSubtitleTrack(erste)
}
/// Wählt die Untertitelspur, sofern eine gewünscht ist.
///
/// **Nur noch Untertitel.** Die Tonspur wird nicht mehr hier gewählt,
/// sondern in `chooseAudioStream(in:of:backend:)` — also **bevor** der
/// Stream ausgehandelt wird. Hier wäre es zu spät: bei einem umgerechneten
/// Stream meldet der Player genau eine Tonspur, und die hat der Server
/// längst ausgesucht.
///
/// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf
/// eine später gemeldete Spurliste das nicht zurücksetzen.
private func applyPreferredLanguages() {
guard selectedSubtitleTrack == nil, !languages.subtitleLanguage.isEmpty,
let treffer = subtitleTracks.first(where: {
LanguageCode.matches(languages.subtitleLanguage, $0.languageCode)
})
else { return }
selectSubtitleTrack(treffer)
}
// MARK: - Die Tonspur
/// Entscheidet, welche Tonspur bestellt wird.
///
/// **Vor dem Aushandeln**, und das ist der ganze Punkt: rechnet der Server
/// um, liefert er genau eine Tonspur aus. Wer erst im Player wählen will,
/// hat dann nichts mehr zur Auswahl. Die Entscheidung muss in die Anfrage.
///
/// Die Abwägung selbst steht in `AudioTrackChooser` — ein reiner Typ ohne
/// Netz und ohne Einstellungen, damit sich der ZDF-Fall wörtlich
/// hinschreiben und durchspielen lässt. Hier steht nur, woher der Wunsch
/// kommt.
private func chooseAudioStream(
in part: MediaFile,
of item: MediaItem,
backend: any MediaBackend
) async {
audioStreams = part.audioStreams
subtitleStreams = part.subtitleStreams
// Untertitel sind eine Zutat, keine Voraussetzung: der Wunsch gilt,
// wenn es einen passenden gibt, und sonst bleibt es ohne.
selectedSubtitleStream = languages.subtitleLanguage.isEmpty ? nil
: subtitleStreams.first {
LanguageCode.matches(languages.subtitleLanguage, $0.languageCode)
}
guard !audioStreams.isEmpty else {
selectedAudioStream = nil
return
}
// Die Mediathek nur nachschlagen, wenn überhaupt jemand eine Sprache
// je Mediathek gesetzt hat. Sonst kostete jede Wiedergabe eine
// Anfrage für eine Ebene, die niemand benutzt.
if languages.usesLibraryLevel, libraryID == nil {
libraryID = await backend.libraryID(for: item)
}
selectedAudioStream = AudioTrackChooser.choose(
from: part.streams,
wish: languages.wish(seriesID: item.seriesID, libraryID: libraryID)
)
if let gewählt = selectedAudioStream {
diagnostics.note("Tonspur \(gewählt.index.map(String.init) ?? "?"): \(gewählt.name)")
}
}
/// Stellt auf eine andere Tonspur um.
///
/// **Und merkt sie sich für die Serie** — ohne zu fragen. Die Frage „für
/// diese Serie merken?" bei jeder Umstellung ist genau die Art Dialog, die
/// man nach dem dritten Mal wegklickt. Sichtbar ist es trotzdem: das
/// Spurenblatt zeigt es an und lässt es löschen.
///
/// Bei Filmen wird nichts gemerkt — ein Film wird einmal gesehen.
func selectAudioStream(_ stream: MediaStream) async {
guard stream != selectedAudioStream else { return }
selectedAudioStream = stream
if let seriesID = item?.seriesID {
languages.remember(stream, forSeries: seriesID)
}
// **Der billige Weg, wenn er offensteht.** Wird die Datei unverändert
// gespielt, liegen alle Spuren im Stream, und der Player schaltet
// ohne Unterbrechung um. Die Zuordnung geht über die Reihenfolge:
// bei unveränderter Wiedergabe reicht AVFoundation die Spuren in der
// Reihenfolge der Datei durch. Stimmt die Anzahl nicht überein, ist
// die Annahme falsch — dann lieber neu aufbauen als die falsche Spur
// schalten.
if case .directPlay = decision?.method,
audioTracks.count == audioStreams.count,
let position = audioStreams.firstIndex(of: stream) {
selectAudioTrack(audioTracks[position])
return
}
await restart()
}
/// Stellt die Untertitel um.
///
/// Dieselbe Bauart wie beim Ton, und aus demselben Grund: rechnet der
/// Server um, steckt die Wahl in der Adresse. Bei unveränderter Wiedergabe
/// liegen dagegen alle im Stream, und der Player schaltet ohne
/// Unterbrechung um.
func selectSubtitleStream(_ stream: MediaStream?) async {
guard stream != selectedSubtitleStream else { return }
selectedSubtitleStream = stream
if case .directPlay = decision?.method {
let treffer = stream.flatMap { gewählt in
subtitleTracks.first { LanguageCode.matches(gewählt.languageCode, $0.languageCode) }
}
// Nur wenn sich die Wahl im Player wirklich abbilden lässt. Sonst
// stünde der falsche Untertitel im Bild, und das wäre schlimmer
// als eine kurze Unterbrechung.
if stream == nil || treffer != nil {
selectSubtitleTrack(treffer)
return
}
}
await restart()
}
/// Was für diese Serie gemerkt ist — für die Anzeige im Spurenblatt.
var rememberedTrackName: String? {
guard let seriesID = item?.seriesID,
let signatur = languages.rememberedTrack(forSeries: seriesID)
else { return nil }
return signatur.title ?? signatur.languageCode
}
/// Vergisst die gemerkte Spur dieser Serie.
func forgetRememberedTrack() {
guard let seriesID = item?.seriesID else { return }
languages.forgetTrack(forSeries: seriesID)
}
private func playerSubtitle(for item: MediaItem) -> 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
}
}
/// Verbindet Bild-in-Bild mit der App.
///
/// Ein eigener Typ, weil `AVPictureInPictureControllerDelegate` von `NSObject`
/// erben muss und `PlayerModel` ein `@Observable` ist. Ohne einen Delegaten
/// war Bild-in-Bild eine Einbahnstraße: man kam hinein, aber nicht zurück in
/// den Player.
private final class PictureInPictureBridge: NSObject, AVPictureInPictureControllerDelegate {
private let restore: () -> Void
init(restore: @escaping () -> Void) {
self.restore = restore
}
func pictureInPictureController(
_ controller: AVPictureInPictureController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completion: @escaping (Bool) -> Void
) {
restore()
completion(true)
}
}