From 59fc9294ea6042ee2fa9eafbd6fe757d34877820 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 16 Aug 2026 10:00:59 +0200 Subject: [PATCH] fix(client/apple): a fallback banner sized the poster tile it was supposed to be cropped into MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as the library "breaking" on iOS whenever a title showed a banner instead of a cover: the tile expanded out of its bounds. `PosterImage` already meant to prevent exactly this — its own comment said a banner "would otherwise report a much wider intrinsic size than the card and overflow into neighboring cards" — and the guard it used, a flexible frame plus `.clipped()`, does not do it. `scaledToFill` answers a proposal with a size that COVERS it, i.e. larger; `.frame(maxWidth: .infinity, maxHeight: .infinity)` then clamps that answer to infinity, which is no clamp at all, so the image's size propagates straight up through `aspectRatio(2/3, .fit)` and decides the tile. Measured offscreen with ImageRenderer, one LazyVGrid column at 170pt: 460x215 header, before poster 1750.9 x 818.4 tile 545.6 x 255.0 460x215 header, after poster 170.0 x 255.0 tile 170.0 x 255.0 300x450 cover, either poster 170.0 x 255.0 tile 170.0 x 255.0 A 545pt tile in a 170pt column is what the report is describing, and the cover case coming out right either way is why this only ever showed on the titles whose cover was missing. The fix gives the sizing role to something that has no opinion about it: `Color.clear` takes the proposal, and the art rides as its overlay, where it can be drawn but never measured. `LibraryCoverflowView` reuses `PosterImage` directly, so it is fixed by the same change. --- .../PunktfunkClient/Home/LibraryWidgets.swift | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift index 475b240b..f5dae289 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift @@ -63,9 +63,10 @@ private extension Image { /// Sequentially tries cover-art URLs over `loader` (so a paired client can reach the host's own /// art proxy, not just public CDNs — see `LibraryArtLoader`), advancing past any that fail to /// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame -/// regardless of its own aspect ratio: a portrait capsule fills it as intended, but a fallback -/// banner (wide hero/header art, used when a title has no portrait capsule) would otherwise report -/// a much wider intrinsic size than the card and overflow into neighboring cards. Not `private` — +/// regardless of its own aspect ratio: a portrait capsule fills it as intended, and a fallback +/// banner (wide hero/header art, used when a title has no portrait capsule) is cropped to the same +/// tile rather than allowed to size it — see the `Color.clear` in `body` for why that takes more +/// than a `.frame(maxWidth:)` and a `.clipped()`. Not `private` — /// the gamepad coverflow (`LibraryCoverflowView`) reuses it directly rather than re-fetching art. struct PosterImage: View { let candidates: [URL] @@ -84,9 +85,20 @@ struct PosterImage: View { var body: some View { Group { if let image { - Image(platformImage: image) - .resizable() - .scaledToFill() + // `Color.clear` is what takes the proposed size; the art rides along as its + // overlay, where it can be DRAWN but never MEASURED. Handing the image the sizing + // role instead is what let a fallback banner escape the tile: `scaledToFill` + // reports a size that covers the proposal, and the flexible frame below clamps it + // to `.infinity` — i.e. not at all. Measured offscreen, a 460×215 `header.jpg` in a + // 170pt grid column resolved the tile to 545×255 and overran its neighbours, while + // a 300×450 cover in the same chain came out correct — which is why this only ever + // showed on the titles whose cover was missing. + Color.clear + .overlay { + Image(platformImage: image) + .resizable() + .scaledToFill() + } .transition(.opacity) } else if index < candidates.count { ZStack { placeholder; ProgressView() }