diff --git a/package-lock.json b/package-lock.json index 2ecbd9d155e..808a4c4546c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "screenfull": "6.0.2", "sortablejs": "1.15.0", "swiper": "9.3.2", + "use-local-storage-state": "17.3.0", "webcomponents.js": "0.7.24", "whatwg-fetch": "3.6.2", "workbox-core": "6.6.0", @@ -15886,6 +15887,15 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/sortablejs": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz", @@ -20019,6 +20029,21 @@ "node": ">=0.10.0" } }, + "node_modules/use-local-storage-state": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/use-local-storage-state/-/use-local-storage-state-17.3.0.tgz", + "integrity": "sha512-PZruqtmMkYCgcC5t+0Mbka1rN2jjhC1SMA2UD8o6HbpblZf8E9aaXF0sEwlQAWlBpKGlNYvbqueB8dlbwX7n/g==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/astoilkov" + }, + "peerDependencies": { + "react": ">=16.8.0 < 18", + "react-dom": ">=16.8.0 < 18" + } + }, "node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -20047,15 +20072,6 @@ "node": ">= 0.4.0" } }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/v8-compile-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", @@ -32508,6 +32524,14 @@ "faye-websocket": "^0.11.3", "uuid": "^8.3.2", "websocket-driver": "^0.7.4" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true + } } }, "sortablejs": { @@ -35703,6 +35727,12 @@ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "dev": true }, + "use-local-storage-state": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/use-local-storage-state/-/use-local-storage-state-17.3.0.tgz", + "integrity": "sha512-PZruqtmMkYCgcC5t+0Mbka1rN2jjhC1SMA2UD8o6HbpblZf8E9aaXF0sEwlQAWlBpKGlNYvbqueB8dlbwX7n/g==", + "requires": {} + }, "use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -35726,12 +35756,6 @@ "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", "dev": true }, - "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true - }, "v8-compile-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", diff --git a/package.json b/package.json index 988c2a0215e..0893bf3979a 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "screenfull": "6.0.2", "sortablejs": "1.15.0", "swiper": "9.3.2", + "use-local-storage-state": "17.3.0", "webcomponents.js": "0.7.24", "whatwg-fetch": "3.6.2", "workbox-core": "6.6.0", diff --git a/src/apps/experimental/components/library/AlphabetPicker.tsx b/src/apps/experimental/components/library/AlphabetPicker.tsx new file mode 100644 index 00000000000..5d92eaaa58d --- /dev/null +++ b/src/apps/experimental/components/library/AlphabetPicker.tsx @@ -0,0 +1,82 @@ +import React, { useCallback } from 'react'; +import classNames from 'classnames'; + +import Box from '@mui/material/Box'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; + +import { LibraryViewSettings } from 'types/library'; +import 'components/alphaPicker/style.scss'; + +interface AlphabetPickerProps { + className?: string; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch< + React.SetStateAction + >; +} + +const AlphabetPicker: React.FC = ({ + className, + libraryViewSettings, + setLibraryViewSettings +}) => { + const handleValue = useCallback( + ( + event: React.MouseEvent, + newValue: string | null | undefined + ) => { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + Alphabet: newValue + })); + }, + [setLibraryViewSettings] + ); + + const containerClassName = classNames( + 'alphaPicker', + className, + 'alphaPicker-fixed-right' + ); + + const btnClassName = classNames( + 'paper-icon-button-light', + 'alphaPickerButton' + ); + + const letters = ['#', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z']; + + return ( + + + {letters.map((l) => ( + + {l} + + ))} + + + ); +}; + +export default AlphabetPicker; diff --git a/src/apps/experimental/components/library/FavoriteItemsContainer.tsx b/src/apps/experimental/components/library/FavoriteItemsContainer.tsx new file mode 100644 index 00000000000..90b08867ebd --- /dev/null +++ b/src/apps/experimental/components/library/FavoriteItemsContainer.tsx @@ -0,0 +1,221 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC } from 'react'; +import FavoritesSectionContainer from './FavoritesSectionContainer'; +import { ParentId } from 'types/library'; +import { SectionsView, SectionsViewType } from 'types/suggestionsSections'; + +function getFavoriteSections() { + return [ + { + name: 'HeaderFavoriteMovies', + type: 'Movie', + view: SectionsView.FavoriteMovies, + parametersOptions: { + includeItemTypes: [BaseItemKind.Movie] + }, + cardOptions: { + shape: 'overflowPortrait', + showTitle: true, + showYear: true, + overlayPlayButton: true, + overlayText: false, + centerText: true + } + }, + { + name: 'HeaderFavoriteShows', + type: 'Series', + view: SectionsView.FavoriteShows, + parametersOptions: { + includeItemTypes: [BaseItemKind.Series] + }, + cardOptions: { + shape: 'overflowPortrait', + showTitle: true, + showYear: true, + overlayPlayButton: true, + overlayText: false, + centerText: true + } + }, + { + name: 'HeaderFavoriteEpisodes', + type: 'Episode', + view: SectionsView.FavoriteEpisode, + parametersOptions: { + includeItemTypes: [BaseItemKind.Episode] + }, + cardOptions: { + shape: 'overflowBackdrop', + preferThumb: false, + showTitle: true, + showParentTitle: true, + overlayPlayButton: true, + overlayText: false, + centerText: true + } + }, + { + name: 'HeaderFavoriteVideos', + type: 'Video,MusicVideo', + view: SectionsView.FavoriteVideos, + parametersOptions: { + includeItemTypes: [BaseItemKind.Video, BaseItemKind.MusicVideo] + }, + cardOptions: { + shape: 'overflowBackdrop', + preferThumb: true, + showTitle: true, + overlayPlayButton: true, + overlayText: false, + centerText: true + } + }, + { + name: 'HeaderFavoriteCollections', + type: 'BoxSet', + view: SectionsView.FavoriteCollections, + parametersOptions: { + includeItemTypes: [BaseItemKind.BoxSet] + }, + cardOptions: { + shape: 'overflowPortrait', + showTitle: true, + overlayPlayButton: true, + overlayText: false, + centerText: true + } + }, + { + name: 'HeaderFavoritePlaylists', + type: 'Playlist', + view: SectionsView.FavoritePlaylists, + parametersOptions: { + includeItemTypes: [BaseItemKind.Playlist] + }, + cardOptions: { + shape: 'overflowSquare', + preferThumb: false, + showTitle: true, + overlayText: false, + showParentTitle: false, + centerText: true, + overlayPlayButton: true, + coverImage: true + } + }, + { + name: 'HeaderFavoritePersons', + viewType: SectionsViewType.Persons, + type: 'Person', + view: SectionsView.FavoritePeople, + cardOptions: { + shape: 'overflowPortrait', + preferThumb: false, + showTitle: true, + overlayText: false, + showParentTitle: false, + centerText: true, + overlayPlayButton: true, + coverImage: true + } + }, + { + name: 'HeaderFavoriteArtists', + viewType: SectionsViewType.Artists, + type: 'MusicArtist', + view: SectionsView.FavoriteArtists, + cardOptions: { + shape: 'overflowSquare', + preferThumb: false, + showTitle: true, + overlayText: false, + showParentTitle: false, + centerText: true, + overlayPlayButton: true, + coverImage: true + } + }, + { + name: 'HeaderFavoriteAlbums', + type: 'MusicAlbum', + view: SectionsView.FavoriteAlbums, + parametersOptions: { + includeItemTypes: [BaseItemKind.MusicAlbum] + }, + cardOptions: { + shape: 'overflowSquare', + preferThumb: false, + showTitle: true, + overlayText: false, + showParentTitle: true, + centerText: true, + overlayPlayButton: true, + coverImage: true + } + }, + { + name: 'HeaderFavoriteSongs', + type: 'Audio', + view: SectionsView.FavoriteSongs, + parametersOptions: { + includeItemTypes: [BaseItemKind.Audio] + }, + cardOptions: { + shape: 'overflowSquare', + preferThumb: false, + showTitle: true, + overlayText: false, + showParentTitle: true, + centerText: true, + overlayMoreButton: true, + action: 'instantmix', + coverImage: true + } + }, + { + name: 'HeaderFavoriteBooks', + type: 'Book', + view: SectionsView.FavoriteBooks, + parametersOptions: { + includeItemTypes: [BaseItemKind.Book] + }, + cardOptions: { + shape: 'overflowPortrait', + showTitle: true, + showYear: true, + overlayPlayButton: true, + overlayText: false, + centerText: true + } + } + ]; +} + +interface FavoriteItemsContainerProps { + parentId?: ParentId; + sectionsView: SectionsView[]; +} + +const FavoriteItemsContainer: FC = ({ + parentId, + sectionsView +}) => { + const favoriteSections = getFavoriteSections(); + + return ( + <> + {favoriteSections + .filter((section) => sectionsView.includes(section.view)) + .map((section) => ( + + ))} + + ); +}; + +export default FavoriteItemsContainer; diff --git a/src/apps/experimental/components/library/FavoritesSectionContainer.tsx b/src/apps/experimental/components/library/FavoritesSectionContainer.tsx new file mode 100644 index 00000000000..765d8936986 --- /dev/null +++ b/src/apps/experimental/components/library/FavoritesSectionContainer.tsx @@ -0,0 +1,50 @@ +import React, { FC } from 'react'; +import SectionContainer from './SectionContainer'; +import globalize from 'scripts/globalize'; +import Loading from 'components/loading/LoadingComponent'; +import { appRouter } from 'components/router/appRouter'; +import { Sections } from 'types/suggestionsSections'; +import { useGetItemsByFavoriteType } from 'hooks/useFetchItems'; + +interface FavoritesSectionContainerProps { + parentId?: string | null; + section: Sections; +} + +const FavoritesSectionContainer: FC = ({ + parentId, + section +}) => { + const getRouteUrl = () => { + return appRouter.getRouteUrl('list', { + serverId: window.ApiClient.serverId(), + itemTypes: section.type, + isFavorite: true + }); + }; + + const { isLoading, data: items } = useGetItemsByFavoriteType( + section, + parentId + ); + + if (isLoading) { + return ; + } + + return ( + + ); +}; + +export default FavoritesSectionContainer; diff --git a/src/apps/experimental/components/library/GenresItemsContainer.tsx b/src/apps/experimental/components/library/GenresItemsContainer.tsx deleted file mode 100644 index 41fba412d14..00000000000 --- a/src/apps/experimental/components/library/GenresItemsContainer.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; -import React, { FC } from 'react'; -import { useGetGenres } from 'hooks/useFetchItems'; -import globalize from 'scripts/globalize'; -import Loading from 'components/loading/LoadingComponent'; -import GenresSectionContainer from './GenresSectionContainer'; -import { CollectionType } from 'types/collectionType'; - -interface GenresItemsContainerProps { - parentId?: string | null; - collectionType?: CollectionType; - itemType: BaseItemKind; -} - -const GenresItemsContainer: FC = ({ - parentId, - collectionType, - itemType -}) => { - const { isLoading, data: genresResult } = useGetGenres( - itemType, - parentId - ); - - if (isLoading) { - return ; - } - - return ( - <> - {!genresResult?.Items?.length ? ( -
-

{globalize.translate('MessageNothingHere')}

-

{globalize.translate('MessageNoGenresAvailable')}

-
- ) : ( - genresResult?.Items?.map((genre) => ( - - )) - )} - - ); -}; - -export default GenresItemsContainer; diff --git a/src/apps/experimental/components/library/GenresSectionContainer.tsx b/src/apps/experimental/components/library/GenresSectionContainer.tsx deleted file mode 100644 index 74f57782b19..00000000000 --- a/src/apps/experimental/components/library/GenresSectionContainer.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; -import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields'; -import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type'; -import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; -import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by'; -import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order'; -import escapeHTML from 'escape-html'; -import React, { FC } from 'react'; - -import { useGetItems } from 'hooks/useFetchItems'; -import Loading from 'components/loading/LoadingComponent'; -import { appRouter } from 'components/router/appRouter'; -import SectionContainer from './SectionContainer'; -import { CollectionType } from 'types/collectionType'; - -interface GenresSectionContainerProps { - parentId?: string | null; - collectionType?: CollectionType; - itemType: BaseItemKind; - genre: BaseItemDto; -} - -const GenresSectionContainer: FC = ({ - parentId, - collectionType, - itemType, - genre -}) => { - const getParametersOptions = () => { - return { - sortBy: [ItemSortBy.Random], - sortOrder: [SortOrder.Ascending], - includeItemTypes: [itemType], - recursive: true, - fields: [ - ItemFields.PrimaryImageAspectRatio, - ItemFields.MediaSourceCount, - ItemFields.BasicSyncInfo - ], - imageTypeLimit: 1, - enableImageTypes: [ImageType.Primary], - limit: 25, - genreIds: genre.Id ? [genre.Id] : undefined, - enableTotalRecordCount: false, - parentId: parentId ?? undefined - }; - }; - - const { isLoading, data: itemsResult } = useGetItems(getParametersOptions()); - - const getRouteUrl = (item: BaseItemDto) => { - return appRouter.getRouteUrl(item, { - context: collectionType, - parentId: parentId - }); - }; - - if (isLoading) { - return ; - } - - return ; -}; - -export default GenresSectionContainer; diff --git a/src/apps/experimental/components/library/GenresView.tsx b/src/apps/experimental/components/library/GenresView.tsx new file mode 100644 index 00000000000..a947705bc10 --- /dev/null +++ b/src/apps/experimental/components/library/GenresView.tsx @@ -0,0 +1,59 @@ +import React, { FC } from 'react'; +import escapeHTML from 'escape-html'; +import { useGetGroupsGenres } from 'hooks/useFetchItems'; +import { appRouter } from 'components/router/appRouter'; +import globalize from 'scripts/globalize'; +import Loading from 'components/loading/LoadingComponent'; +import SectionContainer from './SectionContainer'; +import { CollectionType } from 'types/collectionType'; +import { ParentId } from 'types/library'; +import { useLibrarySettings } from 'hooks/useLibrarySettings'; + +interface GenresViewProps { + collectionType?: CollectionType; + parentId?: ParentId; +} + +const GenresView: FC = () => { + const { item } = useLibrarySettings(); + const { isLoading, data: groupsGenres } = useGetGroupsGenres(item); + + if (isLoading) { + return ; + } + + return ( + <> + {!groupsGenres?.length ? ( +
+

{globalize.translate('MessageNothingHere')}

+

{globalize.translate('MessageNoGenresAvailable')}

+
+ ) : ( + groupsGenres.map(({ genre, items }) => ( + + )) + )} + + ); +}; + +export default GenresView; diff --git a/src/apps/experimental/components/library/ItemsContainer.tsx b/src/apps/experimental/components/library/ItemsContainer.tsx new file mode 100644 index 00000000000..8da11739710 --- /dev/null +++ b/src/apps/experimental/components/library/ItemsContainer.tsx @@ -0,0 +1,135 @@ +import { ImageType, type BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useEffect, useCallback, useRef } from 'react'; +import globalize from 'scripts/globalize'; +import cardBuilder from 'components/cardbuilder/cardBuilder'; +import listview from 'components/listview/listview'; +import imageLoader from 'components/images/imageLoader'; +import ItemsContainerElement from 'elements/ItemsContainerElement'; +import 'elements/emby-itemscontainer/emby-itemscontainer'; +import { LibraryViewSettings, ViewMode } from 'types/library'; +import { LibraryTab } from 'types/libraryTab'; +import { CollectionType } from 'types/collectionType'; +import { CardOptions } from 'types/cardOptions'; + +interface ItemsContainerI { + libraryViewSettings: LibraryViewSettings; + viewType: LibraryTab; + collectionType?: CollectionType; + items: BaseItemDto[]; +} + +const ItemsContainer: FC = ({ libraryViewSettings, viewType, collectionType, items }) => { + const element = useRef(null); + + const getCardOptions = useCallback(() => { + let shape; + let preferThumb; + let preferDisc; + let preferLogo; + let lines = libraryViewSettings.ShowTitle ? 2 : 0; + + if (libraryViewSettings.ImageType === ImageType.Banner) { + shape = 'banner'; + } else if (libraryViewSettings.ImageType === ImageType.Disc) { + shape = 'square'; + preferDisc = true; + } else if (libraryViewSettings.ImageType === ImageType.Logo) { + shape = 'backdrop'; + preferLogo = true; + } else if (libraryViewSettings.ImageType === ImageType.Thumb || viewType === LibraryTab.Networks) { + shape = 'backdrop'; + preferThumb = true; + } else { + shape = 'auto'; + } + + const cardOptions: CardOptions = { + shape: shape, + showTitle: libraryViewSettings.ShowTitle, + showYear: libraryViewSettings.ShowYear, + cardLayout: libraryViewSettings.CardLayout, + centerText: true, + context: collectionType, + coverImage: true, + preferThumb: preferThumb, + preferDisc: preferDisc, + preferLogo: preferLogo, + overlayPlayButton: false, + overlayMoreButton: true, + overlayText: !libraryViewSettings.ShowTitle + }; + + if ( + viewType === LibraryTab.Songs + || viewType === LibraryTab.Albums + || viewType === LibraryTab.Episodes + ) { + cardOptions.showParentTitle = libraryViewSettings.ShowTitle; + } else if (viewType === LibraryTab.Artists) { + cardOptions.showYear = false; + lines = 1; + } + + cardOptions.lines = lines; + cardOptions.items = items; + + return cardOptions; + }, [ + viewType, + collectionType, + items, + libraryViewSettings.CardLayout, + libraryViewSettings.ImageType, + libraryViewSettings.ShowTitle, + libraryViewSettings.ShowYear + ]); + + const getItemsHtml = useCallback(() => { + let html = ''; + + if (libraryViewSettings.ViewMode === ViewMode.ListView) { + html = listview.getListViewHtml({ + items: items, + context: collectionType + }); + } else { + html = cardBuilder.getCardsHtml( + items, + getCardOptions() + ); + } + + if (!items.length) { + html += '
'; + html + += '

' + globalize.translate('MessageNothingHere') + '

'; + html += '

' + globalize.translate('MessageNoItemsAvailable') + '

'; + html += '
'; + } + + return html; + }, [ + getCardOptions, + collectionType, + items, + libraryViewSettings.ViewMode + ]); + + 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 new file mode 100644 index 00000000000..afdbfebbb7c --- /dev/null +++ b/src/apps/experimental/components/library/ItemsView.tsx @@ -0,0 +1,100 @@ +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; +import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by'; +import React, { FC } from 'react'; +import Box from '@mui/material/Box'; + +import { useLibrarySettings } from 'hooks/useLibrarySettings'; +import { useGetItemsViewByType } from 'hooks/useFetchItems'; +import * as userSettings from 'scripts/settings/userSettings'; +import Loading from 'components/loading/LoadingComponent'; +import ItemsContainer from './ItemsContainer'; +import Pagination from './Pagination'; +import AlphabetPicker from './AlphabetPicker'; +import { LibraryTab } from 'types/libraryTab'; +import { ParentId } from 'types/library'; +import { CollectionType } from 'types/collectionType'; + +const visibleAlphaPicker = [ + LibraryTab.Movies, + LibraryTab.Favorites, + LibraryTab.Trailers, + LibraryTab.Series, + LibraryTab.Episodes, + LibraryTab.Albums, + LibraryTab.AlbumArtists, + LibraryTab.Artists +]; + +interface ItemsViewProps { + collectionType?: CollectionType; + parentId?: ParentId; + item?: BaseItemDto; + viewType: LibraryTab; +} + +const ItemsView: FC = ({ + viewType +}) => { + const { item, libraryViewSettings, setLibraryViewSettings } = + useLibrarySettings(); + const { + isLoading, + data: itemsResult, + isPreviousData + } = useGetItemsViewByType(viewType, item); + + const limit = userSettings.libraryPageSize(undefined); + const totalRecordCount = itemsResult?.TotalRecordCount ?? 0; + const showControls = limit > 0 && limit < totalRecordCount; + const items = itemsResult?.Items ?? []; + + const hasSortName = libraryViewSettings.SortBy.includes( + ItemSortBy.SortName + ); + + return ( + + {showControls && ( + + + + )} + + {visibleAlphaPicker.includes(viewType) && hasSortName && ( + + )} + + {isLoading ? ( + + ) : ( + + )} + + {showControls && ( + + + + )} + + ); +}; + +export default ItemsView; diff --git a/src/apps/experimental/components/library/LibraryHeaderSection.tsx b/src/apps/experimental/components/library/LibraryHeaderSection.tsx new file mode 100644 index 00000000000..e11e36ff55e --- /dev/null +++ b/src/apps/experimental/components/library/LibraryHeaderSection.tsx @@ -0,0 +1,252 @@ +import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useCallback } from 'react'; + +import { + Chip, + CircularProgress, + Divider, + MenuItem, + TextField, + Typography, + useScrollTrigger +} from '@mui/material'; +import AppBar from '@mui/material/AppBar'; +import Toolbar from '@mui/material/Toolbar'; +import Stack from '@mui/material/Stack'; + +import globalize from 'scripts/globalize'; +import { useLibrarySettings } from 'hooks/useLibrarySettings'; +import { useGetItemsViewByType } from 'hooks/useFetchItems'; +import { getItemTypesEnum } from 'utils/items'; + +import PlayAllButton from './PlayAllButton'; +import ShuffleButton from './ShuffleButton'; +import SortButton from './SortButton'; +import FilterButton from './filter/FilterButton'; +import NewCollectionButton from './NewCollectionButton'; +import GridListViewButton from './GridListViewButton'; + +import { LibraryTab } from 'types/libraryTab'; +import { CollectionType } from 'types/collectionType'; +import { ParentId } from 'types/library'; + +const visibleBtn = [ + LibraryTab.Movies, + LibraryTab.Favorites, + LibraryTab.Trailers, + LibraryTab.Series, + LibraryTab.Episodes, + LibraryTab.Albums, + LibraryTab.AlbumArtists, + LibraryTab.Artists, + LibraryTab.Collections, + LibraryTab.Songs, + LibraryTab.Photos, + LibraryTab.Videos, + LibraryTab.Channels +]; + +interface LibraryHeaderSectionProps { + collectionType?: CollectionType; + parentId?: ParentId; + item?: BaseItemDto; +} + +const LibraryHeaderSection: FC = () => { + const { + item, + viewSelectOptions, + viewType, + setViewType, + libraryViewSettings, + setLibraryViewSettings + } = useLibrarySettings(); + + const { + isLoading, + data: itemsResult, + isFetching + } = useGetItemsViewByType(viewType, item); + + const handleViewType = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value as LibraryTab; + setViewType(value); + }, + [setViewType] + ); + + const isBtnPlayAllEnabled = + viewType !== LibraryTab.Collections + && viewType !== LibraryTab.Trailers + && viewType !== LibraryTab.AlbumArtists + && viewType !== LibraryTab.Artists + && viewType !== LibraryTab.Photos; + + const isBtnShuffleEnabled = + viewType !== LibraryTab.Collections + && viewType !== LibraryTab.Trailers + && viewType !== LibraryTab.AlbumArtists + && viewType !== LibraryTab.Artists + && viewType !== LibraryTab.Photos; + + const isBtnGridListEnabled = + viewType !== LibraryTab.Songs && viewType !== LibraryTab.Trailers; + + const isBtnSortEnabled = viewType !== LibraryTab.Collections; + + const isBtnFilterEnabled = viewType !== LibraryTab.Collections; + + const isBtnNewCollectionEnabled = viewType === LibraryTab.Collections; + + const totalRecordCount = itemsResult?.TotalRecordCount ?? 0; + const items = itemsResult?.Items ?? []; + const hasFilters = Object.values(libraryViewSettings.Filters ?? {}).some( + (filter) => !!filter + ); + + const trigger = useScrollTrigger({ + disableHysteresis: true, + threshold: 1 + }); + + return ( + + + } + spacing={2} + sx={{ + py: { xs: 1, sm: 0 } + }} + > + + {viewSelectOptions.map((option) => { + return ( + + {globalize.translate(option.title)} + + ); + })} + + + {visibleBtn.includes(viewType) && ( + + ) : ( + + {totalRecordCount} + + ) + } + /> + )} + + + {visibleBtn.includes(viewType) && ( + + } + sx={{ + py: { xs: 1, sm: 0 } + }} + > + {isBtnPlayAllEnabled && ( + + )} + + {isBtnShuffleEnabled && totalRecordCount > 1 && ( + + )} + + {isBtnSortEnabled && ( + + )} + + {isBtnFilterEnabled && ( + + )} + + {isBtnNewCollectionEnabled && } + + {isBtnGridListEnabled && ( + + )} + + )} + + + ); +}; + +export default LibraryHeaderSection; diff --git a/src/apps/experimental/components/library/LibraryMainSection.tsx b/src/apps/experimental/components/library/LibraryMainSection.tsx new file mode 100644 index 00000000000..d1b251a7a2b --- /dev/null +++ b/src/apps/experimental/components/library/LibraryMainSection.tsx @@ -0,0 +1,30 @@ +import React, { FC } from 'react'; + +import { useLibrarySettings } from 'hooks/useLibrarySettings'; +import SuggestionsView from './SuggestionsView'; +import UpComingView from './UpComingView'; +import GenresView from './GenresView'; +import ItemsView from './ItemsView'; + +import { LibraryTab } from 'types/libraryTab'; +import { CollectionType } from 'types/collectionType'; +import { ParentId } from 'types/library'; + +interface LibraryMainSectionProps { + collectionType?: CollectionType; + parentId?: ParentId; +} + +const LibraryMainSection: FC = () => { + const { viewType } = useLibrarySettings(); + + if (viewType === LibraryTab.Suggestions) return ; + + if (viewType === LibraryTab.Upcoming) return ; + + if (viewType === LibraryTab.Genres) return ; + + return ; +}; + +export default LibraryMainSection; diff --git a/src/apps/experimental/components/library/NewCollectionButton.tsx b/src/apps/experimental/components/library/NewCollectionButton.tsx new file mode 100644 index 00000000000..e337de7ddd2 --- /dev/null +++ b/src/apps/experimental/components/library/NewCollectionButton.tsx @@ -0,0 +1,34 @@ +import React, { FC, useCallback } from 'react'; +import { IconButton } from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import globalize from 'scripts/globalize'; + +const NewCollectionButton: FC = () => { + const showCollectionEditor = useCallback(() => { + import('components/collectionEditor/collectionEditor').then( + ({ default: CollectionEditor }) => { + const serverId = window.ApiClient.serverId(); + const collectionEditor = new CollectionEditor(); + collectionEditor.show({ + items: [], + serverId: serverId + }).catch(() => { + // closed collection editor + }); + }).catch(err => { + console.error('[NewCollection] failed to load collection editor', err); + }); + }, []); + + return ( + + + + ); +}; + +export default NewCollectionButton; diff --git a/src/apps/experimental/components/library/Pagination.tsx b/src/apps/experimental/components/library/Pagination.tsx new file mode 100644 index 00000000000..1934dd9014d --- /dev/null +++ b/src/apps/experimental/components/library/Pagination.tsx @@ -0,0 +1,83 @@ +import React, { FC, useCallback } from 'react'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import Box from '@mui/material/Box'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import IconButton from '@mui/material/IconButton'; + +import globalize from 'scripts/globalize'; +import * as userSettings from 'scripts/settings/userSettings'; +import { LibraryViewSettings } from 'types/library'; + +interface PaginationProps { + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; + totalRecordCount: number; + isPreviousData: boolean +} + +const Pagination: FC = ({ + libraryViewSettings, + setLibraryViewSettings, + totalRecordCount, + isPreviousData +}) => { + const limit = userSettings.libraryPageSize(undefined); + const startIndex = libraryViewSettings.StartIndex || 0; + const recordsStart = totalRecordCount ? startIndex + 1 : 0; + const recordsEnd = Math.min(startIndex + limit, totalRecordCount); + + const onNextPageClick = useCallback(() => { + const newIndex = startIndex + limit; + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: newIndex + })); + }, [limit, setLibraryViewSettings, startIndex]); + + const onPreviousPageClick = useCallback(() => { + const newIndex = Math.max(0, startIndex - limit); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: newIndex + })); + }, [limit, setLibraryViewSettings, startIndex]); + + return ( + + + {globalize.translate( + 'ListPaging', + recordsStart, + recordsEnd, + totalRecordCount + )} + + + + + + + + = totalRecordCount || isPreviousData } + onClick={onNextPageClick} + > + + + + + ); +}; + +export default Pagination; diff --git a/src/apps/experimental/components/library/PlayAllButton.tsx b/src/apps/experimental/components/library/PlayAllButton.tsx new file mode 100644 index 00000000000..d7fb0903807 --- /dev/null +++ b/src/apps/experimental/components/library/PlayAllButton.tsx @@ -0,0 +1,57 @@ +import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useCallback } from 'react'; +import { IconButton } from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; + +import { playbackManager } from 'components/playback/playbackmanager'; +import globalize from 'scripts/globalize'; +import { getFiltersQuery } from 'utils/items'; +import { LibraryViewSettings } from 'types/library'; +import { LibraryTab } from 'types/libraryTab'; + +interface PlayAllButtonProps { + item: BaseItemDto | undefined; + items: BaseItemDto[]; + viewType: LibraryTab; + hasFilters: boolean; + libraryViewSettings: LibraryViewSettings +} + +const PlayAllButton: FC = ({ item, items, viewType, hasFilters, libraryViewSettings }) => { + const play = useCallback(() => { + if (item && !hasFilters) { + playbackManager.play({ + items: [item], + autoplay: true, + queryOptions: { + SortBy: [libraryViewSettings.SortBy], + SortOrder: [libraryViewSettings.SortOrder] + } + }); + } else { + playbackManager.play({ + items: items, + autoplay: true, + queryOptions: { + ParentId: item?.Id ?? undefined, + ...getFiltersQuery(viewType, libraryViewSettings), + SortBy: [libraryViewSettings.SortBy], + SortOrder: [libraryViewSettings.SortOrder] + } + + }); + } + }, [hasFilters, item, items, libraryViewSettings, viewType]); + + return ( + + + + ); +}; + +export default PlayAllButton; diff --git a/src/apps/experimental/components/library/QueueButton.tsx b/src/apps/experimental/components/library/QueueButton.tsx new file mode 100644 index 00000000000..fdc6a7666b2 --- /dev/null +++ b/src/apps/experimental/components/library/QueueButton.tsx @@ -0,0 +1,39 @@ +import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useCallback } from 'react'; +import { IconButton } from '@mui/material'; +import QueueIcon from '@mui/icons-material/Queue'; + +import { playbackManager } from 'components/playback/playbackmanager'; +import globalize from 'scripts/globalize'; + +interface QueueButtonProps { + item: BaseItemDto | undefined + items: BaseItemDto[]; + hasFilters: boolean; +} + +const QueueButton: FC = ({ item, items, hasFilters }) => { + const queue = useCallback(() => { + if (item && !hasFilters) { + playbackManager.queue({ + items: [item] + }); + } else { + playbackManager.queue({ + items: items + }); + } + }, [hasFilters, item, items]); + + return ( + + + + ); +}; + +export default QueueButton; diff --git a/src/apps/experimental/components/library/RecommendationContainer.tsx b/src/apps/experimental/components/library/RecommendationContainer.tsx index 4ec8102f57a..6c8ab5cc53e 100644 --- a/src/apps/experimental/components/library/RecommendationContainer.tsx +++ b/src/apps/experimental/components/library/RecommendationContainer.tsx @@ -49,7 +49,7 @@ const RecommendationContainer: FC = ({ return ( = ({ parentId }) => { +const RecommendationItemsContainer: FC = ({ + parentId +}) => { const { isLoading, data: movieRecommendationsItems } = useGetMovieRecommendations(parentId); - if (isLoading) { - return ; - } + if (isLoading) return ; return ( <> - - {!movieRecommendationsItems?.length ? (

{globalize.translate('MessageNothingHere')}

@@ -49,4 +45,4 @@ const SuggestionsView: FC = ({ parentId }) => { ); }; -export default SuggestionsView; +export default RecommendationItemsContainer; diff --git a/src/apps/experimental/components/library/ShuffleButton.tsx b/src/apps/experimental/components/library/ShuffleButton.tsx new file mode 100644 index 00000000000..c81ee4c4bac --- /dev/null +++ b/src/apps/experimental/components/library/ShuffleButton.tsx @@ -0,0 +1,49 @@ +import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; +import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by'; +import React, { FC, useCallback } from 'react'; +import { IconButton } from '@mui/material'; +import ShuffleIcon from '@mui/icons-material/Shuffle'; + +import { playbackManager } from 'components/playback/playbackmanager'; +import globalize from 'scripts/globalize'; +import { getFiltersQuery } from 'utils/items'; +import { LibraryViewSettings } from 'types/library'; +import { LibraryTab } from 'types/libraryTab'; + +interface ShuffleButtonProps { + item: BaseItemDto | undefined; + items: BaseItemDto[]; + viewType: LibraryTab + hasFilters: boolean; + libraryViewSettings: LibraryViewSettings +} + +const ShuffleButton: FC = ({ item, items, viewType, hasFilters, libraryViewSettings }) => { + const shuffle = useCallback(() => { + if (item && !hasFilters) { + playbackManager.shuffle(item); + } else { + playbackManager.play({ + items: items, + autoplay: true, + queryOptions: { + ParentId: item?.Id ?? undefined, + ...getFiltersQuery(viewType, libraryViewSettings), + SortBy: [ItemSortBy.Random] + } + }); + } + }, [hasFilters, item, items, libraryViewSettings, viewType]); + + return ( + + + + ); +}; + +export default ShuffleButton; diff --git a/src/apps/experimental/components/library/SortButton.tsx b/src/apps/experimental/components/library/SortButton.tsx index 7deeae349b0..2c7425f0dea 100644 --- a/src/apps/experimental/components/library/SortButton.tsx +++ b/src/apps/experimental/components/library/SortButton.tsx @@ -98,7 +98,7 @@ const SortButton: FC = ({ title={globalize.translate('Sort')} sx={{ ml: 2 }} aria-describedby={id} - className='paper-icon-button-light btnShuffle autoSize' + className='paper-icon-button-light btnSort autoSize' onClick={handleClick} > diff --git a/src/apps/experimental/components/library/SuggestionsItemsContainer.tsx b/src/apps/experimental/components/library/SuggestionsItemsContainer.tsx index d985592a8ef..dcd5ff91899 100644 --- a/src/apps/experimental/components/library/SuggestionsItemsContainer.tsx +++ b/src/apps/experimental/components/library/SuggestionsItemsContainer.tsx @@ -5,6 +5,7 @@ import React, { FC } from 'react'; import * as userSettings from 'scripts/settings/userSettings'; import SuggestionsSectionContainer from './SuggestionsSectionContainer'; import { Sections, SectionsView, SectionsViewType } from 'types/suggestionsSections'; +import { ParentId } from 'types/library'; const getSuggestionsSections = (): Sections[] => { return [ @@ -178,7 +179,7 @@ const getSuggestionsSections = (): Sections[] => { }; interface SuggestionsItemsContainerProps { - parentId?: string | null; + parentId: ParentId; sectionsView: SectionsView[]; } diff --git a/src/apps/experimental/components/library/SuggestionsSectionContainer.tsx b/src/apps/experimental/components/library/SuggestionsSectionContainer.tsx index 4c52d712e13..7c09bf489f3 100644 --- a/src/apps/experimental/components/library/SuggestionsSectionContainer.tsx +++ b/src/apps/experimental/components/library/SuggestionsSectionContainer.tsx @@ -7,9 +7,10 @@ import { appRouter } from 'components/router/appRouter'; import SectionContainer from './SectionContainer'; import { Sections } from 'types/suggestionsSections'; +import { ParentId } from 'types/library'; interface SuggestionsSectionContainerProps { - parentId?: string | null; + parentId: ParentId; section: Sections; } @@ -37,7 +38,7 @@ const SuggestionsSectionContainer: FC = ({ return ( { + const visibleSuggestionsId: SectionsView[] = []; + const visibleFavoriteId: SectionsView[] = []; + + if (collectionType === CollectionType.Movies) { + visibleSuggestionsId.push(SectionsView.ContinueWatchingMovies); + visibleSuggestionsId.push(SectionsView.LatestMovies); + visibleFavoriteId.push(SectionsView.FavoriteMovies); + } + + if (collectionType === CollectionType.TvShows) { + visibleSuggestionsId.push(SectionsView.ContinueWatchingEpisode); + visibleSuggestionsId.push(SectionsView.LatestEpisode); + visibleSuggestionsId.push(SectionsView.NextUp); + visibleFavoriteId.push(SectionsView.FavoriteShows); + visibleFavoriteId.push(SectionsView.FavoriteEpisode); + } + + if (collectionType === CollectionType.Music) { + visibleSuggestionsId.push(SectionsView.LatestMusic); + visibleSuggestionsId.push(SectionsView.FrequentlyPlayedMusic); + visibleSuggestionsId.push(SectionsView.RecentlyPlayedMusic); + visibleFavoriteId.push(SectionsView.FavoriteArtists); + visibleFavoriteId.push(SectionsView.FavoriteAlbums); + visibleFavoriteId.push(SectionsView.FavoriteSongs); + } + + return { + visibleSuggestionsId, + visibleFavoriteId + }; +}; + +interface SuggestionsViewProps { + collectionType?: CollectionType; + parentId?: ParentId; +} + +const SuggestionsView: FC = () => { + const { item } = useLibrarySettings(); + return ( + + + + + + {item?.CollectionType === CollectionType.Movies && ( + + )} + + ); +}; + +export default SuggestionsView; diff --git a/src/apps/experimental/components/library/UpComingView.tsx b/src/apps/experimental/components/library/UpComingView.tsx new file mode 100644 index 00000000000..51e54af58cd --- /dev/null +++ b/src/apps/experimental/components/library/UpComingView.tsx @@ -0,0 +1,55 @@ +import React, { FC } from 'react'; +import Box from '@mui/material/Box'; +import { useGetGroupsUpcomingEpisodes } from 'hooks/useFetchItems'; +import Loading from 'components/loading/LoadingComponent'; +import globalize from 'scripts/globalize'; +import SectionContainer from './SectionContainer'; +import { ParentId } from 'types/library'; +import { useLibrarySettings } from 'hooks/useLibrarySettings'; + +interface UpComingViewProps { + parentId?: ParentId; +} + +const UpComingView: FC = () => { + const { item } = useLibrarySettings(); + const { isLoading, data: groupsUpcomingEpisodes } = + useGetGroupsUpcomingEpisodes(item); + + if (isLoading) return ; + + return ( + + {!groupsUpcomingEpisodes?.length ? ( +
+

{globalize.translate('MessageNothingHere')}

+

+ {globalize.translate( + 'MessagePleaseEnsureInternetMetadata' + )} +

+
+ ) : ( + groupsUpcomingEpisodes?.map((group) => ( + + )) + )} +
+ ); +}; + +export default UpComingView; diff --git a/src/apps/experimental/components/library/ViewSettingsButton.tsx b/src/apps/experimental/components/library/ViewSettingsButton.tsx index cec5090accb..ba21218491f 100644 --- a/src/apps/experimental/components/library/ViewSettingsButton.tsx +++ b/src/apps/experimental/components/library/ViewSettingsButton.tsx @@ -16,7 +16,7 @@ import Popover from '@mui/material/Popover'; import ViewComfyIcon from '@mui/icons-material/ViewComfy'; import globalize from 'scripts/globalize'; -import { LibraryViewSettings, ViewMode } from 'types/library'; +import { LibraryViewSettings } from 'types/library'; import { LibraryTab } from 'types/libraryTab'; const imageTypesOptions = [ @@ -30,7 +30,9 @@ const imageTypesOptions = [ interface ViewSettingsButtonProps { viewType: LibraryTab; libraryViewSettings: LibraryViewSettings; - setLibraryViewSettings: React.Dispatch>; + setLibraryViewSettings: React.Dispatch< + React.SetStateAction + >; } const ViewSettingsButton: FC = ({ @@ -72,27 +74,12 @@ const ViewSettingsButton: FC = ({ [setLibraryViewSettings] ); - const getVisibleImageType = () => { - const visibleImageType: ImageType[] = [ImageType.Primary]; - - if ( - viewType !== LibraryTab.Episodes - && viewType !== LibraryTab.Artists - && viewType !== LibraryTab.AlbumArtists - && viewType !== LibraryTab.Albums - ) { - visibleImageType.push(ImageType.Banner); - visibleImageType.push(ImageType.Disc); - visibleImageType.push(ImageType.Logo); - visibleImageType.push(ImageType.Thumb); - } - - return visibleImageType; - }; - - const isViewSettingsEnabled = () => { - return libraryViewSettings.ViewMode !== ViewMode.ListView; - }; + const isViewSettingsEnabled = + viewType !== LibraryTab.Episodes + && viewType !== LibraryTab.Songs + && viewType !== LibraryTab.Artists + && viewType !== LibraryTab.AlbumArtists + && viewType !== LibraryTab.Albums; return ( @@ -100,7 +87,7 @@ const ViewSettingsButton: FC = ({ title={globalize.translate('ButtonSelectView')} sx={{ ml: 2 }} aria-describedby={id} - className='paper-icon-button-light btnShuffle autoSize' + className='paper-icon-button-light btnSelectView autoSize' onClick={handleClick} > @@ -122,74 +109,77 @@ const ViewSettingsButton: FC = ({ '& .MuiFormControl-root': { m: 1, width: 220 } }} > + {isViewSettingsEnabled && ( + + + + {globalize.translate('LabelImageType')} + + + + + )} + - - - {globalize.translate('LabelImageType')} - - - - - {isViewSettingsEnabled() && ( - <> - - - - + + - + } + label={globalize.translate('ShowTitle')} + /> + {isViewSettingsEnabled && ( - + } + label={globalize.translate('ShowYear')} + />)} + - - - - )} + } + label={globalize.translate( + 'EnableCardLayout' + )} + /> + + ); diff --git a/src/apps/experimental/components/library/filter/FilterButton.tsx b/src/apps/experimental/components/library/filter/FilterButton.tsx index fd0e54d545b..22a9788f0a8 100644 --- a/src/apps/experimental/components/library/filter/FilterButton.tsx +++ b/src/apps/experimental/components/library/filter/FilterButton.tsx @@ -28,7 +28,7 @@ import FiltersTags from './FiltersTags'; import FiltersVideoTypes from './FiltersVideoTypes'; import FiltersYears from './FiltersYears'; -import { LibraryViewSettings } from 'types/library'; +import { LibraryViewSettings, ParentId } from 'types/library'; import { LibraryTab } from 'types/libraryTab'; const Accordion = styled((props: AccordionProps) => ( @@ -73,9 +73,10 @@ const AccordionDetails = styled(MuiAccordionDetails)(({ theme }) => ({ })); interface FilterButtonProps { - parentId: string | null | undefined; - itemType: BaseItemKind; + parentId: ParentId; + itemType: BaseItemKind[]; viewType: LibraryTab; + hasFilters: boolean; libraryViewSettings: LibraryViewSettings; setLibraryViewSettings: React.Dispatch< React.SetStateAction @@ -86,6 +87,7 @@ const FilterButton: FC = ({ parentId, itemType, viewType, + hasFilters, libraryViewSettings, setLibraryViewSettings }) => { @@ -153,16 +155,13 @@ const FilterButton: FC = ({ return viewType === LibraryTab.Episodes; }; - const hasFilters = - Object.values(libraryViewSettings.Filters || {}).some((filter) => !!filter); - return ( diff --git a/src/apps/experimental/components/tabs/tabRoutes.ts b/src/apps/experimental/components/tabs/tabRoutes.ts index 32c3b8eb448..831bf60d0a3 100644 --- a/src/apps/experimental/components/tabs/tabRoutes.ts +++ b/src/apps/experimental/components/tabs/tabRoutes.ts @@ -1,4 +1,3 @@ -import globalize from 'scripts/globalize'; import * as userSettings from 'scripts/settings/userSettings'; import { LibraryTab } from 'types/libraryTab'; @@ -36,155 +35,7 @@ export const getDefaultTabIndex = (path: string, libraryId?: string | null) => { }; const TabRoutes: TabRoute[] = [ - { - path: '/livetv.html', - tabs: [ - { - index: 0, - label: globalize.translate('Programs'), - value: LibraryTab.Programs, - isDefault: true - }, - { - index: 1, - label: globalize.translate('Guide'), - value: LibraryTab.Guide - }, - { - index: 2, - label: globalize.translate('Channels'), - value: LibraryTab.Channels - }, - { - index: 3, - label: globalize.translate('Recordings'), - value: LibraryTab.Recordings - }, - { - index: 4, - label: globalize.translate('Schedule'), - value: LibraryTab.Schedule - }, - { - index: 5, - label: globalize.translate('Series'), - value: LibraryTab.Series - } - ] - }, - { - path: '/movies.html', - tabs: [ - { - index: 0, - label: globalize.translate('Movies'), - value: LibraryTab.Movies, - isDefault: true - }, - { - index: 1, - label: globalize.translate('Suggestions'), - value: LibraryTab.Suggestions - }, - { - index: 2, - label: globalize.translate('Trailers'), - value: LibraryTab.Trailers - }, - { - index: 3, - label: globalize.translate('Favorites'), - value: LibraryTab.Favorites - }, - { - index: 4, - label: globalize.translate('Collections'), - value: LibraryTab.Collections - }, - { - index: 5, - label: globalize.translate('Genres'), - value: LibraryTab.Genres - } - ] - }, - { - path: '/music.html', - tabs: [ - { - index: 0, - label: globalize.translate('Albums'), - value: LibraryTab.Albums, - isDefault: true - }, - { - index: 1, - label: globalize.translate('Suggestions'), - value: LibraryTab.Suggestions - }, - { - index: 2, - label: globalize.translate('HeaderAlbumArtists'), - value: LibraryTab.AlbumArtists - }, - { - index: 3, - label: globalize.translate('Artists'), - value: LibraryTab.Artists - }, - { - index: 4, - label: globalize.translate('Playlists'), - value: LibraryTab.Playlists - }, - { - index: 5, - label: globalize.translate('Songs'), - value: LibraryTab.Songs - }, - { - index: 6, - label: globalize.translate('Genres'), - value: LibraryTab.Genres - } - ] - }, - { - path: '/tv.html', - tabs: [ - { - index: 0, - label: globalize.translate('Shows'), - value: LibraryTab.Shows, - isDefault: true - }, - { - index: 1, - label: globalize.translate('Suggestions'), - value: LibraryTab.Suggestions - }, - { - index: 2, - label: globalize.translate('TabUpcoming'), - value: LibraryTab.Upcoming - }, - { - index: 3, - label: globalize.translate('Genres'), - value: LibraryTab.Genres - }, - { - index: 4, - label: globalize.translate('TabNetworks'), - value: LibraryTab.Networks - }, - { - index: 5, - label: globalize.translate('Episodes'), - value: LibraryTab.Episodes - } - ] - } + ]; export default TabRoutes; diff --git a/src/apps/experimental/routes/asyncRoutes/user.ts b/src/apps/experimental/routes/asyncRoutes/user.ts index 023e292edbe..de86b6fce53 100644 --- a/src/apps/experimental/routes/asyncRoutes/user.ts +++ b/src/apps/experimental/routes/asyncRoutes/user.ts @@ -5,5 +5,10 @@ export const ASYNC_USER_ROUTES: AsyncRoute[] = [ { path: 'search.html', page: 'search' }, { path: 'userprofile.html', page: 'user/userprofile' }, { path: 'home.html', page: 'home', type: AsyncRouteType.Experimental }, - { path: 'movies.html', page: 'movies', type: AsyncRouteType.Experimental } + { path: 'movies', page: 'library', type: AsyncRouteType.Experimental }, + { path: 'tv', page: 'library', type: AsyncRouteType.Experimental }, + { path: 'music', page: 'library', type: AsyncRouteType.Experimental }, + { path: 'books', page: 'library', type: AsyncRouteType.Experimental }, + { path: 'livetv', page: 'library', type: AsyncRouteType.Experimental }, + { path: 'homevideos', page: 'library', type: AsyncRouteType.Experimental } ]; diff --git a/src/apps/experimental/routes/home.tsx b/src/apps/experimental/routes/home.tsx index 3ab9c8bed7b..837db942b13 100644 --- a/src/apps/experimental/routes/home.tsx +++ b/src/apps/experimental/routes/home.tsx @@ -27,7 +27,7 @@ type ControllerProps = { const Home: FunctionComponent = () => { const [ searchParams ] = useSearchParams(); - const initialTabIndex = parseInt(searchParams.get('tab') || '0', 10); + const initialTabIndex = parseInt(searchParams.get('tab') ?? '0', 10); const tabController = useRef(); const tabControllers = useMemo(() => [], []); diff --git a/src/apps/experimental/routes/legacyRoutes/user.ts b/src/apps/experimental/routes/legacyRoutes/user.ts index 965767d915c..4e282800848 100644 --- a/src/apps/experimental/routes/legacyRoutes/user.ts +++ b/src/apps/experimental/routes/legacyRoutes/user.ts @@ -19,12 +19,6 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [ controller: 'livetv/livetvsuggested', view: 'livetv.html' } - }, { - path: 'music.html', - pageProps: { - controller: 'music/musicrecommended', - view: 'music/music.html' - } }, { path: 'mypreferencesmenu.html', pageProps: { @@ -61,12 +55,6 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [ controller: 'user/subtitles/index', view: 'user/subtitles/index.html' } - }, { - path: 'tv.html', - pageProps: { - controller: 'shows/tvrecommended', - view: 'shows/tvrecommended.html' - } }, { path: 'video', pageProps: { diff --git a/src/apps/experimental/routes/library/index.tsx b/src/apps/experimental/routes/library/index.tsx new file mode 100644 index 00000000000..016d5fa9500 --- /dev/null +++ b/src/apps/experimental/routes/library/index.tsx @@ -0,0 +1,35 @@ +import React, { FC } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import * as userSettings from 'scripts/settings/userSettings'; +import Loading from 'components/loading/LoadingComponent'; +import { useGetItem } from 'hooks/useFetchItems'; +import { LibrarySettingsProvider } from 'hooks/useLibrarySettings'; +import LibraryHeaderSection from 'apps/experimental/components/library/LibraryHeaderSection'; +import LibraryMainSection from 'apps/experimental/components/library/LibraryMainSection'; + +import { LibraryTab } from 'types/libraryTab'; + +const Library: FC = () => { + const [searchParams] = useSearchParams(); + const parentId = searchParams.get('topParentId'); + const defaultView = userSettings.get('landing-' + parentId, false) as LibraryTab; + const { isLoading, data: item } = useGetItem(parentId); + + if (isLoading) return ; + + return ( + + + + + + + + ); +}; + +export default Library; diff --git a/src/apps/experimental/routes/movies/CollectionsView.tsx b/src/apps/experimental/routes/movies/CollectionsView.tsx deleted file mode 100644 index ef574b916e5..00000000000 --- a/src/apps/experimental/routes/movies/CollectionsView.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React, { FC, useCallback } from 'react'; - -import ViewItemsContainer from 'components/common/ViewItemsContainer'; -import { LibraryViewProps } from 'types/library'; - -const CollectionsView: FC = ({ parentId }) => { - const getBasekey = useCallback(() => { - return 'collections'; - }, []); - - const getItemTypes = useCallback(() => { - return ['BoxSet']; - }, []); - - const getNoItemsMessage = useCallback(() => { - return 'MessageNoCollectionsAvailable'; - }, []); - - return ( - - ); -}; - -export default CollectionsView; diff --git a/src/apps/experimental/routes/movies/FavoritesView.tsx b/src/apps/experimental/routes/movies/FavoritesView.tsx deleted file mode 100644 index d22cad6e385..00000000000 --- a/src/apps/experimental/routes/movies/FavoritesView.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React, { FC, useCallback } from 'react'; - -import ViewItemsContainer from 'components/common/ViewItemsContainer'; -import { LibraryViewProps } from 'types/library'; - -const FavoritesView: FC = ({ parentId }) => { - const getBasekey = useCallback(() => { - return 'favorites'; - }, []); - - const getItemTypes = useCallback(() => { - return ['Movie']; - }, []); - - const getNoItemsMessage = useCallback(() => { - return 'MessageNoFavoritesAvailable'; - }, []); - - return ( - - ); -}; - -export default FavoritesView; diff --git a/src/apps/experimental/routes/movies/GenresView.tsx b/src/apps/experimental/routes/movies/GenresView.tsx deleted file mode 100644 index 05e7e216f49..00000000000 --- a/src/apps/experimental/routes/movies/GenresView.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; -import React, { FC } from 'react'; -import GenresItemsContainer from '../../components/library/GenresItemsContainer'; -import { LibraryViewProps } from 'types/library'; -import { CollectionType } from 'types/collectionType'; - -const GenresView: FC = ({ parentId }) => { - return ( - - ); -}; - -export default GenresView; diff --git a/src/apps/experimental/routes/movies/MoviesView.tsx b/src/apps/experimental/routes/movies/MoviesView.tsx deleted file mode 100644 index 8796c9a7111..00000000000 --- a/src/apps/experimental/routes/movies/MoviesView.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React, { FC, useCallback } from 'react'; - -import ViewItemsContainer from 'components/common/ViewItemsContainer'; -import { LibraryViewProps } from 'types/library'; - -const MoviesView: FC = ({ parentId }) => { - const getBasekey = useCallback(() => { - return 'movies'; - }, []); - - const getItemTypes = useCallback(() => { - return ['Movie']; - }, []); - - const getNoItemsMessage = useCallback(() => { - return 'MessageNoItemsAvailable'; - }, []); - - return ( - - ); -}; - -export default MoviesView; diff --git a/src/apps/experimental/routes/movies/TrailersView.tsx b/src/apps/experimental/routes/movies/TrailersView.tsx deleted file mode 100644 index ff0ff0e73e1..00000000000 --- a/src/apps/experimental/routes/movies/TrailersView.tsx +++ /dev/null @@ -1,30 +0,0 @@ - -import React, { FC, useCallback } from 'react'; - -import ViewItemsContainer from 'components/common/ViewItemsContainer'; -import { LibraryViewProps } from 'types/library'; - -const TrailersView: FC = ({ parentId }) => { - const getBasekey = useCallback(() => { - return 'trailers'; - }, []); - - const getItemTypes = useCallback(() => { - return ['Trailer']; - }, []); - - const getNoItemsMessage = useCallback(() => { - return 'MessageNoTrailersFound'; - }, []); - - return ( - - ); -}; - -export default TrailersView; diff --git a/src/apps/experimental/routes/movies/index.tsx b/src/apps/experimental/routes/movies/index.tsx deleted file mode 100644 index e1a30dfb593..00000000000 --- a/src/apps/experimental/routes/movies/index.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import 'elements/emby-scroller/emby-scroller'; -import 'elements/emby-itemscontainer/emby-itemscontainer'; -import 'elements/emby-tabs/emby-tabs'; -import 'elements/emby-button/emby-button'; - -import React, { FC } from 'react'; -import { useLocation, useSearchParams } from 'react-router-dom'; -import Page from 'components/Page'; - -import { getDefaultTabIndex } from '../../components/tabs/tabRoutes'; -import CollectionsView from './CollectionsView'; -import FavoritesView from './FavoritesView'; -import GenresView from './GenresView'; -import MoviesView from './MoviesView'; -import SuggestionsView from './SuggestionsView'; -import TrailersView from './TrailersView'; - -const Movies: FC = () => { - const location = useLocation(); - const [ searchParams ] = useSearchParams(); - const searchParamsParentId = searchParams.get('topParentId'); - const searchParamsTab = searchParams.get('tab'); - const currentTabIndex = searchParamsTab !== null ? parseInt(searchParamsTab, 10) : - getDefaultTabIndex(location.pathname, searchParamsParentId); - - const getTabComponent = (index: number) => { - if (index == null) { - throw new Error('index cannot be null'); - } - - let component; - switch (index) { - case 1: - component = ; - break; - - case 2: - component = ; - break; - - case 3: - component = ; - break; - - case 4: - component = ; - break; - - case 5: - component = ; - break; - default: - component = ; - } - - return component; - }; - - return ( - - {getTabComponent(currentTabIndex)} - - - ); -}; - -export default Movies; diff --git a/src/components/common/AlphaPickerContainer.tsx b/src/components/common/AlphaPickerContainer.tsx deleted file mode 100644 index 6b7c9a07183..00000000000 --- a/src/components/common/AlphaPickerContainer.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; -import AlphaPicker from '../alphaPicker/alphaPicker'; -import { ViewQuerySettings } from '../../types/interface'; - -interface AlphaPickerContainerProps { - viewQuerySettings: ViewQuerySettings; - setViewQuerySettings: React.Dispatch>; -} - -const AlphaPickerContainer: FC = ({ viewQuerySettings, setViewQuerySettings }) => { - const [ alphaPicker, setAlphaPicker ] = useState(); - const element = useRef(null); - - alphaPicker?.updateControls(viewQuerySettings); - - const onAlphaPickerChange = useCallback((e) => { - const newValue = (e as CustomEvent).detail.value; - let updatedValue: React.SetStateAction; - if (newValue === '#') { - updatedValue = { - NameLessThan: 'A', - NameStartsWith: undefined - }; - } else { - updatedValue = { - NameLessThan: undefined, - NameStartsWith: newValue - }; - } - setViewQuerySettings((prevState) => ({ - ...prevState, - StartIndex: 0, - ...updatedValue - })); - }, [setViewQuerySettings]); - - useEffect(() => { - const alphaPickerElement = element.current; - - setAlphaPicker(new AlphaPicker({ - element: alphaPickerElement, - valueChangeEvent: 'click' - })); - - if (alphaPickerElement) { - alphaPickerElement.addEventListener('alphavaluechanged', onAlphaPickerChange); - } - - return () => { - alphaPickerElement?.removeEventListener('alphavaluechanged', onAlphaPickerChange); - }; - }, [onAlphaPickerChange]); - - return ( -
- ); -}; - -export default AlphaPickerContainer; diff --git a/src/components/common/Filter.tsx b/src/components/common/Filter.tsx deleted file mode 100644 index c3ccdd62f3a..00000000000 --- a/src/components/common/Filter.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React, { FC, useCallback, useEffect, useRef } from 'react'; -import IconButtonElement from '../../elements/IconButtonElement'; -import { ViewQuerySettings } from '../../types/interface'; - -interface FilterProps { - topParentId?: string | null; - getItemTypes: () => string[]; - getFilterMenuOptions: () => Record; - getVisibleFilters: () => string[]; - viewQuerySettings: ViewQuerySettings; - setViewQuerySettings: React.Dispatch>; -} - -const Filter: FC = ({ - topParentId, - getItemTypes, - getVisibleFilters, - getFilterMenuOptions, - viewQuerySettings, - setViewQuerySettings -}) => { - const element = useRef(null); - - const showFilterMenu = useCallback(() => { - import('../filtermenu/filtermenu').then(({ default: FilterMenu }) => { - const filterMenu = new FilterMenu(); - filterMenu.show({ - settings: viewQuerySettings, - visibleSettings: getVisibleFilters(), - parentId: topParentId, - itemTypes: getItemTypes(), - serverId: window.ApiClient.serverId(), - filterMenuOptions: getFilterMenuOptions(), - setfilters: setViewQuerySettings - }).catch(() => { - // filter menu closed - }); - }).catch(err => { - console.error('[Filter] failed to load filter menu', err); - }); - }, [viewQuerySettings, getVisibleFilters, topParentId, getItemTypes, getFilterMenuOptions, setViewQuerySettings]); - - useEffect(() => { - const btnFilter = element.current?.querySelector('.btnFilter'); - - btnFilter?.addEventListener('click', showFilterMenu); - - return () => { - btnFilter?.removeEventListener('click', showFilterMenu); - }; - }, [showFilterMenu]); - - return ( -
- -
- ); -}; - -export default Filter; diff --git a/src/components/common/ItemsContainer.tsx b/src/components/common/ItemsContainer.tsx deleted file mode 100644 index 6289c1d8113..00000000000 --- a/src/components/common/ItemsContainer.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React, { FC, useEffect, useRef } from 'react'; - -import ItemsContainerElement from '../../elements/ItemsContainerElement'; -import imageLoader from '../images/imageLoader'; -import '../../elements/emby-itemscontainer/emby-itemscontainer'; -import { ViewQuerySettings } from '../../types/interface'; - -interface ItemsContainerI { - viewQuerySettings: ViewQuerySettings; - getItemsHtml: () => string -} - -const ItemsContainer: FC = ({ viewQuerySettings, getItemsHtml }) => { - const element = useRef(null); - - useEffect(() => { - const itemsContainer = element.current?.querySelector('.itemsContainer') as HTMLDivElement; - itemsContainer.innerHTML = getItemsHtml(); - imageLoader.lazyChildren(itemsContainer); - }, [getItemsHtml]); - - const cssClass = viewQuerySettings.imageType == 'list' ? 'vertical-list' : 'vertical-wrap'; - - return ( -
- -
- ); -}; - -export default ItemsContainer; diff --git a/src/components/common/NewCollection.tsx b/src/components/common/NewCollection.tsx deleted file mode 100644 index 837fe85fd3f..00000000000 --- a/src/components/common/NewCollection.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React, { FC, useCallback, useEffect, useRef } from 'react'; - -import IconButtonElement from '../../elements/IconButtonElement'; - -const NewCollection: FC = () => { - const element = useRef(null); - - const showCollectionEditor = useCallback(() => { - import('../collectionEditor/collectionEditor').then(({ default: CollectionEditor }) => { - const serverId = window.ApiClient.serverId(); - const collectionEditor = new CollectionEditor(); - collectionEditor.show({ - items: [], - serverId: serverId - }).catch(() => { - // closed collection editor - }); - }).catch(err => { - console.error('[NewCollection] failed to load collection editor', err); - }); - }, []); - - useEffect(() => { - const btnNewCollection = element.current?.querySelector('.btnNewCollection'); - if (btnNewCollection) { - btnNewCollection.addEventListener('click', showCollectionEditor); - } - }, [showCollectionEditor]); - - return ( -
- -
- ); -}; - -export default NewCollection; diff --git a/src/components/common/Pagination.tsx b/src/components/common/Pagination.tsx deleted file mode 100644 index 3dd5a60ffd3..00000000000 --- a/src/components/common/Pagination.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'; -import React, { FC, useCallback, useEffect, useRef } from 'react'; -import IconButtonElement from '../../elements/IconButtonElement'; -import globalize from '../../scripts/globalize'; -import * as userSettings from '../../scripts/settings/userSettings'; -import { ViewQuerySettings } from '../../types/interface'; - -interface PaginationProps { - viewQuerySettings: ViewQuerySettings; - setViewQuerySettings: React.Dispatch>; - itemsResult?: BaseItemDtoQueryResult; -} - -const Pagination: FC = ({ viewQuerySettings, setViewQuerySettings, itemsResult = {} }) => { - const limit = userSettings.libraryPageSize(undefined); - const totalRecordCount = itemsResult.TotalRecordCount || 0; - const startIndex = viewQuerySettings.StartIndex || 0; - const recordsStart = totalRecordCount ? startIndex + 1 : 0; - const recordsEnd = limit ? Math.min(startIndex + limit, totalRecordCount) : totalRecordCount; - const showControls = limit > 0 && limit < totalRecordCount; - const element = useRef(null); - - const onNextPageClick = useCallback(() => { - if (limit > 0) { - const newIndex = startIndex + limit; - setViewQuerySettings((prevState) => ({ - ...prevState, - StartIndex: newIndex - })); - } - }, [limit, setViewQuerySettings, startIndex]); - - const onPreviousPageClick = useCallback(() => { - if (limit > 0) { - const newIndex = Math.max(0, startIndex - limit); - setViewQuerySettings((prevState) => ({ - ...prevState, - StartIndex: newIndex - })); - } - }, [limit, setViewQuerySettings, startIndex]); - - useEffect(() => { - const btnNextPage = element.current?.querySelector('.btnNextPage') as HTMLButtonElement; - if (btnNextPage) { - if (startIndex + limit >= totalRecordCount) { - btnNextPage.disabled = true; - } else { - btnNextPage.disabled = false; - } - btnNextPage.addEventListener('click', onNextPageClick); - } - - const btnPreviousPage = element.current?.querySelector('.btnPreviousPage') as HTMLButtonElement; - if (btnPreviousPage) { - if (startIndex) { - btnPreviousPage.disabled = false; - } else { - btnPreviousPage.disabled = true; - } - btnPreviousPage.addEventListener('click', onPreviousPageClick); - } - - return () => { - btnNextPage?.removeEventListener('click', onNextPageClick); - btnPreviousPage?.removeEventListener('click', onPreviousPageClick); - }; - }, [totalRecordCount, onNextPageClick, onPreviousPageClick, limit, startIndex]); - - return ( -
-
-
- - {globalize.translate('ListPaging', recordsStart, recordsEnd, totalRecordCount)} - - {showControls && ( - <> - - - - )} -
-
-
- ); -}; - -export default Pagination; diff --git a/src/components/common/SelectView.tsx b/src/components/common/SelectView.tsx deleted file mode 100644 index bfb34555b87..00000000000 --- a/src/components/common/SelectView.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React, { FC, useCallback, useEffect, useRef } from 'react'; -import IconButtonElement from '../../elements/IconButtonElement'; -import { ViewQuerySettings } from '../../types/interface'; - -interface SelectViewProps { - getVisibleViewSettings: () => string[]; - viewQuerySettings: ViewQuerySettings; - setViewQuerySettings: React.Dispatch>; -} - -const SelectView: FC = ({ - getVisibleViewSettings, - viewQuerySettings, - setViewQuerySettings -}) => { - const element = useRef(null); - - const showViewSettingsMenu = useCallback(() => { - import('../viewSettings/viewSettings').then(({ default: ViewSettings }) => { - const viewsettings = new ViewSettings(); - viewsettings.show({ - settings: viewQuerySettings, - visibleSettings: getVisibleViewSettings(), - setviewsettings: setViewQuerySettings - }).catch(() => { - // view settings closed - }); - }).catch(err => { - console.error('[SelectView] failed to load view settings', err); - }); - }, [getVisibleViewSettings, viewQuerySettings, setViewQuerySettings]); - - useEffect(() => { - const btnSelectView = element.current?.querySelector('.btnSelectView') as HTMLButtonElement; - btnSelectView?.addEventListener('click', showViewSettingsMenu); - - return () => { - btnSelectView?.removeEventListener('click', showViewSettingsMenu); - }; - }, [showViewSettingsMenu]); - - return ( -
- -
- ); -}; - -export default SelectView; diff --git a/src/components/common/Shuffle.tsx b/src/components/common/Shuffle.tsx deleted file mode 100644 index 093dc748744..00000000000 --- a/src/components/common/Shuffle.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'; -import React, { FC, useCallback, useEffect, useRef } from 'react'; - -import { playbackManager } from '../playback/playbackmanager'; -import IconButtonElement from '../../elements/IconButtonElement'; - -interface ShuffleProps { - itemsResult?: BaseItemDtoQueryResult; - topParentId: string | null; -} - -const Shuffle: FC = ({ itemsResult = {}, topParentId }) => { - const element = useRef(null); - - const shuffle = useCallback(() => { - window.ApiClient.getItem( - window.ApiClient.getCurrentUserId(), - topParentId as string - ).then((item) => { - playbackManager.shuffle(item); - }).catch(err => { - console.error('[Shuffle] failed to fetch items', err); - }); - }, [topParentId]); - - useEffect(() => { - const btnShuffle = element.current?.querySelector('.btnShuffle'); - if (btnShuffle) { - btnShuffle.addEventListener('click', shuffle); - } - }, [itemsResult.TotalRecordCount, shuffle]); - - return ( -
- -
- ); -}; - -export default Shuffle; diff --git a/src/components/common/Sort.tsx b/src/components/common/Sort.tsx deleted file mode 100644 index db5cb899562..00000000000 --- a/src/components/common/Sort.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React, { FC, useCallback, useEffect, useRef } from 'react'; -import IconButtonElement from '../../elements/IconButtonElement'; -import { ViewQuerySettings } from '../../types/interface'; - -interface SortProps { - getSortMenuOptions: () => { - name: string; - value: string; - }[]; - viewQuerySettings: ViewQuerySettings; - setViewQuerySettings: React.Dispatch>; -} - -const Sort: FC = ({ - getSortMenuOptions, - viewQuerySettings, - setViewQuerySettings -}) => { - const element = useRef(null); - - const showSortMenu = useCallback(() => { - import('../sortmenu/sortmenu').then(({ default: SortMenu }) => { - const sortMenu = new SortMenu(); - sortMenu.show({ - settings: viewQuerySettings, - sortOptions: getSortMenuOptions(), - setSortValues: setViewQuerySettings - }).catch(() => { - // sort menu closed - }); - }).catch(err => { - console.error('[Sort] failed to load sort menu', err); - }); - }, [getSortMenuOptions, viewQuerySettings, setViewQuerySettings]); - - useEffect(() => { - const btnSort = element.current?.querySelector('.btnSort'); - - btnSort?.addEventListener('click', showSortMenu); - - return () => { - btnSort?.removeEventListener('click', showSortMenu); - }; - }, [showSortMenu]); - - return ( -
- -
- ); -}; - -export default Sort; diff --git a/src/components/common/ViewItemsContainer.tsx b/src/components/common/ViewItemsContainer.tsx deleted file mode 100644 index 1ea5ef9899d..00000000000 --- a/src/components/common/ViewItemsContainer.tsx +++ /dev/null @@ -1,411 +0,0 @@ -import { type BaseItemDtoQueryResult, ItemFields, ItemFilter } from '@jellyfin/sdk/lib/generated-client'; -import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; - -import loading from '../loading/loading'; -import * as userSettings from '../../scripts/settings/userSettings'; -import AlphaPickerContainer from './AlphaPickerContainer'; -import Filter from './Filter'; -import ItemsContainer from './ItemsContainer'; -import Pagination from './Pagination'; -import SelectView from './SelectView'; -import Shuffle from './Shuffle'; -import Sort from './Sort'; -import NewCollection from './NewCollection'; -import globalize from '../../scripts/globalize'; -import ServerConnections from '../ServerConnections'; -import { useLocalStorage } from '../../hooks/useLocalStorage'; -import listview from '../listview/listview'; -import cardBuilder from '../cardbuilder/cardBuilder'; - -import { ViewQuerySettings } from '../../types/interface'; -import { CardOptions } from '../../types/cardOptions'; - -interface ViewItemsContainerProps { - topParentId: string | null; - isBtnShuffleEnabled?: boolean; - isBtnFilterEnabled?: boolean; - isBtnNewCollectionEnabled?: boolean; - isAlphaPickerEnabled?: boolean; - getBasekey: () => string; - getItemTypes: () => string[]; - getNoItemsMessage: () => string; -} - -const getDefaultSortBy = () => { - return 'SortName'; -}; - -const getFields = (viewQuerySettings: ViewQuerySettings) => { - const fields: ItemFields[] = [ - ItemFields.BasicSyncInfo, - ItemFields.MediaSourceCount - ]; - - if (viewQuerySettings.imageType === 'primary') { - fields.push(ItemFields.PrimaryImageAspectRatio); - } - - return fields.join(','); -}; - -const getFilters = (viewQuerySettings: ViewQuerySettings) => { - const filters: ItemFilter[] = []; - - if (viewQuerySettings.IsPlayed) { - filters.push(ItemFilter.IsPlayed); - } - - if (viewQuerySettings.IsUnplayed) { - filters.push(ItemFilter.IsUnplayed); - } - - if (viewQuerySettings.IsFavorite) { - filters.push(ItemFilter.IsFavorite); - } - - if (viewQuerySettings.IsResumable) { - filters.push(ItemFilter.IsResumable); - } - - return filters; -}; - -const getVisibleViewSettings = () => { - return [ - 'showTitle', - 'showYear', - 'imageType', - 'cardLayout' - ]; -}; - -const getFilterMenuOptions = () => { - return {}; -}; - -const getVisibleFilters = () => { - return [ - 'IsUnplayed', - 'IsPlayed', - 'IsFavorite', - 'IsResumable', - 'VideoType', - 'HasSubtitles', - 'HasTrailer', - 'HasSpecialFeature', - 'HasThemeSong', - 'HasThemeVideo' - ]; -}; - -const getSortMenuOptions = () => { - return [{ - name: globalize.translate('Name'), - value: 'SortName,ProductionYear' - }, { - name: globalize.translate('OptionRandom'), - value: 'Random' - }, { - name: globalize.translate('OptionImdbRating'), - value: 'CommunityRating,SortName,ProductionYear' - }, { - name: globalize.translate('OptionCriticRating'), - value: 'CriticRating,SortName,ProductionYear' - }, { - name: globalize.translate('OptionDateAdded'), - value: 'DateCreated,SortName,ProductionYear' - }, { - name: globalize.translate('OptionDatePlayed'), - value: 'DatePlayed,SortName,ProductionYear' - }, { - name: globalize.translate('OptionParentalRating'), - value: 'OfficialRating,SortName,ProductionYear' - }, { - name: globalize.translate('OptionPlayCount'), - value: 'PlayCount,SortName,ProductionYear' - }, { - name: globalize.translate('OptionReleaseDate'), - value: 'PremiereDate,SortName,ProductionYear' - }, { - name: globalize.translate('Runtime'), - value: 'Runtime,SortName,ProductionYear' - }]; -}; - -const defaultViewQuerySettings: ViewQuerySettings = { - showTitle: true, - showYear: true, - imageType: 'primary', - viewType: '', - cardLayout: false, - SortBy: getDefaultSortBy(), - SortOrder: 'Ascending', - IsPlayed: false, - IsUnplayed: false, - IsFavorite: false, - IsResumable: false, - Is4K: null, - IsHD: null, - IsSD: null, - Is3D: null, - VideoTypes: '', - SeriesStatus: '', - HasSubtitles: null, - HasTrailer: null, - HasSpecialFeature: null, - HasThemeSong: null, - HasThemeVideo: null, - GenreIds: '', - StartIndex: 0 -}; - -const ViewItemsContainer: FC = ({ - topParentId, - isBtnShuffleEnabled = false, - isBtnFilterEnabled = true, - isBtnNewCollectionEnabled = false, - isAlphaPickerEnabled = true, - getBasekey, - getItemTypes, - getNoItemsMessage -}) => { - const getSettingsKey = useCallback(() => { - return `${topParentId} - ${getBasekey()}`; - }, [getBasekey, topParentId]); - - const [isLoading, setisLoading] = useState(false); - - const [viewQuerySettings, setViewQuerySettings] = useLocalStorage( - `viewQuerySettings - ${getSettingsKey()}`, - defaultViewQuerySettings - ); - - const [ itemsResult, setItemsResult ] = useState({}); - - const element = useRef(null); - - const getContext = useCallback(() => { - const itemType = getItemTypes().join(','); - if (itemType === 'Movie' || itemType === 'BoxSet') { - return 'movies'; - } - - return null; - }, [getItemTypes]); - - const getCardOptions = useCallback(() => { - let shape; - let preferThumb; - let preferDisc; - let preferLogo; - - if (viewQuerySettings.imageType === 'banner') { - shape = 'banner'; - } else if (viewQuerySettings.imageType === 'disc') { - shape = 'square'; - preferDisc = true; - } else if (viewQuerySettings.imageType === 'logo') { - shape = 'backdrop'; - preferLogo = true; - } else if (viewQuerySettings.imageType === 'thumb') { - shape = 'backdrop'; - preferThumb = true; - } else { - shape = 'autoVertical'; - } - - const cardOptions: CardOptions = { - shape: shape, - showTitle: viewQuerySettings.showTitle, - showYear: viewQuerySettings.showYear, - cardLayout: viewQuerySettings.cardLayout, - centerText: true, - context: getContext(), - coverImage: true, - preferThumb: preferThumb, - preferDisc: preferDisc, - preferLogo: preferLogo, - overlayPlayButton: false, - overlayMoreButton: true, - overlayText: !viewQuerySettings.showTitle - }; - - cardOptions.items = itemsResult.Items || []; - - return cardOptions; - }, [ - getContext, - itemsResult.Items, - viewQuerySettings.cardLayout, - viewQuerySettings.imageType, - viewQuerySettings.showTitle, - viewQuerySettings.showYear - ]); - - const getItemsHtml = useCallback(() => { - let html = ''; - - if (viewQuerySettings.imageType === 'list') { - html = listview.getListViewHtml({ - items: itemsResult.Items || [], - context: getContext() - }); - } else { - html = cardBuilder.getCardsHtml(itemsResult.Items || [], getCardOptions()); - } - - if (!itemsResult.Items?.length) { - html += '
'; - html += '

' + globalize.translate('MessageNothingHere') + '

'; - html += '

' + globalize.translate(getNoItemsMessage()) + '

'; - html += '
'; - } - - return html; - }, [getCardOptions, getContext, itemsResult.Items, getNoItemsMessage, viewQuerySettings.imageType]); - - const getQuery = useCallback(() => { - const queryFilters = getFilters(viewQuerySettings); - - let queryIsHD; - - if (viewQuerySettings.IsHD) { - queryIsHD = true; - } - - if (viewQuerySettings.IsSD) { - queryIsHD = false; - } - - return { - SortBy: viewQuerySettings.SortBy, - SortOrder: viewQuerySettings.SortOrder, - IncludeItemTypes: getItemTypes().join(','), - Recursive: true, - Fields: getFields(viewQuerySettings), - ImageTypeLimit: 1, - EnableImageTypes: 'Primary,Backdrop,Banner,Thumb,Disc,Logo', - Limit: userSettings.libraryPageSize(undefined) || undefined, - IsFavorite: getBasekey() === 'favorites' ? true : null, - VideoTypes: viewQuerySettings.VideoTypes, - GenreIds: viewQuerySettings.GenreIds, - Is4K: viewQuerySettings.Is4K ? true : null, - IsHD: queryIsHD, - Is3D: viewQuerySettings.Is3D ? true : null, - HasSubtitles: viewQuerySettings.HasSubtitles ? true : null, - HasTrailer: viewQuerySettings.HasTrailer ? true : null, - HasSpecialFeature: viewQuerySettings.HasSpecialFeature ? true : null, - HasThemeSong: viewQuerySettings.HasThemeSong ? true : null, - HasThemeVideo: viewQuerySettings.HasThemeVideo ? true : null, - Filters: queryFilters.length ? queryFilters.join(',') : null, - StartIndex: viewQuerySettings.StartIndex, - NameLessThan: viewQuerySettings.NameLessThan, - NameStartsWith: viewQuerySettings.NameStartsWith, - ParentId: topParentId - }; - }, [ - viewQuerySettings, - getItemTypes, - getBasekey, - topParentId - ]); - - const fetchData = useCallback(() => { - loading.show(); - - const apiClient = ServerConnections.getApiClient(window.ApiClient.serverId()); - return apiClient.getItems( - apiClient.getCurrentUserId(), - { - ...getQuery() - } - ); - }, [getQuery]); - - const reloadItems = useCallback(() => { - const page = element.current; - - if (!page) { - console.error('Unexpected null reference'); - return; - } - setisLoading(false); - fetchData().then((result) => { - setItemsResult(result); - - window.scrollTo(0, 0); - - import('../../components/autoFocuser').then(({ default: autoFocuser }) => { - autoFocuser.autoFocus(page); - }).catch(err => { - console.error('[ViewItemsContainer] failed to load autofocuser', err); - }); - loading.hide(); - setisLoading(true); - }).catch(err => { - console.error('[ViewItemsContainer] failed to fetch data', err); - }); - }, [fetchData]); - - useEffect(() => { - reloadItems(); - }, [reloadItems]); - - return ( -
-
- - - {isBtnShuffleEnabled && } - - - - - - {isBtnFilterEnabled && } - - {isBtnNewCollectionEnabled && } - -
- - {isAlphaPickerEnabled && } - - {isLoading && } - -
- -
-
- ); -}; - -export default ViewItemsContainer; diff --git a/src/components/filtermenu/filtermenu.js b/src/components/filtermenu/filtermenu.js index d7e9c876507..4ae7d8dc0ef 100644 --- a/src/components/filtermenu/filtermenu.js +++ b/src/components/filtermenu/filtermenu.js @@ -102,8 +102,16 @@ function onInputCommand(e) { break; } } -function saveValues(context, settings, settingsKey, setfilters) { + +function saveValues(context, settings, settingsKey) { let elems = context.querySelectorAll('.simpleFilter'); + for (let i = 0, length = elems.length; i < length; i++) { + if (elems[i].tagName === 'INPUT') { + setBasicFilter(context, settingsKey + '-filter-' + elems[i].getAttribute('data-settingname'), elems[i]); + } else { + setBasicFilter(context, settingsKey + '-filter-' + elems[i].getAttribute('data-settingname'), elems[i].querySelector('input')); + } + } // Video type const videoTypes = []; @@ -114,6 +122,7 @@ function saveValues(context, settings, settingsKey, setfilters) { videoTypes.push(elems[i].getAttribute('data-filter')); } } + userSettings.setFilter(settingsKey + '-filter-VideoTypes', videoTypes.join(',')); // Series status const seriesStatuses = []; @@ -124,6 +133,7 @@ function saveValues(context, settings, settingsKey, setfilters) { seriesStatuses.push(elems[i].getAttribute('data-filter')); } } + userSettings.setFilter(`${settingsKey}-filter-SeriesStatus`, seriesStatuses.join(',')); // Genres const genres = []; @@ -134,40 +144,9 @@ function saveValues(context, settings, settingsKey, setfilters) { genres.push(elems[i].getAttribute('data-filter')); } } - - if (setfilters) { - setfilters((prevState) => ({ - ...prevState, - StartIndex: 0, - IsPlayed: context.querySelector('.chkPlayed').checked, - IsUnplayed: context.querySelector('.chkUnplayed').checked, - IsFavorite: context.querySelector('.chkFavorite').checked, - IsResumable: context.querySelector('.chkResumable').checked, - Is4K: context.querySelector('.chk4KFilter').checked, - IsHD: context.querySelector('.chkHDFilter').checked, - IsSD: context.querySelector('.chkSDFilter').checked, - Is3D: context.querySelector('.chk3DFilter').checked, - VideoTypes: videoTypes.join(','), - SeriesStatus: seriesStatuses.join(','), - HasSubtitles: context.querySelector('.chkSubtitle').checked, - HasTrailer: context.querySelector('.chkTrailer').checked, - HasSpecialFeature: context.querySelector('.chkSpecialFeature').checked, - HasThemeSong: context.querySelector('.chkThemeSong').checked, - HasThemeVideo: context.querySelector('.chkThemeVideo').checked, - GenreIds: genres.join(',') - })); - } else { - for (let i = 0, length = elems.length; i < length; i++) { - if (elems[i].tagName === 'INPUT') { - setBasicFilter(context, settingsKey + '-filter-' + elems[i].getAttribute('data-settingname'), elems[i]); - } else { - setBasicFilter(context, settingsKey + '-filter-' + elems[i].getAttribute('data-settingname'), elems[i].querySelector('input')); - } - } - - userSettings.setFilter(settingsKey + '-filter-GenreIds', genres.join(',')); - } + userSettings.setFilter(settingsKey + '-filter-GenreIds', genres.join(',')); } + function bindCheckboxInput(context, on) { const elems = context.querySelectorAll('.checkboxList-verticalwrap'); for (let i = 0, length = elems.length; i < length; i++) { @@ -297,7 +276,7 @@ class FilterMenu { } if (submitted) { - saveValues(dlg, options.settings, options.settingsKey, options.setfilters); + saveValues(dlg, options.settings, options.settingsKey); return resolve(); } return resolve(); diff --git a/src/components/router/appRouter.js b/src/components/router/appRouter.js index 253fdee9226..e6bd2e1b66b 100644 --- a/src/components/router/appRouter.js +++ b/src/components/router/appRouter.js @@ -599,6 +599,12 @@ class AppRouter { return '#/details?seriesTimerId=' + id + '&serverId=' + serverId; } + const layoutMode = localStorage.getItem('layout'); + + if (layoutMode === 'experimental' && item.CollectionType == 'livetv') { + return '#/livetv'; + } + if (item.CollectionType == 'livetv') { return '#/livetv.html'; } @@ -637,6 +643,50 @@ class AppRouter { return url; } + if (layoutMode === 'experimental' && context !== 'folders' && !itemHelper.isLocalItem(item)) { + if (item.CollectionType == 'movies') { + url = '#/movies?topParentId=' + item.Id + '&collectionType=' + item.CollectionType; + + if (options && options.section === 'latest') { + url += '&tab=1'; + } + + return url; + } + + if (item.CollectionType == 'tvshows') { + url = '#/tv?topParentId=' + item.Id + '&collectionType=' + item.CollectionType; + + if (options && options.section === 'latest') { + url += '&tab=1'; + } + + return url; + } + + if (item.CollectionType == 'music') { + url = '#/music?topParentId=' + item.Id + '&collectionType=' + item.CollectionType; + + if (options?.section === 'latest') { + url += '&tab=1'; + } + + return url; + } + + if (item.CollectionType == 'books') { + url = '#/books?topParentId=' + item.Id + '&collectionType=' + item.CollectionType; + + return url; + } + + if (item.CollectionType == 'homevideos') { + url = '#/homevideos?topParentId=' + item.Id + '&collectionType=' + item.CollectionType; + + return url; + } + } + if (context !== 'folders' && !itemHelper.isLocalItem(item)) { if (item.CollectionType == 'movies') { url = '#/movies.html?topParentId=' + item.Id; diff --git a/src/components/sortmenu/sortmenu.js b/src/components/sortmenu/sortmenu.js index 65aef222e9d..39d42362bff 100644 --- a/src/components/sortmenu/sortmenu.js +++ b/src/components/sortmenu/sortmenu.js @@ -18,8 +18,8 @@ function onSubmit(e) { function initEditor(context, settings) { context.querySelector('form').addEventListener('submit', onSubmit); - context.querySelector('.selectSortOrder').value = settings.SortOrder; - context.querySelector('.selectSortBy').value = settings.SortBy; + context.querySelector('.selectSortOrder').value = settings.sortOrder; + context.querySelector('.selectSortBy').value = settings.sortBy; } function centerFocus(elem, horiz, on) { @@ -37,18 +37,9 @@ function fillSortBy(context, options) { }).join(''); } -function saveValues(context, settingsKey, setSortValues) { - if (setSortValues) { - setSortValues((prevState) => ({ - ...prevState, - StartIndex: 0, - SortBy: context.querySelector('.selectSortBy').value, - SortOrder: context.querySelector('.selectSortOrder').value - })); - } else { - userSettings.setFilter(settingsKey + '-sortorder', context.querySelector('.selectSortOrder').value); - userSettings.setFilter(settingsKey + '-sortby', context.querySelector('.selectSortBy').value); - } +function saveValues(context, settingsKey) { + userSettings.setFilter(settingsKey + '-sortorder', context.querySelector('.selectSortOrder').value); + userSettings.setFilter(settingsKey + '-sortby', context.querySelector('.selectSortBy').value); } class SortMenu { @@ -104,7 +95,7 @@ class SortMenu { } if (submitted) { - saveValues(dlg, options.settingsKey, options.setSortValues); + saveValues(dlg, options.settingsKey); resolve(); return; } diff --git a/src/components/viewSettings/viewSettings.js b/src/components/viewSettings/viewSettings.js index f5cb864206a..9c4b64710f6 100644 --- a/src/components/viewSettings/viewSettings.js +++ b/src/components/viewSettings/viewSettings.js @@ -29,24 +29,13 @@ function initEditor(context, settings) { context.querySelector('.selectImageType').value = settings.imageType || 'primary'; } -function saveValues(context, settings, settingsKey, setviewsettings) { - if (setviewsettings) { - setviewsettings((prevState) => ({ - ...prevState, - StartIndex: 0, - imageType: context.querySelector('.selectImageType').value, - showTitle: context.querySelector('.chkShowTitle').checked || false, - showYear: context.querySelector('.chkShowYear').checked || false, - cardLayout: context.querySelector('.chkEnableCardLayout').checked || false - })); - } else { - const elems = context.querySelectorAll('.viewSetting-checkboxContainer'); - for (const elem of elems) { - userSettings.set(settingsKey + '-' + elem.getAttribute('data-settingname'), elem.querySelector('input').checked); - } - - userSettings.set(settingsKey + '-imageType', context.querySelector('.selectImageType').value); +function saveValues(context, settings, settingsKey) { + const elems = context.querySelectorAll('.viewSetting-checkboxContainer'); + for (const elem of elems) { + userSettings.set(settingsKey + '-' + elem.getAttribute('data-settingname'), elem.querySelector('input').checked); } + + userSettings.set(settingsKey + '-imageType', context.querySelector('.selectImageType').value); } function centerFocus(elem, horiz, on) { @@ -137,7 +126,7 @@ class ViewSettings { } if (submitted) { - saveValues(dlg, options.settings, options.settingsKey, options.setviewsettings); + saveValues(dlg, options.settings, options.settingsKey); return resolve(); } diff --git a/src/components/viewSettings/viewSettings.template.html b/src/components/viewSettings/viewSettings.template.html index ddee9536baf..df9767fb75b 100644 --- a/src/components/viewSettings/viewSettings.template.html +++ b/src/components/viewSettings/viewSettings.template.html @@ -35,13 +35,6 @@ ${GroupBySeries}
- -
- -
diff --git a/src/hooks/useFetchItems.ts b/src/hooks/useFetchItems.ts index c0ed89b85c9..03f53bc9947 100644 --- a/src/hooks/useFetchItems.ts +++ b/src/hooks/useFetchItems.ts @@ -1,24 +1,44 @@ -import type { ItemsApiGetItemsRequest } from '@jellyfin/sdk/lib/generated-client'; +import { + LocationType, + type BaseItemDto, + type ItemsApiGetItemsRequest, + type BaseItemKind +} from '@jellyfin/sdk/lib/generated-client'; import { AxiosRequestConfig } from 'axios'; -import { 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'; import { ItemFilter } from '@jellyfin/sdk/lib/generated-client/models/item-filter'; import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order'; import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by'; +import { getArtistsApi } from '@jellyfin/sdk/lib/utils/api/artists-api'; import { getFilterApi } from '@jellyfin/sdk/lib/utils/api/filter-api'; import { getGenresApi } from '@jellyfin/sdk/lib/utils/api/genres-api'; import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api'; 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 { getPersonsApi } from '@jellyfin/sdk/lib/utils/api/persons-api'; import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api/user-library-api'; +import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api'; import { useQuery } from '@tanstack/react-query'; -import { JellyfinApiContext, useApi } from './useApi'; +import { type JellyfinApiContext, useApi } from './useApi'; +import { + getAlphaPickerQuery, + getFieldsQuery, + getFiltersQuery, + getLimitQuery, + getVisibleitemType +} from 'utils/items'; import { Sections, SectionsViewType } from 'types/suggestionsSections'; -import { ParentId } from 'types/library'; +import { LibraryViewSettings, ParentId } from 'types/library'; +import { LibraryTab } from 'types/libraryTab'; +import { getItemTypesQuery } from '../utils/items'; +import { useLibrarySettings } from './useLibrarySettings'; +import { CollectionType } from 'types/collectionType'; +import datetime from 'scripts/datetime'; +import globalize from 'scripts/globalize'; const fetchGetItem = async ( currentApi: JellyfinApiContext, @@ -80,7 +100,9 @@ export const useGetItems = (parametersOptions: ItemsApiGetItemsRequest) => { ], queryFn: ({ signal }) => fetchGetItems(currentApi, parametersOptions, { signal }), - cacheTime: parametersOptions.sortBy?.includes(ItemSortBy.Random) ? 0 : undefined + cacheTime: parametersOptions.sortBy?.includes(ItemSortBy.Random) ? + 0 : + undefined }); }; @@ -243,20 +265,21 @@ export const useGetItemsBySectionType = ( return useQuery({ queryKey: ['ItemsBySuggestionsType', sections.view], queryFn: ({ signal }) => - fetchGetItemsBySuggestionsType( - currentApi, - sections, - parentId, - { signal } - ), + fetchGetItemsBySuggestionsType(currentApi, sections, parentId, { + signal + }), enabled: !!sections.view }); }; -const fetchGetGenres = async ( +type GroupsGenres = { + genre: BaseItemDto; + items: BaseItemDto[]; +}; + +const fetchGetGroupsGenres = async ( currentApi: JellyfinApiContext, - itemType: BaseItemKind, - parentId: ParentId, + item: BaseItemDto | undefined, options?: AxiosRequestConfig ) => { const { api, user } = currentApi; @@ -266,32 +289,72 @@ const fetchGetGenres = async ( userId: user.Id, sortBy: [ItemSortBy.SortName], sortOrder: [SortOrder.Ascending], - includeItemTypes: [itemType], + includeItemTypes: getVisibleitemType(item?.CollectionType as CollectionType), enableTotalRecordCount: false, - parentId: parentId ?? undefined + parentId: item?.Id ?? undefined }, { signal: options?.signal } ); - return response.data; + + const groupsGenres: GroupsGenres[] = []; + const genres = response.data.Items ?? []; + for (const genre of genres) { + const responseItems = await getItemsApi(api).getItems( + { + userId: user.Id, + sortBy: [ItemSortBy.Random], + sortOrder: [SortOrder.Ascending], + includeItemTypes: getVisibleitemType(item?.CollectionType as CollectionType), + recursive: true, + fields: [ + ItemFields.PrimaryImageAspectRatio, + ItemFields.MediaSourceCount, + ItemFields.BasicSyncInfo + ], + imageTypeLimit: 1, + enableImageTypes: [ImageType.Primary], + limit: 25, + genreIds: genre.Id ? [genre.Id] : undefined, + enableTotalRecordCount: false, + parentId: item?.Id ?? undefined + }, + { + signal: options?.signal + } + ); + const items = responseItems.data.Items ?? []; + if (items?.length) { + groupsGenres.push({ + genre: genre, + items: items + }); + } + } + + return groupsGenres; } }; -export const useGetGenres = (itemType: BaseItemKind, parentId: ParentId) => { +export const useGetGroupsGenres = (item: BaseItemDto | undefined) => { const currentApi = useApi(); return useQuery({ - queryKey: ['Genres', parentId], + queryKey: ['GroupsGenres', item], queryFn: ({ signal }) => - fetchGetGenres(currentApi, itemType, parentId, { signal }), - enabled: !!parentId + fetchGetGroupsGenres(currentApi, item, { + signal + }), + enabled: !!item?.Id, + refetchOnWindowFocus: false, + cacheTime: 0 }); }; const fetchGetStudios = async ( currentApi: JellyfinApiContext, parentId: ParentId, - itemType: BaseItemKind, + itemType: BaseItemKind[], options?: AxiosRequestConfig ) => { const { api, user } = currentApi; @@ -299,7 +362,7 @@ const fetchGetStudios = async ( const response = await getStudiosApi(api).getStudios( { userId: user.Id, - includeItemTypes: [itemType], + includeItemTypes: itemType, fields: [ ItemFields.DateCreated, ItemFields.PrimaryImageAspectRatio @@ -316,20 +379,217 @@ const fetchGetStudios = async ( } }; -export const useGetStudios = (parentId: ParentId, itemType: BaseItemKind) => { +export const useGetStudios = (parentId: ParentId, itemType: BaseItemKind[]) => { const currentApi = useApi(); return useQuery({ queryKey: ['Studios', parentId, itemType], queryFn: ({ signal }) => - fetchGetStudios(currentApi, parentId, itemType, { signal }), + fetchGetStudios(currentApi, parentId, itemType, { signal: signal }), enabled: !!parentId }); }; +const fetchGetItemsViewByType = async ( + currentApi: JellyfinApiContext, + viewType: LibraryTab, + item: BaseItemDto | undefined, + libraryViewSettings: LibraryViewSettings, + options?: AxiosRequestConfig +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + let response; + switch (viewType) { + case LibraryTab.AlbumArtists: { + response = await getArtistsApi(api).getAlbumArtists( + { + userId: user.Id, + parentId: item?.Id ?? undefined, + includeItemTypes: getVisibleitemType(item?.CollectionType as CollectionType), + sortBy: [libraryViewSettings.SortBy], + sortOrder: [libraryViewSettings.SortOrder], + enableImageTypes: [ + libraryViewSettings.ImageType, + ImageType.Backdrop + ], + ...getLimitQuery(), + ...getFieldsQuery(viewType, libraryViewSettings), + ...getFiltersQuery(viewType, libraryViewSettings), + ...getAlphaPickerQuery(libraryViewSettings), + startIndex: libraryViewSettings.StartIndex + }, + { + signal: options?.signal + } + ); + break; + } + case LibraryTab.Artists: { + response = await getArtistsApi(api).getArtists( + { + userId: user.Id, + parentId: item?.Id ?? undefined, + includeItemTypes: getVisibleitemType(item?.CollectionType as CollectionType), + enableImageTypes: [ + libraryViewSettings.ImageType, + ImageType.Backdrop + ], + ...getLimitQuery(), + ...getFieldsQuery(viewType, libraryViewSettings), + ...getFiltersQuery(viewType, libraryViewSettings), + ...getAlphaPickerQuery(libraryViewSettings), + sortBy: [libraryViewSettings.SortBy], + sortOrder: [libraryViewSettings.SortOrder], + startIndex: libraryViewSettings.StartIndex + }, + { + signal: options?.signal + } + ); + break; + } + case LibraryTab.Networks: + response = await getStudiosApi(api).getStudios( + { + userId: user.Id, + parentId: item?.Id ?? undefined, + includeItemTypes: getVisibleitemType(item?.CollectionType as CollectionType), + enableImageTypes: [ImageType.Thumb], + ...getLimitQuery(), + ...getFieldsQuery(viewType, libraryViewSettings), + + startIndex: 0 + }, + { + signal: options?.signal + } + ); + break; + case LibraryTab.Genres: { + response = await getGenresApi(api).getGenres( + { + userId: user.Id, + sortBy: [ItemSortBy.SortName], + sortOrder: [SortOrder.Ascending], + includeItemTypes: getVisibleitemType(item?.CollectionType as CollectionType), + enableTotalRecordCount: false, + enableImageTypes: [ImageType.Primary], + fields: [ + ItemFields.PrimaryImageAspectRatio, + ItemFields.MediaSourceCount, + ItemFields.BasicSyncInfo, + ItemFields.ItemCounts + ], + parentId: item?.Id ?? undefined + }, + { + signal: options?.signal + } + ); + break; + } + case LibraryTab.Channels: { + response = await getLiveTvApi(api).getLiveTvChannels( + { + userId: user.Id, + ...getFieldsQuery(viewType, libraryViewSettings), + startIndex: libraryViewSettings.StartIndex + }, + { + signal: options?.signal + } + ); + break; + } + default: { + response = await getItemsApi(api).getItems( + { + userId: user.Id, + recursive: true, + imageTypeLimit: 1, + parentId: item?.Id ?? undefined, + enableImageTypes: [ + libraryViewSettings.ImageType, + ImageType.Backdrop + ], + ...getFieldsQuery(viewType, libraryViewSettings), + ...getFiltersQuery(viewType, libraryViewSettings), + ...getLimitQuery(), + ...getAlphaPickerQuery(libraryViewSettings), + ...getItemTypesQuery(viewType), + isFavorite: + viewType === LibraryTab.Favorites ? + true : + undefined, + sortBy: [libraryViewSettings.SortBy], + sortOrder: [libraryViewSettings.SortOrder], + startIndex: libraryViewSettings.StartIndex + }, + { + signal: options?.signal + } + ); + break; + } + } + return response.data; + } +}; + +export const useGetItemsViewByType = ( + viewType: LibraryTab, + item: BaseItemDto | undefined +) => { + const currentApi = useApi(); + const { libraryViewSettings } = useLibrarySettings(); + + return useQuery({ + queryKey: [ + 'ViewItemsByType', + viewType, + item, + { + ...libraryViewSettings + } + ], + queryFn: ({ signal }) => + fetchGetItemsViewByType( + currentApi, + viewType, + item, + libraryViewSettings, + { signal: signal } + ), + refetchOnWindowFocus: false, + keepPreviousData: true, + enabled: + [ + LibraryTab.Movies, + LibraryTab.Favorites, + LibraryTab.Collections, + LibraryTab.Trailers, + LibraryTab.Series, + LibraryTab.Episodes, + LibraryTab.Networks, + LibraryTab.Albums, + LibraryTab.AlbumArtists, + LibraryTab.Artists, + LibraryTab.Playlists, + LibraryTab.Songs, + LibraryTab.Books, + LibraryTab.Photos, + LibraryTab.Videos, + LibraryTab.Genres, + LibraryTab.PhotoAlbums, + LibraryTab.Channels + ].includes(viewType) && !!item?.Id + }); +}; + const fetchGetQueryFiltersLegacy = async ( currentApi: JellyfinApiContext, parentId: ParentId, - itemType: BaseItemKind, + itemType: BaseItemKind[], options?: AxiosRequestConfig ) => { const { api, user } = currentApi; @@ -338,7 +598,7 @@ const fetchGetQueryFiltersLegacy = async ( { userId: user.Id, parentId: parentId ?? undefined, - includeItemTypes: [itemType] + includeItemTypes: itemType }, { signal: options?.signal @@ -350,15 +610,207 @@ const fetchGetQueryFiltersLegacy = async ( export const useGetQueryFiltersLegacy = ( parentId: ParentId, - itemType: BaseItemKind + itemType: BaseItemKind[] ) => { const currentApi = useApi(); return useQuery({ queryKey: ['QueryFiltersLegacy', parentId, itemType], queryFn: ({ signal }) => fetchGetQueryFiltersLegacy(currentApi, parentId, itemType, { - signal + signal: signal }), enabled: !!parentId }); }; + +type GroupsUpcomingEpisodes = { + name: string; + items: BaseItemDto[]; +}; + +function gropsUpcomingEpisodes(items: BaseItemDto[]) { + const groups: GroupsUpcomingEpisodes[] = []; + let currentGroupName = ''; + let currentGroup: BaseItemDto[] = []; + for (const item of items) { + let dateText = ''; + + if (item.PremiereDate) { + try { + const premiereDate = datetime.parseISO8601Date( + item.PremiereDate, + true + ); + dateText = datetime.isRelativeDay(premiereDate, -1) ? + globalize.translate('Yesterday') : + datetime.toLocaleDateString(premiereDate, { + weekday: 'long', + month: 'short', + day: 'numeric' + }); + } catch (err) { + console.error('error parsing timestamp for upcoming tv shows'); + } + } + + if (dateText != currentGroupName) { + if (currentGroup.length) { + groups.push({ + name: currentGroupName, + items: currentGroup + }); + } + + currentGroupName = dateText; + currentGroup = [item]; + } else { + currentGroup.push(item); + } + } + return groups; +} + +const fetchGetGroupsUpcomingEpisodes = async ( + currentApi: JellyfinApiContext, + item: BaseItemDto | undefined, + options?: AxiosRequestConfig +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + const response = await getTvShowsApi(api).getUpcomingEpisodes( + { + userId: user.Id, + limit: 25, + fields: [ItemFields.AirTime], + parentId: item?.Id ?? undefined, + imageTypeLimit: 1, + enableImageTypes: [ + ImageType.Primary, + ImageType.Backdrop, + ImageType.Thumb + ] + }, + { + signal: options?.signal + } + ); + const items = response.data.Items ?? []; + + return gropsUpcomingEpisodes(items); + } +}; + +export const useGetGroupsUpcomingEpisodes = (item: BaseItemDto | undefined) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['GroupsUpcomingEpisodes', item], + queryFn: ({ signal }) => + fetchGetGroupsUpcomingEpisodes(currentApi, item, { + signal: signal + }), + enabled: !!item?.Id + }); +}; + +const fetchGetItemsByFavoriteType = async ( + currentApi: JellyfinApiContext, + parentId: ParentId, + sections: Sections, + options?: AxiosRequestConfig +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + let response; + switch (sections.viewType) { + case SectionsViewType.Artists: { + response = ( + await getArtistsApi(api).getArtists( + { + userId: user.Id, + parentId: parentId ?? undefined, + sortBy: [ItemSortBy.SortName], + sortOrder: [SortOrder.Ascending], + fields: [ + ItemFields.PrimaryImageAspectRatio, + ItemFields.BasicSyncInfo + ], + isFavorite: true, + limit: 25, + enableTotalRecordCount: false, + ...sections.parametersOptions + }, + { + signal: options?.signal + } + ) + ).data.Items; + break; + } + case SectionsViewType.Persons: { + response = ( + await getPersonsApi(api).getPersons( + { + userId: user.Id, + sortBy: [ItemSortBy.SortName], + sortOrder: [SortOrder.Ascending], + fields: [ + ItemFields.PrimaryImageAspectRatio, + ItemFields.BasicSyncInfo + ], + isFavorite: true, + limit: 25, + ...sections.parametersOptions + }, + { + signal: options?.signal + } + ) + ).data.Items; + break; + } + default: { + response = ( + await getItemsApi(api).getItems( + { + userId: user.Id, + parentId: parentId ?? undefined, + sortBy: [ItemSortBy.SortName], + sortOrder: [SortOrder.Ascending], + fields: [ + ItemFields.PrimaryImageAspectRatio, + ItemFields.BasicSyncInfo + ], + isFavorite: true, + collapseBoxSetItems: false, + limit: 25, + enableTotalRecordCount: false, + recursive: true, + excludeLocationTypes: [LocationType.Virtual], + ...sections.parametersOptions + }, + { + signal: options?.signal + } + ) + ).data.Items; + break; + } + } + return response; + } +}; + +export const useGetItemsByFavoriteType = ( + sections: Sections, + parentId: ParentId +) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['ItemsByFavoriteType', sections.view], + queryFn: ({ signal }) => + fetchGetItemsByFavoriteType(currentApi, parentId, sections, { + signal: signal + }), + enabled: !!sections.view + }); +}; diff --git a/src/hooks/useLibrarySettings.tsx b/src/hooks/useLibrarySettings.tsx new file mode 100644 index 00000000000..4a70ce7f485 --- /dev/null +++ b/src/hooks/useLibrarySettings.tsx @@ -0,0 +1,183 @@ +import React, { + createContext, + FC, + useContext, + useMemo, + useState +} from 'react'; +import useLocalStorageState from 'use-local-storage-state'; +import { LibraryViewSelectOptions, LibraryViewSettings, ParentId, ViewMode } from 'types/library'; +import { LibraryTab } from 'types/libraryTab'; +import { Box } from '@mui/material'; +import Page from 'components/Page'; +import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by'; +import { BaseItemDto, ImageType, SortOrder } from '@jellyfin/sdk/lib/generated-client'; +import { CollectionType } from 'types/collectionType'; + +const getLibraryViewMenuOptions = (collectionType: CollectionType) => { + const viewSelectOptions: LibraryViewSelectOptions[] = []; + if (collectionType === CollectionType.Movies) { + viewSelectOptions.push( + { title: 'Movies', value: LibraryTab.Movies }, + { title: 'Trailers', value: LibraryTab.Trailers }, + { title: 'Collections', value: LibraryTab.Collections }, + { title: 'Genres', value: LibraryTab.Genres }, + { title: 'Studios', value: LibraryTab.Networks }, + { title: 'Suggestions', value: LibraryTab.Suggestions } + ); + } + + if (collectionType === CollectionType.TvShows) { + viewSelectOptions.push( + { title: 'Series', value: LibraryTab.Series }, + { title: 'Episodes', value: LibraryTab.Episodes }, + { title: 'Genres', value: LibraryTab.Genres }, + { title: 'Studios', value: LibraryTab.Networks }, + { title: 'TabUpcoming', value: LibraryTab.Upcoming }, + { title: 'Suggestions', value: LibraryTab.Suggestions } + ); + } + + if (collectionType === CollectionType.Music) { + viewSelectOptions.push( + { title: 'Albums', value: LibraryTab.Albums }, + { title: 'HeaderAlbumArtists', value: LibraryTab.AlbumArtists }, + { title: 'Artists', value: LibraryTab.Artists }, + { title: 'Playlists', value: LibraryTab.Playlists }, + { title: 'Genres', value: LibraryTab.Genres }, + { title: 'Songs', value: LibraryTab.Songs }, + { title: 'Suggestions', value: LibraryTab.Suggestions } + ); + } + + if (collectionType === CollectionType.Books) { + viewSelectOptions.push({ title: 'Books', value: LibraryTab.Books }); + } + + if (collectionType === CollectionType.LiveTv) { + viewSelectOptions.push({ + title: 'Channels', + value: LibraryTab.Channels + }); + } + + if (collectionType === CollectionType.HomeVideos) { + viewSelectOptions.push( + { title: 'HeaderPhotoAlbums', value: LibraryTab.PhotoAlbums }, + { title: 'Photos', value: LibraryTab.Photos }, + { title: 'HeaderVideos', value: LibraryTab.Videos } + ); + } + + return viewSelectOptions; +}; + +const getBackDropType = (collectionType: CollectionType) => { + if (collectionType === CollectionType.Movies) { + return 'movie'; + } + + if (collectionType === CollectionType.TvShows) { + return 'series'; + } + + if (collectionType === CollectionType.Music) { + return 'musicartist'; + } + + if (collectionType === CollectionType.Books) { + return 'book'; + } + + if (collectionType === CollectionType.HomeVideos) { + return 'video, photo'; + } + + return ''; +}; +export interface LibrarySettingsContextProps { + item: BaseItemDto | undefined; + viewSelectOptions: LibraryViewSelectOptions[]; + viewType: LibraryTab; + setViewType: React.Dispatch>; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch< + React.SetStateAction + >; +} + +const LibrarySettingsContext = createContext( + {} as LibrarySettingsContextProps +); + +export const useLibrarySettings = () => useContext(LibrarySettingsContext); + +const getSettingsKey = (viewType: LibraryTab, parentId: ParentId) => { + return `${viewType} - ${parentId}`; +}; + +const DEFAULT_Library_View_SETTINGS: LibraryViewSettings = { + SortBy: ItemSortBy.SortName, + SortOrder: SortOrder.Ascending, + StartIndex: 0, + CardLayout: false, + ImageType: ImageType.Primary, + ViewMode: ViewMode.GridView, + ShowTitle: true, + ShowYear: false +}; + +interface LibrarySettingsProviderProps { + item: BaseItemDto | undefined; + parentId?: string | null; + defaultView: LibraryTab; + collectionType?: CollectionType; +} + +export const LibrarySettingsProvider: FC = ({ + item, + defaultView, + children +}) => { + const viewSelectOptions = getLibraryViewMenuOptions(item?.CollectionType as CollectionType); + const [viewType, setViewType] = useState( + defaultView ?? viewSelectOptions[0].value + ); + const [libraryViewSettings, setLibraryViewSettings] = + useLocalStorageState( + getSettingsKey(viewType, item?.Id), + { + defaultValue: DEFAULT_Library_View_SETTINGS + } + ); + + const context = useMemo( + () => ({ + item, + viewSelectOptions, + viewType, + setViewType, + libraryViewSettings, + setLibraryViewSettings + }), + [ + item, + viewSelectOptions, + viewType, + libraryViewSettings, + setLibraryViewSettings + ] + ); + + return ( + + + {children} + + + ); +}; diff --git a/src/strings/en-us.json b/src/strings/en-us.json index ca5dd5778b1..881492a9259 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -378,6 +378,17 @@ "HeaderEpisodesStatus": "Episodes Status", "HeaderError": "Error", "HeaderExternalIds": "External IDs", + "HeaderFavoriteMovies": "Favorite Movies", + "HeaderFavoriteShows": "Favorite Shows", + "HeaderFavoriteEpisodes": "Favorite Episodes", + "HeaderFavoriteAlbums": "Favorite Albums", + "HeaderFavoriteArtists": "Favorite Artists", + "HeaderFavoriteSongs": "Favorite Songs", + "HeaderFavoriteVideos": "Favorite Videos", + "HeaderFavoriteCollections": "Favorite Collections", + "HeaderFavoritePlaylists": "Favorite Playlists", + "HeaderFavoritePersons": "Favorite Persons", + "HeaderFavoriteBooks": "Favorite Books", "HeaderFeatureAccess": "Feature access", "HeaderFetcherSettings": "Fetcher Settings", "HeaderFetchImages": "Fetch Images", diff --git a/src/types/cardOptions.ts b/src/types/cardOptions.ts index 7864ab31577..6589782936b 100644 --- a/src/types/cardOptions.ts +++ b/src/types/cardOptions.ts @@ -1,4 +1,5 @@ import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; +import { CollectionType } from './collectionType'; export interface CardOptions { itemsContainer?: HTMLElement | null; @@ -32,7 +33,7 @@ export interface CardOptions { showUnplayedIndicator?: boolean; showChildCountIndicator?: boolean; lines?: number; - context?: string | null; + context?: CollectionType; action?: string | null; defaultShape?: string; indexBy?: string; diff --git a/src/types/interface.ts b/src/types/interface.ts deleted file mode 100644 index c577f84e2a3..00000000000 --- a/src/types/interface.ts +++ /dev/null @@ -1,29 +0,0 @@ -export interface ViewQuerySettings { - showTitle?: boolean; - showYear?: boolean; - imageType?: string; - viewType?: string; - cardLayout?: boolean; - SortBy?: string | null; - SortOrder?: string | null; - IsPlayed?: boolean | null; - IsUnplayed?: boolean | null; - IsFavorite?: boolean | null; - IsResumable?: boolean | null; - Is4K?: boolean | null; - IsHD?: boolean | null; - IsSD?: boolean | null; - Is3D?: boolean | null; - VideoTypes?: string | null; - SeriesStatus?: string | null; - HasSubtitles?: boolean | null; - HasTrailer?: boolean | null; - HasSpecialFeature?: boolean | null; - ParentIndexNumber?: boolean | null; - HasThemeSong?: boolean | null; - HasThemeVideo?: boolean | null; - GenreIds?: string | null; - NameLessThan?: string | null; - NameStartsWith?: string | null; - StartIndex?: number; -} diff --git a/src/types/library.ts b/src/types/library.ts index 4bf275abe38..018cab5fa03 100644 --- a/src/types/library.ts +++ b/src/types/library.ts @@ -4,11 +4,12 @@ import type { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-o import type { SeriesStatus } from '@jellyfin/sdk/lib/generated-client/models/series-status'; import type { ImageType } from '@jellyfin/sdk/lib/generated-client'; import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by'; +import { LibraryTab } from './libraryTab'; export type ParentId = string | null | undefined; export interface LibraryViewProps { - parentId: string | null; + parentId: ParentId; } export enum FeatureFilters { @@ -62,6 +63,10 @@ export interface LibraryViewSettings { ShowTitle: boolean; ShowYear?: boolean; Filters?: Filters; - NameLessThan?: string | null; - NameStartsWith?: string | null; + Alphabet?: string | null; +} + +export interface LibraryViewSelectOptions { + title: string; + value: LibraryTab; } diff --git a/src/types/libraryTab.ts b/src/types/libraryTab.ts index 1484ed9646d..4147bde34cb 100644 --- a/src/types/libraryTab.ts +++ b/src/types/libraryTab.ts @@ -21,6 +21,7 @@ export enum LibraryTab { Trailers = 'trailers', Upcoming = 'upcoming', Photos = 'photos', + PhotoAlbums = 'photoalbums', Videos = 'videos', - Books = 'books', + Books = 'books' } diff --git a/src/types/suggestionsSections.ts b/src/types/suggestionsSections.ts index afb198a9769..ccf28d21394 100644 --- a/src/types/suggestionsSections.ts +++ b/src/types/suggestionsSections.ts @@ -13,6 +13,8 @@ export enum SectionsViewType { ResumeItems = 'resumeItems', LatestMedia = 'latestMedia', NextUp = 'nextUp', + Artists = 'artists', + Persons = 'persons', } export enum SectionsView { @@ -24,6 +26,17 @@ export enum SectionsView { LatestMusic = 'latestmusic', RecentlyPlayedMusic = 'recentlyplayedmusic', FrequentlyPlayedMusic = 'frequentlyplayedmusic', + FavoriteMovies = 'favoriteMovies', + FavoriteShows = 'favoriteShows', + FavoriteEpisode = 'favoriteEpisode', + FavoriteVideos = 'favoriteVideos', + FavoriteCollections = 'favoriteCollections', + FavoritePlaylists = 'favoritePlaylists', + FavoritePeople = 'favoritePeople', + FavoriteArtists = 'favoriteArtists', + FavoriteAlbums = 'favoriteAlbums', + FavoriteSongs = 'favoriteSongs', + FavoriteBooks = 'favoriteBooks', } export interface Sections { diff --git a/src/utils/items.ts b/src/utils/items.ts new file mode 100644 index 00000000000..8c357042958 --- /dev/null +++ b/src/utils/items.ts @@ -0,0 +1,242 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; +import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields'; +import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type'; +import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by'; +import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order'; +import * as userSettings from 'scripts/settings/userSettings'; +import { EpisodeFilter, FeatureFilters, LibraryViewSettings, ParentId, VideoBasicFilter, ViewMode } from '../types/library'; +import { LibraryTab } from 'types/libraryTab'; +import { CollectionType } from 'types/collectionType'; + +export const getVisibleitemType = (collectionType: CollectionType) => { + const itemType: BaseItemKind[] = []; + + if (collectionType === CollectionType.Movies) { + itemType.push(BaseItemKind.Movie); + } + + if (collectionType === CollectionType.TvShows) { + itemType.push(BaseItemKind.Series); + } + + if (collectionType === CollectionType.Music) { + itemType.push(BaseItemKind.MusicAlbum); + } + + return itemType; +}; + +export const getItemTypesEnum = (viewType: LibraryTab) => { + const itemType: BaseItemKind[] = []; + + if (viewType === LibraryTab.Movies || viewType === LibraryTab.Favorites) { + itemType.push(BaseItemKind.Movie); + } + + if (viewType === LibraryTab.Collections) { + itemType.push(BaseItemKind.BoxSet); + } + + if (viewType === LibraryTab.Trailers) { + itemType.push(BaseItemKind.Trailer); + } + + if (viewType === LibraryTab.Series) { + itemType.push(BaseItemKind.Series); + } + + if (viewType === LibraryTab.Episodes) { + itemType.push(BaseItemKind.Episode); + } + + if (viewType === LibraryTab.Albums) { + itemType.push(BaseItemKind.MusicAlbum); + } + + if (viewType === LibraryTab.Songs) { + itemType.push(BaseItemKind.Audio); + } + + if (viewType === LibraryTab.Playlists) { + itemType.push(BaseItemKind.Playlist); + } + + if (viewType === LibraryTab.Books) { + itemType.push(BaseItemKind.Book); + } + + if (viewType === LibraryTab.PhotoAlbums) { + itemType.push(BaseItemKind.PhotoAlbum); + } + + if (viewType === LibraryTab.Photos) { + itemType.push(BaseItemKind.Photo); + } + + if (viewType === LibraryTab.Videos) { + itemType.push(BaseItemKind.Video); + } + + if (viewType === LibraryTab.Channels) { + itemType.push(BaseItemKind.Channel); + } + + return itemType; +}; + +export const getItemTypesQuery = (viewType: LibraryTab) => { + return { + includeItemTypes: getItemTypesEnum(viewType) + }; +}; + +export const getVideoBasicFilter = (libraryViewSettings: LibraryViewSettings) => { + let isHd; + + if (libraryViewSettings.Filters?.VideoBasicFilter?.includes(VideoBasicFilter.IsHD)) { + isHd = true; + } + + if (libraryViewSettings.Filters?.VideoBasicFilter?.includes(VideoBasicFilter.IsSD)) { + isHd = false; + } + + return { + isHd, + is4K: libraryViewSettings.Filters?.VideoBasicFilter?.includes(VideoBasicFilter.Is4K) ? + true : + undefined, + is3D: libraryViewSettings.Filters?.VideoBasicFilter?.includes(VideoBasicFilter.Is3D) ? + true : + undefined + }; +}; + +export const getFeatureFilters = (libraryViewSettings: LibraryViewSettings) => { + return { + hasSubtitles: libraryViewSettings.Filters?.Features?.includes(FeatureFilters.HasSubtitles) ? + true : + undefined, + hasTrailer: libraryViewSettings.Filters?.Features?.includes(FeatureFilters.HasTrailer) ? + true : + undefined, + hasSpecialFeature: libraryViewSettings.Filters?.Features?.includes( + FeatureFilters.HasSpecialFeature + ) ? + true : + undefined, + hasThemeSong: libraryViewSettings.Filters?.Features?.includes(FeatureFilters.HasThemeSong) ? + true : + undefined, + hasThemeVideo: libraryViewSettings.Filters?.Features?.includes( + FeatureFilters.HasThemeVideo + ) ? + true : + undefined + }; +}; + +export const getEpisodeFilter = ( + viewType: LibraryTab, + libraryViewSettings: LibraryViewSettings +) => { + return { + parentIndexNumber: libraryViewSettings.Filters?.EpisodeFilter?.includes( + EpisodeFilter.ParentIndexNumber + ) ? + 0 : + undefined, + isMissing: + viewType === LibraryTab.Episodes ? + !!libraryViewSettings.Filters?.EpisodeFilter?.includes(EpisodeFilter.IsMissing) : + undefined, + isUnaired: libraryViewSettings.Filters?.EpisodeFilter?.includes(EpisodeFilter.IsUnaired) ? + true : + undefined + }; +}; + +const getItemFieldsEnum = ( + viewType: LibraryTab, + libraryViewSettings: LibraryViewSettings +) => { + const itemFields: ItemFields[] = []; + + if (viewType !== LibraryTab.Networks) { + itemFields.push(ItemFields.BasicSyncInfo, ItemFields.MediaSourceCount); + } + + if (libraryViewSettings.ImageType === ImageType.Primary) { + itemFields.push(ItemFields.PrimaryImageAspectRatio); + } + + if (viewType === LibraryTab.Networks) { + itemFields.push( + ItemFields.DateCreated, + ItemFields.PrimaryImageAspectRatio + ); + } + + return itemFields; +}; + +export const getFieldsQuery = ( + viewType: LibraryTab, + libraryViewSettings: LibraryViewSettings +) => { + return { + fields: getItemFieldsEnum(viewType, libraryViewSettings) + }; +}; + +export const getLimitQuery = () => { + return { + limit: userSettings.libraryPageSize(undefined) ?? undefined + }; +}; + +export const getAlphaPickerQuery = (libraryViewSettings: LibraryViewSettings) => { + const alphabetValue = libraryViewSettings.Alphabet !== null ? + libraryViewSettings.Alphabet : undefined; + + return { + nameLessThan: alphabetValue === '#' ? 'A' : undefined, + nameStartsWith: alphabetValue === '#' ? undefined : alphabetValue + }; +}; + +export const getFiltersQuery = ( + viewType: LibraryTab, + libraryViewSettings: LibraryViewSettings +) => { + return { + ...getFeatureFilters(libraryViewSettings), + ...getEpisodeFilter(viewType, libraryViewSettings), + ...getVideoBasicFilter(libraryViewSettings), + seriesStatus: libraryViewSettings?.Filters?.SeriesStatus, + videoTypes: libraryViewSettings?.Filters?.VideoTypes, + filters: libraryViewSettings?.Filters?.Status, + genres: libraryViewSettings?.Filters?.Genres, + officialRatings: libraryViewSettings?.Filters?.OfficialRatings, + tags: libraryViewSettings?.Filters?.Tags, + years: libraryViewSettings?.Filters?.Years, + studioIds: libraryViewSettings?.Filters?.StudioIds + }; +}; + +export const getSettingsKey = (viewType: LibraryTab, parentId: ParentId) => { + return `${viewType} - ${parentId}`; +}; + +export const getDefaultLibraryViewSettings = (viewType: LibraryTab): LibraryViewSettings => { + return { + ShowTitle: true, + ShowYear: false, + ViewMode: viewType === LibraryTab.Songs ? ViewMode.ListView : ViewMode.GridView, + ImageType: viewType === LibraryTab.Networks ? ImageType.Thumb : ImageType.Primary, + CardLayout: false, + SortBy: ItemSortBy.SortName, + SortOrder: SortOrder.Ascending, + StartIndex: 0 + }; +}; diff --git a/webpack.common.js b/webpack.common.js index 8e729214198..c0cb05439e1 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -199,6 +199,7 @@ const config = { path.resolve(__dirname, 'node_modules/screenfull'), path.resolve(__dirname, 'node_modules/ssr-window'), path.resolve(__dirname, 'node_modules/swiper'), + path.resolve(__dirname, 'node_modules/use-local-storage-state'), path.resolve(__dirname, 'src') ], use: [{