Files
Flaro/Packages/FlixyVLC/Sources/FlixyVLC/VLCPlayerEngine.swift
T
ScarriffleandClaude Opus 5 b0db4f8f5f Bei VLC sass das Bild nie richtig — und der Knopf wirkte nie
Zwei Fehler, die dieselbe Ursache haben: alles, was über VLC läuft, ging
an der Bildebene vorbei, an der die App gedreht hat.

**Das Bild sass nicht mittig.** Nicht ein paar Punkte daneben — gemessen
eine Anzeigefläche von 1200×480 Punkt in einer Zeichenfläche von 711×400,
unten rechts angeschlagen, im falschen Seitenverhältnis. Der Grund steht
im Vertrag von `VLCDrawable`: VLCKit fragt die Grösse **einmal** beim
Anlegen ab und überlässt seine Anzeigefläche danach der
Autoresizing-Maske. Eine Maske hält aber den Unterschied fest, nicht das
Verhältnis — was beim Anlegen zu gross war, bleibt für immer um denselben
Betrag zu gross. Und angelegt wird sie mitten in der Drehung ins
Querformat, während SwiftUI sie einblendet: also regelmässig falsch.

Die Zeichenfläche ist jetzt eine eigene Klasse, die ihrer Anzeigefläche
bei jedem Layout wieder die eigene Grösse gibt. Danach gemessen: 695,7 ×
400 Punkt, Rand links 130,0, rechts 130,3 — mittig.

**Der Knopf für einpassen/füllen tat nichts.** Er schrieb `videoGravity`
auf eine `AVPlayerLayer`, die bei VLC gar nicht im Baum hängt. Die
Einstellung geht deshalb jetzt durch die Engine: `setVideoFit` am
Protokoll, beim Systemplayer eine Voreinstellung, die nichts tut — dort
macht es die Bildebene —, bei VLC `videoFitMode`. `smaller` heisst bei
libvlc „fit inside", `larger` „fit outside".

Gemessen, im Querformat auf einem Pro Max, über VLC:

    eingepasst   695,7 × 400 pt   Rand l/r 130,0/130,3   o/u 20,0/20,0
    füllend      935,3 × 440 pt   Rand l/r  10,3/ 10,3   o/u  0,0/ 0,0

**Zurückgenommen:** im Kommentar an `videoFit` stand, ein `didSet` auf
einer `@Observable`-Klasse werde nicht gemeldet. Das stimmt nicht —
`HomeLayoutSettings` lebt davon. Ein Kommentar, der einen falschen
Mechanismus behauptet, schickt den nächsten in die falsche Richtung;
jetzt steht der belegte Grund dort.

Dazu, damit dieser Weg überhaupt prüfbar ist: die Attrappe meldet mit
`--vlc` DTS-Ton. AVFoundation kann das nicht, also fällt die App auf
VLCKit zurück. Die Beispieldatei bleibt dieselbe — geprüft wird der Weg,
nicht der Ton. Ohne diesen Schalter ist der VLC-Zweig im Simulator gar
nicht zu erreichen, und genau deshalb lagen beide Fehler so lange da.

Ebenfalls dabei: `EdgeInsets.symmetrical` mit fünf Tests und das Polster
im Player, das damit nicht mehr verschiebt statt einrückt.

iOS und tvOS bauen, 465 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 21:05:57 +02:00

376 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = VLCSurfaceView()
private let player = VLCMediaPlayer()
/// Was VLC über den laufenden Stream 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
/// Wie das Bild in `videoView` liegen soll.
///
/// **Gemerkt und nicht nur weitergegeben.** Solange VLC keine
/// Bildausgabe hat, gibt es nichts, worauf die Einstellung wirken könnte;
/// vor dem ersten Bild gesetzt, ist sie weg. Deshalb steht sie hier und
/// wird beim Anlaufen erneut gelegt.
private var fit: VideoFit = .fit
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 setVideoFit(_ fit: VideoFit) {
self.fit = fit
applyFit()
}
/// Legt die gemerkte Einstellung auf den Player.
///
/// `smaller` heisst bei libvlc ausdrücklich „fit inside", `larger`
/// „fit outside" — also einpassen mit Balken beziehungsweise füllen mit
/// Beschnitt. Die Namen sagen es nicht von selbst; die Kopfdatei tut es.
private func applyFit() {
player.videoFitMode = fit == .fill ? .larger : .smaller
}
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()
// Siehe `fit`: vor dem ersten Bild wäre sie ins Leere
// gegangen. Hier gibt es eine Ausgabe.
applyFit()
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 Stream 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)
}
}
/// Die Fläche, die VLC als Zeichenfläche bekommt.
///
/// **Eine eigene Klasse, weil VLC seine Anzeigefläche nur ein einziges Mal
/// misst.** `VLCDrawable` verlangt vom Aufnehmenden zwei Dinge: `addSubview:`
/// und `bounds`. VLCKit fragt die Grösse beim Anlegen ab, legt seine
/// `VLCVideoUIView` genau so gross an und überlässt sie danach der
/// Autoresizing-Maske. Eine Maske hält aber den **Unterschied** fest, nicht
/// das Verhältnis: was beim Anlegen zu gross war, bleibt für immer um denselben
/// Betrag zu gross.
///
/// Und beim Anlegen ist die Grösse regelmässig falsch — die Fläche entsteht
/// mitten in der Drehung ins Querformat, während SwiftUI sie gerade einblendet.
/// Gemessen: eine Anzeigefläche von 1200×480 Punkt in einer Zeichenfläche von
/// 711×400. Auf dem Bildschirm sah man davon das Stück, das hineinpasste —
/// unten rechts angeschlagen, in falschem Seitenverhältnis. Genau das war
/// „das Bild sitzt nicht mittig" bei allem, was über VLC läuft.
///
/// Hier bekommt die Anzeigefläche bei jedem Layout wieder genau die eigene
/// Grösse. Das ist keine Notlösung, sondern der Teil des Vertrags, den nur der
/// Aufnehmende erfüllen kann: VLC kennt den Grund für eine Grössenänderung
/// nicht, die Ansicht kennt ihn.
final class VLCSurfaceView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
for view in subviews where view.frame != bounds {
view.frame = bounds
}
}
}