Files
feishin/src/renderer/components/item-list/helpers/item-list-controls.ts
T
2026-02-07 00:41:36 -08:00

468 lines
20 KiB
TypeScript

import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router';
import { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path';
import { ItemListStateItemWithRequiredProperties } from '/@/renderer/components/item-list/helpers/item-list-state';
import { DefaultItemControlProps, ItemControls } from '/@/renderer/components/item-list/types';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
import { LibraryItem, QueueSong, ServerType, Song } from '/@/shared/types/domain-types';
import { Play, TableColumn } from '/@/shared/types/types';
interface UseDefaultItemListControlsArgs {
enableMultiSelect?: boolean;
onColumnReordered?: (
columnIdFrom: TableColumn,
columnIdTo: TableColumn,
edge: 'bottom' | 'left' | 'right' | 'top' | null,
) => void;
onColumnResized?: (columnId: TableColumn, width: number) => void;
overrides?: Partial<ItemControls>;
}
const itemTypeMapping = {
[LibraryItem.ALBUM]: LibraryItem.ALBUM,
[LibraryItem.ALBUM_ARTIST]: LibraryItem.ALBUM_ARTIST,
[LibraryItem.ARTIST]: LibraryItem.ARTIST,
[LibraryItem.GENRE]: LibraryItem.GENRE,
[LibraryItem.PLAYLIST]: LibraryItem.PLAYLIST,
[LibraryItem.PLAYLIST_SONG]: LibraryItem.SONG,
[LibraryItem.QUEUE_SONG]: LibraryItem.SONG,
};
export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs) => {
const player = usePlayer();
const queryClient = useQueryClient();
const navigate = useNavigate();
const navigateRef = useRef(navigate);
const setFavorite = useSetFavorite();
const setRating = useSetRating();
useEffect(() => {
navigateRef.current = navigate;
}, [navigate]);
const { enableMultiSelect = true, onColumnReordered, onColumnResized, overrides } = args || {};
const controls: ItemControls = useMemo(() => {
return {
onClick: ({ event, internalState, item }: DefaultItemControlProps) => {
if (!item || !internalState || !event) {
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;
// Check if ctrl/cmd key is held for multi-selection
if (event.ctrlKey || event.metaKey) {
const isCurrentlySelected = internalState.isSelected(rowId);
if (isCurrentlySelected) {
// Remove this item from selection
const currentSelected = internalState.getSelected();
const filteredSelected = currentSelected.filter(
(
selectedItem,
): selectedItem is ItemListStateItemWithRequiredProperties =>
typeof selectedItem === 'object' &&
selectedItem !== null &&
internalState.extractRowId(selectedItem) !== rowId,
);
internalState.setSelected(filteredSelected);
} else {
// Add this item to selection
const currentSelected = internalState.getSelected();
const newSelected = [
...currentSelected.filter(
(
selectedItem,
): selectedItem is ItemListStateItemWithRequiredProperties =>
typeof selectedItem === 'object' && selectedItem !== null,
),
itemListItem,
];
internalState.setSelected(newSelected);
}
}
// Check if shift key is held for range selection
else if (event.shiftKey) {
const selectedItems = internalState.getSelected();
const lastSelectedItem = selectedItems[selectedItems.length - 1];
if (
lastSelectedItem &&
typeof lastSelectedItem === 'object' &&
lastSelectedItem !== null
) {
// Get the data array from internalState
const data = internalState.getData();
// Filter out null/undefined values (e.g., header row)
const validData = data.filter((d) => d && typeof d === 'object');
// Find the indices of the last selected item and current item
const lastRowId = internalState.extractRowId(lastSelectedItem);
if (!lastRowId) return;
const lastIndex = internalState.findItemIndex(lastRowId);
const currentIndex = internalState.findItemIndex(rowId);
if (lastIndex !== -1 && currentIndex !== -1) {
// Create range selection - select ALL items in the range
const startIndex = Math.min(lastIndex, currentIndex);
const stopIndex = Math.max(lastIndex, currentIndex);
const rangeItems: ItemListStateItemWithRequiredProperties[] = [];
for (let i = startIndex; i <= stopIndex; i++) {
const rangeItem = validData[i];
if (
rangeItem &&
typeof rangeItem === 'object' &&
'_serverId' in rangeItem &&
'_itemType' in rangeItem
) {
const rangeRowId = internalState.extractRowId(rangeItem);
if (rangeRowId) {
rangeItems.push(
rangeItem as ItemListStateItemWithRequiredProperties,
);
}
}
}
// Merge with existing selection, avoiding duplicates
const currentSelected = internalState.getSelected();
const newSelected = [
...currentSelected.filter(
(
selectedItem,
): selectedItem is ItemListStateItemWithRequiredProperties =>
typeof selectedItem === 'object' && selectedItem !== null,
),
];
rangeItems.forEach((rangeItem) => {
const rangeRowId = internalState.extractRowId(rangeItem);
if (
rangeRowId &&
!newSelected.some(
(selected) =>
internalState.extractRowId(selected) === rangeRowId,
)
) {
newSelected.push(rangeItem);
}
});
internalState.setSelected(newSelected);
}
} else {
// No previous selection, just select this item
internalState.setSelected([itemListItem]);
}
} else {
// Regular click - deselect all others and select only this item
// If this item is already the only selected item, deselect it
const selectedItems = internalState.getSelected();
const isOnlySelected =
selectedItems.length === 1 &&
typeof selectedItems[0] === 'object' &&
selectedItems[0] !== null &&
internalState.extractRowId(selectedItems[0]) === rowId;
if (isOnlySelected) {
internalState.clearSelected();
} else {
internalState.setSelected([itemListItem]);
}
}
},
onColumnReordered: ({
columnIdFrom,
columnIdTo,
edge,
}: {
columnIdFrom: TableColumn;
columnIdTo: TableColumn;
edge: 'bottom' | 'left' | 'right' | 'top' | null;
}) => {
onColumnReordered?.(columnIdFrom, columnIdTo, edge);
},
onColumnResized: ({ columnId, width }: { columnId: TableColumn; width: number }) => {
onColumnResized?.(columnId, width);
},
onDoubleClick: ({ internalState, item, itemType, meta }: DefaultItemControlProps) => {
if (!item || !internalState) {
return;
}
internalState.setSelected([item]);
if (
itemType === LibraryItem.ALBUM ||
itemType === LibraryItem.ALBUM_ARTIST ||
itemType === LibraryItem.ARTIST ||
itemType === LibraryItem.GENRE ||
itemType === LibraryItem.PLAYLIST
) {
const path = getTitlePath(itemType, item.id);
if (path) {
navigateRef.current(path, { state: { item } });
return;
}
}
if (itemType === LibraryItem.SONG || itemType === LibraryItem.PLAYLIST_SONG) {
const data = internalState.getData();
const validSongs = data.filter((d): d is Song => {
if (!d || typeof d !== 'object') {
return false;
}
if (!('_itemType' in d)) {
return false;
}
return (d as { _itemType: LibraryItem })._itemType === LibraryItem.SONG;
});
if (validSongs.length === 0) {
return;
}
const clickedSongId = item.id;
const clickedIndex = validSongs.findIndex((song) => song.id === clickedSongId);
if (clickedIndex === -1) {
return;
}
const playType = (meta?.playType as Play) || Play.NOW;
// For NEXT, LAST, NEXT_SHUFFLE, and LAST_SHUFFLE, only add the clicked song
// For NOW and SHUFFLE, add a range of songs around the clicked song
let songsToAdd: Song[];
if (
playType === Play.NEXT ||
playType === Play.LAST ||
playType === Play.NEXT_SHUFFLE ||
playType === Play.LAST_SHUFFLE
) {
songsToAdd = [item as Song];
} else {
const songsBefore = 50;
const songsAfter = 50;
const startIndex = Math.max(0, clickedIndex - songsBefore);
const endIndex = Math.min(validSongs.length, clickedIndex + songsAfter + 1);
songsToAdd = validSongs.slice(startIndex, endIndex);
}
if (songsToAdd.length === 0) {
return;
}
player.addToQueueByData(songsToAdd, playType, item.id);
return;
}
if (itemType === LibraryItem.QUEUE_SONG) {
const queueSong = item as QueueSong;
if (queueSong._uniqueId) {
player.mediaPlay(queueSong._uniqueId);
}
}
},
onExpand: ({ internalState, item }: DefaultItemControlProps) => {
if (!item || !internalState) {
return;
}
// Extract rowId from the item
const rowId = internalState.extractRowId(item);
if (!rowId) return;
// Use the item directly (rowId is separate, used only as key in state)
const itemListItem = item as ItemListStateItemWithRequiredProperties;
return internalState?.toggleExpanded(itemListItem);
},
onFavorite: ({
favorite,
item,
itemType,
}: DefaultItemControlProps & { favorite: boolean }) => {
if (!item) {
return;
}
const apiItemType = itemTypeMapping[itemType] || itemType;
if (!item.id || !item._serverId) {
return;
}
setFavorite(item._serverId, [item.id], apiItemType, favorite);
},
onMore: ({ event, internalState, item, itemType }: DefaultItemControlProps) => {
if (!item || !event) {
return;
}
// For context menus, prioritize the itemType prop when it's PLAYLIST_SONG or QUEUE_SONG
// This is because playlist/queue songs are Song objects (_itemType: SONG) but need special context menus
// Otherwise, use the item's _itemType if available, or fall back to the mapped itemType
const actualItemType =
itemType === LibraryItem.PLAYLIST_SONG || itemType === LibraryItem.QUEUE_SONG
? itemType
: (item as any)?._itemType || itemTypeMapping[itemType] || itemType;
// If no internalState, call ContextMenuController directly
if (!internalState) {
return ContextMenuController.call({
cmd: { items: [item] as any[], type: actualItemType as any },
event,
});
}
const rowId = internalState.extractRowId(item);
if (!rowId) return;
if (!enableMultiSelect) {
return ContextMenuController.call({
cmd: { items: [item] as any[], type: actualItemType as any },
event,
});
}
// If none selected, select this item
if (internalState.getSelected().length === 0) {
internalState.setSelected([item]);
return ContextMenuController.call({
cmd: { items: [item] as any[], type: actualItemType as any },
event,
});
}
// If this item is not already selected, replace the selection with this item
else if (!internalState.isSelected(rowId)) {
internalState.setSelected([item]);
return ContextMenuController.call({
cmd: { items: [item] as any[], type: actualItemType as any },
event,
});
}
const selectedItems = internalState.getSelected();
// For multiple selected items, prioritize the itemType prop for PLAYLIST_SONG/QUEUE_SONG
// Otherwise use the first item's _itemType or the mapped type
const selectedItemType =
itemType === LibraryItem.PLAYLIST_SONG || itemType === LibraryItem.QUEUE_SONG
? itemType
: selectedItems.length > 0 && (selectedItems[0] as any)?._itemType
? (selectedItems[0] as any)._itemType
: itemTypeMapping[itemType] || itemType;
return ContextMenuController.call({
cmd: { items: selectedItems as any[], type: selectedItemType as any },
event,
});
},
onPlay: ({
item,
itemType,
playType,
}: DefaultItemControlProps & { playType: Play }) => {
if (!item) {
return;
}
const isExternal =
(item as Song & { _serverType?: ServerType })._serverType ===
ServerType.EXTERNAL;
if (isExternal) {
if (
itemType === LibraryItem.SONG ||
itemType === LibraryItem.PLAYLIST_SONG ||
(item as { _itemType?: LibraryItem })._itemType === LibraryItem.SONG
) {
player.addToQueueByData([item as Song], playType, item.id);
return;
}
if (itemType === LibraryItem.ALBUM) {
(async () => {
try {
const album = await queryClient.fetchQuery(
albumQueries.detail({
query: { id: item.id },
serverId: 'musicbrainz',
}),
);
const songs = album?.songs ?? [];
if (songs.length > 0) {
player.addToQueueByData(songs, playType);
}
} catch {
console.error('Error fetching album songs for item', item);
}
})();
return;
}
}
player.addToQueueByFetch(item._serverId, [item.id], itemType, playType);
},
onRating: ({
item,
itemType,
rating,
}: DefaultItemControlProps & { rating: number }) => {
if (!item) {
return;
}
const apiItemType = itemTypeMapping[itemType] || itemType;
if (!item.id || !item._serverId) {
return;
}
const previousRating = (item as { userRating: number }).userRating || 0;
let newRating = rating;
if (previousRating === rating) {
newRating = 0;
}
setRating(item._serverId, [item.id], apiItemType, newRating);
},
...overrides,
};
}, [
enableMultiSelect,
onColumnReordered,
onColumnResized,
overrides,
player,
queryClient,
setFavorite,
setRating,
]);
return controls;
};