Der Crashlog vom Gerät:
_dispatch_assert_queue_fail
_swift_task_checkIsolatedSwift
MainActor.assumeIsolated
VLCPlayerEngine.mediaPlayerStateChanged(_:)
VLCKit input_thread_Events
Im Code stand als Begründung für assumeIsolated, VLCKit stelle seine
Rückrufe über die Hauptwarteschlange zu. Das ist falsch — es ruft den
Delegaten von seinem eigenen Input-Thread. Die App brach deshalb hart
ab, sobald VLC den ersten Zustandswechsel meldete, also unmittelbar
beim Drücken auf Play.
Alle drei Delegat-Methoden springen jetzt mit Task { @MainActor in }
sauber hinüber. Die Reihenfolge bleibt erhalten: die Aufgaben werden aus
demselben Thread nacheinander eingereiht.
Dieselbe Annahme steckte im AVPlayer-Motor in den KVO-Beobachtern —
KVO meldet auf dem Thread, der die Änderung ausgelöst hat, und AVPlayer
ändert seinen Zustand auch aus internen Threads. Ebenfalls umgestellt.
Die beiden Rückrufe mit ausdrücklichem queue: .main behalten
assumeIsolated: dort ist die Annahme belegt statt geraten, und der
Zeitbeobachter feuert viermal je Sekunde.
Dazu die Anmeldung: Plex führt den Rücksprung über forwardUrl nicht
zuverlässig aus. Auf dem iPad landet der Browser nach dem Anmelden auf
watch.plex.tv und bleibt stehen — der Token liegt bereit, aber die App
wartete auf einen Rückruf, der nie kam. Das Fenster wird jetzt geöffnet,
ohne darauf zu warten; parallel wird im Sekundentakt beim Server
nachgefragt. Der Rücksprung ist damit eine Annehmlichkeit, keine
Voraussetzung.
132 Tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
263 lines
9.2 KiB
Swift
263 lines
9.2 KiB
Swift
import AVFoundation
|
|
import Foundation
|
|
import FlixyPlayback
|
|
import UIKit
|
|
@preconcurrency import VLCKit
|
|
|
|
/// Wiedergabe über VLCKit.
|
|
///
|
|
/// Der Weg für alles, was der Systemplayer nicht öffnet: Matroska-Container,
|
|
/// DTS- und TrueHD-Ton, ältere Codecs. Langsamer beim Starten und stromhungriger,
|
|
/// dafür spielt er praktisch jede Datei ohne Zutun des Servers.
|
|
///
|
|
/// `@preconcurrency import`, weil VLCKit als Objective-C-Bibliothek nichts über
|
|
/// Nebenläufigkeit aussagt. Die ganze Klasse liegt deshalb auf dem Hauptakteur —
|
|
/// VLCKit erwartet seine Rückrufe ohnehin dort.
|
|
@MainActor
|
|
public final class VLCPlayerEngine: NSObject, VideoPlayerEngine {
|
|
/// Die Fläche, in die VLC zeichnet. Die Ansicht hängt sie in ihren
|
|
/// Hierarchiebaum.
|
|
public let videoView = UIView()
|
|
|
|
private let player = VLCMediaPlayer()
|
|
|
|
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 {
|
|
.milliseconds(Int(player.time.intValue))
|
|
}
|
|
|
|
/// VLCKit 4 bringt ein eigenes Bild-in-Bild-Protokoll mit
|
|
/// (`VLCPictureInPictureMediaControlling`). Angebunden ist es noch nicht —
|
|
/// solange steht hier `false`, damit kein Knopf erscheint, der nichts tut.
|
|
/// Nachzuholen in Phase 2, zusammen mit der Steuerung im Sperrbildschirm.
|
|
public let supportsPictureInPicture = false
|
|
|
|
public private(set) lazy var events: AsyncStream<PlaybackEvent> = {
|
|
AsyncStream { continuation in
|
|
self.continuation = continuation
|
|
}
|
|
}()
|
|
|
|
private var continuation: AsyncStream<PlaybackEvent>.Continuation?
|
|
private var pendingStartTime: Duration = .zero
|
|
|
|
public override init() {
|
|
super.init()
|
|
videoView.backgroundColor = .black
|
|
player.delegate = self
|
|
player.drawable = videoView
|
|
AudioSessionBridge.activate()
|
|
}
|
|
|
|
public func load(_ source: PlaybackSource) async throws(PlaybackFailure) {
|
|
state = .loading
|
|
pendingStartTime = source.startTime
|
|
|
|
// `initWithURL:` ist nullable. Ohne diese Prüfung bliebe `media` still
|
|
// `nil`, `play()` täte nichts, und auf dem Bildschirm stünde ein
|
|
// Ladekreis ohne Ende — ohne dass irgendwo ein Fehler aufschlüge.
|
|
guard let media = VLCMedia(url: source.url) else {
|
|
let failure = PlaybackFailure(
|
|
kind: .sourceUnavailable,
|
|
underlyingDescription: "VLCMedia ließ sich für \(source.url) nicht anlegen"
|
|
)
|
|
state = .failed(failure)
|
|
throw failure
|
|
}
|
|
player.media = media
|
|
|
|
// VLC kennt Länge und Spuren erst, wenn es die Datei geöffnet hat.
|
|
// Beides kommt später über den Delegaten — hier gibt es nichts zu
|
|
// warten, und ein `await` würde nur den Bildaufbau verzögern.
|
|
player.play()
|
|
}
|
|
|
|
public func play() {
|
|
player.play()
|
|
}
|
|
|
|
public func pause() {
|
|
// `pause()` ist bei VLC ein Umschalter. Ohne diese Prüfung würde ein
|
|
// Aufruf im pausierten Zustand die Wiedergabe wieder starten.
|
|
if player.isPlaying { player.pause() }
|
|
}
|
|
|
|
public func seek(to time: Duration) async {
|
|
player.time = VLCTime(int: time.vlcMilliseconds)
|
|
continuation?.yield(.timeChanged(time))
|
|
}
|
|
|
|
public func setRate(_ rate: Double) {
|
|
player.rate = Float(rate)
|
|
}
|
|
|
|
public func selectAudioTrack(_ track: MediaTrack?) {
|
|
select(track, from: player.audioTracks)
|
|
}
|
|
|
|
public func selectSubtitleTrack(_ track: MediaTrack?) {
|
|
select(track, from: player.textTracks)
|
|
}
|
|
|
|
public func teardown() {
|
|
player.stop()
|
|
player.delegate = nil
|
|
player.drawable = nil
|
|
continuation?.finish()
|
|
continuation = nil
|
|
state = .idle
|
|
AudioSessionBridge.deactivate()
|
|
}
|
|
|
|
// MARK: - Spuren
|
|
|
|
private func select(_ track: MediaTrack?, from tracks: [VLCMediaPlayer.Track]) {
|
|
guard let track, let identifier = track.engineTrackID else {
|
|
// Abschalten: alle Spuren dieser Art abwählen. Bei Untertiteln der
|
|
// Normalfall.
|
|
tracks.forEach { $0.isSelected = false }
|
|
return
|
|
}
|
|
// `isSelectedExclusively` wählt diese Spur und wählt die übrigen
|
|
// desselben Typs ab. Einzeln zu setzen ließe zwei Tonspuren gleichzeitig
|
|
// laufen.
|
|
tracks.first { $0.trackId == identifier }?.isSelectedExclusively = true
|
|
}
|
|
|
|
private func refreshTracks() {
|
|
audioTracks = player.audioTracks.map { $0.mediaTrack(kind: .audio) }
|
|
subtitleTracks = player.textTracks.map { $0.mediaTrack(kind: .subtitle) }
|
|
continuation?.yield(.tracksResolved(audio: audioTracks, subtitle: subtitleTracks))
|
|
}
|
|
}
|
|
|
|
// MARK: - VLCMediaPlayerDelegate
|
|
|
|
/// VLCKit ruft den Delegaten von seinem **eigenen Input-Thread**, nicht über
|
|
/// die Hauptwarteschlange.
|
|
///
|
|
/// Hier stand vorher das Gegenteil, und darauf aufbauend `assumeIsolated` —
|
|
/// mit dem Ergebnis, dass die App beim Drücken auf Play hart abbrach, sobald
|
|
/// VLC den ersten Zustandswechsel meldete:
|
|
///
|
|
/// _dispatch_assert_queue_fail
|
|
/// MainActor.assumeIsolated
|
|
/// VLCPlayerEngine.mediaPlayerStateChanged(_:)
|
|
/// VLCKit input_thread_Events
|
|
///
|
|
/// Deshalb jetzt ein echter Sprung auf den Hauptakteur. Die Reihenfolge bleibt
|
|
/// dabei erhalten: die Aufgaben werden aus demselben Thread nacheinander
|
|
/// eingereiht und auch so abgearbeitet.
|
|
extension VLCPlayerEngine: VLCMediaPlayerDelegate {
|
|
public nonisolated func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) {
|
|
Task { @MainActor in
|
|
switch newState {
|
|
case .opening:
|
|
state = .loading
|
|
case .playing:
|
|
state = .playing
|
|
applyPendingStartTime()
|
|
refreshTracks()
|
|
case .paused:
|
|
state = .paused
|
|
case .stopped, .stopping:
|
|
// VLC meldet das Ende einer Datei als „gestoppt". Ob das
|
|
// Ansehen abgeschlossen ist oder jemand abgebrochen hat,
|
|
// unterscheidet nur die Position.
|
|
state = isNearEnd ? .ended : .idle
|
|
if isNearEnd { continuation?.yield(.reachedEnd) }
|
|
case .error:
|
|
state = .failed(PlaybackFailure(kind: .formatUnsupported))
|
|
case .nothingSpecial:
|
|
break
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
public nonisolated func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
|
Task { @MainActor in
|
|
continuation?.yield(.timeChanged(currentTime))
|
|
}
|
|
}
|
|
|
|
public nonisolated func mediaPlayerLengthChanged(_ length: Int64) {
|
|
Task { @MainActor in
|
|
guard length > 0 else { return }
|
|
let value = Duration.milliseconds(length)
|
|
duration = value
|
|
continuation?.yield(.durationResolved(value))
|
|
}
|
|
}
|
|
|
|
/// Beim Fortsetzen: VLC nimmt eine Position erst an, wenn die Wiedergabe
|
|
/// tatsächlich läuft. Vorher gesetzt, wird sie kommentarlos verworfen.
|
|
private func applyPendingStartTime() {
|
|
guard pendingStartTime > .zero else { return }
|
|
player.time = VLCTime(int: pendingStartTime.vlcMilliseconds)
|
|
pendingStartTime = .zero
|
|
}
|
|
|
|
private var isNearEnd: Bool {
|
|
// Ein Rest von unter zwei Sekunden zählt als durchgesehen — VLC
|
|
// stoppt selten exakt am Dateiende.
|
|
guard let duration else { return false }
|
|
return currentTime + .seconds(2) >= duration
|
|
}
|
|
}
|
|
|
|
extension Duration {
|
|
/// Millisekunden als `Int32`, wie VLCTime sie erwartet.
|
|
///
|
|
/// Abgeschnitten statt umgewandelt: `Int32(...)` stürzt bei Überlauf ab,
|
|
/// und die Grenze liegt bei rund 24 Tagen Laufzeit. Das wird keine Datei
|
|
/// erreichen — aber ein Absturz an dieser Stelle wäre besonders schwer zu
|
|
/// erklären.
|
|
var vlcMilliseconds: Int32 {
|
|
let milliseconds = components.seconds * 1000
|
|
return Int32(clamping: milliseconds)
|
|
}
|
|
}
|
|
|
|
extension VLCMediaPlayer.Track {
|
|
fileprivate func mediaTrack(kind: MediaTrack.Kind) -> MediaTrack {
|
|
MediaTrack(
|
|
id: "vlc-\(trackId)",
|
|
kind: kind,
|
|
displayName: trackName.isEmpty ? trackId : trackName,
|
|
engineIdentifier: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
extension MediaTrack {
|
|
/// VLC führt seine Spuren unter Zeichenketten, nicht unter Zahlen —
|
|
/// deshalb steckt die Kennung im `id`-Feld statt in `engineIdentifier`.
|
|
fileprivate var engineTrackID: String? {
|
|
id.hasPrefix("vlc-") ? String(id.dropFirst(4)) : nil
|
|
}
|
|
}
|
|
|
|
/// Die Tonsitzung, damit der Ton auch bei umgelegtem Stummschalter läuft.
|
|
enum AudioSessionBridge {
|
|
static func activate() {
|
|
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
|
try? AVAudioSession.sharedInstance().setActive(true)
|
|
}
|
|
|
|
static func deactivate() {
|
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
}
|
|
}
|