mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Convert ListView to react
This commit is contained in:
parent
cc87ba3859
commit
9efc71fa3b
14 changed files with 1009 additions and 0 deletions
32
src/components/listview/List/List.tsx
Normal file
32
src/components/listview/List/List.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import React, { FC } from 'react';
|
||||
import useList from './useList';
|
||||
import ListContent from './ListContent';
|
||||
import ListWrapper from './ListWrapper';
|
||||
import type { ItemDto } from 'types/itemDto';
|
||||
import type { ListOptions } from 'types/listOptions';
|
||||
import '../../mediainfo/mediainfo.scss';
|
||||
import '../../guide/programs.scss';
|
||||
|
||||
interface ListProps {
|
||||
index: number;
|
||||
item: ItemDto;
|
||||
listOptions?: ListOptions;
|
||||
}
|
||||
|
||||
const List: FC<ListProps> = ({ index, item, listOptions = {} }) => {
|
||||
const { getListdWrapperProps, getListContentProps } = useList({ item, listOptions } );
|
||||
const listWrapperProps = getListdWrapperProps();
|
||||
const listContentProps = getListContentProps();
|
||||
|
||||
return (
|
||||
<ListWrapper
|
||||
key={index}
|
||||
index={index}
|
||||
{...listWrapperProps}
|
||||
>
|
||||
<ListContent {...listContentProps} />
|
||||
</ListWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default List;
|
106
src/components/listview/List/ListContent.tsx
Normal file
106
src/components/listview/List/ListContent.tsx
Normal file
|
@ -0,0 +1,106 @@
|
|||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { FC } from 'react';
|
||||
import DragHandleIcon from '@mui/icons-material/DragHandle';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
import useIndicator from 'components/indicators/useIndicator';
|
||||
import PrimaryMediaInfo from '../../mediainfo/PrimaryMediaInfo';
|
||||
import ListContentWrapper from './ListContentWrapper';
|
||||
import ListItemBody from './ListItemBody';
|
||||
import ListImageContainer from './ListImageContainer';
|
||||
import ListViewUserDataButtons from './ListViewUserDataButtons';
|
||||
|
||||
import type { ItemDto } from 'types/itemDto';
|
||||
import type { ListOptions } from 'types/listOptions';
|
||||
|
||||
interface ListContentProps {
|
||||
item: ItemDto;
|
||||
listOptions: ListOptions;
|
||||
enableContentWrapper?: boolean;
|
||||
enableOverview?: boolean;
|
||||
enableSideMediaInfo?: boolean;
|
||||
clickEntireItem?: boolean;
|
||||
action?: string;
|
||||
isLargeStyle: boolean;
|
||||
downloadWidth?: number;
|
||||
}
|
||||
|
||||
const ListContent: FC<ListContentProps> = ({
|
||||
item,
|
||||
listOptions,
|
||||
enableContentWrapper,
|
||||
enableOverview,
|
||||
enableSideMediaInfo,
|
||||
clickEntireItem,
|
||||
action,
|
||||
isLargeStyle,
|
||||
downloadWidth
|
||||
}) => {
|
||||
const indicator = useIndicator(item);
|
||||
return (
|
||||
<ListContentWrapper
|
||||
itemOverview={item.Overview}
|
||||
enableContentWrapper={enableContentWrapper}
|
||||
enableOverview={enableOverview}
|
||||
>
|
||||
|
||||
{!clickEntireItem && listOptions.dragHandle && (
|
||||
<DragHandleIcon className='listViewDragHandle listItemIcon listItemIcon-transparent' />
|
||||
)}
|
||||
|
||||
{listOptions.image !== false && (
|
||||
<ListImageContainer
|
||||
item={item}
|
||||
listOptions={listOptions}
|
||||
action={action}
|
||||
isLargeStyle={isLargeStyle}
|
||||
clickEntireItem={clickEntireItem}
|
||||
downloadWidth={downloadWidth}
|
||||
/>
|
||||
)}
|
||||
|
||||
{listOptions.showIndexNumberLeft && (
|
||||
<Box className='listItem-indexnumberleft'>
|
||||
{item.IndexNumber ?? <span> </span>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ListItemBody
|
||||
item={item}
|
||||
listOptions={listOptions}
|
||||
action={action}
|
||||
enableContentWrapper={enableContentWrapper}
|
||||
enableOverview={enableOverview}
|
||||
enableSideMediaInfo={enableSideMediaInfo}
|
||||
getMissingIndicator={indicator.getMissingIndicator}
|
||||
/>
|
||||
|
||||
{listOptions.mediaInfo !== false && enableSideMediaInfo && (
|
||||
<PrimaryMediaInfo
|
||||
className='secondary listItemMediaInfo'
|
||||
item={item}
|
||||
isRuntimeEnabled={true}
|
||||
isStarRatingEnabled={true}
|
||||
isCaptionIndicatorEnabled={true}
|
||||
isEpisodeTitleEnabled={true}
|
||||
isOfficialRatingEnabled={true}
|
||||
getMissingIndicator={indicator.getMissingIndicator}
|
||||
/>
|
||||
)}
|
||||
|
||||
{listOptions.recordButton
|
||||
&& (item.Type === 'Timer' || item.Type === BaseItemKind.Program) && (
|
||||
indicator.getTimerIndicator('listItemAside')
|
||||
)}
|
||||
|
||||
{!clickEntireItem && (
|
||||
<ListViewUserDataButtons
|
||||
item={item}
|
||||
listOptions={listOptions}
|
||||
/>
|
||||
)}
|
||||
</ListContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListContent;
|
34
src/components/listview/List/ListContentWrapper.tsx
Normal file
34
src/components/listview/List/ListContentWrapper.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import React, { FC } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
interface ListContentWrapperProps {
|
||||
itemOverview: string | null | undefined;
|
||||
enableContentWrapper?: boolean;
|
||||
enableOverview?: boolean;
|
||||
}
|
||||
|
||||
const ListContentWrapper: FC<ListContentWrapperProps> = ({
|
||||
itemOverview,
|
||||
enableContentWrapper,
|
||||
enableOverview,
|
||||
children
|
||||
}) => {
|
||||
if (enableContentWrapper) {
|
||||
return (
|
||||
<>
|
||||
<Box className='listItem-content'>{children}</Box>
|
||||
|
||||
{enableOverview && itemOverview && (
|
||||
<Box className='listItem-bottomoverview secondary'>
|
||||
<bdi>{itemOverview}</bdi>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <>{children}</>;
|
||||
}
|
||||
};
|
||||
|
||||
export default ListContentWrapper;
|
30
src/components/listview/List/ListGroupHeaderWrapper.tsx
Normal file
30
src/components/listview/List/ListGroupHeaderWrapper.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import React, { FC } from 'react';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
interface ListGroupHeaderWrapperProps {
|
||||
index?: number;
|
||||
}
|
||||
|
||||
const ListGroupHeaderWrapper: FC<ListGroupHeaderWrapperProps> = ({
|
||||
index,
|
||||
children
|
||||
}) => {
|
||||
if (index === 0) {
|
||||
return (
|
||||
<Typography
|
||||
className='listGroupHeader listGroupHeader-first'
|
||||
variant='h2'
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Typography className='listGroupHeader' variant='h2'>
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ListGroupHeaderWrapper;
|
103
src/components/listview/List/ListImageContainer.tsx
Normal file
103
src/components/listview/List/ListImageContainer.tsx
Normal file
|
@ -0,0 +1,103 @@
|
|||
import React, { FC } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import Box from '@mui/material/Box';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import useIndicator from '../../indicators/useIndicator';
|
||||
import layoutManager from '../../layoutManager';
|
||||
import { getDefaultBackgroundClass } from '../../cardbuilder/cardBuilderUtils';
|
||||
import {
|
||||
canResume,
|
||||
getChannelImageUrl,
|
||||
getImageUrl
|
||||
} from './listHelper';
|
||||
|
||||
import Media from 'components/common/Media';
|
||||
import PlayArrowIconButton from 'components/common/PlayArrowIconButton';
|
||||
import type { ItemDto } from 'types/itemDto';
|
||||
import type { ListOptions } from 'types/listOptions';
|
||||
|
||||
interface ListImageContainerProps {
|
||||
item: ItemDto;
|
||||
listOptions: ListOptions;
|
||||
action?: string | null;
|
||||
isLargeStyle: boolean;
|
||||
clickEntireItem?: boolean;
|
||||
downloadWidth?: number;
|
||||
}
|
||||
|
||||
const ListImageContainer: FC<ListImageContainerProps> = ({
|
||||
item = {},
|
||||
listOptions,
|
||||
action,
|
||||
isLargeStyle,
|
||||
clickEntireItem,
|
||||
downloadWidth
|
||||
}) => {
|
||||
const { api } = useApi();
|
||||
const { getMediaSourceIndicator, getProgressBar, getPlayedIndicator } = useIndicator(item);
|
||||
const imgInfo = listOptions.imageSource === 'channel' ?
|
||||
getChannelImageUrl(item, api, downloadWidth) :
|
||||
getImageUrl(item, api, downloadWidth);
|
||||
|
||||
const defaultCardImageIcon = listOptions.defaultCardImageIcon;
|
||||
const disableIndicators = listOptions.disableIndicators;
|
||||
const imgUrl = imgInfo?.imgUrl;
|
||||
const blurhash = imgInfo.blurhash;
|
||||
|
||||
const imageClass = classNames(
|
||||
'listItemImage',
|
||||
{ 'listItemImage-large': isLargeStyle },
|
||||
{ 'listItemImage-channel': listOptions.imageSource === 'channel' },
|
||||
{ 'listItemImage-large-tv': isLargeStyle && layoutManager.tv },
|
||||
{ itemAction: !clickEntireItem },
|
||||
{ [getDefaultBackgroundClass(item.Name)]: !imgUrl }
|
||||
);
|
||||
|
||||
const playOnImageClick = listOptions.imagePlayButton && !layoutManager.tv;
|
||||
|
||||
const imageAction = playOnImageClick ? 'link' : action;
|
||||
|
||||
const btnCssClass =
|
||||
'paper-icon-button-light listItemImageButton itemAction';
|
||||
|
||||
const mediaSourceIndicator = getMediaSourceIndicator();
|
||||
const playedIndicator = getPlayedIndicator();
|
||||
const progressBar = getProgressBar();
|
||||
const playbackPositionTicks = item?.UserData?.PlaybackPositionTicks;
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-action={imageAction}
|
||||
className={imageClass}
|
||||
>
|
||||
|
||||
<Media item={item} imgUrl={imgUrl} blurhash={blurhash} defaultCardImageIcon={defaultCardImageIcon} />
|
||||
|
||||
{disableIndicators !== true && mediaSourceIndicator}
|
||||
|
||||
{playedIndicator && (
|
||||
<Box className='indicators listItemIndicators'>
|
||||
{playedIndicator}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{playOnImageClick && (
|
||||
<PlayArrowIconButton
|
||||
className={btnCssClass}
|
||||
action={
|
||||
canResume(playbackPositionTicks) ? 'resume' : 'play'
|
||||
}
|
||||
title={
|
||||
canResume(playbackPositionTicks) ?
|
||||
'ButtonResume' :
|
||||
'Play'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{progressBar}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListImageContainer;
|
65
src/components/listview/List/ListItemBody.tsx
Normal file
65
src/components/listview/List/ListItemBody.tsx
Normal file
|
@ -0,0 +1,65 @@
|
|||
import React, { FC } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import Box from '@mui/material/Box';
|
||||
import useListTextlines from './useListTextlines';
|
||||
import PrimaryMediaInfo from '../../mediainfo/PrimaryMediaInfo';
|
||||
|
||||
import type { ItemDto } from 'types/itemDto';
|
||||
import type { ListOptions } from 'types/listOptions';
|
||||
|
||||
interface ListItemBodyProps {
|
||||
item: ItemDto;
|
||||
listOptions: ListOptions;
|
||||
action?: string | null;
|
||||
isLargeStyle?: boolean;
|
||||
clickEntireItem?: boolean;
|
||||
enableContentWrapper?: boolean;
|
||||
enableOverview?: boolean;
|
||||
enableSideMediaInfo?: boolean;
|
||||
getMissingIndicator: () => React.JSX.Element | null
|
||||
}
|
||||
|
||||
const ListItemBody: FC<ListItemBodyProps> = ({
|
||||
item = {},
|
||||
listOptions = {},
|
||||
action,
|
||||
isLargeStyle,
|
||||
clickEntireItem,
|
||||
enableContentWrapper,
|
||||
enableOverview,
|
||||
enableSideMediaInfo,
|
||||
getMissingIndicator
|
||||
}) => {
|
||||
const { listTextLines } = useListTextlines({ item, listOptions, isLargeStyle });
|
||||
const cssClass = classNames(
|
||||
'listItemBody',
|
||||
{ 'itemAction': !clickEntireItem },
|
||||
{ 'listItemBody-noleftpadding': listOptions.image === false }
|
||||
);
|
||||
|
||||
return (
|
||||
<Box data-action={action} className={cssClass}>
|
||||
|
||||
{listTextLines}
|
||||
|
||||
{listOptions.mediaInfo !== false && !enableSideMediaInfo && (
|
||||
<PrimaryMediaInfo
|
||||
className='secondary listItemMediaInfo listItemBodyText'
|
||||
item={item}
|
||||
isEpisodeTitleEnabled={true}
|
||||
isOriginalAirDateEnabled={true}
|
||||
isCaptionIndicatorEnabled={true}
|
||||
getMissingIndicator={getMissingIndicator}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!enableContentWrapper && enableOverview && item.Overview && (
|
||||
<Box className='secondary listItem-overview listItemBodyText'>
|
||||
<bdi>{item.Overview}</bdi>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListItemBody;
|
30
src/components/listview/List/ListTextWrapper.tsx
Normal file
30
src/components/listview/List/ListTextWrapper.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import React, { FC } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
interface ListTextWrapperProps {
|
||||
index?: number;
|
||||
isLargeStyle?: boolean;
|
||||
}
|
||||
|
||||
const ListTextWrapper: FC<ListTextWrapperProps> = ({
|
||||
index,
|
||||
isLargeStyle,
|
||||
children
|
||||
}) => {
|
||||
if (index === 0) {
|
||||
if (isLargeStyle) {
|
||||
return (
|
||||
<Typography className='listItemBodyText' variant='h2'>
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
} else {
|
||||
return <Box className='listItemBodyText'>{children}</Box>;
|
||||
}
|
||||
} else {
|
||||
return <Box className='secondary listItemBodyText'>{children}</Box>;
|
||||
}
|
||||
};
|
||||
|
||||
export default ListTextWrapper;
|
87
src/components/listview/List/ListViewUserDataButtons.tsx
Normal file
87
src/components/listview/List/ListViewUserDataButtons.tsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
import React, { FC } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import itemHelper from '../../itemHelper';
|
||||
import PlayedButton from 'elements/emby-playstatebutton/PlayedButton';
|
||||
import FavoriteButton from 'elements/emby-ratingbutton/FavoriteButton';
|
||||
import PlaylistAddIconButton from '../../common/PlaylistAddIconButton';
|
||||
import InfoIconButton from '../../common/InfoIconButton';
|
||||
import RightIconButtons from '../../common/RightIconButtons';
|
||||
import MoreVertIconButton from '../../common/MoreVertIconButton';
|
||||
|
||||
import type { ItemDto } from 'types/itemDto';
|
||||
import type { ListOptions } from 'types/listOptions';
|
||||
|
||||
interface ListViewUserDataButtonsProps {
|
||||
item: ItemDto;
|
||||
listOptions: ListOptions;
|
||||
}
|
||||
|
||||
const ListViewUserDataButtons: FC<ListViewUserDataButtonsProps> = ({
|
||||
item = {},
|
||||
listOptions
|
||||
}) => {
|
||||
const { IsFavorite, Played } = item.UserData ?? {};
|
||||
|
||||
const renderRightButtons = () => {
|
||||
return listOptions.rightButtons?.map((button, index) => (
|
||||
<RightIconButtons
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
className='listItemButton itemAction'
|
||||
id={button.id}
|
||||
title={button.title}
|
||||
icon={button.icon}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className='listViewUserDataButtons'>
|
||||
{listOptions.addToListButton && (
|
||||
<PlaylistAddIconButton
|
||||
className='paper-icon-button-light listItemButton itemAction'
|
||||
/>
|
||||
|
||||
)}
|
||||
{listOptions.infoButton && (
|
||||
<InfoIconButton
|
||||
className='paper-icon-button-light listItemButton itemAction'
|
||||
/>
|
||||
|
||||
) }
|
||||
|
||||
{listOptions.rightButtons && renderRightButtons()}
|
||||
|
||||
{listOptions.enableUserDataButtons !== false && (
|
||||
<>
|
||||
{itemHelper.canMarkPlayed(item)
|
||||
&& listOptions.enablePlayedButton !== false && (
|
||||
<PlayedButton
|
||||
className='listItemButton'
|
||||
isPlayed={Played}
|
||||
itemId={item.Id}
|
||||
itemType={item.Type}
|
||||
/>
|
||||
)}
|
||||
|
||||
{itemHelper.canRate(item)
|
||||
&& listOptions.enableRatingButton !== false && (
|
||||
<FavoriteButton
|
||||
className='listItemButton'
|
||||
isFavorite={IsFavorite}
|
||||
itemId={item.Id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{listOptions.moreButton !== false && (
|
||||
<MoreVertIconButton
|
||||
className='paper-icon-button-light listItemButton itemAction'
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListViewUserDataButtons;
|
49
src/components/listview/List/ListWrapper.tsx
Normal file
49
src/components/listview/List/ListWrapper.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
import classNames from 'classnames';
|
||||
import escapeHTML from 'escape-html';
|
||||
import React, { FC } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import layoutManager from '../../layoutManager';
|
||||
import type { DataAttributes } from 'types/dataAttributes';
|
||||
|
||||
interface ListWrapperProps {
|
||||
index: number | undefined;
|
||||
title?: string | null;
|
||||
action?: string | null;
|
||||
dataAttributes?: DataAttributes;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ListWrapper: FC<ListWrapperProps> = ({
|
||||
index,
|
||||
action,
|
||||
title,
|
||||
className,
|
||||
dataAttributes,
|
||||
children
|
||||
}) => {
|
||||
if (layoutManager.tv) {
|
||||
return (
|
||||
<Button
|
||||
data-index={index}
|
||||
className={classNames(
|
||||
className,
|
||||
'itemAction listItem-button listItem-focusscale'
|
||||
)}
|
||||
data-action={action}
|
||||
aria-label={escapeHTML(title)}
|
||||
{...dataAttributes}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Box data-index={index} className={className} {...dataAttributes}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ListWrapper;
|
57
src/components/listview/List/Lists.tsx
Normal file
57
src/components/listview/List/Lists.tsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
import React, { FC } from 'react';
|
||||
import escapeHTML from 'escape-html';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import Box from '@mui/material/Box';
|
||||
import { getIndex } from './listHelper';
|
||||
import ListGroupHeaderWrapper from './ListGroupHeaderWrapper';
|
||||
import List from './List';
|
||||
|
||||
import type { ItemDto } from 'types/itemDto';
|
||||
import type { ListOptions } from 'types/listOptions';
|
||||
import '../listview.scss';
|
||||
|
||||
interface ListsProps {
|
||||
items: ItemDto[];
|
||||
listOptions?: ListOptions;
|
||||
}
|
||||
|
||||
const Lists: FC<ListsProps> = ({ items = [], listOptions = {} }) => {
|
||||
const groupedData = groupBy(items, (item) => {
|
||||
if (listOptions.showIndex) {
|
||||
return getIndex(item, listOptions);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const renderListItem = (item: ItemDto, index: number) => {
|
||||
return (
|
||||
<List
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`${item.Id}-${index}`}
|
||||
index={index}
|
||||
item={item}
|
||||
listOptions={listOptions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries(groupedData).map(
|
||||
([itemGroupTitle, getItems], index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Box key={index}>
|
||||
{itemGroupTitle && (
|
||||
<ListGroupHeaderWrapper index={index}>
|
||||
{escapeHTML(itemGroupTitle)}
|
||||
</ListGroupHeaderWrapper>
|
||||
)}
|
||||
{getItems.map((item) => renderListItem(item, index))}
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Lists;
|
171
src/components/listview/List/listHelper.ts
Normal file
171
src/components/listview/List/listHelper.ts
Normal file
|
@ -0,0 +1,171 @@
|
|||
import { Api } from '@jellyfin/sdk';
|
||||
import { BaseItemKind, ImageType } from '@jellyfin/sdk/lib/generated-client';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
import type { ItemDto } from 'types/itemDto';
|
||||
import type { ListOptions } from 'types/listOptions';
|
||||
|
||||
const sortBySortName = (item: ItemDto): string => {
|
||||
if (item.Type === BaseItemKind.Episode) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// SortName
|
||||
const name = (item.SortName ?? item.Name ?? '?')[0].toUpperCase();
|
||||
|
||||
const code = name.charCodeAt(0);
|
||||
if (code < 65 || code > 90) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
return name.toUpperCase();
|
||||
};
|
||||
|
||||
const sortByOfficialrating = (item: ItemDto): string => {
|
||||
return item.OfficialRating ?? globalize.translate('Unrated');
|
||||
};
|
||||
|
||||
const sortByCommunityRating = (item: ItemDto): string => {
|
||||
if (item.CommunityRating == null) {
|
||||
return globalize.translate('Unrated');
|
||||
}
|
||||
|
||||
return String(Math.floor(item.CommunityRating));
|
||||
};
|
||||
|
||||
const sortByCriticRating = (item: ItemDto): string => {
|
||||
if (item.CriticRating == null) {
|
||||
return globalize.translate('Unrated');
|
||||
}
|
||||
|
||||
return String(Math.floor(item.CriticRating));
|
||||
};
|
||||
|
||||
const sortByAlbumArtist = (item: ItemDto): string => {
|
||||
// SortName
|
||||
if (!item.AlbumArtist) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const name = item.AlbumArtist[0].toUpperCase();
|
||||
|
||||
const code = name.charCodeAt(0);
|
||||
if (code < 65 || code > 90) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
return name.toUpperCase();
|
||||
};
|
||||
|
||||
export function getIndex(item: ItemDto, listOptions: ListOptions): string {
|
||||
if (listOptions.index === 'disc') {
|
||||
return item.ParentIndexNumber == null ?
|
||||
'' :
|
||||
globalize.translate('ValueDiscNumber', item.ParentIndexNumber);
|
||||
}
|
||||
|
||||
const sortBy = (listOptions.sortBy ?? '').toLowerCase();
|
||||
|
||||
if (sortBy.startsWith('sortname')) {
|
||||
return sortBySortName(item);
|
||||
}
|
||||
if (sortBy.startsWith('officialrating')) {
|
||||
return sortByOfficialrating(item);
|
||||
}
|
||||
if (sortBy.startsWith('communityrating')) {
|
||||
return sortByCommunityRating(item);
|
||||
}
|
||||
if (sortBy.startsWith('criticrating')) {
|
||||
return sortByCriticRating(item);
|
||||
}
|
||||
if (sortBy.startsWith('albumartist')) {
|
||||
return sortByAlbumArtist(item);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function getImageUrl(
|
||||
item: ItemDto,
|
||||
api: Api | undefined,
|
||||
size: number | undefined
|
||||
) {
|
||||
let imgTag;
|
||||
let itemId;
|
||||
const fillWidth = size;
|
||||
const fillHeight = size;
|
||||
const imgType = ImageType.Primary;
|
||||
|
||||
if (item.ImageTags?.Primary) {
|
||||
imgTag = item.ImageTags.Primary;
|
||||
itemId = item.Id;
|
||||
} else if (item.AlbumId && item.AlbumPrimaryImageTag) {
|
||||
imgTag = item.AlbumPrimaryImageTag;
|
||||
itemId = item.AlbumId;
|
||||
} else if (item.SeriesId && item.SeriesPrimaryImageTag) {
|
||||
imgTag = item.SeriesPrimaryImageTag;
|
||||
itemId = item.SeriesId;
|
||||
} else if (item.ParentPrimaryImageTag) {
|
||||
imgTag = item.ParentPrimaryImageTag;
|
||||
itemId = item.ParentPrimaryImageItemId;
|
||||
}
|
||||
|
||||
if (api && imgTag && imgType && itemId) {
|
||||
const response = api.getItemImageUrl(itemId, imgType, {
|
||||
fillWidth: fillWidth,
|
||||
fillHeight: fillHeight,
|
||||
tag: imgTag
|
||||
});
|
||||
|
||||
return {
|
||||
imgUrl: response,
|
||||
blurhash: item.ImageBlurHashes?.[imgType]?.[imgTag]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
imgUrl: undefined,
|
||||
blurhash: undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function getChannelImageUrl(
|
||||
item: ItemDto,
|
||||
api: Api | undefined,
|
||||
size: number | undefined
|
||||
) {
|
||||
let imgTag;
|
||||
let itemId;
|
||||
const fillWidth = size;
|
||||
const fillHeight = size;
|
||||
const imgType = ImageType.Primary;
|
||||
|
||||
if (item.ChannelId && item.ChannelPrimaryImageTag) {
|
||||
imgTag = item.ChannelPrimaryImageTag;
|
||||
itemId = item.ChannelId;
|
||||
}
|
||||
|
||||
if (api && imgTag && imgType && itemId) {
|
||||
const response = api.getItemImageUrl(itemId, imgType, {
|
||||
fillWidth: fillWidth,
|
||||
fillHeight: fillHeight,
|
||||
tag: imgTag
|
||||
});
|
||||
|
||||
return {
|
||||
imgUrl: response,
|
||||
blurhash: item.ImageBlurHashes?.[imgType]?.[imgTag]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
imgUrl: undefined,
|
||||
blurhash: undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function canResume(PlaybackPositionTicks: number | undefined): boolean {
|
||||
return Boolean(
|
||||
PlaybackPositionTicks
|
||||
&& PlaybackPositionTicks > 0
|
||||
);
|
||||
}
|
77
src/components/listview/List/useList.ts
Normal file
77
src/components/listview/List/useList.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
import classNames from 'classnames';
|
||||
import { getDataAttributes } from 'utils/items';
|
||||
import layoutManager from 'components/layoutManager';
|
||||
|
||||
import type { ItemDto } from 'types/itemDto';
|
||||
import type { ListOptions } from 'types/listOptions';
|
||||
|
||||
interface UseListProps {
|
||||
item: ItemDto;
|
||||
listOptions: ListOptions;
|
||||
}
|
||||
|
||||
function useList({ item, listOptions }: UseListProps) {
|
||||
const action = listOptions.action ?? 'link';
|
||||
const isLargeStyle = listOptions.imageSize === 'large';
|
||||
const enableOverview = listOptions.enableOverview;
|
||||
const clickEntireItem = !!layoutManager.tv;
|
||||
const enableSideMediaInfo = listOptions.enableSideMediaInfo ?? true;
|
||||
const enableContentWrapper =
|
||||
listOptions.enableOverview && !layoutManager.tv;
|
||||
const downloadWidth = isLargeStyle ? 500 : 80;
|
||||
|
||||
const dataAttributes = getDataAttributes(
|
||||
{
|
||||
action,
|
||||
itemServerId: item.ServerId,
|
||||
itemId: item.Id,
|
||||
collectionId: listOptions.collectionId,
|
||||
playlistId: listOptions.playlistId,
|
||||
itemChannelId: item.ChannelId,
|
||||
itemType: item.Type,
|
||||
itemMediaType: item.MediaType,
|
||||
itemCollectionType: item.CollectionType,
|
||||
itemIsFolder: item.IsFolder,
|
||||
itemPlaylistItemId: item.PlaylistItemId
|
||||
}
|
||||
);
|
||||
|
||||
const listWrapperClass = classNames(
|
||||
'listItem',
|
||||
{
|
||||
'listItem-border':
|
||||
listOptions.border
|
||||
?? (listOptions.highlight !== false && !layoutManager.tv)
|
||||
},
|
||||
{ 'itemAction listItem-button': clickEntireItem },
|
||||
{ 'listItem-focusscale': layoutManager.tv },
|
||||
{ 'listItem-largeImage': isLargeStyle },
|
||||
{ 'listItem-withContentWrapper': enableContentWrapper }
|
||||
);
|
||||
|
||||
const getListdWrapperProps = () => ({
|
||||
className: listWrapperClass,
|
||||
title: item.Name,
|
||||
action,
|
||||
dataAttributes
|
||||
});
|
||||
|
||||
const getListContentProps = () => ({
|
||||
item,
|
||||
listOptions,
|
||||
enableContentWrapper,
|
||||
enableOverview,
|
||||
enableSideMediaInfo,
|
||||
clickEntireItem,
|
||||
action,
|
||||
isLargeStyle,
|
||||
downloadWidth
|
||||
});
|
||||
|
||||
return {
|
||||
getListdWrapperProps,
|
||||
getListContentProps
|
||||
};
|
||||
}
|
||||
|
||||
export default useList;
|
167
src/components/listview/List/useListTextlines.tsx
Normal file
167
src/components/listview/List/useListTextlines.tsx
Normal file
|
@ -0,0 +1,167 @@
|
|||
import React from 'react';
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client';
|
||||
import itemHelper from '../../itemHelper';
|
||||
import datetime from 'scripts/datetime';
|
||||
import ListTextWrapper from './ListTextWrapper';
|
||||
import type { ItemDto } from 'types/itemDto';
|
||||
import type { ListOptions } from 'types/listOptions';
|
||||
|
||||
function getParentTitle(
|
||||
showParentTitle: boolean | undefined,
|
||||
item: ItemDto,
|
||||
parentTitleWithTitle: boolean | undefined,
|
||||
displayName: string | null | undefined
|
||||
) {
|
||||
let parentTitle = null;
|
||||
if (showParentTitle) {
|
||||
if (item.Type === BaseItemKind.Episode) {
|
||||
parentTitle = item.SeriesName;
|
||||
} else if (item.IsSeries || (item.EpisodeTitle && item.Name)) {
|
||||
parentTitle = item.Name;
|
||||
}
|
||||
}
|
||||
if (showParentTitle && parentTitleWithTitle) {
|
||||
if (displayName) {
|
||||
parentTitle += ' - ';
|
||||
}
|
||||
parentTitle = (parentTitle ?? '') + displayName;
|
||||
}
|
||||
return parentTitle;
|
||||
}
|
||||
|
||||
function getNameOrIndexWithName(
|
||||
item: ItemDto,
|
||||
listOptions: ListOptions,
|
||||
showIndexNumber: boolean | undefined
|
||||
) {
|
||||
let displayName = itemHelper.getDisplayName(item, {
|
||||
includeParentInfo: listOptions.includeParentInfoInTitle
|
||||
});
|
||||
|
||||
if (showIndexNumber && item.IndexNumber != null) {
|
||||
displayName = `${item.IndexNumber}. ${displayName}`;
|
||||
}
|
||||
return displayName;
|
||||
}
|
||||
|
||||
interface UseListTextlinesProps {
|
||||
item: ItemDto;
|
||||
listOptions?: ListOptions;
|
||||
isLargeStyle?: boolean;
|
||||
}
|
||||
|
||||
function useListTextlines({ item = {}, listOptions = {}, isLargeStyle }: UseListTextlinesProps) {
|
||||
const {
|
||||
showProgramDateTime,
|
||||
showProgramTime,
|
||||
showChannel,
|
||||
showParentTitle,
|
||||
showIndexNumber,
|
||||
parentTitleWithTitle,
|
||||
artist
|
||||
} = listOptions;
|
||||
const textLines: string[] = [];
|
||||
|
||||
const addTextLine = (text: string | null) => {
|
||||
if (text) {
|
||||
textLines.push(text);
|
||||
}
|
||||
};
|
||||
|
||||
const addProgramDateTime = () => {
|
||||
if (showProgramDateTime) {
|
||||
const programDateTime = datetime.toLocaleString(
|
||||
datetime.parseISO8601Date(item.StartDate),
|
||||
{
|
||||
weekday: 'long',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
}
|
||||
);
|
||||
addTextLine(programDateTime);
|
||||
}
|
||||
};
|
||||
|
||||
const addProgramTime = () => {
|
||||
if (showProgramTime) {
|
||||
const programTime = datetime.getDisplayTime(
|
||||
datetime.parseISO8601Date(item.StartDate)
|
||||
);
|
||||
addTextLine(programTime);
|
||||
}
|
||||
};
|
||||
|
||||
const addChannelName = () => {
|
||||
if (showChannel && item.ChannelName) {
|
||||
addTextLine(item.ChannelName);
|
||||
}
|
||||
};
|
||||
|
||||
const displayName = getNameOrIndexWithName(item, listOptions, showIndexNumber);
|
||||
|
||||
const parentTitle = getParentTitle(showParentTitle, item, parentTitleWithTitle, displayName );
|
||||
|
||||
const addParentTitle = () => {
|
||||
addTextLine(parentTitle ?? '');
|
||||
};
|
||||
|
||||
const addDisplayName = () => {
|
||||
if (displayName && !parentTitleWithTitle) {
|
||||
addTextLine(displayName);
|
||||
}
|
||||
};
|
||||
|
||||
const addAlbumArtistOrArtists = () => {
|
||||
if (item.IsFolder && artist !== false) {
|
||||
if (item.AlbumArtist && item.Type === BaseItemKind.MusicAlbum) {
|
||||
addTextLine(item.AlbumArtist);
|
||||
}
|
||||
} else if (artist) {
|
||||
const artistItems = item.ArtistItems;
|
||||
if (artistItems && item.Type !== BaseItemKind.MusicAlbum) {
|
||||
const artists = artistItems.map((a) => a.Name).join(', ');
|
||||
addTextLine(artists);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addCurrentProgram = () => {
|
||||
if (item.Type === BaseItemKind.TvChannel && item.CurrentProgram) {
|
||||
const currentProgram = itemHelper.getDisplayName(
|
||||
item.CurrentProgram
|
||||
);
|
||||
addTextLine(currentProgram);
|
||||
}
|
||||
};
|
||||
|
||||
addProgramDateTime();
|
||||
addProgramTime();
|
||||
addChannelName();
|
||||
addParentTitle();
|
||||
addDisplayName();
|
||||
addAlbumArtistOrArtists();
|
||||
addCurrentProgram();
|
||||
|
||||
const renderTextlines = (text: string, index: number) => {
|
||||
return (
|
||||
<ListTextWrapper
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
index={index}
|
||||
isLargeStyle={isLargeStyle}
|
||||
>
|
||||
<bdi>{text}</bdi>
|
||||
</ListTextWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const listTextLines = textLines?.map((text, index) => renderTextlines(text, index));
|
||||
|
||||
return {
|
||||
listTextLines
|
||||
};
|
||||
}
|
||||
|
||||
export default useListTextlines;
|
|
@ -183,6 +183,7 @@
|
|||
}
|
||||
|
||||
.listItemImage .cardImageIcon {
|
||||
margin: auto;
|
||||
font-size: 3em;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue