mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
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
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;
|
Loading…
Add table
Add a link
Reference in a new issue