refactor album expansion to global scope

This commit is contained in:
jeffvli
2026-02-13 14:59:15 -08:00
parent e855f7dd01
commit 70fdd4bdc3
14 changed files with 297 additions and 225 deletions
@@ -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>
);
};
@@ -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';
@@ -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"
@@ -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;
};
@@ -1055,6 +1055,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;
@@ -1074,7 +1075,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);
@@ -1199,6 +1208,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"
@@ -1376,7 +1386,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 || [];
@@ -1384,6 +1393,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]);
@@ -1652,6 +1663,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;
@@ -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,30 @@ 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
</Suspense> item={globalExpanded.item}
itemType={globalExpanded.itemType}
/>
</ExpandedListContainer>
);
}
function MainContentBody() {
return (
<div className={styles.mainContentBody}>
<div className={styles.mainContentBodyScroll}>
<Suspense fallback={<Spinner container />}>
<Outlet />
</Suspense>
</div>
<GlobalExpandedPanel />
</div>
); );
} }
+35 -1
View File
@@ -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';
@@ -17,6 +20,7 @@ export interface AppSlice extends AppState {
setArtistSelectMode: (mode: 'multi' | 'single') => void; setArtistSelectMode: (mode: 'multi' | 'single') => void;
setGenreIdsMode: (mode: 'and' | 'or') => 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;
@@ -38,6 +42,7 @@ export interface AppState {
commandPalette: CommandPaletteProps; commandPalette: CommandPaletteProps;
genreIdsMode: 'and' | 'or'; 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;
@@ -47,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;
@@ -120,6 +130,11 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
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;
@@ -175,6 +190,7 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
}, },
genreIdsMode: 'and', genreIdsMode: 'and',
genreSelectMode: 'multi', genreSelectMode: 'multi',
globalExpanded: null,
isReorderingQueue: false, isReorderingQueue: false,
pageSidebar: { pageSidebar: {
album: true, album: true,
@@ -210,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,
}, },
), ),
); );
@@ -237,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 };
};