mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
revert to old feature carousel style (#1412)
This commit is contained in:
@@ -27,6 +27,20 @@
|
|||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blurred-background {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
.carousel-item :global(.overlay) {
|
.carousel-item :global(.overlay) {
|
||||||
border-radius: var(--theme-radius-md);
|
border-radius: var(--theme-radius-md);
|
||||||
}
|
}
|
||||||
@@ -53,6 +67,13 @@
|
|||||||
padding: var(--theme-spacing-md);
|
padding: var(--theme-spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.single-carousel-container .carousel-item .content {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--theme-spacing-lg);
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: var(--theme-spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
.title-section {
|
.title-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -77,6 +98,15 @@
|
|||||||
max-height: 160px;
|
max-height: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.single-carousel-container .carousel-item .content .image-section {
|
||||||
|
flex-shrink: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
min-height: auto;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
.play-button-overlay {
|
.play-button-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -106,6 +136,23 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.single-carousel-container .carousel-item .content .metadata-section {
|
||||||
|
flex: 1;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
height: auto;
|
||||||
|
min-height: auto;
|
||||||
|
max-height: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide metadata on screens smaller than xs */
|
||||||
|
@media (width < 36em) {
|
||||||
|
.single-carousel-container .carousel-item .content .metadata-section {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.image-link {
|
.image-link {
|
||||||
display: block;
|
display: block;
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
@@ -129,6 +176,11 @@
|
|||||||
transition: filter 0.3s ease;
|
transition: filter 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.single-carousel-container .album-image-container {
|
||||||
|
width: 200px;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
.album-image-container::before {
|
.album-image-container::before {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -149,7 +201,7 @@
|
|||||||
|
|
||||||
.album-image {
|
.album-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: var(--theme-radius-md);
|
border-radius: var(--theme-radius-md);
|
||||||
}
|
}
|
||||||
@@ -159,6 +211,12 @@
|
|||||||
filter: drop-shadow(0 16px 40px rgb(0 0 0 / 60%)) drop-shadow(0 6px 16px rgb(0 0 0 / 50%));
|
filter: drop-shadow(0 16px 40px rgb(0 0 0 / 60%)) drop-shadow(0 6px 16px rgb(0 0 0 / 50%));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Single carousel: remove hover shadow effect */
|
||||||
|
.single-carousel-container .carousel-item:hover .album-image-container,
|
||||||
|
.single-carousel-container .carousel-link:hover .album-image-container {
|
||||||
|
filter: drop-shadow(0 6px 20px rgb(0 0 0 / 50%)) drop-shadow(0 2px 8px rgb(0 0 0 / 40%));
|
||||||
|
}
|
||||||
|
|
||||||
.artist-link {
|
.artist-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -218,6 +276,20 @@
|
|||||||
transform: translateY(-50%) scale(0.95);
|
transform: translateY(-50%) scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Single carousel: hide arrows by default, show on hover */
|
||||||
|
.single-carousel-container .nav-arrow-left,
|
||||||
|
.single-carousel-container .nav-arrow-right {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-carousel-container:hover .nav-arrow-left,
|
||||||
|
.single-carousel-container:hover .nav-arrow-right {
|
||||||
|
pointer-events: auto;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
@container (min-width: $mantine-breakpoint-xs) {
|
@container (min-width: $mantine-breakpoint-xs) {
|
||||||
.carousel-item {
|
.carousel-item {
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import type { MouseEvent } from 'react';
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { generatePath, Link } from 'react-router';
|
||||||
|
|
||||||
|
import styles from './feature-carousel.module.css';
|
||||||
|
|
||||||
|
import { ItemImage, useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||||
|
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
|
import { BackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';
|
||||||
|
import { calculateTitleSize } from '/@/renderer/features/shared/components/library-header';
|
||||||
|
import { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';
|
||||||
|
import { useContainerQuery, useFastAverageColor } from '/@/renderer/hooks';
|
||||||
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
import { Separator } from '/@/shared/components/separator/separator';
|
||||||
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
|
import { TextTitle } from '/@/shared/components/text-title/text-title';
|
||||||
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
import { Album, LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
import { Play } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
animate: {},
|
||||||
|
exit: {},
|
||||||
|
initial: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: 'easeOut' as const,
|
||||||
|
},
|
||||||
|
y: 0,
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
ease: 'easeIn' as const,
|
||||||
|
},
|
||||||
|
y: 0,
|
||||||
|
},
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
y: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CarouselItemProps {
|
||||||
|
album: Album;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SingleFeatureCarouselProps {
|
||||||
|
data: Album[] | undefined;
|
||||||
|
onNearEnd?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// const CAROUSEL_AUTOPLAY_INTERVAL = 10000;
|
||||||
|
|
||||||
|
const CarouselItem = ({ album }: CarouselItemProps) => {
|
||||||
|
const imageUrl = useItemImageUrl({
|
||||||
|
id: album.imageId || undefined,
|
||||||
|
itemType: LibraryItem.ALBUM,
|
||||||
|
type: 'itemCard',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { background: backgroundColor } = useFastAverageColor({
|
||||||
|
algorithm: 'dominant',
|
||||||
|
src: imageUrl || null,
|
||||||
|
srcLoaded: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = useCurrentServer();
|
||||||
|
const { addToQueueByFetch } = usePlayer();
|
||||||
|
|
||||||
|
const handlePlay = (type: Play) => {
|
||||||
|
if (!server?.id) return;
|
||||||
|
addToQueueByFetch(server.id, [album.id], LibraryItem.ALBUM, type);
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadataItems = useMemo(() => {
|
||||||
|
return [
|
||||||
|
...(album.genres?.slice(0, 2).map((genre) => genre.name) || []),
|
||||||
|
album.releaseYear ? album.releaseYear.toString() : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
}, [album]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.carouselItem}>
|
||||||
|
{imageUrl && (
|
||||||
|
<div
|
||||||
|
className={styles.blurredBackground}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${imageUrl})`,
|
||||||
|
filter: 'blur(3rem)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<BackgroundOverlay backgroundColor={backgroundColor} opacity={0.7} />
|
||||||
|
<Link
|
||||||
|
className={styles.carouselLink}
|
||||||
|
state={{ item: album }}
|
||||||
|
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||||
|
albumId: album.id,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.imageSection}>
|
||||||
|
<ItemImage
|
||||||
|
className={styles.albumImage}
|
||||||
|
containerClassName={styles.albumImageContainer}
|
||||||
|
id={album.imageId}
|
||||||
|
itemType={LibraryItem.ALBUM}
|
||||||
|
type="itemCard"
|
||||||
|
/>
|
||||||
|
<div className={styles.playButtonOverlay}>
|
||||||
|
<PlayButtonGroup onPlay={handlePlay} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.metadataSection}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<TextTitle
|
||||||
|
className={styles.title}
|
||||||
|
fw={900}
|
||||||
|
lh={1.1}
|
||||||
|
order={1}
|
||||||
|
style={{ fontSize: calculateTitleSize(album.name) }}
|
||||||
|
ta="left"
|
||||||
|
>
|
||||||
|
{album.name}
|
||||||
|
</TextTitle>
|
||||||
|
{album.albumArtistName && (
|
||||||
|
<TextTitle
|
||||||
|
className={styles.title}
|
||||||
|
fw={700}
|
||||||
|
lh={1.1}
|
||||||
|
order={5}
|
||||||
|
ta="left"
|
||||||
|
>
|
||||||
|
{album.albumArtistName}
|
||||||
|
</TextTitle>
|
||||||
|
)}
|
||||||
|
<Group gap="xs" justify="flex-start" wrap="wrap">
|
||||||
|
{metadataItems.map((item, index) => (
|
||||||
|
<Text
|
||||||
|
className={styles.title}
|
||||||
|
fw={600}
|
||||||
|
key={`metadata-${item}`}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
{index < metadataItems.length - 1 && <Separator />}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SingleFeatureCarousel = ({ data, onNearEnd }: SingleFeatureCarouselProps) => {
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const directionRef = useRef<{ isNext: boolean }>({ isNext: true });
|
||||||
|
const { ref: containerRef } = useContainerQuery({
|
||||||
|
'2xl': 1920,
|
||||||
|
'3xl': 2560,
|
||||||
|
lg: 1024,
|
||||||
|
md: 768,
|
||||||
|
sm: 640,
|
||||||
|
xl: 1440,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if we're near the end and trigger loading more
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || !onNearEnd) return;
|
||||||
|
const remainingItems = data.length - currentIndex;
|
||||||
|
// Trigger when we have less than 3 items remaining
|
||||||
|
if (remainingItems < 3) {
|
||||||
|
onNearEnd();
|
||||||
|
}
|
||||||
|
}, [data, currentIndex, onNearEnd]);
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (!data || data.length <= 1 || isPaused) {
|
||||||
|
// if (intervalRef.current) {
|
||||||
|
// clearInterval(intervalRef.current);
|
||||||
|
// intervalRef.current = null;
|
||||||
|
// }
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (intervalRef.current) {
|
||||||
|
// clearInterval(intervalRef.current);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// intervalRef.current = setInterval(() => {
|
||||||
|
// setCurrentIndex((prev) => (prev + 1) % data.length);
|
||||||
|
// directionRef.current = { isNext: true };
|
||||||
|
// }, CAROUSEL_AUTOPLAY_INTERVAL);
|
||||||
|
|
||||||
|
// return () => {
|
||||||
|
// if (intervalRef.current) {
|
||||||
|
// clearInterval(intervalRef.current);
|
||||||
|
// intervalRef.current = null;
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
// }, [data, isPaused, intervalKey]);
|
||||||
|
|
||||||
|
const handleNext = useCallback(
|
||||||
|
(e?: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
e?.stopPropagation();
|
||||||
|
if (!data) return;
|
||||||
|
directionRef.current = { isNext: true };
|
||||||
|
setCurrentIndex((prev) => (prev + 1) % data.length);
|
||||||
|
// setIntervalKey((prev) => prev + 1);
|
||||||
|
},
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePrevious = useCallback(
|
||||||
|
(e?: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
e?.stopPropagation();
|
||||||
|
if (!data) return;
|
||||||
|
directionRef.current = { isNext: false };
|
||||||
|
setCurrentIndex((prev) => (prev - 1 + data.length) % data.length);
|
||||||
|
// setIntervalKey((prev) => prev + 1);
|
||||||
|
},
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canNavigate = data && data.length > 1;
|
||||||
|
|
||||||
|
const wheelCooldownRef = useRef(0);
|
||||||
|
const wheelThreshold = 10;
|
||||||
|
const wheelCooldownMs = 250;
|
||||||
|
|
||||||
|
const handleWheel = useCallback(
|
||||||
|
(event: React.WheelEvent<HTMLDivElement>) => {
|
||||||
|
if (!canNavigate || !data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.shiftKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - wheelCooldownRef.current;
|
||||||
|
|
||||||
|
const horizontalDelta = Math.abs(event.deltaY);
|
||||||
|
|
||||||
|
if (horizontalDelta < wheelThreshold || elapsed < wheelCooldownMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.deltaY > 0) {
|
||||||
|
wheelCooldownRef.current = now;
|
||||||
|
handleNext();
|
||||||
|
} else if (event.deltaY < 0) {
|
||||||
|
wheelCooldownRef.current = now;
|
||||||
|
handlePrevious();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[canNavigate, data, handleNext, handlePrevious, wheelCooldownMs, wheelThreshold],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentAlbum = data[currentIndex];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.carouselContainer} ${styles.singleCarouselContainer}`}
|
||||||
|
// onMouseEnter={() => setIsPaused(true)}
|
||||||
|
// onMouseLeave={() => setIsPaused(false)}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
|
<motion.div
|
||||||
|
animate="animate"
|
||||||
|
className={styles.carousel}
|
||||||
|
exit="exit"
|
||||||
|
initial="initial"
|
||||||
|
key={`carousel-${currentIndex}`}
|
||||||
|
style={{ '--items-per-row': 1 } as React.CSSProperties}
|
||||||
|
variants={containerVariants}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
key={`item-${currentAlbum.id}-${currentIndex}`}
|
||||||
|
variants={itemVariants}
|
||||||
|
>
|
||||||
|
<CarouselItem album={currentAlbum} />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{data.length > 1 && (
|
||||||
|
<>
|
||||||
|
<ActionIcon
|
||||||
|
className={styles.navArrowLeft}
|
||||||
|
icon="arrowLeftS"
|
||||||
|
iconProps={{ size: 'xl' }}
|
||||||
|
onClick={handlePrevious}
|
||||||
|
radius="50%"
|
||||||
|
size="md"
|
||||||
|
styles={{
|
||||||
|
icon: {
|
||||||
|
color: 'white',
|
||||||
|
fill: 'white',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
className={styles.navArrowRight}
|
||||||
|
icon="arrowRightS"
|
||||||
|
iconProps={{ size: 'xl' }}
|
||||||
|
onClick={handleNext}
|
||||||
|
radius="50%"
|
||||||
|
size="md"
|
||||||
|
styles={{
|
||||||
|
icon: {
|
||||||
|
color: 'white',
|
||||||
|
fill: 'white',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import { SingleFeatureCarousel } from '/@/renderer/components/feature-carousel/single-feature-carousel';
|
||||||
|
import { useCurrentServerId } from '/@/renderer/store';
|
||||||
|
import { Album, AlbumListResponse, AlbumListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
interface InfiniteAlbumSingleFeatureCarouselProps {
|
||||||
|
itemLimit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlbumInfiniteSingleFeatureCarousel = ({
|
||||||
|
itemLimit = 20,
|
||||||
|
}: InfiniteAlbumSingleFeatureCarouselProps) => {
|
||||||
|
const serverId = useCurrentServerId();
|
||||||
|
const loadMoreTriggeredRef = useRef(false);
|
||||||
|
|
||||||
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||||
|
useSuspenseInfiniteQuery<AlbumListResponse>({
|
||||||
|
getNextPageParam: (lastPage, _allPages, lastPageParam) => {
|
||||||
|
if (lastPage.items.length < itemLimit) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPageParam = Number(lastPageParam) + itemLimit;
|
||||||
|
|
||||||
|
return String(nextPageParam);
|
||||||
|
},
|
||||||
|
initialPageParam: '0',
|
||||||
|
queryFn: ({ pageParam, signal }) => {
|
||||||
|
return api.controller.getAlbumList({
|
||||||
|
apiClientProps: { serverId, signal },
|
||||||
|
query: {
|
||||||
|
limit: itemLimit,
|
||||||
|
sortBy: AlbumListSort.RANDOM,
|
||||||
|
sortOrder: SortOrder.DESC,
|
||||||
|
startIndex: Number(pageParam),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
queryKey: queryKeys.albums.infiniteList(serverId, {
|
||||||
|
sortBy: AlbumListSort.RANDOM,
|
||||||
|
sortOrder: SortOrder.DESC,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flatten all pages and filter for albums with images
|
||||||
|
const albumsWithImages = useMemo(() => {
|
||||||
|
const allAlbums = data.pages.flatMap((page: AlbumListResponse) => page.items);
|
||||||
|
// Filter for albums with images and remove duplicates by ID
|
||||||
|
const uniqueAlbums = new Map<string, Album>();
|
||||||
|
for (const album of allAlbums) {
|
||||||
|
if (album.imageId && !uniqueAlbums.has(album.id)) {
|
||||||
|
uniqueAlbums.set(album.id, album);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(uniqueAlbums.values());
|
||||||
|
}, [data.pages]);
|
||||||
|
|
||||||
|
const handleNearEnd = () => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage && !loadMoreTriggeredRef.current) {
|
||||||
|
loadMoreTriggeredRef.current = true;
|
||||||
|
fetchNextPage().finally(() => {
|
||||||
|
loadMoreTriggeredRef.current = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
albumsWithImages.length < itemLimit * 2 &&
|
||||||
|
hasNextPage &&
|
||||||
|
!isFetchingNextPage &&
|
||||||
|
!loadMoreTriggeredRef.current
|
||||||
|
) {
|
||||||
|
loadMoreTriggeredRef.current = true;
|
||||||
|
fetchNextPage().finally(() => {
|
||||||
|
loadMoreTriggeredRef.current = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [albumsWithImages.length, hasNextPage, isFetchingNextPage, fetchNextPage, itemLimit]);
|
||||||
|
|
||||||
|
if (albumsWithImages.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SingleFeatureCarousel data={albumsWithImages} onNearEnd={handleNearEnd} />;
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
|
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
|
||||||
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';
|
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';
|
||||||
import { AlbumInfiniteFeatureCarousel } from '/@/renderer/features/home/components/album-infinite-feature-carousel';
|
import { AlbumInfiniteFeatureCarousel } from '/@/renderer/features/home/components/album-infinite-feature-carousel';
|
||||||
|
import { AlbumInfiniteSingleFeatureCarousel } from '/@/renderer/features/home/components/album-infinite-single-feature-carousel';
|
||||||
import { FeaturedGenres } from '/@/renderer/features/home/components/featured-genres';
|
import { FeaturedGenres } from '/@/renderer/features/home/components/featured-genres';
|
||||||
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
||||||
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
|
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
|
||||||
@@ -113,7 +114,7 @@ const HomeRoute = () => {
|
|||||||
pt={windowBarStyle === Platform.WEB ? '5rem' : '3rem'}
|
pt={windowBarStyle === Platform.WEB ? '5rem' : '3rem'}
|
||||||
px="2rem"
|
px="2rem"
|
||||||
>
|
>
|
||||||
{homeFeature && <AlbumInfiniteFeatureCarousel />}
|
{homeFeature && <AlbumInfiniteSingleFeatureCarousel />}
|
||||||
{sortedItems.map((item) => {
|
{sortedItems.map((item) => {
|
||||||
if (item.id === HomeItem.GENRES) {
|
if (item.id === HomeItem.GENRES) {
|
||||||
return <FeaturedGenres key="featured-genres" />;
|
return <FeaturedGenres key="featured-genres" />;
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export const LibraryHeader = forwardRef(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const isAsianCharacter = (char: string): boolean => {
|
export const isAsianCharacter = (char: string): boolean => {
|
||||||
const codePoint = char.codePointAt(0);
|
const codePoint = char.codePointAt(0);
|
||||||
|
|
||||||
if (!codePoint) return false;
|
if (!codePoint) return false;
|
||||||
@@ -207,7 +207,7 @@ const isAsianCharacter = (char: string): boolean => {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateWeightedLength = (str: string): number => {
|
export const calculateWeightedLength = (str: string): number => {
|
||||||
let length = 0;
|
let length = 0;
|
||||||
for (const char of str) {
|
for (const char of str) {
|
||||||
length += isAsianCharacter(char) ? 2.5 : 1;
|
length += isAsianCharacter(char) ? 2.5 : 1;
|
||||||
@@ -215,7 +215,7 @@ const calculateWeightedLength = (str: string): number => {
|
|||||||
return length;
|
return length;
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateTitleSize = (title: string) => {
|
export const calculateTitleSize = (title: string) => {
|
||||||
const titleLength = calculateWeightedLength(title);
|
const titleLength = calculateWeightedLength(title);
|
||||||
let baseSize = '3dvw';
|
let baseSize = '3dvw';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user