Compare commits

..

2 Commits

Author SHA1 Message Date
jeffvli e6b77e5883 adjust top songs / favorite songs sections
- use table row height configuration for container calculation
- add wrapper component and use for both Top Songs and Favorite Songs
- remove the view more button, show all items by default
2026-02-13 20:07:37 -08:00
Kendall Garner d54baae3d9 improve album artist favorite performance and search 2026-02-13 09:29:00 -08:00
61 changed files with 679 additions and 1983 deletions
Binary file not shown.
-1
View File
@@ -1,6 +1,5 @@
server {
listen 9180;
listen [::]:9180;
sendfile on;
default_type application/octet-stream;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "1.6.0",
"version": "1.5.0",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
+6 -10
View File
@@ -12,7 +12,7 @@
"search": "$t(common.search)",
"settings": "$t(common.setting, {\"count\": 2})",
"tracks": "$t(entity.track, {\"count\": 2})",
"nowPlaying": "s'està reproduint",
"nowPlaying": "ara sona",
"shared": "$t(entity.playlist, {\"count\": 2}) compartides",
"favorites": "$t(entity.favorite, {\"count\": 2})",
"radio": "$t(entity.radioStation, {\"count\": 2})",
@@ -568,7 +568,7 @@
"sidePlayQueueStyle_optionAttached": "unida",
"sidePlayQueueStyle_optionDetached": "separada",
"audioDevice": "dispositiu d'àudio",
"audioDevice_description": "seleccioneu el dispositiu d'àudio que voleu utilitzar per a la reproducció",
"audioDevice_description": "seleccioneu el dispositiu d'àudio que voleu utilitzar per a la reproducció (només pel reproductor web)",
"audioPlayer": "reproductor d'àudio",
"audioPlayer_description": "seleccioneu el reproductor d'àudio que voleu utilitzar per a la reproducció",
"sidebarConfiguration_description": "selecciona els elements i l'ordre en què apareixen a la barra lateral",
@@ -614,9 +614,9 @@
"customFontPath_description": "estableix la ruta a una font personalitzada per utilitzar-la a l'aplicació",
"discordApplicationId": "id d'aplicació de {{discord}}",
"discordApplicationId_description": "l'id d'aplicació per l'estat d'activitat de {{discord}} (per defecte, {{defaultId}})",
"discordPausedStatus": "mostra l'estat d'activitat quan està en pausa",
"discordPausedStatus": "mosta l'estat d'activitat quan està en pausa",
"discordPausedStatus_description": "si està activat, l'estat es mostrarà quan el reproductor estigui pausat",
"discordIdleStatus": "mosta l'estat d'activitat quan està inactiu",
"discordIdleStatus": "mosta l'estat d'activitat en inactivitat",
"discordIdleStatus_description": "si està activat, s'actualitzarà l'estat mentre el reproductor estigui inactiu",
"discordListening": "mosta l'estat com escoltant",
"discordListening_description": "mosta l'estat com escoltant en comptes de jugant",
@@ -894,9 +894,7 @@
"automaticUpdates_description": "cerca i instal·la actualitzacions automàticament",
"releaseChannel_optionAlpha": "alfa (diària)",
"blurExplicitImages": "desenfoca imatges explícites",
"blurExplicitImages_description": "les caràtules d'àlbums i cançons marcades com a explícites quedaran desenfocades",
"discordStateIcon": "mostra la icona de reproducció",
"discordStateIcon_description": "mostra una petita icona de reproducció a l'estat d'activitat. l'icona de pausa es mostra quan \"mostra l'estat d'activitat quan està en pausa\" està activat"
"blurExplicitImages_description": "les caràtules d'àlbums i cançons marcades com a explícites quedaran desenfocades"
},
"table": {
"column": {
@@ -1050,9 +1048,7 @@
"path": "ruta",
"songCount": "nombre de cançons",
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "ordena per nom",
"matchAnd": "i",
"matchOr": "o"
"sortName": "ordena per nom"
},
"player": {
"muted": "silenciat",
+1 -3
View File
@@ -751,9 +751,7 @@
"album": "$t(entity.album, {\"count\": 1})",
"trackNumber": "skladba",
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "název v řazení",
"matchAnd": "a",
"matchOr": "nebo"
"sortName": "název v řazení"
},
"page": {
"sidebar": {
-4
View File
@@ -211,7 +211,6 @@
"credentialsRequired": "credentials required",
"endpointNotImplementedError": "endpoint {{endpoint}} is not implemented for {{serverType}}",
"genericError": "an error occurred",
"invalidJson": "invalid JSON",
"invalidServer": "invalid server",
"localFontAccessDenied": "access denied to local fonts",
"loginRateError": "too many login attempts, please try again in a few seconds",
@@ -228,7 +227,6 @@
"remotePortError": "an error occurred when trying to set the remote server port",
"remotePortWarning": "restart the server to apply the new port",
"saveQueueFailed": "failed to save queue",
"serverLockSingleServer": "only one server is allowed when server is locked",
"serverNotSelectedError": "no server selected",
"serverRequired": "server required",
"sessionExpiredError": "your session has expired",
@@ -238,8 +236,6 @@
"filter": {
"album": "$t(entity.album, {\"count\": 1})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"matchAnd": "and",
"matchOr": "or",
"albumCount": "$t(entity.album, {\"count\": 2}) count",
"artist": "$t(entity.artist, {\"count\": 1})",
"biography": "biography",
+2 -4
View File
@@ -54,7 +54,7 @@
"remotePort_description": "establece el puerto para el control remoto del servidor",
"hotkey_skipBackward": "retroceder",
"replayGainMode_description": "ajusta el volumen de ganancia acorde a los valores de {{ReplayGain}} almacenados en los metadatos del archivo",
"audioDevice_description": "selecciona el dispositivo de audio a usar durante la reproducción",
"audioDevice_description": "selecciona el dispositivo de audio a usar durante la reproducción (solo reproductor web)",
"theme_description": "establece el tema a usar por la aplicación",
"hotkey_playbackPause": "pausa",
"replayGainFallback": "{{ReplayGain}} alternativa",
@@ -643,9 +643,7 @@
"album": "$t(entity.album, {\"count\": 1})",
"albumCount": "Número de $t(entity.album, {\"count\": 2})",
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "Ordenar por nombre",
"matchAnd": "y",
"matchOr": "o"
"sortName": "Ordenar por nombre"
},
"page": {
"sidebar": {
+2 -4
View File
@@ -424,7 +424,7 @@
"applicationHotkeys": "應用程式快捷鍵",
"applicationHotkeys_description": "設定應用程式快捷鍵。切換勾選框來設為全域快捷鍵(僅桌面端)",
"audioDevice": "音訊設備",
"audioDevice_description": "選擇用於播放的音訊設備",
"audioDevice_description": "選擇用於播放的音訊設備(僅 web 播放器)",
"audioExclusiveMode": "音訊獨占模式",
"audioExclusiveMode_description": "啟用獨占輸出模式。在此模式下,系統通常被鎖定,只有 mpv 能夠輸出音訊",
"audioPlayer": "音訊播放器",
@@ -977,9 +977,7 @@
"toYear": "從年份",
"trackNumber": "曲目",
"explicitStatus": "$t(common.explicitStatus)",
"sortName": "排序名稱",
"matchAnd": "和",
"matchOr": "或"
"sortName": "排序名稱"
},
"form": {
"addServer": {
@@ -763,7 +763,7 @@ export const SubsonicController: InternalControllerEndpoint = {
getFolder: async ({ apiClientProps, context, query }) => {
const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';
const isRootFolderId = query.id === '0';
const isRootFolderId = /^\d+$/.test(query.id);
if (isRootFolderId) {
const res = await ssApiClient(apiClientProps).getIndexes({
@@ -1,3 +1,3 @@
.list-expanded-container {
overflow: auto;
.container {
height: 500px;
}
@@ -1,23 +1,32 @@
import { motion, Variants } from 'motion/react';
import { ReactNode } from 'react';
import styles from './expanded-list-container.module.css';
const EXPANDED_HEIGHT = 300;
const expandedAnimationVariants: Variants = {
hidden: {
height: 0,
minHeight: 0,
},
show: {
minHeight: '300px',
transition: {
duration: 0.3,
ease: 'easeInOut',
},
},
};
export interface ExpandedListContainerProps {
children: ReactNode;
}
export const ExpandedListContainer = ({ children }: ExpandedListContainerProps) => {
export const ExpandedListContainer = ({ children }: { children: ReactNode }) => {
return (
<div
<motion.div
animate="show"
className={styles.listExpandedContainer}
style={{
height: EXPANDED_HEIGHT,
overflow: 'auto',
}}
exit="hidden"
initial="hidden"
variants={expandedAnimationVariants}
>
{children}
</div>
</motion.div>
);
};
@@ -2,18 +2,27 @@ import { Suspense } from 'react';
import styles from './expanded-list-item.module.css';
import { ItemListStateItem } from '/@/renderer/components/item-list/helpers/item-list-state';
import {
ItemListStateActions,
ItemListStateItem,
useItemListStateSubscription,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { ExpandedAlbumListItem } from '/@/renderer/features/albums/components/expanded-album-list-item';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { LibraryItem } from '/@/shared/types/domain-types';
interface ExpandedListItemProps {
item?: ItemListStateItem;
internalState: ItemListStateActions;
itemType: LibraryItem;
}
export const ExpandedListItem = ({ item, itemType }: ExpandedListItemProps) => {
if (!item) {
export const ExpandedListItem = ({ internalState, itemType }: ExpandedListItemProps) => {
const expandedItems = useItemListStateSubscription(internalState, () =>
internalState ? internalState.getExpandedItemsCached() : [],
);
const currentItem = expandedItems[0];
if (!currentItem) {
return null;
}
@@ -21,7 +30,11 @@ export const ExpandedListItem = ({ item, itemType }: ExpandedListItemProps) => {
<div className={styles.container}>
<div className={styles.inner}>
<Suspense fallback={<Spinner container />}>
<SelectedItem item={item} itemType={itemType} />
<SelectedItem
internalState={internalState}
item={currentItem as ItemListStateItem}
itemType={itemType}
/>
</Suspense>
</div>
</div>
@@ -29,14 +42,15 @@ export const ExpandedListItem = ({ item, itemType }: ExpandedListItemProps) => {
};
interface SelectedItemProps {
internalState: ItemListStateActions;
item: ItemListStateItem;
itemType: LibraryItem;
}
const SelectedItem = ({ item, itemType }: SelectedItemProps) => {
const SelectedItem = ({ internalState, item, itemType }: SelectedItemProps) => {
switch (itemType) {
case LibraryItem.ALBUM:
return <ExpandedAlbumListItem item={item} />;
return <ExpandedAlbumListItem internalState={internalState} item={item} />;
default:
return null;
}
@@ -8,7 +8,6 @@ import { ContextMenuController } from '/@/renderer/features/context-menu/context
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
import { useAppStore } from '/@/renderer/store';
import { LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
import { Play, TableColumn } from '/@/shared/types/types';
@@ -278,27 +277,19 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
}
},
onExpand: ({ item, itemType }: DefaultItemControlProps) => {
if (!item) return;
const itemListItem = item as ItemListStateItemWithRequiredProperties;
const setGlobalExpanded = useAppStore.getState().actions.setGlobalExpanded;
const globalExpanded = useAppStore.getState().globalExpanded;
if (globalExpanded?.item?.id === item.id) {
setGlobalExpanded(null);
} else {
const itemForStore: ItemListStateItemWithRequiredProperties & {
imageId: null | string;
} = {
...itemListItem,
imageId: (itemListItem as { imageId?: null | string }).imageId ?? null,
};
setGlobalExpanded({
item: itemForStore,
itemType,
});
onExpand: ({ internalState, item }: DefaultItemControlProps) => {
if (!item || !internalState) {
return;
}
// Extract rowId from the item
const rowId = internalState.extractRowId(item);
if (!rowId) return;
// Use the item directly (rowId is separate, used only as key in state)
const itemListItem = item as ItemListStateItemWithRequiredProperties;
return internalState?.toggleExpanded(itemListItem);
},
onFavorite: ({
@@ -1,6 +1,6 @@
import clsx from 'clsx';
import throttle from 'lodash/throttle';
import { motion } from 'motion/react';
import { AnimatePresence, motion } from 'motion/react';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import React, {
CSSProperties,
@@ -31,12 +31,15 @@ import {
ItemCard,
ItemCardProps,
} from '/@/renderer/components/item-card/item-card';
import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container';
import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';
import { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import {
ItemListStateActions,
ItemListStateItemWithRequiredProperties,
useItemListState,
useItemListStateSubscription,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys';
import { ItemControls, ItemListHandle } from '/@/renderer/components/item-list/types';
@@ -826,6 +829,10 @@ const BaseItemGridList = ({
/>
)}
</AutoSizer>
<AnimatePresence presenceAffectsLayout>
<ExpandedContainer internalState={internalState} itemType={itemType} />
{/* {enableSelectionDialog && <SelectionDialog internalState={internalState} />} */}
</AnimatePresence>
</motion.div>
);
};
@@ -896,3 +903,25 @@ const ListComponent = memo((props: ListChildComponentProps<GridItemProps>) => {
export const ItemGridList = memo(BaseItemGridList);
ItemGridList.displayName = 'ItemGridList';
const ExpandedContainer = ({
internalState,
itemType,
}: {
internalState: ItemListStateActions;
itemType: LibraryItem;
}) => {
const hasExpanded = useItemListStateSubscription(internalState, (state) =>
state ? state.expanded.size > 0 : false,
);
return (
<AnimatePresence initial={false}>
{hasExpanded && (
<ExpandedListContainer>
<ExpandedListItem internalState={internalState} itemType={itemType} />
</ExpandedListContainer>
)}
</AnimatePresence>
);
};
@@ -62,7 +62,6 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {
icon="arrowDownS"
iconProps={{ color: 'muted', size: 'md' }}
onClick={(e) => {
e.stopPropagation();
const item = (props.getRowItem?.(rowIndex) ??
data[rowIndex]) as ItemListItem;
const rowId = internalState.extractRowId(item);
@@ -1,7 +1,7 @@
// Component adapted from https://github.com/bvaughn/react-window/issues/826
import clsx from 'clsx';
import { motion } from 'motion/react';
import { AnimatePresence, motion } from 'motion/react';
import React, {
type JSXElementConstructor,
memo,
@@ -18,12 +18,15 @@ import { type CellComponentProps, Grid } from 'react-window-v2';
import styles from './item-table-list.module.css';
import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container';
import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';
import { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import {
ItemListStateActions,
ItemListStateItemWithRequiredProperties,
useItemListState,
useItemListStateSubscription,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns';
import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys';
@@ -1648,6 +1651,8 @@ const BaseItemTableList = ({
totalColumnCount={totalColumnCount}
totalRowCount={totalRowCount}
/>
<ExpandedContainer internalState={internalState} itemType={itemType} />
{/* {enableSelectionDialog && <SelectionDialog internalState={internalState} />} */}
</motion.div>
</ItemTableListConfigProvider>
</ItemTableListStoreProvider>
@@ -1656,4 +1661,26 @@ const BaseItemTableList = ({
export const ItemTableList = memo(BaseItemTableList);
const ExpandedContainer = ({
internalState,
itemType,
}: {
internalState: ItemListStateActions;
itemType: LibraryItem;
}) => {
const hasExpanded = useItemListStateSubscription(internalState, (state) =>
state ? state.expanded.size > 0 : false,
);
return (
<AnimatePresence initial={false}>
{hasExpanded && (
<ExpandedListContainer>
<ExpandedListItem internalState={internalState} itemType={itemType} />
</ExpandedListContainer>
)}
</AnimatePresence>
);
};
ItemTableList.displayName = 'ItemTableList';
@@ -196,7 +196,7 @@ export const QueryBuilder = ({
filters={filters}
groupIndex={groupIndex || []}
level={level}
noRemove={false}
noRemove={data?.rules?.length === 1}
onChangeField={onChangeField}
onChangeOperator={onChangeOperator}
onChangeValue={onChangeValue}
@@ -21,10 +21,7 @@ import { ItemControls } from '/@/renderer/components/item-list/types';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import {
CLIENT_SIDE_SONG_FILTERS,
ListSortByDropdownControlled,
@@ -758,10 +755,7 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
sortOrder={sortOrder}
/>
<ListConfigMenu
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
listKey={ItemListKey.ALBUM_DETAIL}
optionsConfig={{
table: {
@@ -20,6 +20,7 @@ export function AlbumGridCarousel(props: AlbumGridCarouselProps) {
const controls = useDefaultItemListControls();
const cards = useMemo(() => {
// Filter out excluded IDs if provided
const filteredItems = excludeIds
? data.filter((album) => !excludeIds.includes(album.id))
: data;
@@ -30,7 +31,6 @@ export function AlbumGridCarousel(props: AlbumGridCarouselProps) {
controls={controls}
data={album}
enableDrag
enableExpansion
itemType={LibraryItem.ALBUM}
rows={rows}
type="poster"
@@ -58,6 +58,7 @@ const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps & { rows: DataRow[]
const controls = useDefaultItemListControls();
const cards = useMemo(() => {
// Flatten all pages and filter excluded IDs
const allItems = albums?.pages.flatMap((page: AlbumListResponse) => page.items) || [];
const filteredItems = excludeIds
? allItems.filter((album) => !excludeIds.includes(album.id))
@@ -69,7 +70,6 @@ const BaseAlbumInfiniteCarousel = (props: AlbumCarouselProps & { rows: DataRow[]
controls={controls}
data={album}
enableDrag
enableExpansion
itemType={LibraryItem.ALBUM}
rows={rows}
type="poster"
@@ -6,7 +6,6 @@ import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpe
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list';
import { ItemListComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import {
AlbumListQuery,
@@ -35,7 +34,6 @@ export const AlbumListInfiniteDetail = ({
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
const listQueryFn = api.controller.getAlbumList;
const { pageKey } = useListContext();
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.ALBUM,
@@ -48,7 +46,7 @@ export const AlbumListInfiniteDetail = ({
});
const { getItem, itemCount, loadedItems, onRangeChanged } = useItemListInfiniteLoader({
eventKey: pageKey || ItemListKey.ALBUM,
eventKey: ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -6,7 +6,6 @@ import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-r
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';
import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { useGeneralSettings } from '/@/renderer/store';
import {
@@ -38,11 +37,9 @@ export const AlbumListInfiniteGrid = ({
const listQueryFn = api.controller.getAlbumList;
const { pageKey } = useListContext();
const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =
useItemListInfiniteLoader({
eventKey: pageKey || ItemListKey.ALBUM,
eventKey: ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -8,7 +8,6 @@ import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpe
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import {
AlbumListQuery,
@@ -44,11 +43,10 @@ export const AlbumListInfiniteTable = ({
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
const listQueryFn = api.controller.getAlbumList;
const { pageKey } = useListContext();
const { getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =
useItemListInfiniteLoader({
eventKey: pageKey || ItemListKey.ALBUM,
eventKey: ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -8,7 +8,6 @@ import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-lis
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
import { ItemListComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import {
AlbumListQuery,
@@ -37,7 +36,6 @@ export const AlbumListPaginatedDetail = ({
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
const listQueryFn = api.controller.getAlbumList;
const { pageKey } = useListContext();
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.ALBUM,
@@ -53,7 +51,7 @@ export const AlbumListPaginatedDetail = ({
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
currentPage,
eventKey: pageKey || ItemListKey.ALBUM,
eventKey: ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -8,7 +8,6 @@ import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/it
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { useGeneralSettings } from '/@/renderer/store';
import {
@@ -33,7 +32,6 @@ export const AlbumListPaginatedGrid = ({
serverId,
size,
}: AlbumListPaginatedGridProps) => {
const { pageKey } = useListContext();
const { currentPage, onChange } = useItemListPagination();
const listCountQuery = albumQueries.listCount({
@@ -45,7 +43,7 @@ export const AlbumListPaginatedGrid = ({
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
currentPage,
eventKey: pageKey || ItemListKey.ALBUM,
eventKey: ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -10,7 +10,6 @@ import { useItemListPagination } from '/@/renderer/components/item-list/item-lis
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import {
AlbumListQuery,
@@ -40,7 +39,6 @@ export const AlbumListPaginatedTable = ({
serverId,
size = 'default',
}: AlbumListPaginatedTableProps) => {
const { pageKey } = useListContext();
const { currentPage, onChange } = useItemListPagination();
const listCountQuery = albumQueries.listCount({
@@ -52,7 +50,7 @@ export const AlbumListPaginatedTable = ({
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
currentPage,
eventKey: pageKey || ItemListKey.ALBUM,
eventKey: ItemListKey.ALBUM,
itemsPerPage,
itemType: LibraryItem.ALBUM,
listCountQuery,
@@ -22,7 +22,6 @@ import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';
import { useFastAverageColor } from '/@/renderer/hooks';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { useSetGlobalExpanded } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
@@ -31,24 +30,10 @@ import { Spinner } from '/@/shared/components/spinner/spinner';
import { TextTitle } from '/@/shared/components/text-title/text-title';
import { Text } from '/@/shared/components/text/text';
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
import { LibraryItem, RelatedArtist, Song } from '/@/shared/types/domain-types';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop';
import { Play } from '/@/shared/types/types';
export interface ExpandedAlbumData {
_serverId: string;
albumArtists: RelatedArtist[];
id: string;
imageId: null | string;
name: string;
songs?: null | Song[];
}
export interface ExpandedAlbumListItemProps {
album?: ExpandedAlbumData;
item?: ItemListStateItem;
}
interface AlbumTracksTableProps {
isDark?: boolean;
serverId: string;
@@ -61,6 +46,11 @@ interface AlbumTracksTableProps {
}>;
}
interface ExpandedAlbumListItemProps {
internalState?: ItemListStateActions;
item: ItemListStateItem;
}
interface TrackRowProps {
controls: ReturnType<typeof useDefaultItemListControls>;
internalState: ItemListStateActions;
@@ -70,23 +60,6 @@ interface TrackRowProps {
songs: Song[];
}
const CloseExpandedButton = () => {
const setGlobalExpanded = useSetGlobalExpanded();
return (
<ActionIcon
className={clsx(styles.closeButton)}
icon="x"
iconProps={{
size: 'xl',
}}
onClick={() => setGlobalExpanded(null)}
radius="50%"
size="sm"
variant="default"
/>
);
};
const TrackRow = ({ controls, internalState, player, serverId, song, songs }: TrackRowProps) => {
const rowId = internalState.extractRowId(song);
const isSelected = useItemSelectionState(internalState, rowId);
@@ -215,165 +188,136 @@ const AlbumTracksTable = ({ isDark, serverId, songs }: AlbumTracksTableProps) =>
);
};
interface ExpandedAlbumListItemContentProps {
albumData: ExpandedAlbumData;
}
const ExpandedAlbumListItemContent = ({ albumData }: ExpandedAlbumListItemContentProps) => {
const player = usePlayer();
const imageUrl = useItemImageUrl({
id: albumData.imageId || undefined,
itemType: LibraryItem.ALBUM,
type: 'itemCard',
});
const color = useFastAverageColor({
algorithm: 'sqrt',
id: albumData.id,
src: imageUrl,
srcLoaded: true,
});
const handlePlay = useCallback(
(playType: Play) => {
if (albumData.songs?.length) {
player.addToQueueByData(albumData.songs, playType);
}
},
[albumData.songs, player],
);
if (color.isLoading) {
return <Spinner container />;
}
const songs = albumData.songs ?? null;
return (
<motion.div
animate={{ opacity: 1 }}
className={styles.container}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
style={{ backgroundColor: color.background }}
>
<div className={styles.expanded}>
<div className={styles.content}>
<div className={styles.header}>
<div className={styles.headerTitle}>
<TextTitle
className={clsx(styles.itemTitle, { [styles.dark]: color.isDark })}
fw={700}
order={4}
>
{albumData.name}
</TextTitle>
<CloseExpandedButton />
</div>
<Group
className={clsx(styles.itemSubtitle, { [styles.dark]: color.isDark })}
gap="xs"
>
{albumData.albumArtists?.map((artist, index) => (
<Fragment key={artist.id}>
<Text
className={clsx(styles.itemSubtitle, {
[styles.dark]: color.isDark,
})}
>
{artist.name}
</Text>
{index < (albumData.albumArtists?.length ?? 0) - 1 && (
<Separator />
)}
</Fragment>
))}
</Group>
</div>
<AlbumTracksTable
isDark={color.isDark}
serverId={albumData._serverId}
songs={songs ?? undefined}
/>
</div>
<div className={styles.imageContainer}>
<div
className={styles.backgroundImage}
style={{
['--bg-color' as string]: color?.background,
backgroundImage: `url(${imageUrl})`,
}}
/>
{songs && songs.length > 0 && (
<div className={styles.playButtonGroup}>
<PlayButtonGroup onPlay={handlePlay} />
</div>
)}
</div>
</div>
</motion.div>
);
};
const ExpandedAlbumListItemWithFetch = ({ item }: { item: ItemListStateItem }) => {
const { data } = useSuspenseQuery(
export const ExpandedAlbumListItem = ({ internalState, item }: ExpandedAlbumListItemProps) => {
const { data, isLoading } = useSuspenseQuery(
albumQueries.detail({
query: { id: item.id },
serverId: item._serverId,
}),
);
const albumData: ExpandedAlbumData = {
_serverId: item._serverId,
albumArtists: data?.albumArtists ?? [],
const player = usePlayer();
const imageUrl = useItemImageUrl({
id: item.imageId || undefined,
itemType: LibraryItem.ALBUM,
type: 'itemCard',
});
const color = useFastAverageColor({
algorithm: 'sqrt',
id: item.id,
imageId: item.imageId ?? data?.imageId ?? null,
name: data?.name ?? '',
songs: data?.songs ?? null,
};
src: imageUrl,
srcLoaded: true,
});
return <ExpandedAlbumListItemContent albumData={albumData} />;
};
const handlePlay = useCallback(
(playType: Play) => {
if (!data) {
return;
}
function itemToExpandedAlbumData(
item: ItemListStateItem & {
_playlistSongs?: Song[];
albumArtists?: RelatedArtist[];
name?: string;
},
): ExpandedAlbumData | null {
const songs =
(item as { songs?: Song[] }).songs ?? (item as { _playlistSongs?: Song[] })._playlistSongs;
if (songs == null) return null;
return {
_serverId: item._serverId,
albumArtists: item.albumArtists ?? [],
id: item.id,
imageId: (item as { imageId?: null | string }).imageId ?? null,
name: (item as { name?: string }).name ?? '',
songs,
};
}
if (data.songs) {
player.addToQueueByData(data.songs, playType);
}
},
[data, player],
);
export const ExpandedAlbumListItem = (props: ExpandedAlbumListItemProps) => {
if (props.album != null) {
return <ExpandedAlbumListItemContent albumData={props.album} />;
if (color.isLoading) {
return null;
}
if (props.item != null) {
const albumData = itemToExpandedAlbumData(props.item);
if (albumData != null) {
return <ExpandedAlbumListItemContent albumData={albumData} />;
}
return (
<Suspense fallback={<Spinner container />}>
<ExpandedAlbumListItemWithFetch item={props.item} />
return (
<motion.div
animate={{
opacity: 1,
}}
className={styles.container}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
style={{ backgroundColor: color.background }}
>
{isLoading && (
<div className={styles.loading}>
<Spinner />
</div>
)}
<Suspense>
<div className={styles.expanded}>
<div className={styles.content}>
<div className={styles.header}>
<div className={styles.headerTitle}>
<TextTitle
className={clsx(styles.itemTitle, {
[styles.dark]: color.isDark,
})}
fw={700}
order={4}
>
{data?.name}
</TextTitle>
{internalState && (
<ActionIcon
className={clsx(styles.closeButton)}
icon="x"
iconProps={{
size: 'xl',
}}
onClick={() => {
const rowId = internalState.extractRowId(item);
if (rowId) {
internalState.clearExpanded();
}
}}
radius="50%"
size="sm"
variant="default"
/>
)}
</div>
<Group
className={clsx(styles.itemSubtitle, {
[styles.dark]: color.isDark,
})}
gap="xs"
>
{data?.albumArtists.map((artist, index) => (
<Fragment key={artist.id}>
<Text
className={clsx(styles.itemSubtitle, {
[styles.dark]: color.isDark,
})}
>
{artist.name}
</Text>
{index < data?.albumArtists.length - 1 && <Separator />}
</Fragment>
))}
</Group>
</div>
<AlbumTracksTable
isDark={color.isDark}
serverId={item._serverId}
songs={data?.songs}
/>
</div>
<div className={styles.imageContainer}>
<div
className={styles.backgroundImage}
style={{
['--bg-color' as string]: color?.background,
backgroundImage: `url(${imageUrl})`,
}}
/>
{data?.songs && data.songs.length > 0 && (
<div className={styles.playButtonGroup}>
<PlayButtonGroup onPlay={handlePlay} />
</div>
)}
</div>
</div>
</Suspense>
);
}
return null;
</motion.div>
);
};
@@ -25,10 +25,7 @@ import { ItemControls } from '/@/renderer/components/item-list/types';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { AlbumArtistGridCarousel } from '/@/renderer/features/artists/components/album-artist-grid-carousel';
import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import {
CLIENT_SIDE_ALBUM_FILTERS,
ListSortByDropdownControlled,
@@ -478,10 +475,7 @@ const AlbumArtistMetadataTopSongsContent = ({
value={topSongsQueryType}
/>
<ListConfigMenu
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
listKey={ItemListKey.SONG}
optionsConfig={{
table: {
@@ -733,10 +727,7 @@ const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavori
value={searchTerm}
/>
<ListConfigMenu
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
listKey={ItemListKey.SONG}
optionsConfig={{
table: {
@@ -1076,7 +1067,6 @@ interface AlbumSectionProps {
albums: Album[];
controls: ItemControls;
cq: ReturnType<typeof useContainerQuery>;
enableExpansion?: boolean;
releaseType: string;
rows: DataRow[] | undefined;
title: React.ReactNode | string;
@@ -1096,15 +1086,7 @@ const getItemsPerRow = (cq: ReturnType<typeof useContainerQuery>) => {
return 2;
};
const AlbumSection = ({
albums,
controls,
cq,
enableExpansion,
releaseType,
rows,
title,
}: AlbumSectionProps) => {
const AlbumSection = ({ albums, controls, cq, releaseType, rows, title }: AlbumSectionProps) => {
const { t } = useTranslation();
const itemsPerRow = getItemsPerRow(cq);
@@ -1229,7 +1211,6 @@ const AlbumSection = ({
controls={controls}
data={album}
enableDrag
enableExpansion={enableExpansion ?? true}
itemType={LibraryItem.ALBUM}
rows={rows}
type="poster"
@@ -1407,6 +1388,7 @@ const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
const routeId = (artistId || albumArtistId) as string;
const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM);
const controls = useDefaultItemListControls();
const filteredAndSortedAlbums = useMemo(() => {
const albums = albumsQuery.data?.items || [];
@@ -1414,8 +1396,6 @@ const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
return sortAlbumList(searched, sortBy, sortOrder);
}, [albumsQuery.data?.items, debouncedSearchTerm, sortBy, sortOrder]);
const controls = useDefaultItemListControls();
const albumsByReleaseType = useMemo(() => {
return groupAlbumsByReleaseType(filteredAndSortedAlbums, routeId, groupingType);
}, [filteredAndSortedAlbums, routeId, groupingType]);
@@ -1684,7 +1664,6 @@ const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
albums={albums}
controls={controls}
cq={cq}
enableExpansion
key={releaseType}
releaseType={releaseType}
rows={rows}
@@ -20,6 +20,7 @@ export function AlbumArtistGridCarousel(props: AlbumArtistGridCarouselProps) {
const controls = useDefaultItemListControls();
const cards = useMemo(() => {
// Filter out excluded IDs if provided
const filteredItems = excludeIds
? data.filter((albumArtist) => !excludeIds.includes(albumArtist.id))
: data;
@@ -1,18 +1,63 @@
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { lazy, Suspense, useEffect, useRef } from 'react';
import { createCallable } from 'react-call';
import { useParams } from 'react-router';
import { AlbumArtistContextMenu } from '/@/renderer/features/context-menu/menus/album-artist-context-menu';
import { AlbumContextMenu } from '/@/renderer/features/context-menu/menus/album-context-menu';
import { ArtistContextMenu } from '/@/renderer/features/context-menu/menus/artist-context-menu';
import { FolderContextMenu } from '/@/renderer/features/context-menu/menus/folder-context-menu';
import { GenreContextMenu } from '/@/renderer/features/context-menu/menus/genre-context-menu';
import { PlaylistContextMenu } from '/@/renderer/features/context-menu/menus/playlist-context-menu';
import { PlaylistSongContextMenu } from '/@/renderer/features/context-menu/menus/playlist-song-context-menu';
import { QueueContextMenu } from '/@/renderer/features/context-menu/menus/queue-context-menu';
import { SongContextMenu } from '/@/renderer/features/context-menu/menus/song-context-menu';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
const AlbumArtistContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/album-artist-context-menu').then((module) => ({
default: module.AlbumArtistContextMenu,
})),
);
const AlbumContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/album-context-menu').then((module) => ({
default: module.AlbumContextMenu,
})),
);
const ArtistContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/artist-context-menu').then((module) => ({
default: module.ArtistContextMenu,
})),
);
const FolderContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/folder-context-menu').then((module) => ({
default: module.FolderContextMenu,
})),
);
const GenreContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/genre-context-menu').then((module) => ({
default: module.GenreContextMenu,
})),
);
const PlaylistContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/playlist-context-menu').then((module) => ({
default: module.PlaylistContextMenu,
})),
);
const PlaylistSongContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/playlist-song-context-menu').then((module) => ({
default: module.PlaylistSongContextMenu,
})),
);
const QueueContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/queue-context-menu').then((module) => ({
default: module.QueueContextMenu,
})),
);
const SongContextMenu = lazy(() =>
import('/@/renderer/features/context-menu/menus/song-context-menu').then((module) => ({
default: module.SongContextMenu,
})),
);
import {
Album,
AlbumArtist,
@@ -80,15 +125,17 @@ export const ContextMenuController = createCallable<ContextMenuControllerProps,
}}
/>
</ContextMenu.Target>
{cmd.type === LibraryItem.QUEUE_SONG && <QueueContextMenu {...cmd} />}
{cmd.type === LibraryItem.ALBUM && <AlbumContextMenu {...cmd} />}
{cmd.type === LibraryItem.ALBUM_ARTIST && <AlbumArtistContextMenu {...cmd} />}
{cmd.type === LibraryItem.ARTIST && <ArtistContextMenu {...cmd} />}
{cmd.type === LibraryItem.FOLDER && <FolderContextMenu {...cmd} />}
{cmd.type === LibraryItem.GENRE && <GenreContextMenu {...cmd} />}
{cmd.type === LibraryItem.PLAYLIST && <PlaylistContextMenu {...cmd} />}
{cmd.type === LibraryItem.PLAYLIST_SONG && <PlaylistSongContextMenu {...cmd} />}
{cmd.type === LibraryItem.SONG && <SongContextMenu {...cmd} />}
<Suspense fallback={null}>
{cmd.type === LibraryItem.QUEUE_SONG && <QueueContextMenu {...cmd} />}
{cmd.type === LibraryItem.ALBUM && <AlbumContextMenu {...cmd} />}
{cmd.type === LibraryItem.ALBUM_ARTIST && <AlbumArtistContextMenu {...cmd} />}
{cmd.type === LibraryItem.ARTIST && <ArtistContextMenu {...cmd} />}
{cmd.type === LibraryItem.FOLDER && <FolderContextMenu {...cmd} />}
{cmd.type === LibraryItem.GENRE && <GenreContextMenu {...cmd} />}
{cmd.type === LibraryItem.PLAYLIST && <PlaylistContextMenu {...cmd} />}
{cmd.type === LibraryItem.PLAYLIST_SONG && <PlaylistSongContextMenu {...cmd} />}
{cmd.type === LibraryItem.SONG && <SongContextMenu {...cmd} />}
</Suspense>
</ContextMenu>
);
},
@@ -3,10 +3,7 @@ import { useTranslation } from 'react-i18next';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
@@ -243,10 +240,7 @@ export const FolderListHeaderFilters = () => {
</Group>
<Group gap="sm" wrap="nowrap">
<ListConfigMenu
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
listKey={ItemListKey.SONG}
optionsConfig={{
grid: {
@@ -17,12 +17,7 @@ import { IgnoreCorsSslSwitches } from '/@/renderer/features/servers/components/i
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { AppRoute } from '/@/renderer/router/routes';
import {
getServerById,
useAuthStoreActions,
useCurrentServer,
useServerList,
} from '/@/renderer/store';
import { useAuthStoreActions, useCurrentServer } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { Center } from '/@/shared/components/center/center';
import { Code } from '/@/shared/components/code/code';
@@ -51,14 +46,11 @@ const SERVER_NAMES: Record<ServerType, string> = {
[ServerType.SUBSONIC]: 'OpenSubsonic',
};
const normalizeUrl = (url: string) => url.replace(/\/$/, '');
const LoginRoute = () => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const { addServer, setCurrentServer, updateServer } = useAuthStoreActions();
const { addServer, setCurrentServer } = useAuthStoreActions();
const currentServer = useCurrentServer();
const serverList = useServerList();
// Check if server lock is configured
const serverLock = isServerLock();
@@ -149,43 +141,24 @@ const LoginRoute = () => {
});
}
const normalizedUrl = normalizeUrl(serverUrl);
const existingServer =
serverLock &&
Object.values(serverList).find((s) => normalizeUrl(s.url) === normalizedUrl);
const serverItem: ServerListItemWithCredential = {
credential: data.credential,
id: nanoid(),
isAdmin: data.isAdmin,
name: serverName,
type: serverType as ServerType,
url: normalizedUrl,
url: serverUrl.replace(/\/$/, ''),
userId: data.userId,
username: data.username,
};
if (existingServer) {
const updates: Partial<ServerListItemWithCredential> = {
credential: data.credential,
isAdmin: data.isAdmin,
userId: data.userId,
username: data.username,
};
if (data.ndCredential !== undefined) {
updates.ndCredential = data.ndCredential;
}
updateServer(existingServer.id, updates);
const updated = getServerById(existingServer.id);
if (updated) setCurrentServer(updated);
} else {
if (data.ndCredential !== undefined) {
serverItem.ndCredential = data.ndCredential;
}
addServer(serverItem);
setCurrentServer(serverItem);
if (data.ndCredential !== undefined) {
serverItem.ndCredential = data.ndCredential;
}
addServer(serverItem);
setCurrentServer(serverItem);
toast.success({
message: t('form.addServer.success', { postProcess: 'sentenceCase' }),
});
@@ -6,10 +6,7 @@ import { queryKeys } from '/@/renderer/api/query-keys';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useRestoreQueue, useSaveQueue } from '/@/renderer/features/player/hooks/use-queue-restore';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
import { useCurrentServer } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils';
@@ -67,8 +64,10 @@ export const PlayQueueListControls = ({
/>
<ListConfigMenu
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
{
hidden: true,
value: ListDisplayType.GRID,
},
]}
listKey={type}
optionsConfig={{
@@ -21,10 +21,7 @@ import {
useIsRadioActive,
useRadioPlayer,
} from '/@/renderer/features/radio/hooks/use-radio-player';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { useFastAverageColor } from '/@/renderer/hooks';
import {
useFullScreenPlayerStore,
@@ -562,10 +559,7 @@ const Controls = () => {
buttonProps={{
variant: 'subtle',
}}
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
listKey={ItemListKey.FULL_SCREEN}
optionsConfig={{
table: {
@@ -4,10 +4,7 @@ import { useTranslation } from 'react-i18next';
import styles from './mobile-fullscreen-player.module.css';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import {
useFullScreenPlayerStore,
useFullScreenPlayerStoreActions,
@@ -368,10 +365,7 @@ export const MobileFullscreenPlayerHeader = memo(
buttonProps={{
variant: isPageHovered ? 'default' : 'subtle',
}}
displayTypes={[
{ hidden: true, value: ListDisplayType.GRID },
...SONG_DISPLAY_TYPES,
]}
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
listKey={ItemListKey.FULL_SCREEN}
optionsConfig={{
table: {
@@ -1,635 +0,0 @@
import type { RowComponentProps } from 'react-window-v2';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { applyClientSideSongFilters } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';
import {
ArtistMultiSelectRow,
GenreMultiSelectRow,
} from '/@/renderer/features/shared/components/multi-select-rows';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { useCurrentServer } from '/@/renderer/store';
import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import {
VirtualMultiSelect,
type VirtualMultiSelectOption,
} from '/@/shared/components/multi-select/virtual-multi-select';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
interface BooleanSegmentFilterProps {
label: string;
onChange: (value: boolean | null) => void;
segmentData: Array<{ label: string; value: string }>;
value: boolean | null | undefined;
}
function booleanToSegmentValue(value: boolean | null | undefined): string {
if (value === true) return 'true';
if (value === false) return 'false';
return 'none';
}
function segmentValueToBoolean(value: string): boolean | null {
if (value === 'true') return true;
if (value === 'false') return false;
return null;
}
const BooleanSegmentFilter = ({
label,
onChange,
segmentData,
value,
}: BooleanSegmentFilterProps) => (
<Stack gap="xs">
<Text size="sm" weight={500}>
{label}
</Text>
<SegmentedControl
data={segmentData}
onChange={(v) => onChange(segmentValueToBoolean(v))}
size="sm"
value={booleanToSegmentValue(value)}
w="100%"
/>
</Stack>
);
interface MultiSelectFilterOption {
albumCount: null | number;
imageUrl: string | undefined;
label: string;
songCount: number;
value: string;
}
interface MultiSelectFilterProps {
displayCountType?: 'song';
height: number;
label: React.ReactNode;
onChange: (value: null | string[]) => void;
options: MultiSelectFilterOption[];
RowComponent: (props: RowComponentProps<MultiSelectRowContext>) => React.ReactElement;
singleSelect: boolean;
value: string[];
}
type MultiSelectRowContext = {
disabled?: boolean;
displayCountType?: 'album' | 'song';
focusedIndex: null | number;
onToggle: (value: string) => void;
options: VirtualMultiSelectOption<MultiSelectFilterOption>[];
value: string[];
};
const MultiSelectFilter = ({
displayCountType = 'song',
height,
label,
onChange,
options,
RowComponent,
singleSelect,
value,
}: MultiSelectFilterProps) => (
<VirtualMultiSelect
displayCountType={displayCountType}
height={height}
label={label}
onChange={onChange}
options={options}
RowComponent={RowComponent}
singleSelect={singleSelect}
value={value}
/>
);
interface YearRangeFilterProps {
fromYearLabel: string;
maxYear: number | undefined;
minYear: number | undefined;
onMaxYear: (e: number | string) => void;
onMinYear: (e: number | string) => void;
toYearLabel: string;
}
const YearRangeFilter = ({
fromYearLabel,
maxYear,
minYear,
onMaxYear,
onMinYear,
toYearLabel,
}: YearRangeFilterProps) => (
<Group gap="sm" wrap="nowrap">
<NumberInput
hideControls={false}
label={fromYearLabel}
max={5000}
min={0}
onChange={(e) => onMinYear(e)}
style={{ flex: 1 }}
value={minYear != null ? minYear : ''}
/>
<NumberInput
hideControls={false}
label={toYearLabel}
max={5000}
min={0}
onChange={(e) => onMaxYear(e)}
style={{ flex: 1 }}
value={maxYear != null ? maxYear : ''}
/>
</Group>
);
interface MultiSelectFilterLabelProps {
andOrValue: 'and' | 'or';
entityLabel: string;
filterMultipleLabel: string;
filterSingleLabel: string;
matchAndLabel: string;
matchOrLabel: string;
onAndOrChange: (value: 'and' | 'or') => void;
onSingleMultiChange: (value: string) => void;
showAndOr: boolean;
singleMultiValue: 'multi' | 'single';
}
const MultiSelectFilterLabel = ({
andOrValue,
entityLabel,
filterMultipleLabel,
filterSingleLabel,
matchAndLabel,
matchOrLabel,
onAndOrChange,
onSingleMultiChange,
showAndOr,
singleMultiValue,
}: MultiSelectFilterLabelProps) => (
<Group gap="xs" justify="space-between" w="100%">
<Text fw={500} size="sm">
{entityLabel}
</Text>
<Group gap="xs">
{showAndOr && (
<SegmentedControl
data={[
{ label: matchAndLabel, value: 'and' },
{ label: matchOrLabel, value: 'or' },
]}
onChange={(value) => onAndOrChange(value === 'or' ? 'or' : 'and')}
size="xs"
value={andOrValue}
/>
)}
<SegmentedControl
data={[
{ label: filterSingleLabel, value: 'single' },
{ label: filterMultipleLabel, value: 'multi' },
]}
onChange={onSingleMultiChange}
size="xs"
value={singleMultiValue}
/>
</Group>
</Group>
);
export const ClientSideSongFilters = () => {
const { t } = useTranslation();
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const {
query,
setAlbumArtistIds,
setAlbumArtistIdsMode,
setArtistIds,
setArtistIdsMode,
setFavorite,
setGenreId,
setGenreIdsMode,
setHasRating,
setMaxYear,
setMinYear,
} = usePlaylistSongListFilters();
const playlistSongsQuery = useSuspenseQuery(
playlistsQueries.songList({
query: { id: playlistId },
serverId: server?.id,
}),
);
const albumArtistSelectMode = useAppStore((state) => state.albumArtistSelectMode);
const artistSelectMode = useAppStore((state) => state.artistSelectMode);
const genreSelectMode = useAppStore((state) => state.genreSelectMode);
const { setAlbumArtistSelectMode, setArtistSelectMode, setGenreSelectMode } =
useAppStoreActions();
const songs = useMemo(() => {
return (playlistSongsQuery.data?.items ?? []) as Song[];
}, [playlistSongsQuery.data]);
const filteredSongs = useMemo(
() => applyClientSideSongFilters(songs, query as Record<string, unknown>),
[songs, query],
);
const songsForAlbumArtistOptions = useMemo(() => {
const idsMode =
(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const useFilteredResult = albumArtistSelectMode === 'multi' && idsMode === 'and';
if (!useFilteredResult) {
const queryWithoutAlbumArtist = {
...query,
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: undefined,
} as Record<string, unknown>;
return applyClientSideSongFilters(songs, queryWithoutAlbumArtist);
}
return filteredSongs;
}, [albumArtistSelectMode, filteredSongs, query, songs]);
const songsForArtistOptions = useMemo(() => {
const idsMode =
(query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const useFilteredResult = artistSelectMode === 'multi' && idsMode === 'and';
if (!useFilteredResult) {
const queryWithoutArtist = {
...query,
[FILTER_KEYS.SONG.ARTIST_IDS]: undefined,
} as Record<string, unknown>;
return applyClientSideSongFilters(songs, queryWithoutArtist);
}
return filteredSongs;
}, [artistSelectMode, filteredSongs, query, songs]);
const songsForGenreOptions = useMemo(() => {
const idsMode =
(query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';
const useFilteredResult = genreSelectMode === 'multi' && idsMode === 'and';
if (!useFilteredResult) {
const queryWithoutGenre = {
...query,
[FILTER_KEYS.SONG.GENRE_ID]: undefined,
} as Record<string, unknown>;
return applyClientSideSongFilters(songs, queryWithoutGenre);
}
return filteredSongs;
}, [filteredSongs, genreSelectMode, query, songs]);
const albumArtistOptions = useMemo(() => {
const byId = new Map<
string,
{ id: string; imageUrl: string | undefined; name: string; songCount: number }
>();
for (const song of songsForAlbumArtistOptions) {
for (const artist of song.albumArtists ?? []) {
if (!artist.id) continue;
const existing = byId.get(artist.id);
if (existing) {
existing.songCount += 1;
} else {
byId.set(artist.id, {
id: artist.id,
imageUrl:
artist.imageUrl ??
getItemImageUrl({
id: artist.id,
itemType: LibraryItem.ALBUM_ARTIST,
type: 'table',
}),
name: artist.name,
songCount: 1,
});
}
}
}
return Array.from(byId.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((a) => ({
albumCount: null as null | number,
imageUrl: a.imageUrl,
label: a.name,
songCount: a.songCount,
value: a.id,
}));
}, [songsForAlbumArtistOptions]);
const artistOptions = useMemo(() => {
const byId = new Map<
string,
{ id: string; imageUrl: string | undefined; name: string; songCount: number }
>();
for (const song of songsForArtistOptions) {
for (const artist of song.artists ?? []) {
if (!artist.id) continue;
const existing = byId.get(artist.id);
if (existing) {
existing.songCount += 1;
} else {
byId.set(artist.id, {
id: artist.id,
imageUrl:
artist.imageUrl ??
getItemImageUrl({
id: artist.id,
itemType: LibraryItem.ARTIST,
type: 'table',
}),
name: artist.name,
songCount: 1,
});
}
}
}
return Array.from(byId.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((a) => ({
albumCount: null as null | number,
imageUrl: a.imageUrl,
label: a.name,
songCount: a.songCount,
value: a.id,
}));
}, [songsForArtistOptions]);
const genreOptions = useMemo(() => {
const byId = new Map<string, { id: string; name: string; songCount: number }>();
for (const song of songsForGenreOptions) {
for (const genre of song.genres ?? []) {
if (!genre.id) continue;
const existing = byId.get(genre.id);
if (existing) {
existing.songCount += 1;
} else {
byId.set(genre.id, {
id: genre.id,
name: genre.name,
songCount: 1,
});
}
}
}
return Array.from(byId.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((g) => ({
albumCount: null as null | number,
imageUrl: undefined,
label: g.name,
songCount: g.songCount,
value: g.id,
}));
}, [songsForGenreOptions]);
const segmentedControlData = useMemo(
() => [
{ label: t('common.none', { postProcess: 'titleCase' }), value: 'none' },
{ label: t('common.yes', { postProcess: 'titleCase' }), value: 'true' },
{ label: t('common.no', { postProcess: 'titleCase' }), value: 'false' },
],
[t],
);
const handleMinYear = useMemo(
() => (e: number | string) => {
if (e === '' || e === null || e === undefined) {
setMinYear(null);
return;
}
const year = typeof e === 'number' ? e : Number(e);
setMinYear(!isNaN(year) && isFinite(year) && year > 0 ? year : null);
},
[setMinYear],
);
const handleMaxYear = useMemo(
() => (e: number | string) => {
if (e === '' || e === null || e === undefined) {
setMaxYear(null);
return;
}
const year = typeof e === 'number' ? e : Number(e);
setMaxYear(!isNaN(year) && isFinite(year) && year > 0 ? year : null);
},
[setMaxYear],
);
const debouncedHandleMinYear = useDebouncedCallback(handleMinYear, 300);
const debouncedHandleMaxYear = useDebouncedCallback(handleMaxYear, 300);
const selectedGenreIds = useMemo(
() => (query[FILTER_KEYS.SONG.GENRE_ID] as string[] | undefined) ?? [],
[query],
);
const handleGenreSelectModeChange = useCallback(
(value: string) => {
const newMode = value as 'multi' | 'single';
setGenreSelectMode(newMode);
if (newMode === 'single' && selectedGenreIds.length > 1) {
setGenreId([selectedGenreIds[0]]);
}
},
[selectedGenreIds, setGenreId, setGenreSelectMode],
);
const genreIdsMode =
(query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';
const handleGenreChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
setGenreId(e);
} else {
setGenreId(null);
}
},
[setGenreId],
);
const selectedArtistIds = useMemo(
() => (query[FILTER_KEYS.SONG.ARTIST_IDS] as string[] | undefined) ?? [],
[query],
);
const handleArtistSelectModeChange = useCallback(
(value: string) => {
const newMode = value as 'multi' | 'single';
setArtistSelectMode(newMode);
if (newMode === 'single' && selectedArtistIds.length > 1) {
setArtistIds([selectedArtistIds[0]]);
}
},
[selectedArtistIds, setArtistIds, setArtistSelectMode],
);
const artistIdsMode =
(query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const handleArtistChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
setArtistIds(e);
} else {
setArtistIds(null);
}
},
[setArtistIds],
);
const selectedAlbumArtistIds = useMemo(
() => (query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS] as string[] | undefined) ?? [],
[query],
);
const handleAlbumArtistSelectModeChange = useCallback(
(value: string) => {
const newMode = value as 'multi' | 'single';
setAlbumArtistSelectMode(newMode);
if (newMode === 'single' && selectedAlbumArtistIds.length > 1) {
setAlbumArtistIds([selectedAlbumArtistIds[0]]);
}
},
[selectedAlbumArtistIds, setAlbumArtistIds, setAlbumArtistSelectMode],
);
const albumArtistIdsMode =
(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const handleAlbumArtistChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
setAlbumArtistIds(e);
} else {
setAlbumArtistIds(null);
}
},
[setAlbumArtistIds],
);
const queryFavorite = query[FILTER_KEYS.SONG.FAVORITE] as boolean | undefined;
const queryHasRating = query[FILTER_KEYS.SONG.HAS_RATING] as boolean | undefined;
const queryMinYear = query[FILTER_KEYS.SONG.MIN_YEAR] as number | undefined;
const queryMaxYear = query[FILTER_KEYS.SONG.MAX_YEAR] as number | undefined;
const matchAndLabel = t('filter.matchAnd', { postProcess: 'titleCase' });
const matchOrLabel = t('filter.matchOr', { postProcess: 'titleCase' });
const filterSingleLabel = t('common.filter_single', { postProcess: 'titleCase' });
const filterMultipleLabel = t('common.filter_multiple', { postProcess: 'titleCase' });
return (
<Stack px="md" py="md">
<BooleanSegmentFilter
label={t('filter.isFavorited', { postProcess: 'sentenceCase' })}
onChange={setFavorite}
segmentData={segmentedControlData}
value={queryFavorite}
/>
<Stack gap="xs" mt="md">
<BooleanSegmentFilter
label={t('filter.isRated', { postProcess: 'sentenceCase' })}
onChange={setHasRating}
segmentData={segmentedControlData}
value={queryHasRating}
/>
</Stack>
<Divider my="md" />
<MultiSelectFilter
height={300}
label={
<MultiSelectFilterLabel
andOrValue={artistIdsMode}
entityLabel={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
filterMultipleLabel={filterMultipleLabel}
filterSingleLabel={filterSingleLabel}
matchAndLabel={matchAndLabel}
matchOrLabel={matchOrLabel}
onAndOrChange={setArtistIdsMode}
onSingleMultiChange={handleArtistSelectModeChange}
showAndOr={artistSelectMode === 'multi'}
singleMultiValue={artistSelectMode}
/>
}
onChange={handleArtistChange}
options={artistOptions}
RowComponent={ArtistMultiSelectRow}
singleSelect={artistSelectMode === 'single'}
value={selectedArtistIds}
/>
<Divider my="md" />
<MultiSelectFilter
height={300}
label={
<MultiSelectFilterLabel
andOrValue={albumArtistIdsMode}
entityLabel={t('entity.albumArtist', {
count: 2,
postProcess: 'sentenceCase',
})}
filterMultipleLabel={filterMultipleLabel}
filterSingleLabel={filterSingleLabel}
matchAndLabel={matchAndLabel}
matchOrLabel={matchOrLabel}
onAndOrChange={setAlbumArtistIdsMode}
onSingleMultiChange={handleAlbumArtistSelectModeChange}
showAndOr={albumArtistSelectMode === 'multi'}
singleMultiValue={albumArtistSelectMode}
/>
}
onChange={handleAlbumArtistChange}
options={albumArtistOptions}
RowComponent={ArtistMultiSelectRow}
singleSelect={albumArtistSelectMode === 'single'}
value={selectedAlbumArtistIds}
/>
<Divider my="md" />
<MultiSelectFilter
height={220}
label={
<MultiSelectFilterLabel
andOrValue={genreIdsMode}
entityLabel={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
filterMultipleLabel={filterMultipleLabel}
filterSingleLabel={filterSingleLabel}
matchAndLabel={matchAndLabel}
matchOrLabel={matchOrLabel}
onAndOrChange={setGenreIdsMode}
onSingleMultiChange={handleGenreSelectModeChange}
showAndOr={genreSelectMode === 'multi'}
singleMultiValue={genreSelectMode}
/>
}
onChange={handleGenreChange}
options={genreOptions}
RowComponent={GenreMultiSelectRow}
singleSelect={genreSelectMode === 'single'}
value={selectedGenreIds}
/>
<Divider my="md" />
<YearRangeFilter
fromYearLabel={t('filter.fromYear', { postProcess: 'titleCase' })}
maxYear={queryMaxYear}
minYear={queryMinYear}
onMaxYear={debouncedHandleMaxYear}
onMinYear={debouncedHandleMinYear}
toYearLabel={t('filter.toYear', { postProcess: 'titleCase' })}
/>
</Stack>
);
};
@@ -15,7 +15,6 @@ import { useListContext } from '/@/renderer/context/list-context';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { applyClientSideSongFilters } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';
import { type PlaylistAlbumRow, playlistSongsToAlbums } from '/@/renderer/features/playlists/utils';
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
import { searchLibraryItems } from '/@/renderer/features/shared/utils';
@@ -28,13 +27,7 @@ import {
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import {
ItemListKey,
ListDisplayType,
ListPaginationType,
Play,
TableColumn,
} from '/@/shared/types/types';
import { ItemListKey, ListDisplayType, ListPaginationType, Play } from '/@/shared/types/types';
export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListResponse }) => {
const player = usePlayer();
@@ -47,25 +40,18 @@ export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListRespon
const { searchTerm } = useSearchTermFilter();
const { query } = usePlaylistSongListFilters();
const filteredAndSortedSongs = useMemo(() => {
const raw = data?.items ?? [];
const filtered = applyClientSideSongFilters(raw, query as Record<string, unknown>);
const searched = searchTerm?.trim()
? searchLibraryItems(filtered, searchTerm, LibraryItem.SONG)
: filtered;
return sortSongList(
searched,
const sortedAlbums = useMemo(() => {
let songs = data?.items ?? [];
if (searchTerm?.trim()) {
songs = searchLibraryItems(songs, searchTerm, LibraryItem.SONG);
}
const sortedSongs = sortSongList(
songs,
(query.sortBy as SongListSort) ?? SongListSort.ID,
(query.sortOrder as SortOrder) ?? SortOrder.ASC,
);
}, [data?.items, query, searchTerm]);
const sortedAlbums = useMemo(
() => playlistSongsToAlbums(filteredAndSortedSongs),
[filteredAndSortedSongs],
);
return playlistSongsToAlbums(sortedSongs);
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
const isPaginated = pagination === ListPaginationType.PAGINATED;
const totalAlbumCount = sortedAlbums.length;
@@ -81,7 +67,6 @@ export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListRespon
const albumControlOverrides = useMemo<Partial<ItemControls>>(() => {
return {
onFavorite: undefined,
onMore: ({ event, internalState, item }: DefaultItemControlProps) => {
if (!event) return;
@@ -126,7 +111,6 @@ export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListRespon
}
player.addToQueueByFetch(item._serverId, [item.id], itemType, playType);
},
onRating: undefined,
};
}, [player]);
@@ -135,8 +119,8 @@ export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListRespon
}, [setItemCount, totalAlbumCount]);
useEffect(() => {
setListData?.(filteredAndSortedSongs);
}, [filteredAndSortedSongs, setListData]);
setListData?.(data?.items ?? []);
}, [data?.items, setListData]);
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ enabled: true });
const { handleColumnReordered } = useItemListColumnReorder({
@@ -155,13 +139,6 @@ export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListRespon
});
const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.PLAYLIST_ALBUM, grid.size);
const tableColumns = useMemo(() => {
return table.columns.filter(
(column) =>
column.id !== TableColumn.USER_FAVORITE && column.id !== TableColumn.USER_RATING,
);
}, [table.columns]);
const renderAlbumList = () => {
switch (display) {
case ListDisplayType.DETAIL:
@@ -208,7 +185,7 @@ export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListRespon
<ItemTableList
autoFitColumns={table.autoFitColumns}
CellComponent={ItemTableListColumn}
columns={tableColumns}
columns={table.columns}
data={albumsToRender}
enableAlternateRowColors={table.enableAlternateRowColors}
enableHeader={table.enableHeader}
@@ -100,6 +100,7 @@ export type OverridePlaylistSongListQuery = Omit<Partial<PlaylistSongListQuery>,
interface PlaylistDetailSongListViewProps {
data: PlaylistSongListResponse;
/** When provided, table/grid use this instead of computing from data (avoids duplicate filter/sort). */
items?: Song[];
}
@@ -282,6 +283,7 @@ export const PlaylistDetailSongListEdit = ({ data }: { data: PlaylistSongListRes
}
};
/** Track view: view mode uses centralized list derivation; edit mode uses local reorder state. */
const PlaylistDetailTrackView = ({ data }: { data: PlaylistSongListResponse }) => {
const { isSmartPlaylist, mode } = useListContext();
@@ -296,6 +298,7 @@ const PlaylistDetailTrackView = ({ data }: { data: PlaylistSongListResponse }) =
return <PlaylistDetailTrackViewContent data={data} />;
};
/** Uses usePlaylistTrackList once and passes derived items to the list view. */
const PlaylistDetailTrackViewContent = ({ data }: { data: PlaylistSongListResponse }) => {
const { sortedAndFilteredSongs } = usePlaylistTrackList(data);
return <PlaylistDetailSongListView data={data} items={sortedAndFilteredSongs} />;
@@ -1,6 +1,6 @@
import { openContextModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
@@ -13,20 +13,12 @@ import {
import { useListContext } from '/@/renderer/context/list-context';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { ClientSideSongFilters } from '/@/renderer/features/playlists/components/client-side-song-filters';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { FilterButton } from '/@/renderer/features/shared/components/filter-button';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button';
import { isFilterValueSet } from '/@/renderer/features/shared/components/list-filters';
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { useContainerQuery } from '/@/renderer/hooks';
import {
PlaylistTarget,
@@ -40,9 +32,7 @@ import { Divider } from '/@/shared/components/divider/divider';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Modal } from '/@/shared/components/modal/modal';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
@@ -51,69 +41,6 @@ interface PlaylistDetailSongListHeaderFiltersProps {
isSmartPlaylist?: boolean;
}
const PlaylistSongListFiltersModal = () => {
const { t } = useTranslation();
const { isSidebarOpen, setIsSidebarOpen } = useListContext();
const { clear, query } = usePlaylistSongListFilters();
const [isOpen, handlers] = useDisclosure(false);
const hasActiveFilters = useMemo(() => {
return Boolean(
isFilterValueSet(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]) ||
isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) ||
query[FILTER_KEYS.SONG.FAVORITE] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) ||
query[FILTER_KEYS.SONG.HAS_RATING] !== undefined ||
query[FILTER_KEYS.SONG.MAX_YEAR] !== undefined ||
query[FILTER_KEYS.SONG.MIN_YEAR] !== undefined,
);
}, [query]);
const handlePin = () => {
setIsSidebarOpen?.(!isSidebarOpen);
};
const canPin = Boolean(setIsSidebarOpen);
return (
<>
<FilterButton isActive={hasActiveFilters} onClick={handlers.toggle} />
<Modal
handlers={handlers}
opened={isOpen}
size="lg"
styles={{
content: {
height: '100%',
maxHeight: '640px',
maxWidth: 'var(--theme-content-max-width)',
width: '100%',
},
}}
title={
<Group justify="space-between" style={{ paddingRight: '3rem', width: '100%' }}>
<Group>
{canPin && (
<ActionIcon
icon={isSidebarOpen ? 'unpin' : 'pin'}
onClick={handlePin}
variant="subtle"
/>
)}
{t('common.filters', { postProcess: 'sentenceCase' })}
</Group>
<Button onClick={clear} size="compact-sm" variant="subtle">
{t('common.reset', { postProcess: 'sentenceCase' })}
</Button>
</Group>
}
>
<ClientSideSongFilters />
</Modal>
</>
);
};
export const PlaylistDetailSongListHeaderFilters = ({
isSmartPlaylist,
}: PlaylistDetailSongListHeaderFiltersProps) => {
@@ -187,8 +114,6 @@ export const PlaylistDetailSongListHeaderFilters = ({
disabled={isEditMode}
listKey={ItemListKey.PLAYLIST_SONG}
/>
<Divider orientation="vertical" />
<PlaylistSongListFiltersModal />
<ListRefreshButton disabled={isEditMode} listKey={listKey} />
<MoreButton onClick={handleMore} />
</Group>
@@ -232,7 +157,6 @@ export const PlaylistDetailSongListHeaderFilters = ({
/>
) : (
<ListConfigMenu
displayTypes={SONG_DISPLAY_TYPES}
listKey={listKey}
tableColumnsData={PLAYLIST_SONG_TABLE_COLUMNS}
/>
@@ -1,393 +0,0 @@
import { closeAllModals, openModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
PlaylistQueryBuilder,
PlaylistQueryBuilderRef,
} from '/@/renderer/features/playlists/components/playlist-query-builder';
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
import { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
import { Box } from '/@/shared/components/box/box';
import { Button } from '/@/shared/components/button/button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { JsonInput } from '/@/shared/components/json-input/json-input';
import { ConfirmModal } from '/@/shared/components/modal/modal';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import { SongListSort } from '/@/shared/types/domain-types';
export interface PlaylistQueryEditorProps {
createPlaylistMutation: ReturnType<typeof useCreatePlaylist>;
detailQuery: ReturnType<typeof useQuery<any>>;
handleSave: (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
) => void;
handleSaveAs: (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
) => void;
isQueryBuilderExpanded: boolean;
onToggleExpand: () => void;
playlistId: string;
queryBuilderRef: React.RefObject<null | PlaylistQueryBuilderRef>;
}
type AppliedJsonState = {
limit?: number;
query: Record<string, any>;
sort?: string;
};
type EditorMode = 'builder' | 'json';
const serializeFiltersToRulesJson = (filters: {
extraFilters: { limit?: number; sortBy?: string[] };
filters: any;
}): Record<string, any> => {
const queryValue = convertQueryGroupToNDQuery(filters.filters);
const sortString = filters.extraFilters.sortBy?.[0];
return {
...queryValue,
...(filters.extraFilters.limit != null && { limit: filters.extraFilters.limit }),
...(sortString && { sort: sortString }),
};
};
const parseRulesJsonToSaveArgs = (
parsed: Record<string, any>,
): { extraFilters: { limit?: number; sortBy?: string[] }; filter: Record<string, any> } => {
const rootKey = parsed.all ? 'all' : 'any';
const filter = rootKey in parsed ? { [rootKey]: parsed[rootKey] } : { all: [] };
return {
extraFilters: {
...(parsed.limit != null && { limit: parsed.limit }),
...(parsed.sort != null && { sortBy: [parsed.sort] }),
},
filter,
};
};
export const PlaylistQueryEditor = ({
createPlaylistMutation,
detailQuery,
handleSave,
handleSaveAs,
isQueryBuilderExpanded,
onToggleExpand,
playlistId,
queryBuilderRef,
}: PlaylistQueryEditorProps) => {
const { t } = useTranslation();
const [editorMode, setEditorMode] = useState<EditorMode>('builder');
const [jsonText, setJsonText] = useState('');
const [appliedJsonState, setAppliedJsonState] = useState<AppliedJsonState | null>(null);
const getFiltersForSave = useCallback((): null | {
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string };
filter: Record<string, any>;
} => {
if (editorMode === 'json') {
try {
const parsed = JSON.parse(jsonText) as Record<string, any>;
const { extraFilters, filter } = parseRulesJsonToSaveArgs(parsed);
return { extraFilters, filter };
} catch {
return null;
}
}
const filters = queryBuilderRef.current?.getFilters();
if (!filters) return null;
return {
extraFilters: filters.extraFilters,
filter: convertQueryGroupToNDQuery(filters.filters),
};
}, [editorMode, jsonText, queryBuilderRef]);
const openPreviewModal = useCallback(() => {
const payload = getFiltersForSave();
if (!payload) {
if (editorMode === 'json') {
toast.error({ message: t('error.invalidJson', { postProcess: 'sentenceCase' }) });
}
return;
}
const previewValue = {
...payload.filter,
...(payload.extraFilters.limit != null && { limit: payload.extraFilters.limit }),
...(payload.extraFilters.sortBy?.[0] && { sort: payload.extraFilters.sortBy[0] }),
};
openModal({
children: <JsonPreview value={previewValue} />,
size: 'xl',
title: t('common.preview', { postProcess: 'titleCase' }),
});
}, [editorMode, getFiltersForSave, t]);
const openSaveAndReplaceModal = useCallback(() => {
if (!isQueryBuilderExpanded) return;
const payload = getFiltersForSave();
if (!payload) {
if (editorMode === 'json') {
toast.error({ message: t('error.invalidJson', { postProcess: 'sentenceCase' }) });
}
return;
}
openModal({
children: (
<ConfirmModal
onConfirm={() => {
handleSave(payload.filter, payload.extraFilters);
closeAllModals();
}}
>
<Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>
</ConfirmModal>
),
title: t('common.saveAndReplace', { postProcess: 'titleCase' }),
});
}, [editorMode, getFiltersForSave, handleSave, isQueryBuilderExpanded, t]);
const parseSortBy = useCallback((): string[] => {
const sort = detailQuery?.data?.rules?.sort;
// Handle new syntax: comma-separated with +/- prefix
// e.g., "+album,-year" -> return as single string in array
if (typeof sort === 'string') {
// Check if it's new syntax (has +/- prefix or commas)
if (sort.includes(',') || sort.startsWith('+') || sort.startsWith('-')) {
return [sort];
}
// Old syntax: single field, convert to new format with default order
const order = detailQuery?.data?.rules?.order || 'asc';
const prefix = order === 'desc' ? '-' : '+';
return [`${prefix}${sort}`];
}
if (Array.isArray(sort)) {
// If array, check if first item has +/- prefix
if (
sort.length > 0 &&
typeof sort[0] === 'string' &&
(sort[0].startsWith('+') || sort[0].startsWith('-'))
) {
return sort;
}
// Old array format, convert to new format
const order = detailQuery?.data?.rules?.order || 'asc';
const prefix = order === 'desc' ? '-' : '+';
return sort.map((s) => `${prefix}${s}`);
}
return ['+dateAdded'];
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
const parseSortOrder = useCallback((): 'asc' | 'desc' => {
const sort = detailQuery?.data?.rules?.sort;
if (typeof sort === 'string' && sort.startsWith('-')) {
return 'desc';
}
// Fall back to old order field or default
return detailQuery?.data?.rules?.order || 'asc';
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
const effectiveQuery = useMemo(
() =>
appliedJsonState?.query ??
(detailQuery?.data?.rules?.all
? { all: detailQuery.data.rules.all }
: detailQuery?.data?.rules?.any
? { any: detailQuery.data.rules.any }
: detailQuery?.data?.rules),
[appliedJsonState?.query, detailQuery?.data?.rules],
);
const effectiveLimit = appliedJsonState?.limit ?? detailQuery?.data?.rules?.limit;
const effectiveSortBy = useMemo(
() =>
(appliedJsonState?.sort ? [appliedJsonState.sort] : parseSortBy()) as
| SongListSort
| SongListSort[],
[appliedJsonState?.sort, parseSortBy],
);
const effectiveSortOrder = appliedJsonState?.sort
? appliedJsonState.sort.startsWith('-')
? 'desc'
: 'asc'
: parseSortOrder();
const handleEditorModeChange = useCallback(
(value: string) => {
const nextMode = value as EditorMode;
if (nextMode === 'json') {
const filters = queryBuilderRef.current?.getFilters();
if (filters) {
setJsonText(JSON.stringify(serializeFiltersToRulesJson(filters), null, 2));
} else {
const fallback: Record<string, any> = effectiveQuery
? { ...effectiveQuery }
: { all: [] };
if (effectiveLimit != null) fallback.limit = effectiveLimit;
if (effectiveSortBy?.[0]) fallback.sort = effectiveSortBy[0];
if (!fallback.sort) fallback.sort = '+dateAdded';
setJsonText(JSON.stringify(fallback, null, 2));
}
setEditorMode('json');
} else {
if (editorMode === 'json') {
try {
const parsed = JSON.parse(jsonText) as Record<string, any>;
const rootKey = parsed.all ? 'all' : 'any';
if (!parsed[rootKey] || !Array.isArray(parsed[rootKey])) {
throw new Error('Invalid rules structure');
}
setAppliedJsonState({
limit: parsed.limit,
query: { [rootKey]: parsed[rootKey] },
sort: parsed.sort,
});
} catch {
toast.error({
message: t('error.invalidJson', {
postProcess: 'sentenceCase',
}),
});
return;
}
}
setEditorMode('builder');
}
},
[editorMode, effectiveLimit, effectiveQuery, effectiveSortBy, jsonText, queryBuilderRef, t],
);
return (
<div
className="query-editor-container"
style={{ borderTop: '1px solid var(--theme-colors-border)' }}
>
<Stack gap={0} h="100%" mah="30dvh" p="sm" w="100%">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<Button
leftSection={
<Icon
icon={isQueryBuilderExpanded ? 'arrowDownS' : 'arrowUpS'}
size="lg"
/>
}
onClick={onToggleExpand}
size="sm"
variant="subtle"
>
{t('form.queryEditor.title', {
postProcess: 'titleCase',
})}
</Button>
{isQueryBuilderExpanded && (
<SegmentedControl
data={[
{
label: (
<Flex>
<Icon icon="queryBuilder" />
</Flex>
),
value: 'builder',
},
{
label: (
<Flex>
<Icon icon="json" />
</Flex>
),
value: 'json',
},
]}
onChange={handleEditorModeChange}
size="xs"
value={editorMode}
/>
)}
</Group>
<Group gap="xs">
<Button onClick={openPreviewModal} size="sm" variant="subtle">
{t('common.preview', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={!isQueryBuilderExpanded}
leftSection={<Icon icon="save" />}
loading={createPlaylistMutation?.isPending}
onClick={() => {
if (!isQueryBuilderExpanded) return;
const payload = getFiltersForSave();
if (payload) {
handleSaveAs(payload.filter, payload.extraFilters);
} else if (editorMode === 'json') {
toast.error({
message: t('error.invalidJson', {
postProcess: 'sentenceCase',
}),
});
}
}}
size="sm"
variant="subtle"
>
{t('common.saveAs', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={!isQueryBuilderExpanded}
leftSection={<Icon color="error" icon="save" />}
onClick={openSaveAndReplaceModal}
size="sm"
variant="subtle"
>
{t('common.saveAndReplace', {
postProcess: 'titleCase',
})}
</Button>
</Group>
</Group>
<Box
py="md"
style={{
display: isQueryBuilderExpanded ? 'flex' : 'none',
flex: 1,
minHeight: 0,
overflow: 'hidden',
}}
>
{editorMode === 'builder' ? (
<PlaylistQueryBuilder
key={JSON.stringify(appliedJsonState ?? detailQuery?.data?.rules)}
limit={effectiveLimit}
playlistId={playlistId}
query={effectiveQuery}
ref={queryBuilderRef}
sortBy={effectiveSortBy}
sortOrder={effectiveSortOrder}
/>
) : (
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<JsonInput
autosize
minRows={8}
onChange={(value) => setJsonText(value)}
placeholder='{ "all": [], "limit": 100, "sort": "+dateAdded" }'
spellCheck={false}
style={{ flex: 1, minHeight: 0 }}
value={jsonText}
/>
</ScrollArea>
)}
</Box>
</Stack>
</div>
);
};
@@ -5,25 +5,17 @@ import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-searc
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 { useAppStore } from '/@/renderer/store/app.store';
import {
parseArrayParam,
parseBooleanParam,
parseCustomFiltersParam,
parseIntParam,
setMultipleSearchParams,
setSearchParam,
} from '/@/renderer/utils/query-params';
import { SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export const usePlaylistSongListFilters = () => {
const albumArtistIdsMode = useAppStore((state) => state.albumArtistIdsMode);
const artistIdsMode = useAppStore((state) => state.artistIdsMode);
const genreIdsMode = useAppStore((state) => state.genreIdsMode);
const setAlbumArtistIdsModeStore = useAppStore((state) => state.actions.setAlbumArtistIdsMode);
const setArtistIdsModeStore = useAppStore((state) => state.actions.setArtistIdsMode);
const setGenreIdsModeStore = useAppStore((state) => state.actions.setGenreIdsMode);
const { sortBy } = useSortByFilter<SongListSort>(SongListSort.ID, ItemListKey.PLAYLIST_SONG);
const { sortOrder } = useSortOrderFilter(SortOrder.ASC, ItemListKey.PLAYLIST_SONG);
@@ -32,8 +24,8 @@ export const usePlaylistSongListFilters = () => {
const [searchParams, setSearchParams] = useSearchParams();
const albumArtistIds = useMemo(
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS),
const albumIds = useMemo(
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_IDS),
[searchParams],
);
@@ -62,22 +54,16 @@ export const usePlaylistSongListFilters = () => {
[searchParams],
);
const hasRating = useMemo(
() => parseBooleanParam(searchParams, FILTER_KEYS.SONG.HAS_RATING),
[searchParams],
);
const custom = useMemo(
() => parseCustomFiltersParam(searchParams, FILTER_KEYS.SONG._CUSTOM),
[searchParams],
);
const setAlbumArtistIds = useCallback(
const setAlbumIds = useCallback(
(value: null | string[]) => {
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS, value),
{ replace: true },
);
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_IDS, value), {
replace: true,
});
},
[setSearchParams],
);
@@ -127,30 +113,6 @@ export const usePlaylistSongListFilters = () => {
[setSearchParams],
);
const setHasRating = useCallback(
(value: boolean | null) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.HAS_RATING, value), {
replace: true,
});
},
[setSearchParams],
);
const setAlbumArtistIdsMode = useCallback(
(value: 'and' | 'or') => setAlbumArtistIdsModeStore(value),
[setAlbumArtistIdsModeStore],
);
const setArtistIdsMode = useCallback(
(value: 'and' | 'or') => setArtistIdsModeStore(value),
[setArtistIdsModeStore],
);
const setGenreIdsMode = useCallback(
(value: 'and' | 'or') => setGenreIdsModeStore(value),
[setGenreIdsModeStore],
);
const setCustom = useCallback(
(value: null | Record<string, any>) => {
setSearchParams(
@@ -179,74 +141,26 @@ export const usePlaylistSongListFilters = () => {
[setSearchParams],
);
const clear = useCallback(() => {
setSearchParams(
(prev) =>
setMultipleSearchParams(
prev,
{
[FILTER_KEYS.SONG._CUSTOM]: null,
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: null,
[FILTER_KEYS.SONG.ARTIST_IDS]: null,
[FILTER_KEYS.SONG.FAVORITE]: null,
[FILTER_KEYS.SONG.GENRE_ID]: null,
[FILTER_KEYS.SONG.HAS_RATING]: null,
[FILTER_KEYS.SONG.MAX_YEAR]: null,
[FILTER_KEYS.SONG.MIN_YEAR]: null,
},
new Set([FILTER_KEYS.SONG._CUSTOM]),
),
{ replace: true },
);
}, [setSearchParams]);
const query = useMemo(
() => ({
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
[FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined,
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: albumArtistIds ?? undefined,
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE]: albumArtistIdsMode,
[FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined,
[FILTER_KEYS.SONG.ARTIST_IDS_MODE]: artistIdsMode,
[FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined,
[FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined,
[FILTER_KEYS.SONG.GENRE_ID_MODE]: genreIdsMode,
[FILTER_KEYS.SONG.HAS_RATING]: hasRating ?? undefined,
[FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined,
[FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined,
}),
[
searchTerm,
sortBy,
sortOrder,
custom,
albumArtistIds,
albumArtistIdsMode,
artistIds,
artistIdsMode,
favorite,
genreId,
genreIdsMode,
hasRating,
maxYear,
minYear,
],
);
const query = {
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
[FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined,
[FILTER_KEYS.SONG.ALBUM_IDS]: albumIds ?? undefined,
[FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined,
[FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined,
[FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined,
[FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined,
[FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined,
};
return {
clear,
query,
setAlbumArtistIds,
setAlbumArtistIdsMode,
setAlbumIds,
setArtistIds,
setArtistIdsMode,
setCustom,
setFavorite,
setGenreId,
setGenreIdsMode,
setHasRating,
setMaxYear,
setMinYear,
setSearchTerm,
@@ -3,88 +3,9 @@ import { useEffect, useMemo } from 'react';
import { useListContext } from '/@/renderer/context/list-context';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { searchLibraryItems } from '/@/renderer/features/shared/utils';
import { sortSongList } from '/@/shared/api/utils';
import {
LibraryItem,
PlaylistSongListResponse,
Song,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
export function applyClientSideSongFilters(songs: Song[], query: Record<string, unknown>): Song[] {
let result = songs;
const favorite = query[FILTER_KEYS.SONG.FAVORITE] as boolean | undefined;
if (favorite === true) {
result = result.filter((s) => s.userFavorite === true);
} else if (favorite === false) {
result = result.filter((s) => s.userFavorite === false);
}
const hasRating = query[FILTER_KEYS.SONG.HAS_RATING] as boolean | undefined;
if (hasRating === true) {
result = result.filter((s) => s.userRating != null && s.userRating > 0);
} else if (hasRating === false) {
result = result.filter((s) => s.userRating == null || s.userRating === 0);
}
const albumArtistIdsMode =
(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const albumArtistIds = query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS] as string[] | undefined;
if (albumArtistIds?.length) {
if (albumArtistIdsMode === 'and') {
result = result.filter((s) =>
albumArtistIds!.every((id) => s.albumArtists?.some((a) => a.id === id)),
);
} else {
const set = new Set(albumArtistIds);
result = result.filter((s) => s.albumArtists?.some((a) => a.id && set.has(a.id)));
}
}
const artistIdsMode =
(query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const artistIds = query[FILTER_KEYS.SONG.ARTIST_IDS] as string[] | undefined;
if (artistIds?.length) {
if (artistIdsMode === 'and') {
result = result.filter((s) =>
artistIds!.every((id) => s.artists?.some((a) => a.id === id)),
);
} else {
const set = new Set(artistIds);
result = result.filter((s) => s.artists?.some((a) => a.id && set.has(a.id)));
}
}
const genreIdsMode =
(query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';
const genreIds = query[FILTER_KEYS.SONG.GENRE_ID] as string[] | undefined;
if (genreIds?.length) {
if (genreIdsMode === 'and') {
result = result.filter((s) =>
genreIds!.every((id) => s.genres?.some((g) => g.id === id)),
);
} else {
const set = new Set(genreIds);
result = result.filter((s) => s.genres?.some((g) => g.id && set.has(g.id)));
}
}
const minYear = query[FILTER_KEYS.SONG.MIN_YEAR] as number | undefined;
if (minYear != null) {
result = result.filter((s) => s.releaseYear != null && s.releaseYear >= minYear);
}
const maxYear = query[FILTER_KEYS.SONG.MAX_YEAR] as number | undefined;
if (maxYear != null) {
result = result.filter((s) => s.releaseYear != null && s.releaseYear <= maxYear);
}
return result;
}
import { LibraryItem, PlaylistSongListResponse, Song } from '/@/shared/types/domain-types';
export function usePlaylistTrackList(data: PlaylistSongListResponse | undefined): {
sortedAndFilteredSongs: Song[];
@@ -96,23 +17,20 @@ export function usePlaylistTrackList(data: PlaylistSongListResponse | undefined)
const sortedAndFilteredSongs = useMemo(() => {
const raw = data?.items ?? [];
const filtered = applyClientSideSongFilters(raw, query as Record<string, unknown>);
const searched = searchTerm
? searchLibraryItems(filtered, searchTerm, LibraryItem.SONG)
: filtered;
return sortSongList(
searched,
(query.sortBy as SongListSort) ?? SongListSort.ID,
(query.sortOrder as SortOrder) ?? SortOrder.ASC,
);
}, [data?.items, query, searchTerm]);
if (searchTerm) {
return searchLibraryItems(raw, searchTerm, LibraryItem.SONG);
}
return sortSongList(raw, query.sortBy, query.sortOrder);
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
const totalCount = sortedAndFilteredSongs.length;
useEffect(() => {
setListData?.(sortedAndFilteredSongs);
setItemCount?.(totalCount);
}, [query, searchTerm, setListData, setItemCount, sortedAndFilteredSongs, totalCount]);
}, [sortedAndFilteredSongs, totalCount, setListData, setItemCount]);
return { sortedAndFilteredSongs, totalCount };
}
@@ -1,71 +1,238 @@
import { closeAllModals, openModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import { Suspense, useMemo, useRef, useState } from 'react';
import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath, useLocation, useNavigate, useParams } from 'react-router';
import { ListContext, useListContext } from '/@/renderer/context/list-context';
import { ListContext } from '/@/renderer/context/list-context';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { ClientSideSongFilters } from '/@/renderer/features/playlists/components/client-side-song-filters';
import { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content';
import { PlaylistDetailSongListHeader } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header';
import { PlaylistQueryBuilderRef } from '/@/renderer/features/playlists/components/playlist-query-builder';
import { PlaylistQueryEditor } from '/@/renderer/features/playlists/components/playlist-query-editor';
import {
PlaylistQueryBuilder,
PlaylistQueryBuilderRef,
} from '/@/renderer/features/playlists/components/playlist-query-builder';
import { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
import { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { AppRoute } from '/@/renderer/router/routes';
import {
PlaylistTarget,
useCurrentServer,
usePageSidebar,
usePlaylistTarget,
} from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { PlaylistTarget, useCurrentServer, usePlaylistTarget } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { ConfirmModal } from '/@/shared/components/modal/modal';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
import { LibraryItem, ServerType, SongListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
const PlaylistSongListFiltersSidebar = () => {
interface PlaylistQueryEditorProps {
createPlaylistMutation: ReturnType<typeof useCreatePlaylist>;
detailQuery: ReturnType<typeof useQuery<any>>;
handleSave: (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
) => void;
handleSaveAs: (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
) => void;
isQueryBuilderExpanded: boolean;
onToggleExpand: () => void;
playlistId: string;
queryBuilderRef: React.RefObject<null | PlaylistQueryBuilderRef>;
}
const PlaylistQueryEditor = ({
createPlaylistMutation,
detailQuery,
handleSave,
handleSaveAs,
isQueryBuilderExpanded,
onToggleExpand,
playlistId,
queryBuilderRef,
}: PlaylistQueryEditorProps) => {
const { t } = useTranslation();
const { setIsSidebarOpen } = useListContext();
const { clear } = usePlaylistSongListFilters();
const openPreviewModal = useCallback(() => {
const filters = queryBuilderRef.current?.getFilters();
if (!filters) {
return;
}
const queryValue = convertQueryGroupToNDQuery(filters.filters);
const sortString = filters.extraFilters.sortBy?.[0];
const previewValue = {
...queryValue,
...(filters.extraFilters.limit && { limit: filters.extraFilters.limit }),
...(sortString && { sort: sortString }),
};
openModal({
children: <JsonPreview value={previewValue} />,
size: 'xl',
title: t('common.preview', { postProcess: 'titleCase' }),
});
}, [queryBuilderRef, t]);
const openSaveAndReplaceModal = useCallback(() => {
if (!isQueryBuilderExpanded) {
return;
}
const filters = queryBuilderRef.current?.getFilters();
if (!filters) {
return;
}
openModal({
children: (
<ConfirmModal
onConfirm={() => {
handleSave(
convertQueryGroupToNDQuery(filters.filters),
filters.extraFilters,
);
closeAllModals();
}}
>
<Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>
</ConfirmModal>
),
title: t('common.saveAndReplace', { postProcess: 'sentenceCase' }),
});
}, [isQueryBuilderExpanded, queryBuilderRef, handleSave, t]);
const parseSortBy = useCallback((): string[] => {
const sort = detailQuery?.data?.rules?.sort;
// Handle new syntax: comma-separated with +/- prefix
// e.g., "+album,-year" -> return as single string in array
if (typeof sort === 'string') {
// Check if it's new syntax (has +/- prefix or commas)
if (sort.includes(',') || sort.startsWith('+') || sort.startsWith('-')) {
return [sort];
}
// Old syntax: single field, convert to new format with default order
const order = detailQuery?.data?.rules?.order || 'asc';
const prefix = order === 'desc' ? '-' : '+';
return [`${prefix}${sort}`];
}
if (Array.isArray(sort)) {
// If array, check if first item has +/- prefix
if (
sort.length > 0 &&
typeof sort[0] === 'string' &&
(sort[0].startsWith('+') || sort[0].startsWith('-'))
) {
return sort;
}
// Old array format, convert to new format
const order = detailQuery?.data?.rules?.order || 'asc';
const prefix = order === 'desc' ? '-' : '+';
return sort.map((s) => `${prefix}${s}`);
}
return ['+dateAdded'];
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
const parseSortOrder = useCallback((): 'asc' | 'desc' => {
const sort = detailQuery?.data?.rules?.sort;
if (typeof sort === 'string' && sort.startsWith('-')) {
return 'desc';
}
// Fall back to old order field or default
return detailQuery?.data?.rules?.order || 'asc';
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
return (
<Stack h="100%" style={{ minHeight: 0 }}>
<Group justify="space-between" pb={0} pl="md" pr="md" pt="md">
<Text fw={500} size="xl">
{t('common.filters', { postProcess: 'sentenceCase' })}
</Text>
<Group gap="xs">
<Button onClick={clear} size="compact-sm" variant="subtle">
{t('common.reset', { postProcess: 'sentenceCase' })}
</Button>
{setIsSidebarOpen && (
<ActionIcon
icon="unpin"
onClick={() => setIsSidebarOpen(false)}
size="compact-sm"
<div
className="query-editor-container"
style={{ borderTop: '1px solid var(--theme-colors-border)' }}
>
<Stack gap={0} h="100%" mah="30dvh" p="sm" w="100%">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<Button
leftSection={
<Icon
icon={isQueryBuilderExpanded ? 'arrowDownS' : 'arrowUpS'}
size="lg"
/>
}
onClick={onToggleExpand}
size="sm"
variant="subtle"
/>
)}
>
{t('form.queryEditor.title', {
postProcess: 'titleCase',
})}
</Button>
</Group>
<Group gap="xs">
<Button onClick={openPreviewModal} size="sm" variant="subtle">
{t('common.preview', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={!isQueryBuilderExpanded}
leftSection={<Icon icon="save" />}
loading={createPlaylistMutation?.isPending}
onClick={() => {
if (!isQueryBuilderExpanded) return;
const filters = queryBuilderRef.current?.getFilters();
if (filters) {
handleSaveAs(
convertQueryGroupToNDQuery(filters.filters),
filters.extraFilters,
);
}
}}
size="sm"
variant="subtle"
>
{t('common.saveAs', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={!isQueryBuilderExpanded}
leftSection={<Icon color="error" icon="save" />}
onClick={openSaveAndReplaceModal}
size="sm"
variant="subtle"
>
{t('common.saveAndReplace', {
postProcess: 'titleCase',
})}
</Button>
</Group>
</Group>
</Group>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<ClientSideSongFilters />
</ScrollArea>
</Stack>
<div
style={{
display: isQueryBuilderExpanded ? 'flex' : 'none',
flex: 1,
minHeight: 0,
overflow: 'hidden',
}}
>
<PlaylistQueryBuilder
key={JSON.stringify(detailQuery?.data?.rules)}
limit={detailQuery?.data?.rules?.limit}
playlistId={playlistId}
query={detailQuery?.data?.rules}
ref={queryBuilderRef}
sortBy={parseSortBy() as SongListSort | SongListSort[]}
sortOrder={parseSortOrder()}
/>
</div>
</Stack>
</div>
);
};
@@ -241,36 +408,23 @@ const PlaylistDetailSongListRoute = () => {
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
const [listData, setListData] = useState<unknown[]>([]);
const [mode, setMode] = useState<'edit' | 'view'>('view');
const [isSidebarOpen, setIsSidebarOpen] = usePageSidebar(listKey);
const providerValue = useMemo(() => {
return {
customFilters: undefined,
displayMode,
id: playlistId,
isSidebarOpen,
isSmartPlaylist,
itemCount,
listData,
listKey,
mode,
pageKey: listKey,
setIsSidebarOpen,
setItemCount,
setListData,
setMode,
};
}, [
playlistId,
isSmartPlaylist,
displayMode,
listKey,
isSidebarOpen,
itemCount,
listData,
mode,
setIsSidebarOpen,
]);
}, [playlistId, isSmartPlaylist, displayMode, listKey, itemCount, listData, mode]);
return (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
@@ -287,14 +441,9 @@ const PlaylistDetailSongListRoute = () => {
onToggleQueryBuilder={handleToggleShowQueryBuilder}
/>
<ListWithSidebarContainer>
<ListWithSidebarContainer.SidebarPortal>
<PlaylistSongListFiltersSidebar />
</ListWithSidebarContainer.SidebarPortal>
<Suspense fallback={<Spinner container />}>
<PlaylistDetailSongListContent />
</Suspense>
</ListWithSidebarContainer>
<Suspense fallback={<Spinner container />}>
<PlaylistDetailSongListContent />
</Suspense>
{(isSmartPlaylist || showQueryBuilder) && (
<PlaylistQueryEditor
createPlaylistMutation={createPlaylistMutation}
@@ -11,10 +11,7 @@ import {
import { PageHeader } from '/@/renderer/components/page-header/page-header';
import { FilterBar } from '/@/renderer/features/shared/components/filter-bar';
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
import { AppRoute } from '/@/renderer/router/routes';
import { Button, ButtonGroup } from '/@/shared/components/button/button';
@@ -47,7 +44,6 @@ export const SearchHeader = ({ navigationId }: SearchHeaderProps) => {
tableColumnsData: ALBUM_ARTIST_TABLE_COLUMNS,
},
[LibraryItem.SONG]: {
displayTypes: SONG_DISPLAY_TYPES,
listKey: ItemListKey.SONG,
tableColumnsData: SONG_TABLE_COLUMNS,
},
@@ -13,7 +13,7 @@ import JellyfinIcon from '/@/renderer/features/servers/assets/jellyfin.png';
import NavidromeIcon from '/@/renderer/features/servers/assets/navidrome.png';
import SubsonicIcon from '/@/renderer/features/servers/assets/opensubsonic.png';
import { IgnoreCorsSslSwitches } from '/@/renderer/features/servers/components/ignore-cors-ssl-switches';
import { useAuthStoreActions, useServerList } from '/@/renderer/store';
import { useAuthStoreActions } from '/@/renderer/store';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
@@ -98,7 +98,6 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
const focusTrapRef = useFocusTrap(true);
const [isLoading, setIsLoading] = useState(false);
const { addServer, setCurrentServer } = useAuthStoreActions();
const serverList = useServerList();
const { servers: discovered } = useAutodiscovery();
const serverLock = isServerLock();
@@ -129,13 +128,6 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
};
const handleSubmit = form.onSubmit(async (values) => {
if (serverLock && Object.keys(serverList).length >= 1) {
toast.error({
message: t('error.serverLockSingleServer', { postProcess: 'sentenceCase' }),
});
return;
}
const authFunction = api.controller.authenticate;
if (!authFunction) {
@@ -2,7 +2,6 @@ import { openContextModal } from '@mantine/modals';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';
import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';
import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';
import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';
@@ -24,7 +23,6 @@ export const ServerList = () => {
const { t } = useTranslation();
const currentServer = useCurrentServer();
const serverListQuery = useServerList();
const serverLock = isServerLock();
const handleAddServerModal = () => {
openContextModal({
@@ -72,17 +70,15 @@ export const ServerList = () => {
</Accordion.Item>
);
})}
{!serverLock && (
<Group grow pt="md">
<Button
autoFocus
leftSection={<Icon icon="add" />}
onClick={handleAddServerModal}
>
{t('form.addServer.title', { postProcess: 'titleCase' })}
</Button>
</Group>
)}
<Group grow pt="md">
<Button
autoFocus
leftSection={<Icon icon="add" />}
onClick={handleAddServerModal}
>
{t('form.addServer.title', { postProcess: 'titleCase' })}
</Button>
</Group>
</Accordion>
{isElectron() && (
<>
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Tabs } from '/@/shared/components/tabs/tabs';
const GeneralTab = lazy(() =>
@@ -72,29 +71,29 @@ export const SettingsContent = () => {
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="general">
<Suspense fallback={<Spinner container />}>
<Suspense fallback={null}>
<GeneralTab />
</Suspense>
</Tabs.Panel>
<Tabs.Panel value="playback">
<Suspense fallback={<Spinner container />}>
<Suspense fallback={null}>
<PlaybackTab />
</Suspense>
</Tabs.Panel>
<Tabs.Panel value="hotkeys">
<Suspense fallback={<Spinner container />}>
<Suspense fallback={null}>
<HotkeysTab />
</Suspense>
</Tabs.Panel>
{isElectron() && (
<Tabs.Panel value="window">
<Suspense fallback={<Spinner container />}>
<Suspense fallback={null}>
<WindowTab />
</Suspense>
</Tabs.Panel>
)}
<Tabs.Panel value="advanced">
<Suspense fallback={<Spinner container />}>
<Suspense fallback={null}>
<AdvancedTab />
</Suspense>
</Tabs.Panel>
@@ -30,7 +30,7 @@ export const sharedQueries = {
},
tagList: (args: QueryHookArgs<TagListQuery>) => {
return queryOptions({
gcTime: 1000 * 60 * 24,
gcTime: 1000 * 60,
queryFn: ({ signal }) => {
return api.controller.getTagList({
apiClientProps: { serverId: args.serverId, signal },
@@ -38,8 +38,7 @@ export const sharedQueries = {
});
},
queryKey: queryKeys.tags.list(args.serverId || '', args.query.type),
staleTime: 1000 * 60 * 24,
structuralSharing: false,
staleTime: 1000 * 60,
...args.options,
});
},
@@ -18,10 +18,6 @@ import { Table } from '/@/shared/components/table/table';
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
export const SONG_DISPLAY_TYPES: ListConfigMenuDisplayTypeConfig[] = [
{ hidden: true, value: ListDisplayType.DETAIL },
];
const DISPLAY_TYPES = [
{
label: (
+1 -5
View File
@@ -61,14 +61,10 @@ enum SharedFilterKeys {
enum SongFilterKeys {
_CUSTOM = '_custom',
ALBUM_ARTIST_IDS = 'albumArtistIds',
ALBUM_ARTIST_IDS_MODE = 'albumArtistIdsMode',
ALBUM_IDS = 'albumIds',
ARTIST_IDS = 'artistIds',
ARTIST_IDS_MODE = 'artistIdsMode',
FAVORITE = 'favorite',
GENRE_ID = 'genreIds',
GENRE_ID_MODE = 'genreIdsMode',
HAS_RATING = 'hasRating',
MAX_YEAR = 'maxYear',
MIN_YEAR = 'minYear',
}
@@ -4,10 +4,7 @@ import { useTranslation } from 'react-i18next';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { useListContext } from '/@/renderer/context/list-context';
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button';
import {
isFilterValueSet,
@@ -55,6 +52,7 @@ export const SongListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarget
const query = songFilters.query;
return Boolean(
isFilterValueSet(query[FILTER_KEYS.SONG._CUSTOM]) ||
isFilterValueSet(query[FILTER_KEYS.SONG.ALBUM_IDS]) ||
isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) ||
query[FILTER_KEYS.SONG.FAVORITE] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) ||
@@ -94,11 +92,7 @@ export const SongListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarget
</Group>
<Group gap="sm" wrap="nowrap">
<ListDisplayTypeToggleButton listKey={ItemListKey.SONG} />
<ListConfigMenu
displayTypes={SONG_DISPLAY_TYPES}
listKey={ItemListKey.SONG}
tableColumnsData={SONG_TABLE_COLUMNS}
/>
<ListConfigMenu listKey={ItemListKey.SONG} tableColumnsData={SONG_TABLE_COLUMNS} />
</Group>
</Flex>
);
@@ -6,7 +6,6 @@ import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-r
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';
import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { useGeneralSettings } from '/@/renderer/store';
import { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/shared/types/domain-types';
@@ -32,11 +31,10 @@ export const SongListInfiniteGrid = ({
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
const listQueryFn = api.controller.getSongList;
const { pageKey } = useListContext();
const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =
useItemListInfiniteLoader({
eventKey: pageKey || ItemListKey.SONG,
eventKey: ItemListKey.SONG,
itemsPerPage,
itemType: LibraryItem.SONG,
listCountQuery,
@@ -8,7 +8,6 @@ import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpe
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { usePlayerSong } from '/@/renderer/store';
import { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/shared/types/domain-types';
@@ -40,11 +39,10 @@ export const SongListInfiniteTable = ({
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
const listQueryFn = api.controller.getSongList;
const { pageKey } = useListContext();
const { getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } =
useItemListInfiniteLoader({
eventKey: pageKey || ItemListKey.SONG,
eventKey: ItemListKey.SONG,
itemsPerPage,
itemType: LibraryItem.SONG,
listCountQuery,
@@ -7,7 +7,6 @@ import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/it
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { useGeneralSettings } from '/@/renderer/store';
import { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/shared/types/domain-types';
@@ -26,7 +25,6 @@ export const SongListPaginatedGrid = ({
serverId,
size,
}: SongListPaginatedGridProps) => {
const { pageKey } = useListContext();
const { currentPage, onChange } = useItemListPagination();
const listCountQuery = songsQueries.listCount({
@@ -38,7 +36,7 @@ export const SongListPaginatedGrid = ({
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
currentPage,
eventKey: pageKey || ItemListKey.SONG,
eventKey: ItemListKey.SONG,
itemsPerPage,
itemType: LibraryItem.SONG,
listCountQuery,
@@ -10,7 +10,6 @@ import { useItemListPagination } from '/@/renderer/components/item-list/item-lis
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { usePlayerSong } from '/@/renderer/store';
import { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/shared/types/domain-types';
@@ -36,7 +35,6 @@ export const SongListPaginatedTable = ({
serverId,
size = 'default',
}: SongListPaginatedTableProps) => {
const { pageKey } = useListContext();
const { currentPage, onChange } = useItemListPagination();
const listCountQuery = songsQueries.listCount({
@@ -48,7 +46,7 @@ export const SongListPaginatedTable = ({
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
currentPage,
eventKey: pageKey || ItemListKey.SONG,
eventKey: ItemListKey.SONG,
itemsPerPage,
itemType: LibraryItem.SONG,
listCountQuery,
@@ -28,6 +28,11 @@ export const useSongListFilters = (listKey?: ItemListKey) => {
const [searchParams, setSearchParams] = useSearchParams();
const albumIds = useMemo(
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_IDS),
[searchParams],
);
const genreId = useMemo(
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.GENRE_ID),
[searchParams],
@@ -58,6 +63,15 @@ export const useSongListFilters = (listKey?: ItemListKey) => {
[searchParams],
);
const setAlbumIds = useCallback(
(value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_IDS, value), {
replace: true,
});
},
[setSearchParams],
);
const setGenreId = useCallback(
(value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.GENRE_ID, value), {
@@ -139,6 +153,7 @@ export const useSongListFilters = (listKey?: ItemListKey) => {
{
[FILTER_KEYS.SHARED.SEARCH_TERM]: null,
[FILTER_KEYS.SONG._CUSTOM]: null,
[FILTER_KEYS.SONG.ALBUM_IDS]: null,
[FILTER_KEYS.SONG.ARTIST_IDS]: null,
[FILTER_KEYS.SONG.FAVORITE]: null,
[FILTER_KEYS.SONG.GENRE_ID]: null,
@@ -157,18 +172,31 @@ export const useSongListFilters = (listKey?: ItemListKey) => {
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
[FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined,
[FILTER_KEYS.SONG.ALBUM_IDS]: albumIds ?? undefined,
[FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined,
[FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined,
[FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined,
[FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined,
[FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined,
}),
[searchTerm, sortBy, sortOrder, custom, artistIds, favorite, genreId, maxYear, minYear],
[
searchTerm,
sortBy,
sortOrder,
custom,
albumIds,
artistIds,
favorite,
genreId,
maxYear,
minYear,
],
);
return {
clear,
query,
setAlbumIds,
setArtistIds,
setCustom,
setFavorite,
@@ -27,17 +27,3 @@
.main-content-container.sidebar-collapsed.right-expanded {
grid-template-columns: 80px 1fr var(--right-sidebar-width);
}
.main-content-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.main-content-body-scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
@@ -6,18 +6,11 @@ import { shallow } from 'zustand/shallow';
import styles from './main-content.module.css';
import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container';
import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';
import { FullScreenOverlay } from '/@/renderer/layouts/default-layout/full-screen-overlay';
import { FullScreenVisualizerOverlay } from '/@/renderer/layouts/default-layout/full-screen-visualizer-overlay';
import { LeftSidebar } from '/@/renderer/layouts/default-layout/left-sidebar';
import { RightSidebar } from '/@/renderer/layouts/default-layout/right-sidebar';
import {
useAppStore,
useAppStoreActions,
useGlobalExpanded,
useSideQueueType,
} from '/@/renderer/store';
import { useAppStore, useAppStoreActions, useSideQueueType } from '/@/renderer/store';
import { constrainRightSidebarWidth, constrainSidebarWidth } from '/@/renderer/utils';
import { Spinner } from '/@/shared/components/spinner/spinner';
@@ -166,27 +159,10 @@ export const MainContent = ({ shell }: { shell?: boolean }) => {
);
};
function GlobalExpandedPanel() {
const globalExpanded = useGlobalExpanded();
if (!globalExpanded) return null;
return (
<ExpandedListContainer>
<ExpandedListItem item={globalExpanded.item} itemType={globalExpanded.itemType} />
</ExpandedListContainer>
);
}
function MainContentBody() {
return (
<div className={styles.mainContentBody}>
<div className={styles.mainContentBodyScroll}>
<Suspense fallback={<Spinner container />}>
<Outlet />
</Suspense>
</div>
<GlobalExpandedPanel />
</div>
<Suspense fallback={<Spinner container />}>
<Outlet />
</Suspense>
);
}
+8 -9
View File
@@ -8,7 +8,6 @@ import { AppOutlet } from '/@/renderer/router/app-outlet';
import { AppRoute } from '/@/renderer/router/routes';
import { TitlebarOutlet } from '/@/renderer/router/titlebar-outlet';
import { BaseContextModal, ModalsProvider } from '/@/shared/components/modal/modal';
import { Spinner } from '/@/shared/components/spinner/spinner';
const NowPlayingRoute = lazy(
() => import('/@/renderer/features/now-playing/routes/now-playing-route'),
@@ -91,7 +90,7 @@ const LazyLyricsSettingsContextModal = lazy(() =>
);
const LyricsSettingsContextModal = (props: any) => (
<Suspense fallback={<Spinner container />}>
<Suspense fallback={<></>}>
<LazyLyricsSettingsContextModal {...props} />
</Suspense>
);
@@ -103,7 +102,7 @@ const LazyShuffleAllContextModal = lazy(() =>
);
const ShuffleAllContextModal = (props: any) => (
<Suspense fallback={<Spinner container />}>
<Suspense fallback={<></>}>
<LazyShuffleAllContextModal {...props} />
</Suspense>
);
@@ -117,7 +116,7 @@ const LazyAddToPlaylistContextModal = lazy(() =>
);
const AddToPlaylistContextModal = (props: any) => (
<Suspense fallback={<Spinner container />}>
<Suspense fallback={<></>}>
<LazyAddToPlaylistContextModal {...props} />
</Suspense>
);
@@ -131,7 +130,7 @@ const LazySaveAndReplaceContextModal = lazy(() =>
);
const SaveAndReplaceContextModal = (props: any) => (
<Suspense fallback={<Spinner container />}>
<Suspense fallback={<></>}>
<LazySaveAndReplaceContextModal {...props} />
</Suspense>
);
@@ -143,7 +142,7 @@ const LazyUpdatePlaylistContextModal = lazy(() =>
);
const UpdatePlaylistContextModal = (props: any) => (
<Suspense fallback={<Spinner container />}>
<Suspense fallback={<></>}>
<LazyUpdatePlaylistContextModal {...props} />
</Suspense>
);
@@ -155,7 +154,7 @@ const LazySettingsContextModal = lazy(() =>
);
const SettingsContextModal = (props: any) => (
<Suspense fallback={<Spinner container />}>
<Suspense fallback={<></>}>
<LazySettingsContextModal {...props} />
</Suspense>
);
@@ -167,7 +166,7 @@ const LazyShareItemContextModal = lazy(() =>
);
const ShareItemContextModal = (props: any) => (
<Suspense fallback={<Spinner container />}>
<Suspense fallback={<></>}>
<LazyShareItemContextModal {...props} />
</Suspense>
);
@@ -181,7 +180,7 @@ const LazyVisualizerSettingsContextModal = lazy(() =>
);
const VisualizerSettingsContextModal = (props: any) => (
<Suspense fallback={<Spinner container />}>
<Suspense fallback={<></>}>
<LazyVisualizerSettingsContextModal {...props} />
</Suspense>
);
+1 -67
View File
@@ -1,6 +1,3 @@
import type { ItemListStateItem } from '/@/renderer/components/item-list/helpers/item-list-state';
import type { LibraryItem } from '/@/shared/types/domain-types';
import merge from 'lodash/merge';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
@@ -13,14 +10,9 @@ export interface AppSlice extends AppState {
actions: {
setAlbumArtistDetailGroupingType: (groupingType: 'all' | 'primary') => void;
setAlbumArtistDetailSort: (sortBy: AlbumListSort, sortOrder: SortOrder) => void;
setAlbumArtistIdsMode: (mode: 'and' | 'or') => void;
setAlbumArtistSelectMode: (mode: 'multi' | 'single') => void;
setAppStore: (data: Partial<AppSlice>) => void;
setArtistIdsMode: (mode: 'and' | 'or') => void;
setArtistSelectMode: (mode: 'multi' | 'single') => void;
setGenreIdsMode: (mode: 'and' | 'or') => void;
setGenreSelectMode: (mode: 'multi' | 'single') => void;
setGlobalExpanded: (value: GlobalExpandedState | null) => void;
setPageSidebar: (key: string, value: boolean) => void;
setPrivateMode: (enabled: boolean) => void;
setShowTimeRemaining: (enabled: boolean) => void;
@@ -35,14 +27,9 @@ export interface AppState {
sortBy: AlbumListSort;
sortOrder: SortOrder;
};
albumArtistIdsMode: 'and' | 'or';
albumArtistSelectMode: 'multi' | 'single';
artistIdsMode: 'and' | 'or';
artistSelectMode: 'multi' | 'single';
commandPalette: CommandPaletteProps;
genreIdsMode: 'and' | 'or';
genreSelectMode: 'multi' | 'single';
globalExpanded: GlobalExpandedState | null;
isReorderingQueue: boolean;
pageSidebar: Record<string, boolean>;
platform: Platform;
@@ -52,11 +39,6 @@ export interface AppState {
titlebar: TitlebarProps;
}
export interface GlobalExpandedState {
item: ItemListStateItem;
itemType: LibraryItem;
}
type CommandPaletteProps = {
close: () => void;
open: () => void;
@@ -97,44 +79,19 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
};
});
},
setAlbumArtistIdsMode: (mode) => {
set((state) => {
state.albumArtistIdsMode = mode;
});
},
setAlbumArtistSelectMode: (mode) => {
set((state) => {
state.albumArtistSelectMode = mode;
});
},
setAppStore: (data) => {
set({ ...get(), ...data });
},
setArtistIdsMode: (mode) => {
set((state) => {
state.artistIdsMode = mode;
});
},
setArtistSelectMode: (mode) => {
set((state) => {
state.artistSelectMode = mode;
});
},
setGenreIdsMode: (mode) => {
set((state) => {
state.genreIdsMode = mode;
});
},
setGenreSelectMode: (mode) => {
set((state) => {
state.genreSelectMode = mode;
});
},
setGlobalExpanded: (value) => {
set((state) => {
state.globalExpanded = value;
});
},
setPageSidebar: (key, value) => {
set((state) => {
state.pageSidebar[key] = value;
@@ -166,9 +123,6 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
},
albumArtistIdsMode: 'and',
albumArtistSelectMode: 'multi',
artistIdsMode: 'and',
artistSelectMode: 'multi',
commandPalette: {
close: () => {
@@ -188,9 +142,7 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
});
},
},
genreIdsMode: 'and',
genreSelectMode: 'multi',
globalExpanded: null,
isReorderingQueue: false,
pageSidebar: {
album: true,
@@ -226,12 +178,7 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
return persistedState;
},
name: 'store_app',
partialize: (state) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- ignore non-persisted state
const { globalExpanded: _, ...rest } = state;
return rest;
},
version: 4,
version: 3,
},
),
);
@@ -258,16 +205,3 @@ export const usePageSidebar = (key: string): [boolean, (value: boolean) => void]
return [isOpen, setIsOpen];
};
export const useGlobalExpanded = () => useAppStore((state) => state.globalExpanded);
export const useSetGlobalExpanded = () => useAppStore((state) => state.actions.setGlobalExpanded);
export const useGlobalExpandedState = () => {
const globalExpanded = useGlobalExpanded();
const setGlobalExpanded = useSetGlobalExpanded();
const clearGlobalExpanded = () => setGlobalExpanded(null);
return { clearGlobalExpanded, globalExpanded, setGlobalExpanded };
};
-4
View File
@@ -21,7 +21,6 @@ import {
LuArrowUpNarrowWide,
LuArrowUpToLine,
LuBookOpen,
LuBraces,
LuCheck,
LuChevronDown,
LuChevronLast,
@@ -118,7 +117,6 @@ import {
LuVolumeX,
LuWifi,
LuWifiOff,
LuWrench,
LuX,
} from 'react-icons/lu';
import { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md';
@@ -189,7 +187,6 @@ export const AppIcon = {
info: LuInfo,
itemAlbum: LuDisc3,
itemSong: LuMusic,
json: LuBraces,
keyboard: LuKeyboard,
lastPlayed: LuHeadphones,
layoutDetail: LuLayoutList,
@@ -230,7 +227,6 @@ export const AppIcon = {
playlistAdd: LuListPlus,
playlistDelete: LuListMinus,
plus: LuPlus,
queryBuilder: LuWrench,
queue: LuList,
radio: LuRadio,
refresh: LuRotateCw,