Files
Flaro/Packages/FlixyVLC/Sources/FlixyVLC/VLCPlayerEngine.swift
T
ScarriffleandClaude Opus 5 efed8717a3 „Auto" gibt nicht mehr auf, es versucht es kleiner
Der Fehlstart im Zug hat den eigentlichen Mangel gezeigt: die ganze neue
Regelung misst **ab dem ersten Bild**. Kommt nie eines, misst sie nichts,
greift nichts — und „Auto" hat sich als das erwiesen, was es wörtlich
heisst: „nimm, was in den Einstellungen steht". Steht dort „Original",
bittet die App über eine Zugverbindung um eine 4K-Datei und wartet
vierzig Sekunden auf etwas, das nie kommen kann.

Statt aufzugeben geht der Watchdog jetzt eine Sprosse tiefer und baut
neu auf — bei Filmen über `restart()`, bei Sendern über den Tuner-Abbau.
Gezählt gegen dasselbe Budget wie die Sicherheitsleine, sonst schickt
eine tote Leitung die App endlos im Kreis.

Scheitert auch das, nennt die Meldung jetzt den Grund und den Weg statt
nur „keine Daten mehr": wer ohne Grenze angefragt hat, soll erfahren,
dass es daran liegt und wo es sich ändern lässt.

Und „Strom" heisst Stream. Zweiunddreissig Stellen, dazu das
Gedächtnis ergänzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 18:30:10 +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 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
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 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)
}
}