Der Server lieferte sie, die Spur lag im Stream, ausgewaehlt war sie auch — und angezeigt wurde trotzdem nichts. Der Grund ist eine Eigenschaft, die von Haus aus auf `true` steht: `appliesMediaSelectionCriteriaAutomatically`. Der Player richtet sich damit nach den Untertiteleinstellungen des Systems, und die stehen in aller Regel auf „aus\". Er nahm also jede Wahl, die wir trafen, wieder zurueck — lautlos, und erst nachdem wir sie gesetzt hatten. Beim Ton aendert das Abschalten nichts: welche Spur klingt, ist laengst beim Aushandeln entschieden, und bei einem umgerechneten Stream kommt ohnehin nur eine an. Dazu eine zweite Absicherung: bei einer Abspielliste laedt AVFoundation die Nebenspuren nachtraeglich. Meldet sie beim ersten Mal noch keine, ginge der bestellte Untertitel verloren — deshalb wird im Sekundentakt nachgefasst, solange nichts ausgewaehlt ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1837 lines
79 KiB
Swift
1837 lines
79 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()
|
|
/// 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
|
|
/// 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 }
|
|
// Acht Megabit tragen 1080p ordentlich und laufen auch über eine
|
|
// mässige Fernverbindung an. Wer mehr will und mehr hat, kommt über
|
|
// das Hinaufschalten dorthin.
|
|
return 8_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
|
|
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
|
|
|
|
// 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.
|
|
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)
|
|
// **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
|
|
))
|
|
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 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 Frage, ob der teure Weg nötig ist.
|
|
considerTheHardWay(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
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// 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
|
|
try? await Task.sleep(for: .seconds(40))
|
|
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.
|
|
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
|
|
// 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]
|
|
}
|
|
|
|
|
|
/// 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.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
|
|
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,
|
|
let erste = subtitleTracks.first
|
|
else { return }
|
|
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)
|
|
}
|
|
}
|