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,32 @@
|
||||
import SwiftUI
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Die aktuelle Akzentfarbe, bereits kontrastkorrigiert.
|
||||
///
|
||||
/// Bewusst eine schlichte `Color` in der Umgebung statt einer Abhängigkeit
|
||||
/// auf `HaloTheme`: Bausteine wie `WatchProgressBar` sollen sich in einer
|
||||
/// Vorschau mit einer beliebigen Farbe zeigen lassen, ohne dass dafür ein
|
||||
/// ganzes Theme-Objekt gebaut werden muss.
|
||||
/// Der Vorgabewert liest die gespeicherte Auswahl, statt blind die
|
||||
/// Standardfarbe zu nehmen.
|
||||
///
|
||||
/// Er greift überall dort, wo die Umgebung nicht ankommt — Listen und
|
||||
/// Formulare zeichnen Zeilen teilweise in einem eigenen Kontext. Vorher
|
||||
/// leuchtete dort die Standardfarbe, während daneben schon die gewählte
|
||||
/// stand; jetzt stimmen beide.
|
||||
@Entry public var haloAccent: Color = AccentOption.storedAccentColor()
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Setzt die Akzentfarbe für diesen Teilbaum.
|
||||
///
|
||||
/// Wird einmal an der Wurzel aus `HaloTheme` gesetzt; darunter greift
|
||||
/// alles automatisch darauf zu.
|
||||
public func haloAccent(_ color: Color) -> some View {
|
||||
environment(\.haloAccent, color)
|
||||
}
|
||||
|
||||
public func haloAccent(_ theme: HaloTheme) -> some View {
|
||||
environment(\.haloAccent, theme.accentColor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Eine wählbare Akzentfarbe.
|
||||
///
|
||||
/// Die kuratierten Werte sind so gewählt, dass sie auf `HaloColor.canvas`
|
||||
/// mindestens 4,5:1 erreichen — geprüft von `AccentPaletteTests`, damit beim
|
||||
/// Nachlegen einer Farbe nicht versehentlich etwas Unlesbares hineinrutscht.
|
||||
public struct AccentOption: Sendable, Identifiable {
|
||||
public let id: String
|
||||
public let name: LocalizedStringResource
|
||||
public let color: RGB
|
||||
|
||||
public init(id: String, name: LocalizedStringResource, color: RGB) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.color = color
|
||||
}
|
||||
}
|
||||
|
||||
extension AccentOption: Equatable {
|
||||
public static func == (lhs: AccentOption, rhs: AccentOption) -> Bool { lhs.id == rhs.id }
|
||||
}
|
||||
|
||||
extension AccentOption: Hashable {
|
||||
public func hash(into hasher: inout Hasher) { hasher.combine(id) }
|
||||
}
|
||||
|
||||
extension AccentOption {
|
||||
/// Die kuratierte Auswahl aus den Einstellungen.
|
||||
public static let palette: [AccentOption] = [
|
||||
AccentOption(id: "ember", name: "Ember", color: RGB(hex: 0xE5484D)),
|
||||
AccentOption(id: "tangerine", name: "Tangerine", color: RGB(hex: 0xF76B15)),
|
||||
AccentOption(id: "amber", name: "Amber", color: RGB(hex: 0xFFB224)),
|
||||
AccentOption(id: "lime", name: "Lime", color: RGB(hex: 0x99D52A)),
|
||||
AccentOption(id: "mint", name: "Mint", color: RGB(hex: 0x3DD68C)),
|
||||
AccentOption(id: "teal", name: "Teal", color: RGB(hex: 0x12A594)),
|
||||
AccentOption(id: "sky", name: "Sky", color: RGB(hex: 0x52A9FF)),
|
||||
AccentOption(id: "indigo", name: "Indigo", color: RGB(hex: 0x7C77E8)),
|
||||
AccentOption(id: "lilac", name: "Lilac", color: RGB(hex: 0x9B8AD4)),
|
||||
AccentOption(id: "orchid", name: "Orchid", color: RGB(hex: 0xC04AC9)),
|
||||
AccentOption(id: "rose", name: "Rose", color: RGB(hex: 0xF76190)),
|
||||
]
|
||||
|
||||
/// Die Voreinstellung.
|
||||
///
|
||||
/// **Lila, weil das Icon lila ist.** Vorher stand hier Mint — eine
|
||||
/// hübsche Farbe, die mit der Marke nur nichts zu tun hatte: das Plakat
|
||||
/// auf dem Homescreen ist violett, und die Knöpfe darin waren grün.
|
||||
/// `lilac` ist die entsättigte Schwester desselben Violetts und erreicht
|
||||
/// auf dem dunklen Grund 6,5:1.
|
||||
///
|
||||
/// Über die Kennung gesucht, nicht über den Index: sonst würde ein
|
||||
/// Umsortieren der Palette unbemerkt die Standardfarbe ändern — und zwar
|
||||
/// nur für Neuinstallationen, was beim Suchen besonders unangenehm ist.
|
||||
public static let `default` = palette.first { $0.id == "lilac" } ?? palette[0]
|
||||
|
||||
/// Die Kennung, unter der eine frei gewählte Farbe abgelegt wird.
|
||||
public static let customIdentifier = "custom"
|
||||
|
||||
public static func custom(_ color: RGB) -> AccentOption {
|
||||
AccentOption(id: customIdentifier, name: "Custom", color: color)
|
||||
}
|
||||
}
|
||||
|
||||
extension AccentOption {
|
||||
/// Wo die Auswahl liegt.
|
||||
///
|
||||
/// Hier statt in `HaloTheme`, weil zwei Stellen sie brauchen: das Theme
|
||||
/// und der Vorgabewert der Umgebung. Zwei Kopien derselben Schlüssel
|
||||
/// driften auseinander, und zwar unbemerkt — es fällt erst auf, wenn eine
|
||||
/// Farbe irgendwo nicht mitkommt.
|
||||
enum StorageKey {
|
||||
static let identifier = "halo.accent.identifier"
|
||||
static let customHex = "halo.accent.customHex"
|
||||
}
|
||||
|
||||
/// Die gespeicherte Auswahl, oder die Voreinstellung.
|
||||
public static func stored(in defaults: UserDefaults = .standard) -> AccentOption {
|
||||
guard let identifier = defaults.string(forKey: StorageKey.identifier) else {
|
||||
return .default
|
||||
}
|
||||
if identifier == customIdentifier {
|
||||
// Fällt die gespeicherte Zeichenkette aus (etwa weil jemand die
|
||||
// Voreinstellungen von Hand bearbeitet hat), ist die Vorgabe
|
||||
// besser als eine Farbe, die niemand gewählt hat.
|
||||
guard let hex = defaults.string(forKey: StorageKey.customHex),
|
||||
let color = RGB(string: hex)
|
||||
else { return .default }
|
||||
return .custom(color)
|
||||
}
|
||||
return palette.first { $0.id == identifier } ?? .default
|
||||
}
|
||||
|
||||
/// Merkt sich die Auswahl.
|
||||
public func store(in defaults: UserDefaults = .standard) {
|
||||
defaults.set(id, forKey: StorageKey.identifier)
|
||||
if id == Self.customIdentifier {
|
||||
defaults.set(color.hexString, forKey: StorageKey.customHex)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die gespeicherte Farbe, bereits kontrastkorrigiert.
|
||||
public static func storedAccentColor(in defaults: UserDefaults = .standard) -> Color {
|
||||
Contrast.legible(stored(in: defaults).color, on: HaloColor.canvas).swiftUIColor
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
|
||||
/// Kontrastrechnung nach WCAG 2.1.
|
||||
///
|
||||
/// Gebraucht wird sie für die eigene Akzentfarbe: Halo lässt jede Farbe zu,
|
||||
/// aber ein dunkles Marineblau auf fast schwarzem Grund ist als Schrift oder
|
||||
/// Symbol unlesbar. `legible(_:on:)` hebt solche Farben so weit an, bis sie
|
||||
/// den geforderten Kontrast erreichen — und lässt alles in Ruhe, was ihn
|
||||
/// ohnehin schon hat.
|
||||
public enum Contrast {
|
||||
/// Der Grenzwert aus WCAG für normalen Text.
|
||||
public static let minimumForText: Double = 4.5
|
||||
/// Der Grenzwert für große Schrift und Bedienelemente.
|
||||
public static let minimumForLargeText: Double = 3.0
|
||||
|
||||
/// Relative Leuchtdichte eines Farbwerts (0 = Schwarz, 1 = Weiß).
|
||||
public static func relativeLuminance(_ color: RGB) -> Double {
|
||||
func linear(_ channel: Double) -> Double {
|
||||
// Der Knick bei 0,03928 ist kein Rundungsfehler, sondern Teil der
|
||||
// sRGB-Definition: unterhalb davon ist die Kurve linear.
|
||||
channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
return 0.2126 * linear(color.red)
|
||||
+ 0.7152 * linear(color.green)
|
||||
+ 0.0722 * linear(color.blue)
|
||||
}
|
||||
|
||||
/// Kontrastverhältnis zweier Farben, zwischen 1 (gleich) und 21 (Schwarz zu Weiß).
|
||||
public static func ratio(_ a: RGB, _ b: RGB) -> Double {
|
||||
let la = relativeLuminance(a)
|
||||
let lb = relativeLuminance(b)
|
||||
return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
|
||||
}
|
||||
|
||||
/// Hebt `color` gerade so weit an, dass sie auf `background` lesbar wird.
|
||||
///
|
||||
/// Zuerst wird die Helligkeit angehoben, weil das den Farbton erhält — eine
|
||||
/// gewählte Akzentfarbe soll erkennbar dieselbe bleiben. Reicht selbst
|
||||
/// volle Helligkeit nicht (etwa bei einem kräftigen Rot), wird zusätzlich
|
||||
/// die Sättigung zurückgenommen; die Farbe wandert dann Richtung Weiß.
|
||||
public static func legible(
|
||||
_ color: RGB,
|
||||
on background: RGB,
|
||||
minimumRatio: Double = minimumForText
|
||||
) -> RGB {
|
||||
guard ratio(color, background) < minimumRatio else { return color }
|
||||
|
||||
let base = HSB(color)
|
||||
|
||||
// Beide Schritte sind monoton — mehr Helligkeit bzw. weniger Sättigung
|
||||
// erhöhen die Leuchtdichte immer. Deshalb genügt eine binäre Suche,
|
||||
// und sie terminiert garantiert.
|
||||
if let brightened = search(
|
||||
from: base.brightness, to: 1,
|
||||
build: { HSB(hue: base.hue, saturation: base.saturation, brightness: $0).rgb },
|
||||
satisfies: { ratio($0, background) >= minimumRatio }
|
||||
) {
|
||||
return brightened
|
||||
}
|
||||
|
||||
return search(
|
||||
from: base.saturation, to: 0,
|
||||
build: { HSB(hue: base.hue, saturation: $0, brightness: 1).rgb },
|
||||
satisfies: { ratio($0, background) >= minimumRatio }
|
||||
) ?? RGB(hex: 0xFFFFFF)
|
||||
}
|
||||
|
||||
/// Sucht den Wert nahe `from`, der die Bedingung gerade eben erfüllt.
|
||||
/// `to` ist das Extrem; erfüllt selbst das die Bedingung nicht, gibt es
|
||||
/// keine Lösung auf dieser Achse.
|
||||
private static func search(
|
||||
from: Double,
|
||||
to extreme: Double,
|
||||
build: (Double) -> RGB,
|
||||
satisfies: (RGB) -> Bool
|
||||
) -> RGB? {
|
||||
guard satisfies(build(extreme)) else { return nil }
|
||||
|
||||
var unsatisfying = from
|
||||
var satisfying = extreme
|
||||
// 12 Halbierungen bringen den Wert auf unter 1/4096 genau — feiner als
|
||||
// jeder Unterschied, der auf einem Bildschirm noch sichtbar wäre.
|
||||
for _ in 0..<12 {
|
||||
let middle = (unsatisfying + satisfying) / 2
|
||||
if satisfies(build(middle)) { satisfying = middle } else { unsatisfying = middle }
|
||||
}
|
||||
return build(satisfying)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
|
||||
/// Farbton, Sättigung, Helligkeit — jeweils 0…1.
|
||||
///
|
||||
/// Die Kontrastkorrektur arbeitet in diesem Raum, weil sich darin die
|
||||
/// Eigenschaft „gleiche Farbe, nur heller" als einzelne Zahl ausdrücken lässt.
|
||||
/// In RGB müsste man dafür alle drei Kanäle gleichzeitig verschieben.
|
||||
public struct HSB: Sendable, Equatable {
|
||||
public var hue: Double
|
||||
public var saturation: Double
|
||||
public var brightness: Double
|
||||
|
||||
public init(hue: Double, saturation: Double, brightness: Double) {
|
||||
// Der Farbton ist zyklisch: 1,2 ist dasselbe wie 0,2. Abschneiden wäre
|
||||
// hier falsch, weil Rot dabei zu Magenta würde.
|
||||
self.hue = hue.truncatingRemainder(dividingBy: 1).nonNegativeHue()
|
||||
self.saturation = saturation.clamped()
|
||||
self.brightness = brightness.clamped()
|
||||
}
|
||||
|
||||
public init(_ rgb: RGB) {
|
||||
let maximum = max(rgb.red, rgb.green, rgb.blue)
|
||||
let minimum = min(rgb.red, rgb.green, rgb.blue)
|
||||
let delta = maximum - minimum
|
||||
|
||||
let hue: Double
|
||||
if delta == 0 {
|
||||
hue = 0
|
||||
} else if maximum == rgb.red {
|
||||
hue = ((rgb.green - rgb.blue) / delta).truncatingRemainder(dividingBy: 6) / 6
|
||||
} else if maximum == rgb.green {
|
||||
hue = ((rgb.blue - rgb.red) / delta + 2) / 6
|
||||
} else {
|
||||
hue = ((rgb.red - rgb.green) / delta + 4) / 6
|
||||
}
|
||||
|
||||
self.init(
|
||||
hue: hue,
|
||||
saturation: maximum == 0 ? 0 : delta / maximum,
|
||||
brightness: maximum
|
||||
)
|
||||
}
|
||||
|
||||
public var rgb: RGB {
|
||||
let sector = hue * 6
|
||||
let index = Int(sector) % 6
|
||||
let fraction = sector - Double(Int(sector))
|
||||
|
||||
let p = brightness * (1 - saturation)
|
||||
let q = brightness * (1 - saturation * fraction)
|
||||
let t = brightness * (1 - saturation * (1 - fraction))
|
||||
|
||||
switch index {
|
||||
case 0: return RGB(red: brightness, green: t, blue: p)
|
||||
case 1: return RGB(red: q, green: brightness, blue: p)
|
||||
case 2: return RGB(red: p, green: brightness, blue: t)
|
||||
case 3: return RGB(red: p, green: q, blue: brightness)
|
||||
case 4: return RGB(red: t, green: p, blue: brightness)
|
||||
default: return RGB(red: brightness, green: p, blue: q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Double {
|
||||
fileprivate func nonNegativeHue() -> Double { self < 0 ? self + 1 : self }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die Flächen- und Schriftfarben der App.
|
||||
///
|
||||
/// Halo kennt nur den dunklen Modus — es gibt deshalb bewusst keine
|
||||
/// Hell-/Dunkel-Paare, sondern je einen festen Wert. Die einzige Farbe, die
|
||||
/// sich zur Laufzeit ändert, ist der Akzent; er kommt aus `HaloTheme`.
|
||||
public enum HaloColor {
|
||||
// Flächen, von hinten nach vorn gestaffelt.
|
||||
/// Der Seitenhintergrund.
|
||||
public static let canvas = RGB(hex: 0x0B0B0D)
|
||||
/// Karten, Seitenleiste, Kopfbereiche.
|
||||
public static let surface = RGB(hex: 0x141417)
|
||||
/// Blätter, Menüs, alles was über dem Inhalt schwebt.
|
||||
public static let elevated = RGB(hex: 0x1D1D21)
|
||||
/// Platzhalterkacheln und gedrückte Zustände.
|
||||
public static let placeholder = RGB(hex: 0x26262B)
|
||||
|
||||
// Schrift.
|
||||
public static let textPrimary = RGB(hex: 0xF5F5F7)
|
||||
public static let textSecondary = RGB(hex: 0xA0A0A8)
|
||||
/// Für Beiwerk wie Jahreszahlen und Laufzeiten.
|
||||
public static let textMuted = RGB(hex: 0x6E6E76)
|
||||
|
||||
/// Rot für zerstörerische Aktionen — bewusst nicht der Akzent, damit
|
||||
/// „Löschen" auch dann warnend aussieht, wenn jemand Rot als Akzent wählt.
|
||||
public static let destructive = RGB(hex: 0xE5484D)
|
||||
}
|
||||
|
||||
extension Color {
|
||||
public static let haloCanvas = HaloColor.canvas.swiftUIColor
|
||||
public static let haloSurface = HaloColor.surface.swiftUIColor
|
||||
public static let haloElevated = HaloColor.elevated.swiftUIColor
|
||||
public static let haloPlaceholder = HaloColor.placeholder.swiftUIColor
|
||||
public static let haloTextPrimary = HaloColor.textPrimary.swiftUIColor
|
||||
public static let haloTextSecondary = HaloColor.textSecondary.swiftUIColor
|
||||
public static let haloTextMuted = HaloColor.textMuted.swiftUIColor
|
||||
public static let haloDestructive = HaloColor.destructive.swiftUIColor
|
||||
|
||||
/// Trennlinien und Ränder. Als Weiß mit Deckkraft statt als fester Wert,
|
||||
/// damit sie auf jeder Flächenstufe gleich zurückhaltend wirken.
|
||||
public static let haloSeparator = Color.white.opacity(0.10)
|
||||
/// Die Abdunklung hinter der ausgefahrenen Seitenleiste.
|
||||
public static let haloScrim = Color.black.opacity(0.55)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Eine Farbe als drei Kanäle im Bereich 0…1, unabhängig von SwiftUI.
|
||||
///
|
||||
/// `SwiftUI.Color` lässt sich nicht zuverlässig wieder auslesen — es ist ein
|
||||
/// undurchsichtiger Wert, der erst beim Zeichnen aufgelöst wird. Für
|
||||
/// Kontrastrechnungen brauchen wir die Zahlen aber vorher, deshalb dieser
|
||||
/// eigene Typ. `Color` entsteht erst am Ende, in `swiftUIColor`.
|
||||
public struct RGB: Sendable, Equatable, Hashable, Codable {
|
||||
public var red: Double
|
||||
public var green: Double
|
||||
public var blue: Double
|
||||
|
||||
public init(red: Double, green: Double, blue: Double) {
|
||||
self.red = red.clamped()
|
||||
self.green = green.clamped()
|
||||
self.blue = blue.clamped()
|
||||
}
|
||||
|
||||
/// Aus einem Hexwert der Form `0xRRGGBB`.
|
||||
public init(hex: UInt32) {
|
||||
self.init(
|
||||
red: Double((hex >> 16) & 0xFF) / 255,
|
||||
green: Double((hex >> 8) & 0xFF) / 255,
|
||||
blue: Double(hex & 0xFF) / 255
|
||||
)
|
||||
}
|
||||
|
||||
/// Aus einer Eingabe wie `#1DB954`, `1db954` oder `#1DB`.
|
||||
/// Gibt `nil` zurück, wenn die Zeichenkette keine Farbe ergibt — der
|
||||
/// Einstellungsdialog kann damit direkt eine Fehlermeldung zeigen.
|
||||
public init?(string: String) {
|
||||
var text = string.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
if text.hasPrefix("#") { text.removeFirst() }
|
||||
|
||||
// Kurzform: #ABC meint #AABBCC.
|
||||
if text.count == 3 {
|
||||
text = text.map { "\($0)\($0)" }.joined()
|
||||
}
|
||||
guard text.count == 6, let value = UInt32(text, radix: 16) else { return nil }
|
||||
self.init(hex: value)
|
||||
}
|
||||
|
||||
public var hexString: String {
|
||||
String(
|
||||
format: "#%02X%02X%02X",
|
||||
Int((red * 255).rounded()),
|
||||
Int((green * 255).rounded()),
|
||||
Int((blue * 255).rounded())
|
||||
)
|
||||
}
|
||||
|
||||
public var swiftUIColor: Color {
|
||||
// Explizit sRGB. Ohne Angabe deutet SwiftUI die Werte je nach Kontext
|
||||
// unterschiedlich, und dann stimmt der gerechnete Kontrast nicht mehr
|
||||
// mit dem überein, was auf dem Bildschirm landet.
|
||||
Color(.sRGB, red: red, green: green, blue: blue, opacity: 1)
|
||||
}
|
||||
}
|
||||
|
||||
extension Double {
|
||||
func clamped(to range: ClosedRange<Double> = 0...1) -> Double {
|
||||
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
public typealias PlatformImage = UIImage
|
||||
#else
|
||||
import AppKit
|
||||
public typealias PlatformImage = NSImage
|
||||
#endif
|
||||
|
||||
/// Lädt Bilder vom Server — einmal, und behält sie.
|
||||
///
|
||||
/// `AsyncImage` konnte das nicht: es lädt bei **jedem** Erscheinen neu, kennt
|
||||
/// keinen Zwischenspeicher und legt für jedes Bild eine eigene Anfrage an. Eine
|
||||
/// Startseite zeigt leicht sechzig Kacheln; dazu ein Aufmacher, der ein großes
|
||||
/// Bild anfordert, das der Server erst umrechnen muss. Mit sechs gleichzeitigen
|
||||
/// Verbindungen je Gegenstelle — der Voreinstellung — stauen sich die übrigen
|
||||
/// dahinter, und wer wegscrollt und zurückkommt, stellt sich hinten wieder an.
|
||||
///
|
||||
/// Auf dem iPad fiel das nicht auf, auf dem Mac schon: dort blieben der
|
||||
/// Aufmacher und die Hälfte der Plakate grau.
|
||||
///
|
||||
/// Zwei Speicher übereinander: die entpackten Bilder im Arbeitsspeicher, die
|
||||
/// Rohdaten auf der Platte. Der zweite überlebt einen Neustart der App —
|
||||
/// dieselbe Startseite kostet dann keine einzige Anfrage mehr.
|
||||
public actor ArtworkLoader {
|
||||
public static let shared = ArtworkLoader()
|
||||
|
||||
private let session: URLSession
|
||||
/// Der Speicher liegt **außerhalb** des Actors, damit er sich ohne
|
||||
/// `await` lesen lässt.
|
||||
///
|
||||
/// Das klingt nach einer Kleinigkeit und ist der ganze Punkt: ein `await`
|
||||
/// kostet immer mindestens einen Durchlauf, und genau der war beim
|
||||
/// Überblenden des Aufmachers zu sehen. Das neue Bild erschien im ersten
|
||||
/// Bild noch ohne Schriftzug, im zweiten mit — der Schriftzug sprang also
|
||||
/// getrennt herein, obwohl er längst da war. Wer das Bild schon hat, soll
|
||||
/// es im selben Augenblick zeigen können.
|
||||
private let cache = ImageMemoryCache()
|
||||
/// Läuft eine Anfrage schon, wird sie geteilt statt ein zweites Mal
|
||||
/// gestellt. Ohne das lädt eine Reihe, die dasselbe Plakat zweimal zeigt,
|
||||
/// es auch zweimal.
|
||||
private var inFlight: [URL: Task<PlatformImage?, Never>] = [:]
|
||||
|
||||
init() {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.urlCache = URLCache(
|
||||
memoryCapacity: 32 * 1_024 * 1_024,
|
||||
diskCapacity: 512 * 1_024 * 1_024
|
||||
)
|
||||
configuration.requestCachePolicy = .returnCacheDataElseLoad
|
||||
// Bilder sind klein und viele. Sechs gleichzeitige Verbindungen sind
|
||||
// für eine Seite mit sechzig Kacheln zu wenig.
|
||||
configuration.httpMaximumConnectionsPerHost = 12
|
||||
configuration.timeoutIntervalForRequest = 30
|
||||
session = URLSession(configuration: configuration)
|
||||
|
||||
cache.countLimit = 400
|
||||
}
|
||||
|
||||
/// Das Bild, wenn es schon im Arbeitsspeicher liegt — ohne Warten.
|
||||
///
|
||||
/// `nonisolated`, damit eine Ansicht es beim Zeichnen aufrufen kann.
|
||||
public nonisolated static func cachedImage(for url: URL) -> PlatformImage? {
|
||||
shared.cache.image(for: url)
|
||||
}
|
||||
|
||||
public func image(for url: URL) async -> PlatformImage? {
|
||||
if let cached = cache.image(for: url) { return cached }
|
||||
if let running = inFlight[url] { return await running.value }
|
||||
|
||||
let task = Task<PlatformImage?, Never> { [session] in
|
||||
await Self.fetch(url, using: session)
|
||||
}
|
||||
inFlight[url] = task
|
||||
|
||||
let image = await task.value
|
||||
inFlight[url] = nil
|
||||
if let image { cache.store(image, for: url) }
|
||||
return image
|
||||
}
|
||||
|
||||
/// Holt ein Bild — und gibt nicht beim ersten Nein auf.
|
||||
///
|
||||
/// Der Grund ist ein Fehlerbild, das lange rätselhaft aussah: auf einem
|
||||
/// großen Fenster blieb die **zweite** Reihe fast vollständig grau,
|
||||
/// während die erste geladen hatte. Es lag nicht am Server und nicht an
|
||||
/// fehlenden Bildern, sondern an der Gleichzeitigkeit — sechzig sichtbare
|
||||
/// Kacheln teilen sich zwölf Verbindungen, die hinteren stehen so lange
|
||||
/// in der Schlange, dass sie in den Zeitablauf laufen.
|
||||
///
|
||||
/// Oben fragt niemand ein zweites Mal: `task(id:)` läuft einmal je
|
||||
/// Adresse. Ein einziger verpasster Versuch war damit endgültig.
|
||||
///
|
||||
/// **Nur wiederholt, was sich zu wiederholen lohnt.** Ein 404 heißt, dass
|
||||
/// es das Bild nicht gibt — dreimal zu fragen macht es nicht vorhanden
|
||||
/// und kostet den Server drei Antworten. Ein Zeitablauf oder ein
|
||||
/// abgerissener Strang dagegen sagt nur, dass es *gerade* nicht ging.
|
||||
private static func fetch(
|
||||
_ url: URL,
|
||||
using session: URLSession,
|
||||
attempts: Int = 3
|
||||
) async -> PlatformImage? {
|
||||
for attempt in 1...attempts {
|
||||
if Task.isCancelled { return nil }
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(from: url)
|
||||
guard let http = response as? HTTPURLResponse else { return nil }
|
||||
|
||||
if (200..<300).contains(http.statusCode) {
|
||||
return PlatformImage(data: data)
|
||||
}
|
||||
// Der Server hat geantwortet und nein gesagt. Bei 5xx und 429
|
||||
// lohnt ein zweiter Versuch, sonst nicht.
|
||||
guard http.statusCode >= 500 || http.statusCode == 429 else { return nil }
|
||||
} catch is CancellationError {
|
||||
return nil
|
||||
} catch {
|
||||
// Zeitablauf, abgerissene Verbindung: der Fall, für den das
|
||||
// alles hier gebaut ist.
|
||||
}
|
||||
|
||||
guard attempt < attempts else { return nil }
|
||||
// Wachsender Abstand: ist die Leitung gerade verstopft, hilft es
|
||||
// nicht, sofort noch einmal zu drängeln.
|
||||
try? await Task.sleep(for: .milliseconds(300 * attempt * attempt))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Bildspeicher, der sich von überall lesen lässt.
|
||||
///
|
||||
/// `NSCache` ist laut Apples Dokumentation von sich aus für den Zugriff aus
|
||||
/// mehreren Strängen gebaut — der Compiler weiß das nur nicht. Deshalb hier
|
||||
/// von Hand versichert statt den Speicher in einen Actor zu sperren, was ihn
|
||||
/// genau um die Eigenschaft brächte, für die er hier steht: ohne Warten
|
||||
/// lesbar zu sein.
|
||||
private final class ImageMemoryCache: @unchecked Sendable {
|
||||
private let storage = NSCache<NSURL, PlatformImage>()
|
||||
|
||||
var countLimit: Int {
|
||||
get { storage.countLimit }
|
||||
set { storage.countLimit = newValue }
|
||||
}
|
||||
|
||||
func image(for url: URL) -> PlatformImage? {
|
||||
storage.object(forKey: url as NSURL)
|
||||
}
|
||||
|
||||
func store(_ image: PlatformImage, for url: URL) {
|
||||
storage.setObject(image, forKey: url as NSURL)
|
||||
}
|
||||
}
|
||||
|
||||
extension Image {
|
||||
/// Aus einem Plattformbild, ohne dass die Ansicht wissen muss, welches.
|
||||
init(platform image: PlatformImage) {
|
||||
#if canImport(UIKit)
|
||||
self.init(uiImage: image)
|
||||
#else
|
||||
self.init(nsImage: image)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Ein Bild vom Server mit Lade- und Fehlerzustand.
|
||||
///
|
||||
/// Der Austausch, den die frühere Notiz hier angekündigt hat, ist vollzogen:
|
||||
/// statt `AsyncImage` lädt `ArtworkLoader`. Der Grund stand schon da — eine
|
||||
/// Startseite zeigt dutzende Bilder gleichzeitig, und `AsyncImage` lädt sie bei
|
||||
/// jedem Erscheinen neu. Auf dem Mac wurde daraus ein sichtbarer Fehler: der
|
||||
/// Aufmacher und die Hälfte der Plakate blieben grau.
|
||||
public struct HaloArtwork: View {
|
||||
/// Die Adressen, der Reihe nach probiert.
|
||||
///
|
||||
/// **Warum mehrere.** Eine Folge hat selten ein eigenes Querformatbild,
|
||||
/// die Staffel manchmal, die Serie fast immer. Bisher wurde die erste
|
||||
/// Adresse genommen, die sich **zusammensetzen** liess — und ob es das
|
||||
/// Bild dahinter gibt, stellt sich erst beim Abruf heraus. Gab es das
|
||||
/// Staffelbild nicht, blieb die Fläche grau, und der Weg zur Serie wurde
|
||||
/// nie versucht.
|
||||
///
|
||||
/// Die Reihenfolge gehört deshalb hierher, ins Laden, nicht ins
|
||||
/// Zusammensetzen: erst wer gefragt hat, weiss, ob es etwas gibt.
|
||||
private let urls: [URL]
|
||||
private let aspectRatio: CGFloat
|
||||
/// Ob das Bild den Rahmen einnimmt, den es bekommt, statt sich seine Höhe
|
||||
/// aus dem Seitenverhältnis auszurechnen.
|
||||
///
|
||||
/// Für den Aufmacher: der soll so hoch sein wie das Fenster und nicht so
|
||||
/// hoch, wie 16:9 es vorgibt. Das Seitenverhältnis bleibt trotzdem
|
||||
/// wichtig — danach werden die Bilder beim Server angefordert.
|
||||
private let fillsFrame: Bool
|
||||
private let fallbackSymbol: String
|
||||
private let contentMode: ContentMode
|
||||
|
||||
/// - Parameter contentMode: `.fill` füllt die Fläche und schneidet ab —
|
||||
/// richtig für Plakate und Szenenbilder, die ohnehin formatfüllend
|
||||
/// gedacht sind. `.fit` zeigt das ganze Bild. Das braucht alles, was
|
||||
/// kein festes Seitenverhältnis hat: **Sendersymbole** sind mal breit,
|
||||
/// mal schmal, und formatfüllend in einem Quadrat fehlten links und
|
||||
/// rechts genau die Teile, an denen man den Sender erkennt.
|
||||
public init(
|
||||
url: URL?,
|
||||
aspectRatio: CGFloat,
|
||||
fillsFrame: Bool = false,
|
||||
fallbackSymbol: String = "film",
|
||||
contentMode: ContentMode = .fill
|
||||
) {
|
||||
self.init(
|
||||
urls: [url].compactMap(\.self),
|
||||
aspectRatio: aspectRatio,
|
||||
fillsFrame: fillsFrame,
|
||||
fallbackSymbol: fallbackSymbol,
|
||||
contentMode: contentMode
|
||||
)
|
||||
}
|
||||
|
||||
/// Mehrere Adressen, von der besten zur schlechtesten.
|
||||
public init(
|
||||
urls: [URL],
|
||||
aspectRatio: CGFloat,
|
||||
fillsFrame: Bool = false,
|
||||
fallbackSymbol: String = "film",
|
||||
contentMode: ContentMode = .fill
|
||||
) {
|
||||
self.urls = urls
|
||||
self.aspectRatio = aspectRatio
|
||||
self.fillsFrame = fillsFrame
|
||||
self.fallbackSymbol = fallbackSymbol
|
||||
self.contentMode = contentMode
|
||||
}
|
||||
|
||||
/// Das geladene Bild — **mit den Adressen, zu denen es gehört**.
|
||||
///
|
||||
/// **Ohne sie wird es falsch,** sobald eine Ansicht ihre Identität über
|
||||
/// einen Inhaltswechsel hinweg behält: der Zustand überlebt den Wechsel,
|
||||
/// und das alte Bild bliebe stehen, bis das neue geladen ist. Solange
|
||||
/// jede Kachel ihre eigene Identität hatte, fiel das nicht auf — der
|
||||
/// Aufmacher hat seit dem Umbau der Überblendung keine mehr.
|
||||
@State private var loaded: (urls: [URL], image: PlatformImage)?
|
||||
@State private var hasFailed = false
|
||||
|
||||
public var body: some View {
|
||||
placeholder
|
||||
.overlay {
|
||||
content
|
||||
}
|
||||
.clipped()
|
||||
.task(id: urls) { await load() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var placeholder: some View {
|
||||
if fillsFrame {
|
||||
Rectangle().fill(Color.haloPlaceholder)
|
||||
} else {
|
||||
Rectangle().fill(Color.haloPlaceholder).aspectRatio(aspectRatio, contentMode: .fit)
|
||||
}
|
||||
}
|
||||
|
||||
/// Das Bild, das jetzt gezeigt werden kann.
|
||||
///
|
||||
/// Der Blick in den Speicher steht **hier**, nicht in `load()`: eine
|
||||
/// Kachel, die zum zweiten Mal erscheint, zeigt ihr Bild damit schon im
|
||||
/// ersten Bild und blitzt nicht kurz grau auf. Über ein `await` ginge das
|
||||
/// nicht — das kostet immer einen Durchlauf.
|
||||
private var available: PlatformImage? {
|
||||
if let loaded, loaded.urls == urls { return loaded.image }
|
||||
// Die erste, die schon im Speicher liegt — in der Reihenfolge der
|
||||
// Vorlieben, nicht in der des Zufalls.
|
||||
for url in urls {
|
||||
if let bild = ArtworkLoader.cachedImage(for: url) { return bild }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let loaded = available {
|
||||
switch contentMode {
|
||||
case .fill: Image(platform: loaded).resizable().scaledToFill()
|
||||
case .fit: Image(platform: loaded).resizable().scaledToFit()
|
||||
}
|
||||
} else if hasFailed {
|
||||
// Kein Fehlertext: bei einer Reihe aus zwanzig Kacheln wäre das
|
||||
// mehr Lärm als Hilfe. Das Symbol sagt genug.
|
||||
Image(systemName: fallbackSymbol)
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
} else {
|
||||
ShimmerPlaceholder()
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard !urls.isEmpty else {
|
||||
hasFailed = false
|
||||
loaded = nil
|
||||
return
|
||||
}
|
||||
|
||||
// Was schon da ist, zeigt `available` bereits — hier bleibt nur das
|
||||
// Nachladen.
|
||||
if available != nil {
|
||||
hasFailed = false
|
||||
return
|
||||
}
|
||||
|
||||
loaded = nil
|
||||
hasFailed = false
|
||||
|
||||
// **Der Reihe nach, bis eine antwortet.** Genau das konnte die
|
||||
// frühere Fassung nicht: sie hatte nur eine Adresse und musste sich
|
||||
// vorher entscheiden, ohne zu wissen, ob es das Bild gibt.
|
||||
for url in urls {
|
||||
let bild = await ArtworkLoader.shared.image(for: url)
|
||||
guard !Task.isCancelled else { return }
|
||||
if let bild {
|
||||
withAnimation(.easeOut(duration: 0.2)) { loaded = (urls, bild) }
|
||||
return
|
||||
}
|
||||
}
|
||||
hasFailed = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Das Aufmacher-Bild oben auf der Startseite.
|
||||
///
|
||||
/// Die Aktionsknöpfe kommen von außen, weil ein Film „Abspielen" und
|
||||
/// „Herunterladen" braucht, eine angefangene Folge dagegen „Weiterschauen".
|
||||
/// Der Baustein legt nur Bild, Verlauf und Textsatz fest.
|
||||
public struct HeroBanner<Actions: View>: View {
|
||||
private let title: String
|
||||
private let metadata: String?
|
||||
/// Eine Zeile, in der einzelne Teile anklickbar sind.
|
||||
///
|
||||
/// Steht sie, tritt sie an die Stelle von `metadata`. Gebaut wird sie beim
|
||||
/// Aufrufer, weil nur der weiß, wohin ein Teil führt — die Gestaltung
|
||||
/// kennt keine Ziele. `AnyView` statt eines weiteren Platzhaltertyps: der
|
||||
/// Aufmacher hat schon einen für die Knöpfe, und ein zweiter zöge sich
|
||||
/// durch jeden Aufruf.
|
||||
private let metadataContent: AnyView?
|
||||
private let summary: String?
|
||||
private let artworkURLs: [URL]
|
||||
/// Das Bild, das gerade verschwindet — nur für den Aufmacher mit Wechsel.
|
||||
///
|
||||
/// **Warum die Überblendung hier drin steckt und nicht darüber.** Das
|
||||
/// Karussell legte vorher zwei ganze Aufmacher übereinander und blendete
|
||||
/// den oberen ein. Jeder bringt seinen eigenen Verlauf nach unten mit —
|
||||
/// und zwei halbdurchsichtige Verläufe ergeben zusammen **keinen** ganzen.
|
||||
/// Gemessen an derselben Stelle: im Ruhezustand 68, in der Mitte des
|
||||
/// Wechsels 137. Sichtbar als ein Aufhellen genau dort, wo der Titel
|
||||
/// steht.
|
||||
///
|
||||
/// Jetzt wechselt nur das **Bild**; Verlauf und Text liegen einmal
|
||||
/// darüber und ändern sich nicht. Damit kann der Verlauf gar nicht mehr
|
||||
/// schwächer werden.
|
||||
private let outgoingArtworkURLs: [URL]
|
||||
/// Wie weit das neue Bild schon deckt: 0 beim Beginn, 1 am Ende.
|
||||
private let crossfade: CGFloat
|
||||
/// Wie weit das neue Bild dabei hereinrutscht.
|
||||
private let incomingOffset: CGFloat
|
||||
/// Titel, Zeile und Schriftzug des weichenden Eintrags.
|
||||
///
|
||||
/// **Damit der Schriftzug mitwandert.** Ohne ihn stünde während des
|
||||
/// Wechsels gar kein Text da: der neue blendet erst ein, der alte wäre
|
||||
/// schon weg. Er liegt **über** dem Verlauf, nicht darin — deshalb kann
|
||||
/// er die Abdunklung nicht schwächen, anders als ein zweiter ganzer
|
||||
/// Aufmacher.
|
||||
private let outgoing: (title: String, metadata: String?, logoURL: URL?)?
|
||||
/// Der freigestellte Schriftzug des Films. Steht er zur Verfügung, tritt
|
||||
/// er an die Stelle des gesetzten Titels — „Harry Potter" in seiner
|
||||
/// eigenen Schrift ist wiedererkennbar, eine Systemschrift nicht.
|
||||
private let logoURL: URL?
|
||||
private let aspectRatio: CGFloat
|
||||
/// Ob der Aufmacher den Platz einnimmt, den er bekommt.
|
||||
private let fillsAvailableSpace: Bool
|
||||
private let actions: Actions
|
||||
|
||||
/// Der geladene Schriftzug — **mit der Adresse, zu der er gehört**.
|
||||
///
|
||||
/// **Ohne die Adresse wird er falsch.** Seit der Aufmacher seine
|
||||
/// Identität über den Titelwechsel hinweg behält, überlebt auch dieser
|
||||
/// Zustand ihn: der Schriftzug des vorigen Films stand dann über dem Bild
|
||||
/// des neuen, bis der neue geladen war. „Werner Beinhart" über John Wick.
|
||||
///
|
||||
/// Ein Zustand, der eine andere Angabe zwischenspeichert, muss sagen,
|
||||
/// wozu er gehört — sonst gilt er für alles.
|
||||
@State private var loadedLogo: (url: URL, image: PlatformImage)?
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
metadata: String? = nil,
|
||||
metadataContent: AnyView? = nil,
|
||||
summary: String? = nil,
|
||||
artworkURLs: [URL],
|
||||
outgoingArtworkURLs: [URL] = [],
|
||||
outgoingTitle: String? = nil,
|
||||
outgoingMetadata: String? = nil,
|
||||
outgoingLogoURL: URL? = nil,
|
||||
crossfade: CGFloat = 1,
|
||||
incomingOffset: CGFloat = 0,
|
||||
logoURL: URL? = nil,
|
||||
// 16:9, weil Plex genau das als `art` liefert. Vorher stand hier
|
||||
// 3:4 — mit `scaledToFill` wurde das Bild dadurch links und rechts
|
||||
// kräftig beschnitten, und vom Aufmacher war nur ein Ausschnitt zu
|
||||
// sehen.
|
||||
aspectRatio: CGFloat = HaloAspectRatio.hero,
|
||||
fillsAvailableSpace: Bool = false,
|
||||
@ViewBuilder actions: () -> Actions
|
||||
) {
|
||||
self.title = title
|
||||
self.metadata = metadata
|
||||
self.metadataContent = metadataContent
|
||||
self.summary = summary
|
||||
self.artworkURLs = artworkURLs
|
||||
self.outgoingArtworkURLs = outgoingArtworkURLs
|
||||
self.outgoing = outgoingTitle.map { ($0, outgoingMetadata, outgoingLogoURL) }
|
||||
self.crossfade = crossfade
|
||||
self.incomingOffset = incomingOffset
|
||||
self.logoURL = logoURL
|
||||
self.aspectRatio = aspectRatio
|
||||
self.fillsAvailableSpace = fillsAvailableSpace
|
||||
self.actions = actions()
|
||||
}
|
||||
|
||||
/// Wie stark Schrift und Schriftzug mitwachsen.
|
||||
///
|
||||
/// Der Aufmacher ist so breit wie das Fenster. Bei fester Schriftgröße
|
||||
/// wirkt derselbe Titel auf einem iPad groß und in einem breiten
|
||||
/// Mac-Fenster verloren — das Bild wächst, die Schrift nicht. Bezugsgröße
|
||||
/// ist die Breite eines iPads im Hochformat; alles darüber wächst mit,
|
||||
/// aber gedeckelt, damit ein Fenster über die ganze Wand nicht in
|
||||
/// Plakatschrift endet.
|
||||
private func scale(for width: CGFloat) -> CGFloat {
|
||||
min(max(width / 834, 1), 1.7)
|
||||
}
|
||||
|
||||
/// Das Bild — und, solange gewechselt wird, das vorige darunter.
|
||||
///
|
||||
/// Ohne Wechsel ist es genau ein `HaloArtwork` wie zuvor: die zweite
|
||||
/// Ebene entsteht nur, wenn jemand eine vorige Adresse mitgibt.
|
||||
@ViewBuilder
|
||||
private var bild: some View {
|
||||
if outgoingArtworkURLs.isEmpty {
|
||||
HaloArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
|
||||
} else {
|
||||
ZStack {
|
||||
HaloArtwork(urls: outgoingArtworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
|
||||
HaloArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
|
||||
.opacity(crossfade)
|
||||
.offset(x: incomingOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schriftzug, wenn vorhanden — sonst gesetzter Titel.
|
||||
///
|
||||
/// Geladen wird über `ArtworkLoader`, **nicht** über `AsyncImage`. Der
|
||||
/// Unterschied ist beim Überblenden des Aufmachers zu sehen: `AsyncImage`
|
||||
/// bringt seine eigene Einblendung mit und fängt erst an zu laden, wenn es
|
||||
/// erscheint. Der Schriftzug blendete sich dann getrennt und später ein,
|
||||
/// während Titelzeile, Beschreibung und Knöpfe längst mitgewandert waren —
|
||||
/// zwei Bewegungen, wo eine sein soll.
|
||||
///
|
||||
/// Jetzt liegt das Bild beim Erscheinen schon im Zwischenspeicher (das
|
||||
/// Karussell holt es vor) und wird ohne eigene Bewegung gezeigt: es gehört
|
||||
/// zum Aufmacher und macht mit, was der Aufmacher macht.
|
||||
///
|
||||
/// Ohne Bild-Transcoder, weil der Schriftzug ein freigestelltes PNG ist und
|
||||
/// der Transcoder JPEG liefert — aus dem Logo würde sonst ein schwarzer
|
||||
/// Kasten.
|
||||
/// Der Schriftzug, wenn er **jetzt** gezeigt werden kann.
|
||||
///
|
||||
/// Das Karussell holt ihn vor; liegt er bereit, steht er schon im ersten
|
||||
/// Bild des Übergangs da. Nur wenn nicht, wird nachgeladen — und dann
|
||||
/// steht bis dahin der gesetzte Titel.
|
||||
private var availableLogo: PlatformImage? {
|
||||
guard let logoURL else { return nil }
|
||||
if let loadedLogo, loadedLogo.url == logoURL { return loadedLogo.image }
|
||||
return ArtworkLoader.cachedImage(for: logoURL)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
bild
|
||||
.overlay(alignment: .bottom) {
|
||||
// Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in
|
||||
// Schwarz: sonst entsteht genau dort, wo der Aufmacher in die
|
||||
// erste Reihe übergeht, eine sichtbare Kante.
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .clear, location: 0),
|
||||
.init(color: Color.haloCanvas.opacity(0.75), location: 0.45),
|
||||
.init(color: Color.haloCanvas, location: 1),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
// Oben liegen Uhrzeit, Batterie und die Navigationsinsel — auf
|
||||
// dem Bild, seit der Aufmacher bis an den Bildschirmrand
|
||||
// reicht. Über einem dunklen Aufmacher geht das gut; über
|
||||
// einem hellen verschwinden sie.
|
||||
//
|
||||
// Deshalb ein Verlauf, aber ein knapper: er reicht nur so
|
||||
// weit, wie die Insel baut, und läuft schnell aus. Ein
|
||||
// großzügigerer deckte das Bild zu, und genau das Bild ist der
|
||||
// Grund, warum es randlos sein soll.
|
||||
LinearGradient(
|
||||
colors: [.black.opacity(0.45), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 150)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
GeometryReader { geometry in
|
||||
let scale = scale(for: geometry.size.width)
|
||||
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
// **Der weichende Text bleibt stehen, bis der neue
|
||||
// ihn zudeckt.** Sonst stünde mitten im Wechsel gar
|
||||
// keiner da: der neue blendet ein, der alte wäre
|
||||
// schon weg. Er liegt über dem Verlauf — dort kostet
|
||||
// er die Abdunklung nichts.
|
||||
if let outgoing, crossfade < 1 {
|
||||
inhalt(
|
||||
title: outgoing.title,
|
||||
metadata: outgoing.metadata,
|
||||
logo: outgoing.logoURL.flatMap(ArtworkLoader.cachedImage(for:)),
|
||||
scale: scale
|
||||
)
|
||||
}
|
||||
|
||||
// Der neue — mit demselben Einblenden und demselben
|
||||
// Hereinrutschen wie das Bild darunter, damit beides
|
||||
// als eine Bewegung erscheint.
|
||||
inhalt(
|
||||
title: title,
|
||||
metadata: metadata,
|
||||
logo: availableLogo,
|
||||
scale: scale
|
||||
)
|
||||
.opacity(crossfade)
|
||||
.offset(x: incomingOffset)
|
||||
}
|
||||
.frame(
|
||||
width: geometry.size.width,
|
||||
height: geometry.size.height,
|
||||
alignment: .bottomLeading
|
||||
)
|
||||
}
|
||||
}
|
||||
.task(id: logoURL) {
|
||||
guard let logoURL, ArtworkLoader.cachedImage(for: logoURL) == nil else { return }
|
||||
if let bild = await ArtworkLoader.shared.image(for: logoURL) {
|
||||
loadedLogo = (logoURL, bild)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schriftzug beziehungsweise Titel, Zeile, Beschreibung und Knöpfe.
|
||||
///
|
||||
/// Als eigene Funktion, weil es sie während eines Wechsels zweimal gibt —
|
||||
/// einmal für den weichenden und einmal für den kommenden Eintrag.
|
||||
@ViewBuilder
|
||||
private func inhalt(
|
||||
title: String,
|
||||
metadata: String?,
|
||||
logo: PlatformImage?,
|
||||
scale: CGFloat
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug * scale) {
|
||||
if let logo {
|
||||
Image(platform: logo)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
// Nach oben begrenzt: manche Schriftzüge sind fast
|
||||
// quadratisch, andere sehr breit. Ohne Deckel schöbe ein
|
||||
// hohes Logo die Knöpfe aus dem Bild.
|
||||
.frame(maxWidth: 260 * scale, maxHeight: 76 * scale, alignment: .leading)
|
||||
.accessibilityLabel(Text(title))
|
||||
} else {
|
||||
// Solange nichts da ist — und wenn es scheitert — bleibt es
|
||||
// beim gesetzten Titel. Ein leerer Platz wäre schlimmer als
|
||||
// eine Systemschrift.
|
||||
Text(title)
|
||||
.font(.system(size: 34 * scale, weight: .bold))
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
if let metadataContent {
|
||||
metadataContent
|
||||
.font(.system(size: 12 * scale))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
} else if let metadata {
|
||||
Text(metadata)
|
||||
.font(.system(size: 12 * scale))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
|
||||
if let summary {
|
||||
Text(summary)
|
||||
.font(.system(size: 12 * scale))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.lineLimit(2)
|
||||
// Ein Satz über die ganze Breite eines großen Fensters ist
|
||||
// unlesbar; das Auge verliert die Zeile. Achtzig Zeichen
|
||||
// sind die Grenze, ab der Satzspiegel unangenehm werden.
|
||||
.frame(maxWidth: 640 * scale, alignment: .leading)
|
||||
}
|
||||
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
actions
|
||||
}
|
||||
.padding(.top, HaloSpacing.tight)
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.screenMargin * scale)
|
||||
.padding(.bottom, HaloSpacing.loose * scale)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Ein Titel im Querformat — die Kachelform der Startseite.
|
||||
///
|
||||
/// Anders als das Poster zeigt sie ein Szenenbild und trägt den Titel unter
|
||||
/// dem Bild, weil Szenenbilder selten Schrift enthalten.
|
||||
public struct LandscapeCard: View {
|
||||
private let title: String
|
||||
private let subtitle: String?
|
||||
private let artworkURL: URL?
|
||||
private let progress: Double?
|
||||
|
||||
public static let aspectRatio: CGFloat = 16.0 / 9.0
|
||||
|
||||
public init(title: String, subtitle: String? = nil, artworkURL: URL?, progress: Double? = nil) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.artworkURL = artworkURL
|
||||
self.progress = progress
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||
HaloArtwork(url: artworkURL, aspectRatio: Self.aspectRatio, fallbackSymbol: "play.rectangle")
|
||||
.overlay(alignment: .bottom) {
|
||||
if let progress {
|
||||
WatchProgressBar(fraction: progress)
|
||||
}
|
||||
}
|
||||
.clipShape(.rect(cornerRadius: HaloRadius.card))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Eine Kachel, die es gleich geben wird.
|
||||
///
|
||||
/// **Warum es das gibt.** Eine Mediathek lädt seitenweise nach. Wer unten
|
||||
/// ankommt, sieht ein Raster, das aufhört — und kann nicht unterscheiden, ob
|
||||
/// das alles war oder ob gerade nachgeladen wird. Beides sieht gleich aus, und
|
||||
/// im Zweifel hält man eine halb geladene Liste für vollständig.
|
||||
///
|
||||
/// Diese Kacheln sagen: hier kommt noch etwas. Sie werden nur so oft gezeigt,
|
||||
/// wie der Server tatsächlich noch Titel gemeldet hat — es ist also keine
|
||||
/// Beschwichtigung, sondern eine Auskunft.
|
||||
///
|
||||
/// Ein Ladekreis täte das nicht: er sagt „es passiert etwas", aber nicht
|
||||
/// **wo** und nicht **wie viel**. Die Form der Kachel an der Stelle, an der
|
||||
/// sie erscheinen wird, sagt beides.
|
||||
public struct PlaceholderCard: View {
|
||||
/// Womit das Aufleuchten versetzt wird.
|
||||
///
|
||||
/// Alle gleichzeitig pulsieren zu lassen sieht nach einem Fehler aus —
|
||||
/// als blinkte die ganze Seite. Ein Versatz je Kachel macht daraus eine
|
||||
/// Bewegung, die von links nach rechts läuft.
|
||||
let index: Int
|
||||
|
||||
@State private var isBright = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
public init(index: Int = 0) {
|
||||
self.index = index
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||
RoundedRectangle(cornerRadius: HaloRadius.card)
|
||||
.fill(Color.haloElevated)
|
||||
.aspectRatio(PosterCard.aspectRatio, contentMode: .fit)
|
||||
|
||||
// Zwei Balken an der Stelle von Titel und Jahr. Ohne sie springt
|
||||
// die Zeile, sobald der echte Titel kommt.
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Color.haloElevated)
|
||||
.frame(height: 10)
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Color.haloElevated)
|
||||
.frame(width: 32, height: 8)
|
||||
}
|
||||
.opacity(isBright ? 0.85 : 0.45)
|
||||
.animation(
|
||||
reduceMotion
|
||||
? nil
|
||||
: .easeInOut(duration: 0.9)
|
||||
.repeatForever(autoreverses: true)
|
||||
.delay(Double(index % 6) * 0.1),
|
||||
value: isBright
|
||||
)
|
||||
.onAppear { isBright = true }
|
||||
// Für die Sprachausgabe ist eine Kachel ohne Inhalt nichts, worüber
|
||||
// man wischen möchte.
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Ein Titel im Hochformat, wie er in Bibliotheksrastern steht.
|
||||
public struct PosterCard: View {
|
||||
private let title: String
|
||||
private let subtitle: String?
|
||||
private let artworkURL: URL?
|
||||
private let progress: Double?
|
||||
|
||||
/// Das Seitenverhältnis von Kinoplakaten. Plex liefert Poster in diesem
|
||||
/// Format; ein anderer Wert würde die Bilder beschneiden.
|
||||
public static let aspectRatio: CGFloat = 2.0 / 3.0
|
||||
|
||||
public init(title: String, subtitle: String? = nil, artworkURL: URL?, progress: Double? = nil) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.artworkURL = artworkURL
|
||||
self.progress = progress
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||
HaloArtwork(url: artworkURL, aspectRatio: Self.aspectRatio)
|
||||
.clipShape(.rect(cornerRadius: HaloRadius.card))
|
||||
.overlay(alignment: .bottom) {
|
||||
if let progress {
|
||||
WatchProgressBar(fraction: progress)
|
||||
}
|
||||
}
|
||||
.clipShape(.rect(cornerRadius: HaloRadius.card))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ohne diese Zusammenfassung liest VoiceOver Bild, Titel und Balken als
|
||||
// drei getrennte Elemente vor — bei einer Reihe aus zwanzig Kacheln
|
||||
// wird das Durchwischen dadurch dreimal so lang.
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die Platzhalterfläche, solange ein Bild noch lädt.
|
||||
///
|
||||
/// Ein wandernder Lichtschein statt eines Spinners: er zeigt die endgültige
|
||||
/// Form des Inhalts schon an, wodurch die Startseite beim Aufbauen nicht
|
||||
/// springt.
|
||||
public struct ShimmerPlaceholder: View {
|
||||
@State private var phase: CGFloat = -1
|
||||
/// Bewegte Flächen sind für manche Menschen unangenehm; bei aktivierter
|
||||
/// Systemeinstellung bleibt der Platzhalter deshalb einfach ruhig stehen.
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
Rectangle()
|
||||
.fill(Color.haloPlaceholder)
|
||||
.overlay {
|
||||
if !reduceMotion {
|
||||
GeometryReader { proxy in
|
||||
LinearGradient(
|
||||
colors: [.clear, Color.white.opacity(0.06), .clear],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.frame(width: proxy.size.width * 0.6)
|
||||
.offset(x: phase * proxy.size.width * 1.6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipped()
|
||||
.onAppear {
|
||||
guard !reduceMotion else { return }
|
||||
withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) {
|
||||
phase = 1
|
||||
}
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
#if os(tvOS)
|
||||
import SwiftUI
|
||||
|
||||
/// Ein Plakat, das auf den Fokus antwortet.
|
||||
///
|
||||
/// **Ein Knopf, kein Bild mit Geste.** Auf tvOS ist alles Bedienbare
|
||||
/// fokussierbar, und `.buttonStyle(.card)` bringt die Systemwirkung mit:
|
||||
/// Anheben, Schlagschatten und die Parallaxe, bei der sich die Ebenen beim
|
||||
/// Wischen gegeneinander verschieben. Die von Hand nachzubauen —
|
||||
/// `scaleEffect` und ein Schatten — sieht auf den ersten Blick gleich aus und
|
||||
/// auf den zweiten falsch: die Parallaxe ist der Teil, den man wiedererkennt.
|
||||
///
|
||||
/// Liegt in HaloDesign und nicht im Fernseh-Ziel, aus demselben Grund wie
|
||||
/// `PosterCard`: das Paket kennt weder `MediaItem` noch die Wiedergabe, und
|
||||
/// das soll so bleiben. Herein kommen Zeichenketten und Adressen.
|
||||
public struct TVPosterCard: View {
|
||||
private let title: String
|
||||
private let subtitle: String?
|
||||
private let artworkURL: URL?
|
||||
private let progress: Double?
|
||||
/// Meldet nach oben, wenn diese Karte den Fokus bekommt oder verliert.
|
||||
///
|
||||
/// **Nach aussen als Rückruf, innen als `@FocusState`.** Die Umgebung
|
||||
/// könnte es nicht beantworten — `\.isFocused` meint den nächsten
|
||||
/// fokussierbaren Vorfahren, und das ist in einer Reihe aus Karten nicht
|
||||
/// verlässlich diese. Wer es wissen will, bekommt es deshalb gesagt.
|
||||
private let onFocusChange: (Bool) -> Void
|
||||
private let action: () -> Void
|
||||
|
||||
/// **Ausdrücklich und nicht über `\.isFocused`.** Die Umgebungsvariable
|
||||
/// beantwortet die Frage nach dem nächstgelegenen fokussierbaren
|
||||
/// Vorfahren; in einer Reihe aus Karten ist das nicht verlässlich diese.
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
artworkURL: URL?,
|
||||
progress: Double? = nil,
|
||||
onFocusChange: @escaping (Bool) -> Void = { _ in },
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.artworkURL = artworkURL
|
||||
self.progress = progress
|
||||
self.onFocusChange = onFocusChange
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
TVCard(
|
||||
aspectRatio: PosterCard.aspectRatio,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
artworkURL: artworkURL,
|
||||
progress: progress,
|
||||
isFocused: $isFocused,
|
||||
onFocusChange: onFocusChange,
|
||||
action: action
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Dasselbe im Querformat — für „Weiterschauen".
|
||||
///
|
||||
/// Angefangenes erkennt man an der Szene, an der man aufgehört hat; wer
|
||||
/// dagegen aussucht, was als Nächstes kommt, sucht nach Plakaten.
|
||||
public struct TVLandscapeCard: View {
|
||||
private let title: String
|
||||
private let subtitle: String?
|
||||
private let artworkURL: URL?
|
||||
private let progress: Double?
|
||||
/// Meldet nach oben, wenn diese Karte den Fokus bekommt oder verliert.
|
||||
///
|
||||
/// **Nach aussen als Rückruf, innen als `@FocusState`.** Die Umgebung
|
||||
/// könnte es nicht beantworten — `\.isFocused` meint den nächsten
|
||||
/// fokussierbaren Vorfahren, und das ist in einer Reihe aus Karten nicht
|
||||
/// verlässlich diese. Wer es wissen will, bekommt es deshalb gesagt.
|
||||
private let onFocusChange: (Bool) -> Void
|
||||
private let action: () -> Void
|
||||
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
artworkURL: URL?,
|
||||
progress: Double? = nil,
|
||||
onFocusChange: @escaping (Bool) -> Void = { _ in },
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.artworkURL = artworkURL
|
||||
self.progress = progress
|
||||
self.onFocusChange = onFocusChange
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
TVCard(
|
||||
aspectRatio: HaloAspectRatio.landscape,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
artworkURL: artworkURL,
|
||||
progress: progress,
|
||||
isFocused: $isFocused,
|
||||
onFocusChange: onFocusChange,
|
||||
action: action
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Was beide gemeinsam haben.
|
||||
private struct TVCard: View {
|
||||
let aspectRatio: CGFloat
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let artworkURL: URL?
|
||||
let progress: Double?
|
||||
@FocusState.Binding var isFocused: Bool
|
||||
let onFocusChange: (Bool) -> Void
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||
Button(action: action) {
|
||||
HaloArtwork(url: artworkURL, aspectRatio: aspectRatio)
|
||||
.overlay(alignment: .bottom) {
|
||||
if let progress { WatchProgressBar(fraction: progress) }
|
||||
}
|
||||
.clipShape(.rect(cornerRadius: HaloRadius.card))
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.focused($isFocused)
|
||||
|
||||
// **Die Höhe wird immer belegt, der Text nicht immer gezeigt.**
|
||||
//
|
||||
// Sonst wächst die Karte beim Fokussieren nach unten und schiebt
|
||||
// die ganze Reihe — und man sieht nicht den Fokus, sondern ein
|
||||
// Zucken. Und dauerhaft beschriftet wären zwanzig Plakate aus drei
|
||||
// Metern eine Textwand.
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.opacity(isFocused ? 1 : 0)
|
||||
.animation(.easeOut(duration: 0.15), value: isFocused)
|
||||
}
|
||||
.onChange(of: isFocused) { _, neu in onFocusChange(neu) }
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Der dünne Balken unter angefangenen Titeln.
|
||||
public struct WatchProgressBar: View {
|
||||
/// 0…1. Werte außerhalb werden abgeschnitten, damit ein fehlerhafter
|
||||
/// Serverwert den Balken nicht über die Kachel hinausschieben kann.
|
||||
private let fraction: Double
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
public init(fraction: Double) {
|
||||
self.fraction = fraction.clamped()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack(alignment: .leading) {
|
||||
Rectangle().fill(Color.white.opacity(0.15))
|
||||
Rectangle()
|
||||
.fill(accent)
|
||||
.frame(width: proxy.size.width * fraction)
|
||||
}
|
||||
}
|
||||
.frame(height: 3)
|
||||
.accessibilityElement()
|
||||
.accessibilityLabel(Text("progress.watched", bundle: .module))
|
||||
.accessibilityValue(Text(fraction.formatted(.percent.precision(.fractionLength(0)))))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
|
||||
/// Die Geometrie eines Programmrasters: Zeit in Punkte.
|
||||
///
|
||||
/// Eigener Typ statt Rechnungen in der Ansicht, weil hier alles schiefgehen
|
||||
/// kann, was man nicht sieht: eine Sendung, die vor dem Fenster begann, säße
|
||||
/// bei negativem Versatz außerhalb; eine Kurzmeldung wäre zwei Punkte breit
|
||||
/// und unlesbar; ein leeres Raster hätte die Breite null. Geprüft werden
|
||||
/// diese Fälle nur, wenn sie außerhalb einer `View` stehen.
|
||||
public struct GuideLayout: Sendable, Equatable {
|
||||
/// Der Anfang des Fensters — die angebrochene halbe Stunde vor „jetzt".
|
||||
///
|
||||
/// Nicht „jetzt" selbst: dann trüge die Achse krumme Zeiten und die
|
||||
/// laufende Sendung wäre immer angeschnitten.
|
||||
public let start: Date
|
||||
public let end: Date
|
||||
public let pointsPerMinute: CGFloat
|
||||
/// Schmaler wird kein Block, egal wie kurz die Sendung ist.
|
||||
public let minimumBlockWidth: CGFloat
|
||||
|
||||
private static let halfHour: TimeInterval = 30 * 60
|
||||
|
||||
public init(
|
||||
now: Date,
|
||||
latestEnd: Date? = nil,
|
||||
pointsPerMinute: CGFloat = 4.4,
|
||||
minimumBlockWidth: CGFloat = 36,
|
||||
minimumHours: Double = 3,
|
||||
maximumHours: Double = 14
|
||||
) {
|
||||
let anchor = (now.timeIntervalSince1970 / Self.halfHour).rounded(.down) * Self.halfHour
|
||||
let start = Date(timeIntervalSince1970: anchor)
|
||||
self.start = start
|
||||
// Unten begrenzt, damit ein Raster mit einer einzigen bekannten
|
||||
// Sendung nicht zur Briefmarke wird; oben, damit es bei einem
|
||||
// Anbieter mit zwei Wochen Vorschau nicht endlos wird.
|
||||
let bounded = min(
|
||||
max(latestEnd ?? start, start.addingTimeInterval(minimumHours * 3_600)),
|
||||
start.addingTimeInterval(maximumHours * 3_600)
|
||||
)
|
||||
// Auch das Ende auf die halbe Stunde, nach oben. Sonst endet die
|
||||
// Achse mit einem Stummel, dessen Beschriftung keine Spalte mehr hat.
|
||||
self.end = Date(
|
||||
timeIntervalSince1970: (bounded.timeIntervalSince1970 / Self.halfHour).rounded(.up) * Self.halfHour
|
||||
)
|
||||
self.pointsPerMinute = pointsPerMinute
|
||||
self.minimumBlockWidth = minimumBlockWidth
|
||||
}
|
||||
|
||||
public var totalWidth: CGFloat {
|
||||
CGFloat(end.timeIntervalSince(start) / 60) * pointsPerMinute
|
||||
}
|
||||
|
||||
/// Der waagerechte Versatz eines Zeitpunkts.
|
||||
///
|
||||
/// Nie negativ: was vor dem Fenster liegt, klebt an dessen linkem Rand.
|
||||
public func x(for date: Date) -> CGFloat {
|
||||
max(0, CGFloat(date.timeIntervalSince(start) / 60) * pointsPerMinute)
|
||||
}
|
||||
|
||||
/// Die Breite einer Sendung, auf das Fenster beschnitten.
|
||||
public func width(from begin: Date, to finish: Date) -> CGFloat {
|
||||
let visibleStart = max(begin, start)
|
||||
let visibleEnd = min(finish, end)
|
||||
let minutes = max(0, visibleEnd.timeIntervalSince(visibleStart) / 60)
|
||||
return max(CGFloat(minutes) * pointsPerMinute, minimumBlockWidth)
|
||||
}
|
||||
|
||||
/// Die Beschriftungen der Zeitachse, halbstündlich.
|
||||
public var ticks: [Date] {
|
||||
stride(from: 0, to: end.timeIntervalSince(start), by: Self.halfHour)
|
||||
.map { start.addingTimeInterval($0) }
|
||||
}
|
||||
|
||||
/// Wie breit eine Achsenbeschriftung ist.
|
||||
public var tickWidth: CGFloat { CGFloat(Self.halfHour / 60) * pointsPerMinute }
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die Schriftskala der App.
|
||||
///
|
||||
/// Auf iOS die Systemstile, damit Dynamic Type wirkt. Die Rolle steht im
|
||||
/// Namen, nicht die Größe — sonst führt jede Anpassung zu einer Suche quer
|
||||
/// durch alle Views.
|
||||
///
|
||||
/// **Auf dem Mac stehen feste Größen.** Dieselben Stile sind dort deutlich
|
||||
/// kleiner: `largeTitle` misst 26 Punkt statt 34, `body` 13 statt 17. Eine
|
||||
/// Oberfläche, die auf dem iPad stimmt, wirkt damit auf dem Mac zusammen-
|
||||
/// geschrumpft — und genau das war sie. Die Zahlen hier sind die von iOS.
|
||||
///
|
||||
/// Das kostet auf dem Mac Dynamic Type. Das ist vertretbar: macOS hat keine
|
||||
/// systemweite Textgrößen-Einstellung, auf die Apps reagieren müssten, und ein
|
||||
/// Fenster lässt sich stattdessen größer ziehen.
|
||||
///
|
||||
/// **Auf dem Fernseher wieder Systemstile, aber andere Rollen.** Dynamic Type
|
||||
/// gibt es dort auch, und dass `body` grösser ausfällt als am Telefon, ist
|
||||
/// der Sinn der Sache — drei Meter Abstand. Falsch wäre nur, die iOS-Zuordnung
|
||||
/// einfach durchfallen zu lassen: die Skala ist dort anders gespreizt, und
|
||||
/// eine Beschriftung in `subheadline` unter einem Plakat ist aus drei Metern
|
||||
/// Zierrat.
|
||||
extension Font {
|
||||
#if os(macOS)
|
||||
/// Der Titel im Aufmacher-Bild.
|
||||
public static let haloHero = Font.system(size: 34, weight: .bold)
|
||||
/// Titel auf Detailseiten.
|
||||
public static let haloTitle = Font.system(size: 22, weight: .semibold)
|
||||
/// Überschrift einer Reihe auf der Startseite.
|
||||
public static let haloRowHeader = Font.system(size: 17, weight: .semibold)
|
||||
/// Titel unter einem Poster.
|
||||
public static let haloCardTitle = Font.system(size: 15, weight: .medium)
|
||||
/// Fließtext, Beschreibungen.
|
||||
public static let haloBody = Font.system(size: 17)
|
||||
/// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht.
|
||||
public static let haloCaption = Font.system(size: 12)
|
||||
#elseif os(tvOS)
|
||||
/// Der Titel im Aufmacher-Bild.
|
||||
///
|
||||
/// **`.title` und nicht `.largeTitle`.** In SwiftUI gibt es den Stil auf
|
||||
/// tvOS, in UIKit nicht — `UIFontTextStyleLargeTitle` ist dort als nicht
|
||||
/// verfügbar markiert. Was SwiftUI daraus macht, steht nirgends, und eine
|
||||
/// Schrift, deren Grösse niemand nennen kann, gehört nicht in die grösste
|
||||
/// Zeile der App.
|
||||
public static let haloHero = Font.system(.title, design: .default, weight: .bold)
|
||||
/// Titel auf Detailseiten.
|
||||
public static let haloTitle = Font.system(.title2, design: .default, weight: .semibold)
|
||||
/// Überschrift einer Reihe auf der Startseite.
|
||||
public static let haloRowHeader = Font.system(.headline, design: .default, weight: .semibold)
|
||||
/// Titel unter einem Poster — auf tvOS nur bei Fokus sichtbar.
|
||||
///
|
||||
/// Eine Stufe tiefer als auf iOS, und das ist gewollt: auf tvOS ist
|
||||
/// `caption` schon gross, und ein Raster aus sechs Spalten mit
|
||||
/// `subheadline`-Titeln hätte mehr Schrift als Bild.
|
||||
public static let haloCardTitle = Font.system(.caption, design: .default, weight: .medium)
|
||||
/// Fließtext, Beschreibungen.
|
||||
public static let haloBody = Font.system(.body)
|
||||
/// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht.
|
||||
public static let haloCaption = Font.system(.caption2)
|
||||
#else
|
||||
/// Der Titel im Aufmacher-Bild.
|
||||
public static let haloHero = Font.system(.largeTitle, design: .default, weight: .bold)
|
||||
/// Titel auf Detailseiten.
|
||||
public static let haloTitle = Font.system(.title2, design: .default, weight: .semibold)
|
||||
/// Überschrift einer Reihe auf der Startseite.
|
||||
public static let haloRowHeader = Font.system(.headline, design: .default, weight: .semibold)
|
||||
/// Titel unter einem Poster.
|
||||
public static let haloCardTitle = Font.system(.subheadline, design: .default, weight: .medium)
|
||||
/// Fließtext, Beschreibungen.
|
||||
public static let haloBody = Font.system(.body)
|
||||
/// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht.
|
||||
public static let haloCaption = Font.system(.caption)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Abstände.
|
||||
///
|
||||
/// Eine Skala statt frei gewählter Zahlen: dadurch stimmen die Ränder zwischen
|
||||
/// Bildschirmen überein, ohne dass man sie einzeln nachmisst.
|
||||
public enum HaloSpacing {
|
||||
#if os(tvOS)
|
||||
// Durchweg doppelt: ein Fernseher ist 1920 Punkte breit und wird aus drei
|
||||
// Metern gelesen. Acht Punkte Abstand sind dort keine Trennung mehr.
|
||||
public static let tight: CGFloat = 8
|
||||
public static let snug: CGFloat = 16
|
||||
public static let regular: CGFloat = 24
|
||||
public static let loose: CGFloat = 32
|
||||
public static let section: CGFloat = 48
|
||||
/// Der seitliche Rand des Inhalts — **vom Bildschirmrand aus**.
|
||||
///
|
||||
/// Achtzig Punkte Überscan plus achtundvierzig Punkte Rinne.
|
||||
///
|
||||
/// **Warum die achtzig hier drinstehen.** SwiftUI legt sie von selbst als
|
||||
/// Sicherheitsrand an, und normalerweise nähme man sie geschenkt. Der
|
||||
/// Aufmacher muss aber bis an die Bildschirmkante reichen — ein Bild, das
|
||||
/// achtzig Punkte vor dem Rand aufhört, sieht auf einem Fernseher nach
|
||||
/// Fehler aus. Also laufen die Seiten dieser App bildfüllend
|
||||
/// (`ignoresSafeArea`), und dann muss der Rand hier wieder dazu.
|
||||
///
|
||||
/// Das heisst zugleich: wer diesen Wert benutzt, ignoriert den
|
||||
/// Sicherheitsrand. Beides gehört zusammen.
|
||||
public static let screenMargin: CGFloat = 128
|
||||
#else
|
||||
public static let tight: CGFloat = 4
|
||||
public static let snug: CGFloat = 8
|
||||
public static let regular: CGFloat = 12
|
||||
public static let loose: CGFloat = 16
|
||||
public static let section: CGFloat = 24
|
||||
/// Der seitliche Rand des Inhalts zum Bildschirmrand.
|
||||
public static let screenMargin: CGFloat = 16
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Der Überscan-Bereich eines Fernsehers.
|
||||
///
|
||||
/// **Was das ist.** Ein Fernseher zeigt nicht zwingend das ganze Bild: je nach
|
||||
/// Gerät und Einstellung schneidet er am Rand ab, und was dort steht, sieht
|
||||
/// niemand.
|
||||
///
|
||||
/// **Nachgemessen, nicht abgeschrieben.** Im Simulator eines Apple TV 4K
|
||||
/// meldet SwiftUI bei 1920×1080 genau diese Ränder — sechzig oben und unten,
|
||||
/// **achtzig** seitlich, Nutzfläche also 1760×960. Die verbreitete Angabe
|
||||
/// „neunzig" stimmt nicht; eine zweite Quelle nannte achtzig und hatte recht.
|
||||
///
|
||||
/// **Und man bekommt sie geschenkt.** SwiftUI legt sie von selbst als
|
||||
/// Sicherheitsrand an — deshalb steht in `HaloSpacing.screenMargin` nur die
|
||||
/// Rinne innerhalb davon. Die Zahl hier ist für die Gegenrichtung: ein
|
||||
/// bildfüllender Aufmacher, der den Sicherheitsrand absichtlich übergeht und
|
||||
/// dessen Schrift trotzdem drinbleiben muss.
|
||||
public enum HaloSafeArea {
|
||||
public static let tv = EdgeInsets(top: 60, leading: 80, bottom: 60, trailing: 80)
|
||||
}
|
||||
|
||||
public enum HaloRadius {
|
||||
/// Die Ecke einer Kachel.
|
||||
///
|
||||
/// Auf dem Fernseher grösser: eine Kachel, die sich beim Fokus anhebt,
|
||||
/// wirkt mit acht Punkten bei 1920 Punkten Breite kantig.
|
||||
#if os(tvOS)
|
||||
public static let card: CGFloat = 12
|
||||
#else
|
||||
public static let card: CGFloat = 8
|
||||
#endif
|
||||
public static let sheet: CGFloat = 16
|
||||
public static let control: CGFloat = 10
|
||||
/// Eine schwebende Fläche — die Seitenleiste. So rund wie das System sie
|
||||
/// auf dem iPad zeichnet; daran misst sich der Nachbau.
|
||||
public static let panel: CGFloat = 22
|
||||
}
|
||||
|
||||
/// Die Seitenverhältnisse, in denen Plex seine Bilder liefert.
|
||||
///
|
||||
/// An einer Stelle, weil ein falscher Wert nicht auffällt, sondern nur
|
||||
/// beschneidet: ein 16:9-Hintergrundbild in einem 3:4-Rahmen verliert links
|
||||
/// und rechts je ein Viertel, und man sieht dem Ergebnis nicht an, dass etwas
|
||||
/// fehlt.
|
||||
public enum HaloAspectRatio {
|
||||
/// Kinoplakate im Hochformat.
|
||||
public static let poster: CGFloat = 2.0 / 3.0
|
||||
/// Szenenbilder und Hintergrundbilder.
|
||||
public static let landscape: CGFloat = 16.0 / 9.0
|
||||
/// Der Aufmacher auf der Startseite — dasselbe wie `landscape`, weil Plex
|
||||
/// dafür `art` liefert.
|
||||
public static let hero: CGFloat = landscape
|
||||
/// Der Aufmacher auf einem schmalen Bildschirm.
|
||||
///
|
||||
/// **Quer, aber beschnitten — und beides hat einen Grund.**
|
||||
///
|
||||
/// Quer, weil dort ein Hintergrundbild liegt. Vorher lag hier ein Plakat
|
||||
/// (2:3), und das ist für einen Aufmacher das falsche Bild: ein Plakat
|
||||
/// trägt den Titel schon selbst, und darüber liegt dann noch der
|
||||
/// freigestellte Schriftzug — zweimal derselbe Name.
|
||||
///
|
||||
/// Beschnitten, weil 16:9 auf einem iPhone keine zweihundertfünfzig
|
||||
/// Punkte Höhe sind, und darin sollen Schriftzug, Zeile mit Jahr und
|
||||
/// Länge, ein Satz Beschreibung und zwei Knöpfe Platz finden. Sie fanden
|
||||
/// ihn nicht und lagen übereinander. 4:3 gibt dem Bild rund ein Drittel
|
||||
/// mehr Höhe; dafür fehlt ihm links und rechts zusammen ein Viertel.
|
||||
public static let heroCompact: CGFloat = 4.0 / 3.0
|
||||
|
||||
/// Sendersymbole. Breiter als hoch, weil Senderlogos Schriftzüge sind —
|
||||
/// quadratisch beschnitten fehlt der halbe Name.
|
||||
public static let channelLogo: CGFloat = 5.0 / 3.0
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Hält die gewählte Akzentfarbe und sorgt dafür, dass sie lesbar bleibt.
|
||||
///
|
||||
/// Nach außen gibt es nur `accent` — schon kontrastkorrigiert. Views sollen
|
||||
/// sich nie fragen müssen, ob die eingestellte Farbe dunkel genug ist, um
|
||||
/// darauf zu achten.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class HaloTheme {
|
||||
private let defaults: UserDefaults
|
||||
|
||||
/// Was in den Einstellungen ausgewählt ist — unkorrigiert, damit die
|
||||
/// Farbkachel genau das zeigt, was angetippt wurde.
|
||||
public private(set) var selection: AccentOption
|
||||
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
self.selection = AccentOption.stored(in: defaults)
|
||||
}
|
||||
|
||||
/// Die Farbe, die tatsächlich gezeichnet wird.
|
||||
///
|
||||
/// Bei den kuratierten Werten ändert die Korrektur nichts — sie erfüllen
|
||||
/// den Grenzwert bereits. Sie greift nur bei eigenen Farben.
|
||||
public var accent: RGB {
|
||||
Contrast.legible(selection.color, on: HaloColor.canvas)
|
||||
}
|
||||
|
||||
public var accentColor: Color { accent.swiftUIColor }
|
||||
|
||||
/// Ob die Korrektur bei der aktuellen Auswahl eingegriffen hat. Der
|
||||
/// Einstellungsdialog kann darauf hinweisen, statt den Nutzer rätseln zu
|
||||
/// lassen, warum seine Farbe heller aussieht als gewählt.
|
||||
public var accentWasAdjusted: Bool { accent != selection.color }
|
||||
|
||||
public func select(_ option: AccentOption) {
|
||||
selection = option
|
||||
option.store(in: defaults)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"Amber" : {
|
||||
"comment" : "Name of the accent color option.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Custom" : {
|
||||
"comment" : "Name of the accent color option when the user has selected a custom color.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ember" : {
|
||||
"comment" : "Name of the accent color \"Ember\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Indigo" : {
|
||||
"comment" : "Name of the accent color option \"Indigo\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Lilac" : {
|
||||
"comment" : "Name of the accent color option.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Lime" : {
|
||||
"comment" : "Name of the accent color \"Lime\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Mint" : {
|
||||
"comment" : "Name of the accent color \"Mint\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Orchid" : {
|
||||
"comment" : "Name of the accent color \"Orchid\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"progress.watched" : {
|
||||
"comment" : "Barrierefreiheits-Beschriftung des Fortschrittsbalkens unter angefangenen Titeln",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Fortschritt"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Progress"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Rose" : {
|
||||
"comment" : "Text for a color name.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Sky" : {
|
||||
|
||||
},
|
||||
"Tangerine" : {
|
||||
"comment" : "Name of the accent color option \"Tangerine\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Teal" : {
|
||||
"comment" : "Name of the accent color teal.",
|
||||
"isCommentAutoGenerated" : true
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
|
||||
/// Wo die schwebende Videokachel sitzt.
|
||||
///
|
||||
/// Sie klebt an der linken oder rechten Seite und ist dort **frei in der
|
||||
/// Höhe** — nicht auf vier Ecken festgelegt. Vier Ecken sind schnell gebaut,
|
||||
/// aber im Weg: steht die Kachel unten rechts über einem Knopf, bleibt nur
|
||||
/// die Wahl zwischen vier Orten, von denen drei ebenso stören.
|
||||
///
|
||||
/// Eigener Typ statt Rechnungen in der Ansicht, aus demselben Grund wie bei
|
||||
/// `GuideLayout`: hier geht lautlos schief, was man nicht sieht. Eine Kachel,
|
||||
/// die halb aus dem Bild ragt, fällt erst auf dem Gerät auf — und dann als
|
||||
/// Gefühl, nicht als Fehler.
|
||||
public struct MiniPlayerPlacement: Sendable, Equatable {
|
||||
public enum Side: String, Sendable, CaseIterable, Codable {
|
||||
case leading, trailing
|
||||
}
|
||||
|
||||
/// Der sichtbare Bereich, in dem die Kachel sich bewegen darf.
|
||||
public let bounds: CGSize
|
||||
public let tile: CGSize
|
||||
/// Abstand zum Rand. Eine Kachel, die den Rand berührt, sieht aus wie ein
|
||||
/// Fehler statt wie eine Entscheidung.
|
||||
public let inset: CGFloat
|
||||
|
||||
public init(bounds: CGSize, tile: CGSize, inset: CGFloat = HaloSpacing.regular) {
|
||||
self.bounds = bounds
|
||||
self.tile = tile
|
||||
self.inset = inset
|
||||
}
|
||||
|
||||
private var halfWidth: CGFloat { tile.width / 2 }
|
||||
private var halfHeight: CGFloat { tile.height / 2 }
|
||||
|
||||
private var minimumX: CGFloat { inset + halfWidth }
|
||||
private var maximumX: CGFloat { max(minimumX, bounds.width - inset - halfWidth) }
|
||||
private var minimumY: CGFloat { inset + halfHeight }
|
||||
private var maximumY: CGFloat { max(minimumY, bounds.height - inset - halfHeight) }
|
||||
|
||||
/// An welche Seite eine losgelassene Kachel gehört.
|
||||
///
|
||||
/// Entschieden wird nach dem **Mittelpunkt** der Kachel, nicht nach dem
|
||||
/// Finger: sonst springt eine Kachel, die man am Rand angefasst hat, auf
|
||||
/// die falsche Seite.
|
||||
public func nearestSide(toCenter center: CGPoint) -> Side {
|
||||
center.x < bounds.width / 2 ? .leading : .trailing
|
||||
}
|
||||
|
||||
/// Wie weit unten die Kachel sitzt: 0 ganz oben, 1 ganz unten.
|
||||
///
|
||||
/// Als Anteil statt als Punktwert, damit die Kachel beim Drehen des Geräts
|
||||
/// an derselben *relativen* Stelle bleibt, statt unten herauszufallen.
|
||||
public func verticalFraction(forCenter center: CGPoint) -> Double {
|
||||
let travel = maximumY - minimumY
|
||||
guard travel > 0 else { return 0 }
|
||||
return min(max(Double((center.y - minimumY) / travel), 0), 1)
|
||||
}
|
||||
|
||||
public func center(side: Side, verticalFraction: Double) -> CGPoint {
|
||||
let fraction = min(max(verticalFraction, 0), 1)
|
||||
return CGPoint(
|
||||
x: side == .leading ? minimumX : maximumX,
|
||||
y: minimumY + (maximumY - minimumY) * CGFloat(fraction)
|
||||
)
|
||||
}
|
||||
|
||||
/// Hält einen Mittelpunkt im sichtbaren Bereich.
|
||||
///
|
||||
/// Während des Ziehens, damit die Kachel nicht hinter den Rand rutscht und
|
||||
/// dort unerreichbar wird — der Finger wäre dann schneller als das Ziel.
|
||||
public func clamped(center: CGPoint) -> CGPoint {
|
||||
CGPoint(
|
||||
// `max(min…)` in dieser Reihenfolge, weil bei einem Bereich, der
|
||||
// schmaler ist als die Kachel, sonst der untere Anschlag verliert
|
||||
// und die Kachel rechts hinausragt.
|
||||
x: max(minimumX, min(maximumX, center.x)),
|
||||
y: max(minimumY, min(maximumY, center.y))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Was auf iPhone, Mac und Fernseher verschieden heißt — an einer Stelle.
|
||||
///
|
||||
/// Nicht jede Ansicht soll `#if os(iOS)` tragen. Ein Formular, das auf beiden
|
||||
/// Plattformen dasselbe meint, soll auf beiden gleich aussehen; wo die
|
||||
/// Systeme verschiedene Namen dafür haben, steht der Unterschied hier und
|
||||
/// nirgends sonst. Sonst wandert er in zwanzig Dateien und wird beim
|
||||
/// nächsten Bildschirm vergessen.
|
||||
/// Auf was für einem Gerät läuft das hier gerade?
|
||||
public enum HaloPlatform {
|
||||
/// Läuft die App auf einem Mac?
|
||||
///
|
||||
/// **Zwei Wege führen dorthin, und für den Übersetzer sehen sie
|
||||
/// verschieden aus.** Catalyst ist ein eigener Bau und meldet sich als
|
||||
/// `targetEnvironment(macCatalyst)`. „Für iPad angepasst" ist dagegen
|
||||
/// buchstäblich der iOS-Bau, der auf Apple Silicon startet — dort ist
|
||||
/// dieselbe Abfrage schlicht falsch.
|
||||
///
|
||||
/// Wer nur die erste Hälfte prüft, schreibt Code für den Mac, der auf dem
|
||||
/// Mac nicht läuft: übersetzt sauber, ist aber genau dort abgeschaltet,
|
||||
/// wo er gebraucht wird. Deshalb hier beide Wege, an einer Stelle.
|
||||
///
|
||||
/// **Und warum unten `#error` steht.** Der `#else`-Zweig lautete `true` —
|
||||
/// eine Antwort auf eine Frage, die niemand gestellt hatte. Auf tvOS fiel
|
||||
/// er deshalb hindurch und meldete einen Mac: kein Übersetzungsfehler,
|
||||
/// keine Warnung, nur ein Fernseher, der sich für einen Mac hielt. Eine
|
||||
/// Vorgabe ist an dieser Stelle immer falsch, weil die nächste Plattform
|
||||
/// nicht danach fragt, ob sie gemeint war. Jetzt bricht der Bau ab und
|
||||
/// verlangt eine Entscheidung.
|
||||
public static let isMac: Bool = {
|
||||
#if os(tvOS)
|
||||
false
|
||||
#elseif targetEnvironment(macCatalyst)
|
||||
true
|
||||
#elseif os(iOS)
|
||||
ProcessInfo.processInfo.isiOSAppOnMac
|
||||
#elseif os(macOS)
|
||||
true
|
||||
#else
|
||||
#error("HaloPlatform kennt diese Plattform nicht. Hier eintragen — nicht raten.")
|
||||
#endif
|
||||
}()
|
||||
|
||||
/// Läuft die App auf einem Fernseher?
|
||||
///
|
||||
/// Daneben und nicht in einem Aufzählungstyp: über die Frage wird
|
||||
/// nirgends verzweigt, sie steht überall in einem Fragezeichen-Ausdruck.
|
||||
/// Ein Aufzählungstyp wäre Zeremonie — und „iPhone oder iPad" liesse sich
|
||||
/// darin ohnehin nicht beantworten, weil das `UIDevice.current` verlangt
|
||||
/// und damit den Hauptakteur.
|
||||
public static let isTV: Bool = {
|
||||
#if os(tvOS)
|
||||
true
|
||||
#elseif os(iOS) || os(macOS)
|
||||
false
|
||||
#else
|
||||
#error("HaloPlatform kennt diese Plattform nicht. Hier eintragen — nicht raten.")
|
||||
#endif
|
||||
}()
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Eine schmale Titelzeile — auf dem Mac und auf dem Fernseher gibt es
|
||||
/// sie nicht. Beide fallen in denselben `#else`-Zweig, und beide zu Recht.
|
||||
public func haloInlineTitle() -> some View {
|
||||
#if os(iOS)
|
||||
navigationBarTitleDisplayMode(.inline)
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Ein Sheet, das die halbe Höhe einnimmt.
|
||||
///
|
||||
/// Auf dem Mac ist ein Sheet ein Fenster: dort zählt keine Rastung,
|
||||
/// sondern eine vernünftige Mindestgröße.
|
||||
public func haloMediumSheet() -> some View {
|
||||
#if os(tvOS)
|
||||
// Auf dem Fernseher ist ein Sheet immer bildfüllend. `presentationDetents`
|
||||
// übersetzt dort zwar — es gibt sie seit tvOS 16 —, bewirkt aber
|
||||
// nichts. Und eine Zeile, die nichts bewirkt, liest sich beim nächsten
|
||||
// Mal wie eine, die etwas bewirkt.
|
||||
self
|
||||
#else
|
||||
// **Zur Laufzeit, nicht beim Übersetzen.** Der `#else`-Zweig für
|
||||
// macOS griff nie: auf dem Mac läuft der iOS-Bau, und damit bekam ein
|
||||
// grosses Fenster eine Sheet-Kante in iPhone-Höhe — ein schmaler
|
||||
// Streifen, der unten im Nichts klebt.
|
||||
presentationDetents(HaloPlatform.isMac ? [.large] : [.medium])
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Dasselbe, aber auf Wunsch bis zur vollen Höhe aufziehbar.
|
||||
public func haloResizableSheet() -> some View {
|
||||
#if os(tvOS)
|
||||
self
|
||||
#else
|
||||
// Siehe oben. Auf dem Mac gibt es kein „halb aufgezogen": ein Fenster
|
||||
// hat die Höhe, die es hat, und eine Liste soll sie nutzen.
|
||||
presentationDetents(HaloPlatform.isMac ? [.large] : [.medium, .large])
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Ein Formular, das auf beiden Seiten gleich aussieht.
|
||||
///
|
||||
/// Auf tvOS gibt es `formStyle` nicht und es ist auch nicht nötig — dort
|
||||
/// fällt es in denselben Zweig wie iOS.
|
||||
///
|
||||
/// Auf dem Mac setzt SwiftUI sonst den Spalten-Stil: Beschriftungen rechts
|
||||
/// ausgerichtet, keine abgesetzten Abschnitte, alles eng. Der gruppierte
|
||||
/// Stil ist derselbe, den iOS ohnehin nimmt — und der Grund, warum die
|
||||
/// Einstellungen auf dem Mac wie ein Systemdialog aus einem anderen
|
||||
/// Jahrzehnt aussahen.
|
||||
public func haloForm() -> some View {
|
||||
#if os(macOS)
|
||||
formStyle(.grouped)
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Eingabefelder für Adressen und Namen: keine Großschreibung, keine
|
||||
/// Korrektur — auf dem Mac gibt es beides ohnehin nicht als Tastatur.
|
||||
///
|
||||
/// Auf tvOS ebenfalls der `#else`-Zweig: die Bildschirmtastatur dort
|
||||
/// kennt weder `keyboardType` noch Autokorrektur in dieser Form.
|
||||
public func haloPlainEntry(isAddress: Bool = false) -> some View {
|
||||
#if os(iOS)
|
||||
self
|
||||
.keyboardType(isAddress ? .URL : .default)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
#else
|
||||
autocorrectionDisabled()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Keine eigene Fläche hinter der Titelzeile.
|
||||
///
|
||||
/// Auf der Startseite liegt darunter der Aufmacher — eine weichgezeichnete
|
||||
/// Leiste verschleierte die Hälfte davon. Auf dem Mac und auf dem
|
||||
/// Fernseher gibt es die Leiste in dieser Form nicht — beide fallen in
|
||||
/// den `#else`-Zweig.
|
||||
public func haloClearToolbarBackground() -> some View {
|
||||
#if os(iOS)
|
||||
self
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Etwas in die Zwischenablage legen.
|
||||
///
|
||||
/// UIKit und AppKit heißen verschieden und meinen dasselbe; die Diagnose soll
|
||||
/// deswegen nicht mit `#if` beginnen.
|
||||
public enum HaloClipboard {
|
||||
public static func copy(_ text: String) {
|
||||
#if os(tvOS)
|
||||
// Es gibt keine. `UIPasteboard` ist auf tvOS als nicht verfügbar
|
||||
// markiert — und ein Fernseher hat auch nichts, wohin man etwas
|
||||
// einfügen könnte.
|
||||
//
|
||||
// **Die Reihenfolge ist der ganze Punkt.** `canImport(UIKit)` ist auf
|
||||
// tvOS wahr; stünde dieser Zweig darunter, wäre es kein stiller
|
||||
// Fehler, sondern ein lauter.
|
||||
_ = text
|
||||
#elseif canImport(UIKit)
|
||||
UIPasteboard.general.string = text
|
||||
#else
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(text, forType: .string)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension ToolbarItemPlacement {
|
||||
/// Rechts in der Titelzeile.
|
||||
public static var haloTrailing: ToolbarItemPlacement {
|
||||
#if os(iOS) || os(tvOS)
|
||||
.topBarTrailing
|
||||
#else
|
||||
.primaryAction
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Links in der Titelzeile.
|
||||
public static var haloLeading: ToolbarItemPlacement {
|
||||
#if os(iOS) || os(tvOS)
|
||||
.topBarLeading
|
||||
#else
|
||||
.navigation
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
|
||||
/// Wie schnell ein Download gerade läuft — und wie lange er noch braucht.
|
||||
///
|
||||
/// **Ein Wertetyp mit Uhr von außen.** Die Rückmeldungen der Übertragung
|
||||
/// kommen mehrmals je Sekunde; was daraus eine Zahl macht, ist reine Rechnung
|
||||
/// und gehört nicht in den Koordinator. Die Zeitpunkte kommen als Argument
|
||||
/// herein, damit sich eine Übertragung ohne echte Uhr nachstellen lässt — eine
|
||||
/// Rate, die man nur im Betrieb beurteilen kann, ist keine prüfbare Zahl.
|
||||
///
|
||||
/// **Warum geglättet.** Zwischen zwei Rückmeldungen schwankt die rohe
|
||||
/// Differenz um ein Vielfaches: ein Puffer läuft voll, der Funk hat ein Loch,
|
||||
/// das System hält die Hintergrundsitzung kurz an. Ungeglättet stünde dort
|
||||
/// eine Zahl, die zehnmal je Sekunde springt — und eine springende Zahl ist
|
||||
/// keine Auskunft, sondern Unruhe.
|
||||
public struct DownloadRate: Sendable, Equatable {
|
||||
/// Wie stark ein neuer Messwert gegenüber dem bisherigen zählt.
|
||||
///
|
||||
/// Ein Drittel: schnell genug, dass ein echter Einbruch binnen weniger
|
||||
/// Sekunden ankommt, träge genug, dass einzelne Ausreißer untergehen.
|
||||
private static let weight = 1.0 / 3.0
|
||||
|
||||
/// Kürzere Abstände werden nicht gemessen.
|
||||
///
|
||||
/// Über fünfzig Millisekunden gerechnet entscheidet der Zufall, wann das
|
||||
/// System den Puffer leert — nicht die Leitung.
|
||||
static let minimumInterval: Duration = .milliseconds(500)
|
||||
|
||||
/// Ab dieser Lücke beginnt die Messung von vorn.
|
||||
///
|
||||
/// So lange ohne eine einzige Rückmeldung heißt nicht „langsam", sondern
|
||||
/// „stand still": die App war angehalten, das Gerät gesperrt, das Netz
|
||||
/// weg. Diese Pause in die Rate einzurechnen ergäbe eine Zahl nahe null
|
||||
/// und eine Restzeit von Stunden.
|
||||
static let maximumInterval: Duration = .seconds(10)
|
||||
|
||||
/// Ein Messpunkt: wie viel war wann da.
|
||||
///
|
||||
/// Als Typ und nicht als Tupel — ein Tupel kann `Equatable` nicht erben,
|
||||
/// und ohne das wäre der ganze Wertetyp nicht vergleichbar.
|
||||
private struct Anchor: Equatable {
|
||||
var bytes: Int64
|
||||
var at: ContinuousClock.Instant
|
||||
}
|
||||
|
||||
/// Der letzte Messpunkt, gegen den gerechnet wird.
|
||||
private var anchor: Anchor?
|
||||
private var smoothed: Double?
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Bytes je Sekunde — `nil`, solange nichts gemessen wurde.
|
||||
///
|
||||
/// **Nicht null.** Null wäre eine Auskunft („es geht nichts durch"), und
|
||||
/// die stimmt in der ersten halben Sekunde nie.
|
||||
public var bytesPerSecond: Double? { smoothed }
|
||||
|
||||
/// Nimmt den Stand der Übertragung entgegen.
|
||||
public mutating func record(bytes: Int64, at now: ContinuousClock.Instant) {
|
||||
guard let anchor else {
|
||||
self.anchor = Anchor(bytes: bytes, at: now)
|
||||
return
|
||||
}
|
||||
|
||||
let elapsed = now - anchor.at
|
||||
guard elapsed >= Self.minimumInterval else { return }
|
||||
defer { self.anchor = Anchor(bytes: bytes, at: now) }
|
||||
|
||||
// Rückwärts heißt: eine neue Aufgabe fängt von vorn an. Dann ist jede
|
||||
// bisherige Messung ungültig — und eine negative Rate wäre Unsinn.
|
||||
guard bytes >= anchor.bytes, elapsed <= Self.maximumInterval else {
|
||||
smoothed = nil
|
||||
return
|
||||
}
|
||||
|
||||
let seconds = elapsed.inSeconds
|
||||
guard seconds > 0 else { return }
|
||||
|
||||
let gemessen = Double(bytes - anchor.bytes) / seconds
|
||||
smoothed = smoothed.map { $0 + (gemessen - $0) * Self.weight } ?? gemessen
|
||||
}
|
||||
|
||||
/// Wie lange es bei dieser Rate noch dauert.
|
||||
///
|
||||
/// `nil`, solange keine Rate feststeht oder die Gesamtgröße unbekannt ist.
|
||||
/// Eine Restzeit ohne Rate wäre geraten, und geraten sieht man ihr nicht
|
||||
/// an.
|
||||
public func remaining(received: Int64, total: Int64) -> Duration? {
|
||||
guard let smoothed, smoothed > 0, total > received else { return nil }
|
||||
return .seconds(Double(total - received) / smoothed)
|
||||
}
|
||||
}
|
||||
|
||||
extension Duration {
|
||||
/// Die Dauer als Sekunden.
|
||||
///
|
||||
/// `components` liefert ganze Sekunden und Attosekunden getrennt; für eine
|
||||
/// Division braucht es beides in einer Zahl.
|
||||
var inSeconds: Double {
|
||||
Double(components.seconds) + Double(components.attoseconds) / 1e18
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// Der Lebenslauf eines Downloads.
|
||||
///
|
||||
/// Bewusst als geschlossene Aufzählung mit einer geprüften Übergangstabelle:
|
||||
/// die Engine läuft im Hintergrund weiter, während die App beendet ist, und
|
||||
/// beim nächsten Start muss aus dem gespeicherten Zustand eindeutig
|
||||
/// hervorgehen, was zu tun ist.
|
||||
public enum DownloadState: String, Sendable, Equatable, Codable, CaseIterable {
|
||||
/// Steht in der Warteschlange, hat noch keinen Platz bekommen.
|
||||
case queued
|
||||
/// Überträgt gerade.
|
||||
case downloading
|
||||
/// Vom Nutzer angehalten — der Wiederaufnahme-Datensatz liegt bereit.
|
||||
case paused
|
||||
/// Wartet auf Bedingungen: WLAN fehlt, Speicherlimit erreicht.
|
||||
case waiting
|
||||
case completed
|
||||
case failed
|
||||
|
||||
/// Ob aus diesem Zustand heraus übertragen werden darf.
|
||||
public var isTransferable: Bool {
|
||||
switch self {
|
||||
case .queued, .waiting: true
|
||||
case .downloading, .paused, .completed, .failed: false
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob der Eintrag Platz auf dem Gerät belegt.
|
||||
public var occupiesStorage: Bool {
|
||||
switch self {
|
||||
// Angehaltene und laufende Downloads haben bereits Daten auf der
|
||||
// Platte. Wer sie beim Speicherlimit nicht mitzählt, wundert sich,
|
||||
// warum das Gerät trotz gesetzter Grenze volläuft.
|
||||
case .downloading, .paused, .completed: true
|
||||
case .queued, .waiting, .failed: false
|
||||
}
|
||||
}
|
||||
|
||||
public func canTransition(to next: DownloadState) -> Bool {
|
||||
switch (self, next) {
|
||||
case (.queued, .downloading), (.queued, .waiting), (.queued, .failed):
|
||||
true
|
||||
case (.waiting, .downloading), (.waiting, .queued), (.waiting, .failed):
|
||||
true
|
||||
case (.downloading, .paused), (.downloading, .completed),
|
||||
(.downloading, .failed), (.downloading, .waiting):
|
||||
true
|
||||
case (.paused, .queued), (.paused, .downloading), (.paused, .failed):
|
||||
true
|
||||
// Ein gescheiterter Download darf neu in die Warteschlange — das ist
|
||||
// der Wiederholen-Knopf.
|
||||
case (.failed, .queued):
|
||||
true
|
||||
case (.completed, _):
|
||||
false
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
|
||||
// Catalyst ausdrücklich ausgenommen: dort gilt `os(iOS)`, aber ActivityKit
|
||||
// gibt es nicht — die Typen sind zwar da und jeder Aufruf ist als nicht
|
||||
// verfügbar markiert.
|
||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
||||
// `@preconcurrency`, weil `Activity` und `ActivityContent` keine
|
||||
// Sendable-Zusage tragen. Beide bleiben hier ohnehin auf dem Hauptakteur —
|
||||
// die Klasse ist darauf festgelegt, und ActivityKit erwartet seine Aufrufe
|
||||
// von dort. Ohne diese Zeile lehnt Swift 6 jeden Aufruf ab.
|
||||
@preconcurrency import ActivityKit
|
||||
|
||||
/// Zeigt den Download-Fortschritt im Sperrbildschirm.
|
||||
///
|
||||
/// Getrennt vom Koordinator, weil Live Activities eine Reihe eigener Fallen
|
||||
/// haben — sie können vom Nutzer abgeschaltet sein, ihre Zahl ist begrenzt,
|
||||
/// und eine nicht beendete Activity bleibt stundenlang stehen. Der Koordinator
|
||||
/// meldet nur „das läuft gerade"; was daraus wird, entscheidet sich hier.
|
||||
@MainActor
|
||||
public final class DownloadActivityPresenter {
|
||||
private var activity: Activity<DownloadActivityAttributes>?
|
||||
/// Wann zuletzt aktualisiert wurde.
|
||||
///
|
||||
/// ActivityKit drosselt häufige Aktualisierungen und verwirft sie
|
||||
/// irgendwann kommentarlos. Der Fortschritt kommt mehrmals je Sekunde —
|
||||
/// ungebremst weitergereicht bliebe die Anzeige irgendwann stehen.
|
||||
private var lastUpdate = Date.distantPast
|
||||
private let minimumInterval: TimeInterval = 1.5
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Bringt die Anzeige auf den neuesten Stand.
|
||||
///
|
||||
/// `nil` beendet sie — das ist der Fall, wenn nichts mehr läuft.
|
||||
/// `async`, nicht `Task { … }` im Inneren: `Activity` und
|
||||
/// `ActivityContent` sind nicht `Sendable`, und eine Aufgabe würde sie über
|
||||
/// die Isolationsgrenze schicken. So bleibt alles auf dem Hauptakteur.
|
||||
public func update(with state: DownloadActivityAttributes.ContentState?) async {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||
|
||||
guard let state else {
|
||||
await end()
|
||||
return
|
||||
}
|
||||
|
||||
if let activity {
|
||||
guard Date().timeIntervalSince(lastUpdate) >= minimumInterval else { return }
|
||||
lastUpdate = Date()
|
||||
await activity.update(ActivityContent(state: state, staleDate: nil))
|
||||
} else {
|
||||
start(with: state)
|
||||
}
|
||||
}
|
||||
|
||||
private func start(with state: DownloadActivityAttributes.ContentState) {
|
||||
do {
|
||||
activity = try Activity.request(
|
||||
attributes: DownloadActivityAttributes(),
|
||||
content: ActivityContent(state: state, staleDate: nil)
|
||||
)
|
||||
lastUpdate = Date()
|
||||
} catch {
|
||||
// Scheitert meist, weil die Höchstzahl gleichzeitiger Activities
|
||||
// erreicht ist. Kein Grund, den Download anzuhalten — die Anzeige
|
||||
// ist Beiwerk.
|
||||
activity = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Beendet die Anzeige.
|
||||
public func end() async {
|
||||
guard let activity else { return }
|
||||
self.activity = nil
|
||||
// `.immediate`: eine Download-Anzeige, die nach dem letzten Byte noch
|
||||
// eine Viertelstunde im Sperrbildschirm klebt, ist Müll.
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,801 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
import Observation
|
||||
import SwiftData
|
||||
|
||||
/// Was ein Download braucht, um angelegt zu werden.
|
||||
public struct DownloadPlan: Sendable {
|
||||
public var ratingKey: String
|
||||
public var title: String
|
||||
public var subtitle: String?
|
||||
public var sourceURL: URL
|
||||
public var seriesKey: String?
|
||||
public var seriesTitle: String?
|
||||
public var seasonKey: String?
|
||||
public var seasonIndex: Int?
|
||||
public var episodeIndex: Int?
|
||||
public var durationMilliseconds: Int?
|
||||
public var expectedBytes: Int64
|
||||
/// Ob `expectedBytes` die genaue Größe ist — siehe `DownloadItem`.
|
||||
public var expectedIsExact: Bool
|
||||
public var offlineMedia: OfflineMedia?
|
||||
|
||||
public init(
|
||||
ratingKey: String,
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
sourceURL: URL,
|
||||
seriesKey: String? = nil,
|
||||
seriesTitle: String? = nil,
|
||||
seasonKey: String? = nil,
|
||||
seasonIndex: Int? = nil,
|
||||
episodeIndex: Int? = nil,
|
||||
durationMilliseconds: Int? = nil,
|
||||
expectedBytes: Int64 = 0,
|
||||
expectedIsExact: Bool = false,
|
||||
offlineMedia: OfflineMedia? = nil
|
||||
) {
|
||||
self.ratingKey = ratingKey
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.sourceURL = sourceURL
|
||||
self.seriesKey = seriesKey
|
||||
self.seriesTitle = seriesTitle
|
||||
self.seasonKey = seasonKey
|
||||
self.seasonIndex = seasonIndex
|
||||
self.episodeIndex = episodeIndex
|
||||
self.durationMilliseconds = durationMilliseconds
|
||||
self.expectedBytes = expectedBytes
|
||||
self.expectedIsExact = expectedIsExact
|
||||
self.offlineMedia = offlineMedia
|
||||
}
|
||||
}
|
||||
|
||||
/// Warum ein Download nicht angenommen wurde.
|
||||
public enum DownloadRefusal: Error, Equatable, Sendable {
|
||||
case alreadyExists
|
||||
/// Zu diesem Titel gibt es keine Datei, die sich herunterladen ließe.
|
||||
///
|
||||
/// Eigener Fall statt eines stillen `nil`: eine Funktion, die kommentarlos
|
||||
/// nichts tut, ist selbst der Fehler — der Knopf sah aus, als wäre er
|
||||
/// kaputt, dabei fehlte nur die Angabe, woran es liegt.
|
||||
case noDownloadableFile(reason: String)
|
||||
/// Das eingestellte Speicherlimit wäre überschritten.
|
||||
case storageLimitReached(limitBytes: Int64, usedBytes: Int64)
|
||||
/// Auf dem Gerät ist zu wenig Platz.
|
||||
case insufficientDeviceSpace(requiredBytes: Int64, availableBytes: Int64)
|
||||
}
|
||||
|
||||
/// Führt Downloads aus und hält ihren Zustand.
|
||||
///
|
||||
/// Die Übertragung selbst macht eine Hintergrund-`URLSession`: sie läuft
|
||||
/// weiter, wenn die App geschlossen oder das Gerät gesperrt ist, und iOS
|
||||
/// startet die App zum Abschluss kurz wieder. Deshalb steht der gesamte
|
||||
/// Zustand in SwiftData und nicht im Arbeitsspeicher — nach einem Neustart
|
||||
/// gibt es kein anderes Gedächtnis.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class DownloadCoordinator {
|
||||
/// Die Kennung der Hintergrundsitzung. Muss über App-Starts hinweg gleich
|
||||
/// bleiben, sonst findet iOS die laufenden Übertragungen nicht wieder.
|
||||
public static let sessionIdentifier = "com.scarriffleservices.halo.downloads"
|
||||
|
||||
public private(set) var items: [DownloadItem] = []
|
||||
/// Wie viel die Downloads insgesamt belegen.
|
||||
public private(set) var usedBytes: Int64 = 0
|
||||
|
||||
/// Das gemessene Tempo je laufender Übertragung.
|
||||
///
|
||||
/// **Im Arbeitsspeicher, nicht in der Datenbank** — aus demselben Grund,
|
||||
/// aus dem `handleProgress` kein `save()` ruft: die Messpunkte kommen
|
||||
/// mehrmals je Sekunde, und jedes Schreiben wäre eine Plattenoperation.
|
||||
/// Über einen Neustart hinweg wäre eine alte Rate ohnehin wertlos.
|
||||
private var rates: [String: DownloadRate] = [:]
|
||||
|
||||
/// Für welche Titel der Server die Größe **angekündigt** hat.
|
||||
///
|
||||
/// Der Unterschied entscheidet, wann eine Datei als abgerissen gilt. Bei
|
||||
/// einer angekündigten Größe ist jedes fehlende Byte ein Abriss; bei einer
|
||||
/// hochgerechneten (Transcoder: Bitrate × Dauer) wäre dieselbe Strenge
|
||||
/// falsch, denn die Schätzung weicht von Haus aus ab.
|
||||
///
|
||||
/// Woran man es erkennt: `URLSession` meldet die erwartete Größe nur, wenn
|
||||
/// die Antwort eine `Content-Length` trägt — sonst kommt −1.
|
||||
///
|
||||
/// Ebenfalls im Arbeitsspeicher: nach einem Neustart wird die Übertragung
|
||||
/// neu eingereiht, und dann meldet sich der Server wieder.
|
||||
private var announcedSizes: Set<String> = []
|
||||
|
||||
/// Nur über WLAN übertragen.
|
||||
public var isWiFiOnly: Bool {
|
||||
didSet { defaults.set(isWiFiOnly, forKey: Key.wifiOnly) }
|
||||
}
|
||||
|
||||
/// Obergrenze in Bytes; `nil` heißt unbegrenzt.
|
||||
public var storageLimitBytes: Int64? {
|
||||
didSet { defaults.set(storageLimitBytes ?? 0, forKey: Key.storageLimit) }
|
||||
}
|
||||
|
||||
/// Wie viele Übertragungen gleichzeitig laufen dürfen.
|
||||
///
|
||||
/// Zwei statt aller auf einmal: eine ganze Staffel parallel zu laden teilt
|
||||
/// die Bandbreite so auf, dass keine Folge fertig wird — und unterwegs ist
|
||||
/// eine fertige Folge mehr wert als zehn halbe.
|
||||
public var maximumConcurrentDownloads: Int {
|
||||
didSet { defaults.set(maximumConcurrentDownloads, forKey: Key.maximumConcurrent) }
|
||||
}
|
||||
|
||||
/// Die Regeln fürs automatische Löschen und Nachladen.
|
||||
public var rules: DownloadRules {
|
||||
didSet {
|
||||
defaults.set(rules.deleteWatchedAfter?.seconds ?? -1, forKey: Key.deleteAfter)
|
||||
defaults.set(rules.keepUnwatchedEpisodes ?? -1, forKey: Key.keepUnwatched)
|
||||
}
|
||||
}
|
||||
|
||||
private enum Key {
|
||||
static let wifiOnly = "halo.downloads.wifiOnly"
|
||||
static let storageLimit = "halo.downloads.storageLimit"
|
||||
static let deleteAfter = "halo.downloads.deleteWatchedAfter"
|
||||
static let keepUnwatched = "halo.downloads.keepUnwatched"
|
||||
static let maximumConcurrent = "halo.downloads.maximumConcurrent"
|
||||
}
|
||||
|
||||
/// Der Container, nicht nur der Kontext.
|
||||
///
|
||||
/// Ein `ModelContext` hält seinen Container nicht am Leben. Wird der
|
||||
/// anderswo freigegeben, setzt sich der Kontext zurück und jeder bereits
|
||||
/// geholte Eintrag stürzt beim nächsten Zugriff ab — mit einer Meldung,
|
||||
/// die auf `ModelContext.reset` zeigt und nichts über die wahre Ursache
|
||||
/// sagt. Deshalb liegt die Zuständigkeit hier.
|
||||
private let container: ModelContainer
|
||||
private var context: ModelContext { container.mainContext }
|
||||
private let fileStore: DownloadFileStore
|
||||
private let defaults: UserDefaults
|
||||
/// Die Hintergrundsitzung — erst beim ersten Gebrauch angelegt.
|
||||
///
|
||||
/// `URLSession(configuration: .background(…))` spricht über XPC mit
|
||||
/// nsurlsessiond und braucht dafür spürbar Zeit. Im Initialisierer der App
|
||||
/// aufgerufen verzögerte das den allerersten Bildaufbau: die App stand,
|
||||
/// bevor überhaupt etwas zu sehen war. Wer keine Downloads hat, braucht
|
||||
/// die Sitzung nie.
|
||||
private var createdSession: URLSession?
|
||||
private var sessionConfiguration: URLSessionConfiguration?
|
||||
private let delegate = DownloadSessionDelegate()
|
||||
|
||||
private var session: URLSession {
|
||||
if let createdSession { return createdSession }
|
||||
let created = URLSession(
|
||||
configuration: sessionConfiguration
|
||||
?? Self.backgroundConfiguration(allowsCellularAccess: !isWiFiOnly),
|
||||
delegate: delegate,
|
||||
delegateQueue: nil
|
||||
)
|
||||
createdSession = created
|
||||
return created
|
||||
}
|
||||
|
||||
// Catalyst ausdrücklich ausgenommen: dort gilt `os(iOS)`, aber
|
||||
// ActivityKit gibt es nicht.
|
||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
||||
private let activityPresenter = DownloadActivityPresenter()
|
||||
#endif
|
||||
|
||||
/// Die Konfiguration der Hintergrundsitzung.
|
||||
///
|
||||
/// Als eigene Funktion, damit Tests stattdessen eine flüchtige Sitzung
|
||||
/// einsetzen können. Eine echte Hintergrundsitzung verlangt eine laufende
|
||||
/// App mit Bundle-Kennung — im Testprozess gibt es die nicht.
|
||||
public static func backgroundConfiguration(allowsCellularAccess: Bool) -> URLSessionConfiguration {
|
||||
let configuration = URLSessionConfiguration.background(withIdentifier: sessionIdentifier)
|
||||
// `false`, weil der Nutzer den Download selbst angestoßen hat. Auf
|
||||
// `true` würde iOS ihn auf einen günstigen Zeitpunkt verschieben —
|
||||
// manchmal erst nachts am Ladegerät.
|
||||
configuration.isDiscretionary = false
|
||||
configuration.sessionSendsLaunchEvents = true
|
||||
configuration.allowsCellularAccess = allowsCellularAccess
|
||||
return configuration
|
||||
}
|
||||
|
||||
public init(
|
||||
container: ModelContainer,
|
||||
fileStore: DownloadFileStore,
|
||||
defaults: UserDefaults = .standard,
|
||||
sessionConfiguration: URLSessionConfiguration? = nil
|
||||
) {
|
||||
self.container = container
|
||||
self.fileStore = fileStore
|
||||
self.defaults = defaults
|
||||
self.isWiFiOnly = defaults.object(forKey: Key.wifiOnly) as? Bool ?? true
|
||||
// Zwei statt aller auf einmal: eine ganze Staffel parallel zu laden
|
||||
// teilt die Bandbreite so auf, dass keine Folge fertig wird.
|
||||
self.maximumConcurrentDownloads = defaults.object(forKey: Key.maximumConcurrent) as? Int ?? 2
|
||||
let storedLimit = defaults.object(forKey: Key.storageLimit) as? Int64 ?? 0
|
||||
self.storageLimitBytes = storedLimit > 0 ? storedLimit : nil
|
||||
|
||||
// -1 steht für „abgeschaltet". Ein fehlender Schlüssel liefert 0, was
|
||||
// bei der Löschfrist „sofort" hieße — der gefährlichste Wert als
|
||||
// stille Voreinstellung.
|
||||
let deleteAfter = defaults.object(forKey: Key.deleteAfter) as? Double ?? -1
|
||||
let keepUnwatched = defaults.object(forKey: Key.keepUnwatched) as? Int ?? -1
|
||||
self.rules = DownloadRules(
|
||||
deleteWatchedAfter: deleteAfter >= 0 ? .seconds(deleteAfter) : nil,
|
||||
keepUnwatchedEpisodes: keepUnwatched > 0 ? keepUnwatched : nil
|
||||
)
|
||||
|
||||
self.sessionConfiguration = sessionConfiguration
|
||||
delegate.coordinator = self
|
||||
}
|
||||
|
||||
// MARK: - Start
|
||||
|
||||
/// Liest den gespeicherten Zustand und bringt ihn mit der Wirklichkeit in
|
||||
/// Übereinstimmung.
|
||||
///
|
||||
/// Beim Start unerlässlich: während die App weg war, können Übertragungen
|
||||
/// fertig geworden, gescheitert oder vom System abgebrochen worden sein.
|
||||
public func restore() async {
|
||||
reloadItems()
|
||||
|
||||
// Ohne Einträge gibt es nichts abzugleichen — und vor allem gibt es
|
||||
// keinen Grund, die Hintergrundsitzung überhaupt anzulegen. Bei einer
|
||||
// frischen Installation ist das der Normalfall.
|
||||
guard !items.isEmpty else { return }
|
||||
|
||||
let runningKeys = Set(await session.allTasks.compactMap(\.taskDescription))
|
||||
|
||||
for item in items where item.state == .downloading {
|
||||
// Als „läuft" gespeichert, aber keine Aufgabe dazu: die Übertragung
|
||||
// ist verloren gegangen. Zurück in die Warteschlange, statt einen
|
||||
// Fortschrittsbalken zu zeigen, der sich nie bewegt.
|
||||
if !runningKeys.contains(item.ratingKey) {
|
||||
item.state = .queued
|
||||
}
|
||||
}
|
||||
|
||||
// Dateien ohne Eintrag entfernen — sie entstehen, wenn die App genau
|
||||
// beim Abschluss beendet wird.
|
||||
let known = Set(items.compactMap(\.localFileName))
|
||||
_ = fileStore.removeOrphans(keeping: known)
|
||||
|
||||
save()
|
||||
refreshUsage()
|
||||
applyRules()
|
||||
await startNextIfPossible()
|
||||
}
|
||||
|
||||
/// Wendet die Regeln auf den aktuellen Bestand an.
|
||||
///
|
||||
/// Nur das Löschen — das Nachladen braucht den Server und liegt deshalb
|
||||
/// eine Schicht höher. Läuft beim Start und nach dem Ansehen, nicht auf
|
||||
/// einem Zeitgeber: eine App, die im Hintergrund Filme löscht, wäre
|
||||
/// schwer nachvollziehbar.
|
||||
public func applyRules() {
|
||||
let doomed = rules.itemsToDelete(from: items)
|
||||
guard !doomed.isEmpty else { return }
|
||||
for item in doomed { remove(item) }
|
||||
}
|
||||
|
||||
// MARK: - Hinzufügen
|
||||
|
||||
@discardableResult
|
||||
public func enqueue(_ plan: DownloadPlan) throws(DownloadRefusal) -> DownloadItem {
|
||||
if let existing = items.first(where: { $0.ratingKey == plan.ratingKey }) {
|
||||
// Ein gescheiterter Download darf erneut in die Warteschlange —
|
||||
// das ist der Wiederholen-Knopf.
|
||||
guard existing.state == .failed else { throw .alreadyExists }
|
||||
existing.state = .queued
|
||||
existing.failureMessage = nil
|
||||
save()
|
||||
Task { await startNextIfPossible() }
|
||||
return existing
|
||||
}
|
||||
|
||||
try checkCapacity(for: plan.expectedBytes)
|
||||
|
||||
let item = DownloadItem(
|
||||
ratingKey: plan.ratingKey,
|
||||
title: plan.title,
|
||||
subtitle: plan.subtitle,
|
||||
sourceURLString: plan.sourceURL.absoluteString,
|
||||
totalBytes: plan.expectedBytes,
|
||||
seriesKey: plan.seriesKey,
|
||||
seriesTitle: plan.seriesTitle,
|
||||
seasonKey: plan.seasonKey,
|
||||
seasonIndex: plan.seasonIndex,
|
||||
episodeIndex: plan.episodeIndex,
|
||||
durationMilliseconds: plan.durationMilliseconds,
|
||||
offlineMediaData: plan.offlineMedia.flatMap { try? JSONEncoder().encode($0) },
|
||||
expectedIsExact: plan.expectedIsExact
|
||||
)
|
||||
context.insert(item)
|
||||
save()
|
||||
reloadItems()
|
||||
|
||||
Task { await startNextIfPossible() }
|
||||
return item
|
||||
}
|
||||
|
||||
/// Mehrere Titel auf einmal — eine Staffel oder eine ganze Serie.
|
||||
///
|
||||
/// Gibt zurück, was angenommen wurde und was nicht. Ein einzelner
|
||||
/// abgelehnter Titel darf den Rest des Stapels nicht verhindern: bei einer
|
||||
/// Staffel mit einer bereits geladenen Folge wäre sonst gar nichts
|
||||
/// passiert.
|
||||
@discardableResult
|
||||
public func enqueueBatch(
|
||||
_ plans: [DownloadPlan]
|
||||
) -> (accepted: [DownloadItem], refused: [(DownloadPlan, DownloadRefusal)]) {
|
||||
var accepted: [DownloadItem] = []
|
||||
var refused: [(DownloadPlan, DownloadRefusal)] = []
|
||||
|
||||
for plan in plans {
|
||||
do {
|
||||
accepted.append(try enqueue(plan))
|
||||
} catch {
|
||||
refused.append((plan, error))
|
||||
}
|
||||
}
|
||||
return (accepted, refused)
|
||||
}
|
||||
|
||||
private func checkCapacity(for expectedBytes: Int64) throws(DownloadRefusal) {
|
||||
if let limit = storageLimitBytes, usedBytes + expectedBytes > limit {
|
||||
throw .storageLimitReached(limitBytes: limit, usedBytes: usedBytes)
|
||||
}
|
||||
|
||||
let available = fileStore.availableCapacity()
|
||||
// Ein Puffer von einem Gigabyte bleibt frei. Ein Gerät bis zum letzten
|
||||
// Byte vollzuschreiben legt auch alles andere lahm.
|
||||
let reserve: Int64 = 1_000_000_000
|
||||
if expectedBytes > 0, expectedBytes + reserve > available {
|
||||
throw .insufficientDeviceSpace(requiredBytes: expectedBytes, availableBytes: available)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Steuern
|
||||
|
||||
public func pause(_ item: DownloadItem) async {
|
||||
guard item.state == .downloading else { return }
|
||||
guard let task = await task(for: item.ratingKey) as? URLSessionDownloadTask else {
|
||||
item.state = .paused
|
||||
save()
|
||||
return
|
||||
}
|
||||
|
||||
// `cancel(byProducingResumeData:)` statt `cancel()`: nur so gibt iOS
|
||||
// den Datensatz heraus, mit dem später weitergeladen werden kann.
|
||||
// Sonst begänne der Download wieder bei null — bei 40 GB fatal.
|
||||
let data = await withCheckedContinuation { continuation in
|
||||
task.cancel { continuation.resume(returning: $0) }
|
||||
}
|
||||
item.resumeData = data
|
||||
forgetTransfer(item.ratingKey)
|
||||
item.state = .paused
|
||||
save()
|
||||
await startNextIfPossible()
|
||||
}
|
||||
|
||||
public func resume(_ item: DownloadItem) async {
|
||||
guard item.state == .paused || item.state == .failed else { return }
|
||||
item.state = .queued
|
||||
item.failureMessage = nil
|
||||
save()
|
||||
await startNextIfPossible()
|
||||
}
|
||||
|
||||
public func cancel(_ item: DownloadItem) async {
|
||||
await task(for: item.ratingKey)?.cancel()
|
||||
remove(item)
|
||||
await startNextIfPossible()
|
||||
}
|
||||
|
||||
/// Entfernt Eintrag und Datei.
|
||||
public func remove(_ item: DownloadItem) {
|
||||
forgetTransfer(item.ratingKey)
|
||||
fileStore.remove(fileName: item.localFileName)
|
||||
fileStore.remove(fileName: item.artworkFileName)
|
||||
context.delete(item)
|
||||
save()
|
||||
reloadItems()
|
||||
refreshUsage()
|
||||
}
|
||||
|
||||
/// Merkt sich, dass ein Titel durchgesehen wurde.
|
||||
public func markWatched(ratingKey: String) {
|
||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||
item.watchedAt = Date()
|
||||
save()
|
||||
// Bei der Frist „sofort" wird der Titel hier bereits entfernt. Das ist
|
||||
// gewollt: wer das einstellt, will nach dem Abspann keinen Platz mehr
|
||||
// belegen.
|
||||
applyRules()
|
||||
}
|
||||
|
||||
/// Die lokale Datei eines fertigen Downloads.
|
||||
public func localURL(forRatingKey key: String) -> URL? {
|
||||
guard let item = items.first(where: { $0.ratingKey == key }),
|
||||
item.state == .completed,
|
||||
let fileName = item.localFileName,
|
||||
fileStore.exists(fileName: fileName)
|
||||
else { return nil }
|
||||
return fileStore.url(forFileName: fileName)
|
||||
}
|
||||
|
||||
public func offlineMedia(forRatingKey key: String) -> OfflineMedia? {
|
||||
guard let data = items.first(where: { $0.ratingKey == key })?.offlineMediaData else { return nil }
|
||||
return try? JSONDecoder().decode(OfflineMedia.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Warteschlange
|
||||
|
||||
/// Startet den nächsten wartenden Download, sofern Platz in der
|
||||
/// Warteschlange ist.
|
||||
func startNextIfPossible() async {
|
||||
let running = items.filter { $0.state == .downloading }.count
|
||||
guard running < maximumConcurrentDownloads else { return }
|
||||
|
||||
for item in transferOrder.prefix(maximumConcurrentDownloads - running) {
|
||||
start(item)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Reihenfolge, in der wartende Downloads abgearbeitet werden.
|
||||
///
|
||||
/// Älteste zuerst — außer innerhalb einer Serie, dort nach Staffel und
|
||||
/// Folge. Sonst wäre bei einer Staffel Folge 7 vor Folge 1 fertig, und
|
||||
/// unterwegs nützt die siebte wenig, wenn die erste fehlt.
|
||||
var transferOrder: [DownloadItem] {
|
||||
items
|
||||
.filter { $0.state.isTransferable }
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.seriesKey == rhs.seriesKey {
|
||||
let left = (lhs.seasonIndex ?? 0, lhs.episodeIndex ?? 0)
|
||||
let right = (rhs.seasonIndex ?? 0, rhs.episodeIndex ?? 0)
|
||||
if left != right { return left < right }
|
||||
}
|
||||
return lhs.addedAt < rhs.addedAt
|
||||
}
|
||||
}
|
||||
|
||||
private func start(_ item: DownloadItem) {
|
||||
guard let url = URL(string: item.sourceURLString) else {
|
||||
fail(item, message: "Ungültige Adresse")
|
||||
return
|
||||
}
|
||||
|
||||
let task: URLSessionDownloadTask
|
||||
if let resumeData = item.resumeData {
|
||||
task = session.downloadTask(withResumeData: resumeData)
|
||||
item.resumeData = nil
|
||||
} else {
|
||||
var request = URLRequest(url: url)
|
||||
request.allowsCellularAccess = !isWiFiOnly
|
||||
task = session.downloadTask(with: request)
|
||||
}
|
||||
|
||||
// Über die Beschreibung findet der Delegat den Eintrag wieder — auch
|
||||
// nach einem Neustart der App, denn iOS stellt sie mit wieder her.
|
||||
task.taskDescription = item.ratingKey
|
||||
item.state = .downloading
|
||||
save()
|
||||
task.resume()
|
||||
}
|
||||
|
||||
private func task(for ratingKey: String) async -> URLSessionTask? {
|
||||
await session.allTasks.first { $0.taskDescription == ratingKey }
|
||||
}
|
||||
|
||||
// MARK: - Rückmeldungen des Delegaten
|
||||
|
||||
func handleProgress(ratingKey: String, received: Int64, expected: Int64) {
|
||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||
item.receivedBytes = received
|
||||
if expected > 0 {
|
||||
item.totalBytes = expected
|
||||
// Hier und nur hier ist belegt, dass die Größe vom Server kommt
|
||||
// und nicht hochgerechnet ist. Siehe `announcedSizes`.
|
||||
announcedSizes.insert(ratingKey)
|
||||
}
|
||||
|
||||
var rate = rates[ratingKey] ?? DownloadRate()
|
||||
rate.record(bytes: received, at: .now)
|
||||
rates[ratingKey] = rate
|
||||
|
||||
updateActivity()
|
||||
// Bewusst ohne `save()`: der Fortschritt kommt mehrmals je Sekunde, und
|
||||
// jedes Schreiben wäre eine Plattenoperation. Für die Anzeige reicht
|
||||
// die Beobachtung; gespeichert wird bei Zustandswechseln.
|
||||
}
|
||||
|
||||
/// Das gemessene Tempo einer laufenden Übertragung.
|
||||
///
|
||||
/// `nil`, solange noch nichts gemessen wurde — und das ist eine ehrlichere
|
||||
/// Antwort als „0 MB/s".
|
||||
public func rate(forRatingKey key: String) -> DownloadRate? {
|
||||
rates[key]
|
||||
}
|
||||
|
||||
/// Vergisst, was nur zu einer laufenden Übertragung gehört.
|
||||
///
|
||||
/// Beim nächsten Anlauf wird neu gemessen: eine Rate von vorhin gälte
|
||||
/// sonst für eine Leitung, die es inzwischen nicht mehr gibt.
|
||||
private func forgetTransfer(_ ratingKey: String) {
|
||||
rates[ratingKey] = nil
|
||||
announcedSizes.remove(ratingKey)
|
||||
}
|
||||
|
||||
func handleCompletion(ratingKey: String, temporaryFile: URL) {
|
||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
|
||||
let sourceURL = URL(string: item.sourceURLString)
|
||||
else { return }
|
||||
|
||||
let name = fileStore.fileName(forRatingKey: ratingKey, sourceURL: sourceURL)
|
||||
do {
|
||||
_ = try fileStore.adopt(temporaryFile: temporaryFile, as: name)
|
||||
let written = fileStore.size(ofFileName: name)
|
||||
|
||||
// Die erwartete Größe NICHT überschreiben. Genau das hat den
|
||||
// Fehler verdeckt: kamen nur ein paar Kilobyte an, wurden sie als
|
||||
// empfangen *und* als erwartet notiert — und die Anzeige stand auf
|
||||
// hundert Prozent.
|
||||
let problem = Self.implausibility(
|
||||
written: written,
|
||||
expected: item.totalBytes,
|
||||
announced: item.expectedIsExact || announcedSizes.contains(ratingKey)
|
||||
)
|
||||
if let problem {
|
||||
fileStore.remove(fileName: name)
|
||||
fail(item, message: problem)
|
||||
Task { await startNextIfPossible() }
|
||||
return
|
||||
}
|
||||
|
||||
forgetTransfer(ratingKey)
|
||||
item.localFileName = name
|
||||
item.receivedBytes = written
|
||||
if item.totalBytes <= 0 { item.totalBytes = written }
|
||||
item.state = .completed
|
||||
item.completedAt = Date()
|
||||
save()
|
||||
refreshUsage()
|
||||
updateActivity()
|
||||
} catch {
|
||||
fail(item, message: error.localizedDescription)
|
||||
}
|
||||
|
||||
Task { await startNextIfPossible() }
|
||||
}
|
||||
|
||||
/// Prüft, ob die fertige Datei plausibel ist.
|
||||
///
|
||||
/// Gibt eine Begründung zurück, wenn nicht. Zwei Fälle fallen darunter:
|
||||
/// eine winzige Datei (fast immer eine Fehlerseite) und eine, die unter
|
||||
/// der erwarteten Größe liegt (abgebrochene Übertragung, die als
|
||||
/// erfolgreich gemeldet wurde).
|
||||
///
|
||||
/// **`announced` entscheidet, wie streng gemessen wird** — und daran hing
|
||||
/// ein Fehler, der Dateien kaputt machte und als Erfolg verbuchte. Hier
|
||||
/// stand früher für jede Größe dieselbe Regel: unter der Hälfte ist es ein
|
||||
/// Abriss, darüber in Ordnung. Eine bei sechzig Prozent abgerissene Datei
|
||||
/// kam damit durch, bekam einen Haken — und brach beim Abspielen
|
||||
/// mittendrin ab.
|
||||
///
|
||||
/// Die Hälfte-Regel war nie für diesen Fall gedacht, sondern für die
|
||||
/// **hochgerechnete** Größe der Transcoder-Stufen (Bitrate × Dauer). Dort
|
||||
/// ist Abweichung normal. Hat der Server die Größe dagegen angekündigt,
|
||||
/// ist jedes fehlende Byte ein Abriss, und zwar genau einer.
|
||||
static func implausibility(written: Int64, expected: Int64, announced: Bool) -> String? {
|
||||
// Unter hundert Kilobyte ist keine Videodatei. Dieser Wert liegt
|
||||
// bewusst niedrig: eine sehr kurze Folge kann klein sein, eine
|
||||
// HTML-Fehlerseite ist es immer.
|
||||
if written < 100_000 {
|
||||
return String(
|
||||
localized: "downloads.failed.tooSmall \(written.formatted(.byteCount(style: .file)))",
|
||||
bundle: .module
|
||||
)
|
||||
}
|
||||
// Angekündigt: auf das Byte genau. Hochgerechnet: die Hälfte, denn der
|
||||
// Transcoder kennt die Endgröße vorab nicht.
|
||||
let untergrenze = announced ? Double(expected) : Double(expected) * 0.5
|
||||
if expected > 0, Double(written) < untergrenze {
|
||||
return String(
|
||||
localized: "downloads.failed.truncated \(written.formatted(.byteCount(style: .file))) \(expected.formatted(.byteCount(style: .file)))",
|
||||
bundle: .module
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Der Server hat die Anfrage abgelehnt.
|
||||
func handleServerRejection(ratingKey: String, status: Int) {
|
||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||
fail(item, message: String(localized: "downloads.failed.status \(status)", bundle: .module))
|
||||
Task { await startNextIfPossible() }
|
||||
}
|
||||
|
||||
func handleFailure(ratingKey: String, error: any Error) {
|
||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||
|
||||
// Ein Abbruch durch den Nutzer ist kein Fehler. Ihn als solchen zu
|
||||
// melden hieße, dass jedes Pausieren einen roten Hinweis erzeugt.
|
||||
if (error as? URLError)?.code == .cancelled {
|
||||
Task { await startNextIfPossible() }
|
||||
return
|
||||
}
|
||||
|
||||
// **Den Wiederaufnahme-Datensatz retten.**
|
||||
//
|
||||
// Hier lag der zweite Teil des Fehlers. Es gab ihn nur beim Pausieren
|
||||
// von Hand (`pause(_:)`); nach einem Netzabbruch war er weg, und
|
||||
// „Fortsetzen" lud eine vierzig Gigabyte große Datei wieder ab Byte
|
||||
// null — während der Knopf daneben „Fortsetzen" hieß. Eine
|
||||
// Beschriftung, die der Code nicht halten konnte.
|
||||
//
|
||||
// iOS legt den Datensatz dem Fehler bei, wenn die Übertragung
|
||||
// unterbrochen wurde und fortsetzbar ist. Fehlt er, war es keine
|
||||
// Unterbrechung, sondern eine Ablehnung — und `resumeInterrupted()`
|
||||
// unterscheidet die beiden genau daran.
|
||||
item.resumeData = (error as NSError)
|
||||
.userInfo[NSURLSessionDownloadTaskResumeData] as? Data
|
||||
|
||||
fail(item, message: error.localizedDescription)
|
||||
Task { await startNextIfPossible() }
|
||||
}
|
||||
|
||||
/// Nimmt wieder auf, was nur das Netz unterbrochen hat.
|
||||
///
|
||||
/// **Weil niemand sonst merkt, dass das WLAN zurück ist.** Ein
|
||||
/// gescheiterter Download lag bisher, bis man ihn von Hand antippte — auch
|
||||
/// dann, wenn die Verbindung seit einer Stunde wieder stand.
|
||||
///
|
||||
/// Nur Einträge mit Wiederaufnahme-Datensatz: den gibt es bei einer
|
||||
/// unterbrochenen Übertragung, nicht bei einer abgelehnten. Ein Titel, den
|
||||
/// der Server mit „nicht gefunden" beantwortet, versucht sich damit nicht
|
||||
/// im Kreis.
|
||||
public func resumeInterrupted() async {
|
||||
let unterbrochen = items.filter { $0.state == .failed && $0.resumeData != nil }
|
||||
guard !unterbrochen.isEmpty else { return }
|
||||
|
||||
for item in unterbrochen {
|
||||
item.state = .queued
|
||||
item.failureMessage = nil
|
||||
}
|
||||
save()
|
||||
await startNextIfPossible()
|
||||
}
|
||||
|
||||
private func fail(_ item: DownloadItem, message: String) {
|
||||
forgetTransfer(item.ratingKey)
|
||||
item.state = .failed
|
||||
item.failureMessage = message
|
||||
save()
|
||||
updateActivity()
|
||||
}
|
||||
|
||||
/// Hält die Anzeige im Sperrbildschirm auf dem Stand.
|
||||
///
|
||||
/// Zeigt den am weitesten fortgeschrittenen Download — bei zwei
|
||||
/// gleichzeitigen ist das der, der zuerst fertig wird, und damit der, auf
|
||||
/// den man wartet.
|
||||
private func updateActivity() {
|
||||
// Catalyst ausdrücklich ausgenommen: dort gilt `os(iOS)`, aber
|
||||
// ActivityKit gibt es nicht — die Typen sind zwar da, und jeder
|
||||
// Aufruf ist als nicht verfügbar markiert.
|
||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
||||
let running = items.filter { $0.state == .downloading }
|
||||
guard let leading = running.max(by: { ($0.progress ?? 0) < ($1.progress ?? 0) }) else {
|
||||
Task { [activityPresenter] in await activityPresenter.update(with: nil) }
|
||||
return
|
||||
}
|
||||
|
||||
// Der Zustand wird hier gebaut, nicht in der Aufgabe: SwiftData-Objekte
|
||||
// gehören dem Hauptakteur, der reine Wertetyp darf wandern.
|
||||
let state = DownloadActivityAttributes.ContentState(
|
||||
title: leading.title,
|
||||
progress: leading.progress,
|
||||
receivedBytes: leading.receivedBytes,
|
||||
totalBytes: leading.totalBytes,
|
||||
remainingCount: items.filter { $0.state.isTransferable }.count + running.count - 1
|
||||
)
|
||||
Task { [activityPresenter] in await activityPresenter.update(with: state) }
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Innereien
|
||||
|
||||
private func reloadItems() {
|
||||
let descriptor = FetchDescriptor<DownloadItem>(
|
||||
sortBy: [SortDescriptor(\.addedAt, order: .forward)]
|
||||
)
|
||||
items = (try? context.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
func refreshUsage() {
|
||||
usedBytes = items.reduce(into: Int64(0)) { $0 += $1.occupiedBytes }
|
||||
}
|
||||
|
||||
private func save() {
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
|
||||
/// Nimmt die Rückrufe der Hintergrundsitzung entgegen.
|
||||
///
|
||||
/// Eine eigene Klasse, weil `URLSessionDelegate` von `NSObject` erben muss und
|
||||
/// seine Rückrufe auf einer eigenen Warteschlange kommen. Sie reicht alles an
|
||||
/// den Koordinator auf dem Hauptakteur weiter — dort liegen die Daten.
|
||||
final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
|
||||
weak var coordinator: DownloadCoordinator?
|
||||
|
||||
/// Wird von der App gesetzt, wenn iOS sie zum Abschluss von Downloads
|
||||
/// aufgeweckt hat. Muss aufgerufen werden, sobald alles verarbeitet ist —
|
||||
/// sonst beendet iOS die App hart und merkt sich das negativ.
|
||||
var backgroundCompletionHandler: (@Sendable () -> Void)?
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didFinishDownloadingTo location: URL
|
||||
) {
|
||||
guard let key = downloadTask.taskDescription else { return }
|
||||
|
||||
// `URLSessionDownloadTask` gilt auch bei 404 oder 500 als erfolgreich
|
||||
// und legt die Fehlerseite als Datei ab. Ohne diese Prüfung wurde eine
|
||||
// zwei Kilobyte große HTML-Seite als fertiger Film verbucht — in einer
|
||||
// Sekunde „heruntergeladen".
|
||||
if let response = downloadTask.response as? HTTPURLResponse,
|
||||
!(200..<300).contains(response.statusCode) {
|
||||
let status = response.statusCode
|
||||
Task { @MainActor [weak coordinator] in
|
||||
coordinator?.handleServerRejection(ratingKey: key, status: status)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Die temporäre Datei verschwindet, sobald dieser Aufruf zurückkehrt.
|
||||
// Sie muss deshalb hier und jetzt in Sicherheit gebracht werden, nicht
|
||||
// erst nach einem Sprung auf den Hauptakteur.
|
||||
let temporary = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString)
|
||||
try? FileManager.default.moveItem(at: location, to: temporary)
|
||||
|
||||
Task { @MainActor [weak coordinator] in
|
||||
coordinator?.handleCompletion(ratingKey: key, temporaryFile: temporary)
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didWriteData bytesWritten: Int64,
|
||||
totalBytesWritten: Int64,
|
||||
totalBytesExpectedToWrite: Int64
|
||||
) {
|
||||
guard let key = downloadTask.taskDescription else { return }
|
||||
Task { @MainActor [weak coordinator] in
|
||||
coordinator?.handleProgress(
|
||||
ratingKey: key,
|
||||
received: totalBytesWritten,
|
||||
expected: totalBytesExpectedToWrite
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
didCompleteWithError error: (any Error)?
|
||||
) {
|
||||
guard let error, let key = task.taskDescription else { return }
|
||||
Task { @MainActor [weak coordinator] in
|
||||
coordinator?.handleFailure(ratingKey: key, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||
let handler = backgroundCompletionHandler
|
||||
backgroundCompletionHandler = nil
|
||||
Task { @MainActor in handler?() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
|
||||
/// Verwaltet die heruntergeladenen Dateien auf der Platte.
|
||||
///
|
||||
/// Getrennt von der Datenbank, damit beides einzeln prüfbar bleibt: hier geht
|
||||
/// es nur um Dateien, Pfade und Platz.
|
||||
public struct DownloadFileStore: Sendable {
|
||||
private let root: URL
|
||||
|
||||
/// Nicht gespeichert, sondern bei jedem Zugriff frisch geholt.
|
||||
/// `FileManager` ist nicht Sendable; `.default` ist aber
|
||||
/// threadsicher für die hier verwendeten Operationen, und ein eigener
|
||||
/// Verweis pro Aufruf kostet nichts.
|
||||
private var fileManager: FileManager { .default }
|
||||
|
||||
public init(root: URL? = nil) throws {
|
||||
if let root {
|
||||
self.root = root
|
||||
} else {
|
||||
// „Application Support", nicht „Caches": iOS räumt den Zwischen-
|
||||
// speicher bei knappem Platz eigenmächtig leer. Ein Film, der auf
|
||||
// dem Flug verschwindet, wäre das Gegenteil von Offline-Nutzung.
|
||||
let base = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
self.root = base.appending(path: "Downloads", directoryHint: .isDirectory)
|
||||
}
|
||||
try createRootIfNeeded()
|
||||
}
|
||||
|
||||
private func createRootIfNeeded() throws {
|
||||
guard !fileManager.fileExists(atPath: root.path(percentEncoded: false)) else { return }
|
||||
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
|
||||
// Ein 40-GB-Remux gehört nicht in die iCloud-Sicherung. Ohne diese
|
||||
// Markierung zählt Apple ihn zum Speicherbedarf des Kontos — und die
|
||||
// Sicherung selbst würde tagelang laufen.
|
||||
var resourceValues = URLResourceValues()
|
||||
resourceValues.isExcludedFromBackup = true
|
||||
var mutableRoot = root
|
||||
try mutableRoot.setResourceValues(resourceValues)
|
||||
}
|
||||
|
||||
public func url(forFileName name: String) -> URL {
|
||||
root.appending(path: name)
|
||||
}
|
||||
|
||||
/// Ein Dateiname aus Titelschlüssel und ursprünglicher Endung.
|
||||
///
|
||||
/// Der Schlüssel statt des Titels: Titel enthalten Schrägstriche,
|
||||
/// Doppelpunkte und Umlaute, und die Endung entscheidet mit darüber, ob
|
||||
/// AVFoundation die Datei überhaupt zu öffnen versucht.
|
||||
public func fileName(forRatingKey key: String, sourceURL: URL) -> String {
|
||||
let ext = sourceURL.pathExtension.isEmpty ? "mp4" : sourceURL.pathExtension
|
||||
return "\(key).\(ext)"
|
||||
}
|
||||
|
||||
/// Verschiebt die fertige Datei aus dem temporären Ordner an ihren Platz.
|
||||
///
|
||||
/// Verschieben, nicht kopieren: iOS löscht die temporäre Datei, sobald der
|
||||
/// Rückruf zurückkehrt. Ein Kopiervorgang für 40 GB wäre zudem ein zweites
|
||||
/// Mal derselbe Platzbedarf.
|
||||
public func adopt(temporaryFile: URL, as name: String) throws -> URL {
|
||||
let destination = url(forFileName: name)
|
||||
if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) {
|
||||
try fileManager.removeItem(at: destination)
|
||||
}
|
||||
try fileManager.moveItem(at: temporaryFile, to: destination)
|
||||
return destination
|
||||
}
|
||||
|
||||
public func remove(fileName: String?) {
|
||||
guard let fileName else { return }
|
||||
try? fileManager.removeItem(at: url(forFileName: fileName))
|
||||
}
|
||||
|
||||
public func exists(fileName: String?) -> Bool {
|
||||
guard let fileName else { return false }
|
||||
return fileManager.fileExists(atPath: url(forFileName: fileName).path(percentEncoded: false))
|
||||
}
|
||||
|
||||
public func size(ofFileName fileName: String) -> Int64 {
|
||||
let attributes = try? fileManager.attributesOfItem(
|
||||
atPath: url(forFileName: fileName).path(percentEncoded: false)
|
||||
)
|
||||
return (attributes?[.size] as? NSNumber)?.int64Value ?? 0
|
||||
}
|
||||
|
||||
/// Was die Downloads insgesamt belegen.
|
||||
public func totalSize() -> Int64 {
|
||||
guard let entries = try? fileManager.contentsOfDirectory(
|
||||
at: root,
|
||||
includingPropertiesForKeys: [.fileSizeKey]
|
||||
) else { return 0 }
|
||||
|
||||
return entries.reduce(into: Int64(0)) { total, entry in
|
||||
let values = try? entry.resourceValues(forKeys: [.fileSizeKey])
|
||||
total += Int64(values?.fileSize ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie viel auf dem Gerät noch frei ist.
|
||||
///
|
||||
/// Vor jedem Download geprüft: ein Download, der das Gerät vollschreibt,
|
||||
/// legt auch alles andere lahm.
|
||||
///
|
||||
/// **Auf tvOS gibt es die Auskunft nicht** —
|
||||
/// `volumeAvailableCapacityForImportantUsage` ist dort als nicht
|
||||
/// verfügbar markiert, und ein Apple TV hat auch keinen Speicher, auf den
|
||||
/// man etwas legt. Das Paket wird dort trotzdem mitverlinkt, damit
|
||||
/// `PlayerModel` den Koordinator **nennen** darf, ohne dass zweitausend
|
||||
/// Zeilen Wiedergabelogik von `#if` durchzogen werden. Angelegt wird nie
|
||||
/// einer; diese Zeile läuft also nie. Null heisst „kein Platz", und das
|
||||
/// ist die richtige Antwort für ein Gerät, auf dem nichts liegen soll.
|
||||
public func availableCapacity() -> Int64 {
|
||||
#if os(tvOS)
|
||||
0
|
||||
#else
|
||||
let values = try? root.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
|
||||
return values?.volumeAvailableCapacityForImportantUsage ?? 0
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Entfernt Dateien, zu denen es keinen Eintrag mehr gibt.
|
||||
///
|
||||
/// Verwaiste Dateien entstehen, wenn die App während des Abschlusses
|
||||
/// beendet wird: die Datei liegt schon da, der Eintrag ist noch nicht
|
||||
/// aktualisiert. Ohne Aufräumen belegen sie dauerhaft Platz, ohne dass
|
||||
/// die Downloads-Ansicht sie zeigt.
|
||||
public func removeOrphans(keeping knownFileNames: Set<String>) -> Int {
|
||||
guard let entries = try? fileManager.contentsOfDirectory(
|
||||
at: root,
|
||||
includingPropertiesForKeys: nil
|
||||
) else { return 0 }
|
||||
|
||||
var removed = 0
|
||||
for entry in entries where !knownFileNames.contains(entry.lastPathComponent) {
|
||||
if (try? fileManager.removeItem(at: entry)) != nil { removed += 1 }
|
||||
}
|
||||
return removed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Die Regeln, nach denen Downloads von selbst entstehen und verschwinden.
|
||||
///
|
||||
/// Bewusst als reine Rechnung ohne Zugriff auf Datenbank, Dateien oder Netz:
|
||||
/// sie bekommt den Bestand hereingereicht und gibt zurück, was zu tun wäre.
|
||||
/// Dadurch lässt sich das Verhalten prüfen, ohne etwas zu löschen — und beim
|
||||
/// automatischen Löschen von Filmen ist das keine akademische Vorsicht.
|
||||
public struct DownloadRules: Sendable, Equatable {
|
||||
/// Gesehene Titel nach dieser Frist entfernen. `nil` schaltet es ab.
|
||||
///
|
||||
/// Eine Frist statt sofortigem Löschen: wer eine Folge zu Ende sieht und
|
||||
/// gleich noch einmal die letzte Szene sucht, soll sie nicht schon
|
||||
/// verloren haben.
|
||||
public var deleteWatchedAfter: Duration?
|
||||
|
||||
/// Wie viele ungesehene Folgen je Serie vorgehalten werden. `nil`
|
||||
/// schaltet das Nachladen ab.
|
||||
public var keepUnwatchedEpisodes: Int?
|
||||
|
||||
public init(deleteWatchedAfter: Duration? = nil, keepUnwatchedEpisodes: Int? = nil) {
|
||||
self.deleteWatchedAfter = deleteWatchedAfter
|
||||
self.keepUnwatchedEpisodes = keepUnwatchedEpisodes
|
||||
}
|
||||
|
||||
public static let disabled = DownloadRules()
|
||||
|
||||
/// Welche Einträge nach den Regeln zu entfernen sind.
|
||||
public func itemsToDelete(
|
||||
from items: [DownloadItem],
|
||||
now: Date = Date()
|
||||
) -> [DownloadItem] {
|
||||
guard let deleteWatchedAfter else { return [] }
|
||||
|
||||
return items.filter { item in
|
||||
// Nur Fertiges: ein laufender Download hat nichts, was gesehen
|
||||
// worden sein könnte.
|
||||
guard item.state == .completed, let watchedAt = item.watchedAt else { return false }
|
||||
return now.timeIntervalSince(watchedAt) >= deleteWatchedAfter.seconds
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie viele Folgen je Serie noch fehlen, um den Vorrat zu füllen.
|
||||
///
|
||||
/// Gezählt wird, was offline und ungesehen ist. Gesehene Folgen zählen
|
||||
/// nicht mit — sonst bliebe der Vorrat bei drei gesehenen Folgen stehen
|
||||
/// und es käme nie etwas Neues nach.
|
||||
public func missingEpisodeCount(
|
||||
forSeries seriesKey: String,
|
||||
in items: [DownloadItem]
|
||||
) -> Int {
|
||||
guard let keepUnwatchedEpisodes else { return 0 }
|
||||
|
||||
let unwatchedOffline = items.filter {
|
||||
$0.seriesKey == seriesKey
|
||||
&& $0.watchedAt == nil
|
||||
// Wartende und laufende zählen mit: sonst würde bei jedem
|
||||
// Aufruf erneut nachgelegt, bis die ganze Serie auf dem Gerät
|
||||
// liegt.
|
||||
&& $0.state != .failed
|
||||
}.count
|
||||
|
||||
return max(0, keepUnwatchedEpisodes - unwatchedOffline)
|
||||
}
|
||||
|
||||
/// Die Serien, für die nachgeladen werden müsste.
|
||||
public func seriesNeedingRefill(in items: [DownloadItem]) -> [String: Int] {
|
||||
guard keepUnwatchedEpisodes != nil else { return [:] }
|
||||
|
||||
let seriesKeys = Set(items.compactMap(\.seriesKey))
|
||||
var result: [String: Int] = [:]
|
||||
for key in seriesKeys {
|
||||
let missing = missingEpisodeCount(forSeries: key, in: items)
|
||||
if missing > 0 { result[key] = missing }
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension Duration {
|
||||
/// Gängige Fristen fürs automatische Löschen.
|
||||
public static let immediately = Duration.zero
|
||||
public static let afterOneDay = Duration.seconds(24 * 60 * 60)
|
||||
public static let afterOneWeek = Duration.seconds(7 * 24 * 60 * 60)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"downloads.failed.tooSmall %@": {
|
||||
"comment": "Die heruntergeladene Datei ist zu klein, um ein Video zu sein",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Der Server hat nur %@ geliefert — das ist keine Videodatei."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The server returned only %@ — that isn't a video file."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.failed.truncated %@ %@": {
|
||||
"comment": "Die Übertragung brach ab, wurde aber als erfolgreich gemeldet",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Übertragung unvollständig: %@ von %@ angekommen."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Transfer incomplete: %@ of %@ received."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.failed.status %lld": {
|
||||
"comment": "Der Server hat die Download-Anfrage abgelehnt",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Der Server hat den Download mit Fehler %lld abgelehnt."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The server refused the download with error %lld."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.0"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
// `os(iOS)` statt `canImport(ActivityKit)`: der Import gelingt auch auf
|
||||
// macOS, die Typen darin sind dort aber als nicht verfügbar markiert. Eine
|
||||
// Prüfung auf den Import allein übersetzt deshalb nicht.
|
||||
// Catalyst ausdrücklich ausgenommen: dort gilt `os(iOS)`, aber ActivityKit
|
||||
// gibt es nicht — die Typen sind zwar da und jeder Aufruf ist als nicht
|
||||
// verfügbar markiert.
|
||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
||||
import ActivityKit
|
||||
|
||||
/// Was im Sperrbildschirm und in der Dynamic Island steht.
|
||||
///
|
||||
/// Liegt in HaloDownloads, weil App und Widget-Erweiterung denselben Typ
|
||||
/// brauchen — zwei getrennte Kopien wären die klassische Quelle für eine Live
|
||||
/// Activity, die sich nicht mehr aktualisieren lässt: ActivityKit gleicht die
|
||||
/// Typen über ihren vollen Namen ab.
|
||||
public struct DownloadActivityAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable, Sendable {
|
||||
/// Der Titel, der gerade lädt.
|
||||
public var title: String
|
||||
/// 0…1, oder `nil` solange die Gesamtgröße unbekannt ist.
|
||||
public var progress: Double?
|
||||
public var receivedBytes: Int64
|
||||
public var totalBytes: Int64
|
||||
/// Wie viele weitere in der Warteschlange stehen.
|
||||
public var remainingCount: Int
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
progress: Double?,
|
||||
receivedBytes: Int64,
|
||||
totalBytes: Int64,
|
||||
remainingCount: Int
|
||||
) {
|
||||
self.title = title
|
||||
self.progress = progress
|
||||
self.receivedBytes = receivedBytes
|
||||
self.totalBytes = totalBytes
|
||||
self.remainingCount = remainingCount
|
||||
}
|
||||
}
|
||||
|
||||
/// Bleibt über die Laufzeit gleich. Hier leer: welcher Titel lädt, wechselt
|
||||
/// im Laufe einer Staffel und gehört deshalb in den Zustand.
|
||||
public init() {}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Ein heruntergeladener oder herunterzuladender Titel.
|
||||
///
|
||||
/// Liegt in SwiftData, weil Downloads die App überleben: iOS überträgt im
|
||||
/// Hintergrund weiter, auch wenn die App beendet ist, und beim nächsten Start
|
||||
/// muss aus dem gespeicherten Zustand hervorgehen, was zu tun ist.
|
||||
@Model
|
||||
public final class DownloadItem {
|
||||
/// Der Schlüssel des Titels auf dem Server. Eindeutig — derselbe Titel
|
||||
/// soll nicht zweimal in der Warteschlange stehen.
|
||||
@Attribute(.unique) public var ratingKey: String
|
||||
|
||||
/// Der Zustand als Zeichenkette. SwiftData kann Aufzählungen speichern,
|
||||
/// aber eine Zeichenkette bleibt bei Umbenennungen im Code lesbar — und
|
||||
/// ein unlesbarer Zustand hieße: laufende Downloads sind verloren.
|
||||
public var stateRaw: String
|
||||
|
||||
public var title: String
|
||||
public var subtitle: String?
|
||||
/// Zu welcher Serie und Staffel der Titel gehört. Gruppiert die
|
||||
/// Downloads-Ansicht und ist Grundlage für „halte N Folgen offline".
|
||||
public var seriesKey: String?
|
||||
public var seriesTitle: String?
|
||||
public var seasonKey: String?
|
||||
public var seasonIndex: Int?
|
||||
public var episodeIndex: Int?
|
||||
|
||||
public var totalBytes: Int64
|
||||
public var receivedBytes: Int64
|
||||
/// Ob `totalBytes` die **genaue** Größe ist oder eine Schätzung.
|
||||
///
|
||||
/// **Daran hängt, wann eine Datei als abgerissen gilt.** Beim Original
|
||||
/// nennt der Server die Dateigröße in den Metadaten — da ist jedes
|
||||
/// fehlende Byte ein Abriss. Bei den umgerechneten Stufen rechnet die App
|
||||
/// die Größe aus Bitrate und Dauer hoch, und die weicht von Haus aus ab;
|
||||
/// dort wäre dieselbe Strenge falsch.
|
||||
///
|
||||
/// Ohne diese Unterscheidung galt für beides die Hälfte-Regel — und eine
|
||||
/// bei sechzig Prozent abgerissene Datei bekam einen Haken.
|
||||
public var expectedIsExact: Bool = false
|
||||
|
||||
/// Dateiname im Download-Ordner. Nicht der volle Pfad: der Container der
|
||||
/// App bekommt bei jedem Systemupdate eine neue Kennung, ein gespeicherter
|
||||
/// absoluter Pfad zeigt danach ins Leere.
|
||||
public var localFileName: String?
|
||||
public var artworkFileName: String?
|
||||
|
||||
public var addedAt: Date
|
||||
public var completedAt: Date?
|
||||
/// Wann der Titel durchgesehen wurde — Grundlage fürs automatische Löschen.
|
||||
public var watchedAt: Date?
|
||||
public var failureMessage: String?
|
||||
|
||||
/// Woher geladen wird. Enthält den Token, deshalb liegt er im geschützten
|
||||
/// App-Container und nicht in den Voreinstellungen.
|
||||
public var sourceURLString: String
|
||||
public var durationMilliseconds: Int?
|
||||
|
||||
/// Was der Player offline über die Datei wissen muss: Container, Codecs,
|
||||
/// Spuren, Marken. Als JSON, weil die Form von Plex vorgegeben ist und
|
||||
/// sich eigene SwiftData-Modelle dafür nicht lohnen.
|
||||
public var offlineMediaData: Data?
|
||||
|
||||
/// Zum Fortsetzen nach dem Pausieren. iOS liefert diesen Datensatz beim
|
||||
/// Abbrechen; ohne ihn beginnt ein pausierter Download wieder bei null.
|
||||
public var resumeData: Data?
|
||||
|
||||
public init(
|
||||
ratingKey: String,
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
sourceURLString: String,
|
||||
state: DownloadState = .queued,
|
||||
totalBytes: Int64 = 0,
|
||||
seriesKey: String? = nil,
|
||||
seriesTitle: String? = nil,
|
||||
seasonKey: String? = nil,
|
||||
seasonIndex: Int? = nil,
|
||||
episodeIndex: Int? = nil,
|
||||
durationMilliseconds: Int? = nil,
|
||||
offlineMediaData: Data? = nil,
|
||||
expectedIsExact: Bool = false
|
||||
) {
|
||||
self.ratingKey = ratingKey
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.sourceURLString = sourceURLString
|
||||
self.stateRaw = state.rawValue
|
||||
self.totalBytes = totalBytes
|
||||
self.receivedBytes = 0
|
||||
self.seriesKey = seriesKey
|
||||
self.seriesTitle = seriesTitle
|
||||
self.seasonKey = seasonKey
|
||||
self.seasonIndex = seasonIndex
|
||||
self.episodeIndex = episodeIndex
|
||||
self.durationMilliseconds = durationMilliseconds
|
||||
self.offlineMediaData = offlineMediaData
|
||||
self.expectedIsExact = expectedIsExact
|
||||
self.addedAt = Date()
|
||||
}
|
||||
|
||||
public var state: DownloadState {
|
||||
get { DownloadState(rawValue: stateRaw) ?? .failed }
|
||||
set { stateRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
/// 0…1, oder `nil` solange die Gesamtgröße unbekannt ist.
|
||||
///
|
||||
/// Ein Balken bei null Prozent, der sich nie bewegt, sieht aus wie ein
|
||||
/// hängender Download — ohne Gesamtgröße ist ein unbestimmter Fortschritt
|
||||
/// die ehrlichere Anzeige.
|
||||
public var progress: Double? {
|
||||
guard totalBytes > 0 else { return nil }
|
||||
return min(Double(receivedBytes) / Double(totalBytes), 1)
|
||||
}
|
||||
|
||||
public var isComplete: Bool { state == .completed }
|
||||
|
||||
/// Wie viel Platz dieser Eintrag gerade belegt.
|
||||
public var occupiedBytes: Int64 {
|
||||
state.occupiesStorage ? max(receivedBytes, 0) : 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Was der Player offline über eine Datei wissen muss.
|
||||
///
|
||||
/// Ein Abzug der Serverangaben zum Zeitpunkt des Downloads. Offline lässt sich
|
||||
/// nichts nachfragen — und der `PlaybackDecider` braucht Container und Codecs,
|
||||
/// um zwischen die Engines zu wählen.
|
||||
public struct OfflineMedia: Sendable, Codable, Equatable {
|
||||
public var container: String?
|
||||
public var videoCodec: String?
|
||||
public var audioCodec: String?
|
||||
public var videoResolution: String?
|
||||
public var bitrate: Int?
|
||||
public var audioLanguages: [String]
|
||||
public var subtitleLanguages: [String]
|
||||
/// Intro und Abspann, in Millisekunden.
|
||||
public var markers: [OfflineMarker]
|
||||
|
||||
public init(
|
||||
container: String? = nil,
|
||||
videoCodec: String? = nil,
|
||||
audioCodec: String? = nil,
|
||||
videoResolution: String? = nil,
|
||||
bitrate: Int? = nil,
|
||||
audioLanguages: [String] = [],
|
||||
subtitleLanguages: [String] = [],
|
||||
markers: [OfflineMarker] = []
|
||||
) {
|
||||
self.container = container
|
||||
self.videoCodec = videoCodec
|
||||
self.audioCodec = audioCodec
|
||||
self.videoResolution = videoResolution
|
||||
self.bitrate = bitrate
|
||||
self.audioLanguages = audioLanguages
|
||||
self.subtitleLanguages = subtitleLanguages
|
||||
self.markers = markers
|
||||
}
|
||||
}
|
||||
|
||||
public struct OfflineMarker: Sendable, Codable, Equatable {
|
||||
public var type: String
|
||||
public var startMilliseconds: Int
|
||||
public var endMilliseconds: Int
|
||||
|
||||
public init(type: String, startMilliseconds: Int, endMilliseconds: Int) {
|
||||
self.type = type
|
||||
self.startMilliseconds = startMilliseconds
|
||||
self.endMilliseconds = endMilliseconds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
/// Warum gerade nichts übertragen wird, obwohl etwas in der Schlange steht.
|
||||
///
|
||||
/// Die Hintergrund-Sitzung wartet in diesen Fällen von sich aus — sie meldet
|
||||
/// keinen Fehler, sie tut nur nichts. Für den Betrachter sieht das aus wie
|
||||
/// ein hängender Download: der Balken steht, und nichts sagt, worauf.
|
||||
public enum TransferHold: Sendable, Equatable {
|
||||
case none
|
||||
/// „Nur über WLAN" ist an, und das Gerät hängt am Mobilfunk.
|
||||
case waitingForWiFi
|
||||
/// Gar keine Verbindung.
|
||||
case offline
|
||||
|
||||
/// Was die Übertragung gerade aufhält.
|
||||
///
|
||||
/// `isExpensive` statt einer Abfrage auf den Schnittstellentyp: der Wert
|
||||
/// deckt auch den persönlichen Hotspot ab — technisch WLAN, in der
|
||||
/// Abrechnung Mobilfunk. Wer dort lädt, zahlt es trotzdem.
|
||||
public static func current(
|
||||
isWiFiOnly: Bool,
|
||||
isReachable: Bool,
|
||||
isExpensive: Bool
|
||||
) -> TransferHold {
|
||||
// Ohne Verbindung hilft auch ein ausgeschalteter WLAN-Zwang nicht —
|
||||
// deshalb zuerst geprüft.
|
||||
if !isReachable { return .offline }
|
||||
if isWiFiOnly, isExpensive { return .waitingForWiFi }
|
||||
return .none
|
||||
}
|
||||
|
||||
public var isHolding: Bool { self != .none }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
// **Nur für den Rechner, auf dem entwickelt wird.**
|
||||
//
|
||||
// Das hier ist ein Kommandozeilenwerkzeug: es fragt nach einem Passwort über
|
||||
// `getpass`, löst Rechnernamen über `Host` auf und druckt in ein Terminal.
|
||||
// Nichts davon gibt es auf iOS, und auf tvOS erst recht nicht — die App
|
||||
// hängt auch nicht daran. Ohne diese Bedingung scheitert aber jeder Versuch,
|
||||
// **das ganze Paket** für eine andere Plattform zu übersetzen, an einem
|
||||
// Werkzeug, das dort nie laufen sollte. Das kostete eine halbe Stunde
|
||||
// Fehlersuche an der falschen Stelle.
|
||||
#if os(macOS)
|
||||
import Foundation
|
||||
import JellyfinKit
|
||||
import MediaKit
|
||||
|
||||
// Die Frage, für die dieser Teil des Werkzeugs gebaut ist:
|
||||
//
|
||||
// Liefert der Server eine HLS-Liste mit **mehreren** Stufen — oder mit
|
||||
// einer?
|
||||
//
|
||||
// Davon hängt alles ab. AVPlayer kann nur zwischen Varianten umschalten, die
|
||||
// in der Liste stehen; bei einer gibt es nichts zu wechseln, und jedes
|
||||
// Nachregeln greift ins Leere. Bisher war das eine Annahme, und zwei
|
||||
// Kommentare im Code haben sie als Tatsache ausgegeben.
|
||||
//
|
||||
// Geprüft wird über HTTP, nicht über einen Build aufs Gerät. Der Token bleibt
|
||||
// im Schlüsselbund; gedruckt werden Stufen und Zahlen, nie eine vollständige
|
||||
// Adresse.
|
||||
|
||||
/// Wie der Server die Herkunft dieser Anfrage einordnet.
|
||||
///
|
||||
/// `isInNetwork` ist genau die Größe, an der Jellyfins ABR-Sperre hängt.
|
||||
struct Endpunktlage: Decodable {
|
||||
let isLocal: Bool
|
||||
let isInNetwork: Bool
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case isLocal = "IsLocal"
|
||||
case isInNetwork = "IsInNetwork"
|
||||
}
|
||||
}
|
||||
|
||||
/// Welche Fassung der Server hat.
|
||||
struct Serverfassung: Decodable {
|
||||
let version: String?
|
||||
private enum CodingKeys: String, CodingKey { case version = "Version" }
|
||||
}
|
||||
|
||||
/// Die Netzwerkeinstellungen des Servers — nur der Teil, der hier zählt.
|
||||
struct Netzwerkeinstellung: Decodable {
|
||||
let knownProxies: [String]?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case knownProxies = "KnownProxies"
|
||||
}
|
||||
}
|
||||
|
||||
/// Was in einer Playlist steht.
|
||||
struct Leiter {
|
||||
var stufen: [(bandbreite: Int, auflösung: String?)] = []
|
||||
var istMaster = false
|
||||
var zeilen = 0
|
||||
|
||||
var beschreibung: String {
|
||||
guard istMaster else {
|
||||
return " Keine Master-Playlist (\(zeilen) Zeilen) — das ist eine Liste von Schnipseln, keine von Stufen."
|
||||
}
|
||||
guard !stufen.isEmpty else {
|
||||
return " Master-Playlist ohne eine einzige Stufe."
|
||||
}
|
||||
var text = " \(stufen.count) Stufe\(stufen.count == 1 ? "" : "n"):\n"
|
||||
for stufe in stufen.sorted(by: { $0.bandbreite < $1.bandbreite }) {
|
||||
let mbit = String(format: "%.1f", Double(stufe.bandbreite) / 1_000_000)
|
||||
text += " \(mbit) Mbit/s"
|
||||
if let auflösung = stufe.auflösung { text += " · \(auflösung)" }
|
||||
text += "\n"
|
||||
}
|
||||
if stufen.count == 1 {
|
||||
text += " → Eine Stufe. Es gibt nichts, worauf der Player ausweichen könnte.\n"
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
/// Liest eine Playlist und zählt, was drinsteht.
|
||||
func leiter(in text: String) -> Leiter {
|
||||
var ergebnis = Leiter()
|
||||
let zeilen = text.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
ergebnis.zeilen = zeilen.count
|
||||
|
||||
for zeile in zeilen where zeile.hasPrefix("#EXT-X-STREAM-INF:") {
|
||||
ergebnis.istMaster = true
|
||||
let attribute = zeile.dropFirst("#EXT-X-STREAM-INF:".count)
|
||||
|
||||
func wert(_ name: String) -> String? {
|
||||
// Reicht für BANDWIDTH und RESOLUTION: beide sind unquotiert.
|
||||
guard let bereich = attribute.range(of: "\(name)=") else { return nil }
|
||||
let rest = attribute[bereich.upperBound...]
|
||||
return String(rest.prefix { $0 != "," })
|
||||
}
|
||||
|
||||
guard let roh = wert("BANDWIDTH"), let bandbreite = Int(roh) else { continue }
|
||||
ergebnis.stufen.append((bandbreite, wert("RESOLUTION")))
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
/// Holt eine Playlist und beurteilt sie.
|
||||
func prüfePlaylist(_ url: URL, kopfzeilen: [String: String] = [:]) async -> String {
|
||||
var request = URLRequest(url: url)
|
||||
for (name, wert) in kopfzeilen { request.setValue(wert, forHTTPHeaderField: name) }
|
||||
|
||||
do {
|
||||
let (daten, antwort) = try await URLSession.shared.data(for: request)
|
||||
let status = (antwort as? HTTPURLResponse)?.statusCode ?? 0
|
||||
guard (200..<300).contains(status) else {
|
||||
return " HTTP \(status) — keine Playlist bekommen."
|
||||
}
|
||||
let text = String(decoding: daten, as: UTF8.self)
|
||||
let gelesen = leiter(in: text)
|
||||
// **Bei einer Stufe den Wortlaut zeigen.** Ab hier hilft keine
|
||||
// Zusammenfassung mehr: was fehlt, sieht man nur im Original.
|
||||
guard gelesen.stufen.count <= 1 else { return gelesen.beschreibung }
|
||||
return gelesen.beschreibung + "\n ——— Wortlaut ———\n"
|
||||
+ text.split(separator: "\n").map { " " + $0 }.joined(separator: "\n")
|
||||
} catch {
|
||||
return " Nicht erreichbar: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Setzt einen Abfragewert — vorhandene gleichen Namens werden **ersetzt**.
|
||||
///
|
||||
/// Dieselbe Falle wie bei `maxHeight` im Backend: bei zwei gleichnamigen
|
||||
/// Werten gilt beim Server der erste, und ein danebengestellter zweiter
|
||||
/// bewirkt nichts.
|
||||
func setzend(_ name: String, auf wert: String, in url: URL) -> URL {
|
||||
guard var teile = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return url }
|
||||
var werte = (teile.queryItems ?? []).filter { $0.name.lowercased() != name.lowercased() }
|
||||
werte.append(URLQueryItem(name: name, value: wert))
|
||||
teile.queryItems = werte
|
||||
return teile.url ?? url
|
||||
}
|
||||
|
||||
/// Macht aus einer Varianten-Playlist die Master-Playlist.
|
||||
///
|
||||
/// `main.m3u8` ist die Liste der **Schnipsel** einer Stufe, `master.m3u8` die
|
||||
/// Liste der **Stufen**. Nur auf letzterer kann der Parameter für mehrere
|
||||
/// Varianten überhaupt wirken.
|
||||
func alsMaster(_ url: URL) -> URL {
|
||||
guard url.lastPathComponent == "main.m3u8" else { return url }
|
||||
return url.deletingLastPathComponent().appending(path: "master.m3u8")
|
||||
}
|
||||
|
||||
// MARK: - Jellyfin
|
||||
|
||||
/// Fragt einen Jellyfin-Server, welche Playlist er für einen Titel ausgibt.
|
||||
///
|
||||
/// Das Passwort wird hier eingetippt und nirgends aufbewahrt; der Token bleibt
|
||||
/// in diesem Prozess. Gedruckt wird der **Pfad** der Adresse, nie die
|
||||
/// vollständige mit Schlüssel.
|
||||
func jellyfinLeiter(host: String) async {
|
||||
guard let basis = URL(string: host.contains("://") ? host : "http://\(host)") else {
|
||||
print("Keine brauchbare Adresse: \(host)")
|
||||
return
|
||||
}
|
||||
|
||||
print("Benutzername: ", terminator: "")
|
||||
guard let name = readLine(), !name.isEmpty else { return }
|
||||
|
||||
// Ohne Echo, damit nichts im Bild und nichts im Verlauf steht.
|
||||
guard let passwort = String(validatingCString: getpass("Passwort: ")), !passwort.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let identity = JellyfinIdentity(
|
||||
client: "Halo-Probe",
|
||||
device: "Mac",
|
||||
deviceID: "halo-probe-" + UUID().uuidString,
|
||||
version: "1.0"
|
||||
)
|
||||
let client = JellyfinClient(baseURL: basis, identity: identity)
|
||||
|
||||
let konto: JellyfinAccount
|
||||
do {
|
||||
konto = try await JellyfinAuthenticator(client: client).signIn(
|
||||
userName: name,
|
||||
password: passwort
|
||||
)
|
||||
} catch {
|
||||
print("Anmeldung gescheitert: \(error)")
|
||||
return
|
||||
}
|
||||
print("Angemeldet als \(konto.userName).")
|
||||
|
||||
// Die Fassung des Servers. Ohne sie lese ich womöglich den Quelltext
|
||||
// einer anderen — und suche den Fehler an einer Stelle, die es dort
|
||||
// gar nicht gibt.
|
||||
if let info = try? await client.send(
|
||||
JellyfinRequest(path: "/System/Info/Public"),
|
||||
as: Serverfassung.self
|
||||
) {
|
||||
print("Jellyfin \(info.version ?? "unbekannt")")
|
||||
}
|
||||
print("")
|
||||
|
||||
let backend = JellyfinBackend(client: client, account: konto, serverName: "Probe")
|
||||
|
||||
// **Die Frage, die alles andere erklärt, direkt an den Server.**
|
||||
//
|
||||
// `/System/Endpoint` beantwortet `IsInNetwork` mit demselben Aufruf,
|
||||
// mit dem auch die ABR-Sperre entscheidet:
|
||||
// `_networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIP())`.
|
||||
// Damit muss niemand mehr raten, ob „von zuhause" der Grund ist.
|
||||
let lage = try? await client.send(
|
||||
JellyfinRequest(path: "/System/Endpoint"),
|
||||
as: Endpunktlage.self
|
||||
)
|
||||
if let lage {
|
||||
// Beide Werte ausdrücklich: sie unterscheiden zwei Lagen mit ganz
|
||||
// verschiedenen Ursachen.
|
||||
print("Der Server sieht: IsLocal=\(lage.isLocal), IsInNetwork=\(lage.isInNetwork)")
|
||||
if lage.isLocal {
|
||||
print(" → IsLocal heißt: die Anfrage kommt vom Rechner selbst,")
|
||||
print(" also über 127.0.0.1. Dann steht der Proxy auf demselben")
|
||||
print(" Host, und der Eintrag unter Bekannte Proxys muss 127.0.0.1")
|
||||
print(" lauten (bzw. die Adresse im Docker-Netz), nicht 192.168.x.")
|
||||
}
|
||||
// **Was der Server tatsächlich eingetragen hat.**
|
||||
//
|
||||
// Ein Eintrag, der gespeichert, aber nicht übernommen wurde, sieht
|
||||
// von außen genauso aus wie gar keiner. Hier steht, was gilt.
|
||||
if let netz = try? await client.send(
|
||||
JellyfinRequest(path: "/System/Configuration/network"),
|
||||
as: Netzwerkeinstellung.self
|
||||
) {
|
||||
let proxys = netz.knownProxies ?? []
|
||||
if proxys.isEmpty {
|
||||
print(" Bekannte Proxys: KEINE eingetragen.")
|
||||
} else {
|
||||
print(" Bekannte Proxys: \(proxys.joined(separator: ", "))")
|
||||
}
|
||||
}
|
||||
|
||||
if lage.isInNetwork {
|
||||
print("Der Server hält diese Verbindung für LOKAL.")
|
||||
print(" → Eine Leiter wird er hier grundsätzlich nicht liefern,")
|
||||
print(" ganz gleich was wir anfragen. Das Folgende sagt damit")
|
||||
print(" nichts darüber, was von unterwegs käme.")
|
||||
} else {
|
||||
print("Der Server hält diese Verbindung für AUSSERHALB.")
|
||||
print(" → Hier ist eine Leiter möglich. Kommt trotzdem nur eine")
|
||||
print(" Stufe, liegt es an etwas anderem.")
|
||||
}
|
||||
print("")
|
||||
}
|
||||
|
||||
// Irgendeinen Film. Es geht um die Form der Antwort, nicht um den Inhalt.
|
||||
guard let mediathek = (try? await backend.libraries())?.first(where: { $0.kind == .movie }),
|
||||
let kachel = try? await backend.items(
|
||||
inLibrary: mediathek,
|
||||
query: MediaQuery(),
|
||||
offset: 0,
|
||||
limit: 1
|
||||
).items.first
|
||||
else {
|
||||
print("Keine Filmmediathek mit einem Titel gefunden.")
|
||||
return
|
||||
}
|
||||
|
||||
// Die Kachel aus der Liste trägt keine Dateiangaben — die kommen erst mit
|
||||
// den Einzelheiten. Ohne sie lässt sich keine Wiedergabe vorbereiten.
|
||||
let film = (try? await backend.item(id: kachel.id)) ?? kachel
|
||||
guard let fassung = film.preferredVersion, let datei = fassung.files.first else {
|
||||
print("Zu „\(film.title)“ meldet der Server keine Datei.")
|
||||
return
|
||||
}
|
||||
print("Titel: \(film.title)\n")
|
||||
|
||||
guard let ziel = try? await backend.preparePlayback(PlaybackIntent(
|
||||
itemID: film.id,
|
||||
versionIndex: 0,
|
||||
file: datei,
|
||||
// Ausdrücklich umrechnen lassen: nur dann gibt es überhaupt eine
|
||||
// Playlist, über die sich die Frage stellen lässt.
|
||||
transcode: TranscodeSettings(maximumBitrate: 8_000, resolution: nil)
|
||||
)) else {
|
||||
print("Der Server gibt für diesen Titel keine Transcode-Adresse her.")
|
||||
return
|
||||
}
|
||||
|
||||
let kopf = ["X-Emby-Token": konto.token]
|
||||
let adresse = ziel.url
|
||||
|
||||
print("Was der Server von sich aus liefert:")
|
||||
print(" Pfad: \(adresse.path)")
|
||||
let abfrage = URLComponents(url: adresse, resolvingAgainstBaseURL: true)?.queryItems ?? []
|
||||
print(" Abfragewerte: \(abfrage.map(\.name).sorted().joined(separator: ", "))")
|
||||
|
||||
// **Die Werte nur von den harmlosen Schlüsseln.** Schlüssel, Kennungen
|
||||
// und Sitzungsnummern bleiben ungedruckt — aber ob Bild und Ton
|
||||
// umgerechnet oder nur kopiert werden, entscheidet mit darüber, ob es
|
||||
// überhaupt eine Leiter geben kann, und steht nirgends sonst.
|
||||
let unbedenklich = ["VideoCodec", "AudioCodec", "VideoBitrate", "AudioBitrate",
|
||||
"TranscodeReasons", "SegmentContainer", "maxHeight"]
|
||||
let gezeigt = abfrage
|
||||
.filter { unbedenklich.contains($0.name) }
|
||||
.map { "\($0.name)=\($0.value ?? "")" }
|
||||
.sorted()
|
||||
if !gezeigt.isEmpty { print(" Davon: \(gezeigt.joined(separator: ", "))") }
|
||||
|
||||
if abfrage.contains(where: { $0.name == "AudioCodec" && $0.value?.lowercased() == "copy" })
|
||||
|| abfrage.contains(where: { $0.name == "VideoCodec" && $0.value?.lowercased() == "copy" }) {
|
||||
print("")
|
||||
print(" ACHTUNG: Hier wird kopiert statt gerechnet. Jellyfin liefert")
|
||||
print(" dann grundsätzlich keine Leiter — auch von unterwegs nicht.")
|
||||
}
|
||||
print("")
|
||||
|
||||
print("1) Unverändert")
|
||||
print(await prüfePlaylist(adresse, kopfzeilen: kopf))
|
||||
|
||||
let master = alsMaster(adresse)
|
||||
if master != adresse {
|
||||
print("2) Als master.m3u8")
|
||||
print(await prüfePlaylist(master, kopfzeilen: kopf))
|
||||
}
|
||||
|
||||
print("3) Als master.m3u8 mit enableAdaptiveBitrateStreaming=true")
|
||||
let mitABR = await prüfePlaylist(
|
||||
setzend("enableAdaptiveBitrateStreaming", auf: "true", in: master),
|
||||
kopfzeilen: kopf
|
||||
)
|
||||
print(mitABR)
|
||||
|
||||
if mitABR.contains("1 Stufe:") {
|
||||
erklärungWarumKeineLeiter()
|
||||
await prüfeSelbstgebauteLeiter(master: master, kopfzeilen: kopf)
|
||||
}
|
||||
|
||||
// Aufräumen: die Aushandlung hat am Server eine Sitzung angelegt, und
|
||||
// ohne dieses Signal rechnet der Transcoder weiter, obwohl niemand
|
||||
// zusieht. Ein Messwerkzeug, das Arbeit hinterlässt, ist ein schlechtes.
|
||||
if let sitzung = ziel.sessionID {
|
||||
await backend.endPlayback(sessionID: sitzung)
|
||||
}
|
||||
print("")
|
||||
print("Transcode-Sitzung beendet.")
|
||||
}
|
||||
|
||||
/// Sagt, woran es liegen kann — aus Jellyfins eigenem Quelltext.
|
||||
///
|
||||
/// `DynamicHlsHelper.EnableAdaptiveBitrateStreaming` prüft der Reihe nach,
|
||||
/// und die erste Hürde ist die überraschendste.
|
||||
func erklärungWarumKeineLeiter() {
|
||||
print("")
|
||||
print(" Warum nur eine Stufe kommt — aus Jellyfins Quelltext,")
|
||||
print(" `DynamicHlsHelper.EnableAdaptiveBitrateStreaming`, der Reihe nach:")
|
||||
print("")
|
||||
print(" 1. Im lokalen Netz liefert Jellyfin grundsätzlich keine Leiter.")
|
||||
print(" Wörtlich im Quelltext: „Within the local network this will")
|
||||
print(" likely do more harm than good.\" Das gilt auch für ein")
|
||||
print(" schwaches WLAN — unterschieden wird da nicht.")
|
||||
print(" 2. Ohne den Parameter keine Leiter; im Server steht er auf false.")
|
||||
print(" 3. Bei einem laufenden Sender nicht.")
|
||||
print(" 4. Wird Bild ODER Ton nur kopiert statt neu gerechnet, nicht.")
|
||||
print(" Das trifft oft zu, wenn der Ton schon AAC ist.")
|
||||
print("")
|
||||
print(" Kam dieser Aufruf von zuhause, ist Punkt 1 die Erklärung — und dann")
|
||||
print(" sagt die Messung nichts darüber, was von unterwegs käme.")
|
||||
}
|
||||
|
||||
/// Prüft, ob wir uns die Leiter **selbst** bauen könnten — und ob das etwas
|
||||
/// brächte.
|
||||
///
|
||||
/// Eine Master-Playlist ist nur ein Menü: eine Liste von Adressen. Jellyfins
|
||||
/// eigene Leiter bestünde aus derselben `main.m3u8`-Adresse, mehrfach, mit
|
||||
/// anderem `VideoBitrate`. Schreiben wir das Menü selbst, brauchen wir seine
|
||||
/// Zustimmung nicht.
|
||||
///
|
||||
/// **Die Playlist zu bekommen beweist aber noch nichts.** Sie ist Text und
|
||||
/// kostet nichts; die Datenrate steckt in den Schnipseln. Entscheidend ist
|
||||
/// deshalb allein, ob die Schnipsel bei kleinerer Zahl auch kleiner werden —
|
||||
/// oder ob der Server stur denselben Stream ausliefert, weil die Sitzung schon
|
||||
/// läuft. Deshalb wird hier gewogen, nicht gezählt.
|
||||
func prüfeSelbstgebauteLeiter(master: URL, kopfzeilen: [String: String]) async {
|
||||
print("")
|
||||
print("=== Ginge eine selbstgebaute Leiter — und brächte sie etwas? ===")
|
||||
|
||||
guard let (daten, _) = try? await URLSession.shared.data(for: anfrage(master, kopfzeilen)),
|
||||
let zeile = String(decoding: daten, as: UTF8.self)
|
||||
.split(separator: "\n")
|
||||
.first(where: { $0.hasPrefix("main.m3u8") }),
|
||||
let unter = URL(string: String(zeile), relativeTo: master)?.absoluteURL
|
||||
else {
|
||||
print(" Keine Unter-Playlist im Menü gefunden.")
|
||||
return
|
||||
}
|
||||
|
||||
let versuche: [(String, Int?, Int?)] = [
|
||||
("wie gehabt", nil, nil),
|
||||
("halbe Bitrate", 3_000_000, nil),
|
||||
("ein Viertel", 1_500_000, nil),
|
||||
("ein Viertel + 480p", 1_500_000, 480),
|
||||
]
|
||||
|
||||
for (name, bitrate, höhe) in versuche {
|
||||
var ziel = unter
|
||||
if let bitrate { ziel = setzend("VideoBitrate", auf: String(bitrate), in: ziel) }
|
||||
if let höhe { ziel = setzend("maxHeight", auf: String(höhe), in: ziel) }
|
||||
print(" \(name): \(await wiegeErstenSchnipsel(ziel, kopfzeilen: kopfzeilen))")
|
||||
}
|
||||
|
||||
print("")
|
||||
print(" Werden die Schnipsel kleiner, trägt die Idee. Bleiben sie gleich")
|
||||
print(" gross, rechnet der Server trotz anderer Zahl denselben Stream —")
|
||||
print(" dann bringt ein selbstgebautes Menü nichts.")
|
||||
}
|
||||
|
||||
/// Holt die Unter-Playlist, lädt den ersten Schnipsel und rechnet aus, was das
|
||||
/// an Datenrate bedeutet.
|
||||
private func wiegeErstenSchnipsel(_ playlist: URL, kopfzeilen: [String: String]) async -> String {
|
||||
guard let (daten, antwort) = try? await URLSession.shared.data(for: anfrage(playlist, kopfzeilen)) else {
|
||||
return "nicht erreichbar"
|
||||
}
|
||||
let status = (antwort as? HTTPURLResponse)?.statusCode ?? 0
|
||||
guard (200..<300).contains(status) else { return "HTTP \(status)" }
|
||||
|
||||
let zeilen = String(decoding: daten, as: UTF8.self).split(separator: "\n")
|
||||
guard let dauerZeile = zeilen.firstIndex(where: { $0.hasPrefix("#EXTINF") }),
|
||||
zeilen.indices.contains(dauerZeile + 1),
|
||||
let schnipselURL = URL(string: String(zeilen[dauerZeile + 1]), relativeTo: playlist)?.absoluteURL
|
||||
else { return "HTTP \(status), aber kein Schnipsel darin" }
|
||||
|
||||
let dauer = Double(
|
||||
zeilen[dauerZeile]
|
||||
.dropFirst("#EXTINF:".count)
|
||||
.prefix { $0 != "," }
|
||||
) ?? 0
|
||||
|
||||
guard let (rohdaten, schnipselAntwort) = try? await URLSession.shared.data(
|
||||
for: anfrage(schnipselURL, kopfzeilen)
|
||||
) else { return "Playlist ja, Schnipsel nicht erreichbar" }
|
||||
|
||||
let schnipselStatus = (schnipselAntwort as? HTTPURLResponse)?.statusCode ?? 0
|
||||
guard (200..<300).contains(schnipselStatus) else {
|
||||
return "Playlist ja, Schnipsel HTTP \(schnipselStatus)"
|
||||
}
|
||||
|
||||
let kilobyte = Double(rohdaten.count) / 1024
|
||||
guard dauer > 0 else { return String(format: "%.0f kB", kilobyte) }
|
||||
let kilobit = Double(rohdaten.count) * 8 / 1000 / dauer
|
||||
return String(format: "%.0f kB für %.1f s → %.0f kbit/s", kilobyte, dauer, kilobit)
|
||||
}
|
||||
|
||||
private func anfrage(_ url: URL, _ kopfzeilen: [String: String]) -> URLRequest {
|
||||
var request = URLRequest(url: url)
|
||||
for (name, wert) in kopfzeilen { request.setValue(wert, forHTTPHeaderField: name) }
|
||||
return request
|
||||
}
|
||||
|
||||
/// Zeigt, was der Server über die Tonspuren eines Titels wirklich sagt.
|
||||
///
|
||||
/// **Der Grund:** Die Sprachwahl vergleicht Kürzel, und Kürzel lügen. Ob bei
|
||||
/// einer Datei der Titel das rettet — „eng" im Kürzel, „Deutsch" im Namen —,
|
||||
/// sieht man nur, wenn man beides nebeneinander legt. Alles andere wäre
|
||||
/// geraten.
|
||||
func zeigeSpuren(host: String, suche: String) async {
|
||||
guard let basis = URL(string: host.contains("://") ? host : "http://\(host)") else { return }
|
||||
|
||||
print("Benutzername: ", terminator: "")
|
||||
guard let name = readLine(), !name.isEmpty else { return }
|
||||
guard let passwort = String(validatingCString: getpass("Passwort: ")), !passwort.isEmpty else { return }
|
||||
|
||||
let identity = JellyfinIdentity(
|
||||
client: "Halo-Probe",
|
||||
device: "Mac",
|
||||
deviceID: "halo-probe-" + UUID().uuidString,
|
||||
version: "1.0"
|
||||
)
|
||||
let client = JellyfinClient(baseURL: basis, identity: identity)
|
||||
|
||||
guard let konto = try? await JellyfinAuthenticator(client: client).signIn(
|
||||
userName: name,
|
||||
password: passwort
|
||||
) else {
|
||||
print("Anmeldung gescheitert.")
|
||||
return
|
||||
}
|
||||
print("Angemeldet als \(konto.userName).\n")
|
||||
|
||||
// Suchen, damit man nicht mit Kennungen hantieren muss.
|
||||
let treffer = try? await client.send(
|
||||
JellyfinRequest(path: "/Users/\(konto.userID)/Items", query: [
|
||||
URLQueryItem(name: "searchTerm", value: suche),
|
||||
URLQueryItem(name: "Recursive", value: "true"),
|
||||
URLQueryItem(name: "IncludeItemTypes", value: "Movie,Episode"),
|
||||
URLQueryItem(name: "Limit", value: "5"),
|
||||
URLQueryItem(name: "Fields", value: "MediaSources"),
|
||||
]),
|
||||
as: JellyfinItemPage.self
|
||||
)
|
||||
|
||||
guard let gefunden = treffer?.items, !gefunden.isEmpty else {
|
||||
print("Nichts gefunden zu „\(suche)“.")
|
||||
return
|
||||
}
|
||||
|
||||
for eintrag in gefunden {
|
||||
print("── \(eintrag.name ?? "—")")
|
||||
let quellen = eintrag.mediaSources ?? []
|
||||
guard let streams = quellen.first?.mediaStreams else {
|
||||
print(" keine Spurangaben\n")
|
||||
continue
|
||||
}
|
||||
|
||||
for spur in streams where spur.type == "Audio" {
|
||||
let kürzel = spur.language ?? "—"
|
||||
let name = spur.title ?? "—"
|
||||
var merkmale: [String] = []
|
||||
if spur.isDefault == true { merkmale.append("Standard") }
|
||||
if spur.isForced == true { merkmale.append("erzwungen") }
|
||||
if spur.isHearingImpaired == true { merkmale.append("hörgeschädigt") }
|
||||
if spur.isOriginal == true { merkmale.append("Original") }
|
||||
|
||||
print(" [\(spur.index ?? -1)] Kürzel \(kürzel)")
|
||||
print(" Name in der Datei: \(name)")
|
||||
print(" Wie Jellyfin es zeigt: \(spur.displayTitle ?? "—")")
|
||||
print(" \(spur.codec ?? "—"), \(spur.channels ?? 0) Kanäle"
|
||||
+ (merkmale.isEmpty ? "" : ", " + merkmale.joined(separator: ", ")))
|
||||
}
|
||||
print("")
|
||||
}
|
||||
|
||||
print("Zu lesen ist das so: stimmt das Kürzel nicht mit dem Namen überein,")
|
||||
print("lügt die Datei — und dann hilft nur der Name oder das Merken.")
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,58 @@
|
||||
// **Nur für den Rechner, auf dem entwickelt wird.**
|
||||
//
|
||||
// Das hier ist ein Kommandozeilenwerkzeug: es fragt nach einem Passwort über
|
||||
// `getpass` und druckt in ein Terminal. Nichts davon gibt es auf iOS, und auf
|
||||
// tvOS erst recht nicht — die App hängt auch nicht daran. Ohne diese Bedingung
|
||||
// scheitert aber jeder Versuch, **das ganze Paket** für eine andere Plattform
|
||||
// zu übersetzen, an einem Werkzeug, das dort nie laufen sollte. Das kostete
|
||||
// eine halbe Stunde Fehlersuche an der falschen Stelle.
|
||||
#if os(macOS)
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
// Ein Werkzeug für die Fragen, die reines HTTP sind.
|
||||
//
|
||||
// Der Grund, dass es das gibt: eine Vermutung über die Schnittstelle eines
|
||||
// Servers ging bisher als TestFlight-Build zum Nutzer und kam einen halben Tag
|
||||
// später als Screenshot zurück. Irgendwann war das Upload-Kontingent des Tages
|
||||
// aufgebraucht — für eine Frage, die keine Oberfläche braucht. Hier dauert
|
||||
// eine Runde Sekunden.
|
||||
//
|
||||
// Der Token wird über die übliche Anmeldung geholt und liegt im Schlüsselbund
|
||||
// dieses Rechners. Er steht nirgends in der Ausgabe: gedruckt werden Pfade,
|
||||
// Kennungen und Antworten, keine vollständigen Adressen.
|
||||
|
||||
if let index = CommandLine.arguments.firstIndex(of: "--playlist"),
|
||||
let roh = CommandLine.arguments.dropFirst(index + 1).first,
|
||||
let url = URL(string: roh) {
|
||||
print("=== \(url.lastPathComponent) ===")
|
||||
print(await prüfePlaylist(url))
|
||||
exit(0)
|
||||
}
|
||||
|
||||
if let index = CommandLine.arguments.firstIndex(of: "--spuren"),
|
||||
let host = CommandLine.arguments.dropFirst(index + 1).first,
|
||||
let suche = CommandLine.arguments.dropFirst(index + 2).first {
|
||||
await zeigeSpuren(host: host, suche: suche)
|
||||
exit(0)
|
||||
}
|
||||
|
||||
if let index = CommandLine.arguments.firstIndex(of: "--jellyfin"),
|
||||
let host = CommandLine.arguments.dropFirst(index + 1).first {
|
||||
await jellyfinLeiter(host: host)
|
||||
exit(0)
|
||||
}
|
||||
|
||||
// Kein Modus erkannt. Ohne diesen Hinweis endete das Werkzeug wortlos — und
|
||||
// ein Tippfehler im Argument sähe aus wie ein Server, der nichts liefert.
|
||||
print("""
|
||||
HaloProbe — fragt Server über reines HTTP, ohne Oberfläche und ohne Gerät.
|
||||
|
||||
--playlist <url> Eine HLS-Leiter prüfen: Varianten, Tonspuren,
|
||||
erster Schnipsel
|
||||
--jellyfin <host> Die Leiter holen, die Jellyfin für einen Titel
|
||||
baut, und begründen, warum sie so aussieht
|
||||
--spuren <host> <suche> Die Tonspuren eines Titels auflisten
|
||||
""")
|
||||
exit(1)
|
||||
#endif
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
/// Macht aus dem, was jemand eintippt, eine Adresse, die antwortet.
|
||||
///
|
||||
/// Jellyfin hat kein Verzeichnis wie plex.tv — die Adresse kommt vom Nutzer.
|
||||
/// Und die tippt niemand vollständig: „192.168.1.50" ohne Schema, ohne Port,
|
||||
/// oder „medien.example.com" ohne „https://". Dieser Typ rät nicht **eine**
|
||||
/// Ergänzung, sondern nennt die plausiblen in der Reihenfolge, in der sie es
|
||||
/// wahrscheinlich sind; der Aufrufer probiert sie durch und nimmt die erste,
|
||||
/// die sich als Jellyfin-Server ausweist.
|
||||
///
|
||||
/// Die Reihenfolge ist der ganze Inhalt dieser Datei, und sie ist nicht
|
||||
/// beliebig: eine IP-Adresse im eigenen Netz spricht fast nie HTTPS, ein
|
||||
/// Domänenname fast immer.
|
||||
public enum JellyfinAddress {
|
||||
/// Der Standardport eines Jellyfin-Servers.
|
||||
public static let defaultPort = 8096
|
||||
|
||||
public static func candidates(for input: String) -> [URL] {
|
||||
var text = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
while text.hasSuffix("/") { text.removeLast() }
|
||||
guard !text.isEmpty else { return [] }
|
||||
|
||||
// Wer ein Schema hinschreibt, meint es auch so. Daran wird nichts
|
||||
// ergänzt — sonst landet eine Anfrage unverschlüsselt, obwohl jemand
|
||||
// ausdrücklich „https" getippt hat.
|
||||
if text.contains("://") {
|
||||
return URL(string: text).map { [$0] } ?? []
|
||||
}
|
||||
|
||||
// IPv6 in eckigen Klammern: hier wird nicht geraten, weil die
|
||||
// Doppelpunkte sonst als Port gelesen würden.
|
||||
if text.hasPrefix("[") {
|
||||
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
|
||||
}
|
||||
|
||||
let authority = String(text.split(separator: "/", maxSplits: 1).first ?? "")
|
||||
let host = String(authority.split(separator: ":").first ?? "")
|
||||
let hasExplicitPort = authority.contains(":")
|
||||
&& Int(authority.split(separator: ":").last ?? "") != nil
|
||||
|
||||
if hasExplicitPort {
|
||||
// Port genannt, Schema nicht: erst unverschlüsselt, weil ein
|
||||
// eigener Port fast immer ein Server ohne Zertifikat ist.
|
||||
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
|
||||
}
|
||||
|
||||
// Eine reine Zahlenadresse, ein Bonjour-Name oder ein Wort ohne Punkt
|
||||
// — alles Dinge, die es nur im eigenen Netz gibt.
|
||||
let isLocalLooking = host.allSatisfy { $0.isNumber || $0 == "." }
|
||||
|| host.hasSuffix(".local")
|
||||
|| !host.contains(".")
|
||||
|
||||
let forms = isLocalLooking
|
||||
? ["http://\(withDefaultPort(text))", "http://\(text)", "https://\(text)"]
|
||||
: ["https://\(text)", "http://\(text)", "http://\(withDefaultPort(text))"]
|
||||
return forms.compactMap(URL.init(string:))
|
||||
}
|
||||
|
||||
/// Hängt den Standardport an — vor einen etwaigen Pfad, nicht dahinter.
|
||||
private static func withDefaultPort(_ text: String) -> String {
|
||||
guard let slash = text.firstIndex(of: "/") else { return "\(text):\(defaultPort)" }
|
||||
return "\(text[text.startIndex..<slash]):\(defaultPort)\(text[slash...])"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Wer angemeldet ist.
|
||||
public struct JellyfinAccount: Sendable, Equatable {
|
||||
public let userID: String
|
||||
public let userName: String
|
||||
public let token: String
|
||||
/// Ob dieses Konto den Server verwalten darf.
|
||||
///
|
||||
/// Entscheidet, ob der Verwaltungsbereich überhaupt erscheint. Ihn zu
|
||||
/// zeigen und bei jedem Tipp „keine Berechtigung" zu melden, wäre die
|
||||
/// schlechtere Antwort.
|
||||
public let isAdministrator: Bool
|
||||
|
||||
public init(userID: String, userName: String, token: String, isAdministrator: Bool) {
|
||||
self.userID = userID
|
||||
self.userName = userName
|
||||
self.token = token
|
||||
self.isAdministrator = isAdministrator
|
||||
}
|
||||
}
|
||||
|
||||
/// Was ein Server über sich verrät, bevor man angemeldet ist.
|
||||
public struct JellyfinServerInfo: Sendable, Equatable, Decodable {
|
||||
public let serverName: String
|
||||
public let version: String?
|
||||
public let id: String?
|
||||
/// Ob die Einrichtung abgeschlossen ist.
|
||||
public let startupWizardCompleted: Bool?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case serverName = "ServerName"
|
||||
case version = "Version"
|
||||
case id = "Id"
|
||||
case startupWizardCompleted = "StartupWizardCompleted"
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Code, mit dem sich die App ohne Passwort anmeldet.
|
||||
public struct QuickConnectRequest: Sendable, Equatable, Decodable {
|
||||
public let secret: String
|
||||
public let code: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case secret = "Secret"
|
||||
case code = "Code"
|
||||
}
|
||||
}
|
||||
|
||||
/// Meldet an einem Jellyfin-Server an.
|
||||
///
|
||||
/// Zwei Wege, und die Reihenfolge ist Absicht.
|
||||
///
|
||||
/// **Quick Connect** ist der erste: die App zeigt einen sechsstelligen Code,
|
||||
/// den man in einem bereits angemeldeten Client bestätigt. Das Passwort
|
||||
/// erreicht Halo dabei nie — dieselbe Zusage, die bei Plex gilt, wo die
|
||||
/// Anmeldemaske von Plex selbst kommt.
|
||||
///
|
||||
/// **Benutzername und Passwort** sind der Rückfall, falls der Server Quick
|
||||
/// Connect abgeschaltet hat. Dann hält die App das Passwort für die Dauer
|
||||
/// einer Anfrage — sie speichert es nicht, protokolliert es nicht und legt es
|
||||
/// nirgends ab. Gespeichert wird nur der Token, und der im Schlüsselbund.
|
||||
public actor JellyfinAuthenticator {
|
||||
private let client: JellyfinClient
|
||||
|
||||
public init(client: JellyfinClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// Fragt den Server, ob er überhaupt einer ist.
|
||||
public func serverInfo() async throws(MediaBackendError) -> JellyfinServerInfo {
|
||||
try await client.send(
|
||||
JellyfinRequest(path: "/System/Info/Public", timeout: .seconds(10)),
|
||||
as: JellyfinServerInfo.self
|
||||
)
|
||||
}
|
||||
|
||||
/// Ob der Server die Anmeldung per Code anbietet.
|
||||
public func supportsQuickConnect() async -> Bool {
|
||||
// Schweigt er oder antwortet mit einem Fehler, gilt es als
|
||||
// abgeschaltet. Ein Code, den niemand bestätigen kann, wäre eine
|
||||
// Sackgasse mit Wartezeit.
|
||||
guard let data = try? await client.send(JellyfinRequest(path: "/QuickConnect/Enabled")) else {
|
||||
return false
|
||||
}
|
||||
let text = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.lowercased() == "true"
|
||||
}
|
||||
|
||||
public func beginQuickConnect() async throws(MediaBackendError) -> QuickConnectRequest {
|
||||
try await client.send(
|
||||
JellyfinRequest(method: .post, path: "/QuickConnect/Initiate"),
|
||||
as: QuickConnectRequest.self
|
||||
)
|
||||
}
|
||||
|
||||
/// Sieht nach, ob der Code inzwischen bestätigt wurde.
|
||||
///
|
||||
/// Gibt `nil` zurück, solange er es nicht ist — der Aufrufer führt seine
|
||||
/// eigene Schleife, weil er nebenher auf Abbruch achten muss.
|
||||
public func completeQuickConnect(
|
||||
secret: String
|
||||
) async throws(MediaBackendError) -> JellyfinAccount? {
|
||||
let state = try await client.send(
|
||||
JellyfinRequest(
|
||||
path: "/QuickConnect/Connect",
|
||||
query: [URLQueryItem(name: "secret", value: secret)]
|
||||
),
|
||||
as: QuickConnectState.self
|
||||
)
|
||||
guard state.authenticated else { return nil }
|
||||
|
||||
let body = try encode(["Secret": secret])
|
||||
return try await authenticate(
|
||||
JellyfinRequest(method: .post, path: "/Users/AuthenticateWithQuickConnect", body: body)
|
||||
)
|
||||
}
|
||||
|
||||
/// Der Rückfall, wenn Quick Connect abgeschaltet ist.
|
||||
public func signIn(
|
||||
userName: String,
|
||||
password: String
|
||||
) async throws(MediaBackendError) -> JellyfinAccount {
|
||||
let body = try encode(["Username": userName, "Pw": password])
|
||||
return try await authenticate(
|
||||
JellyfinRequest(method: .post, path: "/Users/AuthenticateByName", body: body)
|
||||
)
|
||||
}
|
||||
|
||||
private func authenticate(
|
||||
_ request: JellyfinRequest
|
||||
) async throws(MediaBackendError) -> JellyfinAccount {
|
||||
let result = try await client.send(request, as: AuthenticationResult.self)
|
||||
await client.setToken(result.accessToken)
|
||||
return JellyfinAccount(
|
||||
userID: result.user.id,
|
||||
userName: result.user.name,
|
||||
token: result.accessToken,
|
||||
isAdministrator: result.user.policy?.isAdministrator ?? false
|
||||
)
|
||||
}
|
||||
|
||||
private func encode(_ fields: [String: String]) throws(MediaBackendError) -> Data {
|
||||
do {
|
||||
return try JSONSerialization.data(withJSONObject: fields)
|
||||
} catch {
|
||||
throw .decoding(description: String(describing: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct QuickConnectState: Decodable, Sendable {
|
||||
let authenticated: Bool
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case authenticated = "Authenticated"
|
||||
}
|
||||
}
|
||||
|
||||
private struct AuthenticationResult: Decodable, Sendable {
|
||||
let accessToken: String
|
||||
let user: User
|
||||
|
||||
struct User: Decodable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let policy: Policy?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id = "Id"
|
||||
case name = "Name"
|
||||
case policy = "Policy"
|
||||
}
|
||||
}
|
||||
|
||||
struct Policy: Decodable, Sendable {
|
||||
let isAdministrator: Bool?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case isAdministrator = "IsAdministrator"
|
||||
}
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "AccessToken"
|
||||
case user = "User"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Jellyfin als `LiveTVBackend`.
|
||||
///
|
||||
/// Deutlich kürzer als das Plex-Gegenstück, und das liegt nicht an weniger
|
||||
/// Sorgfalt: Jellyfin hat feste, beschriebene Endpunkte. Es gibt hier nichts
|
||||
/// zu suchen und darum auch nichts zu berichten, wenn die Suche scheitert.
|
||||
///
|
||||
/// Deshalb bleiben `source` und `probes` in der Senderliste leer. Die
|
||||
/// Oberfläche zeigt den Diagnoseblock dann gar nicht erst — er ist die
|
||||
/// Antwort auf ein Problem, das es hier nicht gibt.
|
||||
extension JellyfinBackend: LiveTVBackend {
|
||||
// MARK: - Ob es etwas zu sehen gibt
|
||||
|
||||
public func liveTVAvailability() async -> LiveTVAvailability {
|
||||
do {
|
||||
let info = try await client.send(
|
||||
JellyfinRequest(path: "/LiveTv/Info"),
|
||||
as: JellyfinLiveTVInfo.self
|
||||
)
|
||||
// `IsEnabled` allein genügt nicht: der Server meldet es auch dann,
|
||||
// wenn zwar der Dienst läuft, aber kein Empfänger daran hängt.
|
||||
// Erst die Dienstliste sagt, ob tatsächlich etwas anliegt.
|
||||
guard info.isEnabled == true, !(info.services ?? []).isEmpty else {
|
||||
return .notConfigured
|
||||
}
|
||||
return .available
|
||||
} catch {
|
||||
// Ein Server ohne Live-TV-Unterstützung antwortet hier mit 404.
|
||||
// Das ist kein Fehler, sondern dieselbe Auskunft wie „nichts
|
||||
// eingerichtet" — und die Oberfläche soll sie erklären, nicht
|
||||
// einen Fehlerdialog zeigen.
|
||||
if case .notFound = error { return .notConfigured }
|
||||
return .failed(message: error.probeDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sender
|
||||
|
||||
public func liveChannels() async -> LiveChannelListing {
|
||||
do {
|
||||
let page = try await client.send(
|
||||
JellyfinRequest(
|
||||
path: "/LiveTv/Channels",
|
||||
query: [
|
||||
URLQueryItem(name: "userId", value: account.userID),
|
||||
// Die laufende Sendung gleich mit. Ohne das bräuchte
|
||||
// die Senderliste einen zweiten Aufruf, nur um zu
|
||||
// zeigen, was gerade läuft.
|
||||
URLQueryItem(name: "addCurrentProgram", value: "true"),
|
||||
URLQueryItem(name: "enableImages", value: "true"),
|
||||
URLQueryItem(name: "enableUserData", value: "false"),
|
||||
]
|
||||
),
|
||||
as: JellyfinItemPage.self
|
||||
)
|
||||
return LiveChannelListing(channels: page.items.map(liveChannel(from:)))
|
||||
} catch {
|
||||
return LiveChannelListing(channels: [])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Programm
|
||||
|
||||
public func livePrograms(
|
||||
channelIDs: [String],
|
||||
from: Date,
|
||||
to: Date
|
||||
) async throws(MediaBackendError) -> [String: [LiveProgram]] {
|
||||
var query = [
|
||||
URLQueryItem(name: "userId", value: account.userID),
|
||||
URLQueryItem(name: "minEndDate", value: Self.timestamp(from)),
|
||||
URLQueryItem(name: "maxStartDate", value: Self.timestamp(to)),
|
||||
URLQueryItem(name: "enableImages", value: "true"),
|
||||
URLQueryItem(name: "enableUserData", value: "false"),
|
||||
URLQueryItem(name: "sortBy", value: "StartDate"),
|
||||
]
|
||||
// Leer heißt: alle. Eine leere Liste mitzuschicken hieße dagegen
|
||||
// „keiner", und dann käme nichts zurück.
|
||||
if !channelIDs.isEmpty {
|
||||
query.append(URLQueryItem(name: "channelIds", value: channelIDs.joined(separator: ",")))
|
||||
}
|
||||
|
||||
let page = try await client.send(
|
||||
JellyfinRequest(path: "/LiveTv/Programs", query: query),
|
||||
as: JellyfinItemPage.self
|
||||
)
|
||||
|
||||
// Nach Sender gebündelt, weil das Raster genau so danach fragt.
|
||||
var out: [String: [LiveProgram]] = [:]
|
||||
for item in page.items {
|
||||
guard let program = liveProgram(from: item) else { continue }
|
||||
out[program.channelID, default: []].append(program)
|
||||
}
|
||||
for (channel, programs) in out {
|
||||
out[channel] = programs.sorted { $0.startsAt < $1.startsAt }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: - Einschalten
|
||||
|
||||
public func tune(
|
||||
channel: LiveChannel,
|
||||
program: LiveProgram?,
|
||||
maximumBitrate: Int?
|
||||
) async -> LiveTuneResult {
|
||||
let session = UUID().uuidString
|
||||
|
||||
// Ein Sender ist für Jellyfin ein Eintrag wie jeder andere: derselbe
|
||||
// Weg wie beim Film, nur dass die Quelle nie endet. Der Server nennt
|
||||
// die Adresse; sie selbst zusammenzubauen wäre eine Vermutung darüber,
|
||||
// was er kopieren kann und was er rechnen muss.
|
||||
do {
|
||||
let info = try await playbackInfo(
|
||||
itemID: channel.id,
|
||||
sourceID: channel.id,
|
||||
transcode: TranscodeSettings(maximumBitrate: maximumBitrate, resolution: nil)
|
||||
)
|
||||
|
||||
let source = info.mediaSources?.first
|
||||
if let path = source?.transcodingURL,
|
||||
let url = URL(string: path, relativeTo: client.baseURL)?.absoluteURL {
|
||||
return LiveTuneResult(
|
||||
url: url,
|
||||
sessionID: info.playSessionID ?? session,
|
||||
subscriptionKey: info.playSessionID
|
||||
)
|
||||
}
|
||||
|
||||
// Kein Transcoder-Weg: dann unverändert. Manche Empfänger liefern
|
||||
// bereits einen Stream, den das Gerät ohne Umrechnung versteht.
|
||||
if let url = client.url(
|
||||
path: "/Videos/\(channel.id)/stream",
|
||||
query: [
|
||||
URLQueryItem(name: "static", value: "true"),
|
||||
URLQueryItem(name: "mediaSourceId", value: source?.id ?? channel.id),
|
||||
],
|
||||
includingToken: account.token
|
||||
) {
|
||||
return LiveTuneResult(
|
||||
url: url,
|
||||
sessionID: info.playSessionID ?? session,
|
||||
subscriptionKey: info.playSessionID
|
||||
)
|
||||
}
|
||||
|
||||
return LiveTuneResult(
|
||||
url: nil,
|
||||
sessionID: session,
|
||||
trace: [LiveChannelProbe(
|
||||
path: "/Items/\(channel.id)/PlaybackInfo",
|
||||
result: "Der Server nannte weder eine Transcoder- noch eine Direktadresse"
|
||||
)]
|
||||
)
|
||||
} catch {
|
||||
return LiveTuneResult(
|
||||
url: nil,
|
||||
sessionID: session,
|
||||
trace: [LiveChannelProbe(
|
||||
path: "/Items/\(channel.id)/PlaybackInfo",
|
||||
result: error.probeDescription
|
||||
)]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func endLiveSession(subscriptionKey: String) async {
|
||||
// Dieselbe Freigabe wie am Ende eines Films: der Transcoder rechnet
|
||||
// sonst weiter, und beim Live-TV bleibt dabei auch der Empfänger
|
||||
// belegt — davon gibt es weniger als von allem anderen.
|
||||
await endPlayback(sessionID: subscriptionKey)
|
||||
}
|
||||
|
||||
// MARK: - Übersetzung
|
||||
|
||||
private func liveChannel(from item: JellyfinItem) -> LiveChannel {
|
||||
LiveChannel(
|
||||
id: item.id,
|
||||
// `Number` steht am Sender, `ChannelNumber` an der Sendung.
|
||||
number: item.number ?? item.channelNumber,
|
||||
title: item.name ?? "—",
|
||||
artworkPath: images.primary(itemID: item.id, tag: item.imageTags?["Primary"]),
|
||||
currentProgram: item.currentProgram.flatMap { current in
|
||||
guard let start = current.startDate, let end = current.endDate else { return nil }
|
||||
return LiveProgram(
|
||||
id: current.id,
|
||||
channelID: item.id,
|
||||
title: current.name ?? "—",
|
||||
summary: current.overview,
|
||||
startsAt: start,
|
||||
endsAt: end
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Eine Sendung — oder `nil`, wenn sie keine Zeit hat.
|
||||
///
|
||||
/// Ohne Anfang und Ende lässt sie sich nicht ins Raster legen, und ein
|
||||
/// Block ohne Ausdehnung wäre in der Anzeige ein unsichtbarer Streifen,
|
||||
/// den man nicht treffen kann.
|
||||
private func liveProgram(from item: JellyfinItem) -> LiveProgram? {
|
||||
guard let channelID = item.channelID,
|
||||
let start = item.startDate,
|
||||
let end = item.endDate
|
||||
else { return nil }
|
||||
|
||||
var kind: LiveProgram.Kind = []
|
||||
if item.isMovie == true { kind.insert(.movie) }
|
||||
if item.isSeries == true { kind.insert(.series) }
|
||||
if item.isNews == true { kind.insert(.news) }
|
||||
if item.isSports == true { kind.insert(.sports) }
|
||||
if item.isKids == true { kind.insert(.kids) }
|
||||
if item.isLive == true { kind.insert(.live) }
|
||||
|
||||
return LiveProgram(
|
||||
id: item.id,
|
||||
channelID: channelID,
|
||||
title: item.name ?? "—",
|
||||
// `EpisodeTitle` und nicht `Name`: hier stand der Reihentitel ein
|
||||
// zweites Mal, und im Sheet hätte derselbe Satz zweimal
|
||||
// untereinander gestanden.
|
||||
subtitle: item.episodeTitle,
|
||||
summary: item.overview,
|
||||
startsAt: start,
|
||||
endsAt: end,
|
||||
artworkPath: images.primary(itemID: item.id, tag: item.imageTags?["Primary"]),
|
||||
seriesTimerID: item.seriesTimerID,
|
||||
kind: kind
|
||||
)
|
||||
}
|
||||
|
||||
/// Ein Zeitpunkt, wie Jellyfin ihn in der Adresse erwartet.
|
||||
static func timestamp(_ date: Date) -> String {
|
||||
ISO8601DateFormatter().string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Was der Server über Live-TV sagt
|
||||
|
||||
/// Die Auskunft unter `/LiveTv/Info`.
|
||||
struct JellyfinLiveTVInfo: Decodable, Sendable {
|
||||
let isEnabled: Bool?
|
||||
/// Die eingerichteten Dienste. Leer heißt: kein Empfänger.
|
||||
let services: [Service]?
|
||||
|
||||
struct Service: Decodable, Sendable {
|
||||
let name: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case name = "Name" }
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case isEnabled = "IsEnabled"
|
||||
case services = "Services"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fehler in der Spur
|
||||
|
||||
extension MediaBackendError {
|
||||
/// Knapp und technisch — für die Diagnosespur, nicht für den Nutzer.
|
||||
///
|
||||
/// Bewusst nicht dasselbe wie die Meldung, die in der Oberfläche steht:
|
||||
/// die soll erklären, was zu tun ist, diese soll festhalten, was passiert
|
||||
/// ist. Sie wird nicht übersetzt, weil sie in einem Bericht landet, den
|
||||
/// man weitergibt.
|
||||
var probeDescription: String {
|
||||
switch self {
|
||||
case .unauthorized: "nicht angemeldet"
|
||||
case .notFound: "404"
|
||||
case .noReachableConnection: "keine Verbindung"
|
||||
case .server(let status): "HTTP \(status)"
|
||||
case .transport(let description): description
|
||||
case .decoding(let description): "unerwartete Antwort: \(description)"
|
||||
case .refused(let reason): reason
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
extension JellyfinBackend {
|
||||
// MARK: - Abspielen
|
||||
|
||||
public func preparePlayback(
|
||||
_ intent: PlaybackIntent
|
||||
) async throws(MediaBackendError) -> PlaybackTarget {
|
||||
// Jellyfin trennt Fassung und Datei nicht: was die neutrale Form
|
||||
// „Datei" nennt, ist dort die Quelle, und ihre Kennung wird bei jedem
|
||||
// weiteren Schritt wieder verlangt.
|
||||
let sourceID = intent.file.key ?? intent.itemID
|
||||
|
||||
guard let transcode = intent.transcode else {
|
||||
// `static=true` schaltet jede Umrechnung ab: die Datei kommt so,
|
||||
// wie sie auf der Platte liegt. Ob das Gerät sie überhaupt
|
||||
// abspielen kann, ist vorher entschieden worden — und wenn nicht
|
||||
// der Systemplayer.
|
||||
guard let url = client.url(
|
||||
path: "/Videos/\(intent.itemID)/stream",
|
||||
query: [
|
||||
URLQueryItem(name: "static", value: "true"),
|
||||
URLQueryItem(name: "mediaSourceId", value: sourceID),
|
||||
],
|
||||
includingToken: account.token
|
||||
) else { throw .notFound }
|
||||
|
||||
// Auch hier eine Sitzungskennung, und zwar eine selbst vergebene:
|
||||
// ohne sie ordnet Jellyfin die Fortschrittsmeldungen keiner
|
||||
// Wiedergabe zu, und der Titel bliebe für den Server ungesehen.
|
||||
return PlaybackTarget(
|
||||
url: url,
|
||||
sessionID: UUID().uuidString,
|
||||
versionID: sourceID
|
||||
)
|
||||
}
|
||||
|
||||
let info = try await playbackInfo(
|
||||
itemID: intent.itemID,
|
||||
sourceID: sourceID,
|
||||
transcode: transcode,
|
||||
audioStreamIndex: intent.audioStreamIndex,
|
||||
subtitleStreamIndex: intent.subtitleStreamIndex,
|
||||
remux: transcode.prefersRemux
|
||||
)
|
||||
|
||||
let source = info.mediaSources?.first { $0.id == sourceID } ?? info.mediaSources?.first
|
||||
|
||||
// Die Adresse kommt vom Server, nicht von uns. Er weiß, welche Spuren
|
||||
// er kopieren kann und welche er neu rechnen muss; eine selbst
|
||||
// zusammengebaute Adresse wäre eine Vermutung darüber.
|
||||
guard let path = source?.transcodingURL,
|
||||
let url = URL(string: path, relativeTo: client.baseURL)?.absoluteURL
|
||||
else {
|
||||
throw .refused(reason: String(
|
||||
localized: "player.transcodeUnavailable",
|
||||
bundle: .module
|
||||
))
|
||||
}
|
||||
|
||||
var adresse = Self.limiting(height: transcode.resolution, in: url)
|
||||
|
||||
// **Immer die Master-Liste, auch ohne Leiter.**
|
||||
//
|
||||
// Der Kommentar, der hier stand, war überholt und hat einen Fehler
|
||||
// verdeckt: er stammte aus der Zeit, als Umpacken eine fortlaufende
|
||||
// MP4 war — da wäre eine Abspielliste tatsächlich verkehrt gewesen.
|
||||
// Seit das Umpacken über HLS läuft, ist sie genau richtig.
|
||||
//
|
||||
// Und sie ist nicht nur richtig, sie ist notwendig: eine
|
||||
// Untertitelspur wird mit `#EXT-X-MEDIA` angekündigt, und das steht
|
||||
// ausschliesslich in einer Master-Liste. Auf `main.m3u8` kann es
|
||||
// strukturell keine Untertitel geben.
|
||||
//
|
||||
// Die **Leiter** bleibt davon unberührt. Sie ist eine eigene Frage
|
||||
// und wird weiterhin nur bei „automatisch" und ohne Umpacken
|
||||
// bestellt — beim Umpacken gibt es nur eine Fassung, nämlich die
|
||||
// Datei selbst.
|
||||
if transcode.prefersAdaptive, !transcode.prefersRemux {
|
||||
adresse = Self.adaptive(adresse)
|
||||
} else {
|
||||
adresse = Self.masterPlaylist(adresse)
|
||||
}
|
||||
|
||||
return PlaybackTarget(
|
||||
url: adresse,
|
||||
sessionID: info.playSessionID,
|
||||
versionID: source?.id ?? sourceID
|
||||
)
|
||||
}
|
||||
|
||||
public func endPlayback(sessionID: String) async {
|
||||
// Ohne dieses Signal rechnet der Transcoder weiter, bis der Server von
|
||||
// selbst merkt, dass niemand mehr Segmente abholt. Bei direkter
|
||||
// Wiedergabe gibt es nichts anzuhalten — der Aufruf läuft dann ins
|
||||
// Leere, und das ist billiger als sich zu merken, welcher Fall es war.
|
||||
_ = try? await client.send(JellyfinRequest(
|
||||
method: .delete,
|
||||
path: "/Videos/ActiveEncodings",
|
||||
query: [
|
||||
URLQueryItem(name: "deviceId", value: client.deviceID),
|
||||
URLQueryItem(name: "playSessionId", value: sessionID),
|
||||
],
|
||||
timeout: .seconds(5)
|
||||
))
|
||||
}
|
||||
|
||||
public func reportProgress(_ report: PlaybackReport) async {
|
||||
var fields: [String: Any] = [
|
||||
"ItemId": report.itemID,
|
||||
"PositionTicks": report.time.milliseconds.ticksFromMilliseconds,
|
||||
"IsPaused": report.state == .paused,
|
||||
"CanSeek": true,
|
||||
]
|
||||
if let sessionID = report.sessionID { fields["PlaySessionId"] = sessionID }
|
||||
if let versionID = report.versionID { fields["MediaSourceId"] = versionID }
|
||||
|
||||
// Erster und letzter Bericht gehen an eigene Endpunkte. Ohne den
|
||||
// ersten erscheint der Titel nirgends als „läuft gerade"; ohne den
|
||||
// letzten bleibt er für immer dort stehen. Ein Fortschritt mit
|
||||
// derselben Zahl täte weder das eine noch das andere.
|
||||
let path = switch report.state {
|
||||
case .started: "/Sessions/Playing"
|
||||
case .stopped: "/Sessions/Playing/Stopped"
|
||||
default: "/Sessions/Playing/Progress"
|
||||
}
|
||||
|
||||
guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return }
|
||||
_ = try? await client.send(JellyfinRequest(
|
||||
method: .post,
|
||||
path: path,
|
||||
body: body,
|
||||
timeout: .seconds(10)
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - Hilfen
|
||||
|
||||
/// Nicht `private`: Live-TV in `JellyfinBackend+LiveTV` fragt denselben
|
||||
/// Weg. Ein Sender ist für Jellyfin ein Eintrag wie jeder andere, und
|
||||
/// eine zweite Fassung dieser Abfrage wäre eine Kopie, die auseinanderläuft.
|
||||
func playbackInfo(
|
||||
itemID: String,
|
||||
sourceID: String,
|
||||
transcode: TranscodeSettings,
|
||||
audioStreamIndex: Int? = nil,
|
||||
subtitleStreamIndex: Int? = nil,
|
||||
remux: Bool = false
|
||||
) async throws(MediaBackendError) -> JellyfinPlaybackInfo {
|
||||
// Jellyfin rechnet in Bit, die App in Kilobit.
|
||||
let bitsPerSecond = (transcode.maximumBitrate ?? 8_000) * 1_000
|
||||
|
||||
// **Umpacken: dieselben Ströme, andere Schachtel.**
|
||||
//
|
||||
// Die Codecs stehen genau so breit wie das, was AVPlayer wirklich
|
||||
// kann: trifft der Server eine Übereinstimmung, **kopiert** er den
|
||||
// Strom, statt ihn neu zu rechnen. Genau dafür ist der Weg da — ein
|
||||
// Matroska-Rip mit HEVC und AC3 kommt so bei AVPlayer an, mit
|
||||
// Bild-in-Bild und AirPlay, ohne dass ein Bild angefasst wurde.
|
||||
//
|
||||
// **Und doch als HLS, obwohl eine fortlaufende Datei schöner wäre.**
|
||||
// Der Versuch mit `Protocol: http` ist am Gerät gescheitert:
|
||||
// AVPlayer verlangt bei einer fortlaufenden Datei Bereichsanfragen —
|
||||
// er will hineinspringen können. Eine MP4, die ffmpeg gerade erst
|
||||
// erzeugt, kann das nicht: sie hat weder Länge noch ein
|
||||
// Inhaltsverzeichnis am Anfang. Die Antwort war
|
||||
// `AVErrorServerIncorrectlyConfigured` (-11850), und zwar sofort.
|
||||
//
|
||||
// Damit bleibt vom ursprünglichen Ziel das Umpacken, nicht das weite
|
||||
// Vorausladen. Das ist immer noch der grössere Teil des Gewinns.
|
||||
//
|
||||
// `Container: mp4` statt `ts`: als fragmentierte MP4 trägt HLS auch
|
||||
// HEVC. In MPEG-TS wäre dafür eine Neukodierung nötig — also genau
|
||||
// das, was hier vermieden werden soll.
|
||||
let umpacken: [String: Any] = [
|
||||
"Type": "Video",
|
||||
"Container": "mp4",
|
||||
"Protocol": "hls",
|
||||
"VideoCodec": "h264,hevc",
|
||||
"AudioCodec": "aac,ac3,eac3,mp3",
|
||||
"Context": "Streaming",
|
||||
"MaxAudioChannels": "8",
|
||||
"MinSegments": 1,
|
||||
]
|
||||
|
||||
let profile: [String: Any] = [
|
||||
"MaxStreamingBitrate": bitsPerSecond,
|
||||
// Leer heißt: nichts geht unverändert. An dieser Stelle ist bereits
|
||||
// entschieden, dass umgerechnet werden soll — dem Server die Wahl
|
||||
// zu lassen hieße, die Entscheidung zweimal zu treffen.
|
||||
"DirectPlayProfiles": [],
|
||||
"TranscodingProfiles": remux ? [umpacken] : [[
|
||||
"Type": "Video",
|
||||
"Container": "ts",
|
||||
"Protocol": "hls",
|
||||
"VideoCodec": "h264",
|
||||
"AudioCodec": "aac,mp3",
|
||||
"Context": "Streaming",
|
||||
"MaxAudioChannels": "2",
|
||||
"MinSegments": 1,
|
||||
"BreakOnNonKeyFrames": true,
|
||||
]],
|
||||
"SubtitleProfiles": [
|
||||
["Format": "vtt", "Method": "Hls"],
|
||||
["Format": "srt", "Method": "External"],
|
||||
],
|
||||
]
|
||||
|
||||
let body: Data
|
||||
do {
|
||||
body = try JSONSerialization.data(withJSONObject: ["DeviceProfile": profile])
|
||||
} catch {
|
||||
throw .decoding(description: String(describing: error))
|
||||
}
|
||||
|
||||
return try await client.send(
|
||||
JellyfinRequest(
|
||||
method: .post,
|
||||
path: "/Items/\(itemID)/PlaybackInfo",
|
||||
query: [
|
||||
URLQueryItem(name: "userId", value: account.userID),
|
||||
URLQueryItem(name: "mediaSourceId", value: sourceID),
|
||||
URLQueryItem(name: "maxStreamingBitrate", value: String(bitsPerSecond)),
|
||||
// **Kein** `startTimeTicks`. Es gäbe zwei Stellen, die
|
||||
// über den Startpunkt entscheiden: der Server ließe den
|
||||
// Stream dort beginnen, und der Player spränge anschließend
|
||||
// noch einmal um dieselbe Strecke vor — bei einundzwanzig
|
||||
// Minuten Fortschritt landete man bei zweiundvierzig.
|
||||
// Gesprungen wird an genau einer Stelle, und das ist der
|
||||
// Player.
|
||||
// **Die Tonspur ausdrücklich nennen.** Ohne sie wählt
|
||||
// der Server, und bei einem umgerechneten Stream kommt
|
||||
// danach nur noch diese eine an — dann ist im Player
|
||||
// nichts mehr umzustellen. Die Sprachwahl muss hier
|
||||
// stattfinden oder gar nicht.
|
||||
URLQueryItem(name: "enableDirectPlay", value: "false"),
|
||||
URLQueryItem(name: "enableDirectStream", value: "false"),
|
||||
URLQueryItem(name: "enableTranscoding", value: "true"),
|
||||
] + (audioStreamIndex.map {
|
||||
[URLQueryItem(name: "audioStreamIndex", value: String($0))]
|
||||
} ?? []) + (subtitleStreamIndex.map {
|
||||
[URLQueryItem(name: "subtitleStreamIndex", value: String($0))]
|
||||
} ?? []),
|
||||
body: body
|
||||
),
|
||||
as: JellyfinPlaybackInfo.self
|
||||
)
|
||||
}
|
||||
|
||||
/// Hängt die gewünschte Bildhöhe an die Adresse des Servers.
|
||||
///
|
||||
/// Die Bitrate versteht Jellyfin über das Geräteprofil, die Bildhöhe nicht
|
||||
/// ohne Weiteres. Sie wird deshalb nachträglich gesetzt — und eine bereits
|
||||
/// vorhandene Angabe ersetzt, statt eine zweite danebenzustellen: bei zwei
|
||||
/// gleichnamigen Werten gilt beim Server der erste, und das wäre dann
|
||||
/// gerade nicht der gewählte.
|
||||
///
|
||||
/// `resolution` kommt in der Form „1280x720"; gebraucht wird die zweite
|
||||
/// Zahl.
|
||||
static func limiting(height resolution: String?, in url: URL) -> URL {
|
||||
guard let resolution,
|
||||
let height = resolution.split(separator: "x").last.flatMap({ Int($0) }),
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
else { return url }
|
||||
|
||||
var items = (components.queryItems ?? []).filter {
|
||||
let name = $0.name.lowercased()
|
||||
return name != "maxheight" && name != "maxwidth"
|
||||
}
|
||||
items.append(URLQueryItem(name: "maxHeight", value: String(height)))
|
||||
components.queryItems = items
|
||||
return components.url ?? url
|
||||
}
|
||||
|
||||
/// Bestellt eine **Leiter** statt einer einzelnen Stufe.
|
||||
///
|
||||
/// Zwei Handgriffe, und beide sind nötig:
|
||||
///
|
||||
/// **`main.m3u8` wird zu `master.m3u8`.** Die eine ist die Liste der
|
||||
/// Schnipsel *einer* Stufe, die andere die Liste der Stufen. Nur auf der
|
||||
/// zweiten kann der Parameter überhaupt wirken — auf der ersten gibt es
|
||||
/// keine Varianten, über die zu entscheiden wäre. Heißt die Adresse schon
|
||||
/// `master.m3u8`, bleibt sie.
|
||||
///
|
||||
/// **Der Parameter wird gesetzt, nicht danebengestellt.** Die
|
||||
/// Voreinstellung im Server ist `false`; ohne ihn kommt in jedem Fall nur
|
||||
/// eine Stufe. Und bei zwei gleichnamigen Werten gilt beim Server der
|
||||
/// erste — dieselbe Falle wie bei `maxHeight` eine Funktion weiter oben.
|
||||
///
|
||||
/// **Was das nicht kann.** Jellyfin verweigert die Leiter im lokalen Netz
|
||||
/// grundsätzlich, und zwar mit Absicht: „Within the local network this
|
||||
/// will likely do more harm than good." Ein schwaches WLAN zählt dort mit.
|
||||
/// Ebenso fällt sie weg, wenn Bild **oder** Ton nur kopiert statt neu
|
||||
/// gerechnet werden. Ob am Ende wirklich mehrere Stufen ankommen, steht
|
||||
/// deshalb nicht hier, sondern in `PlaybackStatistics.variantCount` — und
|
||||
/// das ist die einzige belastbare Auskunft darüber.
|
||||
static func adaptive(_ url: URL) -> URL {
|
||||
guard var components = URLComponents(url: masterPlaylist(url), resolvingAgainstBaseURL: false)
|
||||
else { return masterPlaylist(url) }
|
||||
var items = (components.queryItems ?? []).filter {
|
||||
$0.name.lowercased() != "enableadaptivebitratestreaming"
|
||||
}
|
||||
items.append(URLQueryItem(name: "enableAdaptiveBitrateStreaming", value: "true"))
|
||||
components.queryItems = items
|
||||
return components.url ?? url
|
||||
}
|
||||
|
||||
/// Von der Liste **einer** Stufe zur Liste **aller** Spuren.
|
||||
///
|
||||
/// **Der eigene Handgriff, und der wichtigere.** `main.m3u8` ist eine
|
||||
/// Media Playlist: eine Liste von Schnipseln, sonst nichts. `master.m3u8`
|
||||
/// ist eine Master Playlist, und nur dort darf nach RFC 8216 ein
|
||||
/// `#EXT-X-MEDIA` stehen — also die Zeile, mit der ein Server eine
|
||||
/// **Untertitelspur** ankündigt.
|
||||
///
|
||||
/// Das heisst: auf `main.m3u8` kann es strukturell keine Untertitel
|
||||
/// geben, ganz gleich was bestellt wurde und was der Server liefern
|
||||
/// wollte. Genau daran lag es, dass beim Umpacken und bei jeder von Hand
|
||||
/// gewählten Stufe nie ein Untertitel erschien — dort blieb die Adresse
|
||||
/// `main.m3u8`, weil der Wechsel an der Leiter hing.
|
||||
///
|
||||
/// Deshalb steht er jetzt für sich: **jeder HLS-Stream bekommt die
|
||||
/// Master-Liste.** Ob darin mehrere Stufen stehen, ist eine andere Frage
|
||||
/// — die beantwortet `enableAdaptiveBitrateStreaming`, und eine
|
||||
/// Master-Liste mit einer einzigen Stufe ist völlig in Ordnung.
|
||||
static func masterPlaylist(_ url: URL) -> URL {
|
||||
guard url.lastPathComponent == "main.m3u8" else { return url }
|
||||
return url.deletingLastPathComponent().appending(path: "master.m3u8")
|
||||
}
|
||||
}
|
||||
|
||||
/// Was der Server für eine Wiedergabe vorbereitet hat.
|
||||
public struct JellyfinPlaybackInfo: Decodable, Sendable {
|
||||
public let mediaSources: [JellyfinMediaSource]?
|
||||
public let playSessionID: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case mediaSources = "MediaSources"
|
||||
case playSessionID = "PlaySessionId"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Jellyfin als `LiveTVRecordingBackend`.
|
||||
///
|
||||
/// Plex erfüllt diesen Vertrag nicht — es kann in dieser App nichts aufnehmen.
|
||||
/// Deshalb steht er getrennt: wer aufnehmen will, fragt mit `as?` danach und
|
||||
/// bekommt eine ehrliche Antwort, statt auf Methoden zu treffen, die nur
|
||||
/// werfen.
|
||||
extension JellyfinBackend: LiveTVRecordingBackend {
|
||||
// MARK: - Aufnahmen
|
||||
|
||||
public func recordings() async throws(MediaBackendError) -> [LiveRecording] {
|
||||
let page = try await client.send(
|
||||
JellyfinRequest(
|
||||
path: "/LiveTv/Recordings",
|
||||
query: [
|
||||
URLQueryItem(name: "userId", value: account.userID),
|
||||
URLQueryItem(name: "enableImages", value: "true"),
|
||||
]
|
||||
),
|
||||
as: JellyfinItemPage.self
|
||||
)
|
||||
|
||||
let now = Date()
|
||||
return page.items.map { item in
|
||||
LiveRecording(
|
||||
id: item.id,
|
||||
title: item.name ?? "—",
|
||||
channelName: item.channelName,
|
||||
summary: item.overview,
|
||||
startsAt: item.startDate,
|
||||
endsAt: item.endDate,
|
||||
artworkPath: images.primary(itemID: item.id, tag: item.imageTags?["Primary"]),
|
||||
// Abgeleitet statt erfragt: eine Aufnahme, deren Ende noch
|
||||
// bevorsteht, läuft gerade. Das spart ein Feld, das sonst nur
|
||||
// für diese eine Anzeige im Modell stünde.
|
||||
isInProgress: (item.endDate.map { $0 > now }) ?? false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteRecording(id: String) async throws(MediaBackendError) {
|
||||
_ = try await client.send(JellyfinRequest(
|
||||
method: .delete,
|
||||
path: "/LiveTv/Recordings/\(id)"
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - Vorgemerkt
|
||||
|
||||
public func timers() async throws(MediaBackendError) -> [LiveTimer] {
|
||||
let page = try await client.send(
|
||||
JellyfinRequest(path: "/LiveTv/Timers"),
|
||||
as: JellyfinTimerPage.self
|
||||
)
|
||||
return page.items.map { timer in
|
||||
LiveTimer(
|
||||
id: timer.id ?? "",
|
||||
programID: timer.programID,
|
||||
channelID: timer.channelID,
|
||||
channelName: timer.channelName,
|
||||
title: timer.name ?? "—",
|
||||
startsAt: timer.startDate,
|
||||
endsAt: timer.endDate,
|
||||
seriesTimerID: timer.seriesTimerID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func scheduleTimer(for program: LiveProgram) async throws(MediaBackendError) {
|
||||
try await schedule(program: program, path: "/LiveTv/Timers")
|
||||
}
|
||||
|
||||
public func cancelTimer(id: String) async throws(MediaBackendError) {
|
||||
_ = try await client.send(JellyfinRequest(
|
||||
method: .delete,
|
||||
path: "/LiveTv/Timers/\(id)"
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - Ganze Reihen
|
||||
|
||||
public func seriesTimers() async throws(MediaBackendError) -> [LiveSeriesTimer] {
|
||||
let page = try await client.send(
|
||||
JellyfinRequest(path: "/LiveTv/SeriesTimers"),
|
||||
as: JellyfinSeriesTimerPage.self
|
||||
)
|
||||
return page.items.map { timer in
|
||||
LiveSeriesTimer(
|
||||
id: timer.id ?? "",
|
||||
name: timer.name ?? "—",
|
||||
channelID: timer.channelID,
|
||||
channelName: timer.channelName,
|
||||
recordsNewOnly: timer.skipEpisodesInLibrary ?? false,
|
||||
recordsAnyChannel: timer.recordAnyChannel ?? false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func scheduleSeriesTimer(for program: LiveProgram) async throws(MediaBackendError) {
|
||||
try await schedule(program: program, path: "/LiveTv/SeriesTimers")
|
||||
}
|
||||
|
||||
public func cancelSeriesTimer(id: String) async throws(MediaBackendError) {
|
||||
_ = try await client.send(JellyfinRequest(
|
||||
method: .delete,
|
||||
path: "/LiveTv/SeriesTimers/\(id)"
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - Der gemeinsame Weg
|
||||
|
||||
/// Merkt eine Sendung vor — einmalig oder als ganze Reihe.
|
||||
///
|
||||
/// **Zwei Schritte, und der erste ist der wichtige.** Der Server liefert
|
||||
/// unter `Timers/Defaults` eine für diese Sendung fertig ausgefüllte
|
||||
/// Vorlage: Vor- und Nachlauf, Priorität, Dienstname, wie lange sie
|
||||
/// aufgehoben wird. Was davon Pflicht ist, steht nirgends — die Beschreibung
|
||||
/// nennt kein einziges Feld als erforderlich.
|
||||
///
|
||||
/// Deshalb wird die Vorlage **roh weitergereicht**, so wie sie kam, und
|
||||
/// nicht entschlüsselt und neu zusammengesetzt. Jedes Feld, das dabei
|
||||
/// durch die Modellierung fiele, wäre eines, das der Server vermisst —
|
||||
/// und man erführe es erst an einer Aufnahme, die nicht startet.
|
||||
private func schedule(program: LiveProgram, path: String) async throws(MediaBackendError) {
|
||||
let template = try await client.send(JellyfinRequest(
|
||||
path: "/LiveTv/Timers/Defaults",
|
||||
query: [URLQueryItem(name: "programId", value: program.id)]
|
||||
))
|
||||
|
||||
_ = try await client.send(JellyfinRequest(
|
||||
method: .post,
|
||||
path: path,
|
||||
body: template
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Was Jellyfin über Vorgemerktes schickt
|
||||
|
||||
/// Ein vorgemerkter Termin, wie Jellyfin ihn meldet.
|
||||
///
|
||||
/// Kein `JellyfinItem`: Timer sind bei Jellyfin ausdrücklich **keine**
|
||||
/// Einträge, sondern ein eigener Typ mit eigenen Feldern.
|
||||
struct JellyfinTimer: Decodable, Sendable {
|
||||
let id: String?
|
||||
let programID: String?
|
||||
let channelID: String?
|
||||
let channelName: String?
|
||||
let name: String?
|
||||
let startDate: Date?
|
||||
let endDate: Date?
|
||||
let seriesTimerID: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id = "Id", programID = "ProgramId", channelID = "ChannelId"
|
||||
case channelName = "ChannelName", name = "Name"
|
||||
case startDate = "StartDate", endDate = "EndDate"
|
||||
case seriesTimerID = "SeriesTimerId"
|
||||
}
|
||||
}
|
||||
|
||||
struct JellyfinTimerPage: Decodable, Sendable {
|
||||
let items: [JellyfinTimer]
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case items = "Items" }
|
||||
}
|
||||
|
||||
/// Eine stehende Aufnahme für alles, was zu einer Reihe gehört.
|
||||
struct JellyfinSeriesTimer: Decodable, Sendable {
|
||||
let id: String?
|
||||
let channelID: String?
|
||||
let channelName: String?
|
||||
let name: String?
|
||||
/// Ob bereits Vorhandenes übersprungen wird — das, was man umgangssprachlich
|
||||
/// „nur neue Folgen" nennt.
|
||||
let skipEpisodesInLibrary: Bool?
|
||||
let recordAnyChannel: Bool?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id = "Id", channelID = "ChannelId", channelName = "ChannelName"
|
||||
case name = "Name", skipEpisodesInLibrary = "SkipEpisodesInLibrary"
|
||||
case recordAnyChannel = "RecordAnyChannel"
|
||||
}
|
||||
}
|
||||
|
||||
struct JellyfinSeriesTimerPage: Decodable, Sendable {
|
||||
let items: [JellyfinSeriesTimer]
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case items = "Items" }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Ein Abschnitt, den man überspringen können soll.
|
||||
///
|
||||
/// Jellyfin nennt sie seit 10.10 „Media Segments" und liefert sie über einen
|
||||
/// eigenen Weg — **nicht** als Feld am Titel. Deshalb steht das hier und nicht
|
||||
/// in `JellyfinItem`.
|
||||
///
|
||||
/// Gefüllt werden sie von einem Anbieter im Server, etwa dem Intro-Skipper.
|
||||
/// Hat der Server keinen, kommt eine leere Liste — oder gar nichts, wenn er
|
||||
/// älter ist als 10.10. Beides ist kein Fehler, sondern heisst schlicht: es
|
||||
/// gibt hier nichts zu überspringen.
|
||||
struct JellyfinSegment: Decodable, Sendable {
|
||||
let type: String?
|
||||
/// In Hundertnanosekunden, wie alles Zeitliche bei Jellyfin.
|
||||
let startTicks: Int64?
|
||||
let endTicks: Int64?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type = "Type"
|
||||
case startTicks = "StartTicks"
|
||||
case endTicks = "EndTicks"
|
||||
}
|
||||
|
||||
/// `nil`, wenn der Abschnitt keiner ist, mit dem die App etwas anfangen
|
||||
/// kann — etwa eine Vorschau oder ein Rückblick. Lieber nichts anbieten
|
||||
/// als einen Knopf, der an einer unerwarteten Stelle auftaucht.
|
||||
var asMediaMarker: MediaMarker? {
|
||||
guard let startTicks, let endTicks, endTicks > startTicks else { return nil }
|
||||
let kind: MediaMarker.Kind
|
||||
switch type?.lowercased() {
|
||||
case "intro": kind = .intro
|
||||
case "outro": kind = .credits
|
||||
case "commercial": kind = .commercial
|
||||
default: return nil
|
||||
}
|
||||
return MediaMarker(
|
||||
kind: kind,
|
||||
startMilliseconds: Int(startTicks / 10_000),
|
||||
endMilliseconds: Int(endTicks / 10_000)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Antwort ist eine Seite wie jede andere bei Jellyfin.
|
||||
private struct JellyfinSegmentPage: Decodable {
|
||||
let items: [JellyfinSegment]
|
||||
|
||||
enum CodingKeys: String, CodingKey { case items = "Items" }
|
||||
}
|
||||
|
||||
extension JellyfinBackend {
|
||||
/// Holt Intro und Abspann zu einem Titel.
|
||||
///
|
||||
/// **Wirft nicht.** Ein Server ohne Anbieter antwortet mit einer leeren
|
||||
/// Liste, einer vor 10.10 mit einem 404 — und ein Film ohne Intro ist
|
||||
/// kein Fehler, sondern der Normalfall. Daran die Detailseite scheitern
|
||||
/// zu lassen, wäre die schlechtere Hälfte des Tauschs.
|
||||
///
|
||||
/// **`includeSegmentTypes` zweimal, nicht einmal mit Komma.** Als
|
||||
/// `Intro,Outro` beantwortet Jellyfin dieselbe Frage mit null Treffern;
|
||||
/// als zwei Angaben kommen sie. Das steht nirgends in der Dokumentation
|
||||
/// und kostet sonst eine Stunde Suche am falschen Ende.
|
||||
func segments(forItem id: String) async -> [MediaMarker] {
|
||||
do {
|
||||
let page = try await client.send(
|
||||
JellyfinRequest(
|
||||
path: "/MediaSegments/\(id)",
|
||||
query: [
|
||||
URLQueryItem(name: "includeSegmentTypes", value: "Intro"),
|
||||
URLQueryItem(name: "includeSegmentTypes", value: "Outro"),
|
||||
]
|
||||
),
|
||||
as: JellyfinSegmentPage.self
|
||||
)
|
||||
return page.items.compactMap(\.asMediaMarker).sorted { $0.startMilliseconds < $1.startMilliseconds }
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Jellyfin als `MediaBackend`.
|
||||
public struct JellyfinBackend: MediaBackend {
|
||||
let client: JellyfinClient
|
||||
let account: JellyfinAccount
|
||||
/// Nicht `private`: Live-TV und Aufnahmen liegen in eigenen Dateien und
|
||||
/// brauchen dieselben Bildpfade.
|
||||
let images = JellyfinImagePaths()
|
||||
/// Wo ein Titel hingehört — einmal gefragt, dann gemerkt.
|
||||
///
|
||||
/// Ein Actor, also ein Referenztyp: Kopien dieses Structs teilen sich
|
||||
/// denselben und damit das Gedächtnis. Ohne das fragte jede Folge einer
|
||||
/// Serie dasselbe noch einmal.
|
||||
let ancestors = JellyfinLibraryMemo()
|
||||
public let serverName: String
|
||||
|
||||
/// Konto **und** Adresse: dasselbe Konto auf zwei Servern ist nicht
|
||||
/// dasselbe, und zwei Konten auf einem Server erst recht nicht.
|
||||
public nonisolated var identity: String {
|
||||
"jellyfin:\(client.baseURL.absoluteString):\(account.userID)"
|
||||
}
|
||||
|
||||
public init(client: JellyfinClient, account: JellyfinAccount, serverName: String) {
|
||||
self.client = client
|
||||
self.account = account
|
||||
self.serverName = serverName
|
||||
}
|
||||
|
||||
public var isAdministrator: Bool { account.isAdministrator }
|
||||
|
||||
public nonisolated var capabilities: Set<BackendCapability> {
|
||||
var capabilities: Set<BackendCapability> = [
|
||||
.favourites, .liveTV, .liveTVRecordings, .libraryScan, .transcoding,
|
||||
]
|
||||
// Verwaltung nur, wenn das Konto sie auch darf. Den Bereich zu zeigen
|
||||
// und bei jedem Tipp abzulehnen, wäre die schlechtere Antwort.
|
||||
if account.isAdministrator { capabilities.insert(.administration) }
|
||||
return capabilities
|
||||
}
|
||||
|
||||
// MARK: - Blättern
|
||||
|
||||
/// Ob der Server diese Verbindung als „im eigenen Netz" ansieht.
|
||||
///
|
||||
/// **Gefragt, nicht geraten.** Ein Blick auf die Adresse taugt nicht: wer
|
||||
/// über einen Reverse Proxy geht, erreicht seinen Server auch von der
|
||||
/// Couch aus unter einem öffentlichen Namen. Umgekehrt kann eine private
|
||||
/// Adresse über ein VPN von weit her kommen.
|
||||
///
|
||||
/// `/System/Endpoint` beantwortet es mit **demselben** Aufruf, mit dem
|
||||
/// Jellyfin auch selbst entscheidet, ob es eine Bitratenleiter ausliefert.
|
||||
/// Näher an der Wahrheit kommt man nicht heran.
|
||||
///
|
||||
/// Bei Zweifeln `false`: lieber einmal zu vorsichtig gedeckelt als eine
|
||||
/// Wiedergabe, die über eine Fernverbindung gar nicht erst anläuft.
|
||||
public func isInLocalNetwork() async -> Bool {
|
||||
struct Lage: Decodable {
|
||||
let isInNetwork: Bool
|
||||
private enum CodingKeys: String, CodingKey { case isInNetwork = "IsInNetwork" }
|
||||
}
|
||||
let lage = try? await client.send(
|
||||
JellyfinRequest(path: "/System/Endpoint"),
|
||||
as: Lage.self
|
||||
)
|
||||
return lage?.isInNetwork ?? false
|
||||
}
|
||||
|
||||
public func libraries() async throws(MediaBackendError) -> [MediaLibrary] {
|
||||
let page = try await client.send(
|
||||
JellyfinRequest(path: "/Users/\(account.userID)/Views"),
|
||||
as: JellyfinItemPage.self
|
||||
)
|
||||
return page.items.map { view in
|
||||
let kind = MediaLibrary.Kind(jellyfinCollectionType: view.collectionType)
|
||||
return MediaLibrary(
|
||||
id: view.id,
|
||||
title: view.name ?? "—",
|
||||
kind: kind,
|
||||
thumb: images.primary(itemID: view.id, tag: nil)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
|
||||
// Jellyfin hat keine fertigen Reihen wie Plex' Hubs — die Startseite
|
||||
// wird aus einzelnen Abfragen zusammengesetzt. Das ist kein Mangel:
|
||||
// so steht hier, was die Reihen bedeuten, statt dass es der Server
|
||||
// entscheidet.
|
||||
//
|
||||
// Die Mediatheken werden streng geholt: sie sind die Probe darauf, ob
|
||||
// der Server überhaupt antwortet. Alles Weitere ist nachsichtig —
|
||||
// fällt „Als Nächstes" aus, soll deshalb nicht die ganze Startseite
|
||||
// leer bleiben.
|
||||
let libraries = try await libraries().filter(\.isSupported)
|
||||
|
||||
async let resuming = try? rowItems(path: "/Users/\(account.userID)/Items/Resume", query: [
|
||||
URLQueryItem(name: "Limit", value: "20"),
|
||||
URLQueryItem(name: "MediaTypes", value: "Video"),
|
||||
URLQueryItem(name: "Fields", value: Self.fields),
|
||||
])
|
||||
async let nextUp = try? rowItems(path: "/Shows/NextUp", query: [
|
||||
URLQueryItem(name: "userId", value: account.userID),
|
||||
URLQueryItem(name: "Limit", value: "20"),
|
||||
URLQueryItem(name: "Fields", value: Self.fields),
|
||||
])
|
||||
async let favourites = try? rowItems(path: "/Users/\(account.userID)/Items", query: [
|
||||
URLQueryItem(name: "Filters", value: "IsFavorite"),
|
||||
URLQueryItem(name: "Recursive", value: "true"),
|
||||
URLQueryItem(name: "IncludeItemTypes", value: "Movie,Series"),
|
||||
URLQueryItem(name: "Limit", value: "20"),
|
||||
URLQueryItem(name: "Fields", value: Self.fields),
|
||||
])
|
||||
|
||||
var rows = [
|
||||
MediaRowGroup(
|
||||
id: "resume",
|
||||
title: String(localized: "row.continueWatching", bundle: .module),
|
||||
items: await resuming ?? []
|
||||
),
|
||||
MediaRowGroup(
|
||||
id: "nextup",
|
||||
title: String(localized: "row.nextUp", bundle: .module),
|
||||
items: await nextUp ?? []
|
||||
),
|
||||
]
|
||||
|
||||
// Je Mediathek eine eigene Reihe. „Neu in Filme" und „Neu in Serien"
|
||||
// sind zwei verschiedene Fragen — in einer gemeinsamen Reihe steht am
|
||||
// Ende beides durcheinander, und wer nach einem Film sucht, blättert
|
||||
// an Folgen vorbei.
|
||||
for library in libraries {
|
||||
let items = try? await latestItems(limit: 20, inLibrary: library.id)
|
||||
rows.append(MediaRowGroup(
|
||||
id: "latest.\(library.id)",
|
||||
title: String(
|
||||
localized: "row.latestIn \(library.title)",
|
||||
bundle: .module
|
||||
),
|
||||
libraryID: library.id,
|
||||
items: items ?? []
|
||||
))
|
||||
}
|
||||
|
||||
rows.append(MediaRowGroup(
|
||||
id: "favourites",
|
||||
title: String(localized: "row.favourites", bundle: .module),
|
||||
items: await favourites ?? []
|
||||
))
|
||||
|
||||
// Leere Reihen wären auf der Startseite nur Überschriften ohne Inhalt.
|
||||
return rows.filter { !$0.items.isEmpty }
|
||||
}
|
||||
|
||||
public func items(
|
||||
inLibrary library: MediaLibrary,
|
||||
query: MediaQuery,
|
||||
offset: Int,
|
||||
limit: Int
|
||||
) async throws(MediaBackendError) -> MediaPage {
|
||||
var items: [URLQueryItem] = [
|
||||
URLQueryItem(name: "ParentId", value: library.id),
|
||||
URLQueryItem(name: "Recursive", value: "true"),
|
||||
URLQueryItem(name: "StartIndex", value: String(offset)),
|
||||
URLQueryItem(name: "Limit", value: String(limit)),
|
||||
URLQueryItem(name: "Fields", value: Self.fields),
|
||||
URLQueryItem(name: "SortBy", value: query.sort.jellyfinField),
|
||||
URLQueryItem(name: "SortOrder", value: query.sort.isDescending ? "Descending" : "Ascending"),
|
||||
]
|
||||
|
||||
// Ohne diese Angabe liefert eine Serienmediathek alle Folgen einzeln
|
||||
// statt der Serien — dieselbe Falle wie bei Plex.
|
||||
switch library.kind {
|
||||
case .movie: items.append(URLQueryItem(name: "IncludeItemTypes", value: "Movie"))
|
||||
case .show: items.append(URLQueryItem(name: "IncludeItemTypes", value: "Series"))
|
||||
// Gemischter Inhalt: Filme **und** Serien, aber eben nur die. Ohne
|
||||
// diese Angabe liefert `Recursive` auch jede einzelne Folge, und das
|
||||
// Raster füllt sich mit Folge 3 von Staffel 2 statt mit Titeln.
|
||||
case .other: items.append(URLQueryItem(name: "IncludeItemTypes", value: "Movie,Series"))
|
||||
default: break
|
||||
}
|
||||
|
||||
if query.unwatchedOnly { items.append(URLQueryItem(name: "Filters", value: "IsUnplayed")) }
|
||||
if query.favouritesOnly { items.append(URLQueryItem(name: "Filters", value: "IsFavorite")) }
|
||||
if let genre = query.genre { items.append(URLQueryItem(name: "Genres", value: genre)) }
|
||||
if let year = query.year { items.append(URLQueryItem(name: "Years", value: String(year))) }
|
||||
if let rating = query.contentRating {
|
||||
items.append(URLQueryItem(name: "OfficialRatings", value: rating))
|
||||
}
|
||||
if let text = query.searchText { items.append(URLQueryItem(name: "searchTerm", value: text)) }
|
||||
// Auflösung filtert Jellyfin nicht selbst; „4K" wird unten aussortiert.
|
||||
|
||||
let page = try await client.send(
|
||||
JellyfinRequest(path: "/Users/\(account.userID)/Items", query: items),
|
||||
as: JellyfinItemPage.self
|
||||
)
|
||||
|
||||
var mapped = page.items.map { $0.asMediaItem(imageBase: images) }
|
||||
var total = page.totalRecordCount ?? mapped.count
|
||||
|
||||
if query.resolution?.lowercased() == "4k" {
|
||||
// Nach der Stufe, nicht nach der Zeilenzahl: ein breitwandiges
|
||||
// 4K misst 3840 × 1608 und fiele durch jede Höhenschwelle,
|
||||
// die 2160 erwartet.
|
||||
mapped = mapped.filter { ($0.preferredVersion?.resolution ?? .sd) >= .uhd4K }
|
||||
// Die Gesamtzahl stimmt dann nicht mehr — lieber die gefilterte
|
||||
// Menge als eine Zahl, die zum Nachladen auffordert, obwohl nichts
|
||||
// mehr kommt.
|
||||
total = mapped.count + offset
|
||||
}
|
||||
|
||||
return MediaPage(items: mapped, offset: offset, totalCount: total)
|
||||
}
|
||||
|
||||
/// Die Mediathek über die Ahnenkette.
|
||||
///
|
||||
/// Jellyfin beantwortet das mit `/Items/{id}/Ancestors`: Staffel, Serie,
|
||||
/// Mediathek, Wurzel. Gesucht ist der Eintrag vom Typ `CollectionFolder`
|
||||
/// — das ist eine Mediathek. `UserRootFolder` ist die Wurzel darüber und
|
||||
/// wäre für alle Titel dieselbe, also nutzlos.
|
||||
///
|
||||
/// Die Antwort wird gemerkt: innerhalb einer Serie fragt sonst jede Folge
|
||||
/// dasselbe noch einmal.
|
||||
public func libraryID(for item: MediaItem) async -> String? {
|
||||
let schlüssel = item.seriesID ?? item.id
|
||||
if let gemerkt = await ancestors.recorded(schlüssel) { return gemerkt.value }
|
||||
|
||||
let kette = try? await client.send(
|
||||
JellyfinRequest(path: "/Items/\(schlüssel)/Ancestors"),
|
||||
as: [JellyfinItem].self
|
||||
)
|
||||
let gefunden = kette?.first { $0.type == "CollectionFolder" }?.id
|
||||
await ancestors.store(gefunden, for: schlüssel)
|
||||
return gefunden
|
||||
}
|
||||
|
||||
public func item(id: String) async throws(MediaBackendError) -> MediaItem {
|
||||
// **Nebenher, nicht nacheinander.** Die Marken stehen bei Jellyfin
|
||||
// hinter einem eigenen Weg; sie hinterher zu holen hiesse, die
|
||||
// Detailseite um eine zweite Laufzeit warten zu lassen.
|
||||
async let marken = segments(forItem: id)
|
||||
|
||||
let item = try await client.send(
|
||||
JellyfinRequest(
|
||||
path: "/Users/\(account.userID)/Items/\(id)",
|
||||
query: [URLQueryItem(name: "Fields", value: Self.fields)]
|
||||
),
|
||||
as: JellyfinItem.self
|
||||
)
|
||||
return item.asMediaItem(imageBase: images, markers: await marken)
|
||||
}
|
||||
|
||||
/// Alle Folgen einer Serie in **einer** Anfrage.
|
||||
///
|
||||
/// **Nicht `/Shows/NextUp`.** Der braucht `seriesID`, das bei einer Serie
|
||||
/// selbst `nil` ist, versteckt vollständig gesehene Serien und bringt
|
||||
/// eine eigene Zeitgrenze mit. Dann antworteten Plex und Jellyfin
|
||||
/// verschieden auf dieselbe Frage — und welche Folge dran ist, soll eine
|
||||
/// Regel entscheiden, nicht der Server.
|
||||
///
|
||||
/// `EnableUserData` ausdrücklich: ohne den Sehfortschritt wäre jede Folge
|
||||
/// ungesehen, und es begänne immer bei der ersten.
|
||||
public func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] {
|
||||
try await rowItems(path: "/Shows/\(id)/Episodes", query: [
|
||||
URLQueryItem(name: "userId", value: account.userID),
|
||||
URLQueryItem(name: "EnableUserData", value: "true"),
|
||||
URLQueryItem(name: "Fields", value: Self.fields),
|
||||
])
|
||||
}
|
||||
|
||||
public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] {
|
||||
try await rowItems(path: "/Users/\(account.userID)/Items", query: [
|
||||
URLQueryItem(name: "ParentId", value: id),
|
||||
URLQueryItem(name: "SortBy", value: "SortName"),
|
||||
URLQueryItem(name: "Fields", value: Self.fields),
|
||||
])
|
||||
}
|
||||
|
||||
public func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup] {
|
||||
let page = try await client.send(
|
||||
JellyfinRequest(path: "/Users/\(account.userID)/Items", query: [
|
||||
URLQueryItem(name: "searchTerm", value: text),
|
||||
URLQueryItem(name: "Recursive", value: "true"),
|
||||
URLQueryItem(name: "IncludeItemTypes", value: "Movie,Series,Episode"),
|
||||
URLQueryItem(name: "Limit", value: String(limit)),
|
||||
URLQueryItem(name: "Fields", value: Self.fields),
|
||||
]),
|
||||
as: JellyfinItemPage.self
|
||||
)
|
||||
|
||||
// Nach Art gruppiert, weil Plex' Suche gruppiert zurückkommt und die
|
||||
// Ergebnisansicht darauf ausgelegt ist.
|
||||
let items = page.items.map { $0.asMediaItem(imageBase: images) }
|
||||
let groups: [(MediaItem.Kind, String)] = [
|
||||
(.movie, "search.movies"), (.show, "search.shows"), (.episode, "search.episodes"),
|
||||
]
|
||||
return groups.compactMap { kind, key in
|
||||
let matching = items.filter { $0.kind == kind }
|
||||
guard !matching.isEmpty else { return nil }
|
||||
return MediaRowGroup(
|
||||
id: kind.rawValue,
|
||||
title: String(localized: String.LocalizationValue(key), bundle: .module),
|
||||
items: matching
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func filterOptions(
|
||||
inLibrary library: MediaLibrary,
|
||||
field: MediaQuery.Field
|
||||
) async throws(MediaBackendError) -> [MediaFilterOption] {
|
||||
guard field == .genre else { return [] }
|
||||
let page = try await client.send(
|
||||
JellyfinRequest(path: "/Genres", query: [
|
||||
URLQueryItem(name: "ParentId", value: library.id),
|
||||
URLQueryItem(name: "userId", value: account.userID),
|
||||
]),
|
||||
as: JellyfinItemPage.self
|
||||
)
|
||||
return page.items.map { MediaFilterOption(id: $0.name ?? $0.id, title: $0.name ?? "—") }
|
||||
}
|
||||
|
||||
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
||||
guard let seriesID = item.seriesID else { return nil }
|
||||
let next = try await rowItems(path: "/Shows/NextUp", query: [
|
||||
URLQueryItem(name: "userId", value: account.userID),
|
||||
URLQueryItem(name: "SeriesId", value: seriesID),
|
||||
URLQueryItem(name: "Limit", value: "1"),
|
||||
URLQueryItem(name: "Fields", value: Self.fields),
|
||||
])
|
||||
// Die laufende Folge selbst ist nicht die nächste.
|
||||
return next.first { $0.id != item.id }
|
||||
}
|
||||
|
||||
// MARK: - Bilder
|
||||
|
||||
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
||||
guard let path else { return nil }
|
||||
return imageURL(path: path, extra: [
|
||||
URLQueryItem(name: "fillWidth", value: String(width)),
|
||||
URLQueryItem(name: "fillHeight", value: String(height)),
|
||||
URLQueryItem(name: "quality", value: "90"),
|
||||
])
|
||||
}
|
||||
|
||||
public nonisolated func rawImageURL(path: String?) -> URL? {
|
||||
guard let path else { return nil }
|
||||
return imageURL(path: path, extra: [])
|
||||
}
|
||||
|
||||
/// Setzt Pfad, mitgelieferte Marke und Größenangaben zusammen.
|
||||
private nonisolated func imageURL(path: String, extra: [URLQueryItem]) -> URL? {
|
||||
let pieces = path.split(separator: "?", maxSplits: 1)
|
||||
var query = extra
|
||||
if pieces.count > 1 {
|
||||
query += URLComponents(string: "?\(pieces[1])")?.queryItems ?? []
|
||||
}
|
||||
// Bilder holt kein angemeldeter Client, sondern eine Bildansicht — sie
|
||||
// schickt keine Kopfzeilen mit, also muss der Schlüssel in die Adresse.
|
||||
return client.url(path: String(pieces[0]), query: query, includingToken: account.token)
|
||||
}
|
||||
|
||||
// MARK: - Herunterladen
|
||||
|
||||
public nonisolated func downloadURL(for file: MediaFile) -> URL? {
|
||||
// Im Pfad steht der Titel, in der Abfrage die Fassung. Beides zu
|
||||
// vertauschen fällt bei einem Film mit nur einer Fassung nicht auf —
|
||||
// die Kennungen sind dann dieselbe — und bei zweien sofort.
|
||||
guard let itemID = file.itemID ?? file.key else { return nil }
|
||||
return client.url(
|
||||
path: "/Videos/\(itemID)/stream",
|
||||
query: [
|
||||
URLQueryItem(name: "static", value: "true"),
|
||||
URLQueryItem(name: "mediaSourceId", value: file.key ?? itemID),
|
||||
],
|
||||
includingToken: account.token
|
||||
)
|
||||
}
|
||||
|
||||
/// Dieselbe Datei in einer MP4-Hülle.
|
||||
///
|
||||
/// **Für Downloads, die sonst offline unspielbar wären.** Eine Matroska
|
||||
/// mit H.264 und AC3 ist inhaltlich tadellos — nur die Schachtel öffnet
|
||||
/// AVFoundation nicht. Online hilft derselbe Weg schon
|
||||
/// (`PlaybackDecision.remux`); auf dem Gerät gibt es hinterher niemanden
|
||||
/// mehr, der umpacken könnte. Also beim Herunterladen.
|
||||
///
|
||||
/// `allowVideoStreamCopy`/`allowAudioStreamCopy` sind der ganze Punkt: der
|
||||
/// Server soll die Ströme **durchreichen**, wenn sie in die Liste passen,
|
||||
/// und nur umrechnen, was nicht passt. Das Bild bleibt damit das Original;
|
||||
/// nur DTS oder TrueHD werden zu AC3.
|
||||
///
|
||||
/// NOCH NICHT AN EINEM ECHTEN SERVER GEPRÜFT: ob Jellyfin das Kopieren
|
||||
/// hier tatsächlich wählt, steht erst nach dem ersten Download fest — die
|
||||
/// Dateigröße verrät es.
|
||||
public nonisolated func remuxDownloadURL(
|
||||
itemID: String,
|
||||
versionIndex: Int,
|
||||
audioStreamIndex: Int?,
|
||||
subtitleStreamIndex: Int?
|
||||
) -> URL? {
|
||||
var query = [
|
||||
URLQueryItem(name: "videoCodec", value: "h264,hevc"),
|
||||
URLQueryItem(name: "audioCodec", value: "aac,ac3,eac3"),
|
||||
URLQueryItem(name: "allowVideoStreamCopy", value: "true"),
|
||||
URLQueryItem(name: "allowAudioStreamCopy", value: "true"),
|
||||
URLQueryItem(name: "TranscodingContainer", value: "mp4"),
|
||||
URLQueryItem(name: "TranscodingMaxAudioChannels", value: "6"),
|
||||
]
|
||||
// **Eine Tonspur, nicht alle.** Jellyfin bildet beim Umpacken genau
|
||||
// eine ab; welche, muss hier stehen. Ohne diese Zeile nimmt der Server
|
||||
// seine eigene Vorgabe — und das ist bei deutschen Aufnahmen
|
||||
// regelmäßig die mit Audiodeskription.
|
||||
if let audioStreamIndex {
|
||||
query.append(URLQueryItem(name: "audioStreamIndex", value: String(audioStreamIndex)))
|
||||
}
|
||||
// `Embed` legt den Untertitel als **Spur** in die Datei, statt ihn ins
|
||||
// Bild zu brennen. Nur so bleibt er abschaltbar.
|
||||
if let subtitleStreamIndex {
|
||||
query.append(URLQueryItem(name: "subtitleStreamIndex", value: String(subtitleStreamIndex)))
|
||||
query.append(URLQueryItem(name: "subtitleMethod", value: "Embed"))
|
||||
// **`mov_text`, und zwar ausdrücklich.** Ohne diese Zeile
|
||||
// antwortet der Server mit `500`. Gemessen an einem echten
|
||||
// Jellyfin: derselbe Aufruf ohne Untertitel liefert 61 MB, mit
|
||||
// Untertitel und ohne Kodierer-Angabe einen Fehler, mit
|
||||
// `mov_text` 42 MB samt Spur. MP4 kennt kein SubRip; wer den
|
||||
// Untertitel hineinlegen will, muss sagen, als was.
|
||||
query.append(URLQueryItem(name: "subtitleCodec", value: "mov_text"))
|
||||
}
|
||||
return client.url(
|
||||
path: "/Videos/\(itemID)/stream.mp4",
|
||||
query: query + Self.transcodeSession(itemID: itemID, deviceID: client.deviceID),
|
||||
includingToken: account.token
|
||||
)
|
||||
}
|
||||
|
||||
/// Wer fragt, und für welche Sitzung.
|
||||
///
|
||||
/// **Ohne diese zwei Angaben liefert der Server gar nichts.** Gemessen an
|
||||
/// einem echten Jellyfin: dieselbe Adresse ohne sie antwortet mit `200`
|
||||
/// und `Content-Length: 0` — kein Fehler, keine Datei, nichts. Mit ihnen
|
||||
/// kamen in fünfzehn Sekunden 66 MB.
|
||||
///
|
||||
/// Das betraf auch die verkleinerten Fassungen: die hatten sie nie, und
|
||||
/// damit war „in 480p herunterladen" die ganze Zeit folgenlos.
|
||||
///
|
||||
/// Die Sitzungskennung ist je Auftrag neu, nicht je Titel. Zwei Anfragen
|
||||
/// unter derselben Kennung hielt der Server für dieselbe Sitzung und
|
||||
/// antwortete der zweiten mit einer leeren Datei. Gebaut wird die Adresse
|
||||
/// einmal beim Anlegen des Auftrags; ein Wiederholversuch nimmt dieselbe
|
||||
/// und bleibt damit dieselbe Sitzung.
|
||||
private static func transcodeSession(itemID: String, deviceID: String) -> [URLQueryItem] {
|
||||
[
|
||||
URLQueryItem(name: "deviceId", value: deviceID),
|
||||
URLQueryItem(name: "playSessionId", value: "halo-\(UUID().uuidString)"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Eine verkleinerte Fassung zum Herunterladen.
|
||||
///
|
||||
/// **Fortlaufendes MP4, nicht HLS.** Zum Abspielen liefert Jellyfin eine
|
||||
/// Abspielliste mit hunderten Schnipseln; das ist für einen Stream richtig
|
||||
/// und für eine Datei, die auf dem Gerät liegen soll, unbrauchbar. Der
|
||||
/// Endpunkt `/Videos/{id}/stream.mp4` gibt dieselbe Umrechnung als **eine**
|
||||
/// Datei heraus.
|
||||
///
|
||||
/// Hier wird die Adresse selbst gebaut statt über `/PlaybackInfo` erfragt —
|
||||
/// anders als beim Abspielen. Der Grund ist die Signatur: diese Stelle darf
|
||||
/// keine Anfrage stellen, weil die Oberfläche die Adresse beim Zeichnen
|
||||
/// braucht. Das ist vertretbar, weil hier nichts zu entscheiden ist: die
|
||||
/// Stufe steht fest, der Server soll sie liefern.
|
||||
///
|
||||
/// Die Größe bleibt eine Schätzung. Eine Umrechnung, die erst beim Abruf
|
||||
/// beginnt, kennt ihre eigene Länge nicht — der Fortschrittsbalken rechnet
|
||||
/// deshalb gegen die geschätzten Bytes, nicht gegen eine Zusage des Servers.
|
||||
public nonisolated func compressedDownloadURL(
|
||||
itemID: String,
|
||||
versionIndex: Int,
|
||||
maximumBitrate: Int,
|
||||
resolution: String?
|
||||
) -> URL? {
|
||||
var query = [
|
||||
URLQueryItem(name: "videoCodec", value: "h264"),
|
||||
URLQueryItem(name: "audioCodec", value: "aac"),
|
||||
// Jellyfin rechnet in Bit, die App in Kilobit.
|
||||
URLQueryItem(name: "videoBitRate", value: String(maximumBitrate * 1_000)),
|
||||
URLQueryItem(name: "audioBitRate", value: "128000"),
|
||||
// Zwei Kanäle: ein Handy hat keine Rückkanäle, und 5.1 auf zwei
|
||||
// Lautsprecher heruntergemischt klingt schlechter als von
|
||||
// vornherein Stereo.
|
||||
URLQueryItem(name: "TranscodingMaxAudioChannels", value: "2"),
|
||||
URLQueryItem(name: "TranscodingContainer", value: "mp4"),
|
||||
]
|
||||
if let height = resolution?.split(separator: "x").last.flatMap({ Int($0) }) {
|
||||
query.append(URLQueryItem(name: "maxHeight", value: String(height)))
|
||||
}
|
||||
|
||||
return client.url(
|
||||
path: "/Videos/\(itemID)/stream.mp4",
|
||||
query: query + Self.transcodeSession(itemID: itemID, deviceID: client.deviceID),
|
||||
includingToken: account.token
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Zustand melden
|
||||
|
||||
public func markWatched(id: String) async throws(MediaBackendError) {
|
||||
try await client.send(JellyfinRequest(
|
||||
method: .post,
|
||||
path: "/Users/\(account.userID)/PlayedItems/\(id)"
|
||||
))
|
||||
}
|
||||
|
||||
public func markUnwatched(id: String) async throws(MediaBackendError) {
|
||||
try await client.send(JellyfinRequest(
|
||||
method: .delete,
|
||||
path: "/Users/\(account.userID)/PlayedItems/\(id)"
|
||||
))
|
||||
}
|
||||
|
||||
/// Setzt die gemerkte Stelle auf null — damit fällt der Titel aus
|
||||
/// „Weiterschauen" heraus.
|
||||
///
|
||||
/// **Jellyfin kennt dafür keinen eigenen Weg.** Ein Vorschlag, einen
|
||||
/// Titel mit einem Merker auszublenden, liegt seit Jahren als Entwurf
|
||||
/// vor; was es gibt, ist das Benutzerdatenblatt. „Weiterschauen" führt,
|
||||
/// was eine Stelle grösser null hat — also nimmt man sie weg.
|
||||
///
|
||||
/// **Das Blatt wird gebaut, nicht zurückgelesen.** Hier stand erst ein
|
||||
/// `GET` auf denselben Pfad, und genau daran scheiterte es stumm: diese
|
||||
/// Stelle war die einzige im ganzen Projekt, die ein Benutzerdatenblatt
|
||||
/// eigens liest — überall sonst kommt es am Titel mit. Es gab also kein
|
||||
/// zweites Beispiel, an dem der Weg je funktioniert hätte, und ein 404
|
||||
/// beendete die Sache, bevor überhaupt etwas geschrieben wurde.
|
||||
///
|
||||
/// Gelesen wird trotzdem, aber **unverbindlich**: gelingt es, bleiben
|
||||
/// auch Felder erhalten, die diese App gar nicht kennt — Jellyfins
|
||||
/// Daumen etwa. Gelingt es nicht, wird aus dem Titel gebaut, was wir
|
||||
/// wissen. Weggeworfen wird in keinem Fall mehr als die Stelle.
|
||||
public func removeFromContinueWatching(_ item: MediaItem) async throws(MediaBackendError) {
|
||||
var blatt = await userData(forItem: item.id) ?? [
|
||||
"Played": item.isWatched,
|
||||
"IsFavorite": item.isFavourite,
|
||||
"PlayCount": item.playCount ?? 0,
|
||||
]
|
||||
blatt["PlaybackPositionTicks"] = 0
|
||||
// Ohne das bliebe der Anteil stehen, und manche Ansicht rechnet den
|
||||
// Balken daraus statt aus der Stelle.
|
||||
blatt["PlayedPercentage"] = NSNull()
|
||||
|
||||
guard let körper = try? JSONSerialization.data(withJSONObject: blatt) else {
|
||||
throw .decoding(description: "Benutzerdaten zu \(item.id) liessen sich nicht schreiben")
|
||||
}
|
||||
try await writeUserData(körper, forItem: item.id)
|
||||
}
|
||||
|
||||
/// Das Benutzerdatenblatt, wenn der Server es herausgibt.
|
||||
///
|
||||
/// `nil` statt eines Fehlers: ob es diesen Weg gibt, hängt an der
|
||||
/// Serverfassung, und die Antwort darauf darf die Aktion nicht
|
||||
/// aufhalten.
|
||||
private func userData(forItem id: String) async -> [String: Any]? {
|
||||
for pfad in Self.userDataPaths(itemID: id, userID: account.userID) {
|
||||
guard let roh = try? await client.send(JellyfinRequest(
|
||||
path: pfad.path,
|
||||
query: pfad.query
|
||||
)) else { continue }
|
||||
if let blatt = (try? JSONSerialization.jsonObject(with: roh)) as? [String: Any],
|
||||
// Der Auffangzweig mancher Server antwortet mit einer leeren
|
||||
// Seite und Status 200. Die ist kein Blatt.
|
||||
blatt["PlaybackPositionTicks"] != nil || blatt["Played"] != nil {
|
||||
return blatt
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Schreibt das Blatt zurück — über den Weg, den dieser Server kennt.
|
||||
private func writeUserData(_ body: Data, forItem id: String) async throws(MediaBackendError) {
|
||||
var letzter: MediaBackendError?
|
||||
for pfad in Self.userDataPaths(itemID: id, userID: account.userID) {
|
||||
do {
|
||||
try await client.send(JellyfinRequest(
|
||||
method: .post,
|
||||
path: pfad.path,
|
||||
query: pfad.query,
|
||||
body: body
|
||||
))
|
||||
return
|
||||
} catch {
|
||||
letzter = error
|
||||
// Nur ein „gibt es hier nicht" rechtfertigt den zweiten
|
||||
// Versuch. Ein abgelehnter Zugriff oder ein Serverfehler
|
||||
// würde beim anderen Pfad genauso ausgehen.
|
||||
guard case .notFound = error else { throw error }
|
||||
}
|
||||
}
|
||||
throw letzter ?? .notFound
|
||||
}
|
||||
|
||||
/// Die beiden Wege zum Benutzerdatenblatt, neuerer zuerst.
|
||||
///
|
||||
/// Jellyfin hat die verstreuten Aufrufe in `/UserItems/{id}/UserData`
|
||||
/// gebündelt; ältere Fassungen kennen nur `/Users/{id}/Items/{id}/UserData`.
|
||||
/// Welcher gilt, sagt einem der Server erst, wenn man fragt.
|
||||
private static func userDataPaths(
|
||||
itemID: String,
|
||||
userID: String
|
||||
) -> [(path: String, query: [URLQueryItem])] {
|
||||
[
|
||||
("/UserItems/\(itemID)/UserData", [URLQueryItem(name: "userId", value: userID)]),
|
||||
("/Users/\(userID)/Items/\(itemID)/UserData", []),
|
||||
]
|
||||
}
|
||||
|
||||
public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) {
|
||||
try await client.send(JellyfinRequest(
|
||||
method: isFavourite ? .post : .delete,
|
||||
path: "/Users/\(account.userID)/FavoriteItems/\(id)"
|
||||
))
|
||||
}
|
||||
|
||||
public func rate(id: String, rating: Int) async throws(MediaBackendError) {
|
||||
// Jellyfin kennt nur Daumen hoch und runter, keine Sterne. Eine Zahl
|
||||
// von eins bis zehn hier hineinzupressen hieße, etwas zu behaupten,
|
||||
// was der Server nicht speichert.
|
||||
throw .notFound
|
||||
}
|
||||
|
||||
public func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError) {
|
||||
try await client.send(JellyfinRequest(
|
||||
method: .post,
|
||||
path: "/Items/\(library.id)/Refresh",
|
||||
query: [URLQueryItem(name: "Recursive", value: "true")]
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - Hilfen
|
||||
|
||||
/// Die Neuzugänge, wie der Server sie selbst versteht.
|
||||
///
|
||||
/// Ein eigener Endpunkt statt einer Liste, sortiert nach Anlegedatum —
|
||||
/// und das aus zwei Gründen. Der erste ist die Sortierung selbst: sie kam
|
||||
/// alphabetisch zurück, nicht nach Datum. Der zweite wiegt schwerer und
|
||||
/// bliebe auch bestehen, wenn die Sortierung griffe: das Anlegedatum einer
|
||||
/// **Serie** ist der Tag, an dem sie in die Mediathek kam. Eine Serie, die
|
||||
/// seit zwei Jahren dort steht und gestern eine neue Folge bekommen hat,
|
||||
/// ist neu — nach Anlegedatum wäre sie es nie.
|
||||
///
|
||||
/// Die Antwort ist hier ausnahmsweise ein nacktes Feld, kein Objekt mit
|
||||
/// `Items` darin.
|
||||
private func latestItems(
|
||||
limit: Int,
|
||||
inLibrary libraryID: String? = nil
|
||||
) async throws(MediaBackendError) -> [MediaItem] {
|
||||
var query = [
|
||||
URLQueryItem(name: "Limit", value: String(limit)),
|
||||
URLQueryItem(name: "Fields", value: Self.fields),
|
||||
// Neue Folgen erscheinen als ihre Serie statt als zwölf
|
||||
// Einzelkacheln, die alle dasselbe Plakat tragen.
|
||||
URLQueryItem(name: "GroupItems", value: "true"),
|
||||
]
|
||||
if let libraryID { query.append(URLQueryItem(name: "ParentId", value: libraryID)) }
|
||||
|
||||
let items = try await client.send(
|
||||
JellyfinRequest(path: "/Users/\(account.userID)/Items/Latest", query: query),
|
||||
as: [JellyfinItem].self
|
||||
)
|
||||
return items.map { $0.asMediaItem(imageBase: images) }
|
||||
}
|
||||
|
||||
private func rowItems(
|
||||
path: String,
|
||||
query: [URLQueryItem]
|
||||
) async throws(MediaBackendError) -> [MediaItem] {
|
||||
let page = try await client.send(
|
||||
JellyfinRequest(path: path, query: query),
|
||||
as: JellyfinItemPage.self
|
||||
)
|
||||
return page.items.map { $0.asMediaItem(imageBase: images) }
|
||||
}
|
||||
|
||||
/// Was Jellyfin zusätzlich mitschicken soll.
|
||||
///
|
||||
/// Ohne diese Liste fehlen Beschreibung, Besetzung und Fassungen — und
|
||||
/// jede Detailseite bräuchte eine zweite Anfrage.
|
||||
private static let fields = "Overview,Genres,People,MediaSources,DateCreated,ParentId"
|
||||
}
|
||||
|
||||
extension MediaQuery.Sort {
|
||||
var jellyfinField: String {
|
||||
switch self {
|
||||
case .titleAscending, .titleDescending: "SortName"
|
||||
case .recentlyAdded: "DateCreated"
|
||||
case .releaseDate: "PremiereDate"
|
||||
case .rating: "CommunityRating"
|
||||
case .lastPlayed: "DatePlayed"
|
||||
}
|
||||
}
|
||||
|
||||
var isDescending: Bool {
|
||||
switch self {
|
||||
case .titleAscending: false
|
||||
case .titleDescending, .recentlyAdded, .releaseDate, .rating, .lastPlayed: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaLibrary.Kind {
|
||||
/// Was Jellyfin als Art einer Mediathek meldet.
|
||||
///
|
||||
/// **Eigene Funktion, damit sie prüfbar ist.** Die Zuordnung entscheidet
|
||||
/// mehr, als sie aussieht: was hier als `.photo` oder `.music` landet,
|
||||
/// fällt unter `MediaLibrary.isSupported` heraus und ist in der App
|
||||
/// **gar nicht vorhanden** — keine Reihe, kein Eintrag in der Leiste,
|
||||
/// nicht einmal in der Auswahl für den Aufmacher. Ein Fehler hier sieht
|
||||
/// aus wie eine Mediathek, die der Server nicht liefert.
|
||||
///
|
||||
/// Genau das war mit `homevideos` der Fall: Jellyfin nennt „Heimvideos &
|
||||
/// Fotos" so, und weil Fotos im Namen stehen, lag es bei den Fotos. Der
|
||||
/// Inhalt sind Videos.
|
||||
init(jellyfinCollectionType: String?) {
|
||||
switch jellyfinCollectionType {
|
||||
case "movies": self = .movie
|
||||
case "tvshows": self = .show
|
||||
case "music": self = .music
|
||||
case "photos": self = .photo
|
||||
case "livetv": self = .liveTV
|
||||
// Auch `homevideos`, `musicvideos`, `mixed` und alles Unbekannte:
|
||||
// Videos, die man ansehen kann, auch wenn der Server sie nicht
|
||||
// einsortiert.
|
||||
default: self = .other
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Wer wir für einen Jellyfin-Server sind.
|
||||
///
|
||||
/// Jellyfin erwartet all das in **einer** Kopfzeile, in einer eigenen
|
||||
/// Schreibweise. Die Gerätekennung muss über Starts hinweg gleich bleiben:
|
||||
/// wechselt sie, gilt die App als neues Gerät, und in der Geräteliste des
|
||||
/// Servers sammelt sich Müll an.
|
||||
public struct JellyfinIdentity: Sendable, Equatable {
|
||||
public let client: String
|
||||
public let device: String
|
||||
public let deviceID: String
|
||||
public let version: String
|
||||
|
||||
public init(client: String, device: String, deviceID: String, version: String) {
|
||||
self.client = client
|
||||
self.device = device
|
||||
self.deviceID = deviceID
|
||||
self.version = version
|
||||
}
|
||||
|
||||
/// Die Kopfzeile, mit oder ohne Token.
|
||||
///
|
||||
/// Anführungszeichen sind Pflicht, auch um Werte ohne Leerzeichen —
|
||||
/// Jellyfin zerlegt die Zeile sonst falsch und meldet, man sei nicht
|
||||
/// angemeldet, obwohl der Token stimmt.
|
||||
func authorizationHeader(token: String?) -> String {
|
||||
var fields = [
|
||||
"Client=\"\(client)\"",
|
||||
"Device=\"\(device)\"",
|
||||
"DeviceId=\"\(deviceID)\"",
|
||||
"Version=\"\(version)\"",
|
||||
]
|
||||
if let token { fields.append("Token=\"\(token)\"") }
|
||||
return "MediaBrowser " + fields.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Spricht mit einem Jellyfin-Server.
|
||||
public actor JellyfinClient {
|
||||
public nonisolated let baseURL: URL
|
||||
private let identity: JellyfinIdentity
|
||||
private let transport: any JellyfinTransport
|
||||
private var token: String?
|
||||
|
||||
public init(
|
||||
baseURL: URL,
|
||||
identity: JellyfinIdentity,
|
||||
token: String? = nil,
|
||||
transport: any JellyfinTransport = URLSession.shared
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.identity = identity
|
||||
self.token = token
|
||||
self.transport = transport
|
||||
}
|
||||
|
||||
public func setToken(_ token: String?) {
|
||||
self.token = token
|
||||
}
|
||||
|
||||
/// Unter welcher Kennung der Server dieses Gerät führt.
|
||||
///
|
||||
/// Wird zum Abräumen gebraucht: eine laufende Umrechnung wird über Gerät
|
||||
/// **und** Sitzung angehalten, nicht über die Sitzung allein.
|
||||
public nonisolated var deviceID: String { identity.deviceID }
|
||||
|
||||
public nonisolated func authorization(token: String?) -> String {
|
||||
identity.authorizationHeader(token: token)
|
||||
}
|
||||
|
||||
/// Die Adresse eines Endpunkts, mit Token in der Abfrage.
|
||||
///
|
||||
/// Für alles, was nicht die App abruft, sondern der Systemplayer oder eine
|
||||
/// Bildansicht: die schicken keine Kopfzeilen mit. Bei Plex hat genau das
|
||||
/// tagelang gekostet — hier steht es von Anfang an.
|
||||
public nonisolated func url(
|
||||
path: String,
|
||||
query: [URLQueryItem] = [],
|
||||
includingToken token: String? = nil
|
||||
) -> URL? {
|
||||
guard var components = URLComponents(
|
||||
url: baseURL.appending(path: path),
|
||||
resolvingAgainstBaseURL: false
|
||||
) else { return nil }
|
||||
|
||||
var items = query
|
||||
if let token { items.append(URLQueryItem(name: "ApiKey", value: token)) }
|
||||
components.queryItems = items.isEmpty ? nil : items
|
||||
return components.url
|
||||
}
|
||||
|
||||
public nonisolated var currentTokenForURLs: String? { nil }
|
||||
|
||||
// MARK: - Anfragen
|
||||
|
||||
public func send<Value: Decodable & Sendable>(
|
||||
_ request: JellyfinRequest,
|
||||
as type: Value.Type
|
||||
) async throws(MediaBackendError) -> Value {
|
||||
let data = try await send(request)
|
||||
do {
|
||||
return try Self.decoder.decode(Value.self, from: data)
|
||||
} catch {
|
||||
throw .decoding(description: String(describing: error))
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func send(_ request: JellyfinRequest) async throws(MediaBackendError) -> Data {
|
||||
guard var components = URLComponents(
|
||||
url: baseURL.appending(path: request.path),
|
||||
resolvingAgainstBaseURL: false
|
||||
) else {
|
||||
throw .transport(description: "Adresse nicht baubar")
|
||||
}
|
||||
components.queryItems = request.query.isEmpty ? nil : request.query
|
||||
|
||||
guard let url = components.url else {
|
||||
throw .transport(description: "Adresse nicht baubar")
|
||||
}
|
||||
|
||||
var urlRequest = URLRequest(url: url)
|
||||
urlRequest.httpMethod = request.method.rawValue
|
||||
urlRequest.timeoutInterval = request.timeout.seconds
|
||||
urlRequest.setValue(identity.authorizationHeader(token: token), forHTTPHeaderField: "Authorization")
|
||||
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let body = request.body {
|
||||
urlRequest.httpBody = body
|
||||
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await transport.data(for: urlRequest)
|
||||
} catch {
|
||||
throw .transport(description: error.localizedDescription)
|
||||
}
|
||||
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw .transport(description: "Keine HTTP-Antwort")
|
||||
}
|
||||
|
||||
switch http.statusCode {
|
||||
case 200..<300: return data
|
||||
case 401, 403: throw .unauthorized
|
||||
case 404: throw .notFound
|
||||
default: throw .server(status: http.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
/// Jellyfin schreibt seine Felder groß und rechnet Zeiten in Ticks.
|
||||
///
|
||||
/// Kein `convertFromPascalCase` — das gibt es nicht, und die Felder
|
||||
/// tragen ihre Schreibweise in den `CodingKeys` der Modelle.
|
||||
private static let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return decoder
|
||||
}()
|
||||
}
|
||||
|
||||
/// Eine Anfrage an Jellyfin.
|
||||
public struct JellyfinRequest: Sendable {
|
||||
public enum Method: String, Sendable { case get = "GET", post = "POST", delete = "DELETE" }
|
||||
|
||||
public let method: Method
|
||||
public let path: String
|
||||
public let query: [URLQueryItem]
|
||||
public let body: Data?
|
||||
public let timeout: Duration
|
||||
|
||||
public init(
|
||||
method: Method = .get,
|
||||
path: String,
|
||||
query: [URLQueryItem] = [],
|
||||
body: Data? = nil,
|
||||
timeout: Duration = .seconds(30)
|
||||
) {
|
||||
self.method = method
|
||||
self.path = path
|
||||
self.query = query
|
||||
self.body = body
|
||||
self.timeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie Anfragen tatsächlich verschickt werden.
|
||||
///
|
||||
/// Dieselbe Trennung wie bei Plex: Tests brauchen keinen Server, sondern
|
||||
/// vorbereitete Antworten.
|
||||
public protocol JellyfinTransport: Sendable {
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse)
|
||||
}
|
||||
|
||||
extension URLSession: JellyfinTransport {}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
extension JellyfinIdentity {
|
||||
private static let storageKey = "halo.jellyfin.deviceID"
|
||||
|
||||
/// Baut die Kennung für das laufende Gerät und merkt sie sich.
|
||||
///
|
||||
/// Dieselbe Vorsicht wie bei Plex: wechselt die Gerätekennung, gilt die
|
||||
/// App dem Server als neues Gerät. In der Geräteliste steht dann bei jedem
|
||||
/// Start ein weiterer Eintrag, und laufende Sitzungen lassen sich nicht
|
||||
/// mehr zuordnen.
|
||||
///
|
||||
/// `@MainActor`, weil `UIDevice.current` dort beheimatet ist.
|
||||
@MainActor
|
||||
public static func current(
|
||||
client: String,
|
||||
version: String,
|
||||
defaults: UserDefaults = .standard
|
||||
) -> JellyfinIdentity {
|
||||
let deviceID: String
|
||||
if let stored = defaults.string(forKey: storageKey) {
|
||||
deviceID = stored
|
||||
} else {
|
||||
deviceID = UUID().uuidString
|
||||
defaults.set(deviceID, forKey: storageKey)
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
let device = UIDevice.current.name
|
||||
#else
|
||||
let device = ProcessInfo.processInfo.hostName
|
||||
#endif
|
||||
|
||||
return JellyfinIdentity(
|
||||
client: client,
|
||||
device: device,
|
||||
deviceID: deviceID,
|
||||
version: version
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Die Übersetzung der Jellyfin-Modelle in die neutrale Form.
|
||||
///
|
||||
/// **Hier und nicht in den Ansichten.** Jeder Server bringt seine eigenen
|
||||
/// Felder mit; sie genau einmal an der Modulgrenze zu übersetzen ist der
|
||||
/// Preis dafür, dass es die Ansichten nur einmal gibt. Kommt ein zweiter
|
||||
/// Server dazu, bekommt er seine eigene Datei dieser Art — und sonst nichts.
|
||||
extension JellyfinItem {
|
||||
/// `markers` kommen von aussen, weil Jellyfin sie nicht am Titel
|
||||
/// liefert: sie stehen hinter `/MediaSegments/{id}` und damit hinter
|
||||
/// einer zweiten Anfrage. Siehe `JellyfinBackend.segments(forItem:)`.
|
||||
public func asMediaItem(
|
||||
imageBase: JellyfinImagePaths,
|
||||
markers: [MediaMarker] = []
|
||||
) -> MediaItem {
|
||||
MediaItem(
|
||||
id: id,
|
||||
path: nil,
|
||||
kind: Self.kind(type),
|
||||
title: name ?? "—",
|
||||
summary: overview,
|
||||
year: productionYear,
|
||||
duration: runTimeTicks?.millisecondsFromTicks,
|
||||
contentRating: officialRating,
|
||||
// Jellyfin führt beide auf einer Zehnerskala, wie Plex auch.
|
||||
rating: criticRating.map { $0 / 10 } ?? communityRating,
|
||||
audienceRating: communityRating,
|
||||
// **Nur wenn es auch eines gibt** — dieselbe Vorsicht wie eine
|
||||
// Zeile tiefer beim Hintergrundbild, die hier gefehlt hat.
|
||||
//
|
||||
// Die Adresse entsteht aus der Kennung, nicht aus einer Antwort
|
||||
// des Servers. Ohne die Prüfung trug jeder Titel ohne eigenes
|
||||
// Plakat eine Adresse, die mit „nicht gefunden" antwortet — und
|
||||
// weil ein 404 zu Recht nicht wiederholt wird, blieb die Kachel
|
||||
// für immer grau. Betroffen waren vor allem Staffeln: die haben
|
||||
// bei Jellyfin oft kein eigenes Bild, sondern leihen das der
|
||||
// Serie. Ist hier nichts, darf die Kachel genau das tun.
|
||||
thumb: imageTags?["Primary"].map { imageBase.primary(itemID: id, tag: $0) },
|
||||
// Nur wenn es auch eines gibt: die Adresse entsteht aus der
|
||||
// Kennung, nicht aus einer Antwort des Servers — ohne diese
|
||||
// Prüfung stünde hinter jedem Titel ohne Hintergrundbild eine
|
||||
// Adresse, die mit „nicht gefunden" antwortet. Fehlt dem Titel
|
||||
// eines, nennt Jellyfin die Serie, von der es geliehen werden darf.
|
||||
art: backdropImageTags?.first.map { imageBase.backdrop(itemID: id, tag: $0) }
|
||||
?? parentBackdropItemID.map { imageBase.backdrop(itemID: $0, tag: nil) },
|
||||
parentThumb: seasonID.map { imageBase.primary(itemID: $0, tag: nil) },
|
||||
seriesThumb: seriesID.map { imageBase.primary(itemID: $0, tag: seriesPrimaryImageTag) },
|
||||
seriesArt: seriesID.map { imageBase.backdrop(itemID: $0, tag: nil) },
|
||||
index: indexNumber,
|
||||
parentIndex: parentIndexNumber,
|
||||
parentTitle: seasonName,
|
||||
parentID: seasonID,
|
||||
seriesTitle: seriesName,
|
||||
seriesID: seriesID,
|
||||
playCount: userData?.playCount,
|
||||
resumeMilliseconds: userData?.playbackPositionTicks?.millisecondsFromTicks,
|
||||
lastPlayedAt: nil,
|
||||
addedAt: dateCreated,
|
||||
// Das Feld, das es bei Plex nicht gibt — hier kommt es frei mit.
|
||||
isFavourite: userData?.isFavorite ?? false,
|
||||
episodeCount: recursiveItemCount,
|
||||
playedEpisodeCount: playedCount,
|
||||
childCount: childCount,
|
||||
versions: (mediaSources ?? []).map { $0.asMediaVersion(itemID: id) },
|
||||
genres: genres ?? [],
|
||||
directors: (people ?? []).filter { $0.type == "Director" }.compactMap(\.name),
|
||||
writers: (people ?? []).filter { $0.type == "Writer" }.compactMap(\.name),
|
||||
cast: (people ?? []).filter { $0.type == "Actor" }.map { person in
|
||||
MediaPerson(
|
||||
id: person.id ?? person.name ?? UUID().uuidString,
|
||||
name: person.name ?? "—",
|
||||
role: person.role,
|
||||
thumb: person.id.map { imageBase.primary(itemID: $0, tag: person.primaryImageTag) } ?? nil
|
||||
)
|
||||
},
|
||||
markers: markers,
|
||||
artwork: logoArtwork(imageBase: imageBase)
|
||||
)
|
||||
}
|
||||
|
||||
/// Wie viele Folgen gesehen sind.
|
||||
///
|
||||
/// Jellyfin meldet die **ungesehenen**. Die Zahl, die die Oberfläche
|
||||
/// zeigt, ist die andere — und sie auszurechnen ist ehrlicher, als das
|
||||
/// Gegenteil anzuzeigen und es „Fortschritt" zu nennen.
|
||||
private var playedCount: Int? {
|
||||
guard let total = recursiveItemCount, let unplayed = userData?.unplayedItemCount else {
|
||||
return nil
|
||||
}
|
||||
return max(0, total - unplayed)
|
||||
}
|
||||
|
||||
private func logoArtwork(imageBase: JellyfinImagePaths) -> [MediaArtwork] {
|
||||
guard let tag = imageTags?["Logo"] else { return [] }
|
||||
return [MediaArtwork(kind: .clearLogo, path: imageBase.logo(itemID: id, tag: tag))]
|
||||
}
|
||||
|
||||
private static func kind(_ type: String?) -> MediaItem.Kind? {
|
||||
switch type {
|
||||
case "Movie": .movie
|
||||
case "Series": .show
|
||||
case "Season": .season
|
||||
case "Episode": .episode
|
||||
case "BoxSet": .collection
|
||||
case "Trailer", "Video": .clip
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension JellyfinMediaSource {
|
||||
public func asMediaVersion(itemID: String) -> MediaVersion {
|
||||
let streams = mediaStreams ?? []
|
||||
let video = streams.first { $0.type == "Video" }
|
||||
let audio = streams.first { $0.type == "Audio" && $0.isDefault == true }
|
||||
?? streams.first { $0.type == "Audio" }
|
||||
|
||||
return MediaVersion(
|
||||
id: id ?? itemID,
|
||||
container: container,
|
||||
videoCodec: video?.codec,
|
||||
audioCodec: audio?.codec,
|
||||
videoResolution: video?.height.map(String.init),
|
||||
// Jellyfin rechnet in Bit je Sekunde, die App in Kilobit.
|
||||
bitrate: bitrate.map { $0 / 1_000 },
|
||||
width: video?.width,
|
||||
height: video?.height,
|
||||
audioChannels: audio?.channels,
|
||||
duration: runTimeTicks?.millisecondsFromTicks,
|
||||
files: [
|
||||
// Jellyfin trennt Fassung und Datei nicht: eine Quelle ist
|
||||
// beides. Die neutrale Form kennt beide Ebenen, weil Plex sie
|
||||
// trennt — hier ist die zweite eben einelementig.
|
||||
MediaFile(
|
||||
id: id ?? itemID,
|
||||
key: id ?? itemID,
|
||||
itemID: itemID,
|
||||
container: container,
|
||||
size: size,
|
||||
duration: runTimeTicks?.millisecondsFromTicks,
|
||||
streams: streams.compactMap(\.asMediaStream)
|
||||
)
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension JellyfinMediaStream {
|
||||
public var asMediaStream: MediaStream? {
|
||||
let mapped: MediaStream.Kind
|
||||
switch type {
|
||||
case "Video": mapped = .video
|
||||
case "Audio": mapped = .audio
|
||||
case "Subtitle": mapped = .subtitle
|
||||
default: return nil
|
||||
}
|
||||
|
||||
return MediaStream(
|
||||
id: String(index ?? 0),
|
||||
kind: mapped,
|
||||
codec: codec,
|
||||
language: language,
|
||||
languageCode: language,
|
||||
displayTitle: displayTitle,
|
||||
channels: channels,
|
||||
isSelected: isDefault ?? false,
|
||||
isForced: isForced ?? false,
|
||||
title: title,
|
||||
isDefault: isDefault ?? false,
|
||||
isHearingImpaired: isHearingImpaired ?? false,
|
||||
isOriginal: isOriginal ?? false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Baut die Adressen der Bilder.
|
||||
///
|
||||
/// Eigener Typ, weil jedes Bild eine eigene Adresse ist und nicht ein Pfad wie
|
||||
/// bei Plex. Die Marke (`tag`) gehört dazu: ohne sie liefert der Server zwar
|
||||
/// ein Bild, aber der Zwischenspeicher merkt nicht, wenn es sich ändert.
|
||||
public struct JellyfinImagePaths: Sendable {
|
||||
public init() {}
|
||||
|
||||
public func primary(itemID: String, tag: String?) -> String {
|
||||
path(itemID: itemID, kind: "Primary", tag: tag)
|
||||
}
|
||||
|
||||
public func backdrop(itemID: String, tag: String?) -> String {
|
||||
path(itemID: itemID, kind: "Backdrop", tag: tag)
|
||||
}
|
||||
|
||||
public func logo(itemID: String, tag: String?) -> String {
|
||||
path(itemID: itemID, kind: "Logo", tag: tag)
|
||||
}
|
||||
|
||||
private func path(itemID: String, kind: String, tag: String?) -> String {
|
||||
var path = "/Items/\(itemID)/Images/\(kind)"
|
||||
if let tag { path += "?tag=\(tag)" }
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Ein Eintrag, wie Jellyfin ihn über die Leitung schickt.
|
||||
///
|
||||
/// Die Felder heißen groß und die Zeiten sind Ticks zu hundert Nanosekunden —
|
||||
/// beides bleibt hier unten. Nach oben geht nur `MediaItem`.
|
||||
public struct JellyfinItem: Decodable, Sendable {
|
||||
public let id: String
|
||||
public let name: String?
|
||||
public let type: String?
|
||||
public let overview: String?
|
||||
public let productionYear: Int?
|
||||
/// Laufzeit in Ticks zu 100 ns.
|
||||
public let runTimeTicks: Int64?
|
||||
public let indexNumber: Int?
|
||||
public let parentIndexNumber: Int?
|
||||
public let seriesName: String?
|
||||
public let seriesID: String?
|
||||
public let seasonID: String?
|
||||
public let seasonName: String?
|
||||
public let officialRating: String?
|
||||
public let communityRating: Double?
|
||||
public let criticRating: Double?
|
||||
public let dateCreated: Date?
|
||||
public let childCount: Int?
|
||||
public let recursiveItemCount: Int?
|
||||
public let genres: [String]?
|
||||
public let people: [Person]?
|
||||
public let userData: UserData?
|
||||
public let imageTags: [String: String]?
|
||||
public let backdropImageTags: [String]?
|
||||
public let parentBackdropItemID: String?
|
||||
public let seriesPrimaryImageTag: String?
|
||||
public let mediaSources: [JellyfinMediaSource]?
|
||||
public let collectionType: String?
|
||||
|
||||
// MARK: - Live-TV
|
||||
//
|
||||
// Sender **und** Sendungen kommen bei Jellyfin als derselbe Typ wie
|
||||
// Filme und Folgen. Deshalb stehen ihre Felder hier und nicht in einem
|
||||
// eigenen Modell: ein zweites, das zu neunzig Prozent dasselbe trägt,
|
||||
// wäre eine Kopie mit Pflegeaufwand.
|
||||
|
||||
/// Zu welchem Sender eine Sendung gehört.
|
||||
public let channelID: String?
|
||||
public let channelName: String?
|
||||
/// Der Folgentitel einer Sendung — der Reihentitel steht in `name`.
|
||||
public let episodeTitle: String?
|
||||
/// Die Nummer, unter der ein Sender im Programm steht — „3" oder „11.1".
|
||||
public let channelNumber: String?
|
||||
/// Dasselbe, aber am Sender selbst statt an der Sendung.
|
||||
public let number: String?
|
||||
public let startDate: Date?
|
||||
public let endDate: Date?
|
||||
/// Was auf diesem Sender gerade läuft.
|
||||
///
|
||||
/// Kommt nur, wenn die Senderliste mit `addCurrentProgram` geholt wurde —
|
||||
/// dann aber gleich mit, und die Liste braucht keinen zweiten Aufruf.
|
||||
public let currentProgram: JellyfinCurrentProgram?
|
||||
/// Ob die Sendung zu einem laufenden Serien-Timer gehört.
|
||||
public let seriesTimerID: String?
|
||||
public let isMovie: Bool?
|
||||
public let isSeries: Bool?
|
||||
public let isNews: Bool?
|
||||
public let isSports: Bool?
|
||||
public let isKids: Bool?
|
||||
public let isLive: Bool?
|
||||
|
||||
public struct Person: Decodable, Sendable {
|
||||
public let id: String?
|
||||
public let name: String?
|
||||
public let role: String?
|
||||
public let type: String?
|
||||
public let primaryImageTag: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id = "Id", name = "Name", role = "Role", type = "Type"
|
||||
case primaryImageTag = "PrimaryImageTag"
|
||||
}
|
||||
}
|
||||
|
||||
public struct UserData: Decodable, Sendable {
|
||||
public let playbackPositionTicks: Int64?
|
||||
public let playCount: Int?
|
||||
public let played: Bool?
|
||||
public let isFavorite: Bool?
|
||||
public let unplayedItemCount: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case playbackPositionTicks = "PlaybackPositionTicks"
|
||||
case playCount = "PlayCount"
|
||||
case played = "Played"
|
||||
case isFavorite = "IsFavorite"
|
||||
case unplayedItemCount = "UnplayedItemCount"
|
||||
}
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id = "Id", name = "Name", type = "Type", overview = "Overview"
|
||||
case productionYear = "ProductionYear", runTimeTicks = "RunTimeTicks"
|
||||
case indexNumber = "IndexNumber", parentIndexNumber = "ParentIndexNumber"
|
||||
case seriesName = "SeriesName", seriesID = "SeriesId"
|
||||
case seasonID = "SeasonId", seasonName = "SeasonName"
|
||||
case officialRating = "OfficialRating", communityRating = "CommunityRating"
|
||||
case criticRating = "CriticRating", dateCreated = "DateCreated"
|
||||
case childCount = "ChildCount", recursiveItemCount = "RecursiveItemCount"
|
||||
case genres = "Genres", people = "People", userData = "UserData"
|
||||
case imageTags = "ImageTags", backdropImageTags = "BackdropImageTags"
|
||||
case parentBackdropItemID = "ParentBackdropItemId"
|
||||
case seriesPrimaryImageTag = "SeriesPrimaryImageTag"
|
||||
case mediaSources = "MediaSources", collectionType = "CollectionType"
|
||||
case channelID = "ChannelId", channelName = "ChannelName"
|
||||
case episodeTitle = "EpisodeTitle"
|
||||
case channelNumber = "ChannelNumber", number = "Number"
|
||||
case startDate = "StartDate", endDate = "EndDate"
|
||||
case currentProgram = "CurrentProgram", seriesTimerID = "SeriesTimerId"
|
||||
case isMovie = "IsMovie", isSeries = "IsSeries", isNews = "IsNews"
|
||||
case isSports = "IsSports", isKids = "IsKids", isLive = "IsLive"
|
||||
}
|
||||
}
|
||||
|
||||
/// Was auf einem Sender gerade läuft.
|
||||
///
|
||||
/// Ein eigenes, schmales Modell statt eines verschachtelten `JellyfinItem`:
|
||||
/// ein Struct darf sich nicht selbst enthalten — seine Größe wäre unendlich.
|
||||
/// Gebraucht werden hier ohnehin nur fünf Felder.
|
||||
public struct JellyfinCurrentProgram: Decodable, Sendable {
|
||||
public let id: String
|
||||
public let name: String?
|
||||
public let overview: String?
|
||||
public let startDate: Date?
|
||||
public let endDate: Date?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id = "Id", name = "Name", overview = "Overview"
|
||||
case startDate = "StartDate", endDate = "EndDate"
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Fassung, wie Jellyfin sie meldet.
|
||||
public struct JellyfinMediaSource: Decodable, Sendable {
|
||||
public let id: String?
|
||||
public let container: String?
|
||||
public let size: Int64?
|
||||
public let bitrate: Int?
|
||||
public let runTimeTicks: Int64?
|
||||
public let mediaStreams: [JellyfinMediaStream]?
|
||||
public let supportsDirectPlay: Bool?
|
||||
public let supportsDirectStream: Bool?
|
||||
public let supportsTranscoding: Bool?
|
||||
public let transcodingURL: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id = "Id", container = "Container", size = "Size", bitrate = "Bitrate"
|
||||
case runTimeTicks = "RunTimeTicks", mediaStreams = "MediaStreams"
|
||||
case supportsDirectPlay = "SupportsDirectPlay"
|
||||
case supportsDirectStream = "SupportsDirectStream"
|
||||
case supportsTranscoding = "SupportsTranscoding"
|
||||
case transcodingURL = "TranscodingUrl"
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Spur, wie Jellyfin sie meldet.
|
||||
public struct JellyfinMediaStream: Decodable, Sendable {
|
||||
public let index: Int?
|
||||
public let type: String?
|
||||
public let codec: String?
|
||||
public let language: String?
|
||||
public let displayTitle: String?
|
||||
/// Was **in der Datei** steht — im Gegensatz zu `displayTitle`, das
|
||||
/// Jellyfin aus Sprache, Codec und Kennzeichen zusammensetzt.
|
||||
///
|
||||
/// Das zweite Signal neben dem Sprachkürzel, und manchmal das einzige
|
||||
/// richtige: wo „eng" steht, kann im Titel „Deutsch" stehen.
|
||||
public let title: String?
|
||||
/// Für Hörgeschädigte. **Nicht** Audiodeskription — dafür hat Jellyfin
|
||||
/// kein Kennzeichen.
|
||||
public let isHearingImpaired: Bool?
|
||||
public let isOriginal: Bool?
|
||||
public let channels: Int?
|
||||
public let isDefault: Bool?
|
||||
public let isForced: Bool?
|
||||
public let height: Int?
|
||||
public let width: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case index = "Index", type = "Type", codec = "Codec", language = "Language"
|
||||
case displayTitle = "DisplayTitle", channels = "Channels"
|
||||
case title = "Title"
|
||||
case isHearingImpaired = "IsHearingImpaired", isOriginal = "IsOriginal"
|
||||
case isDefault = "IsDefault", isForced = "IsForced"
|
||||
case height = "Height", width = "Width"
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Hülle, in der Jellyfin Listen schickt.
|
||||
public struct JellyfinItemPage: Decodable, Sendable {
|
||||
public let items: [JellyfinItem]
|
||||
public let totalRecordCount: Int?
|
||||
public let startIndex: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case items = "Items"
|
||||
case totalRecordCount = "TotalRecordCount"
|
||||
case startIndex = "StartIndex"
|
||||
}
|
||||
}
|
||||
|
||||
extension Int64 {
|
||||
/// Ticks zu hundert Nanosekunden in Millisekunden.
|
||||
///
|
||||
/// Jellyfins Einheit. Zehntausend Ticks sind eine Millisekunde — eine
|
||||
/// Zahl, die man beim Lesen zweimal prüft und deshalb genau einmal
|
||||
/// aufschreibt.
|
||||
var millisecondsFromTicks: Int { Int(self / 10_000) }
|
||||
}
|
||||
|
||||
extension Int {
|
||||
var ticksFromMilliseconds: Int64 { Int64(self) * 10_000 }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
|
||||
/// Merkt sich, in welcher Mediathek ein Titel liegt.
|
||||
///
|
||||
/// **Warum überhaupt.** Die Antwort kostet eine Serveranfrage und ändert sich
|
||||
/// praktisch nie — ein Titel wandert nicht von den Filmen zu den Serien. Ohne
|
||||
/// Gedächtnis fragte jede Folge einer Serie dasselbe noch einmal, und zwar
|
||||
/// genau in dem Augenblick, in dem der Film anlaufen soll.
|
||||
///
|
||||
/// **Auch das Nichts wird gemerkt.** Ein Server, der die Frage nicht
|
||||
/// beantworten kann, beantwortet sie beim zweiten Mal auch nicht — sonst
|
||||
/// wiederholte sich die vergebliche Anfrage bei jedem Titel. Deshalb die
|
||||
/// doppelte Optionalität: „nie gefragt" und „gefragt, keine Antwort" sind
|
||||
/// zwei verschiedene Dinge.
|
||||
public actor JellyfinLibraryMemo {
|
||||
/// Eine Antwort, die es gab — der Inhalt darf leer sein.
|
||||
public struct Answer: Sendable {
|
||||
public let value: String?
|
||||
}
|
||||
|
||||
private var seen: [String: String?] = [:]
|
||||
|
||||
public init() {}
|
||||
|
||||
public func recorded(_ key: String) -> Answer? {
|
||||
seen[key].map(Answer.init)
|
||||
}
|
||||
|
||||
public func store(_ value: String?, for key: String) {
|
||||
seen[key] = value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"player.transcodeUnavailable" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Der Server hat keine umgerechnete Fassung angeboten."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "The server didn't offer a transcoded version."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"row.continueWatching" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Weiterschauen"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Continue watching"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"row.favourites" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Favoriten"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Favourites"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"row.latestIn %@" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Neu in %@"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "New in %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"row.nextUp" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Als Nächstes"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Next up"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"row.recentlyAdded" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Zuletzt hinzugefügt"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Recently added"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"search.episodes" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Folgen"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Episodes"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"search.movies" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Filme"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Movies"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"search.shows" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Serien"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Shows"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
/// Etwas, das der Nutzer ausblenden und verschieben kann.
|
||||
public protocol Arrangeable {
|
||||
/// Eine Kennung, die über Abrufe hinweg gleich bleibt.
|
||||
///
|
||||
/// **Nicht** der angezeigte Titel: benennt der Server eine Reihe oder eine
|
||||
/// Mediathek um, wäre sonst alles Gespeicherte hinfällig.
|
||||
var arrangementID: String { get }
|
||||
var arrangementTitle: String { get }
|
||||
}
|
||||
|
||||
/// Was ausgeblendet ist und in welcher Reihenfolge der Rest steht.
|
||||
///
|
||||
/// War bis eben an die Startseiten-Reihen von Plex gebunden. Die Rechnung ist
|
||||
/// dieselbe für jede Liste, die jemand selbst zusammenstellt — Reihen,
|
||||
/// Seitenleisteneinträge, Sender. Sie ein zweites Mal zu schreiben hieße, sie
|
||||
/// ein zweites Mal falsch zu machen.
|
||||
public struct Arrangement: Sendable, Equatable {
|
||||
public var hidden: Set<String>
|
||||
public var order: [String]
|
||||
|
||||
public init(hidden: Set<String> = [], order: [String] = []) {
|
||||
self.hidden = hidden
|
||||
self.order = order
|
||||
}
|
||||
|
||||
/// Wendet Auswahl und Reihenfolge an.
|
||||
///
|
||||
/// Unbekannte Einträge landen am Ende statt zu verschwinden. Sie zu
|
||||
/// verwerfen wäre die einfachere Regel — und der Grund dafür, dass ein
|
||||
/// neu angelegtes Regal auf dem Server in der App nie aufgetaucht wäre.
|
||||
public func apply<Element: Arrangeable>(to elements: [Element]) -> [Element] {
|
||||
let visible = elements.filter { !hidden.contains($0.arrangementID) }
|
||||
guard !order.isEmpty else { return visible }
|
||||
|
||||
let positions = Dictionary(
|
||||
order.enumerated().map { ($1, $0) },
|
||||
// Ein doppelter Eintrag in der gespeicherten Reihenfolge — etwa
|
||||
// nach einem fehlgeschlagenen Schreibvorgang — soll nicht
|
||||
// abstürzen. Die erste Position gewinnt.
|
||||
uniquingKeysWith: { first, _ in first }
|
||||
)
|
||||
|
||||
return visible.enumerated().sorted { left, right in
|
||||
let leftPosition = positions[left.element.arrangementID] ?? Int.max
|
||||
let rightPosition = positions[right.element.arrangementID] ?? Int.max
|
||||
// Bei gleicher Position — beide unbekannt — bleibt die Reihenfolge
|
||||
// des Servers erhalten. Ohne diese Rückfalllinie wäre die
|
||||
// Anordnung neuer Einträge zufällig.
|
||||
if leftPosition == rightPosition { return left.offset < right.offset }
|
||||
return leftPosition < rightPosition
|
||||
}.map(\.element)
|
||||
}
|
||||
|
||||
/// Die Liste für den Einstellungsbildschirm: gespeicherte Reihenfolge
|
||||
/// zuerst, danach alles Übrige.
|
||||
///
|
||||
/// Gespeicherte Einträge, die es nicht mehr gibt, fallen heraus — sie
|
||||
/// anzuzeigen hieße, eine Reihe anzubieten, die niemand mehr hat.
|
||||
public func arrangedIdentifiers<Element: Arrangeable>(from elements: [Element]) -> [String] {
|
||||
let available = elements.map(\.arrangementID)
|
||||
let known = Set(available)
|
||||
let stored = order.filter(known.contains)
|
||||
return stored + available.filter { !stored.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaRowGroup: Arrangeable {
|
||||
public var arrangementID: String { id }
|
||||
public var arrangementTitle: String { title }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
extension Duration {
|
||||
/// Die Dauer als Gleitkommazahl in Sekunden.
|
||||
///
|
||||
/// Foundation und AVFoundation rechnen in `TimeInterval`; `Duration` ist
|
||||
/// der genauere Typ, den der übrige Code verwendet.
|
||||
///
|
||||
/// Sie liegt in der neutralen Schicht und nicht beim Server — das ist
|
||||
/// der Grund, warum Downloads und Wiedergabe ohne jeden Serverbezug
|
||||
/// übersetzen. In einem Servermodul stand sie einmal, „weil es die
|
||||
/// unterste Schicht ist"; das stimmte nur, solange es einen gab.
|
||||
public var seconds: Double {
|
||||
Double(components.seconds) + Double(components.attoseconds) / 1e18
|
||||
}
|
||||
|
||||
/// Die Dauer in ganzen Millisekunden.
|
||||
///
|
||||
/// Die übliche Einheit, in der Server Fortschritt melden — Jellyfin
|
||||
/// allerdings in Ticks zu 100 Nanosekunden, was sein Adapter umrechnet.
|
||||
public var milliseconds: Int {
|
||||
Int(components.seconds * 1000 + components.attoseconds / 1_000_000_000_000_000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
|
||||
/// Welche Folge dran ist, wenn jemand eine Serie oder Staffel startet.
|
||||
///
|
||||
/// **Warum als reiner Typ.** Liste hinein, eine Folge heraus. Kein Netz, keine
|
||||
/// Einstellungen, keine Ansicht — dieselbe Bauform wie `HeroSelection` und
|
||||
/// `AudioTrackChooser`, und aus demselben Grund: im App-Ziel gibt es kein
|
||||
/// Unit-Test-Ziel, also muss alles, worin eine Entscheidung steckt, hierher.
|
||||
/// „Er hat die falsche Folge gestartet" ist sonst ein Befund, den man nicht
|
||||
/// nachstellen kann.
|
||||
///
|
||||
/// **Die Regel in einem Satz:** die älteste Folge, die noch nicht als gesehen
|
||||
/// gilt; wurde sie angefangen, dort weiter; ist alles gesehen, von vorn.
|
||||
public enum EpisodeSelection {
|
||||
/// Ob die Liste eine ganze Serie ist oder genau eine Staffel.
|
||||
///
|
||||
/// Sie entscheidet nur zweierlei: ob Specials wegfallen und was „von vorn"
|
||||
/// bedeutet. Alles Übrige ist gleich.
|
||||
public enum Scope: Sendable, Equatable {
|
||||
case series
|
||||
case season
|
||||
}
|
||||
|
||||
/// Was gespielt werden soll — und ob an der gemerkten Stelle.
|
||||
public struct Choice: Sendable, Equatable {
|
||||
public let episode: MediaItem
|
||||
/// Ob an der gemerkten Stelle fortgesetzt wird.
|
||||
public let resumes: Bool
|
||||
|
||||
public init(episode: MediaItem, resumes: Bool) {
|
||||
self.episode = episode
|
||||
self.resumes = resumes
|
||||
}
|
||||
}
|
||||
|
||||
/// Ab wo eine angefangene Folge **nicht** mehr fortgesetzt wird.
|
||||
///
|
||||
/// Ein Server, der einen Fortschritt vier Sekunden vor Schluss noch nicht
|
||||
/// gelöscht hat, öffnete den Player sonst mitten im Abspann — und das
|
||||
/// sieht aus wie „er hat die Folge übersprungen".
|
||||
static let resumeCeiling = 0.95
|
||||
|
||||
/// Die Folge, mit der es weitergeht.
|
||||
///
|
||||
/// `nil`, wenn nach dem Aussortieren nichts übrig ist. Dann sagt der
|
||||
/// Aufrufer das, statt lautlos nichts zu tun.
|
||||
public static func nextUp(in items: [MediaItem], scope: Scope) -> Choice? {
|
||||
let sortiert = ordered(items, scope: scope)
|
||||
guard !sortiert.isEmpty else { return nil }
|
||||
|
||||
// **Die älteste ungesehene, nicht die am weitesten fortgeschrittene.**
|
||||
//
|
||||
// Bei S1E1 gesehen, S1E2 unberührt und S1E5 halb gesehen kommt S1E2.
|
||||
// Wer sich eine Folge für später aufgehoben hat, soll sie nicht
|
||||
// dadurch verlieren, dass er irgendwo weiter vorn hineingeschaut hat.
|
||||
guard let offen = sortiert.first(where: { !$0.isWatched }) else {
|
||||
// Alles gesehen: von vorn. Bei einer Serie ist das die erste Folge
|
||||
// der ersten Staffel, bei einer Staffel deren erste — derselbe
|
||||
// Code, weil `scope` längst entschieden hat, was in der Liste
|
||||
// liegt.
|
||||
return Choice(episode: sortiert[0], resumes: false)
|
||||
}
|
||||
|
||||
let angefangen = (offen.watchProgress ?? 0) > 0
|
||||
&& (offen.watchProgress ?? 0) < resumeCeiling
|
||||
return Choice(episode: offen, resumes: angefangen)
|
||||
}
|
||||
|
||||
/// Die Sendereihenfolge — **nie** die des Servers.
|
||||
///
|
||||
/// Jellyfins `children(of:)` sortiert nach `SortName`, und das stellt
|
||||
/// „Folge 10" vor „Folge 2". Sich darauf zu verlassen ginge genau so
|
||||
/// lange gut, bis eine Staffel zehn Folgen hat.
|
||||
static func ordered(_ items: [MediaItem], scope: Scope) -> [MediaItem] {
|
||||
var übrig = items.filter { eintrag in
|
||||
switch eintrag.kind {
|
||||
// Behälter sind keine Folgen. `nil` bleibt dagegen: Jellyfin
|
||||
// liefert in Listen nicht immer eine brauchbare Art, und
|
||||
// Wegwerfen leerte die Liste.
|
||||
case .season, .show, .collection: false
|
||||
default: true
|
||||
}
|
||||
}
|
||||
|
||||
// **Specials nur bei der Serie aussortieren.** Staffel 0 vorzuziehen
|
||||
// wäre falsch — sie steht vor Staffel 1 und ist trotzdem nicht der
|
||||
// Anfang. Wer dagegen die Specials-Kachel angeklickt hat, meint die
|
||||
// Specials.
|
||||
if scope == .series {
|
||||
übrig = übrig.filter { ($0.parentIndex ?? 1) != 0 }
|
||||
}
|
||||
|
||||
// Fehlt eine Nummer, ist das keine Null, sondern „der Server hat
|
||||
// nichts gesagt". Solche Einträge gehören ans Ende, nicht an den
|
||||
// Anfang. Die Kennung zuletzt, damit dieselbe Eingabe dasselbe ergibt
|
||||
// — sonst ist ein Fehler nicht wiederholbar.
|
||||
übrig.sort { links, rechts in
|
||||
let staffelL = links.parentIndex ?? .max
|
||||
let staffelR = rechts.parentIndex ?? .max
|
||||
if staffelL != staffelR { return staffelL < staffelR }
|
||||
|
||||
let folgeL = links.index ?? .max
|
||||
let folgeR = rechts.index ?? .max
|
||||
if folgeL != folgeR { return folgeL < folgeR }
|
||||
|
||||
return links.id < rechts.id
|
||||
}
|
||||
return übrig
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import Foundation
|
||||
|
||||
/// Welche Titel oben gross gezeigt werden — und in welcher Reihenfolge.
|
||||
///
|
||||
/// **Warum es das als eigenen Typ gibt.** Die Auswahl soll sich zufällig
|
||||
/// anfühlen und trotzdem stabil sein: dreimal am Abend dieselbe, am nächsten
|
||||
/// Morgen eine andere. Das ist genau die Art Regel, die man nicht im
|
||||
/// laufenden Betrieb beurteilen kann — „es wechselt zu oft" ist kein Befund,
|
||||
/// den man debuggen könnte. Mit einer Liste hinein und einer heraus schon.
|
||||
///
|
||||
/// **Der Fehler, der es hierher gebracht hat.** Vorher wurde ein Zufallszahl-
|
||||
/// erzeuger einmal je Zeitfenster aufgesetzt und dann Titel für Titel
|
||||
/// abgefragt. Das Los hing damit an der **Position** in der Liste: kam ein
|
||||
/// Titel dazu, verschwand einer, oder änderte sich der Fortschritt, bekamen
|
||||
/// alle dahinter ein anderes Los — und der Aufmacher war nach jedem Neustart
|
||||
/// ein anderer, obwohl das Zeitfenster dasselbe war.
|
||||
///
|
||||
/// Jetzt hängt das Los an der **Kennung** des Titels. Wer dazukommt, stört
|
||||
/// niemanden; wer geht, hinterlässt eine Lücke und sonst nichts.
|
||||
public enum HeroSelection {
|
||||
/// Wie lange dieselbe Auswahl gilt.
|
||||
public static let windowHours: Double = 6
|
||||
|
||||
/// Welche Reihen den Aufmacher speisen dürfen.
|
||||
///
|
||||
/// **Leer heisst alle.** Eine Einstellung, bei der nichts angekreuzt ist,
|
||||
/// soll nicht in einen leeren Aufmacher führen — „ich habe nichts
|
||||
/// gewählt" heisst „mir ist es gleich", nicht „nimm nichts".
|
||||
///
|
||||
/// **Ist etwas gewählt, kommt nur daher etwas.** Reihen, die sich keiner
|
||||
/// Mediathek zuordnen lassen — Weiterschauen, Als Nächstes, Favoriten —
|
||||
/// bleiben dann draussen.
|
||||
///
|
||||
/// Das war zuerst andersherum, und es war die falsche Entscheidung. Die
|
||||
/// Überlegung „Angefangenes soll man nicht verlieren" klingt freundlich,
|
||||
/// führt aber dazu, dass im Aufmacher weiter eine Serie steht, obwohl
|
||||
/// gerade „nur Filme" eingestellt wurde — und niemand bringt das mit der
|
||||
/// Einstellung in Verbindung. Eine Einstellung muss das tun, was sie
|
||||
/// sagt; wer Weiterschauen oben haben will, wählt keine Mediathek aus.
|
||||
public static func rows(
|
||||
_ rows: [MediaRowGroup],
|
||||
limitedTo libraries: Set<String>
|
||||
) -> [MediaRowGroup] {
|
||||
guard !libraries.isEmpty else { return rows }
|
||||
let übrig = rows.filter { row in
|
||||
guard let libraryID = row.libraryID else { return false }
|
||||
return libraries.contains(libraryID)
|
||||
}
|
||||
// **Bleibt nichts übrig, gilt die Auswahl als wirkungslos.**
|
||||
//
|
||||
// Ein Aufmacher, der lautlos verschwindet, ist der schlechtere
|
||||
// Fehler — man sucht ihn überall, nur nicht in einer Einstellung
|
||||
// zwei Bildschirme weiter. Und es gibt Fälle, in denen das kein
|
||||
// Versehen ist, sondern die Datenlage: eine gespeicherte Kennung
|
||||
// nach einem Serverwechsel, oder ein Server, dessen Reihen gar
|
||||
// keine Mediathek nennen.
|
||||
return übrig.isEmpty ? rows : übrig
|
||||
}
|
||||
|
||||
/// Ordnet die Titel, ohne die Reihenfolge fest vorzugeben.
|
||||
///
|
||||
/// **Ungleiche Lose.** Ohne Hintergrundbild ist der Aufmacher eine graue
|
||||
/// Fläche mit Text, und ein Titel ohne eines gehört dort eigentlich nicht
|
||||
/// hin. Ihn auszuschliessen wäre aber zu hart: bei einer Mediathek, in
|
||||
/// der wenige Titel eines tragen, blieben immer dieselben drei übrig.
|
||||
/// Also bekommt er ein schlechteres Los statt gar keines.
|
||||
public static func ordered(
|
||||
_ items: [MediaItem],
|
||||
at time: Date = .now,
|
||||
windowHours: Double = windowHours
|
||||
) -> [MediaItem] {
|
||||
let fenster = UInt64(max(0, time.timeIntervalSince1970 / (windowHours * 3_600)))
|
||||
|
||||
var bewertet: [(item: MediaItem, los: Double)] = []
|
||||
bewertet.reserveCapacity(items.count)
|
||||
for item in items {
|
||||
bewertet.append((item, lot(for: item, window: fenster)))
|
||||
}
|
||||
|
||||
// Bei gleichem Los die Kennung: zweimal dieselbe Eingabe soll zweimal
|
||||
// dasselbe ergeben, sonst ist ein Fehler nicht wiederholbar.
|
||||
bewertet.sort { links, rechts in
|
||||
links.los == rechts.los ? links.item.id < rechts.item.id : links.los > rechts.los
|
||||
}
|
||||
return bewertet.map(\.item)
|
||||
}
|
||||
|
||||
/// Das Los eines Titels in diesem Zeitfenster — zwischen 0 und 1.
|
||||
static func lot(for item: MediaItem, window: UInt64) -> Double {
|
||||
let hatBild = item.art != nil || item.seriesArt != nil
|
||||
let gewicht = hatBild ? 1.0 : 0.2
|
||||
return unitRandom(seed: stableHash(item.id) ^ window) * gewicht
|
||||
}
|
||||
|
||||
/// Ein Streuwert, der über Programmläufe hinweg gleich bleibt.
|
||||
///
|
||||
/// **Nicht `hashValue`.** Swift streut Hashes je Programmlauf zufällig;
|
||||
/// damit wäre die Auswahl nach jedem Start eine andere — also genau der
|
||||
/// Fehler, der hier behoben werden soll, nur an einer anderen Stelle.
|
||||
static func stableHash(_ text: String) -> UInt64 {
|
||||
// FNV-1a: vier Zeilen, keine Abhängigkeit, und für das Mischen von
|
||||
// ein paar Kacheln mehr als gut genug.
|
||||
var hash: UInt64 = 0xCBF2_9CE4_8422_2325
|
||||
for byte in text.utf8 {
|
||||
hash ^= UInt64(byte)
|
||||
hash = hash &* 0x0000_0100_0000_01B3
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
/// SplitMix64, auf 0..<1 gebracht.
|
||||
static func unitRandom(seed: UInt64) -> Double {
|
||||
var z = seed &+ 0x9E37_79B9_7F4A_7C15
|
||||
z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
||||
z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
|
||||
z = z ^ (z >> 31)
|
||||
// Die oberen 53 Bit: genau die, die ein `Double` verlustfrei trägt.
|
||||
return Double(z >> 11) / Double(1 << 53)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// Was zuletzt in einer Mediathek stand — auf der Platte, für den nächsten
|
||||
/// Aufruf.
|
||||
///
|
||||
/// **Warum.** Jeder Aufruf einer Mediathek war bisher ein Serverabruf, und
|
||||
/// bis er zurückkam, stand da ein Ladekreis. Die Plakate selbst liegen längst
|
||||
/// im Zwischenspeicher ([ArtworkLoader]); was fehlte, war die **Liste** —
|
||||
/// Namen, Kennungen, Fortschritt, die Pfade zu genau diesen Plakaten. Ohne
|
||||
/// sie ist der Bildspeicher wertlos: man weiß nicht, welche Bilder man zeigen
|
||||
/// soll.
|
||||
///
|
||||
/// **Was er ausdrücklich nicht ist.** Keine zweite Wahrheit. Der Server bleibt
|
||||
/// maßgeblich; das hier ist ein Abzug des letzten Standes, der sofort gezeigt
|
||||
/// und stillschweigend ersetzt wird, sobald der Server geantwortet hat. Nur
|
||||
/// wenn der Server **nicht** antwortet, bleibt der Abzug stehen — dann ist
|
||||
/// eine Woche alte Liste besser als ein Fehlerbildschirm.
|
||||
///
|
||||
/// **Nur die erste Seite.** Nicht aus Sparsamkeit, sondern weil alles andere
|
||||
/// nicht zusammenpasst: stünden 240 gespeicherte Titel da und der Server
|
||||
/// antwortet mit den ersten 60, müsste entweder die Liste vor den Augen
|
||||
/// schrumpfen oder der Versatz fürs Nachladen wäre falsch. Die erste Seite
|
||||
/// deckt genau das ab, was beim Aufrufen zu sehen ist; alles Weitere lädt
|
||||
/// nach wie vor nach, und die Plakate dafür liegen ohnehin schon da.
|
||||
public actor LibraryCache {
|
||||
public static let shared = LibraryCache()
|
||||
|
||||
/// Der gespeicherte Stand einer Mediathek.
|
||||
public struct Snapshot: Codable, Sendable {
|
||||
/// Zu welcher Abfrage er gehört.
|
||||
///
|
||||
/// Eine nach Erscheinungsjahr sortierte Liste ist für eine
|
||||
/// alphabetische Ansicht wertlos, und eine gefilterte erst recht.
|
||||
/// Passt der Schlüssel nicht, gilt der Stand als nicht vorhanden.
|
||||
public let queryKey: String
|
||||
public let items: [MediaItem]
|
||||
public let totalCount: Int
|
||||
public let savedAt: Date
|
||||
|
||||
public init(queryKey: String, items: [MediaItem], totalCount: Int, savedAt: Date = .now) {
|
||||
self.queryKey = queryKey
|
||||
self.items = items
|
||||
self.totalCount = totalCount
|
||||
self.savedAt = savedAt
|
||||
}
|
||||
}
|
||||
|
||||
private let root: URL
|
||||
private let logger = Logger(subsystem: "app.halo", category: "LibraryCache")
|
||||
|
||||
/// Wie viele Titel höchstens gespeichert werden.
|
||||
///
|
||||
/// Eine Seite. Siehe oben, warum nicht mehr.
|
||||
public static let pageLimit = 60
|
||||
|
||||
init(root: URL? = nil) {
|
||||
self.root = root ?? FileManager.default
|
||||
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
.appending(path: "LibraryCache", directoryHint: .isDirectory)
|
||||
}
|
||||
|
||||
// MARK: - Lesen und Schreiben
|
||||
|
||||
public func snapshot(server: String, library: String, queryKey: String) -> Snapshot? {
|
||||
guard let daten = try? Data(contentsOf: file(server: server, library: library)),
|
||||
let stand = try? JSONDecoder().decode(Snapshot.self, from: daten)
|
||||
else { return nil }
|
||||
// Ein Stand zu einer anderen Sortierung ist kein Stand.
|
||||
return stand.queryKey == queryKey ? stand : nil
|
||||
}
|
||||
|
||||
public func store(_ snapshot: Snapshot, server: String, library: String) {
|
||||
let gekürzt = Snapshot(
|
||||
queryKey: snapshot.queryKey,
|
||||
items: snapshot.items.prefix(Self.pageLimit).map(\.listEssentials),
|
||||
totalCount: snapshot.totalCount,
|
||||
savedAt: snapshot.savedAt
|
||||
)
|
||||
let ziel = file(server: server, library: library)
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: ziel.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
try JSONEncoder().encode(gekürzt).write(to: ziel, options: .atomic)
|
||||
} catch {
|
||||
// Ein misslungener Abzug ist kein Grund, irgendetwas abzubrechen —
|
||||
// beim nächsten Mal wird eben geladen wie bisher.
|
||||
logger.warning("Abzug nicht gespeichert: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Liste der Mediatheken selbst.
|
||||
///
|
||||
/// Sie kommt beim Start, noch vor allem anderen, und ohne sie steht die
|
||||
/// Seitenleiste leer.
|
||||
public func libraries(server: String) -> [MediaLibrary]? {
|
||||
guard let daten = try? Data(contentsOf: librariesFile(server: server)) else { return nil }
|
||||
return try? JSONDecoder().decode([MediaLibrary].self, from: daten)
|
||||
}
|
||||
|
||||
public func storeLibraries(_ libraries: [MediaLibrary], server: String) {
|
||||
let ziel = librariesFile(server: server)
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: ziel.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
try JSONEncoder().encode(libraries).write(to: ziel, options: .atomic)
|
||||
} catch {
|
||||
logger.warning("Mediathekenliste nicht gespeichert: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Aufräumen
|
||||
|
||||
/// Alles zu einem Server vergessen — beim Abmelden.
|
||||
///
|
||||
/// Was einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
|
||||
/// verloren: Titel, Zusammenfassungen und Sehfortschritt sind persönlich.
|
||||
public func clear(server: String) {
|
||||
try? FileManager.default.removeItem(at: directory(server: server))
|
||||
}
|
||||
|
||||
public func clearAll() {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
}
|
||||
|
||||
// MARK: - Wo was liegt
|
||||
|
||||
private func directory(server: String) -> URL {
|
||||
root.appending(path: Self.safeName(server), directoryHint: .isDirectory)
|
||||
}
|
||||
|
||||
private func file(server: String, library: String) -> URL {
|
||||
directory(server: server).appending(path: "\(Self.safeName(library)).json")
|
||||
}
|
||||
|
||||
private func librariesFile(server: String) -> URL {
|
||||
directory(server: server).appending(path: "libraries.json")
|
||||
}
|
||||
|
||||
/// Ein Name, der als Datei taugt.
|
||||
///
|
||||
/// Kennungen kommen vom Server: bei Jellyfin GUIDs, bei Plex Zahlen, und
|
||||
/// die Serverkennung enthält einen Adressbestandteil mit Schrägstrichen.
|
||||
/// Base64 ist umkehrbar und braucht keine Tabelle verbotener Zeichen — die
|
||||
/// beiden Zeichen, die in Dateinamen stören, werden getauscht.
|
||||
static func safeName(_ raw: String) -> String {
|
||||
Data(raw.utf8).base64EncodedString()
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
public extension MediaQuery {
|
||||
/// Ein Schlüssel, der sich zwei Starts lang gleich liest.
|
||||
///
|
||||
/// **Nicht `hashValue`.** Swift streut Hashes je Programmlauf zufällig;
|
||||
/// ein damit benannter Abzug wäre nach jedem Neustart unauffindbar — der
|
||||
/// Cache träfe nie, ohne dass irgendetwas kaputt aussähe.
|
||||
var cacheKey: String {
|
||||
[
|
||||
sort.rawValue,
|
||||
unwatchedOnly ? "u" : "",
|
||||
favouritesOnly ? "f" : "",
|
||||
genre ?? "",
|
||||
year.map(String.init) ?? "",
|
||||
contentRating ?? "",
|
||||
resolution ?? "",
|
||||
searchText ?? "",
|
||||
].joined(separator: "|")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Was ein Sender ist
|
||||
|
||||
/// Der Griff, mit dem eine Implementierung ihren Sender wiederfindet.
|
||||
///
|
||||
/// Undurchsichtig mit Absicht. Bei Plex stecken darin die Schlüssel, die der
|
||||
/// Suchlauf gefunden hat — Rasterschlüssel, Sendernummer am Empfänger, der
|
||||
/// Empfänger selbst. Bei Jellyfin genügt die Kennung, und er bleibt leer.
|
||||
///
|
||||
/// Er wird **weitergereicht, nicht gelesen**. Wer einen Sender anzeigt, geht
|
||||
/// sein Inhalt nichts an; wer ihn einschaltet, hat ihn selbst gefüllt.
|
||||
///
|
||||
/// Der Umweg ist nötig, weil beide Backends Werttypen sind und sich zwischen
|
||||
/// zwei Aufrufen nichts merken können. Ohne ihn müsste das Einschalten die
|
||||
/// Senderliste neu laden — bei Plex hieße das, den ganzen Suchlauf noch
|
||||
/// einmal zu gehen.
|
||||
public struct LiveTuningHandle: Sendable, Equatable, Hashable {
|
||||
private let fields: [String: String]
|
||||
|
||||
public init(_ fields: [String: String] = [:]) {
|
||||
self.fields = fields
|
||||
}
|
||||
|
||||
public subscript(_ key: String) -> String? { fields[key] }
|
||||
|
||||
public var isEmpty: Bool { fields.isEmpty }
|
||||
}
|
||||
|
||||
/// Ein Fernsehsender.
|
||||
public struct LiveChannel: Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
/// Die Nummer, unter der er im Programm steht — „3" oder „11.1".
|
||||
///
|
||||
/// Optional, weil nicht jeder Server sie führt. Steht sie, gehört sie vor
|
||||
/// den Namen: danach sucht man.
|
||||
public let number: String?
|
||||
public let title: String
|
||||
public let artworkPath: String?
|
||||
/// Was gerade läuft, sofern der Server es gleich mitliefert.
|
||||
///
|
||||
/// Jellyfin tut das auf Wunsch (`addCurrentProgram`), und dann spart es
|
||||
/// einen zweiten Aufruf für die Senderliste.
|
||||
public let currentProgram: LiveProgram?
|
||||
public let tuning: LiveTuningHandle
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
number: String? = nil,
|
||||
title: String,
|
||||
artworkPath: String? = nil,
|
||||
currentProgram: LiveProgram? = nil,
|
||||
tuning: LiveTuningHandle = LiveTuningHandle()
|
||||
) {
|
||||
self.id = id
|
||||
self.number = number
|
||||
self.title = title
|
||||
self.artworkPath = artworkPath
|
||||
self.currentProgram = currentProgram
|
||||
self.tuning = tuning
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Was eine Sendung ist
|
||||
|
||||
/// Eine Sendung im Programm.
|
||||
public struct LiveProgram: Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
public let channelID: String
|
||||
public let title: String
|
||||
/// Der Folgentitel, wenn es einer ist.
|
||||
public let subtitle: String?
|
||||
public let summary: String?
|
||||
public let startsAt: Date
|
||||
public let endsAt: Date
|
||||
public let artworkPath: String?
|
||||
/// Ob sie zu einem laufenden Serien-Timer gehört.
|
||||
///
|
||||
/// Steht sie, ist die Sendung bereits zur Aufnahme vorgemerkt, und die
|
||||
/// Oberfläche soll „Aufnehmen" nicht noch einmal anbieten.
|
||||
public let seriesTimerID: String?
|
||||
public let kind: Kind
|
||||
|
||||
/// Wovon eine Sendung handelt — soweit der Server es sagt.
|
||||
///
|
||||
/// Als Menge und nicht als einzelner Wert: eine Sportübertragung kann
|
||||
/// zugleich eine Live-Sendung sein, und Jellyfin meldet beide Kennzeichen
|
||||
/// nebeneinander.
|
||||
public struct Kind: OptionSet, Sendable, Equatable, Hashable {
|
||||
public let rawValue: Int
|
||||
public init(rawValue: Int) { self.rawValue = rawValue }
|
||||
|
||||
public static let movie = Kind(rawValue: 1 << 0)
|
||||
public static let series = Kind(rawValue: 1 << 1)
|
||||
public static let news = Kind(rawValue: 1 << 2)
|
||||
public static let sports = Kind(rawValue: 1 << 3)
|
||||
public static let kids = Kind(rawValue: 1 << 4)
|
||||
public static let live = Kind(rawValue: 1 << 5)
|
||||
}
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
channelID: String,
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
summary: String? = nil,
|
||||
startsAt: Date,
|
||||
endsAt: Date,
|
||||
artworkPath: String? = nil,
|
||||
seriesTimerID: String? = nil,
|
||||
kind: Kind = []
|
||||
) {
|
||||
self.id = id
|
||||
self.channelID = channelID
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.summary = summary
|
||||
self.startsAt = startsAt
|
||||
self.endsAt = endsAt
|
||||
self.artworkPath = artworkPath
|
||||
self.seriesTimerID = seriesTimerID
|
||||
self.kind = kind
|
||||
}
|
||||
|
||||
/// Ob sie zu diesem Zeitpunkt läuft.
|
||||
public func isRunning(at moment: Date = .now) -> Bool {
|
||||
startsAt <= moment && endsAt > moment
|
||||
}
|
||||
|
||||
/// Wie weit sie fortgeschritten ist, 0…1.
|
||||
///
|
||||
/// Gerechnet statt gemeldet: beide Server nennen Anfang und Ende, keiner
|
||||
/// nennt den Fortschritt. Beim Umschalten ist er die nützlichste Angabe
|
||||
/// überhaupt — er sagt, ob man noch einsteigt oder nur noch den Abspann
|
||||
/// sieht.
|
||||
public func progress(at moment: Date = .now) -> Double {
|
||||
let total = endsAt.timeIntervalSince(startsAt)
|
||||
guard total > 0 else { return 0 }
|
||||
return min(max(moment.timeIntervalSince(startsAt) / total, 0), 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ob es überhaupt etwas zu sehen gibt
|
||||
|
||||
/// Was beim Live-TV herauskommen kann.
|
||||
public enum LiveTVAvailability: Sendable, Equatable {
|
||||
/// Es gibt einen Empfänger und Sender.
|
||||
///
|
||||
/// Ohne Angabe, **welchen** Empfänger: das ist Sache der Implementierung.
|
||||
/// Vorher stand hier die Liste der Plex-Empfänger, und damit trug die
|
||||
/// neutrale Form einen fremden Typ durch die halbe App.
|
||||
case available
|
||||
/// Der Server läuft, hat aber keinen Empfänger eingerichtet.
|
||||
///
|
||||
/// Der mit Abstand häufigste Fall — Live-TV verlangt eine Fernsehkarte
|
||||
/// oder einen Netzwerk-Tuner. Die Oberfläche soll das erklären, statt
|
||||
/// einen Fehler zu zeigen.
|
||||
case notConfigured
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
/// Eine abgeklopfte Adresse mit ihrer Antwort.
|
||||
///
|
||||
/// Nur Plex füllt das: dort wird die Senderliste gesucht, weil ihre Adresse
|
||||
/// sich zwischen Serverfassungen unterscheidet. Jellyfin hat einen festen
|
||||
/// Endpunkt und liefert nichts — die Oberfläche zeigt den Block dann gar
|
||||
/// nicht erst.
|
||||
public struct LiveChannelProbe: Sendable, Equatable, Identifiable {
|
||||
public let path: String
|
||||
public let result: String
|
||||
|
||||
public var id: String { path }
|
||||
|
||||
public init(path: String, result: String) {
|
||||
self.path = path
|
||||
self.result = result
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Senderliste samt dem, was ihr Zustandekommen erklärt.
|
||||
public struct LiveChannelListing: Sendable, Equatable {
|
||||
public let channels: [LiveChannel]
|
||||
/// Woher sie kam. `nil`, wo es nichts zu erklären gibt.
|
||||
public let source: String?
|
||||
/// Jeder versuchte Weg. Leer, wo nicht gesucht werden musste.
|
||||
public let probes: [LiveChannelProbe]
|
||||
|
||||
public init(channels: [LiveChannel], source: String? = nil, probes: [LiveChannelProbe] = []) {
|
||||
self.channels = channels
|
||||
self.source = source
|
||||
self.probes = probes
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Einschalten
|
||||
|
||||
/// Was beim Einschalten herauskam.
|
||||
public struct LiveTuneResult: Sendable {
|
||||
public let url: URL?
|
||||
public let sessionID: String
|
||||
/// Womit der Empfänger wieder freigegeben wird.
|
||||
///
|
||||
/// Ohne dieses Signal bleibt ein Tuner belegt, bis der Server von selbst
|
||||
/// merkt, dass niemand zusieht — und vier Stück gibt es davon.
|
||||
public let subscriptionKey: String?
|
||||
/// Jeder versuchte Weg mit seiner Antwort.
|
||||
public let trace: [LiveChannelProbe]
|
||||
|
||||
public init(
|
||||
url: URL?,
|
||||
sessionID: String,
|
||||
subscriptionKey: String? = nil,
|
||||
trace: [LiveChannelProbe] = []
|
||||
) {
|
||||
self.url = url
|
||||
self.sessionID = sessionID
|
||||
self.subscriptionKey = subscriptionKey
|
||||
self.trace = trace
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Aufnehmen
|
||||
|
||||
/// Eine fertige Aufnahme auf dem Server.
|
||||
public struct LiveRecording: Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let channelName: String?
|
||||
public let summary: String?
|
||||
public let startsAt: Date?
|
||||
public let endsAt: Date?
|
||||
public let artworkPath: String?
|
||||
/// Ob gerade noch aufgenommen wird.
|
||||
public let isInProgress: Bool
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
title: String,
|
||||
channelName: String? = nil,
|
||||
summary: String? = nil,
|
||||
startsAt: Date? = nil,
|
||||
endsAt: Date? = nil,
|
||||
artworkPath: String? = nil,
|
||||
isInProgress: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.channelName = channelName
|
||||
self.summary = summary
|
||||
self.startsAt = startsAt
|
||||
self.endsAt = endsAt
|
||||
self.artworkPath = artworkPath
|
||||
self.isInProgress = isInProgress
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine vorgemerkte Aufnahme.
|
||||
public struct LiveTimer: Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
public let programID: String?
|
||||
public let channelID: String?
|
||||
public let channelName: String?
|
||||
public let title: String
|
||||
public let startsAt: Date?
|
||||
public let endsAt: Date?
|
||||
/// Ob sie zu einem Serien-Timer gehört.
|
||||
public let seriesTimerID: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
programID: String? = nil,
|
||||
channelID: String? = nil,
|
||||
channelName: String? = nil,
|
||||
title: String,
|
||||
startsAt: Date? = nil,
|
||||
endsAt: Date? = nil,
|
||||
seriesTimerID: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.programID = programID
|
||||
self.channelID = channelID
|
||||
self.channelName = channelName
|
||||
self.title = title
|
||||
self.startsAt = startsAt
|
||||
self.endsAt = endsAt
|
||||
self.seriesTimerID = seriesTimerID
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine stehende Aufnahme für alles, was zu einer Serie gehört.
|
||||
public struct LiveSeriesTimer: Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let channelID: String?
|
||||
public let channelName: String?
|
||||
/// Ob nur Erstausstrahlungen aufgenommen werden.
|
||||
public let recordsNewOnly: Bool
|
||||
/// Ob auf allen Sendern aufgenommen wird, nicht nur auf einem.
|
||||
public let recordsAnyChannel: Bool
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
channelID: String? = nil,
|
||||
channelName: String? = nil,
|
||||
recordsNewOnly: Bool = false,
|
||||
recordsAnyChannel: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.channelID = channelID
|
||||
self.channelName = channelName
|
||||
self.recordsNewOnly = recordsNewOnly
|
||||
self.recordsAnyChannel = recordsAnyChannel
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Die Verträge
|
||||
|
||||
/// Was ein Server können muss, damit Live-TV funktioniert.
|
||||
///
|
||||
/// Bewusst **außerhalb** von `MediaBackend`: nicht jeder Server kann das, und
|
||||
/// ein Vertrag, den die Hälfte der Erfüller nur mit Ausnahmen erfüllt, ist
|
||||
/// keiner. Wer Live-TV will, fragt mit `as?` danach.
|
||||
public protocol LiveTVBackend: Sendable {
|
||||
/// Ob es einen Empfänger gibt.
|
||||
///
|
||||
/// Wirft nicht: „kein Empfänger eingerichtet" ist kein Fehler, sondern
|
||||
/// eine Antwort, und die Oberfläche soll sie erklären können.
|
||||
func liveTVAvailability() async -> LiveTVAvailability
|
||||
|
||||
/// Die Sender — und, wo gesucht werden musste, wie sie gefunden wurden.
|
||||
func liveChannels() async -> LiveChannelListing
|
||||
|
||||
/// Das Programm für die genannten Sender in einem Zeitraum.
|
||||
///
|
||||
/// Nach Senderkennung gebündelt, weil das Raster genau so danach fragt.
|
||||
func livePrograms(
|
||||
channelIDs: [String],
|
||||
from: Date,
|
||||
to: Date
|
||||
) async throws(MediaBackendError) -> [String: [LiveProgram]]
|
||||
|
||||
/// Schaltet ein.
|
||||
///
|
||||
/// Wirft nicht, sondern berichtet: scheitert es, ist die Spur der einzige
|
||||
/// Weg zu sehen, woran. Genau daran hing beim Live-TV tagelang alles.
|
||||
func tune(
|
||||
channel: LiveChannel,
|
||||
program: LiveProgram?,
|
||||
maximumBitrate: Int?
|
||||
) async -> LiveTuneResult
|
||||
|
||||
/// Gibt den Empfänger wieder frei.
|
||||
func endLiveSession(subscriptionKey: String) async
|
||||
}
|
||||
|
||||
/// Was ein Server darüber hinaus können muss, damit Aufnehmen funktioniert.
|
||||
///
|
||||
/// Getrennt vom Rest, weil Plex das eine kann und das andere nicht. Ein
|
||||
/// `as? LiveTVRecordingBackend` sagt die Wahrheit; eine Methode, die bei Plex
|
||||
/// nur wirft, verspricht etwas und bricht es dann.
|
||||
public protocol LiveTVRecordingBackend: Sendable {
|
||||
func recordings() async throws(MediaBackendError) -> [LiveRecording]
|
||||
func deleteRecording(id: String) async throws(MediaBackendError)
|
||||
|
||||
func timers() async throws(MediaBackendError) -> [LiveTimer]
|
||||
func scheduleTimer(for program: LiveProgram) async throws(MediaBackendError)
|
||||
func cancelTimer(id: String) async throws(MediaBackendError)
|
||||
|
||||
func seriesTimers() async throws(MediaBackendError) -> [LiveSeriesTimer]
|
||||
func scheduleSeriesTimer(for program: LiveProgram) async throws(MediaBackendError)
|
||||
func cancelSeriesTimer(id: String) async throws(MediaBackendError)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import Foundation
|
||||
|
||||
/// Was ein Server können muss, damit die App mit ihm arbeiten kann.
|
||||
///
|
||||
/// Der Vertrag ist bewusst schmal. Alles, was nur ein Server kann — Plex' PIN-
|
||||
/// Anmeldung, Jellyfins Verwaltung, die Eigenheiten des Programmführers —
|
||||
/// steht **nicht** hier, sondern als eigene Fähigkeit. Ein gemeinsamer Nenner,
|
||||
/// der alles umfasst, wäre kein Nenner mehr.
|
||||
public protocol MediaBackend: Sendable {
|
||||
/// Wie der Server heißt, so wie er sich selbst nennt.
|
||||
nonisolated var serverName: String { get }
|
||||
/// Wer dieser Server ist — für alles, was ihn über einen Start hinaus
|
||||
/// wiedererkennen muss.
|
||||
///
|
||||
/// **Nicht der Name.** Zwei Server dürfen „Medien" heißen, und derselbe
|
||||
/// Server darf umbenannt werden. Was hier steht, ist so gewählt, dass es
|
||||
/// beides überlebt und beim nächsten Start noch gleich lautet. Es ist
|
||||
/// eine Kennung und geht nirgends an die Oberfläche.
|
||||
nonisolated var identity: String { get }
|
||||
/// Was dieser Server darüber hinaus beherrscht.
|
||||
nonisolated var capabilities: Set<BackendCapability> { get }
|
||||
|
||||
// MARK: - Blättern
|
||||
|
||||
func libraries() async throws(MediaBackendError) -> [MediaLibrary]
|
||||
func homeRows() async throws(MediaBackendError) -> [MediaRowGroup]
|
||||
func items(
|
||||
inLibrary library: MediaLibrary,
|
||||
query: MediaQuery,
|
||||
offset: Int,
|
||||
limit: Int
|
||||
) async throws(MediaBackendError) -> MediaPage
|
||||
func item(id: String) async throws(MediaBackendError) -> MediaItem
|
||||
/// In welcher Mediathek ein Titel liegt.
|
||||
///
|
||||
/// **Warum das eine Frage ist.** Ein Titel weiss es nicht von selbst: er
|
||||
/// kennt seinen unmittelbaren Vorgesetzten — bei einer Folge die Staffel,
|
||||
/// bei einem Film die Mediathek — und mehr nicht. Wer die Mediathek einer
|
||||
/// Folge wissen will, muss die Kette hinaufgehen, und das ist eine
|
||||
/// Serveranfrage.
|
||||
///
|
||||
/// Deshalb steht sie hier als eigene Frage und nicht als Feld an
|
||||
/// `MediaItem`: sie wird selten gebraucht, kostet etwas, und ein Feld,
|
||||
/// das meistens `nil` ist, verschweigt genau das.
|
||||
///
|
||||
/// `nil`, wenn der Server es nicht sagen kann — dann fällt die Ebene weg,
|
||||
/// statt zu raten.
|
||||
func libraryID(for item: MediaItem) async -> String?
|
||||
func children(of id: String) async throws(MediaBackendError) -> [MediaItem]
|
||||
func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup]
|
||||
/// Die möglichen Werte eines Filters, etwa alle Genres einer Mediathek.
|
||||
func filterOptions(
|
||||
inLibrary library: MediaLibrary,
|
||||
field: MediaQuery.Field
|
||||
) async throws(MediaBackendError) -> [MediaFilterOption]
|
||||
/// Alle Folgen einer Serie, über alle Staffeln hinweg.
|
||||
///
|
||||
/// **Als eigene Frage, weil der allgemeine Weg zu teuer ist.** Staffeln
|
||||
/// holen und dann jede Staffel einzeln kostet bei zwölf Staffeln
|
||||
/// dreizehn Anfragen — für einen Klick auf eine Kachel ist das zu viel.
|
||||
/// Ein Server, der es in einer Anfrage kann, soll das dürfen.
|
||||
func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem]
|
||||
/// Die nächste Folge nach dieser, sofern es eine gibt.
|
||||
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
||||
|
||||
// MARK: - Bilder
|
||||
|
||||
/// Ein Bild in der gewünschten Größe.
|
||||
///
|
||||
/// `nonisolated`, weil die Adresse ohne Netzzugriff entsteht und der
|
||||
/// Systemplayer sie ohne Kopfzeilen abruft — der Token steckt deshalb in
|
||||
/// der Adresse selbst.
|
||||
nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL?
|
||||
nonisolated func rawImageURL(path: String?) -> URL?
|
||||
|
||||
// MARK: - Herunterladen
|
||||
|
||||
/// Die Adresse, unter der eine Datei unverändert zu holen ist.
|
||||
nonisolated func downloadURL(for file: MediaFile) -> URL?
|
||||
|
||||
/// Die Adresse für eine verkleinerte Fassung.
|
||||
///
|
||||
/// `nil`, wenn der Server das nicht anbietet — dann bleibt nur das
|
||||
/// Original, und die Oberfläche soll die Wahl gar nicht erst zeigen.
|
||||
nonisolated func compressedDownloadURL(
|
||||
itemID: String,
|
||||
versionIndex: Int,
|
||||
maximumBitrate: Int,
|
||||
resolution: String?
|
||||
) -> URL?
|
||||
|
||||
/// Die Adresse für dieselbe Datei in einer **anderen Schachtel**.
|
||||
///
|
||||
/// Gemeint ist Umpacken, nicht Umrechnen: dieselben Ströme, eine Hülle,
|
||||
/// die der Systemplayer öffnen kann. Nötig für Dateien, die offline sonst
|
||||
/// unspielbar wären — auf dem Gerät gibt es keinen Server mehr, der
|
||||
/// nachträglich helfen könnte.
|
||||
///
|
||||
/// `nil`, wenn der Server das nicht anbietet. Dann bleibt das Original,
|
||||
/// und der Titel ist offline nur so gut wie der Systemplayer.
|
||||
///
|
||||
/// **Mit Spurnummern, weil beim Umpacken eine Wahl ansteht.** Der Server
|
||||
/// legt in die neue Hülle genau eine Tonspur und höchstens einen
|
||||
/// Untertitel. Welche, entscheidet der Aufrufer — offline lässt sich das
|
||||
/// nicht nachholen.
|
||||
nonisolated func remuxDownloadURL(
|
||||
itemID: String,
|
||||
versionIndex: Int,
|
||||
audioStreamIndex: Int?,
|
||||
subtitleStreamIndex: Int?
|
||||
) -> URL?
|
||||
|
||||
// MARK: - Abspielen
|
||||
|
||||
/// Lässt den Server eine Wiedergabe vorbereiten und nennt die Adresse.
|
||||
///
|
||||
/// Wirft mit der Begründung des Servers, wenn er ablehnt. Genau daran hing
|
||||
/// beim Live-TV tagelang alles: der Server hatte im Klartext gesagt, warum
|
||||
/// er nicht mitspielt, und die Antwort wurde weggeworfen.
|
||||
func preparePlayback(_ intent: PlaybackIntent) async throws(MediaBackendError) -> PlaybackTarget
|
||||
|
||||
/// Beendet, was der Server für diese Wiedergabe angelegt hat.
|
||||
///
|
||||
/// Ohne dieses Signal rechnet ein Transcoder minutenlang weiter, obwohl
|
||||
/// niemand mehr zusieht.
|
||||
func endPlayback(sessionID: String) async
|
||||
|
||||
/// Meldet, wo man gerade steht.
|
||||
///
|
||||
/// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
||||
/// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
||||
/// wäre die schlechtere Hälfte des Tauschs.
|
||||
func reportProgress(_ report: PlaybackReport) async
|
||||
|
||||
// MARK: - Zustand melden
|
||||
|
||||
func markWatched(id: String) async throws(MediaBackendError)
|
||||
func markUnwatched(id: String) async throws(MediaBackendError)
|
||||
/// Nimmt einen Titel aus „Weiterschauen", ohne ihn als gesehen zu melden.
|
||||
///
|
||||
/// **Der Fall, für den es sonst nichts gibt.** Nach einer halben Stunde
|
||||
/// keine Lust mehr — der Film ist weder gesehen noch ungesehen, er soll
|
||||
/// nur nicht ewig weiter oben stehen. „Als gesehen markieren" wäre dafür
|
||||
/// eine Lüge, „als ungesehen" tut nichts: der Fortschritt bleibt.
|
||||
///
|
||||
/// **Der ganze Titel, nicht nur seine Kennung.** Jellyfin kennt dafür
|
||||
/// keinen eigenen Weg; dort wird das Benutzerdatenblatt geschrieben, und
|
||||
/// darin stehen auch „gesehen" und „Favorit". Wer sie nicht mitschickt,
|
||||
/// löscht sie — und der Aufrufer hat sie ohnehin zur Hand.
|
||||
func removeFromContinueWatching(_ item: MediaItem) async throws(MediaBackendError)
|
||||
func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError)
|
||||
func rate(id: String, rating: Int) async throws(MediaBackendError)
|
||||
func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError)
|
||||
}
|
||||
|
||||
/// Was ein Server über das Nötigste hinaus kann.
|
||||
///
|
||||
/// Abgefragt statt vorausgesetzt: Plex kennt keine Favoriten, Jellyfin keine
|
||||
/// Plex-Empfänger, und eine Oberfläche, die Knöpfe für Dinge zeigt, die der
|
||||
/// Server nicht kann, ist schlimmer als eine, die sie weglässt.
|
||||
public enum BackendCapability: String, Sendable, Hashable {
|
||||
case liveTV
|
||||
/// Aufnehmen und Vormerken — nicht dasselbe wie `liveTV`.
|
||||
///
|
||||
/// Plex kann Sender zeigen und einschalten, aber in dieser App nichts
|
||||
/// aufnehmen; Jellyfin kann beides. Eine Fähigkeit für beides zusammen
|
||||
/// hieße, entweder Plex Live-TV wegzunehmen oder einen Knopf zu zeigen,
|
||||
/// der dort nichts tut.
|
||||
case liveTVRecordings
|
||||
case favourites
|
||||
case ratings
|
||||
case libraryScan
|
||||
/// Die Verwaltung des Servers aus der App heraus.
|
||||
case administration
|
||||
case transcoding
|
||||
}
|
||||
|
||||
/// Ein Ausschnitt aus einer Mediathek.
|
||||
///
|
||||
/// „Seite" ist hier eine technische Portion, **keine Seite in der Oberfläche**.
|
||||
/// Die Mediathek ist eine einzige lange Liste, die nachlädt, sobald man sich
|
||||
/// dem Ende nähert — Blättern mit „weiter zu Seite 2" gehört nicht in eine
|
||||
/// Mediathek, in der man stöbert.
|
||||
public struct MediaPage: Sendable, Equatable {
|
||||
public let items: [MediaItem]
|
||||
/// Ab welcher Stelle dieser Ausschnitt beginnt.
|
||||
public let offset: Int
|
||||
/// Wie viele es insgesamt gibt — Grundlage fürs Nachladen.
|
||||
public let totalCount: Int
|
||||
|
||||
public init(items: [MediaItem], offset: Int = 0, totalCount: Int) {
|
||||
self.items = items
|
||||
self.offset = offset
|
||||
self.totalCount = totalCount
|
||||
}
|
||||
|
||||
public var hasMore: Bool { offset + items.count < totalCount }
|
||||
public var nextOffset: Int { offset + items.count }
|
||||
}
|
||||
|
||||
public extension MediaBackend {
|
||||
/// Vorgabe: der Server kann es nicht.
|
||||
///
|
||||
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
|
||||
/// gibt — und wer es anbietet, sagt es ausdrücklich.
|
||||
///
|
||||
/// **Mit Spurnummern, weil beim Umpacken eine Wahl ansteht.** Der Server
|
||||
/// legt in die neue Hülle genau eine Tonspur und höchstens einen
|
||||
/// Untertitel. Welche, entscheidet der Aufrufer — offline lässt sich das
|
||||
/// nicht nachholen.
|
||||
nonisolated func remuxDownloadURL(
|
||||
itemID: String,
|
||||
versionIndex: Int,
|
||||
audioStreamIndex: Int?,
|
||||
subtitleStreamIndex: Int?
|
||||
) -> URL? { nil }
|
||||
|
||||
/// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen.
|
||||
///
|
||||
/// Langsam, aber überall gültig — wer es besser kann, überschreibt es.
|
||||
///
|
||||
/// **Ohne Sortieren und ohne Filtern.** Beides ist Sache von
|
||||
/// `EpisodeSelection`, und es zweimal zu tun hiesse, dass die beiden
|
||||
/// irgendwann auseinanderlaufen.
|
||||
func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] {
|
||||
let staffeln = try await children(of: id)
|
||||
var folgen: [MediaItem] = []
|
||||
for staffel in staffeln {
|
||||
folgen += (try? await children(of: staffel.id)) ?? []
|
||||
}
|
||||
return folgen
|
||||
}
|
||||
|
||||
/// Ohne eigene Antwort keine Mediathek.
|
||||
///
|
||||
/// Plex beantwortet das heute nicht, und eine falsche Antwort wäre
|
||||
/// schlechter als keine: sie stellte die Sprache einer fremden Mediathek
|
||||
/// über den allgemeinen Wunsch.
|
||||
func libraryID(for item: MediaItem) async -> String? { nil }
|
||||
}
|
||||
|
||||
/// Ein möglicher Wert für einen Filter.
|
||||
public struct MediaFilterOption: Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
|
||||
public init(id: String, title: String) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
/// Was beim Sprechen mit einem Server schiefgehen kann.
|
||||
///
|
||||
/// Sechs der sieben Fälle galten schon für Plex — sie sind nicht serverbezogen,
|
||||
/// sondern beschreiben die Lage, in der man steckt. Dass es sie jetzt nur
|
||||
/// einmal gibt, beseitigt nebenbei eine Doppelung: die Übersetzung in
|
||||
/// lesbaren Text stand an zwei Stellen, und die beiden waren bereits
|
||||
/// auseinandergelaufen.
|
||||
public enum MediaBackendError: Error, Equatable, Sendable {
|
||||
/// Der Token gilt nicht mehr. Führt zur Abmeldung, nicht zu einer Meldung.
|
||||
case unauthorized
|
||||
case notFound
|
||||
/// Keine der bekannten Adressen antwortet.
|
||||
case noReachableConnection
|
||||
/// Der Server hat geantwortet, aber mit einem Fehler.
|
||||
case server(status: Int)
|
||||
/// Die Anfrage kam gar nicht an.
|
||||
case transport(description: String)
|
||||
/// Der Server hat geantwortet, aber anders als erwartet.
|
||||
case decoding(description: String)
|
||||
/// Der Server hat verstanden und abgelehnt — mit eigener Begründung.
|
||||
///
|
||||
/// Nicht `.server(status:)`: dort steht eine Zahl, hier ein Satz, den der
|
||||
/// Server selbst formuliert hat. Beim Live-TV war genau dieser Satz die
|
||||
/// einzige brauchbare Spur — und er ging verloren, solange es diesen Fall
|
||||
/// nicht gab.
|
||||
case refused(reason: String)
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import Foundation
|
||||
|
||||
/// Ein Eintrag in der Mediathek — Film, Serie, Staffel oder Folge.
|
||||
///
|
||||
/// Die Form, in der die Oberfläche Titel sieht, egal von welchem Server sie
|
||||
/// stammen. Plex und Jellyfin liefern dieselben Dinge unter anderen Namen;
|
||||
/// jeder Server-Adapter übersetzt einmal hierher, und danach ist die Herkunft
|
||||
/// ohne Belang.
|
||||
///
|
||||
/// Die Namen sind dort geblieben, wo sie ohnehin neutral waren — `title`,
|
||||
/// `summary`, `year`, `index`. Umbenannt wurde nur, was Plex-Jargon ist:
|
||||
/// `ratingKey` heißt jetzt `id`, `viewOffset` heißt `resumeMilliseconds`, und
|
||||
/// aus dem „Großelternteil" ist die Serie geworden, die damit gemeint war.
|
||||
public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
||||
public enum Kind: String, Sendable, Codable, CaseIterable {
|
||||
case movie, show, season, episode, clip, collection
|
||||
}
|
||||
|
||||
/// Die Kennung, unter der der Server den Eintrag führt.
|
||||
///
|
||||
/// Bei Plex eine Zahl als Zeichenkette, bei Jellyfin eine GUID. Für die
|
||||
/// App ist beides nur ein undurchsichtiger Schlüssel — **außer** beim
|
||||
/// Herunterladen, wo er mit einem Vorsatz versehen wird, damit sich zwei
|
||||
/// Server nicht ins Gehege kommen.
|
||||
public let id: String
|
||||
/// Der serverinterne Pfad, falls einer gebraucht wird.
|
||||
public let path: String?
|
||||
public let kind: Kind?
|
||||
public let title: String
|
||||
public let sortTitle: String?
|
||||
public let summary: String?
|
||||
public let year: Int?
|
||||
/// Laufzeit in Millisekunden.
|
||||
public let duration: Int?
|
||||
public let contentRating: String?
|
||||
/// Bewertung der Kritik, 0–10.
|
||||
public let rating: Double?
|
||||
/// Bewertung des Publikums, 0–10.
|
||||
public let audienceRating: Double?
|
||||
|
||||
// Bildpfade. Relativ zum Server — die vollständige Adresse entsteht erst
|
||||
// beim Abruf, weil sie Token und Größenangaben braucht.
|
||||
public let thumb: String?
|
||||
public let art: String?
|
||||
/// Das Bild der Staffel.
|
||||
public let parentThumb: String?
|
||||
/// Das Bild der Serie. Bei Plex hieß das „grandparent" — gemeint war immer
|
||||
/// die Serie, und so heißt es jetzt auch.
|
||||
public let seriesThumb: String?
|
||||
public let seriesArt: String?
|
||||
|
||||
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
|
||||
// Staffel.
|
||||
public let index: Int?
|
||||
public let parentIndex: Int?
|
||||
public let parentTitle: String?
|
||||
public let parentID: String?
|
||||
public let seriesTitle: String?
|
||||
public let seriesID: String?
|
||||
|
||||
// Fortschritt.
|
||||
public let playCount: Int?
|
||||
/// Wie weit gesehen wurde, in Millisekunden.
|
||||
public let resumeMilliseconds: Int?
|
||||
public let lastPlayedAt: Date?
|
||||
public let addedAt: Date?
|
||||
/// Ob der Titel als Favorit markiert ist.
|
||||
///
|
||||
/// Plex kennt das nicht und meldet immer `false`; Jellyfin führt eine
|
||||
/// eigene Liste. Ein Feld, das ein Server nicht füllt, ist ehrlicher als
|
||||
/// eine Fähigkeit, die es nur auf einem gibt.
|
||||
public let isFavourite: Bool
|
||||
|
||||
// Zählwerte bei Serien und Staffeln.
|
||||
public let episodeCount: Int?
|
||||
public let playedEpisodeCount: Int?
|
||||
public let childCount: Int?
|
||||
|
||||
/// Die Fassungen, in denen der Titel vorliegt.
|
||||
public let versions: [MediaVersion]
|
||||
|
||||
// Schlagwörter. Beide Server liefern sie nur bei der Einzelabfrage mit,
|
||||
// nicht in Listen — dort sind sie deshalb leer.
|
||||
public let genres: [String]
|
||||
public let directors: [String]
|
||||
public let writers: [String]
|
||||
public let cast: [MediaPerson]
|
||||
public let markers: [MediaMarker]
|
||||
/// Zusätzliche Bilder, etwa der Schriftzug eines Films.
|
||||
public let artwork: [MediaArtwork]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
path: String? = nil,
|
||||
kind: Kind? = nil,
|
||||
title: String,
|
||||
sortTitle: String? = nil,
|
||||
summary: String? = nil,
|
||||
year: Int? = nil,
|
||||
duration: Int? = nil,
|
||||
contentRating: String? = nil,
|
||||
rating: Double? = nil,
|
||||
audienceRating: Double? = nil,
|
||||
thumb: String? = nil,
|
||||
art: String? = nil,
|
||||
parentThumb: String? = nil,
|
||||
seriesThumb: String? = nil,
|
||||
seriesArt: String? = nil,
|
||||
index: Int? = nil,
|
||||
parentIndex: Int? = nil,
|
||||
parentTitle: String? = nil,
|
||||
parentID: String? = nil,
|
||||
seriesTitle: String? = nil,
|
||||
seriesID: String? = nil,
|
||||
playCount: Int? = nil,
|
||||
resumeMilliseconds: Int? = nil,
|
||||
lastPlayedAt: Date? = nil,
|
||||
addedAt: Date? = nil,
|
||||
isFavourite: Bool = false,
|
||||
episodeCount: Int? = nil,
|
||||
playedEpisodeCount: Int? = nil,
|
||||
childCount: Int? = nil,
|
||||
versions: [MediaVersion] = [],
|
||||
genres: [String] = [],
|
||||
directors: [String] = [],
|
||||
writers: [String] = [],
|
||||
cast: [MediaPerson] = [],
|
||||
markers: [MediaMarker] = [],
|
||||
artwork: [MediaArtwork] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.path = path
|
||||
self.kind = kind
|
||||
self.title = title
|
||||
self.sortTitle = sortTitle
|
||||
self.summary = summary
|
||||
self.year = year
|
||||
self.duration = duration
|
||||
self.contentRating = contentRating
|
||||
self.rating = rating
|
||||
self.audienceRating = audienceRating
|
||||
self.thumb = thumb
|
||||
self.art = art
|
||||
self.parentThumb = parentThumb
|
||||
self.seriesThumb = seriesThumb
|
||||
self.seriesArt = seriesArt
|
||||
self.index = index
|
||||
self.parentIndex = parentIndex
|
||||
self.parentTitle = parentTitle
|
||||
self.parentID = parentID
|
||||
self.seriesTitle = seriesTitle
|
||||
self.seriesID = seriesID
|
||||
self.playCount = playCount
|
||||
self.resumeMilliseconds = resumeMilliseconds
|
||||
self.lastPlayedAt = lastPlayedAt
|
||||
self.addedAt = addedAt
|
||||
self.isFavourite = isFavourite
|
||||
self.episodeCount = episodeCount
|
||||
self.playedEpisodeCount = playedEpisodeCount
|
||||
self.childCount = childCount
|
||||
self.versions = versions
|
||||
self.genres = genres
|
||||
self.directors = directors
|
||||
self.writers = writers
|
||||
self.cast = cast
|
||||
self.markers = markers
|
||||
self.artwork = artwork
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaItem {
|
||||
/// Dasselbe ohne alles, was eine Kachel nicht zeigt.
|
||||
///
|
||||
/// **Wozu.** Ein Titel aus einer Listenabfrage bringt seine Fassungen mit,
|
||||
/// und die bringen jede Spur jeder Datei mit — Codec, Sprache, Kanäle. Im
|
||||
/// Raster steht davon nichts: dort stehen Plakat, Titel, Untertitel und
|
||||
/// der Fortschrittsbalken. Gespeichert wäre der Rest das Zwanzigfache an
|
||||
/// Daten, die bei jedem Aufrufen gelesen und ausgepackt werden müssten —
|
||||
/// und damit wäre der Speicher genau das nicht mehr, wofür es ihn gibt.
|
||||
///
|
||||
/// **Warum das gefahrlos ist.** Aus einer Kachel führt nur ein Weg, und
|
||||
/// der geht über die Kennung: die Detailansicht holt den Titel neu. Was
|
||||
/// hier fehlt, fehlt nur so lange, bis der Server geantwortet hat.
|
||||
var listEssentials: MediaItem {
|
||||
MediaItem(
|
||||
id: id,
|
||||
path: path,
|
||||
kind: kind,
|
||||
title: title,
|
||||
sortTitle: sortTitle,
|
||||
year: year,
|
||||
duration: duration,
|
||||
contentRating: contentRating,
|
||||
thumb: thumb,
|
||||
art: art,
|
||||
parentThumb: parentThumb,
|
||||
seriesThumb: seriesThumb,
|
||||
index: index,
|
||||
parentIndex: parentIndex,
|
||||
parentTitle: parentTitle,
|
||||
parentID: parentID,
|
||||
seriesTitle: seriesTitle,
|
||||
seriesID: seriesID,
|
||||
playCount: playCount,
|
||||
resumeMilliseconds: resumeMilliseconds,
|
||||
lastPlayedAt: lastPlayedAt,
|
||||
addedAt: addedAt,
|
||||
isFavourite: isFavourite,
|
||||
episodeCount: episodeCount,
|
||||
playedEpisodeCount: playedEpisodeCount,
|
||||
childCount: childCount
|
||||
)
|
||||
}
|
||||
|
||||
/// Wie weit gesehen — als Anteil zwischen 0 und 1.
|
||||
///
|
||||
/// `nil`, wenn noch nicht begonnen wurde: ein Balken bei null Prozent
|
||||
/// sieht aus wie ein Fehler, nicht wie „noch nicht angefangen".
|
||||
public var watchProgress: Double? {
|
||||
guard let resumeMilliseconds, let duration, duration > 0, resumeMilliseconds > 0 else {
|
||||
return nil
|
||||
}
|
||||
return min(Double(resumeMilliseconds) / Double(duration), 1)
|
||||
}
|
||||
|
||||
public var isWatched: Bool { (playCount ?? 0) > 0 }
|
||||
|
||||
/// Ab wo fortgesetzt wird.
|
||||
public var resumeTime: Duration {
|
||||
.milliseconds(resumeMilliseconds ?? 0)
|
||||
}
|
||||
|
||||
/// Die Fassung, die gespielt werden soll, wenn niemand wählt.
|
||||
public var preferredVersion: MediaVersion? { versions.first }
|
||||
|
||||
public var hasMultipleVersions: Bool { versions.count > 1 }
|
||||
|
||||
/// Der Schriftzug des Films, falls der Server einen hat.
|
||||
///
|
||||
/// Viele Filme bringen ihr eigenes Logo mit — Harry Potter in seiner
|
||||
/// Schrift sagt mehr als derselbe Titel in der Systemschrift.
|
||||
public var clearLogoPath: String? {
|
||||
artwork.first { $0.kind == .clearLogo }?.path
|
||||
}
|
||||
}
|
||||
|
||||
/// Jemand, der mitgewirkt hat.
|
||||
public struct MediaPerson: Sendable, Equatable, Identifiable, Codable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
/// Die Rolle, sofern es eine gibt.
|
||||
public let role: String?
|
||||
public let thumb: String?
|
||||
|
||||
public init(id: String, name: String, role: String? = nil, thumb: String? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.role = role
|
||||
self.thumb = thumb
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Abschnitt, den man überspringen möchte.
|
||||
public struct MediaMarker: Sendable, Equatable, Identifiable, Codable {
|
||||
public enum Kind: String, Sendable, Codable {
|
||||
case intro, credits, commercial, unknown
|
||||
}
|
||||
|
||||
public let kind: Kind
|
||||
public let startMilliseconds: Int
|
||||
public let endMilliseconds: Int
|
||||
|
||||
public init(kind: Kind, startMilliseconds: Int, endMilliseconds: Int) {
|
||||
self.kind = kind
|
||||
self.startMilliseconds = startMilliseconds
|
||||
self.endMilliseconds = endMilliseconds
|
||||
}
|
||||
|
||||
public func contains(_ milliseconds: Int) -> Bool {
|
||||
milliseconds >= startMilliseconds && milliseconds < endMilliseconds
|
||||
}
|
||||
|
||||
/// Der Anfang taugt als Kennung.
|
||||
///
|
||||
/// Zwei Marken derselben Sendung können nicht an derselben Stelle
|
||||
/// beginnen, und der Player braucht die Kennung nur, um sich zu merken,
|
||||
/// was er schon übersprungen hat — ohne dieses Gedächtnis spränge ein
|
||||
/// Rücksprung ins Intro es sofort wieder weg.
|
||||
public var id: Int { startMilliseconds }
|
||||
|
||||
public var isIntro: Bool { kind == .intro }
|
||||
public var isCredits: Bool { kind == .credits }
|
||||
}
|
||||
|
||||
/// Ein zusätzliches Bild zu einem Titel.
|
||||
public struct MediaArtwork: Sendable, Equatable, Codable {
|
||||
public enum Kind: String, Sendable, Codable {
|
||||
case poster, background, banner, clearLogo, unknown
|
||||
}
|
||||
|
||||
public let kind: Kind
|
||||
public let path: String
|
||||
|
||||
public init(kind: Kind, path: String) {
|
||||
self.kind = kind
|
||||
self.path = path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine Mediathek auf dem Server.
|
||||
public struct MediaLibrary: Sendable, Equatable, Identifiable, Codable {
|
||||
public enum Kind: String, Sendable, Equatable, Codable {
|
||||
case movie, show, music, photo, liveTV, other
|
||||
}
|
||||
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let kind: Kind
|
||||
public let thumb: String?
|
||||
public let art: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
title: String,
|
||||
kind: Kind,
|
||||
thumb: String? = nil,
|
||||
art: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.kind = kind
|
||||
self.thumb = thumb
|
||||
self.art = art
|
||||
}
|
||||
|
||||
/// Ob die App mit dieser Mediathek etwas anfangen kann.
|
||||
///
|
||||
/// Musik und Fotos sind bewusst außen vor: sie bräuchten eigene Ansichten
|
||||
/// und eigene Wiedergabe, und eine halbherzige Musikansicht ist schlechter
|
||||
/// als gar keine. Live-TV hat einen eigenen Bildschirm und gehört nicht
|
||||
/// noch einmal in die Mediathekenliste.
|
||||
///
|
||||
/// **`other` zählt dazu.** Hier stand vorher `movie || show`, und alles
|
||||
/// Übrige fiel wortlos heraus — auch eine Mediathek mit gemischtem
|
||||
/// Inhalt, die Jellyfin ohne `CollectionType` meldet. Wer Filme und
|
||||
/// Serien nicht getrennt hat, sah sie nirgends: nicht in der
|
||||
/// Seitenleiste, nicht auf der Startseite, nirgends eine Meldung. Eine
|
||||
/// unbekannte Art ist kein Grund, etwas zu verschweigen — sie ist nur
|
||||
/// ein Grund, nichts über den Inhalt anzunehmen.
|
||||
public var isSupported: Bool {
|
||||
switch kind {
|
||||
case .movie, .show, .other: true
|
||||
case .music, .photo, .liveTV: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Reihe auf der Startseite oder in den Suchergebnissen.
|
||||
public struct MediaRowGroup: Sendable, Equatable, Identifiable {
|
||||
/// Eine stabile Kennung für diese Reihe.
|
||||
///
|
||||
/// Sie entscheidet, welche Reihe der Nutzer ausgeblendet oder verschoben
|
||||
/// hat — und muss deshalb über Abrufe hinweg gleich bleiben, auch wenn
|
||||
/// sich der angezeigte Titel ändert.
|
||||
public let id: String
|
||||
public let title: String
|
||||
/// Der Pfad, unter dem es „alles davon" gäbe.
|
||||
public let moreKey: String?
|
||||
/// Aus welcher Mediathek diese Reihe stammt.
|
||||
///
|
||||
/// **`nil` heisst: lässt sich nicht zuordnen**, nicht „keine". „Neu in
|
||||
/// Filme" gehört zu einer Mediathek; „Weiterschauen" und „Favoriten"
|
||||
/// gehen quer durch alle, und der Server sagt an keiner Stelle, aus
|
||||
/// welcher ein einzelner angefangener Titel kommt.
|
||||
///
|
||||
/// Gebraucht wird es für die Auswahl des Aufmachers: wer „Musikvideos"
|
||||
/// dort nicht sehen will, muss die Reihe benennen können, die sie
|
||||
/// mitbringt.
|
||||
public let libraryID: String?
|
||||
public let items: [MediaItem]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
title: String,
|
||||
moreKey: String? = nil,
|
||||
libraryID: String? = nil,
|
||||
items: [MediaItem] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.moreKey = moreKey
|
||||
self.libraryID = libraryID
|
||||
self.items = items
|
||||
}
|
||||
|
||||
/// Ob diese Reihe das Weiterschauen ist.
|
||||
///
|
||||
/// Sie wird anders dargestellt: dort zeigt das Szenenbild, wo man
|
||||
/// aufgehört hat, während man sonst nach Plakaten sucht.
|
||||
public var isContinueWatching: Bool {
|
||||
let lowered = id.lowercased()
|
||||
return lowered.contains("continue") || lowered.contains("resume") || lowered.contains("nextup")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import Foundation
|
||||
|
||||
/// Was abgespielt werden soll und wie.
|
||||
///
|
||||
/// Die Entscheidung *ob* umgerechnet wird, fällt vorher — im
|
||||
/// `PlaybackDecider`, aus Profil und Leitung. Hier steht nur noch, was der
|
||||
/// Server daraufhin bereitstellen soll. Das ist die Trennung, die es vorher
|
||||
/// nicht gab: der Player hat Plex' Transcoder unmittelbar angesprochen und
|
||||
/// konnte deshalb mit keinem anderen Server reden.
|
||||
public struct PlaybackIntent: Sendable, Equatable {
|
||||
public let itemID: String
|
||||
/// Welche Fassung — ein Film kann mehrfach vorliegen.
|
||||
public let versionIndex: Int
|
||||
/// Die Datei innerhalb der Fassung. Plex adressiert Dateien, Jellyfin
|
||||
/// Quellen; beide finden sich hierüber wieder.
|
||||
public let file: MediaFile
|
||||
/// `nil` heißt: unverändert übertragen.
|
||||
public let transcode: TranscodeSettings?
|
||||
public let startTime: Duration
|
||||
/// Ob der Server im selben Netz steht. Plex rechnet dann anders.
|
||||
public let isLocalConnection: Bool
|
||||
/// Welche Tonspur gespielt werden soll.
|
||||
///
|
||||
/// **Ohne diese Angabe wählt der Server.** Bei einem umgerechneten Stream
|
||||
/// gibt es danach nur noch *eine* Tonspur — was hier nicht gesagt wird,
|
||||
/// lässt sich später nicht mehr umstellen, weil im Player schlicht keine
|
||||
/// zweite ankommt. Genau daran scheiterte die Sprachwahl bisher: sie lief
|
||||
/// auf einer Liste mit einem Eintrag.
|
||||
public let audioStreamIndex: Int?
|
||||
/// Welcher Untertitel eingebrannt oder mitgeliefert werden soll.
|
||||
///
|
||||
/// Dieselbe Lage wie beim Ton: rechnet der Server um, liefert er genau
|
||||
/// den einen aus, für den er sich entschieden hat. Wer erst im Player
|
||||
/// wählen will, hat dann nichts mehr zur Auswahl.
|
||||
///
|
||||
/// **`-1` heisst ausdrücklich „keine".** Nicht dasselbe wie `nil`: das
|
||||
/// hiesse „such dir was aus", und der Server sucht sich dann durchaus
|
||||
/// etwas aus.
|
||||
public let subtitleStreamIndex: Int?
|
||||
|
||||
public init(
|
||||
itemID: String,
|
||||
versionIndex: Int,
|
||||
file: MediaFile,
|
||||
transcode: TranscodeSettings? = nil,
|
||||
startTime: Duration = .zero,
|
||||
isLocalConnection: Bool = false,
|
||||
audioStreamIndex: Int? = nil,
|
||||
subtitleStreamIndex: Int? = nil
|
||||
) {
|
||||
self.itemID = itemID
|
||||
self.versionIndex = versionIndex
|
||||
self.file = file
|
||||
self.transcode = transcode
|
||||
self.startTime = startTime
|
||||
self.isLocalConnection = isLocalConnection
|
||||
self.audioStreamIndex = audioStreamIndex
|
||||
self.subtitleStreamIndex = subtitleStreamIndex
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie weit heruntergerechnet werden soll.
|
||||
public struct TranscodeSettings: Sendable, Equatable {
|
||||
/// In Kilobit je Sekunde. `nil` heißt „so gut wie möglich".
|
||||
public let maximumBitrate: Int?
|
||||
/// Etwa „1280x720". `nil` lässt die Auflösung, wie sie ist.
|
||||
public let resolution: String?
|
||||
/// Ob der Server eine **Leiter** liefern soll statt einer festen Stufe.
|
||||
///
|
||||
/// Dann enthält die Playlist mehrere Varianten, und der Player wechselt
|
||||
/// zwischen ihnen, ohne dass der Stream neu aufgebaut werden muss. Die
|
||||
/// angegebene Bitrate bleibt dabei die **oberste** Sprosse; was darunter
|
||||
/// liegt, erfindet der Server.
|
||||
///
|
||||
/// Nur bei `automatic` gesetzt. Wer im Sheet ausdrücklich „720p · 3 Mbps"
|
||||
/// antippt, soll das bekommen und nicht „höchstens 3, meistens weniger"
|
||||
/// unter unverändertem Etikett.
|
||||
public let prefersAdaptive: Bool
|
||||
/// Nur umpacken, nicht neu rechnen.
|
||||
///
|
||||
/// Dann werden die Ströme kopiert statt kodiert, und heraus kommt eine
|
||||
/// **fortlaufende** MP4 statt einer HLS-Abspielliste. Beides ist wichtig:
|
||||
/// kopieren verliert nichts und kostet den Server fast nichts, und eine
|
||||
/// fortlaufende Datei lässt sich beliebig weit vorausladen — eine
|
||||
/// Abspielliste nicht.
|
||||
public let prefersRemux: Bool
|
||||
|
||||
public init(
|
||||
maximumBitrate: Int?,
|
||||
resolution: String?,
|
||||
prefersAdaptive: Bool = false,
|
||||
prefersRemux: Bool = false
|
||||
) {
|
||||
self.maximumBitrate = maximumBitrate
|
||||
self.resolution = resolution
|
||||
self.prefersAdaptive = prefersAdaptive
|
||||
self.prefersRemux = prefersRemux
|
||||
}
|
||||
}
|
||||
|
||||
/// Was der Server für diese Wiedergabe bereitgestellt hat.
|
||||
public struct PlaybackTarget: Sendable, Equatable {
|
||||
public let url: URL
|
||||
/// Die Sitzung, die der Server dafür angelegt hat.
|
||||
///
|
||||
/// Zum Aufräumen: ohne dieses Kennzeichen rechnet der Transcoder weiter,
|
||||
/// wenn längst niemand mehr zusieht.
|
||||
public let sessionID: String?
|
||||
/// Welche Fassung gespielt wird.
|
||||
///
|
||||
/// Jellyfin will sie bei jeder Fortschrittsmeldung zurückhaben, sonst
|
||||
/// ordnet es die Meldung keiner Wiedergabe zu.
|
||||
public let versionID: String?
|
||||
|
||||
public init(url: URL, sessionID: String? = nil, versionID: String? = nil) {
|
||||
self.url = url
|
||||
self.sessionID = sessionID
|
||||
self.versionID = versionID
|
||||
}
|
||||
}
|
||||
|
||||
/// In welchem Zustand sich die Wiedergabe befindet.
|
||||
public enum PlaybackReportState: String, Sendable {
|
||||
/// Der erste Bericht einer Wiedergabe.
|
||||
///
|
||||
/// Eigener Fall, weil Jellyfin dafür einen eigenen Endpunkt hat: ohne ihn
|
||||
/// erscheint der Titel nirgends als „läuft gerade", und die Sitzung, unter
|
||||
/// der die weiteren Meldungen laufen, ist dem Server unbekannt. Plex
|
||||
/// unterscheidet das nicht und behandelt ihn wie „spielt".
|
||||
case started
|
||||
case playing
|
||||
case paused
|
||||
/// Beim Verlassen des Players. Erst dieser Wert nimmt den Titel auf dem
|
||||
/// Server aus „wird gerade abgespielt" heraus.
|
||||
case stopped
|
||||
case buffering
|
||||
}
|
||||
|
||||
/// Wo man gerade steht.
|
||||
public struct PlaybackReport: Sendable, Equatable {
|
||||
public let itemID: String
|
||||
public let sessionID: String?
|
||||
public let versionID: String?
|
||||
public let state: PlaybackReportState
|
||||
public let time: Duration
|
||||
public let duration: Duration
|
||||
|
||||
public init(
|
||||
itemID: String,
|
||||
sessionID: String? = nil,
|
||||
versionID: String? = nil,
|
||||
state: PlaybackReportState,
|
||||
time: Duration,
|
||||
duration: Duration
|
||||
) {
|
||||
self.itemID = itemID
|
||||
self.sessionID = sessionID
|
||||
self.versionID = versionID
|
||||
self.state = state
|
||||
self.time = time
|
||||
self.duration = duration
|
||||
}
|
||||
}
|
||||
|
||||
extension PlaybackReportState: Equatable {}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
/// Was der `PlaybackDecider` über eine Datei wissen muss — und nicht mehr.
|
||||
///
|
||||
/// Vorher nahm er die Plex-Modelle entgegen und las daraus sechs Felder.
|
||||
/// Das band die Entscheidung an einen bestimmten Server, obwohl an ihr nichts
|
||||
/// serverspezifisch ist: ob iOS eine Datei öffnen kann, hängt an Container und
|
||||
/// Codecs, nicht daran, wer sie ausliefert.
|
||||
///
|
||||
/// Wie ernst die Kopplung war, zeigt der Player selbst: für die
|
||||
/// Offline-Wiedergabe baute er sich **erfundene** Plex-Werte zusammen, nur um
|
||||
/// diese Signatur zu bedienen — für eine Datei, die längst auf dem Gerät lag
|
||||
/// und mit Plex nichts mehr zu tun hatte.
|
||||
public struct MediaProfile: Sendable, Equatable {
|
||||
/// Der Behälter, etwa `mkv` oder `mp4`.
|
||||
public let container: String?
|
||||
public let videoCodec: String?
|
||||
/// Der Codec der Tonspur, die tatsächlich klingen soll.
|
||||
///
|
||||
/// Nicht irgendeiner: eine Datei mit deutschem AAC und englischem DTS ist
|
||||
/// für AVPlayer in Ordnung, solange Deutsch gewählt ist.
|
||||
public let audioCodec: String?
|
||||
/// Gesamtbitrate in Kilobit je Sekunde.
|
||||
public let bitrate: Int?
|
||||
/// Ob der Server die Datei überhaupt schon untersucht hat.
|
||||
///
|
||||
/// Ohne bekannte Spuren lässt sich nichts entscheiden — dann ist der
|
||||
/// Ersatzplayer die sichere Wahl.
|
||||
public let hasKnownStreams: Bool
|
||||
|
||||
public init(
|
||||
container: String? = nil,
|
||||
videoCodec: String? = nil,
|
||||
audioCodec: String? = nil,
|
||||
bitrate: Int? = nil,
|
||||
hasKnownStreams: Bool = true
|
||||
) {
|
||||
self.container = container
|
||||
self.videoCodec = videoCodec
|
||||
self.audioCodec = audioCodec
|
||||
self.bitrate = bitrate
|
||||
self.hasKnownStreams = hasKnownStreams
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// Wonach eine Mediathek durchsucht wird.
|
||||
///
|
||||
/// Gefiltert und sortiert wird **auf dem Server**. Alles hierher zu holen und
|
||||
/// selbst zu sieben wäre bei einer Mediathek mit fünftausend Folgen
|
||||
/// aussichtslos — und die Server können es ohnehin besser.
|
||||
///
|
||||
/// Die Sortierarten sind neutral benannt. Vorher standen hier Plex'
|
||||
/// Drahtwerte (`"titleSort:asc"`), was bedeutete: entweder Jellyfin lernt
|
||||
/// Plex' Schreibweise, oder jede Ansicht muss wissen, mit wem sie spricht.
|
||||
public struct MediaQuery: Sendable, Equatable {
|
||||
public enum Sort: String, Sendable, Equatable, CaseIterable {
|
||||
case titleAscending, titleDescending
|
||||
case recentlyAdded
|
||||
case releaseDate
|
||||
case rating
|
||||
case lastPlayed
|
||||
}
|
||||
|
||||
/// Wonach gefiltert werden kann.
|
||||
public enum Field: String, Sendable, Equatable {
|
||||
case genre, year, contentRating, resolution
|
||||
}
|
||||
|
||||
public var sort: Sort
|
||||
public var unwatchedOnly: Bool
|
||||
public var favouritesOnly: Bool
|
||||
public var genre: String?
|
||||
public var year: Int?
|
||||
public var contentRating: String?
|
||||
public var resolution: String?
|
||||
/// Freitextsuche innerhalb der Mediathek.
|
||||
public var searchText: String?
|
||||
|
||||
public init(
|
||||
sort: Sort = .titleAscending,
|
||||
unwatchedOnly: Bool = false,
|
||||
favouritesOnly: Bool = false,
|
||||
genre: String? = nil,
|
||||
year: Int? = nil,
|
||||
contentRating: String? = nil,
|
||||
resolution: String? = nil,
|
||||
searchText: String? = nil
|
||||
) {
|
||||
self.sort = sort
|
||||
self.unwatchedOnly = unwatchedOnly
|
||||
self.favouritesOnly = favouritesOnly
|
||||
self.genre = genre
|
||||
self.year = year
|
||||
self.contentRating = contentRating
|
||||
self.resolution = resolution
|
||||
self.searchText = searchText
|
||||
}
|
||||
|
||||
/// Ob überhaupt eingeschränkt wird — steuert die Anzeige des Filterknopfs.
|
||||
public var isFiltering: Bool {
|
||||
unwatchedOnly || favouritesOnly || genre != nil || year != nil
|
||||
|| contentRating != nil || resolution != nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine Fassung eines Titels — eine bestimmte Datei in einer bestimmten Güte.
|
||||
///
|
||||
/// Ein Film kann mehrfach vorliegen: als 4K-Remux und als kleine Fassung fürs
|
||||
/// Handy. Welche gespielt wird, entscheidet der Nutzer oder die Voreinstellung.
|
||||
public struct MediaVersion: Sendable, Equatable, Identifiable, Codable {
|
||||
public let id: String
|
||||
public let container: String?
|
||||
public let videoCodec: String?
|
||||
public let audioCodec: String?
|
||||
/// Die Auflösung, wie der Server sie nennt — „1080", „4k".
|
||||
public let videoResolution: String?
|
||||
/// Gesamtbitrate in Kilobit je Sekunde.
|
||||
public let bitrate: Int?
|
||||
public let width: Int?
|
||||
public let height: Int?
|
||||
public let audioChannels: Int?
|
||||
public let duration: Int?
|
||||
public let files: [MediaFile]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
container: String? = nil,
|
||||
videoCodec: String? = nil,
|
||||
audioCodec: String? = nil,
|
||||
videoResolution: String? = nil,
|
||||
bitrate: Int? = nil,
|
||||
width: Int? = nil,
|
||||
height: Int? = nil,
|
||||
audioChannels: Int? = nil,
|
||||
duration: Int? = nil,
|
||||
files: [MediaFile] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.container = container
|
||||
self.videoCodec = videoCodec
|
||||
self.audioCodec = audioCodec
|
||||
self.videoResolution = videoResolution
|
||||
self.bitrate = bitrate
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.audioChannels = audioChannels
|
||||
self.duration = duration
|
||||
self.files = files
|
||||
}
|
||||
|
||||
/// Die Höhe in Bildzeilen.
|
||||
///
|
||||
/// Nicht immer gemeldet: manche Server nennen nur „4k" und keine Zahl.
|
||||
/// Danach richtet sich, welche Qualitätsstufen überhaupt Sinn ergeben —
|
||||
/// eine 720p-Datei auf 1080p hochzurechnen bringt kein Bild dazu.
|
||||
public var pixelHeight: Int? {
|
||||
if let height, height > 0 { return height }
|
||||
return resolution?.nominalHeight
|
||||
}
|
||||
|
||||
/// Die Stufe, in der diese Fassung vorliegt.
|
||||
public var resolution: VideoResolution? {
|
||||
VideoResolution(width: width, height: height, serverLabel: videoResolution)
|
||||
}
|
||||
|
||||
/// Die Auflösung als Wort — „4K", „Full HD".
|
||||
///
|
||||
/// Nicht die Zeichenkette des Servers: Plex sagt „4k" und „1080",
|
||||
/// Jellyfin gibt Zahlen. Gemeint ist dasselbe, und keines davon ist ein
|
||||
/// Name, den jemand benutzt.
|
||||
public var resolutionLabel: String? { resolution?.name }
|
||||
|
||||
/// Eine kurze Beschreibung für die Versionsauswahl, etwa
|
||||
/// „4K · HEVC · 38 Mbit/s".
|
||||
public var qualityLabel: String {
|
||||
var pieces: [String] = []
|
||||
if let label = resolutionLabel { pieces.append(label) }
|
||||
if let videoCodec { pieces.append(videoCodec.uppercased()) }
|
||||
if let bitrate { pieces.append("\(bitrate / 1000) Mbit/s") }
|
||||
return pieces.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// Was die Wiedergabe-Entscheidung über diese Fassung wissen muss.
|
||||
public func profile(file: MediaFile?) -> MediaProfile {
|
||||
MediaProfile(
|
||||
container: file?.container ?? container,
|
||||
videoCodec: videoCodec ?? file?.videoStream?.codec,
|
||||
// Die **gewählte** Tonspur, nicht irgendeine: eine Datei mit
|
||||
// deutschem AAC und englischem DTS ist für AVPlayer in Ordnung,
|
||||
// solange Deutsch klingt.
|
||||
audioCodec: file?.selectedAudioStream?.codec ?? audioCodec,
|
||||
bitrate: bitrate,
|
||||
hasKnownStreams: !(file?.streams.isEmpty ?? true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Datei auf dem Server.
|
||||
public struct MediaFile: Sendable, Equatable, Identifiable, Codable {
|
||||
public let id: String
|
||||
/// Der Pfad, über den die Datei abgerufen wird.
|
||||
public let key: String?
|
||||
/// Zu welchem Titel diese Datei gehört.
|
||||
///
|
||||
/// Plex adressiert Dateien unmittelbar und braucht das nicht; Jellyfin
|
||||
/// adressiert über den Titel und nennt die Datei nur als Beiwerk. Ohne
|
||||
/// diese Angabe traf die Adresse dort den falschen Weg, sobald ein Titel
|
||||
/// in mehreren Fassungen vorliegt — und nur dann, was den Fehler zu einem
|
||||
/// gemacht hätte, den man erst spät findet.
|
||||
public let itemID: String?
|
||||
public let container: String?
|
||||
/// Größe in Bytes.
|
||||
///
|
||||
/// `Int64`, nicht `Int`: ein Remux überschreitet vier Gigabyte mühelos,
|
||||
/// und auf einer 32-Bit-Plattform wäre die Zahl dann falsch.
|
||||
public let size: Int64?
|
||||
public let duration: Int?
|
||||
/// Der Pfad im Dateisystem des Servers — nur zur Anzeige.
|
||||
public let file: String?
|
||||
public let streams: [MediaStream]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
key: String? = nil,
|
||||
itemID: String? = nil,
|
||||
container: String? = nil,
|
||||
size: Int64? = nil,
|
||||
duration: Int? = nil,
|
||||
file: String? = nil,
|
||||
streams: [MediaStream] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.key = key
|
||||
self.itemID = itemID
|
||||
self.container = container
|
||||
self.size = size
|
||||
self.duration = duration
|
||||
self.file = file
|
||||
self.streams = streams
|
||||
}
|
||||
|
||||
public var videoStream: MediaStream? { streams.first { $0.kind == .video } }
|
||||
public var audioStreams: [MediaStream] { streams.filter { $0.kind == .audio } }
|
||||
public var subtitleStreams: [MediaStream] { streams.filter { $0.kind == .subtitle } }
|
||||
|
||||
/// Die Tonspur, die klingen soll.
|
||||
///
|
||||
/// Die ausdrücklich gewählte, sonst die erste. Daran hängt die
|
||||
/// Entscheidung, ob AVPlayer die Datei öffnen kann.
|
||||
public var selectedAudioStream: MediaStream? {
|
||||
audioStreams.first { $0.isSelected } ?? audioStreams.first
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Spur in einer Datei.
|
||||
public struct MediaStream: Sendable, Equatable, Identifiable, Codable {
|
||||
public enum Kind: String, Sendable, Equatable, Codable { case video, audio, subtitle }
|
||||
|
||||
public let id: String
|
||||
public let kind: Kind
|
||||
public let codec: String?
|
||||
public let language: String?
|
||||
public let languageCode: String?
|
||||
/// Wie der Server die Spur nennt — etwa „Deutsch (AC3 5.1)".
|
||||
public let displayTitle: String?
|
||||
public let channels: Int?
|
||||
public let isSelected: Bool
|
||||
/// Untertitel, die auch bei abgeschalteten Untertiteln erscheinen sollen.
|
||||
public let isForced: Bool
|
||||
/// Wie die Spur **in der Datei** heißt.
|
||||
///
|
||||
/// Das zweite Signal neben dem Sprachkürzel — und manchmal das einzige
|
||||
/// richtige. Wo im Kürzel „eng" steht, kann hier „Deutsch" stehen; genau
|
||||
/// so sehen die Dateien aus, bei denen die Sprachwahl bisher danebengriff.
|
||||
public let title: String?
|
||||
/// Die Spur, die der Server als Standard markiert.
|
||||
public let isDefault: Bool
|
||||
/// Für Hörgeschädigte. **Nicht** Audiodeskription — dafür gibt es bei
|
||||
/// Jellyfin kein Kennzeichen, die lässt sich nur am Namen erkennen.
|
||||
public let isHearingImpaired: Bool
|
||||
/// Die Originaltonspur, sofern die Datei das vermerkt.
|
||||
public let isOriginal: Bool
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
kind: Kind,
|
||||
codec: String? = nil,
|
||||
language: String? = nil,
|
||||
languageCode: String? = nil,
|
||||
displayTitle: String? = nil,
|
||||
channels: Int? = nil,
|
||||
isSelected: Bool = false,
|
||||
isForced: Bool = false,
|
||||
title: String? = nil,
|
||||
isDefault: Bool = false,
|
||||
isHearingImpaired: Bool = false,
|
||||
isOriginal: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.codec = codec
|
||||
self.language = language
|
||||
self.languageCode = languageCode
|
||||
self.displayTitle = displayTitle
|
||||
self.channels = channels
|
||||
self.isSelected = isSelected
|
||||
self.isForced = isForced
|
||||
self.title = title
|
||||
self.isDefault = isDefault
|
||||
self.isHearingImpaired = isHearingImpaired
|
||||
self.isOriginal = isOriginal
|
||||
}
|
||||
|
||||
/// Die Nummer, unter der der Server diese Spur führt.
|
||||
public var index: Int? { Int(id) }
|
||||
|
||||
/// Ein brauchbarer Name, auch wenn der Server keinen liefert.
|
||||
///
|
||||
/// „Spur 3" hilft niemandem bei der Wahl; Sprache und Codec schon.
|
||||
public var name: String {
|
||||
if let displayTitle, !displayTitle.isEmpty { return displayTitle }
|
||||
let parts = [language, codec?.uppercased()].compactMap(\.self)
|
||||
return parts.isEmpty ? "—" : parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
/// Wann nachgeladen wird.
|
||||
///
|
||||
/// **Nicht „die letzten fünf".** So stand es bisher, und zwei Dinge daran
|
||||
/// gehen schief. Erstens ist das auf einem Raster aus sechs Spalten nicht
|
||||
/// einmal eine Zeile: wer dort ankommt, wartet trotzdem. Zweitens — und das
|
||||
/// ist der eigentliche Fehler — hing es am **Erscheinen** der Kachel. Beim
|
||||
/// ersten Zeichnen erscheinen alle sechzig gleichzeitig, auch die letzten
|
||||
/// fünf; das Nachladen lief also sofort los statt beim Scrollen.
|
||||
///
|
||||
/// Gemessen wird deshalb in **Zeilen**, und der Auslöser ist auf dem
|
||||
/// Fernseher der Fokus statt des Erscheinens. Auf einem breiten Raster ist
|
||||
/// das nicht Feinschliff: dort sind sechs Kacheln eine Zeile, und mit der
|
||||
/// alten Regel wäre schon die erste Bildschirmseite „fast am Ende".
|
||||
public struct PagingWindow: Sendable, Equatable {
|
||||
/// Wie viele Kacheln nebeneinander stehen.
|
||||
public let columns: Int
|
||||
/// Wie viele Zeilen Vorlauf gewünscht sind.
|
||||
public let rowsAhead: Int
|
||||
|
||||
public init(columns: Int, rowsAhead: Int = 2) {
|
||||
self.columns = columns
|
||||
self.rowsAhead = rowsAhead
|
||||
}
|
||||
|
||||
/// - Parameter focusedIndex: Wo der Fokus gerade steht.
|
||||
/// - Parameter loaded: Wie viele Einträge schon dastehen.
|
||||
/// - Parameter total: Wie viele der Server insgesamt meldet.
|
||||
public func shouldLoad(focusedIndex: Int, loaded: Int, total: Int) -> Bool {
|
||||
// `columns <= 0` ist keine sinnvolle Frage, aber eine mögliche: ein
|
||||
// Raster kennt seine Spaltenzahl erst nach dem ersten Zeichnen. Null
|
||||
// zurückzugeben ist richtiger, als durch null zu teilen.
|
||||
guard columns > 0, loaded > 0, loaded < total else { return false }
|
||||
return focusedIndex >= loaded - columns * rowsAhead
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Legt Tokens im Schlüsselbund ab.
|
||||
///
|
||||
/// Nicht in `UserDefaults`: ein Server-Token gibt vollen Zugriff auf die
|
||||
/// Mediathek und bleibt gültig, bis er am Server zurückgezogen wird. In den
|
||||
/// Voreinstellungen läge er als Klartext in einer Datei, die jedes Backup
|
||||
/// mitnimmt.
|
||||
///
|
||||
/// Der Kontoname trennt die Server: `plex.server` und `jellyfin.server`
|
||||
/// liegen nebeneinander. Wer zwischen beiden wechselt, soll beim
|
||||
/// Zurückwechseln nicht neu anmelden müssen.
|
||||
public protocol TokenStoring: Sendable {
|
||||
func store(_ token: String, for account: String) throws
|
||||
func token(for account: String) -> String?
|
||||
func remove(for account: String)
|
||||
}
|
||||
|
||||
public struct KeychainTokenStore: TokenStoring {
|
||||
private let service: String
|
||||
|
||||
public init(service: String) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
public enum Failure: Error, Equatable {
|
||||
case keychain(status: OSStatus)
|
||||
}
|
||||
|
||||
public func store(_ token: String, for account: String) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: Data(token.utf8),
|
||||
// Ohne Gerätesperre kein Zugriff, und nicht in Backups übertragbar.
|
||||
// Ein Token, der auf einem anderen Gerät wieder auftaucht, wäre
|
||||
// ein stiller Zweitzugang zur Mediathek.
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||
]
|
||||
|
||||
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
return
|
||||
case errSecItemNotFound:
|
||||
let insert = query.merging(attributes) { current, _ in current }
|
||||
let addStatus = SecItemAdd(insert as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else { throw Failure.keychain(status: addStatus) }
|
||||
default:
|
||||
throw Failure.keychain(status: status)
|
||||
}
|
||||
}
|
||||
|
||||
public func token(for account: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
|
||||
var result: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data
|
||||
else { return nil }
|
||||
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
public func remove(for account: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Ablageort im Arbeitsspeicher — für Tests und SwiftUI-Vorschauen.
|
||||
public final class InMemoryTokenStore: TokenStoring, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var tokens: [String: String] = [:]
|
||||
|
||||
public init() {}
|
||||
|
||||
public func store(_ token: String, for account: String) throws {
|
||||
lock.withLock { tokens[account] = token }
|
||||
}
|
||||
|
||||
public func token(for account: String) -> String? {
|
||||
lock.withLock { tokens[account] }
|
||||
}
|
||||
|
||||
public func remove(for account: String) {
|
||||
_ = lock.withLock { tokens.removeValue(forKey: account) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
|
||||
/// Was auf dem Regal über der App steht, wenn man sie im Menü anwählt.
|
||||
///
|
||||
/// **Warum ein eigener Abzug und nicht `LibraryCache`.** Das Regal wird von
|
||||
/// einer **App-Erweiterung** gezeichnet, also von einem anderen Prozess mit
|
||||
/// eigener Sandbox. Der kommt weder an den Schlüsselbund noch an den
|
||||
/// Zwischenspeicher der App, und er soll auch gar nicht: er hat weniger als
|
||||
/// eine Sekunde Zeit, bevor tvOS ihn abbricht. Ein Netzabruf, eine Anmeldung,
|
||||
/// ein Blättern — alles ausgeschlossen.
|
||||
///
|
||||
/// Also legt die App hin, was das Regal braucht, und sonst nichts: Titel,
|
||||
/// Bildadresse, Kennung. Fertig aufbereitet, eine Datei, ein `JSONDecoder`.
|
||||
///
|
||||
/// **Zur Bildadresse.** Sie trägt den Zugangsschlüssel des Servers, weil
|
||||
/// Jellyfin ihn für Bilder verlangt und die Erweiterung ihn sonst nirgends
|
||||
/// herbekäme. Die Datei liegt in der App-Gruppe — erreichbar für die App und
|
||||
/// ihre Erweiterungen, für sonst niemanden. Der Schlüsselbund bleibt, wo er
|
||||
/// ist; hierher kommt nur, was ohnehin in jeder Bildanfrage steht.
|
||||
public struct TopShelfSnapshot: Codable, Sendable, Equatable {
|
||||
public struct Entry: Codable, Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let subtitle: String?
|
||||
/// Das Plakat, in der Grösse, die das Regal zeigt.
|
||||
public let imageURL: URL?
|
||||
/// Wie weit gesehen — für den Balken auf dem Plakat.
|
||||
public let progress: Double?
|
||||
|
||||
public init(
|
||||
id: String, title: String, subtitle: String? = nil,
|
||||
imageURL: URL?, progress: Double? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.imageURL = imageURL
|
||||
self.progress = progress
|
||||
}
|
||||
}
|
||||
|
||||
public struct Section: Codable, Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let entries: [Entry]
|
||||
|
||||
public init(id: String, title: String, entries: [Entry]) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.entries = entries
|
||||
}
|
||||
}
|
||||
|
||||
public let sections: [Section]
|
||||
public let savedAt: Date
|
||||
|
||||
public init(sections: [Section], savedAt: Date = .now) {
|
||||
self.sections = sections
|
||||
self.savedAt = savedAt
|
||||
}
|
||||
|
||||
/// Wie viele Reihen und wie viele Einträge darin höchstens stehen.
|
||||
///
|
||||
/// **Nicht aus Sparsamkeit.** Das Regal zeigt eine knappe Bildschirmbreite;
|
||||
/// alles darüber hinaus lädt die Erweiterung nur, um es nie zu zeigen —
|
||||
/// und sie hat dafür weniger als eine Sekunde.
|
||||
public static let sectionLimit = 3
|
||||
public static let entryLimit = 12
|
||||
}
|
||||
|
||||
/// Wo der Abzug liegt und wie er dorthin kommt.
|
||||
///
|
||||
/// **In der App-Gruppe, nicht im Caches-Ordner.** Jeder Prozess hat seinen
|
||||
/// eigenen; die App-Gruppe ist der einzige Ort, den App und Erweiterung
|
||||
/// gemeinsam sehen.
|
||||
public enum TopShelfStore {
|
||||
/// Gibt `nil` zurück, wenn die App-Gruppe fehlt — und das ist der Fall,
|
||||
/// bis sie im Entwicklerkonto eingetragen ist. Die Erweiterung zeigt dann
|
||||
/// nichts, statt abzustürzen.
|
||||
public static func location(group: String) -> URL? {
|
||||
FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: group)?
|
||||
.appending(path: "topshelf.json", directoryHint: .notDirectory)
|
||||
}
|
||||
|
||||
public static func write(_ snapshot: TopShelfSnapshot, group: String) {
|
||||
guard let ziel = location(group: group),
|
||||
let daten = try? JSONEncoder().encode(snapshot)
|
||||
else { return }
|
||||
// `.atomic`: die Erweiterung kann jederzeit lesen, auch mitten im
|
||||
// Schreiben. Eine halb geschriebene Datei ergäbe eine leere Regalreihe
|
||||
// statt eines Fehlers — und niemand käme darauf, warum.
|
||||
try? daten.write(to: ziel, options: .atomic)
|
||||
}
|
||||
|
||||
public static func read(group: String) -> TopShelfSnapshot? {
|
||||
guard let quelle = location(group: group),
|
||||
let daten = try? Data(contentsOf: quelle)
|
||||
else { return nil }
|
||||
return try? JSONDecoder().decode(TopShelfSnapshot.self, from: daten)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
|
||||
/// Die Auflösung als Stufe, nicht als Zahl.
|
||||
///
|
||||
/// **Warum es das gibt.** Bisher stand auf der Detailseite schlicht `1080` —
|
||||
/// die Zeichenkette, die Plex liefert, in Großbuchstaben. Weder eine Zahl mit
|
||||
/// Bedeutung noch ein Name, den jemand benutzt.
|
||||
///
|
||||
/// **Warum die Breite zählt und nicht die Höhe.** Ein Kinofilm liegt in 2,39:1
|
||||
/// vor und misst dann 1920 × 804. Nach der Höhe wäre das „720p"; nach der
|
||||
/// Breite ist es, was es ist. Genau so sahen die Fassungen aus, bei denen die
|
||||
/// alte Einstufung danebenlag — und zwar ausgerechnet bei den großen Filmen.
|
||||
/// Die Höhe bleibt als Notnagel für Server, die keine Breite melden.
|
||||
public enum VideoResolution: Sendable, Equatable, Comparable, CaseIterable {
|
||||
case sd
|
||||
case hd
|
||||
case fullHD
|
||||
case qhd
|
||||
case uhd4K
|
||||
case uhd8K
|
||||
|
||||
/// Wie der Name in der Oberfläche steht.
|
||||
///
|
||||
/// Nicht übersetzt: „Full HD" und „4K" heißen in jeder Sprache so, und
|
||||
/// eine Übersetzungstabelle für vier feste Wörter wäre eine Fehlerquelle
|
||||
/// ohne Nutzen.
|
||||
public var name: String {
|
||||
switch self {
|
||||
case .sd: "SD"
|
||||
case .hd: "HD"
|
||||
case .fullHD: "Full HD"
|
||||
case .qhd: "QHD"
|
||||
case .uhd4K: "4K"
|
||||
case .uhd8K: "8K"
|
||||
}
|
||||
}
|
||||
|
||||
/// Die technische Schreibweise — für Stufen, die man wählt, statt für
|
||||
/// Material, das man beschreibt.
|
||||
public var technicalName: String {
|
||||
switch self {
|
||||
case .sd: "480p"
|
||||
case .hd: "720p"
|
||||
case .fullHD: "1080p"
|
||||
case .qhd: "1440p"
|
||||
case .uhd4K: "2160p"
|
||||
case .uhd8K: "4320p"
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Bildhöhe, die diese Stufe üblicherweise hat.
|
||||
///
|
||||
/// Für Entscheidungen, die eine Zahl brauchen — etwa welche
|
||||
/// Download-Stufen bei diesem Material überhaupt Sinn ergeben.
|
||||
public var nominalHeight: Int {
|
||||
switch self {
|
||||
case .sd: 480
|
||||
case .hd: 720
|
||||
case .fullHD: 1_080
|
||||
case .qhd: 1_440
|
||||
case .uhd4K: 2_160
|
||||
case .uhd8K: 4_320
|
||||
}
|
||||
}
|
||||
|
||||
/// Stuft eine Fassung ein.
|
||||
///
|
||||
/// In dieser Reihenfolge: Breite, dann Höhe, dann der Name des Servers.
|
||||
/// Gibt `nil` zurück, wenn nichts davon etwas hergibt — dann ist eine
|
||||
/// erfundene Stufe schlechter als gar keine Angabe.
|
||||
public init?(width: Int?, height: Int?, serverLabel: String? = nil) {
|
||||
if let width, width > 0 {
|
||||
self = Self.fromWidth(width)
|
||||
} else if let height, height > 0 {
|
||||
self = Self.fromHeight(height)
|
||||
} else if let stufe = Self.fromServerLabel(serverLabel) {
|
||||
self = stufe
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Der verlässliche Weg. Die Breite ist vom Bildformat unabhängig.
|
||||
private static func fromWidth(_ width: Int) -> VideoResolution {
|
||||
switch width {
|
||||
case 7_000...: .uhd8K
|
||||
case 3_000..<7_000: .uhd4K // 3840 und 4096
|
||||
case 2_000..<3_000: .qhd // 2560
|
||||
case 1_700..<2_000: .fullHD // 1920
|
||||
case 1_100..<1_700: .hd // 1280
|
||||
default: .sd
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Notnagel.
|
||||
///
|
||||
/// Aus der Höhe allein ist ein breitwandiges 1080p (804 Zeilen) nicht von
|
||||
/// einem vollformatigen 720p zu unterscheiden. Das ist keine Nachlässigkeit
|
||||
/// in den Grenzen, sondern liegt an der Angabe: die Information fehlt
|
||||
/// schlicht. Deshalb kommt die Breite zuerst.
|
||||
private static func fromHeight(_ height: Int) -> VideoResolution {
|
||||
switch height {
|
||||
case 2_800...: .uhd8K
|
||||
case 1_500..<2_800: .uhd4K
|
||||
case 1_250..<1_500: .qhd
|
||||
case 850..<1_250: .fullHD
|
||||
case 650..<850: .hd
|
||||
default: .sd
|
||||
}
|
||||
}
|
||||
|
||||
/// Was Plex hinschreibt: „4k", „1080", „sd".
|
||||
private static func fromServerLabel(_ raw: String?) -> VideoResolution? {
|
||||
guard let raw = raw?.lowercased().trimmingCharacters(in: .whitespaces),
|
||||
!raw.isEmpty
|
||||
else { return nil }
|
||||
|
||||
switch raw {
|
||||
case "8k", "4320", "4320p": return .uhd8K
|
||||
case "4k", "uhd", "2160", "2160p": return .uhd4K
|
||||
case "qhd", "1440", "1440p": return .qhd
|
||||
case "fhd", "1080", "1080p": return .fullHD
|
||||
case "hd", "720", "720p": return .hd
|
||||
case "sd", "576", "576p", "480", "480p", "360", "360p", "240", "240p": return .sd
|
||||
default: break
|
||||
}
|
||||
|
||||
// „1080i" und Ähnliches: die Ziffern vorne deuten, den Rest verwerfen.
|
||||
let ziffern = raw.prefix { $0.isNumber }
|
||||
guard let zahl = Int(ziffern), zahl > 0 else { return nil }
|
||||
// Über tausend ist es eine Breite, darunter eine Höhe — andersherum
|
||||
// gibt es keine sinnvolle Lesart.
|
||||
return zahl >= 2_000 ? fromWidth(zahl) : fromHeight(zahl)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user