1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Merge remote-tracking branch 'upstream/master' into dashboard-activity

This commit is contained in:
Bill Thornton 2023-07-06 01:46:52 -04:00
commit 76597a021a
66 changed files with 2129 additions and 707 deletions

View file

@ -0,0 +1,61 @@
import { SyncPlayUserAccessType } from '@jellyfin/sdk/lib/generated-client/models/sync-play-user-access-type';
import Groups from '@mui/icons-material/Groups';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import React, { useCallback, useState } from 'react';
import { pluginManager } from 'components/pluginManager';
import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
import { PluginType } from 'types/plugin';
import AppSyncPlayMenu, { ID } from './menus/SyncPlayMenu';
const SyncPlayButton = () => {
const { user } = useApi();
const [ syncPlayMenuAnchorEl, setSyncPlayMenuAnchorEl ] = useState<null | HTMLElement>(null);
const isSyncPlayMenuOpen = Boolean(syncPlayMenuAnchorEl);
const onSyncPlayButtonClick = useCallback((event) => {
setSyncPlayMenuAnchorEl(event.currentTarget);
}, [ setSyncPlayMenuAnchorEl ]);
const onSyncPlayMenuClose = useCallback(() => {
setSyncPlayMenuAnchorEl(null);
}, [ setSyncPlayMenuAnchorEl ]);
if (
// SyncPlay not enabled for user
(user?.Policy && user.Policy.SyncPlayAccess === SyncPlayUserAccessType.None)
// SyncPlay plugin is not loaded
|| pluginManager.ofType(PluginType.SyncPlay).length === 0
) {
return null;
}
return (
<>
<Tooltip title={globalize.translate('ButtonSyncPlay')}>
<IconButton
size='large'
aria-label={globalize.translate('ButtonSyncPlay')}
aria-controls={ID}
aria-haspopup='true'
onClick={onSyncPlayButtonClick}
color='inherit'
>
<Groups />
</IconButton>
</Tooltip>
<AppSyncPlayMenu
open={isSyncPlayMenuOpen}
anchorEl={syncPlayMenuAnchorEl}
onMenuClose={onSyncPlayMenuClose}
/>
</>
);
};
export default SyncPlayButton;

View file

@ -16,6 +16,7 @@ import AppTabs from '../tabs/AppTabs';
import { isDrawerPath } from '../drawers/AppDrawer';
import UserMenuButton from './UserMenuButton';
import RemotePlayButton from './RemotePlayButton';
import SyncPlayButton from './SyncPlayButton';
interface AppToolbarProps {
isDrawerOpen: boolean
@ -90,6 +91,7 @@ const AppToolbar: FC<AppToolbarProps> = ({
{isUserLoggedIn && (
<>
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'flex-end' }}>
<SyncPlayButton />
<RemotePlayButton />
<Tooltip title={globalize.translate('Search')}>

View file

@ -0,0 +1,308 @@
import type { GroupInfoDto } from '@jellyfin/sdk/lib/generated-client/models/group-info-dto';
import { SyncPlayUserAccessType } from '@jellyfin/sdk/lib/generated-client/models/sync-play-user-access-type';
import { getSyncPlayApi } from '@jellyfin/sdk/lib/utils/api/sync-play-api';
import GroupAdd from '@mui/icons-material/GroupAdd';
import PersonAdd from '@mui/icons-material/PersonAdd';
import PersonOff from '@mui/icons-material/PersonOff';
import PersonRemove from '@mui/icons-material/PersonRemove';
import PlayCircle from '@mui/icons-material/PlayCircle';
import StopCircle from '@mui/icons-material/StopCircle';
import Tune from '@mui/icons-material/Tune';
import Divider from '@mui/material/Divider';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader';
import Menu, { MenuProps } from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import type { ApiClient } from 'jellyfin-apiclient';
import React, { FC, useCallback, useEffect, useState } from 'react';
import { pluginManager } from 'components/pluginManager';
import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
import { PluginType } from 'types/plugin';
import Events from 'utils/events';
export const ID = 'app-sync-play-menu';
interface SyncPlayMenuProps extends MenuProps {
onMenuClose: () => void
}
interface SyncPlayInstance {
Manager: {
getGroupInfo: () => GroupInfoDto | null | undefined
getTimeSyncCore: () => object
isPlaybackActive: () => boolean
isPlaylistEmpty: () => boolean
haltGroupPlayback: (apiClient: ApiClient) => void
resumeGroupPlayback: (apiClient: ApiClient) => void
}
}
const SyncPlayMenu: FC<SyncPlayMenuProps> = ({
anchorEl,
open,
onMenuClose
}) => {
const [ syncPlay, setSyncPlay ] = useState<SyncPlayInstance>();
const { __legacyApiClient__, api, user } = useApi();
const [ groups, setGroups ] = useState<GroupInfoDto[]>([]);
const [ currentGroup, setCurrentGroup ] = useState<GroupInfoDto>();
const isSyncPlayEnabled = Boolean(currentGroup);
useEffect(() => {
setSyncPlay(pluginManager.firstOfType(PluginType.SyncPlay)?.instance);
}, []);
useEffect(() => {
const fetchGroups = async () => {
if (api) {
setGroups((await getSyncPlayApi(api).syncPlayGetGroups()).data);
}
};
fetchGroups()
.catch(err => {
console.error('[SyncPlayMenu] unable to fetch SyncPlay groups', err);
});
}, [ api ]);
const onGroupAddClick = useCallback(() => {
if (api && user) {
getSyncPlayApi(api)
.syncPlayCreateGroup({
newGroupRequestDto: {
GroupName: globalize.translate('SyncPlayGroupDefaultTitle', user.Name)
}
})
.catch(err => {
console.error('[SyncPlayMenu] failed to create a SyncPlay group', err);
});
onMenuClose();
}
}, [ api, onMenuClose, user ]);
const onGroupLeaveClick = useCallback(() => {
if (api) {
getSyncPlayApi(api)
.syncPlayLeaveGroup()
.catch(err => {
console.error('[SyncPlayMenu] failed to leave SyncPlay group', err);
});
onMenuClose();
}
}, [ api, onMenuClose ]);
const onGroupJoinClick = useCallback((GroupId: string) => {
if (api) {
getSyncPlayApi(api)
.syncPlayJoinGroup({
joinGroupRequestDto: {
GroupId
}
})
.catch(err => {
console.error('[SyncPlayMenu] failed to join SyncPlay group', err);
});
onMenuClose();
}
}, [ api, onMenuClose ]);
const onGroupSettingsClick = useCallback(async () => {
if (!syncPlay) return;
// TODO: Rewrite settings UI
const SyncPlaySettingsEditor = (await import('../../../../../plugins/syncPlay/ui/settings/SettingsEditor')).default;
new SyncPlaySettingsEditor(
__legacyApiClient__,
syncPlay.Manager.getTimeSyncCore(),
{
groupInfo: currentGroup
})
.embed()
.catch(err => {
if (err) {
console.error('[SyncPlayMenu] Error creating SyncPlay settings editor', err);
}
});
onMenuClose();
}, [ __legacyApiClient__, currentGroup, onMenuClose, syncPlay ]);
const onStartGroupPlaybackClick = useCallback(() => {
if (__legacyApiClient__) {
syncPlay?.Manager.resumeGroupPlayback(__legacyApiClient__);
onMenuClose();
}
}, [ __legacyApiClient__, onMenuClose, syncPlay ]);
const onStopGroupPlaybackClick = useCallback(() => {
if (__legacyApiClient__) {
syncPlay?.Manager.haltGroupPlayback(__legacyApiClient__);
onMenuClose();
}
}, [ __legacyApiClient__, onMenuClose, syncPlay ]);
const updateSyncPlayGroup = useCallback((_e, enabled) => {
if (syncPlay && enabled) {
setCurrentGroup(syncPlay.Manager.getGroupInfo() ?? undefined);
} else {
setCurrentGroup(undefined);
}
}, [ syncPlay ]);
useEffect(() => {
if (!syncPlay) return;
Events.on(syncPlay.Manager, 'enabled', updateSyncPlayGroup);
return () => {
Events.off(syncPlay.Manager, 'enabled', updateSyncPlayGroup);
};
}, [ updateSyncPlayGroup, syncPlay ]);
const menuItems = [];
if (isSyncPlayEnabled) {
if (!syncPlay?.Manager.isPlaylistEmpty() && !syncPlay?.Manager.isPlaybackActive()) {
menuItems.push(
<MenuItem
key='sync-play-start-playback'
onClick={onStartGroupPlaybackClick}
>
<ListItemIcon>
<PlayCircle />
</ListItemIcon>
<ListItemText primary={globalize.translate('LabelSyncPlayResumePlayback')} />
</MenuItem>
);
} else if (syncPlay?.Manager.isPlaybackActive()) {
menuItems.push(
<MenuItem
key='sync-play-stop-playback'
onClick={onStopGroupPlaybackClick}
>
<ListItemIcon>
<StopCircle />
</ListItemIcon>
<ListItemText primary={globalize.translate('LabelSyncPlayHaltPlayback')} />
</MenuItem>
);
}
menuItems.push(
<MenuItem
key='sync-play-settings'
onClick={onGroupSettingsClick}
>
<ListItemIcon>
<Tune />
</ListItemIcon>
<ListItemText
primary={globalize.translate('Settings')}
/>
</MenuItem>
);
menuItems.push(
<Divider key='sync-play-controls-divider' />
);
menuItems.push(
<MenuItem
key='sync-play-exit'
onClick={onGroupLeaveClick}
>
<ListItemIcon>
<PersonRemove />
</ListItemIcon>
<ListItemText
primary={globalize.translate('LabelSyncPlayLeaveGroup')}
/>
</MenuItem>
);
} else if (groups.length === 0 && user?.Policy?.SyncPlayAccess !== SyncPlayUserAccessType.CreateAndJoinGroups) {
menuItems.push(
<MenuItem key='sync-play-unavailable' disabled>
<ListItemIcon>
<PersonOff />
</ListItemIcon>
<ListItemText primary={globalize.translate('LabelSyncPlayNoGroups')} />
</MenuItem>
);
} else {
if (groups.length > 0) {
groups.forEach(group => {
menuItems.push(
<MenuItem
key={group.GroupId}
// Since we are looping over groups there is no good way to avoid creating a new function here
// eslint-disable-next-line react/jsx-no-bind
onClick={() => group.GroupId && onGroupJoinClick(group.GroupId)}
>
<ListItemIcon>
<PersonAdd />
</ListItemIcon>
<ListItemText
primary={group.GroupName}
secondary={group.Participants?.join(', ')}
/>
</MenuItem>
);
});
menuItems.push(
<Divider key='sync-play-groups-divider' />
);
}
if (user?.Policy?.SyncPlayAccess === SyncPlayUserAccessType.CreateAndJoinGroups) {
menuItems.push(
<MenuItem
key='sync-play-new-group'
onClick={onGroupAddClick}
>
<ListItemIcon>
<GroupAdd />
</ListItemIcon>
<ListItemText primary={globalize.translate('LabelSyncPlayNewGroupDescription')} />
</MenuItem>
);
}
}
const MenuListProps = isSyncPlayEnabled ? {
'aria-labelledby': 'sync-play-active-subheader',
subheader: (
<ListSubheader component='div' id='sync-play-active-subheader'>
{currentGroup?.GroupName}
</ListSubheader>
)
} : undefined;
return (
<Menu
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right'
}}
id={ID}
keepMounted
open={open}
onClose={onMenuClose}
MenuListProps={MenuListProps}
>
{menuItems}
</Menu>
);
};
export default SyncPlayMenu;

View file

@ -0,0 +1,51 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import React, { FC } from 'react';
import { useGetGenres } from 'hooks/useFetchItems';
import globalize from 'scripts/globalize';
import Loading from 'components/loading/LoadingComponent';
import GenresSectionContainer from './GenresSectionContainer';
import { CollectionType } from 'types/collectionType';
interface GenresItemsContainerProps {
parentId?: string | null;
collectionType?: CollectionType;
itemType: BaseItemKind;
}
const GenresItemsContainer: FC<GenresItemsContainerProps> = ({
parentId,
collectionType,
itemType
}) => {
const { isLoading, data: genresResult } = useGetGenres(
itemType,
parentId
);
if (isLoading) {
return <Loading />;
}
return (
<>
{!genresResult?.Items?.length ? (
<div className='noItemsMessage centerMessage'>
<h1>{globalize.translate('MessageNothingHere')}</h1>
<p>{globalize.translate('MessageNoGenresAvailable')}</p>
</div>
) : (
genresResult?.Items?.map((genre) => (
<GenresSectionContainer
key={genre.Id}
collectionType={collectionType}
parentId={parentId}
itemType={itemType}
genre={genre}
/>
))
)}
</>
);
};
export default GenresItemsContainer;

View file

@ -0,0 +1,79 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields';
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order';
import escapeHTML from 'escape-html';
import React, { FC } from 'react';
import { useGetItems } from 'hooks/useFetchItems';
import Loading from 'components/loading/LoadingComponent';
import { appRouter } from 'components/router/appRouter';
import SectionContainer from './SectionContainer';
import { CollectionType } from 'types/collectionType';
interface GenresSectionContainerProps {
parentId?: string | null;
collectionType?: CollectionType;
itemType: BaseItemKind;
genre: BaseItemDto;
}
const GenresSectionContainer: FC<GenresSectionContainerProps> = ({
parentId,
collectionType,
itemType,
genre
}) => {
const getParametersOptions = () => {
return {
sortBy: [ItemSortBy.Random],
sortOrder: [SortOrder.Ascending],
includeItemTypes: [itemType],
recursive: true,
fields: [
ItemFields.PrimaryImageAspectRatio,
ItemFields.MediaSourceCount,
ItemFields.BasicSyncInfo
],
imageTypeLimit: 1,
enableImageTypes: [ImageType.Primary],
limit: 25,
genreIds: genre.Id ? [genre.Id] : undefined,
enableTotalRecordCount: false,
parentId: parentId ?? undefined
};
};
const { isLoading, data: itemsResult } = useGetItems(getParametersOptions());
const getRouteUrl = (item: BaseItemDto) => {
return appRouter.getRouteUrl(item, {
context: collectionType,
parentId: parentId
});
};
if (isLoading) {
return <Loading />;
}
return <SectionContainer
sectionTitle={escapeHTML(genre.Name)}
items={itemsResult?.Items || []}
url={getRouteUrl(genre)}
cardOptions={{
scalable: true,
overlayPlayButton: true,
showTitle: true,
centerText: true,
cardLayout: false,
shape: itemType === BaseItemKind.MusicAlbum ? 'overflowSquare' : 'overflowPortrait',
showParentTitle: itemType === BaseItemKind.MusicAlbum ? true : false,
showYear: itemType === BaseItemKind.MusicAlbum ? false : true
}}
/>;
};
export default GenresSectionContainer;

View file

@ -0,0 +1,66 @@
import { RecommendationDto, RecommendationType } from '@jellyfin/sdk/lib/generated-client';
import React, { FC } from 'react';
import globalize from 'scripts/globalize';
import escapeHTML from 'escape-html';
import SectionContainer from './SectionContainer';
interface RecommendationContainerProps {
recommendation?: RecommendationDto;
}
const RecommendationContainer: FC<RecommendationContainerProps> = ({
recommendation = {}
}) => {
let title = '';
switch (recommendation.RecommendationType) {
case RecommendationType.SimilarToRecentlyPlayed:
title = globalize.translate(
'RecommendationBecauseYouWatched',
recommendation.BaselineItemName
);
break;
case RecommendationType.SimilarToLikedItem:
title = globalize.translate(
'RecommendationBecauseYouLike',
recommendation.BaselineItemName
);
break;
case RecommendationType.HasDirectorFromRecentlyPlayed:
case RecommendationType.HasLikedDirector:
title = globalize.translate(
'RecommendationDirectedBy',
recommendation.BaselineItemName
);
break;
case RecommendationType.HasActorFromRecentlyPlayed:
case RecommendationType.HasLikedActor:
title = globalize.translate(
'RecommendationStarring',
recommendation.BaselineItemName
);
break;
}
return (
<SectionContainer
sectionTitle={escapeHTML(title)}
items={recommendation.Items || []}
cardOptions={{
shape: 'overflowPortrait',
showYear: true,
scalable: true,
overlayPlayButton: true,
showTitle: true,
centerText: true,
cardLayout: false
}}
/>
);
};
export default RecommendationContainer;

View file

@ -0,0 +1,73 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useEffect, useRef } from 'react';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import ItemsContainerElement from 'elements/ItemsContainerElement';
import Scroller from 'elements/emby-scroller/Scroller';
import LinkButton from 'elements/emby-button/LinkButton';
import imageLoader from 'components/images/imageLoader';
import { CardOptions } from 'types/cardOptions';
interface SectionContainerProps {
url?: string;
sectionTitle: string;
items: BaseItemDto[];
cardOptions: CardOptions;
}
const SectionContainer: FC<SectionContainerProps> = ({
sectionTitle,
url,
items,
cardOptions
}) => {
const element = useRef<HTMLDivElement>(null);
useEffect(() => {
const itemsContainer = element.current?.querySelector('.itemsContainer');
cardBuilder.buildCards(items, {
itemsContainer: itemsContainer,
parentContainer: element.current,
...cardOptions
});
imageLoader.lazyChildren(itemsContainer);
}, [cardOptions, items]);
return (
<div ref={element} className='verticalSection hide'>
<div className='sectionTitleContainer sectionTitleContainer-cards padded-left'>
{url && items.length > 5 ? (
<LinkButton
className='more button-flat button-flat-mini sectionTitleTextButton btnMoreFromGenre'
href={url}
>
<h2 className='sectionTitle sectionTitle-cards'>
{sectionTitle}
</h2>
<span
className='material-icons chevron_right'
aria-hidden='true'
></span>
</LinkButton>
) : (
<h2 className='sectionTitle sectionTitle-cards'>
{sectionTitle}
</h2>
)}
</div>
<Scroller
className='padded-top-focusscale padded-bottom-focusscale'
isMouseWheelEnabled={false}
isCenterFocusEnabled={true}
>
<ItemsContainerElement className='itemsContainer scrollSlider focuscontainer-x' />
</Scroller>
</div>
);
};
export default SectionContainer;

View file

@ -0,0 +1,206 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order';
import React, { FC } from 'react';
import * as userSettings from 'scripts/settings/userSettings';
import SuggestionsSectionContainer from './SuggestionsSectionContainer';
import { Sections, SectionsView, SectionsViewType } from 'types/suggestionsSections';
const getSuggestionsSections = (): Sections[] => {
return [
{
name: 'HeaderContinueWatching',
viewType: SectionsViewType.ResumeItems,
type: 'Movie',
view: SectionsView.ContinueWatchingMovies,
parametersOptions: {
includeItemTypes: [BaseItemKind.Movie]
},
cardOptions: {
scalable: true,
overlayPlayButton: true,
showTitle: true,
centerText: true,
cardLayout: false,
preferThumb: true,
shape: 'overflowBackdrop',
showYear: true
}
},
{
name: 'HeaderLatestMovies',
viewType: SectionsViewType.LatestMedia,
type: 'Movie',
view: SectionsView.LatestMovies,
parametersOptions: {
includeItemTypes: [BaseItemKind.Movie]
},
cardOptions: {
scalable: true,
overlayPlayButton: true,
showTitle: true,
centerText: true,
cardLayout: false,
shape: 'overflowPortrait',
showYear: true
}
},
{
name: 'HeaderContinueWatching',
viewType: SectionsViewType.ResumeItems,
type: 'Episode',
view: SectionsView.ContinueWatchingEpisode,
parametersOptions: {
includeItemTypes: [BaseItemKind.Episode]
},
cardOptions: {
scalable: true,
overlayPlayButton: true,
showTitle: true,
centerText: true,
cardLayout: false,
shape: 'overflowBackdrop',
preferThumb: true,
inheritThumb:
!userSettings.useEpisodeImagesInNextUpAndResume(undefined),
showYear: true
}
},
{
name: 'HeaderLatestEpisodes',
viewType: SectionsViewType.LatestMedia,
type: 'Episode',
view: SectionsView.LatestEpisode,
parametersOptions: {
includeItemTypes: [BaseItemKind.Episode]
},
cardOptions: {
scalable: true,
overlayPlayButton: true,
showTitle: true,
centerText: true,
cardLayout: false,
shape: 'overflowBackdrop',
preferThumb: true,
showSeriesYear: true,
showParentTitle: true,
overlayText: false,
showUnplayedIndicator: false,
showChildCountIndicator: true,
lazy: true,
lines: 2
}
},
{
name: 'NextUp',
viewType: SectionsViewType.NextUp,
type: 'nextup',
view: SectionsView.NextUp,
cardOptions: {
scalable: true,
overlayPlayButton: true,
showTitle: true,
centerText: true,
cardLayout: false,
shape: 'overflowBackdrop',
preferThumb: true,
inheritThumb:
!userSettings.useEpisodeImagesInNextUpAndResume(undefined),
showParentTitle: true,
overlayText: false
}
},
{
name: 'HeaderLatestMusic',
viewType: SectionsViewType.LatestMedia,
type: 'Audio',
view: SectionsView.LatestMusic,
parametersOptions: {
includeItemTypes: [BaseItemKind.Audio]
},
cardOptions: {
showUnplayedIndicator: false,
shape: 'overflowSquare',
showTitle: true,
showParentTitle: true,
lazy: true,
centerText: true,
overlayPlayButton: true,
cardLayout: false,
coverImage: true
}
},
{
name: 'HeaderRecentlyPlayed',
type: 'Audio',
view: SectionsView.RecentlyPlayedMusic,
parametersOptions: {
sortBy: [ItemSortBy.DatePlayed],
sortOrder: [SortOrder.Descending],
includeItemTypes: [BaseItemKind.Audio]
},
cardOptions: {
showUnplayedIndicator: false,
shape: 'overflowSquare',
showTitle: true,
showParentTitle: true,
action: 'instantmix',
lazy: true,
centerText: true,
overlayMoreButton: true,
cardLayout: false,
coverImage: true
}
},
{
name: 'HeaderFrequentlyPlayed',
type: 'Audio',
view: SectionsView.FrequentlyPlayedMusic,
parametersOptions: {
sortBy: [ItemSortBy.PlayCount],
sortOrder: [SortOrder.Descending],
includeItemTypes: [BaseItemKind.Audio]
},
cardOptions: {
showUnplayedIndicator: false,
shape: 'overflowSquare',
showTitle: true,
showParentTitle: true,
action: 'instantmix',
lazy: true,
centerText: true,
overlayMoreButton: true,
cardLayout: false,
coverImage: true
}
}
];
};
interface SuggestionsItemsContainerProps {
parentId?: string | null;
sectionsView: SectionsView[];
}
const SuggestionsItemsContainer: FC<SuggestionsItemsContainerProps> = ({
parentId,
sectionsView
}) => {
const suggestionsSections = getSuggestionsSections();
return (
<>
{suggestionsSections
.filter((section) => sectionsView.includes(section.view))
.map((section) => (
<SuggestionsSectionContainer
key={section.view}
parentId={parentId}
section={section}
/>
))}
</>
);
};
export default SuggestionsItemsContainer;

View file

@ -0,0 +1,49 @@
import React, { FC } from 'react';
import { useGetItemsBySectionType } from 'hooks/useFetchItems';
import globalize from 'scripts/globalize';
import Loading from 'components/loading/LoadingComponent';
import { appRouter } from 'components/router/appRouter';
import SectionContainer from './SectionContainer';
import { Sections } from 'types/suggestionsSections';
interface SuggestionsSectionContainerProps {
parentId?: string | null;
section: Sections;
}
const SuggestionsSectionContainer: FC<SuggestionsSectionContainerProps> = ({
parentId,
section
}) => {
const getRouteUrl = () => {
return appRouter.getRouteUrl('list', {
serverId: window.ApiClient.serverId(),
itemTypes: section.type,
parentId: parentId
});
};
const { isLoading, data: items } = useGetItemsBySectionType(
section,
parentId
);
if (isLoading) {
return <Loading />;
}
return (
<SectionContainer
sectionTitle={globalize.translate(section.name)}
items={items || []}
url={getRouteUrl()}
cardOptions={{
...section.cardOptions
}}
/>
);
};
export default SuggestionsSectionContainer;

View file

@ -1,9 +1,9 @@
import React, { FC, useCallback } from 'react';
import ViewItemsContainer from '../../../../components/common/ViewItemsContainer';
import { LibraryViewProps } from '../../../../types/interface';
import ViewItemsContainer from 'components/common/ViewItemsContainer';
import { LibraryViewProps } from 'types/library';
const CollectionsView: FC<LibraryViewProps> = ({ topParentId }) => {
const CollectionsView: FC<LibraryViewProps> = ({ parentId }) => {
const getBasekey = useCallback(() => {
return 'collections';
}, []);
@ -18,7 +18,7 @@ const CollectionsView: FC<LibraryViewProps> = ({ topParentId }) => {
return (
<ViewItemsContainer
topParentId={topParentId}
topParentId={parentId}
isBtnFilterEnabled={false}
isBtnNewCollectionEnabled={true}
isAlphaPickerEnabled={false}

View file

@ -1,9 +1,9 @@
import React, { FC, useCallback } from 'react';
import ViewItemsContainer from '../../../../components/common/ViewItemsContainer';
import { LibraryViewProps } from '../../../../types/interface';
import ViewItemsContainer from 'components/common/ViewItemsContainer';
import { LibraryViewProps } from 'types/library';
const FavoritesView: FC<LibraryViewProps> = ({ topParentId }) => {
const FavoritesView: FC<LibraryViewProps> = ({ parentId }) => {
const getBasekey = useCallback(() => {
return 'favorites';
}, []);
@ -18,7 +18,7 @@ const FavoritesView: FC<LibraryViewProps> = ({ topParentId }) => {
return (
<ViewItemsContainer
topParentId={topParentId}
topParentId={parentId}
getBasekey={getBasekey}
getItemTypes={getItemTypes}
getNoItemsMessage={getNoItemsMessage}

View file

@ -1,41 +1,15 @@
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<LibraryViewProps> = ({ topParentId }) => {
const [ itemsResult, setItemsResult ] = useState<BaseItemDtoQueryResult>({});
const reloadItems = useCallback(() => {
loading.show();
window.ApiClient.getGenres(
window.ApiClient.getCurrentUserId(),
{
SortBy: 'SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Movie',
Recursive: true,
EnableTotalRecordCount: false,
ParentId: topParentId
}
).then((result) => {
setItemsResult(result);
loading.hide();
}).catch(err => {
console.error('[GenresView] failed to fetch genres', err);
});
}, [topParentId]);
useEffect(() => {
reloadItems();
}, [reloadItems]);
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import React, { FC } from 'react';
import GenresItemsContainer from '../../components/library/GenresItemsContainer';
import { LibraryViewProps } from 'types/library';
import { CollectionType } from 'types/collectionType';
const GenresView: FC<LibraryViewProps> = ({ parentId }) => {
return (
<GenresItemsContainer
topParentId={topParentId}
itemsResult={itemsResult}
parentId={parentId}
collectionType={CollectionType.Movies}
itemType={BaseItemKind.Movie}
/>
);
};

View file

@ -1,9 +1,9 @@
import React, { FC, useCallback } from 'react';
import ViewItemsContainer from '../../../../components/common/ViewItemsContainer';
import { LibraryViewProps } from '../../../../types/interface';
import ViewItemsContainer from 'components/common/ViewItemsContainer';
import { LibraryViewProps } from 'types/library';
const MoviesView: FC<LibraryViewProps> = ({ topParentId }) => {
const MoviesView: FC<LibraryViewProps> = ({ parentId }) => {
const getBasekey = useCallback(() => {
return 'movies';
}, []);
@ -18,7 +18,7 @@ const MoviesView: FC<LibraryViewProps> = ({ topParentId }) => {
return (
<ViewItemsContainer
topParentId={topParentId}
topParentId={parentId}
isBtnShuffleEnabled={true}
getBasekey={getBasekey}
getItemTypes={getItemTypes}

View file

@ -1,160 +1,51 @@
import type { BaseItemDto, BaseItemDtoQueryResult, RecommendationDto } from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
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 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';
import { LibraryViewProps } from 'types/library';
import { SectionsView } from 'types/suggestionsSections';
const SuggestionsView: FC<LibraryViewProps> = ({ topParentId }) => {
const [ latestItems, setLatestItems ] = useState<BaseItemDto[]>([]);
const [ resumeResult, setResumeResult ] = useState<BaseItemDtoQueryResult>({});
const [ recommendations, setRecommendations ] = useState<RecommendationDto[]>([]);
const element = useRef<HTMLDivElement>(null);
const SuggestionsView: FC<LibraryViewProps> = ({ parentId }) => {
const {
isLoading,
data: movieRecommendationsItems
} = useGetMovieRecommendations(parentId);
const enableScrollX = useCallback(() => {
return !layoutManager.desktop;
}, []);
const getPortraitShape = useCallback(() => {
return enableScrollX() ? 'overflowPortrait' : 'portrait';
}, [enableScrollX]);
const getThumbShape = useCallback(() => {
return enableScrollX() ? 'overflowBackdrop' : 'backdrop';
}, [enableScrollX]);
const autoFocus = useCallback((page) => {
import('../../../../components/autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(page);
}).catch(err => {
console.error('[SuggestionsView] failed to load data', err);
});
}, []);
const loadResume = useCallback((page, userId, parentId) => {
loading.show();
const screenWidth = dom.getWindowSize().innerWidth;
const options = {
SortBy: 'DatePlayed',
SortOrder: 'Descending',
IncludeItemTypes: 'Movie',
Filters: 'IsResumable',
Limit: screenWidth >= 1600 ? 5 : 3,
Recursive: true,
Fields: 'PrimaryImageAspectRatio,MediaSourceCount,BasicSyncInfo',
CollapseBoxSetItems: false,
ParentId: parentId,
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb',
EnableTotalRecordCount: false
};
window.ApiClient.getItems(userId, options).then(result => {
setResumeResult(result);
loading.hide();
autoFocus(page);
}).catch(err => {
console.error('[SuggestionsView] failed to fetch items', err);
});
}, [autoFocus]);
const loadLatest = useCallback((page: HTMLDivElement, userId: string, parentId: string | null) => {
const options = {
IncludeItemTypes: 'Movie',
Limit: 18,
Fields: 'PrimaryImageAspectRatio,MediaSourceCount,BasicSyncInfo',
ParentId: parentId,
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb',
EnableTotalRecordCount: false
};
window.ApiClient.getJSON(window.ApiClient.getUrl('Users/' + userId + '/Items/Latest', options)).then(items => {
setLatestItems(items);
autoFocus(page);
}).catch(err => {
console.error('[SuggestionsView] failed to fetch latest items', err);
});
}, [autoFocus]);
const loadSuggestions = useCallback((page, userId) => {
const screenWidth = dom.getWindowSize().innerWidth;
let itemLimit = 5;
if (screenWidth >= 1600) {
itemLimit = 8;
} else if (screenWidth >= 1200) {
itemLimit = 6;
}
const url = window.ApiClient.getUrl('Movies/Recommendations', {
userId: userId,
categoryLimit: 6,
ItemLimit: itemLimit,
Fields: 'PrimaryImageAspectRatio,MediaSourceCount,BasicSyncInfo',
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb'
});
window.ApiClient.getJSON(url).then(result => {
setRecommendations(result);
autoFocus(page);
}).catch(err => {
console.error('[SuggestionsView] failed to fetch recommendations', err);
});
}, [autoFocus]);
const loadSuggestionsTab = useCallback((view) => {
const parentId = topParentId;
const userId = window.ApiClient.getCurrentUserId();
loadResume(view, userId, parentId);
loadLatest(view, userId, parentId);
loadSuggestions(view, userId);
}, [loadLatest, loadResume, loadSuggestions, topParentId]);
useEffect(() => {
const page = element.current;
if (!page) {
console.error('Unexpected null reference');
return;
}
loadSuggestionsTab(page);
}, [loadSuggestionsTab]);
if (isLoading) {
return <Loading />;
}
return (
<div ref={element}>
<SectionContainer
sectionTitle={globalize.translate('HeaderContinueWatching')}
enableScrollX={enableScrollX}
items={resumeResult.Items || []}
cardOptions={{
preferThumb: true,
shape: getThumbShape(),
showYear: true
}}
<>
<SuggestionsItemsContainer
parentId={parentId}
sectionsView={[SectionsView.ContinueWatchingMovies, SectionsView.LatestMovies]}
/>
<SectionContainer
sectionTitle={globalize.translate('HeaderLatestMovies')}
enableScrollX={enableScrollX}
items={latestItems}
cardOptions={{
shape: getPortraitShape(),
showYear: true
}}
/>
{!recommendations.length ? <div className='noItemsMessage centerMessage'>
<h1>{globalize.translate('MessageNothingHere')}</h1>
<p>{globalize.translate('MessageNoMovieSuggestionsAvailable')}</p>
</div> : recommendations.map(recommendation => {
return <RecommendationContainer key={recommendation.CategoryId} getPortraitShape={getPortraitShape} enableScrollX={enableScrollX} recommendation={recommendation} />;
})}
</div>
{!movieRecommendationsItems?.length ? (
<div className='noItemsMessage centerMessage'>
<h1>{globalize.translate('MessageNothingHere')}</h1>
<p>
{globalize.translate(
'MessageNoMovieSuggestionsAvailable'
)}
</p>
</div>
) : (
movieRecommendationsItems.map((recommendation, index) => {
return (
<RecommendationContainer
// eslint-disable-next-line react/no-array-index-key
key={`${recommendation.CategoryId}-${index}`} // use a unique id return value may have duplicate id
recommendation={recommendation}
/>
);
})
)}
</>
);
};

View file

@ -1,10 +1,10 @@
import React, { FC, useCallback } from 'react';
import ViewItemsContainer from '../../../../components/common/ViewItemsContainer';
import { LibraryViewProps } from '../../../../types/interface';
import ViewItemsContainer from 'components/common/ViewItemsContainer';
import { LibraryViewProps } from 'types/library';
const TrailersView: FC<LibraryViewProps> = ({ topParentId }) => {
const TrailersView: FC<LibraryViewProps> = ({ parentId }) => {
const getBasekey = useCallback(() => {
return 'trailers';
}, []);
@ -19,7 +19,7 @@ const TrailersView: FC<LibraryViewProps> = ({ topParentId }) => {
return (
<ViewItemsContainer
topParentId={topParentId}
topParentId={parentId}
getBasekey={getBasekey}
getItemTypes={getItemTypes}
getNoItemsMessage={getNoItemsMessage}

View file

@ -1,29 +1,27 @@
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 '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 React, { FC } from 'react';
import { useLocation, useSearchParams } from 'react-router-dom';
import Page from 'components/Page';
import Page from '../../../../components/Page';
import globalize from '../../../../scripts/globalize';
import libraryMenu from '../../../../scripts/libraryMenu';
import { getDefaultTabIndex } from '../../components/tabs/tabRoutes';
import CollectionsView from './CollectionsView';
import FavoritesView from './FavoritesView';
import GenresView from './GenresView';
import MoviesView from './MoviesView';
import SuggestionsView from './SuggestionsView';
import TrailersView from './TrailersView';
import { getDefaultTabIndex } from '../../components/tabs/tabRoutes';
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, searchParams.get('topParentId'));
const element = useRef<HTMLDivElement>(null);
getDefaultTabIndex(location.pathname, searchParamsParentId);
const getTabComponent = (index: number) => {
if (index == null) {
@ -32,72 +30,41 @@ const Movies: FC = () => {
let component;
switch (index) {
case 0:
component = <MoviesView topParentId={searchParams.get('topParentId')} />;
break;
case 1:
component = <SuggestionsView topParentId={searchParams.get('topParentId')} />;
component = <SuggestionsView parentId={searchParamsParentId} />;
break;
case 2:
component = <TrailersView topParentId={searchParams.get('topParentId')} />;
component = <TrailersView parentId={searchParamsParentId} />;
break;
case 3:
component = <FavoritesView topParentId={searchParams.get('topParentId')} />;
component = <FavoritesView parentId={searchParamsParentId} />;
break;
case 4:
component = <CollectionsView topParentId={searchParams.get('topParentId')} />;
component = <CollectionsView parentId={searchParamsParentId} />;
break;
case 5:
component = <GenresView topParentId={searchParams.get('topParentId')} />;
component = <GenresView parentId={searchParamsParentId} />;
break;
default:
component = <MoviesView parentId={searchParamsParentId} />;
}
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 (
<div ref={element}>
<Page
id='moviesPage'
className='mainAnimatedPage libraryPage backdropPage collectionEditorPage pageWithAbsoluteTabs withTabs'
backDropType='movie'
>
{getTabComponent(currentTabIndex)}
<Page
id='moviesPage'
className='mainAnimatedPage libraryPage backdropPage collectionEditorPage pageWithAbsoluteTabs withTabs'
backDropType='movie'
>
{getTabComponent(currentTabIndex)}
</Page>
</div>
</Page>
);
};