Redesign library navigation in experimental layout

This commit is contained in:
Bill Thornton
2024-09-16 14:16:34 -04:00
parent dbb0941fef
commit c5da93c30f
26 changed files with 598 additions and 311 deletions

View File

@@ -9,6 +9,7 @@ import { Outlet, useLocation } from 'react-router-dom';
import AppBody from 'components/AppBody';
import AppToolbar from 'components/toolbar/AppToolbar';
import ServerButton from 'components/toolbar/ServerButton';
import ElevationScroll from 'components/ElevationScroll';
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
import ThemeCss from 'components/ThemeCss';
@@ -22,8 +23,6 @@ import { DASHBOARD_APP_PATHS } from './routes/routes';
import './AppOverrides.scss';
const DRAWERLESS_PATHS = [ DASHBOARD_APP_PATHS.MetadataManager ];
export const Component: FC = () => {
const [ isDrawerActive, setIsDrawerActive ] = useState(false);
const location = useLocation();
@@ -31,8 +30,8 @@ export const Component: FC = () => {
const { dateFnsLocale } = useLocale();
const isMediumScreen = useMediaQuery((t: Theme) => t.breakpoints.up('md'));
const isDrawerAvailable = Boolean(user)
&& !DRAWERLESS_PATHS.some(path => location.pathname.startsWith(`/${path}`));
const isMetadataManager = location.pathname.startsWith(`/${DASHBOARD_APP_PATHS.MetadataManager}`);
const isDrawerAvailable = Boolean(user) && !isMetadataManager;
const isDrawerOpen = isDrawerActive && isDrawerAvailable;
const onToggleDrawer = useCallback(() => {
@@ -74,6 +73,10 @@ export const Component: FC = () => {
<HelpButton />
}
>
{isMetadataManager && (
<ServerButton />
)}
<AppTabs isDrawerOpen={isDrawerOpen} />
</AppToolbar>
</AppBar>

View File

@@ -8,7 +8,6 @@ import { Outlet, useLocation } from 'react-router-dom';
import AppBody from 'components/AppBody';
import CustomCss from 'components/CustomCss';
import ElevationScroll from 'components/ElevationScroll';
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
import ThemeCss from 'components/ThemeCss';
import { useApi } from 'hooks/useApi';
@@ -23,7 +22,7 @@ export const Component = () => {
const location = useLocation();
const isMediumScreen = useMediaQuery((t: Theme) => t.breakpoints.up('md'));
const isDrawerAvailable = isDrawerPath(location.pathname) && Boolean(user);
const isDrawerAvailable = isDrawerPath(location.pathname) && Boolean(user) && !isMediumScreen;
const isDrawerOpen = isDrawerActive && isDrawerAvailable;
const onToggleDrawer = useCallback(() => {
@@ -38,14 +37,8 @@ export const Component = () => {
<AppBar
position='fixed'
sx={{
width: {
xs: '100%',
md: isDrawerAvailable ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%'
},
ml: {
xs: 0,
md: isDrawerAvailable ? DRAWER_WIDTH : 0
}
width: '100%',
ml: 0
}}
>
<AppToolbar

View File

@@ -5,23 +5,10 @@ $mui-bp-md: 900px;
$mui-bp-lg: 1200px;
$mui-bp-xl: 1536px;
$drawer-width: 240px;
#reactRoot {
height: 100%;
}
// Fix main pages layout to work with drawer
.mainAnimatedPage {
@media all and (min-width: $mui-bp-md) {
left: $drawer-width;
}
}
// The fallback page has no drawer
#fallbackPage {
left: 0;
}
// Hide some items from the user "settings" page that are in the drawer
#myPreferencesMenuPage {
.lnkQuickConnectPreferences,
@@ -35,19 +22,10 @@ $drawer-width: 240px;
.homePage.libraryPage.withTabs, // Home page
// Library pages excluding the item details page and tabbed pages
.libraryPage:not(
.itemDetailPage,
.withTabs
.itemDetailPage
) {
padding-top: 3.25rem !important;
}
// Tabbed library pages
.libraryPage.withTabs {
padding-top: 6.5rem !important;
@media all and (min-width: $mui-bp-lg) {
padding-top: 3.25rem !important;
}
}
// Fix backdrop position on mobile item details page
.layout-mobile .itemBackdrop {

View File

@@ -27,21 +27,17 @@ const getUrlParams = (searchParams: URLSearchParams) => {
return params;
};
interface SearchButtonProps {
isTabsAvailable: boolean;
}
const SearchButton: FC<SearchButtonProps> = ({ isTabsAvailable }) => {
const SearchButton: FC = () => {
const location = useLocation();
const [searchParams] = useSearchParams();
const isSearchPath = location.pathname === '/search';
const createSearchLink = isTabsAvailable ?
const search = createSearchParams(getUrlParams(searchParams));
const createSearchLink =
{
pathname: '/search',
search: `?${createSearchParams(getUrlParams(searchParams))}`
} :
'/search';
search: search ? `?${search}` : undefined
};
return (
<Tooltip title={globalize.translate('Search')}>

View File

@@ -1,11 +1,14 @@
import Stack from '@mui/material/Stack';
import React, { type FC } from 'react';
import { useLocation } from 'react-router-dom';
import AppToolbar from 'components/toolbar/AppToolbar';
import AppTabs from '../tabs/AppTabs';
import ServerButton from 'components/toolbar/ServerButton';
import RemotePlayButton from './RemotePlayButton';
import SyncPlayButton from './SyncPlayButton';
import SearchButton from './SearchButton';
import { isTabPath } from '../tabs/tabRoutes';
import UserViewNav from './userViews/UserViewNav';
interface AppToolbarProps {
isDrawerAvailable: boolean
@@ -31,7 +34,6 @@ const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
// The video osd does not show the standard toolbar
if (location.pathname === '/video') return null;
const isTabsAvailable = isTabPath(location.pathname);
const isPublicPath = PUBLIC_PATHS.includes(location.pathname);
return (
@@ -40,7 +42,7 @@ const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
<>
<SyncPlayButton />
<RemotePlayButton />
<SearchButton isTabsAvailable={isTabsAvailable} />
<SearchButton />
</>
)}
isDrawerAvailable={isDrawerAvailable}
@@ -48,7 +50,18 @@ const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
onDrawerButtonClick={onDrawerButtonClick}
isUserMenuAvailable={!isPublicPath}
>
{isTabsAvailable && (<AppTabs isDrawerOpen={isDrawerOpen} />)}
{!isDrawerAvailable && (
<Stack
direction='row'
spacing={0.5}
>
<ServerButton />
{!isPublicPath && (
<UserViewNav />
)}
</Stack>
)}
</AppToolbar>
);
};

View File

@@ -0,0 +1,150 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
import Favorite from '@mui/icons-material/Favorite';
import Button from '@mui/material/Button/Button';
import { Theme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import React, { useCallback, useMemo, useState } from 'react';
import { Link, useLocation, useSearchParams } from 'react-router-dom';
import LibraryIcon from 'apps/experimental/components/LibraryIcon';
import { MetaView } from 'apps/experimental/constants/metaView';
import { isLibraryPath } from 'apps/experimental/features/libraries/utils/path';
import { appRouter } from 'components/router/appRouter';
import { useApi } from 'hooks/useApi';
import useCurrentTab from 'hooks/useCurrentTab';
import { useUserViews } from 'hooks/useUserViews';
import globalize from 'lib/globalize';
import UserViewsMenu from './UserViewsMenu';
const MAX_USER_VIEWS_MD = 3;
const MAX_USER_VIEWS_LG = 5;
const MAX_USER_VIEWS_XL = 8;
const OVERFLOW_MENU_ID = 'user-view-overflow-menu';
const HOME_PATH = '/home';
const LIST_PATH = '/list';
const getCurrentUserView = (
userViews: BaseItemDto[] | undefined,
pathname: string,
libraryId: string | null,
collectionType: string | null,
tab: number
) => {
const isUserViewPath = isLibraryPath(pathname) || [HOME_PATH, LIST_PATH].includes(pathname);
if (!isUserViewPath) return undefined;
if (collectionType === CollectionType.Livetv) {
return userViews?.find(({ CollectionType: type }) => type === CollectionType.Livetv);
}
if (pathname === HOME_PATH && tab === 1) {
return MetaView.Favorites;
}
// eslint-disable-next-line sonarjs/different-types-comparison
return userViews?.find(({ Id: id }) => id === libraryId);
};
const UserViewNav = () => {
const location = useLocation();
const [ searchParams ] = useSearchParams();
const libraryId = searchParams.get('topParentId') || searchParams.get('parentId');
const collectionType = searchParams.get('collectionType');
const { activeTab } = useCurrentTab();
const isExtraLargeScreen = useMediaQuery((t: Theme) => t.breakpoints.up('xl'));
const isLargeScreen = useMediaQuery((t: Theme) => t.breakpoints.up('lg'));
const maxViews = useMemo(() => {
if (isExtraLargeScreen) return MAX_USER_VIEWS_XL;
if (isLargeScreen) return MAX_USER_VIEWS_LG;
return MAX_USER_VIEWS_MD;
}, [ isExtraLargeScreen, isLargeScreen ]);
const { user } = useApi();
const {
data: userViews,
isPending
} = useUserViews(user?.Id);
const primaryViews = useMemo(() => (
userViews?.Items?.slice(0, maxViews)
), [ maxViews, userViews ]);
const overflowViews = useMemo(() => (
userViews?.Items?.slice(maxViews)
), [ maxViews, userViews ]);
const [ overflowAnchorEl, setOverflowAnchorEl ] = useState<null | HTMLElement>(null);
const isOverflowMenuOpen = Boolean(overflowAnchorEl);
const onOverflowButtonClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
setOverflowAnchorEl(event.currentTarget);
}, []);
const onOverflowMenuClose = useCallback(() => {
setOverflowAnchorEl(null);
}, []);
const currentUserView = useMemo(() => (
getCurrentUserView(userViews?.Items, location.pathname, libraryId, collectionType, activeTab)
), [ activeTab, collectionType, libraryId, location.pathname, userViews ]);
if (isPending) return null;
return (
<>
<Button
variant='text'
color={(currentUserView?.Id === MetaView.Favorites.Id) ? 'primary' : 'inherit'}
startIcon={<Favorite />}
component={Link}
to='/home?tab=1'
>
{globalize.translate(MetaView.Favorites.Name)}
</Button>
{primaryViews?.map(view => (
<Button
key={view.Id}
variant='text'
color={(view.Id === currentUserView?.Id) ? 'primary' : 'inherit'}
startIcon={<LibraryIcon item={view} />}
component={Link}
to={appRouter.getRouteUrl(view, { context: view.CollectionType }).substring(1)}
>
{view.Name}
</Button>
))}
{overflowViews && overflowViews.length > 0 && (
<>
<Button
variant='text'
color='inherit'
endIcon={<ArrowDropDown />}
aria-controls={OVERFLOW_MENU_ID}
aria-haspopup='true'
onClick={onOverflowButtonClick}
>
{globalize.translate('ButtonMore')}
</Button>
<UserViewsMenu
anchorEl={overflowAnchorEl}
id={OVERFLOW_MENU_ID}
open={isOverflowMenuOpen}
onMenuClose={onOverflowMenuClose}
userViews={overflowViews}
selectedId={currentUserView?.Id}
/>
</>
)}
</>
);
};
export default UserViewNav;

View File

@@ -0,0 +1,51 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import ListItemIcon from '@mui/material/ListItemIcon/ListItemIcon';
import ListItemText from '@mui/material/ListItemText/ListItemText';
import Menu, { type MenuProps } from '@mui/material/Menu/Menu';
import MenuItem from '@mui/material/MenuItem/MenuItem';
import React, { FC } from 'react';
import { Link } from 'react-router-dom';
import LibraryIcon from 'apps/experimental/components/LibraryIcon';
import { appRouter } from 'components/router/appRouter';
interface UserViewsMenuProps extends MenuProps {
userViews: BaseItemDto[]
selectedId?: string
includeGlobalViews?: boolean
onMenuClose: () => void
}
const UserViewsMenu: FC<UserViewsMenuProps> = ({
userViews,
selectedId,
onMenuClose,
...props
}) => {
return (
<Menu
{...props}
keepMounted
onClose={onMenuClose}
>
{userViews.map(view => (
<MenuItem
key={view.Id}
component={Link}
to={appRouter.getRouteUrl(view, { context: view.CollectionType }).substring(1)}
onClick={onMenuClose}
selected={view.Id === selectedId}
>
<ListItemIcon>
<LibraryIcon item={view} />
</ListItemIcon>
<ListItemText>
{view.Name}
</ListItemText>
</MenuItem>
))}
</Menu>
);
};
export default UserViewsMenu;

View File

@@ -1,5 +1,6 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import Favorite from '@mui/icons-material/Favorite';
import Movie from '@mui/icons-material/Movie';
import MusicNote from '@mui/icons-material/MusicNote';
import Photo from '@mui/icons-material/Photo';
@@ -14,6 +15,8 @@ import VideoLibrary from '@mui/icons-material/VideoLibrary';
import Folder from '@mui/icons-material/Folder';
import React, { FC } from 'react';
import { MetaView } from '../constants/metaView';
interface LibraryIconProps {
item: BaseItemDto
}
@@ -21,6 +24,10 @@ interface LibraryIconProps {
const LibraryIcon: FC<LibraryIconProps> = ({
item
}) => {
if (item.Id === MetaView.Favorites.Id) {
return <Favorite />;
}
switch (item.CollectionType) {
case CollectionType.Movies:
return <Movie />;

View File

@@ -1,5 +1,3 @@
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 Divider from '@mui/material/Divider';
@@ -111,38 +109,6 @@ const MainDrawerContent = () => {
</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'>
<ListItemIcon>
<Dashboard />
</ListItemIcon>
<ListItemText primary={globalize.translate('TabDashboard')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/metadata'>
<ListItemIcon>
<Edit />
</ListItemIcon>
<ListItemText primary={globalize.translate('MetadataManager')} />
</ListItemLink>
</ListItem>
</List>
</>
)}
</>
);
};

View File

@@ -14,6 +14,15 @@ import { CardShape } from 'utils/card';
import Loading from 'components/loading/LoadingComponent';
import { playbackManager } from 'components/playback/playbackmanager';
import ItemsContainer from 'elements/emby-itemscontainer/ItemsContainer';
import NoItemsMessage from 'components/common/NoItemsMessage';
import Lists from 'components/listview/List/Lists';
import Cards from 'components/cardbuilder/Card/Cards';
import { LibraryTab } from 'types/libraryTab';
import { type LibraryViewSettings, type ParentId, ViewMode } from 'types/library';
import type { CardOptions } from 'types/cardOptions';
import type { ListOptions } from 'types/listOptions';
import { useItem } from 'hooks/useItem';
import AlphabetPicker from './AlphabetPicker';
import FilterButton from './filter/FilterButton';
import NewCollectionButton from './NewCollectionButton';
@@ -23,14 +32,7 @@ import QueueButton from './QueueButton';
import ShuffleButton from './ShuffleButton';
import SortButton from './SortButton';
import GridListViewButton from './GridListViewButton';
import NoItemsMessage from 'components/common/NoItemsMessage';
import Lists from 'components/listview/List/Lists';
import Cards from 'components/cardbuilder/Card/Cards';
import { LibraryTab } from 'types/libraryTab';
import { type LibraryViewSettings, type ParentId, ViewMode } from 'types/library';
import type { CardOptions } from 'types/cardOptions';
import type { ListOptions } from 'types/listOptions';
import { useItem } from 'hooks/useItem';
import LibraryViewMenu from './LibraryViewMenu';
interface ItemsViewProps {
viewType: LibraryTab;
@@ -225,17 +227,21 @@ const ItemsView: FC<ItemsViewProps> = ({
'vertical-list' :
'vertical-wrap'
);
return (
<Box>
<Box className='flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x'>
{isPaginationEnabled && (
<Pagination
totalRecordCount={totalRecordCount}
libraryViewSettings={libraryViewSettings}
isPlaceholderData={isPlaceholderData}
setLibraryViewSettings={setLibraryViewSettings}
/>
<Box
className={classNames(
'padded-top padded-left padded-right padded-bottom',
{ 'padded-right-withalphapicker': isAlphabetPickerEnabled }
)}
sx={{
display: 'flex',
flexWrap: 'wrap'
}}
>
<LibraryViewMenu />
{isBtnPlayAllEnabled && (
<PlayAllButton
@@ -246,6 +252,15 @@ const ItemsView: FC<ItemsViewProps> = ({
libraryViewSettings={libraryViewSettings}
/>
)}
{isBtnShuffleEnabled && totalRecordCount > 1 && (
<ShuffleButton
item={item}
items={items}
viewType={viewType}
hasFilters={hasFilters}
libraryViewSettings={libraryViewSettings}
/>
)}
{isBtnQueueEnabled
&& item
&& playbackManager.canQueue(item) && (
@@ -255,15 +270,6 @@ const ItemsView: FC<ItemsViewProps> = ({
hasFilters={hasFilters}
/>
)}
{isBtnShuffleEnabled && totalRecordCount > 1 && (
<ShuffleButton
item={item}
items={items}
viewType={viewType}
hasFilters={hasFilters}
libraryViewSettings={libraryViewSettings}
/>
)}
{isBtnSortEnabled && (
<SortButton
viewType={viewType}
@@ -289,6 +295,24 @@ const ItemsView: FC<ItemsViewProps> = ({
setLibraryViewSettings={setLibraryViewSettings}
/>
)}
{isPaginationEnabled && (
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
flexGrow: 1,
order: 10
}}
>
<Pagination
totalRecordCount={totalRecordCount}
libraryViewSettings={libraryViewSettings}
isPlaceholderData={isPlaceholderData}
setLibraryViewSettings={setLibraryViewSettings}
/>
</Box>
)}
</Box>
{isAlphabetPickerEnabled && hasSortName && (
@@ -312,7 +336,16 @@ const ItemsView: FC<ItemsViewProps> = ({
)}
{isPaginationEnabled && (
<Box className='flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x'>
<Box
className={classNames(
'padded-top padded-left padded-right padded-bottom',
{ 'padded-right-withalphapicker': isAlphabetPickerEnabled }
)}
sx={{
display: 'flex',
justifyContent: 'flex-end'
}}
>
<Pagination
totalRecordCount={totalRecordCount}
libraryViewSettings={libraryViewSettings}

View File

@@ -0,0 +1,76 @@
import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
import Button from '@mui/material/Button/Button';
import Menu from '@mui/material/Menu/Menu';
import MenuItem from '@mui/material/MenuItem/MenuItem';
import React, { FC, useCallback, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router-dom';
import { LibraryRoutes } from 'apps/experimental/features/libraries/constants/libraryRoutes';
import useCurrentTab from 'hooks/useCurrentTab';
import globalize from 'lib/globalize';
const LIBRARY_VIEW_MENU_ID = 'library-view-menu';
const LibraryViewMenu: FC = () => {
const location = useLocation();
const [ searchParams, setSearchParams ] = useSearchParams();
const { activeTab } = useCurrentTab();
const [ menuAnchorEl, setMenuAnchorEl ] = useState<null | HTMLElement>(null);
const isMenuOpen = Boolean(menuAnchorEl);
const onMenuButtonClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
setMenuAnchorEl(event.currentTarget);
}, []);
const onMenuClose = useCallback(() => {
setMenuAnchorEl(null);
}, []);
const currentRoute = LibraryRoutes.find(({ path }) => path === location.pathname);
const currentTab = currentRoute?.views.find(({ index }) => index === activeTab);
if (!currentTab) return null;
return (
<>
<Button
variant='text'
size='large'
color='inherit'
endIcon={<ArrowDropDown />}
aria-controls={LIBRARY_VIEW_MENU_ID}
aria-haspopup='true'
onClick={onMenuButtonClick}
>
{globalize.translate(currentTab.label)}
</Button>
<Menu
anchorEl={menuAnchorEl}
id={LIBRARY_VIEW_MENU_ID}
keepMounted
open={isMenuOpen}
onClose={onMenuClose}
>
{currentRoute?.views.map(tab => (
<MenuItem
key={tab.view}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => {
searchParams.set('tab', `${tab.index}`);
setSearchParams(searchParams);
onMenuClose();
}}
selected={tab.index === currentTab.index}
>
{globalize.translate(tab.label)}
</MenuItem>
))}
</Menu>
</>
);
};
export default LibraryViewMenu;

View File

@@ -1,3 +1,4 @@
import Box from '@mui/material/Box/Box';
import React, { type FC } from 'react';
import SuggestionsSectionView from './SuggestionsSectionView';
import UpcomingView from './UpcomingView';
@@ -8,6 +9,7 @@ import ProgramsSectionView from './ProgramsSectionView';
import { LibraryTab } from 'types/libraryTab';
import type { ParentId } from 'types/library';
import type { LibraryTabContent } from 'types/libraryTabContent';
import LibraryViewMenu from './LibraryViewMenu';
interface PageTabContentProps {
parentId: ParentId;
@@ -17,46 +19,86 @@ interface PageTabContentProps {
const PageTabContent: FC<PageTabContentProps> = ({ parentId, currentTab }) => {
if (currentTab.viewType === LibraryTab.Suggestions) {
return (
<SuggestionsSectionView
parentId={parentId}
sectionType={
currentTab.sectionsView?.suggestionSections ?? []
}
isMovieRecommendationEnabled={
currentTab.sectionsView?.isMovieRecommendations
}
/>
<>
<Box className='padded-top padded-left padded-right padded-bottom'>
<LibraryViewMenu />
</Box>
<SuggestionsSectionView
parentId={parentId}
sectionType={
currentTab.sectionsView?.suggestionSections ?? []
}
isMovieRecommendationEnabled={
currentTab.sectionsView?.isMovieRecommendations
}
/>
</>
);
}
if (currentTab.viewType === LibraryTab.Programs || currentTab.viewType === LibraryTab.Recordings || currentTab.viewType === LibraryTab.Schedule) {
return (
<ProgramsSectionView
parentId={parentId}
sectionType={
currentTab.sectionsView?.programSections ?? []
}
isUpcomingRecordingsEnabled={currentTab.sectionsView?.isLiveTvUpcomingRecordings}
/>
<>
<Box className='padded-top padded-left padded-right padded-bottom'>
<LibraryViewMenu />
</Box>
<ProgramsSectionView
parentId={parentId}
sectionType={
currentTab.sectionsView?.programSections ?? []
}
isUpcomingRecordingsEnabled={currentTab.sectionsView?.isLiveTvUpcomingRecordings}
/>
</>
);
}
if (currentTab.viewType === LibraryTab.Upcoming) {
return <UpcomingView parentId={parentId} />;
return (
<>
<Box className='padded-top padded-left padded-right padded-bottom'>
<LibraryViewMenu />
</Box>
<UpcomingView parentId={parentId} />
</>
);
}
if (currentTab.viewType === LibraryTab.Genres) {
return (
<GenresView
parentId={parentId}
collectionType={currentTab.collectionType}
itemType={currentTab.itemType || []}
/>
<>
<Box className='padded-top padded-left padded-right padded-bottom'>
<LibraryViewMenu />
</Box>
<GenresView
parentId={parentId}
collectionType={currentTab.collectionType}
itemType={currentTab.itemType || []}
/>
</>
);
}
if (currentTab.viewType === LibraryTab.Guide) {
return <GuideView />;
return (
<>
<Box
className='padded-top padded-left padded-right padded-bottom'
sx={{
position: 'relative',
zIndex: 2
}}
>
<LibraryViewMenu />
</Box>
<GuideView />
</>
);
}
return (

View File

@@ -47,10 +47,15 @@ const Pagination: FC<PaginationProps> = ({
}, [limit, setLibraryViewSettings, startIndex]);
return (
<Box className='paging'>
<Box
className='paging'
sx={{
display: 'flex'
}}
>
<Box
className='listPaging'
style={{ display: 'flex', alignItems: 'center' }}
sx={{ display: 'flex', alignItems: 'center' }}
>
<span>
{globalize.translate(

View File

@@ -51,6 +51,12 @@ const PlayAllButton: FC<PlayAllButtonProps> = ({ item, items, viewType, hasFilte
title={globalize.translate('HeaderPlayAll')}
className='paper-icon-button-light btnPlay autoSize'
onClick={play}
sx={{
order: {
xs: 1,
sm: 'unset'
}
}}
>
<PlayArrowIcon />
</IconButton>

View File

@@ -34,6 +34,12 @@ const QueueButton: FC<QueueButtonProps> = ({ item, items, hasFilters }) => {
title={globalize.translate('AddToPlayQueue')}
className='paper-icon-button-light btnQueue autoSize'
onClick={queue}
sx={{
order: {
xs: 3,
sm: 'unset'
}
}}
>
<QueueIcon />
</IconButton>

View File

@@ -42,6 +42,12 @@ const ShuffleButton: FC<ShuffleButtonProps> = ({ item, items, viewType, hasFilte
title={globalize.translate('Shuffle')}
className='paper-icon-button-light btnShuffle autoSize'
onClick={shuffle}
sx={{
order: {
xs: 2,
sm: 'unset'
}
}}
>
<ShuffleIcon />
</IconButton>

View File

@@ -1,85 +0,0 @@
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 } from 'react-router-dom';
import TabRoutes from './tabRoutes';
import useCurrentTab from 'hooks/useCurrentTab';
import globalize from 'lib/globalize';
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 { searchParams, setSearchParams, activeTab } = useCurrentTab();
// 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.path}-tab-${index}`}
label={globalize.translate(label)}
data-tab-index={`${index}`}
onClick={onTabClick}
/>
))
}
</Tabs>
}
/>
))
}
</Routes>
);
};
export default AppTabs;

View File

@@ -0,0 +1,11 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
/**
* Views in the web app that we treat as UserViews.
*/
export const MetaView: Record<string, BaseItemDto> = {
Favorites: {
Id: 'favorites',
Name: 'Favorites'
}
};

View File

@@ -1,211 +1,177 @@
import * as userSettings from 'scripts/settings/userSettings';
import { LibraryTab } from 'types/libraryTab';
interface TabDefinition {
index: number
label: string
value: LibraryTab
isDefault?: boolean
}
import { LibraryRoute } from '../types/LibraryRoute';
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[] = [
export const LibraryRoutes: LibraryRoute[] = [
{
path: '/livetv',
tabs: [
views: [
{
index: 0,
label: 'Programs',
value: LibraryTab.Programs,
view: LibraryTab.Programs,
isDefault: true
},
{
index: 1,
label: 'Guide',
value: LibraryTab.Guide
view: LibraryTab.Guide
},
{
index: 2,
label: 'Channels',
value: LibraryTab.Channels
view: LibraryTab.Channels
},
{
index: 3,
label: 'Recordings',
value: LibraryTab.Recordings
view: LibraryTab.Recordings
},
{
index: 4,
label: 'Schedule',
value: LibraryTab.Schedule
view: LibraryTab.Schedule
},
{
index: 5,
label: 'Series',
value: LibraryTab.SeriesTimers
view: LibraryTab.SeriesTimers
}
]
},
{
path: '/movies',
tabs: [
views: [
{
index: 0,
label: 'Movies',
value: LibraryTab.Movies,
view: LibraryTab.Movies,
isDefault: true
},
{
index: 1,
label: 'Suggestions',
value: LibraryTab.Suggestions
view: LibraryTab.Suggestions
},
{
index: 2,
label: 'Trailers',
value: LibraryTab.Trailers
view: LibraryTab.Trailers
},
{
index: 3,
label: 'Favorites',
value: LibraryTab.Favorites
view: LibraryTab.Favorites
},
{
index: 4,
label: 'Collections',
value: LibraryTab.Collections
view: LibraryTab.Collections
},
{
index: 5,
label: 'Genres',
value: LibraryTab.Genres
view: LibraryTab.Genres
}
]
},
{
path: '/music',
tabs: [
views: [
{
index: 0,
label: 'Albums',
value: LibraryTab.Albums,
view: LibraryTab.Albums,
isDefault: true
},
{
index: 1,
label: 'Suggestions',
value: LibraryTab.Suggestions
view: LibraryTab.Suggestions
},
{
index: 2,
label: 'HeaderAlbumArtists',
value: LibraryTab.AlbumArtists
view: LibraryTab.AlbumArtists
},
{
index: 3,
label: 'Artists',
value: LibraryTab.Artists
view: LibraryTab.Artists
},
{
index: 4,
label: 'Playlists',
value: LibraryTab.Playlists
view: LibraryTab.Playlists
},
{
index: 5,
label: 'Songs',
value: LibraryTab.Songs
view: LibraryTab.Songs
},
{
index: 6,
label: 'Genres',
value: LibraryTab.Genres
view: LibraryTab.Genres
}
]
},
{
path: '/tv',
tabs: [
views: [
{
index: 0,
label: 'Shows',
value: LibraryTab.Series,
view: LibraryTab.Series,
isDefault: true
},
{
index: 1,
label: 'Suggestions',
value: LibraryTab.Suggestions
view: LibraryTab.Suggestions
},
{
index: 2,
label: 'TabUpcoming',
value: LibraryTab.Upcoming
view: LibraryTab.Upcoming
},
{
index: 3,
label: 'Genres',
value: LibraryTab.Genres
view: LibraryTab.Genres
},
{
index: 4,
label: 'TabNetworks',
value: LibraryTab.Networks
view: LibraryTab.Networks
},
{
index: 5,
label: 'Episodes',
value: LibraryTab.Episodes
view: LibraryTab.Episodes
}
]
},
{
path: '/homevideos',
tabs: [
views: [
{
index: 0,
label: 'Photos',
value: LibraryTab.Photos,
view: LibraryTab.Photos,
isDefault: true
},
{
index: 1,
label: 'HeaderPhotoAlbums',
value: LibraryTab.PhotoAlbums,
view: LibraryTab.PhotoAlbums,
isDefault: true
},
{
index: 2,
label: 'HeaderVideos',
value: LibraryTab.Videos
view: LibraryTab.Videos
}
]
}
];
export default TabRoutes;

View File

@@ -0,0 +1,13 @@
import { LibraryTab } from 'types/libraryTab';
interface LibraryViewDefinition {
index: number
label: string
view: LibraryTab
isDefault?: boolean
}
export interface LibraryRoute {
path: string,
views: LibraryViewDefinition[]
}

View File

@@ -0,0 +1,24 @@
import * as userSettings from 'scripts/settings/userSettings';
import { LibraryRoutes } from '../constants/libraryRoutes';
/**
* Utility function to check if a path is a library path.
*/
export const isLibraryPath = (path: string) => (
LibraryRoutes.some(route => route.path === path)
);
/**
* Utility function to get the default view index for a specified URL path and library.
*/
export const getDefaultViewIndex = (path: string, libraryId?: string | null) => {
if (!libraryId) return 0;
const views = LibraryRoutes.find(route => route.path === path)?.views ?? [];
const defaultView = userSettings.get('landing-' + libraryId, false);
return views.find(view => view.view === defaultView)?.index
?? views.find(view => view.isDefault)?.index
?? 0;
};

View File

@@ -46,7 +46,6 @@ const VideoPage: FC = () => {
<AppToolbar
isDrawerAvailable={false}
isDrawerOpen={false}
isFullscreen
isUserMenuAvailable={false}
buttons={
<>

View File

@@ -34,7 +34,7 @@ const SearchResults: FC<SearchResultsProps> = ({
<div>
<Link
className='emby-button'
to={`/search.html?query=${encodeURIComponent(query || '')}`}
to={`/search?query=${encodeURIComponent(query || '')}`}
>{globalize.translate('RetryWithGlobalSearch')}</Link>
</div>
)}

View File

@@ -16,8 +16,8 @@ interface AppToolbarProps {
buttons?: ReactNode
isDrawerAvailable: boolean
isDrawerOpen: boolean
onDrawerButtonClick?: (event: React.MouseEvent<HTMLElement>) => void,
isFullscreen?: boolean,
onDrawerButtonClick?: (event: React.MouseEvent<HTMLElement>) => void
isBackButtonAvailable?: boolean
isUserMenuAvailable?: boolean
}
@@ -34,31 +34,21 @@ const AppToolbar: FC<PropsWithChildren<AppToolbarProps>> = ({
isDrawerAvailable,
isDrawerOpen,
onDrawerButtonClick = () => { /* no-op */ },
isFullscreen = false,
isBackButtonAvailable = false,
isUserMenuAvailable = true
}) => {
const { user } = useApi();
const isUserLoggedIn = Boolean(user);
const isBackButtonAvailable = appRouter.canGoBack();
// Only use the left safe area padding when the drawer is not pinned or in a fullscreen view
const useSafeAreaLeft = isDrawerAvailable || isFullscreen;
return (
<Toolbar
variant='dense'
sx={{
flexWrap: {
xs: 'wrap',
lg: 'nowrap'
flexWrap: 'wrap',
pl: {
xs: 'max(16px, env(safe-area-inset-left))',
sm: 'max(24px, env(safe-area-inset-left))'
},
...(useSafeAreaLeft && {
pl: {
xs: 'max(16px, env(safe-area-inset-left))',
sm: 'max(24px, env(safe-area-inset-left))'
}
}),
pr: {
xs: 'max(16px, env(safe-area-inset-left))',
sm: 'max(24px, env(safe-area-inset-left))'

View File

@@ -0,0 +1,37 @@
import Button from '@mui/material/Button/Button';
import React, { FC } from 'react';
import { Link } from 'react-router-dom';
import { useSystemInfo } from 'hooks/useSystemInfo';
const ServerButton: FC = () => {
const {
data: systemInfo,
isPending
} = useSystemInfo();
return (
<Button
variant='text'
size='large'
color='inherit'
startIcon={
<img
src='assets/img/icon-transparent.png'
alt=''
aria-hidden
style={{
maxHeight: '1.25em',
maxWidth: '1.25em'
}}
/>
}
component={Link}
to='/'
>
{isPending ? '' : (systemInfo?.ServerName || 'Jellyfin')}
</Button>
);
};
export default ServerButton;

View File

@@ -1,6 +1,7 @@
import { getDefaultTabIndex } from 'apps/experimental/components/tabs/tabRoutes';
import { useLocation, useSearchParams } from 'react-router-dom';
import { getDefaultViewIndex } from 'apps/experimental/features/libraries/utils/path';
const useCurrentTab = () => {
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
@@ -12,7 +13,7 @@ const useCurrentTab = () => {
const activeTab: number =
searchParamsTab !== null ?
parseInt(searchParamsTab, 10) :
getDefaultTabIndex(location.pathname, libraryId);
getDefaultViewIndex(location.pathname, libraryId);
return {
searchParams,