Compare commits
49 Commits
sortmedias
...
release-10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c25db80cbd | ||
|
|
e33ffbe9aa | ||
|
|
f68bdaa276 | ||
|
|
e804227d6d | ||
|
|
9092d7fc06 | ||
|
|
148920f2f9 | ||
|
|
482fa20364 | ||
|
|
b102201607 | ||
|
|
7909c0874b | ||
|
|
d691f4c38f | ||
|
|
b4adc677fd | ||
|
|
fd66c5a3fb | ||
|
|
364bbc988d | ||
|
|
51722fd225 | ||
|
|
a158b1f85a | ||
|
|
0bb732c60a | ||
|
|
2782127ba8 | ||
|
|
6a80b4caeb | ||
|
|
b2e95b0ee6 | ||
|
|
b262b98e83 | ||
|
|
b52cb34319 | ||
|
|
914ef1e566 | ||
|
|
f832206145 | ||
|
|
46fcdf91b8 | ||
|
|
3c953d5ffd | ||
|
|
6c28570e82 | ||
|
|
acf632e77e | ||
|
|
44c273f531 | ||
|
|
ef09b24f0c | ||
|
|
87bcf40e81 | ||
|
|
3cae48be23 | ||
|
|
ecc65dfa3b | ||
|
|
9be3f2e731 | ||
|
|
44c7b75dbb | ||
|
|
253b0d96d6 | ||
|
|
7fb75c6d82 | ||
|
|
98816bcce0 | ||
|
|
067e43c0d3 | ||
|
|
a956d602f9 | ||
|
|
9cf6ccc73c | ||
|
|
b3b9f355c3 | ||
|
|
b2d2b1360c | ||
|
|
af7f626a43 | ||
|
|
5568d945a7 | ||
|
|
4dc5535a02 | ||
|
|
b6f5435750 | ||
|
|
8db63ab520 | ||
|
|
527c5fb43e | ||
|
|
9ef4e95467 |
63
.ci/azure-pipelines.yml
Normal file
63
.ci/azure-pipelines.yml
Normal file
@@ -0,0 +1,63 @@
|
||||
trigger:
|
||||
batch: true
|
||||
branches:
|
||||
include:
|
||||
- master
|
||||
- release-*
|
||||
tags:
|
||||
include:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
- job: main_build
|
||||
displayName: 'Main Build'
|
||||
|
||||
dependsOn: lint
|
||||
condition: succeeded()
|
||||
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
|
||||
steps:
|
||||
- task: NodeTool@0
|
||||
displayName: 'Install Node'
|
||||
inputs:
|
||||
versionSpec: '10.x'
|
||||
|
||||
- script: 'yarn install'
|
||||
displayName: 'Install Dependencies'
|
||||
|
||||
- script: 'test -d dist'
|
||||
displayName: 'Check Build'
|
||||
|
||||
- script: 'yarn pack --filename jellyfin-web.tgz'
|
||||
displayName: 'Bundle Release'
|
||||
|
||||
- task: PublishPipelineArtifact@1
|
||||
displayName: 'Publish Release'
|
||||
condition: succeeded()
|
||||
inputs:
|
||||
targetPath: '$(Build.SourcesDirectory)/jellyfin-web.tgz'
|
||||
artifactName: 'jellyfin-web'
|
||||
|
||||
- job: lint
|
||||
displayName: 'Lint'
|
||||
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
|
||||
steps:
|
||||
- task: NodeTool@0
|
||||
displayName: 'Install Node'
|
||||
inputs:
|
||||
versionSpec: '10.x'
|
||||
|
||||
- script: 'yarn install'
|
||||
displayName: 'Install Dependencies'
|
||||
|
||||
- script: 'yarn run lint'
|
||||
displayName: 'Run ESLint'
|
||||
|
||||
- script: |
|
||||
yarn run stylelint
|
||||
displayName: 'Run stylelint'
|
||||
@@ -1,23 +0,0 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
|
||||
{
|
||||
"name": "Node.js",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-20-bullseye",
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
//https://github.com/microsoft/vscode-dev-containers/issues/559
|
||||
"postCreateCommand": "source $NVM_DIR/nvm.sh && nvm install 20"
|
||||
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
@@ -7,6 +7,3 @@ charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
end_of_line = lf
|
||||
|
||||
[*.{json,yaml,yml}]
|
||||
indent_size = 2
|
||||
|
||||
11
.escheckrc
11
.escheckrc
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"ecmaVersion": "es5",
|
||||
"modules": "false",
|
||||
"files": "./dist/**/*.js",
|
||||
"not": [
|
||||
"./dist/libraries/pdf.worker.js",
|
||||
"./dist/libraries/worker-bundle.js",
|
||||
"./dist/libraries/wasm-gen/libarchive.js",
|
||||
"./dist/serviceworker.js"
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1 @@
|
||||
node_modules
|
||||
dist
|
||||
.idea
|
||||
.vscode
|
||||
libraries/
|
||||
|
||||
300
.eslintrc.js
300
.eslintrc.js
@@ -1,300 +0,0 @@
|
||||
const restrictedGlobals = require('confusing-browser-globals');
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: [
|
||||
'@typescript-eslint',
|
||||
'react',
|
||||
'import',
|
||||
'eslint-comments',
|
||||
'sonarjs'
|
||||
],
|
||||
env: {
|
||||
node: true,
|
||||
es6: true,
|
||||
es2017: true,
|
||||
es2020: true
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:import/errors',
|
||||
'plugin:eslint-comments/recommended',
|
||||
'plugin:compat/recommended',
|
||||
'plugin:sonarjs/recommended'
|
||||
],
|
||||
rules: {
|
||||
'array-callback-return': ['error', { 'checkForEach': true }],
|
||||
'block-spacing': ['error'],
|
||||
'brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
|
||||
'comma-dangle': ['error', 'never'],
|
||||
'comma-spacing': ['error'],
|
||||
'curly': ['error', 'multi-line', 'consistent'],
|
||||
'default-case-last': ['error'],
|
||||
'eol-last': ['error'],
|
||||
'indent': ['error', 4, { 'SwitchCase': 1 }],
|
||||
'jsx-quotes': ['error', 'prefer-single'],
|
||||
'keyword-spacing': ['error'],
|
||||
'max-statements-per-line': ['error'],
|
||||
'max-params': ['error', 7],
|
||||
'new-cap': [
|
||||
'error',
|
||||
{
|
||||
'capIsNewExceptions': ['jQuery.Deferred'],
|
||||
'newIsCapExceptionPattern': '\\.default$'
|
||||
}
|
||||
],
|
||||
'no-duplicate-imports': ['error'],
|
||||
'no-empty-function': ['error'],
|
||||
'no-extend-native': ['error'],
|
||||
'no-floating-decimal': ['error'],
|
||||
'no-lonely-if': ['error'],
|
||||
'no-multi-spaces': ['error'],
|
||||
'no-multiple-empty-lines': ['error', { 'max': 1 }],
|
||||
'no-nested-ternary': ['error'],
|
||||
'no-redeclare': ['off'],
|
||||
'@typescript-eslint/no-redeclare': ['error', { builtinGlobals: false }],
|
||||
'no-restricted-globals': ['error'].concat(restrictedGlobals),
|
||||
'no-return-assign': ['error'],
|
||||
'no-return-await': ['error'],
|
||||
'no-sequences': ['error', { 'allowInParentheses': false }],
|
||||
'no-shadow': ['off'],
|
||||
'@typescript-eslint/no-shadow': ['error'],
|
||||
'no-throw-literal': ['error'],
|
||||
'no-trailing-spaces': ['error'],
|
||||
'no-undef-init': ['error'],
|
||||
'no-unneeded-ternary': ['error'],
|
||||
'no-unused-expressions': ['off'],
|
||||
'@typescript-eslint/no-unused-expressions': ['error', { 'allowShortCircuit': true, 'allowTernary': true, 'allowTaggedTemplates': true }],
|
||||
'no-unused-private-class-members': ['error'],
|
||||
'no-useless-rename': ['error'],
|
||||
'no-useless-constructor': ['off'],
|
||||
'@typescript-eslint/no-useless-constructor': ['error'],
|
||||
'no-var': ['error'],
|
||||
'no-void': ['error', { 'allowAsStatement': true }],
|
||||
'no-warning-comments': ['warn', { 'terms': ['fixme', 'hack', 'xxx'] }],
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'one-var': ['error', 'never'],
|
||||
'operator-linebreak': ['error', 'before', { overrides: { '?': 'after', ':': 'after', '=': 'after' } }],
|
||||
'padded-blocks': ['error', 'never'],
|
||||
'prefer-const': ['error', { 'destructuring': 'all' }],
|
||||
'prefer-promise-reject-errors': ['warn', { 'allowEmptyReject': true }],
|
||||
'@typescript-eslint/prefer-for-of': ['error'],
|
||||
'@typescript-eslint/prefer-optional-chain': ['error'],
|
||||
'quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': false }],
|
||||
'radix': ['error'],
|
||||
'@typescript-eslint/semi': ['error'],
|
||||
'space-before-blocks': ['error'],
|
||||
'space-infix-ops': 'error',
|
||||
'yoda': 'error',
|
||||
|
||||
'react/jsx-filename-extension': ['error', { 'extensions': ['.jsx', '.tsx'] }],
|
||||
'react/jsx-no-bind': ['error'],
|
||||
'react/jsx-no-useless-fragment': ['error'],
|
||||
'react/jsx-no-constructed-context-values': ['error'],
|
||||
'react/no-array-index-key': ['error'],
|
||||
|
||||
'sonarjs/no-inverted-boolean-check': ['error'],
|
||||
// TODO: Enable the following rules and fix issues
|
||||
'sonarjs/cognitive-complexity': ['off'],
|
||||
'sonarjs/no-duplicate-string': ['off']
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect'
|
||||
},
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': [ '.ts', '.tsx' ]
|
||||
},
|
||||
'import/resolver': {
|
||||
node: {
|
||||
extensions: [
|
||||
'.js',
|
||||
'.ts',
|
||||
'.jsx',
|
||||
'.tsx'
|
||||
],
|
||||
moduleDirectory: [
|
||||
'node_modules',
|
||||
'src'
|
||||
]
|
||||
}
|
||||
},
|
||||
polyfills: [
|
||||
// Native Promises Only
|
||||
'Promise',
|
||||
// whatwg-fetch
|
||||
'fetch',
|
||||
// document-register-element
|
||||
'document.registerElement',
|
||||
// resize-observer-polyfill
|
||||
'ResizeObserver',
|
||||
// fast-text-encoding
|
||||
'TextEncoder',
|
||||
// intersection-observer
|
||||
'IntersectionObserver',
|
||||
// Core-js
|
||||
'Object.assign',
|
||||
'Object.is',
|
||||
'Object.setPrototypeOf',
|
||||
'Object.toString',
|
||||
'Object.freeze',
|
||||
'Object.seal',
|
||||
'Object.preventExtensions',
|
||||
'Object.isFrozen',
|
||||
'Object.isSealed',
|
||||
'Object.isExtensible',
|
||||
'Object.getOwnPropertyDescriptor',
|
||||
'Object.getPrototypeOf',
|
||||
'Object.keys',
|
||||
'Object.entries',
|
||||
'Object.getOwnPropertyNames',
|
||||
'Function.name',
|
||||
'Function.hasInstance',
|
||||
'Array.from',
|
||||
'Array.arrayOf',
|
||||
'Array.copyWithin',
|
||||
'Array.fill',
|
||||
'Array.find',
|
||||
'Array.findIndex',
|
||||
'Array.iterator',
|
||||
'String.fromCodePoint',
|
||||
'String.raw',
|
||||
'String.iterator',
|
||||
'String.codePointAt',
|
||||
'String.endsWith',
|
||||
'String.includes',
|
||||
'String.repeat',
|
||||
'String.startsWith',
|
||||
'String.trim',
|
||||
'String.anchor',
|
||||
'String.big',
|
||||
'String.blink',
|
||||
'String.bold',
|
||||
'String.fixed',
|
||||
'String.fontcolor',
|
||||
'String.fontsize',
|
||||
'String.italics',
|
||||
'String.link',
|
||||
'String.small',
|
||||
'String.strike',
|
||||
'String.sub',
|
||||
'String.sup',
|
||||
'RegExp',
|
||||
'Number',
|
||||
'Math',
|
||||
'Date',
|
||||
'async',
|
||||
'Symbol',
|
||||
'Map',
|
||||
'Set',
|
||||
'WeakMap',
|
||||
'WeakSet',
|
||||
'ArrayBuffer',
|
||||
'DataView',
|
||||
'Int8Array',
|
||||
'Uint8Array',
|
||||
'Uint8ClampedArray',
|
||||
'Int16Array',
|
||||
'Uint16Array',
|
||||
'Int32Array',
|
||||
'Uint32Array',
|
||||
'Float32Array',
|
||||
'Float64Array',
|
||||
'Reflect',
|
||||
// Temporary while eslint-compat-plugin is buggy
|
||||
'document.querySelector'
|
||||
]
|
||||
},
|
||||
overrides: [
|
||||
// Config files and development scripts
|
||||
{
|
||||
files: [
|
||||
'./babel.config.js',
|
||||
'./.eslintrc.js',
|
||||
'./postcss.config.js',
|
||||
'./webpack.*.js',
|
||||
'./scripts/**/*.js'
|
||||
]
|
||||
},
|
||||
// JavaScript source files
|
||||
{
|
||||
files: [
|
||||
'./src/**/*.{js,jsx,ts,tsx}'
|
||||
],
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json']
|
||||
},
|
||||
env: {
|
||||
node: false,
|
||||
amd: true,
|
||||
browser: true,
|
||||
es6: true,
|
||||
es2017: true,
|
||||
es2020: true
|
||||
},
|
||||
globals: {
|
||||
// Browser globals
|
||||
'MediaMetadata': 'readonly',
|
||||
// Tizen globals
|
||||
'tizen': 'readonly',
|
||||
'webapis': 'readonly',
|
||||
// WebOS globals
|
||||
'webOS': 'readonly',
|
||||
// Dependency globals
|
||||
'$': 'readonly',
|
||||
'jQuery': 'readonly',
|
||||
// Jellyfin globals
|
||||
'ApiClient': 'writable',
|
||||
'Events': 'writable',
|
||||
'chrome': 'writable',
|
||||
'DlnaProfilePage': 'writable',
|
||||
'DashboardPage': 'writable',
|
||||
'Emby': 'readonly',
|
||||
'Globalize': 'writable',
|
||||
'Hls': 'writable',
|
||||
'LibraryMenu': 'writable',
|
||||
'LinkParser': 'writable',
|
||||
'LiveTvHelpers': 'writable',
|
||||
'Loading': 'writable',
|
||||
'MetadataEditor': 'writable',
|
||||
'ServerNotifications': 'writable',
|
||||
'TaskButton': 'writable',
|
||||
'UserParentalControlPage': 'writable',
|
||||
'Windows': 'readonly',
|
||||
// Build time definitions
|
||||
__COMMIT_SHA__: 'readonly',
|
||||
__JF_BUILD_VERSION__: 'readonly',
|
||||
__PACKAGE_JSON_NAME__: 'readonly',
|
||||
__PACKAGE_JSON_VERSION__: 'readonly',
|
||||
__USE_SYSTEM_FONTS__: 'readonly',
|
||||
__WEBPACK_SERVE__: 'readonly'
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/prefer-string-starts-ends-with': ['error']
|
||||
}
|
||||
},
|
||||
// TypeScript source files
|
||||
{
|
||||
files: [
|
||||
'./src/**/*.{ts,tsx}'
|
||||
],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:import/typescript',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:eslint-comments/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:jsx-a11y/recommended'
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/no-floating-promises': ['error'],
|
||||
'@typescript-eslint/no-unused-vars': ['error'],
|
||||
|
||||
'sonarjs/cognitive-complexity': ['error']
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
71
.eslintrc.yml
Normal file
71
.eslintrc.yml
Normal file
@@ -0,0 +1,71 @@
|
||||
env:
|
||||
es6: false
|
||||
browser: true
|
||||
amd: true
|
||||
|
||||
globals:
|
||||
# New browser globals
|
||||
DataView: readonly
|
||||
MediaMetadata: readonly
|
||||
Promise: readonly
|
||||
# Deprecated browser globals
|
||||
DocumentTouch: readonly
|
||||
# Tizen globals
|
||||
tizen: readonly
|
||||
webapis: readonly
|
||||
# WebOS globals
|
||||
webOS: readonly
|
||||
# Dependency globals
|
||||
$: readonly
|
||||
jQuery: readonly
|
||||
queryString: readonly
|
||||
requirejs: readonly
|
||||
# Jellyfin globals
|
||||
ApiClient: writable
|
||||
AppInfo: writable
|
||||
chrome: writable
|
||||
ConnectionManager: writable
|
||||
DlnaProfilePage: writable
|
||||
Dashboard: writable
|
||||
DashboardPage: writable
|
||||
Emby: readonly
|
||||
Events: writable
|
||||
getParameterByName: writable
|
||||
getWindowLocationSearch: writable
|
||||
Globalize: writable
|
||||
Hls: writable
|
||||
humaneDate: writable
|
||||
humaneElapsed: writable
|
||||
LibraryMenu: writable
|
||||
LinkParser: writable
|
||||
LiveTvHelpers: writable
|
||||
MetadataEditor: writable
|
||||
pageClassOn: writable
|
||||
pageIdOn: writable
|
||||
PlaylistViewer: writable
|
||||
UserParentalControlPage: writable
|
||||
Windows: readonly
|
||||
|
||||
extends:
|
||||
- eslint:recommended
|
||||
|
||||
rules:
|
||||
block-spacing: ["error"]
|
||||
brace-style: ["error"]
|
||||
comma-dangle: ["error", "never"]
|
||||
comma-spacing: ["error"]
|
||||
eol-last: ["error"]
|
||||
indent: ["error", 4, { "SwitchCase": 1 }]
|
||||
keyword-spacing: ["error"]
|
||||
max-statements-per-line: ["error"]
|
||||
no-floating-decimal: ["error"]
|
||||
no-multi-spaces: ["error"]
|
||||
no-multiple-empty-lines: ["error", { "max": 1 }]
|
||||
no-trailing-spaces: ["error"]
|
||||
one-var: ["error", "never"]
|
||||
semi: ["warn"]
|
||||
space-before-blocks: ["error"]
|
||||
# TODO: Fix warnings and remove these rules
|
||||
no-redeclare: ["warn"]
|
||||
no-unused-vars: ["warn"]
|
||||
no-useless-escape: ["warn"]
|
||||
36
.gitattributes
vendored
36
.gitattributes
vendored
@@ -1,35 +1 @@
|
||||
* text=auto
|
||||
|
||||
CONTRIBUTORS.md merge=union
|
||||
README.md text
|
||||
LICENSE text
|
||||
|
||||
*.css text
|
||||
*.eot binary
|
||||
*.gif binary
|
||||
*.html text diff=html
|
||||
*.ico binary
|
||||
*.*ignore text
|
||||
*.jpg binary
|
||||
*.js text
|
||||
*.json text
|
||||
*.lock text -diff
|
||||
*.map text -diff
|
||||
*.md text
|
||||
*.otf binary
|
||||
*.png binary
|
||||
*.py text diff=python
|
||||
*.svg binary
|
||||
*.ts text
|
||||
*.ttf binary
|
||||
*.sass text
|
||||
*.vue text
|
||||
*.webp binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
|
||||
.editorconfig text
|
||||
.gitattributes export-ignore
|
||||
.gitignore export-ignore
|
||||
|
||||
*.gitattributes linguist-language=gitattributes
|
||||
/CONTRIBUTORS.md merge=union
|
||||
|
||||
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -1 +0,0 @@
|
||||
* @jellyfin/web
|
||||
22
.github/ISSUE_TEMPLATE/2-playback-issue.md
vendored
22
.github/ISSUE_TEMPLATE/2-playback-issue.md
vendored
@@ -1,22 +0,0 @@
|
||||
---
|
||||
name: Playback Issue
|
||||
about: You have playback issues with some files
|
||||
labels: playback
|
||||
---
|
||||
|
||||
**Describe The Bug**
|
||||
<!-- A clear and concise description of what the bug is. -->
|
||||
|
||||
**Media Information**
|
||||
<!-- Please paste any ffprobe or MediaInfo logs. -->
|
||||
|
||||
**Screenshots**
|
||||
<!-- Add screenshots from the Playback Data and Media Info. -->
|
||||
|
||||
**System (please complete the following information):**
|
||||
- Platform: [e.g. Linux, Windows, iPhone, Tizen]
|
||||
- Browser: [e.g. Firefox, Chrome, Safari]
|
||||
- Jellyfin Version: [e.g. 10.6.0]
|
||||
|
||||
**Additional Context**
|
||||
<!-- Add any other context about the problem here. -->
|
||||
13
.github/ISSUE_TEMPLATE/3-technical-discussion.md
vendored
13
.github/ISSUE_TEMPLATE/3-technical-discussion.md
vendored
@@ -1,13 +0,0 @@
|
||||
---
|
||||
name: Technical Discussion
|
||||
about: You want to discuss technical aspects of changes you intend to make
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
<!-- Explain the change and the motivations behind it.
|
||||
|
||||
For example, if you plan to rely on a new dependency, explain why and what
|
||||
it brings to the project.
|
||||
|
||||
If you plan to make significant changes, go roughly over the steps you intend
|
||||
to take and how you would divide the change in PRs of a manageable size. -->
|
||||
9
.github/ISSUE_TEMPLATE/4-meta-issue.md
vendored
9
.github/ISSUE_TEMPLATE/4-meta-issue.md
vendored
@@ -1,9 +0,0 @@
|
||||
---
|
||||
name: Meta Issue
|
||||
about: You want to track a number of other issues as part of a larger project
|
||||
labels: meta
|
||||
---
|
||||
|
||||
* [ ] Issue 1 [#123]
|
||||
* [ ] Issue 2 [#456]
|
||||
* [ ] ...
|
||||
@@ -1,20 +1,23 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: You have noticed a general issue or regression, and would like to report it
|
||||
name: Bug report
|
||||
about: Create a bug report
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe The Bug**
|
||||
**Describe the bug**
|
||||
<!-- A clear and concise description of what the bug is. -->
|
||||
|
||||
**Steps To Reproduce**
|
||||
**To Reproduce**
|
||||
<!-- Steps to reproduce the behavior: -->
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected Behavior**
|
||||
**Expected behavior**
|
||||
<!-- A clear and concise description of what you expected to happen. -->
|
||||
|
||||
**Logs**
|
||||
@@ -24,9 +27,9 @@ labels: bug
|
||||
<!-- If applicable, add screenshots to help explain your problem. -->
|
||||
|
||||
**System (please complete the following information):**
|
||||
- Platform: [e.g. Linux, Windows, iPhone, Tizen]
|
||||
- OS: [e.g. Docker, Debian, Windows]
|
||||
- Browser: [e.g. Firefox, Chrome, Safari]
|
||||
- Jellyfin Version: [e.g. 10.6.0]
|
||||
- Jellyfin Version: [e.g. 10.0.1]
|
||||
|
||||
**Additional Context**
|
||||
**Additional context**
|
||||
<!-- Add any other context about the problem here. -->
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
8
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature Request
|
||||
url: https://features.jellyfin.org/
|
||||
about: Please head over to our feature request hub to vote on or submit a feature.
|
||||
- name: Help Or Question
|
||||
url: https://matrix.to/#/#jellyfin-troubleshooting:matrix.org
|
||||
about: Please join the troubleshooting Matrix channel to get some help.
|
||||
24
.github/SUPPORT.md
vendored
24
.github/SUPPORT.md
vendored
@@ -1,24 +0,0 @@
|
||||
# Support
|
||||
|
||||
Jellyfin contributors have limited availability to address general support
|
||||
questions. Please make sure you are using the latest version of Jellyfin.
|
||||
|
||||
When looking for support or information, please first search for your
|
||||
question in these venues:
|
||||
|
||||
* [Jellyfin Forum](https://forum.jellyfin.org)
|
||||
* [Jellyfin Documentation](https://jellyfin.org/docs/)
|
||||
* [Open or **closed** issues in the organization](https://github.com/issues?q=sort%3Aupdated-desc+org%3Ajellyfin+is%3Aissue+)
|
||||
|
||||
If you didn't find an answer in the resources above, contributors and other
|
||||
users are reachable through the following channels:
|
||||
|
||||
* #jellyfin on [Matrix](https://matrix.to/#/#jellyfin:matrix.org%22) or [IRC](ircs://irc.libera.chat:6697/#jellyfin)
|
||||
* #jellyfin-troubleshooting on [Matrix](https://matrix.to/#/#jellyfin-troubleshooting:matrix.org) or [IRC](ircs://irc.libera.chat:6697/#jellyfin-troubleshooting)
|
||||
* [/r/jellyfin on Reddit](https://www.reddit.com/r/jellyfin)
|
||||
|
||||
GitHub issues are for tracking enhancements and bugs, not general support.
|
||||
|
||||
The open source license grants you the freedom to use Jellyfin.
|
||||
It does not guarantee commitments of other people's time.
|
||||
Please be respectful and manage your expectations.
|
||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -1,6 +1,6 @@
|
||||
<!--
|
||||
Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y).
|
||||
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://jellyfin.org/docs/general/contributing/issues page.
|
||||
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://docs.jellyfin.org/general/contributing/issues.html page.
|
||||
-->
|
||||
|
||||
**Changes**
|
||||
|
||||
8
.github/renovate.json
vendored
8
.github/renovate.json
vendored
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"github>jellyfin/.github//renovate-presets/nodejs",
|
||||
":semanticCommitsDisabled",
|
||||
":dependencyDashboard"
|
||||
]
|
||||
}
|
||||
20
.github/stale.yml
vendored
Normal file
20
.github/stale.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 90
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 14
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- regression
|
||||
- future
|
||||
- feature
|
||||
- enhancement
|
||||
- confirmed
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: stale
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
Issues go stale after 90d of inactivity. Mark the issue as fresh by adding a comment or commit. Stale issues close after an additional 14d of inactivity.
|
||||
If this issue is safe to close now please do so.
|
||||
If you have any questions you can reach us on [Matrix or Social Media](https://docs.jellyfin.org/general/getting-help.html).
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
23
.github/workflows/automation.yml
vendored
23
.github/workflows/automation.yml
vendored
@@ -1,23 +0,0 @@
|
||||
name: Automation
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request_target:
|
||||
|
||||
jobs:
|
||||
conflicts:
|
||||
name: Merge conflict labeling
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||
steps:
|
||||
- uses: eps1lon/actions-label-merge-conflict@1b1b1fcde06a9b3d089f3464c96417961dde1168 # v3.0.2
|
||||
with:
|
||||
dirtyLabel: 'merge conflict'
|
||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||
repoToken: ${{ secrets.JF_BOT_TOKEN }}
|
||||
129
.github/workflows/build.yml
vendored
129
.github/workflows/build.yml
vendored
@@ -1,129 +0,0 @@
|
||||
name: Build
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, release* ]
|
||||
pull_request_target:
|
||||
branches: [ master, release* ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
run-build-prod:
|
||||
name: Run production build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
cache: npm
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run a production build
|
||||
env:
|
||||
JELLYFIN_VERSION: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: npm run build:production
|
||||
|
||||
- name: Update config.json for testing
|
||||
run: |
|
||||
jq '.multiserver=true | .servers=["https://demo.jellyfin.org/unstable"]' dist/config.json > dist/config.tmp.json
|
||||
mv dist/config.tmp.json dist/config.json
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
with:
|
||||
name: jellyfin-web__prod
|
||||
path: dist
|
||||
|
||||
publish:
|
||||
name: Deploy to Cloudflare Pages
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||
needs:
|
||||
- run-build-prod
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
|
||||
steps:
|
||||
- name: Add comment
|
||||
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
|
||||
if: ${{ github.event_name == 'pull_request_target' }}
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
message: |
|
||||
## Cloudflare Pages deployment
|
||||
|
||||
| **Latest commit** | <code>${{ github.event.pull_request.head.sha || github.sha }}</code> |
|
||||
|-------------------|:-:|
|
||||
| **Status** | 🔄 Deploying... |
|
||||
| **Preview URL** | Not available |
|
||||
| **Type** | 🔀 Preview |
|
||||
pr_number: ${{ github.event.pull_request.number }}
|
||||
comment_tag: CFPages-deployment
|
||||
mode: recreate
|
||||
|
||||
- name: Download workflow artifact
|
||||
uses: dawidd6/action-download-artifact@09f2f74827fd3a8607589e5ad7f9398816f540fe # v3.1.4
|
||||
with:
|
||||
name: jellyfin-web__prod
|
||||
path: dist
|
||||
|
||||
- name: Publish to Cloudflare
|
||||
id: cf
|
||||
uses: cloudflare/wrangler-action@f84a562284fc78278ff9052435d9526f9c718361 # v3.7.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: pages deploy dist --project-name=jellyfin-web --branch=${{
|
||||
(github.event_name != 'pull_request_target' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
&& (github.event.pull_request.head.ref || github.ref_name)
|
||||
|| format('{0}/{1}', github.event.pull_request.head.repo.full_name, github.event.pull_request.head.ref)
|
||||
}} --commit-hash=${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Update status comment (Success)
|
||||
if: ${{ github.event_name == 'pull_request_target' && success() }}
|
||||
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
message: |
|
||||
## Cloudflare Pages deployment
|
||||
|
||||
| **Latest commit** | <code>${{ github.event.pull_request.head.sha || github.sha }}</code> |
|
||||
|-------------------|:-:|
|
||||
| **Status** | ✅ Deployed! |
|
||||
| **Preview URL** | ${{ steps.cf.outputs.deployment-url != '' && steps.cf.outputs.deployment-url || 'Not available' }} |
|
||||
| **Type** | 🔀 Preview |
|
||||
pr_number: ${{ github.event.pull_request.number }}
|
||||
comment_tag: CFPages-deployment
|
||||
mode: recreate
|
||||
|
||||
- name: Update status comment (Failure)
|
||||
if: ${{ github.event_name == 'pull_request_target' && failure() }}
|
||||
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
message: |
|
||||
## Cloudflare Pages deployment
|
||||
|
||||
| **Latest commit** | <code>${{ github.event.pull_request.head.sha || github.sha }}</code> |
|
||||
|-------------------|:-:|
|
||||
| **Status** | ❌ Failure. Check workflow logs for details |
|
||||
| **Preview URL** | Not available |
|
||||
| **Type** | 🔀 Preview |
|
||||
pr_number: ${{ github.event.pull_request.number }}
|
||||
comment_tag: CFPages-deployment
|
||||
mode: recreate
|
||||
34
.github/workflows/codeql.yml
vendored
34
.github/workflows/codeql.yml
vendored
@@ -1,34 +0,0 @@
|
||||
name: CodeQL
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, release* ]
|
||||
pull_request:
|
||||
branches: [ master, release* ]
|
||||
schedule:
|
||||
- cron: '30 7 * * 6'
|
||||
|
||||
jobs:
|
||||
codeql:
|
||||
name: Run CodeQL
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@eb055d739abdc2e8de2e5f4ba1a8b246daa779aa # v3.26.0
|
||||
with:
|
||||
languages: javascript
|
||||
queries: +security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@eb055d739abdc2e8de2e5f4ba1a8b246daa779aa # v3.26.0
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@eb055d739abdc2e8de2e5f4ba1a8b246daa779aa # v3.26.0
|
||||
36
.github/workflows/commands.yml
vendored
36
.github/workflows/commands.yml
vendored
@@ -1,36 +0,0 @@
|
||||
name: Commands
|
||||
on:
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
|
||||
jobs:
|
||||
rebase:
|
||||
name: Rebase
|
||||
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '@jellyfin-bot rebase') && github.event.comment.author_association == 'MEMBER'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify as seen
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
reactions: '+1'
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
- name: Automatic Rebase
|
||||
uses: cirrus-actions/rebase@b87d48154a87a85666003575337e27b8cd65f691 # 1.8
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
- name: Comment on failure
|
||||
if: failure()
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
I'm sorry @${{ github.event.comment.user.login }}, I'm afraid I can't do that.
|
||||
36
.github/workflows/pr-suggestions.yml
vendored
36
.github/workflows/pr-suggestions.yml
vendored
@@ -1,36 +0,0 @@
|
||||
name: PR suggestions
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.id || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [ master, release* ]
|
||||
|
||||
jobs:
|
||||
run-eslint:
|
||||
name: Run eslint suggestions
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
cache: npm
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run eslint
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||
uses: CatChen/eslint-suggestion-action@bc82950fa97bb3e46d9cca16a8bf2ad3e3c010fc # v4.1.5
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
123
.github/workflows/quality.yml
vendored
123
.github/workflows/quality.yml
vendored
@@ -1,123 +0,0 @@
|
||||
name: Quality checks
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, release* ]
|
||||
pull_request:
|
||||
branches: [ master, release* ]
|
||||
|
||||
jobs:
|
||||
run-escheck:
|
||||
name: Run es-check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
cache: npm
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run a production build
|
||||
run: npm run build:production
|
||||
|
||||
- name: Run es-check
|
||||
run: npm run escheck
|
||||
|
||||
run-eslint:
|
||||
name: Run eslint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
cache: npm
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run eslint
|
||||
run: npx eslint --quiet "."
|
||||
|
||||
run-stylelint:
|
||||
name: Run stylelint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
cache: npm
|
||||
|
||||
- name: Set up stylelint matcher
|
||||
uses: xt0rted/stylelint-problem-matcher@34db1b874c0452909f0696aedef70b723870a583 # tag=v1
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run stylelint
|
||||
run: npm run stylelint
|
||||
|
||||
run-tsc:
|
||||
name: Run TypeScript build check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
cache: npm
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run tsc
|
||||
run: npm run build:check
|
||||
|
||||
run-test:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
cache: npm
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run test suite
|
||||
run: npm run test
|
||||
51
.github/workflows/stale.yml
vendored
51
.github/workflows/stale.yml
vendored
@@ -1,51 +0,0 @@
|
||||
name: Stale Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
issues:
|
||||
name: Check issues
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
operations-per-run: 75
|
||||
days-before-stale: 120
|
||||
days-before-pr-stale: -1
|
||||
days-before-close: 21
|
||||
days-before-pr-close: -1
|
||||
exempt-issue-labels: regression,security,roadmap,future,feature,enhancement,confirmed
|
||||
stale-issue-label: stale
|
||||
stale-issue-message: |-
|
||||
This issue has gone 120 days without comment. To avoid abandoned issues, it will be closed in 21 days if there are no new comments.
|
||||
|
||||
If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or master branch, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label.
|
||||
|
||||
This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://jellyfin.org/contact).
|
||||
|
||||
prs-conflicts:
|
||||
name: Check PRs with merge conflicts
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
operations-per-run: 75
|
||||
# The merge conflict action will remove the label when updated
|
||||
remove-stale-when-updated: false
|
||||
days-before-stale: -1
|
||||
days-before-close: 90
|
||||
days-before-issue-close: -1
|
||||
stale-pr-label: merge conflict
|
||||
close-pr-message: |-
|
||||
This PR has been closed due to having unresolved merge conflicts.
|
||||
52
.github/workflows/update-sdk.yml
vendored
52
.github/workflows/update-sdk.yml
vendored
@@ -1,52 +0,0 @@
|
||||
name: Update the Jellyfin SDK
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 7 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: unstable-sdk-pr
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
ref: master
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
cache: npm
|
||||
|
||||
- name: Install latest unstable SDK
|
||||
run: |
|
||||
npm i --save @jellyfin/sdk@unstable
|
||||
VERSION=$(jq -r '.dependencies["@jellyfin/sdk"]' package.json)
|
||||
echo "JF_SDK_VERSION=${VERSION}" >> $GITHUB_ENV
|
||||
|
||||
- name: Open a pull request
|
||||
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
commit-message: Update @jellyfin/sdk to ${{env.JF_SDK_VERSION}}
|
||||
committer: jellyfin-bot <team@jellyfin.org>
|
||||
author: jellyfin-bot <team@jellyfin.org>
|
||||
branch: update-jf-sdk
|
||||
delete-branch: true
|
||||
title: Update @jellyfin/sdk to ${{env.JF_SDK_VERSION}}
|
||||
body: |
|
||||
**Changes**
|
||||
Updates to the latest unstable @jellyfin/sdk build
|
||||
labels: |
|
||||
dependencies
|
||||
npm
|
||||
584
.gitignore
vendored
584
.gitignore
vendored
@@ -1,18 +1,578 @@
|
||||
# npm
|
||||
dist
|
||||
web
|
||||
node_modules
|
||||
|
||||
# test coverage
|
||||
# Created by https://www.gitignore.io/api/node,rider,macos,linux,windows,visualstudio,visualstudiocode
|
||||
# Edit at https://www.gitignore.io/?templates=node,rider,macos,linux,windows,visualstudio,visualstudiocode
|
||||
|
||||
### Linux ###
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
### macOS ###
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# config
|
||||
config.json
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# ide
|
||||
.idea
|
||||
.vs
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# vim
|
||||
*.sw?
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Dependency lockfile
|
||||
package-lock.json
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
### Rider ###
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
### VisualStudioCode Patch ###
|
||||
# Ignore all local history of files
|
||||
.history
|
||||
|
||||
### Windows ###
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
### VisualStudio ###
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUNIT
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# JustCode is a .NET coding add-in
|
||||
.JustCode
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
# ASP.NET Core default setup: bower directory is configured as wwwroot/lib/ and bower restore is true
|
||||
**/wwwroot/lib/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- Backup*.rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# JetBrains Rider
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# End of https://www.gitignore.io/api/node,rider,macos,linux,windows,visualstudio,visualstudiocode
|
||||
|
||||
# dist for webpack output
|
||||
dist
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Exclude test files from Sonar sources
|
||||
# See: https://docs.sonarcloud.io/advanced-setup/analysis-scope/#file-exclusion-and-inclusion
|
||||
sonar.exclusions=src/**/*.test.js,src/**/*.test.ts
|
||||
@@ -1,19 +1,18 @@
|
||||
{
|
||||
"plugins": [
|
||||
"stylelint-no-browser-hacks/lib"
|
||||
],
|
||||
"stylelint-no-browser-hacks/lib",
|
||||
],
|
||||
"rules": {
|
||||
"at-rule-empty-line-before": [ "always", {
|
||||
"except": [
|
||||
except: [
|
||||
"blockless-after-same-name-blockless",
|
||||
"first-nested"
|
||||
"first-nested",
|
||||
],
|
||||
"ignore": ["after-comment"]
|
||||
ignore: ["after-comment"],
|
||||
} ],
|
||||
"at-rule-name-case": "lower",
|
||||
"at-rule-name-space-after": "always-single-line",
|
||||
"at-rule-no-unknown": true,
|
||||
"at-rule-no-vendor-prefix": true,
|
||||
"at-rule-semicolon-newline-after": "always",
|
||||
"block-closing-brace-empty-line-before": "never",
|
||||
"block-closing-brace-newline-after": "always",
|
||||
@@ -27,27 +26,27 @@
|
||||
"color-hex-length": "short",
|
||||
"color-no-invalid-hex": true,
|
||||
"comment-empty-line-before": [ "always", {
|
||||
"except": ["first-nested"],
|
||||
"ignore": ["stylelint-commands"]
|
||||
except: ["first-nested"],
|
||||
ignore: ["stylelint-commands"],
|
||||
} ],
|
||||
"comment-no-empty": true,
|
||||
"comment-whitespace-inside": "always",
|
||||
"custom-property-empty-line-before": [ "always", {
|
||||
"except": [
|
||||
except: [
|
||||
"after-custom-property",
|
||||
"first-nested"
|
||||
"first-nested",
|
||||
],
|
||||
"ignore": [
|
||||
ignore: [
|
||||
"after-comment",
|
||||
"inside-single-line-block"
|
||||
]
|
||||
"inside-single-line-block",
|
||||
],
|
||||
} ],
|
||||
"declaration-bang-space-after": "never",
|
||||
"declaration-bang-space-before": "always",
|
||||
"declaration-block-no-duplicate-properties": [
|
||||
true,
|
||||
{
|
||||
"ignore": ["consecutive-duplicates-with-different-values"]
|
||||
ignore: ["consecutive-duplicates-with-different-values"]
|
||||
}
|
||||
],
|
||||
"declaration-block-no-shorthand-property-overrides": true,
|
||||
@@ -60,6 +59,7 @@
|
||||
"declaration-colon-space-after": "always-single-line",
|
||||
"declaration-colon-space-before": "never",
|
||||
"font-family-no-duplicate-names": true,
|
||||
"function-calc-no-invalid": true,
|
||||
"function-calc-no-unspaced-operator": true,
|
||||
"function-comma-newline-after": "always-multi-line",
|
||||
"function-comma-space-after": "always-single-line",
|
||||
@@ -78,7 +78,6 @@
|
||||
"media-feature-colon-space-before": "never",
|
||||
"media-feature-name-case": "lower",
|
||||
"media-feature-name-no-unknown": true,
|
||||
"media-feature-name-no-vendor-prefix": true,
|
||||
"media-feature-parentheses-space-inside": "never",
|
||||
"media-feature-range-operator-space-after": "always",
|
||||
"media-feature-range-operator-space-before": "always",
|
||||
@@ -105,10 +104,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"property-no-vendor-prefix": true,
|
||||
"rule-empty-line-before": [ "always-multi-line", {
|
||||
"except": ["first-nested"],
|
||||
"ignore": ["after-comment"]
|
||||
except: ["first-nested"],
|
||||
ignore: ["after-comment"],
|
||||
} ],
|
||||
"selector-attribute-brackets-space-inside": "never",
|
||||
"selector-attribute-operator-space-after": "never",
|
||||
@@ -119,7 +117,6 @@
|
||||
"selector-list-comma-newline-after": "always",
|
||||
"selector-list-comma-space-before": "never",
|
||||
"selector-max-empty-lines": 0,
|
||||
"selector-no-vendor-prefix": true,
|
||||
"selector-pseudo-class-case": "lower",
|
||||
"selector-pseudo-class-no-unknown": true,
|
||||
"selector-pseudo-class-parentheses-space-inside": "never",
|
||||
@@ -138,25 +135,9 @@
|
||||
"string-no-newline": true,
|
||||
"unit-case": "lower",
|
||||
"unit-no-unknown": true,
|
||||
"value-no-vendor-prefix": true,
|
||||
"value-list-comma-newline-after": "always-multi-line",
|
||||
"value-list-comma-space-after": "always-single-line",
|
||||
"value-list-comma-space-before": "never",
|
||||
"value-list-max-empty-lines": 0
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.scss",
|
||||
"**/*.scss"
|
||||
],
|
||||
"customSyntax": "postcss-scss",
|
||||
"plugins": [ "stylelint-scss" ],
|
||||
"rules": {
|
||||
"at-rule-no-unknown": null,
|
||||
"scss/at-rule-no-unknown": true,
|
||||
"plugin/no-browser-hacks": null
|
||||
}
|
||||
}
|
||||
]
|
||||
"value-list-max-empty-lines": 0,
|
||||
}
|
||||
}
|
||||
5
.vscode/extensions.json
vendored
5
.vscode/extensions.json
vendored
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"eslint.format.enable": true,
|
||||
"editor.formatOnSave": false
|
||||
}
|
||||
253
CONTRIBUTORS.md
253
CONTRIBUTORS.md
@@ -1,162 +1,99 @@
|
||||
# Jellyfin Contributors
|
||||
|
||||
- [JoshuaBoniface](https://github.com/joshuaboniface)
|
||||
- [nvllsvm](https://github.com/nvllsvm)
|
||||
- [JustAMan](https://github.com/JustAMan)
|
||||
- [dcrdev](https://github.com/dcrdev)
|
||||
- [EraYaN](https://github.com/EraYaN)
|
||||
- [flemse](https://github.com/flemse)
|
||||
- [bfayers](https://github.com/bfayers)
|
||||
- [Bond_009](https://github.com/Bond-009)
|
||||
- [AnthonyLavado](https://github.com/anthonylavado)
|
||||
- [dkanada](https://github.com/dkanada)
|
||||
- [sparky8251](https://github.com/sparky8251)
|
||||
- [LeoVerto](https://github.com/LeoVerto)
|
||||
- [cvium](https://github.com/cvium)
|
||||
- [grafixeyehero](https://github.com/grafixeyehero)
|
||||
- [Drago96](https://github.com/drago-96)
|
||||
- [ViXXoR](https://github.com/ViXXoR)
|
||||
- [nkmerrill](https://github.com/nkmerrill)
|
||||
- [TtheCreator](https://github.com/Tthecreator)
|
||||
- [RazeLighter777](https://github.com/RazeLighter777)
|
||||
- [LogicalPhallacy](https://github.com/LogicalPhallacy)
|
||||
- [thornbill](https://github.com/thornbill)
|
||||
- [redSpoutnik](https://github.com/redSpoutnik)
|
||||
- [DrPandemic](https://github.com/drpandemic)
|
||||
- [Oddstr13](https://github.com/oddstr13)
|
||||
- [petermcneil](https://github.com/petermcneil)
|
||||
- [lewazo](https://github.com/lewazo)
|
||||
- [Raghu Saxena](https://github.com/ckcr4lyf)
|
||||
- [Nickbert7](https://github.com/Nickbert7)
|
||||
- [ferferga](https://github.com/ferferga)
|
||||
- [bilde2910](https://github.com/bilde2910)
|
||||
- [Daniel Hartung](https://github.com/dhartung)
|
||||
- [Ryan Hartzell](https://github.com/ryan-hartzell)
|
||||
- [Thibault Nocchi](https://github.com/ThibaultNocchi)
|
||||
- [MrTimscampi](https://github.com/MrTimscampi)
|
||||
- [artiume](https://github.com/Artiume)
|
||||
- [ConfusedPolarBear](https://github.com/ConfusedPolarBear)
|
||||
- [Sarab Singh](https://github.com/sarab97)
|
||||
- [DesertCookie](https://github.com/desertcookie)
|
||||
- [GuilhermeHideki](https://github.com/GuilhermeHideki)
|
||||
- [Andrei Oanca](https://github.com/OancaAndrei)
|
||||
- [Cromefire_](https://github.com/cromefire)
|
||||
- [Orry Verducci](https://github.com/orryverducci)
|
||||
- [Camc314](https://github.com/camc314)
|
||||
- [danieladov](https://github.com/danieladov)
|
||||
- [Stephane Senart](https://github.com/ssenart)
|
||||
- [imchasingshadows](https://github.com/imchasingshadows)
|
||||
- [Ömer Erdinç Yağmurlu](https://github.com/omeryagmurlu)
|
||||
- [Keegan Dahm](https://github.com/keegandahm)
|
||||
- [GodTamIt](https://github.com/GodTamIt)
|
||||
- [MinecraftPlaye](https://github.com/MinecraftPlaye)
|
||||
- [Matthew Jones](https://github.com/matthew-jones-uk)
|
||||
- [taku0](https://github.com/taku0)
|
||||
- [Viperinius](https://github.com/Viperinius)
|
||||
- [is343](https://github.com/is343)
|
||||
- [Meet Pandya](https://github.com/meet-k-pandya)
|
||||
- [Peter Spenler](https://github.com/peterspenler)
|
||||
- [jomp16](https://github.com/jomp16)
|
||||
- [Leon de Klerk](https://github.com/leondeklerk)
|
||||
- [CrispyBaguette](https://github.com/CrispyBaguette)
|
||||
- [Vankerkom](https://github.com/vankerkom)
|
||||
- [edvwib](https://github.com/edvwib)
|
||||
- [Rob Farraher](https://github.com/farraherbg)
|
||||
- [TelepathicWalrus](https://github.com/TelepathicWalrus)
|
||||
- [Pier-Luc Ducharme](https://github.com/pl-ducharme)
|
||||
- [Anantharaju S](https://github.com/Anantharajus)
|
||||
- [Merlin Sievers](https://github.com/dann-merlin)
|
||||
- [Fishbigger](https://github.com/fishbigger)
|
||||
- [sleepycatcoding](https://github.com/sleepycatcoding)
|
||||
- [TheMelmacian](https://github.com/TheMelmacian)
|
||||
- [v0idMrK](https://github.com/v0idMrK)
|
||||
- [tehciolo](https://github.com/tehciolo)
|
||||
- [scampower3](https://github.com/scampower3)
|
||||
- [LittleBigOwI](https://github.com/LittleBigOwI/)
|
||||
- [Nate G](https://github.com/GGProGaming)
|
||||
- [Grady Hallenbeck](https://github.com/grhallenbeck)
|
||||
- [DinuD](https://github.com/DinuD)
|
||||
- [Kevin Tan (Valius)](https://github.com/valius)
|
||||
- [Rasmus Krämer](https://github.com/rasmuslos)
|
||||
- [ntarelix](https://github.com/ntarelix)
|
||||
- [btopherjohnson](https://github.com/btopherjohnson)
|
||||
- [András Maróy](https://github.com/andrasmaroy)
|
||||
- [Chris-Codes-It](https://github.com/Chris-Codes-It)
|
||||
- [Vedant](https://github.com/viktory36)
|
||||
- [GeorgeH005](https://github.com/GeorgeH005)
|
||||
- [JPUC1143](https://github.com/Jpuc1143)
|
||||
- [David Angel](https://github.com/davidangel)
|
||||
- [Pithaya](https://github.com/Pithaya)
|
||||
- [Peter Santos](https://github.com/prsantos-com)
|
||||
- [Chaitanya Shahare](https://github.com/Chaitanya-Shahare)
|
||||
- [Venkat Karasani](https://github.com/venkat-karasani)
|
||||
- [Connor Smith](https://github.com/ConnorS1110)
|
||||
- [iFraan](https://github.com/iFraan)
|
||||
- [JoshuaBoniface](https://github.com/joshuaboniface)
|
||||
- [nvllsvm](https://github.com/nvllsvm)
|
||||
- [JustAMan](https://github.com/JustAMan)
|
||||
- [dcrdev](https://github.com/dcrdev)
|
||||
- [EraYaN](https://github.com/EraYaN)
|
||||
- [flemse](https://github.com/flemse)
|
||||
- [bfayers](https://github.com/bfayers)
|
||||
- [Bond_009](https://github.com/Bond-009)
|
||||
- [AnthonyLavado](https://github.com/anthonylavado)
|
||||
- [dkanada](https://github.com/dkanada)
|
||||
- [sparky8251](https://github.com/sparky8251)
|
||||
- [LeoVerto](https://github.com/LeoVerto)
|
||||
- [cvium](https://github.com/cvium)
|
||||
- [grafixeyehero](https://github.com/grafixeyehero)
|
||||
- [Drago96](https://github.com/drago-96)
|
||||
- [ViXXoR](https://github.com/ViXXoR)
|
||||
- [nkmerrill](https://github.com/nkmerrill)
|
||||
- [TtheCreator](https://github.com/Tthecreator)
|
||||
- [RazeLighter777](https://github.com/RazeLighter777)
|
||||
- [LogicalPhallacy](https://github.com/LogicalPhallacy)
|
||||
- [thornbill](https://github.com/thornbill)
|
||||
- [redSpoutnik](https://github.com/redSpoutnik)
|
||||
- [DrPandemic](https://github.com/drpandemic)
|
||||
- [Oddstr13](https://github.com/oddstr13)
|
||||
- [petermcneil](https://github.com/petermcneil)
|
||||
- [lewazo](https://github.com/lewazo)
|
||||
- [Raghu Saxena](https://github.com/ckcr4lyf)
|
||||
- [Nickbert7](https://github.com/Nickbert7)
|
||||
- [ferferga](https://github.com/ferferga)
|
||||
- [bilde2910](https://github.com/bilde2910)
|
||||
- [Daniel Hartung](https://github.com/dhartung)
|
||||
- [Ryan Hartzell](https://github.com/ryan-hartzell)
|
||||
- [Thibault Nocchi](https://github.com/ThibaultNocchi)
|
||||
- [MrTimscampi](https://github.com/MrTimscampi)
|
||||
|
||||
## Emby Contributors
|
||||
# Emby Contributors
|
||||
|
||||
- [LukePulverenti](https://github.com/LukePulverenti)
|
||||
- [ebr11](https://github.com/ebr11)
|
||||
- [lalmanzar](https://github.com/lalmanzar)
|
||||
- [schneifu](https://github.com/schneifu)
|
||||
- [Mark2xv](https://github.com/Mark2xv)
|
||||
- [ScottRapsey](https://github.com/ScottRapsey)
|
||||
- [skynet600](https://github.com/skynet600)
|
||||
- [Cheesegeezer](https://githum.com/Cheesegeezer)
|
||||
- [Radeon](https://github.com/radeonorama)
|
||||
- [gcw07](https://github.com/gcw07)
|
||||
- [SivaramAdhiappan](https://github.com/shivaram1190)
|
||||
- [CWatkinsNash](https://github.com/CWatkinsNash)
|
||||
- [sfnetwork](https://github.com/sfnetwork)
|
||||
- [Logos302](https://github.com/Logos302)
|
||||
- [TheWorkz](https://github.com/TheWorkz)
|
||||
- [mboehler](https://github.com/mboehler)
|
||||
- [KaHooli](https://github.com/KaHooli)
|
||||
- [xzener](https://github.com/xzener)
|
||||
- [CBers](https://github.com/CBers)
|
||||
- [Sagaia](https://github.com/Sagaia)
|
||||
- [JHawk111](https://github.com/JHawk111)
|
||||
- [David3663](https://github.com/david3663)
|
||||
- [Smyken](https://github.com/Smyken)
|
||||
- [doron1](https://github.com/doron1)
|
||||
- [brainfryd](https://github.com/brainfryd)
|
||||
- [DGMayor](http://github.com/DGMayor)
|
||||
- [Jon-theHTPC](https://github.com/Jon-theHTPC)
|
||||
- [aspdend](https://github.com/aspdend)
|
||||
- [RedshirtMB](https://github.com/RedshirtMB)
|
||||
- [thealienamongus](https://github.com/thealienamongus)
|
||||
- [brocass](https://github.com/brocass)
|
||||
- [pjrollo2000](https://github.com/pjrollo2000)
|
||||
- [abobader](https://github.com/abobader)
|
||||
- [milli260876](https://github.com/milli260876)
|
||||
- [vileboy](https://github.com/vileboy)
|
||||
- [starkadius](https://github.com/starkadius)
|
||||
- [wraslor](https://github.com/wraslor)
|
||||
- [mrwebsmith](https://github.com/mrwebsmith)
|
||||
- [rickster53](https://github.com/rickster53)
|
||||
- [Tharnax](https://github.com/Tharnax)
|
||||
- [0sm0](https://github.com/0sm0)
|
||||
- [swhitmore](https://github.com/swhitmore)
|
||||
- [DigiTM](https://github.com/DigiTM)
|
||||
- [crisliv / xliv](https://github.com/crisliv)
|
||||
- [Yogi](https://github.com/yogi12)
|
||||
- [madFloyd](https://github.com/madFloyd)
|
||||
- [yardameus](https://github.com/yardameus)
|
||||
- [rrb008](https://github.com/rrb008)
|
||||
- [Toonguy](https://github.com/Toonguy)
|
||||
- [Alwin Hummels](https://github.com/AlwinHummels)
|
||||
- [trooper11](https://github.com/trooper11)
|
||||
- [danlotfy](https://github.com/danlotfy)
|
||||
- [jordy1955](https://github.com/jordy1955)
|
||||
- [JoshFink](https://github.com/JoshFink)
|
||||
- [Detector1](https://github.com/Detector1)
|
||||
- [BlackIce013](https://github.com/blackice013)
|
||||
- [mporcas](https://github.com/mporcas)
|
||||
- [tikuf](https://github.com/tikuf/)
|
||||
- [Tim Hobbs](https://github.com/timhobbs)
|
||||
- [SvenVandenbrande](https://github.com/SvenVandenbrande)
|
||||
<!--
|
||||
NOTE: This is the end of the list of past Emby Contributors.
|
||||
New Jellyfin contributors should add their name to the end
|
||||
of the list of Jellyfin Contributors above. NOT HERE ;)
|
||||
-->
|
||||
- [LukePulverenti](https://github.com/LukePulverenti)
|
||||
- [ebr11](https://github.com/ebr11)
|
||||
- [lalmanzar](https://github.com/lalmanzar)
|
||||
- [schneifu](https://github.com/schneifu)
|
||||
- [Mark2xv](https://github.com/Mark2xv)
|
||||
- [ScottRapsey](https://github.com/ScottRapsey)
|
||||
- [skynet600](https://github.com/skynet600)
|
||||
- [Cheesegeezer](https://githum.com/Cheesegeezer)
|
||||
- [Radeon](https://github.com/radeonorama)
|
||||
- [gcw07](https://github.com/gcw07)
|
||||
- [SivaramAdhiappan](https://github.com/shivaram1190)
|
||||
- [CWatkinsNash](https://github.com/CWatkinsNash)
|
||||
- [sfnetwork](https://github.com/sfnetwork)
|
||||
- [Logos302](https://github.com/Logos302)
|
||||
- [TheWorkz](https://github.com/TheWorkz)
|
||||
- [mboehler](https://github.com/mboehler)
|
||||
- [KaHooli](https://github.com/KaHooli)
|
||||
- [xzener](https://github.com/xzener)
|
||||
- [CBers](https://github.com/CBers)
|
||||
- [Sagaia](https://github.com/Sagaia)
|
||||
- [JHawk111](https://github.com/JHawk111)
|
||||
- [David3663](https://github.com/david3663)
|
||||
- [Smyken](https://github.com/Smyken)
|
||||
- [doron1](https://github.com/doron1)
|
||||
- [brainfryd](https://github.com/brainfryd)
|
||||
- [DGMayor](http://github.com/DGMayor)
|
||||
- [Jon-theHTPC](https://github.com/Jon-theHTPC)
|
||||
- [aspdend](https://github.com/aspdend)
|
||||
- [RedshirtMB](https://github.com/RedshirtMB)
|
||||
- [thealienamongus](https://github.com/thealienamongus)
|
||||
- [brocass](https://github.com/brocass)
|
||||
- [pjrollo2000](https://github.com/pjrollo2000)
|
||||
- [abobader](https://github.com/abobader)
|
||||
- [milli260876](https://github.com/milli260876)
|
||||
- [vileboy](https://github.com/vileboy)
|
||||
- [starkadius](https://github.com/starkadius)
|
||||
- [wraslor](https://github.com/wraslor)
|
||||
- [mrwebsmith](https://github.com/mrwebsmith)
|
||||
- [rickster53](https://github.com/rickster53)
|
||||
- [Tharnax](https://github.com/Tharnax)
|
||||
- [0sm0](https://github.com/0sm0)
|
||||
- [swhitmore](https://github.com/swhitmore)
|
||||
- [DigiTM](https://github.com/DigiTM)
|
||||
- [crisliv / xliv](https://github.com/crisliv)
|
||||
- [Yogi](https://github.com/yogi12)
|
||||
- [madFloyd](https://github.com/madFloyd)
|
||||
- [yardameus](https://github.com/yardameus)
|
||||
- [rrb008](https://github.com/rrb008)
|
||||
- [Toonguy](https://github.com/Toonguy)
|
||||
- [Alwin Hummels](https://github.com/AlwinHummels)
|
||||
- [trooper11](https://github.com/trooper11)
|
||||
- [danlotfy](https://github.com/danlotfy)
|
||||
- [jordy1955](https://github.com/jordy1955)
|
||||
- [JoshFink](https://github.com/JoshFink)
|
||||
- [Detector1](https://github.com/Detector1)
|
||||
- [BlackIce013](https://github.com/blackice013)
|
||||
- [mporcas](https://github.com/mporcas)
|
||||
- [tikuf](https://github.com/tikuf/)
|
||||
- [Tim Hobbs](https://github.com/timhobbs)
|
||||
- [SvenVandenbrande](https://github.com/SvenVandenbrande)
|
||||
|
||||
51
README.md
51
README.md
@@ -23,6 +23,9 @@
|
||||
<a href="https://features.jellyfin.org">
|
||||
<img alt="Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/>
|
||||
</a>
|
||||
<a href="https://forum.jellyfin.org">
|
||||
<img alt="Discuss on our Forum" src="https://img.shields.io/discourse/https/forum.jellyfin.org/users.svg"/>
|
||||
</a>
|
||||
<a href="https://matrix.to/#/+jellyfin:matrix.org">
|
||||
<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfin:matrix.org.svg?logo=matrix"/>
|
||||
</a>
|
||||
@@ -41,63 +44,21 @@ Jellyfin Web is the frontend used for most of the clients available for end user
|
||||
|
||||
### Dependencies
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download)
|
||||
- npm (included in Node.js)
|
||||
- Yarn
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Clone or download this repository.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/jellyfin/jellyfin-web.git
|
||||
cd jellyfin-web
|
||||
```
|
||||
|
||||
2. Install build dependencies in the project directory.
|
||||
|
||||
```sh
|
||||
npm install
|
||||
yarn install
|
||||
```
|
||||
|
||||
3. Run the web client with webpack for local development.
|
||||
|
||||
```sh
|
||||
npm start
|
||||
yarn serve
|
||||
```
|
||||
|
||||
4. Build the client with sourcemaps available.
|
||||
|
||||
```sh
|
||||
npm run build:development
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
.
|
||||
└── src
|
||||
├── apps
|
||||
│ ├── dashboard # Admin dashboard app layout and routes
|
||||
│ ├── experimental # New experimental app layout and routes
|
||||
│ └── stable # Classic (stable) app layout and routes
|
||||
├── assets # Static assets
|
||||
├── components # Higher order visual components and React components
|
||||
├── controllers # Legacy page views and controllers 🧹
|
||||
├── elements # Basic webcomponents and React wrappers 🧹
|
||||
├── 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
|
||||
├── scripts # Random assortment of visual components and utilities 🐉
|
||||
├── strings # Translation files
|
||||
├── styles # Common app Sass stylesheets
|
||||
├── themes # CSS themes
|
||||
├── types # Common TypeScript interfaces/types
|
||||
└── utils # Utility functions
|
||||
```
|
||||
|
||||
- 🧹 — Needs cleanup
|
||||
- 🐉 — Serious mess (Here be dragons)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
module.exports = {
|
||||
babelrcRoots: [
|
||||
// Keep the root as a root
|
||||
'.'
|
||||
],
|
||||
sourceType: 'unambiguous',
|
||||
presets: [
|
||||
[
|
||||
'@babel/preset-env',
|
||||
{
|
||||
useBuiltIns: 'usage',
|
||||
corejs: 3
|
||||
}
|
||||
],
|
||||
'@babel/preset-react'
|
||||
],
|
||||
plugins: [
|
||||
'@babel/plugin-transform-class-properties',
|
||||
'@babel/plugin-transform-private-methods',
|
||||
'babel-plugin-dynamic-import-polyfill'
|
||||
]
|
||||
};
|
||||
29
bump_version
29
bump_version
@@ -1,29 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# bump_version - increase the shared version and generate changelogs
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o xtrace
|
||||
|
||||
usage() {
|
||||
echo -e "bump_version - increase the shared version"
|
||||
echo -e ""
|
||||
echo -e "Usage:"
|
||||
echo -e " $ bump_version <new_version>"
|
||||
}
|
||||
|
||||
if [[ -z $1 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
new_version="$1"
|
||||
new_version_sed="$( cut -f1 -d'-' <<<"${new_version}" )"
|
||||
|
||||
# Bump the NPM version
|
||||
npm --no-git-tag-version --allow-same-version version v${new_version_sed}
|
||||
|
||||
# Stage the changed files for commit
|
||||
git add .
|
||||
git status -v
|
||||
@@ -1,10 +0,0 @@
|
||||
module.exports = {
|
||||
preset: [
|
||||
'default',
|
||||
// Turn off `mergeLonghand` because it combines `padding-*` and `margin-*`,
|
||||
// breaking fallback styles.
|
||||
// https://github.com/cssnano/cssnano/issues/1163
|
||||
// https://github.com/cssnano/cssnano/issues/1192
|
||||
{ mergeLonghand: false }
|
||||
]
|
||||
};
|
||||
41598
package-lock.json
generated
41598
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
179
package.json
179
package.json
@@ -1,163 +1,66 @@
|
||||
{
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.10.0",
|
||||
"version": "0.0.0",
|
||||
"description": "Web interface for Jellyfin",
|
||||
"repository": "https://github.com/jellyfin/jellyfin-web",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.24.9",
|
||||
"@babel/plugin-transform-class-properties": "7.24.7",
|
||||
"@babel/plugin-transform-modules-umd": "7.24.7",
|
||||
"@babel/plugin-transform-private-methods": "7.24.7",
|
||||
"@babel/preset-env": "7.24.8",
|
||||
"@babel/preset-react": "7.24.7",
|
||||
"@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.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@types/sortablejs": "1.15.8",
|
||||
"@typescript-eslint/eslint-plugin": "5.62.0",
|
||||
"@typescript-eslint/parser": "5.62.0",
|
||||
"@uupaa/dynamic-import-polyfill": "1.0.2",
|
||||
"@vitest/coverage-v8": "2.0.5",
|
||||
"autoprefixer": "10.4.19",
|
||||
"babel-loader": "9.1.3",
|
||||
"babel-plugin-dynamic-import-polyfill": "1.0.0",
|
||||
"clean-webpack-plugin": "4.0.0",
|
||||
"confusing-browser-globals": "1.0.11",
|
||||
"copy-webpack-plugin": "12.0.2",
|
||||
"cross-env": "7.0.3",
|
||||
"css-loader": "7.1.2",
|
||||
"cssnano": "7.0.4",
|
||||
"es-check": "7.2.1",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-compat": "4.2.0",
|
||||
"eslint-plugin-eslint-comments": "3.2.0",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "6.9.0",
|
||||
"eslint-plugin-react": "7.35.0",
|
||||
"eslint-plugin-react-hooks": "4.6.2",
|
||||
"eslint-plugin-sonarjs": "0.25.1",
|
||||
"expose-loader": "5.0.0",
|
||||
"fork-ts-checker-webpack-plugin": "9.0.2",
|
||||
"html-loader": "5.1.0",
|
||||
"html-webpack-plugin": "5.6.0",
|
||||
"jsdom": "24.1.1",
|
||||
"mini-css-extract-plugin": "2.9.0",
|
||||
"postcss": "8.4.40",
|
||||
"postcss-loader": "8.1.1",
|
||||
"postcss-preset-env": "9.6.0",
|
||||
"postcss-scss": "4.0.9",
|
||||
"sass": "1.77.8",
|
||||
"sass-loader": "15.0.0",
|
||||
"source-map-loader": "5.0.0",
|
||||
"speed-measure-webpack-plugin": "1.5.0",
|
||||
"style-loader": "4.0.0",
|
||||
"stylelint": "15.11.0",
|
||||
"stylelint-config-rational-order": "0.1.2",
|
||||
"stylelint-no-browser-hacks": "1.3.0",
|
||||
"stylelint-order": "6.0.4",
|
||||
"stylelint-scss": "5.3.2",
|
||||
"ts-loader": "9.5.1",
|
||||
"typescript": "5.5.4",
|
||||
"vitest": "2.0.5",
|
||||
"webpack": "5.93.0",
|
||||
"webpack-bundle-analyzer": "4.10.2",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-dev-server": "5.0.4",
|
||||
"webpack-merge": "6.0.1",
|
||||
"worker-loader": "3.0.8"
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"copy-webpack-plugin": "^5.1.1",
|
||||
"css-loader": "^3.4.2",
|
||||
"eslint": "^6.8.0",
|
||||
"file-loader": "^5.0.2",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"style-loader": "^1.1.3",
|
||||
"stylelint": "^13.1.0",
|
||||
"stylelint-config-rational-order": "^0.1.2",
|
||||
"stylelint-no-browser-hacks": "^1.2.1",
|
||||
"stylelint-order": "^4.0.0",
|
||||
"webpack": "^4.41.5",
|
||||
"webpack-cli": "^3.3.10",
|
||||
"webpack-concat-plugin": "^3.0.0",
|
||||
"webpack-dev-server": "^3.10.3",
|
||||
"webpack-merge": "^4.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.13.0",
|
||||
"@emotion/styled": "11.13.0",
|
||||
"@fontsource/noto-sans": "5.0.22",
|
||||
"@fontsource/noto-sans-hk": "5.0.20",
|
||||
"@fontsource/noto-sans-jp": "5.0.19",
|
||||
"@fontsource/noto-sans-kr": "5.0.19",
|
||||
"@fontsource/noto-sans-sc": "5.0.20",
|
||||
"@fontsource/noto-sans-tc": "5.0.20",
|
||||
"@jellyfin/libass-wasm": "4.2.2",
|
||||
"@jellyfin/sdk": "0.0.0-unstable.202408050429",
|
||||
"@mui/icons-material": "5.15.19",
|
||||
"@mui/material": "5.15.19",
|
||||
"@mui/x-data-grid": "7.6.1",
|
||||
"@react-hook/resize-observer": "2.0.1",
|
||||
"@tanstack/react-query": "5.51.11",
|
||||
"@tanstack/react-query-devtools": "5.51.11",
|
||||
"@types/react-lazy-load-image-component": "1.6.4",
|
||||
"abortcontroller-polyfill": "1.7.5",
|
||||
"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.37.1",
|
||||
"date-fns": "2.30.0",
|
||||
"dompurify": "3.0.1",
|
||||
"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.5.13",
|
||||
"intersection-observer": "0.12.2",
|
||||
"jellyfin-apiclient": "1.11.0",
|
||||
"jquery": "3.7.1",
|
||||
"jstree": "3.3.16",
|
||||
"libarchive.js": "1.3.0",
|
||||
"lodash-es": "4.17.21",
|
||||
"markdown-it": "14.1.0",
|
||||
"material-design-icons-iconfont": "6.7.0",
|
||||
"native-promise-only": "0.8.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"react": "18.3.1",
|
||||
"react-blurhash": "0.3.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-lazy-load-image-component": "1.6.2",
|
||||
"react-router-dom": "6.25.1",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"screenfull": "6.0.2",
|
||||
"sortablejs": "1.15.2",
|
||||
"swiper": "11.1.7",
|
||||
"usehooks-ts": "3.1.0",
|
||||
"webcomponents.js": "0.7.24",
|
||||
"whatwg-fetch": "3.6.20"
|
||||
"alameda": "^1.4.0",
|
||||
"document-register-element": "^1.14.3",
|
||||
"flv.js": "^1.5.0",
|
||||
"hls.js": "^0.13.1",
|
||||
"howler": "^2.1.3",
|
||||
"jellyfin-noto": "https://github.com/jellyfin/jellyfin-noto",
|
||||
"jquery": "^3.4.1",
|
||||
"jstree": "^3.3.7",
|
||||
"libass-wasm": "https://github.com/jellyfin/JavascriptSubtitlesOctopus#4.0.0-jf-cordova",
|
||||
"libjass": "^0.11.0",
|
||||
"material-design-icons-iconfont": "^5.0.1",
|
||||
"native-promise-only": "^0.8.0-a",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"shaka-player": "^2.5.9",
|
||||
"sortablejs": "^1.10.2",
|
||||
"swiper": "^5.3.1",
|
||||
"webcomponents.js": "^0.7.24",
|
||||
"whatwg-fetch": "^3.0.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 Firefox versions",
|
||||
"last 2 Chrome versions",
|
||||
"last 2 ChromeAndroid versions",
|
||||
"last 2 Safari versions",
|
||||
"iOS > 10",
|
||||
"last 2 iOS versions",
|
||||
"last 2 Edge versions",
|
||||
"Chrome 27",
|
||||
"Chrome 38",
|
||||
"Chrome 47",
|
||||
"Chrome 53",
|
||||
"Chrome 56",
|
||||
"Chrome 63",
|
||||
"Edge 18",
|
||||
"Firefox ESR"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "npm run serve",
|
||||
"serve": "webpack serve --config webpack.dev.js",
|
||||
"build:analyze": "cross-env NODE_ENV=\"production\" webpack --config webpack.analyze.js",
|
||||
"build:development": "webpack --config webpack.dev.js",
|
||||
"build:production": "cross-env NODE_ENV=\"production\" webpack --config webpack.prod.js",
|
||||
"build:check": "tsc --noEmit",
|
||||
"escheck": "es-check",
|
||||
"lint": "eslint \"./\"",
|
||||
"test": "vitest --watch=false --config vite.config.ts",
|
||||
"test:watch": "vitest --config vite.config.ts",
|
||||
"stylelint": "stylelint \"src/**/*.{css,scss}\""
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"npm": ">=9.6.4",
|
||||
"yarn": "YARN NO LONGER USED - use npm instead."
|
||||
"serve": "webpack-dev-server --config webpack.dev.js --open",
|
||||
"build": "webpack --config webpack.prod.js",
|
||||
"lint": "eslint \"src\"",
|
||||
"stylelint": "stylelint \"src/**/*.css\"",
|
||||
"prepare": "webpack --config webpack.prod.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
const packageConfig = require('./package.json');
|
||||
const postcssPresetEnv = require('postcss-preset-env');
|
||||
const autoprefixer = require('autoprefixer');
|
||||
const cssnano = require('cssnano');
|
||||
|
||||
const config = () => ({
|
||||
plugins: [
|
||||
// Explicitly specify browserslist to override ones from node_modules
|
||||
// For example, Swiper has it in its package.json
|
||||
postcssPresetEnv({ browsers: packageConfig.browserslist }),
|
||||
autoprefixer({ overrideBrowserslist: packageConfig.browserslist }),
|
||||
cssnano()
|
||||
]
|
||||
});
|
||||
|
||||
module.exports = config;
|
||||
@@ -1,33 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
|
||||
# load every string in the source language
|
||||
# print all duplicate values to a file
|
||||
|
||||
cwd = os.getcwd()
|
||||
source = cwd + '/../src/strings/en-us.json'
|
||||
|
||||
reverse = {}
|
||||
duplicates = {}
|
||||
|
||||
with open(source) as en:
|
||||
strings = json.load(en)
|
||||
for key, value in strings.items():
|
||||
if value not in reverse:
|
||||
reverse[value] = [key]
|
||||
else:
|
||||
reverse[value].append(key)
|
||||
|
||||
for key, value in reverse.items():
|
||||
if len(value) > 1:
|
||||
duplicates[key] = value
|
||||
|
||||
print('LENGTH: ' + str(len(duplicates)))
|
||||
with open('duplicates.txt', 'w') as out:
|
||||
for item in duplicates:
|
||||
out.write(json.dumps(item) + ': ')
|
||||
out.write(json.dumps(duplicates[item]) + '\n')
|
||||
out.close()
|
||||
|
||||
print('DONE')
|
||||
@@ -15,8 +15,6 @@ print(langlst)
|
||||
input('press enter to continue')
|
||||
|
||||
keysus = []
|
||||
missing = []
|
||||
|
||||
with open(langdir + '/' + 'en-us.json') as en:
|
||||
langus = json.load(en)
|
||||
for key in langus:
|
||||
@@ -34,19 +32,10 @@ for lang in langlst:
|
||||
for key in langjson:
|
||||
if key in keysus:
|
||||
langjnew[key] = langjson[key]
|
||||
elif key not in missing:
|
||||
missing.append(key)
|
||||
f.seek(0)
|
||||
f.write(json.dumps(langjnew, indent=inde, sort_keys=False, ensure_ascii=False))
|
||||
f.write('\n')
|
||||
f.truncate()
|
||||
f.close()
|
||||
|
||||
print(missing)
|
||||
print('LENGTH: ' + str(len(missing)))
|
||||
with open('missing.txt', 'w') as out:
|
||||
for item in missing:
|
||||
out.write(item + '\n')
|
||||
out.close()
|
||||
|
||||
print('DONE')
|
||||
@@ -16,7 +16,7 @@ langlst.append('en-us.json')
|
||||
dep = []
|
||||
|
||||
def grep(key):
|
||||
command = 'grep -r -E "(\\\"|\'|\{)%s(\\\"|\'|\})" --include=\*.{js,html} --exclude-dir=../src/strings ../src' % key
|
||||
command = 'grep -r -E "(\(\\\"|\(\'|\{)%s(\\\"|\'|\})" --include=\*.{js,html} --exclude-dir=../src/strings ../src' % key
|
||||
p = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
output = p.stdout.readlines()
|
||||
if output:
|
||||
@@ -34,7 +34,7 @@ for lang in langlst:
|
||||
|
||||
print(dep)
|
||||
print('LENGTH: ' + str(len(dep)))
|
||||
with open('unused.txt', 'w') as out:
|
||||
with open('scout.txt', 'w') as out:
|
||||
for item in dep:
|
||||
out.write(item + '\n')
|
||||
out.close()
|
||||
@@ -11,7 +11,7 @@ langlst = os.listdir(langdir)
|
||||
|
||||
keys = []
|
||||
|
||||
with open('unused.txt', 'r') as f:
|
||||
with open('scout.txt', 'r') as f:
|
||||
for line in f:
|
||||
keys.append(line.strip('\n'))
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import React from 'react';
|
||||
|
||||
import { ApiProvider } from 'hooks/useApi';
|
||||
import { WebConfigProvider } from 'hooks/useWebConfig';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import RootAppRouter from 'RootAppRouter';
|
||||
|
||||
const RootApp = () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ApiProvider>
|
||||
<WebConfigProvider>
|
||||
<RootAppRouter />
|
||||
</WebConfigProvider>
|
||||
</ApiProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
export default RootApp;
|
||||
@@ -1,54 +0,0 @@
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
RouterProvider,
|
||||
createHashRouter,
|
||||
Outlet,
|
||||
useLocation
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { DASHBOARD_APP_PATHS, DASHBOARD_APP_ROUTES } from 'apps/dashboard/routes/routes';
|
||||
import { EXPERIMENTAL_APP_ROUTES } from 'apps/experimental/routes/routes';
|
||||
import { STABLE_APP_ROUTES } from 'apps/stable/routes/routes';
|
||||
import AppHeader from 'components/AppHeader';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import { createRouterHistory } from 'components/router/routerHistory';
|
||||
import UserThemeProvider from 'themes/UserThemeProvider';
|
||||
|
||||
const layoutMode = localStorage.getItem('layout');
|
||||
const isExperimentalLayout = layoutMode === 'experimental';
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
element: <RootAppLayout />,
|
||||
children: [
|
||||
...(isExperimentalLayout ? EXPERIMENTAL_APP_ROUTES : STABLE_APP_ROUTES),
|
||||
...DASHBOARD_APP_ROUTES
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
export const history = createRouterHistory(router);
|
||||
|
||||
export default function RootAppRouter() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout component that renders legacy components required on all pages.
|
||||
* NOTE: The app will crash if these get removed from the DOM.
|
||||
*/
|
||||
function RootAppLayout() {
|
||||
const location = useLocation();
|
||||
const isNewLayoutPath = Object.values(DASHBOARD_APP_PATHS)
|
||||
.some(path => location.pathname.startsWith(`/${path}`));
|
||||
|
||||
return (
|
||||
<UserThemeProvider>
|
||||
<Backdrop />
|
||||
<AppHeader isHidden={isExperimentalLayout || isNewLayoutPath} />
|
||||
|
||||
<Outlet />
|
||||
</UserThemeProvider>
|
||||
);
|
||||
}
|
||||
54
src/addplugin.html
Normal file
54
src/addplugin.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<div id="addPluginPage" data-role="page" class="page type-interior pluginConfigurationPage" data-backbutton="true">
|
||||
<div>
|
||||
<div class="content-primary">
|
||||
<div class="readOnlyContent">
|
||||
<div class="verticalSection">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h1 class="sectionTitle pluginName"></h1>
|
||||
<a is="emby-linkbutton" class="raised button-alt headerHelpButton" target="_blank" href="https://docs.jellyfin.org/general/server/plugins/index.html">${Help}</a>
|
||||
</div>
|
||||
|
||||
<p id="tagline" style="font-style: italic;"></p>
|
||||
<p id="pPreviewImage"></p>
|
||||
<p id="overview"></p>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection">
|
||||
<h2 class="sectionTitle">${HeaderInstall}</h2>
|
||||
|
||||
<form class="addPluginForm">
|
||||
<p id="pCurrentVersion"></p>
|
||||
<div id="pSelectVersion" class="hide selectContainer">
|
||||
<select id="selectVersion" name="selectVersion" is="emby-select" label="${LabelSelectVersionToInstall}"></select>
|
||||
</div>
|
||||
|
||||
<div id="btnInstallDiv" class="hide">
|
||||
<button is="emby-button" type="submit" id="btnInstall" class="raised button-submit block">
|
||||
<span>${Install}</span>
|
||||
</button>
|
||||
<div class="fieldDescription">${ServerRestartNeededAfterPluginInstall}</div>
|
||||
</div>
|
||||
<p id="nonServerMsg"></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div class="readOnlyContent">
|
||||
<div is="emby-collapse" title="${HeaderDeveloperInfo}">
|
||||
<div class="collapseContent">
|
||||
<p id="developer"></p>
|
||||
<p id="pViewWebsite" style="display: none;">
|
||||
<a is="emby-linkbutton" class="button-link" href="#" target="_blank">${ButtonViewWebsite}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div is="emby-collapse" title="${HeaderRevisionHistory}">
|
||||
<div class="collapseContent">
|
||||
<div id="revisionHistory"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,14 +1,14 @@
|
||||
<div data-role="page" class="page standalonePage">
|
||||
<div class="padded-left padded-right padded-bottom-page">
|
||||
<form class="addServerForm" style="margin: 0 auto;" novalidate>
|
||||
<form class="addServerForm" style="margin: 0 auto;">
|
||||
<h1>${HeaderConnectToServer}</h1>
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="url" id="txtServerHost" required="required" label="${LabelServerHost}"/>
|
||||
<input is="emby-input" type="text" id="txtServerHost" required="required" label="${LabelServerHost}" autocomplete="off" spellcheck="false" autocapitalize="none" autocorrect="off" />
|
||||
<div class="fieldDescription">${LabelServerHostHelp}</div>
|
||||
</div>
|
||||
<br />
|
||||
<button is="emby-button" type="submit" class="raised button-submit block">
|
||||
<span>${Connect}</span>
|
||||
<span>${ButtonConnect}</span>
|
||||
</button>
|
||||
<button is="emby-button" type="button" class="raised button-cancel block btnCancel">
|
||||
<span>${ButtonCancel}</span>
|
||||
361
src/apiclient.d.ts
vendored
361
src/apiclient.d.ts
vendored
@@ -1,361 +0,0 @@
|
||||
// TODO: Move to jellyfin-apiclient
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
declare module 'jellyfin-apiclient' {
|
||||
import type {
|
||||
AllThemeMediaResult,
|
||||
AuthenticationResult,
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BufferRequestDto,
|
||||
ClientCapabilities,
|
||||
CountryInfo,
|
||||
CultureDto,
|
||||
DeviceOptions,
|
||||
DisplayPreferencesDto,
|
||||
EndPointInfo,
|
||||
FileSystemEntryInfo,
|
||||
GeneralCommand,
|
||||
GroupInfoDto,
|
||||
GuideInfo,
|
||||
IgnoreWaitRequestDto,
|
||||
ImageInfo,
|
||||
ImageProviderInfo,
|
||||
ImageType,
|
||||
ItemCounts,
|
||||
LiveTvInfo,
|
||||
MovePlaylistItemRequestDto,
|
||||
NewGroupRequestDto,
|
||||
NextItemRequestDto,
|
||||
NotificationResultDto,
|
||||
NotificationsSummaryDto,
|
||||
ParentalRating,
|
||||
PingRequestDto,
|
||||
PlaybackInfoResponse,
|
||||
PlaybackProgressInfo,
|
||||
PlaybackStartInfo,
|
||||
PlaybackStopInfo,
|
||||
PlayCommand,
|
||||
PlaystateCommand,
|
||||
PluginInfo,
|
||||
PluginSecurityInfo,
|
||||
PreviousItemRequestDto,
|
||||
QueryFiltersLegacy,
|
||||
QueueRequestDto,
|
||||
QuickConnectResult,
|
||||
QuickConnectState,
|
||||
ReadyRequestDto,
|
||||
RecommendationDto,
|
||||
RemoteImageResult,
|
||||
RemoveFromPlaylistRequestDto,
|
||||
SearchHintResult,
|
||||
SeekRequestDto,
|
||||
SeriesTimerInfoDto,
|
||||
SeriesTimerInfoDtoQueryResult,
|
||||
ServerConfiguration,
|
||||
SessionInfo,
|
||||
SetPlaylistItemRequestDto,
|
||||
SetRepeatModeRequestDto,
|
||||
SetShuffleModeRequestDto,
|
||||
SystemInfo,
|
||||
TaskInfo,
|
||||
TaskTriggerInfo,
|
||||
TimerInfoDto,
|
||||
TimerInfoDtoQueryResult,
|
||||
UserConfiguration,
|
||||
UserDto,
|
||||
UserItemDataDto,
|
||||
UserPolicy,
|
||||
UtcTimeResponse,
|
||||
VirtualFolderInfo
|
||||
} from '@jellyfin/sdk/lib/generated-client';
|
||||
import { ConnectionState } from './utils/jellyfin-apiclient/ConnectionState';
|
||||
|
||||
class ApiClient {
|
||||
constructor(serverAddress: string, appName: string, appVersion: string, deviceName: string, deviceId: string);
|
||||
|
||||
accessToken(): string;
|
||||
addMediaPath(virtualFolderName: string, mediaPath: string, networkSharePath: string, refreshLibrary?: boolean): Promise<void>;
|
||||
addVirtualFolder(name: string, type?: string, refreshLibrary?: boolean, libraryOptions?: any): Promise<void>;
|
||||
ajax(request: any): Promise<any>;
|
||||
appName(): string;
|
||||
appVersion(): string;
|
||||
authenticateUserByName(name: string, password: string): Promise<AuthenticationResult>;
|
||||
cancelLiveTvSeriesTimer(id: string): Promise<void>;
|
||||
cancelLiveTvTimer(id: string): Promise<void>;
|
||||
cancelSyncItems(itemIds: string[], targetId?: string): Promise<void>;
|
||||
clearAuthenticationInfo(): void;
|
||||
clearUserItemRating(userId: string, itemId: string): Promise<UserItemDataDto>;
|
||||
closeWebSocket(): void;
|
||||
createLiveTvSeriesTimer(item: string): Promise<void>;
|
||||
createLiveTvTimer(item: string): Promise<void>;
|
||||
createPackageReview(review: any): Promise<any>;
|
||||
createSyncPlayGroup(options?: NewGroupRequestDto): Promise<void>;
|
||||
createUser(user: UserDto): Promise<UserDto>;
|
||||
deleteDevice(deviceId: string): Promise<void>;
|
||||
deleteItemImage(itemId: string, imageType: ImageType, imageIndex?: number): Promise<void>;
|
||||
deleteItem(itemId: string): Promise<void>;
|
||||
deleteLiveTvRecording(id: string): Promise<void>;
|
||||
deleteUserImage(userId: string, imageType: ImageType, imageIndex?: number): Promise<void>;
|
||||
deleteUser(userId: string): Promise<void>;
|
||||
detectBitrate(force: boolean): Promise<number>;
|
||||
deviceId(): string;
|
||||
deviceName(): string;
|
||||
disablePlugin(id: string, version: string): Promise<void>;
|
||||
downloadRemoteImage(options: any): Promise<void>;
|
||||
enablePlugin(id: string, version: string): Promise<void>;
|
||||
encodeName(name: string): string;
|
||||
ensureWebSocket(): void;
|
||||
fetch(request: any, includeAuthorization?: boolean): Promise<any>;
|
||||
fetchWithFailover(request: any, enableReconnection?: boolean): Promise<any>;
|
||||
getAdditionalVideoParts(userId?: string, itemId: string): Promise<BaseItemDtoQueryResult>;
|
||||
getAlbumArtists(userId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getAncestorItems(itemId: string, userId?: string): Promise<BaseItemDto[]>;
|
||||
getArtist(name: string, userId?: string): Promise<BaseItemDto>;
|
||||
getArtists(userId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getAvailablePlugins(options?: any): Promise<PluginInfo[]>;
|
||||
getAvailableRemoteImages(options: any): Promise<RemoteImageResult>;
|
||||
getContentUploadHistory(): Promise<any>;
|
||||
getCountries(): Promise<CountryInfo[]>;
|
||||
getCriticReviews(itemId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getCultures(): Promise<CultureDto[]>;
|
||||
getCurrentUser(cache?: boolean): Promise<UserDto>;
|
||||
getCurrentUserId(): string;
|
||||
getDateParamValue(date: Date): string;
|
||||
getDefaultImageQuality(imageType: ImageType): number;
|
||||
getDevicesOptions(): Promise<DeviceOptions>;
|
||||
getDirectoryContents(path: string, options?: any): Promise<FileSystemEntryInfo[]>;
|
||||
getDisplayPreferences(id: string, userId: string, app: string): Promise<DisplayPreferencesDto>;
|
||||
getDownloadSpeed(byteSize: number): Promise<number>;
|
||||
getDrives(): Promise<FileSystemEntryInfo[]>;
|
||||
getEndpointInfo(): Promise<EndPointInfo>;
|
||||
getEpisodes(itemId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getFilters(options?: any): Promise<QueryFiltersLegacy>;
|
||||
getGenre(name: string, userId?: string): Promise<BaseItemDto>;
|
||||
getGenres(userId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getImageUrl(itemId: string, options?: any): string;
|
||||
getInstalledPlugins(): Promise<PluginInfo[]>;
|
||||
getInstantMixFromItem(itemId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getIntros(itemId: string): Promise<BaseItemDtoQueryResult>;
|
||||
getItemCounts(userId?: string): Promise<ItemCounts>;
|
||||
getItemDownloadUrl(itemId: string): string;
|
||||
getItemImageInfos(itemId: string): Promise<ImageInfo[]>;
|
||||
getItems(userId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getItem(userId: string, itemId: string): Promise<BaseItemDto>;
|
||||
getJSON(url: string, includeAuthorization?: boolean): Promise<any>;
|
||||
getLatestItems(options?: any): Promise<BaseItemDto[]>;
|
||||
getLiveStreamMediaInfo(liveStreamId: string): Promise<any>;
|
||||
getLiveTvChannel(id: string, userId?: string): Promise<BaseItemDto>;
|
||||
getLiveTvChannels(options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getLiveTvGuideInfo(userId: string): Promise<GuideInfo>;
|
||||
getLiveTvInfo(userId: string): Promise<LiveTvInfo>;
|
||||
getLiveTvProgram(id: string, userId?: string): Promise<BaseItemDto>;
|
||||
getLiveTvPrograms(options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getLiveTvRecommendedPrograms(options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getLiveTvRecordingGroup(id: string): Promise<BaseItemDto>;
|
||||
getLiveTvRecordingGroups(options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getLiveTvRecording(id: string, userId?: string): Promise<BaseItemDto>;
|
||||
getLiveTvRecordingSeries(options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getLiveTvRecordings(options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getLiveTvSeriesTimer(id: string): Promise<SeriesTimerInfoDto>;
|
||||
getLiveTvSeriesTimers(options?: any): Promise<SeriesTimerInfoDtoQueryResult>;
|
||||
getLiveTvTimer(id: string): Promise<TimerInfoDto>;
|
||||
getLiveTvTimers(options?: any): Promise<TimerInfoDtoQueryResult>;
|
||||
getLocalTrailers(userId: string, itemId: string): Promise<BaseItemDto[]>;
|
||||
getMovieRecommendations(options?: any): Promise<RecommendationDto[]>;
|
||||
getMusicGenre(name: string, userId?: string): Promise<BaseItemDto>;
|
||||
getMusicGenres(userId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getNamedConfiguration(name: string): Promise<any>;
|
||||
getNetworkDevices(): Promise<any>;
|
||||
getNetworkShares(path: string): Promise<FileSystemEntryInfo[]>;
|
||||
getNewLiveTvTimerDefaults(options?: any): Promise<SeriesTimerInfoDto>;
|
||||
getNextUpEpisodes(options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getNotificationSummary(userId: string): Promise<NotificationsSummaryDto>;
|
||||
getNotifications(userId: string, options?: any): Promise<NotificationResultDto>;
|
||||
getPackageInfo(name: string, guid: string): Promise<PackageInfo>;
|
||||
getPackageReviews(packageId: string, minRating?: string, maxRating?: string, limit?: string): Promise<any>;
|
||||
getParentalRatings(): Promise<ParentalRating[]>;
|
||||
getParentPath(path: string): Promise<string>;
|
||||
getPeople(userId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getPerson(name: string, userId?: string): Promise<BaseItemDto>;
|
||||
getPhysicalPaths(): Promise<string[]>;
|
||||
getPlaybackInfo(itemId: string, options: any, deviceProfile: any): Promise<PlaybackInfoResponse>;
|
||||
getPluginConfiguration(id: string): Promise<any>;
|
||||
getPublicSystemInfo(): Promise<PublicSystemInfo>;
|
||||
getPublicUsers(): Promise<UserDto[]>;
|
||||
getQuickConnect(verb: string): Promise<void|boolean|number|QuickConnectResult|QuickConnectState>;
|
||||
getReadySyncItems(deviceId: string): Promise<any>;
|
||||
getRecordingFolders(userId: string): Promise<BaseItemDtoQueryResult>;
|
||||
getRegistrationInfo(feature: string): Promise<any>;
|
||||
getRemoteImageProviders(options: any): Promise<ImageProviderInfo[]>;
|
||||
getResumableItems(userId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getRootFolder(userId: string): Promise<BaseItemDto>;
|
||||
getSavedEndpointInfo(): EndPointInfo;
|
||||
getScaledImageUrl(itemId: string, options?: any): string;
|
||||
getScheduledTask(id: string): Promise<TaskInfo>;
|
||||
getScheduledTasks(options?: any): Promise<TaskInfo[]>;
|
||||
getSearchHints(options?: any): Promise<SearchHintResult>;
|
||||
getSeasons(itemId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getServerConfiguration(): Promise<ServerConfiguration>;
|
||||
getServerTime(): Promise<UtcTimeResponse>;
|
||||
getSessions(options?: any): Promise<SessionInfo[]>;
|
||||
getSimilarItems(itemId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getSpecialFeatures(userId: string, itemId: string): Promise<BaseItemDto[]>;
|
||||
getStudio(name: string, userId?: string): Promise<BaseItemDto>;
|
||||
getStudios(userId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getSyncPlayGroups(): Promise<GroupInfoDto[]>;
|
||||
getSyncStatus(itemId: string): Promise<any>;
|
||||
getSystemInfo(): Promise<SystemInfo>;
|
||||
getThemeMedia(userId?: string, itemId: string, inherit?: boolean): Promise<AllThemeMediaResult>;
|
||||
getThumbImageUrl(item: BaseItemDto, options?: any): string;
|
||||
getUpcomingEpisodes(options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getUrl(name: string, params?: any, serverAddress?: string): string;
|
||||
get(url: string): Promise<any>;
|
||||
getUserImageUrl(userId: string, options?: any): string;
|
||||
getUsers(options?: any): Promise<UserDto[]>;
|
||||
getUser(userId: string): Promise<UserDto>;
|
||||
getUserViews(options?: any, userId: string): Promise<BaseItemDtoQueryResult>;
|
||||
getVirtualFolders(): Promise<VirtualFolderInfo[]>;
|
||||
handleMessageReceived(msg: any): void;
|
||||
installPlugin(name: string, guid: string, version?: string): Promise<void>;
|
||||
isLoggedIn(): boolean;
|
||||
isMessageChannelOpen(): boolean;
|
||||
isMinServerVersion(version: string): boolean;
|
||||
isWebSocketOpen(): boolean;
|
||||
isWebSocketOpenOrConnecting(): boolean;
|
||||
isWebSocketSupported(): boolean;
|
||||
joinSyncPlayGroup(options?: any): Promise<void>;
|
||||
leaveSyncPlayGroup(): Promise<void>;
|
||||
logout(): Promise<void>;
|
||||
markNotificationsRead(userId: string, idList: string[], isRead: boolean): Promise<void>;
|
||||
markPlayed(userId: string, itemId: string, date: Date): Promise<UserItemDataDto>;
|
||||
markUnplayed(userId: string, itemId: string, date: Date): Promise<UserItemDataDto>;
|
||||
openWebSocket(): void;
|
||||
quickConnect(secret: string): Promise<AuthenticationResult>;
|
||||
refreshItem(itemId: string, options?: any): Promise<void>;
|
||||
removeMediaPath(virtualFolderName: string, mediaPath: string, refreshLibrary?: boolean): Promise<void>;
|
||||
removeVirtualFolder(name: string, refreshLibrary?: boolean): Promise<void>;
|
||||
renameVirtualFolder(name: string, newName: string, refreshLibrary?: boolean): Promise<void>;
|
||||
reportCapabilities(capabilities: ClientCapabilities): Promise<void>;
|
||||
reportOfflineActions(actions: any): Promise<any>;
|
||||
reportPlaybackProgress(options: PlaybackProgressInfo): Promise<void>;
|
||||
reportPlaybackStart(options: PlaybackStartInfo): Promise<void>;
|
||||
reportPlaybackStopped(options: PlaybackStopInfo): Promise<void>;
|
||||
reportSyncJobItemTransferred(syncJobItemId: string): Promise<any>;
|
||||
requestSyncPlayBuffering(options?: BufferRequestDto): Promise<void>;
|
||||
requestSyncPlayMovePlaylistItem(options?: MovePlaylistItemRequestDto): Promise<void>;
|
||||
requestSyncPlayNextItem(options?: NextItemRequestDto): Promise<void>;
|
||||
requestSyncPlayPause(): Promise<void>;
|
||||
requestSyncPlayPreviousItem(options?: PreviousItemRequestDto): Promise<void>;
|
||||
requestSyncPlayQueue(options?: QueueRequestDto): Promise<void>;
|
||||
requestSyncPlayReady(options?: ReadyRequestDto): Promise<void>;
|
||||
requestSyncPlayRemoveFromPlaylist(options?: RemoveFromPlaylistRequestDto): Promise<void>;
|
||||
requestSyncPlaySeek(options?: SeekRequestDto): Promise<void>;
|
||||
requestSyncPlaySetIgnoreWait(options?: IgnoreWaitRequestDto): Promise<void>;
|
||||
requestSyncPlaySetNewQueue(options?: NewGroupRequestDto): Promise<void>;
|
||||
requestSyncPlaySetPlaylistItem(options?: SetPlaylistItemRequestDto): Promise<void>;
|
||||
requestSyncPlaySetRepeatMode(options?: SetRepeatModeRequestDto): Promise<void>;
|
||||
requestSyncPlaySetShuffleMode(options?: SetShuffleModeRequestDto): Promise<void>;
|
||||
requestSyncPlayUnpause(): Promise<void>;
|
||||
resetEasyPassword(userId: string): Promise<void>;
|
||||
resetLiveTvTuner(id: string): Promise<void>;
|
||||
resetUserPassword(userId: string): Promise<void>;
|
||||
restartServer(): Promise<void>;
|
||||
sendCommand(sessionId: string, command: any): Promise<void>;
|
||||
sendMessageCommand(sessionId: string, options: GeneralCommand): Promise<void>;
|
||||
sendMessage(name: string, data: any): void;
|
||||
sendPlayCommand(sessionId: string, options: PlayCommand): Promise<void>;
|
||||
sendPlayStateCommand(sessionId: string, command: PlaystateCommand, options?: any): Promise<void>;
|
||||
sendSyncPlayPing(options?: PingRequestDto): Promise<void>;
|
||||
sendWebSocketMessage(name: string, data: any): void;
|
||||
serverAddress(val?: string): string;
|
||||
serverId(): string;
|
||||
serverVersion(): string;
|
||||
setAuthenticationInfo(accessKey?: string, userId?: string): void;
|
||||
setRequestHeaders(headers: any): void;
|
||||
setSystemInfo(info: SystemInfo): void;
|
||||
shutdownServer(): Promise<void>;
|
||||
startScheduledTask(id: string): Promise<void>;
|
||||
stopActiveEncodings(playSessionId: string): Promise<void>;
|
||||
stopScheduledTask(id: string): Promise<void>;
|
||||
syncData(data: any): Promise<any>;
|
||||
uninstallPluginByVersion(id: string, version: string): Promise<void>;
|
||||
uninstallPlugin(id: string): Promise<void>;
|
||||
updateDisplayPreferences(id: string, obj: DisplayPreferencesDto, userId: string, app: string): Promise<void>;
|
||||
updateEasyPassword(userId: string, newPassword: string): Promise<void>;
|
||||
updateFavoriteStatus(userId: string, itemId: string, isFavorite: boolean): Promise<UserItemDataDto>;
|
||||
updateItemImageIndex(itemId: string, imageType: ImageType, imageIndex: number, newIndex: number): Promise<any>;
|
||||
updateItem(item: BaseItemDto): Promise<void>;
|
||||
updateLiveTvSeriesTimer(item: SeriesTimerInfoDto): Promise<void>;
|
||||
updateLiveTvTimer(item: TimerInfoDto): Promise<void>;
|
||||
updateMediaPath(virtualFolderName: string, pathInfo: any): Promise<void>;
|
||||
updateNamedConfiguration(name: string, configuration: any): Promise<void>;
|
||||
updatePluginConfiguration(id: string, configuration: any): Promise<void>;
|
||||
updatePluginSecurityInfo(info: PluginSecurityInfo): Promise<void>;
|
||||
updateScheduledTaskTriggers(id: string, triggers: TaskTriggerInfo[]): Promise<void>;
|
||||
updateServerConfiguration(configuration: ServerConfiguration): Promise<void>;
|
||||
updateServerInfo(server: any, serverUrl: string): void;
|
||||
updateUserConfiguration(userId: string, configuration: UserConfiguration): Promise<void>;
|
||||
updateUserItemRating(userId: string, itemId: string, likes: boolean): Promise<UserItemDataDto>;
|
||||
updateUserPassword(userId: string, currentPassword: string, newPassword: string): Promise<void>;
|
||||
updateUserPolicy(userId: string, policy: UserPolicy): Promise<void>;
|
||||
updateUser(user: UserDto): Promise<void>;
|
||||
updateVirtualFolderOptions(id: string, libraryOptions?: any): Promise<void>;
|
||||
uploadItemImage(itemId: string, imageType: ImageType, file: File): Promise<void>;
|
||||
uploadItemSubtitle(itemId: string, language: string, isForced: boolean, file: File): Promise<void>;
|
||||
uploadUserImage(userId: string, imageType: ImageType, file: File): Promise<void>;
|
||||
}
|
||||
|
||||
class AppStore {
|
||||
constructor();
|
||||
|
||||
getItem(name: string): string|null;
|
||||
removeItem(name: string): void;
|
||||
setItem(name: string, value: string): void;
|
||||
}
|
||||
|
||||
interface ConnectResponse {
|
||||
ApiClient: ApiClient
|
||||
Servers: any[]
|
||||
State: ConnectionState
|
||||
}
|
||||
|
||||
class ConnectionManager {
|
||||
constructor(credentialProvider: Credentials, appName: string, appVersion: string, deviceName: string, deviceId: string, capabilities: ClientCapabilities);
|
||||
|
||||
addApiClient(apiClient: ApiClient): void;
|
||||
clearData(): void;
|
||||
connect(options?: any): Promise<ConnectResponse>;
|
||||
connectToAddress(address: string, options?: any): Promise<any>;
|
||||
connectToServer(server: any, options?: any): Promise<any>;
|
||||
connectToServers(servers: any[], options?: any): Promise<any>;
|
||||
deleteServer(serverId: string): Promise<void>;
|
||||
getApiClient(item: BaseItemDto|string): ApiClient;
|
||||
getApiClients(): ApiClient[];
|
||||
getAvailableServers(): any[];
|
||||
getOrCreateApiClient(serverId: string): ApiClient;
|
||||
getSavedServers(): any[];
|
||||
handleMessageReceived(msg: any): void;
|
||||
logout(): Promise<void>;
|
||||
minServerVersion(val?: string): string;
|
||||
user(apiClient: ApiClient): Promise<any>;
|
||||
}
|
||||
|
||||
class Credentials {
|
||||
constructor(key?: string);
|
||||
|
||||
addOrUpdateServer(list: any[], server: any): any;
|
||||
clear(): void;
|
||||
credentials(data?: any): any;
|
||||
}
|
||||
|
||||
interface Event {
|
||||
type: string;
|
||||
}
|
||||
|
||||
const Events: {
|
||||
off(obj: any, eventName: string, fn: (e: Event, ...args: any[]) => void): void;
|
||||
on(obj: any, eventName: string, fn: (e: Event, ...args: any[]) => void): void;
|
||||
trigger(obj: any, eventName: string, ...args: any[]): void;
|
||||
};
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
@@ -1,22 +1,21 @@
|
||||
<div id="apiKeysPage" data-role="page" class="page type-interior advancedConfigurationPage fullWidthContent" data-title="${HeaderApiKeys}">
|
||||
<div id="apiKeysPage" data-role="page" class="page type-interior advancedConfigurationPage fullWidthContent">
|
||||
<div>
|
||||
<div class="content-primary">
|
||||
<div class="detailSectionHeader">
|
||||
<h2 style="margin:.6em 0;vertical-align:middle;display:inline-block;">${HeaderApiKeys}</h2>
|
||||
<button is="emby-button" type="button" class="fab btnNewKey submit" style="margin-left:1em;" title="${Add}">
|
||||
<span class="material-icons add" aria-hidden="true"></span>
|
||||
<button is="emby-button" type="button" class="fab btnNewKey submit" style="margin-left:1em;" title="${ButtonAdd}">
|
||||
<i class="material-icons">add</i>
|
||||
</button>
|
||||
</div>
|
||||
<p>${HeaderApiKeysHelp}</p>
|
||||
<br />
|
||||
<table class="tblApiKeys detailTable">
|
||||
<caption class="clipForScreenReader">${ApiKeysCaption}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="detailTableHeaderCell"></th>
|
||||
<th scope="col" class="detailTableHeaderCell">${HeaderApiKey}</th>
|
||||
<th scope="col" class="detailTableHeaderCell">${HeaderApp}</th>
|
||||
<th scope="col" class="detailTableHeaderCell">${HeaderDateIssued}</th>
|
||||
<th class="detailTableHeaderCell"></th>
|
||||
<th class="detailTableHeaderCell">${HeaderApiKey}</th>
|
||||
<th class="detailTableHeaderCell">${HeaderApp}</th>
|
||||
<th class="detailTableHeaderCell">${HeaderDateIssued}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="resultBody"></tbody>
|
||||
@@ -1,94 +0,0 @@
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Box from '@mui/material/Box';
|
||||
import { type Theme } from '@mui/material/styles';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
import AppBody from 'components/AppBody';
|
||||
import AppToolbar from 'components/toolbar/AppToolbar';
|
||||
import ElevationScroll from 'components/ElevationScroll';
|
||||
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import AppTabs from './components/AppTabs';
|
||||
import AppDrawer from './components/drawer/AppDrawer';
|
||||
import { DASHBOARD_APP_PATHS } from './routes/routes';
|
||||
|
||||
import './AppOverrides.scss';
|
||||
|
||||
const DRAWERLESS_PATHS = [ DASHBOARD_APP_PATHS.MetadataManager ];
|
||||
|
||||
export const Component: FC = () => {
|
||||
const [ isDrawerActive, setIsDrawerActive ] = useState(false);
|
||||
const location = useLocation();
|
||||
const { user } = useApi();
|
||||
|
||||
const isMediumScreen = useMediaQuery((t: Theme) => t.breakpoints.up('md'));
|
||||
const isDrawerAvailable = Boolean(user)
|
||||
&& !DRAWERLESS_PATHS.some(path => location.pathname.startsWith(`/${path}`));
|
||||
const isDrawerOpen = isDrawerActive && isDrawerAvailable;
|
||||
|
||||
const onToggleDrawer = useCallback(() => {
|
||||
setIsDrawerActive(!isDrawerActive);
|
||||
}, [ isDrawerActive, setIsDrawerActive ]);
|
||||
|
||||
// Update body class
|
||||
useEffect(() => {
|
||||
document.body.classList.add('dashboardDocument');
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('dashboardDocument');
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<ElevationScroll elevate={false}>
|
||||
<AppBar
|
||||
position='fixed'
|
||||
sx={{
|
||||
width: {
|
||||
xs: '100%',
|
||||
md: isDrawerAvailable ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%'
|
||||
},
|
||||
ml: {
|
||||
xs: 0,
|
||||
md: isDrawerAvailable ? DRAWER_WIDTH : 0
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AppToolbar
|
||||
isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
|
||||
isDrawerOpen={isDrawerOpen}
|
||||
onDrawerButtonClick={onToggleDrawer}
|
||||
>
|
||||
<AppTabs isDrawerOpen={isDrawerOpen} />
|
||||
</AppToolbar>
|
||||
</AppBar>
|
||||
</ElevationScroll>
|
||||
|
||||
{
|
||||
isDrawerAvailable && (
|
||||
<AppDrawer
|
||||
open={isDrawerOpen}
|
||||
onClose={onToggleDrawer}
|
||||
onOpen={onToggleDrawer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<Box
|
||||
component='main'
|
||||
sx={{
|
||||
width: '100%',
|
||||
flexGrow: 1
|
||||
}}
|
||||
>
|
||||
<AppBody>
|
||||
<Outlet />
|
||||
</AppBody>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
// Default MUI breakpoints
|
||||
// https://mui.com/material-ui/customization/breakpoints/#default-breakpoints
|
||||
$mui-bp-sm: 600px;
|
||||
$mui-bp-md: 900px;
|
||||
$mui-bp-lg: 1200px;
|
||||
$mui-bp-xl: 1536px;
|
||||
|
||||
$drawer-width: 240px;
|
||||
|
||||
// Fix dashboard pages layout to work with drawer
|
||||
.dashboardDocument {
|
||||
.mainAnimatedPage {
|
||||
@media all and (min-width: $mui-bp-md) {
|
||||
left: $drawer-width;
|
||||
}
|
||||
}
|
||||
|
||||
.skinBody {
|
||||
position: unset !important;
|
||||
}
|
||||
|
||||
// Fix the padding of dashboard pages
|
||||
.content-primary {
|
||||
padding-top: 3.25rem;
|
||||
}
|
||||
// Tabbed pages
|
||||
.withTabs .content-primary {
|
||||
padding-top: 6.5rem;
|
||||
|
||||
@media all and (min-width: $mui-bp-lg) {
|
||||
padding-top: 3.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { Theme } from '@mui/material/styles';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { EventType } from 'types/eventType';
|
||||
import Events, { type Event } from 'utils/events';
|
||||
|
||||
interface AppTabsParams {
|
||||
isDrawerOpen: boolean
|
||||
}
|
||||
|
||||
interface TabDefinition {
|
||||
href: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const handleResize = debounce(() => window.dispatchEvent(new Event('resize')), 100);
|
||||
|
||||
const AppTabs: FC<AppTabsParams> = ({
|
||||
isDrawerOpen
|
||||
}) => {
|
||||
const documentRef = useRef<Document>(document);
|
||||
const [ activeIndex, setActiveIndex ] = useState(0);
|
||||
const [ tabs, setTabs ] = useState<TabDefinition[]>();
|
||||
|
||||
const isBigScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('sm'));
|
||||
|
||||
const onTabsUpdate = useCallback((
|
||||
_e: Event,
|
||||
_newView?: string,
|
||||
newIndex: number | undefined = 0,
|
||||
newTabs?: TabDefinition[]
|
||||
) => {
|
||||
setActiveIndex(newIndex);
|
||||
|
||||
if (!isEqual(tabs, newTabs)) {
|
||||
setTabs(newTabs);
|
||||
}
|
||||
}, [ tabs ]);
|
||||
|
||||
useEffect(() => {
|
||||
const doc = documentRef.current;
|
||||
|
||||
if (doc) Events.on(doc, EventType.SET_TABS, onTabsUpdate);
|
||||
|
||||
return () => {
|
||||
if (doc) Events.off(doc, EventType.SET_TABS, onTabsUpdate);
|
||||
};
|
||||
}, [ onTabsUpdate ]);
|
||||
|
||||
// HACK: Force resizing to workaround upstream bug with tab resizing
|
||||
// https://github.com/mui/material-ui/issues/24011
|
||||
useEffect(() => {
|
||||
handleResize();
|
||||
}, [ isDrawerOpen ]);
|
||||
|
||||
if (!tabs?.length) return null;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeIndex}
|
||||
sx={{
|
||||
width: '100%',
|
||||
flexShrink: {
|
||||
xs: 0,
|
||||
lg: 'unset'
|
||||
},
|
||||
order: {
|
||||
xs: 100,
|
||||
lg: 'unset'
|
||||
}
|
||||
}}
|
||||
variant={isBigScreen ? 'standard' : 'scrollable'}
|
||||
centered={isBigScreen}
|
||||
>
|
||||
{
|
||||
tabs.map(({ href, name }, index) => (
|
||||
<Tab
|
||||
key={`tab-${name}`}
|
||||
label={name}
|
||||
data-tab-index={`${index}`}
|
||||
component={Link}
|
||||
to={href}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppTabs;
|
||||
@@ -1,34 +0,0 @@
|
||||
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import React from 'react';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
const LogLevelChip = ({ level }: { level: LogLevel }) => {
|
||||
let color: 'info' | 'warning' | 'error' | undefined;
|
||||
switch (level) {
|
||||
case LogLevel.Information:
|
||||
color = 'info';
|
||||
break;
|
||||
case LogLevel.Warning:
|
||||
color = 'warning';
|
||||
break;
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Critical:
|
||||
color = 'error';
|
||||
break;
|
||||
}
|
||||
|
||||
const levelText = globalize.translate(`LogLevel.${level}`);
|
||||
|
||||
return (
|
||||
<Chip
|
||||
size='small'
|
||||
color={color}
|
||||
label={levelText}
|
||||
title={levelText}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogLevelChip;
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
|
||||
import Info from '@mui/icons-material/Info';
|
||||
import Box from '@mui/material/Box';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import React, { FC, useCallback, useState } from 'react';
|
||||
|
||||
const OverviewCell: FC<ActivityLogEntry> = ({ Overview, ShortOverview }) => {
|
||||
const displayValue = ShortOverview ?? Overview;
|
||||
const [ open, setOpen ] = useState(false);
|
||||
|
||||
const onTooltipClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const onTooltipOpen = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
if (!displayValue) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
component='div'
|
||||
title={displayValue}
|
||||
>
|
||||
{displayValue}
|
||||
</Box>
|
||||
{ShortOverview && Overview && (
|
||||
<ClickAwayListener onClickAway={onTooltipClose}>
|
||||
<Tooltip
|
||||
title={Overview}
|
||||
placement='top'
|
||||
arrow
|
||||
onClose={onTooltipClose}
|
||||
open={open}
|
||||
disableFocusListener
|
||||
disableHoverListener
|
||||
disableTouchListener
|
||||
>
|
||||
<IconButton onClick={onTooltipOpen}>
|
||||
<Info />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ClickAwayListener>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverviewCell;
|
||||
@@ -1,17 +0,0 @@
|
||||
import React, { type RefAttributes } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { GridActionsCellItem, type GridActionsCellItemProps } from '@mui/x-data-grid';
|
||||
|
||||
type GridActionsCellLinkProps = { to: string } & GridActionsCellItemProps & RefAttributes<HTMLButtonElement>;
|
||||
|
||||
/**
|
||||
* Link component to use in mui's data-grid action column due to a current bug with passing props to custom link components.
|
||||
* @see https://github.com/mui/mui-x/issues/4654
|
||||
*/
|
||||
const GridActionsCellLink = ({ to, ...props }: GridActionsCellLinkProps) => (
|
||||
<Link to={to}>
|
||||
<GridActionsCellItem {...props} />
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default GridActionsCellLink;
|
||||
@@ -1,37 +0,0 @@
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import List from '@mui/material/List';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
import DrawerHeaderLink from 'apps/experimental/components/drawers/DrawerHeaderLink';
|
||||
import ResponsiveDrawer, { ResponsiveDrawerProps } from 'components/ResponsiveDrawer';
|
||||
|
||||
import ServerDrawerSection from './sections/ServerDrawerSection';
|
||||
import DevicesDrawerSection from './sections/DevicesDrawerSection';
|
||||
import LiveTvDrawerSection from './sections/LiveTvDrawerSection';
|
||||
import AdvancedDrawerSection from './sections/AdvancedDrawerSection';
|
||||
import PluginDrawerSection from './sections/PluginDrawerSection';
|
||||
|
||||
const AppDrawer: FC<ResponsiveDrawerProps> = ({
|
||||
open = false,
|
||||
onClose,
|
||||
onOpen
|
||||
}) => (
|
||||
<ResponsiveDrawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onOpen={onOpen}
|
||||
>
|
||||
<List disablePadding>
|
||||
<ListItem disablePadding>
|
||||
<DrawerHeaderLink />
|
||||
</ListItem>
|
||||
</List>
|
||||
<ServerDrawerSection />
|
||||
<DevicesDrawerSection />
|
||||
<LiveTvDrawerSection />
|
||||
<PluginDrawerSection />
|
||||
<AdvancedDrawerSection />
|
||||
</ResponsiveDrawer>
|
||||
);
|
||||
|
||||
export default AppDrawer;
|
||||
@@ -1,61 +0,0 @@
|
||||
import Article from '@mui/icons-material/Article';
|
||||
import Lan from '@mui/icons-material/Lan';
|
||||
import Schedule from '@mui/icons-material/Schedule';
|
||||
import VpnKey from '@mui/icons-material/VpnKey';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import React from 'react';
|
||||
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
const AdvancedDrawerSection = () => {
|
||||
return (
|
||||
<List
|
||||
aria-labelledby='advanced-subheader'
|
||||
subheader={
|
||||
<ListSubheader component='div' id='advanced-subheader'>
|
||||
{globalize.translate('TabAdvanced')}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/networking'>
|
||||
<ListItemIcon>
|
||||
<Lan />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabNetworking')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/keys'>
|
||||
<ListItemIcon>
|
||||
<VpnKey />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('HeaderApiKeys')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/logs'>
|
||||
<ListItemIcon>
|
||||
<Article />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabLogs')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/tasks'>
|
||||
<ListItemIcon>
|
||||
<Schedule />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabScheduledTasks')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedDrawerSection;
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Devices, Analytics } from '@mui/icons-material';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import React from 'react';
|
||||
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
const DevicesDrawerSection = () => {
|
||||
return (
|
||||
<List
|
||||
aria-labelledby='devices-subheader'
|
||||
subheader={
|
||||
<ListSubheader component='div' id='devices-subheader'>
|
||||
{globalize.translate('HeaderDevices')}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/devices'>
|
||||
<ListItemIcon>
|
||||
<Devices />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('HeaderDevices')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/activity'>
|
||||
<ListItemIcon>
|
||||
<Analytics />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('HeaderActivity')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default DevicesDrawerSection;
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Dvr, LiveTv } from '@mui/icons-material';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import React from 'react';
|
||||
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
const LiveTvDrawerSection = () => {
|
||||
return (
|
||||
<List
|
||||
aria-labelledby='livetv-subheader'
|
||||
subheader={
|
||||
<ListSubheader component='div' id='livetv-subheader'>
|
||||
{globalize.translate('LiveTV')}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/livetv'>
|
||||
<ListItemIcon>
|
||||
<LiveTv />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('LiveTV')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/recordings'>
|
||||
<ListItemIcon>
|
||||
<Dvr />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('HeaderDVR')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveTvDrawerSection;
|
||||
@@ -1,71 +0,0 @@
|
||||
import Extension from '@mui/icons-material/Extension';
|
||||
import Folder from '@mui/icons-material/Folder';
|
||||
import Public from '@mui/icons-material/Public';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
import globalize from 'lib/globalize';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import { useConfigurationPages } from 'apps/dashboard/features/plugins/api/useConfigurationPages';
|
||||
|
||||
const PluginDrawerSection = () => {
|
||||
const {
|
||||
data: pagesInfo,
|
||||
error
|
||||
} = useConfigurationPages({ enableInMainMenu: true });
|
||||
|
||||
useEffect(() => {
|
||||
if (error) console.error('[PluginDrawerSection] unable to fetch plugin config pages', error);
|
||||
}, [ error ]);
|
||||
|
||||
return (
|
||||
<List
|
||||
aria-labelledby='plugins-subheader'
|
||||
subheader={
|
||||
<ListSubheader component='div' id='plugins-subheader'>
|
||||
{globalize.translate('TabPlugins')}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItemLink
|
||||
to='/dashboard/plugins'
|
||||
includePaths={[ '/configurationpage' ]}
|
||||
excludePaths={pagesInfo?.map(p => `/${Dashboard.getPluginUrl(p.Name)}`)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Extension />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabMyPlugins')} />
|
||||
</ListItemLink>
|
||||
|
||||
<ListItemLink
|
||||
to='/dashboard/plugins/catalog'
|
||||
includePaths={[ '/dashboard/plugins/repositories' ]}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Public />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabCatalog')} />
|
||||
</ListItemLink>
|
||||
|
||||
{pagesInfo?.map(pageInfo => (
|
||||
<ListItemLink
|
||||
key={pageInfo.PluginId}
|
||||
to={`/${Dashboard.getPluginUrl(pageInfo.Name)}`}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{/* TODO: Support different icons? */}
|
||||
<Folder />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={pageInfo.DisplayName} />
|
||||
</ListItemLink>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginDrawerSection;
|
||||
@@ -1,121 +0,0 @@
|
||||
import { Dashboard, ExpandLess, ExpandMore, LibraryAdd, People, PlayCircle, Settings } from '@mui/icons-material';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
const LIBRARY_PATHS = [
|
||||
'/dashboard/libraries',
|
||||
'/dashboard/libraries/display',
|
||||
'/dashboard/libraries/metadata',
|
||||
'/dashboard/libraries/nfo'
|
||||
];
|
||||
|
||||
const PLAYBACK_PATHS = [
|
||||
'/dashboard/playback/transcoding',
|
||||
'/dashboard/playback/resume',
|
||||
'/dashboard/playback/streaming',
|
||||
'/dashboard/playback/trickplay'
|
||||
];
|
||||
|
||||
const ServerDrawerSection = () => {
|
||||
const location = useLocation();
|
||||
|
||||
const isLibrarySectionOpen = LIBRARY_PATHS.includes(location.pathname);
|
||||
const isPlaybackSectionOpen = PLAYBACK_PATHS.includes(location.pathname);
|
||||
|
||||
return (
|
||||
<List
|
||||
aria-labelledby='server-subheader'
|
||||
subheader={
|
||||
<ListSubheader component='div' id='server-subheader'>
|
||||
{globalize.translate('TabServer')}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard'>
|
||||
<ListItemIcon>
|
||||
<Dashboard />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabDashboard')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/settings'>
|
||||
<ListItemIcon>
|
||||
<Settings />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('General')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/users'>
|
||||
<ListItemIcon>
|
||||
<People />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('HeaderUsers')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/libraries' selected={false}>
|
||||
<ListItemIcon>
|
||||
<LibraryAdd />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('HeaderLibraries')} />
|
||||
{isLibrarySectionOpen ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<Collapse in={isLibrarySectionOpen} timeout='auto' unmountOnExit>
|
||||
<List component='div' disablePadding>
|
||||
<ListItemLink to='/dashboard/libraries' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('HeaderLibraries')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/dashboard/libraries/display' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('Display')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/dashboard/libraries/metadata' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('Metadata')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/dashboard/libraries/nfo' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('TabNfoSettings')} />
|
||||
</ListItemLink>
|
||||
</List>
|
||||
</Collapse>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/playback/transcoding' selected={false}>
|
||||
<ListItemIcon>
|
||||
<PlayCircle />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TitlePlayback')} />
|
||||
{isPlaybackSectionOpen ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<Collapse in={isPlaybackSectionOpen} timeout='auto' unmountOnExit>
|
||||
<List component='div' disablePadding>
|
||||
<ListItemLink to='/dashboard/playback/transcoding' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('Transcoding')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/dashboard/playback/resume' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('ButtonResume')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/dashboard/playback/streaming' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('TabStreaming')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/dashboard/playback/trickplay' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('Trickplay')} />
|
||||
</ListItemLink>
|
||||
</List>
|
||||
</Collapse>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerDrawerSection;
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { ConfigurationPageInfo } from '@jellyfin/sdk/lib/generated-client/models/configuration-page-info';
|
||||
|
||||
export const findBestConfigurationPage = (
|
||||
configurationPages: ConfigurationPageInfo[],
|
||||
pluginId: string
|
||||
) => {
|
||||
// Find candidates matching the plugin id
|
||||
const candidates = configurationPages.filter(c => c.PluginId === pluginId);
|
||||
|
||||
// If none are found, return undefined
|
||||
if (candidates.length === 0) return;
|
||||
// If only one is found, return it
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
// Prefer the first candidate with the EnableInMainMenu flag for consistency
|
||||
const menuCandidate = candidates.find(c => !!c.EnableInMainMenu);
|
||||
if (menuCandidate) return menuCandidate;
|
||||
|
||||
// Fallback to the first match
|
||||
return candidates[0];
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { PluginInfo } from '@jellyfin/sdk/lib/generated-client/models/plugin-info';
|
||||
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
|
||||
|
||||
/**
|
||||
* HACK: The Plugins API is returning garbage data in some cases,
|
||||
* so we need to try to find the "best" match if multiple exist.
|
||||
*/
|
||||
export const findBestPluginInfo = (
|
||||
pluginId: string,
|
||||
plugins?: PluginInfo[]
|
||||
) => {
|
||||
if (!plugins) return;
|
||||
// Find all plugin entries with a matching ID
|
||||
const matches = plugins.filter(p => p.Id === pluginId);
|
||||
// Get the first match (or undefined if none)
|
||||
const firstMatch = matches?.[0];
|
||||
|
||||
if (matches.length > 1) {
|
||||
return matches.find(p => p.Status === PluginStatus.Disabled) // Disabled entries take priority
|
||||
|| matches.find(p => p.Status === PluginStatus.Restart) // Then entries specifying restart is needed
|
||||
|| firstMatch; // Fallback to the first match
|
||||
}
|
||||
|
||||
return firstMatch;
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export enum QueryKey {
|
||||
ConfigurationPages = 'ConfigurationPages',
|
||||
PackageInfo = 'PackageInfo',
|
||||
Plugins = 'Plugins'
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import type { DashboardApiGetConfigurationPagesRequest } from '@jellyfin/sdk/lib/generated-client/api/dashboard-api';
|
||||
import { getDashboardApi } from '@jellyfin/sdk/lib/utils/api/dashboard-api';
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
const fetchConfigurationPages = async (
|
||||
api?: Api,
|
||||
params?: DashboardApiGetConfigurationPagesRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchConfigurationPages] No API instance available');
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await getDashboardApi(api)
|
||||
.getConfigurationPages(params, options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getConfigurationPagesQuery = (
|
||||
api?: Api,
|
||||
params?: DashboardApiGetConfigurationPagesRequest
|
||||
) => queryOptions({
|
||||
queryKey: [ QueryKey.ConfigurationPages, params?.enableInMainMenu ],
|
||||
queryFn: ({ signal }) => fetchConfigurationPages(api, params, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
|
||||
export const useConfigurationPages = (
|
||||
params?: DashboardApiGetConfigurationPagesRequest
|
||||
) => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getConfigurationPagesQuery(api, params));
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { PluginsApiDisablePluginRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
|
||||
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
export const useDisablePlugin = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PluginsApiDisablePluginRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPluginsApi(api!)
|
||||
.disablePlugin(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.Plugins ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { PluginsApiEnablePluginRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
|
||||
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
export const useEnablePlugin = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PluginsApiEnablePluginRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPluginsApi(api!)
|
||||
.enablePlugin(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.Plugins ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { PackageApiInstallPackageRequest } from '@jellyfin/sdk/lib/generated-client/api/package-api';
|
||||
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
export const useInstallPackage = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PackageApiInstallPackageRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPackageApi(api!)
|
||||
.installPackage(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.ConfigurationPages ]
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.Plugins ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import type { PackageApiGetPackageInfoRequest } from '@jellyfin/sdk/lib/generated-client/api/package-api';
|
||||
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
const fetchPackageInfo = async (
|
||||
api?: Api,
|
||||
params?: PackageApiGetPackageInfoRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchPackageInfo] No API instance available');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params) {
|
||||
console.warn('[fetchPackageInfo] Missing request params');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getPackageApi(api)
|
||||
.getPackageInfo(params, options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getPackageInfoQuery = (
|
||||
api?: Api,
|
||||
params?: PackageApiGetPackageInfoRequest
|
||||
) => queryOptions({
|
||||
// Don't retry since requests for plugins not available in repos fail
|
||||
retry: false,
|
||||
queryKey: [ QueryKey.PackageInfo, params?.name, params?.assemblyGuid ],
|
||||
queryFn: ({ signal }) => fetchPackageInfo(api, params, { signal }),
|
||||
enabled: !!api && !!params?.name
|
||||
});
|
||||
|
||||
export const usePackageInfo = (
|
||||
params?: PackageApiGetPackageInfoRequest
|
||||
) => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getPackageInfoQuery(api, params));
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
const fetchPlugins = async (
|
||||
api?: Api,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchPlugins] No API instance available');
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await getPluginsApi(api)
|
||||
.getPlugins(options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getPluginsQuery = (
|
||||
api?: Api
|
||||
) => queryOptions({
|
||||
queryKey: [ QueryKey.Plugins ],
|
||||
queryFn: ({ signal }) => fetchPlugins(api, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
|
||||
export const usePlugins = () => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getPluginsQuery(api));
|
||||
};
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { PluginsApiUninstallPluginByVersionRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
|
||||
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
export const useUninstallPlugin = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PluginsApiUninstallPluginByVersionRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPluginsApi(api!)
|
||||
.uninstallPluginByVersion(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.Plugins ]
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.ConfigurationPages ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import Link from '@mui/material/Link/Link';
|
||||
import Paper, { type PaperProps } from '@mui/material/Paper/Paper';
|
||||
import Skeleton from '@mui/material/Skeleton/Skeleton';
|
||||
import Table from '@mui/material/Table/Table';
|
||||
import TableBody from '@mui/material/TableBody/TableBody';
|
||||
import TableCell from '@mui/material/TableCell/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer/TableContainer';
|
||||
import TableRow from '@mui/material/TableRow/TableRow';
|
||||
import React, { FC } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
import type { PluginDetails } from '../types/PluginDetails';
|
||||
|
||||
interface PluginDetailsTableProps extends PaperProps {
|
||||
isPluginLoading: boolean
|
||||
isRepositoryLoading: boolean
|
||||
pluginDetails?: PluginDetails
|
||||
}
|
||||
|
||||
const PluginDetailsTable: FC<PluginDetailsTableProps> = ({
|
||||
isPluginLoading,
|
||||
isRepositoryLoading,
|
||||
pluginDetails,
|
||||
...paperProps
|
||||
}) => (
|
||||
<TableContainer component={Paper} {...paperProps}>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell variant='head'>
|
||||
{globalize.translate('LabelStatus')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
(isPluginLoading && <Skeleton />)
|
||||
|| pluginDetails?.status
|
||||
|| globalize.translate('LabelNotInstalled')
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell variant='head'>
|
||||
{globalize.translate('LabelVersion')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
(isPluginLoading && <Skeleton />)
|
||||
|| pluginDetails?.version?.version
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell variant='head'>
|
||||
{globalize.translate('LabelDeveloper')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
(isRepositoryLoading && <Skeleton />)
|
||||
|| pluginDetails?.owner
|
||||
|| globalize.translate('Unknown')
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||
>
|
||||
<TableCell variant='head'>
|
||||
{globalize.translate('LabelRepository')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
(isRepositoryLoading && <Skeleton />)
|
||||
|| (pluginDetails?.version?.repositoryUrl && (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={pluginDetails.version.repositoryUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{pluginDetails.version.repositoryName}
|
||||
</Link>
|
||||
))
|
||||
|| globalize.translate('Unknown')
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
|
||||
export default PluginDetailsTable;
|
||||
@@ -1,34 +0,0 @@
|
||||
import Paper from '@mui/material/Paper/Paper';
|
||||
import Skeleton from '@mui/material/Skeleton/Skeleton';
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
interface PluginImageProps {
|
||||
isLoading: boolean
|
||||
alt?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
const PluginImage: FC<PluginImageProps> = ({
|
||||
isLoading,
|
||||
alt,
|
||||
url
|
||||
}) => (
|
||||
<Paper sx={{ width: '100%', aspectRatio: 16 / 9, overflow: 'hidden' }}>
|
||||
{isLoading && (
|
||||
<Skeleton
|
||||
variant='rectangular'
|
||||
width='100%'
|
||||
height='100%'
|
||||
/>
|
||||
)}
|
||||
{url && (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt}
|
||||
width='100%'
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export default PluginImage;
|
||||
@@ -1,67 +0,0 @@
|
||||
import Download from '@mui/icons-material/Download';
|
||||
import DownloadDone from '@mui/icons-material/DownloadDone';
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
import Accordion from '@mui/material/Accordion/Accordion';
|
||||
import AccordionDetails from '@mui/material/AccordionDetails/AccordionDetails';
|
||||
import AccordionSummary from '@mui/material/AccordionSummary/AccordionSummary';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import Stack from '@mui/material/Stack/Stack';
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
import MarkdownBox from 'components/MarkdownBox';
|
||||
import { parseISO8601Date, toLocaleString } from 'scripts/datetime';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
import type { PluginDetails } from '../types/PluginDetails';
|
||||
import { VersionInfo } from '@jellyfin/sdk/lib/generated-client';
|
||||
|
||||
interface PluginRevisionsProps {
|
||||
pluginDetails?: PluginDetails,
|
||||
onInstall: (version?: VersionInfo) => () => void
|
||||
}
|
||||
|
||||
const PluginRevisions: FC<PluginRevisionsProps> = ({
|
||||
pluginDetails,
|
||||
onInstall
|
||||
}) => (
|
||||
pluginDetails?.versions?.map(version => (
|
||||
<Accordion key={version.checksum}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMore />}
|
||||
>
|
||||
{version.version}
|
||||
{version.timestamp && (<>
|
||||
—
|
||||
{toLocaleString(parseISO8601Date(version.timestamp))}
|
||||
</>)}
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<MarkdownBox
|
||||
fallback={globalize.translate('LabelNoChangelog')}
|
||||
markdown={version.changelog}
|
||||
/>
|
||||
{pluginDetails.status && version.version === pluginDetails.version?.version ? (
|
||||
<Button
|
||||
disabled
|
||||
startIcon={<DownloadDone />}
|
||||
variant='outlined'
|
||||
>
|
||||
{globalize.translate('LabelInstalled')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
startIcon={<Download />}
|
||||
variant='outlined'
|
||||
onClick={onInstall(version)}
|
||||
>
|
||||
{globalize.translate('HeaderInstall')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))
|
||||
);
|
||||
|
||||
export default PluginRevisions;
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { ConfigurationPageInfo, PluginStatus, VersionInfo } from '@jellyfin/sdk/lib/generated-client';
|
||||
|
||||
export interface PluginDetails {
|
||||
canUninstall: boolean
|
||||
description?: string
|
||||
id: string
|
||||
imageUrl?: string
|
||||
isEnabled: boolean
|
||||
name?: string
|
||||
owner?: string
|
||||
configurationPage?: ConfigurationPageInfo
|
||||
status?: PluginStatus
|
||||
version?: VersionInfo
|
||||
versions: VersionInfo[]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { AsyncRouteType, type AsyncRoute } from 'components/router/AsyncRoute';
|
||||
|
||||
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||
{ path: 'activity', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'playback/trickplay', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/access', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/add', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/parentalcontrol', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/password', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/profile', type: AsyncRouteType.Dashboard }
|
||||
];
|
||||
@@ -1,143 +0,0 @@
|
||||
import type { LegacyRoute } from 'components/router/LegacyRoute';
|
||||
|
||||
export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
pageProps: {
|
||||
controller: 'dashboard/dashboard',
|
||||
view: 'dashboard/dashboard.html'
|
||||
}
|
||||
}, {
|
||||
path: 'settings',
|
||||
pageProps: {
|
||||
controller: 'dashboard/general',
|
||||
view: 'dashboard/general.html'
|
||||
}
|
||||
}, {
|
||||
path: 'networking',
|
||||
pageProps: {
|
||||
controller: 'dashboard/networking',
|
||||
view: 'dashboard/networking.html'
|
||||
}
|
||||
}, {
|
||||
path: 'devices',
|
||||
pageProps: {
|
||||
controller: 'dashboard/devices/devices',
|
||||
view: 'dashboard/devices/devices.html'
|
||||
}
|
||||
}, {
|
||||
path: 'devices/edit',
|
||||
pageProps: {
|
||||
controller: 'dashboard/devices/device',
|
||||
view: 'dashboard/devices/device.html'
|
||||
}
|
||||
}, {
|
||||
path: 'libraries',
|
||||
pageProps: {
|
||||
controller: 'dashboard/library',
|
||||
view: 'dashboard/library.html'
|
||||
}
|
||||
}, {
|
||||
path: 'libraries/display',
|
||||
pageProps: {
|
||||
controller: 'dashboard/librarydisplay',
|
||||
view: 'dashboard/librarydisplay.html'
|
||||
}
|
||||
}, {
|
||||
path: 'playback/transcoding',
|
||||
pageProps: {
|
||||
controller: 'dashboard/encodingsettings',
|
||||
view: 'dashboard/encodingsettings.html'
|
||||
}
|
||||
}, {
|
||||
path: 'logs',
|
||||
pageProps: {
|
||||
controller: 'dashboard/logs',
|
||||
view: 'dashboard/logs.html'
|
||||
}
|
||||
}, {
|
||||
path: 'libraries/metadata',
|
||||
pageProps: {
|
||||
controller: 'dashboard/metadataImages',
|
||||
view: 'dashboard/metadataimages.html'
|
||||
}
|
||||
}, {
|
||||
path: 'libraries/nfo',
|
||||
pageProps: {
|
||||
controller: 'dashboard/metadatanfo',
|
||||
view: 'dashboard/metadatanfo.html'
|
||||
}
|
||||
}, {
|
||||
path: 'playback/resume',
|
||||
pageProps: {
|
||||
controller: 'dashboard/playback',
|
||||
view: 'dashboard/playback.html'
|
||||
}
|
||||
}, {
|
||||
path: 'plugins/catalog',
|
||||
pageProps: {
|
||||
controller: 'dashboard/plugins/available/index',
|
||||
view: 'dashboard/plugins/available/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'plugins/repositories',
|
||||
pageProps: {
|
||||
controller: 'dashboard/plugins/repositories/index',
|
||||
view: 'dashboard/plugins/repositories/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'livetv/guide',
|
||||
pageProps: {
|
||||
controller: 'livetvguideprovider',
|
||||
view: 'livetvguideprovider.html'
|
||||
}
|
||||
}, {
|
||||
path: 'recordings',
|
||||
pageProps: {
|
||||
controller: 'livetvsettings',
|
||||
view: 'livetvsettings.html'
|
||||
}
|
||||
}, {
|
||||
path: 'livetv',
|
||||
pageProps: {
|
||||
controller: 'livetvstatus',
|
||||
view: 'livetvstatus.html'
|
||||
}
|
||||
}, {
|
||||
path: 'livetv/tuner',
|
||||
pageProps: {
|
||||
controller: 'livetvtuner',
|
||||
view: 'livetvtuner.html'
|
||||
}
|
||||
}, {
|
||||
path: 'plugins',
|
||||
pageProps: {
|
||||
controller: 'dashboard/plugins/installed/index',
|
||||
view: 'dashboard/plugins/installed/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'tasks/edit',
|
||||
pageProps: {
|
||||
controller: 'dashboard/scheduledtasks/scheduledtask',
|
||||
view: 'dashboard/scheduledtasks/scheduledtask.html'
|
||||
}
|
||||
}, {
|
||||
path: 'tasks',
|
||||
pageProps: {
|
||||
controller: 'dashboard/scheduledtasks/scheduledtasks',
|
||||
view: 'dashboard/scheduledtasks/scheduledtasks.html'
|
||||
}
|
||||
}, {
|
||||
path: 'keys',
|
||||
pageProps: {
|
||||
controller: 'dashboard/apikeys',
|
||||
view: 'dashboard/apikeys.html'
|
||||
}
|
||||
}, {
|
||||
path: 'playback/streaming',
|
||||
pageProps: {
|
||||
view: 'dashboard/streaming.html',
|
||||
controller: 'dashboard/streaming'
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { Redirect } from 'components/router/Redirect';
|
||||
|
||||
export const REDIRECTS: Redirect[] = [
|
||||
{ from: 'apikeys.html', to: '/dashboard/keys' },
|
||||
{ from: 'availableplugins.html', to: '/dashboard/plugins/catalog' },
|
||||
{ from: 'dashboard.html', to: '/dashboard' },
|
||||
{ from: 'dashboardgeneral.html', to: '/dashboard/settings' },
|
||||
{ from: 'device.html', to: '/dashboard/devices/edit' },
|
||||
{ from: 'devices.html', to: '/dashboard/devices' },
|
||||
{ from: 'edititemmetadata.html', to: '/metadata' },
|
||||
{ from: 'encodingsettings.html', to: '/dashboard/playback/transcoding' },
|
||||
{ from: 'installedplugins.html', to: '/dashboard/plugins' },
|
||||
{ from: 'library.html', to: '/dashboard/libraries' },
|
||||
{ from: 'librarydisplay.html', to: '/dashboard/libraries/display' },
|
||||
{ from: 'livetvguideprovider.html', to: '/dashboard/livetv/guide' },
|
||||
{ from: 'livetvsettings.html', to: '/dashboard/recordings' },
|
||||
{ from: 'livetvstatus.html', to: '/dashboard/livetv' },
|
||||
{ from: 'livetvtuner.html', to: '/dashboard/livetv/tuner' },
|
||||
{ from: 'log.html', to: '/dashboard/logs' },
|
||||
{ from: 'metadataimages.html', to: '/dashboard/libraries/metadata' },
|
||||
{ from: 'metadatanfo.html', to: '/dashboard/libraries/nfo' },
|
||||
{ from: 'networking.html', to: '/dashboard/networking' },
|
||||
{ from: 'playbackconfiguration.html', to: '/dashboard/playback/resume' },
|
||||
{ from: 'repositories.html', to: '/dashboard/plugins/repositories' },
|
||||
{ from: 'scheduledtask.html', to: '/dashboard/tasks/edit' },
|
||||
{ from: 'scheduledtasks.html', to: '/dashboard/tasks' },
|
||||
{ from: 'serveractivity.html', to: '/dashboard/activity' },
|
||||
{ from: 'streamingsettings.html', to: '/dashboard/playback/streaming' },
|
||||
{ from: 'useredit.html', to: '/dashboard/users/profile' },
|
||||
{ from: 'userlibraryaccess.html', to: '/dashboard/users/access' },
|
||||
{ from: 'usernew.html', to: '/dashboard/users/add' },
|
||||
{ from: 'userparentalcontrol.html', to: '/dashboard/users/parentalcontrol' },
|
||||
{ from: 'userpassword.html', to: '/dashboard/users/password' },
|
||||
{ from: 'userprofiles.html', to: '/dashboard/users' }
|
||||
];
|
||||
@@ -1,274 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { getActivityLogApi } from '@jellyfin/sdk/lib/utils/api/activity-log-api';
|
||||
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
|
||||
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
|
||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
|
||||
import PermMedia from '@mui/icons-material/PermMedia';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import Page from 'components/Page';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { parseISO8601Date, toLocaleDateString, toLocaleTimeString } from 'scripts/datetime';
|
||||
import globalize from 'lib/globalize';
|
||||
import { toBoolean } from 'utils/string';
|
||||
|
||||
import LogLevelChip from '../components/activityTable/LogLevelChip';
|
||||
import OverviewCell from '../components/activityTable/OverviewCell';
|
||||
import GridActionsCellLink from '../components/dataGrid/GridActionsCellLink';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const VIEW_PARAM = 'useractivity';
|
||||
|
||||
const enum ActivityView {
|
||||
All,
|
||||
User,
|
||||
System
|
||||
}
|
||||
|
||||
const getActivityView = (param: string | null) => {
|
||||
if (param === null) return ActivityView.All;
|
||||
if (toBoolean(param)) return ActivityView.User;
|
||||
return ActivityView.System;
|
||||
};
|
||||
|
||||
const getRowId = (row: ActivityLogEntry) => row.Id ?? -1;
|
||||
|
||||
const Activity = () => {
|
||||
const { api } = useApi();
|
||||
const [ searchParams, setSearchParams ] = useSearchParams();
|
||||
|
||||
const [ activityView, setActivityView ] = useState(
|
||||
getActivityView(searchParams.get(VIEW_PARAM)));
|
||||
const [ isLoading, setIsLoading ] = useState(true);
|
||||
const [ paginationModel, setPaginationModel ] = useState({
|
||||
page: 0,
|
||||
pageSize: DEFAULT_PAGE_SIZE
|
||||
});
|
||||
const [ rowCount, setRowCount ] = useState(0);
|
||||
const [ rows, setRows ] = useState<ActivityLogEntry[]>([]);
|
||||
const [ users, setUsers ] = useState<Record<string, UserDto>>({});
|
||||
|
||||
const userColDef: GridColDef[] = activityView !== ActivityView.System ? [
|
||||
{
|
||||
field: 'User',
|
||||
headerName: globalize.translate('LabelUser'),
|
||||
width: 60,
|
||||
valueGetter: ( value, row ) => users[row.UserId]?.Name,
|
||||
renderCell: ({ row }) => (
|
||||
<IconButton
|
||||
size='large'
|
||||
color='inherit'
|
||||
sx={{ padding: 0 }}
|
||||
title={users[row.UserId]?.Name ?? undefined}
|
||||
component={Link}
|
||||
to={`/dashboard/users/profile?userId=${row.UserId}`}
|
||||
>
|
||||
<UserAvatar user={users[row.UserId]} />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
] : [];
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: 'Date',
|
||||
headerName: globalize.translate('LabelDate'),
|
||||
width: 90,
|
||||
type: 'date',
|
||||
valueGetter: ( value ) => parseISO8601Date(value),
|
||||
valueFormatter: ( value ) => toLocaleDateString(value)
|
||||
},
|
||||
{
|
||||
field: 'Time',
|
||||
headerName: globalize.translate('LabelTime'),
|
||||
width: 100,
|
||||
type: 'dateTime',
|
||||
valueGetter: ( value, row ) => parseISO8601Date(row.Date),
|
||||
valueFormatter: ( value ) => toLocaleTimeString(value)
|
||||
},
|
||||
{
|
||||
field: 'Severity',
|
||||
headerName: globalize.translate('LabelLevel'),
|
||||
width: 110,
|
||||
renderCell: ({ value }) => (
|
||||
value ? (
|
||||
<LogLevelChip level={value} />
|
||||
) : undefined
|
||||
)
|
||||
},
|
||||
...userColDef,
|
||||
{
|
||||
field: 'Name',
|
||||
headerName: globalize.translate('LabelName'),
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
field: 'Overview',
|
||||
headerName: globalize.translate('LabelOverview'),
|
||||
width: 200,
|
||||
valueGetter: ( value, row ) => row.ShortOverview ?? row.Overview,
|
||||
renderCell: ({ row }) => (
|
||||
<OverviewCell {...row} />
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'Type',
|
||||
headerName: globalize.translate('LabelType'),
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
type: 'actions',
|
||||
width: 50,
|
||||
getActions: ({ row }) => {
|
||||
const actions = [];
|
||||
|
||||
if (row.ItemId) {
|
||||
actions.push(
|
||||
<GridActionsCellLink
|
||||
size='large'
|
||||
icon={<PermMedia />}
|
||||
label={globalize.translate('LabelMediaDetails')}
|
||||
title={globalize.translate('LabelMediaDetails')}
|
||||
to={`/details?id=${row.ItemId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const onViewChange = useCallback((_e: React.MouseEvent<HTMLElement, MouseEvent>, newView: ActivityView | null) => {
|
||||
if (newView !== null) {
|
||||
setActivityView(newView);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (api) {
|
||||
const fetchUsers = async () => {
|
||||
const { data } = await getUserApi(api).getUsers();
|
||||
const usersById: Record<string, UserDto> = {};
|
||||
data.forEach(user => {
|
||||
if (user.Id) {
|
||||
usersById[user.Id] = user;
|
||||
}
|
||||
});
|
||||
|
||||
setUsers(usersById);
|
||||
};
|
||||
|
||||
fetchUsers()
|
||||
.catch(err => {
|
||||
console.error('[activity] failed to fetch users', err);
|
||||
});
|
||||
}
|
||||
}, [ api ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (api) {
|
||||
const fetchActivity = async () => {
|
||||
const params: {
|
||||
startIndex: number,
|
||||
limit: number,
|
||||
hasUserId?: boolean
|
||||
} = {
|
||||
startIndex: paginationModel.page * paginationModel.pageSize,
|
||||
limit: paginationModel.pageSize
|
||||
};
|
||||
if (activityView !== ActivityView.All) {
|
||||
params.hasUserId = activityView === ActivityView.User;
|
||||
}
|
||||
|
||||
const { data } = await getActivityLogApi(api)
|
||||
.getLogEntries(params);
|
||||
|
||||
setRowCount(data.TotalRecordCount ?? 0);
|
||||
setRows(data.Items ?? []);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
setIsLoading(true);
|
||||
fetchActivity()
|
||||
.catch(err => {
|
||||
console.error('[activity] failed to fetch activity log entries', err);
|
||||
});
|
||||
}
|
||||
}, [ activityView, api, paginationModel ]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentViewParam = getActivityView(searchParams.get(VIEW_PARAM));
|
||||
if (currentViewParam !== activityView) {
|
||||
if (activityView === ActivityView.All) {
|
||||
searchParams.delete(VIEW_PARAM);
|
||||
} else {
|
||||
searchParams.set(VIEW_PARAM, `${activityView === ActivityView.User}`);
|
||||
}
|
||||
setSearchParams(searchParams);
|
||||
}
|
||||
}, [ activityView, searchParams, setSearchParams ]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='serverActivityPage'
|
||||
title={globalize.translate('HeaderActivity')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<div className='content-primary'>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
marginY: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant='h2'>
|
||||
{globalize.translate('HeaderActivity')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<ToggleButtonGroup
|
||||
value={activityView}
|
||||
onChange={onViewChange}
|
||||
exclusive
|
||||
>
|
||||
<ToggleButton value={ActivityView.All}>
|
||||
{globalize.translate('All')}
|
||||
</ToggleButton>
|
||||
<ToggleButton value={ActivityView.User}>
|
||||
{globalize.translate('LabelUser')}
|
||||
</ToggleButton>
|
||||
<ToggleButton value={ActivityView.System}>
|
||||
{globalize.translate('LabelSystem')}
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
pageSizeOptions={[ 10, 25, 50, 100 ]}
|
||||
paginationMode='server'
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
rowCount={rowCount}
|
||||
getRowId={getRowId}
|
||||
loading={isLoading}
|
||||
sx={{
|
||||
minHeight: 500
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default Activity;
|
||||
@@ -1,313 +0,0 @@
|
||||
import type { ServerConfiguration } from '@jellyfin/sdk/lib/generated-client/models/server-configuration';
|
||||
import { TrickplayScanBehavior } from '@jellyfin/sdk/lib/generated-client/models/trickplay-scan-behavior';
|
||||
import { ProcessPriorityClass } from '@jellyfin/sdk/lib/generated-client/models/process-priority-class';
|
||||
import React, { type FC, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import Page from '../../../../components/Page';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import ButtonElement from '../../../../elements/ButtonElement';
|
||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import SelectElement from '../../../../elements/SelectElement';
|
||||
import InputElement from '../../../../elements/InputElement';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import toast from '../../../../components/toast/toast';
|
||||
import ServerConnections from '../../../../components/ServerConnections';
|
||||
|
||||
function onSaveComplete() {
|
||||
loading.hide();
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
}
|
||||
|
||||
const PlaybackTrickplay: FC = () => {
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadConfig = useCallback((config: ServerConfiguration) => {
|
||||
const page = element.current;
|
||||
const options = config.TrickplayOptions;
|
||||
|
||||
if (!page) {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
|
||||
(page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked = options?.EnableHwAcceleration || false;
|
||||
(page.querySelector('.chkEnableHwEncoding') as HTMLInputElement).checked = options?.EnableHwEncoding || false;
|
||||
(page.querySelector('#selectScanBehavior') as HTMLSelectElement).value = (options?.ScanBehavior || TrickplayScanBehavior.NonBlocking);
|
||||
(page.querySelector('#selectProcessPriority') as HTMLSelectElement).value = (options?.ProcessPriority || ProcessPriorityClass.Normal);
|
||||
(page.querySelector('#txtInterval') as HTMLInputElement).value = options?.Interval?.toString() || '10000';
|
||||
(page.querySelector('#txtWidthResolutions') as HTMLInputElement).value = options?.WidthResolutions?.join(',') || '';
|
||||
(page.querySelector('#txtTileWidth') as HTMLInputElement).value = options?.TileWidth?.toString() || '10';
|
||||
(page.querySelector('#txtTileHeight') as HTMLInputElement).value = options?.TileHeight?.toString() || '10';
|
||||
(page.querySelector('#txtQscale') as HTMLInputElement).value = options?.Qscale?.toString() || '4';
|
||||
(page.querySelector('#txtJpegQuality') as HTMLInputElement).value = options?.JpegQuality?.toString() || '90';
|
||||
(page.querySelector('#txtProcessThreads') as HTMLInputElement).value = options?.ProcessThreads?.toString() || '1';
|
||||
|
||||
loading.hide();
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
loading.show();
|
||||
|
||||
ServerConnections.currentApiClient()?.getServerConfiguration().then(function (config) {
|
||||
loadConfig(config);
|
||||
}).catch(err => {
|
||||
console.error('[PlaybackTrickplay] failed to fetch server config', err);
|
||||
});
|
||||
}, [loadConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
|
||||
const saveConfig = (config: ServerConfiguration) => {
|
||||
const apiClient = ServerConnections.currentApiClient();
|
||||
|
||||
if (!apiClient) {
|
||||
console.error('[PlaybackTrickplay] No current apiclient instance');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.TrickplayOptions) {
|
||||
throw new Error('Unexpected null TrickplayOptions');
|
||||
}
|
||||
|
||||
const options = config.TrickplayOptions;
|
||||
options.EnableHwAcceleration = (page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked;
|
||||
options.EnableHwEncoding = (page.querySelector('.chkEnableHwEncoding') as HTMLInputElement).checked;
|
||||
options.ScanBehavior = (page.querySelector('#selectScanBehavior') as HTMLSelectElement).value as TrickplayScanBehavior;
|
||||
options.ProcessPriority = (page.querySelector('#selectProcessPriority') as HTMLSelectElement).value as ProcessPriorityClass;
|
||||
options.Interval = Math.max(1, parseInt((page.querySelector('#txtInterval') as HTMLInputElement).value || '10000', 10));
|
||||
options.WidthResolutions = (page.querySelector('#txtWidthResolutions') as HTMLInputElement).value.replace(' ', '').split(',').map(Number);
|
||||
options.TileWidth = Math.max(1, parseInt((page.querySelector('#txtTileWidth') as HTMLInputElement).value || '10', 10));
|
||||
options.TileHeight = Math.max(1, parseInt((page.querySelector('#txtTileHeight') as HTMLInputElement).value || '10', 10));
|
||||
options.Qscale = Math.min(31, parseInt((page.querySelector('#txtQscale') as HTMLInputElement).value || '4', 10));
|
||||
options.JpegQuality = Math.min(100, parseInt((page.querySelector('#txtJpegQuality') as HTMLInputElement).value || '90', 10));
|
||||
options.ProcessThreads = parseInt((page.querySelector('#txtProcessThreads') as HTMLInputElement).value || '1', 10);
|
||||
|
||||
apiClient.updateServerConfiguration(config).then(() => {
|
||||
onSaveComplete();
|
||||
}).catch(err => {
|
||||
console.error('[PlaybackTrickplay] failed to update config', err);
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = (e: Event) => {
|
||||
const apiClient = ServerConnections.currentApiClient();
|
||||
|
||||
if (!apiClient) {
|
||||
console.error('[PlaybackTrickplay] No current apiclient instance');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.show();
|
||||
apiClient.getServerConfiguration().then(function (config) {
|
||||
saveConfig(config);
|
||||
}).catch(err => {
|
||||
console.error('[PlaybackTrickplay] failed to fetch server config', err);
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
};
|
||||
|
||||
(page.querySelector('.trickplayConfigurationForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const optionScanBehavior = () => {
|
||||
let content = '';
|
||||
content += `<option value='NonBlocking'>${globalize.translate('NonBlockingScan')}</option>`;
|
||||
content += `<option value='Blocking'>${globalize.translate('BlockingScan')}</option>`;
|
||||
return content;
|
||||
};
|
||||
|
||||
const optionProcessPriority = () => {
|
||||
let content = '';
|
||||
content += `<option value='High'>${globalize.translate('PriorityHigh')}</option>`;
|
||||
content += `<option value='AboveNormal'>${globalize.translate('PriorityAboveNormal')}</option>`;
|
||||
content += `<option value='Normal'>${globalize.translate('PriorityNormal')}</option>`;
|
||||
content += `<option value='BelowNormal'>${globalize.translate('PriorityBelowNormal')}</option>`;
|
||||
content += `<option value='Idle'>${globalize.translate('PriorityIdle')}</option>`;
|
||||
return content;
|
||||
};
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='trickplayConfigurationPage'
|
||||
className='mainAnimatedPage type-interior playbackConfigurationPage'
|
||||
title={globalize.translate('Trickplay')}
|
||||
>
|
||||
<div ref={element} className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={globalize.translate('Trickplay')}
|
||||
isLinkVisible={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form className='trickplayConfigurationForm'>
|
||||
<div className='checkboxContainer checkboxContainer-withDescription'>
|
||||
<CheckBoxElement
|
||||
className='chkEnableHwAcceleration'
|
||||
title='LabelTrickplayAccel'
|
||||
/>
|
||||
</div>
|
||||
<div className='checkboxContainer checkboxContainer-withDescription'>
|
||||
<CheckBoxElement
|
||||
className='chkEnableHwEncoding'
|
||||
title='LabelTrickplayAccelEncoding'
|
||||
/>
|
||||
<div className='fieldDescription checkboxFieldDescription'>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelTrickplayAccelEncodingHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='selectContainer fldSelectScanBehavior'>
|
||||
<SelectElement
|
||||
id='selectScanBehavior'
|
||||
label='LabelScanBehavior'
|
||||
>
|
||||
{optionScanBehavior()}
|
||||
</SelectElement>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelScanBehaviorHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='selectContainer fldSelectProcessPriority'>
|
||||
<SelectElement
|
||||
id='selectProcessPriority'
|
||||
label='LabelProcessPriority'
|
||||
>
|
||||
{optionProcessPriority()}
|
||||
</SelectElement>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelProcessPriorityHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtInterval'
|
||||
label='LabelImageInterval'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelImageIntervalHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='text'
|
||||
id='txtWidthResolutions'
|
||||
label='LabelWidthResolutions'
|
||||
options={'required pattern="[0-9,]*"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelWidthResolutionsHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtTileWidth'
|
||||
label='LabelTileWidth'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelTileWidthHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtTileHeight'
|
||||
label='LabelTileHeight'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelTileHeightHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtJpegQuality'
|
||||
label='LabelJpegQuality'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="1" max="100"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelJpegQualityHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtQscale'
|
||||
label='LabelQscale'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="2" max="31"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelQscaleHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtProcessThreads'
|
||||
label='LabelTrickplayThreads'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="0"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelTrickplayThreadsHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ButtonElement
|
||||
type='submit'
|
||||
className='raised button-submit block'
|
||||
title='Save'
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaybackTrickplay;
|
||||
@@ -1,443 +0,0 @@
|
||||
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
|
||||
import type { VersionInfo } from '@jellyfin/sdk/lib/generated-client/models/version-info';
|
||||
import Alert from '@mui/material/Alert/Alert';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import Container from '@mui/material/Container/Container';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel/FormControlLabel';
|
||||
import FormGroup from '@mui/material/FormGroup/FormGroup';
|
||||
import Grid from '@mui/material/Grid/Grid';
|
||||
import Skeleton from '@mui/material/Skeleton/Skeleton';
|
||||
import Stack from '@mui/material/Stack/Stack';
|
||||
import Switch from '@mui/material/Switch/Switch';
|
||||
import Typography from '@mui/material/Typography/Typography';
|
||||
import Delete from '@mui/icons-material/Delete';
|
||||
import Download from '@mui/icons-material/Download';
|
||||
import Settings from '@mui/icons-material/Settings';
|
||||
import React, { type FC, useState, useCallback, useMemo } from 'react';
|
||||
import { useSearchParams, Link as RouterLink, useParams } from 'react-router-dom';
|
||||
|
||||
import { findBestConfigurationPage } from 'apps/dashboard/features/plugins/api/configurationPage';
|
||||
import { findBestPluginInfo } from 'apps/dashboard/features/plugins/api/pluginInfo';
|
||||
import { useConfigurationPages } from 'apps/dashboard/features/plugins/api/useConfigurationPages';
|
||||
import { useDisablePlugin } from 'apps/dashboard/features/plugins/api/useDisablePlugin';
|
||||
import { useEnablePlugin } from 'apps/dashboard/features/plugins/api/useEnablePlugin';
|
||||
import { useInstallPackage } from 'apps/dashboard/features/plugins/api/useInstallPackage';
|
||||
import { usePackageInfo } from 'apps/dashboard/features/plugins/api/usePackageInfo';
|
||||
import { usePlugins } from 'apps/dashboard/features/plugins/api/usePlugins';
|
||||
import { useUninstallPlugin } from 'apps/dashboard/features/plugins/api/useUninstallPlugin';
|
||||
import PluginImage from 'apps/dashboard/features/plugins/components/PluginImage';
|
||||
import PluginDetailsTable from 'apps/dashboard/features/plugins/components/PluginDetailsTable';
|
||||
import PluginRevisions from 'apps/dashboard/features/plugins/components/PluginRevisions';
|
||||
import type { PluginDetails } from 'apps/dashboard/features/plugins/types/PluginDetails';
|
||||
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
import Page from 'components/Page';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import globalize from 'lib/globalize';
|
||||
import { getPluginUrl } from 'utils/dashboard';
|
||||
import { getUri } from 'utils/api';
|
||||
|
||||
interface AlertMessage {
|
||||
severity?: 'success' | 'info' | 'warning' | 'error'
|
||||
messageKey: string
|
||||
}
|
||||
|
||||
// Plugins from this url will be trusted and not prompt for confirmation when installing
|
||||
const TRUSTED_REPO_URL = 'https://repo.jellyfin.org/';
|
||||
|
||||
const PluginPage: FC = () => {
|
||||
const { api } = useApi();
|
||||
const { pluginId } = useParams();
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const disablePlugin = useDisablePlugin();
|
||||
const enablePlugin = useEnablePlugin();
|
||||
const installPlugin = useInstallPackage();
|
||||
const uninstallPlugin = useUninstallPlugin();
|
||||
|
||||
const [ isEnabledOverride, setIsEnabledOverride ] = useState<boolean>();
|
||||
const [ isInstallConfirmOpen, setIsInstallConfirmOpen ] = useState(false);
|
||||
const [ isUninstallConfirmOpen, setIsUninstallConfirmOpen ] = useState(false);
|
||||
const [ pendingInstallVersion, setPendingInstallVersion ] = useState<VersionInfo>();
|
||||
|
||||
const pluginName = searchParams.get('name') ?? undefined;
|
||||
|
||||
const {
|
||||
data: configurationPages,
|
||||
isError: isConfigurationPagesError,
|
||||
isLoading: isConfigurationPagesLoading
|
||||
} = useConfigurationPages();
|
||||
|
||||
const {
|
||||
data: packageInfo,
|
||||
isError: isPackageInfoError,
|
||||
isLoading: isPackageInfoLoading
|
||||
} = usePackageInfo(pluginName ? {
|
||||
name: pluginName,
|
||||
assemblyGuid: pluginId
|
||||
} : undefined);
|
||||
|
||||
const {
|
||||
data: plugins,
|
||||
isLoading: isPluginsLoading,
|
||||
isError: isPluginsError
|
||||
} = usePlugins();
|
||||
|
||||
const isLoading =
|
||||
isConfigurationPagesLoading || isPackageInfoLoading || isPluginsLoading;
|
||||
|
||||
const pluginDetails = useMemo<PluginDetails | undefined>(() => {
|
||||
if (pluginId && !isPluginsLoading) {
|
||||
const pluginInfo = findBestPluginInfo(pluginId, plugins);
|
||||
|
||||
let version;
|
||||
if (pluginInfo) {
|
||||
// Find the installed version
|
||||
const repoVersion = packageInfo?.versions?.find(v => v.version === pluginInfo.Version);
|
||||
version = repoVersion || {
|
||||
version: pluginInfo.Version,
|
||||
VersionNumber: pluginInfo.Version
|
||||
};
|
||||
} else {
|
||||
// Use the latest version
|
||||
version = packageInfo?.versions?.[0];
|
||||
}
|
||||
|
||||
let imageUrl;
|
||||
if (pluginInfo?.HasImage) {
|
||||
imageUrl = getUri(`/Plugins/${pluginInfo.Id}/${pluginInfo.Version}/Image`, api);
|
||||
}
|
||||
|
||||
return {
|
||||
canUninstall: !!pluginInfo?.CanUninstall,
|
||||
description: pluginInfo?.Description || packageInfo?.description || packageInfo?.overview,
|
||||
id: pluginId,
|
||||
imageUrl: imageUrl || packageInfo?.imageUrl || undefined,
|
||||
isEnabled: (isEnabledOverride && pluginInfo?.Status === PluginStatus.Restart)
|
||||
?? pluginInfo?.Status !== PluginStatus.Disabled,
|
||||
name: pluginName || pluginInfo?.Name || packageInfo?.name,
|
||||
owner: packageInfo?.owner,
|
||||
status: pluginInfo?.Status,
|
||||
configurationPage: findBestConfigurationPage(configurationPages || [], pluginId),
|
||||
version,
|
||||
versions: packageInfo?.versions || []
|
||||
};
|
||||
}
|
||||
}, [
|
||||
api,
|
||||
configurationPages,
|
||||
isEnabledOverride,
|
||||
isPluginsLoading,
|
||||
packageInfo?.description,
|
||||
packageInfo?.imageUrl,
|
||||
packageInfo?.name,
|
||||
packageInfo?.overview,
|
||||
packageInfo?.owner,
|
||||
packageInfo?.versions,
|
||||
pluginId,
|
||||
pluginName,
|
||||
plugins
|
||||
]);
|
||||
|
||||
const alertMessages = useMemo(() => {
|
||||
const alerts: AlertMessage[] = [];
|
||||
|
||||
if (disablePlugin.isError) {
|
||||
alerts.push({ messageKey: 'PluginDisableError' });
|
||||
}
|
||||
|
||||
if (enablePlugin.isError) {
|
||||
alerts.push({ messageKey: 'PluginEnableError' });
|
||||
}
|
||||
|
||||
if (installPlugin.isSuccess) {
|
||||
alerts.push({
|
||||
severity: 'success',
|
||||
messageKey: 'MessagePluginInstalled'
|
||||
});
|
||||
}
|
||||
|
||||
if (installPlugin.isError) {
|
||||
alerts.push({ messageKey: 'MessagePluginInstallError' });
|
||||
}
|
||||
|
||||
if (uninstallPlugin.isError) {
|
||||
alerts.push({ messageKey: 'PluginUninstallError' });
|
||||
}
|
||||
|
||||
if (isConfigurationPagesError) {
|
||||
alerts.push({ messageKey: 'PluginLoadConfigError' });
|
||||
}
|
||||
|
||||
if (isPackageInfoError) {
|
||||
alerts.push({
|
||||
severity: 'warning',
|
||||
messageKey: 'PluginLoadRepoError'
|
||||
});
|
||||
}
|
||||
|
||||
if (isPluginsError) {
|
||||
alerts.push({ messageKey: 'MessageGetInstalledPluginsError' });
|
||||
}
|
||||
|
||||
return alerts;
|
||||
}, [
|
||||
disablePlugin.isError,
|
||||
enablePlugin.isError,
|
||||
installPlugin.isError,
|
||||
installPlugin.isSuccess,
|
||||
isConfigurationPagesError,
|
||||
isPackageInfoError,
|
||||
isPluginsError,
|
||||
uninstallPlugin.isError
|
||||
]);
|
||||
|
||||
/** Enable/disable the plugin */
|
||||
const toggleEnabled = useCallback(() => {
|
||||
if (!pluginDetails?.version?.version) return;
|
||||
|
||||
console.debug('[PluginPage] %s plugin', pluginDetails.isEnabled ? 'disabling' : 'enabling', pluginDetails);
|
||||
|
||||
if (pluginDetails.isEnabled) {
|
||||
disablePlugin.mutate({
|
||||
pluginId: pluginDetails.id,
|
||||
version: pluginDetails.version.version
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsEnabledOverride(false);
|
||||
},
|
||||
onSettled: () => {
|
||||
installPlugin.reset();
|
||||
enablePlugin.reset();
|
||||
uninstallPlugin.reset();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
enablePlugin.mutate({
|
||||
pluginId: pluginDetails.id,
|
||||
version: pluginDetails.version.version
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsEnabledOverride(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
installPlugin.reset();
|
||||
disablePlugin.reset();
|
||||
uninstallPlugin.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [ disablePlugin, enablePlugin, installPlugin, pluginDetails, uninstallPlugin ]);
|
||||
|
||||
/** Install the plugin or prompt for confirmation if untrusted */
|
||||
const onInstall = useCallback((version?: VersionInfo, isConfirmed = false) => () => {
|
||||
if (!pluginDetails?.name) return;
|
||||
const installVersion = version || pluginDetails.version;
|
||||
if (!installVersion) return;
|
||||
|
||||
if (!isConfirmed && !installVersion.repositoryUrl?.startsWith(TRUSTED_REPO_URL)) {
|
||||
console.debug('[PluginPage] plugin install needs confirmed', installVersion);
|
||||
setPendingInstallVersion(installVersion);
|
||||
setIsInstallConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('[PluginPage] installing plugin', installVersion);
|
||||
|
||||
installPlugin.mutate({
|
||||
name: pluginDetails.name,
|
||||
assemblyGuid: pluginDetails.id,
|
||||
version: installVersion.version,
|
||||
repositoryUrl: installVersion.repositoryUrl
|
||||
}, {
|
||||
onSettled: () => {
|
||||
setPendingInstallVersion(undefined);
|
||||
disablePlugin.reset();
|
||||
enablePlugin.reset();
|
||||
uninstallPlugin.reset();
|
||||
}
|
||||
});
|
||||
}, [ disablePlugin, enablePlugin, installPlugin, pluginDetails, uninstallPlugin ]);
|
||||
|
||||
/** Confirm and install the plugin */
|
||||
const onConfirmInstall = useCallback(() => {
|
||||
console.debug('[PluginPage] confirmed installing plugin', pendingInstallVersion);
|
||||
setIsInstallConfirmOpen(false);
|
||||
onInstall(pendingInstallVersion, true)();
|
||||
}, [ onInstall, pendingInstallVersion ]);
|
||||
|
||||
/** Close the install confirmation dialog */
|
||||
const onCloseInstallConfirmDialog = useCallback(() => {
|
||||
setPendingInstallVersion(undefined);
|
||||
setIsInstallConfirmOpen(false);
|
||||
}, []);
|
||||
|
||||
/** Show the uninstall confirmation dialog */
|
||||
const onConfirmUninstall = useCallback(() => {
|
||||
setIsUninstallConfirmOpen(true);
|
||||
}, []);
|
||||
|
||||
/** Uninstall the plugin */
|
||||
const onUninstall = useCallback(() => {
|
||||
if (!pluginDetails?.version?.version) return;
|
||||
|
||||
console.debug('[PluginPage] uninstalling plugin', pluginDetails);
|
||||
|
||||
setIsUninstallConfirmOpen(false);
|
||||
|
||||
uninstallPlugin.mutate({
|
||||
pluginId: pluginDetails.id,
|
||||
version: pluginDetails.version.version
|
||||
}, {
|
||||
onSettled: () => {
|
||||
disablePlugin.reset();
|
||||
enablePlugin.reset();
|
||||
installPlugin.reset();
|
||||
}
|
||||
});
|
||||
}, [ disablePlugin, enablePlugin, installPlugin, pluginDetails, uninstallPlugin ]);
|
||||
|
||||
/** Close the uninstall confirmation dialog */
|
||||
const onCloseUninstallConfirmDialog = useCallback(() => {
|
||||
setIsUninstallConfirmOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='addPluginPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Container className='content-primary'>
|
||||
|
||||
{alertMessages.map(({ severity = 'error', messageKey }) => (
|
||||
<Alert key={messageKey} severity={severity}>
|
||||
{globalize.translate(messageKey)}
|
||||
</Alert>
|
||||
))}
|
||||
|
||||
<Grid container spacing={2} sx={{ marginTop: 0 }}>
|
||||
<Grid item xs={12} lg={8}>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant='h1'>
|
||||
{pluginDetails?.name || pluginName}
|
||||
</Typography>
|
||||
|
||||
<Typography sx={{ maxWidth: '80ch' }}>
|
||||
{isLoading && !pluginDetails?.description ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
pluginDetails?.description
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid item lg={4} sx={{ display: { xs: 'none', lg: 'initial' } }}>
|
||||
<PluginImage
|
||||
isLoading={isLoading}
|
||||
alt={pluginDetails?.name}
|
||||
url={pluginDetails?.imageUrl}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} lg={8} sx={{ order: { xs: 1, lg: 'initial' } }}>
|
||||
{!!pluginDetails?.versions.length && (
|
||||
<>
|
||||
<Typography variant='h3' sx={{ marginBottom: 2 }}>
|
||||
{globalize.translate('HeaderRevisionHistory')}
|
||||
</Typography>
|
||||
<PluginRevisions
|
||||
pluginDetails={pluginDetails}
|
||||
onInstall={onInstall}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} lg={4}>
|
||||
<Stack spacing={2} direction={{ xs: 'column', sm: 'row-reverse', lg: 'column' }}>
|
||||
<Stack spacing={1} sx={{ flexBasis: '50%' }}>
|
||||
{!isLoading && !pluginDetails?.status && (
|
||||
<>
|
||||
<Alert severity='info'>
|
||||
{globalize.translate('ServerRestartNeededAfterPluginInstall')}
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
startIcon={<Download />}
|
||||
onClick={onInstall()}
|
||||
>
|
||||
{globalize.translate('HeaderInstall')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isLoading && pluginDetails?.canUninstall && (
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={pluginDetails.isEnabled}
|
||||
onChange={toggleEnabled}
|
||||
disabled={pluginDetails.status === PluginStatus.Restart}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelEnablePlugin')}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
|
||||
{!isLoading && pluginDetails?.configurationPage?.Name && (
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to={`/${getPluginUrl(pluginDetails.configurationPage.Name)}`}
|
||||
startIcon={<Settings />}
|
||||
>
|
||||
{globalize.translate('Settings')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isLoading && pluginDetails?.canUninstall && (
|
||||
<Button
|
||||
color='error'
|
||||
startIcon={<Delete />}
|
||||
onClick={onConfirmUninstall}
|
||||
>
|
||||
{globalize.translate('ButtonUninstall')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<PluginDetailsTable
|
||||
isPluginLoading={isPluginsLoading}
|
||||
isRepositoryLoading={isPackageInfoLoading}
|
||||
pluginDetails={pluginDetails}
|
||||
sx={{ flexBasis: '50%' }}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
|
||||
<ConfirmDialog
|
||||
open={isInstallConfirmOpen}
|
||||
title={globalize.translate('HeaderConfirmPluginInstallation')}
|
||||
text={globalize.translate('MessagePluginInstallDisclaimer')}
|
||||
onCancel={onCloseInstallConfirmDialog}
|
||||
onConfirm={onConfirmInstall}
|
||||
confirmButtonText={globalize.translate('HeaderInstall')}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={isUninstallConfirmOpen}
|
||||
title={globalize.translate('HeaderUninstallPlugin')}
|
||||
text={globalize.translate('UninstallPluginConfirmation', pluginName || '')}
|
||||
onCancel={onCloseUninstallConfirmDialog}
|
||||
onConfirm={onUninstall}
|
||||
confirmButtonColor='error'
|
||||
confirmButtonText={globalize.translate('ButtonUninstall')}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginPage;
|
||||
@@ -1,50 +0,0 @@
|
||||
import React from 'react';
|
||||
import { RouteObject } from 'react-router-dom';
|
||||
import ConnectionRequired from 'components/ConnectionRequired';
|
||||
import { ASYNC_ADMIN_ROUTES } from './_asyncRoutes';
|
||||
import { toAsyncPageRoute } from 'components/router/AsyncRoute';
|
||||
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
|
||||
import { LEGACY_ADMIN_ROUTES } from './_legacyRoutes';
|
||||
import ServerContentPage from 'components/ServerContentPage';
|
||||
import ErrorBoundary from 'components/router/ErrorBoundary';
|
||||
|
||||
export const DASHBOARD_APP_PATHS = {
|
||||
Dashboard: 'dashboard',
|
||||
MetadataManager: 'metadata',
|
||||
PluginConfig: 'configurationpage'
|
||||
};
|
||||
|
||||
export const DASHBOARD_APP_ROUTES: RouteObject[] = [
|
||||
{
|
||||
element: <ConnectionRequired isAdminRequired />,
|
||||
children: [
|
||||
{
|
||||
lazy: () => import('../AppLayout'),
|
||||
children: [
|
||||
{
|
||||
path: DASHBOARD_APP_PATHS.Dashboard,
|
||||
children: [
|
||||
...ASYNC_ADMIN_ROUTES.map(toAsyncPageRoute),
|
||||
...LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute)
|
||||
],
|
||||
errorElement: <ErrorBoundary pageClasses={[ 'type-interior' ]} />
|
||||
},
|
||||
|
||||
/* NOTE: The metadata editor might deserve a dedicated app in the future */
|
||||
toViewManagerPageRoute({
|
||||
path: DASHBOARD_APP_PATHS.MetadataManager,
|
||||
pageProps: {
|
||||
controller: 'edititemmetadata',
|
||||
view: 'edititemmetadata.html'
|
||||
}
|
||||
}),
|
||||
|
||||
{
|
||||
path: DASHBOARD_APP_PATHS.PluginConfig,
|
||||
element: <ServerContentPage view='/web/configurationpage' />
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -1,331 +0,0 @@
|
||||
import type { BaseItemDto, DeviceInfo, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import libraryMenu from '../../../../scripts/libraryMenu';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import toast from '../../../../components/toast/toast';
|
||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||
import ButtonElement from '../../../../elements/ButtonElement';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import AccessContainer from '../../../../components/dashboard/users/AccessContainer';
|
||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import Page from '../../../../components/Page';
|
||||
|
||||
type ItemsArr = {
|
||||
Name?: string | null;
|
||||
Id?: string | null;
|
||||
AppName?: string | null;
|
||||
checkedAttribute?: string
|
||||
};
|
||||
|
||||
const UserLibraryAccess = () => {
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const [ userName, setUserName ] = useState('');
|
||||
const [channelsItems, setChannelsItems] = useState<ItemsArr[]>([]);
|
||||
const [mediaFoldersItems, setMediaFoldersItems] = useState<ItemsArr[]>([]);
|
||||
const [devicesItems, setDevicesItems] = useState<ItemsArr[]>([]);
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
const triggerChange = (select: HTMLInputElement) => {
|
||||
const evt = new Event('change', { bubbles: false, cancelable: true });
|
||||
select.dispatchEvent(evt);
|
||||
};
|
||||
|
||||
const loadMediaFolders = useCallback((user: UserDto, mediaFolders: BaseItemDto[]) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[userlibraryaccess] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsArr: ItemsArr[] = [];
|
||||
|
||||
for (const folder of mediaFolders) {
|
||||
const isChecked = user.Policy?.EnableAllFolders || user.Policy?.EnabledFolders?.indexOf(folder.Id || '') != -1;
|
||||
const checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
itemsArr.push({
|
||||
Id: folder.Id,
|
||||
Name: folder.Name,
|
||||
checkedAttribute: checkedAttribute
|
||||
});
|
||||
}
|
||||
|
||||
setMediaFoldersItems(itemsArr);
|
||||
|
||||
const chkEnableAllFolders = page.querySelector('.chkEnableAllFolders') as HTMLInputElement;
|
||||
chkEnableAllFolders.checked = Boolean(user.Policy?.EnableAllFolders);
|
||||
triggerChange(chkEnableAllFolders);
|
||||
}, []);
|
||||
|
||||
const loadChannels = useCallback((user: UserDto, channels: BaseItemDto[]) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[userlibraryaccess] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsArr: ItemsArr[] = [];
|
||||
|
||||
for (const folder of channels) {
|
||||
const isChecked = user.Policy?.EnableAllChannels || user.Policy?.EnabledChannels?.indexOf(folder.Id || '') != -1;
|
||||
const checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
itemsArr.push({
|
||||
Id: folder.Id,
|
||||
Name: folder.Name,
|
||||
checkedAttribute: checkedAttribute
|
||||
});
|
||||
}
|
||||
|
||||
setChannelsItems(itemsArr);
|
||||
|
||||
if (channels.length) {
|
||||
(page.querySelector('.channelAccessContainer') as HTMLDivElement).classList.remove('hide');
|
||||
} else {
|
||||
(page.querySelector('.channelAccessContainer') as HTMLDivElement).classList.add('hide');
|
||||
}
|
||||
|
||||
const chkEnableAllChannels = page.querySelector('.chkEnableAllChannels') as HTMLInputElement;
|
||||
chkEnableAllChannels.checked = Boolean(user.Policy?.EnableAllChannels);
|
||||
triggerChange(chkEnableAllChannels);
|
||||
}, []);
|
||||
|
||||
const loadDevices = useCallback((user: UserDto, devices: DeviceInfo[]) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[userlibraryaccess] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsArr: ItemsArr[] = [];
|
||||
|
||||
for (const device of devices) {
|
||||
const isChecked = user.Policy?.EnableAllDevices || user.Policy?.EnabledDevices?.indexOf(device.Id || '') != -1;
|
||||
const checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
itemsArr.push({
|
||||
Id: device.Id,
|
||||
Name: device.Name,
|
||||
AppName: device.AppName,
|
||||
checkedAttribute: checkedAttribute
|
||||
});
|
||||
}
|
||||
|
||||
setDevicesItems(itemsArr);
|
||||
|
||||
const chkEnableAllDevices = page.querySelector('.chkEnableAllDevices') as HTMLInputElement;
|
||||
chkEnableAllDevices.checked = Boolean(user.Policy?.EnableAllDevices);
|
||||
triggerChange(chkEnableAllDevices);
|
||||
|
||||
if (user.Policy?.IsAdministrator) {
|
||||
(page.querySelector('.deviceAccessContainer') as HTMLDivElement).classList.add('hide');
|
||||
} else {
|
||||
(page.querySelector('.deviceAccessContainer') as HTMLDivElement).classList.remove('hide');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUser = useCallback((user: UserDto, mediaFolders: BaseItemDto[], channels: BaseItemDto[], devices: DeviceInfo[]) => {
|
||||
setUserName(user.Name || '');
|
||||
libraryMenu.setTitle(user.Name);
|
||||
loadChannels(user, channels);
|
||||
loadMediaFolders(user, mediaFolders);
|
||||
loadDevices(user, devices);
|
||||
loading.hide();
|
||||
}, [loadChannels, loadDevices, loadMediaFolders]);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
loading.show();
|
||||
const promise1 = userId ? window.ApiClient.getUser(userId) : Promise.resolve({ Configuration: {} });
|
||||
const promise2 = window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', {
|
||||
IsHidden: false
|
||||
}));
|
||||
const promise3 = window.ApiClient.getJSON(window.ApiClient.getUrl('Channels'));
|
||||
const promise4 = window.ApiClient.getJSON(window.ApiClient.getUrl('Devices'));
|
||||
Promise.all([promise1, promise2, promise3, promise4]).then(function (responses) {
|
||||
loadUser(responses[0], responses[1].Items, responses[2].Items, responses[3].Items);
|
||||
}).catch(err => {
|
||||
console.error('[userlibraryaccess] failed to load data', err);
|
||||
});
|
||||
}, [loadUser, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[userlibraryaccess] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
loadData();
|
||||
|
||||
const onSubmit = (e: Event) => {
|
||||
if (!userId) {
|
||||
console.error('[userlibraryaccess] missing user id');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.show();
|
||||
window.ApiClient.getUser(userId).then(function (result) {
|
||||
saveUser(result);
|
||||
}).catch(err => {
|
||||
console.error('[userlibraryaccess] failed to fetch user', err);
|
||||
});
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
};
|
||||
|
||||
const saveUser = (user: UserDto) => {
|
||||
if (!user.Id) {
|
||||
throw new Error('Unexpected null user.Id');
|
||||
}
|
||||
|
||||
if (!user.Policy) {
|
||||
throw new Error('Unexpected null user.Policy');
|
||||
}
|
||||
|
||||
user.Policy.EnableAllFolders = (page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked;
|
||||
user.Policy.EnabledFolders = user.Policy.EnableAllFolders ? [] : Array.prototype.filter.call(page.querySelectorAll('.chkFolder'), function (c) {
|
||||
return c.checked;
|
||||
}).map(function (c) {
|
||||
return c.getAttribute('data-id');
|
||||
});
|
||||
user.Policy.EnableAllChannels = (page.querySelector('.chkEnableAllChannels') as HTMLInputElement).checked;
|
||||
user.Policy.EnabledChannels = user.Policy.EnableAllChannels ? [] : Array.prototype.filter.call(page.querySelectorAll('.chkChannel'), function (c) {
|
||||
return c.checked;
|
||||
}).map(function (c) {
|
||||
return c.getAttribute('data-id');
|
||||
});
|
||||
user.Policy.EnableAllDevices = (page.querySelector('.chkEnableAllDevices') as HTMLInputElement).checked;
|
||||
user.Policy.EnabledDevices = user.Policy.EnableAllDevices ? [] : Array.prototype.filter.call(page.querySelectorAll('.chkDevice'), function (c) {
|
||||
return c.checked;
|
||||
}).map(function (c) {
|
||||
return c.getAttribute('data-id');
|
||||
});
|
||||
user.Policy.BlockedChannels = null;
|
||||
user.Policy.BlockedMediaFolders = null;
|
||||
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
|
||||
onSaveComplete();
|
||||
}).catch(err => {
|
||||
console.error('[userlibraryaccess] failed to update user policy', err);
|
||||
});
|
||||
};
|
||||
|
||||
const onSaveComplete = () => {
|
||||
loading.hide();
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
};
|
||||
|
||||
(page.querySelector('.chkEnableAllDevices') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
||||
(page.querySelector('.deviceAccessListContainer') as HTMLDivElement).classList.toggle('hide', this.checked);
|
||||
});
|
||||
|
||||
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
||||
(page.querySelector('.channelAccessListContainer') as HTMLDivElement).classList.toggle('hide', this.checked);
|
||||
});
|
||||
|
||||
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
||||
(page.querySelector('.folderAccessListContainer') as HTMLDivElement).classList.toggle('hide', this.checked);
|
||||
});
|
||||
|
||||
(page.querySelector('.userLibraryAccessForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
}, [loadData]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='userLibraryAccessPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<div ref={element} className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={userName}
|
||||
url='https://jellyfin.org/docs/general/server/users/'
|
||||
/>
|
||||
</div>
|
||||
<SectionTabs activeTab='userlibraryaccess'/>
|
||||
<form className='userLibraryAccessForm'>
|
||||
<AccessContainer
|
||||
containerClassName='folderAccessContainer'
|
||||
headerTitle='HeaderLibraryAccess'
|
||||
checkBoxClassName='chkEnableAllFolders'
|
||||
checkBoxTitle='OptionEnableAccessToAllLibraries'
|
||||
listContainerClassName='folderAccessListContainer'
|
||||
accessClassName='folderAccess'
|
||||
listTitle='HeaderLibraries'
|
||||
description='LibraryAccessHelp'
|
||||
>
|
||||
{mediaFoldersItems.map(Item => (
|
||||
<CheckBoxElement
|
||||
key={Item.Id}
|
||||
className='chkFolder'
|
||||
itemId={Item.Id}
|
||||
itemName={Item.Name}
|
||||
itemCheckedAttribute={Item.checkedAttribute}
|
||||
/>
|
||||
))}
|
||||
</AccessContainer>
|
||||
|
||||
<AccessContainer
|
||||
containerClassName='channelAccessContainer hide'
|
||||
headerTitle='HeaderChannelAccess'
|
||||
checkBoxClassName='chkEnableAllChannels'
|
||||
checkBoxTitle='OptionEnableAccessToAllChannels'
|
||||
listContainerClassName='channelAccessListContainer'
|
||||
accessClassName='channelAccess'
|
||||
listTitle='Channels'
|
||||
description='ChannelAccessHelp'
|
||||
>
|
||||
{channelsItems.map(Item => (
|
||||
<CheckBoxElement
|
||||
key={Item.Id}
|
||||
className='chkChannel'
|
||||
itemId={Item.Id}
|
||||
itemName={Item.Name}
|
||||
itemCheckedAttribute={Item.checkedAttribute}
|
||||
/>
|
||||
))}
|
||||
</AccessContainer>
|
||||
|
||||
<AccessContainer
|
||||
containerClassName='deviceAccessContainer hide'
|
||||
headerTitle='HeaderDeviceAccess'
|
||||
checkBoxClassName='chkEnableAllDevices'
|
||||
checkBoxTitle='OptionEnableAccessFromAllDevices'
|
||||
listContainerClassName='deviceAccessListContainer'
|
||||
accessClassName='deviceAccess'
|
||||
listTitle='HeaderDevices'
|
||||
description='DeviceAccessHelp'
|
||||
>
|
||||
{devicesItems.map(Item => (
|
||||
<CheckBoxElement
|
||||
key={Item.Id}
|
||||
className='chkDevice'
|
||||
itemId={Item.Id}
|
||||
itemName={Item.Name}
|
||||
itemAppName={Item.AppName}
|
||||
itemCheckedAttribute={Item.checkedAttribute}
|
||||
/>
|
||||
))}
|
||||
</AccessContainer>
|
||||
<br />
|
||||
<div>
|
||||
<ButtonElement
|
||||
type='submit'
|
||||
className='raised button-submit block'
|
||||
title='Save'
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default UserLibraryAccess;
|
||||
@@ -1,270 +0,0 @@
|
||||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||
|
||||
import Dashboard from '../../../../utils/dashboard';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import toast from '../../../../components/toast/toast';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import InputElement from '../../../../elements/InputElement';
|
||||
import ButtonElement from '../../../../elements/ButtonElement';
|
||||
import AccessContainer from '../../../../components/dashboard/users/AccessContainer';
|
||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import Page from '../../../../components/Page';
|
||||
|
||||
type userInput = {
|
||||
Name?: string;
|
||||
Password?: string;
|
||||
};
|
||||
|
||||
type ItemsArr = {
|
||||
Name?: string | null;
|
||||
Id?: string;
|
||||
};
|
||||
|
||||
const UserNew = () => {
|
||||
const [ channelsItems, setChannelsItems ] = useState<ItemsArr[]>([]);
|
||||
const [ mediaFoldersItems, setMediaFoldersItems ] = useState<ItemsArr[]>([]);
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getItemsResult = (items: BaseItemDto[]) => {
|
||||
return items.map(item =>
|
||||
({
|
||||
Id: item.Id,
|
||||
Name: item.Name
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const loadMediaFolders = useCallback((result: BaseItemDto[]) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaFolders = getItemsResult(result);
|
||||
|
||||
setMediaFoldersItems(mediaFolders);
|
||||
|
||||
const folderAccess = page.querySelector('.folderAccess') as HTMLDivElement;
|
||||
folderAccess.dispatchEvent(new CustomEvent('create'));
|
||||
|
||||
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked = false;
|
||||
}, []);
|
||||
|
||||
const loadChannels = useCallback((result: BaseItemDto[]) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
|
||||
const channels = getItemsResult(result);
|
||||
|
||||
setChannelsItems(channels);
|
||||
|
||||
const channelAccess = page.querySelector('.channelAccess') as HTMLDivElement;
|
||||
channelAccess.dispatchEvent(new CustomEvent('create'));
|
||||
|
||||
const channelAccessContainer = page.querySelector('.channelAccessContainer') as HTMLDivElement;
|
||||
channels.length ? channelAccessContainer.classList.remove('hide') : channelAccessContainer.classList.add('hide');
|
||||
|
||||
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).checked = false;
|
||||
}, []);
|
||||
|
||||
const loadUser = useCallback(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
|
||||
(page.querySelector('#txtUsername') as HTMLInputElement).value = '';
|
||||
(page.querySelector('#txtPassword') as HTMLInputElement).value = '';
|
||||
loading.show();
|
||||
const promiseFolders = window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', {
|
||||
IsHidden: false
|
||||
}));
|
||||
const promiseChannels = window.ApiClient.getJSON(window.ApiClient.getUrl('Channels'));
|
||||
Promise.all([promiseFolders, promiseChannels]).then(function (responses) {
|
||||
loadMediaFolders(responses[0].Items);
|
||||
loadChannels(responses[1].Items);
|
||||
loading.hide();
|
||||
}).catch(err => {
|
||||
console.error('[usernew] failed to load data', err);
|
||||
});
|
||||
}, [loadChannels, loadMediaFolders]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
|
||||
loadUser();
|
||||
|
||||
const saveUser = () => {
|
||||
const userInput: userInput = {};
|
||||
userInput.Name = (page.querySelector('#txtUsername') as HTMLInputElement).value;
|
||||
userInput.Password = (page.querySelector('#txtPassword') as HTMLInputElement).value;
|
||||
window.ApiClient.createUser(userInput).then(function (user) {
|
||||
if (!user.Id || !user.Policy) {
|
||||
throw new Error('Unexpected null user id or policy');
|
||||
}
|
||||
|
||||
user.Policy.EnableAllFolders = (page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked;
|
||||
user.Policy.EnabledFolders = [];
|
||||
|
||||
if (!user.Policy.EnableAllFolders) {
|
||||
user.Policy.EnabledFolders = Array.prototype.filter.call(page.querySelectorAll('.chkFolder'), function (i) {
|
||||
return i.checked;
|
||||
}).map(function (i) {
|
||||
return i.getAttribute('data-id');
|
||||
});
|
||||
}
|
||||
|
||||
user.Policy.EnableAllChannels = (page.querySelector('.chkEnableAllChannels') as HTMLInputElement).checked;
|
||||
user.Policy.EnabledChannels = [];
|
||||
|
||||
if (!user.Policy.EnableAllChannels) {
|
||||
user.Policy.EnabledChannels = Array.prototype.filter.call(page.querySelectorAll('.chkChannel'), function (i) {
|
||||
return i.checked;
|
||||
}).map(function (i) {
|
||||
return i.getAttribute('data-id');
|
||||
});
|
||||
}
|
||||
|
||||
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
|
||||
Dashboard.navigate('/dashboard/users/profile?userId=' + user.Id)
|
||||
.catch(err => {
|
||||
console.error('[usernew] failed to navigate to edit user page', err);
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('[usernew] failed to update user policy', err);
|
||||
});
|
||||
}, function () {
|
||||
toast(globalize.translate('ErrorDefault'));
|
||||
loading.hide();
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = (e: Event) => {
|
||||
loading.show();
|
||||
saveUser();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
};
|
||||
|
||||
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
||||
const channelAccessListContainer = page.querySelector('.channelAccessListContainer') as HTMLDivElement;
|
||||
this.checked ? channelAccessListContainer.classList.add('hide') : channelAccessListContainer.classList.remove('hide');
|
||||
});
|
||||
|
||||
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
||||
const folderAccessListContainer = page.querySelector('.folderAccessListContainer') as HTMLDivElement;
|
||||
this.checked ? folderAccessListContainer.classList.add('hide') : folderAccessListContainer.classList.remove('hide');
|
||||
});
|
||||
|
||||
(page.querySelector('.newUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
|
||||
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', function() {
|
||||
window.history.back();
|
||||
});
|
||||
}, [loadUser]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='newUserPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<div ref={element} className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={globalize.translate('HeaderAddUser')}
|
||||
url='https://jellyfin.org/docs/general/server/users/'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form className='newUserProfileForm'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='text'
|
||||
id='txtUsername'
|
||||
label='LabelName'
|
||||
options={'required'}
|
||||
/>
|
||||
</div>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='password'
|
||||
id='txtPassword'
|
||||
label='LabelPassword'
|
||||
/>
|
||||
</div>
|
||||
<AccessContainer
|
||||
containerClassName='folderAccessContainer'
|
||||
headerTitle='HeaderLibraryAccess'
|
||||
checkBoxClassName='chkEnableAllFolders'
|
||||
checkBoxTitle='OptionEnableAccessToAllLibraries'
|
||||
listContainerClassName='folderAccessListContainer'
|
||||
accessClassName='folderAccess'
|
||||
listTitle='HeaderLibraries'
|
||||
description='LibraryAccessHelp'
|
||||
>
|
||||
{mediaFoldersItems.map(Item => (
|
||||
<CheckBoxElement
|
||||
key={Item.Id}
|
||||
className='chkFolder'
|
||||
itemId={Item.Id}
|
||||
itemName={Item.Name}
|
||||
/>
|
||||
))}
|
||||
</AccessContainer>
|
||||
|
||||
<AccessContainer
|
||||
containerClassName='channelAccessContainer verticalSection-extrabottompadding hide'
|
||||
headerTitle='HeaderChannelAccess'
|
||||
checkBoxClassName='chkEnableAllChannels'
|
||||
checkBoxTitle='OptionEnableAccessToAllChannels'
|
||||
listContainerClassName='channelAccessListContainer'
|
||||
accessClassName='channelAccess'
|
||||
listTitle='Channels'
|
||||
description='ChannelAccessHelp'
|
||||
>
|
||||
{channelsItems.map(Item => (
|
||||
<CheckBoxElement
|
||||
key={Item.Id}
|
||||
className='chkChannel'
|
||||
itemId={Item.Id}
|
||||
itemName={Item.Name}
|
||||
/>
|
||||
))}
|
||||
</AccessContainer>
|
||||
<div>
|
||||
<ButtonElement
|
||||
type='submit'
|
||||
className='raised button-submit block'
|
||||
title='Save'
|
||||
/>
|
||||
<ButtonElement
|
||||
type='button'
|
||||
id='btnCancel'
|
||||
className='raised button-cancel block'
|
||||
title='ButtonCancel'
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default UserNew;
|
||||
@@ -1,188 +0,0 @@
|
||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
|
||||
import Dashboard from '../../../../utils/dashboard';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import dom from '../../../../scripts/dom';
|
||||
import confirm from '../../../../components/confirm/confirm';
|
||||
import UserCardBox from '../../../../components/dashboard/users/UserCardBox';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import '../../../../elements/emby-button/emby-button';
|
||||
import '../../../../elements/emby-button/paper-icon-button-light';
|
||||
import '../../../../components/cardbuilder/card.scss';
|
||||
import '../../../../components/indicators/indicators.scss';
|
||||
import '../../../../styles/flexstyles.scss';
|
||||
import Page from '../../../../components/Page';
|
||||
|
||||
type MenuEntry = {
|
||||
name?: string;
|
||||
id?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
const UserProfiles = () => {
|
||||
const [ users, setUsers ] = useState<UserDto[]>([]);
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadData = () => {
|
||||
loading.show();
|
||||
window.ApiClient.getUsers().then(function (result) {
|
||||
setUsers(result);
|
||||
loading.hide();
|
||||
}).catch(err => {
|
||||
console.error('[userprofiles] failed to fetch users', err);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
|
||||
loadData();
|
||||
|
||||
const showUserMenu = (elem: HTMLElement) => {
|
||||
const card = dom.parentWithClass(elem, 'card');
|
||||
const userId = card?.getAttribute('data-userid');
|
||||
const username = card?.getAttribute('data-username');
|
||||
|
||||
if (!userId) {
|
||||
console.error('Unexpected null user id');
|
||||
return;
|
||||
}
|
||||
|
||||
const menuItems: MenuEntry[] = [];
|
||||
|
||||
menuItems.push({
|
||||
name: globalize.translate('ButtonEditUser'),
|
||||
id: 'open',
|
||||
icon: 'mode_edit'
|
||||
});
|
||||
menuItems.push({
|
||||
name: globalize.translate('ButtonLibraryAccess'),
|
||||
id: 'access',
|
||||
icon: 'lock'
|
||||
});
|
||||
menuItems.push({
|
||||
name: globalize.translate('ButtonParentalControl'),
|
||||
id: 'parentalcontrol',
|
||||
icon: 'person'
|
||||
});
|
||||
menuItems.push({
|
||||
name: globalize.translate('Delete'),
|
||||
id: 'delete',
|
||||
icon: 'delete'
|
||||
});
|
||||
|
||||
import('../../../../components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
|
||||
actionsheet.show({
|
||||
items: menuItems,
|
||||
positionTo: card,
|
||||
callback: function (id: string) {
|
||||
switch (id) {
|
||||
case 'open':
|
||||
Dashboard.navigate('/dashboard/users/profile?userId=' + userId)
|
||||
.catch(err => {
|
||||
console.error('[userprofiles] failed to navigate to user edit page', err);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'access':
|
||||
Dashboard.navigate('/dashboard/users/access?userId=' + userId)
|
||||
.catch(err => {
|
||||
console.error('[userprofiles] failed to navigate to user library page', err);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'parentalcontrol':
|
||||
Dashboard.navigate('/dashboard/users/parentalcontrol?userId=' + userId)
|
||||
.catch(err => {
|
||||
console.error('[userprofiles] failed to navigate to parental control page', err);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
deleteUser(userId, username);
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
// action sheet closed
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('[userprofiles] failed to load action sheet', err);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteUser = (id: string, username?: string | null) => {
|
||||
const title = username ? globalize.translate('DeleteName', username) : globalize.translate('DeleteUser');
|
||||
const text = globalize.translate('DeleteUserConfirmation');
|
||||
|
||||
confirm({
|
||||
title,
|
||||
text,
|
||||
confirmText: globalize.translate('Delete'),
|
||||
primary: 'delete'
|
||||
}).then(function () {
|
||||
loading.show();
|
||||
window.ApiClient.deleteUser(id).then(function () {
|
||||
loadData();
|
||||
}).catch(err => {
|
||||
console.error('[userprofiles] failed to delete user', err);
|
||||
});
|
||||
}).catch(() => {
|
||||
// confirm dialog closed
|
||||
});
|
||||
};
|
||||
|
||||
page.addEventListener('click', function (e) {
|
||||
const btnUserMenu = dom.parentWithClass(e.target as HTMLElement, 'btnUserMenu');
|
||||
|
||||
if (btnUserMenu) {
|
||||
showUserMenu(btnUserMenu);
|
||||
}
|
||||
});
|
||||
|
||||
(page.querySelector('#btnAddUser') as HTMLButtonElement).addEventListener('click', function() {
|
||||
Dashboard.navigate('/dashboard/users/add')
|
||||
.catch(err => {
|
||||
console.error('[userprofiles] failed to navigate to new user page', err);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='userProfilesPage'
|
||||
className='mainAnimatedPage type-interior userProfilesPage fullWidthContent'
|
||||
title={globalize.translate('HeaderUsers')}
|
||||
>
|
||||
<div ref={element} className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={globalize.translate('HeaderUsers')}
|
||||
isBtnVisible={true}
|
||||
btnId='btnAddUser'
|
||||
btnClassName='fab submit sectionTitleButton'
|
||||
btnTitle='ButtonAddUser'
|
||||
btnIcon='add'
|
||||
url='https://jellyfin.org/docs/general/server/users/adding-managing-users'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='localUsers itemsContainer vertical-wrap'>
|
||||
{users.map(user => {
|
||||
return <UserCardBox key={user.Id} user={user} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfiles;
|
||||
@@ -1,532 +0,0 @@
|
||||
import type { AccessSchedule, ParentalRating, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import { UnratedItem } from '@jellyfin/sdk/lib/generated-client/models/unrated-item';
|
||||
import { DynamicDayOfWeek } from '@jellyfin/sdk/lib/generated-client/models/dynamic-day-of-week';
|
||||
import escapeHTML from 'escape-html';
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import LibraryMenu from '../../../../scripts/libraryMenu';
|
||||
import AccessScheduleList from '../../../../components/dashboard/users/AccessScheduleList';
|
||||
import TagList from '../../../../components/dashboard/users/TagList';
|
||||
import ButtonElement from '../../../../elements/ButtonElement';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import toast from '../../../../components/toast/toast';
|
||||
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';
|
||||
|
||||
type NamedItem = {
|
||||
name: string;
|
||||
value: UnratedItem;
|
||||
};
|
||||
|
||||
type UnratedNamedItem = NamedItem & {
|
||||
checkedAttribute: string
|
||||
};
|
||||
|
||||
function handleSaveUser(
|
||||
page: HTMLDivElement,
|
||||
getSchedulesFromPage: () => AccessSchedule[],
|
||||
getAllowedTagsFromPage: () => string[],
|
||||
getBlockedTagsFromPage: () => string[],
|
||||
onSaveComplete: () => void
|
||||
) {
|
||||
return (user: UserDto) => {
|
||||
const userId = user.Id;
|
||||
const userPolicy = user.Policy;
|
||||
if (!userId || !userPolicy) {
|
||||
throw new Error('Unexpected null user id or policy');
|
||||
}
|
||||
|
||||
const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
|
||||
userPolicy.MaxParentalRating = Number.isNaN(parentalRating) ? null : parentalRating;
|
||||
userPolicy.BlockUnratedItems = Array.prototype.filter
|
||||
.call(page.querySelectorAll('.chkUnratedItem'), i => i.checked)
|
||||
.map(i => i.getAttribute('data-itemtype'));
|
||||
userPolicy.AccessSchedules = getSchedulesFromPage();
|
||||
userPolicy.AllowedTags = getAllowedTagsFromPage();
|
||||
userPolicy.BlockedTags = getBlockedTagsFromPage();
|
||||
ServerConnections.getCurrentApiClientAsync()
|
||||
.then(apiClient => apiClient.updateUserPolicy(userId, userPolicy))
|
||||
.then(() => onSaveComplete())
|
||||
.catch(err => {
|
||||
console.error('[userparentalcontrol] failed to update user policy', err);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const UserParentalControl = () => {
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const [ userName, setUserName ] = useState('');
|
||||
const [ parentalRatings, setParentalRatings ] = useState<ParentalRating[]>([]);
|
||||
const [ unratedItems, setUnratedItems ] = useState<UnratedNamedItem[]>([]);
|
||||
const [ accessSchedules, setAccessSchedules ] = useState<AccessSchedule[]>([]);
|
||||
const [ allowedTags, setAllowedTags ] = useState<string[]>([]);
|
||||
const [ blockedTags, setBlockedTags ] = useState<string[]>([]);
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
const populateRatings = useCallback((allParentalRatings: ParentalRating[]) => {
|
||||
let rating;
|
||||
const ratings: ParentalRating[] = [];
|
||||
|
||||
for (let i = 0, length = allParentalRatings.length; i < length; i++) {
|
||||
rating = allParentalRatings[i];
|
||||
|
||||
if (ratings.length) {
|
||||
const lastRating = ratings[ratings.length - 1];
|
||||
|
||||
if (lastRating.Value === rating.Value) {
|
||||
lastRating.Name += '/' + rating.Name;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
ratings.push({
|
||||
Name: rating.Name,
|
||||
Value: rating.Value
|
||||
});
|
||||
}
|
||||
|
||||
setParentalRatings(ratings);
|
||||
}, []);
|
||||
|
||||
const loadUnratedItems = useCallback((user: UserDto) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[userparentalcontrol] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
const items: NamedItem[] = [{
|
||||
name: globalize.translate('Books'),
|
||||
value: UnratedItem.Book
|
||||
}, {
|
||||
name: globalize.translate('Channels'),
|
||||
value: UnratedItem.ChannelContent
|
||||
}, {
|
||||
name: globalize.translate('LiveTV'),
|
||||
value: UnratedItem.LiveTvChannel
|
||||
}, {
|
||||
name: globalize.translate('Movies'),
|
||||
value: UnratedItem.Movie
|
||||
}, {
|
||||
name: globalize.translate('Music'),
|
||||
value: UnratedItem.Music
|
||||
}, {
|
||||
name: globalize.translate('Trailers'),
|
||||
value: UnratedItem.Trailer
|
||||
}, {
|
||||
name: globalize.translate('Shows'),
|
||||
value: UnratedItem.Series
|
||||
}];
|
||||
|
||||
const unratedNamedItem: UnratedNamedItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const isChecked = user.Policy?.BlockUnratedItems?.indexOf(item.value) != -1;
|
||||
const checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
unratedNamedItem.push({
|
||||
value: item.value,
|
||||
name: item.name,
|
||||
checkedAttribute: checkedAttribute
|
||||
});
|
||||
}
|
||||
|
||||
setUnratedItems(unratedNamedItem);
|
||||
|
||||
const blockUnratedItems = page.querySelector('.blockUnratedItems') as HTMLDivElement;
|
||||
blockUnratedItems.dispatchEvent(new CustomEvent('create'));
|
||||
}, []);
|
||||
|
||||
const loadAllowedTags = useCallback((tags: string[]) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[userparentalcontrol] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
setAllowedTags(tags);
|
||||
|
||||
const allowedTagsElem = page.querySelector('.allowedTags') as HTMLDivElement;
|
||||
|
||||
for (const btnDeleteTag of allowedTagsElem.querySelectorAll('.btnDeleteTag')) {
|
||||
btnDeleteTag.addEventListener('click', function () {
|
||||
const tag = btnDeleteTag.getAttribute('data-tag');
|
||||
const newTags = tags.filter(t => t !== tag);
|
||||
loadAllowedTags(newTags);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadBlockedTags = useCallback((tags: string[]) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[userparentalcontrol] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
setBlockedTags(tags);
|
||||
|
||||
const blockedTagsElem = page.querySelector('.blockedTags') as HTMLDivElement;
|
||||
|
||||
for (const btnDeleteTag of blockedTagsElem.querySelectorAll('.btnDeleteTag')) {
|
||||
btnDeleteTag.addEventListener('click', function () {
|
||||
const tag = btnDeleteTag.getAttribute('data-tag');
|
||||
const newTags = tags.filter(t => t !== tag);
|
||||
loadBlockedTags(newTags);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderAccessSchedule = useCallback((schedules: AccessSchedule[]) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[userparentalcontrol] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
setAccessSchedules(schedules);
|
||||
|
||||
const accessScheduleList = page.querySelector('.accessScheduleList') as HTMLDivElement;
|
||||
|
||||
for (const btnDelete of accessScheduleList.querySelectorAll('.btnDelete')) {
|
||||
btnDelete.addEventListener('click', function () {
|
||||
const index = parseInt(btnDelete.getAttribute('data-index') ?? '0', 10);
|
||||
schedules.splice(index, 1);
|
||||
const newindex = schedules.filter((_, i) => i != index);
|
||||
renderAccessSchedule(newindex);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUser = useCallback((user: UserDto, allParentalRatings: ParentalRating[]) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[userparentalcontrol] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
setUserName(user.Name || '');
|
||||
LibraryMenu.setTitle(user.Name);
|
||||
loadUnratedItems(user);
|
||||
|
||||
loadAllowedTags(user.Policy?.AllowedTags || []);
|
||||
loadBlockedTags(user.Policy?.BlockedTags || []);
|
||||
populateRatings(allParentalRatings);
|
||||
|
||||
let ratingValue = '';
|
||||
if (user.Policy?.MaxParentalRating) {
|
||||
allParentalRatings.forEach(rating => {
|
||||
if (rating.Value && user.Policy?.MaxParentalRating && user.Policy.MaxParentalRating >= rating.Value) {
|
||||
ratingValue = `${rating.Value}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
(page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value = String(ratingValue);
|
||||
|
||||
if (user.Policy?.IsAdministrator) {
|
||||
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.add('hide');
|
||||
} else {
|
||||
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.remove('hide');
|
||||
}
|
||||
renderAccessSchedule(user.Policy?.AccessSchedules || []);
|
||||
loading.hide();
|
||||
}, [loadAllowedTags, loadBlockedTags, loadUnratedItems, populateRatings, renderAccessSchedule]);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
if (!userId) {
|
||||
console.error('[userparentalcontrol.loadData] missing user id');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.show();
|
||||
const promise1 = window.ApiClient.getUser(userId);
|
||||
const promise2 = window.ApiClient.getParentalRatings();
|
||||
Promise.all([promise1, promise2]).then(function (responses) {
|
||||
loadUser(responses[0], responses[1]);
|
||||
}).catch(err => {
|
||||
console.error('[userparentalcontrol] failed to load data', err);
|
||||
});
|
||||
}, [loadUser, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[userparentalcontrol] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
loadData();
|
||||
|
||||
const showSchedulePopup = (schedule: AccessSchedule, index: number) => {
|
||||
schedule = schedule || {};
|
||||
import('../../../../components/accessSchedule/accessSchedule').then(({ default: accessschedule }) => {
|
||||
accessschedule.show({
|
||||
schedule: schedule
|
||||
}).then(function (updatedSchedule) {
|
||||
const schedules = getSchedulesFromPage();
|
||||
|
||||
if (index == -1) {
|
||||
index = schedules.length;
|
||||
}
|
||||
|
||||
schedules[index] = updatedSchedule;
|
||||
renderAccessSchedule(schedules);
|
||||
}).catch(() => {
|
||||
// access schedule closed
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('[userparentalcontrol] failed to load access schedule', err);
|
||||
});
|
||||
};
|
||||
|
||||
const getSchedulesFromPage = () => {
|
||||
return Array.prototype.map.call(page.querySelectorAll('.liSchedule'), function (elem) {
|
||||
return {
|
||||
DayOfWeek: elem.getAttribute('data-day'),
|
||||
StartHour: elem.getAttribute('data-start'),
|
||||
EndHour: elem.getAttribute('data-end')
|
||||
};
|
||||
}) as AccessSchedule[];
|
||||
};
|
||||
|
||||
const getAllowedTagsFromPage = () => {
|
||||
return Array.prototype.map.call(page.querySelectorAll('.allowedTag'), function (elem) {
|
||||
return elem.getAttribute('data-tag');
|
||||
}) as string[];
|
||||
};
|
||||
|
||||
const showAllowedTagPopup = () => {
|
||||
prompt({
|
||||
label: globalize.translate('LabelTag')
|
||||
}).then(function (value) {
|
||||
const tags = getAllowedTagsFromPage();
|
||||
|
||||
if (tags.indexOf(value) == -1) {
|
||||
tags.push(value);
|
||||
loadAllowedTags(tags);
|
||||
}
|
||||
}).catch(() => {
|
||||
// prompt closed
|
||||
});
|
||||
};
|
||||
|
||||
const getBlockedTagsFromPage = () => {
|
||||
return Array.prototype.map.call(page.querySelectorAll('.blockedTag'), function (elem) {
|
||||
return elem.getAttribute('data-tag');
|
||||
}) as string[];
|
||||
};
|
||||
|
||||
const showBlockedTagPopup = () => {
|
||||
prompt({
|
||||
label: globalize.translate('LabelTag')
|
||||
}).then(function (value) {
|
||||
const tags = getBlockedTagsFromPage();
|
||||
|
||||
if (tags.indexOf(value) == -1) {
|
||||
tags.push(value);
|
||||
loadBlockedTags(tags);
|
||||
}
|
||||
}).catch(() => {
|
||||
// prompt closed
|
||||
});
|
||||
};
|
||||
|
||||
const onSaveComplete = () => {
|
||||
loading.hide();
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
};
|
||||
|
||||
const saveUser = handleSaveUser(page, getSchedulesFromPage, getAllowedTagsFromPage, getBlockedTagsFromPage, onSaveComplete);
|
||||
|
||||
const onSubmit = (e: Event) => {
|
||||
if (!userId) {
|
||||
console.error('[userparentalcontrol.onSubmit] missing user id');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.show();
|
||||
window.ApiClient.getUser(userId).then(function (result) {
|
||||
saveUser(result);
|
||||
}).catch(err => {
|
||||
console.error('[userparentalcontrol] failed to fetch user', err);
|
||||
});
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
};
|
||||
|
||||
(page.querySelector('#btnAddSchedule') as HTMLButtonElement).addEventListener('click', function () {
|
||||
showSchedulePopup({
|
||||
Id: 0,
|
||||
UserId: '',
|
||||
DayOfWeek: DynamicDayOfWeek.Sunday,
|
||||
StartHour: 0,
|
||||
EndHour: 0
|
||||
}, -1);
|
||||
});
|
||||
|
||||
(page.querySelector('#btnAddAllowedTag') as HTMLButtonElement).addEventListener('click', function () {
|
||||
showAllowedTagPopup();
|
||||
});
|
||||
|
||||
(page.querySelector('#btnAddBlockedTag') as HTMLButtonElement).addEventListener('click', function () {
|
||||
showBlockedTagPopup();
|
||||
});
|
||||
|
||||
(page.querySelector('.userParentalControlForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
}, [loadAllowedTags, loadBlockedTags, loadData, renderAccessSchedule]);
|
||||
|
||||
const optionMaxParentalRating = () => {
|
||||
let content = '';
|
||||
content += '<option value=\'\'></option>';
|
||||
for (const rating of parentalRatings) {
|
||||
content += `<option value='${rating.Value}'>${escapeHTML(rating.Name)}</option>`;
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='userParentalControlPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<div ref={element} className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={userName}
|
||||
url='https://jellyfin.org/docs/general/server/users/'
|
||||
/>
|
||||
</div>
|
||||
<SectionTabs activeTab='userparentalcontrol'/>
|
||||
<form className='userParentalControlForm'>
|
||||
<div className='selectContainer'>
|
||||
<SelectElement
|
||||
id='selectMaxParentalRating'
|
||||
label='LabelMaxParentalRating'
|
||||
>
|
||||
{optionMaxParentalRating()}
|
||||
</SelectElement>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('MaxParentalRatingHelp')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className='blockUnratedItems'>
|
||||
<h3 className='checkboxListLabel'>
|
||||
{globalize.translate('HeaderBlockItemsWithNoRating')}
|
||||
</h3>
|
||||
<div className='checkboxList paperList' style={{ padding: '.5em 1em' }}>
|
||||
{unratedItems.map(Item => {
|
||||
return <CheckBoxElement
|
||||
key={Item.value}
|
||||
className='chkUnratedItem'
|
||||
itemType={Item.value}
|
||||
itemName={Item.name}
|
||||
itemCheckedAttribute={Item.checkedAttribute}
|
||||
/>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div className='verticalSection' style={{ marginBottom: '2em' }}>
|
||||
<SectionTitleContainer
|
||||
SectionClassName='detailSectionHeader'
|
||||
title={globalize.translate('LabelAllowContentWithTags')}
|
||||
isBtnVisible={true}
|
||||
btnId='btnAddAllowedTag'
|
||||
btnClassName='fab submit sectionTitleButton'
|
||||
btnTitle='Add'
|
||||
btnIcon='add'
|
||||
isLinkVisible={false}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('AllowContentWithTagsHelp')}
|
||||
</div>
|
||||
<div className='allowedTags' style={{ marginTop: '.5em' }}>
|
||||
{allowedTags?.map(tag => {
|
||||
return <TagList
|
||||
key={tag}
|
||||
tag={tag}
|
||||
tagType='allowedTag'
|
||||
/>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className='verticalSection' style={{ marginBottom: '2em' }}>
|
||||
<SectionTitleContainer
|
||||
SectionClassName='detailSectionHeader'
|
||||
title={globalize.translate('LabelBlockContentWithTags')}
|
||||
isBtnVisible={true}
|
||||
btnId='btnAddBlockedTag'
|
||||
btnClassName='fab submit sectionTitleButton'
|
||||
btnTitle='Add'
|
||||
btnIcon='add'
|
||||
isLinkVisible={false}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('BlockContentWithTagsHelp')}
|
||||
</div>
|
||||
<div className='blockedTags' style={{ marginTop: '.5em' }}>
|
||||
{blockedTags.map(tag => {
|
||||
return <TagList
|
||||
key={tag}
|
||||
tag={tag}
|
||||
tagType='blockedTag'
|
||||
/>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className='accessScheduleSection verticalSection' style={{ marginBottom: '2em' }}>
|
||||
<SectionTitleContainer
|
||||
title={globalize.translate('HeaderAccessSchedule')}
|
||||
isBtnVisible={true}
|
||||
btnId='btnAddSchedule'
|
||||
btnClassName='fab submit sectionTitleButton'
|
||||
btnTitle='Add'
|
||||
btnIcon='add'
|
||||
isLinkVisible={false}
|
||||
/>
|
||||
<p>{globalize.translate('HeaderAccessScheduleHelp')}</p>
|
||||
<div className='accessScheduleList paperList'>
|
||||
{accessSchedules.map((accessSchedule, index) => {
|
||||
return <AccessScheduleList
|
||||
key={accessSchedule.Id}
|
||||
index={index}
|
||||
DayOfWeek={accessSchedule.DayOfWeek}
|
||||
StartHour={accessSchedule.StartHour}
|
||||
EndHour={accessSchedule.EndHour}
|
||||
/>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ButtonElement
|
||||
type='submit'
|
||||
className='raised button-submit block'
|
||||
title='Save'
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default UserParentalControl;
|
||||
@@ -1,60 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||
import UserPasswordForm from '../../../../components/dashboard/users/UserPasswordForm';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import Page from '../../../../components/Page';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
|
||||
const UserPassword = () => {
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const [ userName, setUserName ] = useState('');
|
||||
|
||||
const loadUser = useCallback(() => {
|
||||
if (!userId) {
|
||||
console.error('[userpassword] missing user id');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.show();
|
||||
window.ApiClient.getUser(userId).then(function (user) {
|
||||
if (!user.Name) {
|
||||
throw new Error('Unexpected null user.Name');
|
||||
}
|
||||
setUserName(user.Name);
|
||||
loading.hide();
|
||||
}).catch(err => {
|
||||
console.error('[userpassword] failed to fetch user', err);
|
||||
});
|
||||
}, [userId]);
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
}, [loadUser]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='userPasswordPage'
|
||||
className='mainAnimatedPage type-interior userPasswordPage'
|
||||
>
|
||||
<div className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={userName}
|
||||
url='https://jellyfin.org/docs/general/server/users/'
|
||||
/>
|
||||
</div>
|
||||
<SectionTabs activeTab='userpassword'/>
|
||||
<div className='readOnlyContent'>
|
||||
<UserPasswordForm
|
||||
userId={userId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default UserPassword;
|
||||
@@ -1,577 +0,0 @@
|
||||
import type { BaseItemDto, NameIdPair, SyncPlayUserAccessType, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import escapeHTML from 'escape-html';
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import Dashboard from '../../../../utils/dashboard';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import LibraryMenu from '../../../../scripts/libraryMenu';
|
||||
import ButtonElement from '../../../../elements/ButtonElement';
|
||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import InputElement from '../../../../elements/InputElement';
|
||||
import LinkEditUserPreferences from '../../../../components/dashboard/users/LinkEditUserPreferences';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import toast from '../../../../components/toast/toast';
|
||||
import SelectElement from '../../../../elements/SelectElement';
|
||||
import Page from '../../../../components/Page';
|
||||
|
||||
type ResetProvider = BaseItemDto & {
|
||||
checkedAttribute: string
|
||||
};
|
||||
|
||||
const getCheckedElementDataIds = (elements: NodeListOf<Element>) => (
|
||||
Array.prototype.filter.call(elements, e => e.checked)
|
||||
.map(e => e.getAttribute('data-id'))
|
||||
);
|
||||
|
||||
function onSaveComplete() {
|
||||
Dashboard.navigate('/dashboard/users')
|
||||
.catch(err => {
|
||||
console.error('[useredit] failed to navigate to user profile', err);
|
||||
});
|
||||
loading.hide();
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
}
|
||||
|
||||
const UserEdit = () => {
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const [ userName, setUserName ] = useState('');
|
||||
const [ deleteFoldersAccess, setDeleteFoldersAccess ] = useState<ResetProvider[]>([]);
|
||||
const [ authProviders, setAuthProviders ] = useState<NameIdPair[]>([]);
|
||||
const [ passwordResetProviders, setPasswordResetProviders ] = useState<NameIdPair[]>([]);
|
||||
|
||||
const [ authenticationProviderId, setAuthenticationProviderId ] = useState('');
|
||||
const [ passwordResetProviderId, setPasswordResetProviderId ] = useState('');
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
const triggerChange = (select: HTMLInputElement) => {
|
||||
const evt = new Event('change', { bubbles: false, cancelable: true });
|
||||
select.dispatchEvent(evt);
|
||||
};
|
||||
|
||||
const getUser = () => {
|
||||
if (!userId) throw new Error('missing user id');
|
||||
return window.ApiClient.getUser(userId);
|
||||
};
|
||||
|
||||
const loadAuthProviders = useCallback((page: HTMLDivElement, user: UserDto, providers: NameIdPair[]) => {
|
||||
const fldSelectLoginProvider = page.querySelector('.fldSelectLoginProvider') as HTMLDivElement;
|
||||
fldSelectLoginProvider.classList.toggle('hide', providers.length <= 1);
|
||||
|
||||
setAuthProviders(providers);
|
||||
|
||||
const currentProviderId = user.Policy?.AuthenticationProviderId || '';
|
||||
setAuthenticationProviderId(currentProviderId);
|
||||
}, []);
|
||||
|
||||
const loadPasswordResetProviders = useCallback((page: HTMLDivElement, user: UserDto, providers: NameIdPair[]) => {
|
||||
const fldSelectPasswordResetProvider = page.querySelector('.fldSelectPasswordResetProvider') as HTMLDivElement;
|
||||
fldSelectPasswordResetProvider.classList.toggle('hide', providers.length <= 1);
|
||||
|
||||
setPasswordResetProviders(providers);
|
||||
|
||||
const currentProviderId = user.Policy?.PasswordResetProviderId || '';
|
||||
setPasswordResetProviderId(currentProviderId);
|
||||
}, []);
|
||||
|
||||
const loadDeleteFolders = useCallback((page: HTMLDivElement, user: UserDto, mediaFolders: BaseItemDto[]) => {
|
||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Channels', {
|
||||
SupportsMediaDeletion: true
|
||||
})).then(function (channelsResult) {
|
||||
let isChecked;
|
||||
let checkedAttribute;
|
||||
const itemsArr: ResetProvider[] = [];
|
||||
|
||||
for (const mediaFolder of mediaFolders) {
|
||||
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(mediaFolder.Id || '') != -1;
|
||||
checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
itemsArr.push({
|
||||
...mediaFolder,
|
||||
checkedAttribute: checkedAttribute
|
||||
});
|
||||
}
|
||||
|
||||
for (const channel of channelsResult.Items) {
|
||||
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(channel.Id || '') != -1;
|
||||
checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
itemsArr.push({
|
||||
...channel,
|
||||
checkedAttribute: checkedAttribute
|
||||
});
|
||||
}
|
||||
|
||||
setDeleteFoldersAccess(itemsArr);
|
||||
|
||||
const chkEnableDeleteAllFolders = page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement;
|
||||
chkEnableDeleteAllFolders.checked = user.Policy?.EnableContentDeletion || false;
|
||||
triggerChange(chkEnableDeleteAllFolders);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch channels', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadUser = useCallback((user: UserDto) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[useredit] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/Providers')).then(function (providers) {
|
||||
loadAuthProviders(page, user, providers);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch auth providers', err);
|
||||
});
|
||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/PasswordResetProviders')).then(function (providers) {
|
||||
loadPasswordResetProviders(page, user, providers);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch password reset providers', err);
|
||||
});
|
||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', {
|
||||
IsHidden: false
|
||||
})).then(function (folders) {
|
||||
loadDeleteFolders(page, user, folders.Items);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch media folders', err);
|
||||
});
|
||||
|
||||
const disabledUserBanner = page.querySelector('.disabledUserBanner') as HTMLDivElement;
|
||||
disabledUserBanner.classList.toggle('hide', !user.Policy?.IsDisabled);
|
||||
|
||||
const txtUserName = page.querySelector('#txtUserName') as HTMLInputElement;
|
||||
txtUserName.disabled = false;
|
||||
txtUserName.removeAttribute('disabled');
|
||||
|
||||
const lnkEditUserPreferences = page.querySelector('.lnkEditUserPreferences') as HTMLDivElement;
|
||||
lnkEditUserPreferences.setAttribute('href', 'mypreferencesmenu.html?userId=' + user.Id);
|
||||
LibraryMenu.setTitle(user.Name);
|
||||
setUserName(user.Name || '');
|
||||
(page.querySelector('#txtUserName') as HTMLInputElement).value = user.Name || '';
|
||||
(page.querySelector('.chkIsAdmin') as HTMLInputElement).checked = !!user.Policy?.IsAdministrator;
|
||||
(page.querySelector('.chkDisabled') as HTMLInputElement).checked = !!user.Policy?.IsDisabled;
|
||||
(page.querySelector('.chkIsHidden') as HTMLInputElement).checked = !!user.Policy?.IsHidden;
|
||||
(page.querySelector('.chkEnableCollectionManagement') as HTMLInputElement).checked = !!user.Policy?.EnableCollectionManagement;
|
||||
(page.querySelector('.chkEnableSubtitleManagement') as HTMLInputElement).checked = !!user.Policy?.EnableSubtitleManagement;
|
||||
(page.querySelector('.chkRemoteControlSharedDevices') as HTMLInputElement).checked = !!user.Policy?.EnableSharedDeviceControl;
|
||||
(page.querySelector('.chkEnableRemoteControlOtherUsers') as HTMLInputElement).checked = !!user.Policy?.EnableRemoteControlOfOtherUsers;
|
||||
(page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked = !!user.Policy?.EnableContentDownloading;
|
||||
(page.querySelector('.chkManageLiveTv') as HTMLInputElement).checked = !!user.Policy?.EnableLiveTvManagement;
|
||||
(page.querySelector('.chkEnableLiveTvAccess') as HTMLInputElement).checked = !!user.Policy?.EnableLiveTvAccess;
|
||||
(page.querySelector('.chkEnableMediaPlayback') as HTMLInputElement).checked = !!user.Policy?.EnableMediaPlayback;
|
||||
(page.querySelector('.chkEnableAudioPlaybackTranscoding') as HTMLInputElement).checked = !!user.Policy?.EnableAudioPlaybackTranscoding;
|
||||
(page.querySelector('.chkEnableVideoPlaybackTranscoding') as HTMLInputElement).checked = !!user.Policy?.EnableVideoPlaybackTranscoding;
|
||||
(page.querySelector('.chkEnableVideoPlaybackRemuxing') as HTMLInputElement).checked = !!user.Policy?.EnablePlaybackRemuxing;
|
||||
(page.querySelector('.chkForceRemoteSourceTranscoding') as HTMLInputElement).checked = !!user.Policy?.ForceRemoteSourceTranscoding;
|
||||
(page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked = user.Policy?.EnableRemoteAccess == null || user.Policy?.EnableRemoteAccess;
|
||||
(page.querySelector('#txtRemoteClientBitrateLimit') as HTMLInputElement).value = user.Policy?.RemoteClientBitrateLimit && user.Policy?.RemoteClientBitrateLimit > 0 ?
|
||||
(user.Policy?.RemoteClientBitrateLimit / 1e6).toLocaleString(undefined, { maximumFractionDigits: 6 }) : '';
|
||||
(page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value = String(user.Policy?.MaxActiveSessions) || '0';
|
||||
(page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value = String(user.Policy?.SyncPlayAccess) || '0';
|
||||
if (window.ApiClient.isMinServerVersion('10.6.0')) {
|
||||
(page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value = String(user.Policy?.SyncPlayAccess);
|
||||
}
|
||||
loading.hide();
|
||||
}, [loadAuthProviders, loadPasswordResetProviders, loadDeleteFolders ]);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
loading.show();
|
||||
getUser().then(function (user) {
|
||||
loadUser(user);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to load data', err);
|
||||
});
|
||||
}, [loadUser]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[useredit] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
loadData();
|
||||
|
||||
const saveUser = (user: UserDto) => {
|
||||
if (!user.Id || !user.Policy) {
|
||||
throw new Error('Unexpected null user id or policy');
|
||||
}
|
||||
|
||||
user.Name = (page.querySelector('#txtUserName') as HTMLInputElement).value;
|
||||
user.Policy.IsAdministrator = (page.querySelector('.chkIsAdmin') as HTMLInputElement).checked;
|
||||
user.Policy.IsHidden = (page.querySelector('.chkIsHidden') as HTMLInputElement).checked;
|
||||
user.Policy.IsDisabled = (page.querySelector('.chkDisabled') as HTMLInputElement).checked;
|
||||
user.Policy.EnableRemoteControlOfOtherUsers = (page.querySelector('.chkEnableRemoteControlOtherUsers') as HTMLInputElement).checked;
|
||||
user.Policy.EnableLiveTvManagement = (page.querySelector('.chkManageLiveTv') as HTMLInputElement).checked;
|
||||
user.Policy.EnableLiveTvAccess = (page.querySelector('.chkEnableLiveTvAccess') as HTMLInputElement).checked;
|
||||
user.Policy.EnableSharedDeviceControl = (page.querySelector('.chkRemoteControlSharedDevices') as HTMLInputElement).checked;
|
||||
user.Policy.EnableMediaPlayback = (page.querySelector('.chkEnableMediaPlayback') as HTMLInputElement).checked;
|
||||
user.Policy.EnableAudioPlaybackTranscoding = (page.querySelector('.chkEnableAudioPlaybackTranscoding') as HTMLInputElement).checked;
|
||||
user.Policy.EnableVideoPlaybackTranscoding = (page.querySelector('.chkEnableVideoPlaybackTranscoding') as HTMLInputElement).checked;
|
||||
user.Policy.EnablePlaybackRemuxing = (page.querySelector('.chkEnableVideoPlaybackRemuxing') as HTMLInputElement).checked;
|
||||
user.Policy.EnableCollectionManagement = (page.querySelector('.chkEnableCollectionManagement') as HTMLInputElement).checked;
|
||||
user.Policy.EnableSubtitleManagement = (page.querySelector('.chkEnableSubtitleManagement') as HTMLInputElement).checked;
|
||||
user.Policy.ForceRemoteSourceTranscoding = (page.querySelector('.chkForceRemoteSourceTranscoding') as HTMLInputElement).checked;
|
||||
user.Policy.EnableContentDownloading = (page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked;
|
||||
user.Policy.EnableRemoteAccess = (page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked;
|
||||
user.Policy.RemoteClientBitrateLimit = Math.floor(1e6 * parseFloat((page.querySelector('#txtRemoteClientBitrateLimit') as HTMLInputElement).value || '0'));
|
||||
user.Policy.LoginAttemptsBeforeLockout = parseInt((page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value || '0', 10);
|
||||
user.Policy.MaxActiveSessions = parseInt((page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value || '0', 10);
|
||||
user.Policy.AuthenticationProviderId = (page.querySelector('#selectLoginProvider') as HTMLSelectElement).value;
|
||||
user.Policy.PasswordResetProviderId = (page.querySelector('#selectPasswordResetProvider') as HTMLSelectElement).value;
|
||||
user.Policy.EnableContentDeletion = (page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).checked;
|
||||
user.Policy.EnableContentDeletionFromFolders = user.Policy.EnableContentDeletion ? [] : getCheckedElementDataIds(page.querySelectorAll('.chkFolder'));
|
||||
user.Policy.SyncPlayAccess = (page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value as SyncPlayUserAccessType;
|
||||
|
||||
window.ApiClient.updateUser(user).then(() => (
|
||||
window.ApiClient.updateUserPolicy(user.Id || '', user.Policy || { PasswordResetProviderId: '', AuthenticationProviderId: '' })
|
||||
)).then(() => {
|
||||
onSaveComplete();
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to update user', err);
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = (e: Event) => {
|
||||
loading.show();
|
||||
getUser().then(function (result) {
|
||||
saveUser(result);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch user', err);
|
||||
});
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
};
|
||||
|
||||
(page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
||||
(page.querySelector('.deleteAccess') as HTMLDivElement).classList.toggle('hide', this.checked);
|
||||
});
|
||||
|
||||
window.ApiClient.getNamedConfiguration('network').then(function (config) {
|
||||
(page.querySelector('.fldRemoteAccess') as HTMLDivElement).classList.toggle('hide', !config.EnableRemoteAccess);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to load network config', err);
|
||||
});
|
||||
|
||||
(page.querySelector('.editUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
|
||||
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', function() {
|
||||
window.history.back();
|
||||
});
|
||||
}, [loadData]);
|
||||
|
||||
const optionLoginProvider = authProviders.map((provider) => {
|
||||
const selected = provider.Id === authenticationProviderId || authProviders.length < 2 ? ' selected' : '';
|
||||
return `<option value="${provider.Id}"${selected}>${escapeHTML(provider.Name)}</option>`;
|
||||
});
|
||||
|
||||
const optionPasswordResetProvider = passwordResetProviders.map((provider) => {
|
||||
const selected = provider.Id === passwordResetProviderId || passwordResetProviders.length < 2 ? ' selected' : '';
|
||||
return `<option value="${provider.Id}"${selected}>${escapeHTML(provider.Name)}</option>`;
|
||||
});
|
||||
|
||||
const optionSyncPlayAccess = () => {
|
||||
let content = '';
|
||||
content += `<option value='CreateAndJoinGroups'>${globalize.translate('LabelSyncPlayAccessCreateAndJoinGroups')}</option>`;
|
||||
content += `<option value='JoinGroups'>${globalize.translate('LabelSyncPlayAccessJoinGroups')}</option>`;
|
||||
content += `<option value='None'>${globalize.translate('LabelSyncPlayAccessNone')}</option>`;
|
||||
return content;
|
||||
};
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='editUserPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<div ref={element} className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={userName}
|
||||
url='https://jellyfin.org/docs/general/server/users/'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SectionTabs activeTab='useredit'/>
|
||||
<div
|
||||
className='lnkEditUserPreferencesContainer'
|
||||
style={{ paddingBottom: '1em' }}
|
||||
>
|
||||
<LinkEditUserPreferences
|
||||
className= 'lnkEditUserPreferences button-link'
|
||||
title= 'ButtonEditOtherUserPreferences'
|
||||
/>
|
||||
</div>
|
||||
<form className='editUserProfileForm'>
|
||||
<div className='disabledUserBanner hide'>
|
||||
<div className='btn btnDarkAccent btnStatic'>
|
||||
<div>
|
||||
{globalize.translate('HeaderThisUserIsCurrentlyDisabled')}
|
||||
</div>
|
||||
<div style={{ marginTop: 5 }}>
|
||||
{globalize.translate('MessageReenableUser')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id='fldUserName' className='inputContainer'>
|
||||
<InputElement
|
||||
type='text'
|
||||
id='txtUserName'
|
||||
label='LabelName'
|
||||
options={'required'}
|
||||
/>
|
||||
</div>
|
||||
<div className='selectContainer fldSelectLoginProvider hide'>
|
||||
<SelectElement
|
||||
id='selectLoginProvider'
|
||||
label='LabelAuthProvider'
|
||||
>
|
||||
{optionLoginProvider}
|
||||
</SelectElement>
|
||||
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('AuthProviderHelp')}
|
||||
</div>
|
||||
</div>
|
||||
<div className='selectContainer fldSelectPasswordResetProvider hide'>
|
||||
<SelectElement
|
||||
id='selectPasswordResetProvider'
|
||||
label='LabelPasswordResetProvider'
|
||||
>
|
||||
{optionPasswordResetProvider}
|
||||
</SelectElement>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('PasswordResetProviderHelp')}
|
||||
</div>
|
||||
</div>
|
||||
<div className='checkboxContainer checkboxContainer-withDescription fldRemoteAccess hide'>
|
||||
<CheckBoxElement
|
||||
className='chkRemoteAccess'
|
||||
title='AllowRemoteAccess'
|
||||
/>
|
||||
<div className='fieldDescription checkboxFieldDescription'>
|
||||
{globalize.translate('AllowRemoteAccessHelp')}
|
||||
</div>
|
||||
</div>
|
||||
<CheckBoxElement
|
||||
labelClassName='checkboxContainer'
|
||||
className='chkIsAdmin'
|
||||
title='OptionAllowUserToManageServer'
|
||||
/>
|
||||
<CheckBoxElement
|
||||
labelClassName='checkboxContainer'
|
||||
className='chkEnableCollectionManagement'
|
||||
title='AllowCollectionManagement'
|
||||
/>
|
||||
<CheckBoxElement
|
||||
labelClassName='checkboxContainer'
|
||||
className='chkEnableSubtitleManagement'
|
||||
title='AllowSubtitleManagement'
|
||||
/>
|
||||
<div id='featureAccessFields' className='verticalSection'>
|
||||
<h2 className='paperListLabel'>
|
||||
{globalize.translate('HeaderFeatureAccess')}
|
||||
</h2>
|
||||
<div className='checkboxList paperList' style={{ padding: '.5em 1em' }}>
|
||||
<CheckBoxElement
|
||||
className='chkEnableLiveTvAccess'
|
||||
title='OptionAllowBrowsingLiveTv'
|
||||
/>
|
||||
<CheckBoxElement
|
||||
className='chkManageLiveTv'
|
||||
title='OptionAllowManageLiveTv'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='verticalSection'>
|
||||
<h2 className='paperListLabel'>
|
||||
{globalize.translate('HeaderPlayback')}
|
||||
</h2>
|
||||
<div className='checkboxList paperList' style={{ padding: '.5em 1em' }}>
|
||||
<CheckBoxElement
|
||||
className='chkEnableMediaPlayback'
|
||||
title='OptionAllowMediaPlayback'
|
||||
/>
|
||||
<CheckBoxElement
|
||||
className='chkEnableAudioPlaybackTranscoding'
|
||||
title='OptionAllowAudioPlaybackTranscoding'
|
||||
/>
|
||||
<CheckBoxElement
|
||||
className='chkEnableVideoPlaybackTranscoding'
|
||||
title='OptionAllowVideoPlaybackTranscoding'
|
||||
/>
|
||||
<CheckBoxElement
|
||||
className='chkEnableVideoPlaybackRemuxing'
|
||||
title='OptionAllowVideoPlaybackRemuxing'
|
||||
/>
|
||||
<CheckBoxElement
|
||||
className='chkForceRemoteSourceTranscoding'
|
||||
title='OptionForceRemoteSourceTranscoding'
|
||||
/>
|
||||
</div>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('OptionAllowMediaPlaybackTranscodingHelp')}
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtRemoteClientBitrateLimit'
|
||||
label='LabelRemoteClientBitrateLimit'
|
||||
options={'inputMode="decimal" pattern="[0-9]*(.[0-9]+)?" min="{0}" step=".25"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelRemoteClientBitrateLimitHelp')}
|
||||
</div>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelUserRemoteClientBitrateLimitHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='verticalSection'>
|
||||
<div className='selectContainer fldSelectSyncPlayAccess'>
|
||||
<SelectElement
|
||||
id='selectSyncPlayAccess'
|
||||
label='LabelSyncPlayAccess'
|
||||
>
|
||||
{optionSyncPlayAccess()}
|
||||
</SelectElement>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('SyncPlayAccessHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='verticalSection'>
|
||||
<h2 className='checkboxListLabel' style={{ marginBottom: '1em' }}>
|
||||
{globalize.translate('HeaderAllowMediaDeletionFrom')}
|
||||
</h2>
|
||||
<div className='checkboxList paperList checkboxList-paperList'>
|
||||
<CheckBoxElement
|
||||
labelClassName='checkboxContainer'
|
||||
className='chkEnableDeleteAllFolders'
|
||||
title='AllLibraries'
|
||||
/>
|
||||
<div className='deleteAccess'>
|
||||
{deleteFoldersAccess.map(Item => (
|
||||
<CheckBoxElement
|
||||
key={Item.Id}
|
||||
className='chkFolder'
|
||||
itemId={Item.Id}
|
||||
itemName={Item.Name}
|
||||
itemCheckedAttribute={Item.checkedAttribute}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='verticalSection'>
|
||||
<h2 className='checkboxListLabel'>
|
||||
{globalize.translate('HeaderRemoteControl')}
|
||||
</h2>
|
||||
<div className='checkboxList paperList' style={{ padding: '.5em 1em' }}>
|
||||
<CheckBoxElement
|
||||
className='chkEnableRemoteControlOtherUsers'
|
||||
title='OptionAllowRemoteControlOthers'
|
||||
/>
|
||||
<CheckBoxElement
|
||||
className='chkRemoteControlSharedDevices'
|
||||
title='OptionAllowRemoteSharedDevices'
|
||||
/>
|
||||
</div>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('OptionAllowRemoteSharedDevicesHelp')}
|
||||
</div>
|
||||
</div>
|
||||
<h2 className='checkboxListLabel'>
|
||||
{globalize.translate('Other')}
|
||||
</h2>
|
||||
<div className='checkboxContainer checkboxContainer-withDescription'>
|
||||
<CheckBoxElement
|
||||
className='chkEnableDownloading'
|
||||
title='OptionAllowContentDownload'
|
||||
/>
|
||||
<div className='fieldDescription checkboxFieldDescription'>
|
||||
{globalize.translate('OptionAllowContentDownloadHelp')}
|
||||
</div>
|
||||
</div>
|
||||
<div className='checkboxContainer checkboxContainer-withDescription' id='fldIsEnabled'>
|
||||
<CheckBoxElement
|
||||
className='chkDisabled'
|
||||
title='OptionDisableUser'
|
||||
/>
|
||||
<div className='fieldDescription checkboxFieldDescription'>
|
||||
{globalize.translate('OptionDisableUserHelp')}
|
||||
</div>
|
||||
</div>
|
||||
<div className='checkboxContainer checkboxContainer-withDescription' id='fldIsHidden'>
|
||||
<CheckBoxElement
|
||||
className='chkIsHidden'
|
||||
title='OptionHideUser'
|
||||
/>
|
||||
<div className='fieldDescription checkboxFieldDescription'>
|
||||
{globalize.translate('OptionHideUserFromLoginHelp')}
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer' id='fldLoginAttemptsBeforeLockout'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtLoginAttemptsBeforeLockout'
|
||||
label='LabelUserLoginAttemptsBeforeLockout'
|
||||
options={'min={-1} step={1}'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('OptionLoginAttemptsBeforeLockout')}
|
||||
</div>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('OptionLoginAttemptsBeforeLockoutHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer' id='fldMaxActiveSessions'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtMaxActiveSessions'
|
||||
label='LabelUserMaxActiveSessions'
|
||||
options={'min={0} step={1}'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('OptionMaxActiveSessions')}
|
||||
</div>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('OptionMaxActiveSessionsHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<ButtonElement
|
||||
type='submit'
|
||||
className='raised button-submit block'
|
||||
title='Save'
|
||||
/>
|
||||
<ButtonElement
|
||||
type='button'
|
||||
id='btnCancel'
|
||||
className='raised button-cancel block'
|
||||
title='ButtonCancel'
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default UserEdit;
|
||||
@@ -1,78 +0,0 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Box from '@mui/material/Box';
|
||||
import { type Theme } from '@mui/material/styles';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
import AppBody from 'components/AppBody';
|
||||
import ElevationScroll from 'components/ElevationScroll';
|
||||
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import AppToolbar from './components/AppToolbar';
|
||||
import AppDrawer, { isDrawerPath } from './components/drawers/AppDrawer';
|
||||
|
||||
import './AppOverrides.scss';
|
||||
|
||||
export const Component = () => {
|
||||
const [ isDrawerActive, setIsDrawerActive ] = useState(false);
|
||||
const { user } = useApi();
|
||||
const location = useLocation();
|
||||
|
||||
const isMediumScreen = useMediaQuery((t: Theme) => t.breakpoints.up('md'));
|
||||
const isDrawerAvailable = isDrawerPath(location.pathname) && Boolean(user);
|
||||
const isDrawerOpen = isDrawerActive && isDrawerAvailable;
|
||||
|
||||
const onToggleDrawer = useCallback(() => {
|
||||
setIsDrawerActive(!isDrawerActive);
|
||||
}, [ isDrawerActive, setIsDrawerActive ]);
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', display: 'flex' }}>
|
||||
<ElevationScroll elevate={false}>
|
||||
<AppBar
|
||||
position='fixed'
|
||||
sx={{
|
||||
width: {
|
||||
xs: '100%',
|
||||
md: isDrawerAvailable ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%'
|
||||
},
|
||||
ml: {
|
||||
xs: 0,
|
||||
md: isDrawerAvailable ? DRAWER_WIDTH : 0
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AppToolbar
|
||||
isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
|
||||
isDrawerOpen={isDrawerOpen}
|
||||
onDrawerButtonClick={onToggleDrawer}
|
||||
/>
|
||||
</AppBar>
|
||||
</ElevationScroll>
|
||||
|
||||
{
|
||||
isDrawerAvailable && (
|
||||
<AppDrawer
|
||||
open={isDrawerOpen}
|
||||
onClose={onToggleDrawer}
|
||||
onOpen={onToggleDrawer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<Box
|
||||
component='main'
|
||||
sx={{
|
||||
width: '100%',
|
||||
flexGrow: 1
|
||||
}}
|
||||
>
|
||||
<AppBody>
|
||||
<Outlet />
|
||||
</AppBody>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
// Default MUI breakpoints
|
||||
// https://mui.com/material-ui/customization/breakpoints/#default-breakpoints
|
||||
$mui-bp-sm: 600px;
|
||||
$mui-bp-md: 900px;
|
||||
$mui-bp-lg: 1200px;
|
||||
$mui-bp-xl: 1536px;
|
||||
|
||||
$drawer-width: 240px;
|
||||
|
||||
// Fix main pages layout to work with drawer
|
||||
.mainAnimatedPage {
|
||||
@media all and (min-width: $mui-bp-md) {
|
||||
left: $drawer-width;
|
||||
}
|
||||
}
|
||||
|
||||
// Hide some items from the user "settings" page that are in the drawer
|
||||
#myPreferencesMenuPage {
|
||||
.lnkQuickConnectPreferences,
|
||||
.adminSection,
|
||||
.userSection {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Fix the padding of some pages
|
||||
.homePage.libraryPage.withTabs, // Home page
|
||||
// Library pages excluding the item details page and tabbed pages
|
||||
.libraryPage:not(
|
||||
.itemDetailPage,
|
||||
.withTabs
|
||||
) {
|
||||
padding-top: 3.25rem !important;
|
||||
}
|
||||
// Tabbed library pages
|
||||
.libraryPage.withTabs {
|
||||
padding-top: 6.5rem !important;
|
||||
|
||||
@media all and (min-width: $mui-bp-lg) {
|
||||
padding-top: 3.25rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Fix backdrop position on mobile item details page
|
||||
.layout-mobile .itemBackdrop {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import CastConnected from '@mui/icons-material/CastConnected';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Cast from '@mui/icons-material/Cast';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
|
||||
import { playbackManager } from 'components/playback/playbackmanager';
|
||||
import globalize from 'lib/globalize';
|
||||
import Events from 'utils/events';
|
||||
|
||||
import RemotePlayMenu, { ID } from './menus/RemotePlayMenu';
|
||||
import RemotePlayActiveMenu, { ID as ACTIVE_ID } from './menus/RemotePlayActiveMenu';
|
||||
|
||||
const RemotePlayButton = () => {
|
||||
const theme = useTheme();
|
||||
const [ playerInfo, setPlayerInfo ] = useState(playbackManager.getPlayerInfo());
|
||||
|
||||
const updatePlayerInfo = useCallback(() => {
|
||||
setPlayerInfo(playbackManager.getPlayerInfo());
|
||||
}, [ setPlayerInfo ]);
|
||||
|
||||
useEffect(() => {
|
||||
Events.on(playbackManager, 'playerchange', updatePlayerInfo);
|
||||
|
||||
return () => {
|
||||
Events.off(playbackManager, 'playerchange', updatePlayerInfo);
|
||||
};
|
||||
}, [ updatePlayerInfo ]);
|
||||
|
||||
const [ remotePlayMenuAnchorEl, setRemotePlayMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
||||
const isRemotePlayMenuOpen = Boolean(remotePlayMenuAnchorEl);
|
||||
|
||||
const onRemotePlayButtonClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
setRemotePlayMenuAnchorEl(event.currentTarget);
|
||||
}, [ setRemotePlayMenuAnchorEl ]);
|
||||
|
||||
const onRemotePlayMenuClose = useCallback(() => {
|
||||
setRemotePlayMenuAnchorEl(null);
|
||||
}, [ setRemotePlayMenuAnchorEl ]);
|
||||
|
||||
const [ remotePlayActiveMenuAnchorEl, setRemotePlayActiveMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
||||
const isRemotePlayActiveMenuOpen = Boolean(remotePlayActiveMenuAnchorEl);
|
||||
|
||||
const onRemotePlayActiveButtonClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
setRemotePlayActiveMenuAnchorEl(event.currentTarget);
|
||||
}, [ setRemotePlayActiveMenuAnchorEl ]);
|
||||
|
||||
const onRemotePlayActiveMenuClose = useCallback(() => {
|
||||
setRemotePlayActiveMenuAnchorEl(null);
|
||||
}, [ setRemotePlayActiveMenuAnchorEl ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(playerInfo && !playerInfo.isLocalPlayer) ? (
|
||||
<Box
|
||||
sx={{
|
||||
alignSelf: 'center'
|
||||
}}
|
||||
>
|
||||
<Tooltip title={globalize.translate('ButtonCast')}>
|
||||
<Button
|
||||
variant='text'
|
||||
size='large'
|
||||
startIcon={<CastConnected />}
|
||||
aria-label={globalize.translate('ButtonCast')}
|
||||
aria-controls={ACTIVE_ID}
|
||||
aria-haspopup='true'
|
||||
onClick={onRemotePlayActiveButtonClick}
|
||||
color='inherit'
|
||||
sx={{
|
||||
color: theme.palette.primary.main
|
||||
}}
|
||||
>
|
||||
{playerInfo.deviceName || playerInfo.name}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
) : (
|
||||
<Tooltip title={globalize.translate('ButtonCast')}>
|
||||
<IconButton
|
||||
size='large'
|
||||
aria-label={globalize.translate('ButtonCast')}
|
||||
aria-controls={ID}
|
||||
aria-haspopup='true'
|
||||
onClick={onRemotePlayButtonClick}
|
||||
color='inherit'
|
||||
>
|
||||
<Cast />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<RemotePlayMenu
|
||||
open={isRemotePlayMenuOpen}
|
||||
anchorEl={remotePlayMenuAnchorEl}
|
||||
onMenuClose={onRemotePlayMenuClose}
|
||||
/>
|
||||
|
||||
<RemotePlayActiveMenu
|
||||
open={isRemotePlayActiveMenuOpen}
|
||||
anchorEl={remotePlayActiveMenuAnchorEl}
|
||||
onMenuClose={onRemotePlayActiveMenuClose}
|
||||
playerInfo={playerInfo}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RemotePlayButton;
|
||||
@@ -1,62 +0,0 @@
|
||||
import React, { type FC } from 'react';
|
||||
import {
|
||||
Link,
|
||||
URLSearchParamsInit,
|
||||
createSearchParams,
|
||||
useLocation,
|
||||
useSearchParams
|
||||
} from 'react-router-dom';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
const getUrlParams = (searchParams: URLSearchParams) => {
|
||||
const parentId =
|
||||
searchParams.get('parentId') || searchParams.get('topParentId');
|
||||
const collectionType = searchParams.get('collectionType');
|
||||
const params: URLSearchParamsInit = {};
|
||||
|
||||
if (parentId) {
|
||||
params.parentId = parentId;
|
||||
}
|
||||
|
||||
if (collectionType) {
|
||||
params.collectionType = collectionType;
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
interface SearchButtonProps {
|
||||
isTabsAvailable: boolean;
|
||||
}
|
||||
|
||||
const SearchButton: FC<SearchButtonProps> = ({ isTabsAvailable }) => {
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const isSearchPath = location.pathname === '/search.html';
|
||||
const createSearchLink = isTabsAvailable ?
|
||||
{
|
||||
pathname: '/search.html',
|
||||
search: `?${createSearchParams(getUrlParams(searchParams))}`
|
||||
} :
|
||||
'/search.html';
|
||||
|
||||
return (
|
||||
<Tooltip title={globalize.translate('Search')}>
|
||||
<IconButton
|
||||
size='large'
|
||||
aria-label={globalize.translate('Search')}
|
||||
color='inherit'
|
||||
component={Link}
|
||||
disabled={isSearchPath}
|
||||
to={createSearchLink}
|
||||
>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchButton;
|
||||
@@ -1,61 +0,0 @@
|
||||
import { SyncPlayUserAccessType } from '@jellyfin/sdk/lib/generated-client/models/sync-play-user-access-type';
|
||||
import Groups from '@mui/icons-material/Groups';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { pluginManager } from 'components/pluginManager';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import globalize from 'lib/globalize';
|
||||
import { PluginType } from 'types/plugin';
|
||||
|
||||
import AppSyncPlayMenu, { ID } from './menus/SyncPlayMenu';
|
||||
|
||||
const SyncPlayButton = () => {
|
||||
const { user } = useApi();
|
||||
|
||||
const [ syncPlayMenuAnchorEl, setSyncPlayMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
||||
const isSyncPlayMenuOpen = Boolean(syncPlayMenuAnchorEl);
|
||||
|
||||
const onSyncPlayButtonClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
setSyncPlayMenuAnchorEl(event.currentTarget);
|
||||
}, [ setSyncPlayMenuAnchorEl ]);
|
||||
|
||||
const onSyncPlayMenuClose = useCallback(() => {
|
||||
setSyncPlayMenuAnchorEl(null);
|
||||
}, [ setSyncPlayMenuAnchorEl ]);
|
||||
|
||||
if (
|
||||
// SyncPlay not enabled for user
|
||||
(user?.Policy && user.Policy.SyncPlayAccess === SyncPlayUserAccessType.None)
|
||||
// SyncPlay plugin is not loaded
|
||||
|| pluginManager.ofType(PluginType.SyncPlay).length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={globalize.translate('ButtonSyncPlay')}>
|
||||
<IconButton
|
||||
size='large'
|
||||
aria-label={globalize.translate('ButtonSyncPlay')}
|
||||
aria-controls={ID}
|
||||
aria-haspopup='true'
|
||||
onClick={onSyncPlayButtonClick}
|
||||
color='inherit'
|
||||
>
|
||||
<Groups />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<AppSyncPlayMenu
|
||||
open={isSyncPlayMenuOpen}
|
||||
anchorEl={syncPlayMenuAnchorEl}
|
||||
onMenuClose={onSyncPlayMenuClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SyncPlayButton;
|
||||
@@ -1,56 +0,0 @@
|
||||
import React, { type FC } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import AppToolbar from 'components/toolbar/AppToolbar';
|
||||
import AppTabs from '../tabs/AppTabs';
|
||||
import RemotePlayButton from './RemotePlayButton';
|
||||
import SyncPlayButton from './SyncPlayButton';
|
||||
import SearchButton from './SearchButton';
|
||||
import { isTabPath } from '../tabs/tabRoutes';
|
||||
|
||||
interface AppToolbarProps {
|
||||
isDrawerAvailable: boolean
|
||||
isDrawerOpen: boolean
|
||||
onDrawerButtonClick: (event: React.MouseEvent<HTMLElement>) => void
|
||||
}
|
||||
|
||||
const PUBLIC_PATHS = [
|
||||
'/addserver.html',
|
||||
'/selectserver.html',
|
||||
'/login.html',
|
||||
'/forgotpassword.html',
|
||||
'/forgotpasswordpin.html'
|
||||
];
|
||||
|
||||
const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
|
||||
isDrawerAvailable,
|
||||
isDrawerOpen,
|
||||
onDrawerButtonClick
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
|
||||
// The video osd does not show the standard toolbar
|
||||
if (location.pathname === '/video') return null;
|
||||
|
||||
const isTabsAvailable = isTabPath(location.pathname);
|
||||
const isPublicPath = PUBLIC_PATHS.includes(location.pathname);
|
||||
|
||||
return (
|
||||
<AppToolbar
|
||||
buttons={!isPublicPath && (
|
||||
<>
|
||||
<SyncPlayButton />
|
||||
<RemotePlayButton />
|
||||
<SearchButton isTabsAvailable={isTabsAvailable} />
|
||||
</>
|
||||
)}
|
||||
isDrawerAvailable={isDrawerAvailable}
|
||||
isDrawerOpen={isDrawerOpen}
|
||||
onDrawerButtonClick={onDrawerButtonClick}
|
||||
isUserMenuAvailable={!isPublicPath}
|
||||
>
|
||||
{isTabsAvailable && (<AppTabs isDrawerOpen={isDrawerOpen} />)}
|
||||
</AppToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExperimentalAppToolbar;
|
||||
@@ -1,157 +0,0 @@
|
||||
import Check from '@mui/icons-material/Check';
|
||||
import Close from '@mui/icons-material/Close';
|
||||
import SettingsRemote from '@mui/icons-material/SettingsRemote';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import Menu, { MenuProps } from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import dialog from 'components/dialog/dialog';
|
||||
import { playbackManager } from 'components/playback/playbackmanager';
|
||||
import React, { FC, useCallback, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { enable, isEnabled } from 'scripts/autocast';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
interface RemotePlayActiveMenuProps extends MenuProps {
|
||||
onMenuClose: () => void
|
||||
playerInfo: {
|
||||
name: string
|
||||
isLocalPlayer: boolean
|
||||
id?: string
|
||||
deviceName?: string
|
||||
playableMediaTypes?: string[]
|
||||
supportedCommands?: string[]
|
||||
} | null
|
||||
}
|
||||
|
||||
export const ID = 'app-remote-play-active-menu';
|
||||
|
||||
const RemotePlayActiveMenu: FC<RemotePlayActiveMenuProps> = ({
|
||||
anchorEl,
|
||||
open,
|
||||
onMenuClose,
|
||||
playerInfo
|
||||
}) => {
|
||||
const [ isDisplayMirrorEnabled, setIsDisplayMirrorEnabled ] = useState(playbackManager.enableDisplayMirroring());
|
||||
const isDisplayMirrorSupported = playerInfo?.supportedCommands && playerInfo.supportedCommands.indexOf('DisplayContent') !== -1;
|
||||
const toggleDisplayMirror = useCallback(() => {
|
||||
playbackManager.enableDisplayMirroring(!isDisplayMirrorEnabled);
|
||||
setIsDisplayMirrorEnabled(!isDisplayMirrorEnabled);
|
||||
}, [ isDisplayMirrorEnabled, setIsDisplayMirrorEnabled ]);
|
||||
|
||||
const [ isAutoCastEnabled, setIsAutoCastEnabled ] = useState(isEnabled());
|
||||
const toggleAutoCast = useCallback(() => {
|
||||
enable(!isAutoCastEnabled);
|
||||
setIsAutoCastEnabled(!isAutoCastEnabled);
|
||||
}, [ isAutoCastEnabled ]);
|
||||
|
||||
const remotePlayerName = playerInfo?.deviceName || playerInfo?.name;
|
||||
|
||||
const disconnectRemotePlayer = useCallback(() => {
|
||||
if (playbackManager.getSupportedCommands().indexOf('EndSession') !== -1) {
|
||||
dialog.show({
|
||||
buttons: [
|
||||
{
|
||||
name: globalize.translate('Yes'),
|
||||
id: 'yes'
|
||||
}, {
|
||||
name: globalize.translate('No'),
|
||||
id: 'no'
|
||||
}
|
||||
],
|
||||
text: globalize.translate('ConfirmEndPlayerSession', remotePlayerName)
|
||||
}).then(id => {
|
||||
onMenuClose();
|
||||
|
||||
if (id === 'yes') {
|
||||
playbackManager.getCurrentPlayer().endSession();
|
||||
}
|
||||
playbackManager.setDefaultPlayerActive();
|
||||
}).catch(() => {
|
||||
// Dialog closed
|
||||
});
|
||||
} else {
|
||||
onMenuClose();
|
||||
playbackManager.setDefaultPlayerActive();
|
||||
}
|
||||
}, [ onMenuClose, remotePlayerName ]);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
id={ID}
|
||||
keepMounted
|
||||
open={open}
|
||||
onClose={onMenuClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'remote-play-active-subheader',
|
||||
subheader: (
|
||||
<ListSubheader component='div' id='remote-play-active-subheader'>
|
||||
{remotePlayerName}
|
||||
</ListSubheader>
|
||||
)
|
||||
}}
|
||||
>
|
||||
{isDisplayMirrorSupported && (
|
||||
<MenuItem onClick={toggleDisplayMirror}>
|
||||
{isDisplayMirrorEnabled && (
|
||||
<ListItemIcon>
|
||||
<Check />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
<ListItemText inset={!isDisplayMirrorEnabled}>
|
||||
{globalize.translate('EnableDisplayMirroring')}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
<MenuItem onClick={toggleAutoCast}>
|
||||
{isAutoCastEnabled && (
|
||||
<ListItemIcon>
|
||||
<Check />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
<ListItemText inset={!isAutoCastEnabled}>
|
||||
{globalize.translate('EnableAutoCast')}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<MenuItem
|
||||
component={Link}
|
||||
to='/queue'
|
||||
onClick={onMenuClose}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<SettingsRemote />
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
{globalize.translate('HeaderRemoteControl')}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={disconnectRemotePlayer}>
|
||||
<ListItemIcon>
|
||||
<Close />
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
{globalize.translate('Disconnect')}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default RemotePlayActiveMenu;
|
||||
@@ -1,100 +0,0 @@
|
||||
import Warning from '@mui/icons-material/Warning';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Menu, { type MenuProps } from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import React, { FC, useEffect, useState } from 'react';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
import { playbackManager } from 'components/playback/playbackmanager';
|
||||
import { pluginManager } from 'components/pluginManager';
|
||||
import type { PlayTarget } from 'types/playTarget';
|
||||
|
||||
import PlayTargetIcon from '../../PlayTargetIcon';
|
||||
|
||||
interface RemotePlayMenuProps extends MenuProps {
|
||||
onMenuClose: () => void
|
||||
}
|
||||
|
||||
export const ID = 'app-remote-play-menu';
|
||||
|
||||
const RemotePlayMenu: FC<RemotePlayMenuProps> = ({
|
||||
anchorEl,
|
||||
open,
|
||||
onMenuClose
|
||||
}) => {
|
||||
// TODO: Add other checks for support (Android app, secure context, etc)
|
||||
const isChromecastPluginLoaded = !!pluginManager.plugins.find(plugin => plugin.id === 'chromecast');
|
||||
|
||||
const [ playbackTargets, setPlaybackTargets ] = useState<PlayTarget[]>([]);
|
||||
|
||||
const onPlayTargetClick = (target: PlayTarget) => {
|
||||
playbackManager.trySetActivePlayer(target.playerName, target);
|
||||
onMenuClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlaybackTargets = async () => {
|
||||
setPlaybackTargets(
|
||||
await playbackManager.getTargets()
|
||||
);
|
||||
};
|
||||
|
||||
if (open) {
|
||||
fetchPlaybackTargets()
|
||||
.catch(err => {
|
||||
console.error('[AppRemotePlayMenu] unable to get playback targets', err);
|
||||
});
|
||||
}
|
||||
}, [ open, setPlaybackTargets ]);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
id={ID}
|
||||
keepMounted
|
||||
open={open}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
{!isChromecastPluginLoaded && ([
|
||||
<MenuItem key='cast-unsupported-item' disabled>
|
||||
<ListItemIcon>
|
||||
<Warning />
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
{globalize.translate('GoogleCastUnsupported')}
|
||||
</ListItemText>
|
||||
</MenuItem>,
|
||||
<Divider key='cast-unsupported-divider' />
|
||||
])}
|
||||
|
||||
{playbackTargets.map(target => (
|
||||
<MenuItem
|
||||
key={target.id}
|
||||
// Since we are looping over targets there is no good way to avoid creating a new function here
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => onPlayTargetClick(target)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<PlayTargetIcon target={target} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={ target.appName ? `${target.name} - ${target.appName}` : target.name }
|
||||
secondary={ target.user?.Name }
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default RemotePlayMenu;
|
||||
@@ -1,297 +0,0 @@
|
||||
import type { GroupInfoDto } from '@jellyfin/sdk/lib/generated-client/models/group-info-dto';
|
||||
import { SyncPlayUserAccessType } from '@jellyfin/sdk/lib/generated-client/models/sync-play-user-access-type';
|
||||
import { getSyncPlayApi } from '@jellyfin/sdk/lib/utils/api/sync-play-api';
|
||||
import GroupAdd from '@mui/icons-material/GroupAdd';
|
||||
import PersonAdd from '@mui/icons-material/PersonAdd';
|
||||
import PersonOff from '@mui/icons-material/PersonOff';
|
||||
import PersonRemove from '@mui/icons-material/PersonRemove';
|
||||
import PlayCircle from '@mui/icons-material/PlayCircle';
|
||||
import StopCircle from '@mui/icons-material/StopCircle';
|
||||
import Tune from '@mui/icons-material/Tune';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import Menu, { MenuProps } from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import type { ApiClient } from 'jellyfin-apiclient';
|
||||
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { pluginManager } from 'components/pluginManager';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { useSyncPlayGroups } from 'hooks/useSyncPlayGroups';
|
||||
import globalize from 'lib/globalize';
|
||||
import { PluginType } from 'types/plugin';
|
||||
import Events, { Event } from 'utils/events';
|
||||
|
||||
export const ID = 'app-sync-play-menu';
|
||||
|
||||
interface SyncPlayMenuProps extends MenuProps {
|
||||
onMenuClose: () => void
|
||||
}
|
||||
|
||||
interface SyncPlayInstance {
|
||||
Manager: {
|
||||
getGroupInfo: () => GroupInfoDto | null | undefined
|
||||
getTimeSyncCore: () => object
|
||||
isPlaybackActive: () => boolean
|
||||
isPlaylistEmpty: () => boolean
|
||||
haltGroupPlayback: (apiClient: ApiClient) => void
|
||||
resumeGroupPlayback: (apiClient: ApiClient) => void
|
||||
}
|
||||
}
|
||||
|
||||
const SyncPlayMenu: FC<SyncPlayMenuProps> = ({
|
||||
anchorEl,
|
||||
open,
|
||||
onMenuClose
|
||||
}) => {
|
||||
const [ syncPlay, setSyncPlay ] = useState<SyncPlayInstance>();
|
||||
const { __legacyApiClient__, api, user } = useApi();
|
||||
const [ currentGroup, setCurrentGroup ] = useState<GroupInfoDto>();
|
||||
const isSyncPlayEnabled = Boolean(currentGroup);
|
||||
|
||||
useEffect(() => {
|
||||
setSyncPlay(pluginManager.firstOfType(PluginType.SyncPlay)?.instance);
|
||||
}, []);
|
||||
|
||||
const { data: groups } = useSyncPlayGroups();
|
||||
|
||||
const onGroupAddClick = useCallback(() => {
|
||||
if (api && user) {
|
||||
getSyncPlayApi(api)
|
||||
.syncPlayCreateGroup({
|
||||
newGroupRequestDto: {
|
||||
GroupName: globalize.translate('SyncPlayGroupDefaultTitle', user.Name)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[SyncPlayMenu] failed to create a SyncPlay group', err);
|
||||
});
|
||||
|
||||
onMenuClose();
|
||||
}
|
||||
}, [ api, onMenuClose, user ]);
|
||||
|
||||
const onGroupLeaveClick = useCallback(() => {
|
||||
if (api) {
|
||||
getSyncPlayApi(api)
|
||||
.syncPlayLeaveGroup()
|
||||
.catch(err => {
|
||||
console.error('[SyncPlayMenu] failed to leave SyncPlay group', err);
|
||||
});
|
||||
|
||||
onMenuClose();
|
||||
}
|
||||
}, [ api, onMenuClose ]);
|
||||
|
||||
const onGroupJoinClick = useCallback((GroupId: string) => {
|
||||
if (api) {
|
||||
getSyncPlayApi(api)
|
||||
.syncPlayJoinGroup({
|
||||
joinGroupRequestDto: {
|
||||
GroupId
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[SyncPlayMenu] failed to join SyncPlay group', err);
|
||||
});
|
||||
|
||||
onMenuClose();
|
||||
}
|
||||
}, [ api, onMenuClose ]);
|
||||
|
||||
const onGroupSettingsClick = useCallback(async () => {
|
||||
if (!syncPlay) return;
|
||||
|
||||
// TODO: Rewrite settings UI
|
||||
const SyncPlaySettingsEditor = (await import('../../../../../plugins/syncPlay/ui/settings/SettingsEditor')).default;
|
||||
new SyncPlaySettingsEditor(
|
||||
__legacyApiClient__,
|
||||
syncPlay.Manager.getTimeSyncCore(),
|
||||
{
|
||||
groupInfo: currentGroup
|
||||
})
|
||||
.embed()
|
||||
.catch(err => {
|
||||
if (err) {
|
||||
console.error('[SyncPlayMenu] Error creating SyncPlay settings editor', err);
|
||||
}
|
||||
});
|
||||
|
||||
onMenuClose();
|
||||
}, [ __legacyApiClient__, currentGroup, onMenuClose, syncPlay ]);
|
||||
|
||||
const onStartGroupPlaybackClick = useCallback(() => {
|
||||
if (__legacyApiClient__) {
|
||||
syncPlay?.Manager.resumeGroupPlayback(__legacyApiClient__);
|
||||
onMenuClose();
|
||||
}
|
||||
}, [ __legacyApiClient__, onMenuClose, syncPlay ]);
|
||||
|
||||
const onStopGroupPlaybackClick = useCallback(() => {
|
||||
if (__legacyApiClient__) {
|
||||
syncPlay?.Manager.haltGroupPlayback(__legacyApiClient__);
|
||||
onMenuClose();
|
||||
}
|
||||
}, [ __legacyApiClient__, onMenuClose, syncPlay ]);
|
||||
|
||||
const updateSyncPlayGroup = useCallback((_e: Event, enabled: boolean) => {
|
||||
if (syncPlay && enabled) {
|
||||
setCurrentGroup(syncPlay.Manager.getGroupInfo() ?? undefined);
|
||||
} else {
|
||||
setCurrentGroup(undefined);
|
||||
}
|
||||
}, [ syncPlay ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!syncPlay) return;
|
||||
|
||||
Events.on(syncPlay.Manager, 'enabled', updateSyncPlayGroup);
|
||||
|
||||
return () => {
|
||||
Events.off(syncPlay.Manager, 'enabled', updateSyncPlayGroup);
|
||||
};
|
||||
}, [ updateSyncPlayGroup, syncPlay ]);
|
||||
|
||||
const menuItems = [];
|
||||
if (isSyncPlayEnabled) {
|
||||
if (!syncPlay?.Manager.isPlaylistEmpty() && !syncPlay?.Manager.isPlaybackActive()) {
|
||||
menuItems.push(
|
||||
<MenuItem
|
||||
key='sync-play-start-playback'
|
||||
onClick={onStartGroupPlaybackClick}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<PlayCircle />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('LabelSyncPlayResumePlayback')} />
|
||||
</MenuItem>
|
||||
);
|
||||
} else if (syncPlay?.Manager.isPlaybackActive()) {
|
||||
menuItems.push(
|
||||
<MenuItem
|
||||
key='sync-play-stop-playback'
|
||||
onClick={onStopGroupPlaybackClick}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<StopCircle />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('LabelSyncPlayHaltPlayback')} />
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
menuItems.push(
|
||||
<MenuItem
|
||||
key='sync-play-settings'
|
||||
onClick={onGroupSettingsClick}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Tune />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={globalize.translate('Settings')}
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
menuItems.push(
|
||||
<Divider key='sync-play-controls-divider' />
|
||||
);
|
||||
|
||||
menuItems.push(
|
||||
<MenuItem
|
||||
key='sync-play-exit'
|
||||
onClick={onGroupLeaveClick}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<PersonRemove />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={globalize.translate('LabelSyncPlayLeaveGroup')}
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
} else if (!groups?.length && user?.Policy?.SyncPlayAccess !== SyncPlayUserAccessType.CreateAndJoinGroups) {
|
||||
menuItems.push(
|
||||
<MenuItem key='sync-play-unavailable' disabled>
|
||||
<ListItemIcon>
|
||||
<PersonOff />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('LabelSyncPlayNoGroups')} />
|
||||
</MenuItem>
|
||||
);
|
||||
} else {
|
||||
if (groups && groups.length > 0) {
|
||||
groups.forEach(group => {
|
||||
menuItems.push(
|
||||
<MenuItem
|
||||
key={group.GroupId}
|
||||
// Since we are looping over groups there is no good way to avoid creating a new function here
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => group.GroupId && onGroupJoinClick(group.GroupId)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<PersonAdd />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={group.GroupName}
|
||||
secondary={group.Participants?.join(', ')}
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
menuItems.push(
|
||||
<Divider key='sync-play-groups-divider' />
|
||||
);
|
||||
}
|
||||
|
||||
if (user?.Policy?.SyncPlayAccess === SyncPlayUserAccessType.CreateAndJoinGroups) {
|
||||
menuItems.push(
|
||||
<MenuItem
|
||||
key='sync-play-new-group'
|
||||
onClick={onGroupAddClick}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<GroupAdd />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('LabelSyncPlayNewGroupDescription')} />
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const MenuListProps = isSyncPlayEnabled ? {
|
||||
'aria-labelledby': 'sync-play-active-subheader',
|
||||
subheader: (
|
||||
<ListSubheader component='div' id='sync-play-active-subheader'>
|
||||
{currentGroup?.GroupName}
|
||||
</ListSubheader>
|
||||
)
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
id={ID}
|
||||
keepMounted
|
||||
open={open}
|
||||
onClose={onMenuClose}
|
||||
MenuListProps={MenuListProps}
|
||||
>
|
||||
{menuItems}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default SyncPlayMenu;
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import Movie from '@mui/icons-material/Movie';
|
||||
import MusicNote from '@mui/icons-material/MusicNote';
|
||||
import Photo from '@mui/icons-material/Photo';
|
||||
import LiveTv from '@mui/icons-material/LiveTv';
|
||||
import Tv from '@mui/icons-material/Tv';
|
||||
import Theaters from '@mui/icons-material/Theaters';
|
||||
import MusicVideo from '@mui/icons-material/MusicVideo';
|
||||
import Book from '@mui/icons-material/Book';
|
||||
import Collections from '@mui/icons-material/Collections';
|
||||
import Queue from '@mui/icons-material/Queue';
|
||||
import Folder from '@mui/icons-material/Folder';
|
||||
import React, { FC } from 'react';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
|
||||
interface LibraryIconProps {
|
||||
item: BaseItemDto
|
||||
}
|
||||
|
||||
const LibraryIcon: FC<LibraryIconProps> = ({
|
||||
item
|
||||
}) => {
|
||||
switch (item.CollectionType) {
|
||||
case CollectionType.Movies:
|
||||
return <Movie />;
|
||||
case CollectionType.Music:
|
||||
return <MusicNote />;
|
||||
case CollectionType.Homevideos:
|
||||
case CollectionType.Photos:
|
||||
return <Photo />;
|
||||
case CollectionType.Livetv:
|
||||
return <LiveTv />;
|
||||
case CollectionType.Tvshows:
|
||||
return <Tv />;
|
||||
case CollectionType.Trailers:
|
||||
return <Theaters />;
|
||||
case CollectionType.Musicvideos:
|
||||
return <MusicVideo />;
|
||||
case CollectionType.Books:
|
||||
return <Book />;
|
||||
case CollectionType.Boxsets:
|
||||
return <Collections />;
|
||||
case CollectionType.Playlists:
|
||||
return <Queue />;
|
||||
default:
|
||||
return <Folder />;
|
||||
}
|
||||
};
|
||||
|
||||
export default LibraryIcon;
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from 'react';
|
||||
import Cast from '@mui/icons-material/Cast';
|
||||
import Computer from '@mui/icons-material/Computer';
|
||||
import Devices from '@mui/icons-material/Devices';
|
||||
import Smartphone from '@mui/icons-material/Smartphone';
|
||||
import Tablet from '@mui/icons-material/Tablet';
|
||||
import Tv from '@mui/icons-material/Tv';
|
||||
|
||||
import browser from 'scripts/browser';
|
||||
import type { PlayTarget } from 'types/playTarget';
|
||||
|
||||
const PlayTargetIcon = ({ target }: { target: PlayTarget }) => {
|
||||
if (!target.deviceType && target.isLocalPlayer) {
|
||||
if (browser.tv) {
|
||||
return <Tv />;
|
||||
} else if (browser.mobile) {
|
||||
return <Smartphone />;
|
||||
}
|
||||
return <Computer />;
|
||||
}
|
||||
|
||||
switch (target.deviceType) {
|
||||
case 'smartphone':
|
||||
return <Smartphone />;
|
||||
case 'tablet':
|
||||
return <Tablet />;
|
||||
case 'desktop':
|
||||
return <Computer />;
|
||||
case 'cast':
|
||||
return <Cast />;
|
||||
case 'tv':
|
||||
return <Tv />;
|
||||
default:
|
||||
return <Devices />;
|
||||
}
|
||||
};
|
||||
|
||||
export default PlayTargetIcon;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user