Anmelde-Adresse wurde doppelt kodiert URLComponents.fragment kodiert beim Setzen erneut, aus %5B wurde %255B. Plex antwortete mit „Diese Anfrage konnte nicht abgeschlossen werden". Die Adresse wird jetzt von Hand zusammengesetzt. Verbindungssuche hing bis zu anderthalb Minuten probeTimeout war deklariert, aber nirgends angewendet — es galt der URLSession-Standard von 60 Sekunden. Zusammen mit der Prüfung Rang für Rang kostete eine schweigende lokale Adresse die volle Zeit, bevor der direkte Weg überhaupt begann. Jetzt laufen alle Adressen gleichzeitig, mit Zeitgrenze, und die Rangordnung entscheidet unter den erreichbaren. Kein Zurück während des Ladens Die Kopfzeile mit dem Schließen-Knopf wurde nur im Zustand .playing gezeichnet. Bei langsamer Verbindung saß man ohne Ausweg vor einem Ladekreis. Sie liegt jetzt als eigene Ebene darüber und erscheint auch beim Vorbereiten — samt Titel, damit erkennbar bleibt, worauf gewartet wird. Sechs neue Tests, darunter drei, die die Zeitgrenze der Verbindungssuche an einer schweigenden Adresse nachstellen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256 lines
8.7 KiB
Swift
256 lines
8.7 KiB
Swift
import AVFoundation
|
|
import Foundation
|
|
import PlexKit
|
|
|
|
/// Wiedergabe über den Systemplayer.
|
|
///
|
|
/// Der bevorzugte Weg, wann immer das Format ihn zulässt: Bild-in-Bild,
|
|
/// AirPlay, die Steuerung im Sperrbildschirm und der Umgang mit
|
|
/// Unterbrechungen durch Anrufe kommen alle mit, ohne dass etwas davon
|
|
/// nachgebaut werden müsste.
|
|
@MainActor
|
|
public final class AVPlayerEngine: VideoPlayerEngine {
|
|
/// Wird von der Ansicht gebraucht, um die Bildebene zu bauen. Der einzige
|
|
/// Grund, warum AVFoundation überhaupt nach außen sichtbar ist.
|
|
public let player = AVPlayer()
|
|
|
|
public private(set) var state: PlaybackState = .idle {
|
|
didSet {
|
|
guard state != oldValue else { return }
|
|
continuation?.yield(.stateChanged(state))
|
|
}
|
|
}
|
|
|
|
public private(set) var duration: Duration?
|
|
public private(set) var audioTracks: [MediaTrack] = []
|
|
public private(set) var subtitleTracks: [MediaTrack] = []
|
|
|
|
public var currentTime: Duration {
|
|
let seconds = player.currentTime().seconds
|
|
guard seconds.isFinite, seconds >= 0 else { return .zero }
|
|
return .seconds(seconds)
|
|
}
|
|
|
|
public let supportsPictureInPicture = true
|
|
|
|
public 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 observations: [NSKeyValueObservation] = []
|
|
private var endObserver: (any NSObjectProtocol)?
|
|
|
|
public init() {
|
|
AudioSession.activate()
|
|
}
|
|
|
|
public func load(_ source: PlaybackSource) async throws(PlaybackFailure) {
|
|
teardownObservers()
|
|
state = .loading
|
|
|
|
let asset = AVURLAsset(url: source.url)
|
|
let item = AVPlayerItem(asset: asset)
|
|
player.replaceCurrentItem(with: item)
|
|
|
|
observe(item: item)
|
|
observePlayer()
|
|
|
|
do {
|
|
let assetDuration = try await asset.load(.duration)
|
|
if assetDuration.isNumeric {
|
|
let value = Duration.seconds(assetDuration.seconds)
|
|
duration = value
|
|
continuation?.yield(.durationResolved(value))
|
|
}
|
|
try await loadTracks(from: asset)
|
|
} catch {
|
|
let failure = PlaybackFailure(
|
|
kind: .sourceUnavailable,
|
|
underlyingDescription: String(describing: error)
|
|
)
|
|
state = .failed(failure)
|
|
throw failure
|
|
}
|
|
|
|
if source.startTime > .zero {
|
|
await seek(to: source.startTime)
|
|
}
|
|
}
|
|
|
|
public func play() {
|
|
player.play()
|
|
}
|
|
|
|
public func pause() {
|
|
player.pause()
|
|
}
|
|
|
|
public func seek(to time: Duration) async {
|
|
let target = CMTime(seconds: time.seconds, preferredTimescale: 600)
|
|
// Genau springen statt zum nächsten Schlüsselbild: beim Zurückspringen
|
|
// um zehn Sekunden landet man sonst je nach Datei bis zu mehreren
|
|
// Sekunden daneben.
|
|
await player.seek(to: target, toleranceBefore: .zero, toleranceAfter: .zero)
|
|
continuation?.yield(.timeChanged(time))
|
|
}
|
|
|
|
public func setRate(_ rate: Double) {
|
|
player.rate = Float(rate)
|
|
}
|
|
|
|
public func selectAudioTrack(_ track: MediaTrack?) {
|
|
select(track, in: .audible)
|
|
}
|
|
|
|
public func selectSubtitleTrack(_ track: MediaTrack?) {
|
|
select(track, in: .legible)
|
|
}
|
|
|
|
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
|
|
MainActor.assumeIsolated {
|
|
guard let self else { return }
|
|
switch player.timeControlStatus {
|
|
case .playing: self.state = .playing
|
|
case .paused: if self.state != .ended { self.state = .paused }
|
|
case .waitingToPlayAtSpecifiedRate: self.state = .buffering
|
|
@unknown default: break
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
// Viermal je Sekunde: fein genug, dass der Schieberegler flüssig
|
|
// wirkt, grob genug, dass er nicht jede Bildwiederholung antreibt.
|
|
timeObserver = player.addPeriodicTimeObserver(
|
|
forInterval: CMTime(seconds: 0.25, preferredTimescale: 600),
|
|
queue: .main
|
|
) { [weak self] time in
|
|
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
|
|
MainActor.assumeIsolated {
|
|
guard let self, item.status == .failed else { return }
|
|
self.state = .failed(PlaybackFailure(
|
|
kind: .sourceUnavailable,
|
|
underlyingDescription: item.error.map { String(describing: $0) }
|
|
))
|
|
}
|
|
}
|
|
)
|
|
|
|
endObserver = NotificationCenter.default.addObserver(
|
|
forName: AVPlayerItem.didPlayToEndTimeNotification,
|
|
object: item,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
MainActor.assumeIsolated {
|
|
guard let self else { return }
|
|
self.state = .ended
|
|
self.continuation?.yield(.reachedEnd)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func teardownObservers() {
|
|
observations.forEach { $0.invalidate() }
|
|
observations.removeAll()
|
|
|
|
if let timeObserver {
|
|
player.removeTimeObserver(timeObserver)
|
|
self.timeObserver = nil
|
|
}
|
|
if let endObserver {
|
|
NotificationCenter.default.removeObserver(endObserver)
|
|
self.endObserver = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Die Tonsitzung des Systems.
|
|
///
|
|
/// Nur auf iOS vorhanden. Gekapselt statt an drei Stellen mit `#if` versehen —
|
|
/// macOS steht in diesem Paket ohnehin nur, damit die Logik-Tests ohne
|
|
/// Simulator laufen.
|
|
enum AudioSession {
|
|
static func activate() {
|
|
#if os(iOS)
|
|
// Ohne diese Kategorie schweigt die Wiedergabe, sobald der
|
|
// Stummschalter am Gerät umgelegt ist — und Bild-in-Bild gäbe es gar
|
|
// nicht.
|
|
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
|
try? AVAudioSession.sharedInstance().setActive(true)
|
|
#endif
|
|
}
|
|
|
|
static func deactivate() {
|
|
#if os(iOS)
|
|
// `notifyOthersOnDeactivation` lässt die Musik-App wieder anlaufen,
|
|
// die vor dem Film gespielt hat.
|
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
#endif
|
|
}
|
|
}
|