add folder browsing support (#315)

This commit is contained in:
jeffvli
2025-12-02 21:30:44 -08:00
parent 355257104d
commit 917bf91583
53 changed files with 2382 additions and 299 deletions
@@ -2,7 +2,7 @@ import {
ItemListStateActions,
ItemListStateItemWithRequiredProperties,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { Album, AlbumArtist, Artist, Playlist, Song } from '/@/shared/types/domain-types';
import { Album, AlbumArtist, Artist, Folder, Playlist, Song } from '/@/shared/types/domain-types';
/**
* Type guard to assert that an item has the required properties for dragging
@@ -28,13 +28,13 @@ const hasRequiredDragProperties = (
* Otherwise, select and drag only the current item.
* If internalState is not provided, returns the single item wrapped in an array.
*
* @param data - The item data to drag (Album, AlbumArtist, Artist, Playlist, or Song)
* @param data - The item data to drag (Album, AlbumArtist, Artist, Folder, Playlist, or Song)
* @param internalState - The item list state actions (optional)
* @param updateSelection - Whether to update the selection state (default: true)
* @returns Array of items that should be dragged (with original values, asserting id, itemType, and _serverId)
*/
export const getDraggedItems = (
data: Album | AlbumArtist | Artist | Playlist | Song | undefined,
data: Album | AlbumArtist | Artist | Folder | Playlist | Song | undefined,
internalState?: ItemListStateActions,
updateSelection: boolean = true,
): ItemListStateItemWithRequiredProperties[] => {
@@ -299,10 +299,15 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return;
}
// Use the item's _itemType if available, otherwise fall back to the prop itemType
// This allows mixed lists (e.g., folders + songs) to show the correct context menu
const actualItemType =
(item as any)?._itemType || itemTypeMapping[itemType] || itemType;
// If no internalState, call ContextMenuController directly
if (!internalState) {
return ContextMenuController.call({
cmd: { items: [item] as any[], type: itemType as any },
cmd: { items: [item] as any[], type: actualItemType as any },
event,
});
}
@@ -315,7 +320,7 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
if (internalState.getSelected().length === 0) {
internalState.setSelected([item]);
return ContextMenuController.call({
cmd: { items: [item] as any[], type: itemType as any },
cmd: { items: [item] as any[], type: actualItemType as any },
event,
});
}
@@ -323,15 +328,21 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
else if (!internalState.isSelected(rowId)) {
internalState.setSelected([item]);
return ContextMenuController.call({
cmd: { items: [item] as any[], type: itemType as any },
cmd: { items: [item] as any[], type: actualItemType as any },
event,
});
}
const selectedItems = internalState.getSelected();
// For multiple selected items, use the itemType prop (assumes all selected items are of the same type)
const selectedItemType =
selectedItems.length > 0 && (selectedItems[0] as any)?._itemType
? (selectedItems[0] as any)._itemType
: actualItemType;
return ContextMenuController.call({
cmd: { items: selectedItems as any[], type: itemType as any },
cmd: { items: selectedItems as any[], type: selectedItemType as any },
event,
});
},
@@ -54,3 +54,8 @@
width: 24px;
height: 24px;
}
.folder-icon {
color: black;
fill: rgb(255 215 100);
}
@@ -10,9 +10,10 @@ import {
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
import { LONG_PRESS_PLAY_BEHAVIOR } from '/@/renderer/features/shared/components/play-button-group';
import { usePlayButtonBehavior } from '/@/renderer/store';
import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { LibraryItem } from '/@/shared/types/domain-types';
import { Folder, LibraryItem } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
export const ImageColumn = (props: ItemTableListInnerColumn) => {
@@ -98,6 +99,14 @@ export const ImageColumn = (props: ItemTableListInnerColumn) => {
);
}
if ((props.data[props.rowIndex] as unknown as Folder)?._itemType === LibraryItem.FOLDER) {
return (
<TableColumnContainer {...props}>
<Icon className={styles.folderIcon} icon="folder" size="2xl" />
</TableColumnContainer>
);
}
return (
<TableColumnContainer {...props}>
<Skeleton containerClassName={styles.skeleton} />
@@ -21,6 +21,7 @@ export const RowIndexColumn = (props: ItemTableListInnerColumn) => {
const { itemType } = props;
switch (itemType) {
case LibraryItem.FOLDER:
case LibraryItem.PLAYLIST_SONG:
case LibraryItem.QUEUE_SONG:
case LibraryItem.SONG:
@@ -17,6 +17,7 @@ export const TitleColumn = (props: ItemTableListInnerColumn) => {
const { itemType } = props;
switch (itemType) {
case LibraryItem.FOLDER:
case LibraryItem.PLAYLIST_SONG:
case LibraryItem.QUEUE_SONG:
case LibraryItem.SONG:
@@ -32,3 +32,8 @@
white-space: nowrap;
user-select: none;
}
.folder-icon {
color: black;
fill: rgb(255 215 100);
}
@@ -12,9 +12,10 @@ import {
TableColumnContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { AppRoute } from '/@/renderer/router/routes';
import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image';
import { Text } from '/@/shared/components/text/text';
import { LibraryItem, QueueSong, RelatedAlbumArtist } from '/@/shared/types/domain-types';
import { Folder, LibraryItem, QueueSong, RelatedAlbumArtist } from '/@/shared/types/domain-types';
export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
const row: object | undefined = (props.data as (any | undefined)[])[props.rowIndex];
@@ -166,6 +167,44 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
);
}
if ((props.data[props.rowIndex] as unknown as Folder)?._itemType === LibraryItem.FOLDER) {
const rowHeight = props.getRowHeight(props.rowIndex, props);
const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string);
const item = props.data[props.rowIndex] as any;
const textStyles = isActive ? { color: 'var(--theme-colors-primary)' } : {};
const titleLinkProps = path
? {
component: Link,
isLink: true,
state: { item },
to: path,
}
: {};
const title = (props.data[props.rowIndex] as unknown as Folder)?.name;
return (
<TableColumnContainer
className={styles.titleCombined}
containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}
{...props}
>
<Icon className={styles.folderIcon} icon="folder" size="2xl" />
<Text
className={styles.title}
isNoSelect
size="md"
{...titleLinkProps}
style={textStyles}
>
{title}
</Text>
</TableColumnContainer>
);
}
if (row === null) {
return <ColumnNullFallback {...props} />;
}
@@ -177,6 +216,7 @@ export const TitleCombinedColumn = (props: ItemTableListInnerColumn) => {
const { itemType } = props;
switch (itemType) {
case LibraryItem.FOLDER:
case LibraryItem.PLAYLIST_SONG:
case LibraryItem.QUEUE_SONG:
case LibraryItem.SONG:
@@ -53,7 +53,7 @@ import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Text } from '/@/shared/components/text/text';
import { useDoubleClick } from '/@/shared/hooks/use-double-click';
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
import { LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
import { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
import {
dndUtils,
DragData,
@@ -80,6 +80,7 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
const isDataRow = isHeaderEnabled ? props.rowIndex > 0 : true;
const item = isDataRow ? props.data[props.rowIndex] : null;
const shouldEnableDrag = !!props.enableDrag && isDataRow && !!item;
const itemType = (item as unknown as { _itemType?: LibraryItem })?._itemType || props.itemType;
// Check if this row should render a group header (must be before conditional returns)
// Group headers need to be rendered consistently across all grids (pinned left, main, pinned right)
@@ -239,6 +240,48 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
);
break;
}
case DragTarget.FOLDER: {
const items = args.source.item;
const { folders, songs } = (items || []).reduce<{
folders: Folder[];
songs: Song[];
}>(
(acc, item) => {
if ((item as unknown as Song)._itemType === LibraryItem.SONG) {
acc.songs.push(item as unknown as Song);
} else if (
(item as unknown as Folder)._itemType === LibraryItem.FOLDER
) {
acc.folders.push(item as unknown as Folder);
}
return acc;
},
{ folders: [], songs: [] },
);
const folderIds = folders.map((folder) => folder.id);
// Handle folders: fetch and add to queue
if (folderIds.length > 0) {
props.playerContext.addToQueueByFetch(
sourceServerId,
folderIds,
LibraryItem.FOLDER,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
}
// Handle songs: add directly to queue
if (songs.length > 0) {
props.playerContext.addToQueueByData(songs, {
edge: args.edge,
uniqueId: droppedOnUniqueId,
});
}
break;
}
case DragTarget.GENRE: {
props.playerContext.addToQueueByFetch(
sourceServerId,
@@ -366,65 +409,106 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
);
}
if (itemType !== LibraryItem.FOLDER) {
switch (type) {
case TableColumn.ACTIONS:
case TableColumn.SKIP:
return <ActionsColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ALBUM:
return <AlbumColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ALBUM_ARTIST:
return (
<AlbumArtistsColumn {...props} {...dragProps} controls={controls} type={type} />
);
case TableColumn.ALBUM_COUNT:
case TableColumn.PLAY_COUNT:
case TableColumn.SONG_COUNT:
return <CountColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ARTIST:
return <ArtistsColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.BIOGRAPHY:
case TableColumn.COMMENT:
return <TextColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.BIT_RATE:
case TableColumn.BPM:
case TableColumn.CHANNELS:
case TableColumn.DISC_NUMBER:
case TableColumn.TRACK_NUMBER:
case TableColumn.YEAR:
return <NumericColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.DATE_ADDED:
case TableColumn.RELEASE_DATE:
return <DateColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.DURATION:
return <DurationColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.GENRE:
return <GenreColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.GENRE_BADGE:
return (
<GenreBadgeColumn {...props} {...dragProps} controls={controls} type={type} />
);
case TableColumn.IMAGE:
return <ImageColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.LAST_PLAYED:
return (
<RelativeDateColumn {...props} {...dragProps} controls={controls} type={type} />
);
case TableColumn.PATH:
return <PathColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ROW_INDEX:
return <RowIndexColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.SIZE:
return <SizeColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.TITLE:
return <TitleColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.TITLE_COMBINED:
return (
<TitleCombinedColumn
{...props}
{...dragProps}
controls={controls}
type={type}
/>
);
case TableColumn.USER_FAVORITE:
return <FavoriteColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.USER_RATING:
return <RatingColumn {...props} {...dragProps} controls={controls} type={type} />;
default:
return <DefaultColumn {...props} {...dragProps} controls={controls} type={type} />;
}
}
switch (type) {
case TableColumn.ACTIONS:
case TableColumn.SKIP:
return <ActionsColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ALBUM:
return <AlbumColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ALBUM_ARTIST:
return <AlbumArtistsColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ALBUM_COUNT:
case TableColumn.PLAY_COUNT:
case TableColumn.SONG_COUNT:
return <CountColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ARTIST:
return <ArtistsColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.BIOGRAPHY:
case TableColumn.COMMENT:
return <TextColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.BIT_RATE:
case TableColumn.BPM:
case TableColumn.CHANNELS:
case TableColumn.DISC_NUMBER:
case TableColumn.TRACK_NUMBER:
case TableColumn.YEAR:
return <NumericColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.DATE_ADDED:
case TableColumn.RELEASE_DATE:
return <DateColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.DURATION:
return <DurationColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.GENRE:
return <GenreColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.GENRE_BADGE:
return <GenreBadgeColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.IMAGE:
return <ImageColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.LAST_PLAYED:
return <RelativeDateColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.PATH:
return <PathColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.ROW_INDEX:
return <RowIndexColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.SIZE:
return <SizeColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.TITLE:
return <TitleColumn {...props} {...dragProps} controls={controls} type={type} />;
@@ -433,14 +517,8 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
<TitleCombinedColumn {...props} {...dragProps} controls={controls} type={type} />
);
case TableColumn.USER_FAVORITE:
return <FavoriteColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.USER_RATING:
return <RatingColumn {...props} {...dragProps} controls={controls} type={type} />;
default:
return <DefaultColumn {...props} {...dragProps} controls={controls} type={type} />;
return <ColumnNullFallback {...props} {...dragProps} controls={controls} type={type} />;
}
};
+2 -1
View File
@@ -3,6 +3,7 @@ import {
Album,
AlbumArtist,
Artist,
Folder,
LibraryItem,
Playlist,
Song,
@@ -75,7 +76,7 @@ export interface ItemListHandle {
scrollToOffset: (offset: number, options?: { behavior?: 'auto' | 'smooth' }) => void;
}
export type ItemListItem = Album | AlbumArtist | Artist | Playlist | Song | undefined;
export type ItemListItem = Album | AlbumArtist | Artist | Folder | Playlist | Song | undefined;
export interface ItemListTableComponentProps<TQuery> extends ItemListComponentProps<TQuery> {
autoFitColumns?: boolean;