Flixy heißt ab hier Halo

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

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

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

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

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

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

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

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
@@ -0,0 +1,32 @@
import SwiftUI
extension EnvironmentValues {
/// Die aktuelle Akzentfarbe, bereits kontrastkorrigiert.
///
/// Bewusst eine schlichte `Color` in der Umgebung statt einer Abhängigkeit
/// auf `HaloTheme`: Bausteine wie `WatchProgressBar` sollen sich in einer
/// Vorschau mit einer beliebigen Farbe zeigen lassen, ohne dass dafür ein
/// ganzes Theme-Objekt gebaut werden muss.
/// Der Vorgabewert liest die gespeicherte Auswahl, statt blind die
/// Standardfarbe zu nehmen.
///
/// Er greift überall dort, wo die Umgebung nicht ankommt — Listen und
/// Formulare zeichnen Zeilen teilweise in einem eigenen Kontext. Vorher
/// leuchtete dort die Standardfarbe, während daneben schon die gewählte
/// stand; jetzt stimmen beide.
@Entry public var haloAccent: Color = AccentOption.storedAccentColor()
}
extension View {
/// Setzt die Akzentfarbe für diesen Teilbaum.
///
/// Wird einmal an der Wurzel aus `HaloTheme` gesetzt; darunter greift
/// alles automatisch darauf zu.
public func haloAccent(_ color: Color) -> some View {
environment(\.haloAccent, color)
}
public func haloAccent(_ theme: HaloTheme) -> some View {
environment(\.haloAccent, theme.accentColor)
}
}
@@ -0,0 +1,107 @@
import Foundation
import SwiftUI
/// Eine wählbare Akzentfarbe.
///
/// Die kuratierten Werte sind so gewählt, dass sie auf `HaloColor.canvas`
/// mindestens 4,5:1 erreichen — geprüft von `AccentPaletteTests`, damit beim
/// Nachlegen einer Farbe nicht versehentlich etwas Unlesbares hineinrutscht.
public struct AccentOption: Sendable, Identifiable {
public let id: String
public let name: LocalizedStringResource
public let color: RGB
public init(id: String, name: LocalizedStringResource, color: RGB) {
self.id = id
self.name = name
self.color = color
}
}
extension AccentOption: Equatable {
public static func == (lhs: AccentOption, rhs: AccentOption) -> Bool { lhs.id == rhs.id }
}
extension AccentOption: Hashable {
public func hash(into hasher: inout Hasher) { hasher.combine(id) }
}
extension AccentOption {
/// Die kuratierte Auswahl aus den Einstellungen.
public static let palette: [AccentOption] = [
AccentOption(id: "ember", name: "Ember", color: RGB(hex: 0xE5484D)),
AccentOption(id: "tangerine", name: "Tangerine", color: RGB(hex: 0xF76B15)),
AccentOption(id: "amber", name: "Amber", color: RGB(hex: 0xFFB224)),
AccentOption(id: "lime", name: "Lime", color: RGB(hex: 0x99D52A)),
AccentOption(id: "mint", name: "Mint", color: RGB(hex: 0x3DD68C)),
AccentOption(id: "teal", name: "Teal", color: RGB(hex: 0x12A594)),
AccentOption(id: "sky", name: "Sky", color: RGB(hex: 0x52A9FF)),
AccentOption(id: "indigo", name: "Indigo", color: RGB(hex: 0x7C77E8)),
AccentOption(id: "lilac", name: "Lilac", color: RGB(hex: 0x9B8AD4)),
AccentOption(id: "orchid", name: "Orchid", color: RGB(hex: 0xC04AC9)),
AccentOption(id: "rose", name: "Rose", color: RGB(hex: 0xF76190)),
]
/// Die Voreinstellung.
///
/// **Lila, weil das Icon lila ist.** Vorher stand hier Mint — eine
/// hübsche Farbe, die mit der Marke nur nichts zu tun hatte: das Plakat
/// auf dem Homescreen ist violett, und die Knöpfe darin waren grün.
/// `lilac` ist die entsättigte Schwester desselben Violetts und erreicht
/// auf dem dunklen Grund 6,5:1.
///
/// Über die Kennung gesucht, nicht über den Index: sonst würde ein
/// Umsortieren der Palette unbemerkt die Standardfarbe ändern — und zwar
/// nur für Neuinstallationen, was beim Suchen besonders unangenehm ist.
public static let `default` = palette.first { $0.id == "lilac" } ?? palette[0]
/// Die Kennung, unter der eine frei gewählte Farbe abgelegt wird.
public static let customIdentifier = "custom"
public static func custom(_ color: RGB) -> AccentOption {
AccentOption(id: customIdentifier, name: "Custom", color: color)
}
}
extension AccentOption {
/// Wo die Auswahl liegt.
///
/// Hier statt in `HaloTheme`, weil zwei Stellen sie brauchen: das Theme
/// und der Vorgabewert der Umgebung. Zwei Kopien derselben Schlüssel
/// driften auseinander, und zwar unbemerkt — es fällt erst auf, wenn eine
/// Farbe irgendwo nicht mitkommt.
enum StorageKey {
static let identifier = "halo.accent.identifier"
static let customHex = "halo.accent.customHex"
}
/// Die gespeicherte Auswahl, oder die Voreinstellung.
public static func stored(in defaults: UserDefaults = .standard) -> AccentOption {
guard let identifier = defaults.string(forKey: StorageKey.identifier) else {
return .default
}
if identifier == customIdentifier {
// Fällt die gespeicherte Zeichenkette aus (etwa weil jemand die
// Voreinstellungen von Hand bearbeitet hat), ist die Vorgabe
// besser als eine Farbe, die niemand gewählt hat.
guard let hex = defaults.string(forKey: StorageKey.customHex),
let color = RGB(string: hex)
else { return .default }
return .custom(color)
}
return palette.first { $0.id == identifier } ?? .default
}
/// Merkt sich die Auswahl.
public func store(in defaults: UserDefaults = .standard) {
defaults.set(id, forKey: StorageKey.identifier)
if id == Self.customIdentifier {
defaults.set(color.hexString, forKey: StorageKey.customHex)
}
}
/// Die gespeicherte Farbe, bereits kontrastkorrigiert.
public static func storedAccentColor(in defaults: UserDefaults = .standard) -> Color {
Contrast.legible(stored(in: defaults).color, on: HaloColor.canvas).swiftUIColor
}
}
@@ -0,0 +1,89 @@
import Foundation
/// Kontrastrechnung nach WCAG 2.1.
///
/// Gebraucht wird sie für die eigene Akzentfarbe: Halo lässt jede Farbe zu,
/// aber ein dunkles Marineblau auf fast schwarzem Grund ist als Schrift oder
/// Symbol unlesbar. `legible(_:on:)` hebt solche Farben so weit an, bis sie
/// den geforderten Kontrast erreichen — und lässt alles in Ruhe, was ihn
/// ohnehin schon hat.
public enum Contrast {
/// Der Grenzwert aus WCAG für normalen Text.
public static let minimumForText: Double = 4.5
/// Der Grenzwert für große Schrift und Bedienelemente.
public static let minimumForLargeText: Double = 3.0
/// Relative Leuchtdichte eines Farbwerts (0 = Schwarz, 1 = Weiß).
public static func relativeLuminance(_ color: RGB) -> Double {
func linear(_ channel: Double) -> Double {
// Der Knick bei 0,03928 ist kein Rundungsfehler, sondern Teil der
// sRGB-Definition: unterhalb davon ist die Kurve linear.
channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4)
}
return 0.2126 * linear(color.red)
+ 0.7152 * linear(color.green)
+ 0.0722 * linear(color.blue)
}
/// Kontrastverhältnis zweier Farben, zwischen 1 (gleich) und 21 (Schwarz zu Weiß).
public static func ratio(_ a: RGB, _ b: RGB) -> Double {
let la = relativeLuminance(a)
let lb = relativeLuminance(b)
return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
}
/// Hebt `color` gerade so weit an, dass sie auf `background` lesbar wird.
///
/// Zuerst wird die Helligkeit angehoben, weil das den Farbton erhält — eine
/// gewählte Akzentfarbe soll erkennbar dieselbe bleiben. Reicht selbst
/// volle Helligkeit nicht (etwa bei einem kräftigen Rot), wird zusätzlich
/// die Sättigung zurückgenommen; die Farbe wandert dann Richtung Weiß.
public static func legible(
_ color: RGB,
on background: RGB,
minimumRatio: Double = minimumForText
) -> RGB {
guard ratio(color, background) < minimumRatio else { return color }
let base = HSB(color)
// Beide Schritte sind monoton — mehr Helligkeit bzw. weniger Sättigung
// erhöhen die Leuchtdichte immer. Deshalb genügt eine binäre Suche,
// und sie terminiert garantiert.
if let brightened = search(
from: base.brightness, to: 1,
build: { HSB(hue: base.hue, saturation: base.saturation, brightness: $0).rgb },
satisfies: { ratio($0, background) >= minimumRatio }
) {
return brightened
}
return search(
from: base.saturation, to: 0,
build: { HSB(hue: base.hue, saturation: $0, brightness: 1).rgb },
satisfies: { ratio($0, background) >= minimumRatio }
) ?? RGB(hex: 0xFFFFFF)
}
/// Sucht den Wert nahe `from`, der die Bedingung gerade eben erfüllt.
/// `to` ist das Extrem; erfüllt selbst das die Bedingung nicht, gibt es
/// keine Lösung auf dieser Achse.
private static func search(
from: Double,
to extreme: Double,
build: (Double) -> RGB,
satisfies: (RGB) -> Bool
) -> RGB? {
guard satisfies(build(extreme)) else { return nil }
var unsatisfying = from
var satisfying = extreme
// 12 Halbierungen bringen den Wert auf unter 1/4096 genau — feiner als
// jeder Unterschied, der auf einem Bildschirm noch sichtbar wäre.
for _ in 0..<12 {
let middle = (unsatisfying + satisfying) / 2
if satisfies(build(middle)) { satisfying = middle } else { unsatisfying = middle }
}
return build(satisfying)
}
}
@@ -0,0 +1,66 @@
import Foundation
/// Farbton, Sättigung, Helligkeit — jeweils 0…1.
///
/// Die Kontrastkorrektur arbeitet in diesem Raum, weil sich darin die
/// Eigenschaft „gleiche Farbe, nur heller" als einzelne Zahl ausdrücken lässt.
/// In RGB müsste man dafür alle drei Kanäle gleichzeitig verschieben.
public struct HSB: Sendable, Equatable {
public var hue: Double
public var saturation: Double
public var brightness: Double
public init(hue: Double, saturation: Double, brightness: Double) {
// Der Farbton ist zyklisch: 1,2 ist dasselbe wie 0,2. Abschneiden wäre
// hier falsch, weil Rot dabei zu Magenta würde.
self.hue = hue.truncatingRemainder(dividingBy: 1).nonNegativeHue()
self.saturation = saturation.clamped()
self.brightness = brightness.clamped()
}
public init(_ rgb: RGB) {
let maximum = max(rgb.red, rgb.green, rgb.blue)
let minimum = min(rgb.red, rgb.green, rgb.blue)
let delta = maximum - minimum
let hue: Double
if delta == 0 {
hue = 0
} else if maximum == rgb.red {
hue = ((rgb.green - rgb.blue) / delta).truncatingRemainder(dividingBy: 6) / 6
} else if maximum == rgb.green {
hue = ((rgb.blue - rgb.red) / delta + 2) / 6
} else {
hue = ((rgb.red - rgb.green) / delta + 4) / 6
}
self.init(
hue: hue,
saturation: maximum == 0 ? 0 : delta / maximum,
brightness: maximum
)
}
public var rgb: RGB {
let sector = hue * 6
let index = Int(sector) % 6
let fraction = sector - Double(Int(sector))
let p = brightness * (1 - saturation)
let q = brightness * (1 - saturation * fraction)
let t = brightness * (1 - saturation * (1 - fraction))
switch index {
case 0: return RGB(red: brightness, green: t, blue: p)
case 1: return RGB(red: q, green: brightness, blue: p)
case 2: return RGB(red: p, green: brightness, blue: t)
case 3: return RGB(red: p, green: q, blue: brightness)
case 4: return RGB(red: t, green: p, blue: brightness)
default: return RGB(red: brightness, green: p, blue: q)
}
}
}
extension Double {
fileprivate func nonNegativeHue() -> Double { self < 0 ? self + 1 : self }
}
@@ -0,0 +1,45 @@
import SwiftUI
/// Die Flächen- und Schriftfarben der App.
///
/// Halo kennt nur den dunklen Modus — es gibt deshalb bewusst keine
/// Hell-/Dunkel-Paare, sondern je einen festen Wert. Die einzige Farbe, die
/// sich zur Laufzeit ändert, ist der Akzent; er kommt aus `HaloTheme`.
public enum HaloColor {
// Flächen, von hinten nach vorn gestaffelt.
/// Der Seitenhintergrund.
public static let canvas = RGB(hex: 0x0B0B0D)
/// Karten, Seitenleiste, Kopfbereiche.
public static let surface = RGB(hex: 0x141417)
/// Blätter, Menüs, alles was über dem Inhalt schwebt.
public static let elevated = RGB(hex: 0x1D1D21)
/// Platzhalterkacheln und gedrückte Zustände.
public static let placeholder = RGB(hex: 0x26262B)
// Schrift.
public static let textPrimary = RGB(hex: 0xF5F5F7)
public static let textSecondary = RGB(hex: 0xA0A0A8)
/// Für Beiwerk wie Jahreszahlen und Laufzeiten.
public static let textMuted = RGB(hex: 0x6E6E76)
/// Rot für zerstörerische Aktionen — bewusst nicht der Akzent, damit
/// „Löschen" auch dann warnend aussieht, wenn jemand Rot als Akzent wählt.
public static let destructive = RGB(hex: 0xE5484D)
}
extension Color {
public static let haloCanvas = HaloColor.canvas.swiftUIColor
public static let haloSurface = HaloColor.surface.swiftUIColor
public static let haloElevated = HaloColor.elevated.swiftUIColor
public static let haloPlaceholder = HaloColor.placeholder.swiftUIColor
public static let haloTextPrimary = HaloColor.textPrimary.swiftUIColor
public static let haloTextSecondary = HaloColor.textSecondary.swiftUIColor
public static let haloTextMuted = HaloColor.textMuted.swiftUIColor
public static let haloDestructive = HaloColor.destructive.swiftUIColor
/// Trennlinien und Ränder. Als Weiß mit Deckkraft statt als fester Wert,
/// damit sie auf jeder Flächenstufe gleich zurückhaltend wirken.
public static let haloSeparator = Color.white.opacity(0.10)
/// Die Abdunklung hinter der ausgefahrenen Seitenleiste.
public static let haloScrim = Color.black.opacity(0.55)
}
@@ -0,0 +1,65 @@
import SwiftUI
/// Eine Farbe als drei Kanäle im Bereich 0…1, unabhängig von SwiftUI.
///
/// `SwiftUI.Color` lässt sich nicht zuverlässig wieder auslesen — es ist ein
/// undurchsichtiger Wert, der erst beim Zeichnen aufgelöst wird. Für
/// Kontrastrechnungen brauchen wir die Zahlen aber vorher, deshalb dieser
/// eigene Typ. `Color` entsteht erst am Ende, in `swiftUIColor`.
public struct RGB: Sendable, Equatable, Hashable, Codable {
public var red: Double
public var green: Double
public var blue: Double
public init(red: Double, green: Double, blue: Double) {
self.red = red.clamped()
self.green = green.clamped()
self.blue = blue.clamped()
}
/// Aus einem Hexwert der Form `0xRRGGBB`.
public init(hex: UInt32) {
self.init(
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double(hex & 0xFF) / 255
)
}
/// Aus einer Eingabe wie `#1DB954`, `1db954` oder `#1DB`.
/// Gibt `nil` zurück, wenn die Zeichenkette keine Farbe ergibt — der
/// Einstellungsdialog kann damit direkt eine Fehlermeldung zeigen.
public init?(string: String) {
var text = string.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
if text.hasPrefix("#") { text.removeFirst() }
// Kurzform: #ABC meint #AABBCC.
if text.count == 3 {
text = text.map { "\($0)\($0)" }.joined()
}
guard text.count == 6, let value = UInt32(text, radix: 16) else { return nil }
self.init(hex: value)
}
public var hexString: String {
String(
format: "#%02X%02X%02X",
Int((red * 255).rounded()),
Int((green * 255).rounded()),
Int((blue * 255).rounded())
)
}
public var swiftUIColor: Color {
// Explizit sRGB. Ohne Angabe deutet SwiftUI die Werte je nach Kontext
// unterschiedlich, und dann stimmt der gerechnete Kontrast nicht mehr
// mit dem überein, was auf dem Bildschirm landet.
Color(.sRGB, red: red, green: green, blue: blue, opacity: 1)
}
}
extension Double {
func clamped(to range: ClosedRange<Double> = 0...1) -> Double {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}
@@ -0,0 +1,166 @@
import Foundation
import SwiftUI
#if canImport(UIKit)
import UIKit
public typealias PlatformImage = UIImage
#else
import AppKit
public typealias PlatformImage = NSImage
#endif
/// Lädt Bilder vom Server — einmal, und behält sie.
///
/// `AsyncImage` konnte das nicht: es lädt bei **jedem** Erscheinen neu, kennt
/// keinen Zwischenspeicher und legt für jedes Bild eine eigene Anfrage an. Eine
/// Startseite zeigt leicht sechzig Kacheln; dazu ein Aufmacher, der ein großes
/// Bild anfordert, das der Server erst umrechnen muss. Mit sechs gleichzeitigen
/// Verbindungen je Gegenstelle — der Voreinstellung — stauen sich die übrigen
/// dahinter, und wer wegscrollt und zurückkommt, stellt sich hinten wieder an.
///
/// Auf dem iPad fiel das nicht auf, auf dem Mac schon: dort blieben der
/// Aufmacher und die Hälfte der Plakate grau.
///
/// Zwei Speicher übereinander: die entpackten Bilder im Arbeitsspeicher, die
/// Rohdaten auf der Platte. Der zweite überlebt einen Neustart der App —
/// dieselbe Startseite kostet dann keine einzige Anfrage mehr.
public actor ArtworkLoader {
public static let shared = ArtworkLoader()
private let session: URLSession
/// Der Speicher liegt **außerhalb** des Actors, damit er sich ohne
/// `await` lesen lässt.
///
/// Das klingt nach einer Kleinigkeit und ist der ganze Punkt: ein `await`
/// kostet immer mindestens einen Durchlauf, und genau der war beim
/// Überblenden des Aufmachers zu sehen. Das neue Bild erschien im ersten
/// Bild noch ohne Schriftzug, im zweiten mit — der Schriftzug sprang also
/// getrennt herein, obwohl er längst da war. Wer das Bild schon hat, soll
/// es im selben Augenblick zeigen können.
private let cache = ImageMemoryCache()
/// Läuft eine Anfrage schon, wird sie geteilt statt ein zweites Mal
/// gestellt. Ohne das lädt eine Reihe, die dasselbe Plakat zweimal zeigt,
/// es auch zweimal.
private var inFlight: [URL: Task<PlatformImage?, Never>] = [:]
init() {
let configuration = URLSessionConfiguration.default
configuration.urlCache = URLCache(
memoryCapacity: 32 * 1_024 * 1_024,
diskCapacity: 512 * 1_024 * 1_024
)
configuration.requestCachePolicy = .returnCacheDataElseLoad
// Bilder sind klein und viele. Sechs gleichzeitige Verbindungen sind
// für eine Seite mit sechzig Kacheln zu wenig.
configuration.httpMaximumConnectionsPerHost = 12
configuration.timeoutIntervalForRequest = 30
session = URLSession(configuration: configuration)
cache.countLimit = 400
}
/// Das Bild, wenn es schon im Arbeitsspeicher liegt — ohne Warten.
///
/// `nonisolated`, damit eine Ansicht es beim Zeichnen aufrufen kann.
public nonisolated static func cachedImage(for url: URL) -> PlatformImage? {
shared.cache.image(for: url)
}
public func image(for url: URL) async -> PlatformImage? {
if let cached = cache.image(for: url) { return cached }
if let running = inFlight[url] { return await running.value }
let task = Task<PlatformImage?, Never> { [session] in
await Self.fetch(url, using: session)
}
inFlight[url] = task
let image = await task.value
inFlight[url] = nil
if let image { cache.store(image, for: url) }
return image
}
/// Holt ein Bild — und gibt nicht beim ersten Nein auf.
///
/// Der Grund ist ein Fehlerbild, das lange rätselhaft aussah: auf einem
/// großen Fenster blieb die **zweite** Reihe fast vollständig grau,
/// während die erste geladen hatte. Es lag nicht am Server und nicht an
/// fehlenden Bildern, sondern an der Gleichzeitigkeit — sechzig sichtbare
/// Kacheln teilen sich zwölf Verbindungen, die hinteren stehen so lange
/// in der Schlange, dass sie in den Zeitablauf laufen.
///
/// Oben fragt niemand ein zweites Mal: `task(id:)` läuft einmal je
/// Adresse. Ein einziger verpasster Versuch war damit endgültig.
///
/// **Nur wiederholt, was sich zu wiederholen lohnt.** Ein 404 heißt, dass
/// es das Bild nicht gibt — dreimal zu fragen macht es nicht vorhanden
/// und kostet den Server drei Antworten. Ein Zeitablauf oder ein
/// abgerissener Strang dagegen sagt nur, dass es *gerade* nicht ging.
private static func fetch(
_ url: URL,
using session: URLSession,
attempts: Int = 3
) async -> PlatformImage? {
for attempt in 1...attempts {
if Task.isCancelled { return nil }
do {
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse else { return nil }
if (200..<300).contains(http.statusCode) {
return PlatformImage(data: data)
}
// Der Server hat geantwortet und nein gesagt. Bei 5xx und 429
// lohnt ein zweiter Versuch, sonst nicht.
guard http.statusCode >= 500 || http.statusCode == 429 else { return nil }
} catch is CancellationError {
return nil
} catch {
// Zeitablauf, abgerissene Verbindung: der Fall, für den das
// alles hier gebaut ist.
}
guard attempt < attempts else { return nil }
// Wachsender Abstand: ist die Leitung gerade verstopft, hilft es
// nicht, sofort noch einmal zu drängeln.
try? await Task.sleep(for: .milliseconds(300 * attempt * attempt))
}
return nil
}
}
/// Ein Bildspeicher, der sich von überall lesen lässt.
///
/// `NSCache` ist laut Apples Dokumentation von sich aus für den Zugriff aus
/// mehreren Strängen gebaut — der Compiler weiß das nur nicht. Deshalb hier
/// von Hand versichert statt den Speicher in einen Actor zu sperren, was ihn
/// genau um die Eigenschaft brächte, für die er hier steht: ohne Warten
/// lesbar zu sein.
private final class ImageMemoryCache: @unchecked Sendable {
private let storage = NSCache<NSURL, PlatformImage>()
var countLimit: Int {
get { storage.countLimit }
set { storage.countLimit = newValue }
}
func image(for url: URL) -> PlatformImage? {
storage.object(forKey: url as NSURL)
}
func store(_ image: PlatformImage, for url: URL) {
storage.setObject(image, forKey: url as NSURL)
}
}
extension Image {
/// Aus einem Plattformbild, ohne dass die Ansicht wissen muss, welches.
init(platform image: PlatformImage) {
#if canImport(UIKit)
self.init(uiImage: image)
#else
self.init(nsImage: image)
#endif
}
}
@@ -0,0 +1,163 @@
import SwiftUI
/// Ein Bild vom Server mit Lade- und Fehlerzustand.
///
/// Der Austausch, den die frühere Notiz hier angekündigt hat, ist vollzogen:
/// statt `AsyncImage` lädt `ArtworkLoader`. Der Grund stand schon da — eine
/// Startseite zeigt dutzende Bilder gleichzeitig, und `AsyncImage` lädt sie bei
/// jedem Erscheinen neu. Auf dem Mac wurde daraus ein sichtbarer Fehler: der
/// Aufmacher und die Hälfte der Plakate blieben grau.
public struct HaloArtwork: View {
/// Die Adressen, der Reihe nach probiert.
///
/// **Warum mehrere.** Eine Folge hat selten ein eigenes Querformatbild,
/// die Staffel manchmal, die Serie fast immer. Bisher wurde die erste
/// Adresse genommen, die sich **zusammensetzen** liess — und ob es das
/// Bild dahinter gibt, stellt sich erst beim Abruf heraus. Gab es das
/// Staffelbild nicht, blieb die Fläche grau, und der Weg zur Serie wurde
/// nie versucht.
///
/// Die Reihenfolge gehört deshalb hierher, ins Laden, nicht ins
/// Zusammensetzen: erst wer gefragt hat, weiss, ob es etwas gibt.
private let urls: [URL]
private let aspectRatio: CGFloat
/// Ob das Bild den Rahmen einnimmt, den es bekommt, statt sich seine Höhe
/// aus dem Seitenverhältnis auszurechnen.
///
/// Für den Aufmacher: der soll so hoch sein wie das Fenster und nicht so
/// hoch, wie 16:9 es vorgibt. Das Seitenverhältnis bleibt trotzdem
/// wichtig — danach werden die Bilder beim Server angefordert.
private let fillsFrame: Bool
private let fallbackSymbol: String
private let contentMode: ContentMode
/// - Parameter contentMode: `.fill` füllt die Fläche und schneidet ab —
/// richtig für Plakate und Szenenbilder, die ohnehin formatfüllend
/// gedacht sind. `.fit` zeigt das ganze Bild. Das braucht alles, was
/// kein festes Seitenverhältnis hat: **Sendersymbole** sind mal breit,
/// mal schmal, und formatfüllend in einem Quadrat fehlten links und
/// rechts genau die Teile, an denen man den Sender erkennt.
public init(
url: URL?,
aspectRatio: CGFloat,
fillsFrame: Bool = false,
fallbackSymbol: String = "film",
contentMode: ContentMode = .fill
) {
self.init(
urls: [url].compactMap(\.self),
aspectRatio: aspectRatio,
fillsFrame: fillsFrame,
fallbackSymbol: fallbackSymbol,
contentMode: contentMode
)
}
/// Mehrere Adressen, von der besten zur schlechtesten.
public init(
urls: [URL],
aspectRatio: CGFloat,
fillsFrame: Bool = false,
fallbackSymbol: String = "film",
contentMode: ContentMode = .fill
) {
self.urls = urls
self.aspectRatio = aspectRatio
self.fillsFrame = fillsFrame
self.fallbackSymbol = fallbackSymbol
self.contentMode = contentMode
}
/// Das geladene Bild — **mit den Adressen, zu denen es gehört**.
///
/// **Ohne sie wird es falsch,** sobald eine Ansicht ihre Identität über
/// einen Inhaltswechsel hinweg behält: der Zustand überlebt den Wechsel,
/// und das alte Bild bliebe stehen, bis das neue geladen ist. Solange
/// jede Kachel ihre eigene Identität hatte, fiel das nicht auf — der
/// Aufmacher hat seit dem Umbau der Überblendung keine mehr.
@State private var loaded: (urls: [URL], image: PlatformImage)?
@State private var hasFailed = false
public var body: some View {
placeholder
.overlay {
content
}
.clipped()
.task(id: urls) { await load() }
}
@ViewBuilder
private var placeholder: some View {
if fillsFrame {
Rectangle().fill(Color.haloPlaceholder)
} else {
Rectangle().fill(Color.haloPlaceholder).aspectRatio(aspectRatio, contentMode: .fit)
}
}
/// Das Bild, das jetzt gezeigt werden kann.
///
/// Der Blick in den Speicher steht **hier**, nicht in `load()`: eine
/// Kachel, die zum zweiten Mal erscheint, zeigt ihr Bild damit schon im
/// ersten Bild und blitzt nicht kurz grau auf. Über ein `await` ginge das
/// nicht — das kostet immer einen Durchlauf.
private var available: PlatformImage? {
if let loaded, loaded.urls == urls { return loaded.image }
// Die erste, die schon im Speicher liegt — in der Reihenfolge der
// Vorlieben, nicht in der des Zufalls.
for url in urls {
if let bild = ArtworkLoader.cachedImage(for: url) { return bild }
}
return nil
}
@ViewBuilder
private var content: some View {
if let loaded = available {
switch contentMode {
case .fill: Image(platform: loaded).resizable().scaledToFill()
case .fit: Image(platform: loaded).resizable().scaledToFit()
}
} else if hasFailed {
// Kein Fehlertext: bei einer Reihe aus zwanzig Kacheln wäre das
// mehr Lärm als Hilfe. Das Symbol sagt genug.
Image(systemName: fallbackSymbol)
.font(.title2)
.foregroundStyle(Color.haloTextMuted)
} else {
ShimmerPlaceholder()
}
}
private func load() async {
guard !urls.isEmpty else {
hasFailed = false
loaded = nil
return
}
// Was schon da ist, zeigt `available` bereits — hier bleibt nur das
// Nachladen.
if available != nil {
hasFailed = false
return
}
loaded = nil
hasFailed = false
// **Der Reihe nach, bis eine antwortet.** Genau das konnte die
// frühere Fassung nicht: sie hatte nur eine Adresse und musste sich
// vorher entscheiden, ohne zu wissen, ob es das Bild gibt.
for url in urls {
let bild = await ArtworkLoader.shared.image(for: url)
guard !Task.isCancelled else { return }
if let bild {
withAnimation(.easeOut(duration: 0.2)) { loaded = (urls, bild) }
return
}
}
hasFailed = true
}
}
@@ -0,0 +1,302 @@
import SwiftUI
/// Das Aufmacher-Bild oben auf der Startseite.
///
/// Die Aktionsknöpfe kommen von außen, weil ein Film „Abspielen" und
/// „Herunterladen" braucht, eine angefangene Folge dagegen „Weiterschauen".
/// Der Baustein legt nur Bild, Verlauf und Textsatz fest.
public struct HeroBanner<Actions: View>: View {
private let title: String
private let metadata: String?
/// Eine Zeile, in der einzelne Teile anklickbar sind.
///
/// Steht sie, tritt sie an die Stelle von `metadata`. Gebaut wird sie beim
/// Aufrufer, weil nur der weiß, wohin ein Teil führt — die Gestaltung
/// kennt keine Ziele. `AnyView` statt eines weiteren Platzhaltertyps: der
/// Aufmacher hat schon einen für die Knöpfe, und ein zweiter zöge sich
/// durch jeden Aufruf.
private let metadataContent: AnyView?
private let summary: String?
private let artworkURLs: [URL]
/// Das Bild, das gerade verschwindet — nur für den Aufmacher mit Wechsel.
///
/// **Warum die Überblendung hier drin steckt und nicht darüber.** Das
/// Karussell legte vorher zwei ganze Aufmacher übereinander und blendete
/// den oberen ein. Jeder bringt seinen eigenen Verlauf nach unten mit —
/// und zwei halbdurchsichtige Verläufe ergeben zusammen **keinen** ganzen.
/// Gemessen an derselben Stelle: im Ruhezustand 68, in der Mitte des
/// Wechsels 137. Sichtbar als ein Aufhellen genau dort, wo der Titel
/// steht.
///
/// Jetzt wechselt nur das **Bild**; Verlauf und Text liegen einmal
/// darüber und ändern sich nicht. Damit kann der Verlauf gar nicht mehr
/// schwächer werden.
private let outgoingArtworkURLs: [URL]
/// Wie weit das neue Bild schon deckt: 0 beim Beginn, 1 am Ende.
private let crossfade: CGFloat
/// Wie weit das neue Bild dabei hereinrutscht.
private let incomingOffset: CGFloat
/// Titel, Zeile und Schriftzug des weichenden Eintrags.
///
/// **Damit der Schriftzug mitwandert.** Ohne ihn stünde während des
/// Wechsels gar kein Text da: der neue blendet erst ein, der alte wäre
/// schon weg. Er liegt **über** dem Verlauf, nicht darin — deshalb kann
/// er die Abdunklung nicht schwächen, anders als ein zweiter ganzer
/// Aufmacher.
private let outgoing: (title: String, metadata: String?, logoURL: URL?)?
/// Der freigestellte Schriftzug des Films. Steht er zur Verfügung, tritt
/// er an die Stelle des gesetzten Titels — „Harry Potter" in seiner
/// eigenen Schrift ist wiedererkennbar, eine Systemschrift nicht.
private let logoURL: URL?
private let aspectRatio: CGFloat
/// Ob der Aufmacher den Platz einnimmt, den er bekommt.
private let fillsAvailableSpace: Bool
private let actions: Actions
/// Der geladene Schriftzug — **mit der Adresse, zu der er gehört**.
///
/// **Ohne die Adresse wird er falsch.** Seit der Aufmacher seine
/// Identität über den Titelwechsel hinweg behält, überlebt auch dieser
/// Zustand ihn: der Schriftzug des vorigen Films stand dann über dem Bild
/// des neuen, bis der neue geladen war. „Werner Beinhart" über John Wick.
///
/// Ein Zustand, der eine andere Angabe zwischenspeichert, muss sagen,
/// wozu er gehört — sonst gilt er für alles.
@State private var loadedLogo: (url: URL, image: PlatformImage)?
public init(
title: String,
metadata: String? = nil,
metadataContent: AnyView? = nil,
summary: String? = nil,
artworkURLs: [URL],
outgoingArtworkURLs: [URL] = [],
outgoingTitle: String? = nil,
outgoingMetadata: String? = nil,
outgoingLogoURL: URL? = nil,
crossfade: CGFloat = 1,
incomingOffset: CGFloat = 0,
logoURL: URL? = nil,
// 16:9, weil Plex genau das als `art` liefert. Vorher stand hier
// 3:4 — mit `scaledToFill` wurde das Bild dadurch links und rechts
// kräftig beschnitten, und vom Aufmacher war nur ein Ausschnitt zu
// sehen.
aspectRatio: CGFloat = HaloAspectRatio.hero,
fillsAvailableSpace: Bool = false,
@ViewBuilder actions: () -> Actions
) {
self.title = title
self.metadata = metadata
self.metadataContent = metadataContent
self.summary = summary
self.artworkURLs = artworkURLs
self.outgoingArtworkURLs = outgoingArtworkURLs
self.outgoing = outgoingTitle.map { ($0, outgoingMetadata, outgoingLogoURL) }
self.crossfade = crossfade
self.incomingOffset = incomingOffset
self.logoURL = logoURL
self.aspectRatio = aspectRatio
self.fillsAvailableSpace = fillsAvailableSpace
self.actions = actions()
}
/// Wie stark Schrift und Schriftzug mitwachsen.
///
/// Der Aufmacher ist so breit wie das Fenster. Bei fester Schriftgröße
/// wirkt derselbe Titel auf einem iPad groß und in einem breiten
/// Mac-Fenster verloren — das Bild wächst, die Schrift nicht. Bezugsgröße
/// ist die Breite eines iPads im Hochformat; alles darüber wächst mit,
/// aber gedeckelt, damit ein Fenster über die ganze Wand nicht in
/// Plakatschrift endet.
private func scale(for width: CGFloat) -> CGFloat {
min(max(width / 834, 1), 1.7)
}
/// Das Bild — und, solange gewechselt wird, das vorige darunter.
///
/// Ohne Wechsel ist es genau ein `HaloArtwork` wie zuvor: die zweite
/// Ebene entsteht nur, wenn jemand eine vorige Adresse mitgibt.
@ViewBuilder
private var bild: some View {
if outgoingArtworkURLs.isEmpty {
HaloArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
} else {
ZStack {
HaloArtwork(urls: outgoingArtworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
HaloArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
.opacity(crossfade)
.offset(x: incomingOffset)
}
}
}
/// Schriftzug, wenn vorhanden — sonst gesetzter Titel.
///
/// Geladen wird über `ArtworkLoader`, **nicht** über `AsyncImage`. Der
/// Unterschied ist beim Überblenden des Aufmachers zu sehen: `AsyncImage`
/// bringt seine eigene Einblendung mit und fängt erst an zu laden, wenn es
/// erscheint. Der Schriftzug blendete sich dann getrennt und später ein,
/// während Titelzeile, Beschreibung und Knöpfe längst mitgewandert waren —
/// zwei Bewegungen, wo eine sein soll.
///
/// Jetzt liegt das Bild beim Erscheinen schon im Zwischenspeicher (das
/// Karussell holt es vor) und wird ohne eigene Bewegung gezeigt: es gehört
/// zum Aufmacher und macht mit, was der Aufmacher macht.
///
/// Ohne Bild-Transcoder, weil der Schriftzug ein freigestelltes PNG ist und
/// der Transcoder JPEG liefert — aus dem Logo würde sonst ein schwarzer
/// Kasten.
/// Der Schriftzug, wenn er **jetzt** gezeigt werden kann.
///
/// Das Karussell holt ihn vor; liegt er bereit, steht er schon im ersten
/// Bild des Übergangs da. Nur wenn nicht, wird nachgeladen — und dann
/// steht bis dahin der gesetzte Titel.
private var availableLogo: PlatformImage? {
guard let logoURL else { return nil }
if let loadedLogo, loadedLogo.url == logoURL { return loadedLogo.image }
return ArtworkLoader.cachedImage(for: logoURL)
}
public var body: some View {
bild
.overlay(alignment: .bottom) {
// Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in
// Schwarz: sonst entsteht genau dort, wo der Aufmacher in die
// erste Reihe übergeht, eine sichtbare Kante.
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: Color.haloCanvas.opacity(0.75), location: 0.45),
.init(color: Color.haloCanvas, location: 1),
],
startPoint: .top,
endPoint: .bottom
)
.frame(maxHeight: .infinity, alignment: .bottom)
.allowsHitTesting(false)
}
.overlay(alignment: .top) {
// Oben liegen Uhrzeit, Batterie und die Navigationsinsel — auf
// dem Bild, seit der Aufmacher bis an den Bildschirmrand
// reicht. Über einem dunklen Aufmacher geht das gut; über
// einem hellen verschwinden sie.
//
// Deshalb ein Verlauf, aber ein knapper: er reicht nur so
// weit, wie die Insel baut, und läuft schnell aus. Ein
// großzügigerer deckte das Bild zu, und genau das Bild ist der
// Grund, warum es randlos sein soll.
LinearGradient(
colors: [.black.opacity(0.45), .clear],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 150)
.allowsHitTesting(false)
}
.overlay(alignment: .bottomLeading) {
GeometryReader { geometry in
let scale = scale(for: geometry.size.width)
ZStack(alignment: .bottomLeading) {
// **Der weichende Text bleibt stehen, bis der neue
// ihn zudeckt.** Sonst stünde mitten im Wechsel gar
// keiner da: der neue blendet ein, der alte wäre
// schon weg. Er liegt über dem Verlauf — dort kostet
// er die Abdunklung nichts.
if let outgoing, crossfade < 1 {
inhalt(
title: outgoing.title,
metadata: outgoing.metadata,
logo: outgoing.logoURL.flatMap(ArtworkLoader.cachedImage(for:)),
scale: scale
)
}
// Der neue — mit demselben Einblenden und demselben
// Hereinrutschen wie das Bild darunter, damit beides
// als eine Bewegung erscheint.
inhalt(
title: title,
metadata: metadata,
logo: availableLogo,
scale: scale
)
.opacity(crossfade)
.offset(x: incomingOffset)
}
.frame(
width: geometry.size.width,
height: geometry.size.height,
alignment: .bottomLeading
)
}
}
.task(id: logoURL) {
guard let logoURL, ArtworkLoader.cachedImage(for: logoURL) == nil else { return }
if let bild = await ArtworkLoader.shared.image(for: logoURL) {
loadedLogo = (logoURL, bild)
}
}
}
/// Schriftzug beziehungsweise Titel, Zeile, Beschreibung und Knöpfe.
///
/// Als eigene Funktion, weil es sie während eines Wechsels zweimal gibt —
/// einmal für den weichenden und einmal für den kommenden Eintrag.
@ViewBuilder
private func inhalt(
title: String,
metadata: String?,
logo: PlatformImage?,
scale: CGFloat
) -> some View {
VStack(alignment: .leading, spacing: HaloSpacing.snug * scale) {
if let logo {
Image(platform: logo)
.resizable()
.scaledToFit()
// Nach oben begrenzt: manche Schriftzüge sind fast
// quadratisch, andere sehr breit. Ohne Deckel schöbe ein
// hohes Logo die Knöpfe aus dem Bild.
.frame(maxWidth: 260 * scale, maxHeight: 76 * scale, alignment: .leading)
.accessibilityLabel(Text(title))
} else {
// Solange nichts da ist — und wenn es scheitert — bleibt es
// beim gesetzten Titel. Ein leerer Platz wäre schlimmer als
// eine Systemschrift.
Text(title)
.font(.system(size: 34 * scale, weight: .bold))
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(2)
}
if let metadataContent {
metadataContent
.font(.system(size: 12 * scale))
.foregroundStyle(Color.haloTextSecondary)
} else if let metadata {
Text(metadata)
.font(.system(size: 12 * scale))
.foregroundStyle(Color.haloTextSecondary)
}
if let summary {
Text(summary)
.font(.system(size: 12 * scale))
.foregroundStyle(Color.haloTextSecondary)
.lineLimit(2)
// Ein Satz über die ganze Breite eines großen Fensters ist
// unlesbar; das Auge verliert die Zeile. Achtzig Zeichen
// sind die Grenze, ab der Satzspiegel unangenehm werden.
.frame(maxWidth: 640 * scale, alignment: .leading)
}
HStack(spacing: HaloSpacing.regular) {
actions
}
.padding(.top, HaloSpacing.tight)
}
.padding(.horizontal, HaloSpacing.screenMargin * scale)
.padding(.bottom, HaloSpacing.loose * scale)
}
}
@@ -0,0 +1,47 @@
import SwiftUI
/// Ein Titel im Querformat — die Kachelform der Startseite.
///
/// Anders als das Poster zeigt sie ein Szenenbild und trägt den Titel unter
/// dem Bild, weil Szenenbilder selten Schrift enthalten.
public struct LandscapeCard: View {
private let title: String
private let subtitle: String?
private let artworkURL: URL?
private let progress: Double?
public static let aspectRatio: CGFloat = 16.0 / 9.0
public init(title: String, subtitle: String? = nil, artworkURL: URL?, progress: Double? = nil) {
self.title = title
self.subtitle = subtitle
self.artworkURL = artworkURL
self.progress = progress
}
public var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
HaloArtwork(url: artworkURL, aspectRatio: Self.aspectRatio, fallbackSymbol: "play.rectangle")
.overlay(alignment: .bottom) {
if let progress {
WatchProgressBar(fraction: progress)
}
}
.clipShape(.rect(cornerRadius: HaloRadius.card))
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
if let subtitle {
Text(subtitle)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.lineLimit(1)
}
}
}
.accessibilityElement(children: .combine)
}
}
@@ -0,0 +1,61 @@
import SwiftUI
/// Eine Kachel, die es gleich geben wird.
///
/// **Warum es das gibt.** Eine Mediathek lädt seitenweise nach. Wer unten
/// ankommt, sieht ein Raster, das aufhört — und kann nicht unterscheiden, ob
/// das alles war oder ob gerade nachgeladen wird. Beides sieht gleich aus, und
/// im Zweifel hält man eine halb geladene Liste für vollständig.
///
/// Diese Kacheln sagen: hier kommt noch etwas. Sie werden nur so oft gezeigt,
/// wie der Server tatsächlich noch Titel gemeldet hat — es ist also keine
/// Beschwichtigung, sondern eine Auskunft.
///
/// Ein Ladekreis täte das nicht: er sagt „es passiert etwas", aber nicht
/// **wo** und nicht **wie viel**. Die Form der Kachel an der Stelle, an der
/// sie erscheinen wird, sagt beides.
public struct PlaceholderCard: View {
/// Womit das Aufleuchten versetzt wird.
///
/// Alle gleichzeitig pulsieren zu lassen sieht nach einem Fehler aus —
/// als blinkte die ganze Seite. Ein Versatz je Kachel macht daraus eine
/// Bewegung, die von links nach rechts läuft.
let index: Int
@State private var isBright = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
public init(index: Int = 0) {
self.index = index
}
public var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
RoundedRectangle(cornerRadius: HaloRadius.card)
.fill(Color.haloElevated)
.aspectRatio(PosterCard.aspectRatio, contentMode: .fit)
// Zwei Balken an der Stelle von Titel und Jahr. Ohne sie springt
// die Zeile, sobald der echte Titel kommt.
RoundedRectangle(cornerRadius: 3)
.fill(Color.haloElevated)
.frame(height: 10)
RoundedRectangle(cornerRadius: 3)
.fill(Color.haloElevated)
.frame(width: 32, height: 8)
}
.opacity(isBright ? 0.85 : 0.45)
.animation(
reduceMotion
? nil
: .easeInOut(duration: 0.9)
.repeatForever(autoreverses: true)
.delay(Double(index % 6) * 0.1),
value: isBright
)
.onAppear { isBright = true }
// Für die Sprachausgabe ist eine Kachel ohne Inhalt nichts, worüber
// man wischen möchte.
.accessibilityHidden(true)
}
}
@@ -0,0 +1,50 @@
import SwiftUI
/// Ein Titel im Hochformat, wie er in Bibliotheksrastern steht.
public struct PosterCard: View {
private let title: String
private let subtitle: String?
private let artworkURL: URL?
private let progress: Double?
/// Das Seitenverhältnis von Kinoplakaten. Plex liefert Poster in diesem
/// Format; ein anderer Wert würde die Bilder beschneiden.
public static let aspectRatio: CGFloat = 2.0 / 3.0
public init(title: String, subtitle: String? = nil, artworkURL: URL?, progress: Double? = nil) {
self.title = title
self.subtitle = subtitle
self.artworkURL = artworkURL
self.progress = progress
}
public var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
HaloArtwork(url: artworkURL, aspectRatio: Self.aspectRatio)
.clipShape(.rect(cornerRadius: HaloRadius.card))
.overlay(alignment: .bottom) {
if let progress {
WatchProgressBar(fraction: progress)
}
}
.clipShape(.rect(cornerRadius: HaloRadius.card))
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
if let subtitle {
Text(subtitle)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.lineLimit(1)
}
}
}
// Ohne diese Zusammenfassung liest VoiceOver Bild, Titel und Balken als
// drei getrennte Elemente vor — bei einer Reihe aus zwanzig Kacheln
// wird das Durchwischen dadurch dreimal so lang.
.accessibilityElement(children: .combine)
}
}
@@ -0,0 +1,41 @@
import SwiftUI
/// Die Platzhalterfläche, solange ein Bild noch lädt.
///
/// Ein wandernder Lichtschein statt eines Spinners: er zeigt die endgültige
/// Form des Inhalts schon an, wodurch die Startseite beim Aufbauen nicht
/// springt.
public struct ShimmerPlaceholder: View {
@State private var phase: CGFloat = -1
/// Bewegte Flächen sind für manche Menschen unangenehm; bei aktivierter
/// Systemeinstellung bleibt der Platzhalter deshalb einfach ruhig stehen.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
public init() {}
public var body: some View {
Rectangle()
.fill(Color.haloPlaceholder)
.overlay {
if !reduceMotion {
GeometryReader { proxy in
LinearGradient(
colors: [.clear, Color.white.opacity(0.06), .clear],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: proxy.size.width * 0.6)
.offset(x: phase * proxy.size.width * 1.6)
}
}
}
.clipped()
.onAppear {
guard !reduceMotion else { return }
withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) {
phase = 1
}
}
.accessibilityHidden(true)
}
}
@@ -0,0 +1,163 @@
#if os(tvOS)
import SwiftUI
/// Ein Plakat, das auf den Fokus antwortet.
///
/// **Ein Knopf, kein Bild mit Geste.** Auf tvOS ist alles Bedienbare
/// fokussierbar, und `.buttonStyle(.card)` bringt die Systemwirkung mit:
/// Anheben, Schlagschatten und die Parallaxe, bei der sich die Ebenen beim
/// Wischen gegeneinander verschieben. Die von Hand nachzubauen —
/// `scaleEffect` und ein Schatten — sieht auf den ersten Blick gleich aus und
/// auf den zweiten falsch: die Parallaxe ist der Teil, den man wiedererkennt.
///
/// Liegt in HaloDesign und nicht im Fernseh-Ziel, aus demselben Grund wie
/// `PosterCard`: das Paket kennt weder `MediaItem` noch die Wiedergabe, und
/// das soll so bleiben. Herein kommen Zeichenketten und Adressen.
public struct TVPosterCard: View {
private let title: String
private let subtitle: String?
private let artworkURL: URL?
private let progress: Double?
/// Meldet nach oben, wenn diese Karte den Fokus bekommt oder verliert.
///
/// **Nach aussen als Rückruf, innen als `@FocusState`.** Die Umgebung
/// könnte es nicht beantworten — `\.isFocused` meint den nächsten
/// fokussierbaren Vorfahren, und das ist in einer Reihe aus Karten nicht
/// verlässlich diese. Wer es wissen will, bekommt es deshalb gesagt.
private let onFocusChange: (Bool) -> Void
private let action: () -> Void
/// **Ausdrücklich und nicht über `\.isFocused`.** Die Umgebungsvariable
/// beantwortet die Frage nach dem nächstgelegenen fokussierbaren
/// Vorfahren; in einer Reihe aus Karten ist das nicht verlässlich diese.
@FocusState private var isFocused: Bool
public init(
title: String,
subtitle: String? = nil,
artworkURL: URL?,
progress: Double? = nil,
onFocusChange: @escaping (Bool) -> Void = { _ in },
action: @escaping () -> Void
) {
self.title = title
self.subtitle = subtitle
self.artworkURL = artworkURL
self.progress = progress
self.onFocusChange = onFocusChange
self.action = action
}
public var body: some View {
TVCard(
aspectRatio: PosterCard.aspectRatio,
title: title,
subtitle: subtitle,
artworkURL: artworkURL,
progress: progress,
isFocused: $isFocused,
onFocusChange: onFocusChange,
action: action
)
}
}
/// Dasselbe im Querformat — für „Weiterschauen".
///
/// Angefangenes erkennt man an der Szene, an der man aufgehört hat; wer
/// dagegen aussucht, was als Nächstes kommt, sucht nach Plakaten.
public struct TVLandscapeCard: View {
private let title: String
private let subtitle: String?
private let artworkURL: URL?
private let progress: Double?
/// Meldet nach oben, wenn diese Karte den Fokus bekommt oder verliert.
///
/// **Nach aussen als Rückruf, innen als `@FocusState`.** Die Umgebung
/// könnte es nicht beantworten — `\.isFocused` meint den nächsten
/// fokussierbaren Vorfahren, und das ist in einer Reihe aus Karten nicht
/// verlässlich diese. Wer es wissen will, bekommt es deshalb gesagt.
private let onFocusChange: (Bool) -> Void
private let action: () -> Void
@FocusState private var isFocused: Bool
public init(
title: String,
subtitle: String? = nil,
artworkURL: URL?,
progress: Double? = nil,
onFocusChange: @escaping (Bool) -> Void = { _ in },
action: @escaping () -> Void
) {
self.title = title
self.subtitle = subtitle
self.artworkURL = artworkURL
self.progress = progress
self.onFocusChange = onFocusChange
self.action = action
}
public var body: some View {
TVCard(
aspectRatio: HaloAspectRatio.landscape,
title: title,
subtitle: subtitle,
artworkURL: artworkURL,
progress: progress,
isFocused: $isFocused,
onFocusChange: onFocusChange,
action: action
)
}
}
/// Was beide gemeinsam haben.
private struct TVCard: View {
let aspectRatio: CGFloat
let title: String
let subtitle: String?
let artworkURL: URL?
let progress: Double?
@FocusState.Binding var isFocused: Bool
let onFocusChange: (Bool) -> Void
let action: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
Button(action: action) {
HaloArtwork(url: artworkURL, aspectRatio: aspectRatio)
.overlay(alignment: .bottom) {
if let progress { WatchProgressBar(fraction: progress) }
}
.clipShape(.rect(cornerRadius: HaloRadius.card))
}
.buttonStyle(.card)
.focused($isFocused)
// **Die Höhe wird immer belegt, der Text nicht immer gezeigt.**
//
// Sonst wächst die Karte beim Fokussieren nach unten und schiebt
// die ganze Reihe — und man sieht nicht den Fokus, sondern ein
// Zucken. Und dauerhaft beschriftet wären zwanzig Plakate aus drei
// Metern eine Textwand.
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
if let subtitle {
Text(subtitle)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.lineLimit(1)
}
}
.opacity(isFocused ? 1 : 0)
.animation(.easeOut(duration: 0.15), value: isFocused)
}
.onChange(of: isFocused) { _, neu in onFocusChange(neu) }
.accessibilityElement(children: .combine)
}
}
#endif
@@ -0,0 +1,28 @@
import SwiftUI
/// Der dünne Balken unter angefangenen Titeln.
public struct WatchProgressBar: View {
/// 0…1. Werte außerhalb werden abgeschnitten, damit ein fehlerhafter
/// Serverwert den Balken nicht über die Kachel hinausschieben kann.
private let fraction: Double
@Environment(\.haloAccent) private var accent
public init(fraction: Double) {
self.fraction = fraction.clamped()
}
public var body: some View {
GeometryReader { proxy in
ZStack(alignment: .leading) {
Rectangle().fill(Color.white.opacity(0.15))
Rectangle()
.fill(accent)
.frame(width: proxy.size.width * fraction)
}
}
.frame(height: 3)
.accessibilityElement()
.accessibilityLabel(Text("progress.watched", bundle: .module))
.accessibilityValue(Text(fraction.formatted(.percent.precision(.fractionLength(0)))))
}
}
@@ -0,0 +1,78 @@
import Foundation
import CoreGraphics
/// Die Geometrie eines Programmrasters: Zeit in Punkte.
///
/// Eigener Typ statt Rechnungen in der Ansicht, weil hier alles schiefgehen
/// kann, was man nicht sieht: eine Sendung, die vor dem Fenster begann, säße
/// bei negativem Versatz außerhalb; eine Kurzmeldung wäre zwei Punkte breit
/// und unlesbar; ein leeres Raster hätte die Breite null. Geprüft werden
/// diese Fälle nur, wenn sie außerhalb einer `View` stehen.
public struct GuideLayout: Sendable, Equatable {
/// Der Anfang des Fensters — die angebrochene halbe Stunde vor „jetzt".
///
/// Nicht „jetzt" selbst: dann trüge die Achse krumme Zeiten und die
/// laufende Sendung wäre immer angeschnitten.
public let start: Date
public let end: Date
public let pointsPerMinute: CGFloat
/// Schmaler wird kein Block, egal wie kurz die Sendung ist.
public let minimumBlockWidth: CGFloat
private static let halfHour: TimeInterval = 30 * 60
public init(
now: Date,
latestEnd: Date? = nil,
pointsPerMinute: CGFloat = 4.4,
minimumBlockWidth: CGFloat = 36,
minimumHours: Double = 3,
maximumHours: Double = 14
) {
let anchor = (now.timeIntervalSince1970 / Self.halfHour).rounded(.down) * Self.halfHour
let start = Date(timeIntervalSince1970: anchor)
self.start = start
// Unten begrenzt, damit ein Raster mit einer einzigen bekannten
// Sendung nicht zur Briefmarke wird; oben, damit es bei einem
// Anbieter mit zwei Wochen Vorschau nicht endlos wird.
let bounded = min(
max(latestEnd ?? start, start.addingTimeInterval(minimumHours * 3_600)),
start.addingTimeInterval(maximumHours * 3_600)
)
// Auch das Ende auf die halbe Stunde, nach oben. Sonst endet die
// Achse mit einem Stummel, dessen Beschriftung keine Spalte mehr hat.
self.end = Date(
timeIntervalSince1970: (bounded.timeIntervalSince1970 / Self.halfHour).rounded(.up) * Self.halfHour
)
self.pointsPerMinute = pointsPerMinute
self.minimumBlockWidth = minimumBlockWidth
}
public var totalWidth: CGFloat {
CGFloat(end.timeIntervalSince(start) / 60) * pointsPerMinute
}
/// Der waagerechte Versatz eines Zeitpunkts.
///
/// Nie negativ: was vor dem Fenster liegt, klebt an dessen linkem Rand.
public func x(for date: Date) -> CGFloat {
max(0, CGFloat(date.timeIntervalSince(start) / 60) * pointsPerMinute)
}
/// Die Breite einer Sendung, auf das Fenster beschnitten.
public func width(from begin: Date, to finish: Date) -> CGFloat {
let visibleStart = max(begin, start)
let visibleEnd = min(finish, end)
let minutes = max(0, visibleEnd.timeIntervalSince(visibleStart) / 60)
return max(CGFloat(minutes) * pointsPerMinute, minimumBlockWidth)
}
/// Die Beschriftungen der Zeitachse, halbstündlich.
public var ticks: [Date] {
stride(from: 0, to: end.timeIntervalSince(start), by: Self.halfHour)
.map { start.addingTimeInterval($0) }
}
/// Wie breit eine Achsenbeschriftung ist.
public var tickWidth: CGFloat { CGFloat(Self.halfHour / 60) * pointsPerMinute }
}
@@ -0,0 +1,185 @@
import SwiftUI
/// Die Schriftskala der App.
///
/// Auf iOS die Systemstile, damit Dynamic Type wirkt. Die Rolle steht im
/// Namen, nicht die Größe — sonst führt jede Anpassung zu einer Suche quer
/// durch alle Views.
///
/// **Auf dem Mac stehen feste Größen.** Dieselben Stile sind dort deutlich
/// kleiner: `largeTitle` misst 26 Punkt statt 34, `body` 13 statt 17. Eine
/// Oberfläche, die auf dem iPad stimmt, wirkt damit auf dem Mac zusammen-
/// geschrumpft — und genau das war sie. Die Zahlen hier sind die von iOS.
///
/// Das kostet auf dem Mac Dynamic Type. Das ist vertretbar: macOS hat keine
/// systemweite Textgrößen-Einstellung, auf die Apps reagieren müssten, und ein
/// Fenster lässt sich stattdessen größer ziehen.
///
/// **Auf dem Fernseher wieder Systemstile, aber andere Rollen.** Dynamic Type
/// gibt es dort auch, und dass `body` grösser ausfällt als am Telefon, ist
/// der Sinn der Sache — drei Meter Abstand. Falsch wäre nur, die iOS-Zuordnung
/// einfach durchfallen zu lassen: die Skala ist dort anders gespreizt, und
/// eine Beschriftung in `subheadline` unter einem Plakat ist aus drei Metern
/// Zierrat.
extension Font {
#if os(macOS)
/// Der Titel im Aufmacher-Bild.
public static let haloHero = Font.system(size: 34, weight: .bold)
/// Titel auf Detailseiten.
public static let haloTitle = Font.system(size: 22, weight: .semibold)
/// Überschrift einer Reihe auf der Startseite.
public static let haloRowHeader = Font.system(size: 17, weight: .semibold)
/// Titel unter einem Poster.
public static let haloCardTitle = Font.system(size: 15, weight: .medium)
/// Fließtext, Beschreibungen.
public static let haloBody = Font.system(size: 17)
/// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht.
public static let haloCaption = Font.system(size: 12)
#elseif os(tvOS)
/// Der Titel im Aufmacher-Bild.
///
/// **`.title` und nicht `.largeTitle`.** In SwiftUI gibt es den Stil auf
/// tvOS, in UIKit nicht — `UIFontTextStyleLargeTitle` ist dort als nicht
/// verfügbar markiert. Was SwiftUI daraus macht, steht nirgends, und eine
/// Schrift, deren Grösse niemand nennen kann, gehört nicht in die grösste
/// Zeile der App.
public static let haloHero = Font.system(.title, design: .default, weight: .bold)
/// Titel auf Detailseiten.
public static let haloTitle = Font.system(.title2, design: .default, weight: .semibold)
/// Überschrift einer Reihe auf der Startseite.
public static let haloRowHeader = Font.system(.headline, design: .default, weight: .semibold)
/// Titel unter einem Poster — auf tvOS nur bei Fokus sichtbar.
///
/// Eine Stufe tiefer als auf iOS, und das ist gewollt: auf tvOS ist
/// `caption` schon gross, und ein Raster aus sechs Spalten mit
/// `subheadline`-Titeln hätte mehr Schrift als Bild.
public static let haloCardTitle = Font.system(.caption, design: .default, weight: .medium)
/// Fließtext, Beschreibungen.
public static let haloBody = Font.system(.body)
/// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht.
public static let haloCaption = Font.system(.caption2)
#else
/// Der Titel im Aufmacher-Bild.
public static let haloHero = Font.system(.largeTitle, design: .default, weight: .bold)
/// Titel auf Detailseiten.
public static let haloTitle = Font.system(.title2, design: .default, weight: .semibold)
/// Überschrift einer Reihe auf der Startseite.
public static let haloRowHeader = Font.system(.headline, design: .default, weight: .semibold)
/// Titel unter einem Poster.
public static let haloCardTitle = Font.system(.subheadline, design: .default, weight: .medium)
/// Fließtext, Beschreibungen.
public static let haloBody = Font.system(.body)
/// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht.
public static let haloCaption = Font.system(.caption)
#endif
}
/// Abstände.
///
/// Eine Skala statt frei gewählter Zahlen: dadurch stimmen die Ränder zwischen
/// Bildschirmen überein, ohne dass man sie einzeln nachmisst.
public enum HaloSpacing {
#if os(tvOS)
// Durchweg doppelt: ein Fernseher ist 1920 Punkte breit und wird aus drei
// Metern gelesen. Acht Punkte Abstand sind dort keine Trennung mehr.
public static let tight: CGFloat = 8
public static let snug: CGFloat = 16
public static let regular: CGFloat = 24
public static let loose: CGFloat = 32
public static let section: CGFloat = 48
/// Der seitliche Rand des Inhalts — **vom Bildschirmrand aus**.
///
/// Achtzig Punkte Überscan plus achtundvierzig Punkte Rinne.
///
/// **Warum die achtzig hier drinstehen.** SwiftUI legt sie von selbst als
/// Sicherheitsrand an, und normalerweise nähme man sie geschenkt. Der
/// Aufmacher muss aber bis an die Bildschirmkante reichen — ein Bild, das
/// achtzig Punkte vor dem Rand aufhört, sieht auf einem Fernseher nach
/// Fehler aus. Also laufen die Seiten dieser App bildfüllend
/// (`ignoresSafeArea`), und dann muss der Rand hier wieder dazu.
///
/// Das heisst zugleich: wer diesen Wert benutzt, ignoriert den
/// Sicherheitsrand. Beides gehört zusammen.
public static let screenMargin: CGFloat = 128
#else
public static let tight: CGFloat = 4
public static let snug: CGFloat = 8
public static let regular: CGFloat = 12
public static let loose: CGFloat = 16
public static let section: CGFloat = 24
/// Der seitliche Rand des Inhalts zum Bildschirmrand.
public static let screenMargin: CGFloat = 16
#endif
}
/// Der Überscan-Bereich eines Fernsehers.
///
/// **Was das ist.** Ein Fernseher zeigt nicht zwingend das ganze Bild: je nach
/// Gerät und Einstellung schneidet er am Rand ab, und was dort steht, sieht
/// niemand.
///
/// **Nachgemessen, nicht abgeschrieben.** Im Simulator eines Apple TV 4K
/// meldet SwiftUI bei 1920×1080 genau diese Ränder — sechzig oben und unten,
/// **achtzig** seitlich, Nutzfläche also 1760×960. Die verbreitete Angabe
/// „neunzig" stimmt nicht; eine zweite Quelle nannte achtzig und hatte recht.
///
/// **Und man bekommt sie geschenkt.** SwiftUI legt sie von selbst als
/// Sicherheitsrand an — deshalb steht in `HaloSpacing.screenMargin` nur die
/// Rinne innerhalb davon. Die Zahl hier ist für die Gegenrichtung: ein
/// bildfüllender Aufmacher, der den Sicherheitsrand absichtlich übergeht und
/// dessen Schrift trotzdem drinbleiben muss.
public enum HaloSafeArea {
public static let tv = EdgeInsets(top: 60, leading: 80, bottom: 60, trailing: 80)
}
public enum HaloRadius {
/// Die Ecke einer Kachel.
///
/// Auf dem Fernseher grösser: eine Kachel, die sich beim Fokus anhebt,
/// wirkt mit acht Punkten bei 1920 Punkten Breite kantig.
#if os(tvOS)
public static let card: CGFloat = 12
#else
public static let card: CGFloat = 8
#endif
public static let sheet: CGFloat = 16
public static let control: CGFloat = 10
/// Eine schwebende Fläche — die Seitenleiste. So rund wie das System sie
/// auf dem iPad zeichnet; daran misst sich der Nachbau.
public static let panel: CGFloat = 22
}
/// Die Seitenverhältnisse, in denen Plex seine Bilder liefert.
///
/// An einer Stelle, weil ein falscher Wert nicht auffällt, sondern nur
/// beschneidet: ein 16:9-Hintergrundbild in einem 3:4-Rahmen verliert links
/// und rechts je ein Viertel, und man sieht dem Ergebnis nicht an, dass etwas
/// fehlt.
public enum HaloAspectRatio {
/// Kinoplakate im Hochformat.
public static let poster: CGFloat = 2.0 / 3.0
/// Szenenbilder und Hintergrundbilder.
public static let landscape: CGFloat = 16.0 / 9.0
/// Der Aufmacher auf der Startseite — dasselbe wie `landscape`, weil Plex
/// dafür `art` liefert.
public static let hero: CGFloat = landscape
/// Der Aufmacher auf einem schmalen Bildschirm.
///
/// **Quer, aber beschnitten — und beides hat einen Grund.**
///
/// Quer, weil dort ein Hintergrundbild liegt. Vorher lag hier ein Plakat
/// (2:3), und das ist für einen Aufmacher das falsche Bild: ein Plakat
/// trägt den Titel schon selbst, und darüber liegt dann noch der
/// freigestellte Schriftzug — zweimal derselbe Name.
///
/// Beschnitten, weil 16:9 auf einem iPhone keine zweihundertfünfzig
/// Punkte Höhe sind, und darin sollen Schriftzug, Zeile mit Jahr und
/// Länge, ein Satz Beschreibung und zwei Knöpfe Platz finden. Sie fanden
/// ihn nicht und lagen übereinander. 4:3 gibt dem Bild rund ein Drittel
/// mehr Höhe; dafür fehlt ihm links und rechts zusammen ein Viertel.
public static let heroCompact: CGFloat = 4.0 / 3.0
/// Sendersymbole. Breiter als hoch, weil Senderlogos Schriftzüge sind —
/// quadratisch beschnitten fehlt der halbe Name.
public static let channelLogo: CGFloat = 5.0 / 3.0
}
@@ -0,0 +1,42 @@
import SwiftUI
/// Hält die gewählte Akzentfarbe und sorgt dafür, dass sie lesbar bleibt.
///
/// Nach außen gibt es nur `accent` — schon kontrastkorrigiert. Views sollen
/// sich nie fragen müssen, ob die eingestellte Farbe dunkel genug ist, um
/// darauf zu achten.
@MainActor
@Observable
public final class HaloTheme {
private let defaults: UserDefaults
/// Was in den Einstellungen ausgewählt ist — unkorrigiert, damit die
/// Farbkachel genau das zeigt, was angetippt wurde.
public private(set) var selection: AccentOption
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.selection = AccentOption.stored(in: defaults)
}
/// Die Farbe, die tatsächlich gezeichnet wird.
///
/// Bei den kuratierten Werten ändert die Korrektur nichts — sie erfüllen
/// den Grenzwert bereits. Sie greift nur bei eigenen Farben.
public var accent: RGB {
Contrast.legible(selection.color, on: HaloColor.canvas)
}
public var accentColor: Color { accent.swiftUIColor }
/// Ob die Korrektur bei der aktuellen Auswahl eingegriffen hat. Der
/// Einstellungsdialog kann darauf hinweisen, statt den Nutzer rätseln zu
/// lassen, warum seine Farbe heller aussieht als gewählt.
public var accentWasAdjusted: Bool { accent != selection.color }
public func select(_ option: AccentOption) {
selection = option
option.store(in: defaults)
}
}
@@ -0,0 +1,70 @@
{
"sourceLanguage" : "en",
"strings" : {
"Amber" : {
"comment" : "Name of the accent color option.",
"isCommentAutoGenerated" : true
},
"Custom" : {
"comment" : "Name of the accent color option when the user has selected a custom color.",
"isCommentAutoGenerated" : true
},
"Ember" : {
"comment" : "Name of the accent color \"Ember\".",
"isCommentAutoGenerated" : true
},
"Indigo" : {
"comment" : "Name of the accent color option \"Indigo\".",
"isCommentAutoGenerated" : true
},
"Lilac" : {
"comment" : "Name of the accent color option.",
"isCommentAutoGenerated" : true
},
"Lime" : {
"comment" : "Name of the accent color \"Lime\".",
"isCommentAutoGenerated" : true
},
"Mint" : {
"comment" : "Name of the accent color \"Mint\".",
"isCommentAutoGenerated" : true
},
"Orchid" : {
"comment" : "Name of the accent color \"Orchid\".",
"isCommentAutoGenerated" : true
},
"progress.watched" : {
"comment" : "Barrierefreiheits-Beschriftung des Fortschrittsbalkens unter angefangenen Titeln",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Fortschritt"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Progress"
}
}
}
},
"Rose" : {
"comment" : "Text for a color name.",
"isCommentAutoGenerated" : true
},
"Sky" : {
},
"Tangerine" : {
"comment" : "Name of the accent color option \"Tangerine\".",
"isCommentAutoGenerated" : true
},
"Teal" : {
"comment" : "Name of the accent color teal.",
"isCommentAutoGenerated" : true
}
},
"version" : "1.1"
}
@@ -0,0 +1,81 @@
import Foundation
import CoreGraphics
/// Wo die schwebende Videokachel sitzt.
///
/// Sie klebt an der linken oder rechten Seite und ist dort **frei in der
/// Höhe** — nicht auf vier Ecken festgelegt. Vier Ecken sind schnell gebaut,
/// aber im Weg: steht die Kachel unten rechts über einem Knopf, bleibt nur
/// die Wahl zwischen vier Orten, von denen drei ebenso stören.
///
/// Eigener Typ statt Rechnungen in der Ansicht, aus demselben Grund wie bei
/// `GuideLayout`: hier geht lautlos schief, was man nicht sieht. Eine Kachel,
/// die halb aus dem Bild ragt, fällt erst auf dem Gerät auf — und dann als
/// Gefühl, nicht als Fehler.
public struct MiniPlayerPlacement: Sendable, Equatable {
public enum Side: String, Sendable, CaseIterable, Codable {
case leading, trailing
}
/// Der sichtbare Bereich, in dem die Kachel sich bewegen darf.
public let bounds: CGSize
public let tile: CGSize
/// Abstand zum Rand. Eine Kachel, die den Rand berührt, sieht aus wie ein
/// Fehler statt wie eine Entscheidung.
public let inset: CGFloat
public init(bounds: CGSize, tile: CGSize, inset: CGFloat = HaloSpacing.regular) {
self.bounds = bounds
self.tile = tile
self.inset = inset
}
private var halfWidth: CGFloat { tile.width / 2 }
private var halfHeight: CGFloat { tile.height / 2 }
private var minimumX: CGFloat { inset + halfWidth }
private var maximumX: CGFloat { max(minimumX, bounds.width - inset - halfWidth) }
private var minimumY: CGFloat { inset + halfHeight }
private var maximumY: CGFloat { max(minimumY, bounds.height - inset - halfHeight) }
/// An welche Seite eine losgelassene Kachel gehört.
///
/// Entschieden wird nach dem **Mittelpunkt** der Kachel, nicht nach dem
/// Finger: sonst springt eine Kachel, die man am Rand angefasst hat, auf
/// die falsche Seite.
public func nearestSide(toCenter center: CGPoint) -> Side {
center.x < bounds.width / 2 ? .leading : .trailing
}
/// Wie weit unten die Kachel sitzt: 0 ganz oben, 1 ganz unten.
///
/// Als Anteil statt als Punktwert, damit die Kachel beim Drehen des Geräts
/// an derselben *relativen* Stelle bleibt, statt unten herauszufallen.
public func verticalFraction(forCenter center: CGPoint) -> Double {
let travel = maximumY - minimumY
guard travel > 0 else { return 0 }
return min(max(Double((center.y - minimumY) / travel), 0), 1)
}
public func center(side: Side, verticalFraction: Double) -> CGPoint {
let fraction = min(max(verticalFraction, 0), 1)
return CGPoint(
x: side == .leading ? minimumX : maximumX,
y: minimumY + (maximumY - minimumY) * CGFloat(fraction)
)
}
/// Hält einen Mittelpunkt im sichtbaren Bereich.
///
/// Während des Ziehens, damit die Kachel nicht hinter den Rand rutscht und
/// dort unerreichbar wird — der Finger wäre dann schneller als das Ziel.
public func clamped(center: CGPoint) -> CGPoint {
CGPoint(
// `max(min…)` in dieser Reihenfolge, weil bei einem Bereich, der
// schmaler ist als die Kachel, sonst der untere Anschlag verliert
// und die Kachel rechts hinausragt.
x: max(minimumX, min(maximumX, center.x)),
y: max(minimumY, min(maximumY, center.y))
)
}
}
@@ -0,0 +1,205 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
/// Was auf iPhone, Mac und Fernseher verschieden heißt — an einer Stelle.
///
/// Nicht jede Ansicht soll `#if os(iOS)` tragen. Ein Formular, das auf beiden
/// Plattformen dasselbe meint, soll auf beiden gleich aussehen; wo die
/// Systeme verschiedene Namen dafür haben, steht der Unterschied hier und
/// nirgends sonst. Sonst wandert er in zwanzig Dateien und wird beim
/// nächsten Bildschirm vergessen.
/// Auf was für einem Gerät läuft das hier gerade?
public enum HaloPlatform {
/// Läuft die App auf einem Mac?
///
/// **Zwei Wege führen dorthin, und für den Übersetzer sehen sie
/// verschieden aus.** Catalyst ist ein eigener Bau und meldet sich als
/// `targetEnvironment(macCatalyst)`. „Für iPad angepasst" ist dagegen
/// buchstäblich der iOS-Bau, der auf Apple Silicon startet — dort ist
/// dieselbe Abfrage schlicht falsch.
///
/// Wer nur die erste Hälfte prüft, schreibt Code für den Mac, der auf dem
/// Mac nicht läuft: übersetzt sauber, ist aber genau dort abgeschaltet,
/// wo er gebraucht wird. Deshalb hier beide Wege, an einer Stelle.
///
/// **Und warum unten `#error` steht.** Der `#else`-Zweig lautete `true` —
/// eine Antwort auf eine Frage, die niemand gestellt hatte. Auf tvOS fiel
/// er deshalb hindurch und meldete einen Mac: kein Übersetzungsfehler,
/// keine Warnung, nur ein Fernseher, der sich für einen Mac hielt. Eine
/// Vorgabe ist an dieser Stelle immer falsch, weil die nächste Plattform
/// nicht danach fragt, ob sie gemeint war. Jetzt bricht der Bau ab und
/// verlangt eine Entscheidung.
public static let isMac: Bool = {
#if os(tvOS)
false
#elseif targetEnvironment(macCatalyst)
true
#elseif os(iOS)
ProcessInfo.processInfo.isiOSAppOnMac
#elseif os(macOS)
true
#else
#error("HaloPlatform kennt diese Plattform nicht. Hier eintragen — nicht raten.")
#endif
}()
/// Läuft die App auf einem Fernseher?
///
/// Daneben und nicht in einem Aufzählungstyp: über die Frage wird
/// nirgends verzweigt, sie steht überall in einem Fragezeichen-Ausdruck.
/// Ein Aufzählungstyp wäre Zeremonie — und „iPhone oder iPad" liesse sich
/// darin ohnehin nicht beantworten, weil das `UIDevice.current` verlangt
/// und damit den Hauptakteur.
public static let isTV: Bool = {
#if os(tvOS)
true
#elseif os(iOS) || os(macOS)
false
#else
#error("HaloPlatform kennt diese Plattform nicht. Hier eintragen — nicht raten.")
#endif
}()
}
extension View {
/// Eine schmale Titelzeile — auf dem Mac und auf dem Fernseher gibt es
/// sie nicht. Beide fallen in denselben `#else`-Zweig, und beide zu Recht.
public func haloInlineTitle() -> some View {
#if os(iOS)
navigationBarTitleDisplayMode(.inline)
#else
self
#endif
}
/// Ein Sheet, das die halbe Höhe einnimmt.
///
/// Auf dem Mac ist ein Sheet ein Fenster: dort zählt keine Rastung,
/// sondern eine vernünftige Mindestgröße.
public func haloMediumSheet() -> some View {
#if os(tvOS)
// Auf dem Fernseher ist ein Sheet immer bildfüllend. `presentationDetents`
// übersetzt dort zwar — es gibt sie seit tvOS 16 —, bewirkt aber
// nichts. Und eine Zeile, die nichts bewirkt, liest sich beim nächsten
// Mal wie eine, die etwas bewirkt.
self
#else
// **Zur Laufzeit, nicht beim Übersetzen.** Der `#else`-Zweig für
// macOS griff nie: auf dem Mac läuft der iOS-Bau, und damit bekam ein
// grosses Fenster eine Sheet-Kante in iPhone-Höhe — ein schmaler
// Streifen, der unten im Nichts klebt.
presentationDetents(HaloPlatform.isMac ? [.large] : [.medium])
#endif
}
/// Dasselbe, aber auf Wunsch bis zur vollen Höhe aufziehbar.
public func haloResizableSheet() -> some View {
#if os(tvOS)
self
#else
// Siehe oben. Auf dem Mac gibt es kein „halb aufgezogen": ein Fenster
// hat die Höhe, die es hat, und eine Liste soll sie nutzen.
presentationDetents(HaloPlatform.isMac ? [.large] : [.medium, .large])
#endif
}
/// Ein Formular, das auf beiden Seiten gleich aussieht.
///
/// Auf tvOS gibt es `formStyle` nicht und es ist auch nicht nötig — dort
/// fällt es in denselben Zweig wie iOS.
///
/// Auf dem Mac setzt SwiftUI sonst den Spalten-Stil: Beschriftungen rechts
/// ausgerichtet, keine abgesetzten Abschnitte, alles eng. Der gruppierte
/// Stil ist derselbe, den iOS ohnehin nimmt — und der Grund, warum die
/// Einstellungen auf dem Mac wie ein Systemdialog aus einem anderen
/// Jahrzehnt aussahen.
public func haloForm() -> some View {
#if os(macOS)
formStyle(.grouped)
#else
self
#endif
}
/// Eingabefelder für Adressen und Namen: keine Großschreibung, keine
/// Korrektur — auf dem Mac gibt es beides ohnehin nicht als Tastatur.
///
/// Auf tvOS ebenfalls der `#else`-Zweig: die Bildschirmtastatur dort
/// kennt weder `keyboardType` noch Autokorrektur in dieser Form.
public func haloPlainEntry(isAddress: Bool = false) -> some View {
#if os(iOS)
self
.keyboardType(isAddress ? .URL : .default)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
#else
autocorrectionDisabled()
#endif
}
}
extension View {
/// Keine eigene Fläche hinter der Titelzeile.
///
/// Auf der Startseite liegt darunter der Aufmacher — eine weichgezeichnete
/// Leiste verschleierte die Hälfte davon. Auf dem Mac und auf dem
/// Fernseher gibt es die Leiste in dieser Form nicht — beide fallen in
/// den `#else`-Zweig.
public func haloClearToolbarBackground() -> some View {
#if os(iOS)
self
.toolbarBackground(.hidden, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
#else
self
#endif
}
}
/// Etwas in die Zwischenablage legen.
///
/// UIKit und AppKit heißen verschieden und meinen dasselbe; die Diagnose soll
/// deswegen nicht mit `#if` beginnen.
public enum HaloClipboard {
public static func copy(_ text: String) {
#if os(tvOS)
// Es gibt keine. `UIPasteboard` ist auf tvOS als nicht verfügbar
// markiert — und ein Fernseher hat auch nichts, wohin man etwas
// einfügen könnte.
//
// **Die Reihenfolge ist der ganze Punkt.** `canImport(UIKit)` ist auf
// tvOS wahr; stünde dieser Zweig darunter, wäre es kein stiller
// Fehler, sondern ein lauter.
_ = text
#elseif canImport(UIKit)
UIPasteboard.general.string = text
#else
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
#endif
}
}
extension ToolbarItemPlacement {
/// Rechts in der Titelzeile.
public static var haloTrailing: ToolbarItemPlacement {
#if os(iOS) || os(tvOS)
.topBarTrailing
#else
.primaryAction
#endif
}
/// Links in der Titelzeile.
public static var haloLeading: ToolbarItemPlacement {
#if os(iOS) || os(tvOS)
.topBarLeading
#else
.navigation
#endif
}
}