Merge branch 'master' into segment-deletion

This commit is contained in:
Dominik 2023-06-15 20:30:56 +02:00 committed by GitHub
commit 128184cc72
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
497 changed files with 70077 additions and 54756 deletions

30
src/RootApp.tsx Normal file
View file

@ -0,0 +1,30 @@
import loadable from '@loadable/component';
import { History } from '@remix-run/router';
import React from 'react';
import StableApp from './apps/stable/App';
import { HistoryRouter } from './components/router/HistoryRouter';
import { ApiProvider } from './hooks/useApi';
import { WebConfigProvider } from './hooks/useWebConfig';
const ExperimentalApp = loadable(() => import('./apps/experimental/App'));
const RootApp = ({ history }: { history: History }) => {
const layoutMode = localStorage.getItem('layout');
return (
<ApiProvider>
<WebConfigProvider>
<HistoryRouter history={history}>
{
layoutMode === 'experimental' ?
<ExperimentalApp /> :
<StableApp />
}
</HistoryRouter>
</WebConfigProvider>
</ApiProvider>
);
};
export default RootApp;

3
src/apiclient.d.ts vendored
View file

@ -117,6 +117,7 @@ declare module 'jellyfin-apiclient' {
getCountries(): Promise<CountryInfo[]>;
getCriticReviews(itemId: string, options?: any): Promise<BaseItemDtoQueryResult>;
getCultures(): Promise<CultureDto[]>;
getCurrentUser(cache?: boolean): Promise<UserDto>;
getCurrentUserId(): string;
getDateParamValue(date: Date): string;
getDefaultImageQuality(imageType: ImageType): number;
@ -267,7 +268,7 @@ declare module 'jellyfin-apiclient' {
sendWebSocketMessage(name: string, data: any): void;
serverAddress(val?: string): string;
serverId(): string;
serverVersion(): string
serverVersion(): string;
setAuthenticationInfo(accessKey?: string, userId?: string): void;
setRequestHeaders(headers: any): void;
setSystemInfo(info: SystemInfo): void;

View file

@ -0,0 +1,44 @@
import React from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import ConnectionRequired from 'components/ConnectionRequired';
import ServerContentPage from 'components/ServerContentPage';
import { toAsyncPageRoute } from 'components/router/AsyncRoute';
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
import AppLayout from './AppLayout';
import { ASYNC_ADMIN_ROUTES, ASYNC_USER_ROUTES } from './routes/asyncRoutes';
import { LEGACY_ADMIN_ROUTES, LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './routes/legacyRoutes';
const ExperimentalApp = () => {
return (
<Routes>
<Route path='/*' element={<AppLayout />}>
{/* User routes */}
<Route element={<ConnectionRequired />}>
{ASYNC_USER_ROUTES.map(toAsyncPageRoute)}
{LEGACY_USER_ROUTES.map(toViewManagerPageRoute)}
</Route>
{/* Admin routes */}
<Route element={<ConnectionRequired isAdminRequired />}>
{ASYNC_ADMIN_ROUTES.map(toAsyncPageRoute)}
{LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute)}
<Route path='configurationpage' element={
<ServerContentPage view='/web/configurationpage' />
} />
</Route>
{/* Public routes */}
<Route element={<ConnectionRequired isUserRequired={false} />}>
<Route index element={<Navigate replace to='/home.html' />} />
{LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute)}
</Route>
</Route>
</Routes>
);
};
export default ExperimentalApp;

View file

@ -0,0 +1,114 @@
import React, { useCallback, useEffect, useState } from 'react';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import { ThemeProvider } from '@mui/material/styles';
import { Outlet, useLocation } from 'react-router-dom';
import AppHeader from 'components/AppHeader';
import Backdrop from 'components/Backdrop';
import { useApi } from 'hooks/useApi';
import { useLocalStorage } from 'hooks/useLocalStorage';
import AppToolbar from './components/AppToolbar';
import AppDrawer, { DRAWER_WIDTH, isDrawerPath } from './components/drawers/AppDrawer';
import ElevationScroll from './components/ElevationScroll';
import theme from './theme';
import './AppOverrides.scss';
interface ExperimentalAppSettings {
isDrawerPinned: boolean
}
const DEFAULT_EXPERIMENTAL_APP_SETTINGS: ExperimentalAppSettings = {
isDrawerPinned: false
};
const AppLayout = () => {
const [ appSettings, setAppSettings ] = useLocalStorage<ExperimentalAppSettings>('ExperimentalAppSettings', DEFAULT_EXPERIMENTAL_APP_SETTINGS);
const [ isDrawerActive, setIsDrawerActive ] = useState(appSettings.isDrawerPinned);
const { user } = useApi();
const location = useLocation();
const isDrawerAvailable = isDrawerPath(location.pathname);
const isDrawerOpen = isDrawerActive && isDrawerAvailable && Boolean(user);
useEffect(() => {
if (isDrawerActive !== appSettings.isDrawerPinned) {
setAppSettings({
...appSettings,
isDrawerPinned: isDrawerActive
});
}
}, [ appSettings, isDrawerActive, setAppSettings ]);
const onToggleDrawer = useCallback(() => {
setIsDrawerActive(!isDrawerActive);
}, [ isDrawerActive, setIsDrawerActive ]);
return (
<ThemeProvider theme={theme}>
<Backdrop />
<div style={{ display: 'none' }}>
{/*
* TODO: These components are not used, but views interact with them directly so the need to be
* present in the dom. We add them in a hidden element to prevent errors.
*/}
<AppHeader />
</div>
<Box sx={{ display: 'flex' }}>
<ElevationScroll elevate={isDrawerOpen}>
<AppBar
position='fixed'
sx={{ zIndex: (muiTheme) => muiTheme.zIndex.drawer + 1 }}
>
<AppToolbar
isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onToggleDrawer}
/>
</AppBar>
</ElevationScroll>
<AppDrawer
open={isDrawerOpen}
onClose={onToggleDrawer}
onOpen={onToggleDrawer}
/>
<Box
component='main'
sx={{
width: '100%',
flexGrow: 1,
transition: theme.transitions.create('margin', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen
}),
marginLeft: 0,
...(isDrawerAvailable && {
marginLeft: {
sm: `-${DRAWER_WIDTH}px`
}
}),
...(isDrawerActive && {
transition: theme.transitions.create('margin', {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen
}),
marginLeft: 0
})
}}
>
<div className='mainAnimatedPages skinBody' />
<div className='skinBody'>
<Outlet />
</div>
</Box>
</Box>
</ThemeProvider>
);
};
export default AppLayout;

View file

@ -0,0 +1,40 @@
// Default MUI breakpoints
// https://mui.com/material-ui/customization/breakpoints/#default-breakpoints
$mui-bp-sm: 600px;
$mui-bp-md: 900px;
$mui-bp-lg: 1200px;
$mui-bp-xl: 1536px;
// Fix main pages layout to work with drawer
.mainAnimatedPage {
position: relative;
}
// Fix dashboard pages layout to work with drawer
.dashboardDocument .skinBody {
position: unset;
}
// Hide some items from the user "settings" page that are in the drawer
#myPreferencesMenuPage {
.lnkQuickConnectPreferences,
.adminSection,
.userSection {
display: none !important;
}
}
// Fix the padding of some pages
.homePage.libraryPage, // Home page
.libraryPage:not(.withTabs), // Tabless library pages
.content-primary.content-primary { // Dashboard pages
padding-top: 3.25rem !important;
}
.libraryPage.withTabs {
padding-top: 6.5rem !important;
@media all and (min-width: $mui-bp-lg) {
padding-top: 3.25rem !important;
}
}

View file

@ -0,0 +1,112 @@
import React, { useCallback, useEffect, useState } from 'react';
import CastConnected from '@mui/icons-material/CastConnected';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Cast from '@mui/icons-material/Cast';
import IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip';
import { playbackManager } from 'components/playback/playbackmanager';
import globalize from 'scripts/globalize';
import Events from 'utils/events';
import RemotePlayMenu, { ID } from './menus/RemotePlayMenu';
import RemotePlayActiveMenu, { ID as ACTIVE_ID } from './menus/RemotePlayActiveMenu';
const RemotePlayButton = () => {
const theme = useTheme();
const [ playerInfo, setPlayerInfo ] = useState(playbackManager.getPlayerInfo());
const updatePlayerInfo = useCallback(() => {
setPlayerInfo(playbackManager.getPlayerInfo());
}, [ setPlayerInfo ]);
useEffect(() => {
Events.on(playbackManager, 'playerchange', updatePlayerInfo);
return () => {
Events.off(playbackManager, 'playerchange', updatePlayerInfo);
};
}, [ updatePlayerInfo ]);
const [ remotePlayMenuAnchorEl, setRemotePlayMenuAnchorEl ] = useState<null | HTMLElement>(null);
const isRemotePlayMenuOpen = Boolean(remotePlayMenuAnchorEl);
const onRemotePlayButtonClick = useCallback((event) => {
setRemotePlayMenuAnchorEl(event.currentTarget);
}, [ setRemotePlayMenuAnchorEl ]);
const onRemotePlayMenuClose = useCallback(() => {
setRemotePlayMenuAnchorEl(null);
}, [ setRemotePlayMenuAnchorEl ]);
const [ remotePlayActiveMenuAnchorEl, setRemotePlayActiveMenuAnchorEl ] = useState<null | HTMLElement>(null);
const isRemotePlayActiveMenuOpen = Boolean(remotePlayActiveMenuAnchorEl);
const onRemotePlayActiveButtonClick = useCallback((event) => {
setRemotePlayActiveMenuAnchorEl(event.currentTarget);
}, [ setRemotePlayActiveMenuAnchorEl ]);
const onRemotePlayActiveMenuClose = useCallback(() => {
setRemotePlayActiveMenuAnchorEl(null);
}, [ setRemotePlayActiveMenuAnchorEl ]);
return (
<>
{(playerInfo && !playerInfo.isLocalPlayer) ? (
<Box
sx={{
alignSelf: 'center'
}}
>
<Tooltip title={globalize.translate('ButtonCast')}>
<Button
variant='text'
size='large'
startIcon={<CastConnected />}
aria-label={globalize.translate('ButtonCast')}
aria-controls={ACTIVE_ID}
aria-haspopup='true'
onClick={onRemotePlayActiveButtonClick}
color='inherit'
sx={{
color: theme.palette.primary.main
}}
>
{playerInfo.deviceName || playerInfo.name}
</Button>
</Tooltip>
</Box>
) : (
<Tooltip title={globalize.translate('ButtonCast')}>
<IconButton
size='large'
aria-label={globalize.translate('ButtonCast')}
aria-controls={ID}
aria-haspopup='true'
onClick={onRemotePlayButtonClick}
color='inherit'
>
<Cast />
</IconButton>
</Tooltip>
)}
<RemotePlayMenu
open={isRemotePlayMenuOpen}
anchorEl={remotePlayMenuAnchorEl}
onMenuClose={onRemotePlayMenuClose}
/>
<RemotePlayActiveMenu
open={isRemotePlayActiveMenuOpen}
anchorEl={remotePlayActiveMenuAnchorEl}
onMenuClose={onRemotePlayActiveMenuClose}
playerInfo={playerInfo}
/>
</>
);
};
export default RemotePlayButton;

View file

@ -0,0 +1,64 @@
import Avatar from '@mui/material/Avatar';
import IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip';
import React, { useCallback, useState } from 'react';
import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
import AppUserMenu, { ID } from './menus/AppUserMenu';
const UserMenuButton = () => {
const theme = useTheme();
const { api, user } = useApi();
const [ userMenuAnchorEl, setUserMenuAnchorEl ] = useState<null | HTMLElement>(null);
const isUserMenuOpen = Boolean(userMenuAnchorEl);
const onUserButtonClick = useCallback((event) => {
setUserMenuAnchorEl(event.currentTarget);
}, [ setUserMenuAnchorEl ]);
const onUserMenuClose = useCallback(() => {
setUserMenuAnchorEl(null);
}, [ setUserMenuAnchorEl ]);
return (
<>
<Tooltip title={globalize.translate('UserMenu')}>
<IconButton
size='large'
edge='end'
aria-label={globalize.translate('UserMenu')}
aria-controls={ID}
aria-haspopup='true'
onClick={onUserButtonClick}
color='inherit'
sx={{ padding: 0 }}
>
<Avatar
alt={user?.Name || undefined}
src={
api && user?.Id ?
`${api.basePath}/Users/${user.Id}/Images/Primary?tag=${user.PrimaryImageTag}` :
undefined
}
sx={{
bgcolor: theme.palette.primary.dark,
color: 'inherit'
}}
/>
</IconButton>
</Tooltip>
<AppUserMenu
open={isUserMenuOpen}
anchorEl={userMenuAnchorEl}
onMenuClose={onUserMenuClose}
/>
</>
);
};
export default UserMenuButton;

View file

@ -0,0 +1,117 @@
import MenuIcon from '@mui/icons-material/Menu';
import SearchIcon from '@mui/icons-material/Search';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Toolbar from '@mui/material/Toolbar';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import React, { FC } from 'react';
import { Link, useLocation } from 'react-router-dom';
import appIcon from 'assets/img/icon-transparent.png';
import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
import AppTabs from '../tabs/AppTabs';
import { isDrawerPath } from '../drawers/AppDrawer';
import UserMenuButton from './UserMenuButton';
import RemotePlayButton from './RemotePlayButton';
interface AppToolbarProps {
isDrawerOpen: boolean
onDrawerButtonClick: (event: React.MouseEvent<HTMLElement>) => void
}
const AppToolbar: FC<AppToolbarProps> = ({
isDrawerOpen,
onDrawerButtonClick
}) => {
const { user } = useApi();
const isUserLoggedIn = Boolean(user);
const location = useLocation();
const isDrawerAvailable = isDrawerPath(location.pathname);
return (
<Toolbar
variant='dense'
sx={{
flexWrap: {
xs: 'wrap',
lg: 'nowrap'
}
}}
>
{isUserLoggedIn && isDrawerAvailable && (
<Tooltip title={globalize.translate(isDrawerOpen ? 'MenuClose' : 'MenuOpen')}>
<IconButton
size='large'
edge='start'
color='inherit'
aria-label={globalize.translate(isDrawerOpen ? 'MenuClose' : 'MenuOpen')}
sx={{ mr: 2 }}
onClick={onDrawerButtonClick}
>
<MenuIcon />
</IconButton>
</Tooltip>
)}
<Box
component={Link}
to='/'
color='inherit'
aria-label={globalize.translate('Home')}
sx={{
display: 'inline-flex',
textDecoration: 'none'
}}
>
<Box
component='img'
src={appIcon}
sx={{
height: '2rem',
marginInlineEnd: 1
}}
/>
<Typography
variant='h6'
noWrap
component='div'
sx={{ display: { xs: 'none', sm: 'inline-block' } }}
>
Jellyfin
</Typography>
</Box>
<AppTabs isDrawerOpen={isDrawerOpen} />
{isUserLoggedIn && (
<>
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'flex-end' }}>
<RemotePlayButton />
<Tooltip title={globalize.translate('Search')}>
<IconButton
size='large'
aria-label={globalize.translate('Search')}
color='inherit'
component={Link}
to='/search.html'
>
<SearchIcon />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ flexGrow: 0 }}>
<UserMenuButton />
</Box>
</>
)}
</Toolbar>
);
};
export default AppToolbar;

View file

@ -0,0 +1,165 @@
import { AppSettingsAlt, Close } from '@mui/icons-material';
import AccountCircle from '@mui/icons-material/AccountCircle';
import Logout from '@mui/icons-material/Logout';
import PhonelinkLock from '@mui/icons-material/PhonelinkLock';
import Settings from '@mui/icons-material/Settings';
import Storage from '@mui/icons-material/Storage';
import Divider from '@mui/material/Divider';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Menu, { MenuProps } from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import React, { FC, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { appHost } from 'components/apphost';
import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
import Dashboard from 'utils/dashboard';
export const ID = 'app-user-menu';
interface AppUserMenuProps extends MenuProps {
onMenuClose: () => void
}
const AppUserMenu: FC<AppUserMenuProps> = ({
anchorEl,
open,
onMenuClose
}) => {
const { user } = useApi();
const onClientSettingsClick = useCallback(() => {
window.NativeShell?.openClientSettings();
onMenuClose();
}, [ onMenuClose ]);
const onExitAppClick = useCallback(() => {
appHost.exit();
onMenuClose();
}, [ onMenuClose ]);
const onLogoutClick = useCallback(() => {
Dashboard.logout();
onMenuClose();
}, [ onMenuClose ]);
const onSelectServerClick = useCallback(() => {
Dashboard.selectServer();
onMenuClose();
}, [ onMenuClose ]);
return (
<Menu
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right'
}}
id={ID}
keepMounted
open={open}
onClose={onMenuClose}
>
<MenuItem
component={Link}
to={`/userprofile.html?userId=${user?.Id}`}
onClick={onMenuClose}
>
<ListItemIcon>
<AccountCircle />
</ListItemIcon>
<ListItemText>
{globalize.translate('Profile')}
</ListItemText>
</MenuItem>
<MenuItem
component={Link}
to='/mypreferencesmenu.html'
onClick={onMenuClose}
>
<ListItemIcon>
<Settings />
</ListItemIcon>
<ListItemText>
{globalize.translate('Settings')}
</ListItemText>
</MenuItem>
{appHost.supports('clientsettings') && ([
<Divider key='client-settings-divider' />,
<MenuItem
key='client-settings-button'
onClick={onClientSettingsClick}
>
<ListItemIcon>
<AppSettingsAlt />
</ListItemIcon>
<ListItemText>
{globalize.translate('ClientSettings')}
</ListItemText>
</MenuItem>
])}
<Divider />
<MenuItem
component={Link}
to='/mypreferencesquickconnect.html'
onClick={onMenuClose}
>
<ListItemIcon>
<PhonelinkLock />
</ListItemIcon>
<ListItemText>
{globalize.translate('QuickConnect')}
</ListItemText>
</MenuItem>
{appHost.supports('multiserver') && (
<MenuItem
onClick={onSelectServerClick}
>
<ListItemIcon>
<Storage />
</ListItemIcon>
<ListItemText>
{globalize.translate('SelectServer')}
</ListItemText>
</MenuItem>
)}
<MenuItem
onClick={onLogoutClick}
>
<ListItemIcon>
<Logout />
</ListItemIcon>
<ListItemText>
{globalize.translate('ButtonSignOut')}
</ListItemText>
</MenuItem>
{appHost.supports('exitmenu') && ([
<Divider key='exit-menu-divider' />,
<MenuItem
key='exit-menu-button'
onClick={onExitAppClick}
>
<ListItemIcon>
<Close />
</ListItemIcon>
<ListItemText>
{globalize.translate('ButtonExitApp')}
</ListItemText>
</MenuItem>
])}
</Menu>
);
};
export default AppUserMenu;

View file

@ -0,0 +1,160 @@
import Check from '@mui/icons-material/Check';
import Close from '@mui/icons-material/Close';
import SettingsRemote from '@mui/icons-material/SettingsRemote';
import Divider from '@mui/material/Divider';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader';
import Menu, { MenuProps } from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import dialog from 'components/dialog/dialog';
import { playbackManager } from 'components/playback/playbackmanager';
import React, { FC, useCallback, useState } from 'react';
import { Link } from 'react-router-dom';
import { enable, isEnabled, supported } from 'scripts/autocast';
import globalize from 'scripts/globalize';
interface RemotePlayActiveMenuProps extends MenuProps {
onMenuClose: () => void
playerInfo: {
name: string
isLocalPlayer: boolean
id?: string
deviceName?: string
playableMediaTypes?: string[]
supportedCommands?: string[]
} | null
}
export const ID = 'app-remote-play-active-menu';
const RemotePlayActiveMenu: FC<RemotePlayActiveMenuProps> = ({
anchorEl,
open,
onMenuClose,
playerInfo
}) => {
const [ isDisplayMirrorEnabled, setIsDisplayMirrorEnabled ] = useState(playbackManager.enableDisplayMirroring());
const isDisplayMirrorSupported = playerInfo?.supportedCommands && playerInfo.supportedCommands.indexOf('DisplayContent') !== -1;
const toggleDisplayMirror = useCallback(() => {
playbackManager.enableDisplayMirroring(!isDisplayMirrorEnabled);
setIsDisplayMirrorEnabled(!isDisplayMirrorEnabled);
}, [ isDisplayMirrorEnabled, setIsDisplayMirrorEnabled ]);
const [ isAutoCastEnabled, setIsAutoCastEnabled ] = useState(isEnabled());
const isAutoCastSupported = supported();
const toggleAutoCast = useCallback(() => {
enable(!isAutoCastEnabled);
setIsAutoCastEnabled(!isAutoCastEnabled);
}, [ isAutoCastEnabled, setIsAutoCastEnabled ]);
const remotePlayerName = playerInfo?.deviceName || playerInfo?.name;
const disconnectRemotePlayer = useCallback(() => {
if (playbackManager.getSupportedCommands().indexOf('EndSession') !== -1) {
dialog.show({
buttons: [
{
name: globalize.translate('Yes'),
id: 'yes'
}, {
name: globalize.translate('No'),
id: 'no'
}
],
text: globalize.translate('ConfirmEndPlayerSession', remotePlayerName)
}).then(id => {
onMenuClose();
if (id === 'yes') {
playbackManager.getCurrentPlayer().endSession();
}
playbackManager.setDefaultPlayerActive();
}).catch(() => {
// Dialog closed
});
} else {
onMenuClose();
playbackManager.setDefaultPlayerActive();
}
}, [ onMenuClose, remotePlayerName ]);
return (
<Menu
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right'
}}
id={ID}
keepMounted
open={open}
onClose={onMenuClose}
MenuListProps={{
'aria-labelledby': 'remote-play-active-subheader',
subheader: (
<ListSubheader component='div' id='remote-play-active-subheader'>
{remotePlayerName}
</ListSubheader>
)
}}
>
{isDisplayMirrorSupported && (
<MenuItem onClick={toggleDisplayMirror}>
{isDisplayMirrorEnabled && (
<ListItemIcon>
<Check />
</ListItemIcon>
)}
<ListItemText inset={!isDisplayMirrorEnabled}>
{globalize.translate('EnableDisplayMirroring')}
</ListItemText>
</MenuItem>
)}
{isAutoCastSupported && (
<MenuItem onClick={toggleAutoCast}>
{isAutoCastEnabled && (
<ListItemIcon>
<Check />
</ListItemIcon>
)}
<ListItemText inset={!isAutoCastEnabled}>
{globalize.translate('EnableAutoCast')}
</ListItemText>
</MenuItem>
)}
{(isDisplayMirrorSupported || isAutoCastSupported) && <Divider />}
<MenuItem
component={Link}
to='/queue'
onClick={onMenuClose}
>
<ListItemIcon>
<SettingsRemote />
</ListItemIcon>
<ListItemText>
{globalize.translate('HeaderRemoteControl')}
</ListItemText>
</MenuItem>
<Divider />
<MenuItem onClick={disconnectRemotePlayer}>
<ListItemIcon>
<Close />
</ListItemIcon>
<ListItemText>
{globalize.translate('Disconnect')}
</ListItemText>
</MenuItem>
</Menu>
);
};
export default RemotePlayActiveMenu;

View file

@ -0,0 +1,100 @@
import Warning from '@mui/icons-material/Warning';
import Divider from '@mui/material/Divider';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Menu, { type MenuProps } from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import React, { FC, useEffect, useState } from 'react';
import globalize from 'scripts/globalize';
import { playbackManager } from 'components/playback/playbackmanager';
import { pluginManager } from 'components/pluginManager';
import type { PlayTarget } from 'types/playTarget';
import PlayTargetIcon from '../../PlayTargetIcon';
interface RemotePlayMenuProps extends MenuProps {
onMenuClose: () => void
}
export const ID = 'app-remote-play-menu';
const RemotePlayMenu: FC<RemotePlayMenuProps> = ({
anchorEl,
open,
onMenuClose
}) => {
// TODO: Add other checks for support (Android app, secure context, etc)
const isChromecastPluginLoaded = !!pluginManager.plugins.find(plugin => plugin.id === 'chromecast');
const [ playbackTargets, setPlaybackTargets ] = useState<PlayTarget[]>([]);
const onPlayTargetClick = (target: PlayTarget) => {
playbackManager.trySetActivePlayer(target.playerName, target);
onMenuClose();
};
useEffect(() => {
const fetchPlaybackTargets = async () => {
setPlaybackTargets(
await playbackManager.getTargets()
);
};
if (open) {
fetchPlaybackTargets()
.catch(err => {
console.error('[AppRemotePlayMenu] unable to get playback targets', err);
});
}
}, [ open, setPlaybackTargets ]);
return (
<Menu
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right'
}}
id={ID}
keepMounted
open={open}
onClose={onMenuClose}
>
{!isChromecastPluginLoaded && ([
<MenuItem key='cast-unsupported-item' disabled>
<ListItemIcon>
<Warning />
</ListItemIcon>
<ListItemText>
{globalize.translate('GoogleCastUnsupported')}
</ListItemText>
</MenuItem>,
<Divider key='cast-unsupported-divider' />
])}
{playbackTargets.map(target => (
<MenuItem
key={target.id}
// Since we are looping over targets there is no good way to avoid creating a new function here
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onPlayTargetClick(target)}
>
<ListItemIcon>
<PlayTargetIcon target={target} />
</ListItemIcon>
<ListItemText
primary={ target.appName ? `${target.name} - ${target.appName}` : target.name }
secondary={ target.user?.Name }
/>
</MenuItem>
))}
</Menu>
);
};
export default RemotePlayMenu;

View file

@ -0,0 +1,21 @@
import useScrollTrigger from '@mui/material/useScrollTrigger';
import React, { ReactElement } from 'react';
/**
* Component that changes the elevation of a child component when scrolled.
*/
const ElevationScroll = ({ children, elevate = false }: { children: ReactElement, elevate?: boolean }) => {
const trigger = useScrollTrigger({
disableHysteresis: true,
threshold: 0
});
const isElevated = elevate || trigger;
return React.cloneElement(children, {
color: isElevated ? 'primary' : 'transparent',
elevation: isElevated ? 4 : 0
});
};
export default ElevationScroll;

View file

@ -0,0 +1,50 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import Movie from '@mui/icons-material/Movie';
import MusicNote from '@mui/icons-material/MusicNote';
import Photo from '@mui/icons-material/Photo';
import LiveTv from '@mui/icons-material/LiveTv';
import Tv from '@mui/icons-material/Tv';
import Theaters from '@mui/icons-material/Theaters';
import MusicVideo from '@mui/icons-material/MusicVideo';
import Book from '@mui/icons-material/Book';
import Collections from '@mui/icons-material/Collections';
import Queue from '@mui/icons-material/Queue';
import Folder from '@mui/icons-material/Folder';
import React, { FC } from 'react';
import { CollectionType } from 'types/collectionType';
interface LibraryIconProps {
item: BaseItemDto
}
const LibraryIcon: FC<LibraryIconProps> = ({
item
}) => {
switch (item.CollectionType) {
case CollectionType.Movies:
return <Movie />;
case CollectionType.Music:
return <MusicNote />;
case CollectionType.HomeVideos:
case CollectionType.Photos:
return <Photo />;
case CollectionType.LiveTv:
return <LiveTv />;
case CollectionType.TvShows:
return <Tv />;
case CollectionType.Trailers:
return <Theaters />;
case CollectionType.MusicVideos:
return <MusicVideo />;
case CollectionType.Books:
return <Book />;
case CollectionType.BoxSets:
return <Collections />;
case CollectionType.Playlists:
return <Queue />;
default:
return <Folder />;
}
};
export default LibraryIcon;

View file

@ -0,0 +1,38 @@
import React from 'react';
import Cast from '@mui/icons-material/Cast';
import Computer from '@mui/icons-material/Computer';
import Devices from '@mui/icons-material/Devices';
import Smartphone from '@mui/icons-material/Smartphone';
import Tablet from '@mui/icons-material/Tablet';
import Tv from '@mui/icons-material/Tv';
import browser from 'scripts/browser';
import type { PlayTarget } from 'types/playTarget';
const PlayTargetIcon = ({ target }: { target: PlayTarget }) => {
if (!target.deviceType && target.isLocalPlayer) {
if (browser.tv) {
return <Tv />;
} else if (browser.mobile) {
return <Smartphone />;
}
return <Computer />;
}
switch (target.deviceType) {
case 'smartphone':
return <Smartphone />;
case 'tablet':
return <Tablet />;
case 'desktop':
return <Computer />;
case 'cast':
return <Cast />;
case 'tv':
return <Tv />;
default:
return <Devices />;
}
};
export default PlayTargetIcon;

View file

@ -0,0 +1,86 @@
import React, { FC } from 'react';
import { Route, Routes } from 'react-router-dom';
import { ASYNC_ADMIN_ROUTES, ASYNC_USER_ROUTES } from '../../routes/asyncRoutes';
import { LEGACY_ADMIN_ROUTES, LEGACY_USER_ROUTES } from '../../routes/legacyRoutes';
import AdvancedDrawerSection from './dashboard/AdvancedDrawerSection';
import DevicesDrawerSection from './dashboard/DevicesDrawerSection';
import LiveTvDrawerSection from './dashboard/LiveTvDrawerSection';
import PluginDrawerSection from './dashboard/PluginDrawerSection';
import ServerDrawerSection from './dashboard/ServerDrawerSection';
import MainDrawerContent from './MainDrawerContent';
import ResponsiveDrawer, { ResponsiveDrawerProps } from './ResponsiveDrawer';
export const DRAWER_WIDTH = 240;
const DRAWERLESS_ROUTES = [
'edititemmetadata.html', // metadata manager
'video' // video player
];
const MAIN_DRAWER_ROUTES = [
...ASYNC_USER_ROUTES,
...LEGACY_USER_ROUTES
].filter(route => !DRAWERLESS_ROUTES.includes(route.path));
const ADMIN_DRAWER_ROUTES = [
...ASYNC_ADMIN_ROUTES,
...LEGACY_ADMIN_ROUTES,
{ path: '/configurationpage' } // Plugin configuration page
].filter(route => !DRAWERLESS_ROUTES.includes(route.path));
/** Utility function to check if a path has a drawer. */
export const isDrawerPath = (path: string) => (
MAIN_DRAWER_ROUTES.some(route => route.path === path || `/${route.path}` === path)
|| ADMIN_DRAWER_ROUTES.some(route => route.path === path || `/${route.path}` === path)
);
const AppDrawer: FC<ResponsiveDrawerProps> = ({
open = false,
onClose,
onOpen
}) => (
<Routes>
{
MAIN_DRAWER_ROUTES.map(route => (
<Route
key={route.path}
path={route.path}
element={
<ResponsiveDrawer
open={open}
onClose={onClose}
onOpen={onOpen}
>
<MainDrawerContent />
</ResponsiveDrawer>
}
/>
))
}
{
ADMIN_DRAWER_ROUTES.map(route => (
<Route
key={route.path}
path={route.path}
element={
<ResponsiveDrawer
open={open}
onClose={onClose}
onOpen={onOpen}
>
<ServerDrawerSection />
<DevicesDrawerSection />
<LiveTvDrawerSection />
<AdvancedDrawerSection />
<PluginDrawerSection />
</ResponsiveDrawer>
}
/>
))
}
</Routes>
);
export default AppDrawer;

View file

@ -0,0 +1,45 @@
import ListItemButton, { ListItemButtonBaseProps } from '@mui/material/ListItemButton';
import React, { FC } from 'react';
import { Link, useLocation, useSearchParams } from 'react-router-dom';
interface ListItemLinkProps extends ListItemButtonBaseProps {
to: string
}
const isMatchingParams = (routeParams: URLSearchParams, currentParams: URLSearchParams) => {
for (const param of routeParams) {
if (currentParams.get(param[0]) !== param[1]) {
return false;
}
}
return true;
};
const ListItemLink: FC<ListItemLinkProps> = ({
children,
to,
...params
}) => {
const location = useLocation();
const [ searchParams ] = useSearchParams();
const [ toPath, toParams ] = to.split('?');
// eslint-disable-next-line compat/compat
const toSearchParams = new URLSearchParams(`?${toParams}`);
const selected = location.pathname === toPath && (!toParams || isMatchingParams(toSearchParams, searchParams));
return (
<ListItemButton
component={Link}
to={to}
selected={selected}
{...params}
>
{children}
</ListItemButton>
);
};
export default ListItemLink;

View file

@ -0,0 +1,186 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import type { SystemInfo } from '@jellyfin/sdk/lib/generated-client/models/system-info';
import { getUserViewsApi } from '@jellyfin/sdk/lib/utils/api/user-views-api';
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
import Dashboard from '@mui/icons-material/Dashboard';
import Edit from '@mui/icons-material/Edit';
import Favorite from '@mui/icons-material/Favorite';
import Home from '@mui/icons-material/Home';
import Link from '@mui/icons-material/Link';
import Divider from '@mui/material/Divider';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader';
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useApi } from 'hooks/useApi';
import { useWebConfig } from 'hooks/useWebConfig';
import globalize from 'scripts/globalize';
import { appRouter } from 'components/router/appRouter';
import ListItemLink from './ListItemLink';
import LibraryIcon from '../LibraryIcon';
const MainDrawerContent = () => {
const { api, user } = useApi();
const location = useLocation();
const [ systemInfo, setSystemInfo ] = useState<SystemInfo>();
const [ userViews, setUserViews ] = useState<BaseItemDto[]>([]);
const webConfig = useWebConfig();
const isHomeSelected = location.pathname === '/home.html' && (!location.search || location.search === '?tab=0');
useEffect(() => {
if (api && user?.Id) {
getUserViewsApi(api)
.getUserViews({ userId: user.Id })
.then(({ data }) => {
setUserViews(data.Items || []);
})
.catch(err => {
console.warn('[MainDrawer] failed to fetch user views', err);
setUserViews([]);
});
getSystemApi(api)
.getSystemInfo()
.then(({ data }) => {
setSystemInfo(data);
})
.catch(err => {
console.warn('[MainDrawer] failed to fetch system info', err);
});
} else {
setUserViews([]);
}
}, [ api, user?.Id ]);
return (
<>
{/* MAIN LINKS */}
<List>
<ListItem disablePadding>
<ListItemLink to='/home.html' selected={isHomeSelected}>
<ListItemIcon>
<Home />
</ListItemIcon>
<ListItemText primary={globalize.translate('Home')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/home.html?tab=1'>
<ListItemIcon>
<Favorite />
</ListItemIcon>
<ListItemText primary={globalize.translate('Favorites')} />
</ListItemLink>
</ListItem>
</List>
{/* CUSTOM LINKS */}
{(!!webConfig.menuLinks && webConfig.menuLinks.length > 0) && (
<>
<Divider />
<List>
{webConfig.menuLinks.map(menuLink => (
<ListItem
key={`${menuLink.name}_${menuLink.url}`}
disablePadding
>
<ListItemButton
component='a'
href={menuLink.url}
target='_blank'
rel='noopener noreferrer'
>
<ListItemIcon>
{/* TODO: Support custom icons */}
<Link />
</ListItemIcon>
<ListItemText primary={menuLink.name} />
</ListItemButton>
</ListItem>
))}
</List>
</>
)}
{/* LIBRARY LINKS */}
{userViews.length > 0 && (
<>
<Divider />
<List
aria-labelledby='libraries-subheader'
subheader={
<ListSubheader component='div' id='libraries-subheader'>
{globalize.translate('HeaderLibraries')}
</ListSubheader>
}
>
{userViews.map(view => (
<ListItem key={view.Id} disablePadding>
<ListItemLink
to={appRouter.getRouteUrl(view, { context: view.CollectionType }).substring(1)}
>
<ListItemIcon>
<LibraryIcon item={view} />
</ListItemIcon>
<ListItemText primary={view.Name} />
</ListItemLink>
</ListItem>
))}
</List>
</>
)}
{/* ADMIN LINKS */}
{user?.Policy?.IsAdministrator && (
<>
<Divider />
<List
aria-labelledby='admin-subheader'
subheader={
<ListSubheader component='div' id='admin-subheader'>
{globalize.translate('HeaderAdmin')}
</ListSubheader>
}
>
<ListItem disablePadding>
<ListItemLink to='/dashboard.html'>
<ListItemIcon>
<Dashboard />
</ListItemIcon>
<ListItemText primary={globalize.translate('TabDashboard')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/edititemmetadata.html'>
<ListItemIcon>
<Edit />
</ListItemIcon>
<ListItemText primary={globalize.translate('MetadataManager')} />
</ListItemLink>
</ListItem>
</List>
</>
)}
{/* FOOTER */}
<Divider style={{ marginTop: 'auto' }} />
<List>
<ListItem>
<ListItemText
primary={systemInfo?.ServerName ? systemInfo.ServerName : 'Jellyfin'}
secondary={systemInfo?.Version ? `v${systemInfo.Version}` : ''}
/>
</ListItem>
</List>
</>
);
};
export default MainDrawerContent;

View file

@ -0,0 +1,86 @@
import { Theme } from '@mui/material/styles';
import Box from '@mui/material/Box';
import Drawer from '@mui/material/Drawer';
import SwipeableDrawer from '@mui/material/SwipeableDrawer';
import Toolbar from '@mui/material/Toolbar';
import useMediaQuery from '@mui/material/useMediaQuery';
import React, { FC, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import browser from 'scripts/browser';
import { DRAWER_WIDTH } from './AppDrawer';
import { isTabPath } from '../tabs/tabRoutes';
export interface ResponsiveDrawerProps {
open: boolean
onClose: () => void
onOpen: () => void
}
const ResponsiveDrawer: FC<ResponsiveDrawerProps> = ({
children,
open = false,
onClose,
onOpen
}) => {
const location = useLocation();
const isSmallScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('sm'));
const isLargeScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('lg'));
const isTallToolbar = isTabPath(location.pathname) && !isLargeScreen;
const getToolbarStyles = useCallback((theme: Theme) => ({
marginBottom: isTallToolbar ? theme.spacing(6) : 0
}), [ isTallToolbar ]);
return ( isSmallScreen ? (
/* DESKTOP DRAWER */
<Drawer
sx={{
width: DRAWER_WIDTH,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: DRAWER_WIDTH,
boxSizing: 'border-box'
}
}}
variant='persistent'
anchor='left'
open={open}
>
<Toolbar
variant='dense'
sx={getToolbarStyles}
/>
{children}
</Drawer>
) : (
/* MOBILE DRAWER */
<SwipeableDrawer
anchor='left'
open={open}
onClose={onClose}
onOpen={onOpen}
// Disable swipe to open on iOS since it interferes with back navigation
disableDiscovery={browser.iOS}
ModalProps={{
keepMounted: true // Better open performance on mobile.
}}
>
<Toolbar
variant='dense'
sx={getToolbarStyles}
/>
<Box
role='presentation'
// Close the drawer when the content is clicked
onClick={onClose}
onKeyDown={onClose}
>
{children}
</Box>
</SwipeableDrawer>
));
};
export default ResponsiveDrawer;

View file

@ -0,0 +1,110 @@
import Article from '@mui/icons-material/Article';
import EditNotifications from '@mui/icons-material/EditNotifications';
import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';
import Extension from '@mui/icons-material/Extension';
import Lan from '@mui/icons-material/Lan';
import Schedule from '@mui/icons-material/Schedule';
import VpnKey from '@mui/icons-material/VpnKey';
import Collapse from '@mui/material/Collapse';
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 globalize from 'scripts/globalize';
import ListItemLink from '../ListItemLink';
const PLUGIN_PATHS = [
'/installedplugins.html',
'/availableplugins.html',
'/repositories.html',
'/addplugin.html',
'/configurationpage'
];
const AdvancedDrawerSection = () => {
const location = useLocation();
const isPluginSectionOpen = PLUGIN_PATHS.includes(location.pathname);
return (
<List
aria-labelledby='advanced-subheader'
subheader={
<ListSubheader component='div' id='advanced-subheader'>
{globalize.translate('TabAdvanced')}
</ListSubheader>
}
>
<ListItem disablePadding>
<ListItemLink to='/networking.html'>
<ListItemIcon>
<Lan />
</ListItemIcon>
<ListItemText primary={globalize.translate('TabNetworking')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/apikeys.html'>
<ListItemIcon>
<VpnKey />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderApiKeys')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/log.html'>
<ListItemIcon>
<Article />
</ListItemIcon>
<ListItemText primary={globalize.translate('TabLogs')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/notificationsettings.html'>
<ListItemIcon>
<EditNotifications />
</ListItemIcon>
<ListItemText primary={globalize.translate('Notifications')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/installedplugins.html' selected={false}>
<ListItemIcon>
<Extension />
</ListItemIcon>
<ListItemText primary={globalize.translate('TabPlugins')} />
{isPluginSectionOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemLink>
</ListItem>
<Collapse in={isPluginSectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding>
<ListItemLink to='/installedplugins.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabMyPlugins')} />
</ListItemLink>
<ListItemLink to='/availableplugins.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabCatalog')} />
</ListItemLink>
<ListItemLink to='/repositories.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabRepositories')} />
</ListItemLink>
</List>
</Collapse>
<ListItem disablePadding>
<ListItemLink to='/scheduledtasks.html'>
<ListItemIcon>
<Schedule />
</ListItemIcon>
<ListItemText primary={globalize.translate('TabScheduledTasks')} />
</ListItemLink>
</ListItem>
</List>
);
};
export default AdvancedDrawerSection;

View file

@ -0,0 +1,73 @@
import { Devices, Analytics, Input, ExpandLess, ExpandMore } from '@mui/icons-material';
import Collapse from '@mui/material/Collapse';
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 globalize from 'scripts/globalize';
import ListItemLink from '../ListItemLink';
const DLNA_PATHS = [
'/dlnasettings.html',
'/dlnaprofiles.html'
];
const DevicesDrawerSection = () => {
const location = useLocation();
const isDlnaSectionOpen = DLNA_PATHS.includes(location.pathname);
return (
<List
aria-labelledby='devices-subheader'
subheader={
<ListSubheader component='div' id='devices-subheader'>
{globalize.translate('HeaderDevices')}
</ListSubheader>
}
>
<ListItem disablePadding>
<ListItemLink to='/devices.html'>
<ListItemIcon>
<Devices />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderDevices')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/serveractivity.html'>
<ListItemIcon>
<Analytics />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderActivity')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/dlnasettings.html' selected={false}>
<ListItemIcon>
<Input />
</ListItemIcon>
<ListItemText primary={'DLNA'} />
{isDlnaSectionOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemLink>
</ListItem>
<Collapse in={isDlnaSectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding>
<ListItemLink to='/dlnasettings.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('Settings')} />
</ListItemLink>
<ListItemLink to='/dlnaprofiles.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabProfiles')} />
</ListItemLink>
</List>
</Collapse>
</List>
);
};
export default DevicesDrawerSection;

View file

@ -0,0 +1,43 @@
import { Dvr, LiveTv } 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 globalize from 'scripts/globalize';
import ListItemLink from '../ListItemLink';
const LiveTvDrawerSection = () => {
return (
<List
aria-labelledby='livetv-subheader'
subheader={
<ListSubheader component='div' id='livetv-subheader'>
{globalize.translate('LiveTV')}
</ListSubheader>
}
>
<ListItem disablePadding>
<ListItemLink to='/livetvstatus.html'>
<ListItemIcon>
<LiveTv />
</ListItemIcon>
<ListItemText primary={globalize.translate('LiveTV')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/livetvsettings.html'>
<ListItemIcon>
<Dvr />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderDVR')} />
</ListItemLink>
</ListItem>
</List>
);
};
export default LiveTvDrawerSection;

View file

@ -0,0 +1,67 @@
import { ConfigurationPageInfo } from '@jellyfin/sdk/lib/generated-client';
import { getDashboardApi } from '@jellyfin/sdk/lib/utils/api/dashboard-api';
import { Folder } 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, { useEffect, useState } from 'react';
import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
import Dashboard from 'utils/dashboard';
import ListItemLink from '../ListItemLink';
const PluginDrawerSection = () => {
const { api } = useApi();
const [ pagesInfo, setPagesInfo ] = useState<ConfigurationPageInfo[]>([]);
useEffect(() => {
const fetchPluginPages = async () => {
if (!api) return;
const pagesResponse = await getDashboardApi(api)
.getConfigurationPages({ enableInMainMenu: true });
setPagesInfo(pagesResponse.data);
};
fetchPluginPages()
.catch(err => {
console.error('[PluginDrawerSection] unable to fetch plugin config pages', err);
});
}, [ api ]);
if (!api || pagesInfo.length < 1) {
return null;
}
return (
<List
aria-labelledby='plugins-subheader'
subheader={
<ListSubheader component='div' id='plugins-subheader'>
{globalize.translate('TabPlugins')}
</ListSubheader>
}
>
{
pagesInfo.map(pageInfo => (
<ListItem key={pageInfo.PluginId} disablePadding>
<ListItemLink to={`/${Dashboard.getPluginUrl(pageInfo.Name)}`}>
<ListItemIcon>
{/* TODO: Support different icons? */}
<Folder />
</ListItemIcon>
<ListItemText primary={pageInfo.DisplayName} />
</ListItemLink>
</ListItem>
))
}
</List>
);
};
export default PluginDrawerSection;

View file

@ -0,0 +1,118 @@
import { Dashboard, ExpandLess, ExpandMore, LibraryAdd, People, PlayCircle, Settings } from '@mui/icons-material';
import Collapse from '@mui/material/Collapse';
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 globalize from 'scripts/globalize';
import ListItemLink from '../ListItemLink';
const LIBRARY_PATHS = [
'/library.html',
'/librarydisplay.html',
'/metadataimages.html',
'/metadatanfo.html'
];
const PLAYBACK_PATHS = [
'/encodingsettings.html',
'/playbackconfiguration.html',
'/streamingsettings.html'
];
const ServerDrawerSection = () => {
const location = useLocation();
const isLibrarySectionOpen = LIBRARY_PATHS.includes(location.pathname);
const isPlaybackSectionOpen = PLAYBACK_PATHS.includes(location.pathname);
return (
<List
aria-labelledby='server-subheader'
subheader={
<ListSubheader component='div' id='server-subheader'>
{globalize.translate('TabServer')}
</ListSubheader>
}
>
<ListItem disablePadding>
<ListItemLink to='/dashboard.html'>
<ListItemIcon>
<Dashboard />
</ListItemIcon>
<ListItemText primary={globalize.translate('TabDashboard')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/dashboardgeneral.html'>
<ListItemIcon>
<Settings />
</ListItemIcon>
<ListItemText primary={globalize.translate('General')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/userprofiles.html'>
<ListItemIcon>
<People />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderUsers')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/library.html' selected={false}>
<ListItemIcon>
<LibraryAdd />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderLibraries')} />
{isLibrarySectionOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemLink>
</ListItem>
<Collapse in={isLibrarySectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding>
<ListItemLink to='/library.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('HeaderLibraries')} />
</ListItemLink>
<ListItemLink to='/librarydisplay.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('Display')} />
</ListItemLink>
<ListItemLink to='/metadataimages.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('Metadata')} />
</ListItemLink>
<ListItemLink to='/metadatanfo.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabNfoSettings')} />
</ListItemLink>
</List>
</Collapse>
<ListItem disablePadding>
<ListItemLink to='/encodingsettings.html' selected={false}>
<ListItemIcon>
<PlayCircle />
</ListItemIcon>
<ListItemText primary={globalize.translate('TitlePlayback')} />
{isPlaybackSectionOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemLink>
</ListItem>
<Collapse in={isPlaybackSectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding>
<ListItemLink to='/encodingsettings.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('Transcoding')} />
</ListItemLink>
<ListItemLink to='/playbackconfiguration.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('ButtonResume')} />
</ListItemLink>
<ListItemLink to='/streamingsettings.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabStreaming')} />
</ListItemLink>
</List>
</Collapse>
</List>
);
};
export default ServerDrawerSection;

View file

@ -0,0 +1,90 @@
import { Theme } from '@mui/material/styles';
import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import useMediaQuery from '@mui/material/useMediaQuery';
import { debounce } from 'lodash-es';
import React, { FC, useCallback, useEffect } from 'react';
import { Route, Routes, useLocation, useSearchParams } from 'react-router-dom';
import TabRoutes, { getDefaultTabIndex } from './tabRoutes';
interface AppTabsParams {
isDrawerOpen: boolean
}
const handleResize = debounce(() => window.dispatchEvent(new Event('resize')), 100);
const AppTabs: FC<AppTabsParams> = ({
isDrawerOpen
}) => {
const isBigScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('sm'));
const location = useLocation();
const [ searchParams, setSearchParams ] = useSearchParams();
const searchParamsTab = searchParams.get('tab');
const libraryId = location.pathname === '/livetv.html' ?
'livetv' : searchParams.get('topParentId');
const activeTab = searchParamsTab !== null ?
parseInt(searchParamsTab, 10) :
getDefaultTabIndex(location.pathname, libraryId);
// HACK: Force resizing to workaround upstream bug with tab resizing
// https://github.com/mui/material-ui/issues/24011
useEffect(() => {
handleResize();
}, [ isDrawerOpen ]);
const onTabClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
const tabIndex = event.currentTarget.dataset.tabIndex;
if (tabIndex) {
searchParams.set('tab', tabIndex);
setSearchParams(searchParams);
}
}, [ searchParams, setSearchParams ]);
return (
<Routes>
{
TabRoutes.map(route => (
<Route
key={route.path}
path={route.path}
element={
<Tabs
value={activeTab}
sx={{
width: '100%',
flexShrink: {
xs: 0,
lg: 'unset'
},
order: {
xs: 100,
lg: 'unset'
}
}}
variant={isBigScreen ? 'standard' : 'scrollable'}
centered={isBigScreen}
>
{
route.tabs.map(({ index, label }) => (
<Tab
key={`${route}-tab-${index}`}
label={label}
data-tab-index={`${index}`}
onClick={onTabClick}
/>
))
}
</Tabs>
}
/>
))
}
</Routes>
);
};
export default AppTabs;

View file

@ -0,0 +1,190 @@
import globalize from 'scripts/globalize';
import * as userSettings from 'scripts/settings/userSettings';
import { LibraryTab } from 'types/libraryTab';
interface TabDefinition {
index: number
label: string
value: LibraryTab
isDefault?: boolean
}
interface TabRoute {
path: string,
tabs: TabDefinition[]
}
/**
* Utility function to check if a path has tabs.
*/
export const isTabPath = (path: string) => (
TabRoutes.some(route => route.path === path)
);
/**
* Utility function to get the default tab index for a specified URL path and library.
*/
export const getDefaultTabIndex = (path: string, libraryId?: string | null) => {
if (!libraryId) return 0;
const tabs = TabRoutes.find(route => route.path === path)?.tabs ?? [];
const defaultTab = userSettings.get('landing-' + libraryId, false);
return tabs.find(tab => tab.value === defaultTab)?.index
?? tabs.find(tab => tab.isDefault)?.index
?? 0;
};
const TabRoutes: TabRoute[] = [
{
path: '/livetv.html',
tabs: [
{
index: 0,
label: globalize.translate('Programs'),
value: LibraryTab.Programs,
isDefault: true
},
{
index: 1,
label: globalize.translate('Guide'),
value: LibraryTab.Guide
},
{
index: 2,
label: globalize.translate('Channels'),
value: LibraryTab.Channels
},
{
index: 3,
label: globalize.translate('Recordings'),
value: LibraryTab.Recordings
},
{
index: 4,
label: globalize.translate('Schedule'),
value: LibraryTab.Schedule
},
{
index: 5,
label: globalize.translate('Series'),
value: LibraryTab.Series
}
]
},
{
path: '/movies.html',
tabs: [
{
index: 0,
label: globalize.translate('Movies'),
value: LibraryTab.Movies,
isDefault: true
},
{
index: 1,
label: globalize.translate('Suggestions'),
value: LibraryTab.Suggestions
},
{
index: 2,
label: globalize.translate('Trailers'),
value: LibraryTab.Trailers
},
{
index: 3,
label: globalize.translate('Favorites'),
value: LibraryTab.Favorites
},
{
index: 4,
label: globalize.translate('Collections'),
value: LibraryTab.Collections
},
{
index: 5,
label: globalize.translate('Genres'),
value: LibraryTab.Genres
}
]
},
{
path: '/music.html',
tabs: [
{
index: 0,
label: globalize.translate('Albums'),
value: LibraryTab.Albums,
isDefault: true
},
{
index: 1,
label: globalize.translate('Suggestions'),
value: LibraryTab.Suggestions
},
{
index: 2,
label: globalize.translate('HeaderAlbumArtists'),
value: LibraryTab.AlbumArtists
},
{
index: 3,
label: globalize.translate('Artists'),
value: LibraryTab.Artists
},
{
index: 4,
label: globalize.translate('Playlists'),
value: LibraryTab.Playlists
},
{
index: 5,
label: globalize.translate('Songs'),
value: LibraryTab.Songs
},
{
index: 6,
label: globalize.translate('Genres'),
value: LibraryTab.Genres
}
]
},
{
path: '/tv.html',
tabs: [
{
index: 0,
label: globalize.translate('Shows'),
value: LibraryTab.Shows,
isDefault: true
},
{
index: 1,
label: globalize.translate('Suggestions'),
value: LibraryTab.Suggestions
},
{
index: 2,
label: globalize.translate('TabUpcoming'),
value: LibraryTab.Upcoming
},
{
index: 3,
label: globalize.translate('Genres'),
value: LibraryTab.Genres
},
{
index: 4,
label: globalize.translate('TabNetworks'),
value: LibraryTab.Networks
},
{
index: 5,
label: globalize.translate('Episodes'),
value: LibraryTab.Episodes
}
]
}
];
export default TabRoutes;

View file

@ -0,0 +1,11 @@
import { AsyncRoute } from '../../../../components/router/AsyncRoute';
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'notificationsettings.html', page: 'dashboard/notifications' },
{ path: 'usernew.html', page: 'user/usernew' },
{ path: 'userprofiles.html', page: 'user/userprofiles' },
{ path: 'useredit.html', page: 'user/useredit' },
{ path: 'userlibraryaccess.html', page: 'user/userlibraryaccess' },
{ path: 'userparentalcontrol.html', page: 'user/userparentalcontrol' },
{ path: 'userpassword.html', page: 'user/userpassword' }
];

View file

@ -0,0 +1,2 @@
export * from './admin';
export * from './user';

View file

@ -0,0 +1,8 @@
import { AsyncRoute, AsyncRouteType } from '../../../../components/router/AsyncRoute';
export const ASYNC_USER_ROUTES: AsyncRoute[] = [
{ path: 'search.html', page: 'search' },
{ path: 'userprofile.html', page: 'user/userprofile' },
{ path: 'home.html', page: 'home', type: AsyncRouteType.Experimental },
{ path: 'movies.html', page: 'movies', type: AsyncRouteType.Experimental }
];

View file

@ -1,22 +1,20 @@
import React, { FunctionComponent, useCallback, useEffect, useMemo, useRef } from 'react';
import globalize from '../scripts/globalize';
import LibraryMenu from '../scripts/libraryMenu';
import { clearBackdrop } from '../components/backdrop/backdrop';
import layoutManager from '../components/layoutManager';
import * as mainTabsManager from '../components/maintabsmanager';
import '../elements/emby-tabs/emby-tabs';
import '../elements/emby-button/emby-button';
import '../elements/emby-scroller/emby-scroller';
import Page from '../components/Page';
import { useSearchParams } from 'react-router-dom';
type IProps = {
tab?: string;
}
import globalize from '../../../scripts/globalize';
import LibraryMenu from '../../../scripts/libraryMenu';
import { clearBackdrop } from '../../../components/backdrop/backdrop';
import layoutManager from '../../../components/layoutManager';
import * as mainTabsManager from '../../../components/maintabsmanager';
import '../../../elements/emby-tabs/emby-tabs';
import '../../../elements/emby-button/emby-button';
import '../../../elements/emby-scroller/emby-scroller';
import Page from '../../../components/Page';
type OnResumeOptions = {
autoFocus?: boolean;
refresh?: boolean
}
};
type ControllerProps = {
onResume: (
@ -25,17 +23,14 @@ type ControllerProps = {
refreshed: boolean;
onPause: () => void;
destroy: () => void;
}
};
const Home: FunctionComponent<IProps> = (props: IProps) => {
const getDefaultTabIndex = () => {
return 0;
};
const Home: FunctionComponent = () => {
const [ searchParams ] = useSearchParams();
const initialTabIndex = parseInt(searchParams.get('tab') || '0', 10);
const tabController = useRef<ControllerProps | null>();
const currentTabIndex = useRef(parseInt(props.tab || getDefaultTabIndex().toString()));
const tabControllers = useMemo<ControllerProps[]>(() => [], []);
const initialTabIndex = useRef<number | null>(currentTabIndex.current);
const element = useRef<HTMLDivElement>(null);
const setTitle = () => {
@ -70,18 +65,18 @@ const Home: FunctionComponent<IProps> = (props: IProps) => {
depends = 'favorites';
}
return import(/* webpackChunkName: "[request]" */ `../controllers/${depends}`).then(({ default: controllerFactory }) => {
return import(/* webpackChunkName: "[request]" */ `../../../controllers/${depends}`).then(({ default: controllerFactory }) => {
let controller = tabControllers[index];
if (!controller) {
const tabContent = element.current?.querySelector(".tabContent[data-index='" + index + "']");
controller = new controllerFactory(tabContent, props);
controller = new controllerFactory(tabContent, null);
tabControllers[index] = controller;
}
return controller;
});
}, [props, tabControllers]);
}, [ tabControllers ]);
const onViewDestroy = useCallback(() => {
if (tabControllers) {
@ -93,8 +88,7 @@ const Home: FunctionComponent<IProps> = (props: IProps) => {
}
tabController.current = null;
initialTabIndex.current = null;
}, [tabControllers]);
}, [ tabControllers ]);
const loadTab = useCallback((index: number, previousIndex: number | null) => {
getTabController(index).then((controller) => {
@ -106,22 +100,23 @@ const Home: FunctionComponent<IProps> = (props: IProps) => {
});
controller.refreshed = true;
currentTabIndex.current = index;
tabController.current = controller;
}).catch(err => {
console.error('[Home] failed to get tab controller', err);
});
}, [getTabController]);
}, [ getTabController ]);
const onTabChange = useCallback((e: { detail: { selectedTabIndex: string; previousIndex: number | null }; }) => {
const newIndex = parseInt(e.detail.selectedTabIndex);
const newIndex = parseInt(e.detail.selectedTabIndex, 10);
const previousIndex = e.detail.previousIndex;
const previousTabController = previousIndex == null ? null : tabControllers[previousIndex];
if (previousTabController && previousTabController.onPause) {
if (previousTabController?.onPause) {
previousTabController.onPause();
}
loadTab(newIndex, previousIndex);
}, [loadTab, tabControllers]);
}, [ loadTab, tabControllers ]);
const onResume = useCallback(() => {
setTitle();
@ -130,30 +125,29 @@ const Home: FunctionComponent<IProps> = (props: IProps) => {
const currentTabController = tabController.current;
if (!currentTabController) {
mainTabsManager.selectedTabIndex(initialTabIndex.current);
} else if (currentTabController && currentTabController.onResume) {
mainTabsManager.selectedTabIndex(initialTabIndex);
} else if (currentTabController?.onResume) {
currentTabController.onResume({});
}
(document.querySelector('.skinHeader') as HTMLDivElement).classList.add('noHomeButtonHeader');
}, []);
}, [ initialTabIndex ]);
const onPause = useCallback(() => {
const currentTabController = tabController.current;
if (currentTabController && currentTabController.onPause) {
if (currentTabController?.onPause) {
currentTabController.onPause();
}
(document.querySelector('.skinHeader') as HTMLDivElement).classList.remove('noHomeButtonHeader');
}, []);
useEffect(() => {
mainTabsManager.setTabs(element.current, currentTabIndex.current, getTabs, getTabContainers, null, onTabChange, false);
mainTabsManager.setTabs(element.current, initialTabIndex, getTabs, getTabContainers, null, onTabChange, false);
onResume();
return () => {
onPause();
onViewDestroy();
};
}, [onPause, onResume, onTabChange, onViewDestroy]);
}, [ initialTabIndex, onPause, onResume, onTabChange, onViewDestroy ]);
return (
<div ref={element}>

View file

@ -1,4 +1,4 @@
import { LegacyRoute } from '.';
import { LegacyRoute } from '../../../../components/router/LegacyRoute';
export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
{
@ -103,18 +103,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
controller: 'dashboard/metadatanfo',
view: 'dashboard/metadatanfo.html'
}
}, {
path: 'notificationsetting.html',
pageProps: {
controller: 'dashboard/notifications/notification/index',
view: 'dashboard/notifications/notification/index.html'
}
}, {
path: 'notificationsettings.html',
pageProps: {
controller: 'dashboard/notifications/notifications/index',
view: 'dashboard/notifications/notifications/index.html'
}
}, {
path: 'playbackconfiguration.html',
pageProps: {

View file

@ -0,0 +1,3 @@
export * from './admin';
export * from './public';
export * from './user';

View file

@ -0,0 +1,81 @@
import { LegacyRoute } from '../../../../components/router/LegacyRoute';
export const LEGACY_PUBLIC_ROUTES: LegacyRoute[] = [
{
path: 'addserver.html',
pageProps: {
controller: 'session/addServer/index',
view: 'session/addServer/index.html'
}
},
{
path: 'selectserver.html',
pageProps: {
controller: 'session/selectServer/index',
view: 'session/selectServer/index.html'
}
},
{
path: 'login.html',
pageProps: {
controller: 'session/login/index',
view: 'session/login/index.html'
}
},
{
path: 'forgotpassword.html',
pageProps: {
controller: 'session/forgotPassword/index',
view: 'session/forgotPassword/index.html'
}
},
{
path: 'forgotpasswordpin.html',
pageProps: {
controller: 'session/resetPassword/index',
view: 'session/resetPassword/index.html'
}
},
{
path: 'wizardremoteaccess.html',
pageProps: {
controller: 'wizard/remote/index',
view: 'wizard/remote/index.html'
}
},
{
path: 'wizardfinish.html',
pageProps: {
controller: 'wizard/finish/index',
view: 'wizard/finish/index.html'
}
},
{
path: 'wizardlibrary.html',
pageProps: {
controller: 'dashboard/library',
view: 'wizard/library.html'
}
},
{
path: 'wizardsettings.html',
pageProps: {
controller: 'wizard/settings/index',
view: 'wizard/settings/index.html'
}
},
{
path: 'wizardstart.html',
pageProps: {
controller: 'wizard/start/index',
view: 'wizard/start/index.html'
}
},
{
path: 'wizarduser.html',
pageProps: {
controller: 'wizard/user/index',
view: 'wizard/user/index.html'
}
}
];

View file

@ -1,4 +1,4 @@
import { LegacyRoute } from '.';
import { LegacyRoute } from '../../../../components/router/LegacyRoute';
export const LEGACY_USER_ROUTES: LegacyRoute[] = [
{

View file

@ -1,7 +1,7 @@
import React, { FC, useCallback } from 'react';
import ViewItemsContainer from '../../components/common/ViewItemsContainer';
import { LibraryViewProps } from '../../types/interface';
import ViewItemsContainer from '../../../../components/common/ViewItemsContainer';
import { LibraryViewProps } from '../../../../types/interface';
const CollectionsView: FC<LibraryViewProps> = ({ topParentId }) => {
const getBasekey = useCallback(() => {

View file

@ -1,7 +1,7 @@
import React, { FC, useCallback } from 'react';
import ViewItemsContainer from '../../components/common/ViewItemsContainer';
import { LibraryViewProps } from '../../types/interface';
import ViewItemsContainer from '../../../../components/common/ViewItemsContainer';
import { LibraryViewProps } from '../../../../types/interface';
const FavoritesView: FC<LibraryViewProps> = ({ topParentId }) => {
const getBasekey = useCallback(() => {

View file

@ -1,9 +1,9 @@
import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useCallback, useEffect, useState } from 'react';
import loading from '../../components/loading/loading';
import GenresItemsContainer from '../../components/common/GenresItemsContainer';
import { LibraryViewProps } from '../../types/interface';
import loading from '../../../../components/loading/loading';
import GenresItemsContainer from '../../../../components/common/GenresItemsContainer';
import { LibraryViewProps } from '../../../../types/interface';
const GenresView: FC<LibraryViewProps> = ({ topParentId }) => {
const [ itemsResult, setItemsResult ] = useState<BaseItemDtoQueryResult>({});
@ -23,6 +23,8 @@ const GenresView: FC<LibraryViewProps> = ({ topParentId }) => {
).then((result) => {
setItemsResult(result);
loading.hide();
}).catch(err => {
console.error('[GenresView] failed to fetch genres', err);
});
}, [topParentId]);

View file

@ -1,7 +1,7 @@
import React, { FC, useCallback } from 'react';
import ViewItemsContainer from '../../components/common/ViewItemsContainer';
import { LibraryViewProps } from '../../types/interface';
import ViewItemsContainer from '../../../../components/common/ViewItemsContainer';
import { LibraryViewProps } from '../../../../types/interface';
const MoviesView: FC<LibraryViewProps> = ({ topParentId }) => {
const getBasekey = useCallback(() => {

View file

@ -1,15 +1,15 @@
import type { BaseItemDto, BaseItemDtoQueryResult, RecommendationDto } from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import layoutManager from '../../components/layoutManager';
import loading from '../../components/loading/loading';
import dom from '../../scripts/dom';
import globalize from '../../scripts/globalize';
import RecommendationContainer from '../../components/common/RecommendationContainer';
import SectionContainer from '../../components/common/SectionContainer';
import { LibraryViewProps } from '../../types/interface';
import layoutManager from '../../../../components/layoutManager';
import loading from '../../../../components/loading/loading';
import dom from '../../../../scripts/dom';
import globalize from '../../../../scripts/globalize';
import RecommendationContainer from '../../../../components/common/RecommendationContainer';
import SectionContainer from '../../../../components/common/SectionContainer';
import { LibraryViewProps } from '../../../../types/interface';
const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => {
const SuggestionsView: FC<LibraryViewProps> = ({ topParentId }) => {
const [ latestItems, setLatestItems ] = useState<BaseItemDto[]>([]);
const [ resumeResult, setResumeResult ] = useState<BaseItemDtoQueryResult>({});
const [ recommendations, setRecommendations ] = useState<RecommendationDto[]>([]);
@ -28,8 +28,10 @@ const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => {
}, [enableScrollX]);
const autoFocus = useCallback((page) => {
import('../../components/autoFocuser').then(({default: autoFocuser}) => {
import('../../../../components/autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(page);
}).catch(err => {
console.error('[SuggestionsView] failed to load data', err);
});
}, []);
@ -55,6 +57,8 @@ const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => {
loading.hide();
autoFocus(page);
}).catch(err => {
console.error('[SuggestionsView] failed to fetch items', err);
});
}, [autoFocus]);
@ -72,6 +76,8 @@ const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => {
setLatestItems(items);
autoFocus(page);
}).catch(err => {
console.error('[SuggestionsView] failed to fetch latest items', err);
});
}, [autoFocus]);
@ -95,6 +101,8 @@ const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => {
setRecommendations(result);
autoFocus(page);
}).catch(err => {
console.error('[SuggestionsView] failed to fetch recommendations', err);
});
}, [autoFocus]);
@ -143,8 +151,8 @@ const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => {
{!recommendations.length ? <div className='noItemsMessage centerMessage'>
<h1>{globalize.translate('MessageNothingHere')}</h1>
<p>{globalize.translate('MessageNoMovieSuggestionsAvailable')}</p>
</div> : recommendations.map((recommendation, index) => {
return <RecommendationContainer key={index} getPortraitShape={getPortraitShape} enableScrollX={enableScrollX} recommendation={recommendation} />;
</div> : recommendations.map(recommendation => {
return <RecommendationContainer key={recommendation.CategoryId} getPortraitShape={getPortraitShape} enableScrollX={enableScrollX} recommendation={recommendation} />;
})}
</div>
);

View file

@ -1,8 +1,8 @@
import React, { FC, useCallback } from 'react';
import ViewItemsContainer from '../../components/common/ViewItemsContainer';
import { LibraryViewProps } from '../../types/interface';
import ViewItemsContainer from '../../../../components/common/ViewItemsContainer';
import { LibraryViewProps } from '../../../../types/interface';
const TrailersView: FC<LibraryViewProps> = ({ topParentId }) => {
const getBasekey = useCallback(() => {

View file

@ -1,62 +1,28 @@
import '../../elements/emby-scroller/emby-scroller';
import '../../elements/emby-itemscontainer/emby-itemscontainer';
import '../../elements/emby-tabs/emby-tabs';
import '../../elements/emby-button/emby-button';
import '../../../../elements/emby-scroller/emby-scroller';
import '../../../../elements/emby-itemscontainer/emby-itemscontainer';
import '../../../../elements/emby-tabs/emby-tabs';
import '../../../../elements/emby-button/emby-button';
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import React, { FC, useEffect, useRef } from 'react';
import { useLocation, useSearchParams } from 'react-router-dom';
import * as mainTabsManager from '../../components/maintabsmanager';
import Page from '../../components/Page';
import globalize from '../../scripts/globalize';
import libraryMenu from '../../scripts/libraryMenu';
import * as userSettings from '../../scripts/settings/userSettings';
import Page from '../../../../components/Page';
import globalize from '../../../../scripts/globalize';
import libraryMenu from '../../../../scripts/libraryMenu';
import CollectionsView from './CollectionsView';
import FavoritesView from './FavoritesView';
import GenresView from './GenresView';
import MoviesView from './MoviesView';
import SuggestionsView from './SuggestionsView';
import TrailersView from './TrailersView';
const getDefaultTabIndex = (folderId: string | null) => {
switch (userSettings.get('landing-' + folderId, false)) {
case 'suggestions':
return 1;
case 'favorites':
return 3;
case 'collections':
return 4;
case 'genres':
return 5;
default:
return 0;
}
};
const getTabs = () => {
return [{
name: globalize.translate('Movies')
}, {
name: globalize.translate('Suggestions')
}, {
name: globalize.translate('Trailers')
}, {
name: globalize.translate('Favorites')
}, {
name: globalize.translate('Collections')
}, {
name: globalize.translate('Genres')
}];
};
import { getDefaultTabIndex } from '../../components/tabs/tabRoutes';
const Movies: FC = () => {
const location = useLocation();
const [ searchParams ] = useSearchParams();
const currentTabIndex = parseInt(searchParams.get('tab') || getDefaultTabIndex(searchParams.get('topParentId')).toString());
const [ selectedIndex, setSelectedIndex ] = useState(currentTabIndex);
const searchParamsTab = searchParams.get('tab');
const currentTabIndex = searchParamsTab !== null ? parseInt(searchParamsTab, 10) :
getDefaultTabIndex(location.pathname, searchParams.get('topParentId'));
const element = useRef<HTMLDivElement>(null);
const getTabComponent = (index: number) => {
@ -94,11 +60,6 @@ const Movies: FC = () => {
return component;
};
const onTabChange = useCallback((e: { detail: { selectedTabIndex: string; }; }) => {
const newIndex = parseInt(e.detail.selectedTabIndex);
setSelectedIndex(newIndex);
}, []);
useEffect(() => {
const page = element.current;
@ -106,7 +67,7 @@ const Movies: FC = () => {
console.error('Unexpected null reference');
return;
}
mainTabsManager.setTabs(page, selectedIndex, getTabs, undefined, undefined, onTabChange);
if (!page.getAttribute('data-title')) {
const parentId = searchParams.get('topParentId');
@ -114,13 +75,17 @@ const Movies: FC = () => {
window.ApiClient.getItem(window.ApiClient.getCurrentUserId(), parentId).then((item) => {
page.setAttribute('data-title', item.Name as string);
libraryMenu.setTitle(item.Name);
}).catch(err => {
console.error('[movies] failed to fetch library', err);
page.setAttribute('data-title', globalize.translate('Movies'));
libraryMenu.setTitle(globalize.translate('Movies'));
});
} else {
page.setAttribute('data-title', globalize.translate('Movies'));
libraryMenu.setTitle(globalize.translate('Movies'));
}
}
}, [onTabChange, searchParams, selectedIndex]);
}, [ searchParams ]);
return (
<div ref={element}>
@ -129,7 +94,7 @@ const Movies: FC = () => {
className='mainAnimatedPage libraryPage backdropPage collectionEditorPage pageWithAbsoluteTabs withTabs'
backDropType='movie'
>
{getTabComponent(selectedIndex)}
{getTabComponent(currentTabIndex)}
</Page>
</div>

View file

@ -0,0 +1,53 @@
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
palette: {
mode: 'dark',
primary: {
main: '#00a4dc'
},
secondary: {
main: '#aa5cc3'
},
background: {
default: '#101010',
paper: '#202020'
},
action: {
selectedOpacity: 0.2
}
},
typography: {
fontFamily: '"Noto Sans", sans-serif',
button: {
textTransform: 'none'
}
},
components: {
MuiButton: {
defaultProps: {
variant: 'contained'
}
},
MuiFormControl: {
defaultProps: {
variant: 'filled'
}
},
MuiTextField: {
defaultProps: {
variant: 'filled'
}
},
MuiListSubheader: {
styleOverrides: {
root: {
// NOTE: Added for drawer subheaders, but maybe it won't work in other cases?
backgroundColor: 'inherit'
}
}
}
}
});
export default theme;

58
src/apps/stable/App.tsx Normal file
View file

@ -0,0 +1,58 @@
import React from 'react';
import { Navigate, Outlet, Route, Routes } from 'react-router-dom';
import AppHeader from 'components/AppHeader';
import Backdrop from 'components/Backdrop';
import ServerContentPage from 'components/ServerContentPage';
import ConnectionRequired from 'components/ConnectionRequired';
import { toAsyncPageRoute } from 'components/router/AsyncRoute';
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
import { ASYNC_ADMIN_ROUTES, ASYNC_USER_ROUTES } from './routes/asyncRoutes';
import { LEGACY_ADMIN_ROUTES, LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './routes/legacyRoutes';
const Layout = () => (
<>
<Backdrop />
<AppHeader />
<div className='mainAnimatedPages skinBody' />
<div className='skinBody'>
<Outlet />
</div>
</>
);
const StableApp = () => (
<Routes>
<Route element={<Layout />}>
{/* User routes */}
<Route path='/' element={<ConnectionRequired />}>
{ASYNC_USER_ROUTES.map(toAsyncPageRoute)}
{LEGACY_USER_ROUTES.map(toViewManagerPageRoute)}
</Route>
{/* Admin routes */}
<Route path='/' element={<ConnectionRequired isAdminRequired />}>
{ASYNC_ADMIN_ROUTES.map(toAsyncPageRoute)}
{LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute)}
<Route path='configurationpage' element={
<ServerContentPage view='/web/configurationpage' />
} />
</Route>
{/* Public routes */}
<Route path='/' element={<ConnectionRequired isUserRequired={false} />}>
<Route index element={<Navigate replace to='/home.html' />} />
{LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute)}
</Route>
{/* Suppress warnings for unhandled routes */}
<Route path='*' element={null} />
</Route>
</Routes>
);
export default StableApp;

View file

@ -0,0 +1,11 @@
import { AsyncRoute } from '../../../../components/router/AsyncRoute';
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'notificationsettings.html', page: 'dashboard/notifications' },
{ path: 'usernew.html', page: 'user/usernew' },
{ path: 'userprofiles.html', page: 'user/userprofiles' },
{ path: 'useredit.html', page: 'user/useredit' },
{ path: 'userlibraryaccess.html', page: 'user/userlibraryaccess' },
{ path: 'userparentalcontrol.html', page: 'user/userparentalcontrol' },
{ path: 'userpassword.html', page: 'user/userpassword' }
];

View file

@ -0,0 +1,2 @@
export * from './admin';
export * from './user';

View file

@ -0,0 +1,6 @@
import { AsyncRoute } from '../../../../components/router/AsyncRoute';
export const ASYNC_USER_ROUTES: AsyncRoute[] = [
{ path: 'search.html', page: 'search' },
{ path: 'userprofile.html', page: 'user/userprofile' }
];

View file

@ -0,0 +1,36 @@
import React from 'react';
import Page from 'components/Page';
import globalize from 'scripts/globalize';
const PluginLink = () => (
<div
dangerouslySetInnerHTML={{
__html: `<a
is='emby-linkbutton'
class='button-link'
href='#/addplugin.html?name=Webhook&guid=71552a5a5c5c4350a2aeebe451a30173'
>
${globalize.translate('GetThePlugin')}
</a>`
}}
/>
);
const Notifications = () => (
<Page
id='notificationSettingPage'
title={globalize.translate('Notifications')}
className='mainAnimatedPage type-interior'
>
<div className='content-primary'>
<h2>{globalize.translate('Notifications')}</h2>
<p>
{globalize.translate('NotificationsMovedMessage')}
</p>
<PluginLink />
</div>
</Page>
);
export default Notifications;

View file

@ -0,0 +1,185 @@
import { LegacyRoute } from '../../../../components/router/LegacyRoute';
export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
{
path: 'dashboard.html',
pageProps: {
controller: 'dashboard/dashboard',
view: 'dashboard/dashboard.html'
}
}, {
path: 'dashboardgeneral.html',
pageProps: {
controller: 'dashboard/general',
view: 'dashboard/general.html'
}
}, {
path: 'networking.html',
pageProps: {
controller: 'dashboard/networking',
view: 'dashboard/networking.html'
}
}, {
path: 'devices.html',
pageProps: {
controller: 'dashboard/devices/devices',
view: 'dashboard/devices/devices.html'
}
}, {
path: 'device.html',
pageProps: {
controller: 'dashboard/devices/device',
view: 'dashboard/devices/device.html'
}
}, {
path: 'quickConnect.html',
pageProps: {
controller: 'dashboard/quickConnect',
view: 'dashboard/quickConnect.html'
}
}, {
path: 'dlnaprofile.html',
pageProps: {
controller: 'dashboard/dlna/profile',
view: 'dashboard/dlna/profile.html'
}
}, {
path: 'dlnaprofiles.html',
pageProps: {
controller: 'dashboard/dlna/profiles',
view: 'dashboard/dlna/profiles.html'
}
}, {
path: 'dlnasettings.html',
pageProps: {
controller: 'dashboard/dlna/settings',
view: 'dashboard/dlna/settings.html'
}
}, {
path: 'addplugin.html',
pageProps: {
controller: 'dashboard/plugins/add/index',
view: 'dashboard/plugins/add/index.html'
}
}, {
path: 'library.html',
pageProps: {
controller: 'dashboard/library',
view: 'dashboard/library.html'
}
}, {
path: 'librarydisplay.html',
pageProps: {
controller: 'dashboard/librarydisplay',
view: 'dashboard/librarydisplay.html'
}
}, {
path: 'edititemmetadata.html',
pageProps: {
controller: 'edititemmetadata',
view: 'edititemmetadata.html'
}
}, {
path: 'encodingsettings.html',
pageProps: {
controller: 'dashboard/encodingsettings',
view: 'dashboard/encodingsettings.html'
}
}, {
path: 'log.html',
pageProps: {
controller: 'dashboard/logs',
view: 'dashboard/logs.html'
}
}, {
path: 'metadataimages.html',
pageProps: {
controller: 'dashboard/metadataImages',
view: 'dashboard/metadataimages.html'
}
}, {
path: 'metadatanfo.html',
pageProps: {
controller: 'dashboard/metadatanfo',
view: 'dashboard/metadatanfo.html'
}
}, {
path: 'playbackconfiguration.html',
pageProps: {
controller: 'dashboard/playback',
view: 'dashboard/playback.html'
}
}, {
path: 'availableplugins.html',
pageProps: {
controller: 'dashboard/plugins/available/index',
view: 'dashboard/plugins/available/index.html'
}
}, {
path: 'repositories.html',
pageProps: {
controller: 'dashboard/plugins/repositories/index',
view: 'dashboard/plugins/repositories/index.html'
}
}, {
path: 'livetvguideprovider.html',
pageProps: {
controller: 'livetvguideprovider',
view: 'livetvguideprovider.html'
}
}, {
path: 'livetvsettings.html',
pageProps: {
controller: 'livetvsettings',
view: 'livetvsettings.html'
}
}, {
path: 'livetvstatus.html',
pageProps: {
controller: 'livetvstatus',
view: 'livetvstatus.html'
}
}, {
path: 'livetvtuner.html',
pageProps: {
controller: 'livetvtuner',
view: 'livetvtuner.html'
}
}, {
path: 'installedplugins.html',
pageProps: {
controller: 'dashboard/plugins/installed/index',
view: 'dashboard/plugins/installed/index.html'
}
}, {
path: 'scheduledtask.html',
pageProps: {
controller: 'dashboard/scheduledtasks/scheduledtask',
view: 'dashboard/scheduledtasks/scheduledtask.html'
}
}, {
path: 'scheduledtasks.html',
pageProps: {
controller: 'dashboard/scheduledtasks/scheduledtasks',
view: 'dashboard/scheduledtasks/scheduledtasks.html'
}
}, {
path: 'serveractivity.html',
pageProps: {
controller: 'dashboard/serveractivity',
view: 'dashboard/serveractivity.html'
}
}, {
path: 'apikeys.html',
pageProps: {
controller: 'dashboard/apikeys',
view: 'dashboard/apikeys.html'
}
}, {
path: 'streamingsettings.html',
pageProps: {
view: 'dashboard/streaming.html',
controller: 'dashboard/streaming'
}
}
];

View file

@ -0,0 +1,3 @@
export * from './admin';
export * from './public';
export * from './user';

View file

@ -0,0 +1,81 @@
import { LegacyRoute } from '../../../../components/router/LegacyRoute';
export const LEGACY_PUBLIC_ROUTES: LegacyRoute[] = [
{
path: 'addserver.html',
pageProps: {
controller: 'session/addServer/index',
view: 'session/addServer/index.html'
}
},
{
path: 'selectserver.html',
pageProps: {
controller: 'session/selectServer/index',
view: 'session/selectServer/index.html'
}
},
{
path: 'login.html',
pageProps: {
controller: 'session/login/index',
view: 'session/login/index.html'
}
},
{
path: 'forgotpassword.html',
pageProps: {
controller: 'session/forgotPassword/index',
view: 'session/forgotPassword/index.html'
}
},
{
path: 'forgotpasswordpin.html',
pageProps: {
controller: 'session/resetPassword/index',
view: 'session/resetPassword/index.html'
}
},
{
path: 'wizardremoteaccess.html',
pageProps: {
controller: 'wizard/remote/index',
view: 'wizard/remote/index.html'
}
},
{
path: 'wizardfinish.html',
pageProps: {
controller: 'wizard/finish/index',
view: 'wizard/finish/index.html'
}
},
{
path: 'wizardlibrary.html',
pageProps: {
controller: 'dashboard/library',
view: 'wizard/library.html'
}
},
{
path: 'wizardsettings.html',
pageProps: {
controller: 'wizard/settings/index',
view: 'wizard/settings/index.html'
}
},
{
path: 'wizardstart.html',
pageProps: {
controller: 'wizard/start/index',
view: 'wizard/start/index.html'
}
},
{
path: 'wizarduser.html',
pageProps: {
controller: 'wizard/user/index',
view: 'wizard/user/index.html'
}
}
];

View file

@ -0,0 +1,108 @@
import { LegacyRoute } from '../../../../components/router/LegacyRoute';
export const LEGACY_USER_ROUTES: LegacyRoute[] = [
{
path: 'details',
pageProps: {
controller: 'itemDetails/index',
view: 'itemDetails/index.html'
}
}, {
path: 'list.html',
pageProps: {
controller: 'list',
view: 'list.html'
}
}, {
path: 'livetv.html',
pageProps: {
controller: 'livetv/livetvsuggested',
view: 'livetv.html'
}
}, {
path: 'music.html',
pageProps: {
controller: 'music/musicrecommended',
view: 'music/music.html'
}
}, {
path: 'mypreferencesmenu.html',
pageProps: {
controller: 'user/menu/index',
view: 'user/menu/index.html'
}
}, {
path: 'mypreferencescontrols.html',
pageProps: {
controller: 'user/controls/index',
view: 'user/controls/index.html'
}
}, {
path: 'mypreferencesdisplay.html',
pageProps: {
controller: 'user/display/index',
view: 'user/display/index.html'
}
}, {
path: 'mypreferenceshome.html',
pageProps: {
controller: 'user/home/index',
view: 'user/home/index.html'
}
}, {
path: 'mypreferencesquickconnect.html',
pageProps: {
controller: 'user/quickConnect/index',
view: 'user/quickConnect/index.html'
}
}, {
path: 'mypreferencesplayback.html',
pageProps: {
controller: 'user/playback/index',
view: 'user/playback/index.html'
}
}, {
path: 'mypreferencessubtitles.html',
pageProps: {
controller: 'user/subtitles/index',
view: 'user/subtitles/index.html'
}
}, {
path: 'tv.html',
pageProps: {
controller: 'shows/tvrecommended',
view: 'shows/tvrecommended.html'
}
}, {
path: 'video',
pageProps: {
controller: 'playback/video/index',
view: 'playback/video/index.html',
type: 'video-osd',
isFullscreen: true,
isNowPlayingBarEnabled: false,
isThemeMediaSupported: true
}
}, {
path: 'queue',
pageProps: {
controller: 'playback/queue/index',
view: 'playback/queue/index.html',
isFullscreen: true,
isNowPlayingBarEnabled: false,
isThemeMediaSupported: true
}
}, {
path: 'home.html',
pageProps: {
controller: 'home',
view: 'home.html'
}
}, {
path: 'movies.html',
pageProps: {
controller: 'movies/moviesrecommended',
view: 'movies/movies.html'
}
}
];

View file

@ -1,12 +1,12 @@
import React, { FunctionComponent, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import Page from '../components/Page';
import SearchFields from '../components/search/SearchFields';
import SearchResults from '../components/search/SearchResults';
import SearchSuggestions from '../components/search/SearchSuggestions';
import LiveTVSearchResults from '../components/search/LiveTVSearchResults';
import globalize from '../scripts/globalize';
import Page from '../../../components/Page';
import SearchFields from '../../../components/search/SearchFields';
import SearchResults from '../../../components/search/SearchResults';
import SearchSuggestions from '../../../components/search/SearchSuggestions';
import LiveTVSearchResults from '../../../components/search/LiveTVSearchResults';
import globalize from '../../../scripts/globalize';
const Search: FunctionComponent = () => {
const [ query, setQuery ] = useState<string>();
@ -19,9 +19,8 @@ const Search: FunctionComponent = () => {
className='mainAnimatedPage libraryPage allLibraryPage noSecondaryNavPage'
>
<SearchFields onSearch={setQuery} />
{!query &&
<SearchSuggestions
serverId={searchParams.get('serverId') || window.ApiClient.serverId()}
{!query
&& <SearchSuggestions
parentId={searchParams.get('parentId')}
/>
}

View file

@ -1,28 +1,43 @@
import type { SyncPlayUserAccessType, UserDto } from '@jellyfin/sdk/lib/generated-client';
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
import Dashboard from '../../utils/dashboard';
import globalize from '../../scripts/globalize';
import LibraryMenu from '../../scripts/libraryMenu';
import ButtonElement from '../../elements/ButtonElement';
import CheckBoxElement from '../../elements/CheckBoxElement';
import InputElement from '../../elements/InputElement';
import LinkEditUserPreferences from '../../components/dashboard/users/LinkEditUserPreferences';
import SectionTitleContainer from '../../elements/SectionTitleContainer';
import SectionTabs from '../../components/dashboard/users/SectionTabs';
import loading from '../../components/loading/loading';
import toast from '../../components/toast/toast';
import { getParameterByName } from '../../utils/url';
import escapeHTML from 'escape-html';
import SelectElement from '../../elements/SelectElement';
import Page from '../../components/Page';
import Dashboard from '../../../../utils/dashboard';
import globalize from '../../../../scripts/globalize';
import LibraryMenu from '../../../../scripts/libraryMenu';
import ButtonElement from '../../../../elements/ButtonElement';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
import InputElement from '../../../../elements/InputElement';
import LinkEditUserPreferences from '../../../../components/dashboard/users/LinkEditUserPreferences';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import { getParameterByName } from '../../../../utils/url';
import SelectElement from '../../../../elements/SelectElement';
import Page from '../../../../components/Page';
type ResetProvider = AuthProvider & {
checkedAttribute: string
}
};
type AuthProvider = {
Name?: string;
Id?: string;
};
const getCheckedElementDataIds = (elements: NodeListOf<Element>) => (
Array.prototype.filter.call(elements, e => e.checked)
.map(e => e.getAttribute('data-id'))
);
function onSaveComplete() {
Dashboard.navigate('userprofiles.html')
.catch(err => {
console.error('[useredit] failed to navigate to user profile', err);
});
loading.hide();
toast(globalize.translate('SettingsSaved'));
}
const UserEdit: FunctionComponent = () => {
@ -56,7 +71,7 @@ const UserEdit: FunctionComponent = () => {
}
const fldSelectLoginProvider = page.querySelector('.fldSelectLoginProvider') as HTMLDivElement;
providers.length > 1 ? fldSelectLoginProvider.classList.remove('hide') : fldSelectLoginProvider.classList.add('hide');
fldSelectLoginProvider.classList.toggle('hide', providers.length <= 1);
setAuthProviders(providers);
@ -73,7 +88,7 @@ const UserEdit: FunctionComponent = () => {
}
const fldSelectPasswordResetProvider = page.querySelector('.fldSelectPasswordResetProvider') as HTMLDivElement;
providers.length > 1 ? fldSelectPasswordResetProvider.classList.remove('hide') : fldSelectPasswordResetProvider.classList.add('hide');
fldSelectPasswordResetProvider.classList.toggle('hide', providers.length <= 1);
setPasswordResetProviders(providers);
@ -121,6 +136,8 @@ const UserEdit: FunctionComponent = () => {
const chkEnableDeleteAllFolders = page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement;
chkEnableDeleteAllFolders.checked = user.Policy.EnableContentDeletion;
triggerChange(chkEnableDeleteAllFolders);
}).catch(err => {
console.error('[useredit] failed to fetch channels', err);
});
}, []);
@ -134,18 +151,24 @@ const UserEdit: FunctionComponent = () => {
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/Providers')).then(function (providers) {
loadAuthProviders(user, providers);
}).catch(err => {
console.error('[useredit] failed to fetch auth providers', err);
});
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/PasswordResetProviders')).then(function (providers) {
loadPasswordResetProviders(user, providers);
}).catch(err => {
console.error('[useredit] failed to fetch password reset providers', err);
});
window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', {
IsHidden: false
})).then(function (folders) {
loadDeleteFolders(user, folders.Items);
}).catch(err => {
console.error('[useredit] failed to fetch media folders', err);
});
const disabledUserBanner = page.querySelector('.disabledUserBanner') as HTMLDivElement;
user.Policy.IsDisabled ? disabledUserBanner.classList.remove('hide') : disabledUserBanner.classList.add('hide');
disabledUserBanner.classList.toggle('hide', !user.Policy.IsDisabled);
const txtUserName = page.querySelector('#txtUserName') as HTMLInputElement;
txtUserName.disabled = false;
@ -159,6 +182,7 @@ const UserEdit: FunctionComponent = () => {
(page.querySelector('.chkIsAdmin') as HTMLInputElement).checked = user.Policy.IsAdministrator;
(page.querySelector('.chkDisabled') as HTMLInputElement).checked = user.Policy.IsDisabled;
(page.querySelector('.chkIsHidden') as HTMLInputElement).checked = user.Policy.IsHidden;
(page.querySelector('.chkEnableCollectionManagement') as HTMLInputElement).checked = user.Policy.EnableCollectionManagement;
(page.querySelector('.chkRemoteControlSharedDevices') as HTMLInputElement).checked = user.Policy.EnableSharedDeviceControl;
(page.querySelector('.chkEnableRemoteControlOtherUsers') as HTMLInputElement).checked = user.Policy.EnableRemoteControlOfOtherUsers;
(page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked = user.Policy.EnableContentDownloading;
@ -171,7 +195,7 @@ const UserEdit: FunctionComponent = () => {
(page.querySelector('.chkForceRemoteSourceTranscoding') as HTMLInputElement).checked = user.Policy.ForceRemoteSourceTranscoding;
(page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked = user.Policy.EnableRemoteAccess == null || user.Policy.EnableRemoteAccess;
(page.querySelector('#txtRemoteClientBitrateLimit') as HTMLInputElement).value = user.Policy.RemoteClientBitrateLimit > 0 ?
(user.Policy.RemoteClientBitrateLimit / 1e6).toLocaleString(undefined, {maximumFractionDigits: 6}) : '';
(user.Policy.RemoteClientBitrateLimit / 1e6).toLocaleString(undefined, { maximumFractionDigits: 6 }) : '';
(page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value = user.Policy.LoginAttemptsBeforeLockout || '0';
(page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value = user.Policy.MaxActiveSessions || '0';
if (window.ApiClient.isMinServerVersion('10.6.0')) {
@ -184,6 +208,8 @@ const UserEdit: FunctionComponent = () => {
loading.show();
getUser().then(function (user) {
loadUser(user);
}).catch(err => {
console.error('[useredit] failed to load data', err);
});
}, [loadUser]);
@ -197,19 +223,9 @@ const UserEdit: FunctionComponent = () => {
loadData();
function onSaveComplete() {
Dashboard.navigate('userprofiles.html');
loading.hide();
toast(globalize.translate('SettingsSaved'));
}
const saveUser = (user: UserDto) => {
if (!user.Id) {
throw new Error('Unexpected null user.Id');
}
if (!user.Policy) {
throw new Error('Unexpected null user.Policy');
if (!user.Id || !user.Policy) {
throw new Error('Unexpected null user id or policy');
}
user.Name = (page.querySelector('#txtUserName') as HTMLInputElement).value;
@ -224,27 +240,25 @@ const UserEdit: FunctionComponent = () => {
user.Policy.EnableAudioPlaybackTranscoding = (page.querySelector('.chkEnableAudioPlaybackTranscoding') as HTMLInputElement).checked;
user.Policy.EnableVideoPlaybackTranscoding = (page.querySelector('.chkEnableVideoPlaybackTranscoding') as HTMLInputElement).checked;
user.Policy.EnablePlaybackRemuxing = (page.querySelector('.chkEnableVideoPlaybackRemuxing') as HTMLInputElement).checked;
user.Policy.EnableCollectionManagement = (page.querySelector('.chkEnableCollectionManagement') as HTMLInputElement).checked;
user.Policy.ForceRemoteSourceTranscoding = (page.querySelector('.chkForceRemoteSourceTranscoding') as HTMLInputElement).checked;
user.Policy.EnableContentDownloading = (page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked;
user.Policy.EnableRemoteAccess = (page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked;
user.Policy.RemoteClientBitrateLimit = Math.floor(1e6 * parseFloat((page.querySelector('#txtRemoteClientBitrateLimit') as HTMLInputElement).value || '0'));
user.Policy.LoginAttemptsBeforeLockout = parseInt((page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value || '0');
user.Policy.MaxActiveSessions = parseInt((page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value || '0');
user.Policy.LoginAttemptsBeforeLockout = parseInt((page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value || '0', 10);
user.Policy.MaxActiveSessions = parseInt((page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value || '0', 10);
user.Policy.AuthenticationProviderId = (page.querySelector('#selectLoginProvider') as HTMLSelectElement).value;
user.Policy.PasswordResetProviderId = (page.querySelector('#selectPasswordResetProvider') as HTMLSelectElement).value;
user.Policy.EnableContentDeletion = (page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).checked;
user.Policy.EnableContentDeletionFromFolders = user.Policy.EnableContentDeletion ? [] : Array.prototype.filter.call(page.querySelectorAll('.chkFolder'), function (c) {
return c.checked;
}).map(function (c) {
return c.getAttribute('data-id');
});
if (window.ApiClient.isMinServerVersion('10.6.0')) {
user.Policy.SyncPlayAccess = (page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value as SyncPlayUserAccessType;
}
window.ApiClient.updateUser(user).then(function () {
window.ApiClient.updateUserPolicy(user.Id || '', user.Policy || {}).then(function () {
onSaveComplete();
});
user.Policy.EnableContentDeletionFromFolders = user.Policy.EnableContentDeletion ? [] : getCheckedElementDataIds(page.querySelectorAll('.chkFolder'));
user.Policy.SyncPlayAccess = (page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value as SyncPlayUserAccessType;
window.ApiClient.updateUser(user).then(() => (
window.ApiClient.updateUserPolicy(user.Id || '', user.Policy || {})
)).then(() => {
onSaveComplete();
}).catch(err => {
console.error('[useredit] failed to update user', err);
});
};
@ -252,6 +266,8 @@ const UserEdit: FunctionComponent = () => {
loading.show();
getUser().then(function (result) {
saveUser(result);
}).catch(err => {
console.error('[useredit] failed to fetch user', err);
});
e.preventDefault();
e.stopPropagation();
@ -259,16 +275,13 @@ const UserEdit: FunctionComponent = () => {
};
(page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
if (this.checked) {
(page.querySelector('.deleteAccess') as HTMLDivElement).classList.add('hide');
} else {
(page.querySelector('.deleteAccess') as HTMLDivElement).classList.remove('hide');
}
(page.querySelector('.deleteAccess') as HTMLDivElement).classList.toggle('hide', this.checked);
});
window.ApiClient.getNamedConfiguration('network').then(function (config) {
const fldRemoteAccess = page.querySelector('.fldRemoteAccess') as HTMLDivElement;
config.EnableRemoteAccess ? fldRemoteAccess.classList.remove('hide') : fldRemoteAccess.classList.add('hide');
(page.querySelector('.fldRemoteAccess') as HTMLDivElement).classList.toggle('hide', !config.EnableRemoteAccess);
}).catch(err => {
console.error('[useredit] failed to load network config', err);
});
(page.querySelector('.editUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit);
@ -312,7 +325,7 @@ const UserEdit: FunctionComponent = () => {
<SectionTabs activeTab='useredit'/>
<div
className='lnkEditUserPreferencesContainer'
style={{paddingBottom: '1em'}}
style={{ paddingBottom: '1em' }}
>
<LinkEditUserPreferences
className= 'lnkEditUserPreferences button-link'
@ -325,7 +338,7 @@ const UserEdit: FunctionComponent = () => {
<div>
{globalize.translate('HeaderThisUserIsCurrentlyDisabled')}
</div>
<div style={{marginTop: 5}}>
<div style={{ marginTop: 5 }}>
{globalize.translate('MessageReenableUser')}
</div>
</div>
@ -375,11 +388,16 @@ const UserEdit: FunctionComponent = () => {
className='chkIsAdmin'
title='OptionAllowUserToManageServer'
/>
<CheckBoxElement
labelClassName='checkboxContainer'
className='chkEnableCollectionManagement'
title='AllowCollectionManagement'
/>
<div id='featureAccessFields' className='verticalSection'>
<h2 className='paperListLabel'>
{globalize.translate('HeaderFeatureAccess')}
</h2>
<div className='checkboxList paperList' style={{padding: '.5em 1em'}}>
<div className='checkboxList paperList' style={{ padding: '.5em 1em' }}>
<CheckBoxElement
className='chkEnableLiveTvAccess'
title='OptionAllowBrowsingLiveTv'
@ -394,7 +412,7 @@ const UserEdit: FunctionComponent = () => {
<h2 className='paperListLabel'>
{globalize.translate('HeaderPlayback')}
</h2>
<div className='checkboxList paperList' style={{padding: '.5em 1em'}}>
<div className='checkboxList paperList' style={{ padding: '.5em 1em' }}>
<CheckBoxElement
className='chkEnableMediaPlayback'
title='OptionAllowMediaPlayback'
@ -451,7 +469,7 @@ const UserEdit: FunctionComponent = () => {
</div>
</div>
<div className='verticalSection'>
<h2 className='checkboxListLabel' style={{marginBottom: '1em'}}>
<h2 className='checkboxListLabel' style={{ marginBottom: '1em' }}>
{globalize.translate('HeaderAllowMediaDeletionFrom')}
</h2>
<div className='checkboxList paperList checkboxList-paperList'>
@ -477,7 +495,7 @@ const UserEdit: FunctionComponent = () => {
<h2 className='checkboxListLabel'>
{globalize.translate('HeaderRemoteControl')}
</h2>
<div className='checkboxList paperList' style={{padding: '.5em 1em'}}>
<div className='checkboxList paperList' style={{ padding: '.5em 1em' }}>
<CheckBoxElement
className='chkEnableRemoteControlOtherUsers'
title='OptionAllowRemoteControlOthers'

View file

@ -1,24 +1,24 @@
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
import loading from '../../components/loading/loading';
import libraryMenu from '../../scripts/libraryMenu';
import globalize from '../../scripts/globalize';
import toast from '../../components/toast/toast';
import SectionTabs from '../../components/dashboard/users/SectionTabs';
import ButtonElement from '../../elements/ButtonElement';
import { getParameterByName } from '../../utils/url';
import SectionTitleContainer from '../../elements/SectionTitleContainer';
import AccessContainer from '../../components/dashboard/users/AccessContainer';
import CheckBoxElement from '../../elements/CheckBoxElement';
import Page from '../../components/Page';
import loading from '../../../../components/loading/loading';
import libraryMenu from '../../../../scripts/libraryMenu';
import globalize from '../../../../scripts/globalize';
import toast from '../../../../components/toast/toast';
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
import ButtonElement from '../../../../elements/ButtonElement';
import { getParameterByName } from '../../../../utils/url';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import AccessContainer from '../../../../components/dashboard/users/AccessContainer';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
import Page from '../../../../components/Page';
type ItemsArr = {
Name?: string;
Id?: string;
AppName?: string;
checkedAttribute?: string
}
};
const UserLibraryAccess: FunctionComponent = () => {
const [ userName, setUserName ] = useState('');
@ -148,6 +148,8 @@ const UserLibraryAccess: FunctionComponent = () => {
const promise4 = window.ApiClient.getJSON(window.ApiClient.getUrl('Devices'));
Promise.all([promise1, promise2, promise3, promise4]).then(function (responses) {
loadUser(responses[0], responses[1].Items, responses[2].Items, responses[3].Items);
}).catch(err => {
console.error('[userlibraryaccess] failed to load data', err);
});
}, [loadUser]);
@ -166,6 +168,8 @@ const UserLibraryAccess: FunctionComponent = () => {
const userId = getParameterByName('userId');
window.ApiClient.getUser(userId).then(function (result) {
saveUser(result);
}).catch(err => {
console.error('[userlibraryaccess] failed to fetch user', err);
});
e.preventDefault();
e.stopPropagation();
@ -203,6 +207,8 @@ const UserLibraryAccess: FunctionComponent = () => {
user.Policy.BlockedMediaFolders = null;
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
onSaveComplete();
}).catch(err => {
console.error('[userlibraryaccess] failed to update user policy', err);
});
};

View file

@ -1,25 +1,25 @@
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
import Dashboard from '../../utils/dashboard';
import globalize from '../../scripts/globalize';
import loading from '../../components/loading/loading';
import toast from '../../components/toast/toast';
import SectionTitleContainer from '../../elements/SectionTitleContainer';
import InputElement from '../../elements/InputElement';
import ButtonElement from '../../elements/ButtonElement';
import AccessContainer from '../../components/dashboard/users/AccessContainer';
import CheckBoxElement from '../../elements/CheckBoxElement';
import Page from '../../components/Page';
import Dashboard from '../../../../utils/dashboard';
import globalize from '../../../../scripts/globalize';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import InputElement from '../../../../elements/InputElement';
import ButtonElement from '../../../../elements/ButtonElement';
import AccessContainer from '../../../../components/dashboard/users/AccessContainer';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
import Page from '../../../../components/Page';
type userInput = {
Name?: string;
Password?: string;
}
};
type ItemsArr = {
Name?: string;
Id?: string;
}
};
const UserNew: FunctionComponent = () => {
const [ channelsItems, setChannelsItems ] = useState<ItemsArr[]>([]);
@ -93,6 +93,8 @@ const UserNew: FunctionComponent = () => {
loadMediaFolders(responses[0].Items);
loadChannels(responses[1].Items);
loading.hide();
}).catch(err => {
console.error('[usernew] failed to load data', err);
});
}, [loadChannels, loadMediaFolders]);
@ -111,12 +113,8 @@ const UserNew: FunctionComponent = () => {
userInput.Name = (page.querySelector('#txtUsername') as HTMLInputElement).value;
userInput.Password = (page.querySelector('#txtPassword') as HTMLInputElement).value;
window.ApiClient.createUser(userInput).then(function (user) {
if (!user.Id) {
throw new Error('Unexpected null user.Id');
}
if (!user.Policy) {
throw new Error('Unexpected null user.Policy');
if (!user.Id || !user.Policy) {
throw new Error('Unexpected null user id or policy');
}
user.Policy.EnableAllFolders = (page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked;
@ -142,7 +140,12 @@ const UserNew: FunctionComponent = () => {
}
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
Dashboard.navigate('useredit.html?userId=' + user.Id);
Dashboard.navigate('useredit.html?userId=' + user.Id)
.catch(err => {
console.error('[usernew] failed to navigate to edit user page', err);
});
}).catch(err => {
console.error('[usernew] failed to update user policy', err);
});
}, function () {
toast(globalize.translate('ErrorDefault'));

View file

@ -1,26 +1,27 @@
import type { AccessSchedule, ParentalRating, UserDto } from '@jellyfin/sdk/lib/generated-client';
import { DynamicDayOfWeek } from '@jellyfin/sdk/lib/generated-client/models/dynamic-day-of-week';
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
import globalize from '../../scripts/globalize';
import LibraryMenu from '../../scripts/libraryMenu';
import AccessScheduleList from '../../components/dashboard/users/AccessScheduleList';
import BlockedTagList from '../../components/dashboard/users/BlockedTagList';
import ButtonElement from '../../elements/ButtonElement';
import SectionTitleContainer from '../../elements/SectionTitleContainer';
import SectionTabs from '../../components/dashboard/users/SectionTabs';
import loading from '../../components/loading/loading';
import toast from '../../components/toast/toast';
import { getParameterByName } from '../../utils/url';
import CheckBoxElement from '../../elements/CheckBoxElement';
import escapeHTML from 'escape-html';
import SelectElement from '../../elements/SelectElement';
import Page from '../../components/Page';
import globalize from '../../../../scripts/globalize';
import LibraryMenu from '../../../../scripts/libraryMenu';
import AccessScheduleList from '../../../../components/dashboard/users/AccessScheduleList';
import BlockedTagList from '../../../../components/dashboard/users/BlockedTagList';
import ButtonElement from '../../../../elements/ButtonElement';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import { getParameterByName } from '../../../../utils/url';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
import SelectElement from '../../../../elements/SelectElement';
import Page from '../../../../components/Page';
type UnratedItem = {
name: string;
value: string;
checkedAttribute: string
}
};
const UserParentalControl: FunctionComponent = () => {
const [ userName, setUserName ] = useState('');
@ -142,7 +143,7 @@ const UserParentalControl: FunctionComponent = () => {
for (const btnDelete of accessScheduleList.querySelectorAll('.btnDelete')) {
btnDelete.addEventListener('click', function () {
const index = parseInt(btnDelete.getAttribute('data-index') || '0', 10);
const index = parseInt(btnDelete.getAttribute('data-index') ?? '0', 10);
schedules.splice(index, 1);
const newindex = schedules.filter(function (i: number) {
return i != index;
@ -196,6 +197,8 @@ const UserParentalControl: FunctionComponent = () => {
const promise2 = window.ApiClient.getParentalRatings();
Promise.all([promise1, promise2]).then(function (responses) {
loadUser(responses[0], responses[1]);
}).catch(err => {
console.error('[userparentalcontrol] failed to load data', err);
});
}, [loadUser]);
@ -215,15 +218,11 @@ const UserParentalControl: FunctionComponent = () => {
};
const saveUser = (user: UserDto) => {
if (!user.Id) {
throw new Error('Unexpected null user.Id');
if (!user.Id || !user.Policy) {
throw new Error('Unexpected null user id or policy');
}
if (!user.Policy) {
throw new Error('Unexpected null user.Policy');
}
const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value || '0', 10);
const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
user.Policy.MaxParentalRating = Number.isNaN(parentalRating) ? null : parentalRating;
user.Policy.BlockUnratedItems = Array.prototype.filter.call(page.querySelectorAll('.chkUnratedItem'), function (i) {
return i.checked;
@ -234,12 +233,14 @@ const UserParentalControl: FunctionComponent = () => {
user.Policy.BlockedTags = getBlockedTagsFromPage();
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
onSaveComplete();
}).catch(err => {
console.error('[userparentalcontrol] failed to update user policy', err);
});
};
const showSchedulePopup = (schedule: AccessSchedule, index: number) => {
schedule = schedule || {};
import('../../components/accessSchedule/accessSchedule').then(({default: accessschedule}) => {
import('../../../../components/accessSchedule/accessSchedule').then(({ default: accessschedule }) => {
accessschedule.show({
schedule: schedule
}).then(function (updatedSchedule) {
@ -251,7 +252,11 @@ const UserParentalControl: FunctionComponent = () => {
schedules[index] = updatedSchedule;
renderAccessSchedule(schedules);
}).catch(() => {
// access schedule closed
});
}).catch(err => {
console.error('[userparentalcontrol] failed to load access schedule', err);
});
};
@ -272,7 +277,7 @@ const UserParentalControl: FunctionComponent = () => {
};
const showBlockedTagPopup = () => {
import('../../components/prompt/prompt').then(({default: prompt}) => {
import('../../../../components/prompt/prompt').then(({ default: prompt }) => {
prompt({
label: globalize.translate('LabelTag')
}).then(function (value) {
@ -282,7 +287,11 @@ const UserParentalControl: FunctionComponent = () => {
tags.push(value);
loadBlockedTags(tags);
}
}).catch(() => {
// prompt closed
});
}).catch(err => {
console.error('[userparentalcontrol] failed to load prompt', err);
});
};
@ -291,6 +300,8 @@ const UserParentalControl: FunctionComponent = () => {
const userId = getParameterByName('userId');
window.ApiClient.getUser(userId).then(function (result) {
saveUser(result);
}).catch(err => {
console.error('[userparentalcontrol] failed to fetch user', err);
});
e.preventDefault();
e.stopPropagation();
@ -367,7 +378,7 @@ const UserParentalControl: FunctionComponent = () => {
</div>
</div>
<br />
<div className='verticalSection' style={{marginBottom: '2em'}}>
<div className='verticalSection' style={{ marginBottom: '2em' }}>
<SectionTitleContainer
SectionClassName='detailSectionHeader'
title={globalize.translate('LabelBlockContentWithTags')}
@ -378,16 +389,16 @@ const UserParentalControl: FunctionComponent = () => {
btnIcon='add'
isLinkVisible={false}
/>
<div className='blockedTags' style={{marginTop: '.5em'}}>
{blockedTags.map((tag, index) => {
<div className='blockedTags' style={{ marginTop: '.5em' }}>
{blockedTags.map(tag => {
return <BlockedTagList
key={index}
key={tag}
tag={tag}
/>;
})}
</div>
</div>
<div className='accessScheduleSection verticalSection' style={{marginBottom: '2em'}}>
<div className='accessScheduleSection verticalSection' style={{ marginBottom: '2em' }}>
<SectionTitleContainer
title={globalize.translate('HeaderAccessSchedule')}
isBtnVisible={true}
@ -401,7 +412,7 @@ const UserParentalControl: FunctionComponent = () => {
<div className='accessScheduleList paperList'>
{accessSchedules.map((accessSchedule, index) => {
return <AccessScheduleList
key={index}
key={accessSchedule.Id}
index={index}
Id={accessSchedule.Id}
DayOfWeek={accessSchedule.DayOfWeek}

View file

@ -1,10 +1,11 @@
import React, { FunctionComponent, useCallback, useEffect, useState } from 'react';
import SectionTabs from '../../components/dashboard/users/SectionTabs';
import UserPasswordForm from '../../components/dashboard/users/UserPasswordForm';
import { getParameterByName } from '../../utils/url';
import SectionTitleContainer from '../../elements/SectionTitleContainer';
import Page from '../../components/Page';
import loading from '../../components/loading/loading';
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
import UserPasswordForm from '../../../../components/dashboard/users/UserPasswordForm';
import { getParameterByName } from '../../../../utils/url';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import Page from '../../../../components/Page';
import loading from '../../../../components/loading/loading';
const UserPassword: FunctionComponent = () => {
const userId = getParameterByName('userId');
@ -18,6 +19,8 @@ const UserPassword: FunctionComponent = () => {
}
setUserName(user.Name);
loading.hide();
}).catch(err => {
console.error('[userpassword] failed to fetch user', err);
});
}, [userId]);
useEffect(() => {

View file

@ -2,17 +2,17 @@ import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
import React, { FunctionComponent, useEffect, useState, useRef, useCallback } from 'react';
import Dashboard from '../../utils/dashboard';
import globalize from '../../scripts/globalize';
import LibraryMenu from '../../scripts/libraryMenu';
import { appHost } from '../../components/apphost';
import confirm from '../../components/confirm/confirm';
import ButtonElement from '../../elements/ButtonElement';
import UserPasswordForm from '../../components/dashboard/users/UserPasswordForm';
import loading from '../../components/loading/loading';
import toast from '../../components/toast/toast';
import { getParameterByName } from '../../utils/url';
import Page from '../../components/Page';
import Dashboard from '../../../../utils/dashboard';
import globalize from '../../../../scripts/globalize';
import LibraryMenu from '../../../../scripts/libraryMenu';
import { appHost } from '../../../../components/apphost';
import confirm from '../../../../components/confirm/confirm';
import ButtonElement from '../../../../elements/ButtonElement';
import UserPasswordForm from '../../../../components/dashboard/users/UserPasswordForm';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import { getParameterByName } from '../../../../utils/url';
import Page from '../../../../components/Page';
const UserProfile: FunctionComponent = () => {
const userId = getParameterByName('userId');
@ -30,12 +30,8 @@ const UserProfile: FunctionComponent = () => {
loading.show();
window.ApiClient.getUser(userId).then(function (user) {
if (!user.Name) {
throw new Error('Unexpected null user.Name');
}
if (!user.Id) {
throw new Error('Unexpected null user.Id');
if (!user.Name || !user.Id) {
throw new Error('Unexpected null user name or id');
}
setUserName(user.Name);
@ -63,8 +59,12 @@ const UserProfile: FunctionComponent = () => {
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).classList.add('hide');
(page.querySelector('#btnAddImage') as HTMLButtonElement).classList.remove('hide');
}
}).catch(err => {
console.error('[userprofile] failed to get current user', err);
});
loading.hide();
}).catch(err => {
console.error('[userprofile] failed to load data', err);
});
}, [userId]);
@ -102,7 +102,7 @@ const UserProfile: FunctionComponent = () => {
const target = evt.target as HTMLInputElement;
const file = (target.files as FileList)[0];
if (!file || !file.type.match('image.*')) {
if (!file || !/image.*/.exec(file.type)) {
return false;
}
@ -114,6 +114,8 @@ const UserProfile: FunctionComponent = () => {
window.ApiClient.uploadUserImage(userId, ImageType.Primary, file).then(function () {
loading.hide();
reloadUser();
}).catch(err => {
console.error('[userprofile] failed to upload image', err);
});
};
@ -129,7 +131,11 @@ const UserProfile: FunctionComponent = () => {
window.ApiClient.deleteUserImage(userId, ImageType.Primary).then(function () {
loading.hide();
reloadUser();
}).catch(err => {
console.error('[userprofile] failed to delete image', err);
});
}).catch(() => {
// confirm dialog closed
});
});
@ -153,25 +159,25 @@ const UserProfile: FunctionComponent = () => {
<div ref={element} className='padded-left padded-right padded-bottom-page'>
<div
className='readOnlyContent'
style={{margin: '0 auto', marginBottom: '1.8em', padding: '0 1em', display: 'flex', flexDirection: 'row', alignItems: 'center'}}
style={{ margin: '0 auto', marginBottom: '1.8em', padding: '0 1em', display: 'flex', flexDirection: 'row', alignItems: 'center' }}
>
<div
className='imagePlaceHolder'
style={{position: 'relative', display: 'inline-block', maxWidth: 200 }}
style={{ position: 'relative', display: 'inline-block', maxWidth: 200 }}
>
<input
id='uploadImage'
type='file'
accept='image/*'
style={{position: 'absolute', right: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer'}}
style={{ position: 'absolute', right: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer' }}
/>
<div
id='image'
style={{width: 200, height: 200, backgroundRepeat: 'no-repeat', backgroundPosition: 'center', borderRadius: '100%', backgroundSize: 'cover'}}
style={{ width: 200, height: 200, backgroundRepeat: 'no-repeat', backgroundPosition: 'center', borderRadius: '100%', backgroundSize: 'cover' }}
/>
</div>
<div style={{verticalAlign: 'top', margin: '1em 2em', display: 'flex', flexDirection: 'column', alignItems: 'center'}}>
<h2 className='username' style={{margin: 0, fontSize: 'xx-large'}}>
<div style={{ verticalAlign: 'top', margin: '1em 2em', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<h2 className='username' style={{ margin: 0, fontSize: 'xx-large' }}>
{userName}
</h2>
<br />

View file

@ -1,24 +1,25 @@
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
import React, {FunctionComponent, useEffect, useState, useRef} from 'react';
import Dashboard from '../../utils/dashboard';
import globalize from '../../scripts/globalize';
import loading from '../../components/loading/loading';
import dom from '../../scripts/dom';
import confirm from '../../components/confirm/confirm';
import UserCardBox from '../../components/dashboard/users/UserCardBox';
import SectionTitleContainer from '../../elements/SectionTitleContainer';
import '../../elements/emby-button/emby-button';
import '../../elements/emby-button/paper-icon-button-light';
import '../../components/cardbuilder/card.scss';
import '../../components/indicators/indicators.scss';
import '../../assets/css/flexstyles.scss';
import Page from '../../components/Page';
import React, { FunctionComponent, useEffect, useState, useRef } from 'react';
import Dashboard from '../../../../utils/dashboard';
import globalize from '../../../../scripts/globalize';
import loading from '../../../../components/loading/loading';
import dom from '../../../../scripts/dom';
import confirm from '../../../../components/confirm/confirm';
import UserCardBox from '../../../../components/dashboard/users/UserCardBox';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import '../../../../elements/emby-button/emby-button';
import '../../../../elements/emby-button/paper-icon-button-light';
import '../../../../components/cardbuilder/card.scss';
import '../../../../components/indicators/indicators.scss';
import '../../../../styles/flexstyles.scss';
import Page from '../../../../components/Page';
type MenuEntry = {
name?: string;
id?: string;
icon?: string;
}
};
const UserProfiles: FunctionComponent = () => {
const [ users, setUsers ] = useState<UserDto[]>([]);
@ -30,6 +31,8 @@ const UserProfiles: FunctionComponent = () => {
window.ApiClient.getUsers().then(function (result) {
setUsers(result);
loading.hide();
}).catch(err => {
console.error('[userprofiles] failed to fetch users', err);
});
};
@ -75,29 +78,42 @@ const UserProfiles: FunctionComponent = () => {
icon: 'delete'
});
import('../../components/actionSheet/actionSheet').then(({default: actionsheet}) => {
import('../../../../components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
actionsheet.show({
items: menuItems,
positionTo: card,
callback: function (id: string) {
switch (id) {
case 'open':
Dashboard.navigate('useredit.html?userId=' + userId);
Dashboard.navigate('useredit.html?userId=' + userId)
.catch(err => {
console.error('[userprofiles] failed to navigate to user edit page', err);
});
break;
case 'access':
Dashboard.navigate('userlibraryaccess.html?userId=' + userId);
Dashboard.navigate('userlibraryaccess.html?userId=' + userId)
.catch(err => {
console.error('[userprofiles] failed to navigate to user library page', err);
});
break;
case 'parentalcontrol':
Dashboard.navigate('userparentalcontrol.html?userId=' + userId);
Dashboard.navigate('userparentalcontrol.html?userId=' + userId)
.catch(err => {
console.error('[userprofiles] failed to navigate to parental control page', err);
});
break;
case 'delete':
deleteUser(userId);
}
}
}).catch(() => {
// action sheet closed
});
}).catch(err => {
console.error('[userprofiles] failed to load action sheet', err);
});
};
@ -113,7 +129,11 @@ const UserProfiles: FunctionComponent = () => {
loading.show();
window.ApiClient.deleteUser(id).then(function () {
loadData();
}).catch(err => {
console.error('[userprofiles] failed to delete user', err);
});
}).catch(() => {
// confirm dialog closed
});
};
@ -126,7 +146,10 @@ const UserProfiles: FunctionComponent = () => {
});
(page.querySelector('#btnAddUser') as HTMLButtonElement).addEventListener('click', function() {
Dashboard.navigate('usernew.html');
Dashboard.navigate('usernew.html')
.catch(err => {
console.error('[userprofiles] failed to navigate to new user page', err);
});
});
}, []);

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

@ -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 { Outlet, useNavigate } from 'react-router-dom';
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';
@ -31,120 +31,138 @@ const ConnectionRequired: FunctionComponent<ConnectionRequiredProps> = ({
isUserRequired = true
}) => {
const navigate = useNavigate();
const location = useLocation();
const [ isLoading, setIsLoading ] = useState(true);
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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);
return;
case ConnectionState.ServerSignIn:
// Bounce to the login page
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, {
state: {
serverid: connectionResponse.ApiClient.serverId()
}
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.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, 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

@ -86,6 +86,10 @@ class ServerConnections extends ConnectionManager {
return this.localApiClient;
}
/**
* Gets the ApiClient that is currently connected.
* @returns {ApiClient|undefined} apiClient
*/
currentApiClient() {
let apiClient = this.getLocalApiClient();

View file

@ -0,0 +1,62 @@
import React, { FunctionComponent, useEffect } from 'react';
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
}
/**
* Page component that renders html content from a server request.
* Uses the ViewManager to dynamically load and execute the page JS.
*/
const ServerContentPage: FunctionComponent<ServerContentPageProps> = ({ view }) => {
const location = useLocation();
useEffect(() => {
const loadPage = () => {
const viewOptions = {
url: location.pathname + location.search,
state: location.state,
autoFocus: false,
options: {
supportsThemeMedia: false,
enableMediaControl: true
}
};
viewManager.tryRestoreView(viewOptions)
.catch(async (result?: RestoreViewFailResponse) => {
if (!result?.cancelled) {
const apiClient = ServerConnections.currentApiClient();
// Fetch the view html from the server and translate it
const viewHtml = await apiClient?.get(apiClient.getUrl(view + location.search))
.then((html: string) => globalize.translateHtml(html));
viewManager.loadView({
...viewOptions,
view: viewHtml
});
}
});
};
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
]);
return <></>;
};
export default ServerContentPage;

View file

@ -1,6 +1,3 @@
/* eslint-disable indent */
/**
* Module for controlling user parental control from.
* @module components/accessSchedule/accessSchedule
@ -14,84 +11,82 @@ import '../../elements/emby-button/paper-icon-button-light';
import '../formdialog.scss';
import template from './accessSchedule.template.html';
function getDisplayTime(hours) {
let minutes = 0;
const pct = hours % 1;
function getDisplayTime(hours) {
let minutes = 0;
const pct = hours % 1;
if (pct) {
minutes = parseInt(60 * pct);
}
return datetime.getDisplayTime(new Date(2000, 1, 1, hours, minutes, 0, 0));
if (pct) {
minutes = parseInt(60 * pct, 10);
}
function populateHours(context) {
let html = '';
return datetime.getDisplayTime(new Date(2000, 1, 1, hours, minutes, 0, 0));
}
for (let i = 0; i < 24; i++) {
html += `<option value="${i}">${getDisplayTime(i)}</option>`;
}
function populateHours(context) {
let html = '';
html += `<option value="24">${getDisplayTime(0)}</option>`;
context.querySelector('#selectStart').innerHTML = html;
context.querySelector('#selectEnd').innerHTML = html;
for (let i = 0; i < 24; i += 0.5) {
html += `<option value="${i}">${getDisplayTime(i)}</option>`;
}
function loadSchedule(context, {DayOfWeek, StartHour, EndHour}) {
context.querySelector('#selectDay').value = DayOfWeek || 'Sunday';
context.querySelector('#selectStart').value = StartHour || 0;
context.querySelector('#selectEnd').value = EndHour || 0;
html += `<option value="24">${getDisplayTime(0)}</option>`;
context.querySelector('#selectStart').innerHTML = html;
context.querySelector('#selectEnd').innerHTML = html;
}
function loadSchedule(context, { DayOfWeek, StartHour, EndHour }) {
context.querySelector('#selectDay').value = DayOfWeek || 'Sunday';
context.querySelector('#selectStart').value = StartHour || 0;
context.querySelector('#selectEnd').value = EndHour || 0;
}
function submitSchedule(context, options) {
const updatedSchedule = {
DayOfWeek: context.querySelector('#selectDay').value,
StartHour: context.querySelector('#selectStart').value,
EndHour: context.querySelector('#selectEnd').value
};
if (parseFloat(updatedSchedule.StartHour) >= parseFloat(updatedSchedule.EndHour)) {
alert(globalize.translate('ErrorStartHourGreaterThanEnd'));
return;
}
function submitSchedule(context, options) {
const updatedSchedule = {
DayOfWeek: context.querySelector('#selectDay').value,
StartHour: context.querySelector('#selectStart').value,
EndHour: context.querySelector('#selectEnd').value
};
context.submitted = true;
options.schedule = Object.assign(options.schedule, updatedSchedule);
dialogHelper.close(context);
}
if (parseFloat(updatedSchedule.StartHour) >= parseFloat(updatedSchedule.EndHour)) {
alert(globalize.translate('ErrorStartHourGreaterThanEnd'));
return;
}
context.submitted = true;
options.schedule = Object.assign(options.schedule, updatedSchedule);
dialogHelper.close(context);
}
export function show(options) {
return new Promise((resolve, reject) => {
const dlg = dialogHelper.createDialog({
removeOnClose: true,
size: 'small'
});
dlg.classList.add('formDialog');
let html = '';
html += globalize.translateHtml(template);
dlg.innerHTML = html;
populateHours(dlg);
loadSchedule(dlg, options.schedule);
dialogHelper.open(dlg);
dlg.addEventListener('close', () => {
if (dlg.submitted) {
resolve(options.schedule);
} else {
reject();
}
});
dlg.querySelector('.btnCancel').addEventListener('click', () => {
dialogHelper.close(dlg);
});
dlg.querySelector('form').addEventListener('submit', event => {
submitSchedule(dlg, options);
event.preventDefault();
return false;
});
export function show(options) {
return new Promise((resolve, reject) => {
const dlg = dialogHelper.createDialog({
removeOnClose: true,
size: 'small'
});
}
/* eslint-enable indent */
dlg.classList.add('formDialog');
let html = '';
html += globalize.translateHtml(template);
dlg.innerHTML = html;
populateHours(dlg);
loadSchedule(dlg, options.schedule);
dialogHelper.open(dlg);
dlg.addEventListener('close', () => {
if (dlg.submitted) {
resolve(options.schedule);
} else {
reject();
}
});
dlg.querySelector('.btnCancel').addEventListener('click', () => {
dialogHelper.close(dlg);
});
dlg.querySelector('form').addEventListener('submit', event => {
submitSchedule(dlg, options);
event.preventDefault();
return false;
});
});
}
export default {
show: show

View file

@ -6,7 +6,7 @@ import dom from '../../scripts/dom';
import '../../elements/emby-button/emby-button';
import './actionSheet.scss';
import 'material-design-icons-iconfont';
import '../../assets/css/scrollstyles.scss';
import '../../styles/scrollstyles.scss';
import '../../components/listview/listview.scss';
function getOffsets(elems) {

View file

@ -11,130 +11,128 @@ import alert from './alert';
import { getLocale } from '../utils/dateFnsLocale.ts';
import { toBoolean } from '../utils/string.ts';
/* eslint-disable indent */
function getEntryHtml(entry, apiClient) {
let html = '';
html += '<div class="listItem listItem-border">';
let color = '#00a4dc';
let icon = 'notifications';
function getEntryHtml(entry, apiClient) {
let html = '';
html += '<div class="listItem listItem-border">';
let color = '#00a4dc';
let icon = 'notifications';
if (entry.Severity == 'Error' || entry.Severity == 'Fatal' || entry.Severity == 'Warn') {
color = '#cc0000';
icon = 'notification_important';
}
if (entry.Severity == 'Error' || entry.Severity == 'Fatal' || entry.Severity == 'Warn') {
color = '#cc0000';
icon = 'notification_important';
}
if (entry.UserId && entry.UserPrimaryImageTag) {
html += '<span class="listItemIcon material-icons dvr" aria-hidden="true" style="width:2em!important;height:2em!important;padding:0;color:transparent;background-color:' + color + ";background-image:url('" + apiClient.getUserImageUrl(entry.UserId, {
type: 'Primary',
tag: entry.UserPrimaryImageTag
}) + "');background-repeat:no-repeat;background-position:center center;background-size: cover;\"></span>";
} else {
html += '<span class="listItemIcon material-icons ' + icon + '" aria-hidden="true" style="background-color:' + color + '"></span>';
}
if (entry.UserId && entry.UserPrimaryImageTag) {
html += '<span class="listItemIcon material-icons dvr" aria-hidden="true" style="width:2em!important;height:2em!important;padding:0;color:transparent;background-color:' + color + ";background-image:url('" + apiClient.getUserImageUrl(entry.UserId, {
type: 'Primary',
tag: entry.UserPrimaryImageTag
}) + "');background-repeat:no-repeat;background-position:center center;background-size: cover;\"></span>";
} else {
html += '<span class="listItemIcon material-icons ' + icon + '" aria-hidden="true" style="background-color:' + color + '"></span>';
}
html += '<div class="listItemBody three-line">';
html += '<div class="listItemBodyText">';
html += escapeHtml(entry.Name);
html += '</div>';
html += '<div class="listItemBodyText secondary">';
html += formatRelative(Date.parse(entry.Date), Date.now(), { locale: getLocale() });
html += '</div>';
html += '<div class="listItemBodyText secondary listItemBodyText-nowrap">';
html += escapeHtml(entry.ShortOverview || '');
html += '</div>';
html += '</div>';
html += '<div class="listItemBody three-line">';
html += '<div class="listItemBodyText">';
html += escapeHtml(entry.Name);
html += '</div>';
html += '<div class="listItemBodyText secondary">';
html += formatRelative(Date.parse(entry.Date), Date.now(), { locale: getLocale() });
html += '</div>';
html += '<div class="listItemBodyText secondary listItemBodyText-nowrap">';
html += escapeHtml(entry.ShortOverview || '');
html += '</div>';
html += '</div>';
if (entry.Overview) {
html += `<button type="button" is="paper-icon-button-light" class="btnEntryInfo" data-id="${entry.Id}" title="${globalize.translate('Info')}">
if (entry.Overview) {
html += `<button type="button" is="paper-icon-button-light" class="btnEntryInfo" data-id="${entry.Id}" title="${globalize.translate('Info')}">
<span class="material-icons info" aria-hidden="true"></span>
</button>`;
}
html += '</div>';
return html;
}
function renderList(elem, apiClient, result) {
elem.innerHTML = result.Items.map(function (i) {
return getEntryHtml(i, apiClient);
}).join('');
html += '</div>';
return html;
}
function renderList(elem, apiClient, result) {
elem.innerHTML = result.Items.map(function (i) {
return getEntryHtml(i, apiClient);
}).join('');
}
function reloadData(instance, elem, apiClient, startIndex, limit) {
if (startIndex == null) {
startIndex = parseInt(elem.getAttribute('data-activitystartindex') || '0', 10);
}
function reloadData(instance, elem, apiClient, startIndex, limit) {
if (startIndex == null) {
startIndex = parseInt(elem.getAttribute('data-activitystartindex') || '0');
}
limit = limit || parseInt(elem.getAttribute('data-activitylimit') || '7', 10);
const minDate = new Date();
const hasUserId = toBoolean(elem.getAttribute('data-useractivity'), true);
limit = limit || parseInt(elem.getAttribute('data-activitylimit') || '7');
const minDate = new Date();
const hasUserId = toBoolean(elem.getAttribute('data-useractivity'), true);
// TODO: Use date-fns
if (hasUserId) {
minDate.setTime(minDate.getTime() - 24 * 60 * 60 * 1000); // one day back
} else {
minDate.setTime(minDate.getTime() - 7 * 24 * 60 * 60 * 1000); // one week back
}
ApiClient.getJSON(ApiClient.getUrl('System/ActivityLog/Entries', {
startIndex: startIndex,
limit: limit,
minDate: minDate.toISOString(),
hasUserId: hasUserId
})).then(function (result) {
elem.setAttribute('data-activitystartindex', startIndex);
elem.setAttribute('data-activitylimit', limit);
if (!startIndex) {
const activityContainer = dom.parentWithClass(elem, 'activityContainer');
if (activityContainer) {
if (result.Items.length) {
activityContainer.classList.remove('hide');
} else {
activityContainer.classList.add('hide');
}
}
}
instance.items = result.Items;
renderList(elem, apiClient, result);
});
// TODO: Use date-fns
if (hasUserId) {
minDate.setTime(minDate.getTime() - 24 * 60 * 60 * 1000); // one day back
} else {
minDate.setTime(minDate.getTime() - 7 * 24 * 60 * 60 * 1000); // one week back
}
function onActivityLogUpdate(e, apiClient) {
const options = this.options;
ApiClient.getJSON(ApiClient.getUrl('System/ActivityLog/Entries', {
startIndex: startIndex,
limit: limit,
minDate: minDate.toISOString(),
hasUserId: hasUserId
})).then(function (result) {
elem.setAttribute('data-activitystartindex', startIndex);
elem.setAttribute('data-activitylimit', limit);
if (!startIndex) {
const activityContainer = dom.parentWithClass(elem, 'activityContainer');
if (options && options.serverId === apiClient.serverId()) {
reloadData(this, options.element, apiClient);
}
}
function onListClick(e) {
const btnEntryInfo = dom.parentWithClass(e.target, 'btnEntryInfo');
if (btnEntryInfo) {
const id = btnEntryInfo.getAttribute('data-id');
const items = this.items;
if (items) {
const item = items.filter(function (i) {
return i.Id.toString() === id;
})[0];
if (item) {
showItemOverview(item);
if (activityContainer) {
if (result.Items.length) {
activityContainer.classList.remove('hide');
} else {
activityContainer.classList.add('hide');
}
}
}
}
function showItemOverview(item) {
alert({
text: item.Overview
});
instance.items = result.Items;
renderList(elem, apiClient, result);
});
}
function onActivityLogUpdate(e, apiClient) {
const options = this.options;
if (options && options.serverId === apiClient.serverId()) {
reloadData(this, options.element, apiClient);
}
}
function onListClick(e) {
const btnEntryInfo = dom.parentWithClass(e.target, 'btnEntryInfo');
if (btnEntryInfo) {
const id = btnEntryInfo.getAttribute('data-id');
const items = this.items;
if (items) {
const item = items.filter(function (i) {
return i.Id.toString() === id;
})[0];
if (item) {
showItemOverview(item);
}
}
}
}
function showItemOverview(item) {
alert({
text: item.Overview
});
}
class ActivityLog {
constructor(options) {
@ -169,5 +167,3 @@ class ActivityLog {
}
export default ActivityLog;
/* eslint-enable indent */

View file

@ -1,47 +1,43 @@
import { appRouter } from './appRouter';
import { appRouter } from './router/appRouter';
import browser from '../scripts/browser';
import dialog from './dialog/dialog';
import globalize from '../scripts/globalize';
/* eslint-disable indent */
function useNativeAlert() {
// webOS seems to block modals
// Tizen 2.x seems to block modals
return !browser.web0s
function useNativeAlert() {
// webOS seems to block modals
// Tizen 2.x seems to block modals
return !browser.web0s
&& !(browser.tizenVersion && browser.tizenVersion < 3)
&& browser.tv
&& window.alert;
}
export default async function (text, title) {
let options;
if (typeof text === 'string') {
options = {
title: title,
text: text
};
} else {
options = text;
}
export default async function (text, title) {
let options;
if (typeof text === 'string') {
options = {
title: title,
text: text
};
} else {
options = text;
}
await appRouter.ready();
await appRouter.ready();
if (useNativeAlert()) {
alert((options.text || '').replaceAll('<br/>', '\n'));
return Promise.resolve();
} else {
const items = [];
if (useNativeAlert()) {
alert((options.text || '').replaceAll('<br/>', '\n'));
return Promise.resolve();
} else {
const items = [];
items.push({
name: globalize.translate('ButtonGotIt'),
id: 'ok',
type: 'submit'
});
items.push({
name: globalize.translate('ButtonGotIt'),
id: 'ok',
type: 'submit'
});
options.buttons = items;
return dialog.show(options);
}
options.buttons = items;
return dialog.show(options);
}
/* eslint-enable indent */
}

View file

@ -1,5 +1,3 @@
/* eslint-disable indent */
/**
* Module alphaPicker.
* @module components/alphaPicker/alphaPicker
@ -13,312 +11,311 @@ import './style.scss';
import '../../elements/emby-button/paper-icon-button-light';
import 'material-design-icons-iconfont';
const selectedButtonClass = 'alphaPickerButton-selected';
const selectedButtonClass = 'alphaPickerButton-selected';
function focus() {
const scope = this;
const selected = scope.querySelector(`.${selectedButtonClass}`);
function focus() {
const scope = this;
const selected = scope.querySelector(`.${selectedButtonClass}`);
if (selected) {
focusManager.focus(selected);
} else {
focusManager.autoFocus(scope, true);
}
if (selected) {
focusManager.focus(selected);
} else {
focusManager.autoFocus(scope, true);
}
}
function getAlphaPickerButtonClassName(vertical) {
let alphaPickerButtonClassName = 'alphaPickerButton';
if (layoutManager.tv) {
alphaPickerButtonClassName += ' alphaPickerButton-tv';
}
function getAlphaPickerButtonClassName(vertical) {
let alphaPickerButtonClassName = 'alphaPickerButton';
if (layoutManager.tv) {
alphaPickerButtonClassName += ' alphaPickerButton-tv';
}
if (vertical) {
alphaPickerButtonClassName += ' alphaPickerButton-vertical';
}
return alphaPickerButtonClassName;
if (vertical) {
alphaPickerButtonClassName += ' alphaPickerButton-vertical';
}
function getLetterButton(l, vertical) {
return `<button data-value="${l}" class="${getAlphaPickerButtonClassName(vertical)}">${l}</button>`;
return alphaPickerButtonClassName;
}
function getLetterButton(l, vertical) {
return `<button data-value="${l}" class="${getAlphaPickerButtonClassName(vertical)}">${l}</button>`;
}
function mapLetters(letters, vertical) {
return letters.map(l => {
return getLetterButton(l, vertical);
});
}
function render(element, options) {
element.classList.add('alphaPicker');
if (layoutManager.tv) {
element.classList.add('alphaPicker-tv');
}
function mapLetters(letters, vertical) {
return letters.map(l => {
return getLetterButton(l, vertical);
});
const vertical = element.classList.contains('alphaPicker-vertical');
if (!vertical) {
element.classList.add('focuscontainer-x');
}
function render(element, options) {
element.classList.add('alphaPicker');
let html = '';
let letters;
if (layoutManager.tv) {
element.classList.add('alphaPicker-tv');
}
const alphaPickerButtonClassName = getAlphaPickerButtonClassName(vertical);
const vertical = element.classList.contains('alphaPicker-vertical');
let rowClassName = 'alphaPickerRow';
if (!vertical) {
element.classList.add('focuscontainer-x');
}
if (vertical) {
rowClassName += ' alphaPickerRow-vertical';
}
let html = '';
let letters;
const alphaPickerButtonClassName = getAlphaPickerButtonClassName(vertical);
let rowClassName = 'alphaPickerRow';
if (vertical) {
rowClassName += ' alphaPickerRow-vertical';
}
html += `<div class="${rowClassName}">`;
if (options.mode === 'keyboard') {
html += `<button data-value=" " is="paper-icon-button-light" class="${alphaPickerButtonClassName}" aria-label="${globalize.translate('ButtonSpace')}"><span class="material-icons alphaPickerButtonIcon space_bar" aria-hidden="true"></span></button>`;
} else {
letters = ['#'];
html += mapLetters(letters, vertical).join('');
}
letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
html += `<div class="${rowClassName}">`;
if (options.mode === 'keyboard') {
html += `<button data-value=" " is="paper-icon-button-light" class="${alphaPickerButtonClassName}" aria-label="${globalize.translate('ButtonSpace')}"><span class="material-icons alphaPickerButtonIcon space_bar" aria-hidden="true"></span></button>`;
} else {
letters = ['#'];
html += mapLetters(letters, vertical).join('');
if (options.mode === 'keyboard') {
html += `<button data-value="backspace" is="paper-icon-button-light" class="${alphaPickerButtonClassName}" aria-label="${globalize.translate('ButtonBackspace')}"><span class="material-icons alphaPickerButtonIcon backspace" aria-hidden="true"></span></button>`;
html += '</div>';
letters = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
html += `<div class="${rowClassName}">`;
html += '<br/>';
html += mapLetters(letters, vertical).join('');
html += '</div>';
} else {
html += '</div>';
}
element.innerHTML = html;
element.classList.add('focusable');
element.focus = focus;
}
export class AlphaPicker {
constructor(options) {
const self = this;
letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
html += mapLetters(letters, vertical).join('');
this.options = options;
if (options.mode === 'keyboard') {
html += `<button data-value="backspace" is="paper-icon-button-light" class="${alphaPickerButtonClassName}" aria-label="${globalize.translate('ButtonBackspace')}"><span class="material-icons alphaPickerButtonIcon backspace" aria-hidden="true"></span></button>`;
html += '</div>';
const element = options.element;
const itemsContainer = options.itemsContainer;
const itemClass = options.itemClass;
letters = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
html += `<div class="${rowClassName}">`;
html += '<br/>';
html += mapLetters(letters, vertical).join('');
html += '</div>';
} else {
html += '</div>';
}
let itemFocusValue;
let itemFocusTimeout;
element.innerHTML = html;
function onItemFocusTimeout() {
itemFocusTimeout = null;
self.value(itemFocusValue);
}
element.classList.add('focusable');
element.focus = focus;
}
let alphaFocusedElement;
let alphaFocusTimeout;
export class AlphaPicker {
constructor(options) {
const self = this;
function onAlphaFocusTimeout() {
alphaFocusTimeout = null;
this.options = options;
if (document.activeElement === alphaFocusedElement) {
const value = alphaFocusedElement.getAttribute('data-value');
self.value(value, true);
}
}
const element = options.element;
const itemsContainer = options.itemsContainer;
const itemClass = options.itemClass;
function onAlphaPickerInKeyboardModeClick(e) {
const alphaPickerButton = dom.parentWithClass(e.target, 'alphaPickerButton');
let itemFocusValue;
let itemFocusTimeout;
if (alphaPickerButton) {
const value = alphaPickerButton.getAttribute('data-value');
element.dispatchEvent(new CustomEvent('alphavalueclicked', {
cancelable: false,
detail: {
value
}
}));
}
}
function onAlphaPickerClick(e) {
const alphaPickerButton = dom.parentWithClass(e.target, 'alphaPickerButton');
if (alphaPickerButton) {
const value = alphaPickerButton.getAttribute('data-value');
if ((this._currentValue || '').toUpperCase() === value.toUpperCase()) {
this.value(null, true);
} else {
this.value(value, true);
}
}
}
function onAlphaPickerFocusIn(e) {
if (alphaFocusTimeout) {
clearTimeout(alphaFocusTimeout);
alphaFocusTimeout = null;
}
const alphaPickerButton = dom.parentWithClass(e.target, 'alphaPickerButton');
if (alphaPickerButton) {
alphaFocusedElement = alphaPickerButton;
alphaFocusTimeout = setTimeout(onAlphaFocusTimeout, 600);
}
}
function onItemsFocusIn(e) {
const item = dom.parentWithClass(e.target, itemClass);
if (item) {
const prefix = item.getAttribute('data-prefix');
if (prefix && prefix.length) {
itemFocusValue = prefix[0];
if (itemFocusTimeout) {
clearTimeout(itemFocusTimeout);
}
itemFocusTimeout = setTimeout(onItemFocusTimeout, 100);
}
}
}
this.enabled = function (enabled) {
if (enabled) {
if (itemsContainer) {
itemsContainer.addEventListener('focus', onItemsFocusIn, true);
}
if (options.mode === 'keyboard') {
element.addEventListener('click', onAlphaPickerInKeyboardModeClick);
}
if (options.valueChangeEvent !== 'click') {
element.addEventListener('focus', onAlphaPickerFocusIn, true);
} else {
element.addEventListener('click', onAlphaPickerClick.bind(this));
}
} else {
if (itemsContainer) {
itemsContainer.removeEventListener('focus', onItemsFocusIn, true);
}
element.removeEventListener('click', onAlphaPickerInKeyboardModeClick);
element.removeEventListener('focus', onAlphaPickerFocusIn, true);
element.removeEventListener('click', onAlphaPickerClick.bind(this));
}
};
render(element, options);
this.enabled(true);
this.visible(true);
function onItemFocusTimeout() {
itemFocusTimeout = null;
self.value(itemFocusValue);
}
value(value, applyValue) {
const element = this.options.element;
let btn;
let selected;
let alphaFocusedElement;
let alphaFocusTimeout;
if (value !== undefined) {
if (value != null) {
value = value.toUpperCase();
this._currentValue = value;
function onAlphaFocusTimeout() {
alphaFocusTimeout = null;
if (this.options.mode !== 'keyboard') {
selected = element.querySelector(`.${selectedButtonClass}`);
try {
btn = element.querySelector(`.alphaPickerButton[data-value='${value}']`);
} catch (err) {
console.error('error in querySelector:', err);
}
if (btn && btn !== selected) {
btn.classList.add(selectedButtonClass);
}
if (selected && selected !== btn) {
selected.classList.remove(selectedButtonClass);
}
}
} else {
this._currentValue = value;
selected = element.querySelector(`.${selectedButtonClass}`);
if (selected) {
selected.classList.remove(selectedButtonClass);
}
}
if (document.activeElement === alphaFocusedElement) {
const value = alphaFocusedElement.getAttribute('data-value');
self.value(value, true);
}
}
if (applyValue) {
element.dispatchEvent(new CustomEvent('alphavaluechanged', {
function onAlphaPickerInKeyboardModeClick(e) {
const alphaPickerButton = dom.parentWithClass(e.target, 'alphaPickerButton');
if (alphaPickerButton) {
const value = alphaPickerButton.getAttribute('data-value');
element.dispatchEvent(new CustomEvent('alphavalueclicked', {
cancelable: false,
detail: {
value
}
}));
}
return this._currentValue;
}
on(name, fn) {
const element = this.options.element;
element.addEventListener(name, fn);
function onAlphaPickerClick(e) {
const alphaPickerButton = dom.parentWithClass(e.target, 'alphaPickerButton');
if (alphaPickerButton) {
const value = alphaPickerButton.getAttribute('data-value');
if ((this._currentValue || '').toUpperCase() === value.toUpperCase()) {
this.value(null, true);
} else {
this.value(value, true);
}
}
}
off(name, fn) {
const element = this.options.element;
element.removeEventListener(name, fn);
function onAlphaPickerFocusIn(e) {
if (alphaFocusTimeout) {
clearTimeout(alphaFocusTimeout);
alphaFocusTimeout = null;
}
const alphaPickerButton = dom.parentWithClass(e.target, 'alphaPickerButton');
if (alphaPickerButton) {
alphaFocusedElement = alphaPickerButton;
alphaFocusTimeout = setTimeout(onAlphaFocusTimeout, 600);
}
}
updateControls(query) {
if (query.NameLessThan) {
this.value('#');
function onItemsFocusIn(e) {
const item = dom.parentWithClass(e.target, itemClass);
if (item) {
const prefix = item.getAttribute('data-prefix');
if (prefix?.length) {
itemFocusValue = prefix[0];
if (itemFocusTimeout) {
clearTimeout(itemFocusTimeout);
}
itemFocusTimeout = setTimeout(onItemFocusTimeout, 100);
}
}
}
this.enabled = function (enabled) {
if (enabled) {
if (itemsContainer) {
itemsContainer.addEventListener('focus', onItemsFocusIn, true);
}
if (options.mode === 'keyboard') {
element.addEventListener('click', onAlphaPickerInKeyboardModeClick);
}
if (options.valueChangeEvent !== 'click') {
element.addEventListener('focus', onAlphaPickerFocusIn, true);
} else {
element.addEventListener('click', onAlphaPickerClick.bind(this));
}
} else {
this.value(query.NameStartsWith);
if (itemsContainer) {
itemsContainer.removeEventListener('focus', onItemsFocusIn, true);
}
element.removeEventListener('click', onAlphaPickerInKeyboardModeClick);
element.removeEventListener('focus', onAlphaPickerFocusIn, true);
element.removeEventListener('click', onAlphaPickerClick.bind(this));
}
};
this.visible(query.SortBy.indexOf('SortName') !== -1);
}
render(element, options);
visible(visible) {
const element = this.options.element;
element.style.visibility = visible ? 'visible' : 'hidden';
}
values() {
const element = this.options.element;
const elems = element.querySelectorAll('.alphaPickerButton');
const values = [];
for (let i = 0, length = elems.length; i < length; i++) {
values.push(elems[i].getAttribute('data-value'));
}
return values;
}
focus() {
const element = this.options.element;
focusManager.autoFocus(element, true);
}
destroy() {
const element = this.options.element;
this.enabled(false);
element.classList.remove('focuscontainer-x');
this.options = null;
}
this.enabled(true);
this.visible(true);
}
/* eslint-enable indent */
value(value, applyValue) {
const element = this.options.element;
let btn;
let selected;
if (value !== undefined) {
if (value != null) {
value = value.toUpperCase();
this._currentValue = value;
if (this.options.mode !== 'keyboard') {
selected = element.querySelector(`.${selectedButtonClass}`);
try {
btn = element.querySelector(`.alphaPickerButton[data-value='${value}']`);
} catch (err) {
console.error('error in querySelector:', err);
}
if (btn && btn !== selected) {
btn.classList.add(selectedButtonClass);
}
if (selected && selected !== btn) {
selected.classList.remove(selectedButtonClass);
}
}
} else {
this._currentValue = value;
selected = element.querySelector(`.${selectedButtonClass}`);
if (selected) {
selected.classList.remove(selectedButtonClass);
}
}
}
if (applyValue) {
element.dispatchEvent(new CustomEvent('alphavaluechanged', {
cancelable: false,
detail: {
value
}
}));
}
return this._currentValue;
}
on(name, fn) {
const element = this.options.element;
element.addEventListener(name, fn);
}
off(name, fn) {
const element = this.options.element;
element.removeEventListener(name, fn);
}
updateControls(query) {
if (query.NameLessThan) {
this.value('#');
} else {
this.value(query.NameStartsWith);
}
this.visible(query.SortBy.indexOf('SortName') !== -1);
}
visible(visible) {
const element = this.options.element;
element.style.visibility = visible ? 'visible' : 'hidden';
}
values() {
const element = this.options.element;
const elems = element.querySelectorAll('.alphaPickerButton');
const values = [];
for (let i = 0, length = elems.length; i < length; i++) {
values.push(elems[i].getAttribute('data-value'));
}
return values;
}
focus() {
const element = this.options.element;
focusManager.autoFocus(element, true);
}
destroy() {
const element = this.options.element;
this.enabled(false);
element.classList.remove('focuscontainer-x');
this.options = null;
}
}
export default AlphaPicker;

View file

@ -309,8 +309,8 @@ function askForExit() {
exitPromise = actionsheet.show({
title: globalize.translate('MessageConfirmAppExit'),
items: [
{id: 'yes', name: globalize.translate('Yes')},
{id: 'no', name: globalize.translate('No')}
{ id: 'yes', name: globalize.translate('Yes') },
{ id: 'no', name: globalize.translate('No') }
]
}).then(function (value) {
if (value === 'yes') {
@ -366,20 +366,20 @@ export const appHost = {
};
},
deviceName: function () {
return window.NativeShell?.AppHost?.deviceName
? window.NativeShell.AppHost.deviceName() : getDeviceName();
return window.NativeShell?.AppHost?.deviceName ?
window.NativeShell.AppHost.deviceName() : getDeviceName();
},
deviceId: function () {
return window.NativeShell?.AppHost?.deviceId
? window.NativeShell.AppHost.deviceId() : getDeviceId();
return window.NativeShell?.AppHost?.deviceId ?
window.NativeShell.AppHost.deviceId() : getDeviceId();
},
appName: function () {
return window.NativeShell?.AppHost?.appName
? window.NativeShell.AppHost.appName() : appName;
return window.NativeShell?.AppHost?.appName ?
window.NativeShell.AppHost.appName() : appName;
},
appVersion: function () {
return window.NativeShell?.AppHost?.appVersion
? window.NativeShell.AppHost.appVersion() : Package.version;
return window.NativeShell?.AppHost?.appVersion ?
window.NativeShell.AppHost.appVersion() : Package.version;
},
getPushTokenInfo: function () {
return {};

View file

@ -1,5 +1,3 @@
/* eslint-disable indent */
/**
* Module for performing auto-focus.
* @module components/autoFocuser
@ -8,93 +6,91 @@
import focusManager from './focusManager';
import layoutManager from './layoutManager';
/**
/**
* Previously selected element.
*/
let activeElement;
let activeElement;
/**
/**
* Returns _true_ if AutoFocuser is enabled.
*/
export function isEnabled() {
return layoutManager.tv;
}
export function isEnabled() {
return layoutManager.tv;
}
/**
/**
* Start AutoFocuser.
*/
export function enable() {
if (!isEnabled()) {
return;
}
window.addEventListener('focusin', function (e) {
activeElement = e.target;
});
console.debug('AutoFocuser enabled');
export function enable() {
if (!isEnabled()) {
return;
}
/**
window.addEventListener('focusin', function (e) {
activeElement = e.target;
});
console.debug('AutoFocuser enabled');
}
/**
* Set focus on a suitable element, taking into account the previously selected.
* @param {HTMLElement} [container] - Element to limit scope.
* @returns {HTMLElement} Focused element.
*/
export function autoFocus(container) {
if (!isEnabled()) {
return null;
}
container = container || document.body;
let candidates = [];
if (activeElement) {
// These elements are recreated
if (activeElement.classList.contains('btnPreviousPage')) {
candidates.push(container.querySelector('.btnPreviousPage'));
candidates.push(container.querySelector('.btnNextPage'));
} else if (activeElement.classList.contains('btnNextPage')) {
candidates.push(container.querySelector('.btnNextPage'));
candidates.push(container.querySelector('.btnPreviousPage'));
} else if (activeElement.classList.contains('btnSelectView')) {
candidates.push(container.querySelector('.btnSelectView'));
}
candidates.push(activeElement);
}
candidates = candidates.concat(Array.from(container.querySelectorAll('.btnPlay')));
let focusedElement;
candidates.every(function (element) {
if (focusManager.isCurrentlyFocusable(element)) {
focusManager.focus(element);
focusedElement = element;
return false;
}
return true;
});
if (!focusedElement) {
// FIXME: Multiple itemsContainers
const itemsContainer = container.querySelector('.itemsContainer');
if (itemsContainer) {
focusedElement = focusManager.autoFocus(itemsContainer);
}
}
if (!focusedElement) {
focusedElement = focusManager.autoFocus(container);
}
return focusedElement;
export function autoFocus(container) {
if (!isEnabled()) {
return null;
}
/* eslint-enable indent */
container = container || document.body;
let candidates = [];
if (activeElement) {
// These elements are recreated
if (activeElement.classList.contains('btnPreviousPage')) {
candidates.push(container.querySelector('.btnPreviousPage'));
candidates.push(container.querySelector('.btnNextPage'));
} else if (activeElement.classList.contains('btnNextPage')) {
candidates.push(container.querySelector('.btnNextPage'));
candidates.push(container.querySelector('.btnPreviousPage'));
} else if (activeElement.classList.contains('btnSelectView')) {
candidates.push(container.querySelector('.btnSelectView'));
}
candidates.push(activeElement);
}
candidates = candidates.concat(Array.from(container.querySelectorAll('.btnPlay')));
let focusedElement;
candidates.every(function (element) {
if (focusManager.isCurrentlyFocusable(element)) {
focusManager.focus(element);
focusedElement = element;
return false;
}
return true;
});
if (!focusedElement) {
// FIXME: Multiple itemsContainers
const itemsContainer = container.querySelector('.itemsContainer');
if (itemsContainer) {
focusedElement = focusManager.autoFocus(itemsContainer);
}
}
if (!focusedElement) {
focusedElement = focusManager.autoFocus(container);
}
return focusedElement;
}
export default {
isEnabled: isEnabled,

View file

@ -7,282 +7,278 @@ import ServerConnections from '../ServerConnections';
import './backdrop.scss';
/* eslint-disable indent */
function enableAnimation() {
return !browser.slow;
}
function enableAnimation() {
return !browser.slow;
}
function enableRotation() {
return !browser.tv
function enableRotation() {
return !browser.tv
// Causes high cpu usage
&& !browser.firefox;
}
}
class Backdrop {
load(url, parent, existingBackdropImage) {
const img = new Image();
const self = this;
class Backdrop {
load(url, parent, existingBackdropImage) {
const img = new Image();
const self = this;
img.onload = () => {
if (self.isDestroyed) {
return;
}
const backdropImage = document.createElement('div');
backdropImage.classList.add('backdropImage');
backdropImage.classList.add('displayingBackdropImage');
backdropImage.style.backgroundImage = `url('${url}')`;
backdropImage.setAttribute('data-url', url);
backdropImage.classList.add('backdropImageFadeIn');
parent.appendChild(backdropImage);
if (!enableAnimation()) {
if (existingBackdropImage && existingBackdropImage.parentNode) {
existingBackdropImage.parentNode.removeChild(existingBackdropImage);
}
internalBackdrop(true);
return;
}
const onAnimationComplete = () => {
dom.removeEventListener(backdropImage, dom.whichAnimationEvent(), onAnimationComplete, {
once: true
});
if (backdropImage === self.currentAnimatingElement) {
self.currentAnimatingElement = null;
}
if (existingBackdropImage && existingBackdropImage.parentNode) {
existingBackdropImage.parentNode.removeChild(existingBackdropImage);
}
};
dom.addEventListener(backdropImage, dom.whichAnimationEvent(), onAnimationComplete, {
once: true
});
internalBackdrop(true);
};
img.src = url;
}
cancelAnimation() {
const elem = this.currentAnimatingElement;
if (elem) {
elem.classList.remove('backdropImageFadeIn');
this.currentAnimatingElement = null;
}
}
destroy() {
this.isDestroyed = true;
this.cancelAnimation();
}
}
let backdropContainer;
function getBackdropContainer() {
if (!backdropContainer) {
backdropContainer = document.querySelector('.backdropContainer');
}
if (!backdropContainer) {
backdropContainer = document.createElement('div');
backdropContainer.classList.add('backdropContainer');
document.body.insertBefore(backdropContainer, document.body.firstChild);
}
return backdropContainer;
}
export function clearBackdrop(clearAll) {
clearRotation();
if (currentLoadingBackdrop) {
currentLoadingBackdrop.destroy();
currentLoadingBackdrop = null;
}
const elem = getBackdropContainer();
elem.innerHTML = '';
if (clearAll) {
hasExternalBackdrop = false;
}
internalBackdrop(false);
}
let backgroundContainer;
function getBackgroundContainer() {
if (!backgroundContainer) {
backgroundContainer = document.querySelector('.backgroundContainer');
}
return backgroundContainer;
}
function setBackgroundContainerBackgroundEnabled() {
if (hasInternalBackdrop || hasExternalBackdrop) {
getBackgroundContainer().classList.add('withBackdrop');
} else {
getBackgroundContainer().classList.remove('withBackdrop');
}
}
let hasInternalBackdrop;
function internalBackdrop(isEnabled) {
hasInternalBackdrop = isEnabled;
setBackgroundContainerBackgroundEnabled();
}
let hasExternalBackdrop;
export function externalBackdrop(isEnabled) {
hasExternalBackdrop = isEnabled;
setBackgroundContainerBackgroundEnabled();
}
let currentLoadingBackdrop;
function setBackdropImage(url) {
if (currentLoadingBackdrop) {
currentLoadingBackdrop.destroy();
currentLoadingBackdrop = null;
}
const elem = getBackdropContainer();
const existingBackdropImage = elem.querySelector('.displayingBackdropImage');
if (existingBackdropImage && existingBackdropImage.getAttribute('data-url') === url) {
if (existingBackdropImage.getAttribute('data-url') === url) {
img.onload = () => {
if (self.isDestroyed) {
return;
}
existingBackdropImage.classList.remove('displayingBackdropImage');
}
const instance = new Backdrop();
instance.load(url, elem, existingBackdropImage);
currentLoadingBackdrop = instance;
}
const backdropImage = document.createElement('div');
backdropImage.classList.add('backdropImage');
backdropImage.classList.add('displayingBackdropImage');
backdropImage.style.backgroundImage = `url('${url}')`;
backdropImage.setAttribute('data-url', url);
function getItemImageUrls(item, imageOptions) {
imageOptions = imageOptions || {};
backdropImage.classList.add('backdropImageFadeIn');
parent.appendChild(backdropImage);
const apiClient = ServerConnections.getApiClient(item.ServerId);
if (item.BackdropImageTags && item.BackdropImageTags.length > 0) {
return item.BackdropImageTags.map((imgTag, index) => {
return apiClient.getScaledImageUrl(item.BackdropItemId || item.Id, Object.assign(imageOptions, {
type: 'Backdrop',
tag: imgTag,
maxWidth: dom.getScreenWidth(),
index: index
}));
if (!enableAnimation()) {
if (existingBackdropImage && existingBackdropImage.parentNode) {
existingBackdropImage.parentNode.removeChild(existingBackdropImage);
}
internalBackdrop(true);
return;
}
const onAnimationComplete = () => {
dom.removeEventListener(backdropImage, dom.whichAnimationEvent(), onAnimationComplete, {
once: true
});
if (backdropImage === self.currentAnimatingElement) {
self.currentAnimatingElement = null;
}
if (existingBackdropImage && existingBackdropImage.parentNode) {
existingBackdropImage.parentNode.removeChild(existingBackdropImage);
}
};
dom.addEventListener(backdropImage, dom.whichAnimationEvent(), onAnimationComplete, {
once: true
});
}
if (item.ParentBackdropItemId && item.ParentBackdropImageTags && item.ParentBackdropImageTags.length) {
return item.ParentBackdropImageTags.map((imgTag, index) => {
return apiClient.getScaledImageUrl(item.ParentBackdropItemId, Object.assign(imageOptions, {
type: 'Backdrop',
tag: imgTag,
maxWidth: dom.getScreenWidth(),
index: index
}));
});
}
return [];
}
function getImageUrls(items, imageOptions) {
const list = [];
const onImg = img => {
list.push(img);
internalBackdrop(true);
};
for (let i = 0, length = items.length; i < length; i++) {
const itemImages = getItemImageUrls(items[i], imageOptions);
itemImages.forEach(onImg);
}
return list;
img.src = url;
}
function enabled() {
return userSettings.enableBackdrops();
}
let rotationInterval;
let currentRotatingImages = [];
let currentRotationIndex = -1;
export function setBackdrops(items, imageOptions, enableImageRotation) {
if (enabled()) {
const images = getImageUrls(items, imageOptions);
if (images.length) {
startRotation(images, enableImageRotation);
} else {
clearBackdrop();
}
cancelAnimation() {
const elem = this.currentAnimatingElement;
if (elem) {
elem.classList.remove('backdropImageFadeIn');
this.currentAnimatingElement = null;
}
}
function startRotation(images, enableImageRotation) {
if (isEqual(images, currentRotatingImages)) {
destroy() {
this.isDestroyed = true;
this.cancelAnimation();
}
}
let backdropContainer;
function getBackdropContainer() {
if (!backdropContainer) {
backdropContainer = document.querySelector('.backdropContainer');
}
if (!backdropContainer) {
backdropContainer = document.createElement('div');
backdropContainer.classList.add('backdropContainer');
document.body.insertBefore(backdropContainer, document.body.firstChild);
}
return backdropContainer;
}
export function clearBackdrop(clearAll) {
clearRotation();
if (currentLoadingBackdrop) {
currentLoadingBackdrop.destroy();
currentLoadingBackdrop = null;
}
const elem = getBackdropContainer();
elem.innerHTML = '';
if (clearAll) {
hasExternalBackdrop = false;
}
internalBackdrop(false);
}
let backgroundContainer;
function getBackgroundContainer() {
if (!backgroundContainer) {
backgroundContainer = document.querySelector('.backgroundContainer');
}
return backgroundContainer;
}
function setBackgroundContainerBackgroundEnabled() {
if (hasInternalBackdrop || hasExternalBackdrop) {
getBackgroundContainer().classList.add('withBackdrop');
} else {
getBackgroundContainer().classList.remove('withBackdrop');
}
}
let hasInternalBackdrop;
function internalBackdrop(isEnabled) {
hasInternalBackdrop = isEnabled;
setBackgroundContainerBackgroundEnabled();
}
let hasExternalBackdrop;
export function externalBackdrop(isEnabled) {
hasExternalBackdrop = isEnabled;
setBackgroundContainerBackgroundEnabled();
}
let currentLoadingBackdrop;
function setBackdropImage(url) {
if (currentLoadingBackdrop) {
currentLoadingBackdrop.destroy();
currentLoadingBackdrop = null;
}
const elem = getBackdropContainer();
const existingBackdropImage = elem.querySelector('.displayingBackdropImage');
if (existingBackdropImage && existingBackdropImage.getAttribute('data-url') === url) {
if (existingBackdropImage.getAttribute('data-url') === url) {
return;
}
clearRotation();
currentRotatingImages = images;
currentRotationIndex = -1;
if (images.length > 1 && enableImageRotation !== false && enableRotation()) {
rotationInterval = setInterval(onRotationInterval, 24000);
}
onRotationInterval();
existingBackdropImage.classList.remove('displayingBackdropImage');
}
function onRotationInterval() {
if (playbackManager.isPlayingLocally(['Video'])) {
return;
}
const instance = new Backdrop();
instance.load(url, elem, existingBackdropImage);
currentLoadingBackdrop = instance;
}
let newIndex = currentRotationIndex + 1;
if (newIndex >= currentRotatingImages.length) {
newIndex = 0;
}
function getItemImageUrls(item, imageOptions) {
imageOptions = imageOptions || {};
currentRotationIndex = newIndex;
setBackdropImage(currentRotatingImages[newIndex]);
const apiClient = ServerConnections.getApiClient(item.ServerId);
if (item.BackdropImageTags && item.BackdropImageTags.length > 0) {
return item.BackdropImageTags.map((imgTag, index) => {
return apiClient.getScaledImageUrl(item.BackdropItemId || item.Id, Object.assign(imageOptions, {
type: 'Backdrop',
tag: imgTag,
maxWidth: dom.getScreenWidth(),
index: index
}));
});
}
function clearRotation() {
const interval = rotationInterval;
if (interval) {
clearInterval(interval);
}
rotationInterval = null;
currentRotatingImages = [];
currentRotationIndex = -1;
if (item.ParentBackdropItemId && item.ParentBackdropImageTags && item.ParentBackdropImageTags.length) {
return item.ParentBackdropImageTags.map((imgTag, index) => {
return apiClient.getScaledImageUrl(item.ParentBackdropItemId, Object.assign(imageOptions, {
type: 'Backdrop',
tag: imgTag,
maxWidth: dom.getScreenWidth(),
index: index
}));
});
}
export function setBackdrop(url, imageOptions) {
if (url && typeof url !== 'string') {
url = getImageUrls([url], imageOptions)[0];
}
return [];
}
if (url) {
clearRotation();
setBackdropImage(url);
function getImageUrls(items, imageOptions) {
const list = [];
const onImg = img => {
list.push(img);
};
for (let i = 0, length = items.length; i < length; i++) {
const itemImages = getItemImageUrls(items[i], imageOptions);
itemImages.forEach(onImg);
}
return list;
}
function enabled() {
return userSettings.enableBackdrops();
}
let rotationInterval;
let currentRotatingImages = [];
let currentRotationIndex = -1;
export function setBackdrops(items, imageOptions, enableImageRotation) {
if (enabled()) {
const images = getImageUrls(items, imageOptions);
if (images.length) {
startRotation(images, enableImageRotation);
} else {
clearBackdrop();
}
}
}
/* eslint-enable indent */
function startRotation(images, enableImageRotation) {
if (isEqual(images, currentRotatingImages)) {
return;
}
clearRotation();
currentRotatingImages = images;
currentRotationIndex = -1;
if (images.length > 1 && enableImageRotation !== false && enableRotation()) {
rotationInterval = setInterval(onRotationInterval, 24000);
}
onRotationInterval();
}
function onRotationInterval() {
if (playbackManager.isPlayingLocally(['Video'])) {
return;
}
let newIndex = currentRotationIndex + 1;
if (newIndex >= currentRotatingImages.length) {
newIndex = 0;
}
currentRotationIndex = newIndex;
setBackdropImage(currentRotatingImages[newIndex]);
}
function clearRotation() {
const interval = rotationInterval;
if (interval) {
clearInterval(interval);
}
rotationInterval = null;
currentRotatingImages = [];
currentRotationIndex = -1;
}
export function setBackdrop(url, imageOptions) {
if (url && typeof url !== 'string') {
url = getImageUrls([url], imageOptions)[0];
}
if (url) {
clearRotation();
setBackdropImage(url);
} else {
clearBackdrop();
}
}
/**
* @enum TransparencyLevel

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 {

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,3 @@
/* eslint-disable indent */
/**
* Module for building cards from item data.
@ -12,123 +11,121 @@ import layoutManager from '../layoutManager';
import browser from '../../scripts/browser';
import ServerConnections from '../ServerConnections';
const enableFocusTransform = !browser.slow && !browser.edge;
const enableFocusTransform = !browser.slow && !browser.edge;
function buildChapterCardsHtml(item, chapters, options) {
// TODO move card creation code to Card component
function buildChapterCardsHtml(item, chapters, options) {
// TODO move card creation code to Card component
let className = 'card itemAction chapterCard';
let className = 'card itemAction chapterCard';
if (layoutManager.tv) {
className += ' show-focus';
if (layoutManager.tv) {
className += ' show-focus';
if (enableFocusTransform) {
className += ' show-animation';
}
if (enableFocusTransform) {
className += ' show-animation';
}
const mediaStreams = ((item.MediaSources || [])[0] || {}).MediaStreams || [];
const videoStream = mediaStreams.filter(({Type}) => {
return Type === 'Video';
})[0] || {};
let shape = (options.backdropShape || 'backdrop');
if (videoStream.Width && videoStream.Height && (videoStream.Width / videoStream.Height) <= 1.2) {
shape = (options.squareShape || 'square');
}
className += ` ${shape}Card`;
if (options.block || options.rows) {
className += ' block';
}
let html = '';
let itemsInRow = 0;
const apiClient = ServerConnections.getApiClient(item.ServerId);
for (let i = 0, length = chapters.length; i < length; i++) {
if (options.rows && itemsInRow === 0) {
html += '<div class="cardColumn">';
}
const chapter = chapters[i];
html += buildChapterCard(item, apiClient, chapter, i, options, className, shape);
itemsInRow++;
if (options.rows && itemsInRow >= options.rows) {
itemsInRow = 0;
html += '</div>';
}
}
return html;
}
function getImgUrl({Id}, {ImageTag}, index, maxWidth, apiClient) {
if (ImageTag) {
return apiClient.getScaledImageUrl(Id, {
const mediaStreams = ((item.MediaSources || [])[0] || {}).MediaStreams || [];
const videoStream = mediaStreams.filter(({ Type }) => {
return Type === 'Video';
})[0] || {};
maxWidth: maxWidth,
tag: ImageTag,
type: 'Chapter',
index
});
}
let shape = (options.backdropShape || 'backdrop');
return null;
if (videoStream.Width && videoStream.Height && (videoStream.Width / videoStream.Height) <= 1.2) {
shape = (options.squareShape || 'square');
}
function buildChapterCard(item, apiClient, chapter, index, {width, coverImage}, className, shape) {
const imgUrl = getImgUrl(item, chapter, index, width || 400, apiClient);
className += ` ${shape}Card`;
let cardImageContainerClass = 'cardContent cardContent-shadow cardImageContainer chapterCardImageContainer';
if (coverImage) {
cardImageContainerClass += ' coveredImage';
}
const dataAttributes = ` data-action="play" data-isfolder="${item.IsFolder}" data-id="${item.Id}" data-serverid="${item.ServerId}" data-type="${item.Type}" data-mediatype="${item.MediaType}" data-positionticks="${chapter.StartPositionTicks}"`;
let cardImageContainer = imgUrl ? (`<div class="${cardImageContainerClass} lazy" data-src="${imgUrl}">`) : (`<div class="${cardImageContainerClass}">`);
if (!imgUrl) {
cardImageContainer += '<span class="material-icons cardImageIcon local_movies" aria-hidden="true"></span>';
}
let nameHtml = '';
nameHtml += `<div class="cardText">${escapeHtml(chapter.Name)}</div>`;
nameHtml += `<div class="cardText">${datetime.getDisplayRunningTime(chapter.StartPositionTicks)}</div>`;
const cardBoxCssClass = 'cardBox';
const cardScalableClass = 'cardScalable';
return `<button type="button" class="${className}"${dataAttributes}><div class="${cardBoxCssClass}"><div class="${cardScalableClass}"><div class="cardPadder-${shape}"></div>${cardImageContainer}</div><div class="innerCardFooter">${nameHtml}</div></div></div></button>`;
if (options.block || options.rows) {
className += ' block';
}
export function buildChapterCards(item, chapters, options) {
if (options.parentContainer) {
// Abort if the container has been disposed
if (!document.body.contains(options.parentContainer)) {
return;
}
let html = '';
let itemsInRow = 0;
if (chapters.length) {
options.parentContainer.classList.remove('hide');
} else {
options.parentContainer.classList.add('hide');
return;
}
const apiClient = ServerConnections.getApiClient(item.ServerId);
for (let i = 0, length = chapters.length; i < length; i++) {
if (options.rows && itemsInRow === 0) {
html += '<div class="cardColumn">';
}
const html = buildChapterCardsHtml(item, chapters, options);
const chapter = chapters[i];
options.itemsContainer.innerHTML = html;
html += buildChapterCard(item, apiClient, chapter, i, options, className, shape);
itemsInRow++;
imageLoader.lazyChildren(options.itemsContainer);
if (options.rows && itemsInRow >= options.rows) {
itemsInRow = 0;
html += '</div>';
}
}
/* eslint-enable indent */
return html;
}
function getImgUrl({ Id }, { ImageTag }, index, maxWidth, apiClient) {
if (ImageTag) {
return apiClient.getScaledImageUrl(Id, {
maxWidth: maxWidth,
tag: ImageTag,
type: 'Chapter',
index
});
}
return null;
}
function buildChapterCard(item, apiClient, chapter, index, { width, coverImage }, className, shape) {
const imgUrl = getImgUrl(item, chapter, index, width || 400, apiClient);
let cardImageContainerClass = 'cardContent cardContent-shadow cardImageContainer chapterCardImageContainer';
if (coverImage) {
cardImageContainerClass += ' coveredImage';
}
const dataAttributes = ` data-action="play" data-isfolder="${item.IsFolder}" data-id="${item.Id}" data-serverid="${item.ServerId}" data-type="${item.Type}" data-mediatype="${item.MediaType}" data-positionticks="${chapter.StartPositionTicks}"`;
let cardImageContainer = imgUrl ? (`<div class="${cardImageContainerClass} lazy" data-src="${imgUrl}">`) : (`<div class="${cardImageContainerClass}">`);
if (!imgUrl) {
cardImageContainer += '<span class="material-icons cardImageIcon local_movies" aria-hidden="true"></span>';
}
let nameHtml = '';
nameHtml += `<div class="cardText">${escapeHtml(chapter.Name)}</div>`;
nameHtml += `<div class="cardText">${datetime.getDisplayRunningTime(chapter.StartPositionTicks)}</div>`;
const cardBoxCssClass = 'cardBox';
const cardScalableClass = 'cardScalable';
return `<button type="button" class="${className}"${dataAttributes}><div class="${cardBoxCssClass}"><div class="${cardScalableClass}"><div class="cardPadder-${shape}"></div>${cardImageContainer}</div><div class="innerCardFooter">${nameHtml}</div></div></div></button>`;
}
export function buildChapterCards(item, chapters, options) {
if (options.parentContainer) {
// Abort if the container has been disposed
if (!document.body.contains(options.parentContainer)) {
return;
}
if (chapters.length) {
options.parentContainer.classList.remove('hide');
} else {
options.parentContainer.classList.add('hide');
return;
}
}
const html = buildChapterCardsHtml(item, chapters, options);
options.itemsContainer.innerHTML = html;
imageLoader.lazyChildren(options.itemsContainer);
}
export default {
buildChapterCards: buildChapterCards

View file

@ -1,4 +1,3 @@
/* eslint-disable indent */
/**
* Module for building cards from item data.
@ -7,20 +6,18 @@
import cardBuilder from './cardBuilder';
export function buildPeopleCards(items, options) {
options = Object.assign(options || {}, {
cardLayout: false,
centerText: true,
showTitle: true,
cardFooterAside: 'none',
showPersonRoleOrType: true,
cardCssClass: 'personCard',
defaultCardImageIcon: 'person'
});
cardBuilder.buildCards(items, options);
}
/* eslint-enable indent */
export function buildPeopleCards(items, options) {
options = Object.assign(options || {}, {
cardLayout: false,
centerText: true,
showTitle: true,
cardFooterAside: 'none',
showPersonRoleOrType: true,
cardCssClass: 'personCard',
defaultCardImageIcon: 'person'
});
cardBuilder.buildCards(items, options);
}
export default {
buildPeopleCards: buildPeopleCards

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';
@ -12,258 +12,255 @@ import '../../elements/emby-input/emby-input';
import '../../elements/emby-select/emby-select';
import 'material-design-icons-iconfont';
import '../formdialog.scss';
import '../../assets/css/flexstyles.scss';
import '../../styles/flexstyles.scss';
import ServerConnections from '../ServerConnections';
import toast from '../toast/toast';
/* eslint-disable indent */
let currentServerId;
let currentServerId;
function onSubmit(e) {
loading.show();
function onSubmit(e) {
loading.show();
const panel = dom.parentWithClass(this, 'dialog');
const panel = dom.parentWithClass(this, 'dialog');
const collectionId = panel.querySelector('#selectCollectionToAddTo').value;
const collectionId = panel.querySelector('#selectCollectionToAddTo').value;
const apiClient = ServerConnections.getApiClient(currentServerId);
const apiClient = ServerConnections.getApiClient(currentServerId);
if (collectionId) {
addToCollection(apiClient, panel, collectionId);
} else {
createCollection(apiClient, panel);
}
e.preventDefault();
return false;
if (collectionId) {
addToCollection(apiClient, panel, collectionId);
} else {
createCollection(apiClient, panel);
}
function createCollection(apiClient, dlg) {
const url = apiClient.getUrl('Collections', {
e.preventDefault();
return false;
}
Name: dlg.querySelector('#txtNewCollectionName').value,
IsLocked: !dlg.querySelector('#chkEnableInternetMetadata').checked,
Ids: dlg.querySelector('.fldSelectedItemIds').value || ''
});
function createCollection(apiClient, dlg) {
const url = apiClient.getUrl('Collections', {
apiClient.ajax({
type: 'POST',
url: url,
dataType: 'json'
Name: dlg.querySelector('#txtNewCollectionName').value,
IsLocked: !dlg.querySelector('#chkEnableInternetMetadata').checked,
Ids: dlg.querySelector('.fldSelectedItemIds').value || ''
});
}).then(result => {
loading.hide();
apiClient.ajax({
type: 'POST',
url: url,
dataType: 'json'
const id = result.Id;
}).then(result => {
loading.hide();
dlg.submitted = true;
dialogHelper.close(dlg);
redirectToCollection(apiClient, id);
});
}
const id = result.Id;
function redirectToCollection(apiClient, id) {
appRouter.showItem(id, apiClient.serverId());
}
dlg.submitted = true;
dialogHelper.close(dlg);
redirectToCollection(apiClient, id);
});
}
function addToCollection(apiClient, dlg, id) {
const url = apiClient.getUrl(`Collections/${id}/Items`, {
function redirectToCollection(apiClient, id) {
appRouter.showItem(id, apiClient.serverId());
}
Ids: dlg.querySelector('.fldSelectedItemIds').value || ''
});
function addToCollection(apiClient, dlg, id) {
const url = apiClient.getUrl(`Collections/${id}/Items`, {
apiClient.ajax({
type: 'POST',
url: url
Ids: dlg.querySelector('.fldSelectedItemIds').value || ''
});
}).then(() => {
loading.hide();
apiClient.ajax({
type: 'POST',
url: url
dlg.submitted = true;
dialogHelper.close(dlg);
}).then(() => {
loading.hide();
toast(globalize.translate('MessageItemsAdded'));
});
}
dlg.submitted = true;
dialogHelper.close(dlg);
function triggerChange(select) {
select.dispatchEvent(new CustomEvent('change', {}));
}
toast(globalize.translate('MessageItemsAdded'));
});
}
function populateCollections(panel) {
loading.show();
function triggerChange(select) {
select.dispatchEvent(new CustomEvent('change', {}));
}
const select = panel.querySelector('#selectCollectionToAddTo');
function populateCollections(panel) {
loading.show();
panel.querySelector('.newCollectionInfo').classList.add('hide');
const select = panel.querySelector('#selectCollectionToAddTo');
const options = {
panel.querySelector('.newCollectionInfo').classList.add('hide');
Recursive: true,
IncludeItemTypes: 'BoxSet',
SortBy: 'SortName',
EnableTotalRecordCount: false
};
const options = {
const apiClient = ServerConnections.getApiClient(currentServerId);
apiClient.getItems(apiClient.getCurrentUserId(), options).then(result => {
let html = '';
Recursive: true,
IncludeItemTypes: 'BoxSet',
SortBy: 'SortName',
EnableTotalRecordCount: false
};
html += `<option value="">${globalize.translate('OptionNew')}</option>`;
html += result.Items.map(i => {
return `<option value="${i.Id}">${escapeHtml(i.Name)}</option>`;
});
select.innerHTML = html;
select.value = '';
triggerChange(select);
loading.hide();
});
}
function getEditorHtml() {
const apiClient = ServerConnections.getApiClient(currentServerId);
apiClient.getItems(apiClient.getCurrentUserId(), options).then(result => {
let html = '';
html += '<div class="formDialogContent smoothScrollY" style="padding-top:2em;">';
html += '<div class="dialogContentInner dialog-content-centered">';
html += '<form class="newCollectionForm" style="margin:auto;">';
html += `<option value="">${globalize.translate('OptionNew')}</option>`;
html += '<div>';
html += globalize.translate('NewCollectionHelp');
html += '</div>';
html += '<div class="fldSelectCollection">';
html += '<br/>';
html += '<br/>';
html += '<div class="selectContainer">';
html += `<select is="emby-select" label="${globalize.translate('LabelCollection')}" id="selectCollectionToAddTo" autofocus></select>`;
html += '</div>';
html += '</div>';
html += '<div class="newCollectionInfo">';
html += '<div class="inputContainer">';
html += `<input is="emby-input" type="text" id="txtNewCollectionName" required="required" label="${globalize.translate('LabelName')}" />`;
html += `<div class="fieldDescription">${globalize.translate('NewCollectionNameExample')}</div>`;
html += '</div>';
html += '<label class="checkboxContainer">';
html += '<input is="emby-checkbox" type="checkbox" id="chkEnableInternetMetadata" />';
html += `<span>${globalize.translate('SearchForCollectionInternetMetadata')}</span>`;
html += '</label>';
// newCollectionInfo
html += '</div>';
html += '<div class="formDialogFooter">';
html += `<button is="emby-button" type="submit" class="raised btnSubmit block formDialogFooterItem button-submit">${globalize.translate('ButtonOk')}</button>`;
html += '</div>';
html += '<input type="hidden" class="fldSelectedItemIds" />';
html += '</form>';
html += '</div>';
html += '</div>';
return html;
}
function initEditor(content, items) {
content.querySelector('#selectCollectionToAddTo').addEventListener('change', function () {
if (this.value) {
content.querySelector('.newCollectionInfo').classList.add('hide');
content.querySelector('#txtNewCollectionName').removeAttribute('required');
} else {
content.querySelector('.newCollectionInfo').classList.remove('hide');
content.querySelector('#txtNewCollectionName').setAttribute('required', 'required');
}
html += result.Items.map(i => {
return `<option value="${i.Id}">${escapeHtml(i.Name)}</option>`;
});
content.querySelector('form').addEventListener('submit', onSubmit);
select.innerHTML = html;
select.value = '';
triggerChange(select);
content.querySelector('.fldSelectedItemIds', content).value = items.join(',');
loading.hide();
});
}
if (items.length) {
content.querySelector('.fldSelectCollection').classList.remove('hide');
populateCollections(content);
function getEditorHtml() {
let html = '';
html += '<div class="formDialogContent smoothScrollY" style="padding-top:2em;">';
html += '<div class="dialogContentInner dialog-content-centered">';
html += '<form class="newCollectionForm" style="margin:auto;">';
html += '<div>';
html += globalize.translate('NewCollectionHelp');
html += '</div>';
html += '<div class="fldSelectCollection">';
html += '<br/>';
html += '<br/>';
html += '<div class="selectContainer">';
html += `<select is="emby-select" label="${globalize.translate('LabelCollection')}" id="selectCollectionToAddTo" autofocus></select>`;
html += '</div>';
html += '</div>';
html += '<div class="newCollectionInfo">';
html += '<div class="inputContainer">';
html += `<input is="emby-input" type="text" id="txtNewCollectionName" required="required" label="${globalize.translate('LabelName')}" />`;
html += `<div class="fieldDescription">${globalize.translate('NewCollectionNameExample')}</div>`;
html += '</div>';
html += '<label class="checkboxContainer">';
html += '<input is="emby-checkbox" type="checkbox" id="chkEnableInternetMetadata" />';
html += `<span>${globalize.translate('SearchForCollectionInternetMetadata')}</span>`;
html += '</label>';
// newCollectionInfo
html += '</div>';
html += '<div class="formDialogFooter">';
html += `<button is="emby-button" type="submit" class="raised btnSubmit block formDialogFooterItem button-submit">${globalize.translate('ButtonOk')}</button>`;
html += '</div>';
html += '<input type="hidden" class="fldSelectedItemIds" />';
html += '</form>';
html += '</div>';
html += '</div>';
return html;
}
function initEditor(content, items) {
content.querySelector('#selectCollectionToAddTo').addEventListener('change', function () {
if (this.value) {
content.querySelector('.newCollectionInfo').classList.add('hide');
content.querySelector('#txtNewCollectionName').removeAttribute('required');
} else {
content.querySelector('.fldSelectCollection').classList.add('hide');
const selectCollectionToAddTo = content.querySelector('#selectCollectionToAddTo');
selectCollectionToAddTo.innerHTML = '';
selectCollectionToAddTo.value = '';
triggerChange(selectCollectionToAddTo);
content.querySelector('.newCollectionInfo').classList.remove('hide');
content.querySelector('#txtNewCollectionName').setAttribute('required', 'required');
}
}
});
function centerFocus(elem, horiz, on) {
import('../../scripts/scrollHelper').then((scrollHelper) => {
const fn = on ? 'on' : 'off';
scrollHelper.centerFocus[fn](elem, horiz);
content.querySelector('form').addEventListener('submit', onSubmit);
content.querySelector('.fldSelectedItemIds', content).value = items.join(',');
if (items.length) {
content.querySelector('.fldSelectCollection').classList.remove('hide');
populateCollections(content);
} else {
content.querySelector('.fldSelectCollection').classList.add('hide');
const selectCollectionToAddTo = content.querySelector('#selectCollectionToAddTo');
selectCollectionToAddTo.innerHTML = '';
selectCollectionToAddTo.value = '';
triggerChange(selectCollectionToAddTo);
}
}
function centerFocus(elem, horiz, on) {
import('../../scripts/scrollHelper').then((scrollHelper) => {
const fn = on ? 'on' : 'off';
scrollHelper.centerFocus[fn](elem, horiz);
});
}
class CollectionEditor {
show(options) {
const items = options.items || {};
currentServerId = options.serverId;
const dialogOptions = {
removeOnClose: true,
scrollY: false
};
if (layoutManager.tv) {
dialogOptions.size = 'fullscreen';
} else {
dialogOptions.size = 'small';
}
const dlg = dialogHelper.createDialog(dialogOptions);
dlg.classList.add('formDialog');
let html = '';
const title = items.length ? globalize.translate('HeaderAddToCollection') : globalize.translate('NewCollection');
html += '<div class="formDialogHeader">';
html += `<button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1" title="${globalize.translate('ButtonBack')}"><span class="material-icons arrow_back" aria-hidden="true"></span></button>`;
html += '<h3 class="formDialogHeaderTitle">';
html += title;
html += '</h3>';
html += '</div>';
html += getEditorHtml();
dlg.innerHTML = html;
initEditor(dlg, items);
dlg.querySelector('.btnCancel').addEventListener('click', () => {
dialogHelper.close(dlg);
});
if (layoutManager.tv) {
centerFocus(dlg.querySelector('.formDialogContent'), false, true);
}
return dialogHelper.open(dlg).then(() => {
if (layoutManager.tv) {
centerFocus(dlg.querySelector('.formDialogContent'), false, false);
}
if (dlg.submitted) {
return Promise.resolve();
}
return Promise.reject();
});
}
}
class CollectionEditor {
show(options) {
const items = options.items || {};
currentServerId = options.serverId;
const dialogOptions = {
removeOnClose: true,
scrollY: false
};
if (layoutManager.tv) {
dialogOptions.size = 'fullscreen';
} else {
dialogOptions.size = 'small';
}
const dlg = dialogHelper.createDialog(dialogOptions);
dlg.classList.add('formDialog');
let html = '';
const title = items.length ? globalize.translate('HeaderAddToCollection') : globalize.translate('NewCollection');
html += '<div class="formDialogHeader">';
html += `<button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1" title="${globalize.translate('ButtonBack')}"><span class="material-icons arrow_back" aria-hidden="true"></span></button>`;
html += '<h3 class="formDialogHeaderTitle">';
html += title;
html += '</h3>';
html += '</div>';
html += getEditorHtml();
dlg.innerHTML = html;
initEditor(dlg, items);
dlg.querySelector('.btnCancel').addEventListener('click', () => {
dialogHelper.close(dlg);
});
if (layoutManager.tv) {
centerFocus(dlg.querySelector('.formDialogContent'), false, true);
}
return dialogHelper.open(dlg).then(() => {
if (layoutManager.tv) {
centerFocus(dlg.querySelector('.formDialogContent'), false, false);
}
if (dlg.submitted) {
return Promise.resolve();
}
return Promise.reject();
});
}
}
/* eslint-enable indent */
export default CollectionEditor;

View file

@ -17,9 +17,15 @@ const AlphaPickerContainer: FC<AlphaPickerContainerProps> = ({ viewQuerySettings
const newValue = (e as CustomEvent).detail.value;
let updatedValue: React.SetStateAction<ViewQuerySettings>;
if (newValue === '#') {
updatedValue = {NameLessThan: 'A'};
updatedValue = {
NameLessThan: 'A',
NameStartsWith: undefined
};
} else {
updatedValue = {NameStartsWith: newValue};
updatedValue = {
NameLessThan: undefined,
NameStartsWith: newValue
};
}
setViewQuerySettings((prevState) => ({
...prevState,

View file

@ -22,7 +22,7 @@ const Filter: FC<FilterProps> = ({
const element = useRef<HTMLDivElement>(null);
const showFilterMenu = useCallback(() => {
import('../filtermenu/filtermenu').then(({default: FilterMenu}) => {
import('../filtermenu/filtermenu').then(({ default: FilterMenu }) => {
const filterMenu = new FilterMenu();
filterMenu.show({
settings: viewQuerySettings,
@ -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]);
@ -90,8 +92,8 @@ const GenresItemsContainer: FC<GenresItemsContainerProps> = ({
<h1>{globalize.translate('MessageNothingHere')}</h1>
<p>{globalize.translate('MessageNoGenresAvailable')}</p>
</div>
) : items.map((item, index) => (
<div key={index} className='verticalSection'>
) : items.map(item => (
<div key={item.Id} className='verticalSection'>
<div
className='sectionTitleContainer sectionTitleContainer-cards padded-left'
dangerouslySetInnerHTML={createLinkElement({

View file

@ -6,13 +6,17 @@ const NewCollection: FC = () => {
const element = useRef<HTMLDivElement>(null);
const showCollectionEditor = useCallback(() => {
import('../collectionEditor/collectionEditor').then(({default: CollectionEditor}) => {
import('../collectionEditor/collectionEditor').then(({ default: CollectionEditor }) => {
const serverId = window.ApiClient.serverId();
const collectionEditor = new CollectionEditor();
collectionEditor.show({
items: [],
serverId: serverId
}).catch(() => {
// closed collection editor
});
}).catch(err => {
console.error('[NewCollection] failed to load collection editor', err);
});
}, []);

View file

@ -15,8 +15,9 @@ const Pagination: FC<PaginationProps> = ({ viewQuerySettings, setViewQuerySettin
const limit = userSettings.libraryPageSize(undefined);
const totalRecordCount = itemsResult.TotalRecordCount || 0;
const startIndex = viewQuerySettings.StartIndex || 0;
const recordsEnd = Math.min(startIndex + limit, totalRecordCount);
const showControls = limit < totalRecordCount;
const recordsStart = totalRecordCount ? startIndex + 1 : 0;
const recordsEnd = limit ? Math.min(startIndex + limit, totalRecordCount) : totalRecordCount;
const showControls = limit > 0 && limit < totalRecordCount;
const element = useRef<HTMLDivElement>(null);
const onNextPageClick = useCallback(() => {
@ -69,25 +70,25 @@ const Pagination: FC<PaginationProps> = ({ viewQuerySettings, setViewQuerySettin
return (
<div ref={element}>
<div className='paging'>
{showControls && (
<div className='listPaging' style={{ display: 'flex', alignItems: 'center' }}>
<span>
{globalize.translate('ListPaging', (totalRecordCount ? startIndex + 1 : 0), recordsEnd, totalRecordCount)}
</span>
<IconButtonElement
is='paper-icon-button-light'
className='btnPreviousPage autoSize'
icon='material-icons arrow_back'
/>
<IconButtonElement
is='paper-icon-button-light'
className='btnNextPage autoSize'
icon='material-icons arrow_forward'
/>
</div>
)}
<div className='listPaging' style={{ display: 'flex', alignItems: 'center' }}>
<span>
{globalize.translate('ListPaging', recordsStart, recordsEnd, totalRecordCount)}
</span>
{showControls && (
<>
<IconButtonElement
is='paper-icon-button-light'
className='btnPreviousPage autoSize'
icon='material-icons arrow_back'
/>
<IconButtonElement
is='paper-icon-button-light'
className='btnNextPage autoSize'
icon='material-icons arrow_forward'
/>
</>
)}
</div>
</div>
</div>
);

View file

@ -16,13 +16,17 @@ const SelectView: FC<SelectViewProps> = ({
const element = useRef<HTMLDivElement>(null);
const showViewSettingsMenu = useCallback(() => {
import('../viewSettings/viewSettings').then(({default: ViewSettings}) => {
import('../viewSettings/viewSettings').then(({ default: ViewSettings }) => {
const viewsettings = new ViewSettings();
viewsettings.show({
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

@ -19,13 +19,17 @@ const Sort: FC<SortProps> = ({
const element = useRef<HTMLDivElement>(null);
const showSortMenu = useCallback(() => {
import('../sortmenu/sortmenu').then(({default: SortMenu}) => {
import('../sortmenu/sortmenu').then(({ default: SortMenu }) => {
const sortMenu = new SortMenu();
sortMenu.show({
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,10 +280,10 @@ 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),
Limit: userSettings.libraryPageSize(undefined) || undefined,
IsFavorite: getBasekey() === 'favorites' ? true : null,
VideoTypes: viewQuerySettings.VideoTypes,
GenreIds: viewQuerySettings.GenreIds,
@ -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,9 +12,9 @@ type IProps = {
listTitle?: string;
description?: string;
children?: React.ReactNode
}
};
const AccessContainer: FunctionComponent<IProps> = ({containerClassName, headerTitle, checkBoxClassName, checkBoxTitle, listContainerClassName, accessClassName, listTitle, description, children }: IProps) => {
const AccessContainer: FunctionComponent<IProps> = ({ containerClassName, headerTitle, checkBoxClassName, checkBoxTitle, listContainerClassName, accessClassName, listTitle, description, children }: IProps) => {
return (
<div className={containerClassName}>
<h2>{globalize.translate(headerTitle)}</h2>

View file

@ -9,7 +9,7 @@ type AccessScheduleListProps = {
DayOfWeek?: string;
StartHour?: number ;
EndHour?: number;
}
};
function getDisplayTime(hours = 0) {
let minutes = 0;
@ -22,7 +22,7 @@ function getDisplayTime(hours = 0) {
return datetime.getDisplayTime(new Date(2000, 1, 1, hours, minutes, 0, 0));
}
const AccessScheduleList: FunctionComponent<AccessScheduleListProps> = ({index, DayOfWeek, StartHour, EndHour}: AccessScheduleListProps) => {
const AccessScheduleList: FunctionComponent<AccessScheduleListProps> = ({ index, DayOfWeek, StartHour, EndHour }: AccessScheduleListProps) => {
return (
<div
className='liSchedule listItem'

View file

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

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="#"
@ -36,7 +36,7 @@ const createLinkElement = (activeTab: string) => ({
</a>`
});
const SectionTabs: FunctionComponent<IProps> = ({activeTab}: IProps) => {
const SectionTabs: FunctionComponent<IProps> = ({ activeTab }: IProps) => {
return (
<div
data-role='controlgroup'

View file

@ -19,7 +19,7 @@ const createLinkElement = ({ user, renderImgUrl }: { user: UserDto, renderImgUrl
type IProps = {
user?: UserDto;
}
};
const getLastSeenText = (lastActivityDate?: string | null) => {
if (lastActivityDate) {
@ -74,7 +74,7 @@ const UserCardBox: FunctionComponent<IProps> = ({ user = {} }: IProps) => {
</div>
<div className='cardFooter visualCardBox-cardFooter'>
<div
style={{textAlign: 'right', float: 'right', paddingTop: '5px'}}
style={{ textAlign: 'right', float: 'right', paddingTop: '5px' }}
>
<IconButtonElement
is='paper-icon-button-light'

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 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
});
};
@ -214,7 +226,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({userId}: IProps) => {
<div ref={element}>
<form
className='updatePasswordForm passwordSection hide'
style={{margin: '0 auto 2em'}}
style={{ margin: '0 auto 2em' }}
>
<div className='detailSection'>
<div id='fldCurrentPassword' className='inputContainer hide'>
@ -260,7 +272,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({userId}: IProps) => {
<br />
<form
className='localAccessForm localAccessSection'
style={{margin: '0 auto'}}
style={{ margin: '0 auto' }}
>
<div className='detailSection'>
<div className='detailSectionHeader'>

View file

@ -10,132 +10,129 @@ import '../../elements/emby-button/emby-button';
import '../../elements/emby-button/paper-icon-button-light';
import '../../elements/emby-input/emby-input';
import '../formdialog.scss';
import '../../assets/css/flexstyles.scss';
import '../../styles/flexstyles.scss';
import template from './dialog.template.html';
/* eslint-disable indent */
function showDialog(options = { dialogOptions: {}, buttons: [] }) {
const dialogOptions = {
removeOnClose: true,
scrollY: false,
...options.dialogOptions
};
function showDialog(options = { dialogOptions: {}, buttons: [] }) {
const dialogOptions = {
removeOnClose: true,
scrollY: false,
...options.dialogOptions
};
const enableTvLayout = layoutManager.tv;
const enableTvLayout = layoutManager.tv;
if (enableTvLayout) {
dialogOptions.size = 'fullscreen';
}
if (enableTvLayout) {
dialogOptions.size = 'fullscreen';
const dlg = dialogHelper.createDialog(dialogOptions);
dlg.classList.add('formDialog');
dlg.innerHTML = globalize.translateHtml(template, 'core');
dlg.classList.add('align-items-center');
dlg.classList.add('justify-content-center');
const formDialogContent = dlg.querySelector('.formDialogContent');
formDialogContent.classList.add('no-grow');
if (enableTvLayout) {
formDialogContent.style['max-width'] = '50%';
formDialogContent.style['max-height'] = '60%';
scrollHelper.centerFocus.on(formDialogContent, false);
} else {
formDialogContent.style.maxWidth = `${Math.min((options.buttons.length * 150) + 200, dom.getWindowSize().innerWidth - 50)}px`;
dlg.classList.add('dialog-fullscreen-lowres');
}
if (options.title) {
dlg.querySelector('.formDialogHeaderTitle').innerText = options.title || '';
} else {
dlg.querySelector('.formDialogHeaderTitle').classList.add('hide');
}
const displayText = options.html || options.text || '';
dlg.querySelector('.text').innerHTML = DOMPurify.sanitize(displayText);
if (!displayText) {
dlg.querySelector('.dialogContentInner').classList.add('hide');
}
let i;
let length;
let html = '';
let hasDescriptions = false;
for (i = 0, length = options.buttons.length; i < length; i++) {
const item = options.buttons[i];
const autoFocus = i === 0 ? ' autofocus' : '';
let buttonClass = 'btnOption raised formDialogFooterItem formDialogFooterItem-autosize';
if (item.type) {
buttonClass += ` button-${item.type}`;
}
const dlg = dialogHelper.createDialog(dialogOptions);
dlg.classList.add('formDialog');
dlg.innerHTML = globalize.translateHtml(template, 'core');
dlg.classList.add('align-items-center');
dlg.classList.add('justify-content-center');
const formDialogContent = dlg.querySelector('.formDialogContent');
formDialogContent.classList.add('no-grow');
if (enableTvLayout) {
formDialogContent.style['max-width'] = '50%';
formDialogContent.style['max-height'] = '60%';
scrollHelper.centerFocus.on(formDialogContent, false);
} else {
formDialogContent.style.maxWidth = `${Math.min((options.buttons.length * 150) + 200, dom.getWindowSize().innerWidth - 50)}px`;
dlg.classList.add('dialog-fullscreen-lowres');
if (item.description) {
hasDescriptions = true;
}
if (options.title) {
dlg.querySelector('.formDialogHeaderTitle').innerText = options.title || '';
} else {
dlg.querySelector('.formDialogHeaderTitle').classList.add('hide');
}
const displayText = options.html || options.text || '';
dlg.querySelector('.text').innerHTML = DOMPurify.sanitize(displayText);
if (!displayText) {
dlg.querySelector('.dialogContentInner').classList.add('hide');
}
let i;
let length;
let html = '';
let hasDescriptions = false;
for (i = 0, length = options.buttons.length; i < length; i++) {
const item = options.buttons[i];
const autoFocus = i === 0 ? ' autofocus' : '';
let buttonClass = 'btnOption raised formDialogFooterItem formDialogFooterItem-autosize';
if (item.type) {
buttonClass += ` button-${item.type}`;
}
if (item.description) {
hasDescriptions = true;
}
if (hasDescriptions) {
buttonClass += ' formDialogFooterItem-vertical formDialogFooterItem-nomarginbottom';
}
html += `<button is="emby-button" type="button" class="${buttonClass}" data-id="${item.id}"${autoFocus}>${escapeHtml(item.name)}</button>`;
if (item.description) {
html += `<div class="formDialogFooterItem formDialogFooterItem-autosize fieldDescription" style="margin-top:.25em!important;margin-bottom:1.25em!important;">${item.description}</div>`;
}
}
dlg.querySelector('.formDialogFooter').innerHTML = html;
if (hasDescriptions) {
dlg.querySelector('.formDialogFooter').classList.add('formDialogFooter-vertical');
buttonClass += ' formDialogFooterItem-vertical formDialogFooterItem-nomarginbottom';
}
let dialogResult;
function onButtonClick() {
dialogResult = this.getAttribute('data-id');
dialogHelper.close(dlg);
html += `<button is="emby-button" type="button" class="${buttonClass}" data-id="${item.id}"${autoFocus}>${escapeHtml(item.name)}</button>`;
if (item.description) {
html += `<div class="formDialogFooterItem formDialogFooterItem-autosize fieldDescription" style="margin-top:.25em!important;margin-bottom:1.25em!important;">${item.description}</div>`;
}
const buttons = dlg.querySelectorAll('.btnOption');
for (i = 0, length = buttons.length; i < length; i++) {
buttons[i].addEventListener('click', onButtonClick);
}
return dialogHelper.open(dlg).then(() => {
if (enableTvLayout) {
scrollHelper.centerFocus.off(dlg.querySelector('.formDialogContent'), false);
}
if (dialogResult) {
return dialogResult;
} else {
return Promise.reject();
}
});
}
export function show(text, title) {
let options;
if (typeof text === 'string') {
options = {
title: title,
text: text
};
dlg.querySelector('.formDialogFooter').innerHTML = html;
if (hasDescriptions) {
dlg.querySelector('.formDialogFooter').classList.add('formDialogFooter-vertical');
}
let dialogResult;
function onButtonClick() {
dialogResult = this.getAttribute('data-id');
dialogHelper.close(dlg);
}
const buttons = dlg.querySelectorAll('.btnOption');
for (i = 0, length = buttons.length; i < length; i++) {
buttons[i].addEventListener('click', onButtonClick);
}
return dialogHelper.open(dlg).then(() => {
if (enableTvLayout) {
scrollHelper.centerFocus.off(dlg.querySelector('.formDialogContent'), false);
}
if (dialogResult) {
return dialogResult;
} else {
options = text;
return Promise.reject();
}
});
}
return showDialog(options);
export function show(text, title) {
let options;
if (typeof text === 'string') {
options = {
title: title,
text: text
};
} else {
options = text;
}
/* eslint-enable indent */
return showDialog(options);
}
export default {
show: show
};

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';
@ -7,508 +7,503 @@ import { toBoolean } from '../../utils/string.ts';
import dom from '../../scripts/dom';
import './dialoghelper.scss';
import '../../assets/css/scrollstyles.scss';
import '../../styles/scrollstyles.scss';
/* eslint-disable indent */
let globalOnOpenCallback;
let globalOnOpenCallback;
function enableAnimation() {
// too slow
if (browser.tv) {
return false;
}
return browser.supportsCssAnimation();
function enableAnimation() {
// too slow
if (browser.tv) {
return false;
}
function removeCenterFocus(dlg) {
if (layoutManager.tv) {
if (dlg.classList.contains('scrollX')) {
centerFocus(dlg, true, false);
} else if (dlg.classList.contains('smoothScrollY')) {
centerFocus(dlg, false, false);
}
return browser.supportsCssAnimation();
}
function removeCenterFocus(dlg) {
if (layoutManager.tv) {
if (dlg.classList.contains('scrollX')) {
centerFocus(dlg, true, false);
} else if (dlg.classList.contains('smoothScrollY')) {
centerFocus(dlg, false, false);
}
}
}
function tryRemoveElement(elem) {
const parentNode = elem.parentNode;
if (parentNode) {
// Seeing crashes in edge webview
try {
parentNode.removeChild(elem);
} catch (err) {
console.error('[dialogHelper] error removing dialog element: ' + err);
}
}
}
function DialogHashHandler(dlg, hash, resolve) {
const self = this;
self.originalUrl = window.location.href;
const activeElement = document.activeElement;
let removeScrollLockOnClose = false;
let unlisten;
function onHashChange({ location }) {
const dialogs = location.state?.dialogs || [];
const shouldClose = !dialogs.includes(hash);
if ((shouldClose || !isOpened(dlg)) && unlisten) {
unlisten();
unlisten = null;
}
if (shouldClose) {
close(dlg);
}
}
function tryRemoveElement(elem) {
const parentNode = elem.parentNode;
if (parentNode) {
// Seeing crashes in edge webview
try {
parentNode.removeChild(elem);
} catch (err) {
console.error('[dialogHelper] error removing dialog element: ' + err);
}
}
}
function DialogHashHandler(dlg, hash, resolve) {
const self = this;
self.originalUrl = window.location.href;
const activeElement = document.activeElement;
let removeScrollLockOnClose = false;
let unlisten;
function onHashChange({ location }) {
const dialogs = location.state?.dialogs || [];
const shouldClose = !dialogs.includes(hash);
if ((shouldClose || !isOpened(dlg)) && unlisten) {
unlisten();
unlisten = null;
}
if (shouldClose) {
close(dlg);
}
function finishClose() {
if (unlisten) {
unlisten();
unlisten = null;
}
function finishClose() {
if (unlisten) {
unlisten();
unlisten = null;
}
dlg.dispatchEvent(new CustomEvent('close', {
bubbles: false,
cancelable: false
}));
resolve({
element: dlg
});
}
function onBackCommand(e) {
if (e.detail.command === 'back') {
e.preventDefault();
e.stopPropagation();
close(dlg);
}
}
function onDialogClosed() {
if (!isHistoryEnabled(dlg)) {
inputManager.off(dlg, onBackCommand);
}
if (unlisten) {
unlisten();
unlisten = null;
}
removeBackdrop(dlg);
dlg.classList.remove('opened');
if (removeScrollLockOnClose) {
document.body.classList.remove('noScroll');
}
if (isHistoryEnabled(dlg)) {
const state = history.location.state || {};
if (state.dialogs?.length > 0) {
if (state.dialogs[state.dialogs.length - 1] === hash) {
unlisten = history.listen(finishClose);
history.back();
} else if (state.dialogs.includes(hash)) {
console.warn('[dialogHelper] dialog "%s" was closed, but is not the last dialog opened', hash);
unlisten = history.listen(finishClose);
// Remove the closed dialog hash from the history state
history.replace(
`${history.location.pathname}${history.location.search}`,
{
...state,
dialogs: state.dialogs.filter(dialog => dialog !== hash)
}
);
}
}
}
if (layoutManager.tv) {
focusManager.focus(activeElement);
}
if (toBoolean(dlg.getAttribute('data-removeonclose'), true)) {
removeCenterFocus(dlg);
const dialogContainer = dlg.dialogContainer;
if (dialogContainer) {
tryRemoveElement(dialogContainer);
dlg.dialogContainer = null;
} else {
tryRemoveElement(dlg);
}
}
if (!unlisten) {
finishClose();
}
}
dlg.addEventListener('_close', onDialogClosed);
const center = !dlg.classList.contains('dialog-fixedSize');
if (center) {
dlg.classList.add('centeredDialog');
}
dlg.classList.remove('hide');
addBackdropOverlay(dlg);
dlg.classList.add('opened');
dlg.dispatchEvent(new CustomEvent('open', {
dlg.dispatchEvent(new CustomEvent('close', {
bubbles: false,
cancelable: false
}));
if (dlg.getAttribute('data-lockscroll') === 'true' && !document.body.classList.contains('noScroll')) {
document.body.classList.add('noScroll');
removeScrollLockOnClose = true;
resolve({
element: dlg
});
}
function onBackCommand(e) {
if (e.detail.command === 'back') {
e.preventDefault();
e.stopPropagation();
close(dlg);
}
}
function onDialogClosed() {
if (!isHistoryEnabled(dlg)) {
inputManager.off(dlg, onBackCommand);
}
animateDialogOpen(dlg);
if (unlisten) {
unlisten();
unlisten = null;
}
removeBackdrop(dlg);
dlg.classList.remove('opened');
if (removeScrollLockOnClose) {
document.body.classList.remove('noScroll');
}
if (isHistoryEnabled(dlg)) {
const state = history.location.state || {};
const dialogs = state.dialogs || [];
// Add new dialog to the list of open dialogs
dialogs.push(hash);
if (state.dialogs?.length > 0) {
if (state.dialogs[state.dialogs.length - 1] === hash) {
unlisten = history.listen(finishClose);
history.back();
} else if (state.dialogs.includes(hash)) {
console.warn('[dialogHelper] dialog "%s" was closed, but is not the last dialog opened', hash);
history.push(
`${history.location.pathname}${history.location.search}`,
{
...state,
dialogs
unlisten = history.listen(finishClose);
// Remove the closed dialog hash from the history state
history.replace(
`${history.location.pathname}${history.location.search}`,
{
...state,
dialogs: state.dialogs.filter(dialog => dialog !== hash)
}
);
}
);
unlisten = history.listen(onHashChange);
} else {
inputManager.on(dlg, onBackCommand);
}
}
function addBackdropOverlay(dlg) {
const backdrop = document.createElement('div');
backdrop.classList.add('dialogBackdrop');
const backdropParent = dlg.dialogContainer || dlg;
backdropParent.parentNode.insertBefore(backdrop, backdropParent);
dlg.backdrop = backdrop;
// trigger reflow or the backdrop will not animate
void backdrop.offsetWidth;
backdrop.classList.add('dialogBackdropOpened');
dom.addEventListener((dlg.dialogContainer || backdrop), 'click', e => {
if (e.target === dlg.dialogContainer) {
close(dlg);
}
}, {
passive: true
});
}
dom.addEventListener((dlg.dialogContainer || backdrop), 'contextmenu', e => {
if (e.target === dlg.dialogContainer) {
// Close the application dialog menu
close(dlg);
// Prevent the default browser context menu from appearing
e.preventDefault();
if (layoutManager.tv) {
focusManager.focus(activeElement);
}
if (toBoolean(dlg.getAttribute('data-removeonclose'), true)) {
removeCenterFocus(dlg);
const dialogContainer = dlg.dialogContainer;
if (dialogContainer) {
tryRemoveElement(dialogContainer);
dlg.dialogContainer = null;
} else {
tryRemoveElement(dlg);
}
});
}
function isHistoryEnabled(dlg) {
return dlg.getAttribute('data-history') === 'true';
}
export function open(dlg) {
if (globalOnOpenCallback) {
globalOnOpenCallback(dlg);
}
const parent = dlg.parentNode;
if (parent) {
parent.removeChild(dlg);
if (!unlisten) {
finishClose();
}
const dialogContainer = document.createElement('div');
dialogContainer.classList.add('dialogContainer');
dialogContainer.appendChild(dlg);
dlg.dialogContainer = dialogContainer;
document.body.appendChild(dialogContainer);
return new Promise((resolve) => {
new DialogHashHandler(dlg, `dlg${new Date().getTime()}`, resolve);
});
}
function isOpened(dlg) {
//return dlg.opened;
return !dlg.classList.contains('hide');
dlg.addEventListener('_close', onDialogClosed);
const center = !dlg.classList.contains('dialog-fixedSize');
if (center) {
dlg.classList.add('centeredDialog');
}
export function close(dlg) {
if (!dlg.classList.contains('hide')) {
dlg.dispatchEvent(new CustomEvent('closing', {
dlg.classList.remove('hide');
addBackdropOverlay(dlg);
dlg.classList.add('opened');
dlg.dispatchEvent(new CustomEvent('open', {
bubbles: false,
cancelable: false
}));
if (dlg.getAttribute('data-lockscroll') === 'true' && !document.body.classList.contains('noScroll')) {
document.body.classList.add('noScroll');
removeScrollLockOnClose = true;
}
animateDialogOpen(dlg);
if (isHistoryEnabled(dlg)) {
const state = history.location.state || {};
const dialogs = state.dialogs || [];
// Add new dialog to the list of open dialogs
dialogs.push(hash);
history.push(
`${history.location.pathname}${history.location.search}`,
{
...state,
dialogs
}
);
unlisten = history.listen(onHashChange);
} else {
inputManager.on(dlg, onBackCommand);
}
}
function addBackdropOverlay(dlg) {
const backdrop = document.createElement('div');
backdrop.classList.add('dialogBackdrop');
const backdropParent = dlg.dialogContainer || dlg;
backdropParent.parentNode.insertBefore(backdrop, backdropParent);
dlg.backdrop = backdrop;
// trigger reflow or the backdrop will not animate
void backdrop.offsetWidth;
backdrop.classList.add('dialogBackdropOpened');
dom.addEventListener((dlg.dialogContainer || backdrop), 'click', e => {
if (e.target === dlg.dialogContainer) {
close(dlg);
}
}, {
passive: true
});
dom.addEventListener((dlg.dialogContainer || backdrop), 'contextmenu', e => {
if (e.target === dlg.dialogContainer) {
// Close the application dialog menu
close(dlg);
// Prevent the default browser context menu from appearing
e.preventDefault();
}
});
}
function isHistoryEnabled(dlg) {
return dlg.getAttribute('data-history') === 'true';
}
export function open(dlg) {
if (globalOnOpenCallback) {
globalOnOpenCallback(dlg);
}
const parent = dlg.parentNode;
if (parent) {
parent.removeChild(dlg);
}
const dialogContainer = document.createElement('div');
dialogContainer.classList.add('dialogContainer');
dialogContainer.appendChild(dlg);
dlg.dialogContainer = dialogContainer;
document.body.appendChild(dialogContainer);
return new Promise((resolve) => {
new DialogHashHandler(dlg, `dlg${new Date().getTime()}`, resolve);
});
}
function isOpened(dlg) {
return !dlg.classList.contains('hide');
}
export function close(dlg) {
if (!dlg.classList.contains('hide')) {
dlg.dispatchEvent(new CustomEvent('closing', {
bubbles: false,
cancelable: false
}));
const onAnimationFinish = () => {
focusManager.popScope(dlg);
dlg.classList.add('hide');
dlg.dispatchEvent(new CustomEvent('_close', {
bubbles: false,
cancelable: false
}));
};
const onAnimationFinish = () => {
focusManager.popScope(dlg);
dlg.classList.add('hide');
dlg.dispatchEvent(new CustomEvent('_close', {
bubbles: false,
cancelable: false
}));
};
animateDialogClose(dlg, onAnimationFinish);
}
animateDialogClose(dlg, onAnimationFinish);
}
}
const getAnimationEndHandler = (dlg, callback) => function handler() {
dom.removeEventListener(dlg, dom.whichAnimationEvent(), handler, { once: true });
callback();
const getAnimationEndHandler = (dlg, callback) => function handler() {
dom.removeEventListener(dlg, dom.whichAnimationEvent(), handler, { once: true });
callback();
};
function animateDialogOpen(dlg) {
const onAnimationFinish = () => {
focusManager.pushScope(dlg);
if (dlg.getAttribute('data-autofocus') === 'true') {
focusManager.autoFocus(dlg);
}
if (document.activeElement && !dlg.contains(document.activeElement)) {
// Blur foreign element to prevent triggering of an action from the previous scope
document.activeElement.blur();
}
};
function animateDialogOpen(dlg) {
const onAnimationFinish = () => {
focusManager.pushScope(dlg);
if (enableAnimation()) {
dom.addEventListener(
dlg,
dom.whichAnimationEvent(),
getAnimationEndHandler(dlg, onAnimationFinish),
{ once: true });
if (dlg.getAttribute('data-autofocus') === 'true') {
focusManager.autoFocus(dlg);
}
return;
}
if (document.activeElement && !dlg.contains(document.activeElement)) {
// Blur foreign element to prevent triggering of an action from the previous scope
document.activeElement.blur();
}
};
onAnimationFinish();
}
if (enableAnimation()) {
dom.addEventListener(
dlg,
dom.whichAnimationEvent(),
getAnimationEndHandler(dlg, onAnimationFinish),
{ once: true });
function animateDialogClose(dlg, onAnimationFinish) {
if (enableAnimation()) {
let animated = true;
switch (dlg.animationConfig.exit.name) {
case 'fadeout':
dlg.style.animation = `fadeout ${dlg.animationConfig.exit.timing.duration}ms ease-out normal both`;
break;
case 'scaledown':
dlg.style.animation = `scaledown ${dlg.animationConfig.exit.timing.duration}ms ease-out normal both`;
break;
case 'slidedown':
dlg.style.animation = `slidedown ${dlg.animationConfig.exit.timing.duration}ms ease-out normal both`;
break;
default:
animated = false;
break;
}
dom.addEventListener(
dlg,
dom.whichAnimationEvent(),
getAnimationEndHandler(dlg, onAnimationFinish),
{ once: true });
if (animated) {
return;
}
onAnimationFinish();
}
function animateDialogClose(dlg, onAnimationFinish) {
if (enableAnimation()) {
let animated = true;
onAnimationFinish();
}
switch (dlg.animationConfig.exit.name) {
case 'fadeout':
dlg.style.animation = `fadeout ${dlg.animationConfig.exit.timing.duration}ms ease-out normal both`;
break;
case 'scaledown':
dlg.style.animation = `scaledown ${dlg.animationConfig.exit.timing.duration}ms ease-out normal both`;
break;
case 'slidedown':
dlg.style.animation = `slidedown ${dlg.animationConfig.exit.timing.duration}ms ease-out normal both`;
break;
default:
animated = false;
break;
const supportsOverscrollBehavior = 'overscroll-behavior-y' in document.body.style;
function shouldLockDocumentScroll(options) {
if (options.lockScroll != null) {
return options.lockScroll;
}
if (options.size === 'fullscreen') {
return true;
}
if (supportsOverscrollBehavior && (options.size || !browser.touch)) {
return false;
}
if (options.size) {
return true;
}
return browser.touch;
}
function removeBackdrop(dlg) {
const backdrop = dlg.backdrop;
if (!backdrop) {
return;
}
dlg.backdrop = null;
const onAnimationFinish = () => {
tryRemoveElement(backdrop);
};
if (enableAnimation()) {
backdrop.classList.remove('dialogBackdropOpened');
// this is not firing animationend
setTimeout(onAnimationFinish, 300);
return;
}
onAnimationFinish();
}
function centerFocus(elem, horiz, on) {
import('../../scripts/scrollHelper').then((scrollHelper) => {
const fn = on ? 'on' : 'off';
scrollHelper.centerFocus[fn](elem, horiz);
});
}
export function createDialog(options = {}) {
// If there's no native dialog support, use a plain div
// Also not working well in samsung tizen browser, content inside not clickable
// Just go ahead and always use a plain div because we're seeing issues overlaying absoltutely positioned content over a modal dialog
const dlg = document.createElement('div');
// Add an id so we can access the dialog element
if (options.id) {
dlg.id = options.id;
}
dlg.classList.add('focuscontainer');
dlg.classList.add('hide');
if (shouldLockDocumentScroll(options)) {
dlg.setAttribute('data-lockscroll', 'true');
}
if (options.enableHistory !== false) {
dlg.setAttribute('data-history', 'true');
}
// without this safari will scroll the background instead of the dialog contents
// but not needed here since this is already on top of an existing dialog
// but skip it in IE because it's causing the entire browser to hang
// Also have to disable for firefox because it's causing select elements to not be clickable
if (options.modal !== false) {
dlg.setAttribute('modal', 'modal');
}
if (options.autoFocus !== false) {
dlg.setAttribute('data-autofocus', 'true');
}
const defaultEntryAnimation = 'scaleup';
const defaultExitAnimation = 'scaledown';
const entryAnimation = options.entryAnimation || defaultEntryAnimation;
const exitAnimation = options.exitAnimation || defaultExitAnimation;
// If it's not fullscreen then lower the default animation speed to make it open really fast
const entryAnimationDuration = options.entryAnimationDuration || (options.size !== 'fullscreen' ? 180 : 280);
const exitAnimationDuration = options.exitAnimationDuration || (options.size !== 'fullscreen' ? 120 : 220);
dlg.animationConfig = {
// scale up
'entry': {
name: entryAnimation,
timing: {
duration: entryAnimationDuration,
easing: 'ease-out'
}
dom.addEventListener(
dlg,
dom.whichAnimationEvent(),
getAnimationEndHandler(dlg, onAnimationFinish),
{ once: true });
if (animated) {
return;
},
// fade out
'exit': {
name: exitAnimation,
timing: {
duration: exitAnimationDuration,
easing: 'ease-out',
fill: 'both'
}
}
};
onAnimationFinish();
dlg.classList.add('dialog');
if (options.scrollX) {
dlg.classList.add('scrollX');
dlg.classList.add('smoothScrollX');
if (layoutManager.tv) {
centerFocus(dlg, true, true);
}
} else if (options.scrollY !== false) {
dlg.classList.add('smoothScrollY');
if (layoutManager.tv) {
centerFocus(dlg, false, true);
}
}
const supportsOverscrollBehavior = 'overscroll-behavior-y' in document.body.style;
function shouldLockDocumentScroll(options) {
if (options.lockScroll != null) {
return options.lockScroll;
}
if (options.size === 'fullscreen') {
return true;
}
if (supportsOverscrollBehavior && (options.size || !browser.touch)) {
return false;
}
if (options.size) {
return true;
}
return browser.touch;
if (options.removeOnClose) {
dlg.setAttribute('data-removeonclose', 'true');
}
function removeBackdrop(dlg) {
const backdrop = dlg.backdrop;
if (!backdrop) {
return;
}
dlg.backdrop = null;
const onAnimationFinish = () => {
tryRemoveElement(backdrop);
};
if (enableAnimation()) {
backdrop.classList.remove('dialogBackdropOpened');
// this is not firing animationend
setTimeout(onAnimationFinish, 300);
return;
}
onAnimationFinish();
if (options.size) {
dlg.classList.add('dialog-fixedSize');
dlg.classList.add(`dialog-${options.size}`);
}
function centerFocus(elem, horiz, on) {
import('../../scripts/scrollHelper').then((scrollHelper) => {
const fn = on ? 'on' : 'off';
scrollHelper.centerFocus[fn](elem, horiz);
});
if (enableAnimation()) {
switch (dlg.animationConfig.entry.name) {
case 'fadein':
dlg.style.animation = `fadein ${entryAnimationDuration}ms ease-out normal`;
break;
case 'scaleup':
dlg.style.animation = `scaleup ${entryAnimationDuration}ms ease-out normal both`;
break;
case 'slideup':
dlg.style.animation = `slideup ${entryAnimationDuration}ms ease-out normal`;
break;
case 'slidedown':
dlg.style.animation = `slidedown ${entryAnimationDuration}ms ease-out normal`;
break;
default:
break;
}
}
export function createDialog(options = {}) {
// If there's no native dialog support, use a plain div
// Also not working well in samsung tizen browser, content inside not clickable
// Just go ahead and always use a plain div because we're seeing issues overlaying absoltutely positioned content over a modal dialog
const dlg = document.createElement('div');
return dlg;
}
// Add an id so we can access the dialog element
if (options.id) {
dlg.id = options.id;
}
dlg.classList.add('focuscontainer');
dlg.classList.add('hide');
if (shouldLockDocumentScroll(options)) {
dlg.setAttribute('data-lockscroll', 'true');
}
if (options.enableHistory !== false) {
dlg.setAttribute('data-history', 'true');
}
// without this safari will scroll the background instead of the dialog contents
// but not needed here since this is already on top of an existing dialog
// but skip it in IE because it's causing the entire browser to hang
// Also have to disable for firefox because it's causing select elements to not be clickable
if (options.modal !== false) {
dlg.setAttribute('modal', 'modal');
}
if (options.autoFocus !== false) {
dlg.setAttribute('data-autofocus', 'true');
}
const defaultEntryAnimation = 'scaleup';
const defaultExitAnimation = 'scaledown';
const entryAnimation = options.entryAnimation || defaultEntryAnimation;
const exitAnimation = options.exitAnimation || defaultExitAnimation;
// If it's not fullscreen then lower the default animation speed to make it open really fast
const entryAnimationDuration = options.entryAnimationDuration || (options.size !== 'fullscreen' ? 180 : 280);
const exitAnimationDuration = options.exitAnimationDuration || (options.size !== 'fullscreen' ? 120 : 220);
dlg.animationConfig = {
// scale up
'entry': {
name: entryAnimation,
timing: {
duration: entryAnimationDuration,
easing: 'ease-out'
}
},
// fade out
'exit': {
name: exitAnimation,
timing: {
duration: exitAnimationDuration,
easing: 'ease-out',
fill: 'both'
}
}
};
dlg.classList.add('dialog');
if (options.scrollX) {
dlg.classList.add('scrollX');
dlg.classList.add('smoothScrollX');
if (layoutManager.tv) {
centerFocus(dlg, true, true);
}
} else if (options.scrollY !== false) {
dlg.classList.add('smoothScrollY');
if (layoutManager.tv) {
centerFocus(dlg, false, true);
}
}
if (options.removeOnClose) {
dlg.setAttribute('data-removeonclose', 'true');
}
if (options.size) {
dlg.classList.add('dialog-fixedSize');
dlg.classList.add(`dialog-${options.size}`);
}
if (enableAnimation()) {
switch (dlg.animationConfig.entry.name) {
case 'fadein':
dlg.style.animation = `fadein ${entryAnimationDuration}ms ease-out normal`;
break;
case 'scaleup':
dlg.style.animation = `scaleup ${entryAnimationDuration}ms ease-out normal both`;
break;
case 'slideup':
dlg.style.animation = `slideup ${entryAnimationDuration}ms ease-out normal`;
break;
case 'slidedown':
dlg.style.animation = `slidedown ${entryAnimationDuration}ms ease-out normal`;
break;
default:
break;
}
}
return dlg;
}
export function setOnOpen(val) {
globalOnOpenCallback = val;
}
/* eslint-enable indent */
export function setOnOpen(val) {
globalOnOpenCallback = val;
}
export default {
open: open,

View file

@ -43,7 +43,7 @@ function refreshDirectoryBrowser(page, path, fileOptions, updatePathOnError) {
Promise.all(promises).then(
responses => {
const folders = responses[0];
const parentPath = responses[1] || '';
const parentPath = (responses[1] ? JSON.parse(responses[1]) : '') || '';
let html = '';
page.querySelector('.results').scrollTop = 0;

View file

@ -8,6 +8,7 @@ import datetime from '../../scripts/datetime';
import globalize from '../../scripts/globalize';
import loading from '../loading/loading';
import skinManager from '../../scripts/themeManager';
import { PluginType } from '../../types/plugin.ts';
import Events from '../../utils/events.ts';
import '../../elements/emby-select/emby-select';
import '../../elements/emby-checkbox/emby-checkbox';
@ -17,238 +18,235 @@ import ServerConnections from '../ServerConnections';
import toast from '../toast/toast';
import template from './displaySettings.template.html';
/* eslint-disable indent */
function fillThemes(select, selectedTheme) {
skinManager.getThemes().then(themes => {
select.innerHTML = themes.map(t => {
return `<option value="${t.id}">${escapeHtml(t.name)}</option>`;
}).join('');
// get default theme
const defaultTheme = themes.find(theme => theme.default);
// set the current theme
select.value = selectedTheme || defaultTheme.id;
});
}
function loadScreensavers(context, userSettings) {
const selectScreensaver = context.querySelector('.selectScreensaver');
const options = pluginManager.ofType('screensaver').map(plugin => {
return {
name: plugin.name,
value: plugin.id
};
});
options.unshift({
name: globalize.translate('None'),
value: 'none'
});
selectScreensaver.innerHTML = options.map(o => {
return `<option value="${o.value}">${escapeHtml(o.name)}</option>`;
function fillThemes(select, selectedTheme) {
skinManager.getThemes().then(themes => {
select.innerHTML = themes.map(t => {
return `<option value="${t.id}">${escapeHtml(t.name)}</option>`;
}).join('');
selectScreensaver.value = userSettings.screensaver();
// get default theme
const defaultTheme = themes.find(theme => theme.default);
if (!selectScreensaver.value) {
// TODO: set the default instead of none
selectScreensaver.value = 'none';
}
// set the current theme
select.value = selectedTheme || defaultTheme.id;
});
}
function loadScreensavers(context, userSettings) {
const selectScreensaver = context.querySelector('.selectScreensaver');
const options = pluginManager.ofType(PluginType.Screensaver).map(plugin => {
return {
name: plugin.name,
value: plugin.id
};
});
options.unshift({
name: globalize.translate('None'),
value: 'none'
});
selectScreensaver.innerHTML = options.map(o => {
return `<option value="${o.value}">${escapeHtml(o.name)}</option>`;
}).join('');
selectScreensaver.value = userSettings.screensaver();
if (!selectScreensaver.value) {
// TODO: set the default instead of none
selectScreensaver.value = 'none';
}
}
function showOrHideMissingEpisodesField(context) {
if (browser.tizen || browser.web0s) {
context.querySelector('.fldDisplayMissingEpisodes').classList.add('hide');
return;
}
function showOrHideMissingEpisodesField(context) {
if (browser.tizen || browser.web0s) {
context.querySelector('.fldDisplayMissingEpisodes').classList.add('hide');
return;
}
context.querySelector('.fldDisplayMissingEpisodes').classList.remove('hide');
}
context.querySelector('.fldDisplayMissingEpisodes').classList.remove('hide');
function loadForm(context, user, userSettings) {
if (appHost.supports('displaylanguage')) {
context.querySelector('.languageSection').classList.remove('hide');
} else {
context.querySelector('.languageSection').classList.add('hide');
}
function loadForm(context, user, userSettings) {
if (appHost.supports('displaylanguage')) {
context.querySelector('.languageSection').classList.remove('hide');
} else {
context.querySelector('.languageSection').classList.add('hide');
}
if (appHost.supports('displaymode')) {
context.querySelector('.fldDisplayMode').classList.remove('hide');
} else {
context.querySelector('.fldDisplayMode').classList.add('hide');
}
if (appHost.supports('externallinks')) {
context.querySelector('.learnHowToContributeContainer').classList.remove('hide');
} else {
context.querySelector('.learnHowToContributeContainer').classList.add('hide');
}
context.querySelector('.selectDashboardThemeContainer').classList.toggle('hide', !user.Policy.IsAdministrator);
if (appHost.supports('screensaver')) {
context.querySelector('.selectScreensaverContainer').classList.remove('hide');
} else {
context.querySelector('.selectScreensaverContainer').classList.add('hide');
}
if (datetime.supportsLocalization()) {
context.querySelector('.fldDateTimeLocale').classList.remove('hide');
} else {
context.querySelector('.fldDateTimeLocale').classList.add('hide');
}
fillThemes(context.querySelector('#selectTheme'), userSettings.theme());
fillThemes(context.querySelector('#selectDashboardTheme'), userSettings.dashboardTheme());
loadScreensavers(context, userSettings);
context.querySelector('.chkDisplayMissingEpisodes').checked = user.Configuration.DisplayMissingEpisodes || false;
context.querySelector('#chkThemeSong').checked = userSettings.enableThemeSongs();
context.querySelector('#chkThemeVideo').checked = userSettings.enableThemeVideos();
context.querySelector('#chkFadein').checked = userSettings.enableFastFadein();
context.querySelector('#chkBlurhash').checked = userSettings.enableBlurhash();
context.querySelector('#chkBackdrops').checked = userSettings.enableBackdrops();
context.querySelector('#chkDetailsBanner').checked = userSettings.detailsBanner();
context.querySelector('#chkDisableCustomCss').checked = userSettings.disableCustomCss();
context.querySelector('#txtLocalCustomCss').value = userSettings.customCss();
context.querySelector('#selectLanguage').value = userSettings.language() || '';
context.querySelector('.selectDateTimeLocale').value = userSettings.dateTimeLocale() || '';
context.querySelector('#txtLibraryPageSize').value = userSettings.libraryPageSize();
context.querySelector('#txtMaxDaysForNextUp').value = userSettings.maxDaysForNextUp();
context.querySelector('#chkRewatchingNextUp').checked = userSettings.enableRewatchingInNextUp();
context.querySelector('#chkUseEpisodeImagesInNextUp').checked = userSettings.useEpisodeImagesInNextUpAndResume();
context.querySelector('.selectLayout').value = layoutManager.getSavedLayout() || '';
showOrHideMissingEpisodesField(context);
loading.hide();
if (appHost.supports('displaymode')) {
context.querySelector('.fldDisplayMode').classList.remove('hide');
} else {
context.querySelector('.fldDisplayMode').classList.add('hide');
}
function saveUser(context, user, userSettingsInstance, apiClient) {
user.Configuration.DisplayMissingEpisodes = context.querySelector('.chkDisplayMissingEpisodes').checked;
if (appHost.supports('displaylanguage')) {
userSettingsInstance.language(context.querySelector('#selectLanguage').value);
}
userSettingsInstance.dateTimeLocale(context.querySelector('.selectDateTimeLocale').value);
userSettingsInstance.enableThemeSongs(context.querySelector('#chkThemeSong').checked);
userSettingsInstance.enableThemeVideos(context.querySelector('#chkThemeVideo').checked);
userSettingsInstance.theme(context.querySelector('#selectTheme').value);
userSettingsInstance.dashboardTheme(context.querySelector('#selectDashboardTheme').value);
userSettingsInstance.screensaver(context.querySelector('.selectScreensaver').value);
userSettingsInstance.libraryPageSize(context.querySelector('#txtLibraryPageSize').value);
userSettingsInstance.maxDaysForNextUp(context.querySelector('#txtMaxDaysForNextUp').value);
userSettingsInstance.enableRewatchingInNextUp(context.querySelector('#chkRewatchingNextUp').checked);
userSettingsInstance.useEpisodeImagesInNextUpAndResume(context.querySelector('#chkUseEpisodeImagesInNextUp').checked);
userSettingsInstance.enableFastFadein(context.querySelector('#chkFadein').checked);
userSettingsInstance.enableBlurhash(context.querySelector('#chkBlurhash').checked);
userSettingsInstance.enableBackdrops(context.querySelector('#chkBackdrops').checked);
userSettingsInstance.detailsBanner(context.querySelector('#chkDetailsBanner').checked);
userSettingsInstance.disableCustomCss(context.querySelector('#chkDisableCustomCss').checked);
userSettingsInstance.customCss(context.querySelector('#txtLocalCustomCss').value);
if (user.Id === apiClient.getCurrentUserId()) {
skinManager.setTheme(userSettingsInstance.theme());
}
layoutManager.setLayout(context.querySelector('.selectLayout').value);
return apiClient.updateUserConfiguration(user.Id, user.Configuration);
if (appHost.supports('externallinks')) {
context.querySelector('.learnHowToContributeContainer').classList.remove('hide');
} else {
context.querySelector('.learnHowToContributeContainer').classList.add('hide');
}
function save(instance, context, userId, userSettings, apiClient, enableSaveConfirmation) {
context.querySelector('.selectDashboardThemeContainer').classList.toggle('hide', !user.Policy.IsAdministrator);
if (appHost.supports('screensaver')) {
context.querySelector('.selectScreensaverContainer').classList.remove('hide');
} else {
context.querySelector('.selectScreensaverContainer').classList.add('hide');
}
if (datetime.supportsLocalization()) {
context.querySelector('.fldDateTimeLocale').classList.remove('hide');
} else {
context.querySelector('.fldDateTimeLocale').classList.add('hide');
}
fillThemes(context.querySelector('#selectTheme'), userSettings.theme());
fillThemes(context.querySelector('#selectDashboardTheme'), userSettings.dashboardTheme());
loadScreensavers(context, userSettings);
context.querySelector('.chkDisplayMissingEpisodes').checked = user.Configuration.DisplayMissingEpisodes || false;
context.querySelector('#chkThemeSong').checked = userSettings.enableThemeSongs();
context.querySelector('#chkThemeVideo').checked = userSettings.enableThemeVideos();
context.querySelector('#chkFadein').checked = userSettings.enableFastFadein();
context.querySelector('#chkBlurhash').checked = userSettings.enableBlurhash();
context.querySelector('#chkBackdrops').checked = userSettings.enableBackdrops();
context.querySelector('#chkDetailsBanner').checked = userSettings.detailsBanner();
context.querySelector('#chkDisableCustomCss').checked = userSettings.disableCustomCss();
context.querySelector('#txtLocalCustomCss').value = userSettings.customCss();
context.querySelector('#selectLanguage').value = userSettings.language() || '';
context.querySelector('.selectDateTimeLocale').value = userSettings.dateTimeLocale() || '';
context.querySelector('#txtLibraryPageSize').value = userSettings.libraryPageSize();
context.querySelector('#txtMaxDaysForNextUp').value = userSettings.maxDaysForNextUp();
context.querySelector('#chkRewatchingNextUp').checked = userSettings.enableRewatchingInNextUp();
context.querySelector('#chkUseEpisodeImagesInNextUp').checked = userSettings.useEpisodeImagesInNextUpAndResume();
context.querySelector('.selectLayout').value = layoutManager.getSavedLayout() || '';
showOrHideMissingEpisodesField(context);
loading.hide();
}
function saveUser(context, user, userSettingsInstance, apiClient) {
user.Configuration.DisplayMissingEpisodes = context.querySelector('.chkDisplayMissingEpisodes').checked;
if (appHost.supports('displaylanguage')) {
userSettingsInstance.language(context.querySelector('#selectLanguage').value);
}
userSettingsInstance.dateTimeLocale(context.querySelector('.selectDateTimeLocale').value);
userSettingsInstance.enableThemeSongs(context.querySelector('#chkThemeSong').checked);
userSettingsInstance.enableThemeVideos(context.querySelector('#chkThemeVideo').checked);
userSettingsInstance.theme(context.querySelector('#selectTheme').value);
userSettingsInstance.dashboardTheme(context.querySelector('#selectDashboardTheme').value);
userSettingsInstance.screensaver(context.querySelector('.selectScreensaver').value);
userSettingsInstance.libraryPageSize(context.querySelector('#txtLibraryPageSize').value);
userSettingsInstance.maxDaysForNextUp(context.querySelector('#txtMaxDaysForNextUp').value);
userSettingsInstance.enableRewatchingInNextUp(context.querySelector('#chkRewatchingNextUp').checked);
userSettingsInstance.useEpisodeImagesInNextUpAndResume(context.querySelector('#chkUseEpisodeImagesInNextUp').checked);
userSettingsInstance.enableFastFadein(context.querySelector('#chkFadein').checked);
userSettingsInstance.enableBlurhash(context.querySelector('#chkBlurhash').checked);
userSettingsInstance.enableBackdrops(context.querySelector('#chkBackdrops').checked);
userSettingsInstance.detailsBanner(context.querySelector('#chkDetailsBanner').checked);
userSettingsInstance.disableCustomCss(context.querySelector('#chkDisableCustomCss').checked);
userSettingsInstance.customCss(context.querySelector('#txtLocalCustomCss').value);
if (user.Id === apiClient.getCurrentUserId()) {
skinManager.setTheme(userSettingsInstance.theme());
}
layoutManager.setLayout(context.querySelector('.selectLayout').value);
return apiClient.updateUserConfiguration(user.Id, user.Configuration);
}
function save(instance, context, userId, userSettings, apiClient, enableSaveConfirmation) {
loading.show();
apiClient.getUser(userId).then(user => {
saveUser(context, user, userSettings, apiClient).then(() => {
loading.hide();
if (enableSaveConfirmation) {
toast(globalize.translate('SettingsSaved'));
}
Events.trigger(instance, 'saved');
}, () => {
loading.hide();
});
});
}
function onSubmit(e) {
const self = this;
const apiClient = ServerConnections.getApiClient(self.options.serverId);
const userId = self.options.userId;
const userSettings = self.options.userSettings;
userSettings.setUserInfo(userId, apiClient).then(() => {
const enableSaveConfirmation = self.options.enableSaveConfirmation;
save(self, self.options.element, userId, userSettings, apiClient, enableSaveConfirmation);
});
// Disable default form submission
if (e) {
e.preventDefault();
}
return false;
}
function embed(options, self) {
options.element.innerHTML = globalize.translateHtml(template, 'core');
options.element.querySelector('form').addEventListener('submit', onSubmit.bind(self));
if (options.enableSaveButton) {
options.element.querySelector('.btnSave').classList.remove('hide');
}
self.loadData(options.autoFocus);
}
class DisplaySettings {
constructor(options) {
this.options = options;
embed(options, this);
}
loadData(autoFocus) {
const self = this;
const context = self.options.element;
loading.show();
apiClient.getUser(userId).then(user => {
saveUser(context, user, userSettings, apiClient).then(() => {
loading.hide();
if (enableSaveConfirmation) {
toast(globalize.translate('SettingsSaved'));
}
Events.trigger(instance, 'saved');
}, () => {
loading.hide();
});
});
}
function onSubmit(e) {
const self = this;
const apiClient = ServerConnections.getApiClient(self.options.serverId);
const userId = self.options.userId;
const apiClient = ServerConnections.getApiClient(self.options.serverId);
const userSettings = self.options.userSettings;
userSettings.setUserInfo(userId, apiClient).then(() => {
const enableSaveConfirmation = self.options.enableSaveConfirmation;
save(self, self.options.element, userId, userSettings, apiClient, enableSaveConfirmation);
});
// Disable default form submission
if (e) {
e.preventDefault();
}
return false;
}
function embed(options, self) {
options.element.innerHTML = globalize.translateHtml(template, 'core');
options.element.querySelector('form').addEventListener('submit', onSubmit.bind(self));
if (options.enableSaveButton) {
options.element.querySelector('.btnSave').classList.remove('hide');
}
self.loadData(options.autoFocus);
}
class DisplaySettings {
constructor(options) {
this.options = options;
embed(options, this);
}
loadData(autoFocus) {
const self = this;
const context = self.options.element;
loading.show();
const userId = self.options.userId;
const apiClient = ServerConnections.getApiClient(self.options.serverId);
const userSettings = self.options.userSettings;
return apiClient.getUser(userId).then(user => {
return userSettings.setUserInfo(userId, apiClient).then(() => {
self.dataLoaded = true;
loadForm(context, user, userSettings);
if (autoFocus) {
focusManager.autoFocus(context);
}
});
return apiClient.getUser(userId).then(user => {
return userSettings.setUserInfo(userId, apiClient).then(() => {
self.dataLoaded = true;
loadForm(context, user, userSettings);
if (autoFocus) {
focusManager.autoFocus(context);
}
});
}
submit() {
onSubmit.call(this);
}
destroy() {
this.options = null;
}
});
}
/* eslint-enable indent */
submit() {
onSubmit.call(this);
}
destroy() {
this.options = null;
}
}
export default DisplaySettings;

View file

@ -172,6 +172,7 @@
<option value="desktop">${Desktop}</option>
<option value="mobile">${Mobile}</option>
<option value="tv">${TV}</option>
<option value="experimental">${Experimental}</option>
</select>
<div class="fieldDescription">${DisplayModeHelp}</div>
<div class="fieldDescription">${LabelPleaseRestart}</div>

View file

@ -6,239 +6,235 @@ import imageLoader from './images/imageLoader';
import globalize from '../scripts/globalize';
import layoutManager from './layoutManager';
import { getParameterByName } from '../utils/url.ts';
import '../assets/css/scrollstyles.scss';
import '../styles/scrollstyles.scss';
import '../elements/emby-itemscontainer/emby-itemscontainer';
/* eslint-disable indent */
function enableScrollX() {
return !layoutManager.desktop;
}
function enableScrollX() {
return !layoutManager.desktop;
function getThumbShape() {
return enableScrollX() ? 'overflowBackdrop' : 'backdrop';
}
function getPosterShape() {
return enableScrollX() ? 'overflowPortrait' : 'portrait';
}
function getSquareShape() {
return enableScrollX() ? 'overflowSquare' : 'square';
}
function getSections() {
return [{
name: 'Movies',
types: 'Movie',
id: 'favoriteMovies',
shape: getPosterShape(),
showTitle: false,
overlayPlayButton: true
}, {
name: 'Shows',
types: 'Series',
id: 'favoriteShows',
shape: getPosterShape(),
showTitle: false,
overlayPlayButton: true
}, {
name: 'Episodes',
types: 'Episode',
id: 'favoriteEpisode',
shape: getThumbShape(),
preferThumb: false,
showTitle: true,
showParentTitle: true,
overlayPlayButton: true,
overlayText: false,
centerText: true
}, {
name: 'Videos',
types: 'Video,MusicVideo',
id: 'favoriteVideos',
shape: getThumbShape(),
preferThumb: true,
showTitle: true,
overlayPlayButton: true,
overlayText: false,
centerText: true
}, {
name: 'Artists',
types: 'MusicArtist',
id: 'favoriteArtists',
shape: getSquareShape(),
preferThumb: false,
showTitle: true,
overlayText: false,
showParentTitle: false,
centerText: true,
overlayPlayButton: true,
coverImage: true
}, {
name: 'Albums',
types: 'MusicAlbum',
id: 'favoriteAlbums',
shape: getSquareShape(),
preferThumb: false,
showTitle: true,
overlayText: false,
showParentTitle: true,
centerText: true,
overlayPlayButton: true,
coverImage: true
}, {
name: 'Songs',
types: 'Audio',
id: 'favoriteSongs',
shape: getSquareShape(),
preferThumb: false,
showTitle: true,
overlayText: false,
showParentTitle: true,
centerText: true,
overlayMoreButton: true,
action: 'instantmix',
coverImage: true
}];
}
function loadSection(elem, userId, topParentId, section, isSingleSection) {
const screenWidth = dom.getWindowSize().innerWidth;
const options = {
SortBy: 'SortName',
SortOrder: 'Ascending',
Filters: 'IsFavorite',
Recursive: true,
Fields: 'PrimaryImageAspectRatio,BasicSyncInfo',
CollapseBoxSetItems: false,
ExcludeLocationTypes: 'Virtual',
EnableTotalRecordCount: false
};
if (topParentId) {
options.ParentId = topParentId;
}
function getThumbShape() {
return enableScrollX() ? 'overflowBackdrop' : 'backdrop';
}
if (!isSingleSection) {
options.Limit = 6;
function getPosterShape() {
return enableScrollX() ? 'overflowPortrait' : 'portrait';
}
function getSquareShape() {
return enableScrollX() ? 'overflowSquare' : 'square';
}
function getSections() {
return [{
name: 'Movies',
types: 'Movie',
id: 'favoriteMovies',
shape: getPosterShape(),
showTitle: false,
overlayPlayButton: true
}, {
name: 'Shows',
types: 'Series',
id: 'favoriteShows',
shape: getPosterShape(),
showTitle: false,
overlayPlayButton: true
}, {
name: 'Episodes',
types: 'Episode',
id: 'favoriteEpisode',
shape: getThumbShape(),
preferThumb: false,
showTitle: true,
showParentTitle: true,
overlayPlayButton: true,
overlayText: false,
centerText: true
}, {
name: 'Videos',
types: 'Video,MusicVideo',
id: 'favoriteVideos',
shape: getThumbShape(),
preferThumb: true,
showTitle: true,
overlayPlayButton: true,
overlayText: false,
centerText: true
}, {
name: 'Artists',
types: 'MusicArtist',
id: 'favoriteArtists',
shape: getSquareShape(),
preferThumb: false,
showTitle: true,
overlayText: false,
showParentTitle: false,
centerText: true,
overlayPlayButton: true,
coverImage: true
}, {
name: 'Albums',
types: 'MusicAlbum',
id: 'favoriteAlbums',
shape: getSquareShape(),
preferThumb: false,
showTitle: true,
overlayText: false,
showParentTitle: true,
centerText: true,
overlayPlayButton: true,
coverImage: true
}, {
name: 'Songs',
types: 'Audio',
id: 'favoriteSongs',
shape: getSquareShape(),
preferThumb: false,
showTitle: true,
overlayText: false,
showParentTitle: true,
centerText: true,
overlayMoreButton: true,
action: 'instantmix',
coverImage: true
}];
}
function loadSection(elem, userId, topParentId, section, isSingleSection) {
const screenWidth = dom.getWindowSize().innerWidth;
const options = {
SortBy: 'SortName',
SortOrder: 'Ascending',
Filters: 'IsFavorite',
Recursive: true,
Fields: 'PrimaryImageAspectRatio,BasicSyncInfo',
CollapseBoxSetItems: false,
ExcludeLocationTypes: 'Virtual',
EnableTotalRecordCount: false
};
if (topParentId) {
options.ParentId = topParentId;
if (enableScrollX()) {
options.Limit = 20;
} else if (screenWidth >= 1920) {
options.Limit = 10;
} else if (screenWidth >= 1440) {
options.Limit = 8;
}
}
if (!isSingleSection) {
options.Limit = 6;
let promise;
if (section.types === 'MusicArtist') {
promise = ApiClient.getArtists(userId, options);
} else {
options.IncludeItemTypes = section.types;
promise = ApiClient.getItems(userId, options);
}
return promise.then(function (result) {
let html = '';
if (result.Items.length) {
html += '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">';
if (!layoutManager.tv && options.Limit && result.Items.length >= options.Limit) {
html += '<a is="emby-linkbutton" href="' + ('#/list.html?serverId=' + ApiClient.serverId() + '&type=' + section.types + '&IsFavorite=true') + '" class="more button-flat button-flat-mini sectionTitleTextButton">';
html += '<h2 class="sectionTitle sectionTitle-cards">';
html += globalize.translate(section.name);
html += '</h2>';
html += '<span class="material-icons chevron_right" aria-hidden="true"></span>';
html += '</a>';
} else {
html += '<h2 class="sectionTitle sectionTitle-cards">' + globalize.translate(section.name) + '</h2>';
}
html += '</div>';
if (enableScrollX()) {
options.Limit = 20;
} else if (screenWidth >= 1920) {
options.Limit = 10;
} else if (screenWidth >= 1440) {
options.Limit = 8;
}
}
let promise;
if (section.types === 'MusicArtist') {
promise = ApiClient.getArtists(userId, options);
} else {
options.IncludeItemTypes = section.types;
promise = ApiClient.getItems(userId, options);
}
return promise.then(function (result) {
let html = '';
if (result.Items.length) {
html += '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">';
if (!layoutManager.tv && options.Limit && result.Items.length >= options.Limit) {
html += '<a is="emby-linkbutton" href="' + ('#/list.html?serverId=' + ApiClient.serverId() + '&type=' + section.types + '&IsFavorite=true') + '" class="more button-flat button-flat-mini sectionTitleTextButton">';
html += '<h2 class="sectionTitle sectionTitle-cards">';
html += globalize.translate(section.name);
html += '</h2>';
html += '<span class="material-icons chevron_right" aria-hidden="true"></span>';
html += '</a>';
} else {
html += '<h2 class="sectionTitle sectionTitle-cards">' + globalize.translate(section.name) + '</h2>';
let scrollXClass = 'scrollX hiddenScrollX';
if (layoutManager.tv) {
scrollXClass += ' smoothScrollX';
}
html += '</div>';
if (enableScrollX()) {
let scrollXClass = 'scrollX hiddenScrollX';
if (layoutManager.tv) {
scrollXClass += ' smoothScrollX';
}
html += '<div is="emby-itemscontainer" class="itemsContainer ' + scrollXClass + ' padded-left padded-right">';
} else {
html += '<div is="emby-itemscontainer" class="itemsContainer vertical-wrap padded-left padded-right">';
}
let cardLayout = appHost.preferVisualCards && section.autoCardLayout && section.showTitle;
cardLayout = false;
html += cardBuilder.getCardsHtml(result.Items, {
preferThumb: section.preferThumb,
shape: section.shape,
centerText: section.centerText && !cardLayout,
overlayText: section.overlayText !== false,
showTitle: section.showTitle,
showParentTitle: section.showParentTitle,
scalable: true,
coverImage: section.coverImage,
overlayPlayButton: section.overlayPlayButton,
overlayMoreButton: section.overlayMoreButton && !cardLayout,
action: section.action,
allowBottomPadding: !enableScrollX(),
cardLayout: cardLayout
});
html += '</div>';
html += '<div is="emby-itemscontainer" class="itemsContainer ' + scrollXClass + ' padded-left padded-right">';
} else {
html += '<div is="emby-itemscontainer" class="itemsContainer vertical-wrap padded-left padded-right">';
}
elem.innerHTML = html;
imageLoader.lazyChildren(elem);
let cardLayout = appHost.preferVisualCards && section.autoCardLayout && section.showTitle;
cardLayout = false;
html += cardBuilder.getCardsHtml(result.Items, {
preferThumb: section.preferThumb,
shape: section.shape,
centerText: section.centerText && !cardLayout,
overlayText: section.overlayText !== false,
showTitle: section.showTitle,
showParentTitle: section.showParentTitle,
scalable: true,
coverImage: section.coverImage,
overlayPlayButton: section.overlayPlayButton,
overlayMoreButton: section.overlayMoreButton && !cardLayout,
action: section.action,
allowBottomPadding: !enableScrollX(),
cardLayout: cardLayout
});
html += '</div>';
}
elem.innerHTML = html;
imageLoader.lazyChildren(elem);
});
}
export function loadSections(page, userId, topParentId, types) {
loading.show();
let sections = getSections();
const sectionid = getParameterByName('sectionid');
if (sectionid) {
sections = sections.filter(function (s) {
return s.id === sectionid;
});
}
export function loadSections(page, userId, topParentId, types) {
loading.show();
let sections = getSections();
const sectionid = getParameterByName('sectionid');
if (types) {
sections = sections.filter(function (s) {
return types.indexOf(s.id) !== -1;
});
}
if (sectionid) {
sections = sections.filter(function (s) {
return s.id === sectionid;
});
}
let elem = page.querySelector('.favoriteSections');
if (types) {
sections = sections.filter(function (s) {
return types.indexOf(s.id) !== -1;
});
}
let elem = page.querySelector('.favoriteSections');
if (!elem.innerHTML) {
let html = '';
for (let i = 0, length = sections.length; i < length; i++) {
html += '<div class="verticalSection section' + sections[i].id + '"></div>';
}
elem.innerHTML = html;
}
const promises = [];
if (!elem.innerHTML) {
let html = '';
for (let i = 0, length = sections.length; i < length; i++) {
const section = sections[i];
elem = page.querySelector('.section' + section.id);
promises.push(loadSection(elem, userId, topParentId, section, sections.length === 1));
html += '<div class="verticalSection section' + sections[i].id + '"></div>';
}
Promise.all(promises).then(function () {
loading.hide();
});
elem.innerHTML = html;
}
const promises = [];
for (let i = 0, length = sections.length; i < length; i++) {
const section = sections[i];
elem = page.querySelector('.section' + section.id);
promises.push(loadSection(elem, userId, topParentId, section, sections.length === 1));
}
Promise.all(promises).then(function () {
loading.hide();
});
}
export default {
render: loadSections
};
/* eslint-enable indent */

View file

@ -1,110 +1,108 @@
/* eslint-disable indent */
export function getFetchPromise(request) {
const headers = request.headers || {};
export function getFetchPromise(request) {
const headers = request.headers || {};
if (request.dataType === 'json') {
headers.accept = 'application/json';
}
const fetchRequest = {
headers: headers,
method: request.type,
credentials: 'same-origin'
};
let contentType = request.contentType;
if (request.data) {
if (typeof request.data === 'string') {
fetchRequest.body = request.data;
} else {
fetchRequest.body = paramsToString(request.data);
contentType = contentType || 'application/x-www-form-urlencoded; charset=UTF-8';
}
}
if (contentType) {
headers['Content-Type'] = contentType;
}
let url = request.url;
if (request.query) {
const paramString = paramsToString(request.query);
if (paramString) {
url += `?${paramString}`;
}
}
if (!request.timeout) {
return fetch(url, fetchRequest);
}
return fetchWithTimeout(url, fetchRequest, request.timeout);
if (request.dataType === 'json') {
headers.accept = 'application/json';
}
function fetchWithTimeout(url, options, timeoutMs) {
console.debug(`fetchWithTimeout: timeoutMs: ${timeoutMs}, url: ${url}`);
const fetchRequest = {
headers: headers,
method: request.type,
credentials: 'same-origin'
};
return new Promise(function (resolve, reject) {
const timeout = setTimeout(reject, timeoutMs);
let contentType = request.contentType;
options = options || {};
options.credentials = 'same-origin';
if (request.data) {
if (typeof request.data === 'string') {
fetchRequest.body = request.data;
} else {
fetchRequest.body = paramsToString(request.data);
fetch(url, options).then(function (response) {
clearTimeout(timeout);
contentType = contentType || 'application/x-www-form-urlencoded; charset=UTF-8';
}
}
console.debug(`fetchWithTimeout: succeeded connecting to url: ${url}`);
if (contentType) {
headers['Content-Type'] = contentType;
}
resolve(response);
}, function (error) {
clearTimeout(timeout);
let url = request.url;
console.debug(`fetchWithTimeout: timed out connecting to url: ${url}`);
if (request.query) {
const paramString = paramsToString(request.query);
if (paramString) {
url += `?${paramString}`;
}
}
reject(error);
});
if (!request.timeout) {
return fetch(url, fetchRequest);
}
return fetchWithTimeout(url, fetchRequest, request.timeout);
}
function fetchWithTimeout(url, options, timeoutMs) {
console.debug(`fetchWithTimeout: timeoutMs: ${timeoutMs}, url: ${url}`);
return new Promise(function (resolve, reject) {
const timeout = setTimeout(reject, timeoutMs);
options = options || {};
options.credentials = 'same-origin';
fetch(url, options).then(function (response) {
clearTimeout(timeout);
console.debug(`fetchWithTimeout: succeeded connecting to url: ${url}`);
resolve(response);
}, function (error) {
clearTimeout(timeout);
console.debug(`fetchWithTimeout: timed out connecting to url: ${url}`);
reject(error);
});
}
});
}
/**
/**
* @param params {Record<string, string | number | boolean>}
* @returns {string} Query string
*/
function paramsToString(params) {
return Object.entries(params)
.filter(([, v]) => v !== null && v !== undefined && v !== '')
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&');
function paramsToString(params) {
return Object.entries(params)
.filter(([, v]) => v !== null && v !== undefined && v !== '')
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&');
}
export function ajax(request) {
if (!request) {
throw new Error('Request cannot be null');
}
export function ajax(request) {
if (!request) {
throw new Error('Request cannot be null');
}
request.headers = request.headers || {};
request.headers = request.headers || {};
console.debug(`requesting url: ${request.url}`);
console.debug(`requesting url: ${request.url}`);
return getFetchPromise(request).then(function (response) {
console.debug(`response status: ${response.status}, url: ${request.url}`);
if (response.status < 400) {
if (request.dataType === 'json' || request.headers.accept === 'application/json') {
return response.json();
} else if (request.dataType === 'text' || (response.headers.get('Content-Type') || '').toLowerCase().startsWith('text/')) {
return response.text();
} else {
return response;
}
return getFetchPromise(request).then(function (response) {
console.debug(`response status: ${response.status}, url: ${request.url}`);
if (response.status < 400) {
if (request.dataType === 'json' || request.headers.accept === 'application/json') {
return response.json();
} else if (request.dataType === 'text' || (response.headers.get('Content-Type') || '').toLowerCase().startsWith('text/')) {
return response.text();
} else {
return Promise.reject(response);
return response;
}
}, function (err) {
console.error(`request failed to url: ${request.url}`);
throw err;
});
}
/* eslint-enable indent */
} else {
return Promise.reject(response);
}
}, function (err) {
console.error(`request failed to url: ${request.url}`);
throw err;
});
}

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