Der gespiegelte Ausschnitt hinter der Seitenleiste war nicht das Problem — JellyTV macht es genauso. Auffällig wurde er erst durch die Bewegung: beim seitlichen Durchblättern wanderten das echte und das gespiegelte Stück gegeneinander, und die Naht dazwischen war das auffälligste Ding auf dem Bildschirm. Jetzt rutscht das neue Bild 32 Punkte herein und blendet dabei über das alte. Nichts wandert weit genug, als dass ein Versatz auffiele. Wischen blättert weiter, nach vorn wie zurück; das nächste Bild wird geholt, solange das aktuelle noch steht, sonst blendet der Aufmacher auf eine graue Fläche über. Dazu ein Testziel, das die App mit der Attrappe in einen Zustand fährt und fotografiert. Der Grund ist ein wiederkehrender Fehler: Aussagen über das Aussehen waren Vermutungen, weil niemand hinsehen konnte — der Simulator kennt keinen Befehl fürs Antippen, und dieses Xcode bringt kein Simulator-Fenster mehr mit. Damit ist „sieht es so aus?" eine Frage, die sich beantworten lässt. Die Startargument-Anmeldung an der Attrappe steht unter #if DEBUG und führt am Schlüsselbund vorbei: kein echter Server, kein echtes Konto, kein echter Token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
162 lines
6.9 KiB
Swift
162 lines
6.9 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
|
|
|
|
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.
|
|
///
|
|
/// Der Schriftzug wird ohne Bild-Transcoder geladen; er ist ein
|
|
/// freigestelltes PNG, und der Transcoder liefert JPEG. Aus dem
|
|
/// freigestellten Logo würde sonst ein schwarzer Kasten.
|
|
@ViewBuilder
|
|
private func titleView(scale: CGFloat) -> some View {
|
|
if let logoURL {
|
|
AsyncImage(url: logoURL) { phase in
|
|
switch phase {
|
|
case .success(let image):
|
|
image
|
|
.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)
|
|
default:
|
|
// Solange geladen wird — und wenn es scheitert — bleibt es
|
|
// beim gesetzten Titel. Ein leerer Platz wäre schlimmer
|
|
// als eine Systemschrift.
|
|
plainTitle(scale: scale)
|
|
}
|
|
}
|
|
.accessibilityLabel(Text(title))
|
|
} else {
|
|
plainTitle(scale: scale)
|
|
}
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|