fix(artist): show fanart in the artist-detail hero, not the Navidrome cover (#1172)

* fix(cover): report a genuine miss for external artist surfaces

When external artwork is enabled, the fanart/banner ensure fell through to
the Navidrome cover and returned it as a hit on a fanart.tv miss. That made
'this artist has no banner' indistinguishable from a real hit, so the
artist-detail hero short-circuited on the ND cover and never reached the
fanart it actually had. Return hit=false on an external-surface miss instead;
the Navidrome fallback is the caller's job, since each surface has its own
chain (hero: banner->fanart->ND, fullscreen player: fanart->ND).

* fix(artist): hero background banner->fanart->Navidrome with raised focal point

The hero header background now steps through banner -> fanart -> Navidrome
artist cover, resolving the ND cover the same way the fullscreen player does.
Previously it had no Navidrome stage at all and, combined with the backend
masking a banner miss as a hit, showed the ND cover where the fanart belonged.

Also raise the focal point (background-position: center 30%) for the portrait-ish
fanart/ND images so the band's heads stay in frame on wide (2K+) viewports,
where 'cover' scales them up and overflows vertically. The wide banner strip
keeps the shared center. Done via a scoped inline style; .album-detail-bg stays
untouched for the album/playlist headers that share it.

* docs(changelog): artist header external-background fallback fix (#1172)
This commit is contained in:
Psychotoxical
2026-06-24 20:41:16 +02:00
committed by GitHub
parent 09f24beb32
commit c3e6be537c
3 changed files with 53 additions and 17 deletions
+6
View File
@@ -120,6 +120,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Fixed ## 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 ### Playlists header buttons clipped at narrow widths
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1153](https://github.com/Psychotoxical/psysonic/pull/1153)** **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1153](https://github.com/Psychotoxical/psysonic/pull/1153)**
+20 -9
View File
@@ -302,12 +302,18 @@ impl CoverCacheState {
} }
// For an external artist surface (`fanart` 16:9 background or `banner` // For an external artist surface (`fanart` 16:9 background or `banner`
// strip), try fanart.tv before the Navidrome fallback. On any miss it // strip), resolve fanart.tv only. On a hit we return the external image;
// falls through WITHOUT writing a `.fetch-failed` marker, so Navidrome // on a miss we report a genuine `hit=false` (no `.fetch-failed` marker)
// stays the display fallback (§28). // 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 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(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, app,
args, args,
&dir, &dir,
@@ -317,14 +323,19 @@ impl CoverCacheState {
args.tier, args.tier,
surface, surface,
) )
.await .await;
{ return Ok(match external {
return Ok(CoverCacheEnsureResult { Some(path) => CoverCacheEnsureResult {
hit: true, hit: true,
path: path.to_string_lossy().into_owned(), path: path.to_string_lossy().into_owned(),
tier: args.tier, tier: args.tier,
}); },
} None => CoverCacheEnsureResult {
hit: false,
path: String::new(),
tier: args.tier,
},
});
} }
} }
@@ -12,6 +12,8 @@ import { useArtistOfflineState } from '../../hooks/useArtistOfflineState';
import { useIsMobile } from '../../hooks/useIsMobile'; import { useIsMobile } from '../../hooks/useIsMobile';
import { ArtistHeroCover } from '../../cover/artistHero'; import { ArtistHeroCover } from '../../cover/artistHero';
import { useArtistBanner, useArtistFanart } from '../../cover/useArtistFanart'; import { useArtistBanner, useArtistFanart } from '../../cover/useArtistFanart';
import { usePlaybackCoverArt } from '../../cover/usePlaybackCoverArt';
import { useCachedUrl } from '../CachedImage';
import { useCoverLightboxSrc } from '../../cover/lightbox'; import { useCoverLightboxSrc } from '../../cover/lightbox';
import type { CoverArtRef } from '../../cover/types'; import type { CoverArtRef } from '../../cover/types';
import LastfmIcon from '../LastfmIcon'; import LastfmIcon from '../LastfmIcon';
@@ -58,7 +60,7 @@ interface Props {
* `complete` check for an already-cached image whose `load` event can fire * `complete` check for an already-cached image whose `load` event can fire
* before React attaches the handler. * before React attaches the handler.
*/ */
function ArtistHeaderBg({ url }: { url: string }) { function ArtistHeaderBg({ url, position }: { url: string; position?: string }) {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
if (!url) return null; if (!url) return null;
return ( return (
@@ -78,6 +80,11 @@ function ArtistHeaderBg({ url }: { url: string }) {
className="album-detail-bg" className="album-detail-bg"
style={{ style={{
backgroundImage: `url(${url})`, 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, opacity: loaded ? 1 : 0,
transition: 'opacity 0.4s ease', transition: 'opacity 0.4s ease',
}} }}
@@ -136,12 +143,24 @@ export default function ArtistDetailHero({
artistName: artist.name, artistName: artist.name,
albumTitle: albumContext, albumTitle: albumContext,
}); });
// Banner is preferred: while it is still resolving, show nothing rather than // §28 stage 3: the Navidrome artist cover, the last fallback when neither an
// flashing the fanart background first and then swapping. Only once the banner // external banner nor fanart exists. Resolved the same way the fullscreen
// has resolved to a miss do we fall back to the 16:9 fanart (which is itself // player resolves its artist background (`coverRef` is the artist cover ref).
// '' while still loading). Empty when neither has an image. Off → both '', const ndArtist = usePlaybackCoverArt(coverRef ?? undefined, 2000, { fullRes: true });
// not pending → empty, no regression. const ndArtistUrl = useCachedUrl(ndArtist.src, ndArtist.cacheKey, true);
const headerBgUrl = banner.src || (banner.pending ? '' : fanartBg.src); // 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)}`; 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 `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). */} padding so it is full-bleed like the album page (flush .album-detail). */}
<div className="album-detail-header artist-detail-bleed"> <div className="album-detail-header artist-detail-bleed">
<ArtistHeaderBg key={headerBgUrl} url={headerBgUrl} /> <ArtistHeaderBg key={headerBgUrl} url={headerBgUrl} position={headerBgPosition} />
<div className="album-detail-content"> <div className="album-detail-content">
<button className="btn btn-ghost album-detail-back" onClick={() => goBack()}> <button className="btn btn-ghost album-detail-back" onClick={() => goBack()}>
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span> <ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>