Files
Flaro/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift
T
ScarriffleandClaude Opus 5 e5e01838f7 Der Schriftzug gehört zum Aufmacher, nicht zu sich selbst
`AsyncImage` bringt seine eigene Einblendung mit und fängt erst an zu laden,
wenn es erscheint. Beim Überblenden des Aufmachers blendete sich der Schriftzug
des Films deshalb getrennt und später ein, während Titelzeile, Beschreibung und
Knöpfe längst mitgewandert waren — zwei Bewegungen, wo eine sein soll.

Jetzt lädt er über denselben Zwischenspeicher wie alle anderen Bilder, und das
Karussell holt ihn zusammen mit dem Hintergrundbild vor. Beim Erscheinen liegt
er bereit und macht mit, was der Aufmacher macht.

Dazu ein kleines Hilfsprogramm, das die Fensterkennung eines laufenden
Programms ausgibt, damit `screencapture -l` genau dessen Fenster fotografiert
und sonst nichts vom Bildschirm. Das System-Python bringt PyObjC nicht mehr
mit, und ohne Kennung gibt es keinen Weg dorthin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:25:32 +02:00

177 lines
7.6 KiB
Swift

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?
/// 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
private let actions: Actions
@State private var logo: PlatformImage?
public init(
title: String,
metadata: String? = nil,
summary: String? = nil,
artworkURL: URL?,
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 = FlixyAspectRatio.hero,
@ViewBuilder actions: () -> Actions
) {
self.title = title
self.metadata = metadata
self.summary = summary
self.artworkURL = artworkURL
self.logoURL = logoURL
self.aspectRatio = aspectRatio
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)
}
/// 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.
@ViewBuilder
private func titleView(scale: CGFloat) -> some View {
Group {
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.
plainTitle(scale: scale)
}
}
.task(id: logoURL) {
guard let logoURL else {
logo = nil
return
}
logo = await ArtworkLoader.shared.image(for: logoURL)
}
}
private func plainTitle(scale: CGFloat) -> some View {
Text(title)
.font(.system(size: 34 * scale, weight: .bold))
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(2)
}
public var body: some View {
FlixyArtwork(url: artworkURL, aspectRatio: aspectRatio)
// Das Bild läuft über seinen Bereich hinaus — unter die
// Seitenleiste und bis an den Fensterrand. Der Effekt spiegelt die
// Ränder und zeichnet sie weich weiter; hinter der Leiste steht
// damit ein anderer Ausschnitt desselben Plakats.
//
// Das fiel unangenehm auf, solange der Aufmacher seitwärts
// durchblätterte: beim Weiterschieben wanderten der echte und der
// gespiegelte Ausschnitt gegeneinander, und die Naht dazwischen
// war nicht zu übersehen. Nicht der Effekt war falsch, sondern die
// Bewegung — siehe `HeroCarousel`.
.backgroundExtensionEffect()
.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) {
GeometryReader { geometry in
let scale = scale(for: geometry.size.width)
VStack(alignment: .leading, spacing: FlixySpacing.snug * scale) {
titleView(scale: scale)
if let metadata {
Text(metadata)
.font(.system(size: 12 * scale))
.foregroundStyle(Color.flixyTextSecondary)
}
if let summary {
Text(summary)
.font(.system(size: 12 * scale))
.foregroundStyle(Color.flixyTextSecondary)
.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: FlixySpacing.regular) {
actions
}
.padding(.top, FlixySpacing.tight)
}
.padding(.horizontal, FlixySpacing.screenMargin * scale)
.padding(.bottom, FlixySpacing.loose * scale)
.frame(
width: geometry.size.width,
height: geometry.size.height,
alignment: .bottomLeading
)
}
}
}
}