Umgepackt wird als HLS, und ein Fehlschlag faellt auf VLC zurueck
Der Versuch mit einer fortlaufenden MP4 ist am Geraet gescheitert: AVErrorServerIncorrectlyConfigured (-11850), sofort. Der Grund ist einleuchtend, sobald man ihn kennt — AVPlayer verlangt bei einer fortlaufenden Datei Bereichsanfragen, er will hineinspringen koennen. Eine MP4, die ffmpeg gerade erst erzeugt, kann das nicht: sie hat weder Laenge noch ein Inhaltsverzeichnis am Anfang. Das war eine Annahme, die ich haette messen sollen, bevor ich darauf baue. Vom Ziel bleibt damit das Umpacken, nicht das weite Vorausladen — und das ist immer noch der groessere Teil: ein Matroska-Rip mit HEVC und AC3 landet bei AVPlayer statt bei VLC, mit Bild-in-Bild und AirPlay, ohne dass ein Bild neu gerechnet wird. `Container: mp4` statt `ts`, weil HLS als fragmentierte MP4 auch HEVC traegt; in MPEG-TS waere dafuer eine Neukodierung noetig, also genau das, was hier vermieden werden soll. Dazu ein Rueckzugsweg, der vorher gefehlt hat. Ob der Server die Stroeme wirklich kopieren kann, entscheidet sich erst beim Ausliefern, und manche Antworten nimmt AVPlayer nicht an. Das vorher zu wissen ist nicht moeglich; es hinterher aufzufangen schon. Scheitert das Umpacken, laeuft derselbe Titel einmalig mit VLC an, statt den Nutzer vor eine schwarze Wand mit einer Fehlernummer zu stellen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
62691fc2bc
commit
46550fc30b
@@ -198,6 +198,8 @@ final class PlayerModel {
|
|||||||
/// Ohne das gewönne die letzte Meldung von der alten Stelle gegen den
|
/// Ohne das gewönne die letzte Meldung von der alten Stelle gegen den
|
||||||
/// gerade ausgelösten Sprung.
|
/// gerade ausgelösten Sprung.
|
||||||
private var seekTarget: Duration?
|
private var seekTarget: Duration?
|
||||||
|
/// Ob schon einmal auf VLC ausgewichen wurde.
|
||||||
|
private var hasFallenBack = false
|
||||||
private var eventTask: Task<Void, Never>?
|
private var eventTask: Task<Void, Never>?
|
||||||
private var reportTask: Task<Void, Never>?
|
private var reportTask: Task<Void, Never>?
|
||||||
/// Was der Server für diese Wiedergabe angelegt hat.
|
/// Was der Server für diese Wiedergabe angelegt hat.
|
||||||
@@ -241,10 +243,13 @@ final class PlayerModel {
|
|||||||
/// geholt, wenn man sie zu Ende sieht.
|
/// geholt, wenn man sie zu Ende sieht.
|
||||||
private static func forwardBuffer(for method: PlaybackDecision.Method) -> Duration {
|
private static func forwardBuffer(for method: PlaybackDecision.Method) -> Duration {
|
||||||
switch method {
|
switch method {
|
||||||
// Beides eine fortlaufende Datei über Bereichsanfragen — da lässt sich
|
// Eine fortlaufende Datei über Bereichsanfragen — da lässt sich
|
||||||
// vorausladen, so weit man will.
|
// vorausladen, so weit man will.
|
||||||
case .directPlay, .remux: .seconds(600)
|
case .directPlay: .seconds(600)
|
||||||
case .transcode: .seconds(60)
|
// Umgepackt kommt als Abspielliste, genau wie umgerechnet. Der
|
||||||
|
// Unterschied liegt darin, dass kein Bild neu gerechnet wird, nicht
|
||||||
|
// darin, wie weit sich vorausladen lässt.
|
||||||
|
case .remux, .transcode: .seconds(60)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,7 +524,7 @@ final class PlayerModel {
|
|||||||
/// Ablauf ist derselbe, nur der Anlass nicht. Was sich unterscheidet,
|
/// Ablauf ist derselbe, nur der Anlass nicht. Was sich unterscheidet,
|
||||||
/// steht in `effectivePolicy`, nicht hier: die Wahl setzt `quality`, die
|
/// steht in `effectivePolicy`, nicht hier: die Wahl setzt `quality`, die
|
||||||
/// Automatik setzt `automaticCeiling`, und beides fließt dort zusammen.
|
/// Automatik setzt `automaticCeiling`, und beides fließt dort zusammen.
|
||||||
private func restart() async {
|
private func restart(forcing erzwungen: PlaybackDecision? = nil) async {
|
||||||
guard !isOffline,
|
guard !isOffline,
|
||||||
let backend, let item, let media = currentMedia, let part = currentPart
|
let backend, let item, let media = currentMedia, let part = currentPart
|
||||||
else { return }
|
else { return }
|
||||||
@@ -541,7 +546,11 @@ final class PlayerModel {
|
|||||||
engine = nil
|
engine = nil
|
||||||
phase = .preparing
|
phase = .preparing
|
||||||
|
|
||||||
let newDecision = PlaybackDecider().decide(
|
// Eine erzwungene Entscheidung steht über der Rechnung: sie kommt
|
||||||
|
// daher, dass der gerechnete Weg gerade **nachweislich** nicht
|
||||||
|
// funktioniert hat. Ihn noch einmal auszurechnen ergäbe denselben
|
||||||
|
// Weg und denselben Fehler.
|
||||||
|
let newDecision = erzwungen ?? PlaybackDecider().decide(
|
||||||
profile: media.profile(file: part),
|
profile: media.profile(file: part),
|
||||||
policy: effectivePolicy
|
policy: effectivePolicy
|
||||||
)
|
)
|
||||||
@@ -884,6 +893,23 @@ final class PlayerModel {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Der Rückzug auf VLC, wenn das Umpacken nicht getragen hat.
|
||||||
|
///
|
||||||
|
/// Genau **einmal** je Wiedergabe: scheitert auch VLC, 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 }
|
||||||
|
hasFallenBack = true
|
||||||
|
diagnostics.note("Umpacken abgelehnt — noch einmal mit VLC")
|
||||||
|
|
||||||
|
await restart(forcing: PlaybackDecision(
|
||||||
|
method: .directPlay(engine: .vlc),
|
||||||
|
rationale: (decision?.rationale ?? []) + [.transcodingUnavailable]
|
||||||
|
))
|
||||||
|
if case .failed = phase { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/// Die nächste Sprosse unterhalb der aktuellen — aus **unserer** Leiter.
|
/// Die nächste Sprosse unterhalb der aktuellen — aus **unserer** Leiter.
|
||||||
///
|
///
|
||||||
/// Nicht aus der Playlist: die hat in diesem Fall gerade keine, sonst
|
/// Nicht aus der Playlist: die hat in diesem Fall gerade keine, sonst
|
||||||
@@ -1435,6 +1461,22 @@ final class PlayerModel {
|
|||||||
// abzulesen, ob die Engine hängt oder die Leitung.
|
// abzulesen, ob die Engine hängt oder die Leitung.
|
||||||
diagnostics.note("Zustand: \(state)")
|
diagnostics.note("Zustand: \(state)")
|
||||||
if case .failed(let failure) = state {
|
if case .failed(let failure) = state {
|
||||||
|
diagnostics.note("Wiedergabe gescheitert: \(failure)")
|
||||||
|
|
||||||
|
// **Scheitert das Umpacken, bleibt VLC.**
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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 }
|
||||||
|
|
||||||
diagnostics.finish(outcome: "Wiedergabe gescheitert: \(failure)")
|
diagnostics.finish(outcome: "Wiedergabe gescheitert: \(failure)")
|
||||||
// Der Grund gehört auf den Bildschirm, nicht nur ins
|
// Der Grund gehört auf den Bildschirm, nicht nur ins
|
||||||
// Protokoll: „die Wiedergabe wurde unterbrochen" allein
|
// Protokoll: „die Wiedergabe wurde unterbrochen" allein
|
||||||
|
|||||||
@@ -135,25 +135,37 @@ extension JellyfinBackend {
|
|||||||
// Jellyfin rechnet in Bit, die App in Kilobit.
|
// Jellyfin rechnet in Bit, die App in Kilobit.
|
||||||
let bitsPerSecond = (transcode.maximumBitrate ?? 8_000) * 1_000
|
let bitsPerSecond = (transcode.maximumBitrate ?? 8_000) * 1_000
|
||||||
|
|
||||||
// **Umpacken: eine fortlaufende MP4, und die Ströme unverändert.**
|
// **Umpacken: dieselben Ströme, andere Schachtel.**
|
||||||
//
|
|
||||||
// `Protocol: http` statt `hls` ist der entscheidende Unterschied. Eine
|
|
||||||
// Abspielliste lässt sich nicht vorausladen — AVPlayer holt Segment
|
|
||||||
// für Segment und Apple lässt uns dazwischen nicht eingreifen. Eine
|
|
||||||
// fortlaufende Datei kommt dagegen über gewöhnliche Bereichsanfragen,
|
|
||||||
// und die kann man so weit vorausholen, wie man will.
|
|
||||||
//
|
//
|
||||||
// Die Codecs stehen genau so breit wie das, was AVPlayer wirklich
|
// Die Codecs stehen genau so breit wie das, was AVPlayer wirklich
|
||||||
// kann: trifft der Server eine Übereinstimmung, kopiert er den Strom,
|
// kann: trifft der Server eine Übereinstimmung, **kopiert** er den
|
||||||
// statt ihn neu zu rechnen. Genau dafür ist der Weg da.
|
// Strom, statt ihn neu zu rechnen. Genau dafür ist der Weg da — ein
|
||||||
|
// Matroska-Rip mit HEVC und AC3 kommt so bei AVPlayer an, mit
|
||||||
|
// Bild-in-Bild und AirPlay, ohne dass ein Bild angefasst wurde.
|
||||||
|
//
|
||||||
|
// **Und doch als HLS, obwohl eine fortlaufende Datei schöner wäre.**
|
||||||
|
// Der Versuch mit `Protocol: http` ist am Gerät gescheitert:
|
||||||
|
// AVPlayer verlangt bei einer fortlaufenden Datei Bereichsanfragen —
|
||||||
|
// er will hineinspringen können. Eine MP4, die ffmpeg gerade erst
|
||||||
|
// erzeugt, kann das nicht: sie hat weder Länge noch ein
|
||||||
|
// Inhaltsverzeichnis am Anfang. Die Antwort war
|
||||||
|
// `AVErrorServerIncorrectlyConfigured` (-11850), und zwar sofort.
|
||||||
|
//
|
||||||
|
// Damit bleibt vom ursprünglichen Ziel das Umpacken, nicht das weite
|
||||||
|
// Vorausladen. Das ist immer noch der grössere Teil des Gewinns.
|
||||||
|
//
|
||||||
|
// `Container: mp4` statt `ts`: als fragmentierte MP4 trägt HLS auch
|
||||||
|
// HEVC. In MPEG-TS wäre dafür eine Neukodierung nötig — also genau
|
||||||
|
// das, was hier vermieden werden soll.
|
||||||
let umpacken: [String: Any] = [
|
let umpacken: [String: Any] = [
|
||||||
"Type": "Video",
|
"Type": "Video",
|
||||||
"Container": "mp4",
|
"Container": "mp4",
|
||||||
"Protocol": "http",
|
"Protocol": "hls",
|
||||||
"VideoCodec": "h264,hevc",
|
"VideoCodec": "h264,hevc",
|
||||||
"AudioCodec": "aac,ac3,eac3,mp3",
|
"AudioCodec": "aac,ac3,eac3,mp3",
|
||||||
"Context": "Streaming",
|
"Context": "Streaming",
|
||||||
"MaxAudioChannels": "8",
|
"MaxAudioChannels": "8",
|
||||||
|
"MinSegments": 1,
|
||||||
]
|
]
|
||||||
|
|
||||||
let profile: [String: Any] = [
|
let profile: [String: Any] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user