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';