Der Schriftzug gehört zum Aufmacher, nicht zu sich selbst

`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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 17:25:32 +02:00
co-authored by Claude Opus 5
parent 7155f50bac
commit e5e01838f7
3 changed files with 93 additions and 26 deletions
+10 -3
View File
@@ -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) }
}
}
}
@@ -17,6 +17,8 @@ public struct HeroBanner<Actions: View>: 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<Actions: View>: 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)
}
}
+45
View File
@@ -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<kennung>` 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)