add global music folder selector

This commit is contained in:
jeffvli
2025-11-17 01:46:04 -08:00
parent 199a67fdf3
commit a92a829ca7
28 changed files with 782 additions and 351 deletions
@@ -3,11 +3,8 @@ import { useNavigate } from 'react-router';
import styles from './action-bar.module.css';
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
import { useContainerQuery } from '/@/renderer/hooks';
import { useCommandPalette } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
import { Grid } from '/@/shared/components/grid/grid';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
@@ -15,14 +12,18 @@ import { TextInput } from '/@/shared/components/text-input/text-input';
export const ActionBar = () => {
const { t } = useTranslation();
const { ref, ...cq } = useContainerQuery({ md: 300 });
const navigate = useNavigate();
const { open } = useCommandPalette();
return (
<div className={styles.container} ref={ref}>
<Grid display="flex" gutter="sm" px="1rem" w="100%">
<Grid.Col span={6}>
<div className={styles.container}>
<Grid
display="flex"
gutter="sm"
style={{ padding: '0 var(--theme-spacing-md)' }}
w="100%"
>
<Grid.Col span={8}>
<TextInput
leftSection={<Icon icon="search" />}
onClick={open}
@@ -35,18 +36,8 @@ export const ActionBar = () => {
readOnly
/>
</Grid.Col>
<Grid.Col span={6}>
<Grid.Col span={4}>
<Group gap="sm" grow wrap="nowrap">
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button p="0.5rem">
<Icon icon="menu" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<AppMenu />
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button onClick={() => navigate(-1)} p="0.5rem">
<Icon icon="arrowLeftS" />
</Button>
@@ -0,0 +1,163 @@
import { openModal } from '@mantine/modals';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';
import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';
import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';
import { ServerList } from '/@/renderer/features/servers/components/server-list';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { AppRoute } from '/@/renderer/router/routes';
import { useAuthStoreActions, useCurrentServer, useServerList } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils';
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
import { Icon } from '/@/shared/components/icon/icon';
import { ServerListItemWithCredential, ServerType } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
export const ServerSelectorItems = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const currentServer = useCurrentServer();
const serverList = useServerList();
const { setCurrentServer, setMusicFolderId } = useAuthStoreActions();
const { data: musicFolders } = useQuery(
currentServer
? sharedQueries.musicFolders({ query: null, serverId: currentServer.id })
: { enabled: false, queryKey: ['disabled'] },
);
const handleSetCurrentServer = (server: ServerListItemWithCredential) => {
navigate(AppRoute.HOME);
setCurrentServer(server);
setMusicFolderId(undefined);
};
const supportsMultiSelect = hasFeature(currentServer, ServerFeature.MUSIC_FOLDER_MULTISELECT);
const queryClient = useQueryClient();
const handleToggleMusicFolder = (musicFolderId: string) => {
if (supportsMultiSelect) {
const currentIds = currentServer.musicFolderId || [];
const isSelected = currentIds.includes(musicFolderId);
if (isSelected) {
// Remove from selection
const newIds = currentIds.filter((id) => id !== musicFolderId);
setMusicFolderId(newIds.length > 0 ? newIds : undefined);
} else {
// Add to selection
setMusicFolderId([...currentIds, musicFolderId]);
}
} else {
const currentId = Array.isArray(currentServer.musicFolderId)
? currentServer.musicFolderId[0]
: currentServer.musicFolderId;
const isSelected = currentId === musicFolderId;
if (isSelected) {
setMusicFolderId(undefined);
} else {
setMusicFolderId([musicFolderId]);
}
}
queryClient.resetQueries();
};
const handleClearMusicFolders = () => {
setMusicFolderId(undefined);
queryClient.resetQueries();
};
if (!currentServer) {
return null;
}
const selectedMusicFolders =
musicFolders?.items.filter((folder) => currentServer.musicFolderId?.includes(folder.id)) ||
[];
const handleManageServersModal = () => {
openModal({
children: <ServerList />,
title: t('page.manageServers.title', { postProcess: 'titleCase' }),
});
};
return (
<>
<DropdownMenu.Label>
{t('page.appMenu.selectServer', { postProcess: 'titleCase' })}
</DropdownMenu.Label>
{Object.values(serverList).map((server) => {
const isNavidromeExpired =
server.type === ServerType.NAVIDROME && !server.ndCredential;
const isJellyfinExpired = server.type === ServerType.JELLYFIN && !server.credential;
const isSessionExpired = isNavidromeExpired || isJellyfinExpired;
const logo =
server.type === ServerType.NAVIDROME
? NavidromeLogo
: server.type === ServerType.JELLYFIN
? JellyfinLogo
: OpenSubsonicLogo;
return (
<DropdownMenu.Item
isSelected={currentServer?.id === server.id}
key={`server-${server.id}`}
leftSection={<img src={logo} style={{ height: '1rem', width: '1rem' }} />}
onClick={() => {
if (!isSessionExpired) {
handleSetCurrentServer(server);
}
}}
>
{server.name}
</DropdownMenu.Item>
);
})}
<DropdownMenu.Item
leftSection={<Icon icon="edit" />}
onClick={handleManageServersModal}
>
{t('page.appMenu.manageServers', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
{musicFolders && musicFolders.items.length > 0 && (
<>
<DropdownMenu.Divider />
<DropdownMenu.Label>
{t('page.appMenu.selectMusicFolder', { postProcess: 'sentenceCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item
isSelected={selectedMusicFolders.length === 0}
leftSection={<Icon icon="minus" />}
onClick={handleClearMusicFolders}
>
{t('common.none', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
{musicFolders.items.map((folder) => {
const isSelected = supportsMultiSelect
? currentServer.musicFolderId?.includes(folder.id) || false
: (Array.isArray(currentServer.musicFolderId)
? currentServer.musicFolderId[0]
: currentServer.musicFolderId) === folder.id;
return (
<DropdownMenu.Item
isSelected={isSelected}
key={`musicFolder-${folder.id}`}
leftSection={<Icon icon={isSelected ? 'check' : 'folder'} />}
onClick={() => handleToggleMusicFolder(folder.id)}
>
{folder.name}
</DropdownMenu.Item>
);
})}
</>
)}
</>
);
};
@@ -0,0 +1,46 @@
.button-container {
align-items: center;
width: 100%;
padding: var(--theme-spacing-md);
cursor: pointer;
}
.button-container-no-bottom-padding {
padding-bottom: 0;
}
.button-group {
padding: var(--theme-spacing-sm);
background: var(--theme-colors-surface);
border-radius: var(--theme-radius-md);
}
.logo {
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
object-fit: cover;
border-radius: var(--theme-radius-md);
}
.button-stack {
flex: 1;
min-width: 0;
}
.popover-target {
width: 100%;
}
.scroll-area {
max-height: 400px;
}
.server-logo {
width: var(--theme-font-size-md);
height: var(--theme-font-size-md);
}
.server-button {
justify-content: flex-start;
}
@@ -0,0 +1,97 @@
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import styles from './server-selector.module.css';
import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';
import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';
import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
import { useCurrentServer } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils';
import { Box } from '/@/shared/components/box/box';
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { ServerType } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
interface ServerSelectorProps {
showImage?: boolean;
}
export const ServerSelector = ({ showImage = false }: ServerSelectorProps) => {
const { t } = useTranslation();
const currentServer = useCurrentServer();
const { data: musicFolders } = useQuery(
currentServer
? sharedQueries.musicFolders({ query: null, serverId: currentServer.id })
: { enabled: false, queryKey: ['disabled'] },
);
if (!currentServer) {
return null;
}
const supportsMultiSelect = hasFeature(currentServer, ServerFeature.MUSIC_FOLDER_MULTISELECT);
const selectedMusicFolders =
musicFolders?.items.filter((folder) => currentServer.musicFolderId?.includes(folder.id)) ||
[];
const musicFolderDisplayText = (() => {
if (selectedMusicFolders.length === 0) {
return t('page.appMenu.noMusicFolder', { postProcess: 'sentenceCase' });
}
if (supportsMultiSelect && selectedMusicFolders.length > 1) {
return t('page.appMenu.multipleMusicFolders', {
count: selectedMusicFolders.length,
postProcess: 'sentenceCase',
});
}
return selectedMusicFolders[0].name;
})();
const logo =
currentServer.type === ServerType.NAVIDROME
? NavidromeLogo
: currentServer.type === ServerType.JELLYFIN
? JellyfinLogo
: OpenSubsonicLogo;
return (
<DropdownMenu position="right">
<DropdownMenu.Target>
<div className={styles.popoverTarget}>
<Box
className={`${styles.buttonContainer} ${
showImage ? styles.buttonContainerNoBottomPadding : ''
}`}
>
<Group className={styles.buttonGroup} gap="sm">
<img className={styles.logo} src={logo} />
<Stack className={styles.buttonStack} gap={2}>
<Text fw={600} size="sm" truncate>
{currentServer.name}
</Text>
<Text isMuted size="xs" truncate>
{musicFolderDisplayText}
</Text>
</Stack>
<Icon icon="ellipsisVertical" size="sm" />
</Group>
</Box>
</div>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<AppMenu />
</DropdownMenu.Dropdown>
</DropdownMenu>
);
};
@@ -13,6 +13,8 @@
}
.scroll-area {
flex: 1;
min-height: 0;
padding: 0 var(--theme-spacing-md) var(--theme-spacing-md) var(--theme-spacing-md);
}
@@ -27,10 +29,11 @@
}
.image-container {
position: absolute;
bottom: 0;
position: relative;
flex-shrink: 0;
width: var(--sidebar-image-height);
height: var(--sidebar-image-height);
padding: var(--theme-spacing-md);
cursor: pointer;
animation: fade-in 0.2s ease-in-out;
@@ -48,7 +51,7 @@
height: 100%;
object-fit: var(--theme-image-fit);
background: var(--theme-colors-foreground-muted);
border-radius: 0;
border-radius: var(--theme-radius-md);
}
.accordion-root {
@@ -8,6 +8,7 @@ import styles from './sidebar.module.css';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
import {
@@ -106,15 +107,6 @@ export const Sidebar = () => {
return items;
}, [sidebarItems, translatedSidebarItemMap]);
const scrollAreaHeight = useMemo(() => {
if (showImage) {
// Subtract the height of the top bar and padding
return `calc(100% - 65px - var(--mantine-spacing-xs) - ${sidebar.leftWidth})`;
}
return '100%';
}, [showImage, sidebar.leftWidth]);
const isCustomWindowBar =
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS;
@@ -125,16 +117,10 @@ export const Sidebar = () => {
})}
id="left-sidebar"
>
<Group grow id="global-search-container">
<Group grow id="global-search-container" style={{ flexShrink: 0 }}>
<ActionBar />
</Group>
<ScrollArea
allowDragScroll
className={styles.scrollArea}
style={{
height: scrollAreaHeight,
}}
>
<ScrollArea allowDragScroll className={styles.scrollArea}>
<Accordion
classNames={{
content: styles.accordionContent,
@@ -177,6 +163,9 @@ export const Sidebar = () => {
)}
</Accordion>
</ScrollArea>
<div style={{ flexShrink: 0, position: 'relative', zIndex: 1 }}>
<ServerSelector showImage={showImage} />
</div>
<AnimatePresence initial={false} mode="popLayout">
{showImage && (
<motion.div
@@ -221,8 +210,8 @@ export const Sidebar = () => {
style={{
cursor: 'default',
position: 'absolute',
right: 5,
top: 5,
right: '1rem',
top: '1rem',
}}
tooltip={{
label: t('common.collapse', {