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>
133 lines
6.0 KiB
Swift
133 lines
6.0 KiB
Swift
import Foundation
|
|
import MediaKit
|
|
|
|
/// Entscheidet, wie ein Titel abgespielt wird.
|
|
///
|
|
/// Reine Rechnung ohne Netz und ohne Zustand — deshalb vollständig testbar.
|
|
/// Die zugehörige Testtabelle in `PlaybackDeciderTests` ist die wichtigste im
|
|
/// Projekt: sie hält fest, welche Datei auf welchem Weg landet, und schlägt
|
|
/// an, sobald jemand die Fähigkeitenliste anfasst.
|
|
public struct PlaybackDecider: Sendable {
|
|
private let capabilities: DeviceCapabilities
|
|
|
|
public init(capabilities: DeviceCapabilities = .avFoundation) {
|
|
self.capabilities = capabilities
|
|
}
|
|
|
|
public func decide(
|
|
profile: MediaProfile,
|
|
policy: PlaybackPolicy
|
|
) -> PlaybackDecision {
|
|
var rationale: [PlaybackDecision.Rationale] = []
|
|
|
|
// 0. Hat jemand ausdrücklich eine Stufe gewählt, wird umgerechnet —
|
|
// auch wenn die Datei die Grenze gar nicht überschreitet. Sonst
|
|
// bliebe die Wahl folgenlos: für eine 8-Mbit-Datei „12 Mbit" zu
|
|
// wählen ergäbe DirectPlay, und am Bild änderte sich nichts.
|
|
if policy.forcesTranscoding, policy.allowsTranscoding {
|
|
rationale.append(.qualityChosen)
|
|
return PlaybackDecision(
|
|
method: .transcode(maximumBitrate: policy.maximumBitrate),
|
|
rationale: rationale
|
|
)
|
|
}
|
|
|
|
// 1. Reicht die Leitung? Das kommt zuerst, weil es unabhängig vom
|
|
// Format ist: eine 80-Mbit-Datei über Mobilfunk ist auch dann keine
|
|
// gute Idee, wenn AVPlayer sie problemlos öffnen könnte.
|
|
if let limit = policy.maximumBitrate, let bitrate = profile.bitrate, bitrate > limit {
|
|
rationale.append(.bitrateExceedsLimit(mediaBitrate: bitrate, limit: limit))
|
|
if policy.allowsTranscoding {
|
|
return PlaybackDecision(method: .transcode(maximumBitrate: limit), rationale: rationale)
|
|
}
|
|
// Ohne Transcoder bleibt nur, es trotzdem zu versuchen. Das ist
|
|
// ehrlicher als eine Fehlermeldung: im WLAN klappt es meistens,
|
|
// und der Grund steht in der Begründung.
|
|
rationale.append(.transcodingUnavailable)
|
|
}
|
|
|
|
// 2. Kennt der Server die Datei überhaupt?
|
|
let container = normalized(profile.container)
|
|
guard profile.hasKnownStreams, let videoCodec = normalized(profile.videoCodec) else {
|
|
rationale.append(.streamsUnknown)
|
|
return fallback(policy: policy, rationale: rationale)
|
|
}
|
|
|
|
// 3. Kann AVPlayer das?
|
|
var blockers: [PlaybackDecision.Rationale] = []
|
|
if let container, !capabilities.containers.contains(container) {
|
|
blockers.append(.containerUnsupported(container))
|
|
}
|
|
if !capabilities.videoCodecs.contains(videoCodec) {
|
|
blockers.append(.videoCodecUnsupported(videoCodec))
|
|
}
|
|
// Geprüft wird die Spur, die tatsächlich klingen soll — welche das
|
|
// ist, hat der Aufrufer beim Bauen des Profils entschieden.
|
|
if let audioCodec = normalized(profile.audioCodec),
|
|
!capabilities.audioCodecs.contains(audioCodec) {
|
|
blockers.append(.audioCodecUnsupported(audioCodec))
|
|
}
|
|
|
|
guard blockers.isEmpty else {
|
|
rationale.append(contentsOf: blockers)
|
|
|
|
// **Steht nur die Schachtel im Weg, wird umgepackt.**
|
|
//
|
|
// Genau das ist der Blu-ray-Rip: Matroska aussen, H.264 oder HEVC
|
|
// innen, AC3 dazu. AVPlayer könnte jeden dieser Ströme — er kommt
|
|
// nur nicht an sie heran. Umpacken kostet den Server fast nichts
|
|
// und verliert nichts, und der Titel landet danach bei AVPlayer
|
|
// statt bei VLC: mit Bild-in-Bild, AirPlay und einer Datei, die
|
|
// sich weit vorausladen lässt.
|
|
//
|
|
// Stimmt dagegen auch nur ein Codec nicht, hilft Umpacken nicht —
|
|
// dann fehlt nicht der Zugang, sondern das Können.
|
|
let nurDieSchachtel = blockers.allSatisfy {
|
|
if case .containerUnsupported = $0 { return true }
|
|
return false
|
|
}
|
|
if nurDieSchachtel, policy.allowsTranscoding,
|
|
let container, DeviceCapabilities.remuxableContainers.contains(container) {
|
|
return PlaybackDecision(method: .remux, rationale: rationale)
|
|
}
|
|
|
|
return fallback(policy: policy, rationale: rationale)
|
|
}
|
|
|
|
rationale.append(.nativelySupported)
|
|
return PlaybackDecision(method: .directPlay(engine: .avPlayer), rationale: rationale)
|
|
}
|
|
|
|
/// Was passiert, wenn AVPlayer nicht kann.
|
|
///
|
|
/// VLCKit hat Vorrang vor dem Transcoder: die Datei geht unverändert über
|
|
/// die Leitung, der Server muss nicht rechnen, und die Bildqualität bleibt
|
|
/// die des Originals. Umgerechnet wird erst, wenn auch VLC ausscheidet.
|
|
private func fallback(
|
|
policy: PlaybackPolicy,
|
|
rationale: [PlaybackDecision.Rationale]
|
|
) -> PlaybackDecision {
|
|
if policy.allowsFallbackEngine {
|
|
return PlaybackDecision(method: .directPlay(engine: .vlc), rationale: rationale)
|
|
}
|
|
if policy.allowsTranscoding {
|
|
return PlaybackDecision(
|
|
method: .transcode(maximumBitrate: policy.maximumBitrate),
|
|
rationale: rationale
|
|
)
|
|
}
|
|
var rationale = rationale
|
|
rationale.append(.transcodingUnavailable)
|
|
return PlaybackDecision(method: .directPlay(engine: .avPlayer), rationale: rationale)
|
|
}
|
|
|
|
/// Plex schreibt Codecs mal groß, mal klein, und hängt bei Containern
|
|
/// gelegentlich Punkte an. Ein Vergleich ohne diese Angleichung geht
|
|
/// sporadisch schief — und zwar nur bei manchen Dateien, was die Suche
|
|
/// nach dem Grund unangenehm macht.
|
|
private func normalized(_ value: String?) -> String? {
|
|
guard let value, !value.isEmpty else { return nil }
|
|
return value.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ". "))
|
|
}
|
|
}
|