mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
526 lines
22 KiB
TypeScript
526 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}
|