mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -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)**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user