Add initial experimental UI based on MUI

This commit is contained in:
Bill Thornton 2022-11-28 16:39:13 -05:00
parent 6155524e2c
commit 69fe4f067a
14 changed files with 1793 additions and 233 deletions

1193
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -65,6 +65,8 @@
"worker-loader": "3.0.8"
},
"dependencies": {
"@emotion/react": "11.10.5",
"@emotion/styled": "11.10.5",
"@fontsource/noto-sans": "4.5.11",
"@fontsource/noto-sans-hk": "4.5.12",
"@fontsource/noto-sans-jp": "4.5.12",
@ -73,6 +75,8 @@
"@fontsource/noto-sans-tc": "4.5.12",
"@jellyfin/sdk": "unstable",
"@loadable/component": "5.15.3",
"@mui/icons-material": "5.10.14",
"@mui/material": "5.10.14",
"blurhash": "2.0.5",
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
"classnames": "2.3.2",

View file

@ -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 Backdrop from '../../components/Backdrop';
import AppHeader from 'components/AppHeader';
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 theme from './theme';
const ExperimentalApp = () => (
<>
import './AppOverrides.scss';
const ExperimentalApp = () => {
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 (
<ThemeProvider theme={theme}>
<Backdrop />
<AppHeader />
<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;

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

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

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

@ -3,60 +3,26 @@ 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 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(), 10);
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, 10);
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');
@ -116,13 +77,15 @@ const Movies: FC = () => {
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}>
@ -131,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;

1
src/global.d.ts vendored
View file

@ -4,5 +4,6 @@ export declare global {
interface Window {
ApiClient: ApiClient;
Events: Events;
NativeShell: any;
}
}

View file

@ -1047,6 +1047,8 @@
"MediaInfoVideoRange": "Video range",
"MediaIsBeingConverted": "The media is being converted into a format that is compatible with the device that is playing the media.",
"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.",
"MessageAlreadyInstalled": "This version is already installed.",
"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",
"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.",
"UserMenu": "User Menu",
"UserProfilesIntro": "Jellyfin includes support for user profiles with granular display settings, play state, and parental controls.",
"ValueAlbumCount": "{0} albums",
"ValueAudioCodec": "Audio Codec: {0}",

View file

@ -33,7 +33,16 @@ const config = {
modules: [
path.resolve(__dirname, 'src'),
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: [
new DefinePlugin({