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:
Scarriffle
2026-10-03 19:38:27 +02:00
co-authored by Claude Opus 5
parent f98b995050
commit a7cf6c5def
390 changed files with 2078 additions and 2069 deletions
@@ -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)))))
}
}