diff --git a/packages/renderer/src/components/card/album-card.tsx b/packages/renderer/src/components/card/album-card.tsx
new file mode 100644
index 000000000..a26065dae
--- /dev/null
+++ b/packages/renderer/src/components/card/album-card.tsx
@@ -0,0 +1,302 @@
+import React from 'react';
+import { Center } from '@mantine/core';
+import { RiAlbumFill } from 'react-icons/ri';
+import { generatePath, useNavigate } from 'react-router';
+import { Link } from 'react-router-dom';
+import { SimpleImg } from 'react-simple-img';
+import styled from 'styled-components';
+import { Text } from '/@/components/text';
+import type { LibraryItem, CardRow, CardRoute, Play } from '/@/types';
+import { Skeleton } from '/@/components/skeleton';
+import CardControls from '/@/features/shared/components/card-controls';
+
+const CardWrapper = styled.div<{
+ link?: boolean;
+}>`
+ padding: 1rem;
+ background: var(--card-default-bg);
+ border-radius: var(--card-default-radius);
+ cursor: ${({ link }) => link && 'pointer'};
+ transition: border 0.2s ease-in-out, background 0.2s ease-in-out;
+
+ &:hover {
+ background: var(--card-default-bg-hover);
+ }
+
+ &:hover div {
+ opacity: 1;
+ }
+
+ &:hover * {
+ &::before {
+ opacity: 0.5;
+ }
+ }
+
+ &:focus-visible {
+ outline: 1px solid #fff;
+ }
+`;
+
+const StyledCard = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ border-radius: var(--card-default-radius);
+`;
+
+const ImageSection = styled.div`
+ position: relative;
+ display: flex;
+ justify-content: center;
+ border-radius: var(--card-default-radius);
+
+ &::before {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
+ opacity: 0;
+ transition: all 0.2s ease-in-out;
+ content: '';
+ user-select: none;
+ }
+`;
+
+const Image = styled(SimpleImg)`
+ border-radius: var(--card-default-radius);
+ box-shadow: 2px 2px 10px 10px rgba(0, 0, 0, 20%);
+`;
+
+const ControlsContainer = styled.div`
+ position: absolute;
+ bottom: 0;
+ z-index: 50;
+ width: 100%;
+ opacity: 0;
+ transition: all 0.2s ease-in-out;
+`;
+
+const DetailSection = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const Row = styled.div<{ $secondary?: boolean }>`
+ width: 100%;
+ max-width: 100%;
+ height: 22px;
+ padding: 0 0.2rem;
+ overflow: hidden;
+ color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ user-select: none;
+`;
+
+interface BaseGridCardProps {
+ controls: {
+ cardRows: CardRow[];
+ itemType: LibraryItem;
+ playButtonBehavior: Play;
+ route: CardRoute;
+ };
+ data: any;
+ loading?: boolean;
+ size: number;
+}
+
+export const AlbumCard = ({ loading, size, data, controls }: BaseGridCardProps) => {
+ const navigate = useNavigate();
+ const { itemType, cardRows, route } = controls;
+
+ if (loading) {
+ return (
+
+
+
+
+
+
+ {cardRows.map((row: CardRow, index: number) => (
+ 0 ? '50%' : '90%') : '100%'}
+ >
+
+
+ ))}
+
+
+
+ );
+ }
+
+ return (
+
+ navigate(
+ generatePath(
+ route.route,
+ route.slugs?.reduce((acc, slug) => {
+ return {
+ ...acc,
+ [slug.slugProperty]: data[slug.idProperty],
+ };
+ }, {}),
+ ),
+ )
+ }
+ >
+
+
+ {data?.imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+ {cardRows.map((row: CardRow, index: number) => {
+ if (row.arrayProperty && row.route) {
+ return (
+ 0}
+ >
+ {data[row.property].map((item: any, itemIndex: number) => (
+
+ {itemIndex > 0 && (
+
+ ,
+
+ )}{' '}
+ 0}
+ component={Link}
+ overflow="hidden"
+ size={index > 0 ? 'xs' : 'md'}
+ to={generatePath(
+ row.route!.route,
+ row.route!.slugs?.reduce((acc, slug) => {
+ return {
+ ...acc,
+ [slug.slugProperty]: data[slug.idProperty],
+ };
+ }, {}),
+ )}
+ onClick={(e) => e.stopPropagation()}
+ >
+ {row.arrayProperty && item[row.arrayProperty]}
+
+
+ ))}
+
+ );
+ }
+
+ if (row.arrayProperty) {
+ return (
+
+ {data[row.property].map((item: any) => (
+ 0}
+ overflow="hidden"
+ size={index > 0 ? 'xs' : 'md'}
+ >
+ {row.arrayProperty && item[row.arrayProperty]}
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {row.route ? (
+ {
+ return {
+ ...acc,
+ [slug.slugProperty]: data[slug.idProperty],
+ };
+ }, {}),
+ )}
+ onClick={(e) => e.stopPropagation()}
+ >
+ {data && data[row.property]}
+
+ ) : (
+ 0}
+ overflow="hidden"
+ size={index > 0 ? 'xs' : 'md'}
+ >
+ {data && data[row.property]}
+
+ )}
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/packages/renderer/src/components/card/index.tsx b/packages/renderer/src/components/card/index.tsx
new file mode 100644
index 000000000..fe08e1c16
--- /dev/null
+++ b/packages/renderer/src/components/card/index.tsx
@@ -0,0 +1 @@
+export * from './album-card';
diff --git a/packages/renderer/src/components/grid-carousel/index.tsx b/packages/renderer/src/components/grid-carousel/index.tsx
new file mode 100644
index 000000000..12f3f4216
--- /dev/null
+++ b/packages/renderer/src/components/grid-carousel/index.tsx
@@ -0,0 +1,131 @@
+import { createContext, useContext } from 'react';
+import { Group, Stack } from '@mantine/core';
+import { motion } from 'framer-motion';
+import { RiArrowLeftSFill, RiArrowRightSFill } from 'react-icons/ri';
+import { AlbumCard, Button } from '/@/components';
+import { AppRoute } from '/@/router/routes';
+import type { CardRow } from '/@/types';
+import { LibraryItem, Play } from '/@/types';
+import styled from 'styled-components';
+
+interface GridCarouselProps {
+ cardRows: CardRow[];
+ children: React.ReactElement;
+ containerWidth: number;
+ data: any[] | undefined;
+ loading?: boolean;
+ pagination?: {
+ handleNextPage?: () => void;
+ handlePreviousPage?: () => void;
+ hasNextPage?: boolean;
+ hasPreviousPage?: boolean;
+ itemsPerPage?: number;
+ };
+}
+
+const GridCarouselContext = createContext(null);
+
+export const GridCarousel = ({
+ data,
+ loading,
+ cardRows,
+ pagination,
+ children,
+ containerWidth,
+}: GridCarouselProps) => {
+ const gridHeight = (containerWidth * 1.2 - 36) / (pagination?.itemsPerPage || 4);
+ const imageSize = gridHeight * 0.66;
+ const providerValue = { cardRows, data, gridHeight, imageSize, loading, pagination };
+
+ return (
+
+
+ {children}
+ {data && (
+
+ )}
+
+
+ );
+};
+
+const GridContainer = styled.div<{ height: number; itemsPerPage: number }>`
+ display: grid;
+ grid-auto-rows: 0;
+ grid-gap: 18px;
+ grid-template-rows: 1fr;
+ grid-template-columns: repeat(${(props) => props.itemsPerPage || 4}, minmax(0, 1fr));
+ height: ${(props) => props.height}px;
+`;
+
+const Carousel = ({ data, cardRows }: any) => {
+ const { loading, pagination, gridHeight, imageSize } = useContext(GridCarouselContext);
+
+ return (
+
+
+ {data?.map((item: any) => (
+
+ ))}
+
+
+ );
+};
+
+interface TitleProps {
+ children?: React.ReactNode;
+}
+
+const Title = ({ children }: TitleProps) => {
+ const { pagination } = useContext(GridCarouselContext);
+
+ return (
+
+ {children}
+
+
+
+
+
+ );
+};
+
+GridCarousel.Title = Title;
+GridCarousel.Carousel = Carousel;
diff --git a/packages/renderer/src/components/index.ts b/packages/renderer/src/components/index.ts
index f0757916a..14405745a 100644
--- a/packages/renderer/src/components/index.ts
+++ b/packages/renderer/src/components/index.ts
@@ -23,3 +23,5 @@ export * from './virtual-table';
export * from './skeleton';
export * from './page-header';
export * from './text-title';
+export * from './grid-carousel';
+export * from './card';
diff --git a/packages/renderer/src/components/page-header/index.tsx b/packages/renderer/src/components/page-header/index.tsx
index 1a540fb90..626547206 100644
--- a/packages/renderer/src/components/page-header/index.tsx
+++ b/packages/renderer/src/components/page-header/index.tsx
@@ -1,10 +1,18 @@
import { motion } from 'framer-motion';
+import { useEffect, useRef } from 'react';
import styled from 'styled-components';
import { useShouldPadTitlebar } from '/@/hooks';
-const Container = styled(motion.div)``;
+const Container = styled(motion.div)<{ $useOpacity?: boolean; height?: string }>`
+ z-index: 100;
+ width: 100%;
+ height: ${(props) => props.height || '55px'};
+ opacity: ${(props) => props.$useOpacity && 'var(--header-opacity)'};
+ transition: opacity 0.3s ease-in-out;
+`;
const Header = styled(motion.div)<{ $padRight?: boolean }>`
+ height: 100%;
margin-right: ${(props) => props.$padRight && '170px'};
padding: 1rem;
-webkit-app-region: drag;
@@ -16,21 +24,29 @@ const Header = styled(motion.div)<{ $padRight?: boolean }>`
interface PageHeaderProps {
backgroundColor?: string;
- children: React.ReactNode;
+ children?: React.ReactNode;
+ height?: string;
+ useOpacity?: boolean;
}
-export const PageHeader = ({ backgroundColor, children }: PageHeaderProps) => {
+export const PageHeader = ({ height, backgroundColor, useOpacity, children }: PageHeaderProps) => {
+ const ref = useRef(null);
const padRight = useShouldPadTitlebar();
- console.log('padRight :>> ', padRight);
+ useEffect(() => {
+ const rootElement = document.querySelector(':root') as HTMLElement;
+ rootElement?.style?.setProperty('--header-opacity', '0');
+ }, []);
return (
diff --git a/packages/renderer/src/features/dashboard/routes/DashboardRoute.tsx b/packages/renderer/src/features/dashboard/routes/DashboardRoute.tsx
deleted file mode 100644
index bb605fad3..000000000
--- a/packages/renderer/src/features/dashboard/routes/DashboardRoute.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-const DashboardRoute = () => {
- return <>>;
-};
-
-export default DashboardRoute;
diff --git a/packages/renderer/src/features/home/routes/home-route.tsx b/packages/renderer/src/features/home/routes/home-route.tsx
new file mode 100644
index 000000000..333445ef4
--- /dev/null
+++ b/packages/renderer/src/features/home/routes/home-route.tsx
@@ -0,0 +1,283 @@
+import { Box, Stack } from '@mantine/core';
+import { AlbumListSort, SortOrder } from '/@/api/types';
+import { GridCarousel, PageHeader, ScrollArea, TextTitle } from '/@/components';
+import { useAlbumList } from '/@/features/albums';
+import { useRecentlyPlayed } from '/@/features/home/queries/recently-played-query';
+import { AnimatedPage, useContainerQuery } from '/@/features/shared';
+import { AppRoute } from '/@/router/routes';
+import { useSetState } from '@mantine/hooks';
+import { throttle } from 'lodash';
+
+const HomeRoute = () => {
+ const rootElement = document.querySelector(':root') as HTMLElement;
+ const cq = useContainerQuery();
+
+ const itemsPerPage = cq.isXl ? 9 : cq.isLg ? 7 : cq.isMd ? 5 : cq.isSm ? 4 : 3;
+ const [pagination, setPagination] = useSetState({
+ mostPlayed: 0,
+ random: 0,
+ recentlyAdded: 0,
+ recentlyPlayed: 0,
+ });
+
+ const random = useAlbumList(
+ {
+ limit: itemsPerPage,
+ sortBy: AlbumListSort.RANDOM,
+ sortOrder: SortOrder.ASC,
+ startIndex: pagination.random * itemsPerPage,
+ },
+ {
+ keepPreviousData: true,
+ staleTime: 0,
+ },
+ );
+
+ const recentlyPlayed = useRecentlyPlayed(
+ {
+ limit: itemsPerPage,
+ sortBy: AlbumListSort.RECENTLY_PLAYED,
+ sortOrder: SortOrder.ASC,
+ startIndex: pagination.recentlyPlayed * itemsPerPage,
+ },
+ {
+ keepPreviousData: true,
+ staleTime: 0,
+ },
+ );
+
+ const recentlyAdded = useAlbumList(
+ {
+ limit: itemsPerPage,
+ sortBy: AlbumListSort.RECENTLY_ADDED,
+ sortOrder: SortOrder.ASC,
+ startIndex: pagination.recentlyAdded * itemsPerPage,
+ },
+ {
+ keepPreviousData: true,
+ staleTime: 0,
+ },
+ );
+
+ const mostPlayed = useAlbumList(
+ {
+ limit: itemsPerPage,
+ sortBy: AlbumListSort.PLAY_COUNT,
+ sortOrder: SortOrder.DESC,
+ startIndex: pagination.mostPlayed * itemsPerPage,
+ },
+ {
+ keepPreviousData: true,
+ staleTime: 0,
+ },
+ );
+
+ const handleNextPage = (key: 'mostPlayed' | 'random' | 'recentlyAdded' | 'recentlyPlayed') => {
+ setPagination({
+ [key]: pagination[key as keyof typeof pagination] + 1,
+ });
+ };
+
+ const handlePreviousPage = (
+ key: 'mostPlayed' | 'random' | 'recentlyAdded' | 'recentlyPlayed',
+ ) => {
+ setPagination({
+ [key]: pagination[key as keyof typeof pagination] - 1,
+ });
+ };
+
+ const handleScroll = (position: { x: number; y: number }) => {
+ if (position.y <= 15) {
+ return rootElement?.style?.setProperty('--header-opacity', '0');
+ }
+
+ return rootElement?.style?.setProperty('--header-opacity', '1');
+ };
+
+ const throttledScroll = throttle(handleScroll, 200);
+
+ return (
+ <>
+
+
+
+
+
+
+ handleNextPage('random'),
+ handlePreviousPage: () => handlePreviousPage('random'),
+ hasPreviousPage: pagination.random > 0,
+ itemsPerPage,
+ }}
+ >
+
+
+ Explore from your library
+
+
+
+ handleNextPage('recentlyPlayed'),
+ handlePreviousPage: () => handlePreviousPage('recentlyPlayed'),
+ hasPreviousPage: pagination.recentlyPlayed > 0,
+ itemsPerPage,
+ }}
+ >
+
+
+ Recently played
+
+
+
+ handleNextPage('recentlyAdded'),
+ handlePreviousPage: () => handlePreviousPage('recentlyAdded'),
+ hasPreviousPage: pagination.recentlyAdded > 0,
+ itemsPerPage,
+ }}
+ >
+
+
+ Newly added releases
+
+
+
+ handleNextPage('mostPlayed'),
+ handlePreviousPage: () => handlePreviousPage('mostPlayed'),
+ hasPreviousPage: pagination.mostPlayed > 0,
+ itemsPerPage,
+ }}
+ >
+
+
+ Most played
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default HomeRoute;
diff --git a/packages/renderer/src/router/app-router.tsx b/packages/renderer/src/router/app-router.tsx
index 6c7a149c5..c5bb507d9 100644
--- a/packages/renderer/src/router/app-router.tsx
+++ b/packages/renderer/src/router/app-router.tsx
@@ -11,7 +11,7 @@ import { AppRoute } from './routes';
import { RouteErrorBoundary } from '/@/features/action-required';
import { TitlebarOutlet } from '/@/router/titlebar-outlet';
-const DashboardRoute = lazy(() => import('/@/features/dashboard/routes/DashboardRoute'));
+const HomeRoute = lazy(() => import('/@/features/home/routes/home-route'));
const NowPlayingRoute = lazy(() => import('/@/features/now-playing/routes/now-playing-route'));
@@ -35,10 +35,10 @@ export const AppRouter = () => {
}>
}
+ element={}
/>
}
+ element={}
path={AppRoute.HOME}
/>