diff --git a/Flixy/Views/Home/HeroArtwork.swift b/Flixy/Views/Home/HeroArtwork.swift new file mode 100644 index 0000000..b731856 --- /dev/null +++ b/Flixy/Views/Home/HeroArtwork.swift @@ -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) + } +} diff --git a/Flixy/Views/Home/HeroCarousel.swift b/Flixy/Views/Home/HeroCarousel.swift index aa908a1..df0b0a5 100644 --- a/Flixy/Views/Home/HeroCarousel.swift +++ b/Flixy/Views/Home/HeroCarousel.swift @@ -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) } } } diff --git a/Flixy/Views/Home/HeroSection.swift b/Flixy/Views/Home/HeroSection.swift index aabde91..101f6b3 100644 --- a/Flixy/Views/Home/HeroSection.swift +++ b/Flixy/Views/Home/HeroSection.swift @@ -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) diff --git a/Flixy/Views/Navigation/FlixyNavigationBar.swift b/Flixy/Views/Navigation/FlixyNavigationBar.swift index 3a39414..adf016d 100644 --- a/Flixy/Views/Navigation/FlixyNavigationBar.swift +++ b/Flixy/Views/Navigation/FlixyNavigationBar.swift @@ -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) } diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index cbfd491..9edb2a6 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -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 diff --git a/FlixyUITests/LayoutSnapshots.swift b/FlixyUITests/LayoutSnapshots.swift index 1839b98..a256ce4 100644 --- a/FlixyUITests/LayoutSnapshots.swift +++ b/FlixyUITests/LayoutSnapshots.swift @@ -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") } } diff --git a/Packages/FlixyKit/Sources/FlixyDesign/ChromeInset.swift b/Packages/FlixyKit/Sources/FlixyDesign/ChromeInset.swift new file mode 100644 index 0000000..e85dd65 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/ChromeInset.swift @@ -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 +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift index 120ee54..e189767 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift @@ -19,6 +19,14 @@ public struct HeroBanner: 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: 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 diff --git a/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift b/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift index 27f1c89..265a9b3 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift @@ -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