From 331f991c8b3680444314ccc183cf8aefd057aa67 Mon Sep 17 00:00:00 2001 From: grafixeyehero Date: Thu, 25 May 2023 05:12:20 +0300 Subject: [PATCH] Add Library View --- package-lock.json | 206 +++ package.json | 3 + src/apps/experimental/App.tsx | 129 +- .../components/library/LibraryHeader.tsx | 244 ++++ .../library/NewCollectionButton.tsx | 34 + .../components/library/Pagination.tsx | 87 ++ .../components/library/PlayAllButton.tsx | 44 + .../components/library/ShuffleButton.tsx | 43 + .../components/library/SortButton.tsx | 193 +++ .../components/library/ViewSettingsButton.tsx | 207 +++ .../container}/AlphaPickerContainer.tsx | 18 +- .../container/FavoriteItemsContainer.tsx | 228 +++ .../container/FavoritesSectionContainer.tsx | 50 + .../container/LibraryItemsContainer.tsx | 136 ++ .../container/RecommendationContainer.tsx | 61 + .../RecommendationItemsContainer.tsx | 60 + .../library/container/SectionContainer.tsx | 74 + .../container/SuggestionsItemsContainer.tsx | 205 +++ .../container/SuggestionsSectionContainer.tsx | 48 + .../container/UpComingItemsContainer.tsx | 87 ++ .../library/filter/FilterButton.tsx | 450 ++++++ .../library/filter/FiltersFeatures.tsx | 83 ++ .../library/filter/FiltersGenres.tsx | 72 + .../library/filter/FiltersOfficialRatings.tsx | 72 + .../library/filter/FiltersSeriesStatus.tsx | 77 ++ .../library/filter/FiltersStatus.tsx | 88 ++ .../library/filter/FiltersStudios.tsx | 73 + .../components/library/filter/FiltersTags.tsx | 72 + .../library/filter/FiltersVideoTypes.tsx | 83 ++ .../library/filter/FiltersYears.tsx | 71 + .../experimental/components/tabs/tabRoutes.ts | 113 -- .../experimental/routes/asyncRoutes/user.ts | 6 +- .../routes/library/LibraryView.tsx | 90 ++ .../routes/library/SuggestionsView.tsx | 76 + .../routes/library/UpComingView.tsx | 50 + .../experimental/routes/library/index.tsx | 155 +++ .../routes/movies/CollectionsView.tsx | 32 - .../routes/movies/FavoritesView.tsx | 29 - .../experimental/routes/movies/GenresView.tsx | 43 - .../experimental/routes/movies/MoviesView.tsx | 30 - .../routes/movies/SuggestionsView.tsx | 161 --- .../routes/movies/TrailersView.tsx | 30 - src/apps/experimental/routes/movies/index.tsx | 104 -- src/components/common/Filter.tsx | 65 - .../common/GenresItemsContainer.tsx | 128 -- src/components/common/ItemsContainer.tsx | 33 - src/components/common/NewCollection.tsx | 42 - src/components/common/Pagination.tsx | 97 -- .../common/RecommendationContainer.tsx | 48 - src/components/common/SectionContainer.tsx | 62 - src/components/common/SelectView.tsx | 54 - src/components/common/Shuffle.tsx | 45 - src/components/common/Sort.tsx | 58 - src/components/common/ViewItemsContainer.tsx | 409 ------ .../homeScreenSettings/homeScreenSettings.js | 5 +- src/components/router/appRouter.js | 18 +- src/hooks/useApi.tsx | 2 +- src/hooks/useFetchItems.ts | 1228 +++++++++++++++++ src/hooks/useLibrarySettings.tsx | 79 ++ src/hooks/useLocalStorage.tsx | 20 - src/strings/en-us.json | 12 + src/types/cardOptions.ts | 74 + src/types/library.ts | 67 + src/types/sections.ts | 11 + src/utils/items.ts | 367 +++++ 65 files changed, 5465 insertions(+), 1676 deletions(-) create mode 100644 src/apps/experimental/components/library/LibraryHeader.tsx create mode 100644 src/apps/experimental/components/library/NewCollectionButton.tsx create mode 100644 src/apps/experimental/components/library/Pagination.tsx create mode 100644 src/apps/experimental/components/library/PlayAllButton.tsx create mode 100644 src/apps/experimental/components/library/ShuffleButton.tsx create mode 100644 src/apps/experimental/components/library/SortButton.tsx create mode 100644 src/apps/experimental/components/library/ViewSettingsButton.tsx rename src/{components/common => apps/experimental/components/library/container}/AlphaPickerContainer.tsx (71%) create mode 100644 src/apps/experimental/components/library/container/FavoriteItemsContainer.tsx create mode 100644 src/apps/experimental/components/library/container/FavoritesSectionContainer.tsx create mode 100644 src/apps/experimental/components/library/container/LibraryItemsContainer.tsx create mode 100644 src/apps/experimental/components/library/container/RecommendationContainer.tsx create mode 100644 src/apps/experimental/components/library/container/RecommendationItemsContainer.tsx create mode 100644 src/apps/experimental/components/library/container/SectionContainer.tsx create mode 100644 src/apps/experimental/components/library/container/SuggestionsItemsContainer.tsx create mode 100644 src/apps/experimental/components/library/container/SuggestionsSectionContainer.tsx create mode 100644 src/apps/experimental/components/library/container/UpComingItemsContainer.tsx create mode 100644 src/apps/experimental/components/library/filter/FilterButton.tsx create mode 100644 src/apps/experimental/components/library/filter/FiltersFeatures.tsx create mode 100644 src/apps/experimental/components/library/filter/FiltersGenres.tsx create mode 100644 src/apps/experimental/components/library/filter/FiltersOfficialRatings.tsx create mode 100644 src/apps/experimental/components/library/filter/FiltersSeriesStatus.tsx create mode 100644 src/apps/experimental/components/library/filter/FiltersStatus.tsx create mode 100644 src/apps/experimental/components/library/filter/FiltersStudios.tsx create mode 100644 src/apps/experimental/components/library/filter/FiltersTags.tsx create mode 100644 src/apps/experimental/components/library/filter/FiltersVideoTypes.tsx create mode 100644 src/apps/experimental/components/library/filter/FiltersYears.tsx create mode 100644 src/apps/experimental/routes/library/LibraryView.tsx create mode 100644 src/apps/experimental/routes/library/SuggestionsView.tsx create mode 100644 src/apps/experimental/routes/library/UpComingView.tsx create mode 100644 src/apps/experimental/routes/library/index.tsx delete mode 100644 src/apps/experimental/routes/movies/CollectionsView.tsx delete mode 100644 src/apps/experimental/routes/movies/FavoritesView.tsx delete mode 100644 src/apps/experimental/routes/movies/GenresView.tsx delete mode 100644 src/apps/experimental/routes/movies/MoviesView.tsx delete mode 100644 src/apps/experimental/routes/movies/SuggestionsView.tsx delete mode 100644 src/apps/experimental/routes/movies/TrailersView.tsx delete mode 100644 src/apps/experimental/routes/movies/index.tsx delete mode 100644 src/components/common/Filter.tsx delete mode 100644 src/components/common/GenresItemsContainer.tsx delete mode 100644 src/components/common/ItemsContainer.tsx delete mode 100644 src/components/common/NewCollection.tsx delete mode 100644 src/components/common/Pagination.tsx delete mode 100644 src/components/common/RecommendationContainer.tsx delete mode 100644 src/components/common/SectionContainer.tsx delete mode 100644 src/components/common/SelectView.tsx delete mode 100644 src/components/common/Shuffle.tsx delete mode 100644 src/components/common/Sort.tsx delete mode 100644 src/components/common/ViewItemsContainer.tsx create mode 100644 src/hooks/useFetchItems.ts create mode 100644 src/hooks/useLibrarySettings.tsx delete mode 100644 src/hooks/useLocalStorage.tsx create mode 100644 src/types/cardOptions.ts create mode 100644 src/types/library.ts create mode 100644 src/types/sections.ts create mode 100644 src/utils/items.ts diff --git a/package-lock.json b/package-lock.json index 547803c8a7aa..7cdcb8f3ee79 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.0", + "@tanstack/react-query": "4.29.7", + "@tanstack/react-query-devtools": "4.29.7", "blurhash": "2.0.5", "classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz", "classnames": "2.3.2", @@ -53,6 +55,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.5.4", @@ -3387,6 +3390,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.7", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.29.7.tgz", + "integrity": "sha512-GXG4b5hV2Loir+h2G+RXhJdoZhJLnrBWsuLB2r0qBRyhWuXq9w/dWxzvpP89H0UARlH6Mr9DiVj4SMtpkF/aUA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "4.29.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.29.7.tgz", + "integrity": "sha512-ijBWEzAIo09fB1yd22slRZzprrZ5zMdWYzBnCg5qiXuFbH78uGN1qtGz8+Ed4MuhaPaYSD+hykn+QEKtQviEtg==", + "dependencies": { + "@tanstack/query-core": "4.29.7", + "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.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.29.7.tgz", + "integrity": "sha512-fckNnBV6Kfbtq6EJqQen8oBjPqGFcOPS9SJmNKLbFLQgd7OpNIlA4M0r37iJYUY9m14/ESKc1wzKd36VfeiPjg==", + "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.7", + "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", @@ -5800,6 +5872,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", @@ -10369,6 +10455,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-what": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.11.tgz", + "integrity": "sha512-gr9+qDrJvdwT4+N2TAACsZQIB4Ow9j2eefqlh3m9JUV41M1LoKhcE+/j+IVni/r6U8Jnc1PwhjdjVJr+Xmtb0A==", + "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", @@ -14362,6 +14459,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", @@ -18292,6 +18394,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", @@ -19326,6 +19439,29 @@ "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", + "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", @@ -22513,6 +22649,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.7", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.29.7.tgz", + "integrity": "sha512-GXG4b5hV2Loir+h2G+RXhJdoZhJLnrBWsuLB2r0qBRyhWuXq9w/dWxzvpP89H0UARlH6Mr9DiVj4SMtpkF/aUA==" + }, + "@tanstack/react-query": { + "version": "4.29.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.29.7.tgz", + "integrity": "sha512-ijBWEzAIo09fB1yd22slRZzprrZ5zMdWYzBnCg5qiXuFbH78uGN1qtGz8+Ed4MuhaPaYSD+hykn+QEKtQviEtg==", + "requires": { + "@tanstack/query-core": "4.29.7", + "use-sync-external-store": "^1.2.0" + } + }, + "@tanstack/react-query-devtools": { + "version": "4.29.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.29.7.tgz", + "integrity": "sha512-fckNnBV6Kfbtq6EJqQen8oBjPqGFcOPS9SJmNKLbFLQgd7OpNIlA4M0r37iJYUY9m14/ESKc1wzKd36VfeiPjg==", + "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", @@ -24399,6 +24567,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", @@ -27793,6 +27969,11 @@ "get-intrinsic": "^1.1.1" } }, + "is-what": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.11.tgz", + "integrity": "sha512-gr9+qDrJvdwT4+N2TAACsZQIB4Ow9j2eefqlh3m9JUV41M1LoKhcE+/j+IVni/r6U8Jnc1PwhjdjVJr+Xmtb0A==" + }, "is-whitespace-character": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz", @@ -30670,6 +30851,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", @@ -33767,6 +33953,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", @@ -34517,6 +34711,18 @@ "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", + "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 94dc105fdeab..f60508de8390 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.0", + "@tanstack/react-query": "4.29.7", + "@tanstack/react-query-devtools": "4.29.7", "blurhash": "2.0.5", "classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz", "classnames": "2.3.2", @@ -109,6 +111,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.5.4", diff --git a/src/apps/experimental/App.tsx b/src/apps/experimental/App.tsx index 3b58bb6aa609..16fa5b51b900 100644 --- a/src/apps/experimental/App.tsx +++ b/src/apps/experimental/App.tsx @@ -1,13 +1,15 @@ import React, { useCallback, useEffect, useState } from 'react'; + import AppBar from '@mui/material/AppBar'; import Box from '@mui/material/Box'; import { ThemeProvider } from '@mui/material/styles'; import { useLocation } from 'react-router-dom'; - +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import useLocalStorageState from 'use-local-storage-state'; import AppHeader from 'components/AppHeader'; import Backdrop from 'components/Backdrop'; import { useApi } from 'hooks/useApi'; -import { useLocalStorage } from 'hooks/useLocalStorage'; import AppToolbar from './components/AppToolbar'; import AppDrawer, { DRAWER_WIDTH, isDrawerPath } from './components/drawers/AppDrawer'; @@ -25,8 +27,18 @@ const DEFAULT_EXPERIMENTAL_APP_SETTINGS: ExperimentalAppSettings = { isDrawerPinned: false }; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false + } + } +}); + const ExperimentalApp = () => { - const [ appSettings, setAppSettings ] = useLocalStorage('ExperimentalAppSettings', DEFAULT_EXPERIMENTAL_APP_SETTINGS); + const [ appSettings, setAppSettings ] = useLocalStorageState('ExperimentalAppSettings', { + defaultValue: DEFAULT_EXPERIMENTAL_APP_SETTINGS + }); const [ isDrawerActive, setIsDrawerActive ] = useState(appSettings.isDrawerPinned); const { user } = useApi(); const location = useLocation(); @@ -48,67 +60,70 @@ const ExperimentalApp = () => { }, [ isDrawerActive, setIsDrawerActive ]); return ( - - + + + -
- {/* +
+ {/* * TODO: These components are not used, but views interact with them directly so the need to be * present in the dom. We add them in a hidden element to prevent errors. */} - -
- - - - muiTheme.zIndex.drawer + 1 }} - > - - - - - - - +
+ + + + muiTheme.zIndex.drawer + 1 }} + > + + + + + + + -
-
- -
+ marginLeft: 0, + ...(isDrawerAvailable && { + marginLeft: { + sm: `-${DRAWER_WIDTH}px` + } + }), + ...(isDrawerActive && { + transition: theme.transitions.create('margin', { + easing: theme.transitions.easing.easeOut, + duration: theme.transitions.duration.enteringScreen + }), + marginLeft: 0 + }) + }} + > +
+
+ +
+ - - + + + ); }; diff --git a/src/apps/experimental/components/library/LibraryHeader.tsx b/src/apps/experimental/components/library/LibraryHeader.tsx new file mode 100644 index 000000000000..30f882135371 --- /dev/null +++ b/src/apps/experimental/components/library/LibraryHeader.tsx @@ -0,0 +1,244 @@ +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 { useLibrarySettings } from 'hooks/useLibrarySettings'; +import { useGetViewItemsByType } from 'hooks/useFetchItems'; +import { getItemTypesEnum } from 'utils/items'; + +import FilterButton from './filter/FilterButton'; +import NewCollectionButton from './NewCollectionButton'; +import PlayAllButton from './PlayAllButton'; +import ShuffleButton from './ShuffleButton'; +import SortButton from './SortButton'; +import ViewSettingsButton from './ViewSettingsButton'; + +import { LibraryViewSelectOptions } from 'types/library'; + +const visibleBtn = [ + 'movies', + 'favorites', + 'trailers', + 'collections', + 'series', + 'episodes', + 'albums', + 'albumArtists', + 'artists', + 'songs', + 'photos', + 'videos' +]; + +interface LibraryHeaderProps { + collectionType?: string | null; + parentId: string | null; + item?: BaseItemDto; + viewSelectOptions: LibraryViewSelectOptions[]; + viewType: string; + setViewType: React.Dispatch>; +} + +const LibraryHeader: FC = ({ + viewSelectOptions, + viewType, + setViewType, + collectionType, + parentId +}) => { + const { libraryViewSettings, setLibraryViewSettings } = + useLibrarySettings(); + const { + isLoading: isLoading, + data: itemsResult, + isFetching + } = useGetViewItemsByType(viewType, parentId); + + const handleViewType = useCallback( + (e: React.ChangeEvent) => { + setViewType(e.target.value); + }, + [setViewType] + ); + + const isBtnPlayAllEnabled = useCallback(() => { + return ( + viewType !== 'collections' + && viewType !== 'trailers' + && viewType !== 'albumArtists' + && viewType !== 'artists' + && viewType !== 'photos' + ); + }, [viewType]); + + const isBtnShuffleEnabled = useCallback(() => { + return ( + viewType !== 'collections' + && viewType !== 'trailers' + && viewType !== 'albumArtists' + && viewType !== 'artists' + && viewType !== 'photos' + ); + }, [viewType]); + + const isBtnViewSettingsEnabled = useCallback(() => { + return viewType !== 'songs' && viewType !== 'trailers'; + }, [viewType]); + + const isBtnSortEnabled = useCallback(() => { + return viewType !== 'collections'; + }, [viewType]); + + const isBtnFilterEnabled = useCallback(() => { + return viewType !== 'collections'; + }, [viewType]); + + const isBtnNewCollectionEnabled = useCallback(() => { + return viewType === 'collections'; + }, [viewType]); + + const trigger = useScrollTrigger({ + disableHysteresis: true, + threshold: 1 + }); + + return ( + + + } + spacing={2} + sx={{ + py: { xs: 1, sm: 0 } + }} + > + + {viewSelectOptions.map((option) => { + return ( + + {option.title} + + ); + })} + + + {visibleBtn.includes(viewType) && ( + + ) : ( + + {itemsResult?.TotalRecordCount} + + ) + } + /> + )} + + + {visibleBtn.includes(viewType) && ( + + } + sx={{ + py: { xs: 1, sm: 0 } + }} + > + {isBtnPlayAllEnabled() && ( + + )} + + {isBtnShuffleEnabled() && ( + + )} + + {isBtnViewSettingsEnabled() && ( + + )} + + {isBtnSortEnabled() && ( + + )} + + {isBtnFilterEnabled() && ( + + )} + + {isBtnNewCollectionEnabled() && } + + )} + + + ); +}; + +export default LibraryHeader; diff --git a/src/apps/experimental/components/library/NewCollectionButton.tsx b/src/apps/experimental/components/library/NewCollectionButton.tsx new file mode 100644 index 000000000000..e337de7ddd2c --- /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 000000000000..5263744c7b66 --- /dev/null +++ b/src/apps/experimental/components/library/Pagination.tsx @@ -0,0 +1,87 @@ +import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useCallback } from 'react'; + +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +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>; + itemsResult?: BaseItemDtoQueryResult; +} + +const Pagination: FC = ({ + libraryViewSettings, + setLibraryViewSettings, + itemsResult = {} +}) => { + const limit = userSettings.libraryPageSize(undefined); + const totalRecordCount = itemsResult.TotalRecordCount || 0; + const startIndex = libraryViewSettings.StartIndex || 0; + const recordsEnd = Math.min(startIndex + limit, totalRecordCount); + + const onNextPageClick = useCallback(() => { + if (limit > 0) { + const newIndex = startIndex + limit; + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: newIndex + })); + } + }, [limit, setLibraryViewSettings, startIndex]); + + const onPreviousPageClick = useCallback(() => { + if (limit > 0) { + const newIndex = Math.max(0, startIndex - limit); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: newIndex + })); + } + }, [limit, setLibraryViewSettings, startIndex]); + + return ( +
+ + {globalize.translate( + 'ListPaging', + totalRecordCount ? startIndex + 1 : 0, + recordsEnd, + totalRecordCount + )} + + + + + + + = totalRecordCount ? + true : + false + } + 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 000000000000..f3d0f6c47960 --- /dev/null +++ b/src/apps/experimental/components/library/PlayAllButton.tsx @@ -0,0 +1,44 @@ +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { IconButton } from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import { useGetItem } from 'hooks/useFetchItems'; +import { playbackManager } from 'components/playback/playbackmanager'; +import globalize from 'scripts/globalize'; + +interface PlayAllButtonProps { + parentId?: string | null; +} + +const PlayAllButton: FC = ({ parentId }) => { + const [ enableFetch, setEnableFetch ] = useState(false); + + const { data: item } = useGetItem(parentId, enableFetch); + + useEffect(() => { + if (parentId) { + setEnableFetch(true); + } + + return () => { + setEnableFetch(false); + }; + }, [parentId]); + + const playAll = useCallback(() => { + playbackManager.play({ + items: [item] + }); + }, [item]); + + return ( + + + + ); +}; + +export default PlayAllButton; diff --git a/src/apps/experimental/components/library/ShuffleButton.tsx b/src/apps/experimental/components/library/ShuffleButton.tsx new file mode 100644 index 000000000000..f39abcde7ee1 --- /dev/null +++ b/src/apps/experimental/components/library/ShuffleButton.tsx @@ -0,0 +1,43 @@ +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { IconButton } from '@mui/material'; +import ShuffleIcon from '@mui/icons-material/Shuffle'; + +import { useGetItem } from 'hooks/useFetchItems'; +import globalize from 'scripts/globalize'; +import { playbackManager } from 'components/playback/playbackmanager'; + +interface ShuffleButtonProps { + parentId: string | null; +} + +const ShuffleButton: FC = ({ parentId }) => { + const [ enableFetch, setEnableFetch ] = useState(false); + + const { data: item } = useGetItem(parentId, enableFetch); + + const shuffle = useCallback(() => { + playbackManager.shuffle(item); + }, [item]); + + useEffect(() => { + if (parentId) { + setEnableFetch(true); + } + + return () => { + setEnableFetch(false); + }; + }, [parentId]); + + return ( + + + + ); +}; + +export default ShuffleButton; diff --git a/src/apps/experimental/components/library/SortButton.tsx b/src/apps/experimental/components/library/SortButton.tsx new file mode 100644 index 000000000000..0f4b80c64314 --- /dev/null +++ b/src/apps/experimental/components/library/SortButton.tsx @@ -0,0 +1,193 @@ +import React, { FC, useCallback } from 'react'; + +import IconButton from '@mui/material/IconButton'; +import MenuItem from '@mui/material/MenuItem'; +import SortByAlphaIcon from '@mui/icons-material/SortByAlpha'; +import { Popover } from '@mui/material'; +import Typography from '@mui/material/Typography'; +import Divider from '@mui/material/Divider'; +import Box from '@mui/material/Box'; +import InputLabel from '@mui/material/InputLabel'; +import FormControl from '@mui/material/FormControl'; +import Select, { SelectChangeEvent } from '@mui/material/Select'; + +import globalize from 'scripts/globalize'; +import { LibraryViewSettings } from 'types/library'; + +interface SortButtonProps { + viewType: string; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch< + React.SetStateAction + >; +} + +const sortByOptions = [ + { label: globalize.translate('Name'), value: 'SortName' }, + { label: globalize.translate('OptionRandom'), value: 'Random' }, + { label: globalize.translate('OptionImdbRating'), value: 'CommunityRating' }, + { label: globalize.translate('OptionCriticRating'), value: 'CriticRating' }, + { label: globalize.translate('OptionDateAdded'), value: 'DateCreated' }, + { label: globalize.translate('OptionDatePlayed'), value: 'DatePlayed' }, + { label: globalize.translate('OptionParentalRating'), value: 'OfficialRating' }, + { label: globalize.translate('OptionPlayCount'), value: 'PlayCount' }, + { label: globalize.translate('OptionReleaseDate'), value: 'PremiereDate' }, + { label: globalize.translate('Runtime'), value: 'Runtime' }, + { label: globalize.translate('Folders'), value: 'IsFolder' } +]; + +const sortOrderMenuOptions = [ + { label: 'Ascending', value: 'Ascending' }, + { label: 'Descending', value: 'Descending' } +]; + +const SortButton: FC = ({ + viewType, + libraryViewSettings, + setLibraryViewSettings +}) => { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const id = open ? 'sort-popover' : undefined; + + const handleClick = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + + const handleClose = useCallback(() => { + setAnchorEl(null); + }, []); + + const onSelectChange = useCallback( + (event: SelectChangeEvent) => { + const name = event.target.name; + + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + [name]: event.target.value + })); + }, + [setLibraryViewSettings] + ); + + const getVisibleSortBy = () => { + const visibleSortBy = ['SortName', 'Random', 'DateCreated']; + + if ( + viewType !== 'books' + && viewType !== 'photos' + && viewType !== 'videos' + ) { + visibleSortBy.push('CommunityRating'); + visibleSortBy.push('CriticRating'); + visibleSortBy.push('DatePlayed'); + visibleSortBy.push('OfficialRating'); + visibleSortBy.push('PlayCount'); + visibleSortBy.push('PremiereDate'); + visibleSortBy.push('Runtime'); + visibleSortBy.push('OfficialRating'); + } + + if (viewType === 'books' || viewType === 'photos' || viewType === 'videos') { + visibleSortBy.push('IsFolder'); + } + + return visibleSortBy; + }; + + return ( +
+ + + + + +
+ + + + {globalize.translate('LabelSortBy')} + + + + +
+ + +
+ + + + {globalize.translate('LabelSortOrder')} + + + + +
+
+
+
+ ); +}; + +export default SortButton; diff --git a/src/apps/experimental/components/library/ViewSettingsButton.tsx b/src/apps/experimental/components/library/ViewSettingsButton.tsx new file mode 100644 index 000000000000..d88318bf1030 --- /dev/null +++ b/src/apps/experimental/components/library/ViewSettingsButton.tsx @@ -0,0 +1,207 @@ +import React, { FC, useCallback } from 'react'; +import ViewComfyIcon from '@mui/icons-material/ViewComfy'; +import { + Typography, + Checkbox, + Divider, + FormControl, + FormControlLabel, + FormGroup, + IconButton, + InputLabel, + MenuItem, + Select, + SelectChangeEvent +} from '@mui/material'; +import Popover from '@mui/material/Popover'; +import globalize from 'scripts/globalize'; +import { LibraryViewSettings } from 'types/library'; + +interface ViewSettingsButtonProps { + viewType: string; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const imageTypesOptions = [ + { label: globalize.translate('Primary'), value: 'primary' }, + { label: globalize.translate('Banner'), value: 'banner' }, + { label: globalize.translate('Disc'), value: 'disc' }, + { label: globalize.translate('Logo'), value: 'logo' }, + { label: globalize.translate('Thumb'), value: 'thumb' }, + { label: globalize.translate('List'), value: 'list' } +]; + +const ViewSettingsButton: FC = ({ + viewType, + libraryViewSettings, + setLibraryViewSettings +}) => { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const id = open ? 'selectview-popover' : undefined; + + const handleClick = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + + const handleClose = useCallback(() => { + setAnchorEl(null); + }, []); + + const handleChange = useCallback( + (event: React.ChangeEvent) => { + setLibraryViewSettings({ + ...libraryViewSettings, + [event.target.name]: event.target.checked + }); + }, + [setLibraryViewSettings, libraryViewSettings] + ); + + const onSelectChange = useCallback( + (event: SelectChangeEvent) => { + setLibraryViewSettings({ + ...libraryViewSettings, + imageType: event.target.value as string + }); + }, + [setLibraryViewSettings, libraryViewSettings] + ); + + const getVisibleImageType = () => { + const visibleImageType = ['primary', 'list']; + + if ( + viewType !== 'episodes' + && viewType !== 'artists' + && viewType !== 'albumArtists' + && viewType !== 'albums' + && viewType !== 'books' + && viewType !== 'photos' + && viewType !== 'videos' + ) { + visibleImageType.push('banner'); + visibleImageType.push('disc'); + visibleImageType.push('logo'); + visibleImageType.push('thumb'); + } + + return visibleImageType; + }; + + const isViewSettingsEnabled = () => { + return libraryViewSettings.imageType !== 'list'; + }; + + const isShowYearEnabled = useCallback(() => { + return ( + viewType !== 'books' + && viewType !== 'photos' + && viewType !== 'videos' + ); + }, [viewType]); + + return ( +
+ + + + + + + + {globalize.translate('LabelImageType')} + + + + + {isViewSettingsEnabled() && ( + <> + + + + + } + label={globalize.translate('ShowTitle')} + /> + {isShowYearEnabled() && ( + } + label={globalize.translate('ShowYear')} + />)} + + } + label={globalize.translate( + 'EnableCardLayout' + )} + /> + + + + )} + +
+ ); +}; + +export default ViewSettingsButton; diff --git a/src/components/common/AlphaPickerContainer.tsx b/src/apps/experimental/components/library/container/AlphaPickerContainer.tsx similarity index 71% rename from src/components/common/AlphaPickerContainer.tsx rename to src/apps/experimental/components/library/container/AlphaPickerContainer.tsx index 6b7c9a07183d..290ac86ed3dd 100644 --- a/src/components/common/AlphaPickerContainer.tsx +++ b/src/apps/experimental/components/library/container/AlphaPickerContainer.tsx @@ -1,21 +1,21 @@ import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; -import AlphaPicker from '../alphaPicker/alphaPicker'; -import { ViewQuerySettings } from '../../types/interface'; +import AlphaPicker from 'components/alphaPicker/alphaPicker'; +import { LibraryViewSettings } from 'types/library'; interface AlphaPickerContainerProps { - viewQuerySettings: ViewQuerySettings; - setViewQuerySettings: React.Dispatch>; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; } -const AlphaPickerContainer: FC = ({ viewQuerySettings, setViewQuerySettings }) => { +const AlphaPickerContainer: FC = ({ libraryViewSettings, setLibraryViewSettings }) => { const [ alphaPicker, setAlphaPicker ] = useState(); const element = useRef(null); - alphaPicker?.updateControls(viewQuerySettings); + alphaPicker?.updateControls(libraryViewSettings); const onAlphaPickerChange = useCallback((e) => { const newValue = (e as CustomEvent).detail.value; - let updatedValue: React.SetStateAction; + let updatedValue: React.SetStateAction; if (newValue === '#') { updatedValue = { NameLessThan: 'A', @@ -27,12 +27,12 @@ const AlphaPickerContainer: FC = ({ viewQuerySettings NameStartsWith: newValue }; } - setViewQuerySettings((prevState) => ({ + setLibraryViewSettings((prevState) => ({ ...prevState, StartIndex: 0, ...updatedValue })); - }, [setViewQuerySettings]); + }, [setLibraryViewSettings]); useEffect(() => { const alphaPickerElement = element.current; diff --git a/src/apps/experimental/components/library/container/FavoriteItemsContainer.tsx b/src/apps/experimental/components/library/container/FavoriteItemsContainer.tsx new file mode 100644 index 000000000000..a3e795af2152 --- /dev/null +++ b/src/apps/experimental/components/library/container/FavoriteItemsContainer.tsx @@ -0,0 +1,228 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC } from 'react'; +import FavoritesSectionContainer from './FavoritesSectionContainer'; + +function getFavoriteSections() { + return [ + { + name: 'HeaderFavoriteMovies', + viewType: 'Movies', + type: 'Movie', + id: 'favoriteMovies', + parametersOptions: { + includeItemTypes: [BaseItemKind.Movie] + }, + cardOptions: { + shape: 'overflowPortrait', + showTitle: true, + showYear: true, + overlayPlayButton: true, + overlayText: false, + centerText: true + } + }, + { + name: 'HeaderFavoriteShows', + viewType: 'Shows', + type: 'Series', + id: 'favoriteShows', + parametersOptions: { + includeItemTypes: [BaseItemKind.Series] + }, + cardOptions: { + shape: 'overflowPortrait', + showTitle: true, + showYear: true, + overlayPlayButton: true, + overlayText: false, + centerText: true + } + }, + { + name: 'HeaderFavoriteEpisodes', + viewType: 'Episodes', + type: 'Episode', + id: 'favoriteEpisode', + parametersOptions: { + includeItemTypes: [BaseItemKind.Episode] + }, + cardOptions: { + shape: 'overflowBackdrop', + preferThumb: false, + showTitle: true, + showParentTitle: true, + overlayPlayButton: true, + overlayText: false, + centerText: true + } + }, + { + name: 'HeaderFavoriteVideos', + viewType: 'Videos', + type: 'Video,MusicVideo', + id: 'favoriteVideos', + parametersOptions: { + includeItemTypes: [BaseItemKind.Video, BaseItemKind.MusicVideo] + }, + cardOptions: { + shape: 'overflowBackdrop', + preferThumb: true, + showTitle: true, + overlayPlayButton: true, + overlayText: false, + centerText: true + } + }, + { + name: 'HeaderFavoriteCollections', + viewType: 'Collections', + type: 'BoxSet', + id: 'favoriteCollections', + parametersOptions: { + includeItemTypes: [BaseItemKind.BoxSet] + }, + cardOptions: { + shape: 'overflowPortrait', + showTitle: true, + overlayPlayButton: true, + overlayText: false, + centerText: true + } + }, + { + name: 'HeaderFavoritePlaylists', + viewType: 'Playlists', + type: 'Playlist', + id: '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: 'Persons', + type: 'Person', + id: 'favoritePeople', + cardOptions: { + shape: 'overflowPortrait', + preferThumb: false, + showTitle: true, + overlayText: false, + showParentTitle: false, + centerText: true, + overlayPlayButton: true, + coverImage: true + } + }, + { + name: 'HeaderFavoriteArtists', + viewType: 'Artists', + type: 'MusicArtist', + id: 'favoriteArtists', + cardOptions: { + shape: 'overflowSquare', + preferThumb: false, + showTitle: true, + overlayText: false, + showParentTitle: false, + centerText: true, + overlayPlayButton: true, + coverImage: true + } + }, + { + name: 'HeaderFavoriteAlbums', + viewType: 'Albums', + type: 'MusicAlbum', + id: 'favoriteAlbums', + parametersOptions: { + includeItemTypes: [BaseItemKind.MusicAlbum] + }, + cardOptions: { + shape: 'overflowSquare', + preferThumb: false, + showTitle: true, + overlayText: false, + showParentTitle: true, + centerText: true, + overlayPlayButton: true, + coverImage: true + } + }, + { + name: 'HeaderFavoriteSongs', + viewType: 'Songs', + type: 'Audio', + id: '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', + viewType: 'Books', + type: 'Book', + id: 'favoriteBooks', + parametersOptions: { + includeItemTypes: [BaseItemKind.Book] + }, + cardOptions: { + shape: 'overflowPortrait', + showTitle: true, + showYear: true, + overlayPlayButton: true, + overlayText: false, + centerText: true + } + } + ]; +} + +interface FavoriteItemsContainerProps { + topParentId?: string | null; + visibleId: string[]; +} + +const FavoriteItemsContainer: FC = ({ + topParentId, + visibleId +}) => { + const favoriteSections = getFavoriteSections(); + + return ( + <> + {favoriteSections + .filter((section) => visibleId.includes(section.id)) + .map((section) => ( + + ))} + + ); +}; + +export default FavoriteItemsContainer; diff --git a/src/apps/experimental/components/library/container/FavoritesSectionContainer.tsx b/src/apps/experimental/components/library/container/FavoritesSectionContainer.tsx new file mode 100644 index 000000000000..4e2ee0bf3b6a --- /dev/null +++ b/src/apps/experimental/components/library/container/FavoritesSectionContainer.tsx @@ -0,0 +1,50 @@ +import React, { FC } from 'react'; +import { useGetItemsByFavoriteType } from 'hooks/useFetchItems'; +import Loading from 'components/loading/LoadingComponent'; +import { appRouter } from 'components/router/appRouter'; +import globalize from 'scripts/globalize'; +import SectionContainer from './SectionContainer'; + +import { Sections } from 'types/sections'; +interface FavoritesSectionContainerProps { + topParentId?: string | null; + section: Sections; +} + +const FavoritesSectionContainer: FC = ({ + topParentId, + section +}) => { + const getRouteUrl = () => { + return appRouter.getRouteUrl('list', { + serverId: window.ApiClient.serverId(), + itemTypes: section.type, + isFavorite: true + }); + }; + + const { isLoading, data: items } = useGetItemsByFavoriteType( + section, + topParentId + ); + + if (isLoading) { + return ; + } + + return ( + + ); +}; + +export default FavoritesSectionContainer; diff --git a/src/apps/experimental/components/library/container/LibraryItemsContainer.tsx b/src/apps/experimental/components/library/container/LibraryItemsContainer.tsx new file mode 100644 index 000000000000..d92287020cdd --- /dev/null +++ b/src/apps/experimental/components/library/container/LibraryItemsContainer.tsx @@ -0,0 +1,136 @@ +import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useEffect, useCallback, useRef } from 'react'; + +import globalize from 'scripts/globalize'; +import imageLoader from 'components/images/imageLoader'; +import listview from 'components/listview/listview'; +import cardBuilder from 'components/cardbuilder/cardBuilder'; +import ItemsContainerElement from 'elements/ItemsContainerElement'; +import 'elements/emby-itemscontainer/emby-itemscontainer'; + +import { LibraryViewSettings } from 'types/library'; +import { CardOptions } from 'types/cardOptions'; + +interface LibraryItemsContainerProps { + libraryViewSettings: LibraryViewSettings; + collectionType: string | null | undefined; + viewType: string | undefined; + itemsResult?: BaseItemDtoQueryResult; +} + +const LibraryItemsContainer: FC = ({ libraryViewSettings, collectionType, itemsResult, viewType }) => { + const element = useRef(null); + + const getCardOptions = useCallback(() => { + let shape; + let preferThumb; + let preferDisc; + let preferLogo; + let lines = libraryViewSettings.showTitle ? 2 : 0; + + if (libraryViewSettings.imageType === 'banner') { + shape = 'banner'; + } else if (libraryViewSettings.imageType === 'disc') { + shape = 'square'; + preferDisc = true; + } else if (libraryViewSettings.imageType === 'logo') { + shape = 'backdrop'; + preferLogo = true; + } else if (libraryViewSettings.imageType === 'thumb') { + 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 === 'songs' + || viewType === 'albums' + || viewType === 'episodes' + ) { + cardOptions.showParentTitle = libraryViewSettings.showTitle; + } else if (viewType === 'artists') { + cardOptions.showYear = false; + lines = 1; + } + + cardOptions.lines = lines; + cardOptions.items = itemsResult?.Items || []; + + return cardOptions; + }, [ + viewType, + collectionType, + itemsResult?.Items, + libraryViewSettings.cardLayout, + libraryViewSettings.imageType, + libraryViewSettings.showTitle, + libraryViewSettings.showYear + ]); + + const getItemsHtml = useCallback(() => { + let html = ''; + + if (libraryViewSettings.imageType === 'list') { + html = listview.getListViewHtml({ + items: itemsResult?.Items || [], + context: collectionType + }); + } else { + html = cardBuilder.getCardsHtml( + itemsResult?.Items || [], + getCardOptions() + ); + } + + if (!itemsResult?.Items?.length) { + html += '
'; + html + += '

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

'; + html + += '

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

'; + html += '
'; + } + + return html; + }, [ + getCardOptions, + collectionType, + itemsResult?.Items, + libraryViewSettings.imageType + ]); + + useEffect(() => { + const itemsContainer = element.current?.querySelector('.itemsContainer') as HTMLDivElement; + itemsContainer.innerHTML = getItemsHtml(); + imageLoader.lazyChildren(itemsContainer); + }, [getItemsHtml]); + + const cssClass = libraryViewSettings.imageType == 'list' ? 'vertical-list' : 'vertical-wrap'; + + return ( +
+ +
+ ); +}; + +export default LibraryItemsContainer; diff --git a/src/apps/experimental/components/library/container/RecommendationContainer.tsx b/src/apps/experimental/components/library/container/RecommendationContainer.tsx new file mode 100644 index 000000000000..b08c596ac87a --- /dev/null +++ b/src/apps/experimental/components/library/container/RecommendationContainer.tsx @@ -0,0 +1,61 @@ +import type { RecommendationDto } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC } from 'react'; +import escapeHTML from 'escape-html'; + +import globalize from 'scripts/globalize'; +import SectionContainer from './SectionContainer'; + +interface RecommendationContainerProps { + recommendation?: RecommendationDto; +} + +const RecommendationContainer: FC = ({ + 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/apps/experimental/components/library/container/RecommendationItemsContainer.tsx b/src/apps/experimental/components/library/container/RecommendationItemsContainer.tsx new file mode 100644 index 000000000000..d54fcb7e3e3a --- /dev/null +++ b/src/apps/experimental/components/library/container/RecommendationItemsContainer.tsx @@ -0,0 +1,60 @@ +import React, { FC, useEffect, useState } from 'react'; +import { useGetMovieRecommendations } from 'hooks/useFetchItems'; +import Loading from 'components/loading/LoadingComponent'; +import globalize from 'scripts/globalize'; +import RecommendationContainer from './RecommendationContainer'; + +interface RecommendationItemsContainerProps { + topParentId?: string | null; +} + +const RecommendationItemsContainer: FC = ({ + topParentId +}) => { + const [ enableFetch, setEnableFetch ] = useState(false); + const { + isLoading, + data: movieRecommendationsItems + } = useGetMovieRecommendations(topParentId, enableFetch); + + useEffect(() => { + if (topParentId) { + setEnableFetch(true); + } + + return () => { + setEnableFetch(false); + }; + }, [topParentId]); + + if (isLoading) { + return ; + } + + return ( + <> + {!movieRecommendationsItems?.length ? ( +
+

{globalize.translate('MessageNothingHere')}

+

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

+
+ ) : ( + movieRecommendationsItems.map((recommendation, index) => { + return ( + + ); + }) + )} + + ); +}; + +export default RecommendationItemsContainer; diff --git a/src/apps/experimental/components/library/container/SectionContainer.tsx b/src/apps/experimental/components/library/container/SectionContainer.tsx new file mode 100644 index 000000000000..5b99f44d8c15 --- /dev/null +++ b/src/apps/experimental/components/library/container/SectionContainer.tsx @@ -0,0 +1,74 @@ +import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useEffect, useRef } from 'react'; + +import imageLoader from 'components/images/imageLoader'; +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 { 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/container/SuggestionsItemsContainer.tsx b/src/apps/experimental/components/library/container/SuggestionsItemsContainer.tsx new file mode 100644 index 000000000000..6ab0d7c9cc6d --- /dev/null +++ b/src/apps/experimental/components/library/container/SuggestionsItemsContainer.tsx @@ -0,0 +1,205 @@ +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'; + +function getSuggestionsSections() { + return [ + { + name: 'HeaderContinueWatching', + viewType: 'resumeItems', + type: 'Movie', + id: 'suggestionContinueWatchingMovies', + parametersOptions: { + includeItemTypes: [BaseItemKind.Movie] + }, + cardOptions: { + scalable: true, + overlayPlayButton: true, + showTitle: true, + centerText: true, + cardLayout: false, + preferThumb: true, + shape: 'overflowBackdrop', + showYear: true + } + }, + { + name: 'HeaderLatestMovies', + viewType: 'latestMedia', + type: 'Movie', + id: 'suggestionLatestMovies', + parametersOptions: { + includeItemTypes: [BaseItemKind.Movie] + }, + cardOptions: { + scalable: true, + overlayPlayButton: true, + showTitle: true, + centerText: true, + cardLayout: false, + shape: 'overflowPortrait', + showYear: true + } + }, + { + name: 'HeaderContinueWatching', + viewType: 'resumeItems', + type: 'Episode', + id: 'suggestionContinueWatchingEpisode', + 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: 'latestMedia', + type: 'Episode', + id: 'suggestionLatestEpisode', + 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: 'nextUp', + type: 'nextup', + id: 'suggestionNextUp', + 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: 'latestMedia', + type: 'Audio', + id: 'suggestionLatestMusic', + 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', + id: 'suggestionRecentlyPlayed', + 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', + id: 'suggestionFrequentlyPlayed', + 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 { + topParentId?: string | null; + visibleId: string[]; +} + +const SuggestionsItemsContainer: FC = ({ + topParentId, + visibleId +}) => { + const suggestionsSections = getSuggestionsSections(); + + return ( + <> + {suggestionsSections + .filter((section) => visibleId.includes(section.id)) + .map((section) => ( + + ))} + + ); +}; + +export default SuggestionsItemsContainer; diff --git a/src/apps/experimental/components/library/container/SuggestionsSectionContainer.tsx b/src/apps/experimental/components/library/container/SuggestionsSectionContainer.tsx new file mode 100644 index 000000000000..3f15195a808a --- /dev/null +++ b/src/apps/experimental/components/library/container/SuggestionsSectionContainer.tsx @@ -0,0 +1,48 @@ +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/sections'; + +interface SuggestionsSectionContainerProps { + topParentId?: string | null; + section: Sections; +} + +const SuggestionsSectionContainer: FC = ({ + topParentId, + section +}) => { + const getRouteUrl = () => { + return appRouter.getRouteUrl('list', { + serverId: window.ApiClient.serverId(), + itemTypes: section.type, + parentId: topParentId + }); + }; + + const { isLoading, data: items } = useGetItemsBySectionType( + section, + topParentId + ); + + if (isLoading) { + return ; + } + + return ( + + ); +}; + +export default SuggestionsSectionContainer; diff --git a/src/apps/experimental/components/library/container/UpComingItemsContainer.tsx b/src/apps/experimental/components/library/container/UpComingItemsContainer.tsx new file mode 100644 index 000000000000..a6786cec8e66 --- /dev/null +++ b/src/apps/experimental/components/library/container/UpComingItemsContainer.tsx @@ -0,0 +1,87 @@ +import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import Box from '@mui/material/Box'; +import datetime from 'scripts/datetime'; +import globalize from 'scripts/globalize'; +import SectionContainer from './SectionContainer'; + +interface UpComingItemsContainerProps { + topParentId?: string | null; + items: BaseItemDto[]; +} + +interface UpcomingGroups { + name: string; + items: BaseItemDto[]; +} + +const UpComingItemsContainer: FC = ({ items }) => { + const [upcomingGroups, setUpcomingGroups] = useState([]); + + const renderUpcoming = useCallback(() => { + const groups: UpcomingGroups[] = []; + 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); + } + } + + setUpcomingGroups(groups); + }, [items]); + + useEffect(() => { + renderUpcoming(); + }, [renderUpcoming]); + + return ( + + {upcomingGroups?.map((group) => ( + + ))} + + ); +}; + +export default UpComingItemsContainer; diff --git a/src/apps/experimental/components/library/filter/FilterButton.tsx b/src/apps/experimental/components/library/filter/FilterButton.tsx new file mode 100644 index 000000000000..cfb73d8db8c4 --- /dev/null +++ b/src/apps/experimental/components/library/filter/FilterButton.tsx @@ -0,0 +1,450 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; +import React, { FC, useCallback, useEffect, useState } from 'react'; + +import { styled } from '@mui/material/styles'; +import IconButton from '@mui/material/IconButton'; +import FilterListIcon from '@mui/icons-material/FilterList'; +import { Popover } from '@mui/material'; +import ArrowForwardIosSharpIcon from '@mui/icons-material/ArrowForwardIosSharp'; +import MuiAccordion, { AccordionProps } from '@mui/material/Accordion'; +import MuiAccordionSummary, { + AccordionSummaryProps +} from '@mui/material/AccordionSummary'; +import MuiAccordionDetails from '@mui/material/AccordionDetails'; +import Typography from '@mui/material/Typography'; +import { + useGetQueryFiltersLegacy, + useGetStudios +} from 'hooks/useFetchItems'; +import globalize from 'scripts/globalize'; +import FiltersStatus from './FiltersStatus'; +import FiltersFeatures from './FiltersFeatures'; +import FiltersVideoTypes from './FiltersVideoTypes'; +import FiltersGenres from './FiltersGenres'; +import FiltersYears from './FiltersYears'; +import FiltersTags from './FiltersTags'; +import FiltersOfficialRatings from './FiltersOfficialRatings'; +import FiltersStudios from './FiltersStudios'; +import FiltersSeriesStatus from './FiltersSeriesStatus'; + +import { LibraryViewSettings } from 'types/library'; + +const Accordion = styled((props: AccordionProps) => ( + +))(({ theme }) => ({ + border: `1px solid ${theme.palette.divider}`, + '&:not(:last-child)': { + borderBottom: 0 + }, + '&:before': { + display: 'none' + } +})); + +const AccordionSummary = styled((props: AccordionSummaryProps) => ( + } + {...props} + /> +))(({ theme }) => ({ + backgroundColor: + theme.palette.mode === 'dark' ? + 'rgba(255, 255, 255, .05)' : + 'rgba(0, 0, 0, .03)', + '& .MuiAccordionSummary-expandIconWrapper.Mui-expanded': { + transform: 'rotate(90deg)' + }, + '& .MuiAccordionSummary-content': { + marginLeft: theme.spacing(1) + } +})); + +const AccordionDetails = styled(MuiAccordionDetails)(({ theme }) => ({ + padding: theme.spacing(2), + borderTop: '1px solid rgba(0, 0, 0, .125)' +})); + +interface FilterButtonProps { + parentId?: string | null; + itemType: BaseItemKind[]; + viewType: string; + context?: string | null; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const FilterButton: FC = ({ + parentId, + itemType, + viewType, + libraryViewSettings, + setLibraryViewSettings +}) => { + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const id = open ? 'filter-popover' : undefined; + const [expanded, setExpanded] = useState(false); + const [ enableFetch, setEnableFetch ] = useState(false); + + const getVisibleFilters = () => { + const visibleFilters = ['IsFavorite']; + + if ( + viewType !== 'albums' + && viewType !== 'artists' + && viewType !== 'albumArtists' + && viewType !== 'songs' + && viewType !== 'photos' + ) { + visibleFilters.push('IsUnplayed'); + visibleFilters.push('IsPlayed'); + visibleFilters.push('IsResumable'); + } + + if (viewType === 'movies' || viewType === 'episodes') { + visibleFilters.push('Bluray'); + visibleFilters.push('Dvd'); + visibleFilters.push('IsHD'); + visibleFilters.push('IsSD'); + visibleFilters.push('Is3D'); + visibleFilters.push('Is4K'); + } + + if ( + viewType === 'movies' + || viewType === 'series' + || viewType === 'episodes' + ) { + visibleFilters.push('HasTrailer'); + visibleFilters.push('HasSubtitles'); + visibleFilters.push('HasSpecialFeature'); + visibleFilters.push('HasThemeSong'); + visibleFilters.push('HasThemeVideo'); + } + + if (viewType === 'episodes') { + visibleFilters.push('ParentIndexNumber'); + visibleFilters.push('IsMissing'); + visibleFilters.push('IsUnaired'); + } + + return visibleFilters; + }; + + const isFiltersLegacyEnabled = () => { + return ( + viewType === 'movies' + || viewType === 'series' + || viewType === 'albums' + || viewType === 'albumArtists' + || viewType === 'artists' + || viewType === 'songs' + || viewType === 'episodes' + ); + }; + + const isFiltersStudiosEnabled = () => { + return viewType === 'movies' || viewType === 'series'; + }; + + const isFiltersFeaturesEnabled = () => { + return ( + viewType === 'movies' + || viewType === 'series' + || viewType === 'episodes' + ); + }; + + const isFiltersVideoTypesEnabled = () => { + return viewType === 'movies' || viewType === 'episodes'; + }; + + const isFiltersSeriesStatusEnabled = () => { + return viewType === 'series'; + }; + + const { data } = useGetQueryFiltersLegacy(parentId, itemType, enableFetch); + const { data: studios } = useGetStudios(parentId, itemType, enableFetch); + + const handleChange = + (panel: string) => + (event: React.SyntheticEvent, newExpanded: boolean) => { + setExpanded(newExpanded ? panel : false); + }; + + const handleClick = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + + const handleClose = useCallback(() => { + setAnchorEl(null); + }, []); + + useEffect(() => { + if (parentId && itemType) { + setEnableFetch(true); + } + + return () => { + setEnableFetch(false); + }; + }, [itemType, parentId]); + + return ( +
+ + + + + + + + {globalize.translate('Filters')} + + + + + + + {isFiltersSeriesStatusEnabled() && ( + <> + + + + {globalize.translate('HeaderSeriesStatus')} + + + + + + + + )} + {isFiltersFeaturesEnabled() && ( + <> + + + + {globalize.translate('Features')} + + + + + + + + )} + + {isFiltersVideoTypesEnabled() && ( + <> + + + + {globalize.translate('HeaderVideoType')} + + + + + + + + )} + + {isFiltersLegacyEnabled() && ( + <> + {data?.Genres && data?.Genres?.length > 0 && ( + + + + {globalize.translate('Genres')} + + + + + + + )} + + {data?.OfficialRatings && data?.OfficialRatings?.length > 0 && ( + + + + {globalize.translate( + 'HeaderParentalRatings' + )} + + + + + + + )} + + {data?.Tags && data?.Tags.length > 0 && ( + + + + {globalize.translate('Tags')} + + + + + + + )} + + {data?.Years && data?.Years?.length > 0 && ( + + + + {globalize.translate('HeaderYears')} + + + + + + + )} + + )} + {isFiltersStudiosEnabled() && ( + <> + + + + {globalize.translate('Studios')} + + + + + + + + )} + +
+ ); +}; + +export default FilterButton; diff --git a/src/apps/experimental/components/library/filter/FiltersFeatures.tsx b/src/apps/experimental/components/library/filter/FiltersFeatures.tsx new file mode 100644 index 000000000000..d23b0f58aa34 --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersFeatures.tsx @@ -0,0 +1,83 @@ +import React, { FC, useCallback } from 'react'; + +import FormControl from '@mui/material/FormControl'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import globalize from 'scripts/globalize'; + +import { LibraryViewSettings } from 'types/library'; + +interface FiltersFeaturesProps { + visibleSettings: string[]; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const featureFiltersOptions = [ + { label: globalize.translate('Subtitles'), value: 'HasSubtitles' }, + { label: globalize.translate('Trailers'), value: 'HasTrailer' }, + { label: globalize.translate('Extras'), value: 'HasSpecialFeature' }, + { label: globalize.translate('ThemeSongs'), value: 'HasThemeSong' }, + { label: globalize.translate('ThemeVideos'), value: 'HasThemeVideo' } +]; + +const FiltersFeatures: FC = ({ + visibleSettings, + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersFeaturesChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = String(event.target.value); + const existingValue = libraryViewSettings.filtersFeatures as string[]; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: string) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + filtersFeatures: newValue + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + filtersFeatures: [...existingValue, value] + })); + } + }, + [setLibraryViewSettings, libraryViewSettings.filtersFeatures] + ); + + return ( + + + {featureFiltersOptions + .filter((filter) => visibleSettings.includes(filter.value)) + .map((filter) => ( + + } + label={filter.label} + /> + ))} + + + ); +}; + +export default FiltersFeatures; diff --git a/src/apps/experimental/components/library/filter/FiltersGenres.tsx b/src/apps/experimental/components/library/filter/FiltersGenres.tsx new file mode 100644 index 000000000000..df5911531ebc --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersGenres.tsx @@ -0,0 +1,72 @@ +import type { QueryFiltersLegacy } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useCallback } from 'react'; +import FormControl from '@mui/material/FormControl'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; + +import { LibraryViewSettings } from 'types/library'; + +interface FiltersGenresProps { + filtes?: QueryFiltersLegacy; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const FiltersGenres: FC = ({ + filtes, + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersGenresChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = String(event.target.value); + const existingValue = libraryViewSettings.filtersGenres as string[]; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: string) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + filtersGenres: newValue + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + filtersGenres: [...existingValue, value] + })); + } + }, + [setLibraryViewSettings, libraryViewSettings.filtersGenres] + ); + + return ( + + + {filtes?.Genres?.map((filter) => ( + + } + label={filter} + /> + ))} + + + ); +}; + +export default FiltersGenres; diff --git a/src/apps/experimental/components/library/filter/FiltersOfficialRatings.tsx b/src/apps/experimental/components/library/filter/FiltersOfficialRatings.tsx new file mode 100644 index 000000000000..8fc0226cb049 --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersOfficialRatings.tsx @@ -0,0 +1,72 @@ +import type { QueryFiltersLegacy } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useCallback } from 'react'; +import FormControl from '@mui/material/FormControl'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; + +import { LibraryViewSettings } from 'types/library'; + +interface FiltersOfficialRatingsProps { + filtes?: QueryFiltersLegacy; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const FiltersOfficialRatings: FC = ({ + filtes, + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersOfficialRatingsChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = String(event.target.value); + const existingValue = libraryViewSettings.filtersOfficialRatings as string[]; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: string) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + filtersOfficialRatings: newValue + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + filtersOfficialRatings: [...existingValue, value] + })); + } + }, + [setLibraryViewSettings, libraryViewSettings.filtersOfficialRatings] + ); + + return ( + + + {filtes?.OfficialRatings?.map((filter) => ( + + } + label={filter} + /> + ))} + + + ); +}; + +export default FiltersOfficialRatings; diff --git a/src/apps/experimental/components/library/filter/FiltersSeriesStatus.tsx b/src/apps/experimental/components/library/filter/FiltersSeriesStatus.tsx new file mode 100644 index 000000000000..cdc32ad018ba --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersSeriesStatus.tsx @@ -0,0 +1,77 @@ +import React, { FC, useCallback } from 'react'; + +import FormControl from '@mui/material/FormControl'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import globalize from 'scripts/globalize'; + +import { LibraryViewSettings } from 'types/library'; + +interface FiltersSeriesStatusProps { + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const statusFiltersOptions = [ + { label: globalize.translate('Continuing'), value: 'Continuing' }, + { label: globalize.translate('Ended'), value: 'Ended' } +]; + +const FiltersSeriesStatus: FC = ({ + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersSeriesStatusChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = String(event.target.value); + const existingValue = libraryViewSettings.filtersSeriesStatus as string[]; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: string) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + + filtersSeriesStatus: newValue + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + filtersSeriesStatus: [...existingValue, value] + })); + } + }, + [setLibraryViewSettings, libraryViewSettings.filtersSeriesStatus] + ); + + return ( + + + {statusFiltersOptions.map((filter) => ( + + } + label={filter.label} + /> + ))} + + + ); +}; + +export default FiltersSeriesStatus; diff --git a/src/apps/experimental/components/library/filter/FiltersStatus.tsx b/src/apps/experimental/components/library/filter/FiltersStatus.tsx new file mode 100644 index 000000000000..1215e92641ab --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersStatus.tsx @@ -0,0 +1,88 @@ +import React, { FC, useCallback } from 'react'; + +import FormControl from '@mui/material/FormControl'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import globalize from 'scripts/globalize'; + +import { LibraryViewSettings } from 'types/library'; + +interface FiltersStatusProps { + visibleSettings: string[]; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const statusFiltersOptions = [ + { label: globalize.translate('Played'), value: 'IsPlayed' }, + { label: globalize.translate('Unplayed'), value: 'IsUnplayed' }, + { label: globalize.translate('Favorite'), value: 'IsFavorite' }, + { label: globalize.translate('ContinueWatching'), value: 'IsResumable' }, + { label: globalize.translate('OptionSpecialEpisode'), value: 'ParentIndexNumber' }, + { label: globalize.translate('OptionMissingEpisode'), value: 'IsMissing' }, + { label: globalize.translate('OptionUnairedEpisode'), value: 'IsUnaired' } +]; + +const FiltersStatus: FC = ({ + visibleSettings, + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersStatusChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = String(event.target.value); + const existingValue = libraryViewSettings.filtersStatus as string[]; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: string) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + + filtersStatus: newValue + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + filtersStatus: [...existingValue, value] + })); + } + }, + [setLibraryViewSettings, libraryViewSettings.filtersStatus] + ); + + return ( + + + {statusFiltersOptions + .filter( + (filter) => visibleSettings.includes(filter.value) + ) + .map((filter) => ( + + } + label={filter.label} + /> + ))} + + + ); +}; + +export default FiltersStatus; diff --git a/src/apps/experimental/components/library/filter/FiltersStudios.tsx b/src/apps/experimental/components/library/filter/FiltersStudios.tsx new file mode 100644 index 000000000000..8ea70cc01fb4 --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersStudios.tsx @@ -0,0 +1,73 @@ +import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useCallback } from 'react'; + +import FormControl from '@mui/material/FormControl'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; + +import { LibraryViewSettings } from 'types/library'; + +interface FiltersStudiosProps { + filtes?: BaseItemDtoQueryResult; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const FiltersStudios: FC = ({ + filtes, + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersStudiosChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = String(event.target.value); + const existingValue = libraryViewSettings.filtersStudioIds as string[]; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: string) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + filtersStudioIds: newValue + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + filtersStudioIds: [...existingValue, value] + })); + } + }, + [setLibraryViewSettings, libraryViewSettings.filtersStudioIds] + ); + + return ( + + + {filtes?.Items?.map((filter) => ( + + } + label={filter.Name} + /> + ))} + + + ); +}; + +export default FiltersStudios; diff --git a/src/apps/experimental/components/library/filter/FiltersTags.tsx b/src/apps/experimental/components/library/filter/FiltersTags.tsx new file mode 100644 index 000000000000..c540ed5287e5 --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersTags.tsx @@ -0,0 +1,72 @@ +import type { QueryFiltersLegacy } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useCallback } from 'react'; +import FormControl from '@mui/material/FormControl'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; + +import { LibraryViewSettings } from 'types/library'; + +interface FiltersTagsProps { + filtes?: QueryFiltersLegacy; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const FiltersTags: FC = ({ + filtes, + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersTagsChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = String(event.target.value); + const existingValue = libraryViewSettings.filtersTags as string[]; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: string) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + filtersTags: newValue + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + filtersTags: [...existingValue, value] + })); + } + }, + [setLibraryViewSettings, libraryViewSettings.filtersTags] + ); + + return ( + + + {filtes?.Tags?.map((filter) => ( + + } + label={filter} + /> + ))} + + + ); +}; + +export default FiltersTags; diff --git a/src/apps/experimental/components/library/filter/FiltersVideoTypes.tsx b/src/apps/experimental/components/library/filter/FiltersVideoTypes.tsx new file mode 100644 index 000000000000..ed3351b85406 --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersVideoTypes.tsx @@ -0,0 +1,83 @@ +import React, { FC, useCallback } from 'react'; +import FormControl from '@mui/material/FormControl'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; + +import { LibraryViewSettings } from 'types/library'; + +interface FiltersVideoTypesProps { + visibleSettings: string[]; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const videoTypesFiltersOptions = [ + { label: 'SD', value: 'IsSD' }, + { label: 'DVD', value: 'Dvd' }, + { label: 'HD', value: 'IsHD' }, + { label: 'Blu-ray', value: 'Bluray' }, + { label: '4K', value: 'Is4K' }, + { label: '3D', value: 'Is3D' }, + { label: 'ISO', value: 'Iso' } +]; + +const FiltersVideoTypes: FC = ({ + visibleSettings, + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersVideoTypesChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = String(event.target.value); + const existingValue = libraryViewSettings.filtersVideoTypes as string[]; + + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: string) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + filtersVideoTypes: newValue + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + filtersVideoTypes: [...existingValue, value] + })); + } + }, + [setLibraryViewSettings, libraryViewSettings.filtersVideoTypes] + ); + + return ( + + + {videoTypesFiltersOptions + .filter((filter) => visibleSettings.includes(filter.value)) + .map((filter) => ( + + } + label={filter.label} + /> + ))} + + + ); +}; + +export default FiltersVideoTypes; diff --git a/src/apps/experimental/components/library/filter/FiltersYears.tsx b/src/apps/experimental/components/library/filter/FiltersYears.tsx new file mode 100644 index 000000000000..6a06266c8e66 --- /dev/null +++ b/src/apps/experimental/components/library/filter/FiltersYears.tsx @@ -0,0 +1,71 @@ +import type { QueryFiltersLegacy } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC, useCallback } from 'react'; +import FormControl from '@mui/material/FormControl'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; + +import { LibraryViewSettings } from 'types/library'; + +interface FiltersYearsProps { + filtes?: QueryFiltersLegacy; + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const FiltersYears: FC = ({ + filtes, + libraryViewSettings, + setLibraryViewSettings +}) => { + const onFiltersYearsChange = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const value = Number(event.target.value); + const existingValue = libraryViewSettings.filtersYears as number[]; + if (existingValue?.includes(value)) { + const newValue = existingValue?.filter( + (prevState: number) => prevState !== value + ); + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + filtersYears: newValue + })); + } else { + setLibraryViewSettings((prevState) => ({ + ...prevState, + StartIndex: 0, + filtersYears: [...existingValue, value] + })); + } + }, + [setLibraryViewSettings, libraryViewSettings.filtersYears] + ); + + return ( + + + {filtes?.Years?.map((filter) => ( + + } + label={filter} + /> + ))} + + + ); +}; + +export default FiltersYears; diff --git a/src/apps/experimental/components/tabs/tabRoutes.ts b/src/apps/experimental/components/tabs/tabRoutes.ts index 32c3b8eb448a..a96daac4788a 100644 --- a/src/apps/experimental/components/tabs/tabRoutes.ts +++ b/src/apps/experimental/components/tabs/tabRoutes.ts @@ -71,119 +71,6 @@ const TabRoutes: TabRoute[] = [ 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 - } - ] } ]; diff --git a/src/apps/experimental/routes/asyncRoutes/user.ts b/src/apps/experimental/routes/asyncRoutes/user.ts index d7ea0dd7d42e..061201027aef 100644 --- a/src/apps/experimental/routes/asyncRoutes/user.ts +++ b/src/apps/experimental/routes/asyncRoutes/user.ts @@ -4,5 +4,9 @@ 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.html', page: 'library', type: AsyncRouteType.Experimental }, + { path: 'tv.html', page: 'library', type: AsyncRouteType.Experimental }, + { path: 'music.html', page: 'library', type: AsyncRouteType.Experimental }, + { path: 'books.html', page: 'library', type: AsyncRouteType.Experimental }, + { path: 'homevideos.html', page: 'library', type: AsyncRouteType.Experimental } ]; diff --git a/src/apps/experimental/routes/library/LibraryView.tsx b/src/apps/experimental/routes/library/LibraryView.tsx new file mode 100644 index 000000000000..04005199a7db --- /dev/null +++ b/src/apps/experimental/routes/library/LibraryView.tsx @@ -0,0 +1,90 @@ +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; +import React, { FC } from 'react'; + +import Box from '@mui/material/Box'; +import { useLibrarySettings } from 'hooks/useLibrarySettings'; +import { useGetViewItemsByType } from 'hooks/useFetchItems'; + +import * as userSettings from 'scripts/settings/userSettings'; +import Loading from 'components/loading/LoadingComponent'; +import AlphaPickerContainer from '../../components/library/container/AlphaPickerContainer'; +import LibraryItemsContainer from '../../components/library/container/LibraryItemsContainer'; +import Pagination from '../../components/library/Pagination'; + +const visibleAlphaPicker = [ + 'movies', + 'favorites', + 'trailers', + 'series', + 'episodes', + 'albums', + 'albumArtists', + 'artists' +]; +interface LibraryViewProps { + collectionType?: string | null; + parentId: string | null; + item?: BaseItemDto; + viewType: string; +} + +const LibraryView: FC = ({ + parentId, + viewType, + collectionType +}) => { + const { libraryViewSettings, setLibraryViewSettings } = useLibrarySettings(); + const { + isLoading: isLoading, + data: itemsResult, + isFetching + } = useGetViewItemsByType(viewType, parentId); + + const limit = userSettings.libraryPageSize(undefined); + const totalRecordCount = itemsResult?.TotalRecordCount || 0; + const showControls = limit > 0 && limit < totalRecordCount; + + return ( + + {showControls && ( +
+ +
+ )} + + {visibleAlphaPicker.includes(viewType) && ( + + )} + + {isLoading || isFetching ? ( + + ) : ( + + )} + + {showControls && ( +
+ +
+ )} +
+ ); +}; + +export default LibraryView; diff --git a/src/apps/experimental/routes/library/SuggestionsView.tsx b/src/apps/experimental/routes/library/SuggestionsView.tsx new file mode 100644 index 000000000000..6fa8ebe43218 --- /dev/null +++ b/src/apps/experimental/routes/library/SuggestionsView.tsx @@ -0,0 +1,76 @@ +import React, { FC } from 'react'; +import Box from '@mui/material/Box'; +import SuggestionsItemsContainer from '../../components/library/container/SuggestionsItemsContainer'; +import RecommendationItemsContainer from '../../components/library/container/RecommendationItemsContainer'; +import FavoriteItemsContainer from '../../components/library/container/FavoriteItemsContainer'; + +interface SuggestionsViewProps { + collectionType?: string | null; + parentId?: string | null; +} + +const SuggestionsView: FC = ({ collectionType, parentId }) => { + const getVisibleId = () => { + const visibleSuggestionsId: string[] = []; + const visibleFavoriteId: string[] = []; + + if (collectionType === 'movies') { + visibleSuggestionsId.push('suggestionContinueWatchingMovies'); + visibleSuggestionsId.push('suggestionLatestMovies'); + visibleFavoriteId.push('favoriteMovies'); + } + + if (collectionType === 'tvshows') { + visibleSuggestionsId.push('suggestionContinueWatchingEpisode'); + visibleSuggestionsId.push('suggestionLatestEpisode'); + visibleSuggestionsId.push('suggestionNextUp'); + visibleFavoriteId.push('favoriteShows'); + visibleFavoriteId.push('favoriteEpisode'); + } + + if (collectionType === 'music') { + visibleSuggestionsId.push('suggestionLatestMusic'); + visibleSuggestionsId.push('suggestionFrequentlyPlayed'); + visibleSuggestionsId.push('suggestionRecentlyPlayed'); + visibleSuggestionsId.push('suggestionContinueWatchingMovies'); + visibleFavoriteId.push('favoriteArtists'); + visibleFavoriteId.push('favoriteAlbums'); + visibleFavoriteId.push('favoriteSongs'); + } + + if (collectionType === 'music') { + visibleSuggestionsId.push('suggestionLatestMusic'); + visibleSuggestionsId.push('suggestionFrequentlyPlayed'); + visibleSuggestionsId.push('suggestionRecentlyPlayed'); + visibleSuggestionsId.push('suggestionContinueWatchingMovies'); + visibleFavoriteId.push('favoriteArtists'); + visibleFavoriteId.push('favoriteAlbums'); + visibleFavoriteId.push('favoriteSongs'); + } + + return { + visibleSuggestionsId, + visibleFavoriteId + }; + }; + + return ( + + + + + + {collectionType === 'movies' && ( + + )} + + ); +}; + +export default SuggestionsView; diff --git a/src/apps/experimental/routes/library/UpComingView.tsx b/src/apps/experimental/routes/library/UpComingView.tsx new file mode 100644 index 000000000000..2258e6bde70b --- /dev/null +++ b/src/apps/experimental/routes/library/UpComingView.tsx @@ -0,0 +1,50 @@ +import React, { FC, useEffect, useState } from 'react'; +import Box from '@mui/material/Box'; +import { useGetUpcomingEpisodes } from 'hooks/useFetchItems'; +import Loading from 'components/loading/LoadingComponent'; +import globalize from 'scripts/globalize'; +import UpComingItemsContainer from '../../components/library/container/UpComingItemsContainer'; + +interface UpComingViewProps { + parentId?: string | null; +} + +const UpComingView: FC = ({ parentId }) => { + const [ enableFetch, setEnableFetch ] = useState(false); + const { isLoading, data: upcomingEpisodesResult } = + useGetUpcomingEpisodes(parentId, enableFetch); + + useEffect(() => { + if (parentId) { + setEnableFetch(true); + } + + return () => { + setEnableFetch(false); + }; + }, [parentId]); + + if (isLoading) return ; + + return ( + + {!upcomingEpisodesResult?.Items?.length ? ( +
+

{globalize.translate('MessageNothingHere')}

+

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

+
+ ) : ( + + )} +
+ ); +}; + +export default UpComingView; diff --git a/src/apps/experimental/routes/library/index.tsx b/src/apps/experimental/routes/library/index.tsx new file mode 100644 index 000000000000..e92d52374854 --- /dev/null +++ b/src/apps/experimental/routes/library/index.tsx @@ -0,0 +1,155 @@ +import React, { FC, useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import Box from '@mui/material/Box'; +import { LibrarySettingsProvider } from 'hooks/useLibrarySettings'; +import * as userSettings from 'scripts/settings/userSettings'; +import Page from 'components/Page'; +import Loading from 'components/loading/LoadingComponent'; +import LibraryHeader from '../../components/library/LibraryHeader'; +import LibraryView from './LibraryView'; +import SuggestionsView from './SuggestionsView'; +import UpComingView from './UpComingView'; + +import { LibraryViewSelectOptions } from 'types/library'; + +export const getLibraryViewMenuOptions = ( + collectionType: string | null | undefined +) => { + const viewSelectOptions: LibraryViewSelectOptions[] = []; + if (collectionType === 'movies') { + viewSelectOptions.push( + { title: 'Movies', value: 'movies' }, + { title: 'Trailers', value: 'trailers' }, + { title: 'Collections', value: 'collections' }, + { title: 'Genres', value: 'genres' }, + { title: 'Studios', value: 'studios' }, + { title: 'Suggestions', value: 'suggestions' } + ); + } + + if (collectionType === 'tvshows') { + viewSelectOptions.push( + { title: 'Series', value: 'series' }, + { title: 'Episodes', value: 'episodes' }, + { title: 'Genres', value: 'genres' }, + { title: 'Studios', value: 'studios' }, + { title: 'Upcoming', value: 'upcoming' }, + { title: 'Suggestions', value: 'suggestions' } + ); + } + + if (collectionType === 'music') { + viewSelectOptions.push( + { title: 'Albums', value: 'albums' }, + { title: 'AlbumArtists', value: 'albumArtists' }, + { title: 'Artists', value: 'artists' }, + { title: 'Playlist', value: 'playlist' }, + { title: 'Genres', value: 'genres' }, + { title: 'Songs', value: 'songs' }, + { title: 'Suggestions', value: 'suggestions' } + ); + } + + if (collectionType === 'books') { + viewSelectOptions.push({ title: 'Books', value: 'books' }); + } + + if (collectionType === 'homevideos') { + viewSelectOptions.push( + { title: 'Photos', value: 'photos' }, + { title: 'Videos', value: 'videos' } + ); + } + + return viewSelectOptions; +}; + +const getBackDropType = (collectionType: string | null | undefined) => { + if (collectionType === 'movies') { + return 'movie'; + } + + if (collectionType === 'tvshows') { + return 'series'; + } + + if (collectionType === 'music') { + return 'musicartist'; + } + + if (collectionType === 'books') { + return 'book'; + } + + if (collectionType === 'homevideos') { + return 'video, photo'; + } + + return ''; +}; + +const Library: FC = () => { + const [searchParams] = useSearchParams(); + const parentId = searchParams.get('topParentId'); + const collectionType = searchParams.get('collectionType'); + const [viewSelectOptions, setViewSelectOptions] = useState([]); + const [ viewType, setViewType] = useState(''); + + const getViewComponent = () => { + let component; + + switch (viewType) { + case 'suggestions': + component = ; + break; + + case 'upcoming': + component = ; + break; + + default: + component = ; + break; + } + + return component; + }; + + useEffect(() => { + const viewOptions = getLibraryViewMenuOptions(collectionType); + setViewSelectOptions(viewOptions); + const defaultView = userSettings.get('landing-' + parentId, false); + setViewType( defaultView ?? viewOptions[0].value); + }, [collectionType, parentId]); + + if (!viewType) return ; + + return ( + + + + + + {getViewComponent()} + + + + + ); +}; + +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 b58cc957e5c9..000000000000 --- 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/interface'; - -const CollectionsView: FC = ({ topParentId }) => { - 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 bca4227df480..000000000000 --- 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/interface'; - -const FavoritesView: FC = ({ topParentId }) => { - 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 dd1af9efc9a1..000000000000 --- a/src/apps/experimental/routes/movies/GenresView.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'; -import React, { FC, useCallback, useEffect, useState } from 'react'; - -import loading from '../../../../components/loading/loading'; -import GenresItemsContainer from '../../../../components/common/GenresItemsContainer'; -import { LibraryViewProps } from '../../../../types/interface'; - -const GenresView: FC = ({ 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]); - - 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 510ed9e2b230..000000000000 --- 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/interface'; - -const MoviesView: FC = ({ topParentId }) => { - 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/SuggestionsView.tsx b/src/apps/experimental/routes/movies/SuggestionsView.tsx deleted file mode 100644 index 1d468d009b4c..000000000000 --- a/src/apps/experimental/routes/movies/SuggestionsView.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import type { BaseItemDto, BaseItemDtoQueryResult, RecommendationDto } from '@jellyfin/sdk/lib/generated-client'; -import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; - -import layoutManager from '../../../../components/layoutManager'; -import loading from '../../../../components/loading/loading'; -import dom from '../../../../scripts/dom'; -import globalize from '../../../../scripts/globalize'; -import RecommendationContainer from '../../../../components/common/RecommendationContainer'; -import SectionContainer from '../../../../components/common/SectionContainer'; -import { LibraryViewProps } from '../../../../types/interface'; - -const SuggestionsView: FC = ({ topParentId }) => { - const [ latestItems, setLatestItems ] = useState([]); - const [ resumeResult, setResumeResult ] = useState({}); - const [ recommendations, setRecommendations ] = useState([]); - const element = useRef(null); - - 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]); - - return ( -
- - - - - {!recommendations.length ?
-

{globalize.translate('MessageNothingHere')}

-

{globalize.translate('MessageNoMovieSuggestionsAvailable')}

-
: recommendations.map(recommendation => { - return ; - })} -
- ); -}; - -export default SuggestionsView; diff --git a/src/apps/experimental/routes/movies/TrailersView.tsx b/src/apps/experimental/routes/movies/TrailersView.tsx deleted file mode 100644 index 55f6189cfceb..000000000000 --- 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/interface'; - -const TrailersView: FC = ({ topParentId }) => { - 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 2b2c6ea9d328..000000000000 --- a/src/apps/experimental/routes/movies/index.tsx +++ /dev/null @@ -1,104 +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, useEffect, useRef } from 'react'; -import { useLocation, useSearchParams } from 'react-router-dom'; - -import Page from '../../../../components/Page'; -import globalize from '../../../../scripts/globalize'; -import libraryMenu from '../../../../scripts/libraryMenu'; -import CollectionsView from './CollectionsView'; -import FavoritesView from './FavoritesView'; -import GenresView from './GenresView'; -import MoviesView from './MoviesView'; -import SuggestionsView from './SuggestionsView'; -import TrailersView from './TrailersView'; -import { getDefaultTabIndex } from '../../components/tabs/tabRoutes'; - -const Movies: FC = () => { - const location = useLocation(); - const [ searchParams ] = useSearchParams(); - const searchParamsTab = searchParams.get('tab'); - const currentTabIndex = searchParamsTab !== null ? parseInt(searchParamsTab, 10) : - getDefaultTabIndex(location.pathname, searchParams.get('topParentId')); - const element = useRef(null); - - const getTabComponent = (index: number) => { - if (index == null) { - throw new Error('index cannot be null'); - } - - let component; - switch (index) { - case 0: - component = ; - break; - - case 1: - component = ; - break; - - case 2: - component = ; - break; - - case 3: - component = ; - break; - - case 4: - component = ; - break; - - case 5: - component = ; - break; - } - - 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)} - - -
- ); -}; - -export default Movies; diff --git a/src/components/common/Filter.tsx b/src/components/common/Filter.tsx deleted file mode 100644 index c3ccdd62f3ab..000000000000 --- 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/GenresItemsContainer.tsx b/src/components/common/GenresItemsContainer.tsx deleted file mode 100644 index 09623e7e5753..000000000000 --- 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/ItemsContainer.tsx b/src/components/common/ItemsContainer.tsx deleted file mode 100644 index 6289c1d81132..000000000000 --- 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 837fe85fd3fb..000000000000 --- 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 3dd5a60ffd3d..000000000000 --- 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/RecommendationContainer.tsx b/src/components/common/RecommendationContainer.tsx deleted file mode 100644 index 6c28272c9f0d..000000000000 --- 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 13c29ee61e12..000000000000 --- 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/SelectView.tsx b/src/components/common/SelectView.tsx deleted file mode 100644 index bfb34555b87d..000000000000 --- 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 093dc7487448..000000000000 --- 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 db5cb8995624..000000000000 --- 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 5cbc7aace60f..000000000000 --- a/src/components/common/ViewItemsContainer.tsx +++ /dev/null @@ -1,409 +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 { CardOptions, ViewQuerySettings } from '../../types/interface'; -import ServerConnections from '../ServerConnections'; -import { useLocalStorage } from '../../hooks/useLocalStorage'; -import listview from '../listview/listview'; -import cardBuilder from '../cardbuilder/cardBuilder'; - -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/homeScreenSettings/homeScreenSettings.js b/src/components/homeScreenSettings/homeScreenSettings.js index 5a192905b8b7..795aae959d4f 100644 --- a/src/components/homeScreenSettings/homeScreenSettings.js +++ b/src/components/homeScreenSettings/homeScreenSettings.js @@ -160,10 +160,11 @@ function getLandingScreenOptions(type) { } function getLandingScreenOptionsHtml(type, userValue) { - return getLandingScreenOptions(type).map(o => { + const landingScreenOptions = getLandingScreenOptions(type); + return landingScreenOptions.map(o => { const selected = userValue === o.value || (o.isDefault && !userValue); const selectedHtml = selected ? ' selected' : ''; - const optionValue = o.isDefault ? '' : o.value; + const optionValue = o.isDefault ? `${landingScreenOptions[0].value}` : o.value; return ``; }).join(''); diff --git a/src/components/router/appRouter.js b/src/components/router/appRouter.js index 7c0d8e86c1bf..1039d50baee9 100644 --- a/src/components/router/appRouter.js +++ b/src/components/router/appRouter.js @@ -638,7 +638,7 @@ class AppRouter { if (context !== 'folders' && !itemHelper.isLocalItem(item)) { if (item.CollectionType == 'movies') { - url = '#/movies.html?topParentId=' + item.Id; + url = '#/movies.html?topParentId=' + item.Id + '&collectionType=' + item.CollectionType; if (options && options.section === 'latest') { url += '&tab=1'; @@ -648,7 +648,7 @@ class AppRouter { } if (item.CollectionType == 'tvshows') { - url = '#/tv.html?topParentId=' + item.Id; + url = '#/tv.html?topParentId=' + item.Id + '&collectionType=' + item.CollectionType; if (options && options.section === 'latest') { url += '&tab=1'; @@ -658,7 +658,7 @@ class AppRouter { } if (item.CollectionType == 'music') { - url = '#/music.html?topParentId=' + item.Id; + url = '#/music.html?topParentId=' + item.Id + '&collectionType=' + item.CollectionType; if (options?.section === 'latest') { url += '&tab=1'; @@ -666,6 +666,18 @@ class AppRouter { return url; } + + if (item.CollectionType == 'books') { + url = '#/books.html?topParentId=' + item.Id + '&collectionType=' + item.CollectionType; + + return url; + } + + if (item.CollectionType == 'homevideos') { + url = '#/homevideos.html?topParentId=' + item.Id + '&collectionType=' + item.CollectionType; + + return url; + } } const itemTypes = ['Playlist', 'TvChannel', 'Program', 'BoxSet', 'MusicAlbum', 'MusicGenre', 'Person', 'Recording', 'MusicArtist']; diff --git a/src/hooks/useApi.tsx b/src/hooks/useApi.tsx index ff94b7a5374e..a1eb4b624dd0 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 000000000000..c2656fb1a426 --- /dev/null +++ b/src/hooks/useFetchItems.ts @@ -0,0 +1,1228 @@ +import type { + ChannelsApiGetChannelsRequest, + ConfigurationApiGetNamedConfigurationRequest, + DevicesApiGetDevicesRequest, + ItemsApiGetItemsRequest, + LibraryApiGetMediaFoldersRequest, + ParentalRating, + UserApiCreateUserByNameRequest, + UserApiDeleteUserRequest, + UserApiGetUserByIdRequest, + UserApiGetUsersRequest, + UserApiUpdateUserConfigurationRequest, + UserApiUpdateUserEasyPasswordRequest, + UserApiUpdateUserPasswordRequest, + UserApiUpdateUserPolicyRequest, + UserApiUpdateUserRequest +} from '@jellyfin/sdk/lib/generated-client'; + +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 { LocationType } from '@jellyfin/sdk/lib/generated-client/models/location-type'; +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 { getChannelsApi } from '@jellyfin/sdk/lib/utils/api/channels-api'; +import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api'; +import { getDevicesApi } from '@jellyfin/sdk/lib/utils/api/devices-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 { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api'; +import { getLocalizationApi } from '@jellyfin/sdk/lib/utils/api/localization-api'; +import { getMoviesApi } from '@jellyfin/sdk/lib/utils/api/movies-api'; +import { getPersonsApi } from '@jellyfin/sdk/lib/utils/api/persons-api'; +import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api'; +import { getStudiosApi } from '@jellyfin/sdk/lib/utils/api/studios-api'; +import { getTvShowsApi } from '@jellyfin/sdk/lib/utils/api/tv-shows-api'; +import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api'; +import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api/user-library-api'; +import { getUserViewsApi } from '@jellyfin/sdk/lib/utils/api/user-views-api'; +import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { JellyfinApiContext, useApi } from './useApi'; +import { useLibrarySettings } from './useLibrarySettings'; + +import { getAlphaPickerQuery, getFiltersQuery, getImageTypesQuery, getIsFavoriteQuery, getItemFieldsQuery, getItemTypesQuery, getLimitQuery, getSortQuery } from 'utils/items'; + +import { LibraryParametersOptions, LibraryViewSettings } from 'types/library'; +import { Sections } from 'types/sections'; + +type ParentId = string | null | undefined; + +const fetchGetItem = async ( + currentApi: JellyfinApiContext, + itemId: string, + signal: AbortSignal | undefined +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + const response = await getUserLibraryApi(api).getItem( + { + userId: user.Id, + itemId: itemId + }, + { + signal: signal + } + ); + return response.data; + } +}; + +export const useGetItem = (parentId: ParentId, enableFetch: boolean) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['Item', parentId], + queryFn: ({ signal }) => fetchGetItem(currentApi, String(parentId), signal), + enabled: enableFetch + }); +}; + +const fetchGetItems = async ( + currentApi: JellyfinApiContext, + parametersOptions: ItemsApiGetItemsRequest, + signal: AbortSignal | undefined +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + const response = await getItemsApi(api).getItems( + { + userId: user.Id, + ...parametersOptions + }, + { + signal: signal + } + ); + return response.data; + } +}; + +export const useGetItems = (parametersOptions: ItemsApiGetItemsRequest) => { + const currentApi = useApi(); + return useQuery({ + queryKey: [ + 'Items', + { + ...parametersOptions + } + ], + queryFn: ({ signal }) => + fetchGetItems(currentApi, parametersOptions, signal) + }); +}; + +const fetchGetItemsByViewType = async ( + currentApi: JellyfinApiContext, + viewType: string, + parentId: ParentId, + libraryViewSettings: LibraryViewSettings, + signal: AbortSignal | undefined +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + let response; + switch (viewType) { + case 'albumArtists': { + response = await getArtistsApi(api).getAlbumArtists( + { + userId: user.Id, + parentId: parentId ?? undefined, + ...getLimitQuery(), + ...getSortQuery(libraryViewSettings), + ...getItemFieldsQuery(viewType, libraryViewSettings), + ...getImageTypesQuery(libraryViewSettings), + ...getFiltersQuery(viewType, libraryViewSettings), + ...getItemTypesQuery(viewType), + ...getAlphaPickerQuery(libraryViewSettings), + startIndex: libraryViewSettings.StartIndex + }, + { + signal: signal + } + ); + break; + } + case 'artists': { + response = await getArtistsApi(api).getArtists( + { + userId: user.Id, + parentId: parentId ?? undefined, + ...getLimitQuery(), + ...getSortQuery(libraryViewSettings), + ...getItemFieldsQuery(viewType, libraryViewSettings), + ...getImageTypesQuery(libraryViewSettings), + ...getFiltersQuery(viewType, libraryViewSettings), + ...getItemTypesQuery(viewType), + ...getAlphaPickerQuery(libraryViewSettings), + startIndex: libraryViewSettings.StartIndex + }, + { + signal: signal + } + ); + break; + } + case 'studios': + response = await getStudiosApi(api).getStudios( + { + userId: user.Id, + parentId: parentId ?? undefined, + ...getLimitQuery(), + ...getSortQuery(libraryViewSettings), + ...getItemFieldsQuery(viewType, libraryViewSettings), + ...getImageTypesQuery(libraryViewSettings), + ...getItemTypesQuery(viewType), + startIndex: 0 + }, + { + signal: signal + } + ); + break; + case 'genres': { + response = await getGenresApi(api).getGenres( + { + userId: user.Id, + enableTotalRecordCount: false, + enableImageTypes: [ImageType.Primary], + parentId: parentId ?? undefined + }, + { + signal: signal + } + ); + break; + } + default: { + response = await getItemsApi(api).getItems( + { + userId: user.Id, + recursive: true, + imageTypeLimit: 1, + parentId: parentId ?? undefined, + ...getLimitQuery(), + ...getIsFavoriteQuery(viewType), + ...getSortQuery(libraryViewSettings), + ...getItemFieldsQuery(viewType, libraryViewSettings), + ...getImageTypesQuery(libraryViewSettings), + ...getFiltersQuery(viewType, libraryViewSettings), + ...getItemTypesQuery(viewType), + ...getAlphaPickerQuery(libraryViewSettings), + startIndex: libraryViewSettings.StartIndex + }, + { + signal: signal + } + ); + break; + } + } + return response.data; + } +}; + +export const useGetViewItemsByType = (viewType: string, parentId: ParentId) => { + const currentApi = useApi(); + const { libraryViewSettings } = useLibrarySettings(); + + return useQuery({ + queryKey: [ + 'ViewItemsByType', + viewType, + parentId, + { + ...libraryViewSettings + } + ], + queryFn: ({ signal }) => + fetchGetItemsByViewType( + currentApi, + viewType, + parentId, + libraryViewSettings, + signal + ), + refetchOnWindowFocus: false, + enabled: + [ + 'movies', + 'favorites', + 'collections', + 'trailers', + 'genres', + 'series', + 'episodes', + 'studios', + 'albums', + 'albumArtists', + 'artists', + 'playlist', + 'songs', + 'books', + 'photos', + 'videos' + ].includes(viewType) && !!parentId + }); +}; + +const fetchGetQueryFiltersLegacy = async ( + currentApi: JellyfinApiContext, + parentId: ParentId, + itemType: BaseItemKind[], + signal: AbortSignal | undefined +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + const response = await getFilterApi(api).getQueryFiltersLegacy( + { + userId: user.Id, + parentId: parentId ?? undefined, + includeItemTypes: itemType + }, + { + signal: signal + } + ); + return response.data; + } +}; + +export const useGetQueryFiltersLegacy = ( parentId: ParentId, itemType: BaseItemKind[], enableFetch: boolean ) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['QueryFiltersLegacy', parentId, itemType], + queryFn: ({ signal }) => + fetchGetQueryFiltersLegacy(currentApi, parentId, itemType, signal), + enabled: enableFetch + }); +}; + +const fetchGetQueryFilters = async ( + currentApi: JellyfinApiContext, + parentId: ParentId, + signal: AbortSignal | undefined +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + const response = await getFilterApi(api).getQueryFilters( + { + userId: user.Id, + parentId: parentId ?? undefined + }, + { + signal: signal + } + ); + return response.data; + } +}; + +export const useGetQueryFilters = (parentId: ParentId) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['QueryFilters', parentId], + queryFn: ({ signal }) => + fetchGetQueryFilters(currentApi, parentId, signal), + enabled: !!parentId + }); +}; + +const fetchGetGenres = async ( + currentApi: JellyfinApiContext, + parentId: ParentId, + itemType: BaseItemKind[], + signal: AbortSignal | undefined +) => { + 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: 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 + }); +}; + +const fetchGetStudios = async ( + currentApi: JellyfinApiContext, + parentId: ParentId, + itemType: BaseItemKind[], + signal: AbortSignal | undefined +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + const response = await getStudiosApi(api).getStudios( + { + userId: user.Id, + includeItemTypes: itemType, + fields: [ + ItemFields.DateCreated, + ItemFields.PrimaryImageAspectRatio + ], + enableImageTypes: [ImageType.Thumb], + parentId: parentId ?? undefined, + enableTotalRecordCount: false + }, + { + signal: signal + } + ); + return response.data; + } +}; + +export const useGetStudios = (parentId: ParentId, itemType: BaseItemKind[], enableFetch: boolean) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['Studios', parentId, itemType], + queryFn: ({ signal }) => + fetchGetStudios(currentApi, parentId, itemType, signal), + enabled: enableFetch + }); +}; + +const fetchGetMovieRecommendations = async ( + currentApi: JellyfinApiContext, + parentId: ParentId, + signal: AbortSignal | undefined +) => { + 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: signal + } + ); + return response.data; + } +}; + +export const useGetMovieRecommendations = (parentId: ParentId, enableFetch: boolean) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['MovieRecommendations', parentId], + queryFn: ({ signal }) => + fetchGetMovieRecommendations(currentApi, parentId, signal), + enabled: enableFetch + }); +}; + +const fetchGetUpcomingEpisodes = async ( + currentApi: JellyfinApiContext, + parentId: ParentId, + signal: AbortSignal | undefined +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + const response = await getTvShowsApi(api).getUpcomingEpisodes( + { + userId: user.Id, + limit: 25, + fields: [ItemFields.AirTime], + parentId: parentId ?? undefined, + imageTypeLimit: 1, + enableImageTypes: [ + ImageType.Primary, + ImageType.Backdrop, + ImageType.Thumb + ] + }, + { + signal: signal + } + ); + return response.data; + } +}; + +export const useGetUpcomingEpisodes = (parentId: ParentId, enableFetch: boolean) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['UpcomingEpisodes', parentId], + queryFn: ({ signal }) => + fetchGetUpcomingEpisodes(currentApi, parentId, signal), + enabled: enableFetch + }); +}; + +const fetchGetItemsBySuggestionsType = async ( + currentApi: JellyfinApiContext, + viewType: string | undefined, + parentId: ParentId, + parametersOptions: LibraryParametersOptions | undefined, + signal: AbortSignal | undefined +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + let response; + switch (viewType) { + case '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, + ...parametersOptions + }, + { + signal: signal + } + ) + ).data.Items; + break; + } + case '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, + ...parametersOptions + }, + { + signal: signal + } + ) + ).data.Items; + break; + } + case 'latestMedia': { + response = ( + await getUserLibraryApi(api).getLatestMedia( + { + userId: user.Id, + fields: [ + ItemFields.PrimaryImageAspectRatio, + ItemFields.MediaSourceCount, + ItemFields.BasicSyncInfo + ], + parentId: parentId ?? undefined, + imageTypeLimit: 1, + enableImageTypes: [ImageType.Primary], + ...parametersOptions + }, + { + signal: 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, + ...parametersOptions + }, + { + signal: signal + } + ) + ).data.Items; + break; + } + } + return response; + } +}; + +export const useGetItemsBySectionType = ( + sections: Sections, + parentId: ParentId +) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['ItemsBySuggestionsType', sections.id], + queryFn: ({ signal }) => + fetchGetItemsBySuggestionsType( + currentApi, + sections.viewType, + parentId, + sections.parametersOptions, + signal + ), + enabled: !!sections.id + }); +}; + +const fetchGetItemsByFavoriteType = async ( + currentApi: JellyfinApiContext, + parentId: ParentId, + sections: Sections, + signal: AbortSignal | undefined +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + let response; + switch (sections.viewType) { + case 'artists': { + response = ( + await getArtistsApi(api).getArtists( + { + userId: user.Id, + parentId: parentId ?? undefined, + sortBy: [ItemSortBy.SortName], + sortOrder: [SortOrder.Ascending], + fields: [ + ItemFields.PrimaryImageAspectRatio, + ItemFields.BasicSyncInfo + ], + filters: [ItemFilter.IsFavorite], + collapseBoxSetItems: false, + limit: 25, + enableTotalRecordCount: false, + ...sections.parametersOptions + }, + { + signal: signal + } + ) + ).data.Items; + break; + } + case 'persons': { + response = ( + await getPersonsApi(api).getPersons( + { + userId: user.Id, + sortBy: [ItemSortBy.SortName], + sortOrder: [SortOrder.Ascending], + fields: [ + ItemFields.PrimaryImageAspectRatio, + ItemFields.BasicSyncInfo + ], + filters: [ItemFilter.IsFavorite], + collapseBoxSetItems: false, + limit: 25, + enableTotalRecordCount: false, + ...sections.parametersOptions + }, + { + signal: 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 + ], + filters: [ItemFilter.IsFavorite], + collapseBoxSetItems: false, + limit: 25, + enableTotalRecordCount: false, + recursive: true, + excludeLocationTypes: [LocationType.Virtual], + ...sections.parametersOptions + }, + { + signal: signal + } + ) + ).data.Items; + break; + } + } + return response; + } +}; + +export const useGetItemsByFavoriteType = ( + sections: Sections, + parentId: ParentId +) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['ItemsByFavoriteType', sections.id], + queryFn: ({ signal }) => + fetchGetItemsByFavoriteType( + currentApi, + parentId, + sections, + signal + ), + enabled: !!sections.id + }); +}; + +const fetchGetUsers = async ( + currentApi: JellyfinApiContext, + parametersOptions: UserApiGetUsersRequest, + signal: AbortSignal | undefined +) => { + const { api } = currentApi; + if (api) { + const response = await getUserApi(api).getUsers( + { + ...parametersOptions + }, + { + signal: signal + } + ); + return response.data; + } +}; + +export const useGetUsers = (parametersOptions: UserApiGetUsersRequest) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['Users'], + queryFn: ({ signal }) => + fetchGetUsers(currentApi, parametersOptions, signal) + }); +}; + +const fetchGetUserById = async ( + currentApi: JellyfinApiContext, + parametersOptions: UserApiGetUserByIdRequest, + signal: AbortSignal | undefined +) => { + const { api } = currentApi; + if (api) { + const response = await getUserApi(api).getUserById( + { + ...parametersOptions + }, + { + signal: signal + } + ); + return response.data; + } +}; + +export const useGetUserById = ( + parametersOptions: UserApiGetUserByIdRequest +) => { + const currentApi = useApi(); + return useQuery({ + queryKey: [ + 'UserById', + { + ...parametersOptions + } + ], + queryFn: ({ signal }) => + fetchGetUserById(currentApi, parametersOptions, signal), + enabled: Boolean(parametersOptions?.userId) + }); +}; + +const fetchGetAuthProviders = async ( + currentApi: JellyfinApiContext, + signal: AbortSignal | undefined +) => { + const { api } = currentApi; + if (api) { + const response = await getSessionApi(api).getAuthProviders({ + signal: signal + }); + return response.data; + } +}; + +export const useGetAuthProviders = () => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['AuthProviders'], + queryFn: ({ signal }) => fetchGetAuthProviders(currentApi, signal) + }); +}; + +const fetchGetPasswordResetProviders = async ( + currentApi: JellyfinApiContext, + signal: AbortSignal | undefined +) => { + const { api } = currentApi; + if (api) { + const response = await getSessionApi(api).getPasswordResetProviders({ + signal: signal + }); + return response.data; + } +}; + +export const useGetPasswordResetProviders = () => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['PasswordResetProviders'], + queryFn: ({ signal }) => + fetchGetPasswordResetProviders(currentApi, signal) + }); +}; + +const fetchGetMediaFolders = async ( + currentApi: JellyfinApiContext, + parametersOptions: LibraryApiGetMediaFoldersRequest, + signal: AbortSignal | undefined +) => { + const { api } = currentApi; + if (api) { + const response = await getLibraryApi(api).getMediaFolders( + { + ...parametersOptions + }, + { + signal: signal + } + ); + return response.data.Items || []; + } +}; + +export const useGetMediaFolders = ( + parametersOptions: LibraryApiGetMediaFoldersRequest +) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['MediaFolders', parametersOptions.isHidden], + queryFn: ({ signal }) => + fetchGetMediaFolders(currentApi, parametersOptions, signal) + }); +}; + +const fetchGetChannels = async ( + currentApi: JellyfinApiContext, + parametersOptions: ChannelsApiGetChannelsRequest, + signal: AbortSignal | undefined +) => { + const { api } = currentApi; + if (api) { + const response = await getChannelsApi(api).getChannels( + { + ...parametersOptions + }, + { + signal: signal + } + ); + return response.data.Items ?? []; + } +}; + +export const useGetChannels = ( + parametersOptions: ChannelsApiGetChannelsRequest +) => { + const currentApi = useApi(); + return useQuery({ + queryKey: [ + 'Channels', + { + ...parametersOptions + } + ], + queryFn: ({ signal }) => + fetchGetChannels(currentApi, parametersOptions, signal) + }); +}; + +const fetchGetDevices = async ( + currentApi: JellyfinApiContext, + parametersOptions: DevicesApiGetDevicesRequest, + signal: AbortSignal | undefined +) => { + const { api } = currentApi; + if (api) { + const response = await getDevicesApi(api).getDevices( + { + ...parametersOptions + }, + { + signal: signal + } + ); + return response.data.Items ?? []; + } +}; + +export const useGetDevices = ( + parametersOptions: DevicesApiGetDevicesRequest +) => { + const currentApi = useApi(); + return useQuery({ + queryKey: [ + 'Devices', + { + ...parametersOptions + } + ], + queryFn: ({ signal }) => + fetchGetDevices(currentApi, parametersOptions, signal), + enabled: !!parametersOptions.userId + }); +}; + +const fetchGetNamedConfiguration = async ( + currentApi: JellyfinApiContext, + parametersOptions: ConfigurationApiGetNamedConfigurationRequest, + signal: AbortSignal | undefined +) => { + const { api } = currentApi; + if (api) { + const response = await getConfigurationApi(api).getNamedConfiguration( + { + ...parametersOptions + }, + { + signal: signal + } + ); + return response.data; + } +}; + +export const useGetNamedConfiguration = ( + parametersOptions: ConfigurationApiGetNamedConfigurationRequest +) => { + const currentApi = useApi(); + return useQuery({ + queryKey: [ + 'NamedConfiguration', + { + ...parametersOptions + } + ], + queryFn: ({ signal }) => + fetchGetNamedConfiguration(currentApi, parametersOptions, signal), + enabled: !!parametersOptions.key + }); +}; + +const fetchGetParentalRatings = async ( + currentApi: JellyfinApiContext, + signal: AbortSignal | undefined +) => { + const { api } = currentApi; + if (api) { + const response = await getLocalizationApi(api).getParentalRatings({ + signal: signal + }); + return response.data; + } +}; + +export const useGetParentalRatings = () => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['ParentalRatings'], + queryFn: ({ signal }) => + fetchGetParentalRatings(currentApi, signal), + select: (data) => { + let rating; + const ratings: ParentalRating[] = []; + + for (const parentalRating of data ?? []) { + rating = parentalRating; + if (ratings.length) { + const lastRating = ratings[ratings.length - 1]; + + if (lastRating.Value === rating.Value) { + lastRating.Name += '/' + rating.Name; + continue; + } + } + + ratings.push({ + Name: rating.Name, + Value: rating.Value + }); + } + return ratings; + } + }); +}; + +const fetchUpdateUser = async ( + currentApi: JellyfinApiContext, + parametersOptions: UserApiUpdateUserRequest +) => { + const { api } = currentApi; + if (api) { + const response = await getUserApi(api).updateUser({ + ...parametersOptions + }); + return response.data; + } +}; + +export const useUpdateUser = () => { + const currentApi = useApi(); + return useMutation({ + mutationFn: (parametersOptions: UserApiUpdateUserRequest) => + fetchUpdateUser(currentApi, parametersOptions) + }); +}; + +const fetchUpdateUserPolicy = async ( + currentApi: JellyfinApiContext, + parametersOptions: UserApiUpdateUserPolicyRequest +) => { + const { api } = currentApi; + if (api) { + const response = await getUserApi(api).updateUserPolicy({ + ...parametersOptions + }); + + return response.data; + } +}; + +export const useUpdateUserPolicy = () => { + const currentApi = useApi(); + return useMutation({ + mutationFn: (parametersOptions: UserApiUpdateUserPolicyRequest) => + fetchUpdateUserPolicy(currentApi, parametersOptions) + }); +}; + +const fetchDeleteUser = async ( + currentApi: JellyfinApiContext, + parametersOptions: UserApiDeleteUserRequest +) => { + const { api } = currentApi; + if (api) { + const response = await getUserApi(api).deleteUser({ + ...parametersOptions + }); + return response.data; + } +}; + +export const useDeleteUser = () => { + const currentApi = useApi(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (parametersOptions: UserApiDeleteUserRequest) => + fetchDeleteUser(currentApi, parametersOptions), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['Users'] }).catch(err => { + console.error('[Users] failed to fetch Users', err); + }); + } + }); +}; + +const fetchUpdateUserPassword = async ( + currentApi: JellyfinApiContext, + parametersOptions: UserApiUpdateUserPasswordRequest +) => { + const { api } = currentApi; + if (api) { + const response = await getUserApi(api).updateUserPassword({ + ...parametersOptions + }); + return response.data; + } +}; + +export const useUpdateUserPassword = () => { + const currentApi = useApi(); + return useMutation({ + mutationFn: (parametersOptions: UserApiUpdateUserPasswordRequest) => + fetchUpdateUserPassword(currentApi, parametersOptions) + }); +}; + +const fetchUpdateUserEasyPassword = async ( + currentApi: JellyfinApiContext, + parametersOptions: UserApiUpdateUserEasyPasswordRequest +) => { + const { api } = currentApi; + if (api) { + const response = await getUserApi(api).updateUserEasyPassword({ + ...parametersOptions + }); + return response.data; + } +}; + +export const useUpdateUserEasyPassword = () => { + const currentApi = useApi(); + return useMutation({ + mutationFn: (parametersOptions: UserApiUpdateUserEasyPasswordRequest) => + fetchUpdateUserEasyPassword(currentApi, parametersOptions) + }); +}; + +const fetchUpdateUserConfiguration = async ( + currentApi: JellyfinApiContext, + parametersOptions: UserApiUpdateUserConfigurationRequest +) => { + const { api } = currentApi; + if (api) { + const response = await getUserApi(api).updateUserConfiguration({ + ...parametersOptions + }); + return response.data; + } +}; + +export const useUpdateUserConfiguration = () => { + const currentApi = useApi(); + return useMutation({ + mutationFn: ( + parametersOptions: UserApiUpdateUserConfigurationRequest + ) => fetchUpdateUserConfiguration(currentApi, parametersOptions) + }); +}; + +const fetchCreateUserByName = async ( + currentApi: JellyfinApiContext, + parametersOptions: UserApiCreateUserByNameRequest +) => { + const { api } = currentApi; + if (api) { + const response = await getUserApi(api).createUserByName({ + ...parametersOptions + }); + return response.data; + } +}; + +export const useCreateUserByName = () => { + const currentApi = useApi(); + return useMutation({ + mutationFn: (parametersOptions: UserApiCreateUserByNameRequest) => + fetchCreateUserByName(currentApi, parametersOptions) + }); +}; + +const fetchGetCurrentUser = async ( + currentApi: JellyfinApiContext, + signal: AbortSignal | undefined +) => { + const { api } = currentApi; + if (api) { + const response = await getUserApi(api).getCurrentUser({ + signal: signal + }); + return response.data; + } +}; + +export const useGetCurrentUser = () => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['getCurrentUser'], + queryFn: ({ signal }) => fetchGetCurrentUser(currentApi, signal) + }); +}; + +const fetchUserViews = async ( + currentApi: JellyfinApiContext +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + const response = await getUserViewsApi(api).getUserViews({ + userId: user.Id + }); + return response.data.Items; + } +}; + +export const useUserViews = () => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['UserViews'], + queryFn: () => fetchUserViews(currentApi) + }); +}; + +const fetchSystemInfo = async ( + currentApi: JellyfinApiContext +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + const response = await getSystemApi(api).getSystemInfo(); + return response.data; + } +}; + +export const useSystemInfo = () => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['SystemInfo'], + queryFn: () => fetchSystemInfo(currentApi) + }); +}; + diff --git a/src/hooks/useLibrarySettings.tsx b/src/hooks/useLibrarySettings.tsx new file mode 100644 index 000000000000..43233678f931 --- /dev/null +++ b/src/hooks/useLibrarySettings.tsx @@ -0,0 +1,79 @@ +import React, { createContext, FC, useContext, useEffect, useMemo } from 'react'; +import useLocalStorageState from 'use-local-storage-state'; + +import { LibraryViewSettings } from 'types/library'; + +export interface LibrarySettingsContextProps { + libraryViewSettings: LibraryViewSettings; + setLibraryViewSettings: React.Dispatch>; +} + +const LibrarySettingsContext = createContext( + {} as LibrarySettingsContextProps +); + +export const useLibrarySettings = () => useContext(LibrarySettingsContext); + +const DEFAULT_Library_View_SETTINGS: LibraryViewSettings = { + SortBy: 'SortName', + SortOrder: 'Ascending', + StartIndex: 0, + cardLayout: false, + filtersFeatures: [], + filtersGenres: [], + filtersOfficialRatings: [], + filtersSeriesStatus: [], + filtersStatus: [], + filtersStudioIds: [], + filtersTags: [], + filtersVideoTypes: [], + filtersYears: [], + imageType: 'primary', + showTitle: true, + showYear: false +}; + +interface LibrarySettingsProviderProps { + parentId: string | null; + viewType: string; +} + +export const LibrarySettingsProvider: FC = ({ + parentId, + viewType, + children +}) => { + const [libraryViewSettings, setLibraryViewSettings] = useLocalStorageState( + `${viewType} - ${parentId}`, { + defaultValue: DEFAULT_Library_View_SETTINGS + }); + + const context = useMemo( + () => ({ + libraryViewSettings, + setLibraryViewSettings + }), + [setLibraryViewSettings, libraryViewSettings] + ); + + useEffect(() => { + // update default imageType + if (viewType === 'studios' && libraryViewSettings.imageType !== 'thumb') { + setLibraryViewSettings((prevState) => ({ + ...prevState, + imageType: 'thumb' + })); + } else if (viewType === 'songs' && libraryViewSettings.imageType !== 'list') { + setLibraryViewSettings((prevState) => ({ + ...prevState, + imageType: 'list' + })); + } + }, [viewType, setLibraryViewSettings, libraryViewSettings.imageType]); + + return ( + + {children} + + ); +}; diff --git a/src/hooks/useLocalStorage.tsx b/src/hooks/useLocalStorage.tsx deleted file mode 100644 index 779147039c62..000000000000 --- a/src/hooks/useLocalStorage.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useEffect, useState } from 'react'; - -export function useLocalStorage(key: string, initialValue: T | (() => T)) { - const [value, setValue] = useState(() => { - const storedValues = localStorage.getItem(key); - if (storedValues != null) return JSON.parse(storedValues); - - if (typeof initialValue === 'function') { - return (initialValue as () => T)(); - } else { - return initialValue; - } - }); - - useEffect(() => { - localStorage.setItem(key, JSON.stringify(value)); - }, [key, value]); - - return [value, setValue] as [typeof value, typeof setValue]; -} diff --git a/src/strings/en-us.json b/src/strings/en-us.json index e819da990de9..3f7f5a9c2e51 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -369,6 +369,17 @@ "HeaderEnabledFieldsHelp": "Uncheck a field to lock it and prevent its data from being changed.", "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", @@ -1146,6 +1157,7 @@ "Movie": "Movie", "MovieLibraryHelp": "Review the {0}movie naming guide{1}.", "Movies": "Movies", + "Music": "Music", "MusicAlbum": "Music Album", "MusicArtist": "Music Artist", "MusicLibraryHelp": "Review the {0}music naming guide{1}.", diff --git a/src/types/cardOptions.ts b/src/types/cardOptions.ts new file mode 100644 index 000000000000..7864ab315771 --- /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/library.ts b/src/types/library.ts new file mode 100644 index 000000000000..b958a31318ec --- /dev/null +++ b/src/types/library.ts @@ -0,0 +1,67 @@ +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 LibraryParametersOptions { + 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 LibraryViewSettings { + SortBy?: string; + SortOrder?: string; + StartIndex?: number; + cardLayout?: boolean; + filtersFeatures?: string[]; + filtersGenres?: string[]; + filtersOfficialRatings?: string[]; + filtersSeriesStatus?: string[]; + filtersStatus?: string[]; + filtersStudioIds?: string[]; + filtersTags?: string[]; + filtersVideoTypes?: string[]; + filtersYears?: number[]; + imageType?: string; + showTitle?: boolean; + showYear?: boolean; + NameLessThan?: string | null; + NameStartsWith?: string | null; +} + +export interface LibraryViewSelectOptions { + title: string; + value: string; +} diff --git a/src/types/sections.ts b/src/types/sections.ts new file mode 100644 index 000000000000..8c03e6d0b956 --- /dev/null +++ b/src/types/sections.ts @@ -0,0 +1,11 @@ +import { CardOptions } from './cardOptions'; +import { LibraryParametersOptions } from './library'; + +export interface Sections { + name: string; + id: string; + type: string; + viewType?: string, + parametersOptions?: LibraryParametersOptions; + cardOptions: CardOptions; +} diff --git a/src/utils/items.ts b/src/utils/items.ts new file mode 100644 index 000000000000..7fc37dc101e5 --- /dev/null +++ b/src/utils/items.ts @@ -0,0 +1,367 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; +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 { 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'; +import * as userSettings from 'scripts/settings/userSettings'; +import { LibraryViewSettings } from '../types/library'; + +export const getSortByEnum = (libraryViewSettings: LibraryViewSettings) => { + const itemSortBy: ItemSortBy[] = []; + + if (libraryViewSettings.SortBy === 'SortName') { + itemSortBy.push(ItemSortBy.SortName); + itemSortBy.push(ItemSortBy.ProductionYear); + } + + if (libraryViewSettings.SortBy === 'Random') { + itemSortBy.push(ItemSortBy.Random); + } + + if (libraryViewSettings.SortBy === 'CommunityRating') { + itemSortBy.push(ItemSortBy.CommunityRating); + itemSortBy.push(ItemSortBy.SortName); + itemSortBy.push(ItemSortBy.ProductionYear); + } + + if (libraryViewSettings.SortBy === 'DateCreated') { + itemSortBy.push(ItemSortBy.DateCreated); + itemSortBy.push(ItemSortBy.SortName); + itemSortBy.push(ItemSortBy.ProductionYear); + } + + if (libraryViewSettings.SortBy === 'DatePlayed') { + itemSortBy.push(ItemSortBy.DatePlayed); + itemSortBy.push(ItemSortBy.SortName); + itemSortBy.push(ItemSortBy.ProductionYear); + } + + if (libraryViewSettings.SortBy === 'OfficialRating') { + itemSortBy.push(ItemSortBy.OfficialRating); + itemSortBy.push(ItemSortBy.SortName); + itemSortBy.push(ItemSortBy.ProductionYear); + } + + if (libraryViewSettings.SortBy === 'PlayCount') { + itemSortBy.push(ItemSortBy.PlayCount); + itemSortBy.push(ItemSortBy.SortName); + itemSortBy.push(ItemSortBy.ProductionYear); + } + + if (libraryViewSettings.SortBy === 'PremiereDate') { + itemSortBy.push(ItemSortBy.PremiereDate); + itemSortBy.push(ItemSortBy.SortName); + itemSortBy.push(ItemSortBy.ProductionYear); + } + + if (libraryViewSettings.SortBy === 'Runtime') { + itemSortBy.push(ItemSortBy.Runtime); + itemSortBy.push(ItemSortBy.SortName); + itemSortBy.push(ItemSortBy.ProductionYear); + } + + return itemSortBy; +}; + +export const getSortOrderEnum = (libraryViewSettings: LibraryViewSettings) => { + if (libraryViewSettings.SortOrder === 'Descending') { + return [SortOrder.Descending]; + } + + return [SortOrder.Ascending]; +}; + +export const getSortQuery = (libraryViewSettings: LibraryViewSettings) => { + return { + sortBy: getSortByEnum(libraryViewSettings), + sortOrder: getSortOrderEnum(libraryViewSettings) + }; +}; + +export const getIsFavoriteQuery = (viewType: string) => { + return { + isFavorite: viewType === 'favorites' ? true : undefined + }; +}; + +export const getLimitQuery = () => { + return { + limit: userSettings.libraryPageSize(undefined) || undefined + }; +}; + +export const getAlphaPickerQuery = (libraryViewSettings: LibraryViewSettings) => { + return { + nameLessThan: libraryViewSettings.NameLessThan !== null ? + libraryViewSettings.NameLessThan : undefined, + nameStartsWith: libraryViewSettings.NameStartsWith !== null ? + libraryViewSettings.NameStartsWith : undefined + }; +}; + +export const getImageTypesEnum = (libraryViewSettings: LibraryViewSettings) => { + const imageTypes: ImageType[] = [ImageType.Backdrop]; + + if ( + libraryViewSettings.imageType === 'primary' + || libraryViewSettings.imageType === 'list' + ) { + imageTypes.push(ImageType.Primary); + } + + if (libraryViewSettings.imageType === 'banner') { + imageTypes.push(ImageType.Banner); + } + + if (libraryViewSettings.imageType === 'disc') { + imageTypes.push(ImageType.Disc); + } + + if (libraryViewSettings.imageType === 'logo') { + imageTypes.push(ImageType.Logo); + } + + if (libraryViewSettings.imageType === 'thumb') { + imageTypes.push(ImageType.Thumb); + } + + return imageTypes; +}; + +export const getImageTypesQuery = (libraryViewSettings: LibraryViewSettings) => { + return { + enableImageTypes: getImageTypesEnum(libraryViewSettings) + }; +}; + +export const getVideoTypesEnum = (libraryViewSettings: LibraryViewSettings) => { + const videoTypes: VideoType[] = []; + + if (libraryViewSettings.filtersVideoTypes?.includes('Bluray')) { + videoTypes.push(VideoType.BluRay); + } + + if (libraryViewSettings.filtersVideoTypes?.includes('Dvd')) { + videoTypes.push(VideoType.Dvd); + } + + if (libraryViewSettings.filtersVideoTypes?.includes('Iso')) { + videoTypes.push(VideoType.Iso); + } + + return videoTypes; +}; + +export const getHasVideoTypes = (libraryViewSettings: LibraryViewSettings) => { + let isHd; + + if (libraryViewSettings.filtersVideoTypes?.includes('IsHD')) { + isHd = true; + } + + if (libraryViewSettings.filtersVideoTypes?.includes('IsSD')) { + isHd = false; + } + + return { + isHd, + is4K: libraryViewSettings.filtersVideoTypes?.includes('Is4K') ? + true : + undefined, + is3D: libraryViewSettings.filtersVideoTypes?.includes('Is3D') ? + true : + undefined + }; +}; + +export const getFiltersFeatures = (libraryViewSettings: LibraryViewSettings) => { + return { + hasSubtitles: libraryViewSettings.filtersFeatures?.includes('HasSubtitles') ? + true : + undefined, + hasTrailer: libraryViewSettings.filtersFeatures?.includes('HasTrailer') ? + true : + undefined, + hasSpecialFeature: libraryViewSettings.filtersFeatures?.includes( + 'HasSpecialFeature' + ) ? + true : + undefined, + hasThemeSong: libraryViewSettings.filtersFeatures?.includes('HasThemeSong') ? + true : + undefined, + hasThemeVideo: libraryViewSettings.filtersFeatures?.includes( + 'HasThemeVideo' + ) ? + true : + undefined + }; +}; + +export const getFiltersStatus = ( + viewType: string, + libraryViewSettings: LibraryViewSettings +) => { + return { + parentIndexNumber: libraryViewSettings.filtersStatus?.includes( + 'ParentIndexNumber' + ) ? + 0 : + undefined, + isMissing: + viewType === 'episodes' ? + !!libraryViewSettings.filtersStatus?.includes('IsMissing') : + undefined, + isUnaired: libraryViewSettings.filtersStatus?.includes('IsUnaired') ? + true : + undefined + }; +}; + +export const getSeriesStatusEnum = (libraryViewSettings: LibraryViewSettings) => { + const seriesStatus: SeriesStatus[] = []; + + if (libraryViewSettings.filtersSeriesStatus?.includes('Continuing')) { + seriesStatus.push(SeriesStatus.Continuing); + } + + if (libraryViewSettings.filtersSeriesStatus?.includes('Ended')) { + seriesStatus.push(SeriesStatus.Ended); + } + + return seriesStatus; +}; + +export const getItemFiltersEnum = (libraryViewSettings: LibraryViewSettings) => { + const itemFilter: ItemFilter[] = []; + + if (libraryViewSettings.filtersStatus?.includes('IsPlayed')) { + itemFilter.push(ItemFilter.IsPlayed); + } + + if (libraryViewSettings.filtersStatus?.includes('IsUnplayed')) { + itemFilter.push(ItemFilter.IsUnplayed); + } + + if (libraryViewSettings.filtersStatus?.includes('IsFavorite')) { + itemFilter.push(ItemFilter.IsFavorite); + } + + if (libraryViewSettings.filtersStatus?.includes('IsResumable')) { + itemFilter.push(ItemFilter.IsResumable); + } + + return itemFilter; +}; + +export const getFiltersQuery = ( + viewType: string, + libraryViewSettings: LibraryViewSettings +) => { + return { + ...getFiltersFeatures(libraryViewSettings), + ...getFiltersStatus(viewType, libraryViewSettings), + ...getHasVideoTypes(libraryViewSettings), + seriesStatus: getSeriesStatusEnum(libraryViewSettings), + videoTypes: getVideoTypesEnum(libraryViewSettings), + filters: getItemFiltersEnum(libraryViewSettings), + genres: libraryViewSettings.filtersGenres, + officialRatings: libraryViewSettings.filtersOfficialRatings, + tags: libraryViewSettings.filtersTags, + years: libraryViewSettings.filtersYears, + studioIds: libraryViewSettings.filtersStudioIds + }; +}; + +export const getFieldsEnum = ( + viewType: string, + libraryViewSettings: LibraryViewSettings +) => { + const itemFields: ItemFields[] = []; + + if (viewType !== 'studios') { + itemFields.push(ItemFields.BasicSyncInfo, ItemFields.MediaSourceCount); + } + + if (libraryViewSettings.imageType === 'primary') { + itemFields.push(ItemFields.PrimaryImageAspectRatio); + } + + if (viewType === 'studios') { + itemFields.push( + ItemFields.DateCreated, + ItemFields.PrimaryImageAspectRatio + ); + } + + return itemFields; +}; + +export const getItemFieldsQuery = ( + viewType: string, + libraryViewSettings: LibraryViewSettings +) => { + return { + fields: getFieldsEnum(viewType, libraryViewSettings) + }; +}; + +export const getItemTypesEnum = (viewType: string) => { + const itemType: BaseItemKind[] = []; + + if (viewType === 'movies' || viewType === 'favorites') { + itemType.push(BaseItemKind.Movie); + } + + if (viewType === 'collections') { + itemType.push(BaseItemKind.BoxSet); + } + + if (viewType === 'trailers') { + itemType.push(BaseItemKind.Trailer); + } + + if (viewType === 'series') { + itemType.push(BaseItemKind.Series); + } + + if (viewType === 'episodes') { + itemType.push(BaseItemKind.Episode); + } + + if (viewType === 'albums') { + itemType.push(BaseItemKind.MusicAlbum); + } + + if (viewType === 'songs') { + itemType.push(BaseItemKind.Audio); + } + + if (viewType === 'playlist') { + itemType.push(BaseItemKind.Playlist); + } + + if (viewType === 'books') { + itemType.push(BaseItemKind.Book); + } + + if (viewType === 'photos') { + itemType.push(BaseItemKind.Photo); + } + + if (viewType === 'videos') { + itemType.push(BaseItemKind.Video); + } + + return itemType; +}; + +export const getItemTypesQuery = (viewType: string) => { + return { + includeItemTypes: getItemTypesEnum(viewType) + }; +}; +