Files
Flaro/Packages/HaloKit/Sources/HaloPlayback/Engine/AVPlayerEngine.swift
T
ScarriffleandClaude Opus 5 4b9a1e7b67 Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit,
damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind.

712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25
Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit
`HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`,
`JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben.

`docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind
ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben
hiesse, die Vergangenheit zu glätten.

**Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis:
`com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte
Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container;
Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu
laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen.

**Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der
Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr
heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter
`…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur
anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim
Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor
dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet.

Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht
statt umbenannt — sie steht ohnehin nicht im Repo.

Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist
ein Handgriff ausserhalb des Projekts.

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 11:12:32 +02:00

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. 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
}
}