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
-17
View File
@@ -7326,23 +7326,6 @@
}
}
},
"technical.directPlayVLC": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Direkt (VLC)"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Direct (VLC)"
}
}
}
},
"technical.file": {
"localizations": {
"de": {
+1 -1
View File
@@ -133,7 +133,7 @@ final class NowPlaying {
///
/// Die Handlung läuft über einen Sprung auf den Hauptakteur statt über
/// `assumeIsolated`: dass diese Rückrufe dort ankommen, ist nirgends
/// zugesagt, und dieselbe Annahme hat die App im VLC-Engine schon einmal
/// zugesagt, und dieselbe Annahme hat die App schon einmal
/// zum Absturz gebracht. Der Umweg kostet einen Durchlauf — unbemerkbar
/// neben dem Tastendruck, der ihn ausgelöst hat.
private func add(
+4 -7
View File
@@ -6,8 +6,8 @@ import FlixyPlayback
///
/// Der Player verschwindet, wenn man ihn schließt — und mit ihm jeder Hinweis
/// darauf, woran es lag. Dieser Mitschnitt überlebt das und steht in den
/// Einstellungen. Aus „es läuft nicht" wird damit „VLC, lokale Verbindung,
/// hängt bei Öffnen" — und das ist ein Unterschied, der Stunden spart.
/// Einstellungen. Aus „es läuft nicht" wird damit „umgepackt, lokale
/// Verbindung, hängt bei Öffnen" — ein Unterschied, der Stunden spart.
@MainActor
@Observable
final class PlaybackDiagnostics {
@@ -65,11 +65,8 @@ final class PlaybackDiagnostics {
private static func describe(_ decision: PlaybackDecision) -> String {
switch decision.method {
case .directPlay(let engine):
switch engine {
case .avPlayer: "Direkt · AVPlayer"
case .vlc: "Direkt · VLC"
}
case .directPlay:
"Direkt"
case .remux:
decision.rationale.contains { if case .audioCodecUnsupported = $0 { true } else { false } }
? "Umgepackt · Bild unverändert, Ton umgerechnet"
-31
View File
@@ -1,12 +1,4 @@
import Foundation
// **`!os(tvOS)` und nicht nur `canImport`.** Der Fernseher verlinkt FlixyVLC
// nicht, aber der Abhängigkeits-Sucher von Clang prüft den Import trotzdem —
// und bricht dann an VLCKit ab, das für dieses Ziel gar nicht gebaut wird.
// „Unable to resolve module dependency: VLCKit" nennt dabei weder diese Datei
// noch VLC als Grund.
#if canImport(FlixyVLC) && !os(tvOS)
import FlixyVLC
#endif
import Observation
import FlixyPlayback
@@ -60,25 +52,6 @@ final class PlaybackSettings {
/// Die Geschwindigkeiten, mit denen eine Wiedergabe beginnen kann.
static let rateChoices: [Double] = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
/// Ob ein zweiter Spieler zur Verfügung steht.
///
/// VLC steckt in einem eigenen Paket, das nur für iOS gebaut wird — sein
/// Binärpaket hat keinen Teil für Mac Catalyst. Wo es fehlt, übernimmt der
/// Server: MKV und DTS werden dann umgerechnet statt unverändert
/// übertragen. Das kostet ihn Rechenzeit und sieht man nicht, aber es
/// spielt.
///
/// Auf dem Fernseher ebenfalls nicht: 920 MB Binärpaket für einen
/// Rückfallweg, der auf einem Apple TV selten greift, sind die Bauzeit
/// nicht wert.
static var hasFallbackEngine: Bool {
#if canImport(FlixyVLC) && !os(tvOS)
true
#else
false
#endif
}
private let defaults: UserDefaults
var wifiQuality: Quality { didSet { defaults.set(wifiQuality.rawValue, forKey: Key.wifiQuality) } }
@@ -165,10 +138,6 @@ final class PlaybackSettings {
return PlaybackPolicy(
allowsTranscoding: true,
// Nur, wenn es ihn auf dieser Plattform gibt. Ohne VLC wählte die
// Entscheidung sonst einen Spieler, den es nicht gibt — und die
// Wiedergabe bliebe schwarz, statt dass der Server umrechnet.
allowsFallbackEngine: Self.hasFallbackEngine,
maximumBitrate: quality.bitrateLimit
)
}
+1 -1
View File
@@ -90,7 +90,7 @@ final class PlexWebAuthenticator: NSObject {
callbackURLScheme: callbackScheme
) { [weak self] _, error in
// ASWebAuthenticationSession ruft den Abschlussblock auf dem
// Hauptthread auf — anders als VLCKit, wo dieselbe Annahme die App
// Hauptthread auf — anders als mancher Rückruf, wo dieselbe Annahme die App
// zum Absturz gebracht hat. Hier ist sie dokumentiert.
MainActor.assumeIsolated {
guard let self else { return }
+1 -1
View File
@@ -5,7 +5,7 @@
// einen Import genügte dabei nicht: der Abhängigkeits-Sucher von Clang prüft
// ihn trotzdem und bricht mit „Unable to resolve module dependency:
// TVServices" ab, ohne den Fernseher zu erwähnen. Dieselbe Falle wie bei
// VLCKit, siehe `PlaybackSettings`.
// siehe `PlaybackSettings`.
#if os(tvOS)
import Foundation
import MediaKit
+2 -5
View File
@@ -96,11 +96,8 @@ struct TechnicalDetails: View {
private var playbackMethodLabel: String {
switch decision.method {
case .directPlay(let engine):
switch engine {
case .avPlayer: String(localized: "technical.directPlayNative")
case .vlc: String(localized: "technical.directPlayVLC")
}
case .directPlay:
String(localized: "technical.directPlayNative")
// **Zwei Fälle unter einem Namen wären eine Lüge.** Umgepackt bleibt
// das Bild unberührt — der Ton nur dann, wenn AVPlayer ihn kennt.
case .remux:
+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
+2 -4
View File
@@ -516,11 +516,9 @@ struct PlayerView: View {
)
}
// **Bild-in-Bild von Hand.** Automatisch geht es bisher nur beim
// **Bild-in-Bild von Hand.** Automatisch geht es nur beim
// Verlassen der App — und auf dem Mac gar nicht. Der Knopf
// erscheint nur, wenn es wirklich ginge: bei VLC gibt es keine
// `AVPlayerLayer`, und ohne die kann das System kein kleines Fenster
// aufziehen.
// erscheint nur, wenn es wirklich ginge.
if model.canUsePictureInPicture {
Button {
model.togglePictureInPicture()
+1 -23
View File
@@ -6,11 +6,6 @@ import UIKit
#else
import AppKit
#endif
// 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
/// Die Ansichtsklasse der jeweiligen Plattform.
///
@@ -22,12 +17,7 @@ typealias PlatformView = UIView
typealias PlatformView = NSView
#endif
/// Die Bildfläche des laufendie Engine.
///
/// Die einzige Stelle in der App, an der der Unterschied zwischen den beiden
/// Engines sichtbar wird — weil beide ihr Bild auf unterschiedliche Weise
/// ausliefern: AVPlayer über eine `AVPlayerLayer`, VLC in eine gewöhnliche
/// `UIView`.
/// Die Bildfläche der laufenden Engine.
struct VideoSurface: View {
let engine: (any VideoPlayerEngine)?
/// Die Bildebene des Systemplayers, gehalten vom Modell.
@@ -36,8 +26,6 @@ struct VideoSurface: View {
/// entsteht, stirbt mit jedem Identitätswechsel der Ansicht — und genau
/// das passiert beim Wechsel zwischen Kachel und Vollbild. Das Bild
/// setzte dann jedes Mal neu an, sichtbar als Zucken und Schwarzbild.
/// VLC macht es seit jeher richtig und reicht seine `videoView` durch;
/// hier ist es jetzt derselbe Weg.
let surface: PlayerLayerView
/// Ob **diese** Ansicht die Bildfläche gerade haben soll.
@@ -61,21 +49,11 @@ struct VideoSurface: View {
var bottomInset: CGFloat = 0
var body: some View {
#if canImport(FlixyVLC) && !os(tvOS)
if let vlcEngine = engine as? VLCPlayerEngine {
SurfaceContainer(view: vlcEngine.videoView, isActive: isActive, bottomInset: bottomInset)
} else if engine is AVPlayerEngine {
SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset)
} else {
Color.black
}
#else
if engine is AVPlayerEngine {
SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset)
} else {
Color.black
}
#endif
}
}