From e5e01838f7888cd3bacd76b6ed41b203d15c1528 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 13 Sep 2026 17:25:32 +0200 Subject: [PATCH] =?UTF-8?q?Der=20Schriftzug=20geh=C3=B6rt=20zum=20Aufmache?= =?UTF-8?q?r,=20nicht=20zu=20sich=20selbst?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AsyncImage` bringt seine eigene Einblendung mit und fängt erst an zu laden, wenn es erscheint. Beim Überblenden des Aufmachers blendete sich der Schriftzug des Films deshalb getrennt und später ein, während Titelzeile, Beschreibung und Knöpfe längst mitgewandert waren — zwei Bewegungen, wo eine sein soll. Jetzt lädt er über denselben Zwischenspeicher wie alle anderen Bilder, und das Karussell holt ihn zusammen mit dem Hintergrundbild vor. Beim Erscheinen liegt er bereit und macht mit, was der Aufmacher macht. Dazu ein kleines Hilfsprogramm, das die Fensterkennung eines laufenden Programms ausgibt, damit `screencapture -l` genau dessen Fenster fotografiert und sonst nichts vom Bildschirm. Das System-Python bringt PyObjC nicht mehr mit, und ohne Kennung gibt es keinen Weg dorthin. Co-Authored-By: Claude Opus 5 --- Flixy/Views/Home/HeroCarousel.swift | 13 +++- .../FlixyDesign/Components/HeroBanner.swift | 61 ++++++++++++------- Tools/WindowShot/main.swift | 45 ++++++++++++++ 3 files changed, 93 insertions(+), 26 deletions(-) create mode 100644 Tools/WindowShot/main.swift diff --git a/Flixy/Views/Home/HeroCarousel.swift b/Flixy/Views/Home/HeroCarousel.swift index 3a9e534..4dc4629 100644 --- a/Flixy/Views/Home/HeroCarousel.swift +++ b/Flixy/Views/Home/HeroCarousel.swift @@ -138,11 +138,18 @@ struct HeroCarousel: View { 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. - guard let url = backend.artworkURL( + let backdrop = backend.artworkURL( path: item.art ?? item.seriesArt ?? item.thumb, width: 900, height: 1200 - ) else { return } - Task { _ = await ArtworkLoader.shared.image(for: url) } + ) + // 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) + + for url in [backdrop, logo].compactMap(\.self) { + Task { _ = await ArtworkLoader.shared.image(for: url) } + } } } diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift index d3d46cc..8c3a460 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift @@ -17,6 +17,8 @@ public struct HeroBanner: View { private let aspectRatio: CGFloat private let actions: Actions + @State private var logo: PlatformImage? + public init( title: String, metadata: String? = nil, @@ -53,32 +55,45 @@ public struct HeroBanner: View { /// 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. + /// Geladen wird über `ArtworkLoader`, **nicht** über `AsyncImage`. Der + /// Unterschied ist beim Überblenden des Aufmachers zu sehen: `AsyncImage` + /// bringt seine eigene Einblendung mit und fängt erst an zu laden, wenn es + /// erscheint. Der Schriftzug blendete sich dann getrennt und später ein, + /// während Titelzeile, Beschreibung und Knöpfe längst mitgewandert waren — + /// zwei Bewegungen, wo eine sein soll. + /// + /// Jetzt liegt das Bild beim Erscheinen schon im Zwischenspeicher (das + /// Karussell holt es vor) und wird ohne eigene Bewegung gezeigt: es gehört + /// zum Aufmacher und macht mit, was der Aufmacher macht. + /// + /// Ohne Bild-Transcoder, weil der Schriftzug ein freigestelltes PNG ist und + /// der Transcoder JPEG liefert — aus dem 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) - } + Group { + if let logo { + Image(platform: logo) + .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) + .accessibilityLabel(Text(title)) + } else { + // Solange nichts da ist — 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) + } + .task(id: logoURL) { + guard let logoURL else { + logo = nil + return + } + logo = await ArtworkLoader.shared.image(for: logoURL) } } diff --git a/Tools/WindowShot/main.swift b/Tools/WindowShot/main.swift new file mode 100644 index 0000000..11e6fc0 --- /dev/null +++ b/Tools/WindowShot/main.swift @@ -0,0 +1,45 @@ +import CoreGraphics +import Foundation + +// Sucht das größte sichtbare Fenster eines Programms und gibt seine Kennung +// aus. Damit lässt sich `screencapture -l` aufrufen — das fotografiert +// genau dieses eine Fenster und sonst nichts vom Bildschirm. +// +// Gebaut, weil es sonst keinen Weg dorthin gibt: das System-Python bringt +// PyObjC nicht mehr mit, und `screencapture` kennt nur Kennungen, keine Namen. + +let name = CommandLine.arguments.dropFirst().first ?? "Flixy" + +guard let windows = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as? [[String: Any]] else { + FileHandle.standardError.write(Data("Fensterliste nicht lesbar\n".utf8)) + exit(2) +} + +let candidates = windows.filter { window in + guard let owner = window[kCGWindowOwnerName as String] as? String, owner == name else { return false } + // Ebene 0 ist das normale Fenster; alles darüber sind Menüs und Schatten. + guard (window[kCGWindowLayer as String] as? Int) == 0 else { return false } + return true +} + +let largest = candidates.max { a, b in + func area(_ w: [String: Any]) -> Double { + guard let bounds = w[kCGWindowBounds as String] as? [String: Any], + let width = bounds["Width"] as? Double, + let height = bounds["Height"] as? Double + else { return 0 } + return width * height + } + return area(a) < area(b) +} + +guard let window = largest, let id = window[kCGWindowNumber as String] as? Int else { + FileHandle.standardError.write(Data("Kein sichtbares Fenster von „\(name)“\n".utf8)) + exit(1) +} + +if let bounds = window[kCGWindowBounds as String] as? [String: Any], + let width = bounds["Width"] as? Double, let height = bounds["Height"] as? Double { + FileHandle.standardError.write(Data("\(name): \(Int(width))×\(Int(height))\n".utf8)) +} +print(id)