Phase 0: Projektgerüst, Design-Tokens und Navigation
Legt das Fundament für den Plex-Client an: - XcodeGen-Projekt nach dem Muster von Onyx (com.scarriffleservices, strict concurrency, Projektdatei nicht eingecheckt) - FlixyKit mit vier Modulen: PlexKit, FlixyDesign, FlixyPlayback, FlixyDownloads. macOS steht als Plattform mit drin, damit die Logik-Tests ohne Simulator laufen. - FlixyDesign: dunkle Farb-Tokens, Schriftskala, Poster- und Querformat-Kacheln, Aufmacher-Bild. Die Akzentfarbe fließt über die Umgebung und wird per WCAG-Rechnung lesbar gehalten. - PlexKit: Kopfzeilen-Identität, Transport-Protokoll, Fehlerarten und die Medienmodelle samt nachsichtigem Decoder. - FlixyPlayback: VideoPlayerEngine-Protokoll und PlaybackDecider — die eine Funktion, an der Phase 2 das Transcoding andockt. - Ausfahrbare Seitenleiste auf dem iPhone, Split-View auf dem iPad. - 45 Tests, alle grün. VLCKit 4.0.0-a24 ist vorab geprüft: baut über SPM unter Swift 6 mit strict concurrency. Die iOS-Slice ist mit 239 MB allerdings gewichtig, deshalb bekommt sie in Phase 1 ein eigenes Target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import SwiftUI
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Die aktuelle Akzentfarbe, bereits kontrastkorrigiert.
|
||||
///
|
||||
/// Bewusst eine schlichte `Color` in der Umgebung statt einer Abhängigkeit
|
||||
/// auf `FlixyTheme`: 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.
|
||||
@Entry public var flixyAccent: Color = AccentOption.default.color.swiftUIColor
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Setzt die Akzentfarbe für diesen Teilbaum.
|
||||
///
|
||||
/// Wird einmal an der Wurzel aus `FlixyTheme` gesetzt; darunter greift
|
||||
/// alles automatisch darauf zu.
|
||||
public func flixyAccent(_ color: Color) -> some View {
|
||||
environment(\.flixyAccent, color)
|
||||
}
|
||||
|
||||
public func flixyAccent(_ theme: FlixyTheme) -> some View {
|
||||
environment(\.flixyAccent, theme.accentColor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine wählbare Akzentfarbe.
|
||||
///
|
||||
/// Die kuratierten Werte sind so gewählt, dass sie auf `FlixyColor.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: "orchid", name: "Orchid", color: RGB(hex: 0xC04AC9)),
|
||||
AccentOption(id: "rose", name: "Rose", color: RGB(hex: 0xF76190)),
|
||||
]
|
||||
|
||||
public static let `default` = 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
|
||||
/// Kontrastrechnung nach WCAG 2.1.
|
||||
///
|
||||
/// Gebraucht wird sie für die eigene Akzentfarbe: Flixy 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.
|
||||
///
|
||||
/// Flixy 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 `FlixyTheme`.
|
||||
public enum FlixyColor {
|
||||
// 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 flixyCanvas = FlixyColor.canvas.swiftUIColor
|
||||
public static let flixySurface = FlixyColor.surface.swiftUIColor
|
||||
public static let flixyElevated = FlixyColor.elevated.swiftUIColor
|
||||
public static let flixyPlaceholder = FlixyColor.placeholder.swiftUIColor
|
||||
public static let flixyTextPrimary = FlixyColor.textPrimary.swiftUIColor
|
||||
public static let flixyTextSecondary = FlixyColor.textSecondary.swiftUIColor
|
||||
public static let flixyTextMuted = FlixyColor.textMuted.swiftUIColor
|
||||
public static let flixyDestructive = FlixyColor.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 flixySeparator = Color.white.opacity(0.10)
|
||||
/// Die Abdunklung hinter der ausgefahrenen Seitenleiste.
|
||||
public static let flixyScrim = 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,44 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Ein Bild vom Server mit Lade- und Fehlerzustand.
|
||||
///
|
||||
/// Kapselt `AsyncImage`, damit der Austausch gegen einen eigenen, zwischen-
|
||||
/// speichernden Lader später genau eine Datei betrifft. Die Startseite zeigt
|
||||
/// dutzende Bilder gleichzeitig; ohne Zwischenspeicher lädt `AsyncImage` sie
|
||||
/// bei jedem Erscheinen neu.
|
||||
public struct FlixyArtwork: View {
|
||||
private let url: URL?
|
||||
private let aspectRatio: CGFloat
|
||||
private let fallbackSymbol: String
|
||||
|
||||
public init(url: URL?, aspectRatio: CGFloat, fallbackSymbol: String = "film") {
|
||||
self.url = url
|
||||
self.aspectRatio = aspectRatio
|
||||
self.fallbackSymbol = fallbackSymbol
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Rectangle()
|
||||
.fill(Color.flixyPlaceholder)
|
||||
.aspectRatio(aspectRatio, contentMode: .fit)
|
||||
.overlay {
|
||||
AsyncImage(url: url, transaction: Transaction(animation: .easeOut(duration: 0.2))) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFill()
|
||||
case .failure:
|
||||
// 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.flixyTextMuted)
|
||||
case .empty:
|
||||
ShimmerPlaceholder()
|
||||
@unknown default:
|
||||
ShimmerPlaceholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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?
|
||||
private let summary: String?
|
||||
private let artworkURL: URL?
|
||||
private let actions: Actions
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
metadata: String? = nil,
|
||||
summary: String? = nil,
|
||||
artworkURL: URL?,
|
||||
@ViewBuilder actions: () -> Actions
|
||||
) {
|
||||
self.title = title
|
||||
self.metadata = metadata
|
||||
self.summary = summary
|
||||
self.artworkURL = artworkURL
|
||||
self.actions = actions()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
FlixyArtwork(url: artworkURL, aspectRatio: 3.0 / 4.0)
|
||||
.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.flixyCanvas.opacity(0.75), location: 0.45),
|
||||
.init(color: Color.flixyCanvas, location: 1),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
|
||||
Text(title)
|
||||
.font(.flixyHero)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
.lineLimit(2)
|
||||
|
||||
if let metadata {
|
||||
Text(metadata)
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
}
|
||||
|
||||
if let summary {
|
||||
Text(summary)
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
HStack(spacing: FlixySpacing.regular) {
|
||||
actions
|
||||
}
|
||||
.padding(.top, FlixySpacing.tight)
|
||||
}
|
||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
||||
.padding(.bottom, FlixySpacing.loose)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: FlixySpacing.snug) {
|
||||
FlixyArtwork(url: artworkURL, aspectRatio: Self.aspectRatio, fallbackSymbol: "play.rectangle")
|
||||
.overlay(alignment: .bottom) {
|
||||
if let progress {
|
||||
WatchProgressBar(fraction: progress)
|
||||
}
|
||||
}
|
||||
.clipShape(.rect(cornerRadius: FlixyRadius.card))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.flixyCardTitle)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -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: FlixySpacing.snug) {
|
||||
FlixyArtwork(url: artworkURL, aspectRatio: Self.aspectRatio)
|
||||
.clipShape(.rect(cornerRadius: FlixyRadius.card))
|
||||
.overlay(alignment: .bottom) {
|
||||
if let progress {
|
||||
WatchProgressBar(fraction: progress)
|
||||
}
|
||||
}
|
||||
.clipShape(.rect(cornerRadius: FlixyRadius.card))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.flixyCardTitle)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
.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.flixyPlaceholder)
|
||||
.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,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(\.flixyAccent) 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,41 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die Schriftskala der App.
|
||||
///
|
||||
/// Durchgehend die Systemschrift mit `Font.TextStyle` als Grundlage, 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.
|
||||
extension Font {
|
||||
/// Der Titel im Aufmacher-Bild.
|
||||
public static let flixyHero = Font.system(.largeTitle, design: .default, weight: .bold)
|
||||
/// Titel auf Detailseiten.
|
||||
public static let flixyTitle = Font.system(.title2, design: .default, weight: .semibold)
|
||||
/// Überschrift einer Reihe auf der Startseite.
|
||||
public static let flixyRowHeader = Font.system(.headline, design: .default, weight: .semibold)
|
||||
/// Titel unter einem Poster.
|
||||
public static let flixyCardTitle = Font.system(.subheadline, design: .default, weight: .medium)
|
||||
/// Fließtext, Beschreibungen.
|
||||
public static let flixyBody = Font.system(.body)
|
||||
/// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht.
|
||||
public static let flixyCaption = Font.system(.caption)
|
||||
}
|
||||
|
||||
/// 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 FlixySpacing {
|
||||
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
|
||||
}
|
||||
|
||||
public enum FlixyRadius {
|
||||
public static let card: CGFloat = 8
|
||||
public static let sheet: CGFloat = 16
|
||||
public static let control: CGFloat = 10
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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 FlixyTheme {
|
||||
private let defaults: UserDefaults
|
||||
|
||||
private enum Key {
|
||||
static let selectedIdentifier = "flixy.accent.identifier"
|
||||
static let customHex = "flixy.accent.customHex"
|
||||
}
|
||||
|
||||
/// 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 = Self.restore(from: 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: FlixyColor.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
|
||||
defaults.set(option.id, forKey: Key.selectedIdentifier)
|
||||
if option.id == AccentOption.customIdentifier {
|
||||
defaults.set(option.color.hexString, forKey: Key.customHex)
|
||||
}
|
||||
}
|
||||
|
||||
private static func restore(from defaults: UserDefaults) -> AccentOption {
|
||||
guard let identifier = defaults.string(forKey: Key.selectedIdentifier) else {
|
||||
return .default
|
||||
}
|
||||
if identifier == AccentOption.customIdentifier {
|
||||
// Fällt die gespeicherte Zeichenkette aus (etwa weil jemand die
|
||||
// Voreinstellungen von Hand bearbeitet hat), ist der Standard
|
||||
// besser als eine Farbe, die niemand gewählt hat.
|
||||
guard let hex = defaults.string(forKey: Key.customHex),
|
||||
let color = RGB(string: hex)
|
||||
else { return .default }
|
||||
return .custom(color)
|
||||
}
|
||||
return AccentOption.palette.first { $0.id == identifier } ?? .default
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"progress.watched" : {
|
||||
"comment" : "Barrierefreiheits-Beschriftung des Fortschrittsbalkens unter angefangenen Titeln",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : { "state" : "translated", "value" : "Fortschritt" }
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : { "state" : "translated", "value" : "Progress" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
Reference in New Issue
Block a user