diff --git a/src/components/cardbuilder/Card/Card.tsx b/src/components/cardbuilder/Card/Card.tsx new file mode 100644 index 0000000000..2173e0301b --- /dev/null +++ b/src/components/cardbuilder/Card/Card.tsx @@ -0,0 +1,25 @@ +import React, { FC } from 'react'; +import useCard from './useCard'; +import CardWrapper from './CardWrapper'; +import CardBox from './CardBox'; + +import type { CardOptions } from 'types/cardOptions'; +import type { ItemDto } from 'types/itemDto'; + +interface CardProps { + item?: ItemDto; + cardOptions: CardOptions; +} + +const Card: FC = ({ item = {}, cardOptions }) => { + const { getCardWrapperProps, getCardBoxProps } = useCard({ item, cardOptions } ); + const cardWrapperProps = getCardWrapperProps(); + const cardBoxProps = getCardBoxProps(); + return ( + + + + ); +}; + +export default Card; diff --git a/src/components/cardbuilder/Card/CardBox.tsx b/src/components/cardbuilder/Card/CardBox.tsx new file mode 100644 index 0000000000..430c27b444 --- /dev/null +++ b/src/components/cardbuilder/Card/CardBox.tsx @@ -0,0 +1,79 @@ + +import React, { FC } from 'react'; +import layoutManager from 'components/layoutManager'; + +import CardOverlayButtons from './CardOverlayButtons'; +import CardHoverMenu from './CardHoverMenu'; +import CardOuterFooter from './CardOuterFooter'; +import CardContent from './CardContent'; + +import type { ItemDto } from 'types/itemDto'; +import type { CardOptions } from 'types/cardOptions'; + +interface CardBoxProps { + item: ItemDto; + cardOptions: CardOptions; + className: string; + shape: string | null | undefined; + imgUrl: string | undefined; + blurhash: string | undefined; + forceName: boolean; + coveredImage: boolean; + overlayText: boolean | undefined; +} + +const CardBox: FC = ({ + item, + cardOptions, + className, + shape, + imgUrl, + blurhash, + forceName, + coveredImage, + overlayText +}) => { + return ( +
+
+
+ + {layoutManager.mobile && ( + + )} + + {layoutManager.desktop + && !cardOptions.disableHoverMenu && ( + + )} +
+ {!overlayText && ( + + )} +
+ ); +}; + +export default CardBox; + diff --git a/src/components/cardbuilder/Card/CardContent.tsx b/src/components/cardbuilder/Card/CardContent.tsx new file mode 100644 index 0000000000..3d846758a4 --- /dev/null +++ b/src/components/cardbuilder/Card/CardContent.tsx @@ -0,0 +1,50 @@ +import React, { FC } from 'react'; +import classNames from 'classnames'; +import { getDefaultBackgroundClass } from '../cardBuilderUtils'; +import CardImageContainer from './CardImageContainer'; + +import type { ItemDto } from 'types/itemDto'; +import type { CardOptions } from 'types/cardOptions'; + +interface CardContentProps { + item: ItemDto; + cardOptions: CardOptions; + coveredImage: boolean; + overlayText: boolean | undefined; + imgUrl: string | undefined; + blurhash: string | undefined; + forceName: boolean; +} + +const CardContent: FC = ({ + item, + cardOptions, + coveredImage, + overlayText, + imgUrl, + blurhash, + forceName +}) => { + const cardContentClass = classNames( + 'cardContent', + { [getDefaultBackgroundClass(item.Name)]: !imgUrl } + ); + + return ( +
+ +
+ ); +}; + +export default CardContent; diff --git a/src/components/cardbuilder/Card/CardFooterText.tsx b/src/components/cardbuilder/Card/CardFooterText.tsx new file mode 100644 index 0000000000..87ba3b22ea --- /dev/null +++ b/src/components/cardbuilder/Card/CardFooterText.tsx @@ -0,0 +1,81 @@ +import React, { FC } from 'react'; +import Box from '@mui/material/Box'; +import useCardText from './useCardText'; +import layoutManager from 'components/layoutManager'; +import MoreVertIconButton from '../../common/MoreVertIconButton'; +import type { ItemDto } from 'types/itemDto'; +import type { CardOptions } from 'types/cardOptions'; + +const shouldShowDetailsMenu = ( + cardOptions: CardOptions, + isOuterFooter: boolean +) => { + return ( + cardOptions.showDetailsMenu + && isOuterFooter + && cardOptions.cardLayout + && layoutManager.mobile + && cardOptions.cardFooterAside !== 'none' + ); +}; + +interface LogoComponentProps { + logoUrl: string; +} + +const LogoComponent: FC = ({ logoUrl }) => { + return ; +}; + +interface CardFooterTextProps { + item: ItemDto; + cardOptions: CardOptions; + forceName: boolean; + overlayText: boolean | undefined; + imgUrl: string | undefined; + footerClass: string | undefined; + progressBar?: React.JSX.Element | null; + logoUrl?: string; + isOuterFooter: boolean; +} + +const CardFooterText: FC = ({ + item, + cardOptions, + forceName, + imgUrl, + footerClass, + overlayText, + progressBar, + logoUrl, + isOuterFooter +}) => { + const { cardTextLines } = useCardText({ + item, + cardOptions, + forceName, + imgUrl, + overlayText, + isOuterFooter, + cssClass: cardOptions.centerText ? + 'cardText cardTextCentered' : + 'cardText', + forceLines: !cardOptions.overlayText, + maxLines: cardOptions.lines + }); + + return ( + + {logoUrl && } + {shouldShowDetailsMenu(cardOptions, isOuterFooter) && ( + + )} + + {cardTextLines} + + {progressBar} + + ); +}; + +export default CardFooterText; diff --git a/src/components/cardbuilder/Card/CardHoverMenu.tsx b/src/components/cardbuilder/Card/CardHoverMenu.tsx new file mode 100644 index 0000000000..e135d1bd82 --- /dev/null +++ b/src/components/cardbuilder/Card/CardHoverMenu.tsx @@ -0,0 +1,83 @@ +import React, { FC } from 'react'; +import Box from '@mui/material/Box'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import classNames from 'classnames'; +import escapeHTML from 'escape-html'; +import { appRouter } from 'components/router/appRouter'; +import itemHelper from 'components/itemHelper'; +import { playbackManager } from 'components/playback/playbackmanager'; + +import PlayedButton from 'elements/emby-playstatebutton/PlayedButton'; +import FavoriteButton from 'elements/emby-ratingbutton/FavoriteButton'; +import PlayArrowIconButton from '../../common/PlayArrowIconButton'; +import MoreVertIconButton from '../../common/MoreVertIconButton'; + +import type { ItemDto } from 'types/itemDto'; +import type { CardOptions } from 'types/cardOptions'; + +interface CardHoverMenuProps { + item: ItemDto; + cardOptions: CardOptions; +} + +const CardHoverMenu: FC = ({ + item, + cardOptions +}) => { + const url = appRouter.getRouteUrl(item, { + parentId: cardOptions.parentId + }); + const btnCssClass = + 'paper-icon-button-light cardOverlayButton cardOverlayButton-hover itemAction'; + + const centerPlayButtonClass = classNames( + btnCssClass, + 'cardOverlayFab-primary' + ); + const { IsFavorite, Played } = item.UserData ?? {}; + + return ( + + + + {playbackManager.canPlay(item) && ( + + )} + + + {itemHelper.canMarkPlayed(item) && cardOptions.enablePlayedButton !== false && ( + + )} + + {itemHelper.canRate(item) && cardOptions.enableRatingButton !== false && ( + + )} + + + + + ); +}; + +export default CardHoverMenu; diff --git a/src/components/cardbuilder/Card/CardImageContainer.tsx b/src/components/cardbuilder/Card/CardImageContainer.tsx new file mode 100644 index 0000000000..3b66048e9e --- /dev/null +++ b/src/components/cardbuilder/Card/CardImageContainer.tsx @@ -0,0 +1,82 @@ +import React, { FC } from 'react'; +import Box from '@mui/material/Box'; +import classNames from 'classnames'; +import useIndicator from 'components/indicators/useIndicator'; +import RefreshIndicator from 'elements/emby-itemrefreshindicator/RefreshIndicator'; +import Media from '../../common/Media'; +import CardInnerFooter from './CardInnerFooter'; + +import type { ItemDto } from 'types/itemDto'; +import type { CardOptions } from 'types/cardOptions'; + +interface CardImageContainerProps { + item: ItemDto; + cardOptions: CardOptions; + coveredImage: boolean; + overlayText: boolean | undefined; + imgUrl: string | undefined; + blurhash: string | undefined; + forceName: boolean; +} + +const CardImageContainer: FC = ({ + item, + cardOptions, + coveredImage, + overlayText, + imgUrl, + blurhash, + forceName +}) => { + const indicator = useIndicator(item); + const cardImageClass = classNames( + 'cardImageContainer', + { coveredImage: coveredImage }, + { 'coveredImage-contain': coveredImage && item.Type === 'TvChannel' } + ); + + return ( +
+ {cardOptions.disableIndicators !== true && ( + + {indicator.getMediaSourceIndicator()} + + + {cardOptions.missingIndicator !== false + && indicator.getMissingIndicator()} + + {indicator.getTimerIndicator()} + {indicator.getTypeIndicator()} + + {cardOptions.showGroupCount ? + indicator.getChildCountIndicator() : + indicator.getPlayedIndicator()} + + {(item.Type === 'CollectionFolder' + || item.CollectionType) + && item.RefreshProgress && ( + + )} + + + )} + + + + {overlayText && ( + + )} + + {!overlayText && indicator.getProgressBar()} +
+ ); +}; + +export default CardImageContainer; diff --git a/src/components/cardbuilder/Card/CardInnerFooter.tsx b/src/components/cardbuilder/Card/CardInnerFooter.tsx new file mode 100644 index 0000000000..d6edf853c0 --- /dev/null +++ b/src/components/cardbuilder/Card/CardInnerFooter.tsx @@ -0,0 +1,42 @@ +import React, { FC } from 'react'; +import classNames from 'classnames'; +import CardFooterText from './CardFooterText'; +import type { ItemDto } from 'types/itemDto'; +import type { CardOptions } from 'types/cardOptions'; + +interface CardInnerFooterProps { + item: ItemDto; + cardOptions: CardOptions; + imgUrl: string | undefined; + progressBar?: React.JSX.Element | null; + forceName: boolean; + overlayText: boolean | undefined; +} + +const CardInnerFooter: FC = ({ + item, + cardOptions, + imgUrl, + overlayText, + progressBar, + forceName +}) => { + const footerClass = classNames('innerCardFooter', { + fullInnerCardFooter: progressBar + }); + + return ( + + ); +}; + +export default CardInnerFooter; diff --git a/src/components/cardbuilder/Card/CardOuterFooter.tsx b/src/components/cardbuilder/Card/CardOuterFooter.tsx new file mode 100644 index 0000000000..020a64d584 --- /dev/null +++ b/src/components/cardbuilder/Card/CardOuterFooter.tsx @@ -0,0 +1,45 @@ +import React, { FC } from 'react'; +import classNames from 'classnames'; +import { useApi } from 'hooks/useApi'; +import { getCardLogoUrl } from './cardHelper'; +import CardFooterText from './CardFooterText'; + +import type { ItemDto } from 'types/itemDto'; +import type { CardOptions } from 'types/cardOptions'; + +interface CardOuterFooterProps { + item: ItemDto + cardOptions: CardOptions; + imgUrl: string | undefined; + forceName: boolean; + overlayText: boolean | undefined +} + +const CardOuterFooter: FC = ({ item, cardOptions, overlayText, imgUrl, forceName }) => { + const { api } = useApi(); + const logoInfo = getCardLogoUrl(item, api, cardOptions); + const logoUrl = logoInfo.logoUrl; + + const footerClass = classNames( + 'cardFooter', + { 'cardFooter-transparent': cardOptions.cardLayout }, + { 'cardFooter-withlogo': logoUrl } + ); + + return ( + + + ); +}; + +export default CardOuterFooter; diff --git a/src/components/cardbuilder/Card/CardOverlayButtons.tsx b/src/components/cardbuilder/Card/CardOverlayButtons.tsx new file mode 100644 index 0000000000..0ac8463969 --- /dev/null +++ b/src/components/cardbuilder/Card/CardOverlayButtons.tsx @@ -0,0 +1,96 @@ +import React, { FC } from 'react'; +import Box from '@mui/material/Box'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import classNames from 'classnames'; + +import PlayArrowIconButton from '../../common/PlayArrowIconButton'; +import MoreVertIconButton from '../../common/MoreVertIconButton'; + +import type { ItemDto } from 'types/itemDto'; +import type { CardOptions } from 'types/cardOptions'; + +const sholudShowOverlayPlayButton = ( + overlayPlayButton: boolean | undefined, + item: ItemDto +) => { + return ( + overlayPlayButton + && !item.IsPlaceHolder + && (item.LocationType !== 'Virtual' + || !item.MediaType + || item.Type === 'Program') + && item.Type !== 'Person' + ); +}; + +interface CardOverlayButtonsProps { + item: ItemDto; + cardOptions: CardOptions; +} + +const CardOverlayButtons: FC = ({ + item, + cardOptions +}) => { + let overlayPlayButton = cardOptions.overlayPlayButton; + + if ( + overlayPlayButton == null + && !cardOptions.overlayMoreButton + && !cardOptions.overlayInfoButton + && !cardOptions.cardLayout + ) { + overlayPlayButton = item.MediaType === 'Video'; + } + + const btnCssClass = classNames( + 'paper-icon-button-light', + 'cardOverlayButton', + 'itemAction' + ); + + const centerPlayButtonClass = classNames( + btnCssClass, + 'cardOverlayButton-centered' + ); + + return ( + + {cardOptions.centerPlayButton && ( + + )} + + + {sholudShowOverlayPlayButton(overlayPlayButton, item) && ( + + )} + + {cardOptions.overlayMoreButton && ( + + )} + + + ); +}; + +export default CardOverlayButtons; diff --git a/src/components/cardbuilder/Card/CardText.tsx b/src/components/cardbuilder/Card/CardText.tsx new file mode 100644 index 0000000000..be6d0b049c --- /dev/null +++ b/src/components/cardbuilder/Card/CardText.tsx @@ -0,0 +1,33 @@ +import React, { FC } from 'react'; +import Box from '@mui/material/Box'; +import escapeHTML from 'escape-html'; +import type { TextLine } from './cardHelper'; + +interface CardTextProps { + className?: string; + textLine: TextLine; +} + +const CardText: FC = ({ className, textLine }) => { + const { title, titleAction } = textLine; + const renderCardText = () => { + if (titleAction) { + return ( + + {escapeHTML(titleAction.title)} + + ); + } else { + return title; + } + }; + + return {renderCardText()}; +}; + +export default CardText; diff --git a/src/components/cardbuilder/Card/CardWrapper.tsx b/src/components/cardbuilder/Card/CardWrapper.tsx new file mode 100644 index 0000000000..01d6446a91 --- /dev/null +++ b/src/components/cardbuilder/Card/CardWrapper.tsx @@ -0,0 +1,30 @@ +import React, { FC } from 'react'; +import layoutManager from 'components/layoutManager'; +import type { DataAttributes } from 'types/dataAttributes'; + +interface CardWrapperProps { + className: string; + dataAttributes: DataAttributes; +} + +const CardWrapper: FC = ({ + className, + dataAttributes, + children +}) => { + if (layoutManager.tv) { + return ( + + ); + } else { + return ( +
+ {children} +
+ ); + } +}; + +export default CardWrapper; diff --git a/src/components/cardbuilder/Card/Cards.tsx b/src/components/cardbuilder/Card/Cards.tsx new file mode 100644 index 0000000000..fcf2454a57 --- /dev/null +++ b/src/components/cardbuilder/Card/Cards.tsx @@ -0,0 +1,32 @@ +import React, { FC } from 'react'; +import { setCardData } from '../cardBuilder'; +import Card from './Card'; +import type { ItemDto } from 'types/itemDto'; +import type { CardOptions } from 'types/cardOptions'; +import '../card.scss'; + +interface CardsProps { + items: ItemDto[]; + cardOptions: CardOptions; +} + +const Cards: FC = ({ + items = [], + cardOptions +}) => { + setCardData(items, cardOptions); + return ( + // eslint-disable-next-line react/jsx-no-useless-fragment + <> + {items?.map((item) => ( + + ))} + + ); +}; + +export default Cards; diff --git a/src/components/cardbuilder/Card/cardHelper.ts b/src/components/cardbuilder/Card/cardHelper.ts new file mode 100644 index 0000000000..b6f8c37ab1 --- /dev/null +++ b/src/components/cardbuilder/Card/cardHelper.ts @@ -0,0 +1,721 @@ +import { + BaseItemDto, + BaseItemKind, + BaseItemPerson, + ImageType +} from '@jellyfin/sdk/lib/generated-client'; +import { Api } from '@jellyfin/sdk'; +import escapeHTML from 'escape-html'; + +import { appRouter } from 'components/router/appRouter'; +import layoutManager from 'components/layoutManager'; +import itemHelper from 'components/itemHelper'; +import globalize from 'scripts/globalize'; +import datetime from 'scripts/datetime'; + +import { isUsingLiveTvNaming } from '../cardBuilderUtils'; + +import type { ItemDto, NullableNumber, NullableString } from 'types/itemDto'; +import type { CardOptions } from 'types/cardOptions'; +import type { DataAttributes } from 'types/dataAttributes'; +import { getDataAttributes } from 'utils/items'; + +export function getCardLogoUrl( + item: ItemDto, + api: Api | undefined, + cardOptions: CardOptions +) { + let imgType; + let imgTag; + let itemId; + const logoHeight = 40; + + if (cardOptions.showChannelLogo && item.ChannelPrimaryImageTag) { + imgType = ImageType.Primary; + imgTag = item.ChannelPrimaryImageTag; + itemId = item.ChannelId; + } else if (cardOptions.showLogo && item.ParentLogoImageTag) { + imgType = ImageType.Logo; + imgTag = item.ParentLogoImageTag; + itemId = item.ParentLogoItemId; + } + + if (!itemId) { + itemId = item.Id; + } + + if (api && imgTag && imgType && itemId) { + const response = api.getItemImageUrl(itemId, imgType, { + height: logoHeight, + tag: imgTag + }); + + return { + logoUrl: response + }; + } + + return { + logoUrl: undefined + }; +} + +interface TextAction { + url: string; + title: string; + dataAttributes: DataAttributes +} + +export interface TextLine { + title?: NullableString; + titleAction?: TextAction; +} + +export function getTextActionButton( + item: ItemDto, + text?: NullableString, + serverId?: NullableString +): TextLine { + if (!text) { + text = itemHelper.getDisplayName(item); + } + + text = escapeHTML(text); + + if (layoutManager.tv) { + return { + title: text + }; + } + + const url = appRouter.getRouteUrl(item); + + const dataAttributes = getDataAttributes( + { + action: 'link', + itemServerId: serverId ?? item.ServerId, + itemId: item.Id, + itemChannelId: item.ChannelId, + itemType: item.Type, + itemMediaType: item.MediaType, + itemCollectionType: item.CollectionType, + itemIsFolder: item.IsFolder + } + ); + + return { + titleAction: { + url, + title: text, + dataAttributes + } + }; +} + +export function getAirTimeText( + item: ItemDto, + showAirDateTime: boolean | undefined, + showAirEndTime: boolean | undefined +) { + let airTimeText = ''; + + if (item.StartDate) { + try { + let date = datetime.parseISO8601Date(item.StartDate); + + if (showAirDateTime) { + airTimeText + += datetime.toLocaleDateString(date, { + weekday: 'short', + month: 'short', + day: 'numeric' + }) + ' '; + } + + airTimeText += datetime.getDisplayTime(date); + + if (item.EndDate && showAirEndTime) { + date = datetime.parseISO8601Date(item.EndDate); + airTimeText += ' - ' + datetime.getDisplayTime(date); + } + } catch (e) { + console.error('error parsing date: ' + item.StartDate); + } + } + return airTimeText; +} + +function isGenreOrStudio(itemType: NullableString) { + return itemType === BaseItemKind.Genre || itemType === BaseItemKind.Studio; +} + +function isMusicGenreOrMusicArtist( + itemType: NullableString, + context: NullableString +) { + return itemType === BaseItemKind.MusicGenre || context === 'MusicArtist'; +} + +function getMovieCount(itemMovieCount: NullableNumber) { + if (itemMovieCount) { + return itemMovieCount === 1 ? + globalize.translate('ValueOneMovie') : + globalize.translate('ValueMovieCount', itemMovieCount); + } +} + +function getSeriesCount(itemSeriesCount: NullableNumber) { + if (itemSeriesCount) { + return itemSeriesCount === 1 ? + globalize.translate('ValueOneSeries') : + globalize.translate('ValueSeriesCount', itemSeriesCount); + } +} + +function getEpisodeCount(itemEpisodeCount: NullableNumber) { + if (itemEpisodeCount) { + return itemEpisodeCount === 1 ? + globalize.translate('ValueOneEpisode') : + globalize.translate('ValueEpisodeCount', itemEpisodeCount); + } +} + +function getAlbumCount(itemAlbumCount: NullableNumber) { + if (itemAlbumCount) { + return itemAlbumCount === 1 ? + globalize.translate('ValueOneAlbum') : + globalize.translate('ValueAlbumCount', itemAlbumCount); + } +} + +function getSongCount(itemSongCount: NullableNumber) { + if (itemSongCount) { + return itemSongCount === 1 ? + globalize.translate('ValueOneSong') : + globalize.translate('ValueSongCount', itemSongCount); + } +} + +function getMusicVideoCount(itemMusicVideoCount: NullableNumber) { + if (itemMusicVideoCount) { + return itemMusicVideoCount === 1 ? + globalize.translate('ValueOneMusicVideo') : + globalize.translate('ValueMusicVideoCount', itemMusicVideoCount); + } +} + +function getRecursiveItemCount(itemRecursiveItemCount: NullableNumber) { + return itemRecursiveItemCount === 1 ? + globalize.translate('ValueOneEpisode') : + globalize.translate('ValueEpisodeCount', itemRecursiveItemCount); +} + +function getParentTitle( + isOuterFooter: boolean, + serverId: NullableString, + item: ItemDto +) { + if (isOuterFooter && item.AlbumArtists && item.AlbumArtists.length) { + (item.AlbumArtists[0] as BaseItemDto).Type = BaseItemKind.MusicArtist; + (item.AlbumArtists[0] as BaseItemDto).IsFolder = true; + return getTextActionButton(item.AlbumArtists[0], null, serverId); + } else { + return { + title: isUsingLiveTvNaming(item.Type) ? + item.Name : + item.SeriesName + || item.Series + || item.Album + || item.AlbumArtist + || '' + }; + } +} + +function getRunTimeTicks(itemRunTimeTicks: NullableNumber) { + if (itemRunTimeTicks) { + let minutes = itemRunTimeTicks / 600000000; + + minutes = minutes || 1; + + return globalize.translate('ValueMinutes', Math.round(minutes)); + } else { + return globalize.translate('ValueMinutes', 0); + } +} + +export function getItemCounts(cardOptions: CardOptions, item: ItemDto) { + const counts: string[] = []; + + const addCount = (text: NullableString) => { + if (text) { + counts.push(text); + } + }; + + if (item.Type === BaseItemKind.Playlist) { + const runTimeTicksText = getRunTimeTicks(item.RunTimeTicks); + addCount(runTimeTicksText); + } else if (isGenreOrStudio(item.Type)) { + const movieCountText = getMovieCount(item.MovieCount); + addCount(movieCountText); + + const seriesCountText = getSeriesCount(item.SeriesCount); + addCount(seriesCountText); + + const episodeCountText = getEpisodeCount(item.EpisodeCount); + addCount(episodeCountText); + } else if (isMusicGenreOrMusicArtist(item.Type, cardOptions.context)) { + const albumCountText = getAlbumCount(item.AlbumCount); + addCount(albumCountText); + + const songCountText = getSongCount(item.SongCount); + addCount(songCountText); + + const musicVideoCountText = getMusicVideoCount(item.MusicVideoCount); + addCount(musicVideoCountText); + } else if (item.Type === BaseItemKind.Series) { + const recursiveItemCountText = getRecursiveItemCount( + item.RecursiveItemCount + ); + addCount(recursiveItemCountText); + } + + return counts.join(', '); +} + +export function shouldShowTitle( + showTitle: boolean | string | undefined, + itemType: NullableString +) { + return ( + Boolean(showTitle) + || itemType === BaseItemKind.PhotoAlbum + || itemType === BaseItemKind.Folder + ); +} + +export function shouldShowOtherText( + isOuterFooter: boolean, + overlayText: boolean | undefined +) { + return isOuterFooter ? !overlayText : overlayText; +} + +export function shouldShowParentTitleUnderneath( + itemType: NullableString +) { + return ( + itemType === BaseItemKind.MusicAlbum + || itemType === BaseItemKind.Audio + || itemType === BaseItemKind.MusicVideo + ); +} + +function shouldShowMediaTitle( + titleAdded: boolean, + showTitle: boolean, + forceName: boolean, + cardOptions: CardOptions, + textLines: TextLine[] +) { + let showMediaTitle = + (showTitle && !titleAdded) + || (cardOptions.showParentTitleOrTitle && !textLines.length); + if (!showMediaTitle && !titleAdded && (showTitle || forceName)) { + showMediaTitle = true; + } + return showMediaTitle; +} + +function shouldShowExtraType(itemExtraType: NullableString) { + return itemExtraType && itemExtraType !== 'Unknown'; +} + +function shouldShowSeriesYearOrYear( + showYear: string | boolean | undefined, + showSeriesYear: boolean | undefined +) { + return Boolean(showYear) || showSeriesYear; +} + +function shouldShowCurrentProgram( + showCurrentProgram: boolean | undefined, + itemType: NullableString +) { + return showCurrentProgram && itemType === BaseItemKind.TvChannel; +} + +function shouldShowCurrentProgramTime( + showCurrentProgramTime: boolean | undefined, + itemType: NullableString +) { + return showCurrentProgramTime && itemType === BaseItemKind.TvChannel; +} + +function shouldShowPersonRoleOrType( + showPersonRoleOrType: boolean | undefined, + item: ItemDto +) { + return showPersonRoleOrType && (item as BaseItemPerson).Role; +} + +function shouldShowParentTitle( + showParentTitle: boolean | undefined, + parentTitleUnderneath: boolean +) { + return showParentTitle && parentTitleUnderneath; +} + +function addOtherText( + cardOptions: CardOptions, + parentTitleUnderneath: boolean, + isOuterFooter: boolean, + item: ItemDto, + addTextLine: (val: TextLine) => void, + serverId: NullableString +) { + if ( + shouldShowParentTitle( + cardOptions.showParentTitle, + parentTitleUnderneath + ) + ) { + addTextLine(getParentTitle(isOuterFooter, serverId, item)); + } + + if (shouldShowExtraType(item.ExtraType)) { + addTextLine({ title: globalize.translate(item.ExtraType) }); + } + + if (cardOptions.showItemCounts) { + addTextLine({ title: getItemCounts(cardOptions, item) }); + } + + if (cardOptions.textLines) { + addTextLine({ title: getAdditionalLines(cardOptions.textLines, item) }); + } + + if (cardOptions.showSongCount) { + addTextLine({ title: getSongCount(item.SongCount) }); + } + + if (cardOptions.showPremiereDate) { + addTextLine({ title: getPremiereDate(item.PremiereDate) }); + } + + if ( + shouldShowSeriesYearOrYear( + cardOptions.showYear, + cardOptions.showSeriesYear + ) + ) { + addTextLine({ title: getProductionYear(item) }); + } + + if (cardOptions.showRuntime) { + addTextLine({ title: getRunTime(item.RunTimeTicks) }); + } + + if (cardOptions.showAirTime) { + addTextLine({ + title: getAirTimeText( + item, + cardOptions.showAirDateTime, + cardOptions.showAirEndTime + ) + }); + } + + if (cardOptions.showChannelName) { + addTextLine(getChannelName(item)); + } + + if (shouldShowCurrentProgram(cardOptions.showCurrentProgram, item.Type)) { + addTextLine({ title: getCurrentProgramName(item.CurrentProgram) }); + } + + if ( + shouldShowCurrentProgramTime( + cardOptions.showCurrentProgramTime, + item.Type + ) + ) { + addTextLine({ title: getCurrentProgramTime(item.CurrentProgram) }); + } + + if (cardOptions.showSeriesTimerTime) { + addTextLine({ title: getSeriesTimerTime(item) }); + } + + if (cardOptions.showSeriesTimerChannel) { + addTextLine({ title: getSeriesTimerChannel(item) }); + } + + if (shouldShowPersonRoleOrType(cardOptions.showCurrentProgramTime, item)) { + addTextLine({ + title: globalize.translate( + 'PersonRole', + (item as BaseItemPerson).Role + ) + }); + } +} + +function getSeriesTimerChannel(item: ItemDto) { + if (item.RecordAnyChannel) { + return globalize.translate('AllChannels'); + } else { + return item.ChannelName || '' || globalize.translate('OneChannel'); + } +} + +function getSeriesTimerTime(item: ItemDto) { + if (item.RecordAnyTime) { + return globalize.translate('Anytime'); + } else { + return datetime.getDisplayTime(item.StartDate); + } +} + +function getCurrentProgramTime(CurrentProgram: BaseItemDto | undefined) { + if (CurrentProgram) { + return getAirTimeText(CurrentProgram, false, true) || ''; + } else { + return ''; + } +} + +function getCurrentProgramName(CurrentProgram: BaseItemDto | undefined) { + if (CurrentProgram) { + return CurrentProgram.Name; + } else { + return ''; + } +} + +function getChannelName(item: ItemDto) { + if (item.ChannelId) { + return getTextActionButton( + { + Id: item.ChannelId, + ServerId: item.ServerId, + Name: item.ChannelName, + Type: BaseItemKind.TvChannel, + MediaType: item.MediaType, + IsFolder: false + }, + item.ChannelName + ); + } else { + return { title: item.ChannelName || '' || ' ' }; + } +} + +function getRunTime(itemRunTimeTicks: NullableNumber) { + if (itemRunTimeTicks) { + return datetime.getDisplayRunningTime(itemRunTimeTicks); + } else { + return ''; + } +} + +function getPremiereDate(PremiereDate: string | null | undefined) { + if (PremiereDate) { + try { + return datetime.toLocaleDateString( + datetime.parseISO8601Date(PremiereDate), + { weekday: 'long', month: 'long', day: 'numeric' } + ); + } catch (err) { + return ''; + } + } else { + return ''; + } +} + +function getAdditionalLines( + textLines: (item: ItemDto) => (string | undefined)[], + item: ItemDto +) { + const additionalLines = textLines(item); + for (const additionalLine of additionalLines) { + return additionalLine; + } +} + +function getProductionYear(item: ItemDto) { + const productionYear = + item.ProductionYear + && datetime.toLocaleString(item.ProductionYear, { + useGrouping: false + }); + if (item.Type === BaseItemKind.Series) { + if (item.Status === 'Continuing') { + return globalize.translate( + 'SeriesYearToPresent', + productionYear || '' + ); + } else if (item.EndDate && item.ProductionYear) { + const endYear = datetime.toLocaleString( + datetime.parseISO8601Date(item.EndDate).getFullYear(), + { useGrouping: false } + ); + return ( + productionYear + + (endYear === productionYear ? '' : ' - ' + endYear) + ); + } else { + return productionYear || ''; + } + } else { + return productionYear || ''; + } +} + +function getMediaTitle(cardOptions: CardOptions, item: ItemDto): TextLine { + const name = + cardOptions.showTitle === 'auto' + && !item.IsFolder + && item.MediaType === 'Photo' ? + '' : + itemHelper.getDisplayName(item, { + includeParentInfo: cardOptions.includeParentInfoInTitle + }); + + return getTextActionButton({ + Id: item.Id, + ServerId: item.ServerId, + Name: name, + Type: item.Type, + CollectionType: item.CollectionType, + IsFolder: item.IsFolder + }); +} + +function getParentTitleOrTitle( + isOuterFooter: boolean, + item: ItemDto, + setTitleAdded: (val: boolean) => void, + showTitle: boolean +): TextLine { + if ( + isOuterFooter + && item.Type === BaseItemKind.Episode + && item.SeriesName + ) { + if (item.SeriesId) { + return getTextActionButton({ + Id: item.SeriesId, + ServerId: item.ServerId, + Name: item.SeriesName, + Type: BaseItemKind.Series, + IsFolder: true + }); + } else { + return { title: item.SeriesName }; + } + } else if (isUsingLiveTvNaming(item.Type)) { + if (!item.EpisodeTitle && !item.IndexNumber) { + setTitleAdded(true); + } + return { title: item.Name }; + } else { + const parentTitle = + item.SeriesName + || item.Series + || item.Album + || item.AlbumArtist + || ''; + + if (parentTitle || showTitle) { + return { title: parentTitle }; + } + + return { title: '' }; + } +} + +interface TextLinesOpts { + isOuterFooter: boolean; + overlayText: boolean | undefined; + forceName: boolean; + item: ItemDto; + cardOptions: CardOptions; + imgUrl: string | undefined; +} + +export function getCardTextLines({ + isOuterFooter, + overlayText, + forceName, + item, + cardOptions, + imgUrl +}: TextLinesOpts) { + const showTitle = shouldShowTitle(cardOptions.showTitle, item.Type); + const showOtherText = shouldShowOtherText(isOuterFooter, overlayText); + const serverId = item.ServerId || cardOptions.serverId; + let textLines: TextLine[] = []; + const parentTitleUnderneath = shouldShowParentTitleUnderneath(item.Type); + + let titleAdded = false; + const addTextLine = (val: TextLine) => { + textLines.push(val); + }; + + const setTitleAdded = (val: boolean) => { + titleAdded = val; + }; + + if ( + showOtherText + && (cardOptions.showParentTitle || cardOptions.showParentTitleOrTitle) + && !parentTitleUnderneath + ) { + addTextLine( + getParentTitleOrTitle(isOuterFooter, item, setTitleAdded, showTitle) + ); + } + + const showMediaTitle = shouldShowMediaTitle( + titleAdded, + showTitle, + forceName, + cardOptions, + textLines + ); + + if (showMediaTitle) { + addTextLine(getMediaTitle(cardOptions, item)); + } + + if (showOtherText) { + addOtherText( + cardOptions, + parentTitleUnderneath, + isOuterFooter, + item, + addTextLine, + serverId + ); + } + + if ( + (showTitle || !imgUrl) + && forceName + && overlayText + && textLines.length === 1 + ) { + textLines = []; + } + + if (overlayText && showTitle) { + textLines = [{ title: item.Name }]; + } + + return { + textLines + }; +} diff --git a/src/components/cardbuilder/Card/useCard.ts b/src/components/cardbuilder/Card/useCard.ts new file mode 100644 index 0000000000..5751471801 --- /dev/null +++ b/src/components/cardbuilder/Card/useCard.ts @@ -0,0 +1,121 @@ +import classNames from 'classnames'; +import useCardImageUrl from './useCardImageUrl'; +import { + resolveAction, + resolveMixedShapeByAspectRatio +} from '../cardBuilderUtils'; +import { getDataAttributes } from 'utils/items'; +import layoutManager from 'components/layoutManager'; + +import type { ItemDto } from 'types/itemDto'; +import type { CardOptions } from 'types/cardOptions'; + +interface UseCardProps { + item: ItemDto; + cardOptions: CardOptions; +} + +function useCard({ item, cardOptions }: UseCardProps) { + const action = resolveAction({ + defaultAction: cardOptions.action ?? 'link', + isFolder: item.IsFolder ?? false, + isPhoto: item.MediaType === 'Photo' + }); + + let shape = cardOptions.shape; + + if (shape === 'mixed') { + shape = resolveMixedShapeByAspectRatio(item.PrimaryImageAspectRatio); + } + + const imgInfo = useCardImageUrl({ + item: item.ProgramInfo ?? item, + cardOptions, + shape + }); + const imgUrl = imgInfo.imgUrl; + const blurhash = imgInfo.blurhash; + const forceName = imgInfo.forceName; + const coveredImage = cardOptions.coverImage ?? imgInfo.coverImage; + const overlayText = cardOptions.overlayText; + + const nameWithPrefix = item.SortName ?? item.Name ?? ''; + let prefix = nameWithPrefix.substring( + 0, + Math.min(3, nameWithPrefix.length) + ); + + if (prefix) { + prefix = prefix.toUpperCase(); + } + + const dataAttributes = getDataAttributes( + { + action, + itemServerId: item.ServerId ?? cardOptions.serverId, + context: cardOptions.context, + parentId: cardOptions.parentId, + collectionId: cardOptions.collectionId, + playlistId: cardOptions.playlistId, + itemId: item.Id, + itemTimerId: item.TimerId, + itemSeriesTimerId: item.SeriesTimerId, + itemChannelId: item.ChannelId, + itemType: item.Type, + itemMediaType: item.MediaType, + itemCollectionType: item.CollectionType, + itemIsFolder: item.IsFolder, + itemPath: item.Path, + itemStartDate: item.StartDate, + itemEndDate: item.EndDate, + itemUserData: item.UserData, + prefix + } + ); + + const cardClass = classNames( + 'card', + { [`${shape}Card`]: shape }, + cardOptions.cardCssClass, + cardOptions.cardClass, + { 'card-hoverable': layoutManager.desktop }, + { groupedCard: cardOptions.showChildCountIndicator && item.ChildCount }, + { + 'card-withuserdata': + item.Type !== 'MusicAlbum' + && item.Type !== 'MusicArtist' + && item.Type !== 'Audio' + }, + { itemAction: layoutManager.tv } + ); + + const cardBoxClass = classNames( + 'cardBox', + { visualCardBox: cardOptions.cardLayout }, + { 'cardBox-bottompadded': !cardOptions.cardLayout } + ); + + const getCardWrapperProps = () => ({ + className: cardClass, + dataAttributes + }); + + const getCardBoxProps = () => ({ + item, + cardOptions, + className: cardBoxClass, + shape, + imgUrl, + blurhash, + forceName, + coveredImage, + overlayText + }); + + return { + getCardWrapperProps, + getCardBoxProps + }; +} + +export default useCard; diff --git a/src/components/cardbuilder/Card/useCardImageUrl.ts b/src/components/cardbuilder/Card/useCardImageUrl.ts new file mode 100644 index 0000000000..ef1cb6ab61 --- /dev/null +++ b/src/components/cardbuilder/Card/useCardImageUrl.ts @@ -0,0 +1,301 @@ +import { BaseItemKind, ImageType } from '@jellyfin/sdk/lib/generated-client'; +import { useApi } from 'hooks/useApi'; +import { getDesiredAspect } from '../cardBuilderUtils'; + +import type { ItemDto } from 'types/itemDto'; +import type { CardOptions } from 'types/cardOptions'; + +type ShapeType = string | null | undefined; + +function getPreferThumbInfo(item: ItemDto, cardOptions: CardOptions) { + let imgType; + let itemId; + let imgTag; + let forceName = false; + + if (item.ImageTags?.Thumb) { + imgType = ImageType.Thumb; + imgTag = item.ImageTags.Thumb; + itemId = item.Id; + } else if (item.SeriesThumbImageTag && cardOptions.inheritThumb !== false) { + imgType = ImageType.Thumb; + imgTag = item.SeriesThumbImageTag; + itemId = item.SeriesId; + } else if ( + item.ParentThumbItemId + && cardOptions.inheritThumb !== false + && item.MediaType !== 'Photo' + ) { + imgType = ImageType.Thumb; + imgTag = item.ParentThumbImageTag; + itemId = item.ParentThumbItemId; + } else if (item.BackdropImageTags?.length) { + imgType = ImageType.Backdrop; + imgTag = item.BackdropImageTags[0]; + itemId = item.Id; + forceName = true; + } else if ( + item.ParentBackdropImageTags?.length + && cardOptions.inheritThumb !== false + && item.Type === BaseItemKind.Episode + ) { + imgType = ImageType.Backdrop; + imgTag = item.ParentBackdropImageTags[0]; + itemId = item.ParentBackdropItemId; + } + return { + itemId: itemId, + imgTag: imgTag, + imgType: imgType, + forceName: forceName + }; +} + +function getPreferLogoInfo(item: ItemDto) { + let imgType; + let itemId; + let imgTag; + + if (item.ImageTags?.Logo) { + imgType = ImageType.Logo; + imgTag = item.ImageTags.Logo; + itemId = item.Id; + } else if (item.ParentLogoImageTag && item.ParentLogoItemId) { + imgType = ImageType.Logo; + imgTag = item.ParentLogoImageTag; + itemId = item.ParentLogoItemId; + } + return { + itemId: itemId, + imgTag: imgTag, + imgType: imgType + }; +} + +function getCalculatedHeight( + width: number | undefined, + primaryImageAspectRatio: number | null | undefined +) { + if (width && primaryImageAspectRatio) { + return Math.round(width / primaryImageAspectRatio); + } +} + +function isForceName(cardOptions: CardOptions) { + return !!(cardOptions.preferThumb && cardOptions.showTitle !== false); +} + +function isCoverImage( + primaryImageAspectRatio: number | null | undefined, + uiAspect: number | null +) { + if (primaryImageAspectRatio && uiAspect) { + return Math.abs(primaryImageAspectRatio - uiAspect) / uiAspect <= 0.2; + } + + return false; +} + +function shouldShowPreferBanner( + imageTagsBanner: string | undefined, + cardOptions: CardOptions, + shape: ShapeType +): boolean { + return ( + (cardOptions.preferBanner || shape === 'banner') + && Boolean(imageTagsBanner) + ); +} + +function shouldShowPreferDisc( + imageTagsDisc: string | undefined, + cardOptions: CardOptions +): boolean { + return cardOptions.preferDisc === true && Boolean(imageTagsDisc); +} + +function shouldShowImageTagsPrimary(item: ItemDto): boolean { + return ( + Boolean(item.ImageTags?.Primary) && (item.Type !== BaseItemKind.Episode || item.ChildCount !== 0) + ); +} + +function shouldShowImageTagsThumb(item: ItemDto): boolean { + return item.Type === BaseItemKind.Season && Boolean(item.ImageTags?.Thumb); +} + +function shouldShowSeriesThumbImageTag( + item: ItemDto, + cardOptions: CardOptions +): boolean { + return ( + Boolean(item.SeriesThumbImageTag) && cardOptions.inheritThumb !== false + ); +} + +function shouldShowParentThumbImageTag( + item: ItemDto, + cardOptions: CardOptions +): boolean { + return ( + Boolean(item.ParentThumbItemId) && cardOptions.inheritThumb !== false + ); +} + +function shouldShowParentBackdropImageTags(item: ItemDto): boolean { + return Boolean(item.AlbumId) && Boolean(item.AlbumPrimaryImageTag); +} + +function shouldShowPreferThumb(type: string | null | undefined, cardOptions: CardOptions): boolean { + return Boolean(cardOptions.preferThumb) && !(type === BaseItemKind.Program || type === BaseItemKind.Episode); +} + +function getCardImageInfo( + item: ItemDto, + cardOptions: CardOptions, + shape: ShapeType +) { + const width = cardOptions.width; + let height; + const primaryImageAspectRatio = item.PrimaryImageAspectRatio; + let forceName = false; + let imgTag; + let coverImage = false; + const uiAspect = getDesiredAspect(shape); + let imgType; + let itemId; + + if (shouldShowPreferThumb(item.Type, cardOptions)) { + const preferThumbInfo = getPreferThumbInfo(item, cardOptions); + imgType = preferThumbInfo.imgType; + imgTag = preferThumbInfo.imgTag; + itemId = preferThumbInfo.itemId; + forceName = preferThumbInfo.forceName; + } else if (shouldShowPreferBanner(item.ImageTags?.Banner, cardOptions, shape)) { + imgType = ImageType.Banner; + imgTag = item.ImageTags?.Banner; + itemId = item.Id; + } else if (shouldShowPreferDisc(item.ImageTags?.Disc, cardOptions)) { + imgType = ImageType.Disc; + imgTag = item.ImageTags?.Disc; + itemId = item.Id; + } else if (cardOptions.preferLogo) { + const preferLogoInfo = getPreferLogoInfo(item); + imgType = preferLogoInfo.imgType; + imgTag = preferLogoInfo.imgType; + itemId = preferLogoInfo.itemId; + } else if (shouldShowImageTagsPrimary(item)) { + imgType = ImageType.Primary; + imgTag = item.ImageTags?.Primary; + itemId = item.Id; + height = getCalculatedHeight(width, primaryImageAspectRatio); + forceName = isForceName(cardOptions); + coverImage = isCoverImage(primaryImageAspectRatio, uiAspect); + } else if (item.SeriesPrimaryImageTag) { + imgType = ImageType.Primary; + imgTag = item.SeriesPrimaryImageTag; + itemId = item.SeriesId; + } else if (item.PrimaryImageTag) { + imgType = ImageType.Primary; + imgTag = item.PrimaryImageTag; + itemId = item.PrimaryImageItemId; + height = getCalculatedHeight(width, primaryImageAspectRatio); + forceName = isForceName(cardOptions); + coverImage = isCoverImage(primaryImageAspectRatio, uiAspect); + } else if (item.ParentPrimaryImageTag) { + imgType = ImageType.Primary; + imgTag = item.ParentPrimaryImageTag; + itemId = item.ParentPrimaryImageItemId; + } else if (shouldShowParentBackdropImageTags(item)) { + imgType = ImageType.Primary; + imgTag = item.AlbumPrimaryImageTag; + itemId = item.AlbumId; + height = getCalculatedHeight(width, primaryImageAspectRatio); + forceName = isForceName(cardOptions); + coverImage = isCoverImage(primaryImageAspectRatio, uiAspect); + } else if (shouldShowImageTagsThumb(item)) { + imgType = ImageType.Thumb; + imgTag = item.ImageTags?.Thumb; + itemId = item.Id; + } else if (item.BackdropImageTags?.length) { + imgType = ImageType.Backdrop; + imgTag = item.BackdropImageTags[0]; + itemId = item.Id; + /*} else if (item.ImageTags?.Thumb) { + imgType = ImageType.Thumb; + imgTag = item.ImageTags.Thumb; + itemId = item.Id;*/ + } else if (shouldShowSeriesThumbImageTag(item, cardOptions)) { + imgType = ImageType.Thumb; + imgTag = item.SeriesThumbImageTag; + itemId = item.SeriesId; + } else if (shouldShowParentThumbImageTag(item, cardOptions)) { + imgType = ImageType.Thumb; + imgTag = item.ParentThumbImageTag; + itemId = item.ParentThumbItemId; + } else if ( + item.ParentBackdropImageTags?.length + && cardOptions.inheritThumb !== false + ) { + imgType = ImageType.Backdrop; + imgTag = item.ParentBackdropImageTags[0]; + itemId = item.ParentBackdropItemId; + } + + return { + imgType, + imgTag, + itemId, + width, + height, + forceName, + coverImage + }; +} + +interface UseCardImageUrlProps { + item: ItemDto; + cardOptions: CardOptions; + shape: ShapeType; +} + +function useCardImageUrl({ item, cardOptions, shape }: UseCardImageUrlProps) { + const { api } = useApi(); + const imgInfo = getCardImageInfo(item, cardOptions, shape); + + let width = imgInfo.width; + let height = imgInfo.height; + const imgTag = imgInfo.imgTag; + const imgType = imgInfo.imgType; + const itemId = imgInfo.itemId; + const ratio = window.devicePixelRatio || 1; + let imgUrl; + let blurhash; + + if (api && imgTag && imgType && itemId) { + if (width) { + width = Math.round(width * ratio); + } + + if (height) { + height = Math.round(height * ratio); + } + imgUrl = api?.getItemImageUrl(itemId, imgType, { + quality: 96, + fillWidth: width, + fillHeight: height, + tag: imgTag + }); + + blurhash = item?.ImageBlurHashes?.[imgType]?.[imgTag]; + } + + return { + imgUrl: imgUrl, + blurhash: blurhash, + forceName: imgInfo.forceName, + coverImage: imgInfo.coverImage + }; +} + +export default useCardImageUrl; diff --git a/src/components/cardbuilder/Card/useCardText.tsx b/src/components/cardbuilder/Card/useCardText.tsx new file mode 100644 index 0000000000..904777fe85 --- /dev/null +++ b/src/components/cardbuilder/Card/useCardText.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import classNames from 'classnames'; +import layoutManager from 'components/layoutManager'; +import CardText from './CardText'; +import { getCardTextLines } from './cardHelper'; + +import type { ItemDto } from 'types/itemDto'; +import type { CardOptions } from 'types/cardOptions'; + +const enableRightMargin = ( + isOuterFooter: boolean, + cardLayout: boolean | null | undefined, + centerText: boolean | undefined, + cardFooterAside: string | undefined +) => { + return ( + isOuterFooter + && cardLayout + && !centerText + && cardFooterAside !== 'none' + && layoutManager.mobile + ); +}; + +interface UseCardTextProps { + item: ItemDto; + cardOptions: CardOptions; + forceName: boolean; + overlayText: boolean | undefined; + imgUrl: string | undefined; + isOuterFooter: boolean; + cssClass: string; + forceLines: boolean; + maxLines: number | undefined; +} + +function useCardText({ + item, + cardOptions, + forceName, + imgUrl, + overlayText, + isOuterFooter, + cssClass, + forceLines, + maxLines +}: UseCardTextProps) { + const { textLines } = getCardTextLines({ + isOuterFooter, + overlayText, + forceName, + item, + cardOptions, + imgUrl + }); + + const addRightMargin = enableRightMargin( + isOuterFooter, + cardOptions.cardLayout, + cardOptions.centerText, + cardOptions.cardFooterAside + ); + + const renderCardTextLines = () => { + const components: React.ReactNode[] = []; + let valid = 0; + for (const textLine of textLines) { + const currentCssClass = classNames( + cssClass, + { + 'cardText-secondary': + valid > 0 && isOuterFooter + }, + { 'cardText-first': valid === 0 && isOuterFooter }, + { 'cardText-rightmargin': addRightMargin } + ); + + if (textLine) { + components.push( + + ); + + valid++; + if (maxLines && valid >= maxLines) { + break; + } + } + } + + if (forceLines) { + const linesLength = maxLines ?? Math.min(textLines.length, maxLines ?? textLines.length); + while (valid < linesLength) { + components.push( + +   + + ); + valid++; + } + } + + return components; + }; + + const cardTextLines = renderCardTextLines(); + + return { + cardTextLines + }; +} + +export default useCardText; diff --git a/src/components/cardbuilder/card.scss b/src/components/cardbuilder/card.scss index 28f55abe2d..731ef32a80 100644 --- a/src/components/cardbuilder/card.scss +++ b/src/components/cardbuilder/card.scss @@ -378,7 +378,7 @@ button::-moz-focus-inner { margin-right: 2em; } -.cardDefaultText { +.cardImageContainer > .cardDefaultText { white-space: normal; text-align: center; font-size: 2em; @@ -408,6 +408,7 @@ button::-moz-focus-inner { display: flex; align-items: center; contain: layout style; + z-index: 1; [dir="ltr"] & { right: 0.225em; @@ -852,7 +853,7 @@ button::-moz-focus-inner { opacity: 1; } -.cardOverlayFab-primary { +.cardOverlayContainer > .cardOverlayFab-primary { background-color: rgba(0, 0, 0, 0.7); font-size: 130%; padding: 0; @@ -865,7 +866,7 @@ button::-moz-focus-inner { left: 50%; } -.cardOverlayFab-primary:hover { +.cardOverlayContainer > .cardOverlayFab-primary:hover { transform: scale(1.4, 1.4); transition: 0.2s; } diff --git a/src/components/cardbuilder/cardBuilder.js b/src/components/cardbuilder/cardBuilder.js index 811bac1851..acbe05c270 100644 --- a/src/components/cardbuilder/cardBuilder.js +++ b/src/components/cardbuilder/cardBuilder.js @@ -73,7 +73,7 @@ function getImageWidth(shape, screenWidth, isOrientationLandscape) { * @param {Object} items - A set of items. * @param {Object} options - Options for handling the items. */ -function setCardData(items, options) { +export function setCardData(items, options) { options.shape = options.shape || 'auto'; const primaryImageAspectRatio = imageLoader.getPrimaryImageAspectRatio(items); diff --git a/src/components/cardbuilder/cardBuilderUtils.ts b/src/components/cardbuilder/cardBuilderUtils.ts index d7215b190c..3ac471ccb1 100644 --- a/src/components/cardbuilder/cardBuilderUtils.ts +++ b/src/components/cardbuilder/cardBuilderUtils.ts @@ -10,10 +10,10 @@ const ASPECT_RATIOS = { /** * Determines if the item is live TV. - * @param {string} itemType - Item type to use for the check. + * @param {string | null | undefined} itemType - Item type to use for the check. * @returns {boolean} Flag showing if the item is live TV. */ -export const isUsingLiveTvNaming = (itemType: string): boolean => itemType === 'Program' || itemType === 'Timer' || itemType === 'Recording'; +export const isUsingLiveTvNaming = (itemType: string | null | undefined): boolean => itemType === 'Program' || itemType === 'Timer' || itemType === 'Recording'; /** * Resolves Card action to display diff --git a/src/types/cardOptions.ts b/src/types/cardOptions.ts index cb4f49c0af..3745d804a4 100644 --- a/src/types/cardOptions.ts +++ b/src/types/cardOptions.ts @@ -1,10 +1,12 @@ -import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; +import type { BaseItemDtoImageBlurHashes, BaseItemKind, ImageType, UserItemDataDto } from '@jellyfin/sdk/lib/generated-client'; import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type'; +import type { ItemDto, NullableString } from './itemDto'; +import { ParentId } from './library'; export interface CardOptions { itemsContainer?: HTMLElement | null; parentContainer?: HTMLElement | null; - items?: BaseItemDto[] | null; + items?: ItemDto[] | null; allowBottomPadding?: boolean; centerText?: boolean; coverImage?: boolean; @@ -12,13 +14,15 @@ export interface CardOptions { overlayMoreButton?: boolean; overlayPlayButton?: boolean; overlayText?: boolean; + imageBlurhashes?: BaseItemDtoImageBlurHashes | null; + preferBanner?: boolean; preferThumb?: boolean | string | null; preferDisc?: boolean; preferLogo?: boolean; scalable?: boolean; shape?: string | null; lazy?: boolean; - cardLayout?: boolean | string; + cardLayout?: boolean | null; showParentTitle?: boolean; showParentTitleOrTitle?: boolean; showAirTime?: boolean; @@ -37,7 +41,7 @@ export interface CardOptions { action?: string | null; defaultShape?: string; indexBy?: string; - parentId?: string | null; + parentId?: ParentId; showMenu?: boolean; cardCssClass?: string | null; cardClass?: string | null; @@ -61,9 +65,10 @@ export interface CardOptions { showSeriesTimerChannel?: boolean; showSongCount?: boolean; width?: number; + widths?: any; showChannelLogo?: boolean; showLogo?: boolean; - serverId?: string; + serverId?: NullableString; collectionId?: string | null; playlistId?: string | null; defaultCardImageIcon?: string; @@ -72,4 +77,46 @@ export interface CardOptions { showGroupCount?: boolean; containerClass?: string; noItemsMessage?: string; + showIndex?: boolean; + index?: string; + showIndexNumber?: boolean; + enableContentWrapper?: boolean; + enableOverview?: boolean; + enablePlayedButton?: boolean; + infoButton?: boolean; + imageSize?: string; + enableSideMediaInfo?: boolean; + imagePlayButton?: boolean; + border?: boolean; + highlight?: boolean; + smallIcon?: boolean; + artist?: boolean; + addToListButton?: boolean; + enableUserDataButtons?: boolean; + enableRatingButton?: boolean; + image?: boolean; + imageSource?: string; + showProgramDateTime?: boolean; + showChannel?: boolean; + mediaInfo?: boolean; + moreButton?: boolean; + recordButton?: boolean; + dragHandle?: boolean; + showProgramTime?: boolean; + parentTitleWithTitle?: boolean; + showIndexNumberLeft?: boolean; + sortBy?: string; + textLines?: (item: ItemDto) => (BaseItemKind | string | undefined)[]; + userData?: UserItemDataDto; + rightButtons?: { + icon: string; + title: string; + id: string; + }[]; + uiAspect?: number | null; + primaryImageAspectRatio?: number | null; + rows?: number | null; + imageType?: ImageType; + queryKey?: string[] } +