diff --git a/CHANGELOG.md b/CHANGELOG.md index 413370f2..67c78816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Fixed +### Artist header showing the plain image instead of the external background + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1172](https://github.com/Psychotoxical/psysonic/pull/1172)** + +* On the artist page, when an artist had an external background image (from fanart.tv) but no banner, the header showed the plain Navidrome artist image instead of the background — even though the fullscreen player used the background correctly. The header now falls back banner → background → Navidrome image as intended. The background also sits a little higher so band members' heads aren't cropped on wide screens. + ### Playlists header buttons clipped at narrow widths **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1153](https://github.com/Psychotoxical/psysonic/pull/1153)** diff --git a/src-tauri/src/cover_cache/mod.rs b/src-tauri/src/cover_cache/mod.rs index 8dabb0ee..58298e8c 100644 --- a/src-tauri/src/cover_cache/mod.rs +++ b/src-tauri/src/cover_cache/mod.rs @@ -302,12 +302,18 @@ impl CoverCacheState { } // For an external artist surface (`fanart` 16:9 background or `banner` - // strip), try fanart.tv before the Navidrome fallback. On any miss it - // falls through WITHOUT writing a `.fetch-failed` marker, so Navidrome - // stays the display fallback (§28). + // strip), resolve fanart.tv only. On a hit we return the external image; + // on a miss we report a genuine `hit=false` (no `.fetch-failed` marker) + // and DO NOT fall back to the Navidrome cover here. The fallback is the + // caller's job, because each surface has its own chain: the artist-detail + // hero wants banner → fanart → Navidrome, the fullscreen player wants + // fanart → Navidrome. Falling back to Navidrome at this layer would mask + // "this artist has no banner" as a hit and short-circuit the caller's + // fanart step (the banner ensure would win the `||` with the ND cover and + // the existing fanart would never show). if args.external_artwork_enabled && !args.library_bulk && args.cache_kind == "artist" { if let Some(surface) = external_ensure::external_surface(args.surface_kind.as_deref()) { - if let Some(path) = external_ensure::try_external_fanart( + let external = external_ensure::try_external_fanart( app, args, &dir, @@ -317,14 +323,19 @@ impl CoverCacheState { args.tier, surface, ) - .await - { - return Ok(CoverCacheEnsureResult { + .await; + return Ok(match external { + Some(path) => CoverCacheEnsureResult { hit: true, path: path.to_string_lossy().into_owned(), tier: args.tier, - }); - } + }, + None => CoverCacheEnsureResult { + hit: false, + path: String::new(), + tier: args.tier, + }, + }); } } diff --git a/src/components/artistDetail/ArtistDetailHero.tsx b/src/components/artistDetail/ArtistDetailHero.tsx index ebe2399b..fcad1fff 100644 --- a/src/components/artistDetail/ArtistDetailHero.tsx +++ b/src/components/artistDetail/ArtistDetailHero.tsx @@ -12,6 +12,8 @@ import { useArtistOfflineState } from '../../hooks/useArtistOfflineState'; import { useIsMobile } from '../../hooks/useIsMobile'; import { ArtistHeroCover } from '../../cover/artistHero'; import { useArtistBanner, useArtistFanart } from '../../cover/useArtistFanart'; +import { usePlaybackCoverArt } from '../../cover/usePlaybackCoverArt'; +import { useCachedUrl } from '../CachedImage'; import { useCoverLightboxSrc } from '../../cover/lightbox'; import type { CoverArtRef } from '../../cover/types'; import LastfmIcon from '../LastfmIcon'; @@ -58,7 +60,7 @@ interface Props { * `complete` check for an already-cached image whose `load` event can fire * before React attaches the handler. */ -function ArtistHeaderBg({ url }: { url: string }) { +function ArtistHeaderBg({ url, position }: { url: string; position?: string }) { const [loaded, setLoaded] = useState(false); if (!url) return null; return ( @@ -78,6 +80,11 @@ function ArtistHeaderBg({ url }: { url: string }) { className="album-detail-bg" style={{ backgroundImage: `url(${url})`, + // Portrait-ish artist images (fanart / Navidrome) get a higher focal + // point so the band's heads aren't cropped off the top on wide (2K+) + // viewports, where `cover` scales the image up and overflows vertically. + // The wide banner strip is left at the shared `center` (no override). + ...(position ? { backgroundPosition: position } : {}), opacity: loaded ? 1 : 0, transition: 'opacity 0.4s ease', }} @@ -136,12 +143,24 @@ export default function ArtistDetailHero({ artistName: artist.name, albumTitle: albumContext, }); - // Banner is preferred: while it is still resolving, show nothing rather than - // flashing the fanart background first and then swapping. Only once the banner - // has resolved to a miss do we fall back to the 16:9 fanart (which is itself - // '' while still loading). Empty when neither has an image. Off → both '', - // not pending → empty, no regression. - const headerBgUrl = banner.src || (banner.pending ? '' : fanartBg.src); + // §28 stage 3: the Navidrome artist cover, the last fallback when neither an + // external banner nor fanart exists. Resolved the same way the fullscreen + // player resolves its artist background (`coverRef` is the artist cover ref). + const ndArtist = usePlaybackCoverArt(coverRef ?? undefined, 2000, { fullRes: true }); + const ndArtistUrl = useCachedUrl(ndArtist.src, ndArtist.cacheKey, true); + // Header background priority (§28): banner → fanart → Navidrome artist cover. + // Each external surface now reports a genuine miss (`src === ''`, not the ND + // cover), so the chain can step through cleanly: while a stage is still + // resolving we show nothing rather than flashing a lower-priority image; on a + // confirmed miss we drop to the next stage. Off → external surfaces are '', + // not pending → we fall straight through to the Navidrome cover. + const headerBgUrl = + banner.src || + (banner.pending ? '' : fanartBg.src || (fanartBg.pending ? '' : ndArtistUrl)); + // The banner is a purpose-built wide strip → keep it centered. The fanart / + // Navidrome artist images are portrait-ish → raise the focal point so heads + // stay in frame on wide viewports. + const headerBgPosition = banner.src ? undefined : 'center 30%'; const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`; @@ -154,7 +173,7 @@ export default function ArtistDetailHero({ `artist-detail-bleed` breaks out of the artist page's .content-body padding so it is full-bleed like the album page (flush .album-detail). */}