Kein Video lief jemals an AVPlayerEngine.load() wartete auf asset.load(.duration) und die Spurenliste, BEVOR es zurückkehrte — und erst danach rief das Modell play(). AVFoundation muss für die Dauer aber so viel von der Datei holen, bis es den Index findet, und der sitzt bei MP4 wie MKV oft am Ende. Über eine langsame Leitung sind das Minuten, in denen nur ein Ladekreis zu sehen war, obwohl der Player längst hätte anfangen können. Jetzt kehrt load() zurück, sobald das Item hängt; Dauer und Spuren kommen nach und melden sich über den Ereignisstrom. Dazu AVURLAssetPreferPreciseDurationAndTimingKey: false — sonst durchsucht AVFoundation bei manchen Formaten die ganze Datei. Zweiter Fund an derselben Stelle: VLCMedia(url:) ist nullable. Ging es schief, blieb media still nil, play() tat nichts und es gab keinen Fehler — nur einen Ladekreis ohne Ende. Wird jetzt gemeldet. Kein Querformat im Player requestGeometryUpdate allein dreht nicht: das System dreht sofort zurück, solange die erlaubten Ausrichtungen es nicht hergeben. Die Erlaubnis kommt nur über den App-Delegaten, deshalb OrientationLock als Brücke. Player dreht ins Querformat, beim Verlassen zurück. iPad bleibt frei — dort ist Querformat beim Blättern der Normalfall. Uhrzeit über dem Film Die Statusleiste war nur ausgeblendet, solange die Bedienelemente weg waren. Jetzt immer. Sehr langer Start Zwei Ursachen. Erstens legte der Initialisierer der App die Hintergrund-URLSession an — die spricht über XPC mit nsurlsessiond und verzögerte den allerersten Bildaufbau. Jetzt erst bei Bedarf, und wer keine Downloads hat, braucht sie nie. Zweitens fragte jeder Start erst plex.tv nach der Serverliste und suchte die Verbindung neu. Die zuletzt funktionierende wird jetzt gemerkt (Server-Token im Schlüsselbund) und mit kurzer Zeitgrenze zuerst probiert; die vollständige Suche greift nur noch, wenn sie nicht mehr antwortet — etwa wenn man von zuhause weggeht. Dazu ein Hinweis im Player, wenn die Verbindung über die gedrosselte Plex-Vermittlung läuft. Sonst sucht man den Fehler in der App. 132 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
302 lines
11 KiB
Swift
302 lines
11 KiB
Swift
import AVFoundation
|
|
import Foundation
|
|
import PlexKit
|
|
|
|
/// 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 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)?
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
MainActor.assumeIsolated {
|
|
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
|
|
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
|
|
MainActor.assumeIsolated {
|
|
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
|
|
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
|
|
}
|
|
}
|