From cb3c0fe0d4d8234de36e776563b7944db89bba2f Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 30 Nov 2025 01:12:29 -0800 Subject: [PATCH] add favorites list --- src/i18n/locales/en.json | 4 + .../api/navidrome/navidrome-controller.ts | 2 + .../hooks/use-album-artist-list-filters.ts | 18 ++- .../components/favorites-content.tsx | 96 +++++++++++ .../favorites/components/favorites-header.tsx | 151 ++++++++++++++++++ .../favorites/routes/favorites-route.tsx | 73 +++++++++ .../components/general/sidebar-reorder.tsx | 1 + .../sidebar/components/collapsed-sidebar.tsx | 1 + .../sidebar/components/mobile-sidebar.tsx | 3 +- .../sidebar/components/sidebar-icon.tsx | 6 + .../features/sidebar/components/sidebar.tsx | 1 + src/renderer/router/app-router.tsx | 3 + src/renderer/router/routes.ts | 1 + src/renderer/store/settings.store.ts | 17 +- .../api/navidrome/navidrome-normalize.ts | 1 - src/shared/api/subsonic/subsonic-normalize.ts | 6 +- src/shared/types/domain-types.ts | 2 + 17 files changed, 377 insertions(+), 9 deletions(-) create mode 100644 src/renderer/features/favorites/components/favorites-content.tsx create mode 100644 src/renderer/features/favorites/components/favorites-header.tsx create mode 100644 src/renderer/features/favorites/routes/favorites-route.tsx diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b5361edee..1c1336e3e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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)", diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 4ff23be3b..2bbcbce09 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -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), diff --git a/src/renderer/features/artists/hooks/use-album-artist-list-filters.ts b/src/renderer/features/artists/hooks/use-album-artist-list-filters.ts index ad52f0df0..cec05928d 100644 --- a/src/renderer/features/artists/hooks/use-album-artist-list-filters.ts +++ b/src/renderer/features/artists/hooks/use-album-artist-list-filters.ts @@ -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(null, ItemListKey.ALBUM_ARTIST); + const { setSortBy, sortBy } = useSortByFilter( + 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, }; diff --git a/src/renderer/features/favorites/components/favorites-content.tsx b/src/renderer/features/favorites/components/favorites-content.tsx new file mode 100644 index 000000000..8edc49b38 --- /dev/null +++ b/src/renderer/features/favorites/components/favorites-content.tsx @@ -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 ( + + }> + {itemType === LibraryItem.ALBUM && } + {itemType === LibraryItem.SONG && } + {itemType === LibraryItem.ALBUM_ARTIST && } + + + ); +}; + +const AlbumFavorites = () => { + const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ALBUM); + const { customFilters } = useListContext(); + + const albumQuery: OverrideAlbumListQuery = { + ...(customFilters as OverrideAlbumListQuery), + }; + + return ( + + ); +}; + +const SongFavorites = () => { + const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.SONG); + const { customFilters } = useListContext(); + + const songQuery: OverrideSongListQuery = { + ...(customFilters as OverrideSongListQuery), + }; + + return ( + + ); +}; + +const ArtistFavorites = () => { + const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ARTIST); + const { customFilters } = useListContext(); + + const albumArtistQuery: OverrideAlbumArtistListQuery = { + ...(customFilters as OverrideAlbumArtistListQuery), + }; + + return ( + + ); +}; diff --git a/src/renderer/features/favorites/components/favorites-header.tsx b/src/renderer/features/favorites/components/favorites-header.tsx new file mode 100644 index 000000000..2ef738432 --- /dev/null +++ b/src/renderer/features/favorites/components/favorites-header.tsx @@ -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 ( + + + + + + + + + + + + {t('page.favorites.title', { + postProcess: 'sentenceCase', + })} + + + + + {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', + })} + + + + + } + onClick={() => handleItemTypeChange(LibraryItem.SONG)} + > + {t('entity.track_other', { postProcess: 'sentenceCase' })} + + } + onClick={() => handleItemTypeChange(LibraryItem.ALBUM)} + > + {t('entity.album_other', { postProcess: 'sentenceCase' })} + + } + onClick={() => + handleItemTypeChange(LibraryItem.ALBUM_ARTIST) + } + > + {t('entity.artist_other', { postProcess: 'sentenceCase' })} + + + + + + {itemCount} + + + + + + + + + {itemType === LibraryItem.ALBUM && } + {itemType === LibraryItem.ALBUM_ARTIST && } + {itemType === LibraryItem.SONG && } + + + ); +}; + +const PlayButton = ({ itemType, query }: { itemType: LibraryItem; query: Record }) => { + return ; +}; diff --git a/src/renderer/features/favorites/routes/favorites-route.tsx b/src/renderer/features/favorites/routes/favorites-route.tsx new file mode 100644 index 000000000..3a5c0a564 --- /dev/null +++ b/src/renderer/features/favorites/routes/favorites-route.tsx @@ -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(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 ( + + + + + + + ); +}; + +const FavoritesRouteWithBoundary = () => { + return ( + + + + ); +}; + +export default FavoritesRouteWithBoundary; diff --git a/src/renderer/features/settings/components/general/sidebar-reorder.tsx b/src/renderer/features/settings/components/general/sidebar-reorder.tsx index 966b5a5c9..32b8061bb 100644 --- a/src/renderer/features/settings/components/general/sidebar-reorder.tsx +++ b/src/renderer/features/settings/components/general/sidebar-reorder.tsx @@ -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'], ]; diff --git a/src/renderer/features/sidebar/components/collapsed-sidebar.tsx b/src/renderer/features/sidebar/components/collapsed-sidebar.tsx index 34f410776..aecdf5477 100644 --- a/src/renderer/features/sidebar/components/collapsed-sidebar.tsx +++ b/src/renderer/features/sidebar/components/collapsed-sidebar.tsx @@ -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' }), diff --git a/src/renderer/features/sidebar/components/mobile-sidebar.tsx b/src/renderer/features/sidebar/components/mobile-sidebar.tsx index e74a4380d..9e311a250 100644 --- a/src/renderer/features/sidebar/components/mobile-sidebar.tsx +++ b/src/renderer/features/sidebar/components/mobile-sidebar.tsx @@ -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 = () => {
- +
); diff --git a/src/renderer/features/sidebar/components/sidebar-icon.tsx b/src/renderer/features/sidebar/components/sidebar-icon.tsx index 96c53d540..fe9fbd85d 100644 --- a/src/renderer/features/sidebar/components/sidebar-icon.tsx +++ b/src/renderer/features/sidebar/components/sidebar-icon.tsx @@ -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 ; return ; default: + if (route.startsWith(AppRoute.FAVORITES)) { + if (active) return ; + return ; + } return ; } }; diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index b85080ef2..06ca4cab9 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -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' }), diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index 22cf52301..b11cc77bd 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -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 = ( @@ -90,6 +92,7 @@ export const AppRouter = () => { } index /> } path={AppRoute.HOME} /> } path={AppRoute.SEARCH} /> + } path={AppRoute.FAVORITES} /> } path={AppRoute.NOW_PLAYING} diff --git a/src/renderer/router/routes.ts b/src/renderer/router/routes.ts index 4c18a1ef1..35b40a35c 100644 --- a/src/renderer/router/routes.ts +++ b/src/renderer/router/routes.ts @@ -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', diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 205224c91..329f5a9ce 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -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()( 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, }, ), ); diff --git a/src/shared/api/navidrome/navidrome-normalize.ts b/src/shared/api/navidrome/navidrome-normalize.ts index 17b742267..faf84a817 100644 --- a/src/shared/api/navidrome/navidrome-normalize.ts +++ b/src/shared/api/navidrome/navidrome-normalize.ts @@ -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, diff --git a/src/shared/api/subsonic/subsonic-normalize.ts b/src/shared/api/subsonic/subsonic-normalize.ts index d34ea9140..8993a55f9 100644 --- a/src/shared/api/subsonic/subsonic-normalize.ts +++ b/src/shared/api/subsonic/subsonic-normalize.ts @@ -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, }; diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index c8842cb98..a8a133222 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -661,6 +661,7 @@ export type AlbumArtistListCountArgs = BaseEndpointArgs & { export interface AlbumArtistListQuery extends BaseQuery { _custom?: Record; + favorite?: boolean; limit?: number; musicFolderId?: string | string[]; searchTerm?: string; @@ -753,6 +754,7 @@ export type ArtistListCountArgs = BaseEndpointArgs & { query: ListCountQuery { _custom?: Record; + favorite?: boolean; limit?: number; musicFolderId?: string | string[]; role?: string;