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": [
"./dist/libraries/pdf.worker.js",
"./dist/libraries/worker-bundle.js",
"./dist/libraries/wasm-gen/libarchive.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",
"extends": [
"github>jellyfin/.github//renovate-presets/nodejs",
":semanticCommitsDisabled",
":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:
workflow_call:
@ -12,32 +12,26 @@ on:
preview_url:
required: false
type: string
build_workflow_run_id:
required: false
type: number
commenting_workflow_run_id:
required: true
type: string
in_progress:
required: true
type: boolean
outputs:
msg:
description: The composed message
value: ${{ jobs.msg.outputs.msg }}
comment:
required: false
type: boolean
marker:
description: Hidden marker to detect PR comments composed by the bot
value: "CFPages-deployment"
required: false
type: string
default: "CFPages-deployment"
jobs:
msg:
name: Deployment status
cf_pages_msg:
name: CloudFlare Pages deployment 📃🚀
runs-on: ubuntu-latest
outputs:
msg: ${{ env.msg }}
steps:
- name: Compose message
- name: Compose message 📃
if: ${{ always() }}
id: compose
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' }}
DEPLOY_STATUS: ${{ inputs.in_progress && '🔄 Deploying...' || (inputs.preview_url != '' && '✅ Deployed!' || '❌ Failure. Check workflow logs for details') }}
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) || '' }}
COMMENTING_WORKFLOW_RUN: ${{ format('**[View bot logs](https://github.com/{0}/actions/runs/{1})**', 'jellyfin/jellyfin-web', inputs.commenting_workflow_run_id) }}
WORKFLOW_RUN: ${{ !inputs.in_progress && format('**[View build logs](https://github.com/{0}/actions/runs/{1})**', github.repository, github.run_id) || '' }}
# EOF is needed for multiline environment variables in a GitHub Actions context
run: |
echo "## Cloudflare Pages deployment" > $GITHUB_STEP_SUMMARY
@ -57,9 +50,16 @@ jobs:
echo "| **Preview URL** | $PREVIEW_URL |" >> $GITHUB_STEP_SUMMARY
echo "| **Type** | $DEPLOYMENT_TYPE |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "$BUILD_WORKFLOW_RUN" >> $GITHUB_STEP_SUMMARY
echo "$COMMENTING_WORKFLOW_RUN" >> $GITHUB_STEP_SUMMARY
echo "$WORKFLOW_RUN" >> $GITHUB_STEP_SUMMARY
COMPOSED_MSG=$(cat $GITHUB_STEP_SUMMARY)
echo "msg<<EOF" >> $GITHUB_ENV
echo "$COMPOSED_MSG" >> $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:
schedule:
- cron: '30 1 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs:
issues:
name: Check issues
name: Check stale issues and PRs
runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }}
steps:
- uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
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-pr-stale: -1
days-before-close: 21
days-before-pr-close: -1
exempt-issue-labels: regression,security,roadmap,future,feature,enhancement,confirmed
stale-issue-label: stale
stale-issue-message: |-
@ -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.
This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://jellyfin.org/contact).
prs-conflicts:
name: Check PRs with merge conflicts
runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }}
steps:
- uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
operations-per-run: 75
# The merge conflict action will remove the label when updated
remove-stale-when-updated: false
days-before-stale: -1
days-before-close: 90
days-before-issue-close: -1
stale-pr-label: merge conflict
# PRs are closed after having unresolved merge conflicts for 90 days
days-before-pr-stale: 0
days-before-pr-close: 90
only-pr-labels: merge conflict
stale-pr-label: stale
close-pr-message: |-
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
node_modules
# test coverage
coverage
# config
config.json
@ -10,12 +13,6 @@ config.json
.idea
.vs
# log
yarn-error.log
# vim
*.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": [
"@stylistic/stylelint-plugin",
"stylelint-no-browser-hacks/lib"
],
"rules": {
@ -10,20 +11,20 @@
],
"ignore": ["after-comment"]
} ],
"at-rule-name-case": "lower",
"at-rule-name-space-after": "always-single-line",
"@stylistic/at-rule-name-case": "lower",
"@stylistic/at-rule-name-space-after": "always-single-line",
"at-rule-no-unknown": true,
"at-rule-no-vendor-prefix": true,
"at-rule-semicolon-newline-after": "always",
"block-closing-brace-empty-line-before": "never",
"block-closing-brace-newline-after": "always",
"block-closing-brace-newline-before": "always-multi-line",
"block-closing-brace-space-before": "always-single-line",
"@stylistic/at-rule-semicolon-newline-after": "always",
"@stylistic/block-closing-brace-empty-line-before": "never",
"@stylistic/block-closing-brace-newline-after": "always",
"@stylistic/block-closing-brace-newline-before": "always-multi-line",
"@stylistic/block-closing-brace-space-before": "always-single-line",
"block-no-empty": true,
"block-opening-brace-newline-after": "always-multi-line",
"block-opening-brace-space-after": "always-single-line",
"block-opening-brace-space-before": "always",
"color-hex-case": "lower",
"@stylistic/block-opening-brace-newline-after": "always-multi-line",
"@stylistic/block-opening-brace-space-after": "always-single-line",
"@stylistic/block-opening-brace-space-before": "always",
"@stylistic/color-hex-case": "lower",
"color-hex-length": "short",
"color-no-invalid-hex": true,
"comment-empty-line-before": [ "always", {
@ -42,8 +43,8 @@
"inside-single-line-block"
]
} ],
"declaration-bang-space-after": "never",
"declaration-bang-space-before": "always",
"@stylistic/declaration-bang-space-after": "never",
"@stylistic/declaration-bang-space-before": "always",
"declaration-block-no-duplicate-properties": [
true,
{
@ -51,52 +52,52 @@
}
],
"declaration-block-no-shorthand-property-overrides": true,
"declaration-block-semicolon-newline-after": "always-multi-line",
"declaration-block-semicolon-space-after": "always-single-line",
"declaration-block-semicolon-space-before": "never",
"@stylistic/declaration-block-semicolon-newline-after": "always-multi-line",
"@stylistic/declaration-block-semicolon-space-after": "always-single-line",
"@stylistic/declaration-block-semicolon-space-before": "never",
"declaration-block-single-line-max-declarations": 1,
"declaration-block-trailing-semicolon": "always",
"declaration-colon-newline-after": "always-multi-line",
"declaration-colon-space-after": "always-single-line",
"declaration-colon-space-before": "never",
"@stylistic/declaration-block-trailing-semicolon": "always",
"@stylistic/declaration-colon-newline-after": "always-multi-line",
"@stylistic/declaration-colon-space-after": "always-single-line",
"@stylistic/declaration-colon-space-before": "never",
"font-family-no-duplicate-names": true,
"function-calc-no-unspaced-operator": true,
"function-comma-newline-after": "always-multi-line",
"function-comma-space-after": "always-single-line",
"function-comma-space-before": "never",
"@stylistic/function-comma-newline-after": "always-multi-line",
"@stylistic/function-comma-space-after": "always-single-line",
"@stylistic/function-comma-space-before": "never",
"function-linear-gradient-no-nonstandard-direction": true,
"function-max-empty-lines": 0,
"@stylistic/function-max-empty-lines": 0,
"function-name-case": "lower",
"function-parentheses-newline-inside": "always-multi-line",
"function-parentheses-space-inside": "never-single-line",
"function-whitespace-after": "always",
"indentation": 4,
"@stylistic/function-parentheses-newline-inside": "always-multi-line",
"@stylistic/function-parentheses-space-inside": "never-single-line",
"@stylistic/function-whitespace-after": "always",
"@stylistic/indentation": 4,
"keyframe-declaration-no-important": true,
"length-zero-no-unit": true,
"max-empty-lines": 1,
"media-feature-colon-space-after": "always",
"media-feature-colon-space-before": "never",
"media-feature-name-case": "lower",
"@stylistic/max-empty-lines": 1,
"@stylistic/media-feature-colon-space-after": "always",
"@stylistic/media-feature-colon-space-before": "never",
"@stylistic/media-feature-name-case": "lower",
"media-feature-name-no-unknown": true,
"media-feature-name-no-vendor-prefix": true,
"media-feature-parentheses-space-inside": "never",
"media-feature-range-operator-space-after": "always",
"media-feature-range-operator-space-before": "always",
"media-query-list-comma-newline-after": "always-multi-line",
"media-query-list-comma-space-after": "always-single-line",
"media-query-list-comma-space-before": "never",
"@stylistic/media-feature-parentheses-space-inside": "never",
"@stylistic/media-feature-range-operator-space-after": "always",
"@stylistic/media-feature-range-operator-space-before": "always",
"@stylistic/media-query-list-comma-newline-after": "always-multi-line",
"@stylistic/media-query-list-comma-space-after": "always-single-line",
"@stylistic/media-query-list-comma-space-before": "never",
"no-descending-specificity": true,
"no-duplicate-at-import-rules": true,
"no-duplicate-selectors": true,
"no-empty-source": true,
"no-eol-whitespace": true,
"no-extra-semicolons": true,
"@stylistic/no-eol-whitespace": true,
"@stylistic/no-extra-semicolons": true,
"no-invalid-double-slash-comments": true,
"no-missing-end-of-source-newline": true,
"number-leading-zero": "always",
"number-no-trailing-zeros": true,
"@stylistic/no-missing-end-of-source-newline": true,
"@stylistic/number-leading-zero": "always",
"@stylistic/number-no-trailing-zeros": true,
"plugin/no-browser-hacks": true,
"property-case": "lower",
"@stylistic/property-case": "lower",
"property-no-unknown": [
true,
{
@ -110,20 +111,20 @@
"except": ["first-nested"],
"ignore": ["after-comment"]
} ],
"selector-attribute-brackets-space-inside": "never",
"selector-attribute-operator-space-after": "never",
"selector-attribute-operator-space-before": "never",
"selector-combinator-space-after": "always",
"selector-combinator-space-before": "always",
"selector-descendant-combinator-no-non-space": true,
"selector-list-comma-newline-after": "always",
"selector-list-comma-space-before": "never",
"selector-max-empty-lines": 0,
"@stylistic/selector-attribute-brackets-space-inside": "never",
"@stylistic/selector-attribute-operator-space-after": "never",
"@stylistic/selector-attribute-operator-space-before": "never",
"@stylistic/selector-combinator-space-after": "always",
"@stylistic/selector-combinator-space-before": "always",
"@stylistic/selector-descendant-combinator-no-non-space": true,
"@stylistic/selector-list-comma-newline-after": "always",
"@stylistic/selector-list-comma-space-before": "never",
"@stylistic/selector-max-empty-lines": 0,
"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-parentheses-space-inside": "never",
"selector-pseudo-element-case": "lower",
"@stylistic/selector-pseudo-class-parentheses-space-inside": "never",
"@stylistic/selector-pseudo-element-case": "lower",
"selector-pseudo-element-colon-notation": "double",
"selector-pseudo-element-no-unknown": [
true,
@ -136,13 +137,13 @@
"selector-type-case": "lower",
"selector-type-no-unknown": true,
"string-no-newline": true,
"unit-case": "lower",
"@stylistic/unit-case": "lower",
"unit-no-unknown": true,
"value-no-vendor-prefix": true,
"value-list-comma-newline-after": "always-multi-line",
"value-list-comma-space-after": "always-single-line",
"value-list-comma-space-before": "never",
"value-list-max-empty-lines": 0
"@stylistic/value-list-comma-newline-after": "always-multi-line",
"@stylistic/value-list-comma-space-after": "always-single-line",
"@stylistic/value-list-comma-space-before": "never",
"@stylistic/value-list-max-empty-lines": 0
},
"overrides": [
{

View file

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

View file

@ -87,6 +87,15 @@
- [JPUC1143](https://github.com/Jpuc1143)
- [David Angel](https://github.com/davidangel)
- [Pithaya](https://github.com/Pithaya)
- [Peter Santos](https://github.com/prsantos-com)
- [Chaitanya Shahare](https://github.com/Chaitanya-Shahare)
- [Venkat Karasani](https://github.com/venkat-karasani)
- [Connor Smith](https://github.com/ConnorS1110)
- [iFraan](https://github.com/iFraan)
- [Ali](https://github.com/bu3alwa)
- [K. Kyle Puchkov](https://github.com/kepper104)
- [ItsAllAboutTheCode](https://github.com/ItsAllAboutTheCode)
- [Jxiced](https://github.com/Jxiced)
## 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
> [!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
@ -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
├── assets # Static assets
├── components # Higher order visual components and React components
├── controllers # Legacy page views and controllers 🧹
├── elements # Basic webcomponents and React wrappers 🧹
├── constants # Common constant values
├── controllers # Legacy page views and controllers 🧹 ❌
├── elements # Basic webcomponents and React equivalents 🧹
├── hooks # Custom React hooks
├── legacy # Polyfills for legacy browsers
├── libraries # Third party libraries 🧹
├── lib # Reusable libraries
│   ├── globalize # Custom localization library
│   ├── legacy # Polyfills for legacy browsers
│   ├── navdrawer # Navigation drawer library for classic layout
│   └── scroller # Content scrolling library
├── plugins # Client plugins
├── scripts # Random assortment of visual components and utilities 🐉
├── strings # Translation files
├── scripts # Random assortment of visual components and utilities 🐉
├── strings # Translation files (only commit changes to en-us.json)
├── styles # Common app Sass stylesheets
├── themes # CSS themes
├── types # Common TypeScript interfaces/types
└── utils # Utility functions
```
- ❌ &mdash; Deprecated, do **not** create new files here
- 🧹 &mdash; Needs cleanup
- 🐉 &mdash; Serious mess (Here be dragons)

View file

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

View file

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

6
src/apiclient.d.ts vendored
View file

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

View file

@ -2,7 +2,9 @@ import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import { type Theme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import React, { FC, useCallback, 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 AppBody from 'components/AppBody';
@ -10,81 +12,95 @@ import AppToolbar from 'components/toolbar/AppToolbar';
import ElevationScroll from 'components/ElevationScroll';
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
import { useApi } from 'hooks/useApi';
import { useLocale } from 'hooks/useLocale';
import AppTabs from './components/AppTabs';
import AppDrawer from './components/drawer/AppDrawer';
import HelpButton from './components/toolbar/HelpButton';
import { DASHBOARD_APP_PATHS } from './routes/routes';
import './AppOverrides.scss';
interface AppLayoutProps {
drawerlessPaths: string[]
}
const DRAWERLESS_PATHS = [ DASHBOARD_APP_PATHS.MetadataManager ];
const AppLayout: FC<AppLayoutProps> = ({
drawerlessPaths
}) => {
export const Component: FC = () => {
const [ isDrawerActive, setIsDrawerActive ] = useState(false);
const location = useLocation();
const { user } = useApi();
const { dateFnsLocale } = useLocale();
const isMediumScreen = useMediaQuery((t: Theme) => t.breakpoints.up('md'));
const isDrawerAvailable = Boolean(user)
&& !drawerlessPaths.some(path => location.pathname.startsWith(`/${path}`));
&& !DRAWERLESS_PATHS.some(path => location.pathname.startsWith(`/${path}`));
const isDrawerOpen = isDrawerActive && isDrawerAvailable;
const onToggleDrawer = useCallback(() => {
setIsDrawerActive(!isDrawerActive);
}, [ isDrawerActive, setIsDrawerActive ]);
// Update body class
useEffect(() => {
document.body.classList.add('dashboardDocument');
return () => {
document.body.classList.remove('dashboardDocument');
};
}, []);
return (
<Box sx={{ display: 'flex' }}>
<ElevationScroll elevate={false}>
<AppBar
position='fixed'
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={dateFnsLocale}>
<Box sx={{ display: 'flex' }}>
<StrictMode>
<ElevationScroll elevate={false}>
<AppBar
position='fixed'
sx={{
width: {
xs: '100%',
md: isDrawerAvailable ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%'
},
ml: {
xs: 0,
md: isDrawerAvailable ? DRAWER_WIDTH : 0
}
}}
>
<AppToolbar
isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onToggleDrawer}
buttons={
<HelpButton />
}
>
<AppTabs isDrawerOpen={isDrawerOpen} />
</AppToolbar>
</AppBar>
</ElevationScroll>
{
isDrawerAvailable && (
<AppDrawer
open={isDrawerOpen}
onClose={onToggleDrawer}
onOpen={onToggleDrawer}
/>
)
}
</StrictMode>
<Box
component='main'
sx={{
width: {
xs: '100%',
md: isDrawerAvailable ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%'
},
ml: {
xs: 0,
md: isDrawerAvailable ? DRAWER_WIDTH : 0
}
width: '100%',
flexGrow: 1
}}
>
<AppToolbar
isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onToggleDrawer}
>
<AppTabs isDrawerOpen={isDrawerOpen} />
</AppToolbar>
</AppBar>
</ElevationScroll>
{
isDrawerAvailable && (
<AppDrawer
open={isDrawerOpen}
onClose={onToggleDrawer}
onOpen={onToggleDrawer}
/>
)
}
<Box
component='main'
sx={{
width: '100%',
flexGrow: 1
}}
>
<AppBody>
<Outlet />
</AppBody>
<AppBody>
<Outlet />
</AppBody>
</Box>
</Box>
</Box>
</LocalizationProvider>
);
};
export default AppLayout;

View file

@ -5,10 +5,14 @@ $mui-bp-md: 900px;
$mui-bp-lg: 1200px;
$mui-bp-xl: 1536px;
$drawer-width: 240px;
// Fix dashboard pages layout to work with drawer
.dashboardDocument {
.mainAnimatedPage {
position: relative;
.mainAnimatedPage:not(.metadataEditorPage) {
@media all and (min-width: $mui-bp-md) {
left: $drawer-width;
}
}
.skinBody {
@ -27,4 +31,8 @@ $mui-bp-xl: 1536px;
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 />
<DevicesDrawerSection />
<LiveTvDrawerSection />
<AdvancedDrawerSection />
<PluginDrawerSection />
<AdvancedDrawerSection />
</ResponsiveDrawer>
);

View file

@ -1,36 +1,18 @@
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 Schedule from '@mui/icons-material/Schedule';
import VpnKey from '@mui/icons-material/VpnKey';
import Collapse from '@mui/material/Collapse';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader';
import React from 'react';
import { useLocation } from 'react-router-dom';
import ListItemLink from 'components/ListItemLink';
import globalize from 'scripts/globalize';
const PLUGIN_PATHS = [
'/dashboard/plugins',
'/dashboard/plugins/catalog',
'/dashboard/plugins/repositories',
'/dashboard/plugins/add',
'/configurationpage'
];
import globalize from 'lib/globalize';
const AdvancedDrawerSection = () => {
const location = useLocation();
const isPluginSectionOpen = PLUGIN_PATHS.includes(location.pathname);
return (
<List
aria-labelledby='advanced-subheader'
@ -64,36 +46,6 @@ const AdvancedDrawerSection = () => {
<ListItemText primary={globalize.translate('TabLogs')} />
</ListItemLink>
</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>
<ListItemLink to='/dashboard/tasks'>
<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 ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon';
@ -7,7 +7,7 @@ import ListSubheader from '@mui/material/ListSubheader';
import React from 'react';
import ListItemLink from 'components/ListItemLink';
import globalize from 'scripts/globalize';
import globalize from 'lib/globalize';
const DevicesDrawerSection = () => {
return (
@ -35,14 +35,6 @@ const DevicesDrawerSection = () => {
<ListItemText primary={globalize.translate('HeaderActivity')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/dashboard/dlna'>
<ListItemIcon>
<Input />
</ListItemIcon>
<ListItemText primary={'DLNA'} />
</ListItemLink>
</ListItem>
</List>
);
};

View file

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

View file

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

View file

@ -1,15 +1,17 @@
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 List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
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 ListItemLink from 'components/ListItemLink';
import globalize from 'scripts/globalize';
import globalize from 'lib/globalize';
const LIBRARY_PATHS = [
'/dashboard/libraries',
@ -28,8 +30,20 @@ const PLAYBACK_PATHS = [
const ServerDrawerSection = () => {
const location = useLocation();
const isLibrarySectionOpen = LIBRARY_PATHS.includes(location.pathname);
const isPlaybackSectionOpen = PLAYBACK_PATHS.includes(location.pathname);
const [ isLibrarySectionOpen, setIsLibrarySectionOpen ] = useState(LIBRARY_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 (
<List
@ -56,6 +70,12 @@ const ServerDrawerSection = () => {
<ListItemText primary={globalize.translate('General')} />
</ListItemLink>
</ListItem>
<ListItemLink to='/dashboard/branding'>
<ListItemIcon>
<Palette />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderBranding')} />
</ListItemLink>
<ListItem disablePadding>
<ListItemLink to='/dashboard/users'>
<ListItemIcon>
@ -65,13 +85,13 @@ const ServerDrawerSection = () => {
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/dashboard/libraries' selected={false}>
<ListItemButton onClick={onLibrarySectionClick}>
<ListItemIcon>
<LibraryAdd />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderLibraries')} />
{isLibrarySectionOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemLink>
</ListItemButton>
</ListItem>
<Collapse in={isLibrarySectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding>
@ -82,7 +102,7 @@ const ServerDrawerSection = () => {
<ListItemText inset primary={globalize.translate('Display')} />
</ListItemLink>
<ListItemLink to='/dashboard/libraries/metadata' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('Metadata')} />
<ListItemText inset primary={globalize.translate('LabelMetadata')} />
</ListItemLink>
<ListItemLink to='/dashboard/libraries/nfo' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabNfoSettings')} />
@ -90,13 +110,13 @@ const ServerDrawerSection = () => {
</List>
</Collapse>
<ListItem disablePadding>
<ListItemLink to='/dashboard/playback/transcoding' selected={false}>
<ListItemButton onClick={onPlaybackSectionClick}>
<ListItemIcon>
<PlayCircle />
</ListItemIcon>
<ListItemText primary={globalize.translate('TitlePlayback')} />
{isPlaybackSectionOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemLink>
</ListItemButton>
</ListItem>
<Collapse in={isPlaybackSectionOpen} timeout='auto' unmountOnExit>
<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="dashboardSections" style="padding-top:.5em;">
<div class="dashboardColumn dashboardColumn-2-60 dashboardColumn-3-46">
@ -23,10 +23,10 @@
<button is="emby-button" type="button" class="raised btnRefresh">
<span>${ButtonScanAllLibraries}</span>
</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>
</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>
</button>
</div>

View file

@ -1,36 +1,36 @@
import escapeHtml from 'escape-html';
import datetime from '../../scripts/datetime';
import Events from '../../utils/events.ts';
import itemHelper from '../../components/itemHelper';
import serverNotifications from '../../scripts/serverNotifications';
import dom from '../../scripts/dom';
import globalize from '../../scripts/globalize';
import datetime from 'scripts/datetime';
import Events from 'utils/events.ts';
import itemHelper from 'components/itemHelper';
import serverNotifications from 'scripts/serverNotifications';
import dom from 'scripts/dom';
import globalize from 'lib/globalize';
import { formatDistanceToNow } from 'date-fns';
import { getLocaleWithSuffix } from '../../utils/dateFnsLocale.ts';
import loading from '../../components/loading/loading';
import playMethodHelper from '../../components/playback/playmethodhelper';
import cardBuilder from '../../components/cardbuilder/cardBuilder';
import imageLoader from '../../components/images/imageLoader';
import ActivityLog from '../../components/activitylog';
import imageHelper from '../../utils/image';
import indicators from '../../components/indicators/indicators';
import taskButton from '../../scripts/taskbutton';
import Dashboard from '../../utils/dashboard';
import ServerConnections from '../../components/ServerConnections';
import alert from '../../components/alert';
import confirm from '../../components/confirm/confirm';
import { getDefaultBackgroundClass } from '../../components/cardbuilder/cardBuilderUtils';
import { getLocaleWithSuffix } from 'utils/dateFnsLocale.ts';
import loading from 'components/loading/loading';
import playMethodHelper from 'components/playback/playmethodhelper';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import imageLoader from 'components/images/imageLoader';
import ActivityLog from 'components/activitylog';
import imageHelper from 'utils/image';
import indicators from 'components/indicators/indicators';
import taskButton from 'scripts/taskbutton';
import Dashboard from 'utils/dashboard';
import ServerConnections from 'components/ServerConnections';
import alert from 'components/alert';
import confirm from 'components/confirm/confirm';
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
import { getSystemInfoQuery } from 'hooks/useSystemInfo';
import { toApi } from 'utils/jellyfin-apiclient/compat';
import { queryClient } from 'utils/query/queryClient';
import '../../elements/emby-button/emby-button';
import '../../elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-button/emby-button';
import 'elements/emby-itemscontainer/emby-itemscontainer';
import '../../components/listview/listview.scss';
import '../../styles/flexstyles.scss';
import 'components/listview/listview.scss';
import 'styles/flexstyles.scss';
import './dashboard.scss';
function showPlaybackInfo(btn, session) {
@ -72,7 +72,7 @@ function showPlaybackInfo(btn, session) {
}
function showSendMessageForm(btn, session) {
import('../../components/prompt/prompt').then(({ default: prompt }) => {
import('components/prompt/prompt').then(({ default: prompt }) => {
prompt({
title: globalize.translate('HeaderSendMessage'),
label: globalize.translate('LabelMessageText'),
@ -89,7 +89,7 @@ function showSendMessageForm(btn, session) {
}
function showOptionsMenu(btn, session) {
import('../../components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
const menuItems = [];
if (session.ServerId && session.DeviceId !== ServerConnections.deviceId()) {
@ -208,7 +208,12 @@ function refreshActiveRecordings(view, apiClient) {
function reloadSystemInfo(view, apiClient) {
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
.fetchQuery(getSystemInfoQuery(toApi(apiClient)))
@ -319,7 +324,7 @@ function renderActiveConnections(view, sessions) {
html += '<div class="sessionCardButtons flex align-items-center justify-content-center">';
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 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;
}
window.DashboardPage = {
const DashboardPage = {
startInterval: function (apiClient) {
apiClient.sendMessage('SessionsStart', '0,1500');
apiClient.sendMessage('ScheduledTasksInfoStart', '0,1000');
@ -709,33 +714,38 @@ window.DashboardPage = {
pollForInfo(page, ApiClient);
});
},
restart: function (btn) {
restart: function (event) {
confirm({
title: globalize.translate('Restart'),
text: globalize.translate('MessageConfirmRestart'),
confirmText: globalize.translate('Restart'),
primary: 'delete'
}).then(function () {
const page = dom.parentWithClass(btn, 'page');
}).then(() => {
const page = dom.parentWithClass(event.target, 'page');
page.querySelector('#btnRestartServer').disabled = true;
page.querySelector('#btnShutdown').disabled = true;
ApiClient.restartServer();
}).catch(() => {
// Confirm dialog closed
});
},
shutdown: function (btn) {
shutdown: function (event) {
confirm({
title: globalize.translate('ButtonShutdown'),
text: globalize.translate('MessageConfirmShutdown'),
confirmText: globalize.translate('ButtonShutdown'),
primary: 'delete'
}).then(function () {
const page = dom.parentWithClass(btn, 'page');
}).then(() => {
const page = dom.parentWithClass(event.target, 'page');
page.querySelector('#btnRestartServer').disabled = true;
page.querySelector('#btnShutdown').disabled = true;
ApiClient.shutdownServer();
}).catch(() => {
// Confirm dialog closed
});
}
};
export default function (view) {
function onRestartRequired(evt, apiClient) {
console.debug('onRestartRequired not implemented', evt, apiClient);
@ -811,7 +821,11 @@ export default function (view) {
taskKey: 'RefreshLibrary',
button: page.querySelector('.btnRefresh')
});
page.querySelector('#btnRestartServer').addEventListener('click', DashboardPage.restart);
page.querySelector('#btnShutdown').addEventListener('click', DashboardPage.shutdown);
});
view.addEventListener('viewbeforehide', function () {
const apiClient = ApiClient;
const page = this;
@ -833,6 +847,9 @@ export default function (view) {
taskKey: 'RefreshLibrary',
button: page.querySelector('.btnRefresh')
});
page.querySelector('#btnRestartServer').removeEventListener('click', DashboardPage.restart);
page.querySelector('#btnShutdown').removeEventListener('click', DashboardPage.shutdown);
});
view.addEventListener('viewdestroy', function () {
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 class="content-primary">
<form class="encodingSettingsForm">
<div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center">
<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 class="selectContainer">
<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="nvenc">Nvidia NVENC</option>
<option value="qsv">Intel QuickSync (QSV)</option>
@ -30,6 +29,11 @@
<div class="fieldDescription">${LabelVaapiDeviceHelp}</div>
</div>
<div class="inputContainer hide fldQsvDevice">
<input is="emby-input" type="text" id="txtQsvDevice" label="${LabelQsvDevice}" />
<div class="fieldDescription">${LabelQsvDeviceHelp}</div>
</div>
<div class="hardwareAccelerationOptions hide">
<div class="checkboxListContainer decodingCodecsList">
<h3 class="checkboxListLabel">${LabelEnableHardwareDecodingFor}</h3>
@ -81,6 +85,16 @@
<span>VP9 10bit</span>
</label>
</div>
<div class="checkboxList hide fldHevcRextHwDecoding">
<label>
<input type="checkbox" is="emby-checkbox" id="chkDecodingColorDepth10HevcRext" />
<span>HEVC RExt 8/10bit</span>
</label>
<label>
<input type="checkbox" is="emby-checkbox" id="chkDecodingColorDepth12HevcRext" />
<span>HEVC RExt 12bit</span>
</label>
</div>
</div>
<div class="checkboxListContainer hide fldEnhancedNvdec">
@ -168,12 +182,13 @@
</div>
<div class="tonemappingOptions hide">
<div class="checkboxListContainer checkboxContainer-withDescription">
<div class="checkboxListContainer checkboxContainer-withDescription fldTonemapCheckbox hide">
<label>
<input type="checkbox" is="emby-checkbox" id="chkTonemapping" />
<span>${EnableTonemapping}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${AllowTonemappingHelp}</div>
<div class="fieldDescription checkboxFieldDescription allowTonemappingHardwareHelp">${AllowTonemappingHelp}</div>
<div class="fieldDescription checkboxFieldDescription allowTonemappingSoftwareHelp">${AllowTonemappingSoftwareHelp}</div>
</div>
<div class="selectContainer">
<select is="emby-select" id="selectTonemappingAlgorithm" label="${LabelTonemappingAlgorithm}">
@ -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>
</div>
</div>
<div class="selectContainer">
<div class="tonemappingModeOptions selectContainer">
<select is="emby-select" id="selectTonemappingMode" label="${LabelTonemappingMode}">
<option value="auto">${Auto}</option>
<option value="max">MAX</option>
<option value="rgb">RGB</option>
<option value="lum">LUM</option>
<option value="itp">ITP</option>
</select>
<div class="fieldDescription">${TonemappingModeHelp}</div>
</div>
@ -297,6 +314,8 @@
<option value="None">${None}</option>
<option value="Dave750">Dave750</option>
<option value="NightmodeDialogue">NightmodeDialogue</option>
<option value="Rfc7845">RFC7845</option>
<option value="Ac4">AC-4</option>
</select>
<div class="fieldDescription">${StereoDownmixAlgorithmHelp}</div>
</div>
@ -307,7 +326,7 @@
<div class="selectContainer">
<select is="emby-select" id="selectEncoderPreset" label="${LabelEncoderPreset}">
<option value="">${Auto}</option>
<option value="auto">${Auto}</option>
<option value="veryslow">veryslow</option>
<option value="slower">slower</option>
<option value="slow">slow</option>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,15 @@
import loading from '../components/loading/loading';
import globalize from '../scripts/globalize';
import Dashboard, { pageIdOn } from '../utils/dashboard';
import { getParameterByName } from '../utils/url.ts';
import Events from '../utils/events.ts';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import Dashboard, { pageIdOn } from 'utils/dashboard';
import { getParameterByName } from 'utils/url';
import Events from 'utils/events';
function onListingsSubmitted() {
Dashboard.navigate('dashboard/livetv');
}
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, {});
Events.on(instance, 'submitted', onListingsSubmitted);
instance.init();
@ -17,7 +17,7 @@ function init(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);
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 class="content-primary">
<div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center">
<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>

View file

@ -1,16 +1,17 @@
import 'jquery';
import loading from '../components/loading/loading';
import globalize from '../scripts/globalize';
import '../elements/emby-button/emby-button';
import Dashboard from '../utils/dashboard';
import alert from '../components/alert';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import 'elements/emby-button/emby-button';
import Dashboard from 'utils/dashboard';
import alert from 'components/alert';
function loadPage(page, config) {
$('.liveTvSettingsForm', page).show();
$('.noLiveTvServices', page).hide();
$('#selectGuideDays', page).val(config.GuideDays || '');
$('#txtPrePaddingMinutes', page).val(config.PrePaddingSeconds / 60);
$('#txtPostPaddingMinutes', page).val(config.PostPaddingSeconds / 60);
page.querySelector('.liveTvSettingsForm').classList.remove('hide');
page.querySelector('.noLiveTvServices')?.classList.add('hide');
page.querySelector('#selectGuideDays').value = config.GuideDays || '';
page.querySelector('#txtPrePaddingMinutes').value = config.PrePaddingSeconds / 60;
page.querySelector('#txtPostPaddingMinutes').value = config.PostPaddingSeconds / 60;
page.querySelector('#txtRecordingPath').value = config.RecordingPath || '';
page.querySelector('#txtMovieRecordingPath').value = config.MovieRecordingPath || '';
page.querySelector('#txtSeriesRecordingPath').value = config.SeriesRecordingPath || '';
@ -25,7 +26,7 @@ function onSubmit() {
loading.show();
const form = this;
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 movieRecordingPath = form.querySelector('#txtMovieRecordingPath').value || null;
const seriesRecordingPath = form.querySelector('#txtSeriesRecordingPath').value || null;
@ -34,10 +35,10 @@ function onSubmit() {
config.MovieRecordingPath = movieRecordingPath;
config.SeriesRecordingPath = seriesRecordingPath;
config.RecordingEncodingFormat = 'mkv';
config.PrePaddingSeconds = 60 * $('#txtPrePaddingMinutes', form).val();
config.PostPaddingSeconds = 60 * $('#txtPostPaddingMinutes', form).val();
config.RecordingPostProcessor = $('#txtPostProcessor', form).val();
config.RecordingPostProcessorArguments = $('#txtPostProcessorArguments', form).val();
config.PrePaddingSeconds = 60 * form.querySelector('#txtPrePaddingMinutes').value;
config.PostPaddingSeconds = 60 * form.querySelector('#txtPostPaddingMinutes').value;
config.RecordingPostProcessor = form.querySelector('#txtPostProcessor').value;
config.RecordingPostProcessorArguments = form.querySelector('#txtPostProcessorArguments').value;
config.SaveRecordingNFO = form.querySelector('#chkSaveRecordingNFO').checked;
config.SaveRecordingImages = form.querySelector('#chkSaveRecordingImages').checked;
ApiClient.updateNamedConfiguration('livetv', config).then(function () {
@ -64,12 +65,12 @@ $(document).on('pageinit', '#liveTvSettingsPage', function () {
const page = this;
$('.liveTvSettingsForm').off('submit', onSubmit).on('submit', onSubmit);
$('#btnSelectRecordingPath', page).on('click.selectDirectory', function () {
import('../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser();
picker.show({
callback: function (path) {
if (path) {
$('#txtRecordingPath', page).val(path);
page.querySelector('#txtRecordingPath').value = path;
}
picker.close();
@ -79,12 +80,12 @@ $(document).on('pageinit', '#liveTvSettingsPage', 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();
picker.show({
callback: function (path) {
if (path) {
$('#txtMovieRecordingPath', page).val(path);
page.querySelector('#txtMovieRecordingPath').value = path;
}
picker.close();
@ -94,12 +95,12 @@ $(document).on('pageinit', '#liveTvSettingsPage', 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();
picker.show({
callback: function (path) {
if (path) {
$('#txtSeriesRecordingPath', page).val(path);
page.querySelector('#txtSeriesRecordingPath').value = path;
}
picker.close();
@ -109,13 +110,13 @@ $(document).on('pageinit', '#liveTvSettingsPage', 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();
picker.show({
includeFiles: true,
callback: function (path) {
if (path) {
$('#txtPostProcessor', page).val(path);
page.querySelector('#txtPostProcessor').value = path;
}
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 class="content-primary">
<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}">
<span class="material-icons add" aria-hidden="true"></span>
</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 class="devicesList itemsContainer vertical-wrap" data-hovermenu="false" data-multiselect="false" style="margin-top: .5em;"></div>
</div>

View file

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

View file

@ -5,7 +5,6 @@
<div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center">
<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>
@ -38,6 +37,11 @@
<div class="fieldDescription">${SimultaneousConnectionLimitHelp}</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">
<label>
<input type="checkbox" is="emby-checkbox" class="chkFavorite" />
@ -54,6 +58,22 @@
<div class="fieldDescription checkboxFieldDescription">${AllowHWTranscodingHelp}</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">
<label>
<input type="checkbox" is="emby-checkbox" class="chkStreamLoop" />

View file

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

View file

@ -2,11 +2,11 @@ import { ImageResolution } from '@jellyfin/sdk/lib/generated-client/models/image
import 'jquery';
import loading from '../../components/loading/loading';
import globalize from '../../scripts/globalize';
import Dashboard from '../../utils/dashboard';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import Dashboard from 'utils/dashboard';
import '../../components/listview/listview.scss';
import 'components/listview/listview.scss';
function populateImageResolutionOptions(select) {
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>

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>

View file

@ -1,17 +1,20 @@
import escapeHtml from 'escape-html';
import 'jquery';
import loading from '../../components/loading/loading';
import globalize from '../../scripts/globalize';
import Dashboard from '../../utils/dashboard';
import alert from '../../components/alert';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import Dashboard from 'utils/dashboard';
import alert from 'components/alert';
function loadPage(page, config, users) {
let html = '<option value="" selected="selected">' + globalize.translate('None') + '</option>';
html += users.map(function (user) {
return '<option value="' + user.Id + '">' + escapeHtml(user.Name) + '</option>';
}).join('');
$('#selectUser', page).html(html).val(config.UserId || '');
$('#selectReleaseDateFormat', page).val(config.ReleaseDateFormat);
const elem = page.querySelector('#selectUser');
elem.innerHTML = html;
elem.value = config.UserId || '';
page.querySelector('#selectReleaseDateFormat').value = config.ReleaseDateFormat;
page.querySelector('#chkSaveImagePaths').checked = config.SaveImagePathsInNfo;
page.querySelector('#chkEnablePathSubstitution').checked = config.EnablePathSubstitution;
page.querySelector('#chkEnableExtraThumbs').checked = config.EnableExtraThumbsDuplication;
@ -22,8 +25,8 @@ function onSubmit() {
loading.show();
const form = this;
ApiClient.getNamedConfiguration(metadataKey).then(function (config) {
config.UserId = $('#selectUser', form).val() || null;
config.ReleaseDateFormat = $('#selectReleaseDateFormat', form).val();
config.UserId = form.querySelector('#selectUser').value || null;
config.ReleaseDateFormat = form.querySelector('#selectReleaseDateFormat').value;
config.SaveImagePathsInNfo = form.querySelector('#chkSaveImagePaths').checked;
config.EnablePathSubstitution = form.querySelector('#chkEnablePathSubstitution').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 class="content-primary">
<form class="dashboardHostingForm">
<div class="verticalSection verticalSection-extrabottompadding">
<div class="sectionTitleContainer flex align-items-center">
<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>
<fieldset class='verticalSection verticalSection-extrabottompadding'>
@ -97,14 +96,6 @@
<option value="blacklist">${Blacklist}</option>
</select>
</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">
<input is="emby-input" type="number" label="${LabelPublicHttpPort}" id="txtPublicHttpPort" pattern="[0-9]*" required="required" min="1" max="65535" />
<div class="fieldDescription">${LabelPublicHttpPortHelp}</div>

View file

@ -1,15 +1,14 @@
import loading from '../../components/loading/loading';
import globalize from '../../scripts/globalize';
import '../../elements/emby-checkbox/emby-checkbox';
import '../../elements/emby-select/emby-select';
import Dashboard from '../../utils/dashboard';
import alert from '../../components/alert';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import 'elements/emby-checkbox/emby-checkbox';
import 'elements/emby-select/emby-select';
import Dashboard from 'utils/dashboard';
import alert from 'components/alert';
function onSubmit(e) {
const form = this;
const localAddress = form.querySelector('#txtLocalAddress').value;
const enableUpnp = form.querySelector('#chkEnableUpnp').checked;
confirmSelections(localAddress, enableUpnp, function () {
confirmSelections(localAddress, function () {
const validationResult = getValidationAlert(form);
if (validationResult) {
@ -54,7 +53,6 @@ function onSubmit(e) {
config.InternalHttpsPort = form.querySelector('#txtHttpsPort').value;
config.EnableHttps = form.querySelector('#chkEnableHttps').checked;
config.RequireHttps = form.querySelector('#chkRequireHttps').checked;
config.EnableUPnP = enableUpnp;
config.BaseUrl = form.querySelector('#txtBaseUrl').value;
config.EnableRemoteAccess = form.querySelector('#chkRemoteAccess').checked;
config.CertificatePath = form.querySelector('#txtCertificatePath').value || null;
@ -110,8 +108,8 @@ function showAlertText(options) {
});
}
function confirmSelections(localAddress, enableUpnp, callback) {
if (localAddress || !enableUpnp) {
function confirmSelections(localAddress, callback) {
if (localAddress) {
showAlertText({
title: globalize.translate('TitleHostingSettings'),
text: globalize.translate('SettingsWarning')
@ -139,7 +137,6 @@ export default function (view) {
const txtCertificatePath = page.querySelector('#txtCertificatePath');
txtCertificatePath.value = config.CertificatePath || '';
page.querySelector('#txtCertPassword').value = config.CertificatePassword || '';
page.querySelector('#chkEnableUpnp').checked = config.EnableUPnP;
triggerChange(page.querySelector('#chkRemoteAccess'));
page.querySelector('#chkAutodiscovery').checked = config.AutoDiscovery;
page.querySelector('#chkEnableIP6').checked = config.EnableIPv6;
@ -154,17 +151,15 @@ export default function (view) {
view.querySelector('.fldExternalAddressFilterMode').classList.remove('hide');
view.querySelector('.fldPublicHttpPort').classList.remove('hide');
view.querySelector('.fldPublicHttpsPort').classList.remove('hide');
view.querySelector('.fldEnableUpnp').classList.remove('hide');
} else {
view.querySelector('.fldExternalAddressFilter').classList.add('hide');
view.querySelector('.fldExternalAddressFilterMode').classList.add('hide');
view.querySelector('.fldPublicHttpPort').classList.add('hide');
view.querySelector('.fldPublicHttpsPort').classList.add('hide');
view.querySelector('.fldEnableUpnp').classList.add('hide');
}
});
view.querySelector('#btnSelectCertPath').addEventListener('click', function () {
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser();
picker.show({
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 loading from '../../../../components/loading/loading';
import globalize from '../../../../scripts/globalize';
import '../../../../components/cardbuilder/card.scss';
import '../../../../elements/emby-button/emby-button';
import '../../../../elements/emby-checkbox/emby-checkbox';
import '../../../../elements/emby-select/emby-select';
import { getDefaultBackgroundClass } from '../../../../components/cardbuilder/cardBuilderUtils';
import { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels';
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import 'components/cardbuilder/card.scss';
import 'elements/emby-button/emby-button';
import 'elements/emby-checkbox/emby-checkbox';
import 'elements/emby-select/emby-select';
function reloadList(page) {
loading.show();
@ -23,19 +25,14 @@ function reloadList(page) {
}
function getHeaderText(category) {
category = category.replace(' ', '');
// TODO: Replace with switch
if (category === 'Channel') {
category = 'Channels';
} else if (category === 'Theme') {
category = 'Themes';
} else if (category === 'LiveTV') {
category = 'LiveTV';
} else if (category === 'ScreenSaver') {
category = 'HeaderScreenSavers';
const categoryKey = category.replaceAll(' ', '');
if (CATEGORY_LABELS[categoryKey]) {
return globalize.translate(CATEGORY_LABELS[categoryKey]);
}
return globalize.translate(category);
console.warn('[AvailablePlugins] unmapped category label', category);
return category;
}
function populateList(options) {
@ -43,7 +40,7 @@ function populateList(options) {
const installedPlugins = options.installedPlugins;
availablePlugins.forEach(function (plugin, index, array) {
plugin.category = plugin.category || 'General';
plugin.category = plugin.category || 'Other';
plugin.categoryDisplayName = getHeaderText(plugin.category);
array[index] = plugin;
});
@ -119,7 +116,8 @@ function onSearchBarType(searchBar) {
function getPluginHtml(plugin, options, installedPlugins) {
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) {
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 class="content-primary">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${TabMyPlugins}</h2>
</div>
<div class="inputContainer">
<input id="txtSearchPlugins" name="txtSearchPlugins" type="text" is="emby-input" label="${Search}" />
</div>

View file

@ -1,11 +1,11 @@
import loading from '../../../../components/loading/loading';
import dom from '../../../../scripts/dom';
import globalize from '../../../../scripts/globalize';
import '../../../../components/cardbuilder/card.scss';
import '../../../../elements/emby-button/emby-button';
import Dashboard, { pageIdOn } from '../../../../utils/dashboard';
import confirm from '../../../../components/confirm/confirm';
import { getDefaultBackgroundClass } from '../../../../components/cardbuilder/cardBuilderUtils';
import loading from 'components/loading/loading';
import dom from 'scripts/dom';
import globalize from 'lib/globalize';
import 'components/cardbuilder/card.scss';
import 'elements/emby-button/emby-button';
import Dashboard, { pageIdOn } from 'utils/dashboard';
import confirm from 'components/confirm/confirm';
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
function deletePlugin(page, uniqueid, version, name) {
const msg = globalize.translate('UninstallPluginConfirmation', name);
@ -187,7 +187,7 @@ function showPluginMenu(page, elem) {
});
}
import('../../../../components/actionSheet/actionSheet').then((actionsheet) => {
import('components/actionSheet/actionSheet').then((actionsheet) => {
actionsheet.show({
items: menuItems,
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 class="content-primary">
<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}">
<span class="material-icons add" aria-hidden="true"></span>
</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 id="repositories"></div>

View file

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

View file

@ -4,7 +4,6 @@
<div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center">
<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>
<p id="pTaskDescription"></p>
</div>

View file

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