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