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

Integrate branch 'master' into feature/osd_chapter_selection

This commit is contained in:
TheMelmacian 2025-02-27 22:53:20 +01:00
commit 19af2bf834
756 changed files with 42708 additions and 21088 deletions

View file

@ -5,7 +5,6 @@
"not": [ "not": [
"./dist/libraries/pdf.worker.js", "./dist/libraries/pdf.worker.js",
"./dist/libraries/worker-bundle.js", "./dist/libraries/worker-bundle.js",
"./dist/libraries/wasm-gen/libarchive.js",
"./dist/serviceworker.js" "./dist/serviceworker.js"
] ]
} }

View file

@ -1,4 +0,0 @@
node_modules
dist
.idea
.vscode

View file

@ -1,298 +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' }],
'@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
__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']
}
}
]
};

View file

@ -1,32 +0,0 @@
---
name: Bug Report
about: You have noticed a general issue or regression, and would like to report it
labels: bug
---
**Describe The Bug**
<!-- A clear and concise description of what the bug is. -->
**Steps To Reproduce**
<!-- Steps to reproduce the behavior: -->
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected Behavior**
<!-- A clear and concise description of what you expected to happen. -->
**Logs**
<!-- Please paste any log errors. -->
**Screenshots**
<!-- If applicable, add screenshots to help explain your problem. -->
**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. -->

122
.github/ISSUE_TEMPLATE/1-bug-report.yml vendored Normal file
View file

@ -0,0 +1,122 @@
name: Bug Report
description: You have noticed a general issue or regression, and would like to report it
labels:
- bug
body:
- type: checkboxes
id: before-posting
attributes:
label: "This issue respects the following points:"
description: All conditions are **required**.
options:
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-web/issues?q=is%3Aissue) _(I've searched it)_.
required: true
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
required: true
- label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one.
required: true
- type: markdown
attributes:
value: |
## Bug information
- type: textarea
id: description
attributes:
label: Describe the bug
description: |
A clear and concise description of the bug.
You can also attach screenshots or screen recordings here to help explain your issue.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction Steps
description: |
Steps to reproduce the behavior:
placeholder: |
1. Go to …
2. Click on …
3. Scroll down to …
4. See error / the app crashes
validations:
required: true
- type: textarea
id: behaviour
attributes:
label: Expected/Actual behaviour
description: |
Describe the behavior you were expecting versus what actually occurred.
placeholder: |
I expected the app to... However, the actual behavior was that...
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs
description: |
Please paste any log errors.
placeholder: Paste logs…
- type: markdown
attributes:
value: |
## Environment
- type: markdown
attributes:
value: |
### Server
You will find these values in your Admin Dashboard
- type: input
id: server-version
attributes:
label: Server version
placeholder: 10.10.2
validations:
required: true
- type: input
id: web-version
attributes:
label: Web version
placeholder: 10.10.2
validations:
required: true
- type: input
id: build-version
attributes:
label: Build version
placeholder: 10.10.2
validations:
required: true
- type: markdown
attributes:
value: |
### Client
Information about the device you are seeing the issue on
- type: input
id: platform
attributes:
label: Platform
description: Specify the operating system or device where the issue occurs. If relevant, include details like version or model.
placeholder: e.g. Linux, Windows, iPhone, Tizen
validations:
required: true
- type: input
id: browser
attributes:
label: Browser
description: Indicate which browser you're using when encountering the issue. If possible, mention the browser version as well.
placeholder: e.g. Firefox, Chrome, Safari
validations:
required: true
- type: markdown
attributes:
value: |
## Additional
- type: textarea
attributes:
label: Additional information
description: Include any relevant details, resources, or screenshots that might help in understanding or implementing the request.
placeholder: Add any additional context here.
validations:
required: false

View file

@ -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. -->

View file

@ -0,0 +1,145 @@
name: Playback Issue
description: Create a bug report related to media playback
labels:
- bug
- playback
body:
- type: checkboxes
id: before-posting
attributes:
label: "This issue respects the following points:"
description: All conditions are **required**.
options:
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-web/issues?q=is%3Aissue) _(I've searched it)_.
required: true
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
required: true
- label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one.
required: true
- type: markdown
attributes:
value: |
## Bug information
- type: textarea
id: description
attributes:
label: Describe the bug
description: |
A clear and concise description of the bug.
You can also attach screenshots or screen recordings here to help explain your issue.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction Steps
description: |
Steps to reproduce the behavior:
placeholder: |
1. Go to …
2. Click on …
3. Scroll down to …
4. See error / the app crashes
validations:
required: true
- type: textarea
id: behaviour
attributes:
label: Expected/Actual behaviour
description: |
Describe the behavior you were expecting versus what actually occurred.
placeholder: |
I expected the app to... However, the actual behavior was that...
validations:
required: true
- type: textarea
id: mediainfo
attributes:
label: Media info of the file
description: |
Please share the media information for the file causing issues. You can use a variety of tools to retrieve this information.
- Use ffprobe (`ffprobe ./file.mp4`)
- Copy the media info from the web interface
placeholder: Paste media info…
render: shell
- type: markdown
attributes:
value: |
## Logs
- type: textarea
id: logs
attributes:
label: Logs
description: |
Please paste your logs here if applicable.
placeholder: Paste logs…
- type: textarea
id: logs-ffmpeg
attributes:
label: FFmpeg logs
description: |
Please paste your FFmpeg logs here if available. You can find these in your servers dashboard under "logs".
placeholder: Paste logs…
render: shell
- type: markdown
attributes:
value: |
## Environment
- type: markdown
attributes:
value: |
### Server
You will find these values in your Admin Dashboard
- type: input
id: server-version
attributes:
label: Server version
placeholder: 10.10.2
validations:
required: true
- type: input
id: web-version
attributes:
label: Web version
placeholder: 10.10.2
validations:
required: true
- type: input
id: build-version
attributes:
label: Build version
placeholder: 10.10.2
validations:
required: true
- type: markdown
attributes:
value: |
### Client
Information about the device you are seeing the issue on
- type: input
id: platform
attributes:
label: Platform
description: Specify the operating system or device where the issue occurs. If relevant, include details like version or model.
placeholder: e.g. Linux, Windows, iPhone, Tizen
validations:
required: true
- type: input
id: browser
attributes:
label: Browser
description: Indicate which browser you're using when encountering the issue. If possible, mention the browser version as well.
placeholder: e.g. Firefox, Chrome, Safari
validations:
required: true
- type: markdown
attributes:
value: |
## Additional
- type: textarea
attributes:
label: Additional information
description: Include any relevant details, resources, or screenshots that might help in understanding or implementing the request.
placeholder: Add any additional context here.
validations:
required: false

View file

@ -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. -->

View file

@ -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]
* [ ] ...

22
.github/renovate.json vendored
View file

@ -2,7 +2,27 @@
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [ "extends": [
"github>jellyfin/.github//renovate-presets/nodejs", "github>jellyfin/.github//renovate-presets/nodejs",
":semanticCommitsDisabled",
":dependencyDashboard" ":dependencyDashboard"
],
"lockFileMaintenance": {
"enabled": false
},
"packageRules": [
{
"matchPackageNames": [ "@jellyfin/sdk" ],
"followTag": "unstable",
"minimumReleaseAge": null,
"prPriority": 5,
"schedule": [ "after 7:00 am" ]
},
{
"matchPackageNames": [ "dompurify" ],
"matchUpdateTypes": [ "major" ],
"enabled": false
},
{
"matchPackageNames": [ "hls.js" ],
"prPriority": 5
}
] ]
} }

15
.github/workflows/__automation.yml vendored Normal file
View file

@ -0,0 +1,15 @@
name: Automation 🎛️
on:
workflow_call:
jobs:
conflicts:
name: Merge conflict labeling 🏷️
runs-on: ubuntu-latest
steps:
- uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
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 }}

40
.github/workflows/__codeql.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: GitHub CodeQL 🔬
on:
workflow_call:
inputs:
commit:
required: true
type: string
jobs:
analyze:
name: Analyze ${{ matrix.language }} 🔬
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language:
- javascript-typescript
steps:
- name: Checkout repository ⬇️
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.commit }}
show-progress: false
- name: Initialize CodeQL 🛠️
uses: github/codeql-action/init@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
with:
queries: security-and-quality
languages: ${{ matrix.language }}
- name: Autobuild 📦
uses: github/codeql-action/autobuild@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
- name: Perform CodeQL Analysis 🧪
uses: github/codeql-action/analyze@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
with:
category: '/language:${{matrix.language}}'

59
.github/workflows/__deploy.yml vendored Normal file
View file

@ -0,0 +1,59 @@
name: Deploy 🏗️
on:
workflow_call:
inputs:
branch:
required: true
type: string
commit:
required: false
type: string
comment:
required: false
type: boolean
artifact_name:
required: false
type: string
default: frontend
jobs:
cf-pages:
name: CloudFlare Pages 📃
runs-on: ubuntu-latest
environment:
name: ${{ inputs.branch == 'master' && 'Production' || 'Preview' }}
url: ${{ steps.cf.outputs.deployment-url }}
outputs:
url: ${{ steps.cf.outputs.deployment-url }}
steps:
- name: Download workflow artifact ⬇️
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
name: ${{ inputs.artifact_name }}
path: dist
- name: Publish to Cloudflare Pages 📃
uses: cloudflare/wrangler-action@392082e81ffbcb9ebdde27400634aa004b35ea37 # v3.14.0
id: cf
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name=jellyfin-web --branch=${{ inputs.branch }}
compose-comment:
name: Compose and push comment 📝
# Always run so the comment is composed for the workflow summary
if: ${{ always() }}
uses: ./.github/workflows/__job_messages.yml
secrets: inherit
needs:
- cf-pages
with:
branch: ${{ inputs.branch }}
commit: ${{ inputs.commit }}
preview_url: ${{ needs.cf-pages.outputs.url }}
in_progress: false
comment: ${{ inputs.comment }}

View file

@ -1,4 +1,4 @@
name: Job messages name: Job messages ⚙️
on: on:
workflow_call: workflow_call:
@ -12,32 +12,26 @@ on:
preview_url: preview_url:
required: false required: false
type: string type: string
build_workflow_run_id:
required: false
type: number
commenting_workflow_run_id:
required: true
type: string
in_progress: in_progress:
required: true required: true
type: boolean type: boolean
outputs: comment:
msg: required: false
description: The composed message type: boolean
value: ${{ jobs.msg.outputs.msg }}
marker: marker:
description: Hidden marker to detect PR comments composed by the bot description: Hidden marker to detect PR comments composed by the bot
value: "CFPages-deployment" required: false
type: string
default: "CFPages-deployment"
jobs: jobs:
msg: cf_pages_msg:
name: Deployment status name: CloudFlare Pages deployment 📃🚀
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
msg: ${{ env.msg }}
steps: steps:
- name: Compose message - name: Compose message 📃
if: ${{ always() }} if: ${{ always() }}
id: compose id: compose
env: env:
@ -45,8 +39,7 @@ jobs:
PREVIEW_URL: ${{ inputs.preview_url != '' && (inputs.branch != 'master' && inputs.preview_url || format('https://jellyfin-web.pages.dev ({0})', inputs.preview_url)) || 'Not available' }} PREVIEW_URL: ${{ inputs.preview_url != '' && (inputs.branch != 'master' && inputs.preview_url || format('https://jellyfin-web.pages.dev ({0})', inputs.preview_url)) || 'Not available' }}
DEPLOY_STATUS: ${{ inputs.in_progress && '🔄 Deploying...' || (inputs.preview_url != '' && '✅ Deployed!' || '❌ Failure. Check workflow logs for details') }} DEPLOY_STATUS: ${{ inputs.in_progress && '🔄 Deploying...' || (inputs.preview_url != '' && '✅ Deployed!' || '❌ Failure. Check workflow logs for details') }}
DEPLOYMENT_TYPE: ${{ inputs.branch != 'master' && '🔀 Preview' || '⚙️ Production' }} DEPLOYMENT_TYPE: ${{ inputs.branch != 'master' && '🔀 Preview' || '⚙️ Production' }}
BUILD_WORKFLOW_RUN: ${{ !inputs.in_progress && format('**[View build logs](https://github.com/{0}/actions/runs/{1})**', 'jellyfin/jellyfin-web', inputs.build_workflow_run_id) || '' }} WORKFLOW_RUN: ${{ !inputs.in_progress && format('**[View build logs](https://github.com/{0}/actions/runs/{1})**', github.repository, github.run_id) || '' }}
COMMENTING_WORKFLOW_RUN: ${{ format('**[View bot logs](https://github.com/{0}/actions/runs/{1})**', 'jellyfin/jellyfin-web', inputs.commenting_workflow_run_id) }}
# EOF is needed for multiline environment variables in a GitHub Actions context # EOF is needed for multiline environment variables in a GitHub Actions context
run: | run: |
echo "## Cloudflare Pages deployment" > $GITHUB_STEP_SUMMARY echo "## Cloudflare Pages deployment" > $GITHUB_STEP_SUMMARY
@ -57,9 +50,16 @@ jobs:
echo "| **Preview URL** | $PREVIEW_URL |" >> $GITHUB_STEP_SUMMARY echo "| **Preview URL** | $PREVIEW_URL |" >> $GITHUB_STEP_SUMMARY
echo "| **Type** | $DEPLOYMENT_TYPE |" >> $GITHUB_STEP_SUMMARY echo "| **Type** | $DEPLOYMENT_TYPE |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "$BUILD_WORKFLOW_RUN" >> $GITHUB_STEP_SUMMARY echo "$WORKFLOW_RUN" >> $GITHUB_STEP_SUMMARY
echo "$COMMENTING_WORKFLOW_RUN" >> $GITHUB_STEP_SUMMARY
COMPOSED_MSG=$(cat $GITHUB_STEP_SUMMARY) COMPOSED_MSG=$(cat $GITHUB_STEP_SUMMARY)
echo "msg<<EOF" >> $GITHUB_ENV echo "msg<<EOF" >> $GITHUB_ENV
echo "$COMPOSED_MSG" >> $GITHUB_ENV echo "$COMPOSED_MSG" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV echo "EOF" >> $GITHUB_ENV
- name: Push comment to Pull Request 🔼
uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1
if: ${{ inputs.comment && steps.compose.conclusion == 'success' }}
with:
github-token: ${{ secrets.JF_BOT_TOKEN }}
message: ${{ env.msg }}
comment-tag: ${{ inputs.marker }}

45
.github/workflows/__package.yml vendored Normal file
View file

@ -0,0 +1,45 @@
name: Packaging 📦
on:
workflow_call:
inputs:
commit:
required: false
type: string
jobs:
run-build-prod:
name: Run production build 🏗️
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.commit || github.sha }}
- name: Setup node environment
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: 20
cache: npm
check-latest: true
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run a production build
env:
JELLYFIN_VERSION: ${{ inputs.commit || 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@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: frontend
path: dist

61
.github/workflows/__quality_checks.yml vendored Normal file
View file

@ -0,0 +1,61 @@
name: Quality checks 👌🧪
on:
workflow_call:
inputs:
commit:
required: true
type: string
workflow_dispatch:
jobs:
dependency-review:
name: Vulnerable dependencies 🔎
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.commit }}
show-progress: false
- name: Scan
uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0
with:
## Workaround from https://github.com/actions/dependency-review-action/issues/456
## TODO: Remove when necessary
base-ref: ${{ github.event.pull_request.base.sha || 'master' }}
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
quality:
name: Run ${{ matrix.command }} 🕵️‍♂️
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
command:
- build:es-check
- lint
- stylelint
- build:check
- test
steps:
- name: Checkout ⬇️
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.commit }}
show-progress: false
- name: Setup node environment ⚙️
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: 20
cache: npm
check-latest: true
- name: Install dependencies 📦
run: npm ci --no-audit
- name: Run ${{ matrix.command }} ⚙️
run: npm run ${{ matrix.command }}

View file

@ -1,21 +0,0 @@
name: 'Automation'
on:
push:
branches:
- master
pull_request_target:
types:
- synchronize
jobs:
triage:
name: 'Merge conflict labeling'
runs-on: ubuntu-latest
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
steps:
- uses: eps1lon/actions-label-merge-conflict@6d74047dcef155976a15e4a124dde2c7fe0c5522 # v3.0.1
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 }}

View file

@ -1,75 +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:
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@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- name: Setup node environment
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
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@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
with:
name: jellyfin-web__prod
path: |
dist
pr_context:
name: Save PR context as artifact
if: ${{ always() && !cancelled() && github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
needs:
- run-build-prod
steps:
- name: Save PR context
env:
PR_BRANCH: ${{ github.ref_name }}
PR_NUMBER: ${{ github.event.number }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
run: |
echo $PR_BRANCH > PR_branch
echo $PR_NUMBER > PR_number
echo $PR_SHA > PR_sha
- name: Upload PR number as artifact
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
with:
name: PR_context
path: |
PR_branch
PR_number
PR_sha

View file

@ -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@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- name: Initialize CodeQL
uses: github/codeql-action/init@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5
with:
languages: javascript
queries: +security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5

View file

@ -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@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
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.

View file

@ -1,38 +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* ]
types:
- synchronize
jobs:
run-eslint:
name: Run eslint suggestions
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup node environment
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
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@b110ac684564c7b73e47cc223eb7a5266ec83fd3 # v4.1.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,102 +0,0 @@
name: Publish
on:
workflow_run:
workflows:
- Build
types:
- completed
jobs:
pr-context:
name: PR context
if: ${{ github.event.workflow_run.event == 'pull_request' }}
runs-on: ubuntu-latest
outputs:
branch: ${{ env.pr_branch }}
commit: ${{ env.pr_sha }}
pr_number: ${{ env.pr_number }}
steps:
- name: Get PR context
uses: dawidd6/action-download-artifact@09f2f74827fd3a8607589e5ad7f9398816f540fe # v3.1.4
id: pr_context
with:
run_id: ${{ github.event.workflow_run.id }}
name: PR_context
- name: Set PR context environment variables
if: ${{ steps.pr_context.conclusion == 'success' }}
run: |
echo "pr_branch=$(cat PR_branch)" >> $GITHUB_ENV
echo "pr_number=$(cat PR_number)" >> $GITHUB_ENV
echo "pr_sha=$(cat PR_sha)" >> $GITHUB_ENV
publish:
permissions:
contents: read
deployments: write
name: Deploy to Cloudflare Pages
if: ${{ always() }}
runs-on: ubuntu-latest
needs:
- pr-context
outputs:
url: ${{ steps.cf.outputs.url }}
steps:
- name: Download workflow artifact
uses: dawidd6/action-download-artifact@09f2f74827fd3a8607589e5ad7f9398816f540fe # v3.1.4
with:
run_id: ${{ github.event.workflow_run.id }}
name: jellyfin-web__prod
path: dist
- name: Publish
id: cf
uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # 1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: jellyfin-web
branch: ${{ needs.pr-context.outputs.branch || github.ref_name }}
directory: dist
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
compose-comment:
name: Compose comment
if: ${{ always() }}
uses: ./.github/workflows/job-messages.yml
needs:
- publish
- pr-context
with:
branch: ${{ needs.pr-context.outputs.branch || github.ref_name }}
commit: ${{ needs.pr-context.outputs.commit != '' && needs.pr-context.outputs.commit || github.event.workflow_run.head_sha }}
preview_url: ${{ needs.publish.outputs.url }}
build_workflow_run_id: ${{ github.event.workflow_run.id }}
commenting_workflow_run_id: ${{ github.run_id }}
in_progress: false
comment-status:
name: Create comment status
if: |
always() &&
github.event.workflow_run.event == 'pull_request' &&
needs.pr-context.outputs.pr_number != ''
runs-on: ubuntu-latest
needs:
- compose-comment
- pr-context
steps:
- name: Update job summary in PR comment
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
message: ${{ needs.compose-comment.outputs.msg }}
pr_number: ${{ needs.pr-context.outputs.pr_number }}
comment_tag: ${{ needs.compose-comment.outputs.marker }}
mode: recreate

100
.github/workflows/pull_request.yml vendored Normal file
View file

@ -0,0 +1,100 @@
name: Pull Request 📥
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
on:
pull_request_target:
branches:
- master
- release*
paths-ignore:
- '**/*.md'
merge_group:
jobs:
push-comment:
name: Create comments ✍️
if: ${{ always() && !cancelled() && github.repository == 'jellyfin/jellyfin-web' }}
uses: ./.github/workflows/__job_messages.yml
secrets: inherit
with:
commit: ${{ github.event.pull_request.head.sha }}
in_progress: true
comment: true
build:
name: Build 🏗️
if: ${{ always() && !cancelled() }}
uses: ./.github/workflows/__package.yml
with:
commit: ${{ github.event.pull_request.head.sha }}
automation:
name: Automation 🎛️
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
uses: ./.github/workflows/__automation.yml
secrets: inherit
quality_checks:
name: Quality checks 👌🧪
if: ${{ always() && !cancelled() }}
uses: ./.github/workflows/__quality_checks.yml
permissions: {}
with:
commit: ${{ github.event.pull_request.head.sha }}
codeql:
name: GitHub CodeQL 🔬
if: ${{ always() && !cancelled() }}
uses: ./.github/workflows/__codeql.yml
permissions:
actions: read
contents: read
security-events: write
with:
commit: ${{ github.event.pull_request.head.sha }}
deploy:
name: Deploy 🚀
uses: ./.github/workflows/__deploy.yml
if: ${{ always() && !cancelled() && needs.build.result == 'success' && github.repository == 'jellyfin/jellyfin-web' }}
needs:
- push-comment
- build
permissions:
contents: read
deployments: write
secrets: inherit
with:
# If the PR is from the master branch of a fork, append the fork's name to the branch name
branch: ${{ github.event.pull_request.head.repo.full_name != github.repository && github.event.pull_request.head.ref == 'master' && format('{0}/{1}', github.event.pull_request.head.repo.full_name, github.event.pull_request.head.ref) || github.event.pull_request.head.ref }}
comment: true
commit: ${{ github.event.pull_request.head.sha }}
run-eslint:
name: Run eslint suggestions
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup node environment
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: 20
cache: npm
check-latest: true
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run eslint
uses: CatChen/eslint-suggestion-action@3ba53ce078667d5f60a73a8005627cf95ab57dce # v4.1.9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

58
.github/workflows/push.yml vendored Normal file
View file

@ -0,0 +1,58 @@
name: Push & Release 🌍
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.ref }}
cancel-in-progress: true
on:
push:
branches:
- master
- release*
paths-ignore:
- '**/*.md'
jobs:
automation:
name: Automation 🎛️
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
uses: ./.github/workflows/__automation.yml
secrets: inherit
main:
name: 'Unstable release 🚀⚠️'
uses: ./.github/workflows/__package.yml
with:
commit: ${{ github.sha }}
quality_checks:
name: Quality checks 👌🧪
if: ${{ always() && !cancelled() }}
uses: ./.github/workflows/__quality_checks.yml
permissions: {}
with:
commit: ${{ github.sha }}
codeql:
name: GitHub CodeQL 🔬
uses: ./.github/workflows/__codeql.yml
permissions:
actions: read
contents: read
security-events: write
with:
commit: ${{ github.sha }}
deploy:
name: Deploy 🚀
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
uses: ./.github/workflows/__deploy.yml
needs:
- main
permissions:
contents: read
deployments: write
secrets: inherit
with:
branch: ${{ github.ref_name }}
comment: false

View file

@ -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@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- name: Setup node environment
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
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@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- name: Setup node environment
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
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@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- name: Setup node environment
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
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@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- name: Setup node environment
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
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@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- name: Setup node environment
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
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

View file

@ -1,28 +1,26 @@
name: Stale Check name: Scheduled tasks 🕑
on: on:
schedule: schedule:
- cron: '30 1 * * *' - cron: '30 1 * * *'
workflow_dispatch: workflow_dispatch:
permissions: permissions:
issues: write issues: write
pull-requests: write pull-requests: write
jobs: jobs:
issues: issues:
name: Check issues name: Check stale issues and PRs
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }} if: ${{ contains(github.repository, 'jellyfin/') }}
steps: steps:
- uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0 - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
with: with:
repo-token: ${{ secrets.JF_BOT_TOKEN }} repo-token: ${{ secrets.JF_BOT_TOKEN }}
operations-per-run: 75 operations-per-run: 75
# Issues receive a stale warning after 120 days and close after an additional 21 days
days-before-stale: 120 days-before-stale: 120
days-before-pr-stale: -1
days-before-close: 21 days-before-close: 21
days-before-pr-close: -1
exempt-issue-labels: regression,security,roadmap,future,feature,enhancement,confirmed exempt-issue-labels: regression,security,roadmap,future,feature,enhancement,confirmed
stale-issue-label: stale stale-issue-label: stale
stale-issue-message: |- stale-issue-message: |-
@ -31,21 +29,10 @@ jobs:
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. 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). 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 are closed after having unresolved merge conflicts for 90 days
prs-conflicts: days-before-pr-stale: 0
name: Check PRs with merge conflicts days-before-pr-close: 90
runs-on: ubuntu-latest only-pr-labels: merge conflict
if: ${{ contains(github.repository, 'jellyfin/') }} stale-pr-label: stale
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: |- close-pr-message: |-
This PR has been closed due to having unresolved merge conflicts. This PR has been closed due to having unresolved merge conflicts.

View file

@ -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@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
with:
ref: master
token: ${{ secrets.JF_BOT_TOKEN }}
- name: Set up Node.js
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
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@6d6857d36972b65feb161a90e484f2984215f83e # v6.0.5
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

9
.gitignore vendored
View file

@ -3,6 +3,9 @@ dist
web web
node_modules node_modules
# test coverage
coverage
# config # config
config.json config.json
@ -10,12 +13,6 @@ config.json
.idea .idea
.vs .vs
# log
yarn-error.log
# vim # vim
*.sw? *.sw?
# build artifacts
fedora/jellyfin-web-*.src.rpm
fedora/jellyfin-web-*.tar.gz

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
20

View file

@ -1,5 +1,6 @@
{ {
"plugins": [ "plugins": [
"@stylistic/stylelint-plugin",
"stylelint-no-browser-hacks/lib" "stylelint-no-browser-hacks/lib"
], ],
"rules": { "rules": {
@ -10,20 +11,20 @@
], ],
"ignore": ["after-comment"] "ignore": ["after-comment"]
} ], } ],
"at-rule-name-case": "lower", "@stylistic/at-rule-name-case": "lower",
"at-rule-name-space-after": "always-single-line", "@stylistic/at-rule-name-space-after": "always-single-line",
"at-rule-no-unknown": true, "at-rule-no-unknown": true,
"at-rule-no-vendor-prefix": true, "at-rule-no-vendor-prefix": true,
"at-rule-semicolon-newline-after": "always", "@stylistic/at-rule-semicolon-newline-after": "always",
"block-closing-brace-empty-line-before": "never", "@stylistic/block-closing-brace-empty-line-before": "never",
"block-closing-brace-newline-after": "always", "@stylistic/block-closing-brace-newline-after": "always",
"block-closing-brace-newline-before": "always-multi-line", "@stylistic/block-closing-brace-newline-before": "always-multi-line",
"block-closing-brace-space-before": "always-single-line", "@stylistic/block-closing-brace-space-before": "always-single-line",
"block-no-empty": true, "block-no-empty": true,
"block-opening-brace-newline-after": "always-multi-line", "@stylistic/block-opening-brace-newline-after": "always-multi-line",
"block-opening-brace-space-after": "always-single-line", "@stylistic/block-opening-brace-space-after": "always-single-line",
"block-opening-brace-space-before": "always", "@stylistic/block-opening-brace-space-before": "always",
"color-hex-case": "lower", "@stylistic/color-hex-case": "lower",
"color-hex-length": "short", "color-hex-length": "short",
"color-no-invalid-hex": true, "color-no-invalid-hex": true,
"comment-empty-line-before": [ "always", { "comment-empty-line-before": [ "always", {
@ -42,8 +43,8 @@
"inside-single-line-block" "inside-single-line-block"
] ]
} ], } ],
"declaration-bang-space-after": "never", "@stylistic/declaration-bang-space-after": "never",
"declaration-bang-space-before": "always", "@stylistic/declaration-bang-space-before": "always",
"declaration-block-no-duplicate-properties": [ "declaration-block-no-duplicate-properties": [
true, true,
{ {
@ -51,52 +52,52 @@
} }
], ],
"declaration-block-no-shorthand-property-overrides": true, "declaration-block-no-shorthand-property-overrides": true,
"declaration-block-semicolon-newline-after": "always-multi-line", "@stylistic/declaration-block-semicolon-newline-after": "always-multi-line",
"declaration-block-semicolon-space-after": "always-single-line", "@stylistic/declaration-block-semicolon-space-after": "always-single-line",
"declaration-block-semicolon-space-before": "never", "@stylistic/declaration-block-semicolon-space-before": "never",
"declaration-block-single-line-max-declarations": 1, "declaration-block-single-line-max-declarations": 1,
"declaration-block-trailing-semicolon": "always", "@stylistic/declaration-block-trailing-semicolon": "always",
"declaration-colon-newline-after": "always-multi-line", "@stylistic/declaration-colon-newline-after": "always-multi-line",
"declaration-colon-space-after": "always-single-line", "@stylistic/declaration-colon-space-after": "always-single-line",
"declaration-colon-space-before": "never", "@stylistic/declaration-colon-space-before": "never",
"font-family-no-duplicate-names": true, "font-family-no-duplicate-names": true,
"function-calc-no-unspaced-operator": true, "function-calc-no-unspaced-operator": true,
"function-comma-newline-after": "always-multi-line", "@stylistic/function-comma-newline-after": "always-multi-line",
"function-comma-space-after": "always-single-line", "@stylistic/function-comma-space-after": "always-single-line",
"function-comma-space-before": "never", "@stylistic/function-comma-space-before": "never",
"function-linear-gradient-no-nonstandard-direction": true, "function-linear-gradient-no-nonstandard-direction": true,
"function-max-empty-lines": 0, "@stylistic/function-max-empty-lines": 0,
"function-name-case": "lower", "function-name-case": "lower",
"function-parentheses-newline-inside": "always-multi-line", "@stylistic/function-parentheses-newline-inside": "always-multi-line",
"function-parentheses-space-inside": "never-single-line", "@stylistic/function-parentheses-space-inside": "never-single-line",
"function-whitespace-after": "always", "@stylistic/function-whitespace-after": "always",
"indentation": 4, "@stylistic/indentation": 4,
"keyframe-declaration-no-important": true, "keyframe-declaration-no-important": true,
"length-zero-no-unit": true, "length-zero-no-unit": true,
"max-empty-lines": 1, "@stylistic/max-empty-lines": 1,
"media-feature-colon-space-after": "always", "@stylistic/media-feature-colon-space-after": "always",
"media-feature-colon-space-before": "never", "@stylistic/media-feature-colon-space-before": "never",
"media-feature-name-case": "lower", "@stylistic/media-feature-name-case": "lower",
"media-feature-name-no-unknown": true, "media-feature-name-no-unknown": true,
"media-feature-name-no-vendor-prefix": true, "media-feature-name-no-vendor-prefix": true,
"media-feature-parentheses-space-inside": "never", "@stylistic/media-feature-parentheses-space-inside": "never",
"media-feature-range-operator-space-after": "always", "@stylistic/media-feature-range-operator-space-after": "always",
"media-feature-range-operator-space-before": "always", "@stylistic/media-feature-range-operator-space-before": "always",
"media-query-list-comma-newline-after": "always-multi-line", "@stylistic/media-query-list-comma-newline-after": "always-multi-line",
"media-query-list-comma-space-after": "always-single-line", "@stylistic/media-query-list-comma-space-after": "always-single-line",
"media-query-list-comma-space-before": "never", "@stylistic/media-query-list-comma-space-before": "never",
"no-descending-specificity": true, "no-descending-specificity": true,
"no-duplicate-at-import-rules": true, "no-duplicate-at-import-rules": true,
"no-duplicate-selectors": true, "no-duplicate-selectors": true,
"no-empty-source": true, "no-empty-source": true,
"no-eol-whitespace": true, "@stylistic/no-eol-whitespace": true,
"no-extra-semicolons": true, "@stylistic/no-extra-semicolons": true,
"no-invalid-double-slash-comments": true, "no-invalid-double-slash-comments": true,
"no-missing-end-of-source-newline": true, "@stylistic/no-missing-end-of-source-newline": true,
"number-leading-zero": "always", "@stylistic/number-leading-zero": "always",
"number-no-trailing-zeros": true, "@stylistic/number-no-trailing-zeros": true,
"plugin/no-browser-hacks": true, "plugin/no-browser-hacks": true,
"property-case": "lower", "@stylistic/property-case": "lower",
"property-no-unknown": [ "property-no-unknown": [
true, true,
{ {
@ -110,20 +111,20 @@
"except": ["first-nested"], "except": ["first-nested"],
"ignore": ["after-comment"] "ignore": ["after-comment"]
} ], } ],
"selector-attribute-brackets-space-inside": "never", "@stylistic/selector-attribute-brackets-space-inside": "never",
"selector-attribute-operator-space-after": "never", "@stylistic/selector-attribute-operator-space-after": "never",
"selector-attribute-operator-space-before": "never", "@stylistic/selector-attribute-operator-space-before": "never",
"selector-combinator-space-after": "always", "@stylistic/selector-combinator-space-after": "always",
"selector-combinator-space-before": "always", "@stylistic/selector-combinator-space-before": "always",
"selector-descendant-combinator-no-non-space": true, "@stylistic/selector-descendant-combinator-no-non-space": true,
"selector-list-comma-newline-after": "always", "@stylistic/selector-list-comma-newline-after": "always",
"selector-list-comma-space-before": "never", "@stylistic/selector-list-comma-space-before": "never",
"selector-max-empty-lines": 0, "@stylistic/selector-max-empty-lines": 0,
"selector-no-vendor-prefix": true, "selector-no-vendor-prefix": true,
"selector-pseudo-class-case": "lower", "@stylistic/selector-pseudo-class-case": "lower",
"selector-pseudo-class-no-unknown": true, "selector-pseudo-class-no-unknown": true,
"selector-pseudo-class-parentheses-space-inside": "never", "@stylistic/selector-pseudo-class-parentheses-space-inside": "never",
"selector-pseudo-element-case": "lower", "@stylistic/selector-pseudo-element-case": "lower",
"selector-pseudo-element-colon-notation": "double", "selector-pseudo-element-colon-notation": "double",
"selector-pseudo-element-no-unknown": [ "selector-pseudo-element-no-unknown": [
true, true,
@ -136,13 +137,13 @@
"selector-type-case": "lower", "selector-type-case": "lower",
"selector-type-no-unknown": true, "selector-type-no-unknown": true,
"string-no-newline": true, "string-no-newline": true,
"unit-case": "lower", "@stylistic/unit-case": "lower",
"unit-no-unknown": true, "unit-no-unknown": true,
"value-no-vendor-prefix": true, "value-no-vendor-prefix": true,
"value-list-comma-newline-after": "always-multi-line", "@stylistic/value-list-comma-newline-after": "always-multi-line",
"value-list-comma-space-after": "always-single-line", "@stylistic/value-list-comma-space-after": "always-single-line",
"value-list-comma-space-before": "never", "@stylistic/value-list-comma-space-before": "never",
"value-list-max-empty-lines": 0 "@stylistic/value-list-max-empty-lines": 0
}, },
"overrides": [ "overrides": [
{ {

View file

@ -1,4 +1,7 @@
{ {
"[json][typescript][typescriptreact][javascript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit" "source.fixAll.eslint": "explicit"
}, },

View file

@ -87,6 +87,15 @@
- [JPUC1143](https://github.com/Jpuc1143) - [JPUC1143](https://github.com/Jpuc1143)
- [David Angel](https://github.com/davidangel) - [David Angel](https://github.com/davidangel)
- [Pithaya](https://github.com/Pithaya) - [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)
- [Ali](https://github.com/bu3alwa)
- [K. Kyle Puchkov](https://github.com/kepper104)
- [ItsAllAboutTheCode](https://github.com/ItsAllAboutTheCode)
- [Jxiced](https://github.com/Jxiced)
## Emby Contributors ## Emby Contributors

View file

@ -73,6 +73,10 @@ Jellyfin Web is the frontend used for most of the clients available for end user
## Directory Structure ## Directory Structure
> [!NOTE]
> We are in the process of refactoring to a [new structure](https://forum.jellyfin.org/t-proposed-update-to-the-structure-of-jellyfin-web) based on [Bulletproof React](https://github.com/alan2207/bulletproof-react/blob/master/docs/project-structure.md) architecture guidelines.
> Most new code should be organized under the appropriate app directory unless it is common/shared.
``` ```
. .
└── src └── src
@ -82,19 +86,24 @@ Jellyfin Web is the frontend used for most of the clients available for end user
│   └── stable # Classic (stable) app layout and routes │   └── stable # Classic (stable) app layout and routes
├── assets # Static assets ├── assets # Static assets
├── components # Higher order visual components and React components ├── components # Higher order visual components and React components
├── controllers # Legacy page views and controllers 🧹 ├── constants # Common constant values
├── elements # Basic webcomponents and React wrappers 🧹 ├── controllers # Legacy page views and controllers 🧹 ❌
├── elements # Basic webcomponents and React equivalents 🧹
├── hooks # Custom React hooks ├── hooks # Custom React hooks
├── legacy # Polyfills for legacy browsers ├── lib # Reusable libraries
├── libraries # Third party libraries 🧹 │   ├── globalize # Custom localization library
│   ├── legacy # Polyfills for legacy browsers
│   ├── navdrawer # Navigation drawer library for classic layout
│   └── scroller # Content scrolling library
├── plugins # Client plugins ├── plugins # Client plugins
├── scripts # Random assortment of visual components and utilities 🐉 ├── scripts # Random assortment of visual components and utilities 🐉
├── strings # Translation files ├── strings # Translation files (only commit changes to en-us.json)
├── styles # Common app Sass stylesheets ├── styles # Common app Sass stylesheets
├── themes # CSS themes ├── themes # CSS themes
├── types # Common TypeScript interfaces/types ├── types # Common TypeScript interfaces/types
└── utils # Utility functions └── utils # Utility functions
``` ```
- ❌ &mdash; Deprecated, do **not** create new files here
- 🧹 &mdash; Needs cleanup - 🧹 &mdash; Needs cleanup
- 🐉 &mdash; Serious mess (Here be dragons) - 🐉 &mdash; Serious mess (Here be dragons)

View file

@ -15,8 +15,5 @@ module.exports = {
'@babel/preset-react' '@babel/preset-react'
], ],
plugins: [ plugins: [
'@babel/plugin-proposal-class-properties',
'@babel/plugin-proposal-private-methods',
'babel-plugin-dynamic-import-polyfill'
] ]
}; };

385
eslint.config.mjs Normal file
View file

@ -0,0 +1,385 @@
// @ts-check
import eslint from '@eslint/js';
import comments from '@eslint-community/eslint-plugin-eslint-comments/configs';
import compat from 'eslint-plugin-compat';
import globals from 'globals';
// @ts-expect-error Missing type definition
import importPlugin from 'eslint-plugin-import';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import reactPlugin from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import restrictedGlobals from 'confusing-browser-globals';
import sonarjs from 'eslint-plugin-sonarjs';
import stylistic from '@stylistic/eslint-plugin';
// eslint-disable-next-line import/no-unresolved
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.recommended,
// @ts-expect-error Harmless type mismatch in dependency
comments.recommended,
compat.configs['flat/recommended'],
importPlugin.flatConfigs.errors,
sonarjs.configs.recommended,
reactPlugin.configs.flat.recommended,
{
settings: {
react: {
version: 'detect'
}
}
},
jsxA11y.flatConfigs.recommended,
// Global ignores
{
ignores: [
'node_modules',
'coverage',
'dist',
'.idea',
'.vscode'
]
},
// Global style rules
{
plugins: {
'@stylistic': stylistic
},
extends: [ importPlugin.flatConfigs.typescript ],
rules: {
'array-callback-return': ['error', { 'checkForEach': true }],
'curly': ['error', 'multi-line', 'consistent'],
'default-case-last': '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-lonely-if': 'error',
'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-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',
'@typescript-eslint/no-unused-vars': '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': ['hack', 'xxx'] }],
'one-var': ['error', 'never'],
'prefer-const': ['error', { 'destructuring': 'all' }],
'prefer-promise-reject-errors': ['warn', { 'allowEmptyReject': true }],
'@typescript-eslint/prefer-for-of': 'error',
'radix': 'error',
'yoda': 'error',
'sonarjs/fixme-tag': 'warn',
'sonarjs/todo-tag': 'off',
'sonarjs/deprecation': 'warn',
'sonarjs/no-alphabetical-sort': 'warn',
'sonarjs/no-inverted-boolean-check': 'error',
'sonarjs/no-selector-parameter': 'off',
'sonarjs/pseudo-random': 'warn',
// TODO: Enable the following sonarjs rules and fix issues
'sonarjs/no-duplicate-string': 'off',
'sonarjs/no-nested-functions': 'warn',
// TODO: Replace with stylistic.configs.customize()
'@stylistic/block-spacing': 'error',
'@stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
'@stylistic/comma-dangle': ['error', 'never'],
'@stylistic/comma-spacing': 'error',
'@stylistic/eol-last': 'error',
'@stylistic/indent': ['error', 4, { 'SwitchCase': 1 }],
'@stylistic/jsx-quotes': ['error', 'prefer-single'],
'@stylistic/keyword-spacing': 'error',
'@stylistic/max-statements-per-line': 'error',
'@stylistic/no-floating-decimal': 'error',
'@stylistic/no-mixed-spaces-and-tabs': 'error',
'@stylistic/no-multi-spaces': 'error',
'@stylistic/no-multiple-empty-lines': ['error', { 'max': 1 }],
'@stylistic/no-trailing-spaces': 'error',
'@stylistic/object-curly-spacing': ['error', 'always'],
'@stylistic/operator-linebreak': ['error', 'before', { overrides: { '?': 'after', ':': 'after', '=': 'after' } }],
'@stylistic/padded-blocks': ['error', 'never'],
'@stylistic/quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': false }],
'@stylistic/semi': 'error',
'@stylistic/space-before-blocks': 'error',
'@stylistic/space-infix-ops': 'error'
}
},
// Config files use node globals
{
ignores: [ 'src' ],
languageOptions: {
globals: {
...globals.node
}
}
},
// Config files are commonjs by default
{
files: [ '**/*.{cjs,js}' ],
ignores: [ 'src' ],
languageOptions: {
sourceType: 'commonjs'
},
rules: {
'@typescript-eslint/no-require-imports': 'off'
}
},
// App files
{
files: [
'src/**/*.{js,jsx,ts,tsx}'
],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname
},
globals: {
...globals.browser,
// Tizen globals
'tizen': false,
'webapis': false,
// WebOS globals
'webOS': false,
// Dependency globals
'$': false,
'jQuery': false,
// Jellyfin globals
'ApiClient': true,
'Events': true,
'chrome': true,
'Emby': false,
'Hls': true,
'LibraryMenu': true,
'Windows': false,
// Build time definitions
__COMMIT_SHA__: false,
__JF_BUILD_VERSION__: false,
__PACKAGE_JSON_NAME__: false,
__PACKAGE_JSON_VERSION__: false,
__USE_SYSTEM_FONTS__: false,
__WEBPACK_SERVE__: false
}
},
settings: {
'import/resolver': {
node: {
extensions: [
'.js',
'.ts',
'.jsx',
'.tsx'
],
moduleDirectory: [
'node_modules',
'src'
]
}
},
polyfills: [
'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'
]
},
rules: {
// TODO: Add typescript recommended typed rules
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'default',
format: [ 'camelCase', 'PascalCase' ],
leadingUnderscore: 'allow'
},
{
selector: 'variable',
format: [ 'camelCase', 'PascalCase', 'UPPER_CASE' ],
leadingUnderscore: 'allowSingleOrDouble',
trailingUnderscore: 'allowSingleOrDouble'
},
{
selector: 'typeLike',
format: [ 'PascalCase' ]
},
{
selector: 'enumMember',
format: [ 'PascalCase', 'UPPER_CASE' ]
},
{
selector: [ 'objectLiteralProperty', 'typeProperty' ],
format: [ 'camelCase', 'PascalCase' ],
leadingUnderscore: 'allowSingleOrDouble',
trailingUnderscore: 'allowSingleOrDouble'
},
// Ignore numbers, locale strings (en-us), aria/data attributes, CSS selectors,
// and api_key parameter
{
selector: [ 'objectLiteralProperty', 'typeProperty' ],
format: null,
filter: {
regex: '[ &\\-]|^([0-9]+)$|^api_key$',
match: true
}
}
],
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/prefer-string-starts-ends-with': 'error'
}
},
// React files
{
files: [ 'src/**/*.{jsx,tsx}' ],
plugins: {
'react-hooks': reactHooks
},
rules: {
'react/jsx-filename-extension': ['error', { 'extensions': ['.jsx', '.tsx'] }],
'react/jsx-no-bind': 'error',
'react/jsx-no-useless-fragment': 'error',
'react/no-array-index-key': 'error',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn'
}
},
// Service worker
{
files: [ 'src/serviceworker.js' ],
languageOptions: {
globals: {
...globals.serviceworker
}
}
},
// Legacy JS (less strict)
{
files: [ 'src/**/*.{js,jsx}' ],
rules: {
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/no-this-alias': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'sonarjs/public-static-readonly': 'off',
// TODO: Enable the following rules and fix issues
'sonarjs/cognitive-complexity': 'off',
'sonarjs/constructor-for-side-effects': 'off',
'sonarjs/function-return-type': 'off',
'sonarjs/no-async-constructor': 'off',
'sonarjs/no-duplicate-string': 'off',
'sonarjs/no-ignored-exceptions': 'off',
'sonarjs/no-invariant-returns': 'warn',
'sonarjs/no-nested-functions': 'off',
'sonarjs/void-use': 'off'
}
}
);

20108
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,130 +1,137 @@
{ {
"name": "jellyfin-web", "name": "jellyfin-web",
"version": "10.10.0", "version": "10.11.0",
"description": "Web interface for Jellyfin", "description": "Web interface for Jellyfin",
"repository": "https://github.com/jellyfin/jellyfin-web", "repository": "https://github.com/jellyfin/jellyfin-web",
"license": "GPL-2.0-or-later", "license": "GPL-2.0-or-later",
"devDependencies": { "devDependencies": {
"@babel/core": "7.24.5", "@babel/core": "7.26.9",
"@babel/plugin-proposal-class-properties": "7.18.6", "@babel/plugin-transform-modules-umd": "7.25.9",
"@babel/plugin-proposal-private-methods": "7.18.6", "@babel/preset-env": "7.26.9",
"@babel/plugin-transform-modules-umd": "7.24.1", "@babel/preset-react": "7.26.3",
"@babel/preset-env": "7.24.5", "@eslint-community/eslint-plugin-eslint-comments": "4.4.1",
"@babel/preset-react": "7.24.1", "@eslint/js": "9.20.0",
"@stylistic/eslint-plugin": "3.1.0",
"@stylistic/stylelint-plugin": "3.1.2",
"@types/dompurify": "3.0.5",
"@types/escape-html": "1.0.4", "@types/escape-html": "1.0.4",
"@types/loadable__component": "5.13.9", "@types/loadable__component": "5.13.9",
"@types/lodash-es": "4.17.12", "@types/lodash-es": "4.17.12",
"@types/markdown-it": "14.1.1", "@types/markdown-it": "14.1.2",
"@types/react": "17.0.80", "@types/react": "18.3.11",
"@types/react-dom": "17.0.25", "@types/react-dom": "18.3.1",
"@types/react-lazy-load-image-component": "1.6.4",
"@types/sortablejs": "1.15.8", "@types/sortablejs": "1.15.8",
"@typescript-eslint/eslint-plugin": "5.62.0", "@typescript-eslint/parser": "8.24.1",
"@typescript-eslint/parser": "5.62.0",
"@uupaa/dynamic-import-polyfill": "1.0.2", "@uupaa/dynamic-import-polyfill": "1.0.2",
"autoprefixer": "10.4.19", "@vitest/coverage-v8": "3.0.5",
"babel-loader": "9.1.3", "autoprefixer": "10.4.20",
"babel-plugin-dynamic-import-polyfill": "1.0.0", "babel-loader": "9.2.1",
"clean-webpack-plugin": "4.0.0", "clean-webpack-plugin": "4.0.0",
"confusing-browser-globals": "1.0.11", "confusing-browser-globals": "1.0.11",
"copy-webpack-plugin": "12.0.2", "copy-webpack-plugin": "12.0.2",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"css-loader": "7.1.1", "css-loader": "7.1.2",
"cssnano": "7.0.1", "cssnano": "7.0.6",
"es-check": "7.1.1", "es-check": "7.2.1",
"eslint": "8.57.0", "eslint": "9.20.1",
"eslint-plugin-compat": "4.2.0", "eslint-plugin-compat": "6.0.2",
"eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-import": "2.31.0",
"eslint-plugin-import": "2.29.1", "eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-jsx-a11y": "6.8.0", "eslint-plugin-react": "7.37.4",
"eslint-plugin-react": "7.34.1", "eslint-plugin-react-hooks": "5.1.0",
"eslint-plugin-react-hooks": "4.6.2", "eslint-plugin-sonarjs": "3.0.2",
"eslint-plugin-sonarjs": "0.25.1", "expose-loader": "5.0.1",
"expose-loader": "5.0.0",
"fork-ts-checker-webpack-plugin": "9.0.2", "fork-ts-checker-webpack-plugin": "9.0.2",
"html-loader": "5.0.0", "globals": "15.15.0",
"html-webpack-plugin": "5.6.0", "html-loader": "5.1.0",
"jsdom": "24.0.0", "html-webpack-plugin": "5.6.3",
"mini-css-extract-plugin": "2.9.0", "jsdom": "25.0.1",
"postcss": "8.4.38", "mini-css-extract-plugin": "2.9.2",
"postcss": "8.5.2",
"postcss-loader": "8.1.1", "postcss-loader": "8.1.1",
"postcss-preset-env": "9.5.12", "postcss-preset-env": "10.1.4",
"postcss-scss": "4.0.9", "postcss-scss": "4.0.9",
"sass": "1.77.1", "sass": "1.85.0",
"sass-loader": "14.2.1", "sass-loader": "16.0.5",
"source-map-loader": "5.0.0", "source-map-loader": "5.0.0",
"speed-measure-webpack-plugin": "1.5.0", "speed-measure-webpack-plugin": "1.5.0",
"style-loader": "4.0.0", "style-loader": "4.0.0",
"stylelint": "15.11.0", "stylelint": "16.14.1",
"stylelint-config-rational-order": "0.1.2", "stylelint-config-rational-order": "0.1.2",
"stylelint-no-browser-hacks": "1.3.0", "stylelint-no-browser-hacks": "1.3.0",
"stylelint-order": "6.0.4", "stylelint-order": "6.0.4",
"stylelint-scss": "5.3.2", "stylelint-scss": "6.11.0",
"ts-loader": "9.5.1", "ts-loader": "9.5.2",
"typescript": "5.4.5", "typescript": "5.7.3",
"vitest": "1.6.0", "typescript-eslint": "8.24.1",
"webpack": "5.91.0", "vitest": "3.0.5",
"webpack": "5.98.0",
"webpack-bundle-analyzer": "4.10.2", "webpack-bundle-analyzer": "4.10.2",
"webpack-cli": "5.1.4", "webpack-cli": "5.1.4",
"webpack-dev-server": "5.0.4", "webpack-dev-server": "5.2.0",
"webpack-merge": "5.10.0", "webpack-merge": "6.0.1",
"worker-loader": "3.0.8" "worker-loader": "3.0.8"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "11.11.4", "@emotion/react": "11.13.3",
"@emotion/styled": "11.11.5", "@emotion/styled": "11.13.0",
"@fontsource/noto-sans": "5.0.22", "@fontsource/noto-sans": "5.1.1",
"@fontsource/noto-sans-hk": "5.0.19", "@fontsource/noto-sans-hk": "5.1.1",
"@fontsource/noto-sans-jp": "5.0.19", "@fontsource/noto-sans-jp": "5.1.1",
"@fontsource/noto-sans-kr": "5.0.19", "@fontsource/noto-sans-kr": "5.1.1",
"@fontsource/noto-sans-sc": "5.0.19", "@fontsource/noto-sans-sc": "5.1.1",
"@fontsource/noto-sans-tc": "5.0.19", "@fontsource/noto-sans-tc": "5.1.1",
"@jellyfin/libass-wasm": "4.2.1", "@jellyfin/libass-wasm": "4.2.3",
"@jellyfin/sdk": "0.0.0-unstable.202405190501", "@jellyfin/sdk": "0.0.0-unstable.202502210501",
"@loadable/component": "5.16.4", "@mui/icons-material": "5.16.14",
"@mui/icons-material": "5.15.17", "@mui/material": "5.16.14",
"@mui/material": "5.15.17", "@mui/x-date-pickers": "7.26.0",
"@mui/x-data-grid": "6.19.11", "@react-hook/resize-observer": "2.0.2",
"@react-hook/resize-observer": "1.2.6", "@tanstack/react-query": "5.62.16",
"@tanstack/react-query": "4.36.1", "@tanstack/react-query-devtools": "5.62.16",
"@tanstack/react-query-devtools": "4.36.1", "abortcontroller-polyfill": "1.7.8",
"@types/react-lazy-load-image-component": "1.6.4",
"abortcontroller-polyfill": "1.7.5",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz", "classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
"classnames": "2.5.1", "classnames": "2.5.1",
"core-js": "3.37.0", "core-js": "3.38.1",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"dompurify": "3.0.1", "dompurify": "2.5.8",
"epubjs": "0.3.93", "epubjs": "0.3.93",
"escape-html": "1.0.3", "escape-html": "1.0.3",
"fast-text-encoding": "1.0.6", "fast-text-encoding": "1.0.6",
"flv.js": "1.6.2", "flv.js": "1.6.2",
"headroom.js": "0.12.0", "headroom.js": "0.12.0",
"history": "5.3.0", "history": "5.3.0",
"hls.js": "1.5.8", "hls.js": "1.5.20",
"intersection-observer": "0.12.2", "intersection-observer": "0.12.2",
"jellyfin-apiclient": "1.11.0", "jellyfin-apiclient": "1.11.0",
"jquery": "3.7.1", "jquery": "3.7.1",
"jstree": "3.3.16", "jstree": "3.3.17",
"libarchive.js": "1.3.0", "libarchive.js": "2.0.2",
"libpgs": "0.8.1",
"lodash-es": "4.17.21", "lodash-es": "4.17.21",
"markdown-it": "14.1.0", "markdown-it": "14.1.0",
"material-design-icons-iconfont": "6.7.0", "material-design-icons-iconfont": "6.7.0",
"material-react-table": "2.13.3",
"native-promise-only": "0.8.1", "native-promise-only": "0.8.1",
"pdfjs-dist": "3.11.174", "pdfjs-dist": "3.11.174",
"react": "17.0.2", "react": "18.3.1",
"react-blurhash": "0.3.0", "react-blurhash": "0.3.0",
"react-dom": "17.0.2", "react-dom": "18.3.1",
"react-lazy-load-image-component": "1.6.0", "react-lazy-load-image-component": "1.6.2",
"react-router-dom": "6.23.1", "react-router-dom": "6.27.0",
"resize-observer-polyfill": "1.5.1", "resize-observer-polyfill": "1.5.1",
"screenfull": "6.0.2", "screenfull": "6.0.2",
"sortablejs": "1.15.2", "sortablejs": "1.15.6",
"swiper": "11.1.1", "swiper": "11.2.3",
"usehooks-ts": "3.1.0", "usehooks-ts": "3.1.1",
"webcomponents.js": "0.7.24", "webcomponents.js": "0.7.24",
"whatwg-fetch": "3.6.20" "whatwg-fetch": "3.6.20"
}, },
"optionalDependencies": {
"sass-embedded": "1.85.0"
},
"browserslist": [ "browserslist": [
"last 2 Firefox versions", "last 2 Firefox versions",
"last 2 Chrome versions", "last 2 Chrome versions",
@ -148,6 +155,7 @@
"build:development": "webpack --config webpack.dev.js", "build:development": "webpack --config webpack.dev.js",
"build:production": "cross-env NODE_ENV=\"production\" webpack --config webpack.prod.js", "build:production": "cross-env NODE_ENV=\"production\" webpack --config webpack.prod.js",
"build:check": "tsc --noEmit", "build:check": "tsc --noEmit",
"build:es-check": "npm run build:production && npm run escheck",
"escheck": "es-check", "escheck": "es-check",
"lint": "eslint \"./\"", "lint": "eslint \"./\"",
"test": "vitest --watch=false --config vite.config.ts", "test": "vitest --watch=false --config vite.config.ts",

View file

@ -1,37 +1,31 @@
import loadable from '@loadable/component';
import { ThemeProvider } from '@mui/material/styles';
import { History } from '@remix-run/router';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import React from 'react'; import React from 'react';
import { ApiProvider } from 'hooks/useApi'; import { ApiProvider } from 'hooks/useApi';
import { UserSettingsProvider } from 'hooks/useUserSettings';
import { WebConfigProvider } from 'hooks/useWebConfig'; import { WebConfigProvider } from 'hooks/useWebConfig';
import theme from 'themes/theme'; import browser from 'scripts/browser';
import { queryClient } from 'utils/query/queryClient'; import { queryClient } from 'utils/query/queryClient';
const StableAppRouter = loadable(() => import('./apps/stable/AppRouter')); import RootAppRouter from 'RootAppRouter';
const RootAppRouter = loadable(() => import('./RootAppRouter'));
const RootApp = ({ history }: Readonly<{ history: History }>) => { const useReactQueryDevtools = window.Proxy // '@tanstack/query-devtools' requires 'Proxy', which cannot be polyfilled for legacy browsers
const layoutMode = localStorage.getItem('layout'); && !browser.tv; // Don't use devtools on the TV as the navigation is weird
const isExperimentalLayout = layoutMode === 'experimental';
return ( const RootApp = () => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ApiProvider> <ApiProvider>
<UserSettingsProvider>
<WebConfigProvider> <WebConfigProvider>
<ThemeProvider theme={theme}> <RootAppRouter />
{isExperimentalLayout ?
<RootAppRouter history={history} /> :
<StableAppRouter history={history} />
}
</ThemeProvider>
</WebConfigProvider> </WebConfigProvider>
</UserSettingsProvider>
</ApiProvider> </ApiProvider>
{useReactQueryDevtools && (
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider> </QueryClientProvider>
); );
};
export default RootApp; export default RootApp;

View file

@ -1,31 +1,41 @@
import { History } from '@remix-run/router';
import React from 'react'; import React from 'react';
import { import {
RouterProvider, RouterProvider,
createHashRouter, createHashRouter,
Outlet Outlet,
useLocation
} from 'react-router-dom'; } 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 { EXPERIMENTAL_APP_ROUTES } from 'apps/experimental/routes/routes';
import { STABLE_APP_ROUTES } from 'apps/stable/routes/routes';
import AppHeader from 'components/AppHeader'; import AppHeader from 'components/AppHeader';
import Backdrop from 'components/Backdrop'; import Backdrop from 'components/Backdrop';
import { useLegacyRouterSync } from 'hooks/useLegacyRouterSync'; import BangRedirect from 'components/router/BangRedirect';
import { DASHBOARD_APP_ROUTES } from 'apps/dashboard/routes/routes'; import { createRouterHistory } from 'components/router/routerHistory';
import UserThemeProvider from 'themes/UserThemeProvider';
const layoutMode = localStorage.getItem('layout');
const isExperimentalLayout = layoutMode === 'experimental';
const router = createHashRouter([ const router = createHashRouter([
{ {
element: <RootAppLayout />, element: <RootAppLayout />,
children: [ children: [
...EXPERIMENTAL_APP_ROUTES, ...(isExperimentalLayout ? EXPERIMENTAL_APP_ROUTES : STABLE_APP_ROUTES),
...DASHBOARD_APP_ROUTES ...DASHBOARD_APP_ROUTES,
{
path: '!/*',
Component: BangRedirect
}
] ]
} }
]); ]);
export default function RootAppRouter({ history }: Readonly<{ history: History}>) { export const history = createRouterHistory(router);
useLegacyRouterSync({ router, history });
export default function RootAppRouter() {
return <RouterProvider router={router} />; return <RouterProvider router={router} />;
} }
@ -34,12 +44,16 @@ export default function RootAppRouter({ history }: Readonly<{ history: History}>
* NOTE: The app will crash if these get removed from the DOM. * NOTE: The app will crash if these get removed from the DOM.
*/ */
function RootAppLayout() { function RootAppLayout() {
const location = useLocation();
const isNewLayoutPath = Object.values(DASHBOARD_APP_PATHS)
.some(path => location.pathname.startsWith(`/${path}`));
return ( return (
<> <UserThemeProvider>
<Backdrop /> <Backdrop />
<AppHeader isHidden /> <AppHeader isHidden={isExperimentalLayout || isNewLayoutPath} />
<Outlet /> <Outlet />
</> </UserThemeProvider>
); );
} }

6
src/apiclient.d.ts vendored
View file

@ -182,7 +182,7 @@ declare module 'jellyfin-apiclient' {
getPluginConfiguration(id: string): Promise<any>; getPluginConfiguration(id: string): Promise<any>;
getPublicSystemInfo(): Promise<PublicSystemInfo>; getPublicSystemInfo(): Promise<PublicSystemInfo>;
getPublicUsers(): Promise<UserDto[]>; getPublicUsers(): Promise<UserDto[]>;
getQuickConnect(verb: string): Promise<void|boolean|number|QuickConnectResult|QuickConnectState>; getQuickConnect(verb: string): Promise<void | boolean | number | QuickConnectResult | QuickConnectState>;
getReadySyncItems(deviceId: string): Promise<any>; getReadySyncItems(deviceId: string): Promise<any>;
getRecordingFolders(userId: string): Promise<BaseItemDtoQueryResult>; getRecordingFolders(userId: string): Promise<BaseItemDtoQueryResult>;
getRegistrationInfo(feature: string): Promise<any>; getRegistrationInfo(feature: string): Promise<any>;
@ -308,7 +308,7 @@ declare module 'jellyfin-apiclient' {
class AppStore { class AppStore {
constructor(); constructor();
getItem(name: string): string|null; getItem(name: string): string | null;
removeItem(name: string): void; removeItem(name: string): void;
setItem(name: string, value: string): void; setItem(name: string, value: string): void;
} }
@ -329,7 +329,7 @@ declare module 'jellyfin-apiclient' {
connectToServer(server: any, options?: any): Promise<any>; connectToServer(server: any, options?: any): Promise<any>;
connectToServers(servers: any[], options?: any): Promise<any>; connectToServers(servers: any[], options?: any): Promise<any>;
deleteServer(serverId: string): Promise<void>; deleteServer(serverId: string): Promise<void>;
getApiClient(item: BaseItemDto|string): ApiClient; getApiClient(item: BaseItemDto | string): ApiClient;
getApiClients(): ApiClient[]; getApiClients(): ApiClient[];
getAvailableServers(): any[]; getAvailableServers(): any[];
getOrCreateApiClient(serverId: string): ApiClient; getOrCreateApiClient(serverId: string): ApiClient;

View file

@ -2,7 +2,9 @@ import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { type Theme } from '@mui/material/styles'; import { type Theme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery'; import useMediaQuery from '@mui/material/useMediaQuery';
import React, { FC, useCallback, useState } from 'react'; import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import React, { FC, StrictMode, useCallback, useEffect, useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom'; import { Outlet, useLocation } from 'react-router-dom';
import AppBody from 'components/AppBody'; import AppBody from 'components/AppBody';
@ -10,34 +12,45 @@ import AppToolbar from 'components/toolbar/AppToolbar';
import ElevationScroll from 'components/ElevationScroll'; import ElevationScroll from 'components/ElevationScroll';
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer'; import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
import { useApi } from 'hooks/useApi'; import { useApi } from 'hooks/useApi';
import { useLocale } from 'hooks/useLocale';
import AppTabs from './components/AppTabs'; import AppTabs from './components/AppTabs';
import AppDrawer from './components/drawer/AppDrawer'; import AppDrawer from './components/drawer/AppDrawer';
import HelpButton from './components/toolbar/HelpButton';
import { DASHBOARD_APP_PATHS } from './routes/routes';
import './AppOverrides.scss'; import './AppOverrides.scss';
interface AppLayoutProps { const DRAWERLESS_PATHS = [ DASHBOARD_APP_PATHS.MetadataManager ];
drawerlessPaths: string[]
}
const AppLayout: FC<AppLayoutProps> = ({ export const Component: FC = () => {
drawerlessPaths
}) => {
const [ isDrawerActive, setIsDrawerActive ] = useState(false); const [ isDrawerActive, setIsDrawerActive ] = useState(false);
const location = useLocation(); const location = useLocation();
const { user } = useApi(); const { user } = useApi();
const { dateFnsLocale } = useLocale();
const isMediumScreen = useMediaQuery((t: Theme) => t.breakpoints.up('md')); const isMediumScreen = useMediaQuery((t: Theme) => t.breakpoints.up('md'));
const isDrawerAvailable = Boolean(user) const isDrawerAvailable = Boolean(user)
&& !drawerlessPaths.some(path => location.pathname.startsWith(`/${path}`)); && !DRAWERLESS_PATHS.some(path => location.pathname.startsWith(`/${path}`));
const isDrawerOpen = isDrawerActive && isDrawerAvailable; const isDrawerOpen = isDrawerActive && isDrawerAvailable;
const onToggleDrawer = useCallback(() => { const onToggleDrawer = useCallback(() => {
setIsDrawerActive(!isDrawerActive); setIsDrawerActive(!isDrawerActive);
}, [ isDrawerActive, setIsDrawerActive ]); }, [ isDrawerActive, setIsDrawerActive ]);
// Update body class
useEffect(() => {
document.body.classList.add('dashboardDocument');
return () => {
document.body.classList.remove('dashboardDocument');
};
}, []);
return ( return (
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={dateFnsLocale}>
<Box sx={{ display: 'flex' }}> <Box sx={{ display: 'flex' }}>
<StrictMode>
<ElevationScroll elevate={false}> <ElevationScroll elevate={false}>
<AppBar <AppBar
position='fixed' position='fixed'
@ -56,6 +69,9 @@ const AppLayout: FC<AppLayoutProps> = ({
isDrawerAvailable={!isMediumScreen && isDrawerAvailable} isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
isDrawerOpen={isDrawerOpen} isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onToggleDrawer} onDrawerButtonClick={onToggleDrawer}
buttons={
<HelpButton />
}
> >
<AppTabs isDrawerOpen={isDrawerOpen} /> <AppTabs isDrawerOpen={isDrawerOpen} />
</AppToolbar> </AppToolbar>
@ -71,6 +87,7 @@ const AppLayout: FC<AppLayoutProps> = ({
/> />
) )
} }
</StrictMode>
<Box <Box
component='main' component='main'
@ -84,7 +101,6 @@ const AppLayout: FC<AppLayoutProps> = ({
</AppBody> </AppBody>
</Box> </Box>
</Box> </Box>
</LocalizationProvider>
); );
}; };
export default AppLayout;

View file

@ -5,10 +5,14 @@ $mui-bp-md: 900px;
$mui-bp-lg: 1200px; $mui-bp-lg: 1200px;
$mui-bp-xl: 1536px; $mui-bp-xl: 1536px;
$drawer-width: 240px;
// Fix dashboard pages layout to work with drawer // Fix dashboard pages layout to work with drawer
.dashboardDocument { .dashboardDocument {
.mainAnimatedPage { .mainAnimatedPage:not(.metadataEditorPage) {
position: relative; @media all and (min-width: $mui-bp-md) {
left: $drawer-width;
}
} }
.skinBody { .skinBody {
@ -27,4 +31,8 @@ $mui-bp-xl: 1536px;
padding-top: 3.25rem; padding-top: 3.25rem;
} }
} }
.metadataEditorPage {
padding-top: 3.25rem !important;
}
} }

View file

@ -0,0 +1,35 @@
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
import type { SxProps, Theme } from '@mui/material';
import IconButton from '@mui/material/IconButton/IconButton';
import React, { type FC } from 'react';
import { Link } from 'react-router-dom';
import UserAvatar from 'components/UserAvatar';
interface UserAvatarButtonProps {
user?: UserDto
sx?: SxProps<Theme>
}
const UserAvatarButton: FC<UserAvatarButtonProps> = ({
user,
sx
}) => (
user?.Id ? (
<IconButton
size='large'
color='inherit'
sx={{
padding: 0,
...sx
}}
title={user.Name || undefined}
component={Link}
to={`/dashboard/users/profile?userId=${user.Id}`}
>
<UserAvatar user={user} />
</IconButton>
) : undefined
);
export default UserAvatarButton;

View file

@ -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;

View file

@ -29,8 +29,8 @@ const AppDrawer: FC<ResponsiveDrawerProps> = ({
<ServerDrawerSection /> <ServerDrawerSection />
<DevicesDrawerSection /> <DevicesDrawerSection />
<LiveTvDrawerSection /> <LiveTvDrawerSection />
<AdvancedDrawerSection />
<PluginDrawerSection /> <PluginDrawerSection />
<AdvancedDrawerSection />
</ResponsiveDrawer> </ResponsiveDrawer>
); );

View file

@ -1,36 +1,18 @@
import Article from '@mui/icons-material/Article'; import Article from '@mui/icons-material/Article';
import EditNotifications from '@mui/icons-material/EditNotifications';
import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';
import Extension from '@mui/icons-material/Extension';
import Lan from '@mui/icons-material/Lan'; import Lan from '@mui/icons-material/Lan';
import Schedule from '@mui/icons-material/Schedule'; import Schedule from '@mui/icons-material/Schedule';
import VpnKey from '@mui/icons-material/VpnKey'; import VpnKey from '@mui/icons-material/VpnKey';
import Collapse from '@mui/material/Collapse';
import List from '@mui/material/List'; import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem'; import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader'; import ListSubheader from '@mui/material/ListSubheader';
import React from 'react'; import React from 'react';
import { useLocation } from 'react-router-dom';
import ListItemLink from 'components/ListItemLink'; import ListItemLink from 'components/ListItemLink';
import globalize from 'scripts/globalize'; import globalize from 'lib/globalize';
const PLUGIN_PATHS = [
'/dashboard/plugins',
'/dashboard/plugins/catalog',
'/dashboard/plugins/repositories',
'/dashboard/plugins/add',
'/configurationpage'
];
const AdvancedDrawerSection = () => { const AdvancedDrawerSection = () => {
const location = useLocation();
const isPluginSectionOpen = PLUGIN_PATHS.includes(location.pathname);
return ( return (
<List <List
aria-labelledby='advanced-subheader' aria-labelledby='advanced-subheader'
@ -64,36 +46,6 @@ const AdvancedDrawerSection = () => {
<ListItemText primary={globalize.translate('TabLogs')} /> <ListItemText primary={globalize.translate('TabLogs')} />
</ListItemLink> </ListItemLink>
</ListItem> </ListItem>
<ListItem disablePadding>
<ListItemLink to='/dashboard/notifications'>
<ListItemIcon>
<EditNotifications />
</ListItemIcon>
<ListItemText primary={globalize.translate('Notifications')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/dashboard/plugins' selected={false}>
<ListItemIcon>
<Extension />
</ListItemIcon>
<ListItemText primary={globalize.translate('TabPlugins')} />
{isPluginSectionOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemLink>
</ListItem>
<Collapse in={isPluginSectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding>
<ListItemLink to='/dashboard/plugins' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabMyPlugins')} />
</ListItemLink>
<ListItemLink to='/dashboard/plugins/catalog' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabCatalog')} />
</ListItemLink>
<ListItemLink to='/dashboard/plugins/repositories' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabRepositories')} />
</ListItemLink>
</List>
</Collapse>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/dashboard/tasks'> <ListItemLink to='/dashboard/tasks'>
<ListItemIcon> <ListItemIcon>

View file

@ -1,4 +1,4 @@
import { Devices, Analytics, Input } from '@mui/icons-material'; import { Devices, Analytics } from '@mui/icons-material';
import List from '@mui/material/List'; import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem'; import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemIcon from '@mui/material/ListItemIcon';
@ -7,7 +7,7 @@ import ListSubheader from '@mui/material/ListSubheader';
import React from 'react'; import React from 'react';
import ListItemLink from 'components/ListItemLink'; import ListItemLink from 'components/ListItemLink';
import globalize from 'scripts/globalize'; import globalize from 'lib/globalize';
const DevicesDrawerSection = () => { const DevicesDrawerSection = () => {
return ( return (
@ -35,14 +35,6 @@ const DevicesDrawerSection = () => {
<ListItemText primary={globalize.translate('HeaderActivity')} /> <ListItemText primary={globalize.translate('HeaderActivity')} />
</ListItemLink> </ListItemLink>
</ListItem> </ListItem>
<ListItem disablePadding>
<ListItemLink to='/dashboard/dlna'>
<ListItemIcon>
<Input />
</ListItemIcon>
<ListItemText primary={'DLNA'} />
</ListItemLink>
</ListItem>
</List> </List>
); );
}; };

View file

@ -7,7 +7,7 @@ import ListSubheader from '@mui/material/ListSubheader';
import React from 'react'; import React from 'react';
import ListItemLink from 'components/ListItemLink'; import ListItemLink from 'components/ListItemLink';
import globalize from 'scripts/globalize'; import globalize from 'lib/globalize';
const LiveTvDrawerSection = () => { const LiveTvDrawerSection = () => {
return ( return (

View file

@ -1,41 +1,26 @@
import { ConfigurationPageInfo } from '@jellyfin/sdk/lib/generated-client'; import Extension from '@mui/icons-material/Extension';
import { getDashboardApi } from '@jellyfin/sdk/lib/utils/api/dashboard-api'; import Folder from '@mui/icons-material/Folder';
import { Folder } from '@mui/icons-material'; import Public from '@mui/icons-material/Public';
import List from '@mui/material/List'; import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader'; import ListSubheader from '@mui/material/ListSubheader';
import React, { useEffect, useState } from 'react'; import React, { useEffect } from 'react';
import ListItemLink from 'components/ListItemLink'; import ListItemLink from 'components/ListItemLink';
import { useApi } from 'hooks/useApi'; import globalize from 'lib/globalize';
import globalize from 'scripts/globalize';
import Dashboard from 'utils/dashboard'; import Dashboard from 'utils/dashboard';
import { useConfigurationPages } from 'apps/dashboard/features/plugins/api/useConfigurationPages';
const PluginDrawerSection = () => { const PluginDrawerSection = () => {
const { api } = useApi(); const {
const [ pagesInfo, setPagesInfo ] = useState<ConfigurationPageInfo[]>([]); data: pagesInfo,
error
} = useConfigurationPages({ enableInMainMenu: true });
useEffect(() => { useEffect(() => {
const fetchPluginPages = async () => { if (error) console.error('[PluginDrawerSection] unable to fetch plugin config pages', error);
if (!api) return; }, [ error ]);
const pagesResponse = await getDashboardApi(api)
.getConfigurationPages({ enableInMainMenu: true });
setPagesInfo(pagesResponse.data);
};
fetchPluginPages()
.catch(err => {
console.error('[PluginDrawerSection] unable to fetch plugin config pages', err);
});
}, [ api ]);
if (!api || pagesInfo.length < 1) {
return null;
}
return ( return (
<List <List
@ -46,19 +31,39 @@ const PluginDrawerSection = () => {
</ListSubheader> </ListSubheader>
} }
> >
{ <ListItemLink
pagesInfo.map(pageInfo => ( to='/dashboard/plugins'
<ListItem key={pageInfo.PluginId} disablePadding> includePaths={[ '/configurationpage' ]}
<ListItemLink to={`/${Dashboard.getPluginUrl(pageInfo.Name)}`}> 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> <ListItemIcon>
{/* TODO: Support different icons? */} {/* TODO: Support different icons? */}
<Folder /> <Folder />
</ListItemIcon> </ListItemIcon>
<ListItemText primary={pageInfo.DisplayName} /> <ListItemText primary={pageInfo.DisplayName} />
</ListItemLink> </ListItemLink>
</ListItem> ))}
))
}
</List> </List>
); );
}; };

View file

@ -1,15 +1,17 @@
import { Dashboard, ExpandLess, ExpandMore, LibraryAdd, People, PlayCircle, Settings } from '@mui/icons-material'; import { Dashboard, ExpandLess, ExpandMore, LibraryAdd, People, PlayCircle, Settings } from '@mui/icons-material';
import Palette from '@mui/icons-material/Palette';
import Collapse from '@mui/material/Collapse'; import Collapse from '@mui/material/Collapse';
import List from '@mui/material/List'; import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem'; import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader'; import ListSubheader from '@mui/material/ListSubheader';
import React from 'react'; import React, { type MouseEvent, useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import ListItemLink from 'components/ListItemLink'; import ListItemLink from 'components/ListItemLink';
import globalize from 'scripts/globalize'; import globalize from 'lib/globalize';
const LIBRARY_PATHS = [ const LIBRARY_PATHS = [
'/dashboard/libraries', '/dashboard/libraries',
@ -28,8 +30,20 @@ const PLAYBACK_PATHS = [
const ServerDrawerSection = () => { const ServerDrawerSection = () => {
const location = useLocation(); const location = useLocation();
const isLibrarySectionOpen = LIBRARY_PATHS.includes(location.pathname); const [ isLibrarySectionOpen, setIsLibrarySectionOpen ] = useState(LIBRARY_PATHS.includes(location.pathname));
const isPlaybackSectionOpen = PLAYBACK_PATHS.includes(location.pathname); const [ isPlaybackSectionOpen, setIsPlaybackSectionOpen ] = useState(PLAYBACK_PATHS.includes(location.pathname));
const onLibrarySectionClick = useCallback((e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsLibrarySectionOpen(isOpen => !isOpen);
}, []);
const onPlaybackSectionClick = useCallback((e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsPlaybackSectionOpen(isOpen => !isOpen);
}, []);
return ( return (
<List <List
@ -56,6 +70,12 @@ const ServerDrawerSection = () => {
<ListItemText primary={globalize.translate('General')} /> <ListItemText primary={globalize.translate('General')} />
</ListItemLink> </ListItemLink>
</ListItem> </ListItem>
<ListItemLink to='/dashboard/branding'>
<ListItemIcon>
<Palette />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderBranding')} />
</ListItemLink>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/dashboard/users'> <ListItemLink to='/dashboard/users'>
<ListItemIcon> <ListItemIcon>
@ -65,13 +85,13 @@ const ServerDrawerSection = () => {
</ListItemLink> </ListItemLink>
</ListItem> </ListItem>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/dashboard/libraries' selected={false}> <ListItemButton onClick={onLibrarySectionClick}>
<ListItemIcon> <ListItemIcon>
<LibraryAdd /> <LibraryAdd />
</ListItemIcon> </ListItemIcon>
<ListItemText primary={globalize.translate('HeaderLibraries')} /> <ListItemText primary={globalize.translate('HeaderLibraries')} />
{isLibrarySectionOpen ? <ExpandLess /> : <ExpandMore />} {isLibrarySectionOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemLink> </ListItemButton>
</ListItem> </ListItem>
<Collapse in={isLibrarySectionOpen} timeout='auto' unmountOnExit> <Collapse in={isLibrarySectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding> <List component='div' disablePadding>
@ -82,7 +102,7 @@ const ServerDrawerSection = () => {
<ListItemText inset primary={globalize.translate('Display')} /> <ListItemText inset primary={globalize.translate('Display')} />
</ListItemLink> </ListItemLink>
<ListItemLink to='/dashboard/libraries/metadata' sx={{ pl: 4 }}> <ListItemLink to='/dashboard/libraries/metadata' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('Metadata')} /> <ListItemText inset primary={globalize.translate('LabelMetadata')} />
</ListItemLink> </ListItemLink>
<ListItemLink to='/dashboard/libraries/nfo' sx={{ pl: 4 }}> <ListItemLink to='/dashboard/libraries/nfo' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabNfoSettings')} /> <ListItemText inset primary={globalize.translate('TabNfoSettings')} />
@ -90,13 +110,13 @@ const ServerDrawerSection = () => {
</List> </List>
</Collapse> </Collapse>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemLink to='/dashboard/playback/transcoding' selected={false}> <ListItemButton onClick={onPlaybackSectionClick}>
<ListItemIcon> <ListItemIcon>
<PlayCircle /> <PlayCircle />
</ListItemIcon> </ListItemIcon>
<ListItemText primary={globalize.translate('TitlePlayback')} /> <ListItemText primary={globalize.translate('TitlePlayback')} />
{isPlaybackSectionOpen ? <ExpandLess /> : <ExpandMore />} {isPlaybackSectionOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemLink> </ListItemButton>
</ListItem> </ListItem>
<Collapse in={isPlaybackSectionOpen} timeout='auto' unmountOnExit> <Collapse in={isPlaybackSectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding> <List component='div' disablePadding>

View file

@ -0,0 +1,17 @@
import format from 'date-fns/format';
import type { MRT_Cell, MRT_RowData } from 'material-react-table';
import { FC } from 'react';
import { useLocale } from 'hooks/useLocale';
interface CellProps {
cell: MRT_Cell<MRT_RowData>
}
const DateTimeCell: FC<CellProps> = ({ cell }) => {
const { dateFnsLocale } = useLocale();
return format(cell.getValue<Date>(), 'Pp', { locale: dateFnsLocale });
};
export default DateTimeCell;

View file

@ -0,0 +1,72 @@
import Box from '@mui/material/Box/Box';
import Stack from '@mui/material/Stack/Stack';
import Typography from '@mui/material/Typography/Typography';
import { type MRT_RowData, type MRT_TableInstance, MaterialReactTable } from 'material-react-table';
import React from 'react';
import Page, { type PageProps } from 'components/Page';
interface TablePageProps<T extends MRT_RowData> extends PageProps {
title: string
subtitle?: string
table: MRT_TableInstance<T>
}
export const DEFAULT_TABLE_OPTIONS = {
// Enable custom features
enableColumnPinning: true,
enableColumnResizing: true,
// Sticky header/footer
enableStickyFooter: true,
enableStickyHeader: true,
muiTableContainerProps: {
sx: {
maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer
}
}
};
const TablePage = <T extends MRT_RowData>({
title,
subtitle,
table,
children,
...pageProps
}: TablePageProps<T>) => {
return (
<Page
title={title}
{...pageProps}
>
<Box
className='content-primary'
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%'
}}
>
<Stack
spacing={2}
sx={{
marginBottom: 1
}}
>
<Typography variant='h2'>
{title}
</Typography>
{subtitle && (
<Typography>
{subtitle}
</Typography>
)}
</Stack>
<MaterialReactTable table={table} />
</Box>
{children}
</Page>
);
};
export default TablePage;

View file

@ -0,0 +1,36 @@
import HelpOutline from '@mui/icons-material/HelpOutline';
import IconButton from '@mui/material/IconButton/IconButton';
import Tooltip from '@mui/material/Tooltip/Tooltip';
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { HelpLinks } from 'apps/dashboard/constants/helpLinks';
import globalize from 'lib/globalize';
const HelpButton = () => (
<Routes>
{
HelpLinks.map(({ paths, url }) => paths.map(path => (
<Route
key={[url, path].join('-')}
path={path}
element={
<Tooltip title={globalize.translate('Help')}>
<IconButton
href={url}
rel='noopener noreferrer'
target='_blank'
size='large'
color='inherit'
>
<HelpOutline />
</IconButton>
</Tooltip>
}
/>
))).flat()
}
</Routes>
);
export default HelpButton;

View file

@ -0,0 +1,54 @@
export const HelpLinks = [
{
paths: ['/dashboard/devices'],
url: 'https://jellyfin.org/docs/general/server/devices'
}, {
paths: ['/dashboard/libraries'],
url: 'https://jellyfin.org/docs/general/server/libraries'
}, {
paths: [
'/dashboard/livetv',
'/dashboard/livetv/tuner',
'/dashboard/recordings'
],
url: 'https://jellyfin.org/docs/general/server/live-tv/'
}, {
paths: ['/dashboard/livetv/guide'],
url: 'https://jellyfin.org/docs/general/server/live-tv/setup-guide#adding-guide-data'
}, {
paths: ['/dashboard/networking'],
url: 'https://jellyfin.org/docs/general/networking/'
}, {
paths: ['/dashboard/playback/transcoding'],
url: 'https://jellyfin.org/docs/general/server/transcoding'
}, {
paths: [
'/dashboard/plugins',
'/dashboard/plugins/catalog'
],
url: 'https://jellyfin.org/docs/general/server/plugins/'
}, {
paths: ['/dashboard/plugins/repositories'],
url: 'https://jellyfin.org/docs/general/server/plugins/#repositories'
}, {
paths: [
'/dashboard/branding',
'/dashboard/settings'
],
url: 'https://jellyfin.org/docs/general/server/settings'
}, {
paths: ['/dashboard/tasks'],
url: 'https://jellyfin.org/docs/general/server/tasks'
}, {
paths: ['/dashboard/users'],
url: 'https://jellyfin.org/docs/general/server/users/adding-managing-users'
}, {
paths: [
'/dashboard/users/access',
'/dashboard/users/parentalcontrol',
'/dashboard/users/password',
'/dashboard/users/profile'
],
url: 'https://jellyfin.org/docs/general/server/users/'
}
];

View file

@ -1,4 +1,4 @@
<div id="dashboardPage" data-role="page" class="page type-interior dashboardHomePage fullWidthContent"> <div id="dashboardPage" data-role="page" class="page type-interior dashboardHomePage fullWidthContent" data-title="${TabDashboard}">
<div class="content-primary"> <div class="content-primary">
<div class="dashboardSections" style="padding-top:.5em;"> <div class="dashboardSections" style="padding-top:.5em;">
<div class="dashboardColumn dashboardColumn-2-60 dashboardColumn-3-46"> <div class="dashboardColumn dashboardColumn-2-60 dashboardColumn-3-46">
@ -23,10 +23,10 @@
<button is="emby-button" type="button" class="raised btnRefresh"> <button is="emby-button" type="button" class="raised btnRefresh">
<span>${ButtonScanAllLibraries}</span> <span>${ButtonScanAllLibraries}</span>
</button> </button>
<button is="emby-button" type="button" id="btnRestartServer" class="raised" onclick="DashboardPage.restart(this);"> <button is="emby-button" type="button" id="btnRestartServer" class="raised">
<span>${Restart}</span> <span>${Restart}</span>
</button> </button>
<button is="emby-button" type="button" id="btnShutdown" class="raised" onclick="DashboardPage.shutdown(this);"> <button is="emby-button" type="button" id="btnShutdown" class="raised">
<span>${ButtonShutdown}</span> <span>${ButtonShutdown}</span>
</button> </button>
</div> </div>

View file

@ -1,36 +1,36 @@
import escapeHtml from 'escape-html'; import escapeHtml from 'escape-html';
import datetime from '../../scripts/datetime'; import datetime from 'scripts/datetime';
import Events from '../../utils/events.ts'; import Events from 'utils/events.ts';
import itemHelper from '../../components/itemHelper'; import itemHelper from 'components/itemHelper';
import serverNotifications from '../../scripts/serverNotifications'; import serverNotifications from 'scripts/serverNotifications';
import dom from '../../scripts/dom'; import dom from 'scripts/dom';
import globalize from '../../scripts/globalize'; import globalize from 'lib/globalize';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { getLocaleWithSuffix } from '../../utils/dateFnsLocale.ts'; import { getLocaleWithSuffix } from 'utils/dateFnsLocale.ts';
import loading from '../../components/loading/loading'; import loading from 'components/loading/loading';
import playMethodHelper from '../../components/playback/playmethodhelper'; import playMethodHelper from 'components/playback/playmethodhelper';
import cardBuilder from '../../components/cardbuilder/cardBuilder'; import cardBuilder from 'components/cardbuilder/cardBuilder';
import imageLoader from '../../components/images/imageLoader'; import imageLoader from 'components/images/imageLoader';
import ActivityLog from '../../components/activitylog'; import ActivityLog from 'components/activitylog';
import imageHelper from '../../utils/image'; import imageHelper from 'utils/image';
import indicators from '../../components/indicators/indicators'; import indicators from 'components/indicators/indicators';
import taskButton from '../../scripts/taskbutton'; import taskButton from 'scripts/taskbutton';
import Dashboard from '../../utils/dashboard'; import Dashboard from 'utils/dashboard';
import ServerConnections from '../../components/ServerConnections'; import ServerConnections from 'components/ServerConnections';
import alert from '../../components/alert'; import alert from 'components/alert';
import confirm from '../../components/confirm/confirm'; import confirm from 'components/confirm/confirm';
import { getDefaultBackgroundClass } from '../../components/cardbuilder/cardBuilderUtils'; import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
import { getSystemInfoQuery } from 'hooks/useSystemInfo'; import { getSystemInfoQuery } from 'hooks/useSystemInfo';
import { toApi } from 'utils/jellyfin-apiclient/compat'; import { toApi } from 'utils/jellyfin-apiclient/compat';
import { queryClient } from 'utils/query/queryClient'; import { queryClient } from 'utils/query/queryClient';
import '../../elements/emby-button/emby-button'; import 'elements/emby-button/emby-button';
import '../../elements/emby-itemscontainer/emby-itemscontainer'; import 'elements/emby-itemscontainer/emby-itemscontainer';
import '../../components/listview/listview.scss'; import 'components/listview/listview.scss';
import '../../styles/flexstyles.scss'; import 'styles/flexstyles.scss';
import './dashboard.scss'; import './dashboard.scss';
function showPlaybackInfo(btn, session) { function showPlaybackInfo(btn, session) {
@ -72,7 +72,7 @@ function showPlaybackInfo(btn, session) {
} }
function showSendMessageForm(btn, session) { function showSendMessageForm(btn, session) {
import('../../components/prompt/prompt').then(({ default: prompt }) => { import('components/prompt/prompt').then(({ default: prompt }) => {
prompt({ prompt({
title: globalize.translate('HeaderSendMessage'), title: globalize.translate('HeaderSendMessage'),
label: globalize.translate('LabelMessageText'), label: globalize.translate('LabelMessageText'),
@ -89,7 +89,7 @@ function showSendMessageForm(btn, session) {
} }
function showOptionsMenu(btn, session) { function showOptionsMenu(btn, session) {
import('../../components/actionSheet/actionSheet').then(({ default: actionsheet }) => { import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
const menuItems = []; const menuItems = [];
if (session.ServerId && session.DeviceId !== ServerConnections.deviceId()) { if (session.ServerId && session.DeviceId !== ServerConnections.deviceId()) {
@ -208,7 +208,12 @@ function refreshActiveRecordings(view, apiClient) {
function reloadSystemInfo(view, apiClient) { function reloadSystemInfo(view, apiClient) {
view.querySelector('#buildVersion').innerText = __JF_BUILD_VERSION__; view.querySelector('#buildVersion').innerText = __JF_BUILD_VERSION__;
view.querySelector('#webVersion').innerText = __PACKAGE_JSON_VERSION__;
let webVersion = __PACKAGE_JSON_VERSION__;
if (__COMMIT_SHA__) {
webVersion += ` (${__COMMIT_SHA__})`;
}
view.querySelector('#webVersion').innerText = webVersion;
queryClient queryClient
.fetchQuery(getSystemInfoQuery(toApi(apiClient))) .fetchQuery(getSystemInfoQuery(toApi(apiClient)))
@ -319,7 +324,7 @@ function renderActiveConnections(view, sessions) {
html += '<div class="sessionCardButtons flex align-items-center justify-content-center">'; html += '<div class="sessionCardButtons flex align-items-center justify-content-center">';
let btnCssClass = session.ServerId && session.NowPlayingItem && session.SupportsRemoteControl ? '' : ' hide'; let btnCssClass = session.ServerId && session.NowPlayingItem && session.SupportsRemoteControl ? '' : ' hide';
const playIcon = session.PlayState.IsPaused ? 'pause' : 'play_arrow'; const playIcon = session.PlayState.IsPaused ? 'play_arrow' : 'pause';
html += '<button is="paper-icon-button-light" class="sessionCardButton btnSessionPlayPause paper-icon-button-light ' + btnCssClass + '"><span class="material-icons ' + playIcon + '" aria-hidden="true"></span></button>'; html += '<button is="paper-icon-button-light" class="sessionCardButton btnSessionPlayPause paper-icon-button-light ' + btnCssClass + '"><span class="material-icons ' + playIcon + '" aria-hidden="true"></span></button>';
html += '<button is="paper-icon-button-light" class="sessionCardButton btnSessionStop paper-icon-button-light ' + btnCssClass + '"><span class="material-icons stop" aria-hidden="true"></span></button>'; html += '<button is="paper-icon-button-light" class="sessionCardButton btnSessionStop paper-icon-button-light ' + btnCssClass + '"><span class="material-icons stop" aria-hidden="true"></span></button>';
@ -389,7 +394,7 @@ function renderRunningTasks(view, tasks) {
view.querySelector('#divRunningTasks').innerHTML = html; view.querySelector('#divRunningTasks').innerHTML = html;
} }
window.DashboardPage = { const DashboardPage = {
startInterval: function (apiClient) { startInterval: function (apiClient) {
apiClient.sendMessage('SessionsStart', '0,1500'); apiClient.sendMessage('SessionsStart', '0,1500');
apiClient.sendMessage('ScheduledTasksInfoStart', '0,1000'); apiClient.sendMessage('ScheduledTasksInfoStart', '0,1000');
@ -709,33 +714,38 @@ window.DashboardPage = {
pollForInfo(page, ApiClient); pollForInfo(page, ApiClient);
}); });
}, },
restart: function (btn) { restart: function (event) {
confirm({ confirm({
title: globalize.translate('Restart'), title: globalize.translate('Restart'),
text: globalize.translate('MessageConfirmRestart'), text: globalize.translate('MessageConfirmRestart'),
confirmText: globalize.translate('Restart'), confirmText: globalize.translate('Restart'),
primary: 'delete' primary: 'delete'
}).then(function () { }).then(() => {
const page = dom.parentWithClass(btn, 'page'); const page = dom.parentWithClass(event.target, 'page');
page.querySelector('#btnRestartServer').disabled = true; page.querySelector('#btnRestartServer').disabled = true;
page.querySelector('#btnShutdown').disabled = true; page.querySelector('#btnShutdown').disabled = true;
ApiClient.restartServer(); ApiClient.restartServer();
}).catch(() => {
// Confirm dialog closed
}); });
}, },
shutdown: function (btn) { shutdown: function (event) {
confirm({ confirm({
title: globalize.translate('ButtonShutdown'), title: globalize.translate('ButtonShutdown'),
text: globalize.translate('MessageConfirmShutdown'), text: globalize.translate('MessageConfirmShutdown'),
confirmText: globalize.translate('ButtonShutdown'), confirmText: globalize.translate('ButtonShutdown'),
primary: 'delete' primary: 'delete'
}).then(function () { }).then(() => {
const page = dom.parentWithClass(btn, 'page'); const page = dom.parentWithClass(event.target, 'page');
page.querySelector('#btnRestartServer').disabled = true; page.querySelector('#btnRestartServer').disabled = true;
page.querySelector('#btnShutdown').disabled = true; page.querySelector('#btnShutdown').disabled = true;
ApiClient.shutdownServer(); ApiClient.shutdownServer();
}).catch(() => {
// Confirm dialog closed
}); });
} }
}; };
export default function (view) { export default function (view) {
function onRestartRequired(evt, apiClient) { function onRestartRequired(evt, apiClient) {
console.debug('onRestartRequired not implemented', evt, apiClient); console.debug('onRestartRequired not implemented', evt, apiClient);
@ -811,7 +821,11 @@ export default function (view) {
taskKey: 'RefreshLibrary', taskKey: 'RefreshLibrary',
button: page.querySelector('.btnRefresh') button: page.querySelector('.btnRefresh')
}); });
page.querySelector('#btnRestartServer').addEventListener('click', DashboardPage.restart);
page.querySelector('#btnShutdown').addEventListener('click', DashboardPage.shutdown);
}); });
view.addEventListener('viewbeforehide', function () { view.addEventListener('viewbeforehide', function () {
const apiClient = ApiClient; const apiClient = ApiClient;
const page = this; const page = this;
@ -833,6 +847,9 @@ export default function (view) {
taskKey: 'RefreshLibrary', taskKey: 'RefreshLibrary',
button: page.querySelector('.btnRefresh') button: page.querySelector('.btnRefresh')
}); });
page.querySelector('#btnRestartServer').removeEventListener('click', DashboardPage.restart);
page.querySelector('#btnShutdown').removeEventListener('click', DashboardPage.shutdown);
}); });
view.addEventListener('viewdestroy', function () { view.addEventListener('viewdestroy', function () {
const page = this; const page = this;

View file

@ -1,17 +1,16 @@
<div id="encodingSettingsPage" data-role="page" class="page type-interior playbackConfigurationPage"> <div id="encodingSettingsPage" data-role="page" class="page type-interior playbackConfigurationPage" data-title="${TitlePlayback}">
<div> <div>
<div class="content-primary"> <div class="content-primary">
<form class="encodingSettingsForm"> <form class="encodingSettingsForm">
<div class="verticalSection"> <div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center"> <div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${Transcoding}</h2> <h2 class="sectionTitle">${Transcoding}</h2>
<a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton" target="_blank" href="https://jellyfin.org/docs/general/server/transcoding">${Help}</a>
</div> </div>
</div> </div>
<div class="selectContainer"> <div class="selectContainer">
<select is="emby-select" id="selectVideoDecoder" label="${LabelHardwareAccelerationType}"> <select is="emby-select" id="selectVideoDecoder" label="${LabelHardwareAccelerationType}">
<option value="">${None}</option> <option value="none">${None}</option>
<option value="amf">AMD AMF</option> <option value="amf">AMD AMF</option>
<option value="nvenc">Nvidia NVENC</option> <option value="nvenc">Nvidia NVENC</option>
<option value="qsv">Intel QuickSync (QSV)</option> <option value="qsv">Intel QuickSync (QSV)</option>
@ -30,6 +29,11 @@
<div class="fieldDescription">${LabelVaapiDeviceHelp}</div> <div class="fieldDescription">${LabelVaapiDeviceHelp}</div>
</div> </div>
<div class="inputContainer hide fldQsvDevice">
<input is="emby-input" type="text" id="txtQsvDevice" label="${LabelQsvDevice}" />
<div class="fieldDescription">${LabelQsvDeviceHelp}</div>
</div>
<div class="hardwareAccelerationOptions hide"> <div class="hardwareAccelerationOptions hide">
<div class="checkboxListContainer decodingCodecsList"> <div class="checkboxListContainer decodingCodecsList">
<h3 class="checkboxListLabel">${LabelEnableHardwareDecodingFor}</h3> <h3 class="checkboxListLabel">${LabelEnableHardwareDecodingFor}</h3>
@ -81,6 +85,16 @@
<span>VP9 10bit</span> <span>VP9 10bit</span>
</label> </label>
</div> </div>
<div class="checkboxList hide fldHevcRextHwDecoding">
<label>
<input type="checkbox" is="emby-checkbox" id="chkDecodingColorDepth10HevcRext" />
<span>HEVC RExt 8/10bit</span>
</label>
<label>
<input type="checkbox" is="emby-checkbox" id="chkDecodingColorDepth12HevcRext" />
<span>HEVC RExt 12bit</span>
</label>
</div>
</div> </div>
<div class="checkboxListContainer hide fldEnhancedNvdec"> <div class="checkboxListContainer hide fldEnhancedNvdec">
@ -168,12 +182,13 @@
</div> </div>
<div class="tonemappingOptions hide"> <div class="tonemappingOptions hide">
<div class="checkboxListContainer checkboxContainer-withDescription"> <div class="checkboxListContainer checkboxContainer-withDescription fldTonemapCheckbox hide">
<label> <label>
<input type="checkbox" is="emby-checkbox" id="chkTonemapping" /> <input type="checkbox" is="emby-checkbox" id="chkTonemapping" />
<span>${EnableTonemapping}</span> <span>${EnableTonemapping}</span>
</label> </label>
<div class="fieldDescription checkboxFieldDescription">${AllowTonemappingHelp}</div> <div class="fieldDescription checkboxFieldDescription allowTonemappingHardwareHelp">${AllowTonemappingHelp}</div>
<div class="fieldDescription checkboxFieldDescription allowTonemappingSoftwareHelp">${AllowTonemappingSoftwareHelp}</div>
</div> </div>
<div class="selectContainer"> <div class="selectContainer">
<select is="emby-select" id="selectTonemappingAlgorithm" label="${LabelTonemappingAlgorithm}"> <select is="emby-select" id="selectTonemappingAlgorithm" label="${LabelTonemappingAlgorithm}">
@ -190,11 +205,13 @@
<a is="emby-linkbutton" rel="noopener noreferrer" class="button-link" href="http://ffmpeg.org/ffmpeg-all.html#tonemap_005fopencl" target="_blank">${TonemappingAlgorithmHelp}</a> <a is="emby-linkbutton" rel="noopener noreferrer" class="button-link" href="http://ffmpeg.org/ffmpeg-all.html#tonemap_005fopencl" target="_blank">${TonemappingAlgorithmHelp}</a>
</div> </div>
</div> </div>
<div class="selectContainer"> <div class="tonemappingModeOptions selectContainer">
<select is="emby-select" id="selectTonemappingMode" label="${LabelTonemappingMode}"> <select is="emby-select" id="selectTonemappingMode" label="${LabelTonemappingMode}">
<option value="auto">${Auto}</option> <option value="auto">${Auto}</option>
<option value="max">MAX</option> <option value="max">MAX</option>
<option value="rgb">RGB</option> <option value="rgb">RGB</option>
<option value="lum">LUM</option>
<option value="itp">ITP</option>
</select> </select>
<div class="fieldDescription">${TonemappingModeHelp}</div> <div class="fieldDescription">${TonemappingModeHelp}</div>
</div> </div>
@ -297,6 +314,8 @@
<option value="None">${None}</option> <option value="None">${None}</option>
<option value="Dave750">Dave750</option> <option value="Dave750">Dave750</option>
<option value="NightmodeDialogue">NightmodeDialogue</option> <option value="NightmodeDialogue">NightmodeDialogue</option>
<option value="Rfc7845">RFC7845</option>
<option value="Ac4">AC-4</option>
</select> </select>
<div class="fieldDescription">${StereoDownmixAlgorithmHelp}</div> <div class="fieldDescription">${StereoDownmixAlgorithmHelp}</div>
</div> </div>
@ -307,7 +326,7 @@
<div class="selectContainer"> <div class="selectContainer">
<select is="emby-select" id="selectEncoderPreset" label="${LabelEncoderPreset}"> <select is="emby-select" id="selectEncoderPreset" label="${LabelEncoderPreset}">
<option value="">${Auto}</option> <option value="auto">${Auto}</option>
<option value="veryslow">veryslow</option> <option value="veryslow">veryslow</option>
<option value="slower">slower</option> <option value="slower">slower</option>
<option value="slow">slow</option> <option value="slow">slow</option>

View file

@ -1,9 +1,9 @@
import 'jquery'; import 'jquery';
import loading from '../../components/loading/loading'; import loading from 'components/loading/loading';
import globalize from '../../scripts/globalize'; import globalize from 'lib/globalize';
import dom from '../../scripts/dom'; import dom from 'scripts/dom';
import Dashboard from '../../utils/dashboard'; import Dashboard from 'utils/dashboard';
import alert from '../../components/alert'; import alert from 'components/alert';
function loadPage(page, config, systemInfo) { function loadPage(page, config, systemInfo) {
Array.prototype.forEach.call(page.querySelectorAll('.chkDecodeCodec'), function (c) { Array.prototype.forEach.call(page.querySelectorAll('.chkDecodeCodec'), function (c) {
@ -11,6 +11,8 @@ function loadPage(page, config, systemInfo) {
}); });
page.querySelector('#chkDecodingColorDepth10Hevc').checked = config.EnableDecodingColorDepth10Hevc; page.querySelector('#chkDecodingColorDepth10Hevc').checked = config.EnableDecodingColorDepth10Hevc;
page.querySelector('#chkDecodingColorDepth10Vp9').checked = config.EnableDecodingColorDepth10Vp9; page.querySelector('#chkDecodingColorDepth10Vp9').checked = config.EnableDecodingColorDepth10Vp9;
page.querySelector('#chkDecodingColorDepth10HevcRext').checked = config.EnableDecodingColorDepth10HevcRext;
page.querySelector('#chkDecodingColorDepth12HevcRext').checked = config.EnableDecodingColorDepth12HevcRext;
page.querySelector('#chkEnhancedNvdecDecoder').checked = config.EnableEnhancedNvdecDecoder; page.querySelector('#chkEnhancedNvdecDecoder').checked = config.EnableEnhancedNvdecDecoder;
page.querySelector('#chkSystemNativeHwDecoder').checked = config.PreferSystemNativeHwDecoder; page.querySelector('#chkSystemNativeHwDecoder').checked = config.PreferSystemNativeHwDecoder;
page.querySelector('#chkIntelLpH264HwEncoder').checked = config.EnableIntelLowPowerH264HwEncoder; page.querySelector('#chkIntelLpH264HwEncoder').checked = config.EnableIntelLowPowerH264HwEncoder;
@ -18,32 +20,33 @@ function loadPage(page, config, systemInfo) {
page.querySelector('#chkHardwareEncoding').checked = config.EnableHardwareEncoding; page.querySelector('#chkHardwareEncoding').checked = config.EnableHardwareEncoding;
page.querySelector('#chkAllowHevcEncoding').checked = config.AllowHevcEncoding; page.querySelector('#chkAllowHevcEncoding').checked = config.AllowHevcEncoding;
page.querySelector('#chkAllowAv1Encoding').checked = config.AllowAv1Encoding; page.querySelector('#chkAllowAv1Encoding').checked = config.AllowAv1Encoding;
$('#selectVideoDecoder', page).val(config.HardwareAccelerationType); page.querySelector('#selectVideoDecoder').value = config.HardwareAccelerationType || 'none';
$('#selectThreadCount', page).val(config.EncodingThreadCount); page.querySelector('#selectThreadCount').value = config.EncodingThreadCount;
page.querySelector('#chkEnableAudioVbr').checked = config.EnableAudioVbr; page.querySelector('#chkEnableAudioVbr').checked = config.EnableAudioVbr;
$('#txtDownMixAudioBoost', page).val(config.DownMixAudioBoost); page.querySelector('#txtDownMixAudioBoost').value = config.DownMixAudioBoost;
$('#selectStereoDownmixAlgorithm').val(config.DownMixStereoAlgorithm || 'None'); page.querySelector('#selectStereoDownmixAlgorithm').value = config.DownMixStereoAlgorithm || 'None';
page.querySelector('#txtMaxMuxingQueueSize').value = config.MaxMuxingQueueSize || ''; page.querySelector('#txtMaxMuxingQueueSize').value = config.MaxMuxingQueueSize || '';
page.querySelector('.txtEncoderPath').value = config.EncoderAppPathDisplay || ''; page.querySelector('.txtEncoderPath').value = config.EncoderAppPathDisplay || '';
$('#txtTranscodingTempPath', page).val(systemInfo.TranscodingTempPath || ''); page.querySelector('#txtTranscodingTempPath').value = systemInfo.TranscodingTempPath || '';
page.querySelector('#txtFallbackFontPath').value = config.FallbackFontPath || ''; page.querySelector('#txtFallbackFontPath').value = config.FallbackFontPath || '';
page.querySelector('#chkEnableFallbackFont').checked = config.EnableFallbackFont; page.querySelector('#chkEnableFallbackFont').checked = config.EnableFallbackFont;
$('#txtVaapiDevice', page).val(config.VaapiDevice || ''); page.querySelector('#txtVaapiDevice').value = config.VaapiDevice || '';
page.querySelector('#txtQsvDevice').value = config.QsvDevice || '';
page.querySelector('#chkTonemapping').checked = config.EnableTonemapping; page.querySelector('#chkTonemapping').checked = config.EnableTonemapping;
page.querySelector('#chkVppTonemapping').checked = config.EnableVppTonemapping; page.querySelector('#chkVppTonemapping').checked = config.EnableVppTonemapping;
page.querySelector('#chkVideoToolboxTonemapping').checked = config.EnableVideoToolboxTonemapping; page.querySelector('#chkVideoToolboxTonemapping').checked = config.EnableVideoToolboxTonemapping;
page.querySelector('#selectTonemappingAlgorithm').value = config.TonemappingAlgorithm; page.querySelector('#selectTonemappingAlgorithm').value = config.TonemappingAlgorithm || 'none';
page.querySelector('#selectTonemappingMode').value = config.TonemappingMode; page.querySelector('#selectTonemappingMode').value = config.TonemappingMode || 'auto';
page.querySelector('#selectTonemappingRange').value = config.TonemappingRange; page.querySelector('#selectTonemappingRange').value = config.TonemappingRange || 'auto';
page.querySelector('#txtTonemappingDesat').value = config.TonemappingDesat; page.querySelector('#txtTonemappingDesat').value = config.TonemappingDesat;
page.querySelector('#txtTonemappingPeak').value = config.TonemappingPeak; page.querySelector('#txtTonemappingPeak').value = config.TonemappingPeak;
page.querySelector('#txtTonemappingParam').value = config.TonemappingParam || ''; page.querySelector('#txtTonemappingParam').value = config.TonemappingParam || '';
page.querySelector('#txtVppTonemappingBrightness').value = config.VppTonemappingBrightness; page.querySelector('#txtVppTonemappingBrightness').value = config.VppTonemappingBrightness;
page.querySelector('#txtVppTonemappingContrast').value = config.VppTonemappingContrast; page.querySelector('#txtVppTonemappingContrast').value = config.VppTonemappingContrast;
page.querySelector('#selectEncoderPreset').value = config.EncoderPreset || ''; page.querySelector('#selectEncoderPreset').value = config.EncoderPreset || 'auto';
page.querySelector('#txtH264Crf').value = config.H264Crf || ''; page.querySelector('#txtH264Crf').value = config.H264Crf || '';
page.querySelector('#txtH265Crf').value = config.H265Crf || ''; page.querySelector('#txtH265Crf').value = config.H265Crf || '';
page.querySelector('#selectDeinterlaceMethod').value = config.DeinterlaceMethod || ''; page.querySelector('#selectDeinterlaceMethod').value = config.DeinterlaceMethod || 'yadif';
page.querySelector('#chkDoubleRateDeinterlacing').checked = config.DeinterlaceDoubleRate; page.querySelector('#chkDoubleRateDeinterlacing').checked = config.DeinterlaceDoubleRate;
page.querySelector('#chkEnableSubtitleExtraction').checked = config.EnableSubtitleExtraction || false; page.querySelector('#chkEnableSubtitleExtraction').checked = config.EnableSubtitleExtraction || false;
page.querySelector('#chkEnableThrottling').checked = config.EnableThrottling || false; page.querySelector('#chkEnableThrottling').checked = config.EnableThrottling || false;
@ -82,15 +85,16 @@ function onSubmit() {
loading.show(); loading.show();
ApiClient.getNamedConfiguration('encoding').then(function (config) { ApiClient.getNamedConfiguration('encoding').then(function (config) {
config.EnableAudioVbr = form.querySelector('#chkEnableAudioVbr').checked; config.EnableAudioVbr = form.querySelector('#chkEnableAudioVbr').checked;
config.DownMixAudioBoost = $('#txtDownMixAudioBoost', form).val(); config.DownMixAudioBoost = form.querySelector('#txtDownMixAudioBoost').value;
config.DownMixStereoAlgorithm = $('#selectStereoDownmixAlgorithm', form).val() || 'None'; config.DownMixStereoAlgorithm = form.querySelector('#selectStereoDownmixAlgorithm').value || 'None';
config.MaxMuxingQueueSize = form.querySelector('#txtMaxMuxingQueueSize').value; config.MaxMuxingQueueSize = form.querySelector('#txtMaxMuxingQueueSize').value;
config.TranscodingTempPath = $('#txtTranscodingTempPath', form).val(); config.TranscodingTempPath = form.querySelector('#txtTranscodingTempPath').value;
config.FallbackFontPath = form.querySelector('#txtFallbackFontPath').value; config.FallbackFontPath = form.querySelector('#txtFallbackFontPath').value;
config.EnableFallbackFont = form.querySelector('#txtFallbackFontPath').value ? form.querySelector('#chkEnableFallbackFont').checked : false; config.EnableFallbackFont = form.querySelector('#txtFallbackFontPath').value ? form.querySelector('#chkEnableFallbackFont').checked : false;
config.EncodingThreadCount = $('#selectThreadCount', form).val(); config.EncodingThreadCount = form.querySelector('#selectThreadCount').value;
config.HardwareAccelerationType = $('#selectVideoDecoder', form).val(); config.HardwareAccelerationType = form.querySelector('#selectVideoDecoder').value;
config.VaapiDevice = $('#txtVaapiDevice', form).val(); config.VaapiDevice = form.querySelector('#txtVaapiDevice').value;
config.QsvDevice = form.querySelector('#txtQsvDevice').value;
config.EnableTonemapping = form.querySelector('#chkTonemapping').checked; config.EnableTonemapping = form.querySelector('#chkTonemapping').checked;
config.EnableVppTonemapping = form.querySelector('#chkVppTonemapping').checked; config.EnableVppTonemapping = form.querySelector('#chkVppTonemapping').checked;
config.EnableVideoToolboxTonemapping = form.querySelector('#chkVideoToolboxTonemapping').checked; config.EnableVideoToolboxTonemapping = form.querySelector('#chkVideoToolboxTonemapping').checked;
@ -119,6 +123,8 @@ function onSubmit() {
}); });
config.EnableDecodingColorDepth10Hevc = form.querySelector('#chkDecodingColorDepth10Hevc').checked; config.EnableDecodingColorDepth10Hevc = form.querySelector('#chkDecodingColorDepth10Hevc').checked;
config.EnableDecodingColorDepth10Vp9 = form.querySelector('#chkDecodingColorDepth10Vp9').checked; config.EnableDecodingColorDepth10Vp9 = form.querySelector('#chkDecodingColorDepth10Vp9').checked;
config.EnableDecodingColorDepth10HevcRext = form.querySelector('#chkDecodingColorDepth10HevcRext').checked;
config.EnableDecodingColorDepth12HevcRext = form.querySelector('#chkDecodingColorDepth12HevcRext').checked;
config.EnableEnhancedNvdecDecoder = form.querySelector('#chkEnhancedNvdecDecoder').checked; config.EnableEnhancedNvdecDecoder = form.querySelector('#chkEnhancedNvdecDecoder').checked;
config.PreferSystemNativeHwDecoder = form.querySelector('#chkSystemNativeHwDecoder').checked; config.PreferSystemNativeHwDecoder = form.querySelector('#chkSystemNativeHwDecoder').checked;
config.EnableIntelLowPowerH264HwEncoder = form.querySelector('#chkIntelLpH264HwEncoder').checked; config.EnableIntelLowPowerH264HwEncoder = form.querySelector('#chkIntelLpH264HwEncoder').checked;
@ -135,7 +141,7 @@ function onSubmit() {
}); });
}; };
if ($('#selectVideoDecoder', form).val()) { if (form.querySelector('#selectVideoDecoder').value !== 'none') {
alert({ alert({
title: globalize.translate('TitleHardwareAcceleration'), title: globalize.translate('TitleHardwareAcceleration'),
text: globalize.translate('HardwareAccelerationWarning') text: globalize.translate('HardwareAccelerationWarning')
@ -188,18 +194,34 @@ $(document).on('pageinit', '#encodingSettingsPage', function () {
page.querySelector('#txtVaapiDevice').removeAttribute('required'); page.querySelector('#txtVaapiDevice').removeAttribute('required');
} }
if (this.value == 'amf' || this.value == 'nvenc' || this.value == 'qsv' || this.value == 'vaapi' || this.value == 'rkmpp' || this.value == 'videotoolbox') { if (this.value == 'amf' || this.value == 'nvenc' || this.value == 'qsv' || this.value == 'vaapi' || this.value == 'rkmpp') {
page.querySelector('.fld10bitHevcVp9HwDecoding').classList.remove('hide'); page.querySelector('.fld10bitHevcVp9HwDecoding').classList.remove('hide');
} else { } else {
page.querySelector('.fld10bitHevcVp9HwDecoding').classList.add('hide'); page.querySelector('.fld10bitHevcVp9HwDecoding').classList.add('hide');
} }
if (this.value == 'amf' || this.value == 'nvenc' || this.value == 'qsv' || this.value == 'vaapi' || this.value == 'rkmpp' || this.value == 'videotoolbox') { if (this.value == 'nvenc' || this.value == 'qsv' || this.value == 'vaapi') {
page.querySelector('.fldHevcRextHwDecoding').classList.remove('hide');
} else {
page.querySelector('.fldHevcRextHwDecoding').classList.add('hide');
}
const isHwaSelected = [ 'amf', 'nvenc', 'qsv', 'vaapi', 'rkmpp', 'videotoolbox' ].includes(this.value);
if (this.value === 'none') {
page.querySelector('.tonemappingOptions').classList.remove('hide'); page.querySelector('.tonemappingOptions').classList.remove('hide');
page.querySelector('.fldTonemapCheckbox').classList.add('hide');
} else if (isHwaSelected) {
page.querySelector('.tonemappingOptions').classList.remove('hide');
page.querySelector('.fldTonemapCheckbox').classList.remove('hide');
} else { } else {
page.querySelector('.tonemappingOptions').classList.add('hide'); page.querySelector('.tonemappingOptions').classList.add('hide');
page.querySelector('.fldTonemapCheckbox').classList.add('hide');
} }
page.querySelector('.tonemappingModeOptions').classList.toggle('hide', !isHwaSelected);
page.querySelector('.allowTonemappingHardwareHelp').classList.toggle('hide', !isHwaSelected);
page.querySelector('.allowTonemappingSoftwareHelp').classList.toggle('hide', isHwaSelected);
if (this.value == 'qsv' || this.value == 'vaapi') { if (this.value == 'qsv' || this.value == 'vaapi') {
page.querySelector('.fldIntelLp').classList.remove('hide'); page.querySelector('.fldIntelLp').classList.remove('hide');
} else { } else {
@ -220,8 +242,10 @@ $(document).on('pageinit', '#encodingSettingsPage', function () {
if (this.value == 'qsv') { if (this.value == 'qsv') {
page.querySelector('.fldSysNativeHwDecoder').classList.remove('hide'); page.querySelector('.fldSysNativeHwDecoder').classList.remove('hide');
page.querySelector('.fldQsvDevice').classList.remove('hide');
} else { } else {
page.querySelector('.fldSysNativeHwDecoder').classList.add('hide'); page.querySelector('.fldSysNativeHwDecoder').classList.add('hide');
page.querySelector('.fldQsvDevice').classList.add('hide');
} }
if (this.value == 'nvenc') { if (this.value == 'nvenc') {
@ -230,7 +254,7 @@ $(document).on('pageinit', '#encodingSettingsPage', function () {
page.querySelector('.fldEnhancedNvdec').classList.add('hide'); page.querySelector('.fldEnhancedNvdec').classList.add('hide');
} }
if (this.value) { if (this.value !== 'none') {
page.querySelector('.hardwareAccelerationOptions').classList.remove('hide'); page.querySelector('.hardwareAccelerationOptions').classList.remove('hide');
} else { } else {
page.querySelector('.hardwareAccelerationOptions').classList.add('hide'); page.querySelector('.hardwareAccelerationOptions').classList.add('hide');
@ -239,12 +263,12 @@ $(document).on('pageinit', '#encodingSettingsPage', function () {
setDecodingCodecsVisible(page, this.value); setDecodingCodecsVisible(page, this.value);
}); });
$('#btnSelectTranscodingTempPath', page).on('click.selectDirectory', function () { $('#btnSelectTranscodingTempPath', page).on('click.selectDirectory', function () {
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => { import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser(); const picker = new DirectoryBrowser();
picker.show({ picker.show({
callback: function (path) { callback: function (path) {
if (path) { if (path) {
$('#txtTranscodingTempPath', page).val(path); page.querySelector('#txtTranscodingTempPath').value = path;
} }
picker.close(); picker.close();
@ -256,7 +280,7 @@ $(document).on('pageinit', '#encodingSettingsPage', function () {
}); });
}); });
$('#btnSelectFallbackFontPath', page).on('click.selectDirectory', function () { $('#btnSelectFallbackFontPath', page).on('click.selectDirectory', function () {
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => { import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser(); const picker = new DirectoryBrowser();
picker.show({ picker.show({
includeDirectories: true, includeDirectories: true,

View file

@ -1,11 +1,10 @@
<div id="dashboardGeneralPage" data-role="page" class="page type-interior dashboardHomePage"> <div id="dashboardGeneralPage" data-role="page" class="page type-interior dashboardHomePage" data-title="${General}">
<div> <div>
<div class="content-primary"> <div class="content-primary">
<form class="dashboardGeneralForm"> <form class="dashboardGeneralForm">
<div class="verticalSection"> <div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center"> <div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${Settings}</h2> <h2 class="sectionTitle">${Settings}</h2>
<a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton" target="_blank" href="https://jellyfin.org/docs/general/server/settings">${Help}</a>
</div> </div>
</div> </div>
@ -62,24 +61,6 @@
</label> </label>
</div> </div>
<div class="verticalSection">
<h2>${HeaderBranding}</h2>
<div class="inputContainer">
<textarea is="emby-textarea" id="txtLoginDisclaimer" label="${LabelLoginDisclaimer}" class="textarea-mono"></textarea>
<div class="fieldDescription">${LabelLoginDisclaimerHelp}</div>
</div>
<div class="inputContainer customCssContainer">
<textarea is="emby-textarea" id="txtCustomCss" label="${LabelCustomCss}" class="textarea-mono"></textarea>
<div class="fieldDescription">${LabelCustomCssHelp}</div>
</div>
<div class="checkboxList paperList" style="padding:.5em 1em;">
<label>
<input type="checkbox" is="emby-checkbox" id="chkSplashScreenAvailable" />
<span>${EnableSplashScreen}</span>
</label>
</div>
</div>
<div class="verticalSection"> <div class="verticalSection">
<h2>${HeaderPerformance}</h2> <h2>${HeaderPerformance}</h2>
<div class="inputContainer"> <div class="inputContainer">

View file

@ -1,24 +1,26 @@
import 'jquery'; import 'jquery';
import loading from '../../components/loading/loading'; import loading from 'components/loading/loading';
import globalize from '../../scripts/globalize'; import globalize from 'lib/globalize';
import '../../elements/emby-checkbox/emby-checkbox'; import 'elements/emby-checkbox/emby-checkbox';
import '../../elements/emby-textarea/emby-textarea'; import 'elements/emby-textarea/emby-textarea';
import '../../elements/emby-input/emby-input'; import 'elements/emby-input/emby-input';
import '../../elements/emby-select/emby-select'; import 'elements/emby-select/emby-select';
import '../../elements/emby-button/emby-button'; import 'elements/emby-button/emby-button';
import Dashboard from '../../utils/dashboard'; import Dashboard from 'utils/dashboard';
import alert from '../../components/alert'; import alert from 'components/alert';
function loadPage(page, config, languageOptions, systemInfo) { function loadPage(page, config, languageOptions, systemInfo) {
page.querySelector('#txtServerName').value = systemInfo.ServerName; page.querySelector('#txtServerName').value = systemInfo.ServerName;
page.querySelector('#txtCachePath').value = systemInfo.CachePath || ''; page.querySelector('#txtCachePath').value = systemInfo.CachePath || '';
page.querySelector('#chkQuickConnectAvailable').checked = config.QuickConnectAvailable === true; page.querySelector('#chkQuickConnectAvailable').checked = config.QuickConnectAvailable === true;
$('#txtMetadataPath', page).val(systemInfo.InternalMetadataPath || ''); page.querySelector('#txtMetadataPath').value = systemInfo.InternalMetadataPath || '';
$('#txtMetadataNetworkPath', page).val(systemInfo.MetadataNetworkPath || ''); page.querySelector('#txtMetadataNetworkPath').value = systemInfo.MetadataNetworkPath || '';
$('#selectLocalizationLanguage', page).html(languageOptions.map(function (language) { const localizationLanguageElem = page.querySelector('#selectLocalizationLanguage');
localizationLanguageElem.innerHTML = languageOptions.map(function (language) {
return '<option value="' + language.Value + '">' + language.Name + '</option>'; return '<option value="' + language.Value + '">' + language.Name + '</option>';
})).val(config.UICulture); }).join('');
localizationLanguageElem.value = config.UICulture;
page.querySelector('#txtLibraryScanFanoutConcurrency').value = config.LibraryScanFanoutConcurrency || ''; page.querySelector('#txtLibraryScanFanoutConcurrency').value = config.LibraryScanFanoutConcurrency || '';
page.querySelector('#txtParallelImageEncodingLimit').value = config.ParallelImageEncodingLimit || ''; page.querySelector('#txtParallelImageEncodingLimit').value = config.ParallelImageEncodingLimit || '';
@ -28,39 +30,30 @@ function loadPage(page, config, languageOptions, systemInfo) {
function onSubmit() { function onSubmit() {
loading.show(); loading.show();
const form = this; const form = this;
$(form).parents('.page');
ApiClient.getServerConfiguration().then(function (config) { ApiClient.getServerConfiguration().then(function (config) {
config.ServerName = $('#txtServerName', form).val(); config.ServerName = form.querySelector('#txtServerName').value;
config.UICulture = $('#selectLocalizationLanguage', form).val(); config.UICulture = form.querySelector('#selectLocalizationLanguage').value;
config.CachePath = form.querySelector('#txtCachePath').value; config.CachePath = form.querySelector('#txtCachePath').value;
config.MetadataPath = $('#txtMetadataPath', form).val(); config.MetadataPath = form.querySelector('#txtMetadataPath').value;
config.MetadataNetworkPath = $('#txtMetadataNetworkPath', form).val(); config.MetadataNetworkPath = form.querySelector('#txtMetadataNetworkPath').value;
config.QuickConnectAvailable = form.querySelector('#chkQuickConnectAvailable').checked; config.QuickConnectAvailable = form.querySelector('#chkQuickConnectAvailable').checked;
config.LibraryScanFanoutConcurrency = parseInt(form.querySelector('#txtLibraryScanFanoutConcurrency').value || '0', 10); config.LibraryScanFanoutConcurrency = parseInt(form.querySelector('#txtLibraryScanFanoutConcurrency').value || '0', 10);
config.ParallelImageEncodingLimit = parseInt(form.querySelector('#txtParallelImageEncodingLimit').value || '0', 10); config.ParallelImageEncodingLimit = parseInt(form.querySelector('#txtParallelImageEncodingLimit').value || '0', 10);
ApiClient.updateServerConfiguration(config).then(function() { return ApiClient.updateServerConfiguration(config)
ApiClient.getNamedConfiguration(brandingConfigKey).then(function(brandingConfig) { .then(() => {
brandingConfig.LoginDisclaimer = form.querySelector('#txtLoginDisclaimer').value;
brandingConfig.CustomCss = form.querySelector('#txtCustomCss').value;
brandingConfig.SplashscreenEnabled = form.querySelector('#chkSplashScreenAvailable').checked;
ApiClient.updateNamedConfiguration(brandingConfigKey, brandingConfig).then(function () {
Dashboard.processServerConfigurationUpdateResult(); Dashboard.processServerConfigurationUpdateResult();
}); }).catch(() => {
}); loading.hide();
}, function () {
alert(globalize.translate('ErrorDefault')); alert(globalize.translate('ErrorDefault'));
Dashboard.processServerConfigurationUpdateResult();
}); });
}); });
return false; return false;
} }
const brandingConfigKey = 'branding';
export default function (view) { export default function (view) {
$('#btnSelectCachePath', view).on('click.selectDirectory', function () { $('#btnSelectCachePath', view).on('click.selectDirectory', function () {
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => { import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser(); const picker = new DirectoryBrowser();
picker.show({ picker.show({
callback: function (path) { callback: function (path) {
@ -77,18 +70,18 @@ export default function (view) {
}); });
}); });
$('#btnSelectMetadataPath', view).on('click.selectDirectory', function () { $('#btnSelectMetadataPath', view).on('click.selectDirectory', function () {
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => { import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser(); const picker = new DirectoryBrowser();
picker.show({ picker.show({
path: $('#txtMetadataPath', view).val(), path: view.querySelector('#txtMetadataPath').value,
networkSharePath: $('#txtMetadataNetworkPath', view).val(), networkSharePath: view.querySelector('#txtMetadataNetworkPath').value,
callback: function (path, networkPath) { callback: function (path, networkPath) {
if (path) { if (path) {
$('#txtMetadataPath', view).val(path); view.querySelector('#txtMetadataPath').value = path;
} }
if (networkPath) { if (networkPath) {
$('#txtMetadataNetworkPath', view).val(networkPath); view.querySelector('#txtMetadataNetworkPath').value = networkPath;
} }
picker.close(); picker.close();
@ -107,11 +100,6 @@ export default function (view) {
Promise.all([promiseConfig, promiseLanguageOptions, promiseSystemInfo]).then(function (responses) { Promise.all([promiseConfig, promiseLanguageOptions, promiseSystemInfo]).then(function (responses) {
loadPage(view, responses[0], responses[1], responses[2]); loadPage(view, responses[0], responses[1], responses[2]);
}); });
ApiClient.getNamedConfiguration(brandingConfigKey).then(function (config) {
view.querySelector('#txtLoginDisclaimer').value = config.LoginDisclaimer || '';
view.querySelector('#txtCustomCss').value = config.CustomCss || '';
view.querySelector('#chkSplashScreenAvailable').checked = config.SplashscreenEnabled === true;
});
}); });
} }

View file

@ -1,4 +1,4 @@
<div id="mediaLibraryPage" data-role="page" class="page type-interior mediaLibraryPage librarySectionPage fullWidthContent"> <div id="mediaLibraryPage" data-role="page" class="page type-interior mediaLibraryPage librarySectionPage fullWidthContent" data-title="${HeaderLibraries}">
<div> <div>
<div class="content-primary"> <div class="content-primary">
<div class="padded-top padded-bottom"> <div class="padded-top padded-bottom">
@ -6,7 +6,6 @@
<span>${ButtonScanAllLibraries}</span> <span>${ButtonScanAllLibraries}</span>
</button> </button>
<progress max="100" min="0" style="display: inline-block; vertical-align: middle;" class="refreshProgress"></progress> <progress max="100" min="0" style="display: inline-block; vertical-align: middle;" class="refreshProgress"></progress>
<a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt" target="_blank" href="https://jellyfin.org/docs/general/server/libraries">${Help}</a>
</div> </div>
<div id="divVirtualFolders"></div> <div id="divVirtualFolders"></div>

View file

@ -1,18 +1,18 @@
import escapeHtml from 'escape-html'; import escapeHtml from 'escape-html';
import 'jquery';
import taskButton from '../../scripts/taskbutton'; import taskButton from 'scripts/taskbutton';
import loading from '../../components/loading/loading'; import loading from 'components/loading/loading';
import globalize from '../../scripts/globalize'; import globalize from 'lib/globalize';
import dom from '../../scripts/dom'; import dom from 'scripts/dom';
import imageHelper from '../../utils/image'; import imageHelper from 'utils/image';
import '../../components/cardbuilder/card.scss'; import 'components/cardbuilder/card.scss';
import '../../elements/emby-itemrefreshindicator/emby-itemrefreshindicator'; import 'elements/emby-itemrefreshindicator/emby-itemrefreshindicator';
import Dashboard, { pageClassOn, pageIdOn } from '../../utils/dashboard'; import Dashboard, { pageClassOn, pageIdOn } from 'utils/dashboard';
import confirm from '../../components/confirm/confirm'; import confirm from 'components/confirm/confirm';
import { getDefaultBackgroundClass } from '../../components/cardbuilder/cardBuilderUtils'; import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
function addVirtualFolder(page) { function addVirtualFolder(page) {
import('../../components/mediaLibraryCreator/mediaLibraryCreator').then(({ default: MediaLibraryCreator }) => { import('components/mediaLibraryCreator/mediaLibraryCreator').then(({ default: MediaLibraryCreator }) => {
new MediaLibraryCreator({ new MediaLibraryCreator({
collectionTypeOptions: getCollectionTypeOptions().filter(function (f) { collectionTypeOptions: getCollectionTypeOptions().filter(function (f) {
return !f.hidden; return !f.hidden;
@ -27,7 +27,7 @@ function addVirtualFolder(page) {
} }
function editVirtualFolder(page, virtualFolder) { function editVirtualFolder(page, virtualFolder) {
import('../../components/mediaLibraryEditor/mediaLibraryEditor').then(({ default: MediaLibraryEditor }) => { import('components/mediaLibraryEditor/mediaLibraryEditor').then(({ default: MediaLibraryEditor }) => {
new MediaLibraryEditor({ new MediaLibraryEditor({
refresh: shouldRefreshLibraryAfterChanges(page), refresh: shouldRefreshLibraryAfterChanges(page),
library: virtualFolder library: virtualFolder
@ -61,7 +61,7 @@ function deleteVirtualFolder(page, virtualFolder) {
} }
function refreshVirtualFolder(page, virtualFolder) { function refreshVirtualFolder(page, virtualFolder) {
import('../../components/refreshdialog/refreshdialog').then(({ default: RefreshDialog }) => { import('components/refreshdialog/refreshdialog').then(({ default: RefreshDialog }) => {
new RefreshDialog({ new RefreshDialog({
itemIds: [virtualFolder.ItemId], itemIds: [virtualFolder.ItemId],
serverId: ApiClient.serverId(), serverId: ApiClient.serverId(),
@ -71,7 +71,7 @@ function refreshVirtualFolder(page, virtualFolder) {
} }
function renameVirtualFolder(page, virtualFolder) { function renameVirtualFolder(page, virtualFolder) {
import('../../components/prompt/prompt').then(({ default: prompt }) => { import('components/prompt/prompt').then(({ default: prompt }) => {
prompt({ prompt({
label: globalize.translate('LabelNewName'), label: globalize.translate('LabelNewName'),
description: globalize.translate('MessageRenameMediaFolder'), description: globalize.translate('MessageRenameMediaFolder'),
@ -118,7 +118,7 @@ function showCardMenu(page, elem, virtualFolders) {
icon: 'delete' icon: 'delete'
}); });
import('../../components/actionSheet/actionSheet').then((actionsheet) => { import('components/actionSheet/actionSheet').then((actionsheet) => {
actionsheet.show({ actionsheet.show({
items: menuItems, items: menuItems,
positionTo: elem, positionTo: elem,
@ -181,14 +181,20 @@ function reloadVirtualFolders(page, virtualFolders) {
divVirtualFolders.innerHTML = html; divVirtualFolders.innerHTML = html;
divVirtualFolders.classList.add('itemsContainer'); divVirtualFolders.classList.add('itemsContainer');
divVirtualFolders.classList.add('vertical-wrap'); divVirtualFolders.classList.add('vertical-wrap');
$('.btnCardMenu', divVirtualFolders).on('click', function () { const btnCardMenuElements = divVirtualFolders.querySelectorAll('.btnCardMenu');
showCardMenu(page, this, virtualFolders); btnCardMenuElements.forEach(function (btn) {
btn.addEventListener('click', function () {
showCardMenu(page, btn, virtualFolders);
});
}); });
divVirtualFolders.querySelector('#addLibrary').addEventListener('click', function () { divVirtualFolders.querySelector('#addLibrary').addEventListener('click', function () {
addVirtualFolder(page); addVirtualFolder(page);
}); });
$('.editLibrary', divVirtualFolders).on('click', function () {
const card = $(this).parents('.card')[0]; const libraryEditElements = divVirtualFolders.querySelectorAll('.editLibrary');
libraryEditElements.forEach(function (btn) {
btn.addEventListener('click', function () {
const card = dom.parentWithClass(btn, 'card');
const index = parseInt(card.getAttribute('data-index'), 10); const index = parseInt(card.getAttribute('data-index'), 10);
const virtualFolder = virtualFolders[index]; const virtualFolder = virtualFolders[index];
@ -196,11 +202,12 @@ function reloadVirtualFolders(page, virtualFolders) {
editVirtualFolder(page, virtualFolder); editVirtualFolder(page, virtualFolder);
} }
}); });
});
loading.hide(); loading.hide();
} }
function editImages(page, virtualFolder) { function editImages(page, virtualFolder) {
import('../../components/imageeditor/imageeditor').then((imageEditor) => { import('components/imageeditor/imageeditor').then((imageEditor) => {
imageEditor.show({ imageEditor.show({
itemId: virtualFolder.ItemId, itemId: virtualFolder.ItemId,
serverId: ApiClient.serverId() serverId: ApiClient.serverId()

View file

@ -1,4 +1,4 @@
<div id="libraryDisplayPage" data-role="page" class="page type-interior librarySectionPage"> <div id="libraryDisplayPage" data-role="page" class="page type-interior librarySectionPage" data-title="${Display}">
<div> <div>
<div class="content-primary"> <div class="content-primary">
<form> <form>

View file

@ -1,7 +1,7 @@
import loading from '../../components/loading/loading'; import loading from 'components/loading/loading';
import '../../elements/emby-checkbox/emby-checkbox'; import 'elements/emby-checkbox/emby-checkbox';
import '../../elements/emby-button/emby-button'; import 'elements/emby-button/emby-button';
import Dashboard from '../../utils/dashboard'; import Dashboard from 'utils/dashboard';
export default function(view) { export default function(view) {
function loadData() { function loadData() {
@ -29,7 +29,7 @@ export default function(view) {
ApiClient.updateServerConfiguration(config).then(Dashboard.processServerConfigurationUpdateResult); ApiClient.updateServerConfiguration(config).then(Dashboard.processServerConfigurationUpdateResult);
}); });
ApiClient.getNamedConfiguration('metadata').then(function(config) { ApiClient.getNamedConfiguration('metadata').then(function(config) {
config.UseFileCreationTimeForDateAdded = $('#selectDateAdded', form).val() === '1'; config.UseFileCreationTimeForDateAdded = form.querySelector('#selectDateAdded').value === '1';
ApiClient.updateNamedConfiguration('metadata', config); ApiClient.updateNamedConfiguration('metadata', config);
}); });

View file

@ -1,15 +1,15 @@
import loading from '../components/loading/loading'; import loading from 'components/loading/loading';
import globalize from '../scripts/globalize'; import globalize from 'lib/globalize';
import Dashboard, { pageIdOn } from '../utils/dashboard'; import Dashboard, { pageIdOn } from 'utils/dashboard';
import { getParameterByName } from '../utils/url.ts'; import { getParameterByName } from 'utils/url';
import Events from '../utils/events.ts'; import Events from 'utils/events';
function onListingsSubmitted() { function onListingsSubmitted() {
Dashboard.navigate('dashboard/livetv'); Dashboard.navigate('dashboard/livetv');
} }
function init(page, type, providerId) { function init(page, type, providerId) {
import(`../components/tvproviders/${type}`).then(({ default: ProviderFactory }) => { import(`components/tvproviders/${type}`).then(({ default: ProviderFactory }) => {
const instance = new ProviderFactory(page, providerId, {}); const instance = new ProviderFactory(page, providerId, {});
Events.on(instance, 'submitted', onListingsSubmitted); Events.on(instance, 'submitted', onListingsSubmitted);
instance.init(); instance.init();
@ -17,7 +17,7 @@ function init(page, type, providerId) {
} }
function loadTemplate(page, type, providerId) { function loadTemplate(page, type, providerId) {
import(`../components/tvproviders/${type}.template.html`).then(({ default: html }) => { import(`components/tvproviders/${type}.template.html`).then(({ default: html }) => {
page.querySelector('.providerTemplate').innerHTML = globalize.translateHtml(html); page.querySelector('.providerTemplate').innerHTML = globalize.translateHtml(html);
init(page, type, providerId); init(page, type, providerId);
}); });

View file

@ -1,10 +1,9 @@
<div id="liveTvSettingsPage" data-role="page" class="page type-interior liveTvPage"> <div id="liveTvSettingsPage" data-role="page" class="page type-interior liveTvPage" data-title="${HeaderDVR}">
<div> <div>
<div class="content-primary"> <div class="content-primary">
<div class="verticalSection"> <div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center"> <div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${HeaderDVR}</h2> <h2 class="sectionTitle">${HeaderDVR}</h2>
<a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton" target="_blank" href="https://jellyfin.org/docs/general/server/live-tv/">${Help}</a>
</div> </div>
</div> </div>

View file

@ -1,16 +1,17 @@
import 'jquery'; import 'jquery';
import loading from '../components/loading/loading';
import globalize from '../scripts/globalize'; import loading from 'components/loading/loading';
import '../elements/emby-button/emby-button'; import globalize from 'lib/globalize';
import Dashboard from '../utils/dashboard'; import 'elements/emby-button/emby-button';
import alert from '../components/alert'; import Dashboard from 'utils/dashboard';
import alert from 'components/alert';
function loadPage(page, config) { function loadPage(page, config) {
$('.liveTvSettingsForm', page).show(); page.querySelector('.liveTvSettingsForm').classList.remove('hide');
$('.noLiveTvServices', page).hide(); page.querySelector('.noLiveTvServices')?.classList.add('hide');
$('#selectGuideDays', page).val(config.GuideDays || ''); page.querySelector('#selectGuideDays').value = config.GuideDays || '';
$('#txtPrePaddingMinutes', page).val(config.PrePaddingSeconds / 60); page.querySelector('#txtPrePaddingMinutes').value = config.PrePaddingSeconds / 60;
$('#txtPostPaddingMinutes', page).val(config.PostPaddingSeconds / 60); page.querySelector('#txtPostPaddingMinutes').value = config.PostPaddingSeconds / 60;
page.querySelector('#txtRecordingPath').value = config.RecordingPath || ''; page.querySelector('#txtRecordingPath').value = config.RecordingPath || '';
page.querySelector('#txtMovieRecordingPath').value = config.MovieRecordingPath || ''; page.querySelector('#txtMovieRecordingPath').value = config.MovieRecordingPath || '';
page.querySelector('#txtSeriesRecordingPath').value = config.SeriesRecordingPath || ''; page.querySelector('#txtSeriesRecordingPath').value = config.SeriesRecordingPath || '';
@ -25,7 +26,7 @@ function onSubmit() {
loading.show(); loading.show();
const form = this; const form = this;
ApiClient.getNamedConfiguration('livetv').then(function (config) { ApiClient.getNamedConfiguration('livetv').then(function (config) {
config.GuideDays = $('#selectGuideDays', form).val() || null; config.GuideDays = form.querySelector('#selectGuideDays').value || null;
const recordingPath = form.querySelector('#txtRecordingPath').value || null; const recordingPath = form.querySelector('#txtRecordingPath').value || null;
const movieRecordingPath = form.querySelector('#txtMovieRecordingPath').value || null; const movieRecordingPath = form.querySelector('#txtMovieRecordingPath').value || null;
const seriesRecordingPath = form.querySelector('#txtSeriesRecordingPath').value || null; const seriesRecordingPath = form.querySelector('#txtSeriesRecordingPath').value || null;
@ -34,10 +35,10 @@ function onSubmit() {
config.MovieRecordingPath = movieRecordingPath; config.MovieRecordingPath = movieRecordingPath;
config.SeriesRecordingPath = seriesRecordingPath; config.SeriesRecordingPath = seriesRecordingPath;
config.RecordingEncodingFormat = 'mkv'; config.RecordingEncodingFormat = 'mkv';
config.PrePaddingSeconds = 60 * $('#txtPrePaddingMinutes', form).val(); config.PrePaddingSeconds = 60 * form.querySelector('#txtPrePaddingMinutes').value;
config.PostPaddingSeconds = 60 * $('#txtPostPaddingMinutes', form).val(); config.PostPaddingSeconds = 60 * form.querySelector('#txtPostPaddingMinutes').value;
config.RecordingPostProcessor = $('#txtPostProcessor', form).val(); config.RecordingPostProcessor = form.querySelector('#txtPostProcessor').value;
config.RecordingPostProcessorArguments = $('#txtPostProcessorArguments', form).val(); config.RecordingPostProcessorArguments = form.querySelector('#txtPostProcessorArguments').value;
config.SaveRecordingNFO = form.querySelector('#chkSaveRecordingNFO').checked; config.SaveRecordingNFO = form.querySelector('#chkSaveRecordingNFO').checked;
config.SaveRecordingImages = form.querySelector('#chkSaveRecordingImages').checked; config.SaveRecordingImages = form.querySelector('#chkSaveRecordingImages').checked;
ApiClient.updateNamedConfiguration('livetv', config).then(function () { ApiClient.updateNamedConfiguration('livetv', config).then(function () {
@ -64,12 +65,12 @@ $(document).on('pageinit', '#liveTvSettingsPage', function () {
const page = this; const page = this;
$('.liveTvSettingsForm').off('submit', onSubmit).on('submit', onSubmit); $('.liveTvSettingsForm').off('submit', onSubmit).on('submit', onSubmit);
$('#btnSelectRecordingPath', page).on('click.selectDirectory', function () { $('#btnSelectRecordingPath', page).on('click.selectDirectory', function () {
import('../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => { import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser(); const picker = new DirectoryBrowser();
picker.show({ picker.show({
callback: function (path) { callback: function (path) {
if (path) { if (path) {
$('#txtRecordingPath', page).val(path); page.querySelector('#txtRecordingPath').value = path;
} }
picker.close(); picker.close();
@ -79,12 +80,12 @@ $(document).on('pageinit', '#liveTvSettingsPage', function () {
}); });
}); });
$('#btnSelectMovieRecordingPath', page).on('click.selectDirectory', function () { $('#btnSelectMovieRecordingPath', page).on('click.selectDirectory', function () {
import('../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => { import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser(); const picker = new DirectoryBrowser();
picker.show({ picker.show({
callback: function (path) { callback: function (path) {
if (path) { if (path) {
$('#txtMovieRecordingPath', page).val(path); page.querySelector('#txtMovieRecordingPath').value = path;
} }
picker.close(); picker.close();
@ -94,12 +95,12 @@ $(document).on('pageinit', '#liveTvSettingsPage', function () {
}); });
}); });
$('#btnSelectSeriesRecordingPath', page).on('click.selectDirectory', function () { $('#btnSelectSeriesRecordingPath', page).on('click.selectDirectory', function () {
import('../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => { import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser(); const picker = new DirectoryBrowser();
picker.show({ picker.show({
callback: function (path) { callback: function (path) {
if (path) { if (path) {
$('#txtSeriesRecordingPath', page).val(path); page.querySelector('#txtSeriesRecordingPath').value = path;
} }
picker.close(); picker.close();
@ -109,13 +110,13 @@ $(document).on('pageinit', '#liveTvSettingsPage', function () {
}); });
}); });
$('#btnSelectPostProcessorPath', page).on('click.selectDirectory', function () { $('#btnSelectPostProcessorPath', page).on('click.selectDirectory', function () {
import('../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => { import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser(); const picker = new DirectoryBrowser();
picker.show({ picker.show({
includeFiles: true, includeFiles: true,
callback: function (path) { callback: function (path) {
if (path) { if (path) {
$('#txtPostProcessor', page).val(path); page.querySelector('#txtPostProcessor').value = path;
} }
picker.close(); picker.close();

View file

@ -1,4 +1,4 @@
<div id="liveTvStatusPage" data-role="page" class="page type-interior liveTvSettingsPage"> <div id="liveTvStatusPage" data-role="page" class="page type-interior liveTvSettingsPage" data-title="${LiveTV}">
<div> <div>
<div class="content-primary"> <div class="content-primary">
<div class="verticalSection verticalSection-extrabottompadding"> <div class="verticalSection verticalSection-extrabottompadding">
@ -10,7 +10,6 @@
<button is="emby-button" type="button" class="fab btnAddDevice submit sectionTitleButton" style="margin-left:1em;" title="${Add}"> <button is="emby-button" type="button" class="fab btnAddDevice submit sectionTitleButton" style="margin-left:1em;" title="${Add}">
<span class="material-icons add" aria-hidden="true"></span> <span class="material-icons add" aria-hidden="true"></span>
</button> </button>
<a is="emby-linkbutton" rel="noopener noreferrer" style="margin-left:2em!important;" class="raised button-alt headerHelpButton" target="_blank" href="https://jellyfin.org/docs/general/server/live-tv/">${Help}</a>
</div> </div>
<div class="devicesList itemsContainer vertical-wrap" data-hovermenu="false" data-multiselect="false" style="margin-top: .5em;"></div> <div class="devicesList itemsContainer vertical-wrap" data-hovermenu="false" data-multiselect="false" style="margin-top: .5em;"></div>
</div> </div>

View file

@ -1,19 +1,20 @@
import 'jquery'; import 'jquery';
import globalize from '../scripts/globalize';
import taskButton from '../scripts/taskbutton'; import globalize from 'lib/globalize';
import dom from '../scripts/dom'; import taskButton from 'scripts/taskbutton';
import layoutManager from '../components/layoutManager'; import dom from 'scripts/dom';
import loading from '../components/loading/loading'; import layoutManager from 'components/layoutManager';
import browser from '../scripts/browser'; import loading from 'components/loading/loading';
import '../components/listview/listview.scss'; import browser from 'scripts/browser';
import '../styles/flexstyles.scss'; import 'components/listview/listview.scss';
import '../elements/emby-itemscontainer/emby-itemscontainer'; import 'styles/flexstyles.scss';
import '../components/cardbuilder/card.scss'; import 'elements/emby-itemscontainer/emby-itemscontainer';
import 'components/cardbuilder/card.scss';
import 'material-design-icons-iconfont'; import 'material-design-icons-iconfont';
import '../elements/emby-button/emby-button'; import 'elements/emby-button/emby-button';
import Dashboard from '../utils/dashboard'; import Dashboard from 'utils/dashboard';
import confirm from '../components/confirm/confirm'; import confirm from 'components/confirm/confirm';
import { getDefaultBackgroundClass } from '../components/cardbuilder/cardBuilderUtils'; import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
const enableFocusTransform = !browser.slow && !browser.edge; const enableFocusTransform = !browser.slow && !browser.edge;
@ -89,8 +90,8 @@ function submitAddDeviceForm(page) {
type: 'POST', type: 'POST',
url: ApiClient.getUrl('LiveTv/TunerHosts'), url: ApiClient.getUrl('LiveTv/TunerHosts'),
data: JSON.stringify({ data: JSON.stringify({
Type: $('#selectTunerDeviceType', page).val(), Type: page.querySelector('#selectTunerDeviceType').value,
Url: $('#txtDevicePath', page).val() Url: page.querySelector('#txtDevicePath').value
}), }),
contentType: 'application/json' contentType: 'application/json'
}).then(function () { }).then(function () {
@ -129,11 +130,17 @@ function renderProviders(page, providers) {
html += '</div>'; html += '</div>';
} }
const elem = $('.providerList', page).html(html); const elem = page.querySelector('.providerList');
$('.btnOptions', elem).on('click', function () { elem.innerHTML = html;
if (elem.querySelector('.btnOptions')) {
const btnOptionElements = elem.querySelectorAll('.btnOptions');
btnOptionElements.forEach(function (btn) {
btn.addEventListener('click', function () {
const id = this.getAttribute('data-id'); const id = this.getAttribute('data-id');
showProviderOptions(page, id, this); showProviderOptions(page, id, btn);
}); });
});
}
} }
function showProviderOptions(page, providerId, button) { function showProviderOptions(page, providerId, button) {
@ -147,7 +154,7 @@ function showProviderOptions(page, providerId, button) {
id: 'map' id: 'map'
}); });
import('../components/actionSheet/actionSheet').then(({ default: actionsheet }) => { import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
actionsheet.show({ actionsheet.show({
items: items, items: items,
positionTo: button positionTo: button
@ -165,7 +172,7 @@ function showProviderOptions(page, providerId, button) {
} }
function mapChannels(page, providerId) { function mapChannels(page, providerId) {
import('../components/channelMapper/channelMapper').then(({ default: ChannelMapper }) => { import('components/channelMapper/channelMapper').then(({ default: ChannelMapper }) => {
new ChannelMapper({ new ChannelMapper({
serverId: ApiClient.serverInfo().Id, serverId: ApiClient.serverInfo().Id,
providerId: providerId providerId: providerId
@ -237,7 +244,7 @@ function addProvider(button) {
id: 'xmltv' id: 'xmltv'
}); });
import('../components/actionSheet/actionSheet').then(({ default: actionsheet }) => { import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
actionsheet.show({ actionsheet.show({
items: menuItems, items: menuItems,
positionTo: button, positionTo: button,
@ -263,7 +270,7 @@ function showDeviceMenu(button, tunerDeviceId) {
id: 'edit' id: 'edit'
}); });
import('../components/actionSheet/actionSheet').then(({ default: actionsheet }) => { import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
actionsheet.show({ actionsheet.show({
items: items, items: items,
positionTo: button positionTo: button
@ -297,14 +304,17 @@ function onDevicesListClick(e) {
$(document).on('pageinit', '#liveTvStatusPage', function () { $(document).on('pageinit', '#liveTvStatusPage', function () {
const page = this; const page = this;
$('.btnAddDevice', page).on('click', function () { page.querySelector('.btnAddDevice').addEventListener('click', function () {
addDevice(); addDevice();
}); });
$('.formAddDevice', page).on('submit', function () { if (page.querySelector('.formAddDevice')) {
// NOTE: unused?
page.querySelector('.formAddDevice').addEventListener('submit', function (e) {
e.preventDefault();
submitAddDeviceForm(page); submitAddDeviceForm(page);
return false;
}); });
$('.btnAddProvider', page).on('click', function () { }
page.querySelector('.btnAddProvider').addEventListener('click', function () {
addProvider(this); addProvider(this);
}); });
page.querySelector('.devicesList').addEventListener('click', onDevicesListClick); page.querySelector('.devicesList').addEventListener('click', onDevicesListClick);

View file

@ -5,7 +5,6 @@
<div class="verticalSection"> <div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center"> <div class="sectionTitleContainer flex align-items-center">
<h1 class="sectionTitle">${HeaderLiveTvTunerSetup}</h1> <h1 class="sectionTitle">${HeaderLiveTvTunerSetup}</h1>
<a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton" target="_blank" href="https://jellyfin.org/docs/general/server/live-tv/">${Help}</a>
</div> </div>
</div> </div>
@ -38,6 +37,11 @@
<div class="fieldDescription">${SimultaneousConnectionLimitHelp}</div> <div class="fieldDescription">${SimultaneousConnectionLimitHelp}</div>
</div> </div>
<div class="inputContainer fldFallbackMaxStreamingBitrate hide">
<input is="emby-input" type="number" pattern="[0-9]*" required="required" min="1" step="1" class="txtFallbackMaxStreamingBitrate" label="${LabelFallbackMaxStreamingBitrate}" autocomplete="off" value="30" />
<div class="fieldDescription">${FallbackMaxStreamingBitrateHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription fldFavorites hide"> <div class="checkboxContainer checkboxContainer-withDescription fldFavorites hide">
<label> <label>
<input type="checkbox" is="emby-checkbox" class="chkFavorite" /> <input type="checkbox" is="emby-checkbox" class="chkFavorite" />
@ -54,6 +58,22 @@
<div class="fieldDescription checkboxFieldDescription">${AllowHWTranscodingHelp}</div> <div class="fieldDescription checkboxFieldDescription">${AllowHWTranscodingHelp}</div>
</div> </div>
<div class="checkboxContainer checkboxContainer-withDescription fldFmp4Container hide">
<label>
<input type="checkbox" is="emby-checkbox" class="chkFmp4Container" />
<span>${LabelAllowFmp4TranscodingContainer}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${AllowFmp4TranscodingContainerHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription fldStreamSharing hide">
<label>
<input type="checkbox" is="emby-checkbox" class="chkStreamSharing" checked />
<span>${LabelAllowStreamSharing}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${AllowStreamSharingHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription fldStreamLoop hide"> <div class="checkboxContainer checkboxContainer-withDescription fldStreamLoop hide">
<label> <label>
<input type="checkbox" is="emby-checkbox" class="chkStreamLoop" /> <input type="checkbox" is="emby-checkbox" class="chkStreamLoop" />

View file

@ -1,12 +1,12 @@
import globalize from '../scripts/globalize'; import globalize from 'lib/globalize';
import loading from '../components/loading/loading'; import loading from 'components/loading/loading';
import dom from '../scripts/dom'; import dom from 'scripts/dom';
import '../elements/emby-input/emby-input'; import 'elements/emby-input/emby-input';
import '../elements/emby-button/emby-button'; import 'elements/emby-button/emby-button';
import '../elements/emby-checkbox/emby-checkbox'; import 'elements/emby-checkbox/emby-checkbox';
import '../elements/emby-select/emby-select'; import 'elements/emby-select/emby-select';
import Dashboard from '../utils/dashboard'; import Dashboard from 'utils/dashboard';
import { getParameterByName } from '../utils/url.ts'; import { getParameterByName } from 'utils/url';
function isM3uVariant(type) { function isM3uVariant(type) {
return ['nextpvr'].indexOf(type || '') !== -1; return ['nextpvr'].indexOf(type || '') !== -1;
@ -61,7 +61,10 @@ function fillTunerHostInfo(view, info) {
view.querySelector('.chkFavorite').checked = info.ImportFavoritesOnly; view.querySelector('.chkFavorite').checked = info.ImportFavoritesOnly;
view.querySelector('.chkTranscode').checked = info.AllowHWTranscoding; view.querySelector('.chkTranscode').checked = info.AllowHWTranscoding;
view.querySelector('.chkStreamLoop').checked = info.EnableStreamLooping; view.querySelector('.chkStreamLoop').checked = info.EnableStreamLooping;
view.querySelector('.chkFmp4Container').checked = info.AllowFmp4TranscodingContainer;
view.querySelector('.chkStreamSharing').checked = info.AllowStreamSharing;
view.querySelector('.chkIgnoreDts').checked = info.IgnoreDts; view.querySelector('.chkIgnoreDts').checked = info.IgnoreDts;
view.querySelector('.txtFallbackMaxStreamingBitrate').value = info.FallbackMaxStreamingBitrate / 1e6 || '30';
view.querySelector('.txtTunerCount').value = info.TunerCount || '0'; view.querySelector('.txtTunerCount').value = info.TunerCount || '0';
} }
@ -74,8 +77,11 @@ function submitForm(page) {
FriendlyName: page.querySelector('.txtFriendlyName').value || null, FriendlyName: page.querySelector('.txtFriendlyName').value || null,
DeviceId: page.querySelector('.fldDeviceId').value || null, DeviceId: page.querySelector('.fldDeviceId').value || null,
TunerCount: page.querySelector('.txtTunerCount').value || 0, TunerCount: page.querySelector('.txtTunerCount').value || 0,
FallbackMaxStreamingBitrate: parseInt(1e6 * parseFloat(page.querySelector('.txtFallbackMaxStreamingBitrate').value || '30'), 10),
ImportFavoritesOnly: page.querySelector('.chkFavorite').checked, ImportFavoritesOnly: page.querySelector('.chkFavorite').checked,
AllowHWTranscoding: page.querySelector('.chkTranscode').checked, AllowHWTranscoding: page.querySelector('.chkTranscode').checked,
AllowFmp4TranscodingContainer: page.querySelector('.chkFmp4Container').checked,
AllowStreamSharing: page.querySelector('.chkStreamSharing').checked,
EnableStreamLooping: page.querySelector('.chkStreamLoop').checked, EnableStreamLooping: page.querySelector('.chkStreamLoop').checked,
IgnoreDts: page.querySelector('.chkIgnoreDts').checked IgnoreDts: page.querySelector('.chkIgnoreDts').checked
}; };
@ -106,7 +112,7 @@ function submitForm(page) {
} }
function getDetectedDevice() { function getDetectedDevice() {
return import('../components/tunerPicker').then(({ default: TunerPicker }) => { return import('components/tunerPicker').then(({ default: TunerPicker }) => {
return new TunerPicker().show({ return new TunerPicker().show({
serverId: ApiClient.serverId() serverId: ApiClient.serverId()
}); });
@ -125,6 +131,9 @@ function onTypeChange() {
const supportsIgnoreDts = value === 'm3u'; const supportsIgnoreDts = value === 'm3u';
const supportsTunerCount = value === 'm3u'; const supportsTunerCount = value === 'm3u';
const supportsUserAgent = value === 'm3u'; const supportsUserAgent = value === 'm3u';
const supportsFmp4Container = value === 'm3u';
const supportsStreamSharing = value === 'm3u';
const supportsFallbackBitrate = value === 'm3u' || value === 'hdhomerun';
const suppportsSubmit = value !== 'other'; const suppportsSubmit = value !== 'other';
const supportsSelectablePath = supportsTunerFileOrUrl; const supportsSelectablePath = supportsTunerFileOrUrl;
const txtDevicePath = view.querySelector('.txtDevicePath'); const txtDevicePath = view.querySelector('.txtDevicePath');
@ -165,6 +174,10 @@ function onTypeChange() {
view.querySelector('.fldTranscode').classList.add('hide'); view.querySelector('.fldTranscode').classList.add('hide');
} }
view.querySelector('.fldFmp4Container').classList.toggle('hide', !supportsFmp4Container);
view.querySelector('.fldStreamSharing').classList.toggle('hide', !supportsStreamSharing);
view.querySelector('.fldFallbackMaxStreamingBitrate').classList.toggle('hide', !supportsFallbackBitrate);
if (supportsStreamLooping) { if (supportsStreamLooping) {
view.querySelector('.fldStreamLoop').classList.remove('hide'); view.querySelector('.fldStreamLoop').classList.remove('hide');
} else { } else {
@ -222,7 +235,7 @@ export default function (view, params) {
}); });
}); });
view.querySelector('.btnSelectPath').addEventListener('click', function () { view.querySelector('.btnSelectPath').addEventListener('click', function () {
import('../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => { import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser(); const picker = new DirectoryBrowser();
picker.show({ picker.show({
includeFiles: true, includeFiles: true,

View file

@ -2,11 +2,11 @@ import { ImageResolution } from '@jellyfin/sdk/lib/generated-client/models/image
import 'jquery'; import 'jquery';
import loading from '../../components/loading/loading'; import loading from 'components/loading/loading';
import globalize from '../../scripts/globalize'; import globalize from 'lib/globalize';
import Dashboard from '../../utils/dashboard'; import Dashboard from 'utils/dashboard';
import '../../components/listview/listview.scss'; import 'components/listview/listview.scss';
function populateImageResolutionOptions(select) { function populateImageResolutionOptions(select) {
let html = ''; let html = '';

View file

@ -1,4 +1,4 @@
<div id="metadataImagesConfigurationPage" data-role="page" class="page type-interior metadataConfigurationPage"> <div id="metadataImagesConfigurationPage" data-role="page" class="page type-interior metadataConfigurationPage" data-title="${LabelMetadata}">
<div> <div>

View file

@ -1,4 +1,4 @@
<div id="metadataNfoPage" data-role="page" class="page type-interior metadataConfigurationPage"> <div id="metadataNfoPage" data-role="page" class="page type-interior metadataConfigurationPage" data-title="${TabNfoSettings}">
<div> <div>

View file

@ -1,17 +1,20 @@
import escapeHtml from 'escape-html'; import escapeHtml from 'escape-html';
import 'jquery'; import 'jquery';
import loading from '../../components/loading/loading';
import globalize from '../../scripts/globalize'; import loading from 'components/loading/loading';
import Dashboard from '../../utils/dashboard'; import globalize from 'lib/globalize';
import alert from '../../components/alert'; import Dashboard from 'utils/dashboard';
import alert from 'components/alert';
function loadPage(page, config, users) { function loadPage(page, config, users) {
let html = '<option value="" selected="selected">' + globalize.translate('None') + '</option>'; let html = '<option value="" selected="selected">' + globalize.translate('None') + '</option>';
html += users.map(function (user) { html += users.map(function (user) {
return '<option value="' + user.Id + '">' + escapeHtml(user.Name) + '</option>'; return '<option value="' + user.Id + '">' + escapeHtml(user.Name) + '</option>';
}).join(''); }).join('');
$('#selectUser', page).html(html).val(config.UserId || ''); const elem = page.querySelector('#selectUser');
$('#selectReleaseDateFormat', page).val(config.ReleaseDateFormat); elem.innerHTML = html;
elem.value = config.UserId || '';
page.querySelector('#selectReleaseDateFormat').value = config.ReleaseDateFormat;
page.querySelector('#chkSaveImagePaths').checked = config.SaveImagePathsInNfo; page.querySelector('#chkSaveImagePaths').checked = config.SaveImagePathsInNfo;
page.querySelector('#chkEnablePathSubstitution').checked = config.EnablePathSubstitution; page.querySelector('#chkEnablePathSubstitution').checked = config.EnablePathSubstitution;
page.querySelector('#chkEnableExtraThumbs').checked = config.EnableExtraThumbsDuplication; page.querySelector('#chkEnableExtraThumbs').checked = config.EnableExtraThumbsDuplication;
@ -22,8 +25,8 @@ function onSubmit() {
loading.show(); loading.show();
const form = this; const form = this;
ApiClient.getNamedConfiguration(metadataKey).then(function (config) { ApiClient.getNamedConfiguration(metadataKey).then(function (config) {
config.UserId = $('#selectUser', form).val() || null; config.UserId = form.querySelector('#selectUser').value || null;
config.ReleaseDateFormat = $('#selectReleaseDateFormat', form).val(); config.ReleaseDateFormat = form.querySelector('#selectReleaseDateFormat').value;
config.SaveImagePathsInNfo = form.querySelector('#chkSaveImagePaths').checked; config.SaveImagePathsInNfo = form.querySelector('#chkSaveImagePaths').checked;
config.EnablePathSubstitution = form.querySelector('#chkEnablePathSubstitution').checked; config.EnablePathSubstitution = form.querySelector('#chkEnablePathSubstitution').checked;
config.EnableExtraThumbsDuplication = form.querySelector('#chkEnableExtraThumbs').checked; config.EnableExtraThumbsDuplication = form.querySelector('#chkEnableExtraThumbs').checked;

View file

@ -1,11 +1,10 @@
<div id="networkingPage" data-role="page" class="page type-interior advancedConfigurationPage"> <div id="networkingPage" data-role="page" class="page type-interior advancedConfigurationPage" data-title="${TabNetworking}">
<div> <div>
<div class="content-primary"> <div class="content-primary">
<form class="dashboardHostingForm"> <form class="dashboardHostingForm">
<div class="verticalSection verticalSection-extrabottompadding"> <div class="verticalSection verticalSection-extrabottompadding">
<div class="sectionTitleContainer flex align-items-center"> <div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${TabNetworking}</h2> <h2 class="sectionTitle">${TabNetworking}</h2>
<a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton" target="_blank" href="https://jellyfin.org/docs/general/networking/">${Help}</a>
</div> </div>
<fieldset class='verticalSection verticalSection-extrabottompadding'> <fieldset class='verticalSection verticalSection-extrabottompadding'>
@ -97,14 +96,6 @@
<option value="blacklist">${Blacklist}</option> <option value="blacklist">${Blacklist}</option>
</select> </select>
</div> </div>
<div class="checkboxContainer checkboxContainer-withDescription fldEnableUpnp hide">
<label>
<input type="checkbox" is="emby-checkbox" id="chkEnableUpnp" />
<span>${LabelEnableAutomaticPortMap}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelEnableAutomaticPortMapHelp}</div>
</div>
<div class="inputContainer fldPublicHttpPort hide"> <div class="inputContainer fldPublicHttpPort hide">
<input is="emby-input" type="number" label="${LabelPublicHttpPort}" id="txtPublicHttpPort" pattern="[0-9]*" required="required" min="1" max="65535" /> <input is="emby-input" type="number" label="${LabelPublicHttpPort}" id="txtPublicHttpPort" pattern="[0-9]*" required="required" min="1" max="65535" />
<div class="fieldDescription">${LabelPublicHttpPortHelp}</div> <div class="fieldDescription">${LabelPublicHttpPortHelp}</div>

View file

@ -1,15 +1,14 @@
import loading from '../../components/loading/loading'; import loading from 'components/loading/loading';
import globalize from '../../scripts/globalize'; import globalize from 'lib/globalize';
import '../../elements/emby-checkbox/emby-checkbox'; import 'elements/emby-checkbox/emby-checkbox';
import '../../elements/emby-select/emby-select'; import 'elements/emby-select/emby-select';
import Dashboard from '../../utils/dashboard'; import Dashboard from 'utils/dashboard';
import alert from '../../components/alert'; import alert from 'components/alert';
function onSubmit(e) { function onSubmit(e) {
const form = this; const form = this;
const localAddress = form.querySelector('#txtLocalAddress').value; const localAddress = form.querySelector('#txtLocalAddress').value;
const enableUpnp = form.querySelector('#chkEnableUpnp').checked; confirmSelections(localAddress, function () {
confirmSelections(localAddress, enableUpnp, function () {
const validationResult = getValidationAlert(form); const validationResult = getValidationAlert(form);
if (validationResult) { if (validationResult) {
@ -54,7 +53,6 @@ function onSubmit(e) {
config.InternalHttpsPort = form.querySelector('#txtHttpsPort').value; config.InternalHttpsPort = form.querySelector('#txtHttpsPort').value;
config.EnableHttps = form.querySelector('#chkEnableHttps').checked; config.EnableHttps = form.querySelector('#chkEnableHttps').checked;
config.RequireHttps = form.querySelector('#chkRequireHttps').checked; config.RequireHttps = form.querySelector('#chkRequireHttps').checked;
config.EnableUPnP = enableUpnp;
config.BaseUrl = form.querySelector('#txtBaseUrl').value; config.BaseUrl = form.querySelector('#txtBaseUrl').value;
config.EnableRemoteAccess = form.querySelector('#chkRemoteAccess').checked; config.EnableRemoteAccess = form.querySelector('#chkRemoteAccess').checked;
config.CertificatePath = form.querySelector('#txtCertificatePath').value || null; config.CertificatePath = form.querySelector('#txtCertificatePath').value || null;
@ -110,8 +108,8 @@ function showAlertText(options) {
}); });
} }
function confirmSelections(localAddress, enableUpnp, callback) { function confirmSelections(localAddress, callback) {
if (localAddress || !enableUpnp) { if (localAddress) {
showAlertText({ showAlertText({
title: globalize.translate('TitleHostingSettings'), title: globalize.translate('TitleHostingSettings'),
text: globalize.translate('SettingsWarning') text: globalize.translate('SettingsWarning')
@ -139,7 +137,6 @@ export default function (view) {
const txtCertificatePath = page.querySelector('#txtCertificatePath'); const txtCertificatePath = page.querySelector('#txtCertificatePath');
txtCertificatePath.value = config.CertificatePath || ''; txtCertificatePath.value = config.CertificatePath || '';
page.querySelector('#txtCertPassword').value = config.CertificatePassword || ''; page.querySelector('#txtCertPassword').value = config.CertificatePassword || '';
page.querySelector('#chkEnableUpnp').checked = config.EnableUPnP;
triggerChange(page.querySelector('#chkRemoteAccess')); triggerChange(page.querySelector('#chkRemoteAccess'));
page.querySelector('#chkAutodiscovery').checked = config.AutoDiscovery; page.querySelector('#chkAutodiscovery').checked = config.AutoDiscovery;
page.querySelector('#chkEnableIP6').checked = config.EnableIPv6; page.querySelector('#chkEnableIP6').checked = config.EnableIPv6;
@ -154,17 +151,15 @@ export default function (view) {
view.querySelector('.fldExternalAddressFilterMode').classList.remove('hide'); view.querySelector('.fldExternalAddressFilterMode').classList.remove('hide');
view.querySelector('.fldPublicHttpPort').classList.remove('hide'); view.querySelector('.fldPublicHttpPort').classList.remove('hide');
view.querySelector('.fldPublicHttpsPort').classList.remove('hide'); view.querySelector('.fldPublicHttpsPort').classList.remove('hide');
view.querySelector('.fldEnableUpnp').classList.remove('hide');
} else { } else {
view.querySelector('.fldExternalAddressFilter').classList.add('hide'); view.querySelector('.fldExternalAddressFilter').classList.add('hide');
view.querySelector('.fldExternalAddressFilterMode').classList.add('hide'); view.querySelector('.fldExternalAddressFilterMode').classList.add('hide');
view.querySelector('.fldPublicHttpPort').classList.add('hide'); view.querySelector('.fldPublicHttpPort').classList.add('hide');
view.querySelector('.fldPublicHttpsPort').classList.add('hide'); view.querySelector('.fldPublicHttpsPort').classList.add('hide');
view.querySelector('.fldEnableUpnp').classList.add('hide');
} }
}); });
view.querySelector('#btnSelectCertPath').addEventListener('click', function () { view.querySelector('#btnSelectCertPath').addEventListener('click', function () {
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => { import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser(); const picker = new DirectoryBrowser();
picker.show({ picker.show({
includeFiles: true, includeFiles: true,

View file

@ -0,0 +1,17 @@
<div id="pluginCatalogPage" data-role="page" class="page type-interior pluginConfigurationPage fullWidthContent" data-title="${TabCatalog}">
<div>
<div class="content-primary">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${TabCatalog}</h2>
<a is="emby-linkbutton" class="fab" href="#/dashboard/plugins/repositories" style="margin-left:1em;" title="${Settings}">
<span class="material-icons settings" aria-hidden="true"></span>
</a>
</div>
<div class="inputContainer">
<input id="txtSearchPlugins" name="txtSearchPlugins" type="text" is="emby-input" label="${Search}" />
</div>
<div id="noPlugins" class="hide">${MessageNoAvailablePlugins}</div>
<div id="pluginTiles" style="text-align:left;"></div>
</div>
</div>
</div>

View file

@ -1,12 +1,14 @@
import escapeHTML from 'escape-html'; import escapeHTML from 'escape-html';
import loading from '../../../../components/loading/loading'; import { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels';
import globalize from '../../../../scripts/globalize'; import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
import '../../../../components/cardbuilder/card.scss'; import loading from 'components/loading/loading';
import '../../../../elements/emby-button/emby-button'; import globalize from 'lib/globalize';
import '../../../../elements/emby-checkbox/emby-checkbox';
import '../../../../elements/emby-select/emby-select'; import 'components/cardbuilder/card.scss';
import { getDefaultBackgroundClass } from '../../../../components/cardbuilder/cardBuilderUtils'; import 'elements/emby-button/emby-button';
import 'elements/emby-checkbox/emby-checkbox';
import 'elements/emby-select/emby-select';
function reloadList(page) { function reloadList(page) {
loading.show(); loading.show();
@ -23,19 +25,14 @@ function reloadList(page) {
} }
function getHeaderText(category) { function getHeaderText(category) {
category = category.replace(' ', ''); const categoryKey = category.replaceAll(' ', '');
// TODO: Replace with switch
if (category === 'Channel') { if (CATEGORY_LABELS[categoryKey]) {
category = 'Channels'; return globalize.translate(CATEGORY_LABELS[categoryKey]);
} else if (category === 'Theme') {
category = 'Themes';
} else if (category === 'LiveTV') {
category = 'LiveTV';
} else if (category === 'ScreenSaver') {
category = 'HeaderScreenSavers';
} }
return globalize.translate(category); console.warn('[AvailablePlugins] unmapped category label', category);
return category;
} }
function populateList(options) { function populateList(options) {
@ -43,7 +40,7 @@ function populateList(options) {
const installedPlugins = options.installedPlugins; const installedPlugins = options.installedPlugins;
availablePlugins.forEach(function (plugin, index, array) { availablePlugins.forEach(function (plugin, index, array) {
plugin.category = plugin.category || 'General'; plugin.category = plugin.category || 'Other';
plugin.categoryDisplayName = getHeaderText(plugin.category); plugin.categoryDisplayName = getHeaderText(plugin.category);
array[index] = plugin; array[index] = plugin;
}); });
@ -119,7 +116,8 @@ function onSearchBarType(searchBar) {
function getPluginHtml(plugin, options, installedPlugins) { function getPluginHtml(plugin, options, installedPlugins) {
let html = ''; let html = '';
let href = plugin.externalUrl ? plugin.externalUrl : '#/dashboard/plugins/add?name=' + encodeURIComponent(plugin.name) + '&guid=' + plugin.guid; let href = plugin.externalUrl ? plugin.externalUrl :
`#/dashboard/plugins/${plugin.guid}?name=${encodeURIComponent(plugin.name)}`;
if (options.context) { if (options.context) {
href += '&context=' + options.context; href += '&context=' + options.context;

View file

@ -1,6 +1,9 @@
<div id="pluginsPage" data-role="page" class="page type-interior pluginConfigurationPage fullWidthContent"> <div id="pluginsPage" data-role="page" class="page type-interior pluginConfigurationPage fullWidthContent" data-title="${TabPlugins}">
<div> <div>
<div class="content-primary"> <div class="content-primary">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${TabMyPlugins}</h2>
</div>
<div class="inputContainer"> <div class="inputContainer">
<input id="txtSearchPlugins" name="txtSearchPlugins" type="text" is="emby-input" label="${Search}" /> <input id="txtSearchPlugins" name="txtSearchPlugins" type="text" is="emby-input" label="${Search}" />
</div> </div>

View file

@ -1,11 +1,11 @@
import loading from '../../../../components/loading/loading'; import loading from 'components/loading/loading';
import dom from '../../../../scripts/dom'; import dom from 'scripts/dom';
import globalize from '../../../../scripts/globalize'; import globalize from 'lib/globalize';
import '../../../../components/cardbuilder/card.scss'; import 'components/cardbuilder/card.scss';
import '../../../../elements/emby-button/emby-button'; import 'elements/emby-button/emby-button';
import Dashboard, { pageIdOn } from '../../../../utils/dashboard'; import Dashboard, { pageIdOn } from 'utils/dashboard';
import confirm from '../../../../components/confirm/confirm'; import confirm from 'components/confirm/confirm';
import { getDefaultBackgroundClass } from '../../../../components/cardbuilder/cardBuilderUtils'; import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
function deletePlugin(page, uniqueid, version, name) { function deletePlugin(page, uniqueid, version, name) {
const msg = globalize.translate('UninstallPluginConfirmation', name); const msg = globalize.translate('UninstallPluginConfirmation', name);
@ -187,7 +187,7 @@ function showPluginMenu(page, elem) {
}); });
} }
import('../../../../components/actionSheet/actionSheet').then((actionsheet) => { import('components/actionSheet/actionSheet').then((actionsheet) => {
actionsheet.show({ actionsheet.show({
items: menuItems, items: menuItems,
positionTo: elem, positionTo: elem,

View file

@ -1,4 +1,4 @@
<div id="repositories" data-role="page" class="page type-interior fullWidthContent"> <div id="repositories" data-role="page" class="page type-interior fullWidthContent" data-title="${TabRepositories}">
<div> <div>
<div class="content-primary"> <div class="content-primary">
<div class="sectionTitleContainer flex align-items-center"> <div class="sectionTitleContainer flex align-items-center">
@ -6,9 +6,6 @@
<button is="emby-button" type="button" class="fab btnNewRepository submit" style="margin-left:1em;" title="${Add}"> <button is="emby-button" type="button" class="fab btnNewRepository submit" style="margin-left:1em;" title="${Add}">
<span class="material-icons add" aria-hidden="true"></span> <span class="material-icons add" aria-hidden="true"></span>
</button> </button>
<a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton" target="_blank" href="https://jellyfin.org/docs/general/server/plugins/#repositories">
${Help}
</a>
</div> </div>
<div id="repositories"></div> <div id="repositories"></div>

View file

@ -1,14 +1,14 @@
import loading from '../../../../components/loading/loading'; import loading from 'components/loading/loading';
import globalize from '../../../../scripts/globalize'; import globalize from 'lib/globalize';
import dialogHelper from '../../../../components/dialogHelper/dialogHelper'; import dialogHelper from 'components/dialogHelper/dialogHelper';
import confirm from '../../../../components/confirm/confirm'; import confirm from 'components/confirm/confirm';
import '../../../../elements/emby-button/emby-button'; import 'elements/emby-button/emby-button';
import '../../../../elements/emby-checkbox/emby-checkbox'; import 'elements/emby-checkbox/emby-checkbox';
import '../../../../elements/emby-select/emby-select'; import 'elements/emby-select/emby-select';
import '../../../../components/formdialog.scss'; import 'components/formdialog.scss';
import '../../../../components/listview/listview.scss'; import 'components/listview/listview.scss';
let repositories = []; let repositories = [];

View file

@ -4,7 +4,6 @@
<div class="verticalSection"> <div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center"> <div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle taskName"></h2> <h2 class="sectionTitle taskName"></h2>
<a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton" target="_blank" href="https://jellyfin.org/docs/general/server/tasks">${Help}</a>
</div> </div>
<p id="pTaskDescription"></p> <p id="pTaskDescription"></p>
</div> </div>

View file

@ -1,13 +1,12 @@
import 'jquery'; import loading from 'components/loading/loading';
import loading from '../../../components/loading/loading'; import datetime from 'scripts/datetime';
import datetime from '../../../scripts/datetime'; import dom from 'scripts/dom';
import dom from '../../../scripts/dom'; import globalize from 'lib/globalize';
import globalize from '../../../scripts/globalize'; import 'elements/emby-input/emby-input';
import '../../../elements/emby-input/emby-input'; import 'elements/emby-button/emby-button';
import '../../../elements/emby-button/emby-button'; import 'elements/emby-select/emby-select';
import '../../../elements/emby-select/emby-select'; import confirm from 'components/confirm/confirm';
import confirm from '../../../components/confirm/confirm'; import { getParameterByName } from 'utils/url.ts';
import { getParameterByName } from '../../../utils/url.ts';
function fillTimeOfDay(select) { function fillTimeOfDay(select) {
const options = []; const options = [];
@ -33,10 +32,10 @@ const ScheduledTaskPage = {
}); });
}, },
loadScheduledTask: function (view, task) { loadScheduledTask: function (view, task) {
$('.taskName', view).html(task.Name); view.querySelector('.taskName').innerHTML = task.Name;
$('#pTaskDescription', view).html(task.Description); view.querySelector('#pTaskDescription').innerHTML = task.Description;
import('../../../components/listview/listview.scss').then(() => { import('components/listview/listview.scss').then(() => {
ScheduledTaskPage.loadTaskTriggers(view, task); ScheduledTaskPage.loadTaskTriggers(view, task);
}); });
@ -124,9 +123,9 @@ const ScheduledTaskPage = {
return datetime.getDisplayTime(now); return datetime.getDisplayTime(now);
}, },
showAddTriggerPopup: function (view) { showAddTriggerPopup: function (view) {
$('#selectTriggerType', view).val('DailyTrigger'); view.querySelector('#selectTriggerType').value = 'DailyTrigger';
view.querySelector('#selectTriggerType').dispatchEvent(new CustomEvent('change', {})); view.querySelector('#selectTriggerType').dispatchEvent(new CustomEvent('change', {}));
$('#popupAddTrigger', view).removeClass('hide'); view.querySelector('#popupAddTrigger').classList.remove('hide');
}, },
confirmDeleteTrigger: function (view, index) { confirmDeleteTrigger: function (view, index) {
confirm(globalize.translate('MessageDeleteTaskTrigger'), globalize.translate('HeaderDeleteTaskTrigger')).then(function () { confirm(globalize.translate('MessageDeleteTaskTrigger'), globalize.translate('HeaderDeleteTaskTrigger')).then(function () {
@ -145,54 +144,54 @@ const ScheduledTaskPage = {
}, },
refreshTriggerFields: function (page, triggerType) { refreshTriggerFields: function (page, triggerType) {
if (triggerType == 'DailyTrigger') { if (triggerType == 'DailyTrigger') {
$('#fldTimeOfDay', page).show(); page.querySelector('#fldTimeOfDay').classList.remove('hide');
$('#fldDayOfWeek', page).hide(); page.querySelector('#fldDayOfWeek').classList.add('hide');
$('#fldSelectSystemEvent', page).hide(); page.querySelector('#fldSelectSystemEvent').classList.add('hide');
$('#fldSelectInterval', page).hide(); page.querySelector('#fldSelectInterval').classList.add('hide');
$('#selectTimeOfDay', page).attr('required', 'required'); page.querySelector('#selectTimeOfDay').setAttribute('required', 'required');
} else if (triggerType == 'WeeklyTrigger') { } else if (triggerType == 'WeeklyTrigger') {
$('#fldTimeOfDay', page).show(); page.querySelector('#fldTimeOfDay').classList.remove('hide');
$('#fldDayOfWeek', page).show(); page.querySelector('#fldDayOfWeek').classList.remove('hide');
$('#fldSelectSystemEvent', page).hide(); page.querySelector('#fldSelectSystemEvent').classList.add('hide');
$('#fldSelectInterval', page).hide(); page.querySelector('#fldSelectInterval').classList.add('hide');
$('#selectTimeOfDay', page).attr('required', 'required'); page.querySelector('#selectTimeOfDay').setAttribute('required', 'required');
} else if (triggerType == 'SystemEventTrigger') { } else if (triggerType == 'SystemEventTrigger') {
$('#fldTimeOfDay', page).hide(); page.querySelector('#fldTimeOfDay').classList.add('hide');
$('#fldDayOfWeek', page).hide(); page.querySelector('#fldDayOfWeek').classList.add('hide');
$('#fldSelectSystemEvent', page).show(); page.querySelector('#fldSelectSystemEvent').classList.remove('hide');
$('#fldSelectInterval', page).hide(); page.querySelector('#fldSelectInterval').classList.add('hide');
$('#selectTimeOfDay', page).removeAttr('required'); page.querySelector('#selectTimeOfDay').removeAttribute('required');
} else if (triggerType == 'IntervalTrigger') { } else if (triggerType == 'IntervalTrigger') {
$('#fldTimeOfDay', page).hide(); page.querySelector('#fldTimeOfDay').classList.add('hide');
$('#fldDayOfWeek', page).hide(); page.querySelector('#fldDayOfWeek').classList.add('hide');
$('#fldSelectSystemEvent', page).hide(); page.querySelector('#fldSelectSystemEvent').classList.add('hide');
$('#fldSelectInterval', page).show(); page.querySelector('#fldSelectInterval').classList.remove('hide');
$('#selectTimeOfDay', page).removeAttr('required'); page.querySelector('#selectTimeOfDay').removeAttribute('required');
} else if (triggerType == 'StartupTrigger') { } else if (triggerType == 'StartupTrigger') {
$('#fldTimeOfDay', page).hide(); page.querySelector('#fldTimeOfDay').classList.add('hide');
$('#fldDayOfWeek', page).hide(); page.querySelector('#fldDayOfWeek').classList.add('hide');
$('#fldSelectSystemEvent', page).hide(); page.querySelector('#fldSelectSystemEvent').classList.add('hide');
$('#fldSelectInterval', page).hide(); page.querySelector('#fldSelectInterval').classList.add('hide');
$('#selectTimeOfDay', page).removeAttr('required'); page.querySelector('#selectTimeOfDay').removeAttribute('required');
} }
}, },
getTriggerToAdd: function (page) { getTriggerToAdd: function (page) {
const trigger = { const trigger = {
Type: $('#selectTriggerType', page).val() Type: page.querySelector('#selectTriggerType').value
}; };
if (trigger.Type == 'DailyTrigger') { if (trigger.Type == 'DailyTrigger') {
trigger.TimeOfDayTicks = $('#selectTimeOfDay', page).val(); trigger.TimeOfDayTicks = page.querySelector('#selectTimeOfDay').value;
} else if (trigger.Type == 'WeeklyTrigger') { } else if (trigger.Type == 'WeeklyTrigger') {
trigger.DayOfWeek = $('#selectDayOfWeek', page).val(); trigger.DayOfWeek = page.querySelector('#selectDayOfWeek').value;
trigger.TimeOfDayTicks = $('#selectTimeOfDay', page).val(); trigger.TimeOfDayTicks = page.querySelector('#selectTimeOfDay').value;
} else if (trigger.Type == 'SystemEventTrigger') { } else if (trigger.Type == 'SystemEventTrigger') {
trigger.SystemEvent = $('#selectSystemEvent', page).val(); trigger.SystemEvent = page.querySelector('#selectSystemEvent').value;
} else if (trigger.Type == 'IntervalTrigger') { } else if (trigger.Type == 'IntervalTrigger') {
trigger.IntervalTicks = $('#selectInterval', page).val(); trigger.IntervalTicks = page.querySelector('#selectInterval').value;
} }
let timeLimit = $('#txtTimeLimit', page).val() || '0'; let timeLimit = page.querySelector('#txtTimeLimit').value || '0';
timeLimit = parseFloat(timeLimit) * 3600000; timeLimit = parseFloat(timeLimit) * 3600000;
trigger.MaxRuntimeTicks = timeLimit * 1e4 || null; trigger.MaxRuntimeTicks = timeLimit * 1e4 || null;
@ -207,7 +206,7 @@ export default function (view) {
ApiClient.getScheduledTask(id).then(function (task) { ApiClient.getScheduledTask(id).then(function (task) {
task.Triggers.push(ScheduledTaskPage.getTriggerToAdd(view)); task.Triggers.push(ScheduledTaskPage.getTriggerToAdd(view));
ApiClient.updateScheduledTaskTriggers(task.Id, task.Triggers).then(function () { ApiClient.updateScheduledTaskTriggers(task.Id, task.Triggers).then(function () {
$('#popupAddTrigger').addClass('hide'); document.querySelector('#popupAddTrigger').classList.add('hide');
ScheduledTaskPage.refreshScheduledTask(view); ScheduledTaskPage.refreshScheduledTask(view);
}); });
}); });
@ -216,7 +215,7 @@ export default function (view) {
view.querySelector('.addTriggerForm').addEventListener('submit', onSubmit); view.querySelector('.addTriggerForm').addEventListener('submit', onSubmit);
fillTimeOfDay(view.querySelector('#selectTimeOfDay')); fillTimeOfDay(view.querySelector('#selectTimeOfDay'));
$(view.querySelector('#popupAddTrigger').parentNode).trigger('create'); view.querySelector('#popupAddTrigger').parentNode.trigger(new Event('create'));
view.querySelector('.selectTriggerType').addEventListener('change', function () { view.querySelector('.selectTriggerType').addEventListener('change', function () {
ScheduledTaskPage.refreshTriggerFields(view, this.value); ScheduledTaskPage.refreshTriggerFields(view, this.value);
}); });

View file

@ -0,0 +1,36 @@
import type { ActivityLogApiGetLogEntriesRequest } from '@jellyfin/sdk/lib/generated-client';
import type { AxiosRequestConfig } from 'axios';
import type { Api } from '@jellyfin/sdk';
import { getActivityLogApi } from '@jellyfin/sdk/lib/utils/api/activity-log-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
const fetchLogEntries = async (
api?: Api,
requestParams?: ActivityLogApiGetLogEntriesRequest,
options?: AxiosRequestConfig
) => {
if (!api) {
console.warn('[fetchLogEntries] No API instance available');
return;
}
const response = await getActivityLogApi(api).getLogEntries(requestParams, {
signal: options?.signal
});
return response.data;
};
export const useLogEntries = (
requestParams: ActivityLogApiGetLogEntriesRequest
) => {
const { api } = useApi();
return useQuery({
queryKey: ['ActivityLogEntries', requestParams],
queryFn: ({ signal }) =>
fetchLogEntries(api, requestParams, { signal }),
enabled: !!api
});
};

View file

@ -0,0 +1,22 @@
import IconButton from '@mui/material/IconButton/IconButton';
import PermMedia from '@mui/icons-material/PermMedia';
import React, { type FC } from 'react';
import { Link } from 'react-router-dom';
import type { ActivityLogEntryCell } from 'apps/dashboard/features/activity/types/ActivityLogEntryCell';
import globalize from 'lib/globalize';
const ActionsCell: FC<ActivityLogEntryCell> = ({ row }) => (
row.original.ItemId ? (
<IconButton
size='large'
title={globalize.translate('LabelMediaDetails')}
component={Link}
to={`/details?id=${row.original.ItemId}`}
>
<PermMedia fontSize='inherit' />
</IconButton>
) : undefined
);
export default ActionsCell;

View file

@ -0,0 +1,14 @@
import type { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
import React, { type FC } from 'react';
import { ActivityLogEntryCell } from '../types/ActivityLogEntryCell';
import LogLevelChip from './LogLevelChip';
const LogLevelCell: FC<ActivityLogEntryCell> = ({ cell }) => {
const level = cell.getValue<LogLevel | undefined>();
return level ? (
<LogLevelChip level={level} />
) : undefined;
};
export default LogLevelCell;

View file

@ -2,7 +2,7 @@ import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import React from 'react'; import React from 'react';
import globalize from 'scripts/globalize'; import globalize from 'lib/globalize';
const LogLevelChip = ({ level }: { level: LogLevel }) => { const LogLevelChip = ({ level }: { level: LogLevel }) => {
let color: 'info' | 'warning' | 'error' | undefined; let color: 'info' | 'warning' | 'error' | undefined;

View file

@ -1,12 +1,14 @@
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
import Info from '@mui/icons-material/Info'; import Info from '@mui/icons-material/Info';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import ClickAwayListener from '@mui/material/ClickAwayListener'; import ClickAwayListener from '@mui/material/ClickAwayListener';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import React, { FC, useCallback, useState } from 'react'; import React, { type FC, useCallback, useState } from 'react';
const OverviewCell: FC<ActivityLogEntry> = ({ Overview, ShortOverview }) => { import type { ActivityLogEntryCell } from '../types/ActivityLogEntryCell';
const OverviewCell: FC<ActivityLogEntryCell> = ({ row }) => {
const { ShortOverview, Overview } = row.original;
const displayValue = ShortOverview ?? Overview; const displayValue = ShortOverview ?? Overview;
const [ open, setOpen ] = useState(false); const [ open, setOpen ] = useState(false);

View file

@ -0,0 +1,7 @@
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
import type { MRT_Cell, MRT_Row } from 'material-react-table';
export interface ActivityLogEntryCell {
cell: MRT_Cell<ActivityLogEntry>
row: MRT_Row<ActivityLogEntry>
}

View file

@ -0,0 +1,35 @@
import { Api } from '@jellyfin/sdk';
import { getBrandingApi } from '@jellyfin/sdk/lib/utils/api/branding-api';
import { queryOptions, useQuery } from '@tanstack/react-query';
import type { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
export const QUERY_KEY = 'BrandingOptions';
const fetchBrandingOptions = async (
api?: Api,
options?: AxiosRequestConfig
) => {
if (!api) {
console.error('[fetchBrandingOptions] no Api instance provided');
throw new Error('No Api instance provided to fetchBrandingOptions');
}
return getBrandingApi(api)
.getBrandingOptions(options)
.then(({ data }) => data);
};
export const getBrandingOptionsQuery = (
api?: Api
) => queryOptions({
queryKey: [ QUERY_KEY ],
queryFn: ({ signal }) => fetchBrandingOptions(api, { signal }),
enabled: !!api
});
export const useBrandingOptions = () => {
const { api } = useApi();
return useQuery(getBrandingOptionsQuery(api));
};

View file

@ -0,0 +1,24 @@
import type { DevicesApiDeleteDeviceRequest } from '@jellyfin/sdk/lib/generated-client/api/devices-api';
import { getDevicesApi } from '@jellyfin/sdk/lib/utils/api/devices-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QUERY_KEY } from './useDevices';
export const useDeleteDevice = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: DevicesApiDeleteDeviceRequest) => (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getDevicesApi(api!)
.deleteDevice(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
}
});
};

View file

@ -0,0 +1,38 @@
import type { DevicesApiGetDevicesRequest } from '@jellyfin/sdk/lib/generated-client';
import type { AxiosRequestConfig } from 'axios';
import type { Api } from '@jellyfin/sdk';
import { getDevicesApi } from '@jellyfin/sdk/lib/utils/api/devices-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
export const QUERY_KEY = 'Devices';
const fetchDevices = async (
api?: Api,
requestParams?: DevicesApiGetDevicesRequest,
options?: AxiosRequestConfig
) => {
if (!api) {
console.warn('[fetchDevices] No API instance available');
return;
}
const response = await getDevicesApi(api).getDevices(requestParams, {
signal: options?.signal
});
return response.data;
};
export const useDevices = (
requestParams: DevicesApiGetDevicesRequest
) => {
const { api } = useApi();
return useQuery({
queryKey: [QUERY_KEY, requestParams],
queryFn: ({ signal }) =>
fetchDevices(api, requestParams, { signal }),
enabled: !!api
});
};

View file

@ -0,0 +1,24 @@
import type { DevicesApiUpdateDeviceOptionsRequest } from '@jellyfin/sdk/lib/generated-client/api/devices-api';
import { getDevicesApi } from '@jellyfin/sdk/lib/utils/api/devices-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QUERY_KEY } from './useDevices';
export const useUpdateDevice = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: DevicesApiUpdateDeviceOptionsRequest) => (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getDevicesApi(api!)
.updateDeviceOptions(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
}
});
};

View file

@ -0,0 +1,22 @@
import React, { FC } from 'react';
import { DeviceInfoCell } from 'apps/dashboard/features/devices/types/deviceInfoCell';
import { getDeviceIcon } from 'utils/image';
const DeviceNameCell: FC<DeviceInfoCell> = ({ row, renderedCellValue }) => (
<>
<img
alt={row.original.AppName || undefined}
src={getDeviceIcon(row.original)}
style={{
display: 'inline-block',
maxWidth: '1.5em',
maxHeight: '1.5em',
marginRight: '1rem'
}}
/>
{renderedCellValue}
</>
);
export default DeviceNameCell;

View file

@ -0,0 +1,7 @@
import type { DeviceInfoDto } from '@jellyfin/sdk/lib/generated-client/models/device-info-dto';
import type { MRT_Row } from 'material-react-table';
export interface DeviceInfoCell {
renderedCellValue: React.ReactNode
row: MRT_Row<DeviceInfoDto>
}

View file

@ -0,0 +1,27 @@
import { Api } from '@jellyfin/sdk';
import { getApiKeyApi } from '@jellyfin/sdk/lib/utils/api/api-key-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
export const QUERY_KEY = 'ApiKeys';
const fetchApiKeys = async (api?: Api) => {
if (!api) {
console.error('[useApiKeys] Failed to create Api instance');
return;
}
const response = await getApiKeyApi(api).getKeys();
return response.data;
};
export const useApiKeys = () => {
const { api } = useApi();
return useQuery({
queryKey: [ QUERY_KEY ],
queryFn: () => fetchApiKeys(api),
enabled: !!api
});
};

Some files were not shown because too many files have changed in this diff Show more