Add initial experimental UI based on MUI
This commit is contained in:
parent
6155524e2c
commit
69fe4f067a
14 changed files with 1793 additions and 233 deletions
1193
package-lock.json
generated
1193
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -65,6 +65,8 @@
|
||||||
"worker-loader": "3.0.8"
|
"worker-loader": "3.0.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emotion/react": "11.10.5",
|
||||||
|
"@emotion/styled": "11.10.5",
|
||||||
"@fontsource/noto-sans": "4.5.11",
|
"@fontsource/noto-sans": "4.5.11",
|
||||||
"@fontsource/noto-sans-hk": "4.5.12",
|
"@fontsource/noto-sans-hk": "4.5.12",
|
||||||
"@fontsource/noto-sans-jp": "4.5.12",
|
"@fontsource/noto-sans-jp": "4.5.12",
|
||||||
|
@ -73,6 +75,8 @@
|
||||||
"@fontsource/noto-sans-tc": "4.5.12",
|
"@fontsource/noto-sans-tc": "4.5.12",
|
||||||
"@jellyfin/sdk": "unstable",
|
"@jellyfin/sdk": "unstable",
|
||||||
"@loadable/component": "5.15.3",
|
"@loadable/component": "5.15.3",
|
||||||
|
"@mui/icons-material": "5.10.14",
|
||||||
|
"@mui/material": "5.10.14",
|
||||||
"blurhash": "2.0.5",
|
"blurhash": "2.0.5",
|
||||||
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
|
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
|
||||||
"classnames": "2.3.2",
|
"classnames": "2.3.2",
|
||||||
|
|
|
@ -1,19 +1,81 @@
|
||||||
import React from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import AppBar from '@mui/material/AppBar';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
|
|
||||||
import AppHeader from '../../components/AppHeader';
|
import AppHeader from 'components/AppHeader';
|
||||||
import Backdrop from '../../components/Backdrop';
|
import Backdrop from 'components/Backdrop';
|
||||||
|
|
||||||
|
import AppToolbar from './components/AppToolbar';
|
||||||
|
import AppUserMenu from './components/AppUserMenu';
|
||||||
|
import ElevationScroll from './components/ElevationScroll';
|
||||||
import { ExperimentalAppRoutes } from './routes/AppRoutes';
|
import { ExperimentalAppRoutes } from './routes/AppRoutes';
|
||||||
|
import theme from './theme';
|
||||||
|
|
||||||
const ExperimentalApp = () => (
|
import './AppOverrides.scss';
|
||||||
<>
|
|
||||||
<Backdrop />
|
|
||||||
<AppHeader />
|
|
||||||
|
|
||||||
<div className='mainAnimatedPages skinBody' />
|
const ExperimentalApp = () => {
|
||||||
<div className='skinBody'>
|
const [ userMenuAnchorEl, setUserMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
||||||
<ExperimentalAppRoutes />
|
const isUserMenuOpen = Boolean(userMenuAnchorEl);
|
||||||
</div>
|
|
||||||
</>
|
const onUserButtonClick = useCallback((event) => {
|
||||||
);
|
setUserMenuAnchorEl(event.currentTarget);
|
||||||
|
}, [ setUserMenuAnchorEl ]);
|
||||||
|
|
||||||
|
const onUserMenuClose = useCallback(() => {
|
||||||
|
setUserMenuAnchorEl(null);
|
||||||
|
}, [ setUserMenuAnchorEl ]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<AppBar
|
||||||
|
position='fixed'
|
||||||
|
sx={{ zIndex: (muiTheme) => muiTheme.zIndex.drawer + 1 }}
|
||||||
|
>
|
||||||
|
<AppToolbar
|
||||||
|
onUserButtonClick={onUserButtonClick}
|
||||||
|
/>
|
||||||
|
</AppBar>
|
||||||
|
</ElevationScroll>
|
||||||
|
|
||||||
|
<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
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='mainAnimatedPages skinBody' />
|
||||||
|
<div className='skinBody'>
|
||||||
|
<ExperimentalAppRoutes />
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<AppUserMenu
|
||||||
|
open={isUserMenuOpen}
|
||||||
|
anchorEl={userMenuAnchorEl}
|
||||||
|
onMenuClose={onUserMenuClose}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default ExperimentalApp;
|
export default ExperimentalApp;
|
||||||
|
|
40
src/apps/experimental/AppOverrides.scss
Normal file
40
src/apps/experimental/AppOverrides.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
118
src/apps/experimental/components/AppToolbar.tsx
Normal file
118
src/apps/experimental/components/AppToolbar.tsx
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
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 } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
|
import { ID as UserMenuId } from './AppUserMenu';
|
||||||
|
import AppTabs from './tabs/AppTabs';
|
||||||
|
|
||||||
|
interface AppToolbarProps {
|
||||||
|
onUserButtonClick: (event: React.MouseEvent<HTMLElement>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppToolbar: FC<AppToolbarProps> = ({
|
||||||
|
onUserButtonClick
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { api, user } = useApi();
|
||||||
|
const isUserLoggedIn = Boolean(user);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toolbar
|
||||||
|
variant='dense'
|
||||||
|
sx={{
|
||||||
|
flexWrap: {
|
||||||
|
xs: 'wrap',
|
||||||
|
lg: 'nowrap'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component={Link}
|
||||||
|
to='/'
|
||||||
|
color='inherit'
|
||||||
|
aria-label={globalize.translate('Home')}
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
textDecoration: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component='img'
|
||||||
|
src='/assets/img/icon-transparent.png'
|
||||||
|
sx={{
|
||||||
|
height: '2rem',
|
||||||
|
marginInlineEnd: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant='h6'
|
||||||
|
noWrap
|
||||||
|
component='div'
|
||||||
|
sx={{ display: { xs: 'none', sm: 'inline-block' } }}
|
||||||
|
>
|
||||||
|
Jellyfin
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<AppTabs />
|
||||||
|
|
||||||
|
{isUserLoggedIn && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'flex-end' }}>
|
||||||
|
<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 }}>
|
||||||
|
<Tooltip title={globalize.translate('UserMenu')}>
|
||||||
|
<IconButton
|
||||||
|
size='large'
|
||||||
|
edge='end'
|
||||||
|
aria-label={globalize.translate('UserMenu')}
|
||||||
|
aria-controls={UserMenuId}
|
||||||
|
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>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Toolbar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppToolbar;
|
165
src/apps/experimental/components/AppUserMenu.tsx
Normal file
165
src/apps/experimental/components/AppUserMenu.tsx
Normal 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;
|
21
src/apps/experimental/components/ElevationScroll.tsx
Normal file
21
src/apps/experimental/components/ElevationScroll.tsx
Normal 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;
|
78
src/apps/experimental/components/tabs/AppTabs.tsx
Normal file
78
src/apps/experimental/components/tabs/AppTabs.tsx
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
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 React, { FC, useCallback } from 'react';
|
||||||
|
import { Route, Routes, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import TabRoutes, { getDefaultTabIndex } from './tabRoutes';
|
||||||
|
|
||||||
|
const AppTabs: FC = () => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* Suppress warnings for unhandled routes */}
|
||||||
|
<Route path='*' element={null} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppTabs;
|
190
src/apps/experimental/components/tabs/tabRoutes.ts
Normal file
190
src/apps/experimental/components/tabs/tabRoutes.ts
Normal 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;
|
|
@ -3,60 +3,26 @@ import '../../../../elements/emby-itemscontainer/emby-itemscontainer';
|
||||||
import '../../../../elements/emby-tabs/emby-tabs';
|
import '../../../../elements/emby-tabs/emby-tabs';
|
||||||
import '../../../../elements/emby-button/emby-button';
|
import '../../../../elements/emby-button/emby-button';
|
||||||
|
|
||||||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { FC, useEffect, useRef } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import * as mainTabsManager from '../../../../components/maintabsmanager';
|
|
||||||
import Page from '../../../../components/Page';
|
import Page from '../../../../components/Page';
|
||||||
import globalize from '../../../../scripts/globalize';
|
import globalize from '../../../../scripts/globalize';
|
||||||
import libraryMenu from '../../../../scripts/libraryMenu';
|
import libraryMenu from '../../../../scripts/libraryMenu';
|
||||||
import * as userSettings from '../../../../scripts/settings/userSettings';
|
|
||||||
import CollectionsView from './CollectionsView';
|
import CollectionsView from './CollectionsView';
|
||||||
import FavoritesView from './FavoritesView';
|
import FavoritesView from './FavoritesView';
|
||||||
import GenresView from './GenresView';
|
import GenresView from './GenresView';
|
||||||
import MoviesView from './MoviesView';
|
import MoviesView from './MoviesView';
|
||||||
import SuggestionsView from './SuggestionsView';
|
import SuggestionsView from './SuggestionsView';
|
||||||
import TrailersView from './TrailersView';
|
import TrailersView from './TrailersView';
|
||||||
|
import { getDefaultTabIndex } from '../../components/tabs/tabRoutes';
|
||||||
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')
|
|
||||||
}];
|
|
||||||
};
|
|
||||||
|
|
||||||
const Movies: FC = () => {
|
const Movies: FC = () => {
|
||||||
|
const location = useLocation();
|
||||||
const [ searchParams ] = useSearchParams();
|
const [ searchParams ] = useSearchParams();
|
||||||
const currentTabIndex = parseInt(searchParams.get('tab') || getDefaultTabIndex(searchParams.get('topParentId')).toString(), 10);
|
const searchParamsTab = searchParams.get('tab');
|
||||||
const [ selectedIndex, setSelectedIndex ] = useState(currentTabIndex);
|
const currentTabIndex = searchParamsTab !== null ? parseInt(searchParamsTab, 10) :
|
||||||
|
getDefaultTabIndex(location.pathname, searchParams.get('topParentId'));
|
||||||
const element = useRef<HTMLDivElement>(null);
|
const element = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const getTabComponent = (index: number) => {
|
const getTabComponent = (index: number) => {
|
||||||
|
@ -94,11 +60,6 @@ const Movies: FC = () => {
|
||||||
return component;
|
return component;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTabChange = useCallback((e: { detail: { selectedTabIndex: string; }; }) => {
|
|
||||||
const newIndex = parseInt(e.detail.selectedTabIndex, 10);
|
|
||||||
setSelectedIndex(newIndex);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const page = element.current;
|
const page = element.current;
|
||||||
|
|
||||||
|
@ -106,7 +67,7 @@ const Movies: FC = () => {
|
||||||
console.error('Unexpected null reference');
|
console.error('Unexpected null reference');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mainTabsManager.setTabs(page, selectedIndex, getTabs, undefined, undefined, onTabChange);
|
|
||||||
if (!page.getAttribute('data-title')) {
|
if (!page.getAttribute('data-title')) {
|
||||||
const parentId = searchParams.get('topParentId');
|
const parentId = searchParams.get('topParentId');
|
||||||
|
|
||||||
|
@ -116,13 +77,15 @@ const Movies: FC = () => {
|
||||||
libraryMenu.setTitle(item.Name);
|
libraryMenu.setTitle(item.Name);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('[movies] failed to fetch library', err);
|
console.error('[movies] failed to fetch library', err);
|
||||||
|
page.setAttribute('data-title', globalize.translate('Movies'));
|
||||||
|
libraryMenu.setTitle(globalize.translate('Movies'));
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
page.setAttribute('data-title', globalize.translate('Movies'));
|
page.setAttribute('data-title', globalize.translate('Movies'));
|
||||||
libraryMenu.setTitle(globalize.translate('Movies'));
|
libraryMenu.setTitle(globalize.translate('Movies'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [onTabChange, searchParams, selectedIndex]);
|
}, [ searchParams ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={element}>
|
<div ref={element}>
|
||||||
|
@ -131,7 +94,7 @@ const Movies: FC = () => {
|
||||||
className='mainAnimatedPage libraryPage backdropPage collectionEditorPage pageWithAbsoluteTabs withTabs'
|
className='mainAnimatedPage libraryPage backdropPage collectionEditorPage pageWithAbsoluteTabs withTabs'
|
||||||
backDropType='movie'
|
backDropType='movie'
|
||||||
>
|
>
|
||||||
{getTabComponent(selectedIndex)}
|
{getTabComponent(currentTabIndex)}
|
||||||
|
|
||||||
</Page>
|
</Page>
|
||||||
</div>
|
</div>
|
||||||
|
|
53
src/apps/experimental/theme.ts
Normal file
53
src/apps/experimental/theme.ts
Normal 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;
|
1
src/global.d.ts
vendored
1
src/global.d.ts
vendored
|
@ -4,5 +4,6 @@ export declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
ApiClient: ApiClient;
|
ApiClient: ApiClient;
|
||||||
Events: Events;
|
Events: Events;
|
||||||
|
NativeShell: any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1047,6 +1047,8 @@
|
||||||
"MediaInfoVideoRange": "Video range",
|
"MediaInfoVideoRange": "Video range",
|
||||||
"MediaIsBeingConverted": "The media is being converted into a format that is compatible with the device that is playing the media.",
|
"MediaIsBeingConverted": "The media is being converted into a format that is compatible with the device that is playing the media.",
|
||||||
"Menu": "Menu",
|
"Menu": "Menu",
|
||||||
|
"MenuOpen": "Open Menu",
|
||||||
|
"MenuClose": "Close Menu",
|
||||||
"MessageAddRepository": "If you wish to add a repository, click the button next to the header and fill out the requested information.",
|
"MessageAddRepository": "If you wish to add a repository, click the button next to the header and fill out the requested information.",
|
||||||
"MessageAlreadyInstalled": "This version is already installed.",
|
"MessageAlreadyInstalled": "This version is already installed.",
|
||||||
"MessageAreYouSureDeleteSubtitles": "Are you sure you wish to delete this subtitle file?",
|
"MessageAreYouSureDeleteSubtitles": "Are you sure you wish to delete this subtitle file?",
|
||||||
|
@ -1560,6 +1562,7 @@
|
||||||
"UseEpisodeImagesInNextUp": "Use episode images in 'Next Up' and 'Continue Watching' sections",
|
"UseEpisodeImagesInNextUp": "Use episode images in 'Next Up' and 'Continue Watching' sections",
|
||||||
"UseEpisodeImagesInNextUpHelp": "'Next Up' and 'Continue Watching' sections will use episode images as thumbnails instead of the primary thumbnail of the show.",
|
"UseEpisodeImagesInNextUpHelp": "'Next Up' and 'Continue Watching' sections will use episode images as thumbnails instead of the primary thumbnail of the show.",
|
||||||
"UserAgentHelp": "Supply a custom 'User-Agent' HTTP header.",
|
"UserAgentHelp": "Supply a custom 'User-Agent' HTTP header.",
|
||||||
|
"UserMenu": "User Menu",
|
||||||
"UserProfilesIntro": "Jellyfin includes support for user profiles with granular display settings, play state, and parental controls.",
|
"UserProfilesIntro": "Jellyfin includes support for user profiles with granular display settings, play state, and parental controls.",
|
||||||
"ValueAlbumCount": "{0} albums",
|
"ValueAlbumCount": "{0} albums",
|
||||||
"ValueAudioCodec": "Audio Codec: {0}",
|
"ValueAudioCodec": "Audio Codec: {0}",
|
||||||
|
|
|
@ -33,7 +33,16 @@ const config = {
|
||||||
modules: [
|
modules: [
|
||||||
path.resolve(__dirname, 'src'),
|
path.resolve(__dirname, 'src'),
|
||||||
path.resolve(__dirname, 'node_modules')
|
path.resolve(__dirname, 'node_modules')
|
||||||
]
|
],
|
||||||
|
alias: {
|
||||||
|
'@mui/base': '@mui/base/legacy',
|
||||||
|
'@mui/lab': '@mui/lab/legacy',
|
||||||
|
'@mui/material': '@mui/material/legacy',
|
||||||
|
'@mui/private-theming': '@mui/private-theming/legacy',
|
||||||
|
'@mui/styled-engine': '@mui/styled-engine/legacy',
|
||||||
|
'@mui/system': '@mui/system/legacy',
|
||||||
|
'@mui/utils': '@mui/utils/legacy'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new DefinePlugin({
|
new DefinePlugin({
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue