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:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user