1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Merge branch 'master' into Sorting-Only-Thumbnail-Fix-5584

This commit is contained in:
Marco 2023-10-05 22:53:18 +01:00 committed by GitHub
commit 0f8d29a573
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
193 changed files with 5197 additions and 1973 deletions

View file

@ -0,0 +1,24 @@
import React, { FC, useEffect } from 'react';
import viewContainer from './viewContainer';
/**
* A simple component that includes the correct structure for ViewManager pages
* to exist alongside standard React pages.
*/
const AppBody: FC = ({ children }) => {
useEffect(() => () => {
// Reset view container state on unload
viewContainer.reset();
}, []);
return (
<>
<div className='mainAnimatedPages skinBody' />
<div className='skinBody'>
{children}
</div>
</>
);
};
export default AppBody;

View file

@ -1,19 +1,29 @@
import React, { useEffect } from 'react';
import React, { FC, useEffect } from 'react';
const AppHeader = () => {
interface AppHeaderParams {
isHidden?: boolean
}
const AppHeader: FC<AppHeaderParams> = ({
isHidden = false
}) => {
useEffect(() => {
// Initialize the UI components after first render
import('../scripts/libraryMenu');
}, []);
return (
<>
/**
* NOTE: These components are not used with the new layouts, but legacy views interact with the elements
* directly so they need to be present in the DOM. We use display: none to hide them and prevent errors.
*/
<div style={isHidden ? { display: 'none' } : undefined}>
<div className='mainDrawer hide'>
<div className='mainDrawer-scrollContainer scrollContainer focuscontainer-y' />
</div>
<div className='skinHeader focuscontainer-x' />
<div className='mainDrawerHandle' />
</>
</div>
);
};

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

View file

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

View file

@ -5,24 +5,30 @@
*/
import escapeHtml from 'escape-html';
import datetime from '../../scripts/datetime';
import imageLoader from '../images/imageLoader';
import itemHelper from '../itemHelper';
import cardBuilderUtils from './cardBuilderUtils';
import browser from 'scripts/browser';
import datetime from 'scripts/datetime';
import dom from 'scripts/dom';
import globalize from 'scripts/globalize';
import imageHelper from 'scripts/imagehelper';
import { getBackdropShape, getPortraitShape, getSquareShape } from 'utils/card';
import { randomInt } from 'utils/number';
import focusManager from '../focusManager';
import imageLoader from '../images/imageLoader';
import indicators from '../indicators/indicators';
import globalize from '../../scripts/globalize';
import itemHelper from '../itemHelper';
import layoutManager from '../layoutManager';
import dom from '../../scripts/dom';
import browser from '../../scripts/browser';
import { playbackManager } from '../playback/playbackmanager';
import itemShortcuts from '../shortcuts';
import imageHelper from '../../scripts/imagehelper';
import { randomInt } from '../../utils/number.ts';
import './card.scss';
import '../../elements/emby-button/paper-icon-button-light';
import '../guide/programs.scss';
import ServerConnections from '../ServerConnections';
import { appRouter } from '../router/appRouter';
import ServerConnections from '../ServerConnections';
import itemShortcuts from '../shortcuts';
import 'elements/emby-button/paper-icon-button-light';
import './card.scss';
import '../guide/programs.scss';
const enableFocusTransform = !browser.slow && !browser.edge;
@ -41,217 +47,6 @@ export function getCardsHtml(items, options) {
return buildCardsHtmlInternal(items, options);
}
/**
* Computes the number of posters per row.
* @param {string} shape - Shape of the cards.
* @param {number} screenWidth - Width of the screen.
* @param {boolean} isOrientationLandscape - Flag for the orientation of the screen.
* @returns {number} Number of cards per row for an itemsContainer.
*/
function getPostersPerRow(shape, screenWidth, isOrientationLandscape) {
switch (shape) {
case 'portrait':
if (layoutManager.tv) {
return 100 / 16.66666667;
}
if (screenWidth >= 2200) {
return 100 / 10;
}
if (screenWidth >= 1920) {
return 100 / 11.1111111111;
}
if (screenWidth >= 1600) {
return 100 / 12.5;
}
if (screenWidth >= 1400) {
return 100 / 14.28571428571;
}
if (screenWidth >= 1200) {
return 100 / 16.66666667;
}
if (screenWidth >= 800) {
return 5;
}
if (screenWidth >= 700) {
return 4;
}
if (screenWidth >= 500) {
return 100 / 33.33333333;
}
return 100 / 33.33333333;
case 'square':
if (layoutManager.tv) {
return 100 / 16.66666667;
}
if (screenWidth >= 2200) {
return 100 / 10;
}
if (screenWidth >= 1920) {
return 100 / 11.1111111111;
}
if (screenWidth >= 1600) {
return 100 / 12.5;
}
if (screenWidth >= 1400) {
return 100 / 14.28571428571;
}
if (screenWidth >= 1200) {
return 100 / 16.66666667;
}
if (screenWidth >= 800) {
return 5;
}
if (screenWidth >= 700) {
return 4;
}
if (screenWidth >= 500) {
return 100 / 33.33333333;
}
return 2;
case 'banner':
if (screenWidth >= 2200) {
return 100 / 25;
}
if (screenWidth >= 1200) {
return 100 / 33.33333333;
}
if (screenWidth >= 800) {
return 2;
}
return 1;
case 'backdrop':
if (layoutManager.tv) {
return 100 / 25;
}
if (screenWidth >= 2500) {
return 6;
}
if (screenWidth >= 1600) {
return 5;
}
if (screenWidth >= 1200) {
return 4;
}
if (screenWidth >= 770) {
return 3;
}
if (screenWidth >= 420) {
return 2;
}
return 1;
case 'smallBackdrop':
if (screenWidth >= 1600) {
return 100 / 12.5;
}
if (screenWidth >= 1400) {
return 100 / 14.2857142857;
}
if (screenWidth >= 1200) {
return 100 / 16.66666667;
}
if (screenWidth >= 1000) {
return 5;
}
if (screenWidth >= 800) {
return 4;
}
if (screenWidth >= 500) {
return 100 / 33.33333333;
}
return 2;
case 'overflowSmallBackdrop':
if (layoutManager.tv) {
return 100 / 18.9;
}
if (isOrientationLandscape) {
if (screenWidth >= 800) {
return 100 / 15.5;
}
return 100 / 23.3;
} else {
if (screenWidth >= 540) {
return 100 / 30;
}
return 100 / 72;
}
case 'overflowPortrait':
if (layoutManager.tv) {
return 100 / 15.5;
}
if (isOrientationLandscape) {
if (screenWidth >= 1700) {
return 100 / 11.6;
}
return 100 / 15.5;
} else {
if (screenWidth >= 1400) {
return 100 / 15;
}
if (screenWidth >= 1200) {
return 100 / 18;
}
if (screenWidth >= 760) {
return 100 / 23;
}
if (screenWidth >= 400) {
return 100 / 31.5;
}
return 100 / 42;
}
case 'overflowSquare':
if (layoutManager.tv) {
return 100 / 15.5;
}
if (isOrientationLandscape) {
if (screenWidth >= 1700) {
return 100 / 11.6;
}
return 100 / 15.5;
} else {
if (screenWidth >= 1400) {
return 100 / 15;
}
if (screenWidth >= 1200) {
return 100 / 18;
}
if (screenWidth >= 760) {
return 100 / 23;
}
if (screenWidth >= 540) {
return 100 / 31.5;
}
return 100 / 42;
}
case 'overflowBackdrop':
if (layoutManager.tv) {
return 100 / 23.3;
}
if (isOrientationLandscape) {
if (screenWidth >= 1700) {
return 100 / 18.5;
}
return 100 / 23.3;
} else {
if (screenWidth >= 1800) {
return 100 / 23.5;
}
if (screenWidth >= 1400) {
return 100 / 30;
}
if (screenWidth >= 760) {
return 100 / 40;
}
if (screenWidth >= 640) {
return 100 / 56;
}
return 100 / 72;
}
default:
return 4;
}
}
/**
* Checks if the window is resizable.
* @param {number} windowWidth - Width of the device's screen.
@ -278,7 +73,7 @@ function isResizable(windowWidth) {
* @returns {number} Width of the image for a card.
*/
function getImageWidth(shape, screenWidth, isOrientationLandscape) {
const imagesPerRow = getPostersPerRow(shape, screenWidth, isOrientationLandscape);
const imagesPerRow = cardBuilderUtils.getPostersPerRow(shape, screenWidth, isOrientationLandscape, layoutManager.tv);
return Math.round(screenWidth / imagesPerRow);
}
@ -301,16 +96,16 @@ function setCardData(items, options) {
options.shape = 'banner';
options.coverImage = true;
} else if (primaryImageAspectRatio >= 1.33) {
options.shape = requestedShape === 'autooverflow' ? 'overflowBackdrop' : 'backdrop';
options.shape = getBackdropShape(requestedShape === 'autooverflow');
} else if (primaryImageAspectRatio > 0.71) {
options.shape = requestedShape === 'autooverflow' ? 'overflowSquare' : 'square';
options.shape = getSquareShape(requestedShape === 'autooverflow');
} else {
options.shape = requestedShape === 'autooverflow' ? 'overflowPortrait' : 'portrait';
options.shape = getPortraitShape(requestedShape === 'autooverflow');
}
}
if (!options.shape) {
options.shape = options.defaultShape || (requestedShape === 'autooverflow' ? 'overflowSquare' : 'square');
options.shape = options.defaultShape || getSquareShape(requestedShape === 'autooverflow');
}
}
@ -318,7 +113,7 @@ function setCardData(items, options) {
options.preferThumb = options.shape === 'backdrop' || options.shape === 'overflowBackdrop';
}
options.uiAspect = getDesiredAspect(options.shape);
options.uiAspect = cardBuilderUtils.getDesiredAspect(options.shape);
options.primaryImageAspectRatio = primaryImageAspectRatio;
if (!options.width && options.widths) {
@ -460,30 +255,6 @@ function buildCardsHtmlInternal(items, options) {
return html;
}
/**
* Computes the aspect ratio for a card given its shape.
* @param {string} shape - Shape for which to get the aspect ratio.
* @returns {null|number} Ratio of the shape.
*/
function getDesiredAspect(shape) {
if (shape) {
shape = shape.toLowerCase();
if (shape.indexOf('portrait') !== -1) {
return (2 / 3);
}
if (shape.indexOf('backdrop') !== -1) {
return (16 / 9);
}
if (shape.indexOf('square') !== -1) {
return 1;
}
if (shape.indexOf('banner') !== -1) {
return (1000 / 185);
}
}
return null;
}
/**
* @typedef {Object} CardImageUrl
* @property {string} imgUrl - Image URL.
@ -509,7 +280,7 @@ function getCardImageUrl(item, apiClient, options, shape) {
let imgUrl = null;
let imgTag = null;
let coverImage = false;
const uiAspect = getDesiredAspect(shape);
const uiAspect = cardBuilderUtils.getDesiredAspect(shape);
let imgType = null;
let itemId = null;

View file

@ -0,0 +1,173 @@
const ASPECT_RATIOS = {
portrait: (2 / 3),
backdrop: (16 / 9),
square: 1,
banner: (1000 / 185)
};
/**
* Computes the aspect ratio for a card given its shape.
* @param {string} shape - Shape for which to get the aspect ratio.
* @returns {null|number} Ratio of the shape.
*/
function getDesiredAspect(shape) {
if (!shape) {
return null;
}
shape = shape.toLowerCase();
if (shape.indexOf('portrait') !== -1) {
return ASPECT_RATIOS.portrait;
}
if (shape.indexOf('backdrop') !== -1) {
return ASPECT_RATIOS.backdrop;
}
if (shape.indexOf('square') !== -1) {
return ASPECT_RATIOS.square;
}
if (shape.indexOf('banner') !== -1) {
return ASPECT_RATIOS.banner;
}
return null;
}
/**
* Computes the number of posters per row.
* @param {string} shape - Shape of the cards.
* @param {number} screenWidth - Width of the screen.
* @param {boolean} isOrientationLandscape - Flag for the orientation of the screen.
* @param {boolean} isTV - Flag to denote if posters are rendered on a television screen.
* @returns {number} Number of cards per row for an itemsContainer.
*/
function getPostersPerRow(shape, screenWidth, isOrientationLandscape, isTV) {
switch (shape) {
case 'portrait': return postersPerRowPortrait(screenWidth, isTV);
case 'square': return postersPerRowSquare(screenWidth, isTV);
case 'banner': return postersPerRowBanner(screenWidth);
case 'backdrop': return postersPerRowBackdrop(screenWidth, isTV);
case 'smallBackdrop': return postersPerRowSmallBackdrop(screenWidth);
case 'overflowSmallBackdrop': return postersPerRowOverflowSmallBackdrop(screenWidth, isOrientationLandscape, isTV);
case 'overflowPortrait': return postersPerRowOverflowPortrait(screenWidth, isOrientationLandscape, isTV);
case 'overflowSquare': return postersPerRowOverflowSquare(screenWidth, isOrientationLandscape, isTV);
case 'overflowBackdrop': return postersPerRowOverflowBackdrop(screenWidth, isOrientationLandscape, isTV);
default: return 4;
}
}
const postersPerRowPortrait = (screenWidth, isTV) => {
switch (true) {
case isTV: return 100 / 16.66666667;
case screenWidth >= 2200: return 10;
case screenWidth >= 1920: return 100 / 11.1111111111;
case screenWidth >= 1600: return 8;
case screenWidth >= 1400: return 100 / 14.28571428571;
case screenWidth >= 1200: return 100 / 16.66666667;
case screenWidth >= 800: return 5;
case screenWidth >= 700: return 4;
case screenWidth >= 500: return 100 / 33.33333333;
default: return 100 / 33.33333333;
}
};
const postersPerRowSquare = (screenWidth, isTV) => {
switch (true) {
case isTV: return 100 / 16.66666667;
case screenWidth >= 2200: return 10;
case screenWidth >= 1920: return 100 / 11.1111111111;
case screenWidth >= 1600: return 8;
case screenWidth >= 1400: return 100 / 14.28571428571;
case screenWidth >= 1200: return 100 / 16.66666667;
case screenWidth >= 800: return 5;
case screenWidth >= 700: return 4;
case screenWidth >= 500: return 100 / 33.33333333;
default: return 2;
}
};
const postersPerRowBanner = (screenWidth) => {
switch (true) {
case screenWidth >= 2200: return 4;
case screenWidth >= 1200: return 100 / 33.33333333;
case screenWidth >= 800: return 2;
default: return 1;
}
};
const postersPerRowBackdrop = (screenWidth, isTV) => {
switch (true) {
case isTV: return 4;
case screenWidth >= 2500: return 6;
case screenWidth >= 1600: return 5;
case screenWidth >= 1200: return 4;
case screenWidth >= 770: return 3;
case screenWidth >= 420: return 2;
default: return 1;
}
};
function postersPerRowSmallBackdrop(screenWidth) {
switch (true) {
case screenWidth >= 1600: return 8;
case screenWidth >= 1400: return 100 / 14.2857142857;
case screenWidth >= 1200: return 100 / 16.66666667;
case screenWidth >= 1000: return 5;
case screenWidth >= 800: return 4;
case screenWidth >= 500: return 100 / 33.33333333;
default: return 2;
}
}
const postersPerRowOverflowSmallBackdrop = (screenWidth, isLandscape, isTV) => {
switch (true) {
case isTV: return 100 / 18.9;
case isLandscape && screenWidth >= 800: return 100 / 15.5;
case isLandscape: return 100 / 23.3;
case screenWidth >= 540: return 100 / 30;
default: return 100 / 72;
}
};
const postersPerRowOverflowPortrait = (screenWidth, isLandscape, isTV) => {
switch (true) {
case isTV: return 100 / 15.5;
case isLandscape && screenWidth >= 1700: return 100 / 11.6;
case isLandscape: return 100 / 15.5;
case screenWidth >= 1400: return 100 / 15;
case screenWidth >= 1200: return 100 / 18;
case screenWidth >= 760: return 100 / 23;
case screenWidth >= 400: return 100 / 31.5;
default: return 100 / 42;
}
};
const postersPerRowOverflowSquare = (screenWidth, isLandscape, isTV) => {
switch (true) {
case isTV: return 100 / 15.5;
case isLandscape && screenWidth >= 1700: return 100 / 11.6;
case isLandscape: return 100 / 15.5;
case screenWidth >= 1400: return 100 / 15;
case screenWidth >= 1200: return 100 / 18;
case screenWidth >= 760: return 100 / 23;
case screenWidth >= 540: return 100 / 31.5;
default: return 100 / 42;
}
};
const postersPerRowOverflowBackdrop = (screenWidth, isLandscape, isTV) => {
switch (true) {
case isTV: return 100 / 23.3;
case isLandscape && screenWidth >= 1700: return 100 / 18.5;
case isLandscape: return 100 / 23.3;
case screenWidth >= 1800: return 100 / 23.5;
case screenWidth >= 1400: return 100 / 30;
case screenWidth >= 760: return 100 / 40;
case screenWidth >= 640: return 100 / 56;
default: return 100 / 72;
}
};
export default {
getDesiredAspect,
getPostersPerRow
};

View file

@ -0,0 +1,417 @@
import { describe, expect, test } from 'vitest';
import cardBuilderUtils from './cardBuilderUtils';
describe('getDesiredAspect', () => {
test('"portrait" (case insensitive)', () => {
expect(cardBuilderUtils.getDesiredAspect('portrait')).toEqual((2 / 3));
expect(cardBuilderUtils.getDesiredAspect('PorTRaIt')).toEqual((2 / 3));
});
test('"backdrop" (case insensitive)', () => {
expect(cardBuilderUtils.getDesiredAspect('backdrop')).toEqual((16 / 9));
expect(cardBuilderUtils.getDesiredAspect('BaCkDroP')).toEqual((16 / 9));
});
test('"square" (case insensitive)', () => {
expect(cardBuilderUtils.getDesiredAspect('square')).toEqual(1);
expect(cardBuilderUtils.getDesiredAspect('sQuArE')).toEqual(1);
});
test('"banner" (case insensitive)', () => {
expect(cardBuilderUtils.getDesiredAspect('banner')).toEqual((1000 / 185));
expect(cardBuilderUtils.getDesiredAspect('BaNnEr')).toEqual((1000 / 185));
});
test('invalid shape', () => {
expect(cardBuilderUtils.getDesiredAspect('invalid')).toBeNull();
});
test('shape is not provided', () => {
expect(cardBuilderUtils.getDesiredAspect('')).toBeNull();
});
});
describe('getPostersPerRow', () => {
test('resolves to default of 4 posters per row if shape is not provided', () => {
expect(cardBuilderUtils.getPostersPerRow('', 0, false, false)).toEqual(4);
});
describe('portrait', () => {
const postersPerRowForPortrait = (screenWidth, isTV) => (cardBuilderUtils.getPostersPerRow('portrait', screenWidth, false, isTV));
test('television', () => {
expect(postersPerRowForPortrait(0, true)).toEqual(100 / 16.66666667);
});
test('screen width less than 500px', () => {
expect(postersPerRowForPortrait(100, false)).toEqual(100 / 33.33333333);
expect(postersPerRowForPortrait(499, false)).toEqual(100 / 33.33333333);
});
test('screen width greater or equal to 500px', () => {
expect(postersPerRowForPortrait(500, false)).toEqual(100 / 33.33333333);
expect(postersPerRowForPortrait(501, false)).toEqual(100 / 33.33333333);
});
test('screen width greater or equal to 700px', () => {
expect(postersPerRowForPortrait(700, false)).toEqual(4);
expect(postersPerRowForPortrait(701, false)).toEqual(4);
});
test('screen width greater or equal to 800px', () => {
expect(postersPerRowForPortrait(800, false)).toEqual(5);
expect(postersPerRowForPortrait(801, false)).toEqual(5);
});
test('screen width greater or equal to 1200px', () => {
expect(postersPerRowForPortrait(1200, false)).toEqual(100 / 16.66666667);
expect(postersPerRowForPortrait(1201, false)).toEqual(100 / 16.66666667);
});
test('screen width greater or equal to 1400px', () => {
expect(postersPerRowForPortrait(1400, false)).toEqual( 100 / 14.28571428571);
expect(postersPerRowForPortrait(1401, false)).toEqual( 100 / 14.28571428571);
});
test('screen width greater or equal to 1600px', () => {
expect(postersPerRowForPortrait(1600, false)).toEqual( 8);
expect(postersPerRowForPortrait(1601, false)).toEqual( 8);
});
test('screen width greater or equal to 1920px', () => {
expect(postersPerRowForPortrait(1920, false)).toEqual( 100 / 11.1111111111);
expect(postersPerRowForPortrait(1921, false)).toEqual( 100 / 11.1111111111);
});
test('screen width greater or equal to 2200px', () => {
expect(postersPerRowForPortrait(2200, false)).toEqual( 10);
expect(postersPerRowForPortrait(2201, false)).toEqual( 10);
});
});
describe('square', () => {
const postersPerRowForSquare = (screenWidth, isTV) => (cardBuilderUtils.getPostersPerRow('square', screenWidth, false, isTV));
test('television', () => {
expect(postersPerRowForSquare(0, true)).toEqual(100 / 16.66666667);
});
test('screen width less than 500px', () => {
expect(postersPerRowForSquare(100, false)).toEqual(2);
expect(postersPerRowForSquare(499, false)).toEqual(2);
});
test('screen width greater or equal to 500px', () => {
expect(postersPerRowForSquare(500, false)).toEqual(100 / 33.33333333);
expect(postersPerRowForSquare(501, false)).toEqual(100 / 33.33333333);
});
test('screen width greater or equal to 700px', () => {
expect(postersPerRowForSquare(700, false)).toEqual(4);
expect(postersPerRowForSquare(701, false)).toEqual(4);
});
test('screen width greater or equal to 800px', () => {
expect(postersPerRowForSquare(800, false)).toEqual(5);
expect(postersPerRowForSquare(801, false)).toEqual(5);
});
test('screen width greater or equal to 1200px', () => {
expect(postersPerRowForSquare(1200, false)).toEqual(100 / 16.66666667);
expect(postersPerRowForSquare(1201, false)).toEqual(100 / 16.66666667);
});
test('screen width greater or equal to 1400px', () => {
expect(postersPerRowForSquare(1400, false)).toEqual( 100 / 14.28571428571);
expect(postersPerRowForSquare(1401, false)).toEqual( 100 / 14.28571428571);
});
test('screen width greater or equal to 1600px', () => {
expect(postersPerRowForSquare(1600, false)).toEqual(8);
expect(postersPerRowForSquare(1601, false)).toEqual(8);
});
test('screen width greater or equal to 1920px', () => {
expect(postersPerRowForSquare(1920, false)).toEqual(100 / 11.1111111111);
expect(postersPerRowForSquare(1921, false)).toEqual(100 / 11.1111111111);
});
test('screen width greater or equal to 2200px', () => {
expect(postersPerRowForSquare(2200, false)).toEqual( 10);
expect(postersPerRowForSquare(2201, false)).toEqual( 10);
});
});
describe('banner', () => {
const postersPerRowForBanner = (screenWidth) => (cardBuilderUtils.getPostersPerRow('banner', screenWidth, false, false));
test('screen width less than 800px', () => {
expect(postersPerRowForBanner(799)).toEqual(1);
});
test('screen width greater than or equal to 800px', () => {
expect(postersPerRowForBanner(800)).toEqual(2);
expect(postersPerRowForBanner(801)).toEqual(2);
});
test('screen width greater than or equal to 1200px', () => {
expect(postersPerRowForBanner(1200)).toEqual(100 / 33.33333333);
expect(postersPerRowForBanner(1201)).toEqual(100 / 33.33333333);
});
test('screen width greater than or equal to 2200px', () => {
expect(postersPerRowForBanner(2200)).toEqual(4);
expect(postersPerRowForBanner(2201)).toEqual(4);
});
});
describe('backdrop', () => {
const postersPerRowForBackdrop = (screenWidth, isTV) => (cardBuilderUtils.getPostersPerRow('backdrop', screenWidth, false, isTV));
test('television', () => {
expect(postersPerRowForBackdrop(0, true)).toEqual(4);
});
test('screen width less than 420px', () => {
expect(postersPerRowForBackdrop(100, false)).toEqual(1);
expect(postersPerRowForBackdrop(419, false)).toEqual(1);
});
test('screen width greater or equal to 420px', () => {
expect(postersPerRowForBackdrop(420, false)).toEqual(2);
expect(postersPerRowForBackdrop(421, false)).toEqual(2);
});
test('screen width greater or equal to 770px', () => {
expect(postersPerRowForBackdrop(770, false)).toEqual(3);
expect(postersPerRowForBackdrop(771, false)).toEqual(3);
});
test('screen width greater or equal to 1200px', () => {
expect(postersPerRowForBackdrop(1200, false)).toEqual(4);
expect(postersPerRowForBackdrop(1201, false)).toEqual(4);
});
test('screen width greater or equal to 1600px', () => {
expect(postersPerRowForBackdrop(1600, false)).toEqual(5);
expect(postersPerRowForBackdrop(1601, false)).toEqual(5);
});
test('screen width greater or equal to 2500px', () => {
expect(postersPerRowForBackdrop(2500, false)).toEqual(6);
expect(postersPerRowForBackdrop(2501, false)).toEqual(6);
});
});
describe('small backdrop', () => {
const postersPerRowForSmallBackdrop = (screenWidth) => (cardBuilderUtils.getPostersPerRow('smallBackdrop', screenWidth, false, false));
test('screen width less than 500px', () => {
expect(postersPerRowForSmallBackdrop(100)).toEqual(2);
expect(postersPerRowForSmallBackdrop(499)).toEqual(2);
});
test('screen width greater or equal to 500px', () => {
expect(postersPerRowForSmallBackdrop(500)).toEqual(100 / 33.33333333);
expect(postersPerRowForSmallBackdrop(501)).toEqual(100 / 33.33333333);
});
test('screen width greater or equal to 800px', () => {
expect(postersPerRowForSmallBackdrop(800)).toEqual(4);
expect(postersPerRowForSmallBackdrop(801)).toEqual(4);
});
test('screen width greater or equal to 1000px', () => {
expect(postersPerRowForSmallBackdrop(1000)).toEqual(5);
expect(postersPerRowForSmallBackdrop(1001)).toEqual(5);
});
test('screen width greater or equal to 1200px', () => {
expect(postersPerRowForSmallBackdrop(1200)).toEqual(100 / 16.66666667);
expect(postersPerRowForSmallBackdrop(1201)).toEqual(100 / 16.66666667);
});
test('screen width greater or equal to 1400px', () => {
expect(postersPerRowForSmallBackdrop(1400)).toEqual(100 / 14.2857142857);
expect(postersPerRowForSmallBackdrop(1401)).toEqual(100 / 14.2857142857);
});
test('screen width greater or equal to 1600px', () => {
expect(postersPerRowForSmallBackdrop(1600)).toEqual(8);
expect(postersPerRowForSmallBackdrop(1601)).toEqual(8);
});
});
describe('overflow small backdrop', () => {
const postersPerRowForOverflowSmallBackdrop = (screenWidth, isLandscape, isTV) => (cardBuilderUtils.getPostersPerRow('overflowSmallBackdrop', screenWidth, isLandscape, isTV));
test('television', () => {
expect(postersPerRowForOverflowSmallBackdrop(0, false, true)).toEqual( 100 / 18.9);
});
describe('non-landscape', () => {
test('screen width greater or equal to 540px', () => {
expect(postersPerRowForOverflowSmallBackdrop(540, false)).toEqual(100 / 30);
expect(postersPerRowForOverflowSmallBackdrop(541, false)).toEqual(100 / 30);
});
test('screen width is less than 540px', () => {
expect(postersPerRowForOverflowSmallBackdrop(539, false)).toEqual(100 / 72);
expect(postersPerRowForOverflowSmallBackdrop(100, false)).toEqual(100 / 72);
});
});
describe('landscape', () => {
test('screen width greater or equal to 800px', () => {
expect(postersPerRowForOverflowSmallBackdrop(800, true)).toEqual(100 / 15.5);
expect(postersPerRowForOverflowSmallBackdrop(801, true)).toEqual(100 / 15.5);
});
test('screen width is less than 800px', () => {
expect(postersPerRowForOverflowSmallBackdrop(799, true)).toEqual(100 / 23.3);
expect(postersPerRowForOverflowSmallBackdrop(100, true)).toEqual(100 / 23.3);
});
});
});
describe('overflow portrait', () => {
const postersPerRowForOverflowPortrait = (screenWidth, isLandscape, isTV) => (cardBuilderUtils.getPostersPerRow('overflowPortrait', screenWidth, isLandscape, isTV));
test('television', () => {
expect(postersPerRowForOverflowPortrait(0, false, true)).toEqual( 100 / 15.5);
});
describe('non-landscape', () => {
test('screen width greater or equal to 1400px', () => {
expect(postersPerRowForOverflowPortrait(1400, false)).toEqual(100 / 15);
expect(postersPerRowForOverflowPortrait(1401, false)).toEqual(100 / 15);
});
test('screen width greater or equal to 1200px', () => {
expect(postersPerRowForOverflowPortrait(1200, false)).toEqual(100 / 18);
expect(postersPerRowForOverflowPortrait(1201, false)).toEqual(100 / 18);
});
test('screen width greater or equal to 760px', () => {
expect(postersPerRowForOverflowPortrait(760, false)).toEqual(100 / 23);
expect(postersPerRowForOverflowPortrait(761, false)).toEqual(100 / 23);
});
test('screen width greater or equal to 400px', () => {
expect(postersPerRowForOverflowPortrait(400, false)).toEqual(100 / 31.5);
expect(postersPerRowForOverflowPortrait(401, false)).toEqual(100 / 31.5);
});
test('screen width is less than 400px', () => {
expect(postersPerRowForOverflowPortrait(399, false)).toEqual(100 / 42);
expect(postersPerRowForOverflowPortrait(100, false)).toEqual(100 / 42);
});
});
describe('landscape', () => {
test('screen width greater or equal to 1700px', () => {
expect(postersPerRowForOverflowPortrait(1700, true)).toEqual(100 / 11.6);
expect(postersPerRowForOverflowPortrait(1701, true)).toEqual(100 / 11.6);
});
test('screen width is less than 1700px', () => {
expect(postersPerRowForOverflowPortrait(1699, true)).toEqual(100 / 15.5);
expect(postersPerRowForOverflowPortrait(100, true)).toEqual(100 / 15.5);
});
});
});
describe('overflow square', () => {
const postersPerRowForOverflowSquare = (screenWidth, isLandscape, isTV) => (cardBuilderUtils.getPostersPerRow('overflowSquare', screenWidth, isLandscape, isTV));
test('television', () => {
expect(postersPerRowForOverflowSquare(0, false, true)).toEqual( 100 / 15.5);
});
describe('non-landscape', () => {
test('screen width greater or equal to 1400px', () => {
expect(postersPerRowForOverflowSquare(1400, false)).toEqual(100 / 15);
expect(postersPerRowForOverflowSquare(1401, false)).toEqual(100 / 15);
});
test('screen width greater or equal to 1200px', () => {
expect(postersPerRowForOverflowSquare(1200, false)).toEqual(100 / 18);
expect(postersPerRowForOverflowSquare(1201, false)).toEqual(100 / 18);
});
test('screen width greater or equal to 760px', () => {
expect(postersPerRowForOverflowSquare(760, false)).toEqual(100 / 23);
expect(postersPerRowForOverflowSquare(761, false)).toEqual(100 / 23);
});
test('screen width greater or equal to 540px', () => {
expect(postersPerRowForOverflowSquare(540, false)).toEqual(100 / 31.5);
expect(postersPerRowForOverflowSquare(541, false)).toEqual(100 / 31.5);
});
test('screen width is less than 540px', () => {
expect(postersPerRowForOverflowSquare(539, false)).toEqual(100 / 42);
expect(postersPerRowForOverflowSquare(100, false)).toEqual(100 / 42);
});
});
describe('landscape', () => {
test('screen width greater or equal to 1700px', () => {
expect(postersPerRowForOverflowSquare(1700, true)).toEqual(100 / 11.6);
expect(postersPerRowForOverflowSquare(1701, true)).toEqual(100 / 11.6);
});
test('screen width is less than 1700px', () => {
expect(postersPerRowForOverflowSquare(1699, true)).toEqual(100 / 15.5);
expect(postersPerRowForOverflowSquare(100, true)).toEqual(100 / 15.5);
});
});
});
describe('overflow backdrop', () => {
const postersPerRowForOverflowBackdrop = (screenWidth, isLandscape, isTV) => (cardBuilderUtils.getPostersPerRow('overflowBackdrop', screenWidth, isLandscape, isTV));
test('television', () => {
expect(postersPerRowForOverflowBackdrop(0, false, true)).toEqual( 100 / 23.3);
});
describe('non-landscape', () => {
test('screen width greater or equal to 1800px', () => {
expect(postersPerRowForOverflowBackdrop(1800, false)).toEqual(100 / 23.5);
expect(postersPerRowForOverflowBackdrop(1801, false)).toEqual(100 / 23.5);
});
test('screen width greater or equal to 1400px', () => {
expect(postersPerRowForOverflowBackdrop(1400, false)).toEqual(100 / 30);
expect(postersPerRowForOverflowBackdrop(1401, false)).toEqual(100 / 30);
});
test('screen width greater or equal to 760px', () => {
expect(postersPerRowForOverflowBackdrop(760, false)).toEqual(100 / 40);
expect(postersPerRowForOverflowBackdrop(761, false)).toEqual(100 / 40);
});
test('screen width greater or equal to 640px', () => {
expect(postersPerRowForOverflowBackdrop(640, false)).toEqual(100 / 56);
expect(postersPerRowForOverflowBackdrop(641, false)).toEqual(100 / 56);
});
test('screen width is less than 640px', () => {
expect(postersPerRowForOverflowBackdrop(639, false)).toEqual(100 / 72);
expect(postersPerRowForOverflowBackdrop(100, false)).toEqual(100 / 72);
});
});
describe('landscape', () => {
test('screen width greater or equal to 1700px', () => {
expect(postersPerRowForOverflowBackdrop(1700, true)).toEqual(100 / 18.5);
expect(postersPerRowForOverflowBackdrop(1701, true)).toEqual(100 / 18.5);
});
test('screen width is less than 1700px', () => {
expect(postersPerRowForOverflowBackdrop(1699, true)).toEqual(100 / 23.3);
expect(postersPerRowForOverflowBackdrop(100, true)).toEqual(100 / 23.3);
});
});
});
});

View file

@ -10,28 +10,28 @@ const createLinkElement = (activeTab: string) => ({
is="emby-linkbutton"
data-role="button"
class="${activeTab === 'useredit' ? 'ui-btn-active' : ''}"
onclick="Dashboard.navigate('useredit.html', true);">
onclick="Dashboard.navigate('/dashboard/users/profile', true);">
${globalize.translate('Profile')}
</a>
<a href="#"
is="emby-linkbutton"
data-role="button"
class="${activeTab === 'userlibraryaccess' ? 'ui-btn-active' : ''}"
onclick="Dashboard.navigate('userlibraryaccess.html', true);">
onclick="Dashboard.navigate('/dashboard/users/access', true);">
${globalize.translate('TabAccess')}
</a>
<a href="#"
is="emby-linkbutton"
data-role="button"
class="${activeTab === 'userparentalcontrol' ? 'ui-btn-active' : ''}"
onclick="Dashboard.navigate('userparentalcontrol.html', true);">
onclick="Dashboard.navigate('/dashboard/users/parentalcontrol', true);">
${globalize.translate('TabParentalControl')}
</a>
<a href="#"
is="emby-linkbutton"
data-role="button"
class="${activeTab === 'userpassword' ? 'ui-btn-active' : ''}"
onclick="Dashboard.navigate('userpassword.html', true);">
onclick="Dashboard.navigate('/dashboard/users/password', true);">
${globalize.translate('HeaderPassword')}
</a>`
});

View file

@ -11,7 +11,7 @@ const createLinkElement = ({ user, renderImgUrl }: { user: UserDto, renderImgUrl
__html: `<a
is="emby-linkbutton"
class="cardContent"
href="#/useredit.html?userId=${user.Id}"
href="#/dashboard/users/profile?userId=${user.Id}"
>
${renderImgUrl}
</a>`

View file

@ -36,7 +36,7 @@ function loadScreensavers(context, userSettings) {
const selectScreensaver = context.querySelector('.selectScreensaver');
const options = pluginManager.ofType(PluginType.Screensaver).map(plugin => {
return {
name: plugin.name,
name: globalize.translate(plugin.name),
value: plugin.id
};
});

View file

@ -1,50 +1,42 @@
import loading from './loading/loading';
import cardBuilder from './cardbuilder/cardBuilder';
import dom from '../scripts/dom';
import dom from 'scripts/dom';
import globalize from 'scripts/globalize';
import { getBackdropShape, getPortraitShape, getSquareShape } from 'utils/card';
import { getParameterByName } from 'utils/url';
import { appHost } from './apphost';
import cardBuilder from './cardbuilder/cardBuilder';
import imageLoader from './images/imageLoader';
import globalize from '../scripts/globalize';
import layoutManager from './layoutManager';
import { getParameterByName } from '../utils/url.ts';
import '../styles/scrollstyles.scss';
import '../elements/emby-itemscontainer/emby-itemscontainer';
import loading from './loading/loading';
import 'elements/emby-itemscontainer/emby-itemscontainer';
import 'styles/scrollstyles.scss';
function enableScrollX() {
return !layoutManager.desktop;
}
function getThumbShape() {
return enableScrollX() ? 'overflowBackdrop' : 'backdrop';
}
function getPosterShape() {
return enableScrollX() ? 'overflowPortrait' : 'portrait';
}
function getSquareShape() {
return enableScrollX() ? 'overflowSquare' : 'square';
}
function getSections() {
return [{
name: 'Movies',
types: 'Movie',
id: 'favoriteMovies',
shape: getPosterShape(),
shape: getPortraitShape(enableScrollX()),
showTitle: false,
overlayPlayButton: true
}, {
name: 'Shows',
types: 'Series',
id: 'favoriteShows',
shape: getPosterShape(),
shape: getPortraitShape(enableScrollX()),
showTitle: false,
overlayPlayButton: true
}, {
name: 'Episodes',
types: 'Episode',
id: 'favoriteEpisode',
shape: getThumbShape(),
shape: getBackdropShape(enableScrollX()),
preferThumb: false,
showTitle: true,
showParentTitle: true,
@ -55,7 +47,7 @@ function getSections() {
name: 'Videos',
types: 'Video,MusicVideo',
id: 'favoriteVideos',
shape: getThumbShape(),
shape: getBackdropShape(enableScrollX()),
preferThumb: true,
showTitle: true,
overlayPlayButton: true,
@ -65,7 +57,7 @@ function getSections() {
name: 'Artists',
types: 'MusicArtist',
id: 'favoriteArtists',
shape: getSquareShape(),
shape: getSquareShape(enableScrollX()),
preferThumb: false,
showTitle: true,
overlayText: false,
@ -77,7 +69,7 @@ function getSections() {
name: 'Albums',
types: 'MusicAlbum',
id: 'favoriteAlbums',
shape: getSquareShape(),
shape: getSquareShape(enableScrollX()),
preferThumb: false,
showTitle: true,
overlayText: false,
@ -89,7 +81,7 @@ function getSections() {
name: 'Songs',
types: 'Audio',
id: 'favoriteSongs',
shape: getSquareShape(),
shape: getSquareShape(enableScrollX()),
preferThumb: false,
showTitle: true,
overlayText: false,

View file

@ -1,18 +1,23 @@
import escapeHtml from 'escape-html';
import globalize from 'scripts/globalize';
import imageHelper from 'scripts/imagehelper';
import { getBackdropShape, getPortraitShape, getSquareShape } from 'utils/card';
import Dashboard from 'utils/dashboard';
import cardBuilder from '../cardbuilder/cardBuilder';
import layoutManager from '../layoutManager';
import imageLoader from '../images/imageLoader';
import globalize from '../../scripts/globalize';
import layoutManager from '../layoutManager';
import { appRouter } from '../router/appRouter';
import imageHelper from '../../scripts/imagehelper';
import '../../elements/emby-button/paper-icon-button-light';
import '../../elements/emby-itemscontainer/emby-itemscontainer';
import '../../elements/emby-scroller/emby-scroller';
import '../../elements/emby-button/emby-button';
import './homesections.scss';
import Dashboard from '../../utils/dashboard';
import ServerConnections from '../ServerConnections';
import 'elements/emby-button/paper-icon-button-light';
import 'elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-scroller/emby-scroller';
import 'elements/emby-button/emby-button';
import './homesections.scss';
export function getDefaultSection(index) {
switch (index) {
case 0:
@ -69,11 +74,14 @@ export function loadSections(elem, apiClient, user, userSettings) {
promises.push(loadSection(elem, apiClient, user, userSettings, userViews, sections, i));
}
return Promise.all(promises).then(function () {
return resume(elem, {
refresh: true
return Promise.all(promises)
// Timeout for polyfilled CustomElements (webOS 1.2)
.then(() => new Promise((resolve) => setTimeout(resolve, 0)))
.then(() => {
return resume(elem, {
refresh: true
});
});
});
} else {
let noLibDescription;
if (user.Policy?.IsAdministrator) {
@ -91,7 +99,7 @@ export function loadSections(elem, apiClient, user, userSettings) {
const createNowLink = elem.querySelector('#button-createLibrary');
if (createNowLink) {
createNowLink.addEventListener('click', function () {
Dashboard.navigate('library.html');
Dashboard.navigate('dashboard/libraries');
});
}
}
@ -166,18 +174,6 @@ function enableScrollX() {
return true;
}
function getSquareShape() {
return enableScrollX() ? 'overflowSquare' : 'square';
}
function getThumbShape() {
return enableScrollX() ? 'overflowBackdrop' : 'backdrop';
}
function getPortraitShape() {
return enableScrollX() ? 'overflowPortrait' : 'portrait';
}
function getLibraryButtonsHtml(items) {
let html = '';
@ -241,11 +237,11 @@ function getLatestItemsHtmlFn(itemType, viewType) {
const cardLayout = false;
let shape;
if (itemType === 'Channel' || viewType === 'movies' || viewType === 'books' || viewType === 'tvshows') {
shape = getPortraitShape();
shape = getPortraitShape(enableScrollX());
} else if (viewType === 'music' || viewType === 'homevideos') {
shape = getSquareShape();
shape = getSquareShape(enableScrollX());
} else {
shape = getThumbShape();
shape = getBackdropShape(enableScrollX());
}
return cardBuilder.getCardsHtml({
@ -342,7 +338,7 @@ export function loadLibraryTiles(elem, apiClient, user, userSettings, shape, use
html += cardBuilder.getCardsHtml({
items: userViews,
shape: getThumbShape(),
shape: getBackdropShape(enableScrollX()),
showTitle: true,
centerText: true,
overlayText: false,
@ -420,7 +416,9 @@ function getItemsToResumeHtmlFn(useEpisodeImages, mediaType) {
items: items,
preferThumb: true,
inheritThumb: !useEpisodeImages,
shape: (mediaType === 'Book') ? getPortraitShape() : getThumbShape(),
shape: (mediaType === 'Book') ?
getPortraitShape(enableScrollX()) :
getBackdropShape(enableScrollX()),
overlayText: false,
showTitle: true,
showParentTitle: true,
@ -468,7 +466,7 @@ function getOnNowItemsHtml(items) {
showChannelName: false,
showAirDateTime: false,
showAirEndTime: true,
defaultShape: getThumbShape(),
defaultShape: getBackdropShape(enableScrollX()),
lines: 3,
overlayPlayButton: true
});
@ -611,7 +609,7 @@ function getNextUpItemsHtmlFn(useEpisodeImages) {
items: items,
preferThumb: true,
inheritThumb: !useEpisodeImages,
shape: getThumbShape(),
shape: getBackdropShape(enableScrollX()),
overlayText: false,
showTitle: true,
showParentTitle: true,

View file

@ -445,7 +445,7 @@ function executeCommand(item, id, options) {
});
break;
case 'multiSelect':
import('./multiSelect/multiSelect').then(({ startMultiSelect: startMultiSelect }) => {
import('./multiSelect/multiSelect').then(({ startMultiSelect }) => {
const card = dom.parentWithClass(options.positionTo, 'card');
startMultiSelect(card);
});

View file

@ -1,4 +1,4 @@
import loadable from '@loadable/component';
import loadable, { LoadableComponent } from '@loadable/component';
import React from 'react';
import { Route } from 'react-router-dom';
@ -10,13 +10,18 @@ export enum AsyncRouteType {
export interface AsyncRoute {
/** The URL path for this route. */
path: string
/** The relative path to the page component in the routes directory. */
page: string
/** The route should use the page component from the experimental app. */
/**
* The relative path to the page component in the routes directory.
* Will fallback to using the `path` value if not specified.
*/
page?: string
/** The page element to render. */
element?: LoadableComponent<AsyncPageProps>
/** The page type used to load the correct page element. */
type?: AsyncRouteType
}
interface AsyncPageProps {
export interface AsyncPageProps {
/** The relative path to the page component in the routes directory. */
page: string
}
@ -31,14 +36,19 @@ const StableAsyncPage = loadable(
{ cacheKey: (props: AsyncPageProps) => props.page }
);
export const toAsyncPageRoute = ({ path, page, type = AsyncRouteType.Stable }: AsyncRoute) => (
<Route
key={path}
path={path}
element={(
export const toAsyncPageRoute = ({ path, page, element, type = AsyncRouteType.Stable }: AsyncRoute) => {
const Element = element
|| (
type === AsyncRouteType.Experimental ?
<ExperimentalAsyncPage page={page} /> :
<StableAsyncPage page={page} />
)}
/>
);
ExperimentalAsyncPage :
StableAsyncPage
);
return (
<Route
key={path}
path={path}
element={<Element page={page ?? path} />}
/>
);
};

View file

@ -0,0 +1,28 @@
import React from 'react';
import { Navigate, Route, useLocation } from 'react-router-dom';
export interface Redirect {
from: string
to: string
}
const RedirectWithSearch = ({ to }: { to: string }) => {
const { search } = useLocation();
return (
<Navigate
replace
to={`${to}${search}`}
/>
);
};
export function toRedirectRoute({ from, to }: Redirect) {
return (
<Route
key={from}
path={from}
element={<RedirectWithSearch to={to} />}
/>
);
}

View file

@ -527,7 +527,7 @@ class AppRouter {
}
if (item === 'manageserver') {
return '#/dashboard.html';
return '#/dashboard';
}
if (item === 'recordedtv') {

View file

@ -46,15 +46,8 @@ function init(instance) {
if (inputOffset) {
inputOffset = inputOffset[0];
inputOffset = parseFloat(inputOffset);
inputOffset = Math.min(30, Math.max(-30, inputOffset));
// replace current text by considered offset
this.textContent = inputOffset + 's';
// set new offset
playbackManager.setSubtitleOffset(inputOffset, player);
// synchronize with slider value
subtitleSyncSlider.updateOffset(
getSliderValueFromOffset(inputOffset));
subtitleSyncSlider.updateOffset(inputOffset);
} else {
this.textContent = (playbackManager.getPlayerSubtitleOffset(player) || 0) + 's';
}
@ -79,23 +72,26 @@ function init(instance) {
}
};
function updateSubtitleOffset() {
const value = parseFloat(subtitleSyncSlider.value);
// set new offset
playbackManager.setSubtitleOffset(value, player);
// synchronize with textField value
subtitleSyncTextField.updateOffset(value);
}
subtitleSyncSlider.updateOffset = function (sliderValue) {
// default value is 0s = 0ms
this.value = sliderValue === undefined ? 0 : sliderValue;
updateSubtitleOffset();
};
subtitleSyncSlider.addEventListener('change', function () {
// set new offset
playbackManager.setSubtitleOffset(getOffsetFromSliderValue(this.value), player);
// synchronize with textField value
subtitleSyncTextField.updateOffset(
getOffsetFromSliderValue(this.value));
});
subtitleSyncSlider.addEventListener('change', () => updateSubtitleOffset());
subtitleSyncSlider.getBubbleHtml = function (value) {
const newOffset = getOffsetFromPercentage(value);
subtitleSyncSlider.getBubbleHtml = function (_, value) {
return '<h1 class="sliderBubbleText">'
+ (newOffset > 0 ? '+' : '') + parseFloat(newOffset) + 's'
+ (value > 0 ? '+' : '') + parseFloat(value) + 's'
+ '</h1>';
};
@ -107,25 +103,6 @@ function init(instance) {
instance.element = parent;
}
function getOffsetFromPercentage(value) {
// convert percentage to fraction
let offset = (value - 50) / 50;
// multiply by offset min/max range value (-x to +x) :
offset *= 30;
return offset.toFixed(1);
}
function getOffsetFromSliderValue(value) {
// convert slider value to offset
const offset = value / 10;
return offset.toFixed(1);
}
function getSliderValueFromOffset(value) {
const sliderValue = value * 10;
return Math.min(300, Math.max(-300, sliderValue.toFixed(1)));
}
class SubtitleSync {
constructor(currentPlayer) {
player = currentPlayer;

View file

@ -3,7 +3,7 @@
<button type="button" is="paper-icon-button-light" class="subtitleSync-closeButton"><span class="material-icons close" aria-hidden="true"></span></button>
<div class="subtitleSyncTextField" contenteditable="true" spellcheck="false">0s</div>
<div class="sliderContainer subtitleSyncSliderContainer">
<input is="emby-slider" type="range" step="1" min="-300" max="300" value="0" class="subtitleSyncSlider" data-slider-keep-progress="true" />
<input is="emby-slider" type="range" step="0.1" min="-30" max="30" value="0" class="subtitleSyncSlider" data-slider-keep-progress="true" />
</div>
</div>
</div>

View file

@ -1,5 +1,7 @@
import escapeHtml from 'escape-html';
import { getSubtitleApi } from '@jellyfin/sdk/lib/utils/api/subtitle-api';
import { toApi } from 'utils/jellyfin-apiclient/compat';
import dialogHelper from '../../components/dialogHelper/dialogHelper';
import ServerConnections from '../ServerConnections';
import dom from '../../scripts/dom';
@ -13,6 +15,7 @@ import '../../elements/emby-button/emby-button';
import '../../elements/emby-select/emby-select';
import '../formdialog.scss';
import './style.scss';
import { readFileAsBase64 } from 'utils/file';
let currentItemId;
let currentServerId;
@ -75,7 +78,7 @@ function setFiles(page, files) {
reader.readAsDataURL(file);
}
function onSubmit(e) {
async function onSubmit(e) {
const file = currentFile;
if (!isValidSubtitleFile(file)) {
@ -89,8 +92,17 @@ function onSubmit(e) {
const dlg = dom.parentWithClass(this, 'dialog');
const language = dlg.querySelector('#selectLanguage').value;
const isForced = dlg.querySelector('#chkIsForced').checked;
const isHearingImpaired = dlg.querySelector('#chkIsHearingImpaired').checked;
ServerConnections.getApiClient(currentServerId).uploadItemSubtitle(currentItemId, language, isForced, file).then(function () {
const subtitleApi = getSubtitleApi(toApi(ServerConnections.getApiClient(currentServerId)));
const data = await readFileAsBase64(file);
const format = file.name.substring(file.name.lastIndexOf('.') + 1).toLowerCase();
subtitleApi.uploadSubtitle({
itemId: currentItemId,
uploadSubtitleDto: { Data: data, Language: language, IsForced: isForced, Format: format, IsHearingImpaired: isHearingImpaired }
}).then(function () {
dlg.querySelector('#uploadSubtitle').value = '';
loading.hide();
hasChanges = true;

View file

@ -31,6 +31,10 @@
<input type="checkbox" is="emby-checkbox" id="chkIsForced" />
<span>${LabelIsForced}</span>
</label>
<label>
<input type="checkbox" is="emby-checkbox" id="chkIsHearingImpaired" />
<span>${LabelIsHearingImpaired}</span>
</label>
</div>
<div class="selectContainer flex-grow">
<select is="emby-select" id="selectLanguage" required="required" label="${LabelLanguage}"></select>

View file

@ -0,0 +1,129 @@
import ArrowBack from '@mui/icons-material/ArrowBack';
import MenuIcon from '@mui/icons-material/Menu';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Toolbar from '@mui/material/Toolbar';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import React, { FC, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import appIcon from 'assets/img/icon-transparent.png';
import { appRouter } from 'components/router/appRouter';
import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
import UserMenuButton from './UserMenuButton';
interface AppToolbarProps {
buttons?: ReactNode
isDrawerAvailable: boolean
isDrawerOpen: boolean
onDrawerButtonClick: (event: React.MouseEvent<HTMLElement>) => void
}
const onBackButtonClick = () => {
appRouter.back()
.catch(err => {
console.error('[AppToolbar] error calling appRouter.back', err);
});
};
const AppToolbar: FC<AppToolbarProps> = ({
buttons,
children,
isDrawerAvailable,
isDrawerOpen,
onDrawerButtonClick
}) => {
const { user } = useApi();
const isUserLoggedIn = Boolean(user);
const isBackButtonAvailable = appRouter.canGoBack();
return (
<Toolbar
variant='dense'
sx={{
flexWrap: {
xs: 'wrap',
lg: 'nowrap'
}
}}
>
{isUserLoggedIn && isDrawerAvailable && (
<Tooltip title={globalize.translate(isDrawerOpen ? 'MenuClose' : 'MenuOpen')}>
<IconButton
size='large'
edge='start'
color='inherit'
aria-label={globalize.translate(isDrawerOpen ? 'MenuClose' : 'MenuOpen')}
onClick={onDrawerButtonClick}
>
<MenuIcon />
</IconButton>
</Tooltip>
)}
{isBackButtonAvailable && (
<Tooltip title={globalize.translate('ButtonBack')}>
<IconButton
size='large'
// Set the edge if the drawer button is not shown
edge={!(isUserLoggedIn && isDrawerAvailable) ? 'start' : undefined}
color='inherit'
aria-label={globalize.translate('ButtonBack')}
onClick={onBackButtonClick}
>
<ArrowBack />
</IconButton>
</Tooltip>
)}
<Box
component={Link}
to='/'
color='inherit'
aria-label={globalize.translate('Home')}
sx={{
ml: 2,
display: 'inline-flex',
textDecoration: 'none'
}}
>
<Box
component='img'
src={appIcon}
sx={{
height: '2rem',
marginInlineEnd: 1
}}
/>
<Typography
variant='h6'
noWrap
component='div'
sx={{ display: { xs: 'none', sm: 'inline-block' } }}
>
Jellyfin
</Typography>
</Box>
{children}
{isUserLoggedIn && (
<>
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'flex-end' }}>
{buttons}
</Box>
<Box sx={{ flexGrow: 0 }}>
<UserMenuButton />
</Box>
</>
)}
</Toolbar>
);
};
export default AppToolbar;

View file

@ -0,0 +1,196 @@
import AccountCircle from '@mui/icons-material/AccountCircle';
import AppSettingsAlt from '@mui/icons-material/AppSettingsAlt';
import Close from '@mui/icons-material/Close';
import DashboardIcon from '@mui/icons-material/Dashboard';
import Edit from '@mui/icons-material/Edit';
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>
])}
{/* ADMIN LINKS */}
{user?.Policy?.IsAdministrator && ([
<Divider key='admin-links-divider' />,
<MenuItem
key='admin-dashboard-link'
component={Link}
to='/dashboard'
onClick={onMenuClose}
>
<ListItemIcon>
<DashboardIcon />
</ListItemIcon>
<ListItemText primary={globalize.translate('TabDashboard')} />
</MenuItem>,
<MenuItem
key='admin-metadata-link'
component={Link}
to='/metadata'
onClick={onMenuClose}
>
<ListItemIcon>
<Edit />
</ListItemIcon>
<ListItemText primary={globalize.translate('MetadataManager')} />
</MenuItem>
])}
<Divider />
<MenuItem
component={Link}
to='/quickconnect'
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,51 @@
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import React, { useCallback, useState } from 'react';
import UserAvatar from 'components/UserAvatar';
import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
import AppUserMenu, { ID } from './AppUserMenu';
const UserMenuButton = () => {
const { user } = useApi();
const [ userMenuAnchorEl, setUserMenuAnchorEl ] = useState<null | HTMLElement>(null);
const isUserMenuOpen = Boolean(userMenuAnchorEl);
const onUserButtonClick = useCallback((event) => {
setUserMenuAnchorEl(event.currentTarget);
}, [ setUserMenuAnchorEl ]);
const onUserMenuClose = useCallback(() => {
setUserMenuAnchorEl(null);
}, [ setUserMenuAnchorEl ]);
return (
<>
<Tooltip title={globalize.translate('UserMenu')}>
<IconButton
size='large'
edge='end'
aria-label={globalize.translate('UserMenu')}
aria-controls={ID}
aria-haspopup='true'
onClick={onUserButtonClick}
color='inherit'
sx={{ padding: 0 }}
>
<UserAvatar user={user} />
</IconButton>
</Tooltip>
<AppUserMenu
open={isUserMenuOpen}
anchorEl={userMenuAnchorEl}
onMenuClose={onUserMenuClose}
/>
</>
);
};
export default UserMenuButton;

View file

@ -3,11 +3,7 @@ import './viewManager/viewContainer.scss';
import Dashboard from '../utils/dashboard';
const getMainAnimatedPages = () => {
if (!mainAnimatedPages) {
mainAnimatedPages = document.querySelector('.mainAnimatedPages');
}
return mainAnimatedPages;
return document.querySelector('.mainAnimatedPages');
};
function setControllerClass(view, options) {
@ -61,7 +57,9 @@ export function loadView(options) {
view.classList.add('mainAnimatedPage');
if (!getMainAnimatedPages()) {
const mainAnimatedPages = getMainAnimatedPages();
if (!mainAnimatedPages) {
console.warn('[viewContainer] main animated pages element is not present');
return;
}
@ -187,6 +185,7 @@ export function setOnBeforeChange(fn) {
}
export function tryRestoreView(options) {
console.debug('[viewContainer] tryRestoreView', options);
const url = options.url;
const index = currentUrls.indexOf(url);
@ -232,14 +231,15 @@ function triggerDestroy(view) {
}
export function reset() {
console.debug('[viewContainer] resetting view cache');
allPages = [];
currentUrls = [];
const mainAnimatedPages = getMainAnimatedPages();
if (mainAnimatedPages) mainAnimatedPages.innerHTML = '';
selectedPageIndex = -1;
}
let onBeforeChange;
let mainAnimatedPages;
let allPages = [];
let currentUrls = [];
const pageContainerCount = 3;
@ -248,8 +248,8 @@ reset();
getMainAnimatedPages()?.classList.remove('hide');
export default {
loadView: loadView,
tryRestoreView: tryRestoreView,
reset: reset,
setOnBeforeChange: setOnBeforeChange
loadView,
tryRestoreView,
reset,
setOnBeforeChange
};