fix(client/apple): a fallback banner sized the poster tile it was supposed to be cropped into

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.
This commit is contained in:
2026-08-16 10:00:59 +02:00
parent f5f11f84f0
commit 59fc9294ea
@@ -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() }