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? 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 } /// 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-Strom 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() /// 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 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? private var eventTask: Task? private var reportTask: Task? /// 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? /// Wie viel Vorrat bei einem Strom ü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. /// /// Fünfundzwanzig Sekunden sind ein Anfangswert, kein Messergebnis: genug, /// um ein Loch von einigen Sekunden zu überbrücken, wenig genug, dass der /// Start nicht spürbar später kommt. Die richtige Zahl gehört mit einer /// gedrosselten Leitung ermittelt. private static let forwardBuffer: Duration = .seconds(25) /// Wie oft der Zustand des Stroms 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 Stroms, fortlaufend beurteilt. @ObservationIgnored private var health = StreamHealth() @ObservationIgnored private var healthTask: Task? /// 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 } /// Die Richtlinie, wie sie **jetzt** gilt. /// /// Die eingestellte Obergrenze, überschrieben 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, let ceiling = automaticCeiling else { return base } return PlaybackPolicy( allowsTranscoding: true, // Ohne Ersatzmotor: 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 = [] init( request: PlaybackRequest, backend: (any MediaBackend)?, liveTV: (any LiveTVBackend)?, downloads: DownloadCoordinator, diagnostics: PlaybackDiagnostics, policy: PlaybackPolicy, settings: PlaybackSettings, 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.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 // Die zuletzt gewählte Stufe, falls sie gemerkt werden soll. **Vor** // der Entscheidung, nicht danach: hinterher umzustellen hieße, den // Strom 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 Strom 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() async { guard !isOffline, let backend, let item, let media = currentMedia, let part = currentPart else { return } isChangingQuality = true defer { isChangingQuality = false } let resumeAt = currentTime resumeTarget = resumeAt // 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 phase = .preparing let newDecision = 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. Task { [weak self] in try? await Task.sleep(for: .seconds(30)) 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) 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 Stroms 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 case .transcode(let maximumBitrate): TranscodeSettings( maximumBitrate: maximumBitrate, resolution: quality.resolution, // **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 )) playbackSession = target.sessionID playbackVersionID = target.versionID return PlaybackSource( url: target.url, origin: .remote, startTime: start, forwardBuffer: Self.forwardBuffer ) } 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 Strom 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 quality == .automatic, !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 } self.sampleStream() } } } /// 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 Frage, ob der teure Weg nötig ist. considerTheHardWay(urteil, statistics: statistics, at: now) } // MARK: - Die Sicherheitsleine /// Prüft, ob der Strom 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 } } /// Baut den Strom 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() } } /// 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 } guard let höher = sprossen.first(where: { $0 > aktuell }) 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 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 try? await Task.sleep(for: .seconds(40)) guard !Task.isCancelled, let self, self.isBuffering else { return } diagnostics.note("Seit 40 s keine Daten — abgebrochen") // Den Engine gleich stilllegen: er würde sonst weiter an einem // toten Stream ziehen, und bei Live-TV bliebe der Tuner belegt. silence() phase = .failed(message: String(localized: "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 } 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 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] } /// 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 surface.playerLayer.videoGravity = .resizeAspect 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.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): 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 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: 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 } } 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) } } /// 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) } }