diff --git a/src/apps/experimental/components/library/GenresItemsContainer.tsx b/src/apps/experimental/components/library/GenresItemsContainer.tsx index 0348beb943..cb3029d687 100644 --- a/src/apps/experimental/components/library/GenresItemsContainer.tsx +++ b/src/apps/experimental/components/library/GenresItemsContainer.tsx @@ -9,8 +9,8 @@ import { ParentId } from 'types/library'; interface GenresItemsContainerProps { parentId: ParentId; - collectionType: CollectionType; - itemType: BaseItemKind; + collectionType: CollectionType | undefined; + itemType: BaseItemKind[]; } const GenresItemsContainer: FC = ({ diff --git a/src/apps/experimental/components/library/GenresSectionContainer.tsx b/src/apps/experimental/components/library/GenresSectionContainer.tsx index 14b061850d..506728c768 100644 --- a/src/apps/experimental/components/library/GenresSectionContainer.tsx +++ b/src/apps/experimental/components/library/GenresSectionContainer.tsx @@ -16,8 +16,8 @@ import { ParentId } from 'types/library'; interface GenresSectionContainerProps { parentId: ParentId; - collectionType: CollectionType; - itemType: BaseItemKind; + collectionType: CollectionType | undefined; + itemType: BaseItemKind[]; genre: BaseItemDto; } @@ -31,7 +31,7 @@ const GenresSectionContainer: FC = ({ return { sortBy: [ItemSortBy.Random], sortOrder: [SortOrder.Ascending], - includeItemTypes: [itemType], + includeItemTypes: itemType, recursive: true, fields: [ ItemFields.PrimaryImageAspectRatio, @@ -70,9 +70,9 @@ const GenresSectionContainer: FC = ({ showTitle: true, centerText: true, cardLayout: false, - shape: itemType === BaseItemKind.MusicAlbum ? 'overflowSquare' : 'overflowPortrait', - showParentTitle: itemType === BaseItemKind.MusicAlbum, - showYear: itemType !== BaseItemKind.MusicAlbum + shape: collectionType === CollectionType.Music ? 'overflowSquare' : 'overflowPortrait', + showParentTitle: collectionType === CollectionType.Music, + showYear: collectionType !== CollectionType.Music }} />; }; diff --git a/src/apps/experimental/components/library/GenresView.tsx b/src/apps/experimental/components/library/GenresView.tsx new file mode 100644 index 0000000000..7916f83b63 --- /dev/null +++ b/src/apps/experimental/components/library/GenresView.tsx @@ -0,0 +1,23 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; +import React, { FC } from 'react'; +import GenresItemsContainer from './GenresItemsContainer'; +import { ParentId } from 'types/library'; +import { CollectionType } from 'types/collectionType'; + +interface GenresViewProps { + parentId: ParentId; + collectionType: CollectionType | undefined; + itemType: BaseItemKind[]; +} + +const GenresView: FC = ({ parentId, collectionType, itemType }) => { + return ( + + ); +}; + +export default GenresView; diff --git a/src/apps/experimental/components/library/PageTabContent.tsx b/src/apps/experimental/components/library/PageTabContent.tsx new file mode 100644 index 0000000000..26c75de956 --- /dev/null +++ b/src/apps/experimental/components/library/PageTabContent.tsx @@ -0,0 +1,65 @@ +import React, { FC } from 'react'; +import SuggestionsView from './SuggestionsView'; +import UpcomingView from './UpcomingView'; +import GenresView from './GenresView'; +import ItemsView from './ItemsView'; +import { LibraryTab } from 'types/libraryTab'; +import { ParentId } from 'types/library'; +import { LibraryTabContent } from 'types/libraryTabContent'; + +interface PageTabContentProps { + parentId: ParentId; + currentTab: LibraryTabContent; +} + +const PageTabContent: FC = ({ parentId, currentTab }) => { + if (currentTab.viewType === LibraryTab.Suggestions) { + return ( + + ); + } + + if (currentTab.viewType === LibraryTab.Upcoming) { + return ; + } + + if (currentTab.viewType === LibraryTab.Genres) { + return ( + + ); + } + + return ( + + ); +}; + +export default PageTabContent; diff --git a/src/apps/experimental/components/library/RecommendationItemsContainer.tsx b/src/apps/experimental/components/library/RecommendationItemsContainer.tsx new file mode 100644 index 0000000000..1a1537a14c --- /dev/null +++ b/src/apps/experimental/components/library/RecommendationItemsContainer.tsx @@ -0,0 +1,46 @@ +import React, { FC } from 'react'; +import { useGetMovieRecommendations } from 'hooks/useFetchItems'; +import Loading from 'components/loading/LoadingComponent'; +import globalize from 'scripts/globalize'; +import RecommendationContainer from './RecommendationContainer'; +import { ParentId } from 'types/library'; + +interface RecommendationItemsContainerProps { + parentId?: ParentId; +} + +const RecommendationItemsContainer: FC = ({ + parentId +}) => { + const { isLoading, data: movieRecommendationsItems } = + useGetMovieRecommendations(parentId); + + if (isLoading) return ; + + if (!movieRecommendationsItems?.length) { + return ( +
+

{globalize.translate('MessageNothingHere')}

+

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

+
+ ); + } + + return ( + <> + {movieRecommendationsItems.map((recommendation, index) => { + return ( + + ); + })} + + ); +}; + +export default RecommendationItemsContainer; diff --git a/src/apps/experimental/components/library/SuggestionsView.tsx b/src/apps/experimental/components/library/SuggestionsView.tsx new file mode 100644 index 0000000000..41aed82912 --- /dev/null +++ b/src/apps/experimental/components/library/SuggestionsView.tsx @@ -0,0 +1,33 @@ +import React, { FC } from 'react'; +import { Box } from '@mui/material'; +import SuggestionsItemsContainer from './SuggestionsItemsContainer'; +import RecommendationItemsContainer from './RecommendationItemsContainer'; +import { ParentId } from 'types/library'; +import { SectionsView } from 'types/suggestionsSections'; + +interface SuggestionsViewProps { + parentId: ParentId; + suggestionSectionViews: SectionsView[] | undefined; + isMovieRecommendations: boolean | undefined; +} + +const SuggestionsView: FC = ({ + parentId, + suggestionSectionViews = [], + isMovieRecommendations = false +}) => { + return ( + + + + {isMovieRecommendations && ( + + )} + + ); +}; + +export default SuggestionsView; diff --git a/src/apps/experimental/components/library/UpcomingView.tsx b/src/apps/experimental/components/library/UpcomingView.tsx new file mode 100644 index 0000000000..bf6a6b0ace --- /dev/null +++ b/src/apps/experimental/components/library/UpcomingView.tsx @@ -0,0 +1,48 @@ +import React, { FC } from 'react'; +import Box from '@mui/material/Box'; +import { useGetGroupsUpcomingEpisodes } from 'hooks/useFetchItems'; +import Loading from 'components/loading/LoadingComponent'; +import globalize from 'scripts/globalize'; +import SectionContainer from './SectionContainer'; +import { LibraryViewProps } from 'types/library'; + +const UpcomingView: FC = ({ parentId }) => { + const { isLoading, data: groupsUpcomingEpisodes } = useGetGroupsUpcomingEpisodes(parentId); + + if (isLoading) return ; + + return ( + + {!groupsUpcomingEpisodes?.length ? ( +
+

{globalize.translate('MessageNothingHere')}

+

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

+
+ ) : ( + groupsUpcomingEpisodes?.map((group) => ( + + )) + )} +
+ ); +}; + +export default UpcomingView; diff --git a/src/apps/experimental/routes/movies/CollectionsView.tsx b/src/apps/experimental/routes/movies/CollectionsView.tsx deleted file mode 100644 index 82ce015565..0000000000 --- a/src/apps/experimental/routes/movies/CollectionsView.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; -import React, { FC } from 'react'; - -import ItemsView from '../../components/library/ItemsView'; -import { LibraryViewProps } from 'types/library'; -import { CollectionType } from 'types/collectionType'; -import { LibraryTab } from 'types/libraryTab'; - -const CollectionsView: FC = ({ parentId }) => { - 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 7bb89edb15..0000000000 --- a/src/apps/experimental/routes/movies/FavoritesView.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; -import React, { FC } from 'react'; - -import ItemsView from '../../components/library/ItemsView'; -import { LibraryViewProps } from 'types/library'; -import { LibraryTab } from 'types/libraryTab'; - -const FavoritesView: FC = ({ parentId }) => { - 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 05e7e216f4..0000000000 --- a/src/apps/experimental/routes/movies/GenresView.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; -import React, { FC } from 'react'; -import GenresItemsContainer from '../../components/library/GenresItemsContainer'; -import { LibraryViewProps } from 'types/library'; -import { CollectionType } from 'types/collectionType'; - -const GenresView: FC = ({ parentId }) => { - return ( - - ); -}; - -export default GenresView; diff --git a/src/apps/experimental/routes/movies/MoviesView.tsx b/src/apps/experimental/routes/movies/MoviesView.tsx deleted file mode 100644 index b09f468db1..0000000000 --- a/src/apps/experimental/routes/movies/MoviesView.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; -import React, { FC } from 'react'; - -import ItemsView from '../../components/library/ItemsView'; -import { LibraryViewProps } from 'types/library'; -import { CollectionType } from 'types/collectionType'; -import { LibraryTab } from 'types/libraryTab'; - -const MoviesView: FC = ({ parentId }) => { - 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 a31200f58b..0000000000 --- a/src/apps/experimental/routes/movies/SuggestionsView.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { FC } from 'react'; -import { useGetMovieRecommendations } from 'hooks/useFetchItems'; -import globalize from 'scripts/globalize'; -import Loading from 'components/loading/LoadingComponent'; -import RecommendationContainer from '../../components/library/RecommendationContainer'; -import SuggestionsItemsContainer from '../../components/library/SuggestionsItemsContainer'; - -import { LibraryViewProps } from 'types/library'; -import { SectionsView } from 'types/suggestionsSections'; - -const SuggestionsView: FC = ({ parentId }) => { - const { - isLoading, - data: movieRecommendationsItems - } = useGetMovieRecommendations(parentId); - - if (isLoading) { - return ; - } - - return ( - <> - - - {!movieRecommendationsItems?.length ? ( -
-

{globalize.translate('MessageNothingHere')}

-

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

-
- ) : ( - movieRecommendationsItems.map((recommendation, index) => { - 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 6acfd1c8ca..0000000000 --- a/src/apps/experimental/routes/movies/TrailersView.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; -import React, { FC } from 'react'; - -import ItemsView from '../../components/library/ItemsView'; -import { LibraryViewProps } from 'types/library'; -import { LibraryTab } from 'types/libraryTab'; - -const TrailersView: FC = ({ parentId }) => { - return ( - - ); -}; - -export default TrailersView; diff --git a/src/apps/experimental/routes/movies/index.tsx b/src/apps/experimental/routes/movies/index.tsx index 8e74b7a3e4..b24f19b0db 100644 --- a/src/apps/experimental/routes/movies/index.tsx +++ b/src/apps/experimental/routes/movies/index.tsx @@ -1,55 +1,72 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; import React, { FC } from 'react'; -import { useLocation, useSearchParams } from 'react-router-dom'; - -import { getDefaultTabIndex } from '../../components/tabs/tabRoutes'; +import useCurrentTab from 'hooks/useCurrentTab'; import Page from 'components/Page'; -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 PageTabContent from '../../components/library/PageTabContent'; +import { LibraryTab } from 'types/libraryTab'; +import { CollectionType } from 'types/collectionType'; +import { LibraryTabContent, LibraryTabMapping } from 'types/libraryTabContent'; +import { SectionsView } from 'types/suggestionsSections'; + +const moviesTabContent: LibraryTabContent = { + viewType: LibraryTab.Movies, + collectionType: CollectionType.Movies, + isBtnShuffleEnabled: true, + itemType: [BaseItemKind.Movie] +}; + +const collectionsTabContent: LibraryTabContent = { + viewType: LibraryTab.Collections, + collectionType: CollectionType.Movies, + isBtnFilterEnabled: false, + isBtnNewCollectionEnabled: true, + isAlphabetPickerEnabled: false, + itemType: [BaseItemKind.BoxSet], + noItemsMessage: 'MessageNoCollectionsAvailable' +}; + +const favoritesTabContent: LibraryTabContent = { + viewType: LibraryTab.Favorites, + collectionType: CollectionType.Movies, + itemType: [BaseItemKind.Movie] +}; + +const trailersTabContent: LibraryTabContent = { + viewType: LibraryTab.Trailers, + itemType: [BaseItemKind.Trailer], + noItemsMessage: 'MessageNoTrailersFound' +}; + +const suggestionsTabContent: LibraryTabContent = { + viewType: LibraryTab.Suggestions, + collectionType: CollectionType.Movies, + sectionsType: { + suggestionSectionsView: [ + SectionsView.ContinueWatchingMovies, + SectionsView.LatestMovies + ], + isMovieRecommendations: true + } +}; + +const genresTabContent: LibraryTabContent = { + viewType: LibraryTab.Genres, + collectionType: CollectionType.Movies, + itemType: [BaseItemKind.Movie] +}; + +const moviesTabMapping: LibraryTabMapping = { + 0: moviesTabContent, + 1: suggestionsTabContent, + 2: trailersTabContent, + 3: favoritesTabContent, + 4: collectionsTabContent, + 5: genresTabContent +}; const Movies: FC = () => { - const location = useLocation(); - const [ searchParams ] = useSearchParams(); - const searchParamsParentId = searchParams.get('topParentId'); - const searchParamsTab = searchParams.get('tab'); - const currentTabIndex = searchParamsTab !== null ? parseInt(searchParamsTab, 10) : - getDefaultTabIndex(location.pathname, searchParamsParentId); - - const getTabComponent = (index: number) => { - if (index == null) { - throw new Error('index cannot be null'); - } - - let component; - switch (index) { - case 1: - component = ; - break; - - case 2: - component = ; - break; - - case 3: - component = ; - break; - - case 4: - component = ; - break; - - case 5: - component = ; - break; - default: - component = ; - } - - return component; - }; + const { searchParamsParentId, currentTabIndex } = useCurrentTab(); + const currentTab = moviesTabMapping[currentTabIndex]; return ( { className='mainAnimatedPage libraryPage backdropPage collectionEditorPage pageWithAbsoluteTabs withTabs' backDropType='movie' > - {getTabComponent(currentTabIndex)} - + ); }; diff --git a/src/hooks/useCurrentTab.ts b/src/hooks/useCurrentTab.ts new file mode 100644 index 0000000000..33bc7400ce --- /dev/null +++ b/src/hooks/useCurrentTab.ts @@ -0,0 +1,20 @@ +import { getDefaultTabIndex } from 'apps/experimental/components/tabs/tabRoutes'; +import { useLocation, useSearchParams } from 'react-router-dom'; + +const useCurrentTab = () => { + const location = useLocation(); + const [searchParams] = useSearchParams(); + const searchParamsParentId = searchParams.get('topParentId'); + const searchParamsTab = searchParams.get('tab'); + const currentTabIndex: number = + searchParamsTab !== null ? + parseInt(searchParamsTab, 10) : + getDefaultTabIndex(location.pathname, searchParamsParentId); + + return { + searchParamsParentId, + currentTabIndex + }; +}; + +export default useCurrentTab; diff --git a/src/hooks/useFetchItems.ts b/src/hooks/useFetchItems.ts index 89c7498eff..6f8580b0b7 100644 --- a/src/hooks/useFetchItems.ts +++ b/src/hooks/useFetchItems.ts @@ -1,5 +1,5 @@ import { AxiosRequestConfig } from 'axios'; -import type { ItemsApiGetItemsRequest, PlaylistsApiMoveItemRequest } from '@jellyfin/sdk/lib/generated-client'; +import type { BaseItemDto, ItemsApiGetItemsRequest, PlaylistsApiMoveItemRequest } from '@jellyfin/sdk/lib/generated-client'; import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type'; import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields'; @@ -16,6 +16,8 @@ import { getTvShowsApi } from '@jellyfin/sdk/lib/utils/api/tv-shows-api'; import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api/user-library-api'; import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api'; import { useMutation, useQuery } from '@tanstack/react-query'; +import datetime from 'scripts/datetime'; +import globalize from 'scripts/globalize'; import { JellyfinApiContext, useApi } from './useApi'; import { getAlphaPickerQuery, getFieldsQuery, getFiltersQuery, getLimitQuery } from 'utils/items'; @@ -197,7 +199,7 @@ const fetchGetItemsBySuggestionsType = async ( ], parentId: parentId ?? undefined, imageTypeLimit: 1, - enableImageTypes: [ImageType.Primary], + enableImageTypes: [ ImageType.Primary, ImageType.Thumb ], ...sections.parametersOptions }, { @@ -258,7 +260,7 @@ export const useGetItemsBySectionType = ( const fetchGetGenres = async ( currentApi: JellyfinApiContext, - itemType: BaseItemKind, + itemType: BaseItemKind[], parentId: ParentId, options?: AxiosRequestConfig ) => { @@ -269,7 +271,7 @@ const fetchGetGenres = async ( userId: user.Id, sortBy: [ItemSortBy.SortName], sortOrder: [SortOrder.Ascending], - includeItemTypes: [itemType], + includeItemTypes: itemType, enableTotalRecordCount: false, parentId: parentId ?? undefined }, @@ -281,7 +283,7 @@ const fetchGetGenres = async ( } }; -export const useGetGenres = (itemType: BaseItemKind, parentId: ParentId) => { +export const useGetGenres = (itemType: BaseItemKind[], parentId: ParentId) => { const currentApi = useApi(); return useQuery({ queryKey: ['Genres', parentId], @@ -531,3 +533,91 @@ export const usePlaylistsMoveItemMutation = () => { fetchPlaylistsMoveItem(currentApi, requestParameters ) }); }; + +type GroupsUpcomingEpisodes = { + name: string; + items: BaseItemDto[]; +}; + +function groupsUpcomingEpisodes(items: BaseItemDto[]) { + const groups: GroupsUpcomingEpisodes[] = []; + let currentGroupName = ''; + let currentGroup: BaseItemDto[] = []; + + for (const item of items) { + let dateText = ''; + + if (item.PremiereDate) { + try { + const premiereDate = datetime.parseISO8601Date( + item.PremiereDate, + true + ); + dateText = datetime.isRelativeDay(premiereDate, -1) ? + globalize.translate('Yesterday') : + datetime.toLocaleDateString(premiereDate, { + weekday: 'long', + month: 'short', + day: 'numeric' + }); + } catch (err) { + console.error('error parsing timestamp for upcoming tv shows'); + } + } + + if (dateText != currentGroupName) { + if (currentGroup.length) { + groups.push({ + name: currentGroupName, + items: currentGroup + }); + } + + currentGroupName = dateText; + currentGroup = [item]; + } else { + currentGroup.push(item); + } + } + return groups; +} + +const fetchGetGroupsUpcomingEpisodes = async ( + currentApi: JellyfinApiContext, + parentId: ParentId, + options?: AxiosRequestConfig +) => { + const { api, user } = currentApi; + if (api && user?.Id) { + const response = await getTvShowsApi(api).getUpcomingEpisodes( + { + userId: user.Id, + limit: 25, + fields: [ItemFields.AirTime], + parentId: parentId ?? undefined, + imageTypeLimit: 1, + enableImageTypes: [ + ImageType.Primary, + ImageType.Backdrop, + ImageType.Thumb + ] + }, + { + signal: options?.signal + } + ); + const items = response.data.Items ?? []; + + return groupsUpcomingEpisodes(items); + } +}; + +export const useGetGroupsUpcomingEpisodes = (parentId: ParentId) => { + const currentApi = useApi(); + return useQuery({ + queryKey: ['GroupsUpcomingEpisodes', parentId], + queryFn: ({ signal }) => + fetchGetGroupsUpcomingEpisodes(currentApi, parentId, { signal }), + enabled: !!parentId + }); +}; diff --git a/src/types/libraryTabContent.ts b/src/types/libraryTabContent.ts new file mode 100644 index 0000000000..79bff924e5 --- /dev/null +++ b/src/types/libraryTabContent.ts @@ -0,0 +1,29 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client'; +import { LibraryTab } from './libraryTab'; +import { CollectionType } from './collectionType'; +import { SectionsView } from './suggestionsSections'; + +export interface SuggestionsSectionsType { + suggestionSectionsView: SectionsView[]; + isMovieRecommendations?: boolean; +} + +export interface LibraryTabContent { + viewType: LibraryTab; + itemType?: BaseItemKind[]; + collectionType?: CollectionType; + sectionsType?: SuggestionsSectionsType; + isBtnPlayAllEnabled?: boolean; + isBtnQueueEnabled?: boolean; + isBtnShuffleEnabled?: boolean; + isBtnSortEnabled?: boolean; + isBtnFilterEnabled?: boolean; + isBtnNewCollectionEnabled?: boolean; + isBtnGridListEnabled?: boolean; + isAlphabetPickerEnabled?: boolean; + noItemsMessage?: string; +} + +export interface LibraryTabMapping { + [index: number]: LibraryTabContent; +}