diff --git a/src/renderer/components/feature-carousel/feature-carousel.module.css b/src/renderer/components/feature-carousel/feature-carousel.module.css index 75c673397..3ec1825ef 100644 --- a/src/renderer/components/feature-carousel/feature-carousel.module.css +++ b/src/renderer/components/feature-carousel/feature-carousel.module.css @@ -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 { position: relative; - height: 35vh; - min-height: 250px; - max-height: 300px; - padding: var(--theme-spacing-md); - overflow: hidden; -} - -.grid { display: grid; - grid-template-areas: 'image info'; - grid-template-rows: 1fr; - grid-template-columns: 200px minmax(0, 1fr); - grid-auto-columns: 1fr; + grid-template-columns: repeat(var(--items-per-row, 1), 1fr); + gap: var(--theme-spacing-md); width: 100%; - max-width: 100%; - height: 100%; + min-height: 400px; + padding: var(--theme-spacing-xl); + overflow: hidden; + + --items-per-row: 1; } -.image-column { - 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 { +.carousel-item { position: relative; width: 100%; - height: 100%; + min-height: 400px; overflow: hidden; + border-radius: var(--theme-radius-md); + isolation: isolate; } -.title-wrapper { - display: -webkit-box; - overflow: hidden; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; +.carousel-item :global(.overlay) { + border-radius: var(--theme-radius-md); +} + +.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; + } } diff --git a/src/renderer/components/feature-carousel/feature-carousel.tsx b/src/renderer/components/feature-carousel/feature-carousel.tsx index 3152921d7..cd960056d 100644 --- a/src/renderer/components/feature-carousel/feature-carousel.tsx +++ b/src/renderer/components/feature-carousel/feature-carousel.tsx @@ -1,39 +1,41 @@ -import type { Variants } from 'motion/react'; import type { MouseEvent } from 'react'; import { AnimatePresence, motion } from 'motion/react'; -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; +import { useMemo, useState } from 'react'; import { generatePath, Link } from 'react-router'; import styles from './feature-carousel.module.css'; -import { usePlayer } from '/@/renderer/features/player/context/player-context'; -import { PlayButton } from '/@/renderer/features/shared/components/play-button'; +import { ItemCard } from '/@/renderer/components/item-card/item-card'; +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 { useCurrentServer, usePlayButtonBehavior } from '/@/renderer/store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { Badge } from '/@/shared/components/badge/badge'; -import { Button } from '/@/shared/components/button/button'; 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 { 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 variants: Variants = { - animate: { +const fadeVariants = { + center: { opacity: 1, - transition: { opacity: { duration: 0.5 } }, + transition: { + duration: 0.4, + ease: 'easeInOut' as const, + }, + }, + enter: { + opacity: 0, }, exit: { opacity: 0, - transition: { opacity: { duration: 0.5 } }, - }, - initial: { - opacity: 0, + transition: { + duration: 0.4, + ease: 'easeInOut' as const, + }, }, }; @@ -41,146 +43,191 @@ interface FeatureCarouselProps { data: Album[] | undefined; } -export const FeatureCarousel = ({ data }: FeatureCarouselProps) => { - const { t } = useTranslation(); - const { addToQueueByFetch } = usePlayer(); - const server = useCurrentServer(); - const [itemIndex, setItemIndex] = useState(0); - const [direction, setDirection] = useState(0); - const playType = usePlayButtonBehavior(); +const getItemsPerRow = (breakpoints: { + is2xl: boolean; + isLg: boolean; + isMd: boolean; + isSm: boolean; + isXl: boolean; +}) => { + 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) => { - e.preventDefault(); - setDirection(1); - if (itemIndex === (data?.length || 0) - 1 || 0) { - setItemIndex(0); - return; - } +const CarouselItem = ({ album }: CarouselItemProps) => { + const { background: backgroundColor } = useFastAverageColor({ + algorithm: 'dominant', + src: album.imageUrl || null, + srcLoaded: true, + }); - setItemIndex((prev) => prev + 1); - }; - - const handlePrevious = (e: MouseEvent) => { - e.preventDefault(); - setDirection(-1); - if (itemIndex === 0) { - setItemIndex((data?.length || 0) - 1); - return; - } - - setItemIndex((prev) => prev - 1); - }; + const controls = useDefaultItemListControls(); return ( - - - {data && ( - -
-
- -
-
- -
- - {currentItem?.name} - -
-
- {currentItem?.albumArtists.slice(0, 1).map((artist) => ( - - {artist.name} - - ))} -
- - {currentItem?.genres?.slice(0, 1).map((genre) => ( - - {genre.name} - - ))} - {currentItem?.releaseYear} - - - { - e.preventDefault(); - e.stopPropagation(); - if (!currentItem || !server?.id) return; +
+ + +
+
+ + {album.name} + +
- addToQueueByFetch( - server.id, - [currentItem.id], - LibraryItem.ALBUM, - playType, - ); - }} - variant="outline" - > - {t( - playType === Play.NOW - ? 'player.play' - : playType === Play.NEXT - ? 'player.addNext' - : 'player.addLast', - { postProcess: 'titleCase' }, - )} - - - - - - - -
-
- { + e.preventDefault(); + e.stopPropagation(); + }} + > + -
- - )} - - +
+ +
+ + {album.albumArtists.slice(0, 1).map((artist) => ( + { + e.stopPropagation(); + }} + state={{ item: artist }} + to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { + albumArtistId: artist.id, + })} + > + + {artist.name} + + + ))} + + {album.genres?.slice(0, 2).map((genre) => ( + + {genre.name} + + ))} + {album.releaseYear && {album.releaseYear}} + + +
+
+ +
+ ); +}; + +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) => { + e?.preventDefault(); + e?.stopPropagation(); + if (!data) return; + setStartIndex((prev) => (prev + itemsPerRow) % data.length); + }; + + const handlePrevious = (e?: MouseEvent) => { + e?.preventDefault(); + e?.stopPropagation(); + if (!data) return; + setStartIndex((prev) => (prev - itemsPerRow + data.length) % data.length); + }; + + if (!data || data.length === 0) { + return null; + } + + return ( +
+ + + {visibleItems.map((album) => ( + + ))} + + + + {data.length > itemsPerRow && ( + <> + + + + )} +
); }; diff --git a/src/renderer/features/home/routes/home-route.tsx b/src/renderer/features/home/routes/home-route.tsx index 19823306c..9601b0542 100644 --- a/src/renderer/features/home/routes/home-route.tsx +++ b/src/renderer/features/home/routes/home-route.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { Suspense, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; @@ -28,12 +28,12 @@ const HomeRoute = () => { const isJellyfin = server?.type === ServerType.JELLYFIN; - const feature = useQuery( - albumQueries.list({ + const feature = useSuspenseQuery({ + ...albumQueries.list({ options: { enabled: homeFeature, - gcTime: 1000 * 60, - staleTime: 1000 * 60, + gcTime: 1000 * 30, + staleTime: 1000 * 30, }, query: { limit: 20, @@ -43,7 +43,8 @@ const HomeRoute = () => { }, serverId: server?.id, }), - ); + queryKey: ['home', 'feature'], + }); const featureItemsWithImage = useMemo(() => { return feature.data?.items?.filter((item) => item.imageUrl) ?? []; @@ -124,17 +125,13 @@ const HomeRoute = () => { {sortedCarousel.map((carousel) => { if (carousel.itemType === LibraryItem.ALBUM) { return ( - } + - - + rowCount={1} + sortBy={carousel.sortBy} + sortOrder={carousel.sortOrder} + title={carousel.title} + /> ); } @@ -151,4 +148,12 @@ const HomeRoute = () => { ); }; -export default HomeRoute; +const SuspensedHomeRoute = () => { + return ( + }> + + + ); +}; + +export default SuspensedHomeRoute; diff --git a/src/renderer/features/shared/components/library-background-overlay.module.css b/src/renderer/features/shared/components/library-background-overlay.module.css index 76488a966..2c2a1052d 100644 --- a/src/renderer/features/shared/components/library-background-overlay.module.css +++ b/src/renderer/features/shared/components/library-background-overlay.module.css @@ -6,7 +6,28 @@ pointer-events: none; user-select: none; 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 { diff --git a/src/renderer/features/shared/components/library-background-overlay.tsx b/src/renderer/features/shared/components/library-background-overlay.tsx index 804e9f880..a820a0f69 100644 --- a/src/renderer/features/shared/components/library-background-overlay.tsx +++ b/src/renderer/features/shared/components/library-background-overlay.tsx @@ -1,28 +1,71 @@ +import { generateColors } from '@mantine/colors-generator'; +import clsx from 'clsx'; import { useEffect, useState } from 'react'; import styles from './library-background-overlay.module.css'; +import { useAppThemeColors } from '/@/renderer/themes/use-app-theme'; + interface LibraryBackgroundOverlayProps { backgroundColor?: string; headerRef: React.RefObject; + opacity?: number; } export const LibraryBackgroundOverlay = ({ backgroundColor, headerRef, + opacity = 0.7, }: LibraryBackgroundOverlayProps) => { const height = useHeaderHeight(headerRef); + return (
); }; +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 ( +
+ ); +}; + interface LibraryBackgroundProps { blur?: number; headerRef: React.RefObject; diff --git a/src/renderer/themes/use-app-theme.ts b/src/renderer/themes/use-app-theme.ts index 46455569e..936cdfd00 100644 --- a/src/renderer/themes/use-app-theme.ts +++ b/src/renderer/themes/use-app-theme.ts @@ -230,3 +230,55 @@ export const useColorScheme = () => { 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), + }; +};