Halo heisst ab hier Flaro — die Marke stand im Weg
Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging daneben. **Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler. Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim letzten Mal stattfand. --- 2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`, `Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen `FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und das Image. **Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes Ersetzen hätte den sonst zerlegt. `docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden in die Irre führt. **Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag: `com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe. 308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS bauen, das Image baut, und ein frischer Container läuft durch Einrichtung und Anmeldung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f98b995050
commit
a7cf6c5def
@@ -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 `FlaroTheme`: 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 flaroAccent: Color = AccentOption.storedAccentColor()
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Setzt die Akzentfarbe für diesen Teilbaum.
|
||||
///
|
||||
/// Wird einmal an der Wurzel aus `FlaroTheme` gesetzt; darunter greift
|
||||
/// alles automatisch darauf zu.
|
||||
public func flaroAccent(_ color: Color) -> some View {
|
||||
environment(\.flaroAccent, color)
|
||||
}
|
||||
|
||||
public func flaroAccent(_ theme: FlaroTheme) -> some View {
|
||||
environment(\.flaroAccent, theme.accentColor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Eine wählbare Akzentfarbe.
|
||||
///
|
||||
/// Die kuratierten Werte sind so gewählt, dass sie auf `FlaroColor.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 `FlaroTheme`, 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 = "flaro.accent.identifier"
|
||||
static let customHex = "flaro.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: FlaroColor.canvas).swiftUIColor
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
|
||||
/// Kontrastrechnung nach WCAG 2.1.
|
||||
///
|
||||
/// Gebraucht wird sie für die eigene Akzentfarbe: Flaro 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,45 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die Flächen- und Schriftfarben der App.
|
||||
///
|
||||
/// Flaro 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 `FlaroTheme`.
|
||||
public enum FlaroColor {
|
||||
// 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 flaroCanvas = FlaroColor.canvas.swiftUIColor
|
||||
public static let flaroSurface = FlaroColor.surface.swiftUIColor
|
||||
public static let flaroElevated = FlaroColor.elevated.swiftUIColor
|
||||
public static let flaroPlaceholder = FlaroColor.placeholder.swiftUIColor
|
||||
public static let flaroTextPrimary = FlaroColor.textPrimary.swiftUIColor
|
||||
public static let flaroTextSecondary = FlaroColor.textSecondary.swiftUIColor
|
||||
public static let flaroTextMuted = FlaroColor.textMuted.swiftUIColor
|
||||
public static let flaroDestructive = FlaroColor.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 flaroSeparator = Color.white.opacity(0.10)
|
||||
/// Die Abdunklung hinter der ausgefahrenen Seitenleiste.
|
||||
public static let flaroScrim = Color.black.opacity(0.55)
|
||||
}
|
||||
@@ -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,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 FlaroArtwork: 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.flaroPlaceholder)
|
||||
} else {
|
||||
Rectangle().fill(Color.flaroPlaceholder).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.flaroTextMuted)
|
||||
} 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 = FlaroAspectRatio.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 `FlaroArtwork` wie zuvor: die zweite
|
||||
/// Ebene entsteht nur, wenn jemand eine vorige Adresse mitgibt.
|
||||
@ViewBuilder
|
||||
private var bild: some View {
|
||||
if outgoingArtworkURLs.isEmpty {
|
||||
FlaroArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
|
||||
} else {
|
||||
ZStack {
|
||||
FlaroArtwork(urls: outgoingArtworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
|
||||
FlaroArtwork(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.flaroCanvas.opacity(0.75), location: 0.45),
|
||||
.init(color: Color.flaroCanvas, 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: FlaroSpacing.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.flaroTextPrimary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
if let metadataContent {
|
||||
metadataContent
|
||||
.font(.system(size: 12 * scale))
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
} else if let metadata {
|
||||
Text(metadata)
|
||||
.font(.system(size: 12 * scale))
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
|
||||
if let summary {
|
||||
Text(summary)
|
||||
.font(.system(size: 12 * scale))
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
.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: FlaroSpacing.regular) {
|
||||
actions
|
||||
}
|
||||
.padding(.top, FlaroSpacing.tight)
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin * scale)
|
||||
.padding(.bottom, FlaroSpacing.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: FlaroSpacing.snug) {
|
||||
FlaroArtwork(url: artworkURL, aspectRatio: Self.aspectRatio, fallbackSymbol: "play.rectangle")
|
||||
.overlay(alignment: .bottom) {
|
||||
if let progress {
|
||||
WatchProgressBar(fraction: progress)
|
||||
}
|
||||
}
|
||||
.clipShape(.rect(cornerRadius: FlaroRadius.card))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.flaroCardTitle)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.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: FlaroSpacing.snug) {
|
||||
RoundedRectangle(cornerRadius: FlaroRadius.card)
|
||||
.fill(Color.flaroElevated)
|
||||
.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.flaroElevated)
|
||||
.frame(height: 10)
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Color.flaroElevated)
|
||||
.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: FlaroSpacing.snug) {
|
||||
FlaroArtwork(url: artworkURL, aspectRatio: Self.aspectRatio)
|
||||
.clipShape(.rect(cornerRadius: FlaroRadius.card))
|
||||
.overlay(alignment: .bottom) {
|
||||
if let progress {
|
||||
WatchProgressBar(fraction: progress)
|
||||
}
|
||||
}
|
||||
.clipShape(.rect(cornerRadius: FlaroRadius.card))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.flaroCardTitle)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.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.flaroPlaceholder)
|
||||
.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 FlaroDesign 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: FlaroAspectRatio.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: FlaroSpacing.snug) {
|
||||
Button(action: action) {
|
||||
FlaroArtwork(url: artworkURL, aspectRatio: aspectRatio)
|
||||
.overlay(alignment: .bottom) {
|
||||
if let progress { WatchProgressBar(fraction: progress) }
|
||||
}
|
||||
.clipShape(.rect(cornerRadius: FlaroRadius.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(.flaroCardTitle)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.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(\.flaroAccent) 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,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 flaroHero = Font.system(size: 34, weight: .bold)
|
||||
/// Titel auf Detailseiten.
|
||||
public static let flaroTitle = Font.system(size: 22, weight: .semibold)
|
||||
/// Überschrift einer Reihe auf der Startseite.
|
||||
public static let flaroRowHeader = Font.system(size: 17, weight: .semibold)
|
||||
/// Titel unter einem Poster.
|
||||
public static let flaroCardTitle = Font.system(size: 15, weight: .medium)
|
||||
/// Fließtext, Beschreibungen.
|
||||
public static let flaroBody = Font.system(size: 17)
|
||||
/// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht.
|
||||
public static let flaroCaption = 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 flaroHero = Font.system(.title, design: .default, weight: .bold)
|
||||
/// Titel auf Detailseiten.
|
||||
public static let flaroTitle = Font.system(.title2, design: .default, weight: .semibold)
|
||||
/// Überschrift einer Reihe auf der Startseite.
|
||||
public static let flaroRowHeader = 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 flaroCardTitle = Font.system(.caption, design: .default, weight: .medium)
|
||||
/// Fließtext, Beschreibungen.
|
||||
public static let flaroBody = Font.system(.body)
|
||||
/// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht.
|
||||
public static let flaroCaption = Font.system(.caption2)
|
||||
#else
|
||||
/// Der Titel im Aufmacher-Bild.
|
||||
public static let flaroHero = Font.system(.largeTitle, design: .default, weight: .bold)
|
||||
/// Titel auf Detailseiten.
|
||||
public static let flaroTitle = Font.system(.title2, design: .default, weight: .semibold)
|
||||
/// Überschrift einer Reihe auf der Startseite.
|
||||
public static let flaroRowHeader = Font.system(.headline, design: .default, weight: .semibold)
|
||||
/// Titel unter einem Poster.
|
||||
public static let flaroCardTitle = Font.system(.subheadline, design: .default, weight: .medium)
|
||||
/// Fließtext, Beschreibungen.
|
||||
public static let flaroBody = Font.system(.body)
|
||||
/// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht.
|
||||
public static let flaroCaption = 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 FlaroSpacing {
|
||||
#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 `FlaroSpacing.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 FlaroSafeArea {
|
||||
public static let tv = EdgeInsets(top: 60, leading: 80, bottom: 60, trailing: 80)
|
||||
}
|
||||
|
||||
public enum FlaroRadius {
|
||||
/// 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 FlaroAspectRatio {
|
||||
/// 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 FlaroTheme {
|
||||
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: FlaroColor.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,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,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 = FlaroSpacing.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 FlaroPlatform {
|
||||
/// 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("FlaroPlatform 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("FlaroPlatform 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 flaroInlineTitle() -> 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 flaroMediumSheet() -> 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(FlaroPlatform.isMac ? [.large] : [.medium])
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Dasselbe, aber auf Wunsch bis zur vollen Höhe aufziehbar.
|
||||
public func flaroResizableSheet() -> 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(FlaroPlatform.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 flaroForm() -> 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 flaroPlainEntry(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 flaroClearToolbarBackground() -> 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 FlaroClipboard {
|
||||
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 flaroTrailing: ToolbarItemPlacement {
|
||||
#if os(iOS) || os(tvOS)
|
||||
.topBarTrailing
|
||||
#else
|
||||
.primaryAction
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Links in der Titelzeile.
|
||||
public static var flaroLeading: 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.flaro.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 = "flaro.downloads.wifiOnly"
|
||||
static let storageLimit = "flaro.downloads.storageLimit"
|
||||
static let deleteAfter = "flaro.downloads.deleteWatchedAfter"
|
||||
static let keepUnwatched = "flaro.downloads.keepUnwatched"
|
||||
static let maximumConcurrent = "flaro.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 FlaroDownloads, 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: "flaro.network"))
|
||||
}
|
||||
|
||||
deinit {
|
||||
monitor.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Entscheidet, wie ein Titel abgespielt wird.
|
||||
///
|
||||
/// Reine Rechnung ohne Netz und ohne Zustand — deshalb vollständig testbar.
|
||||
/// Die zugehörige Testtabelle in `PlaybackDeciderTests` ist die wichtigste im
|
||||
/// Projekt: sie hält fest, welche Datei auf welchem Weg landet, und schlägt
|
||||
/// an, sobald jemand die Fähigkeitenliste anfasst.
|
||||
public struct PlaybackDecider: Sendable {
|
||||
private let capabilities: DeviceCapabilities
|
||||
|
||||
public init(capabilities: DeviceCapabilities = .avFoundation) {
|
||||
self.capabilities = capabilities
|
||||
}
|
||||
|
||||
public func decide(
|
||||
profile: MediaProfile,
|
||||
policy: PlaybackPolicy
|
||||
) -> PlaybackDecision {
|
||||
var rationale: [PlaybackDecision.Rationale] = []
|
||||
|
||||
// 0. Hat jemand ausdrücklich eine Stufe gewählt, wird umgerechnet —
|
||||
// auch wenn die Datei die Grenze gar nicht überschreitet. Sonst
|
||||
// bliebe die Wahl folgenlos: für eine 8-Mbit-Datei „12 Mbit" zu
|
||||
// wählen ergäbe DirectPlay, und am Bild änderte sich nichts.
|
||||
if policy.forcesTranscoding, policy.allowsTranscoding {
|
||||
rationale.append(.qualityChosen)
|
||||
return PlaybackDecision(
|
||||
method: .transcode(maximumBitrate: policy.maximumBitrate),
|
||||
rationale: rationale
|
||||
)
|
||||
}
|
||||
|
||||
// 1. Reicht die Leitung? Das kommt zuerst, weil es unabhängig vom
|
||||
// Format ist: eine 80-Mbit-Datei über Mobilfunk ist auch dann keine
|
||||
// gute Idee, wenn AVPlayer sie problemlos öffnen könnte.
|
||||
if let limit = policy.maximumBitrate, let bitrate = profile.bitrate, bitrate > limit {
|
||||
rationale.append(.bitrateExceedsLimit(mediaBitrate: bitrate, limit: limit))
|
||||
if policy.allowsTranscoding {
|
||||
return PlaybackDecision(method: .transcode(maximumBitrate: limit), rationale: rationale)
|
||||
}
|
||||
// Ohne Transcoder bleibt nur, es trotzdem zu versuchen. Das ist
|
||||
// ehrlicher als eine Fehlermeldung: im WLAN klappt es meistens,
|
||||
// und der Grund steht in der Begründung.
|
||||
rationale.append(.transcodingUnavailable)
|
||||
}
|
||||
|
||||
// 2. Kennt der Server die Datei überhaupt?
|
||||
let container = normalized(profile.container)
|
||||
guard profile.hasKnownStreams, let videoCodec = normalized(profile.videoCodec) else {
|
||||
rationale.append(.streamsUnknown)
|
||||
return fallback(policy: policy, rationale: rationale)
|
||||
}
|
||||
|
||||
// 3. Kann AVPlayer das?
|
||||
var blockers: [PlaybackDecision.Rationale] = []
|
||||
if let container, !capabilities.containers.contains(container) {
|
||||
blockers.append(.containerUnsupported(container))
|
||||
}
|
||||
if !capabilities.videoCodecs.contains(videoCodec) {
|
||||
blockers.append(.videoCodecUnsupported(videoCodec))
|
||||
}
|
||||
// Geprüft wird die Spur, die tatsächlich klingen soll — welche das
|
||||
// ist, hat der Aufrufer beim Bauen des Profils entschieden.
|
||||
if let audioCodec = normalized(profile.audioCodec),
|
||||
!capabilities.audioCodecs.contains(audioCodec) {
|
||||
blockers.append(.audioCodecUnsupported(audioCodec))
|
||||
}
|
||||
|
||||
guard blockers.isEmpty else {
|
||||
rationale.append(contentsOf: blockers)
|
||||
|
||||
// **Solange das Bild durchgereicht werden kann, hilft der Server.**
|
||||
//
|
||||
// Zwei Fälle, und beide enden hier. Steht nur die Schachtel im
|
||||
// Weg — Matroska aussen, H.264 oder HEVC innen, AC3 dazu —, wird
|
||||
// umgepackt: dieselben Ströme, andere Verpackung, nichts geht
|
||||
// verloren. Steht der **Ton** im Weg — DTS, TrueHD, Vorbis —,
|
||||
// rechnet der Server genau diese eine Spur um und kopiert das
|
||||
// Bild. Beides zusammen geht ebenso.
|
||||
//
|
||||
// Das kostet Tonqualität, und zwar nur beim Ton. Dagegen steht,
|
||||
// was es einbringt: der Titel läuft überhaupt — mit Bild-in-Bild,
|
||||
// AirPlay und Sperrbildschirm, und ohne dass das ganze Bild neu
|
||||
// gerechnet werden muss.
|
||||
//
|
||||
// Stimmt dagegen der **Videocodec** nicht, ist hier Schluss: dann
|
||||
// fehlt nicht der Zugang, sondern das Können, und jeder Weg über
|
||||
// den Server hiesse, jedes Bild neu zu rechnen.
|
||||
let bildBleibt = blockers.allSatisfy {
|
||||
switch $0 {
|
||||
case .containerUnsupported, .audioCodecUnsupported: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
// Umpackbar muss die Schachtel nur sein, wenn sie überhaupt im
|
||||
// Weg steht. Eine MP4 mit DTS-Ton bleibt die MP4, die sie ist —
|
||||
// sie an dieser Liste zu messen, hiesse sie an einer Frage
|
||||
// scheitern zu lassen, die sich gar nicht stellt.
|
||||
let schachtelTraegt = container.map {
|
||||
capabilities.containers.contains($0)
|
||||
|| DeviceCapabilities.remuxableContainers.contains($0)
|
||||
} ?? true
|
||||
if bildBleibt, schachtelTraegt, policy.allowsTranscoding {
|
||||
return PlaybackDecision(method: .remux, rationale: rationale)
|
||||
}
|
||||
|
||||
return fallback(policy: policy, rationale: rationale)
|
||||
}
|
||||
|
||||
rationale.append(.nativelySupported)
|
||||
return PlaybackDecision(method: .directPlay, rationale: rationale)
|
||||
}
|
||||
|
||||
/// Was passiert, wenn AVPlayer nicht kann.
|
||||
///
|
||||
/// **Hier stand einmal eine zweite Engine.** VLCKit hatte Vorrang vor dem
|
||||
/// Transcoder: die Datei ging unverändert über die Leitung, der Server
|
||||
/// musste nicht rechnen. Es gibt sie nicht mehr — was der Systemplayer
|
||||
/// nicht öffnet, rechnet der Server um, und was heruntergeladen wird,
|
||||
/// kommt schon in einer Hülle, die er öffnet.
|
||||
private func fallback(
|
||||
policy: PlaybackPolicy,
|
||||
rationale: [PlaybackDecision.Rationale]
|
||||
) -> PlaybackDecision {
|
||||
if policy.allowsTranscoding {
|
||||
return PlaybackDecision(
|
||||
method: .transcode(maximumBitrate: policy.maximumBitrate),
|
||||
rationale: rationale
|
||||
)
|
||||
}
|
||||
var rationale = rationale
|
||||
rationale.append(.transcodingUnavailable)
|
||||
return PlaybackDecision(method: .directPlay, rationale: rationale)
|
||||
}
|
||||
|
||||
/// Plex schreibt Codecs mal groß, mal klein, und hängt bei Containern
|
||||
/// gelegentlich Punkte an. Ein Vergleich ohne diese Angleichung geht
|
||||
/// sporadisch schief — und zwar nur bei manchen Dateien, was die Suche
|
||||
/// nach dem Grund unangenehm macht.
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
guard let value, !value.isEmpty else { return nil }
|
||||
return value.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ". "))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
/// Wie ein Titel abgespielt werden soll.
|
||||
public struct PlaybackDecision: Sendable, Equatable {
|
||||
public enum Method: Sendable, Equatable {
|
||||
/// Die Datei wird unverändert übertragen und lokal dekodiert.
|
||||
case directPlay
|
||||
/// Der Server reicht das Bild durch und legt es in eine andere
|
||||
/// Schachtel — den Ton rechnet er um, falls nötig.
|
||||
///
|
||||
/// **Der dritte Weg, und der wichtigste für diesen Bestand.** Ein
|
||||
/// Blu-ray-Rip liegt als Matroska vor, und AVFoundation öffnet
|
||||
/// Matroska nicht — obwohl das Bild darin H.264 oder HEVC ist und der
|
||||
/// Ton AAC oder AC3, also alles, was AVPlayer bestens kann. Im Weg
|
||||
/// steht allein die Verpackung.
|
||||
///
|
||||
/// **Das Bild wird dabei nie angefasst.** Es geht Byte für Byte in
|
||||
/// eine MP4, und das kostet den Server fast nichts — anders als beim
|
||||
/// Neurechnen.
|
||||
///
|
||||
/// **Der Ton kann es müssen.** Steht DTS, TrueHD oder Vorbis darin,
|
||||
/// kennt AVPlayer die Spur nicht; der Server kodiert dann genau diese
|
||||
/// eine Spur nach AC3 oder AAC. Das kostet Tonqualität — und sonst
|
||||
/// nichts, vor allem keine einzige Bildzeile. Ob es im Einzelfall
|
||||
/// passiert ist, steht in `rationale`: `audioCodecUnsupported`.
|
||||
///
|
||||
/// Der Gewinn ist gross: solche Titel laufen damit im Systemplayer,
|
||||
/// mit Bild-in-Bild, AirPlay und Systembedienung — und es muss nicht
|
||||
/// das ganze Bild neu gerechnet werden.
|
||||
case remux
|
||||
/// Der Server rechnet um. Ab Phase 2; `maximumBitrate` in Kilobit.
|
||||
case transcode(maximumBitrate: Int?)
|
||||
}
|
||||
|
||||
/// Warum es so entschieden wurde.
|
||||
///
|
||||
/// Wandert in die technischen Infos auf der Detailseite und ins Protokoll.
|
||||
/// Ohne diese Begründung ist „warum ruckelt das" nicht zu beantworten.
|
||||
public enum Rationale: Sendable, Equatable {
|
||||
case nativelySupported
|
||||
case containerUnsupported(String)
|
||||
case videoCodecUnsupported(String)
|
||||
case audioCodecUnsupported(String)
|
||||
/// Der Server hat die Datei noch nicht analysiert.
|
||||
case streamsUnknown
|
||||
case bitrateExceedsLimit(mediaBitrate: Int, limit: Int)
|
||||
/// Transcoding wäre passender, ist aber (noch) nicht verfügbar.
|
||||
case transcodingUnavailable
|
||||
/// Im Player ausdrücklich eine Stufe gewählt.
|
||||
case qualityChosen
|
||||
}
|
||||
|
||||
public let method: Method
|
||||
public let rationale: [Rationale]
|
||||
|
||||
public init(method: Method, rationale: [Rationale]) {
|
||||
self.method = method
|
||||
self.rationale = rationale
|
||||
}
|
||||
|
||||
public var isDirectPlay: Bool {
|
||||
if case .directPlay = method { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
/// Was bei der Wiedergabe erlaubt ist.
|
||||
///
|
||||
/// Das ist die Stellschraube, an der Phase 2 andockt: heute steht
|
||||
/// `allowsTranscoding` auf `false` und der Decider kommt zwangsläufig immer
|
||||
/// bei DirectPlay heraus. Sobald der Transcoder steht, wird hier umgelegt —
|
||||
/// die Views merken davon nichts.
|
||||
public struct PlaybackPolicy: Sendable, Equatable {
|
||||
/// Darf der Server umrechnen?
|
||||
public var allowsTranscoding: Bool
|
||||
/// Obergrenze in Kilobit pro Sekunde. `nil` heißt unbegrenzt — der
|
||||
/// Normalfall im heimischen WLAN.
|
||||
public var maximumBitrate: Int?
|
||||
/// Umrechnen, auch wenn die Datei die Grenze gar nicht überschreitet.
|
||||
///
|
||||
/// Für die Wahl im Player. Ohne das verpuffte sie: wer für eine
|
||||
/// 8-Mbit-Datei „12 Mbit" wählt, bekam DirectPlay, weil die Datei ja
|
||||
/// unter der Grenze liegt — die Wahl blieb folgenlos. Gemeint ist aber
|
||||
/// „rechne es mir auf diese Stufe um".
|
||||
public var forcesTranscoding: Bool
|
||||
|
||||
public init(
|
||||
allowsTranscoding: Bool,
|
||||
maximumBitrate: Int? = nil,
|
||||
forcesTranscoding: Bool = false
|
||||
) {
|
||||
self.allowsTranscoding = allowsTranscoding
|
||||
self.maximumBitrate = maximumBitrate
|
||||
self.forcesTranscoding = forcesTranscoding
|
||||
}
|
||||
|
||||
/// Phase 1: alles direkt, nichts umrechnen.
|
||||
public static let directPlayOnly = PlaybackPolicy(allowsTranscoding: false)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine im Player wählbare Qualitätsstufe.
|
||||
///
|
||||
/// Getrennt von den Einstellungen unter „Qualität": dort steht, was
|
||||
/// üblicherweise gelten soll; hier greift man für diesen einen Film ein — weil
|
||||
/// es gerade ruckelt oder weil man ausnahmsweise das Original will.
|
||||
public enum PlayerQuality: Hashable, Sendable, Identifiable {
|
||||
/// Was in den Einstellungen für die aktuelle Verbindung hinterlegt ist.
|
||||
case automatic
|
||||
/// Die Datei unverändert, ohne Umrechnung.
|
||||
case original
|
||||
/// Eine Stufe der Leiter, die der Server anbietet.
|
||||
case limited(Rung)
|
||||
/// So wenig Bild wie möglich.
|
||||
case audioOnly
|
||||
|
||||
/// Eine Sprosse: Bitrate und die Auflösung, die Plex dafür vorsieht.
|
||||
///
|
||||
/// Die Werte stammen nicht aus der Luft, sondern aus dem, was der Server
|
||||
/// über sich selbst sagt (`transcoderVideoBitrates`). Eine eigene Leiter
|
||||
/// zu erfinden hieße, Stufen anzubieten, die der Transcoder gar nicht
|
||||
/// kennt — er rundet dann still auf die nächste, und die Anzeige log.
|
||||
public struct Rung: Hashable, Sendable {
|
||||
public let bitrate: Int
|
||||
public let height: Int
|
||||
|
||||
public init(bitrate: Int, height: Int) {
|
||||
self.bitrate = bitrate
|
||||
self.height = height
|
||||
}
|
||||
|
||||
public var width: Int {
|
||||
// 16:9, auf gerade Zahlen gerundet — ungerade Kantenlängen mag
|
||||
// kein Kodierer.
|
||||
let raw = Int((Double(height) * 16.0 / 9.0).rounded())
|
||||
return raw % 2 == 0 ? raw : raw + 1
|
||||
}
|
||||
|
||||
public var resolution: String { "\(width)x\(height)" }
|
||||
}
|
||||
|
||||
/// Die Leiter von oben nach unten.
|
||||
public static let ladder: [Rung] = [
|
||||
Rung(bitrate: 20_000, height: 1080),
|
||||
Rung(bitrate: 12_000, height: 1080),
|
||||
Rung(bitrate: 10_000, height: 1080),
|
||||
Rung(bitrate: 8_000, height: 1080),
|
||||
Rung(bitrate: 4_000, height: 1080),
|
||||
Rung(bitrate: 3_000, height: 720),
|
||||
Rung(bitrate: 2_000, height: 720),
|
||||
Rung(bitrate: 1_500, height: 720),
|
||||
Rung(bitrate: 720, height: 480),
|
||||
Rung(bitrate: 320, height: 360),
|
||||
Rung(bitrate: 208, height: 360),
|
||||
Rung(bitrate: 96, height: 240),
|
||||
Rung(bitrate: 64, height: 160),
|
||||
]
|
||||
|
||||
/// Eine Zahl, die sich aufbewahren lässt.
|
||||
///
|
||||
/// Über mehrere Filme hinweg gilt nur die **Absicht** — „ungefähr so viel
|
||||
/// Bitrate" —, nicht die genaue Sprosse: welche Sprossen es gibt, hängt
|
||||
/// von der Quelle ab. Deshalb eine Zahl und kein Aufzählungswert.
|
||||
public var storageValue: Int {
|
||||
switch self {
|
||||
case .automatic: 0
|
||||
case .original: -1
|
||||
case .audioOnly: -2
|
||||
case .limited(let rung): rung.bitrate
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Stufe zu einer aufbewahrten Zahl, so nah wie möglich.
|
||||
///
|
||||
/// Gibt es die Sprosse für diese Quelle nicht, wird die nächstgelegene
|
||||
/// genommen — eine 720p-Datei kennt keine 20-Megabit-Stufe, und dann
|
||||
/// stillschweigend auf „automatisch" zurückzufallen wäre eine andere
|
||||
/// Antwort als die, um die gebeten wurde.
|
||||
public static func restored(from value: Int, forSourceHeight height: Int?) -> PlayerQuality? {
|
||||
switch value {
|
||||
case 0: return nil
|
||||
case -1: return .original
|
||||
case -2: return .audioOnly
|
||||
default: break
|
||||
}
|
||||
|
||||
let rungs = available(forSourceHeight: height).compactMap { quality -> Rung? in
|
||||
if case .limited(let rung) = quality { return rung }
|
||||
return nil
|
||||
}
|
||||
guard let closest = rungs.min(by: {
|
||||
abs($0.bitrate - value) < abs($1.bitrate - value)
|
||||
}) else { return nil }
|
||||
return .limited(closest)
|
||||
}
|
||||
|
||||
public static var allCases: [PlayerQuality] {
|
||||
[.automatic, .original] + ladder.map(PlayerQuality.limited) + [.audioOnly]
|
||||
}
|
||||
|
||||
public var id: String {
|
||||
switch self {
|
||||
case .automatic: "automatic"
|
||||
case .original: "original"
|
||||
case .audioOnly: "audioOnly"
|
||||
case .limited(let rung): "limited-\(rung.bitrate)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Obergrenze in Kilobit. `nil` bei `automatic` und `original`.
|
||||
public var maximumBitrate: Int? {
|
||||
switch self {
|
||||
case .automatic, .original: nil
|
||||
case .limited(let rung): rung.bitrate
|
||||
// Plex kennt für Videos keinen reinen Tonstrom. Die unterste Sprosse
|
||||
// ist das, was dem am nächsten kommt; das Bild wird dazu im Player
|
||||
// ausgeblendet, damit der Schirm dunkel bleiben kann.
|
||||
case .audioOnly: Self.ladder.last?.bitrate
|
||||
}
|
||||
}
|
||||
|
||||
public var resolution: String? {
|
||||
switch self {
|
||||
case .automatic, .original: nil
|
||||
case .limited(let rung): rung.resolution
|
||||
case .audioOnly: Self.ladder.last?.resolution
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Höhe in Bildzeilen, sofern die Stufe eine vorgibt.
|
||||
public var height: Int? {
|
||||
switch self {
|
||||
case .automatic, .original: nil
|
||||
case .limited(let rung): rung.height
|
||||
case .audioOnly: Self.ladder.last?.height
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob das Bild gezeigt werden soll.
|
||||
public var showsVideo: Bool {
|
||||
if case .audioOnly = self { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Ob diese Stufe eine Umrechnung erzwingt.
|
||||
///
|
||||
/// `original` erzwingt das Gegenteil: keine Umrechnung, auch wenn die
|
||||
/// Einstellungen eine Grenze vorsehen.
|
||||
public var forcesTranscode: Bool {
|
||||
switch self {
|
||||
case .automatic, .original: false
|
||||
case .limited, .audioOnly: true
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Richtlinie für diese Stufe.
|
||||
///
|
||||
/// `automatic` reicht durch, was ohnehin gilt. Die übrigen überschreiben
|
||||
/// sie — das ist der Zweck eines Eingriffs im Player.
|
||||
public func policy(basedOn base: PlaybackPolicy) -> PlaybackPolicy {
|
||||
switch self {
|
||||
case .automatic:
|
||||
base
|
||||
case .original:
|
||||
// Keine Grenze und kein Umrechnen: ausdrücklich das Original.
|
||||
PlaybackPolicy(allowsTranscoding: false, maximumBitrate: nil)
|
||||
case .limited, .audioOnly:
|
||||
PlaybackPolicy(
|
||||
allowsTranscoding: true,
|
||||
maximumBitrate: maximumBitrate,
|
||||
forcesTranscoding: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die kurze Liste: je Auflösung eine Stufe, und zwar eine brauchbare.
|
||||
///
|
||||
/// Dreizehn Sprossen sind für den Normalfall zu viel — man will „geht
|
||||
/// gerade nicht flüssig, nimm eine Stufe kleiner", nicht die Wahl
|
||||
/// zwischen zehn und zwölf Megabit. Die vollständige Leiter bleibt
|
||||
/// erreichbar, steht aber nicht im Weg.
|
||||
public static let everyday: [Rung] = [
|
||||
Rung(bitrate: 8_000, height: 1080),
|
||||
Rung(bitrate: 3_000, height: 720),
|
||||
Rung(bitrate: 720, height: 480),
|
||||
]
|
||||
|
||||
/// Die kurze Auswahl für eine Quelle dieser Höhe.
|
||||
public static func everyday(forSourceHeight height: Int?) -> [PlayerQuality] {
|
||||
let rungs = everyday.filter { height == nil || $0.height <= (height ?? 0) }
|
||||
let usable = rungs.isEmpty ? Array(everyday.suffix(1)) : rungs
|
||||
return [.automatic, .original] + usable.map(PlayerQuality.limited) + [.audioOnly]
|
||||
}
|
||||
|
||||
/// Nur die Stufen, die für eine Quelle dieser Höhe sinnvoll sind.
|
||||
///
|
||||
/// Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein Bild dazu, und
|
||||
/// die Stufen sähen alle gleich aus — dieselbe Falle wie beim Herunterladen.
|
||||
public static func available(forSourceHeight height: Int?) -> [PlayerQuality] {
|
||||
guard let height, height > 0 else { return allCases }
|
||||
let fitting = ladder.filter { $0.height <= height }
|
||||
// Mindestens die unterste Sprosse anbieten, sonst bliebe bei einer
|
||||
// sehr kleinen Quelle nur „Original".
|
||||
let rungs = fitting.isEmpty ? Array(ladder.suffix(1)) : fitting
|
||||
return [.automatic, .original] + rungs.map(PlayerQuality.limited) + [.audioOnly]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import Foundation
|
||||
|
||||
/// Buch über das Stocken, mit gleitendem Fenster.
|
||||
///
|
||||
/// **Warum es das gibt.** Der Watchdog im Player kannte nur eine Frage: steht
|
||||
/// es seit vierzig Sekunden am Stück? Bei jedem kurzen Erholen setzte er sich
|
||||
/// zurück — zehn Rückler à dreißig Sekunden lösten nichts aus, obwohl die
|
||||
/// halbe Stunde unbrauchbar war. Genau dieser Fall ist der, über den geklagt
|
||||
/// wird: nicht der tote Stream, sondern der, der immer wieder hakt.
|
||||
///
|
||||
/// **Warum als eigener Typ.** Keine Uhr, kein AVFoundation, kein Netz — die
|
||||
/// Zeiten kommen von außen herein. Damit lässt sich eine Folge von Rücklern
|
||||
/// als Zahlenreihe hinschreiben und das Urteil prüfen. Eine Regel, die man
|
||||
/// nur im laufenden Film beobachten kann, ist keine Regel, sondern ein
|
||||
/// Eindruck.
|
||||
///
|
||||
/// Alle Zeitpunkte sind **seit Beginn dieser Wiedergabe** gemessen, nicht als
|
||||
/// Datum. Das macht die Sache gegen Zeitzonen, Sommerzeit und eine Systemuhr,
|
||||
/// die sich mitten im Film stellt, gleichgültig.
|
||||
public struct StallLedger: Sendable, Equatable {
|
||||
public struct Episode: Sendable, Equatable {
|
||||
/// Wann dieser Rückler vorbei war.
|
||||
public let endedAt: Duration
|
||||
public let length: Duration
|
||||
|
||||
public init(endedAt: Duration, length: Duration) {
|
||||
self.endedAt = endedAt
|
||||
self.length = length
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie weit zurück gezählt wird.
|
||||
///
|
||||
/// Drei Minuten: lang genug, dass ein Muster erkennbar wird, kurz genug,
|
||||
/// dass eine überstandene Durststrecke nicht noch eine Viertelstunde
|
||||
/// später nachwirkt.
|
||||
public let window: Duration
|
||||
|
||||
private(set) var episodes: [Episode] = []
|
||||
|
||||
public init(window: Duration = .seconds(180)) {
|
||||
self.window = window
|
||||
}
|
||||
|
||||
/// Trägt einen überstandenen Rückler ein.
|
||||
public mutating func record(_ episode: Episode) {
|
||||
episodes.append(episode)
|
||||
prune(at: episode.endedAt)
|
||||
}
|
||||
|
||||
/// Wie viele Rückler im Fenster liegen.
|
||||
public func count(at now: Duration) -> Int {
|
||||
episodes.count { $0.endedAt >= now - window }
|
||||
}
|
||||
|
||||
/// Welcher Anteil des Fensters mit Warten verbracht wurde — 0 bis 1.
|
||||
///
|
||||
/// Der laufende Rückler zählt mit, sofern er übergeben wird: wer mitten
|
||||
/// im dritten Stocken steht, soll nicht erst nach dessen Ende erfahren,
|
||||
/// dass es zu viel war.
|
||||
public func share(at now: Duration, ongoing: Duration = .zero) -> Double {
|
||||
let horizon = now - window
|
||||
let waited = episodes
|
||||
.filter { $0.endedAt >= horizon }
|
||||
.reduce(Duration.zero) { $0 + $1.length } + ongoing
|
||||
|
||||
// Am Anfang ist das Fenster noch nicht voll. Dann gegen die
|
||||
// tatsächlich vergangene Zeit rechnen, sonst sähe eine Wiedergabe,
|
||||
// die nach zwanzig Sekunden fünf Sekunden hakte, mit 3 % harmlos aus
|
||||
// statt mit 25 %.
|
||||
let elapsed = min(now, window)
|
||||
guard elapsed > .zero else { return 0 }
|
||||
return min(1, waited / elapsed)
|
||||
}
|
||||
|
||||
/// Der längste einzelne Rückler im Fenster.
|
||||
///
|
||||
/// Ein einzelner langer ist etwas anderes als viele kurze: der eine sagt
|
||||
/// „die Leitung war weg", die vielen sagen „sie trägt es dauerhaft nicht".
|
||||
/// Beide sollen auslösen dürfen, aber nicht über dieselbe Schwelle.
|
||||
public func longest(at now: Duration) -> Duration {
|
||||
episodes
|
||||
.filter { $0.endedAt >= now - window }
|
||||
.map(\.length)
|
||||
.max() ?? .zero
|
||||
}
|
||||
|
||||
/// Wann der letzte Rückler zu Ende war.
|
||||
///
|
||||
/// `nil`, wenn es im Fenster keinen gab — und das ist etwas anderes als
|
||||
/// „vor langer Zeit": wer noch nie gestockt hat, wartet auf keine Frist.
|
||||
///
|
||||
/// Gebraucht fürs Hinaufschalten: wer gerade gestockt hat, hat keine
|
||||
/// Reserve, und ein Neuaufbau nach oben wäre die zweite Unterbrechung
|
||||
/// binnen einer Minute.
|
||||
public func lastStall(at now: Duration) -> Duration? {
|
||||
episodes
|
||||
.filter { $0.endedAt >= now - window }
|
||||
.map(\.endedAt)
|
||||
.max()
|
||||
}
|
||||
|
||||
/// Wirft heraus, was aus dem Fenster gefallen ist.
|
||||
public mutating func prune(at now: Duration) {
|
||||
let horizon = now - window
|
||||
episodes.removeAll { $0.endedAt < horizon }
|
||||
}
|
||||
|
||||
public mutating func removeAll() {
|
||||
episodes.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private extension Duration {
|
||||
/// Anteil zweier Dauern. `Duration` kann sich nicht selbst teilen.
|
||||
static func / (lhs: Duration, rhs: Duration) -> Double {
|
||||
let left = Double(lhs.components.seconds) + Double(lhs.components.attoseconds) / 1e18
|
||||
let right = Double(rhs.components.seconds) + Double(rhs.components.attoseconds) / 1e18
|
||||
guard right != 0 else { return 0 }
|
||||
return left / right
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine Probe vom laufenden Stream.
|
||||
///
|
||||
/// Alle Zeiten **seit Beginn dieser Wiedergabe**, nicht als Datum — dasselbe
|
||||
/// wie beim `StallLedger`, und aus demselben Grund: eine Systemuhr, die sich
|
||||
/// mitten im Film stellt, darf das Urteil nicht kippen.
|
||||
public struct StreamSample: Sendable, Equatable {
|
||||
public let at: Duration
|
||||
/// Wie weit der Puffer vor der Abspielstelle reicht. `nil` heißt
|
||||
/// „noch nichts geladen" — etwas anderes als ein leerer Puffer.
|
||||
public let bufferedAhead: Duration?
|
||||
public let isLikelyToKeepUp: Bool?
|
||||
/// Der fortlaufende Zähler, nicht die Zahl seit der letzten Probe.
|
||||
public let stallCount: Int
|
||||
/// Auf welcher Sprosse gerade gespielt wird, in Kilobit je Sekunde.
|
||||
public let indicatedBitrate: Int?
|
||||
|
||||
public init(
|
||||
at: Duration,
|
||||
bufferedAhead: Duration? = nil,
|
||||
isLikelyToKeepUp: Bool? = nil,
|
||||
stallCount: Int = 0,
|
||||
indicatedBitrate: Int? = nil
|
||||
) {
|
||||
self.at = at
|
||||
self.bufferedAhead = bufferedAhead
|
||||
self.isLikelyToKeepUp = isLikelyToKeepUp
|
||||
self.stallCount = stallCount
|
||||
self.indicatedBitrate = indicatedBitrate
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie es dem Stream geht.
|
||||
public enum StreamCondition: Sendable, Equatable {
|
||||
/// Trägt. Es darf wieder eine Stufe höher gehen.
|
||||
case healthy
|
||||
/// Wird knapp. Eine Stufe tiefer, bevor das Bild steht.
|
||||
case strained
|
||||
/// Trägt nicht. So tief wie möglich.
|
||||
case failing
|
||||
}
|
||||
|
||||
/// Beurteilt den Stream aus einer Folge von Proben.
|
||||
///
|
||||
/// **Warum als reiner Typ.** Kein AVFoundation, keine Uhr, kein Netz — Proben
|
||||
/// hinein, Urteil heraus. Dieselbe Bauform wie `PlaybackDecider`, und aus
|
||||
/// demselben Grund: eine Regelung, die man nur im laufenden Film beobachten
|
||||
/// kann, ist nicht zu beurteilen. Hier lässt sich eine schlechte Zugfahrt als
|
||||
/// Zahlenreihe hinschreiben und nachsehen, was sie auslöst.
|
||||
///
|
||||
/// **Welches Signal wann meldet** — das ist der eigentliche Inhalt:
|
||||
///
|
||||
/// - **Der Pufferverlauf ist der einzige echte Frühwarner.** Schrumpft er
|
||||
/// schneller, als die Zeit vergeht, kommt weniger herein als hinausgeht.
|
||||
/// Das sieht man zehn bis zwanzig Sekunden vor dem Rückler.
|
||||
/// - `isLikelyToKeepUp` ist früh, aber grob: Apple legt es um, wenn es sich
|
||||
/// schon sorgt. Als Bestätigung gut, als alleiniger Auslöser zu sprunghaft.
|
||||
/// - Der Rücklerzähler meldet **zu spät zum Regeln** — das Bild stand bereits.
|
||||
/// Er ist trotzdem unverzichtbar: an ihm misst sich hinterher, ob es besser
|
||||
/// wurde.
|
||||
/// - Die **gemessene** Bitrate steht hier bewusst nicht. Auf einer kleinen
|
||||
/// Sprosse holt der Player wenig, ohne dass die Leitung schlecht wäre —
|
||||
/// als Kriterium wäre sie irreführend.
|
||||
///
|
||||
/// **Absichtlich unsymmetrisch: schnell runter, langsam rauf.** Ein falscher
|
||||
/// Schritt nach unten kostet Schärfe, ein falscher nach oben einen Rückler.
|
||||
public struct StreamHealth: Sendable, Equatable {
|
||||
/// Wie viel Puffer als knapp gilt.
|
||||
public static let lowBuffer: Duration = .seconds(6)
|
||||
/// Ab wann er als reichlich gilt.
|
||||
public static let goodBuffer: Duration = .seconds(20)
|
||||
|
||||
private var samples: [StreamSample] = []
|
||||
private(set) public var condition: StreamCondition = .healthy
|
||||
/// Seit wann es ununterbrochen gut aussieht. `nil`, sobald es das nicht tut.
|
||||
private var healthySince: Duration?
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Nimmt eine Probe und gibt das aktuelle Urteil zurück.
|
||||
@discardableResult
|
||||
public mutating func record(_ sample: StreamSample) -> StreamCondition {
|
||||
samples.append(sample)
|
||||
samples.removeAll { $0.at < sample.at - .seconds(60) }
|
||||
|
||||
condition = judge(at: sample)
|
||||
return condition
|
||||
}
|
||||
|
||||
public mutating func removeAll() {
|
||||
samples.removeAll()
|
||||
healthySince = nil
|
||||
condition = .healthy
|
||||
}
|
||||
|
||||
// MARK: - Das Urteil
|
||||
|
||||
private mutating func judge(at now: StreamSample) -> StreamCondition {
|
||||
// **Zuerst das Schlimmste.** Zwei Rückler in einer Minute heißen: es
|
||||
// war kein Ausrutscher.
|
||||
if stalls(within: .seconds(60), at: now) >= 2 {
|
||||
healthySince = nil
|
||||
return .failing
|
||||
}
|
||||
// Puffer auf null, während gespielt wird. `nil` zählt nicht — das
|
||||
// heißt „noch nichts geladen" und ist der Zustand beim Start.
|
||||
if let buffer = now.bufferedAhead, buffer == .zero {
|
||||
healthySince = nil
|
||||
return .failing
|
||||
}
|
||||
|
||||
if isStrained(at: now) {
|
||||
healthySince = nil
|
||||
return .strained
|
||||
}
|
||||
|
||||
// **Der Weg zurück ist der schmalste.** Reichlich Puffer allein
|
||||
// genügt nicht: er muss eine halbe Minute lang reichlich geblieben
|
||||
// sein, und in der letzten Minute darf es nicht gehakt haben. Sonst
|
||||
// pendelt die Stufe im Takt der Aussetzer, und das ist schlimmer als
|
||||
// eine Stufe zu niedrig zu bleiben.
|
||||
guard let buffer = now.bufferedAhead, buffer >= Self.goodBuffer else {
|
||||
healthySince = nil
|
||||
return condition == .healthy ? .healthy : condition
|
||||
}
|
||||
|
||||
let seit = healthySince ?? now.at
|
||||
healthySince = seit
|
||||
|
||||
let langGenug = now.at - seit >= .seconds(30)
|
||||
let ruhig = stalls(within: .seconds(60), at: now) == 0
|
||||
guard langGenug, ruhig else {
|
||||
return condition == .healthy ? .healthy : condition
|
||||
}
|
||||
return .healthy
|
||||
}
|
||||
|
||||
private func isStrained(at now: StreamSample) -> Bool {
|
||||
// Knapp **und** fallend. Ein kleiner Puffer, der stabil bleibt, ist
|
||||
// kein Alarm — er ist nur klein.
|
||||
if let buffer = now.bufferedAhead, buffer < Self.lowBuffer, isFalling(at: now) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Apples eigene Sorge, aber erst wenn sie anhält: ein einzelner
|
||||
// Ausschlag kommt bei jedem Sprung vor.
|
||||
let letzte = samples.filter { $0.at >= now.at - .seconds(3) }
|
||||
if letzte.count >= 3, letzte.allSatisfy({ $0.isLikelyToKeepUp == false }) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Ob der Puffer über die letzten Sekunden hinweg schrumpft.
|
||||
private func isFalling(at now: StreamSample) -> Bool {
|
||||
let fenster = samples.filter { $0.at >= now.at - .seconds(5) }
|
||||
guard fenster.count >= 3,
|
||||
let ältester = fenster.first?.bufferedAhead,
|
||||
let jüngster = fenster.last?.bufferedAhead
|
||||
else { return false }
|
||||
return jüngster < ältester
|
||||
}
|
||||
|
||||
/// Wie viele Rückler im Zeitfenster dazugekommen sind.
|
||||
///
|
||||
/// Aus der **Differenz** des fortlaufenden Zählers, nicht aus einer
|
||||
/// eigenen Zählung: der Zähler kommt vom System und weiß auch von
|
||||
/// Rücklern zwischen zwei Proben.
|
||||
private func stalls(within window: Duration, at now: StreamSample) -> Int {
|
||||
let fenster = samples.filter { $0.at >= now.at - window }
|
||||
guard let erster = fenster.first else { return 0 }
|
||||
return max(0, now.stallCount - erster.stallCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Was in einem Spurenmenü steht — und was ein Umschalten kostet.
|
||||
///
|
||||
/// **Warum es diesen Typ gibt.** Auf dem Fernseher sitzt die Spurwahl in
|
||||
/// Apples Transportleiste, und die nimmt `UIMenu`-Einträge entgegen. Die
|
||||
/// Abbildung von Spuren auf Einträge hat mit UIKit aber nichts zu tun: sie
|
||||
/// beantwortet, welche Spuren zur Wahl stehen, welche gerade gilt, und — das
|
||||
/// ist der Teil, der sonst nirgends steht — **ob das Umschalten Sekunden mit
|
||||
/// schwarzem Bild kostet**.
|
||||
///
|
||||
/// Diese Regel lag bisher nur mitten im Vollzug von
|
||||
/// `PlayerModel.selectAudioStream` und war damit weder ablesbar noch prüfbar:
|
||||
/// auslösen lässt sie sich nur an einem echten Server mit einer Datei, die
|
||||
/// mehrere Tonspuren hat. Hier steht sie als Auskunft — und die Oberfläche
|
||||
/// kann sie **vorher** anzeigen, statt den Nutzer hinterher vor ein schwarzes
|
||||
/// Bild zu setzen.
|
||||
///
|
||||
/// Liegt neben `AudioTrackChooser` und `DowngradeRule`, weil es dasselbe ist:
|
||||
/// eine Regel über die Wiedergabe, ohne Oberfläche und ohne Netz.
|
||||
public enum TrackMenu {
|
||||
public struct Entry: Equatable, Sendable, Identifiable {
|
||||
/// Der Index der Spur beim Server. `nil` heisst „Aus".
|
||||
public let id: Int?
|
||||
public let title: String
|
||||
public let isSelected: Bool
|
||||
/// Ob das Umschalten den Stream neu aufbaut.
|
||||
public let rebuilds: Bool
|
||||
|
||||
public init(id: Int?, title: String, isSelected: Bool, rebuilds: Bool) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.isSelected = isSelected
|
||||
self.rebuilds = rebuilds
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Tonspuren **der Datei**, nicht die des Players.
|
||||
///
|
||||
/// Rechnet der Server um, kennt der Player genau eine Spur — die, für die
|
||||
/// er sich entschieden hat. Zur Wahl steht, was in der Datei liegt.
|
||||
///
|
||||
/// - Parameter playerTrackCount: Wie viele Tonspuren der Player meldet.
|
||||
/// Weicht die Zahl ab, stimmt die Zuordnung über die Reihenfolge nicht,
|
||||
/// und es muss neu aufgebaut werden — lieber eine Unterbrechung als die
|
||||
/// falsche Sprache.
|
||||
public static func audio(
|
||||
from streams: [MediaStream],
|
||||
selected: MediaStream?,
|
||||
isDirectPlay: Bool,
|
||||
playerTrackCount: Int
|
||||
) -> [Entry] {
|
||||
let billig = isDirectPlay && playerTrackCount == streams.count
|
||||
return streams.enumerated().map { stelle, stream in
|
||||
Entry(
|
||||
id: stream.index ?? stelle,
|
||||
title: stream.name,
|
||||
isSelected: stream == selected,
|
||||
rebuilds: !billig
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Untertitelspuren, mit „Aus" an erster Stelle.
|
||||
///
|
||||
/// - Parameter offLabel: Wie „Aus" in der Sprache des Nutzers heisst.
|
||||
/// Als Argument, weil dieses Paket keinen Textkatalog der App kennt.
|
||||
/// - Parameter playerTrackLanguages: Die Sprachkürzel der Spuren, die der
|
||||
/// Player wirklich hat. Nur was sich darin abbilden lässt, schaltet ohne
|
||||
/// Neuaufbau um — sonst stünde der falsche Untertitel im Bild, und das
|
||||
/// ist schlimmer als eine kurze Unterbrechung.
|
||||
public static func subtitles(
|
||||
from streams: [MediaStream],
|
||||
selected: MediaStream?,
|
||||
isDirectPlay: Bool,
|
||||
playerTrackLanguages: [String?] = [],
|
||||
offLabel: String
|
||||
) -> [Entry] {
|
||||
// „Aus" kostet nie einen Neuaufbau: abschalten kann der Player immer,
|
||||
// auch eine Spur, die er nie hatte.
|
||||
var einträge = [
|
||||
Entry(id: nil, title: offLabel, isSelected: selected == nil, rebuilds: false)
|
||||
]
|
||||
|
||||
einträge += streams.enumerated().map { stelle, stream in
|
||||
let abbildbar = isDirectPlay && playerTrackLanguages.contains {
|
||||
LanguageCode.matches(stream.languageCode, $0)
|
||||
}
|
||||
return Entry(
|
||||
id: stream.index ?? stelle,
|
||||
title: stream.name,
|
||||
isSelected: stream == selected,
|
||||
rebuilds: !abbildbar
|
||||
)
|
||||
}
|
||||
return einträge
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import Foundation
|
||||
|
||||
/// Wann **hinauf** gegangen wird — und wann der teure Weg dafür erlaubt ist.
|
||||
///
|
||||
/// **Die Falle, um die es hier geht.** Damit ein Film zügig anläuft, wird mit
|
||||
/// einer niedrigen Stufe begonnen. Der Server baut seine Leiter aber aus
|
||||
/// genau dieser Bestellung: wer mit drei Megabit anfängt, bekommt eine
|
||||
/// Leiter, deren oberste Sprosse drei Megabit ist. Das billige Hinaufschalten
|
||||
/// über `preferredPeakBitRate` bewegt sich **innerhalb** dieser Leiter — es
|
||||
/// käme also nie darüber hinaus, und aus dem schnellen Start würde ein Film,
|
||||
/// der den ganzen Abend in 720p bleibt.
|
||||
///
|
||||
/// Höher geht es nur mit einem Neuaufbau: neu aushandeln, mit einer grösseren
|
||||
/// Grenze. Der kostet Sekunden mit schwarzem Bild — deshalb dieselbe Bauform
|
||||
/// wie `DowngradeRule`: eine Liste von Bedingungen, die **alle** gelten
|
||||
/// müssen, kein Schwellwert.
|
||||
///
|
||||
/// **Und deutlich zurückhaltender als das Absenken.** Ein zu spätes Absenken
|
||||
/// bedeutet Ruckeln; ein zu spätes Hinaufschalten bedeutet ein etwas
|
||||
/// weicheres Bild. Das erste ist ein Fehler, das zweite eine Geschmacksfrage.
|
||||
public enum UpgradeRule {
|
||||
/// Wie lange es ruhig laufen muss, bevor überhaupt gefragt wird.
|
||||
///
|
||||
/// Lang genug, dass es kein anfängliches Aufatmen ist: der Puffer füllt
|
||||
/// sich nach dem Start immer, und das sieht zwanzig Sekunden lang nach
|
||||
/// einer hervorragenden Leitung aus.
|
||||
public static let settleIn: Duration = .seconds(45)
|
||||
/// Sperrfrist zwischen zwei Neuaufbauten nach oben.
|
||||
public static let cooldown: Duration = .seconds(150)
|
||||
/// Wie lange nach einem Rückler gar nicht hinaufgegangen wird.
|
||||
///
|
||||
/// Wer gerade gestockt hat, hat keine Reserve — und ein Neuaufbau nach
|
||||
/// oben wäre dann die zweite Unterbrechung binnen einer Minute.
|
||||
public static let afterStall: Duration = .seconds(90)
|
||||
/// Wie viel Vorrat dasein muss.
|
||||
///
|
||||
/// Nicht „es läuft gerade", sondern „es liegt etwas auf der hohen Kante".
|
||||
/// Nur das trägt einen Neuaufbau.
|
||||
public static let bufferNeeded: Duration = .seconds(20)
|
||||
|
||||
/// Was zum Zeitpunkt der Frage gilt.
|
||||
public struct Facts: Sendable, Equatable {
|
||||
/// Eine ausdrücklich gewählte Stufe ist eine Ansage und wird nicht
|
||||
/// überstimmt — auch nicht nach oben.
|
||||
public let isAutomatic: Bool
|
||||
public let condition: StreamCondition
|
||||
/// Ob die Leiter noch eine Sprosse nach oben hat. Solange ja, ist der
|
||||
/// **billige** Weg der richtige.
|
||||
public let hasHigherRung: Bool
|
||||
/// Ob die eingestellte Obergrenze überhaupt mehr zuliesse.
|
||||
public let ceilingAllowsMore: Bool
|
||||
/// Wie viel Vorrat vor der Abspielstelle liegt.
|
||||
public let bufferedAhead: Duration?
|
||||
/// Wie lange schon gespielt wird.
|
||||
public let playedFor: Duration
|
||||
/// Wie lange der letzte Rückler her ist. `nil` heisst: keiner gewesen.
|
||||
public let sinceLastStall: Duration?
|
||||
/// Wie lange der letzte Sprung her ist.
|
||||
public let sinceLastSeek: Duration?
|
||||
/// Wie lange der letzte Neuaufbau nach oben her ist.
|
||||
public let sinceLastUpgrade: Duration?
|
||||
/// Wie viele es in dieser Wiedergabe schon gab.
|
||||
public let used: Int
|
||||
/// Wie viele es höchstens geben darf.
|
||||
public let budget: Int
|
||||
|
||||
public init(
|
||||
isAutomatic: Bool,
|
||||
condition: StreamCondition,
|
||||
hasHigherRung: Bool,
|
||||
ceilingAllowsMore: Bool,
|
||||
bufferedAhead: Duration?,
|
||||
playedFor: Duration,
|
||||
sinceLastStall: Duration? = nil,
|
||||
sinceLastSeek: Duration? = nil,
|
||||
sinceLastUpgrade: Duration? = nil,
|
||||
used: Int = 0,
|
||||
budget: Int = 2
|
||||
) {
|
||||
self.isAutomatic = isAutomatic
|
||||
self.condition = condition
|
||||
self.hasHigherRung = hasHigherRung
|
||||
self.ceilingAllowsMore = ceilingAllowsMore
|
||||
self.bufferedAhead = bufferedAhead
|
||||
self.playedFor = playedFor
|
||||
self.sinceLastStall = sinceLastStall
|
||||
self.sinceLastSeek = sinceLastSeek
|
||||
self.sinceLastUpgrade = sinceLastUpgrade
|
||||
self.used = used
|
||||
self.budget = budget
|
||||
}
|
||||
}
|
||||
|
||||
/// Warum **nicht** hinaufgegangen wird.
|
||||
///
|
||||
/// Als Aufzählung statt als `Bool`, aus demselben Grund wie beim
|
||||
/// Absenken: „es läuft gut und es passiert nichts" ist ohne Begründung
|
||||
/// von einem Fehler nicht zu unterscheiden.
|
||||
public enum Verdict: Sendable, Equatable {
|
||||
case rebuildHigher
|
||||
/// Eine gewählte Stufe wird nicht überstimmt.
|
||||
case notAutomatic
|
||||
/// Die Leiter hat noch eine Sprosse — der billige Weg genügt.
|
||||
case cheaperPathRemains
|
||||
/// Die Einstellung lässt nicht mehr zu. Kein Fehler, eine Ansage.
|
||||
case atCeiling
|
||||
/// Es läuft nicht ruhig genug.
|
||||
case notCalmEnough
|
||||
/// Zu früh nach Start, Sprung oder Rückler.
|
||||
case tooEarly
|
||||
/// Sperrfrist läuft noch.
|
||||
case cooling
|
||||
/// Verbraucht.
|
||||
case budgetSpent
|
||||
}
|
||||
|
||||
public static func verdict(_ facts: Facts) -> Verdict {
|
||||
guard facts.isAutomatic else { return .notAutomatic }
|
||||
guard facts.ceilingAllowsMore else { return .atCeiling }
|
||||
|
||||
// **Erst die Leiter ausreizen.** Solange es eine Sprosse höher gibt,
|
||||
// kostet das Hinaufgehen kein schwarzes Bild — und wäre ein
|
||||
// Neuaufbau dafür Verschwendung.
|
||||
if facts.hasHigherRung { return .cheaperPathRemains }
|
||||
|
||||
guard facts.condition == .healthy else { return .notCalmEnough }
|
||||
guard let vorrat = facts.bufferedAhead, vorrat >= bufferNeeded else {
|
||||
return .notCalmEnough
|
||||
}
|
||||
|
||||
guard facts.playedFor >= settleIn else { return .tooEarly }
|
||||
if let seit = facts.sinceLastSeek, seit < DowngradeRule.afterSeek { return .tooEarly }
|
||||
if let seit = facts.sinceLastStall, seit < afterStall { return .tooEarly }
|
||||
|
||||
if let seit = facts.sinceLastUpgrade, seit < cooldown { return .cooling }
|
||||
guard facts.used < facts.budget else { return .budgetSpent }
|
||||
|
||||
return .rebuildHigher
|
||||
}
|
||||
}
|
||||
@@ -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.flaro", 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.flaro", 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: "flaro-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: "Flaro-Probe",
|
||||
device: "Mac",
|
||||
deviceID: "flaro-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: "Flaro-Probe",
|
||||
device: "Mac",
|
||||
deviceID: "flaro-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("""
|
||||
FlaroProbe — 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 Flaro 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: "flaro-\(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 = "flaro.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.flaro", 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