Flixy heißt ab hier Halo

Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit,
damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind.

712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25
Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit
`HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`,
`JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben.

`docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind
ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben
hiesse, die Vergangenheit zu glätten.

**Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis:
`com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte
Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container;
Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu
laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen.

**Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der
Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr
heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter
`…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur
anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim
Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor
dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet.

Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht
statt umbenannt — sie steht ohnehin nicht im Repo.

Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist
ein Handgriff ausserhalb des Projekts.

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
@@ -0,0 +1,216 @@
import Foundation
import MediaKit
/// Welche Tonspur genommen wird — und warum.
///
/// **Warum als reiner Typ.** Spuren und Wunsch hinein, eine Spur heraus. Kein
/// Netz, keine Einstellungen, keine Ansicht. Dieselbe Bauform wie
/// `PlaybackDecider` und `StreamHealth`, und aus demselben Grund: eine
/// Auswahl, die man nur im laufenden Film beobachten kann, ist nicht zu
/// beurteilen. Hier lässt sich der ZDF-Fall wörtlich hinschreiben.
///
/// **Warum es nicht reicht, das Kürzel zu vergleichen.** Weil Kürzel lügen.
/// Es gibt Dateien, in denen die als deutsch ausgezeichnete Spur die mit
/// Audiodeskription ist und die als englisch ausgezeichnete die eigentliche
/// deutsche. Jellyfin hat **kein Kennzeichen für Audiodeskription** —
/// `MediaStream` führt `IsDefault`, `IsForced`, `IsHearingImpaired`,
/// `IsOriginal`, mehr nicht. Bleibt der Name der Spur, und der ist Text.
///
/// Deshalb kein Filtern, sondern **Abwägen**: jede Spur bekommt Punkte, die
/// höchste gewinnt. Damit gibt es immer eine Antwort, auch wenn nichts passt
/// — und Audiodeskription wird gemieden, ohne unmöglich zu werden.
public enum AudioTrackChooser {
/// Was gewünscht ist.
public struct Wish: Sendable, Equatable {
/// Die Sprache, die gelten soll — aus Serie, Mediathek oder allgemein.
public let language: String?
/// Die Rangliste, wenn die Wunschsprache fehlt. Von oben nach unten.
public let fallbacks: [String]
/// Was zuletzt für **diese Serie** gewählt wurde.
public let remembered: TrackSignature?
/// Ob Audiodeskription gemieden werden soll.
public let avoidsAudioDescription: Bool
public init(
language: String? = nil,
fallbacks: [String] = [],
remembered: TrackSignature? = nil,
avoidsAudioDescription: Bool = true
) {
self.language = language
self.fallbacks = fallbacks
self.remembered = remembered
self.avoidsAudioDescription = avoidsAudioDescription
}
}
/// Drei Ebenen, eine Antwort.
///
/// Serie → Mediathek → allgemein
///
/// Gefragt wird von unten nach oben; die erste Antwort gilt. Bei Filmen
/// gibt es die Serienebene nicht, dort endet es bei der Mediathek.
///
/// **Warum die Rangfolge hier steht und nicht bei den Einstellungen.**
/// Weil sie eine Regel ist, keine Ablage. Im App-Ziel liesse sie sich
/// nicht mit einer Wertefolge durchspielen — hier schon, und das ist der
/// Unterschied zwischen einer geprüften und einer geglaubten Rangfolge.
///
/// Eine leere Zeichenkette zählt als „nichts gesagt": in den Einstellungen
/// steht sie für „soll der Server entscheiden", und das ist keine Sprache,
/// sondern das Ausbleiben einer Antwort.
public static func resolve(
series: String?,
library: String?,
general: String?,
fallbacks: [String] = [],
remembered: TrackSignature? = nil,
avoidsAudioDescription: Bool = true
) -> Wish {
func gesagt(_ wert: String?) -> String? {
guard let wert, !wert.trimmingCharacters(in: .whitespaces).isEmpty else { return nil }
return wert
}
return Wish(
language: gesagt(series) ?? gesagt(library) ?? gesagt(general),
fallbacks: fallbacks,
remembered: remembered,
avoidsAudioDescription: avoidsAudioDescription
)
}
/// Wie eine Spur aussieht — genug, um sie in der nächsten Folge
/// wiederzuerkennen.
///
/// **Keine Nummer.** Folgen derselben Serie haben unterschiedlich
/// sortierte Spuren; „Spur 2" bedeutet in der nächsten Folge etwas
/// anderes. Eine Beschreibung überlebt das.
public struct TrackSignature: Sendable, Equatable, Codable {
public let languageCode: String?
public let title: String?
public let channels: Int?
public let codec: String?
public init(languageCode: String?, title: String?, channels: Int?, codec: String?) {
self.languageCode = LanguageCode.normalized(languageCode)
self.title = title?.lowercased()
self.channels = channels
self.codec = codec?.lowercased()
}
public init(_ stream: MediaStream) {
self.init(
languageCode: stream.languageCode,
title: stream.title ?? stream.displayTitle,
channels: stream.channels,
codec: stream.codec
)
}
/// Wie gut eine Spur zu dieser Beschreibung passt — 0 bis 4.
func similarity(to stream: MediaStream) -> Int {
var punkte = 0
if let languageCode, LanguageCode.matches(languageCode, stream.languageCode) { punkte += 1 }
if let title, let anderer = (stream.title ?? stream.displayTitle)?.lowercased(),
title == anderer { punkte += 2 }
if let channels, channels == stream.channels { punkte += 1 }
return punkte
}
}
/// Wortmarken für Audiodeskription.
///
/// Nur am Text zu erkennen, weil es kein Kennzeichen dafür gibt. Die
/// Liste steht an **einer** Stelle und ist geprüft.
static let audioDescriptionMarkers = [
"audiodeskription", "audio-deskription", "audio description",
"audiodescription", "hörfilm", "hoerfilm", "descriptive",
"described", "narration",
]
/// Wortmarken für Kommentarspuren.
static let commentaryMarkers = [
"kommentar", "commentary", "director", "regisseur",
]
/// Wählt die Spur.
///
/// Gibt `nil` zurück, wenn es keine einzige gibt — sonst immer eine.
public static func choose(from streams: [MediaStream], wish: Wish) -> MediaStream? {
let tonspuren = streams.filter { $0.kind == .audio }
guard !tonspuren.isEmpty else { return nil }
let bewertet = tonspuren.map { (stream: $0, punkte: score($0, wish: wish)) }
// Bei Gleichstand die kleinere Nummer: dieselbe Datei soll immer
// dasselbe ergeben, sonst ist ein Fehler nicht wiederholbar.
return bewertet
.sorted {
$0.punkte == $1.punkte
? ($0.stream.index ?? .max) < ($1.stream.index ?? .max)
: $0.punkte > $1.punkte
}
.first?
.stream
}
/// Die Punkte einer einzelnen Spur. Offen gelegt, damit die Tests die
/// Abwägung selbst nachrechnen können.
public static func score(_ stream: MediaStream, wish: Wish) -> Int {
var punkte = 0
// Was zuletzt gewählt wurde, wiegt am schwersten — es ist die einzige
// Angabe, die von einem Menschen kommt und nicht aus der Datei.
if let remembered = wish.remembered {
punkte += remembered.similarity(to: stream) * 25
}
if let language = wish.language, LanguageCode.matches(language, stream.languageCode) {
punkte += 60
} else {
// Die Rangliste, von oben nach unten. Weiter unten heißt weniger.
for (rang, sprache) in wish.fallbacks.enumerated()
where LanguageCode.matches(sprache, stream.languageCode) {
punkte += max(5, 50 - rang * 5)
break
}
}
if stream.isDefault { punkte += 10 }
punkte += stream.channels ?? 0
// **Abzug, kein Ausschluss.** Gibt es eine Sprache nur mit
// Audiodeskription, ist sie immer noch besser als Stille.
if wish.avoidsAudioDescription, looksLikeAudioDescription(stream) { punkte -= 200 }
if looksLikeCommentary(stream) { punkte -= 200 }
return punkte
}
static func looksLikeAudioDescription(_ stream: MediaStream) -> Bool {
contains(stream, any: audioDescriptionMarkers) || standsAlone(stream, marker: "ad")
}
static func looksLikeCommentary(_ stream: MediaStream) -> Bool {
contains(stream, any: commentaryMarkers)
}
private static func contains(_ stream: MediaStream, any markers: [String]) -> Bool {
let text = [stream.title, stream.displayTitle]
.compactMap(\.self)
.joined(separator: " ")
.lowercased()
return markers.contains { text.contains($0) }
}
/// „AD" als eigenes Wort — nicht als Teil von „Broadcast" oder „Advanced".
private static func standsAlone(_ stream: MediaStream, marker: String) -> Bool {
let text = [stream.title, stream.displayTitle]
.compactMap(\.self)
.joined(separator: " ")
.lowercased()
return text
.split(whereSeparator: { !$0.isLetter && !$0.isNumber })
.contains { $0 == marker }
}
}
@@ -0,0 +1,63 @@
import Foundation
/// Was AVPlayer auf diesem Gerät nativ abspielen kann.
///
/// Diese Liste ist der Grund, warum es das Umpacken gibt: iOS spielt weder
/// Matroska-Dateien noch DTS- oder TrueHD-Ton, und genau das liegt in
/// typischen Beständen. Alles, was hier nicht steht, lässt die App vom Server
/// umpacken — beim Abspielen wie beim Herunterladen.
public struct DeviceCapabilities: Sendable, Equatable {
public var containers: Set<String>
public var videoCodecs: Set<String>
public var audioCodecs: Set<String>
public init(containers: Set<String>, videoCodecs: Set<String>, audioCodecs: Set<String>) {
self.containers = containers
self.videoCodecs = videoCodecs
self.audioCodecs = audioCodecs
}
/// Der Stand für iOS 26 auf allen Geräten, die diese Version tragen
/// (A12 aufwärts).
public static let avFoundation = DeviceCapabilities(
// Kein `mkv`, kein `avi`, kein `ts`. Das ist keine Auslassung, sondern
// der Kern des Problems: Matroska ist der übliche Container für
// Remuxe, und AVFoundation öffnet ihn nicht.
containers: ["mp4", "m4v", "mov", "qt"],
// AV1 fehlt bewusst: Hardware-Dekodierung gibt es erst ab A17 Pro, und
// Software-Dekodierung von 4K-AV1 bringt auch neue Geräte an die
// Grenze. Umrechnen ist dort das Verlässlichere.
videoCodecs: ["h264", "avc1", "hevc", "h265", "hvc1", "dvh1", "dvhe"],
// DTS, TrueHD und Vorbis fehlen — die drei Gründe, warum ein sonst
// tadelloser MP4-Film trotzdem stumm bliebe.
audioCodecs: ["aac", "mp3", "ac3", "eac3", "alac", "pcm", "lpcm"]
)
/// Schachteln, die sich verlustfrei in eine MP4 umpacken lassen.
///
/// Nicht jede: ein Container kann Dinge enthalten, für die MP4 keinen
/// Platz hat. Diese drei sind die, die in echten Beständen vorkommen und
/// deren Ströme regelmässig MP4-tauglich sind.
public static let remuxableContainers: Set<String> = ["mkv", "matroska", "ts", "m2ts", "avi"]
/// Ob diese Datei so, wie sie ist, geöffnet werden kann.
///
/// **Hier und nicht beim Aufrufer**, weil es die Frage inzwischen zweimal
/// gibt: beim Abspielen entscheidet sie `PlaybackDecider`, beim
/// Herunterladen muss sie vorher beantwortet sein — was offline liegt,
/// kann hinterher niemand mehr umpacken.
///
/// **Was fehlt, zählt als passend.** Nennt der Server einen Codec nicht,
/// ist das keine Aussage über seine Untauglichkeit. Vorsichtshalber
/// umzupacken hiesse, aus einer tadellosen MP4 eine neu gerechnete Datei
/// zu machen.
public func opens(container: String?, videoCodec: String?, audioCodec: String?) -> Bool {
func passt(_ wert: String?, in liste: Set<String>) -> Bool {
guard let wert, !wert.isEmpty else { return true }
return liste.contains(wert.lowercased())
}
return passt(container, in: containers)
&& passt(videoCodec, in: videoCodecs)
&& passt(audioCodec, in: audioCodecs)
}
}
@@ -0,0 +1,132 @@
import Foundation
/// Wann der **teure** Weg gegangen werden darf.
///
/// Der billige Hebel ist `preferredPeakBitRate`: er wirkt ohne Neuaufbau.
/// Wenn er nichts ausrichtet — weil die Playlist nur eine Stufe hat oder weil
/// schon die unterste läuft —, bleibt nur der Neuaufbau mit kleinerer Bitrate:
/// Transcoder anhalten, Engine abreißen, neu aushandeln, zurückspringen. Das
/// sind Sekunden mit schwarzem Bild.
///
/// **Ein Notnagel, keine Regelschleife.** Zu oft ausgelöst erzeugt er genau
/// das Ruckeln, das er beheben soll. Deshalb steht hier eine Liste von
/// Bedingungen, die **alle** gelten müssen — und nicht eine Schwelle.
///
/// Eigener Typ ohne Uhr und ohne Netz, damit sich jede einzelne Bedingung
/// einzeln hinschreiben und prüfen lässt. Eine Regel, die nur im laufenden
/// Film zu beobachten ist, ist keine Regel.
public enum DowngradeRule {
/// Wie lange nach dem Start nichts ausgelöst wird.
///
/// Das anfängliche Füllen des Puffers sieht aus wie eine Notlage und ist
/// keine.
public static let settleIn: Duration = .seconds(10)
/// Wie lange nach einem Sprung nichts ausgelöst wird.
///
/// Ein Sprung leert den Puffer **immer**. Ihn als Beleg für eine schlechte
/// Leitung zu nehmen hieße, sich für das eigene Tun zu bestrafen.
public static let afterSeek: Duration = .seconds(10)
/// Sperrfrist zwischen zwei Absenkungen.
public static let cooldown: Duration = .seconds(180)
/// Das Fenster, in dem Rückler gezählt werden.
public static let window: Duration = .seconds(90)
/// Ab wie vielen Rücklern im Fenster es genügt.
public static let stallsNeeded = 3
/// Ab welcher Länge ein **einzelner** Rückler genügt.
///
/// Fünfzehn Sekunden Stillstand sind keine Schwankung mehr. Ihn
/// mitzuzählen, bis drei zusammenkommen, hieße viereinhalb Minuten
/// zusehen.
public static let longStall: Duration = .seconds(15)
/// Was zum Zeitpunkt der Frage gilt.
public struct Facts: Sendable, Equatable {
/// Ob überhaupt eingegriffen werden darf. Eine ausdrücklich gewählte
/// Stufe ist eine Ansage und wird nicht überstimmt.
public let isAutomatic: Bool
/// Ob die Playlist mehrere Stufen führt.
public let hasLadder: Bool
/// Ob schon die unterste davon läuft.
public let isAtLowestRung: Bool
public let condition: StreamCondition
public let stallsInWindow: Int
public let longestStallInWindow: Duration
/// Wie lange schon gespielt wird.
public let playedFor: Duration
/// Wie lange der letzte Sprung her ist. `nil` heißt: keiner gewesen.
public let sinceLastSeek: Duration?
/// Wie lange die letzte Absenkung her ist. `nil` heißt: keine gewesen.
public let sinceLastDowngrade: Duration?
/// Wie viele es in dieser Wiedergabe schon gab.
public let used: Int
/// Wie viele es höchstens geben darf.
public let budget: Int
public init(
isAutomatic: Bool,
hasLadder: Bool,
isAtLowestRung: Bool,
condition: StreamCondition,
stallsInWindow: Int,
longestStallInWindow: Duration,
playedFor: Duration,
sinceLastSeek: Duration? = nil,
sinceLastDowngrade: Duration? = nil,
used: Int = 0,
budget: Int = 2
) {
self.isAutomatic = isAutomatic
self.hasLadder = hasLadder
self.isAtLowestRung = isAtLowestRung
self.condition = condition
self.stallsInWindow = stallsInWindow
self.longestStallInWindow = longestStallInWindow
self.playedFor = playedFor
self.sinceLastSeek = sinceLastSeek
self.sinceLastDowngrade = sinceLastDowngrade
self.used = used
self.budget = budget
}
}
/// Warum **nicht** abgesenkt wird.
///
/// Als Aufzählung statt als `Bool`, weil genau das im Protokoll stehen
/// soll: „es hakt, aber wir tun nichts" ist ohne Grund von einem Fehler
/// nicht zu unterscheiden.
public enum Verdict: Sendable, Equatable {
case downgrade
/// Eine gewählte Stufe wird nicht überstimmt.
case notAutomatic
/// Der billige Hebel ist noch nicht ausgereizt.
case cheaperPathRemains
/// Es hakt nicht genug.
case notBadEnough
/// Zu früh nach Start oder Sprung.
case tooEarly
/// Sperrfrist läuft noch.
case cooling
/// Verbraucht. Ab hier gehört es dem Nutzer gesagt.
case budgetSpent
}
public static func verdict(_ facts: Facts) -> Verdict {
guard facts.isAutomatic else { return .notAutomatic }
// Solange es noch eine Sprosse tiefer gibt, ist der billige Weg der
// richtige — er kostet kein schwarzes Bild.
if facts.hasLadder, !facts.isAtLowestRung { return .cheaperPathRemains }
let schlimmGenug = facts.stallsInWindow >= stallsNeeded
|| facts.longestStallInWindow >= longStall
guard schlimmGenug, facts.condition != .healthy else { return .notBadEnough }
guard facts.playedFor >= settleIn else { return .tooEarly }
if let seit = facts.sinceLastSeek, seit < afterSeek { return .tooEarly }
if let seit = facts.sinceLastDowngrade, seit < cooldown { return .cooling }
guard facts.used < facts.budget else { return .budgetSpent }
return .downgrade
}
}
@@ -0,0 +1,84 @@
import Foundation
/// Sprachkürzel vergleichen, ohne auf ihre Schreibweise hereinzufallen.
///
/// **Warum es das gibt.** Verglichen wurden bisher die ersten zwei Zeichen.
/// Das ist in einem Fall falsch, und ausgerechnet der ist häufig:
///
/// deu ~ de → Treffer
/// ger ~ de → KEIN Treffer
/// de-DE ~ de → Treffer
///
/// `ger` ist die ISO-639-2/B-Form von Deutsch, und ffmpeg schreibt sie in
/// viele Dateien. Bei solchen Titeln wirkte die Spracheinstellung
/// **überhaupt nicht** — lautlos, ohne dass irgendetwas darauf hindeutete.
///
/// Zwanzig Sprachen haben zwei verschiedene dreibuchstabige Formen: eine
/// bibliografische (/B, aus dem Englischen) und eine terminologische (/T, aus
/// der Sprache selbst). Nur die zwanzig stehen unten — für alle anderen ist
/// die dreibuchstabige Form eindeutig, und eine vollständige Tabelle wäre
/// siebenhundert Zeilen, die niemand prüft.
public enum LanguageCode {
/// Paare, bei denen dieselbe Sprache zwei dreibuchstabige Kürzel hat.
///
/// Zusätzlich die zweibuchstabige Form, denn das ist das Ziel: alles,
/// was dasselbe meint, soll auf denselben Wert fallen.
private static let aliases: [String: String] = [
"alb": "sq", "sqi": "sq",
"arm": "hy", "hye": "hy",
"baq": "eu", "eus": "eu",
"bur": "my", "mya": "my",
"chi": "zh", "zho": "zh",
"cze": "cs", "ces": "cs",
"dut": "nl", "nld": "nl",
"fre": "fr", "fra": "fr",
"geo": "ka", "kat": "ka",
"ger": "de", "deu": "de",
"gre": "el", "ell": "el",
"ice": "is", "isl": "is",
"mac": "mk", "mkd": "mk",
"mao": "mi", "mri": "mi",
"may": "ms", "msa": "ms",
"per": "fa", "fas": "fa",
"rum": "ro", "ron": "ro",
"slo": "sk", "slk": "sk",
"tib": "bo", "bod": "bo",
"wel": "cy", "cym": "cy",
// Die häufigen, die keine zwei Formen haben, aber oft dreibuchstabig
// in Dateien stehen.
"eng": "en", "spa": "es", "ita": "it", "por": "pt", "rus": "ru",
"jpn": "ja", "kor": "ko", "pol": "pl", "swe": "sv", "nor": "no",
"dan": "da", "fin": "fi", "tur": "tr", "ara": "ar", "heb": "he",
"hin": "hi", "tha": "th", "vie": "vi", "ukr": "uk", "hun": "hu",
"bul": "bg", "hrv": "hr", "srp": "sr", "slv": "sl", "est": "et",
"lav": "lv", "lit": "lt", "cat": "ca", "gle": "ga",
]
/// Bringt ein Kürzel auf eine Form, die sich vergleichen lässt.
///
/// **`gsw` bleibt `gsw`.** Schweizerdeutsch ist keine Schreibweise von
/// Deutsch, sondern eine eigene Sprache ohne zweibuchstabiges Kürzel. Es
/// auf `de` abzubilden wäre bequem und falsch: wer Hochdeutsch will,
/// bekäme Schweizerdeutsch untergeschoben. Dasselbe gilt für Bairisch
/// (`bar`), Plattdeutsch (`nds`) und alles andere dieser Art — sie gehören
/// in die Rangliste, nicht in eine Gleichsetzung.
public static func normalized(_ raw: String?) -> String? {
guard let raw else { return nil }
// Region abschneiden: „de-DE", „pt_BR", „zh-Hans".
let ohneRegion = raw
.lowercased()
.split(whereSeparator: { $0 == "-" || $0 == "_" })
.first
.map(String.init) ?? ""
guard !ohneRegion.isEmpty else { return nil }
if let bekannt = aliases[ohneRegion] { return bekannt }
return ohneRegion
}
/// Ob zwei Kürzel dieselbe Sprache meinen.
public static func matches(_ lhs: String?, _ rhs: String?) -> Bool {
guard let links = normalized(lhs), let rechts = normalized(rhs) else { return false }
return links == rechts
}
}
@@ -0,0 +1,38 @@
import Foundation
import Network
import Observation
/// Beobachtet, worüber das Gerät gerade am Netz hängt.
///
/// Die Unterscheidung WLAN/Mobilfunk entscheidet, welche Bitratengrenze gilt.
/// `isExpensive` statt einer Abfrage auf den Schnittstellentyp: der Wert deckt
/// auch den Fall ab, dass das iPhone am persönlichen Hotspot eines anderen
/// Geräts hängt — technisch WLAN, in der Abrechnung Mobilfunk.
@MainActor
@Observable
public final class NetworkConditions {
public private(set) var isReachable = true
/// Mobilfunk oder Hotspot — alles, was Datenvolumen kostet.
public private(set) var isExpensive = false
/// Der Sparmodus für Daten ist an. Dann sollte die App von sich aus
/// sparsam sein, auch im WLAN.
public private(set) var isConstrained = false
private let monitor = NWPathMonitor()
public init() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor [weak self] in
guard let self else { return }
isReachable = path.status == .satisfied
isExpensive = path.isExpensive
isConstrained = path.isConstrained
}
}
monitor.start(queue: DispatchQueue(label: "halo.network"))
}
deinit {
monitor.cancel()
}
}
@@ -0,0 +1,146 @@
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)
// **Solange das Bild durchgereicht werden kann, hilft der Server.**
//
// Zwei Fälle, und beide enden hier. Steht nur die Schachtel im
// Weg — Matroska aussen, H.264 oder HEVC innen, AC3 dazu —, wird
// umgepackt: dieselben Ströme, andere Verpackung, nichts geht
// verloren. Steht der **Ton** im Weg — DTS, TrueHD, Vorbis —,
// rechnet der Server genau diese eine Spur um und kopiert das
// Bild. Beides zusammen geht ebenso.
//
// Das kostet Tonqualität, und zwar nur beim Ton. Dagegen steht,
// was es einbringt: der Titel läuft überhaupt — mit Bild-in-Bild,
// AirPlay und Sperrbildschirm, und ohne dass das ganze Bild neu
// gerechnet werden muss.
//
// Stimmt dagegen der **Videocodec** nicht, ist hier Schluss: dann
// fehlt nicht der Zugang, sondern das Können, und jeder Weg über
// den Server hiesse, jedes Bild neu zu rechnen.
let bildBleibt = blockers.allSatisfy {
switch $0 {
case .containerUnsupported, .audioCodecUnsupported: true
default: false
}
}
// Umpackbar muss die Schachtel nur sein, wenn sie überhaupt im
// Weg steht. Eine MP4 mit DTS-Ton bleibt die MP4, die sie ist —
// sie an dieser Liste zu messen, hiesse sie an einer Frage
// scheitern zu lassen, die sich gar nicht stellt.
let schachtelTraegt = container.map {
capabilities.containers.contains($0)
|| DeviceCapabilities.remuxableContainers.contains($0)
} ?? true
if bildBleibt, schachtelTraegt, policy.allowsTranscoding {
return PlaybackDecision(method: .remux, rationale: rationale)
}
return fallback(policy: policy, rationale: rationale)
}
rationale.append(.nativelySupported)
return PlaybackDecision(method: .directPlay, rationale: rationale)
}
/// Was passiert, wenn AVPlayer nicht kann.
///
/// **Hier stand einmal eine zweite Engine.** VLCKit hatte Vorrang vor dem
/// Transcoder: die Datei ging unverändert über die Leitung, der Server
/// musste nicht rechnen. Es gibt sie nicht mehr — was der Systemplayer
/// nicht öffnet, rechnet der Server um, und was heruntergeladen wird,
/// kommt schon in einer Hülle, die er öffnet.
private func fallback(
policy: PlaybackPolicy,
rationale: [PlaybackDecision.Rationale]
) -> PlaybackDecision {
if policy.allowsTranscoding {
return PlaybackDecision(
method: .transcode(maximumBitrate: policy.maximumBitrate),
rationale: rationale
)
}
var rationale = rationale
rationale.append(.transcodingUnavailable)
return PlaybackDecision(method: .directPlay, 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: ". "))
}
}
@@ -0,0 +1,65 @@
import Foundation
/// Wie ein Titel abgespielt werden soll.
public struct PlaybackDecision: Sendable, Equatable {
public enum Method: Sendable, Equatable {
/// Die Datei wird unverändert übertragen und lokal dekodiert.
case directPlay
/// Der Server reicht das Bild durch und legt es in eine andere
/// Schachtel — den Ton rechnet er um, falls nötig.
///
/// **Der dritte Weg, und der wichtigste für diesen Bestand.** Ein
/// Blu-ray-Rip liegt als Matroska vor, und AVFoundation öffnet
/// Matroska nicht — obwohl das Bild darin H.264 oder HEVC ist und der
/// Ton AAC oder AC3, also alles, was AVPlayer bestens kann. Im Weg
/// steht allein die Verpackung.
///
/// **Das Bild wird dabei nie angefasst.** Es geht Byte für Byte in
/// eine MP4, und das kostet den Server fast nichts — anders als beim
/// Neurechnen.
///
/// **Der Ton kann es müssen.** Steht DTS, TrueHD oder Vorbis darin,
/// kennt AVPlayer die Spur nicht; der Server kodiert dann genau diese
/// eine Spur nach AC3 oder AAC. Das kostet Tonqualität — und sonst
/// nichts, vor allem keine einzige Bildzeile. Ob es im Einzelfall
/// passiert ist, steht in `rationale`: `audioCodecUnsupported`.
///
/// Der Gewinn ist gross: solche Titel laufen damit im Systemplayer,
/// mit Bild-in-Bild, AirPlay und Systembedienung — und es muss nicht
/// das ganze Bild neu gerechnet werden.
case remux
/// Der Server rechnet um. Ab Phase 2; `maximumBitrate` in Kilobit.
case transcode(maximumBitrate: Int?)
}
/// Warum es so entschieden wurde.
///
/// Wandert in die technischen Infos auf der Detailseite und ins Protokoll.
/// Ohne diese Begründung ist „warum ruckelt das" nicht zu beantworten.
public enum Rationale: Sendable, Equatable {
case nativelySupported
case containerUnsupported(String)
case videoCodecUnsupported(String)
case audioCodecUnsupported(String)
/// Der Server hat die Datei noch nicht analysiert.
case streamsUnknown
case bitrateExceedsLimit(mediaBitrate: Int, limit: Int)
/// Transcoding wäre passender, ist aber (noch) nicht verfügbar.
case transcodingUnavailable
/// Im Player ausdrücklich eine Stufe gewählt.
case qualityChosen
}
public let method: Method
public let rationale: [Rationale]
public init(method: Method, rationale: [Rationale]) {
self.method = method
self.rationale = rationale
}
public var isDirectPlay: Bool {
if case .directPlay = method { return true }
return false
}
}
@@ -0,0 +1,35 @@
import Foundation
/// Was bei der Wiedergabe erlaubt ist.
///
/// Das ist die Stellschraube, an der Phase 2 andockt: heute steht
/// `allowsTranscoding` auf `false` und der Decider kommt zwangsläufig immer
/// bei DirectPlay heraus. Sobald der Transcoder steht, wird hier umgelegt —
/// die Views merken davon nichts.
public struct PlaybackPolicy: Sendable, Equatable {
/// Darf der Server umrechnen?
public var allowsTranscoding: Bool
/// Obergrenze in Kilobit pro Sekunde. `nil` heißt unbegrenzt — der
/// Normalfall im heimischen WLAN.
public var maximumBitrate: Int?
/// Umrechnen, auch wenn die Datei die Grenze gar nicht überschreitet.
///
/// Für die Wahl im Player. Ohne das verpuffte sie: wer für eine
/// 8-Mbit-Datei „12 Mbit" wählt, bekam DirectPlay, weil die Datei ja
/// unter der Grenze liegt — die Wahl blieb folgenlos. Gemeint ist aber
/// „rechne es mir auf diese Stufe um".
public var forcesTranscoding: Bool
public init(
allowsTranscoding: Bool,
maximumBitrate: Int? = nil,
forcesTranscoding: Bool = false
) {
self.allowsTranscoding = allowsTranscoding
self.maximumBitrate = maximumBitrate
self.forcesTranscoding = forcesTranscoding
}
/// Phase 1: alles direkt, nichts umrechnen.
public static let directPlayOnly = PlaybackPolicy(allowsTranscoding: false)
}
@@ -0,0 +1,208 @@
import Foundation
/// Eine im Player wählbare Qualitätsstufe.
///
/// Getrennt von den Einstellungen unter „Qualität": dort steht, was
/// üblicherweise gelten soll; hier greift man für diesen einen Film ein — weil
/// es gerade ruckelt oder weil man ausnahmsweise das Original will.
public enum PlayerQuality: Hashable, Sendable, Identifiable {
/// Was in den Einstellungen für die aktuelle Verbindung hinterlegt ist.
case automatic
/// Die Datei unverändert, ohne Umrechnung.
case original
/// Eine Stufe der Leiter, die der Server anbietet.
case limited(Rung)
/// So wenig Bild wie möglich.
case audioOnly
/// Eine Sprosse: Bitrate und die Auflösung, die Plex dafür vorsieht.
///
/// Die Werte stammen nicht aus der Luft, sondern aus dem, was der Server
/// über sich selbst sagt (`transcoderVideoBitrates`). Eine eigene Leiter
/// zu erfinden hieße, Stufen anzubieten, die der Transcoder gar nicht
/// kennt — er rundet dann still auf die nächste, und die Anzeige log.
public struct Rung: Hashable, Sendable {
public let bitrate: Int
public let height: Int
public init(bitrate: Int, height: Int) {
self.bitrate = bitrate
self.height = height
}
public var width: Int {
// 16:9, auf gerade Zahlen gerundet — ungerade Kantenlängen mag
// kein Kodierer.
let raw = Int((Double(height) * 16.0 / 9.0).rounded())
return raw % 2 == 0 ? raw : raw + 1
}
public var resolution: String { "\(width)x\(height)" }
}
/// Die Leiter von oben nach unten.
public static let ladder: [Rung] = [
Rung(bitrate: 20_000, height: 1080),
Rung(bitrate: 12_000, height: 1080),
Rung(bitrate: 10_000, height: 1080),
Rung(bitrate: 8_000, height: 1080),
Rung(bitrate: 4_000, height: 1080),
Rung(bitrate: 3_000, height: 720),
Rung(bitrate: 2_000, height: 720),
Rung(bitrate: 1_500, height: 720),
Rung(bitrate: 720, height: 480),
Rung(bitrate: 320, height: 360),
Rung(bitrate: 208, height: 360),
Rung(bitrate: 96, height: 240),
Rung(bitrate: 64, height: 160),
]
/// Eine Zahl, die sich aufbewahren lässt.
///
/// Über mehrere Filme hinweg gilt nur die **Absicht** — „ungefähr so viel
/// Bitrate" —, nicht die genaue Sprosse: welche Sprossen es gibt, hängt
/// von der Quelle ab. Deshalb eine Zahl und kein Aufzählungswert.
public var storageValue: Int {
switch self {
case .automatic: 0
case .original: -1
case .audioOnly: -2
case .limited(let rung): rung.bitrate
}
}
/// Die Stufe zu einer aufbewahrten Zahl, so nah wie möglich.
///
/// Gibt es die Sprosse für diese Quelle nicht, wird die nächstgelegene
/// genommen — eine 720p-Datei kennt keine 20-Megabit-Stufe, und dann
/// stillschweigend auf „automatisch" zurückzufallen wäre eine andere
/// Antwort als die, um die gebeten wurde.
public static func restored(from value: Int, forSourceHeight height: Int?) -> PlayerQuality? {
switch value {
case 0: return nil
case -1: return .original
case -2: return .audioOnly
default: break
}
let rungs = available(forSourceHeight: height).compactMap { quality -> Rung? in
if case .limited(let rung) = quality { return rung }
return nil
}
guard let closest = rungs.min(by: {
abs($0.bitrate - value) < abs($1.bitrate - value)
}) else { return nil }
return .limited(closest)
}
public static var allCases: [PlayerQuality] {
[.automatic, .original] + ladder.map(PlayerQuality.limited) + [.audioOnly]
}
public var id: String {
switch self {
case .automatic: "automatic"
case .original: "original"
case .audioOnly: "audioOnly"
case .limited(let rung): "limited-\(rung.bitrate)"
}
}
/// Obergrenze in Kilobit. `nil` bei `automatic` und `original`.
public var maximumBitrate: Int? {
switch self {
case .automatic, .original: nil
case .limited(let rung): rung.bitrate
// Plex kennt für Videos keinen reinen Tonstrom. Die unterste Sprosse
// ist das, was dem am nächsten kommt; das Bild wird dazu im Player
// ausgeblendet, damit der Schirm dunkel bleiben kann.
case .audioOnly: Self.ladder.last?.bitrate
}
}
public var resolution: String? {
switch self {
case .automatic, .original: nil
case .limited(let rung): rung.resolution
case .audioOnly: Self.ladder.last?.resolution
}
}
/// Die Höhe in Bildzeilen, sofern die Stufe eine vorgibt.
public var height: Int? {
switch self {
case .automatic, .original: nil
case .limited(let rung): rung.height
case .audioOnly: Self.ladder.last?.height
}
}
/// Ob das Bild gezeigt werden soll.
public var showsVideo: Bool {
if case .audioOnly = self { return false }
return true
}
/// Ob diese Stufe eine Umrechnung erzwingt.
///
/// `original` erzwingt das Gegenteil: keine Umrechnung, auch wenn die
/// Einstellungen eine Grenze vorsehen.
public var forcesTranscode: Bool {
switch self {
case .automatic, .original: false
case .limited, .audioOnly: true
}
}
/// Die Richtlinie für diese Stufe.
///
/// `automatic` reicht durch, was ohnehin gilt. Die übrigen überschreiben
/// sie — das ist der Zweck eines Eingriffs im Player.
public func policy(basedOn base: PlaybackPolicy) -> PlaybackPolicy {
switch self {
case .automatic:
base
case .original:
// Keine Grenze und kein Umrechnen: ausdrücklich das Original.
PlaybackPolicy(allowsTranscoding: false, maximumBitrate: nil)
case .limited, .audioOnly:
PlaybackPolicy(
allowsTranscoding: true,
maximumBitrate: maximumBitrate,
forcesTranscoding: true
)
}
}
/// Die kurze Liste: je Auflösung eine Stufe, und zwar eine brauchbare.
///
/// Dreizehn Sprossen sind für den Normalfall zu viel — man will „geht
/// gerade nicht flüssig, nimm eine Stufe kleiner", nicht die Wahl
/// zwischen zehn und zwölf Megabit. Die vollständige Leiter bleibt
/// erreichbar, steht aber nicht im Weg.
public static let everyday: [Rung] = [
Rung(bitrate: 8_000, height: 1080),
Rung(bitrate: 3_000, height: 720),
Rung(bitrate: 720, height: 480),
]
/// Die kurze Auswahl für eine Quelle dieser Höhe.
public static func everyday(forSourceHeight height: Int?) -> [PlayerQuality] {
let rungs = everyday.filter { height == nil || $0.height <= (height ?? 0) }
let usable = rungs.isEmpty ? Array(everyday.suffix(1)) : rungs
return [.automatic, .original] + usable.map(PlayerQuality.limited) + [.audioOnly]
}
/// Nur die Stufen, die für eine Quelle dieser Höhe sinnvoll sind.
///
/// Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein Bild dazu, und
/// die Stufen sähen alle gleich aus — dieselbe Falle wie beim Herunterladen.
public static func available(forSourceHeight height: Int?) -> [PlayerQuality] {
guard let height, height > 0 else { return allCases }
let fitting = ladder.filter { $0.height <= height }
// Mindestens die unterste Sprosse anbieten, sonst bliebe bei einer
// sehr kleinen Quelle nur „Original".
let rungs = fitting.isEmpty ? Array(ladder.suffix(1)) : fitting
return [.automatic, .original] + rungs.map(PlayerQuality.limited) + [.audioOnly]
}
}
@@ -0,0 +1,122 @@
import Foundation
/// Buch über das Stocken, mit gleitendem Fenster.
///
/// **Warum es das gibt.** Der Watchdog im Player kannte nur eine Frage: steht
/// es seit vierzig Sekunden am Stück? Bei jedem kurzen Erholen setzte er sich
/// zurück — zehn Rückler à dreißig Sekunden lösten nichts aus, obwohl die
/// halbe Stunde unbrauchbar war. Genau dieser Fall ist der, über den geklagt
/// wird: nicht der tote Stream, sondern der, der immer wieder hakt.
///
/// **Warum als eigener Typ.** Keine Uhr, kein AVFoundation, kein Netz — die
/// Zeiten kommen von außen herein. Damit lässt sich eine Folge von Rücklern
/// als Zahlenreihe hinschreiben und das Urteil prüfen. Eine Regel, die man
/// nur im laufenden Film beobachten kann, ist keine Regel, sondern ein
/// Eindruck.
///
/// Alle Zeitpunkte sind **seit Beginn dieser Wiedergabe** gemessen, nicht als
/// Datum. Das macht die Sache gegen Zeitzonen, Sommerzeit und eine Systemuhr,
/// die sich mitten im Film stellt, gleichgültig.
public struct StallLedger: Sendable, Equatable {
public struct Episode: Sendable, Equatable {
/// Wann dieser Rückler vorbei war.
public let endedAt: Duration
public let length: Duration
public init(endedAt: Duration, length: Duration) {
self.endedAt = endedAt
self.length = length
}
}
/// Wie weit zurück gezählt wird.
///
/// Drei Minuten: lang genug, dass ein Muster erkennbar wird, kurz genug,
/// dass eine überstandene Durststrecke nicht noch eine Viertelstunde
/// später nachwirkt.
public let window: Duration
private(set) var episodes: [Episode] = []
public init(window: Duration = .seconds(180)) {
self.window = window
}
/// Trägt einen überstandenen Rückler ein.
public mutating func record(_ episode: Episode) {
episodes.append(episode)
prune(at: episode.endedAt)
}
/// Wie viele Rückler im Fenster liegen.
public func count(at now: Duration) -> Int {
episodes.count { $0.endedAt >= now - window }
}
/// Welcher Anteil des Fensters mit Warten verbracht wurde — 0 bis 1.
///
/// Der laufende Rückler zählt mit, sofern er übergeben wird: wer mitten
/// im dritten Stocken steht, soll nicht erst nach dessen Ende erfahren,
/// dass es zu viel war.
public func share(at now: Duration, ongoing: Duration = .zero) -> Double {
let horizon = now - window
let waited = episodes
.filter { $0.endedAt >= horizon }
.reduce(Duration.zero) { $0 + $1.length } + ongoing
// Am Anfang ist das Fenster noch nicht voll. Dann gegen die
// tatsächlich vergangene Zeit rechnen, sonst sähe eine Wiedergabe,
// die nach zwanzig Sekunden fünf Sekunden hakte, mit 3 % harmlos aus
// statt mit 25 %.
let elapsed = min(now, window)
guard elapsed > .zero else { return 0 }
return min(1, waited / elapsed)
}
/// Der längste einzelne Rückler im Fenster.
///
/// Ein einzelner langer ist etwas anderes als viele kurze: der eine sagt
/// „die Leitung war weg", die vielen sagen „sie trägt es dauerhaft nicht".
/// Beide sollen auslösen dürfen, aber nicht über dieselbe Schwelle.
public func longest(at now: Duration) -> Duration {
episodes
.filter { $0.endedAt >= now - window }
.map(\.length)
.max() ?? .zero
}
/// Wann der letzte Rückler zu Ende war.
///
/// `nil`, wenn es im Fenster keinen gab — und das ist etwas anderes als
/// „vor langer Zeit": wer noch nie gestockt hat, wartet auf keine Frist.
///
/// Gebraucht fürs Hinaufschalten: wer gerade gestockt hat, hat keine
/// Reserve, und ein Neuaufbau nach oben wäre die zweite Unterbrechung
/// binnen einer Minute.
public func lastStall(at now: Duration) -> Duration? {
episodes
.filter { $0.endedAt >= now - window }
.map(\.endedAt)
.max()
}
/// Wirft heraus, was aus dem Fenster gefallen ist.
public mutating func prune(at now: Duration) {
let horizon = now - window
episodes.removeAll { $0.endedAt < horizon }
}
public mutating func removeAll() {
episodes.removeAll()
}
}
private extension Duration {
/// Anteil zweier Dauern. `Duration` kann sich nicht selbst teilen.
static func / (lhs: Duration, rhs: Duration) -> Double {
let left = Double(lhs.components.seconds) + Double(lhs.components.attoseconds) / 1e18
let right = Double(rhs.components.seconds) + Double(rhs.components.attoseconds) / 1e18
guard right != 0 else { return 0 }
return left / right
}
}
@@ -0,0 +1,175 @@
import Foundation
/// Eine Probe vom laufenden Stream.
///
/// Alle Zeiten **seit Beginn dieser Wiedergabe**, nicht als Datum — dasselbe
/// wie beim `StallLedger`, und aus demselben Grund: eine Systemuhr, die sich
/// mitten im Film stellt, darf das Urteil nicht kippen.
public struct StreamSample: Sendable, Equatable {
public let at: Duration
/// Wie weit der Puffer vor der Abspielstelle reicht. `nil` heißt
/// „noch nichts geladen" — etwas anderes als ein leerer Puffer.
public let bufferedAhead: Duration?
public let isLikelyToKeepUp: Bool?
/// Der fortlaufende Zähler, nicht die Zahl seit der letzten Probe.
public let stallCount: Int
/// Auf welcher Sprosse gerade gespielt wird, in Kilobit je Sekunde.
public let indicatedBitrate: Int?
public init(
at: Duration,
bufferedAhead: Duration? = nil,
isLikelyToKeepUp: Bool? = nil,
stallCount: Int = 0,
indicatedBitrate: Int? = nil
) {
self.at = at
self.bufferedAhead = bufferedAhead
self.isLikelyToKeepUp = isLikelyToKeepUp
self.stallCount = stallCount
self.indicatedBitrate = indicatedBitrate
}
}
/// Wie es dem Stream geht.
public enum StreamCondition: Sendable, Equatable {
/// Trägt. Es darf wieder eine Stufe höher gehen.
case healthy
/// Wird knapp. Eine Stufe tiefer, bevor das Bild steht.
case strained
/// Trägt nicht. So tief wie möglich.
case failing
}
/// Beurteilt den Stream aus einer Folge von Proben.
///
/// **Warum als reiner Typ.** Kein AVFoundation, keine Uhr, kein Netz — Proben
/// hinein, Urteil heraus. Dieselbe Bauform wie `PlaybackDecider`, und aus
/// demselben Grund: eine Regelung, die man nur im laufenden Film beobachten
/// kann, ist nicht zu beurteilen. Hier lässt sich eine schlechte Zugfahrt als
/// Zahlenreihe hinschreiben und nachsehen, was sie auslöst.
///
/// **Welches Signal wann meldet** — das ist der eigentliche Inhalt:
///
/// - **Der Pufferverlauf ist der einzige echte Frühwarner.** Schrumpft er
/// schneller, als die Zeit vergeht, kommt weniger herein als hinausgeht.
/// Das sieht man zehn bis zwanzig Sekunden vor dem Rückler.
/// - `isLikelyToKeepUp` ist früh, aber grob: Apple legt es um, wenn es sich
/// schon sorgt. Als Bestätigung gut, als alleiniger Auslöser zu sprunghaft.
/// - Der Rücklerzähler meldet **zu spät zum Regeln** — das Bild stand bereits.
/// Er ist trotzdem unverzichtbar: an ihm misst sich hinterher, ob es besser
/// wurde.
/// - Die **gemessene** Bitrate steht hier bewusst nicht. Auf einer kleinen
/// Sprosse holt der Player wenig, ohne dass die Leitung schlecht wäre —
/// als Kriterium wäre sie irreführend.
///
/// **Absichtlich unsymmetrisch: schnell runter, langsam rauf.** Ein falscher
/// Schritt nach unten kostet Schärfe, ein falscher nach oben einen Rückler.
public struct StreamHealth: Sendable, Equatable {
/// Wie viel Puffer als knapp gilt.
public static let lowBuffer: Duration = .seconds(6)
/// Ab wann er als reichlich gilt.
public static let goodBuffer: Duration = .seconds(20)
private var samples: [StreamSample] = []
private(set) public var condition: StreamCondition = .healthy
/// Seit wann es ununterbrochen gut aussieht. `nil`, sobald es das nicht tut.
private var healthySince: Duration?
public init() {}
/// Nimmt eine Probe und gibt das aktuelle Urteil zurück.
@discardableResult
public mutating func record(_ sample: StreamSample) -> StreamCondition {
samples.append(sample)
samples.removeAll { $0.at < sample.at - .seconds(60) }
condition = judge(at: sample)
return condition
}
public mutating func removeAll() {
samples.removeAll()
healthySince = nil
condition = .healthy
}
// MARK: - Das Urteil
private mutating func judge(at now: StreamSample) -> StreamCondition {
// **Zuerst das Schlimmste.** Zwei Rückler in einer Minute heißen: es
// war kein Ausrutscher.
if stalls(within: .seconds(60), at: now) >= 2 {
healthySince = nil
return .failing
}
// Puffer auf null, während gespielt wird. `nil` zählt nicht — das
// heißt „noch nichts geladen" und ist der Zustand beim Start.
if let buffer = now.bufferedAhead, buffer == .zero {
healthySince = nil
return .failing
}
if isStrained(at: now) {
healthySince = nil
return .strained
}
// **Der Weg zurück ist der schmalste.** Reichlich Puffer allein
// genügt nicht: er muss eine halbe Minute lang reichlich geblieben
// sein, und in der letzten Minute darf es nicht gehakt haben. Sonst
// pendelt die Stufe im Takt der Aussetzer, und das ist schlimmer als
// eine Stufe zu niedrig zu bleiben.
guard let buffer = now.bufferedAhead, buffer >= Self.goodBuffer else {
healthySince = nil
return condition == .healthy ? .healthy : condition
}
let seit = healthySince ?? now.at
healthySince = seit
let langGenug = now.at - seit >= .seconds(30)
let ruhig = stalls(within: .seconds(60), at: now) == 0
guard langGenug, ruhig else {
return condition == .healthy ? .healthy : condition
}
return .healthy
}
private func isStrained(at now: StreamSample) -> Bool {
// Knapp **und** fallend. Ein kleiner Puffer, der stabil bleibt, ist
// kein Alarm — er ist nur klein.
if let buffer = now.bufferedAhead, buffer < Self.lowBuffer, isFalling(at: now) {
return true
}
// Apples eigene Sorge, aber erst wenn sie anhält: ein einzelner
// Ausschlag kommt bei jedem Sprung vor.
let letzte = samples.filter { $0.at >= now.at - .seconds(3) }
if letzte.count >= 3, letzte.allSatisfy({ $0.isLikelyToKeepUp == false }) {
return true
}
return false
}
/// Ob der Puffer über die letzten Sekunden hinweg schrumpft.
private func isFalling(at now: StreamSample) -> Bool {
let fenster = samples.filter { $0.at >= now.at - .seconds(5) }
guard fenster.count >= 3,
let ältester = fenster.first?.bufferedAhead,
let jüngster = fenster.last?.bufferedAhead
else { return false }
return jüngster < ältester
}
/// Wie viele Rückler im Zeitfenster dazugekommen sind.
///
/// Aus der **Differenz** des fortlaufenden Zählers, nicht aus einer
/// eigenen Zählung: der Zähler kommt vom System und weiß auch von
/// Rücklern zwischen zwei Proben.
private func stalls(within window: Duration, at now: StreamSample) -> Int {
let fenster = samples.filter { $0.at >= now.at - window }
guard let erster = fenster.first else { return 0 }
return max(0, now.stallCount - erster.stallCount)
}
}
@@ -0,0 +1,99 @@
import Foundation
import MediaKit
/// Was in einem Spurenmenü steht — und was ein Umschalten kostet.
///
/// **Warum es diesen Typ gibt.** Auf dem Fernseher sitzt die Spurwahl in
/// Apples Transportleiste, und die nimmt `UIMenu`-Einträge entgegen. Die
/// Abbildung von Spuren auf Einträge hat mit UIKit aber nichts zu tun: sie
/// beantwortet, welche Spuren zur Wahl stehen, welche gerade gilt, und — das
/// ist der Teil, der sonst nirgends steht — **ob das Umschalten Sekunden mit
/// schwarzem Bild kostet**.
///
/// Diese Regel lag bisher nur mitten im Vollzug von
/// `PlayerModel.selectAudioStream` und war damit weder ablesbar noch prüfbar:
/// auslösen lässt sie sich nur an einem echten Server mit einer Datei, die
/// mehrere Tonspuren hat. Hier steht sie als Auskunft — und die Oberfläche
/// kann sie **vorher** anzeigen, statt den Nutzer hinterher vor ein schwarzes
/// Bild zu setzen.
///
/// Liegt neben `AudioTrackChooser` und `DowngradeRule`, weil es dasselbe ist:
/// eine Regel über die Wiedergabe, ohne Oberfläche und ohne Netz.
public enum TrackMenu {
public struct Entry: Equatable, Sendable, Identifiable {
/// Der Index der Spur beim Server. `nil` heisst „Aus".
public let id: Int?
public let title: String
public let isSelected: Bool
/// Ob das Umschalten den Stream neu aufbaut.
public let rebuilds: Bool
public init(id: Int?, title: String, isSelected: Bool, rebuilds: Bool) {
self.id = id
self.title = title
self.isSelected = isSelected
self.rebuilds = rebuilds
}
}
/// Die Tonspuren **der Datei**, nicht die des Players.
///
/// Rechnet der Server um, kennt der Player genau eine Spur — die, für die
/// er sich entschieden hat. Zur Wahl steht, was in der Datei liegt.
///
/// - Parameter playerTrackCount: Wie viele Tonspuren der Player meldet.
/// Weicht die Zahl ab, stimmt die Zuordnung über die Reihenfolge nicht,
/// und es muss neu aufgebaut werden — lieber eine Unterbrechung als die
/// falsche Sprache.
public static func audio(
from streams: [MediaStream],
selected: MediaStream?,
isDirectPlay: Bool,
playerTrackCount: Int
) -> [Entry] {
let billig = isDirectPlay && playerTrackCount == streams.count
return streams.enumerated().map { stelle, stream in
Entry(
id: stream.index ?? stelle,
title: stream.name,
isSelected: stream == selected,
rebuilds: !billig
)
}
}
/// Die Untertitelspuren, mit „Aus" an erster Stelle.
///
/// - Parameter offLabel: Wie „Aus" in der Sprache des Nutzers heisst.
/// Als Argument, weil dieses Paket keinen Textkatalog der App kennt.
/// - Parameter playerTrackLanguages: Die Sprachkürzel der Spuren, die der
/// Player wirklich hat. Nur was sich darin abbilden lässt, schaltet ohne
/// Neuaufbau um — sonst stünde der falsche Untertitel im Bild, und das
/// ist schlimmer als eine kurze Unterbrechung.
public static func subtitles(
from streams: [MediaStream],
selected: MediaStream?,
isDirectPlay: Bool,
playerTrackLanguages: [String?] = [],
offLabel: String
) -> [Entry] {
// „Aus" kostet nie einen Neuaufbau: abschalten kann der Player immer,
// auch eine Spur, die er nie hatte.
var einträge = [
Entry(id: nil, title: offLabel, isSelected: selected == nil, rebuilds: false)
]
einträge += streams.enumerated().map { stelle, stream in
let abbildbar = isDirectPlay && playerTrackLanguages.contains {
LanguageCode.matches(stream.languageCode, $0)
}
return Entry(
id: stream.index ?? stelle,
title: stream.name,
isSelected: stream == selected,
rebuilds: !abbildbar
)
}
return einträge
}
}
@@ -0,0 +1,140 @@
import Foundation
/// Wann **hinauf** gegangen wird — und wann der teure Weg dafür erlaubt ist.
///
/// **Die Falle, um die es hier geht.** Damit ein Film zügig anläuft, wird mit
/// einer niedrigen Stufe begonnen. Der Server baut seine Leiter aber aus
/// genau dieser Bestellung: wer mit drei Megabit anfängt, bekommt eine
/// Leiter, deren oberste Sprosse drei Megabit ist. Das billige Hinaufschalten
/// über `preferredPeakBitRate` bewegt sich **innerhalb** dieser Leiter — es
/// käme also nie darüber hinaus, und aus dem schnellen Start würde ein Film,
/// der den ganzen Abend in 720p bleibt.
///
/// Höher geht es nur mit einem Neuaufbau: neu aushandeln, mit einer grösseren
/// Grenze. Der kostet Sekunden mit schwarzem Bild — deshalb dieselbe Bauform
/// wie `DowngradeRule`: eine Liste von Bedingungen, die **alle** gelten
/// müssen, kein Schwellwert.
///
/// **Und deutlich zurückhaltender als das Absenken.** Ein zu spätes Absenken
/// bedeutet Ruckeln; ein zu spätes Hinaufschalten bedeutet ein etwas
/// weicheres Bild. Das erste ist ein Fehler, das zweite eine Geschmacksfrage.
public enum UpgradeRule {
/// Wie lange es ruhig laufen muss, bevor überhaupt gefragt wird.
///
/// Lang genug, dass es kein anfängliches Aufatmen ist: der Puffer füllt
/// sich nach dem Start immer, und das sieht zwanzig Sekunden lang nach
/// einer hervorragenden Leitung aus.
public static let settleIn: Duration = .seconds(45)
/// Sperrfrist zwischen zwei Neuaufbauten nach oben.
public static let cooldown: Duration = .seconds(150)
/// Wie lange nach einem Rückler gar nicht hinaufgegangen wird.
///
/// Wer gerade gestockt hat, hat keine Reserve — und ein Neuaufbau nach
/// oben wäre dann die zweite Unterbrechung binnen einer Minute.
public static let afterStall: Duration = .seconds(90)
/// Wie viel Vorrat dasein muss.
///
/// Nicht „es läuft gerade", sondern „es liegt etwas auf der hohen Kante".
/// Nur das trägt einen Neuaufbau.
public static let bufferNeeded: Duration = .seconds(20)
/// Was zum Zeitpunkt der Frage gilt.
public struct Facts: Sendable, Equatable {
/// Eine ausdrücklich gewählte Stufe ist eine Ansage und wird nicht
/// überstimmt — auch nicht nach oben.
public let isAutomatic: Bool
public let condition: StreamCondition
/// Ob die Leiter noch eine Sprosse nach oben hat. Solange ja, ist der
/// **billige** Weg der richtige.
public let hasHigherRung: Bool
/// Ob die eingestellte Obergrenze überhaupt mehr zuliesse.
public let ceilingAllowsMore: Bool
/// Wie viel Vorrat vor der Abspielstelle liegt.
public let bufferedAhead: Duration?
/// Wie lange schon gespielt wird.
public let playedFor: Duration
/// Wie lange der letzte Rückler her ist. `nil` heisst: keiner gewesen.
public let sinceLastStall: Duration?
/// Wie lange der letzte Sprung her ist.
public let sinceLastSeek: Duration?
/// Wie lange der letzte Neuaufbau nach oben her ist.
public let sinceLastUpgrade: Duration?
/// Wie viele es in dieser Wiedergabe schon gab.
public let used: Int
/// Wie viele es höchstens geben darf.
public let budget: Int
public init(
isAutomatic: Bool,
condition: StreamCondition,
hasHigherRung: Bool,
ceilingAllowsMore: Bool,
bufferedAhead: Duration?,
playedFor: Duration,
sinceLastStall: Duration? = nil,
sinceLastSeek: Duration? = nil,
sinceLastUpgrade: Duration? = nil,
used: Int = 0,
budget: Int = 2
) {
self.isAutomatic = isAutomatic
self.condition = condition
self.hasHigherRung = hasHigherRung
self.ceilingAllowsMore = ceilingAllowsMore
self.bufferedAhead = bufferedAhead
self.playedFor = playedFor
self.sinceLastStall = sinceLastStall
self.sinceLastSeek = sinceLastSeek
self.sinceLastUpgrade = sinceLastUpgrade
self.used = used
self.budget = budget
}
}
/// Warum **nicht** hinaufgegangen wird.
///
/// Als Aufzählung statt als `Bool`, aus demselben Grund wie beim
/// Absenken: „es läuft gut und es passiert nichts" ist ohne Begründung
/// von einem Fehler nicht zu unterscheiden.
public enum Verdict: Sendable, Equatable {
case rebuildHigher
/// Eine gewählte Stufe wird nicht überstimmt.
case notAutomatic
/// Die Leiter hat noch eine Sprosse — der billige Weg genügt.
case cheaperPathRemains
/// Die Einstellung lässt nicht mehr zu. Kein Fehler, eine Ansage.
case atCeiling
/// Es läuft nicht ruhig genug.
case notCalmEnough
/// Zu früh nach Start, Sprung oder Rückler.
case tooEarly
/// Sperrfrist läuft noch.
case cooling
/// Verbraucht.
case budgetSpent
}
public static func verdict(_ facts: Facts) -> Verdict {
guard facts.isAutomatic else { return .notAutomatic }
guard facts.ceilingAllowsMore else { return .atCeiling }
// **Erst die Leiter ausreizen.** Solange es eine Sprosse höher gibt,
// kostet das Hinaufgehen kein schwarzes Bild — und wäre ein
// Neuaufbau dafür Verschwendung.
if facts.hasHigherRung { return .cheaperPathRemains }
guard facts.condition == .healthy else { return .notCalmEnough }
guard let vorrat = facts.bufferedAhead, vorrat >= bufferNeeded else {
return .notCalmEnough
}
guard facts.playedFor >= settleIn else { return .tooEarly }
if let seit = facts.sinceLastSeek, seit < DowngradeRule.afterSeek { return .tooEarly }
if let seit = facts.sinceLastStall, seit < afterStall { return .tooEarly }
if let seit = facts.sinceLastUpgrade, seit < cooldown { return .cooling }
guard facts.used < facts.budget else { return .budgetSpent }
return .rebuildHigher
}
}