Merge branch 'master' into issue5486
This commit is contained in:
@@ -26,15 +26,15 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: Initialize CodeQL 🛠️
|
||||
uses: github/codeql-action/init@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15
|
||||
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
with:
|
||||
queries: security-and-quality
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild 📦
|
||||
uses: github/codeql-action/autobuild@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15
|
||||
uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
|
||||
- name: Perform CodeQL Analysis 🧪
|
||||
uses: github/codeql-action/analyze@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15
|
||||
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download workflow artifact ⬇️
|
||||
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: ${{ inputs.artifact_name }}
|
||||
path: dist
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
ref: ${{ inputs.commit || github.sha }}
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: Scan
|
||||
uses: actions/dependency-review-action@ce3cf9537a52e8119d91fd484ab5b8a807627bf8 # v4.6.0
|
||||
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
|
||||
with:
|
||||
## Workaround from https://github.com/actions/dependency-review-action/issues/456
|
||||
## TODO: Remove when necessary
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: Setup node environment ⚙️
|
||||
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
@@ -95,6 +95,6 @@ jobs:
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run eslint
|
||||
uses: CatChen/eslint-suggestion-action@82d525bf0c6a9ff5bdb6c5c5caee6b25971ac766 # v4.1.16
|
||||
uses: CatChen/eslint-suggestion-action@4dda35decf912ab18ea3e071acec2c6c2eda00b6 # v4.1.18
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -97,6 +97,10 @@
|
||||
- [ItsAllAboutTheCode](https://github.com/ItsAllAboutTheCode)
|
||||
- [Jxiced](https://github.com/Jxiced)
|
||||
- [Derek Huber](https://github.com/Derek4aty1)
|
||||
- [StableCrimson](https://github.com/StableCrimson)
|
||||
- [diegoeche](https://github.com/diegoeche)
|
||||
- [Free O'Toole](https://github.com/freeotoole)
|
||||
- [TheBosZ](https://github.com/thebosz)
|
||||
|
||||
## Emby Contributors
|
||||
|
||||
|
||||
@@ -81,28 +81,29 @@ Jellyfin Web is the frontend used for most of the clients available for end user
|
||||
.
|
||||
└── src
|
||||
├── apps
|
||||
│ ├── dashboard # Admin dashboard app
|
||||
│ ├── experimental # New experimental app
|
||||
│ ├── stable # Classic (stable) app
|
||||
│ └── wizard # Startup wizard app
|
||||
├── assets # Static assets
|
||||
├── components # Higher order visual components and React components
|
||||
├── constants # Common constant values
|
||||
├── controllers # Legacy page views and controllers 🧹 ❌
|
||||
├── elements # Basic webcomponents and React equivalents 🧹
|
||||
├── hooks # Custom React hooks
|
||||
├── lib # Reusable libraries
|
||||
│ ├── globalize # Custom localization library
|
||||
│ ├── legacy # Polyfills for legacy browsers
|
||||
│ ├── navdrawer # Navigation drawer library for classic layout
|
||||
│ └── scroller # Content scrolling library
|
||||
├── plugins # Client plugins (features dynamically loaded at runtime)
|
||||
├── scripts # Random assortment of visual components and utilities 🐉 ❌
|
||||
├── strings # Translation files (only commit changes to en-us.json)
|
||||
├── styles # Common app Sass stylesheets
|
||||
├── themes # Sass and MUI themes
|
||||
├── types # Common TypeScript interfaces/types
|
||||
└── utils # Utility functions
|
||||
│ ├── dashboard # Admin dashboard app
|
||||
│ ├── experimental # New experimental app
|
||||
│ ├── stable # Classic (stable) app
|
||||
│ └── wizard # Startup wizard app
|
||||
├── assets # Static assets
|
||||
├── components # Higher order visual components and React components
|
||||
├── constants # Common constant values
|
||||
├── controllers # Legacy page views and controllers 🧹 ❌
|
||||
├── elements # Basic webcomponents and React equivalents 🧹
|
||||
├── hooks # Custom React hooks
|
||||
├── lib # Reusable libraries
|
||||
│ ├── globalize # Custom localization library
|
||||
│ ├── jellyfin-apiclient # Supporting code for the deprecated apiclient package
|
||||
│ ├── legacy # Polyfills for legacy browsers
|
||||
│ ├── navdrawer # Navigation drawer library for classic layout
|
||||
│ └── scroller # Content scrolling library
|
||||
├── plugins # Client plugins (features dynamically loaded at runtime)
|
||||
├── scripts # Random assortment of visual components and utilities 🐉 ❌
|
||||
├── strings # Translation files (only commit changes to en-us.json)
|
||||
├── styles # Common app Sass stylesheets
|
||||
├── themes # Sass and MUI themes
|
||||
├── types # Common TypeScript interfaces/types
|
||||
└── utils # Utility functions
|
||||
```
|
||||
|
||||
- ❌ — Deprecated, do **not** create new files here
|
||||
|
||||
@@ -149,6 +149,11 @@ export default tseslint.config(
|
||||
message: 'Use direct file imports for tree-shaking',
|
||||
allowTypeImports: true
|
||||
},
|
||||
{
|
||||
name: '@mui/icons-material',
|
||||
message: 'Use direct file imports for tree-shaking',
|
||||
allowTypeImports: true
|
||||
},
|
||||
{
|
||||
name: '@mui/material',
|
||||
message: 'Use direct file imports for tree-shaking',
|
||||
|
||||
Generated
+3909
-5882
File diff suppressed because it is too large
Load Diff
+49
-47
@@ -5,26 +5,26 @@
|
||||
"repository": "https://github.com/jellyfin/jellyfin-web",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.26.10",
|
||||
"@babel/plugin-transform-modules-umd": "7.25.9",
|
||||
"@babel/preset-env": "7.26.9",
|
||||
"@babel/preset-react": "7.26.3",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.4.1",
|
||||
"@eslint/js": "9.24.0",
|
||||
"@stylistic/eslint-plugin": "4.2.0",
|
||||
"@stylistic/stylelint-plugin": "3.1.2",
|
||||
"@babel/core": "7.27.4",
|
||||
"@babel/plugin-transform-modules-umd": "7.27.1",
|
||||
"@babel/preset-env": "7.27.2",
|
||||
"@babel/preset-react": "7.27.1",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.5.0",
|
||||
"@eslint/js": "9.30.1",
|
||||
"@stylistic/eslint-plugin": "4.4.1",
|
||||
"@stylistic/stylelint-plugin": "3.1.3",
|
||||
"@types/dompurify": "3.0.5",
|
||||
"@types/escape-html": "1.0.4",
|
||||
"@types/loadable__component": "5.13.9",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/markdown-it": "14.1.2",
|
||||
"@types/react": "18.3.20",
|
||||
"@types/react-dom": "18.3.6",
|
||||
"@types/react": "18.3.23",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@types/react-lazy-load-image-component": "1.6.4",
|
||||
"@types/sortablejs": "1.15.8",
|
||||
"@typescript-eslint/parser": "8.29.0",
|
||||
"@typescript-eslint/parser": "8.35.1",
|
||||
"@uupaa/dynamic-import-polyfill": "1.0.2",
|
||||
"@vitest/coverage-v8": "3.1.1",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"autoprefixer": "10.4.21",
|
||||
"babel-loader": "10.0.0",
|
||||
"clean-webpack-plugin": "4.0.0",
|
||||
@@ -32,80 +32,81 @@
|
||||
"copy-webpack-plugin": "13.0.0",
|
||||
"cross-env": "7.0.3",
|
||||
"css-loader": "7.1.2",
|
||||
"cssnano": "7.0.6",
|
||||
"es-check": "8.0.2",
|
||||
"eslint": "9.24.0",
|
||||
"cssnano": "7.0.7",
|
||||
"es-check": "9.1.4",
|
||||
"eslint": "9.30.1",
|
||||
"eslint-plugin-compat": "6.0.2",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-sonarjs": "3.0.2",
|
||||
"eslint-plugin-sonarjs": "3.0.4",
|
||||
"expose-loader": "5.0.1",
|
||||
"fast-glob": "3.3.3",
|
||||
"fork-ts-checker-webpack-plugin": "9.1.0",
|
||||
"globals": "16.0.0",
|
||||
"globals": "16.2.0",
|
||||
"html-loader": "5.1.0",
|
||||
"html-webpack-plugin": "5.6.3",
|
||||
"jsdom": "26.0.0",
|
||||
"jsdom": "26.1.0",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"postcss": "8.5.3",
|
||||
"postcss": "8.5.6",
|
||||
"postcss-loader": "8.1.1",
|
||||
"postcss-preset-env": "10.1.5",
|
||||
"postcss-preset-env": "10.2.3",
|
||||
"postcss-scss": "4.0.9",
|
||||
"sass": "1.86.3",
|
||||
"sass": "1.89.2",
|
||||
"sass-loader": "16.0.5",
|
||||
"source-map-loader": "5.0.0",
|
||||
"speed-measure-webpack-plugin": "1.5.0",
|
||||
"style-loader": "4.0.0",
|
||||
"stylelint": "16.18.0",
|
||||
"stylelint": "16.21.0",
|
||||
"stylelint-config-rational-order": "0.1.2",
|
||||
"stylelint-no-browser-hacks": "1.3.0",
|
||||
"stylelint-order": "6.0.4",
|
||||
"stylelint-scss": "6.11.1",
|
||||
"stylelint-no-browser-hacks": "2.0.0",
|
||||
"stylelint-order": "7.0.0",
|
||||
"stylelint-scss": "6.12.1",
|
||||
"ts-loader": "9.5.2",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "8.29.0",
|
||||
"vitest": "3.1.1",
|
||||
"webpack": "5.98.0",
|
||||
"typescript-eslint": "8.35.1",
|
||||
"vitest": "3.2.4",
|
||||
"webpack": "5.99.9",
|
||||
"webpack-bundle-analyzer": "4.10.2",
|
||||
"webpack-cli": "6.0.1",
|
||||
"webpack-dev-server": "5.2.1",
|
||||
"webpack-dev-server": "5.2.2",
|
||||
"webpack-merge": "6.0.1",
|
||||
"worker-loader": "3.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.14.0",
|
||||
"@emotion/styled": "11.14.0",
|
||||
"@fontsource/noto-sans": "5.2.6",
|
||||
"@fontsource/noto-sans-hk": "5.2.5",
|
||||
"@fontsource/noto-sans-jp": "5.2.5",
|
||||
"@fontsource/noto-sans-kr": "5.2.5",
|
||||
"@fontsource/noto-sans-sc": "5.2.5",
|
||||
"@fontsource/noto-sans-tc": "5.2.5",
|
||||
"@fontsource/noto-sans": "5.2.7",
|
||||
"@fontsource/noto-sans-hk": "5.2.6",
|
||||
"@fontsource/noto-sans-jp": "5.2.6",
|
||||
"@fontsource/noto-sans-kr": "5.2.6",
|
||||
"@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.202504130507",
|
||||
"@jellyfin/sdk": "0.0.0-unstable.202507090504",
|
||||
"@jellyfin/ux-web": "1.0.0",
|
||||
"@mui/icons-material": "6.4.10",
|
||||
"@mui/material": "6.4.10",
|
||||
"@mui/x-date-pickers": "7.28.3",
|
||||
"@mui/icons-material": "6.4.12",
|
||||
"@mui/material": "6.4.12",
|
||||
"@mui/x-date-pickers": "7.29.4",
|
||||
"@react-hook/resize-observer": "2.0.2",
|
||||
"@tanstack/react-query": "5.71.10",
|
||||
"@tanstack/react-query-devtools": "5.71.10",
|
||||
"@tanstack/react-query": "5.80.10",
|
||||
"@tanstack/react-query-devtools": "5.80.10",
|
||||
"abortcontroller-polyfill": "1.7.8",
|
||||
"blurhash": "2.0.5",
|
||||
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
|
||||
"classnames": "2.5.1",
|
||||
"core-js": "3.41.0",
|
||||
"core-js": "3.43.0",
|
||||
"date-fns": "2.30.0",
|
||||
"dompurify": "2.5.8",
|
||||
"element-closest-polyfill": "1.0.7",
|
||||
"epubjs": "0.3.93",
|
||||
"escape-html": "1.0.3",
|
||||
"fast-text-encoding": "1.0.6",
|
||||
"flv.js": "1.6.2",
|
||||
"headroom.js": "0.12.0",
|
||||
"history": "5.3.0",
|
||||
"hls.js": "1.6.1",
|
||||
"hls.js": "1.6.5",
|
||||
"intersection-observer": "0.12.2",
|
||||
"jellyfin-apiclient": "1.11.0",
|
||||
"jquery": "3.7.1",
|
||||
@@ -118,21 +119,22 @@
|
||||
"material-react-table": "3.2.1",
|
||||
"native-promise-only": "0.8.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"proxy-polyfill": "0.3.2",
|
||||
"react": "18.3.1",
|
||||
"react-blurhash": "0.3.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-lazy-load-image-component": "1.6.3",
|
||||
"react-router-dom": "6.30.0",
|
||||
"react-router-dom": "6.30.1",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"screenfull": "6.0.2",
|
||||
"sortablejs": "1.15.6",
|
||||
"swiper": "11.2.6",
|
||||
"swiper": "11.2.8",
|
||||
"usehooks-ts": "3.1.1",
|
||||
"webcomponents.js": "0.7.24",
|
||||
"whatwg-fetch": "3.6.20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sass-embedded": "1.86.3"
|
||||
"sass-embedded": "1.89.2"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 Firefox versions",
|
||||
|
||||
Vendored
+2
-2
@@ -1,4 +1,3 @@
|
||||
// TODO: Move to jellyfin-apiclient
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
declare module 'jellyfin-apiclient' {
|
||||
import type {
|
||||
@@ -68,7 +67,7 @@ declare module 'jellyfin-apiclient' {
|
||||
UtcTimeResponse,
|
||||
VirtualFolderInfo
|
||||
} from '@jellyfin/sdk/lib/generated-client';
|
||||
import { ConnectionState } from './utils/jellyfin-apiclient/ConnectionState';
|
||||
import type { ConnectionState } from 'lib/jellyfin-apiclient';
|
||||
|
||||
class ApiClient {
|
||||
constructor(serverAddress: string, appName: string, appVersion: string, deviceName: string, deviceId: string);
|
||||
@@ -337,6 +336,7 @@ declare module 'jellyfin-apiclient' {
|
||||
handleMessageReceived(msg: any): void;
|
||||
logout(): Promise<void>;
|
||||
minServerVersion(val?: string): string;
|
||||
updateSavedServerId(server: any): Promise<void>;
|
||||
user(apiClient: ApiClient): Promise<any>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardHeader from '@mui/material/CardHeader';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { Link, To } from 'react-router-dom';
|
||||
|
||||
interface IProps {
|
||||
title?: string;
|
||||
secondaryTitle?: string;
|
||||
text?: string;
|
||||
image?: string | null;
|
||||
icon?: React.ReactNode;
|
||||
to?: To;
|
||||
onClick?: () => void;
|
||||
action?: boolean;
|
||||
actionRef?: React.MutableRefObject<HTMLButtonElement | null>;
|
||||
onActionClick?: () => void;
|
||||
};
|
||||
|
||||
const BaseCard = ({ title, secondaryTitle, text, image, icon, to, onClick, action, actionRef, onActionClick }: IProps) => {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 240
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
{...(to && {
|
||||
component: Link,
|
||||
to: to
|
||||
})}
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
alignItems: 'stretch'
|
||||
}}
|
||||
>
|
||||
{image ? (
|
||||
<CardMedia
|
||||
sx={{ flexGrow: 1 }}
|
||||
image={image}
|
||||
title={title}
|
||||
/>
|
||||
) : (
|
||||
<Box className={getDefaultBackgroundClass(title)} sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
</CardActionArea>
|
||||
<CardHeader
|
||||
title={
|
||||
<Stack direction='row' gap={1} alignItems='center'>
|
||||
<Typography sx={{
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{title}
|
||||
</Typography>
|
||||
{secondaryTitle && (
|
||||
<Typography variant='body2' color='text.secondary'>{secondaryTitle}</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
subheader={text}
|
||||
action={
|
||||
action ? (
|
||||
<IconButton ref={actionRef} onClick={onActionClick}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseCard;
|
||||
@@ -1,4 +1,5 @@
|
||||
import Article from '@mui/icons-material/Article';
|
||||
import Backup from '@mui/icons-material/Backup';
|
||||
import Lan from '@mui/icons-material/Lan';
|
||||
import Schedule from '@mui/icons-material/Schedule';
|
||||
import VpnKey from '@mui/icons-material/VpnKey';
|
||||
@@ -38,6 +39,14 @@ const AdvancedDrawerSection = () => {
|
||||
<ListItemText primary={globalize.translate('HeaderApiKeys')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/backups'>
|
||||
<ListItemIcon>
|
||||
<Backup />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('HeaderBackups')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/logs'>
|
||||
<ListItemIcon>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Devices, Analytics } from '@mui/icons-material';
|
||||
import Analytics from '@mui/icons-material/Analytics';
|
||||
import Devices from '@mui/icons-material/Devices';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Dvr, LiveTv } from '@mui/icons-material';
|
||||
import Dvr from '@mui/icons-material/Dvr';
|
||||
import LiveTv from '@mui/icons-material/LiveTv';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
@@ -28,7 +29,7 @@ const LiveTvDrawerSection = () => {
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/recordings'>
|
||||
<ListItemLink to='/dashboard/livetv/recordings'>
|
||||
<ListItemIcon>
|
||||
<Dvr />
|
||||
</ListItemIcon>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Dashboard, ExpandLess, ExpandMore, LibraryAdd, People, PlayCircle, Settings } from '@mui/icons-material';
|
||||
import Dashboard from '@mui/icons-material/Dashboard';
|
||||
import ExpandLess from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
import LibraryAdd from '@mui/icons-material/LibraryAdd';
|
||||
import Palette from '@mui/icons-material/Palette';
|
||||
import People from '@mui/icons-material/People';
|
||||
import PlayCircle from '@mui/icons-material/PlayCircle';
|
||||
import Settings from '@mui/icons-material/Settings';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
|
||||
@@ -53,7 +53,7 @@ const TablePage = <T extends MRT_RowData>({
|
||||
marginBottom: 1
|
||||
}}
|
||||
>
|
||||
<Typography variant='h2'>
|
||||
<Typography variant='h1'>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ItemCounts } from '@jellyfin/sdk/lib/generated-client/models/item-counts';
|
||||
import Book from '@mui/icons-material/Book';
|
||||
import Movie from '@mui/icons-material/Movie';
|
||||
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 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';
|
||||
|
||||
interface MetricDefinition {
|
||||
key: keyof ItemCounts
|
||||
i18n: string
|
||||
}
|
||||
|
||||
interface CardDefinition {
|
||||
Icon: typeof SvgIcon
|
||||
metrics: MetricDefinition[]
|
||||
}
|
||||
|
||||
const CARD_DEFINITIONS: CardDefinition[] = [
|
||||
{
|
||||
Icon: Movie,
|
||||
metrics: [{ key: 'MovieCount', i18n: 'Movies' }]
|
||||
}, {
|
||||
Icon: Tv,
|
||||
metrics: [
|
||||
{ key: 'SeriesCount', i18n: 'Series' },
|
||||
{ key: 'EpisodeCount', i18n: 'Episodes' }
|
||||
]
|
||||
}, {
|
||||
Icon: MusicNote,
|
||||
metrics: [
|
||||
{ key: 'AlbumCount', i18n: 'Albums' },
|
||||
{ key: 'SongCount', i18n: 'Songs' }
|
||||
]
|
||||
}, {
|
||||
Icon: MusicVideo,
|
||||
metrics: [{ key: 'MusicVideoCount', i18n: 'MusicVideos' }]
|
||||
}, {
|
||||
Icon: Book,
|
||||
metrics: [{ key: 'BookCount', i18n: 'Books' }]
|
||||
}, {
|
||||
Icon: VideoLibrary,
|
||||
metrics: [{ key: 'BoxSetCount', i18n: 'Collections' }]
|
||||
}
|
||||
];
|
||||
|
||||
const ItemCountsWidget = () => {
|
||||
const {
|
||||
data: counts,
|
||||
isPending
|
||||
} = useItemCounts();
|
||||
|
||||
const cards: MetricCardProps[] = useMemo(() => {
|
||||
return CARD_DEFINITIONS
|
||||
.filter(def => (
|
||||
// Include all cards while the request is pending
|
||||
isPending
|
||||
// Check if the metrics are present in counts
|
||||
|| def.metrics.some(({ key }) => counts?.[key])
|
||||
))
|
||||
.map(({ Icon, metrics }) => ({
|
||||
Icon,
|
||||
metrics: metrics.map(({ i18n, key }) => ({
|
||||
label: globalize.translate(i18n),
|
||||
value: counts?.[key]
|
||||
}))
|
||||
}));
|
||||
}, [ 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemCountsWidget;
|
||||
@@ -0,0 +1,67 @@
|
||||
import ChevronRight from '@mui/icons-material/ChevronRight';
|
||||
import Button from '@mui/material/Button';
|
||||
import List from '@mui/material/List';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React from 'react';
|
||||
|
||||
import { useSystemStorage } from 'apps/dashboard/features/storage/api/useSystemStorage';
|
||||
import StorageListItem from 'apps/dashboard/features/storage/components/StorageListItem';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
const ServerPathWidget = () => {
|
||||
const { data: systemStorage } = useSystemStorage();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant='text'
|
||||
color='inherit'
|
||||
endIcon={<ChevronRight />}
|
||||
sx={{
|
||||
marginTop: 1,
|
||||
marginBottom: 1
|
||||
}}
|
||||
// NOTE: We should use a react-router Link component, but components rendered in legacy views lack the
|
||||
// routing context
|
||||
href='#/dashboard/settings'
|
||||
>
|
||||
<Typography variant='h3' component='span'>
|
||||
{globalize.translate('HeaderPaths')}
|
||||
</Typography>
|
||||
</Button>
|
||||
|
||||
<List sx={{ bgcolor: 'background.paper' }}>
|
||||
<StorageListItem
|
||||
label={globalize.translate('LabelCache')}
|
||||
folder={systemStorage?.CacheFolder}
|
||||
/>
|
||||
<StorageListItem
|
||||
label={globalize.translate('LabelImageCache')}
|
||||
folder={systemStorage?.ImageCacheFolder}
|
||||
/>
|
||||
<StorageListItem
|
||||
label={globalize.translate('LabelProgramData')}
|
||||
folder={systemStorage?.ProgramDataFolder}
|
||||
/>
|
||||
<StorageListItem
|
||||
label={globalize.translate('LabelLogs')}
|
||||
folder={systemStorage?.LogFolder}
|
||||
/>
|
||||
<StorageListItem
|
||||
label={globalize.translate('LabelMetadata')}
|
||||
folder={systemStorage?.InternalMetadataFolder}
|
||||
/>
|
||||
<StorageListItem
|
||||
label={globalize.translate('LabelTranscodes')}
|
||||
folder={systemStorage?.TranscodingTempFolder}
|
||||
/>
|
||||
<StorageListItem
|
||||
label={globalize.translate('LabelWeb')}
|
||||
folder={systemStorage?.WebFolder}
|
||||
/>
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerPathWidget;
|
||||
@@ -20,13 +20,13 @@
|
||||
</div>
|
||||
|
||||
<div class="dashboardActionsContainer">
|
||||
<button is="emby-button" type="button" class="raised btnRefresh">
|
||||
<button is="emby-button" type="button" class="raised btnRefresh button-submit">
|
||||
<span>${ButtonScanAllLibraries}</span>
|
||||
</button>
|
||||
<button is="emby-button" type="button" id="btnRestartServer" class="raised">
|
||||
<button is="emby-button" type="button" id="btnRestartServer" class="raised button-delete">
|
||||
<span>${Restart}</span>
|
||||
</button>
|
||||
<button is="emby-button" type="button" id="btnShutdown" class="raised">
|
||||
<button is="emby-button" type="button" id="btnShutdown" class="raised button-delete">
|
||||
<span>${ButtonShutdown}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -36,6 +36,8 @@
|
||||
<div id="divRunningTasks" class="paperList" style="padding: 1em;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="itemCounts"></div>
|
||||
</div>
|
||||
|
||||
<div class="dashboardSection">
|
||||
@@ -75,44 +77,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboardSection">
|
||||
<a is="emby-linkbutton" href="#/dashboard/settings" class="button-flat sectionTitleTextButton">
|
||||
<h3>${HeaderPaths}</h3>
|
||||
<span class="material-icons chevron_right" aria-hidden="true"></span>
|
||||
</a>
|
||||
<div class="paperList">
|
||||
<div class="listItem listItem-border">
|
||||
<div class="listItemBody two-line">
|
||||
<div class="listItemBodyText secondary" style="margin:0;">${LabelCache}</div>
|
||||
<div class="listItemBodyText" id="cachePath" dir="ltr" style="text-align: left;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="listItem listItem-border">
|
||||
<div class="listItemBody two-line">
|
||||
<div class="listItemBodyText secondary" style="margin:0;">${LabelLogs}</div>
|
||||
<div class="listItemBodyText" id="logPath" dir="ltr" style="text-align: left;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="listItem listItem-border">
|
||||
<div class="listItemBody two-line">
|
||||
<div class="listItemBodyText secondary" style="margin:0;">${LabelMetadata}</div>
|
||||
<div class="listItemBodyText" id="metadataPath" dir="ltr" style="text-align: left;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="listItem listItem-border">
|
||||
<div class="listItemBody two-line">
|
||||
<div class="listItemBodyText secondary" style="margin:0;">${LabelTranscodes}</div>
|
||||
<div class="listItemBodyText" id="transcodePath" dir="ltr" style="text-align: left;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="listItem listItem-border">
|
||||
<div class="listItemBody two-line">
|
||||
<div class="listItemBodyText secondary" style="margin:0;">${LabelWeb}</div>
|
||||
<div class="listItemBodyText" id="webPath" dir="ltr" style="text-align: left;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="serverPaths" class="dashboardSection"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import escapeHtml from 'escape-html';
|
||||
|
||||
import datetime from 'scripts/datetime';
|
||||
import Events from 'utils/events.ts';
|
||||
import ServerPathWidget from 'apps/dashboard/components/widgets/ServerPathWidget';
|
||||
import ActivityLog from 'components/activitylog';
|
||||
import alert from 'components/alert';
|
||||
import cardBuilder from 'components/cardbuilder/cardBuilder';
|
||||
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import imageLoader from 'components/images/imageLoader';
|
||||
import indicators from 'components/indicators/indicators';
|
||||
import itemHelper from 'components/itemHelper';
|
||||
import serverNotifications from 'scripts/serverNotifications';
|
||||
import dom from 'scripts/dom';
|
||||
import globalize from 'lib/globalize';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { getLocaleWithSuffix } from 'utils/dateFnsLocale.ts';
|
||||
import loading from 'components/loading/loading';
|
||||
import playMethodHelper from 'components/playback/playmethodhelper';
|
||||
import cardBuilder from 'components/cardbuilder/cardBuilder';
|
||||
import imageLoader from 'components/images/imageLoader';
|
||||
import ActivityLog from 'components/activitylog';
|
||||
import imageHelper from 'utils/image';
|
||||
import indicators from 'components/indicators/indicators';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { getSystemInfoQuery } from 'hooks/useSystemInfo';
|
||||
import globalize from 'lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import datetime from 'scripts/datetime';
|
||||
import dom from 'scripts/dom';
|
||||
import serverNotifications from 'scripts/serverNotifications';
|
||||
import taskButton from 'scripts/taskbutton';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import alert from 'components/alert';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
|
||||
|
||||
import { getSystemInfoQuery } from 'hooks/useSystemInfo';
|
||||
import { getLocaleWithSuffix } from 'utils/dateFnsLocale.ts';
|
||||
import Events from 'utils/events.ts';
|
||||
import imageHelper from 'utils/image';
|
||||
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { renderComponent } from 'utils/reactUtils';
|
||||
|
||||
import 'elements/emby-button/emby-button';
|
||||
import 'elements/emby-itemscontainer/emby-itemscontainer';
|
||||
@@ -32,6 +33,7 @@ import 'elements/emby-itemscontainer/emby-itemscontainer';
|
||||
import 'components/listview/listview.scss';
|
||||
import 'styles/flexstyles.scss';
|
||||
import './dashboard.scss';
|
||||
import ItemCountsWidget from '../components/widgets/ItemCountsWidget';
|
||||
|
||||
function showPlaybackInfo(btn, session) {
|
||||
let title;
|
||||
@@ -220,12 +222,6 @@ function reloadSystemInfo(view, apiClient) {
|
||||
.then(systemInfo => {
|
||||
view.querySelector('#serverName').innerText = systemInfo.ServerName;
|
||||
view.querySelector('#versionNumber').innerText = systemInfo.Version;
|
||||
|
||||
view.querySelector('#cachePath').innerText = systemInfo.CachePath;
|
||||
view.querySelector('#logPath').innerText = systemInfo.LogPath;
|
||||
view.querySelector('#transcodePath').innerText = systemInfo.TranscodingTempPath;
|
||||
view.querySelector('#metadataPath').innerText = systemInfo.InternalMetadataPath;
|
||||
view.querySelector('#webPath').innerText = systemInfo.WebPath;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -752,6 +748,9 @@ const DashboardPage = {
|
||||
};
|
||||
|
||||
export default function (view) {
|
||||
const serverId = ApiClient.serverId();
|
||||
let unmountWidgetFns = [];
|
||||
|
||||
function onRestartRequired(evt, apiClient) {
|
||||
console.debug('onRestartRequired not implemented', evt, apiClient);
|
||||
}
|
||||
@@ -783,7 +782,6 @@ export default function (view) {
|
||||
}
|
||||
}
|
||||
|
||||
const serverId = ApiClient.serverId();
|
||||
view.querySelector('.activeDevices').addEventListener('click', onActiveDevicesClick);
|
||||
view.addEventListener('viewshow', function () {
|
||||
const page = this;
|
||||
@@ -827,6 +825,9 @@ export default function (view) {
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
|
||||
unmountWidgetFns.push(renderComponent(ItemCountsWidget, {}, page.querySelector('#itemCounts')));
|
||||
unmountWidgetFns.push(renderComponent(ServerPathWidget, {}, page.querySelector('#serverPaths')));
|
||||
|
||||
page.querySelector('#btnRestartServer').addEventListener('click', DashboardPage.restart);
|
||||
page.querySelector('#btnShutdown').addEventListener('click', DashboardPage.shutdown);
|
||||
});
|
||||
@@ -853,6 +854,11 @@ export default function (view) {
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
|
||||
unmountWidgetFns.forEach(unmount => {
|
||||
unmount();
|
||||
});
|
||||
unmountWidgetFns = [];
|
||||
|
||||
page.querySelector('#btnRestartServer').removeEventListener('click', DashboardPage.restart);
|
||||
page.querySelector('#btnShutdown').removeEventListener('click', DashboardPage.shutdown);
|
||||
});
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
<div id="encodingSettingsPage" data-role="page" class="page type-interior playbackConfigurationPage" data-title="${TitlePlayback}">
|
||||
<div>
|
||||
<div class="content-primary">
|
||||
<form class="encodingSettingsForm">
|
||||
<div class="verticalSection">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h2 class="sectionTitle">${Transcoding}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selectContainer">
|
||||
<select is="emby-select" id="selectVideoDecoder" label="${LabelHardwareAccelerationType}">
|
||||
<option value="none">${None}</option>
|
||||
<option value="amf">AMD AMF</option>
|
||||
<option value="nvenc">Nvidia NVENC</option>
|
||||
<option value="qsv">Intel QuickSync (QSV)</option>
|
||||
<option value="vaapi">Video Acceleration API (VAAPI)</option>
|
||||
<option value="rkmpp">Rockchip MPP (RKMPP)</option>
|
||||
<option value="videotoolbox">Apple VideoToolBox</option>
|
||||
<option value="v4l2m2m">Video4Linux2 (V4L2)</option>
|
||||
</select>
|
||||
<div class="fieldDescription">
|
||||
<a is="emby-linkbutton" rel="noopener noreferrer" class="button-link" href="https://jellyfin.org/docs/general/administration/hardware-acceleration" target="_blank">${LabelHardwareAccelerationTypeHelp}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer hide fldVaapiDevice">
|
||||
<input is="emby-input" type="text" id="txtVaapiDevice" label="${LabelVaapiDevice}" />
|
||||
<div class="fieldDescription">${LabelVaapiDeviceHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer hide fldQsvDevice">
|
||||
<input is="emby-input" type="text" id="txtQsvDevice" label="${LabelQsvDevice}" />
|
||||
<div class="fieldDescription">${LabelQsvDeviceHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="hardwareAccelerationOptions hide">
|
||||
<div class="checkboxListContainer decodingCodecsList">
|
||||
<h3 class="checkboxListLabel">${LabelEnableHardwareDecodingFor}</h3>
|
||||
<div class="checkboxList">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" class="chkDecodeCodec" data-codec="h264" data-types="amf,nvenc,qsv,vaapi,rkmpp,videotoolbox,v4l2m2m" />
|
||||
<span>H264</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" class="chkDecodeCodec" data-codec="hevc" data-types="amf,nvenc,qsv,vaapi,rkmpp,videotoolbox" />
|
||||
<span>HEVC</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" class="chkDecodeCodec" data-codec="mpeg1video" data-types="rkmpp" />
|
||||
<span>MPEG1</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" class="chkDecodeCodec" data-codec="mpeg2video" data-types="amf,nvenc,qsv,vaapi,rkmpp" />
|
||||
<span>MPEG2</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" class="chkDecodeCodec" data-codec="mpeg4" data-types="nvenc,rkmpp" />
|
||||
<span>MPEG4</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" class="chkDecodeCodec" data-codec="vc1" data-types="amf,nvenc,qsv,vaapi" />
|
||||
<span>VC1</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" class="chkDecodeCodec" data-codec="vp8" data-types="nvenc,qsv,vaapi,rkmpp,videotoolbox" />
|
||||
<span>VP8</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" class="chkDecodeCodec" data-codec="vp9" data-types="amf,nvenc,qsv,vaapi,rkmpp,videotoolbox" />
|
||||
<span>VP9</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" class="chkDecodeCodec" data-codec="av1" data-types="amf,nvenc,qsv,vaapi,rkmpp,videotoolbox" />
|
||||
<span>AV1</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkboxList hide fld10bitHevcVp9HwDecoding">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkDecodingColorDepth10Hevc" />
|
||||
<span>HEVC 10bit</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkDecodingColorDepth10Vp9" />
|
||||
<span>VP9 10bit</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkboxList hide fldHevcRextHwDecoding">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkDecodingColorDepth10HevcRext" />
|
||||
<span>HEVC RExt 8/10bit</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkDecodingColorDepth12HevcRext" />
|
||||
<span>HEVC RExt 12bit</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxListContainer hide fldEnhancedNvdec">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkEnhancedNvdecDecoder" />
|
||||
<span>${EnableEnhancedNvdecDecoder}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${EnableEnhancedNvdecDecoderHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxListContainer hide fldSysNativeHwDecoder">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkSystemNativeHwDecoder" />
|
||||
<span>${PreferSystemNativeHwDecoder}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="checkboxListContainer">
|
||||
<h3 class="checkboxListLabel">${LabelHardwareEncodingOptions}</h3>
|
||||
<div class="checkboxList">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkHardwareEncoding" />
|
||||
<span>${EnableHardwareEncoding}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkboxList hide fldIntelLp">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkIntelLpH264HwEncoder" />
|
||||
<span>${EnableIntelLowPowerH264HwEncoder}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkIntelLpHevcHwEncoder" />
|
||||
<span>${EnableIntelLowPowerHevcHwEncoder}</span>
|
||||
</label>
|
||||
<div class="fieldDescription">
|
||||
<a is="emby-linkbutton" rel="noopener noreferrer" class="button-link" href="https://jellyfin.org/docs/general/administration/hardware-acceleration/intel#configure-and-verify-lp-mode-on-linux" target="_blank">${IntelLowPowerEncHelp}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxListContainer">
|
||||
<h3 class="checkboxListLabel">${LabelEncodingFormatOptions}</h3>
|
||||
<div class="fieldDescription">${EncodingFormatHelp}</div>
|
||||
<div class="checkboxList">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkAllowHevcEncoding" />
|
||||
<span>${AllowHevcEncoding}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkboxList">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkAllowAv1Encoding" />
|
||||
<span>${AllowAv1Encoding}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vppTonemappingOptions hide">
|
||||
<div class="checkboxListContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkVppTonemapping" />
|
||||
<span>${EnableVppTonemapping}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${AllowVppTonemappingHelp}</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="number" id="txtVppTonemappingBrightness" pattern="[0-9]*" min="0" max="100" step=".00001" label="${LabelVppTonemappingBrightness}" />
|
||||
<div class="fieldDescription">${LabelVppTonemappingBrightnessHelp}</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="number" id="txtVppTonemappingContrast" pattern="[0-9]*" min="1" max="2" step=".00001" label="${LabelVppTonemappingContrast}" />
|
||||
<div class="fieldDescription">${LabelVppTonemappingContrastHelp}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="videoToolboxTonemappingOptions hide">
|
||||
<div class="checkboxListContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkVideoToolboxTonemapping" />
|
||||
<span>${EnableVideoToolboxTonemapping}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${AllowVideoToolboxTonemappingHelp}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tonemappingOptions hide">
|
||||
<div class="checkboxListContainer checkboxContainer-withDescription fldTonemapCheckbox hide">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkTonemapping" />
|
||||
<span>${EnableTonemapping}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription allowTonemappingHardwareHelp">${AllowTonemappingHelp}</div>
|
||||
<div class="fieldDescription checkboxFieldDescription allowTonemappingSoftwareHelp">${AllowTonemappingSoftwareHelp}</div>
|
||||
</div>
|
||||
<div class="selectContainer">
|
||||
<select is="emby-select" id="selectTonemappingAlgorithm" label="${LabelTonemappingAlgorithm}">
|
||||
<option value="none">${None}</option>
|
||||
<option value="clip">Clip</option>
|
||||
<option value="linear">Linear</option>
|
||||
<option value="gamma">Gamma</option>
|
||||
<option value="reinhard">Reinhard</option>
|
||||
<option value="hable">Hable</option>
|
||||
<option value="mobius">Mobius</option>
|
||||
<option value="bt2390">BT.2390</option>
|
||||
</select>
|
||||
<div class="fieldDescription">
|
||||
<a is="emby-linkbutton" rel="noopener noreferrer" class="button-link" href="http://ffmpeg.org/ffmpeg-all.html#tonemap_005fopencl" target="_blank">${TonemappingAlgorithmHelp}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tonemappingModeOptions selectContainer">
|
||||
<select is="emby-select" id="selectTonemappingMode" label="${LabelTonemappingMode}">
|
||||
<option value="auto">${Auto}</option>
|
||||
<option value="max">MAX</option>
|
||||
<option value="rgb">RGB</option>
|
||||
<option value="lum">LUM</option>
|
||||
<option value="itp">ITP</option>
|
||||
</select>
|
||||
<div class="fieldDescription">${TonemappingModeHelp}</div>
|
||||
</div>
|
||||
<div class="selectContainer">
|
||||
<select is="emby-select" id="selectTonemappingRange" label="${LabelTonemappingRange}">
|
||||
<option value="auto">${Auto}</option>
|
||||
<option value="tv">TV</option>
|
||||
<option value="pc">PC</option>
|
||||
</select>
|
||||
<div class="fieldDescription">${TonemappingRangeHelp}</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="number" id="txtTonemappingDesat" pattern="[0-9]*" min="0" max="1.79769e+308" step=".00001" label="${LabelTonemappingDesat}" />
|
||||
<div class="fieldDescription">${LabelTonemappingDesatHelp}</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="number" id="txtTonemappingPeak" pattern="[0-9]*" min="0" max="1.79769e+308" step=".00001" label="${LabelTonemappingPeak}" />
|
||||
<div class="fieldDescription">${LabelTonemappingPeakHelp}</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="number" id="txtTonemappingParam" pattern="[0-9]*" min="2.22507e-308" max="1.79769e+308" step=".00001" label="${LabelTonemappingParam}" />
|
||||
<div class="fieldDescription">${LabelTonemappingParamHelp}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selectContainer">
|
||||
<select is="emby-select" id="selectThreadCount" label="${LabelTranscodingThreadCount}">
|
||||
<option value="-1">${Auto}</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6">6</option>
|
||||
<option value="7">7</option>
|
||||
<option value="8">8</option>
|
||||
<option value="9">9</option>
|
||||
<option value="10">10</option>
|
||||
<option value="11">11</option>
|
||||
<option value="12">12</option>
|
||||
<option value="13">13</option>
|
||||
<option value="14">14</option>
|
||||
<option value="15">15</option>
|
||||
<option value="16">16</option>
|
||||
<option value="0">${OptionMax}</option>
|
||||
</select>
|
||||
<div class="fieldDescription">${LabelTranscodingThreadCountHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer fldEncoderPath">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<div style="flex-grow:1;">
|
||||
<input is="emby-input" class="txtEncoderPath" label="${LabelffmpegPath}" autocomplete="off" dir="ltr" disabled/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fieldDescription">
|
||||
<div>${LabelffmpegPathHelp}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<div style="flex-grow:1;">
|
||||
<input is="emby-input" id="txtTranscodingTempPath" label="${LabelTranscodePath}" autocomplete="off" dir="ltr" />
|
||||
</div>
|
||||
<button type="button" is="paper-icon-button-light" id="btnSelectTranscodingTempPath" class="emby-input-iconbutton"><span class="material-icons search" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
<div class="fieldDescription">${LabelTranscodingTempPathHelp}</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<div style="flex-grow:1;">
|
||||
<input is="emby-input" id="txtFallbackFontPath" label="${LabelFallbackFontPath}" autocomplete="off" dir="ltr" />
|
||||
</div>
|
||||
<button type="button" is="paper-icon-button-light" id="btnSelectFallbackFontPath" class="emby-input-iconbutton"><span class="material-icons search" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
<div class="fieldDescription">
|
||||
<a is="emby-linkbutton" rel="noopener noreferrer" class="button-link" href="https://jellyfin.org/docs/general/administration/configuration#fonts" target="_blank">${LabelFallbackFontPathHelp}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input is="emby-checkbox" type="checkbox" id="chkEnableFallbackFont" />
|
||||
<span>${EnableFallbackFont}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${EnableFallbackFontHelp}</div>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input is="emby-checkbox" type="checkbox" id="chkEnableAudioVbr" />
|
||||
<span>${LabelEnableAudioVbr}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${LabelEnableAudioVbrHelp}</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="number" id="txtDownMixAudioBoost" pattern="[0-9]*" required="required" min=".5" max="3" step=".1" label="${LabelDownMixAudioScale}" />
|
||||
<div class="fieldDescription">${LabelDownMixAudioScaleHelp}</div>
|
||||
</div>
|
||||
<div class="selectContainer">
|
||||
<select is="emby-select" id="selectStereoDownmixAlgorithm" label="${LabelStereoDownmixAlgorithm}">
|
||||
<option value="None">${None}</option>
|
||||
<option value="Dave750">Dave750</option>
|
||||
<option value="NightmodeDialogue">NightmodeDialogue</option>
|
||||
<option value="Rfc7845">RFC7845</option>
|
||||
<option value="Ac4">AC-4</option>
|
||||
</select>
|
||||
<div class="fieldDescription">${StereoDownmixAlgorithmHelp}</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="number" id="txtMaxMuxingQueueSize" pattern="[0-9]*" required="required" min="128" max="2147483647" step="1" label="${LabelMaxMuxingQueueSize}" />
|
||||
<div class="fieldDescription">${LabelMaxMuxingQueueSizeHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="selectContainer">
|
||||
<select is="emby-select" id="selectEncoderPreset" label="${LabelEncoderPreset}">
|
||||
<option value="auto">${Auto}</option>
|
||||
<option value="veryslow">veryslow</option>
|
||||
<option value="slower">slower</option>
|
||||
<option value="slow">slow</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="fast">fast</option>
|
||||
<option value="faster">faster</option>
|
||||
<option value="veryfast">veryfast</option>
|
||||
<option value="superfast">superfast</option>
|
||||
<option value="ultrafast">ultrafast</option>
|
||||
</select>
|
||||
<div class="fieldDescription">${EncoderPresetHelp}</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="number" id="txtH265Crf" pattern="[0-9]*" min="0" max="51" step="1" label="${LabelH265Crf}" />
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="number" id="txtH264Crf" pattern="[0-9]*" min="0" max="51" step="1" label="${LabelH264Crf}" />
|
||||
<div class="fieldDescription">${H264CrfHelp}</div>
|
||||
</div>
|
||||
<div class="selectContainer">
|
||||
<select is="emby-select" id="selectDeinterlaceMethod" label="${LabelDeinterlaceMethod}">
|
||||
<option value="yadif">${Yadif}</option>
|
||||
<option value="bwdif">${Bwdif}</option>
|
||||
</select>
|
||||
<div class="fieldDescription">${DeinterlaceMethodHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input is="emby-checkbox" type="checkbox" id="chkDoubleRateDeinterlacing" />
|
||||
<span>${UseDoubleRateDeinterlacing}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${UseDoubleRateDeinterlacingHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input is="emby-checkbox" type="checkbox" id="chkEnableSubtitleExtraction" />
|
||||
<span>${AllowOnTheFlySubtitleExtraction}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${AllowOnTheFlySubtitleExtractionHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input is="emby-checkbox" type="checkbox" id="chkEnableThrottling" />
|
||||
<span>${AllowFfmpegThrottling}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${AllowFfmpegThrottlingHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input is="emby-checkbox" type="checkbox" id="chkEnableSegmentDeletion" />
|
||||
<span>${AllowSegmentDeletion}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${AllowSegmentDeletionHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="number" id="txtThrottleDelaySeconds" pattern="[0-9]*" min="10" max="3600" step="1" label="${LabelThrottleDelaySeconds}" />
|
||||
<div class="fieldDescription">${LabelThrottleDelaySecondsHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="number" id="txtSegmentKeepSeconds" pattern="[0-9]*" min="15" max="3600" step="1" label="${LabelSegmentKeepSeconds}" />
|
||||
<div class="fieldDescription">${LabelSegmentKeepSecondsHelp}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block">
|
||||
<span>${Save}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,309 +0,0 @@
|
||||
import 'jquery';
|
||||
import loading from 'components/loading/loading';
|
||||
import globalize from 'lib/globalize';
|
||||
import dom from 'scripts/dom';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import alert from 'components/alert';
|
||||
|
||||
function loadPage(page, config, systemInfo) {
|
||||
Array.prototype.forEach.call(page.querySelectorAll('.chkDecodeCodec'), function (c) {
|
||||
c.checked = (config.HardwareDecodingCodecs || []).indexOf(c.getAttribute('data-codec')) !== -1;
|
||||
});
|
||||
page.querySelector('#chkDecodingColorDepth10Hevc').checked = config.EnableDecodingColorDepth10Hevc;
|
||||
page.querySelector('#chkDecodingColorDepth10Vp9').checked = config.EnableDecodingColorDepth10Vp9;
|
||||
page.querySelector('#chkDecodingColorDepth10HevcRext').checked = config.EnableDecodingColorDepth10HevcRext;
|
||||
page.querySelector('#chkDecodingColorDepth12HevcRext').checked = config.EnableDecodingColorDepth12HevcRext;
|
||||
page.querySelector('#chkEnhancedNvdecDecoder').checked = config.EnableEnhancedNvdecDecoder;
|
||||
page.querySelector('#chkSystemNativeHwDecoder').checked = config.PreferSystemNativeHwDecoder;
|
||||
page.querySelector('#chkIntelLpH264HwEncoder').checked = config.EnableIntelLowPowerH264HwEncoder;
|
||||
page.querySelector('#chkIntelLpHevcHwEncoder').checked = config.EnableIntelLowPowerHevcHwEncoder;
|
||||
page.querySelector('#chkHardwareEncoding').checked = config.EnableHardwareEncoding;
|
||||
page.querySelector('#chkAllowHevcEncoding').checked = config.AllowHevcEncoding;
|
||||
page.querySelector('#chkAllowAv1Encoding').checked = config.AllowAv1Encoding;
|
||||
page.querySelector('#selectVideoDecoder').value = config.HardwareAccelerationType || 'none';
|
||||
page.querySelector('#selectThreadCount').value = config.EncodingThreadCount;
|
||||
page.querySelector('#chkEnableAudioVbr').checked = config.EnableAudioVbr;
|
||||
page.querySelector('#txtDownMixAudioBoost').value = config.DownMixAudioBoost;
|
||||
page.querySelector('#selectStereoDownmixAlgorithm').value = config.DownMixStereoAlgorithm || 'None';
|
||||
page.querySelector('#txtMaxMuxingQueueSize').value = config.MaxMuxingQueueSize || '';
|
||||
page.querySelector('.txtEncoderPath').value = config.EncoderAppPathDisplay || '';
|
||||
page.querySelector('#txtTranscodingTempPath').value = systemInfo.TranscodingTempPath || '';
|
||||
page.querySelector('#txtFallbackFontPath').value = config.FallbackFontPath || '';
|
||||
page.querySelector('#chkEnableFallbackFont').checked = config.EnableFallbackFont;
|
||||
page.querySelector('#txtVaapiDevice').value = config.VaapiDevice || '';
|
||||
page.querySelector('#txtQsvDevice').value = config.QsvDevice || '';
|
||||
page.querySelector('#chkTonemapping').checked = config.EnableTonemapping;
|
||||
page.querySelector('#chkVppTonemapping').checked = config.EnableVppTonemapping;
|
||||
page.querySelector('#chkVideoToolboxTonemapping').checked = config.EnableVideoToolboxTonemapping;
|
||||
page.querySelector('#selectTonemappingAlgorithm').value = config.TonemappingAlgorithm || 'none';
|
||||
page.querySelector('#selectTonemappingMode').value = config.TonemappingMode || 'auto';
|
||||
page.querySelector('#selectTonemappingRange').value = config.TonemappingRange || 'auto';
|
||||
page.querySelector('#txtTonemappingDesat').value = config.TonemappingDesat;
|
||||
page.querySelector('#txtTonemappingPeak').value = config.TonemappingPeak;
|
||||
page.querySelector('#txtTonemappingParam').value = config.TonemappingParam || '';
|
||||
page.querySelector('#txtVppTonemappingBrightness').value = config.VppTonemappingBrightness;
|
||||
page.querySelector('#txtVppTonemappingContrast').value = config.VppTonemappingContrast;
|
||||
page.querySelector('#selectEncoderPreset').value = config.EncoderPreset || 'auto';
|
||||
page.querySelector('#txtH264Crf').value = config.H264Crf || '';
|
||||
page.querySelector('#txtH265Crf').value = config.H265Crf || '';
|
||||
page.querySelector('#selectDeinterlaceMethod').value = config.DeinterlaceMethod || 'yadif';
|
||||
page.querySelector('#chkDoubleRateDeinterlacing').checked = config.DeinterlaceDoubleRate;
|
||||
page.querySelector('#chkEnableSubtitleExtraction').checked = config.EnableSubtitleExtraction || false;
|
||||
page.querySelector('#chkEnableThrottling').checked = config.EnableThrottling || false;
|
||||
page.querySelector('#chkEnableSegmentDeletion').checked = config.EnableSegmentDeletion || false;
|
||||
page.querySelector('#txtThrottleDelaySeconds').value = config.ThrottleDelaySeconds || '';
|
||||
page.querySelector('#txtSegmentKeepSeconds').value = config.SegmentKeepSeconds || '';
|
||||
page.querySelector('#selectVideoDecoder').dispatchEvent(new CustomEvent('change', {
|
||||
bubbles: true
|
||||
}));
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
function onSaveEncodingPathFailure() {
|
||||
loading.hide();
|
||||
alert(globalize.translate('FFmpegSavePathNotFound'));
|
||||
}
|
||||
|
||||
function updateEncoder(form) {
|
||||
return ApiClient.getSystemInfo().then(function () {
|
||||
return ApiClient.ajax({
|
||||
url: ApiClient.getUrl('System/MediaEncoder/Path'),
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
Path: form.querySelector('.txtEncoderPath').value,
|
||||
PathType: 'Custom'
|
||||
}),
|
||||
contentType: 'application/json'
|
||||
}).then(Dashboard.processServerConfigurationUpdateResult, onSaveEncodingPathFailure);
|
||||
});
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
const form = this;
|
||||
|
||||
const onDecoderConfirmed = function () {
|
||||
loading.show();
|
||||
ApiClient.getNamedConfiguration('encoding').then(function (config) {
|
||||
config.EnableAudioVbr = form.querySelector('#chkEnableAudioVbr').checked;
|
||||
config.DownMixAudioBoost = form.querySelector('#txtDownMixAudioBoost').value;
|
||||
config.DownMixStereoAlgorithm = form.querySelector('#selectStereoDownmixAlgorithm').value || 'None';
|
||||
config.MaxMuxingQueueSize = form.querySelector('#txtMaxMuxingQueueSize').value;
|
||||
config.TranscodingTempPath = form.querySelector('#txtTranscodingTempPath').value;
|
||||
config.FallbackFontPath = form.querySelector('#txtFallbackFontPath').value;
|
||||
config.EnableFallbackFont = form.querySelector('#txtFallbackFontPath').value ? form.querySelector('#chkEnableFallbackFont').checked : false;
|
||||
config.EncodingThreadCount = form.querySelector('#selectThreadCount').value;
|
||||
config.HardwareAccelerationType = form.querySelector('#selectVideoDecoder').value;
|
||||
config.VaapiDevice = form.querySelector('#txtVaapiDevice').value;
|
||||
config.QsvDevice = form.querySelector('#txtQsvDevice').value;
|
||||
config.EnableTonemapping = form.querySelector('#chkTonemapping').checked;
|
||||
config.EnableVppTonemapping = form.querySelector('#chkVppTonemapping').checked;
|
||||
config.EnableVideoToolboxTonemapping = form.querySelector('#chkVideoToolboxTonemapping').checked;
|
||||
config.TonemappingAlgorithm = form.querySelector('#selectTonemappingAlgorithm').value;
|
||||
config.TonemappingMode = form.querySelector('#selectTonemappingMode').value;
|
||||
config.TonemappingRange = form.querySelector('#selectTonemappingRange').value;
|
||||
config.TonemappingDesat = form.querySelector('#txtTonemappingDesat').value;
|
||||
config.TonemappingPeak = form.querySelector('#txtTonemappingPeak').value;
|
||||
config.TonemappingParam = form.querySelector('#txtTonemappingParam').value || '0';
|
||||
config.VppTonemappingBrightness = form.querySelector('#txtVppTonemappingBrightness').value;
|
||||
config.VppTonemappingContrast = form.querySelector('#txtVppTonemappingContrast').value;
|
||||
config.EncoderPreset = form.querySelector('#selectEncoderPreset').value;
|
||||
config.H264Crf = parseInt(form.querySelector('#txtH264Crf').value || '0', 10);
|
||||
config.H265Crf = parseInt(form.querySelector('#txtH265Crf').value || '0', 10);
|
||||
config.DeinterlaceMethod = form.querySelector('#selectDeinterlaceMethod').value;
|
||||
config.DeinterlaceDoubleRate = form.querySelector('#chkDoubleRateDeinterlacing').checked;
|
||||
config.EnableSubtitleExtraction = form.querySelector('#chkEnableSubtitleExtraction').checked;
|
||||
config.EnableThrottling = form.querySelector('#chkEnableThrottling').checked;
|
||||
config.EnableSegmentDeletion = form.querySelector('#chkEnableSegmentDeletion').checked;
|
||||
config.ThrottleDelaySeconds = parseInt(form.querySelector('#txtThrottleDelaySeconds').value || '0', 10);
|
||||
config.SegmentKeepSeconds = parseInt(form.querySelector('#txtSegmentKeepSeconds').value || '0', 10);
|
||||
config.HardwareDecodingCodecs = Array.prototype.map.call(Array.prototype.filter.call(form.querySelectorAll('.chkDecodeCodec'), function (c) {
|
||||
return c.checked;
|
||||
}), function (c) {
|
||||
return c.getAttribute('data-codec');
|
||||
});
|
||||
config.EnableDecodingColorDepth10Hevc = form.querySelector('#chkDecodingColorDepth10Hevc').checked;
|
||||
config.EnableDecodingColorDepth10Vp9 = form.querySelector('#chkDecodingColorDepth10Vp9').checked;
|
||||
config.EnableDecodingColorDepth10HevcRext = form.querySelector('#chkDecodingColorDepth10HevcRext').checked;
|
||||
config.EnableDecodingColorDepth12HevcRext = form.querySelector('#chkDecodingColorDepth12HevcRext').checked;
|
||||
config.EnableEnhancedNvdecDecoder = form.querySelector('#chkEnhancedNvdecDecoder').checked;
|
||||
config.PreferSystemNativeHwDecoder = form.querySelector('#chkSystemNativeHwDecoder').checked;
|
||||
config.EnableIntelLowPowerH264HwEncoder = form.querySelector('#chkIntelLpH264HwEncoder').checked;
|
||||
config.EnableIntelLowPowerHevcHwEncoder = form.querySelector('#chkIntelLpHevcHwEncoder').checked;
|
||||
config.EnableHardwareEncoding = form.querySelector('#chkHardwareEncoding').checked;
|
||||
config.AllowHevcEncoding = form.querySelector('#chkAllowHevcEncoding').checked;
|
||||
config.AllowAv1Encoding = form.querySelector('#chkAllowAv1Encoding').checked;
|
||||
ApiClient.updateNamedConfiguration('encoding', config).then(function () {
|
||||
updateEncoder(form);
|
||||
}, function () {
|
||||
alert(globalize.translate('ErrorDefault'));
|
||||
Dashboard.processServerConfigurationUpdateResult();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (form.querySelector('#selectVideoDecoder').value !== 'none') {
|
||||
alert({
|
||||
title: globalize.translate('TitleHardwareAcceleration'),
|
||||
text: globalize.translate('HardwareAccelerationWarning')
|
||||
}).then(onDecoderConfirmed);
|
||||
} else {
|
||||
onDecoderConfirmed();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function setDecodingCodecsVisible(context, value) {
|
||||
value = value || '';
|
||||
let any;
|
||||
Array.prototype.forEach.call(context.querySelectorAll('.chkDecodeCodec'), function (c) {
|
||||
if (c.getAttribute('data-types').split(',').indexOf(value) === -1) {
|
||||
dom.parentWithTag(c, 'LABEL').classList.add('hide');
|
||||
} else {
|
||||
dom.parentWithTag(c, 'LABEL').classList.remove('hide');
|
||||
any = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (any) {
|
||||
context.querySelector('.decodingCodecsList').classList.remove('hide');
|
||||
} else {
|
||||
context.querySelector('.decodingCodecsList').classList.add('hide');
|
||||
}
|
||||
}
|
||||
|
||||
let systemInfo;
|
||||
function getSystemInfo() {
|
||||
return systemInfo ? Promise.resolve(systemInfo) : ApiClient.getPublicSystemInfo().then(
|
||||
info => {
|
||||
systemInfo = info;
|
||||
return info;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
$(document).on('pageinit', '#encodingSettingsPage', function () {
|
||||
const page = this;
|
||||
getSystemInfo();
|
||||
page.querySelector('#selectVideoDecoder').addEventListener('change', function () {
|
||||
if (this.value == 'vaapi') {
|
||||
page.querySelector('.fldVaapiDevice').classList.remove('hide');
|
||||
page.querySelector('#txtVaapiDevice').setAttribute('required', 'required');
|
||||
} else {
|
||||
page.querySelector('.fldVaapiDevice').classList.add('hide');
|
||||
page.querySelector('#txtVaapiDevice').removeAttribute('required');
|
||||
}
|
||||
|
||||
if (this.value == 'amf' || this.value == 'nvenc' || this.value == 'qsv' || this.value == 'vaapi' || this.value == 'rkmpp') {
|
||||
page.querySelector('.fld10bitHevcVp9HwDecoding').classList.remove('hide');
|
||||
} else {
|
||||
page.querySelector('.fld10bitHevcVp9HwDecoding').classList.add('hide');
|
||||
}
|
||||
|
||||
if (this.value == 'nvenc' || this.value == 'qsv' || this.value == 'vaapi') {
|
||||
page.querySelector('.fldHevcRextHwDecoding').classList.remove('hide');
|
||||
} else {
|
||||
page.querySelector('.fldHevcRextHwDecoding').classList.add('hide');
|
||||
}
|
||||
|
||||
const isHwaSelected = [ 'amf', 'nvenc', 'qsv', 'vaapi', 'rkmpp', 'videotoolbox' ].includes(this.value);
|
||||
if (this.value === 'none') {
|
||||
page.querySelector('.tonemappingOptions').classList.remove('hide');
|
||||
page.querySelector('.fldTonemapCheckbox').classList.add('hide');
|
||||
} else if (isHwaSelected) {
|
||||
page.querySelector('.tonemappingOptions').classList.remove('hide');
|
||||
page.querySelector('.fldTonemapCheckbox').classList.remove('hide');
|
||||
} else {
|
||||
page.querySelector('.tonemappingOptions').classList.add('hide');
|
||||
page.querySelector('.fldTonemapCheckbox').classList.add('hide');
|
||||
}
|
||||
|
||||
page.querySelector('.tonemappingModeOptions').classList.toggle('hide', !isHwaSelected);
|
||||
page.querySelector('.allowTonemappingHardwareHelp').classList.toggle('hide', !isHwaSelected);
|
||||
page.querySelector('.allowTonemappingSoftwareHelp').classList.toggle('hide', isHwaSelected);
|
||||
|
||||
if (this.value == 'qsv' || this.value == 'vaapi') {
|
||||
page.querySelector('.fldIntelLp').classList.remove('hide');
|
||||
} else {
|
||||
page.querySelector('.fldIntelLp').classList.add('hide');
|
||||
}
|
||||
|
||||
if (this.value === 'videotoolbox') {
|
||||
page.querySelector('.videoToolboxTonemappingOptions').classList.remove('hide');
|
||||
} else {
|
||||
page.querySelector('.videoToolboxTonemappingOptions').classList.add('hide');
|
||||
}
|
||||
|
||||
if (this.value == 'qsv' || this.value == 'vaapi') {
|
||||
page.querySelector('.vppTonemappingOptions').classList.remove('hide');
|
||||
} else {
|
||||
page.querySelector('.vppTonemappingOptions').classList.add('hide');
|
||||
}
|
||||
|
||||
if (this.value == 'qsv') {
|
||||
page.querySelector('.fldSysNativeHwDecoder').classList.remove('hide');
|
||||
page.querySelector('.fldQsvDevice').classList.remove('hide');
|
||||
} else {
|
||||
page.querySelector('.fldSysNativeHwDecoder').classList.add('hide');
|
||||
page.querySelector('.fldQsvDevice').classList.add('hide');
|
||||
}
|
||||
|
||||
if (this.value == 'nvenc') {
|
||||
page.querySelector('.fldEnhancedNvdec').classList.remove('hide');
|
||||
} else {
|
||||
page.querySelector('.fldEnhancedNvdec').classList.add('hide');
|
||||
}
|
||||
|
||||
if (this.value !== 'none') {
|
||||
page.querySelector('.hardwareAccelerationOptions').classList.remove('hide');
|
||||
} else {
|
||||
page.querySelector('.hardwareAccelerationOptions').classList.add('hide');
|
||||
}
|
||||
|
||||
setDecodingCodecsVisible(page, this.value);
|
||||
});
|
||||
$('#btnSelectTranscodingTempPath', page).on('click.selectDirectory', function () {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
callback: function (path) {
|
||||
if (path) {
|
||||
page.querySelector('#txtTranscodingTempPath').value = path;
|
||||
}
|
||||
|
||||
picker.close();
|
||||
},
|
||||
validateWriteable: true,
|
||||
header: globalize.translate('HeaderSelectTranscodingPath'),
|
||||
instruction: globalize.translate('HeaderSelectTranscodingPathHelp')
|
||||
});
|
||||
});
|
||||
});
|
||||
$('#btnSelectFallbackFontPath', page).on('click.selectDirectory', function () {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
includeDirectories: true,
|
||||
callback: function (path) {
|
||||
if (path) {
|
||||
page.querySelector('#txtFallbackFontPath').value = path;
|
||||
}
|
||||
|
||||
picker.close();
|
||||
},
|
||||
header: globalize.translate('HeaderSelectFallbackFontPath'),
|
||||
instruction: globalize.translate('HeaderSelectFallbackFontPathHelp')
|
||||
});
|
||||
});
|
||||
});
|
||||
$('.encodingSettingsForm').off('submit', onSubmit).on('submit', onSubmit);
|
||||
}).on('pageshow', '#encodingSettingsPage', function () {
|
||||
loading.show();
|
||||
const page = this;
|
||||
ApiClient.getNamedConfiguration('encoding').then(function (config) {
|
||||
ApiClient.getSystemInfo().then(function (fetchedSystemInfo) {
|
||||
loadPage(page, config, fetchedSystemInfo);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
<div id="liveTvSettingsPage" data-role="page" class="page type-interior liveTvPage" data-title="${HeaderDVR}">
|
||||
<div>
|
||||
<div class="content-primary">
|
||||
<div class="verticalSection">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h2 class="sectionTitle">${HeaderDVR}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="liveTvSettingsForm">
|
||||
<div class="selectContainer">
|
||||
<select is="emby-select" id="selectGuideDays" label="${LabelNumberOfGuideDays}">
|
||||
<option value="">${Auto}</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6">6</option>
|
||||
<option value="7">7</option>
|
||||
<option value="8">8</option>
|
||||
<option value="9">9</option>
|
||||
<option value="10">10</option>
|
||||
<option value="11">11</option>
|
||||
<option value="12">12</option>
|
||||
<option value="13">13</option>
|
||||
<option value="14">14</option>
|
||||
</select>
|
||||
<div class="fieldDescription">${LabelNumberOfGuideDaysHelp}</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<div style="flex-grow:1;">
|
||||
<input is="emby-input" id="txtRecordingPath" label="${LabelRecordingPath}" autocomplete="off" />
|
||||
</div>
|
||||
<button type="button" is="paper-icon-button-light" id="btnSelectRecordingPath" title="${ButtonSelectDirectory}" class="emby-input-iconbutton"><span class="material-icons search" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
<div class="fieldDescription">${LabelRecordingPathHelp}</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<div style="flex-grow:1;">
|
||||
<input is="emby-input" id="txtMovieRecordingPath" label="${LabelMovieRecordingPath}" autocomplete="off" />
|
||||
</div>
|
||||
<button type="button" is="paper-icon-button-light" id="btnSelectMovieRecordingPath" title="${ButtonSelectDirectory}" class="emby-input-iconbutton"><span class="material-icons search" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<div style="flex-grow:1;">
|
||||
<input is="emby-input" id="txtSeriesRecordingPath" label="${LabelSeriesRecordingPath}" autocomplete="off" />
|
||||
</div>
|
||||
<button type="button" is="paper-icon-button-light" id="btnSelectSeriesRecordingPath" title="${ButtonSelectDirectory}" class="emby-input-iconbutton"><span class="material-icons search" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="verticalSection">
|
||||
<h2 class="sectionTitle">${HeaderDefaultRecordingSettings}</h2>
|
||||
<div class="inputContainer">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<div style="flex-grow: 1;">
|
||||
<input is="emby-input" type="number" id="txtPrePaddingMinutes" pattern="[0-9]*" required="required" min="0" step="1" label="${LabelStartWhenPossible}" />
|
||||
</div>
|
||||
<div class="fieldDescription" style="margin-left:.5em;font-size:90%;margin-top:1.3em;">
|
||||
${MinutesBefore}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<div style="flex-grow: 1;">
|
||||
<input is="emby-input" type="number" id="txtPostPaddingMinutes" pattern="[0-9]*" required="required" min="0" step="1" label="${LabelStopWhenPossible}" />
|
||||
</div>
|
||||
<div class="fieldDescription" style="margin-left:.5em;font-size:90%;margin-top:1.3em;">
|
||||
${MinutesAfter}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="verticalSection">
|
||||
<h2 class="sectionTitle">${HeaderRecordingPostProcessing}</h2>
|
||||
<div class="inputContainer">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<div style="flex-grow:1;">
|
||||
<input is="emby-input" type="text" id="txtPostProcessor" label="${LabelPostProcessor}" />
|
||||
</div>
|
||||
<button type="button" is="paper-icon-button-light" id="btnSelectPostProcessorPath" class="emby-input-iconbutton"><span class="material-icons search" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="text" id="txtPostProcessorArguments" label="${LabelPostProcessorArguments}" />
|
||||
<div class="fieldDescription">${LabelPostProcessorArgumentsHelp}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="verticalSection">
|
||||
<h2 class="sectionTitle">${HeaderRecordingMetadataSaving}</h2>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input is="emby-checkbox" type="checkbox" id="chkSaveRecordingNFO" />
|
||||
<span>${SaveRecordingNFO}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${SaveRecordingNFOHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input is="emby-checkbox" type="checkbox" id="chkSaveRecordingImages" />
|
||||
<span>${SaveRecordingImages}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${SaveRecordingImagesHelp}</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block"><span>${Save}</span></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,133 +0,0 @@
|
||||
import 'jquery';
|
||||
|
||||
import loading from 'components/loading/loading';
|
||||
import globalize from 'lib/globalize';
|
||||
import 'elements/emby-button/emby-button';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import alert from 'components/alert';
|
||||
|
||||
function loadPage(page, config) {
|
||||
page.querySelector('.liveTvSettingsForm').classList.remove('hide');
|
||||
page.querySelector('.noLiveTvServices')?.classList.add('hide');
|
||||
page.querySelector('#selectGuideDays').value = config.GuideDays || '';
|
||||
page.querySelector('#txtPrePaddingMinutes').value = config.PrePaddingSeconds / 60;
|
||||
page.querySelector('#txtPostPaddingMinutes').value = config.PostPaddingSeconds / 60;
|
||||
page.querySelector('#txtRecordingPath').value = config.RecordingPath || '';
|
||||
page.querySelector('#txtMovieRecordingPath').value = config.MovieRecordingPath || '';
|
||||
page.querySelector('#txtSeriesRecordingPath').value = config.SeriesRecordingPath || '';
|
||||
page.querySelector('#txtPostProcessor').value = config.RecordingPostProcessor || '';
|
||||
page.querySelector('#txtPostProcessorArguments').value = config.RecordingPostProcessorArguments || '';
|
||||
page.querySelector('#chkSaveRecordingNFO').checked = config.SaveRecordingNFO;
|
||||
page.querySelector('#chkSaveRecordingImages').checked = config.SaveRecordingImages;
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
loading.show();
|
||||
const form = this;
|
||||
ApiClient.getNamedConfiguration('livetv').then(function (config) {
|
||||
config.GuideDays = form.querySelector('#selectGuideDays').value || null;
|
||||
const recordingPath = form.querySelector('#txtRecordingPath').value || null;
|
||||
const movieRecordingPath = form.querySelector('#txtMovieRecordingPath').value || null;
|
||||
const seriesRecordingPath = form.querySelector('#txtSeriesRecordingPath').value || null;
|
||||
const recordingPathChanged = recordingPath != config.RecordingPath || movieRecordingPath != config.MovieRecordingPath || seriesRecordingPath != config.SeriesRecordingPath;
|
||||
config.RecordingPath = recordingPath;
|
||||
config.MovieRecordingPath = movieRecordingPath;
|
||||
config.SeriesRecordingPath = seriesRecordingPath;
|
||||
config.RecordingEncodingFormat = 'mkv';
|
||||
config.PrePaddingSeconds = 60 * form.querySelector('#txtPrePaddingMinutes').value;
|
||||
config.PostPaddingSeconds = 60 * form.querySelector('#txtPostPaddingMinutes').value;
|
||||
config.RecordingPostProcessor = form.querySelector('#txtPostProcessor').value;
|
||||
config.RecordingPostProcessorArguments = form.querySelector('#txtPostProcessorArguments').value;
|
||||
config.SaveRecordingNFO = form.querySelector('#chkSaveRecordingNFO').checked;
|
||||
config.SaveRecordingImages = form.querySelector('#chkSaveRecordingImages').checked;
|
||||
ApiClient.updateNamedConfiguration('livetv', config).then(function () {
|
||||
Dashboard.processServerConfigurationUpdateResult();
|
||||
showSaveMessage(recordingPathChanged);
|
||||
});
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function showSaveMessage(recordingPathChanged) {
|
||||
let msg = '';
|
||||
|
||||
if (recordingPathChanged) {
|
||||
msg += globalize.translate('MessageChangeRecordingPath');
|
||||
}
|
||||
|
||||
if (msg) {
|
||||
alert(msg);
|
||||
}
|
||||
}
|
||||
|
||||
$(document).on('pageinit', '#liveTvSettingsPage', function () {
|
||||
const page = this;
|
||||
$('.liveTvSettingsForm').off('submit', onSubmit).on('submit', onSubmit);
|
||||
$('#btnSelectRecordingPath', page).on('click.selectDirectory', function () {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
callback: function (path) {
|
||||
if (path) {
|
||||
page.querySelector('#txtRecordingPath').value = path;
|
||||
}
|
||||
|
||||
picker.close();
|
||||
},
|
||||
validateWriteable: true
|
||||
});
|
||||
});
|
||||
});
|
||||
$('#btnSelectMovieRecordingPath', page).on('click.selectDirectory', function () {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
callback: function (path) {
|
||||
if (path) {
|
||||
page.querySelector('#txtMovieRecordingPath').value = path;
|
||||
}
|
||||
|
||||
picker.close();
|
||||
},
|
||||
validateWriteable: true
|
||||
});
|
||||
});
|
||||
});
|
||||
$('#btnSelectSeriesRecordingPath', page).on('click.selectDirectory', function () {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
callback: function (path) {
|
||||
if (path) {
|
||||
page.querySelector('#txtSeriesRecordingPath').value = path;
|
||||
}
|
||||
|
||||
picker.close();
|
||||
},
|
||||
validateWriteable: true
|
||||
});
|
||||
});
|
||||
});
|
||||
$('#btnSelectPostProcessorPath', page).on('click.selectDirectory', function () {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
includeFiles: true,
|
||||
callback: function (path) {
|
||||
if (path) {
|
||||
page.querySelector('#txtPostProcessor').value = path;
|
||||
}
|
||||
|
||||
picker.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}).on('pageshow', '#liveTvSettingsPage', function () {
|
||||
loading.show();
|
||||
const page = this;
|
||||
ApiClient.getNamedConfiguration('livetv').then(function (config) {
|
||||
loadPage(page, config);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
<div id="pluginCatalogPage" data-role="page" class="page type-interior pluginConfigurationPage fullWidthContent" data-title="${TabCatalog}">
|
||||
<div>
|
||||
<div class="content-primary">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h2 class="sectionTitle">${TabCatalog}</h2>
|
||||
<a is="emby-linkbutton" class="fab" href="#/dashboard/plugins/repositories" style="margin-left:1em;" title="${Settings}">
|
||||
<span class="material-icons settings" aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<input id="txtSearchPlugins" name="txtSearchPlugins" type="text" is="emby-input" label="${Search}" />
|
||||
</div>
|
||||
<div id="noPlugins" class="hide">${MessageNoAvailablePlugins}</div>
|
||||
<div id="pluginTiles" style="text-align:left;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,163 +0,0 @@
|
||||
import escapeHTML from 'escape-html';
|
||||
|
||||
import { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels';
|
||||
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
|
||||
import loading from 'components/loading/loading';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
import 'components/cardbuilder/card.scss';
|
||||
import 'elements/emby-button/emby-button';
|
||||
import 'elements/emby-checkbox/emby-checkbox';
|
||||
import 'elements/emby-select/emby-select';
|
||||
|
||||
function reloadList(page) {
|
||||
loading.show();
|
||||
const promise1 = ApiClient.getAvailablePlugins();
|
||||
const promise2 = ApiClient.getInstalledPlugins();
|
||||
Promise.all([promise1, promise2]).then(function (responses) {
|
||||
populateList({
|
||||
catalogElement: page.querySelector('#pluginTiles'),
|
||||
noItemsElement: page.querySelector('#noPlugins'),
|
||||
availablePlugins: responses[0],
|
||||
installedPlugins: responses[1]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getHeaderText(category) {
|
||||
const categoryKey = category.replaceAll(' ', '');
|
||||
|
||||
if (CATEGORY_LABELS[categoryKey]) {
|
||||
return globalize.translate(CATEGORY_LABELS[categoryKey]);
|
||||
}
|
||||
|
||||
console.warn('[AvailablePlugins] unmapped category label', category);
|
||||
return category;
|
||||
}
|
||||
|
||||
function populateList(options) {
|
||||
const availablePlugins = options.availablePlugins;
|
||||
const installedPlugins = options.installedPlugins;
|
||||
|
||||
availablePlugins.forEach(function (plugin, index, array) {
|
||||
plugin.category = plugin.category || 'Other';
|
||||
plugin.categoryDisplayName = getHeaderText(plugin.category);
|
||||
array[index] = plugin;
|
||||
});
|
||||
|
||||
availablePlugins.sort(function (a, b) {
|
||||
if (a.category > b.category) {
|
||||
return 1;
|
||||
} else if (b.category > a.category) {
|
||||
return -1;
|
||||
}
|
||||
if (a.name > b.name) {
|
||||
return 1;
|
||||
} else if (b.name > a.name) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
let currentCategory = null;
|
||||
let html = '';
|
||||
|
||||
for (const plugin of availablePlugins) {
|
||||
const category = plugin.categoryDisplayName;
|
||||
if (category != currentCategory) {
|
||||
if (currentCategory) {
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
html += '<div class="verticalSection">';
|
||||
html += '<h2 class="sectionTitle sectionTitle-cards">' + escapeHTML(category) + '</h2>';
|
||||
html += '<div class="itemsContainer vertical-wrap">';
|
||||
currentCategory = category;
|
||||
}
|
||||
html += getPluginHtml(plugin, options, installedPlugins);
|
||||
}
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
|
||||
if (!availablePlugins.length && options.noItemsElement) {
|
||||
options.noItemsElement.classList.remove('hide');
|
||||
}
|
||||
|
||||
const searchBar = document.getElementById('txtSearchPlugins');
|
||||
if (searchBar) {
|
||||
searchBar.addEventListener('input', () => onSearchBarType(searchBar));
|
||||
}
|
||||
|
||||
options.catalogElement.innerHTML = html;
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
function onSearchBarType(searchBar) {
|
||||
const filter = searchBar.value.toLowerCase();
|
||||
for (const header of document.querySelectorAll('div .verticalSection')) {
|
||||
// keep track of shown cards after each search
|
||||
let shown = 0;
|
||||
for (const card of header.querySelectorAll('div .card')) {
|
||||
if (filter && filter != '' && !card.textContent.toLowerCase().includes(filter)) {
|
||||
card.style.display = 'none';
|
||||
} else {
|
||||
card.style.display = 'unset';
|
||||
shown++;
|
||||
}
|
||||
}
|
||||
// hide title if no cards are shown
|
||||
if (shown <= 0) {
|
||||
header.style.display = 'none';
|
||||
} else {
|
||||
header.style.display = 'unset';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPluginHtml(plugin, options, installedPlugins) {
|
||||
let html = '';
|
||||
let href = plugin.externalUrl ? plugin.externalUrl :
|
||||
`#/dashboard/plugins/${plugin.guid}?name=${encodeURIComponent(plugin.name)}`;
|
||||
|
||||
if (options.context) {
|
||||
href += '&context=' + options.context;
|
||||
}
|
||||
|
||||
const target = plugin.externalUrl ? ' target="_blank"' : '';
|
||||
html += "<div class='card backdropCard'>";
|
||||
html += '<div class="cardBox visualCardBox">';
|
||||
html += '<div class="cardScalable visualCardBox-cardScalable">';
|
||||
html += '<div class="cardPadder cardPadder-backdrop"></div>';
|
||||
html += '<div class="cardContent">';
|
||||
html += `<a class="cardImageContainer" is="emby-linkbutton" style="margin:0;padding:0" href="${href}" ${target}>`;
|
||||
|
||||
if (plugin.imageUrl) {
|
||||
html += `<img src="${escapeHTML(plugin.imageUrl)}" style="width:100%" />`;
|
||||
} else {
|
||||
html += `<div class="cardImage flex align-items-center justify-content-center ${getDefaultBackgroundClass()}">`;
|
||||
html += '<span class="cardImageIcon material-icons extension" aria-hidden="true"></span>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</a>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '<div class="cardFooter">';
|
||||
html += "<div class='cardText'>";
|
||||
html += escapeHTML(plugin.name);
|
||||
html += '</div>';
|
||||
const installedPlugin = installedPlugins.find(installed => installed.Id === plugin.guid);
|
||||
html += "<div class='cardText cardText-secondary'>";
|
||||
html += installedPlugin ? globalize.translate('LabelVersionInstalled', installedPlugin.Version) : ' ';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
export default function (view) {
|
||||
view.addEventListener('viewshow', function () {
|
||||
reloadList(this);
|
||||
});
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<div id="pluginsPage" data-role="page" class="page type-interior pluginConfigurationPage fullWidthContent" data-title="${TabPlugins}">
|
||||
<div>
|
||||
<div class="content-primary">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h2 class="sectionTitle">${TabMyPlugins}</h2>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<input id="txtSearchPlugins" name="txtSearchPlugins" type="text" is="emby-input" label="${Search}" />
|
||||
</div>
|
||||
<div class="installedPlugins"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,251 +0,0 @@
|
||||
import loading from 'components/loading/loading';
|
||||
import dom from 'scripts/dom';
|
||||
import globalize from 'lib/globalize';
|
||||
import 'components/cardbuilder/card.scss';
|
||||
import 'elements/emby-button/emby-button';
|
||||
import Dashboard, { pageIdOn } from 'utils/dashboard';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
|
||||
|
||||
function deletePlugin(page, uniqueid, version, name) {
|
||||
const msg = globalize.translate('UninstallPluginConfirmation', name);
|
||||
|
||||
confirm({
|
||||
title: globalize.translate('HeaderUninstallPlugin'),
|
||||
text: msg,
|
||||
primary: 'delete',
|
||||
confirmText: globalize.translate('HeaderUninstallPlugin')
|
||||
}).then(function () {
|
||||
loading.show();
|
||||
ApiClient.uninstallPluginByVersion(uniqueid, version).then(function () {
|
||||
reloadList(page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function enablePlugin(page, uniqueid, version) {
|
||||
loading.show();
|
||||
ApiClient.enablePlugin(uniqueid, version).then(function () {
|
||||
reloadList(page);
|
||||
});
|
||||
}
|
||||
|
||||
function disablePlugin(page, uniqueid, version) {
|
||||
loading.show();
|
||||
ApiClient.disablePlugin(uniqueid, version).then(function () {
|
||||
reloadList(page);
|
||||
});
|
||||
}
|
||||
|
||||
function showNoConfigurationMessage() {
|
||||
Dashboard.alert({
|
||||
message: globalize.translate('MessageNoPluginConfiguration')
|
||||
});
|
||||
}
|
||||
|
||||
function showConnectMessage() {
|
||||
Dashboard.alert({
|
||||
message: globalize.translate('MessagePluginConfigurationRequiresLocalAccess')
|
||||
});
|
||||
}
|
||||
|
||||
function getPluginCardHtml(plugin, pluginConfigurationPages) {
|
||||
const configPage = pluginConfigurationPages.filter(function (pluginConfigurationPage) {
|
||||
return pluginConfigurationPage.PluginId == plugin.Id;
|
||||
})[0];
|
||||
|
||||
const configPageUrl = configPage ? Dashboard.getPluginUrl(configPage.Name) : null;
|
||||
let html = '';
|
||||
|
||||
html += `<div data-id='${plugin.Id}' data-version='${plugin.Version}' data-name='${plugin.Name}' data-removable='${plugin.CanUninstall}' data-status='${plugin.Status}' class='card backdropCard'>`;
|
||||
html += '<div class="cardBox visualCardBox">';
|
||||
html += '<div class="cardScalable">';
|
||||
html += '<div class="cardPadder cardPadder-backdrop"></div>';
|
||||
html += '<div class="cardContent">';
|
||||
if (configPageUrl) {
|
||||
html += `<a class="cardImageContainer" is="emby-linkbutton" style="margin:0;padding:0" href="${configPageUrl}">`;
|
||||
} else {
|
||||
html += '<div class="cardImageContainer noConfigPluginCard noHoverEffect emby-button" style="margin:0;padding:0">';
|
||||
}
|
||||
|
||||
if (plugin.HasImage) {
|
||||
const imageUrl = ApiClient.getUrl(`/Plugins/${plugin.Id}/${plugin.Version}/Image`);
|
||||
html += `<img src="${imageUrl}" style="width:100%" />`;
|
||||
} else {
|
||||
html += `<div class="cardImage flex align-items-center justify-content-center ${getDefaultBackgroundClass()}">`;
|
||||
html += '<span class="cardImageIcon material-icons extension" aria-hidden="true"></span>';
|
||||
html += '</div>';
|
||||
}
|
||||
html += configPageUrl ? '</a>' : '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '<div class="cardFooter">';
|
||||
|
||||
if (configPage || plugin.CanUninstall) {
|
||||
if (globalize.getIsRTL()) {
|
||||
html += '<div style="text-align:left; float:left;padding-top:5px;">';
|
||||
} else {
|
||||
html += '<div style="text-align:right; float:right;padding-top:5px;">';
|
||||
}
|
||||
html += '<button type="button" is="paper-icon-button-light" class="btnCardMenu autoSize"><span class="material-icons more_vert" aria-hidden="true"></span></button>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '<div class="cardText">';
|
||||
html += `${plugin.Name}<span class='cardText cardText-secondary'>${plugin.Version}</span>`;
|
||||
html += '</div>';
|
||||
html += `<div class="cardText">${globalize.translate('LabelStatus')} ${plugin.Status}</div>`;
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderPlugins(page, plugins) {
|
||||
ApiClient.getJSON(ApiClient.getUrl('web/configurationpages') + '?pageType=PluginConfiguration').then(function (configPages) {
|
||||
populateList(page, plugins, configPages);
|
||||
});
|
||||
}
|
||||
|
||||
function populateList(page, plugins, pluginConfigurationPages) {
|
||||
plugins = plugins.sort(function (plugin1, plugin2) {
|
||||
if (plugin1.Name > plugin2.Name) {
|
||||
return 1;
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
|
||||
let html = plugins.map(function (p) {
|
||||
return getPluginCardHtml(p, pluginConfigurationPages);
|
||||
}).join('');
|
||||
|
||||
const installedPluginsElement = page.querySelector('.installedPlugins');
|
||||
installedPluginsElement.removeEventListener('click', onInstalledPluginsClick);
|
||||
installedPluginsElement.addEventListener('click', onInstalledPluginsClick);
|
||||
|
||||
if (plugins.length) {
|
||||
installedPluginsElement.classList.add('itemsContainer');
|
||||
installedPluginsElement.classList.add('vertical-wrap');
|
||||
} else {
|
||||
html += '<div class="centerMessage">';
|
||||
html += '<h1>' + globalize.translate('MessageNoPluginsInstalled') + '</h1>';
|
||||
html += '<p><a is="emby-linkbutton" class="button-link" href="#/dashboard/plugins/catalog">';
|
||||
html += globalize.translate('MessageBrowsePluginCatalog');
|
||||
html += '</a></p>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// add search box listener
|
||||
const searchBar = page.querySelector('#txtSearchPlugins');
|
||||
if (searchBar) {
|
||||
searchBar.addEventListener('input', () => onFilterType(page, searchBar));
|
||||
}
|
||||
|
||||
installedPluginsElement.innerHTML = html;
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
function showPluginMenu(page, elem) {
|
||||
const card = dom.parentWithClass(elem, 'card');
|
||||
const id = card.getAttribute('data-id');
|
||||
const name = card.getAttribute('data-name');
|
||||
const removable = card.getAttribute('data-removable');
|
||||
const configHref = card.querySelector('.cardImageContainer').getAttribute('href');
|
||||
const status = card.getAttribute('data-status');
|
||||
const version = card.getAttribute('data-version');
|
||||
const menuItems = [];
|
||||
|
||||
if (configHref) {
|
||||
menuItems.push({
|
||||
name: globalize.translate('Settings'),
|
||||
id: 'open',
|
||||
icon: 'mode_edit'
|
||||
});
|
||||
}
|
||||
|
||||
if (removable === 'true') {
|
||||
if (status === 'Disabled') {
|
||||
menuItems.push({
|
||||
name: globalize.translate('EnablePlugin'),
|
||||
id: 'enable',
|
||||
icon: 'check_circle_outline'
|
||||
});
|
||||
}
|
||||
|
||||
if (status === 'Active') {
|
||||
menuItems.push({
|
||||
name: globalize.translate('DisablePlugin'),
|
||||
id: 'disable',
|
||||
icon: 'do_not_disturb'
|
||||
});
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
name: globalize.translate('ButtonUninstall'),
|
||||
id: 'delete',
|
||||
icon: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
import('components/actionSheet/actionSheet').then((actionsheet) => {
|
||||
actionsheet.show({
|
||||
items: menuItems,
|
||||
positionTo: elem,
|
||||
callback: function (resultId) {
|
||||
switch (resultId) {
|
||||
case 'open':
|
||||
Dashboard.navigate(configHref);
|
||||
break;
|
||||
case 'delete':
|
||||
deletePlugin(page, id, version, name);
|
||||
break;
|
||||
case 'enable':
|
||||
enablePlugin(page, id, version);
|
||||
break;
|
||||
case 'disable':
|
||||
disablePlugin(page, id, version);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function reloadList(page) {
|
||||
loading.show();
|
||||
ApiClient.getInstalledPlugins().then(function (plugins) {
|
||||
renderPlugins(page, plugins);
|
||||
});
|
||||
}
|
||||
|
||||
function onInstalledPluginsClick(e) {
|
||||
if (dom.parentWithClass(e.target, 'noConfigPluginCard')) {
|
||||
showNoConfigurationMessage();
|
||||
} else if (dom.parentWithClass(e.target, 'connectModePluginCard')) {
|
||||
showConnectMessage();
|
||||
} else {
|
||||
const btnCardMenu = dom.parentWithClass(e.target, 'btnCardMenu');
|
||||
if (btnCardMenu) {
|
||||
showPluginMenu(dom.parentWithClass(btnCardMenu, 'page'), btnCardMenu);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onFilterType(page, searchBar) {
|
||||
const filter = searchBar.value.toLowerCase();
|
||||
for (const card of page.querySelectorAll('.card')) {
|
||||
if (filter && filter != '' && !card.textContent.toLowerCase().includes(filter)) {
|
||||
card.style.display = 'none';
|
||||
} else {
|
||||
card.style.display = 'unset';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pageIdOn('pageshow', 'pluginsPage', function () {
|
||||
reloadList(this);
|
||||
});
|
||||
|
||||
window.PluginsPage = {
|
||||
renderPlugins: renderPlugins
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
<div id="repositories" data-role="page" class="page type-interior fullWidthContent" data-title="${TabRepositories}">
|
||||
<div>
|
||||
<div class="content-primary">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h2 class="sectionTitle">${TabRepositories}</h2>
|
||||
<button is="emby-button" type="button" class="fab btnNewRepository submit" style="margin-left:1em;" title="${Add}">
|
||||
<span class="material-icons add" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="repositories"></div>
|
||||
|
||||
<div id="none" class="noItemsMessage centerMessage hide">
|
||||
<h1>${MessageNoRepositories}</h1>
|
||||
<p>${MessageAddRepository}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,190 +0,0 @@
|
||||
import loading from 'components/loading/loading';
|
||||
import globalize from 'lib/globalize';
|
||||
import dialogHelper from 'components/dialogHelper/dialogHelper';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
|
||||
import 'elements/emby-button/emby-button';
|
||||
import 'elements/emby-checkbox/emby-checkbox';
|
||||
import 'elements/emby-select/emby-select';
|
||||
|
||||
import 'components/formdialog.scss';
|
||||
import 'components/listview/listview.scss';
|
||||
|
||||
let repositories = [];
|
||||
|
||||
function reloadList(page) {
|
||||
loading.show();
|
||||
ApiClient.getJSON(ApiClient.getUrl('Repositories')).then(list => {
|
||||
repositories = list;
|
||||
populateList({
|
||||
listElement: page.querySelector('#repositories'),
|
||||
noneElement: page.querySelector('#none'),
|
||||
repositories: repositories
|
||||
});
|
||||
}).catch(e => {
|
||||
console.error('error loading repositories', e);
|
||||
page.querySelector('#none').classList.remove('hide');
|
||||
loading.hide();
|
||||
});
|
||||
}
|
||||
|
||||
function saveList(page) {
|
||||
loading.show();
|
||||
ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: ApiClient.getUrl('Repositories'),
|
||||
data: JSON.stringify(repositories),
|
||||
contentType: 'application/json'
|
||||
}).then(() => {
|
||||
reloadList(page);
|
||||
}).catch(e => {
|
||||
console.error('error saving repositories', e);
|
||||
loading.hide();
|
||||
});
|
||||
}
|
||||
|
||||
function populateList(options) {
|
||||
const paperList = document.createElement('div');
|
||||
paperList.className = 'paperList';
|
||||
|
||||
options.repositories.forEach(repo => {
|
||||
paperList.appendChild(getRepositoryElement(repo));
|
||||
});
|
||||
|
||||
if (!options.repositories.length) {
|
||||
options.noneElement.classList.remove('hide');
|
||||
} else {
|
||||
options.noneElement.classList.add('hide');
|
||||
}
|
||||
|
||||
options.listElement.innerHTML = '';
|
||||
options.listElement.appendChild(paperList);
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
function getRepositoryElement(repository) {
|
||||
const listItem = document.createElement('div');
|
||||
listItem.className = 'listItem listItem-border';
|
||||
|
||||
const repoLink = document.createElement('a', 'emby-linkbutton');
|
||||
repoLink.classList.add('clearLink', 'listItemIconContainer');
|
||||
repoLink.style.margin = '0';
|
||||
repoLink.style.padding = '0';
|
||||
repoLink.rel = 'noopener noreferrer';
|
||||
repoLink.target = '_blank';
|
||||
repoLink.href = repository.Url;
|
||||
repoLink.innerHTML = '<span class="material-icons listItemIcon open_in_new" aria-hidden="true"></span>';
|
||||
listItem.appendChild(repoLink);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'listItemBody two-line';
|
||||
|
||||
const name = document.createElement('h3');
|
||||
name.className = 'listItemBodyText';
|
||||
name.innerText = repository.Name;
|
||||
body.appendChild(name);
|
||||
|
||||
const url = document.createElement('div');
|
||||
url.className = 'listItemBodyText secondary';
|
||||
url.innerText = repository.Url;
|
||||
body.appendChild(url);
|
||||
|
||||
listItem.appendChild(body);
|
||||
|
||||
const button = document.createElement('button', 'paper-icon-button-light');
|
||||
button.type = 'button';
|
||||
button.classList.add('btnDelete');
|
||||
button.id = repository.Url;
|
||||
button.title = globalize.translate('Delete');
|
||||
button.innerHTML = '<span class="material-icons delete" aria-hidden="true"></span>';
|
||||
listItem.appendChild(button);
|
||||
|
||||
return listItem;
|
||||
}
|
||||
|
||||
export default function(view) {
|
||||
view.addEventListener('viewshow', function () {
|
||||
reloadList(this);
|
||||
|
||||
const save = this;
|
||||
$('#repositories', view).on('click', '.btnDelete', function() {
|
||||
const button = this;
|
||||
repositories = repositories.filter(function (r) {
|
||||
return r.Url !== button.id;
|
||||
});
|
||||
|
||||
saveList(save);
|
||||
});
|
||||
});
|
||||
|
||||
view.querySelector('.btnNewRepository').addEventListener('click', () => {
|
||||
const dialog = dialogHelper.createDialog({
|
||||
scrollY: false,
|
||||
size: 'large',
|
||||
modal: false,
|
||||
removeOnClose: true
|
||||
});
|
||||
|
||||
let html = '';
|
||||
|
||||
html += '<div class="formDialogHeader">';
|
||||
html += `<button type="button" is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1" title="${globalize.translate('ButtonBack')}"><span class="material-icons arrow_back" aria-hidden="true"></span></button>`;
|
||||
html += `<h3 class="formDialogHeaderTitle">${globalize.translate('HeaderNewRepository')}</h3>`;
|
||||
html += '</div>';
|
||||
html += '<form class="newPluginForm" style="margin:4em">';
|
||||
html += '<div class="inputContainer">';
|
||||
html += `<input is="emby-input" id="txtRepositoryName" label="${globalize.translate('LabelRepositoryName')}" type="text" required />`;
|
||||
html += `<div class="fieldDescription">${globalize.translate('LabelRepositoryNameHelp')}</div>`;
|
||||
html += '</div>';
|
||||
html += '<div class="inputContainer">';
|
||||
html += `<input is="emby-input" id="txtRepositoryUrl" label="${globalize.translate('LabelRepositoryUrl')}" type="url" required />`;
|
||||
html += `<div class="fieldDescription">${globalize.translate('LabelRepositoryUrlHelp')}</div>`;
|
||||
html += '</div>';
|
||||
html += `<button is="emby-button" type="submit" class="raised button-submit block"><span>${globalize.translate('Save')}</span></button>`;
|
||||
html += '</div>';
|
||||
html += '</form>';
|
||||
|
||||
dialog.innerHTML = html;
|
||||
dialog.querySelector('.btnCancel').addEventListener('click', () => {
|
||||
dialogHelper.close(dialog);
|
||||
});
|
||||
|
||||
dialog.querySelector('.newPluginForm').addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
|
||||
const repositoryUrl = dialog.querySelector('#txtRepositoryUrl').value.toLowerCase();
|
||||
|
||||
const alertCallback = function () {
|
||||
repositories.push({
|
||||
Name: dialog.querySelector('#txtRepositoryName').value,
|
||||
Url: dialog.querySelector('#txtRepositoryUrl').value,
|
||||
Enabled: true
|
||||
});
|
||||
saveList(view);
|
||||
dialogHelper.close(dialog);
|
||||
};
|
||||
|
||||
// Check the repository URL for the official Jellyfin repository domain, or
|
||||
// present the warning for 3rd party plugins.
|
||||
if (!repositoryUrl.startsWith('https://repo.jellyfin.org/')) {
|
||||
let msg = globalize.translate('MessageRepositoryInstallDisclaimer');
|
||||
msg += '<br/>';
|
||||
msg += '<br/>';
|
||||
msg += globalize.translate('PleaseConfirmRepositoryInstallation');
|
||||
|
||||
confirm(msg, globalize.translate('HeaderConfirmRepositoryInstallation')).then(function () {
|
||||
alertCallback();
|
||||
}).catch(() => {
|
||||
console.debug('repository not installed');
|
||||
dialogHelper.close(dialog);
|
||||
});
|
||||
} else {
|
||||
alertCallback();
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
dialogHelper.open(dialog);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { BackupApi } from '@jellyfin/sdk/lib/generated-client/api/backup-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
export const QUERY_KEY = 'Backups';
|
||||
|
||||
const fetchBackups = async (api: Api, options?: AxiosRequestConfig) => {
|
||||
// FIXME: Replace with getBackupApi when available in SDK
|
||||
const backupApi = new BackupApi(api.configuration, undefined, api.axiosInstance);
|
||||
|
||||
const response = await backupApi.listBackups(options);
|
||||
|
||||
const backups = response.data;
|
||||
|
||||
backups.sort((a, b) => {
|
||||
if (a.DateCreated && b.DateCreated) {
|
||||
return new Date(b.DateCreated).getTime() - new Date(a.DateCreated).getTime();
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return backups;
|
||||
};
|
||||
|
||||
export const useBackups = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [ QUERY_KEY ],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchBackups(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BackupOptionsDto } from '@jellyfin/sdk/lib/generated-client/models/backup-options-dto';
|
||||
import { BackupApi } from '@jellyfin/sdk/lib/generated-client/api/backup-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { QUERY_KEY } from './useBackups';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
export const useCreateBackup = () => {
|
||||
const { api } = useApi();
|
||||
// FIXME: Replace with getBackupApi when available in SDK
|
||||
const backupApi = new BackupApi(api?.configuration, undefined, api?.axiosInstance);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (backupOptions: BackupOptionsDto) => (
|
||||
backupApi.createBackup({
|
||||
backupOptionsDto: backupOptions
|
||||
})
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { BackupApi } from '@jellyfin/sdk/lib/generated-client/api/backup-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
export const useRestoreBackup = () => {
|
||||
const { api } = useApi();
|
||||
// FIXME: Replace with getBackupApi when available in SDK
|
||||
const backupApi = new BackupApi(api?.configuration, undefined, api?.axiosInstance);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (fileName: string) => (
|
||||
backupApi.startRestoreBackup({
|
||||
backupRestoreRequestDto: {
|
||||
ArchiveFileName: fileName
|
||||
}
|
||||
})
|
||||
)
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { FunctionComponent, useCallback, useState } from 'react';
|
||||
import type { BackupManifestDto } from '@jellyfin/sdk/lib/generated-client/models/backup-manifest-dto';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import Restore from '@mui/icons-material/Restore';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import globalize from 'lib/globalize';
|
||||
import BackupInfoDialog from './BackupInfoDialog';
|
||||
|
||||
type BackupProps = {
|
||||
backup: BackupManifestDto;
|
||||
onRestore: (backup: BackupManifestDto) => void;
|
||||
};
|
||||
|
||||
const Backup: FunctionComponent<BackupProps> = ({ backup, onRestore }) => {
|
||||
const [ isInfoDialogOpen, setIsInfoDialogOpen ] = useState(false);
|
||||
|
||||
const onDialogClose = useCallback(() => {
|
||||
setIsInfoDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const openDialog = useCallback(() => {
|
||||
setIsInfoDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const restore = useCallback(() => {
|
||||
onRestore(backup);
|
||||
}, [ backup, onRestore ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BackupInfoDialog
|
||||
backup={backup}
|
||||
onClose={onDialogClose}
|
||||
open={isInfoDialogOpen}
|
||||
/>
|
||||
<ListItem
|
||||
disablePadding
|
||||
secondaryAction={
|
||||
<Tooltip disableInteractive title={globalize.translate('LabelRestore')}>
|
||||
<IconButton onClick={restore}>
|
||||
<Restore />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<ListItemButton onClick={openDialog}>
|
||||
<ListItemText
|
||||
primary={backup.DateCreated}
|
||||
secondary={backup.Path}
|
||||
slotProps={{
|
||||
primary: {
|
||||
variant: 'h3'
|
||||
},
|
||||
secondary: {
|
||||
variant: 'body1'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Backup;
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { BackupManifestDto } from '@jellyfin/sdk/lib/generated-client/models/backup-manifest-dto';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import Box from '@mui/material/Box';
|
||||
import globalize from 'lib/globalize';
|
||||
import React, { FunctionComponent, useCallback } from 'react';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import FormGroup from '@mui/material/FormGroup';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import ContentCopy from '@mui/icons-material/ContentCopy';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { copy } from 'scripts/clipboard';
|
||||
import toast from 'components/toast/toast';
|
||||
|
||||
type IProps = {
|
||||
backup: BackupManifestDto;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const BackupInfoDialog: FunctionComponent<IProps> = ({ backup, open, onClose }: IProps) => {
|
||||
const copyPath = useCallback(async () => {
|
||||
if (backup.Path) {
|
||||
await copy(backup.Path);
|
||||
toast({ text: globalize.translate('Copied') });
|
||||
}
|
||||
}, [ backup.Path ]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
maxWidth={'sm'}
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
{backup.DateCreated}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Stack gap={2}>
|
||||
<Box>
|
||||
<Stack
|
||||
direction='row'
|
||||
gap={2}
|
||||
>
|
||||
<Typography fontWeight='bold'>{globalize.translate('LabelPath')}</Typography>
|
||||
<Stack direction='row'>
|
||||
<Typography color='text.secondary'>{backup.Path}</Typography>
|
||||
<IconButton size='small' onClick={copyPath}>
|
||||
<ContentCopy fontSize='small' />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction='row'
|
||||
gap={2}
|
||||
>
|
||||
<Typography fontWeight='bold'>{globalize.translate('LabelVersion')}</Typography>
|
||||
<Typography color='text.secondary'>{backup.ServerVersion}</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<FormGroup>
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name='Database'
|
||||
defaultChecked={true}
|
||||
disabled
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelDatabase')}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name='Metadata'
|
||||
defaultChecked={backup.Options?.Metadata}
|
||||
disabled
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelMetadata')}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name='Subtitles'
|
||||
defaultChecked={backup.Options?.Subtitles}
|
||||
disabled
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('Subtitles')}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name='Trickplay'
|
||||
defaultChecked={backup.Options?.Trickplay}
|
||||
disabled
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('Trickplay')}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>
|
||||
{globalize.translate('ButtonOk')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackupInfoDialog;
|
||||
@@ -0,0 +1,27 @@
|
||||
import React, { FunctionComponent } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
type IProps = {
|
||||
open: boolean
|
||||
};
|
||||
|
||||
const BackupProgressDialog: FunctionComponent<IProps> = ({ open }) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
maxWidth={'xs'}
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>{globalize.translate('MessageBackupInProgress')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<LinearProgress />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackupProgressDialog;
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { FunctionComponent, useCallback } from 'react';
|
||||
import globalize from 'lib/globalize';
|
||||
import type { BackupOptionsDto } from '@jellyfin/sdk/lib/generated-client/models/backup-options-dto';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Button from '@mui/material/Button';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormGroup from '@mui/material/FormGroup';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
|
||||
type IProps = {
|
||||
open: boolean,
|
||||
onClose?: () => void,
|
||||
onCreate: (backupOptions: BackupOptionsDto) => void
|
||||
};
|
||||
|
||||
const CreateBackupForm: FunctionComponent<IProps> = ({ open, onClose, onCreate }) => {
|
||||
const onSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
const backupOptions: BackupOptionsDto = {
|
||||
'Metadata': data.Metadata?.toString() === 'on',
|
||||
'Trickplay': data.Trickplay?.toString() === 'on',
|
||||
'Subtitles': data.Subtitles?.toString() === 'on'
|
||||
};
|
||||
|
||||
onCreate(backupOptions);
|
||||
}, [ onCreate ]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
maxWidth={'xs'}
|
||||
fullWidth
|
||||
onClose={onClose}
|
||||
slotProps={{
|
||||
paper: {
|
||||
component: 'form',
|
||||
onSubmit
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle>{globalize.translate('ButtonCreateBackup')}</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Stack spacing={2}>
|
||||
<DialogContentText>
|
||||
{globalize.translate('MessageBackupDisclaimer')}
|
||||
</DialogContentText>
|
||||
<FormGroup>
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name='Database'
|
||||
defaultChecked={true}
|
||||
disabled
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelDatabase')}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name='Metadata'
|
||||
defaultChecked={false}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelMetadata')}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name='Subtitles'
|
||||
defaultChecked={false}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('Subtitles')}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name='Trickplay'
|
||||
defaultChecked={false}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('Trickplay')}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant='text'
|
||||
>{globalize.translate('ButtonCancel')}</Button>
|
||||
<Button type='submit'>{globalize.translate('Create')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateBackupForm;
|
||||
@@ -0,0 +1,46 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import globalize from 'lib/globalize';
|
||||
import React, { FunctionComponent } from 'react';
|
||||
|
||||
type IProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
const RestoreConfirmationDialog: FunctionComponent<IProps> = ({ open, onClose, onConfirm }: IProps) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth={'xs'}
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
{globalize.translate('LabelRestore')}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{globalize.translate('MessageRestoreDisclaimer')}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} color='error'>
|
||||
{globalize.translate('ButtonCancel')}
|
||||
</Button>
|
||||
<Button onClick={onConfirm}>
|
||||
{globalize.translate('LabelRestore')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default RestoreConfirmationDialog;
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, { FunctionComponent } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
type IProps = {
|
||||
open: boolean
|
||||
};
|
||||
|
||||
const RestoreProgressDialog: FunctionComponent<IProps> = ({ open }) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
maxWidth={'xs'}
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>{globalize.translate('MessageRestoreInProgress')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2}>
|
||||
<DialogContentText>{globalize.translate('MessageWaitingForServer')}</DialogContentText>
|
||||
<LinearProgress />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default RestoreProgressDialog;
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import type { LibraryApiGetItemCountsRequest } from '@jellyfin/sdk/lib/generated-client/api/library-api';
|
||||
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
const fetchItemCounts = async (
|
||||
api: Api,
|
||||
params?: LibraryApiGetItemCountsRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await getLibraryApi(api)
|
||||
.getItemCounts(params, options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getItemCountsQuery = (
|
||||
api?: Api,
|
||||
params?: LibraryApiGetItemCountsRequest
|
||||
) => queryOptions({
|
||||
queryKey: [ 'ItemCounts', params ],
|
||||
queryFn: ({ signal }) => fetchItemCounts(api!, params, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
|
||||
export const useItemCounts = (
|
||||
params?: LibraryApiGetItemCountsRequest
|
||||
) => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getItemCountsQuery(api, params));
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import SvgIcon from '@mui/material/SvgIcon';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
import { useLocale } from 'hooks/useLocale';
|
||||
import { toDecimalString } from 'utils/number';
|
||||
|
||||
interface Metric {
|
||||
label: string
|
||||
value?: number
|
||||
}
|
||||
|
||||
export interface MetricCardProps {
|
||||
metrics: Metric[]
|
||||
Icon: typeof SvgIcon
|
||||
}
|
||||
|
||||
const MetricCard: FC<MetricCardProps> = ({
|
||||
metrics,
|
||||
Icon
|
||||
}) => {
|
||||
const { dateTimeLocale } = useLocale();
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction='row'
|
||||
sx={{
|
||||
width: '100%',
|
||||
padding: 2,
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
{metrics.map(({ label, value }) => (
|
||||
<Box key={label}>
|
||||
<Typography
|
||||
variant='body2'
|
||||
color='text.secondary'
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant='h5'
|
||||
component='div'
|
||||
>
|
||||
{typeof value !== 'undefined' ? (
|
||||
toDecimalString(value, dateTimeLocale)
|
||||
) : (
|
||||
<Skeleton />
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<Icon fontSize='large' />
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricCard;
|
||||
@@ -0,0 +1,113 @@
|
||||
/** List of codecs and their supported hardware acceleration types */
|
||||
export const CODECS = [
|
||||
{
|
||||
name: 'H264',
|
||||
codec: 'h264',
|
||||
types: [
|
||||
'amf',
|
||||
'nvenc',
|
||||
'qsv',
|
||||
'vaapi',
|
||||
'rkmpp',
|
||||
'videotoolbox',
|
||||
'v4l2m2m'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'HEVC',
|
||||
codec: 'hevc',
|
||||
types: [
|
||||
'amf',
|
||||
'nvenc',
|
||||
'qsv',
|
||||
'vaapi',
|
||||
'rkmpp',
|
||||
'videotoolbox'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'MPEG1',
|
||||
codec: 'mpeg1video',
|
||||
types: [ 'rkmpp' ]
|
||||
},
|
||||
{
|
||||
name: 'MPEG2',
|
||||
codec: 'mpeg2video',
|
||||
types: [
|
||||
'amf',
|
||||
'nvenc',
|
||||
'qsv',
|
||||
'vaapi',
|
||||
'rkmpp'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'MPEG4',
|
||||
codec: 'mpeg4',
|
||||
types: [
|
||||
'nvenc',
|
||||
'rkmpp'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'VC1',
|
||||
codec: 'vc1',
|
||||
types: [
|
||||
'amf',
|
||||
'nvenc',
|
||||
'qsv',
|
||||
'vaapi'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'VP8',
|
||||
codec: 'vp8',
|
||||
types: [
|
||||
'nvenc',
|
||||
'qsv',
|
||||
'vaapi',
|
||||
'rkmpp',
|
||||
'videotoolbox'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'VP9',
|
||||
codec: 'vp9',
|
||||
types: [
|
||||
'amf',
|
||||
'nvenc',
|
||||
'qsv',
|
||||
'vaapi',
|
||||
'rkmpp',
|
||||
'videotoolbox'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'AV1',
|
||||
codec: 'av1',
|
||||
types: [
|
||||
'amf',
|
||||
'nvenc',
|
||||
'qsv',
|
||||
'vaapi',
|
||||
'rkmpp',
|
||||
'videotoolbox'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
/** Hardware decoders which support 10-bit HEVC & VP9 */
|
||||
export const HEVC_VP9_HW_DECODING_TYPES = [
|
||||
'amf',
|
||||
'nvenc',
|
||||
'qsv',
|
||||
'vaapi',
|
||||
'rkmpp'
|
||||
];
|
||||
|
||||
/** Hardware decoders which support HEVC RExt */
|
||||
export const HEVC_REXT_DECODING_TYPES = [
|
||||
'nvenc',
|
||||
'qsv',
|
||||
'vaapi'
|
||||
];
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum QueryKey {
|
||||
ConfigurationPages = 'ConfigurationPages',
|
||||
PackageInfo = 'PackageInfo',
|
||||
Plugins = 'Plugins'
|
||||
Packages = 'Packages',
|
||||
Plugins = 'Plugins',
|
||||
Repositories = 'Repositories'
|
||||
}
|
||||
|
||||
@@ -7,12 +7,24 @@ import type { AxiosRequestConfig } from 'axios';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info';
|
||||
|
||||
const fetchPackageInfo = async (
|
||||
api: Api,
|
||||
params: PackageApiGetPackageInfoRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const packagesData = queryClient.getQueryData([ QueryKey.Packages ]) as PackageInfo[];
|
||||
if (packagesData && params.assemblyGuid) {
|
||||
// Use cached query to avoid re-fetching
|
||||
const pkg = packagesData.find(v => v.guid === params.assemblyGuid);
|
||||
|
||||
if (pkg) {
|
||||
return pkg;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await getPackageApi(api)
|
||||
.getPackageInfo(params, options);
|
||||
return response.data;
|
||||
@@ -24,7 +36,7 @@ const getPackageInfoQuery = (
|
||||
) => queryOptions({
|
||||
// Don't retry since requests for plugins not available in repos fail
|
||||
retry: false,
|
||||
queryKey: [ QueryKey.PackageInfo, params?.name, params?.assemblyGuid ],
|
||||
queryKey: [ QueryKey.Packages, params?.name, params?.assemblyGuid ],
|
||||
queryFn: ({ signal }) => fetchPackageInfo(api!, params!, { signal }),
|
||||
enabled: !!params && !!api && !!params.name
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
const fetchPackages = async (
|
||||
api: Api,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await getPackageApi(api)
|
||||
.getPackages(options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getPackagesQuery = (
|
||||
api?: Api
|
||||
) => queryOptions({
|
||||
queryKey: [ QueryKey.Packages ],
|
||||
queryFn: ({ signal }) => fetchPackages(api!, { signal }),
|
||||
enabled: !!api,
|
||||
staleTime: 15 * 60 * 1000 // 15 minutes
|
||||
});
|
||||
|
||||
export const usePackages = () => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getPackagesQuery(api));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { QueryKey } from './queryKey';
|
||||
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
|
||||
|
||||
const fetchRepositories = async (
|
||||
api: Api,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await getPackageApi(api)
|
||||
.getRepositories(options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getRepositoriesQuery = (
|
||||
api?: Api
|
||||
) => queryOptions({
|
||||
queryKey: [ QueryKey.Repositories ],
|
||||
queryFn: ({ signal }) => fetchRepositories(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
|
||||
export const useRepositories = () => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getRepositoriesQuery(api));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { QueryKey } from './queryKey';
|
||||
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
|
||||
import { PackageApiSetRepositoriesRequest } from '@jellyfin/sdk/lib/generated-client/api/package-api';
|
||||
|
||||
export const useSetRepositories = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PackageApiSetRepositoriesRequest) => (
|
||||
getPackageApi(api!)
|
||||
.setRepositories(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.Repositories ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import type { RepositoryInfo } from '@jellyfin/sdk/lib/generated-client/models/repository-info';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import globalize from 'lib/globalize';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
|
||||
type IProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAdd: (repository: RepositoryInfo) => void;
|
||||
};
|
||||
|
||||
const NewRepositoryForm = ({ open, onClose, onAdd }: IProps) => {
|
||||
const onSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
const repository: RepositoryInfo = {
|
||||
Name: data.Name?.toString(),
|
||||
Url: data.Url?.toString(),
|
||||
Enabled: true
|
||||
};
|
||||
|
||||
onAdd(repository);
|
||||
}, [ onAdd ]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
maxWidth={'xs'}
|
||||
fullWidth
|
||||
onClose={onClose}
|
||||
slotProps={{
|
||||
paper: {
|
||||
component: 'form',
|
||||
onSubmit
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle>{globalize.translate('HeaderNewRepository')}</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Stack spacing={3}>
|
||||
<TextField
|
||||
name='Name'
|
||||
label={globalize.translate('LabelRepositoryName')}
|
||||
helperText={globalize.translate('LabelRepositoryNameHelp')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
required: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name='Url'
|
||||
label={globalize.translate('LabelRepositoryUrl')}
|
||||
helperText={globalize.translate('LabelRepositoryUrlHelp')}
|
||||
type='url'
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant='text'
|
||||
>{globalize.translate('ButtonCancel')}</Button>
|
||||
<Button type='submit'>{globalize.translate('Add')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewRepositoryForm;
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info';
|
||||
import ExtensionIcon from '@mui/icons-material/Extension';
|
||||
import BaseCard from 'apps/dashboard/components/BaseCard';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
type IProps = {
|
||||
pkg: PackageInfo;
|
||||
};
|
||||
|
||||
const PackageCard = ({ pkg }: IProps) => {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
title={pkg.name}
|
||||
image={pkg.imageUrl}
|
||||
icon={<ExtensionIcon sx={{ width: 80, height: 80 }} />}
|
||||
to={{
|
||||
pathname: `/dashboard/plugins/${pkg.guid}`,
|
||||
search: `?name=${encodeURIComponent(pkg.name || '')}`,
|
||||
hash: location.hash
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PackageCard;
|
||||
@@ -0,0 +1,172 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import type { PluginInfo } from '@jellyfin/sdk/lib/generated-client/models/plugin-info';
|
||||
import globalize from 'lib/globalize';
|
||||
import BaseCard from 'apps/dashboard/components/BaseCard';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import Settings from '@mui/icons-material/Settings';
|
||||
import Delete from '@mui/icons-material/Delete';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
import BlockIcon from '@mui/icons-material/Block';
|
||||
import ExtensionIcon from '@mui/icons-material/Extension';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
|
||||
import type { ConfigurationPageInfo } from '@jellyfin/sdk/lib/generated-client/models/configuration-page-info';
|
||||
import { useEnablePlugin } from '../api/useEnablePlugin';
|
||||
import { useDisablePlugin } from '../api/useDisablePlugin';
|
||||
import { useUninstallPlugin } from '../api/useUninstallPlugin';
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
|
||||
interface IProps {
|
||||
plugin: PluginInfo;
|
||||
configurationPage?: ConfigurationPageInfo;
|
||||
};
|
||||
|
||||
const PluginCard = ({ plugin, configurationPage }: IProps) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const actionRef = useRef<HTMLButtonElement | null>(null);
|
||||
const enablePlugin = useEnablePlugin();
|
||||
const disablePlugin = useDisablePlugin();
|
||||
const uninstallPlugin = useUninstallPlugin();
|
||||
const [ anchorEl, setAnchorEl ] = useState<HTMLElement | null>(null);
|
||||
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
|
||||
const [ isUninstallConfirmOpen, setIsUninstallConfirmOpen ] = useState(false);
|
||||
const { api } = useApi();
|
||||
|
||||
const pluginPage = useMemo(() => (
|
||||
{
|
||||
pathname: '/configurationpage',
|
||||
search: `?name=${encodeURIComponent(configurationPage?.Name || '')}`,
|
||||
hash: location.hash
|
||||
}
|
||||
), [ location, configurationPage ]);
|
||||
|
||||
const navigateToPluginSettings = useCallback(() => {
|
||||
navigate(pluginPage);
|
||||
}, [ navigate, pluginPage ]);
|
||||
|
||||
const onEnablePlugin = useCallback(() => {
|
||||
if (plugin.Id && plugin.Version) {
|
||||
enablePlugin.mutate({
|
||||
pluginId: plugin.Id,
|
||||
version: plugin.Version
|
||||
});
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}, [ plugin, enablePlugin ]);
|
||||
|
||||
const onDisablePlugin = useCallback(() => {
|
||||
if (plugin.Id && plugin.Version) {
|
||||
disablePlugin.mutate({
|
||||
pluginId: plugin.Id,
|
||||
version: plugin.Version
|
||||
});
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}, [ plugin, disablePlugin ]);
|
||||
|
||||
const onCloseUninstallConfirmDialog = useCallback(() => {
|
||||
setIsUninstallConfirmOpen(false);
|
||||
}, []);
|
||||
|
||||
const showUninstallConfirmDialog = useCallback(() => {
|
||||
setIsUninstallConfirmOpen(true);
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const onUninstall = useCallback(() => {
|
||||
if (plugin.Id && plugin.Version) {
|
||||
uninstallPlugin.mutate({
|
||||
pluginId: plugin.Id,
|
||||
version: plugin.Version
|
||||
});
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}, [ plugin, uninstallPlugin ]);
|
||||
|
||||
const onMenuClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const onActionClick = useCallback(() => {
|
||||
setAnchorEl(actionRef.current);
|
||||
setIsMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseCard
|
||||
title={plugin.Name}
|
||||
secondaryTitle={plugin.Version}
|
||||
to={pluginPage}
|
||||
text={`${globalize.translate('LabelStatus')} ${plugin.Status}`}
|
||||
image={plugin.HasImage ? api?.getUri(`/Plugins/${plugin.Id}/${plugin.Version}/Image`) : null}
|
||||
icon={<ExtensionIcon sx={{ width: 80, height: 80 }} />}
|
||||
action={true}
|
||||
actionRef={actionRef}
|
||||
onActionClick={onActionClick}
|
||||
/>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
{configurationPage && (
|
||||
<MenuItem onClick={navigateToPluginSettings}>
|
||||
<ListItemIcon>
|
||||
<Settings />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('Settings')}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{(plugin.CanUninstall && plugin.Status === PluginStatus.Active) && (
|
||||
<MenuItem onClick={onDisablePlugin}>
|
||||
<ListItemIcon>
|
||||
<BlockIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('DisablePlugin')}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{(plugin.CanUninstall && plugin.Status === PluginStatus.Disabled) && (
|
||||
<MenuItem onClick={onEnablePlugin}>
|
||||
<ListItemIcon>
|
||||
<CheckCircleOutlineIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('EnablePlugin')}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{plugin.CanUninstall && (
|
||||
<MenuItem onClick={showUninstallConfirmDialog}>
|
||||
<ListItemIcon>
|
||||
<Delete />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('ButtonUninstall')}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
<ConfirmDialog
|
||||
open={isUninstallConfirmOpen}
|
||||
title={globalize.translate('HeaderUninstallPlugin')}
|
||||
text={globalize.translate('UninstallPluginConfirmation', plugin.Name || '')}
|
||||
onCancel={onCloseUninstallConfirmDialog}
|
||||
onConfirm={onUninstall}
|
||||
confirmButtonColor='error'
|
||||
confirmButtonText={globalize.translate('ButtonUninstall')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginCard;
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import type { RepositoryInfo } from '@jellyfin/sdk/lib/generated-client/models/repository-info';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Delete from '@mui/icons-material/Delete';
|
||||
import globalize from 'lib/globalize';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import OpenInNew from '@mui/icons-material/OpenInNew';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import Link from '@mui/material/Link';
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
|
||||
type IProps = {
|
||||
repository: RepositoryInfo;
|
||||
onDelete: (repository: RepositoryInfo) => void;
|
||||
};
|
||||
|
||||
const RepositoryListItem = ({ repository, onDelete }: IProps) => {
|
||||
const [ isConfirmDeleteOpen, setIsConfirmDeleteOpen ] = useState(false);
|
||||
|
||||
const confirmDeletePrompt = useCallback(() => {
|
||||
setIsConfirmDeleteOpen(true);
|
||||
}, []);
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
setIsConfirmDeleteOpen(false);
|
||||
}, []);
|
||||
|
||||
const onConfirmDelete = useCallback(() => {
|
||||
onDelete(repository);
|
||||
setIsConfirmDeleteOpen(false);
|
||||
}, [ onDelete, repository ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
open={isConfirmDeleteOpen}
|
||||
title={globalize.translate('ConfirmDeleteRepository')}
|
||||
text={globalize.translate('DeleteRepositoryConfirmation')}
|
||||
onConfirm={onConfirmDelete}
|
||||
onCancel={onCancel}
|
||||
confirmButtonColor='error'
|
||||
confirmButtonText={globalize.translate('Delete')}
|
||||
/>
|
||||
<ListItem
|
||||
disablePadding
|
||||
secondaryAction={
|
||||
<Tooltip disableInteractive title={globalize.translate('ButtonRemove')}>
|
||||
<IconButton onClick={confirmDeletePrompt}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<ListItemButton>
|
||||
<Link href={repository.Url || '#'} target='_blank' rel='noopener noreferrer'>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||
<OpenInNew sx={{ color: '#fff' }} />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
</Link>
|
||||
<ListItemText
|
||||
primary={repository.Name}
|
||||
secondary={repository.Url}
|
||||
slotProps={{
|
||||
primary: {
|
||||
variant: 'h3'
|
||||
},
|
||||
secondary: {
|
||||
variant: 'body1'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RepositoryListItem;
|
||||
@@ -1,5 +1,5 @@
|
||||
/** A mapping of category names used by the plugin repository to translation keys. */
|
||||
export const CATEGORY_LABELS = {
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
Administration: 'HeaderAdmin',
|
||||
Anime: 'Anime',
|
||||
Authentication: 'LabelAuthProvider', // Legacy
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info';
|
||||
|
||||
const getPackageCategories = (packages?: PackageInfo[]) => {
|
||||
if (!packages) return [];
|
||||
|
||||
const categories: string[] = [];
|
||||
|
||||
for (const pkg of packages) {
|
||||
if (pkg.category && !categories.includes(pkg.category)) {
|
||||
categories.push(pkg.category);
|
||||
}
|
||||
}
|
||||
|
||||
return categories.sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
export default getPackageCategories;
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info';
|
||||
|
||||
const getPackagesByCategory = (packages: PackageInfo[] | undefined, category: string) => {
|
||||
if (!packages) return [];
|
||||
|
||||
return packages
|
||||
.filter(pkg => pkg.category === category)
|
||||
.sort((a, b) => {
|
||||
if (a.name && b.name) {
|
||||
return a.name.localeCompare(b.name);
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default getPackagesByCategory;
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
const fetchSystemStorage = async (
|
||||
api: Api,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await getSystemApi(api)
|
||||
.getSystemStorage(options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getSystemStorageQuery = (
|
||||
api?: Api
|
||||
) => queryOptions({
|
||||
queryKey: [ 'SystemStorage' ],
|
||||
queryFn: ({ signal }) => fetchSystemStorage(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
|
||||
export const useSystemStorage = () => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getSystemStorageQuery(api));
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { FolderStorageDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
import { getReadableSize } from 'utils/file';
|
||||
|
||||
import { StorageType } from '../constants/StorageType';
|
||||
import { calculateTotal, calculateUsedPercentage } from '../utils/space';
|
||||
|
||||
import StorageTypeIcon from './StorageTypeIcon';
|
||||
|
||||
interface StorageListItemProps {
|
||||
label: string
|
||||
folder?: FolderStorageDto
|
||||
}
|
||||
|
||||
const getStatusColor = (percent: number) => {
|
||||
if (percent >= 90) return 'error';
|
||||
if (percent >= 80) return 'warning';
|
||||
return 'success';
|
||||
};
|
||||
|
||||
const getStorageTypeText = (type?: string | null) => {
|
||||
if (!type) return undefined;
|
||||
|
||||
if (Object.keys(StorageType).includes(type)) {
|
||||
return globalize.translate(`StorageType.${type}`);
|
||||
}
|
||||
|
||||
return type;
|
||||
};
|
||||
|
||||
const StorageListItem: FC<StorageListItemProps> = ({
|
||||
label,
|
||||
folder
|
||||
}) => {
|
||||
const readableUsedSpace = (typeof folder?.UsedSpace === 'undefined' || folder.UsedSpace < 0) ?
|
||||
'?' : getReadableSize(folder.UsedSpace);
|
||||
const totalSpace = calculateTotal(folder);
|
||||
const readableTotalSpace = (totalSpace < 0) ? '?' : getReadableSize(totalSpace);
|
||||
const usedPercentage = calculateUsedPercentage(folder);
|
||||
const statusColor = folder ? getStatusColor(usedPercentage) : 'primary';
|
||||
|
||||
return (
|
||||
<ListItem>
|
||||
<ListItemIcon title={getStorageTypeText(folder?.StorageType)}>
|
||||
<StorageTypeIcon type={folder?.StorageType} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography
|
||||
component='span'
|
||||
variant='body2'
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<>
|
||||
<Typography
|
||||
color='textPrimary'
|
||||
sx={{
|
||||
paddingBottom: 0.5
|
||||
}}
|
||||
>
|
||||
{folder ? folder.Path : (
|
||||
<Skeleton />
|
||||
)}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant={folder ? 'determinate' : 'indeterminate'}
|
||||
color={statusColor}
|
||||
value={usedPercentage}
|
||||
/>
|
||||
<Typography
|
||||
variant='body2'
|
||||
color='textSecondary'
|
||||
sx={{
|
||||
textAlign: 'end'
|
||||
}}
|
||||
>
|
||||
{`${readableUsedSpace} / ${readableTotalSpace}`}
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
slots={{
|
||||
secondary: 'div'
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageListItem;
|
||||
@@ -0,0 +1,31 @@
|
||||
import Album from '@mui/icons-material/Album';
|
||||
import Lan from '@mui/icons-material/Lan';
|
||||
import Memory from '@mui/icons-material/Memory';
|
||||
import Storage from '@mui/icons-material/Storage';
|
||||
import Usb from '@mui/icons-material/Usb';
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
import { StorageType } from '../constants/StorageType';
|
||||
|
||||
interface StorageTypeIconProps {
|
||||
type?: string | null
|
||||
}
|
||||
|
||||
const StorageTypeIcon: FC<StorageTypeIconProps> = ({
|
||||
type
|
||||
}) => {
|
||||
switch (type) {
|
||||
case StorageType.CDRom:
|
||||
return <Album />;
|
||||
case StorageType.Network:
|
||||
return <Lan />;
|
||||
case StorageType.Ram:
|
||||
return <Memory />;
|
||||
case StorageType.Removable:
|
||||
return <Usb />;
|
||||
default:
|
||||
return <Storage />;
|
||||
}
|
||||
};
|
||||
|
||||
export default StorageTypeIcon;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Common storage type values returned by the API.
|
||||
* NOTE: This list is not comprehensive as .NET might return any string.
|
||||
*/
|
||||
export enum StorageType {
|
||||
CDRom = 'CDRom',
|
||||
Fixed = 'Fixed',
|
||||
Network = 'Network',
|
||||
NoRootDirectory = 'NoRootDirectory',
|
||||
Ram = 'Ram',
|
||||
Removable = 'Removable',
|
||||
Unknown = 'Unknown'
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { calculateUsedPercentage, calculateTotal } from './space';
|
||||
|
||||
describe('calculateTotal()', () => {
|
||||
it('should return the total', () => {
|
||||
expect(calculateTotal({ FreeSpace: 1, UsedSpace: 2 })).toBe(3);
|
||||
});
|
||||
|
||||
it('should return -1 for invalid used space values', () => {
|
||||
expect(calculateTotal({ FreeSpace: 1 })).toBe(-1);
|
||||
expect(calculateTotal({ FreeSpace: 1, UsedSpace: -1 })).toBe(-1);
|
||||
});
|
||||
|
||||
it('should treat invalid free space values as 0', () => {
|
||||
expect(calculateTotal({ UsedSpace: 1 })).toBe(1);
|
||||
expect(calculateTotal({ FreeSpace: -1, UsedSpace: 1 })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateUsedPercentage', () => {
|
||||
it('should return the percentage used', () => {
|
||||
expect(calculateUsedPercentage({ FreeSpace: 1, UsedSpace: 3 })).toBe(75);
|
||||
});
|
||||
|
||||
it('should return 0 for invalid used space values', () => {
|
||||
expect(calculateUsedPercentage({ FreeSpace: 1 })).toBe(0);
|
||||
expect(calculateUsedPercentage({ FreeSpace: 1, UsedSpace: -1 })).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 100 for invalid free space values', () => {
|
||||
expect(calculateUsedPercentage({ UsedSpace: 1 })).toBe(100);
|
||||
expect(calculateUsedPercentage({ FreeSpace: -1, UsedSpace: 1 })).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { FolderStorageDto } from '@jellyfin/sdk/lib/generated-client/models/folder-storage-dto';
|
||||
|
||||
export const calculateTotal = (folder?: FolderStorageDto) => {
|
||||
if (typeof folder?.UsedSpace === 'undefined' || folder.UsedSpace < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const freeSpace = Math.max(0, folder.FreeSpace || 0);
|
||||
const usedSpace = Math.max(0, folder.UsedSpace);
|
||||
return freeSpace + usedSpace;
|
||||
};
|
||||
|
||||
export const calculateUsedPercentage = (folder?: FolderStorageDto) => {
|
||||
const totalSpace = calculateTotal(folder);
|
||||
if (totalSpace <= 0) return 0;
|
||||
|
||||
const usedSpace = folder?.UsedSpace || 0;
|
||||
|
||||
return Math.min(100, (usedSpace / totalSpace) * 100);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { AppType } from 'constants/appType';
|
||||
|
||||
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||
{ path: 'activity', type: AppType.Dashboard },
|
||||
{ path: 'backups', type: AppType.Dashboard },
|
||||
{ path: 'branding', type: AppType.Dashboard },
|
||||
{ path: 'devices', type: AppType.Dashboard },
|
||||
{ path: 'settings', type: AppType.Dashboard },
|
||||
@@ -10,12 +11,17 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||
{ path: 'libraries/display', type: AppType.Dashboard },
|
||||
{ path: 'libraries/metadata', type: AppType.Dashboard },
|
||||
{ path: 'libraries/nfo', type: AppType.Dashboard },
|
||||
{ path: 'livetv/recordings', type: AppType.Dashboard },
|
||||
{ path: 'logs', type: AppType.Dashboard },
|
||||
{ path: 'logs/:file', page: 'logs/file', type: AppType.Dashboard },
|
||||
{ path: 'playback/resume', type: AppType.Dashboard },
|
||||
{ path: 'playback/streaming', type: AppType.Dashboard },
|
||||
{ path: 'playback/transcoding', type: AppType.Dashboard },
|
||||
{ path: 'playback/trickplay', type: AppType.Dashboard },
|
||||
{ path: 'plugins', type: AppType.Dashboard },
|
||||
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AppType.Dashboard },
|
||||
{ path: 'plugins/catalog', type: AppType.Dashboard },
|
||||
{ path: 'plugins/repositories', type: AppType.Dashboard },
|
||||
{ path: 'tasks', type: AppType.Dashboard },
|
||||
{ path: 'tasks/:id', page: 'tasks/task', type: AppType.Dashboard },
|
||||
{ path: 'users', type: AppType.Dashboard },
|
||||
|
||||
@@ -23,27 +23,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||
controller: 'library',
|
||||
view: 'library.html'
|
||||
}
|
||||
}, {
|
||||
path: 'playback/transcoding',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'encodingsettings',
|
||||
view: 'encodingsettings.html'
|
||||
}
|
||||
}, {
|
||||
path: 'plugins/catalog',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'plugins/available/index',
|
||||
view: 'plugins/available/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'plugins/repositories',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'plugins/repositories/index',
|
||||
view: 'plugins/repositories/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'livetv/guide',
|
||||
pageProps: {
|
||||
@@ -51,13 +30,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||
controller: 'livetvguideprovider',
|
||||
view: 'livetvguideprovider.html'
|
||||
}
|
||||
}, {
|
||||
path: 'recordings',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'livetvsettings',
|
||||
view: 'livetvsettings.html'
|
||||
}
|
||||
}, {
|
||||
path: 'livetv',
|
||||
pageProps: {
|
||||
@@ -72,12 +44,5 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||
controller: 'livetvtuner',
|
||||
view: 'livetvtuner.html'
|
||||
}
|
||||
}, {
|
||||
path: 'plugins',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'plugins/installed/index',
|
||||
view: 'plugins/installed/index.html'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { useBackups } from 'apps/dashboard/features/backups/api/useBackups';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import List from '@mui/material/List';
|
||||
import CreateBackupForm from 'apps/dashboard/features/backups/components/CreateBackupForm';
|
||||
import type { BackupOptionsDto } from '@jellyfin/sdk/lib/generated-client/models/backup-options-dto';
|
||||
import type { BackupManifestDto } from '@jellyfin/sdk/lib/generated-client/models/backup-manifest-dto';
|
||||
import { useCreateBackup } from 'apps/dashboard/features/backups/api/useCreateBackup';
|
||||
import BackupProgressDialog from 'apps/dashboard/features/backups/components/BackupProgressDialog';
|
||||
import Backup from 'apps/dashboard/features/backups/components/Backup';
|
||||
import SimpleAlert from 'components/SimpleAlert';
|
||||
import RestoreConfirmationDialog from 'apps/dashboard/features/backups/components/RestoreConfirmationDialog';
|
||||
import { useRestoreBackup } from 'apps/dashboard/features/backups/api/useRestoreBackup';
|
||||
import RestoreProgressDialog from 'apps/dashboard/features/backups/components/RestoreProgressDialog';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
|
||||
|
||||
export const Component = () => {
|
||||
const { api } = useApi();
|
||||
const { data: backups, isPending, isError } = useBackups();
|
||||
const [ isCreateFormOpen, setIsCreateFormOpen ] = useState(false);
|
||||
const [ backupInProgress, setBackupInProgress ] = useState(false);
|
||||
const [ restoreInProgress, setRestoreInProgress ] = useState(false);
|
||||
const [ isRestoreSuccess, setIsRestoreSuccess ] = useState(false);
|
||||
const [ isErrorOccurred, setIsErrorOccurred ] = useState(false);
|
||||
const [ isRestoreDialogOpen, setIsRestoreDialogOpen ] = useState(false);
|
||||
const [ backupToRestore, setBackupToRestore ] = useState<BackupManifestDto | null>(null);
|
||||
const createBackup = useCreateBackup();
|
||||
const restoreBackup = useRestoreBackup();
|
||||
|
||||
const onCreateClick = useCallback(() => {
|
||||
setIsCreateFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const onCreateFormClose = useCallback(() => {
|
||||
setIsCreateFormOpen(false);
|
||||
}, []);
|
||||
|
||||
const onRestoreDialogClose = useCallback(() => {
|
||||
setIsRestoreDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const onErrorAlertClose = useCallback(() => {
|
||||
setIsErrorOccurred(false);
|
||||
}, []);
|
||||
|
||||
const onRestoreSuccessAlertClose = useCallback(() => {
|
||||
setIsRestoreSuccess(false);
|
||||
}, []);
|
||||
|
||||
const onBackupCreate = useCallback((backupOptions: BackupOptionsDto) => {
|
||||
setBackupInProgress(true);
|
||||
setIsCreateFormOpen(false);
|
||||
createBackup.mutate(backupOptions, {
|
||||
onError: () => {
|
||||
setIsErrorOccurred(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setBackupInProgress(false);
|
||||
}
|
||||
});
|
||||
}, [ createBackup ]);
|
||||
|
||||
const promptRestore = useCallback((backup: BackupManifestDto) => {
|
||||
setIsRestoreDialogOpen(true);
|
||||
setBackupToRestore(backup);
|
||||
}, []);
|
||||
|
||||
const onRestoreConfirm = useCallback(() => {
|
||||
if (backupToRestore?.Path) {
|
||||
restoreBackup.mutate(backupToRestore?.Path, {
|
||||
onSuccess: () => {
|
||||
setRestoreInProgress(true);
|
||||
},
|
||||
onError: () => {
|
||||
setIsErrorOccurred(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsRestoreDialogOpen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [backupToRestore, restoreBackup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (restoreInProgress) {
|
||||
const serverCheckInterval = setInterval(() => {
|
||||
void getSystemApi(api!)
|
||||
.getPublicSystemInfo()
|
||||
.then(() => {
|
||||
setRestoreInProgress(false);
|
||||
setIsRestoreSuccess(true);
|
||||
clearInterval(serverCheckInterval);
|
||||
}).catch(() => {
|
||||
// Server is still down
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
clearInterval(serverCheckInterval);
|
||||
};
|
||||
}
|
||||
}, [api, restoreInProgress]);
|
||||
|
||||
if (isPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='backupsPage'
|
||||
title={globalize.translate('HeaderBackups')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<BackupProgressDialog open={backupInProgress} />
|
||||
<RestoreProgressDialog open={restoreInProgress} />
|
||||
<CreateBackupForm
|
||||
open={isCreateFormOpen}
|
||||
onClose={onCreateFormClose}
|
||||
onCreate={onBackupCreate}
|
||||
/>
|
||||
<SimpleAlert
|
||||
open={isErrorOccurred}
|
||||
text={globalize.translate('UnknownError')}
|
||||
onClose={onErrorAlertClose}
|
||||
/>
|
||||
<SimpleAlert
|
||||
open={isRestoreSuccess}
|
||||
title={globalize.translate('Success')}
|
||||
text={globalize.translate('MessageRestoreSuccess')}
|
||||
onClose={onRestoreSuccessAlertClose}
|
||||
/>
|
||||
<RestoreConfirmationDialog
|
||||
open={isRestoreDialogOpen}
|
||||
onClose={onRestoreDialogClose}
|
||||
onConfirm={onRestoreConfirm}
|
||||
/>
|
||||
<Box className='content-primary'>
|
||||
{isError ? (
|
||||
<Alert severity='error'>{globalize.translate('BackupsPageLoadError')}</Alert>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h1'>
|
||||
{globalize.translate('HeaderBackups')}
|
||||
</Typography>
|
||||
<Typography>
|
||||
{globalize.translate('HeaderBackupsHelp')}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
startIcon={<AddIcon />}
|
||||
onClick={onCreateClick}
|
||||
>
|
||||
{globalize.translate('ButtonCreateBackup')}
|
||||
</Button>
|
||||
|
||||
<Box className='readOnlyContent'>
|
||||
{backups.length > 0 && (
|
||||
<List sx={{ bgcolor: 'background.paper' }}>
|
||||
{backups.map(backup => {
|
||||
return <Backup
|
||||
key={backup.Path}
|
||||
backup={backup}
|
||||
onRestore={promptRestore}
|
||||
/>;
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
@@ -18,14 +18,14 @@ import { getBrandingOptionsQuery, QUERY_KEY, useBrandingOptions } from 'apps/das
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Image from 'components/Image';
|
||||
import Page from 'components/Page';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { SPLASHSCREEN_URL } from 'constants/branding';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import globalize from 'lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { ActionData } from 'types/actionData';
|
||||
|
||||
const BRANDING_CONFIG_KEY = 'branding';
|
||||
const SPLASHSCREEN_URL = '/Branding/Splashscreen';
|
||||
const BrandingOption = {
|
||||
CustomCss: 'CustomCss',
|
||||
LoginDisclaimer: 'LoginDisclaimer',
|
||||
|
||||
@@ -12,14 +12,15 @@ import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Page from 'components/Page';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||
import { QUERY_KEY as CONFIG_QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
|
||||
import { QUERY_KEY as NAMED_CONFIG_QUERY_KEY, NamedConfiguration, useNamedConfiguration } from 'hooks/useNamedConfiguration';
|
||||
import { QUERY_KEY as NAMED_CONFIG_QUERY_KEY, useNamedConfiguration } from 'hooks/useNamedConfiguration';
|
||||
import globalize from 'lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
|
||||
import { ActionData } from 'types/actionData';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import type { MetadataConfiguration } from '@jellyfin/sdk/lib/generated-client/models/metadata-configuration';
|
||||
|
||||
const CONFIG_KEY = 'metadata';
|
||||
|
||||
@@ -32,7 +33,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
|
||||
const { data: config } = await getConfigurationApi(api).getConfiguration();
|
||||
|
||||
const metadataConfig: NamedConfiguration = {
|
||||
const metadataConfig: MetadataConfiguration = {
|
||||
UseFileCreationTimeForDateAdded: data.DateAddedBehavior.toString() === '1'
|
||||
};
|
||||
|
||||
@@ -70,7 +71,7 @@ export const Component = () => {
|
||||
data: namedConfig,
|
||||
isPending: isNamedConfigPending,
|
||||
isError: isNamedConfigError
|
||||
} = useNamedConfiguration(CONFIG_KEY);
|
||||
} = useNamedConfiguration<MetadataConfiguration>(CONFIG_KEY);
|
||||
|
||||
const navigation = useNavigation();
|
||||
const actionData = useActionData() as ActionData | undefined;
|
||||
@@ -97,7 +98,7 @@ export const Component = () => {
|
||||
{globalize.translate('SettingsSaved')}
|
||||
</Alert>
|
||||
)}
|
||||
<Typography variant='h2'>{globalize.translate('Display')}</Typography>
|
||||
<Typography variant='h1'>{globalize.translate('Display')}</Typography>
|
||||
<TextField
|
||||
name={'DateAddedBehavior'}
|
||||
label={globalize.translate('LabelDateAddedBehavior')}
|
||||
|
||||
@@ -12,9 +12,9 @@ import { useCultures } from 'apps/dashboard/features/libraries/api/useCultures';
|
||||
import { getImageResolutionOptions } from 'apps/dashboard/features/libraries/utils/metadataOptions';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Page from 'components/Page';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
|
||||
import globalize from 'lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import React from 'react';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
|
||||
import { ActionData } from 'types/actionData';
|
||||
|
||||
@@ -12,26 +12,19 @@ import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Page from 'components/Page';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import SimpleAlert from 'components/SimpleAlert';
|
||||
import { QUERY_KEY, useNamedConfiguration } from 'hooks/useNamedConfiguration';
|
||||
import { useUsers } from 'hooks/useUsers';
|
||||
import globalize from 'lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
|
||||
import { ActionData } from 'types/actionData';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import type { XbmcMetadataOptions } from '@jellyfin/sdk/lib/generated-client/models/xbmc-metadata-options';
|
||||
|
||||
const CONFIG_KEY = 'xbmcmetadata';
|
||||
|
||||
interface NFOSettingsConfig {
|
||||
UserId?: string;
|
||||
EnableExtraThumbsDuplication?: boolean;
|
||||
EnablePathSubstitution?: boolean;
|
||||
ReleaseDateFormat?: string;
|
||||
SaveImagePathsInNfo?: boolean;
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const api = ServerConnections.getCurrentApi();
|
||||
if (!api) throw new Error('No Api instance available');
|
||||
@@ -39,7 +32,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const formData = await request.formData();
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
const newConfig: NFOSettingsConfig = {
|
||||
const newConfig: XbmcMetadataOptions = {
|
||||
UserId: data.UserId?.toString(),
|
||||
ReleaseDateFormat: 'yyyy-MM-dd',
|
||||
SaveImagePathsInNfo: data.SaveImagePathsInNfo?.toString() === 'on',
|
||||
@@ -64,7 +57,7 @@ export const Component = () => {
|
||||
data: config,
|
||||
isPending: isConfigPending,
|
||||
isError: isConfigError
|
||||
} = useNamedConfiguration(CONFIG_KEY);
|
||||
} = useNamedConfiguration<XbmcMetadataOptions>(CONFIG_KEY);
|
||||
const {
|
||||
data: users,
|
||||
isPending: isUsersPending,
|
||||
@@ -75,8 +68,6 @@ export const Component = () => {
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false);
|
||||
|
||||
const nfoConfig = config as NFOSettingsConfig;
|
||||
|
||||
const onAlertClose = useCallback(() => {
|
||||
setIsAlertOpen(false);
|
||||
}, []);
|
||||
@@ -111,13 +102,13 @@ export const Component = () => {
|
||||
{globalize.translate('SettingsSaved')}
|
||||
</Alert>
|
||||
)}
|
||||
<Typography variant='h2'>{globalize.translate('TabNfoSettings')}</Typography>
|
||||
<Typography variant='h1'>{globalize.translate('TabNfoSettings')}</Typography>
|
||||
<Typography>{globalize.translate('HeaderKodiMetadataHelp')}</Typography>
|
||||
|
||||
<TextField
|
||||
name={'UserId'}
|
||||
label={globalize.translate('LabelKodiMetadataUser')}
|
||||
defaultValue={nfoConfig.UserId || ''}
|
||||
defaultValue={config.UserId || ''}
|
||||
select
|
||||
helperText={globalize.translate('LabelKodiMetadataUserHelp')}
|
||||
slotProps={{
|
||||
@@ -141,7 +132,7 @@ export const Component = () => {
|
||||
control={
|
||||
<Checkbox
|
||||
name={'SaveImagePathsInNfo'}
|
||||
defaultChecked={nfoConfig.SaveImagePathsInNfo}
|
||||
defaultChecked={config.SaveImagePathsInNfo}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelKodiMetadataSaveImagePaths')}
|
||||
@@ -154,7 +145,7 @@ export const Component = () => {
|
||||
control={
|
||||
<Checkbox
|
||||
name={'EnablePathSubstitution'}
|
||||
defaultChecked={nfoConfig.EnablePathSubstitution}
|
||||
defaultChecked={config.EnablePathSubstitution}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelKodiMetadataEnablePathSubstitution')}
|
||||
@@ -167,7 +158,7 @@ export const Component = () => {
|
||||
control={
|
||||
<Checkbox
|
||||
name={'EnableExtraThumbsDuplication'}
|
||||
defaultChecked={nfoConfig.EnableExtraThumbsDuplication}
|
||||
defaultChecked={config.EnableExtraThumbsDuplication}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelKodiMetadataEnableExtraThumbs')}
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Page from 'components/Page';
|
||||
import { QUERY_KEY, useNamedConfiguration } from 'hooks/useNamedConfiguration';
|
||||
import globalize from 'lib/globalize';
|
||||
import { ActionFunctionArgs, Form, useActionData, useNavigation, useSubmit } from 'react-router-dom';
|
||||
import type { LiveTvOptions } from '@jellyfin/sdk/lib/generated-client/models/live-tv-options';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import DirectoryBrowser from 'components/directorybrowser/directorybrowser';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import Button from '@mui/material/Button';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { ActionData } from 'types/actionData';
|
||||
|
||||
const CONFIG_KEY = 'livetv';
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const api = ServerConnections.getCurrentApi();
|
||||
if (!api) throw new Error('No Api instance available');
|
||||
|
||||
const data = await request.json() as LiveTvOptions;
|
||||
|
||||
await getConfigurationApi(api)
|
||||
.updateNamedConfiguration({ key: CONFIG_KEY, body: data });
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY, CONFIG_KEY ]
|
||||
});
|
||||
|
||||
return {
|
||||
isSaved: true
|
||||
};
|
||||
};
|
||||
|
||||
export const Component = () => {
|
||||
const navigation = useNavigation();
|
||||
const actionData = useActionData() as ActionData | undefined;
|
||||
const { data: initialConfig, isPending, isError } = useNamedConfiguration<LiveTvOptions>(CONFIG_KEY);
|
||||
const [ config, setConfig ] = useState<LiveTvOptions | null>(null);
|
||||
const [ prePaddingMinutes, setPrePaddingMinutes ] = useState('');
|
||||
const [ postPaddingMinutes, setPostPaddingMinutes ] = useState('');
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
const submit = useSubmit();
|
||||
|
||||
useEffect(() => {
|
||||
if (initialConfig && config == null) {
|
||||
setConfig(initialConfig);
|
||||
if (initialConfig.PrePaddingSeconds) {
|
||||
setPrePaddingMinutes((initialConfig.PrePaddingSeconds / 60).toString());
|
||||
}
|
||||
if (initialConfig.PostPaddingSeconds) {
|
||||
setPostPaddingMinutes((initialConfig.PostPaddingSeconds / 60).toString());
|
||||
}
|
||||
}
|
||||
}, [ initialConfig, config ]);
|
||||
|
||||
const onPrePaddingMinutesChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPrePaddingMinutes(e.target.value);
|
||||
setConfig({
|
||||
...config,
|
||||
PrePaddingSeconds: parseInt(e.target.value, 10) * 60
|
||||
});
|
||||
}, [ config ]);
|
||||
|
||||
const onPostPaddingMinutesChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPostPaddingMinutes(e.target.value);
|
||||
setConfig({
|
||||
...config,
|
||||
PostPaddingSeconds: parseInt(e.target.value, 10) * 60
|
||||
});
|
||||
}, [ config ]);
|
||||
|
||||
const onChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setConfig({
|
||||
...config,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
}, [ config ]);
|
||||
|
||||
const onCheckboxChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setConfig({
|
||||
...config,
|
||||
[e.target.name]: e.target.checked
|
||||
});
|
||||
}, [ config ]);
|
||||
|
||||
const showRecordingPathPicker = useCallback(() => {
|
||||
const picker = new DirectoryBrowser();
|
||||
|
||||
picker.show({
|
||||
callback: (path: string) => {
|
||||
if (path) {
|
||||
setConfig({
|
||||
...config,
|
||||
RecordingPath: path
|
||||
});
|
||||
}
|
||||
|
||||
picker.close();
|
||||
},
|
||||
validateWriteable: true
|
||||
});
|
||||
}, [ config ]);
|
||||
|
||||
const showMovieRecordingPathPicker = useCallback(() => {
|
||||
const picker = new DirectoryBrowser();
|
||||
|
||||
picker.show({
|
||||
callback: (path: string) => {
|
||||
if (path) {
|
||||
setConfig({
|
||||
...config,
|
||||
MovieRecordingPath: path
|
||||
});
|
||||
}
|
||||
|
||||
picker.close();
|
||||
},
|
||||
validateWriteable: true
|
||||
});
|
||||
}, [ config ]);
|
||||
|
||||
const showSeriesRecordingPathPicker = useCallback(() => {
|
||||
const picker = new DirectoryBrowser();
|
||||
|
||||
picker.show({
|
||||
callback: (path: string) => {
|
||||
if (path) {
|
||||
setConfig({
|
||||
...config,
|
||||
SeriesRecordingPath: path
|
||||
});
|
||||
}
|
||||
|
||||
picker.close();
|
||||
},
|
||||
validateWriteable: true
|
||||
});
|
||||
}, [ config ]);
|
||||
|
||||
const showPostProcessorPicker = useCallback(() => {
|
||||
const picker = new DirectoryBrowser();
|
||||
|
||||
picker.show({
|
||||
callback: (path: string) => {
|
||||
if (path) {
|
||||
setConfig({
|
||||
...config,
|
||||
RecordingPostProcessor: path
|
||||
});
|
||||
}
|
||||
|
||||
picker.close();
|
||||
},
|
||||
validateWriteable: true
|
||||
});
|
||||
}, [ config ]);
|
||||
|
||||
const onSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (config) {
|
||||
submit(
|
||||
JSON.stringify(config),
|
||||
{ method: 'post', encType: 'application/json' }
|
||||
);
|
||||
}
|
||||
}, [ config, submit ]);
|
||||
|
||||
if (isPending || !config) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='liveTvSettingsPage'
|
||||
title={globalize.translate('HeaderDVR')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
{isError ? (
|
||||
<Alert severity='error'>{globalize.translate('LiveTVPageLoadError')}</Alert>
|
||||
) : (
|
||||
<Form method='POST' onSubmit={onSubmit}>
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h1'>{globalize.translate('HeaderDVR')}</Typography>
|
||||
|
||||
{(!isSubmitting && actionData?.isSaved) && (
|
||||
<Alert severity='success'>
|
||||
{globalize.translate('SettingsSaved')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
select
|
||||
name='GuideDays'
|
||||
label={globalize.translate('LabelNumberOfGuideDays')}
|
||||
helperText={globalize.translate('LabelNumberOfGuideDaysHelp')}
|
||||
value={config.GuideDays || ''}
|
||||
onChange={onChange}
|
||||
slotProps={{
|
||||
select: {
|
||||
displayEmpty: true
|
||||
},
|
||||
|
||||
inputLabel: {
|
||||
shrink: true
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value=''>{globalize.translate('Auto')}</MenuItem>
|
||||
<MenuItem value='1'>1</MenuItem>
|
||||
<MenuItem value='2'>2</MenuItem>
|
||||
<MenuItem value='3'>3</MenuItem>
|
||||
<MenuItem value='4'>4</MenuItem>
|
||||
<MenuItem value='5'>5</MenuItem>
|
||||
<MenuItem value='6'>6</MenuItem>
|
||||
<MenuItem value='7'>7</MenuItem>
|
||||
<MenuItem value='8'>8</MenuItem>
|
||||
<MenuItem value='9'>9</MenuItem>
|
||||
<MenuItem value='10'>10</MenuItem>
|
||||
<MenuItem value='11'>11</MenuItem>
|
||||
<MenuItem value='12'>12</MenuItem>
|
||||
<MenuItem value='13'>13</MenuItem>
|
||||
<MenuItem value='14'>14</MenuItem>
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
name='RecordingPath'
|
||||
label={globalize.translate('LabelRecordingPath')}
|
||||
helperText={globalize.translate('LabelRecordingPathHelp')}
|
||||
value={config.RecordingPath}
|
||||
onChange={onChange}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
<IconButton edge='end' onClick={showRecordingPathPicker}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
},
|
||||
|
||||
inputLabel: {
|
||||
shrink: !!config.RecordingPath
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name='MovieRecordingPath'
|
||||
label={globalize.translate('LabelMovieRecordingPath')}
|
||||
value={config.MovieRecordingPath}
|
||||
onChange={onChange}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
<IconButton edge='end' onClick={showMovieRecordingPathPicker}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
},
|
||||
|
||||
inputLabel: {
|
||||
shrink: !!config.MovieRecordingPath
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name='SeriesRecordingPath'
|
||||
label={globalize.translate('LabelSeriesRecordingPath')}
|
||||
value={config.SeriesRecordingPath}
|
||||
onChange={onChange}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
<IconButton edge='end' onClick={showSeriesRecordingPathPicker}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
},
|
||||
|
||||
inputLabel: {
|
||||
shrink: !!config.SeriesRecordingPath
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography variant='h2'>{globalize.translate('HeaderDefaultRecordingSettings')}</Typography>
|
||||
|
||||
<TextField
|
||||
name='PrePaddingMinutes'
|
||||
label={globalize.translate('LabelStartWhenPossible')}
|
||||
value={prePaddingMinutes}
|
||||
onChange={onPrePaddingMinutesChange}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
<Typography variant='body1' color='text.secondary'>{globalize.translate('MinutesBefore')}</Typography>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name='PostPaddingMinutes'
|
||||
label={globalize.translate('LabelStopWhenPossible')}
|
||||
value={postPaddingMinutes}
|
||||
onChange={onPostPaddingMinutesChange}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
<Typography variant='body1' color='text.secondary'>{globalize.translate('MinutesAfter')}</Typography>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography variant='h2'>{globalize.translate('HeaderRecordingPostProcessing')}</Typography>
|
||||
|
||||
<TextField
|
||||
name='RecordingPostProcessor'
|
||||
label={globalize.translate('LabelPostProcessor')}
|
||||
value={config.RecordingPostProcessor}
|
||||
onChange={onChange}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
<IconButton edge='end' onClick={showPostProcessorPicker}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
},
|
||||
|
||||
inputLabel: {
|
||||
shrink: !!config.RecordingPostProcessor
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name='RecordingPostProcessorArguments'
|
||||
label={globalize.translate('LabelPostProcessorArguments')}
|
||||
helperText={globalize.translate('LabelPostProcessorArgumentsHelp')}
|
||||
value={config.RecordingPostProcessorArguments}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<Typography variant='h2'>{globalize.translate('HeaderRecordingMetadataSaving')}</Typography>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name='SaveRecordingNFO'
|
||||
checked={config.SaveRecordingNFO}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('SaveRecordingNFO')}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('SaveRecordingNFOHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name='SaveRecordingImages'
|
||||
checked={config.SaveRecordingImages}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('SaveRecordingImages')}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('SaveRecordingImagesHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<Button type='submit' size='large'>
|
||||
{globalize.translate('Save')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Form>
|
||||
)}
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'LiveTvRecordingsPage';
|
||||
@@ -10,7 +10,8 @@ import ButtonGroup from '@mui/material/ButtonGroup';
|
||||
import Container from '@mui/material/Container';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { ContentCopy, FileDownload } from '@mui/icons-material';
|
||||
import ContentCopy from '@mui/icons-material/ContentCopy';
|
||||
import FileDownload from '@mui/icons-material/FileDownload';
|
||||
import globalize from 'lib/globalize';
|
||||
import toast from 'components/toast/toast';
|
||||
import { copy } from 'scripts/clipboard';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-a
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
@@ -12,7 +13,6 @@ import Checkbox from '@mui/material/Checkbox';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { useServerLogs } from 'apps/dashboard/features/logs/api/useServerLogs';
|
||||
import { useConfiguration } from 'hooks/useConfiguration';
|
||||
import type { ServerConfiguration } from '@jellyfin/sdk/lib/generated-client/models/server-configuration';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
@@ -11,7 +12,6 @@ import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'rea
|
||||
import { ActionData } from 'types/actionData';
|
||||
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
@@ -66,7 +66,7 @@ export const Component = () => {
|
||||
<Box className='content-primary'>
|
||||
<Form method='POST'>
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h2'>
|
||||
<Typography variant='h1'>
|
||||
{globalize.translate('ButtonResume')}
|
||||
</Typography>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
@@ -8,7 +9,6 @@ import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
@@ -57,7 +57,7 @@ export const Component = () => {
|
||||
<Box className='content-primary'>
|
||||
<Form method='POST'>
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h2'>
|
||||
<Typography variant='h1'>
|
||||
{globalize.translate('TabStreaming')}
|
||||
</Typography>
|
||||
|
||||
|
||||
@@ -0,0 +1,896 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Link from '@mui/material/Link';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import FormGroup from '@mui/material/FormGroup';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import DirectoryBrowser from 'components/directorybrowser/directorybrowser';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Button from '@mui/material/Button';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation, useSubmit } from 'react-router-dom';
|
||||
import { QUERY_KEY, useNamedConfiguration } from 'hooks/useNamedConfiguration';
|
||||
import type { EncodingOptions } from '@jellyfin/sdk/lib/generated-client/models/encoding-options';
|
||||
import { HardwareAccelerationType } from '@jellyfin/sdk/lib/generated-client/models/hardware-acceleration-type';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { ActionData } from 'types/actionData';
|
||||
import { CODECS, HEVC_REXT_DECODING_TYPES, HEVC_VP9_HW_DECODING_TYPES } from 'apps/dashboard/features/playback/constants/codecs';
|
||||
import SimpleAlert from 'components/SimpleAlert';
|
||||
|
||||
const CONFIG_KEY = 'encoding';
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const api = ServerConnections.getCurrentApi();
|
||||
if (!api) throw new Error('No Api instance available');
|
||||
|
||||
const data = await request.json() as EncodingOptions;
|
||||
|
||||
await getConfigurationApi(api)
|
||||
.updateNamedConfiguration({ key: CONFIG_KEY, body: data });
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [QUERY_KEY, CONFIG_KEY]
|
||||
});
|
||||
|
||||
return {
|
||||
isSaved: true
|
||||
};
|
||||
};
|
||||
|
||||
export const Component = () => {
|
||||
const { data: initialConfig, isPending, isError } = useNamedConfiguration<EncodingOptions>(CONFIG_KEY);
|
||||
const [ config, setConfig ] = useState<EncodingOptions | null>(null);
|
||||
const navigation = useNavigation();
|
||||
const actionData = useActionData() as ActionData | undefined;
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
const [ isAlertOpen, setIsAlertOpen ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialConfig && config == null) {
|
||||
setConfig(initialConfig);
|
||||
}
|
||||
}, [ initialConfig, config ]);
|
||||
|
||||
const onConfigChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
setConfig({
|
||||
...config,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
}, [ config ]);
|
||||
|
||||
const onCheckboxChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setConfig({
|
||||
...config,
|
||||
[e.target.name]: e.target.checked
|
||||
});
|
||||
}, [ config ]);
|
||||
|
||||
const onCodecChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (config?.HardwareDecodingCodecs) {
|
||||
if (e.target.checked) {
|
||||
setConfig({
|
||||
...config,
|
||||
HardwareDecodingCodecs: [
|
||||
...config.HardwareDecodingCodecs,
|
||||
e.target.name
|
||||
]
|
||||
});
|
||||
} else {
|
||||
setConfig({
|
||||
...config,
|
||||
HardwareDecodingCodecs: config.HardwareDecodingCodecs.filter(v => v !== e.target.name)
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [ config ]);
|
||||
|
||||
const onAlertClose = useCallback(() => {
|
||||
setIsAlertOpen(false);
|
||||
}, []);
|
||||
|
||||
const onSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (config) {
|
||||
setIsAlertOpen(true);
|
||||
submit(
|
||||
{ ...config },
|
||||
{ method: 'post', encType: 'application/json' }
|
||||
);
|
||||
}
|
||||
}, [ config, submit ]);
|
||||
|
||||
const showTranscodingPathPicker = useCallback(() => {
|
||||
const picker = new DirectoryBrowser();
|
||||
|
||||
picker.show({
|
||||
callback: (path: string) => {
|
||||
setConfig({
|
||||
...config,
|
||||
TranscodingTempPath: path
|
||||
});
|
||||
|
||||
picker.close();
|
||||
},
|
||||
validateWriteable: true,
|
||||
header: globalize.translate('HeaderSelectTranscodingPath'),
|
||||
instruction: globalize.translate('HeaderSelectTranscodingPathHelp')
|
||||
});
|
||||
}, [ config ]);
|
||||
|
||||
const showFallbackFontPathPicker = useCallback(() => {
|
||||
const picker = new DirectoryBrowser();
|
||||
|
||||
picker.show({
|
||||
callback: (path: string) => {
|
||||
setConfig({
|
||||
...config,
|
||||
FallbackFontPath: path
|
||||
});
|
||||
|
||||
picker.close();
|
||||
},
|
||||
header: globalize.translate('HeaderSelectFallbackFontPath'),
|
||||
instruction: globalize.translate('HeaderSelectFallbackFontPathHelp')
|
||||
});
|
||||
}, [ config ]);
|
||||
|
||||
const hardwareAccelType = config?.HardwareAccelerationType || HardwareAccelerationType.None;
|
||||
const isHwaSelected = [ 'amf', 'nvenc', 'qsv', 'vaapi', 'rkmpp', 'videotoolbox' ].includes(hardwareAccelType);
|
||||
|
||||
const availableCodecs = useMemo(() => (
|
||||
CODECS.filter(codec => codec.types.includes(hardwareAccelType))
|
||||
), [hardwareAccelType]);
|
||||
|
||||
if (isPending || !config) return <Loading />;
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='encodingSettingsPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
title={globalize.translate('TitlePlayback')}
|
||||
>
|
||||
<SimpleAlert
|
||||
open={isAlertOpen}
|
||||
onClose={onAlertClose}
|
||||
title={globalize.translate('TitleHardwareAcceleration')}
|
||||
text={globalize.translate('HardwareAccelerationWarning')}
|
||||
/>
|
||||
<Box className='content-primary'>
|
||||
{isError ? (
|
||||
<Alert severity='error'>{globalize.translate('TranscodingLoadError')}</Alert>
|
||||
) : (
|
||||
<Form method='POST' onSubmit={onSubmit}>
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h1'>{globalize.translate('Transcoding')}</Typography>
|
||||
|
||||
{!isSubmitting && actionData?.isSaved && (
|
||||
<Alert severity='success'>
|
||||
{globalize.translate('SettingsSaved')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
name='HardwareAccelerationType'
|
||||
select
|
||||
label={globalize.translate('LabelHardwareAccelerationType')}
|
||||
value={config.HardwareAccelerationType}
|
||||
onChange={onConfigChange}
|
||||
helperText={(
|
||||
<Link href='https://jellyfin.org/docs/general/administration/hardware-acceleration' target='_blank'>
|
||||
{globalize.translate('LabelHardwareAccelerationTypeHelp')}
|
||||
</Link>
|
||||
)}
|
||||
>
|
||||
<MenuItem value='none'>{globalize.translate('None')}</MenuItem>
|
||||
<MenuItem value='amf'>AMD AMF</MenuItem>
|
||||
<MenuItem value='nvenc'>Nvidia NVENC</MenuItem>
|
||||
<MenuItem value='qsv'>Intel Quicksync (QSV)</MenuItem>
|
||||
<MenuItem value='vaapi'>Video Acceleration API (VAAPI)</MenuItem>
|
||||
<MenuItem value='rkmpp'>Rockchip MPP (RKMPP)</MenuItem>
|
||||
<MenuItem value='videotoolbox'>Apple VideoToolBox</MenuItem>
|
||||
<MenuItem value='v4l2m2m'>Video4Linux2 (V4L2)</MenuItem>
|
||||
</TextField>
|
||||
|
||||
{hardwareAccelType === 'vaapi' && (
|
||||
<TextField
|
||||
name='VaapiDevice'
|
||||
label={globalize.translate('LabelVaapiDevice')}
|
||||
value={config.VaapiDevice}
|
||||
onChange={onConfigChange}
|
||||
helperText={globalize.translate('LabelVaapiDeviceHelp')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hardwareAccelType === 'qsv' && (
|
||||
<TextField
|
||||
name='QsvDevice'
|
||||
label={globalize.translate('LabelQsvDevice')}
|
||||
value={config.QsvDevice}
|
||||
onChange={onConfigChange}
|
||||
helperText={globalize.translate('LabelQsvDeviceHelp')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hardwareAccelType !== 'none' && (
|
||||
<>
|
||||
<Typography variant='h3'>{globalize.translate('LabelEnableHardwareDecodingFor')}</Typography>
|
||||
<FormGroup>
|
||||
{availableCodecs.map(codec => (
|
||||
<FormControlLabel
|
||||
key={codec.name}
|
||||
label={codec.name}
|
||||
control={
|
||||
<Checkbox
|
||||
name={codec.codec}
|
||||
checked={(config.HardwareDecodingCodecs || []).includes(codec.codec)}
|
||||
onChange={onCodecChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{HEVC_VP9_HW_DECODING_TYPES.includes(hardwareAccelType) && (
|
||||
<FormControlLabel
|
||||
label={'HEVC 10bit'}
|
||||
control={
|
||||
<Checkbox
|
||||
name={'EnableDecodingColorDepth10Hevc'}
|
||||
checked={config.EnableDecodingColorDepth10Hevc}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{HEVC_VP9_HW_DECODING_TYPES.includes(hardwareAccelType) && (
|
||||
<FormControlLabel
|
||||
label={'VP9 10bit'}
|
||||
control={
|
||||
<Checkbox
|
||||
name={'EnableDecodingColorDepth10Vp9'}
|
||||
checked={config.EnableDecodingColorDepth10Vp9}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{HEVC_REXT_DECODING_TYPES.includes(hardwareAccelType) && (
|
||||
<FormControlLabel
|
||||
label={'HEVC RExt 8/10bit'}
|
||||
control={
|
||||
<Checkbox
|
||||
name={'EnableDecodingColorDepth10HevcRext'}
|
||||
checked={config.EnableDecodingColorDepth10HevcRext}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{HEVC_REXT_DECODING_TYPES.includes(hardwareAccelType) && (
|
||||
<FormControlLabel
|
||||
label={'HEVC RExt 12bit'}
|
||||
control={
|
||||
<Checkbox
|
||||
name={'EnableDecodingColorDepth12HevcRext'}
|
||||
checked={config.EnableDecodingColorDepth12HevcRext}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</FormGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hardwareAccelType === 'nvenc' && (
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('EnableEnhancedNvdecDecoder')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='EnableEnhancedNvdecDecoder'
|
||||
checked={config.EnableEnhancedNvdecDecoder}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('EnableEnhancedNvdecDecoderHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{hardwareAccelType === 'qsv' && (
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('PreferSystemNativeHwDecoder')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='PreferSystemNativeHwDecoder'
|
||||
checked={config.PreferSystemNativeHwDecoder}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{hardwareAccelType !== 'none' && (
|
||||
<FormControl variant='standard'>
|
||||
<Typography variant='h3'>{globalize.translate('LabelHardwareEncodingOptions')}</Typography>
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('EnableHardwareEncoding')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='EnableHardwareEncoding'
|
||||
checked={config.EnableHardwareEncoding}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{(hardwareAccelType === 'qsv' || hardwareAccelType === 'vaapi') && (
|
||||
<>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('EnableIntelLowPowerH264HwEncoder')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='EnableIntelLowPowerH264HwEncoder'
|
||||
checked={config.EnableIntelLowPowerH264HwEncoder}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('EnableIntelLowPowerHevcHwEncoder')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='EnableIntelLowPowerHevcHwEncoder'
|
||||
checked={config.EnableIntelLowPowerHevcHwEncoder}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormHelperText>
|
||||
<Link href='https://jellyfin.org/docs/general/post-install/transcoding/hardware-acceleration/intel#configure-and-verify-lp-mode-on-linux' target='_blank'>
|
||||
{globalize.translate('IntelLowPowerEncHelp')}
|
||||
</Link>
|
||||
</FormHelperText>
|
||||
</>
|
||||
)}
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<FormControl variant='standard'>
|
||||
<Typography variant='h3'>{globalize.translate('LabelEncodingFormatOptions')}</Typography>
|
||||
<FormHelperText>{globalize.translate('EncodingFormatHelp')}</FormHelperText>
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('AllowHevcEncoding')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='AllowHevcEncoding'
|
||||
checked={config.AllowHevcEncoding}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('AllowAv1Encoding')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='AllowAv1Encoding'
|
||||
checked={config.AllowAv1Encoding}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
|
||||
{(hardwareAccelType === 'qsv' || hardwareAccelType === 'vaapi') && (
|
||||
<>
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('EnableVppTonemapping')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='EnableVppTonemapping'
|
||||
checked={config.EnableVppTonemapping}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('AllowVppTonemappingHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
name='VppTonemappingBrightness'
|
||||
value={config.VppTonemappingBrightness}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelVppTonemappingBrightness')}
|
||||
helperText={globalize.translate('LabelVppTonemappingBrightnessHelp')}
|
||||
type='number'
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 0.00001
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name='VppTonemappingContrast'
|
||||
value={config.VppTonemappingContrast}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelVppTonemappingContrast')}
|
||||
helperText={globalize.translate('LabelVppTonemappingContrastHelp')}
|
||||
type='number'
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 1,
|
||||
max: 2,
|
||||
step: 0.00001
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hardwareAccelType === 'videotoolbox' && (
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('EnableVideoToolboxTonemapping')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='EnableVideoToolboxTonemapping'
|
||||
checked={config.EnableVideoToolboxTonemapping}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('AllowVideoToolboxTonemappingHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{(hardwareAccelType === 'none' || isHwaSelected) && (
|
||||
<>
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('EnableTonemapping')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='EnableTonemapping'
|
||||
checked={config.EnableTonemapping}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate(isHwaSelected ? 'AllowTonemappingHelp' : 'AllowTonemappingSoftwareHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
name='TonemappingAlgorithm'
|
||||
select
|
||||
label={globalize.translate('LabelTonemappingAlgorithm')}
|
||||
value={config.TonemappingAlgorithm}
|
||||
onChange={onConfigChange}
|
||||
helperText={(
|
||||
<Link href='https://ffmpeg.org/ffmpeg-all.html#tonemap_005fopencl' target='_blank'>
|
||||
{globalize.translate('TonemappingAlgorithmHelp')}
|
||||
</Link>
|
||||
)}
|
||||
>
|
||||
<MenuItem value='none'>{globalize.translate('None')}</MenuItem>
|
||||
<MenuItem value='clip'>Clip</MenuItem>
|
||||
<MenuItem value='linear'>Linear</MenuItem>
|
||||
<MenuItem value='gamma'>Gamma</MenuItem>
|
||||
<MenuItem value='reinhard'>Reinhard</MenuItem>
|
||||
<MenuItem value='hable'>Hable</MenuItem>
|
||||
<MenuItem value='mobius'>Mobius</MenuItem>
|
||||
<MenuItem value='bt2390'>BT.2390</MenuItem>
|
||||
</TextField>
|
||||
|
||||
{isHwaSelected && (
|
||||
<TextField
|
||||
name='TonemappingMode'
|
||||
select
|
||||
value={config.TonemappingMode}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelTonemappingMode')}
|
||||
helperText={globalize.translate('TonemappingModeHelp')}
|
||||
>
|
||||
<MenuItem value='auto'>{globalize.translate('Auto')}</MenuItem>
|
||||
<MenuItem value='max'>MAX</MenuItem>
|
||||
<MenuItem value='rgb'>RGB</MenuItem>
|
||||
<MenuItem value='lum'>LUM</MenuItem>
|
||||
<MenuItem value='itp'>ITP</MenuItem>
|
||||
</TextField>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
name='TonemappingRange'
|
||||
select
|
||||
value={config.TonemappingRange}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelTonemappingRange')}
|
||||
helperText={globalize.translate('TonemappingRangeHelp')}
|
||||
>
|
||||
<MenuItem value='auto'>{globalize.translate('Auto')}</MenuItem>
|
||||
<MenuItem value='tv'>TV</MenuItem>
|
||||
<MenuItem value='pc'>PC</MenuItem>
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
name='TonemappingDesat'
|
||||
value={config.TonemappingDesat}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelTonemappingDesat')}
|
||||
helperText={globalize.translate('LabelTonemappingDesatHelp')}
|
||||
type='number'
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
step: 0.00001
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name='TonemappingPeak'
|
||||
value={config.TonemappingPeak}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelTonemappingPeak')}
|
||||
helperText={globalize.translate('LabelTonemappingPeakHelp')}
|
||||
type='number'
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
step: 0.00001
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name='TonemappingParam'
|
||||
value={config.TonemappingParam || ''}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelTonemappingParam')}
|
||||
helperText={globalize.translate('LabelTonemappingParamHelp')}
|
||||
type='number'
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
step: 0.00001
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
name='EncodingThreadCount'
|
||||
value={config.EncodingThreadCount}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelTranscodingThreadCount')}
|
||||
helperText={globalize.translate('LabelTranscodingThreadCountHelp')}
|
||||
select
|
||||
>
|
||||
<MenuItem value='-1'>{globalize.translate('Auto')}</MenuItem>
|
||||
<MenuItem value='1'>1</MenuItem>
|
||||
<MenuItem value='2'>2</MenuItem>
|
||||
<MenuItem value='3'>3</MenuItem>
|
||||
<MenuItem value='4'>4</MenuItem>
|
||||
<MenuItem value='5'>5</MenuItem>
|
||||
<MenuItem value='6'>6</MenuItem>
|
||||
<MenuItem value='7'>7</MenuItem>
|
||||
<MenuItem value='8'>8</MenuItem>
|
||||
<MenuItem value='9'>9</MenuItem>
|
||||
<MenuItem value='10'>10</MenuItem>
|
||||
<MenuItem value='11'>11</MenuItem>
|
||||
<MenuItem value='12'>12</MenuItem>
|
||||
<MenuItem value='13'>13</MenuItem>
|
||||
<MenuItem value='14'>14</MenuItem>
|
||||
<MenuItem value='15'>15</MenuItem>
|
||||
<MenuItem value='16'>16</MenuItem>
|
||||
<MenuItem value='0'>{globalize.translate('OptionMax')}</MenuItem>
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
name='FFmpegPath'
|
||||
value={config.EncoderAppPathDisplay}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelffmpegPath')}
|
||||
helperText={globalize.translate('LabelffmpegPathHelp')}
|
||||
disabled
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name='TranscodingTempPath'
|
||||
value={config.TranscodingTempPath}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelTranscodePath')}
|
||||
helperText={globalize.translate('LabelTranscodingTempPathHelp')}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
<IconButton edge='end' onClick={showTranscodingPathPicker}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name='FallbackFontPath'
|
||||
value={config.FallbackFontPath}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelFallbackFontPath')}
|
||||
helperText={
|
||||
<Link href='https://jellyfin.org/docs/general/administration/configuration#fonts' target='_blank'>
|
||||
{globalize.translate('LabelFallbackFontPathHelp')}
|
||||
</Link>
|
||||
}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
<IconButton edge='end' onClick={showFallbackFontPathPicker}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('EnableFallbackFont')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='EnableFallbackFont'
|
||||
checked={config.EnableFallbackFont}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('EnableFallbackFontHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('LabelEnableAudioVbr')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='EnableAudioVbr'
|
||||
checked={config.EnableAudioVbr}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('LabelEnableAudioVbrHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
name='DownMixAudioBoost'
|
||||
value={config.DownMixAudioBoost}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelDownMixAudioScale')}
|
||||
helperText={globalize.translate('LabelDownMixAudioScaleHelp')}
|
||||
type='number'
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
required: true,
|
||||
min: 0.5,
|
||||
max: 3,
|
||||
step: 0.1
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name='DownMixStereoAlgorithm'
|
||||
value={config.DownMixStereoAlgorithm}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelStereoDownmixAlgorithm')}
|
||||
helperText={globalize.translate('StereoDownmixAlgorithmHelp')}
|
||||
select
|
||||
>
|
||||
<MenuItem value='None'>{globalize.translate('None')}</MenuItem>
|
||||
<MenuItem value='Dave750'>Dave750</MenuItem>
|
||||
<MenuItem value='NightmodeDialogue'>NightmodeDialogue</MenuItem>
|
||||
<MenuItem value='Rfc7845'>RFC7845</MenuItem>
|
||||
<MenuItem value='Ac4'>AC-4</MenuItem>
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
name='MaxMuxingQueueSize'
|
||||
value={config.MaxMuxingQueueSize}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelMaxMuxingQueueSize')}
|
||||
helperText={globalize.translate('LabelMaxMuxingQueueSizeHelp')}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name='EncoderPreset'
|
||||
value={config.EncoderPreset}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelEncoderPreset')}
|
||||
helperText={globalize.translate('EncoderPresetHelp')}
|
||||
select
|
||||
>
|
||||
<MenuItem value='auto'>{globalize.translate('Auto')}</MenuItem>
|
||||
<MenuItem value='veryslow'>veryslow</MenuItem>
|
||||
<MenuItem value='slower'>slower</MenuItem>
|
||||
<MenuItem value='slow'>slow</MenuItem>
|
||||
<MenuItem value='medium'>medium</MenuItem>
|
||||
<MenuItem value='fast'>fast</MenuItem>
|
||||
<MenuItem value='faster'>faster</MenuItem>
|
||||
<MenuItem value='veryfast'>veryfast</MenuItem>
|
||||
<MenuItem value='superfast'>superfast</MenuItem>
|
||||
<MenuItem value='ultrafast'>ultrafast</MenuItem>
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
name='H265Crf'
|
||||
value={config.H265Crf}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelH265Crf')}
|
||||
type='number'
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
max: 51,
|
||||
step: 1
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name='H264Crf'
|
||||
value={config.H264Crf}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelH264Crf')}
|
||||
helperText={globalize.translate('H264CrfHelp')}
|
||||
type='number'
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
max: 51,
|
||||
step: 1
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name='DeinterlaceMethod'
|
||||
value={config.DeinterlaceMethod}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelDeinterlaceMethod')}
|
||||
helperText={globalize.translate('DeinterlaceMethodHelp')}
|
||||
select
|
||||
>
|
||||
<MenuItem value='yadif'>{globalize.translate('Yadif')}</MenuItem>
|
||||
<MenuItem value='bwdif'>{globalize.translate('Bwdif')}</MenuItem>
|
||||
</TextField>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('UseDoubleRateDeinterlacing')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='DeinterlaceDoubleRate'
|
||||
checked={config.DeinterlaceDoubleRate}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('UseDoubleRateDeinterlacingHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('AllowOnTheFlySubtitleExtraction')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='EnableSubtitleExtraction'
|
||||
checked={config.EnableSubtitleExtraction}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('AllowOnTheFlySubtitleExtractionHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('AllowFfmpegThrottling')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='EnableThrottling'
|
||||
checked={config.EnableThrottling}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('AllowFfmpegThrottlingHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('AllowSegmentDeletion')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='EnableSegmentDeletion'
|
||||
checked={config.EnableSegmentDeletion}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('AllowSegmentDeletionHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
name='ThrottleDelaySeconds'
|
||||
value={config.ThrottleDelaySeconds}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelThrottleDelaySeconds')}
|
||||
helperText={globalize.translate('LabelThrottleDelaySecondsHelp')}
|
||||
type='number'
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 10,
|
||||
max: 3600,
|
||||
step: 1
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name='SegmentKeepSeconds'
|
||||
value={config.SegmentKeepSeconds}
|
||||
onChange={onConfigChange}
|
||||
label={globalize.translate('LabelSegmentKeepSeconds')}
|
||||
helperText={globalize.translate('LabelSegmentKeepSecondsHelp')}
|
||||
type='number'
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 10,
|
||||
max: 3600,
|
||||
step: 1
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button type='submit' size='large'>
|
||||
{globalize.translate('Save')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Form>
|
||||
)}
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'TranscodingPage';
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
|
||||
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
|
||||
import Page from 'components/Page';
|
||||
@@ -16,7 +17,6 @@ import MenuItem from '@mui/material/MenuItem';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||
import { TrickplayScanBehavior } from '@jellyfin/sdk/lib/generated-client/models/trickplay-scan-behavior';
|
||||
import { ProcessPriorityClass } from '@jellyfin/sdk/lib/generated-client/models/process-priority-class';
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { usePackages } from 'apps/dashboard/features/plugins/api/usePackages';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import getPackageCategories from 'apps/dashboard/features/plugins/utils/getPackageCategories';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import getPackagesByCategory from 'apps/dashboard/features/plugins/utils/getPackagesByCategory';
|
||||
import PackageCard from 'apps/dashboard/features/plugins/components/PackageCard';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Settings from '@mui/icons-material/Settings';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels';
|
||||
|
||||
export const Component = () => {
|
||||
const { data: packages, isPending: isPackagesPending } = usePackages();
|
||||
const [ searchQuery, setSearchQuery ] = useState('');
|
||||
|
||||
const filteredPackages = useMemo(() => {
|
||||
return packages?.filter(i => i.name?.toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase()));
|
||||
}, [ packages, searchQuery ]);
|
||||
|
||||
const packageCategories = getPackageCategories(filteredPackages);
|
||||
|
||||
const updateSearchQuery = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
}, []);
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const categoryKey = category.replace(/\s/g, '');
|
||||
|
||||
if (CATEGORY_LABELS[categoryKey]) {
|
||||
return globalize.translate(CATEGORY_LABELS[categoryKey]);
|
||||
}
|
||||
|
||||
console.warn('[AvailablePlugins] unmapped category label', category);
|
||||
return category;
|
||||
};
|
||||
|
||||
if (isPackagesPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='pluginCatalogPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
title={globalize.translate('TabCatalog')}
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction='row' gap={1}>
|
||||
<Typography variant='h1'>{globalize.translate('TabCatalog')}</Typography>
|
||||
<IconButton
|
||||
component={Link}
|
||||
to='/dashboard/plugins/repositories'
|
||||
sx={{
|
||||
backgroundColor: 'background.paper'
|
||||
}}
|
||||
>
|
||||
<Settings />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
label={globalize.translate('Search')}
|
||||
value={searchQuery}
|
||||
onChange={updateSearchQuery}
|
||||
/>
|
||||
|
||||
{packageCategories.map(category => (
|
||||
<Stack key={category} spacing={2}>
|
||||
<Typography variant='h2'>{getCategoryLabel(category)}</Typography>
|
||||
|
||||
<Grid container spacing={2} columns={{ xs: 1, sm: 4, md: 9, lg: 8, xl: 10 }}>
|
||||
{getPackagesByCategory(filteredPackages, category).map(pkg => (
|
||||
<Grid key={pkg.guid} size={{ xs: 1, sm: 2, md: 3, lg: 2 }}>
|
||||
<PackageCard
|
||||
pkg={pkg}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'PluginsCatalogPage';
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { usePlugins } from 'apps/dashboard/features/plugins/api/usePlugins';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
import PluginCard from 'apps/dashboard/features/plugins/components/PluginCard';
|
||||
import { useConfigurationPages } from 'apps/dashboard/features/plugins/api/useConfigurationPages';
|
||||
import { findBestConfigurationPage } from 'apps/dashboard/features/plugins/api/configurationPage';
|
||||
|
||||
export const Component = () => {
|
||||
const {
|
||||
data: plugins,
|
||||
isPending,
|
||||
isError
|
||||
} = usePlugins();
|
||||
const {
|
||||
data: configurationPages,
|
||||
isError: isConfigurationPagesError,
|
||||
isPending: isConfigurationPagesPending
|
||||
} = useConfigurationPages();
|
||||
const [ searchQuery, setSearchQuery ] = useState('');
|
||||
|
||||
const onSearchChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
setSearchQuery(event.target.value);
|
||||
}, []);
|
||||
|
||||
const filteredPlugins = useMemo(() => {
|
||||
if (plugins) {
|
||||
return plugins.filter(i => i.Name?.toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase()));
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}, [ plugins, searchQuery ]);
|
||||
|
||||
if (isPending || isConfigurationPagesPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='pluginsPage'
|
||||
title={globalize.translate('TabPlugins')}
|
||||
className='type-interior mainAnimatedPage'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
{isError || isConfigurationPagesError ? (
|
||||
<Alert severity='error'>{globalize.translate('PluginsLoadError')}</Alert>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h1'>
|
||||
{globalize.translate('TabMyPlugins')}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label={globalize.translate('Search')}
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Grid container spacing={2} columns={{ xs: 1, sm: 4, md: 9, lg: 8, xl: 10 }}>
|
||||
{filteredPlugins.map(plugin => (
|
||||
<Grid key={plugin.Id} size={{ xs: 1, sm: 2, md: 3, lg: 2 }}>
|
||||
<PluginCard
|
||||
plugin={plugin}
|
||||
configurationPage={findBestConfigurationPage(configurationPages, plugin.Id || '')}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'InstalledPlugins';
|
||||
@@ -64,13 +64,13 @@ const PluginPage: FC = () => {
|
||||
const {
|
||||
data: configurationPages,
|
||||
isError: isConfigurationPagesError,
|
||||
isLoading: isConfigurationPagesLoading
|
||||
isPending: isConfigurationPagesLoading
|
||||
} = useConfigurationPages();
|
||||
|
||||
const {
|
||||
data: packageInfo,
|
||||
isError: isPackageInfoError,
|
||||
isLoading: isPackageInfoLoading
|
||||
isPending: isPackageInfoLoading
|
||||
} = usePackageInfo(pluginName ? {
|
||||
name: pluginName,
|
||||
assemblyGuid: pluginId
|
||||
@@ -78,8 +78,8 @@ const PluginPage: FC = () => {
|
||||
|
||||
const {
|
||||
data: plugins,
|
||||
isLoading: isPluginsLoading,
|
||||
isError: isPluginsError
|
||||
isError: isPluginsError,
|
||||
isPending: isPluginsLoading
|
||||
} = usePlugins();
|
||||
|
||||
const isLoading =
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { useRepositories } from 'apps/dashboard/features/plugins/api/useRepositories';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import List from '@mui/material/List';
|
||||
import RepositoryListItem from 'apps/dashboard/features/plugins/components/RepositoryListItem';
|
||||
import type { RepositoryInfo } from '@jellyfin/sdk/lib/generated-client/models/repository-info';
|
||||
import { useSetRepositories } from 'apps/dashboard/features/plugins/api/useSetRepositories';
|
||||
import NewRepositoryForm from 'apps/dashboard/features/plugins/components/NewRepositoryForm';
|
||||
|
||||
export const Component = () => {
|
||||
const { data: repositories, isPending, isError } = useRepositories();
|
||||
const [ isRepositoryFormOpen, setIsRepositoryFormOpen ] = useState(false);
|
||||
const setRepositories = useSetRepositories();
|
||||
|
||||
const onDelete = useCallback((repository: RepositoryInfo) => {
|
||||
if (repositories) {
|
||||
setRepositories.mutate({
|
||||
repositoryInfo: repositories.filter(currentRepo => currentRepo.Url !== repository.Url)
|
||||
});
|
||||
}
|
||||
}, [ repositories, setRepositories ]);
|
||||
|
||||
const onRepositoryAdd = useCallback((repository: RepositoryInfo) => {
|
||||
if (repositories) {
|
||||
setRepositories.mutate({
|
||||
repositoryInfo: [
|
||||
...repositories,
|
||||
repository
|
||||
]
|
||||
}, {
|
||||
onSettled: () => {
|
||||
setIsRepositoryFormOpen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [ repositories, setRepositories ]);
|
||||
|
||||
const openRepositoryForm = useCallback(() => {
|
||||
setIsRepositoryFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const onRepositoryFormClose = useCallback(() => {
|
||||
setIsRepositoryFormOpen(false);
|
||||
}, []);
|
||||
|
||||
if (isPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='repositories'
|
||||
title={globalize.translate('TabRepositories')}
|
||||
className='type-interior mainAnimatedPage'
|
||||
>
|
||||
<NewRepositoryForm
|
||||
open={isRepositoryFormOpen}
|
||||
onClose={onRepositoryFormClose}
|
||||
onAdd={onRepositoryAdd}
|
||||
/>
|
||||
<Box className='content-primary'>
|
||||
{isError ? (
|
||||
<Alert severity='error'>{globalize.translate('RepositoriesPageLoadError')}</Alert>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h1'>{globalize.translate('TabRepositories')}</Typography>
|
||||
|
||||
<Button
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
startIcon={<AddIcon />}
|
||||
onClick={openRepositoryForm}
|
||||
>
|
||||
{globalize.translate('HeaderNewRepository')}
|
||||
</Button>
|
||||
|
||||
{repositories.length > 0 ? (
|
||||
<List sx={{ bgcolor: 'background.paper' }}>
|
||||
{repositories.map(repository => {
|
||||
return <RepositoryListItem
|
||||
key={repository.Url}
|
||||
repository={repository}
|
||||
onDelete={onDelete}
|
||||
/>;
|
||||
})}
|
||||
</List>
|
||||
) : (
|
||||
<Stack alignSelf='center' alignItems='center' maxWidth={'500px'} spacing={2}>
|
||||
<Typography variant='h2'>{globalize.translate('MessageNoRepositories')}</Typography>
|
||||
<Typography textAlign='center'>{globalize.translate('MessageAddRepository')}</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'PluginRepositoriesPage';
|
||||
@@ -9,8 +9,8 @@ import { useLocalizationOptions } from 'apps/dashboard/features/settings/api/use
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Page from 'components/Page';
|
||||
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
|
||||
import { useSystemInfo } from 'hooks/useSystemInfo';
|
||||
import globalize from 'lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
@@ -21,7 +21,6 @@ import Checkbox from '@mui/material/Checkbox';
|
||||
import Button from '@mui/material/Button';
|
||||
import Link from '@mui/material/Link';
|
||||
import DirectoryBrowser from 'components/directorybrowser/directorybrowser';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { ActionData } from 'types/actionData';
|
||||
@@ -64,11 +63,6 @@ export const Component = () => {
|
||||
isPending: isLocalizationOptionsPending,
|
||||
isError: isLocalizationOptionsError
|
||||
} = useLocalizationOptions();
|
||||
const {
|
||||
data: systemInfo,
|
||||
isPending: isSystemInfoPending,
|
||||
isError: isSystemInfoError
|
||||
} = useSystemInfo();
|
||||
|
||||
const navigation = useNavigation();
|
||||
const actionData = useActionData() as ActionData | undefined;
|
||||
@@ -120,13 +114,13 @@ export const Component = () => {
|
||||
}, [metadataPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSystemInfoPending && !isSystemInfoError) {
|
||||
setCachePath(systemInfo.CachePath);
|
||||
setMetadataPath(systemInfo.InternalMetadataPath);
|
||||
if (!isConfigPending && !isConfigError) {
|
||||
setCachePath(config.CachePath);
|
||||
setMetadataPath(config.MetadataPath);
|
||||
}
|
||||
}, [systemInfo, isSystemInfoPending, isSystemInfoError]);
|
||||
}, [config, isConfigPending, isConfigError]);
|
||||
|
||||
if (isConfigPending || isLocalizationOptionsPending || isSystemInfoPending) {
|
||||
if (isConfigPending || isLocalizationOptionsPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
@@ -137,7 +131,7 @@ export const Component = () => {
|
||||
className='type-interior mainAnimatedPage'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
{isConfigError || isLocalizationOptionsError || isSystemInfoError ? (
|
||||
{isConfigError || isLocalizationOptionsError ? (
|
||||
<Alert severity='error'>{globalize.translate('SettingsPageLoadError')}</Alert>
|
||||
) : (
|
||||
<Form method='POST'>
|
||||
@@ -154,7 +148,7 @@ export const Component = () => {
|
||||
name='ServerName'
|
||||
label={globalize.translate('LabelServerName')}
|
||||
helperText={globalize.translate('LabelServerNameHelp')}
|
||||
defaultValue={systemInfo.ServerName}
|
||||
defaultValue={config.ServerName}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
@@ -164,7 +158,7 @@ export const Component = () => {
|
||||
helperText={(
|
||||
<>
|
||||
<span>{globalize.translate('LabelDisplayLanguageHelp')}</span>
|
||||
<Link href='https://jellyfin.org/docs/general/contributing/#translating'>
|
||||
<Link href='https://jellyfin.org/docs/general/contributing/#translating' target='_blank'>
|
||||
{globalize.translate('LearnHowYouCanContribute')}
|
||||
</Link>
|
||||
</>
|
||||
|
||||
@@ -17,7 +17,7 @@ import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import SelectElement from '../../../../elements/SelectElement';
|
||||
import Page from '../../../../components/Page';
|
||||
import prompt from '../../../../components/prompt/prompt';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
|
||||
type NamedItem = {
|
||||
name: string;
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import ButtonGroup from '@mui/material/ButtonGroup/ButtonGroup';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
||||
import ViewListIcon from '@mui/icons-material/ViewList';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
import { LibraryViewSettings, ViewMode } from 'types/library';
|
||||
import { LibraryTab } from 'types/libraryTab';
|
||||
import ViewSettingsButton from './ViewSettingsButton';
|
||||
|
||||
interface GridListViewButtonProps {
|
||||
viewType: LibraryTab;
|
||||
libraryViewSettings: LibraryViewSettings;
|
||||
setLibraryViewSettings: React.Dispatch<React.SetStateAction<LibraryViewSettings>>;
|
||||
}
|
||||
|
||||
const GridListViewButton: FC<GridListViewButtonProps> = ({
|
||||
viewType,
|
||||
libraryViewSettings,
|
||||
setLibraryViewSettings
|
||||
}) => {
|
||||
const handleToggleCurrentView = useCallback(() => {
|
||||
setLibraryViewSettings((prevState) => ({
|
||||
...prevState,
|
||||
ViewMode:
|
||||
prevState.ViewMode === ViewMode.ListView ? ViewMode.GridView : ViewMode.ListView
|
||||
}));
|
||||
}, [setLibraryViewSettings]);
|
||||
|
||||
const isGridView = libraryViewSettings.ViewMode === ViewMode.GridView;
|
||||
|
||||
return (
|
||||
<ButtonGroup>
|
||||
{isGridView ? (
|
||||
<ViewSettingsButton
|
||||
viewType={viewType}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
setLibraryViewSettings={setLibraryViewSettings}
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
title={globalize.translate('GridView')}
|
||||
className='paper-icon-button-light autoSize'
|
||||
disabled={isGridView}
|
||||
onClick={handleToggleCurrentView}
|
||||
>
|
||||
<ViewModuleIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
title={globalize.translate('ListView')}
|
||||
className='paper-icon-button-light autoSize'
|
||||
disabled={!isGridView}
|
||||
onClick={handleToggleCurrentView}
|
||||
>
|
||||
<ViewListIcon />
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default GridListViewButton;
|
||||
@@ -2,9 +2,12 @@ import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/bas
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
|
||||
import React, { type FC, useCallback } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||
import type { Theme } from '@mui/material/styles';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import classNames from 'classnames';
|
||||
import React, { type FC, useCallback } from 'react';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { useLocalStorage } from 'hooks/useLocalStorage';
|
||||
@@ -31,8 +34,8 @@ import PlayAllButton from './PlayAllButton';
|
||||
import QueueButton from './QueueButton';
|
||||
import ShuffleButton from './ShuffleButton';
|
||||
import SortButton from './SortButton';
|
||||
import GridListViewButton from './GridListViewButton';
|
||||
import LibraryViewMenu from './LibraryViewMenu';
|
||||
import ViewSettingsButton from './ViewSettingsButton';
|
||||
|
||||
interface ItemsViewProps {
|
||||
viewType: LibraryTab;
|
||||
@@ -72,10 +75,11 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||
getSettingsKey(viewType, parentId),
|
||||
getDefaultLibraryViewSettings(viewType)
|
||||
);
|
||||
const isSmallScreen = useMediaQuery((t: Theme) => t.breakpoints.up('sm'));
|
||||
|
||||
const { __legacyApiClient__ } = useApi();
|
||||
const {
|
||||
isLoading,
|
||||
isPending,
|
||||
data: itemsResult,
|
||||
isPlaceholderData,
|
||||
refetch
|
||||
@@ -229,90 +233,142 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box className='padded-bottom-page'>
|
||||
<Box
|
||||
className={classNames(
|
||||
'padded-top padded-left padded-right padded-bottom',
|
||||
'padded-top padded-left padded-right',
|
||||
{ 'padded-right-withalphapicker': isAlphabetPickerEnabled }
|
||||
)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap'
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{ marginRight: 1 }}
|
||||
>
|
||||
<LibraryViewMenu />
|
||||
</Box>
|
||||
|
||||
<LibraryViewMenu />
|
||||
|
||||
{isBtnPlayAllEnabled && (
|
||||
<PlayAllButton
|
||||
item={item}
|
||||
items={items}
|
||||
viewType={viewType}
|
||||
hasFilters={hasFilters}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
/>
|
||||
)}
|
||||
{isBtnShuffleEnabled && totalRecordCount > 1 && (
|
||||
<ShuffleButton
|
||||
item={item}
|
||||
items={items}
|
||||
viewType={viewType}
|
||||
hasFilters={hasFilters}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
/>
|
||||
)}
|
||||
{isBtnQueueEnabled
|
||||
&& item
|
||||
&& playbackManager.canQueue(item) && (
|
||||
<QueueButton
|
||||
item={item}
|
||||
items={items}
|
||||
hasFilters={hasFilters}
|
||||
/>
|
||||
)}
|
||||
{isBtnSortEnabled && (
|
||||
<SortButton
|
||||
viewType={viewType}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
setLibraryViewSettings={setLibraryViewSettings}
|
||||
/>
|
||||
)}
|
||||
{isBtnFilterEnabled && (
|
||||
<FilterButton
|
||||
parentId={parentId}
|
||||
itemType={itemType}
|
||||
viewType={viewType}
|
||||
hasFilters={hasFilters}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
setLibraryViewSettings={setLibraryViewSettings}
|
||||
/>
|
||||
)}
|
||||
{isBtnNewCollectionEnabled && <NewCollectionButton />}
|
||||
{isBtnGridListEnabled && (
|
||||
<GridListViewButton
|
||||
viewType={viewType}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
setLibraryViewSettings={setLibraryViewSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPaginationEnabled && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
flexGrow: 1,
|
||||
order: 10
|
||||
}}
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: {
|
||||
xs: 1,
|
||||
sm: 0
|
||||
},
|
||||
marginRight: 1
|
||||
}}
|
||||
>
|
||||
<ButtonGroup
|
||||
color='inherit'
|
||||
variant='text'
|
||||
>
|
||||
{isBtnFilterEnabled && (
|
||||
<FilterButton
|
||||
parentId={parentId}
|
||||
itemType={itemType}
|
||||
viewType={viewType}
|
||||
hasFilters={hasFilters}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
setLibraryViewSettings={setLibraryViewSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isBtnSortEnabled && (
|
||||
<SortButton
|
||||
viewType={viewType}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
setLibraryViewSettings={setLibraryViewSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isBtnGridListEnabled && (
|
||||
<ViewSettingsButton
|
||||
viewType={viewType}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
setLibraryViewSettings={setLibraryViewSettings}
|
||||
/>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexGrow: {
|
||||
xs: 1,
|
||||
sm: 0
|
||||
},
|
||||
justifyContent: 'end'
|
||||
}}
|
||||
>
|
||||
{!isPending && (
|
||||
<>
|
||||
<ButtonGroup
|
||||
variant='contained'
|
||||
>
|
||||
{isBtnPlayAllEnabled && (
|
||||
<PlayAllButton
|
||||
item={item}
|
||||
items={items}
|
||||
viewType={viewType}
|
||||
hasFilters={hasFilters}
|
||||
isTextVisible={isSmallScreen}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isBtnShuffleEnabled && totalRecordCount > 1 && (
|
||||
<ShuffleButton
|
||||
item={item}
|
||||
items={items}
|
||||
viewType={viewType}
|
||||
hasFilters={hasFilters}
|
||||
isTextVisible={isSmallScreen && !isBtnPlayAllEnabled}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isBtnQueueEnabled && item && playbackManager.canQueue(item) && (
|
||||
<QueueButton
|
||||
item={item}
|
||||
items={items}
|
||||
hasFilters={hasFilters}
|
||||
isTextVisible={isSmallScreen && !isBtnPlayAllEnabled && !isBtnShuffleEnabled}
|
||||
/>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
|
||||
{isBtnNewCollectionEnabled && <NewCollectionButton isTextVisible={isSmallScreen} />}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'end',
|
||||
flexBasis: {
|
||||
xs: '100%',
|
||||
sm: 'auto'
|
||||
},
|
||||
flexGrow: 1,
|
||||
marginTop: {
|
||||
xs: 0.5,
|
||||
sm: 0
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!isPending && isPaginationEnabled && (
|
||||
<Pagination
|
||||
totalRecordCount={totalRecordCount}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
isPlaceholderData={isPlaceholderData}
|
||||
setLibraryViewSettings={setLibraryViewSettings}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isAlphabetPickerEnabled && hasSortName && (
|
||||
@@ -322,7 +378,7 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
{isPending ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<ItemsContainer
|
||||
@@ -335,10 +391,10 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||
</ItemsContainer>
|
||||
)}
|
||||
|
||||
{isPaginationEnabled && (
|
||||
{!isPending && isPaginationEnabled && (
|
||||
<Box
|
||||
className={classNames(
|
||||
'padded-top padded-left padded-right padded-bottom',
|
||||
'padded-left padded-right',
|
||||
{ 'padded-right-withalphapicker': isAlphabetPickerEnabled }
|
||||
)}
|
||||
sx={{
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import Add from '@mui/icons-material/Add';
|
||||
import Button from '@mui/material/Button';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
const NewCollectionButton: FC = () => {
|
||||
interface NewCollectionButtonProps {
|
||||
isTextVisible: boolean
|
||||
}
|
||||
|
||||
const NewCollectionButton: FC<NewCollectionButtonProps> = ({
|
||||
isTextVisible
|
||||
}) => {
|
||||
const showCollectionEditor = useCallback(() => {
|
||||
import('components/collectionEditor/collectionEditor').then(
|
||||
({ default: CollectionEditor }) => {
|
||||
@@ -21,13 +28,17 @@ const NewCollectionButton: FC = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
title={globalize.translate('Add')}
|
||||
className='paper-icon-button-light btnNewCollection autoSize'
|
||||
<Button
|
||||
variant='contained'
|
||||
startIcon={isTextVisible ? <Add /> : undefined}
|
||||
onClick={showCollectionEditor}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
{isTextVisible ? (
|
||||
globalize.translate('NewCollection')
|
||||
) : (
|
||||
<Add />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,8 +2,12 @@ import React, { FC, useCallback } from 'react';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import type { Theme } from '@mui/material/styles';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
import * as userSettings from 'scripts/settings/userSettings';
|
||||
@@ -22,6 +26,8 @@ const Pagination: FC<PaginationProps> = ({
|
||||
totalRecordCount,
|
||||
isPlaceholderData
|
||||
}) => {
|
||||
const isSmallScreen = useMediaQuery((t: Theme) => t.breakpoints.up('sm'));
|
||||
|
||||
const limit = userSettings.libraryPageSize(undefined);
|
||||
const startIndex = libraryViewSettings.StartIndex ?? 0;
|
||||
const recordsStart = totalRecordCount ? startIndex + 1 : 0;
|
||||
@@ -47,47 +53,88 @@ const Pagination: FC<PaginationProps> = ({
|
||||
}, [limit, setLibraryViewSettings, startIndex]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
className='paging'
|
||||
<Stack
|
||||
direction='row'
|
||||
spacing={0.5}
|
||||
sx={{
|
||||
display: 'flex'
|
||||
alignItems: 'center',
|
||||
flexGrow: {
|
||||
xs: 1,
|
||||
sm: 0
|
||||
},
|
||||
marginLeft: {
|
||||
xs: 0,
|
||||
sm: 0.5
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!isSmallScreen && (
|
||||
<Button
|
||||
color='inherit'
|
||||
variant='text'
|
||||
title={globalize.translate('Previous')}
|
||||
disabled={!showControls || startIndex == 0 || isPlaceholderData}
|
||||
onClick={onPreviousPageClick}
|
||||
>
|
||||
<ArrowBackIcon />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Box
|
||||
className='listPaging'
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 1,
|
||||
marginRight: 1
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<Typography variant='body2'>
|
||||
{globalize.translate(
|
||||
'ListPaging',
|
||||
recordsStart,
|
||||
recordsEnd,
|
||||
totalRecordCount
|
||||
)}
|
||||
</span>
|
||||
{showControls && (
|
||||
<ButtonGroup>
|
||||
<IconButton
|
||||
title={globalize.translate('Previous')}
|
||||
className='paper-icon-button-light btnPreviousPage autoSize'
|
||||
disabled={startIndex == 0 || isPlaceholderData}
|
||||
onClick={onPreviousPageClick}
|
||||
>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
title={globalize.translate('Next')}
|
||||
className='paper-icon-button-light btnNextPage autoSize'
|
||||
disabled={startIndex + limit >= totalRecordCount || isPlaceholderData }
|
||||
onClick={onNextPageClick}
|
||||
>
|
||||
<ArrowForwardIcon />
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isSmallScreen && (
|
||||
<ButtonGroup
|
||||
color='inherit'
|
||||
variant='text'
|
||||
>
|
||||
<Button
|
||||
title={globalize.translate('Previous')}
|
||||
disabled={!showControls || startIndex == 0 || isPlaceholderData}
|
||||
onClick={onPreviousPageClick}
|
||||
>
|
||||
<ArrowBackIcon />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
title={globalize.translate('Next')}
|
||||
disabled={!showControls || startIndex + limit >= totalRecordCount || isPlaceholderData }
|
||||
onClick={onNextPageClick}
|
||||
>
|
||||
<ArrowForwardIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
|
||||
{!isSmallScreen && (
|
||||
<Button
|
||||
color='inherit'
|
||||
variant='text'
|
||||
title={globalize.translate('Next')}
|
||||
disabled={!showControls || startIndex + limit >= totalRecordCount || isPlaceholderData }
|
||||
onClick={onNextPageClick}
|
||||
>
|
||||
<ArrowForwardIcon />
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import Button from '@mui/material/Button';
|
||||
import PlayArrow from '@mui/icons-material/PlayArrow';
|
||||
|
||||
import { playbackManager } from 'components/playback/playbackmanager';
|
||||
import globalize from 'lib/globalize';
|
||||
@@ -10,14 +10,22 @@ import { LibraryTab } from 'types/libraryTab';
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
|
||||
interface PlayAllButtonProps {
|
||||
item: ItemDto | undefined;
|
||||
items: ItemDto[];
|
||||
viewType: LibraryTab;
|
||||
hasFilters: boolean;
|
||||
item: ItemDto | undefined
|
||||
items: ItemDto[]
|
||||
viewType: LibraryTab
|
||||
hasFilters: boolean
|
||||
isTextVisible: boolean
|
||||
libraryViewSettings: LibraryViewSettings
|
||||
}
|
||||
|
||||
const PlayAllButton: FC<PlayAllButtonProps> = ({ item, items, viewType, hasFilters, libraryViewSettings }) => {
|
||||
const PlayAllButton: FC<PlayAllButtonProps> = ({
|
||||
item,
|
||||
items,
|
||||
viewType,
|
||||
hasFilters,
|
||||
isTextVisible,
|
||||
libraryViewSettings
|
||||
}) => {
|
||||
const play = useCallback(() => {
|
||||
if (item && !hasFilters) {
|
||||
playbackManager.play({
|
||||
@@ -47,19 +55,17 @@ const PlayAllButton: FC<PlayAllButtonProps> = ({ item, items, viewType, hasFilte
|
||||
}, [hasFilters, item, items, libraryViewSettings, viewType]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
<Button
|
||||
title={globalize.translate('HeaderPlayAll')}
|
||||
className='paper-icon-button-light btnPlay autoSize'
|
||||
startIcon={isTextVisible ? <PlayArrow /> : undefined}
|
||||
onClick={play}
|
||||
sx={{
|
||||
order: {
|
||||
xs: 1,
|
||||
sm: 'unset'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlayArrowIcon />
|
||||
</IconButton>
|
||||
{isTextVisible ? (
|
||||
globalize.translate('HeaderPlayAll')
|
||||
) : (
|
||||
<PlayArrow />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import QueueIcon from '@mui/icons-material/Queue';
|
||||
import Button from '@mui/material/Button';
|
||||
import Queue from '@mui/icons-material/Queue';
|
||||
|
||||
import { playbackManager } from 'components/playback/playbackmanager';
|
||||
import globalize from 'lib/globalize';
|
||||
@@ -8,11 +8,17 @@ import type { ItemDto } from 'types/base/models/item-dto';
|
||||
|
||||
interface QueueButtonProps {
|
||||
item: ItemDto | undefined
|
||||
items: ItemDto[];
|
||||
hasFilters: boolean;
|
||||
items: ItemDto[]
|
||||
hasFilters: boolean
|
||||
isTextVisible: boolean
|
||||
}
|
||||
|
||||
const QueueButton: FC<QueueButtonProps> = ({ item, items, hasFilters }) => {
|
||||
const QueueButton: FC<QueueButtonProps> = ({
|
||||
item,
|
||||
items,
|
||||
hasFilters,
|
||||
isTextVisible
|
||||
}) => {
|
||||
const queue = useCallback(() => {
|
||||
if (item && !hasFilters) {
|
||||
playbackManager.queue({
|
||||
@@ -30,19 +36,17 @@ const QueueButton: FC<QueueButtonProps> = ({ item, items, hasFilters }) => {
|
||||
}, [hasFilters, item, items]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
<Button
|
||||
title={globalize.translate('AddToPlayQueue')}
|
||||
className='paper-icon-button-light btnQueue autoSize'
|
||||
startIcon={isTextVisible ? <Queue /> : undefined}
|
||||
onClick={queue}
|
||||
sx={{
|
||||
order: {
|
||||
xs: 3,
|
||||
sm: 'unset'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<QueueIcon />
|
||||
</IconButton>
|
||||
{isTextVisible ? (
|
||||
globalize.translate('AddToPlayQueue')
|
||||
) : (
|
||||
<Queue />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ShuffleIcon from '@mui/icons-material/Shuffle';
|
||||
import Shuffle from '@mui/icons-material/Shuffle';
|
||||
import Button from '@mui/material/Button';
|
||||
|
||||
import { playbackManager } from 'components/playback/playbackmanager';
|
||||
import globalize from 'lib/globalize';
|
||||
@@ -11,14 +11,22 @@ import { LibraryTab } from 'types/libraryTab';
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
|
||||
interface ShuffleButtonProps {
|
||||
item: ItemDto | undefined;
|
||||
items: ItemDto[];
|
||||
item: ItemDto | undefined
|
||||
items: ItemDto[]
|
||||
viewType: LibraryTab
|
||||
hasFilters: boolean;
|
||||
hasFilters: boolean
|
||||
isTextVisible: boolean
|
||||
libraryViewSettings: LibraryViewSettings
|
||||
}
|
||||
|
||||
const ShuffleButton: FC<ShuffleButtonProps> = ({ item, items, viewType, hasFilters, libraryViewSettings }) => {
|
||||
const ShuffleButton: FC<ShuffleButtonProps> = ({
|
||||
item,
|
||||
items,
|
||||
viewType,
|
||||
hasFilters,
|
||||
isTextVisible,
|
||||
libraryViewSettings
|
||||
}) => {
|
||||
const shuffle = useCallback(() => {
|
||||
if (item && !hasFilters) {
|
||||
playbackManager.shuffle(item);
|
||||
@@ -38,19 +46,17 @@ const ShuffleButton: FC<ShuffleButtonProps> = ({ item, items, viewType, hasFilte
|
||||
}, [hasFilters, item, items, libraryViewSettings, viewType]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
<Button
|
||||
title={globalize.translate('Shuffle')}
|
||||
className='paper-icon-button-light btnShuffle autoSize'
|
||||
startIcon={isTextVisible ? <Shuffle /> : undefined}
|
||||
onClick={shuffle}
|
||||
sx={{
|
||||
order: {
|
||||
xs: 2,
|
||||
sm: 'unset'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ShuffleIcon />
|
||||
</IconButton>
|
||||
{isTextVisible ? (
|
||||
globalize.translate('Shuffle')
|
||||
) : (
|
||||
<Shuffle />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
|
||||
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order';
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Button from '@mui/material/Button';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Box from '@mui/material/Box';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
||||
@@ -149,16 +148,14 @@ const SortButton: FC<SortButtonProps> = ({
|
||||
const sortMenuOptions = getSortMenuOptions(viewType);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<IconButton
|
||||
<>
|
||||
<Button
|
||||
title={globalize.translate('Sort')}
|
||||
sx={{ ml: 2 }}
|
||||
aria-describedby={id}
|
||||
className='paper-icon-button-light btnSort autoSize'
|
||||
onClick={handleClick}
|
||||
>
|
||||
<SortByAlphaIcon />
|
||||
</IconButton>
|
||||
</Button>
|
||||
<Popover
|
||||
id={id}
|
||||
open={open}
|
||||
@@ -229,7 +226,7 @@ const SortButton: FC<SortButtonProps> = ({
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Popover>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
||||
import React, { FC, useCallback } from 'react';
|
||||
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Check from '@mui/icons-material/Check';
|
||||
import MoreVert from '@mui/icons-material/MoreVert';
|
||||
import Button from '@mui/material/Button';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Box from '@mui/material/Box';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormGroup from '@mui/material/FormGroup';
|
||||
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import ViewComfyIcon from '@mui/icons-material/ViewComfy';
|
||||
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
import { LibraryViewSettings } from 'types/library';
|
||||
import { LibraryViewSettings, ViewMode } from 'types/library';
|
||||
import { LibraryTab } from 'types/libraryTab';
|
||||
|
||||
const excludedViewType = [
|
||||
const IMAGE_TYPE_EXCLUDED_VIEWS = [
|
||||
LibraryTab.Episodes,
|
||||
LibraryTab.Artists,
|
||||
LibraryTab.AlbumArtists,
|
||||
@@ -71,6 +74,20 @@ const ViewSettingsButton: FC<ViewSettingsButtonProps> = ({
|
||||
[setLibraryViewSettings]
|
||||
);
|
||||
|
||||
const onGridViewClick = useCallback(() => {
|
||||
setLibraryViewSettings(prevState => ({
|
||||
...prevState,
|
||||
ViewMode: ViewMode.GridView
|
||||
}));
|
||||
}, [ setLibraryViewSettings ]);
|
||||
|
||||
const onListViewClick = useCallback(() => {
|
||||
setLibraryViewSettings(prevState => ({
|
||||
...prevState,
|
||||
ViewMode: ViewMode.ListView
|
||||
}));
|
||||
}, [ setLibraryViewSettings ]);
|
||||
|
||||
const onSelectChange = useCallback(
|
||||
(event: SelectChangeEvent) => {
|
||||
setLibraryViewSettings((prevState) => ({
|
||||
@@ -81,19 +98,18 @@ const ViewSettingsButton: FC<ViewSettingsButtonProps> = ({
|
||||
[setLibraryViewSettings]
|
||||
);
|
||||
|
||||
const isVisible = !excludedViewType.includes(viewType);
|
||||
const isGridView = libraryViewSettings.ViewMode === ViewMode.GridView;
|
||||
const isImageTypeVisible = !IMAGE_TYPE_EXCLUDED_VIEWS.includes(viewType);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<IconButton
|
||||
title={globalize.translate('ButtonSelectView')}
|
||||
sx={{ ml: 2 }}
|
||||
<>
|
||||
<Button
|
||||
title={globalize.translate('ViewSettings')}
|
||||
aria-describedby={id}
|
||||
className='paper-icon-button-light btnSelectView autoSize'
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ViewComfyIcon />
|
||||
</IconButton>
|
||||
<MoreVert />
|
||||
</Button>
|
||||
<Popover
|
||||
id={id}
|
||||
open={open}
|
||||
@@ -111,70 +127,101 @@ const ViewSettingsButton: FC<ViewSettingsButtonProps> = ({
|
||||
'& .MuiFormControl-root': { m: 1, width: 220 }
|
||||
}}
|
||||
>
|
||||
{isVisible && (
|
||||
<FormControl>
|
||||
<InputLabel id='select-sort-label'>
|
||||
<Typography component='span'>
|
||||
{globalize.translate('LabelImageType')}
|
||||
</Typography>
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={libraryViewSettings.ImageType}
|
||||
label={globalize.translate('LabelImageType')}
|
||||
onChange={onSelectChange}
|
||||
>
|
||||
{imageTypesOptions.map((imageType) => (
|
||||
<MenuItem
|
||||
key={imageType.value}
|
||||
value={imageType.value}
|
||||
>
|
||||
<Typography component='span'>
|
||||
{globalize.translate(imageType.label)}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
<Divider />
|
||||
<FormControl>
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={libraryViewSettings.ShowTitle}
|
||||
onChange={handleChange}
|
||||
name='ShowTitle'
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('ShowTitle')}
|
||||
/>
|
||||
{isVisible && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={libraryViewSettings.ShowYear}
|
||||
onChange={handleChange}
|
||||
name='ShowYear'
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('ShowYear')}
|
||||
/>
|
||||
<MenuList>
|
||||
|
||||
<MenuItem
|
||||
onClick={onGridViewClick}
|
||||
>
|
||||
{isGridView && (
|
||||
<ListItemIcon><Check /></ListItemIcon>
|
||||
)}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={libraryViewSettings.CardLayout}
|
||||
onChange={handleChange}
|
||||
name='CardLayout'
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('EnableCardLayout')}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
<ListItemText inset={!isGridView}>
|
||||
{globalize.translate('GridView')}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={onListViewClick}
|
||||
>
|
||||
{!isGridView && (
|
||||
<ListItemIcon><Check /></ListItemIcon>
|
||||
)}
|
||||
<ListItemText inset={isGridView}>
|
||||
{globalize.translate('ListView')}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
{isGridView && (
|
||||
<>
|
||||
<Divider />
|
||||
{isImageTypeVisible && (
|
||||
<>
|
||||
<FormControl>
|
||||
<InputLabel>
|
||||
<Typography component='span'>
|
||||
{globalize.translate('LabelImageType')}
|
||||
</Typography>
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={libraryViewSettings.ImageType}
|
||||
label={globalize.translate('LabelImageType')}
|
||||
onChange={onSelectChange}
|
||||
>
|
||||
{imageTypesOptions.map((imageType) => (
|
||||
<MenuItem
|
||||
key={imageType.value}
|
||||
value={imageType.value}
|
||||
>
|
||||
<Typography component='span'>
|
||||
{globalize.translate(imageType.label)}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
<FormControl>
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={libraryViewSettings.ShowTitle}
|
||||
onChange={handleChange}
|
||||
name='ShowTitle'
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('ShowTitle')}
|
||||
/>
|
||||
{isImageTypeVisible && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={libraryViewSettings.ShowYear}
|
||||
onChange={handleChange}
|
||||
name='ShowYear'
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('ShowYear')}
|
||||
/>
|
||||
)}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={libraryViewSettings.CardLayout}
|
||||
onChange={handleChange}
|
||||
name='CardLayout'
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('EnableCardLayout')}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
</MenuList>
|
||||
</Popover>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import ArrowForwardIosSharpIcon from '@mui/icons-material/ArrowForwardIosSharp';
|
||||
import Box from '@mui/material/Box';
|
||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||
import FilterAlt from '@mui/icons-material/FilterAlt';
|
||||
import Button from '@mui/material/Button';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import MuiAccordion, { AccordionProps } from '@mui/material/Accordion';
|
||||
import MuiAccordionDetails from '@mui/material/AccordionDetails';
|
||||
@@ -10,7 +10,6 @@ import MuiAccordionSummary, {
|
||||
AccordionSummaryProps
|
||||
} from '@mui/material/AccordionSummary';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
@@ -158,18 +157,16 @@ const FilterButton: FC<FilterButtonProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<IconButton
|
||||
<>
|
||||
<Button
|
||||
title={globalize.translate('Filter')}
|
||||
sx={{ ml: 2 }}
|
||||
aria-describedby={id}
|
||||
className='paper-icon-button-light btnFilter autoSize'
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Badge color='info' variant='dot' invisible={!hasFilters}>
|
||||
<FilterListIcon />
|
||||
<FilterAlt />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Button>
|
||||
<Popover
|
||||
id={id}
|
||||
open={open}
|
||||
@@ -451,7 +448,7 @@ const FilterButton: FC<FilterButtonProps> = ({
|
||||
</Accordion>
|
||||
)}
|
||||
</Popover>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import Typography from '@mui/material/Typography';
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import { appHost } from 'components/apphost';
|
||||
import { AppFeature } from 'constants/appFeature';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { useThemes } from 'hooks/useThemes';
|
||||
import globalize from 'lib/globalize';
|
||||
@@ -32,7 +33,7 @@ export function DisplayPreferences({ onChange, values }: Readonly<DisplayPrefere
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h2'>{globalize.translate('Display')}</Typography>
|
||||
|
||||
{ appHost.supports('displaymode') && (
|
||||
{ appHost.supports(AppFeature.DisplayMode) && (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id='display-settings-layout-label'>{globalize.translate('LabelDisplayMode')}</InputLabel>
|
||||
<Select
|
||||
@@ -124,7 +125,7 @@ export function DisplayPreferences({ onChange, values }: Readonly<DisplayPrefere
|
||||
</FormControl>
|
||||
) }
|
||||
|
||||
{ screensavers.length > 0 && appHost.supports('screensaver') && (
|
||||
{ screensavers.length > 0 && appHost.supports(AppFeature.Screensaver) && (
|
||||
<Fragment>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id='display-settings-screensaver-label'>{globalize.translate('LabelScreensaver')}</InputLabel>
|
||||
|
||||
@@ -10,8 +10,9 @@ import React from 'react';
|
||||
|
||||
import { DATE_LOCALE_OPTIONS, LANGUAGE_OPTIONS } from 'apps/experimental/features/preferences/constants/locales';
|
||||
import { appHost } from 'components/apphost';
|
||||
import datetime from 'scripts/datetime';
|
||||
import { AppFeature } from 'constants/appFeature';
|
||||
import globalize from 'lib/globalize';
|
||||
import datetime from 'scripts/datetime';
|
||||
|
||||
import type { DisplaySettingsValues } from '../types/displaySettingsValues';
|
||||
|
||||
@@ -21,14 +22,14 @@ interface LocalizationPreferencesProps {
|
||||
}
|
||||
|
||||
export function LocalizationPreferences({ onChange, values }: Readonly<LocalizationPreferencesProps>) {
|
||||
if (!appHost.supports('displaylanguage') && !datetime.supportsLocalization()) {
|
||||
if (!appHost.supports(AppFeature.DisplayLanguage) && !datetime.supportsLocalization()) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h2'>{globalize.translate('Localization')}</Typography>
|
||||
|
||||
{ appHost.supports('displaylanguage') && (
|
||||
{ appHost.supports(AppFeature.DisplayLanguage) && (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id='display-settings-language-label'>{globalize.translate('LabelDisplayLanguage')}</InputLabel>
|
||||
<Select
|
||||
@@ -46,7 +47,7 @@ export function LocalizationPreferences({ onChange, values }: Readonly<Localizat
|
||||
</Select>
|
||||
<FormHelperText component={Stack} id='display-settings-language-description'>
|
||||
<span>{globalize.translate('LabelDisplayLanguageHelp')}</span>
|
||||
{ appHost.supports('externallinks') && (
|
||||
{ appHost.supports(AppFeature.ExternalLinks) && (
|
||||
<Link
|
||||
href='https://github.com/jellyfin/jellyfin'
|
||||
rel='noopener noreferrer'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { appHost } from 'components/apphost';
|
||||
import layoutManager from 'components/layoutManager';
|
||||
import { AppFeature } from 'constants/appFeature';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import themeManager from 'scripts/themeManager';
|
||||
import { currentSettings, UserSettings } from 'scripts/settings/userSettings';
|
||||
@@ -120,7 +121,7 @@ async function saveDisplaySettings({
|
||||
}: SaveDisplaySettingsParams) {
|
||||
const user = await api.getUser(userId);
|
||||
|
||||
if (appHost.supports('displaylanguage')) {
|
||||
if (appHost.supports(AppFeature.DisplayLanguage)) {
|
||||
userSettings.language(normalizeValue(newDisplaySettings.language));
|
||||
}
|
||||
userSettings.customCss(normalizeValue(newDisplaySettings.customCss));
|
||||
|
||||
@@ -39,6 +39,7 @@ const playlistsTabContent: LibraryTabContent = {
|
||||
|
||||
const songsTabContent: LibraryTabContent = {
|
||||
viewType: LibraryTab.Songs,
|
||||
isBtnShuffleEnabled: true,
|
||||
isBtnGridListEnabled: false,
|
||||
isAlphabetPickerEnabled: false,
|
||||
itemType: [BaseItemKind.Audio]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
||||
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
|
||||
interface ImageOptions {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { MediaSegmentType } from '@jellyfin/sdk/lib/generated-client/models/medi
|
||||
import { getMediaSegmentsApi } from '@jellyfin/sdk/lib/utils/api/media-segments-api';
|
||||
|
||||
import type { PlaybackManager } from 'components/playback/playbackmanager';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { TICKS_PER_MILLISECOND, TICKS_PER_SECOND } from 'constants/time';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import { currentSettings as userSettings } from 'scripts/settings/userSettings';
|
||||
import type { PlayerState } from 'types/playbackStopInfo';
|
||||
import type { Event } from 'utils/events';
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './user';
|
||||
export * from './public';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AsyncRoute } from '../../../../components/router/AsyncRoute';
|
||||
|
||||
export const ASYNC_PUBLIC_ROUTES: AsyncRoute[] = [
|
||||
{ path: 'forgotpassword', page: 'session/forgotPassword' }
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user