Files
Psychotoxical-psysonic/src/components/Hero.tsx
T

526 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getRandomAlbums } from '../api/subsonicLibrary';
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { Play, ListPlus, ChevronLeft, ChevronRight } from 'lucide-react';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { useArtistBanner, useArtistFanart } from '../cover/useArtistFanart';
import { useCoverArt } from '../cover/useCoverArt';
import { useHeroBackdrop } from '../cover/useHeroBackdrop';
import { useCachedUrl } from '@/ui/CachedImage';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../hooks/useIsMobile';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mix/mixRatingFilter';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
import { useLongPressAction } from '../hooks/useLongPressAction';
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
import { albumArtistDisplayName, deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
const INTERVAL_MS = 10000;
const HERO_ALBUM_COUNT = 8;
/** Larger pool when mix rating filter is on so we can still fill the hero strip. */
const HERO_RANDOM_POOL = 32;
/** Hero foreground cover (`.hero-cover` 220×220). */
const HERO_FG_CSS_PX = 220;
/** Hero blurred backdrop (full banner height). */
const HERO_BG_CSS_PX = 360;
// Crossfading background — same layer pattern as FullscreenPlayer. Each layer
// carries its own `position` (the banner stays centered, portrait-ish fanart /
// artist covers raise the focal point), so a crossfade never re-frames the
// outgoing image. `position` is keyed off `url` (it only changes when the url
// changes) so the effect dep stays `[url]`.
function HeroBg({ url, position }: { url: string; position?: string }) {
const [layers, setLayers] = useState<
Array<{ url: string; position?: string; id: number; visible: boolean }>
>(() => (url ? [{ url, position, id: 0, visible: true }] : []));
const counter = useRef(url ? 1 : 0);
const latestUrlRef = useRef(url);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
latestUrlRef.current = url;
useEffect(() => {
if (!url) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLayers([]);
return;
}
const id = counter.current++;
setLayers(prev => [...prev, { url, position, id, visible: false }]);
let revealed = false;
let cleanup: ReturnType<typeof setTimeout> | undefined;
const reveal = () => {
if (revealed || latestUrlRef.current !== url) return;
revealed = true;
// Crossfade this layer in; the others fade out, then get dropped.
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
cleanup = setTimeout(() => {
if (latestUrlRef.current !== url) return;
setLayers(prev => prev.filter(l => l.id === id));
}, 900);
};
// Reveal only once the bytes are decoded, so the crossfade never fades in a
// blank / half-loaded image (the flicker the bare 20 ms timer had). The
// preload + scheduling happen exactly once here per url — no per-render
// <img> ref / onLoad, so this can't stack updates like the reverted attempt.
const pre = new Image();
pre.decoding = 'async';
pre.src = url;
let fallback: ReturnType<typeof setTimeout> | undefined;
if (pre.complete && pre.naturalWidth > 0) {
reveal();
} else {
pre.onload = reveal;
pre.onerror = reveal;
fallback = setTimeout(reveal, 1500);
}
return () => {
if (fallback) clearTimeout(fallback);
if (cleanup) clearTimeout(cleanup);
pre.onload = null;
pre.onerror = null;
};
// `position` is intentionally omitted — it tracks `url` 1:1, and adding it
// would spawn a duplicate layer if it ever changed without the url.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
return (
<>
{layers.map(layer => (
<img
key={layer.id}
className="hero-bg-image"
src={layer.url}
style={{ opacity: layer.visible ? 1 : 0, objectPosition: layer.position }}
aria-hidden="true"
alt=""
loading="eager"
decoding="sync"
draggable={false}
/>
))}
</>
);
}
interface HeroProps {
albums?: SubsonicAlbum[];
}
export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const perfFlags = usePerfProbeFlags();
const { t } = useTranslation();
const navigateToAlbum = useNavigateToAlbum();
const isMobile = useIsMobile();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
const mainstageBackdrop = useThemeStore(s => s.backdrops.mainstageHero);
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
const [albums, setAlbums] = useState<SubsonicAlbum[]>(() =>
albumsProp?.length ? albumsProp : [],
);
const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const windowHidden = useWindowVisibility();
const [windowBlurred, setWindowBlurred] = useState<boolean>(() => Boolean(window.__psyBlurred));
const heroRef = useRef<HTMLDivElement | null>(null);
const heroScrollRootRef = useRef<HTMLElement | null>(null);
const visibilityRafRef = useRef<number | null>(null);
const [heroInView, setHeroInView] = useState(true);
const heroInViewRef = useRef(true);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
heroInViewRef.current = heroInView;
const computeHeroVisibleNow = useCallback((): boolean => {
const node = heroRef.current;
if (!node) return false;
const rect = node.getBoundingClientRect();
if (rect.height <= 0 || rect.width <= 0) {
return false;
}
const root = heroScrollRootRef.current;
const viewportTop = root ? root.getBoundingClientRect().top : 0;
const viewportBottom = root ? root.getBoundingClientRect().bottom : window.innerHeight;
const overlap = Math.max(0, Math.min(rect.bottom, viewportBottom) - Math.max(rect.top, viewportTop));
// Consider hero visible only when at least a meaningful slice is on screen.
const minVisiblePx = Math.min(56, rect.height * 0.2);
return overlap >= minVisiblePx;
}, []);
const updateHeroVisibility = useCallback(() => {
const visible = computeHeroVisibleNow();
setHeroInView(prev => (prev === visible ? prev : visible));
}, [computeHeroVisibleNow]);
useEffect(() => {
const node = heroRef.current;
if (!node) return;
// Prefer the nearest actual scrolling ancestor; class fallback for safety.
let scrollRoot: HTMLElement | null = null;
let parent = node.parentElement;
while (parent) {
const styles = window.getComputedStyle(parent);
const overflowY = styles.overflowY;
if ((overflowY === 'auto' || overflowY === 'scroll') && parent.scrollHeight > parent.clientHeight + 2) {
scrollRoot = parent;
break;
}
parent = parent.parentElement;
}
heroScrollRootRef.current =
scrollRoot ?? (node.closest('.app-shell-route-scroll__viewport') as HTMLElement | null);
updateHeroVisibility();
// Layout may settle after first paint (hero mounts after albums hydrate from props).
const layoutRaf = window.requestAnimationFrame(() => updateHeroVisibility());
const root = heroScrollRootRef.current;
const onScroll = () => {
if (visibilityRafRef.current != null) return;
visibilityRafRef.current = window.requestAnimationFrame(() => {
visibilityRafRef.current = null;
updateHeroVisibility();
});
};
const onResize = () => updateHeroVisibility();
const onFocusLike = () => updateHeroVisibility();
root?.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onResize);
window.addEventListener('focus', onFocusLike);
document.addEventListener('visibilitychange', onFocusLike);
return () => {
root?.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onResize);
window.removeEventListener('focus', onFocusLike);
document.removeEventListener('visibilitychange', onFocusLike);
if (visibilityRafRef.current != null) {
window.cancelAnimationFrame(visibilityRafRef.current);
visibilityRafRef.current = null;
}
window.cancelAnimationFrame(layoutRaf);
};
}, [updateHeroVisibility, albums.length]);
useEffect(() => {
const updateBlurState = () => {
setWindowBlurred(Boolean(window.__psyBlurred));
};
window.addEventListener('focus', updateBlurState);
window.addEventListener('blur', updateBlurState);
updateBlurState();
return () => {
window.removeEventListener('focus', updateBlurState);
window.removeEventListener('blur', updateBlurState);
};
}, []);
useEffect(() => {
if (heroInView || windowHidden) return;
// Recovery guard: if a scroll/RAF event was missed while hero was outside
// viewport, keep checking briefly so autoplay/background resume immediately
// after returning into view.
const id = window.setInterval(() => {
updateHeroVisibility();
}, 220);
return () => window.clearInterval(id);
}, [heroInView, windowHidden, updateHeroVisibility]);
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (albumsProp?.length) { setAlbums(albumsProp); return; }
const cfg = { ...getMixMinRatingsConfigFromAuth(), minSong: 0 };
const albumMix = cfg.enabled && (cfg.minAlbum > 0 || cfg.minArtist > 0);
const pool = albumMix ? HERO_RANDOM_POOL : HERO_ALBUM_COUNT;
getRandomAlbums(pool)
.then(async raw => {
const list = albumMix
? (await filterAlbumsByMixRatings(raw, cfg)).slice(0, HERO_ALBUM_COUNT)
: raw;
setAlbums(list);
})
.catch(() => {});
}, [
albumsProp,
musicLibraryFilterVersion,
mixMinRatingFilterEnabled,
mixMinRatingAlbum,
mixMinRatingArtist,
]);
// Start / restart auto-advance timer (paused while the Tauri window is hidden).
const startTimer = useCallback((len: number) => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
if (len <= 1 || windowHidden || windowBlurred || !heroInViewRef.current || !computeHeroVisibleNow()) return;
timerRef.current = setInterval(() => {
const visibleNow = computeHeroVisibleNow();
if (!visibleNow && heroInViewRef.current) setHeroInView(false);
if (document.hidden || window.__psyHidden || window.__psyBlurred || !heroInViewRef.current || !visibleNow) {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
return;
}
setActiveIdx(prev => (prev + 1) % len);
}, INTERVAL_MS);
}, [windowHidden, windowBlurred, computeHeroVisibleNow]);
useEffect(() => {
// Hard-stop timer immediately when hero leaves viewport.
if (heroInView && !windowBlurred) return;
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, [heroInView, windowBlurred]);
useEffect(() => {
startTimer(albums.length);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
};
}, [albums.length, heroInView, startTimer]);
const goPrev = useCallback(() => {
const len = albums.length;
if (len <= 1) return;
setActiveIdx(prev => (prev - 1 + len) % len);
startTimer(len);
}, [albums.length, startTimer]);
const goNext = useCallback(() => {
const len = albums.length;
if (len <= 1) return;
setActiveIdx(prev => (prev + 1) % len);
startTimer(len);
}, [albums.length, startTimer]);
const album = albums[activeIdx] ?? null;
const heroArtistLabel = useMemo(
() => (album ? albumArtistDisplayName(album) : ''),
[album],
);
// Lazily fetch format label for the currently-visible album (cached by id)
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
useEffect(() => {
if (!album || albumFormats[album.id] !== undefined) return;
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
resolveAlbum(serverId, album.id).then(data => {
if (!data) {
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
return;
}
const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))];
setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') }));
}).catch(() => {
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
});
// Intentionally keyed on album?.id only: the format label is fetched once per
// album id and cached in albumFormats. Depending on the album object or the
// albumFormats map would re-run on every render / cache write.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [album?.id]);
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt);
const albumId = album?.id;
// Mainstage hero backdrop — the album artist's fanart (banner → 16:9 fanart),
// but its LAST fallback is the album's own Navidrome cover, not the artist
// image: the hero frames an album, so its base layer stays the album cover
// (the same backdrop shown when the feature is off). The artist-detail header
// keeps the artist cover as its last fallback — that surface frames an artist.
// Fed entirely from the album already in hand (artist id + name + album title),
// so there is no getArtist/getAlbum round-trip: the MBID lookup + fanart fetch
// live Rust-side in cover_cache.
const heroArtist = useMemo(
() => (album ? deriveAlbumArtistRefs(album)[0] : undefined),
[album],
);
const heroArtistId = heroArtist?.id;
const heroBanner = useArtistBanner(heroArtistId, {
artistName: heroArtist?.name,
albumTitle: album?.name,
});
const heroFanart = useArtistFanart(heroArtistId, {
artistName: heroArtist?.name,
albumTitle: album?.name,
});
// Last-fallback layer: the album's own Navidrome cover (HERO_BG_CSS_PX, full
// res), resolved scope-true from the album cover ref already in hand.
const ndAlbum = useCoverArt(heroCoverRef, HERO_BG_CSS_PX, { surface: 'sparse', fullRes: true });
const ndAlbumUrl = useCachedUrl(ndAlbum.src, ndAlbum.cacheKey, true);
const heroBackdrop = useHeroBackdrop(
mainstageBackdrop.sources,
{ banner: heroBanner, fanart: heroFanart, navidrome: ndAlbumUrl },
albumId,
);
const showHeroBackdrop =
mainstageBackdrop.enabled &&
!perfFlags.disableMainstageHeroBackdrop &&
heroInView;
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => { if (albumId) playAlbum(albumId); },
onLongPress: () => { if (albumId) playAlbumShuffled(albumId); },
});
if (!album) return <div className="hero-placeholder" />;
return (
<div
ref={heroRef}
className="hero"
role="banner"
aria-label={t('hero.eyebrow')}
onClick={() => navigateToAlbum(album.id)}
style={{ cursor: 'pointer' }}
>
{showHeroBackdrop && <HeroBg url={heroBackdrop.url} position={heroBackdrop.position} />}
{showHeroBackdrop && <div className="hero-overlay" aria-hidden="true" />}
{/* key causes re-mount → animate-fade-in triggers on each album change */}
<div className="hero-content" key={album.id}>
{heroCoverRef && !isMobile && (
<CoverArtImage
coverRef={heroCoverRef}
displayCssPx={HERO_FG_CSS_PX}
surface="dense"
ensurePriority="high"
className="hero-cover"
alt={`${album.name} Cover`}
/>
)}
<div className="hero-text">
<span className="hero-eyebrow">{t('hero.eyebrow')}</span>
<h2 className="hero-title">{album.name}</h2>
<p className="hero-artist">{heroArtistLabel}</p>
<div className="hero-meta">
{album.year && <span className="badge">{album.year}</span>}
{album.genre && <span className="badge">{album.genre}</span>}
{!isMobile && album.songCount && <span className="badge">{album.songCount} Tracks</span>}
{!isMobile && albumFormats[album.id] && <span className="badge">{albumFormats[album.id]}</span>}
</div>
{isMobile ? (
<div className="hero-actions-mobile" onClick={e => e.stopPropagation()}>
<button
className="album-icon-btn album-icon-btn--play long-press-play-btn"
{...pressBind}
aria-label={`${t('hero.playAlbum')} ${album.name}`}
data-tooltip={t('hero.playAlbumTooltip')}
>
<LongPressWaveOverlay active={isHolding} size="compact" />
<span className="long-press-play-btn__icon">
<Play size={22} fill="currentColor" />
</span>
</button>
<button
className="album-icon-btn album-icon-btn--queue"
onClick={async e => {
e.stopPropagation();
try {
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const albumData = await resolveAlbum(serverId, album.id);
if (!albumData) return;
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
} catch (_) { /* ignore: best-effort */ }
}}
aria-label={t('hero.enqueue')}
data-tooltip={t('hero.enqueueTooltip')}
>
<ListPlus size={20} />
</button>
</div>
) : (
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<button
className="hero-play-btn long-press-play-btn"
id="hero-play-btn"
{...pressBind}
aria-label={`${t('hero.playAlbum')} ${album.name}`}
data-tooltip={t('hero.playAlbumTooltip')}
>
<LongPressWaveOverlay active={isHolding} />
<span className="long-press-play-btn__icon" style={{ gap: '8px' }}>
<Play size={18} fill="currentColor" />
{t('hero.playAlbum')}
</span>
</button>
<button
className="btn btn-surface"
onClick={async (e) => {
e.stopPropagation();
try {
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const albumData = await resolveAlbum(serverId, album.id);
if (!albumData) return;
const tracks = albumData.songs.map(songToTrack);
usePlayerStore.getState().enqueue(tracks);
} catch (_) { /* ignore: best-effort */ }
}}
style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }}
data-tooltip={t('hero.enqueueTooltip')}
>
<ListPlus size={18} />
{t('hero.enqueue')}
</button>
</div>
)}
</div>
</div>
{/* Carousel navigation arrows + decorative dot indicators */}
{albums.length > 1 && (
<>
<div className="hero-nav" aria-hidden="false">
<button
type="button"
className="hero-nav-arrow hero-nav-arrow--left"
onClick={e => { e.stopPropagation(); goPrev(); }}
aria-label={t('hero.previousAlbum')}
data-tooltip={t('hero.previousAlbum')}
>
<ChevronLeft size={24} />
</button>
<button
type="button"
className="hero-nav-arrow hero-nav-arrow--right"
onClick={e => { e.stopPropagation(); goNext(); }}
aria-label={t('hero.nextAlbum')}
data-tooltip={t('hero.nextAlbum')}
>
<ChevronRight size={24} />
</button>
</div>
<div className="hero-dots" aria-hidden="true">
{albums.map((_, i) => (
<span
key={i}
className={`hero-dot${i === activeIdx ? ' hero-dot-active' : ''}`}
/>
))}
</div>
</>
)}
</div>
);
}