import { getRandomAlbums } from '../api/subsonicLibrary'; import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve'; 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 { useCoverArt } from '../cover/useCoverArt'; import { useAlbumCoverRef } from '../cover/useLibraryCoverRef'; 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 } 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 function HeroBg({ url }: { url: string }) { const [layers, setLayers] = useState>(() => url ? [{ url, id: 0, visible: true }] : [] ); const counter = useRef(url ? 1 : 0); const latestUrlRef = useRef(url); latestUrlRef.current = url; useEffect(() => { if (!url) { setLayers([]); return; } const id = counter.current++; setLayers(prev => [...prev, { url, id, visible: false }]); const t1 = setTimeout(() => { if (latestUrlRef.current !== url) return; setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))); }, 20); const t2 = setTimeout(() => { if (latestUrlRef.current !== url) return; setLayers(prev => prev.filter(l => l.id === id)); }, 900); return () => { clearTimeout(t1); clearTimeout(t2); }; }, [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 enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground); 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); 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(() => { 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]: '' })); }); }, [album?.id]); const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt); const bgHandle = useCoverArt(heroCoverRef, HERO_BG_CSS_PX, { surface: 'dense', ensurePriority: 'high', }); // Per-album fallback so a cache miss on the current slide does not flash empty, // but never reuse another album's art (that caused bg/foreground desync on fast nav). const stableBgByAlbum = useRef>({}); const albumId = album?.id; useEffect(() => { if (bgHandle.src && albumId) { stableBgByAlbum.current[albumId] = bgHandle.src; } }, [bgHandle.src, albumId]); const heroBgUrl = bgHandle.src || (albumId ? stableBgByAlbum.current[albumId] ?? '' : ''); const { isHolding, pressBind } = useLongPressAction({ onShortPress: () => { if (albumId) playAlbum(albumId); }, onLongPress: () => { if (albumId) playAlbumShuffled(albumId); }, }); if (!album) return
; return (
navigateToAlbum(album.id)} style={{ cursor: 'pointer' }} > {enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && } {enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && ); }