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>
187 lines
8.2 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|