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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user