**Eine Taste zum Vergrößern** unten rechts auf der Kachel. Das Antippen der Fläche vergrößert weiterhin, aber auf derselben Fläche liegen schon das Ziehen und das Aufziehen — und was dabei danebengeht, sieht aus wie „reagiert nicht". Ein Knopf geht nicht daneben. **Ton und Untertitel getrennt.** Es sind zwei verschiedene Fragen: die Tonspur stellt man einmal am Anfang um, die Untertitel mitten im Film. In einem gemeinsamen Blatt scrollte man jedes Mal an der anderen Liste vorbei. Das Untertitel-Symbol ist gefüllt, solange welche laufen — so sieht man es, ohne das Blatt zu öffnen. Und eine Auswahl schließt das Blatt jetzt selbst. **AirPlay.** Nicht nachgebaut, sondern die Systemstelle: sie kennt Geräte, die eine App nie aufzählen könnte. Der Knopf erscheint nur beim Systemplayer — VLC zeichnet selbst und kann kein AirPlay, und ein Knopf, der dort ins Leere führte, wäre schlimmer als keiner. **Die Qualität steht als Text da**, nicht als drei Schieberegler: „1080p", „720p", „Auto". Bei „automatisch" steht dort, was gerade tatsächlich ankommt — das weiß nur der Player, und ihn zu fragen ist ehrlicher, als „Auto" hinzuschreiben und die Antwort schuldig zu bleiben. Dazu die Einstellungen mit farbigen Symbolen und einer Kopfkarte je Bereich. Das ist nicht nur Zierde: in einer Liste aus zehn gleich aussehenden Zeilen findet das Auge nichts wieder, und „das mit dem blauen Zahnrad" ist eine brauchbare Beschreibung. Die Farben sind fest und ausdrücklich **nicht** die Akzentfarbe — trüge alles dieselbe, unterschiede sie nichts mehr. Und der Aufmacher läuft jetzt unter der Seitenleiste weiter (`backgroundExtensionEffect`): die Ränder werden gespiegelt und weich fortgeschrieben, während Titel und Knöpfe stehen bleiben. Vorher endete das Bild an der Kante der Leiste, und beim Weiterwandern schob sich das nächste an einer harten Linie ins Bild. 263 Tests grün. Player-Knöpfe und Einstellungen angesehen; den Aufmacher hinter der Leiste konnte ich an den einfarbigen Flächen der Attrappe nicht sicher beurteilen — der Simulator ist dabei erneut abgestürzt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
135 lines
5.3 KiB
Swift
135 lines
5.3 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()
|
|
}
|
|
|
|
/// 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 var titleView: 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, maxHeight: 76, 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
|
|
}
|
|
}
|
|
.accessibilityLabel(Text(title))
|
|
} else {
|
|
plainTitle
|
|
}
|
|
}
|
|
|
|
private var plainTitle: some View {
|
|
Text(title)
|
|
.font(.flixyHero)
|
|
.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, statt das Bild zu
|
|
// verzerren; der Bildinhalt selbst bleibt, wo er ist, und mit ihm
|
|
// Titel, Beschreibung und Knöpfe.
|
|
//
|
|
// Ohne das endete der Aufmacher an der Kante der Seitenleiste, und
|
|
// beim Weiterwandern schob sich das nächste Bild an einer harten
|
|
// Linie ins Bild.
|
|
.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) {
|
|
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
|
|
titleView
|
|
|
|
if let metadata {
|
|
Text(metadata)
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
}
|
|
|
|
if let summary {
|
|
Text(summary)
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
.lineLimit(2)
|
|
}
|
|
|
|
HStack(spacing: FlixySpacing.regular) {
|
|
actions
|
|
}
|
|
.padding(.top, FlixySpacing.tight)
|
|
}
|
|
.padding(.horizontal, FlixySpacing.screenMargin)
|
|
.padding(.bottom, FlixySpacing.loose)
|
|
}
|
|
}
|
|
}
|