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