Ein Blu-ray-Rip liegt als Matroska vor, und AVFoundation oeffnet Matroska nicht — obwohl das Bild darin H.264 oder HEVC ist und der Ton AC3, also alles, was AVPlayer bestens kann. Im Weg stand allein die Verpackung. Dafuer gibt es jetzt einen dritten Weg neben Durchreichen und Neurechnen: umpacken. Dieselben Stroeme, Byte fuer Byte, in eine MP4. Kein Bild wird angefasst, es geht nichts an Qualitaet verloren, und den Server kostet es fast nichts. Entscheidend ist dabei `Protocol: http` statt `hls`. Eine Abspielliste laesst sich nicht vorausladen — AVPlayer holt Segment fuer Segment, und Apple laesst uns dazwischen nicht eingreifen. Eine fortlaufende Datei kommt ueber gewoehnliche Bereichsanfragen, und die kann man so weit vorausholen, wie man will. Der Vorratswunsch steht dort deshalb bei zehn Minuten statt bei einer. Der Gewinn ist gross: solche Titel bekommen Bild-in-Bild, AirPlay und die Systembedienung, die es bei VLC nicht gibt. Umgepackt wird nur, wenn **allein** die Schachtel im Weg steht. Stimmt auch nur ein Codec nicht — DTS, TrueHD, AV1 —, fehlt nicht der Zugang zur Datei, sondern das Koennen; dann bleibt es bei VLC. Sechs Tests halten die Grenze fest, in beide Richtungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
88 lines
2.9 KiB
Swift
88 lines
2.9 KiB
Swift
import Foundation
|
|
import Observation
|
|
import FlixyPlayback
|
|
|
|
/// Hält fest, was beim letzten Abspielversuch passiert ist.
|
|
///
|
|
/// 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.
|
|
@MainActor
|
|
@Observable
|
|
final class PlaybackDiagnostics {
|
|
struct Attempt: Identifiable {
|
|
let id = UUID()
|
|
let startedAt: Date
|
|
var title: String
|
|
var method: String
|
|
var connection: String
|
|
/// Die Adresse ohne Abfrageteil.
|
|
///
|
|
/// Der Token steckt in der Abfrage. Er hat in einem Protokoll nichts
|
|
/// zu suchen — es landet in Screenshots, und wer ihn hat, kommt an die
|
|
/// ganze Mediathek.
|
|
var sourceDescription: String
|
|
var steps: [String] = []
|
|
var outcome: String?
|
|
}
|
|
|
|
private(set) var lastAttempt: Attempt?
|
|
|
|
func begin(title: String, connection: String?) {
|
|
lastAttempt = Attempt(
|
|
startedAt: Date(),
|
|
title: title,
|
|
method: "—",
|
|
connection: connection ?? "—",
|
|
sourceDescription: "—"
|
|
)
|
|
note("Player geöffnet")
|
|
}
|
|
|
|
func record(decision: PlaybackDecision, source: URL) {
|
|
lastAttempt?.method = Self.describe(decision)
|
|
lastAttempt?.sourceDescription = Self.describe(source)
|
|
note("Quelle gewählt")
|
|
}
|
|
|
|
/// Ein Zwischenschritt, mit Zeitabstand zum Beginn.
|
|
///
|
|
/// Der Abstand ist die eigentliche Auskunft: steht zwischen „Quelle
|
|
/// gewählt" und „spielt" eine Minute, liegt es an der Leitung; kommt
|
|
/// „spielt" gar nicht, am Engine.
|
|
func note(_ step: String) {
|
|
guard var attempt = lastAttempt else { return }
|
|
let elapsed = Date().timeIntervalSince(attempt.startedAt)
|
|
attempt.steps.append(String(format: "%.1fs %@", elapsed, step))
|
|
lastAttempt = attempt
|
|
}
|
|
|
|
func finish(outcome: String) {
|
|
lastAttempt?.outcome = outcome
|
|
note(outcome)
|
|
}
|
|
|
|
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 .remux: "Umgepackt · MP4, ohne Neukodierung"
|
|
case .transcode(let limit):
|
|
limit.map { "Transcode · max. \($0 / 1000) Mbit" } ?? "Transcode"
|
|
}
|
|
}
|
|
|
|
|
|
private static func describe(_ url: URL) -> String {
|
|
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
|
return "—"
|
|
}
|
|
components.query = nil
|
|
return components.string ?? "—"
|
|
}
|
|
}
|