Files
Flaro/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift
T
ScarriffleandClaude Opus 5 0815432efd Untertitel lassen sich jetzt einstellen
Groesse in vier Stufen, Schrift und Hintergrund als fuenf feste Paare —
Weiss, Weiss auf Schwarz, Schwarz auf Weiss, Gelb, Gelb auf Schwarz. Feste
Paare statt zweier Regler, weil unabhaengige Achsen unlesbare Mischungen
zulassen: Schwarz auf durchsichtig ueber einer Nachtszene ist eine
gueltige Einstellung und eine wertlose.

Die Uebersetzung nach AVFoundation ist eine Tabelle aus Schluesseln, bei
der jeder Fehler lautlos ist — ein falscher Schluessel laesst den
Initialisierer `nil` zurueckgeben, und dann gibt es gar keine Regel. Auf
dem Bildschirm sieht „keine Regel\" genauso aus wie „Regel ohne Wirkung\".
Zehn Tests decken das ab, darunter die drei Fallen: Alpha zuerst in der
Farbe, Prozentwert statt Punktgroesse, und der Hintergrund auch fuer
„durchsichtig\" ausdruecklich gesetzt statt weggelassen — weggelassen gilt
sonst, was Jellyfins WebVTT selbst mitbringt, und das ist oft ein
schwarzer Kasten.

Als Protokollmethode, nicht als Feld an `PlaybackSource`: die wird einmal
beim Oeffnen gelesen, und eine Einstellung, die erst beim naechsten Film
wirkt, haelt jeder fuer kaputt. Angewandt wird an zwei Stellen — am
laufenden Gegenstand und am frisch angelegten. Nur das erste zu tun hiesse,
dass die naechste Folge wieder in der Vorgabe erscheint.

VLC erbt die leere Vorgabe. Die Einstellungsseite sagt beides: dass es fuer
den Standardplayer gilt, und dass eingebrannte Bilduntertitel von Blu-rays
sich nicht gestalten lassen, weil es dort keinen Text gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 21:22:22 +02:00

520 lines
21 KiB
Swift

import AVFoundation
import Foundation
import MediaKit
/// Wiedergabe über den Systemplayer.
///
/// Der bevorzugte Weg, wann immer das Format ihn zulässt: Bild-in-Bild,
/// AirPlay, die Steuerung im Sperrbildschirm und der Umgang mit
/// Unterbrechungen durch Anrufe kommen alle mit, ohne dass etwas davon
/// nachgebaut werden müsste.
@MainActor
public final class AVPlayerEngine: VideoPlayerEngine {
/// Wird von der Ansicht gebraucht, um die Bildebene zu bauen. Der einzige
/// Grund, warum AVFoundation überhaupt nach außen sichtbar ist.
public let player = AVPlayer()
public private(set) var state: PlaybackState = .idle {
didSet {
guard state != oldValue else { return }
noteStall(from: oldValue, to: state)
continuation?.yield(.stateChanged(state))
}
}
public private(set) var duration: Duration?
public private(set) var audioTracks: [MediaTrack] = []
public private(set) var subtitleTracks: [MediaTrack] = []
public var currentTime: Duration {
let seconds = player.currentTime().seconds
guard seconds.isFinite, seconds >= 0 else { return .zero }
return .seconds(seconds)
}
public let supportsPictureInPicture = true
public let supportsAirPlay = true
public private(set) lazy var events: AsyncStream<PlaybackEvent> = {
AsyncStream { continuation in
self.continuation = continuation
}
}()
private var continuation: AsyncStream<PlaybackEvent>.Continuation?
private var timeObserver: Any?
private var metadataTask: Task<Void, Never>?
/// Wohin gesprungen werden soll, sobald der Player bereit ist.
///
/// Ein `seek` vor `readyToPlay` verwirft AVPlayer kommentarlos — das
/// Fortsetzen landete dann wieder am Anfang.
private var pendingStartTime: Duration = .zero
private var observations: [NSKeyValueObservation] = []
private var endObserver: (any NSObjectProtocol)?
private var stallObserver: (any NSObjectProtocol)?
/// Die Sprossen der Playlist, aufsteigend, in Kilobit je Sekunde.
///
/// Leer, solange nichts geladen ist — und leer bleibt sie auch bei einer
/// Datei, die gar keine Playlist ist. Genau daran unterscheidet
/// `statistics` später „keine Leiter" von „eine Stufe".
private var variantBitrates: [Int] = []
/// Ob überhaupt jemand spielen wollte.
///
/// **Der Unterschied zwischen „pausiert" und „kommt nicht voran".**
/// `timeControlStatus` meldet beides als `.paused`: das Anhalten durch
/// den Nutzer und den Fall, dass `play()` gerufen wurde, der Player aber
/// nicht losläuft — weil die Quelle noch nicht bereit ist oder weil nichts
/// ankommt. Ohne diese Unterscheidung zeigte der Player ein
/// Abspielsymbol, als hätte man selbst angehalten, während in Wahrheit
/// auf Daten gewartet wurde. Wer dann tippte, drückte auf Play bei etwas,
/// das längst laufen sollte.
private var wantsToPlay = false
/// Wie oft es gestockt hat, seit diese Quelle läuft.
///
/// Aus `playbackStalledNotification` und **nicht** aus dem
/// Zugriffsprotokoll: die Meldung kommt bei jeder Quelle, das Protokoll
/// nur bei Streamsn über das Netz. Beides zu addieren zählte doppelt.
private var subtitleStyle: SubtitleStyle?
private var stallCount = 0
/// Aufsummierte Wartezeit. Siehe `noteStall(from:to:)`.
private var accumulatedStall: Duration = .zero
private var bufferingSince: ContinuousClock.Instant?
public init() {
AudioSession.activate()
}
public func load(_ source: PlaybackSource) async throws(PlaybackFailure) {
teardownObservers()
state = .loading
// Die Buchführung gehört zur Quelle, nicht zur Engine: ein neuer Titel
// fängt bei null an, sonst schleppte die Zahl den vorigen Film mit.
variantBitrates = []
wantsToPlay = false
stallCount = 0
accumulatedStall = .zero
bufferingSince = nil
pendingStartTime = source.startTime
let asset = AVURLAsset(url: source.url, options: [
// Keine genaue Dauer verlangen. Sonst durchsucht AVFoundation bei
// manchen Formaten die ganze Datei, bevor es überhaupt anfängt —
// und „ganze Datei" heißt bei einem Remux vierzig Gigabyte.
AVURLAssetPreferPreciseDurationAndTimingKey: false,
])
let item = AVPlayerItem(asset: asset)
// **Vorrat statt Reaktion.**
//
// Ohne diese Angabe legt AVPlayer so viel an, wie es für einen
// zügigen Start braucht — und keinen Meter mehr. Das ist richtig, wenn
// die Leitung trägt, und falsch, wenn sie aussetzt: ein Loch von
// fünf Sekunden trifft dann auf einen Puffer von fünf Sekunden, und
// das Bild steht.
//
// Der Preis ist ehrlich: der Start dauert länger, und wer springt,
// wirft den angelegten Vorrat weg. Beides ist weniger schlimm als ein
// Rückler mitten im Satz.
if let vorrat = source.forwardBuffer {
item.preferredForwardBufferDuration = TimeInterval(
Double(vorrat.components.seconds)
)
}
// **Auch am frischen Gegenstand.** Er beginnt ungestaltet, und ohne
// diese Zeile fiele jede weitere Folge auf die Vorgabe zurück.
if let subtitleStyle {
item.textStyleRules = subtitleStyle.textStyleRules
}
player.replaceCurrentItem(with: item)
observe(item: item)
observePlayer()
// Hier ist Schluss. Dauer und Spuren werden NICHT abgewartet.
//
// Genau daran hing der Player vorher fest: `asset.load(.duration)`
// holt so viel von der Datei, bis der Index gefunden ist. Bei MP4 und
// MKV sitzt der oft am Ende — über eine langsame Leitung dauert das
// Minuten, und in dieser Zeit stand nur ein Ladekreis da, obwohl
// AVPlayer längst hätte anfangen können.
//
// Beides kommt jetzt nach, sobald es da ist, und meldet sich über den
// Ereignisstrom. Bis dahin läuft das Bild schon.
loadMetadataInBackground(from: asset)
}
/// Holt Dauer und Spuren nach, ohne die Wiedergabe aufzuhalten.
private func loadMetadataInBackground(from asset: AVURLAsset) {
metadataTask?.cancel()
metadataTask = Task { [weak self] in
guard let self else { return }
if let assetDuration = try? await asset.load(.duration),
assetDuration.isNumeric,
!Task.isCancelled {
let value = Duration.seconds(assetDuration.seconds)
duration = value
continuation?.yield(.durationResolved(value))
}
guard !Task.isCancelled else { return }
// **Die Leiter.** Hier und nicht vorher: die Frage „wie viele
// Stufen hat diese Playlist" beantwortet der Parser des Players,
// aber sie zu beantworten kostet einen Abruf — und der darf den
// Start so wenig aufhalten wie Dauer und Spuren.
//
// Bei einer Datei kommt eine leere Liste zurück. Das ist keine
// Leiter mit null Sprossen, sondern gar keine Leiter, und genau
// so wird es unten auch gemeldet.
if let variants = try? await asset.load(.variants), !Task.isCancelled {
variantBitrates = variants
.compactMap(\.peakBitRate)
.map { Int(($0 / 1_000).rounded()) }
.filter { $0 > 0 }
.sorted()
}
guard !Task.isCancelled else { return }
// Scheitert die Spurenliste, bleibt die Wiedergabe trotzdem
// laufen — es fehlt dann nur die Auswahl im Menü. Das ist ein
// besserer Zustand als ein Abbruch.
try? await loadTracks(from: asset)
}
}
public func play() {
wantsToPlay = true
player.play()
}
public func pause() {
wantsToPlay = false
player.pause()
}
public func seek(to time: Duration) async {
let target = CMTime(seconds: time.seconds, preferredTimescale: 600)
// Genau springen statt zum nächsten Schlüsselbild: beim Zurückspringen
// um zehn Sekunden landet man sonst je nach Datei bis zu mehreren
// Sekunden daneben.
await player.seek(to: target, toleranceBefore: .zero, toleranceAfter: .zero)
continuation?.yield(.timeChanged(time))
}
public func setPeakBitrate(_ kilobits: Int?) {
guard let item = player.currentItem else { return }
// Null heißt bei AVFoundation „keine Grenze". Kein Sonderfall, nur
// eine andere Schreibweise für dasselbe.
item.preferredPeakBitRate = kilobits.map { Double($0) * 1_000 } ?? 0
}
public func setRate(_ rate: Double) {
player.rate = Float(rate)
}
public func selectAudioTrack(_ track: MediaTrack?) {
select(track, in: .audible)
}
public func selectSubtitleTrack(_ track: MediaTrack?) {
select(track, in: .legible)
}
/// Gestaltet die Untertitel — sofort, und auch beim nächsten Film.
///
/// **Beides ist nötig.** Ein frisches `AVPlayerItem` beginnt ungestaltet;
/// wer nur das laufende anfasst, sieht die nächste Folge wieder in der
/// Vorgabe. Deshalb wird der Wunsch gemerkt und in `load(_:)` erneut
/// angelegt.
public func applySubtitleStyle(_ style: SubtitleStyle) {
subtitleStyle = style
player.currentItem?.textStyleRules = style.textStyleRules
}
/// Was gerade über die Leitung kommt.
///
/// Aus dem Zugriffsprotokoll des Players: es führt Buch über jede
/// gezogene Stufe. Der **letzte** Eintrag ist der aktuelle; die früheren
/// beschreiben, was vorher lief.
public var statistics: PlaybackStatistics? {
guard let item = player.currentItem else { return nil }
let events = item.accessLog()?.events ?? []
let event = events.last
func kilobits(_ value: Double?) -> Int? {
guard let value, value > 0 else { return nil }
return Int((value / 1_000).rounded())
}
let size = item.presentationSize
return PlaybackStatistics(
observedBitrate: kilobits(event?.observedBitrate),
indicatedBitrate: kilobits(event?.indicatedBitrate),
width: size.width > 0 ? Int(size.width) : nil,
height: size.height > 0 ? Int(size.height) : nil,
// **Über alle Einträge summiert, nicht nur der letzte.** Das
// Protokoll legt bei jedem Stufenwechsel einen neuen an, und
// dessen Zähler beginnt wieder bei null. Wer nur `last` liest,
// verliert nach jedem Wechsel die ganze Vorgeschichte — und sieht
// eine Wiedergabe, die schon zwanzigmal Bilder verworfen hat, als
// makellos an.
droppedFrames: events.isEmpty
? nil
: events.reduce(0) { $0 + max(0, $1.numberOfDroppedVideoFrames) },
variantCount: variantBitrates.isEmpty ? nil : variantBitrates.count,
variantBitrates: variantBitrates,
bufferedAhead: bufferedAhead(in: item),
isLikelyToKeepUp: item.isPlaybackLikelyToKeepUp,
stallCount: stallCount,
stalledTime: stalledTime
)
}
/// Wie weit der Puffer vor der Abspielstelle reicht.
///
/// Gibt `nil` zurück, wenn noch gar nichts geladen ist — das ist etwas
/// anderes als ein leerer Puffer und darf nicht als Alarm durchgehen.
/// Liegen dagegen Bereiche vor, in denen die Abspielstelle **nicht**
/// liegt, ist der Puffer an genau dieser Stelle leer, und das ist eine
/// Aussage: null.
private func bufferedAhead(in item: AVPlayerItem) -> Duration? {
let ranges = item.loadedTimeRanges.map(\.timeRangeValue)
guard !ranges.isEmpty else { return nil }
let now = item.currentTime()
guard now.isNumeric else { return nil }
for range in ranges where range.containsTime(now) {
let end = range.end
guard end.isNumeric else { continue }
return .seconds(max(0, (end - now).seconds))
}
return .zero
}
/// Die Wartezeit einschließlich der gerade laufenden.
private var stalledTime: Duration {
guard let bufferingSince else { return accumulatedStall }
return accumulatedStall + (ContinuousClock.now - bufferingSince)
}
/// Buchführung über das Stocken.
///
/// Gezählt wird **nur** der Weg vom Spielen ins Warten. Das anfängliche
/// Puffern einer frisch geladenen Quelle ist kein Rückler — es ist der
/// Start. Würde es mitgezählt, hätte jede Wiedergabe von vornherein eine
/// Vorstrafe, und eine Regel wie „zwei Rückler in einer Minute" löste
/// schon beim Anschauen des ersten Bildes aus.
private func noteStall(from previous: PlaybackState, to current: PlaybackState) {
if current == .buffering {
if previous == .playing { bufferingSince = .now }
} else if let since = bufferingSince {
accumulatedStall += ContinuousClock.now - since
bufferingSince = nil
}
}
public func teardown() {
teardownObservers()
player.replaceCurrentItem(with: nil)
continuation?.finish()
continuation = nil
state = .idle
AudioSession.deactivate()
}
// MARK: - Spurwahl
private func select(_ track: MediaTrack?, in characteristic: AVMediaCharacteristic) {
guard let item = player.currentItem,
let group = item.asset.mediaSelectionGroup(forMediaCharacteristic: characteristic)
else { return }
guard let track, let index = track.engineIdentifier, group.options.indices.contains(index) else {
// `nil` schaltet ab — bei Untertiteln der Normalfall, bei Ton
// sollte es nicht vorkommen.
item.select(nil, in: group)
return
}
item.select(group.options[index], in: group)
}
private func loadTracks(from asset: AVURLAsset) async throws {
func tracks(
for characteristic: AVMediaCharacteristic,
kind: MediaTrack.Kind
) async throws -> [MediaTrack] {
guard let group = try await asset.loadMediaSelectionGroup(for: characteristic) else { return [] }
return group.options.enumerated().map { index, option in
MediaTrack(
id: "av-\(kind)-\(index)",
kind: kind,
displayName: option.displayName,
languageCode: option.extendedLanguageTag,
engineIdentifier: index,
isForced: option.hasMediaCharacteristic(.containsOnlyForcedSubtitles)
)
}
}
audioTracks = try await tracks(for: .audible, kind: .audio)
subtitleTracks = try await tracks(for: .legible, kind: .subtitle)
continuation?.yield(.tracksResolved(audio: audioTracks, subtitle: subtitleTracks))
}
// MARK: - Beobachtung
private func observePlayer() {
observations.append(
player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
// Kein `assumeIsolated`: KVO meldet auf dem Thread, der die
// Änderung ausgelöst hat, und AVPlayer ändert seinen Zustand
// auch aus internen Threads. Genau dieselbe Annahme hat die App
// im VLC-Engine zum Absturz gebracht.
Task { @MainActor in
guard let self else { return }
switch player.timeControlStatus {
case .playing: self.state = .playing
case .paused:
guard self.state != .ended else { break }
// Stillstand mit Absicht ist Pause. Stillstand ohne
// Absicht ist Warten — und gehört als solches
// gemeldet, nicht als Pause.
self.state = self.wantsToPlay ? .buffering : .paused
case .waitingToPlayAtSpecifiedRate: self.state = .buffering
@unknown default: break
}
}
}
)
// Viermal je Sekunde: fein genug, dass der Schieberegler flüssig
// wirkt, grob genug, dass er nicht jede Bildwiederholung antreibt.
timeObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.25, preferredTimescale: 600),
queue: .main
) { [weak self] time in
// Hier ist `assumeIsolated` belegt statt geraten: die Warteschlange
// steht eine Zeile darüber. Viermal je Sekunde eine Aufgabe
// einzureihen wäre unnötiger Aufwand.
MainActor.assumeIsolated {
guard let self, time.isNumeric else { return }
self.continuation?.yield(.timeChanged(.seconds(time.seconds)))
}
}
}
private func observe(item: AVPlayerItem) {
observations.append(
item.observe(\.status, options: [.new]) { [weak self] item, _ in
// Siehe oben: auch dieser Rückruf kommt nicht verlässlich vom
// Hauptthread.
Task { @MainActor in
guard let self else { return }
switch item.status {
case .failed:
self.state = .failed(PlaybackFailure(
kind: .sourceUnavailable,
underlyingDescription: item.error.map { String(describing: $0) }
))
case .readyToPlay:
self.applyPendingStartTime()
case .unknown:
break
@unknown default:
break
}
}
}
)
// Der ausdrückliche Stocker-Melder des Systems.
//
// Er kommt **nur**, wenn die Wiedergabe schon lief und dann stehen
// blieb — beim anfänglichen Puffern schweigt er. Damit ist er der
// ehrlichste Zähler, den es gibt, und der Massstab, an dem sich am
// Ende ablesen lässt, ob es besser wurde.
stallObserver = NotificationCenter.default.addObserver(
forName: AVPlayerItem.playbackStalledNotification,
object: item,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.stallCount += 1 }
}
endObserver = NotificationCenter.default.addObserver(
forName: AVPlayerItem.didPlayToEndTimeNotification,
object: item,
queue: .main
) { [weak self] _ in
// Ebenfalls ausdrücklich auf der Hauptwarteschlange.
MainActor.assumeIsolated {
guard let self else { return }
self.state = .ended
self.continuation?.yield(.reachedEnd)
}
}
}
/// Springt an die gemerkte Stelle, sobald der Player bereit ist.
private func applyPendingStartTime() {
guard pendingStartTime > .zero else { return }
let target = pendingStartTime
pendingStartTime = .zero
Task { await seek(to: target) }
}
private func teardownObservers() {
metadataTask?.cancel()
metadataTask = nil
observations.forEach { $0.invalidate() }
observations.removeAll()
if let timeObserver {
player.removeTimeObserver(timeObserver)
self.timeObserver = nil
}
if let endObserver {
NotificationCenter.default.removeObserver(endObserver)
self.endObserver = nil
}
if let stallObserver {
NotificationCenter.default.removeObserver(stallObserver)
self.stallObserver = nil
}
}
}
/// Die Tonsitzung des Systems.
///
/// Nur auf iOS vorhanden. Gekapselt statt an drei Stellen mit `#if` versehen —
/// macOS steht in diesem Paket ohnehin nur, damit die Logik-Tests ohne
/// Simulator laufen.
enum AudioSession {
static func activate() {
#if os(iOS)
// Ohne diese Kategorie schweigt die Wiedergabe, sobald der
// Stummschalter am Gerät umgelegt ist — und Bild-in-Bild gäbe es gar
// nicht.
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try? AVAudioSession.sharedInstance().setActive(true)
#endif
}
static func deactivate() {
#if os(iOS)
// `notifyOthersOnDeactivation` lässt die Musik-App wieder anlaufen,
// die vor dem Film gespielt hat.
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
}