Unter dem Folgentitel stand „S2 E1" als Text, und wer zur Serie wollte, musste darunter eine Knopfreihe finden. Jetzt steht dort „Serienname · Staffel 2 · Episode 1", und Serienname wie Staffel führen dorthin, wohin sie zeigen. Der Name steht ohnehin da — dann soll er auch der Weg sein. `HeroBanner` nimmt dafür wahlweise eine fertige Zeile entgegen statt eines Textes. Gebaut wird sie beim Aufrufer, weil nur der weiß, wohin ein Teil führt; die Gestaltung kennt keine Ziele. Die Knopfreihe bleibt bei Staffeln, wo sie der einzige Weg nach oben ist, und entfällt bei Folgen. Beim Entfernen eines Downloads gibt es keine Rückfrage mehr. Sie stand da, weil „löschen" nach etwas Endgültigem klingt — es ist aber nur die heruntergeladene Kopie, der Titel bleibt auf dem Server. Wer nach links wischt und auf „Löschen" tippt, hat zweimal gesagt, was er will. Und das Umschalten sieht jetzt nach Umzug aus: die Seitenleiste klappt nach oben weg, die Leiste oben schiebt sich seitwärts herein — und zurück genauso. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
8.4 KiB
Swift
192 lines
8.4 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?
|
|
/// 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 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
|
|
/// Ob der Aufmacher den Platz einnimmt, den er bekommt.
|
|
private let fillsAvailableSpace: Bool
|
|
private let actions: Actions
|
|
|
|
@State private var logo: PlatformImage?
|
|
|
|
public init(
|
|
title: String,
|
|
metadata: String? = nil,
|
|
metadataContent: AnyView? = 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,
|
|
fillsAvailableSpace: Bool = false,
|
|
@ViewBuilder actions: () -> Actions
|
|
) {
|
|
self.title = title
|
|
self.metadata = metadata
|
|
self.metadataContent = metadataContent
|
|
self.summary = summary
|
|
self.artworkURL = artworkURL
|
|
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)
|
|
}
|
|
|
|
/// 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, fillsFrame: fillsAvailableSpace)
|
|
.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 metadataContent {
|
|
metadataContent
|
|
.font(.system(size: 12 * scale))
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
} else 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
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|