diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 2741902..dc3b7ad 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -198,6 +198,8 @@ 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. + private var hasFallenBack = false private var eventTask: Task? private var reportTask: Task? /// Was der Server für diese Wiedergabe angelegt hat. @@ -241,10 +243,13 @@ final class PlayerModel { /// geholt, wenn man sie zu Ende sieht. private static func forwardBuffer(for method: PlaybackDecision.Method) -> Duration { 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. - case .directPlay, .remux: .seconds(600) - case .transcode: .seconds(60) + case .directPlay: .seconds(600) + // 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, /// steht in `effectivePolicy`, nicht hier: die Wahl setzt `quality`, die /// Automatik setzt `automaticCeiling`, und beides fließt dort zusammen. - private func restart() async { + private func restart(forcing erzwungen: PlaybackDecision? = nil) async { guard !isOffline, let backend, let item, let media = currentMedia, let part = currentPart else { return } @@ -541,7 +546,11 @@ final class PlayerModel { engine = nil 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), policy: effectivePolicy ) @@ -884,6 +893,23 @@ final class PlayerModel { 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. /// /// 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. diagnostics.note("Zustand: \(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)") // Der Grund gehört auf den Bildschirm, nicht nur ins // Protokoll: „die Wiedergabe wurde unterbrochen" allein diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift index b42d538..424a38a 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift @@ -135,25 +135,37 @@ extension JellyfinBackend { // Jellyfin rechnet in Bit, die App in Kilobit. let bitsPerSecond = (transcode.maximumBitrate ?? 8_000) * 1_000 - // **Umpacken: eine fortlaufende MP4, und die Ströme unverändert.** - // - // `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. + // **Umpacken: dieselben Ströme, andere Schachtel.** // // Die Codecs stehen genau so breit wie das, was AVPlayer wirklich - // kann: trifft der Server eine Übereinstimmung, kopiert er den Strom, - // statt ihn neu zu rechnen. Genau dafür ist der Weg da. + // kann: trifft der Server eine Übereinstimmung, **kopiert** er den + // 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] = [ "Type": "Video", "Container": "mp4", - "Protocol": "http", + "Protocol": "hls", "VideoCodec": "h264,hevc", "AudioCodec": "aac,ac3,eac3,mp3", "Context": "Streaming", "MaxAudioChannels": "8", + "MinSegments": 1, ] let profile: [String: Any] = [