diff --git a/src/renderer/api/jellyfin.api.ts b/src/renderer/api/jellyfin.api.ts index fbdfae95..0c0d0d9f 100644 --- a/src/renderer/api/jellyfin.api.ts +++ b/src/renderer/api/jellyfin.api.ts @@ -252,9 +252,11 @@ const getAlbumList = async (args: AlbumListArgs): Promise => { limit: query.limit, parentId: query.musicFolderId, recursive: true, + searchTerm: query.searchTerm, sortBy: albumListSortMap.jellyfin[query.sortBy], sortOrder: sortOrderMap.jellyfin[query.sortOrder], startIndex: query.startIndex, + ...query.jfParams, }; const data = await api diff --git a/src/renderer/api/navidrome.api.ts b/src/renderer/api/navidrome.api.ts index a4c67dc6..ae00d613 100644 --- a/src/renderer/api/navidrome.api.ts +++ b/src/renderer/api/navidrome.api.ts @@ -208,6 +208,7 @@ const getAlbumList = async (args: AlbumListArgs): Promise => { _order: sortOrderMap.navidrome[query.sortOrder], _sort: albumListSortMap.navidrome[query.sortBy], _start: query.startIndex, + name: query.searchTerm, ...query.ndParams, }; diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index adb01bcd..fceb4c3b 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -40,13 +40,33 @@ import { SSAlbumArtistDetail, SSMusicFolderList, } from '/@/renderer/api/subsonic.types'; -import { ServerListItem, ServerType } from '/@/renderer/types'; export enum SortOrder { ASC = 'ASC', DESC = 'DESC', } +export type ServerListItem = { + credential: string; + id: string; + name: string; + ndCredential?: string; + type: ServerType; + url: string; + userId: string | null; + username: string; +}; + +export enum ServerType { + JELLYFIN = 'jellyfin', + NAVIDROME = 'navidrome', + SUBSONIC = 'subsonic', +} + +export type QueueSong = Song & { + uniqueId: string; +}; + type SortOrderMap = { jellyfin: Record; navidrome: Record; @@ -281,6 +301,7 @@ export type AlbumListQuery = { starred?: boolean; year?: number; }; + searchTerm?: string; sortBy: AlbumListSort; sortOrder: SortOrder; startIndex: number; diff --git a/src/renderer/components/index.ts b/src/renderer/components/index.ts index 0c36de21..04d1612c 100644 --- a/src/renderer/components/index.ts +++ b/src/renderer/components/index.ts @@ -27,3 +27,4 @@ export * from './grid-carousel'; export * from './card'; export * from './feature-carousel'; export * from './badge'; +export * from './search-input'; diff --git a/src/renderer/components/input/index.tsx b/src/renderer/components/input/index.tsx index e4993294..ed29beee 100644 --- a/src/renderer/components/input/index.tsx +++ b/src/renderer/components/input/index.tsx @@ -82,6 +82,8 @@ const StyledTextInput = styled(MantineTextInput)` & .mantine-TextInput-disabled { opacity: 0.6; } + + transition: width 0.3s ease-in-out; `; const StyledNumberInput = styled(MantineNumberInput)` @@ -125,6 +127,8 @@ const StyledNumberInput = styled(MantineNumberInput)` & .mantine-NumberInput-disabled { opacity: 0.6; } + + transition: width 0.3s ease-in-out; `; const StyledPasswordInput = styled(MantinePasswordInput)` @@ -152,6 +156,8 @@ const StyledPasswordInput = styled(MantinePasswordInput)` & .mantine-PasswordInput-disabled { opacity: 0.6; } + + transition: width 0.3s ease-in-out; `; const StyledFileInput = styled(MantineFileInput)` @@ -179,6 +185,8 @@ const StyledFileInput = styled(MantineFileInput)` & .mantine-FileInput-disabled { opacity: 0.6; } + + transition: width 0.3s ease-in-out; `; const StyledJsonInput = styled(MantineJsonInput)` @@ -206,6 +214,8 @@ const StyledJsonInput = styled(MantineJsonInput)` & .mantine-JsonInput-disabled { opacity: 0.6; } + + transition: width 0.3s ease-in-out; `; const StyledTextarea = styled(MantineTextarea)` @@ -233,6 +243,8 @@ const StyledTextarea = styled(MantineTextarea)` & .mantine-Textarea-disabled { opacity: 0.6; } + + transition: width 0.3s ease-in-out; `; export const TextInput = forwardRef( diff --git a/src/renderer/components/search-input/index.tsx b/src/renderer/components/search-input/index.tsx new file mode 100644 index 00000000..ccb09e6e --- /dev/null +++ b/src/renderer/components/search-input/index.tsx @@ -0,0 +1,50 @@ +import { ChangeEvent } from 'react'; +import { TextInputProps } from '@mantine/core'; +import { useFocusWithin, useHotkeys, useMergedRef } from '@mantine/hooks'; +import { RiSearchLine } from 'react-icons/ri'; +import { TextInput } from '/@/renderer/components/input'; + +interface SearchInputProps extends TextInputProps { + initialWidth?: number; + onChange?: (event: ChangeEvent) => void; + openedWidth?: number; + value?: string; +} + +export const SearchInput = ({ + initialWidth, + onChange, + openedWidth, + ...props +}: SearchInputProps) => { + const { ref, focused } = useFocusWithin(); + const mergedRef = useMergedRef(ref); + + const isOpened = focused || ref.current?.value; + + useHotkeys([ + [ + 'ctrl+F', + () => { + ref.current.select(); + }, + ], + ]); + + return ( + } + styles={{ + input: { + backgroundColor: isOpened ? 'inherit' : 'transparent !important', + border: 'none !important', + padding: isOpened ? '10px' : 0, + }, + }} + width={isOpened ? openedWidth || 200 : initialWidth || 50} + onChange={onChange} + /> + ); +}; diff --git a/src/renderer/features/albums/components/album-list-header.tsx b/src/renderer/features/albums/components/album-list-header.tsx index f01ff0fa..ad95c6f3 100644 --- a/src/renderer/features/albums/components/album-list-header.tsx +++ b/src/renderer/features/albums/components/album-list-header.tsx @@ -1,13 +1,20 @@ -import type { MouseEvent } from 'react'; +import type { MouseEvent, ChangeEvent } from 'react'; import { useCallback } from 'react'; -import { Group, Slider } from '@mantine/core'; +import { Flex, Slider } from '@mantine/core'; +import debounce from 'lodash/debounce'; 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 { Button, DropdownMenu, PageHeader, SearchInput } from '/@/renderer/components'; +import { + useCurrentServer, + useAlbumListStore, + useSetAlbumFilters, + useSetAlbumStore, +} from '/@/renderer/store'; import { CardDisplayType } from '/@/renderer/types'; import { useMusicFolders } from '/@/renderer/features/shared'; +import styled from 'styled-components'; const FILTERS = { jellyfin: [ @@ -39,11 +46,18 @@ const ORDER = [ { name: 'Descending', value: SortOrder.DESC }, ]; +const HeaderItems = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; +`; + export const AlbumListHeader = () => { const server = useCurrentServer(); - const { setPage } = useAppStoreActions(); - const page = useAlbumRouteStore(); - const filters = page.list.filter; + const setPage = useSetAlbumStore(); + const setFilter = useSetAlbumFilters(); + const page = useAlbumListStore(); + const filters = page.filter; const musicFoldersQuery = useMusicFolders(); @@ -58,59 +72,44 @@ export const AlbumListHeader = () => { const setSize = throttle( (e: number) => - setPage('albums', { - ...page, - list: { ...page.list, size: e }, + setPage({ + list: { ...page, grid: { ...page.grid, size: e } }, }), 200, ); - const handleSetFilter = useCallback( + const handleSetSortBy = useCallback( (e: MouseEvent) => { if (!e.currentTarget?.value) return; - setPage('albums', { - list: { - ...page.list, - filter: { - ...page.list.filter, - sortBy: e.currentTarget.value as AlbumListSort, - }, - }, + setFilter({ + sortBy: e.currentTarget.value as AlbumListSort, }); }, - [page.list, setPage], + [setFilter], ); const handleSetMusicFolder = useCallback( (e: MouseEvent) => { if (!e.currentTarget?.value) return; - setPage('albums', { - list: { - ...page.list, - filter: { - ...page.list.filter, - musicFolderId: e.currentTarget.value, - }, - }, + setFilter({ + musicFolderId: e.currentTarget.value, }); }, - [page.list, setPage], + [setFilter], ); const handleSetOrder = useCallback( (e: MouseEvent) => { if (!e.currentTarget?.value) return; - setPage('albums', { - list: { - ...page.list, - filter: { - ...page.list.filter, + debounce( + () => + setFilter({ sortOrder: e.currentTarget.value as SortOrder, - }, - }, - }); + }), + 1000, + ); }, - [page.list, setPage], + [setFilter], ); const handleSetViewType = useCallback( @@ -118,29 +117,24 @@ export const AlbumListHeader = () => { if (!e.currentTarget?.value) return; const type = e.currentTarget.value; if (type === CardDisplayType.CARD) { - setPage('albums', { - ...page, + setPage({ list: { - ...page.list, + ...page, display: CardDisplayType.CARD, - type: 'grid', }, }); } else if (type === CardDisplayType.POSTER) { - setPage('albums', { - ...page, + setPage({ list: { - ...page.list, + ...page, display: CardDisplayType.POSTER, - type: 'grid', }, }); } else { - setPage('albums', { - ...page, + setPage({ list: { - ...page.list, - type: 'list', + ...page, + display: CardDisplayType.TABLE, }, }); } @@ -148,127 +142,142 @@ export const AlbumListHeader = () => { [page, setPage], ); + const handleSearch = debounce((e: ChangeEvent) => { + setFilter({ + searchTerm: e.target.value, + }); + }, 500); + return ( - - + - - - - - - - - - - Card - - - Poster - - - List - - - - - - - - - {FILTERS[server?.type as keyof typeof FILTERS].map((filter) => ( - + + + + + + - ))} - - - - - - - - {ORDER.map((sort) => ( + - {sort.name} + Card - ))} - - - - - - - - {musicFoldersQuery.data?.map((folder) => ( - {folder.name} + Poster - ))} - - - + + List + + + + + + + + + {FILTERS[server?.type as keyof typeof FILTERS].map((filter) => ( + + {filter.name} + + ))} + + + + + + + + {ORDER.map((sort) => ( + + {sort.name} + + ))} + + + + + + + + {musicFoldersQuery.data?.map((folder) => ( + + {folder.name} + + ))} + + + + + + + ); }; diff --git a/src/renderer/features/albums/routes/album-list-route.tsx b/src/renderer/features/albums/routes/album-list-route.tsx index e646f231..31faa48f 100644 --- a/src/renderer/features/albums/routes/album-list-route.tsx +++ b/src/renderer/features/albums/routes/album-list-route.tsx @@ -10,7 +10,7 @@ import { VirtualInfiniteGrid, } from '/@/renderer/components'; import { AppRoute } from '/@/renderer/router/routes'; -import { useAlbumRouteStore, useAppStoreActions, useCurrentServer } from '/@/renderer/store'; +import { useAlbumListStore, useCurrentServer, useSetAlbumStore } from '/@/renderer/store'; import { LibraryItem, CardDisplayType } from '/@/renderer/types'; import { useAlbumList } from '../queries/album-list-query'; import { controller } from '/@/renderer/api/controller'; @@ -22,17 +22,15 @@ import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-han const AlbumListRoute = () => { const queryClient = useQueryClient(); const server = useCurrentServer(); - const { setPage } = useAppStoreActions(); - const page = useAlbumRouteStore(); - const filters = page.list.filter; + const setPage = useSetAlbumStore(); + + const page = useAlbumListStore(); const handlePlayQueueAdd = useHandlePlayQueueAdd(); const albumListQuery = useAlbumList({ limit: 1, - musicFolderId: filters.musicFolderId, - sortBy: filters.sortBy, - sortOrder: filters.sortOrder, startIndex: 0, + ...page.filter, }); const fetch = useCallback( @@ -40,17 +38,15 @@ const AlbumListRoute = () => { const queryKey = queryKeys.albums.list(server?.id || '', { limit: take, startIndex: skip, - ...filters, + ...page.filter, }); const albums = await queryClient.fetchQuery(queryKey, async ({ signal }) => controller.getAlbumList({ query: { limit: take, - musicFolderId: filters.musicFolderId, - sortBy: filters.sortBy, - sortOrder: filters.sortOrder, startIndex: skip, + ...page.filter, }, server, signal, @@ -59,16 +55,18 @@ const AlbumListRoute = () => { return api.normalize.albumList(albums, server); }, - [filters, queryClient, server], + [page.filter, queryClient, server], ); const handleGridScroll = useCallback( (e: ListOnScrollProps) => { - setPage('albums', { - ...page, + setPage({ list: { - ...page.list, - gridScrollOffset: e.scrollOffset, + ...page, + grid: { + ...page.grid, + scrollOffset: e.scrollOffset, + }, }, }); }, @@ -103,17 +101,17 @@ const AlbumListRoute = () => { property: 'releaseYear', }, ]} - display={page.list?.display || CardDisplayType.CARD} + display={page.display || CardDisplayType.CARD} fetchFn={fetch} handlePlayQueueAdd={handlePlayQueueAdd} height={height} - initialScrollOffset={page.list?.gridScrollOffset || 0} + initialScrollOffset={page?.grid.scrollOffset || 0} itemCount={albumListQuery?.data?.totalRecordCount || 0} itemGap={20} - itemSize={150 + page.list?.size} + itemSize={150 + page.grid?.size} itemType={LibraryItem.ALBUM} minimumBatchSize={40} - refresh={filters.musicFolderId} + refresh={page.filter} route={{ route: AppRoute.LIBRARY_ALBUMS_DETAIL, slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],