Flixy heißt ab hier Halo

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

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

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

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

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

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

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

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
@@ -0,0 +1,216 @@
import Foundation
import MediaKit
/// Welche Tonspur genommen wird — und warum.
///
/// **Warum als reiner Typ.** Spuren und Wunsch hinein, eine Spur heraus. Kein
/// Netz, keine Einstellungen, keine Ansicht. Dieselbe Bauform wie
/// `PlaybackDecider` und `StreamHealth`, und aus demselben Grund: eine
/// Auswahl, die man nur im laufenden Film beobachten kann, ist nicht zu
/// beurteilen. Hier lässt sich der ZDF-Fall wörtlich hinschreiben.
///
/// **Warum es nicht reicht, das Kürzel zu vergleichen.** Weil Kürzel lügen.
/// Es gibt Dateien, in denen die als deutsch ausgezeichnete Spur die mit
/// Audiodeskription ist und die als englisch ausgezeichnete die eigentliche
/// deutsche. Jellyfin hat **kein Kennzeichen für Audiodeskription** —
/// `MediaStream` führt `IsDefault`, `IsForced`, `IsHearingImpaired`,
/// `IsOriginal`, mehr nicht. Bleibt der Name der Spur, und der ist Text.
///
/// Deshalb kein Filtern, sondern **Abwägen**: jede Spur bekommt Punkte, die
/// höchste gewinnt. Damit gibt es immer eine Antwort, auch wenn nichts passt
/// — und Audiodeskription wird gemieden, ohne unmöglich zu werden.
public enum AudioTrackChooser {
/// Was gewünscht ist.
public struct Wish: Sendable, Equatable {
/// Die Sprache, die gelten soll — aus Serie, Mediathek oder allgemein.
public let language: String?
/// Die Rangliste, wenn die Wunschsprache fehlt. Von oben nach unten.
public let fallbacks: [String]
/// Was zuletzt für **diese Serie** gewählt wurde.
public let remembered: TrackSignature?
/// Ob Audiodeskription gemieden werden soll.
public let avoidsAudioDescription: Bool
public init(
language: String? = nil,
fallbacks: [String] = [],
remembered: TrackSignature? = nil,
avoidsAudioDescription: Bool = true
) {
self.language = language
self.fallbacks = fallbacks
self.remembered = remembered
self.avoidsAudioDescription = avoidsAudioDescription
}
}
/// Drei Ebenen, eine Antwort.
///
/// Serie → Mediathek → allgemein
///
/// Gefragt wird von unten nach oben; die erste Antwort gilt. Bei Filmen
/// gibt es die Serienebene nicht, dort endet es bei der Mediathek.
///
/// **Warum die Rangfolge hier steht und nicht bei den Einstellungen.**
/// Weil sie eine Regel ist, keine Ablage. Im App-Ziel liesse sie sich
/// nicht mit einer Wertefolge durchspielen — hier schon, und das ist der
/// Unterschied zwischen einer geprüften und einer geglaubten Rangfolge.
///
/// Eine leere Zeichenkette zählt als „nichts gesagt": in den Einstellungen
/// steht sie für „soll der Server entscheiden", und das ist keine Sprache,
/// sondern das Ausbleiben einer Antwort.
public static func resolve(
series: String?,
library: String?,
general: String?,
fallbacks: [String] = [],
remembered: TrackSignature? = nil,
avoidsAudioDescription: Bool = true
) -> Wish {
func gesagt(_ wert: String?) -> String? {
guard let wert, !wert.trimmingCharacters(in: .whitespaces).isEmpty else { return nil }
return wert
}
return Wish(
language: gesagt(series) ?? gesagt(library) ?? gesagt(general),
fallbacks: fallbacks,
remembered: remembered,
avoidsAudioDescription: avoidsAudioDescription
)
}
/// Wie eine Spur aussieht — genug, um sie in der nächsten Folge
/// wiederzuerkennen.
///
/// **Keine Nummer.** Folgen derselben Serie haben unterschiedlich
/// sortierte Spuren; „Spur 2" bedeutet in der nächsten Folge etwas
/// anderes. Eine Beschreibung überlebt das.
public struct TrackSignature: Sendable, Equatable, Codable {
public let languageCode: String?
public let title: String?
public let channels: Int?
public let codec: String?
public init(languageCode: String?, title: String?, channels: Int?, codec: String?) {
self.languageCode = LanguageCode.normalized(languageCode)
self.title = title?.lowercased()
self.channels = channels
self.codec = codec?.lowercased()
}
public init(_ stream: MediaStream) {
self.init(
languageCode: stream.languageCode,
title: stream.title ?? stream.displayTitle,
channels: stream.channels,
codec: stream.codec
)
}
/// Wie gut eine Spur zu dieser Beschreibung passt — 0 bis 4.
func similarity(to stream: MediaStream) -> Int {
var punkte = 0
if let languageCode, LanguageCode.matches(languageCode, stream.languageCode) { punkte += 1 }
if let title, let anderer = (stream.title ?? stream.displayTitle)?.lowercased(),
title == anderer { punkte += 2 }
if let channels, channels == stream.channels { punkte += 1 }
return punkte
}
}
/// Wortmarken für Audiodeskription.
///
/// Nur am Text zu erkennen, weil es kein Kennzeichen dafür gibt. Die
/// Liste steht an **einer** Stelle und ist geprüft.
static let audioDescriptionMarkers = [
"audiodeskription", "audio-deskription", "audio description",
"audiodescription", "hörfilm", "hoerfilm", "descriptive",
"described", "narration",
]
/// Wortmarken für Kommentarspuren.
static let commentaryMarkers = [
"kommentar", "commentary", "director", "regisseur",
]
/// Wählt die Spur.
///
/// Gibt `nil` zurück, wenn es keine einzige gibt — sonst immer eine.
public static func choose(from streams: [MediaStream], wish: Wish) -> MediaStream? {
let tonspuren = streams.filter { $0.kind == .audio }
guard !tonspuren.isEmpty else { return nil }
let bewertet = tonspuren.map { (stream: $0, punkte: score($0, wish: wish)) }
// Bei Gleichstand die kleinere Nummer: dieselbe Datei soll immer
// dasselbe ergeben, sonst ist ein Fehler nicht wiederholbar.
return bewertet
.sorted {
$0.punkte == $1.punkte
? ($0.stream.index ?? .max) < ($1.stream.index ?? .max)
: $0.punkte > $1.punkte
}
.first?
.stream
}
/// Die Punkte einer einzelnen Spur. Offen gelegt, damit die Tests die
/// Abwägung selbst nachrechnen können.
public static func score(_ stream: MediaStream, wish: Wish) -> Int {
var punkte = 0
// Was zuletzt gewählt wurde, wiegt am schwersten — es ist die einzige
// Angabe, die von einem Menschen kommt und nicht aus der Datei.
if let remembered = wish.remembered {
punkte += remembered.similarity(to: stream) * 25
}
if let language = wish.language, LanguageCode.matches(language, stream.languageCode) {
punkte += 60
} else {
// Die Rangliste, von oben nach unten. Weiter unten heißt weniger.
for (rang, sprache) in wish.fallbacks.enumerated()
where LanguageCode.matches(sprache, stream.languageCode) {
punkte += max(5, 50 - rang * 5)
break
}
}
if stream.isDefault { punkte += 10 }
punkte += stream.channels ?? 0
// **Abzug, kein Ausschluss.** Gibt es eine Sprache nur mit
// Audiodeskription, ist sie immer noch besser als Stille.
if wish.avoidsAudioDescription, looksLikeAudioDescription(stream) { punkte -= 200 }
if looksLikeCommentary(stream) { punkte -= 200 }
return punkte
}
static func looksLikeAudioDescription(_ stream: MediaStream) -> Bool {
contains(stream, any: audioDescriptionMarkers) || standsAlone(stream, marker: "ad")
}
static func looksLikeCommentary(_ stream: MediaStream) -> Bool {
contains(stream, any: commentaryMarkers)
}
private static func contains(_ stream: MediaStream, any markers: [String]) -> Bool {
let text = [stream.title, stream.displayTitle]
.compactMap(\.self)
.joined(separator: " ")
.lowercased()
return markers.contains { text.contains($0) }
}
/// „AD" als eigenes Wort — nicht als Teil von „Broadcast" oder „Advanced".
private static func standsAlone(_ stream: MediaStream, marker: String) -> Bool {
let text = [stream.title, stream.displayTitle]
.compactMap(\.self)
.joined(separator: " ")
.lowercased()
return text
.split(whereSeparator: { !$0.isLetter && !$0.isNumber })
.contains { $0 == marker }
}
}
@@ -0,0 +1,63 @@
import Foundation
/// Was AVPlayer auf diesem Gerät nativ abspielen kann.
///
/// Diese Liste ist der Grund, warum es das Umpacken gibt: iOS spielt weder
/// Matroska-Dateien noch DTS- oder TrueHD-Ton, und genau das liegt in
/// typischen Beständen. Alles, was hier nicht steht, lässt die App vom Server
/// umpacken — beim Abspielen wie beim Herunterladen.
public struct DeviceCapabilities: Sendable, Equatable {
public var containers: Set<String>
public var videoCodecs: Set<String>
public var audioCodecs: Set<String>
public init(containers: Set<String>, videoCodecs: Set<String>, audioCodecs: Set<String>) {
self.containers = containers
self.videoCodecs = videoCodecs
self.audioCodecs = audioCodecs
}
/// Der Stand für iOS 26 auf allen Geräten, die diese Version tragen
/// (A12 aufwärts).
public static let avFoundation = DeviceCapabilities(
// Kein `mkv`, kein `avi`, kein `ts`. Das ist keine Auslassung, sondern
// der Kern des Problems: Matroska ist der übliche Container für
// Remuxe, und AVFoundation öffnet ihn nicht.
containers: ["mp4", "m4v", "mov", "qt"],
// AV1 fehlt bewusst: Hardware-Dekodierung gibt es erst ab A17 Pro, und
// Software-Dekodierung von 4K-AV1 bringt auch neue Geräte an die
// Grenze. Umrechnen ist dort das Verlässlichere.
videoCodecs: ["h264", "avc1", "hevc", "h265", "hvc1", "dvh1", "dvhe"],
// DTS, TrueHD und Vorbis fehlen — die drei Gründe, warum ein sonst
// tadelloser MP4-Film trotzdem stumm bliebe.
audioCodecs: ["aac", "mp3", "ac3", "eac3", "alac", "pcm", "lpcm"]
)
/// Schachteln, die sich verlustfrei in eine MP4 umpacken lassen.
///
/// Nicht jede: ein Container kann Dinge enthalten, für die MP4 keinen
/// Platz hat. Diese drei sind die, die in echten Beständen vorkommen und
/// deren Ströme regelmässig MP4-tauglich sind.
public static let remuxableContainers: Set<String> = ["mkv", "matroska", "ts", "m2ts", "avi"]
/// Ob diese Datei so, wie sie ist, geöffnet werden kann.
///
/// **Hier und nicht beim Aufrufer**, weil es die Frage inzwischen zweimal
/// gibt: beim Abspielen entscheidet sie `PlaybackDecider`, beim
/// Herunterladen muss sie vorher beantwortet sein — was offline liegt,
/// kann hinterher niemand mehr umpacken.
///
/// **Was fehlt, zählt als passend.** Nennt der Server einen Codec nicht,
/// ist das keine Aussage über seine Untauglichkeit. Vorsichtshalber
/// umzupacken hiesse, aus einer tadellosen MP4 eine neu gerechnete Datei
/// zu machen.
public func opens(container: String?, videoCodec: String?, audioCodec: String?) -> Bool {
func passt(_ wert: String?, in liste: Set<String>) -> Bool {
guard let wert, !wert.isEmpty else { return true }
return liste.contains(wert.lowercased())
}
return passt(container, in: containers)
&& passt(videoCodec, in: videoCodecs)
&& passt(audioCodec, in: audioCodecs)
}
}
@@ -0,0 +1,132 @@
import Foundation
/// Wann der **teure** Weg gegangen werden darf.
///
/// Der billige Hebel ist `preferredPeakBitRate`: er wirkt ohne Neuaufbau.
/// Wenn er nichts ausrichtet — weil die Playlist nur eine Stufe hat oder weil
/// schon die unterste läuft —, bleibt nur der Neuaufbau mit kleinerer Bitrate:
/// Transcoder anhalten, Engine abreißen, neu aushandeln, zurückspringen. Das
/// sind Sekunden mit schwarzem Bild.
///
/// **Ein Notnagel, keine Regelschleife.** Zu oft ausgelöst erzeugt er genau
/// das Ruckeln, das er beheben soll. Deshalb steht hier eine Liste von
/// Bedingungen, die **alle** gelten müssen — und nicht eine Schwelle.
///
/// Eigener Typ ohne Uhr und ohne Netz, damit sich jede einzelne Bedingung
/// einzeln hinschreiben und prüfen lässt. Eine Regel, die nur im laufenden
/// Film zu beobachten ist, ist keine Regel.
public enum DowngradeRule {
/// Wie lange nach dem Start nichts ausgelöst wird.
///
/// Das anfängliche Füllen des Puffers sieht aus wie eine Notlage und ist
/// keine.
public static let settleIn: Duration = .seconds(10)
/// Wie lange nach einem Sprung nichts ausgelöst wird.
///
/// Ein Sprung leert den Puffer **immer**. Ihn als Beleg für eine schlechte
/// Leitung zu nehmen hieße, sich für das eigene Tun zu bestrafen.
public static let afterSeek: Duration = .seconds(10)
/// Sperrfrist zwischen zwei Absenkungen.
public static let cooldown: Duration = .seconds(180)
/// Das Fenster, in dem Rückler gezählt werden.
public static let window: Duration = .seconds(90)
/// Ab wie vielen Rücklern im Fenster es genügt.
public static let stallsNeeded = 3
/// Ab welcher Länge ein **einzelner** Rückler genügt.
///
/// Fünfzehn Sekunden Stillstand sind keine Schwankung mehr. Ihn
/// mitzuzählen, bis drei zusammenkommen, hieße viereinhalb Minuten
/// zusehen.
public static let longStall: Duration = .seconds(15)
/// Was zum Zeitpunkt der Frage gilt.
public struct Facts: Sendable, Equatable {
/// Ob überhaupt eingegriffen werden darf. Eine ausdrücklich gewählte
/// Stufe ist eine Ansage und wird nicht überstimmt.
public let isAutomatic: Bool
/// Ob die Playlist mehrere Stufen führt.
public let hasLadder: Bool
/// Ob schon die unterste davon läuft.
public let isAtLowestRung: Bool
public let condition: StreamCondition
public let stallsInWindow: Int
public let longestStallInWindow: Duration
/// Wie lange schon gespielt wird.
public let playedFor: Duration
/// Wie lange der letzte Sprung her ist. `nil` heißt: keiner gewesen.
public let sinceLastSeek: Duration?
/// Wie lange die letzte Absenkung her ist. `nil` heißt: keine gewesen.
public let sinceLastDowngrade: Duration?
/// Wie viele es in dieser Wiedergabe schon gab.
public let used: Int
/// Wie viele es höchstens geben darf.
public let budget: Int
public init(
isAutomatic: Bool,
hasLadder: Bool,
isAtLowestRung: Bool,
condition: StreamCondition,
stallsInWindow: Int,
longestStallInWindow: Duration,
playedFor: Duration,
sinceLastSeek: Duration? = nil,
sinceLastDowngrade: Duration? = nil,
used: Int = 0,
budget: Int = 2
) {
self.isAutomatic = isAutomatic
self.hasLadder = hasLadder
self.isAtLowestRung = isAtLowestRung
self.condition = condition
self.stallsInWindow = stallsInWindow
self.longestStallInWindow = longestStallInWindow
self.playedFor = playedFor
self.sinceLastSeek = sinceLastSeek
self.sinceLastDowngrade = sinceLastDowngrade
self.used = used
self.budget = budget
}
}
/// Warum **nicht** abgesenkt wird.
///
/// Als Aufzählung statt als `Bool`, weil genau das im Protokoll stehen
/// soll: „es hakt, aber wir tun nichts" ist ohne Grund von einem Fehler
/// nicht zu unterscheiden.
public enum Verdict: Sendable, Equatable {
case downgrade
/// Eine gewählte Stufe wird nicht überstimmt.
case notAutomatic
/// Der billige Hebel ist noch nicht ausgereizt.
case cheaperPathRemains
/// Es hakt nicht genug.
case notBadEnough
/// Zu früh nach Start oder Sprung.
case tooEarly
/// Sperrfrist läuft noch.
case cooling
/// Verbraucht. Ab hier gehört es dem Nutzer gesagt.
case budgetSpent
}
public static func verdict(_ facts: Facts) -> Verdict {
guard facts.isAutomatic else { return .notAutomatic }
// Solange es noch eine Sprosse tiefer gibt, ist der billige Weg der
// richtige — er kostet kein schwarzes Bild.
if facts.hasLadder, !facts.isAtLowestRung { return .cheaperPathRemains }
let schlimmGenug = facts.stallsInWindow >= stallsNeeded
|| facts.longestStallInWindow >= longStall
guard schlimmGenug, facts.condition != .healthy else { return .notBadEnough }
guard facts.playedFor >= settleIn else { return .tooEarly }
if let seit = facts.sinceLastSeek, seit < afterSeek { return .tooEarly }
if let seit = facts.sinceLastDowngrade, seit < cooldown { return .cooling }
guard facts.used < facts.budget else { return .budgetSpent }
return .downgrade
}
}
@@ -0,0 +1,84 @@
import Foundation
/// Sprachkürzel vergleichen, ohne auf ihre Schreibweise hereinzufallen.
///
/// **Warum es das gibt.** Verglichen wurden bisher die ersten zwei Zeichen.
/// Das ist in einem Fall falsch, und ausgerechnet der ist häufig:
///
/// deu ~ de → Treffer
/// ger ~ de → KEIN Treffer
/// de-DE ~ de → Treffer
///
/// `ger` ist die ISO-639-2/B-Form von Deutsch, und ffmpeg schreibt sie in
/// viele Dateien. Bei solchen Titeln wirkte die Spracheinstellung
/// **überhaupt nicht** — lautlos, ohne dass irgendetwas darauf hindeutete.
///
/// Zwanzig Sprachen haben zwei verschiedene dreibuchstabige Formen: eine
/// bibliografische (/B, aus dem Englischen) und eine terminologische (/T, aus
/// der Sprache selbst). Nur die zwanzig stehen unten — für alle anderen ist
/// die dreibuchstabige Form eindeutig, und eine vollständige Tabelle wäre
/// siebenhundert Zeilen, die niemand prüft.
public enum LanguageCode {
/// Paare, bei denen dieselbe Sprache zwei dreibuchstabige Kürzel hat.
///
/// Zusätzlich die zweibuchstabige Form, denn das ist das Ziel: alles,
/// was dasselbe meint, soll auf denselben Wert fallen.
private static let aliases: [String: String] = [
"alb": "sq", "sqi": "sq",
"arm": "hy", "hye": "hy",
"baq": "eu", "eus": "eu",
"bur": "my", "mya": "my",
"chi": "zh", "zho": "zh",
"cze": "cs", "ces": "cs",
"dut": "nl", "nld": "nl",
"fre": "fr", "fra": "fr",
"geo": "ka", "kat": "ka",
"ger": "de", "deu": "de",
"gre": "el", "ell": "el",
"ice": "is", "isl": "is",
"mac": "mk", "mkd": "mk",
"mao": "mi", "mri": "mi",
"may": "ms", "msa": "ms",
"per": "fa", "fas": "fa",
"rum": "ro", "ron": "ro",
"slo": "sk", "slk": "sk",
"tib": "bo", "bod": "bo",
"wel": "cy", "cym": "cy",
// Die häufigen, die keine zwei Formen haben, aber oft dreibuchstabig
// in Dateien stehen.
"eng": "en", "spa": "es", "ita": "it", "por": "pt", "rus": "ru",
"jpn": "ja", "kor": "ko", "pol": "pl", "swe": "sv", "nor": "no",
"dan": "da", "fin": "fi", "tur": "tr", "ara": "ar", "heb": "he",
"hin": "hi", "tha": "th", "vie": "vi", "ukr": "uk", "hun": "hu",
"bul": "bg", "hrv": "hr", "srp": "sr", "slv": "sl", "est": "et",
"lav": "lv", "lit": "lt", "cat": "ca", "gle": "ga",
]
/// Bringt ein Kürzel auf eine Form, die sich vergleichen lässt.
///
/// **`gsw` bleibt `gsw`.** Schweizerdeutsch ist keine Schreibweise von
/// Deutsch, sondern eine eigene Sprache ohne zweibuchstabiges Kürzel. Es
/// auf `de` abzubilden wäre bequem und falsch: wer Hochdeutsch will,
/// bekäme Schweizerdeutsch untergeschoben. Dasselbe gilt für Bairisch
/// (`bar`), Plattdeutsch (`nds`) und alles andere dieser Art — sie gehören
/// in die Rangliste, nicht in eine Gleichsetzung.
public static func normalized(_ raw: String?) -> String? {
guard let raw else { return nil }
// Region abschneiden: „de-DE", „pt_BR", „zh-Hans".
let ohneRegion = raw
.lowercased()
.split(whereSeparator: { $0 == "-" || $0 == "_" })
.first
.map(String.init) ?? ""
guard !ohneRegion.isEmpty else { return nil }
if let bekannt = aliases[ohneRegion] { return bekannt }
return ohneRegion
}
/// Ob zwei Kürzel dieselbe Sprache meinen.
public static func matches(_ lhs: String?, _ rhs: String?) -> Bool {
guard let links = normalized(lhs), let rechts = normalized(rhs) else { return false }
return links == rechts
}
}
@@ -0,0 +1,38 @@
import Foundation
import Network
import Observation
/// Beobachtet, worüber das Gerät gerade am Netz hängt.
///
/// Die Unterscheidung WLAN/Mobilfunk entscheidet, welche Bitratengrenze gilt.
/// `isExpensive` statt einer Abfrage auf den Schnittstellentyp: der Wert deckt
/// auch den Fall ab, dass das iPhone am persönlichen Hotspot eines anderen
/// Geräts hängt — technisch WLAN, in der Abrechnung Mobilfunk.
@MainActor
@Observable
public final class NetworkConditions {
public private(set) var isReachable = true
/// Mobilfunk oder Hotspot — alles, was Datenvolumen kostet.
public private(set) var isExpensive = false
/// Der Sparmodus für Daten ist an. Dann sollte die App von sich aus
/// sparsam sein, auch im WLAN.
public private(set) var isConstrained = false
private let monitor = NWPathMonitor()
public init() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor [weak self] in
guard let self else { return }
isReachable = path.status == .satisfied
isExpensive = path.isExpensive
isConstrained = path.isConstrained
}
}
monitor.start(queue: DispatchQueue(label: "halo.network"))
}
deinit {
monitor.cancel()
}
}
@@ -0,0 +1,146 @@
import Foundation
import MediaKit
/// Entscheidet, wie ein Titel abgespielt wird.
///
/// Reine Rechnung ohne Netz und ohne Zustand — deshalb vollständig testbar.
/// Die zugehörige Testtabelle in `PlaybackDeciderTests` ist die wichtigste im
/// Projekt: sie hält fest, welche Datei auf welchem Weg landet, und schlägt
/// an, sobald jemand die Fähigkeitenliste anfasst.
public struct PlaybackDecider: Sendable {
private let capabilities: DeviceCapabilities
public init(capabilities: DeviceCapabilities = .avFoundation) {
self.capabilities = capabilities
}
public func decide(
profile: MediaProfile,
policy: PlaybackPolicy
) -> PlaybackDecision {
var rationale: [PlaybackDecision.Rationale] = []
// 0. Hat jemand ausdrücklich eine Stufe gewählt, wird umgerechnet —
// auch wenn die Datei die Grenze gar nicht überschreitet. Sonst
// bliebe die Wahl folgenlos: für eine 8-Mbit-Datei „12 Mbit" zu
// wählen ergäbe DirectPlay, und am Bild änderte sich nichts.
if policy.forcesTranscoding, policy.allowsTranscoding {
rationale.append(.qualityChosen)
return PlaybackDecision(
method: .transcode(maximumBitrate: policy.maximumBitrate),
rationale: rationale
)
}
// 1. Reicht die Leitung? Das kommt zuerst, weil es unabhängig vom
// Format ist: eine 80-Mbit-Datei über Mobilfunk ist auch dann keine
// gute Idee, wenn AVPlayer sie problemlos öffnen könnte.
if let limit = policy.maximumBitrate, let bitrate = profile.bitrate, bitrate > limit {
rationale.append(.bitrateExceedsLimit(mediaBitrate: bitrate, limit: limit))
if policy.allowsTranscoding {
return PlaybackDecision(method: .transcode(maximumBitrate: limit), rationale: rationale)
}
// Ohne Transcoder bleibt nur, es trotzdem zu versuchen. Das ist
// ehrlicher als eine Fehlermeldung: im WLAN klappt es meistens,
// und der Grund steht in der Begründung.
rationale.append(.transcodingUnavailable)
}
// 2. Kennt der Server die Datei überhaupt?
let container = normalized(profile.container)
guard profile.hasKnownStreams, let videoCodec = normalized(profile.videoCodec) else {
rationale.append(.streamsUnknown)
return fallback(policy: policy, rationale: rationale)
}
// 3. Kann AVPlayer das?
var blockers: [PlaybackDecision.Rationale] = []
if let container, !capabilities.containers.contains(container) {
blockers.append(.containerUnsupported(container))
}
if !capabilities.videoCodecs.contains(videoCodec) {
blockers.append(.videoCodecUnsupported(videoCodec))
}
// Geprüft wird die Spur, die tatsächlich klingen soll — welche das
// ist, hat der Aufrufer beim Bauen des Profils entschieden.
if let audioCodec = normalized(profile.audioCodec),
!capabilities.audioCodecs.contains(audioCodec) {
blockers.append(.audioCodecUnsupported(audioCodec))
}
guard blockers.isEmpty else {
rationale.append(contentsOf: blockers)
// **Solange das Bild durchgereicht werden kann, hilft der Server.**
//
// Zwei Fälle, und beide enden hier. Steht nur die Schachtel im
// Weg — Matroska aussen, H.264 oder HEVC innen, AC3 dazu —, wird
// umgepackt: dieselben Ströme, andere Verpackung, nichts geht
// verloren. Steht der **Ton** im Weg — DTS, TrueHD, Vorbis —,
// rechnet der Server genau diese eine Spur um und kopiert das
// Bild. Beides zusammen geht ebenso.
//
// Das kostet Tonqualität, und zwar nur beim Ton. Dagegen steht,
// was es einbringt: der Titel läuft überhaupt — mit Bild-in-Bild,
// AirPlay und Sperrbildschirm, und ohne dass das ganze Bild neu
// gerechnet werden muss.
//
// Stimmt dagegen der **Videocodec** nicht, ist hier Schluss: dann
// fehlt nicht der Zugang, sondern das Können, und jeder Weg über
// den Server hiesse, jedes Bild neu zu rechnen.
let bildBleibt = blockers.allSatisfy {
switch $0 {
case .containerUnsupported, .audioCodecUnsupported: true
default: false
}
}
// Umpackbar muss die Schachtel nur sein, wenn sie überhaupt im
// Weg steht. Eine MP4 mit DTS-Ton bleibt die MP4, die sie ist —
// sie an dieser Liste zu messen, hiesse sie an einer Frage
// scheitern zu lassen, die sich gar nicht stellt.
let schachtelTraegt = container.map {
capabilities.containers.contains($0)
|| DeviceCapabilities.remuxableContainers.contains($0)
} ?? true
if bildBleibt, schachtelTraegt, policy.allowsTranscoding {
return PlaybackDecision(method: .remux, rationale: rationale)
}
return fallback(policy: policy, rationale: rationale)
}
rationale.append(.nativelySupported)
return PlaybackDecision(method: .directPlay, rationale: rationale)
}
/// Was passiert, wenn AVPlayer nicht kann.
///
/// **Hier stand einmal eine zweite Engine.** VLCKit hatte Vorrang vor dem
/// Transcoder: die Datei ging unverändert über die Leitung, der Server
/// musste nicht rechnen. Es gibt sie nicht mehr — was der Systemplayer
/// nicht öffnet, rechnet der Server um, und was heruntergeladen wird,
/// kommt schon in einer Hülle, die er öffnet.
private func fallback(
policy: PlaybackPolicy,
rationale: [PlaybackDecision.Rationale]
) -> PlaybackDecision {
if policy.allowsTranscoding {
return PlaybackDecision(
method: .transcode(maximumBitrate: policy.maximumBitrate),
rationale: rationale
)
}
var rationale = rationale
rationale.append(.transcodingUnavailable)
return PlaybackDecision(method: .directPlay, rationale: rationale)
}
/// Plex schreibt Codecs mal groß, mal klein, und hängt bei Containern
/// gelegentlich Punkte an. Ein Vergleich ohne diese Angleichung geht
/// sporadisch schief — und zwar nur bei manchen Dateien, was die Suche
/// nach dem Grund unangenehm macht.
private func normalized(_ value: String?) -> String? {
guard let value, !value.isEmpty else { return nil }
return value.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ". "))
}
}
@@ -0,0 +1,65 @@
import Foundation
/// Wie ein Titel abgespielt werden soll.
public struct PlaybackDecision: Sendable, Equatable {
public enum Method: Sendable, Equatable {
/// Die Datei wird unverändert übertragen und lokal dekodiert.
case directPlay
/// Der Server reicht das Bild durch und legt es in eine andere
/// Schachtel — den Ton rechnet er um, falls nötig.
///
/// **Der dritte Weg, und der wichtigste für diesen Bestand.** Ein
/// Blu-ray-Rip liegt als Matroska vor, und AVFoundation öffnet
/// Matroska nicht — obwohl das Bild darin H.264 oder HEVC ist und der
/// Ton AAC oder AC3, also alles, was AVPlayer bestens kann. Im Weg
/// steht allein die Verpackung.
///
/// **Das Bild wird dabei nie angefasst.** Es geht Byte für Byte in
/// eine MP4, und das kostet den Server fast nichts — anders als beim
/// Neurechnen.
///
/// **Der Ton kann es müssen.** Steht DTS, TrueHD oder Vorbis darin,
/// kennt AVPlayer die Spur nicht; der Server kodiert dann genau diese
/// eine Spur nach AC3 oder AAC. Das kostet Tonqualität — und sonst
/// nichts, vor allem keine einzige Bildzeile. Ob es im Einzelfall
/// passiert ist, steht in `rationale`: `audioCodecUnsupported`.
///
/// Der Gewinn ist gross: solche Titel laufen damit im Systemplayer,
/// mit Bild-in-Bild, AirPlay und Systembedienung — und es muss nicht
/// das ganze Bild neu gerechnet werden.
case remux
/// Der Server rechnet um. Ab Phase 2; `maximumBitrate` in Kilobit.
case transcode(maximumBitrate: Int?)
}
/// Warum es so entschieden wurde.
///
/// Wandert in die technischen Infos auf der Detailseite und ins Protokoll.
/// Ohne diese Begründung ist „warum ruckelt das" nicht zu beantworten.
public enum Rationale: Sendable, Equatable {
case nativelySupported
case containerUnsupported(String)
case videoCodecUnsupported(String)
case audioCodecUnsupported(String)
/// Der Server hat die Datei noch nicht analysiert.
case streamsUnknown
case bitrateExceedsLimit(mediaBitrate: Int, limit: Int)
/// Transcoding wäre passender, ist aber (noch) nicht verfügbar.
case transcodingUnavailable
/// Im Player ausdrücklich eine Stufe gewählt.
case qualityChosen
}
public let method: Method
public let rationale: [Rationale]
public init(method: Method, rationale: [Rationale]) {
self.method = method
self.rationale = rationale
}
public var isDirectPlay: Bool {
if case .directPlay = method { return true }
return false
}
}
@@ -0,0 +1,35 @@
import Foundation
/// Was bei der Wiedergabe erlaubt ist.
///
/// Das ist die Stellschraube, an der Phase 2 andockt: heute steht
/// `allowsTranscoding` auf `false` und der Decider kommt zwangsläufig immer
/// bei DirectPlay heraus. Sobald der Transcoder steht, wird hier umgelegt —
/// die Views merken davon nichts.
public struct PlaybackPolicy: Sendable, Equatable {
/// Darf der Server umrechnen?
public var allowsTranscoding: Bool
/// Obergrenze in Kilobit pro Sekunde. `nil` heißt unbegrenzt — der
/// Normalfall im heimischen WLAN.
public var maximumBitrate: Int?
/// Umrechnen, auch wenn die Datei die Grenze gar nicht überschreitet.
///
/// Für die Wahl im Player. Ohne das verpuffte sie: wer für eine
/// 8-Mbit-Datei „12 Mbit" wählt, bekam DirectPlay, weil die Datei ja
/// unter der Grenze liegt — die Wahl blieb folgenlos. Gemeint ist aber
/// „rechne es mir auf diese Stufe um".
public var forcesTranscoding: Bool
public init(
allowsTranscoding: Bool,
maximumBitrate: Int? = nil,
forcesTranscoding: Bool = false
) {
self.allowsTranscoding = allowsTranscoding
self.maximumBitrate = maximumBitrate
self.forcesTranscoding = forcesTranscoding
}
/// Phase 1: alles direkt, nichts umrechnen.
public static let directPlayOnly = PlaybackPolicy(allowsTranscoding: false)
}
@@ -0,0 +1,208 @@
import Foundation
/// Eine im Player wählbare Qualitätsstufe.
///
/// Getrennt von den Einstellungen unter „Qualität": dort steht, was
/// üblicherweise gelten soll; hier greift man für diesen einen Film ein — weil
/// es gerade ruckelt oder weil man ausnahmsweise das Original will.
public enum PlayerQuality: Hashable, Sendable, Identifiable {
/// Was in den Einstellungen für die aktuelle Verbindung hinterlegt ist.
case automatic
/// Die Datei unverändert, ohne Umrechnung.
case original
/// Eine Stufe der Leiter, die der Server anbietet.
case limited(Rung)
/// So wenig Bild wie möglich.
case audioOnly
/// Eine Sprosse: Bitrate und die Auflösung, die Plex dafür vorsieht.
///
/// Die Werte stammen nicht aus der Luft, sondern aus dem, was der Server
/// über sich selbst sagt (`transcoderVideoBitrates`). Eine eigene Leiter
/// zu erfinden hieße, Stufen anzubieten, die der Transcoder gar nicht
/// kennt — er rundet dann still auf die nächste, und die Anzeige log.
public struct Rung: Hashable, Sendable {
public let bitrate: Int
public let height: Int
public init(bitrate: Int, height: Int) {
self.bitrate = bitrate
self.height = height
}
public var width: Int {
// 16:9, auf gerade Zahlen gerundet — ungerade Kantenlängen mag
// kein Kodierer.
let raw = Int((Double(height) * 16.0 / 9.0).rounded())
return raw % 2 == 0 ? raw : raw + 1
}
public var resolution: String { "\(width)x\(height)" }
}
/// Die Leiter von oben nach unten.
public static let ladder: [Rung] = [
Rung(bitrate: 20_000, height: 1080),
Rung(bitrate: 12_000, height: 1080),
Rung(bitrate: 10_000, height: 1080),
Rung(bitrate: 8_000, height: 1080),
Rung(bitrate: 4_000, height: 1080),
Rung(bitrate: 3_000, height: 720),
Rung(bitrate: 2_000, height: 720),
Rung(bitrate: 1_500, height: 720),
Rung(bitrate: 720, height: 480),
Rung(bitrate: 320, height: 360),
Rung(bitrate: 208, height: 360),
Rung(bitrate: 96, height: 240),
Rung(bitrate: 64, height: 160),
]
/// Eine Zahl, die sich aufbewahren lässt.
///
/// Über mehrere Filme hinweg gilt nur die **Absicht** — „ungefähr so viel
/// Bitrate" —, nicht die genaue Sprosse: welche Sprossen es gibt, hängt
/// von der Quelle ab. Deshalb eine Zahl und kein Aufzählungswert.
public var storageValue: Int {
switch self {
case .automatic: 0
case .original: -1
case .audioOnly: -2
case .limited(let rung): rung.bitrate
}
}
/// Die Stufe zu einer aufbewahrten Zahl, so nah wie möglich.
///
/// Gibt es die Sprosse für diese Quelle nicht, wird die nächstgelegene
/// genommen — eine 720p-Datei kennt keine 20-Megabit-Stufe, und dann
/// stillschweigend auf „automatisch" zurückzufallen wäre eine andere
/// Antwort als die, um die gebeten wurde.
public static func restored(from value: Int, forSourceHeight height: Int?) -> PlayerQuality? {
switch value {
case 0: return nil
case -1: return .original
case -2: return .audioOnly
default: break
}
let rungs = available(forSourceHeight: height).compactMap { quality -> Rung? in
if case .limited(let rung) = quality { return rung }
return nil
}
guard let closest = rungs.min(by: {
abs($0.bitrate - value) < abs($1.bitrate - value)
}) else { return nil }
return .limited(closest)
}
public static var allCases: [PlayerQuality] {
[.automatic, .original] + ladder.map(PlayerQuality.limited) + [.audioOnly]
}
public var id: String {
switch self {
case .automatic: "automatic"
case .original: "original"
case .audioOnly: "audioOnly"
case .limited(let rung): "limited-\(rung.bitrate)"
}
}
/// Obergrenze in Kilobit. `nil` bei `automatic` und `original`.
public var maximumBitrate: Int? {
switch self {
case .automatic, .original: nil
case .limited(let rung): rung.bitrate
// Plex kennt für Videos keinen reinen Tonstrom. Die unterste Sprosse
// ist das, was dem am nächsten kommt; das Bild wird dazu im Player
// ausgeblendet, damit der Schirm dunkel bleiben kann.
case .audioOnly: Self.ladder.last?.bitrate
}
}
public var resolution: String? {
switch self {
case .automatic, .original: nil
case .limited(let rung): rung.resolution
case .audioOnly: Self.ladder.last?.resolution
}
}
/// Die Höhe in Bildzeilen, sofern die Stufe eine vorgibt.
public var height: Int? {
switch self {
case .automatic, .original: nil
case .limited(let rung): rung.height
case .audioOnly: Self.ladder.last?.height
}
}
/// Ob das Bild gezeigt werden soll.
public var showsVideo: Bool {
if case .audioOnly = self { return false }
return true
}
/// Ob diese Stufe eine Umrechnung erzwingt.
///
/// `original` erzwingt das Gegenteil: keine Umrechnung, auch wenn die
/// Einstellungen eine Grenze vorsehen.
public var forcesTranscode: Bool {
switch self {
case .automatic, .original: false
case .limited, .audioOnly: true
}
}
/// Die Richtlinie für diese Stufe.
///
/// `automatic` reicht durch, was ohnehin gilt. Die übrigen überschreiben
/// sie — das ist der Zweck eines Eingriffs im Player.
public func policy(basedOn base: PlaybackPolicy) -> PlaybackPolicy {
switch self {
case .automatic:
base
case .original:
// Keine Grenze und kein Umrechnen: ausdrücklich das Original.
PlaybackPolicy(allowsTranscoding: false, maximumBitrate: nil)
case .limited, .audioOnly:
PlaybackPolicy(
allowsTranscoding: true,
maximumBitrate: maximumBitrate,
forcesTranscoding: true
)
}
}
/// Die kurze Liste: je Auflösung eine Stufe, und zwar eine brauchbare.
///
/// Dreizehn Sprossen sind für den Normalfall zu viel — man will „geht
/// gerade nicht flüssig, nimm eine Stufe kleiner", nicht die Wahl
/// zwischen zehn und zwölf Megabit. Die vollständige Leiter bleibt
/// erreichbar, steht aber nicht im Weg.
public static let everyday: [Rung] = [
Rung(bitrate: 8_000, height: 1080),
Rung(bitrate: 3_000, height: 720),
Rung(bitrate: 720, height: 480),
]
/// Die kurze Auswahl für eine Quelle dieser Höhe.
public static func everyday(forSourceHeight height: Int?) -> [PlayerQuality] {
let rungs = everyday.filter { height == nil || $0.height <= (height ?? 0) }
let usable = rungs.isEmpty ? Array(everyday.suffix(1)) : rungs
return [.automatic, .original] + usable.map(PlayerQuality.limited) + [.audioOnly]
}
/// Nur die Stufen, die für eine Quelle dieser Höhe sinnvoll sind.
///
/// Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein Bild dazu, und
/// die Stufen sähen alle gleich aus — dieselbe Falle wie beim Herunterladen.
public static func available(forSourceHeight height: Int?) -> [PlayerQuality] {
guard let height, height > 0 else { return allCases }
let fitting = ladder.filter { $0.height <= height }
// Mindestens die unterste Sprosse anbieten, sonst bliebe bei einer
// sehr kleinen Quelle nur „Original".
let rungs = fitting.isEmpty ? Array(ladder.suffix(1)) : fitting
return [.automatic, .original] + rungs.map(PlayerQuality.limited) + [.audioOnly]
}
}
@@ -0,0 +1,122 @@
import Foundation
/// Buch über das Stocken, mit gleitendem Fenster.
///
/// **Warum es das gibt.** Der Watchdog im Player kannte nur eine Frage: steht
/// es seit vierzig Sekunden am Stück? Bei jedem kurzen Erholen setzte er sich
/// zurück — zehn Rückler à dreißig Sekunden lösten nichts aus, obwohl die
/// halbe Stunde unbrauchbar war. Genau dieser Fall ist der, über den geklagt
/// wird: nicht der tote Stream, sondern der, der immer wieder hakt.
///
/// **Warum als eigener Typ.** Keine Uhr, kein AVFoundation, kein Netz — die
/// Zeiten kommen von außen herein. Damit lässt sich eine Folge von Rücklern
/// als Zahlenreihe hinschreiben und das Urteil prüfen. Eine Regel, die man
/// nur im laufenden Film beobachten kann, ist keine Regel, sondern ein
/// Eindruck.
///
/// Alle Zeitpunkte sind **seit Beginn dieser Wiedergabe** gemessen, nicht als
/// Datum. Das macht die Sache gegen Zeitzonen, Sommerzeit und eine Systemuhr,
/// die sich mitten im Film stellt, gleichgültig.
public struct StallLedger: Sendable, Equatable {
public struct Episode: Sendable, Equatable {
/// Wann dieser Rückler vorbei war.
public let endedAt: Duration
public let length: Duration
public init(endedAt: Duration, length: Duration) {
self.endedAt = endedAt
self.length = length
}
}
/// Wie weit zurück gezählt wird.
///
/// Drei Minuten: lang genug, dass ein Muster erkennbar wird, kurz genug,
/// dass eine überstandene Durststrecke nicht noch eine Viertelstunde
/// später nachwirkt.
public let window: Duration
private(set) var episodes: [Episode] = []
public init(window: Duration = .seconds(180)) {
self.window = window
}
/// Trägt einen überstandenen Rückler ein.
public mutating func record(_ episode: Episode) {
episodes.append(episode)
prune(at: episode.endedAt)
}
/// Wie viele Rückler im Fenster liegen.
public func count(at now: Duration) -> Int {
episodes.count { $0.endedAt >= now - window }
}
/// Welcher Anteil des Fensters mit Warten verbracht wurde — 0 bis 1.
///
/// Der laufende Rückler zählt mit, sofern er übergeben wird: wer mitten
/// im dritten Stocken steht, soll nicht erst nach dessen Ende erfahren,
/// dass es zu viel war.
public func share(at now: Duration, ongoing: Duration = .zero) -> Double {
let horizon = now - window
let waited = episodes
.filter { $0.endedAt >= horizon }
.reduce(Duration.zero) { $0 + $1.length } + ongoing
// Am Anfang ist das Fenster noch nicht voll. Dann gegen die
// tatsächlich vergangene Zeit rechnen, sonst sähe eine Wiedergabe,
// die nach zwanzig Sekunden fünf Sekunden hakte, mit 3 % harmlos aus
// statt mit 25 %.
let elapsed = min(now, window)
guard elapsed > .zero else { return 0 }
return min(1, waited / elapsed)
}
/// Der längste einzelne Rückler im Fenster.
///
/// Ein einzelner langer ist etwas anderes als viele kurze: der eine sagt
/// „die Leitung war weg", die vielen sagen „sie trägt es dauerhaft nicht".
/// Beide sollen auslösen dürfen, aber nicht über dieselbe Schwelle.
public func longest(at now: Duration) -> Duration {
episodes
.filter { $0.endedAt >= now - window }
.map(\.length)
.max() ?? .zero
}
/// Wann der letzte Rückler zu Ende war.
///
/// `nil`, wenn es im Fenster keinen gab — und das ist etwas anderes als
/// „vor langer Zeit": wer noch nie gestockt hat, wartet auf keine Frist.
///
/// Gebraucht fürs Hinaufschalten: wer gerade gestockt hat, hat keine
/// Reserve, und ein Neuaufbau nach oben wäre die zweite Unterbrechung
/// binnen einer Minute.
public func lastStall(at now: Duration) -> Duration? {
episodes
.filter { $0.endedAt >= now - window }
.map(\.endedAt)
.max()
}
/// Wirft heraus, was aus dem Fenster gefallen ist.
public mutating func prune(at now: Duration) {
let horizon = now - window
episodes.removeAll { $0.endedAt < horizon }
}
public mutating func removeAll() {
episodes.removeAll()
}
}
private extension Duration {
/// Anteil zweier Dauern. `Duration` kann sich nicht selbst teilen.
static func / (lhs: Duration, rhs: Duration) -> Double {
let left = Double(lhs.components.seconds) + Double(lhs.components.attoseconds) / 1e18
let right = Double(rhs.components.seconds) + Double(rhs.components.attoseconds) / 1e18
guard right != 0 else { return 0 }
return left / right
}
}
@@ -0,0 +1,175 @@
import Foundation
/// Eine Probe vom laufenden Stream.
///
/// Alle Zeiten **seit Beginn dieser Wiedergabe**, nicht als Datum — dasselbe
/// wie beim `StallLedger`, und aus demselben Grund: eine Systemuhr, die sich
/// mitten im Film stellt, darf das Urteil nicht kippen.
public struct StreamSample: Sendable, Equatable {
public let at: Duration
/// Wie weit der Puffer vor der Abspielstelle reicht. `nil` heißt
/// „noch nichts geladen" — etwas anderes als ein leerer Puffer.
public let bufferedAhead: Duration?
public let isLikelyToKeepUp: Bool?
/// Der fortlaufende Zähler, nicht die Zahl seit der letzten Probe.
public let stallCount: Int
/// Auf welcher Sprosse gerade gespielt wird, in Kilobit je Sekunde.
public let indicatedBitrate: Int?
public init(
at: Duration,
bufferedAhead: Duration? = nil,
isLikelyToKeepUp: Bool? = nil,
stallCount: Int = 0,
indicatedBitrate: Int? = nil
) {
self.at = at
self.bufferedAhead = bufferedAhead
self.isLikelyToKeepUp = isLikelyToKeepUp
self.stallCount = stallCount
self.indicatedBitrate = indicatedBitrate
}
}
/// Wie es dem Stream geht.
public enum StreamCondition: Sendable, Equatable {
/// Trägt. Es darf wieder eine Stufe höher gehen.
case healthy
/// Wird knapp. Eine Stufe tiefer, bevor das Bild steht.
case strained
/// Trägt nicht. So tief wie möglich.
case failing
}
/// Beurteilt den Stream aus einer Folge von Proben.
///
/// **Warum als reiner Typ.** Kein AVFoundation, keine Uhr, kein Netz — Proben
/// hinein, Urteil heraus. Dieselbe Bauform wie `PlaybackDecider`, und aus
/// demselben Grund: eine Regelung, die man nur im laufenden Film beobachten
/// kann, ist nicht zu beurteilen. Hier lässt sich eine schlechte Zugfahrt als
/// Zahlenreihe hinschreiben und nachsehen, was sie auslöst.
///
/// **Welches Signal wann meldet** — das ist der eigentliche Inhalt:
///
/// - **Der Pufferverlauf ist der einzige echte Frühwarner.** Schrumpft er
/// schneller, als die Zeit vergeht, kommt weniger herein als hinausgeht.
/// Das sieht man zehn bis zwanzig Sekunden vor dem Rückler.
/// - `isLikelyToKeepUp` ist früh, aber grob: Apple legt es um, wenn es sich
/// schon sorgt. Als Bestätigung gut, als alleiniger Auslöser zu sprunghaft.
/// - Der Rücklerzähler meldet **zu spät zum Regeln** — das Bild stand bereits.
/// Er ist trotzdem unverzichtbar: an ihm misst sich hinterher, ob es besser
/// wurde.
/// - Die **gemessene** Bitrate steht hier bewusst nicht. Auf einer kleinen
/// Sprosse holt der Player wenig, ohne dass die Leitung schlecht wäre —
/// als Kriterium wäre sie irreführend.
///
/// **Absichtlich unsymmetrisch: schnell runter, langsam rauf.** Ein falscher
/// Schritt nach unten kostet Schärfe, ein falscher nach oben einen Rückler.
public struct StreamHealth: Sendable, Equatable {
/// Wie viel Puffer als knapp gilt.
public static let lowBuffer: Duration = .seconds(6)
/// Ab wann er als reichlich gilt.
public static let goodBuffer: Duration = .seconds(20)
private var samples: [StreamSample] = []
private(set) public var condition: StreamCondition = .healthy
/// Seit wann es ununterbrochen gut aussieht. `nil`, sobald es das nicht tut.
private var healthySince: Duration?
public init() {}
/// Nimmt eine Probe und gibt das aktuelle Urteil zurück.
@discardableResult
public mutating func record(_ sample: StreamSample) -> StreamCondition {
samples.append(sample)
samples.removeAll { $0.at < sample.at - .seconds(60) }
condition = judge(at: sample)
return condition
}
public mutating func removeAll() {
samples.removeAll()
healthySince = nil
condition = .healthy
}
// MARK: - Das Urteil
private mutating func judge(at now: StreamSample) -> StreamCondition {
// **Zuerst das Schlimmste.** Zwei Rückler in einer Minute heißen: es
// war kein Ausrutscher.
if stalls(within: .seconds(60), at: now) >= 2 {
healthySince = nil
return .failing
}
// Puffer auf null, während gespielt wird. `nil` zählt nicht — das
// heißt „noch nichts geladen" und ist der Zustand beim Start.
if let buffer = now.bufferedAhead, buffer == .zero {
healthySince = nil
return .failing
}
if isStrained(at: now) {
healthySince = nil
return .strained
}
// **Der Weg zurück ist der schmalste.** Reichlich Puffer allein
// genügt nicht: er muss eine halbe Minute lang reichlich geblieben
// sein, und in der letzten Minute darf es nicht gehakt haben. Sonst
// pendelt die Stufe im Takt der Aussetzer, und das ist schlimmer als
// eine Stufe zu niedrig zu bleiben.
guard let buffer = now.bufferedAhead, buffer >= Self.goodBuffer else {
healthySince = nil
return condition == .healthy ? .healthy : condition
}
let seit = healthySince ?? now.at
healthySince = seit
let langGenug = now.at - seit >= .seconds(30)
let ruhig = stalls(within: .seconds(60), at: now) == 0
guard langGenug, ruhig else {
return condition == .healthy ? .healthy : condition
}
return .healthy
}
private func isStrained(at now: StreamSample) -> Bool {
// Knapp **und** fallend. Ein kleiner Puffer, der stabil bleibt, ist
// kein Alarm — er ist nur klein.
if let buffer = now.bufferedAhead, buffer < Self.lowBuffer, isFalling(at: now) {
return true
}
// Apples eigene Sorge, aber erst wenn sie anhält: ein einzelner
// Ausschlag kommt bei jedem Sprung vor.
let letzte = samples.filter { $0.at >= now.at - .seconds(3) }
if letzte.count >= 3, letzte.allSatisfy({ $0.isLikelyToKeepUp == false }) {
return true
}
return false
}
/// Ob der Puffer über die letzten Sekunden hinweg schrumpft.
private func isFalling(at now: StreamSample) -> Bool {
let fenster = samples.filter { $0.at >= now.at - .seconds(5) }
guard fenster.count >= 3,
let ältester = fenster.first?.bufferedAhead,
let jüngster = fenster.last?.bufferedAhead
else { return false }
return jüngster < ältester
}
/// Wie viele Rückler im Zeitfenster dazugekommen sind.
///
/// Aus der **Differenz** des fortlaufenden Zählers, nicht aus einer
/// eigenen Zählung: der Zähler kommt vom System und weiß auch von
/// Rücklern zwischen zwei Proben.
private func stalls(within window: Duration, at now: StreamSample) -> Int {
let fenster = samples.filter { $0.at >= now.at - window }
guard let erster = fenster.first else { return 0 }
return max(0, now.stallCount - erster.stallCount)
}
}
@@ -0,0 +1,99 @@
import Foundation
import MediaKit
/// Was in einem Spurenmenü steht — und was ein Umschalten kostet.
///
/// **Warum es diesen Typ gibt.** Auf dem Fernseher sitzt die Spurwahl in
/// Apples Transportleiste, und die nimmt `UIMenu`-Einträge entgegen. Die
/// Abbildung von Spuren auf Einträge hat mit UIKit aber nichts zu tun: sie
/// beantwortet, welche Spuren zur Wahl stehen, welche gerade gilt, und — das
/// ist der Teil, der sonst nirgends steht — **ob das Umschalten Sekunden mit
/// schwarzem Bild kostet**.
///
/// Diese Regel lag bisher nur mitten im Vollzug von
/// `PlayerModel.selectAudioStream` und war damit weder ablesbar noch prüfbar:
/// auslösen lässt sie sich nur an einem echten Server mit einer Datei, die
/// mehrere Tonspuren hat. Hier steht sie als Auskunft — und die Oberfläche
/// kann sie **vorher** anzeigen, statt den Nutzer hinterher vor ein schwarzes
/// Bild zu setzen.
///
/// Liegt neben `AudioTrackChooser` und `DowngradeRule`, weil es dasselbe ist:
/// eine Regel über die Wiedergabe, ohne Oberfläche und ohne Netz.
public enum TrackMenu {
public struct Entry: Equatable, Sendable, Identifiable {
/// Der Index der Spur beim Server. `nil` heisst „Aus".
public let id: Int?
public let title: String
public let isSelected: Bool
/// Ob das Umschalten den Stream neu aufbaut.
public let rebuilds: Bool
public init(id: Int?, title: String, isSelected: Bool, rebuilds: Bool) {
self.id = id
self.title = title
self.isSelected = isSelected
self.rebuilds = rebuilds
}
}
/// Die Tonspuren **der Datei**, nicht die des Players.
///
/// Rechnet der Server um, kennt der Player genau eine Spur — die, für die
/// er sich entschieden hat. Zur Wahl steht, was in der Datei liegt.
///
/// - Parameter playerTrackCount: Wie viele Tonspuren der Player meldet.
/// Weicht die Zahl ab, stimmt die Zuordnung über die Reihenfolge nicht,
/// und es muss neu aufgebaut werden — lieber eine Unterbrechung als die
/// falsche Sprache.
public static func audio(
from streams: [MediaStream],
selected: MediaStream?,
isDirectPlay: Bool,
playerTrackCount: Int
) -> [Entry] {
let billig = isDirectPlay && playerTrackCount == streams.count
return streams.enumerated().map { stelle, stream in
Entry(
id: stream.index ?? stelle,
title: stream.name,
isSelected: stream == selected,
rebuilds: !billig
)
}
}
/// Die Untertitelspuren, mit „Aus" an erster Stelle.
///
/// - Parameter offLabel: Wie „Aus" in der Sprache des Nutzers heisst.
/// Als Argument, weil dieses Paket keinen Textkatalog der App kennt.
/// - Parameter playerTrackLanguages: Die Sprachkürzel der Spuren, die der
/// Player wirklich hat. Nur was sich darin abbilden lässt, schaltet ohne
/// Neuaufbau um — sonst stünde der falsche Untertitel im Bild, und das
/// ist schlimmer als eine kurze Unterbrechung.
public static func subtitles(
from streams: [MediaStream],
selected: MediaStream?,
isDirectPlay: Bool,
playerTrackLanguages: [String?] = [],
offLabel: String
) -> [Entry] {
// „Aus" kostet nie einen Neuaufbau: abschalten kann der Player immer,
// auch eine Spur, die er nie hatte.
var einträge = [
Entry(id: nil, title: offLabel, isSelected: selected == nil, rebuilds: false)
]
einträge += streams.enumerated().map { stelle, stream in
let abbildbar = isDirectPlay && playerTrackLanguages.contains {
LanguageCode.matches(stream.languageCode, $0)
}
return Entry(
id: stream.index ?? stelle,
title: stream.name,
isSelected: stream == selected,
rebuilds: !abbildbar
)
}
return einträge
}
}
@@ -0,0 +1,140 @@
import Foundation
/// Wann **hinauf** gegangen wird — und wann der teure Weg dafür erlaubt ist.
///
/// **Die Falle, um die es hier geht.** Damit ein Film zügig anläuft, wird mit
/// einer niedrigen Stufe begonnen. Der Server baut seine Leiter aber aus
/// genau dieser Bestellung: wer mit drei Megabit anfängt, bekommt eine
/// Leiter, deren oberste Sprosse drei Megabit ist. Das billige Hinaufschalten
/// über `preferredPeakBitRate` bewegt sich **innerhalb** dieser Leiter — es
/// käme also nie darüber hinaus, und aus dem schnellen Start würde ein Film,
/// der den ganzen Abend in 720p bleibt.
///
/// Höher geht es nur mit einem Neuaufbau: neu aushandeln, mit einer grösseren
/// Grenze. Der kostet Sekunden mit schwarzem Bild — deshalb dieselbe Bauform
/// wie `DowngradeRule`: eine Liste von Bedingungen, die **alle** gelten
/// müssen, kein Schwellwert.
///
/// **Und deutlich zurückhaltender als das Absenken.** Ein zu spätes Absenken
/// bedeutet Ruckeln; ein zu spätes Hinaufschalten bedeutet ein etwas
/// weicheres Bild. Das erste ist ein Fehler, das zweite eine Geschmacksfrage.
public enum UpgradeRule {
/// Wie lange es ruhig laufen muss, bevor überhaupt gefragt wird.
///
/// Lang genug, dass es kein anfängliches Aufatmen ist: der Puffer füllt
/// sich nach dem Start immer, und das sieht zwanzig Sekunden lang nach
/// einer hervorragenden Leitung aus.
public static let settleIn: Duration = .seconds(45)
/// Sperrfrist zwischen zwei Neuaufbauten nach oben.
public static let cooldown: Duration = .seconds(150)
/// Wie lange nach einem Rückler gar nicht hinaufgegangen wird.
///
/// Wer gerade gestockt hat, hat keine Reserve — und ein Neuaufbau nach
/// oben wäre dann die zweite Unterbrechung binnen einer Minute.
public static let afterStall: Duration = .seconds(90)
/// Wie viel Vorrat dasein muss.
///
/// Nicht „es läuft gerade", sondern „es liegt etwas auf der hohen Kante".
/// Nur das trägt einen Neuaufbau.
public static let bufferNeeded: Duration = .seconds(20)
/// Was zum Zeitpunkt der Frage gilt.
public struct Facts: Sendable, Equatable {
/// Eine ausdrücklich gewählte Stufe ist eine Ansage und wird nicht
/// überstimmt — auch nicht nach oben.
public let isAutomatic: Bool
public let condition: StreamCondition
/// Ob die Leiter noch eine Sprosse nach oben hat. Solange ja, ist der
/// **billige** Weg der richtige.
public let hasHigherRung: Bool
/// Ob die eingestellte Obergrenze überhaupt mehr zuliesse.
public let ceilingAllowsMore: Bool
/// Wie viel Vorrat vor der Abspielstelle liegt.
public let bufferedAhead: Duration?
/// Wie lange schon gespielt wird.
public let playedFor: Duration
/// Wie lange der letzte Rückler her ist. `nil` heisst: keiner gewesen.
public let sinceLastStall: Duration?
/// Wie lange der letzte Sprung her ist.
public let sinceLastSeek: Duration?
/// Wie lange der letzte Neuaufbau nach oben her ist.
public let sinceLastUpgrade: Duration?
/// Wie viele es in dieser Wiedergabe schon gab.
public let used: Int
/// Wie viele es höchstens geben darf.
public let budget: Int
public init(
isAutomatic: Bool,
condition: StreamCondition,
hasHigherRung: Bool,
ceilingAllowsMore: Bool,
bufferedAhead: Duration?,
playedFor: Duration,
sinceLastStall: Duration? = nil,
sinceLastSeek: Duration? = nil,
sinceLastUpgrade: Duration? = nil,
used: Int = 0,
budget: Int = 2
) {
self.isAutomatic = isAutomatic
self.condition = condition
self.hasHigherRung = hasHigherRung
self.ceilingAllowsMore = ceilingAllowsMore
self.bufferedAhead = bufferedAhead
self.playedFor = playedFor
self.sinceLastStall = sinceLastStall
self.sinceLastSeek = sinceLastSeek
self.sinceLastUpgrade = sinceLastUpgrade
self.used = used
self.budget = budget
}
}
/// Warum **nicht** hinaufgegangen wird.
///
/// Als Aufzählung statt als `Bool`, aus demselben Grund wie beim
/// Absenken: „es läuft gut und es passiert nichts" ist ohne Begründung
/// von einem Fehler nicht zu unterscheiden.
public enum Verdict: Sendable, Equatable {
case rebuildHigher
/// Eine gewählte Stufe wird nicht überstimmt.
case notAutomatic
/// Die Leiter hat noch eine Sprosse — der billige Weg genügt.
case cheaperPathRemains
/// Die Einstellung lässt nicht mehr zu. Kein Fehler, eine Ansage.
case atCeiling
/// Es läuft nicht ruhig genug.
case notCalmEnough
/// Zu früh nach Start, Sprung oder Rückler.
case tooEarly
/// Sperrfrist läuft noch.
case cooling
/// Verbraucht.
case budgetSpent
}
public static func verdict(_ facts: Facts) -> Verdict {
guard facts.isAutomatic else { return .notAutomatic }
guard facts.ceilingAllowsMore else { return .atCeiling }
// **Erst die Leiter ausreizen.** Solange es eine Sprosse höher gibt,
// kostet das Hinaufgehen kein schwarzes Bild — und wäre ein
// Neuaufbau dafür Verschwendung.
if facts.hasHigherRung { return .cheaperPathRemains }
guard facts.condition == .healthy else { return .notCalmEnough }
guard let vorrat = facts.bufferedAhead, vorrat >= bufferNeeded else {
return .notCalmEnough
}
guard facts.playedFor >= settleIn else { return .tooEarly }
if let seit = facts.sinceLastSeek, seit < DowngradeRule.afterSeek { return .tooEarly }
if let seit = facts.sinceLastStall, seit < afterStall { return .tooEarly }
if let seit = facts.sinceLastUpgrade, seit < cooldown { return .cooling }
guard facts.used < facts.budget else { return .budgetSpent }
return .rebuildHigher
}
}
@@ -0,0 +1,616 @@
import AVFoundation
import Foundation
import MediaKit
/// Wiedergabe über den Systemplayer.
///
/// Der bevorzugte Weg, wann immer das Format ihn zulässt: Bild-in-Bild,
/// AirPlay, die Steuerung im Sperrbildschirm und der Umgang mit
/// Unterbrechungen durch Anrufe kommen alle mit, ohne dass etwas davon
/// nachgebaut werden müsste.
@MainActor
public final class AVPlayerEngine: VideoPlayerEngine {
/// Wird von der Ansicht gebraucht, um die Bildebene zu bauen. Der einzige
/// Grund, warum AVFoundation überhaupt nach außen sichtbar ist.
public let player = AVPlayer()
public private(set) var state: PlaybackState = .idle {
didSet {
guard state != oldValue else { return }
noteStall(from: oldValue, to: state)
continuation?.yield(.stateChanged(state))
}
}
public private(set) var duration: Duration?
public private(set) var audioTracks: [MediaTrack] = []
public private(set) var subtitleTracks: [MediaTrack] = []
public var currentTime: Duration {
let seconds = player.currentTime().seconds
guard seconds.isFinite, seconds >= 0 else { return .zero }
return .seconds(seconds)
}
public let supportsPictureInPicture = true
public let supportsAirPlay = true
public private(set) lazy var events: AsyncStream<PlaybackEvent> = {
AsyncStream { continuation in
self.continuation = continuation
}
}()
private var continuation: AsyncStream<PlaybackEvent>.Continuation?
private var timeObserver: Any?
private var metadataTask: Task<Void, Never>?
/// Wohin gesprungen werden soll, sobald der Player bereit ist.
///
/// Ein `seek` vor `readyToPlay` verwirft AVPlayer kommentarlos — das
/// Fortsetzen landete dann wieder am Anfang.
private var pendingStartTime: Duration = .zero
/// Ob gerade ein Sprung an die gemerkte Stelle läuft.
private var isSeekingToStart = false
private var observations: [NSKeyValueObservation] = []
private var endObserver: (any NSObjectProtocol)?
private var stallObserver: (any NSObjectProtocol)?
/// Die Sprossen der Playlist, aufsteigend, in Kilobit je Sekunde.
///
/// Leer, solange nichts geladen ist — und leer bleibt sie auch bei einer
/// Datei, die gar keine Playlist ist. Genau daran unterscheidet
/// `statistics` später „keine Leiter" von „eine Stufe".
private var variantBitrates: [Int] = []
/// Ob überhaupt jemand spielen wollte.
///
/// **Der Unterschied zwischen „pausiert" und „kommt nicht voran".**
/// `timeControlStatus` meldet beides als `.paused`: das Anhalten durch
/// den Nutzer und den Fall, dass `play()` gerufen wurde, der Player aber
/// nicht losläuft — weil die Quelle noch nicht bereit ist oder weil nichts
/// ankommt. Ohne diese Unterscheidung zeigte der Player ein
/// Abspielsymbol, als hätte man selbst angehalten, während in Wahrheit
/// auf Daten gewartet wurde. Wer dann tippte, drückte auf Play bei etwas,
/// das längst laufen sollte.
private var wantsToPlay = false
/// Wie oft es gestockt hat, seit diese Quelle läuft.
///
/// Aus `playbackStalledNotification` und **nicht** aus dem
/// Zugriffsprotokoll: die Meldung kommt bei jeder Quelle, das Protokoll
/// nur bei Streamsn über das Netz. Beides zu addieren zählte doppelt.
private var subtitleStyle: SubtitleStyle?
private var stallCount = 0
/// Aufsummierte Wartezeit. Siehe `noteStall(from:to:)`.
private var accumulatedStall: Duration = .zero
private var bufferingSince: ContinuousClock.Instant?
public init() {
AudioSession.activate()
// **AVPlayer wählt Untertitel sonst selbst — und schaltet sie ab.**
//
// Diese Eigenschaft steht von Haus aus auf `true`: der Player richtet
// sich dann nach den Untertiteleinstellungen des Systems. Die stehen
// in aller Regel auf „aus", und damit nimmt er jede Wahl, die wir
// treffen, wieder zurück — lautlos, und erst nachdem wir sie gesetzt
// haben.
//
// Zu sehen war das als „ich schalte Untertitel ein und sehe nichts":
// der Server lieferte sie, die Spur war im Stream, ausgewählt war sie
// auch, und angezeigt wurde trotzdem nichts.
//
// Beim Ton ändert das Abschalten nichts: welche Spur klingt, ist
// längst beim Aushandeln entschieden, und bei einem umgerechneten
// Stream kommt ohnehin nur eine an.
player.appliesMediaSelectionCriteriaAutomatically = false
}
public func load(_ source: PlaybackSource) async throws(PlaybackFailure) {
teardownObservers()
state = .loading
// Die Buchführung gehört zur Quelle, nicht zur Engine: ein neuer Titel
// fängt bei null an, sonst schleppte die Zahl den vorigen Film mit.
variantBitrates = []
// Sie gehören zum Stream, nicht zur Engine — sonst hielte die Liste
// zwischen zwei Aufbauten die Spuren des alten.
audioTracks = []
subtitleTracks = []
isSeekingToStart = false
wantsToPlay = false
stallCount = 0
accumulatedStall = .zero
bufferingSince = nil
pendingStartTime = source.startTime
let asset = AVURLAsset(url: source.url, options: [
// Keine genaue Dauer verlangen. Sonst durchsucht AVFoundation bei
// manchen Formaten die ganze Datei, bevor es überhaupt anfängt —
// und „ganze Datei" heißt bei einem Remux vierzig Gigabyte.
AVURLAssetPreferPreciseDurationAndTimingKey: false,
])
let item = AVPlayerItem(asset: asset)
// **Vorrat statt Reaktion.**
//
// Ohne diese Angabe legt AVPlayer so viel an, wie es für einen
// zügigen Start braucht — und keinen Meter mehr. Das ist richtig, wenn
// die Leitung trägt, und falsch, wenn sie aussetzt: ein Loch von
// fünf Sekunden trifft dann auf einen Puffer von fünf Sekunden, und
// das Bild steht.
//
// Der Preis ist ehrlich: der Start dauert länger, und wer springt,
// wirft den angelegten Vorrat weg. Beides ist weniger schlimm als ein
// Rückler mitten im Satz.
if let vorrat = source.forwardBuffer {
item.preferredForwardBufferDuration = TimeInterval(
Double(vorrat.components.seconds)
)
}
// **Auch am frischen Gegenstand.** Er beginnt ungestaltet, und ohne
// diese Zeile fiele jede weitere Folge auf die Vorgabe zurück.
if let subtitleStyle {
item.textStyleRules = subtitleStyle.textStyleRules
}
player.replaceCurrentItem(with: item)
observe(item: item)
observePlayer()
// Hier ist Schluss. Dauer und Spuren werden NICHT abgewartet.
//
// Genau daran hing der Player vorher fest: `asset.load(.duration)`
// holt so viel von der Datei, bis der Index gefunden ist. Bei MP4 und
// MKV sitzt der oft am Ende — über eine langsame Leitung dauert das
// Minuten, und in dieser Zeit stand nur ein Ladekreis da, obwohl
// AVPlayer längst hätte anfangen können.
//
// Beides kommt jetzt nach, sobald es da ist, und meldet sich über den
// Ereignisstrom. Bis dahin läuft das Bild schon.
loadMetadataInBackground(from: asset)
}
/// Holt Dauer und Spuren nach, ohne die Wiedergabe aufzuhalten.
private func loadMetadataInBackground(from asset: AVURLAsset) {
metadataTask?.cancel()
metadataTask = Task { [weak self] in
guard let self else { return }
if let assetDuration = try? await asset.load(.duration),
assetDuration.isNumeric,
!Task.isCancelled {
let value = Duration.seconds(assetDuration.seconds)
duration = value
continuation?.yield(.durationResolved(value))
}
guard !Task.isCancelled else { return }
// **Die Leiter.** Hier und nicht vorher: die Frage „wie viele
// Stufen hat diese Playlist" beantwortet der Parser des Players,
// aber sie zu beantworten kostet einen Abruf — und der darf den
// Start so wenig aufhalten wie Dauer und Spuren.
//
// Bei einer Datei kommt eine leere Liste zurück. Das ist keine
// Leiter mit null Sprossen, sondern gar keine Leiter, und genau
// so wird es unten auch gemeldet.
if let variants = try? await asset.load(.variants), !Task.isCancelled {
variantBitrates = variants
.compactMap(\.peakBitRate)
.map { Int(($0 / 1_000).rounded()) }
.filter { $0 > 0 }
.sorted()
}
guard !Task.isCancelled else { return }
// Scheitert die Spurenliste, bleibt die Wiedergabe trotzdem
// laufen — es fehlt dann nur die Auswahl im Menü. Das ist ein
// besserer Zustand als ein Abbruch.
try? await loadTracks(from: asset)
}
}
public func play() {
wantsToPlay = true
player.play()
}
public func pause() {
wantsToPlay = false
player.pause()
}
public func seek(to time: Duration) async {
let target = CMTime(seconds: time.seconds, preferredTimescale: 600)
// Genau springen statt zum nächsten Schlüsselbild: beim Zurückspringen
// um zehn Sekunden landet man sonst je nach Datei bis zu mehreren
// Sekunden daneben.
await player.seek(to: target, toleranceBefore: .zero, toleranceAfter: .zero)
continuation?.yield(.timeChanged(time))
}
public func setPeakBitrate(_ kilobits: Int?) {
guard let item = player.currentItem else { return }
// Null heißt bei AVFoundation „keine Grenze". Kein Sonderfall, nur
// eine andere Schreibweise für dasselbe.
item.preferredPeakBitRate = kilobits.map { Double($0) * 1_000 } ?? 0
}
public func setRate(_ rate: Double) {
player.rate = Float(rate)
}
public func selectAudioTrack(_ track: MediaTrack?) {
select(track, in: .audible)
}
public func selectSubtitleTrack(_ track: MediaTrack?) {
select(track, in: .legible)
}
/// Gestaltet die Untertitel — sofort, und auch beim nächsten Film.
///
/// **Beides ist nötig.** Ein frisches `AVPlayerItem` beginnt ungestaltet;
/// wer nur das laufende anfasst, sieht die nächste Folge wieder in der
/// Vorgabe. Deshalb wird der Wunsch gemerkt und in `load(_:)` erneut
/// angelegt.
public func applySubtitleStyle(_ style: SubtitleStyle) {
subtitleStyle = style
guard let item = player.currentItem else { return }
item.textStyleRules = style.textStyleRules
// **Und die Spur neu wählen, sonst bleibt es bei der alten Form.**
//
// `textStyleRules` wird nur gelesen, wenn die Untertitelspur
// ausgewählt wird — eine laufende Einblendung zeichnet AVFoundation
// davon nicht neu. Das war genau der Eindruck: man stellt um, es
// passiert nichts, und erst wer die Untertitel aus- und wieder
// einschaltet, sieht die Änderung. Das Aus- und Einschalten ist die
// Neuwahl — also machen wir sie selbst.
//
// Abgewählt und wieder angewählt wird dieselbe Option, nicht die
// erste: bei mehreren Spuren wäre das ein stiller Sprachwechsel.
guard let group = item.asset.mediaSelectionGroup(forMediaCharacteristic: .legible),
let gewählt = item.currentMediaSelection.selectedMediaOption(in: group)
else { return }
item.select(nil, in: group)
item.select(gewählt, in: group)
}
/// Was gerade über die Leitung kommt.
///
/// Aus dem Zugriffsprotokoll des Players: es führt Buch über jede
/// gezogene Stufe. Der **letzte** Eintrag ist der aktuelle; die früheren
/// beschreiben, was vorher lief.
public var statistics: PlaybackStatistics? {
guard let item = player.currentItem else { return nil }
let events = item.accessLog()?.events ?? []
let event = events.last
func kilobits(_ value: Double?) -> Int? {
guard let value, value > 0 else { return nil }
return Int((value / 1_000).rounded())
}
let size = item.presentationSize
return PlaybackStatistics(
observedBitrate: kilobits(event?.observedBitrate),
indicatedBitrate: kilobits(event?.indicatedBitrate),
width: size.width > 0 ? Int(size.width) : nil,
height: size.height > 0 ? Int(size.height) : nil,
// **Über alle Einträge summiert, nicht nur der letzte.** Das
// Protokoll legt bei jedem Stufenwechsel einen neuen an, und
// dessen Zähler beginnt wieder bei null. Wer nur `last` liest,
// verliert nach jedem Wechsel die ganze Vorgeschichte — und sieht
// eine Wiedergabe, die schon zwanzigmal Bilder verworfen hat, als
// makellos an.
droppedFrames: events.isEmpty
? nil
: events.reduce(0) { $0 + max(0, $1.numberOfDroppedVideoFrames) },
variantCount: variantBitrates.isEmpty ? nil : variantBitrates.count,
variantBitrates: variantBitrates,
bufferedAhead: bufferedAhead(in: item),
isLikelyToKeepUp: item.isPlaybackLikelyToKeepUp,
stallCount: stallCount,
stalledTime: stalledTime
)
}
/// Wie weit der Puffer vor der Abspielstelle reicht.
///
/// Gibt `nil` zurück, wenn noch gar nichts geladen ist — das ist etwas
/// anderes als ein leerer Puffer und darf nicht als Alarm durchgehen.
/// Liegen dagegen Bereiche vor, in denen die Abspielstelle **nicht**
/// liegt, ist der Puffer an genau dieser Stelle leer, und das ist eine
/// Aussage: null.
private func bufferedAhead(in item: AVPlayerItem) -> Duration? {
let ranges = item.loadedTimeRanges.map(\.timeRangeValue)
guard !ranges.isEmpty else { return nil }
let now = item.currentTime()
guard now.isNumeric else { return nil }
for range in ranges where range.containsTime(now) {
let end = range.end
guard end.isNumeric else { continue }
return .seconds(max(0, (end - now).seconds))
}
return .zero
}
/// Die Wartezeit einschließlich der gerade laufenden.
private var stalledTime: Duration {
guard let bufferingSince else { return accumulatedStall }
return accumulatedStall + (ContinuousClock.now - bufferingSince)
}
/// Buchführung über das Stocken.
///
/// Gezählt wird **nur** der Weg vom Spielen ins Warten. Das anfängliche
/// Puffern einer frisch geladenen Quelle ist kein Rückler — es ist der
/// Start. Würde es mitgezählt, hätte jede Wiedergabe von vornherein eine
/// Vorstrafe, und eine Regel wie „zwei Rückler in einer Minute" löste
/// schon beim Anschauen des ersten Bildes aus.
private func noteStall(from previous: PlaybackState, to current: PlaybackState) {
if current == .buffering {
if previous == .playing { bufferingSince = .now }
} else if let since = bufferingSince {
accumulatedStall += ContinuousClock.now - since
bufferingSince = nil
}
}
public func teardown() {
teardownObservers()
player.replaceCurrentItem(with: nil)
continuation?.finish()
continuation = nil
state = .idle
AudioSession.deactivate()
}
// MARK: - Spurwahl
private func select(_ track: MediaTrack?, in characteristic: AVMediaCharacteristic) {
guard let item = player.currentItem,
let group = item.asset.mediaSelectionGroup(forMediaCharacteristic: characteristic)
else { return }
guard let track, let index = track.engineIdentifier, group.options.indices.contains(index) else {
// `nil` schaltet ab — bei Untertiteln der Normalfall, bei Ton
// sollte es nicht vorkommen.
item.select(nil, in: group)
return
}
item.select(group.options[index], in: group)
}
private func loadTracks(from asset: AVURLAsset) async throws {
func tracks(
for characteristic: AVMediaCharacteristic,
kind: MediaTrack.Kind
) async throws -> [MediaTrack] {
guard let group = try await asset.loadMediaSelectionGroup(for: characteristic) else { return [] }
return group.options.enumerated().map { index, option in
MediaTrack(
id: "av-\(kind)-\(index)",
kind: kind,
displayName: option.displayName,
languageCode: option.extendedLanguageTag,
engineIdentifier: index,
isForced: option.hasMediaCharacteristic(.containsOnlyForcedSubtitles)
)
}
}
audioTracks = try await tracks(for: .audible, kind: .audio)
subtitleTracks = try await tracks(for: .legible, kind: .subtitle)
continuation?.yield(.tracksResolved(audio: audioTracks, subtitle: subtitleTracks))
}
// MARK: - Beobachtung
private func observePlayer() {
observations.append(
player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
// Kein `assumeIsolated`: KVO meldet auf dem Thread, der die
// Änderung ausgelöst hat, und AVPlayer ändert seinen Zustand
// auch aus internen Threads. Dieselbe Annahme hat die App
// an anderer Stelle schon einmal hart abbrechen lassen.
Task { @MainActor in
guard let self else { return }
switch player.timeControlStatus {
case .playing: self.state = .playing
case .paused:
guard self.state != .ended else { break }
// Stillstand mit Absicht ist Pause. Stillstand ohne
// Absicht ist Warten — und gehört als solches
// gemeldet, nicht als Pause.
self.state = self.wantsToPlay ? .buffering : .paused
case .waitingToPlayAtSpecifiedRate: self.state = .buffering
@unknown default: break
}
}
}
)
// Viermal je Sekunde: fein genug, dass der Schieberegler flüssig
// wirkt, grob genug, dass er nicht jede Bildwiederholung antreibt.
timeObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.25, preferredTimescale: 600),
queue: .main
) { [weak self] time in
// Hier ist `assumeIsolated` belegt statt geraten: die Warteschlange
// steht eine Zeile darüber. Viermal je Sekunde eine Aufgabe
// einzureihen wäre unnötiger Aufwand.
MainActor.assumeIsolated {
guard let self, time.isNumeric else { return }
self.continuation?.yield(.timeChanged(.seconds(time.seconds)))
}
}
}
private func observe(item: AVPlayerItem) {
observations.append(
item.observe(\.status, options: [.new]) { [weak self] item, _ in
// Siehe oben: auch dieser Rückruf kommt nicht verlässlich vom
// Hauptthread.
Task { @MainActor in
guard let self else { return }
switch item.status {
case .failed:
self.state = .failed(PlaybackFailure(
kind: .sourceUnavailable,
underlyingDescription: item.error.map { String(describing: $0) }
))
case .readyToPlay:
self.applyPendingStartTime()
case .unknown:
break
@unknown default:
break
}
}
}
)
// **Ein zweiter Anlass für den Sprung.**
//
// Bei einer Abspielliste wächst der springbare Bereich, während der
// Server produziert. Ein Sprung, der bei „bereit" noch ins Leere
// ging, kann Sekunden später gelingen — und ohne diesen Beobachter
// gäbe es niemanden, der es noch einmal versucht. Die Stelle bleibt
// gemerkt, bis ein Sprung bestätigt ist; danach tut der Beobachter
// nichts mehr.
observations.append(
item.observe(\.seekableTimeRanges, options: [.new]) { [weak self] _, _ in
Task { @MainActor in self?.applyPendingStartTime() }
}
)
// Der ausdrückliche Stocker-Melder des Systems.
//
// Er kommt **nur**, wenn die Wiedergabe schon lief und dann stehen
// blieb — beim anfänglichen Puffern schweigt er. Damit ist er der
// ehrlichste Zähler, den es gibt, und der Massstab, an dem sich am
// Ende ablesen lässt, ob es besser wurde.
stallObserver = NotificationCenter.default.addObserver(
forName: AVPlayerItem.playbackStalledNotification,
object: item,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.stallCount += 1 }
}
endObserver = NotificationCenter.default.addObserver(
forName: AVPlayerItem.didPlayToEndTimeNotification,
object: item,
queue: .main
) { [weak self] _ in
// Ebenfalls ausdrücklich auf der Hauptwarteschlange.
MainActor.assumeIsolated {
guard let self else { return }
self.state = .ended
self.continuation?.yield(.reachedEnd)
}
}
}
/// Springt an die gemerkte Stelle, sobald der Player bereit ist.
///
/// **Und merkt sich, ob es geklappt hat.** Vorher wurde die Stelle
/// verbraucht, *bevor* gesprungen wurde, und der Rückgabewert von
/// `seek` verworfen. Misslang der Sprung — und bei einem gerade erst
/// entstehenden Transcode ist das der Normalfall, nicht die Ausnahme —,
/// merkte es niemand und niemand wiederholte ihn. Das Fortsetzen hängt an
/// genau diesem einen Sprung, weil der Server bewusst bei null beginnt.
///
/// Jetzt bleibt die Stelle stehen, bis ein Sprung bestätigt ist, und
/// jede Änderung am springbaren Bereich ist ein neuer Anlass, es zu
/// versuchen.
private func applyPendingStartTime() {
guard pendingStartTime > .zero, !isSeekingToStart else { return }
let target = pendingStartTime
isSeekingToStart = true
Task { [weak self] in
let geschafft = await self?.seekReportingSuccess(to: target) ?? false
guard let self else { return }
isSeekingToStart = false
if geschafft { pendingStartTime = .zero }
}
}
/// Springt und sagt, ob es getragen hat.
///
/// Die Toleranz ist bewusst grosszügig: framegenau in einen Stream zu
/// springen, den der Server gerade erst erzeugt, ist der teuerste und
/// fragilste Fall. Eine halbe Sekunde daneben merkt niemand, ein
/// misslungener Sprung dagegen kostet den ganzen Fortschritt.
private func seekReportingSuccess(to time: Duration) async -> Bool {
let target = CMTime(seconds: time.seconds, preferredTimescale: 600)
let geschafft = await player.seek(
to: target,
toleranceBefore: CMTime(seconds: 0.5, preferredTimescale: 600),
toleranceAfter: CMTime(seconds: 0.5, preferredTimescale: 600)
)
if geschafft { continuation?.yield(.timeChanged(time)) }
return geschafft
}
private func teardownObservers() {
metadataTask?.cancel()
metadataTask = nil
observations.forEach { $0.invalidate() }
observations.removeAll()
if let timeObserver {
player.removeTimeObserver(timeObserver)
self.timeObserver = nil
}
if let endObserver {
NotificationCenter.default.removeObserver(endObserver)
self.endObserver = nil
}
if let stallObserver {
NotificationCenter.default.removeObserver(stallObserver)
self.stallObserver = nil
}
}
}
/// Die Tonsitzung des Systems.
///
/// Auf iOS **und tvOS**. Gekapselt statt an drei Stellen mit `#if` versehen —
/// macOS steht in diesem Paket ohnehin nur, damit die Logik-Tests ohne
/// Simulator laufen.
///
/// **tvOS stand hier lange nicht**, und das war ein stiller Fehler: die
/// Kategorie wäre dort verfügbar (`AVAudioSessionCategoryPlayback` ist
/// `tvos(9.0)`), wurde aber nie gesetzt. Man hätte es nicht am Übersetzer
/// gemerkt, sondern erst daran, dass sich die Wiedergabe gegen anderen Ton
/// nicht durchsetzt.
enum AudioSession {
static func activate() {
#if os(iOS) || os(tvOS)
// Ohne diese Kategorie schweigt die Wiedergabe, sobald der
// Stummschalter am Gerät umgelegt ist — und Bild-in-Bild gäbe es gar
// nicht.
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try? AVAudioSession.sharedInstance().setActive(true)
#endif
}
static func deactivate() {
#if os(iOS) || os(tvOS)
// `notifyOthersOnDeactivation` lässt die Musik-App wieder anlaufen,
// die vor dem Film gespielt hat.
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
}
@@ -0,0 +1,39 @@
import Foundation
/// Eine wählbare Ton- oder Untertitelspur.
///
/// Bewusst getrennt von den Spuren, die der Server meldet: der Player
/// nummeriert selbst, und seine Nummern stimmen mit denen des Servers nicht
/// überein. `engineIdentifier` hält fest, wie die Engine die Spur nennt,
/// `serverStreamID`, wie der Server sie nennt.
public struct MediaTrack: Sendable, Equatable, Identifiable {
public enum Kind: Sendable, Equatable { case audio, subtitle }
public let id: String
public let kind: Kind
public let displayName: String
public let languageCode: String?
public let serverStreamID: Int?
/// Die Nummer, unter der der laufende Engine die Spur führt.
public let engineIdentifier: Int?
public let isForced: Bool
public init(
id: String,
kind: Kind,
displayName: String,
languageCode: String? = nil,
serverStreamID: Int? = nil,
engineIdentifier: Int? = nil,
isForced: Bool = false
) {
self.id = id
self.kind = kind
self.displayName = displayName
self.languageCode = languageCode
self.serverStreamID = serverStreamID
self.engineIdentifier = engineIdentifier
self.isForced = isForced
}
}
@@ -0,0 +1,46 @@
import Foundation
/// Der Zustand der Wiedergabe, unabhängig vom Engine.
public enum PlaybackState: Sendable, Equatable {
case idle
/// Quelle wird geöffnet, es ist noch nichts zu sehen.
case loading
case playing
case paused
/// Läuft eigentlich, wartet aber auf Daten.
case buffering
case ended
case failed(PlaybackFailure)
public var isActive: Bool {
switch self {
case .playing, .buffering: true
case .idle, .loading, .paused, .ended, .failed: false
}
}
}
/// Ein Fehler bei der Wiedergabe, in der Sprache der App statt in der des
/// jeweiligen Engine.
public struct PlaybackFailure: Sendable, Equatable, Error {
public enum Kind: Sendable, Equatable {
/// Die Quelle war nicht erreichbar.
case sourceUnavailable
/// Der Engine kann das Format nicht — sollte nach einer korrekten
/// Entscheidung nicht vorkommen und ist deshalb ein Hinweis darauf,
/// dass die Fähigkeitenliste nachgezogen werden muss.
case formatUnsupported
case networkInterrupted
case unknown
}
public let kind: Kind
/// Der ursprüngliche Fehlertext. Wandert nicht in die Oberfläche, aber in
/// die Protokolle — sonst ist „geht nicht" alles, was man hat.
public let underlyingDescription: String?
public init(kind: Kind, underlyingDescription: String? = nil) {
self.kind = kind
self.underlyingDescription = underlyingDescription
}
}
@@ -0,0 +1,45 @@
import AVFoundation
import CoreMedia
import Foundation
public extension SubtitleStyle {
/// Die Regeln, wie AVFoundation sie erwartet.
///
/// **Vier Fallen stecken hier drin, und jede ist lautlos:**
///
/// 1. **Die Farbe ist ein Feld aus vier Zahlen von 0 bis 1, Alpha
/// zuerst** — A, R, G, B. Kein `CGColor`, kein `UIColor`; ein solcher
/// wird ohne Fehlermeldung ignoriert.
/// 2. **Die Grösse ist ein Prozentwert**, hundert heisst unverändert.
/// `BaseFontSizePercentageRelativeToVideoHeight` **nicht** zusätzlich
/// setzen — die beiden beziehen sich auf Verschiedenes und streiten
/// sich.
/// 3. **Den Hintergrund immer setzen, auch für „durchsichtig".** Ihn
/// wegzulassen ist nicht dasselbe wie Alpha null: dann gilt, was der
/// Untertitel selbst mitbringt, und Jellyfins WebVTT bringt oft einen
/// schwarzen Kasten mit.
/// 4. **`AVTextStyleRule(textMarkupAttributes:)` kann fehlschlagen.** Ein
/// falscher Schlüssel ergibt `nil` — und damit gar keine Regel, ohne
/// dass irgendwo etwas auffiele. Genau dafür gibt es den Test.
var textStyleRules: [AVTextStyleRule] {
var attribute: [String: Any] = [
kCMTextMarkupAttribute_ForegroundColorARGB as String: foreground.argb,
kCMTextMarkupAttribute_BackgroundColorARGB as String: background.argb,
kCMTextMarkupAttribute_RelativeFontSize as String: Double(size.rawValue),
]
if wantsEdgeShadow {
attribute[kCMTextMarkupAttribute_CharacterEdgeStyle as String] =
kCMTextMarkupCharacterEdgeStyle_DropShadow as String
}
return [AVTextStyleRule(textMarkupAttributes: attribute)].compactMap(\.self)
}
}
extension SubtitleStyle.RGBA {
/// Alpha zuerst — die Reihenfolge, die CoreMedia verlangt.
var argb: [NSNumber] {
[alpha, red, green, blue].map(NSNumber.init(value:))
}
}
@@ -0,0 +1,97 @@
import Foundation
/// Wie Untertitel aussehen sollen.
///
/// **Warum hier und nicht im App-Ziel.** Weil es prüfbar sein muss: die
/// Übersetzung nach AVFoundation ist eine Tabelle aus Schlüsseln, bei der ein
/// Tippfehler lautlos dazu führt, dass **gar nichts** gesetzt wird. Ein Test
/// findet das, ein Blick auf den Bildschirm nicht — dort sieht „keine Regel"
/// genauso aus wie „Regel ohne Wirkung".
public struct SubtitleStyle: Sendable, Equatable {
/// Eine Farbe, wie CoreMedia sie versteht: vier Anteile von 0 bis 1.
public struct RGBA: Sendable, Equatable {
public let red: Double
public let green: Double
public let blue: Double
public let alpha: Double
public init(red: Double, green: Double, blue: Double, alpha: Double = 1) {
self.red = red
self.green = green
self.blue = blue
self.alpha = alpha
}
static let white = RGBA(red: 1, green: 1, blue: 1)
static let black = RGBA(red: 0, green: 0, blue: 0)
static let yellow = RGBA(red: 1, green: 0.85, blue: 0.2)
static let clear = RGBA(red: 0, green: 0, blue: 0, alpha: 0)
/// Nicht ganz deckend: ein voll schwarzer Kasten wirkt wie ein Loch
/// im Bild.
static let box = RGBA(red: 0, green: 0, blue: 0, alpha: 0.75)
}
/// Die Grösse, **relativ** zur Vorgabe des Players.
///
/// Ein Prozentwert, keine Punktgrösse: hundert heisst unverändert. So
/// bleibt die Einstellung richtig, egal wie gross das Bild ist.
public enum Size: Int, CaseIterable, Identifiable, Sendable, Codable {
case small = 75
case normal = 100
case large = 130
case huge = 175
public var id: Int { rawValue }
}
/// Schrift und Kasten als **Paar**, nicht als zwei Regler.
///
/// Unabhängige Achsen liessen unlesbare Mischungen zu — Schwarz auf
/// durchsichtig über einer Nachtszene ist eine gültige Einstellung und
/// eine wertlose. Diese fünf decken ab, was man tatsächlich will.
public enum Palette: String, CaseIterable, Identifiable, Sendable, Codable {
case whiteOnClear
case whiteOnBlack
case blackOnWhite
case yellowOnClear
case yellowOnBlack
public var id: String { rawValue }
}
public var size: Size
public var palette: Palette
public init(size: Size = .normal, palette: Palette = .whiteOnClear) {
self.size = size
self.palette = palette
}
public var foreground: RGBA {
switch palette {
case .whiteOnClear, .whiteOnBlack: .white
case .blackOnWhite: .black
case .yellowOnClear, .yellowOnBlack: .yellow
}
}
public var background: RGBA {
switch palette {
case .whiteOnClear, .yellowOnClear: .clear
case .whiteOnBlack, .yellowOnBlack: .box
case .blackOnWhite: RGBA(red: 1, green: 1, blue: 1, alpha: 0.85)
}
}
/// Ob ein Schlagschatten nötig ist.
///
/// Ohne Kasten steht die Schrift unmittelbar im Bild, und Weiss auf einer
/// hellen Szene ist dort unlesbar. Mit Kasten wäre der Schatten nur
/// Unruhe.
public var wantsEdgeShadow: Bool {
switch palette {
case .whiteOnClear, .yellowOnClear: true
case .whiteOnBlack, .blackOnWhite, .yellowOnBlack: false
}
}
}
@@ -0,0 +1,251 @@
import Foundation
/// Woher das Bild kommt.
public struct PlaybackSource: Sendable, Equatable {
public enum Origin: Sendable, Equatable {
/// Vom Server — per DirectPlay oder als HLS-Stream des Transcoders.
case remote
/// Aus dem Download-Ordner.
case local
}
public let url: URL
public let origin: Origin
/// Ab wo gestartet werden soll. Kommt aus dem zuletzt gemeldeten
/// Fortschritt; `.zero` heißt von vorn.
public let startTime: Duration
/// Wie viel Vorrat der Player anlegen soll.
///
/// `nil` überlässt es dem System. Das ist für eine Datei auf der Platte
/// richtig und für einen Sender richtig — bei einem Stream über eine
/// wackelige Leitung ist es zu wenig: der Systemwert ist auf schnellen
/// Start ausgelegt, nicht darauf, einen Aussetzer zu überstehen.
///
/// **Wer das entscheidet, muss mehr wissen als die Engine.** Ob es ein
/// Sender ist, ob die Datei lokal liegt — das weiß der Player, nicht die
/// Engine. Deshalb reist die Zahl mit der Quelle, statt dass die Engine
/// rät.
public let forwardBuffer: Duration?
public init(
url: URL,
origin: Origin,
startTime: Duration = .zero,
forwardBuffer: Duration? = nil
) {
self.url = url
self.origin = origin
self.startTime = startTime
self.forwardBuffer = forwardBuffer
}
}
/// Was eine Engine von sich aus meldet.
///
/// Ein Stream statt beobachtbarer Eigenschaften: der Player meldet über
/// KVO, und das in eine Ereignisfolge zu übersetzen hält den Unterschied an
/// einer Stelle — statt in jeder View, die den Zustand anzeigt.
public enum PlaybackEvent: Sendable, Equatable {
case stateChanged(PlaybackState)
case timeChanged(Duration)
case durationResolved(Duration)
case tracksResolved(audio: [MediaTrack], subtitle: [MediaTrack])
case reachedEnd
}
/// Wo das Bild aufhört.
///
/// **Hier und nicht in der App**, weil es beide Engines angeht. Am Bild
/// einzupassen ist nur zur Hälfte Sache der Ansicht: sie bestimmt die Fläche,
/// die Engine bestimmt, wie das Bild darin liegt.
public enum VideoFit: Sendable {
/// Ganz sichtbar, mit schwarzen Balken.
case fit
/// Bildfüllend, oben und unten beschnitten.
case fill
}
/// Was die Oberfläche zum Steuern braucht — und nichts darüber hinaus.
///
/// **Ein Protokoll für einen Spieler.** Es gab einmal zwei, und der Schnitt
/// ist geblieben: er hält die Oberfläche von AVFoundation fern, und was hier
/// nicht steht, gibt es für die Views nicht.
@MainActor
public protocol VideoPlayerEngine: AnyObject {
var events: AsyncStream<PlaybackEvent> { get }
var state: PlaybackState { get }
var currentTime: Duration { get }
var duration: Duration? { get }
/// Ob dieser Engine Bild-in-Bild kann. Steuert, ob der Knopf überhaupt
/// erscheint — ein Knopf, der bei manchen Dateien nichts tut, ist
/// schlimmer als keiner.
var supportsPictureInPicture: Bool { get }
/// Ob dieser Engine sein Bild an ein AirPlay-Gerät geben kann.
///
/// Ein Knopf dafür darf nur stehen, wo er auch etwas trifft.
var supportsAirPlay: Bool { get }
func load(_ source: PlaybackSource) async throws(PlaybackFailure)
func play()
func pause()
func seek(to time: Duration) async
func setRate(_ rate: Double)
var audioTracks: [MediaTrack] { get }
var subtitleTracks: [MediaTrack] { get }
func selectAudioTrack(_ track: MediaTrack?)
/// `nil` schaltet die Untertitel ab.
func selectSubtitleTrack(_ track: MediaTrack?)
/// Wie Untertitel dargestellt werden sollen.
///
/// **Darf jederzeit gesetzt werden, auch mitten im Film.** Genau deshalb
/// steht es hier und nicht an `PlaybackSource`: die wird einmal beim
/// Öffnen gelesen, und eine Einstellung, die erst beim nächsten Film
/// wirkt, hält jeder für kaputt.
func applySubtitleStyle(_ style: SubtitleStyle)
/// Was gerade tatsächlich über die Leitung kommt.
///
/// Nicht, was angefordert wurde: bei einem HLS-Stream entscheidet der
/// Player selbst, welche Stufe er zieht, und die kann von der gewählten
/// Obergrenze abweichen. Ohne diese Angabe bleibt „ruckelt es, weil die
/// Leitung schlecht ist?" eine Vermutung.
var statistics: PlaybackStatistics? { get }
/// Deckelt, welche Sprosse der Player höchstens ziehen darf.
///
/// **Der billige Hebel.** Er wirkt ohne Neuaufbau: kein Anhalten am
/// Server, keine neue Aushandlung, kein Sprung zurück an die alte Stelle.
/// `nil` hebt die Grenze auf.
///
/// Zwei Einschränkungen gehören dazugesagt. Der Wert greift erst an der
/// nächsten Schnipselgrenze, also mit einigen Sekunden Verzug — ein Grund
/// mehr, nicht bei jedem Ausschlag daran zu drehen. Und **bei einer
/// Playlist mit einer einzigen Stufe tut er nichts**: der Player spielt
/// sie auch dann, wenn sie über der Grenze liegt. Der Wert ist ein
/// Wunsch, keine Vorschrift.
func setPeakBitrate(_ kilobits: Int?)
/// Gibt Puffer und Dekoder frei. Muss beim Verlassen des Players laufen.
func teardown()
}
/// Was über die Leitung kommt, während gespielt wird.
public struct PlaybackStatistics: Sendable, Equatable {
/// Was tatsächlich ankommt, in Kilobit je Sekunde.
///
/// **Nachlaufend und leicht misszuverstehen.** Spielt der Player
/// zufrieden eine kleine Sprosse, holt er wenig — und dieser Wert sieht
/// schlecht aus, ohne dass die Leitung schlecht wäre. Als alleiniges
/// Kriterium für eine Regelung taugt er deshalb nicht.
public let observedBitrate: Int?
/// Was der Stream für sich angibt — also auf welcher Sprosse man steht.
public let indicatedBitrate: Int?
public let width: Int?
public let height: Int?
/// Verworfene Bilder — der ehrlichste Hinweis auf Überlastung.
public let droppedFrames: Int?
/// Wie viele Stufen die Playlist überhaupt anbietet.
///
/// **Die wichtigste Zahl hier**, und die einzige, die die Frage
/// beantwortet, ob es etwas zum Umschalten gibt. Sie kommt vom Parser des
/// Players, nicht aus einer Vermutung über den Server: `1` heißt, dass
/// alles Nachregeln ins Leere greift, ganz gleich was bestellt wurde.
///
/// `nil` bei allem, was keine Playlist ist — eine Datei hat keine Leiter,
/// und das ist die richtige Aussage, nicht „null Stufen".
public let variantCount: Int?
/// Die Sprossen selbst, aufsteigend, in Kilobit je Sekunde.
///
/// Nötig, um eine Stufe tiefer zu **zielen**: ein Wert zwischen zwei
/// Sprossen wählt dieselbe wie die untere und macht jede Anzeige darüber
/// zur Lüge.
public let variantBitrates: [Int]
/// Wie weit der Puffer vor der Abspielstelle reicht.
///
/// **Der einzige echte Frühwarner.** Schrumpft er schneller, als die Zeit
/// vergeht, kommt weniger herein als hinausgeht — und das ist zehn bis
/// zwanzig Sekunden vor dem Rückler zu sehen. Alles andere meldet sich
/// erst, wenn das Bild schon steht.
public let bufferedAhead: Duration?
/// Apples eigene Einschätzung. Früh, aber grob.
public let isLikelyToKeepUp: Bool?
/// Wie oft es seit dem Start gestockt hat.
public let stallCount: Int
/// Wie lange insgesamt gewartet wurde.
public let stalledTime: Duration
public init(
observedBitrate: Int? = nil,
indicatedBitrate: Int? = nil,
width: Int? = nil,
height: Int? = nil,
droppedFrames: Int? = nil,
variantCount: Int? = nil,
variantBitrates: [Int] = [],
bufferedAhead: Duration? = nil,
isLikelyToKeepUp: Bool? = nil,
stallCount: Int = 0,
stalledTime: Duration = .zero
) {
self.observedBitrate = observedBitrate
self.indicatedBitrate = indicatedBitrate
self.width = width
self.height = height
self.droppedFrames = droppedFrames
self.variantCount = variantCount
self.variantBitrates = variantBitrates
self.bufferedAhead = bufferedAhead
self.isLikelyToKeepUp = isLikelyToKeepUp
self.stallCount = stallCount
self.stalledTime = stalledTime
}
public var resolution: String? {
guard let width, let height, width > 0, height > 0 else { return nil }
return "\(width)×\(height)"
}
/// Ob es überhaupt etwas zum Umschalten gibt.
///
/// Bei `false` ist `preferredPeakBitRate` wirkungslos — AVPlayer spielt
/// die eine Stufe auch dann, wenn sie über der Grenze liegt. Genau dafür
/// gibt es die Sicherheitsleine.
public var hasLadder: Bool { (variantCount ?? 0) > 1 }
/// Die nächste Sprosse unterhalb der angegebenen — oder die unterste.
///
/// Gibt `nil` zurück, wenn es nichts Tieferes gibt. Dann ist der billige
/// Weg erschöpft.
public func rung(below bitrate: Int) -> Int? {
variantBitrates.last { $0 < bitrate }
}
}
extension VideoPlayerEngine {
/// Engines, die nichts melden, melden nichts — statt jeden zu zwingen.
public var statistics: PlaybackStatistics? { nil }
/// Wer keine Leiter hat, kann auch nichts deckeln. Kein Fehler, nur
/// nichts zu tun — bei einer Datei gibt es keine Stufen.
public func setPeakBitrate(_ kilobits: Int?) {}
/// Wer Untertitel nicht gestalten kann, lässt sie, wie sie sind.
///
public func applySubtitleStyle(_ style: SubtitleStyle) {}
/// Wer nichts dazu sagt, kann es nicht.
public var supportsAirPlay: Bool { false }
/// Der Systemplayer regelt es über seine Bildebene, und die hält die App —
/// siehe `PlayerModel.applyVideoFit()`. Hier ist nichts zu tun.
public func setVideoFit(_ fit: VideoFit) {}
}
@@ -0,0 +1,249 @@
import Foundation
import Network
import OSLog
/// Ein winziger HTTP-Server, von dem AVPlayer seinen Film holt.
///
/// **Warum überhaupt ein Server.** Apple lässt uns bei HLS nicht zwischen
/// Player und Netz: auf eine Anfrage nach einem Schnipsel nimmt AVPlayer von
/// uns nur eine Weiterleitung an, keine Daten. Der einzige Weg, ihm etwas
/// Eigenes zu liefern, ist, die Gegenstelle **zu sein**. Dann ist es keine
/// Einmischung mehr, sondern eine gewöhnliche Anfrage an eine gewöhnliche
/// Adresse — die zufällig auf demselben Gerät liegt.
///
/// **Nur auf diesem Gerät.** Der Lauscher hängt ausdrücklich an `127.0.0.1`,
/// nicht an allen Schnittstellen. Ein Server, der den Film ins Netz stellt,
/// wäre das Letzte, was eine Mediathek-App tun sollte — und in einem offenen
/// WLAN eine Einladung.
///
/// Der Port kommt vom System. Ein fester wäre beim zweiten Start belegt, und
/// zwei Wiedergaben gleichzeitig — Kachel und Vollbild beim Umschalten — gäbe
/// es dann nie zweimal.
public actor LocalStreamServer {
/// Der Pfad, unter dem Schnipsel ausgeliefert werden.
private static let segmentPrefix = "/s/"
/// Der Pfad der Abspielliste selbst.
private static let playlistPath = "/playlist.m3u8"
/// Der Pfad einer Stufe, die eine Ebene tiefer liegt.
///
/// Die Endung muss stimmen: AVPlayer entscheidet am Namen, ob er eine
/// Abspielliste oder Bild erwartet.
private static let levelPrefix = "/level.m3u8"
/// Wie eine Adresse aus der Abspielliste beim Server heisst.
///
/// Der ursprüngliche Pfad wandert dabei in die Abfrage, damit Schrägstriche
/// und Punkte darin nichts anrichten.
/// Wie eine Stufe aus der Stufenliste beim Server heisst.
///
/// Vollständig, nicht relativ: eine Stufe liegt beim Server unter einem
/// ganz anderen Pfad als bei uns, und relativ aufgelöst zeigte sie ins
/// Leere.
/// - Parameter prefetch: ob dahinter Bild liegt. Bei einer Nebenspur —
/// Untertitel oder zusätzlicher Ton — steht es auf `false`, sonst lüde
/// der Vorrat Untertitel statt Bild, und der Film bliebe stehen,
/// während die Anzeige reichlich Vorrat meldet.
public nonisolated static func localPlaylistPath(
for absolute: String,
prefetch: Bool
) -> String {
var teile = URLComponents()
teile.path = levelPrefix
teile.queryItems = [
URLQueryItem(name: "u", value: absolute),
URLQueryItem(name: "v", value: prefetch ? "1" : "0"),
]
return teile.string ?? levelPrefix
}
/// Wie ein Schnipsel beim Server heisst.
///
/// Die **vollständige** Adresse wandert in die Abfrage. Eine relative
/// gälte je nach Liste gegen eine andere Grundadresse — die
/// Untertitelspur liegt woanders als das Bild —, und wer das erst beim
/// Abholen auflöst, löst es irgendwann gegen die falsche auf.
public nonisolated static func localPath(for absolute: String) -> String {
var teile = URLComponents()
teile.path = segmentPrefix
teile.queryItems = [URLQueryItem(name: "u", value: absolute)]
return teile.string ?? segmentPrefix
}
private let cache: StreamCache
private let logger = Logger(subsystem: "app.halo", category: "LocalStreamServer")
private var listener: NWListener?
private var port: UInt16?
private var playlist: String?
public init(cache: StreamCache) {
self.cache = cache
}
// MARK: - Starten
/// Startet den Server und gibt die Adresse zurück, die der Player bekommt.
///
/// Wirft, wenn der Lauscher nicht hochkommt — dann muss der Aufrufer die
/// ursprüngliche Adresse verwenden. **Kein Ersatz für Wiedergabe:** lieber
/// ohne Vorrat spielen als gar nicht.
public func start(upstream: URL, headers: [String: String] = [:]) async throws -> URL {
playlist = try await cache.start(upstream: upstream, headers: headers)
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any)
let lauscher = try NWListener(using: parameters)
lauscher.newConnectionHandler = { [weak self] verbindung in
Task { await self?.accept(verbindung) }
}
// Der Zustandsmelder kommt mehrfach und aus fremdem Zusammenhang.
// Eine Fortsetzung zweimal aufzulösen bringt das Programm zum
// Anhalten — deshalb ein Riegel, der das ausschliesst.
let vergeben: UInt16 = try await withCheckedThrowingContinuation { fortsetzen in
let riegel = OneShot()
lauscher.stateUpdateHandler = { zustand in
switch zustand {
case .ready:
guard riegel.claim() else { return }
fortsetzen.resume(returning: lauscher.port?.rawValue ?? 0)
case .failed(let fehler):
guard riegel.claim() else { return }
fortsetzen.resume(throwing: fehler)
default:
break
}
}
lauscher.start(queue: .global(qos: .userInitiated))
}
listener = lauscher
port = vergeben
guard vergeben > 0,
let adresse = URL(string: "http://127.0.0.1:\(vergeben)\(Self.playlistPath)")
else { throw URLError(.cannotConnectToHost) }
return adresse
}
public func stop() {
listener?.cancel()
listener = nil
Task { await cache.teardown() }
}
// MARK: - Bedienen
private func accept(_ verbindung: NWConnection) {
verbindung.start(queue: .global(qos: .userInitiated))
receive(on: verbindung, buffer: Data())
}
private nonisolated func receive(on verbindung: NWConnection, buffer: Data) {
verbindung.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1_024) {
[weak self] daten, _, abgeschlossen, fehler in
guard let self else { return }
var gesammelt = buffer
if let daten { gesammelt.append(daten) }
// Der Kopf endet mit einer Leerzeile. Bis dahin sammeln — eine
// Anfrage kann auf mehrere Pakete verteilt ankommen, und wer das
// nicht abwartet, liest eine halbe Zeile als ganze Anfrage.
guard let kopfEnde = gesammelt.range(of: Data("\r\n\r\n".utf8)) else {
if fehler == nil, !abgeschlossen {
self.receive(on: verbindung, buffer: gesammelt)
} else {
verbindung.cancel()
}
return
}
let kopf = String(decoding: gesammelt[..<kopfEnde.lowerBound], as: UTF8.self)
Task { await self.respond(to: kopf, on: verbindung) }
}
}
private func respond(to kopf: String, on verbindung: NWConnection) async {
guard let anfrage = kopf.components(separatedBy: "\r\n").first,
let pfadTeil = anfrage.components(separatedBy: " ").dropFirst().first
else { return finish(verbindung, status: 400, body: Data()) }
if pfadTeil.hasPrefix(Self.levelPrefix) {
guard let teile = URLComponents(string: pfadTeil),
let adresse = teile.queryItems?.first(where: { $0.name == "u" })?.value,
let inhalt = await cache.playlist(
at: adresse,
prefetch: teile.queryItems?.first { $0.name == "v" }?.value != "0"
)
else { return finish(verbindung, status: 404, body: Data()) }
return finish(
verbindung, status: 200, body: Data(inhalt.utf8),
type: "application/vnd.apple.mpegurl"
)
}
if pfadTeil.hasPrefix(Self.playlistPath) {
let inhalt = Data((playlist ?? "").utf8)
return finish(
verbindung, status: 200, body: inhalt,
type: "application/vnd.apple.mpegurl"
)
}
guard pfadTeil.hasPrefix(Self.segmentPrefix),
let teile = URLComponents(string: pfadTeil),
let adresse = teile.queryItems?.first(where: { $0.name == "u" })?.value
else { return finish(verbindung, status: 404, body: Data()) }
guard let schnipsel = await cache.data(for: adresse) else {
return finish(verbindung, status: 504, body: Data())
}
// **Die Sorte, die wirklich ankam.** Hier stand fest „video/mp4", für
// jeden Schnipsel. Ein WebVTT-Körper, so angekündigt, geht bei
// AVFoundation an den falschen Parser und ergibt lautlos keine
// Einblendung — die Spur steht im Blatt, ist ausgewählt, und bleibt
// leer. Genau daran lag es.
finish(verbindung, status: 200, body: schnipsel.data, type: schnipsel.contentType)
}
private nonisolated func finish(
_ verbindung: NWConnection,
status: Int,
body: Data,
type: String = "application/octet-stream"
) {
let kopf = """
HTTP/1.1 \(status) \(status == 200 ? "OK" : "Error")\r
Content-Type: \(type)\r
Content-Length: \(body.count)\r
Accept-Ranges: none\r
Connection: close\r
\r
"""
var antwort = Data(kopf.utf8)
antwort.append(body)
verbindung.send(content: antwort, completion: .contentProcessed { _ in
verbindung.cancel()
})
}
}
/// Ein Riegel, der genau einmal aufgeht.
///
/// Für Rückrufe, die mehrfach kommen, aber nur einmal beantwortet werden
/// dürfen. Eine `var` im Abschluss täte es nicht: sie wird aus fremdem
/// Zusammenhang berührt, und Swift lässt das zu Recht nicht durchgehen.
private final class OneShot: @unchecked Sendable {
private let lock = NSLock()
private var used = false
func claim() -> Bool {
lock.withLock {
guard !used else { return false }
used = true
return true
}
}
}
@@ -0,0 +1,155 @@
import Foundation
/// Eine HLS-Abspielliste — gelesen und umgeschrieben.
///
/// **Wozu.** Damit ein Vorratsspeicher zwischen Player und Server passen
/// kann, muss er zweierlei können: wissen, welche Schnipsel es gibt und in
/// welcher Reihenfolge, und dem Player eine Liste geben, die auf ihn selbst
/// zeigt statt auf den Server. Beides ist reine Textarbeit an einer Datei mit
/// einem sehr einfachen Aufbau.
///
/// **Warum als eigener Typ ohne Netz.** Weil hier ein Fehler besonders teuer
/// wäre: eine falsch umgeschriebene Liste ergibt entweder ein schwarzes Bild
/// oder — schlimmer — eine, die stellenweise am Speicher vorbeigeht und nur
/// manchmal hakt. So etwas ist im laufenden Film nicht zu finden. Mit einer
/// Zeichenkette hinein und einer heraus schon.
///
/// Gelesen wird nur, was gebraucht wird: die Zeilen, die auf Dateien zeigen.
/// Alles andere — Verschlüsselung, Zeitmarken, Untertitelspuren — bleibt
/// unangetastet stehen. Eine Abspielliste, die wir nicht verstehen, soll
/// hindurchgehen, nicht verstümmelt werden.
public struct MediaPlaylist: Sendable, Equatable {
/// Die Zeilen, die auf eine Datei zeigen — in der Reihenfolge der Liste.
public let segments: [String]
/// Ob es eine Liste von Stufen ist statt einer von Schnipseln.
///
/// Die beiden sehen sich ähnlich und meinen Verschiedenes: eine
/// Master-Liste zählt Qualitätsstufen auf, eine Medienliste die Schnipsel
/// einer Stufe. Wer das verwechselt, lädt Abspiellisten vor statt Bild.
public let isMaster: Bool
private let lines: [String]
public init(_ text: String) {
let zeilen = text.components(separatedBy: .newlines)
self.lines = zeilen
var gefunden: [String] = []
var master = false
var nächsteIstStufe = false
for zeile in zeilen {
let getrimmt = zeile.trimmingCharacters(in: .whitespaces)
if getrimmt.hasPrefix("#EXT-X-STREAM-INF") {
master = true
nächsteIstStufe = true
continue
}
guard !getrimmt.isEmpty else { continue }
// Kommentare und Anweisungen beginnen mit `#`. Alles andere ist
// eine Adresse.
guard !getrimmt.hasPrefix("#") else { continue }
if nächsteIstStufe {
nächsteIstStufe = false
// Bei einer Master-Liste zeigen die Zeilen auf weitere
// Listen, nicht auf Bild. Sie zählen trotzdem als Adresse.
}
gefunden.append(getrimmt)
}
self.segments = gefunden
self.isMaster = master
}
/// Was für eine Adresse an einer Stelle steht.
///
/// **Warum das jemanden interessiert.** Eine Abspielliste enthält
/// viererlei Adressen, und sie wollen Verschiedenes: eine Qualitätsstufe
/// führt zu weiteren Schnipseln und soll vorgeladen werden, eine
/// Untertitelspur führt zu einer eigenen Liste und soll es **nicht** —
/// sonst lädt der Vorrat Untertitel statt Bild.
public enum Reference: Sendable, Equatable {
/// Eine Qualitätsstufe — dahinter liegt das Bild.
case level
/// Eine Nebenspur: Untertitel oder zusätzlicher Ton.
case rendition
/// Das Kopfstück einer fragmentierten MP4.
case map
/// Ein Schnipsel.
case segment
}
/// Schreibt jede Adresse um, ohne sonst etwas anzufassen.
///
/// Die Reihenfolge bleibt, die Anweisungen bleiben, die Leerzeilen
/// bleiben. Nur wo eine Adresse stand, steht danach eine andere.
///
/// **Auch die Adressen in Anweisungen.** Zwei Anweisungen tragen eine:
/// `EXT-X-MAP` das Kopfstück und `EXT-X-MEDIA` die Nebenspuren. Beide
/// stehen zu lassen ergibt eine Liste, die halb auf uns und halb ins
/// Leere zeigt — und das endet mit „404", sobald jemand Untertitel
/// einschaltet.
public func rewritingReferences(_ transform: (String, Reference) -> String) -> String {
var ergebnis: [String] = []
ergebnis.reserveCapacity(lines.count)
var nächsteIstStufe = false
for zeile in lines {
let getrimmt = zeile.trimmingCharacters(in: .whitespaces)
if getrimmt.hasPrefix("#EXT-X-STREAM-INF") {
nächsteIstStufe = true
ergebnis.append(zeile)
} else if getrimmt.hasPrefix("#EXT-X-MEDIA:") {
ergebnis.append(Self.rewritingURI(in: zeile) { transform($0, .rendition) })
} else if getrimmt.hasPrefix("#EXT-X-MAP:") {
ergebnis.append(Self.rewritingURI(in: zeile) { transform($0, .map) })
} else if getrimmt.isEmpty || getrimmt.hasPrefix("#") {
ergebnis.append(zeile)
} else {
ergebnis.append(transform(getrimmt, nächsteIstStufe ? .level : .segment))
nächsteIstStufe = false
}
}
return ergebnis.joined(separator: "\n")
}
/// Tauscht die Adresse in `URI="…"` aus, sonst nichts.
private static func rewritingURI(
in zeile: String,
_ transform: (String) -> String
) -> String {
let marke = "URI=\""
guard let start = zeile.range(of: marke),
let ende = zeile[start.upperBound...].firstIndex(of: "\"")
else { return zeile }
let adresse = String(zeile[start.upperBound..<ende])
return zeile.replacingCharacters(in: start.upperBound..<ende, with: transform(adresse))
}
/// Ob die Liste vollständig ist — dann kommen keine Schnipsel mehr dazu.
///
/// Bei einem laufenden Sender fehlt diese Marke, und die Liste muss
/// wiederholt geholt werden. Bei einem Film steht sie am Ende, und dann
/// weiss der Vorratsspeicher, dass er die Liste kennt.
public var isComplete: Bool {
lines.contains { $0.trimmingCharacters(in: .whitespaces) == "#EXT-X-ENDLIST" }
}
/// Wie lang die Schnipsel dauern — für die Frage, wie viele einem
/// Vorsprung von N Sekunden entsprechen.
public var targetDuration: Duration? {
for zeile in lines {
let getrimmt = zeile.trimmingCharacters(in: .whitespaces)
guard getrimmt.hasPrefix("#EXT-X-TARGETDURATION:") else { continue }
let wert = getrimmt.dropFirst("#EXT-X-TARGETDURATION:".count)
guard let sekunden = Double(wert.trimmingCharacters(in: .whitespaces)),
sekunden > 0
else { return nil }
return .seconds(sekunden)
}
return nil
}
}
@@ -0,0 +1,309 @@
import Foundation
import OSLog
/// Holt die Schnipsel eines Streams, bevor der Player sie verlangt.
///
/// **Warum es das gibt.** AVPlayer entscheidet selbst, wie weit er puffert,
/// und bei HLS lässt Apple uns nicht dazwischen. Wir müssen aber auch nicht:
/// steht zwischen Player und Server ein eigener Speicher, kann der so weit
/// vorauslaufen, wie er will. Der Player holt dann von der Platte, und ob die
/// Leitung gerade steht, merkt er nicht.
///
/// Genau das ist der Unterschied zwischen „Puffer" und „Vorrat": ein Puffer
/// ist das, was der Player im Arbeitsspeicher hält, und den bestimmt Apple.
/// Der Vorrat hier ist das, was **wir** schon haben, und den bestimmen wir.
///
/// **Streng der Reihe nach.** Der Server erzeugt die Schnipsel gerade erst.
/// Wer Nummer 40 verlangt, während er bei 12 ist, sieht für Jellyfin aus wie
/// jemand, der gesprungen ist — und dann beginnt das Umrechnen von vorn, an
/// der falschen Stelle. Deshalb wird nie übersprungen, sondern nur zügiger
/// geholt, als abgespielt wird.
///
/// **Der Vorsprung ist begrenzt**, und zwar in Sekunden Film, nicht in
/// Schnipseln: die Zahl soll dasselbe bedeuten, egal wie der Server schneidet.
public actor StreamCache {
/// Ein Schnipsel — Daten **und** wofür sie sich ausgeben.
public struct Segment: Sendable {
public let data: Data
public let contentType: String
}
/// Wie weit vorausgelaufen wird.
public struct Policy: Sendable {
/// Wie viel Film im Voraus geholt werden soll.
public let lookahead: Duration
/// Wie viel Platz der Vorrat höchstens belegen darf.
public let diskLimit: Int
public init(lookahead: Duration = .seconds(300), diskLimit: Int = 768 * 1_024 * 1_024) {
self.lookahead = lookahead
self.diskLimit = diskLimit
}
}
private let policy: Policy
private let session: URLSession
private let directory: URL
private let logger = Logger(subsystem: "app.halo", category: "StreamCache")
/// Woher die Schnipsel wirklich kommen.
private var upstream: URL?
private var headers: [String: String] = [:]
/// Die Liste, in der Reihenfolge des Servers.
private var segments: [String] = []
private var targetDuration: Duration = .seconds(6)
/// Was schon auf der Platte liegt — Adresse auf Datei **und Sorte**.
///
/// **Die Sorte gehört dazu.** Ein Schnipsel ist nicht zwangsläufig Bild:
/// die Untertitelspur liefert WebVTT, der Ton kann AAC sein. Wer die
/// Sorte wegwirft und später etwas behauptet, liefert dem Player einen
/// Text als Video — der gibt ihn dem falschen Parser, findet keine Box,
/// und es entsteht lautlos gar nichts.
private var stored: [String: (ort: URL, type: String)] = [:]
private var storedBytes = 0
/// Welchen Schnipsel der Player zuletzt verlangt hat.
private var lastRequested = 0
/// Bis wohin geholt wurde.
private var fetched = 0
private var prefetchTask: Task<Void, Never>?
/// Ob gerade eine Anfrage des Players offen ist.
private var isServingPlayer = false
public init(policy: Policy = Policy()) {
self.policy = policy
let configuration = URLSessionConfiguration.ephemeral
// Der Vorrat ist unser Zwischenspeicher; ein zweiter darunter
// verdoppelte nur den Platzbedarf.
configuration.urlCache = nil
configuration.httpMaximumConnectionsPerHost = 4
configuration.timeoutIntervalForRequest = 30
self.session = URLSession(configuration: configuration)
self.directory = FileManager.default.temporaryDirectory
.appending(path: "halo-stream-\(UUID().uuidString)", directoryHint: .isDirectory)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
// MARK: - Einrichten
/// Nimmt die Abspielliste entgegen und beginnt vorauszulaufen.
///
/// **Alles wird auf eine vollständige Adresse umgeschrieben.** Relative
/// Angaben gelten je nach Liste gegen eine andere Grundadresse — die
/// Untertitelspur liegt woanders als das Bild. Wer das erst beim Abholen
/// auflöst, löst es irgendwann gegen die falsche auf. Hier steht die
/// Grundadresse noch fest.
public func start(upstream url: URL, headers: [String: String] = [:]) async throws -> String {
self.headers = headers
let liste = try await load(url)
return rewrite(liste, base: url, adopting: !liste.isMaster)
}
/// Eine Liste, die der Player eine Ebene tiefer angefordert hat.
///
/// `prefetch` sagt, ob dahinter Bild liegt. Bei einer Nebenspur — also
/// Untertiteln — steht es auf `false`: sonst lüde der Vorrat Untertitel
/// statt Bild, und der Film bliebe stehen, während die Kachel meldet, es
/// sei reichlich geladen.
public func playlist(at address: String, prefetch: Bool) async -> String? {
guard let url = URL(string: address), let liste = try? await load(url) else { return nil }
return rewrite(liste, base: url, adopting: prefetch && !liste.isMaster)
}
private func rewrite(_ liste: MediaPlaylist, base: URL, adopting: Bool) -> String {
if adopting { adopt(liste, base: base) }
return liste.rewritingReferences { adresse, art in
let absolut = URL(string: adresse, relativeTo: base)?.absoluteString ?? adresse
switch art {
case .level:
return LocalStreamServer.localPlaylistPath(for: absolut, prefetch: true)
case .rendition:
return LocalStreamServer.localPlaylistPath(for: absolut, prefetch: false)
case .map, .segment:
return LocalStreamServer.localPath(for: absolut)
}
}
}
/// Übernimmt eine Liste von Schnipseln als die, der wir vorauslaufen.
private func adopt(_ liste: MediaPlaylist, base: URL) {
// **Nur bei einem echten Wechsel von vorn.** Der Player holt dieselbe
// Liste immer wieder — nach einem Sprung, beim Stufenwechsel, von
// sich aus. Jedes Mal zurückzusetzen hiesse, den Vorrat jedes Mal zu
// vergessen und von Schnipsel null neu zu zählen.
let neueQuelle = upstream != base
upstream = base
segments = liste.segments.map {
URL(string: $0, relativeTo: base)?.absoluteString ?? $0
}
if let dauer = liste.targetDuration { targetDuration = dauer }
if neueQuelle {
lastRequested = 0
fetched = 0
}
beginPrefetching()
}
private func load(_ url: URL) async throws -> MediaPlaylist {
var request = URLRequest(url: url)
for (name, wert) in headers { request.setValue(wert, forHTTPHeaderField: name) }
let (daten, _) = try await session.data(for: request)
return MediaPlaylist(String(decoding: daten, as: UTF8.self))
}
// MARK: - Ausliefern
/// Die Daten zu einer Adresse — aus dem Vorrat, sonst frisch geholt.
public func data(for address: String) async -> Segment? {
if let index = segments.firstIndex(of: address) {
lastRequested = max(lastRequested, index)
trimBehind()
}
if let eintrag = stored[address], let daten = try? Data(contentsOf: eintrag.ort) {
return Segment(data: daten, contentType: eintrag.type)
}
// **Der Player hat Vorrang.** Fehlt ihm etwas, wartet er darauf — und
// der Vorrat darf ihm dabei nicht die Leitung wegnehmen. Rechnet der
// Server gerade um, bedient er ohnehin einen Schnipsel nach dem
// anderen: zwei Anfragen gleichzeitig machen die dringende langsamer,
// nicht schneller. Genau so kam es zu „Stream abgerissen", sobald das
// Umrechnen teuer wurde.
isServingPlayer = true
defer { isServingPlayer = false }
return await fetch(address)
}
// MARK: - Vorauslaufen
private func beginPrefetching() {
prefetchTask?.cancel()
prefetchTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let weiter = await self.prefetchNext()
if !weiter { try? await Task.sleep(for: .milliseconds(500)) }
}
}
}
/// Holt den nächsten Schnipsel, falls einer fehlt und Platz ist.
///
/// Gibt `false` zurück, wenn gerade nichts zu tun ist — dann wartet die
/// Schleife, statt zu drehen.
private func prefetchNext() async -> Bool {
// Solange der Player wartet, wird nicht vorgeladen.
guard !isServingPlayer else { return false }
// **Ist der Player vorausgesprungen, dort weitermachen.**
//
// Sonst holt der Vorrat brav weiter Schnipsel hinter der
// Abspielstelle — und der eine, auf den gerade gewartet wird, kommt
// als letzter. Das ist genau der Fall, in dem es nach einem Sprung
// lange lädt, obwohl vorn alles bereitliegt.
if lastRequested > fetched { fetched = lastRequested }
guard fetched < segments.count else { return false }
// Der Vorsprung in Schnipseln, aus dem Vorsprung in Sekunden.
let vorsprung = Int((policy.lookahead.seconds / max(targetDuration.seconds, 1)).rounded())
guard fetched - lastRequested < vorsprung else { return false }
guard storedBytes < policy.diskLimit else { return false }
let pfad = segments[fetched]
if stored[pfad] != nil {
fetched += 1
return true
}
guard await fetch(pfad) != nil else {
// Nicht weiterzählen: beim nächsten Durchgang noch einmal
// versuchen. Ein Schnipsel, den der Server noch nicht fertig hat,
// ist kein Fehler — nur ein „gleich".
return false
}
fetched += 1
return true
}
@discardableResult
private func fetch(_ address: String) async -> Segment? {
guard let ziel = URL(string: address) else { return nil }
do {
var anfrage = URLRequest(url: ziel)
for (name, wert) in headers { anfrage.setValue(wert, forHTTPHeaderField: name) }
let (daten, antwort) = try await session.data(for: anfrage)
let http = antwort as? HTTPURLResponse
guard http?.statusCode ?? 200 < 400 else { return nil }
// **Die Sorte kommt von der Gegenstelle, nicht von uns.** Sie
// weiss, was sie geschickt hat; wir wüssten es nur aus der
// Endung, und die kann fehlen oder lügen. Erst wenn sie nichts
// sagt, raten wir anhand der Adresse.
let sorte = http?.value(forHTTPHeaderField: "Content-Type")
?? Self.guessedType(for: address)
store(daten, type: sorte, at: address)
return Segment(data: daten, contentType: sorte)
} catch {
return nil
}
}
/// Die Sorte aus der Endung — der Notnagel.
static func guessedType(for address: String) -> String {
let pfad = URL(string: address)?.path.lowercased() ?? address.lowercased()
if pfad.hasSuffix(".vtt") { return "text/vtt" }
if pfad.hasSuffix(".ts") { return "video/MP2T" }
if pfad.hasSuffix(".aac") { return "audio/aac" }
if pfad.hasSuffix(".m3u8") { return "application/vnd.apple.mpegurl" }
return "video/mp4"
}
private func store(_ daten: Data, type: String, at path: String) {
let ort = directory.appending(path: UUID().uuidString)
guard (try? daten.write(to: ort, options: .atomic)) != nil else { return }
stored[path] = (ort, type)
storedBytes += daten.count
}
/// Wirft weg, was hinter der Abspielstelle liegt.
///
/// **Nicht alles dahinter**, sondern mit Abstand: ein kurzer Rücksprung
/// ist üblich, und ihn jedes Mal neu zu holen wäre teurer als die paar
/// Megabyte.
private func trimBehind() {
let grenze = lastRequested - 12
guard grenze > 0 else { return }
for index in 0..<min(grenze, segments.count) {
let pfad = segments[index]
guard let eintrag = stored.removeValue(forKey: pfad) else { continue }
let größe = (try? FileManager.default.attributesOfItem(atPath: eintrag.ort.path))
.flatMap { $0[.size] as? Int } ?? 0
storedBytes = max(0, storedBytes - größe)
try? FileManager.default.removeItem(at: eintrag.ort)
}
}
// MARK: - Abräumen
public func teardown() {
prefetchTask?.cancel()
prefetchTask = nil
stored.removeAll()
storedBytes = 0
try? FileManager.default.removeItem(at: directory)
}
/// Wie viel Film im Voraus bereitliegt — für die Anzeige.
public var bufferedAhead: Duration {
let voraus = max(0, fetched - lastRequested)
return .seconds(Double(voraus) * targetDuration.seconds)
}
}