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 '@/features/album'; 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 '@/features/album'; 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 | 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 // 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 | 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 => ( ))} ); } 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(() => albumsProp?.length ? albumsProp : [], ); const [activeIdx, setActiveIdx] = useState(0); const timerRef = useRef | null>(null); const windowHidden = useWindowVisibility(); const [windowBlurred, setWindowBlurred] = useState(() => Boolean(window.__psyBlurred)); const heroRef = useRef(null); const heroScrollRootRef = useRef(null); const visibilityRafRef = useRef(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>({}); 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
; return (
navigateToAlbum(album.id)} style={{ cursor: 'pointer' }} > {showHeroBackdrop && } {showHeroBackdrop && ); }