add favorites list

This commit is contained in:
jeffvli
2025-11-30 01:12:29 -08:00
parent 1c0cbafa3e
commit cb3c0fe0d4
17 changed files with 377 additions and 9 deletions
+4
View File
@@ -363,6 +363,9 @@
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)",
"title": "$t(entity.album_other)"
},
"favorites": {
"title": "$t(entity.favorite_other)"
},
"appMenu": {
"collapseSidebar": "collapse sidebar",
"expandSidebar": "expand sidebar",
@@ -497,6 +500,7 @@
"albumArtists": "$t(entity.albumArtist_other)",
"albums": "$t(entity.album_other)",
"artists": "$t(entity.artist_other)",
"favorites": "$t(entity.favorite_other)",
"folders": "$t(entity.folder_other)",
"genres": "$t(entity.genre_other)",
"home": "$t(common.home)",
@@ -199,6 +199,7 @@ export const NavidromeController: InternalControllerEndpoint = {
_start: query.startIndex,
library_id: getLibraryId(query.musicFolderId),
name: query.searchTerm,
starred: query.favorite,
...query._custom,
role: hasFeature(apiClientProps.server, ServerFeature.BFR) ? 'albumartist' : '',
...excludeMissing(apiClientProps.server),
@@ -330,6 +331,7 @@ export const NavidromeController: InternalControllerEndpoint = {
_start: query.startIndex,
library_id: getLibraryId(query.musicFolderId),
name: query.searchTerm,
starred: query.favorite,
...query._custom,
role: query.role || undefined,
...excludeMissing(apiClientProps.server),
@@ -1,17 +1,28 @@
import { useCallback } from 'react';
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { AlbumArtistListSort } from '/@/shared/types/domain-types';
import { AlbumArtistListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export const useAlbumArtistListFilters = () => {
const { sortBy } = useSortByFilter<AlbumArtistListSort>(null, ItemListKey.ALBUM_ARTIST);
const { setSortBy, sortBy } = useSortByFilter<AlbumArtistListSort>(
null,
ItemListKey.ALBUM_ARTIST,
);
const { sortOrder } = useSortOrderFilter(null, ItemListKey.ALBUM_ARTIST);
const { setSortOrder, sortOrder } = useSortOrderFilter(null, ItemListKey.ALBUM_ARTIST);
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
const clear = useCallback(() => {
setSearchTerm(null);
setSortBy(AlbumArtistListSort.NAME);
setSortOrder(SortOrder.ASC);
}, [setSearchTerm, setSortBy, setSortOrder]);
const query = {
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
@@ -19,6 +30,7 @@ export const useAlbumArtistListFilters = () => {
};
return {
clear,
query,
setSearchTerm,
};
@@ -0,0 +1,96 @@
import { Suspense } from 'react';
import { useListContext } from '/@/renderer/context/list-context';
import {
AlbumListView,
OverrideAlbumListQuery,
} from '/@/renderer/features/albums/components/album-list-content';
import {
AlbumArtistListView,
OverrideAlbumArtistListQuery,
} from '/@/renderer/features/artists/components/album-artist-list-content';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import {
OverrideSongListQuery,
SongListView,
} from '/@/renderer/features/songs/components/song-list-content';
import { useListSettings } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { LibraryItem } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
interface FavoritesContentProps {
itemType: LibraryItem;
}
export const FavoritesContent = ({ itemType }: FavoritesContentProps) => {
return (
<AnimatedPage>
<Suspense fallback={<Spinner container />}>
{itemType === LibraryItem.ALBUM && <AlbumFavorites />}
{itemType === LibraryItem.SONG && <SongFavorites />}
{itemType === LibraryItem.ALBUM_ARTIST && <ArtistFavorites />}
</Suspense>
</AnimatedPage>
);
};
const AlbumFavorites = () => {
const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ALBUM);
const { customFilters } = useListContext();
const albumQuery: OverrideAlbumListQuery = {
...(customFilters as OverrideAlbumListQuery),
};
return (
<AlbumListView
display={display}
grid={grid}
itemsPerPage={itemsPerPage}
overrideQuery={albumQuery}
pagination={pagination}
table={table}
/>
);
};
const SongFavorites = () => {
const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.SONG);
const { customFilters } = useListContext();
const songQuery: OverrideSongListQuery = {
...(customFilters as OverrideSongListQuery),
};
return (
<SongListView
display={display}
grid={grid}
itemsPerPage={itemsPerPage}
overrideQuery={songQuery}
pagination={pagination}
table={table}
/>
);
};
const ArtistFavorites = () => {
const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ARTIST);
const { customFilters } = useListContext();
const albumArtistQuery: OverrideAlbumArtistListQuery = {
...(customFilters as OverrideAlbumArtistListQuery),
};
return (
<AlbumArtistListView
display={display}
grid={grid}
itemsPerPage={itemsPerPage}
overrideQuery={albumArtistQuery}
pagination={pagination}
table={table}
/>
);
};
@@ -0,0 +1,151 @@
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { PageHeader } from '/@/renderer/components/page-header/page-header';
import { useListContext } from '/@/renderer/context/list-context';
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';
import { useAlbumArtistListFilters } from '/@/renderer/features/artists/hooks/use-album-artist-list-filters';
import { FilterBar } from '/@/renderer/features/shared/components/filter-bar';
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';
import { SongListHeaderFilters } from '/@/renderer/features/songs/components/song-list-header-filters';
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Stack } from '/@/shared/components/stack/stack';
import { TextTitle } from '/@/shared/components/text-title/text-title';
import { Text } from '/@/shared/components/text/text';
import { LibraryItem } from '/@/shared/types/domain-types';
interface FavoritesHeaderProps {
itemType: LibraryItem;
}
export const FavoritesHeader = ({ itemType }: FavoritesHeaderProps) => {
const { t } = useTranslation();
const { customFilters, itemCount } = useListContext();
const navigate = useNavigate();
const albumFilters = useAlbumListFilters();
const albumArtistFilters = useAlbumArtistListFilters();
const songFilters = useSongListFilters();
const playQuery = useMemo(() => {
let query = {};
switch (itemType) {
case LibraryItem.ALBUM:
query = albumFilters.query;
break;
case LibraryItem.ALBUM_ARTIST:
query = albumArtistFilters.query;
break;
case LibraryItem.SONG:
query = songFilters.query;
break;
}
return {
...query,
...(customFilters ?? {}),
};
}, [albumFilters.query, albumArtistFilters.query, songFilters.query, customFilters, itemType]);
const handleItemTypeChange = useCallback(
(type: LibraryItem) => {
albumFilters.clear();
songFilters.clear();
albumArtistFilters.clear();
// Clear all URL search params except 'type'
navigate(`?type=${type}`, { replace: true });
},
[albumFilters, albumArtistFilters, songFilters, navigate],
);
return (
<Stack gap={0}>
<PageHeader>
<Flex justify="space-between" w="100%">
<LibraryHeaderBar ignoreMaxWidth>
<PlayButton itemType={itemType} query={playQuery} />
<LibraryHeaderBar.Title>
<DropdownMenu position="right">
<DropdownMenu.Target>
<Stack gap={0} style={{ cursor: 'pointer' }}>
<Group>
<TextTitle isNoSelect order={3}>
{t('page.favorites.title', {
postProcess: 'sentenceCase',
})}
</TextTitle>
<Icon icon="dropdown" size="xl" />
</Group>
<Text isMuted size="sm">
{itemType === LibraryItem.ALBUM &&
t('entity.album_other', {
postProcess: 'sentenceCase',
})}
{itemType === LibraryItem.ALBUM_ARTIST &&
t('entity.artist_other', {
postProcess: 'sentenceCase',
})}
{itemType === LibraryItem.SONG &&
t('entity.track_other', {
postProcess: 'sentenceCase',
})}
</Text>
</Stack>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
isSelected={itemType === LibraryItem.SONG}
leftSection={<Icon icon="track" size="xl" />}
onClick={() => handleItemTypeChange(LibraryItem.SONG)}
>
{t('entity.track_other', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
isSelected={itemType === LibraryItem.ALBUM}
leftSection={<Icon icon="album" size="xl" />}
onClick={() => handleItemTypeChange(LibraryItem.ALBUM)}
>
{t('entity.album_other', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
isSelected={itemType === LibraryItem.ALBUM_ARTIST}
leftSection={<Icon icon="artist" size="xl" />}
onClick={() =>
handleItemTypeChange(LibraryItem.ALBUM_ARTIST)
}
>
{t('entity.artist_other', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge isLoading={!itemCount}>
{itemCount}
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<ListSearchInput />
</Group>
</Flex>
</PageHeader>
<FilterBar>
{itemType === LibraryItem.ALBUM && <AlbumListHeaderFilters />}
{itemType === LibraryItem.ALBUM_ARTIST && <AlbumArtistListHeaderFilters />}
{itemType === LibraryItem.SONG && <SongListHeaderFilters />}
</FilterBar>
</Stack>
);
};
const PlayButton = ({ itemType, query }: { itemType: LibraryItem; query: Record<string, any> }) => {
return <LibraryHeaderBar.PlayButton itemType={itemType} listQuery={query} variant="filled" />;
};
@@ -0,0 +1,73 @@
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import { ListContext } from '/@/renderer/context/list-context';
import { FavoritesContent } from '/@/renderer/features/favorites/components/favorites-content';
import { FavoritesHeader } from '/@/renderer/features/favorites/components/favorites-header';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { LibraryItem } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
const FavoritesRoute = () => {
const [searchParams] = useSearchParams();
const itemType = (searchParams.get('type') as LibraryItem) || LibraryItem.SONG;
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
const getPageKey = (type: LibraryItem): ItemListKey => {
switch (type) {
case LibraryItem.ALBUM:
return ItemListKey.ALBUM;
case LibraryItem.ALBUM_ARTIST:
return ItemListKey.ALBUM_ARTIST;
case LibraryItem.SONG:
return ItemListKey.SONG;
default:
return ItemListKey.SONG;
}
};
const pageKey = useMemo(() => getPageKey(itemType), [itemType]);
const customFilters = useMemo(() => {
switch (itemType) {
case LibraryItem.ALBUM:
return { favorite: true };
case LibraryItem.ALBUM_ARTIST:
return { favorite: true };
case LibraryItem.SONG:
return { favorite: true };
default:
return {};
}
}, [itemType]);
const providerValue = useMemo(() => {
return {
customFilters,
itemCount,
pageKey,
setItemCount,
};
}, [customFilters, itemCount, pageKey]);
return (
<AnimatedPage>
<ListContext.Provider value={providerValue}>
<FavoritesHeader itemType={itemType} />
<FavoritesContent itemType={itemType} />
</ListContext.Provider>
</AnimatedPage>
);
};
const FavoritesRouteWithBoundary = () => {
return (
<PageErrorBoundary>
<FavoritesRoute />
</PageErrorBoundary>
);
};
export default FavoritesRouteWithBoundary;
@@ -10,6 +10,7 @@ const SIDEBAR_ITEMS: Array<[string, string]> = [
['Now Playing', 'page.sidebar.nowPlaying'],
['Playlists', 'page.sidebar.playlists'],
['Search', 'page.sidebar.search'],
['Favorites', 'page.sidebar.favorites'],
['Settings', 'page.sidebar.settings'],
['Tracks', 'page.sidebar.tracks'],
];
@@ -32,6 +32,7 @@ export const CollapsedSidebar = () => {
'\n',
),
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
@@ -28,6 +28,7 @@ export const MobileSidebar = () => {
Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),
@@ -105,7 +106,7 @@ export const MobileSidebar = () => {
</Accordion>
</ScrollArea>
<div className={styles.serverSelectorWrapper}>
<ServerSelector showImage={false} />
<ServerSelector />
</div>
</div>
);
@@ -5,6 +5,8 @@ import {
RiFlag2Line,
RiFolder3Fill,
RiFolder3Line,
RiHeartFill,
RiHeartLine,
RiHome6Fill,
RiHome6Line,
RiMusic2Fill,
@@ -67,6 +69,10 @@ export const SidebarIcon = ({ active, route, size }: SidebarIconProps) => {
if (active) return <RiSearchFill size={size} />;
return <RiSearchLine size={size} />;
default:
if (route.startsWith(AppRoute.FAVORITES)) {
if (active) return <RiHeartFill size={size} />;
return <RiHeartLine size={size} />;
}
return <RiHome6Line size={size} />;
}
};
@@ -46,6 +46,7 @@ export const Sidebar = () => {
Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
Favorites: t('page.sidebar.favorites', { postProcess: 'titleCase' }),
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),
+3
View File
@@ -70,6 +70,8 @@ const GenreDetailRoute = lazy(
const SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route'));
const FavoritesRoute = lazy(() => import('/@/renderer/features/favorites/routes/favorites-route'));
export const AppRouter = () => {
const router = (
<HashRouter>
@@ -90,6 +92,7 @@ export const AppRouter = () => {
<Route element={<HomeRoute />} index />
<Route element={<HomeRoute />} path={AppRoute.HOME} />
<Route element={<SearchRoute />} path={AppRoute.SEARCH} />
<Route element={<FavoritesRoute />} path={AppRoute.FAVORITES} />
<Route
element={<NowPlayingRoute />}
path={AppRoute.NOW_PLAYING}
+1
View File
@@ -2,6 +2,7 @@ export enum AppRoute {
ACTION_REQUIRED = '/action-required',
EXPLORE = '/explore',
FAKE_LIBRARY_ALBUM_DETAILS = '/library/albums/dummy/:albumId',
FAVORITES = '/favorites',
HOME = '/',
LIBRARY_ALBUM_ARTISTS = '/library/album-artists',
LIBRARY_ALBUM_ARTISTS_DETAIL = '/library/album-artists/:albumArtistId',
+16 -1
View File
@@ -527,6 +527,12 @@ export const sidebarItems: SidebarItemType[] = [
route: generatePath(AppRoute.SEARCH, { itemType: LibraryItem.SONG }),
},
{ disabled: false, id: 'Home', label: i18n.t('page.sidebar.home'), route: AppRoute.HOME },
{
disabled: false,
id: 'Favorites',
label: i18n.t('page.sidebar.favorites'),
route: AppRoute.FAVORITES,
},
{
disabled: false,
id: 'Albums',
@@ -1291,10 +1297,19 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
return state;
}
if (version <= 10) {
state.general.sidebarItems.push({
disabled: false,
id: 'Favorites',
label: i18n.t('page.sidebar.favorites'),
route: AppRoute.FAVORITES,
});
}
return persistedState;
},
name: 'store_settings',
version: 10,
version: 11,
},
),
);
@@ -210,7 +210,6 @@ const normalizeSong = (
releaseYear: item.year || null,
sampleRate: item.sampleRate || null,
size: item.size,
streamUrl: `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`,
tags: item.tags || null,
trackNumber: item.trackNumber,
updatedAt: item.updatedAt,
@@ -195,7 +195,7 @@ const normalizeSong = (
tags: null,
trackNumber: item.track || 1,
updatedAt: '',
userFavorite: item.starred || false,
userFavorite: Boolean(item.starred) || false,
userRating: item.userRating || null,
};
};
@@ -232,7 +232,7 @@ const normalizeAlbumArtist = (
playCount: null,
similarArtists: [],
songCount: null,
userFavorite: false,
userFavorite: Boolean(item.starred) || false,
userRating: null,
};
};
@@ -290,7 +290,7 @@ const normalizeAlbum = (
) || [],
tags: null,
updatedAt: item.created,
userFavorite: item.starred || false,
userFavorite: Boolean(item.starred) || false,
userRating: item.userRating || null,
version: item.version || null,
};
+2
View File
@@ -661,6 +661,7 @@ export type AlbumArtistListCountArgs = BaseEndpointArgs & {
export interface AlbumArtistListQuery extends BaseQuery<AlbumArtistListSort> {
_custom?: Record<string, any>;
favorite?: boolean;
limit?: number;
musicFolderId?: string | string[];
searchTerm?: string;
@@ -753,6 +754,7 @@ export type ArtistListCountArgs = BaseEndpointArgs & { query: ListCountQuery<Art
export interface ArtistListQuery extends BaseQuery<ArtistListSort> {
_custom?: Record<string, any>;
favorite?: boolean;
limit?: number;
musicFolderId?: string | string[];
role?: string;