Compare commits
113 Commits
master
...
release-10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c66db4e18d | ||
|
|
ea2abad3e1 | ||
|
|
6d8c8c0566 | ||
|
|
a2855c785e | ||
|
|
bf31a733a7 | ||
|
|
bf70fb80aa | ||
|
|
2acc6f360a | ||
|
|
a36eb7b546 | ||
|
|
fb6250d108 | ||
|
|
a82ae33aa3 | ||
|
|
32d916b420 | ||
|
|
014af0ebe9 | ||
|
|
9b80917cd1 | ||
|
|
238c5bbf58 | ||
|
|
264cdafaff | ||
|
|
1459a11320 | ||
|
|
ad3223cb77 | ||
|
|
445fe22f29 | ||
|
|
e28d70d34c | ||
|
|
9a207e9ba9 | ||
|
|
5db40d03ac | ||
|
|
ae58599bd0 | ||
|
|
e2ae48d8e5 | ||
|
|
bc39ee10ba | ||
|
|
603b5ed20c | ||
|
|
6bfff061ce | ||
|
|
44818f0c97 | ||
|
|
b3725e9dd5 | ||
|
|
ce22f8fe22 | ||
|
|
9f1370f242 | ||
|
|
b3913d7bb3 | ||
|
|
69d169e45f | ||
|
|
264eedc90a | ||
|
|
6fba30a0a9 | ||
|
|
3376a126de | ||
|
|
4e9c2e71a9 | ||
|
|
06f5442fc9 | ||
|
|
c478d6e307 | ||
|
|
cacb660ff8 | ||
|
|
4bdc0fd974 | ||
|
|
9af155b291 | ||
|
|
74f98bb120 | ||
|
|
e568ecbf30 | ||
|
|
1686788be5 | ||
|
|
43749273e4 | ||
|
|
b807ebfa4a | ||
|
|
8cc49df625 | ||
|
|
f2d2c5b26e | ||
|
|
5c444198ea | ||
|
|
dee5a1bcea | ||
|
|
3d55ce3724 | ||
|
|
3c6a5160a6 | ||
|
|
01200f3d70 | ||
|
|
39f971ffa4 | ||
|
|
e6141968d7 | ||
|
|
f445e53f7e | ||
|
|
d1379dce8a | ||
|
|
03c2cebbd3 | ||
|
|
ab0042d46f | ||
|
|
3c388fef92 | ||
|
|
9c76311936 | ||
|
|
f077e294a9 | ||
|
|
1c8f221006 | ||
|
|
a1d8bec051 | ||
|
|
000f89b99e | ||
|
|
83317879a8 | ||
|
|
7c0807680d | ||
|
|
053ce59352 | ||
|
|
b3833e7479 | ||
|
|
21d7dd86ea | ||
|
|
e2e679f0be | ||
|
|
993d370582 | ||
|
|
933e1b255b | ||
|
|
2c45c5ba4a | ||
|
|
cdde002ca6 | ||
|
|
19cb2e9977 | ||
|
|
fb7a1538d0 | ||
|
|
7491722364 | ||
|
|
d6c169321e | ||
|
|
6e2c62525a | ||
|
|
09dc3ae3a8 | ||
|
|
e102334812 | ||
|
|
907947c523 | ||
|
|
f3d7994b2a | ||
|
|
b9fdc61b6d | ||
|
|
37dcc07da5 | ||
|
|
e4e2c97bd5 | ||
|
|
6ce3e579c2 | ||
|
|
dbcac4c6f4 | ||
|
|
c11d630e42 | ||
|
|
7643885c6b | ||
|
|
92a1aa16dc | ||
|
|
4560d7c90f | ||
|
|
e97d658b3c | ||
|
|
7c0c2e088f | ||
|
|
0989a3034f | ||
|
|
17a1e2e94c | ||
|
|
b5382f0142 | ||
|
|
12079b9462 | ||
|
|
6a55ee3d71 | ||
|
|
6ee77f18bc | ||
|
|
db7498ed03 | ||
|
|
4f83e97592 | ||
|
|
4b072633fb | ||
|
|
0772f146b4 | ||
|
|
0bb8f7cb47 | ||
|
|
f7583a842b | ||
|
|
45bca06b2c | ||
|
|
c688faacb8 | ||
|
|
737b85b0b6 | ||
|
|
81698d5da7 | ||
|
|
64fbd6d3de | ||
|
|
fa7831bd1f |
54
package-lock.json
generated
54
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.11.0",
|
||||
"version": "10.11.6",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.11.0",
|
||||
"version": "10.11.6",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.14.0",
|
||||
@@ -18,7 +18,7 @@
|
||||
"@fontsource/noto-sans-sc": "5.2.6",
|
||||
"@fontsource/noto-sans-tc": "5.2.6",
|
||||
"@jellyfin/libass-wasm": "4.2.3",
|
||||
"@jellyfin/sdk": "0.0.0-unstable.202510030502",
|
||||
"@jellyfin/sdk": "0.12.0",
|
||||
"@jellyfin/ux-web": "1.0.0",
|
||||
"@mui/icons-material": "6.4.12",
|
||||
"@mui/material": "6.4.12",
|
||||
@@ -4129,12 +4129,12 @@
|
||||
"license": "LGPL-2.1-or-later AND (FTL OR GPL-2.0-or-later) AND MIT AND MIT-Modern-Variant AND ISC AND NTP AND Zlib AND BSL-1.0"
|
||||
},
|
||||
"node_modules/@jellyfin/sdk": {
|
||||
"version": "0.0.0-unstable.202510030502",
|
||||
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202510030502.tgz",
|
||||
"integrity": "sha512-khxWQ4dKirp03sLCkz8s+MrfAmJUGD4xTfVHRq3NOsgz8ueDH3qVxdya8YnV8U83p+bfZpGtnf1IAvyh+f959g==",
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.12.0.tgz",
|
||||
"integrity": "sha512-do3cks7TD316Qw27lBMHZQ7ufaS1MC8HMsQF5rFv5/DUInuwEOqWthqVyHl3sIjThOThF1zxQyE6OdpUl0dNUg==",
|
||||
"license": "MPL-2.0",
|
||||
"peerDependencies": {
|
||||
"axios": "^1.3.4"
|
||||
"axios": "^1.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jellyfin/ux-web": {
|
||||
@@ -7233,6 +7233,7 @@
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/atob": {
|
||||
@@ -7312,14 +7313,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
|
||||
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -8420,6 +8421,7 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
@@ -9509,6 +9511,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
@@ -11962,15 +11965,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
|
||||
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
@@ -18228,6 +18232,7 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/proxy-polyfill": {
|
||||
@@ -27097,9 +27102,9 @@
|
||||
"integrity": "sha512-C0OlBxIr9NdeFESMTA/OVDqNSWtog6Mi7wwzwH12xbZpxsMD0RgCupUcIP7zZgcpTNecW3fZq5d6xVo7Q8HEJw=="
|
||||
},
|
||||
"@jellyfin/sdk": {
|
||||
"version": "0.0.0-unstable.202510030502",
|
||||
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202510030502.tgz",
|
||||
"integrity": "sha512-khxWQ4dKirp03sLCkz8s+MrfAmJUGD4xTfVHRq3NOsgz8ueDH3qVxdya8YnV8U83p+bfZpGtnf1IAvyh+f959g==",
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.12.0.tgz",
|
||||
"integrity": "sha512-do3cks7TD316Qw27lBMHZQ7ufaS1MC8HMsQF5rFv5/DUInuwEOqWthqVyHl3sIjThOThF1zxQyE6OdpUl0dNUg==",
|
||||
"requires": {}
|
||||
},
|
||||
"@jellyfin/ux-web": {
|
||||
@@ -29097,13 +29102,13 @@
|
||||
"dev": true
|
||||
},
|
||||
"axios": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
|
||||
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -32390,14 +32395,15 @@
|
||||
}
|
||||
},
|
||||
"form-data": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
|
||||
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.11.0",
|
||||
"version": "10.11.6",
|
||||
"description": "Web interface for Jellyfin",
|
||||
"repository": "https://github.com/jellyfin/jellyfin-web",
|
||||
"license": "GPL-2.0-or-later",
|
||||
@@ -84,7 +84,7 @@
|
||||
"@fontsource/noto-sans-sc": "5.2.6",
|
||||
"@fontsource/noto-sans-tc": "5.2.6",
|
||||
"@jellyfin/libass-wasm": "4.2.3",
|
||||
"@jellyfin/sdk": "0.0.0-unstable.202510030502",
|
||||
"@jellyfin/sdk": "0.12.0",
|
||||
"@jellyfin/ux-web": "1.0.0",
|
||||
"@mui/icons-material": "6.4.12",
|
||||
"@mui/material": "6.4.12",
|
||||
|
||||
@@ -94,7 +94,13 @@ const BaseCard = ({
|
||||
{title}
|
||||
</Typography>
|
||||
{text && (
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
<Typography
|
||||
variant='body2'
|
||||
color='text.secondary'
|
||||
sx={{
|
||||
lineBreak: 'anywhere'
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -5,13 +5,14 @@ import MusicNote from '@mui/icons-material/MusicNote';
|
||||
import MusicVideo from '@mui/icons-material/MusicVideo';
|
||||
import Tv from '@mui/icons-material/Tv';
|
||||
import VideoLibrary from '@mui/icons-material/VideoLibrary';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import SvgIcon from '@mui/material/SvgIcon';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useItemCounts } from 'apps/dashboard/features/metrics/api/useItemCounts';
|
||||
import MetricCard, { type MetricCardProps } from 'apps/dashboard/features/metrics/components/MetricCard';
|
||||
import globalize from 'lib/globalize';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
interface MetricDefinition {
|
||||
key: keyof ItemCounts
|
||||
@@ -75,23 +76,27 @@ const ItemCountsWidget = () => {
|
||||
}, [ counts, isPending ]);
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: 'stretch',
|
||||
marginTop: 2
|
||||
}}
|
||||
>
|
||||
{cards.map(card => (
|
||||
<Grid
|
||||
key={card.metrics.map(metric => metric.label).join('-')}
|
||||
size={{ xs: 12, sm: 6, lg: 4 }}
|
||||
>
|
||||
<MetricCard {...card} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
<Box>
|
||||
<Grid
|
||||
container
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: 'stretch'
|
||||
}}
|
||||
>
|
||||
{cards.map(card => (
|
||||
<Grid
|
||||
key={card.metrics.map(metric => metric.label).join('-')}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
lg={4}
|
||||
>
|
||||
<MetricCard {...card} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -15,9 +15,15 @@ type ServerInfoWidgetProps = {
|
||||
onScanLibrariesClick?: () => void;
|
||||
onRestartClick?: () => void;
|
||||
onShutdownClick?: () => void;
|
||||
isScanning?: boolean;
|
||||
};
|
||||
|
||||
const ServerInfoWidget = ({ onScanLibrariesClick, onRestartClick, onShutdownClick }: ServerInfoWidgetProps) => {
|
||||
const ServerInfoWidget = ({
|
||||
onScanLibrariesClick,
|
||||
onRestartClick,
|
||||
onShutdownClick,
|
||||
isScanning
|
||||
}: ServerInfoWidgetProps) => {
|
||||
const { data: systemInfo, isPending } = useSystemInfo();
|
||||
|
||||
return (
|
||||
@@ -63,6 +69,7 @@ const ServerInfoWidget = ({ onScanLibrariesClick, onRestartClick, onShutdownClic
|
||||
sx={{
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
disabled={isScanning}
|
||||
>
|
||||
{globalize.translate('ButtonScanAllLibraries')}
|
||||
</Button>
|
||||
|
||||
@@ -47,5 +47,8 @@ export const HelpLinks = [
|
||||
'/dashboard/users/profile'
|
||||
],
|
||||
url: 'https://jellyfin.org/docs/general/server/users/'
|
||||
}, {
|
||||
paths: ['/dashboard/backups'],
|
||||
url: 'https://jellyfin.org/docs/general/administration/backup-and-restore/'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -78,7 +78,6 @@ const TunerDeviceCard = ({ tunerHost }: TunerDeviceCardProps) => {
|
||||
title={tunerHost.FriendlyName || getTunerName(tunerHost.Type) || ''}
|
||||
text={tunerHost.Url || ''}
|
||||
icon={<DvrIcon sx={{ fontSize: 70 }} />}
|
||||
width={340}
|
||||
action={true}
|
||||
actionRef={actionRef}
|
||||
onActionClick={onActionClick}
|
||||
|
||||
@@ -12,7 +12,13 @@ const fetchServerLog = async (
|
||||
const response = await getSystemApi(api).getLogFile({ name }, options);
|
||||
|
||||
// FIXME: TypeScript SDK thinks it is returning a File but in reality it is a string
|
||||
return response.data as never as string;
|
||||
const data = response.data as never as string | object;
|
||||
|
||||
if (typeof data === 'object') {
|
||||
return JSON.stringify(data, null, 2);
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
};
|
||||
export const useServerLog = (name: string) => {
|
||||
const { api } = useApi();
|
||||
|
||||
@@ -102,7 +102,7 @@ export const Component = () => {
|
||||
}).catch(() => {
|
||||
// Server is still down
|
||||
});
|
||||
}, 5000);
|
||||
}, 45000);
|
||||
|
||||
return () => {
|
||||
clearInterval(serverCheckInterval);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import Box from '@mui/material/Box';
|
||||
@@ -16,6 +16,7 @@ import RunningTasksWidget from '../components/widgets/RunningTasksWidget';
|
||||
import DevicesWidget from '../components/widgets/DevicesWidget';
|
||||
import { useStartTask } from '../features/tasks/api/useStartTask';
|
||||
import ItemCountsWidget from '../components/widgets/ItemCountsWidget';
|
||||
import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state';
|
||||
|
||||
export const Component = () => {
|
||||
const [ isRestartConfirmDialogOpen, setIsRestartConfirmDialogOpen ] = useState(false);
|
||||
@@ -26,6 +27,10 @@ export const Component = () => {
|
||||
|
||||
const { data: tasks } = useLiveTasks({ isHidden: false });
|
||||
|
||||
const librariesTask = useMemo(() => (
|
||||
tasks?.find((value) => value.Key === 'RefreshLibrary')
|
||||
), [ tasks ]);
|
||||
|
||||
const promptRestart = useCallback(() => {
|
||||
setIsRestartConfirmDialogOpen(true);
|
||||
}, []);
|
||||
@@ -94,6 +99,7 @@ export const Component = () => {
|
||||
onScanLibrariesClick={onScanLibraries}
|
||||
onRestartClick={promptRestart}
|
||||
onShutdownClick={promptShutdown}
|
||||
isScanning={librariesTask?.State !== TaskState.Idle}
|
||||
/>
|
||||
<ItemCountsWidget />
|
||||
<RunningTasksWidget tasks={tasks} />
|
||||
|
||||
@@ -29,7 +29,8 @@ export const Component = () => {
|
||||
|
||||
const showMediaLibraryCreator = useCallback(() => {
|
||||
const mediaLibraryCreator = new MediaLibraryCreator({
|
||||
collectionTypeOptions: getCollectionTypeOptions()
|
||||
collectionTypeOptions: getCollectionTypeOptions(),
|
||||
refresh: true
|
||||
}) as Promise<boolean>;
|
||||
|
||||
void mediaLibraryCreator.then((hasChanges: boolean) => {
|
||||
@@ -69,7 +70,7 @@ export const Component = () => {
|
||||
<Button
|
||||
onClick={onScanLibraries}
|
||||
startIcon={<RefreshIcon />}
|
||||
loading={librariesTask && librariesTask.State === TaskState.Running}
|
||||
loading={librariesTask && librariesTask.State !== TaskState.Idle}
|
||||
loadingPosition='start'
|
||||
variant='outlined'
|
||||
>
|
||||
|
||||
@@ -12,7 +12,7 @@ import useLiveTasks from 'apps/dashboard/features/tasks/hooks/useLiveTasks';
|
||||
import Button from '@mui/material/Button';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { Form, Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useStartTask } from 'apps/dashboard/features/tasks/api/useStartTask';
|
||||
import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state';
|
||||
import TaskProgress from 'apps/dashboard/features/tasks/components/TaskProgress';
|
||||
@@ -22,6 +22,7 @@ import ListItemText from '@mui/material/ListItemText';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import List from '@mui/material/List';
|
||||
import Provider from 'apps/dashboard/features/livetv/components/Provider';
|
||||
import Grid from '@mui/material/Grid';
|
||||
|
||||
const CONFIG_KEY = 'livetv';
|
||||
|
||||
@@ -81,33 +82,44 @@ export const Component = () => {
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
<Form>
|
||||
{(isConfigError || isTasksError) ? (
|
||||
<Alert severity='error'>{globalize.translate('HeaderError')}</Alert>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h2'>{globalize.translate('HeaderTunerDevices')}</Typography>
|
||||
{(isConfigError || isTasksError) ? (
|
||||
<Alert severity='error'>{globalize.translate('HeaderError')}</Alert>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h2'>{globalize.translate('HeaderTunerDevices')}</Typography>
|
||||
|
||||
<Button
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
startIcon={<AddIcon />}
|
||||
component={Link}
|
||||
to='/dashboard/livetv/tuner'
|
||||
>
|
||||
{globalize.translate('ButtonAddTunerDevice')}
|
||||
</Button>
|
||||
<Button
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
startIcon={<AddIcon />}
|
||||
component={Link}
|
||||
to='/dashboard/livetv/tuner'
|
||||
>
|
||||
{globalize.translate('ButtonAddTunerDevice')}
|
||||
</Button>
|
||||
|
||||
<Stack direction='row' spacing={2}>
|
||||
{ config.TunerHosts?.map(tunerHost => (
|
||||
<TunerDeviceCard
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
{config.TunerHosts?.map(tunerHost => (
|
||||
<Grid
|
||||
key={tunerHost.Id}
|
||||
tunerHost={tunerHost}
|
||||
/>
|
||||
)) }
|
||||
</Stack>
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={3}
|
||||
lg={2.4}
|
||||
>
|
||||
<TunerDeviceCard
|
||||
key={tunerHost.Id}
|
||||
tunerHost={tunerHost}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Typography variant='h2'>{globalize.translate('HeaderGuideProviders')}</Typography>
|
||||
<Typography variant='h2'>{globalize.translate('HeaderGuideProviders')}</Typography>
|
||||
|
||||
<Stack sx={{ alignSelf: 'flex-start' }} spacing={2}>
|
||||
<Stack direction='row' spacing={1.5}>
|
||||
<Button
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
@@ -132,32 +144,33 @@ export const Component = () => {
|
||||
{(refreshGuideTask && refreshGuideTask.State === TaskState.Running) && (
|
||||
<TaskProgress task={refreshGuideTask} />
|
||||
)}
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
<MenuItem onClick={navigateToSchedulesDirect}>
|
||||
<ListItemText>Schedules Direct</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={navigateToXMLTV}>
|
||||
<ListItemText>XMLTV</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{(config.ListingProviders && config.ListingProviders?.length > 0) && (
|
||||
<List sx={{ backgroundColor: 'background.paper' }}>
|
||||
{config.ListingProviders?.map(provider => (
|
||||
<Provider
|
||||
key={provider.Id}
|
||||
provider={provider}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
<MenuItem onClick={navigateToSchedulesDirect}>
|
||||
<ListItemText>Schedules Direct</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={navigateToXMLTV}>
|
||||
<ListItemText>XMLTV</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{(config.ListingProviders && config.ListingProviders?.length > 0) && (
|
||||
<List sx={{ backgroundColor: 'background.paper' }}>
|
||||
{config.ListingProviders?.map(provider => (
|
||||
<Provider
|
||||
key={provider.Id}
|
||||
provider={provider}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -62,7 +62,7 @@ export const Component = () => {
|
||||
onClose={handleToastClose}
|
||||
message={globalize.translate('CopyLogSuccess')}
|
||||
/>
|
||||
<Container className='content-primary'>
|
||||
<Container className='content-primary' maxWidth={false}>
|
||||
<Box>
|
||||
<Typography variant='h1'>{fileName}</Typography>
|
||||
|
||||
@@ -106,7 +106,14 @@ export const Component = () => {
|
||||
|
||||
<Paper sx={{ mt: 2 }}>
|
||||
<code>
|
||||
<pre style={{ overflow:'auto', margin: 0, padding: '16px' }}>{log}</pre>
|
||||
<pre style={{
|
||||
overflow:'auto',
|
||||
margin: 0,
|
||||
padding: '16px',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}>
|
||||
{log}
|
||||
</pre>
|
||||
</code>
|
||||
</Paper>
|
||||
</>
|
||||
|
||||
@@ -98,164 +98,166 @@ export const Component = () => {
|
||||
className='type-interior mainAnimatedPage'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
{isError ? (
|
||||
<Alert
|
||||
severity='error'
|
||||
sx={{ marginBottom: 2 }}
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction='row'
|
||||
sx={{
|
||||
flexWrap: {
|
||||
xs: 'wrap',
|
||||
sm: 'nowrap'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{globalize.translate('PluginsLoadError')}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction='row'
|
||||
<Typography
|
||||
variant='h1'
|
||||
component='span'
|
||||
sx={{
|
||||
flexWrap: {
|
||||
xs: 'wrap',
|
||||
sm: 'nowrap'
|
||||
flexGrow: 1,
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
>
|
||||
{globalize.translate('TabPlugins')}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
component={Link}
|
||||
to='/dashboard/plugins/repositories'
|
||||
variant='outlined'
|
||||
sx={{
|
||||
marginLeft: 2
|
||||
}}
|
||||
>
|
||||
{globalize.translate('ManageRepositories')}
|
||||
</Button>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'end',
|
||||
marginTop: {
|
||||
xs: 2,
|
||||
sm: 0
|
||||
},
|
||||
marginLeft: {
|
||||
xs: 0,
|
||||
sm: 2
|
||||
},
|
||||
width: {
|
||||
xs: '100%',
|
||||
sm: 'auto'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant='h1'
|
||||
component='span'
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
>
|
||||
{globalize.translate('TabPlugins')}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
component={Link}
|
||||
to='/dashboard/plugins/repositories'
|
||||
variant='outlined'
|
||||
sx={{
|
||||
marginLeft: 2
|
||||
}}
|
||||
>
|
||||
{globalize.translate('ManageRepositories')}
|
||||
</Button>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'end',
|
||||
marginTop: {
|
||||
xs: 2,
|
||||
sm: 0
|
||||
},
|
||||
marginLeft: {
|
||||
xs: 0,
|
||||
sm: 2
|
||||
},
|
||||
width: {
|
||||
xs: '100%',
|
||||
sm: 'auto'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SearchInput
|
||||
label={globalize.translate('Search')}
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Stack
|
||||
direction='row'
|
||||
spacing={1}
|
||||
sx={{
|
||||
marginLeft: '-1rem',
|
||||
marginRight: '-1rem',
|
||||
paddingLeft: '1rem',
|
||||
paddingRight: '1rem',
|
||||
paddingBottom: {
|
||||
xs: 1,
|
||||
md: 0.5
|
||||
},
|
||||
overflowX: 'auto'
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
color={status === PluginStatusOption.All ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.All)}
|
||||
label={globalize.translate('All')}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
color={status === PluginStatusOption.Available ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.Available)}
|
||||
label={globalize.translate('LabelAvailable')}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
color={status === PluginStatusOption.Installed ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.Installed)}
|
||||
label={globalize.translate('LabelInstalled')}
|
||||
/>
|
||||
|
||||
<Divider orientation='vertical' flexItem />
|
||||
|
||||
<Chip
|
||||
color={!category ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory('')}
|
||||
label={globalize.translate('All')}
|
||||
/>
|
||||
|
||||
{Object.values(PluginCategory).map(c => (
|
||||
<Chip
|
||||
key={c}
|
||||
color={category === c.toLowerCase() ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory(c.toLowerCase())}
|
||||
label={globalize.translate(CATEGORY_LABELS[c as PluginCategory])}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Divider />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{filteredPlugins.length > 0 ? (
|
||||
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
<Grid container spacing={2}>
|
||||
{filteredPlugins.map(plugin => (
|
||||
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
<Grid
|
||||
key={plugin.id}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={4}
|
||||
lg={3}
|
||||
xl={2}
|
||||
>
|
||||
<PluginCard
|
||||
plugin={plugin}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<NoPluginResults
|
||||
isFiltered={!!category || status !== PluginStatusOption.All}
|
||||
onViewAll={onViewAll}
|
||||
query={searchQuery}
|
||||
/>
|
||||
)}
|
||||
<SearchInput
|
||||
label={globalize.translate('Search')}
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{isError ? (
|
||||
<Alert
|
||||
severity='error'
|
||||
sx={{ marginBottom: 2 }}
|
||||
>
|
||||
{globalize.translate('PluginsLoadError')}
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Box>
|
||||
<Stack
|
||||
direction='row'
|
||||
spacing={1}
|
||||
sx={{
|
||||
marginLeft: '-1rem',
|
||||
marginRight: '-1rem',
|
||||
paddingLeft: '1rem',
|
||||
paddingRight: '1rem',
|
||||
paddingBottom: {
|
||||
xs: 1,
|
||||
md: 0.5
|
||||
},
|
||||
overflowX: 'auto'
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
color={status === PluginStatusOption.All ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.All)}
|
||||
label={globalize.translate('All')}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
color={status === PluginStatusOption.Available ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.Available)}
|
||||
label={globalize.translate('LabelAvailable')}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
color={status === PluginStatusOption.Installed ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.Installed)}
|
||||
label={globalize.translate('LabelInstalled')}
|
||||
/>
|
||||
|
||||
<Divider orientation='vertical' flexItem />
|
||||
|
||||
<Chip
|
||||
color={!category ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory('')}
|
||||
label={globalize.translate('All')}
|
||||
/>
|
||||
|
||||
{Object.values(PluginCategory).map(c => (
|
||||
<Chip
|
||||
key={c}
|
||||
color={category === c.toLowerCase() ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory(c.toLowerCase())}
|
||||
label={globalize.translate(CATEGORY_LABELS[c as PluginCategory])}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Divider />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{filteredPlugins.length > 0 ? (
|
||||
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
<Grid container spacing={2}>
|
||||
{filteredPlugins.map(plugin => (
|
||||
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
<Grid
|
||||
key={plugin.id}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={4}
|
||||
lg={3}
|
||||
xl={2}
|
||||
>
|
||||
<PluginCard
|
||||
plugin={plugin}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<NoPluginResults
|
||||
isFiltered={!!category || status !== PluginStatusOption.All}
|
||||
onViewAll={onViewAll}
|
||||
query={searchQuery}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import Stack from '@mui/material/Stack';
|
||||
import React, { type FC } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { appRouter, PUBLIC_PATHS } from 'components/router/appRouter';
|
||||
import AppToolbar from 'components/toolbar/AppToolbar';
|
||||
import ServerButton from 'components/toolbar/ServerButton';
|
||||
|
||||
@@ -16,14 +17,6 @@ interface AppToolbarProps {
|
||||
onDrawerButtonClick: (event: React.MouseEvent<HTMLElement>) => void
|
||||
}
|
||||
|
||||
const PUBLIC_PATHS = [
|
||||
'/addserver',
|
||||
'/selectserver',
|
||||
'/login',
|
||||
'/forgotpassword',
|
||||
'/forgotpasswordpin'
|
||||
];
|
||||
|
||||
const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
|
||||
isDrawerAvailable,
|
||||
isDrawerOpen,
|
||||
@@ -34,6 +27,10 @@ const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
|
||||
// The video osd does not show the standard toolbar
|
||||
if (location.pathname === '/video') return null;
|
||||
|
||||
// Only show the back button in apps when appropriate
|
||||
const isBackButtonAvailable = window.NativeShell && appRouter.canGoBack(location.pathname);
|
||||
|
||||
// Check if the current path is a public path to hide user content
|
||||
const isPublicPath = PUBLIC_PATHS.includes(location.pathname);
|
||||
|
||||
return (
|
||||
@@ -48,6 +45,7 @@ const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
|
||||
isDrawerAvailable={isDrawerAvailable}
|
||||
isDrawerOpen={isDrawerOpen}
|
||||
onDrawerButtonClick={onDrawerButtonClick}
|
||||
isBackButtonAvailable={isBackButtonAvailable}
|
||||
isUserMenuAvailable={!isPublicPath}
|
||||
>
|
||||
{!isDrawerAvailable && (
|
||||
|
||||
@@ -221,9 +221,7 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||
const hasFilters = Object.values(libraryViewSettings.Filters ?? {}).some(
|
||||
(filter) => !!filter
|
||||
);
|
||||
const hasSortName = libraryViewSettings.SortBy.includes(
|
||||
ItemSortBy.SortName
|
||||
);
|
||||
const hasSortName = libraryViewSettings.SortBy !== ItemSortBy.Random;
|
||||
|
||||
const itemsContainerClass = classNames(
|
||||
'centered padded-left padded-right padded-right-withalphapicker',
|
||||
|
||||
@@ -22,6 +22,14 @@ type SortOption = {
|
||||
|
||||
type SortOptionsMapping = Record<string, SortOption[]>;
|
||||
|
||||
const collectionMovieOptions: SortOption[] = [
|
||||
{ label: 'Name', value: ItemSortBy.SortName },
|
||||
{ label: 'OptionCommunityRating', value: ItemSortBy.CommunityRating },
|
||||
{ label: 'OptionDateAdded', value: ItemSortBy.DateCreated },
|
||||
{ label: 'OptionParentalRating', value: ItemSortBy.OfficialRating },
|
||||
{ label: 'OptionReleaseDate', value: ItemSortBy.PremiereDate }
|
||||
];
|
||||
|
||||
const movieOrFavoriteOptions = [
|
||||
{ label: 'Name', value: ItemSortBy.SortName },
|
||||
{ label: 'OptionRandom', value: ItemSortBy.Random },
|
||||
@@ -43,6 +51,7 @@ const photosOrPhotoAlbumsOptions = [
|
||||
|
||||
const sortOptionsMapping: SortOptionsMapping = {
|
||||
[LibraryTab.Movies]: movieOrFavoriteOptions,
|
||||
[LibraryTab.Collections]: collectionMovieOptions,
|
||||
[LibraryTab.Favorites]: movieOrFavoriteOptions,
|
||||
[LibraryTab.Series]: [
|
||||
{ label: 'Name', value: ItemSortBy.SortName },
|
||||
|
||||
@@ -10,6 +10,9 @@ import themeManager from 'scripts/themeManager';
|
||||
import { currentSettings, UserSettings } from 'scripts/settings/userSettings';
|
||||
|
||||
import type { DisplaySettingsValues } from '../types/displaySettingsValues';
|
||||
import { useThemes } from 'hooks/useThemes';
|
||||
import { Theme } from 'types/webConfig';
|
||||
import { FALLBACK_THEME_ID } from 'hooks/useUserTheme';
|
||||
|
||||
interface UseDisplaySettingsParams {
|
||||
userId?: string | null;
|
||||
@@ -20,6 +23,7 @@ export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {
|
||||
const [userSettings, setUserSettings] = useState<UserSettings>();
|
||||
const [displaySettings, setDisplaySettings] = useState<DisplaySettingsValues>();
|
||||
const { __legacyApiClient__, user: currentUser } = useApi();
|
||||
const { defaultTheme } = useThemes();
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || !currentUser || !__legacyApiClient__) {
|
||||
@@ -29,7 +33,7 @@ export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {
|
||||
setLoading(true);
|
||||
|
||||
void (async () => {
|
||||
const loadedSettings = await loadDisplaySettings({ api: __legacyApiClient__, currentUser, userId });
|
||||
const loadedSettings = await loadDisplaySettings({ api: __legacyApiClient__, currentUser, userId, defaultTheme });
|
||||
|
||||
setDisplaySettings(loadedSettings.displaySettings);
|
||||
setUserSettings(loadedSettings.userSettings);
|
||||
@@ -62,15 +66,17 @@ export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {
|
||||
}
|
||||
|
||||
interface LoadDisplaySettingsParams {
|
||||
currentUser: UserDto;
|
||||
userId?: string;
|
||||
api: ApiClient;
|
||||
currentUser: UserDto
|
||||
userId?: string
|
||||
api: ApiClient
|
||||
defaultTheme?: Theme
|
||||
}
|
||||
|
||||
async function loadDisplaySettings({
|
||||
currentUser,
|
||||
userId,
|
||||
api
|
||||
api,
|
||||
defaultTheme
|
||||
}: LoadDisplaySettingsParams) {
|
||||
const settings = (!userId || userId === currentUser?.Id) ? currentSettings : new UserSettings();
|
||||
const user = (!userId || userId === currentUser?.Id) ? currentUser : await api.getUser(userId);
|
||||
@@ -78,8 +84,8 @@ async function loadDisplaySettings({
|
||||
await settings.setUserInfo(userId, api);
|
||||
|
||||
const displaySettings = {
|
||||
customCss: settings.customCss(),
|
||||
dashboardTheme: settings.dashboardTheme() || 'auto',
|
||||
customCss: settings.customCss() || '',
|
||||
dashboardTheme: settings.dashboardTheme() || defaultTheme?.id || FALLBACK_THEME_ID,
|
||||
dateTimeLocale: settings.dateTimeLocale() || 'auto',
|
||||
disableCustomCss: Boolean(settings.disableCustomCss()),
|
||||
displayMissingEpisodes: user?.Configuration?.DisplayMissingEpisodes ?? false,
|
||||
@@ -97,7 +103,7 @@ async function loadDisplaySettings({
|
||||
maxDaysForNextUp: settings.maxDaysForNextUp(),
|
||||
screensaver: settings.screensaver() || 'none',
|
||||
screensaverInterval: settings.backdropScreensaverInterval(),
|
||||
theme: settings.theme()
|
||||
theme: settings.theme() || defaultTheme?.id || FALLBACK_THEME_ID
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -125,7 +131,7 @@ async function saveDisplaySettings({
|
||||
userSettings.language(normalizeValue(newDisplaySettings.language));
|
||||
}
|
||||
userSettings.customCss(normalizeValue(newDisplaySettings.customCss));
|
||||
userSettings.dashboardTheme(normalizeValue(newDisplaySettings.dashboardTheme));
|
||||
userSettings.dashboardTheme(newDisplaySettings.dashboardTheme);
|
||||
userSettings.dateTimeLocale(normalizeValue(newDisplaySettings.dateTimeLocale));
|
||||
userSettings.disableCustomCss(newDisplaySettings.disableCustomCss);
|
||||
userSettings.enableBlurhash(newDisplaySettings.enableBlurHash);
|
||||
|
||||
@@ -51,19 +51,20 @@ class MediaSessionSubscriber extends PlaybackSubscriber {
|
||||
}
|
||||
|
||||
private bindNavigatorSession() {
|
||||
/* eslint-disable compat/compat */
|
||||
navigator.mediaSession.setActionHandler('pause', this.onMediaSessionAction.bind(this));
|
||||
navigator.mediaSession.setActionHandler('play', this.onMediaSessionAction.bind(this));
|
||||
navigator.mediaSession.setActionHandler('stop', this.onMediaSessionAction.bind(this));
|
||||
navigator.mediaSession.setActionHandler('previoustrack', this.onMediaSessionAction.bind(this));
|
||||
navigator.mediaSession.setActionHandler('nexttrack', this.onMediaSessionAction.bind(this));
|
||||
navigator.mediaSession.setActionHandler('seekto', this.onMediaSessionAction.bind(this));
|
||||
const actions: MediaSessionAction[] = ['pause', 'play', 'previoustrack', 'nexttrack', 'stop', 'seekto'];
|
||||
|
||||
// iOS will only show next/prev track controls or seek controls
|
||||
if (!browser.iOS) {
|
||||
navigator.mediaSession.setActionHandler('seekbackward', this.onMediaSessionAction.bind(this));
|
||||
navigator.mediaSession.setActionHandler('seekforward', this.onMediaSessionAction.bind(this));
|
||||
if (!browser.iOS) actions.push('seekbackward', 'seekforward');
|
||||
|
||||
for (const action of actions) {
|
||||
try {
|
||||
/* eslint-disable-next-line compat/compat */
|
||||
navigator.mediaSession.setActionHandler(action, this.onMediaSessionAction.bind(this));
|
||||
} catch (err) {
|
||||
// NOTE: Some legacy (TV) browsers lack support for the stop and seekto actions
|
||||
console.warn(`[MediaSessionSubscriber] Failed to add "${action}" action handler`, err);
|
||||
}
|
||||
}
|
||||
/* eslint-enable compat/compat */
|
||||
}
|
||||
|
||||
private onMediaSessionAction(details: MediaSessionActionDetails) {
|
||||
|
||||
@@ -78,12 +78,14 @@ export abstract class PlaybackSubscriber {
|
||||
constructor(
|
||||
protected readonly playbackManager: PlaybackManager
|
||||
) {
|
||||
// Bind player events before invoking any player change handlers
|
||||
Events.on(playbackManager, PlaybackManagerEvent.PlayerChange, this.bindPlayerEvents.bind(this));
|
||||
|
||||
Object.entries(this.playbackManagerEvents).forEach(([event, handler]) => {
|
||||
if (handler) Events.on(playbackManager, event, handler);
|
||||
});
|
||||
|
||||
this.bindPlayerEvents();
|
||||
Events.on(playbackManager, PlaybackManagerEvent.PlayerChange, this.bindPlayerEvents.bind(this));
|
||||
}
|
||||
|
||||
private bindPlayerEvents() {
|
||||
|
||||
1
src/assets/img/devices/firetv.svg
Normal file
1
src/assets/img/devices/firetv.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Amazon Fire TV</title><path d="M20.196 15.12c.265.337-.294 1.73-.542 2.353-.077.19.085.266.257.123 1.106-.926 1.39-2.867 1.166-3.149-.226-.277-2.16-.516-3.341.314-.183.127-.151.304.05.279.665-.08 2.147-.257 2.41.08m-.858.981c-2.064 1.523-5.056 2.333-7.632 2.333-3.611 0-6.862-1.334-9.322-3.555-.194-.176-.02-.414.21-.28 2.655 1.545 5.939 2.477 9.328 2.477 2.287 0 4.803-.476 7.115-1.458.348-.147.642.231.3.483m2.034-3.155a.388.388 0 0 1-.201-.04c-.041-.026-.087-.1-.133-.225l-1.734-4.355a1.79 1.79 0 0 0-.046-.117.266.266 0 0 1-.023-.108c0-.084.049-.128.146-.128h.58c.098 0 .165.014.205.04.04.026.082.102.127.226l1.344 3.823 1.343-3.823c.046-.124.089-.2.128-.226a.402.402 0 0 1 .205-.04h.54c.1 0 .148.044.148.128a.3.3 0 0 1-.025.108c-.016.04-.032.078-.044.117l-1.727 4.355c-.045.124-.09.199-.132.225a.388.388 0 0 1-.201.04zm-3.644.068c-.929 0-1.392-.463-1.392-1.392V8.739h-.706c-.13 0-.197-.066-.197-.196v-.246a.22.22 0 0 1 .045-.147c.03-.031.086-.055.171-.067l.717-.09.127-1.215c.013-.13.082-.196.207-.196h.41c.13 0 .196.066.196.196v1.196h1.276c.13 0 .195.065.195.197v.372c0 .13-.064.196-.195.196h-1.276v2.834c0 .243.055.411.162.51.108.098.293.147.555.147.124 0 .277-.016.46-.049.099-.02.164-.03.197-.03.052 0 .088.014.108.044.02.03.029.077.029.142v.266a.366.366 0 0 1-.04.19c-.026.043-.078.078-.157.103a3.018 3.018 0 0 1-.892.118m-4.665-2.976c.006-.052.011-.137.011-.255 0-.399-.094-.698-.28-.901-.186-.204-.46-.306-.818-.306-.412 0-.732.123-.962.369-.228.245-.36.61-.392 1.093zm-.942 3.07c-.803 0-1.411-.222-1.824-.667-.412-.444-.616-1.102-.616-1.972 0-.83.204-1.475.616-1.937.413-.46.988-.691 1.728-.691.62 0 1.098.176 1.432.524.332.351.5.846.5 1.487 0 .21-.017.422-.05.638-.014.077-.034.13-.064.156-.029.027-.077.04-.142.04h-3.08c.013.563.154.977.418 1.245.265.268.674.403 1.23.403.196 0 .385-.014.564-.04a5.04 5.04 0 0 0 .682-.166l.117-.035a.284.284 0 0 1 .09-.016c.085 0 .125.06.125.177v.276c0 .085-.012.144-.037.18a.441.441 0 0 1-.167.114 3.38 3.38 0 0 1-.701.205 4.236 4.236 0 0 1-.82.079m-5.424-.147c-.13 0-.195-.066-.195-.197v-4.58c0-.13.064-.195.195-.195h.432c.064 0 .116.012.153.039.036.025.06.076.072.146l.07.55c.176-.19.343-.34.499-.452a1.725 1.725 0 0 1 1.02-.323c.079 0 .158.003.235.01.112.014.168.072.168.176v.53c0 .117-.058.177-.178.177-.058 0-.114-.004-.17-.01a1.638 1.638 0 0 0-.18-.01c-.524 0-.973.157-1.346.47v3.472c0 .131-.066.197-.195.197zm-2.249 0c-.13 0-.196-.066-.196-.197v-4.58c0-.13.066-.195.196-.195h.579c.13 0 .195.064.195.195v4.58c0 .131-.065.197-.195.197zm.295-5.856c-.19 0-.339-.054-.447-.16a.581.581 0 0 1-.161-.428c0-.176.054-.318.16-.426.11-.109.257-.163.448-.163.189 0 .337.054.446.163.107.108.16.25.16.426a.581.581 0 0 1-.16.427.608.608 0 0 1-.446.161m-3.625 5.856c-.132 0-.197-.066-.197-.197v-4.01H.195c-.13 0-.195-.066-.195-.197v-.245c0-.065.014-.114.043-.147.03-.033.088-.055.173-.07l.705-.087v-.804c0-1.091.523-1.638 1.57-1.638.248 0 .51.036.784.109.072.019.122.047.152.088.029.038.044.107.044.205v.255c0 .124-.048.186-.148.186-.058 0-.14-.01-.248-.029-.11-.02-.23-.03-.369-.03-.3 0-.51.057-.633.172-.121.115-.181.303-.181.564v.903h1.324c.131 0 .197.064.197.195v.373c0 .13-.066.197-.197.197H1.892v4.01c0 .131-.065.197-.196.197Z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
12
src/assets/img/devices/titanos.svg
Normal file
12
src/assets/img/devices/titanos.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="35" height="33" viewBox="0 0 35 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<script xmlns="" />
|
||||
<path
|
||||
d="M0.0146346 0.593029L0.764804 3.57722C0.809995 3.75303 0.945568 3.88826 1.12633 3.92432L14.765 6.91753C15.1897 7.01219 15.5106 7.37733 15.5377 7.81459L16.6585 23.9031C16.6675 24.0158 16.7127 24.1195 16.7895 24.2006L17.133 24.5567C17.2233 24.6514 17.3454 24.7055 17.4764 24.7055C17.6075 24.7055 17.7295 24.6514 17.8199 24.5567L18.1633 24.2006C18.2401 24.1195 18.2853 24.0158 18.2944 23.9031L19.4151 7.81459C19.4467 7.38184 19.7631 7.01219 20.1879 6.91753L33.8265 3.92432C34.0027 3.88375 34.1428 3.75303 34.188 3.57722L34.9382 0.593029C34.9789 0.430747 34.9382 0.268465 34.8162 0.146753C34.6987 0.0250412 34.536 -0.0245451 34.3733 0.0115177L17.6843 3.66738C17.5442 3.69893 17.3996 3.69893 17.2595 3.66738L0.579521 0.0160255C0.543368 0.00250199 0.511735 0.00250199 0.475582 0.00250199C0.349047 0.00250199 0.227032 0.0520882 0.13665 0.146753C0.0191537 0.268465 -0.0260373 0.430747 0.0146346 0.593029Z"
|
||||
fill="#fff" />
|
||||
<path
|
||||
d="M23.0349 32.301L23.8257 32.0936C23.9658 32.0576 24.0743 31.9494 24.0969 31.8096L27.7528 14.0397C27.8116 13.7062 28.0692 13.4357 28.4036 13.3545L31.7567 12.5476C31.8878 12.5161 31.9872 12.4169 32.0188 12.2862L32.8142 9.11267C32.8458 8.99095 32.8142 8.86474 32.7193 8.77458C32.6515 8.70696 32.5611 8.6709 32.4708 8.6709C32.4436 8.6709 32.412 8.6709 32.3849 8.67991L24.3319 10.6228C24.1782 10.6589 24.0698 10.7896 24.0607 10.9429L22.5965 31.9268C22.5875 32.0395 22.6327 32.1477 22.7231 32.2244C22.8089 32.2965 22.9264 32.3235 23.0394 32.2965L23.0349 32.301Z"
|
||||
fill="#fff" />
|
||||
<path
|
||||
d="M10.996 10.6274L2.94297 8.68455C2.91586 8.67554 2.88422 8.67554 2.85711 8.67554C2.76221 8.67554 2.67635 8.7116 2.60856 8.77922C2.51818 8.86937 2.48654 8.99109 2.51366 9.11731L3.30902 12.2908C3.34065 12.4216 3.44007 12.5207 3.57113 12.5523L6.92429 13.3592C7.25871 13.4403 7.51629 13.7108 7.57504 14.0489L11.231 31.8053C11.2581 31.9495 11.362 32.0622 11.4976 32.0938L12.2975 32.3011C12.406 32.3282 12.5189 32.3011 12.6048 32.2245C12.6907 32.1524 12.7404 32.0442 12.7313 31.927L11.2671 10.943C11.2581 10.7852 11.1451 10.659 10.996 10.6229V10.6274Z"
|
||||
fill="#fff" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -1,27 +1,12 @@
|
||||
import { appRouter } from './router/appRouter';
|
||||
import browser from '../scripts/browser';
|
||||
import dialog from './dialog/dialog';
|
||||
import globalize from '../lib/globalize';
|
||||
|
||||
export default async function (text, title) {
|
||||
// Modals seem to be blocked on Web OS and Tizen 2.x
|
||||
const canUseNativeAlert = !!(
|
||||
!browser.web0s
|
||||
&& !(browser.tizenVersion && (browser.tizenVersion < 3 || browser.tizenVersion >= 8))
|
||||
&& browser.tv
|
||||
&& window.alert
|
||||
);
|
||||
|
||||
const options = typeof text === 'string' ? { title, text } : text;
|
||||
|
||||
await appRouter.ready();
|
||||
|
||||
if (canUseNativeAlert) {
|
||||
alert((options.text || '').replaceAll('<br/>', '\n'));
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
options.buttons = [
|
||||
{
|
||||
name: globalize.translate('ButtonGotIt'),
|
||||
|
||||
@@ -12,6 +12,8 @@ const appName = 'Jellyfin Web';
|
||||
const BrowserName = {
|
||||
tizen: 'Samsung Smart TV',
|
||||
web0s: 'LG Smart TV',
|
||||
titanos: 'Titan OS',
|
||||
vega: 'Vega OS',
|
||||
operaTv: 'Opera TV',
|
||||
xboxOne: 'Xbox One',
|
||||
ps4: 'Sony PS4',
|
||||
|
||||
@@ -12,9 +12,7 @@ function enableAnimation() {
|
||||
}
|
||||
|
||||
function enableRotation() {
|
||||
return !browser.tv
|
||||
// Causes high cpu usage
|
||||
&& !browser.firefox;
|
||||
return !browser.tv;
|
||||
}
|
||||
|
||||
class Backdrop {
|
||||
@@ -236,7 +234,7 @@ export function setBackdropImages(images) {
|
||||
currentRotationIndex = -1;
|
||||
|
||||
if (images.length > 1 && enableRotation()) {
|
||||
rotationInterval = setInterval(onRotationInterval, 24000);
|
||||
rotationInterval = setInterval(onRotationInterval, 10000);
|
||||
}
|
||||
|
||||
onRotationInterval();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { type FC } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
import { ensureArray } from 'utils/array';
|
||||
|
||||
import type { TextLine } from './cardHelper';
|
||||
|
||||
interface CardTextProps {
|
||||
@@ -7,27 +10,33 @@ interface CardTextProps {
|
||||
textLine: TextLine;
|
||||
}
|
||||
|
||||
const SEPARATOR = ' / ';
|
||||
|
||||
const CardText: FC<CardTextProps> = ({ className, textLine }) => {
|
||||
const { title, titleAction } = textLine;
|
||||
// eslint-disable-next-line sonarjs/function-return-type
|
||||
const renderCardText = () => {
|
||||
if (titleAction) {
|
||||
return (
|
||||
<a
|
||||
className='itemAction textActionButton'
|
||||
href={titleAction.url}
|
||||
title={titleAction.title}
|
||||
{...titleAction.dataAttributes}
|
||||
>
|
||||
{titleAction.title}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
return title;
|
||||
}
|
||||
};
|
||||
|
||||
return <Box className={className}>{renderCardText()}</Box>;
|
||||
return (
|
||||
<Box className={className}>
|
||||
{titleAction ? (
|
||||
ensureArray(titleAction).map((action, i, arr) => (
|
||||
<>
|
||||
<a
|
||||
className='itemAction textActionButton'
|
||||
href={action.url}
|
||||
title={action.title}
|
||||
{...action.dataAttributes}
|
||||
>
|
||||
{action.title}
|
||||
</a>
|
||||
{/* If there are more items, add the separator */}
|
||||
{(i < arr.length - 1) && SEPARATOR}
|
||||
</>
|
||||
))
|
||||
) : (
|
||||
ensureArray(title).join(SEPARATOR)
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardText;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import type { BaseItemPerson } from '@jellyfin/sdk/lib/generated-client/models/base-item-person';
|
||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
||||
import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
|
||||
@@ -12,6 +13,7 @@ import { isUsingLiveTvNaming } from '../cardBuilderUtils';
|
||||
import { getDataAttributes } from 'utils/items';
|
||||
import { ItemKind } from 'types/base/models/item-kind';
|
||||
import { ItemMediaKind } from 'types/base/models/item-media-kind';
|
||||
import { ensureArray } from 'utils/array';
|
||||
|
||||
import type { NullableNumber, NullableString } from 'types/base/common/shared/types';
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
@@ -65,8 +67,8 @@ interface TextAction {
|
||||
}
|
||||
|
||||
export interface TextLine {
|
||||
title?: NullableString;
|
||||
titleAction?: TextAction;
|
||||
title?: NullableString | string[];
|
||||
titleAction?: TextAction | TextAction[];
|
||||
}
|
||||
|
||||
export function getTextActionButton(
|
||||
@@ -210,9 +212,25 @@ function getParentTitle(
|
||||
item: ItemDto
|
||||
) {
|
||||
if (isOuterFooter && item.AlbumArtists?.length) {
|
||||
(item.AlbumArtists[0] as ItemDto).Type = ItemKind.MusicArtist;
|
||||
(item.AlbumArtists[0] as ItemDto).IsFolder = true;
|
||||
return getTextActionButton(item.AlbumArtists[0], null, serverId);
|
||||
return item.AlbumArtists
|
||||
.map(artist => {
|
||||
const artistItem: ItemDto = {
|
||||
...artist,
|
||||
Type: BaseItemKind.MusicArtist,
|
||||
IsFolder: true
|
||||
};
|
||||
return getTextActionButton(artistItem, null, serverId);
|
||||
})
|
||||
.reduce((acc, line) => ({
|
||||
title: [
|
||||
...ensureArray(acc.title),
|
||||
...ensureArray(line.title)
|
||||
],
|
||||
titleAction: [
|
||||
...ensureArray(acc.titleAction),
|
||||
...ensureArray(line.titleAction)
|
||||
]
|
||||
}), {});
|
||||
} else {
|
||||
return {
|
||||
title: isUsingLiveTvNaming(item.Type) ?
|
||||
|
||||
@@ -575,9 +575,15 @@ function getCardFooterText(item, apiClient, options, footerClass, progressHtml,
|
||||
if (showOtherText) {
|
||||
if (options.showParentTitle && parentTitleUnderneath) {
|
||||
if (flags.isOuterFooter && item.AlbumArtists?.length) {
|
||||
item.AlbumArtists[0].Type = 'MusicArtist';
|
||||
item.AlbumArtists[0].IsFolder = true;
|
||||
lines.push(getTextActionButton(item.AlbumArtists, null, serverId));
|
||||
const artistText = item.AlbumArtists
|
||||
.map(artist => {
|
||||
artist.ServerId = serverId;
|
||||
artist.Type = BaseItemKind.MusicArtist;
|
||||
artist.IsFolder = true;
|
||||
return getTextActionButton(artist);
|
||||
})
|
||||
.join(' / ');
|
||||
lines.push(artistText);
|
||||
} else {
|
||||
lines.push(escapeHtml(isUsingLiveTvNaming(item.Type) ? item.Name : (item.SeriesName || item.Series || item.Album || item.AlbumArtist || '')));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import dialog from 'components/dialog/dialog';
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
import globalize from 'lib/globalize';
|
||||
import browser from 'scripts/browser';
|
||||
|
||||
interface OptionItem {
|
||||
id: string,
|
||||
@@ -18,34 +17,7 @@ interface ConfirmOptions {
|
||||
buttons?: OptionItem[]
|
||||
}
|
||||
|
||||
function shouldUseNativeConfirm() {
|
||||
// webOS seems to block modals
|
||||
// Tizen 2.x seems to block modals
|
||||
return !browser.web0s
|
||||
&& !(browser.tizenVersion && (browser.tizenVersion < 3 || browser.tizenVersion >= 8))
|
||||
&& browser.tv
|
||||
&& !!window.confirm;
|
||||
}
|
||||
|
||||
async function nativeConfirm(options: string | ConfirmOptions) {
|
||||
if (typeof options === 'string') {
|
||||
options = {
|
||||
text: options
|
||||
} as ConfirmOptions;
|
||||
}
|
||||
|
||||
const text = (options.text || '').replace(/<br\/>/g, '\n');
|
||||
await appRouter.ready();
|
||||
const result = window.confirm(text);
|
||||
|
||||
if (result) {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
return Promise.reject(new Error('Confirm dialog rejected'));
|
||||
}
|
||||
}
|
||||
|
||||
async function customConfirm(options: string | ConfirmOptions, title: string = '') {
|
||||
async function confirm(options: string | ConfirmOptions, title: string = '') {
|
||||
if (typeof options === 'string') {
|
||||
options = {
|
||||
title,
|
||||
@@ -80,6 +52,4 @@ async function customConfirm(options: string | ConfirmOptions, title: string = '
|
||||
});
|
||||
}
|
||||
|
||||
const confirm = shouldUseNativeConfirm() ? nativeConfirm : customConfirm;
|
||||
|
||||
export default confirm;
|
||||
|
||||
@@ -53,7 +53,7 @@ function renderFilters(context, result, query) {
|
||||
const delimeter = '|';
|
||||
return (delimeter + (query.Tags || '') + delimeter).includes(delimeter + i + delimeter);
|
||||
});
|
||||
renderOptions(context, '.yearFilters', 'chkYearFilter', merge(result.Years, query.Years, ','), function (i) {
|
||||
renderOptions(context, '.yearFilters', 'chkYearFilter', merge(result.Years.map(String), query.Years, ','), function (i) {
|
||||
const delimeter = ',';
|
||||
return (delimeter + (query.Years || '') + delimeter).includes(delimeter + i + delimeter);
|
||||
});
|
||||
|
||||
@@ -48,12 +48,6 @@ export function enableHlsJsPlayer(runTimeTicks, mediaType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Native HLS support in WebOS only plays stereo sound. hls.js works better, but works only on WebOS 4 or newer.
|
||||
// Using hls.js also seems to fix fast forward issues that native HLS has.
|
||||
if (browser.web0sVersion >= 4) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The native players on these devices support seeking live streams, no need to use hls.js here
|
||||
if (browser.tizen || browser.web0s) {
|
||||
return false;
|
||||
@@ -65,6 +59,12 @@ export function enableHlsJsPlayer(runTimeTicks, mediaType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Chromium 141+ brings native HLS support that does not support switching HDR/SDR playlists.
|
||||
// Always use hls.js to avoid falling back to transcoding from remuxing and client side tone-mapping.
|
||||
if (browser.chrome || browser.edgeChromium || browser.opera) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// simple playback should use the native support
|
||||
if (runTimeTicks) {
|
||||
return false;
|
||||
|
||||
@@ -52,14 +52,6 @@ export function getDisplayName(item, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(item)) {
|
||||
if (item.length > 1) {
|
||||
return item.map(i => getDisplayName(i, options)).join(' / ');
|
||||
} else if (item.length === 1) {
|
||||
return item[0].Name;
|
||||
}
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
|
||||
@@ -1024,7 +1024,7 @@ export class PlaybackManager {
|
||||
self.canPlay = function (item) {
|
||||
const itemType = item.Type;
|
||||
|
||||
if (itemType === 'PhotoAlbum' || itemType === 'MusicGenre' || itemType === 'Season' || itemType === 'Series' || itemType === 'BoxSet' || itemType === 'MusicAlbum' || itemType === 'MusicArtist' || itemType === 'Playlist') {
|
||||
if (itemType === 'Book' || itemType === 'PhotoAlbum' || itemType === 'MusicGenre' || itemType === 'Season' || itemType === 'Series' || itemType === 'BoxSet' || itemType === 'MusicAlbum' || itemType === 'MusicArtist' || itemType === 'Playlist') {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1980,28 +1980,18 @@ export class PlaybackManager {
|
||||
const startSeasonId = firstItem.Type === 'Season' ? items[options.startIndex || 0].Id : undefined;
|
||||
|
||||
const seasonId = (startSeasonId && items.length === 1) ? startSeasonId : undefined;
|
||||
const seriesId = firstItem.SeriesId || firstItem.Id;
|
||||
const SeriesId = firstItem.SeriesId || firstItem.Id;
|
||||
const UserId = apiClient.getCurrentUserId();
|
||||
|
||||
let startItemId;
|
||||
|
||||
// Start from a specific (the next unwatched) episode if we want to watch in order and have not chosen a specific season
|
||||
if (!options.shuffle && !seasonId) {
|
||||
const initialUnplayedEpisode = await getItems(apiClient, UserId, {
|
||||
SortBy: 'SeriesSortName,SortName',
|
||||
SortOrder: 'Ascending',
|
||||
IncludeItemTypes: 'Episode',
|
||||
Recursive: true,
|
||||
IsMissing: false,
|
||||
ParentId: seriesId,
|
||||
limit: 1,
|
||||
Filters: 'IsUnplayed'
|
||||
});
|
||||
|
||||
startItemId = initialUnplayedEpisode?.Items?.at(0)?.Id;
|
||||
const nextUp = await apiClient.getNextUpEpisodes({ SeriesId, UserId });
|
||||
startItemId = nextUp?.Items?.[0]?.Id;
|
||||
}
|
||||
|
||||
const episodesResult = await apiClient.getEpisodes(seriesId, {
|
||||
const episodesResult = await apiClient.getEpisodes(SeriesId, {
|
||||
IsVirtualUnaired: false,
|
||||
IsMissing: false,
|
||||
SeasonId: seasonId,
|
||||
|
||||
@@ -93,12 +93,16 @@ function getQualitySecondaryText(player) {
|
||||
return stream.Type === 'Video';
|
||||
})[0];
|
||||
|
||||
const videoCodec = videoStream ? videoStream.Codec : null;
|
||||
const videoBitRate = videoStream ? videoStream.BitRate : null;
|
||||
const videoWidth = videoStream ? videoStream.Width : null;
|
||||
const videoHeight = videoStream ? videoStream.Height : null;
|
||||
|
||||
const options = qualityoptions.getVideoQualityOptions({
|
||||
currentMaxBitrate: playbackManager.getMaxStreamingBitrate(player),
|
||||
isAutomaticBitrateEnabled: playbackManager.enableAutomaticBitrateDetection(player),
|
||||
videoCodec,
|
||||
videoBitRate,
|
||||
videoWidth: videoWidth,
|
||||
videoHeight: videoHeight,
|
||||
enableAuto: true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PlaybackManager } from './playbackmanager';
|
||||
import { TICKS_PER_MILLISECOND, TICKS_PER_SECOND } from 'constants/time';
|
||||
import type { MediaSegmentDto } from '@jellyfin/sdk/lib/generated-client/models/media-segment-dto';
|
||||
import type { PlaybackStopInfo } from 'types/playbackStopInfo';
|
||||
import { PlaybackSubscriber } from 'apps/stable/features/playback/utils/playbackSubscriber';
|
||||
import { isInSegment } from 'apps/stable/features/playback/utils/mediaSegments';
|
||||
import Events, { type Event } from 'utils/events';
|
||||
@@ -188,10 +189,12 @@ class SkipSegment extends PlaybackSubscriber {
|
||||
}
|
||||
}
|
||||
|
||||
onPlaybackStop() {
|
||||
onPlaybackStop(_e: Event, playbackStopInfo: PlaybackStopInfo) {
|
||||
this.currentSegment = null;
|
||||
this.hideSkipButton();
|
||||
Events.off(document, EventType.SHOW_VIDEO_OSD, this.onOsdChanged);
|
||||
if (!playbackStopInfo.nextItem) {
|
||||
Events.off(document, EventType.SHOW_VIDEO_OSD, this.onOsdChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import browser from '../../scripts/browser';
|
||||
import dialogHelper from '../dialogHelper/dialogHelper';
|
||||
import layoutManager from '../layoutManager';
|
||||
import scrollHelper from '../../scripts/scrollHelper';
|
||||
@@ -92,33 +91,13 @@ export default (() => {
|
||||
});
|
||||
}
|
||||
|
||||
if ((browser.tv || browser.xboxOne) && window.confirm) {
|
||||
return options => {
|
||||
if (typeof options === 'string') {
|
||||
options = {
|
||||
label: '',
|
||||
text: options
|
||||
};
|
||||
}
|
||||
|
||||
const label = (options.label || '').replaceAll('<br/>', '\n');
|
||||
const result = prompt(label, options.text || '');
|
||||
|
||||
if (result) {
|
||||
return Promise.resolve(result);
|
||||
} else {
|
||||
return Promise.reject(result);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return options => {
|
||||
if (typeof options === 'string') {
|
||||
options = {
|
||||
title: '',
|
||||
text: options
|
||||
};
|
||||
}
|
||||
return showDialog(options);
|
||||
};
|
||||
}
|
||||
return options => {
|
||||
if (typeof options === 'string') {
|
||||
options = {
|
||||
title: '',
|
||||
text: options
|
||||
};
|
||||
}
|
||||
return showDialog(options);
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -16,7 +16,7 @@ import { history } from 'RootAppRouter';
|
||||
const START_PAGE_PATHS = ['/home', '/login', '/selectserver'];
|
||||
|
||||
/** Pages that do not require a user to be logged in to view. */
|
||||
const PUBLIC_PATHS = [
|
||||
export const PUBLIC_PATHS = [
|
||||
'/addserver',
|
||||
'/selectserver',
|
||||
'/login',
|
||||
@@ -121,9 +121,7 @@ class AppRouter {
|
||||
return this.baseRoute;
|
||||
}
|
||||
|
||||
canGoBack() {
|
||||
const path = history.location.pathname;
|
||||
|
||||
canGoBack(path = history.location.pathname) {
|
||||
if (
|
||||
!document.querySelector('.dialogContainer')
|
||||
&& START_PAGE_PATHS.includes(path)
|
||||
@@ -261,15 +259,15 @@ class AppRouter {
|
||||
}
|
||||
|
||||
if (item === 'recordedtv') {
|
||||
return '#/livetv?tab=3&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=3&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (item === 'nextup') {
|
||||
return '#/list?type=nextup&serverId=' + options.serverId;
|
||||
return '#/list?type=nextup&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (item === 'list') {
|
||||
let urlForList = '#/list?serverId=' + options.serverId + '&type=' + options.itemTypes;
|
||||
let urlForList = '#/list?serverId=' + serverId + '&type=' + options.itemTypes;
|
||||
|
||||
if (options.isFavorite) {
|
||||
urlForList += '&IsFavorite=true';
|
||||
@@ -304,49 +302,49 @@ class AppRouter {
|
||||
|
||||
if (item === 'livetv') {
|
||||
if (options.section === 'programs') {
|
||||
return '#/livetv?tab=0&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=0&serverId=' + serverId;
|
||||
}
|
||||
if (options.section === 'guide') {
|
||||
return '#/livetv?tab=1&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=1&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'movies') {
|
||||
return '#/list?type=Programs&IsMovie=true&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsMovie=true&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'shows') {
|
||||
return '#/list?type=Programs&IsSeries=true&IsMovie=false&IsNews=false&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsSeries=true&IsMovie=false&IsNews=false&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'sports') {
|
||||
return '#/list?type=Programs&IsSports=true&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsSports=true&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'kids') {
|
||||
return '#/list?type=Programs&IsKids=true&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsKids=true&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'news') {
|
||||
return '#/list?type=Programs&IsNews=true&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsNews=true&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'onnow') {
|
||||
return '#/list?type=Programs&IsAiring=true&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsAiring=true&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'channels') {
|
||||
return '#/livetv?tab=2&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=2&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'dvrschedule') {
|
||||
return '#/livetv?tab=4&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=4&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'seriesrecording') {
|
||||
return '#/livetv?tab=5&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=5&serverId=' + serverId;
|
||||
}
|
||||
|
||||
return '#/livetv?serverId=' + options.serverId;
|
||||
return '#/livetv?serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (itemType == 'SeriesTimer') {
|
||||
|
||||
@@ -41,7 +41,7 @@ try {
|
||||
const opts = Object.defineProperty({}, 'behavior', {
|
||||
get: function () {
|
||||
supportsScrollToOptions = true;
|
||||
return null;
|
||||
return 'auto';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -390,7 +390,7 @@ export function onClick(e) {
|
||||
}
|
||||
}
|
||||
|
||||
if (action) {
|
||||
if (action && action !== 'none') {
|
||||
executeAction(card, actionElement, action);
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
@@ -80,11 +80,12 @@ function setFiles(page, files) {
|
||||
}
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const file = currentFile;
|
||||
|
||||
if (!isValidSubtitleFile(file)) {
|
||||
toast(globalize.translate('MessageSubtitleFileTypeAllowed'));
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -109,8 +110,6 @@ async function onSubmit(e) {
|
||||
hasChanges = true;
|
||||
dialogHelper.close(dlg);
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function initEditor(page) {
|
||||
|
||||
@@ -69,6 +69,7 @@ const PlayedButton: FC<PlayedButtonProps> = ({
|
||||
);
|
||||
return (
|
||||
<IconButton
|
||||
data-action='none'
|
||||
title={getTitle()}
|
||||
className={btnClass}
|
||||
size='small'
|
||||
|
||||
@@ -56,6 +56,7 @@ const FavoriteButton: FC<FavoriteButtonProps> = ({
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
data-action='none'
|
||||
title={isFavorite ? globalize.translate('Favorite') : globalize.translate('AddToFavorites')}
|
||||
className={btnClass}
|
||||
size='small'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useThemes } from './useThemes';
|
||||
import { useUserSettings } from './useUserSettings';
|
||||
|
||||
const FALLBACK_THEME_ID = 'dark';
|
||||
export const FALLBACK_THEME_ID = 'dark';
|
||||
|
||||
export function useUserTheme() {
|
||||
const { theme, dashboardTheme } = useUserSettings();
|
||||
|
||||
@@ -765,6 +765,10 @@ const scrollerFactory = function (frame, options) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (layoutManager.tv) {
|
||||
frame.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
slideeElement.style['will-change'] = 'transform';
|
||||
slideeElement.style.transition = 'transform ' + o.speed + 'ms ease-out';
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export class BookPlayer {
|
||||
this.type = PluginType.MediaPlayer;
|
||||
this.id = 'bookplayer';
|
||||
this.priority = 1;
|
||||
this.THEMES = THEMES;
|
||||
if (!userSettings.theme() || userSettings.theme() === 'dark') {
|
||||
this.theme = 'dark';
|
||||
} else {
|
||||
|
||||
@@ -22,12 +22,12 @@
|
||||
|
||||
#btnBookplayerToc {
|
||||
float: left;
|
||||
margin-left: 2vw;
|
||||
margin: 0.5vh 0.5vh 0.5vh 2vw;
|
||||
}
|
||||
|
||||
#btnBookplayerExit {
|
||||
float: right;
|
||||
margin-right: 2vw;
|
||||
margin: 0.5vh 2vw 0.5vh 0.5vh;
|
||||
}
|
||||
|
||||
.bookplayerErrorMsg {
|
||||
@@ -46,13 +46,20 @@
|
||||
width: fit-content;
|
||||
max-height: 80%;
|
||||
max-width: 60%;
|
||||
padding-right: 50px;
|
||||
padding-bottom: 15px;
|
||||
|
||||
.bookplayerButtonIcon {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.bookplayerButton {
|
||||
margin: 0.5vh;
|
||||
}
|
||||
|
||||
.toc {
|
||||
margin: 0;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.toc li {
|
||||
margin-bottom: 5px;
|
||||
|
||||
@@ -80,3 +87,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 60em) {
|
||||
#dialogToc {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import escapeHTML from 'escape-html';
|
||||
import dialogHelper from '../../components/dialogHelper/dialogHelper';
|
||||
import layoutManager from 'components/layoutManager';
|
||||
|
||||
export default class TableOfContents {
|
||||
constructor(bookPlayer) {
|
||||
@@ -57,7 +58,7 @@ export default class TableOfContents {
|
||||
|
||||
// remove parent directory reference from href to fix certain books
|
||||
const link = chapter.href.startsWith('../') ? chapter.href.slice(3) : chapter.href;
|
||||
itemHtml += `<a href="${escapeHTML(book.path.directory + link)}">${escapeHTML(chapter.label)}</a>`;
|
||||
itemHtml += `<a style="color: ${layoutManager.mobile ? this.bookPlayer.THEMES[this.bookPlayer.theme].body.color : 'inherit'}" href="${escapeHTML(book.path.directory + link)}">${escapeHTML(chapter.label)}</a>`;
|
||||
|
||||
if (chapter.subitems?.length) {
|
||||
const subHtml = chapter.subitems
|
||||
@@ -85,7 +86,7 @@ export default class TableOfContents {
|
||||
let tocHtml = '<div class="topRightActionButtons">';
|
||||
tocHtml += '<button is="paper-icon-button-light" class="autoSize bookplayerButton btnBookplayerTocClose hide-mouse-idle-tv" tabindex="-1"><span class="material-icons bookplayerButtonIcon close" aria-hidden="true"></span></button>';
|
||||
tocHtml += '</div>';
|
||||
tocHtml += '<ul class="toc">';
|
||||
tocHtml += `<ul style="background-color: ${layoutManager.mobile ? this.bookPlayer.THEMES[this.bookPlayer.theme].body.background : 'inherit'}" class="toc">`;
|
||||
rendition.book.navigation.forEach((chapter) => {
|
||||
tocHtml += this.chapterTocItem(rendition.book, chapter);
|
||||
});
|
||||
|
||||
@@ -1064,9 +1064,9 @@ export class HtmlVideoPlayer {
|
||||
Events.trigger(this, 'pause');
|
||||
};
|
||||
|
||||
onWaiting() {
|
||||
onWaiting = () => {
|
||||
Events.trigger(this, 'waiting');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @private
|
||||
|
||||
@@ -96,8 +96,8 @@ class HtmlVideoPlayer extends NoActivePlayer {
|
||||
|
||||
Events.off(this.player, 'playbackstart', this._onPlaybackStart);
|
||||
Events.off(this.player, 'playbackstop', this._onPlaybackStop);
|
||||
Events.off(this.player, 'unpause', this._onPlayerUnpause);
|
||||
Events.off(this.player, 'pause', this._onPlayerPause);
|
||||
Events.off(this.player, 'unpause', this._onUnpause);
|
||||
Events.off(this.player, 'pause', this._onPause);
|
||||
Events.off(this.player, 'timeupdate', this._onTimeUpdate);
|
||||
Events.off(this.player, 'playing', this._onPlaying);
|
||||
Events.off(this.player, 'waiting', this._onWaiting);
|
||||
|
||||
@@ -34,12 +34,12 @@
|
||||
</div>
|
||||
<div class="inputContainer inputContainer-withDescription">
|
||||
<input type="number" is="emby-input" id="txtMaxDelaySpeedToSync" pattern="[0-9]*"
|
||||
label="${LabelSyncPlaySettingsMaxDelaySpeedToSync}" />
|
||||
label="${LabelSyncPlaySettingsMaxDelaySpeedToSync}" min="0"/>
|
||||
<div class="fieldDescription">${LabelSyncPlaySettingsMaxDelaySpeedToSyncHelp}</div>
|
||||
</div>
|
||||
<div class="inputContainer inputContainer-withDescription">
|
||||
<input type="number" is="emby-input" id="txtSpeedToSyncDuration" pattern="[0-9]*"
|
||||
label="${LabelSyncPlaySettingsSpeedToSyncDuration}" />
|
||||
label="${LabelSyncPlaySettingsSpeedToSyncDuration}" min="0"/>
|
||||
<div class="fieldDescription">${LabelSyncPlaySettingsSpeedToSyncDurationHelp}</div>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
</div>
|
||||
<div class="inputContainer inputContainer-withDescription">
|
||||
<input type="number" is="emby-input" id="txtMinDelaySkipToSync" pattern="[0-9]*"
|
||||
label="${LabelSyncPlaySettingsMinDelaySkipToSync}" />
|
||||
label="${LabelSyncPlaySettingsMinDelaySkipToSync}" min="0"/>
|
||||
<div class="fieldDescription">${LabelSyncPlaySettingsMinDelaySkipToSyncHelp}</div>
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<h2 class="sectionTitle">${HeaderSyncPlayTimeSyncSettings}</h2>
|
||||
<div class="inputContainer inputContainer-withDescription">
|
||||
<input type="number" is="emby-input" id="txtExtraTimeOffset" pattern="[0-9]*"
|
||||
label="${LabelSyncPlaySettingsExtraTimeOffset}" />
|
||||
label="${LabelSyncPlaySettingsExtraTimeOffset}" min="0"/>
|
||||
<div class="fieldDescription">${LabelSyncPlaySettingsExtraTimeOffsetHelp}</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
4
src/scripts/browser.d.ts
vendored
4
src/scripts/browser.d.ts
vendored
@@ -21,8 +21,10 @@ declare namespace browser {
|
||||
export let animate: boolean;
|
||||
export let hisense: boolean;
|
||||
export let tizen: boolean;
|
||||
export let vega: boolean;
|
||||
export let vidaa: boolean;
|
||||
export let web0s: boolean;
|
||||
export let titanos: boolean;
|
||||
export let edgeUwp: boolean;
|
||||
export let web0sVersion: number | undefined;
|
||||
export let tizenVersion: number | undefined;
|
||||
@@ -36,4 +38,6 @@ declare namespace browser {
|
||||
export let iOSVersion: number | undefined;
|
||||
}
|
||||
|
||||
export function detectBrowser(userAgent?: string): browser;
|
||||
|
||||
export default browser;
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
function isTv() {
|
||||
function isTv(userAgent) {
|
||||
// This is going to be really difficult to get right
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
// The OculusBrowsers userAgent also has the samsungbrowser defined but is not a tv.
|
||||
if (userAgent.indexOf('oculusbrowser') !== -1) {
|
||||
if (userAgent.includes('oculusbrowser')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (userAgent.indexOf('tv') !== -1) {
|
||||
if (userAgent.includes('tv')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (userAgent.indexOf('samsungbrowser') !== -1) {
|
||||
if (userAgent.includes('samsungbrowser')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (userAgent.indexOf('viera') !== -1) {
|
||||
if (userAgent.includes('viera')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isWeb0s();
|
||||
if (userAgent.includes('titanos')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isWeb0s(userAgent);
|
||||
}
|
||||
|
||||
function isWeb0s() {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
return userAgent.indexOf('netcast') !== -1
|
||||
|| userAgent.indexOf('web0s') !== -1;
|
||||
function isWeb0s(userAgent) {
|
||||
return userAgent.includes('netcast')
|
||||
|| userAgent.includes('web0s');
|
||||
}
|
||||
|
||||
function isMobile(userAgent) {
|
||||
@@ -42,10 +43,8 @@ function isMobile(userAgent) {
|
||||
'opera mini'
|
||||
];
|
||||
|
||||
const lower = userAgent.toLowerCase();
|
||||
|
||||
for (let i = 0, length = terms.length; i < length; i++) {
|
||||
if (lower.indexOf(terms[i]) !== -1) {
|
||||
for (const term of terms) {
|
||||
if (userAgent.includes(term)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -105,7 +104,7 @@ function web0sVersion(browser) {
|
||||
if (browser.chrome) {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
if (userAgent.indexOf('netcast') !== -1) {
|
||||
if (userAgent.includes('netcast')) {
|
||||
// The built-in browser (NetCast) may have a version that doesn't correspond to the actual web engine
|
||||
// Since there is no reliable way to detect webOS version, we return an undefined version
|
||||
|
||||
@@ -187,20 +186,20 @@ function supportsCssAnimation(allowPrefix) {
|
||||
}
|
||||
|
||||
const uaMatch = function (ua) {
|
||||
ua = ua.toLowerCase();
|
||||
|
||||
// Motorola Edge device UA triggers false positive for Edge browser
|
||||
ua = ua.replace(/(motorola edge)/, '').trim();
|
||||
|
||||
const match = /(edg)[ /]([\w.]+)/.exec(ua)
|
||||
|| /(edga)[ /]([\w.]+)/.exec(ua)
|
||||
|| /(edgios)[ /]([\w.]+)/.exec(ua)
|
||||
|| /(edge)[ /]([\w.]+)/.exec(ua)
|
||||
|| /(titanos)[ /]([\w.]+)/.exec(ua)
|
||||
|| /(opera)[ /]([\w.]+)/.exec(ua)
|
||||
|| /(opr)[ /]([\w.]+)/.exec(ua)
|
||||
|| /(chrome)[ /]([\w.]+)/.exec(ua)
|
||||
|| /(safari)[ /]([\w.]+)/.exec(ua)
|
||||
|| /(firefox)[ /]([\w.]+)/.exec(ua)
|
||||
|| ua.indexOf('compatible') < 0 && /(mozilla)(?:.*? rv:([\w.]+)|)/.exec(ua)
|
||||
|| !ua.includes('compatible') && /(mozilla)(?:.*? rv:([\w.]+)|)/.exec(ua)
|
||||
|| [];
|
||||
|
||||
const versionMatch = /(version)[ /]([\w.]+)/.exec(ua);
|
||||
@@ -209,6 +208,7 @@ const uaMatch = function (ua) {
|
||||
|| /(iphone)/.exec(ua)
|
||||
|| /(windows)/.exec(ua)
|
||||
|| /(android)/.exec(ua)
|
||||
|| /(titanos)/.exec(ua)
|
||||
|| [];
|
||||
|
||||
let browser = match[1] || '';
|
||||
@@ -235,102 +235,112 @@ const uaMatch = function (ua) {
|
||||
}
|
||||
|
||||
return {
|
||||
browser: browser,
|
||||
version: version,
|
||||
browser,
|
||||
version,
|
||||
platform: platformMatch[0] || '',
|
||||
versionMajor: versionMajor
|
||||
versionMajor
|
||||
};
|
||||
};
|
||||
|
||||
const userAgent = navigator.userAgent;
|
||||
export const detectBrowser = (userAgent = navigator.userAgent) => {
|
||||
const normalizedUA = userAgent.toLowerCase();
|
||||
|
||||
const matched = uaMatch(userAgent);
|
||||
const browser = {};
|
||||
const matched = uaMatch(normalizedUA);
|
||||
const browser = {};
|
||||
|
||||
if (matched.browser) {
|
||||
browser[matched.browser] = true;
|
||||
browser.version = matched.version;
|
||||
browser.versionMajor = matched.versionMajor;
|
||||
}
|
||||
|
||||
if (matched.platform) {
|
||||
browser[matched.platform] = true;
|
||||
}
|
||||
|
||||
browser.edgeChromium = browser.edg || browser.edga || browser.edgios;
|
||||
|
||||
if (!browser.chrome && !browser.edgeChromium && !browser.edge && !browser.opera && userAgent.toLowerCase().indexOf('webkit') !== -1) {
|
||||
browser.safari = true;
|
||||
}
|
||||
|
||||
browser.osx = userAgent.toLowerCase().indexOf('mac os x') !== -1;
|
||||
|
||||
// This is a workaround to detect iPads on iOS 13+ that report as desktop Safari
|
||||
// This may break in the future if Apple releases a touchscreen Mac
|
||||
// https://forums.developer.apple.com/thread/119186
|
||||
if (browser.osx && !browser.iphone && !browser.ipod && !browser.ipad && navigator.maxTouchPoints > 1) {
|
||||
browser.ipad = true;
|
||||
}
|
||||
|
||||
if (userAgent.toLowerCase().indexOf('playstation 4') !== -1) {
|
||||
browser.ps4 = true;
|
||||
browser.tv = true;
|
||||
}
|
||||
|
||||
if (isMobile(userAgent)) {
|
||||
browser.mobile = true;
|
||||
}
|
||||
|
||||
if (userAgent.toLowerCase().indexOf('xbox') !== -1) {
|
||||
browser.xboxOne = true;
|
||||
browser.tv = true;
|
||||
}
|
||||
browser.animate = typeof document !== 'undefined' && document.documentElement.animate != null;
|
||||
browser.hisense = userAgent.toLowerCase().includes('hisense');
|
||||
browser.tizen = userAgent.toLowerCase().indexOf('tizen') !== -1 || window.tizen != null;
|
||||
browser.vidaa = userAgent.toLowerCase().includes('vidaa');
|
||||
browser.web0s = isWeb0s();
|
||||
browser.edgeUwp = (browser.edge || browser.edgeChromium) && (userAgent.toLowerCase().indexOf('msapphost') !== -1 || userAgent.toLowerCase().indexOf('webview') !== -1);
|
||||
|
||||
if (browser.web0s) {
|
||||
browser.web0sVersion = web0sVersion(browser);
|
||||
|
||||
// UserAgent string contains 'Chrome' and 'Safari', but we only want 'web0s' to be true
|
||||
delete browser.chrome;
|
||||
delete browser.safari;
|
||||
} else if (browser.tizen) {
|
||||
const v = RegExp(/Tizen (\d+).(\d+)/).exec(userAgent);
|
||||
browser.tizenVersion = parseInt(v[1], 10) + parseInt(v[2], 10) / 10;
|
||||
|
||||
// UserAgent string contains 'Chrome' and 'Safari', but we only want 'tizen' to be true
|
||||
delete browser.chrome;
|
||||
delete browser.safari;
|
||||
} else {
|
||||
browser.orsay = userAgent.toLowerCase().indexOf('smarthub') !== -1;
|
||||
}
|
||||
|
||||
browser.tv = isTv();
|
||||
browser.operaTv = browser.tv && userAgent.toLowerCase().indexOf('opr/') !== -1;
|
||||
|
||||
if (browser.mobile || browser.tv) {
|
||||
browser.slow = true;
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined' && ('ontouchstart' in window) || (navigator.maxTouchPoints > 0)) {
|
||||
browser.touch = true;
|
||||
}
|
||||
|
||||
browser.keyboard = hasKeyboard(browser);
|
||||
browser.supportsCssAnimation = supportsCssAnimation;
|
||||
|
||||
browser.iOS = browser.ipad || browser.iphone || browser.ipod;
|
||||
|
||||
if (browser.iOS) {
|
||||
browser.iOSVersion = iOSversion();
|
||||
|
||||
if (browser.iOSVersion && browser.iOSVersion.length >= 2) {
|
||||
browser.iOSVersion = browser.iOSVersion[0] + (browser.iOSVersion[1] / 10);
|
||||
if (matched.browser) {
|
||||
browser[matched.browser] = true;
|
||||
browser.version = matched.version;
|
||||
browser.versionMajor = matched.versionMajor;
|
||||
}
|
||||
}
|
||||
|
||||
export default browser;
|
||||
if (matched.platform) {
|
||||
browser[matched.platform] = true;
|
||||
}
|
||||
|
||||
browser.edgeChromium = browser.edg || browser.edga || browser.edgios;
|
||||
|
||||
if (!browser.chrome && !browser.edgeChromium && !browser.edge && !browser.opera && normalizedUA.includes('webkit')) {
|
||||
browser.safari = true;
|
||||
}
|
||||
|
||||
browser.osx = normalizedUA.includes('mac os x');
|
||||
|
||||
// This is a workaround to detect iPads on iOS 13+ that report as desktop Safari
|
||||
// This may break in the future if Apple releases a touchscreen Mac
|
||||
// https://forums.developer.apple.com/thread/119186
|
||||
if (browser.osx && !browser.iphone && !browser.ipod && !browser.ipad && navigator.maxTouchPoints > 1) {
|
||||
browser.ipad = true;
|
||||
}
|
||||
|
||||
if (isMobile(normalizedUA)) {
|
||||
browser.mobile = true;
|
||||
}
|
||||
|
||||
browser.ps4 = normalizedUA.includes('playstation 4');
|
||||
browser.xboxOne = normalizedUA.includes('xbox');
|
||||
|
||||
browser.animate = typeof document !== 'undefined' && document.documentElement.animate != null;
|
||||
browser.hisense = normalizedUA.includes('hisense');
|
||||
browser.tizen = normalizedUA.includes('tizen') || window.tizen != null;
|
||||
browser.vega = normalizedUA.includes('kepler');
|
||||
browser.vidaa = normalizedUA.includes('vidaa');
|
||||
browser.web0s = isWeb0s(normalizedUA);
|
||||
|
||||
browser.tv = browser.ps4 || browser.vega || browser.xboxOne || isTv(normalizedUA);
|
||||
browser.operaTv = browser.tv && normalizedUA.includes('opr/');
|
||||
|
||||
browser.edgeUwp = (browser.edge || browser.edgeChromium) && (normalizedUA.includes('msapphost') || normalizedUA.includes('webview'));
|
||||
|
||||
if (browser.web0s) {
|
||||
browser.web0sVersion = web0sVersion(browser);
|
||||
|
||||
// UserAgent string contains 'Chrome' and 'Safari', but we only want 'web0s' to be true
|
||||
delete browser.chrome;
|
||||
delete browser.safari;
|
||||
} else if (browser.tizen) {
|
||||
const v = RegExp(/Tizen (\d+).(\d+)/).exec(userAgent);
|
||||
browser.tizenVersion = parseInt(v[1], 10) + parseInt(v[2], 10) / 10;
|
||||
|
||||
// UserAgent string contains 'Chrome' and 'Safari', but we only want 'tizen' to be true
|
||||
delete browser.chrome;
|
||||
delete browser.safari;
|
||||
} else if (browser.titanos) {
|
||||
// UserAgent string contains 'Opr' and 'Safari', but we only want 'titanos' to be true
|
||||
delete browser.operaTv;
|
||||
delete browser.safari;
|
||||
} else if (browser.vega) {
|
||||
// UserAgent string contains 'Chrome' and 'Safari', but we only want 'vega' to be true
|
||||
delete browser.chrome;
|
||||
delete browser.safari;
|
||||
// UserAgent string contains 'Mobile Chrome', but it is a TV
|
||||
delete browser.mobile;
|
||||
} else {
|
||||
browser.orsay = normalizedUA.includes('smarthub');
|
||||
}
|
||||
|
||||
if (browser.mobile || browser.tv) {
|
||||
browser.slow = true;
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined' && ('ontouchstart' in window) || (navigator.maxTouchPoints > 0)) {
|
||||
browser.touch = true;
|
||||
}
|
||||
|
||||
browser.keyboard = hasKeyboard(browser);
|
||||
browser.supportsCssAnimation = supportsCssAnimation;
|
||||
|
||||
browser.iOS = browser.ipad || browser.iphone || browser.ipod;
|
||||
|
||||
if (browser.iOS) {
|
||||
browser.iOSVersion = iOSversion();
|
||||
|
||||
if (browser.iOSVersion && browser.iOSVersion.length >= 2) {
|
||||
browser.iOSVersion = browser.iOSVersion[0] + (browser.iOSVersion[1] / 10);
|
||||
}
|
||||
}
|
||||
|
||||
return browser;
|
||||
};
|
||||
|
||||
export default detectBrowser();
|
||||
|
||||
38
src/scripts/browser.test.ts
Normal file
38
src/scripts/browser.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { detectBrowser } from './browser';
|
||||
|
||||
describe('Browser', () => {
|
||||
it('should identify TitanOS devices', () => {
|
||||
// Ref: https://docs.titanos.tv/user-agents-specifications
|
||||
// Philips example
|
||||
let browser = detectBrowser('Mozilla/5.0 (Linux armv7l) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.4147.62 Safari/537.36 OPR/46.0.2207.0 OMI/4.24, TV_NT72690_2025_4K /<SW version> (Philips, <CTN>, wired) CE-HTML/1.0 NETTV/4.6.0.8 SignOn/2.0 SmartTvA/5.0.0 TitanOS/3.0 en Ginga');
|
||||
expect(browser.titanos).toBe(true);
|
||||
expect(browser.operaTv).toBeFalsy();
|
||||
expect(browser.safari).toBeFalsy();
|
||||
expect(browser.tv).toBe(true);
|
||||
|
||||
// JVC example
|
||||
browser = detectBrowser('Mozilla/5.0 (Linux ) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.128 Safari/537.36 OMI/4.24.3.93.MIKE.227 Model/Vestel-MB190 VSTVB MB100 FVC/9.0 (VESTEL; MB190; ) HbbTV/1.7.1 (+DRM; VESTEL; MB190; 0.9.0.0; ; _TV__2025;) TitanOS/3.0 (Vestel MB190 VESTEL) SmartTvA/3.0.0');
|
||||
expect(browser.titanos).toBe(true);
|
||||
expect(browser.operaTv).toBeFalsy();
|
||||
expect(browser.safari).toBeFalsy();
|
||||
expect(browser.tv).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify Vega devices', () => {
|
||||
// Ref: https://developer.amazon.com/docs/vega/0.21/webview-development-best-practices-tv.html#avoid-relying-on-the-useragent
|
||||
const browser = detectBrowser('Mozilla/5.0 (Linux; Kepler 1.1; AFTCA002 user/1234; wv) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Chrome/130.0.6723.192 Safari/537.36');
|
||||
expect(browser.vega).toBe(true);
|
||||
expect(browser.chrome).toBeFalsy();
|
||||
expect(browser.safari).toBeFalsy();
|
||||
expect(browser.mobile).toBeFalsy();
|
||||
expect(browser.tv).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify Xbox devices', () => {
|
||||
const browser = detectBrowser('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0 WebView2 Xbox');
|
||||
expect(browser.xboxOne).toBe(true);
|
||||
expect(browser.tv).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,11 @@ function canPlayHevc(videoTestElement, options) {
|
||||
}
|
||||
|
||||
function canPlayAv1(videoTestElement) {
|
||||
// Xbox UWP WebView2 falsely reports AV1 support but cannot play it
|
||||
if (browser.xboxOne) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (browser.tizenVersion >= 5.5 || browser.web0sVersion >= 5) {
|
||||
return true;
|
||||
}
|
||||
@@ -137,14 +142,6 @@ function supportsEac3(videoTestElement) {
|
||||
}
|
||||
|
||||
function supportsAc3InHls(videoTestElement) {
|
||||
// We use hls.js on WebOS 4 and newer and hls.js uses Media Sources Extensions (MSE) API.
|
||||
// On WebOS MSE does support AC-3 and EAC-3 only on audio mp4 file but not on audiovideo mp4
|
||||
// therefore until audio and video is not separated when generating stream and m3u8 this should
|
||||
// return false.
|
||||
if (browser.web0sVersion >= 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (browser.tizen || browser.web0s) {
|
||||
return true;
|
||||
}
|
||||
@@ -178,6 +175,11 @@ function canPlayAudioFormat(format) {
|
||||
return true;
|
||||
}
|
||||
} else if (format === 'opus') {
|
||||
// Xbox UWP WebView2 falsely reports Opus support but cannot play it
|
||||
if (browser.xboxOne) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (browser.web0s) {
|
||||
// canPlayType lies about OPUS support
|
||||
return browser.web0sVersion >= 3.5;
|
||||
@@ -213,6 +215,13 @@ function testCanPlayMkv(videoTestElement) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (browser.firefox) {
|
||||
// As of Firefox 145, its mkv support is buggy and causes playback issues because it would force preloading the
|
||||
// whole mkv file before playback starts, which is extremely undesirable for streaming.
|
||||
// See https://github.com/jellyfin/jellyfin/issues/15521
|
||||
return false;
|
||||
}
|
||||
|
||||
if (videoTestElement.canPlayType('video/x-matroska').replace(/no/, '')
|
||||
|| videoTestElement.canPlayType('video/mkv').replace(/no/, '')) {
|
||||
return true;
|
||||
@@ -924,7 +933,7 @@ export default function (options) {
|
||||
|
||||
profile.ContainerProfiles = [];
|
||||
|
||||
if (browser.tizen) {
|
||||
if (browser.tizenVersion < 6.5) {
|
||||
// Tizen doesn't support more than 32 streams in a single file
|
||||
profile.ContainerProfiles.push({
|
||||
Type: 'Video',
|
||||
@@ -1147,6 +1156,13 @@ export default function (options) {
|
||||
hevcProfiles = 'main|main 10';
|
||||
}
|
||||
|
||||
// hevc main10 level 6.2
|
||||
if (videoTestElement.canPlayType('video/mp4; codecs="hvc1.2.4.L186"').replace(/no/, '')
|
||||
|| videoTestElement.canPlayType('video/mp4; codecs="hev1.2.4.L186"').replace(/no/, '')) {
|
||||
maxHevcLevel = 186;
|
||||
hevcProfiles = 'main|main 10';
|
||||
}
|
||||
|
||||
let maxAv1Level = 15; // level 5.3
|
||||
const av1Profiles = 'main'; // av1 main covers 4:2:0 8 & 10 bits
|
||||
|
||||
@@ -1184,12 +1200,18 @@ export default function (options) {
|
||||
}
|
||||
|
||||
if (supportsHdr10(options)) {
|
||||
hevcVideoRangeTypes += '|HDR10';
|
||||
vp9VideoRangeTypes += '|HDR10';
|
||||
av1VideoRangeTypes += '|HDR10';
|
||||
// HDR10+ videos can be safely played on all HDR10 capable devices, just without the dynamic metadata.
|
||||
hevcVideoRangeTypes += '|HDR10|HDR10Plus';
|
||||
vp9VideoRangeTypes += '|HDR10|HDR10Plus';
|
||||
av1VideoRangeTypes += '|HDR10|HDR10Plus';
|
||||
|
||||
if (browser.tizenVersion >= 3 || browser.vidaa) {
|
||||
hevcVideoRangeTypes += '|DOVIWithHDR10';
|
||||
// Tizen TV does not support Dolby Vision at all, but it can safely play the HDR fallback.
|
||||
// Advertising the support so that the server doesn't have to remux.
|
||||
hevcVideoRangeTypes += '|DOVIWithHDR10|DOVIWithHDR10Plus|DOVIWithEL|DOVIWithELHDR10Plus|DOVIInvalid';
|
||||
// Although no official tools exist to create AV1+DV files yet, some of our users managed to use community tools to create such files.
|
||||
// These files should also be playable on Tizen TVs.
|
||||
av1VideoRangeTypes += '|DOVIWithHDR10|DOVIWithHDR10Plus|DOVIWithEL|DOVIWithELHDR10Plus|DOVIInvalid';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1209,11 +1231,22 @@ export default function (options) {
|
||||
hevcVideoRangeTypes += '|DOVI';
|
||||
}
|
||||
if (profiles.includes(8)) {
|
||||
hevcVideoRangeTypes += '|DOVIWithHDR10|DOVIWithHLG|DOVIWithSDR';
|
||||
hevcVideoRangeTypes += '|DOVIWithHDR10|DOVIWithHLG|DOVIWithSDR|DOVIWithHDR10Plus';
|
||||
}
|
||||
|
||||
if (browser.web0s) {
|
||||
// For webOS, we should allow direct play of some not fully supported DV profiles to avoid unnecessary remux/transcode
|
||||
// webOS seems to be able to play the fallback of Profile 7 and most invalid profiles
|
||||
hevcVideoRangeTypes += '|DOVIWithEL|DOVIWithELHDR10Plus|DOVIInvalid';
|
||||
}
|
||||
|
||||
if (supportedDolbyVisionProfileAv1(videoTestElement)) {
|
||||
av1VideoRangeTypes += '|DOVI|DOVIWithHDR10|DOVIWithHLG|DOVIWithSDR';
|
||||
av1VideoRangeTypes += '|DOVI|DOVIWithHDR10|DOVIWithHLG|DOVIWithSDR|DOVIWithHDR10Plus';
|
||||
if (browser.web0s) {
|
||||
// For webOS, we should allow direct play of some not fully supported DV profiles to avoid unnecessary remux/transcode
|
||||
// webOS seems to be able to play the fallback of Profile 7 and most invalid profiles
|
||||
av1VideoRangeTypes += '|DOVIWithEL|DOVIWithELHDR10Plus|DOVIInvalid';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { toApi } from 'utils/jellyfin-apiclient/compat';
|
||||
function getFetchPlaylistItemsFn(apiClient, itemId) {
|
||||
return function () {
|
||||
const query = {
|
||||
Fields: 'PrimaryImageAspectRatio',
|
||||
Fields: 'PrimaryImageAspectRatio,MediaSourceCount,Chapters,Trickplay',
|
||||
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb',
|
||||
UserId: apiClient.getCurrentUserId()
|
||||
};
|
||||
|
||||
@@ -501,6 +501,20 @@
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
|
||||
[dir="rtl"] & {
|
||||
left: 25vw;
|
||||
right: unset;
|
||||
}
|
||||
|
||||
.layout-mobile &,
|
||||
.layout-tv & {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media all and (max-width: 68.75em) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.criticReview:first-child {
|
||||
@@ -841,7 +855,6 @@
|
||||
|
||||
.detailPageSecondaryContainer {
|
||||
padding-top: 1.25em;
|
||||
overflow: hidden;
|
||||
|
||||
.layout-desktop & {
|
||||
flex-grow: 1;
|
||||
@@ -902,6 +915,7 @@
|
||||
|
||||
[dir="rtl"] & {
|
||||
left: unset;
|
||||
float: right;
|
||||
|
||||
.layout-mobile &,
|
||||
.layout-tv & {
|
||||
@@ -940,19 +954,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.layout-mobile,
|
||||
.layout-tv {
|
||||
.detailLogo {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-width: 68.75em) {
|
||||
.detailLogo {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.itemDetailImage {
|
||||
width: 100% !important;
|
||||
box-shadow: 0 0.1em 0.5em 0 rgba(0, 0, 0, 0.75);
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
|
||||
html {
|
||||
@include fullpage;
|
||||
|
||||
/* Set the default font for cases we don't load the brand font */
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
|
||||
8
src/utils/array.ts
Normal file
8
src/utils/array.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Utility function that converts a value that can be a single item, array of items, null, or undefined to an array.
|
||||
*/
|
||||
export function ensureArray<T>(val: T | T[] | null | undefined): T[] {
|
||||
if (val == null) return [];
|
||||
if (Array.isArray(val)) return val;
|
||||
return [ val ];
|
||||
}
|
||||
@@ -31,6 +31,10 @@ function getWebDeviceIcon(browser: string | null | undefined) {
|
||||
return BASE_DEVICE_IMAGE_URL + 'edge.svg';
|
||||
case 'Internet Explorer':
|
||||
return BASE_DEVICE_IMAGE_URL + 'msie.svg';
|
||||
case 'Titan OS':
|
||||
return BASE_DEVICE_IMAGE_URL + 'titanos.svg';
|
||||
case 'Vega OS':
|
||||
return BASE_DEVICE_IMAGE_URL + 'firetv.svg';
|
||||
default:
|
||||
return BASE_DEVICE_IMAGE_URL + 'html5.svg';
|
||||
}
|
||||
|
||||
@@ -196,7 +196,6 @@ export const getProgramSections = (): Section[] => {
|
||||
apiMethod: SectionApiMethod.LiveTvPrograms,
|
||||
type: SectionType.UpcomingEpisodes,
|
||||
parametersOptions: {
|
||||
isAiring: false,
|
||||
hasAired: false,
|
||||
isMovie: false,
|
||||
isSports: false,
|
||||
@@ -221,7 +220,6 @@ export const getProgramSections = (): Section[] => {
|
||||
apiMethod: SectionApiMethod.LiveTvPrograms,
|
||||
type: SectionType.UpcomingMovies,
|
||||
parametersOptions: {
|
||||
isAiring: false,
|
||||
hasAired: false,
|
||||
isMovie: true
|
||||
},
|
||||
@@ -242,7 +240,6 @@ export const getProgramSections = (): Section[] => {
|
||||
apiMethod: SectionApiMethod.LiveTvPrograms,
|
||||
type: SectionType.UpcomingSports,
|
||||
parametersOptions: {
|
||||
isAiring: false,
|
||||
hasAired: false,
|
||||
isSports: true
|
||||
},
|
||||
@@ -263,7 +260,6 @@ export const getProgramSections = (): Section[] => {
|
||||
apiMethod: SectionApiMethod.LiveTvPrograms,
|
||||
type: SectionType.UpcomingKids,
|
||||
parametersOptions: {
|
||||
isAiring: false,
|
||||
hasAired: false,
|
||||
isKids: true
|
||||
},
|
||||
@@ -284,7 +280,6 @@ export const getProgramSections = (): Section[] => {
|
||||
apiMethod: SectionApiMethod.LiveTvPrograms,
|
||||
type: SectionType.UpcomingNews,
|
||||
parametersOptions: {
|
||||
isAiring: false,
|
||||
hasAired: false,
|
||||
isNews: true
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user