**Eine Taste zum Vergrößern** unten rechts auf der Kachel. Das Antippen der Fläche vergrößert weiterhin, aber auf derselben Fläche liegen schon das Ziehen und das Aufziehen — und was dabei danebengeht, sieht aus wie „reagiert nicht". Ein Knopf geht nicht daneben. **Ton und Untertitel getrennt.** Es sind zwei verschiedene Fragen: die Tonspur stellt man einmal am Anfang um, die Untertitel mitten im Film. In einem gemeinsamen Blatt scrollte man jedes Mal an der anderen Liste vorbei. Das Untertitel-Symbol ist gefüllt, solange welche laufen — so sieht man es, ohne das Blatt zu öffnen. Und eine Auswahl schließt das Blatt jetzt selbst. **AirPlay.** Nicht nachgebaut, sondern die Systemstelle: sie kennt Geräte, die eine App nie aufzählen könnte. Der Knopf erscheint nur beim Systemplayer — VLC zeichnet selbst und kann kein AirPlay, und ein Knopf, der dort ins Leere führte, wäre schlimmer als keiner. **Die Qualität steht als Text da**, nicht als drei Schieberegler: „1080p", „720p", „Auto". Bei „automatisch" steht dort, was gerade tatsächlich ankommt — das weiß nur der Player, und ihn zu fragen ist ehrlicher, als „Auto" hinzuschreiben und die Antwort schuldig zu bleiben. Dazu die Einstellungen mit farbigen Symbolen und einer Kopfkarte je Bereich. Das ist nicht nur Zierde: in einer Liste aus zehn gleich aussehenden Zeilen findet das Auge nichts wieder, und „das mit dem blauen Zahnrad" ist eine brauchbare Beschreibung. Die Farben sind fest und ausdrücklich **nicht** die Akzentfarbe — trüge alles dieselbe, unterschiede sie nichts mehr. Und der Aufmacher läuft jetzt unter der Seitenleiste weiter (`backgroundExtensionEffect`): die Ränder werden gespiegelt und weich fortgeschrieben, während Titel und Knöpfe stehen bleiben. Vorher endete das Bild an der Kante der Leiste, und beim Weiterwandern schob sich das nächste an einer harten Linie ins Bild. 263 Tests grün. Player-Knöpfe und Einstellungen angesehen; den Aufmacher hinter der Leiste konnte ich an den einfarbigen Flächen der Attrappe nicht sicher beurteilen — der Simulator ist dabei erneut abgestürzt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
337 lines
12 KiB
Swift
337 lines
12 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 }
|
|
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)?
|
|
|
|
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
|
|
}
|
|
}
|