Der Aufmacher endet an der Leiste, und durch sie sieht man nur noch gedämpft
Vier Dinge, die zusammengehören: Die Spiegelung ist weg. `backgroundExtensionEffect` füllt die Fläche unter der Leiste, indem er den Bildrand gespiegelt weiterzeichnet — und je größer die zu füllende Fläche wurde, desto mehr übernahm der Spiegel das Bild. Auf dem iPhone legte er sich ohne jede Seitenleiste über den Statusbalken. Jetzt läuft das Bild selbst weiter, über einen negativen Rand. Es endet dabei an der **Kante der Leiste**, nicht am Fensterrand. Der Unterschied sind zwölf Punkte, und die waren deutlich zu sehen: neben der schwebenden Leiste stand ein heller Streifen Bild, den nichts abdeckte. Das Seitenverhältnis im Karussell musste weichen. Es legte den Aufmacher auf die Breite des Inhaltsbereichs fest; weil er nach links unter die Leiste greift, blieb rechts genau so viel schwarz übrig, wie er links gewann. Und die Maße, in denen das Hintergrundbild angefordert wird, richten sich jetzt nach dem Rahmen, in dem es landet. Vorher wurden 900×1200 geholt — hochkant — und in einem 16:9-Rahmen gezeigt: zu sehen war ein Streifen aus der Mitte, oben und unten fehlte je ein Drittel. Dazu: auf schmalen Geräten ist der Aufmacher hochkant. In 16:9 blieben auf einem iPhone keine zweihundertzwanzig Punkte Höhe, und Schriftzug, Zeile, Beschreibung und zwei Knöpfe lagen übereinander. Und ein Schleier unter dem Glas der Leiste. Reines Glas zeigt alles, was dahinter vorbeizieht, und beim Scrollen zieht die ganze Startseite vorbei — aus den ruhigen Beschriftungen wurde ein unruhiger Brei. Der Schleier nimmt der Bewegung die Schärfe, ohne die Fläche zuzumauern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fbed83b10f
commit
d0f732402c
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
import FlixyDesign
|
||||
import MediaKit
|
||||
|
||||
/// Woher der Aufmacher seine Bilder nimmt.
|
||||
///
|
||||
/// An **einer** Stelle, weil zwei Stellen es schon zweimal falsch gemacht
|
||||
/// haben:
|
||||
///
|
||||
/// Erstens wurde das Hintergrundbild in 900×1200 angefordert — hochkant — und
|
||||
/// dann in einem 16:9-Rahmen gezeigt. Zu sehen war davon ein schmaler Streifen
|
||||
/// aus der Mitte; vom Bild fehlte oben und unten je ein Drittel. Die Maße
|
||||
/// richten sich jetzt nach dem Rahmen, in dem das Bild landet.
|
||||
///
|
||||
/// Zweitens holt das Karussell das nächste Bild vor, solange das aktuelle noch
|
||||
/// steht. Weicht dessen Adresse auch nur in einer Zahl ab, ist es eine andere
|
||||
/// Adresse, der Zwischenspeicher trifft daneben und der Aufmacher blendet auf
|
||||
/// eine graue Fläche über.
|
||||
enum HeroArtwork {
|
||||
/// Die Maße, in denen das Hintergrundbild angefordert wird.
|
||||
///
|
||||
/// Groß genug für ein breites Fenster, und im Seitenverhältnis des
|
||||
/// Rahmens, damit nichts beschnitten werden muss.
|
||||
static func size(compact: Bool) -> (width: Int, height: Int) {
|
||||
compact ? (900, 1200) : (1920, 1080)
|
||||
}
|
||||
|
||||
static func aspectRatio(compact: Bool) -> CGFloat {
|
||||
compact ? FlixyAspectRatio.heroCompact : FlixyAspectRatio.hero
|
||||
}
|
||||
|
||||
static func backdropURL(for item: MediaItem, backend: any MediaBackend, compact: Bool) -> URL? {
|
||||
let size = size(compact: compact)
|
||||
return backend.artworkURL(
|
||||
path: item.art ?? item.seriesArt ?? item.thumb,
|
||||
width: size.width,
|
||||
height: size.height
|
||||
)
|
||||
}
|
||||
|
||||
/// Der freigestellte Schriftzug — ohne Bild-Transcoder.
|
||||
///
|
||||
/// Er ist ein PNG mit durchsichtigem Grund, und der Transcoder liefert
|
||||
/// JPEG. Aus dem Schriftzug würde sonst ein schwarzer Kasten.
|
||||
static func logoURL(for item: MediaItem, backend: any MediaBackend) -> URL? {
|
||||
backend.rawImageURL(path: item.clearLogoPath)
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ struct HeroCarousel: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
private var currentIndex: Int {
|
||||
items.firstIndex { $0.id == shownID } ?? 0
|
||||
@@ -84,7 +85,12 @@ struct HeroCarousel: View {
|
||||
.offset(x: (1 - cover) * (isMovingForward ? Self.slide : -Self.slide))
|
||||
}
|
||||
}
|
||||
.aspectRatio(FlixyAspectRatio.hero, contentMode: .fit)
|
||||
// **Kein** Seitenverhältnis hier: der Aufmacher bringt sein
|
||||
// eigenes mit, und eines darüber legte ihn auf die Breite des
|
||||
// Inhaltsbereichs fest. Weil er nach links unter die Seitenleiste
|
||||
// greift, blieb dann rechts genau so viel schwarz übrig, wie er
|
||||
// links gewonnen hatte — auf dem Mac ein Streifen von gut zweihundert
|
||||
// Punkten.
|
||||
// **Nicht** beschneiden: der Aufmacher greift bewusst nach links
|
||||
// über seinen Bereich hinaus, damit sein Bild unter der
|
||||
// Seitenleiste durchläuft. Ein `.clipped()` schnitt genau das ab —
|
||||
@@ -161,22 +167,19 @@ struct HeroCarousel: View {
|
||||
}
|
||||
|
||||
private func prefetchNeighbours() {
|
||||
guard let backend = session.backend else { return }
|
||||
let next = (currentIndex + 1) % items.count
|
||||
let item = items[next]
|
||||
// Dieselben Maße wie in `HeroSection` — eine andere Größe wäre eine
|
||||
// andere Adresse und der Zwischenspeicher träfe daneben.
|
||||
let backdrop = backend.artworkURL(
|
||||
path: item.art ?? item.seriesArt ?? item.thumb,
|
||||
width: 900,
|
||||
height: 1200
|
||||
)
|
||||
// Der Schriftzug gehört dazu. Fehlt er beim Überblenden, steht dort
|
||||
// erst der gesetzte Titel und das Logo springt eine Sekunde später
|
||||
// hinterher — zwei Bewegungen, wo eine sein soll.
|
||||
let logo = backend.rawImageURL(path: item.clearLogoPath)
|
||||
guard let backend = session.backend, !items.isEmpty else { return }
|
||||
let item = items[(currentIndex + 1) % items.count]
|
||||
let compact = sizeClass == .compact
|
||||
|
||||
for url in [backdrop, logo].compactMap(\.self) {
|
||||
// Hintergrundbild und Schriftzug zusammen: fehlt der Schriftzug beim
|
||||
// Überblenden, steht dort erst der gesetzte Titel und das Logo springt
|
||||
// eine Sekunde später hinterher — zwei Bewegungen, wo eine sein soll.
|
||||
let urls = [
|
||||
HeroArtwork.backdropURL(for: item, backend: backend, compact: compact),
|
||||
HeroArtwork.logoURL(for: item, backend: backend),
|
||||
]
|
||||
|
||||
for url in urls.compactMap(\.self) {
|
||||
Task { _ = await ArtworkLoader.shared.image(for: url) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,18 +9,20 @@ struct HeroSection: View {
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
var body: some View {
|
||||
HeroBanner(
|
||||
title: item.rowTitle,
|
||||
metadata: metadata,
|
||||
summary: item.summary,
|
||||
artworkURL: session.backend?.artworkURL(
|
||||
path: item.art ?? item.seriesArt ?? item.thumb,
|
||||
width: 900,
|
||||
height: 1200
|
||||
),
|
||||
logoURL: session.backend?.rawImageURL(path: item.clearLogoPath)
|
||||
artworkURL: session.backend.flatMap {
|
||||
HeroArtwork.backdropURL(for: item, backend: $0, compact: sizeClass == .compact)
|
||||
},
|
||||
logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) },
|
||||
// Auf dem iPhone hochkant: 16:9 ließ dem Text so wenig Platz, dass
|
||||
// Schriftzug und Beschreibung übereinanderlagen.
|
||||
aspectRatio: HeroArtwork.aspectRatio(compact: sizeClass == .compact)
|
||||
) {
|
||||
Button {
|
||||
playback.play(item)
|
||||
|
||||
@@ -38,6 +38,12 @@ struct NavigationGroup: Identifiable {
|
||||
///
|
||||
/// Eine schwebende Fläche mit Rand ringsum — genau das war die Bitte: „eine
|
||||
/// eigene Bubble mit einem klaren Rand, der nicht der Fensterrand ist".
|
||||
///
|
||||
/// Der Rand kommt von `glassEffect` und nicht von einem gezeichneten Strich.
|
||||
/// Ein Material mit einer halben Linie Kontur darum sah aus wie eine Fläche
|
||||
/// mit Kontur; Glas bringt seine Kante selbst mit — sie hellt auf, wo Licht
|
||||
/// fällt, und verschwindet, wo keins ist. Das ist der Unterschied zwischen
|
||||
/// „umrandet" und „aus Glas".
|
||||
struct FlixySidebar: View {
|
||||
let groups: [NavigationGroup]
|
||||
@Binding var selection: SidebarDestination
|
||||
@@ -75,15 +81,21 @@ struct FlixySidebar: View {
|
||||
.padding(.vertical, FlixySpacing.snug)
|
||||
}
|
||||
.frame(width: Self.width)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: FlixyRadius.panel, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: FlixyRadius.panel, style: .continuous)
|
||||
.strokeBorder(Color.flixySeparator.opacity(0.6), lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: FlixyRadius.panel, style: .continuous))
|
||||
// Ein Schleier unter dem Glas.
|
||||
//
|
||||
// Reines Glas zeigt alles, was dahinter vorbeizieht — und beim Scrollen
|
||||
// zieht die ganze Startseite vorbei. Der Aufmacher, die Plakatreihen,
|
||||
// die Überschriften: alles wanderte durch die Leiste hindurch, und
|
||||
// neben den ruhigen Beschriftungen wurde daraus ein unruhiger Brei.
|
||||
//
|
||||
// Der Schleier nimmt der Bewegung die Schärfe, ohne die Fläche
|
||||
// zuzumauern: man sieht noch, dass dort etwas ist, aber man liest es
|
||||
// nicht mehr mit.
|
||||
.background(
|
||||
Color.flixyCanvas.opacity(0.72),
|
||||
in: .rect(cornerRadius: FlixyRadius.panel, style: .continuous)
|
||||
)
|
||||
.glassEffect(.regular, in: .rect(cornerRadius: FlixyRadius.panel))
|
||||
.padding(Self.margin)
|
||||
}
|
||||
|
||||
@@ -211,10 +223,8 @@ struct FlixyTopBar: View {
|
||||
}
|
||||
.padding(.horizontal, FlixySpacing.snug)
|
||||
.padding(.vertical, FlixySpacing.tight)
|
||||
.background {
|
||||
Capsule().fill(.ultraThinMaterial)
|
||||
.overlay { Capsule().strokeBorder(Color.flixySeparator.opacity(0.6), lineWidth: 0.5) }
|
||||
}
|
||||
.background(Color.flixyCanvas.opacity(0.72), in: .capsule)
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
.padding(.top, FlixySpacing.snug)
|
||||
}
|
||||
|
||||
|
||||
@@ -399,6 +399,16 @@ struct RootView: View {
|
||||
let insets = sizeClass == .regular ? chromeInsets : EdgeInsets()
|
||||
content()
|
||||
.safeAreaPadding(insets)
|
||||
// Wie weit der Aufmacher nach links ausbrechen darf: bis genau an
|
||||
// die **Kante der Leiste**, nicht bis an den Fensterrand.
|
||||
//
|
||||
// Der Unterschied ist ein Streifen von zwölf Punkten, und der war
|
||||
// deutlich zu sehen: die Leiste schwebt mit Abstand zum Rand, das
|
||||
// Bild lief daran vorbei, und neben der Leiste stand ein heller
|
||||
// Streifen Bild, den nichts abdeckte. Endet das Bild an ihrer
|
||||
// Kante, sieht man es nur noch **durch** sie hindurch — und genau
|
||||
// so soll es sein.
|
||||
.environment(\.flixyChromeInset, max(insets.leading - FlixySidebar.margin, 0))
|
||||
#else
|
||||
content()
|
||||
#endif
|
||||
|
||||
@@ -65,6 +65,20 @@ final class LayoutSnapshots: XCTestCase {
|
||||
shoot(app, "12-oben")
|
||||
}
|
||||
|
||||
/// Dasselbe auf einem schmalen Bildschirm, hochkant.
|
||||
///
|
||||
/// Eigener Test, weil dort alles anders ist: keine Seitenleiste, die
|
||||
/// Leiste unten, und der Aufmacher hochkant statt 16:9.
|
||||
func testPhoneHome() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-FlixyStubServer", "http://127.0.0.1:8096"]
|
||||
app.launch()
|
||||
|
||||
XCTAssertTrue(app.staticTexts["Weiterschauen"].waitForExistence(timeout: 20))
|
||||
Thread.sleep(forTimeInterval: 3)
|
||||
shoot(app, "20-iphone")
|
||||
}
|
||||
|
||||
func testHomeAndDetail() throws {
|
||||
let app = XCUIApplication()
|
||||
// Die Stellung der Leiste wird gespeichert, und ein voriger Lauf hat
|
||||
@@ -95,5 +109,24 @@ final class LayoutSnapshots: XCTestCase {
|
||||
|
||||
Thread.sleep(forTimeInterval: 2)
|
||||
shoot(app, "03-danach")
|
||||
|
||||
// Und gescrollt: dort zieht alles an der Seitenleiste vorbei, und was
|
||||
// dabei durch sie hindurch zu sehen ist, entscheidet, ob sie ruhig
|
||||
// wirkt oder unruhig.
|
||||
// Ein gezogener Strich statt `swipeUp`: der Aufmacher fängt Wischgesten
|
||||
// ab, und `app.scrollViews.firstMatch` ist die **waagerechte** Reihe,
|
||||
// nicht die Seite. Gezogen wird deshalb tief unten, wo nur die Seite
|
||||
// selbst zuhört.
|
||||
func scroll() {
|
||||
let from = app.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.92))
|
||||
let to = app.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.35))
|
||||
from.press(forDuration: 0.05, thenDragTo: to)
|
||||
Thread.sleep(forTimeInterval: 1.5)
|
||||
}
|
||||
|
||||
scroll()
|
||||
shoot(app, "04-gescrollt")
|
||||
scroll()
|
||||
shoot(app, "05-gescrollt-weiter")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
public extension EnvironmentValues {
|
||||
/// Wie weit die Navigationsleiste von links in den Bildschirm hineinragt.
|
||||
///
|
||||
/// Null, wo es keine gibt — auf dem iPhone, bei Leiste oben.
|
||||
///
|
||||
/// Gebraucht, weil Bild und Inhalt zwei verschiedene Dinge wollen: der
|
||||
/// **Inhalt** — Überschriften, Kacheln, Knöpfe — fängt rechts der Leiste
|
||||
/// an, dafür sorgt der Sicherheitsrand. Das **Bild** des Aufmachers soll
|
||||
/// dagegen darunter weiterlaufen und den Fensterrand erreichen, sonst
|
||||
/// endet es an einer harten Kante und daneben steht ein schwarzer Streifen.
|
||||
@Entry var flixyChromeInset: CGFloat = 0
|
||||
}
|
||||
@@ -19,6 +19,14 @@ public struct HeroBanner<Actions: View>: View {
|
||||
|
||||
@State private var logo: PlatformImage?
|
||||
|
||||
/// Wie weit die Navigationsleiste hereinragt.
|
||||
///
|
||||
/// Nur das **Bild** greift danach; Titel, Beschreibung und Knöpfe nicht.
|
||||
/// Die liegen in einer Überlagerung, und die sitzt auf dem ungedehnten
|
||||
/// Rahmen — sie stehen also von selbst da, wo auch die Reihen darunter
|
||||
/// anfangen.
|
||||
@Environment(\.flixyChromeInset) private var chromeInset
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
metadata: String? = nil,
|
||||
@@ -114,19 +122,18 @@ public struct HeroBanner<Actions: View>: View {
|
||||
|
||||
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.
|
||||
// Bis unter die Leiste und an den Fensterrand.
|
||||
//
|
||||
// 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()
|
||||
// Hier stand `backgroundExtensionEffect()`. Der spiegelt den
|
||||
// Bildrand und zeichnet ihn weichgezeichnet weiter — und je größer
|
||||
// die Fläche wurde, die er füllen musste, desto mehr übernahm der
|
||||
// Spiegel das Bild. Über die Seitenleiste hinaus war er nicht mehr
|
||||
// Fortsetzung, sondern Hauptsache; auf dem iPhone legte er sich
|
||||
// ohne jede Seitenleiste über den Statusbalken.
|
||||
//
|
||||
// Ein negativer Rand tut dasselbe ehrlicher: das Bild **selbst**
|
||||
// läuft weiter. Eine Fläche, ein Ausschnitt, keine Naht.
|
||||
.padding(.leading, -chromeInset)
|
||||
.overlay(alignment: .bottom) {
|
||||
// Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in
|
||||
// Schwarz: sonst entsteht genau dort, wo der Aufmacher in die
|
||||
|
||||
@@ -81,6 +81,15 @@ public enum FlixyAspectRatio {
|
||||
/// Der Aufmacher auf der Startseite — dasselbe wie `landscape`, weil Plex
|
||||
/// dafür `art` liefert.
|
||||
public static let hero: CGFloat = landscape
|
||||
/// Der Aufmacher auf einem schmalen Bildschirm.
|
||||
///
|
||||
/// Nicht 16:9: auf einem iPhone sind das keine zweihundertzwanzig Punkte
|
||||
/// Höhe, und darin sollen Titel oder Schriftzug, Zeile mit Jahr und Länge,
|
||||
/// ein Satz Beschreibung und zwei Knöpfe Platz finden. Sie fanden ihn
|
||||
/// nicht und lagen übereinander. Hochkant ist hier auch die ehrlichere
|
||||
/// Form — ein Handy hält man so.
|
||||
public static let heroCompact: CGFloat = 3.0 / 4.0
|
||||
|
||||
/// Sendersymbole. Breiter als hoch, weil Senderlogos Schriftzüge sind —
|
||||
/// quadratisch beschnitten fehlt der halbe Name.
|
||||
public static let channelLogo: CGFloat = 5.0 / 3.0
|
||||
|
||||
Reference in New Issue
Block a user