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 enable-airplay-audioplayer

This commit is contained in:
stamatovg 2023-05-11 22:32:27 +03:00 committed by GitHub
commit 070dadbc69
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
213 changed files with 3445 additions and 5186 deletions

View file

@ -0,0 +1,20 @@
import React, { useEffect } from 'react';
const AppHeader = () => {
useEffect(() => {
// Initialize the UI components after first render
import('../scripts/libraryMenu');
}, []);
return (
<>
<div className='mainDrawer hide'>
<div className='mainDrawer-scrollContainer scrollContainer focuscontainer-y' />
</div>
<div className='skinHeader focuscontainer-x' />
<div className='mainDrawerHandle' />
</>
);
};
export default AppHeader;

View file

@ -1,17 +0,0 @@
import loadable from '@loadable/component';
interface AsyncPageProps {
/** The relative path to the page component in the routes directory. */
page: string
}
/**
* Page component that uses the loadable component library to load pages defined in the routes directory asynchronously
* with code splitting.
*/
const AsyncPage = loadable(
(props: AsyncPageProps) => import(/* webpackChunkName: "[request]" */ `../routes/${props.page}`),
{ cacheKey: (props: AsyncPageProps) => props.page }
);
export default AsyncPage;

View file

@ -0,0 +1,17 @@
import React, { useEffect } from 'react';
const Backdrop = () => {
useEffect(() => {
// Initialize the UI components after first render
import('../scripts/autoBackdrops');
}, []);
return (
<>
<div className='backdropContainer' />
<div className='backgroundContainer' />
</>
);
};
export default Backdrop;

View file

@ -1,9 +1,9 @@
import React, { FunctionComponent, useEffect, useState } from 'react';
import React, { FunctionComponent, useCallback, useEffect, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import type { ConnectResponse } from 'jellyfin-apiclient';
import alert from './alert';
import { appRouter } from './appRouter';
import { appRouter } from './router/appRouter';
import Loading from './loading/LoadingComponent';
import ServerConnections from './ServerConnections';
import globalize from '../scripts/globalize';
@ -35,116 +35,134 @@ const ConnectionRequired: FunctionComponent<ConnectionRequiredProps> = ({
const [ isLoading, setIsLoading ] = useState(true);
useEffect(() => {
const bounce = async (connectionResponse: ConnectResponse) => {
switch (connectionResponse.State) {
case ConnectionState.SignedIn:
// Already logged in, bounce to the home page
console.debug('[ConnectionRequired] already logged in, redirecting to home');
navigate(BounceRoutes.Home);
const bounce = useCallback(async (connectionResponse: ConnectResponse) => {
switch (connectionResponse.State) {
case ConnectionState.SignedIn:
// Already logged in, bounce to the home page
console.debug('[ConnectionRequired] already logged in, redirecting to home');
navigate(BounceRoutes.Home);
return;
case ConnectionState.ServerSignIn:
// Bounce to the login page
if (location.pathname === BounceRoutes.Login) {
setIsLoading(false);
} else {
console.debug('[ConnectionRequired] not logged in, redirecting to login page');
navigate(`${BounceRoutes.Login}?serverid=${connectionResponse.ApiClient.serverId()}`);
}
return;
case ConnectionState.ServerSelection:
// Bounce to select server page
console.debug('[ConnectionRequired] redirecting to select server page');
navigate(BounceRoutes.SelectServer);
return;
case ConnectionState.ServerUpdateNeeded:
// Show update needed message and bounce to select server page
try {
await alert({
text: globalize.translate('ServerUpdateNeeded', 'https://github.com/jellyfin/jellyfin'),
html: globalize.translate('ServerUpdateNeeded', '<a href="https://github.com/jellyfin/jellyfin">https://github.com/jellyfin/jellyfin</a>')
});
} catch (ex) {
console.warn('[ConnectionRequired] failed to show alert', ex);
}
console.debug('[ConnectionRequired] server update required, redirecting to select server page');
navigate(BounceRoutes.SelectServer);
return;
}
console.warn('[ConnectionRequired] unhandled connection state', connectionResponse.State);
}, [location.pathname, navigate]);
const handleIncompleteWizard = useCallback(async (firstConnection: ConnectResponse) => {
if (firstConnection.State === ConnectionState.ServerSignIn) {
// Verify the wizard is complete
try {
const infoResponse = await fetch(`${firstConnection.ApiClient.serverAddress()}/System/Info/Public`);
if (!infoResponse.ok) {
throw new Error('Public system info request failed');
}
const systemInfo = await infoResponse.json();
if (!systemInfo?.StartupWizardCompleted) {
// Update the current ApiClient
// TODO: Is there a better place to handle this?
ServerConnections.setLocalApiClient(firstConnection.ApiClient);
// Bounce to the wizard
console.info('[ConnectionRequired] startup wizard is not complete, redirecting there');
navigate(BounceRoutes.StartWizard);
return;
case ConnectionState.ServerSignIn:
// Bounce to the login page
if (location.pathname === BounceRoutes.Login) {
setIsLoading(false);
} else {
console.debug('[ConnectionRequired] not logged in, redirecting to login page');
navigate(`${BounceRoutes.Login}?serverid=${connectionResponse.ApiClient.serverId()}`);
}
return;
case ConnectionState.ServerSelection:
// Bounce to select server page
console.debug('[ConnectionRequired] redirecting to select server page');
navigate(BounceRoutes.SelectServer);
return;
case ConnectionState.ServerUpdateNeeded:
// Show update needed message and bounce to select server page
try {
await alert({
text: globalize.translate('ServerUpdateNeeded', 'https://github.com/jellyfin/jellyfin'),
html: globalize.translate('ServerUpdateNeeded', '<a href="https://github.com/jellyfin/jellyfin">https://github.com/jellyfin/jellyfin</a>')
}
} catch (ex) {
console.error('[ConnectionRequired] checking wizard status failed', ex);
return;
}
}
// Bounce to the correct page in the login flow
bounce(firstConnection)
.catch(err => {
console.error('[ConnectionRequired] failed to bounce', err);
});
}, [bounce, navigate]);
const validateUserAccess = useCallback(async () => {
const client = ServerConnections.currentApiClient();
// If this is a user route, ensure a user is logged in
if ((isAdminRequired || isUserRequired) && !client?.isLoggedIn()) {
try {
console.warn('[ConnectionRequired] unauthenticated user attempted to access user route');
bounce(await ServerConnections.connect())
.catch(err => {
console.error('[ConnectionRequired] failed to bounce', err);
});
} catch (ex) {
console.warn('[ConnectionRequired] error bouncing from user route', ex);
}
return;
}
// If this is an admin route, ensure the user has access
if (isAdminRequired) {
try {
const user = await client?.getCurrentUser();
if (!user?.Policy?.IsAdministrator) {
console.warn('[ConnectionRequired] normal user attempted to access admin route');
bounce(await ServerConnections.connect())
.catch(err => {
console.error('[ConnectionRequired] failed to bounce', err);
});
} catch (ex) {
console.warn('[ConnectionRequired] failed to show alert', ex);
}
console.debug('[ConnectionRequired] server update required, redirecting to select server page');
navigate(BounceRoutes.SelectServer);
return;
}
console.warn('[ConnectionRequired] unhandled connection state', connectionResponse.State);
};
const validateConnection = async () => {
// Check connection status on initial page load
const firstConnection = appRouter.firstConnectionResult;
appRouter.firstConnectionResult = null;
if (firstConnection && firstConnection.State !== ConnectionState.SignedIn) {
if (firstConnection.State === ConnectionState.ServerSignIn) {
// Verify the wizard is complete
try {
const infoResponse = await fetch(`${firstConnection.ApiClient.serverAddress()}/System/Info/Public`);
if (!infoResponse.ok) {
throw new Error('Public system info request failed');
}
const systemInfo = await infoResponse.json();
if (!systemInfo?.StartupWizardCompleted) {
// Update the current ApiClient
// TODO: Is there a better place to handle this?
ServerConnections.setLocalApiClient(firstConnection.ApiClient);
// Bounce to the wizard
console.info('[ConnectionRequired] startup wizard is not complete, redirecting there');
navigate(BounceRoutes.StartWizard);
return;
}
} catch (ex) {
console.error('[ConnectionRequired] checking wizard status failed', ex);
return;
}
}
// Bounce to the correct page in the login flow
bounce(firstConnection);
return;
}
// TODO: appRouter will call appHost.exit() if navigating back when you are already at the default route.
// This case will need to be handled elsewhere before appRouter can be killed.
const client = ServerConnections.currentApiClient();
// If this is a user route, ensure a user is logged in
if ((isAdminRequired || isUserRequired) && !client?.isLoggedIn()) {
try {
console.warn('[ConnectionRequired] unauthenticated user attempted to access user route');
bounce(await ServerConnections.connect());
} catch (ex) {
console.warn('[ConnectionRequired] error bouncing from user route', ex);
}
return;
}
// If this is an admin route, ensure the user has access
if (isAdminRequired) {
try {
const user = await client?.getCurrentUser();
if (!user?.Policy?.IsAdministrator) {
console.warn('[ConnectionRequired] normal user attempted to access admin route');
bounce(await ServerConnections.connect());
return;
}
} catch (ex) {
console.warn('[ConnectionRequired] error bouncing from admin route', ex);
return;
}
} catch (ex) {
console.warn('[ConnectionRequired] error bouncing from admin route', ex);
return;
}
}
setIsLoading(false);
};
setIsLoading(false);
}, [bounce, isAdminRequired, isUserRequired]);
validateConnection();
}, [ isAdminRequired, isUserRequired, location.pathname, navigate ]);
useEffect(() => {
// TODO: appRouter will call appHost.exit() if navigating back when you are already at the default route.
// This case will need to be handled elsewhere before appRouter can be killed.
// Check connection status on initial page load
const firstConnection = appRouter.firstConnectionResult;
appRouter.firstConnectionResult = null;
if (firstConnection && firstConnection.State !== ConnectionState.SignedIn) {
handleIncompleteWizard(firstConnection)
.catch(err => {
console.error('[ConnectionRequired] failed to start wizard', err);
});
} else {
validateUserAccess()
.catch(err => {
console.error('[ConnectionRequired] failed to validate user access', err);
});
}
}, [handleIncompleteWizard, validateUserAccess]);
if (isLoading) {
return <Loading />;

View file

@ -4,6 +4,7 @@ import { useLocation } from 'react-router-dom';
import ServerConnections from './ServerConnections';
import viewManager from './viewManager/viewManager';
import globalize from '../scripts/globalize';
import type { RestoreViewFailResponse } from '../types/viewManager';
interface ServerContentPageProps {
view: string
@ -29,7 +30,7 @@ const ServerContentPage: FunctionComponent<ServerContentPageProps> = ({ view })
};
viewManager.tryRestoreView(viewOptions)
.catch(async (result?: any) => {
.catch(async (result?: RestoreViewFailResponse) => {
if (!result || !result.cancelled) {
const apiClient = ServerConnections.currentApiClient();
@ -46,12 +47,13 @@ const ServerContentPage: FunctionComponent<ServerContentPageProps> = ({ view })
};
loadPage();
}, [
},
// location.state is NOT included as a dependency here since dialogs will update state while the current view stays the same
// eslint-disable-next-line react-hooks/exhaustive-deps
[
view,
location.pathname,
location.search
// location.state is NOT included as a dependency here since dialogs will update state while the current view
// stays the same
]);
return <></>;

View file

@ -1,4 +1,4 @@
import { appRouter } from './appRouter';
import { appRouter } from './router/appRouter';
import browser from '../scripts/browser';
import dialog from './dialog/dialog';
import globalize from '../scripts/globalize';

View file

@ -114,7 +114,7 @@ button::-moz-focus-inner {
}
.card.show-animation:focus > .cardBox {
transform: scale(1.18, 1.18);
transform: scale(1.07, 1.07);
}
.cardBox-bottompadded {

View file

@ -22,7 +22,7 @@ import './card.scss';
import '../../elements/emby-button/paper-icon-button-light';
import '../guide/programs.scss';
import ServerConnections from '../ServerConnections';
import { appRouter } from '../appRouter';
import { appRouter } from '../router/appRouter';
const enableFocusTransform = !browser.slow && !browser.edge;
@ -679,9 +679,8 @@ function getCardTextLines(lines, cssClass, forceLines, isOuterFooter, cardLayout
let valid = 0;
for (let i = 0; i < lines.length; i++) {
for (const text of lines) {
let currentCssClass = cssClass;
const text = lines[i];
if (valid > 0 && isOuterFooter) {
currentCssClass += ' cardText-secondary';
@ -862,8 +861,8 @@ function getCardFooterText(item, apiClient, options, footerClass, progressHtml,
if (options.textLines) {
const additionalLines = options.textLines(item);
for (let i = 0; i < additionalLines.length; i++) {
lines.push(additionalLines[i]);
for (const additionalLine of additionalLines) {
lines.push(additionalLine);
}
}
@ -1118,7 +1117,6 @@ let refreshIndicatorLoaded;
function importRefreshIndicator() {
if (!refreshIndicatorLoaded) {
refreshIndicatorLoaded = true;
/* eslint-disable-next-line @babel/no-unused-expressions */
import('../../elements/emby-itemrefreshindicator/emby-itemrefreshindicator');
}
}
@ -1469,7 +1467,6 @@ function getHoverMenuHtml(item, action) {
const userData = item.UserData || {};
if (itemHelper.canMarkPlayed(item)) {
/* eslint-disable-next-line @babel/no-unused-expressions */
import('../../elements/emby-playstatebutton/emby-playstatebutton');
html += '<button is="emby-playstatebutton" type="button" data-action="none" class="' + btnCssClass + '" data-id="' + item.Id + '" data-serverid="' + item.ServerId + '" data-itemtype="' + item.Type + '" data-played="' + (userData.Played) + '"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check" aria-hidden="true"></span></button>';
}
@ -1477,7 +1474,6 @@ function getHoverMenuHtml(item, action) {
if (itemHelper.canRate(item)) {
const likes = userData.Likes == null ? '' : userData.Likes;
/* eslint-disable-next-line @babel/no-unused-expressions */
import('../../elements/emby-ratingbutton/emby-ratingbutton');
html += '<button is="emby-ratingbutton" type="button" data-action="none" class="' + btnCssClass + '" data-id="' + item.Id + '" data-serverid="' + item.ServerId + '" data-itemtype="' + item.Type + '" data-likes="' + likes + '" data-isfavorite="' + (userData.IsFavorite) + '"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite" aria-hidden="true"></span></button>';
}
@ -1724,8 +1720,7 @@ export function onTimerCreated(programId, newTimerId, itemsContainer) {
export function onTimerCancelled(timerId, itemsContainer) {
const cells = itemsContainer.querySelectorAll('.card[data-timerid="' + timerId + '"]');
for (let i = 0; i < cells.length; i++) {
const cell = cells[i];
for (const cell of cells) {
const icon = cell.querySelector('.timerIndicator');
if (icon) {
icon.parentNode.removeChild(icon);
@ -1742,8 +1737,7 @@ export function onTimerCancelled(timerId, itemsContainer) {
export function onSeriesTimerCancelled(cancelledTimerId, itemsContainer) {
const cells = itemsContainer.querySelectorAll('.card[data-seriestimerid="' + cancelledTimerId + '"]');
for (let i = 0; i < cells.length; i++) {
const cell = cells[i];
for (const cell of cells) {
const icon = cell.querySelector('.timerIndicator');
if (icon) {
icon.parentNode.removeChild(icon);

View file

@ -3,7 +3,7 @@ import dom from '../../scripts/dom';
import dialogHelper from '../dialogHelper/dialogHelper';
import loading from '../loading/loading';
import layoutManager from '../layoutManager';
import { appRouter } from '../appRouter';
import { appRouter } from '../router/appRouter';
import globalize from '../../scripts/globalize';
import '../../elements/emby-button/emby-button';
import '../../elements/emby-button/paper-icon-button-light';

View file

@ -32,7 +32,11 @@ const Filter: FC<FilterProps> = ({
serverId: window.ApiClient.serverId(),
filterMenuOptions: getFilterMenuOptions(),
setfilters: setViewQuerySettings
}).catch(() => {
// filter menu closed
});
}).catch(err => {
console.error('[Filter] failed to load filter menu', err);
});
}, [viewQuerySettings, getVisibleFilters, topParentId, getItemTypes, getFilterMenuOptions, setViewQuerySettings]);

View file

@ -5,7 +5,7 @@ import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'
import escapeHTML from 'escape-html';
import React, { FC, useCallback, useEffect, useRef } from 'react';
import { appRouter } from '../appRouter';
import { appRouter } from '../router/appRouter';
import cardBuilder from '../cardbuilder/cardBuilder';
import layoutManager from '../layoutManager';
import lazyLoader from '../lazyLoader/lazyLoaderIntersectionObserver';
@ -73,6 +73,8 @@ const GenresItemsContainer: FC<GenresItemsContainerProps> = ({
centerText: true,
showYear: true
});
}).catch(err => {
console.error('[GenresItemsContainer] failed to fetch items', err);
});
}, [getPortraitShape, topParentId]);

View file

@ -12,7 +12,11 @@ const NewCollection: FC = () => {
collectionEditor.show({
items: [],
serverId: serverId
}).catch(() => {
// closed collection editor
});
}).catch(err => {
console.error('[NewCollection] failed to load collection editor', err);
});
}, []);

View file

@ -22,7 +22,11 @@ const SelectView: FC<SelectViewProps> = ({
settings: viewQuerySettings,
visibleSettings: getVisibleViewSettings(),
setviewsettings: setViewQuerySettings
}).catch(() => {
// view settings closed
});
}).catch(err => {
console.error('[SelectView] failed to load view settings', err);
});
}, [getVisibleViewSettings, viewQuerySettings, setViewQuerySettings]);

View file

@ -18,6 +18,8 @@ const Shuffle: FC<ShuffleProps> = ({ itemsResult = {}, topParentId }) => {
topParentId as string
).then((item) => {
playbackManager.shuffle(item);
}).catch(err => {
console.error('[Shuffle] failed to fetch items', err);
});
}, [topParentId]);

View file

@ -25,7 +25,11 @@ const Sort: FC<SortProps> = ({
settings: viewQuerySettings,
sortOptions: getSortMenuOptions(),
setSortValues: setViewQuerySettings
}).catch(() => {
// sort menu closed
});
}).catch(err => {
console.error('[Sort] failed to load sort menu', err);
});
}, [getSortMenuOptions, viewQuerySettings, setViewQuerySettings]);

View file

@ -1,4 +1,4 @@
import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client';
import { type BaseItemDtoQueryResult, ItemFields, ItemFilter } from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import loading from '../loading/loading';
@ -33,6 +33,41 @@ const getDefaultSortBy = () => {
return 'SortName';
};
const getFields = (viewQuerySettings: ViewQuerySettings) => {
const fields: ItemFields[] = [
ItemFields.BasicSyncInfo,
ItemFields.MediaSourceCount
];
if (viewQuerySettings.imageType === 'primary') {
fields.push(ItemFields.PrimaryImageAspectRatio);
}
return fields.join(',');
};
const getFilters = (viewQuerySettings: ViewQuerySettings) => {
const filters: ItemFilter[] = [];
if (viewQuerySettings.IsPlayed) {
filters.push(ItemFilter.IsPlayed);
}
if (viewQuerySettings.IsUnplayed) {
filters.push(ItemFilter.IsUnplayed);
}
if (viewQuerySettings.IsFavorite) {
filters.push(ItemFilter.IsFavorite);
}
if (viewQuerySettings.IsResumable) {
filters.push(ItemFilter.IsResumable);
}
return filters;
};
const getVisibleViewSettings = () => {
return [
'showTitle',
@ -228,33 +263,7 @@ const ViewItemsContainer: FC<ViewItemsContainerProps> = ({
}, [getCardOptions, getContext, itemsResult.Items, getNoItemsMessage, viewQuerySettings.imageType]);
const getQuery = useCallback(() => {
let fields = 'BasicSyncInfo,MediaSourceCount';
if (viewQuerySettings.imageType === 'primary') {
fields += ',PrimaryImageAspectRatio';
}
if (viewQuerySettings.showYear) {
fields += ',ProductionYear';
}
const queryFilters: string[] = [];
if (viewQuerySettings.IsPlayed) {
queryFilters.push('IsPlayed');
}
if (viewQuerySettings.IsUnplayed) {
queryFilters.push('IsUnplayed');
}
if (viewQuerySettings.IsFavorite) {
queryFilters.push('IsFavorite');
}
if (viewQuerySettings.IsResumable) {
queryFilters.push('IsResumable');
}
const queryFilters = getFilters(viewQuerySettings);
let queryIsHD;
@ -271,7 +280,7 @@ const ViewItemsContainer: FC<ViewItemsContainerProps> = ({
SortOrder: viewQuerySettings.SortOrder,
IncludeItemTypes: getItemTypes().join(','),
Recursive: true,
Fields: fields,
Fields: getFields(viewQuerySettings),
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb,Disc,Logo',
Limit: userSettings.libraryPageSize(undefined) || undefined,
@ -293,28 +302,7 @@ const ViewItemsContainer: FC<ViewItemsContainerProps> = ({
ParentId: topParentId
};
}, [
viewQuerySettings.imageType,
viewQuerySettings.showYear,
viewQuerySettings.IsPlayed,
viewQuerySettings.IsUnplayed,
viewQuerySettings.IsFavorite,
viewQuerySettings.IsResumable,
viewQuerySettings.IsHD,
viewQuerySettings.IsSD,
viewQuerySettings.SortBy,
viewQuerySettings.SortOrder,
viewQuerySettings.VideoTypes,
viewQuerySettings.GenreIds,
viewQuerySettings.Is4K,
viewQuerySettings.Is3D,
viewQuerySettings.HasSubtitles,
viewQuerySettings.HasTrailer,
viewQuerySettings.HasSpecialFeature,
viewQuerySettings.HasThemeSong,
viewQuerySettings.HasThemeVideo,
viewQuerySettings.StartIndex,
viewQuerySettings.NameLessThan,
viewQuerySettings.NameStartsWith,
viewQuerySettings,
getItemTypes,
getBasekey,
topParentId
@ -347,9 +335,13 @@ const ViewItemsContainer: FC<ViewItemsContainerProps> = ({
import('../../components/autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(page);
}).catch(err => {
console.error('[ViewItemsContainer] failed to load autofocuser', err);
});
loading.hide();
setisLoading(true);
}).catch(err => {
console.error('[ViewItemsContainer] failed to fetch data', err);
});
}, [fetchData]);

View file

@ -1,4 +1,4 @@
import { appRouter } from '../appRouter';
import { appRouter } from '../router/appRouter';
import browser from '../../scripts/browser';
import dialog from '../dialog/dialog';
import globalize from '../../scripts/globalize';

View file

@ -12,7 +12,7 @@ type IProps = {
listTitle?: string;
description?: string;
children?: React.ReactNode
}
};
const AccessContainer: FunctionComponent<IProps> = ({ containerClassName, headerTitle, checkBoxClassName, checkBoxTitle, listContainerClassName, accessClassName, listTitle, description, children }: IProps) => {
return (

View file

@ -9,7 +9,7 @@ type AccessScheduleListProps = {
DayOfWeek?: string;
StartHour?: number ;
EndHour?: number;
}
};
function getDisplayTime(hours = 0) {
let minutes = 0;

View file

@ -3,7 +3,7 @@ import IconButtonElement from '../../../elements/IconButtonElement';
type IProps = {
tag?: string;
}
};
const BlockedTagList: FunctionComponent<IProps> = ({ tag }: IProps) => {
return (

View file

@ -4,7 +4,7 @@ import globalize from '../../../scripts/globalize';
type IProps = {
title?: string;
className?: string;
}
};
const createLinkElement = ({ className, title }: IProps) => ({
__html: `<a

View file

@ -3,7 +3,7 @@ import globalize from '../../../scripts/globalize';
type IProps = {
activeTab: string;
}
};
const createLinkElement = (activeTab: string) => ({
__html: `<a href="#"

View file

@ -19,7 +19,7 @@ const createLinkElement = ({ user, renderImgUrl }: { user: UserDto, renderImgUrl
type IProps = {
user?: UserDto;
}
};
const getLastSeenText = (lastActivityDate?: string | null) => {
if (lastActivityDate) {

View file

@ -1,4 +1,3 @@
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
import React, { FunctionComponent, useCallback, useEffect, useRef } from 'react';
import Dashboard from '../../../utils/dashboard';
import globalize from '../../../scripts/globalize';
@ -12,12 +11,12 @@ import InputElement from '../../../elements/InputElement';
type IProps = {
userId: string;
}
};
const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
const element = useRef<HTMLDivElement>(null);
const loadUser = useCallback(() => {
const loadUser = useCallback(async () => {
const page = element.current;
if (!page) {
@ -25,61 +24,50 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
return;
}
window.ApiClient.getUser(userId).then(function (user) {
Dashboard.getCurrentUser().then(function (loggedInUser: UserDto) {
if (!user.Policy) {
throw new Error('Unexpected null user.Policy');
}
const user = await window.ApiClient.getUser(userId);
const loggedInUser = await Dashboard.getCurrentUser();
if (!user.Configuration) {
throw new Error('Unexpected null user.Configuration');
}
if (!user.Policy || !user.Configuration) {
throw new Error('Unexpected null user policy or configuration');
}
LibraryMenu.setTitle(user.Name);
LibraryMenu.setTitle(user.Name);
let showLocalAccessSection = false;
let showLocalAccessSection = false;
if (user.HasConfiguredPassword) {
(page.querySelector('#btnResetPassword') as HTMLDivElement).classList.remove('hide');
(page.querySelector('#fldCurrentPassword') as HTMLDivElement).classList.remove('hide');
showLocalAccessSection = true;
} else {
(page.querySelector('#btnResetPassword') as HTMLDivElement).classList.add('hide');
(page.querySelector('#fldCurrentPassword') as HTMLDivElement).classList.add('hide');
}
if (user.HasConfiguredPassword) {
(page.querySelector('#btnResetPassword') as HTMLDivElement).classList.remove('hide');
(page.querySelector('#fldCurrentPassword') as HTMLDivElement).classList.remove('hide');
showLocalAccessSection = true;
} else {
(page.querySelector('#btnResetPassword') as HTMLDivElement).classList.add('hide');
(page.querySelector('#fldCurrentPassword') as HTMLDivElement).classList.add('hide');
}
if (loggedInUser?.Policy?.IsAdministrator || user.Policy.EnableUserPreferenceAccess) {
(page.querySelector('.passwordSection') as HTMLDivElement).classList.remove('hide');
} else {
(page.querySelector('.passwordSection') as HTMLDivElement).classList.add('hide');
}
const canChangePassword = loggedInUser?.Policy?.IsAdministrator || user.Policy.EnableUserPreferenceAccess;
(page.querySelector('.passwordSection') as HTMLDivElement).classList.toggle('hide', !canChangePassword);
(page.querySelector('.localAccessSection') as HTMLDivElement).classList.toggle('hide', !(showLocalAccessSection && canChangePassword));
if (showLocalAccessSection && (loggedInUser?.Policy?.IsAdministrator || user.Policy.EnableUserPreferenceAccess)) {
(page.querySelector('.localAccessSection') as HTMLDivElement).classList.remove('hide');
} else {
(page.querySelector('.localAccessSection') as HTMLDivElement).classList.add('hide');
}
const txtEasyPassword = page.querySelector('#txtEasyPassword') as HTMLInputElement;
txtEasyPassword.value = '';
const txtEasyPassword = page.querySelector('#txtEasyPassword') as HTMLInputElement;
txtEasyPassword.value = '';
if (user.HasConfiguredEasyPassword) {
txtEasyPassword.placeholder = '******';
(page.querySelector('#btnResetEasyPassword') as HTMLDivElement).classList.remove('hide');
} else {
txtEasyPassword.removeAttribute('placeholder');
txtEasyPassword.placeholder = '';
(page.querySelector('#btnResetEasyPassword') as HTMLDivElement).classList.add('hide');
}
if (user.HasConfiguredEasyPassword) {
txtEasyPassword.placeholder = '******';
(page.querySelector('#btnResetEasyPassword') as HTMLDivElement).classList.remove('hide');
} else {
txtEasyPassword.removeAttribute('placeholder');
txtEasyPassword.placeholder = '';
(page.querySelector('#btnResetEasyPassword') as HTMLDivElement).classList.add('hide');
}
const chkEnableLocalEasyPassword = page.querySelector('.chkEnableLocalEasyPassword') as HTMLInputElement;
const chkEnableLocalEasyPassword = page.querySelector('.chkEnableLocalEasyPassword') as HTMLInputElement;
chkEnableLocalEasyPassword.checked = user.Configuration.EnableLocalPassword || false;
chkEnableLocalEasyPassword.checked = user.Configuration.EnableLocalPassword || false;
import('../../autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(page);
});
});
import('../../autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(page);
}).catch(err => {
console.error('[UserPasswordForm] failed to load autofocuser', err);
});
(page.querySelector('#txtCurrentPassword') as HTMLInputElement).value = '';
@ -95,7 +83,9 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
return;
}
loadUser();
loadUser().catch(err => {
console.error('[UserPasswordForm] failed to load user', err);
});
const onSubmit = (e: Event) => {
if ((page.querySelector('#txtNewPassword') as HTMLInputElement).value != (page.querySelector('#txtNewPasswordConfirm') as HTMLInputElement).value) {
@ -123,7 +113,9 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
loading.hide();
toast(globalize.translate('PasswordSaved'));
loadUser();
loadUser().catch(err => {
console.error('[UserPasswordForm] failed to load user', err);
});
}, function () {
loading.hide();
Dashboard.alert({
@ -146,6 +138,8 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
if (easyPassword) {
window.ApiClient.updateEasyPassword(userId, easyPassword).then(function () {
onEasyPasswordSaved();
}).catch(err => {
console.error('[UserPasswordForm] failed to update easy password', err);
});
} else {
onEasyPasswordSaved();
@ -167,8 +161,14 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
loading.hide();
toast(globalize.translate('SettingsSaved'));
loadUser();
loadUser().catch(err => {
console.error('[UserPasswordForm] failed to load user', err);
});
}).catch(err => {
console.error('[UserPasswordForm] failed to update user configuration', err);
});
}).catch(err => {
console.error('[UserPasswordForm] failed to fetch user', err);
});
};
@ -183,8 +183,14 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
message: globalize.translate('PinCodeResetComplete'),
title: globalize.translate('HeaderPinCodeReset')
});
loadUser();
loadUser().catch(err => {
console.error('[UserPasswordForm] failed to load user', err);
});
}).catch(err => {
console.error('[UserPasswordForm] failed to reset easy password', err);
});
}).catch(() => {
// confirm dialog was closed
});
};
@ -198,8 +204,14 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
message: globalize.translate('PasswordResetComplete'),
title: globalize.translate('ResetPassword')
});
loadUser();
loadUser().catch(err => {
console.error('[UserPasswordForm] failed to load user', err);
});
}).catch(err => {
console.error('[UserPasswordForm] failed to reset user password', err);
});
}).catch(() => {
// confirm dialog was closed
});
};

View file

@ -1,4 +1,4 @@
import { history } from '../appRouter';
import { history } from '../router/appRouter';
import focusManager from '../focusManager';
import browser from '../../scripts/browser';
import layoutManager from '../layoutManager';

View file

@ -1,5 +1,5 @@
import dom from '../scripts/dom';
import { appRouter } from './appRouter';
import { appRouter } from './router/appRouter';
import Dashboard from '../utils/dashboard';
import ServerConnections from './ServerConnections';

View file

@ -13,6 +13,7 @@ import '../../elements/emby-checkbox/emby-checkbox';
import ServerConnections from '../ServerConnections';
import toast from '../toast/toast';
import template from './homeScreenSettings.template.html';
import { LibraryTab } from '../../types/libraryTab.ts';
const numConfigurableSections = 7;
@ -48,110 +49,110 @@ function getLandingScreenOptions(type) {
if (type === 'movies') {
list.push({
name: globalize.translate('Movies'),
value: 'movies',
value: LibraryTab.Movies,
isDefault: true
});
list.push({
name: globalize.translate('Suggestions'),
value: 'suggestions'
value: LibraryTab.Suggestions
});
list.push({
name: globalize.translate('Trailers'),
value: 'trailers'
value: LibraryTab.Trailers
});
list.push({
name: globalize.translate('Favorites'),
value: 'favorites'
value: LibraryTab.Favorites
});
list.push({
name: globalize.translate('Collections'),
value: 'collections'
value: LibraryTab.Collections
});
list.push({
name: globalize.translate('Genres'),
value: 'genres'
value: LibraryTab.Genres
});
} else if (type === 'tvshows') {
list.push({
name: globalize.translate('Shows'),
value: 'shows',
value: LibraryTab.Shows,
isDefault: true
});
list.push({
name: globalize.translate('Suggestions'),
value: 'suggestions'
value: LibraryTab.Suggestions
});
list.push({
name: globalize.translate('TabUpcoming'),
value: 'upcoming'
value: LibraryTab.Upcoming
});
list.push({
name: globalize.translate('Genres'),
value: 'genres'
value: LibraryTab.Genres
});
list.push({
name: globalize.translate('TabNetworks'),
value: 'networks'
value: LibraryTab.Networks
});
list.push({
name: globalize.translate('Episodes'),
value: 'episodes'
value: LibraryTab.Episodes
});
} else if (type === 'music') {
list.push({
name: globalize.translate('Albums'),
value: 'albums',
value: LibraryTab.Albums,
isDefault: true
});
list.push({
name: globalize.translate('Suggestions'),
value: 'suggestions'
value: LibraryTab.Suggestions
});
list.push({
name: globalize.translate('HeaderAlbumArtists'),
value: 'albumartists'
value: LibraryTab.AlbumArtists
});
list.push({
name: globalize.translate('Artists'),
value: 'artists'
value: LibraryTab.Artists
});
list.push({
name: globalize.translate('Playlists'),
value: 'playlists'
value: LibraryTab.Playlists
});
list.push({
name: globalize.translate('Songs'),
value: 'songs'
value: LibraryTab.Songs
});
list.push({
name: globalize.translate('Genres'),
value: 'genres'
value: LibraryTab.Genres
});
} else if (type === 'livetv') {
list.push({
name: globalize.translate('Programs'),
value: 'programs',
value: LibraryTab.Programs,
isDefault: true
});
list.push({
name: globalize.translate('Guide'),
value: 'guide'
value: LibraryTab.Guide
});
list.push({
name: globalize.translate('Channels'),
value: 'channels'
value: LibraryTab.Channels
});
list.push({
name: globalize.translate('Recordings'),
value: 'recordings'
value: LibraryTab.Recordings
});
list.push({
name: globalize.translate('Schedule'),
value: 'schedule'
value: LibraryTab.Schedule
});
list.push({
name: globalize.translate('Series'),
value: 'series'
value: LibraryTab.Series
});
}

View file

@ -3,7 +3,7 @@ import cardBuilder from '../cardbuilder/cardBuilder';
import layoutManager from '../layoutManager';
import imageLoader from '../images/imageLoader';
import globalize from '../../scripts/globalize';
import { appRouter } from '../appRouter';
import { appRouter } from '../router/appRouter';
import imageHelper from '../../scripts/imagehelper';
import '../../elements/emby-button/paper-icon-button-light';
import '../../elements/emby-itemscontainer/emby-itemscontainer';
@ -100,10 +100,10 @@ export function loadSections(elem, apiClient, user, userSettings) {
export function destroySections(elem) {
const elems = elem.querySelectorAll('.itemsContainer');
for (let i = 0; i < elems.length; i++) {
elems[i].fetchData = null;
elems[i].parentContainer = null;
elems[i].getItemsHtml = null;
for (const e of elems) {
e.fetchData = null;
e.parentContainer = null;
e.getItemsHtml = null;
}
elem.innerHTML = '';
@ -111,8 +111,8 @@ export function destroySections(elem) {
export function pause(elem) {
const elems = elem.querySelectorAll('.itemsContainer');
for (let i = 0; i < elems.length; i++) {
elems[i].pause();
for (const e of elems) {
e.pause();
}
}

View file

@ -1,9 +1,10 @@
import browser from '../scripts/browser';
import { copy } from '../scripts/clipboard';
import dom from '../scripts/dom';
import globalize from '../scripts/globalize';
import actionsheet from './actionSheet/actionSheet';
import { appHost } from './apphost';
import { appRouter } from './appRouter';
import { appRouter } from './router/appRouter';
import itemHelper from './itemHelper';
import { playbackManager } from './playback/playbackmanager';
import ServerConnections from './ServerConnections';
@ -98,6 +99,16 @@ export function getCommands(options) {
}
if (!browser.tv) {
// Multiselect is currrently only ran on long clicks of card components
// This disables Select on any context menu not originating from a card i.e songs
if (options.positionTo && (dom.parentWithClass(options.positionTo, 'card') !== null)) {
commands.push({
name: globalize.translate('Select'),
id: 'multiSelect',
icon: 'library_add_check'
});
}
if (itemHelper.supportsAddingToCollection(item) && options.EnableCollectionManagement) {
commands.push({
name: globalize.translate('AddToCollection'),
@ -432,6 +443,12 @@ function executeCommand(item, id, options) {
itemMediaInfo.show(itemId, serverId).then(getResolveFunction(resolve, id), getResolveFunction(resolve, id));
});
break;
case 'multiSelect':
import('./multiSelect/multiSelect').then(({ startMultiSelect: startMultiSelect }) => {
const card = dom.parentWithClass(options.positionTo, 'card');
startMultiSelect(card);
});
break;
case 'refresh':
refresh(apiClient, item);
getResolveFunction(resolve, id)();

View file

@ -23,8 +23,7 @@ function populateLanguages(parent) {
function populateLanguagesIntoSelect(select, languages) {
let html = '';
html += "<option value=''></option>";
for (let i = 0; i < languages.length; i++) {
const culture = languages[i];
for (const culture of languages) {
html += `<option value='${culture.TwoLetterISOLanguageName}'>${culture.DisplayName}</option>`;
}
select.innerHTML = html;
@ -32,8 +31,7 @@ function populateLanguagesIntoSelect(select, languages) {
function populateLanguagesIntoList(element, languages) {
let html = '';
for (let i = 0; i < languages.length; i++) {
const culture = languages[i];
for (const culture of languages) {
html += `<label><input type="checkbox" is="emby-checkbox" class="chkSubtitleLanguage" data-lang="${culture.ThreeLetterISOLanguageName.toLowerCase()}" /><span>${culture.DisplayName}</span></label>`;
}
element.innerHTML = html;
@ -43,8 +41,7 @@ function populateCountries(select) {
return ApiClient.getCountries().then(allCountries => {
let html = '';
html += "<option value=''></option>";
for (let i = 0; i < allCountries.length; i++) {
const culture = allCountries[i];
for (const culture of allCountries) {
html += `<option value='${culture.TwoLetterISORegionName}'>${culture.DisplayName}</option>`;
}
select.innerHTML = html;
@ -109,8 +106,7 @@ function renderMetadataSavers(page, metadataSavers) {
}
html += `<h3 class="checkboxListLabel">${globalize.translate('LabelMetadataSavers')}</h3>`;
html += '<div class="checkboxList paperList checkboxList-paperList">';
for (let i = 0; i < metadataSavers.length; i++) {
const plugin = metadataSavers[i];
for (const plugin of metadataSavers) {
html += `<label><input type="checkbox" data-defaultenabled="${plugin.DefaultEnabled}" is="emby-checkbox" class="chkMetadataSaver" data-pluginname="${escapeHtml(plugin.Name)}" ${false}><span>${escapeHtml(plugin.Name)}</span></label>`;
}
html += '</div>';
@ -157,8 +153,7 @@ function getMetadataFetchersForTypeHtml(availableTypeOptions, libraryOptionsForT
function getTypeOptions(allOptions, type) {
const allTypeOptions = allOptions.TypeOptions || [];
for (let i = 0; i < allTypeOptions.length; i++) {
const typeOptions = allTypeOptions[i];
for (const typeOptions of allTypeOptions) {
if (typeOptions.Type === type) return typeOptions;
}
return null;
@ -167,8 +162,7 @@ function getTypeOptions(allOptions, type) {
function renderMetadataFetchers(page, availableOptions, libraryOptions) {
let html = '';
const elem = page.querySelector('.metadataFetchers');
for (let i = 0; i < availableOptions.TypeOptions.length; i++) {
const availableTypeOptions = availableOptions.TypeOptions[i];
for (const availableTypeOptions of availableOptions.TypeOptions) {
html += getMetadataFetchersForTypeHtml(availableTypeOptions, getTypeOptions(libraryOptions, availableTypeOptions.Type) || {});
}
elem.innerHTML = html;
@ -262,8 +256,7 @@ function getImageFetchersForTypeHtml(availableTypeOptions, libraryOptionsForType
function renderImageFetchers(page, availableOptions, libraryOptions) {
let html = '';
const elem = page.querySelector('.imageFetchers');
for (let i = 0; i < availableOptions.TypeOptions.length; i++) {
const availableTypeOptions = availableOptions.TypeOptions[i];
for (const availableTypeOptions of availableOptions.TypeOptions) {
html += getImageFetchersForTypeHtml(availableTypeOptions, getTypeOptions(libraryOptions, availableTypeOptions.Type) || {});
}
elem.innerHTML = html;
@ -454,8 +447,7 @@ function setSubtitleFetchersIntoOptions(parent, options) {
function setMetadataFetchersIntoOptions(parent, options) {
const sections = parent.querySelectorAll('.metadataFetcher');
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
for (const section of sections) {
const type = section.getAttribute('data-type');
let typeOptions = getTypeOptions(options, type);
if (!typeOptions) {
@ -478,8 +470,7 @@ function setMetadataFetchersIntoOptions(parent, options) {
function setImageFetchersIntoOptions(parent, options) {
const sections = parent.querySelectorAll('.imageFetcher');
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
for (const section of sections) {
const type = section.getAttribute('data-type');
let typeOptions = getTypeOptions(options, type);
if (!typeOptions) {
@ -503,8 +494,7 @@ function setImageFetchersIntoOptions(parent, options) {
function setImageOptionsIntoOptions(options) {
const originalTypeOptions = (currentLibraryOptions || {}).TypeOptions || [];
for (let i = 0; i < originalTypeOptions.length; i++) {
const originalTypeOption = originalTypeOptions[i];
for (const originalTypeOption of originalTypeOptions) {
let typeOptions = getTypeOptions(options, originalTypeOption.Type);
if (!typeOptions) {

View file

@ -1,7 +1,7 @@
import escapeHtml from 'escape-html';
import datetime from '../../scripts/datetime';
import globalize from '../../scripts/globalize';
import { appRouter } from '../appRouter';
import { appRouter } from '../router/appRouter';
import itemHelper from '../itemHelper';
import indicators from '../indicators/indicators';
import 'material-design-icons-iconfont';

View file

@ -20,7 +20,7 @@ import '../../styles/flexstyles.scss';
import './style.scss';
import ServerConnections from '../ServerConnections';
import toast from '../toast/toast';
import { appRouter } from '../appRouter';
import { appRouter } from '../router/appRouter';
import template from './metadataEditor.template.html';
let currentContext;
@ -108,7 +108,7 @@ function getDateValue(form, element, property) {
const parts = date.toISOString().split('T');
// If the date is the same, preserve the time
if (parts[0].indexOf(val) === 0) {
if (parts[0].startsWith(val)) {
const iso = parts[1];
val += 'T' + iso;
@ -955,8 +955,7 @@ function populatePeople(context, people) {
function getLockedFieldsHtml(fields, currentFields) {
let html = '';
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
for (const field of fields) {
const name = field.name;
const value = field.value || field.name;
const checkedHtml = currentFields.indexOf(value) === -1 ? ' checked' : '';

View file

@ -564,3 +564,6 @@ export default function (options) {
};
}
export const startMultiSelect = (card) => {
showSelections(card);
};

View file

@ -15,7 +15,7 @@ import appFooter from '../appFooter/appFooter';
import itemShortcuts from '../shortcuts';
import './nowPlayingBar.scss';
import '../../elements/emby-slider/emby-slider';
import { appRouter } from '../appRouter';
import { appRouter } from '../router/appRouter';
let currentPlayer;
let currentPlayerSupportedCommands = [];

View file

@ -853,11 +853,9 @@ class PlaybackManager {
user: user
});
for (let i = 0; i < responses.length; i++) {
const subTargets = responses[i];
for (let j = 0; j < subTargets.length; j++) {
targets.push(subTargets[j]);
for (const subTargets of responses) {
for (const subTarget of subTargets) {
targets.push(subTarget);
}
}

View file

@ -4,7 +4,7 @@ import browser from '../../scripts/browser';
import loading from '../loading/loading';
import { playbackManager } from '../playback/playbackmanager';
import { pluginManager } from '../pluginManager';
import { appRouter } from '../appRouter';
import { appRouter } from '../router/appRouter';
import globalize from '../../scripts/globalize';
import { appHost } from '../apphost';
import { enable, isEnabled, supported } from '../../scripts/autocast';

View file

@ -1,4 +1,3 @@
import browser from '../../scripts/browser';
import appSettings from '../../scripts/settings/appSettings';
import { appHost } from '../apphost';
import focusManager from '../focusManager';
@ -137,15 +136,6 @@ function showHideQualityFields(context, user, apiClient) {
});
}
function showOrHideEpisodesField(context) {
if (browser.tizen || browser.web0s) {
context.querySelector('.fldEpisodeAutoPlay').classList.add('hide');
return;
}
context.querySelector('.fldEpisodeAutoPlay').classList.remove('hide');
}
function loadForm(context, user, userSettings, apiClient) {
const loggedInUserId = apiClient.getCurrentUserId();
const userId = user.Id;
@ -208,8 +198,6 @@ function loadForm(context, user, userSettings, apiClient) {
fillSkipLengths(selectSkipBackLength);
selectSkipBackLength.value = userSettings.skipBackLength();
showOrHideEpisodesField(context);
loading.hide();
}

View file

@ -88,7 +88,7 @@
<div class="fieldDescription checkboxFieldDescription">${CinemaModeConfigurationHelp}</div>
</div>
<div class="checkboxContainer fldEpisodeAutoPlay hide">
<div class="checkboxContainer fldEpisodeAutoPlay">
<label>
<input type="checkbox" is="emby-checkbox" class="chkEpisodeAutoPlay" />
<span>${PlayNextEpisodeAutomatically}</span>

View file

@ -6,7 +6,7 @@ import layoutManager from '../layoutManager';
import { playbackManager } from '../playback/playbackmanager';
import { pluginManager } from '../pluginManager';
import * as userSettings from '../../scripts/settings/userSettings';
import { appRouter } from '../appRouter';
import { appRouter } from '../router/appRouter';
import globalize from '../../scripts/globalize';
import { PluginType } from '../../types/plugin.ts';

View file

@ -4,7 +4,7 @@ import loading from './loading/loading';
import appSettings from '../scripts/settings/appSettings';
import { playbackManager } from './playback/playbackmanager';
import { appHost } from '../components/apphost';
import { appRouter } from '../components/appRouter';
import { appRouter } from './router/appRouter';
import * as inputManager from '../scripts/inputManager';
import toast from '../components/toast/toast';
import confirm from '../components/confirm/confirm';

View file

@ -18,7 +18,7 @@ import './remotecontrol.scss';
import '../../elements/emby-ratingbutton/emby-ratingbutton';
import ServerConnections from '../ServerConnections';
import toast from '../toast/toast';
import { appRouter } from '../appRouter';
import { appRouter } from '../router/appRouter';
let showMuteButton = true;
let showVolumeSlider = true;
@ -472,8 +472,8 @@ export default function () {
}
}
context.querySelector('.positionTime').innerHTML = positionTicks == null ? '--:--' : datetime.getDisplayRunningTime(positionTicks);
context.querySelector('.runtime').innerHTML = runtimeTicks != null ? datetime.getDisplayRunningTime(runtimeTicks) : '--:--';
context.querySelector('.positionTime').innerHTML = Number.isFinite(positionTicks) ? datetime.getDisplayRunningTime(positionTicks) : '--:--';
context.querySelector('.runtime').innerHTML = Number.isFinite(runtimeTicks) ? datetime.getDisplayRunningTime(runtimeTicks) : '--:--';
}
function getPlaylistItems(player) {

View file

@ -0,0 +1,44 @@
import loadable from '@loadable/component';
import React from 'react';
import { Route } from 'react-router-dom';
export enum AsyncRouteType {
Stable,
Experimental
}
export interface AsyncRoute {
/** The URL path for this route. */
path: string
/** The relative path to the page component in the routes directory. */
page: string
/** The route should use the page component from the experimental app. */
type?: AsyncRouteType
}
interface AsyncPageProps {
/** The relative path to the page component in the routes directory. */
page: string
}
const ExperimentalAsyncPage = loadable(
(props: { page: string }) => import(/* webpackChunkName: "[request]" */ `../../apps/experimental/routes/${props.page}`),
{ cacheKey: (props: AsyncPageProps) => props.page }
);
const StableAsyncPage = loadable(
(props: { page: string }) => import(/* webpackChunkName: "[request]" */ `../../apps/stable/routes/${props.page}`),
{ cacheKey: (props: AsyncPageProps) => props.page }
);
export const toAsyncPageRoute = ({ path, page, type = AsyncRouteType.Stable }: AsyncRoute) => (
<Route
key={path}
path={path}
element={(
type === AsyncRouteType.Experimental ?
<ExperimentalAsyncPage page={page} /> :
<StableAsyncPage page={page} />
)}
/>
);

View file

@ -0,0 +1,21 @@
import React from 'react';
import { Route } from 'react-router-dom';
import ViewManagerPage, { ViewManagerPageProps } from '../viewManager/ViewManagerPage';
export interface LegacyRoute {
path: string,
pageProps: ViewManagerPageProps
}
export function toViewManagerPageRoute(route: LegacyRoute) {
return (
<Route
key={route.path}
path={route.path}
element={
<ViewManagerPage {...route.pageProps} />
}
/>
);
}

View file

@ -1,15 +1,15 @@
import { Action, createHashHistory } from 'history';
import { appHost } from './apphost';
import { clearBackdrop, setBackdropTransparency } from './backdrop/backdrop';
import globalize from '../scripts/globalize';
import Events from '../utils/events.ts';
import itemHelper from './itemHelper';
import loading from './loading/loading';
import viewManager from './viewManager/viewManager';
import ServerConnections from './ServerConnections';
import alert from './alert';
import { ConnectionState } from '../utils/jellyfin-apiclient/ConnectionState.ts';
import { appHost } from '../apphost';
import { clearBackdrop, setBackdropTransparency } from '../backdrop/backdrop';
import globalize from '../../scripts/globalize';
import Events from '../../utils/events.ts';
import itemHelper from '../itemHelper';
import loading from '../loading/loading';
import viewManager from '../viewManager/viewManager';
import ServerConnections from '../ServerConnections';
import alert from '../alert';
import { ConnectionState } from '../../utils/jellyfin-apiclient/ConnectionState.ts';
export const history = createHashHistory();
@ -17,6 +17,7 @@ export const history = createHashHistory();
* Page types of "no return" (when "Go back" should behave differently, probably quitting the application).
*/
const START_PAGE_TYPES = ['home', 'login', 'selectserver'];
const START_PAGE_PATHS = ['/home.html', '/login.html', '/selectserver.html'];
class AppRouter {
allRoutes = new Map();
@ -165,12 +166,13 @@ class AppRouter {
}
canGoBack() {
const curr = this.currentRouteInfo?.route;
if (!curr) {
const { path, route } = this.currentRouteInfo;
if (!route) {
return false;
}
if (!document.querySelector('.dialogContainer') && START_PAGE_TYPES.includes(curr.type)) {
if (!document.querySelector('.dialogContainer') && (START_PAGE_TYPES.includes(route.type) || START_PAGE_PATHS.includes(path))) {
return false;
}
@ -247,7 +249,7 @@ class AppRouter {
url = apiClient.getUrl(`/web${url}`);
promise = apiClient.get(url);
} else {
promise = import(/* webpackChunkName: "[request]" */ `../controllers/${url}`);
promise = import(/* webpackChunkName: "[request]" */ `../../controllers/${url}`);
}
promise.then((html) => {
@ -267,7 +269,7 @@ class AppRouter {
};
if (route.controller) {
import(/* webpackChunkName: "[request]" */ '../controllers/' + route.controller).then(onInitComplete);
import(/* webpackChunkName: "[request]" */ '../../controllers/' + route.controller).then(onInitComplete);
} else {
onInitComplete();
}

View file

@ -39,15 +39,15 @@ try {
const elem = document.createElement('div');
const opts = Object.defineProperty({}, 'behavior', {
// eslint-disable-next-line getter-return
get: function () {
supportsScrollToOptions = true;
return null;
}
});
elem.scrollTo(opts);
} catch (e) {
console.error('error checking ScrollToOptions support');
} catch {
// no scroll to options support
}
/**

View file

@ -24,7 +24,7 @@ type LiveTVSearchResultsProps = {
parentId?: string | null;
collectionType?: string | null;
query?: string;
}
};
/*
* React component to display search result rows for live tv library search
@ -79,7 +79,9 @@ const LiveTVSearchResults: FunctionComponent<LiveTVSearchResultsProps> = ({ serv
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: true
}).then(result => setMovies(result.Items || []));
})
.then(result => setMovies(result.Items || []))
.catch(() => setMovies([]));
// Episodes row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
@ -88,22 +90,30 @@ const LiveTVSearchResults: FunctionComponent<LiveTVSearchResultsProps> = ({ serv
IsSports: false,
IsKids: false,
IsNews: false
}).then(result => setEpisodes(result.Items || []));
})
.then(result => setEpisodes(result.Items || []))
.catch(() => setEpisodes([]));
// Sports row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsSports: true
}).then(result => setSports(result.Items || []));
})
.then(result => setSports(result.Items || []))
.catch(() => setSports([]));
// Kids row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsKids: true
}).then(result => setKids(result.Items || []));
})
.then(result => setKids(result.Items || []))
.catch(() => setKids([]));
// News row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsNews: true
}).then(result => setNews(result.Items || []));
})
.then(result => setNews(result.Items || []))
.catch(() => setNews([]));
// Programs row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
@ -112,10 +122,13 @@ const LiveTVSearchResults: FunctionComponent<LiveTVSearchResultsProps> = ({ serv
IsSports: false,
IsKids: false,
IsNews: false
}).then(result => setPrograms(result.Items || []));
})
.then(result => setPrograms(result.Items || []))
.catch(() => setPrograms([]));
// Channels row
fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' })
.then(result => setChannels(result.Items || []));
.then(result => setChannels(result.Items || []))
.catch(() => setChannels([]));
}
}, [collectionType, parentId, query, serverId]);

View file

@ -1,7 +1,7 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import type { BaseItemDto, BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client';
import type { ApiClient } from 'jellyfin-apiclient';
import classNames from 'classnames';
import React, { FunctionComponent, useEffect, useState } from 'react';
import React, { FunctionComponent, useCallback, useEffect, useState } from 'react';
import globalize from '../../scripts/globalize';
import ServerConnections from '../ServerConnections';
@ -12,7 +12,18 @@ type SearchResultsProps = {
parentId?: string | null;
collectionType?: string | null;
query?: string;
}
};
const ensureNonNullItems = (result: BaseItemDtoQueryResult) => ({
...result,
Items: result.Items || []
});
const isMovies = (collectionType: string) => collectionType === 'movies';
const isMusic = (collectionType: string) => collectionType === 'music';
const isTVShows = (collectionType: string) => collectionType === 'tvshows' || collectionType === 'tv';
/*
* React component to display search result rows for global search and non-live tv library search
@ -35,55 +46,55 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
const [ people, setPeople ] = useState<BaseItemDto[]>([]);
const [ collections, setCollections ] = useState<BaseItemDto[]>([]);
useEffect(() => {
const getDefaultParameters = () => ({
ParentId: parentId,
searchTerm: query,
Limit: 24,
Fields: 'PrimaryImageAspectRatio,CanDelete,BasicSyncInfo,MediaSourceCount',
Recursive: true,
EnableTotalRecordCount: false,
ImageTypeLimit: 1,
IncludePeople: false,
IncludeMedia: false,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false
});
const getDefaultParameters = useCallback(() => ({
ParentId: parentId,
searchTerm: query,
Limit: 24,
Fields: 'PrimaryImageAspectRatio,CanDelete,BasicSyncInfo,MediaSourceCount',
Recursive: true,
EnableTotalRecordCount: false,
ImageTypeLimit: 1,
IncludePeople: false,
IncludeMedia: false,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false
}), [parentId, query]);
const fetchArtists = (apiClient: ApiClient, params = {}) => apiClient?.getArtists(
apiClient?.getCurrentUserId(),
const fetchArtists = useCallback((apiClient: ApiClient, params = {}) => (
apiClient?.getArtists(
apiClient.getCurrentUserId(),
{
...getDefaultParameters(),
IncludeArtists: true,
...params
}
);
).then(ensureNonNullItems)
), [getDefaultParameters]);
const fetchItems = (apiClient: ApiClient, params = {}) => apiClient?.getItems(
apiClient?.getCurrentUserId(),
const fetchItems = useCallback((apiClient: ApiClient, params = {}) => (
apiClient?.getItems(
apiClient.getCurrentUserId(),
{
...getDefaultParameters(),
IncludeMedia: true,
...params
}
);
).then(ensureNonNullItems)
), [getDefaultParameters]);
const fetchPeople = (apiClient: ApiClient, params = {}) => apiClient?.getPeople(
apiClient?.getCurrentUserId(),
const fetchPeople = useCallback((apiClient: ApiClient, params = {}) => (
apiClient?.getPeople(
apiClient.getCurrentUserId(),
{
...getDefaultParameters(),
IncludePeople: true,
...params
}
);
const isMovies = () => collectionType === 'movies';
const isMusic = () => collectionType === 'music';
const isTVShows = () => collectionType === 'tvshows' || collectionType === 'tv';
).then(ensureNonNullItems)
), [getDefaultParameters]);
useEffect(() => {
// Reset state
setMovies([]);
setShows([]);
@ -102,78 +113,99 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
setPeople([]);
setCollections([]);
if (query) {
const apiClient = ServerConnections.getApiClient(serverId);
// Movie libraries
if (!collectionType || isMovies()) {
// Movies row
fetchItems(apiClient, { IncludeItemTypes: 'Movie' })
.then(result => setMovies(result.Items || []));
}
// TV Show libraries
if (!collectionType || isTVShows()) {
// Shows row
fetchItems(apiClient, { IncludeItemTypes: 'Series' })
.then(result => setShows(result.Items || []));
// Episodes row
fetchItems(apiClient, { IncludeItemTypes: 'Episode' })
.then(result => setEpisodes(result.Items || []));
}
// People are included for Movies and TV Shows
if (!collectionType || isMovies() || isTVShows()) {
// People row
fetchPeople(apiClient).then(result => setPeople(result.Items || []));
}
// Music libraries
if (!collectionType || isMusic()) {
// Playlists row
fetchItems(apiClient, { IncludeItemTypes: 'Playlist' })
.then(results => setPlaylists(results.Items || []));
// Artists row
fetchArtists(apiClient).then(result => setArtists(result.Items || []));
// Albums row
fetchItems(apiClient, { IncludeItemTypes: 'MusicAlbum' })
.then(result => setAlbums(result.Items || []));
// Songs row
fetchItems(apiClient, { IncludeItemTypes: 'Audio' })
.then(result => setSongs(result.Items || []));
}
// Other libraries do not support in-library search currently
if (!collectionType) {
// Videos row
fetchItems(apiClient, {
MediaTypes: 'Video',
ExcludeItemTypes: 'Movie,Episode,TvChannel'
}).then(result => setVideos(result.Items || []));
// Programs row
fetchItems(apiClient, { IncludeItemTypes: 'LiveTvProgram' })
.then(result => setPrograms(result.Items || []));
// Channels row
fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' })
.then(result => setChannels(result.Items || []));
// Photo Albums row
fetchItems(apiClient, { IncludeItemTypes: 'PhotoAlbum' })
.then(results => setPhotoAlbums(results.Items || []));
// Photos row
fetchItems(apiClient, { IncludeItemTypes: 'Photo' })
.then(results => setPhotos(results.Items || []));
// Audio Books row
fetchItems(apiClient, { IncludeItemTypes: 'AudioBook' })
.then(results => setAudioBooks(results.Items || []));
// Books row
fetchItems(apiClient, { IncludeItemTypes: 'Book' })
.then(results => setBooks(results.Items || []));
// Collections row
fetchItems(apiClient, { IncludeItemTypes: 'BoxSet' })
.then(result => setCollections(result.Items || []));
}
if (!query) {
return;
}
}, [collectionType, parentId, query, serverId]);
const apiClient = ServerConnections.getApiClient(serverId);
// Movie libraries
if (!collectionType || isMovies(collectionType)) {
// Movies row
fetchItems(apiClient, { IncludeItemTypes: 'Movie' })
.then(result => setMovies(result.Items))
.catch(() => setMovies([]));
}
// TV Show libraries
if (!collectionType || isTVShows(collectionType)) {
// Shows row
fetchItems(apiClient, { IncludeItemTypes: 'Series' })
.then(result => setShows(result.Items))
.catch(() => setShows([]));
// Episodes row
fetchItems(apiClient, { IncludeItemTypes: 'Episode' })
.then(result => setEpisodes(result.Items))
.catch(() => setEpisodes([]));
}
// People are included for Movies and TV Shows
if (!collectionType || isMovies(collectionType) || isTVShows(collectionType)) {
// People row
fetchPeople(apiClient)
.then(result => setPeople(result.Items))
.catch(() => setPeople([]));
}
// Music libraries
if (!collectionType || isMusic(collectionType)) {
// Playlists row
fetchItems(apiClient, { IncludeItemTypes: 'Playlist' })
.then(results => setPlaylists(results.Items))
.catch(() => setPlaylists([]));
// Artists row
fetchArtists(apiClient)
.then(result => setArtists(result.Items))
.catch(() => setArtists([]));
// Albums row
fetchItems(apiClient, { IncludeItemTypes: 'MusicAlbum' })
.then(result => setAlbums(result.Items))
.catch(() => setAlbums([]));
// Songs row
fetchItems(apiClient, { IncludeItemTypes: 'Audio' })
.then(result => setSongs(result.Items))
.catch(() => setSongs([]));
}
// Other libraries do not support in-library search currently
if (!collectionType) {
// Videos row
fetchItems(apiClient, {
MediaTypes: 'Video',
ExcludeItemTypes: 'Movie,Episode,TvChannel'
})
.then(result => setVideos(result.Items))
.catch(() => setVideos([]));
// Programs row
fetchItems(apiClient, { IncludeItemTypes: 'LiveTvProgram' })
.then(result => setPrograms(result.Items))
.catch(() => setPrograms([]));
// Channels row
fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' })
.then(result => setChannels(result.Items))
.catch(() => setChannels([]));
// Photo Albums row
fetchItems(apiClient, { IncludeItemTypes: 'PhotoAlbum' })
.then(result => setPhotoAlbums(result.Items))
.catch(() => setPhotoAlbums([]));
// Photos row
fetchItems(apiClient, { IncludeItemTypes: 'Photo' })
.then(result => setPhotos(result.Items))
.catch(() => setPhotos([]));
// Audio Books row
fetchItems(apiClient, { IncludeItemTypes: 'AudioBook' })
.then(result => setAudioBooks(result.Items))
.catch(() => setAudioBooks([]));
// Books row
fetchItems(apiClient, { IncludeItemTypes: 'Book' })
.then(result => setBooks(result.Items))
.catch(() => setBooks([]));
// Collections row
fetchItems(apiClient, { IncludeItemTypes: 'BoxSet' })
.then(result => setCollections(result.Items))
.catch(() => setCollections([]));
}
}, [collectionType, fetchArtists, fetchItems, fetchPeople, query, serverId]);
return (
<div

View file

@ -35,13 +35,13 @@ type CardOptions = {
showChannelName?: boolean,
showTitle?: boolean,
showYear?: boolean
}
};
type SearchResultsRowProps = {
title?: string;
items?: BaseItemDto[];
cardOptions?: CardOptions;
}
};
const SearchResultsRow: FunctionComponent<SearchResultsRowProps> = ({ title, items = [], cardOptions = {} }: SearchResultsRowProps) => {
const element = useRef<HTMLDivElement>(null);

View file

@ -5,7 +5,7 @@ import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
import escapeHtml from 'escape-html';
import React, { FunctionComponent, useEffect, useState } from 'react';
import { appRouter } from '../appRouter';
import { appRouter } from '../router/appRouter';
import { useApi } from '../../hooks/useApi';
import globalize from '../../scripts/globalize';
@ -25,7 +25,7 @@ const createSuggestionLink = ({ name, href }: { name: string, href: string }) =>
type SearchSuggestionsProps = {
parentId?: string | null;
}
};
const SearchSuggestions: FunctionComponent<SearchSuggestionsProps> = ({ parentId }: SearchSuggestionsProps) => {
const [ suggestions, setSuggestions ] = useState<BaseItemDto[]>([]);
@ -45,7 +45,11 @@ const SearchSuggestions: FunctionComponent<SearchSuggestionsProps> = ({ parentId
parentId: parentId || undefined,
enableTotalRecordCount: false
})
.then(result => setSuggestions(result.data.Items || []));
.then(result => setSuggestions(result.data.Items || []))
.catch(err => {
console.error('[SearchSuggestions] failed to fetch search suggestions', err);
setSuggestions([]);
});
}
}, [ api, parentId, user ]);

View file

@ -5,7 +5,7 @@
import { playbackManager } from './playback/playbackmanager';
import inputManager from '../scripts/inputManager';
import { appRouter } from './appRouter';
import { appRouter } from './router/appRouter';
import globalize from '../scripts/globalize';
import dom from '../scripts/dom';
import recordingHelper from './recordingcreator/recordinghelper';

View file

@ -2,6 +2,7 @@ import React, { FunctionComponent, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import globalize from '../../scripts/globalize';
import type { RestoreViewFailResponse } from '../../types/viewManager';
import viewManager from './viewManager';
export interface ViewManagerPageProps {
@ -45,7 +46,7 @@ const ViewManagerPage: FunctionComponent<ViewManagerPageProps> = ({
};
viewManager.tryRestoreView(viewOptions)
.catch(async (result?: any) => {
.catch(async (result?: RestoreViewFailResponse) => {
if (!result || !result.cancelled) {
const [ controllerFactory, viewHtml ] = await Promise.all([
import(/* webpackChunkName: "[request]" */ `../../controllers/${controller}`),
@ -63,7 +64,10 @@ const ViewManagerPage: FunctionComponent<ViewManagerPageProps> = ({
};
loadPage();
}, [
},
// location.state is NOT included as a dependency here since dialogs will update state while the current view stays the same
// eslint-disable-next-line react-hooks/exhaustive-deps
[
controller,
view,
type,
@ -73,8 +77,6 @@ const ViewManagerPage: FunctionComponent<ViewManagerPageProps> = ({
transition,
location.pathname,
location.search
// location.state is NOT included as a dependency here since dialogs will update state while the current view
// stays the same
]);
return <></>;