Halo heisst ab hier Flaro — die Marke stand im Weg

Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine
eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt
so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner
Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging
daneben.

**Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den
deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche
Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in
Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store
der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden
frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler.

Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen
ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim
letzten Mal stattfand.

---

2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der
Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`,
`Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen
`FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und
das Image.

**Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen
Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes
Ersetzen hätte den sonst zerlegt.

`docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte
Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das
Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden
in die Irre führt.

**Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag:
`com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat
jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe.

308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS
bauen, das Image baut, und ein frischer Container läuft durch Einrichtung
und Anmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 19:38:27 +02:00
co-authored by Claude Opus 5
parent f98b995050
commit a7cf6c5def
390 changed files with 2078 additions and 2069 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: "flaro.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
}
}