diff --git a/package-lock.json b/package-lock.json index f2a76ca971..cab7207fdb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,7 @@ "@types/lodash-es": "4.17.7", "@types/react": "17.0.59", "@types/react-dom": "17.0.20", + "@types/sortablejs": "1.15.3", "@typescript-eslint/eslint-plugin": "5.59.7", "@typescript-eslint/parser": "5.59.7", "@uupaa/dynamic-import-polyfill": "1.0.2", @@ -4317,6 +4318,12 @@ "@types/node": "*" } }, + "node_modules/@types/sortablejs": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.3.tgz", + "integrity": "sha512-v+zh6TZP/cLeMUK0MDx1onp8e7Jk2/4iTQ7sb/n80rTAvBm14yJkpOEfJdrTCkHiF7IZbPjxGX2NRJfogRoYIg==", + "dev": true + }, "node_modules/@types/trusted-types": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", @@ -24923,6 +24930,12 @@ "@types/node": "*" } }, + "@types/sortablejs": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.3.tgz", + "integrity": "sha512-v+zh6TZP/cLeMUK0MDx1onp8e7Jk2/4iTQ7sb/n80rTAvBm14yJkpOEfJdrTCkHiF7IZbPjxGX2NRJfogRoYIg==", + "dev": true + }, "@types/trusted-types": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", diff --git a/package.json b/package.json index cacf693046..9aceeadcbf 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@types/lodash-es": "4.17.7", "@types/react": "17.0.59", "@types/react-dom": "17.0.20", + "@types/sortablejs": "1.15.3", "@typescript-eslint/eslint-plugin": "5.59.7", "@typescript-eslint/parser": "5.59.7", "@uupaa/dynamic-import-polyfill": "1.0.2", diff --git a/src/apps/experimental/components/library/ItemsContainer.tsx b/src/apps/experimental/components/library/ItemsContainer.tsx deleted file mode 100644 index 4c3b28c71c..0000000000 --- a/src/apps/experimental/components/library/ItemsContainer.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React, { FC, useEffect, useRef } from 'react'; - -import ItemsContainerElement from 'elements/ItemsContainerElement'; -import imageLoader from 'components/images/imageLoader'; -import 'elements/emby-itemscontainer/emby-itemscontainer'; -import { LibraryViewSettings, ViewMode } from 'types/library'; - -interface ItemsContainerI { - libraryViewSettings: LibraryViewSettings; - getItemsHtml: () => string -} - -const ItemsContainer: FC = ({ libraryViewSettings, getItemsHtml }) => { - const element = useRef(null); - - useEffect(() => { - const itemsContainer = element.current?.querySelector('.itemsContainer') as HTMLDivElement; - itemsContainer.innerHTML = getItemsHtml(); - imageLoader.lazyChildren(itemsContainer); - }, [getItemsHtml]); - - const cssClass = libraryViewSettings.ViewMode === ViewMode.ListView ? 'vertical-list' : 'vertical-wrap'; - - return ( -
- -
- ); -}; - -export default ItemsContainer; diff --git a/src/apps/experimental/components/library/ItemsView.tsx b/src/apps/experimental/components/library/ItemsView.tsx index bdbb54fc77..5dffd1a66d 100644 --- a/src/apps/experimental/components/library/ItemsView.tsx +++ b/src/apps/experimental/components/library/ItemsView.tsx @@ -11,9 +11,9 @@ import listview from 'components/listview/listview'; import cardBuilder from 'components/cardbuilder/cardBuilder'; import { playbackManager } from 'components/playback/playbackmanager'; import globalize from 'scripts/globalize'; +import ItemsContainer from 'elements/emby-itemscontainer/ItemsContainer'; import AlphabetPicker from './AlphabetPicker'; import FilterButton from './filter/FilterButton'; -import ItemsContainer from './ItemsContainer'; import NewCollectionButton from './NewCollectionButton'; import Pagination from './Pagination'; import PlayAllButton from './PlayAllButton'; @@ -67,7 +67,8 @@ const ItemsView: FC = ({ const { isLoading, data: itemsResult, - isPreviousData + isPreviousData, + refetch } = useGetItemsViewByType( viewType, parentId, @@ -252,7 +253,10 @@ const ItemsView: FC = ({ ) : ( )} diff --git a/src/elements/emby-itemscontainer/ItemsContainer.tsx b/src/elements/emby-itemscontainer/ItemsContainer.tsx new file mode 100644 index 0000000000..c4b25547a6 --- /dev/null +++ b/src/elements/emby-itemscontainer/ItemsContainer.tsx @@ -0,0 +1,522 @@ +import type { + LibraryUpdateInfo, + SeriesTimerInfoDto, + TimerInfoDto, + UserItemDataDto +} from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useCallback, useEffect, useRef } from 'react'; +import classNames from 'classnames'; +import { Box } from '@mui/material'; +import Sortable from 'sortablejs'; +import { usePlaylistsMoveItemMutation } from 'hooks/useFetchItems'; +import Events, { Event } from 'utils/events'; +import serverNotifications from 'scripts/serverNotifications'; +import inputManager from 'scripts/inputManager'; +import dom from 'scripts/dom'; +import browser from 'scripts/browser'; +import imageLoader from 'components/images/imageLoader'; +import layoutManager from 'components/layoutManager'; +import { playbackManager } from 'components/playback/playbackmanager'; +import itemShortcuts from 'components/shortcuts'; +import MultiSelect from 'components/multiSelect/multiSelect'; +import loading from 'components/loading/loading'; +import focusManager from 'components/focusManager'; +import { LibraryViewSettings, ParentId, ViewMode } from 'types/library'; + +function disableEvent(e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + return false; +} + +function getShortcutOptions() { + return { + click: false + }; +} + +interface ItemsContainerProps { + className?: string; + libraryViewSettings: LibraryViewSettings; + isContextMenuEnabled?: boolean; + isMultiSelectEnabled?: boolean; + isDragreOrderEnabled?: boolean; + dataMonitor?: string; + parentId?: ParentId; + reloadItems: () => void; + getItemsHtml?: () => string; + children?: React.ReactNode; +} + +const ItemsContainer: FC = ({ + className, + libraryViewSettings, + isContextMenuEnabled, + isMultiSelectEnabled, + isDragreOrderEnabled, + dataMonitor, + parentId, + reloadItems, + getItemsHtml, + children +}) => { + const { mutateAsync: playlistsMoveItemMutation } = usePlaylistsMoveItemMutation(); + const itemsContainerRef = useRef(null); + const multiSelectref = useRef(null); + const sortableref = useRef(null); + const timerRef = useRef | null>(null); + + const onClick = useCallback((e: MouseEvent) => { + const itemsContainer = itemsContainerRef.current as HTMLDivElement; + const multiSelect = multiSelectref.current; + + if ( + multiSelect + && multiSelect.onContainerClick.call(itemsContainer, e) === false + ) { + return; + } + + itemShortcuts.onClick.call(itemsContainer, e); + }, []); + + const onContextMenu = useCallback((e: MouseEvent) => { + const target = e.target as HTMLElement; + const card = dom.parentWithAttribute(target, 'data-id'); + + // check for serverId, it won't be present on selectserver + if (card?.getAttribute('data-serverid')) { + inputManager.handleCommand('menu', { + sourceElement: card + }); + + e.preventDefault(); + e.stopPropagation(); + return false; + } + }, []); + + const initMultiSelect = useCallback((itemsContainer: HTMLDivElement) => { + multiSelectref.current = new MultiSelect({ + container: itemsContainer, + bindOnClick: false + }); + }, []); + + const destroyMultiSelect = useCallback(() => { + if (multiSelectref.current) { + multiSelectref.current.destroy(); + multiSelectref.current = null; + } + }, []); + + const onDrop = useCallback( + async (evt: Sortable.SortableEvent) => { + const el = evt.item; + + const newIndex = evt.newIndex; + const itemId = el.getAttribute('data-playlistitemid'); + const playlistId = el.getAttribute('data-playlistid'); + + if (!playlistId) { + const oldIndex = evt.oldIndex; + el.dispatchEvent( + new CustomEvent('itemdrop', { + detail: { + oldIndex: oldIndex, + newIndex: newIndex, + playlistItemId: itemId + }, + bubbles: true, + cancelable: false + }) + ); + return; + } + + if (!itemId) throw new Error('null itemId'); + if (!newIndex) throw new Error('null newIndex'); + + try { + loading.show(); + await playlistsMoveItemMutation({ + playlistId, + itemId, + newIndex + }); + loading.hide(); + } catch (error) { + loading.hide(); + reloadItems(); + } + }, + [playlistsMoveItemMutation, reloadItems] + ); + + const initDragReordering = useCallback((itemsContainer: HTMLDivElement) => { + sortableref.current = Sortable.create(itemsContainer, { + draggable: '.listItem', + handle: '.listViewDragHandle', + + // dragging ended + onEnd: (evt: Sortable.SortableEvent) => { + return onDrop(evt); + } + }); + }, [onDrop]); + + const destroyDragReordering = useCallback(() => { + if (sortableref.current) { + sortableref.current.destroy(); + sortableref.current = null; + } + }, []); + + const notifyRefreshNeeded = useCallback( + (isInForeground: boolean) => { + if (isInForeground === true) { + reloadItems(); + } else { + timerRef.current = setTimeout(() => reloadItems(), 10000); + } + }, + [reloadItems] + ); + + const getEventsToMonitor = useCallback(() => { + const monitor = dataMonitor; + if (monitor) { + return monitor.split(','); + } + + return []; + }, [dataMonitor]); + + const onUserDataChanged = useCallback( + (_e: Event, userData: UserItemDataDto) => { + const itemsContainer = itemsContainerRef.current as HTMLDivElement; + + import('../../components/cardbuilder/cardBuilder') + .then((cardBuilder) => { + cardBuilder.onUserDataChanged(userData, itemsContainer); + }) + .catch((err) => { + console.error( + '[onUserDataChanged] failed to load onUserData Changed', + err + ); + }); + + const eventsToMonitor = getEventsToMonitor(); + if ( + eventsToMonitor.indexOf('markfavorite') !== -1 + || eventsToMonitor.indexOf('markplayed') !== -1 + ) { + notifyRefreshNeeded(false); + } + }, + [getEventsToMonitor, notifyRefreshNeeded] + ); + + const onTimerCreated = useCallback( + (_e: Event, data: TimerInfoDto) => { + const itemsContainer = itemsContainerRef.current as HTMLDivElement; + const eventsToMonitor = getEventsToMonitor(); + if (eventsToMonitor.indexOf('timers') !== -1) { + notifyRefreshNeeded(false); + return; + } + + const programId = data.ProgramId; + // This could be null, not supported by all tv providers + const newTimerId = data.Id; + if (programId && newTimerId) { + import('../../components/cardbuilder/cardBuilder') + .then((cardBuilder) => { + cardBuilder.onTimerCreated( + programId, + newTimerId, + itemsContainer + ); + }) + .catch((err) => { + console.error( + '[onTimerCreated] failed to load onTimer Created', + err + ); + }); + } + }, + [getEventsToMonitor, notifyRefreshNeeded] + ); + + const onSeriesTimerCreated = useCallback(() => { + const eventsToMonitor = getEventsToMonitor(); + if (eventsToMonitor.indexOf('seriestimers') !== -1) { + notifyRefreshNeeded(false); + } + }, [getEventsToMonitor, notifyRefreshNeeded]); + + const onTimerCancelled = useCallback( + (_e: Event, data: TimerInfoDto) => { + const itemsContainer = itemsContainerRef.current as HTMLDivElement; + const eventsToMonitor = getEventsToMonitor(); + if (eventsToMonitor.indexOf('timers') !== -1) { + notifyRefreshNeeded(false); + return; + } + + const timerId = data.Id; + + if (timerId) { + import('../../components/cardbuilder/cardBuilder') + .then((cardBuilder) => { + cardBuilder.onTimerCancelled(timerId, itemsContainer); + }) + .catch((err) => { + console.error( + '[onTimerCancelled] failed to load onTimer Cancelled', + err + ); + }); + } + }, + [getEventsToMonitor, notifyRefreshNeeded] + ); + + const onSeriesTimerCancelled = useCallback( + (_e: Event, data: SeriesTimerInfoDto) => { + const itemsContainer = itemsContainerRef.current as HTMLDivElement; + const eventsToMonitor = getEventsToMonitor(); + if (eventsToMonitor.indexOf('seriestimers') !== -1) { + notifyRefreshNeeded(false); + return; + } + + const cancelledTimerId = data.Id; + + if (cancelledTimerId) { + import('../../components/cardbuilder/cardBuilder') + .then((cardBuilder) => { + cardBuilder.onSeriesTimerCancelled( + cancelledTimerId, + itemsContainer + ); + }) + .catch((err) => { + console.error( + '[onSeriesTimerCancelled] failed to load onSeriesTimer Cancelled', + err + ); + }); + } + }, + [getEventsToMonitor, notifyRefreshNeeded] + ); + + const onLibraryChanged = useCallback( + (_e: Event, data: LibraryUpdateInfo) => { + const eventsToMonitor = getEventsToMonitor(); + if ( + eventsToMonitor.indexOf('seriestimers') !== -1 + || eventsToMonitor.indexOf('timers') !== -1 + ) { + // yes this is an assumption + return; + } + + const itemsAdded = data.ItemsAdded ?? []; + const itemsRemoved = data.ItemsRemoved ?? []; + if (!itemsAdded.length && !itemsRemoved.length) { + return; + } + + if (parentId) { + const foldersAddedTo = data.FoldersAddedTo ?? []; + const foldersRemovedFrom = data.FoldersRemovedFrom ?? []; + const collectionFolders = data.CollectionFolders ?? []; + + if ( + foldersAddedTo.indexOf(parentId) === -1 + && foldersRemovedFrom.indexOf(parentId) === -1 + && collectionFolders.indexOf(parentId) === -1 + ) { + return; + } + } + + notifyRefreshNeeded(false); + }, + [getEventsToMonitor, notifyRefreshNeeded, parentId] + ); + + const onPlaybackStopped = useCallback( + (_e: Event, stopInfo) => { + const state = stopInfo.state; + + const eventsToMonitor = getEventsToMonitor(); + if ( + state.NowPlayingItem + && state.NowPlayingItem.MediaType === 'Video' + ) { + if (eventsToMonitor.indexOf('videoplayback') !== -1) { + notifyRefreshNeeded(true); + return; + } + } else if ( + state.NowPlayingItem + && state.NowPlayingItem.MediaType === 'Audio' + && eventsToMonitor.indexOf('audioplayback') !== -1 + ) { + notifyRefreshNeeded(true); + return; + } + }, + [getEventsToMonitor, notifyRefreshNeeded] + ); + + const setFocus = useCallback( + ( + itemsContainer: HTMLDivElement, + focusId: string | null | undefined + ) => { + if (focusId) { + const newElement = itemsContainer.querySelector( + '[data-id="' + focusId + '"]' + ); + if (newElement) { + try { + focusManager.focus(newElement); + return; + } catch (err) { + console.error(err); + } + } + } + + focusManager.autoFocus(itemsContainer); + }, + [] + ); + + useEffect(() => { + const itemsContainer = itemsContainerRef.current; + + if (!itemsContainer) { + console.error('Unexpected null reference'); + return; + } + + const activeElement = document.activeElement; + let focusId; + let hasActiveElement; + if (itemsContainer?.contains(activeElement)) { + hasActiveElement = true; + focusId = activeElement?.getAttribute('data-id'); + } + + if (getItemsHtml) { + itemsContainer.innerHTML = getItemsHtml(); + } + + imageLoader.lazyChildren(itemsContainer); + + if (hasActiveElement) { + setFocus(itemsContainer, focusId); + } + }, [getItemsHtml, setFocus]); + + useEffect(() => { + const itemsContainer = itemsContainerRef.current; + + if (!itemsContainer) { + console.error('Unexpected null reference'); + return; + } + + if ( + layoutManager.desktop + || (layoutManager.mobile && isMultiSelectEnabled !== false) + ) { + initMultiSelect(itemsContainer); + } + + if (isDragreOrderEnabled === true) { + initDragReordering(itemsContainer); + } + + itemsContainer.addEventListener('click', onClick); + + if (browser.touch) { + itemsContainer.addEventListener('contextmenu', disableEvent); + } else if (isContextMenuEnabled !== false) { + itemsContainer.addEventListener('contextmenu', onContextMenu); + } + + itemShortcuts.on(itemsContainer, getShortcutOptions()); + + Events.on(serverNotifications, 'UserDataChanged', onUserDataChanged); + Events.on(serverNotifications, 'TimerCreated', onTimerCreated); + Events.on(serverNotifications, 'TimerCancelled', onTimerCancelled); + Events.on(serverNotifications, 'SeriesTimerCreated', onSeriesTimerCreated); + Events.on(serverNotifications, 'SeriesTimerCancelled', onSeriesTimerCancelled); + Events.on(serverNotifications, 'LibraryChanged', onLibraryChanged); + Events.on(playbackManager, 'playbackstop', onPlaybackStopped); + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + destroyMultiSelect(); + destroyDragReordering(); + itemsContainer.removeEventListener('click', onClick); + itemsContainer.removeEventListener('contextmenu', onContextMenu); + itemsContainer.removeEventListener('contextmenu', disableEvent); + + itemShortcuts.off(itemsContainer, getShortcutOptions()); + + Events.off(serverNotifications, 'UserDataChanged', onUserDataChanged); + Events.off(serverNotifications, 'TimerCreated', onTimerCreated); + Events.off(serverNotifications, 'TimerCancelled', onTimerCancelled); + Events.off( serverNotifications, 'SeriesTimerCreated', onSeriesTimerCreated); + Events.off(serverNotifications, 'SeriesTimerCancelled', onSeriesTimerCancelled); + Events.off(serverNotifications, 'LibraryChanged', onLibraryChanged); + Events.off(playbackManager, 'playbackstop', onPlaybackStopped); + }; + }, [ + destroyDragReordering, + destroyMultiSelect, + initDragReordering, + initMultiSelect, + isContextMenuEnabled, + isDragreOrderEnabled, + isMultiSelectEnabled, + onClick, + onContextMenu, + onLibraryChanged, + onPlaybackStopped, + onSeriesTimerCancelled, + onSeriesTimerCreated, + onTimerCancelled, + onTimerCreated, + onUserDataChanged + ]); + + const itemsContainerClass = classNames( + 'itemsContainer', + { 'itemsContainer-tv': layoutManager.tv }, + libraryViewSettings.ViewMode === ViewMode.ListView ? + 'vertical-list' : + 'vertical-wrap', + className + ); + + return ( + + {children} + + ); +}; + +export default ItemsContainer; diff --git a/src/hooks/useFetchItems.ts b/src/hooks/useFetchItems.ts index 6ddd77a120..89c7498eff 100644 --- a/src/hooks/useFetchItems.ts +++ b/src/hooks/useFetchItems.ts @@ -1,4 +1,5 @@ -import type { ItemsApiGetItemsRequest } from '@jellyfin/sdk/lib/generated-client'; +import { AxiosRequestConfig } from 'axios'; +import type { ItemsApiGetItemsRequest, PlaylistsApiMoveItemRequest } from '@jellyfin/sdk/lib/generated-client'; import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type'; import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields'; @@ -13,8 +14,8 @@ import { getMoviesApi } from '@jellyfin/sdk/lib/utils/api/movies-api'; import { getStudiosApi } from '@jellyfin/sdk/lib/utils/api/studios-api'; import { getTvShowsApi } from '@jellyfin/sdk/lib/utils/api/tv-shows-api'; import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api/user-library-api'; -import { AxiosRequestConfig } from 'axios'; -import { useQuery } from '@tanstack/react-query'; +import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { JellyfinApiContext, useApi } from './useApi'; import { getAlphaPickerQuery, getFieldsQuery, getFiltersQuery, getLimitQuery } from 'utils/items'; @@ -509,3 +510,24 @@ export const useGetItemsViewByType = ( ].includes(viewType) && !!parentId }); }; + +const fetchPlaylistsMoveItem = async ( + currentApi: JellyfinApiContext, + requestParameters: PlaylistsApiMoveItemRequest +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + const response = await getPlaylistsApi(api).moveItem({ + ...requestParameters + }); + return response.data; + } +}; + +export const usePlaylistsMoveItemMutation = () => { + const currentApi = useApi(); + return useMutation({ + mutationFn: (requestParameters: PlaylistsApiMoveItemRequest) => + fetchPlaylistsMoveItem(currentApi, requestParameters ) + }); +}; diff --git a/src/utils/events.ts b/src/utils/events.ts index 0144ce2270..97d8852151 100644 --- a/src/utils/events.ts +++ b/src/utils/events.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -interface Event { +export interface Event { type: string; }