diff --git a/src/renderer/components/feature-carousel/feature-carousel.module.css b/src/renderer/components/feature-carousel/feature-carousel.module.css
index b5986508d..5ad5de53f 100644
--- a/src/renderer/components/feature-carousel/feature-carousel.module.css
+++ b/src/renderer/components/feature-carousel/feature-carousel.module.css
@@ -27,6 +27,20 @@
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) {
border-radius: var(--theme-radius-md);
}
@@ -53,6 +67,13 @@
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 {
display: flex;
flex-shrink: 0;
@@ -77,6 +98,15 @@
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 {
position: absolute;
top: 50%;
@@ -106,6 +136,23 @@
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 {
display: block;
transition: transform 0.3s ease;
@@ -129,6 +176,11 @@
transition: filter 0.3s ease;
}
+.single-carousel-container .album-image-container {
+ width: 200px;
+ max-width: 200px;
+}
+
.album-image-container::before {
position: absolute;
top: 0;
@@ -149,7 +201,7 @@
.album-image {
width: 100%;
- height: auto;
+ height: 100%;
object-fit: cover;
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%));
}
+/* 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 {
display: inline-block;
color: inherit;
@@ -218,6 +276,20 @@
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) {
.carousel-item {
min-height: 300px;
diff --git a/src/renderer/components/feature-carousel/single-feature-carousel.tsx b/src/renderer/components/feature-carousel/single-feature-carousel.tsx
new file mode 100644
index 000000000..cf45596ff
--- /dev/null
+++ b/src/renderer/components/feature-carousel/single-feature-carousel.tsx
@@ -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 (
+
+ {imageUrl && (
+
+ )}
+
+
+
+
+
+
+
+
+ {album.name}
+
+ {album.albumArtistName && (
+
+ {album.albumArtistName}
+
+ )}
+
+ {metadataItems.map((item, index) => (
+
+ {item}
+ {index < metadataItems.length - 1 && }
+
+ ))}
+
+
+
+
+
+
+ );
+};
+
+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) => {
+ 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) => {
+ 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) => {
+ 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 (
+ setIsPaused(true)}
+ // onMouseLeave={() => setIsPaused(false)}
+ onWheel={handleWheel}
+ ref={containerRef}
+ >
+
+
+
+
+
+
+
+
+ {data.length > 1 && (
+ <>
+
+
+ >
+ )}
+
+ );
+};
diff --git a/src/renderer/features/home/components/album-infinite-single-feature-carousel.tsx b/src/renderer/features/home/components/album-infinite-single-feature-carousel.tsx
new file mode 100644
index 000000000..67105fb57
--- /dev/null
+++ b/src/renderer/features/home/components/album-infinite-single-feature-carousel.tsx
@@ -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({
+ 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();
+ 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 ;
+};
diff --git a/src/renderer/features/home/routes/home-route.tsx b/src/renderer/features/home/routes/home-route.tsx
index cca5d7372..417435c47 100644
--- a/src/renderer/features/home/routes/home-route.tsx
+++ b/src/renderer/features/home/routes/home-route.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-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 { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
@@ -113,7 +114,7 @@ const HomeRoute = () => {
pt={windowBarStyle === Platform.WEB ? '5rem' : '3rem'}
px="2rem"
>
- {homeFeature && }
+ {homeFeature && }
{sortedItems.map((item) => {
if (item.id === HomeItem.GENRES) {
return ;
diff --git a/src/renderer/features/shared/components/library-header.tsx b/src/renderer/features/shared/components/library-header.tsx
index 7d90b2d3d..f3d60b967 100644
--- a/src/renderer/features/shared/components/library-header.tsx
+++ b/src/renderer/features/shared/components/library-header.tsx
@@ -180,7 +180,7 @@ export const LibraryHeader = forwardRef(
},
);
-const isAsianCharacter = (char: string): boolean => {
+export const isAsianCharacter = (char: string): boolean => {
const codePoint = char.codePointAt(0);
if (!codePoint) return false;
@@ -207,7 +207,7 @@ const isAsianCharacter = (char: string): boolean => {
return false;
};
-const calculateWeightedLength = (str: string): number => {
+export const calculateWeightedLength = (str: string): number => {
let length = 0;
for (const char of str) {
length += isAsianCharacter(char) ? 2.5 : 1;
@@ -215,7 +215,7 @@ const calculateWeightedLength = (str: string): number => {
return length;
};
-const calculateTitleSize = (title: string) => {
+export const calculateTitleSize = (title: string) => {
const titleLength = calculateWeightedLength(title);
let baseSize = '3dvw';