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>
376 lines
14 KiB
Swift
376 lines
14 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 = 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
|
||
}
|
||
}
|
||
}
|