Der Aufmacher auf dem iPhone hat nicht das Hintergrundbild geholt,
sondern das **Plakat**. Dafür gab es einen Grund, und er stimmte: ein
16:9-Bild in einem 2:3-Rahmen ist ein Streifen aus der Mitte.
Nur war der Rahmen das Problem, nicht das Bild. Ein Plakat trägt den
Titel selbst, und darüber legt der Aufmacher noch den freigestellten
Schriftzug — derselbe Name zweimal, einmal gesetzt und einmal gemalt.
**Jetzt ein Querbild in einem 4:3-Rahmen.** 16:9 ginge nicht: das sind
auf 440 Punkten Breite 248 Punkte Höhe, und darin sollen Schriftzug,
Zeile, ein Satz Beschreibung und zwei Knöpfe stehen. Sie standen schon
einmal übereinander, das ist der dokumentierte Grund für das Hochformat.
4:3 gibt 330 Punkte — gemessen passt der Text hinein; dafür fehlt dem
Bild links und rechts zusammen ein Viertel.
**Die Detailseite bleibt hochkant.** Dort hat man sich schon entschieden;
das Plakat ist das Bild dieses Films, und du wolltest den Kopf der Seite
ausdrücklich höher. Beide Seiten lasen bisher aus **derselben**
Funktion — eine Änderung für den Aufmacher hätte die Detailseite von 660
auf 330 Punkte gestutzt, ohne dass irgendwo ein Fehler aufgetaucht wäre.
`HeroArtwork` kennt deshalb jetzt den Verwendungszweck:
HeroArtwork.aspectRatio(.detailHeader, compact: …) // 2:3
HeroArtwork.aspectRatio(compact: …) // 4:3 bzw. 16:9
Maße, Verhältnis und Reihenfolge der Bildpfade hängen alle an **einer**
privaten Abfrage — sie müssen zusammenpassen, sonst beschneidet der
Server anders als der Rahmen.
Die Maße schmal: 1200×900 statt 1920×1080. Der Rahmen ist dort höchstens
1320 Bildpunkte breit; das spart auf einer Mobilfunkverbindung ein
Drittel.
Gemessen im Simulator (iPhone 17 Pro Max): Aufmacher 4:3, Text passt,
Detailseite unverändert hochkant. iOS und tvOS bauen, 476 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
100 lines
4.6 KiB
Swift
100 lines
4.6 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import MediaKit
|
|
|
|
/// Der Aufmacher oben auf der Startseite.
|
|
struct HeroSection: View {
|
|
let item: MediaItem
|
|
/// Der Titel, dessen Bild gerade verschwindet — nur im Karussell gesetzt.
|
|
var outgoing: MediaItem?
|
|
/// Wie weit das neue Bild schon deckt.
|
|
var crossfade: CGFloat = 1
|
|
/// Wie weit es dabei hereinrutscht.
|
|
var incomingOffset: CGFloat = 0
|
|
|
|
@Environment(FlixySession.self) private var session
|
|
@Environment(PlaybackCoordinator.self) private var playback
|
|
@Environment(QuickPlay.self) private var quickPlay
|
|
@Environment(\.flixyAccent) private var accent
|
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
|
|
|
var body: some View {
|
|
HeroBanner(
|
|
title: item.rowTitle,
|
|
metadata: metadata,
|
|
summary: item.summary,
|
|
artworkURLs: session.backend.map {
|
|
HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact)
|
|
} ?? [],
|
|
outgoingArtworkURLs: session.backend.flatMap { backend in
|
|
outgoing.map { HeroArtwork.backdropURLs(for: $0, backend: backend, compact: sizeClass == .compact) }
|
|
} ?? [],
|
|
outgoingTitle: outgoing?.rowTitle,
|
|
outgoingMetadata: outgoing.flatMap(Self.metadata(for:)),
|
|
outgoingLogoURL: session.backend.flatMap { backend in
|
|
outgoing.flatMap { HeroArtwork.logoURL(for: $0, backend: backend) }
|
|
},
|
|
crossfade: crossfade,
|
|
incomingOffset: incomingOffset,
|
|
logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) },
|
|
// Auf dem iPhone 4:3 statt 16:9: hochkant ließ 16:9 dem Text so
|
|
// wenig Platz, dass Schriftzug und Beschreibung übereinanderlagen.
|
|
// Beschnitten statt hochkant — das Bild bleibt ein Querbild.
|
|
aspectRatio: HeroArtwork.aspectRatio(compact: sizeClass == .compact),
|
|
// Auf einem großen Bildschirm nimmt der Aufmacher die Höhe, die
|
|
// das Karussell ihm gibt — sonst wäre er so hoch, wie 16:9 es
|
|
// vorschreibt, und darüber und darunter bliebe der halbe
|
|
// Bildschirm leer.
|
|
fillsAvailableSpace: sizeClass != .compact
|
|
) {
|
|
Button {
|
|
// **Über QuickPlay, nicht unmittelbar.** Steht im Aufmacher
|
|
// eine Serie — und Jellyfins „Zuletzt hinzugefügt" liefert
|
|
// neue Folgen als ihre Serie —, schickte der direkte Weg
|
|
// deren eigene Kennung an den Player, und der scheiterte mit
|
|
// „keine abspielbare Datei". Eine Serie hat keine Fassungen.
|
|
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
|
} label: {
|
|
Label(playLabel, systemImage: "play.fill")
|
|
.font(.flixyCardTitle)
|
|
.padding(.horizontal, FlixySpacing.loose)
|
|
.padding(.vertical, FlixySpacing.snug)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(accent)
|
|
|
|
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
|
Label("hero.details", systemImage: "info.circle")
|
|
.font(.flixyCardTitle)
|
|
.padding(.horizontal, FlixySpacing.loose)
|
|
.padding(.vertical, FlixySpacing.snug)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(Color.flixyTextSecondary)
|
|
}
|
|
}
|
|
|
|
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
|
|
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
|
|
private var playLabel: LocalizedStringKey {
|
|
item.watchProgress == nil ? "hero.play" : "hero.resume"
|
|
}
|
|
|
|
private var metadata: String? { Self.metadata(for: item) }
|
|
|
|
/// Die Zeile unter dem Titel.
|
|
///
|
|
/// `static`, weil sie während eines Wechsels auch für den weichenden
|
|
/// Titel gebraucht wird — und der ist nicht `item`.
|
|
static func metadata(for item: MediaItem) -> String? {
|
|
var pieces: [String] = []
|
|
if item.kind == .episode, let season = item.parentIndex, let episode = item.index {
|
|
pieces.append(String(localized: "episode.short \(season) \(episode)"))
|
|
}
|
|
if let year = item.year { pieces.append(String(year)) }
|
|
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
|
|
if let rating = item.contentRating { pieces.append(rating) }
|
|
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
|
|
}
|
|
}
|