revert to old feature carousel style (#1412)

This commit is contained in:
jeffvli
2026-01-14 21:15:24 -08:00
parent a8604dd150
commit b79ebdfbef
5 changed files with 517 additions and 5 deletions
@@ -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';