diff --git a/src/components/mediainfo/CaptionMediaInfo.tsx b/src/components/mediainfo/CaptionMediaInfo.tsx new file mode 100644 index 0000000000..58a6f49af5 --- /dev/null +++ b/src/components/mediainfo/CaptionMediaInfo.tsx @@ -0,0 +1,25 @@ +import React, { FC } from 'react'; +import classNames from 'classnames'; +import ClosedCaptionIcon from '@mui/icons-material/ClosedCaption'; +import Box from '@mui/material/Box'; + +interface CaptionMediaInfoProps { + className?: string; +} + +const CaptionMediaInfo: FC = ({ className }) => { + const cssClass = classNames( + 'mediaInfoItem', + 'mediaInfoText', + 'closedCaptionMediaInfoText', + className + ); + + return ( + + + + ); +}; + +export default CaptionMediaInfo; diff --git a/src/components/mediainfo/CriticRatingMediaInfo.tsx b/src/components/mediainfo/CriticRatingMediaInfo.tsx new file mode 100644 index 0000000000..080aef78fa --- /dev/null +++ b/src/components/mediainfo/CriticRatingMediaInfo.tsx @@ -0,0 +1,25 @@ +import React, { FC } from 'react'; +import classNames from 'classnames'; +import Box from '@mui/material/Box'; + +interface CriticRatingMediaInfoProps { + className?: string; + criticRating: number; +} + +const CriticRatingMediaInfo: FC = ({ + className, + criticRating +}) => { + const cssClass = classNames( + 'mediaInfoCriticRating', + 'mediaInfoItem', + criticRating >= 60 ? + 'mediaInfoCriticRatingFresh' : + 'mediaInfoCriticRatingRotten', + className + ); + return {criticRating}; +}; + +export default CriticRatingMediaInfo; diff --git a/src/components/mediainfo/EndsAt.tsx b/src/components/mediainfo/EndsAt.tsx new file mode 100644 index 0000000000..693f949f8b --- /dev/null +++ b/src/components/mediainfo/EndsAt.tsx @@ -0,0 +1,31 @@ +import React, { FC } from 'react'; +import classNames from 'classnames'; +import Box from '@mui/material/Box'; +import datetime from 'scripts/datetime'; +import globalize from 'scripts/globalize'; + +interface EndsAtProps { + className?: string; + runTimeTicks: number +} + +const EndsAt: FC = ({ runTimeTicks, className }) => { + const cssClass = classNames( + 'mediaInfoItem', + 'mediaInfoText', + 'endsAt', + className + ); + + const endTime = new Date().getTime() + (runTimeTicks / 10000); + const endDate = new Date(endTime); + const displayTime = datetime.getDisplayTime(endDate); + + return ( + + {globalize.translate('EndsAtValue', displayTime)} + + ); +}; + +export default EndsAt; diff --git a/src/components/mediainfo/MediaInfoItem.tsx b/src/components/mediainfo/MediaInfoItem.tsx new file mode 100644 index 0000000000..b832e02e45 --- /dev/null +++ b/src/components/mediainfo/MediaInfoItem.tsx @@ -0,0 +1,27 @@ +import React, { FC } from 'react'; +import Box from '@mui/material/Box'; +import classNames from 'classnames'; +import type { MiscInfo } from 'types/mediaInfoItem'; + +interface MediaInfoItemProps { + className?: string; + miscInfo?: MiscInfo ; + +} + +const MediaInfoItem: FC = ({ className, miscInfo }) => { + const cssClass = classNames( + 'mediaInfoItem', + 'mediaInfoText', + className, + miscInfo?.cssClass + ); + + return ( + + {miscInfo?.text} + + ); +}; + +export default MediaInfoItem; diff --git a/src/components/mediainfo/PrimaryMediaInfo.tsx b/src/components/mediainfo/PrimaryMediaInfo.tsx new file mode 100644 index 0000000000..90b640054a --- /dev/null +++ b/src/components/mediainfo/PrimaryMediaInfo.tsx @@ -0,0 +1,103 @@ +import React, { FC } from 'react'; +import classNames from 'classnames'; +import Box from '@mui/material/Box'; +import usePrimaryMediaInfo from './usePrimaryMediaInfo'; + +import MediaInfoItem from './MediaInfoItem'; +import StarIcons from './StarIcons'; +import CaptionMediaInfo from './CaptionMediaInfo'; +import CriticRatingMediaInfo from './CriticRatingMediaInfo'; +import EndsAt from './EndsAt'; +import type { ItemDto } from 'types/itemDto'; +import type { MiscInfo } from 'types/mediaInfoItem'; + +interface PrimaryMediaInfoProps { + className?: string; + item: ItemDto; + isYearEnabled?: boolean; + isContainerEnabled?: boolean; + isEpisodeTitleEnabled?: boolean; + isCriticRatingEnabled?: boolean; + isEndsAtEnabled?: boolean; + isOriginalAirDateEnabled?: boolean; + isRuntimeEnabled?: boolean; + isProgramIndicatorEnabled?: boolean; + isEpisodeTitleIndexNumberEnabled?: boolean; + isOfficialRatingEnabled?: boolean; + isStarRatingEnabled?: boolean; + isCaptionIndicatorEnabled?: boolean; + isMissingIndicatorEnabled?: boolean; + getMissingIndicator: () => React.JSX.Element | null +} + +const PrimaryMediaInfo: FC = ({ + className, + item, + isYearEnabled = false, + isContainerEnabled = false, + isEpisodeTitleEnabled = false, + isCriticRatingEnabled = false, + isEndsAtEnabled = false, + isOriginalAirDateEnabled = false, + isRuntimeEnabled = false, + isProgramIndicatorEnabled = false, + isEpisodeTitleIndexNumberEnabled = false, + isOfficialRatingEnabled = false, + isStarRatingEnabled = false, + isCaptionIndicatorEnabled = false, + isMissingIndicatorEnabled = false, + getMissingIndicator +}) => { + const miscInfo = usePrimaryMediaInfo({ + item, + isYearEnabled, + isContainerEnabled, + isEpisodeTitleEnabled, + isOriginalAirDateEnabled, + isRuntimeEnabled, + isProgramIndicatorEnabled, + isEpisodeTitleIndexNumberEnabled, + isOfficialRatingEnabled + }); + const { + StartDate, + HasSubtitles, + MediaType, + RunTimeTicks, + CommunityRating, + CriticRating + } = item; + + const cssClass = classNames(className); + + const renderMediaInfo = (info: MiscInfo | undefined, index: number) => ( + + ); + + return ( + + {miscInfo.map((info, index) => renderMediaInfo(info, index))} + + {isStarRatingEnabled && CommunityRating && ( + + )} + + {HasSubtitles && isCaptionIndicatorEnabled && } + + {CriticRating && isCriticRatingEnabled && ( + + )} + + {isEndsAtEnabled + && MediaType === 'Video' + && RunTimeTicks + && !StartDate && } + + {isMissingIndicatorEnabled && ( + getMissingIndicator() + )} + + ); +}; + +export default PrimaryMediaInfo; diff --git a/src/components/mediainfo/StarIcons.tsx b/src/components/mediainfo/StarIcons.tsx new file mode 100644 index 0000000000..d253a2db3d --- /dev/null +++ b/src/components/mediainfo/StarIcons.tsx @@ -0,0 +1,29 @@ +import React, { FC } from 'react'; +import classNames from 'classnames'; +import StarIcon from '@mui/icons-material/Star'; +import Box from '@mui/material/Box'; + +interface StarIconsProps { + className?: string; + communityRating: number; +} + +const StarIcons: FC = ({ className, communityRating }) => { + const cssClass = classNames( + 'mediaInfoItem', + 'mediaInfoText', + 'starRatingContainer', + className + ); + + return ( + + + {communityRating.toFixed(1)} + + ); +}; + +export default StarIcons; diff --git a/src/components/mediainfo/usePrimaryMediaInfo.tsx b/src/components/mediainfo/usePrimaryMediaInfo.tsx new file mode 100644 index 0000000000..480f31dbbc --- /dev/null +++ b/src/components/mediainfo/usePrimaryMediaInfo.tsx @@ -0,0 +1,522 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client'; +import * as userSettings from 'scripts/settings/userSettings'; +import datetime from 'scripts/datetime'; +import globalize from 'scripts/globalize'; +import itemHelper from '../itemHelper'; +import type { ItemDto, NullableNumber, NullableString } from 'types/itemDto'; +import type { MiscInfo } from 'types/mediaInfoItem'; + +function shouldShowFolderRuntime( + itemType: NullableString, + itemMediaType: NullableString +): boolean { + return ( + itemType === BaseItemKind.MusicAlbum + || itemMediaType === 'MusicArtist' + || itemType === BaseItemKind.Playlist + || itemMediaType === 'Playlist' + || itemMediaType === 'MusicGenre' + ); +} + +function addTrackCountOrItemCount( + showFolderRuntime: boolean, + itemSongCount: NullableNumber, + itemChildCount: NullableNumber, + itemRunTimeTicks: NullableNumber, + itemType: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + if (showFolderRuntime) { + const count = itemSongCount ?? itemChildCount; + if (count) { + addMiscInfo({ text: globalize.translate('TrackCount', count) }); + } + + if (itemRunTimeTicks) { + addMiscInfo({ text: datetime.getDisplayDuration(itemRunTimeTicks) }); + } + } else if (itemType === BaseItemKind.PhotoAlbum || itemType === BaseItemKind.BoxSet) { + const count = itemChildCount; + if (count) { + addMiscInfo({ text: globalize.translate('ItemCount', count) }); + } + } +} + +function addOriginalAirDateInfo( + itemType: NullableString, + itemMediaType: NullableString, + isOriginalAirDateEnabled: boolean, + itemPremiereDate: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + if ( + itemPremiereDate + && (itemType === BaseItemKind.Episode || itemMediaType === 'Photo') + && isOriginalAirDateEnabled + ) { + try { + //don't modify date to locale if episode. Only Dates (not times) are stored, or editable in the edit metadata dialog + const date = datetime.parseISO8601Date( + itemPremiereDate, + itemType !== BaseItemKind.Episode + ); + addMiscInfo({ text: datetime.toLocaleDateString(date) }); + } catch (e) { + console.error('error parsing date:', itemPremiereDate); + } + } +} + +function addSeriesTimerInfo( + itemType: NullableString, + itemRecordAnyTime: boolean | undefined, + itemStartDate: NullableString, + itemRecordAnyChannel: boolean | undefined, + itemChannelName: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + if (itemType === 'SeriesTimer') { + if (itemRecordAnyTime) { + addMiscInfo({ text: globalize.translate('Anytime') }); + } else { + addMiscInfo({ text: datetime.getDisplayTime(itemStartDate) }); + } + + if (itemRecordAnyChannel) { + addMiscInfo({ text: globalize.translate('AllChannels') }); + } else { + addMiscInfo({ + text: itemChannelName ?? globalize.translate('OneChannel') + }); + } + } +} + +function addProgramIndicatorInfo( + program: ItemDto | undefined, + addMiscInfo: (val: MiscInfo) => void +): void { + if ( + program?.IsLive + && userSettings.get('guide-indicator-live', false) === 'true' + ) { + addMiscInfo({ + text: globalize.translate('Live'), + cssClass: 'mediaInfoProgramAttribute liveTvProgram' + }); + } else if ( + program?.IsPremiere + && userSettings.get('guide-indicator-premiere', false) === 'true' + ) { + addMiscInfo({ + text: globalize.translate('Premiere'), + cssClass: 'mediaInfoProgramAttribute premiereTvProgram' + }); + } else if ( + program?.IsSeries + && !program?.IsRepeat + && userSettings.get('guide-indicator-new', false) === 'true' + ) { + addMiscInfo({ + text: globalize.translate('New'), + cssClass: 'mediaInfoProgramAttribute newTvProgram' + }); + } else if ( + program?.IsSeries + && program?.IsRepeat + && userSettings.get('guide-indicator-repeat', false) === 'true' + ) { + addMiscInfo({ + text: globalize.translate('Repeat'), + cssClass: 'mediaInfoProgramAttribute repeatTvProgram' + }); + } +} + +function addProgramIndicators( + item: ItemDto, + isYearEnabled: boolean, + isEpisodeTitleEnabled: boolean, + isOriginalAirDateEnabled: boolean, + isProgramIndicatorEnabled: boolean, + isEpisodeTitleIndexNumberEnabled: boolean, + addMiscInfo: (val: MiscInfo) => void +): void { + if (item.Type === BaseItemKind.Program || item.Type === 'Timer') { + let program = item; + if (item.Type === 'Timer' && item.ProgramInfo) { + program = item.ProgramInfo; + } + + if (isProgramIndicatorEnabled !== false) { + addProgramIndicatorInfo(program, addMiscInfo); + } + + addProgramTextInfo( + program, + isEpisodeTitleEnabled, + isEpisodeTitleIndexNumberEnabled, + isOriginalAirDateEnabled, + isYearEnabled, + addMiscInfo + ); + } +} + +function addProgramTextInfo( + program: ItemDto, + isEpisodeTitleEnabled: boolean, + isEpisodeTitleIndexNumberEnabled: boolean, + isOriginalAirDateEnabled: boolean, + isYearEnabled: boolean, + addMiscInfo: (val: MiscInfo) => void +): void { + if ((program?.IsSeries || program?.EpisodeTitle) + && isEpisodeTitleEnabled !== false) { + const text = itemHelper.getDisplayName(program, { + includeIndexNumber: isEpisodeTitleIndexNumberEnabled + }); + + if (text) { + addMiscInfo({ text: text }); + } + } else if ( + program?.ProductionYear + && ((program?.IsMovie && isOriginalAirDateEnabled !== false) + || isYearEnabled !== false) + ) { + addMiscInfo({ text: program.ProductionYear }); + } else if (program?.PremiereDate && isOriginalAirDateEnabled !== false) { + try { + const date = datetime.parseISO8601Date(program.PremiereDate); + const text = globalize.translate( + 'OriginalAirDateValue', + datetime.toLocaleDateString(date) + ); + addMiscInfo({ text: text }); + } catch (e) { + console.error('error parsing date:', program.PremiereDate); + } + } +} + +function addStartDateInfo( + itemStartDate: NullableString, + itemType: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + if ( + itemStartDate + && itemType !== BaseItemKind.Program + && itemType !== 'SeriesTimer' + && itemType !== 'Timer' + ) { + try { + const date = datetime.parseISO8601Date(itemStartDate); + addMiscInfo({ text: datetime.toLocaleDateString(date) }); + + if (itemType !== BaseItemKind.Recording) { + addMiscInfo({ text: datetime.getDisplayTime(date) }); + } + } catch (e) { + console.error('error parsing date:', itemStartDate); + } + } +} + +function addSeriesProductionYearInfo( + itemProductionYear: NullableNumber, + itemType: NullableString, + isYearEnabled: boolean, + itemStatus: NullableString, + itemEndDate: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + if (itemProductionYear && isYearEnabled && itemType === BaseItemKind.Series) { + if (itemStatus === 'Continuing') { + addMiscInfo({ + text: globalize.translate( + 'SeriesYearToPresent', + datetime.toLocaleString(itemProductionYear, { + useGrouping: false + }) + ) + }); + } else { + addproductionYearWithEndDate(itemProductionYear, itemEndDate, addMiscInfo); + } + } +} + +function addproductionYearWithEndDate( + itemProductionYear: number, + itemEndDate: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + let productionYear = datetime.toLocaleString(itemProductionYear, { + useGrouping: false + }); + + if (itemEndDate) { + try { + const endYear = datetime.toLocaleString( + datetime.parseISO8601Date(itemEndDate).getFullYear(), + { useGrouping: false } + ); + /* At this point, text will contain only the start year */ + if (endYear !== itemProductionYear) { + productionYear += `-${endYear}`; + } + } catch (e) { + console.error('error parsing date:', itemEndDate); + } + } + addMiscInfo({ text: productionYear }); +} + +function addYearInfo( + isYearEnabled: boolean, + itemType: NullableString, + itemMediaType: NullableString, + itemProductionYear: NullableNumber, + itemPremiereDate: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + if ( + isYearEnabled + && itemType !== BaseItemKind.Series + && itemType !== BaseItemKind.Episode + && itemType !== BaseItemKind.Person + && itemMediaType !== 'Photo' + && itemType !== BaseItemKind.Program + && itemType !== BaseItemKind.Season + ) { + if (itemProductionYear) { + addMiscInfo({ text: itemProductionYear }); + } else if (itemPremiereDate) { + try { + const text = datetime.toLocaleString( + datetime.parseISO8601Date(itemPremiereDate).getFullYear(), + { useGrouping: false } + ); + addMiscInfo({ text: text }); + } catch (e) { + console.error('error parsing date:', itemPremiereDate); + } + } + } +} + +function addVideo3DFormat( + itemVideo3DFormat: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + if (itemVideo3DFormat) { + addMiscInfo({ text: '3D' }); + } +} + +function addRunTimeInfo( + itemRunTimeTicks: NullableNumber, + itemType: NullableString, + showFolderRuntime: boolean, + isRuntimeEnabled: boolean, + addMiscInfo: (val: MiscInfo) => void +): void { + if ( + itemRunTimeTicks + && itemType !== BaseItemKind.Series + && itemType !== BaseItemKind.Program + && itemType !== 'Timer' + && itemType !== BaseItemKind.Book + && !showFolderRuntime + && isRuntimeEnabled + ) { + if (itemType === BaseItemKind.Audio) { + addMiscInfo({ text: datetime.getDisplayRunningTime(itemRunTimeTicks) }); + } else { + addMiscInfo({ text: datetime.getDisplayDuration(itemRunTimeTicks) }); + } + } +} + +function addOfficialRatingInfo( + itemOfficialRating: NullableString, + itemType: NullableString, + isOfficialRatingEnabled: boolean, + addMiscInfo: (val: MiscInfo) => void +): void { + if ( + itemOfficialRating + && isOfficialRatingEnabled + && itemType !== BaseItemKind.Season + && itemType !== BaseItemKind.Episode + ) { + addMiscInfo({ + text: itemOfficialRating, + cssClass: 'mediaInfoOfficialRating' + }); + } +} + +function addAudioContainer( + itemContainer: NullableString, + isContainerEnabled: boolean, + itemType: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + if (itemContainer && isContainerEnabled && itemType === BaseItemKind.Audio) { + addMiscInfo({ text: itemContainer }); + } +} + +function addPhotoSize( + itemMediaType: NullableString, + itemWidth: NullableNumber, + itemHeight: NullableNumber, + addMiscInfo: (val: MiscInfo) => void +): void { + if (itemMediaType === 'Photo' && itemWidth && itemHeight) { + const size = `${itemWidth}x${itemHeight}`; + + addMiscInfo({ text: size }); + } +} + +interface UsePrimaryMediaInfoProps { + item: ItemDto; + isYearEnabled: boolean; + isContainerEnabled: boolean; + isEpisodeTitleEnabled: boolean; + isOriginalAirDateEnabled: boolean; + isRuntimeEnabled: boolean; + isProgramIndicatorEnabled: boolean; + isEpisodeTitleIndexNumberEnabled: boolean; + isOfficialRatingEnabled: boolean; +} + +function usePrimaryMediaInfo({ + item, + isYearEnabled = false, + isContainerEnabled = false, + isEpisodeTitleEnabled = false, + isOriginalAirDateEnabled = false, + isRuntimeEnabled = false, + isProgramIndicatorEnabled = false, + isEpisodeTitleIndexNumberEnabled = false, + isOfficialRatingEnabled = false +}: UsePrimaryMediaInfoProps) { + const { + EndDate, + Status, + StartDate, + ProductionYear, + Video3DFormat, + Type, + Width, + Height, + MediaType, + SongCount, + RecordAnyTime, + RecordAnyChannel, + ChannelName, + ChildCount, + RunTimeTicks, + PremiereDate, + OfficialRating, + Container + } = item; + + const miscInfo: MiscInfo[] = []; + + const addMiscInfo = (val: MiscInfo) => { + if (val) { + miscInfo.push(val); + } + }; + + const showFolderRuntime = shouldShowFolderRuntime(Type, MediaType); + + addTrackCountOrItemCount( + showFolderRuntime, + SongCount, + ChildCount, + RunTimeTicks, + Type, + addMiscInfo + ); + + addOriginalAirDateInfo( + Type, + MediaType, + isOriginalAirDateEnabled, + PremiereDate, + addMiscInfo + ); + + addSeriesTimerInfo( + Type, + RecordAnyTime, + StartDate, + RecordAnyChannel, + ChannelName, + addMiscInfo + ); + + addStartDateInfo(StartDate, Type, addMiscInfo); + + addSeriesProductionYearInfo( + ProductionYear, + Type, + isYearEnabled, + Status, + EndDate, + addMiscInfo + ); + + addProgramIndicators( + item, + isProgramIndicatorEnabled, + isEpisodeTitleEnabled, + isEpisodeTitleIndexNumberEnabled, + isOriginalAirDateEnabled, + isYearEnabled, + addMiscInfo + ); + + addYearInfo( + isYearEnabled, + Type, + MediaType, + ProductionYear, + PremiereDate, + addMiscInfo + ); + + addRunTimeInfo( + RunTimeTicks, + Type, + showFolderRuntime, + isRuntimeEnabled, + addMiscInfo + ); + + addOfficialRatingInfo( + OfficialRating, + Type, + isOfficialRatingEnabled, + addMiscInfo + ); + + addVideo3DFormat(Video3DFormat, addMiscInfo); + + addPhotoSize(MediaType, Width, Height, addMiscInfo); + + addAudioContainer(Container, isContainerEnabled, Type, addMiscInfo); + + return miscInfo; +} + +export default usePrimaryMediaInfo; diff --git a/src/types/mediaInfoItem.ts b/src/types/mediaInfoItem.ts new file mode 100644 index 0000000000..3620fad620 --- /dev/null +++ b/src/types/mediaInfoItem.ts @@ -0,0 +1,4 @@ +export interface MiscInfo { + text?: string | number; + cssClass?: string; +}