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 } 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 private var observations: [NSKeyValueObservation] = [] private var endObserver: (any NSObjectProtocol)? public init() { AudioSession.activate() } public func load(_ source: PlaybackSource) async throws(PlaybackFailure) { teardownObservers() state = .loading 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) 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 } // 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() { player.play() } public func pause() { 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 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) } /// 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 event = item.accessLog()?.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, droppedFrames: event.map { max(0, $0.numberOfDroppedVideoFrames) } ) } 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: if self.state != .ended { self.state = .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 } } } ) 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 } } } /// 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 } }