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 trickplay-new

This commit is contained in:
Nick 2024-03-08 21:47:06 -08:00 committed by GitHub
commit 2dfc0aa061
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
137 changed files with 5909 additions and 2829 deletions

View file

@ -1,26 +1,15 @@
import { Devices, Analytics, Input, ExpandLess, ExpandMore } from '@mui/icons-material';
import Collapse from '@mui/material/Collapse';
import { Devices, Analytics, Input } from '@mui/icons-material';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader';
import React from 'react';
import { useLocation } from 'react-router-dom';
import ListItemLink from 'components/ListItemLink';
import globalize from 'scripts/globalize';
const DLNA_PATHS = [
'/dashboard/dlna',
'/dashboard/dlna/profiles'
];
const DevicesDrawerSection = () => {
const location = useLocation();
const isDlnaSectionOpen = DLNA_PATHS.includes(location.pathname);
return (
<List
aria-labelledby='devices-subheader'
@ -47,24 +36,13 @@ const DevicesDrawerSection = () => {
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/dashboard/dlna' selected={false}>
<ListItemLink to='/dashboard/dlna'>
<ListItemIcon>
<Input />
</ListItemIcon>
<ListItemText primary={'DLNA'} />
{isDlnaSectionOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemLink>
</ListItem>
<Collapse in={isDlnaSectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding>
<ListItemLink to='/dashboard/dlna' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('Settings')} />
</ListItemLink>
<ListItemLink to='/dashboard/dlna/profiles' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabProfiles')} />
</ListItemLink>
</List>
</Collapse>
</List>
);
};

View file

@ -2,6 +2,7 @@ import { AsyncRouteType, type AsyncRoute } from 'components/router/AsyncRoute';
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'activity', type: AsyncRouteType.Dashboard },
{ path: 'dlna', type: AsyncRouteType.Dashboard },
{ path: 'notifications', type: AsyncRouteType.Dashboard },
{ path: 'users', type: AsyncRouteType.Dashboard },
{ path: 'users/access', type: AsyncRouteType.Dashboard },

View file

@ -31,24 +31,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
controller: 'dashboard/devices/device',
view: 'dashboard/devices/device.html'
}
}, {
path: 'dlna/profiles/edit',
pageProps: {
controller: 'dashboard/dlna/profile',
view: 'dashboard/dlna/profile.html'
}
}, {
path: 'dlna/profiles',
pageProps: {
controller: 'dashboard/dlna/profiles',
view: 'dashboard/dlna/profiles.html'
}
}, {
path: 'dlna',
pageProps: {
controller: 'dashboard/dlna/settings',
view: 'dashboard/dlna/settings.html'
}
}, {
path: 'plugins/add',
pageProps: {

View file

@ -8,8 +8,8 @@ export const REDIRECTS: Redirect[] = [
{ from: 'dashboardgeneral.html', to: '/dashboard/settings' },
{ from: 'device.html', to: '/dashboard/devices/edit' },
{ from: 'devices.html', to: '/dashboard/devices' },
{ from: 'dlnaprofile.html', to: '/dashboard/dlna/profiles/edit' },
{ from: 'dlnaprofiles.html', to: '/dashboard/dlna/profiles' },
{ from: 'dlnaprofile.html', to: '/dashboard/dlna' },
{ from: 'dlnaprofiles.html', to: '/dashboard/dlna' },
{ from: 'dlnasettings.html', to: '/dashboard/dlna' },
{ from: 'edititemmetadata.html', to: '/metadata' },
{ from: 'encodingsettings.html', to: '/dashboard/playback/transcoding' },

View file

@ -0,0 +1,33 @@
import Alert from '@mui/material/Alert/Alert';
import Box from '@mui/material/Box/Box';
import Button from '@mui/material/Button/Button';
import React from 'react';
import { Link } from 'react-router-dom';
import Page from 'components/Page';
import globalize from 'scripts/globalize';
const DlnaPage = () => (
<Page
id='dlnaSettingsPage'
title='DLNA'
className='mainAnimatedPage type-interior'
>
<div className='content-primary'>
<h2>DLNA</h2>
<Alert severity='info'>
<Box sx={{ marginBottom: 2 }}>
{globalize.translate('DlnaMovedMessage')}
</Box>
<Button
component={Link}
to='/dashboard/plugins/add?name=DLNA&guid=33eba9cd7da14720967fdd7dae7b74a1'
>
{globalize.translate('GetThePlugin')}
</Button>
</Alert>
</div>
</Page>
);
export default DlnaPage;

View file

@ -1,23 +1,13 @@
import Alert from '@mui/material/Alert/Alert';
import Box from '@mui/material/Box/Box';
import Button from '@mui/material/Button/Button';
import React from 'react';
import { Link } from 'react-router-dom';
import Page from 'components/Page';
import globalize from 'scripts/globalize';
const PluginLink = () => (
<div
dangerouslySetInnerHTML={{
__html: `<a
is='emby-linkbutton'
class='button-link'
href='#/dashboard/plugins/add?name=Webhook&guid=71552a5a5c5c4350a2aeebe451a30173'
>
${globalize.translate('GetThePlugin')}
</a>`
}}
/>
);
const Notifications = () => (
const NotificationsPage = () => (
<Page
id='notificationSettingPage'
title={globalize.translate('Notifications')}
@ -25,12 +15,20 @@ const Notifications = () => (
>
<div className='content-primary'>
<h2>{globalize.translate('Notifications')}</h2>
<p>
{globalize.translate('NotificationsMovedMessage')}
</p>
<PluginLink />
<Alert severity='info'>
<Box sx={{ marginBottom: 2 }}>
{globalize.translate('NotificationsMovedMessage')}
</Box>
<Button
component={Link}
to='/dashboard/plugins/add?name=Webhook&guid=71552a5a5c5c4350a2aeebe451a30173'
>
{globalize.translate('GetThePlugin')}
</Button>
</Alert>
</div>
</Page>
);
export default Notifications;
export default NotificationsPage;

View file

@ -1,11 +1,11 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import type { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import React, { FC } from 'react';
import { useGetGenres } from 'hooks/useFetchItems';
import globalize from 'scripts/globalize';
import Loading from 'components/loading/LoadingComponent';
import GenresSectionContainer from './GenresSectionContainer';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { ParentId } from 'types/library';
import type { ParentId } from 'types/library';
interface GenresItemsContainerProps {
parentId: ParentId;

View file

@ -1,18 +1,17 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields';
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order';
import escapeHTML from 'escape-html';
import React, { FC } from 'react';
import React, { type FC } from 'react';
import { useGetItems } from 'hooks/useFetchItems';
import Loading from 'components/loading/LoadingComponent';
import { appRouter } from 'components/router/appRouter';
import SectionContainer from './SectionContainer';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { ParentId } from 'types/library';
import { CardShape } from 'utils/card';
import type { ParentId } from 'types/library';
interface GenresSectionContainerProps {
parentId: ParentId;
@ -60,7 +59,7 @@ const GenresSectionContainer: FC<GenresSectionContainerProps> = ({
}
return <SectionContainer
sectionTitle={escapeHTML(genre.Name)}
sectionTitle={genre.Name || ''}
items={itemsResult?.Items || []}
url={getRouteUrl(genre)}
cardOptions={{
@ -69,7 +68,7 @@ const GenresSectionContainer: FC<GenresSectionContainerProps> = ({
showTitle: true,
centerText: true,
cardLayout: false,
shape: collectionType === CollectionType.Music ? 'overflowSquare' : 'overflowPortrait',
shape: collectionType === CollectionType.Music ? CardShape.SquareOverflow : CardShape.PortraitOverflow,
showParentTitle: collectionType === CollectionType.Music,
showYear: collectionType !== CollectionType.Music
}}

View file

@ -1,8 +1,8 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import type { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import React, { FC } from 'react';
import GenresItemsContainer from './GenresItemsContainer';
import { ParentId } from 'types/library';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import type { ParentId } from 'types/library';
interface GenresViewProps {
parentId: ParentId;

View file

@ -1,17 +1,16 @@
import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { ImageType } from '@jellyfin/sdk/lib/generated-client';
import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
import React, { FC, useCallback } from 'react';
import React, { type FC, useCallback } from 'react';
import Box from '@mui/material/Box';
import classNames from 'classnames';
import { useLocalStorage } from 'hooks/useLocalStorage';
import { useGetItem, useGetItemsViewByType } from 'hooks/useFetchItems';
import { getDefaultLibraryViewSettings, getSettingsKey } from 'utils/items';
import { CardShape } from 'utils/card';
import Loading from 'components/loading/LoadingComponent';
import listview from 'components/listview/listview';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import { playbackManager } from 'components/playback/playbackmanager';
import globalize from 'scripts/globalize';
import ItemsContainer from 'elements/emby-itemscontainer/ItemsContainer';
import AlphabetPicker from './AlphabetPicker';
import FilterButton from './filter/FilterButton';
@ -22,12 +21,13 @@ import QueueButton from './QueueButton';
import ShuffleButton from './ShuffleButton';
import SortButton from './SortButton';
import GridListViewButton from './GridListViewButton';
import { LibraryViewSettings, ParentId, ViewMode } from 'types/library';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import NoItemsMessage from 'components/common/NoItemsMessage';
import Lists from 'components/listview/List/Lists';
import Cards from 'components/cardbuilder/Card/Cards';
import { LibraryTab } from 'types/libraryTab';
import { CardOptions } from 'types/cardOptions';
import { ListOptions } from 'types/listOptions';
import { type LibraryViewSettings, type ParentId, ViewMode } from 'types/library';
import type { CardOptions } from 'types/cardOptions';
import type { ListOptions } from 'types/listOptions';
interface ItemsViewProps {
viewType: LibraryTab;
@ -110,18 +110,18 @@ const ItemsView: FC<ItemsViewProps> = ({
let preferLogo;
if (libraryViewSettings.ImageType === ImageType.Banner) {
shape = 'banner';
shape = CardShape.Banner;
} else if (libraryViewSettings.ImageType === ImageType.Disc) {
shape = 'square';
shape = CardShape.Square;
preferDisc = true;
} else if (libraryViewSettings.ImageType === ImageType.Logo) {
shape = 'backdrop';
shape = CardShape.Backdrop;
preferLogo = true;
} else if (libraryViewSettings.ImageType === ImageType.Thumb) {
shape = 'backdrop';
shape = CardShape.Backdrop;
preferThumb = true;
} else {
shape = 'auto';
shape = CardShape.Auto;
}
const cardOptions: CardOptions = {
@ -135,9 +135,9 @@ const ItemsView: FC<ItemsViewProps> = ({
preferThumb: preferThumb,
preferDisc: preferDisc,
preferLogo: preferLogo,
overlayPlayButton: false,
overlayMoreButton: true,
overlayText: !libraryViewSettings.ShowTitle
overlayText: !libraryViewSettings.ShowTitle,
imageType: libraryViewSettings.ImageType,
queryKey: ['ItemsViewByType']
};
if (
@ -146,20 +146,26 @@ const ItemsView: FC<ItemsViewProps> = ({
|| viewType === LibraryTab.Episodes
) {
cardOptions.showParentTitle = libraryViewSettings.ShowTitle;
cardOptions.overlayPlayButton = true;
} else if (viewType === LibraryTab.Artists) {
cardOptions.lines = 1;
cardOptions.showYear = false;
cardOptions.overlayPlayButton = true;
} else if (viewType === LibraryTab.Channels) {
cardOptions.shape = 'square';
cardOptions.shape = CardShape.Square;
cardOptions.showDetailsMenu = true;
cardOptions.showCurrentProgram = true;
cardOptions.showCurrentProgramTime = true;
} else if (viewType === LibraryTab.SeriesTimers) {
cardOptions.defaultShape = 'portrait';
cardOptions.preferThumb = 'auto';
cardOptions.shape = CardShape.Backdrop;
cardOptions.showSeriesTimerTime = true;
cardOptions.showSeriesTimerChannel = true;
cardOptions.overlayMoreButton = true;
cardOptions.lines = 3;
} else if (viewType === LibraryTab.Movies) {
cardOptions.overlayPlayButton = true;
} else if (viewType === LibraryTab.Series || viewType === LibraryTab.Networks) {
cardOptions.overlayMoreButton = true;
}
return cardOptions;
@ -172,27 +178,32 @@ const ItemsView: FC<ItemsViewProps> = ({
viewType
]);
const getItemsHtml = useCallback(() => {
let html = '';
const getItems = useCallback(() => {
if (!itemsResult?.Items?.length) {
return <NoItemsMessage noItemsMessage={noItemsMessage} />;
}
if (libraryViewSettings.ViewMode === ViewMode.ListView) {
html = listview.getListViewHtml(getListOptions());
} else {
html = cardBuilder.getCardsHtml(
itemsResult?.Items ?? [],
getCardOptions()
return (
<Lists
items={itemsResult?.Items ?? []}
listOptions={getListOptions()}
/>
);
}
if (!itemsResult?.Items?.length) {
html += '<div class="noItemsMessage centerMessage">';
html += '<h1>' + globalize.translate('MessageNothingHere') + '</h1>';
html += '<p>' + globalize.translate(noItemsMessage) + '</p>';
html += '</div>';
}
return html;
}, [libraryViewSettings.ViewMode, itemsResult?.Items, getListOptions, getCardOptions, noItemsMessage]);
return (
<Cards
items={itemsResult?.Items ?? []}
cardOptions={getCardOptions()}
/>
);
}, [
libraryViewSettings.ViewMode,
itemsResult?.Items,
getListOptions,
getCardOptions,
noItemsMessage
]);
const totalRecordCount = itemsResult?.TotalRecordCount ?? 0;
const items = itemsResult?.Items ?? [];
@ -289,8 +300,10 @@ const ItemsView: FC<ItemsViewProps> = ({
className={itemsContainerClass}
parentId={parentId}
reloadItems={refetch}
getItemsHtml={getItemsHtml}
/>
queryKey={['ItemsViewByType']}
>
{getItems()}
</ItemsContainer>
)}
{isPaginationEnabled && (

View file

@ -1,13 +1,13 @@
import React, { FC } from 'react';
import React, { type FC } from 'react';
import SuggestionsSectionView from './SuggestionsSectionView';
import UpcomingView from './UpcomingView';
import GenresView from './GenresView';
import ItemsView from './ItemsView';
import { LibraryTab } from 'types/libraryTab';
import { ParentId } from 'types/library';
import { LibraryTabContent } from 'types/libraryTabContent';
import GuideView from './GuideView';
import ProgramsSectionView from './ProgramsSectionView';
import { LibraryTab } from 'types/libraryTab';
import type { ParentId } from 'types/library';
import type { LibraryTabContent } from 'types/libraryTabContent';
interface PageTabContentProps {
parentId: ParentId;

View file

@ -1,11 +1,12 @@
import React, { FC } from 'react';
import React, { type FC } from 'react';
import { useGetProgramsSectionsWithItems, useGetTimers } from 'hooks/useFetchItems';
import { appRouter } from 'components/router/appRouter';
import globalize from 'scripts/globalize';
import Loading from 'components/loading/LoadingComponent';
import SectionContainer from './SectionContainer';
import { ParentId } from 'types/library';
import { Section, SectionType } from 'types/sections';
import { CardShape } from 'utils/card';
import type { ParentId } from 'types/library';
import type { Section, SectionType } from 'types/sections';
interface ProgramsSectionViewProps {
parentId: ParentId;
@ -18,7 +19,7 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
sectionType,
isUpcomingRecordingsEnabled = false
}) => {
const { isLoading, data: sectionsWithItems } = useGetProgramsSectionsWithItems(parentId, sectionType);
const { isLoading, data: sectionsWithItems, refetch } = useGetProgramsSectionsWithItems(parentId, sectionType);
const {
isLoading: isUpcomingRecordingsLoading,
data: upcomingRecordings
@ -60,8 +61,10 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
sectionTitle={globalize.translate(section.name)}
items={items ?? []}
url={getRouteUrl(section)}
reloadItems={refetch}
cardOptions={{
...section.cardOptions
...section.cardOptions,
queryKey: ['ProgramSectionWithItems']
}}
/>
@ -73,7 +76,8 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
sectionTitle={group.name}
items={group.timerInfo ?? []}
cardOptions={{
shape: 'overflowBackdrop',
queryKey: ['Timers'],
shape: CardShape.BackdropOverflow,
showTitle: true,
showParentTitleOrTitle: true,
showAirTime: true,

View file

@ -1,43 +1,29 @@
import type { BaseItemDto, TimerInfoDto } from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useEffect, useRef } from 'react';
import React, { FC } from 'react';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import ItemsContainer from 'elements/emby-itemscontainer/ItemsContainer';
import Scroller from 'elements/emby-scroller/Scroller';
import LinkButton from 'elements/emby-button/LinkButton';
import imageLoader from 'components/images/imageLoader';
import { CardOptions } from 'types/cardOptions';
import Cards from 'components/cardbuilder/Card/Cards';
import type { CardOptions } from 'types/cardOptions';
interface SectionContainerProps {
url?: string;
sectionTitle: string;
items: BaseItemDto[] | TimerInfoDto[];
cardOptions: CardOptions;
reloadItems?: () => void;
}
const SectionContainer: FC<SectionContainerProps> = ({
sectionTitle,
url,
items,
cardOptions
cardOptions,
reloadItems
}) => {
const element = useRef<HTMLDivElement>(null);
useEffect(() => {
const itemsContainer = element.current?.querySelector('.itemsContainer');
cardBuilder.buildCards(items, {
itemsContainer: itemsContainer,
parentContainer: element.current,
...cardOptions
});
imageLoader.lazyChildren(itemsContainer);
}, [cardOptions, items]);
return (
<div ref={element} className='verticalSection hide'>
<div className='verticalSection'>
<div className='sectionTitleContainer sectionTitleContainer-cards padded-left'>
{url && items.length > 5 ? (
<LinkButton
@ -66,7 +52,11 @@ const SectionContainer: FC<SectionContainerProps> = ({
>
<ItemsContainer
className='itemsContainer scrollSlider focuscontainer-x'
/>
reloadItems={reloadItems}
queryKey={cardOptions.queryKey}
>
<Cards items={items} cardOptions={cardOptions} />
</ItemsContainer>
</Scroller>
</div>
);

View file

@ -1,9 +1,8 @@
import {
RecommendationDto,
type RecommendationDto,
RecommendationType
} from '@jellyfin/sdk/lib/generated-client';
import React, { FC } from 'react';
import escapeHTML from 'escape-html';
import React, { type FC } from 'react';
import {
useGetMovieRecommendations,
useGetSuggestionSectionsWithItems
@ -12,8 +11,9 @@ import { appRouter } from 'components/router/appRouter';
import globalize from 'scripts/globalize';
import Loading from 'components/loading/LoadingComponent';
import SectionContainer from './SectionContainer';
import { ParentId } from 'types/library';
import { Section, SectionType } from 'types/sections';
import { CardShape } from 'utils/card';
import type { ParentId } from 'types/library';
import type { Section, SectionType } from 'types/sections';
interface SuggestionsSectionViewProps {
parentId: ParentId;
@ -89,7 +89,7 @@ const SuggestionsSectionView: FC<SuggestionsSectionViewProps> = ({
);
break;
}
return escapeHTML(title);
return title;
};
return (
@ -102,6 +102,7 @@ const SuggestionsSectionView: FC<SuggestionsSectionViewProps> = ({
url={getRouteUrl(section)}
cardOptions={{
...section.cardOptions,
queryKey: ['SuggestionSectionWithItems'],
showTitle: true,
centerText: true,
cardLayout: false,
@ -117,7 +118,8 @@ const SuggestionsSectionView: FC<SuggestionsSectionViewProps> = ({
sectionTitle={getRecommendationTittle(recommendation)}
items={recommendation.Items ?? []}
cardOptions={{
shape: 'overflowPortrait',
queryKey: ['MovieRecommendations'],
shape: CardShape.PortraitOverflow,
showYear: true,
scalable: true,
overlayPlayButton: true,

View file

@ -1,10 +1,11 @@
import React, { FC } from 'react';
import React, { type FC } from 'react';
import Box from '@mui/material/Box';
import { useGetGroupsUpcomingEpisodes } from 'hooks/useFetchItems';
import Loading from 'components/loading/LoadingComponent';
import globalize from 'scripts/globalize';
import SectionContainer from './SectionContainer';
import { LibraryViewProps } from 'types/library';
import { CardShape } from 'utils/card';
import type { LibraryViewProps } from 'types/library';
const UpcomingView: FC<LibraryViewProps> = ({ parentId }) => {
const { isLoading, data: groupsUpcomingEpisodes } = useGetGroupsUpcomingEpisodes(parentId);
@ -29,7 +30,7 @@ const UpcomingView: FC<LibraryViewProps> = ({ parentId }) => {
sectionTitle={group.name}
items={group.items ?? []}
cardOptions={{
shape: 'overflowBackdrop',
shape: CardShape.BackdropOverflow,
showLocationTypeIndicator: false,
showParentTitle: true,
preferThumb: true,

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,81 @@
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';
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 }) => {
return <Box className='lazy cardFooterLogo' data-src={logoUrl} />;
};
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,
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,723 @@
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 escapeHTML from 'escape-html';
import { appRouter } from 'components/router/appRouter';
import layoutManager from 'components/layoutManager';
import itemHelper from 'components/itemHelper';
import globalize from 'scripts/globalize';
import datetime from 'scripts/datetime';
import { isUsingLiveTvNaming } from '../cardBuilderUtils';
import type { 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 {
if (!text) {
text = itemHelper.getDisplayName(item);
}
text = escapeHTML(text);
if (layoutManager.tv) {
return {
title: text
};
}
const url = appRouter.getRouteUrl(item);
const dataAttributes = getDataAttributes(
{
action: 'link',
itemServerId: serverId ?? item.ServerId,
itemId: item.Id,
itemChannelId: item.ChannelId,
itemType: item.Type,
itemMediaType: item.MediaType,
itemCollectionType: item.CollectionType,
itemIsFolder: item.IsFolder
}
);
return {
titleAction: {
url,
title: text,
dataAttributes
}
};
}
export function getAirTimeText(
item: ItemDto,
showAirDateTime: boolean | undefined,
showAirEndTime: boolean | undefined
) {
let airTimeText = '';
if (item.StartDate) {
try {
let date = datetime.parseISO8601Date(item.StartDate);
if (showAirDateTime) {
airTimeText
+= datetime.toLocaleDateString(date, {
weekday: 'short',
month: 'short',
day: 'numeric'
}) + ' ';
}
airTimeText += datetime.getDisplayTime(date);
if (item.EndDate && showAirEndTime) {
date = datetime.parseISO8601Date(item.EndDate);
airTimeText += ' - ' + datetime.getDisplayTime(date);
}
} catch (e) {
console.error('error parsing date: ' + item.StartDate);
}
}
return airTimeText;
}
function isGenreOrStudio(itemType: NullableString) {
return itemType === BaseItemKind.Genre || itemType === BaseItemKind.Studio;
}
function isMusicGenreOrMusicArtist(
itemType: NullableString,
context: NullableString
) {
return itemType === BaseItemKind.MusicGenre || context === 'MusicArtist';
}
function getMovieCount(itemMovieCount: NullableNumber) {
if (itemMovieCount) {
return itemMovieCount === 1 ?
globalize.translate('ValueOneMovie') :
globalize.translate('ValueMovieCount', itemMovieCount);
}
}
function getSeriesCount(itemSeriesCount: NullableNumber) {
if (itemSeriesCount) {
return itemSeriesCount === 1 ?
globalize.translate('ValueOneSeries') :
globalize.translate('ValueSeriesCount', itemSeriesCount);
}
}
function getEpisodeCount(itemEpisodeCount: NullableNumber) {
if (itemEpisodeCount) {
return itemEpisodeCount === 1 ?
globalize.translate('ValueOneEpisode') :
globalize.translate('ValueEpisodeCount', itemEpisodeCount);
}
}
function getAlbumCount(itemAlbumCount: NullableNumber) {
if (itemAlbumCount) {
return itemAlbumCount === 1 ?
globalize.translate('ValueOneAlbum') :
globalize.translate('ValueAlbumCount', itemAlbumCount);
}
}
function getSongCount(itemSongCount: NullableNumber) {
if (itemSongCount) {
return itemSongCount === 1 ?
globalize.translate('ValueOneSong') :
globalize.translate('ValueSongCount', itemSongCount);
}
}
function getMusicVideoCount(itemMusicVideoCount: NullableNumber) {
if (itemMusicVideoCount) {
return itemMusicVideoCount === 1 ?
globalize.translate('ValueOneMusicVideo') :
globalize.translate('ValueMusicVideoCount', itemMusicVideoCount);
}
}
function getRecursiveItemCount(itemRecursiveItemCount: NullableNumber) {
return itemRecursiveItemCount === 1 ?
globalize.translate('ValueOneEpisode') :
globalize.translate('ValueEpisodeCount', itemRecursiveItemCount);
}
function getParentTitle(
isOuterFooter: boolean,
serverId: NullableString,
item: ItemDto
) {
if (isOuterFooter && item.AlbumArtists?.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 || '' || '&nbsp;' };
}
}
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

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

@ -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,
@ -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

@ -1,584 +0,0 @@
<div id="dlnaProfilePage" data-role="page" class="page type-interior dlnaPage withTabs">
<div data-role="content">
<div class="content-primary">
<form class="dlnaProfileForm" style="max-width: 650px;">
<div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${HeaderProfileInformation}</h2>
</div>
</div>
<div data-role="controlgroup" data-type="horizontal" data-mini="true">
<a href="#" is="emby-linkbutton" data-role="button" class="radioTabButton" id="radioInfo" data-value="tabInfo">${ButtonInfo}</a>
<a href="#" is="emby-linkbutton" data-role="button" class="radioTabButton" id="radioDirectPlay" data-value="tabDirectPlayProfiles">${TabDirectPlay}</a>
<a href="#" is="emby-linkbutton" data-role="button" class="radioTabButton" id="radioTranscoding" data-value="tabTranscodingProfiles">${Transcoding}</a>
<a href="#" is="emby-linkbutton" data-role="button" class="radioTabButton" id="radioContainers" data-value="tabContainerProfiles">${TabContainers}</a>
<a href="#" is="emby-linkbutton" data-role="button" class="radioTabButton" id="radioCodecs" data-value="tabCodecProfiles">${TabCodecs}</a>
<a href="#" is="emby-linkbutton" data-role="button" class="radioTabButton" id="radioMediaProfiles" data-value="tabMediaProfiles">${TabResponses}</a>
</div>
<br />
<div class="tabContent tabInfo">
<div class="inputContainer">
<input is="emby-input" type="text" id="txtName" required="required" label="${LabelName}" />
</div>
<div class="selectContainer">
<select is="emby-select" id="selectUser" label="${LabelUserLibrary}"></select>
<div class="fieldDescription">${LabelUserLibraryHelp}</div>
</div>
<div>
<h3 class="checkboxListLabel">${LabelSupportedMediaTypes}</h3>
<div class="checkboxList paperList checkboxList-paperList">
<label>
<input is="emby-checkbox" type="checkbox" id="chkAudio" data-value="Audio" class="chkMediaType" />
<span>${Audio}</span>
</label>
<label>
<input is="emby-checkbox" type="checkbox" id="chkPhoto" data-value="Photo" class="chkMediaType" />
<span>${Photo}</span>
</label>
<label>
<input is="emby-checkbox" type="checkbox" id="chkVideo" data-value="Video" class="chkMediaType" />
<span>${Video}</span>
</label>
</div>
</div>
<br />
<div class="inputContainer">
<input is="emby-input" type="number" id="txtMaxAllowedBitrate" pattern="[0-9]*" min="1" label="${LabelMaxStreamingBitrate}" />
<div class="fieldDescription">${LabelMaxStreamingBitrateHelp}</div>
</div>
<div class="inputContainer">
<input is="emby-input" type="number" id="txtMusicStreamingTranscodingBitrate" pattern="[0-9]*" min="1" label="${LabelMusicStreamingTranscodingBitrate}" />
<div class="fieldDescription">${LabelMusicStreamingTranscodingBitrateHelp}</div>
</div>
<div style="display:none;">
<label for="chkIgnoreTranscodeByteRangeRequests">${OptionIgnoreTranscodeByteRangeRequests}</label>
<input type="checkbox" id="chkIgnoreTranscodeByteRangeRequests" data-mini="true" />
<div class="fieldDescription">${OptionIgnoreTranscodeByteRangeRequestsHelp}</div>
</div>
<div is="emby-collapse" title="${HeaderIdentification}">
<div class="collapseContent">
<h3>${HeaderIdentificationCriteriaHelp}</h3>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtIdFriendlyName" label="${LabelFriendlyName}" />
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtIdManufacturer" label="${LabelManufacturer}" />
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtIdManufacturerUrl" label="${LabelManufacturerUrl}" />
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtIdModelName" label="${LabelModelName}" />
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtIdModelNumber" label="${LabelModelNumber}" />
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtIdModelDesription" label="${LabelModelDescription}" />
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtIdModelUrl" label="${LabelModelUrl}" />
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtIdSerialNumber" label="${LabelSerialNumber}" />
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtIdDeviceDescription" label="${LabelDeviceDescription}" />
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
</div>
<div>
<h2 style="vertical-align:middle;display:inline-block;">${HeaderHttpHeaders}</h2>
<button is="emby-button" type="button" class="fab btnAddIdentificationHttpHeader submit sectionTitleButton" style="margin-left:1em;" title="${Add}">
<span class="material-icons add" aria-hidden="true"></span>
</button>
</div>
<div class="httpHeaderIdentificationList"></div>
</div>
</div>
<div is="emby-collapse" title="${Display}">
<div class="collapseContent">
<br />
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkRequiresPlainFolders" />
<span>${OptionPlainStorageFolders}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${OptionPlainStorageFoldersHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkRequiresPlainVideoItems" />
<span>${OptionPlainVideoItems}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${OptionPlainVideoItemsHelp}</div>
</div>
</div>
</div>
<div is="emby-collapse" title="${HeaderImageSettings}">
<div class="collapseContent">
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkEnableAlbumArtInDidl" data-mini="true" />
<span>${LabelEmbedAlbumArtDidl}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelEmbedAlbumArtDidlHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkEnableSingleImageLimit" data-mini="true" />
<span>${LabelEnableSingleImageInDidlLimit}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelEnableSingleImageInDidlLimitHelp}</div>
</div>
<div class="inputContainer">
<input type="text" is="emby-input" id="txtAlbumArtPn" label="${LabelAlbumArtPN}" />
<div class="fieldDescription">${LabelAlbumArtHelp}</div>
</div>
<div class="inputContainer">
<input type="number" is="emby-input" id="txtAlbumArtMaxWidth" pattern="[0-9]*" min="1" label="${LabelAlbumArtMaxWidth}" />
<div class="fieldDescription">${LabelAlbumArtMaxResHelp}</div>
</div>
<div class="inputContainer">
<input type="number" is="emby-input" id="txtAlbumArtMaxHeight" pattern="[0-9]*" min="1" label="${LabelAlbumArtMaxHeight}" />
<div class="fieldDescription">${LabelAlbumArtMaxResHelp}</div>
</div>
<div class="inputContainer">
<input type="number" is="emby-input" id="txtIconMaxWidth" pattern="[0-9]*" min="1" label="${LabelIconMaxWidth}" />
<div class="fieldDescription">${LabelIconMaxResHelp}</div>
</div>
<div class="inputContainer">
<input type="number" is="emby-input" id="txtIconMaxHeight" pattern="[0-9]*" min="1" label="${LabelIconMaxHeight}" />
<div class="fieldDescription">${LabelIconMaxResHelp}</div>
</div>
</div>
</div>
<div is="emby-collapse" title="${HeaderServerSettings}">
<div class="collapseContent">
<p>${HeaderProfileServerSettingsHelp}</p>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtInfoFriendlyName" label="${LabelFriendlyName}" />
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtInfoManufacturer" label="${LabelManufacturer}" />
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtInfoManufacturerUrl" label="${LabelManufacturerUrl}" />
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtInfoModelName" label="${LabelModelName}" />
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtInfoModelNumber" label="${LabelModelNumber}" />
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtInfoModelDesription" label="${LabelModelDescription}" />
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtInfoModelUrl" label="${LabelModelUrl}" />
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtInfoSerialNumber" label="${LabelSerialNumber}" />
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtProtocolInfo" label="${LabelProtocolInfo}" />
<div class="fieldDescription">${LabelProtocolInfoHelp}</div>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtXDlnaCap" label="${LabelXDlnaCap}" />
<div class="fieldDescription">${LabelXDlnaCapHelp}</div>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtXDlnaDoc" label="${LabelXDlnaDoc}" />
<div class="fieldDescription">${LabelXDlnaDocHelp}</div>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtSonyAggregationFlags" label="${LabelSonyAggregationFlags}" />
<div class="fieldDescription">${LabelSonyAggregationFlagsHelp}</div>
</div>
</div>
</div>
<div is="emby-collapse" title="${HeaderSubtitleProfiles}">
<div class="collapseContent">
<p>${HeaderSubtitleProfilesHelp}</p>
<button is="emby-button" type="button" class="raised submit block btnAddSubtitleProfile">
<span>${Add}</span>
</button>
<div class="subtitleProfileList"></div>
<br />
</div>
</div>
<div is="emby-collapse" title="${HeaderXmlSettings}">
<div class="collapseContent">
<div>
<h2 style="vertical-align:middle;display:inline-block;">${HeaderXmlDocumentAttributes}</h2>
<button is="emby-button" type="button" class="fab btnAddXmlDocumentAttribute submit sectionTitleButton" style="margin-left:1em;" title="${Add}">
<span class="material-icons add" aria-hidden="true"></span>
</button>
</div>
<div class="xmlDocumentAttributeList"></div>
<div class="fieldDescription">${XmlDocumentAttributeListHelp}</div>
<br />
</div>
</div>
</div>
<div class="tabContent tabDirectPlayProfiles">
<p>${HeaderDirectPlayProfileHelp}</p>
<button is="emby-button" class="raised submit block btnAddDirectPlayProfile" type="button" data-mini="true" data-icon="plus">${New}</button>
<br />
<div class="directPlayProfiles"></div>
</div>
<div class="tabContent tabTranscodingProfiles">
<p>${HeaderTranscodingProfileHelp}</p>
<button is="emby-button" class="raised submit block btnAddTranscodingProfile" type="button" data-mini="true" data-icon="plus">${New}</button>
<br />
<div class="transcodingProfiles"></div>
</div>
<div class="tabContent tabContainerProfiles">
<p>${HeaderContainerProfileHelp}</p>
<button is="emby-button" class="raised submit block btnAddContainerProfile" type="button" data-mini="true" data-icon="plus">${New}</button>
<br />
<div class="containerProfiles"></div>
</div>
<div class="tabContent tabCodecProfiles">
<p>${HeaderCodecProfileHelp}</p>
<button is="emby-button" class="raised submit block btnAddCodecProfile" type="button" data-icon="plus">${New}</button>
<br />
<div class="codecProfiles"></div>
</div>
<div class="tabContent tabMediaProfiles">
<p>${HeaderResponseProfileHelp}</p>
<button is="emby-button" class="raised submit block btnAddResponseProfile" type="button" data-mini="true" data-icon="plus">${New}</button>
<br />
<div class="mediaProfiles"></div>
</div>
<br />
<div>
<button is="emby-button" type="submit" class="raised button-submit block">
<span>${Save}</span>
</button>
<button is="emby-button" type="button" class="button-cancel raised block" onclick="Dashboard.navigate('dashboard/dlna/profiles');">
<span>${ButtonCancel}</span>
</button>
</div>
</form>
</div>
</div>
<div data-role="popup" id="popupEditDirectPlayProfile" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%;">
<form class="editDirectPlayProfileForm" style="padding:1em;">
<div class="ui-bar-a">
<h3 class="sectionTitle">${HeaderDirectPlayProfile}</h3>
</div>
<div data-role="content">
<div class="selectContainer">
<select id="selectDirectPlayProfileType" name="selectDirectPlayProfileType" is="emby-select" label="${LabelType}">
<option value="Audio">${Audio}</option>
<option value="Photo">${Photo}</option>
<option value="Video">${Video}</option>
</select>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtDirectPlayContainer" label="${LabelProfileContainer}" />
<div class="fieldDescription">${LabelProfileContainersHelp}</div>
</div>
<div id="fldDirectPlayVideoCodec" style="margin: 1em 0;">
<div class="inputContainer">
<input is="emby-input" type="text" id="txtDirectPlayVideoCodec" label="${LabelProfileVideoCodecs}" />
<div class="fieldDescription">${LabelProfileCodecsHelp}</div>
</div>
</div>
<div id="fldDirectPlayAudioCodec" style="margin: 1em 0 2em;">
<div class="inputContainer">
<input is="emby-input" type="text" id="txtDirectPlayAudioCodec" label="${LabelProfileAudioCodecs}" />
<div class="fieldDescription">${LabelProfileCodecsHelp}</div>
</div>
</div>
<p>
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check" data-mini="true">
<span>${ButtonOk}</span>
</button>
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');" data-mini="true">
<span>${ButtonCancel}</span>
</button>
</p>
</div>
</form>
</div>
<div data-role="popup" id="transcodingProfilePopup" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%;">
<form class="transcodingProfileForm" style="padding:1em;">
<div class="ui-bar-a">
<h3 class="sectionTitle">${HeaderTranscodingProfile}</h3>
</div>
<div data-role="content">
<div data-role="controlgroup" data-type="horizontal" data-mini="true">
<input type="radio" name="radioTranscodingTab" class="radioTabButton" id="radioTranscodingBasics" value="tabTranscodingBasics">
<label for="radioTranscodingBasics">${ButtonInfo}</label>
<input type="radio" name="radioTranscodingTab" class="radioTabButton" id="radioTranscodingAdvanced" value="tabTranscodingAdvanced">
<label for="radioTranscodingAdvanced">${TabAdvanced}</label>
</div>
<br />
<div class="tabContent tabTranscodingBasics" style="display: none;">
<div class="selectContainer">
<select id="selectTranscodingProfileType" name="selectTranscodingProfileType" is="emby-select" label="${LabelType}">
<option value="Audio">${Audio}</option>
<option value="Photo">${Photo}</option>
<option value="Video">${Video}</option>
</select>
</div>
<div id="fldTranscodingProtocol" style="margin: 1em 0;">
<div class="selectContainer">
<select id="selectTranscodingProtocol" name="selectTranscodingProtocol" is="emby-select" label="${LabelProtocol}">
<option value="Http">${OptionProtocolHttp}</option>
<option value="Hls">${OptionProtocolHls}</option>
</select>
</div>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtTranscodingContainer" label="${LabelProfileContainer}"; required="required" />
</div>
<div id="fldTranscodingVideoCodec" style="margin: 1em 0;">
<div class="inputContainer">
<input is="emby-input" type="text" id="txtTranscodingVideoCodec" label="${LabelVideoCodec}" />
</div>
</div>
<div id="fldTranscodingAudioCodec" style="margin: 1em 0;">
<div class="inputContainer">
<input is="emby-input" type="text" id="txtTranscodingAudioCodec" label="${LabelAudioCodec}" />
</div>
</div>
</div>
<div class="tabContent tabTranscodingAdvanced" style="display: none;">
<div id="fldEnableMpegtsM2TsMode" style="margin: 1em 0;">
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkEnableMpegtsM2TsMode" />
<span>${OptionEnableM2tsMode}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${OptionEnableM2tsModeHelp}</div>
</div>
</div>
<div id="fldEstimateContentLength" style="margin: 1em 0;">
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkEstimateContentLength" />
<span>${OptionEstimateContentLength}</span>
</label>
</div>
</div>
<div id="fldReportByteRangeRequests" style="margin: 1em 0;">
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkReportByteRangeRequests" />
<span>${OptionReportByteRangeSeekingWhenTranscoding}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${OptionReportByteRangeSeekingWhenTranscodingHelp}</div>
</div>
</div>
</div>
<p>
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check" data-mini="true">
<span>${ButtonOk}</span>
</button>
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');" data-mini="true">
<span>${ButtonCancel}</span>
</button>
</p>
</div>
</form>
</div>
<div data-role="popup" id="containerProfilePopup" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%;">
<form class="containerProfileForm" style="padding:1em;">
<div class="ui-bar-a">
<h3 class="sectionTitle">${HeaderContainerProfile}</h3>
</div>
<div data-role="content">
<p>${HeaderContainerProfileHelp}</p>
<div class="tabContent tabContainerBasics">
<div class="selectContainer">
<select id="selectContainerProfileType" name="selectContainerProfileType" is="emby-select" label="${LabelType}">
<option value="Audio">${Audio}</option>
<option value="Photo">${Photo}</option>
<option value="Video">${Video}</option>
</select>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtContainerProfileContainer" label="${LabelProfileContainer}" />
<div class="fieldDescription">${LabelProfileContainersHelp}</div>
</div>
</div>
<div class="tabContent tabContainerConditions" style="display: none;"></div>
<p>
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check" data-mini="true">
<span>${ButtonOk}</span>
</button>
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');" data-mini="true">
<span>${ButtonCancel}</span>
</button>
</p>
</div>
</form>
</div>
<div data-role="popup" id="codecProfilePopup" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%;">
<form class="codecProfileForm" style="padding:1em;">
<div class="ui-bar-a">
<h3 class="sectionTitle">${HeaderCodecProfile}</h3>
</div>
<div data-role="content">
<p>${HeaderCodecProfileHelp}</p>
<div class="selectContainer">
<select id="selectCodecProfileType" name="selectCodecProfileType" is="emby-select" label="${LabelType}">
<option value="Video">${Video}</option>
<option value="VideoAudio">${VideoAudio}</option>
<option value="Audio">${Audio}</option>
</select>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtCodecProfileCodec" label="${LabelProfileCodecs}" />
<div class="fieldDescription">${LabelProfileCodecsHelp}</div>
</div>
<p>
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check" data-mini="true">
<span>${ButtonOk}</span>
</button>
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');" data-mini="true">
<span>${ButtonCancel}</span>
</button>
</p>
</div>
</form>
</div>
<div data-role="popup" id="responseProfilePopup" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%;">
<form class="editResponseProfileForm" style="padding:1em;">
<div class="ui-bar-a">
<h3 class="sectionTitle">${HeaderResponseProfile}</h3>
</div>
<div data-role="content">
<div class="selectContainer">
<select id="selectResponseProfileType" name="selectResponseProfileType" is="emby-select" label="${LabelType}">
<option value="Audio">${Audio}</option>
<option value="Photo">${Photo}</option>
<option value="Video">${Video}</option>
</select>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtResponseProfileContainer" label="${LabelProfileContainer}" />
<div class="fieldDescription">${LabelProfileContainersHelp}</div>
</div>
<div id="fldResponseProfileVideoCodec" style="margin: 1em 0;">
<div class="inputContainer">
<input is="emby-input" type="text" id="txtResponseProfileVideoCodec" label="${LabelProfileVideoCodecs}" />
<div class="fieldDescription">${LabelProfileCodecsHelp}</div>
</div>
</div>
<div id="fldResponseProfileAudioCodec" style="margin: 1em 0 2em;">
<div class="inputContainer">
<input is="emby-input" type="text" id="txtResponseProfileAudioCodec" label="${LabelProfileAudioCodecs}" />
<div class="fieldDescription">${LabelProfileCodecsHelp}</div>
</div>
</div>
<p>
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check" data-mini="true">
<span>${ButtonOk}</span>
</button>
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');" data-mini="true">
<span>${ButtonCancel}</span>
</button>
</p>
</div>
</form>
</div>
<div data-role="popup" id="identificationHeaderPopup" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%;">
<form class="identificationHeaderForm" style="padding:1em;">
<div class="ui-bar-a">
<h3 class="sectionTitle">${HeaderIdentificationHeader}</h3>
</div>
<div data-role="content">
<div class="inputContainer">
<input is="emby-input" type="text" id="txtIdentificationHeaderName" label="${LabelName}" />
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtIdentificationHeaderValue" label="${LabelValue}" />
</div>
<div class="selectContainer">
<select id="selectMatchType" name="selectMatchType" is="emby-select" label="${LabelMatchType}">
<option value="Equals">${OptionEquals}</option>
<option value="Regex">${OptionRegex}</option>
<option value="Substring">${OptionSubstring}</option>
</select>
</div>
<p>
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check" data-mini="true">
<span>${ButtonOk}</span>
</button>
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');" data-mini="true">
<span>${ButtonCancel}</span>
</button>
</p>
</div>
</form>
</div>
<div data-role="popup" id="xmlAttributePopup" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%;">
<form class="xmlAttributeForm" style="padding:1em;">
<div class="ui-bar-a">
<h3 class="sectionTitle">${HeaderXmlDocumentAttribute}</h3>
</div>
<div data-role="content">
<div class="inputContainer">
<input is="emby-input" type="text" id="txtXmlAttributeName" label="${LabelName}" />
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtXmlAttributeValue" label="${LabelValue}" />
</div>
<p>
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check" data-mini="true">
<span>${ButtonOk}</span>
</button>
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');" data-mini="true">
<span>${ButtonCancel}</span>
</button>
</p>
</div>
</form>
</div>
<div data-role="popup" id="subtitleProfilePopup" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%;">
<form class="subtitleProfileForm" style="padding:1em;">
<div class="ui-bar-a">
<h3 class="sectionTitle">${HeaderSubtitleProfile}</h3>
</div>
<div data-role="content">
<div class="inputContainer">
<input is="emby-input" type="text" id="txtSubtitleProfileFormat" label="${LabelFormat}" />
<div class="fieldDescription">${LabelSubtitleFormatHelp}</div>
</div>
<div class="selectContainer">
<select id="selectSubtitleProfileMethod" name="selectSubtitleProfileMethod" is="emby-select" label="${LabelMethod}">
<option value="Embed">${OptionEmbedSubtitles}</option>
<option value="External">${OptionExternallyDownloaded}</option>
<option value="Hls">${OptionHlsSegmentedSubtitles}</option>
</select>
</div>
<div class="selectContainer">
<select id="selectSubtitleProfileDidlMode" name="selectSubtitleProfileDidlMode" is="emby-select" label="${LabelDidlMode}">
<option value="">${OptionResElement}</option>
<option value="CaptionInfoEx">${OptionCaptionInfoExSamsung}</option>
</select>
</div>
<p>
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check" data-mini="true">
<span>${ButtonOk}</span>
</button>
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');" data-mini="true">
<span>${ButtonCancel}</span>
</button>
</p>
</div>
</form>
</div>
</div>

View file

@ -1,830 +0,0 @@
import escapeHtml from 'escape-html';
import 'jquery';
import loading from '../../../components/loading/loading';
import globalize from '../../../scripts/globalize';
import '../../../elements/emby-select/emby-select';
import '../../../elements/emby-button/emby-button';
import '../../../elements/emby-input/emby-input';
import '../../../elements/emby-checkbox/emby-checkbox';
import '../../../components/listview/listview.scss';
import Dashboard from '../../../utils/dashboard';
import toast from '../../../components/toast/toast';
import { getParameterByName } from '../../../utils/url.ts';
function loadProfile(page) {
loading.show();
const promise1 = getProfile();
const promise2 = ApiClient.getUsers();
Promise.all([promise1, promise2]).then(function (responses) {
currentProfile = responses[0];
renderProfile(page, currentProfile, responses[1]);
loading.hide();
});
}
function getProfile() {
const id = getParameterByName('id');
const url = id ? 'Dlna/Profiles/' + id : 'Dlna/Profiles/Default';
return ApiClient.getJSON(ApiClient.getUrl(url));
}
function renderProfile(page, profile, users) {
$('#txtName', page).val(profile.Name);
$('.chkMediaType', page).each(function () {
this.checked = (profile.SupportedMediaTypes || '').split(',').indexOf(this.getAttribute('data-value')) != -1;
});
$('#chkEnableAlbumArtInDidl', page).prop('checked', profile.EnableAlbumArtInDidl);
$('#chkEnableSingleImageLimit', page).prop('checked', profile.EnableSingleAlbumArtLimit);
renderXmlDocumentAttributes(page, profile.XmlRootAttributes || []);
const idInfo = profile.Identification || {};
renderIdentificationHeaders(page, idInfo.Headers || []);
renderSubtitleProfiles(page, profile.SubtitleProfiles || []);
$('#txtInfoFriendlyName', page).val(profile.FriendlyName || '');
$('#txtInfoModelName', page).val(profile.ModelName || '');
$('#txtInfoModelNumber', page).val(profile.ModelNumber || '');
$('#txtInfoModelDescription', page).val(profile.ModelDescription || '');
$('#txtInfoModelUrl', page).val(profile.ModelUrl || '');
$('#txtInfoManufacturer', page).val(profile.Manufacturer || '');
$('#txtInfoManufacturerUrl', page).val(profile.ManufacturerUrl || '');
$('#txtInfoSerialNumber', page).val(profile.SerialNumber || '');
$('#txtIdFriendlyName', page).val(idInfo.FriendlyName || '');
$('#txtIdModelName', page).val(idInfo.ModelName || '');
$('#txtIdModelNumber', page).val(idInfo.ModelNumber || '');
$('#txtIdModelDescription', page).val(idInfo.ModelDescription || '');
$('#txtIdModelUrl', page).val(idInfo.ModelUrl || '');
$('#txtIdManufacturer', page).val(idInfo.Manufacturer || '');
$('#txtIdManufacturerUrl', page).val(idInfo.ManufacturerUrl || '');
$('#txtIdSerialNumber', page).val(idInfo.SerialNumber || '');
$('#txtIdDeviceDescription', page).val(idInfo.DeviceDescription || '');
$('#txtAlbumArtPn', page).val(profile.AlbumArtPn || '');
$('#txtAlbumArtMaxWidth', page).val(profile.MaxAlbumArtWidth || '');
$('#txtAlbumArtMaxHeight', page).val(profile.MaxAlbumArtHeight || '');
$('#txtIconMaxWidth', page).val(profile.MaxIconWidth || '');
$('#txtIconMaxHeight', page).val(profile.MaxIconHeight || '');
$('#chkIgnoreTranscodeByteRangeRequests', page).prop('checked', profile.IgnoreTranscodeByteRangeRequests);
$('#txtMaxAllowedBitrate', page).val(profile.MaxStreamingBitrate || '');
$('#txtMusicStreamingTranscodingBitrate', page).val(profile.MusicStreamingTranscodingBitrate || '');
$('#chkRequiresPlainFolders', page).prop('checked', profile.RequiresPlainFolders);
$('#chkRequiresPlainVideoItems', page).prop('checked', profile.RequiresPlainVideoItems);
$('#txtProtocolInfo', page).val(profile.ProtocolInfo || '');
$('#txtXDlnaCap', page).val(profile.XDlnaCap || '');
$('#txtXDlnaDoc', page).val(profile.XDlnaDoc || '');
$('#txtSonyAggregationFlags', page).val(profile.SonyAggregationFlags || '');
profile.DirectPlayProfiles = profile.DirectPlayProfiles || [];
profile.TranscodingProfiles = profile.TranscodingProfiles || [];
profile.ContainerProfiles = profile.ContainerProfiles || [];
profile.CodecProfiles = profile.CodecProfiles || [];
profile.ResponseProfiles = profile.ResponseProfiles || [];
const usersHtml = '<option></option>' + users.map(function (u) {
return '<option value="' + u.Id + '">' + escapeHtml(u.Name) + '</option>';
}).join('');
$('#selectUser', page).html(usersHtml).val(profile.UserId || '');
renderSubProfiles(page, profile);
}
function renderIdentificationHeaders(page, headers) {
let index = 0;
const html = '<div class="paperList">' + headers.map(function (h) {
let li = '<div class="listItem">';
li += '<span class="material-icons listItemIcon info" aria-hidden="true"></span>';
li += '<div class="listItemBody">';
li += '<h3 class="listItemBodyText">' + escapeHtml(h.Name + ': ' + (h.Value || '')) + '</h3>';
li += '<div class="listItemBodyText secondary">' + escapeHtml(h.Match || '') + '</div>';
li += '</div>';
li += '<button type="button" is="paper-icon-button-light" class="btnDeleteIdentificationHeader listItemButton" data-index="' + index + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
li += '</div>';
index++;
return li;
}).join('') + '</div>';
const elem = $('.httpHeaderIdentificationList', page).html(html).trigger('create');
$('.btnDeleteIdentificationHeader', elem).on('click', function () {
const itemIndex = parseInt(this.getAttribute('data-index'), 10);
currentProfile.Identification.Headers.splice(itemIndex, 1);
renderIdentificationHeaders(page, currentProfile.Identification.Headers);
});
}
function openPopup(elem) {
elem.classList.remove('hide');
}
function closePopup(elem) {
elem.classList.add('hide');
}
function editIdentificationHeader(page, header) {
isSubProfileNew = header == null;
header = header || {};
currentSubProfile = header;
const popup = $('#identificationHeaderPopup', page);
$('#txtIdentificationHeaderName', popup).val(header.Name || '');
$('#txtIdentificationHeaderValue', popup).val(header.Value || '');
$('#selectMatchType', popup).val(header.Match || 'Equals');
openPopup(popup[0]);
}
function saveIdentificationHeader(page) {
currentSubProfile.Name = $('#txtIdentificationHeaderName', page).val();
currentSubProfile.Value = $('#txtIdentificationHeaderValue', page).val();
currentSubProfile.Match = $('#selectMatchType', page).val();
if (isSubProfileNew) {
currentProfile.Identification = currentProfile.Identification || {};
currentProfile.Identification.Headers = currentProfile.Identification.Headers || [];
currentProfile.Identification.Headers.push(currentSubProfile);
}
renderIdentificationHeaders(page, currentProfile.Identification.Headers);
currentSubProfile = null;
closePopup($('#identificationHeaderPopup', page)[0]);
}
function renderXmlDocumentAttributes(page, attribute) {
const html = '<div class="paperList">' + attribute.map(function (h) {
let li = '<div class="listItem">';
li += '<span class="material-icons listItemIcon info" aria-hidden="true"></span>';
li += '<div class="listItemBody">';
li += '<h3 class="listItemBodyText">' + escapeHtml(h.Name + ' = ' + (h.Value || '')) + '</h3>';
li += '</div>';
li += '<button type="button" is="paper-icon-button-light" class="btnDeleteXmlAttribute listItemButton" data-index="0"><span class="material-icons delete" aria-hidden="true"></span></button>';
li += '</div>';
return li;
}).join('') + '</div>';
const elem = $('.xmlDocumentAttributeList', page).html(html).trigger('create');
$('.btnDeleteXmlAttribute', elem).on('click', function () {
const itemIndex = parseInt(this.getAttribute('data-index'), 10);
currentProfile.XmlRootAttributes.splice(itemIndex, 1);
renderXmlDocumentAttributes(page, currentProfile.XmlRootAttributes);
});
}
function editXmlDocumentAttribute(page, attribute) {
isSubProfileNew = attribute == null;
attribute = attribute || {};
currentSubProfile = attribute;
const popup = $('#xmlAttributePopup', page);
$('#txtXmlAttributeName', popup).val(attribute.Name || '');
$('#txtXmlAttributeValue', popup).val(attribute.Value || '');
openPopup(popup[0]);
}
function saveXmlDocumentAttribute(page) {
currentSubProfile.Name = $('#txtXmlAttributeName', page).val();
currentSubProfile.Value = $('#txtXmlAttributeValue', page).val();
if (isSubProfileNew) {
currentProfile.XmlRootAttributes.push(currentSubProfile);
}
renderXmlDocumentAttributes(page, currentProfile.XmlRootAttributes);
currentSubProfile = null;
closePopup($('#xmlAttributePopup', page)[0]);
}
function renderSubtitleProfiles(page, profiles) {
let index = 0;
const html = '<div class="paperList">' + profiles.map(function (h) {
let li = '<div class="listItem lnkEditSubProfile" data-index="' + index + '">';
li += '<span class="material-icons listItemIcon info" aria-hidden="true"></span>';
li += '<div class="listItemBody">';
li += '<h3 class="listItemBodyText">' + escapeHtml(h.Format || '') + '</h3>';
li += '</div>';
li += '<button type="button" is="paper-icon-button-light" class="btnDeleteProfile listItemButton" data-index="' + index + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
li += '</div>';
index++;
return li;
}).join('') + '</div>';
const elem = $('.subtitleProfileList', page).html(html).trigger('create');
$('.btnDeleteProfile', elem).on('click', function () {
const itemIndex = parseInt(this.getAttribute('data-index'), 10);
currentProfile.SubtitleProfiles.splice(itemIndex, 1);
renderSubtitleProfiles(page, currentProfile.SubtitleProfiles);
});
$('.lnkEditSubProfile', elem).on('click', function () {
const itemIndex = parseInt(this.getAttribute('data-index'), 10);
editSubtitleProfile(page, currentProfile.SubtitleProfiles[itemIndex]);
});
}
function editSubtitleProfile(page, profile) {
isSubProfileNew = profile == null;
profile = profile || {};
currentSubProfile = profile;
const popup = $('#subtitleProfilePopup', page);
$('#txtSubtitleProfileFormat', popup).val(profile.Format || '');
$('#selectSubtitleProfileMethod', popup).val(profile.Method || '');
$('#selectSubtitleProfileDidlMode', popup).val(profile.DidlMode || '');
openPopup(popup[0]);
}
function saveSubtitleProfile(page) {
currentSubProfile.Format = $('#txtSubtitleProfileFormat', page).val();
currentSubProfile.Method = $('#selectSubtitleProfileMethod', page).val();
currentSubProfile.DidlMode = $('#selectSubtitleProfileDidlMode', page).val();
if (isSubProfileNew) {
currentProfile.SubtitleProfiles.push(currentSubProfile);
}
renderSubtitleProfiles(page, currentProfile.SubtitleProfiles);
currentSubProfile = null;
closePopup($('#subtitleProfilePopup', page)[0]);
}
function renderSubProfiles(page, profile) {
renderDirectPlayProfiles(page, profile.DirectPlayProfiles);
renderTranscodingProfiles(page, profile.TranscodingProfiles);
renderContainerProfiles(page, profile.ContainerProfiles);
renderCodecProfiles(page, profile.CodecProfiles);
renderResponseProfiles(page, profile.ResponseProfiles);
}
function saveDirectPlayProfile(page) {
currentSubProfile.Type = $('#selectDirectPlayProfileType', page).val();
currentSubProfile.Container = $('#txtDirectPlayContainer', page).val();
currentSubProfile.AudioCodec = $('#txtDirectPlayAudioCodec', page).val();
currentSubProfile.VideoCodec = $('#txtDirectPlayVideoCodec', page).val();
if (isSubProfileNew) {
currentProfile.DirectPlayProfiles.push(currentSubProfile);
}
renderSubProfiles(page, currentProfile);
currentSubProfile = null;
closePopup($('#popupEditDirectPlayProfile', page)[0]);
}
function renderDirectPlayProfiles(page, profiles) {
let html = '';
html += '<ul data-role="listview" data-inset="true" data-split-icon="delete">';
let currentType;
for (const [index, profile] of profiles.entries()) {
if (profile.Type !== currentType) {
html += '<li data-role="list-divider">' + profile.Type + '</li>';
currentType = profile.Type;
}
html += '<div>';
html += '<a is="emby-linkbutton" href="#" class="lnkEditSubProfile" data-profileindex="' + index + '">';
html += '<p>' + globalize.translate('ValueContainer', profile.Container || allText) + '</p>';
if (profile.Type == 'Video') {
html += '<p>' + globalize.translate('ValueVideoCodec', profile.VideoCodec || allText) + '</p>';
html += '<p>' + globalize.translate('ValueAudioCodec', profile.AudioCodec || allText) + '</p>';
} else if (profile.Type == 'Audio') {
html += '<p>' + globalize.translate('ValueCodec', profile.AudioCodec || allText) + '</p>';
}
html += '</a>';
html += '<button type="button" is="paper-icon-button-light" class="btnDeleteProfile listItemButton" data-profileindex="' + index + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
html += '</div>';
}
html += '</ul>';
const elem = $('.directPlayProfiles', page).html(html).trigger('create');
$('.btnDeleteProfile', elem).on('click', function () {
const index = this.getAttribute('data-profileindex');
deleteDirectPlayProfile(page, index);
});
$('.lnkEditSubProfile', elem).on('click', function () {
const index = parseInt(this.getAttribute('data-profileindex'), 10);
editDirectPlayProfile(page, currentProfile.DirectPlayProfiles[index]);
});
}
function deleteDirectPlayProfile(page, index) {
currentProfile.DirectPlayProfiles.splice(index, 1);
renderDirectPlayProfiles(page, currentProfile.DirectPlayProfiles);
}
function editDirectPlayProfile(page, directPlayProfile) {
isSubProfileNew = directPlayProfile == null;
directPlayProfile = directPlayProfile || {};
currentSubProfile = directPlayProfile;
const popup = $('#popupEditDirectPlayProfile', page);
$('#selectDirectPlayProfileType', popup).val(directPlayProfile.Type || 'Video').trigger('change');
$('#txtDirectPlayContainer', popup).val(directPlayProfile.Container || '');
$('#txtDirectPlayAudioCodec', popup).val(directPlayProfile.AudioCodec || '');
$('#txtDirectPlayVideoCodec', popup).val(directPlayProfile.VideoCodec || '');
openPopup(popup[0]);
}
function renderTranscodingProfiles(page, profiles) {
let html = '';
html += '<ul data-role="listview" data-inset="true" data-split-icon="delete">';
let currentType;
for (let i = 0, length = profiles.length; i < length; i++) {
const profile = profiles[i];
if (profile.Type !== currentType) {
html += '<li data-role="list-divider">' + profile.Type + '</li>';
currentType = profile.Type;
}
html += '<div>';
html += '<a is="emby-linkbutton" href="#" class="lnkEditSubProfile" data-profileindex="' + i + '">';
html += '<p>Protocol: ' + (profile.Protocol || 'Http') + '</p>';
html += '<p>' + globalize.translate('ValueContainer', profile.Container || allText) + '</p>';
if (profile.Type == 'Video') {
html += '<p>' + globalize.translate('ValueVideoCodec', profile.VideoCodec || allText) + '</p>';
html += '<p>' + globalize.translate('ValueAudioCodec', profile.AudioCodec || allText) + '</p>';
} else if (profile.Type == 'Audio') {
html += '<p>' + globalize.translate('ValueCodec', profile.AudioCodec || allText) + '</p>';
}
html += '</a>';
html += '<button type="button" is="paper-icon-button-light" class="btnDeleteProfile listItemButton" data-profileindex="' + i + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
html += '</div>';
}
html += '</ul>';
const elem = $('.transcodingProfiles', page).html(html).trigger('create');
$('.btnDeleteProfile', elem).on('click', function () {
const index = this.getAttribute('data-profileindex');
deleteTranscodingProfile(page, index);
});
$('.lnkEditSubProfile', elem).on('click', function () {
const index = parseInt(this.getAttribute('data-profileindex'), 10);
editTranscodingProfile(page, currentProfile.TranscodingProfiles[index]);
});
}
function editTranscodingProfile(page, transcodingProfile) {
isSubProfileNew = transcodingProfile == null;
transcodingProfile = transcodingProfile || {};
currentSubProfile = transcodingProfile;
const popup = $('#transcodingProfilePopup', page);
$('#selectTranscodingProfileType', popup).val(transcodingProfile.Type || 'Video').trigger('change');
$('#txtTranscodingContainer', popup).val(transcodingProfile.Container || '');
$('#txtTranscodingAudioCodec', popup).val(transcodingProfile.AudioCodec || '');
$('#txtTranscodingVideoCodec', popup).val(transcodingProfile.VideoCodec || '');
$('#selectTranscodingProtocol', popup).val(transcodingProfile.Protocol || 'Http');
$('#chkEnableMpegtsM2TsMode', popup).prop('checked', transcodingProfile.EnableMpegtsM2TsMode || false);
$('#chkEstimateContentLength', popup).prop('checked', transcodingProfile.EstimateContentLength || false);
$('#chkReportByteRangeRequests', popup).prop('checked', transcodingProfile.TranscodeSeekInfo == 'Bytes');
$('.radioTabButton:first', popup).trigger('click');
openPopup(popup[0]);
}
function deleteTranscodingProfile(page, index) {
currentProfile.TranscodingProfiles.splice(index, 1);
renderTranscodingProfiles(page, currentProfile.TranscodingProfiles);
}
function saveTranscodingProfile(page) {
currentSubProfile.Type = $('#selectTranscodingProfileType', page).val();
currentSubProfile.Container = $('#txtTranscodingContainer', page).val();
currentSubProfile.AudioCodec = $('#txtTranscodingAudioCodec', page).val();
currentSubProfile.VideoCodec = $('#txtTranscodingVideoCodec', page).val();
currentSubProfile.Protocol = $('#selectTranscodingProtocol', page).val();
currentSubProfile.Context = 'Streaming';
currentSubProfile.EnableMpegtsM2TsMode = $('#chkEnableMpegtsM2TsMode', page).is(':checked');
currentSubProfile.EstimateContentLength = $('#chkEstimateContentLength', page).is(':checked');
currentSubProfile.TranscodeSeekInfo = $('#chkReportByteRangeRequests', page).is(':checked') ? 'Bytes' : 'Auto';
if (isSubProfileNew) {
currentProfile.TranscodingProfiles.push(currentSubProfile);
}
renderSubProfiles(page, currentProfile);
currentSubProfile = null;
closePopup($('#transcodingProfilePopup', page)[0]);
}
function renderContainerProfiles(page, profiles) {
let html = '';
html += '<ul data-role="listview" data-inset="true" data-split-icon="delete">';
let currentType;
for (let i = 0, length = profiles.length; i < length; i++) {
const profile = profiles[i];
if (profile.Type !== currentType) {
html += '<li data-role="list-divider">' + profile.Type + '</li>';
currentType = profile.Type;
}
html += '<div>';
html += '<a is="emby-linkbutton" href="#" class="lnkEditSubProfile" data-profileindex="' + i + '">';
html += '<p>' + globalize.translate('ValueContainer', profile.Container || allText) + '</p>';
if (profile.Conditions?.length) {
html += '<p>';
html += globalize.translate('ValueConditions', profile.Conditions.map(function (c) {
return c.Property;
}).join(', '));
html += '</p>';
}
html += '</a>';
html += '<button type="button" is="paper-icon-button-light" class="btnDeleteProfile listItemButton" data-profileindex="' + i + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
html += '</div>';
}
html += '</ul>';
const elem = $('.containerProfiles', page).html(html).trigger('create');
$('.btnDeleteProfile', elem).on('click', function () {
const index = this.getAttribute('data-profileindex');
deleteContainerProfile(page, index);
});
$('.lnkEditSubProfile', elem).on('click', function () {
const index = parseInt(this.getAttribute('data-profileindex'), 10);
editContainerProfile(page, currentProfile.ContainerProfiles[index]);
});
}
function deleteContainerProfile(page, index) {
currentProfile.ContainerProfiles.splice(index, 1);
renderContainerProfiles(page, currentProfile.ContainerProfiles);
}
function editContainerProfile(page, containerProfile) {
isSubProfileNew = containerProfile == null;
containerProfile = containerProfile || {};
currentSubProfile = containerProfile;
const popup = $('#containerProfilePopup', page);
$('#selectContainerProfileType', popup).val(containerProfile.Type || 'Video').trigger('change');
$('#txtContainerProfileContainer', popup).val(containerProfile.Container || '');
$('.radioTabButton:first', popup).trigger('click');
openPopup(popup[0]);
}
function saveContainerProfile(page) {
currentSubProfile.Type = $('#selectContainerProfileType', page).val();
currentSubProfile.Container = $('#txtContainerProfileContainer', page).val();
if (isSubProfileNew) {
currentProfile.ContainerProfiles.push(currentSubProfile);
}
renderSubProfiles(page, currentProfile);
currentSubProfile = null;
closePopup($('#containerProfilePopup', page)[0]);
}
function renderCodecProfiles(page, profiles) {
let html = '';
html += '<ul data-role="listview" data-inset="true" data-split-icon="delete">';
let currentType;
for (let i = 0, length = profiles.length; i < length; i++) {
const profile = profiles[i];
const type = profile.Type.replace('VideoAudio', 'Video Audio');
if (type !== currentType) {
html += '<li data-role="list-divider">' + type + '</li>';
currentType = type;
}
html += '<div>';
html += '<a is="emby-linkbutton" href="#" class="lnkEditSubProfile" data-profileindex="' + i + '">';
html += '<p>' + globalize.translate('ValueCodec', profile.Codec || allText) + '</p>';
if (profile.Conditions?.length) {
html += '<p>';
html += globalize.translate('ValueConditions', profile.Conditions.map(function (c) {
return c.Property;
}).join(', '));
html += '</p>';
}
html += '</a>';
html += '<button type="button" is="paper-icon-button-light" class="btnDeleteProfile listItemButton" data-profileindex="' + i + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
html += '</div>';
}
html += '</ul>';
const elem = $('.codecProfiles', page).html(html).trigger('create');
$('.btnDeleteProfile', elem).on('click', function () {
const index = this.getAttribute('data-profileindex');
deleteCodecProfile(page, index);
});
$('.lnkEditSubProfile', elem).on('click', function () {
const index = parseInt(this.getAttribute('data-profileindex'), 10);
editCodecProfile(page, currentProfile.CodecProfiles[index]);
});
}
function deleteCodecProfile(page, index) {
currentProfile.CodecProfiles.splice(index, 1);
renderCodecProfiles(page, currentProfile.CodecProfiles);
}
function editCodecProfile(page, codecProfile) {
isSubProfileNew = codecProfile == null;
codecProfile = codecProfile || {};
currentSubProfile = codecProfile;
const popup = $('#codecProfilePopup', page);
$('#selectCodecProfileType', popup).val(codecProfile.Type || 'Video').trigger('change');
$('#txtCodecProfileCodec', popup).val(codecProfile.Codec || '');
$('.radioTabButton:first', popup).trigger('click');
openPopup(popup[0]);
}
function saveCodecProfile(page) {
currentSubProfile.Type = $('#selectCodecProfileType', page).val();
currentSubProfile.Codec = $('#txtCodecProfileCodec', page).val();
if (isSubProfileNew) {
currentProfile.CodecProfiles.push(currentSubProfile);
}
renderSubProfiles(page, currentProfile);
currentSubProfile = null;
closePopup($('#codecProfilePopup', page)[0]);
}
function renderResponseProfiles(page, profiles) {
let html = '';
html += '<ul data-role="listview" data-inset="true" data-split-icon="delete">';
let currentType;
for (let i = 0, length = profiles.length; i < length; i++) {
const profile = profiles[i];
if (profile.Type !== currentType) {
html += '<li data-role="list-divider">' + profile.Type + '</li>';
currentType = profile.Type;
}
html += '<div>';
html += '<a is="emby-linkbutton" href="#" class="lnkEditSubProfile" data-profileindex="' + i + '">';
html += '<p>' + globalize.translate('ValueContainer', profile.Container || allText) + '</p>';
if (profile.Type == 'Video') {
html += '<p>' + globalize.translate('ValueVideoCodec', profile.VideoCodec || allText) + '</p>';
html += '<p>' + globalize.translate('ValueAudioCodec', profile.AudioCodec || allText) + '</p>';
} else if (profile.Type == 'Audio') {
html += '<p>' + globalize.translate('ValueCodec', profile.AudioCodec || allText) + '</p>';
}
if (profile.Conditions?.length) {
html += '<p>';
html += globalize.translate('ValueConditions', profile.Conditions.map(function (c) {
return c.Property;
}).join(', '));
html += '</p>';
}
html += '</a>';
html += '<button type="button" is="paper-icon-button-light" class="btnDeleteProfile listItemButton" data-profileindex="' + i + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
html += '</div>';
}
html += '</ul>';
const elem = $('.mediaProfiles', page).html(html).trigger('create');
$('.btnDeleteProfile', elem).on('click', function () {
const index = this.getAttribute('data-profileindex');
deleteResponseProfile(page, index);
});
$('.lnkEditSubProfile', elem).on('click', function () {
const index = parseInt(this.getAttribute('data-profileindex'), 10);
editResponseProfile(page, currentProfile.ResponseProfiles[index]);
});
}
function deleteResponseProfile(page, index) {
currentProfile.ResponseProfiles.splice(index, 1);
renderResponseProfiles(page, currentProfile.ResponseProfiles);
}
function editResponseProfile(page, responseProfile) {
isSubProfileNew = responseProfile == null;
responseProfile = responseProfile || {};
currentSubProfile = responseProfile;
const popup = $('#responseProfilePopup', page);
$('#selectResponseProfileType', popup).val(responseProfile.Type || 'Video').trigger('change');
$('#txtResponseProfileContainer', popup).val(responseProfile.Container || '');
$('#txtResponseProfileAudioCodec', popup).val(responseProfile.AudioCodec || '');
$('#txtResponseProfileVideoCodec', popup).val(responseProfile.VideoCodec || '');
$('.radioTabButton:first', popup).trigger('click');
openPopup(popup[0]);
}
function saveResponseProfile(page) {
currentSubProfile.Type = $('#selectResponseProfileType', page).val();
currentSubProfile.Container = $('#txtResponseProfileContainer', page).val();
currentSubProfile.AudioCodec = $('#txtResponseProfileAudioCodec', page).val();
currentSubProfile.VideoCodec = $('#txtResponseProfileVideoCodec', page).val();
if (isSubProfileNew) {
currentProfile.ResponseProfiles.push(currentSubProfile);
}
renderSubProfiles(page, currentProfile);
currentSubProfile = null;
closePopup($('#responseProfilePopup', page)[0]);
}
function saveProfile(page, profile) {
updateProfile(page, profile);
const id = getParameterByName('id');
if (id) {
ApiClient.ajax({
type: 'POST',
url: ApiClient.getUrl('Dlna/Profiles/' + id),
data: JSON.stringify(profile),
contentType: 'application/json'
}).then(function () {
toast(globalize.translate('SettingsSaved'));
}, Dashboard.processErrorResponse);
} else {
ApiClient.ajax({
type: 'POST',
url: ApiClient.getUrl('Dlna/Profiles'),
data: JSON.stringify(profile),
contentType: 'application/json'
}).then(function () {
Dashboard.navigate('dashboard/dlna/profiles');
}, Dashboard.processErrorResponse);
}
loading.hide();
}
function updateProfile(page, profile) {
profile.Name = $('#txtName', page).val();
profile.EnableAlbumArtInDidl = $('#chkEnableAlbumArtInDidl', page).is(':checked');
profile.EnableSingleAlbumArtLimit = $('#chkEnableSingleImageLimit', page).is(':checked');
profile.SupportedMediaTypes = $('.chkMediaType:checked', page).get().map(function (c) {
return c.getAttribute('data-value');
}).join(',');
profile.Identification = profile.Identification || {};
profile.FriendlyName = $('#txtInfoFriendlyName', page).val();
profile.ModelName = $('#txtInfoModelName', page).val();
profile.ModelNumber = $('#txtInfoModelNumber', page).val();
profile.ModelDescription = $('#txtInfoModelDescription', page).val();
profile.ModelUrl = $('#txtInfoModelUrl', page).val();
profile.Manufacturer = $('#txtInfoManufacturer', page).val();
profile.ManufacturerUrl = $('#txtInfoManufacturerUrl', page).val();
profile.SerialNumber = $('#txtInfoSerialNumber', page).val();
profile.Identification.FriendlyName = $('#txtIdFriendlyName', page).val();
profile.Identification.ModelName = $('#txtIdModelName', page).val();
profile.Identification.ModelNumber = $('#txtIdModelNumber', page).val();
profile.Identification.ModelDescription = $('#txtIdModelDescription', page).val();
profile.Identification.ModelUrl = $('#txtIdModelUrl', page).val();
profile.Identification.Manufacturer = $('#txtIdManufacturer', page).val();
profile.Identification.ManufacturerUrl = $('#txtIdManufacturerUrl', page).val();
profile.Identification.SerialNumber = $('#txtIdSerialNumber', page).val();
profile.Identification.DeviceDescription = $('#txtIdDeviceDescription', page).val();
profile.AlbumArtPn = $('#txtAlbumArtPn', page).val();
profile.MaxAlbumArtWidth = $('#txtAlbumArtMaxWidth', page).val();
profile.MaxAlbumArtHeight = $('#txtAlbumArtMaxHeight', page).val();
profile.MaxIconWidth = $('#txtIconMaxWidth', page).val();
profile.MaxIconHeight = $('#txtIconMaxHeight', page).val();
profile.RequiresPlainFolders = $('#chkRequiresPlainFolders', page).is(':checked');
profile.RequiresPlainVideoItems = $('#chkRequiresPlainVideoItems', page).is(':checked');
profile.IgnoreTranscodeByteRangeRequests = $('#chkIgnoreTranscodeByteRangeRequests', page).is(':checked');
profile.MaxStreamingBitrate = $('#txtMaxAllowedBitrate', page).val();
profile.MusicStreamingTranscodingBitrate = $('#txtMusicStreamingTranscodingBitrate', page).val();
profile.ProtocolInfo = $('#txtProtocolInfo', page).val();
profile.XDlnaCap = $('#txtXDlnaCap', page).val();
profile.XDlnaDoc = $('#txtXDlnaDoc', page).val();
profile.SonyAggregationFlags = $('#txtSonyAggregationFlags', page).val();
profile.UserId = $('#selectUser', page).val();
}
let currentProfile;
let currentSubProfile;
let isSubProfileNew;
const allText = globalize.translate('All');
$(document).on('pageinit', '#dlnaProfilePage', function () {
const page = this;
$('.radioTabButton', page).on('click', function () {
$(this).siblings().removeClass('ui-btn-active');
$(this).addClass('ui-btn-active');
const value = this.tagName == 'A' ? this.getAttribute('data-value') : this.value;
const elem = $('.' + value, page);
elem.siblings('.tabContent').hide();
elem.show();
});
$('#selectDirectPlayProfileType', page).on('change', function () {
if (this.value == 'Video') {
$('#fldDirectPlayVideoCodec', page).show();
} else {
$('#fldDirectPlayVideoCodec', page).hide();
}
if (this.value == 'Photo') {
$('#fldDirectPlayAudioCodec', page).hide();
} else {
$('#fldDirectPlayAudioCodec', page).show();
}
});
$('#selectTranscodingProfileType', page).on('change', function () {
if (this.value == 'Video') {
$('#fldTranscodingVideoCodec', page).show();
$('#fldTranscodingProtocol', page).show();
$('#fldEnableMpegtsM2TsMode', page).show();
} else {
$('#fldTranscodingVideoCodec', page).hide();
$('#fldTranscodingProtocol', page).hide();
$('#fldEnableMpegtsM2TsMode', page).hide();
}
if (this.value == 'Photo') {
$('#fldTranscodingAudioCodec', page).hide();
$('#fldEstimateContentLength', page).hide();
$('#fldReportByteRangeRequests', page).hide();
} else {
$('#fldTranscodingAudioCodec', page).show();
$('#fldEstimateContentLength', page).show();
$('#fldReportByteRangeRequests', page).show();
}
});
$('#selectResponseProfileType', page).on('change', function () {
if (this.value == 'Video') {
$('#fldResponseProfileVideoCodec', page).show();
} else {
$('#fldResponseProfileVideoCodec', page).hide();
}
if (this.value == 'Photo') {
$('#fldResponseProfileAudioCodec', page).hide();
} else {
$('#fldResponseProfileAudioCodec', page).show();
}
});
$('.btnAddDirectPlayProfile', page).on('click', function () {
editDirectPlayProfile(page);
});
$('.btnAddTranscodingProfile', page).on('click', function () {
editTranscodingProfile(page);
});
$('.btnAddContainerProfile', page).on('click', function () {
editContainerProfile(page);
});
$('.btnAddCodecProfile', page).on('click', function () {
editCodecProfile(page);
});
$('.btnAddResponseProfile', page).on('click', function () {
editResponseProfile(page);
});
$('.btnAddIdentificationHttpHeader', page).on('click', function () {
editIdentificationHeader(page);
});
$('.btnAddXmlDocumentAttribute', page).on('click', function () {
editXmlDocumentAttribute(page);
});
$('.btnAddSubtitleProfile', page).on('click', function () {
editSubtitleProfile(page);
});
$('.dlnaProfileForm').off('submit', DlnaProfilePage.onSubmit).on('submit', DlnaProfilePage.onSubmit);
$('.editDirectPlayProfileForm').off('submit', DlnaProfilePage.onDirectPlayFormSubmit).on('submit', DlnaProfilePage.onDirectPlayFormSubmit);
$('.transcodingProfileForm').off('submit', DlnaProfilePage.onTranscodingProfileFormSubmit).on('submit', DlnaProfilePage.onTranscodingProfileFormSubmit);
$('.containerProfileForm').off('submit', DlnaProfilePage.onContainerProfileFormSubmit).on('submit', DlnaProfilePage.onContainerProfileFormSubmit);
$('.codecProfileForm').off('submit', DlnaProfilePage.onCodecProfileFormSubmit).on('submit', DlnaProfilePage.onCodecProfileFormSubmit);
$('.editResponseProfileForm').off('submit', DlnaProfilePage.onResponseProfileFormSubmit).on('submit', DlnaProfilePage.onResponseProfileFormSubmit);
$('.identificationHeaderForm').off('submit', DlnaProfilePage.onIdentificationHeaderFormSubmit).on('submit', DlnaProfilePage.onIdentificationHeaderFormSubmit);
$('.xmlAttributeForm').off('submit', DlnaProfilePage.onXmlAttributeFormSubmit).on('submit', DlnaProfilePage.onXmlAttributeFormSubmit);
$('.subtitleProfileForm').off('submit', DlnaProfilePage.onSubtitleProfileFormSubmit).on('submit', DlnaProfilePage.onSubtitleProfileFormSubmit);
}).on('pageshow', '#dlnaProfilePage', function () {
const page = this;
$('#radioInfo', page).trigger('click');
loadProfile(page);
});
window.DlnaProfilePage = {
onSubmit: function () {
loading.show();
saveProfile($(this).parents('.page'), currentProfile);
return false;
},
onDirectPlayFormSubmit: function () {
saveDirectPlayProfile($(this).parents('.page'));
return false;
},
onTranscodingProfileFormSubmit: function () {
saveTranscodingProfile($(this).parents('.page'));
return false;
},
onContainerProfileFormSubmit: function () {
saveContainerProfile($(this).parents('.page'));
return false;
},
onCodecProfileFormSubmit: function () {
saveCodecProfile($(this).parents('.page'));
return false;
},
onResponseProfileFormSubmit: function () {
saveResponseProfile($(this).parents('.page'));
return false;
},
onIdentificationHeaderFormSubmit: function () {
saveIdentificationHeader($(this).parents('.page'));
return false;
},
onXmlAttributeFormSubmit: function () {
saveXmlDocumentAttribute($(this).parents('.page'));
return false;
},
onSubtitleProfileFormSubmit: function () {
saveSubtitleProfile($(this).parents('.page'));
return false;
}
};

View file

@ -1,32 +0,0 @@
<div id="dlnaProfilesPage" data-role="page" class="page type-interior dlnaPage withTabs">
<div>
<div class="content-primary">
<div class="readOnlyContent">
<div class="verticalSection verticalSection-extrabottompadding">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${HeaderCustomDlnaProfiles}</h2>
<a is="emby-linkbutton" href="#/dashboard/dlna/profiles/edit" class="fab submit" style="margin:0 0 0 1em">
<span class="material-icons add" aria-hidden="true"></span>
</a>
</div>
<p>${CustomDlnaProfilesHelp}</p>
<div class="customProfiles"></div>
</div>
<div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${HeaderSystemDlnaProfiles}</h2>
</div>
<p>${SystemDlnaProfilesHelp}</p>
<div class="systemProfiles"></div>
</div>
</div>
</div>
</div>
</div>

View file

@ -1,93 +0,0 @@
import escapeHtml from 'escape-html';
import 'jquery';
import globalize from '../../../scripts/globalize';
import loading from '../../../components/loading/loading';
import libraryMenu from '../../../scripts/libraryMenu';
import '../../../components/listview/listview.scss';
import '../../../elements/emby-button/emby-button';
import confirm from '../../../components/confirm/confirm';
function loadProfiles(page) {
loading.show();
ApiClient.getJSON(ApiClient.getUrl('Dlna/ProfileInfos')).then(function (result) {
renderUserProfiles(page, result);
renderSystemProfiles(page, result);
loading.hide();
});
}
function renderUserProfiles(page, profiles) {
renderProfiles(page, page.querySelector('.customProfiles'), profiles.filter(function (p) {
return p.Type == 'User';
}));
}
function renderSystemProfiles(page, profiles) {
renderProfiles(page, page.querySelector('.systemProfiles'), profiles.filter(function (p) {
return p.Type == 'System';
}));
}
function renderProfiles(page, element, profiles) {
let html = '';
if (profiles.length) {
html += '<div class="paperList">';
}
for (let i = 0, length = profiles.length; i < length; i++) {
const profile = profiles[i];
html += '<div class="listItem listItem-border">';
html += '<span class="listItemIcon material-icons live_tv" aria-hidden="true"></span>';
html += '<div class="listItemBody two-line">';
html += "<a is='emby-linkbutton' style='padding:0;margin:0;' data-ripple='false' class='clearLink' href='#/dashboard/dlna/profiles/edit?id=" + profile.Id + "'>";
html += '<div>' + escapeHtml(profile.Name) + '</div>';
html += '</a>';
html += '</div>';
if (profile.Type == 'User') {
html += '<button type="button" is="paper-icon-button-light" class="btnDeleteProfile" data-profileid="' + profile.Id + '" title="' + globalize.translate('Delete') + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
}
html += '</div>';
}
if (profiles.length) {
html += '</div>';
}
element.innerHTML = html;
$('.btnDeleteProfile', element).on('click', function () {
const id = this.getAttribute('data-profileid');
deleteProfile(page, id);
});
}
function deleteProfile(page, id) {
confirm(globalize.translate('MessageConfirmProfileDeletion'), globalize.translate('HeaderConfirmProfileDeletion')).then(function () {
loading.show();
ApiClient.ajax({
type: 'DELETE',
url: ApiClient.getUrl('Dlna/Profiles/' + id)
}).then(function () {
loading.hide();
loadProfiles(page);
});
});
}
function getTabs() {
return [{
href: '#/dashboard/dlna',
name: globalize.translate('Settings')
}, {
href: '#/dashboard/dlna/profiles',
name: globalize.translate('TabProfiles')
}];
}
$(document).on('pageshow', '#dlnaProfilesPage', function () {
libraryMenu.setTabs('dlna', 1, getTabs);
loadProfiles(this);
});

View file

@ -1,69 +0,0 @@
<div id="dlnaSettingsPage" data-role="page" class="page type-interior withTabs">
<div>
<div class="content-primary">
<form class="dlnaSettingsForm">
<div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${Settings}</h2>
<a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton" target="_blank" href="https://jellyfin.org/docs/general/networking/dlna">${Help}</a>
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkEnablePlayTo" />
<span>${LabelEnableDlnaPlayTo}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelEnableDlnaPlayToHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkEnableDlnaDebugLogging" />
<span>${LabelEnableDlnaDebugLogging}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelEnableDlnaDebugLoggingHelp}</div>
</div>
<div class="inputContainer">
<input is="emby-input" type="number" id="txtClientDiscoveryInterval" min="1" max="300" label="${LabelEnableDlnaClientDiscoveryInterval}" />
<div class="fieldDescription">${LabelEnableDlnaClientDiscoveryIntervalHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkEnableServer" />
<span>${LabelEnableDlnaServer}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelEnableDlnaServerHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkBlastAliveMessages" />
<span>${LabelEnableBlastAliveMessages}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelEnableBlastAliveMessagesHelp}</div>
</div>
<div class="inputContainer">
<input is="emby-input" type="number" id="txtBlastInterval" min="1" max="3600" label="${LabelBlastMessageInterval}" />
<div class="fieldDescription">${LabelBlastMessageIntervalHelp}</div>
</div>
<div class="selectContainer">
<select is="emby-select" id="selectUser" data-mini="true" label="${LabelDefaultUser}"></select>
<div class="fieldDescription">${LabelDefaultUserHelp}</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block">
<span>${Save}</span>
</button>
</div>
</form>
</div>
</div>
</div>

View file

@ -1,60 +0,0 @@
import escapeHtml from 'escape-html';
import 'jquery';
import loading from '../../../components/loading/loading';
import libraryMenu from '../../../scripts/libraryMenu';
import globalize from '../../../scripts/globalize';
import Dashboard from '../../../utils/dashboard';
function loadPage(page, config, users) {
page.querySelector('#chkEnablePlayTo').checked = config.EnablePlayTo;
page.querySelector('#chkEnableDlnaDebugLogging').checked = config.EnableDebugLog;
$('#txtClientDiscoveryInterval', page).val(config.ClientDiscoveryIntervalSeconds);
$('#chkEnableServer', page).prop('checked', config.EnableServer);
$('#chkBlastAliveMessages', page).prop('checked', config.BlastAliveMessages);
$('#txtBlastInterval', page).val(config.BlastAliveMessageIntervalSeconds);
const usersHtml = users.map(function (u) {
return '<option value="' + u.Id + '">' + escapeHtml(u.Name) + '</option>';
}).join('');
$('#selectUser', page).html(usersHtml).val(config.DefaultUserId || '');
loading.hide();
}
function onSubmit() {
loading.show();
const form = this;
ApiClient.getNamedConfiguration('dlna').then(function (config) {
config.EnablePlayTo = form.querySelector('#chkEnablePlayTo').checked;
config.EnableDebugLog = form.querySelector('#chkEnableDlnaDebugLogging').checked;
config.ClientDiscoveryIntervalSeconds = $('#txtClientDiscoveryInterval', form).val();
config.EnableServer = $('#chkEnableServer', form).is(':checked');
config.BlastAliveMessages = $('#chkBlastAliveMessages', form).is(':checked');
config.BlastAliveMessageIntervalSeconds = $('#txtBlastInterval', form).val();
config.DefaultUserId = $('#selectUser', form).val();
ApiClient.updateNamedConfiguration('dlna', config).then(Dashboard.processServerConfigurationUpdateResult);
});
return false;
}
function getTabs() {
return [{
href: '#/dashboard/dlna',
name: globalize.translate('Settings')
}, {
href: '#/dashboard/dlna/profiles',
name: globalize.translate('TabProfiles')
}];
}
$(document).on('pageinit', '#dlnaSettingsPage', function () {
$('.dlnaSettingsForm').off('submit', onSubmit).on('submit', onSubmit);
}).on('pageshow', '#dlnaSettingsPage', function () {
libraryMenu.setTabs('dlna', 0, getTabs);
loading.show();
const page = this;
const promise1 = ApiClient.getNamedConfiguration('dlna');
const promise2 = ApiClient.getUsers();
Promise.all([promise1, promise2]).then(function (responses) {
loadPage(page, responses[0], responses[1]);
});
});

View file

@ -36,7 +36,7 @@ function handleConnectionResult(page, result) {
function submitServer(page) {
loading.show();
const host = page.querySelector('#txtServerHost').value;
const host = page.querySelector('#txtServerHost').value.replace(/\/+$/, '');
ServerConnections.connectToAddress(host, {
enableAutoLogin: appSettings.enableAutoLogin()
}).then(function(result) {

View file

@ -0,0 +1,92 @@
import React, { type FC, useCallback, useEffect, useState } from 'react';
import Events, { Event } from 'utils/events';
import serverNotifications from 'scripts/serverNotifications';
import classNames from 'classnames';
import CircularProgress, {
CircularProgressProps
} from '@mui/material/CircularProgress';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import { toPercent } from 'utils/number';
import { getCurrentDateTimeLocale } from 'scripts/globalize';
import type { ItemDto } from 'types/base/models/item-dto';
function CircularProgressWithLabel(
props: CircularProgressProps & { value: number }
) {
return (
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
<CircularProgress variant='determinate' {...props} />
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography
variant='caption'
component='div'
color='text.secondary'
>
{toPercent(props.value / 100, getCurrentDateTimeLocale())}
</Typography>
</Box>
</Box>
);
}
interface RefreshIndicatorProps {
item: ItemDto;
className?: string;
}
const RefreshIndicator: FC<RefreshIndicatorProps> = ({ item, className }) => {
const [progress, setProgress] = useState(item.RefreshProgress || 0);
const onRefreshProgress = useCallback((_e: Event, apiClient, info) => {
if (info.ItemId === item?.Id) {
setProgress(parseFloat(info.Progress));
}
}, [item?.Id]);
const unbindEvents = useCallback(() => {
Events.off(serverNotifications, 'RefreshProgress', onRefreshProgress);
}, [onRefreshProgress]);
const bindEvents = useCallback(() => {
unbindEvents();
if (item?.Id) {
Events.on(serverNotifications, 'RefreshProgress', onRefreshProgress);
}
}, [item?.Id, onRefreshProgress, unbindEvents]);
useEffect(() => {
bindEvents();
return () => {
unbindEvents();
};
}, [bindEvents, item.Id, unbindEvents]);
const progressringClass = classNames(
'progressring',
className,
{ 'hide': !progress || progress >= 100 }
);
return (
<div className={progressringClass}>
<CircularProgressWithLabel value={Math.floor(progress)} />
</div>
);
};
export default RefreshIndicator;

View file

@ -1,13 +1,11 @@
import type {
LibraryUpdateInfo,
SeriesTimerInfoDto,
TimerInfoDto,
UserItemDataDto
LibraryUpdateInfo
} from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useCallback, useEffect, useRef } from 'react';
import React, { type FC, useCallback, useEffect, useRef } from 'react';
import classNames from 'classnames';
import { Box } from '@mui/material';
import Box from '@mui/material/Box';
import Sortable from 'sortablejs';
import { useQueryClient } from '@tanstack/react-query';
import { usePlaylistsMoveItemMutation } from 'hooks/useFetchItems';
import Events, { Event } from 'utils/events';
import serverNotifications from 'scripts/serverNotifications';
@ -21,7 +19,7 @@ import itemShortcuts from 'components/shortcuts';
import MultiSelect from 'components/multiSelect/multiSelect';
import loading from 'components/loading/loading';
import focusManager from 'components/focusManager';
import { ParentId } from 'types/library';
import type { ParentId } from 'types/library';
function disableEvent(e: MouseEvent) {
e.preventDefault();
@ -40,11 +38,11 @@ interface ItemsContainerProps {
isContextMenuEnabled?: boolean;
isMultiSelectEnabled?: boolean;
isDragreOrderEnabled?: boolean;
dataMonitor?: string;
eventsToMonitor?: string[];
parentId?: ParentId;
reloadItems?: () => void;
getItemsHtml?: () => string;
children?: React.ReactNode;
queryKey?: string[]
}
const ItemsContainer: FC<ItemsContainerProps> = ({
@ -52,12 +50,14 @@ const ItemsContainer: FC<ItemsContainerProps> = ({
isContextMenuEnabled,
isMultiSelectEnabled,
isDragreOrderEnabled,
dataMonitor,
eventsToMonitor = [],
parentId,
queryKey,
reloadItems,
getItemsHtml,
children
}) => {
const queryClient = useQueryClient();
const { mutateAsync: playlistsMoveItemMutation } = usePlaylistsMoveItemMutation();
const itemsContainerRef = useRef<HTMLDivElement>(null);
const multiSelectref = useRef<MultiSelect | null>(null);
@ -172,6 +172,14 @@ const ItemsContainer: FC<ItemsContainerProps> = ({
}
}, []);
const invalidateQueries = useCallback(async () => {
await queryClient.invalidateQueries({
queryKey,
type: 'all',
refetchType: 'active'
});
}, [queryClient, queryKey]);
const notifyRefreshNeeded = useCallback(
(isInForeground: boolean) => {
if (!reloadItems) return;
@ -184,144 +192,37 @@ const ItemsContainer: FC<ItemsContainerProps> = ({
[reloadItems]
);
const getEventsToMonitor = useCallback(() => {
const monitor = dataMonitor;
if (monitor) {
return monitor.split(',');
}
return [];
}, [dataMonitor]);
const onUserDataChanged = useCallback(
(_e: Event, userData: UserItemDataDto) => {
const itemsContainer = itemsContainerRef.current as HTMLDivElement;
import('../../components/cardbuilder/cardBuilder')
.then((cardBuilder) => {
cardBuilder.onUserDataChanged(userData, itemsContainer);
})
.catch((err) => {
console.error(
'[onUserDataChanged] failed to load onUserData Changed',
err
);
});
const eventsToMonitor = getEventsToMonitor();
if (
eventsToMonitor.indexOf('markfavorite') !== -1
|| eventsToMonitor.indexOf('markplayed') !== -1
) {
notifyRefreshNeeded(false);
}
},
[getEventsToMonitor, notifyRefreshNeeded]
const onUserDataChanged = useCallback(async () => {
await invalidateQueries();
},
[invalidateQueries]
);
const onTimerCreated = useCallback(
(_e: Event, data: TimerInfoDto) => {
const itemsContainer = itemsContainerRef.current as HTMLDivElement;
const eventsToMonitor = getEventsToMonitor();
if (eventsToMonitor.indexOf('timers') !== -1) {
notifyRefreshNeeded(false);
return;
}
const programId = data.ProgramId;
// This could be null, not supported by all tv providers
const newTimerId = data.Id;
if (programId && newTimerId) {
import('../../components/cardbuilder/cardBuilder')
.then((cardBuilder) => {
cardBuilder.onTimerCreated(
programId,
newTimerId,
itemsContainer
);
})
.catch((err) => {
console.error(
'[onTimerCreated] failed to load onTimer Created',
err
);
});
}
},
[getEventsToMonitor, notifyRefreshNeeded]
const onTimerCreated = useCallback(async () => {
await invalidateQueries();
},
[invalidateQueries]
);
const onSeriesTimerCreated = useCallback(() => {
const eventsToMonitor = getEventsToMonitor();
if (eventsToMonitor.indexOf('seriestimers') !== -1) {
notifyRefreshNeeded(false);
}
}, [getEventsToMonitor, notifyRefreshNeeded]);
const onSeriesTimerCreated = useCallback(async () => {
await invalidateQueries();
}, [invalidateQueries]);
const onTimerCancelled = useCallback(
(_e: Event, data: TimerInfoDto) => {
const itemsContainer = itemsContainerRef.current as HTMLDivElement;
const eventsToMonitor = getEventsToMonitor();
if (eventsToMonitor.indexOf('timers') !== -1) {
notifyRefreshNeeded(false);
return;
}
const timerId = data.Id;
if (timerId) {
import('../../components/cardbuilder/cardBuilder')
.then((cardBuilder) => {
cardBuilder.onTimerCancelled(timerId, itemsContainer);
})
.catch((err) => {
console.error(
'[onTimerCancelled] failed to load onTimer Cancelled',
err
);
});
}
},
[getEventsToMonitor, notifyRefreshNeeded]
const onTimerCancelled = useCallback(async () => {
await invalidateQueries();
},
[invalidateQueries]
);
const onSeriesTimerCancelled = useCallback(
(_e: Event, data: SeriesTimerInfoDto) => {
const itemsContainer = itemsContainerRef.current as HTMLDivElement;
const eventsToMonitor = getEventsToMonitor();
if (eventsToMonitor.indexOf('seriestimers') !== -1) {
notifyRefreshNeeded(false);
return;
}
const cancelledTimerId = data.Id;
if (cancelledTimerId) {
import('../../components/cardbuilder/cardBuilder')
.then((cardBuilder) => {
cardBuilder.onSeriesTimerCancelled(
cancelledTimerId,
itemsContainer
);
})
.catch((err) => {
console.error(
'[onSeriesTimerCancelled] failed to load onSeriesTimer Cancelled',
err
);
});
}
},
[getEventsToMonitor, notifyRefreshNeeded]
const onSeriesTimerCancelled = useCallback(async () => {
await invalidateQueries();
},
[invalidateQueries]
);
const onLibraryChanged = useCallback(
(_e: Event, data: LibraryUpdateInfo) => {
const eventsToMonitor = getEventsToMonitor();
if (
eventsToMonitor.indexOf('seriestimers') !== -1
|| eventsToMonitor.indexOf('timers') !== -1
) {
(_e: Event, apiClient, data: LibraryUpdateInfo) => {
if (eventsToMonitor.includes('seriestimers') || eventsToMonitor.includes('timers')) {
// yes this is an assumption
return;
}
@ -348,32 +249,31 @@ const ItemsContainer: FC<ItemsContainerProps> = ({
notifyRefreshNeeded(false);
},
[getEventsToMonitor, notifyRefreshNeeded, parentId]
[eventsToMonitor, notifyRefreshNeeded, parentId]
);
const onPlaybackStopped = useCallback(
(_e: Event, stopInfo) => {
(_e: Event, apiClient, stopInfo) => {
const state = stopInfo.state;
const eventsToMonitor = getEventsToMonitor();
if (
state.NowPlayingItem
&& state.NowPlayingItem.MediaType === 'Video'
) {
if (eventsToMonitor.indexOf('videoplayback') !== -1) {
if (eventsToMonitor.includes('videoplayback')) {
notifyRefreshNeeded(true);
return;
}
} else if (
state.NowPlayingItem
&& state.NowPlayingItem.MediaType === 'Audio'
&& eventsToMonitor.indexOf('audioplayback') !== -1
&& eventsToMonitor.includes('videoplayback')
) {
notifyRefreshNeeded(true);
return;
}
},
[getEventsToMonitor, notifyRefreshNeeded]
[eventsToMonitor, notifyRefreshNeeded]
);
const setFocus = useCallback(
@ -418,10 +318,9 @@ const ItemsContainer: FC<ItemsContainerProps> = ({
if (getItemsHtml) {
itemsContainer.innerHTML = getItemsHtml();
imageLoader.lazyChildren(itemsContainer);
}
imageLoader.lazyChildren(itemsContainer);
if (hasActiveElement) {
setFocus(itemsContainer, focusId);
}

View file

@ -1,5 +1,6 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useCallback } from 'react';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { useQueryClient } from '@tanstack/react-query';
import React, { type FC, useCallback } from 'react';
import CheckIcon from '@mui/icons-material/Check';
import { IconButton } from '@mui/material';
import classNames from 'classnames';
@ -10,28 +11,30 @@ interface PlayedButtonProps {
className?: string;
isPlayed : boolean | undefined;
itemId: string | null | undefined;
itemType: string | null | undefined
itemType: string | null | undefined,
queryKey?: string[]
}
const PlayedButton: FC<PlayedButtonProps> = ({
className,
isPlayed = false,
itemId,
itemType
itemType,
queryKey
}) => {
const queryClient = useQueryClient();
const { mutateAsync: togglePlayedMutation } = useTogglePlayedMutation();
const [playedState, setPlayedState] = React.useState<boolean>(isPlayed);
const getTitle = useCallback(() => {
let buttonTitle;
if (itemType !== BaseItemKind.AudioBook) {
buttonTitle = playedState ? globalize.translate('Watched') : globalize.translate('MarkPlayed');
buttonTitle = isPlayed ? globalize.translate('Watched') : globalize.translate('MarkPlayed');
} else {
buttonTitle = playedState ? globalize.translate('Played') : globalize.translate('MarkPlayed');
buttonTitle = isPlayed ? globalize.translate('Played') : globalize.translate('MarkPlayed');
}
return buttonTitle;
}, [playedState, itemType]);
}, [itemType, isPlayed]);
const onClick = useCallback(async () => {
try {
@ -39,23 +42,29 @@ const PlayedButton: FC<PlayedButtonProps> = ({
throw new Error('Item has no Id');
}
const _isPlayed = await togglePlayedMutation({
await togglePlayedMutation({
itemId,
playedState
});
setPlayedState(!!_isPlayed);
isPlayed
},
{ onSuccess: async() => {
await queryClient.invalidateQueries({
queryKey,
type: 'all',
refetchType: 'active'
});
} });
} catch (e) {
console.error(e);
}
}, [playedState, itemId, togglePlayedMutation]);
}, [itemId, togglePlayedMutation, isPlayed, queryClient, queryKey]);
const btnClass = classNames(
className,
{ 'playstatebutton-played': playedState }
{ 'playstatebutton-played': isPlayed }
);
const iconClass = classNames(
{ 'playstatebutton-icon-played': playedState }
{ 'playstatebutton-icon-played': isPlayed }
);
return (
<IconButton

View file

@ -0,0 +1,79 @@
import React, { type FC, useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import LinearProgress, { linearProgressClasses } from '@mui/material/LinearProgress';
import { useTheme } from '@mui/material/styles';
import type { ProgressOptions } from 'types/progressOptions';
interface AutoTimeProgressBarProps {
pct: number;
starTtime: number;
endTtime: number;
isRecording: boolean;
dataAutoMode?: string;
progressOptions?: ProgressOptions;
}
const AutoTimeProgressBar: FC<AutoTimeProgressBarProps> = ({
pct,
dataAutoMode,
isRecording,
starTtime,
endTtime,
progressOptions
}) => {
const [progress, setProgress] = useState(pct);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const theme = useTheme();
const onAutoTimeProgress = useCallback(() => {
const start = parseInt(starTtime.toString(), 10);
const end = parseInt(endTtime.toString(), 10);
const now = new Date().getTime();
const total = end - start;
let percentage = 100 * ((now - start) / total);
percentage = Math.min(100, percentage);
percentage = Math.max(0, percentage);
setProgress(percentage);
}, [endTtime, starTtime]);
useEffect(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
if (dataAutoMode === 'time') {
timerRef.current = setInterval(onAutoTimeProgress, 60000);
}
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [dataAutoMode, onAutoTimeProgress]);
const progressBarClass = classNames(
'itemLinearProgress',
progressOptions?.containerClass
);
return (
<LinearProgress
className={progressBarClass}
variant='determinate'
value={progress}
sx={{
[`& .${linearProgressClasses.bar}`]: {
borderRadius: 5,
backgroundColor: isRecording ? theme.palette.error.main : theme.palette.primary.main
}
}}
/>
);
};
export default AutoTimeProgressBar;

View file

@ -1,4 +1,5 @@
import React, { FC, useCallback } from 'react';
import React, { type FC, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import FavoriteIcon from '@mui/icons-material/Favorite';
import { IconButton } from '@mui/material';
import classNames from 'classnames';
@ -8,16 +9,18 @@ import globalize from 'scripts/globalize';
interface FavoriteButtonProps {
className?: string;
isFavorite: boolean | undefined;
itemId: string | null | undefined
itemId: string | null | undefined;
queryKey?: string[]
}
const FavoriteButton: FC<FavoriteButtonProps> = ({
className,
isFavorite = false,
itemId
itemId,
queryKey
}) => {
const queryClient = useQueryClient();
const { mutateAsync: toggleFavoriteMutation } = useToggleFavoriteMutation();
const [favoriteState, setFavoriteState] = React.useState<boolean>(isFavorite);
const onClick = useCallback(async () => {
try {
@ -25,28 +28,34 @@ const FavoriteButton: FC<FavoriteButtonProps> = ({
throw new Error('Item has no Id');
}
const _isFavorite = await toggleFavoriteMutation({
await toggleFavoriteMutation({
itemId,
favoriteState
});
setFavoriteState(!!_isFavorite);
isFavorite
},
{ onSuccess: async() => {
await queryClient.invalidateQueries({
queryKey,
type: 'all',
refetchType: 'active'
});
} });
} catch (e) {
console.error(e);
}
}, [favoriteState, itemId, toggleFavoriteMutation]);
}, [isFavorite, itemId, queryClient, queryKey, toggleFavoriteMutation]);
const btnClass = classNames(
className,
{ 'ratingbutton-withrating': favoriteState }
{ 'ratingbutton-withrating': isFavorite }
);
const iconClass = classNames(
{ 'ratingbutton-icon-withrating': favoriteState }
{ 'ratingbutton-icon-withrating': isFavorite }
);
return (
<IconButton
title={favoriteState ? globalize.translate('Favorite') : globalize.translate('AddToFavorites')}
title={isFavorite ? globalize.translate('Favorite') : globalize.translate('AddToFavorites')}
className={btnClass}
size='small'
onClick={onClick}

View file

@ -1,4 +1,4 @@
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import React, { type FC, useCallback, useEffect, useRef, useState } from 'react';
import scrollerFactory from '../../libraries/scroller';
import globalize from '../../scripts/globalize';
import IconButton from '../emby-button/IconButton';

View file

@ -1,4 +1,4 @@
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import React, { type FC, useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import useElementSize from 'hooks/useElementSize';
import layoutManager from '../../components/layoutManager';

View file

@ -376,10 +376,12 @@ export const useGetItemsViewByType = (
return useQuery({
queryKey: [
'ItemsViewByType',
viewType,
parentId,
itemType,
libraryViewSettings
{
viewType,
parentId,
itemType,
libraryViewSettings
}
],
queryFn: ({ signal }) =>
fetchGetItemsViewByType(
@ -526,17 +528,17 @@ export const useGetGroupsUpcomingEpisodes = (parentId: ParentId) => {
interface ToggleFavoriteMutationProp {
itemId: string;
favoriteState: boolean
isFavorite: boolean
}
const fetchUpdateFavoriteStatus = async (
currentApi: JellyfinApiContext,
itemId: string,
favoriteState: boolean
isFavorite: boolean
) => {
const { api, user } = currentApi;
if (api && user?.Id) {
if (favoriteState) {
if (isFavorite) {
const response = await getUserLibraryApi(api).unmarkFavoriteItem({
userId: user.Id,
itemId: itemId
@ -555,24 +557,24 @@ const fetchUpdateFavoriteStatus = async (
export const useToggleFavoriteMutation = () => {
const currentApi = useApi();
return useMutation({
mutationFn: ({ itemId, favoriteState }: ToggleFavoriteMutationProp) =>
fetchUpdateFavoriteStatus(currentApi, itemId, favoriteState )
mutationFn: ({ itemId, isFavorite }: ToggleFavoriteMutationProp) =>
fetchUpdateFavoriteStatus(currentApi, itemId, isFavorite )
});
};
interface TogglePlayedMutationProp {
itemId: string;
playedState: boolean
isPlayed: boolean
}
const fetchUpdatePlayedState = async (
currentApi: JellyfinApiContext,
itemId: string,
playedState: boolean
isPlayed: boolean
) => {
const { api, user } = currentApi;
if (api && user?.Id) {
if (playedState) {
if (isPlayed) {
const response = await getPlaystateApi(api).markUnplayedItem({
userId: user.Id,
itemId: itemId
@ -591,8 +593,8 @@ const fetchUpdatePlayedState = async (
export const useTogglePlayedMutation = () => {
const currentApi = useApi();
return useMutation({
mutationFn: ({ itemId, playedState }: TogglePlayedMutationProp) =>
fetchUpdatePlayedState(currentApi, itemId, playedState )
mutationFn: ({ itemId, isPlayed }: TogglePlayedMutationProp) =>
fetchUpdatePlayedState(currentApi, itemId, isPlayed )
});
};
@ -676,7 +678,7 @@ const fetchGetTimers = async (
export const useGetTimers = (isUpcomingRecordingsEnabled: boolean, indexByDate?: boolean) => {
const currentApi = useApi();
return useQuery({
queryKey: ['Timers', isUpcomingRecordingsEnabled, indexByDate],
queryKey: ['Timers', { isUpcomingRecordingsEnabled, indexByDate }],
queryFn: ({ signal }) =>
isUpcomingRecordingsEnabled ? fetchGetTimers(currentApi, indexByDate, { signal }) : []
});
@ -830,7 +832,7 @@ const fetchGetSectionItems = async (
],
parentId: parentId ?? undefined,
imageTypeLimit: 1,
enableImageTypes: [ImageType.Primary],
enableImageTypes: [ImageType.Primary, ImageType.Thumb],
...section.parametersOptions
},
{
@ -882,19 +884,15 @@ const getSectionsWithItems = async (
const updatedSectionWithItems: SectionWithItems[] = [];
for (const section of sections) {
try {
const items = await fetchGetSectionItems(
currentApi, parentId, section, options
);
const items = await fetchGetSectionItems(
currentApi, parentId, section, options
);
if (items && items.length > 0) {
updatedSectionWithItems.push({
section,
items
});
}
} catch (error) {
console.error(`Error occurred for section ${section.type}: ${error}`);
if (items && items.length > 0) {
updatedSectionWithItems.push({
section,
items
});
}
}
@ -908,7 +906,7 @@ export const useGetSuggestionSectionsWithItems = (
const currentApi = useApi();
const sections = getSuggestionSections();
return useQuery({
queryKey: ['SuggestionSectionWithItems', suggestionSectionType],
queryKey: ['SuggestionSectionWithItems', { suggestionSectionType }],
queryFn: ({ signal }) =>
getSectionsWithItems(currentApi, parentId, sections, suggestionSectionType, { signal }),
enabled: !!parentId
@ -922,9 +920,8 @@ export const useGetProgramsSectionsWithItems = (
const currentApi = useApi();
const sections = getProgramSections();
return useQuery({
queryKey: ['ProgramSectionWithItems', programSectionType],
queryFn: ({ signal }) =>
getSectionsWithItems(currentApi, parentId, sections, programSectionType, { signal })
queryKey: ['ProgramSectionWithItems', { programSectionType }],
queryFn: ({ signal }) => getSectionsWithItems(currentApi, parentId, sections, programSectionType, { signal })
});
};

View file

@ -194,7 +194,8 @@ function supportsHdr10(options) {
|| browser.web0s
|| browser.safari && ((browser.iOS && browser.iOSVersion >= 11) || browser.osx)
// Chrome mobile and Firefox have no client side tone-mapping
// Edge Chromium on Nvidia is known to have color issues on 10-bit video
// Edge Chromium 121+ fixed the tone-mapping color issue on Nvidia
|| browser.edgeChromium && (browser.versionMajor >= 121)
|| browser.chrome && !browser.mobile
);
}
@ -394,8 +395,7 @@ export function canPlaySecondaryAudio(videoTestElement) {
&& !browser.firefox
// It seems to work on Tizen 5.5+ (2020, Chrome 69+). See https://developer.tizen.org/forums/web-application-development/video-tag-not-work-audiotracks
&& (browser.tizenVersion >= 5.5 || !browser.tizen)
// Assume webOS 5+ (2020, Chrome 68+) supports secondary audio like Tizen 5.5+
&& (browser.web0sVersion >= 5.0 || !browser.web0sVersion);
&& (browser.web0sVersion >= 4.0 || !browser.web0sVersion);
}
export default function (options) {
@ -573,8 +573,8 @@ export default function (options) {
const hlsInFmp4VideoCodecs = [];
if (canPlayAv1(videoTestElement)
&& !browser.mobile && (browser.edgeChromium || browser.firefox || browser.chrome)) {
// disable av1 on mobile since it can be very slow software decoding
&& (browser.safari || (!browser.mobile && (browser.edgeChromium || browser.firefox || browser.chrome)))) {
// disable av1 on non-safari mobile browsers since it can be very slow software decoding
hlsInFmp4VideoCodecs.push('av1');
}
@ -591,11 +591,7 @@ export default function (options) {
}
if (canPlayHevc(videoTestElement, options)) {
// safari is lying on HDR and 60fps videos, use fMP4 instead
if (!browser.safari) {
mp4VideoCodecs.push('hevc');
}
mp4VideoCodecs.push('hevc');
if (browser.tizen || browser.web0s) {
hlsInTsVideoCodecs.push('hevc');
}
@ -619,12 +615,20 @@ export default function (options) {
if (canPlayVp9) {
mp4VideoCodecs.push('vp9');
webmVideoCodecs.push('vp9');
// webm support is unreliable on safari 17
if (!browser.safari
|| (browser.safari && browser.versionMajor >= 15 && browser.versionMajor < 17)) {
webmVideoCodecs.push('vp9');
}
}
if (canPlayAv1(videoTestElement)) {
mp4VideoCodecs.push('av1');
webmVideoCodecs.push('av1');
// webm support is unreliable on safari 17
if (!browser.safari
|| (browser.safari && browser.versionMajor >= 15 && browser.versionMajor < 17)) {
webmVideoCodecs.push('av1');
}
}
if (canPlayVp8 || browser.tizen) {
@ -1106,6 +1110,25 @@ export default function (options) {
});
}
// Safari quirks for HEVC direct-play
if (browser.safari) {
// Only hvc1 & dvh1 tags are supported
hevcCodecProfileConditions.push({
Condition: 'EqualsAny',
Property: 'VideoCodecTag',
Value: 'hvc1|dvh1',
IsRequired: true
});
// Framerate above 60fps is not supported
hevcCodecProfileConditions.push({
Condition: 'LessThanEqual',
Property: 'VideoFramerate',
Value: '60',
IsRequired: true
});
}
// On iOS 12.x, for TS container max h264 level is 4.2
if (browser.iOS && browser.iOSVersion < 13) {
const codecProfile = {

View file

@ -1722,5 +1722,6 @@
"LabelEnableAudioVbrHelp": "معدل البِت المتغير ينتج على جودة أفضل مقارنة بمعدل البت المتوسط، ولكن في بعض الحالات النادرة قد يسبب مشاكل في التخزين المؤقت والتوافق.",
"LabelSegmentKeepSecondsHelp": "الزمن بالثواني الذي يجب الاحتفاظ به للشرائح قبل أن يتم الكتابة فوقها. يجب أن يكون أكبر من \"بعد الخنق\". يعمل هذا ألأعداد فقط إذا كان حذف الشرائح مفعلًا.",
"AiTranslated": "مترجمة من قبل ذكاء اسطناعي",
"SelectAudioNormalizationHelp": "كسب الالبوم-تعديل الصوت لكل مسار لكي يعملون بنفس مستوى- كسب الالبوم- تعديل مستوى الصوت لكل المسارات في البوم واحد مع ابقاء على النطاق الديناميكي للألبوم."
"SelectAudioNormalizationHelp": "كسب الالبوم-تعديل الصوت لكل مسار لكي يعملون بنفس مستوى- كسب الالبوم- تعديل مستوى الصوت لكل المسارات في البوم واحد مع ابقاء على النطاق الديناميكي للألبوم.",
"ButtonEditUser": "تعديل مستخدم"
}

View file

@ -231,7 +231,7 @@
"LabelCollection": "Поредица",
"LabelCommunityRating": "Обществена оценка",
"LabelContentType": "Тип на съдържанието",
"LabelCountry": "Държава",
"LabelCountry": "Държава/Регион",
"LabelCriticRating": "Оценка на критиците",
"LabelCurrentPassword": "Текуща парола",
"LabelCustomCertificatePath": "Ръчно задаване на пътя към SSL сертификата",
@ -1464,7 +1464,7 @@
"LabelMaxDaysForNextUpHelp": "Задайте максималния брой дни, през които едно шоу би трябвало да остане в списъка „Next Up“, без да го гледате.",
"LabelEnableAudioVbr": "Разреши VBR звуково кодиране",
"HeaderPerformance": "Производителност",
"LabelChapterImageResolutionHelp": "Резолюцията на извлечените снимки към раздела.",
"LabelChapterImageResolutionHelp": "Резолюцията на извлечените снимки към раздела. Промяна в тези настройки няма да повлияе на съществуващите тестови раздели.",
"AllowCollectionManagement": "Позволи този потребител да управлява колекции",
"LabelDummyChapterDuration": "Интервал",
"LabelDummyChapterCount": "Граница",
@ -1488,5 +1488,19 @@
"LabelDeveloper": "Програмист",
"LabelDate": "Дата",
"GridView": "Грид изглед",
"HeaderRecordingMetadataSaving": "Запис на Мета-данни"
"HeaderRecordingMetadataSaving": "Запис на Мета-данни",
"ButtonEditUser": "Редактиране на потребител",
"ChannelResolutionSD": "SD",
"ChannelResolutionHD": "Висока резолюция",
"ChannelResolutionFullHD": "Пълна висока резолюция",
"ChannelResolutionUHD4K": "4К Резолюция",
"HeaderDummyChapter": "Кадъри от видео сегмент",
"HeaderEpisodesStatus": "Статус на епизод",
"LabelMediaDetails": "Детайли на медия",
"HeaderAllRecordings": "Всички записи",
"LabelLevel": "Ниво",
"LabelSelectAudioNormalization": "Аудио нормализация",
"LabelBuildVersion": "Софтуерна версия",
"LabelEnableLUFSScanHelp": "Нормализаране на силата на звука на всички аудио файлове. Това ще увеличи времети за сканиране на библиотеката и ще използва допъкнителни ресурси.",
"HeaderGuestCast": "Гостуващи актьори"
}

View file

@ -1318,8 +1318,8 @@
"LabelSyncPlayTimeOffset": "Časový rozdíl mezi serverem:",
"HeaderSyncPlayEnabled": "Synchronizace přehrávání povolena",
"HeaderSyncPlaySelectGroup": "Připojit ke skupině",
"EnableDetailsBannerHelp": "Zobrazí obrázek ve vrchní části detailu položky.",
"EnableDetailsBanner": "Obrázek detailu",
"EnableDetailsBannerHelp": "Zobrazí banner v horní části stránky s detailem položky.",
"EnableDetailsBanner": "Zobrazit banner na stránce s detailem",
"ShowMore": "Zobrazit více",
"ShowLess": "Zobrazit méně",
"EnableBlurHashHelp": "Obrázky, které se ještě načítají, budou zobrazeny pomocí jedinečných zástupných obrázků.",
@ -1785,5 +1785,12 @@
"HeaderAllRecordings": "Všechny nahrávky",
"LabelBuildVersion": "Verze sestavení",
"LabelServerVersion": "Verze serveru",
"LabelWebVersion": "Verze webu"
"LabelWebVersion": "Verze webu",
"ButtonEditUser": "Upravit uživatele",
"DlnaMovedMessage": "Funkce DLNA byla přesunuta do zásuvného modulu.",
"ChannelResolutionSD": "SD",
"ChannelResolutionSDPAL": "SD (PAL)",
"ChannelResolutionHD": "HD",
"ChannelResolutionFullHD": "Full HD",
"ChannelResolutionUHD4K": "UHD (4K)"
}

View file

@ -346,7 +346,7 @@
"LabelCollection": "Samling",
"LabelCommunityRating": "Fællesskabsvurdering",
"LabelContentType": "Indholdstype",
"LabelCountry": "Land",
"LabelCountry": "Land/Region",
"LabelCriticRating": "Kritiker bedømmelse",
"LabelCurrentPassword": "Nuværende kode",
"LabelCustomCertificatePath": "Brugerdefineret SSL certifikat sti",
@ -1754,5 +1754,12 @@
"LabelLevel": "Niveau",
"LabelMediaDetails": "Media detaljer",
"MenuClose": "Luk menu",
"MediaInfoDvBlSignalCompatibilityId": "DV bl signals kompatibilitets id"
"MediaInfoDvBlSignalCompatibilityId": "DV bl signals kompatibilitets id",
"ButtonEditUser": "Rediger bruger",
"HeaderAllRecordings": "alle optagelser",
"ListView": "liste udsigt",
"LabelVppTonemappingContrastHelp": "Tilføj kontrastere",
"LogLevel.Trace": "Spore",
"LabelServerVersion": "server version",
"LabelWebVersion": "Web version"
}

View file

@ -1761,7 +1761,7 @@
"LabelThrottleDelaySeconds": "Drosseln nach",
"LabelThrottleDelaySecondsHelp": "Zeit in Sekunden, nach der die Transkodierung gedrosselt wird. Muss groß genug sein, damit der Client einen gesunden Puffer aufrechterhalten kann. Funktioniert nur wenn \"Transkodierung drosseln\" aktiviert ist.",
"LabelSegmentKeepSeconds": "Zeit, wie lange Segmente behalten werden",
"LabelSegmentKeepSecondsHelp": "Zeit in Sekunden, in der Segmente nicht überschrieben werden dürfen. Muss größer sein als \"Drosseln nach\". Funktioniert nur wenn \"Segmente löschen\" aktiviert ist.",
"LabelSegmentKeepSecondsHelp": "Zeit in Sekunden, für die Segmente behalten werden sollen, bevor sie überschrieben werden. Muss größer sein als \"Drosseln nach\". Funktioniert nur wenn \"Segmente löschen\" aktiviert ist.",
"LogoScreensaver": "Logo Bildschirmschoner",
"UnknownError": "Ein unbekannter Fehler trat auf.",
"GridView": "Kachelansicht",
@ -1779,11 +1779,13 @@
"LabelBackdropScreensaverIntervalHelp": "Die Zeit in Sekunden zwischen dem Wechsel verschiedener Hintergrundbilder im Bildschirmschoner.",
"SearchResultsEmpty": "Entschuldigung! Es konnten keine Ergebnisse für „{0}“ gefunden werden",
"LabelTrackGain": "Titel Gain",
"SelectAudioNormalizationHelp": "Track Gain - passt die Lautstärke der einzelnen Tracks an, so dass sie mit der gleichen Lautstärke wiedergegeben werden. Albumverstärkung - passt die Lautstärke aller Titel eines Albums an, wobei der Dynamikbereich des Albums erhalten bleibt.",
"SelectAudioNormalizationHelp": "Track Gain - passt die Lautstärke der einzelnen Tracks an, sodass sie mit der gleichen Lautstärke wiedergegeben werden. Albumverstärkung - passt die Lautstärke aller Titel eines Albums an, wobei der Dynamikbereich des Albums erhalten bleibt.",
"LabelAlbumGain": "Albumlautstärke",
"LabelSelectAudioNormalization": "Audio Normalisierung",
"HeaderAllRecordings": "Alle Aufnahmen",
"LabelBuildVersion": "Build-Version",
"LabelServerVersion": "Server-Version",
"LabelWebVersion": "Web-Version"
"LabelWebVersion": "Web-Version",
"ButtonEditUser": "Editiere Benutzer",
"DlnaMovedMessage": "Die DLNA-Funktion wurde in ein Plugin verschoben."
}

View file

@ -1782,5 +1782,9 @@
"SearchResultsEmpty": "Sorry! No results found for \"{0}\"",
"LabelSelectAudioNormalization": "Audio Normalisation",
"LabelTrackGain": "Track Gain",
"HeaderAllRecordings": "All Recordings"
"HeaderAllRecordings": "All Recordings",
"ButtonEditUser": "Edit user",
"LabelBuildVersion": "Build version",
"LabelServerVersion": "Server version",
"LabelWebVersion": "Web version"
}

View file

@ -1782,5 +1782,6 @@
"LabelAlbumGain": "Ganancia de Álbum",
"LabelSelectAudioNormalization": "Normalización de audio",
"LabelTrackGain": "Ganancia de pista",
"HeaderAllRecordings": "Todas las grabaciones"
"HeaderAllRecordings": "Todas las grabaciones",
"ButtonEditUser": "Editar usuario"
}

View file

@ -12,7 +12,7 @@
"FileReadCancelled": "Tiedoston luku on peruutettu.",
"FileReadError": "Virhe tiedostoa luettaessa.",
"LabelAudioLanguagePreference": "Ensisijainen äänen kieli",
"LabelCountry": "Maa",
"LabelCountry": "Maa/Alue",
"LabelCurrentPassword": "Nykyinen salasana",
"LabelFinish": "Valmis",
"LabelLanguage": "Kieli",
@ -1780,5 +1780,12 @@
"LabelSelectAudioNormalization": "Äänenvoimakkuuden normalisointi",
"LabelTrackGain": "Kappelkohtainen vahvistus",
"SearchResultsEmpty": "Pahoittelut! Haku \"{0}\" ei tuottanut tuloksia",
"HeaderAllRecordings": "Kaikki tallenteet"
"HeaderAllRecordings": "Kaikki tallenteet",
"ButtonEditUser": "Muokkaa käyttäjää",
"ChannelResolutionSD": "peruslaatu",
"ChannelResolutionHD": "teräväpiirto",
"DlnaMovedMessage": "DLNA-ominaisuus on siirtynyt lisäosaan.",
"LabelBuildVersion": "Käännetty versio",
"LabelServerVersion": "Palvelimen versio",
"LabelWebVersion": "Verkko versio"
}

View file

@ -1785,5 +1785,12 @@
"HeaderAllRecordings": "Tous les enregistrements",
"LabelBuildVersion": "Numéro de build",
"LabelServerVersion": "Version du serveur",
"LabelWebVersion": "Version web"
"LabelWebVersion": "Version web",
"ButtonEditUser": "Modifier l'utilisateur",
"DlnaMovedMessage": "La fonctionnalité DLNA a été déplacée vers un plugin.",
"ChannelResolutionSD": "SD",
"ChannelResolutionSDPAL": "SD (PAL)",
"ChannelResolutionHD": "HD",
"ChannelResolutionFullHD": "Full HD",
"ChannelResolutionUHD4K": "UHD (4K)"
}

View file

@ -56,7 +56,7 @@
"UnsupportedPlayback": "Jellyfin DRM द्वारा संरक्षित सामग्री को डिक्रिप्ट नहीं कर सकता है, लेकिन सभी सामग्री की परवाह किए बिना, संरक्षित शीर्षकों सहित प्रयास किया जाएगा। एन्क्रिप्शन या अन्य असमर्थित सुविधाओं जैसे इंटरेक्टिव शीर्षक के कारण कुछ फाइलें पूरी तरह से काली दिखाई दे सकती हैं।",
"BoxRear": "बॉक्स (पीछे)",
"Box": "बॉक्स",
"Books": "पुस्तकं",
"Books": "पुस्तकं",
"BookLibraryHelp": "ऑडियो और पाठ्य पुस्तकें समर्थित हैं। {0} पुस्तक नामकरण गाइड {1} की समीक्षा करें।",
"Blacklist": "काला सूची में डालना",
"BirthPlaceValue": "जन्म स्थान: {0}",
@ -72,7 +72,7 @@
"AskAdminToCreateLibrary": "लाइब्रेरी बनाने के लिए किसी व्यवस्थापक से पूछें।",
"Ascending": "आरोही",
"AsManyAsPossible": "जितने अधिक संभव हों",
"Artists": "कलाकारों",
"Artists": "कलाकार",
"Artist": "कलाकार",
"Art": "कला",
"AroundTime": "लगभग {0}",
@ -80,10 +80,10 @@
"AnyLanguage": "कोई भी भाषा",
"AlwaysPlaySubtitlesHelp": "भाषा की वरीयता से मेल खाने वाले उपशीर्षक ऑडियो भाषा की परवाह किए बिना लोड किए जाएंगे।",
"AlwaysPlaySubtitles": "हमेशा खेलो",
"AllowedRemoteAddressesHelp": "कोमा ने नेटवर्क के लिए आईपी पते या आईपी / नेटमास्क प्रविष्टियों की सूची को अलग कर दिया है जिन्हें दूरस्थ रूप से कनेक्ट करने की अनुमति दी जाएगी। यदि खाली छोड़ दिया जाता है, तो सभी दूरस्थ पते की अनुमति दी जाएगी।",
"AllowedRemoteAddressesHelp": "नेटवर्क के लिए आईपी पतों आईपी/नेटमास्क एंट्रीज़ की कॉमा विभाजित सूची जो रिमोट रूप से कनेक्ट करने की अनुमति देगा। यदि खाली छोड़ा गया है, तो सभी रिमोट पतों को अनुमति दी जाएगी।",
"AllowRemoteAccessHelp": "अनियंत्रित होने पर, सभी दूरस्थ कनेक्शन अवरुद्ध हो जाएंगे।",
"AllowRemoteAccess": "इस सर्वर को असमीप संपर्क की अनुमति दें",
"AllowFfmpegThrottlingHelp": "जब क ट्रांसकोड या रीमक्स वर्तमान प्लेबैक स्थिति से काफी आगे हो जाता है, तो प्रक्रिया को रोकें ताकि यह कम संसाधनों का उपभोग करेगा। अक्सर मांग किए बिना देखने पर यह सबसे उपयोगी है। यदि आप प्लेबैक समस्याओं का अनुभव करते हैं तो इसे बंद कर दें।",
"AllowFfmpegThrottlingHelp": "जब ोई ट्रांसकोड या रीमक्स वर्तमान प्लेबैक स्थिति से काफी आगे निकल जाता है, तो प्रक्रिया को रोकें ताकि यह कम संसाधनों का उपभोग करे। अक्सर बिना खोजे देखते समय यह सबसे उपयोगी होता है। यदि आप प्लेबैक समस्याओं का अनुभव करते हैं तो इसे बंद कर दें।",
"AllowFfmpegThrottling": "थ्रोटल ट्रांसकोड",
"AllowOnTheFlySubtitleExtractionHelp": "वीडियो ट्रांसकोडिंग को रोकने में मदद करने के लिए एंबेडेड सबटाइटल वीडियो से निकाले जा सकते हैं और सादे पाठ में ग्राहकों तक पहुंचाए जाते हैं। कुछ प्रणालियों पर यह एक लंबा समय ले सकता है और निष्कर्षण प्रक्रिया के दौरान वीडियो प्लेबैक को स्टाल करने का कारण बन सकता है। जब वे क्लाइंट डिवाइस द्वारा मूल रूप से समर्थित नहीं होते हैं, तो वीडियो ट्रांसकोडिंग के साथ जले हुए एम्बेडेड उपशीर्षक को अक्षम करें।",
"AlbumArtist": "एल्बम कलाकार",
@ -96,13 +96,13 @@
"BurnSubtitlesHelp": "निर्धारित करता है कि वीडियो ट्रांसकोडिंग करते समय सर्वर को उपशीर्षक बर्न-इन करना चाहिए। इससे बचने से प्रदर्शन में बहुत सुधार होगा। छवि आधारित उपशीर्षक (VOBSUB, PGS, SUB, IDX, …) एवं ASS अथवा SSA जैसे उपशीर्षक बर्न-इन करने के लिए ऑटो का चयन करें।",
"ButtonRemove": "हटाना",
"ButtonOpen": "खोलो",
"HeaderContinueWatching": "देखते रहिए",
"HeaderContinueWatching": "देखना जारी रखें",
"HeaderAlbumArtists": "एल्बम कलाकार",
"Genres": "शैल",
"Folders": "फ़ोल्डरें",
"Genres": "शैलियां",
"Folders": "फ़ोल्डर",
"Favorites": "पसंदीदा",
"Default": "प्राथमिक",
"Collections": "संग्रहों",
"Collections": "संग्रह",
"Channels": "चैनल",
"Movies": "फ़िल्म",
"ButtonActivate": "सक्रिय",
@ -151,9 +151,11 @@
"Shows": "शो",
"ValueSpecialEpisodeName": "विशेष - {0}",
"Sync": "समाकलयति",
"AllowCollectionManagement": "इस यूजर को कलेक्शन परिवर्तन करने की अनुमति दें",
"AllowCollectionManagement": "इस यूजर को संग्रह प्रबंधित करने की अनुमति दें",
"AllowSegmentDeletion": "खंड हटाएँ",
"AllowSegmentDeletionHelp": "क्लाइंट को भेजे जाने के बाद पुराने सेगमेंट हटा दें। यह संपूर्ण ट्रांसकोड की गई फ़ाइल को डिस्क पर स्टोर करने से रोकता है। केवल थ्रॉटलिंग सक्षम होने पर ही काम करेगा। यदि आप प्लेबैक समस्याओं का अनुभव करते हैं तो इसे बंद कर दें।",
"LabelThrottleDelaySeconds": "थ्रॉटर बाद",
"LabelSegmentKeepSeconds": "सेगमेंट रखने का समय"
"LabelSegmentKeepSeconds": "सेगमेंट रखने का समय",
"LabelThrottleDelaySecondsHelp": "सेकंड में समय जिसके बाद ट्रांसकोडर को थ्रॉटल कर दिया जाएगा। क्लाइंट के लिए स्वस्थ बफ़र बनाए रखने के लिए पर्याप्त बड़ा होना चाहिए। केवल तभी काम करता है जब थ्रॉटलिंग सक्रिय हो।",
"LabelSegmentKeepSecondsHelp": "किन खंडों को अधिलेखित करने से पहले रखा जाना चाहिए, इसका समय सेकंड में। \"थ्रोटल आफ्टर\" से बड़ा होना चाहिए। केवल तभी काम करता है जब खंड हटाना सक्रिय हो।"
}

View file

@ -53,7 +53,7 @@
"ButtonBack": "Indietro",
"ButtonCancel": "Annulla",
"ButtonChangeServer": "Cambia Server",
"ButtonEditOtherUserPreferences": "Modifica questo utente di profilo, l'immagine e le preferenze personali.",
"ButtonEditOtherUserPreferences": "Modifica il profilo, l'immagine e le preferenze personali di questo utente.",
"ButtonForgotPassword": "Password Dimenticata",
"ButtonFullscreen": "Schermo Intero",
"ButtonGotIt": "Ho capito",
@ -74,16 +74,16 @@
"ButtonResume": "Riprendi",
"ButtonRevoke": "Revoca",
"ButtonScanAllLibraries": "Scansiona Tutte le Librerie",
"ButtonSelectDirectory": "Seleziona cartella",
"ButtonSelectDirectory": "Seleziona Cartella",
"ButtonSelectView": "Seleziona vista",
"ButtonSend": "Invia",
"ButtonShutdown": "Arresta Server",
"ButtonSignIn": "Accedi",
"ButtonSignOut": "Esci",
"ButtonStart": "Avvio",
"ButtonStart": "Avvia",
"ButtonSubmit": "Invia",
"ButtonUninstall": "Disinstalla",
"ButtonWebsite": "Web",
"ButtonWebsite": "Sito Web",
"CancelRecording": "Annulla la registrazione",
"CancelSeries": "Annulla Serie TV",
"Categories": "Categorie",
@ -1114,7 +1114,7 @@
"Box": "Scatola",
"ButtonInfo": "Info",
"ButtonOk": "Ok",
"ButtonStop": "Stop",
"ButtonStop": "Ferma",
"ButtonTrailer": "Trailer",
"ChangingMetadataImageSettingsNewContent": "I cambiamenti alle impostazioni dei download dei metadati verranno applicati solamente ai nuovi contenuti aggiunti alla libreria. Per applicare i cambiamenti ai titoli già esistenti devi ricaricare i metadati manualmente.",
"DownloadsValue": "{0} scaricati",
@ -1785,5 +1785,6 @@
"HeaderAllRecordings": "Tutte le registrazioni",
"LabelBuildVersion": "Versione Compilata",
"LabelServerVersion": "Versione server",
"LabelWebVersion": "Versione web"
"LabelWebVersion": "Versione web",
"ButtonEditUser": "Modifica utente"
}

View file

@ -729,7 +729,7 @@
"LabelCollection": "コレクション",
"LabelCommunityRating": "コミュニティ評価",
"LabelContentType": "コンテンツタイプ",
"LabelCountry": "国",
"LabelCountry": "国/地域",
"LabelDashboardTheme": "サーバーダッシュボードテーマ",
"LabelPublicHttpsPortHelp": "公開ポート番号はローカルHTTPSポートにマッピングしてください。",
"LabelAlbumArtMaxWidth": "アルバムアート最大高さ",
@ -1338,7 +1338,7 @@
"QuickConnectDescription": "クイックコネクトでサインインする場合、ログインするデバイスのクイックコネクトボタンを選択して以下のコードを入力します。",
"QuickConnectDeactivated": "ログインのリクエストが承認される前にクイックコネクトが無効化されました",
"QuickConnectAuthorizeFail": "不明なクイックコネクトコードです",
"QuickConnectAuthorizeSuccess": "リクエストが承認されました",
"QuickConnectAuthorizeSuccess": "デバイスの認証に成功しました!",
"QuickConnectAuthorizeCode": "コード {0} を入力してログイン",
"QuickConnectActivationSuccessful": "有効化に成功しました",
"QuickConnect": "クイックコネクト",
@ -1429,7 +1429,7 @@
"DeleteAll": "すべて削除",
"EnableFallbackFontHelp": "カスタム代替フォントの有効にする。これにより間違った字幕レンダリングの問題を回避することができます。",
"EnableFallbackFont": "代替フォントを有効化",
"LabelFallbackFontPathHelp": "ASS/SSA字幕レンダリングのための代替フォントを含むパスを指定してください。フォントファイルサイズは最大20MBです。woff2のような軽量でweb向けのフォントフォーマットが推奨です。",
"LabelFallbackFontPathHelp": "これらのフォントは、一部のクライアントによって字幕を表示するために使用されます。詳細については、ドキュメントを参照してください。",
"LabelFallbackFontPath": "代替フォントフォルダパス",
"HeaderSelectFallbackFontPathHelp": "ASS/SSA字幕レンダリングに使用する代替フォントフォルダパスを参照/入力。",
"HeaderSelectFallbackFontPath": "代替フォントフォルダパスを選択",
@ -1624,7 +1624,7 @@
"LabelRemuxingInfo": "リミキシング情報",
"LabelOriginalMediaInfo": "オリジナルメディア情報",
"PreferFmp4HlsContainer": "fMP4-HLSメディアコンテナを優先する",
"PreferFmp4HlsContainerHelp": "HLSのデフォルトコンテナとしてfMP4を優先的に使用し、対応デバイスでHEVCコンテンツのダイレクトストリーミングを可能にします。",
"PreferFmp4HlsContainerHelp": "HLSのデフォルトコンテナとしてfMP4を優先的に使用し、対応デバイスでHEVCとAV1コンテンツのダイレクトストリーミングを可能にします。",
"AllowHevcEncoding": "HEVCフォーマットでのエンコードを可能にする",
"LabelAllowedAudioChannels": "最大許容オーディオチャンネル数",
"LabelSelectAudioChannels": "チャンネル",
@ -1731,7 +1731,7 @@
"LogLevel.Warning": "警告",
"LogLevel.None": "なし",
"LabelEnableLUFSScan": "LUFS解析を有効",
"LabelEnableLUFSScanHelp": "音楽用のLUFS解析を有効(より時間とリソースを要します)。",
"LabelEnableLUFSScanHelp": "クライアントはオーディオ再生を正規化して、トラック間で等しい大きさを実現できます。これにより、ライブラリのスキャン時間が長くなり、より多くのリソースを消費します。",
"MenuClose": "メニューを閉じる",
"Notifications": "通知",
"NotificationsMovedMessage": "通知機能はWebhookプラグインに移行されました。",
@ -1765,5 +1765,23 @@
"LogoScreensaver": "ロゴスクリーンセーバー",
"AiTranslated": "AI翻訳",
"MachineTranslated": "機械翻訳",
"BackdropScreensaver": "背景スクリーンセーバー"
"BackdropScreensaver": "背景スクリーンセーバー",
"ButtonEditUser": "ユーザーを編集",
"SearchResultsEmpty": "申し訳ありません!「{0}」に対する結果が見つかりませんでした",
"LabelAlbumGain": "アルバムゲイン",
"HearingImpairedShort": "HI/SDH",
"LabelIsHearingImpaired": "聴覚障害者向けSDH",
"GridView": "グリッドビュー",
"ListView": "リスト表示",
"LabelBackdropScreensaverInterval": "バックドロップスクリーンセーバー間隔",
"LabelBackdropScreensaverIntervalHelp": "バックドロップスクリーンセーバーを使用時に異なるバックドロップ間での秒数。",
"HeaderAllRecordings": "すべての録音",
"SelectAudioNormalizationHelp": "トラックゲイン - 各トラックの音量を調整して、同じ大きさで再生されるようにします。アルバムゲイン - アルバム内の全てのトラックの音量を調整し、アルバムのダイナミックレンジを保持します。",
"LabelSelectAudioNormalization": "音量正規化",
"LabelBuildVersion": "ビルドバージョン",
"LabelServerVersion": "サーバーバージョン",
"LabelWebVersion": "ウェブバージョン",
"LabelTrackGain": "トラックゲイン",
"ForeignPartsOnly": "強制/外国語パートのみ",
"HeaderGuestCast": "ゲストスター"
}

View file

@ -1781,5 +1781,6 @@
"LabelAlbumGain": "Albumjustering",
"LabelSelectAudioNormalization": "Lydnormalisering",
"LabelTrackGain": "Sporjustering",
"HeaderAllRecordings": "Alle opptak"
"HeaderAllRecordings": "Alle opptak",
"ButtonEditUser": "Rediger bruker"
}

View file

@ -1784,5 +1784,12 @@
"HeaderAllRecordings": "Alle opnamen",
"LabelBuildVersion": "Buildversie",
"LabelServerVersion": "Serverversie",
"LabelWebVersion": "Webversie"
"LabelWebVersion": "Webversie",
"ButtonEditUser": "Gebruiker bewerken",
"DlnaMovedMessage": "De DLNA-functionaliteit is verplaatst naar een plug-in.",
"ChannelResolutionSD": "SD",
"ChannelResolutionSDPAL": "SD (PAL)",
"ChannelResolutionHD": "HD",
"ChannelResolutionFullHD": "Full HD",
"ChannelResolutionUHD4K": "UHD (4K)"
}

View file

@ -1786,5 +1786,11 @@
"LabelBuildVersion": "Wersja kompilacji",
"LabelServerVersion": "Wersja serwera",
"LabelWebVersion": "Wersja sieciowa",
"ButtonEditUser": "Edytuj użytkownika"
"ButtonEditUser": "Edytuj użytkownika",
"DlnaMovedMessage": "Funkcjonalność DLNA została przeniesiona do wtyczki.",
"ChannelResolutionSD": "SD",
"ChannelResolutionSDPAL": "SD (PAL)",
"ChannelResolutionHD": "HD",
"ChannelResolutionFullHD": "Full HD",
"ChannelResolutionUHD4K": "UHD (4K)"
}

Some files were not shown because too many files have changed in this diff Show more