mirror of
https://github.com/antebudimir/feishin.git
synced 2026-03-02 12:17:25 +00:00
Add files
This commit is contained in:
commit
e87c814068
266 changed files with 63938 additions and 0 deletions
|
|
@ -0,0 +1,26 @@
|
|||
import { Stack, Group } from '@mantine/core';
|
||||
import { RiAlertFill } from 'react-icons/ri';
|
||||
import { Text } from '/@/renderer/components';
|
||||
|
||||
interface ActionRequiredContainerProps {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const ActionRequiredContainer = ({ title, children }: ActionRequiredContainerProps) => (
|
||||
<Stack sx={{ cursor: 'default', maxWidth: '700px' }}>
|
||||
<Group>
|
||||
<RiAlertFill
|
||||
color="var(--warning-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text
|
||||
size="xl"
|
||||
sx={{ textTransform: 'uppercase' }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack>{children}</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { Box, Center, Divider, Group, Stack } from '@mantine/core';
|
||||
import type { FallbackProps } from 'react-error-boundary';
|
||||
import { RiErrorWarningLine, RiArrowLeftLine } from 'react-icons/ri';
|
||||
import { useNavigate, useRouteError } from 'react-router';
|
||||
import styled from 'styled-components';
|
||||
import { Button, Text } from '/@/renderer/components';
|
||||
|
||||
const Container = styled(Box)`
|
||||
background: var(--main-bg);
|
||||
`;
|
||||
|
||||
export const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
|
||||
return (
|
||||
<Container>
|
||||
<Center sx={{ height: '100vh' }}>
|
||||
<Stack sx={{ maxWidth: '50%' }}>
|
||||
<Group spacing="xs">
|
||||
<RiErrorWarningLine
|
||||
color="var(--danger-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text size="lg">Something went wrong</Text>
|
||||
</Group>
|
||||
<Text>{error.message}</Text>
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={resetErrorBoundary}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export const RouteErrorBoundary = () => {
|
||||
const navigate = useNavigate();
|
||||
const error = useRouteError() as any;
|
||||
console.log('error', error);
|
||||
|
||||
const handleReload = () => {
|
||||
navigate(0);
|
||||
};
|
||||
|
||||
const handleReturn = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Center sx={{ height: '100vh' }}>
|
||||
<Stack sx={{ maxWidth: '50%' }}>
|
||||
<Group>
|
||||
<RiErrorWarningLine
|
||||
color="var(--danger-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text size="lg">Something went wrong</Text>
|
||||
</Group>
|
||||
<Divider my={5} />
|
||||
<Text size="sm">{error?.message}</Text>
|
||||
<Group grow>
|
||||
<Button
|
||||
leftIcon={<RiArrowLeftLine />}
|
||||
sx={{ flex: 0.5 }}
|
||||
variant="default"
|
||||
onClick={handleReturn}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={handleReload}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import isElectron from 'is-electron';
|
||||
import { FileInput, Text, Button } from '/@/renderer/components';
|
||||
|
||||
const localSettings = window.electron.localSettings;
|
||||
|
||||
export const MpvRequired = () => {
|
||||
const [mpvPath, setMpvPath] = useState('');
|
||||
const handleSetMpvPath = (e: File) => {
|
||||
localSettings.set('mpv_path', e.path);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const getMpvPath = async () => {
|
||||
if (!isElectron()) return setMpvPath('');
|
||||
const mpvPath = localSettings.get('mpv_path') as string;
|
||||
return setMpvPath(mpvPath);
|
||||
};
|
||||
|
||||
getMpvPath();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text size="lg">Set your MPV executable location below and restart the application.</Text>
|
||||
<Text>
|
||||
MPV is available at the following:{' '}
|
||||
<a
|
||||
href="https://mpv.io/installation/"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
https://mpv.io/
|
||||
</a>
|
||||
</Text>
|
||||
<FileInput
|
||||
placeholder={mpvPath}
|
||||
onChange={handleSetMpvPath}
|
||||
/>
|
||||
<Button onClick={() => localSettings.restart()}>Restart</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { Text } from '/@/renderer/components';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
export const ServerCredentialRequired = () => {
|
||||
const currentServer = useCurrentServer();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text size="lg">
|
||||
The selected server '{currentServer?.name}' requires an additional login to
|
||||
access.
|
||||
</Text>
|
||||
<Text size="lg">
|
||||
Add your credentials in the 'manage servers' menu or switch to a different server.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Text } from '/@/renderer/components';
|
||||
|
||||
export const ServerRequired = () => {
|
||||
return (
|
||||
<>
|
||||
<Text size="xl">No server selected.</Text>
|
||||
<Text>Add or select a server in the file menu.</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
src/renderer/features/action-required/index.ts
Normal file
1
src/renderer/features/action-required/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './components/error-fallback';
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Center, Group, Stack } from '@mantine/core';
|
||||
import isElectron from 'is-electron';
|
||||
import { RiCheckFill } from 'react-icons/ri';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Text } from '/@/renderer/components';
|
||||
import { ActionRequiredContainer } from '/@/renderer/features/action-required/components/action-required-container';
|
||||
import { MpvRequired } from '/@/renderer/features/action-required/components/mpv-required';
|
||||
import { ServerCredentialRequired } from '/@/renderer/features/action-required/components/server-credential-required';
|
||||
import { ServerRequired } from '/@/renderer/features/action-required/components/server-required';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
const localSettings = window.electron.localSettings;
|
||||
|
||||
const ActionRequiredRoute = () => {
|
||||
const currentServer = useCurrentServer();
|
||||
const [isMpvRequired, setIsMpvRequired] = useState(false);
|
||||
const isServerRequired = !currentServer;
|
||||
// const isCredentialRequired = currentServer?.noCredential && !serverToken;
|
||||
const isCredentialRequired = false;
|
||||
|
||||
useEffect(() => {
|
||||
const getMpvPath = async () => {
|
||||
if (!isElectron()) return setIsMpvRequired(false);
|
||||
const mpvPath = await localSettings.get('mpv_path');
|
||||
return setIsMpvRequired(!mpvPath);
|
||||
};
|
||||
|
||||
getMpvPath();
|
||||
}, []);
|
||||
|
||||
const checks = [
|
||||
{
|
||||
component: <MpvRequired />,
|
||||
title: 'MPV required',
|
||||
valid: !isMpvRequired,
|
||||
},
|
||||
{
|
||||
component: <ServerCredentialRequired />,
|
||||
title: 'Credentials required',
|
||||
valid: !isCredentialRequired,
|
||||
},
|
||||
{
|
||||
component: <ServerRequired />,
|
||||
title: 'Server required',
|
||||
valid: !isServerRequired,
|
||||
},
|
||||
];
|
||||
|
||||
const canReturnHome = checks.every((c) => c.valid);
|
||||
const displayedCheck = checks.find((c) => !c.valid);
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<Center sx={{ height: '100%', width: '100vw' }}>
|
||||
<Stack
|
||||
spacing="xl"
|
||||
sx={{ maxWidth: '50%' }}
|
||||
>
|
||||
<Group noWrap>
|
||||
{displayedCheck && (
|
||||
<ActionRequiredContainer title={displayedCheck.title}>
|
||||
{displayedCheck?.component}
|
||||
</ActionRequiredContainer>
|
||||
)}
|
||||
</Group>
|
||||
<Stack mt="2rem">
|
||||
{canReturnHome && (
|
||||
<>
|
||||
<Group
|
||||
noWrap
|
||||
position="center"
|
||||
>
|
||||
<RiCheckFill
|
||||
color="var(--success-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text size="xl">No issues found</Text>
|
||||
</Group>
|
||||
<Button
|
||||
component={Link}
|
||||
disabled={!canReturnHome}
|
||||
to={AppRoute.HOME}
|
||||
variant="filled"
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Center>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionRequiredRoute;
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { Center, Group, Stack } from '@mantine/core';
|
||||
import { RiQuestionLine } from 'react-icons/ri';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Button, Text } from '/@/renderer/components';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
|
||||
const InvalidRoute = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<Center sx={{ height: '100%', width: '100%' }}>
|
||||
<Stack>
|
||||
<Group
|
||||
noWrap
|
||||
position="center"
|
||||
>
|
||||
<RiQuestionLine
|
||||
color="var(--warning-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text size="xl">Page not found</Text>
|
||||
</Group>
|
||||
<Text>{location.pathname}</Text>
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvalidRoute;
|
||||
256
src/renderer/features/albums/components/album-list-header.tsx
Normal file
256
src/renderer/features/albums/components/album-list-header.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import type { MouseEvent } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { Group, Slider } from '@mantine/core';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { RiArrowDownSLine } from 'react-icons/ri';
|
||||
import { AlbumListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import { Button, DropdownMenu, PageHeader } from '/@/renderer/components';
|
||||
import { useCurrentServer, useAppStoreActions, useAlbumRouteStore } from '/@/renderer/store';
|
||||
import { CardDisplayType } from '/@/renderer/types';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
{ name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
|
||||
{ name: 'Community Rating', value: AlbumListSort.COMMUNITY_RATING },
|
||||
{ name: 'Critic Rating', value: AlbumListSort.CRITIC_RATING },
|
||||
{ name: 'Name', value: AlbumListSort.NAME },
|
||||
{ name: 'Random', value: AlbumListSort.RANDOM },
|
||||
{ name: 'Recently Added', value: AlbumListSort.RECENTLY_ADDED },
|
||||
{ name: 'Release Date', value: AlbumListSort.RELEASE_DATE },
|
||||
],
|
||||
navidrome: [
|
||||
{ name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
|
||||
{ name: 'Artist', value: AlbumListSort.ARTIST },
|
||||
{ name: 'Duration', value: AlbumListSort.DURATION },
|
||||
{ name: 'Name', value: AlbumListSort.NAME },
|
||||
{ name: 'Play Count', value: AlbumListSort.PLAY_COUNT },
|
||||
{ name: 'Random', value: AlbumListSort.RANDOM },
|
||||
{ name: 'Rating', value: AlbumListSort.RATING },
|
||||
{ name: 'Recently Added', value: AlbumListSort.RECENTLY_ADDED },
|
||||
{ name: 'Song Count', value: AlbumListSort.SONG_COUNT },
|
||||
{ name: 'Favorited', value: AlbumListSort.FAVORITED },
|
||||
{ name: 'Year', value: AlbumListSort.YEAR },
|
||||
],
|
||||
};
|
||||
|
||||
const ORDER = [
|
||||
{ name: 'Ascending', value: SortOrder.ASC },
|
||||
{ name: 'Descending', value: SortOrder.DESC },
|
||||
];
|
||||
|
||||
export const AlbumListHeader = () => {
|
||||
const server = useCurrentServer();
|
||||
const { setPage } = useAppStoreActions();
|
||||
const page = useAlbumRouteStore();
|
||||
const filters = page.list.filter;
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
(FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[]).find(
|
||||
(f) => f.value === filters.sortBy,
|
||||
)?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const sortOrderLabel = ORDER.find((s) => s.value === filters.sortOrder)?.name;
|
||||
|
||||
const setSize = throttle(
|
||||
(e: number) =>
|
||||
setPage('albums', {
|
||||
...page,
|
||||
list: { ...page.list, size: e },
|
||||
}),
|
||||
200,
|
||||
);
|
||||
|
||||
const handleSetFilter = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
setPage('albums', {
|
||||
list: {
|
||||
...page.list,
|
||||
filter: {
|
||||
...page.list.filter,
|
||||
sortBy: e.currentTarget.value as AlbumListSort,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[page.list, setPage],
|
||||
);
|
||||
|
||||
const handleSetOrder = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
setPage('albums', {
|
||||
list: {
|
||||
...page.list,
|
||||
filter: {
|
||||
...page.list.filter,
|
||||
sortOrder: e.currentTarget.value as SortOrder,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[page.list, setPage],
|
||||
);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
const type = e.currentTarget.value;
|
||||
if (type === CardDisplayType.CARD) {
|
||||
setPage('albums', {
|
||||
...page,
|
||||
list: {
|
||||
...page.list,
|
||||
display: CardDisplayType.CARD,
|
||||
type: 'grid',
|
||||
},
|
||||
});
|
||||
} else if (type === CardDisplayType.POSTER) {
|
||||
setPage('albums', {
|
||||
...page,
|
||||
list: {
|
||||
...page.list,
|
||||
display: CardDisplayType.POSTER,
|
||||
type: 'grid',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setPage('albums', {
|
||||
...page,
|
||||
list: {
|
||||
...page.list,
|
||||
type: 'list',
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[page, setPage],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageHeader>
|
||||
<Group>
|
||||
<DropdownMenu
|
||||
position="bottom-end"
|
||||
width={100}
|
||||
>
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
rightIcon={<RiArrowDownSLine size={15} />}
|
||||
size="xl"
|
||||
sx={{ paddingLeft: 0, paddingRight: 0 }}
|
||||
variant="subtle"
|
||||
>
|
||||
Albums
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item>
|
||||
<Slider
|
||||
defaultValue={page.list?.size || 0}
|
||||
label={null}
|
||||
onChange={setSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.list.type === 'grid' && page.list.display === CardDisplayType.CARD}
|
||||
value={CardDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Card
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.list.type === 'grid' && page.list.display === CardDisplayType.POSTER}
|
||||
value={CardDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Poster
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled
|
||||
$isActive={page.list.type === 'list'}
|
||||
value="list"
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
List
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="normal"
|
||||
variant="subtle"
|
||||
>
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
|
||||
<DropdownMenu.Item
|
||||
key={`filter-${filter.name}`}
|
||||
$isActive={filter.value === filters.sortBy}
|
||||
value={filter.value}
|
||||
onClick={handleSetFilter}
|
||||
>
|
||||
{filter.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="normal"
|
||||
variant="subtle"
|
||||
>
|
||||
{sortOrderLabel}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{ORDER.map((sort) => (
|
||||
<DropdownMenu.Item
|
||||
key={`sort-${sort.value}`}
|
||||
$isActive={sort.value === filters.sortOrder}
|
||||
value={sort.value}
|
||||
onClick={handleSetOrder}
|
||||
>
|
||||
{sort.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="normal"
|
||||
variant="subtle"
|
||||
>
|
||||
Folder
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
{/* <DropdownMenu.Dropdown>
|
||||
{serverFolders?.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
key={folder.id}
|
||||
$isActive={filters.serverFolderId.includes(folder.id)}
|
||||
closeMenuOnClick={false}
|
||||
value={folder.id}
|
||||
onClick={handleSetServerFolder}
|
||||
>
|
||||
{folder.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown> */}
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
</PageHeader>
|
||||
);
|
||||
};
|
||||
2
src/renderer/features/albums/index.ts
Normal file
2
src/renderer/features/albums/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './queries/album-detail-query';
|
||||
export * from './queries/album-list-query';
|
||||
16
src/renderer/features/albums/queries/album-detail-query.ts
Normal file
16
src/renderer/features/albums/queries/album-detail-query.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '../../../store/auth.store';
|
||||
import type { AlbumDetailQuery } from '/@/renderer/api/types';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
|
||||
export const useAlbumDetail = (query: AlbumDetailQuery, options: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) => controller.getAlbumDetail({ query, server, signal }),
|
||||
queryKey: queryKeys.albums.detail(server?.id || '', query),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
40
src/renderer/features/albums/queries/album-list-query.ts
Normal file
40
src/renderer/features/albums/queries/album-list-query.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { AlbumListQuery, RawAlbumListResponse } from '/@/renderer/api/types';
|
||||
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
|
||||
export const useAlbumList = (query: AlbumListQuery, options?: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
|
||||
return useQuery({
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => controller.getAlbumList({ query, server, signal }),
|
||||
queryKey: queryKeys.albums.list(server?.id || '', query),
|
||||
select: useCallback(
|
||||
(data: RawAlbumListResponse | undefined) => api.normalize.albumList(data, server),
|
||||
[server],
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// export const useAlbumListInfinite = (params: AlbumListParams, options?: QueryOptions) => {
|
||||
// const serverId = useAuthStore((state) => state.currentServer?.id) || '';
|
||||
|
||||
// return useInfiniteQuery({
|
||||
// enabled: !!serverId,
|
||||
// getNextPageParam: (lastPage: AlbumListResponse) => {
|
||||
// return !!lastPage.pagination.nextPage;
|
||||
// },
|
||||
// getPreviousPageParam: (firstPage: AlbumListResponse) => {
|
||||
// return !!firstPage.pagination.prevPage;
|
||||
// },
|
||||
// queryFn: ({ pageParam }) => api.albums.getAlbumList({ serverId }, { ...(pageParam || params) }),
|
||||
// queryKey: queryKeys.albums.list(serverId, params),
|
||||
// ...options,
|
||||
// });
|
||||
// };
|
||||
127
src/renderer/features/albums/routes/album-list-route.tsx
Normal file
127
src/renderer/features/albums/routes/album-list-route.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/* eslint-disable no-plusplus */
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import type { ListOnScrollProps } from 'react-window';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualGridContainer,
|
||||
VirtualInfiniteGrid,
|
||||
} from '/@/renderer/components';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useAlbumRouteStore, useAppStoreActions, useCurrentServer } from '/@/renderer/store';
|
||||
import { LibraryItem, CardDisplayType } from '/@/renderer/types';
|
||||
import { useAlbumList } from '../queries/album-list-query';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header';
|
||||
import { api } from '/@/renderer/api';
|
||||
|
||||
const AlbumListRoute = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const { setPage } = useAppStoreActions();
|
||||
const page = useAlbumRouteStore();
|
||||
const filters = page.list.filter;
|
||||
|
||||
const albumListQuery = useAlbumList({
|
||||
limit: 1,
|
||||
sortBy: filters.sortBy,
|
||||
sortOrder: filters.sortOrder,
|
||||
startIndex: 0,
|
||||
});
|
||||
|
||||
const fetch = useCallback(
|
||||
async ({ skip, take }: { skip: number; take: number }) => {
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', {
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filters,
|
||||
});
|
||||
|
||||
const albums = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
controller.getAlbumList({
|
||||
query: {
|
||||
limit: take,
|
||||
sortBy: filters.sortBy,
|
||||
sortOrder: filters.sortOrder,
|
||||
startIndex: skip,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
|
||||
return api.normalize.albumList(albums, server);
|
||||
},
|
||||
[filters, queryClient, server],
|
||||
);
|
||||
|
||||
const handleGridScroll = useCallback(
|
||||
(e: ListOnScrollProps) => {
|
||||
setPage('albums', {
|
||||
...page,
|
||||
list: {
|
||||
...page.list,
|
||||
gridScrollOffset: e.scrollOffset,
|
||||
},
|
||||
});
|
||||
},
|
||||
[page, setPage],
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<VirtualGridContainer>
|
||||
<AlbumListHeader />
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<VirtualInfiniteGrid
|
||||
cardRows={[
|
||||
{
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
arrayProperty: 'name',
|
||||
property: 'albumArtists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'releaseYear',
|
||||
},
|
||||
]}
|
||||
display={page.list?.display || CardDisplayType.CARD}
|
||||
fetchFn={fetch}
|
||||
height={height}
|
||||
initialScrollOffset={page.list?.gridScrollOffset || 0}
|
||||
itemCount={albumListQuery?.data?.totalRecordCount || 0}
|
||||
itemGap={20}
|
||||
itemSize={150 + page.list?.size}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
minimumBatchSize={40}
|
||||
// refresh={advancedFilters}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
}}
|
||||
width={width}
|
||||
onScroll={handleGridScroll}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
</VirtualGridContainer>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlbumListRoute;
|
||||
58
src/renderer/features/home/queries/recently-played-query.ts
Normal file
58
src/renderer/features/home/queries/recently-played-query.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { ndNormalize } from '/@/renderer/api/navidrome.api';
|
||||
import { NDAlbum } from '/@/renderer/api/navidrome.types';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
AlbumListQuery,
|
||||
AlbumListSort,
|
||||
RawAlbumListResponse,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { QueryOptions } from '/@/renderer/lib/react-query';
|
||||
|
||||
export const useRecentlyPlayed = (query: Partial<AlbumListQuery>, options?: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
|
||||
const requestQuery: AlbumListQuery = {
|
||||
limit: 5,
|
||||
sortBy: AlbumListSort.RECENTLY_PLAYED,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
...query,
|
||||
};
|
||||
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getAlbumList({
|
||||
query: requestQuery,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
queryKey: queryKeys.albums.list(server?.id || '', requestQuery),
|
||||
select: useCallback(
|
||||
(data: RawAlbumListResponse | undefined) => {
|
||||
let albums;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
break;
|
||||
case 'navidrome':
|
||||
albums = data?.items.map((item) => ndNormalize.album(item as NDAlbum, server));
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: albums,
|
||||
startIndex: data?.startIndex,
|
||||
totalRecordCount: data?.totalRecordCount,
|
||||
};
|
||||
},
|
||||
[server],
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
261
src/renderer/features/home/routes/home-route.tsx
Normal file
261
src/renderer/features/home/routes/home-route.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { Box, Stack } from '@mantine/core';
|
||||
import { useSetState } from '@mantine/hooks';
|
||||
import { AlbumListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import { TextTitle, PageHeader, FeatureCarousel, GridCarousel } from '/@/renderer/components';
|
||||
import { useAlbumList } from '/@/renderer/features/albums';
|
||||
import { useRecentlyPlayed } from '/@/renderer/features/home/queries/recently-played-query';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
|
||||
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 feature = useAlbumList({
|
||||
limit: 20,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: 0,
|
||||
});
|
||||
|
||||
const featureItemsWithImage = useMemo(() => {
|
||||
return feature.data?.items?.filter((item) => item.imageUrl) ?? [];
|
||||
}, [feature.data?.items]);
|
||||
|
||||
const random = useAlbumList(
|
||||
{
|
||||
limit: itemsPerPage,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: pagination.random * itemsPerPage,
|
||||
},
|
||||
{
|
||||
cacheTime: 0,
|
||||
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 = useCallback(
|
||||
(key: 'mostPlayed' | 'random' | 'recentlyAdded' | 'recentlyPlayed') => {
|
||||
setPagination({
|
||||
[key]: pagination[key as keyof typeof pagination] + 1,
|
||||
});
|
||||
},
|
||||
[pagination, setPagination],
|
||||
);
|
||||
|
||||
const handlePreviousPage = useCallback(
|
||||
(key: 'mostPlayed' | 'random' | 'recentlyAdded' | 'recentlyPlayed') => {
|
||||
setPagination({
|
||||
[key]: pagination[key as keyof typeof pagination] - 1,
|
||||
});
|
||||
},
|
||||
[pagination, setPagination],
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
||||
const carousels = [
|
||||
{
|
||||
data: random?.data?.items,
|
||||
loading: random?.isLoading || random.isFetching,
|
||||
pagination: {
|
||||
handleNextPage: () => handleNextPage('random'),
|
||||
handlePreviousPage: () => handlePreviousPage('random'),
|
||||
hasPreviousPage: pagination.random > 0,
|
||||
itemsPerPage,
|
||||
},
|
||||
title: (
|
||||
<TextTitle
|
||||
fw="bold"
|
||||
order={3}
|
||||
>
|
||||
Explore from your library
|
||||
</TextTitle>
|
||||
),
|
||||
uniqueId: 'random',
|
||||
},
|
||||
{
|
||||
data: recentlyPlayed?.data?.items,
|
||||
loading: recentlyPlayed?.isLoading || recentlyPlayed.isFetching,
|
||||
pagination: {
|
||||
handleNextPage: () => handleNextPage('recentlyPlayed'),
|
||||
handlePreviousPage: () => handlePreviousPage('recentlyPlayed'),
|
||||
hasPreviousPage: pagination.recentlyPlayed > 0,
|
||||
itemsPerPage,
|
||||
},
|
||||
title: (
|
||||
<TextTitle
|
||||
fw="bold"
|
||||
order={3}
|
||||
>
|
||||
Recently played
|
||||
</TextTitle>
|
||||
),
|
||||
uniqueId: 'recentlyPlayed',
|
||||
},
|
||||
{
|
||||
data: recentlyAdded?.data?.items,
|
||||
loading: recentlyAdded?.isLoading || recentlyAdded.isFetching,
|
||||
pagination: {
|
||||
handleNextPage: () => handleNextPage('recentlyAdded'),
|
||||
handlePreviousPage: () => handlePreviousPage('recentlyAdded'),
|
||||
hasPreviousPage: pagination.recentlyAdded > 0,
|
||||
itemsPerPage,
|
||||
},
|
||||
title: (
|
||||
<TextTitle
|
||||
fw="bold"
|
||||
order={3}
|
||||
>
|
||||
Newly added releases
|
||||
</TextTitle>
|
||||
),
|
||||
uniqueId: 'recentlyAdded',
|
||||
},
|
||||
{
|
||||
data: mostPlayed?.data?.items,
|
||||
loading: mostPlayed?.isLoading || mostPlayed.isFetching,
|
||||
pagination: {
|
||||
handleNextPage: () => handleNextPage('mostPlayed'),
|
||||
handlePreviousPage: () => handlePreviousPage('mostPlayed'),
|
||||
hasPreviousPage: pagination.mostPlayed > 0,
|
||||
itemsPerPage,
|
||||
},
|
||||
title: (
|
||||
<TextTitle
|
||||
fw="bold"
|
||||
order={3}
|
||||
>
|
||||
Most played
|
||||
</TextTitle>
|
||||
),
|
||||
uniqueId: 'mostPlayed',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<PageHeader
|
||||
useOpacity
|
||||
backgroundColor="var(--sidebar-bg)"
|
||||
/>
|
||||
<Box
|
||||
mb="1rem"
|
||||
mt="-1.5rem"
|
||||
px="1rem"
|
||||
sx={{
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
ref={cq.ref}
|
||||
sx={{
|
||||
height: '100%',
|
||||
maxWidth: '1920px',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={35}>
|
||||
<FeatureCarousel
|
||||
data={featureItemsWithImage}
|
||||
loading={feature.isLoading || feature.isFetching}
|
||||
/>
|
||||
{carousels.map((carousel, index) => (
|
||||
<GridCarousel
|
||||
key={`carousel-${carousel.uniqueId}-${index}`}
|
||||
cardRows={[
|
||||
{
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
arrayProperty: 'name',
|
||||
property: 'albumArtists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
]}
|
||||
containerWidth={cq.width}
|
||||
data={carousel.data}
|
||||
loading={carousel.loading}
|
||||
pagination={carousel.pagination}
|
||||
uniqueId={carousel.uniqueId}
|
||||
>
|
||||
<GridCarousel.Title>{carousel.title}</GridCarousel.Title>
|
||||
</GridCarousel>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeRoute;
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { useRef } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { PlayQueueListControls } from './play-queue-list-controls';
|
||||
import { Song } from '/@/renderer/api/types';
|
||||
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
|
||||
|
||||
export const DrawerPlayQueue = () => {
|
||||
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
pb="1rem"
|
||||
sx={{ height: '100%' }}
|
||||
>
|
||||
<PlayQueue
|
||||
ref={queueRef}
|
||||
type="sideQueue"
|
||||
/>
|
||||
<PlayQueueListControls
|
||||
tableRef={queueRef}
|
||||
type="sideQueue"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Group } from '@mantine/core';
|
||||
import { FastAverageColor } from 'fast-average-color';
|
||||
import { PageHeader, Text } from '/@/renderer/components';
|
||||
import { useCurrentSong } from '/@/renderer/store';
|
||||
import { getHeaderColor } from '/@/renderer/utils';
|
||||
import { useTheme } from '/@/renderer/hooks';
|
||||
|
||||
export const NowPlayingHeader = () => {
|
||||
const [headerColor, setHeaderColor] = useState({ isDark: false, value: 'rgba(0, 0, 0, 0)' });
|
||||
const currentSong = useCurrentSong();
|
||||
const theme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const fac = new FastAverageColor();
|
||||
const url = currentSong?.imageUrl;
|
||||
|
||||
if (url) {
|
||||
fac
|
||||
.getColorAsync(currentSong?.imageUrl, {
|
||||
algorithm: 'simple',
|
||||
ignoredColor: [
|
||||
[255, 255, 255, 255], // White
|
||||
[0, 0, 0, 255], // Black
|
||||
],
|
||||
mode: 'precision',
|
||||
})
|
||||
.then((color) => {
|
||||
const isDark = color.isDark;
|
||||
setHeaderColor({
|
||||
isDark,
|
||||
value: getHeaderColor(color.rgb, theme === 'dark' ? 0.5 : 0.8),
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
fac.destroy();
|
||||
};
|
||||
}, [currentSong?.imageUrl, theme]);
|
||||
|
||||
return (
|
||||
<PageHeader backgroundColor={headerColor.value}>
|
||||
<Group>
|
||||
<Text size="xl">Queue</Text>
|
||||
</Group>
|
||||
</PageHeader>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import type { MutableRefObject } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Group } from '@mantine/core';
|
||||
import { Button, Popover, TableConfigDropdown } from '/@/renderer/components';
|
||||
import {
|
||||
RiArrowDownLine,
|
||||
RiArrowUpLine,
|
||||
RiShuffleLine,
|
||||
RiDeleteBinLine,
|
||||
RiListSettingsLine,
|
||||
RiEraserLine,
|
||||
} from 'react-icons/ri';
|
||||
import { Song } from '/@/renderer/api/types';
|
||||
import { useQueueControls } from '/@/renderer/store';
|
||||
import { TableType } from '/@/renderer/types';
|
||||
|
||||
const mpvPlayer = window.electron.mpvPlayer;
|
||||
|
||||
interface PlayQueueListOptionsProps {
|
||||
tableRef: MutableRefObject<{ grid: AgGridReactType<Song> } | null>;
|
||||
type: TableType;
|
||||
}
|
||||
|
||||
export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsProps) => {
|
||||
const { clearQueue, moveToBottomOfQueue, moveToTopOfQueue, shuffleQueue, removeFromQueue } =
|
||||
useQueueControls();
|
||||
|
||||
const handleMoveToBottom = () => {
|
||||
const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
|
||||
const uniqueIds = selectedRows?.map((row) => row.uniqueId);
|
||||
if (!uniqueIds?.length) return;
|
||||
|
||||
const playerData = moveToBottomOfQueue(uniqueIds);
|
||||
mpvPlayer.setQueueNext(playerData);
|
||||
};
|
||||
|
||||
const handleMoveToTop = () => {
|
||||
const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
|
||||
const uniqueIds = selectedRows?.map((row) => row.uniqueId);
|
||||
if (!uniqueIds?.length) return;
|
||||
|
||||
const playerData = moveToTopOfQueue(uniqueIds);
|
||||
mpvPlayer.setQueueNext(playerData);
|
||||
};
|
||||
|
||||
const handleRemoveSelected = () => {
|
||||
const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
|
||||
const uniqueIds = selectedRows?.map((row) => row.uniqueId);
|
||||
if (!uniqueIds?.length) return;
|
||||
|
||||
const playerData = removeFromQueue(uniqueIds);
|
||||
mpvPlayer.setQueueNext(playerData);
|
||||
};
|
||||
|
||||
const handleClearQueue = () => {
|
||||
const playerData = clearQueue();
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.stop();
|
||||
};
|
||||
|
||||
const handleShuffleQueue = () => {
|
||||
const playerData = shuffleQueue();
|
||||
mpvPlayer.setQueueNext(playerData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Group
|
||||
position="apart"
|
||||
px="1rem"
|
||||
sx={{ alignItems: 'center' }}
|
||||
>
|
||||
<Group>
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
tooltip={{ label: 'Shuffle queue' }}
|
||||
variant="default"
|
||||
onClick={handleShuffleQueue}
|
||||
>
|
||||
<RiShuffleLine size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
tooltip={{ label: 'Move selected to top' }}
|
||||
variant="default"
|
||||
onClick={handleMoveToTop}
|
||||
>
|
||||
<RiArrowUpLine size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
tooltip={{ label: 'Move selected to bottom' }}
|
||||
variant="default"
|
||||
onClick={handleMoveToBottom}
|
||||
>
|
||||
<RiArrowDownLine size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
tooltip={{ label: 'Remove selected' }}
|
||||
variant="default"
|
||||
onClick={handleRemoveSelected}
|
||||
>
|
||||
<RiEraserLine size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
tooltip={{ label: 'Clear queue' }}
|
||||
variant="default"
|
||||
onClick={handleClearQueue}
|
||||
>
|
||||
<RiDeleteBinLine size={15} />
|
||||
</Button>
|
||||
</Group>
|
||||
<Group>
|
||||
<Popover>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
tooltip={{ label: 'Configure' }}
|
||||
variant="default"
|
||||
>
|
||||
<RiListSettingsLine size={15} />
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<TableConfigDropdown type={type} />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
236
src/renderer/features/now-playing/components/play-queue.tsx
Normal file
236
src/renderer/features/now-playing/components/play-queue.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import type { Ref } from 'react';
|
||||
import { useState, forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
|
||||
import type {
|
||||
CellDoubleClickedEvent,
|
||||
ColDef,
|
||||
RowClassRules,
|
||||
RowDragEvent,
|
||||
RowNode,
|
||||
} from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import '@ag-grid-community/styles/ag-theme-alpine.css';
|
||||
import {
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualGridContainer,
|
||||
getColumnDefs,
|
||||
} from '/@/renderer/components';
|
||||
import {
|
||||
useAppStoreActions,
|
||||
useCurrentSong,
|
||||
useDefaultQueue,
|
||||
usePreviousSong,
|
||||
useQueueControls,
|
||||
} from '/@/renderer/store';
|
||||
import {
|
||||
useSettingsStore,
|
||||
useSettingsStoreActions,
|
||||
useTableSettings,
|
||||
} from '/@/renderer/store/settings.store';
|
||||
import { useMergedRef } from '@mantine/hooks';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
import { ErrorFallback } from '/@/renderer/features/action-required';
|
||||
import { TableType, QueueSong } from '/@/renderer/types';
|
||||
|
||||
const mpvPlayer = window.electron.mpvPlayer;
|
||||
|
||||
type QueueProps = {
|
||||
type: TableType;
|
||||
};
|
||||
|
||||
export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const mergedRef = useMergedRef(ref, tableRef);
|
||||
const queue = useDefaultQueue();
|
||||
const { reorderQueue, setCurrentTrack } = useQueueControls();
|
||||
const currentSong = useCurrentSong();
|
||||
const previousSong = usePreviousSong();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const { setAppStore } = useAppStoreActions();
|
||||
const tableConfig = useTableSettings(type);
|
||||
const [gridApi, setGridApi] = useState<AgGridReactType | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (tableRef.current) {
|
||||
setGridApi(tableRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
get grid() {
|
||||
return gridApi;
|
||||
},
|
||||
}));
|
||||
|
||||
const columnDefs = useMemo(() => getColumnDefs(tableConfig.columns), [tableConfig.columns]);
|
||||
const defaultColumnDefs: ColDef = useMemo(() => {
|
||||
return {
|
||||
lockPinned: true,
|
||||
lockVisible: true,
|
||||
resizable: true,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePlayByRowClick = (e: CellDoubleClickedEvent) => {
|
||||
const playerData = setCurrentTrack(e.data.uniqueId);
|
||||
mpvPlayer.setQueue(playerData);
|
||||
};
|
||||
|
||||
const handleDragStart = () => {
|
||||
if (type === 'sideDrawerQueue') {
|
||||
setAppStore({ isReorderingQueue: true });
|
||||
}
|
||||
};
|
||||
|
||||
let timeout: any;
|
||||
const handleDragEnd = (e: RowDragEvent<QueueSong>) => {
|
||||
if (!e.nodes.length) return;
|
||||
const selectedUniqueIds = e.nodes
|
||||
.map((node) => node.data?.uniqueId)
|
||||
.filter((e) => e !== undefined);
|
||||
|
||||
const playerData = reorderQueue(selectedUniqueIds as string[], e.overNode?.data?.uniqueId);
|
||||
mpvPlayer.setQueueNext(playerData);
|
||||
|
||||
if (type === 'sideDrawerQueue') {
|
||||
setAppStore({ isReorderingQueue: false });
|
||||
}
|
||||
|
||||
const { api } = tableRef?.current || {};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => api?.redrawRows(), 250);
|
||||
};
|
||||
|
||||
const handleGridReady = () => {
|
||||
const { api } = tableRef?.current || {};
|
||||
|
||||
if (currentSong?.uniqueId) {
|
||||
const currentNode = api?.getRowNode(currentSong?.uniqueId);
|
||||
|
||||
if (!currentNode) return;
|
||||
api?.ensureNodeVisible(currentNode, 'middle');
|
||||
}
|
||||
};
|
||||
|
||||
const handleColumnChange = () => {
|
||||
const { columnApi } = tableRef?.current || {};
|
||||
const columnsOrder = columnApi?.getAllGridColumns();
|
||||
if (!columnsOrder) return;
|
||||
|
||||
const columnsInSettings = useSettingsStore.getState().tables[type].columns;
|
||||
|
||||
const updatedColumns = [];
|
||||
for (const column of columnsOrder) {
|
||||
const columnInSettings = columnsInSettings.find((c) => c.column === column.colId);
|
||||
|
||||
if (columnInSettings) {
|
||||
updatedColumns.push({
|
||||
...columnInSettings,
|
||||
...(!useSettingsStore.getState().tables[type].autoFit && {
|
||||
width: column.actualWidth,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setSettings({
|
||||
tables: {
|
||||
...useSettingsStore.getState().tables,
|
||||
[type]: {
|
||||
...useSettingsStore.getState().tables[type],
|
||||
columns: updatedColumns,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleGridSizeChange = () => {
|
||||
if (tableConfig.autoFit) {
|
||||
tableRef?.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
const rowClassRules = useMemo<RowClassRules>(() => {
|
||||
return {
|
||||
'current-song': (params) => {
|
||||
return params.data.uniqueId === currentSong?.uniqueId;
|
||||
},
|
||||
};
|
||||
}, [currentSong?.uniqueId]);
|
||||
|
||||
// Redraw the current song row when the previous song changes
|
||||
useEffect(() => {
|
||||
if (tableRef?.current) {
|
||||
const { api, columnApi } = tableRef?.current || {};
|
||||
if (api == null || columnApi == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentNode = currentSong?.uniqueId ? api.getRowNode(currentSong.uniqueId) : undefined;
|
||||
const previousNode = previousSong?.uniqueId
|
||||
? api.getRowNode(previousSong?.uniqueId)
|
||||
: undefined;
|
||||
|
||||
const rowNodes = [currentNode, previousNode].filter((e) => e !== undefined) as RowNode<any>[];
|
||||
|
||||
if (rowNodes) {
|
||||
api.redrawRows({ rowNodes });
|
||||
if (tableConfig.followCurrentSong) {
|
||||
if (!currentNode) return;
|
||||
api.ensureNodeVisible(currentNode, 'middle');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [currentSong, previousSong, tableConfig.followCurrentSong]);
|
||||
|
||||
// Auto resize the columns when the column config changes
|
||||
useEffect(() => {
|
||||
if (tableConfig.autoFit) {
|
||||
const { api } = tableRef?.current || {};
|
||||
api?.sizeColumnsToFit();
|
||||
}
|
||||
}, [tableConfig.autoFit, tableConfig.columns]);
|
||||
|
||||
useEffect(() => {
|
||||
const { api } = tableRef?.current || {};
|
||||
api?.resetRowHeights();
|
||||
api?.redrawRows();
|
||||
}, [tableConfig.rowHeight]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<VirtualGridContainer>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<VirtualTable
|
||||
ref={mergedRef}
|
||||
alwaysShowHorizontalScroll
|
||||
animateRows
|
||||
maintainColumnOrder
|
||||
rowDragEntireRow
|
||||
rowDragMultiRow
|
||||
suppressCopyRowsToClipboard
|
||||
suppressMoveWhenRowDragging
|
||||
suppressRowDrag
|
||||
suppressScrollOnNewData
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColumnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
rowBuffer={30}
|
||||
rowClassRules={rowClassRules}
|
||||
rowData={queue}
|
||||
rowHeight={tableConfig.rowHeight || 40}
|
||||
rowSelection="multiple"
|
||||
onCellDoubleClicked={handlePlayByRowClick}
|
||||
onColumnMoved={handleColumnChange}
|
||||
onColumnResized={handleColumnChange}
|
||||
onDragStarted={handleDragStart}
|
||||
onGridReady={handleGridReady}
|
||||
onGridSizeChanged={handleGridSizeChange}
|
||||
onRowDragEnd={handleDragEnd}
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
</VirtualGridContainer>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { useRef } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
|
||||
import { PlayQueueListControls } from './play-queue-list-controls';
|
||||
import { Song } from '/@/renderer/api/types';
|
||||
|
||||
export const SidebarPlayQueue = () => {
|
||||
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
pb="1rem"
|
||||
pt="2.5rem"
|
||||
sx={{ height: '100%' }}
|
||||
>
|
||||
<PlayQueue
|
||||
ref={queueRef}
|
||||
type="sideQueue"
|
||||
/>
|
||||
<PlayQueueListControls
|
||||
tableRef={queueRef}
|
||||
type="sideQueue"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
4
src/renderer/features/now-playing/index.ts
Normal file
4
src/renderer/features/now-playing/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './components/play-queue';
|
||||
export * from './components/sidebar-play-queue';
|
||||
export * from './components/drawer-play-queue';
|
||||
export * from './components/play-queue-list-controls';
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { useRef } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { NowPlayingHeader } from '/@/renderer/features/now-playing/components/now-playing-header';
|
||||
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
|
||||
import { PlayQueueListControls } from '/@/renderer/features/now-playing/components/play-queue-list-controls';
|
||||
import type { Song } from '/@/renderer/api/types';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
|
||||
const NowPlayingRoute = () => {
|
||||
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<Stack
|
||||
pb="1rem"
|
||||
spacing={0}
|
||||
sx={{ height: '100%' }}
|
||||
>
|
||||
<NowPlayingHeader />
|
||||
<PlayQueue
|
||||
ref={queueRef}
|
||||
type="nowPlaying"
|
||||
/>
|
||||
|
||||
<PlayQueueListControls
|
||||
tableRef={queueRef}
|
||||
type="nowPlaying"
|
||||
/>
|
||||
</Stack>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default NowPlayingRoute;
|
||||
238
src/renderer/features/player/components/center-controls.tsx
Normal file
238
src/renderer/features/player/components/center-controls.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import format from 'format-duration';
|
||||
import isElectron from 'is-electron';
|
||||
import { IoIosPause } from 'react-icons/io';
|
||||
import {
|
||||
RiPlayFill,
|
||||
RiRepeat2Line,
|
||||
RiRepeatOneLine,
|
||||
RiRewindFill,
|
||||
RiShuffleFill,
|
||||
RiSkipBackFill,
|
||||
RiSkipForwardFill,
|
||||
RiSpeedFill,
|
||||
} from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { Text } from '/@/renderer/components';
|
||||
import { useCenterControls } from '../hooks/use-center-controls';
|
||||
import { PlayerButton } from './player-button';
|
||||
import { Slider } from './slider';
|
||||
import {
|
||||
useCurrentSong,
|
||||
useCurrentStatus,
|
||||
useCurrentPlayer,
|
||||
useSetCurrentTime,
|
||||
useRepeatStatus,
|
||||
useShuffleStatus,
|
||||
useCurrentTime,
|
||||
} from '/@/renderer/store';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { PlayerStatus, PlaybackType, PlayerShuffle, PlayerRepeat } from '/@/renderer/types';
|
||||
|
||||
interface CenterControlsProps {
|
||||
playersRef: any;
|
||||
}
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 35px;
|
||||
`;
|
||||
|
||||
const ButtonsContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const SliderContainer = styled.div`
|
||||
display: flex;
|
||||
height: 20px;
|
||||
`;
|
||||
|
||||
const SliderValueWrapper = styled.div<{ position: 'left' | 'right' }>`
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
max-width: 50px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const SliderWrapper = styled.div`
|
||||
display: flex;
|
||||
flex: 6;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const currentSong = useCurrentSong();
|
||||
const songDuration = currentSong?.duration;
|
||||
const skip = useSettingsStore((state) => state.player.skipButtons);
|
||||
const playerType = useSettingsStore((state) => state.player.type);
|
||||
const player1 = playersRef?.current?.player1;
|
||||
const player2 = playersRef?.current?.player2;
|
||||
const status = useCurrentStatus();
|
||||
const player = useCurrentPlayer();
|
||||
const setCurrentTime = useSetCurrentTime();
|
||||
const repeat = useRepeatStatus();
|
||||
const shuffle = useShuffleStatus();
|
||||
|
||||
const {
|
||||
handleNextTrack,
|
||||
handlePlayPause,
|
||||
handlePrevTrack,
|
||||
handleSeekSlider,
|
||||
handleSkipBackward,
|
||||
handleSkipForward,
|
||||
handleToggleRepeat,
|
||||
handleToggleShuffle,
|
||||
} = useCenterControls({ playersRef });
|
||||
|
||||
const currentTime = useCurrentTime();
|
||||
const currentPlayerRef = player === 1 ? player1 : player2;
|
||||
const duration = format((songDuration || 0) * 1000);
|
||||
const formattedTime = format(currentTime * 1000 || 0);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: any;
|
||||
|
||||
if (status === PlayerStatus.PLAYING && !isSeeking) {
|
||||
if (!isElectron() || playerType === PlaybackType.WEB) {
|
||||
interval = setInterval(() => {
|
||||
setCurrentTime(currentPlayerRef.getCurrentTime());
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
}
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [currentPlayerRef, isSeeking, setCurrentTime, playerType, status]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ControlsContainer>
|
||||
<ButtonsContainer>
|
||||
<PlayerButton
|
||||
$isActive={shuffle !== PlayerShuffle.NONE}
|
||||
icon={<RiShuffleFill size={15} />}
|
||||
tooltip={{
|
||||
label:
|
||||
shuffle === PlayerShuffle.NONE
|
||||
? 'Shuffle disabled'
|
||||
: shuffle === PlayerShuffle.TRACK
|
||||
? 'Shuffle tracks'
|
||||
: 'Shuffle albums',
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="tertiary"
|
||||
onClick={handleToggleShuffle}
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<RiSkipBackFill size={15} />}
|
||||
tooltip={{ label: 'Previous track', openDelay: 500 }}
|
||||
variant="secondary"
|
||||
onClick={handlePrevTrack}
|
||||
/>
|
||||
{skip?.enabled && (
|
||||
<PlayerButton
|
||||
icon={<RiRewindFill size={15} />}
|
||||
tooltip={{
|
||||
label: `Skip backwards ${skip?.skipBackwardSeconds} seconds`,
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
onClick={() => handleSkipBackward(skip?.skipBackwardSeconds)}
|
||||
/>
|
||||
)}
|
||||
<PlayerButton
|
||||
icon={
|
||||
status === PlayerStatus.PAUSED ? <RiPlayFill size={20} /> : <IoIosPause size={20} />
|
||||
}
|
||||
tooltip={{
|
||||
label: status === PlayerStatus.PAUSED ? 'Play' : 'Pause',
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="main"
|
||||
onClick={handlePlayPause}
|
||||
/>
|
||||
{skip?.enabled && (
|
||||
<PlayerButton
|
||||
icon={<RiSpeedFill size={15} />}
|
||||
tooltip={{
|
||||
label: `Skip forwards ${skip?.skipForwardSeconds} seconds`,
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
onClick={() => handleSkipForward(skip?.skipForwardSeconds)}
|
||||
/>
|
||||
)}
|
||||
<PlayerButton
|
||||
icon={<RiSkipForwardFill size={15} />}
|
||||
tooltip={{ label: 'Next track', openDelay: 500 }}
|
||||
variant="secondary"
|
||||
onClick={handleNextTrack}
|
||||
/>
|
||||
<PlayerButton
|
||||
$isActive={repeat !== PlayerRepeat.NONE}
|
||||
icon={
|
||||
repeat === PlayerRepeat.ONE ? (
|
||||
<RiRepeatOneLine size={15} />
|
||||
) : (
|
||||
<RiRepeat2Line size={15} />
|
||||
)
|
||||
}
|
||||
tooltip={{
|
||||
label: `${
|
||||
repeat === PlayerRepeat.NONE
|
||||
? 'Repeat disabled'
|
||||
: repeat === PlayerRepeat.ALL
|
||||
? 'Repeat all'
|
||||
: 'Repeat one'
|
||||
}`,
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="tertiary"
|
||||
onClick={handleToggleRepeat}
|
||||
/>
|
||||
</ButtonsContainer>
|
||||
</ControlsContainer>
|
||||
<SliderContainer>
|
||||
<SliderValueWrapper position="left">
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary
|
||||
size="xs"
|
||||
weight={600}
|
||||
>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
</SliderValueWrapper>
|
||||
<SliderWrapper>
|
||||
<Slider
|
||||
height="100%"
|
||||
max={songDuration}
|
||||
min={0}
|
||||
tooltipType="time"
|
||||
value={currentTime}
|
||||
onAfterChange={(e) => {
|
||||
handleSeekSlider(e);
|
||||
setIsSeeking(false);
|
||||
}}
|
||||
/>
|
||||
</SliderWrapper>
|
||||
<SliderValueWrapper position="right">
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary
|
||||
size="xs"
|
||||
weight={600}
|
||||
>
|
||||
{duration}
|
||||
</Text>
|
||||
</SliderValueWrapper>
|
||||
</SliderContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
207
src/renderer/features/player/components/left-controls.tsx
Normal file
207
src/renderer/features/player/components/left-controls.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import React from 'react';
|
||||
import { Center } from '@mantine/core';
|
||||
import { motion, AnimatePresence, LayoutGroup } from 'framer-motion';
|
||||
import { RiArrowUpSLine, RiDiscLine } from 'react-icons/ri';
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { Button, Text } from '/@/renderer/components';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useAppStoreActions, useAppStore, useCurrentSong } from '/@/renderer/store';
|
||||
import { fadeIn } from '/@/renderer/styles';
|
||||
|
||||
const LeftControlsContainer = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-left: 1rem;
|
||||
`;
|
||||
|
||||
const ImageWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem 1rem 1rem 0;
|
||||
`;
|
||||
|
||||
const MetadataStack = styled(motion.div)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Image = styled(motion(Link))`
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 100%));
|
||||
|
||||
${fadeIn};
|
||||
animation: fadein 0.2s ease-in-out;
|
||||
|
||||
button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover button {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
const PlayerbarImage = styled.img`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
`;
|
||||
|
||||
const LineItem = styled.div<{ $secondary?: boolean }>`
|
||||
display: inline-block;
|
||||
width: 95%;
|
||||
max-width: 20vw;
|
||||
overflow: hidden;
|
||||
color: ${(props) => props.$secondary && 'var(--main-fg-secondary)'};
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
a {
|
||||
color: ${(props) => props.$secondary && 'var(--text-secondary)'};
|
||||
}
|
||||
`;
|
||||
|
||||
export const LeftControls = () => {
|
||||
const { setSidebar } = useAppStoreActions();
|
||||
const hideImage = useAppStore((state) => state.sidebar.image);
|
||||
const currentSong = useCurrentSong();
|
||||
const title = currentSong?.name;
|
||||
const artists = currentSong?.artists;
|
||||
|
||||
return (
|
||||
<LeftControlsContainer>
|
||||
<LayoutGroup>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{!hideImage && (
|
||||
<ImageWrapper>
|
||||
<Image
|
||||
key="playerbar-image"
|
||||
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
to={AppRoute.NOW_PLAYING}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
{currentSong?.imageUrl ? (
|
||||
<PlayerbarImage
|
||||
loading="eager"
|
||||
src={currentSong?.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<RiDiscLine
|
||||
color="var(--placeholder-fg)"
|
||||
size={50}
|
||||
/>
|
||||
</Center>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
compact
|
||||
opacity={0.8}
|
||||
radius={50}
|
||||
size="xs"
|
||||
sx={{ position: 'absolute', right: 2, top: 2 }}
|
||||
tooltip={{ label: 'Expand' }}
|
||||
variant="default"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSidebar({ image: true });
|
||||
}}
|
||||
>
|
||||
<RiArrowUpSLine
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</Button>
|
||||
</Image>
|
||||
</ImageWrapper>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<MetadataStack layout>
|
||||
<LineItem>
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
to={AppRoute.NOW_PLAYING}
|
||||
weight={500}
|
||||
>
|
||||
{title || '—'}
|
||||
</Text>
|
||||
</LineItem>
|
||||
<LineItem $secondary>
|
||||
{artists?.map((artist, index) => (
|
||||
<React.Fragment key={`bar-${artist.id}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
size="xs"
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="xs"
|
||||
to={
|
||||
artist.id
|
||||
? generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, {
|
||||
artistId: artist.id,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
weight={500}
|
||||
>
|
||||
{artist.name || '—'}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</LineItem>
|
||||
<LineItem $secondary>
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="xs"
|
||||
to={
|
||||
currentSong?.albumId
|
||||
? generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: currentSong.albumId,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
weight={500}
|
||||
>
|
||||
{currentSong?.album || '—'}
|
||||
</Text>
|
||||
</LineItem>
|
||||
</MetadataStack>
|
||||
</LayoutGroup>
|
||||
</LeftControlsContainer>
|
||||
);
|
||||
};
|
||||
156
src/renderer/features/player/components/player-button.tsx
Normal file
156
src/renderer/features/player/components/player-button.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/* stylelint-disable no-descending-specificity */
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
|
||||
import type { TooltipProps, UnstyledButtonProps } from '@mantine/core';
|
||||
import { UnstyledButton } from '@mantine/core';
|
||||
import { motion } from 'framer-motion';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { Tooltip } from '/@/renderer/components';
|
||||
|
||||
type MantineButtonProps = UnstyledButtonProps & ComponentPropsWithoutRef<'button'>;
|
||||
interface PlayerButtonProps extends MantineButtonProps {
|
||||
$isActive?: boolean;
|
||||
icon: ReactNode;
|
||||
tooltip?: Omit<TooltipProps, 'children'>;
|
||||
variant: 'main' | 'secondary' | 'tertiary';
|
||||
}
|
||||
|
||||
const WrapperMainVariant = css`
|
||||
margin: 0 0.5rem;
|
||||
`;
|
||||
|
||||
type MotionWrapperProps = { variant: PlayerButtonProps['variant'] };
|
||||
|
||||
const MotionWrapper = styled(motion.div)<MotionWrapperProps>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
${({ variant }) => variant === 'main' && WrapperMainVariant};
|
||||
`;
|
||||
|
||||
const ButtonMainVariant = css`
|
||||
padding: 0.5rem;
|
||||
background: var(--playerbar-btn-main-bg);
|
||||
border-radius: 50%;
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
fill: var(--playerbar-btn-main-fg);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
background: var(--playerbar-btn-main-bg-hover);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--playerbar-btn-main-bg-hover);
|
||||
|
||||
svg {
|
||||
fill: var(--playerbar-btn-main-fg-hover);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ButtonSecondaryVariant = css`
|
||||
padding: 0.5rem;
|
||||
`;
|
||||
|
||||
const ButtonTertiaryVariant = css`
|
||||
padding: 0.5rem;
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
svg {
|
||||
fill: var(--playerbar-btn-fg-hover);
|
||||
stroke: var(--playerbar-btn-fg-hover);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type StyledPlayerButtonProps = Omit<PlayerButtonProps, 'icon'>;
|
||||
|
||||
const StyledPlayerButton = styled(UnstyledButton)<StyledPlayerButtonProps>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
overflow: visible;
|
||||
background: var(--playerbar-btn-bg-hover);
|
||||
all: unset;
|
||||
cursor: default;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
background: var(--playerbar-btn-bg-hover);
|
||||
outline: 1px var(--primary-color) solid;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
fill: ${({ $isActive }) => ($isActive ? 'var(--primary-color)' : 'var(--playerbar-btn-fg)')};
|
||||
stroke: var(--playerbar-btn-fg);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--playerbar-btn-bg-hover);
|
||||
|
||||
svg {
|
||||
fill: ${({ $isActive }) =>
|
||||
$isActive ? 'var(--primary-color)' : 'var(--playerbar-btn-fg-hover)'};
|
||||
}
|
||||
}
|
||||
|
||||
${({ variant }) =>
|
||||
variant === 'main'
|
||||
? ButtonMainVariant
|
||||
: variant === 'secondary'
|
||||
? ButtonSecondaryVariant
|
||||
: ButtonTertiaryVariant};
|
||||
`;
|
||||
|
||||
export const PlayerButton = ({ tooltip, variant, icon, ...rest }: PlayerButtonProps) => {
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip {...tooltip}>
|
||||
<MotionWrapper
|
||||
variant={variant}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 1 }}
|
||||
>
|
||||
<StyledPlayerButton
|
||||
variant={variant}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
</StyledPlayerButton>
|
||||
</MotionWrapper>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MotionWrapper variant={variant}>
|
||||
<StyledPlayerButton
|
||||
variant={variant}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
</StyledPlayerButton>
|
||||
</MotionWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
PlayerButton.defaultProps = {
|
||||
$isActive: false,
|
||||
tooltip: undefined,
|
||||
};
|
||||
94
src/renderer/features/player/components/playerbar.tsx
Normal file
94
src/renderer/features/player/components/playerbar.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { PlaybackType } from '/@/renderer/types';
|
||||
import { AudioPlayer } from '/@/renderer/components';
|
||||
import {
|
||||
useCurrentPlayer,
|
||||
useCurrentStatus,
|
||||
usePlayer1Data,
|
||||
usePlayer2Data,
|
||||
usePlayerControls,
|
||||
useVolume,
|
||||
} from '/@/renderer/store';
|
||||
import { CenterControls } from './center-controls';
|
||||
import { LeftControls } from './left-controls';
|
||||
import { RightControls } from './right-controls';
|
||||
|
||||
const PlayerbarContainer = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-top: var(--playerbar-border-top);
|
||||
`;
|
||||
|
||||
const PlayerbarControlsGrid = styled.div`
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const RightGridItem = styled.div`
|
||||
align-self: center;
|
||||
width: calc(100% / 3);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const LeftGridItem = styled.div`
|
||||
width: calc(100% / 3);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const CenterGridItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
width: calc(100% / 3);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const Playerbar = () => {
|
||||
const playersRef = useRef<any>();
|
||||
const settings = useSettingsStore((state) => state.player);
|
||||
const volume = useVolume();
|
||||
const player1 = usePlayer1Data();
|
||||
const player2 = usePlayer2Data();
|
||||
const status = useCurrentStatus();
|
||||
const player = useCurrentPlayer();
|
||||
const { autoNext } = usePlayerControls();
|
||||
|
||||
return (
|
||||
<PlayerbarContainer>
|
||||
<PlayerbarControlsGrid>
|
||||
<LeftGridItem>
|
||||
<LeftControls />
|
||||
</LeftGridItem>
|
||||
<CenterGridItem>
|
||||
<CenterControls playersRef={playersRef} />
|
||||
</CenterGridItem>
|
||||
<RightGridItem>
|
||||
<RightControls />
|
||||
</RightGridItem>
|
||||
</PlayerbarControlsGrid>
|
||||
{settings.type === PlaybackType.WEB && (
|
||||
<AudioPlayer
|
||||
ref={playersRef}
|
||||
autoNext={autoNext}
|
||||
crossfadeDuration={settings.crossfadeDuration}
|
||||
crossfadeStyle={settings.crossfadeStyle}
|
||||
currentPlayer={player}
|
||||
muted={settings.muted}
|
||||
playbackStyle={settings.style}
|
||||
player1={player1}
|
||||
player2={player2}
|
||||
status={status}
|
||||
style={settings.style}
|
||||
volume={(volume / 100) ** 2}
|
||||
/>
|
||||
)}
|
||||
</PlayerbarContainer>
|
||||
);
|
||||
};
|
||||
81
src/renderer/features/player/components/right-controls.tsx
Normal file
81
src/renderer/features/player/components/right-controls.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { Group } from '@mantine/core';
|
||||
import { HiOutlineQueueList } from 'react-icons/hi2';
|
||||
import { RiVolumeUpFill, RiVolumeDownFill, RiVolumeMuteFill } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { useAppStoreActions, useMuted, useSidebarStore, useVolume } from '/@/renderer/store';
|
||||
import { useRightControls } from '../hooks/use-right-controls';
|
||||
import { PlayerButton } from './player-button';
|
||||
import { Slider } from './slider';
|
||||
|
||||
const RightControlsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-right: 1rem;
|
||||
`;
|
||||
|
||||
const VolumeSliderWrapper = styled.div`
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
width: 90px;
|
||||
`;
|
||||
|
||||
const MetadataStack = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
`;
|
||||
|
||||
export const RightControls = () => {
|
||||
const volume = useVolume();
|
||||
const muted = useMuted();
|
||||
const { setSidebar } = useAppStoreActions();
|
||||
const { rightExpanded: isQueueExpanded } = useSidebarStore();
|
||||
const { handleVolumeSlider, handleVolumeSliderState, handleMute } = useRightControls();
|
||||
|
||||
return (
|
||||
<RightControlsContainer>
|
||||
<Group>
|
||||
<PlayerButton
|
||||
icon={<HiOutlineQueueList />}
|
||||
tooltip={{ label: 'View queue', openDelay: 500 }}
|
||||
variant="secondary"
|
||||
onClick={() => setSidebar({ rightExpanded: !isQueueExpanded })}
|
||||
/>
|
||||
</Group>
|
||||
<MetadataStack>
|
||||
<VolumeSliderWrapper>
|
||||
<PlayerButton
|
||||
icon={
|
||||
muted ? (
|
||||
<RiVolumeMuteFill size={15} />
|
||||
) : volume > 50 ? (
|
||||
<RiVolumeUpFill size={15} />
|
||||
) : (
|
||||
<RiVolumeDownFill size={15} />
|
||||
)
|
||||
}
|
||||
tooltip={{ label: muted ? 'Muted' : volume, openDelay: 500 }}
|
||||
variant="secondary"
|
||||
onClick={handleMute}
|
||||
/>
|
||||
<Slider
|
||||
hasTooltip
|
||||
height="60%"
|
||||
max={100}
|
||||
min={0}
|
||||
value={volume}
|
||||
onAfterChange={handleVolumeSliderState}
|
||||
onChange={handleVolumeSlider}
|
||||
/>
|
||||
</VolumeSliderWrapper>
|
||||
</MetadataStack>
|
||||
</RightControlsContainer>
|
||||
);
|
||||
};
|
||||
149
src/renderer/features/player/components/slider.tsx
Normal file
149
src/renderer/features/player/components/slider.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import format from 'format-duration';
|
||||
import type { ReactSliderProps } from 'react-slider';
|
||||
import ReactSlider from 'react-slider';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface SliderProps extends ReactSliderProps {
|
||||
hasTooltip?: boolean;
|
||||
height: string;
|
||||
tooltipType?: 'text' | 'time';
|
||||
}
|
||||
|
||||
const StyledSlider = styled(ReactSlider)<SliderProps | any>`
|
||||
width: 100%;
|
||||
height: ${(props) => props.height};
|
||||
outline: none;
|
||||
|
||||
.thumb {
|
||||
top: 37%;
|
||||
opacity: 1;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: -25px;
|
||||
left: -18px;
|
||||
display: ${(props) => (props.$isDragging && props.$hasToolTip ? 'block' : 'none')};
|
||||
padding: 2px 6px;
|
||||
color: var(--tooltip-fg);
|
||||
white-space: nowrap;
|
||||
background: var(--tooltip-bg);
|
||||
border-radius: 4px;
|
||||
content: attr(data-tooltip);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
border: 1px var(--primary-color) solid;
|
||||
border-radius: 100%;
|
||||
outline: none;
|
||||
transform: translate(-12px, -4px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.track-0 {
|
||||
background: ${(props) => props.$isDragging && 'var(--primary-color)'};
|
||||
transition: background 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.track {
|
||||
top: 37%;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.track-0 {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const MemoizedThumb = ({ props, state, toolTipType }: any) => {
|
||||
const { value } = state;
|
||||
const formattedValue = useMemo(() => {
|
||||
if (toolTipType === 'text') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return format(value * 1000);
|
||||
}, [toolTipType, value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
data-tooltip={formattedValue}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledTrack = styled.div<any>`
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
height: 5px;
|
||||
background: ${(props) =>
|
||||
props.index === 1
|
||||
? 'var(--playerbar-slider-track-bg)'
|
||||
: 'var(--playerbar-slider-track-progress-bg)'};
|
||||
`;
|
||||
|
||||
const Track = (props: any, state: any) => (
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
<StyledTrack
|
||||
{...props}
|
||||
index={state.index}
|
||||
/>
|
||||
);
|
||||
const Thumb = (props: any, state: any, toolTipType: any) => (
|
||||
<MemoizedThumb
|
||||
key="slider"
|
||||
props={props}
|
||||
state={state}
|
||||
tabIndex={0}
|
||||
toolTipType={toolTipType}
|
||||
/>
|
||||
);
|
||||
|
||||
export const Slider = ({
|
||||
height,
|
||||
tooltipType: toolTipType,
|
||||
hasTooltip: hasToolTip,
|
||||
...rest
|
||||
}: SliderProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
return (
|
||||
<StyledSlider
|
||||
{...rest}
|
||||
$hasToolTip={hasToolTip}
|
||||
$isDragging={isDragging}
|
||||
className="player-slider"
|
||||
defaultValue={0}
|
||||
height={height}
|
||||
renderThumb={(props: any, state: any) => {
|
||||
return Thumb(props, state, toolTipType);
|
||||
}}
|
||||
renderTrack={Track}
|
||||
onAfterChange={(e: number, index: number) => {
|
||||
if (rest.onAfterChange) {
|
||||
rest.onAfterChange(e, index);
|
||||
}
|
||||
setIsDragging(false);
|
||||
}}
|
||||
onBeforeChange={(e: number, index: number) => {
|
||||
if (rest.onBeforeChange) {
|
||||
rest.onBeforeChange(e, index);
|
||||
}
|
||||
setIsDragging(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Slider.defaultProps = {
|
||||
hasTooltip: true,
|
||||
tooltipType: 'text',
|
||||
};
|
||||
520
src/renderer/features/player/hooks/use-center-controls.ts
Normal file
520
src/renderer/features/player/hooks/use-center-controls.ts
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
import isElectron from 'is-electron';
|
||||
import { PlaybackType, PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/renderer/types';
|
||||
import {
|
||||
useCurrentPlayer,
|
||||
useCurrentStatus,
|
||||
useDefaultQueue,
|
||||
usePlayerControls,
|
||||
usePlayerStore,
|
||||
useRepeatStatus,
|
||||
useSetCurrentTime,
|
||||
useShuffleStatus,
|
||||
} from '/@/renderer/store';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
|
||||
const mpvPlayer = window.electron.mpvPlayer;
|
||||
const mpvPlayerListener = window.electron.mpvPlayerListener;
|
||||
const ipc = window.electron.ipc;
|
||||
|
||||
export const useCenterControls = (args: { playersRef: any }) => {
|
||||
const { playersRef } = args;
|
||||
|
||||
const settings = useSettingsStore((state) => state.player);
|
||||
const currentPlayer = useCurrentPlayer();
|
||||
const { setShuffle, setRepeat, play, pause, previous, next, setCurrentIndex, autoNext } =
|
||||
usePlayerControls();
|
||||
const setCurrentTime = useSetCurrentTime();
|
||||
const queue = useDefaultQueue();
|
||||
const playerStatus = useCurrentStatus();
|
||||
const repeatStatus = useRepeatStatus();
|
||||
const shuffleStatus = useShuffleStatus();
|
||||
const playerType = useSettingsStore((state) => state.player.type);
|
||||
const player1Ref = playersRef?.current?.player1;
|
||||
const player2Ref = playersRef?.current?.player2;
|
||||
const currentPlayerRef = currentPlayer === 1 ? player1Ref : player2Ref;
|
||||
const nextPlayerRef = currentPlayer === 1 ? player2Ref : player1Ref;
|
||||
|
||||
const resetPlayers = useCallback(() => {
|
||||
if (player1Ref.getInternalPlayer()) {
|
||||
player1Ref.getInternalPlayer().currentTime = 0;
|
||||
player1Ref.getInternalPlayer().pause();
|
||||
}
|
||||
|
||||
if (player2Ref.getInternalPlayer()) {
|
||||
player2Ref.getInternalPlayer().currentTime = 0;
|
||||
player2Ref.getInternalPlayer().pause();
|
||||
}
|
||||
}, [player1Ref, player2Ref]);
|
||||
|
||||
const resetNextPlayer = useCallback(() => {
|
||||
currentPlayerRef.getInternalPlayer().volume = 0.1;
|
||||
nextPlayerRef.getInternalPlayer().currentTime = 0;
|
||||
nextPlayerRef.getInternalPlayer().pause();
|
||||
}, [currentPlayerRef, nextPlayerRef]);
|
||||
|
||||
const stopPlayback = useCallback(() => {
|
||||
player1Ref.getInternalPlayer().pause();
|
||||
player2Ref.getInternalPlayer().pause();
|
||||
resetPlayers();
|
||||
}, [player1Ref, player2Ref, resetPlayers]);
|
||||
|
||||
const isMpvPlayer = isElectron() && settings.type === PlaybackType.LOCAL;
|
||||
|
||||
const handlePlay = useCallback(() => {
|
||||
if (isMpvPlayer) {
|
||||
mpvPlayer.play();
|
||||
} else {
|
||||
currentPlayerRef.getInternalPlayer().play();
|
||||
}
|
||||
|
||||
play();
|
||||
}, [currentPlayerRef, isMpvPlayer, play]);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
if (isMpvPlayer) {
|
||||
mpvPlayer.pause();
|
||||
}
|
||||
|
||||
pause();
|
||||
}, [isMpvPlayer, pause]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (isMpvPlayer) {
|
||||
mpvPlayer.stop();
|
||||
} else {
|
||||
stopPlayback();
|
||||
}
|
||||
|
||||
setCurrentTime(0);
|
||||
pause();
|
||||
}, [isMpvPlayer, pause, setCurrentTime, stopPlayback]);
|
||||
|
||||
const handleToggleShuffle = useCallback(() => {
|
||||
if (shuffleStatus === PlayerShuffle.NONE) {
|
||||
const playerData = setShuffle(PlayerShuffle.TRACK);
|
||||
return mpvPlayer.setQueueNext(playerData);
|
||||
}
|
||||
|
||||
const playerData = setShuffle(PlayerShuffle.NONE);
|
||||
return mpvPlayer.setQueueNext(playerData);
|
||||
}, [setShuffle, shuffleStatus]);
|
||||
|
||||
const handleToggleRepeat = useCallback(() => {
|
||||
if (repeatStatus === PlayerRepeat.NONE) {
|
||||
const playerData = setRepeat(PlayerRepeat.ALL);
|
||||
return mpvPlayer.setQueueNext(playerData);
|
||||
}
|
||||
|
||||
if (repeatStatus === PlayerRepeat.ALL) {
|
||||
const playerData = setRepeat(PlayerRepeat.ONE);
|
||||
return mpvPlayer.setQueueNext(playerData);
|
||||
}
|
||||
|
||||
return setRepeat(PlayerRepeat.NONE);
|
||||
}, [repeatStatus, setRepeat]);
|
||||
|
||||
const checkIsLastTrack = useCallback(() => {
|
||||
return usePlayerStore.getState().actions.checkIsLastTrack();
|
||||
}, []);
|
||||
|
||||
const checkIsFirstTrack = useCallback(() => {
|
||||
return usePlayerStore.getState().actions.checkIsFirstTrack();
|
||||
}, []);
|
||||
|
||||
const handleAutoNext = useCallback(() => {
|
||||
const isLastTrack = checkIsLastTrack();
|
||||
|
||||
const handleRepeatAll = {
|
||||
local: () => {
|
||||
const playerData = autoNext();
|
||||
mpvPlayer.autoNext(playerData);
|
||||
play();
|
||||
},
|
||||
web: () => {
|
||||
autoNext();
|
||||
},
|
||||
};
|
||||
|
||||
const handleRepeatNone = {
|
||||
local: () => {
|
||||
if (isLastTrack) {
|
||||
const playerData = setCurrentIndex(0);
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.pause();
|
||||
pause();
|
||||
} else {
|
||||
const playerData = autoNext();
|
||||
mpvPlayer.autoNext(playerData);
|
||||
play();
|
||||
}
|
||||
},
|
||||
web: () => {
|
||||
if (isLastTrack) {
|
||||
resetPlayers();
|
||||
pause();
|
||||
} else {
|
||||
autoNext();
|
||||
resetPlayers();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const handleRepeatOne = {
|
||||
local: () => {
|
||||
const playerData = autoNext();
|
||||
mpvPlayer.autoNext(playerData);
|
||||
play();
|
||||
},
|
||||
web: () => {
|
||||
if (isLastTrack) {
|
||||
resetPlayers();
|
||||
} else {
|
||||
autoNext();
|
||||
resetPlayers();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
switch (repeatStatus) {
|
||||
case PlayerRepeat.NONE:
|
||||
handleRepeatNone[playerType]();
|
||||
break;
|
||||
case PlayerRepeat.ALL:
|
||||
handleRepeatAll[playerType]();
|
||||
break;
|
||||
case PlayerRepeat.ONE:
|
||||
handleRepeatOne[playerType]();
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [
|
||||
autoNext,
|
||||
checkIsLastTrack,
|
||||
pause,
|
||||
play,
|
||||
playerType,
|
||||
repeatStatus,
|
||||
resetPlayers,
|
||||
setCurrentIndex,
|
||||
]);
|
||||
|
||||
const handleNextTrack = useCallback(() => {
|
||||
const isLastTrack = checkIsLastTrack();
|
||||
|
||||
const handleRepeatAll = {
|
||||
local: () => {
|
||||
const playerData = next();
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.next();
|
||||
},
|
||||
web: () => {
|
||||
next();
|
||||
},
|
||||
};
|
||||
|
||||
const handleRepeatNone = {
|
||||
local: () => {
|
||||
if (isLastTrack) {
|
||||
const playerData = setCurrentIndex(0);
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.pause();
|
||||
pause();
|
||||
} else {
|
||||
const playerData = next();
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.next();
|
||||
}
|
||||
},
|
||||
web: () => {
|
||||
if (isLastTrack) {
|
||||
setCurrentIndex(0);
|
||||
resetPlayers();
|
||||
pause();
|
||||
} else {
|
||||
next();
|
||||
resetPlayers();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const handleRepeatOne = {
|
||||
local: () => {
|
||||
const playerData = next();
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.next();
|
||||
},
|
||||
web: () => {
|
||||
if (!isLastTrack) {
|
||||
next();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
switch (repeatStatus) {
|
||||
case PlayerRepeat.NONE:
|
||||
handleRepeatNone[playerType]();
|
||||
break;
|
||||
case PlayerRepeat.ALL:
|
||||
handleRepeatAll[playerType]();
|
||||
break;
|
||||
case PlayerRepeat.ONE:
|
||||
handleRepeatOne[playerType]();
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
setCurrentTime(0);
|
||||
}, [
|
||||
checkIsLastTrack,
|
||||
next,
|
||||
pause,
|
||||
playerType,
|
||||
repeatStatus,
|
||||
resetPlayers,
|
||||
setCurrentIndex,
|
||||
setCurrentTime,
|
||||
]);
|
||||
|
||||
const handlePrevTrack = useCallback(() => {
|
||||
const currentTime = isMpvPlayer
|
||||
? usePlayerStore.getState().current.time
|
||||
: currentPlayerRef.getCurrentTime();
|
||||
|
||||
// Reset the current track more than 10 seconds have elapsed
|
||||
if (currentTime >= 10) {
|
||||
if (isMpvPlayer) {
|
||||
return mpvPlayer.seekTo(0);
|
||||
}
|
||||
return currentPlayerRef.seekTo(0);
|
||||
}
|
||||
|
||||
const isFirstTrack = checkIsFirstTrack();
|
||||
|
||||
const handleRepeatAll = {
|
||||
local: () => {
|
||||
if (!isFirstTrack) {
|
||||
const playerData = previous();
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.previous();
|
||||
} else {
|
||||
const playerData = setCurrentIndex(queue.length - 1);
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.previous();
|
||||
}
|
||||
},
|
||||
web: () => {
|
||||
if (isFirstTrack) {
|
||||
setCurrentIndex(queue.length - 1);
|
||||
resetPlayers();
|
||||
} else {
|
||||
previous();
|
||||
resetPlayers();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const handleRepeatNone = {
|
||||
local: () => {
|
||||
const playerData = previous();
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.previous();
|
||||
},
|
||||
web: () => {
|
||||
if (isFirstTrack) {
|
||||
resetPlayers();
|
||||
pause();
|
||||
} else {
|
||||
previous();
|
||||
resetPlayers();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const handleRepeatOne = {
|
||||
local: () => {
|
||||
if (!isFirstTrack) {
|
||||
const playerData = previous();
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.previous();
|
||||
} else {
|
||||
mpvPlayer.stop();
|
||||
}
|
||||
},
|
||||
web: () => {
|
||||
previous();
|
||||
resetPlayers();
|
||||
},
|
||||
};
|
||||
|
||||
switch (repeatStatus) {
|
||||
case PlayerRepeat.NONE:
|
||||
handleRepeatNone[playerType]();
|
||||
break;
|
||||
case PlayerRepeat.ALL:
|
||||
handleRepeatAll[playerType]();
|
||||
break;
|
||||
case PlayerRepeat.ONE:
|
||||
handleRepeatOne[playerType]();
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return setCurrentTime(0);
|
||||
}, [
|
||||
checkIsFirstTrack,
|
||||
currentPlayerRef,
|
||||
isMpvPlayer,
|
||||
pause,
|
||||
playerType,
|
||||
previous,
|
||||
queue.length,
|
||||
repeatStatus,
|
||||
resetPlayers,
|
||||
setCurrentIndex,
|
||||
setCurrentTime,
|
||||
]);
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (queue) {
|
||||
if (playerStatus === PlayerStatus.PAUSED) {
|
||||
return handlePlay();
|
||||
}
|
||||
|
||||
return handlePause();
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [handlePause, handlePlay, playerStatus, queue]);
|
||||
|
||||
const handleSkipBackward = (seconds: number) => {
|
||||
const currentTime = isMpvPlayer
|
||||
? usePlayerStore.getState().current.time
|
||||
: currentPlayerRef.getCurrentTime();
|
||||
|
||||
if (isMpvPlayer) {
|
||||
const newTime = currentTime - seconds;
|
||||
mpvPlayer.seek(-seconds);
|
||||
setCurrentTime(newTime < 0 ? 0 : newTime);
|
||||
} else {
|
||||
const newTime = currentTime - seconds;
|
||||
resetNextPlayer();
|
||||
setCurrentTime(newTime);
|
||||
currentPlayerRef.seekTo(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipForward = (seconds: number) => {
|
||||
const currentTime = isMpvPlayer
|
||||
? usePlayerStore.getState().current.time
|
||||
: currentPlayerRef.getCurrentTime();
|
||||
|
||||
if (isMpvPlayer) {
|
||||
const newTime = currentTime + seconds;
|
||||
mpvPlayer.seek(seconds);
|
||||
setCurrentTime(newTime);
|
||||
} else {
|
||||
const checkNewTime = currentTime + seconds;
|
||||
const songDuration = currentPlayerRef.player.player.duration;
|
||||
|
||||
const newTime = checkNewTime >= songDuration ? songDuration - 1 : checkNewTime;
|
||||
|
||||
resetNextPlayer();
|
||||
setCurrentTime(newTime);
|
||||
currentPlayerRef.seekTo(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeekSlider = useCallback(
|
||||
(e: number | any) => {
|
||||
setCurrentTime(e);
|
||||
|
||||
if (isMpvPlayer) {
|
||||
mpvPlayer.seekTo(e);
|
||||
} else {
|
||||
currentPlayerRef.seekTo(e);
|
||||
}
|
||||
},
|
||||
[currentPlayerRef, isMpvPlayer, setCurrentTime],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
mpvPlayerListener.rendererPlayPause(() => {
|
||||
handlePlayPause();
|
||||
});
|
||||
|
||||
mpvPlayerListener.rendererNext(() => {
|
||||
handleNextTrack();
|
||||
});
|
||||
|
||||
mpvPlayerListener.rendererPrevious(() => {
|
||||
handlePrevTrack();
|
||||
});
|
||||
|
||||
mpvPlayerListener.rendererPlay(() => {
|
||||
handlePlay();
|
||||
});
|
||||
|
||||
mpvPlayerListener.rendererPause(() => {
|
||||
handlePause();
|
||||
});
|
||||
|
||||
mpvPlayerListener.rendererStop(() => {
|
||||
handleStop();
|
||||
});
|
||||
|
||||
mpvPlayerListener.rendererCurrentTime((_event: any, time: number) => {
|
||||
setCurrentTime(time);
|
||||
});
|
||||
|
||||
mpvPlayerListener.rendererAutoNext(() => {
|
||||
handleAutoNext();
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
ipc?.removeAllListeners('renderer-player-play-pause');
|
||||
ipc?.removeAllListeners('renderer-player-next');
|
||||
ipc?.removeAllListeners('renderer-player-previous');
|
||||
ipc?.removeAllListeners('renderer-player-play');
|
||||
ipc?.removeAllListeners('renderer-player-pause');
|
||||
ipc?.removeAllListeners('renderer-player-stop');
|
||||
ipc?.removeAllListeners('renderer-player-current-time');
|
||||
ipc?.removeAllListeners('renderer-player-auto-next');
|
||||
};
|
||||
}, [
|
||||
autoNext,
|
||||
handleAutoNext,
|
||||
handleNextTrack,
|
||||
handlePause,
|
||||
handlePlay,
|
||||
handlePlayPause,
|
||||
handlePrevTrack,
|
||||
handleStop,
|
||||
isMpvPlayer,
|
||||
next,
|
||||
pause,
|
||||
play,
|
||||
previous,
|
||||
setCurrentTime,
|
||||
]);
|
||||
|
||||
return {
|
||||
handleNextTrack,
|
||||
handlePlayPause,
|
||||
handlePrevTrack,
|
||||
handleSeekSlider,
|
||||
handleSkipBackward,
|
||||
handleSkipForward,
|
||||
handleStop,
|
||||
handleToggleRepeat,
|
||||
handleToggleShuffle,
|
||||
};
|
||||
};
|
||||
44
src/renderer/features/player/hooks/use-right-controls.ts
Normal file
44
src/renderer/features/player/hooks/use-right-controls.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { useEffect } from 'react';
|
||||
import isElectron from 'is-electron';
|
||||
import { useMuted, usePlayerControls, useVolume } from '/@/renderer/store';
|
||||
|
||||
const mpvPlayer = window.electron.mpvPlayer;
|
||||
|
||||
export const useRightControls = () => {
|
||||
const { setVolume, setMuted } = usePlayerControls();
|
||||
const volume = useVolume();
|
||||
const muted = useMuted();
|
||||
|
||||
// Ensure that the mpv player volume is set on startup
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
mpvPlayer.volume(volume);
|
||||
|
||||
if (muted) {
|
||||
mpvPlayer.mute();
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleVolumeSlider = (e: number) => {
|
||||
mpvPlayer.volume(e);
|
||||
setVolume(e);
|
||||
};
|
||||
|
||||
const handleVolumeSliderState = (e: number) => {
|
||||
setVolume(e);
|
||||
};
|
||||
|
||||
const handleMute = () => {
|
||||
setMuted(!muted);
|
||||
mpvPlayer.mute();
|
||||
};
|
||||
|
||||
return {
|
||||
handleMute,
|
||||
handleVolumeSlider,
|
||||
handleVolumeSliderState,
|
||||
};
|
||||
};
|
||||
23
src/renderer/features/player/hooks/use-scrobble.ts
Normal file
23
src/renderer/features/player/hooks/use-scrobble.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { useState } from 'react';
|
||||
import { usePlayerStore } from '/@/renderer/store';
|
||||
|
||||
export const useScrobble = () => {
|
||||
const [isScrobbled, setIsScrobbled] = useState(false);
|
||||
|
||||
const currentSongDuration = usePlayerStore((state) => state.current.song?.duration);
|
||||
|
||||
const scrobbleAtPercentage = usePlayerStore((state) => state.settings.scrobbleAtPercentage);
|
||||
|
||||
console.log('currentSongDuration', currentSongDuration);
|
||||
|
||||
const scrobbleAtTime = (currentSongDuration * scrobbleAtPercentage) / 100;
|
||||
|
||||
console.log('scrobbleAtTime', scrobbleAtTime);
|
||||
|
||||
console.log('render');
|
||||
const handleScrobble = () => {
|
||||
console.log('scrobble complete');
|
||||
};
|
||||
|
||||
return { handleScrobble, isScrobbled, setIsScrobbled };
|
||||
};
|
||||
4
src/renderer/features/player/index.ts
Normal file
4
src/renderer/features/player/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './components/center-controls';
|
||||
export * from './components/left-controls';
|
||||
export * from './components/playerbar';
|
||||
export * from './components/slider';
|
||||
71
src/renderer/features/player/utils/handle-playqueue-add.ts
Normal file
71
src/renderer/features/player/utils/handle-playqueue-add.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { controller } from '/@/renderer/api/controller';
|
||||
import { jfNormalize } from '/@/renderer/api/jellyfin.api';
|
||||
import { JFSong } from '/@/renderer/api/jellyfin.types';
|
||||
import { ndNormalize } from '/@/renderer/api/navidrome.api';
|
||||
import { NDSong } from '/@/renderer/api/navidrome.types';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { toast } from '/@/renderer/components';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
import { useAuthStore, usePlayerStore } from '/@/renderer/store';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { PlayQueueAddOptions, LibraryItem, Play, PlaybackType } from '/@/renderer/types';
|
||||
|
||||
const mpvPlayer = window.electron.mpvPlayer;
|
||||
|
||||
export const handlePlayQueueAdd = async (options: PlayQueueAddOptions) => {
|
||||
const playerType = useSettingsStore.getState().player.type;
|
||||
const deviceId = useAuthStore.getState().deviceId;
|
||||
const server = useAuthStore.getState().currentServer;
|
||||
|
||||
if (!server) return toast.error({ message: 'No server selected' });
|
||||
|
||||
if (options.byItemType) {
|
||||
let songs = null;
|
||||
|
||||
if (options.byItemType.type === LibraryItem.ALBUM) {
|
||||
const albumDetail = await queryClient.fetchQuery(
|
||||
queryKeys.albums.detail(server?.id, { id: options.byItemType.id }),
|
||||
async ({ signal }) =>
|
||||
controller.getAlbumDetail({ query: { id: options.byItemType!.id }, server, signal }),
|
||||
);
|
||||
|
||||
if (!albumDetail) return null;
|
||||
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
songs = albumDetail.songs?.map((song) =>
|
||||
jfNormalize.song(song as JFSong, server, deviceId),
|
||||
);
|
||||
break;
|
||||
case 'navidrome':
|
||||
songs = albumDetail.songs?.map((song) =>
|
||||
ndNormalize.song(song as NDSong, server, deviceId),
|
||||
);
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!songs) return null;
|
||||
|
||||
const playerData = usePlayerStore.getState().actions.addToQueue(songs, options.play);
|
||||
|
||||
if (options.play === Play.NEXT || options.play === Play.LAST) {
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
mpvPlayer.setQueueNext(playerData);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.play === Play.NOW) {
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.play();
|
||||
}
|
||||
|
||||
usePlayerStore.getState().actions.play();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
141
src/renderer/features/servers/components/add-server-form.tsx
Normal file
141
src/renderer/features/servers/components/add-server-form.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { useState } from 'react';
|
||||
import { Stack, Group, Checkbox } from '@mantine/core';
|
||||
import { Button, PasswordInput, SegmentedControl, TextInput } from '/@/renderer/components';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useFocusTrap } from '@mantine/hooks';
|
||||
import { closeAllModals } from '@mantine/modals';
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
||||
import { navidromeApi } from '/@/renderer/api/navidrome.api';
|
||||
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
||||
import { AuthenticationResponse } from '/@/renderer/api/types';
|
||||
import { toast } from '/@/renderer/components';
|
||||
import { useAuthStoreActions } from '/@/renderer/store';
|
||||
import { ServerType } from '/@/renderer/types';
|
||||
|
||||
const SERVER_TYPES = [
|
||||
{ label: 'Jellyfin', value: ServerType.JELLYFIN },
|
||||
{ label: 'Navidrome', value: ServerType.NAVIDROME },
|
||||
{ label: 'Subsonic', value: ServerType.SUBSONIC },
|
||||
];
|
||||
|
||||
const AUTH_FUNCTIONS = {
|
||||
[ServerType.JELLYFIN]: jellyfinApi.authenticate,
|
||||
[ServerType.NAVIDROME]: navidromeApi.authenticate,
|
||||
[ServerType.SUBSONIC]: subsonicApi.authenticate,
|
||||
};
|
||||
|
||||
interface AddServerFormProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||
const focusTrapRef = useFocusTrap(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { addServer } = useAuthStoreActions();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
legacyAuth: false,
|
||||
name: '',
|
||||
password: '',
|
||||
type: ServerType.JELLYFIN,
|
||||
url: 'http://',
|
||||
username: '',
|
||||
},
|
||||
});
|
||||
|
||||
const isSubmitDisabled =
|
||||
!form.values.name || !form.values.url || !form.values.username || !form.values.password;
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
const authFunction = AUTH_FUNCTIONS[values.type];
|
||||
|
||||
if (!authFunction) {
|
||||
return toast.error({ message: 'Selected server type is invalid' });
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data: AuthenticationResponse = await authFunction(values.url, {
|
||||
legacy: values.legacyAuth,
|
||||
password: values.password,
|
||||
username: values.username,
|
||||
});
|
||||
|
||||
addServer({
|
||||
credential: data.credential,
|
||||
id: nanoid(),
|
||||
name: values.name,
|
||||
ndCredential: data.ndCredential,
|
||||
type: values.type,
|
||||
url: values.url,
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
});
|
||||
|
||||
toast.success({ message: 'Server added' });
|
||||
closeAllModals();
|
||||
} catch (err: any) {
|
||||
setIsLoading(false);
|
||||
return toast.error({ message: err?.message });
|
||||
}
|
||||
|
||||
return setIsLoading(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack
|
||||
ref={focusTrapRef}
|
||||
m={5}
|
||||
>
|
||||
<SegmentedControl
|
||||
data={SERVER_TYPES}
|
||||
{...form.getInputProps('type')}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
label="Name"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Url"
|
||||
{...form.getInputProps('url')}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Username"
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
{form.values.type === ServerType.SUBSONIC && (
|
||||
<Checkbox
|
||||
label="Enable legacy authentication"
|
||||
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
|
||||
/>
|
||||
)}
|
||||
<Group position="right">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isLoading}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
137
src/renderer/features/servers/components/edit-server-form.tsx
Normal file
137
src/renderer/features/servers/components/edit-server-form.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { useState } from 'react';
|
||||
import { Checkbox, Stack, Group } from '@mantine/core';
|
||||
import { Button, PasswordInput, TextInput, toast } from '/@/renderer/components';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { closeAllModals } from '@mantine/modals';
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
import { RiInformationLine } from 'react-icons/ri';
|
||||
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
||||
import { navidromeApi } from '/@/renderer/api/navidrome.api';
|
||||
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
||||
import { AuthenticationResponse } from '/@/renderer/api/types';
|
||||
import { useAuthStoreActions } from '/@/renderer/store';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
|
||||
interface EditServerFormProps {
|
||||
isUpdate?: boolean;
|
||||
onCancel: () => void;
|
||||
server: ServerListItem;
|
||||
}
|
||||
|
||||
const AUTH_FUNCTIONS = {
|
||||
[ServerType.JELLYFIN]: jellyfinApi.authenticate,
|
||||
[ServerType.NAVIDROME]: navidromeApi.authenticate,
|
||||
[ServerType.SUBSONIC]: subsonicApi.authenticate,
|
||||
};
|
||||
|
||||
export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormProps) => {
|
||||
const { updateServer, setCurrentServer } = useAuthStoreActions();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
legacyAuth: false,
|
||||
name: server?.name,
|
||||
password: '',
|
||||
type: server?.type,
|
||||
url: server?.url,
|
||||
username: server?.username,
|
||||
},
|
||||
});
|
||||
|
||||
const isSubsonic = form.values.type === ServerType.SUBSONIC;
|
||||
|
||||
const isSubmitDisabled =
|
||||
!form.values.name || !form.values.url || !form.values.username || !form.values.password;
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
const authFunction = AUTH_FUNCTIONS[values.type];
|
||||
|
||||
if (!authFunction) {
|
||||
return toast.error({ message: 'Selected server type is invalid' });
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data: AuthenticationResponse = await authFunction(values.url, {
|
||||
legacy: values.legacyAuth,
|
||||
password: values.password,
|
||||
username: values.username,
|
||||
});
|
||||
|
||||
const serverItem = {
|
||||
credential: data.credential,
|
||||
id: nanoid(),
|
||||
name: values.name,
|
||||
ndCredential: data.ndCredential,
|
||||
type: values.type,
|
||||
url: values.url,
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
};
|
||||
|
||||
updateServer(server.id, serverItem);
|
||||
setCurrentServer(serverItem);
|
||||
|
||||
toast.success({ message: 'Server updated' });
|
||||
} catch (err: any) {
|
||||
setIsLoading(false);
|
||||
return toast.error({ message: err?.message });
|
||||
}
|
||||
|
||||
if (isUpdate) closeAllModals();
|
||||
return setIsLoading(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
required
|
||||
label="Name"
|
||||
rightSection={form.isDirty('name') && <RiInformationLine color="red" />}
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label="Url"
|
||||
rightSection={form.isDirty('url') && <RiInformationLine color="red" />}
|
||||
{...form.getInputProps('url')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Username"
|
||||
rightSection={form.isDirty('username') && <RiInformationLine color="red" />}
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
{isSubsonic && (
|
||||
<Checkbox
|
||||
label="Enable legacy authentication"
|
||||
{...form.getInputProps('legacyAuth', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<Group position="right">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isLoading}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { Stack, Group, Divider } from '@mantine/core';
|
||||
import { Button, Text, TimeoutButton } from '/@/renderer/components';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { RiDeleteBin2Line, RiEdit2Fill } from 'react-icons/ri';
|
||||
import { EditServerForm } from '/@/renderer/features/servers/components/edit-server-form';
|
||||
import { ServerSection } from '/@/renderer/features/servers/components/server-section';
|
||||
import { useAuthStoreActions } from '/@/renderer/store';
|
||||
import { ServerListItem as ServerItem } from '/@/renderer/types';
|
||||
|
||||
interface ServerListItemProps {
|
||||
server: ServerItem;
|
||||
}
|
||||
|
||||
export const ServerListItem = ({ server }: ServerListItemProps) => {
|
||||
const [edit, editHandlers] = useDisclosure(false);
|
||||
const { deleteServer } = useAuthStoreActions();
|
||||
|
||||
const handleDeleteServer = () => {
|
||||
deleteServer(server.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
mt="1rem"
|
||||
p="1rem"
|
||||
spacing="xl"
|
||||
>
|
||||
<ServerSection
|
||||
title={
|
||||
<Group position="apart">
|
||||
<Text>Server details</Text>
|
||||
<Group spacing="md" />
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{edit ? (
|
||||
<EditServerForm
|
||||
server={server}
|
||||
onCancel={() => editHandlers.toggle()}
|
||||
/>
|
||||
) : (
|
||||
<Group position="apart">
|
||||
<Group>
|
||||
<Stack>
|
||||
<Text>URL</Text>
|
||||
<Text>Username</Text>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Text size="sm">{server.url}</Text>
|
||||
<Text size="sm">{server.username}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group>
|
||||
<Button
|
||||
tooltip={{ label: 'Edit server details' }}
|
||||
variant="subtle"
|
||||
onClick={() => editHandlers.toggle()}
|
||||
>
|
||||
<RiEdit2Fill />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
</ServerSection>
|
||||
<Divider my="xl" />
|
||||
<TimeoutButton
|
||||
leftIcon={<RiDeleteBin2Line />}
|
||||
timeoutProps={{ callback: handleDeleteServer, duration: 1500 }}
|
||||
variant="subtle"
|
||||
>
|
||||
Remove server
|
||||
</TimeoutButton>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
66
src/renderer/features/servers/components/server-list.tsx
Normal file
66
src/renderer/features/servers/components/server-list.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { Group } from '@mantine/core';
|
||||
import { Accordion, Button, ContextModalVars } from '/@/renderer/components';
|
||||
import { openContextModal } from '@mantine/modals';
|
||||
import { RiAddFill, RiServerFill } from 'react-icons/ri';
|
||||
import { AddServerForm } from '/@/renderer/features/servers/components/add-server-form';
|
||||
import { ServerListItem } from '/@/renderer/features/servers/components/server-list-item';
|
||||
import { useServerList } from '/@/renderer/store';
|
||||
import { titleCase } from '/@/renderer/utils';
|
||||
|
||||
export const ServerList = () => {
|
||||
const serverListQuery = useServerList();
|
||||
|
||||
const handleAddServerModal = () => {
|
||||
openContextModal({
|
||||
innerProps: {
|
||||
modalBody: (vars: ContextModalVars) => (
|
||||
<AddServerForm onCancel={() => vars.context.closeModal(vars.id)} />
|
||||
),
|
||||
},
|
||||
modal: 'base',
|
||||
title: 'Add server',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group
|
||||
mb={10}
|
||||
position="right"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 55,
|
||||
transform: 'translateY(-4rem)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
autoFocus
|
||||
compact
|
||||
leftIcon={<RiAddFill size={15} />}
|
||||
size="sm"
|
||||
variant="filled"
|
||||
onClick={handleAddServerModal}
|
||||
>
|
||||
Add server
|
||||
</Button>
|
||||
</Group>
|
||||
<Accordion variant="separated">
|
||||
{serverListQuery?.map((s) => (
|
||||
<Accordion.Item
|
||||
key={s.id}
|
||||
value={s.name}
|
||||
>
|
||||
<Accordion.Control icon={<RiServerFill size={15} />}>
|
||||
<Group position="apart">
|
||||
{titleCase(s.type)} - {s.name}
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<ServerListItem server={s} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
};
|
||||
24
src/renderer/features/servers/components/server-section.tsx
Normal file
24
src/renderer/features/servers/components/server-section.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Text } from '/@/renderer/components';
|
||||
|
||||
interface ServerSectionProps {
|
||||
children: React.ReactNode;
|
||||
title: string | React.ReactNode;
|
||||
}
|
||||
|
||||
const Container = styled.div``;
|
||||
|
||||
const Section = styled.div`
|
||||
padding: 1rem;
|
||||
border: 1px dashed var(--generic-border-color);
|
||||
`;
|
||||
|
||||
export const ServerSection = ({ title, children }: ServerSectionProps) => {
|
||||
return (
|
||||
<Container>
|
||||
{React.isValidElement(title) ? title : <Text>{title}</Text>}
|
||||
<Section>{children}</Section>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
2
src/renderer/features/servers/index.ts
Normal file
2
src/renderer/features/servers/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './components/add-server-form';
|
||||
export * from './components/server-list';
|
||||
258
src/renderer/features/settings/components/general-tab.tsx
Normal file
258
src/renderer/features/settings/components/general-tab.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { Divider, Stack } from '@mantine/core';
|
||||
import { Select, Switch } from '/@/renderer/components';
|
||||
import isElectron from 'is-electron';
|
||||
import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';
|
||||
import { THEME_DATA } from '/@/renderer/hooks';
|
||||
import {
|
||||
useGeneralSettings,
|
||||
useSettingsStoreActions,
|
||||
SideQueueType,
|
||||
} from '/@/renderer/store/settings.store';
|
||||
import { AppTheme } from '/@/renderer/themes/types';
|
||||
|
||||
const FONT_OPTIONS = [
|
||||
{ label: 'AnekTamil', value: 'AnekTamil' },
|
||||
{ label: 'Archivo', value: 'Archivo' },
|
||||
{ label: 'Circular STD', value: 'Circular STD' },
|
||||
{ label: 'Didact Gothic', value: 'Didact Gothic' },
|
||||
{ label: 'DM Sans', value: 'DM Sans' },
|
||||
{ label: 'Encode Sans', value: 'Encode Sans' },
|
||||
{ label: 'Epilogue', value: 'Epilogue' },
|
||||
{ label: 'Gotham', value: 'Gotham' },
|
||||
{ label: 'Inconsolata', value: 'Inconsolata' },
|
||||
{ label: 'Inter', value: 'Inter' },
|
||||
{ label: 'JetBrains Mono', value: 'JetBrainsMono' },
|
||||
{ label: 'Manrope', value: 'Manrope' },
|
||||
{ label: 'Montserrat', value: 'Montserrat' },
|
||||
{ label: 'Oxygen', value: 'Oxygen' },
|
||||
{ label: 'Poppins', value: 'Poppins' },
|
||||
{ label: 'Raleway', value: 'Raleway' },
|
||||
{ label: 'Roboto', value: 'Roboto' },
|
||||
{ label: 'Sora', value: 'Sora' },
|
||||
{ label: 'Work Sans', value: 'Work Sans' },
|
||||
];
|
||||
|
||||
const SIDE_QUEUE_OPTIONS = [
|
||||
{ label: 'Fixed', value: 'sideQueue' },
|
||||
{ label: 'Floating', value: 'sideDrawerQueue' },
|
||||
];
|
||||
|
||||
export const GeneralTab = () => {
|
||||
const settings = useGeneralSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
|
||||
const options = [
|
||||
{
|
||||
control: (
|
||||
<Select
|
||||
disabled
|
||||
data={['Windows', 'macOS']}
|
||||
defaultValue="Windows"
|
||||
/>
|
||||
),
|
||||
description: 'Adjust the style of the titlebar',
|
||||
isHidden: !isElectron(),
|
||||
title: 'Titlebar style',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Select
|
||||
disabled
|
||||
data={[]}
|
||||
/>
|
||||
),
|
||||
description: 'Sets the application language',
|
||||
isHidden: false,
|
||||
title: 'Language',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Select
|
||||
data={FONT_OPTIONS}
|
||||
defaultValue={settings.fontContent}
|
||||
onChange={(e) => {
|
||||
if (!e) return;
|
||||
setSettings({
|
||||
general: {
|
||||
...settings,
|
||||
fontContent: e,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: 'Sets the application content font',
|
||||
isHidden: false,
|
||||
title: 'Font (Content)',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Select
|
||||
data={FONT_OPTIONS}
|
||||
defaultValue={settings.fontHeader}
|
||||
onChange={(e) => {
|
||||
if (!e) return;
|
||||
setSettings({
|
||||
general: {
|
||||
...settings,
|
||||
fontHeader: e,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: 'Sets the application header font',
|
||||
isHidden: false,
|
||||
title: 'Font (Header)',
|
||||
},
|
||||
];
|
||||
|
||||
const themeOptions = [
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
defaultChecked={settings.followSystemTheme}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
general: {
|
||||
...settings,
|
||||
followSystemTheme: e.currentTarget.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: 'Follows the system-defined light or dark preference',
|
||||
isHidden: false,
|
||||
title: 'Use system theme',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Select
|
||||
data={THEME_DATA}
|
||||
defaultValue={settings.theme}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
general: {
|
||||
...settings,
|
||||
theme: e as AppTheme,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: 'Sets the default theme',
|
||||
isHidden: settings.followSystemTheme,
|
||||
title: 'Theme',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Select
|
||||
data={THEME_DATA}
|
||||
defaultValue={settings.themeDark}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
general: {
|
||||
...settings,
|
||||
themeDark: e as AppTheme,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: 'Sets the dark theme',
|
||||
isHidden: !settings.followSystemTheme,
|
||||
title: 'Theme (dark)',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Select
|
||||
data={THEME_DATA}
|
||||
defaultValue={settings.themeLight}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
general: {
|
||||
...settings,
|
||||
themeLight: e as AppTheme,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: 'Sets the light theme',
|
||||
isHidden: !settings.followSystemTheme,
|
||||
title: 'Theme (light)',
|
||||
},
|
||||
];
|
||||
|
||||
const layoutOptions = [
|
||||
{
|
||||
control: (
|
||||
<Select
|
||||
data={SIDE_QUEUE_OPTIONS}
|
||||
defaultValue={settings.sideQueueType}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
general: {
|
||||
...settings,
|
||||
sideQueueType: e as SideQueueType,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: 'The style of the sidebar play queue',
|
||||
isHidden: false,
|
||||
title: 'Side play queue style',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
defaultChecked={settings.showQueueDrawerButton}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
general: {
|
||||
...settings,
|
||||
showQueueDrawerButton: e.currentTarget.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: 'Display a hover icon on the right side of the application view the play queue',
|
||||
isHidden: false,
|
||||
title: 'Show floating queue hover area',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack spacing="xl">
|
||||
{options
|
||||
.filter((o) => !o.isHidden)
|
||||
.map((option) => (
|
||||
<SettingsOptions
|
||||
key={`general-${option.title}`}
|
||||
{...option}
|
||||
/>
|
||||
))}
|
||||
<Divider />
|
||||
{themeOptions
|
||||
.filter((o) => !o.isHidden)
|
||||
.map((option) => (
|
||||
<SettingsOptions
|
||||
key={`general-${option.title}`}
|
||||
{...option}
|
||||
/>
|
||||
))}
|
||||
<Divider />
|
||||
{layoutOptions
|
||||
.filter((o) => !o.isHidden)
|
||||
.map((option) => (
|
||||
<SettingsOptions
|
||||
key={`general-${option.title}`}
|
||||
{...option}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
383
src/renderer/features/settings/components/playback-tab.tsx
Normal file
383
src/renderer/features/settings/components/playback-tab.tsx
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Divider, Group, SelectItem, Stack } from '@mantine/core';
|
||||
import {
|
||||
FileInput,
|
||||
NumberInput,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Slider,
|
||||
Switch,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
toast,
|
||||
Text,
|
||||
} from '/@/renderer/components';
|
||||
import isElectron from 'is-electron';
|
||||
import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';
|
||||
import { useCurrentStatus, usePlayerStore } from '/@/renderer/store';
|
||||
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
||||
import { PlaybackType, PlayerStatus, PlaybackStyle, CrossfadeStyle, Play } from '/@/renderer/types';
|
||||
|
||||
const localSettings = window.electron.localSettings;
|
||||
const mpvPlayer = window.electron.mpvPlayer;
|
||||
|
||||
const getAudioDevice = async () => {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
return (devices || []).filter((dev: MediaDeviceInfo) => dev.kind === 'audiooutput');
|
||||
};
|
||||
|
||||
export const PlaybackTab = () => {
|
||||
const settings = useSettingsStore((state) => state.player);
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const status = useCurrentStatus();
|
||||
const [audioDevices, setAudioDevices] = useState<SelectItem[]>([]);
|
||||
const [mpvPath, setMpvPath] = useState('');
|
||||
const [mpvParameters, setMpvParameters] = useState('');
|
||||
|
||||
const handleSetMpvPath = (e: File) => {
|
||||
localSettings.set('mpv_path', e.path);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const getMpvPath = async () => {
|
||||
if (!isElectron()) return setMpvPath('');
|
||||
const mpvPath = (await localSettings.get('mpv_path')) as string;
|
||||
return setMpvPath(mpvPath);
|
||||
};
|
||||
|
||||
const getMpvParameters = async () => {
|
||||
if (!isElectron()) return setMpvPath('');
|
||||
const mpvParametersFromSettings = (await localSettings.get('mpv_parameters')) as string[];
|
||||
const mpvParameters = mpvParametersFromSettings?.join('\n');
|
||||
return setMpvParameters(mpvParameters);
|
||||
};
|
||||
|
||||
getMpvPath();
|
||||
getMpvParameters();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const getAudioDevices = () => {
|
||||
getAudioDevice()
|
||||
.then((dev) => setAudioDevices(dev.map((d) => ({ label: d.label, value: d.deviceId }))))
|
||||
.catch(() => toast.error({ message: 'Error fetching audio devices' }));
|
||||
};
|
||||
|
||||
getAudioDevices();
|
||||
}, []);
|
||||
|
||||
const playerOptions = [
|
||||
{
|
||||
control: (
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{
|
||||
disabled: !isElectron(),
|
||||
label: 'Mpv',
|
||||
value: PlaybackType.LOCAL,
|
||||
},
|
||||
{ label: 'Web', value: PlaybackType.WEB },
|
||||
]}
|
||||
defaultValue={settings.type}
|
||||
disabled={status === PlayerStatus.PLAYING}
|
||||
onChange={(e) => {
|
||||
setSettings({ player: { ...settings, type: e as PlaybackType } });
|
||||
if (isElectron() && e === PlaybackType.LOCAL) {
|
||||
const queueData = usePlayerStore.getState().actions.getPlayerData();
|
||||
mpvPlayer.setQueue(queueData);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: 'The audio player to use for playback',
|
||||
isHidden: !isElectron(),
|
||||
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
|
||||
title: 'Audio player',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<FileInput
|
||||
placeholder={mpvPath}
|
||||
size="sm"
|
||||
width={225}
|
||||
onChange={handleSetMpvPath}
|
||||
/>
|
||||
),
|
||||
description: 'The location of your mpv executable',
|
||||
isHidden: settings.type !== PlaybackType.LOCAL,
|
||||
note: 'Restart required',
|
||||
title: 'Mpv executable path',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Stack spacing="xs">
|
||||
<Textarea
|
||||
autosize
|
||||
defaultValue={mpvParameters}
|
||||
minRows={4}
|
||||
placeholder={'--gapless-playback=yes\n--prefetch-playlist=yes'}
|
||||
width={225}
|
||||
onBlur={(e) => {
|
||||
if (isElectron()) {
|
||||
localSettings.set('mpv_parameters', e.currentTarget.value.split('\n'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
),
|
||||
description: (
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary
|
||||
size="sm"
|
||||
>
|
||||
Options to pass to the player{' '}
|
||||
<a
|
||||
href="https://mpv.io/manual/stable/#audio"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
https://mpv.io/manual/stable/#audio
|
||||
</a>
|
||||
</Text>
|
||||
),
|
||||
isHidden: settings.type !== PlaybackType.LOCAL,
|
||||
note: 'Restart required',
|
||||
title: 'Mpv parameters',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Select
|
||||
clearable
|
||||
data={audioDevices}
|
||||
defaultValue={settings.audioDeviceId}
|
||||
disabled={settings.type !== PlaybackType.WEB}
|
||||
onChange={(e) => setSettings({ player: { ...settings, audioDeviceId: e } })}
|
||||
/>
|
||||
),
|
||||
description: 'The audio device to use for playback (web player only)',
|
||||
isHidden: !isElectron() || settings.type !== PlaybackType.WEB,
|
||||
title: 'Audio device',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{ label: 'Normal', value: PlaybackStyle.GAPLESS },
|
||||
{ label: 'Crossfade', value: PlaybackStyle.CROSSFADE },
|
||||
]}
|
||||
defaultValue={settings.style}
|
||||
disabled={settings.type !== PlaybackType.WEB || status === PlayerStatus.PLAYING}
|
||||
onChange={(e) => setSettings({ player: { ...settings, style: e as PlaybackStyle } })}
|
||||
/>
|
||||
),
|
||||
description: 'Adjust the playback style (web player only)',
|
||||
isHidden: settings.type !== PlaybackType.WEB,
|
||||
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
|
||||
title: 'Playback style',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Slider
|
||||
defaultValue={settings.crossfadeDuration}
|
||||
disabled={
|
||||
settings.type !== PlaybackType.WEB ||
|
||||
settings.style !== PlaybackStyle.CROSSFADE ||
|
||||
status === PlayerStatus.PLAYING
|
||||
}
|
||||
max={15}
|
||||
min={0}
|
||||
w={100}
|
||||
onChangeEnd={(e) => setSettings({ player: { ...settings, crossfadeDuration: e } })}
|
||||
/>
|
||||
),
|
||||
description: 'Adjust the crossfade duration (web player only)',
|
||||
isHidden: settings.type !== PlaybackType.WEB,
|
||||
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
|
||||
title: 'Crossfade Duration',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Select
|
||||
data={[
|
||||
{ label: 'Linear', value: CrossfadeStyle.LINEAR },
|
||||
{ label: 'Constant Power', value: CrossfadeStyle.CONSTANT_POWER },
|
||||
{
|
||||
label: 'Constant Power (Slow cut)',
|
||||
value: CrossfadeStyle.CONSTANT_POWER_SLOW_CUT,
|
||||
},
|
||||
{
|
||||
label: 'Constant Power (Slow fade)',
|
||||
value: CrossfadeStyle.CONSTANT_POWER_SLOW_FADE,
|
||||
},
|
||||
{ label: 'Dipped', value: CrossfadeStyle.DIPPED },
|
||||
{ label: 'Equal Power', value: CrossfadeStyle.EQUALPOWER },
|
||||
]}
|
||||
defaultValue={settings.crossfadeStyle}
|
||||
disabled={
|
||||
settings.type !== PlaybackType.WEB ||
|
||||
settings.style !== PlaybackStyle.CROSSFADE ||
|
||||
status === PlayerStatus.PLAYING
|
||||
}
|
||||
width={200}
|
||||
onChange={(e) => {
|
||||
if (!e) return;
|
||||
setSettings({
|
||||
player: { ...settings, crossfadeStyle: e as CrossfadeStyle },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: 'Change the crossfade algorithm (web player only)',
|
||||
isHidden: settings.type !== PlaybackType.WEB,
|
||||
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
|
||||
title: 'Crossfade Style',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
aria-label="Toggle global media hotkeys"
|
||||
defaultChecked={settings.globalMediaHotkeys}
|
||||
disabled={!isElectron()}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
player: {
|
||||
...settings,
|
||||
globalMediaHotkeys: e.currentTarget.checked,
|
||||
},
|
||||
});
|
||||
localSettings.set('global_media_hotkeys', e.currentTarget.checked);
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
localSettings.enableMediaKeys();
|
||||
} else {
|
||||
localSettings.disableMediaKeys();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description:
|
||||
'Enable or disable the usage of your system media hotkeys to control the audio player (desktop only)',
|
||||
isHidden: !isElectron(),
|
||||
title: 'Global media hotkeys',
|
||||
},
|
||||
];
|
||||
|
||||
const otherOptions = [
|
||||
{
|
||||
control: (
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{ label: 'Now', value: Play.NOW },
|
||||
{ label: 'Next', value: Play.NEXT },
|
||||
{ label: 'Last', value: Play.LAST },
|
||||
]}
|
||||
defaultValue={settings.playButtonBehavior}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
player: {
|
||||
...settings,
|
||||
playButtonBehavior: e as Play,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
description: 'The default behavior of the play button when adding songs to the queue',
|
||||
isHidden: false,
|
||||
title: 'Play button behavior',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
aria-label="Toggle skip buttons"
|
||||
defaultChecked={settings.skipButtons?.enabled}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
player: {
|
||||
...settings,
|
||||
skipButtons: {
|
||||
...settings.skipButtons,
|
||||
enabled: e.currentTarget.checked,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
description: 'Show or hide the skip buttons on the playerbar',
|
||||
isHidden: false,
|
||||
title: 'Show skip buttons',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Group>
|
||||
<Tooltip label="Backward">
|
||||
<NumberInput
|
||||
defaultValue={settings.skipButtons.skipBackwardSeconds}
|
||||
min={0}
|
||||
width={75}
|
||||
onBlur={(e) =>
|
||||
setSettings({
|
||||
player: {
|
||||
...settings,
|
||||
skipButtons: {
|
||||
...settings.skipButtons,
|
||||
skipBackwardSeconds: e.currentTarget.value
|
||||
? Number(e.currentTarget.value)
|
||||
: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Forward">
|
||||
<NumberInput
|
||||
defaultValue={settings.skipButtons.skipForwardSeconds}
|
||||
min={0}
|
||||
width={75}
|
||||
onBlur={(e) =>
|
||||
setSettings({
|
||||
player: {
|
||||
...settings,
|
||||
skipButtons: {
|
||||
...settings.skipButtons,
|
||||
skipForwardSeconds: e.currentTarget.value ? Number(e.currentTarget.value) : 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
),
|
||||
description:
|
||||
'The number (in seconds) to skip forward or backward when using the skip buttons',
|
||||
isHidden: false,
|
||||
title: 'Skip duration',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack spacing="xl">
|
||||
{playerOptions
|
||||
.filter((o) => !o.isHidden)
|
||||
.map((option) => (
|
||||
<SettingsOptions
|
||||
key={`playback-${option.title}`}
|
||||
{...option}
|
||||
/>
|
||||
))}
|
||||
<Divider />
|
||||
{otherOptions
|
||||
.filter((o) => !o.isHidden)
|
||||
.map((option) => (
|
||||
<SettingsOptions
|
||||
key={`playerbar-${option.title}`}
|
||||
{...option}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import React from 'react';
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import { RiInformationLine } from 'react-icons/ri';
|
||||
import { Text, Tooltip } from '/@/renderer/components';
|
||||
|
||||
interface SettingsOptionProps {
|
||||
control: React.ReactNode;
|
||||
description?: React.ReactNode | string;
|
||||
note?: string;
|
||||
title: React.ReactNode | string;
|
||||
}
|
||||
|
||||
export const SettingsOptions = ({ title, description, control, note }: SettingsOptionProps) => {
|
||||
return (
|
||||
<>
|
||||
<Group
|
||||
noWrap
|
||||
position="apart"
|
||||
sx={{ alignItems: 'center' }}
|
||||
>
|
||||
<Stack
|
||||
spacing="xs"
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
display: 'flex',
|
||||
maxWidth: '50%',
|
||||
}}
|
||||
>
|
||||
<Group>
|
||||
<Text
|
||||
$noSelect
|
||||
size="sm"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{note && (
|
||||
<Tooltip
|
||||
label={note}
|
||||
openDelay={0}
|
||||
>
|
||||
<Group>
|
||||
<RiInformationLine size={15} />
|
||||
</Group>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
{React.isValidElement(description) ? (
|
||||
description
|
||||
) : (
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary
|
||||
size="sm"
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Group position="right">{control}</Group>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
SettingsOptions.defaultProps = {
|
||||
description: undefined,
|
||||
note: undefined,
|
||||
};
|
||||
72
src/renderer/features/settings/components/settings.tsx
Normal file
72
src/renderer/features/settings/components/settings.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Box } from '@mantine/core';
|
||||
import { Tabs } from '/@/renderer/components';
|
||||
import type { Variants } from 'framer-motion';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { GeneralTab } from '/@/renderer/features/settings/components/general-tab';
|
||||
import { PlaybackTab } from '/@/renderer/features/settings/components/playback-tab';
|
||||
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
||||
|
||||
export const Settings = () => {
|
||||
const currentTab = useSettingsStore((state) => state.tab);
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
|
||||
const tabVariants: Variants = {
|
||||
in: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
x: 0,
|
||||
},
|
||||
out: {
|
||||
opacity: 0,
|
||||
x: 50,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
m={5}
|
||||
sx={{ height: '800px', maxHeight: '50vh', overflowX: 'hidden' }}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
<Tabs
|
||||
keepMounted={false}
|
||||
orientation="vertical"
|
||||
styles={{
|
||||
tab: {
|
||||
fontSize: '1.1rem',
|
||||
padding: '0.5rem 1rem',
|
||||
},
|
||||
}}
|
||||
value={currentTab}
|
||||
variant="pills"
|
||||
onTabChange={(e) => e && setSettings({ tab: e })}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="general">General</Tabs.Tab>
|
||||
<Tabs.Tab value="playback">Playback</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="general">
|
||||
<motion.div
|
||||
animate="in"
|
||||
initial="out"
|
||||
variants={tabVariants}
|
||||
>
|
||||
<GeneralTab />
|
||||
</motion.div>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="playback">
|
||||
<motion.div
|
||||
animate="in"
|
||||
initial="out"
|
||||
variants={tabVariants}
|
||||
>
|
||||
<PlaybackTab />
|
||||
</motion.div>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
1
src/renderer/features/settings/index.ts
Normal file
1
src/renderer/features/settings/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './components/settings';
|
||||
40
src/renderer/features/shared/components/animated-page.tsx
Normal file
40
src/renderer/features/shared/components/animated-page.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { ReactNode, Ref } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface AnimatedPageProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const StyledAnimatedPage = styled(motion.div)`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const variants = {
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
initial: { opacity: 0 },
|
||||
};
|
||||
|
||||
export const AnimatedPage = forwardRef(
|
||||
({ children }: AnimatedPageProps, ref: Ref<HTMLDivElement>) => {
|
||||
return (
|
||||
<StyledAnimatedPage
|
||||
ref={ref}
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
transition={{ duration: 0.2, type: 'tween' }}
|
||||
variants={variants}
|
||||
>
|
||||
{children}
|
||||
</StyledAnimatedPage>
|
||||
);
|
||||
},
|
||||
);
|
||||
1
src/renderer/features/shared/index.ts
Normal file
1
src/renderer/features/shared/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './components/animated-page';
|
||||
79
src/renderer/features/sidebar/components/sidebar-item.tsx
Normal file
79
src/renderer/features/sidebar/components/sidebar-item.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import type { LinkProps } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
interface ListItemProps {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
const StyledItem = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
font-family: var(--content-font-family);
|
||||
|
||||
&:focus-visible {
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
`;
|
||||
|
||||
const ItemStyle = css`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--sidebar-btn-color);
|
||||
font-family: var(--content-font-family);
|
||||
border: 1px transparent solid;
|
||||
transition: color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
color: var(--sidebar-btn-color-hover);
|
||||
}
|
||||
`;
|
||||
|
||||
const Box = styled.div`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
const ItemLink = styled(Link)<LinkProps & { disabled?: boolean }>`
|
||||
opacity: ${(props) => props.disabled && 0.6};
|
||||
pointer-events: ${(props) => props.disabled && 'none'};
|
||||
|
||||
&:focus-visible {
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
export const SidebarItem = ({ to, children, ...rest }: ListItemProps) => {
|
||||
if (to) {
|
||||
return (
|
||||
<ItemLink
|
||||
to={to}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</ItemLink>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StyledItem
|
||||
tabIndex={0}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</StyledItem>
|
||||
);
|
||||
};
|
||||
|
||||
SidebarItem.Box = Box;
|
||||
|
||||
SidebarItem.Link = ItemLink;
|
||||
|
||||
SidebarItem.defaultProps = {
|
||||
disabled: false,
|
||||
to: undefined,
|
||||
};
|
||||
269
src/renderer/features/sidebar/components/sidebar.tsx
Normal file
269
src/renderer/features/sidebar/components/sidebar.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import { Stack, Group, Grid, Accordion, Center } from '@mantine/core';
|
||||
import { SpotlightProvider } from '@mantine/spotlight';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { BsCollection } from 'react-icons/bs';
|
||||
import { Button, TextInput } from '/@/renderer/components';
|
||||
import {
|
||||
RiAlbumLine,
|
||||
RiArrowDownSLine,
|
||||
RiArrowLeftSLine,
|
||||
RiArrowRightSLine,
|
||||
RiDatabaseLine,
|
||||
RiDiscLine,
|
||||
RiEyeLine,
|
||||
RiFolder3Line,
|
||||
RiHome5Line,
|
||||
RiMusicLine,
|
||||
RiPlayListLine,
|
||||
RiSearchLine,
|
||||
RiUserVoiceLine,
|
||||
} from 'react-icons/ri';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useSidebarStore, useAppStoreActions, useCurrentSong } from '/@/renderer/store';
|
||||
import { fadeIn } from '/@/renderer/styles';
|
||||
|
||||
const SidebarContainer = styled.div`
|
||||
height: 100%;
|
||||
max-height: calc(100vh - 85px); // Account for and playerbar
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const ImageContainer = styled(motion(Link))<{ height: string }>`
|
||||
position: relative;
|
||||
height: ${(props) => props.height};
|
||||
|
||||
${fadeIn};
|
||||
animation: fadein 0.2s ease-in-out;
|
||||
|
||||
button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover button {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
const SidebarImage = styled.img`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background: var(--placeholder-bg);
|
||||
`;
|
||||
|
||||
const ActionsContainer = styled(Grid)`
|
||||
-webkit-app-region: drag;
|
||||
`;
|
||||
|
||||
export const Sidebar = () => {
|
||||
const navigate = useNavigate();
|
||||
const sidebar = useSidebarStore();
|
||||
const { setSidebar } = useAppStoreActions();
|
||||
const imageUrl = useCurrentSong()?.imageUrl;
|
||||
|
||||
const showImage = sidebar.image;
|
||||
|
||||
return (
|
||||
<SidebarContainer>
|
||||
<Stack
|
||||
justify="space-between"
|
||||
spacing={0}
|
||||
sx={{ height: '100%' }}
|
||||
>
|
||||
<Stack
|
||||
sx={{
|
||||
maxHeight: showImage ? `calc(100% - ${sidebar.leftWidth})` : '100%',
|
||||
}}
|
||||
>
|
||||
<ActionsContainer p={10}>
|
||||
<Grid.Col span={8}>
|
||||
<SpotlightProvider actions={[]}>
|
||||
<TextInput
|
||||
disabled
|
||||
readOnly
|
||||
icon={<RiSearchLine />}
|
||||
placeholder="Search"
|
||||
rightSectionWidth={90}
|
||||
// onClick={() => openSpotlight()}
|
||||
/>
|
||||
</SpotlightProvider>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={4}>
|
||||
<Group
|
||||
grow
|
||||
spacing={5}
|
||||
>
|
||||
<Button
|
||||
px={5}
|
||||
sx={{ color: 'var(--titlebar-fg)' }}
|
||||
variant="default"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<RiArrowLeftSLine size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
px={5}
|
||||
sx={{ color: 'var(--titlebar-fg)' }}
|
||||
variant="default"
|
||||
onClick={() => navigate(1)}
|
||||
>
|
||||
<RiArrowRightSLine size={20} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</ActionsContainer>
|
||||
<Stack
|
||||
spacing={0}
|
||||
sx={{ overflowY: 'auto' }}
|
||||
>
|
||||
<SidebarItem to={AppRoute.HOME}>
|
||||
<Group>
|
||||
<RiHome5Line size={15} />
|
||||
Home
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
<SidebarItem>
|
||||
<SidebarItem.Link
|
||||
disabled
|
||||
to={AppRoute.EXPLORE}
|
||||
>
|
||||
<Group>
|
||||
<RiEyeLine />
|
||||
Explore
|
||||
</Group>
|
||||
</SidebarItem.Link>
|
||||
</SidebarItem>
|
||||
<Accordion
|
||||
multiple
|
||||
styles={{
|
||||
item: { borderBottom: 'none' },
|
||||
panel: {
|
||||
borderLeft: '1px solid rgba(100,100,100,.5)',
|
||||
marginLeft: '1.5rem',
|
||||
},
|
||||
}}
|
||||
value={sidebar.expanded}
|
||||
onChange={(e) => setSidebar({ expanded: e })}
|
||||
>
|
||||
<Accordion.Item value="library">
|
||||
<Accordion.Control p="1rem">
|
||||
<Group>
|
||||
<RiDatabaseLine size={15} />
|
||||
Library
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<SidebarItem to={AppRoute.LIBRARY_ALBUMS}>
|
||||
<Group>
|
||||
<RiAlbumLine />
|
||||
Albums
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
<SidebarItem to={AppRoute.LIBRARY_SONGS}>
|
||||
<Group>
|
||||
<RiMusicLine />
|
||||
Tracks
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
<SidebarItem
|
||||
disabled
|
||||
to={AppRoute.LIBRARY_ALBUMARTISTS}
|
||||
>
|
||||
<Group>
|
||||
<RiUserVoiceLine />
|
||||
Artists
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
<SidebarItem
|
||||
disabled
|
||||
to={AppRoute.LIBRARY_FOLDERS}
|
||||
>
|
||||
<Group>
|
||||
<RiFolder3Line />
|
||||
Folders
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="collections">
|
||||
<Accordion.Control
|
||||
disabled
|
||||
p="1rem"
|
||||
>
|
||||
<Group>
|
||||
<BsCollection size={15} />
|
||||
Collections
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel />
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="playlists">
|
||||
<Accordion.Control
|
||||
disabled
|
||||
p="1rem"
|
||||
>
|
||||
<Group>
|
||||
<RiPlayListLine size={15} />
|
||||
Playlists
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel />
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{showImage && (
|
||||
<ImageContainer
|
||||
key="sidebar-image"
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
height={sidebar.leftWidth}
|
||||
initial={{ opacity: 0, y: 200 }}
|
||||
to={AppRoute.NOW_PLAYING}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<SidebarImage
|
||||
loading="eager"
|
||||
src={imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Center sx={{ background: 'var(--placeholder-bg)', height: '100%' }}>
|
||||
<RiDiscLine
|
||||
color="var(--placeholder-fg)"
|
||||
size={50}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
<Button
|
||||
compact
|
||||
opacity={0.8}
|
||||
radius={100}
|
||||
size="sm"
|
||||
sx={{ position: 'absolute', right: 5, top: 5 }}
|
||||
tooltip={{ label: 'Collapse' }}
|
||||
variant="default"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSidebar({ image: false });
|
||||
}}
|
||||
>
|
||||
<RiArrowDownSLine
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</Button>
|
||||
</ImageContainer>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Stack>
|
||||
</SidebarContainer>
|
||||
);
|
||||
};
|
||||
261
src/renderer/features/songs/components/song-list-header.tsx
Normal file
261
src/renderer/features/songs/components/song-list-header.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import type { MouseEvent } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { Group } from '@mantine/core';
|
||||
import { Button, Slider, PageHeader, DropdownMenu } from '/@/renderer/components';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { RiArrowDownSLine } from 'react-icons/ri';
|
||||
import { SongListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import { useCurrentServer, useAppStoreActions, useSongRouteStore } from '/@/renderer/store';
|
||||
import { CardDisplayType } from '/@/renderer/types';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
{ name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
|
||||
{ name: 'Artist', value: SongListSort.ARTIST },
|
||||
{ name: 'Duration', value: SongListSort.DURATION },
|
||||
{ name: 'Name', value: SongListSort.NAME },
|
||||
{ name: 'Name', value: SongListSort.PLAY_COUNT },
|
||||
{ name: 'Random', value: SongListSort.RANDOM },
|
||||
{ name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
|
||||
{ name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
|
||||
{ name: 'Release Date', value: SongListSort.RELEASE_DATE },
|
||||
],
|
||||
navidrome: [
|
||||
{ name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
|
||||
{ name: 'Artist', value: SongListSort.ARTIST },
|
||||
{ name: 'BPM', value: SongListSort.BPM },
|
||||
{ name: 'Channels', value: SongListSort.CHANNELS },
|
||||
{ name: 'Comment', value: SongListSort.COMMENT },
|
||||
{ name: 'Duration', value: SongListSort.DURATION },
|
||||
{ name: 'Favorited', value: SongListSort.FAVORITED },
|
||||
{ name: 'Genre', value: SongListSort.GENRE },
|
||||
{ name: 'Name', value: SongListSort.NAME },
|
||||
{ name: 'Play Count', value: SongListSort.PLAY_COUNT },
|
||||
{ name: 'Rating', value: SongListSort.RATING },
|
||||
{ name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
|
||||
{ name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
|
||||
{ name: 'Year', value: SongListSort.YEAR },
|
||||
],
|
||||
};
|
||||
|
||||
const ORDER = [
|
||||
{ name: 'Ascending', value: SortOrder.ASC },
|
||||
{ name: 'Descending', value: SortOrder.DESC },
|
||||
];
|
||||
|
||||
export const SongListHeader = () => {
|
||||
const server = useCurrentServer();
|
||||
const { setPage } = useAppStoreActions();
|
||||
const page = useSongRouteStore();
|
||||
const filters = page.list.filter;
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
(FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[]).find(
|
||||
(f) => f.value === filters.sortBy,
|
||||
)?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const sortOrderLabel = ORDER.find((s) => s.value === filters.sortOrder)?.name;
|
||||
|
||||
const setSize = throttle(
|
||||
(e: number) =>
|
||||
setPage('songs', {
|
||||
...page,
|
||||
list: { ...page.list, size: e },
|
||||
}),
|
||||
200,
|
||||
);
|
||||
|
||||
const handleSetFilter = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
setPage('songs', {
|
||||
list: {
|
||||
...page.list,
|
||||
filter: {
|
||||
...page.list.filter,
|
||||
sortBy: e.currentTarget.value as SongListSort,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[page.list, setPage],
|
||||
);
|
||||
|
||||
const handleSetOrder = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
setPage('songs', {
|
||||
list: {
|
||||
...page.list,
|
||||
filter: {
|
||||
...page.list.filter,
|
||||
sortOrder: e.currentTarget.value as SortOrder,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[page.list, setPage],
|
||||
);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
const type = e.currentTarget.value;
|
||||
if (type === CardDisplayType.CARD) {
|
||||
setPage('songs', {
|
||||
...page,
|
||||
list: {
|
||||
...page.list,
|
||||
display: CardDisplayType.CARD,
|
||||
type: 'grid',
|
||||
},
|
||||
});
|
||||
} else if (type === CardDisplayType.POSTER) {
|
||||
setPage('songs', {
|
||||
...page,
|
||||
list: {
|
||||
...page.list,
|
||||
display: CardDisplayType.POSTER,
|
||||
type: 'grid',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setPage('songs', {
|
||||
...page,
|
||||
list: {
|
||||
...page.list,
|
||||
type: 'list',
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[page, setPage],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageHeader>
|
||||
<Group>
|
||||
<DropdownMenu
|
||||
position="bottom-end"
|
||||
width={100}
|
||||
>
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
rightIcon={<RiArrowDownSLine size={15} />}
|
||||
size="xl"
|
||||
sx={{ paddingLeft: 0, paddingRight: 0 }}
|
||||
variant="subtle"
|
||||
>
|
||||
Tracks
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item>
|
||||
<Slider
|
||||
defaultValue={page.list?.size || 0}
|
||||
label={null}
|
||||
onChange={setSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.list.type === 'grid' && page.list.display === CardDisplayType.CARD}
|
||||
value={CardDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Card
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.list.type === 'grid' && page.list.display === CardDisplayType.POSTER}
|
||||
value={CardDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Poster
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled
|
||||
$isActive={page.list.type === 'list'}
|
||||
value="list"
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
List
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="normal"
|
||||
variant="subtle"
|
||||
>
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
|
||||
<DropdownMenu.Item
|
||||
key={`filter-${filter.name}`}
|
||||
$isActive={filter.value === filters.sortBy}
|
||||
value={filter.value}
|
||||
onClick={handleSetFilter}
|
||||
>
|
||||
{filter.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="normal"
|
||||
variant="subtle"
|
||||
>
|
||||
{sortOrderLabel}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{ORDER.map((sort) => (
|
||||
<DropdownMenu.Item
|
||||
key={`sort-${sort.value}`}
|
||||
$isActive={sort.value === filters.sortOrder}
|
||||
value={sort.value}
|
||||
onClick={handleSetOrder}
|
||||
>
|
||||
{sort.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="normal"
|
||||
variant="subtle"
|
||||
>
|
||||
Folder
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
{/* <DropdownMenu.Dropdown>
|
||||
{serverFolders?.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
key={folder.id}
|
||||
$isActive={filters.serverFolderId.includes(folder.id)}
|
||||
closeMenuOnClick={false}
|
||||
value={folder.id}
|
||||
onClick={handleSetServerFolder}
|
||||
>
|
||||
{folder.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown> */}
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
</PageHeader>
|
||||
);
|
||||
};
|
||||
1
src/renderer/features/songs/index.ts
Normal file
1
src/renderer/features/songs/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './routes/song-list-route';
|
||||
23
src/renderer/features/songs/queries/song-list-query.ts
Normal file
23
src/renderer/features/songs/queries/song-list-query.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { RawSongListResponse, SongListQuery } from '/@/renderer/api/types';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||
|
||||
export const useSongList = (query: SongListQuery, options?: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
|
||||
return useQuery({
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => controller.getSongList({ query, server, signal }),
|
||||
queryKey: queryKeys.songs.list(server?.id || '', query),
|
||||
select: useCallback(
|
||||
(data: RawSongListResponse | undefined) => api.normalize.songList(data, server),
|
||||
[server],
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
116
src/renderer/features/songs/routes/song-list-route.tsx
Normal file
116
src/renderer/features/songs/routes/song-list-route.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
VirtualGridContainer,
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualTable,
|
||||
getColumnDefs,
|
||||
} from '/@/renderer/components';
|
||||
import type { ColDef, GridReadyEvent, IDatasource } from '@ag-grid-community/core';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { useTableSettings } from '/@/renderer/store/settings.store';
|
||||
import { SongListHeader } from '/@/renderer/features/songs/components/song-list-header';
|
||||
import { useSongList } from '/@/renderer/features/songs/queries/song-list-query';
|
||||
import { SongListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { useCurrentServer, useSongRouteStore } from '/@/renderer/store';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
const TrackListRoute = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const page = useSongRouteStore();
|
||||
const filters = page.list.filter;
|
||||
const tableConfig = useTableSettings('songs');
|
||||
|
||||
const checkSongList = useSongList({
|
||||
limit: 1,
|
||||
sortBy: SongListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
});
|
||||
|
||||
const columnDefs = useMemo(() => getColumnDefs(tableConfig.columns), [tableConfig.columns]);
|
||||
const defaultColumnDefs: ColDef = useMemo(() => {
|
||||
return {
|
||||
lockPinned: true,
|
||||
lockVisible: true,
|
||||
resizable: true,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onGridReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
console.log(`asking for ${params.startRow} to ${params.endRow}`);
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
});
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
controller.getSongList({
|
||||
query: {
|
||||
limit,
|
||||
sortBy: filters.sortBy,
|
||||
sortOrder: filters.sortOrder,
|
||||
startIndex,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
|
||||
const songs = api.normalize.songList(songsRes, server);
|
||||
|
||||
console.log('songs :>> ', songs);
|
||||
params.successCallback(songs?.items || [], -1);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
},
|
||||
[filters, queryClient, server],
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<VirtualGridContainer>
|
||||
<SongListHeader />
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<VirtualTable
|
||||
alwaysShowHorizontalScroll
|
||||
animateRows
|
||||
maintainColumnOrder
|
||||
suppressCopyRowsToClipboard
|
||||
suppressMoveWhenRowDragging
|
||||
suppressRowDrag
|
||||
suppressScrollOnNewData
|
||||
cacheBlockSize={500}
|
||||
cacheOverflowSize={0}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColumnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
infiniteInitialRowCount={checkSongList.data?.totalRecordCount}
|
||||
maxConcurrentDatasourceRequests={3}
|
||||
rowBuffer={20}
|
||||
// rowData={queue}
|
||||
rowHeight={tableConfig.rowHeight || 40}
|
||||
rowModelType="infinite"
|
||||
rowSelection="multiple"
|
||||
onGridReady={onGridReady}
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
</VirtualGridContainer>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrackListRoute;
|
||||
131
src/renderer/features/titlebar/components/app-menu.tsx
Normal file
131
src/renderer/features/titlebar/components/app-menu.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { Button, Group } from '@mantine/core';
|
||||
import { openModal, closeAllModals } from '@mantine/modals';
|
||||
import {
|
||||
RiSearch2Line,
|
||||
RiSettings2Fill,
|
||||
RiSettings2Line,
|
||||
RiEdit2Line,
|
||||
RiLockLine,
|
||||
RiMenuFill,
|
||||
} from 'react-icons/ri';
|
||||
import { DropdownMenu, Text } from '/@/renderer/components';
|
||||
import { ServerList } from '/@/renderer/features/servers';
|
||||
import { EditServerForm } from '/@/renderer/features/servers/components/edit-server-form';
|
||||
import { Settings } from '/@/renderer/features/settings';
|
||||
import { useCurrentServer, useServerList, useAuthStoreActions } from '/@/renderer/store';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
|
||||
export const AppMenu = () => {
|
||||
const currentServer = useCurrentServer();
|
||||
const serverList = useServerList();
|
||||
const { setCurrentServer } = useAuthStoreActions();
|
||||
|
||||
const handleSetCurrentServer = (server: ServerListItem) => {
|
||||
setCurrentServer(server);
|
||||
};
|
||||
|
||||
const handleCredentialsModal = (server: ServerListItem) => {
|
||||
openModal({
|
||||
children: server && (
|
||||
<EditServerForm
|
||||
isUpdate
|
||||
server={server}
|
||||
onCancel={closeAllModals}
|
||||
/>
|
||||
),
|
||||
size: 'sm',
|
||||
title: `Update session for "${server.name}"`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleManageServersModal = () => {
|
||||
openModal({
|
||||
children: <ServerList />,
|
||||
title: 'Manage Servers',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSettingsModal = () => {
|
||||
openModal({
|
||||
children: <Settings />,
|
||||
size: 'xl',
|
||||
title: (
|
||||
<Group position="center">
|
||||
<RiSettings2Fill size={20} />
|
||||
<Text>Settings</Text>
|
||||
</Group>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
withArrow
|
||||
withinPortal
|
||||
position="bottom"
|
||||
width={200}
|
||||
>
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
px={5}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMenuFill
|
||||
color="var(--titlebar-fg)"
|
||||
size={15}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Select a server</DropdownMenu.Label>
|
||||
{serverList.map((s) => {
|
||||
const isNavidromeExpired = s.type === ServerType.NAVIDROME && !s.ndCredential;
|
||||
const isJellyfinExpired = false;
|
||||
const isSessionExpired = isNavidromeExpired || isJellyfinExpired;
|
||||
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
key={`server-${s.id}`}
|
||||
$isActive={s.id === currentServer?.id}
|
||||
icon={
|
||||
isSessionExpired && (
|
||||
<RiLockLine
|
||||
color="var(--danger-color)"
|
||||
size={12}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (!isSessionExpired) return handleSetCurrentServer(s);
|
||||
return handleCredentialsModal(s);
|
||||
}}
|
||||
>
|
||||
<Group>{s.name}</Group>
|
||||
</DropdownMenu.Item>
|
||||
);
|
||||
})}
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
disabled
|
||||
rightSection={<RiSearch2Line />}
|
||||
>
|
||||
Search
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
rightSection={<RiSettings2Line />}
|
||||
onClick={handleSettingsModal}
|
||||
>
|
||||
Settings
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
rightSection={<RiEdit2Line />}
|
||||
onClick={handleManageServersModal}
|
||||
>
|
||||
Manage servers
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
68
src/renderer/features/titlebar/components/titlebar.tsx
Normal file
68
src/renderer/features/titlebar/components/titlebar.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { Group } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
import { WindowControls } from '../../window-controls';
|
||||
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
|
||||
|
||||
interface TitlebarProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const TitlebarContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--titlebar-fg);
|
||||
|
||||
button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
`;
|
||||
|
||||
// const Left = styled.div`
|
||||
// display: flex;
|
||||
// flex: 1/3;
|
||||
// justify-content: center;
|
||||
// height: 100%;
|
||||
// padding-left: 1rem;
|
||||
// opacity: 0;
|
||||
// `;
|
||||
|
||||
// const Center = styled.div`
|
||||
// display: flex;
|
||||
// flex: 1/3;
|
||||
// justify-content: center;
|
||||
// height: 100%;
|
||||
// opacity: 0;
|
||||
// `;
|
||||
|
||||
const Right = styled.div`
|
||||
display: flex;
|
||||
flex: 1/3;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
export const Titlebar = ({ children }: TitlebarProps) => {
|
||||
return (
|
||||
<>
|
||||
<TitlebarContainer>
|
||||
<Right>
|
||||
{children}
|
||||
<Group spacing="xs">
|
||||
<>
|
||||
{/* <ActivityMenu /> */}
|
||||
<AppMenu />
|
||||
</>
|
||||
<WindowControls />
|
||||
</Group>
|
||||
</Right>
|
||||
</TitlebarContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Titlebar.defaultProps = {
|
||||
children: undefined,
|
||||
};
|
||||
BIN
src/renderer/features/window-controls/assets/close-w-10.png
Normal file
BIN
src/renderer/features/window-controls/assets/close-w-10.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/renderer/features/window-controls/assets/max-w-10.png
Normal file
BIN
src/renderer/features/window-controls/assets/max-w-10.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/renderer/features/window-controls/assets/min-w-10.png
Normal file
BIN
src/renderer/features/window-controls/assets/min-w-10.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
|
|
@ -0,0 +1,97 @@
|
|||
import { useState } from 'react';
|
||||
import isElectron from 'is-electron';
|
||||
import { RiCheckboxBlankLine, RiCloseLine, RiSubtractLine } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const browser = window.electron.browser;
|
||||
|
||||
interface WindowControlsProps {
|
||||
style?: 'macos' | 'windows' | 'linux';
|
||||
}
|
||||
|
||||
const WindowsButtonGroup = styled.div`
|
||||
display: flex;
|
||||
width: 130px;
|
||||
height: 100%;
|
||||
-webkit-app-region: no-drag;
|
||||
`;
|
||||
|
||||
export const WindowsButton = styled.div<{ $exit?: boolean }>`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
-webkit-app-region: no-drag;
|
||||
width: 50px;
|
||||
height: 30px;
|
||||
|
||||
img {
|
||||
width: 35%;
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${({ $exit }) => ($exit ? 'rgba(200, 50, 0, 30%)' : 'rgba(125, 125, 125, 30%)')};
|
||||
}
|
||||
`;
|
||||
|
||||
const close = () => browser.exit();
|
||||
|
||||
const minimize = () => browser.minimize();
|
||||
|
||||
const maximize = () => browser.maximize();
|
||||
|
||||
const unmaximize = () => browser.unmaximize();
|
||||
|
||||
export const WindowControls = ({ style }: WindowControlsProps) => {
|
||||
const [max, setMax] = useState(false);
|
||||
|
||||
const handleMinimize = () => minimize();
|
||||
|
||||
const handleMaximize = () => {
|
||||
if (max) {
|
||||
unmaximize();
|
||||
} else {
|
||||
maximize();
|
||||
}
|
||||
setMax(!max);
|
||||
};
|
||||
|
||||
const handleClose = () => close();
|
||||
|
||||
return (
|
||||
<>
|
||||
{isElectron() && (
|
||||
<>
|
||||
{style === 'windows' && (
|
||||
<WindowsButtonGroup>
|
||||
<WindowsButton
|
||||
role="button"
|
||||
onClick={handleMinimize}
|
||||
>
|
||||
<RiSubtractLine size={20} />
|
||||
</WindowsButton>
|
||||
<WindowsButton
|
||||
role="button"
|
||||
onClick={handleMaximize}
|
||||
>
|
||||
<RiCheckboxBlankLine size={15} />
|
||||
</WindowsButton>
|
||||
<WindowsButton
|
||||
$exit
|
||||
role="button"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<RiCloseLine size={20} />
|
||||
</WindowsButton>
|
||||
</WindowsButtonGroup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
WindowControls.defaultProps = {
|
||||
style: 'windows',
|
||||
};
|
||||
1
src/renderer/features/window-controls/index.ts
Normal file
1
src/renderer/features/window-controls/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './components/window-controls';
|
||||
Loading…
Add table
Add a link
Reference in a new issue