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

Merge branch 'master' into EnableLibrary

This commit is contained in:
Cody Robibero 2024-03-25 07:16:27 -06:00 committed by GitHub
commit b84e9250fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
263 changed files with 10059 additions and 4684 deletions

View file

@ -104,6 +104,18 @@ class ServerConnections extends ConnectionManager {
return apiClient;
}
/**
* Gets the ApiClient that is currently connected or throws if not defined.
* @async
* @returns {Promise<ApiClient>} The current ApiClient instance.
*/
async getCurrentApiClientAsync() {
const apiClient = this.currentApiClient();
if (!apiClient) throw new Error('[ServerConnection] No current ApiClient instance');
return apiClient;
}
onLocalUserSignedIn(user) {
const apiClient = this.getApiClient(user.ServerId);
this.setLocalApiClient(apiClient);

View file

@ -9,8 +9,64 @@ import 'material-design-icons-iconfont';
import '../../styles/scrollstyles.scss';
import '../../components/listview/listview.scss';
function getOffsets(elems) {
const results = [];
interface OptionItem {
asideText?: string;
divider?: boolean;
icon?: string;
id?: string;
innerText?: string;
name?: string;
secondaryText?: string;
selected?: boolean;
textContent?: string;
value?: string;
}
interface Options {
items: OptionItem[];
border?: boolean;
callback?: (id: string) => void;
dialogClass?: string;
enableHistory?: boolean;
entryAnimationDuration?: number;
entryAnimation?: string;
exitAnimationDuration?: number;
exitAnimation?: string;
menuItemClass?: string;
offsetLeft?: number;
offsetTop?: number;
positionTo?: Element | null;
positionY?: string;
resolveOnClick?: boolean | (string | null)[];
shaded?: boolean;
showCancel?: boolean;
text?: string;
timeout?: number;
title?: string;
}
interface Offset {
top: number;
left: number;
width: number;
height: number;
}
interface DialogOptions {
autoFocus?: boolean;
enableHistory?: boolean;
entryAnimationDuration?: number;
entryAnimation?: string;
exitAnimationDuration?: number;
exitAnimation?: string;
modal?: boolean;
removeOnClose?: boolean;
scrollY?: boolean;
size?: string;
}
function getOffsets(elems: Element[]): Offset[] {
const results: Offset[] = [];
if (!document) {
return results;
@ -30,12 +86,12 @@ function getOffsets(elems) {
return results;
}
function getPosition(options, dlg) {
function getPosition(positionTo: Element, options: Options, dlg: HTMLElement) {
const windowSize = dom.getWindowSize();
const windowHeight = windowSize.innerHeight;
const windowWidth = windowSize.innerWidth;
const pos = getOffsets([options.positionTo])[0];
const pos = getOffsets([positionTo])[0];
if (options.positionY !== 'top') {
pos.top += (pos.height || 0) / 2;
@ -71,19 +127,22 @@ function getPosition(options, dlg) {
return pos;
}
function centerFocus(elem, horiz, on) {
function centerFocus(elem: Element, horiz: boolean, on: boolean) {
import('../../scripts/scrollHelper').then((scrollHelper) => {
const fn = on ? 'on' : 'off';
scrollHelper.centerFocus[fn](elem, horiz);
}).catch(e => {
console.warn('Error in centerFocus', e);
});
}
export function show(options) {
/* eslint-disable-next-line sonarjs/cognitive-complexity */
export function show(options: Options) {
// items
// positionTo
// showCancel
// title
const dialogOptions = {
const dialogOptions: DialogOptions = {
removeOnClose: true,
enableHistory: options.enableHistory,
scrollY: false
@ -239,7 +298,10 @@ export function show(options) {
dlg.innerHTML = html;
if (layoutManager.tv) {
centerFocus(dlg.querySelector('.actionSheetScroller'), false, true);
const scroller = dlg.querySelector('.actionSheetScroller');
if (scroller) {
centerFocus(scroller, false, true);
}
}
const btnCloseActionSheet = dlg.querySelector('.btnCloseActionSheet');
@ -249,9 +311,9 @@ export function show(options) {
});
}
let selectedId;
let selectedId: string | null = null;
let timeout;
let timeout: ReturnType<typeof setTimeout> | undefined;
if (options.timeout) {
timeout = setTimeout(function () {
dialogHelper.close(dlg);
@ -259,16 +321,16 @@ export function show(options) {
}
return new Promise(function (resolve, reject) {
let isResolved;
let isResolved = false;
dlg.addEventListener('click', function (e) {
const actionSheetMenuItem = dom.parentWithClass(e.target, 'actionSheetMenuItem');
const actionSheetMenuItem = dom.parentWithClass(e.target as HTMLElement, 'actionSheetMenuItem');
if (actionSheetMenuItem) {
selectedId = actionSheetMenuItem.getAttribute('data-id');
if (options.resolveOnClick) {
if (options.resolveOnClick.indexOf) {
if (Array.isArray(options.resolveOnClick)) {
if (options.resolveOnClick.indexOf(selectedId) !== -1) {
resolve(selectedId);
isResolved = true;
@ -285,12 +347,15 @@ export function show(options) {
dlg.addEventListener('close', function () {
if (layoutManager.tv) {
centerFocus(dlg.querySelector('.actionSheetScroller'), false, false);
const scroller = dlg.querySelector('.actionSheetScroller');
if (scroller) {
centerFocus(scroller, false, false);
}
}
if (timeout) {
clearTimeout(timeout);
timeout = null;
timeout = undefined;
}
if (!isResolved) {
@ -306,13 +371,15 @@ export function show(options) {
}
});
dialogHelper.open(dlg);
dialogHelper.open(dlg).catch(e => {
console.warn('DialogHelper.open error', e);
});
const pos = options.positionTo && dialogOptions.size !== 'fullscreen' ? getPosition(options, dlg) : null;
const pos = options.positionTo && dialogOptions.size !== 'fullscreen' ? getPosition(options.positionTo, options, dlg) : null;
if (pos) {
dlg.style.position = 'fixed';
dlg.style.margin = 0;
dlg.style.margin = '0';
dlg.style.left = pos.left + 'px';
dlg.style.top = pos.top + 'px';
}

View file

@ -1,4 +1,3 @@
import Package from '../../package.json';
import appSettings from '../scripts/settings/appSettings';
import browser from '../scripts/browser';
import Events from '../utils/events.ts';
@ -36,7 +35,7 @@ function getDeviceProfile(item) {
let profile;
if (window.NativeShell) {
profile = window.NativeShell.AppHost.getDeviceProfile(profileBuilder, Package.version);
profile = window.NativeShell.AppHost.getDeviceProfile(profileBuilder, __PACKAGE_JSON_VERSION__);
} else {
const builderOpts = getBaseProfileOptions(item);
profile = profileBuilder(builderOpts);
@ -46,18 +45,27 @@ function getDeviceProfile(item) {
const maxTranscodingVideoWidth = maxVideoWidth < 0 ? appHost.screen()?.maxAllowedWidth : maxVideoWidth;
if (maxTranscodingVideoWidth) {
const conditionWidth = {
Condition: 'LessThanEqual',
Property: 'Width',
Value: maxTranscodingVideoWidth.toString(),
IsRequired: false
};
if (appSettings.limitSupportedVideoResolution()) {
profile.CodecProfiles.push({
Type: 'Video',
Conditions: [conditionWidth]
});
}
profile.TranscodingProfiles.forEach((transcodingProfile) => {
if (transcodingProfile.Type === 'Video') {
transcodingProfile.Conditions = (transcodingProfile.Conditions || []).filter((condition) => {
return condition.Property !== 'Width';
});
transcodingProfile.Conditions.push({
Condition: 'LessThanEqual',
Property: 'Width',
Value: maxTranscodingVideoWidth.toString(),
IsRequired: false
});
transcodingProfile.Conditions.push(conditionWidth);
}
});
}
@ -378,7 +386,7 @@ export const appHost = {
},
appVersion: function () {
return window.NativeShell?.AppHost?.appVersion ?
window.NativeShell.AppHost.appVersion() : Package.version;
window.NativeShell.AppHost.appVersion() : __PACKAGE_JSON_VERSION__;
},
getPushTokenInfo: function () {
return {};

View file

@ -0,0 +1,25 @@
import React, { type 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/base/models/item-dto';
interface CardProps {
item?: ItemDto;
cardOptions: CardOptions;
}
const Card: FC<CardProps> = ({ item = {}, cardOptions }) => {
const { getCardWrapperProps, getCardBoxProps } = useCard({ item, cardOptions } );
const cardWrapperProps = getCardWrapperProps();
const cardBoxProps = getCardBoxProps();
return (
<CardWrapper {...cardWrapperProps}>
<CardBox {...cardBoxProps} />
</CardWrapper>
);
};
export default Card;

View file

@ -0,0 +1,78 @@
import React, { type 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 { CardShape } from 'utils/card';
import type { ItemDto } from 'types/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
interface CardBoxProps {
item: ItemDto;
cardOptions: CardOptions;
className: string;
shape: CardShape | undefined;
imgUrl: string | undefined;
blurhash: string | undefined;
forceName: boolean;
coveredImage: boolean;
overlayText: boolean | undefined;
}
const CardBox: FC<CardBoxProps> = ({
item,
cardOptions,
className,
shape,
imgUrl,
blurhash,
forceName,
coveredImage,
overlayText
}) => {
return (
<div className={className}>
<div className='cardScalable'>
<div className={`cardPadder cardPadder-${shape}`}></div>
<CardContent
item={item}
cardOptions={cardOptions}
coveredImage={coveredImage}
overlayText={overlayText}
imgUrl={imgUrl}
blurhash={blurhash}
forceName={forceName}
/>
{layoutManager.mobile && (
<CardOverlayButtons
item={item}
cardOptions={cardOptions}
/>
)}
{layoutManager.desktop
&& !cardOptions.disableHoverMenu && (
<CardHoverMenu
item={item}
cardOptions={cardOptions}
/>
)}
</div>
{!overlayText && (
<CardOuterFooter
item={item}
cardOptions={cardOptions}
forceName={forceName}
overlayText={overlayText}
imgUrl={imgUrl}
/>
)}
</div>
);
};
export default CardBox;

View file

@ -0,0 +1,50 @@
import React, { type FC } from 'react';
import classNames from 'classnames';
import { getDefaultBackgroundClass } from '../cardBuilderUtils';
import CardImageContainer from './CardImageContainer';
import type { ItemDto } from 'types/base/models/item-dto';
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<CardContentProps> = ({
item,
cardOptions,
coveredImage,
overlayText,
imgUrl,
blurhash,
forceName
}) => {
const cardContentClass = classNames(
'cardContent',
{ [getDefaultBackgroundClass(item.Name)]: !imgUrl }
);
return (
<div
className={cardContentClass}
>
<CardImageContainer
item={item}
cardOptions={cardOptions}
coveredImage={coveredImage}
overlayText={overlayText}
imgUrl={imgUrl}
blurhash={blurhash}
forceName={forceName}
/>
</div>
);
};
export default CardContent;

View file

@ -0,0 +1,87 @@
import React, { type 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/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
import Image from 'components/common/Image';
const shouldShowDetailsMenu = (
cardOptions: CardOptions,
isOuterFooter: boolean
) => {
return (
cardOptions.showDetailsMenu
&& isOuterFooter
&& cardOptions.cardLayout
&& layoutManager.mobile
&& cardOptions.cardFooterAside !== 'none'
);
};
interface LogoComponentProps {
logoUrl: string;
}
const LogoComponent: FC<LogoComponentProps> = ({ logoUrl }) => (
<Box className='cardFooterLogo'>
<Image
imgUrl={logoUrl}
containImage
/>
</Box>
);
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<CardFooterTextProps> = ({
item,
cardOptions,
forceName,
imgUrl,
footerClass,
overlayText,
progressBar,
logoUrl,
isOuterFooter
}) => {
const { cardTextLines } = useCardText({
item: item.ProgramInfo || item,
cardOptions,
forceName,
imgUrl,
overlayText,
isOuterFooter,
cssClass: cardOptions.centerText ?
'cardText cardTextCentered' :
'cardText',
forceLines: !cardOptions.overlayText,
maxLines: cardOptions.lines
});
return (
<Box className={footerClass}>
{logoUrl && <LogoComponent logoUrl={logoUrl} />}
{shouldShowDetailsMenu(cardOptions, isOuterFooter) && (
<MoreVertIconButton className='itemAction btnCardOptions' />
)}
{cardTextLines}
{progressBar}
</Box>
);
};
export default CardFooterText;

View file

@ -0,0 +1,82 @@
import React, { type FC } from 'react';
import Box from '@mui/material/Box';
import ButtonGroup from '@mui/material/ButtonGroup';
import classNames from 'classnames';
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/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
interface CardHoverMenuProps {
item: ItemDto;
cardOptions: CardOptions;
}
const CardHoverMenu: FC<CardHoverMenuProps> = ({
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 (
<Box
className='cardOverlayContainer'
>
<a
href={url}
aria-label={item.Name || ''}
className='cardImageContainer'
></a>
{playbackManager.canPlay(item) && (
<PlayArrowIconButton
className={centerPlayButtonClass}
action='play'
title='Play'
/>
)}
<ButtonGroup className='cardOverlayButton-br flex'>
{itemHelper.canMarkPlayed(item) && cardOptions.enablePlayedButton !== false && (
<PlayedButton
className={btnCssClass}
isPlayed={Played}
itemId={item.Id}
itemType={item.Type}
queryKey={cardOptions.queryKey}
/>
)}
{itemHelper.canRate(item) && cardOptions.enableRatingButton !== false && (
<FavoriteButton
className={btnCssClass}
isFavorite={IsFavorite}
itemId={item.Id}
queryKey={cardOptions.queryKey}
/>
)}
<MoreVertIconButton className={btnCssClass} />
</ButtonGroup>
</Box>
);
};
export default CardHoverMenu;

View file

@ -0,0 +1,83 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import React, { type 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/base/models/item-dto';
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<CardImageContainerProps> = ({
item,
cardOptions,
coveredImage,
overlayText,
imgUrl,
blurhash,
forceName
}) => {
const indicator = useIndicator(item);
const cardImageClass = classNames(
'cardImageContainer',
{ coveredImage: coveredImage },
{ 'coveredImage-contain': coveredImage && item.Type === BaseItemKind.TvChannel }
);
return (
<div className={cardImageClass}>
{cardOptions.disableIndicators !== true && (
<Box className='indicators'>
{indicator.getMediaSourceIndicator()}
<Box className='cardIndicators'>
{cardOptions.missingIndicator !== false
&& indicator.getMissingIndicator()}
{indicator.getTimerIndicator()}
{indicator.getTypeIndicator()}
{cardOptions.showGroupCount ?
indicator.getChildCountIndicator() :
indicator.getPlayedIndicator()}
{(item.Type === BaseItemKind.CollectionFolder
|| item.CollectionType)
&& item.RefreshProgress && (
<RefreshIndicator item={item} />
)}
</Box>
</Box>
)}
<Media item={item} imgUrl={imgUrl} blurhash={blurhash} imageType={cardOptions.imageType} />
{overlayText && (
<CardInnerFooter
item={item}
cardOptions={cardOptions}
forceName={forceName}
overlayText={overlayText}
imgUrl={imgUrl}
progressBar={indicator.getProgressBar()}
/>
)}
{!overlayText && indicator.getProgressBar()}
</div>
);
};
export default CardImageContainer;

View file

@ -0,0 +1,42 @@
import React, { type FC } from 'react';
import classNames from 'classnames';
import CardFooterText from './CardFooterText';
import type { ItemDto } from 'types/base/models/item-dto';
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<CardInnerFooterProps> = ({
item,
cardOptions,
imgUrl,
overlayText,
progressBar,
forceName
}) => {
const footerClass = classNames('innerCardFooter', {
fullInnerCardFooter: progressBar
});
return (
<CardFooterText
item={item}
cardOptions={cardOptions}
forceName={forceName}
overlayText={overlayText}
imgUrl={imgUrl}
footerClass={footerClass}
progressBar={progressBar}
isOuterFooter={false}
/>
);
};
export default CardInnerFooter;

View file

@ -0,0 +1,45 @@
import React, { type 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/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
interface CardOuterFooterProps {
item: ItemDto
cardOptions: CardOptions;
imgUrl: string | undefined;
forceName: boolean;
overlayText: boolean | undefined
}
const CardOuterFooter: FC<CardOuterFooterProps> = ({ 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 (
<CardFooterText
item={item}
cardOptions={cardOptions}
forceName={forceName}
overlayText={overlayText}
imgUrl={imgUrl}
footerClass={footerClass}
progressBar={undefined}
logoUrl={logoUrl}
isOuterFooter={true}
/>
);
};
export default CardOuterFooter;

View file

@ -0,0 +1,104 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { LocationType } from '@jellyfin/sdk/lib/generated-client/models/location-type';
import React, { type FC } from 'react';
import ButtonGroup from '@mui/material/ButtonGroup';
import classNames from 'classnames';
import { appRouter } from 'components/router/appRouter';
import PlayArrowIconButton from '../../common/PlayArrowIconButton';
import MoreVertIconButton from '../../common/MoreVertIconButton';
import type { ItemDto } from 'types/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
const sholudShowOverlayPlayButton = (
overlayPlayButton: boolean | undefined,
item: ItemDto
) => {
return (
overlayPlayButton
&& !item.IsPlaceHolder
&& (item.LocationType !== LocationType.Virtual
|| !item.MediaType
|| item.Type === BaseItemKind.Program)
&& item.Type !== BaseItemKind.Person
);
};
interface CardOverlayButtonsProps {
item: ItemDto;
cardOptions: CardOptions;
}
const CardOverlayButtons: FC<CardOverlayButtonsProps> = ({
item,
cardOptions
}) => {
let overlayPlayButton = cardOptions.overlayPlayButton;
if (
overlayPlayButton == null
&& !cardOptions.overlayMoreButton
&& !cardOptions.overlayInfoButton
&& !cardOptions.cardLayout
) {
overlayPlayButton = item.MediaType === 'Video';
}
const url = appRouter.getRouteUrl(item, {
parentId: cardOptions.parentId
});
const btnCssClass = classNames(
'paper-icon-button-light',
'cardOverlayButton',
'itemAction'
);
const centerPlayButtonClass = classNames(
btnCssClass,
'cardOverlayButton-centered'
);
return (
<a
href={url}
aria-label={item.Name || ''}
style={{
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
userSelect: 'none',
borderRadius: '0.2em'
}}
>
{cardOptions.centerPlayButton && (
<PlayArrowIconButton
className={centerPlayButtonClass}
action='play'
title='Play'
/>
)}
<ButtonGroup className='cardOverlayButton-br'>
{sholudShowOverlayPlayButton(overlayPlayButton, item) && (
<PlayArrowIconButton
className={btnCssClass}
action='play'
title='Play'
/>
)}
{cardOptions.overlayMoreButton && (
<MoreVertIconButton
className={btnCssClass}
/>
)}
</ButtonGroup>
</a>
);
};
export default CardOverlayButtons;

View file

@ -0,0 +1,32 @@
import React, { type FC } from 'react';
import Box from '@mui/material/Box';
import type { TextLine } from './cardHelper';
interface CardTextProps {
className?: string;
textLine: TextLine;
}
const CardText: FC<CardTextProps> = ({ className, textLine }) => {
const { title, titleAction } = textLine;
const renderCardText = () => {
if (titleAction) {
return (
<a
className='itemAction textActionButton'
href={titleAction.url}
title={titleAction.title}
{...titleAction.dataAttributes}
>
{titleAction.title}
</a>
);
} else {
return title;
}
};
return <Box className={className}>{renderCardText()}</Box>;
};
export default CardText;

View file

@ -0,0 +1,30 @@
import React, { type FC } from 'react';
import layoutManager from 'components/layoutManager';
import type { DataAttributes } from 'types/dataAttributes';
interface CardWrapperProps {
className: string;
dataAttributes: DataAttributes;
}
const CardWrapper: FC<CardWrapperProps> = ({
className,
dataAttributes,
children
}) => {
if (layoutManager.tv) {
return (
<button className={className} {...dataAttributes}>
{children}
</button>
);
} else {
return (
<div className={className} {...dataAttributes}>
{children}
</div>
);
}
};
export default CardWrapper;

View file

@ -0,0 +1,24 @@
import React, { type FC } from 'react';
import { setCardData } from '../cardBuilder';
import Card from './Card';
import type { ItemDto } from 'types/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
import '../card.scss';
interface CardsProps {
items: ItemDto[];
cardOptions: CardOptions;
}
const Cards: FC<CardsProps> = ({ items, cardOptions }) => {
setCardData(items, cardOptions);
const renderCards = () =>
items.map((item) => (
<Card key={item.Id} item={item} cardOptions={cardOptions} />
));
return <>{renderCards()}</>;
};
export default Cards;

View file

@ -0,0 +1,718 @@
import {
BaseItemDto,
BaseItemKind,
BaseItemPerson,
ImageType
} from '@jellyfin/sdk/lib/generated-client';
import { Api } from '@jellyfin/sdk';
import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
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 { NullableNumber, NullableString } from 'types/base/common/shared/types';
import type { ItemDto } from 'types/base/models/item-dto';
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 = getImageApi(api).getItemImageUrlById(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 {
const title = text || itemHelper.getDisplayName(item);
if (layoutManager.tv) {
return {
title
};
}
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,
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?.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 || '\u00A0' };
}
}
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
};
}

View file

@ -0,0 +1,123 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import classNames from 'classnames';
import useCardImageUrl from './useCardImageUrl';
import {
resolveAction,
resolveMixedShapeByAspectRatio
} from '../cardBuilderUtils';
import { getDataAttributes } from 'utils/items';
import { CardShape } from 'utils/card';
import layoutManager from 'components/layoutManager';
import type { ItemDto } from 'types/base/models/item-dto';
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 === CardShape.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 !== BaseItemKind.MusicAlbum
&& item.Type !== BaseItemKind.MusicArtist
&& item.Type !== BaseItemKind.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;

View file

@ -0,0 +1,298 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
import { useApi } from 'hooks/useApi';
import { getDesiredAspect } from '../cardBuilderUtils';
import { CardShape } from 'utils/card';
import type { NullableNumber, NullableString } from 'types/base/common/shared/types';
import type { ItemDto } from 'types/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
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(
itemWidth: NullableNumber,
itemPrimaryImageAspectRatio: NullableNumber
) {
if (itemWidth && itemPrimaryImageAspectRatio) {
return Math.round(itemWidth / itemPrimaryImageAspectRatio);
}
}
function isForceName(cardOptions: CardOptions) {
return !!(cardOptions.preferThumb && cardOptions.showTitle !== false);
}
function isCoverImage(
itemPrimaryImageAspectRatio: NullableNumber,
uiAspect: NullableNumber
) {
if (itemPrimaryImageAspectRatio && uiAspect) {
return Math.abs(itemPrimaryImageAspectRatio - uiAspect) / uiAspect <= 0.2;
}
return false;
}
function shouldShowPreferBanner(
imageTagsBanner: NullableString,
cardOptions: CardOptions,
shape: CardShape | undefined
): boolean {
return (
(cardOptions.preferBanner || shape === CardShape.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(
itemSeriesThumbImageTag: NullableString,
cardOptions: CardOptions
): boolean {
return (
Boolean(itemSeriesThumbImageTag) && cardOptions.inheritThumb !== false
);
}
function shouldShowParentThumbImageTag(
itemParentThumbItemId: NullableString,
cardOptions: CardOptions
): boolean {
return (
Boolean(itemParentThumbItemId) && cardOptions.inheritThumb !== false
);
}
function shouldShowParentBackdropImageTags(item: ItemDto): boolean {
return Boolean(item.AlbumId) && Boolean(item.AlbumPrimaryImageTag);
}
function shouldShowPreferThumb(itemType: NullableString, cardOptions: CardOptions): boolean {
return Boolean(cardOptions.preferThumb) && !(itemType === BaseItemKind.Program || itemType === BaseItemKind.Episode);
}
function getCardImageInfo(
item: ItemDto,
cardOptions: CardOptions,
shape: CardShape | undefined
) {
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 (shouldShowSeriesThumbImageTag(item.SeriesThumbImageTag, cardOptions)) {
imgType = ImageType.Thumb;
imgTag = item.SeriesThumbImageTag;
itemId = item.SeriesId;
} else if (shouldShowParentThumbImageTag(item.ParentThumbItemId, 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: CardShape | undefined;
}
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 = getImageApi(api).getItemImageUrlById(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;

View file

@ -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/base/models/item-dto';
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(
<CardText key={valid} className={currentCssClass} textLine={textLine} />
);
valid++;
if (maxLines && valid >= maxLines) {
break;
}
}
}
if (forceLines) {
const linesLength = maxLines ?? Math.min(textLines.length, maxLines ?? textLines.length);
while (valid < linesLength) {
components.push(
<Box key={valid} className={cssClass}>
&nbsp;
</Box>
);
valid++;
}
}
return components;
};
const cardTextLines = renderCardTextLines();
return {
cardTextLines
};
}
export default useCardText;

View file

@ -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;
}

View file

@ -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);

View file

@ -1,3 +1,4 @@
import { CardShape } from '../../utils/card';
import { randomInt } from '../../utils/number';
import classNames from 'classnames';
@ -10,10 +11,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
@ -54,15 +55,15 @@ export const isResizable = (windowWidth: number): boolean => {
*/
export const resolveMixedShapeByAspectRatio = (primaryImageAspectRatio: number | null | undefined) => {
if (primaryImageAspectRatio === undefined || primaryImageAspectRatio === null) {
return 'mixedSquare';
return CardShape.MixedSquare;
}
if (primaryImageAspectRatio >= 1.33) {
return 'mixedBackdrop';
return CardShape.MixedBackdrop;
} else if (primaryImageAspectRatio > 0.71) {
return 'mixedSquare';
return CardShape.MixedSquare;
} else {
return 'mixedPortrait';
return CardShape.MixedPortrait;
}
};

View file

@ -0,0 +1,56 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import React, { type FC } from 'react';
import Icon from '@mui/material/Icon';
import imageHelper from 'utils/image';
import DefaultName from './DefaultName';
import type { ItemDto } from 'types/base/models/item-dto';
interface DefaultIconTextProps {
item: ItemDto;
defaultCardImageIcon?: string;
}
const DefaultIconText: FC<DefaultIconTextProps> = ({
item,
defaultCardImageIcon
}) => {
if (item.CollectionType) {
return (
<Icon
className='cardImageIcon'
sx={{ color: 'inherit', fontSize: '5em' }}
aria-hidden='true'
>
{imageHelper.getLibraryIcon(item.CollectionType)}
</Icon>
);
}
if (item.Type && !(item.Type === BaseItemKind.TvChannel || item.Type === BaseItemKind.Studio )) {
return (
<Icon
className='cardImageIcon'
sx={{ color: 'inherit', fontSize: '5em' }}
aria-hidden='true'
>
{imageHelper.getItemTypeIcon(item.Type)}
</Icon>
);
}
if (defaultCardImageIcon) {
return (
<Icon
className='cardImageIcon'
sx={{ color: 'inherit', fontSize: '5em' }}
aria-hidden='true'
>
{defaultCardImageIcon}
</Icon>
);
}
return <DefaultName item={item} />;
};
export default DefaultIconText;

View file

@ -0,0 +1,22 @@
import React, { type FC } from 'react';
import Box from '@mui/material/Box';
import itemHelper from 'components/itemHelper';
import { isUsingLiveTvNaming } from '../cardbuilder/cardBuilderUtils';
import type { ItemDto } from 'types/base/models/item-dto';
interface DefaultNameProps {
item: ItemDto;
}
const DefaultName: FC<DefaultNameProps> = ({ item }) => {
const defaultName = isUsingLiveTvNaming(item.Type) ?
item.Name :
itemHelper.getDisplayName(item);
return (
<Box className='cardText cardDefaultText'>
{defaultName}
</Box>
);
};
export default DefaultName;

View file

@ -0,0 +1,67 @@
import React, { type FC, useCallback, useState } from 'react';
import { BlurhashCanvas } from 'react-blurhash';
import { LazyLoadImage } from 'react-lazy-load-image-component';
const imageStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
width: '100%',
height: '100%',
zIndex: 0
};
interface ImageProps {
imgUrl: string;
blurhash?: string;
containImage: boolean;
}
const Image: FC<ImageProps> = ({
imgUrl,
blurhash,
containImage
}) => {
const [isLoaded, setIsLoaded] = useState(false);
const [isLoadStarted, setIsLoadStarted] = useState(false);
const handleLoad = useCallback(() => {
setIsLoaded(true);
}, []);
const handleLoadStarted = useCallback(() => {
setIsLoadStarted(true);
}, []);
return (
<div>
{!isLoaded && isLoadStarted && blurhash && (
<BlurhashCanvas
hash={blurhash}
width= {20}
height={20}
punch={1}
style={{
...imageStyle,
borderRadius: '0.2em',
pointerEvents: 'none'
}}
/>
)}
<LazyLoadImage
key={imgUrl}
src={imgUrl}
style={{
...imageStyle,
objectFit: containImage ? 'contain' : 'cover'
}}
onLoad={handleLoad}
beforeLoad={handleLoadStarted}
/>
</div>
);
};
export default Image;

View file

@ -0,0 +1,22 @@
import React, { type FC } from 'react';
import IconButton from '@mui/material/IconButton';
import InfoIcon from '@mui/icons-material/Info';
import globalize from 'scripts/globalize';
interface InfoIconButtonProps {
className?: string;
}
const InfoIconButton: FC<InfoIconButtonProps> = ({ className }) => {
return (
<IconButton
className={className}
data-action='link'
title={globalize.translate('ButtonInfo')}
>
<InfoIcon />
</IconButton>
);
};
export default InfoIconButton;

View file

@ -0,0 +1,36 @@
import { BaseItemKind, ImageType } from '@jellyfin/sdk/lib/generated-client';
import React, { type FC } from 'react';
import Image from './Image';
import DefaultIconText from './DefaultIconText';
import type { ItemDto } from 'types/base/models/item-dto';
interface MediaProps {
item: ItemDto;
imgUrl: string | undefined;
blurhash: string | undefined;
imageType?: ImageType
defaultCardImageIcon?: string
}
const Media: FC<MediaProps> = ({
item,
imgUrl,
blurhash,
imageType,
defaultCardImageIcon
}) => {
return imgUrl ? (
<Image
imgUrl={imgUrl}
blurhash={blurhash}
containImage={item.Type === BaseItemKind.TvChannel || imageType === ImageType.Logo}
/>
) : (
<DefaultIconText
item={item}
defaultCardImageIcon={defaultCardImageIcon}
/>
);
};
export default Media;

View file

@ -0,0 +1,23 @@
import React, { type FC } from 'react';
import IconButton from '@mui/material/IconButton';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import globalize from 'scripts/globalize';
interface MoreVertIconButtonProps {
className?: string;
iconClassName?: string;
}
const MoreVertIconButton: FC<MoreVertIconButtonProps> = ({ className, iconClassName }) => {
return (
<IconButton
className={className}
data-action='menu'
title={globalize.translate('ButtonMore')}
>
<MoreVertIcon className={iconClassName} />
</IconButton>
);
};
export default MoreVertIconButton;

View file

@ -0,0 +1,25 @@
import React, { type FC } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import globalize from 'scripts/globalize';
interface NoItemsMessageProps {
noItemsMessage?: string;
}
const NoItemsMessage: FC<NoItemsMessageProps> = ({
noItemsMessage = 'MessageNoItemsAvailable'
}) => {
return (
<Box className='noItemsMessage centerMessage'>
<Typography variant='h2'>
{globalize.translate('MessageNothingHere')}
</Typography>
<Typography paragraph variant='h2'>
{globalize.translate(noItemsMessage)}
</Typography>
</Box>
);
};
export default NoItemsMessage;

View file

@ -0,0 +1,25 @@
import React, { type FC } from 'react';
import IconButton from '@mui/material/IconButton';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import globalize from 'scripts/globalize';
interface PlayArrowIconButtonProps {
className: string;
action: string;
title: string;
iconClassName?: string;
}
const PlayArrowIconButton: FC<PlayArrowIconButtonProps> = ({ className, action, title, iconClassName }) => {
return (
<IconButton
className={className}
data-action={action}
title={globalize.translate(title)}
>
<PlayArrowIcon className={iconClassName} />
</IconButton>
);
};
export default PlayArrowIconButton;

View file

@ -0,0 +1,22 @@
import React, { type FC } from 'react';
import IconButton from '@mui/material/IconButton';
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
import globalize from 'scripts/globalize';
interface PlaylistAddIconButtonProps {
className?: string;
}
const PlaylistAddIconButton: FC<PlaylistAddIconButtonProps> = ({ className }) => {
return (
<IconButton
className={className}
data-action='addtoplaylist'
title={globalize.translate('AddToPlaylist')}
>
<PlaylistAddIcon />
</IconButton>
);
};
export default PlaylistAddIconButton;

View file

@ -0,0 +1,24 @@
import React, { type FC } from 'react';
import IconButton from '@mui/material/IconButton';
interface RightIconButtonsProps {
className?: string;
id: string;
icon: string;
title: string;
}
const RightIconButtons: FC<RightIconButtonsProps> = ({ className, id, title, icon }) => {
return (
<IconButton
className={className}
data-action='custom'
data-customaction={id}
title={title}
>
{icon}
</IconButton>
);
};
export default RightIconButtons;

View file

@ -0,0 +1,33 @@
import React, { type FunctionComponent } from 'react';
import globalize from '../../../../scripts/globalize';
type IProps = {
title?: string;
className?: string;
href?: string;
};
const createLinkElement = ({ className, title, href }: IProps) => ({
__html: `<a
is="emby-linkbutton"
rel="noopener noreferrer"
class="${className}"
href="${href}"
>
${title}
</a>`
});
const LinkTrickplayAcceleration: FunctionComponent<IProps> = ({ className, title, href }: IProps) => {
return (
<div
dangerouslySetInnerHTML={createLinkElement({
className,
title: globalize.translate(title),
href
})}
/>
);
};
export default LinkTrickplayAcceleration;

View file

@ -2,10 +2,11 @@ import React, { FunctionComponent } from 'react';
import IconButtonElement from '../../../elements/IconButtonElement';
type IProps = {
tag?: string;
tag?: string,
tagType?: string;
};
const BlockedTagList: FunctionComponent<IProps> = ({ tag }: IProps) => {
const TagList: FunctionComponent<IProps> = ({ tag, tagType }: IProps) => {
return (
<div className='paperList'>
<div className='listItem'>
@ -16,7 +17,7 @@ const BlockedTagList: FunctionComponent<IProps> = ({ tag }: IProps) => {
</div>
<IconButtonElement
is='paper-icon-button-light'
className='blockedTag btnDeleteTag listItemButton'
className={`${tagType} btnDeleteTag listItemButton`}
title='Delete'
icon='delete'
dataTag={tag}
@ -26,4 +27,4 @@ const BlockedTagList: FunctionComponent<IProps> = ({ tag }: IProps) => {
);
};
export default BlockedTagList;
export default TagList;

View file

@ -61,7 +61,7 @@ const UserCardBox: FunctionComponent<IProps> = ({ user = {} }: IProps) => {
</div>`;
return (
<div data-userid={user.Id} className={cssClass}>
<div data-userid={user.Id} data-username={user.Name} className={cssClass}>
<div className='cardBox visualCardBox'>
<div className='cardScalable visualCardBox-cardScalable'>
<div className='cardPadder cardPadder-square'></div>

View file

@ -1,6 +1,7 @@
import appSettings from '../scripts/settings/appSettings' ;
import browser from '../scripts/browser';
import Events from '../utils/events.ts';
import { MediaError } from 'types/mediaError';
export function getSavedVolume() {
return appSettings.get('volume') || 1;
@ -87,7 +88,7 @@ export function handleHlsJsMediaError(instance, reject) {
if (reject) {
reject();
} else {
onErrorInternal(instance, 'mediadecodeerror');
onErrorInternal(instance, MediaError.FATAL_HLS_ERROR);
}
}
}
@ -98,11 +99,7 @@ export function onErrorInternal(instance, type) {
instance.destroyCustomTrack(instance._mediaElement);
}
Events.trigger(instance, 'error', [
{
type: type
}
]);
Events.trigger(instance, 'error', [{ type }]);
}
export function isValidDuration(duration) {
@ -193,7 +190,7 @@ export function playWithPromise(elem, onErrorFn) {
// swallow this error because the user can still click the play button on the video element
return Promise.resolve();
}
return Promise.reject();
return Promise.reject(e);
})
.then(() => {
onSuccessfulPlay(elem, onErrorFn);
@ -269,10 +266,10 @@ export function bindEventsToHlsPlayer(instance, hls, elem, onErrorFn, resolve, r
hls.destroy();
if (reject) {
reject('servererror');
reject(MediaError.SERVER_ERROR);
reject = null;
} else {
onErrorInternal(instance, 'servererror');
onErrorInternal(instance, MediaError.SERVER_ERROR);
}
return;
@ -291,10 +288,10 @@ export function bindEventsToHlsPlayer(instance, hls, elem, onErrorFn, resolve, r
hls.destroy();
if (reject) {
reject('network');
reject(MediaError.NETWORK_ERROR);
reject = null;
} else {
onErrorInternal(instance, 'network');
onErrorInternal(instance, MediaError.NETWORK_ERROR);
}
} else {
console.debug('fatal network error encountered, try to recover');
@ -318,7 +315,7 @@ export function bindEventsToHlsPlayer(instance, hls, elem, onErrorFn, resolve, r
reject();
reject = null;
} else {
onErrorInternal(instance, 'mediadecodeerror');
onErrorInternal(instance, MediaError.FATAL_HLS_ERROR);
}
break;
}

View file

@ -5,6 +5,14 @@
height: 0.28em;
}
.itemLinearProgress {
width: 100%;
position: absolute;
left: 0;
bottom: 0;
border-radius: 100px;
}
.itemProgressBarForeground {
position: absolute;
top: 0;

View file

@ -0,0 +1,261 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { LocationType } from '@jellyfin/sdk/lib/generated-client/models/location-type';
import React from 'react';
import Box from '@mui/material/Box';
import LinearProgress, {
linearProgressClasses
} from '@mui/material/LinearProgress';
import FiberSmartRecordIcon from '@mui/icons-material/FiberSmartRecord';
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
import CheckIcon from '@mui/icons-material/Check';
import VideocamIcon from '@mui/icons-material/Videocam';
import FolderIcon from '@mui/icons-material/Folder';
import PhotoAlbumIcon from '@mui/icons-material/PhotoAlbum';
import PhotoIcon from '@mui/icons-material/Photo';
import classNames from 'classnames';
import datetime from 'scripts/datetime';
import itemHelper from 'components/itemHelper';
import AutoTimeProgressBar from 'elements/emby-progressbar/AutoTimeProgressBar';
import type { NullableString } from 'types/base/common/shared/types';
import type { ItemDto } from 'types/base/models/item-dto';
import type { ProgressOptions } from 'types/progressOptions';
const TypeIcon = {
Video: <VideocamIcon className='indicatorIcon' />,
Folder: <FolderIcon className='indicatorIcon' />,
PhotoAlbum: <PhotoAlbumIcon className='indicatorIcon' />,
Photo: <PhotoIcon className='indicatorIcon' />
};
const getTypeIcon = (itemType: NullableString) => {
return TypeIcon[itemType as keyof typeof TypeIcon];
};
const enableProgressIndicator = (
itemType: NullableString,
itemMediaType: NullableString
) => {
return (
(itemMediaType === 'Video' && itemType !== BaseItemKind.TvChannel)
|| itemType === BaseItemKind.AudioBook
|| itemType === 'AudioPodcast'
);
};
const enableAutoTimeProgressIndicator = (
itemType: NullableString,
itemStartDate: NullableString,
itemEndDate: NullableString
) => {
return (
(itemType === BaseItemKind.Program
|| itemType === 'Timer'
|| itemType === BaseItemKind.Recording)
&& Boolean(itemStartDate)
&& Boolean(itemEndDate)
);
};
const enablePlayedIndicator = (item: ItemDto) => {
return itemHelper.canMarkPlayed(item);
};
const useIndicator = (item: ItemDto) => {
const getMediaSourceIndicator = () => {
const mediaSourceCount = item.MediaSourceCount ?? 0;
if (mediaSourceCount > 1) {
return <Box className='mediaSourceIndicator'>{mediaSourceCount}</Box>;
}
return null;
};
const getMissingIndicator = () => {
if (
item.Type === BaseItemKind.Episode
&& item.LocationType === LocationType.Virtual
) {
if (item.PremiereDate) {
try {
const premiereDate = datetime
.parseISO8601Date(item.PremiereDate)
.getTime();
if (premiereDate > new Date().getTime()) {
return <Box className='unairedIndicator'>Unaired</Box>;
}
} catch (err) {
console.error(err);
}
}
return <Box className='missingIndicator'>Missing</Box>;
}
return null;
};
const getTimerIndicator = (className?: string) => {
const indicatorIconClass = classNames('timerIndicator', className);
let status;
if (item.Type === 'SeriesTimer') {
return <FiberSmartRecordIcon className={indicatorIconClass} />;
} else if (item.TimerId || item.SeriesTimerId) {
status = item.Status || 'Cancelled';
} else if (item.Type === 'Timer') {
status = item.Status;
} else {
return null;
}
if (item.SeriesTimerId) {
return (
<FiberSmartRecordIcon
className={`${indicatorIconClass} ${
status === 'Cancelled' ? 'timerIndicator-inactive' : ''
}`}
/>
);
}
return <FiberManualRecordIcon className={indicatorIconClass} />;
};
const getTypeIndicator = () => {
const icon = getTypeIcon(item.Type);
if (icon) {
return <Box className='indicator videoIndicator'>{icon}</Box>;
}
return null;
};
const getChildCountIndicator = () => {
const childCount = item.ChildCount ?? 0;
if (childCount > 1) {
return (
<Box className='countIndicator indicator childCountIndicator'>
{datetime.toLocaleString(item.ChildCount)}
</Box>
);
}
return null;
};
const getPlayedIndicator = () => {
if (enablePlayedIndicator(item)) {
const userData = item.UserData || {};
if (userData.UnplayedItemCount) {
return (
<Box className='countIndicator indicator unplayedItemCount'>
{datetime.toLocaleString(userData.UnplayedItemCount)}
</Box>
);
}
if (
(userData.PlayedPercentage
&& userData.PlayedPercentage >= 100)
|| userData.Played
) {
return (
<Box className='playedIndicator indicator'>
<CheckIcon className='indicatorIcon' />
</Box>
);
}
}
return null;
};
const getProgress = (pct: number, progressOptions?: ProgressOptions) => {
const progressBarClass = classNames(
'itemLinearProgress',
progressOptions?.containerClass
);
return (
<LinearProgress
className={progressBarClass}
variant='determinate'
value={pct}
sx={{
[`& .${linearProgressClasses.bar}`]: {
borderRadius: 5,
backgroundColor: '#00a4dc'
}
}}
/>
);
};
const getProgressBar = (progressOptions?: ProgressOptions) => {
if (
enableProgressIndicator(item.Type, item.MediaType)
&& item.Type !== BaseItemKind.Recording
) {
const playedPercentage = progressOptions?.userData?.PlayedPercentage ?
progressOptions.userData.PlayedPercentage :
item?.UserData?.PlayedPercentage;
if (playedPercentage && playedPercentage < 100) {
return getProgress(playedPercentage);
}
}
if (
enableAutoTimeProgressIndicator(
item.Type,
item.StartDate,
item.EndDate
)
) {
let startDate = 0;
let endDate = 1;
try {
startDate = datetime.parseISO8601Date(item.StartDate).getTime();
endDate = datetime.parseISO8601Date(item.EndDate).getTime();
} catch (err) {
console.error(err);
}
const now = new Date().getTime();
const total = endDate - startDate;
const pct = 100 * ((now - startDate) / total);
if (pct > 0 && pct < 100) {
const isRecording =
item.Type === 'Timer'
|| item.Type === BaseItemKind.Recording
|| Boolean(item.TimerId);
return (
<AutoTimeProgressBar
pct={pct}
progressOptions={progressOptions}
isRecording={isRecording}
starTtime={startDate}
endTtime={endDate}
dataAutoMode='time'
/>
);
}
}
return null;
};
return {
getProgress,
getProgressBar,
getMediaSourceIndicator,
getMissingIndicator,
getTimerIndicator,
getTypeIndicator,
getChildCountIndicator,
getPlayedIndicator
};
};
export default useIndicator;

View file

@ -10,6 +10,24 @@ import { playbackManager } from './playback/playbackmanager';
import ServerConnections from './ServerConnections';
import toast from './toast/toast';
import * as userSettings from '../scripts/settings/userSettings';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
function getDeleteLabel(type) {
switch (type) {
case BaseItemKind.Series:
return globalize.translate('DeleteSeries');
case BaseItemKind.Episode:
return globalize.translate('DeleteEpisode');
case BaseItemKind.Playlist:
case BaseItemKind.BoxSet:
return globalize.translate('Delete');
default:
return globalize.translate('DeleteMedia');
}
}
export function getCommands(options) {
const item = options.item;
@ -160,19 +178,11 @@ export function getCommands(options) {
}
if (item.CanDelete && options.deleteItem !== false) {
if (item.Type === 'Playlist' || item.Type === 'BoxSet') {
commands.push({
name: globalize.translate('Delete'),
id: 'delete',
icon: 'delete'
});
} else {
commands.push({
name: globalize.translate('DeleteMedia'),
id: 'delete',
icon: 'delete'
});
}
commands.push({
name: getDeleteLabel(item.Type),
id: 'delete',
icon: 'delete'
});
}
// Books are promoted to major download Button and therefor excluded in the context menu
@ -214,11 +224,7 @@ export function getCommands(options) {
});
}
if (canEdit && item.MediaType === 'Video' && item.Type !== 'TvChannel' && item.Type !== 'Program'
&& item.LocationType !== 'Virtual'
&& !(item.Type === 'Recording' && item.Status !== 'Completed')
&& options.editSubtitles !== false
) {
if (itemHelper.canEditSubtitles(user, item) && options.editSubtitles !== false) {
commands.push({
name: globalize.translate('EditSubtitles'),
id: 'editsubtitles',
@ -339,7 +345,8 @@ function executeCommand(item, id, options) {
break;
case 'addtoplaylist':
import('./playlisteditor/playlisteditor').then(({ default: PlaylistEditor }) => {
new PlaylistEditor({
const playlistEditor = new PlaylistEditor();
playlistEditor.show({
items: [itemId],
serverId: serverId
}).then(getResolveFunction(resolve, id, true), getResolveFunction(resolve, id));

View file

@ -1,6 +1,10 @@
import { appHost } from './apphost';
import globalize from '../scripts/globalize';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { LocationType } from '@jellyfin/sdk/lib/generated-client/models/location-type';
import { RecordingStatus } from '@jellyfin/sdk/lib/generated-client/models/recording-status';
import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
export function getDisplayName(item, options = {}) {
if (!item) {
@ -155,6 +159,33 @@ export function canEditImages (user, item) {
return itemType !== 'Timer' && itemType !== 'SeriesTimer' && canEdit(user, item) && !isLocalItem(item);
}
export function canEditSubtitles (user, item) {
if (item.MediaType !== MediaType.Video) {
return false;
}
const itemType = item.Type;
if (itemType === BaseItemKind.Recording && item.Status !== RecordingStatus.Completed) {
return false;
}
if (itemType === BaseItemKind.TvChannel
|| itemType === BaseItemKind.Program
|| itemType === 'Timer'
|| itemType === 'SeriesTimer'
|| itemType === BaseItemKind.UserRootFolder
|| itemType === BaseItemKind.UserView
) {
return false;
}
if (isLocalItem(item)) {
return false;
}
if (item.LocationType === LocationType.Virtual) {
return false;
}
return user.Policy.EnableSubtitleManagement
|| user.Policy.IsAdministrator;
}
export function canShare (item, user) {
if (item.Type === 'Program') {
return false;
@ -300,6 +331,7 @@ export default {
canIdentify: canIdentify,
canEdit: canEdit,
canEditImages: canEditImages,
canEditSubtitles,
canShare: canShare,
enableDateAddedDisplay: enableDateAddedDisplay,
canMarkPlayed: canMarkPlayed,

View file

@ -84,6 +84,7 @@ function getMediaSourceHtml(user, item, version) {
case 'Data':
case 'Subtitle':
case 'Video':
case 'Lyric':
translateString = stream.Type;
break;
case 'EmbeddedImage':
@ -145,10 +146,10 @@ function getMediaSourceHtml(user, item, version) {
if (stream.BitDepth) {
attributes.push(createAttribute(globalize.translate('MediaInfoBitDepth'), `${stream.BitDepth} bit`));
}
if (stream.VideoRange) {
if (stream.VideoRange && stream.Type === 'Video') {
attributes.push(createAttribute(globalize.translate('MediaInfoVideoRange'), stream.VideoRange));
}
if (stream.VideoRangeType) {
if (stream.VideoRangeType && stream.Type === 'Video') {
attributes.push(createAttribute(globalize.translate('MediaInfoVideoRangeType'), stream.VideoRangeType));
}
if (stream.VideoDoViTitle) {

View file

@ -391,8 +391,10 @@ export function setContentType(parent, contentType) {
}
if (contentType !== 'tvshows' && contentType !== 'movies' && contentType !== 'homevideos' && contentType !== 'musicvideos' && contentType !== 'mixed') {
parent.querySelector('.trickplaySettingsSection').classList.add('hide');
parent.querySelector('.chapterSettingsSection').classList.add('hide');
} else {
parent.querySelector('.trickplaySettingsSection').classList.remove('hide');
parent.querySelector('.chapterSettingsSection').classList.remove('hide');
}
@ -416,6 +418,8 @@ export function setContentType(parent, contentType) {
}
}
parent.querySelector('.chkUseReplayGainTagsContainer').classList.toggle('hide', contentType !== 'music');
parent.querySelector('.chkEnableLUFSScanContainer').classList.toggle('hide', contentType !== 'music');
if (contentType === 'tvshows') {
@ -516,6 +520,9 @@ export function getLibraryOptions(parent) {
EnablePhotos: parent.querySelector('.chkEnablePhotos').checked,
EnableRealtimeMonitor: parent.querySelector('.chkEnableRealtimeMonitor').checked,
EnableLUFSScan: parent.querySelector('.chkEnableLUFSScan').checked,
ExtractTrickplayImagesDuringLibraryScan: parent.querySelector('.chkExtractTrickplayDuringLibraryScan').checked,
EnableTrickplayImageExtraction: parent.querySelector('.chkExtractTrickplayImages').checked,
UseReplayGainTags: parent.querySelector('.chkUseReplayGainTags').checked,
ExtractChapterImagesDuringLibraryScan: parent.querySelector('.chkExtractChaptersDuringLibraryScan').checked,
EnableChapterImageExtraction: parent.querySelector('.chkExtractChapterImages').checked,
EnableInternetProviders: true,
@ -579,6 +586,9 @@ export function setLibraryOptions(parent, options) {
parent.querySelector('.chkEnablePhotos').checked = options.EnablePhotos;
parent.querySelector('.chkEnableRealtimeMonitor').checked = options.EnableRealtimeMonitor;
parent.querySelector('.chkEnableLUFSScan').checked = options.EnableLUFSScan;
parent.querySelector('.chkExtractTrickplayDuringLibraryScan').checked = options.ExtractTrickplayImagesDuringLibraryScan;
parent.querySelector('.chkExtractTrickplayImages').checked = options.EnableTrickplayImageExtraction;
parent.querySelector('.chkUseReplayGainTags').checked = options.UseReplayGainTags;
parent.querySelector('.chkExtractChaptersDuringLibraryScan').checked = options.ExtractChapterImagesDuringLibraryScan;
parent.querySelector('.chkExtractChapterImages').checked = options.EnableChapterImageExtraction;
parent.querySelector('#chkSaveLocal').checked = options.SaveLocalMetadata;

View file

@ -63,6 +63,14 @@
<div class="fieldDescription checkboxFieldDescription">${LabelEnableRealtimeMonitorHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription chkUseReplayGainTagsContainer advanced">
<label>
<input type="checkbox" is="emby-checkbox" class="chkUseReplayGainTags" checked />
<span>${LabelUseReplayGainTags}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelUseReplayGainTagsHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription chkEnableLUFSScanContainer advanced">
<label>
<input type="checkbox" is="emby-checkbox" class="chkEnableLUFSScan" checked />
@ -112,6 +120,25 @@
<div class="fieldDescription checkboxFieldDescription">${OptionAutomaticallyGroupSeriesHelp}</div>
</div>
<div class="trickplaySettingsSection hide">
<h2>${Trickplay}</h2>
<div class="checkboxContainer checkboxContainer-withDescription fldExtractTrickplayImages">
<label>
<input type="checkbox" is="emby-checkbox" class="chkExtractTrickplayImages" />
<span>${OptionExtractTrickplayImage}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${ExtractTrickplayImagesHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription fldExtractTrickplayDuringLibraryScan advanced">
<label>
<input type="checkbox" is="emby-checkbox" class="chkExtractTrickplayDuringLibraryScan" />
<span>${LabelExtractTrickplayDuringLibraryScan}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelExtractTrickplayDuringLibraryScanHelp}</div>
</div>
</div>
<div class="chapterSettingsSection hide">
<h2>${HeaderChapterImages}</h2>
<div class="checkboxContainer checkboxContainer-withDescription fldExtractChapterImages">

View file

@ -0,0 +1,32 @@
import React, { type FC } from 'react';
import useList from './useList';
import ListContent from './ListContent';
import ListWrapper from './ListWrapper';
import type { ItemDto } from 'types/base/models/item-dto';
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;

View file

@ -0,0 +1,106 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import React, { type 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/base/models/item-dto';
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>&nbsp;</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;

View file

@ -0,0 +1,34 @@
import React, { type 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;

View file

@ -0,0 +1,30 @@
import React, { type 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;

View file

@ -0,0 +1,103 @@
import React, { type 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/base/models/item-dto';
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;

View file

@ -0,0 +1,65 @@
import React, { type 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/base/models/item-dto';
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;

View file

@ -0,0 +1,30 @@
import React, { type 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;

View file

@ -0,0 +1,87 @@
import React, { type 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/base/models/item-dto';
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;

View file

@ -0,0 +1,48 @@
import classNames from 'classnames';
import React, { type 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={title || ''}
{...dataAttributes}
>
{children}
</Button>
);
} else {
return (
<Box data-index={index} className={className} {...dataAttributes}>
{children}
</Box>
);
}
};
export default ListWrapper;

View file

@ -0,0 +1,56 @@
import React, { type FC } from 'react';
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/base/models/item-dto';
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}>
{itemGroupTitle}
</ListGroupHeaderWrapper>
)}
{getItems.map((item) => renderListItem(item, index))}
</Box>
)
)}
</>
);
};
export default Lists;

View file

@ -0,0 +1,172 @@
import { Api } from '@jellyfin/sdk';
import { BaseItemKind, ImageType } from '@jellyfin/sdk/lib/generated-client';
import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
import globalize from 'scripts/globalize';
import type { ItemDto } from 'types/base/models/item-dto';
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 = getImageApi(api).getItemImageUrlById(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
);
}

View file

@ -0,0 +1,77 @@
import classNames from 'classnames';
import { getDataAttributes } from 'utils/items';
import layoutManager from 'components/layoutManager';
import type { ItemDto } from 'types/base/models/item-dto';
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;

View file

@ -0,0 +1,167 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import React from 'react';
import itemHelper from '../../itemHelper';
import datetime from 'scripts/datetime';
import ListTextWrapper from './ListTextWrapper';
import type { ItemDto } from 'types/base/models/item-dto';
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;

View file

@ -183,6 +183,7 @@
}
.listItemImage .cardImageIcon {
margin: auto;
font-size: 3em;
}

View file

@ -0,0 +1,25 @@
import React, { type 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<CaptionMediaInfoProps> = ({ className }) => {
const cssClass = classNames(
'mediaInfoItem',
'mediaInfoText',
'closedCaptionMediaInfoText',
className
);
return (
<Box className={cssClass}>
<ClosedCaptionIcon fontSize={'small'} />
</Box>
);
};
export default CaptionMediaInfo;

View file

@ -0,0 +1,25 @@
import React, { type FC } from 'react';
import classNames from 'classnames';
import Box from '@mui/material/Box';
interface CriticRatingMediaInfoProps {
className?: string;
criticRating: number;
}
const CriticRatingMediaInfo: FC<CriticRatingMediaInfoProps> = ({
className,
criticRating
}) => {
const cssClass = classNames(
'mediaInfoCriticRating',
'mediaInfoItem',
criticRating >= 60 ?
'mediaInfoCriticRatingFresh' :
'mediaInfoCriticRatingRotten',
className
);
return <Box className={cssClass}>{criticRating}</Box>;
};
export default CriticRatingMediaInfo;

View file

@ -0,0 +1,31 @@
import React, { type 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<EndsAtProps> = ({ 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 (
<Box className={cssClass}>
{globalize.translate('EndsAtValue', displayTime)}
</Box>
);
};
export default EndsAt;

View file

@ -0,0 +1,27 @@
import React, { type 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<MediaInfoItemProps> = ({ className, miscInfo }) => {
const cssClass = classNames(
'mediaInfoItem',
'mediaInfoText',
className,
miscInfo?.cssClass
);
return (
<Box className={cssClass}>
{miscInfo?.text}
</Box>
);
};
export default MediaInfoItem;

View file

@ -0,0 +1,103 @@
import React, { type 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/base/models/item-dto';
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<PrimaryMediaInfoProps> = ({
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) => (
<MediaInfoItem key={index} miscInfo={info} />
);
return (
<Box className={cssClass}>
{miscInfo.map((info, index) => renderMediaInfo(info, index))}
{isStarRatingEnabled && CommunityRating && (
<StarIcons communityRating={CommunityRating} />
)}
{HasSubtitles && isCaptionIndicatorEnabled && <CaptionMediaInfo />}
{CriticRating && isCriticRatingEnabled && (
<CriticRatingMediaInfo criticRating={CriticRating} />
)}
{isEndsAtEnabled
&& MediaType === 'Video'
&& RunTimeTicks
&& !StartDate && <EndsAt runTimeTicks={RunTimeTicks} />}
{isMissingIndicatorEnabled && (
getMissingIndicator()
)}
</Box>
);
};
export default PrimaryMediaInfo;

View file

@ -0,0 +1,31 @@
import React, { type FC } from 'react';
import classNames from 'classnames';
import StarIcon from '@mui/icons-material/Star';
import Box from '@mui/material/Box';
import { useTheme } from '@mui/material/styles';
interface StarIconsProps {
className?: string;
communityRating: number;
}
const StarIcons: FC<StarIconsProps> = ({ className, communityRating }) => {
const theme = useTheme();
const cssClass = classNames(
'mediaInfoItem',
'mediaInfoText',
'starRatingContainer',
className
);
return (
<Box className={cssClass}>
<StarIcon fontSize={'small'} sx={{
color: theme.palette.starIcon.main
}} />
{communityRating.toFixed(1)}
</Box>
);
};
export default StarIcons;

View file

@ -0,0 +1,523 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import * as userSettings from 'scripts/settings/userSettings';
import datetime from 'scripts/datetime';
import globalize from 'scripts/globalize';
import itemHelper from '../itemHelper';
import type { NullableNumber, NullableString } from 'types/base/common/shared/types';
import type { ItemDto } from 'types/base/models/item-dto';
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;

View file

@ -153,6 +153,7 @@ function onSubmit(e) {
DateCreated: getDateValue(form, '#txtDateAdded', 'DateCreated'),
EndDate: getDateValue(form, '#txtEndDate', 'EndDate'),
ProductionYear: form.querySelector('#txtProductionYear').value,
Height: form.querySelector('#selectHeight').value,
AspectRatio: form.querySelector('#txtOriginalAspectRatio').value,
Video3DFormat: form.querySelector('#select3dFormat').value,
@ -270,7 +271,7 @@ function showMoreMenu(context, button, user) {
} else if (result.updated) {
reload(context, item.Id, item.ServerId);
}
});
}).catch(() => { /* no-op */ });
});
}
@ -650,6 +651,12 @@ function setFieldVisibilities(context, item) {
hideElement('#fldPlaceOfBirth');
}
if (item.MediaType === 'Video' && item.Type === 'TvChannel') {
showElement('#fldHeight');
} else {
hideElement('#fldHeight');
}
if (item.MediaType === 'Video' && item.Type !== 'TvChannel') {
showElement('#fldOriginalAspectRatio');
} else {
@ -828,6 +835,8 @@ function fillItemInfo(context, item, parentalRatingOptions) {
const placeofBirth = item.ProductionLocations?.length ? item.ProductionLocations[0] : '';
context.querySelector('#txtPlaceOfBirth').value = placeofBirth;
context.querySelector('#selectHeight').value = item.Height || '';
context.querySelector('#txtOriginalAspectRatio').value = item.AspectRatio || '';
context.querySelector('#selectLanguage').value = item.PreferredMetadataLanguage || '';

View file

@ -142,6 +142,16 @@
<select is="emby-select" id="selectCustomRating" label="${LabelCustomRating}"></select>
</div>
</div>
<div id="fldHeight" class="selectContainer hide">
<select is="emby-select" id="selectHeight" label="${MediaInfoResolution}">
<option value="0"></option>
<option value="480">${ChannelResolutionSD}</option>
<option value="576">${ChannelResolutionSDPAL}</option>
<option value="720">${ChannelResolutionHD}</option>
<option value="1080">${ChannelResolutionFullHD}</option>
<option value="2160">${ChannelResolutionUHD4K}</option>
</select>
</div>
<div class="inlineForm">
<div id="fldOriginalAspectRatio" class="inputContainer hide">
<input is="emby-input" id="txtOriginalAspectRatio" type="text" label="${LabelOriginalAspectRatio}" />

View file

@ -6,7 +6,6 @@ import dom from '../../scripts/dom';
import './multiSelect.scss';
import ServerConnections from '../ServerConnections';
import alert from '../alert';
import PlaylistEditor from '../playlisteditor/playlisteditor';
import confirm from '../confirm/confirm';
import itemHelper from '../itemHelper';
import datetime from '../../scripts/datetime';
@ -269,9 +268,16 @@ function showMenuForSelectedItems(e) {
dispatchNeedsRefresh();
break;
case 'playlist':
new PlaylistEditor({
items: items,
serverId: serverId
import('../playlisteditor/playlisteditor').then(({ default: PlaylistEditor }) => {
const playlistEditor = new PlaylistEditor();
playlistEditor.show({
items: items,
serverId: serverId
}).catch(() => {
// Dialog closed
});
}).catch(err => {
console.error('[AddToPlaylist] failed to load playlist editor', err);
});
hideSelections();
dispatchNeedsRefresh();

View file

@ -1,3 +1,7 @@
import { PlaybackErrorCode } from '@jellyfin/sdk/lib/generated-client/models/playback-error-code.js';
import merge from 'lodash-es/merge';
import Screenfull from 'screenfull';
import Events from '../../utils/events.ts';
import datetime from '../../scripts/datetime';
import appSettings from '../../scripts/settings/appSettings';
@ -8,14 +12,15 @@ import * as userSettings from '../../scripts/settings/userSettings';
import globalize from '../../scripts/globalize';
import loading from '../loading/loading';
import { appHost } from '../apphost';
import Screenfull from 'screenfull';
import ServerConnections from '../ServerConnections';
import alert from '../alert';
import { PluginType } from '../../types/plugin.ts';
import { includesAny } from '../../utils/container.ts';
import { getItems } from '../../utils/jellyfin-apiclient/getItems.ts';
import { getItemBackdropImageUrl } from '../../utils/jellyfin-apiclient/backdropImage';
import merge from 'lodash-es/merge';
import { MediaError } from 'types/mediaError';
import { getMediaError } from 'utils/mediaError';
const UNLIMITED_ITEMS = -1;
@ -125,7 +130,7 @@ function getItemsForPlayback(serverId, query) {
} else {
query.Limit = query.Limit || 300;
}
query.Fields = 'Chapters';
query.Fields = ['Chapters', 'Trickplay'];
query.ExcludeLocationTypes = 'Virtual';
query.EnableTotalRecordCount = false;
query.CollapseBoxSetItems = false;
@ -588,9 +593,18 @@ function supportsDirectPlay(apiClient, item, mediaSource) {
return Promise.resolve(false);
}
/**
* @param {PlaybackManager} instance
* @param {import('@jellyfin/sdk/lib/generated-client/index.js').PlaybackInfoResponse} result
* @returns {boolean}
*/
function validatePlaybackInfoResult(instance, result) {
if (result.ErrorCode) {
showPlaybackInfoErrorMessage(instance, 'PlaybackError' + result.ErrorCode);
// NOTE: To avoid needing to retranslate the "NoCompatibleStream" message,
// we need to keep the key in the same format.
const errMessage = result.ErrorCode === PlaybackErrorCode.NoCompatibleStream ?
'PlaybackErrorNoCompatibleStream' : `PlaybackError.${result.ErrorCode}`;
showPlaybackInfoErrorMessage(instance, errMessage);
return false;
}
@ -1720,7 +1734,8 @@ class PlaybackManager {
streamInfo.resetSubtitleOffset = false;
if (!streamInfo.url) {
showPlaybackInfoErrorMessage(self, 'PlaybackErrorNoCompatibleStream');
cancelPlayback();
showPlaybackInfoErrorMessage(self, `PlaybackError.${MediaError.NO_MEDIA_ERROR}`);
return;
}
@ -1768,8 +1783,8 @@ class PlaybackManager {
playerData.isChangingStream = false;
onPlaybackError.call(player, e, {
type: 'mediadecodeerror',
streamInfo: streamInfo
type: getMediaError(e),
streamInfo
});
});
}
@ -1858,7 +1873,7 @@ class PlaybackManager {
IsVirtualUnaired: false,
IsMissing: false,
UserId: apiClient.getCurrentUserId(),
Fields: 'Chapters'
Fields: ['Chapters', 'Trickplay']
}).then(function (episodesResult) {
const originalResults = episodesResult.Items;
const isSeries = firstItem.Type === 'Series';
@ -1940,7 +1955,7 @@ class PlaybackManager {
IsVirtualUnaired: false,
IsMissing: false,
UserId: apiClient.getCurrentUserId(),
Fields: 'Chapters'
Fields: ['Chapters', 'Trickplay']
}).then(function (episodesResult) {
let foundItem = false;
episodesResult.Items = episodesResult.Items.filter(function (e) {
@ -2179,7 +2194,7 @@ class PlaybackManager {
// If it's still null then there's nothing to play
if (!firstItem) {
showPlaybackInfoErrorMessage(self, 'PlaybackErrorNoCompatibleStream');
showPlaybackInfoErrorMessage(self, `PlaybackError.${MediaError.NO_MEDIA_ERROR}`);
return Promise.reject();
}
@ -2551,8 +2566,8 @@ class PlaybackManager {
onPlaybackStarted(player, playOptions, streamInfo, mediaSource);
setTimeout(function () {
onPlaybackError.call(player, err, {
type: 'mediadecodeerror',
streamInfo: streamInfo
type: getMediaError(err),
streamInfo
});
}, 100);
});
@ -2785,7 +2800,7 @@ class PlaybackManager {
return mediaSource;
}
} else {
showPlaybackInfoErrorMessage(self, 'PlaybackErrorNoCompatibleStream');
showPlaybackInfoErrorMessage(self, `PlaybackError.${MediaError.NO_MEDIA_ERROR}`);
return Promise.reject();
}
});
@ -3194,22 +3209,32 @@ class PlaybackManager {
}
}
/**
* @param {object} streamInfo
* @param {MediaError} errorType
* @param {boolean} currentlyPreventsVideoStreamCopy
* @param {boolean} currentlyPreventsAudioStreamCopy
* @returns {boolean} Returns true if the stream should be retried by transcoding.
*/
function enablePlaybackRetryWithTranscoding(streamInfo, errorType, currentlyPreventsVideoStreamCopy, currentlyPreventsAudioStreamCopy) {
// mediadecodeerror, medianotsupported, network, servererror
return streamInfo.mediaSource.SupportsTranscoding
&& (!currentlyPreventsVideoStreamCopy || !currentlyPreventsAudioStreamCopy);
}
/**
* Playback error handler.
* @param {Error} e
* @param {object} error
* @param {object} error.streamInfo
* @param {MediaError} error.type
*/
function onPlaybackError(e, error) {
const player = this;
error = error || {};
// network
// mediadecodeerror
// medianotsupported
const errorType = error.type;
console.debug('playbackmanager playback error type: ' + (errorType || ''));
console.warn('[playbackmanager] onPlaybackError:', e, error);
const streamInfo = error.streamInfo || getPlayerData(player).streamInfo;
@ -3235,8 +3260,7 @@ class PlaybackManager {
Events.trigger(self, 'playbackerror', [errorType]);
const displayErrorCode = 'NoCompatibleStream';
onPlaybackStopped.call(player, e, displayErrorCode);
onPlaybackStopped.call(player, e, `.${errorType}`);
}
function onPlaybackStopped(e, displayErrorCode) {

View file

@ -179,6 +179,7 @@ function loadForm(context, user, userSettings, systemInfo, apiClient) {
context.querySelector('.chkRememberAudioSelections').checked = user.Configuration.RememberAudioSelections || false;
context.querySelector('.chkRememberSubtitleSelections').checked = user.Configuration.RememberSubtitleSelections || false;
context.querySelector('.chkExternalVideoPlayer').checked = appSettings.enableSystemExternalPlayers();
context.querySelector('.chkLimitSupportedVideoResolution').checked = appSettings.limitSupportedVideoResolution();
setMaxBitrateIntoField(context.querySelector('.selectVideoInNetworkQuality'), true, 'Video');
setMaxBitrateIntoField(context.querySelector('.selectVideoInternetQuality'), false, 'Video');
@ -194,8 +195,8 @@ function loadForm(context, user, userSettings, systemInfo, apiClient) {
selectChromecastVersion.innerHTML = ccAppsHtml;
selectChromecastVersion.value = user.Configuration.CastReceiverId;
const selectLabelMaxVideoWidth = context.querySelector('.selectLabelMaxVideoWidth');
selectLabelMaxVideoWidth.value = appSettings.maxVideoWidth();
const selectMaxVideoWidth = context.querySelector('.selectMaxVideoWidth');
selectMaxVideoWidth.value = appSettings.maxVideoWidth();
const selectSkipForwardLength = context.querySelector('.selectSkipForwardLength');
fillSkipLengths(selectSkipForwardLength);
@ -212,7 +213,8 @@ function saveUser(context, user, userSettingsInstance, apiClient) {
appSettings.enableSystemExternalPlayers(context.querySelector('.chkExternalVideoPlayer').checked);
appSettings.maxChromecastBitrate(context.querySelector('.selectChromecastVideoQuality').value);
appSettings.maxVideoWidth(context.querySelector('.selectLabelMaxVideoWidth').value);
appSettings.maxVideoWidth(context.querySelector('.selectMaxVideoWidth').value);
appSettings.limitSupportedVideoResolution(context.querySelector('.chkLimitSupportedVideoResolution').checked);
setMaxBitrateFromField(context.querySelector('.selectVideoInNetworkQuality'), true, 'Video');
setMaxBitrateFromField(context.querySelector('.selectVideoInternetQuality'), false, 'Video');
@ -309,7 +311,7 @@ function embed(options, self) {
options.element.querySelector('.btnSave').classList.remove('hide');
}
options.element.querySelector('.selectLabelMaxVideoWidth').addEventListener('change', onMaxVideoWidthChange.bind(self));
options.element.querySelector('.selectMaxVideoWidth').addEventListener('change', onMaxVideoWidthChange.bind(self));
self.loadData();

View file

@ -43,7 +43,7 @@
</div>
<div class="selectContainer">
<select is="emby-select" class="selectLabelMaxVideoWidth" label="${LabelMaxVideoResolution}">
<select is="emby-select" class="selectMaxVideoWidth" label="${LabelMaxVideoResolution}">
<option value="0">${Auto}</option>
<option value="-1">${ScreenResolution}</option>
<option value="640">360p</option>
@ -54,6 +54,14 @@
<option value="7680">8K</option>
</select>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" class="chkLimitSupportedVideoResolution" />
<span>${LimitSupportedVideoResolution}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LimitSupportedVideoResolutionHelp}</div>
</div>
</div>
<div class="verticalSection verticalSection-extrabottompadding musicQualitySection hide">

View file

@ -222,7 +222,7 @@ function centerFocus(elem, horiz, on) {
}
export class PlaylistEditor {
constructor(options) {
show(options) {
const items = options.items || {};
currentServerId = options.serverId;

View file

@ -704,15 +704,20 @@ export default function () {
import('../playlisteditor/playlisteditor').then(({ default: PlaylistEditor }) => {
getSaveablePlaylistItems().then(function (items) {
const serverId = items.length ? items[0].ServerId : ApiClient.serverId();
new PlaylistEditor({
const playlistEditor = new PlaylistEditor();
playlistEditor.show({
items: items.map(function (i) {
return i.Id;
}),
serverId: serverId,
enableAddToPlayQueue: false,
defaultValue: 'new'
}).catch(() => {
// Dialog closed
});
});
}).catch(err => {
console.error('[savePlaylist] failed to load playlist editor', err);
});
}

View file

@ -4,7 +4,7 @@
*/
import dom from '../scripts/dom';
import browser from '../scripts/browser';
import appSettings from 'scripts/settings/appSettings';
import layoutManager from './layoutManager';
/**
@ -477,7 +477,7 @@ function doScroll(xScroller, scrollX, yScroller, scrollY, smooth) {
* Returns true if smooth scroll must be used.
*/
function useSmoothScroll() {
return !!browser.tizen;
return appSettings.enableSmoothScroll();
}
/**

View file

@ -2,7 +2,8 @@ import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import type { ApiClient } from 'jellyfin-apiclient';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import classNames from 'classnames';
import React, { FunctionComponent, useEffect, useState } from 'react';
import React, { type FC, useCallback, useEffect, useState } from 'react';
import { useDebounceValue } from 'usehooks-ts';
import globalize from '../../scripts/globalize';
import ServerConnections from '../ServerConnections';
@ -30,7 +31,7 @@ type LiveTVSearchResultsProps = {
/*
* React component to display search result rows for live tv library search
*/
const LiveTVSearchResults: FunctionComponent<LiveTVSearchResultsProps> = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: LiveTVSearchResultsProps) => {
const LiveTVSearchResults: FC<LiveTVSearchResultsProps> = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: LiveTVSearchResultsProps) => {
const [ movies, setMovies ] = useState<BaseItemDto[]>([]);
const [ episodes, setEpisodes ] = useState<BaseItemDto[]>([]);
const [ sports, setSports ] = useState<BaseItemDto[]>([]);
@ -38,23 +39,24 @@ const LiveTVSearchResults: FunctionComponent<LiveTVSearchResultsProps> = ({ serv
const [ news, setNews ] = useState<BaseItemDto[]>([]);
const [ programs, setPrograms ] = useState<BaseItemDto[]>([]);
const [ channels, setChannels ] = useState<BaseItemDto[]>([]);
const [ debouncedQuery ] = useDebounceValue(query, 500);
const getDefaultParameters = useCallback(() => ({
ParentId: parentId,
searchTerm: debouncedQuery,
Limit: 24,
Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount',
Recursive: true,
EnableTotalRecordCount: false,
ImageTypeLimit: 1,
IncludePeople: false,
IncludeMedia: false,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false
}), [ parentId, debouncedQuery ]);
useEffect(() => {
const getDefaultParameters = () => ({
ParentId: parentId,
searchTerm: query,
Limit: 24,
Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount',
Recursive: true,
EnableTotalRecordCount: false,
ImageTypeLimit: 1,
IncludePeople: false,
IncludeMedia: false,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false
});
const fetchItems = (apiClient: ApiClient, params = {}) => apiClient?.getItems(
apiClient?.getCurrentUserId(),
{
@ -73,65 +75,67 @@ const LiveTVSearchResults: FunctionComponent<LiveTVSearchResultsProps> = ({ serv
setPrograms([]);
setChannels([]);
if (query && collectionType === CollectionType.Livetv) {
const apiClient = ServerConnections.getApiClient(serverId);
// Movies row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: true
})
.then(result => setMovies(result.Items || []))
.catch(() => setMovies([]));
// Episodes row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: false,
IsSeries: true,
IsSports: false,
IsKids: false,
IsNews: false
})
.then(result => setEpisodes(result.Items || []))
.catch(() => setEpisodes([]));
// Sports row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsSports: true
})
.then(result => setSports(result.Items || []))
.catch(() => setSports([]));
// Kids row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsKids: true
})
.then(result => setKids(result.Items || []))
.catch(() => setKids([]));
// News row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsNews: true
})
.then(result => setNews(result.Items || []))
.catch(() => setNews([]));
// Programs row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: false,
IsSeries: false,
IsSports: false,
IsKids: false,
IsNews: false
})
.then(result => setPrograms(result.Items || []))
.catch(() => setPrograms([]));
// Channels row
fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' })
.then(result => setChannels(result.Items || []))
.catch(() => setChannels([]));
if (!debouncedQuery || collectionType !== CollectionType.Livetv) {
return;
}
}, [collectionType, parentId, query, serverId]);
const apiClient = ServerConnections.getApiClient(serverId);
// Movies row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: true
})
.then(result => setMovies(result.Items || []))
.catch(() => setMovies([]));
// Episodes row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: false,
IsSeries: true,
IsSports: false,
IsKids: false,
IsNews: false
})
.then(result => setEpisodes(result.Items || []))
.catch(() => setEpisodes([]));
// Sports row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsSports: true
})
.then(result => setSports(result.Items || []))
.catch(() => setSports([]));
// Kids row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsKids: true
})
.then(result => setKids(result.Items || []))
.catch(() => setKids([]));
// News row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsNews: true
})
.then(result => setNews(result.Items || []))
.catch(() => setNews([]));
// Programs row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: false,
IsSeries: false,
IsSports: false,
IsKids: false,
IsNews: false
})
.then(result => setPrograms(result.Items || []))
.catch(() => setPrograms([]));
// Channels row
fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' })
.then(result => setChannels(result.Items || []))
.catch(() => setChannels([]));
}, [collectionType, debouncedQuery, getDefaultParameters, parentId, serverId]);
return (
<div
@ -139,7 +143,7 @@ const LiveTVSearchResults: FunctionComponent<LiveTVSearchResultsProps> = ({ serv
'searchResults',
'padded-bottom-page',
'padded-top',
{ 'hide': !query || collectionType !== CollectionType.Livetv }
{ 'hide': !debouncedQuery || collectionType !== CollectionType.Livetv }
)}
>
<SearchResultsRow

View file

@ -1,89 +1,61 @@
import debounce from 'lodash-es/debounce';
import React, { FunctionComponent, useCallback, useEffect, useMemo, useRef } from 'react';
import React, { type ChangeEvent, type FC, useCallback } from 'react';
import AlphaPicker from '../alphaPicker/AlphaPickerComponent';
import Input from 'elements/emby-input/Input';
import globalize from '../../scripts/globalize';
import 'material-design-icons-iconfont';
import '../../elements/emby-input/emby-input';
import '../../styles/flexstyles.scss';
import './searchfields.scss';
import layoutManager from '../layoutManager';
import browser from '../../scripts/browser';
// There seems to be some compatibility issues here between
// React and our legacy web components, so we need to inject
// them as an html string for now =/
const createInputElement = () => ({
__html: `<input
is="emby-input"
class="searchfields-txtSearch"
type="text"
data-keyboard="true"
placeholder="${globalize.translate('Search')}"
autocomplete="off"
maxlength="40"
autofocus
/>`
});
import 'material-design-icons-iconfont';
const normalizeInput = (value = '') => value.trim();
import '../../styles/flexstyles.scss';
import './searchfields.scss';
type SearchFieldsProps = {
query: string,
onSearch?: (query: string) => void
};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const SearchFields: FunctionComponent<SearchFieldsProps> = ({ onSearch = () => {} }: SearchFieldsProps) => {
const element = useRef<HTMLDivElement>(null);
const getSearchInput = () => element?.current?.querySelector<HTMLInputElement>('.searchfields-txtSearch');
const debouncedOnSearch = useMemo(() => debounce(onSearch, 400), [onSearch]);
useEffect(() => {
getSearchInput()?.addEventListener('input', e => {
debouncedOnSearch(normalizeInput((e.target as HTMLInputElement).value));
});
getSearchInput()?.focus();
return () => {
debouncedOnSearch.cancel();
};
}, [debouncedOnSearch]);
const SearchFields: FC<SearchFieldsProps> = ({
onSearch = () => { /* no-op */ },
query
}: SearchFieldsProps) => {
const onAlphaPicked = useCallback((e: Event) => {
const value = (e as CustomEvent).detail.value;
const searchInput = getSearchInput();
if (!searchInput) {
console.error('Unexpected null reference');
return;
}
if (value === 'backspace') {
const currentValue = searchInput.value;
searchInput.value = currentValue.length ? currentValue.substring(0, currentValue.length - 1) : '';
onSearch(query.length ? query.substring(0, query.length - 1) : '');
} else {
searchInput.value += value;
onSearch(query + value);
}
}, [ onSearch, query ]);
searchInput.dispatchEvent(new CustomEvent('input', { bubbles: true }));
}, []);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
onSearch(e.target.value);
}, [ onSearch ]);
return (
<div
className='padded-left padded-right searchFields'
ref={element}
>
<div className='padded-left padded-right searchFields'>
<div className='searchFieldsInner flex align-items-center justify-content-center'>
<span className='searchfields-icon material-icons search' aria-hidden='true' />
<div
className='inputContainer flex-grow'
style={{ marginBottom: 0 }}
dangerouslySetInnerHTML={createInputElement()}
/>
>
<Input
id='searchTextInput'
className='searchfields-txtSearch'
type='text'
data-keyboard='true'
placeholder={globalize.translate('Search')}
autoComplete='off'
maxLength={40}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
value={query}
onChange={onChange}
/>
</div>
</div>
{layoutManager.tv && !browser.tv
&& <AlphaPicker onAlphaPicked={onAlphaPicked} />

View file

@ -1,8 +1,9 @@
import type { BaseItemDto, BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client';
import type { ApiClient } from 'jellyfin-apiclient';
import classNames from 'classnames';
import React, { FunctionComponent, useCallback, useEffect, useState } from 'react';
import React, { type FC, useCallback, useEffect, useState } from 'react';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { useDebounceValue } from 'usehooks-ts';
import globalize from '../../scripts/globalize';
import ServerConnections from '../ServerConnections';
@ -30,7 +31,7 @@ const isTVShows = (collectionType: string) => collectionType === CollectionType.
/*
* React component to display search result rows for global search and non-live tv library search
*/
const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: SearchResultsProps) => {
const SearchResults: FC<SearchResultsProps> = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: SearchResultsProps) => {
const [ movies, setMovies ] = useState<BaseItemDto[]>([]);
const [ shows, setShows ] = useState<BaseItemDto[]>([]);
const [ episodes, setEpisodes ] = useState<BaseItemDto[]>([]);
@ -47,11 +48,12 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
const [ books, setBooks ] = useState<BaseItemDto[]>([]);
const [ people, setPeople ] = useState<BaseItemDto[]>([]);
const [ collections, setCollections ] = useState<BaseItemDto[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [ isLoading, setIsLoading ] = useState(false);
const [ debouncedQuery ] = useDebounceValue(query, 500);
const getDefaultParameters = useCallback(() => ({
ParentId: parentId,
searchTerm: query,
searchTerm: debouncedQuery,
Limit: 100,
Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount',
Recursive: true,
@ -62,7 +64,7 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false
}), [parentId, query]);
}), [ parentId, debouncedQuery ]);
const fetchArtists = useCallback((apiClient: ApiClient, params = {}) => (
apiClient?.getArtists(
@ -97,6 +99,10 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
).then(ensureNonNullItems)
), [getDefaultParameters]);
useEffect(() => {
if (query) setIsLoading(true);
}, [ query ]);
useEffect(() => {
// Reset state
setMovies([]);
@ -116,13 +122,11 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
setPeople([]);
setCollections([]);
if (!query) {
if (!debouncedQuery) {
setIsLoading(false);
return;
}
setIsLoading(true);
const apiClient = ServerConnections.getApiClient(serverId);
const fetchPromises = [];
@ -230,7 +234,7 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
console.error('An error occurred while fetching data:', error);
setIsLoading(false); // Set loading to false even if an error occurs
});
}, [collectionType, fetchArtists, fetchItems, fetchPeople, query, serverId]);
}, [collectionType, fetchArtists, fetchItems, fetchPeople, debouncedQuery, serverId]);
const allEmpty = [movies, shows, episodes, videos, programs, channels, playlists, artists, albums, songs, photoAlbums, photos, audioBooks, books, people, collections].every(arr => arr.length === 0);
@ -240,7 +244,7 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
'searchResults',
'padded-bottom-page',
'padded-top',
{ 'hide': !query || collectionType === CollectionType.Livetv }
{ 'hide': !debouncedQuery || collectionType === CollectionType.Livetv }
)}
>
{isLoading ? (
@ -335,8 +339,10 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
cardOptions={{ coverImage: true }}
/>
{allEmpty && query && !isLoading && (
<div className='sorry-text'>{globalize.translate('SearchResultsEmpty', query)}</div>
{allEmpty && debouncedQuery && !isLoading && (
<div className='noItemsMessage centerMessage'>
{globalize.translate('SearchResultsEmpty', debouncedQuery)}
</div>
)}
</>
)}

View file

@ -9,14 +9,3 @@
font-size: 2em;
align-self: flex-end;
}
.sorry-text {
font-size: 2em;
text-align: center;
font-family: inherit;
width: 100%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

View file

@ -282,11 +282,15 @@ function executeAction(card, target, action) {
function addToPlaylist(item) {
import('./playlisteditor/playlisteditor').then(({ default: PlaylistEditor }) => {
new PlaylistEditor().show({
const playlistEditor = new PlaylistEditor();
playlistEditor.show({
items: [item.Id],
serverId: item.ServerId
}).catch(() => {
// Dialog closed
});
}).catch(err => {
console.error('[addToPlaylist] failed to load playlist editor', err);
});
}