VLC ist weg — es gibt nur noch einen Spieler

Du hattest recht mit der Erwartung, nur nicht mit dem Stand: entfernt war er
nicht. Was wir gemacht haben, war, ihm Arbeit wegzunehmen — DTS, TrueHD und
Vorbis gehen seit Kurzem über das Umpacken beim Server. Übrig blieb er an
einer Stelle, `PlaybackDecider.fallback`.

Jetzt ist er ganz weg, samt dem 920-MB-Binärpaket.

**Was an seine Stelle tritt.** Die Entscheidung sagt nicht mehr, *wer* spielt,
sondern *was ankommt*: direkt, umgepackt oder umgerechnet. `PlaybackEngineKind`
fällt damit weg, `directPlay` trägt keine Engine mehr, und `PlaybackPolicy`
kennt kein `allowsFallbackEngine`.

**Das Auffangnetz bleibt, nur anders geknüpft.** Scheiterte das Umpacken am
Player, sprang bisher VLC ein — der Server entscheidet erst beim Ausliefern,
ob er die Ströme wirklich kopieren kann, und manche Antworten nimmt AVPlayer
nicht an. Statt der zweiten Engine rechnet der Server jetzt neu
(`fallBackToTranscode`). Teurer für ihn, aber es kommt etwas an, statt einer
schwarzen Wand mit einer Fehlernummer.

**Offline wäre das ein Rückschritt gewesen** — dort gibt es keinen Server, der
einspringt. Deshalb ging der vorige Commit voraus: was heruntergeladen wird,
kommt schon in einer Hülle, die der Systemplayer öffnet.

Was dadurch überall gilt, auch für Dateien, die bisher bei VLC landeten:
Bild-in-Bild, AirPlay, Bedienung im Sperrbildschirm — und dasselbe Verhalten
auf dem Apple TV, wo es VLC ohnehin nie gab.

Die Attrappe heißt ihren Schalter jetzt `--dts` statt `--vlc`: der Fall ist
geblieben, sein Ziel nicht.

iOS und tvOS bauen, 494 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 08:04:23 +02:00
co-authored by Claude Opus 5
parent ef2596e2bb
commit 6b927c21b0
26 changed files with 147 additions and 744 deletions
+32 -69
View File
@@ -4,11 +4,6 @@ import MediaKit
import Observation
import FlixyDownloads
import FlixyPlayback
// Siehe `PlaybackSettings`: `!os(tvOS)` muss dazu, sonst bricht der
// Abhängigkeits-Sucher an VLCKit ab, das für dieses Ziel gar nicht gebaut wird.
#if canImport(FlixyVLC) && !os(tvOS)
import FlixyVLC
#endif
import PlexKit
/// Führt einen Abspielwunsch aus.
@@ -162,9 +157,6 @@ final class PlayerModel {
/// das Bild zeichnet: auf dem Telefon hängt die gehaltene `AVPlayerLayer`
/// daran, auf dem Fernseher ein `AVPlayerViewController`. Beide holen sich
/// hier dasselbe ab.
///
/// `nil` bei VLC — kein Sonderfall, sondern dieselbe Auskunft, die
/// `VideoSurface` heute schon auswertet.
var systemPlayer: AVPlayer? { (engine as? AVPlayerEngine)?.player }
/// Wie das Bild in die Fläche gelegt wird.
@@ -174,18 +166,6 @@ final class PlayerModel {
/// Neuaufbau der Engine gehen an der Ansicht vorbei. Läge die Wahl dort,
/// spränge das Bild bei jedem Qualitätswechsel zurück auf „eingepasst".
///
/// **Warum der Knopf lange nichts tat, und warum der Grund nicht der
/// `didSet` war, der hier stand.** Ein `didSet` auf einer verfolgten
/// Eigenschaft einer `@Observable`-Klasse wird durchaus gemeldet —
/// `HomeLayoutSettings` lebt davon. Der Knopf schrieb `videoGravity` auf
/// `surface.playerLayer`, und diese Ebene hängt bei einer Datei, die über
/// VLC läuft, überhaupt nicht im Baum: `VideoSurface` nimmt dort
/// `vlcEngine.videoView`, und `attachSurface` steigt für alles ausser
/// AVPlayer gleich wieder aus. Geschrieben wurde also auf eine Ebene, die
/// niemand sieht.
///
/// Deshalb geht die Einstellung jetzt durch die Engine — siehe
/// `applyVideoFit()`.
var videoFit: VideoFit = .fit
/// Wo das Bild aufhört.
@@ -208,9 +188,6 @@ final class PlayerModel {
/// **Beobachtet, nicht abgefragt.** `isPictureInPicturePossible` steht erst
/// fest, wenn der Player ein Bild hat — wer beim Zeichnen fragt, bekommt
/// `false` und zeichnet den Knopf nie wieder. Also meldet es sich selbst.
///
/// Bleibt bei VLC dauerhaft falsch: `AVPictureInPictureController` braucht
/// eine `AVPlayerLayer`, und VLC zeichnet in eine eigene Ansicht.
private(set) var canUsePictureInPicture = false
private(set) var isPictureInPictureActive = false
/// Was beim Beenden von Bild-in-Bild wieder ins Vollbild führen soll.
@@ -269,7 +246,7 @@ final class PlayerModel {
/// Ohne das gewönne die letzte Meldung von der alten Stelle gegen den
/// gerade ausgelösten Sprung.
private var seekTarget: Duration?
/// Ob schon einmal auf VLC ausgewichen wurde.
/// Ob schon einmal auf den Transcoder ausgewichen wurde.
private var hasFallenBack = false
/// Ob der Vorratsspeicher für diese Wiedergabe aufgegeben wurde.
///
@@ -440,9 +417,6 @@ final class PlayerModel {
guard let ceiling = automaticCeiling ?? startingCeiling else { return base }
return PlaybackPolicy(
allowsTranscoding: true,
// Ohne Ersatz-Engine: sonst spielte VLC die Datei unverändert ab,
// und die abgesenkte Grenze bliebe wirkungslos.
allowsFallbackEngine: false,
maximumBitrate: min(ceiling, base.maximumBitrate ?? ceiling),
forcesTranscoding: true
)
@@ -759,8 +733,8 @@ final class PlayerModel {
videoCodec: offline?.videoCodec,
audioCodec: offline?.audioCodec,
bitrate: offline?.bitrate,
// Was heruntergeladen wurde, ist untersucht. Ohne diese Zusage
// ginge jede Offline-Datei vorsichtshalber an VLC.
// Was heruntergeladen wurde, ist untersucht — und seit das
// Herunterladen bei Bedarf umpackt, auch zu öffnen.
hasKnownStreams: offline?.videoCodec != nil
),
// Lokal gibt es nichts umzurechnen und keine Bandbreitengrenze.
@@ -781,10 +755,6 @@ final class PlayerModel {
let engine = makeEngine(for: decision)
self.engine = engine
attachSurface(to: engine)
// **Auch für VLC**, und deshalb hier und nicht in `attachSurface`:
// das steigt für alles ausser AVPlayer sofort aus, weil es die
// Bildebene anhängt. Die Skalierung geht aber beide an.
engine.setVideoFit(videoFit)
observe(engine)
// **Vor dem Öffnen.** Ein frisch angelegter Gegenstand ist
// ungestaltet, und der erste Untertitel käme sonst in der Vorgabe.
@@ -1208,18 +1178,24 @@ final class PlayerModel {
return true
}
/// Der Rückzug auf VLC, wenn das Umpacken nicht getragen hat.
/// Der Rückzug auf den Transcoder, wenn das Umpacken nicht getragen hat.
///
/// Genau **einmal** je Wiedergabe: scheitert auch VLC, ist es ein Fehler
/// **Hier stand VLC.** Scheiterte das Umpacken, spielte die zweite Engine
/// die Datei unverändert — das gibt es nicht mehr. Statt dessen rechnet der
/// Server das Bild neu: teurer für ihn, aber es kommt etwas an, und der
/// Nutzer steht nicht vor einer schwarzen Wand mit einer Fehlernummer.
///
/// Genau **einmal** je Wiedergabe: scheitert auch das, ist es ein Fehler
/// des Materials, und ein zweiter Anlauf schickte die App im Kreis.
private func fallBackToVLC() async -> Bool {
guard !hasFallenBack, policy.allowsFallbackEngine else { return false }
private func fallBackToTranscode() async -> Bool {
let geltend = effectivePolicy
guard !hasFallenBack, geltend.allowsTranscoding else { return false }
hasFallenBack = true
diagnostics.note("Umpacken abgelehnt — noch einmal mit VLC")
diagnostics.note("Umpacken abgelehnt — noch einmal, diesmal umgerechnet")
await restart(forcing: PlaybackDecision(
method: .directPlay(engine: .vlc),
rationale: (decision?.rationale ?? []) + [.transcodingUnavailable]
method: .transcode(maximumBitrate: geltend.maximumBitrate),
rationale: decision?.rationale ?? []
))
if case .failed = phase { return false }
return true
@@ -1741,14 +1717,10 @@ final class PlayerModel {
/// Eine eigene Funktion, weil es zwei Anlässe gibt: die Wahl des Nutzers
/// und jeder Engine-Wechsel. Beide müssen dasselbe tun.
///
/// **Zwei Zeilen, weil es zwei Engines gibt.** Die Bildebene gehört der
/// App und nicht dem Systemplayer — deshalb steht `videoGravity` hier und
/// nicht in `AVPlayerEngine`. VLC zeichnet dagegen selbst und passt sein
/// Bild von sich aus ein; ihm muss man es sagen. Solange nur die erste
/// Zeile hier stand, war der Knopf bei jeder Datei über VLC ohne Wirkung.
/// Die Bildebene gehört der App und nicht dem Systemplayer — deshalb
/// steht `videoGravity` hier und nicht in `AVPlayerEngine`.
func applyVideoFit() {
surface.playerLayer.videoGravity = videoFit == .fill ? .resizeAspectFill : .resizeAspect
engine?.setVideoFit(videoFit)
}
/// Hängt die gehaltene Bildebene an den aktuellen Engine.
@@ -1758,7 +1730,6 @@ final class PlayerModel {
/// weiter auf einen abgeräumten Player und bleibt schwarz.
private func attachSurface(to engine: any VideoPlayerEngine) {
guard let avEngine = engine as? AVPlayerEngine else { return }
surface.playerLayer.player = avEngine.player
// **Den Zustand lesen, keine Konstante.** Hier stand fest
// `.resizeAspect` — und weil diese Zeile nach jedem Neuaufbau erneut
@@ -1818,20 +1789,14 @@ final class PlayerModel {
restoreFromPictureInPicture = action
}
/// **Es gibt nur noch einen.**
///
/// Die Entscheidung sagt nicht mehr, *wer* spielt, sondern *was ankommt*:
/// direkt, umgepackt oder umgerechnet. Am Ende steht immer der
/// Systemplayer — und damit überall Bild-in-Bild, AirPlay und die
/// Bedienung im Sperrbildschirm, auch auf dem Apple TV.
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
#if canImport(FlixyVLC) && !os(tvOS)
switch decision.engine {
case .vlc: VLCPlayerEngine()
// Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS, und
// genau darin wechselt AVPlayer die Bitrate von selbst.
case .avPlayer, .none: AVPlayerEngine()
}
#else
// Auf dem Mac gibt es VLC (noch) nicht: das Binärpaket ist auf iOS
// festgelegt. Was der Systemplayer nicht öffnen kann, scheitert dort
// mit einer Meldung, statt still schwarz zu bleiben.
AVPlayerEngine()
#endif
}
private func observe(_ engine: any VideoPlayerEngine) {
@@ -1853,19 +1818,17 @@ final class PlayerModel {
if case .failed(let failure) = state {
diagnostics.note("Wiedergabe gescheitert: \(failure)")
// **Scheitert das Umpacken, bleibt VLC.**
// **Scheitert das Umpacken, rechnet der Server um.**
//
// Der Server entscheidet erst beim Ausliefern, ob er
// die Ströme wirklich kopieren kann — und manche
// Antworten kann AVPlayer nicht annehmen, auch wenn
// sie formal richtig sind. Das hier vorher zu wissen
// ist nicht möglich; es hinterher aufzufangen schon.
// Er entscheidet erst beim Ausliefern, ob er die
// Ströme wirklich kopieren kann — und manche Antworten
// kann AVPlayer nicht annehmen, auch wenn sie formal
// richtig sind. Das vorher zu wissen ist nicht
// möglich; es hinterher aufzufangen schon.
//
// Ohne diesen Weg stünde der Nutzer vor einer
// schwarzen Wand mit einer Fehlernummer, obwohl ein
// Player danebensteht, der die Datei anstandslos
// spielt.
if case .remux = decision?.method, await fallBackToVLC() { return }
// schwarzen Wand mit einer Fehlernummer.
if case .remux = decision?.method, await fallBackToTranscode() { return }
diagnostics.finish(outcome: "Wiedergabe gescheitert: \(failure)")
// Der Grund gehört auf den Bildschirm, nicht nur ins