redesign feature carousel

This commit is contained in:
jeffvli
2025-11-22 13:22:02 -08:00
parent e80fc1a513
commit 6a0b36cfa6
6 changed files with 612 additions and 230 deletions
@@ -1,73 +1,287 @@
.carousel-container {
position: relative;
width: 100%;
margin-bottom: var(--theme-spacing-xl);
container-type: inline-size;
overflow: hidden;
border-radius: var(--theme-radius-lg);
}
.carousel { .carousel {
position: relative; position: relative;
height: 35vh;
min-height: 250px;
max-height: 300px;
padding: var(--theme-spacing-md);
overflow: hidden;
}
.grid {
display: grid; display: grid;
grid-template-areas: 'image info'; grid-template-columns: repeat(var(--items-per-row, 1), 1fr);
grid-template-rows: 1fr; gap: var(--theme-spacing-md);
grid-template-columns: 200px minmax(0, 1fr);
grid-auto-columns: 1fr;
width: 100%; width: 100%;
max-width: 100%; min-height: 400px;
height: 100%; padding: var(--theme-spacing-xl);
overflow: hidden;
--items-per-row: 1;
} }
.image-column { .carousel-item {
z-index: 15;
display: flex;
grid-area: image;
align-items: flex-end;
}
.info-column {
z-index: 15;
display: flex;
grid-area: info;
align-items: flex-end;
width: 100%;
max-width: 100%;
padding-left: 1rem;
}
.background-image {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 150%;
height: 150%;
user-select: none;
object-fit: var(--theme-image-fit);
object-position: 0 30%;
filter: blur(24px);
}
.background-image-overlay {
position: absolute;
top: 0;
left: 0;
z-index: 10;
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgb(25 26 28 / 30%), var(--theme-colors-background));
}
.wrapper {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; min-height: 400px;
overflow: hidden; overflow: hidden;
border-radius: var(--theme-radius-md);
isolation: isolate;
} }
.title-wrapper { .carousel-item :global(.overlay) {
display: -webkit-box; border-radius: var(--theme-radius-md);
overflow: hidden; }
-webkit-line-clamp: 2;
-webkit-box-orient: vertical; .carousel-link {
display: block;
width: 100%;
height: 100%;
color: inherit;
text-decoration: none;
}
.content {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
gap: var(--theme-spacing-md);
align-items: center;
justify-content: flex-start;
width: 100%;
height: 100%;
min-height: 400px;
padding: var(--theme-spacing-xl);
}
.title-section {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 100%;
height: 60px;
min-height: 60px;
max-height: 60px;
text-align: center;
}
.image-section {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 100%;
height: 200px;
min-height: 200px;
max-height: 200px;
}
.metadata-section {
display: flex;
flex-shrink: 0;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100px;
min-height: 100px;
max-height: 100px;
text-align: center;
}
.image-link {
display: block;
transition: transform 0.3s ease;
}
.image-link:hover {
transform: scale(1.02);
}
.image-link:active {
transform: scale(0.98);
}
.album-image {
width: 100%;
max-width: 180px;
height: auto;
border-radius: var(--theme-radius-lg);
box-shadow: 0 8px 24px rgb(0 0 0 / 60%);
transition: box-shadow 0.3s ease;
}
.image-link:hover .album-image {
box-shadow: 0 12px 32px rgb(0 0 0 / 40%);
}
.artist-link {
display: inline-block;
color: inherit;
text-decoration: none;
transition: opacity 0.2s ease;
}
.artist-link:hover {
opacity: 0.8;
}
.title {
margin-bottom: var(--theme-spacing-xs);
color: white;
text-shadow: 0 0 10px rgb(0 0 0 / 50%);
}
.artist {
color: white;
text-shadow: 0 0 10px rgb(0 0 0 / 50%);
}
.nav-arrow-left,
.nav-arrow-right {
position: absolute;
top: 50%;
z-index: 20;
border: 1px solid rgb(255 255 255 / 25%);
backdrop-filter: blur(10px);
transform: translateY(-50%);
transition: all 0.2s ease;
}
.nav-arrow-left {
left: var(--theme-spacing-xs);
}
.nav-arrow-right {
right: var(--theme-spacing-xs);
}
.nav-arrow-left:hover,
.nav-arrow-right:hover {
background: transparent !important;
border-color: rgb(255 255 255 / 35%);
transform: translateY(-50%) scale(1.1);
}
.nav-arrow-left:active,
.nav-arrow-right:active {
transform: translateY(-50%) scale(0.95);
}
@container (min-width: 640px) {
.carousel {
--items-per-row: 1;
}
}
@container (min-width: $mantine-breakpoint-sm) {
.carousel {
--items-per-row: 3;
gap: var(--theme-spacing-lg);
}
.carousel-item {
min-height: 450px;
}
.content {
min-height: 450px;
}
.title-section {
height: 70px;
min-height: 70px;
max-height: 70px;
}
.image-section {
height: 220px;
min-height: 220px;
max-height: 220px;
}
.metadata-section {
height: 110px;
min-height: 110px;
max-height: 110px;
}
.album-image {
max-width: 200px;
}
}
@container (min-width: $mantine-breakpoint-md) {
.carousel {
--items-per-row: 4;
}
.carousel-item {
min-height: 500px;
}
.content {
min-height: 500px;
}
.title-section {
height: 80px;
min-height: 80px;
max-height: 80px;
}
.image-section {
height: 250px;
min-height: 250px;
max-height: 250px;
}
.metadata-section {
height: 120px;
min-height: 120px;
max-height: 120px;
}
.album-image {
max-width: 220px;
}
}
@container (min-width: $mantine-breakpoint-xl) {
.carousel {
--items-per-row: 5;
}
.carousel-item {
min-height: 550px;
}
.content {
min-height: 550px;
}
.title-section {
height: 90px;
min-height: 90px;
max-height: 90px;
}
.image-section {
height: 280px;
min-height: 280px;
max-height: 280px;
}
.metadata-section {
height: 130px;
min-height: 130px;
max-height: 130px;
}
.album-image {
max-width: 240px;
}
} }
@@ -1,39 +1,41 @@
import type { Variants } from 'motion/react';
import type { MouseEvent } from 'react'; import type { MouseEvent } from 'react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath, Link } from 'react-router'; import { generatePath, Link } from 'react-router';
import styles from './feature-carousel.module.css'; import styles from './feature-carousel.module.css';
import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { ItemCard } from '/@/renderer/components/item-card/item-card';
import { PlayButton } from '/@/renderer/features/shared/components/play-button'; import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import { BackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';
import { useContainerQuery, useFastAverageColor } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, usePlayButtonBehavior } from '/@/renderer/store'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Badge } from '/@/shared/components/badge/badge'; import { Badge } from '/@/shared/components/badge/badge';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { TextTitle } from '/@/shared/components/text-title/text-title'; import { TextTitle } from '/@/shared/components/text-title/text-title';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { Album, LibraryItem } from '/@/shared/types/domain-types'; import { Album, LibraryItem } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
const variants: Variants = { const fadeVariants = {
animate: { center: {
opacity: 1, opacity: 1,
transition: { opacity: { duration: 0.5 } }, transition: {
duration: 0.4,
ease: 'easeInOut' as const,
},
},
enter: {
opacity: 0,
}, },
exit: { exit: {
opacity: 0, opacity: 0,
transition: { opacity: { duration: 0.5 } }, transition: {
}, duration: 0.4,
initial: { ease: 'easeInOut' as const,
opacity: 0, },
}, },
}; };
@@ -41,146 +43,191 @@ interface FeatureCarouselProps {
data: Album[] | undefined; data: Album[] | undefined;
} }
export const FeatureCarousel = ({ data }: FeatureCarouselProps) => { const getItemsPerRow = (breakpoints: {
const { t } = useTranslation(); is2xl: boolean;
const { addToQueueByFetch } = usePlayer(); isLg: boolean;
const server = useCurrentServer(); isMd: boolean;
const [itemIndex, setItemIndex] = useState(0); isSm: boolean;
const [direction, setDirection] = useState(0); isXl: boolean;
const playType = usePlayButtonBehavior(); }) => {
if (breakpoints.is2xl) return 5;
if (breakpoints.isXl) return 5;
if (breakpoints.isLg) return 4;
if (breakpoints.isMd) return 3;
return 1;
};
const currentItem = data?.[itemIndex]; interface CarouselItemProps {
album: Album;
}
const handleNext = (e: MouseEvent<HTMLButtonElement>) => { const CarouselItem = ({ album }: CarouselItemProps) => {
e.preventDefault(); const { background: backgroundColor } = useFastAverageColor({
setDirection(1); algorithm: 'dominant',
if (itemIndex === (data?.length || 0) - 1 || 0) { src: album.imageUrl || null,
setItemIndex(0); srcLoaded: true,
return; });
}
setItemIndex((prev) => prev + 1); const controls = useDefaultItemListControls();
};
const handlePrevious = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setDirection(-1);
if (itemIndex === 0) {
setItemIndex((data?.length || 0) - 1);
return;
}
setItemIndex((prev) => prev - 1);
};
return ( return (
<Link <div className={styles.carouselItem}>
className={styles.wrapper} <BackgroundOverlay backgroundColor={backgroundColor} opacity={1} />
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: currentItem?.id || '' })} <Link
> className={styles.carouselLink}
<AnimatePresence custom={direction} initial={false} mode="popLayout"> state={{ item: album }}
{data && ( to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
<motion.div albumId: album.id,
animate="animate" })}
className={styles.carousel} >
custom={direction} <div className={styles.content}>
exit="exit" <div className={styles.titleSection}>
initial="initial" <TextTitle className={styles.title} fw={700} lineClamp={2} order={3}>
key={`image-${itemIndex}`} {album.name}
variants={variants} </TextTitle>
> </div>
<div className={styles.grid}>
<div className={styles.imageColumn}>
<Image
height={225}
src={data[itemIndex]?.imageUrl || ''}
width={225}
/>
</div>
<div className={styles.infoColumn}>
<Stack gap="md" style={{ width: '100%' }}>
<div className={styles.titleWrapper}>
<TextTitle
fw={900}
lineClamp={2}
order={1}
overflow="hidden"
>
{currentItem?.name}
</TextTitle>
</div>
<div className={styles.titleWrapper}>
{currentItem?.albumArtists.slice(0, 1).map((artist) => (
<Text fw={600} key={`carousel-artist-${artist.id}`}>
{artist.name}
</Text>
))}
</div>
<Group>
{currentItem?.genres?.slice(0, 1).map((genre) => (
<Badge
key={`carousel-genre-${genre.id}`}
variant="default"
>
{genre.name}
</Badge>
))}
<Badge variant="default">{currentItem?.releaseYear}</Badge>
</Group>
<Group justify="space-between">
<PlayButton
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!currentItem || !server?.id) return;
addToQueueByFetch( <div
server.id, className={styles.imageSection}
[currentItem.id], onClick={(e) => {
LibraryItem.ALBUM, e.preventDefault();
playType, e.stopPropagation();
); }}
}} >
variant="outline" <ItemCard
> controls={controls}
{t( data={album}
playType === Play.NOW itemType={LibraryItem.ALBUM}
? 'player.play' rows={[]}
: playType === Play.NEXT type="poster"
? 'player.addNext' withControls
: 'player.addLast',
{ postProcess: 'titleCase' },
)}
</PlayButton>
<Group gap="sm">
<Button
onClick={handlePrevious}
radius="lg"
variant="subtle"
>
<Icon icon="arrowLeftS" />
</Button>
<Button
onClick={handleNext}
radius="lg"
variant="subtle"
>
<Icon icon="arrowRightS" />
</Button>
</Group>
</Group>
</Stack>
</div>
</div>
<Image
className={styles.backgroundImage}
draggable="false"
src={currentItem?.imageUrl || ''}
/> />
<div className={styles.backgroundImageOverlay} /> </div>
</motion.div>
)} <div className={styles.metadataSection}>
</AnimatePresence> <Stack gap="sm">
</Link> {album.albumArtists.slice(0, 1).map((artist) => (
<Link
className={styles.artistLink}
key={`artist-${artist.id}`}
onClick={(e) => {
e.stopPropagation();
}}
state={{ item: artist }}
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
>
<Text className={styles.artist} fw={600} size="md">
{artist.name}
</Text>
</Link>
))}
<Group gap="sm" justify="center" wrap="wrap">
{album.genres?.slice(0, 2).map((genre) => (
<Badge key={`genre-${genre.id}`} size="sm">
{genre.name}
</Badge>
))}
{album.releaseYear && <Badge size="sm">{album.releaseYear}</Badge>}
</Group>
</Stack>
</div>
</div>
</Link>
</div>
);
};
export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
const [startIndex, setStartIndex] = useState(0);
const {
is2xl,
isLg,
isMd,
isSm,
isXl,
ref: containerRef,
} = useContainerQuery({
'2xl': 1920,
lg: 1024,
md: 768,
sm: 640,
xl: 1440,
});
const itemsPerRow = useMemo(
() => getItemsPerRow({ is2xl, isLg, isMd, isSm, isXl }),
[is2xl, isLg, isMd, isSm, isXl],
);
const visibleItems = useMemo(() => {
if (!data) return [];
const items: Album[] = [];
for (let i = 0; i < itemsPerRow; i++) {
const index = (startIndex + i) % data.length;
items.push(data[index]);
}
return items;
}, [data, startIndex, itemsPerRow]);
const handleNext = (e?: MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
if (!data) return;
setStartIndex((prev) => (prev + itemsPerRow) % data.length);
};
const handlePrevious = (e?: MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
if (!data) return;
setStartIndex((prev) => (prev - itemsPerRow + data.length) % data.length);
};
if (!data || data.length === 0) {
return null;
}
return (
<div className={styles.carouselContainer} ref={containerRef}>
<AnimatePresence initial={false} mode="popLayout">
<motion.div
animate="center"
className={styles.carousel}
exit="exit"
initial="enter"
key={`carousel-${startIndex}`}
variants={fadeVariants}
>
{visibleItems.map((album) => (
<CarouselItem album={album} key={`item-${album.id}-${startIndex}`} />
))}
</motion.div>
</AnimatePresence>
{data.length > itemsPerRow && (
<>
<ActionIcon
className={styles.navArrowLeft}
icon="arrowLeftS"
iconProps={{ size: 'xl' }}
onClick={handlePrevious}
radius="50%"
size="md"
variant="subtle"
/>
<ActionIcon
className={styles.navArrowRight}
icon="arrowRightS"
iconProps={{ size: 'xl' }}
onClick={handleNext}
radius="50%"
size="md"
variant="subtle"
/>
</>
)}
</div>
); );
}; };
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense, useMemo, useRef } from 'react'; import { Suspense, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -28,12 +28,12 @@ const HomeRoute = () => {
const isJellyfin = server?.type === ServerType.JELLYFIN; const isJellyfin = server?.type === ServerType.JELLYFIN;
const feature = useQuery( const feature = useSuspenseQuery({
albumQueries.list({ ...albumQueries.list({
options: { options: {
enabled: homeFeature, enabled: homeFeature,
gcTime: 1000 * 60, gcTime: 1000 * 30,
staleTime: 1000 * 60, staleTime: 1000 * 30,
}, },
query: { query: {
limit: 20, limit: 20,
@@ -43,7 +43,8 @@ const HomeRoute = () => {
}, },
serverId: server?.id, serverId: server?.id,
}), }),
); queryKey: ['home', 'feature'],
});
const featureItemsWithImage = useMemo(() => { const featureItemsWithImage = useMemo(() => {
return feature.data?.items?.filter((item) => item.imageUrl) ?? []; return feature.data?.items?.filter((item) => item.imageUrl) ?? [];
@@ -124,17 +125,13 @@ const HomeRoute = () => {
{sortedCarousel.map((carousel) => { {sortedCarousel.map((carousel) => {
if (carousel.itemType === LibraryItem.ALBUM) { if (carousel.itemType === LibraryItem.ALBUM) {
return ( return (
<Suspense <AlbumInfiniteCarousel
fallback={<Spinner container />}
key={`carousel-${carousel.uniqueId}`} key={`carousel-${carousel.uniqueId}`}
> rowCount={1}
<AlbumInfiniteCarousel sortBy={carousel.sortBy}
rowCount={1} sortOrder={carousel.sortOrder}
sortBy={carousel.sortBy} title={carousel.title}
sortOrder={carousel.sortOrder} />
title={carousel.title}
/>
</Suspense>
); );
} }
@@ -151,4 +148,12 @@ const HomeRoute = () => {
); );
}; };
export default HomeRoute; const SuspensedHomeRoute = () => {
return (
<Suspense fallback={<Spinner container />}>
<HomeRoute />
</Suspense>
);
};
export default SuspensedHomeRoute;
@@ -6,7 +6,28 @@
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
background-image: var(--theme-overlay-subheader); background-image: var(--theme-overlay-subheader);
opacity: 0.7; }
.background-overlay {
--color-from: var(--background-base-min-contrast);
--color-to: transparent;
--dither: none;
--direction-and-possibly-color-interpolation: to bottom;
position: absolute;
z-index: -1;
width: 100%;
min-height: 200px;
pointer-events: none;
user-select: none;
background-color: var(--color-from);
background-image:
linear-gradient(
var(--direction-and-possibly-color-interpolation),
var(--color-from),
var(--color-to)
),
var(--dither);
} }
.background-image { .background-image {
@@ -1,28 +1,71 @@
import { generateColors } from '@mantine/colors-generator';
import clsx from 'clsx';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import styles from './library-background-overlay.module.css'; import styles from './library-background-overlay.module.css';
import { useAppThemeColors } from '/@/renderer/themes/use-app-theme';
interface LibraryBackgroundOverlayProps { interface LibraryBackgroundOverlayProps {
backgroundColor?: string; backgroundColor?: string;
headerRef: React.RefObject<HTMLDivElement | null>; headerRef: React.RefObject<HTMLDivElement | null>;
opacity?: number;
} }
export const LibraryBackgroundOverlay = ({ export const LibraryBackgroundOverlay = ({
backgroundColor, backgroundColor,
headerRef, headerRef,
opacity = 0.7,
}: LibraryBackgroundOverlayProps) => { }: LibraryBackgroundOverlayProps) => {
const height = useHeaderHeight(headerRef); const height = useHeaderHeight(headerRef);
return ( return (
<div <div
className={styles.overlay} className={styles.overlay}
style={{ style={{
backgroundColor, backgroundColor,
height: height ? `${height + 64}px` : undefined, height: height ? `${height + 64}px` : undefined,
opacity,
}} }}
/> />
); );
}; };
interface BackgroundOverlayProps {
backgroundColor?: string;
direction?: string;
height?: number | string;
opacity?: number;
}
export const BackgroundOverlay = ({
backgroundColor,
direction = 'to bottom',
height = '100%',
opacity,
}: BackgroundOverlayProps) => {
const theme = useAppThemeColors();
const colors = generateColors(backgroundColor || theme.color['--theme-colors-background']);
return (
<div
className={clsx(styles.backgroundOverlay)}
style={
{
'--color-from': colors[6],
'--color-to': colors[9],
'--direction-and-possibly-color-interpolation': direction,
'--dither': 'none',
backgroundColor: backgroundColor,
height,
opacity,
} as React.CSSProperties
}
/>
);
};
interface LibraryBackgroundProps { interface LibraryBackgroundProps {
blur?: number; blur?: number;
headerRef: React.RefObject<HTMLDivElement | null>; headerRef: React.RefObject<HTMLDivElement | null>;
+52
View File
@@ -230,3 +230,55 @@ export const useColorScheme = () => {
return colorScheme === 'dark' ? 'dark' : 'light'; return colorScheme === 'dark' ? 'dark' : 'light';
}; };
export const useAppThemeColors = () => {
const accent = useSettingsStore((store) => store.general.accent);
const getCurrentTheme = () => window.matchMedia('(prefers-color-scheme: dark)').matches;
const [isDarkTheme] = useState(getCurrentTheme());
const { followSystemTheme, theme, themeDark, themeLight } = useSettingsStore(
(state) => state.general,
);
const getSelectedTheme = () => {
if (followSystemTheme) {
return isDarkTheme ? themeDark : themeLight;
}
return theme;
};
const selectedTheme = getSelectedTheme();
const appTheme: AppThemeConfiguration = useMemo(() => {
const themeProperties = getAppTheme(selectedTheme);
return {
...themeProperties,
colors: {
...themeProperties.colors,
primary: accent,
},
};
}, [accent, selectedTheme]);
const themeVars = useMemo(() => {
return Object.entries(appTheme?.app ?? {})
.map(([key, value]) => {
return [`--theme-${key}`, value];
})
.filter(Boolean) as [string, string][];
}, [appTheme]);
const colorVars = useMemo(() => {
return Object.entries(appTheme?.colors ?? {})
.map(([key, value]) => {
return [`--theme-colors-${key}`, value];
})
.filter(Boolean) as [string, string][];
}, [appTheme]);
return {
color: Object.fromEntries(colorVars),
theme: Object.fromEntries(themeVars),
};
};