Bisher hieß `PlayerQuality.automatic` wörtlich: nimm die Zahl, die für WLAN oder Mobilfunk hinterlegt ist. Keine Messung, kein Nachregeln — die Entscheidung fiel einmal beim Start und galt bis zum Ende. `StreamHealth` beurteilt den Strom jetzt fortlaufend aus Proben im Sekundentakt. Ein reiner Typ: kein AVFoundation, keine Uhr, kein Netz. Damit lässt sich eine schlechte Zugfahrt als Zahlenreihe hinschreiben und nachsehen, was sie auslöst — zehn Tests tun genau das. **Welches Signal wann meldet**, ist der eigentliche Inhalt. Der Pufferverlauf ist der einzige echte Frühwarner: schrumpft er schneller, als die Zeit vergeht, kommt weniger herein als hinausgeht, und das sieht man zehn bis zwanzig Sekunden vor dem Rückler. `isLikelyToKeepUp` ist früh, aber sprunghaft — es zählt erst, wenn es anhält. Der Rücklerzähler meldet zu spät zum Regeln und bleibt trotzdem der Massstab, an dem sich hinterher ablesen lässt, ob es besser wurde. Die **gemessene** Bitrate steht bewusst nicht drin: auf einer kleinen Sprosse holt der Player wenig, ohne dass die Leitung schlecht wäre. Ausdrücklich kein Bandbreitentest. Der kostet Daten, ist beim Vorliegen veraltet und misst die Vergangenheit. Der Pufferverlauf ist die bessere Messung, weil er die einzige Größe misst, auf die es ankommt: lädt es schneller, als es läuft. Gehandelt wird über `preferredPeakBitRate` — der billige Hebel, der ohne Neuaufbau wirkt. Gezielt wird dabei auf eine Sprosse, **die es in `AVAsset.variants` wirklich gibt**: ein Wert zwischen zweien wählt dieselbe wie die untere und machte jede Anzeige darüber zur Lüge. Absichtlich unsymmetrisch — runter sofort, hinauf frühestens nach einer Minute Ruhe. Und getrennt von `quality`: die Automatik ruft **nicht** `select(quality:)`. Die Methode schreibt `rememberedQuality` fort und setzte eine manuelle Stufe — womit die Automatik sich selbst abschaltete und eine schlechte Zugfahrt dauerhaft das Wohnzimmer verschlechterte. Hat die Playlist nur eine Stufe, greift der Hebel ins Leere. Das ist kein Fehler, sondern der Fall, für den die Sicherheitsleine gebaut wird — bis dahin steht er wenigstens im Protokoll. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
502 lines
20 KiB
Swift
502 lines
20 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
|
|
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 Strömen über das Netz. Beides zu addieren zählte doppelt.
|
|
private var stallCount = 0
|
|
/// Aufsummierte Wartezeit. Siehe `noteStall(from:to:)`.
|
|
private var accumulatedStall: Duration = .zero
|
|
private var bufferingSince: ContinuousClock.Instant?
|
|
|
|
public init() {
|
|
AudioSession.activate()
|
|
}
|
|
|
|
public func load(_ source: PlaybackSource) async throws(PlaybackFailure) {
|
|
teardownObservers()
|
|
state = .loading
|
|
|
|
// Die Buchführung gehört zur Quelle, nicht zum Motor: ein neuer Titel
|
|
// fängt bei null an, sonst schleppte die Zahl den vorigen Film mit.
|
|
variantBitrates = []
|
|
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)
|
|
)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
// 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.
|
|
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
|
|
}
|
|
if let stallObserver {
|
|
NotificationCenter.default.removeObserver(stallObserver)
|
|
self.stallObserver = 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
|
|
}
|
|
}
|