Files
Flaro/Flixy/Views/Player/PlayerModel.swift
T
ScarriffleandClaude Opus 5 e324a474fd Watchdog heisst Watchdog, Engine heisst Engine
Eingedeutschte Fachbegriffe in den Kommentaren — „Wachhund" für
Watchdog, „Motor" für Engine. Das liest sich albern und ist unklarer als
das Original; niemand sagt Wachhund, wenn er einen Watchdog meint.

Acht Stellen berichtigt. Gewöhnliche Sprache bleibt deutsch, der Stil
der Kommentare ändert sich nicht.

Dazu die Plex-Leiter messbar gemacht: `--leiter` baut dieselbe Anfrage
zweimal, mit und ohne `autoAdjustQuality=1`, und zählt die Stufen. Die
Zeile steht seit dem Umbau im Code und war nie geprüft — genau die Art
ungeprüfter Annahme über Serververhalten, die bei Jellyfin vier Runden
gekostet hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 17:47:31 +02:00

1322 lines
54 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
/// Wann zuletzt gesprungen wurde — ein Sprung leert den Puffer immer.
@ObservationIgnored private var lastSeekAt: Duration?
@ObservationIgnored private var lastDowngradeAt: Duration?
@ObservationIgnored private var downgradesUsed = 0
/// Ob die Automatik aufgegeben hat.
///
/// Dann wird es gesagt statt lautlos weiter abgesenkt: still bis zur
/// untersten Sprosse zu gehen ist schlimmer, als einmal zuzugeben, dass
/// die Leitung es nicht trägt.
private(set) var hasGivenUp = false
/// Wie oft in **dieser** Wiedergabe der teure Weg gegangen werden darf.
///
/// Bei Live-TV nur einmal. Jeder Neuaufbau stimmt dort den Tuner neu ab,
/// und davon gibt es vier Stück — ein Stottern ist der kleinere Schaden
/// als ein Sender, der gar nicht mehr angeht.
private var downgradeBudget: Int { isLive ? 1 : 2 }
/// Die Richtlinie, wie sie **jetzt** gilt.
///
/// Die eingestellte Obergrenze, überschrieben von dem, was die Automatik
/// unterwegs gelernt hat. Getrennt von `quality`, damit eine schlechte
/// Zugfahrt nicht als gemerkte Stufe das Wohnzimmer verschlechtert.
private var effectivePolicy: PlaybackPolicy {
let base = quality.policy(basedOn: policy)
guard quality == .automatic, let ceiling = automaticCeiling else { return base }
return PlaybackPolicy(
allowsTranscoding: true,
// Ohne Ersatzmotor: sonst spielte VLC die Datei unverändert ab,
// und die abgesenkte Grenze bliebe wirkungslos.
allowsFallbackEngine: false,
maximumBitrate: min(ceiling, base.maximumBitrate ?? ceiling),
forcesTranscoding: true
)
}
/// Die Obergrenze, die die Automatik gerade setzt — in Kilobit.
///
/// **Getrennt von `quality`, und das ist wesentlich.** Würde die
/// Automatik `select(quality:)` rufen, schriebe sie `rememberedQuality`
/// fort und setzte eine manuelle Stufe — womit sie sich selbst
/// abschaltete und eine schlechte Zugfahrt dauerhaft das Wohnzimmer
/// verschlechterte. `quality` bleibt deshalb `.automatic`; nur dieser
/// Wert wandert.
private(set) var automaticCeiling: Int?
/// Das Buch über die Rückler dieser Wiedergabe.
@ObservationIgnored private var stallLedger = StallLedger()
/// Seit wann tatsächlich gespielt wird — der Nullpunkt für das Buch.
///
/// Erst ab dem ersten Bild, nicht ab dem Antippen: das anfängliche
/// Puffern ist der Start und kein Rückler.
@ObservationIgnored private var playbackStartedAt: ContinuousClock.Instant?
/// Wann das laufende Stocken begann, gemessen ab `playbackStartedAt`.
@ObservationIgnored private var stallBegan: Duration?
/// Ob die Leitung diese Wiedergabe gerade nicht trägt.
///
/// Nicht „es hat einmal gehakt" — das tut jede Verbindung. Sondern: in
/// den letzten drei Minuten ging ein Viertel der Zeit fürs Warten drauf,
/// oder ein einzelner Rückler dauerte länger als fünfzehn Sekunden.
///
/// Das ist der Befund, aus dem später das Herunterschalten folgt — und
/// schon jetzt der Beleg, auf den sich ein Hinweis in den Einstellungen
/// stützen darf, statt auf eine Vermutung.
private(set) var isConnectionStruggling = false
/// Marken, die bereits automatisch übersprungen wurden. Ohne dieses
/// Gedächtnis würde ein Rücksprung ins Intro es sofort wieder wegspringen
/// lassen — und man käme nie zurück.
private var skippedMarkers: Set<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 }
quality = newQuality
// Von Hand gewählt: was die Automatik gelernt hatte, gilt nicht mehr.
automaticCeiling = nil
hasGivenUp = false
// Merken, wenn es gewünscht ist — sonst gilt die Wahl nur für diesen
// einen Film.
if settings.remembersQuality { settings.rememberedQuality = newQuality.storageValue }
await restart()
}
/// Baut den Strom mit der **aktuell geltenden** Richtlinie neu auf.
///
/// Geteilt zwischen der Wahl von Hand und der Sicherheitsleine — der
/// Ablauf ist derselbe, nur der Anlass nicht. Was sich unterscheidet,
/// steht in `effectivePolicy`, nicht hier: die Wahl setzt `quality`, die
/// Automatik setzt `automaticCeiling`, und beides fließt dort zusammen.
private func restart() async {
guard !isOffline,
let backend, let item, let media = currentMedia, let part = currentPart
else { return }
isChangingQuality = true
defer { isChangingQuality = false }
let resumeAt = currentTime
resumeTarget = resumeAt
// Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet
// der Server zwei Streams gleichzeitig für einen Zuschauer.
if let playbackSession {
await backend.endPlayback(sessionID: playbackSession)
self.playbackSession = nil
}
eventTask?.cancel()
engine?.teardown()
engine = nil
phase = .preparing
let newDecision = PlaybackDecider().decide(
profile: media.profile(file: part),
policy: effectivePolicy
)
decision = newDecision
guard let source = await source(
for: newDecision,
item: item,
media: media,
part: part,
backend: backend,
startingAt: resumeAt
) else {
// Nur dann eine allgemeine Meldung, wenn `source` keine eigene
// hinterlassen hat. Vorher stand hier immer „keine abspielbare
// Datei", auch wenn der Server im Klartext gesagt hatte, warum er
// ablehnt — und damit war der eine brauchbare Hinweis weg.
if case .failed = phase {} else {
phase = .failed(message: String(localized: "player.noPlayableFile"))
}
return
}
await load(source, decision: newDecision)
// Notbremse: springt der Player nie an die Stelle, bliebe sonst für
// immer eine Abdeckung über dem Bild.
Task { [weak self] in
try? await Task.sleep(for: .seconds(30))
self?.resumeTarget = nil
}
}
/// Wiedergabe aus dem Download-Ordner.
///
/// Ohne Server und ohne Transcoding: die Datei liegt so vor, wie sie auf
/// dem Server lag. Welcher Engine sie öffnen kann, steht im Abzug, der beim
/// Herunterladen mitgesichert wurde — offline lässt sich nichts nachfragen.
/// Live-TV: die Adresse steht schon fest.
///
/// Ohne Metadatenabruf, weil es nichts abzurufen gibt — ein laufender
/// Sender steht in keiner Mediathek. Und ohne Fortschrittsmeldung: es
/// gibt keine Stelle, an der man ihn später fortsetzen könnte.
private func startLiveStream(url: URL) async {
diagnostics.note("Live-Stream öffnen")
isLive = true
title = request.title
// Was gerade läuft, kommt mit dem Wunsch — nachzuschlagen ist es
// nicht, ein Sender steht in keiner Mediathek.
subtitle = request.subtitle
programmeEndsAt = request.subtitleEndsAt
// Die Sitzungskennung aus der Adresse übernehmen. Ohne sie bleibt der
// Tuner nach dem Schließen belegt — und beim nächsten Sender hat der
// Server keinen freien mehr.
playbackSession = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "session" }?.value
// Live kommt immer umgerechnet: der Tuner liefert einen
// Transportstrom, den kein iPhone direkt abspielt.
let decision = PlaybackDecision(method: .transcode(maximumBitrate: nil), rationale: [])
self.decision = decision
await load(PlaybackSource(url: url, origin: .remote), decision: decision)
}
private func startLocal(url: URL) async {
let offline = downloads.offlineMedia(forRatingKey: request.ratingKey)
let decision = PlaybackDecider().decide(
// Früher standen hier **erfundene** Plex-Modelle: eine Media mit
// Kennung 0, ein Part mit dem Dateinamen als Schlüssel und
// zusammengebaute Spuren — alles nur, damit die Signatur passte,
// für eine Datei, die längst auf dem Gerät lag. Genau daran war zu
// sehen, dass die Entscheidung nie etwas mit Plex zu tun hatte.
profile: MediaProfile(
container: offline?.container,
videoCodec: offline?.videoCodec,
audioCodec: offline?.audioCodec,
bitrate: offline?.bitrate,
// Was heruntergeladen wurde, ist untersucht. Ohne diese Zusage
// ginge jede Offline-Datei vorsichtshalber an VLC.
hasKnownStreams: offline?.videoCodec != nil
),
// Lokal gibt es nichts umzurechnen und keine Bandbreitengrenze.
policy: .directPlayOnly
)
self.decision = decision
isOffline = true
await load(
PlaybackSource(url: url, origin: .local, startTime: request.startTime),
decision: decision
)
}
private func load(_ source: PlaybackSource, decision: PlaybackDecision) async {
diagnostics.record(decision: decision, source: source.url)
let engine = makeEngine(for: decision)
self.engine = engine
attachSurface(to: engine)
observe(engine)
do {
diagnostics.note("Engine öffnet Quelle")
try await engine.load(source)
engine.play()
// Erst nach dem Start: vorher hat die Engine noch keinen Gegenstand,
// dessen Geschwindigkeit sich setzen ließe.
if settings.defaultRate != 1 { engine.setRate(settings.defaultRate) }
phase = .playing
diagnostics.note("Engine gestartet")
// Weder offline noch live gibt es einen Fortschritt zu melden.
if !isOffline, !isLive { startReporting() }
// Den Zustand des Stroms dagegen auch bei Live beobachten: dort
// hakt es am ehesten, und der billige Hebel kostet nichts.
startHealthWatch()
} catch {
diagnostics.finish(outcome: "Öffnen fehlgeschlagen: \(error)")
phase = .failed(message: String(localized: "player.loadFailed"))
}
}
/// Lässt den Server die Wiedergabe vorbereiten.
///
/// Was hier steht, ist für jeden Server dasselbe: die Entscheidung ist
/// gefallen, der Server soll sie ausführen. Wie er das tut — Plex' Universal
/// Transcoder, Jellyfins PlaybackInfo — steht in der jeweiligen Hülle.
private func source(
for decision: PlaybackDecision,
item: MediaItem,
media: MediaVersion,
part: MediaFile,
backend: any MediaBackend,
startingAt: Duration? = nil
) async -> PlaybackSource? {
let start = startingAt ?? request.startTime
let transcode: TranscodeSettings? = switch decision.method {
case .directPlay: nil
case .transcode(let maximumBitrate):
TranscodeSettings(
maximumBitrate: maximumBitrate,
resolution: quality.resolution,
// **Nur bei „automatisch".** Eine ausdrücklich gewählte Stufe
// ist eine Ansage, keine Obergrenze: wer „720p · 3 Mbps"
// antippt, bekommt das und nicht „höchstens 3, meistens
// weniger" unter unverändertem Etikett.
prefersAdaptive: quality == .automatic
)
}
do {
let target = try await backend.preparePlayback(PlaybackIntent(
itemID: item.id,
versionIndex: request.mediaIndex,
file: part,
transcode: transcode,
startTime: start,
isLocalConnection: isLocalConnection
))
playbackSession = target.sessionID
playbackVersionID = target.versionID
return PlaybackSource(
url: target.url,
origin: .remote,
startTime: start,
forwardBuffer: Self.forwardBuffer
)
} catch {
// Die Worte des Servers stehen auf dem Bildschirm, nicht „keine
// abspielbare Datei". Sagt er, warum er ablehnt, ist das die einzige
// brauchbare Spur — und genau sie ging vorher verloren.
phase = .failed(message: ErrorText.describe(error))
return nil
}
}
// MARK: - Steuerung
// MARK: - Die Qualität selbst nachregeln
/// Beobachtet den Strom und passt die Obergrenze an.
///
/// Läuft nur bei „automatisch". Eine ausdrücklich gewählte Stufe ist eine
/// Ansage und wird nicht überstimmt — `automatic` ist die Erlaubnis
/// einzugreifen, alles andere nicht.
private func startHealthWatch() {
healthTask?.cancel()
guard quality == .automatic, !isOffline else { return }
healthTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: Self.healthInterval)
guard !Task.isCancelled, let self else { return }
self.sampleStream()
}
}
}
/// Eine Probe nehmen und daraus handeln.
private func sampleStream() {
guard let engine, let startedAt = playbackStartedAt else { return }
guard let statistics = engine.statistics else { return }
let now = ContinuousClock.now - startedAt
let urteil = health.record(StreamSample(
at: now,
bufferedAhead: statistics.bufferedAhead,
isLikelyToKeepUp: statistics.isLikelyToKeepUp,
stallCount: statistics.stallCount,
indicatedBitrate: statistics.indicatedBitrate
))
// Erst der billige Hebel — er kostet kein schwarzes Bild.
if statistics.hasLadder {
adjustCeiling(for: urteil, statistics: statistics, at: now)
} else {
noteMissingLadder(urteil)
}
// Und dann die Frage, ob der teure Weg nötig ist.
considerTheHardWay(urteil, statistics: statistics, at: now)
}
// MARK: - Die Sicherheitsleine
/// Prüft, ob der Strom neu aufgebaut werden muss — und tut es.
private func considerTheHardWay(
_ condition: StreamCondition,
statistics: PlaybackStatistics,
at now: Duration
) {
guard !hasGivenUp, !isChangingQuality, resumeTarget == nil else { return }
let unterste = statistics.variantBitrates.first
let facts = DowngradeRule.Facts(
isAutomatic: quality == .automatic,
hasLadder: statistics.hasLadder,
isAtLowestRung: unterste.map { (automaticCeiling ?? .max) <= $0 } ?? false,
condition: condition,
stallsInWindow: stallLedger.count(at: now),
longestStallInWindow: stallLedger.longest(at: now),
playedFor: now,
sinceLastSeek: lastSeekAt.map { now - $0 },
sinceLastDowngrade: lastDowngradeAt.map { now - $0 },
used: downgradesUsed,
budget: downgradeBudget
)
switch DowngradeRule.verdict(facts) {
case .downgrade:
Task { await downgradeTheHardWay(at: now) }
case .budgetSpent:
// **Hier hört es auf und wird gesagt.** Lautlos die ganze Leiter
// hinunterzugehen ist schlimmer, als einmal zuzugeben, dass die
// Leitung es gerade nicht trägt.
hasGivenUp = true
diagnostics.note("Automatik aufgegeben — die Leitung trägt es nicht")
case .notAutomatic, .cheaperPathRemains, .notBadEnough, .tooEarly, .cooling:
break
}
}
/// Baut den Strom mit kleinerer Bitrate neu auf.
private func downgradeTheHardWay(at now: Duration) async {
guard let ziel = nextLowerRung() else {
hasGivenUp = true
diagnostics.note("Keine kleinere Stufe mehr übrig")
return
}
automaticCeiling = ziel
downgradesUsed += 1
lastDowngradeAt = now
diagnostics.note("Neuaufbau mit höchstens \(ziel) kbit — es hakte zu oft")
if isLive {
await retuneLive(to: ziel)
} else {
await restart()
}
}
/// Die nächste Sprosse unterhalb der aktuellen — aus **unserer** Leiter.
///
/// Nicht aus der Playlist: die hat in diesem Fall gerade keine, sonst
/// wären wir hier nicht. Die Sprossen richten sich nach der Höhe der
/// Quelle — eine 720p-Datei auf 1080p „hochzurechnen" bringt kein Bild
/// dazu.
private func nextLowerRung() -> Int? {
let aktuell = automaticCeiling
?? statistics?.indicatedBitrate
?? policy.maximumBitrate
?? 8_000
let sprossen = PlayerQuality.available(forSourceHeight: sourceHeight)
.compactMap { option -> Int? in
guard case .limited(let rung) = option else { return nil }
return rung.bitrate
}
.sorted()
return sprossen.last { $0 < aktuell }
}
/// Stimmt einen Sender mit kleinerer Bitrate neu ab.
///
/// **Der Tuner wird zuerst freigegeben.** Er ist ein Stück Hardware, vier
/// Stück gibt es, und ein doppelt belegter ist im Alltag schlimmer als
/// ein Stottern. Erst danach wird neu abgestimmt.
///
/// Bekannte Grenze: `endLiveSession` meldet nicht, **ob** die Freigabe
/// geklappt hat — das Protokoll gibt nichts zurück. Deshalb hier nur ein
/// Versuch je Wiedergabe und jeder Schritt im Protokoll, damit ein
/// verlorener Tuner wenigstens nachvollziehbar bleibt.
private func retuneLive(to bitrate: Int) async {
guard let liveTV, let subscription = request.liveSubscriptionKey else {
hasGivenUp = true
return
}
isChangingQuality = true
defer { isChangingQuality = false }
let stelle = currentTime
eventTask?.cancel()
engine?.teardown()
engine = nil
phase = .preparing
diagnostics.note("Tuner wird freigegeben")
await liveTV.endLiveSession(subscriptionKey: subscription)
let liste = await liveTV.liveChannels()
guard let sender = liste.channels.first(where: { $0.id == request.ratingKey }) else {
diagnostics.finish(outcome: "Sender nach dem Freigeben nicht mehr gefunden")
phase = .failed(message: String(localized: "player.stalled"))
return
}
let ergebnis = await liveTV.tune(
channel: sender,
program: nil,
maximumBitrate: bitrate
)
guard let url = ergebnis.url else {
diagnostics.finish(outcome: "Neues Abstimmen gescheitert")
phase = .failed(message: String(localized: "player.stalled"))
return
}
diagnostics.note("Neu abgestimmt mit höchstens \(bitrate) kbit")
let entscheidung = PlaybackDecision(method: .transcode(maximumBitrate: bitrate), rationale: [])
self.decision = entscheidung
await load(
PlaybackSource(url: url, origin: .remote, startTime: stelle),
decision: entscheidung
)
}
/// Setzt die Obergrenze eine Sprosse tiefer oder höher.
private func adjustCeiling(
for condition: StreamCondition,
statistics: PlaybackStatistics,
at now: Duration
) {
let sprossen = statistics.variantBitrates
guard let unterste = sprossen.first, let oberste = sprossen.last else { return }
let aktuell = automaticCeiling ?? statistics.indicatedBitrate ?? oberste
let ziel: Int?
switch condition {
case .failing:
ziel = unterste
case .strained:
// **Auf eine Sprosse, die es wirklich gibt.** Ein Wert zwischen
// zweien wählt dieselbe wie die untere und macht jede Anzeige
// darüber zur Lüge.
ziel = statistics.rung(below: aktuell) ?? unterste
case .healthy:
// Hinauf nur zögerlich, und nur nach einer Ruhefrist.
guard let lastStepUp, now - lastStepUp >= Self.stepUpCooldown else {
if lastStepUp == nil { self.lastStepUp = now }
return
}
guard let höher = sprossen.first(where: { $0 > aktuell }) else { return }
ziel = höher
}
guard let ziel, ziel != automaticCeiling else { return }
automaticCeiling = ziel
if condition == .healthy { lastStepUp = now }
// Die oberste Sprosse ist keine Grenze, sondern das Fehlen einer.
engine?.setPeakBitrate(ziel >= oberste ? nil : ziel)
diagnostics.note(String(
format: "Automatik: %@ → höchstens %d kbit",
String(describing: condition),
ziel
))
}
/// Protokolliert einmalig, dass es nichts zum Umschalten gibt.
private func noteMissingLadder(_ condition: StreamCondition) {
guard condition != .healthy, !hasNotedMissingLadder else { return }
hasNotedMissingLadder = true
diagnostics.note("Es hakt, aber die Playlist hat nur eine Stufe — nichts zum Ausweichen")
}
/// Führt Buch über das Stocken — die **zweite** Uhr.
///
/// Der Watchdog darunter ist ein Totmann: vierzig Sekunden am Stück ohne
/// Daten, dann Schluss. Er setzt sich bei jedem kurzen Erholen zurück,
/// und genau das ist sein blinder Fleck: zehn Rückler à dreißig Sekunden
/// lösen ihn nie aus, obwohl die halbe Stunde unbrauchbar war.
///
/// Dieses Buch zählt stattdessen mit, wie viel Zeit im gleitenden Fenster
/// fürs Warten draufging. Es **bricht nichts ab** — es stellt nur fest.
private func noteStalling(_ state: PlaybackState, wasPlaying: Bool) {
guard let startedAt = playbackStartedAt else {
// Der Nullpunkt ist das erste Bild, nicht das Antippen.
if state == .playing { playbackStartedAt = .now }
return
}
let now = ContinuousClock.now - startedAt
if state == .buffering {
// Nur aus dem Spielen heraus. Ein Stocken direkt nach einem
// Sprung oder beim Umschalten der Stufe ist keine Auskunft über
// die Leitung, sondern über das, was wir gerade selbst getan
// haben.
if wasPlaying, stallBegan == nil { stallBegan = now }
} else if let began = stallBegan {
let episode = StallLedger.Episode(endedAt: now, length: now - began)
stallLedger.record(episode)
stallBegan = nil
judgeConnection(at: now, episode: episode)
}
}
/// Fällt das Urteil über die Leitung — und schreibt es auf.
private func judgeConnection(at now: Duration, episode: StallLedger.Episode) {
let share = stallLedger.share(at: now)
let struggling = share >= 0.25 || episode.length >= .seconds(15)
guard struggling != isConnectionStruggling else { return }
isConnectionStruggling = struggling
if struggling {
diagnostics.note(String(
format: "Leitung trägt nicht: %d Rückler, %.0f %% Wartezeit",
stallLedger.count(at: now),
share * 100
))
} else {
diagnostics.note("Leitung hat sich erholt")
}
}
/// Beendet ewiges Laden mit einer Meldung statt mit einem Kreisel.
///
/// Ein Live-Stream kann sterben, ohne dass die Engine es merkt — der Tuner
/// gibt auf, der Transcoder bricht ab, und der Player wartet auf Daten,
/// die nie kommen. Ohne diese Grenze dreht sich der Kreisel bis zum
/// nächsten Antippen, und niemand weiß, ob noch etwas kommt.
private func watchBuffering(_ isBuffering: Bool) {
bufferWatchdog?.cancel()
guard isBuffering else { return }
bufferWatchdog = Task { [weak self] in
try? await Task.sleep(for: .seconds(40))
guard !Task.isCancelled, let self, self.isBuffering else { return }
diagnostics.note("Seit 40 s keine Daten — abgebrochen")
// Den Engine gleich stilllegen: er würde sonst weiter an einem
// toten Stream ziehen, und bei Live-TV bliebe der Tuner belegt.
silence()
phase = .failed(message: String(localized: "player.stalled"))
Task { await self.releaseOnServer() }
}
}
/// Holt die laufende Sendung nach, falls die angezeigte vorbei ist.
///
/// Beim Einschalten reist der Name mit; ein Sender läuft aber weiter,
/// während die Sendung wechselt. Nur dann abgefragt, wenn die alte
/// wirklich abgelaufen ist — das Raster über alle Sender zu holen ist
/// nichts, was man bei jedem Antippen tun will.
func refreshLiveProgrammeIfNeeded() async {
guard isLive, let liveTV else { return }
if let programmeEndsAt, programmeEndsAt > .now { return }
let now = Date()
// Nur dieser eine Sender. Vorher ging hier das Raster über **alle**
// Sender hinaus, weil der Plex-Weg keinen schmaleren kannte — beim
// neutralen Vertrag ist die Senderliste ein Parameter.
let programs = (try? await liveTV.livePrograms(
channelIDs: [request.ratingKey],
from: now,
to: now.addingTimeInterval(2 * 3_600)
)) ?? [:]
guard let match = programs[request.ratingKey]?.first(where: { $0.isRunning() }) else {
// Lieber gar nichts als etwas Falsches.
subtitle = nil
return
}
subtitle = match.title
programmeEndsAt = match.endsAt
}
/// Anhalten oder weiterlaufen lassen.
///
/// Das Warten auf Daten zählt hier zum Laufen. Ohne `isBuffering` in
/// dieser Bedingung führte ein Tipp in genau dem Moment zu `play()` auf
/// etwas, das bereits spielte — und sichtbar geschah nichts.
func togglePlayPause() {
guard let engine else { return }
if isPlaying || isBuffering { engine.pause() } else { engine.play() }
}
func seek(to time: Duration) {
guard let engine else { return }
if let startedAt = playbackStartedAt { lastSeekAt = ContinuousClock.now - startedAt }
Task { await engine.seek(to: time) }
}
/// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe.
func skip(by offset: Duration) {
guard let duration else { return }
seek(to: max(.zero, min(currentTime + offset, duration)))
}
/// Springt ans Ende der laufenden Marke.
func skipActiveMarker() {
guard let marker = activeMarker else { return }
skippedMarkers.insert(marker.id)
seek(to: .milliseconds(marker.endMilliseconds))
}
func selectAudioTrack(_ track: MediaTrack) {
engine?.selectAudioTrack(track)
selectedAudioTrack = track
}
/// `nil` schaltet die Untertitel ab.
func selectSubtitleTrack(_ track: MediaTrack?) {
engine?.selectSubtitleTrack(track)
selectedSubtitleTrack = track
}
/// Legt die Engine **sofort** still. Kein Netzwerk, kein Warten.
///
/// Getrennt vom Aufräumen am Server, und das ist der ganze Punkt: früher
/// stand die Engine erst nach drei Netzwerkaufrufen still. Wurde in der
/// Zwischenzeit etwas Neues gestartet, liefen zwei Engines gleichzeitig,
/// und der alte hat der neuen Wiedergabe beim Abräumen die Audiositzung
/// weggenommen. Was man hörte: der alte Ton lief weiter — auch nachdem
/// die App im Hintergrund war —, und das Neue blieb stumm.
func silence() {
resumeTarget = nil
eventTask?.cancel()
reportTask?.cancel()
engine?.teardown()
engine = nil
pictureInPicture = nil
pictureInPictureDelegate = nil
surface.playerLayer.player = nil
isPlaying = false
isBuffering = false
bufferWatchdog?.cancel()
// Das Buch gehört zur Quelle. Ein neuer Titel fängt bei null an.
stallLedger.removeAll()
playbackStartedAt = nil
stallBegan = nil
isConnectionStruggling = false
healthTask?.cancel()
healthTask = nil
health.removeAll()
automaticCeiling = nil
lastStepUp = nil
hasNotedMissingLadder = false
lastSeekAt = nil
lastDowngradeAt = nil
downgradesUsed = 0
hasGivenUp = false
NowPlaying.shared.release(for: self)
}
/// Meldet dem Server, dass Schluss ist. Darf dauern.
func releaseOnServer() async {
// Letzte Meldung vor dem Verlassen: erst sie nimmt den Titel auf dem
// Server aus „wird gerade abgespielt" heraus.
await report(state: .stopped)
// Den Transcoder anhalten. Ohne dieses Signal rechnet der Server
// minutenlang weiter, obwohl niemand mehr zusieht.
if let playbackSession, let backend {
await backend.endPlayback(sessionID: playbackSession)
}
// Und bei Live-TV den Tuner freigeben. Er ist ein Stück Hardware:
// vier Stück gibt es, und ein nicht freigegebener fehlt beim nächsten
// Einschalten — bis der Server von selbst merkt, dass niemand zusieht.
if let subscription = request.liveSubscriptionKey, let server = liveTV {
await server.endLiveSession(subscriptionKey: subscription)
}
}
func stop() async {
silence()
await releaseOnServer()
}
// MARK: - Fortschritt melden
private func startReporting() {
reportTask?.cancel()
reportTask = Task { [weak self] in
// Sofort, nicht erst nach zehn Sekunden: erst diese Meldung legt
// beim Server die Sitzung an, unter der alles Weitere läuft.
await self?.report(state: .started)
while !Task.isCancelled {
// Alle zehn Sekunden — so oft melden es auch die Apps von Plex
// selbst. Häufiger brächte nichts, seltener verlöre beim
// Abwürgen der App zu viel.
try? await Task.sleep(for: .seconds(10))
guard let self, !Task.isCancelled else { return }
await report(state: isPlaying ? .playing : .paused)
}
}
}
private func report(state: PlaybackReportState) async {
guard let backend, let item else { return }
// Die Laufzeit der Engine ist die genauere, aber sie steht erst ein
// paar Sekunden nach dem Öffnen fest. Für die erste Meldung tut es die
// des Servers — sonst fiele ausgerechnet der Bericht aus, der die
// Sitzung anlegt, und der Titel erschiene nirgends als „läuft gerade".
guard let total = duration ?? item.duration.map({ Duration.milliseconds($0) })
else { return }
// Beim Beginn steht die Zeit noch auf null — das ist keine fehlende
// Angabe, sondern der Anfang. Alle anderen Meldungen ohne Zeit wären
// dagegen wertlos und würden einen gemerkten Fortschritt überschreiben.
guard currentTime > .zero || state == .started else { return }
await backend.reportProgress(PlaybackReport(
itemID: item.id,
sessionID: playbackSession,
versionID: playbackVersionID,
state: state,
time: currentTime,
duration: total
))
}
// MARK: - Marken und nächste Folge
private func updateActiveMarker() {
guard let item else { return }
let milliseconds = currentTime.milliseconds
activeMarker = item.markers.first { marker in
guard marker.contains(milliseconds) else { return false }
// Der Abspann zählt nur als Marke, wenn danach noch etwas kommt —
// bei einem Film ist „Abspann überspringen" gleichbedeutend mit
// „Film beenden".
if marker.isCredits && item.kind != .episode { return false }
return true
}
guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return }
let shouldSkip = (marker.isIntro && settings.autoSkipIntro)
|| (marker.isCredits && settings.autoSkipCredits)
if shouldSkip { skipActiveMarker() }
}
private func preloadNextEpisode() {
guard let item, item.kind == .episode, let backend else { return }
Task { [weak self] in
let next = try? await backend.nextEpisode(after: item)
self?.nextEpisode = next
}
}
/// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den
/// Koordinator weiter.
func nextEpisodeRequest() -> PlaybackRequest? {
guard let nextEpisode else { return nil }
return PlaybackRequest(
ratingKey: nextEpisode.id,
title: nextEpisode.playbackTitle,
mediaIndex: 0,
startTime: .zero
)
}
// MARK: - Innereien
private func version(of item: MediaItem) -> MediaVersion? {
guard item.versions.indices.contains(request.mediaIndex) else { return item.preferredVersion }
return item.versions[request.mediaIndex]
}
/// Hängt die gehaltene Bildebene an den aktuellen Engine.
///
/// Nach **jedem** Engine-Wechsel nötig — also auch nach einem
/// Qualitätswechsel, der die Engine neu baut. Ohne das zeigt die Ebene
/// weiter auf einen abgeräumten Player und bleibt schwarz.
private func attachSurface(to engine: any VideoPlayerEngine) {
guard let avEngine = engine as? AVPlayerEngine else { return }
surface.playerLayer.player = avEngine.player
surface.playerLayer.videoGravity = .resizeAspect
guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
if pictureInPicture == nil {
let bridge = PictureInPictureBridge { [weak self] in
self?.restoreFromPictureInPicture?()
}
let controller = AVPictureInPictureController(playerLayer: surface.playerLayer)
#if os(iOS)
// Beim Verlassen der App ins Bild-in-Bild wechseln, statt die
// Wiedergabe anzuhalten. Auf dem Mac verschwindet eine App nicht
// in den Hintergrund — dort startet Bild-in-Bild von Hand.
controller?.canStartPictureInPictureAutomaticallyFromInline = true
#endif
controller?.delegate = bridge
pictureInPictureDelegate = bridge
pictureInPicture = controller
}
}
/// Was passieren soll, wenn der Nutzer aus Bild-in-Bild zurückkehrt.
///
/// Bisher gab es das nicht: Bild-in-Bild war eine Einbahnstraße, weil
/// niemand den Delegaten gesetzt hat.
func onPictureInPictureRestore(_ action: @escaping () -> Void) {
restoreFromPictureInPicture = action
}
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
#if canImport(FlixyVLC)
switch decision.engine {
case .vlc: VLCPlayerEngine()
// Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS, und
// genau darin wechselt AVPlayer die Bitrate von selbst.
case .avPlayer, .none: AVPlayerEngine()
}
#else
// Auf dem Mac gibt es VLC (noch) nicht: das Binärpaket ist auf iOS
// festgelegt. Was der Systemplayer nicht öffnen kann, scheitert dort
// mit einer Meldung, statt still schwarz zu bleiben.
AVPlayerEngine()
#endif
}
private func observe(_ engine: any VideoPlayerEngine) {
eventTask?.cancel()
eventTask = Task { [weak self] in
for await event in engine.events {
guard let self else { return }
switch event {
case .stateChanged(let state):
let wasPlaying = isPlaying
isPlaying = state == .playing
isBuffering = state == .buffering || state == .loading
noteStalling(state, wasPlaying: wasPlaying)
watchBuffering(isBuffering)
NowPlaying.shared.update(from: self)
// Jeder Zustandswechsel mit Zeitstempel. Erst daran ist
// abzulesen, ob die Engine hängt oder die Leitung.
diagnostics.note("Zustand: \(state)")
if case .failed(let failure) = state {
diagnostics.finish(outcome: "Wiedergabe gescheitert: \(failure)")
// Der Grund gehört auf den Bildschirm, nicht nur ins
// Protokoll: „die Wiedergabe wurde unterbrochen" allein
// ist eine Sackgasse — es sagt weder, ob der Server
// ablehnt, noch ob die Engine das Format nicht mag.
phase = .failed(message: [
String(localized: "player.playbackFailed"),
String(describing: failure),
].joined(separator: "\n\n"))
}
case .timeChanged(let time):
currentTime = time
// Gedrosselt in `NowPlaying`: zwischen zwei Meldungen
// zählt das System selbst weiter, es kennt das Tempo.
NowPlaying.shared.update(from: self)
// Angekommen: ab hier zeigt das Bild wieder die Stelle,
// an der man war.
if let target = resumeTarget, time + .seconds(2) >= target {
resumeTarget = nil
}
updateActiveMarker()
case .durationResolved(let value):
duration = value
NowPlaying.shared.update(from: self)
case .reachedEnd:
isPlaying = false
hasReachedEnd = true
// Grundlage fürs automatische Löschen nach dem Ansehen —
// und der Auslöser fürs Nachladen: der Vorrat ist jetzt
// um eine Folge kleiner.
downloads.markWatched(ratingKey: request.ratingKey)
Task { [backend, downloads] in
await DownloadRefiller(backend: backend, coordinator: downloads).refill()
}
case .tracksResolved(let audio, let subtitle):
audioTracks = audio
subtitleTracks = subtitle
applyPreferredLanguages()
}
}
}
}
/// Wählt die bevorzugten Sprachen, sofern es sie gibt.
///
/// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf
/// eine später gemeldete Spurliste das nicht zurücksetzen.
private func applyPreferredLanguages() {
if selectedAudioTrack == nil, !settings.preferredAudioLanguage.isEmpty,
let match = audioTracks.first(where: { $0.matches(language: settings.preferredAudioLanguage) }) {
selectAudioTrack(match)
}
if selectedSubtitleTrack == nil, !settings.preferredSubtitleLanguage.isEmpty,
let match = subtitleTracks.first(where: { $0.matches(language: settings.preferredSubtitleLanguage) }) {
selectSubtitleTrack(match)
}
}
private func playerSubtitle(for item: MediaItem) -> String? {
guard item.kind == .episode else { return nil }
if let season = item.parentIndex, let episode = item.index {
return "\(String(localized: "episode.short \(season) \(episode)")) · \(item.title)"
}
return item.title
}
}
extension MediaTrack {
/// Ob die Spur zu einem Sprachcode passt.
///
/// Verglichen wird nachsichtig: Plex meldet mal `de`, mal `deu`, mal
/// `ger`, und AVFoundation liefert manchmal `de-DE`. Ein strenger
/// Vergleich fände die Spur in der Hälfte der Fälle nicht.
func matches(language code: String) -> Bool {
guard let languageCode, !code.isEmpty else { return false }
let normalized = languageCode.lowercased().prefix(2)
return normalized == code.lowercased().prefix(2)
}
}
/// Verbindet Bild-in-Bild mit der App.
///
/// Ein eigener Typ, weil `AVPictureInPictureControllerDelegate` von `NSObject`
/// erben muss und `PlayerModel` ein `@Observable` ist. Ohne einen Delegaten
/// war Bild-in-Bild eine Einbahnstraße: man kam hinein, aber nicht zurück in
/// den Player.
private final class PictureInPictureBridge: NSObject, AVPictureInPictureControllerDelegate {
private let restore: () -> Void
init(restore: @escaping () -> Void) {
self.restore = restore
}
func pictureInPictureController(
_ controller: AVPictureInPictureController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completion: @escaping (Bool) -> Void
) {
restore()
completion(true)
}
}