Schritt sieben. Wiedergabe, Spurwahl, Titelzeile, „Intro überspringen". **Die schwierige Stelle war die Spurwahl.** Unsere Ton- und Untertitelwahl geht als `audioStreamIndex` an Jellyfin und verlangt bei einem Transcode einen **neuen Stream**. Apples eingebauter Spurwähler zeigt dagegen die Auswahlgruppen des **laufenden** Streams — beim Transcode genau eine. Er ist die richtige Oberfläche für die falsche Liste. Unsere Menüs sitzen deshalb über `transportBarCustomMenuItems` daneben; `customInfoViewControllers` wäre falsch, denn die Tonspur ist ein Bedienelement der Wiedergabe und kein Begleitmaterial. **Der Neuaufbau wird angesagt, nicht erklärt.** Jeder Eintrag, der Sekunden mit schwarzem Bild kostet, trägt das als Untertitel — vorher, nicht hinterher. Wer drückt, sieht den Haken sofort wandern, weil das Menü sich schliesst und darin kein Platz für einen Kreisel ist. Das schwarze Bild deckt eine Überlagerung in `contentOverlayView` zu, die hinter den Bedienelementen liegt und keinen Fokus nimmt — `customOverlayViewController` täte beides nicht und machte die Fernbedienung währenddessen tot. Und solange umgebaut wird, sind alle Einträge gesperrt: sonst räumt `restart()` eine Engine ab, die eine andere gerade baut. **Die Regel dahinter ist jetzt prüfbar.** „Wann kostet ein Spurwechsel einen Neuaufbau" lag bisher mitten im Vollzug von `selectAudioStream` und liess sich nur an einem echten Server mit mehreren Tonspuren auslösen. Als `TrackMenu` steht sie neben den übrigen Entscheidungsregeln, mit sieben Tests — darunter der Fall, auf den der Kommentar dort seit jeher hinweist: stimmt die Spurzahl nicht überein, ist die Zuordnung über die Reihenfolge falsch, und es muss neu aufgebaut werden. **Zwei stille Fehler nebenbei.** Die Tonsitzung stand hinter `#if os(iOS)` und wäre auf tvOS nie gesetzt worden — verfügbar ist sie dort seit 9.0, man hätte es nur daran gemerkt, dass sich die Wiedergabe gegen anderen Ton nicht durchsetzt. Und `PlayerModel.downloads` ist jetzt optional statt eines leeren Koordinators: ein leerer bräuchte einen `ModelContainer`, und der ist das Einzige, wofür diese App SwiftData benutzt. **Belegt statt vermutet.** Im Simulator lief die Wiedergabe gegen die Attrappe, und die Titelzeile zeigte „Unbekannt". Statt an AVKit zu drehen, hat ein Protokolleintrag die Frage beantwortet: `externalMetadata` wird gesetzt, der Wert stimmt, und „Unbekannt" ist wörtlich das, was die Attrappe für unbekannte Kennungen liefert. Geblieben ist davon nur die Erkenntnis, dass die Beschriftung auf das Item warten muss — beim Austausch des Players gibt es noch keines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
617 lines
26 KiB
Swift
617 lines
26 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
|
|
/// 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.
|
|
///
|
|
/// Auf iOS **und tvOS**. Gekapselt statt an drei Stellen mit `#if` versehen —
|
|
/// macOS steht in diesem Paket ohnehin nur, damit die Logik-Tests ohne
|
|
/// Simulator laufen.
|
|
///
|
|
/// **tvOS stand hier lange nicht**, und das war ein stiller Fehler: die
|
|
/// Kategorie wäre dort verfügbar (`AVAudioSessionCategoryPlayback` ist
|
|
/// `tvos(9.0)`), wurde aber nie gesetzt. Man hätte es nicht am Übersetzer
|
|
/// gemerkt, sondern erst daran, dass sich die Wiedergabe gegen anderen Ton
|
|
/// nicht durchsetzt.
|
|
enum AudioSession {
|
|
static func activate() {
|
|
#if os(iOS) || os(tvOS)
|
|
// 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) || os(tvOS)
|
|
// `notifyOthersOnDeactivation` lässt die Musik-App wieder anlaufen,
|
|
// die vor dem Film gespielt hat.
|
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
#endif
|
|
}
|
|
}
|