Files
Flaro/Flixy/Views/Player/PlayerModel.swift
T
ScarriffleandClaude Opus 5 52d6ebba43 „Automatisch" misst jetzt, statt die Einstellung durchzureichen
Bisher hieß `PlayerQuality.automatic` wörtlich: nimm die Zahl, die für
WLAN oder Mobilfunk hinterlegt ist. Keine Messung, kein Nachregeln — die
Entscheidung fiel einmal beim Start und galt bis zum Ende.

`StreamHealth` beurteilt den Strom jetzt fortlaufend aus Proben im
Sekundentakt. Ein reiner Typ: kein AVFoundation, keine Uhr, kein Netz.
Damit lässt sich eine schlechte Zugfahrt als Zahlenreihe hinschreiben
und nachsehen, was sie auslöst — zehn Tests tun genau das.

**Welches Signal wann meldet**, ist der eigentliche Inhalt. Der
Pufferverlauf ist der einzige echte Frühwarner: schrumpft er schneller,
als die Zeit vergeht, kommt weniger herein als hinausgeht, und das sieht
man zehn bis zwanzig Sekunden vor dem Rückler. `isLikelyToKeepUp` ist
früh, aber sprunghaft — es zählt erst, wenn es anhält. Der Rücklerzähler
meldet zu spät zum Regeln und bleibt trotzdem der Massstab, an dem sich
hinterher ablesen lässt, ob es besser wurde. Die **gemessene** Bitrate
steht bewusst nicht drin: auf einer kleinen Sprosse holt der Player
wenig, ohne dass die Leitung schlecht wäre.

Ausdrücklich kein Bandbreitentest. Der kostet Daten, ist beim Vorliegen
veraltet und misst die Vergangenheit. Der Pufferverlauf ist die bessere
Messung, weil er die einzige Größe misst, auf die es ankommt: lädt es
schneller, als es läuft.

Gehandelt wird über `preferredPeakBitRate` — der billige Hebel, der ohne
Neuaufbau wirkt. Gezielt wird dabei auf eine Sprosse, **die es in
`AVAsset.variants` wirklich gibt**: ein Wert zwischen zweien wählt
dieselbe wie die untere und machte jede Anzeige darüber zur Lüge.
Absichtlich unsymmetrisch — runter sofort, hinauf frühestens nach einer
Minute Ruhe.

Und getrennt von `quality`: die Automatik ruft **nicht** `select(quality:)`.
Die Methode schreibt `rememberedQuality` fort und setzte eine manuelle
Stufe — womit die Automatik sich selbst abschaltete und eine schlechte
Zugfahrt dauerhaft das Wohnzimmer verschlechterte.

Hat die Playlist nur eine Stufe, greift der Hebel ins Leere. Das ist kein
Fehler, sondern der Fall, für den die Sicherheitsleine gebaut wird — bis
dahin steht er wenigstens im Protokoll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 15:40:48 +02:00

1130 lines
47 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?
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<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 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<Void, Never>?
/// Wann zuletzt eine Stufe höher gegangen wurde.
@ObservationIgnored private var lastStepUp: Duration?
@ObservationIgnored private var hasNotedMissingLadder = false
/// 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,
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 }
isChangingQuality = true
defer { isChangingQuality = false }
let resumeAt = currentTime
resumeTarget = resumeAt
quality = newQuality
// Merken, wenn es gewünscht ist — sonst gilt die Wahl nur für diesen
// einen Film.
if settings.remembersQuality { settings.rememberedQuality = newQuality.storageValue }
// 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 newPolicy = newQuality.policy(basedOn: policy)
let newDecision = PlaybackDecider().decide(
profile: media.profile(file: part),
policy: newPolicy
)
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
))
// **Ohne Leiter greift der billige Hebel ins Leere.** AVPlayer spielt
// die eine Stufe auch dann, wenn sie über der Grenze liegt. Das ist
// kein Fehler, sondern der Fall, für den es die Sicherheitsleine gibt
// — und bis die steht, gehört er wenigstens ins Protokoll.
guard statistics.hasLadder else {
noteMissingLadder(urteil)
return
}
adjustCeiling(for: urteil, statistics: statistics, at: now)
}
/// 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 Wachhund 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 }
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
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)
}
}