Files
Flaro/Packages/FlixyVLC/Sources/FlixyVLC/VLCPlayerEngine.swift
T
ScarriffleandClaude Opus 5 e324a474fd Watchdog heisst Watchdog, Engine heisst Engine
Eingedeutschte Fachbegriffe in den Kommentaren — „Wachhund" für
Watchdog, „Motor" für Engine. Das liest sich albern und ist unklarer als
das Original; niemand sagt Wachhund, wenn er einen Watchdog meint.

Acht Stellen berichtigt. Gewöhnliche Sprache bleibt deutsch, der Stil
der Kommentare ändert sich nicht.

Dazu die Plex-Leiter messbar gemacht: `--leiter` baut dieselbe Anfrage
zweimal, mit und ohne `autoAdjustQuality=1`, und zählt die Stufen. Die
Zeile steht seit dem Umbau im Code und war nie geprüft — genau die Art
ungeprüfter Annahme über Serververhalten, die bei Jellyfin vier Runden
gekostet hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 17:47:31 +02:00

321 lines
12 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()
/// Was VLC über den laufenden Strom weiß.
///
/// Bisher meldete dieser Weg gar nichts: die Standardimplementierung gibt
/// `nil` zurück, und die Bitratenanzeige blieb bei VLC dauerhaft leer.
///
/// **Keine Leiter, und das ist die richtige Aussage.** VLC spielt hier
/// eine Datei, keine Playlist — es gibt keine Stufen, zwischen denen
/// etwas wechseln könnte. `variantCount` bleibt deshalb `nil` und nicht
/// etwa `1`: „gibt es nicht" ist etwas anderes als „gibt es einmal".
///
/// **Zur Bitrate, ehrlich:** libvlc führt `inputBitrate` als Byte je
/// Sekunde, deshalb der Faktor acht. Das ist der wahrscheinliche, aber
/// nicht belegte Fall — wer es prüfen will, hält den Wert neben die
/// bekannte Bitrate der Datei; liegt er um Faktor acht daneben, steht
/// hier das Falsche.
public var statistics: PlaybackStatistics? {
guard let media = player.media else { return nil }
let werte = media.statistics
let kilobit = Int((werte.inputBitrate * 8 / 1_000).rounded())
let größe = player.videoSize
return PlaybackStatistics(
observedBitrate: kilobit > 0 ? kilobit : nil,
indicatedBitrate: nil,
width: größe.width > 0 ? Int(größe.width) : nil,
height: größe.height > 0 ? Int(größe.height) : nil,
// Nur die wirklich verworfenen. `latePictures` sind zu spät
// gekommen, aber angezeigt worden — sie unter „verworfen" zu
// führen machte die Zahl größer und die Aussage falsch.
droppedFrames: Int(werte.lostPictures)
)
}
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
}
}
}
/// Der Puffer-Fortschritt — **der einzige Weg, auf dem VLC das Stocken
/// überhaupt meldet.**
///
/// `VLCMediaPlayerState` kennt keinen Wert für „wartet auf Daten": die
/// Aufzählung führt nur `opening`, `playing`, `paused`, `stopped` und
/// `error`. Weil diese Methode bisher fehlte, ging der Zustand
/// `.buffering` auf dem VLC-Weg **nie** an — und damit löste der
/// Watchdog im Player dort nie aus. Ein hängender Strom lief stumm ins
/// Nichts, ohne Kreisel, ohne Meldung, ohne Abbruch.
///
/// Nur aus dem Spielen heraus gewertet. Die Dokumentation sagt, dass vor
/// jeder erfolgreichen Wiedergabe einmal 0,0 und einmal 1,0 kommt —
/// dieses anfängliche Füllen ist der Start und kein Rückler, und es
/// mitzuzählen gäbe jeder Wiedergabe eine Vorstrafe.
public nonisolated func mediaPlayerBufferingChanged(_ progress: Float) {
Task { @MainActor in
if progress >= 1 {
if state == .buffering { state = .playing }
} else if state == .playing {
state = .buffering
}
}
}
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)
}
}