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 = { AsyncStream { continuation in self.continuation = continuation } }() private var continuation: AsyncStream.Continuation? private var timeObserver: Any? private var metadataTask: Task? /// 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 /// Ob gerade ein Sprung an die gemerkte Stelle läuft. private var isSeekingToStart = false 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() // **AVPlayer wählt Untertitel sonst selbst — und schaltet sie ab.** // // Diese Eigenschaft steht von Haus aus auf `true`: der Player richtet // sich dann nach den Untertiteleinstellungen des Systems. Die stehen // in aller Regel auf „aus", und damit nimmt er jede Wahl, die wir // treffen, wieder zurück — lautlos, und erst nachdem wir sie gesetzt // haben. // // Zu sehen war das als „ich schalte Untertitel ein und sehe nichts": // der Server lieferte sie, die Spur war im Stream, ausgewählt war sie // auch, und angezeigt wurde trotzdem nichts. // // Beim Ton ändert das Abschalten nichts: welche Spur klingt, ist // längst beim Aushandeln entschieden, und bei einem umgerechneten // Stream kommt ohnehin nur eine an. player.appliesMediaSelectionCriteriaAutomatically = false } 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 = [] // Sie gehören zum Stream, nicht zur Engine — sonst hielte die Liste // zwischen zwei Aufbauten die Spuren des alten. audioTracks = [] subtitleTracks = [] isSeekingToStart = false 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 guard let item = player.currentItem else { return } item.textStyleRules = style.textStyleRules // **Und die Spur neu wählen, sonst bleibt es bei der alten Form.** // // `textStyleRules` wird nur gelesen, wenn die Untertitelspur // ausgewählt wird — eine laufende Einblendung zeichnet AVFoundation // davon nicht neu. Das war genau der Eindruck: man stellt um, es // passiert nichts, und erst wer die Untertitel aus- und wieder // einschaltet, sieht die Änderung. Das Aus- und Einschalten ist die // Neuwahl — also machen wir sie selbst. // // Abgewählt und wieder angewählt wird dieselbe Option, nicht die // erste: bei mehreren Spuren wäre das ein stiller Sprachwechsel. guard let group = item.asset.mediaSelectionGroup(forMediaCharacteristic: .legible), let gewählt = item.currentMediaSelection.selectedMediaOption(in: group) else { return } item.select(nil, in: group) item.select(gewählt, in: group) } /// 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 } } } ) // **Ein zweiter Anlass für den Sprung.** // // Bei einer Abspielliste wächst der springbare Bereich, während der // Server produziert. Ein Sprung, der bei „bereit" noch ins Leere // ging, kann Sekunden später gelingen — und ohne diesen Beobachter // gäbe es niemanden, der es noch einmal versucht. Die Stelle bleibt // gemerkt, bis ein Sprung bestätigt ist; danach tut der Beobachter // nichts mehr. observations.append( item.observe(\.seekableTimeRanges, options: [.new]) { [weak self] _, _ in Task { @MainActor in self?.applyPendingStartTime() } } ) // 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. /// /// **Und merkt sich, ob es geklappt hat.** Vorher wurde die Stelle /// verbraucht, *bevor* gesprungen wurde, und der Rückgabewert von /// `seek` verworfen. Misslang der Sprung — und bei einem gerade erst /// entstehenden Transcode ist das der Normalfall, nicht die Ausnahme —, /// merkte es niemand und niemand wiederholte ihn. Das Fortsetzen hängt an /// genau diesem einen Sprung, weil der Server bewusst bei null beginnt. /// /// Jetzt bleibt die Stelle stehen, bis ein Sprung bestätigt ist, und /// jede Änderung am springbaren Bereich ist ein neuer Anlass, es zu /// versuchen. private func applyPendingStartTime() { guard pendingStartTime > .zero, !isSeekingToStart else { return } let target = pendingStartTime isSeekingToStart = true Task { [weak self] in let geschafft = await self?.seekReportingSuccess(to: target) ?? false guard let self else { return } isSeekingToStart = false if geschafft { pendingStartTime = .zero } } } /// Springt und sagt, ob es getragen hat. /// /// Die Toleranz ist bewusst grosszügig: framegenau in einen Stream zu /// springen, den der Server gerade erst erzeugt, ist der teuerste und /// fragilste Fall. Eine halbe Sekunde daneben merkt niemand, ein /// misslungener Sprung dagegen kostet den ganzen Fortschritt. private func seekReportingSuccess(to time: Duration) async -> Bool { let target = CMTime(seconds: time.seconds, preferredTimescale: 600) let geschafft = await player.seek( to: target, toleranceBefore: CMTime(seconds: 0.5, preferredTimescale: 600), toleranceAfter: CMTime(seconds: 0.5, preferredTimescale: 600) ) if geschafft { continuation?.yield(.timeChanged(time)) } return geschafft } 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 } }