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)