„Keks", „Keksglas", „Kekse" — eingedeutscht, wo es nichts zu verdeutschen gibt. Ein Cookie ist ein Cookie; wer danach sucht, sucht nicht nach Keksen, und wer den Quelltext liest, liest daneben die Schnittstelle, in der es `HTTPCookieStorage` heisst. Betrifft Kommentare und Prüfungsnamen, kein Verhalten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
630 lines
26 KiB
Swift
630 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
|
|
|
|
// **Cookies muss man AVFoundation ausdrücklich mitgeben.**
|
|
//
|
|
// `URLSession` liest den gemeinsamen Speicher von allein, `AVURLAsset`
|
|
// nicht. Für Jellyfin ist das belanglos — dort steht der Zugang in
|
|
// der Adresse. Flaro schreibt ihn bewusst nicht dorthin: er stünde
|
|
// sonst in jedem Zugriffsprotokoll und in jedem weitergegebenen Link.
|
|
// Ohne diese Zeile bekäme der Spieler dort 401 und zeigte Schwarz.
|
|
//
|
|
// Serverneutral: wo kein Cookie liegt, ist die Liste leer und es
|
|
// ändert sich nichts.
|
|
let cookies = HTTPCookieStorage.shared.cookies(for: source.url) ?? []
|
|
|
|
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,
|
|
AVURLAssetHTTPCookiesKey: cookies,
|
|
])
|
|
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. Dieselbe Annahme hat die App
|
|
// an anderer Stelle schon einmal hart abbrechen lassen.
|
|
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
|
|
}
|
|
}
|