Files
Flaro/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift
T
ScarriffleandClaude Opus 5 fccec1217b Eigene Seitenleiste — überall dieselbe
Die System-Seitenleiste sitzt auf dem iPad als schwebende Fläche und auf dem
Mac angeklebt am Fensterrand. `preferredLayout = .overlap` ist die dafür
vorgesehene Bitte, und macOS verwirft sie stillschweigend — gemessen, nicht
vermutet.

Statt deshalb die ganze Navigation neu zu bauen, bleibt der `TabView` stehen
und verliert nur sein Kleid: `TabChromeSuppressor` blendet Seiten- und
Tableiste aus, gezeichnet wird darüber. Auswahl, ein eigener Navigationsstapel
je Eintrag, erhaltener Scrollstand beim Wechseln — all das macht weiter das
System. Dass das Ausblenden geht, war kein Ratschluss: derselbe Griff hatte die
Leiste vorher schon versuchsweise verschwinden lassen.

Neu sind `FlixySidebar` (schwebende Fläche mit eigenem Rand, Mediatheken
einklappbar, Konto unten) und `FlixyTopBar` (dieselben Einträge als Leiste
oben, Mediatheken als Menü, weil oben der Platz waagerecht begrenzt ist). Das
iPhone bleibt beim System: dort gibt es keine Seitenleiste, sondern die Leiste
unten, und die ist überall dieselbe.

Der Platz für die Leiste ist ein **Sicherheitsrand**, kein Abstand, und er
hängt je Bildschirm — weder `TabView` noch `NavigationStack` reichen ihn an
ihren Inhalt weiter. Damit bleibt eine Reihe fensterbreit und schiebt ihre
Kacheln beim Wischen hinter die Leiste, stellt aber im Ruhezustand keine
dorthin. Der Aufmacher darf über seinen Bereich hinaus; das `.clipped()` im
Karussell musste dafür weichen, es schnitt genau das ab.

Noch nicht dabei: „Bearbeiten" mit Ziehen und Ein-/Ausblenden. Die Einträge
stehen in fester Reihenfolge.

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

187 lines
8.2 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 = availableLogo {
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, ArtworkLoader.cachedImage(for: logoURL) == nil else { return }
logo = await ArtworkLoader.shared.image(for: logoURL)
}
}
/// 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? {
if let logo { return logo }
guard let logoURL else { return nil }
return ArtworkLoader.cachedImage(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. Dass es das darf, hängt
// daran, dass der Aufmacher **nicht** beschnitten wird; siehe
// `HeroCarousel`. 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
)
}
}
}
}