diff --git a/package-lock.json b/package-lock.json index 64c7e6147c..8bcb3c653c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,8 @@ "@loadable/component": "5.15.3", "@mui/icons-material": "5.11.16", "@mui/material": "5.13.3", + "@tanstack/react-query": "4.29.12", + "@tanstack/react-query-devtools": "4.29.12", "blurhash": "2.0.5", "classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz", "classnames": "2.3.2", @@ -3472,6 +3474,75 @@ "string.prototype.matchall": "^4.0.6" } }, + "node_modules/@tanstack/match-sorter-utils": { + "version": "8.8.4", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.8.4.tgz", + "integrity": "sha512-rKH8LjZiszWEvmi01NR72QWZ8m4xmXre0OOwlRGnjU01Eqz/QnN+cqpty2PJ0efHblq09+KilvyR7lsbzmXVEw==", + "dependencies": { + "remove-accents": "0.4.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kentcdodds" + } + }, + "node_modules/@tanstack/query-core": { + "version": "4.29.11", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.29.11.tgz", + "integrity": "sha512-8C+hF6SFAb/TlFZyS9FItgNwrw4PMa7YeX+KQYe2ZAiEz6uzg6yIr+QBzPkUwZ/L0bXvGd1sufTm3wotoz+GwQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "4.29.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.29.12.tgz", + "integrity": "sha512-zhcN6+zF6cxprxhTHQajHGlvxgK8npnp9uLe9yaWhGc6sYcPWXzyO4raL4HomUzQOPzu3jLvkriJQ7BOrDM8vA==", + "dependencies": { + "@tanstack/query-core": "4.29.11", + "use-sync-external-store": "^1.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "4.29.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.29.12.tgz", + "integrity": "sha512-ug4YGQhMhh6QI8/sWJhjXxuvdeehxf1cyxpTifGMH5qreQ5ECHT6vzqG/aKvADQDzqLBGrF0q4wTDnRRYvvtrA==", + "dependencies": { + "@tanstack/match-sorter-utils": "^8.7.0", + "superjson": "^1.10.0", + "use-sync-external-store": "^1.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "4.29.12", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -5880,6 +5951,20 @@ "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=", "dev": true }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/copy-descriptor": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", @@ -10460,6 +10545,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-what": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.13.tgz", + "integrity": "sha512-Aoe8pT24sWzyoO0S2PTDyutGp9l7qYHyFtzYlC8hMLshyqV/minljBANT4f2hiS5OxnWvcKMiA5io+VaLMJ1oA==", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/is-whitespace-character": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz", @@ -14466,6 +14562,11 @@ "node": ">= 0.10" } }, + "node_modules/remove-accents": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", + "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==" + }, "node_modules/renderkid": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", @@ -18396,6 +18497,17 @@ "node": ">=6" } }, + "node_modules/superjson": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-1.12.3.tgz", + "integrity": "sha512-0j+U70KUtP8+roVPbwfqkyQI7lBt7ETnuA7KXbTDX3mCKiD/4fXs2ldKSMdt0MCfpTwiMxo20yFU3vu6ewETpQ==", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -19430,6 +19542,14 @@ "node": ">=0.10.0" } }, + "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", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -22627,6 +22747,38 @@ "string.prototype.matchall": "^4.0.6" } }, + "@tanstack/match-sorter-utils": { + "version": "8.8.4", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.8.4.tgz", + "integrity": "sha512-rKH8LjZiszWEvmi01NR72QWZ8m4xmXre0OOwlRGnjU01Eqz/QnN+cqpty2PJ0efHblq09+KilvyR7lsbzmXVEw==", + "requires": { + "remove-accents": "0.4.2" + } + }, + "@tanstack/query-core": { + "version": "4.29.11", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.29.11.tgz", + "integrity": "sha512-8C+hF6SFAb/TlFZyS9FItgNwrw4PMa7YeX+KQYe2ZAiEz6uzg6yIr+QBzPkUwZ/L0bXvGd1sufTm3wotoz+GwQ==" + }, + "@tanstack/react-query": { + "version": "4.29.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.29.12.tgz", + "integrity": "sha512-zhcN6+zF6cxprxhTHQajHGlvxgK8npnp9uLe9yaWhGc6sYcPWXzyO4raL4HomUzQOPzu3jLvkriJQ7BOrDM8vA==", + "requires": { + "@tanstack/query-core": "4.29.11", + "use-sync-external-store": "^1.2.0" + } + }, + "@tanstack/react-query-devtools": { + "version": "4.29.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.29.12.tgz", + "integrity": "sha512-ug4YGQhMhh6QI8/sWJhjXxuvdeehxf1cyxpTifGMH5qreQ5ECHT6vzqG/aKvADQDzqLBGrF0q4wTDnRRYvvtrA==", + "requires": { + "@tanstack/match-sorter-utils": "^8.7.0", + "superjson": "^1.10.0", + "use-sync-external-store": "^1.2.0" + } + }, "@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -24508,6 +24660,14 @@ "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=", "dev": true }, + "copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "requires": { + "is-what": "^4.1.8" + } + }, "copy-descriptor": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", @@ -27907,6 +28067,11 @@ "get-intrinsic": "^1.1.1" } }, + "is-what": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.13.tgz", + "integrity": "sha512-Aoe8pT24sWzyoO0S2PTDyutGp9l7qYHyFtzYlC8hMLshyqV/minljBANT4f2hiS5OxnWvcKMiA5io+VaLMJ1oA==" + }, "is-whitespace-character": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz", @@ -30779,6 +30944,11 @@ "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=", "dev": true }, + "remove-accents": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", + "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==" + }, "renderkid": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", @@ -33876,6 +34046,14 @@ } } }, + "superjson": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-1.12.3.tgz", + "integrity": "sha512-0j+U70KUtP8+roVPbwfqkyQI7lBt7ETnuA7KXbTDX3mCKiD/4fXs2ldKSMdt0MCfpTwiMxo20yFU3vu6ewETpQ==", + "requires": { + "copy-anything": "^3.0.2" + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -34626,6 +34804,12 @@ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "dev": true }, + "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", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "requires": {} + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 0a3d6f7e62..fe5c1b6d98 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,8 @@ "@loadable/component": "5.15.3", "@mui/icons-material": "5.11.16", "@mui/material": "5.13.3", + "@tanstack/react-query": "4.29.12", + "@tanstack/react-query-devtools": "4.29.12", "blurhash": "2.0.5", "classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz", "classnames": "2.3.2", diff --git a/src/RootApp.tsx b/src/RootApp.tsx index a44e25d565..62223f7236 100644 --- a/src/RootApp.tsx +++ b/src/RootApp.tsx @@ -1,6 +1,8 @@ import loadable from '@loadable/component'; import { History } from '@remix-run/router'; import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import StableApp from './apps/stable/App'; import { HistoryRouter } from './components/router/HistoryRouter'; @@ -9,21 +11,26 @@ import { WebConfigProvider } from './hooks/useWebConfig'; const ExperimentalApp = loadable(() => import('./apps/experimental/App')); +const queryClient = new QueryClient(); + const RootApp = ({ history }: { history: History }) => { const layoutMode = localStorage.getItem('layout'); return ( - - - - { - layoutMode === 'experimental' ? - : - - } - - - + + + + + { + layoutMode === 'experimental' ? + : + + } + + + + + ); }; diff --git a/src/apps/experimental/components/library/GenresItemsContainer.tsx b/src/apps/experimental/components/library/GenresItemsContainer.tsx new file mode 100644 index 0000000000..52968f5f16 --- /dev/null +++ b/src/apps/experimental/components/library/GenresItemsContainer.tsx @@ -0,0 +1,52 @@ +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( + parentId, + itemType + ); + + if (isLoading) { + return ; + } + + return ( + <> + {!genresResult?.Items?.length ? ( +
+

{globalize.translate('MessageNothingHere')}

+

{globalize.translate('MessageNoGenresAvailable')}

+
+ ) : ( + genresResult?.Items + && 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 new file mode 100644 index 0000000000..74f57782b1 --- /dev/null +++ b/src/apps/experimental/components/library/GenresSectionContainer.tsx @@ -0,0 +1,79 @@ +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/RecommendationContainer.tsx b/src/apps/experimental/components/library/RecommendationContainer.tsx new file mode 100644 index 0000000000..f91ed8c223 --- /dev/null +++ b/src/apps/experimental/components/library/RecommendationContainer.tsx @@ -0,0 +1,61 @@ +import { RecommendationDto, RecommendationType } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC } from 'react'; + +import globalize from 'scripts/globalize'; +import escapeHTML from 'escape-html'; +import SectionContainer from './SectionContainer'; + +interface RecommendationContainerProps { + recommendation?: RecommendationDto; +} + +const RecommendationContainer: FC = ({ + recommendation = {} +}) => { + let title = ''; + + switch (recommendation.RecommendationType) { + case RecommendationType.SimilarToRecentlyPlayed: + title = globalize.translate( + 'RecommendationBecauseYouWatched', + recommendation.BaselineItemName + ); + break; + + case RecommendationType.SimilarToLikedItem: + title = globalize.translate( + 'RecommendationBecauseYouLike', + recommendation.BaselineItemName + ); + break; + + case RecommendationType.HasDirectorFromRecentlyPlayed: + case RecommendationType.HasLikedDirector: + title = globalize.translate( + 'RecommendationDirectedBy', + recommendation.BaselineItemName + ); + break; + + case RecommendationType.HasActorFromRecentlyPlayed: + case RecommendationType.HasLikedActor: + title = globalize.translate( + 'RecommendationStarring', + recommendation.BaselineItemName + ); + break; + } + + return ( + + ); +}; + +export default RecommendationContainer; diff --git a/src/apps/experimental/components/library/SectionContainer.tsx b/src/apps/experimental/components/library/SectionContainer.tsx new file mode 100644 index 0000000000..325c950a03 --- /dev/null +++ b/src/apps/experimental/components/library/SectionContainer.tsx @@ -0,0 +1,73 @@ +import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useEffect, useRef } from 'react'; + +import cardBuilder from 'components/cardbuilder/cardBuilder'; +import ItemsContainerElement from 'elements/ItemsContainerElement'; +import Scroller from 'elements/emby-scroller/Scroller'; +import LinkButton from 'elements/emby-button/LinkButton'; +import imageLoader from 'components/images/imageLoader'; + +import { CardOptions } from 'types/cardOptions'; + +interface SectionContainerProps { + url?: string; + sectionTitle: string; + items: BaseItemDto[]; + cardOptions: CardOptions; +} + +const SectionContainer: FC = ({ + sectionTitle, + url, + items, + cardOptions +}) => { + const element = useRef(null); + + useEffect(() => { + const itemsContainer = element.current?.querySelector('.itemsContainer'); + cardBuilder.buildCards(items, { + itemsContainer: itemsContainer, + parentContainer: element.current, + + ...cardOptions + }); + + imageLoader.lazyChildren(itemsContainer); + }, [cardOptions, items]); + + return ( +
+
+ {url && items.length > 5 ? ( + +

+ {sectionTitle} +

+ +
+ ) : ( +

+ {sectionTitle} +

+ )} +
+ + + + +
+ ); +}; + +export default SectionContainer; diff --git a/src/apps/experimental/components/library/SuggestionsItemsContainer.tsx b/src/apps/experimental/components/library/SuggestionsItemsContainer.tsx new file mode 100644 index 0000000000..d985592a8e --- /dev/null +++ b/src/apps/experimental/components/library/SuggestionsItemsContainer.tsx @@ -0,0 +1,206 @@ +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 React, { FC } from 'react'; +import * as userSettings from 'scripts/settings/userSettings'; +import SuggestionsSectionContainer from './SuggestionsSectionContainer'; +import { Sections, SectionsView, SectionsViewType } from 'types/suggestionsSections'; + +const getSuggestionsSections = (): Sections[] => { + return [ + { + name: 'HeaderContinueWatching', + viewType: SectionsViewType.ResumeItems, + type: 'Movie', + view: SectionsView.ContinueWatchingMovies, + parametersOptions: { + includeItemTypes: [BaseItemKind.Movie] + }, + cardOptions: { + scalable: true, + overlayPlayButton: true, + showTitle: true, + centerText: true, + cardLayout: false, + preferThumb: true, + shape: 'overflowBackdrop', + showYear: true + } + }, + { + name: 'HeaderLatestMovies', + viewType: SectionsViewType.LatestMedia, + type: 'Movie', + view: SectionsView.LatestMovies, + parametersOptions: { + includeItemTypes: [BaseItemKind.Movie] + }, + cardOptions: { + scalable: true, + overlayPlayButton: true, + showTitle: true, + centerText: true, + cardLayout: false, + shape: 'overflowPortrait', + showYear: true + } + }, + { + name: 'HeaderContinueWatching', + viewType: SectionsViewType.ResumeItems, + type: 'Episode', + view: SectionsView.ContinueWatchingEpisode, + parametersOptions: { + includeItemTypes: [BaseItemKind.Episode] + }, + cardOptions: { + scalable: true, + overlayPlayButton: true, + showTitle: true, + centerText: true, + cardLayout: false, + shape: 'overflowBackdrop', + preferThumb: true, + inheritThumb: + !userSettings.useEpisodeImagesInNextUpAndResume(undefined), + showYear: true + } + }, + { + name: 'HeaderLatestEpisodes', + viewType: SectionsViewType.LatestMedia, + type: 'Episode', + view: SectionsView.LatestEpisode, + parametersOptions: { + includeItemTypes: [BaseItemKind.Episode] + }, + cardOptions: { + scalable: true, + overlayPlayButton: true, + showTitle: true, + centerText: true, + cardLayout: false, + shape: 'overflowBackdrop', + preferThumb: true, + showSeriesYear: true, + showParentTitle: true, + overlayText: false, + showUnplayedIndicator: false, + showChildCountIndicator: true, + lazy: true, + lines: 2 + } + }, + { + name: 'NextUp', + viewType: SectionsViewType.NextUp, + type: 'nextup', + view: SectionsView.NextUp, + cardOptions: { + scalable: true, + overlayPlayButton: true, + showTitle: true, + centerText: true, + cardLayout: false, + shape: 'overflowBackdrop', + preferThumb: true, + inheritThumb: + !userSettings.useEpisodeImagesInNextUpAndResume(undefined), + showParentTitle: true, + overlayText: false + } + }, + { + name: 'HeaderLatestMusic', + viewType: SectionsViewType.LatestMedia, + type: 'Audio', + view: SectionsView.LatestMusic, + parametersOptions: { + includeItemTypes: [BaseItemKind.Audio] + }, + cardOptions: { + showUnplayedIndicator: false, + shape: 'overflowSquare', + showTitle: true, + showParentTitle: true, + lazy: true, + centerText: true, + overlayPlayButton: true, + cardLayout: false, + coverImage: true + } + }, + { + name: 'HeaderRecentlyPlayed', + type: 'Audio', + view: SectionsView.RecentlyPlayedMusic, + parametersOptions: { + sortBy: [ItemSortBy.DatePlayed], + sortOrder: [SortOrder.Descending], + includeItemTypes: [BaseItemKind.Audio] + }, + cardOptions: { + showUnplayedIndicator: false, + shape: 'overflowSquare', + showTitle: true, + showParentTitle: true, + action: 'instantmix', + lazy: true, + centerText: true, + overlayMoreButton: true, + cardLayout: false, + coverImage: true + } + }, + { + name: 'HeaderFrequentlyPlayed', + type: 'Audio', + view: SectionsView.FrequentlyPlayedMusic, + parametersOptions: { + sortBy: [ItemSortBy.PlayCount], + sortOrder: [SortOrder.Descending], + includeItemTypes: [BaseItemKind.Audio] + }, + cardOptions: { + showUnplayedIndicator: false, + shape: 'overflowSquare', + showTitle: true, + showParentTitle: true, + action: 'instantmix', + lazy: true, + centerText: true, + overlayMoreButton: true, + cardLayout: false, + coverImage: true + } + } + ]; +}; + +interface SuggestionsItemsContainerProps { + parentId?: string | null; + sectionsView: SectionsView[]; +} + +const SuggestionsItemsContainer: FC = ({ + parentId, + sectionsView +}) => { + const suggestionsSections = getSuggestionsSections(); + + return ( + <> + {suggestionsSections + .filter((section) => sectionsView.includes(section.view)) + .map((section) => ( + + ))} + + ); +}; + +export default SuggestionsItemsContainer; diff --git a/src/apps/experimental/components/library/SuggestionsSectionContainer.tsx b/src/apps/experimental/components/library/SuggestionsSectionContainer.tsx new file mode 100644 index 0000000000..4c52d712e1 --- /dev/null +++ b/src/apps/experimental/components/library/SuggestionsSectionContainer.tsx @@ -0,0 +1,49 @@ +import React, { FC } from 'react'; +import { useGetItemsBySectionType } from 'hooks/useFetchItems'; +import globalize from 'scripts/globalize'; + +import Loading from 'components/loading/LoadingComponent'; +import { appRouter } from 'components/router/appRouter'; +import SectionContainer from './SectionContainer'; + +import { Sections } from 'types/suggestionsSections'; + +interface SuggestionsSectionContainerProps { + parentId?: string | null; + section: Sections; +} + +const SuggestionsSectionContainer: FC = ({ + parentId, + section +}) => { + const getRouteUrl = () => { + return appRouter.getRouteUrl('list', { + serverId: window.ApiClient.serverId(), + itemTypes: section.type, + parentId: parentId + }); + }; + + const { isLoading, data: items } = useGetItemsBySectionType( + section, + parentId + ); + + if (isLoading) { + return ; + } + + return ( + + ); +}; + +export default SuggestionsSectionContainer; diff --git a/src/apps/experimental/routes/movies/CollectionsView.tsx b/src/apps/experimental/routes/movies/CollectionsView.tsx index b58cc957e5..ef574b916e 100644 --- a/src/apps/experimental/routes/movies/CollectionsView.tsx +++ b/src/apps/experimental/routes/movies/CollectionsView.tsx @@ -1,9 +1,9 @@ import React, { FC, useCallback } from 'react'; -import ViewItemsContainer from '../../../../components/common/ViewItemsContainer'; -import { LibraryViewProps } from '../../../../types/interface'; +import ViewItemsContainer from 'components/common/ViewItemsContainer'; +import { LibraryViewProps } from 'types/library'; -const CollectionsView: FC = ({ topParentId }) => { +const CollectionsView: FC = ({ parentId }) => { const getBasekey = useCallback(() => { return 'collections'; }, []); @@ -18,7 +18,7 @@ const CollectionsView: FC = ({ topParentId }) => { return ( = ({ topParentId }) => { +const FavoritesView: FC = ({ parentId }) => { const getBasekey = useCallback(() => { return 'favorites'; }, []); @@ -18,7 +18,7 @@ const FavoritesView: FC = ({ topParentId }) => { return ( = ({ topParentId }) => { - const [ itemsResult, setItemsResult ] = useState({}); - - const reloadItems = useCallback(() => { - loading.show(); - window.ApiClient.getGenres( - window.ApiClient.getCurrentUserId(), - { - SortBy: 'SortName', - SortOrder: 'Ascending', - IncludeItemTypes: 'Movie', - Recursive: true, - EnableTotalRecordCount: false, - ParentId: topParentId - } - ).then((result) => { - setItemsResult(result); - loading.hide(); - }).catch(err => { - console.error('[GenresView] failed to fetch genres', err); - }); - }, [topParentId]); - - useEffect(() => { - reloadItems(); - }, [reloadItems]); +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 ( ); }; diff --git a/src/apps/experimental/routes/movies/MoviesView.tsx b/src/apps/experimental/routes/movies/MoviesView.tsx index 510ed9e2b2..8796c9a711 100644 --- a/src/apps/experimental/routes/movies/MoviesView.tsx +++ b/src/apps/experimental/routes/movies/MoviesView.tsx @@ -1,9 +1,9 @@ import React, { FC, useCallback } from 'react'; -import ViewItemsContainer from '../../../../components/common/ViewItemsContainer'; -import { LibraryViewProps } from '../../../../types/interface'; +import ViewItemsContainer from 'components/common/ViewItemsContainer'; +import { LibraryViewProps } from 'types/library'; -const MoviesView: FC = ({ topParentId }) => { +const MoviesView: FC = ({ parentId }) => { const getBasekey = useCallback(() => { return 'movies'; }, []); @@ -18,7 +18,7 @@ const MoviesView: FC = ({ topParentId }) => { return ( = ({ topParentId }) => { - const [ latestItems, setLatestItems ] = useState([]); - const [ resumeResult, setResumeResult ] = useState({}); - const [ recommendations, setRecommendations ] = useState([]); - const element = useRef(null); +const SuggestionsView: FC = ({ parentId }) => { + const { + isLoading, + data: movieRecommendationsItems + } = useGetMovieRecommendations(parentId); - const enableScrollX = useCallback(() => { - return !layoutManager.desktop; - }, []); - - const getPortraitShape = useCallback(() => { - return enableScrollX() ? 'overflowPortrait' : 'portrait'; - }, [enableScrollX]); - - const getThumbShape = useCallback(() => { - return enableScrollX() ? 'overflowBackdrop' : 'backdrop'; - }, [enableScrollX]); - - const autoFocus = useCallback((page) => { - import('../../../../components/autoFocuser').then(({ default: autoFocuser }) => { - autoFocuser.autoFocus(page); - }).catch(err => { - console.error('[SuggestionsView] failed to load data', err); - }); - }, []); - - const loadResume = useCallback((page, userId, parentId) => { - loading.show(); - const screenWidth = dom.getWindowSize().innerWidth; - const options = { - SortBy: 'DatePlayed', - SortOrder: 'Descending', - IncludeItemTypes: 'Movie', - Filters: 'IsResumable', - Limit: screenWidth >= 1600 ? 5 : 3, - Recursive: true, - Fields: 'PrimaryImageAspectRatio,MediaSourceCount,BasicSyncInfo', - CollapseBoxSetItems: false, - ParentId: parentId, - ImageTypeLimit: 1, - EnableImageTypes: 'Primary,Backdrop,Banner,Thumb', - EnableTotalRecordCount: false - }; - window.ApiClient.getItems(userId, options).then(result => { - setResumeResult(result); - - loading.hide(); - autoFocus(page); - }).catch(err => { - console.error('[SuggestionsView] failed to fetch items', err); - }); - }, [autoFocus]); - - const loadLatest = useCallback((page: HTMLDivElement, userId: string, parentId: string | null) => { - const options = { - IncludeItemTypes: 'Movie', - Limit: 18, - Fields: 'PrimaryImageAspectRatio,MediaSourceCount,BasicSyncInfo', - ParentId: parentId, - ImageTypeLimit: 1, - EnableImageTypes: 'Primary,Backdrop,Banner,Thumb', - EnableTotalRecordCount: false - }; - window.ApiClient.getJSON(window.ApiClient.getUrl('Users/' + userId + '/Items/Latest', options)).then(items => { - setLatestItems(items); - - autoFocus(page); - }).catch(err => { - console.error('[SuggestionsView] failed to fetch latest items', err); - }); - }, [autoFocus]); - - const loadSuggestions = useCallback((page, userId) => { - const screenWidth = dom.getWindowSize().innerWidth; - let itemLimit = 5; - if (screenWidth >= 1600) { - itemLimit = 8; - } else if (screenWidth >= 1200) { - itemLimit = 6; - } - const url = window.ApiClient.getUrl('Movies/Recommendations', { - userId: userId, - categoryLimit: 6, - ItemLimit: itemLimit, - Fields: 'PrimaryImageAspectRatio,MediaSourceCount,BasicSyncInfo', - ImageTypeLimit: 1, - EnableImageTypes: 'Primary,Backdrop,Banner,Thumb' - }); - window.ApiClient.getJSON(url).then(result => { - setRecommendations(result); - - autoFocus(page); - }).catch(err => { - console.error('[SuggestionsView] failed to fetch recommendations', err); - }); - }, [autoFocus]); - - const loadSuggestionsTab = useCallback((view) => { - const parentId = topParentId; - const userId = window.ApiClient.getCurrentUserId(); - loadResume(view, userId, parentId); - loadLatest(view, userId, parentId); - loadSuggestions(view, userId); - }, [loadLatest, loadResume, loadSuggestions, topParentId]); - - useEffect(() => { - const page = element.current; - - if (!page) { - console.error('Unexpected null reference'); - return; - } - - loadSuggestionsTab(page); - }, [loadSuggestionsTab]); + if (isLoading) { + return ; + } return ( -
- + - - - {!recommendations.length ?
-

{globalize.translate('MessageNothingHere')}

-

{globalize.translate('MessageNoMovieSuggestionsAvailable')}

-
: recommendations.map(recommendation => { - return ; - })} -
+ {!movieRecommendationsItems?.length ? ( +
+

{globalize.translate('MessageNothingHere')}

+

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

+
+ ) : ( + movieRecommendationsItems.map((recommendation, index) => { + return ( + + ); + }) + )} + ); }; diff --git a/src/apps/experimental/routes/movies/TrailersView.tsx b/src/apps/experimental/routes/movies/TrailersView.tsx index 55f6189cfc..ff0ff0e73e 100644 --- a/src/apps/experimental/routes/movies/TrailersView.tsx +++ b/src/apps/experimental/routes/movies/TrailersView.tsx @@ -1,10 +1,10 @@ import React, { FC, useCallback } from 'react'; -import ViewItemsContainer from '../../../../components/common/ViewItemsContainer'; -import { LibraryViewProps } from '../../../../types/interface'; +import ViewItemsContainer from 'components/common/ViewItemsContainer'; +import { LibraryViewProps } from 'types/library'; -const TrailersView: FC = ({ topParentId }) => { +const TrailersView: FC = ({ parentId }) => { const getBasekey = useCallback(() => { return 'trailers'; }, []); @@ -19,7 +19,7 @@ const TrailersView: FC = ({ topParentId }) => { return ( { 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, searchParams.get('topParentId')); - const element = useRef(null); + getDefaultTabIndex(location.pathname, searchParamsParentId); const getTabComponent = (index: number) => { if (index == null) { @@ -32,72 +30,41 @@ const Movies: FC = () => { let component; switch (index) { - case 0: - component = ; - break; - case 1: - component = ; + component = ; break; case 2: - component = ; + component = ; break; case 3: - component = ; + component = ; break; case 4: - component = ; + component = ; break; case 5: - component = ; + component = ; break; + default: + component = ; } return component; }; - useEffect(() => { - const page = element.current; - - if (!page) { - console.error('Unexpected null reference'); - return; - } - - if (!page.getAttribute('data-title')) { - const parentId = searchParams.get('topParentId'); - - if (parentId) { - window.ApiClient.getItem(window.ApiClient.getCurrentUserId(), parentId).then((item) => { - page.setAttribute('data-title', item.Name as string); - libraryMenu.setTitle(item.Name); - }).catch(err => { - console.error('[movies] failed to fetch library', err); - page.setAttribute('data-title', globalize.translate('Movies')); - libraryMenu.setTitle(globalize.translate('Movies')); - }); - } else { - page.setAttribute('data-title', globalize.translate('Movies')); - libraryMenu.setTitle(globalize.translate('Movies')); - } - } - }, [ searchParams ]); - return ( -
- - {getTabComponent(currentTabIndex)} + + {getTabComponent(currentTabIndex)} - -
+ ); }; diff --git a/src/components/common/GenresItemsContainer.tsx b/src/components/common/GenresItemsContainer.tsx deleted file mode 100644 index 09623e7e57..0000000000 --- a/src/components/common/GenresItemsContainer.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import '../../elements/emby-button/emby-button'; -import '../../elements/emby-itemscontainer/emby-itemscontainer'; - -import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'; -import escapeHTML from 'escape-html'; -import React, { FC, useCallback, useEffect, useRef } from 'react'; - -import { appRouter } from '../router/appRouter'; -import cardBuilder from '../cardbuilder/cardBuilder'; -import layoutManager from '../layoutManager'; -import lazyLoader from '../lazyLoader/lazyLoaderIntersectionObserver'; -import globalize from '../../scripts/globalize'; -import ItemsScrollerContainerElement from '../../elements/ItemsScrollerContainerElement'; -import ItemsContainerElement from '../../elements/ItemsContainerElement'; - -const createLinkElement = ({ className, title, href }: { className?: string, title?: string | null, href?: string }) => ({ - __html: ` -

- ${title} -

- -
` -}); - -interface GenresItemsContainerProps { - topParentId?: string | null; - itemsResult: BaseItemDtoQueryResult; -} - -const GenresItemsContainer: FC = ({ - topParentId, - itemsResult = {} -}) => { - const element = useRef(null); - - const enableScrollX = useCallback(() => { - return !layoutManager.desktop; - }, []); - - const getPortraitShape = useCallback(() => { - return enableScrollX() ? 'overflowPortrait' : 'portrait'; - }, [enableScrollX]); - - const fillItemsContainer = useCallback((entry) => { - const elem = entry.target; - const id = elem.getAttribute('data-id'); - - const query = { - SortBy: 'Random', - SortOrder: 'Ascending', - IncludeItemTypes: 'Movie', - Recursive: true, - Fields: 'PrimaryImageAspectRatio,MediaSourceCount,BasicSyncInfo', - ImageTypeLimit: 1, - EnableImageTypes: 'Primary', - Limit: 12, - GenreIds: id, - EnableTotalRecordCount: false, - ParentId: topParentId - }; - window.ApiClient.getItems(window.ApiClient.getCurrentUserId(), query).then((result) => { - cardBuilder.buildCards(result.Items || [], { - itemsContainer: elem, - shape: getPortraitShape(), - scalable: true, - overlayMoreButton: true, - allowBottomPadding: true, - showTitle: true, - centerText: true, - showYear: true - }); - }).catch(err => { - console.error('[GenresItemsContainer] failed to fetch items', err); - }); - }, [getPortraitShape, topParentId]); - - useEffect(() => { - const elem = element.current; - lazyLoader.lazyChildren(elem, fillItemsContainer); - }, [itemsResult.Items, fillItemsContainer]); - - const items = itemsResult.Items || []; - return ( -
- { - !items.length ? ( -
-

{globalize.translate('MessageNothingHere')}

-

{globalize.translate('MessageNoGenresAvailable')}

-
- ) : items.map(item => ( -
-
- - {enableScrollX() ? - : - } -
- )) - } -
- ); -}; - -export default GenresItemsContainer; diff --git a/src/components/common/RecommendationContainer.tsx b/src/components/common/RecommendationContainer.tsx deleted file mode 100644 index 6c28272c9f..0000000000 --- a/src/components/common/RecommendationContainer.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { RecommendationDto } from '@jellyfin/sdk/lib/generated-client'; -import React, { FC } from 'react'; - -import globalize from '../../scripts/globalize'; -import escapeHTML from 'escape-html'; -import SectionContainer from './SectionContainer'; - -interface RecommendationContainerProps { - getPortraitShape: () => string; - enableScrollX: () => boolean; - recommendation?: RecommendationDto; -} - -const RecommendationContainer: FC = ({ getPortraitShape, enableScrollX, recommendation = {} }) => { - let title = ''; - - switch (recommendation.RecommendationType) { - case 'SimilarToRecentlyPlayed': - title = globalize.translate('RecommendationBecauseYouWatched', recommendation.BaselineItemName); - break; - - case 'SimilarToLikedItem': - title = globalize.translate('RecommendationBecauseYouLike', recommendation.BaselineItemName); - break; - - case 'HasDirectorFromRecentlyPlayed': - case 'HasLikedDirector': - title = globalize.translate('RecommendationDirectedBy', recommendation.BaselineItemName); - break; - - case 'HasActorFromRecentlyPlayed': - case 'HasLikedActor': - title = globalize.translate('RecommendationStarring', recommendation.BaselineItemName); - break; - } - - return ; -}; - -export default RecommendationContainer; diff --git a/src/components/common/SectionContainer.tsx b/src/components/common/SectionContainer.tsx deleted file mode 100644 index 13c29ee61e..0000000000 --- a/src/components/common/SectionContainer.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import '../../elements/emby-itemscontainer/emby-itemscontainer'; - -import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; -import React, { FC, useEffect, useRef } from 'react'; - -import cardBuilder from '../cardbuilder/cardBuilder'; -import ItemsContainerElement from '../../elements/ItemsContainerElement'; -import ItemsScrollerContainerElement from '../../elements/ItemsScrollerContainerElement'; -import { CardOptions } from '../../types/interface'; - -interface SectionContainerProps { - sectionTitle: string; - enableScrollX: () => boolean; - items?: BaseItemDto[]; - cardOptions?: CardOptions; -} - -const SectionContainer: FC = ({ - sectionTitle, - enableScrollX, - items = [], - cardOptions = {} -}) => { - const element = useRef(null); - - useEffect(() => { - cardBuilder.buildCards(items, { - itemsContainer: element.current?.querySelector('.itemsContainer'), - parentContainer: element.current?.querySelector('.verticalSection'), - scalable: true, - overlayPlayButton: true, - showTitle: true, - centerText: true, - cardLayout: false, - ...cardOptions - }); - }, [cardOptions, enableScrollX, items]); - - return ( -
-
-
-

- {sectionTitle} -

-
- - {enableScrollX() ? : } - -
-
- ); -}; - -export default SectionContainer; diff --git a/src/components/common/ViewItemsContainer.tsx b/src/components/common/ViewItemsContainer.tsx index 5cbc7aace6..e501ffaefc 100644 --- a/src/components/common/ViewItemsContainer.tsx +++ b/src/components/common/ViewItemsContainer.tsx @@ -12,12 +12,13 @@ import Shuffle from './Shuffle'; import Sort from './Sort'; import NewCollection from './NewCollection'; import globalize from '../../scripts/globalize'; -import { CardOptions, ViewQuerySettings } from '../../types/interface'; 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; diff --git a/src/hooks/useApi.tsx b/src/hooks/useApi.tsx index ff94b7a537..a1eb4b624d 100644 --- a/src/hooks/useApi.tsx +++ b/src/hooks/useApi.tsx @@ -7,7 +7,7 @@ import ServerConnections from '../components/ServerConnections'; import events from '../utils/events'; import { toApi } from '../utils/jellyfin-apiclient/compat'; -interface JellyfinApiContext { +export interface JellyfinApiContext { __legacyApiClient__?: ApiClient api?: Api user?: UserDto diff --git a/src/hooks/useFetchItems.ts b/src/hooks/useFetchItems.ts new file mode 100644 index 0000000000..bd24387e86 --- /dev/null +++ b/src/hooks/useFetchItems.ts @@ -0,0 +1,287 @@ +import type { ItemsApiGetItemsRequest } 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 { 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 { getTvShowsApi } from '@jellyfin/sdk/lib/utils/api/tv-shows-api'; +import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api/user-library-api'; +import { useQuery } from '@tanstack/react-query'; + +import { JellyfinApiContext, useApi } from './useApi'; +import { Sections, SectionsViewType } from 'types/suggestionsSections'; + +type ParentId = string | null | undefined; + +const fetchGetItem = async ( + currentApi: JellyfinApiContext, + parentId: ParentId, + options?: AxiosRequestConfig +) => { + const { api, user } = currentApi; + if (api && user?.Id && parentId) { + const response = await getUserLibraryApi(api).getItem( + { + userId: user.Id, + itemId: parentId + }, + { + signal: options?.signal + } + ); + return response.data; + } +}; + +export const useGetItem = (parentId: ParentId) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['Item', parentId], + queryFn: ({ signal }) => fetchGetItem(currentApi, parentId, { signal }), + enabled: !!parentId + }); +}; + +const fetchGetItems = async ( + currentApi: JellyfinApiContext, + parametersOptions: ItemsApiGetItemsRequest, + options?: AxiosRequestConfig +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + const response = await getItemsApi(api).getItems( + { + userId: user.Id, + ...parametersOptions + }, + { + signal: options?.signal + } + ); + return response.data; + } +}; + +export const useGetItems = (parametersOptions: ItemsApiGetItemsRequest) => { + const currentApi = useApi(); + return useQuery({ + queryKey: [ + 'Items', + { + ...parametersOptions + } + ], + queryFn: ({ signal }) => + fetchGetItems(currentApi, parametersOptions, { signal }) + }); +}; + +const fetchGetMovieRecommendations = async ( + currentApi: JellyfinApiContext, + parentId: ParentId, + options?: AxiosRequestConfig +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + const response = await getMoviesApi(api).getMovieRecommendations( + { + userId: user.Id, + fields: [ + ItemFields.PrimaryImageAspectRatio, + ItemFields.MediaSourceCount, + ItemFields.BasicSyncInfo + ], + parentId: parentId ?? undefined, + categoryLimit: 6, + itemLimit: 20 + }, + { + signal: options?.signal + } + ); + return response.data; + } +}; + +export const useGetMovieRecommendations = (parentId: ParentId) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['MovieRecommendations', parentId], + queryFn: ({ signal }) => + fetchGetMovieRecommendations(currentApi, parentId, { signal }), + enabled: !!parentId + }); +}; + +const fetchGetItemsBySuggestionsType = async ( + currentApi: JellyfinApiContext, + sections: Sections, + parentId: ParentId, + options?: AxiosRequestConfig +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + let response; + switch (sections.viewType) { + case SectionsViewType.NextUp: { + response = ( + await getTvShowsApi(api).getNextUp( + { + userId: user.Id, + limit: 25, + fields: [ + ItemFields.PrimaryImageAspectRatio, + ItemFields.MediaSourceCount, + ItemFields.BasicSyncInfo + ], + parentId: parentId ?? undefined, + imageTypeLimit: 1, + enableImageTypes: [ + ImageType.Primary, + ImageType.Backdrop, + ImageType.Thumb + ], + enableTotalRecordCount: false, + ...sections.parametersOptions + }, + { + signal: options?.signal + } + ) + ).data.Items; + break; + } + case SectionsViewType.ResumeItems: { + response = ( + await getItemsApi(api).getResumeItems( + { + userId: user?.Id, + parentId: parentId ?? undefined, + fields: [ + ItemFields.PrimaryImageAspectRatio, + ItemFields.MediaSourceCount, + ItemFields.BasicSyncInfo + ], + imageTypeLimit: 1, + enableImageTypes: [ImageType.Thumb], + enableTotalRecordCount: false, + ...sections.parametersOptions + }, + { + signal: options?.signal + } + ) + ).data.Items; + break; + } + case SectionsViewType.LatestMedia: { + response = ( + await getUserLibraryApi(api).getLatestMedia( + { + userId: user.Id, + fields: [ + ItemFields.PrimaryImageAspectRatio, + ItemFields.MediaSourceCount, + ItemFields.BasicSyncInfo + ], + parentId: parentId ?? undefined, + imageTypeLimit: 1, + enableImageTypes: [ImageType.Primary], + ...sections.parametersOptions + }, + { + signal: options?.signal + } + ) + ).data; + break; + } + default: { + response = ( + await getItemsApi(api).getItems( + { + userId: user.Id, + parentId: parentId ?? undefined, + recursive: true, + fields: [ItemFields.PrimaryImageAspectRatio], + filters: [ItemFilter.IsPlayed], + imageTypeLimit: 1, + enableImageTypes: [ + ImageType.Primary, + ImageType.Backdrop, + ImageType.Thumb + ], + limit: 25, + enableTotalRecordCount: false, + ...sections.parametersOptions + }, + { + signal: options?.signal + } + ) + ).data.Items; + break; + } + } + return response; + } +}; + +export const useGetItemsBySectionType = ( + sections: Sections, + parentId: ParentId +) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['ItemsBySuggestionsType', sections.view], + queryFn: ({ signal }) => + fetchGetItemsBySuggestionsType( + currentApi, + sections, + parentId, + { signal } + ), + enabled: !!sections.view + }); +}; + +const fetchGetGenres = async ( + currentApi: JellyfinApiContext, + parentId: ParentId, + itemType: BaseItemKind, + options?: AxiosRequestConfig +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + const response = await getGenresApi(api).getGenres( + { + userId: user.Id, + sortBy: [ItemSortBy.SortName], + sortOrder: [SortOrder.Ascending], + includeItemTypes: [itemType], + enableTotalRecordCount: false, + parentId: parentId ?? undefined + }, + { + signal: options?.signal + } + ); + return response.data; + } +}; + +export const useGetGenres = (parentId: ParentId, itemType: BaseItemKind) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['Genres', parentId], + queryFn: ({ signal }) => + fetchGetGenres(currentApi, parentId, itemType, { signal }), + enabled: !!parentId + }); +}; diff --git a/src/types/cardOptions.ts b/src/types/cardOptions.ts new file mode 100644 index 0000000000..7864ab3157 --- /dev/null +++ b/src/types/cardOptions.ts @@ -0,0 +1,74 @@ +import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; + +export interface CardOptions { + itemsContainer?: HTMLElement | null; + parentContainer?: HTMLElement | null; + items?: BaseItemDto[] | null; + allowBottomPadding?: boolean; + centerText?: boolean; + coverImage?: boolean; + inheritThumb?: boolean; + overlayMoreButton?: boolean; + overlayPlayButton?: boolean; + overlayText?: boolean; + preferThumb?: boolean; + preferDisc?: boolean; + preferLogo?: boolean; + scalable?: boolean; + shape?: string | null; + lazy?: boolean; + cardLayout?: boolean | string; + showParentTitle?: boolean; + showParentTitleOrTitle?: boolean; + showAirTime?: boolean; + showAirDateTime?: boolean; + showChannelName?: boolean; + showTitle?: boolean | string; + showYear?: boolean | string; + showDetailsMenu?: boolean; + missingIndicator?: boolean; + showLocationTypeIndicator?: boolean; + showSeriesYear?: boolean; + showUnplayedIndicator?: boolean; + showChildCountIndicator?: boolean; + lines?: number; + context?: string | null; + action?: string | null; + defaultShape?: string; + indexBy?: string; + parentId?: string | null; + showMenu?: boolean; + cardCssClass?: string | null; + cardClass?: string | null; + centerPlayButton?: boolean; + overlayInfoButton?: boolean; + autoUpdate?: boolean; + cardFooterAside?: string; + includeParentInfoInTitle?: boolean; + maxLines?: number; + overlayMarkPlayedButton?: boolean; + overlayRateButton?: boolean; + showAirEndTime?: boolean; + showCurrentProgram?: boolean; + showCurrentProgramTime?: boolean; + showItemCounts?: boolean; + showPersonRoleOrType?: boolean; + showProgressBar?: boolean; + showPremiereDate?: boolean; + showRuntime?: boolean; + showSeriesTimerTime?: boolean; + showSeriesTimerChannel?: boolean; + showSongCount?: boolean; + width?: number; + showChannelLogo?: boolean; + showLogo?: boolean; + serverId?: string; + collectionId?: string | null; + playlistId?: string | null; + defaultCardImageIcon?: string; + disableHoverMenu?: boolean; + disableIndicators?: boolean; + showGroupCount?: boolean; + containerClass?: string; + noItemsMessage?: string; +} diff --git a/src/types/interface.ts b/src/types/interface.ts index 67c6565e27..c577f84e2a 100644 --- a/src/types/interface.ts +++ b/src/types/interface.ts @@ -1,19 +1,3 @@ -import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; - -export interface Query extends ViewQuerySettings { - IncludeItemTypes?: string; - Recursive?: boolean; - Fields?: string | null; - ImageTypeLimit?: number; - EnableTotalRecordCount?: boolean; - EnableImageTypes?: string; - StartIndex?: number; - ParentId?: string | null; - IsMissing?: boolean | null; - Limit?:number; - Filters?: string | null; -} - export interface ViewQuerySettings { showTitle?: boolean; showYear?: boolean; @@ -43,80 +27,3 @@ export interface ViewQuerySettings { NameStartsWith?: string | null; StartIndex?: number; } - -export interface CardOptions { - itemsContainer?: HTMLElement | null; - parentContainer?: HTMLElement | null; - items?: BaseItemDto[] | null; - allowBottomPadding?: boolean; - centerText?: boolean; - coverImage?: boolean; - inheritThumb?: boolean; - overlayMoreButton?: boolean; - overlayPlayButton?: boolean; - overlayText?: boolean; - preferThumb?: boolean; - preferDisc?: boolean; - preferLogo?: boolean; - scalable?: boolean; - shape?: string | null; - lazy?: boolean; - cardLayout?: boolean | string; - showParentTitle?: boolean; - showParentTitleOrTitle?: boolean; - showAirTime?: boolean; - showAirDateTime?: boolean; - showChannelName?: boolean; - showTitle?: boolean | string; - showYear?: boolean | string; - showDetailsMenu?: boolean; - missingIndicator?: boolean; - showLocationTypeIndicator?: boolean; - showSeriesYear?: boolean; - showUnplayedIndicator?: boolean; - showChildCountIndicator?: boolean; - lines?: number; - context?: string | null; - action?: string | null; - defaultShape?: string; - indexBy?: string; - parentId?: string | null; - showMenu?: boolean; - cardCssClass?: string | null; - cardClass?: string | null; - centerPlayButton?: boolean; - overlayInfoButton?: boolean; - autoUpdate?: boolean; - cardFooterAside?: string; - includeParentInfoInTitle?: boolean; - maxLines?: number; - overlayMarkPlayedButton?: boolean; - overlayRateButton?: boolean; - showAirEndTime?: boolean; - showCurrentProgram?: boolean; - showCurrentProgramTime?: boolean; - showItemCounts?: boolean; - showPersonRoleOrType?: boolean; - showProgressBar?: boolean; - showPremiereDate?: boolean; - showRuntime?: boolean; - showSeriesTimerTime?: boolean; - showSeriesTimerChannel?: boolean; - showSongCount?: boolean; - width?: number; - showChannelLogo?: boolean; - showLogo?: boolean; - serverId?: string; - collectionId?: string | null; - playlistId?: string | null; - defaultCardImageIcon?: string; - disableHoverMenu?: boolean; - disableIndicators?: boolean; - showGroupCount?: boolean; - containerClass?: string; - noItemsMessage?: string; -} - -export interface LibraryViewProps { - topParentId: string | null; -} diff --git a/src/types/library.ts b/src/types/library.ts new file mode 100644 index 0000000000..2a25ca7197 --- /dev/null +++ b/src/types/library.ts @@ -0,0 +1,45 @@ +import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields'; +import { ItemFilter } from '@jellyfin/sdk/lib/generated-client/models/item-filter'; +import { VideoType } from '@jellyfin/sdk/lib/generated-client/models/video-type'; +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 { SeriesStatus } from '@jellyfin/sdk/lib/generated-client/models/series-status'; + +export interface ParametersOptions { + sortBy?: ItemSortBy[]; + sortOrder?: SortOrder[]; + includeItemTypes?: BaseItemKind[]; + fields?: ItemFields[]; + enableImageTypes?: ImageType[]; + videoTypes?: VideoType[]; + seriesStatus?: SeriesStatus[]; + filters?: ItemFilter[]; + limit?: number; + isFavorite?: boolean; + genres?: string[]; + officialRatings?: string[]; + tags?: string[]; + years?: number[]; + is4K?: boolean; + isHd?: boolean; + is3D?: boolean; + hasSubtitles?: boolean; + hasTrailer?: boolean; + hasSpecialFeature?: boolean; + hasThemeSong?: boolean; + hasThemeVideo?: boolean; + parentIndexNumber?: number; + isMissing?: boolean; + isUnaired?: boolean; + startIndex?: number; + nameLessThan?: string; + nameStartsWith?: string; + collapseBoxSetItems?: boolean; + enableTotalRecordCount?: boolean; +} + +export interface LibraryViewProps { + parentId: string | null; +} diff --git a/src/types/suggestionsSections.ts b/src/types/suggestionsSections.ts new file mode 100644 index 0000000000..e0e89edb5f --- /dev/null +++ b/src/types/suggestionsSections.ts @@ -0,0 +1,28 @@ +import { CardOptions } from './cardOptions'; +import { ParametersOptions } from './library'; + +export enum SectionsViewType { + ResumeItems = 'resumeItems', + LatestMedia = 'latestMedia', + NextUp = 'nextUp', +} + +export enum SectionsView { + ContinueWatchingMovies = 'continuewatchingmovies', + LatestMovies = 'latestmovies', + ContinueWatchingEpisode = 'continuewatchingepisode', + LatestEpisode = 'latestepisode', + NextUp = 'nextUp', + LatestMusic = 'latestmusic', + RecentlyPlayedMusic = 'recentlyplayedmusic', + FrequentlyPlayedMusic = 'frequentlyplayedmusic', +} + +export interface Sections { + name: string; + view: SectionsView; + type: string; + viewType?: SectionsViewType, + parametersOptions?: ParametersOptions; + cardOptions: CardOptions; +} diff --git a/webpack.common.js b/webpack.common.js index e1448db74f..b1fccfd5f1 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -161,12 +161,14 @@ const config = { } }, { - test: /\.(js|jsx)$/, + test: /\.(js|jsx|mjs)$/, include: [ path.resolve(__dirname, 'node_modules/event-target-polyfill'), path.resolve(__dirname, 'node_modules/rvfc-polyfill'), path.resolve(__dirname, 'node_modules/@jellyfin/sdk'), path.resolve(__dirname, 'node_modules/@remix-run/router'), + path.resolve(__dirname, 'node_modules/@tanstack/query-core'), + path.resolve(__dirname, 'node_modules/@tanstack/react-query'), path.resolve(__dirname, 'node_modules/@uupaa/dynamic-import-polyfill'), path.resolve(__dirname, 'node_modules/axios'), path.resolve(__dirname, 'node_modules/blurhash'),