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

Merge branch 'master' into segment-deletion

This commit is contained in:
Dominik 2023-06-15 20:30:56 +02:00 committed by GitHub
commit 128184cc72
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
497 changed files with 70077 additions and 54756 deletions

View file

@ -26,8 +26,6 @@ jobs:
- script: 'npm ci --no-audit' - script: 'npm ci --no-audit'
displayName: 'Install Dependencies' displayName: 'Install Dependencies'
env:
SKIP_PREPARE: 'true'
- script: 'npm run build:development' - script: 'npm run build:development'
displayName: 'Build Development' displayName: 'Build Development'

View file

@ -6,7 +6,6 @@
"./dist/libraries/pdf.worker.js", "./dist/libraries/pdf.worker.js",
"./dist/libraries/worker-bundle.js", "./dist/libraries/worker-bundle.js",
"./dist/libraries/wasm-gen/libarchive.js", "./dist/libraries/wasm-gen/libarchive.js",
"./dist/node_modules.@jellyfin.libass-wasm.*.chunk.js",
"./dist/serviceworker.js" "./dist/serviceworker.js"
] ]
} }

View file

@ -2,8 +2,9 @@ const restrictedGlobals = require('confusing-browser-globals');
module.exports = { module.exports = {
root: true, root: true,
parser: '@typescript-eslint/parser',
plugins: [ plugins: [
'@babel', '@typescript-eslint',
'react', 'react',
'promise', 'promise',
'import', 'import',
@ -16,14 +17,6 @@ module.exports = {
es2017: true, es2017: true,
es2020: true es2020: true
}, },
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: {
impliedStrict: true,
jsx: true
}
},
extends: [ extends: [
'eslint:recommended', 'eslint:recommended',
'plugin:react/recommended', 'plugin:react/recommended',
@ -46,51 +39,73 @@ module.exports = {
'keyword-spacing': ['error'], 'keyword-spacing': ['error'],
'no-throw-literal': ['error'], 'no-throw-literal': ['error'],
'max-statements-per-line': ['error'], 'max-statements-per-line': ['error'],
'max-params': ['error', 7],
'no-duplicate-imports': ['error'], 'no-duplicate-imports': ['error'],
'no-empty-function': ['error'], 'no-empty-function': ['error'],
'no-floating-decimal': ['error'], 'no-floating-decimal': ['error'],
'no-multi-spaces': ['error'], 'no-multi-spaces': ['error'],
'no-multiple-empty-lines': ['error', { 'max': 1 }], 'no-multiple-empty-lines': ['error', { 'max': 1 }],
'no-nested-ternary': ['error'], 'no-nested-ternary': ['error'],
'no-redeclare': ['off'],
'@typescript-eslint/no-redeclare': ['error', { builtinGlobals: false }],
'no-restricted-globals': ['error'].concat(restrictedGlobals), 'no-restricted-globals': ['error'].concat(restrictedGlobals),
'no-return-assign': ['error'], 'no-return-assign': ['error'],
'no-return-await': ['error'], 'no-return-await': ['error'],
'no-sequences': ['error', { 'allowInParentheses': false }], 'no-sequences': ['error', { 'allowInParentheses': false }],
'no-shadow': ['off'],
'@typescript-eslint/no-shadow': ['error'],
'no-trailing-spaces': ['error'], 'no-trailing-spaces': ['error'],
'@babel/no-unused-expressions': ['error', { 'allowShortCircuit': true, 'allowTernary': true, 'allowTaggedTemplates': true }], 'no-unused-expressions': ['off'],
'no-useless-constructor': ['error'], '@typescript-eslint/no-unused-expressions': ['error', { 'allowShortCircuit': true, 'allowTernary': true, 'allowTaggedTemplates': true }],
'no-useless-constructor': ['off'],
'@typescript-eslint/no-useless-constructor': ['error'],
'no-var': ['error'], 'no-var': ['error'],
'no-void': ['error', { 'allowAsStatement': true }], 'no-void': ['error', { 'allowAsStatement': true }],
'no-warning-comments': ['warn', { 'terms': ['fixme', 'hack', 'xxx'] }], 'no-warning-comments': ['warn', { 'terms': ['fixme', 'hack', 'xxx'] }],
'object-curly-spacing': ['error', 'always'],
'one-var': ['error', 'never'], 'one-var': ['error', 'never'],
'operator-linebreak': ['error', 'before', { overrides: { '?': 'after', ':': 'after', '=': 'after' } }],
'padded-blocks': ['error', 'never'], 'padded-blocks': ['error', 'never'],
'prefer-const': ['error', { 'destructuring': 'all' }], 'prefer-const': ['error', { 'destructuring': 'all' }],
'@typescript-eslint/prefer-for-of': ['error'],
'quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': false }], 'quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': false }],
'@babel/semi': ['error'], 'radix': ['error'],
'@typescript-eslint/semi': ['error'],
'space-before-blocks': ['error'], 'space-before-blocks': ['error'],
'space-infix-ops': 'error', 'space-infix-ops': 'error',
'yoda': 'error', 'yoda': 'error',
'@typescript-eslint/no-shadow': 'error',
'react/jsx-filename-extension': ['error', { 'extensions': ['.jsx', '.tsx'] }], 'react/jsx-filename-extension': ['error', { 'extensions': ['.jsx', '.tsx'] }],
'react/jsx-no-bind': ['error'],
'react/jsx-no-constructed-context-values': ['error'],
'react/no-array-index-key': ['error'],
'sonarjs/cognitive-complexity': ['warn'], 'sonarjs/no-inverted-boolean-check': ['error'],
// TODO: Enable the following rules and fix issues // TODO: Enable the following rules and fix issues
'sonarjs/cognitive-complexity': ['off'],
'sonarjs/no-duplicate-string': ['off'] 'sonarjs/no-duplicate-string': ['off']
}, },
settings: { settings: {
react: { react: {
version: 'detect' version: 'detect'
}, },
'import/extensions': [
'.js',
'.ts',
'.jsx',
'.tsx'
],
'import/parsers': { 'import/parsers': {
'@typescript-eslint/parser': [ '.ts', '.tsx' ] '@typescript-eslint/parser': [ '.ts', '.tsx' ]
}, },
'import/resolver': {
node: {
extensions: [
'.js',
'.ts',
'.jsx',
'.tsx'
],
moduleDirectory: [
'node_modules',
'src'
]
}
},
polyfills: [ polyfills: [
// Native Promises Only // Native Promises Only
'Promise', 'Promise',
@ -193,9 +208,12 @@ module.exports = {
files: [ files: [
'./src/**/*.js', './src/**/*.js',
'./src/**/*.jsx', './src/**/*.jsx',
'./src/**/*.ts' './src/**/*.ts',
'./src/**/*.tsx'
], ],
parser: '@babel/eslint-parser', parserOptions: {
project: ['./tsconfig.json']
},
env: { env: {
node: false, node: false,
amd: true, amd: true,
@ -235,6 +253,8 @@ module.exports = {
'Windows': 'readonly' 'Windows': 'readonly'
}, },
rules: { rules: {
'@typescript-eslint/no-floating-promises': ['warn'],
'@typescript-eslint/prefer-string-starts-ends-with': ['error']
} }
}, },
// TypeScript source files // TypeScript source files
@ -243,8 +263,6 @@ module.exports = {
'./src/**/*.ts', './src/**/*.ts',
'./src/**/*.tsx' './src/**/*.tsx'
], ],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: [ extends: [
'eslint:recommended', 'eslint:recommended',
'plugin:import/typescript', 'plugin:import/typescript',
@ -255,8 +273,9 @@ module.exports = {
'plugin:jsx-a11y/recommended' 'plugin:jsx-a11y/recommended'
], ],
rules: { rules: {
'no-useless-constructor': ['off'], '@typescript-eslint/no-floating-promises': ['error'],
'@typescript-eslint/no-useless-constructor': ['error']
'sonarjs/cognitive-complexity': ['error']
} }
} }
] ]

6
.github/CODEOWNERS vendored
View file

@ -1,6 +1,2 @@
.ci @dkanada @EraYaN * @jellyfin/web
.github @jellyfin/core .github @jellyfin/core
fedora @joshuaboniface
debian @joshuaboniface
.copr @joshuaboniface
deployment @joshuaboniface

View file

@ -17,4 +17,5 @@ jobs:
- uses: eps1lon/actions-label-merge-conflict@fd1f295ee7443d13745804bc49fe158e240f6c6e # tag=v2.1.0 - uses: eps1lon/actions-label-merge-conflict@fd1f295ee7443d13745804bc49fe158e240f6c6e # tag=v2.1.0
with: with:
dirtyLabel: 'merge conflict' 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 }} repoToken: ${{ secrets.JF_BOT_TOKEN }}

View file

@ -19,13 +19,13 @@ jobs:
language: [ 'javascript' ] language: [ 'javascript' ]
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3 uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@c3b6fce4ee2ca25bc1066aa3bf73962fda0e8898 # tag=v2 uses: github/codeql-action/init@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-extended queries: +security-extended
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@c3b6fce4ee2ca25bc1066aa3bf73962fda0e8898 # tag=v2 uses: github/codeql-action/autobuild@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@c3b6fce4ee2ca25bc1066aa3bf73962fda0e8898 # tag=v2 uses: github/codeql-action/analyze@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6

View file

@ -12,17 +12,25 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Notify as seen - name: Notify as seen
uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2.1.0 uses: peter-evans/create-or-update-comment@ca08ebd5dc95aa0cd97021e9708fcd6b87138c9b # v3.0.1
with: with:
token: ${{ secrets.JF_BOT_TOKEN }} token: ${{ secrets.JF_BOT_TOKEN }}
comment-id: ${{ github.event.comment.id }} comment-id: ${{ github.event.comment.id }}
reactions: '+1' reactions: '+1'
- name: Checkout the latest code - name: Checkout the latest code
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3.1.0 uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
with: with:
token: ${{ secrets.JF_BOT_TOKEN }} token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0 fetch-depth: 0
- name: Automatic Rebase - name: Automatic Rebase
uses: cirrus-actions/rebase@6e572f08c244e2f04f9beb85a943eb618218714d # tag=1.7 uses: cirrus-actions/rebase@b87d48154a87a85666003575337e27b8cd65f691 # 1.8
env: env:
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }} GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
- name: Comment on failure
if: failure()
uses: peter-evans/create-or-update-comment@ca08ebd5dc95aa0cd97021e9708fcd6b87138c9b # v3.0.1
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

@ -13,10 +13,10 @@ jobs:
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3 uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
- name: Setup node environment - name: Setup node environment
uses: actions/setup-node@8c91899e586c5b171469028077307d293428b516 # tag=v3.5.1 uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0
with: with:
node-version: 16 node-version: 16
check-latest: true check-latest: true
@ -24,8 +24,6 @@ jobs:
- name: Install Node.js dependencies - name: Install Node.js dependencies
run: npm ci --no-audit run: npm ci --no-audit
env:
SKIP_PREPARE: true
- name: Run a production build - name: Run a production build
run: npm run build:production run: npm run build:production
@ -39,10 +37,10 @@ jobs:
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3 uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
- name: Setup node environment - name: Setup node environment
uses: actions/setup-node@8c91899e586c5b171469028077307d293428b516 # tag=v3.5.1 uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0
with: with:
node-version: 16 node-version: 16
check-latest: true check-latest: true
@ -50,8 +48,6 @@ jobs:
- name: Install Node.js dependencies - name: Install Node.js dependencies
run: npm ci --no-audit run: npm ci --no-audit
env:
SKIP_PREPARE: true
- name: Run eslint - name: Run eslint
run: npm run lint run: npm run lint
@ -62,10 +58,10 @@ jobs:
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3 uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
- name: Setup node environment - name: Setup node environment
uses: actions/setup-node@8c91899e586c5b171469028077307d293428b516 # tag=v3.5.1 uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0
with: with:
node-version: 16 node-version: 16
check-latest: true check-latest: true
@ -76,8 +72,6 @@ jobs:
- name: Install Node.js dependencies - name: Install Node.js dependencies
run: npm ci --no-audit run: npm ci --no-audit
env:
SKIP_PREPARE: true
- name: Run stylelint - name: Run stylelint
run: npm run stylelint:css run: npm run stylelint:css
@ -88,10 +82,10 @@ jobs:
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3 uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
- name: Setup node environment - name: Setup node environment
uses: actions/setup-node@8c91899e586c5b171469028077307d293428b516 # tag=v3.5.1 uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0
with: with:
node-version: 16 node-version: 16
check-latest: true check-latest: true
@ -102,8 +96,6 @@ jobs:
- name: Install Node.js dependencies - name: Install Node.js dependencies
run: npm ci --no-audit run: npm ci --no-audit
env:
SKIP_PREPARE: true
- name: Run stylelint - name: Run stylelint
run: npm run stylelint:scss run: npm run stylelint:scss

View file

@ -1,18 +1,24 @@
name: Issue Stale Check name: Stale Check
on: on:
schedule: schedule:
- cron: '30 1 * * *' - cron: '30 1 * * *'
workflow_dispatch: workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs: jobs:
stale: issues:
name: Check issues
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }} if: ${{ contains(github.repository, 'jellyfin/') }}
steps: steps:
- uses: actions/stale@5ebf00ea0e4c1561e9b43a292ed34424fb1d4578 # tag=v6.0.1 - uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0
with: with:
repo-token: ${{ secrets.JF_BOT_TOKEN }} repo-token: ${{ secrets.JF_BOT_TOKEN }}
operations-per-run: 75
days-before-stale: 120 days-before-stale: 120
days-before-pr-stale: -1 days-before-pr-stale: -1
days-before-close: 21 days-before-close: 21
@ -25,3 +31,21 @@ jobs:
If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or master branch, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label. If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or master branch, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label.
This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://jellyfin.org/contact). This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://jellyfin.org/contact).
prs-conflicts:
name: Check PRs with merge conflicts
runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }}
steps:
- uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
operations-per-run: 75
# The merge conflict action will remove the label when updated
remove-stale-when-updated: false
days-before-stale: -1
days-before-close: 90
days-before-issue-close: -1
stale-pr-label: merge conflict
close-pr-message: |-
This PR has been closed due to having unresolved merge conflicts.

29
.github/workflows/tsc.yml vendored Normal file
View file

@ -0,0 +1,29 @@
name: TypeScript Build Check
on:
push:
branches: [ master, release* ]
pull_request:
branches: [ master, release* ]
jobs:
tsc:
name: Run TypeScript build check
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
- name: Setup node environment
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0
with:
node-version: 16
check-latest: true
cache: npm
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run tsc
run: npm run build:check

View file

@ -13,6 +13,7 @@
"at-rule-name-case": "lower", "at-rule-name-case": "lower",
"at-rule-name-space-after": "always-single-line", "at-rule-name-space-after": "always-single-line",
"at-rule-no-unknown": true, "at-rule-no-unknown": true,
"at-rule-no-vendor-prefix": true,
"at-rule-semicolon-newline-after": "always", "at-rule-semicolon-newline-after": "always",
"block-closing-brace-empty-line-before": "never", "block-closing-brace-empty-line-before": "never",
"block-closing-brace-newline-after": "always", "block-closing-brace-newline-after": "always",
@ -77,6 +78,7 @@
"media-feature-colon-space-before": "never", "media-feature-colon-space-before": "never",
"media-feature-name-case": "lower", "media-feature-name-case": "lower",
"media-feature-name-no-unknown": true, "media-feature-name-no-unknown": true,
"media-feature-name-no-vendor-prefix": true,
"media-feature-parentheses-space-inside": "never", "media-feature-parentheses-space-inside": "never",
"media-feature-range-operator-space-after": "always", "media-feature-range-operator-space-after": "always",
"media-feature-range-operator-space-before": "always", "media-feature-range-operator-space-before": "always",
@ -103,6 +105,7 @@
] ]
} }
], ],
"property-no-vendor-prefix": true,
"rule-empty-line-before": [ "always-multi-line", { "rule-empty-line-before": [ "always-multi-line", {
"except": ["first-nested"], "except": ["first-nested"],
"ignore": ["after-comment"] "ignore": ["after-comment"]
@ -116,6 +119,7 @@
"selector-list-comma-newline-after": "always", "selector-list-comma-newline-after": "always",
"selector-list-comma-space-before": "never", "selector-list-comma-space-before": "never",
"selector-max-empty-lines": 0, "selector-max-empty-lines": 0,
"selector-no-vendor-prefix": true,
"selector-pseudo-class-case": "lower", "selector-pseudo-class-case": "lower",
"selector-pseudo-class-no-unknown": true, "selector-pseudo-class-no-unknown": true,
"selector-pseudo-class-parentheses-space-inside": "never", "selector-pseudo-class-parentheses-space-inside": "never",
@ -134,6 +138,7 @@
"string-no-newline": true, "string-no-newline": true,
"unit-case": "lower", "unit-case": "lower",
"unit-no-unknown": true, "unit-no-unknown": true,
"value-no-vendor-prefix": true,
"value-list-comma-newline-after": "always-multi-line", "value-list-comma-newline-after": "always-multi-line",
"value-list-comma-space-after": "always-single-line", "value-list-comma-space-after": "always-single-line",
"value-list-comma-space-before": "never", "value-list-comma-space-before": "never",

View file

@ -56,6 +56,15 @@
- [is343](https://github.com/is343) - [is343](https://github.com/is343)
- [Meet Pandya](https://github.com/meet-k-pandya) - [Meet Pandya](https://github.com/meet-k-pandya)
- [Peter Spenler](https://github.com/peterspenler) - [Peter Spenler](https://github.com/peterspenler)
- [Vankerkom](https://github.com/vankerkom)
- [edvwib](https://github.com/edvwib)
- [Rob Farraher](https://github.com/farraherbg)
- [TelepathicWalrus](https://github.com/TelepathicWalrus)
- [Pier-Luc Ducharme](https://github.com/pl-ducharme)
- [Anantharaju S](https://github.com/Anantharajus)
- [Merlin Sievers](https://github.com/dann-merlin)
- [Fishbigger](https://github.com/fishbigger)
- [sleepycatcoding](https://github.com/sleepycatcoding)
# Emby Contributors # Emby Contributors

View file

@ -76,19 +76,24 @@ Jellyfin Web is the frontend used for most of the clients available for end user
``` ```
. .
└── src └── src
├── assets # Static assets ├── apps
├── components # Higher order visual components and React components │   ├── experimental # New experimental app layout
├── controllers # Legacy page views and controllers 🧹 │   └── stable # Classic (stable) app layout
├── elements # Basic webcomponents and React wrappers 🧹 ├── assets # Static assets
├── legacy # Polyfills for legacy browsers ├── components # Higher order visual components and React components
├── libraries # Third party libraries 🧹 ├── controllers # Legacy page views and controllers 🧹
├── plugins # Client plugins ├── elements # Basic webcomponents and React wrappers 🧹
├── routes # React routes/pages ├── hooks # Custom React hooks
├── scripts # Random assortment of visual components and utilities 🐉 ├── legacy # Polyfills for legacy browsers
├── strings # Translation files ├── libraries # Third party libraries 🧹
├── styles # Common app Sass stylesheets ├── plugins # Client plugins
├── themes # CSS themes ├── routes # React routes/pages
└── utils # Utility functions ├── scripts # Random assortment of visual components and utilities 🐉
├── strings # Translation files
├── styles # Common app Sass stylesheets
├── themes # CSS themes
├── types # Common TypeScript interfaces/types
└── utils # Utility functions
``` ```
- 🧹 — Needs cleanup - 🧹 — Needs cleanup

View file

@ -12,14 +12,7 @@ module.exports = {
corejs: 3 corejs: 3
} }
], ],
'@babel/preset-react', '@babel/preset-react'
[
'@babel/preset-typescript',
{
isTSX: true,
allExtensions: true
}
]
], ],
plugins: [ plugins: [
'@babel/plugin-proposal-class-properties', '@babel/plugin-proposal-class-properties',

1
debian/rules vendored
View file

@ -12,6 +12,7 @@ override_dh_clistrip:
override_dh_auto_build: override_dh_auto_build:
npm ci --no-audit --unsafe-perm npm ci --no-audit --unsafe-perm
npm run build:production
mv $(CURDIR)/dist $(CURDIR)/web mv $(CURDIR)/dist $(CURDIR)/web
override_dh_auto_clean: override_dh_auto_clean:

View file

@ -1,4 +1,4 @@
FROM centos:8 FROM quay.io/centos/centos:stream8
# Docker build arguments # Docker build arguments
ARG SOURCE_DIR=/jellyfin ARG SOURCE_DIR=/jellyfin
@ -12,7 +12,7 @@ ENV IS_DOCKER=YES
# Prepare CentOS environment # Prepare CentOS environment
RUN yum update -y \ RUN yum update -y \
&& yum install -y epel-release \ && yum install -y epel-release \
&& yum install -y @buildsys-build rpmdevtools git yum-plugins-core autoconf automake glibc-devel gcc-c++ make \ && yum install -y rpmdevtools git autoconf automake glibc-devel gcc-c++ make \
&& curl -fsSL https://rpm.nodesource.com/setup_16.x | bash - \ && curl -fsSL https://rpm.nodesource.com/setup_16.x | bash - \
&& yum install -y nodejs && yum install -y nodejs

View file

@ -8,4 +8,6 @@ RUN apk add autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool ma
WORKDIR ${SOURCE_DIR} WORKDIR ${SOURCE_DIR}
COPY . . COPY . .
RUN npm ci --no-audit --unsafe-perm && mv dist ${ARTIFACT_DIR} RUN npm ci --no-audit --unsafe-perm \
&& npm run build:production \
&& mv dist ${ARTIFACT_DIR}

View file

@ -1,4 +1,4 @@
FROM fedora:37 FROM fedora:39
# Docker build arguments # Docker build arguments
ARG SOURCE_DIR=/jellyfin ARG SOURCE_DIR=/jellyfin

View file

@ -15,6 +15,7 @@ fi
# build archives # build archives
npm ci --no-audit --unsafe-perm npm ci --no-audit --unsafe-perm
npm run build:production
mv dist jellyfin-web_${version} mv dist jellyfin-web_${version}
tar -czf jellyfin-web_${version}_portable.tar.gz jellyfin-web_${version} tar -czf jellyfin-web_${version}_portable.tar.gz jellyfin-web_${version}
rm -rf dist rm -rf dist

View file

@ -35,6 +35,7 @@ chown root:root -R .
%build %build
npm ci --no-audit --unsafe-perm npm ci --no-audit --unsafe-perm
npm run build:production
%install %install

10688
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,106 +5,110 @@
"repository": "https://github.com/jellyfin/jellyfin-web", "repository": "https://github.com/jellyfin/jellyfin-web",
"license": "GPL-2.0-or-later", "license": "GPL-2.0-or-later",
"devDependencies": { "devDependencies": {
"@babel/core": "7.19.3", "@babel/core": "7.21.8",
"@babel/eslint-parser": "7.19.1",
"@babel/eslint-plugin": "7.19.1",
"@babel/plugin-proposal-class-properties": "7.18.6", "@babel/plugin-proposal-class-properties": "7.18.6",
"@babel/plugin-proposal-private-methods": "7.18.6", "@babel/plugin-proposal-private-methods": "7.18.6",
"@babel/plugin-transform-modules-umd": "7.18.6", "@babel/plugin-transform-modules-umd": "7.18.6",
"@babel/preset-env": "7.19.4", "@babel/preset-env": "7.21.5",
"@babel/preset-react": "7.18.6", "@babel/preset-react": "7.18.6",
"@babel/preset-typescript": "7.18.6",
"@types/escape-html": "1.0.2", "@types/escape-html": "1.0.2",
"@types/lodash-es": "4.17.6", "@types/loadable__component": "5.13.4",
"@types/react": "17.0.51", "@types/lodash-es": "4.17.7",
"@types/react-dom": "17.0.17", "@types/react": "17.0.59",
"@typescript-eslint/eslint-plugin": "5.41.0", "@types/react-dom": "17.0.20",
"@typescript-eslint/parser": "5.41.0", "@typescript-eslint/eslint-plugin": "5.59.7",
"@typescript-eslint/parser": "5.59.7",
"@uupaa/dynamic-import-polyfill": "1.0.2", "@uupaa/dynamic-import-polyfill": "1.0.2",
"autoprefixer": "10.4.12", "autoprefixer": "10.4.14",
"babel-loader": "8.2.5", "babel-loader": "9.1.2",
"babel-plugin-dynamic-import-polyfill": "1.0.0", "babel-plugin-dynamic-import-polyfill": "1.0.0",
"clean-webpack-plugin": "4.0.0", "clean-webpack-plugin": "4.0.0",
"confusing-browser-globals": "1.0.11", "confusing-browser-globals": "1.0.11",
"copy-webpack-plugin": "11.0.0", "copy-webpack-plugin": "11.0.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"css-loader": "6.7.1", "css-loader": "6.7.4",
"cssnano": "5.1.13", "cssnano": "6.0.1",
"es-check": "7.0.1", "es-check": "7.1.1",
"eslint": "8.26.0", "eslint": "8.41.0",
"eslint-plugin-compat": "4.0.2", "eslint-plugin-compat": "4.1.4",
"eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-eslint-comments": "3.2.0",
"eslint-plugin-import": "2.26.0", "eslint-plugin-import": "2.27.5",
"eslint-plugin-jsx-a11y": "6.6.1", "eslint-plugin-jsx-a11y": "6.7.1",
"eslint-plugin-promise": "6.1.1", "eslint-plugin-promise": "6.1.1",
"eslint-plugin-react": "7.31.10", "eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-sonarjs": "0.16.0", "eslint-plugin-sonarjs": "0.19.0",
"expose-loader": "4.0.0", "expose-loader": "4.1.0",
"html-loader": "4.2.0", "html-loader": "4.2.0",
"html-webpack-plugin": "5.5.0", "html-webpack-plugin": "5.5.1",
"mini-css-extract-plugin": "2.6.1", "mini-css-extract-plugin": "2.7.6",
"postcss": "8.4.18", "postcss": "8.4.24",
"postcss-loader": "7.0.1", "postcss-loader": "7.3.1",
"postcss-preset-env": "7.8.2", "postcss-preset-env": "8.4.1",
"postcss-scss": "4.0.5", "postcss-scss": "4.0.6",
"sass": "1.55.0", "sass": "1.62.1",
"sass-loader": "13.1.0", "sass-loader": "13.3.0",
"source-map-loader": "4.0.1", "source-map-loader": "4.0.1",
"style-loader": "3.3.1", "style-loader": "3.3.3",
"stylelint": "14.14.0", "stylelint": "15.6.2",
"stylelint-config-rational-order": "0.1.2", "stylelint-config-rational-order": "0.1.2",
"stylelint-no-browser-hacks": "1.2.1", "stylelint-no-browser-hacks": "1.2.1",
"stylelint-order": "5.0.0", "stylelint-order": "6.0.3",
"stylelint-scss": "4.3.0", "stylelint-scss": "5.0.0",
"ts-loader": "9.4.1", "ts-loader": "9.4.3",
"typescript": "4.8.4", "typescript": "5.0.4",
"webpack": "5.74.0", "webpack": "5.84.1",
"webpack-cli": "4.10.0", "webpack-cli": "5.1.1",
"webpack-dev-server": "4.11.1", "webpack-dev-server": "4.15.0",
"webpack-merge": "5.8.0", "webpack-merge": "5.9.0",
"workbox-webpack-plugin": "6.5.4", "workbox-webpack-plugin": "6.5.4",
"worker-loader": "3.0.8" "worker-loader": "3.0.8"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "11.11.0",
"@emotion/styled": "11.11.0",
"@fontsource/noto-sans": "4.5.11", "@fontsource/noto-sans": "4.5.11",
"@fontsource/noto-sans-hk": "4.5.12", "@fontsource/noto-sans-hk": "4.5.12",
"@fontsource/noto-sans-jp": "4.5.12", "@fontsource/noto-sans-jp": "4.5.12",
"@fontsource/noto-sans-kr": "4.5.12", "@fontsource/noto-sans-kr": "4.5.12",
"@fontsource/noto-sans-sc": "4.5.12", "@fontsource/noto-sans-sc": "4.5.12",
"@fontsource/noto-sans-tc": "4.5.12", "@fontsource/noto-sans-tc": "4.5.12",
"@jellyfin/libass-wasm": "4.1.1", "@jellyfin/sdk": "unstable",
"@jellyfin/sdk": "0.7.0", "@loadable/component": "5.15.3",
"blurhash": "2.0.3", "@mui/icons-material": "5.11.16",
"@mui/material": "5.13.3",
"blurhash": "2.0.5",
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz", "classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
"classnames": "2.3.2", "classnames": "2.3.2",
"core-js": "3.26.0", "core-js": "3.30.2",
"date-fns": "2.29.3", "date-fns": "2.30.0",
"dompurify": "2.4.0", "dompurify": "3.0.1",
"epubjs": "0.4.2", "epubjs": "0.3.93",
"escape-html": "1.0.3", "escape-html": "1.0.3",
"event-target-polyfill": "github:ThaUnknown/event-target-polyfill",
"fast-text-encoding": "1.0.6", "fast-text-encoding": "1.0.6",
"flv.js": "1.6.2", "flv.js": "1.6.2",
"headroom.js": "0.12.0", "headroom.js": "0.12.0",
"history": "5.3.0", "history": "5.3.0",
"hls.js": "0.14.17", "hls.js": "1.4.4",
"intersection-observer": "0.12.2", "intersection-observer": "0.12.2",
"jassub": "1.7.1",
"jellyfin-apiclient": "1.10.0", "jellyfin-apiclient": "1.10.0",
"jquery": "3.6.1", "jquery": "3.7.0",
"jstree": "3.3.12", "jstree": "3.3.15",
"libarchive.js": "1.3.0", "libarchive.js": "1.3.0",
"lodash-es": "4.17.21", "lodash-es": "4.17.21",
"marked": "4.1.1", "marked": "4.3.0",
"material-design-icons-iconfont": "6.7.0", "material-design-icons-iconfont": "6.7.0",
"native-promise-only": "0.8.1", "native-promise-only": "0.8.1",
"pdfjs-dist": "2.16.105", "pdfjs-dist": "3.6.172",
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-router-dom": "6.4.2", "react-router-dom": "6.11.1",
"resize-observer-polyfill": "1.5.1", "resize-observer-polyfill": "1.5.1",
"screenfull": "6.0.2", "screenfull": "6.0.2",
"sortablejs": "1.15.0", "sortablejs": "1.15.0",
"swiper": "8.4.4", "swiper": "9.3.2",
"webcomponents.js": "0.7.24", "webcomponents.js": "0.7.24",
"whatwg-fetch": "3.6.2", "whatwg-fetch": "3.6.2",
"workbox-core": "6.5.4", "workbox-core": "6.5.4",
@ -129,9 +133,9 @@
"scripts": { "scripts": {
"start": "npm run serve", "start": "npm run serve",
"serve": "webpack serve --config webpack.dev.js", "serve": "webpack serve --config webpack.dev.js",
"prepare": "node ./scripts/prepare.js", "build:development": "cross-env NODE_OPTIONS=\"--max_old_space_size=6144\" webpack --config webpack.dev.js",
"build:development": "webpack --config webpack.dev.js", "build:production": "cross-env NODE_ENV=\"production\" NODE_OPTIONS=\"--max_old_space_size=6144\" webpack --config webpack.prod.js",
"build:production": "cross-env NODE_ENV=\"production\" webpack --config webpack.prod.js", "build:check": "tsc --noEmit",
"escheck": "es-check", "escheck": "es-check",
"lint": "eslint \"./\"", "lint": "eslint \"./\"",
"stylelint": "npm run stylelint:css && npm run stylelint:scss", "stylelint": "npm run stylelint:css && npm run stylelint:scss",

View file

@ -7,8 +7,8 @@ const config = () => ({
plugins: [ plugins: [
// Explicitly specify browserslist to override ones from node_modules // Explicitly specify browserslist to override ones from node_modules
// For example, Swiper has it in its package.json // For example, Swiper has it in its package.json
postcssPresetEnv({browsers: packageConfig.browserslist}), postcssPresetEnv({ browsers: packageConfig.browserslist }),
autoprefixer({overrideBrowserslist: packageConfig.browserslist}), autoprefixer({ overrideBrowserslist: packageConfig.browserslist }),
cssnano() cssnano()
] ]
}); });

View file

@ -1,12 +0,0 @@
const { execSync } = require('child_process');
/**
* The npm `prepare` script needs to run a build to support installing
* a package from git repositories (this is dumb but a limitation of how
* npm behaves). We don't want to run these in CI though because
* building is slow so this script will skip the build when the
* `SKIP_PREPARE` environment variable has been set.
*/
if (!process.env.SKIP_PREPARE) {
execSync('webpack --config webpack.prod.js', { stdio: 'inherit' });
}

30
src/RootApp.tsx Normal file
View file

@ -0,0 +1,30 @@
import loadable from '@loadable/component';
import { History } from '@remix-run/router';
import React from 'react';
import StableApp from './apps/stable/App';
import { HistoryRouter } from './components/router/HistoryRouter';
import { ApiProvider } from './hooks/useApi';
import { WebConfigProvider } from './hooks/useWebConfig';
const ExperimentalApp = loadable(() => import('./apps/experimental/App'));
const RootApp = ({ history }: { history: History }) => {
const layoutMode = localStorage.getItem('layout');
return (
<ApiProvider>
<WebConfigProvider>
<HistoryRouter history={history}>
{
layoutMode === 'experimental' ?
<ExperimentalApp /> :
<StableApp />
}
</HistoryRouter>
</WebConfigProvider>
</ApiProvider>
);
};
export default RootApp;

3
src/apiclient.d.ts vendored
View file

@ -117,6 +117,7 @@ declare module 'jellyfin-apiclient' {
getCountries(): Promise<CountryInfo[]>; getCountries(): Promise<CountryInfo[]>;
getCriticReviews(itemId: string, options?: any): Promise<BaseItemDtoQueryResult>; getCriticReviews(itemId: string, options?: any): Promise<BaseItemDtoQueryResult>;
getCultures(): Promise<CultureDto[]>; getCultures(): Promise<CultureDto[]>;
getCurrentUser(cache?: boolean): Promise<UserDto>;
getCurrentUserId(): string; getCurrentUserId(): string;
getDateParamValue(date: Date): string; getDateParamValue(date: Date): string;
getDefaultImageQuality(imageType: ImageType): number; getDefaultImageQuality(imageType: ImageType): number;
@ -267,7 +268,7 @@ declare module 'jellyfin-apiclient' {
sendWebSocketMessage(name: string, data: any): void; sendWebSocketMessage(name: string, data: any): void;
serverAddress(val?: string): string; serverAddress(val?: string): string;
serverId(): string; serverId(): string;
serverVersion(): string serverVersion(): string;
setAuthenticationInfo(accessKey?: string, userId?: string): void; setAuthenticationInfo(accessKey?: string, userId?: string): void;
setRequestHeaders(headers: any): void; setRequestHeaders(headers: any): void;
setSystemInfo(info: SystemInfo): void; setSystemInfo(info: SystemInfo): void;

View file

@ -0,0 +1,44 @@
import React from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import ConnectionRequired from 'components/ConnectionRequired';
import ServerContentPage from 'components/ServerContentPage';
import { toAsyncPageRoute } from 'components/router/AsyncRoute';
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
import AppLayout from './AppLayout';
import { ASYNC_ADMIN_ROUTES, ASYNC_USER_ROUTES } from './routes/asyncRoutes';
import { LEGACY_ADMIN_ROUTES, LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './routes/legacyRoutes';
const ExperimentalApp = () => {
return (
<Routes>
<Route path='/*' element={<AppLayout />}>
{/* User routes */}
<Route element={<ConnectionRequired />}>
{ASYNC_USER_ROUTES.map(toAsyncPageRoute)}
{LEGACY_USER_ROUTES.map(toViewManagerPageRoute)}
</Route>
{/* Admin routes */}
<Route element={<ConnectionRequired isAdminRequired />}>
{ASYNC_ADMIN_ROUTES.map(toAsyncPageRoute)}
{LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute)}
<Route path='configurationpage' element={
<ServerContentPage view='/web/configurationpage' />
} />
</Route>
{/* Public routes */}
<Route element={<ConnectionRequired isUserRequired={false} />}>
<Route index element={<Navigate replace to='/home.html' />} />
{LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute)}
</Route>
</Route>
</Routes>
);
};
export default ExperimentalApp;

View file

@ -0,0 +1,114 @@
import React, { useCallback, useEffect, useState } from 'react';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import { ThemeProvider } from '@mui/material/styles';
import { Outlet, useLocation } from 'react-router-dom';
import AppHeader from 'components/AppHeader';
import Backdrop from 'components/Backdrop';
import { useApi } from 'hooks/useApi';
import { useLocalStorage } from 'hooks/useLocalStorage';
import AppToolbar from './components/AppToolbar';
import AppDrawer, { DRAWER_WIDTH, isDrawerPath } from './components/drawers/AppDrawer';
import ElevationScroll from './components/ElevationScroll';
import theme from './theme';
import './AppOverrides.scss';
interface ExperimentalAppSettings {
isDrawerPinned: boolean
}
const DEFAULT_EXPERIMENTAL_APP_SETTINGS: ExperimentalAppSettings = {
isDrawerPinned: false
};
const AppLayout = () => {
const [ appSettings, setAppSettings ] = useLocalStorage<ExperimentalAppSettings>('ExperimentalAppSettings', DEFAULT_EXPERIMENTAL_APP_SETTINGS);
const [ isDrawerActive, setIsDrawerActive ] = useState(appSettings.isDrawerPinned);
const { user } = useApi();
const location = useLocation();
const isDrawerAvailable = isDrawerPath(location.pathname);
const isDrawerOpen = isDrawerActive && isDrawerAvailable && Boolean(user);
useEffect(() => {
if (isDrawerActive !== appSettings.isDrawerPinned) {
setAppSettings({
...appSettings,
isDrawerPinned: isDrawerActive
});
}
}, [ appSettings, isDrawerActive, setAppSettings ]);
const onToggleDrawer = useCallback(() => {
setIsDrawerActive(!isDrawerActive);
}, [ isDrawerActive, setIsDrawerActive ]);
return (
<ThemeProvider theme={theme}>
<Backdrop />
<div style={{ display: 'none' }}>
{/*
* TODO: These components are not used, but views interact with them directly so the need to be
* present in the dom. We add them in a hidden element to prevent errors.
*/}
<AppHeader />
</div>
<Box sx={{ display: 'flex' }}>
<ElevationScroll elevate={isDrawerOpen}>
<AppBar
position='fixed'
sx={{ zIndex: (muiTheme) => muiTheme.zIndex.drawer + 1 }}
>
<AppToolbar
isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onToggleDrawer}
/>
</AppBar>
</ElevationScroll>
<AppDrawer
open={isDrawerOpen}
onClose={onToggleDrawer}
onOpen={onToggleDrawer}
/>
<Box
component='main'
sx={{
width: '100%',
flexGrow: 1,
transition: theme.transitions.create('margin', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen
}),
marginLeft: 0,
...(isDrawerAvailable && {
marginLeft: {
sm: `-${DRAWER_WIDTH}px`
}
}),
...(isDrawerActive && {
transition: theme.transitions.create('margin', {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen
}),
marginLeft: 0
})
}}
>
<div className='mainAnimatedPages skinBody' />
<div className='skinBody'>
<Outlet />
</div>
</Box>
</Box>
</ThemeProvider>
);
};
export default AppLayout;

View file

@ -0,0 +1,40 @@
// Default MUI breakpoints
// https://mui.com/material-ui/customization/breakpoints/#default-breakpoints
$mui-bp-sm: 600px;
$mui-bp-md: 900px;
$mui-bp-lg: 1200px;
$mui-bp-xl: 1536px;
// Fix main pages layout to work with drawer
.mainAnimatedPage {
position: relative;
}
// Fix dashboard pages layout to work with drawer
.dashboardDocument .skinBody {
position: unset;
}
// Hide some items from the user "settings" page that are in the drawer
#myPreferencesMenuPage {
.lnkQuickConnectPreferences,
.adminSection,
.userSection {
display: none !important;
}
}
// Fix the padding of some pages
.homePage.libraryPage, // Home page
.libraryPage:not(.withTabs), // Tabless library pages
.content-primary.content-primary { // Dashboard pages
padding-top: 3.25rem !important;
}
.libraryPage.withTabs {
padding-top: 6.5rem !important;
@media all and (min-width: $mui-bp-lg) {
padding-top: 3.25rem !important;
}
}

View file

@ -0,0 +1,112 @@
import React, { useCallback, useEffect, useState } from 'react';
import CastConnected from '@mui/icons-material/CastConnected';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Cast from '@mui/icons-material/Cast';
import IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip';
import { playbackManager } from 'components/playback/playbackmanager';
import globalize from 'scripts/globalize';
import Events from 'utils/events';
import RemotePlayMenu, { ID } from './menus/RemotePlayMenu';
import RemotePlayActiveMenu, { ID as ACTIVE_ID } from './menus/RemotePlayActiveMenu';
const RemotePlayButton = () => {
const theme = useTheme();
const [ playerInfo, setPlayerInfo ] = useState(playbackManager.getPlayerInfo());
const updatePlayerInfo = useCallback(() => {
setPlayerInfo(playbackManager.getPlayerInfo());
}, [ setPlayerInfo ]);
useEffect(() => {
Events.on(playbackManager, 'playerchange', updatePlayerInfo);
return () => {
Events.off(playbackManager, 'playerchange', updatePlayerInfo);
};
}, [ updatePlayerInfo ]);
const [ remotePlayMenuAnchorEl, setRemotePlayMenuAnchorEl ] = useState<null | HTMLElement>(null);
const isRemotePlayMenuOpen = Boolean(remotePlayMenuAnchorEl);
const onRemotePlayButtonClick = useCallback((event) => {
setRemotePlayMenuAnchorEl(event.currentTarget);
}, [ setRemotePlayMenuAnchorEl ]);
const onRemotePlayMenuClose = useCallback(() => {
setRemotePlayMenuAnchorEl(null);
}, [ setRemotePlayMenuAnchorEl ]);
const [ remotePlayActiveMenuAnchorEl, setRemotePlayActiveMenuAnchorEl ] = useState<null | HTMLElement>(null);
const isRemotePlayActiveMenuOpen = Boolean(remotePlayActiveMenuAnchorEl);
const onRemotePlayActiveButtonClick = useCallback((event) => {
setRemotePlayActiveMenuAnchorEl(event.currentTarget);
}, [ setRemotePlayActiveMenuAnchorEl ]);
const onRemotePlayActiveMenuClose = useCallback(() => {
setRemotePlayActiveMenuAnchorEl(null);
}, [ setRemotePlayActiveMenuAnchorEl ]);
return (
<>
{(playerInfo && !playerInfo.isLocalPlayer) ? (
<Box
sx={{
alignSelf: 'center'
}}
>
<Tooltip title={globalize.translate('ButtonCast')}>
<Button
variant='text'
size='large'
startIcon={<CastConnected />}
aria-label={globalize.translate('ButtonCast')}
aria-controls={ACTIVE_ID}
aria-haspopup='true'
onClick={onRemotePlayActiveButtonClick}
color='inherit'
sx={{
color: theme.palette.primary.main
}}
>
{playerInfo.deviceName || playerInfo.name}
</Button>
</Tooltip>
</Box>
) : (
<Tooltip title={globalize.translate('ButtonCast')}>
<IconButton
size='large'
aria-label={globalize.translate('ButtonCast')}
aria-controls={ID}
aria-haspopup='true'
onClick={onRemotePlayButtonClick}
color='inherit'
>
<Cast />
</IconButton>
</Tooltip>
)}
<RemotePlayMenu
open={isRemotePlayMenuOpen}
anchorEl={remotePlayMenuAnchorEl}
onMenuClose={onRemotePlayMenuClose}
/>
<RemotePlayActiveMenu
open={isRemotePlayActiveMenuOpen}
anchorEl={remotePlayActiveMenuAnchorEl}
onMenuClose={onRemotePlayActiveMenuClose}
playerInfo={playerInfo}
/>
</>
);
};
export default RemotePlayButton;

View file

@ -0,0 +1,64 @@
import Avatar from '@mui/material/Avatar';
import IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip';
import React, { useCallback, useState } from 'react';
import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
import AppUserMenu, { ID } from './menus/AppUserMenu';
const UserMenuButton = () => {
const theme = useTheme();
const { api, user } = useApi();
const [ userMenuAnchorEl, setUserMenuAnchorEl ] = useState<null | HTMLElement>(null);
const isUserMenuOpen = Boolean(userMenuAnchorEl);
const onUserButtonClick = useCallback((event) => {
setUserMenuAnchorEl(event.currentTarget);
}, [ setUserMenuAnchorEl ]);
const onUserMenuClose = useCallback(() => {
setUserMenuAnchorEl(null);
}, [ setUserMenuAnchorEl ]);
return (
<>
<Tooltip title={globalize.translate('UserMenu')}>
<IconButton
size='large'
edge='end'
aria-label={globalize.translate('UserMenu')}
aria-controls={ID}
aria-haspopup='true'
onClick={onUserButtonClick}
color='inherit'
sx={{ padding: 0 }}
>
<Avatar
alt={user?.Name || undefined}
src={
api && user?.Id ?
`${api.basePath}/Users/${user.Id}/Images/Primary?tag=${user.PrimaryImageTag}` :
undefined
}
sx={{
bgcolor: theme.palette.primary.dark,
color: 'inherit'
}}
/>
</IconButton>
</Tooltip>
<AppUserMenu
open={isUserMenuOpen}
anchorEl={userMenuAnchorEl}
onMenuClose={onUserMenuClose}
/>
</>
);
};
export default UserMenuButton;

View file

@ -0,0 +1,117 @@
import MenuIcon from '@mui/icons-material/Menu';
import SearchIcon from '@mui/icons-material/Search';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Toolbar from '@mui/material/Toolbar';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import React, { FC } from 'react';
import { Link, useLocation } from 'react-router-dom';
import appIcon from 'assets/img/icon-transparent.png';
import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
import AppTabs from '../tabs/AppTabs';
import { isDrawerPath } from '../drawers/AppDrawer';
import UserMenuButton from './UserMenuButton';
import RemotePlayButton from './RemotePlayButton';
interface AppToolbarProps {
isDrawerOpen: boolean
onDrawerButtonClick: (event: React.MouseEvent<HTMLElement>) => void
}
const AppToolbar: FC<AppToolbarProps> = ({
isDrawerOpen,
onDrawerButtonClick
}) => {
const { user } = useApi();
const isUserLoggedIn = Boolean(user);
const location = useLocation();
const isDrawerAvailable = isDrawerPath(location.pathname);
return (
<Toolbar
variant='dense'
sx={{
flexWrap: {
xs: 'wrap',
lg: 'nowrap'
}
}}
>
{isUserLoggedIn && isDrawerAvailable && (
<Tooltip title={globalize.translate(isDrawerOpen ? 'MenuClose' : 'MenuOpen')}>
<IconButton
size='large'
edge='start'
color='inherit'
aria-label={globalize.translate(isDrawerOpen ? 'MenuClose' : 'MenuOpen')}
sx={{ mr: 2 }}
onClick={onDrawerButtonClick}
>
<MenuIcon />
</IconButton>
</Tooltip>
)}
<Box
component={Link}
to='/'
color='inherit'
aria-label={globalize.translate('Home')}
sx={{
display: 'inline-flex',
textDecoration: 'none'
}}
>
<Box
component='img'
src={appIcon}
sx={{
height: '2rem',
marginInlineEnd: 1
}}
/>
<Typography
variant='h6'
noWrap
component='div'
sx={{ display: { xs: 'none', sm: 'inline-block' } }}
>
Jellyfin
</Typography>
</Box>
<AppTabs isDrawerOpen={isDrawerOpen} />
{isUserLoggedIn && (
<>
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'flex-end' }}>
<RemotePlayButton />
<Tooltip title={globalize.translate('Search')}>
<IconButton
size='large'
aria-label={globalize.translate('Search')}
color='inherit'
component={Link}
to='/search.html'
>
<SearchIcon />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ flexGrow: 0 }}>
<UserMenuButton />
</Box>
</>
)}
</Toolbar>
);
};
export default AppToolbar;

View file

@ -0,0 +1,165 @@
import { AppSettingsAlt, Close } from '@mui/icons-material';
import AccountCircle from '@mui/icons-material/AccountCircle';
import Logout from '@mui/icons-material/Logout';
import PhonelinkLock from '@mui/icons-material/PhonelinkLock';
import Settings from '@mui/icons-material/Settings';
import Storage from '@mui/icons-material/Storage';
import Divider from '@mui/material/Divider';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Menu, { MenuProps } from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import React, { FC, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { appHost } from 'components/apphost';
import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
import Dashboard from 'utils/dashboard';
export const ID = 'app-user-menu';
interface AppUserMenuProps extends MenuProps {
onMenuClose: () => void
}
const AppUserMenu: FC<AppUserMenuProps> = ({
anchorEl,
open,
onMenuClose
}) => {
const { user } = useApi();
const onClientSettingsClick = useCallback(() => {
window.NativeShell?.openClientSettings();
onMenuClose();
}, [ onMenuClose ]);
const onExitAppClick = useCallback(() => {
appHost.exit();
onMenuClose();
}, [ onMenuClose ]);
const onLogoutClick = useCallback(() => {
Dashboard.logout();
onMenuClose();
}, [ onMenuClose ]);
const onSelectServerClick = useCallback(() => {
Dashboard.selectServer();
onMenuClose();
}, [ onMenuClose ]);
return (
<Menu
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right'
}}
id={ID}
keepMounted
open={open}
onClose={onMenuClose}
>
<MenuItem
component={Link}
to={`/userprofile.html?userId=${user?.Id}`}
onClick={onMenuClose}
>
<ListItemIcon>
<AccountCircle />
</ListItemIcon>
<ListItemText>
{globalize.translate('Profile')}
</ListItemText>
</MenuItem>
<MenuItem
component={Link}
to='/mypreferencesmenu.html'
onClick={onMenuClose}
>
<ListItemIcon>
<Settings />
</ListItemIcon>
<ListItemText>
{globalize.translate('Settings')}
</ListItemText>
</MenuItem>
{appHost.supports('clientsettings') && ([
<Divider key='client-settings-divider' />,
<MenuItem
key='client-settings-button'
onClick={onClientSettingsClick}
>
<ListItemIcon>
<AppSettingsAlt />
</ListItemIcon>
<ListItemText>
{globalize.translate('ClientSettings')}
</ListItemText>
</MenuItem>
])}
<Divider />
<MenuItem
component={Link}
to='/mypreferencesquickconnect.html'
onClick={onMenuClose}
>
<ListItemIcon>
<PhonelinkLock />
</ListItemIcon>
<ListItemText>
{globalize.translate('QuickConnect')}
</ListItemText>
</MenuItem>
{appHost.supports('multiserver') && (
<MenuItem
onClick={onSelectServerClick}
>
<ListItemIcon>
<Storage />
</ListItemIcon>
<ListItemText>
{globalize.translate('SelectServer')}
</ListItemText>
</MenuItem>
)}
<MenuItem
onClick={onLogoutClick}
>
<ListItemIcon>
<Logout />
</ListItemIcon>
<ListItemText>
{globalize.translate('ButtonSignOut')}
</ListItemText>
</MenuItem>
{appHost.supports('exitmenu') && ([
<Divider key='exit-menu-divider' />,
<MenuItem
key='exit-menu-button'
onClick={onExitAppClick}
>
<ListItemIcon>
<Close />
</ListItemIcon>
<ListItemText>
{globalize.translate('ButtonExitApp')}
</ListItemText>
</MenuItem>
])}
</Menu>
);
};
export default AppUserMenu;

View file

@ -0,0 +1,160 @@
import Check from '@mui/icons-material/Check';
import Close from '@mui/icons-material/Close';
import SettingsRemote from '@mui/icons-material/SettingsRemote';
import Divider from '@mui/material/Divider';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader';
import Menu, { MenuProps } from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import dialog from 'components/dialog/dialog';
import { playbackManager } from 'components/playback/playbackmanager';
import React, { FC, useCallback, useState } from 'react';
import { Link } from 'react-router-dom';
import { enable, isEnabled, supported } from 'scripts/autocast';
import globalize from 'scripts/globalize';
interface RemotePlayActiveMenuProps extends MenuProps {
onMenuClose: () => void
playerInfo: {
name: string
isLocalPlayer: boolean
id?: string
deviceName?: string
playableMediaTypes?: string[]
supportedCommands?: string[]
} | null
}
export const ID = 'app-remote-play-active-menu';
const RemotePlayActiveMenu: FC<RemotePlayActiveMenuProps> = ({
anchorEl,
open,
onMenuClose,
playerInfo
}) => {
const [ isDisplayMirrorEnabled, setIsDisplayMirrorEnabled ] = useState(playbackManager.enableDisplayMirroring());
const isDisplayMirrorSupported = playerInfo?.supportedCommands && playerInfo.supportedCommands.indexOf('DisplayContent') !== -1;
const toggleDisplayMirror = useCallback(() => {
playbackManager.enableDisplayMirroring(!isDisplayMirrorEnabled);
setIsDisplayMirrorEnabled(!isDisplayMirrorEnabled);
}, [ isDisplayMirrorEnabled, setIsDisplayMirrorEnabled ]);
const [ isAutoCastEnabled, setIsAutoCastEnabled ] = useState(isEnabled());
const isAutoCastSupported = supported();
const toggleAutoCast = useCallback(() => {
enable(!isAutoCastEnabled);
setIsAutoCastEnabled(!isAutoCastEnabled);
}, [ isAutoCastEnabled, setIsAutoCastEnabled ]);
const remotePlayerName = playerInfo?.deviceName || playerInfo?.name;
const disconnectRemotePlayer = useCallback(() => {
if (playbackManager.getSupportedCommands().indexOf('EndSession') !== -1) {
dialog.show({
buttons: [
{
name: globalize.translate('Yes'),
id: 'yes'
}, {
name: globalize.translate('No'),
id: 'no'
}
],
text: globalize.translate('ConfirmEndPlayerSession', remotePlayerName)
}).then(id => {
onMenuClose();
if (id === 'yes') {
playbackManager.getCurrentPlayer().endSession();
}
playbackManager.setDefaultPlayerActive();
}).catch(() => {
// Dialog closed
});
} else {
onMenuClose();
playbackManager.setDefaultPlayerActive();
}
}, [ onMenuClose, remotePlayerName ]);
return (
<Menu
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right'
}}
id={ID}
keepMounted
open={open}
onClose={onMenuClose}
MenuListProps={{
'aria-labelledby': 'remote-play-active-subheader',
subheader: (
<ListSubheader component='div' id='remote-play-active-subheader'>
{remotePlayerName}
</ListSubheader>
)
}}
>
{isDisplayMirrorSupported && (
<MenuItem onClick={toggleDisplayMirror}>
{isDisplayMirrorEnabled && (
<ListItemIcon>
<Check />
</ListItemIcon>
)}
<ListItemText inset={!isDisplayMirrorEnabled}>
{globalize.translate('EnableDisplayMirroring')}
</ListItemText>
</MenuItem>
)}
{isAutoCastSupported && (
<MenuItem onClick={toggleAutoCast}>
{isAutoCastEnabled && (
<ListItemIcon>
<Check />
</ListItemIcon>
)}
<ListItemText inset={!isAutoCastEnabled}>
{globalize.translate('EnableAutoCast')}
</ListItemText>
</MenuItem>
)}
{(isDisplayMirrorSupported || isAutoCastSupported) && <Divider />}
<MenuItem
component={Link}
to='/queue'
onClick={onMenuClose}
>
<ListItemIcon>
<SettingsRemote />
</ListItemIcon>
<ListItemText>
{globalize.translate('HeaderRemoteControl')}
</ListItemText>
</MenuItem>
<Divider />
<MenuItem onClick={disconnectRemotePlayer}>
<ListItemIcon>
<Close />
</ListItemIcon>
<ListItemText>
{globalize.translate('Disconnect')}
</ListItemText>
</MenuItem>
</Menu>
);
};
export default RemotePlayActiveMenu;

View file

@ -0,0 +1,100 @@
import Warning from '@mui/icons-material/Warning';
import Divider from '@mui/material/Divider';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Menu, { type MenuProps } from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import React, { FC, useEffect, useState } from 'react';
import globalize from 'scripts/globalize';
import { playbackManager } from 'components/playback/playbackmanager';
import { pluginManager } from 'components/pluginManager';
import type { PlayTarget } from 'types/playTarget';
import PlayTargetIcon from '../../PlayTargetIcon';
interface RemotePlayMenuProps extends MenuProps {
onMenuClose: () => void
}
export const ID = 'app-remote-play-menu';
const RemotePlayMenu: FC<RemotePlayMenuProps> = ({
anchorEl,
open,
onMenuClose
}) => {
// TODO: Add other checks for support (Android app, secure context, etc)
const isChromecastPluginLoaded = !!pluginManager.plugins.find(plugin => plugin.id === 'chromecast');
const [ playbackTargets, setPlaybackTargets ] = useState<PlayTarget[]>([]);
const onPlayTargetClick = (target: PlayTarget) => {
playbackManager.trySetActivePlayer(target.playerName, target);
onMenuClose();
};
useEffect(() => {
const fetchPlaybackTargets = async () => {
setPlaybackTargets(
await playbackManager.getTargets()
);
};
if (open) {
fetchPlaybackTargets()
.catch(err => {
console.error('[AppRemotePlayMenu] unable to get playback targets', err);
});
}
}, [ open, setPlaybackTargets ]);
return (
<Menu
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right'
}}
id={ID}
keepMounted
open={open}
onClose={onMenuClose}
>
{!isChromecastPluginLoaded && ([
<MenuItem key='cast-unsupported-item' disabled>
<ListItemIcon>
<Warning />
</ListItemIcon>
<ListItemText>
{globalize.translate('GoogleCastUnsupported')}
</ListItemText>
</MenuItem>,
<Divider key='cast-unsupported-divider' />
])}
{playbackTargets.map(target => (
<MenuItem
key={target.id}
// Since we are looping over targets there is no good way to avoid creating a new function here
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onPlayTargetClick(target)}
>
<ListItemIcon>
<PlayTargetIcon target={target} />
</ListItemIcon>
<ListItemText
primary={ target.appName ? `${target.name} - ${target.appName}` : target.name }
secondary={ target.user?.Name }
/>
</MenuItem>
))}
</Menu>
);
};
export default RemotePlayMenu;

View file

@ -0,0 +1,21 @@
import useScrollTrigger from '@mui/material/useScrollTrigger';
import React, { ReactElement } from 'react';
/**
* Component that changes the elevation of a child component when scrolled.
*/
const ElevationScroll = ({ children, elevate = false }: { children: ReactElement, elevate?: boolean }) => {
const trigger = useScrollTrigger({
disableHysteresis: true,
threshold: 0
});
const isElevated = elevate || trigger;
return React.cloneElement(children, {
color: isElevated ? 'primary' : 'transparent',
elevation: isElevated ? 4 : 0
});
};
export default ElevationScroll;

View file

@ -0,0 +1,50 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import Movie from '@mui/icons-material/Movie';
import MusicNote from '@mui/icons-material/MusicNote';
import Photo from '@mui/icons-material/Photo';
import LiveTv from '@mui/icons-material/LiveTv';
import Tv from '@mui/icons-material/Tv';
import Theaters from '@mui/icons-material/Theaters';
import MusicVideo from '@mui/icons-material/MusicVideo';
import Book from '@mui/icons-material/Book';
import Collections from '@mui/icons-material/Collections';
import Queue from '@mui/icons-material/Queue';
import Folder from '@mui/icons-material/Folder';
import React, { FC } from 'react';
import { CollectionType } from 'types/collectionType';
interface LibraryIconProps {
item: BaseItemDto
}
const LibraryIcon: FC<LibraryIconProps> = ({
item
}) => {
switch (item.CollectionType) {
case CollectionType.Movies:
return <Movie />;
case CollectionType.Music:
return <MusicNote />;
case CollectionType.HomeVideos:
case CollectionType.Photos:
return <Photo />;
case CollectionType.LiveTv:
return <LiveTv />;
case CollectionType.TvShows:
return <Tv />;
case CollectionType.Trailers:
return <Theaters />;
case CollectionType.MusicVideos:
return <MusicVideo />;
case CollectionType.Books:
return <Book />;
case CollectionType.BoxSets:
return <Collections />;
case CollectionType.Playlists:
return <Queue />;
default:
return <Folder />;
}
};
export default LibraryIcon;

View file

@ -0,0 +1,38 @@
import React from 'react';
import Cast from '@mui/icons-material/Cast';
import Computer from '@mui/icons-material/Computer';
import Devices from '@mui/icons-material/Devices';
import Smartphone from '@mui/icons-material/Smartphone';
import Tablet from '@mui/icons-material/Tablet';
import Tv from '@mui/icons-material/Tv';
import browser from 'scripts/browser';
import type { PlayTarget } from 'types/playTarget';
const PlayTargetIcon = ({ target }: { target: PlayTarget }) => {
if (!target.deviceType && target.isLocalPlayer) {
if (browser.tv) {
return <Tv />;
} else if (browser.mobile) {
return <Smartphone />;
}
return <Computer />;
}
switch (target.deviceType) {
case 'smartphone':
return <Smartphone />;
case 'tablet':
return <Tablet />;
case 'desktop':
return <Computer />;
case 'cast':
return <Cast />;
case 'tv':
return <Tv />;
default:
return <Devices />;
}
};
export default PlayTargetIcon;

View file

@ -0,0 +1,86 @@
import React, { FC } from 'react';
import { Route, Routes } from 'react-router-dom';
import { ASYNC_ADMIN_ROUTES, ASYNC_USER_ROUTES } from '../../routes/asyncRoutes';
import { LEGACY_ADMIN_ROUTES, LEGACY_USER_ROUTES } from '../../routes/legacyRoutes';
import AdvancedDrawerSection from './dashboard/AdvancedDrawerSection';
import DevicesDrawerSection from './dashboard/DevicesDrawerSection';
import LiveTvDrawerSection from './dashboard/LiveTvDrawerSection';
import PluginDrawerSection from './dashboard/PluginDrawerSection';
import ServerDrawerSection from './dashboard/ServerDrawerSection';
import MainDrawerContent from './MainDrawerContent';
import ResponsiveDrawer, { ResponsiveDrawerProps } from './ResponsiveDrawer';
export const DRAWER_WIDTH = 240;
const DRAWERLESS_ROUTES = [
'edititemmetadata.html', // metadata manager
'video' // video player
];
const MAIN_DRAWER_ROUTES = [
...ASYNC_USER_ROUTES,
...LEGACY_USER_ROUTES
].filter(route => !DRAWERLESS_ROUTES.includes(route.path));
const ADMIN_DRAWER_ROUTES = [
...ASYNC_ADMIN_ROUTES,
...LEGACY_ADMIN_ROUTES,
{ path: '/configurationpage' } // Plugin configuration page
].filter(route => !DRAWERLESS_ROUTES.includes(route.path));
/** Utility function to check if a path has a drawer. */
export const isDrawerPath = (path: string) => (
MAIN_DRAWER_ROUTES.some(route => route.path === path || `/${route.path}` === path)
|| ADMIN_DRAWER_ROUTES.some(route => route.path === path || `/${route.path}` === path)
);
const AppDrawer: FC<ResponsiveDrawerProps> = ({
open = false,
onClose,
onOpen
}) => (
<Routes>
{
MAIN_DRAWER_ROUTES.map(route => (
<Route
key={route.path}
path={route.path}
element={
<ResponsiveDrawer
open={open}
onClose={onClose}
onOpen={onOpen}
>
<MainDrawerContent />
</ResponsiveDrawer>
}
/>
))
}
{
ADMIN_DRAWER_ROUTES.map(route => (
<Route
key={route.path}
path={route.path}
element={
<ResponsiveDrawer
open={open}
onClose={onClose}
onOpen={onOpen}
>
<ServerDrawerSection />
<DevicesDrawerSection />
<LiveTvDrawerSection />
<AdvancedDrawerSection />
<PluginDrawerSection />
</ResponsiveDrawer>
}
/>
))
}
</Routes>
);
export default AppDrawer;

View file

@ -0,0 +1,45 @@
import ListItemButton, { ListItemButtonBaseProps } from '@mui/material/ListItemButton';
import React, { FC } from 'react';
import { Link, useLocation, useSearchParams } from 'react-router-dom';
interface ListItemLinkProps extends ListItemButtonBaseProps {
to: string
}
const isMatchingParams = (routeParams: URLSearchParams, currentParams: URLSearchParams) => {
for (const param of routeParams) {
if (currentParams.get(param[0]) !== param[1]) {
return false;
}
}
return true;
};
const ListItemLink: FC<ListItemLinkProps> = ({
children,
to,
...params
}) => {
const location = useLocation();
const [ searchParams ] = useSearchParams();
const [ toPath, toParams ] = to.split('?');
// eslint-disable-next-line compat/compat
const toSearchParams = new URLSearchParams(`?${toParams}`);
const selected = location.pathname === toPath && (!toParams || isMatchingParams(toSearchParams, searchParams));
return (
<ListItemButton
component={Link}
to={to}
selected={selected}
{...params}
>
{children}
</ListItemButton>
);
};
export default ListItemLink;

View file

@ -0,0 +1,186 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import type { SystemInfo } from '@jellyfin/sdk/lib/generated-client/models/system-info';
import { getUserViewsApi } from '@jellyfin/sdk/lib/utils/api/user-views-api';
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
import Dashboard from '@mui/icons-material/Dashboard';
import Edit from '@mui/icons-material/Edit';
import Favorite from '@mui/icons-material/Favorite';
import Home from '@mui/icons-material/Home';
import Link from '@mui/icons-material/Link';
import Divider from '@mui/material/Divider';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
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 { useLocation } from 'react-router-dom';
import { useApi } from 'hooks/useApi';
import { useWebConfig } from 'hooks/useWebConfig';
import globalize from 'scripts/globalize';
import { appRouter } from 'components/router/appRouter';
import ListItemLink from './ListItemLink';
import LibraryIcon from '../LibraryIcon';
const MainDrawerContent = () => {
const { api, user } = useApi();
const location = useLocation();
const [ systemInfo, setSystemInfo ] = useState<SystemInfo>();
const [ userViews, setUserViews ] = useState<BaseItemDto[]>([]);
const webConfig = useWebConfig();
const isHomeSelected = location.pathname === '/home.html' && (!location.search || location.search === '?tab=0');
useEffect(() => {
if (api && user?.Id) {
getUserViewsApi(api)
.getUserViews({ userId: user.Id })
.then(({ data }) => {
setUserViews(data.Items || []);
})
.catch(err => {
console.warn('[MainDrawer] failed to fetch user views', err);
setUserViews([]);
});
getSystemApi(api)
.getSystemInfo()
.then(({ data }) => {
setSystemInfo(data);
})
.catch(err => {
console.warn('[MainDrawer] failed to fetch system info', err);
});
} else {
setUserViews([]);
}
}, [ api, user?.Id ]);
return (
<>
{/* MAIN LINKS */}
<List>
<ListItem disablePadding>
<ListItemLink to='/home.html' selected={isHomeSelected}>
<ListItemIcon>
<Home />
</ListItemIcon>
<ListItemText primary={globalize.translate('Home')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/home.html?tab=1'>
<ListItemIcon>
<Favorite />
</ListItemIcon>
<ListItemText primary={globalize.translate('Favorites')} />
</ListItemLink>
</ListItem>
</List>
{/* CUSTOM LINKS */}
{(!!webConfig.menuLinks && webConfig.menuLinks.length > 0) && (
<>
<Divider />
<List>
{webConfig.menuLinks.map(menuLink => (
<ListItem
key={`${menuLink.name}_${menuLink.url}`}
disablePadding
>
<ListItemButton
component='a'
href={menuLink.url}
target='_blank'
rel='noopener noreferrer'
>
<ListItemIcon>
{/* TODO: Support custom icons */}
<Link />
</ListItemIcon>
<ListItemText primary={menuLink.name} />
</ListItemButton>
</ListItem>
))}
</List>
</>
)}
{/* LIBRARY LINKS */}
{userViews.length > 0 && (
<>
<Divider />
<List
aria-labelledby='libraries-subheader'
subheader={
<ListSubheader component='div' id='libraries-subheader'>
{globalize.translate('HeaderLibraries')}
</ListSubheader>
}
>
{userViews.map(view => (
<ListItem key={view.Id} disablePadding>
<ListItemLink
to={appRouter.getRouteUrl(view, { context: view.CollectionType }).substring(1)}
>
<ListItemIcon>
<LibraryIcon item={view} />
</ListItemIcon>
<ListItemText primary={view.Name} />
</ListItemLink>
</ListItem>
))}
</List>
</>
)}
{/* ADMIN LINKS */}
{user?.Policy?.IsAdministrator && (
<>
<Divider />
<List
aria-labelledby='admin-subheader'
subheader={
<ListSubheader component='div' id='admin-subheader'>
{globalize.translate('HeaderAdmin')}
</ListSubheader>
}
>
<ListItem disablePadding>
<ListItemLink to='/dashboard.html'>
<ListItemIcon>
<Dashboard />
</ListItemIcon>
<ListItemText primary={globalize.translate('TabDashboard')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/edititemmetadata.html'>
<ListItemIcon>
<Edit />
</ListItemIcon>
<ListItemText primary={globalize.translate('MetadataManager')} />
</ListItemLink>
</ListItem>
</List>
</>
)}
{/* FOOTER */}
<Divider style={{ marginTop: 'auto' }} />
<List>
<ListItem>
<ListItemText
primary={systemInfo?.ServerName ? systemInfo.ServerName : 'Jellyfin'}
secondary={systemInfo?.Version ? `v${systemInfo.Version}` : ''}
/>
</ListItem>
</List>
</>
);
};
export default MainDrawerContent;

View file

@ -0,0 +1,86 @@
import { Theme } from '@mui/material/styles';
import Box from '@mui/material/Box';
import Drawer from '@mui/material/Drawer';
import SwipeableDrawer from '@mui/material/SwipeableDrawer';
import Toolbar from '@mui/material/Toolbar';
import useMediaQuery from '@mui/material/useMediaQuery';
import React, { FC, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import browser from 'scripts/browser';
import { DRAWER_WIDTH } from './AppDrawer';
import { isTabPath } from '../tabs/tabRoutes';
export interface ResponsiveDrawerProps {
open: boolean
onClose: () => void
onOpen: () => void
}
const ResponsiveDrawer: FC<ResponsiveDrawerProps> = ({
children,
open = false,
onClose,
onOpen
}) => {
const location = useLocation();
const isSmallScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('sm'));
const isLargeScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('lg'));
const isTallToolbar = isTabPath(location.pathname) && !isLargeScreen;
const getToolbarStyles = useCallback((theme: Theme) => ({
marginBottom: isTallToolbar ? theme.spacing(6) : 0
}), [ isTallToolbar ]);
return ( isSmallScreen ? (
/* DESKTOP DRAWER */
<Drawer
sx={{
width: DRAWER_WIDTH,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: DRAWER_WIDTH,
boxSizing: 'border-box'
}
}}
variant='persistent'
anchor='left'
open={open}
>
<Toolbar
variant='dense'
sx={getToolbarStyles}
/>
{children}
</Drawer>
) : (
/* MOBILE DRAWER */
<SwipeableDrawer
anchor='left'
open={open}
onClose={onClose}
onOpen={onOpen}
// Disable swipe to open on iOS since it interferes with back navigation
disableDiscovery={browser.iOS}
ModalProps={{
keepMounted: true // Better open performance on mobile.
}}
>
<Toolbar
variant='dense'
sx={getToolbarStyles}
/>
<Box
role='presentation'
// Close the drawer when the content is clicked
onClick={onClose}
onKeyDown={onClose}
>
{children}
</Box>
</SwipeableDrawer>
));
};
export default ResponsiveDrawer;

View file

@ -0,0 +1,110 @@
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 globalize from 'scripts/globalize';
import ListItemLink from '../ListItemLink';
const PLUGIN_PATHS = [
'/installedplugins.html',
'/availableplugins.html',
'/repositories.html',
'/addplugin.html',
'/configurationpage'
];
const AdvancedDrawerSection = () => {
const location = useLocation();
const isPluginSectionOpen = PLUGIN_PATHS.includes(location.pathname);
return (
<List
aria-labelledby='advanced-subheader'
subheader={
<ListSubheader component='div' id='advanced-subheader'>
{globalize.translate('TabAdvanced')}
</ListSubheader>
}
>
<ListItem disablePadding>
<ListItemLink to='/networking.html'>
<ListItemIcon>
<Lan />
</ListItemIcon>
<ListItemText primary={globalize.translate('TabNetworking')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/apikeys.html'>
<ListItemIcon>
<VpnKey />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderApiKeys')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/log.html'>
<ListItemIcon>
<Article />
</ListItemIcon>
<ListItemText primary={globalize.translate('TabLogs')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/notificationsettings.html'>
<ListItemIcon>
<EditNotifications />
</ListItemIcon>
<ListItemText primary={globalize.translate('Notifications')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/installedplugins.html' 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='/installedplugins.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabMyPlugins')} />
</ListItemLink>
<ListItemLink to='/availableplugins.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabCatalog')} />
</ListItemLink>
<ListItemLink to='/repositories.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabRepositories')} />
</ListItemLink>
</List>
</Collapse>
<ListItem disablePadding>
<ListItemLink to='/scheduledtasks.html'>
<ListItemIcon>
<Schedule />
</ListItemIcon>
<ListItemText primary={globalize.translate('TabScheduledTasks')} />
</ListItemLink>
</ListItem>
</List>
);
};
export default AdvancedDrawerSection;

View file

@ -0,0 +1,73 @@
import { Devices, Analytics, Input, ExpandLess, ExpandMore } from '@mui/icons-material';
import Collapse from '@mui/material/Collapse';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader';
import React from 'react';
import { useLocation } from 'react-router-dom';
import globalize from 'scripts/globalize';
import ListItemLink from '../ListItemLink';
const DLNA_PATHS = [
'/dlnasettings.html',
'/dlnaprofiles.html'
];
const DevicesDrawerSection = () => {
const location = useLocation();
const isDlnaSectionOpen = DLNA_PATHS.includes(location.pathname);
return (
<List
aria-labelledby='devices-subheader'
subheader={
<ListSubheader component='div' id='devices-subheader'>
{globalize.translate('HeaderDevices')}
</ListSubheader>
}
>
<ListItem disablePadding>
<ListItemLink to='/devices.html'>
<ListItemIcon>
<Devices />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderDevices')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/serveractivity.html'>
<ListItemIcon>
<Analytics />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderActivity')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/dlnasettings.html' selected={false}>
<ListItemIcon>
<Input />
</ListItemIcon>
<ListItemText primary={'DLNA'} />
{isDlnaSectionOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemLink>
</ListItem>
<Collapse in={isDlnaSectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding>
<ListItemLink to='/dlnasettings.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('Settings')} />
</ListItemLink>
<ListItemLink to='/dlnaprofiles.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabProfiles')} />
</ListItemLink>
</List>
</Collapse>
</List>
);
};
export default DevicesDrawerSection;

View file

@ -0,0 +1,43 @@
import { Dvr, LiveTv } from '@mui/icons-material';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader';
import React from 'react';
import globalize from 'scripts/globalize';
import ListItemLink from '../ListItemLink';
const LiveTvDrawerSection = () => {
return (
<List
aria-labelledby='livetv-subheader'
subheader={
<ListSubheader component='div' id='livetv-subheader'>
{globalize.translate('LiveTV')}
</ListSubheader>
}
>
<ListItem disablePadding>
<ListItemLink to='/livetvstatus.html'>
<ListItemIcon>
<LiveTv />
</ListItemIcon>
<ListItemText primary={globalize.translate('LiveTV')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/livetvsettings.html'>
<ListItemIcon>
<Dvr />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderDVR')} />
</ListItemLink>
</ListItem>
</List>
);
};
export default LiveTvDrawerSection;

View file

@ -0,0 +1,67 @@
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 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 { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
import Dashboard from 'utils/dashboard';
import ListItemLink from '../ListItemLink';
const PluginDrawerSection = () => {
const { api } = useApi();
const [ pagesInfo, setPagesInfo ] = useState<ConfigurationPageInfo[]>([]);
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;
}
return (
<List
aria-labelledby='plugins-subheader'
subheader={
<ListSubheader component='div' id='plugins-subheader'>
{globalize.translate('TabPlugins')}
</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>
))
}
</List>
);
};
export default PluginDrawerSection;

View file

@ -0,0 +1,118 @@
import { Dashboard, ExpandLess, ExpandMore, LibraryAdd, People, PlayCircle, Settings } from '@mui/icons-material';
import Collapse from '@mui/material/Collapse';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader';
import React from 'react';
import { useLocation } from 'react-router-dom';
import globalize from 'scripts/globalize';
import ListItemLink from '../ListItemLink';
const LIBRARY_PATHS = [
'/library.html',
'/librarydisplay.html',
'/metadataimages.html',
'/metadatanfo.html'
];
const PLAYBACK_PATHS = [
'/encodingsettings.html',
'/playbackconfiguration.html',
'/streamingsettings.html'
];
const ServerDrawerSection = () => {
const location = useLocation();
const isLibrarySectionOpen = LIBRARY_PATHS.includes(location.pathname);
const isPlaybackSectionOpen = PLAYBACK_PATHS.includes(location.pathname);
return (
<List
aria-labelledby='server-subheader'
subheader={
<ListSubheader component='div' id='server-subheader'>
{globalize.translate('TabServer')}
</ListSubheader>
}
>
<ListItem disablePadding>
<ListItemLink to='/dashboard.html'>
<ListItemIcon>
<Dashboard />
</ListItemIcon>
<ListItemText primary={globalize.translate('TabDashboard')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/dashboardgeneral.html'>
<ListItemIcon>
<Settings />
</ListItemIcon>
<ListItemText primary={globalize.translate('General')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/userprofiles.html'>
<ListItemIcon>
<People />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderUsers')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/library.html' selected={false}>
<ListItemIcon>
<LibraryAdd />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderLibraries')} />
{isLibrarySectionOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemLink>
</ListItem>
<Collapse in={isLibrarySectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding>
<ListItemLink to='/library.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('HeaderLibraries')} />
</ListItemLink>
<ListItemLink to='/librarydisplay.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('Display')} />
</ListItemLink>
<ListItemLink to='/metadataimages.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('Metadata')} />
</ListItemLink>
<ListItemLink to='/metadatanfo.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabNfoSettings')} />
</ListItemLink>
</List>
</Collapse>
<ListItem disablePadding>
<ListItemLink to='/encodingsettings.html' selected={false}>
<ListItemIcon>
<PlayCircle />
</ListItemIcon>
<ListItemText primary={globalize.translate('TitlePlayback')} />
{isPlaybackSectionOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemLink>
</ListItem>
<Collapse in={isPlaybackSectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding>
<ListItemLink to='/encodingsettings.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('Transcoding')} />
</ListItemLink>
<ListItemLink to='/playbackconfiguration.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('ButtonResume')} />
</ListItemLink>
<ListItemLink to='/streamingsettings.html' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabStreaming')} />
</ListItemLink>
</List>
</Collapse>
</List>
);
};
export default ServerDrawerSection;

View file

@ -0,0 +1,90 @@
import { Theme } from '@mui/material/styles';
import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import useMediaQuery from '@mui/material/useMediaQuery';
import { debounce } from 'lodash-es';
import React, { FC, useCallback, useEffect } from 'react';
import { Route, Routes, useLocation, useSearchParams } from 'react-router-dom';
import TabRoutes, { getDefaultTabIndex } from './tabRoutes';
interface AppTabsParams {
isDrawerOpen: boolean
}
const handleResize = debounce(() => window.dispatchEvent(new Event('resize')), 100);
const AppTabs: FC<AppTabsParams> = ({
isDrawerOpen
}) => {
const isBigScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('sm'));
const location = useLocation();
const [ searchParams, setSearchParams ] = useSearchParams();
const searchParamsTab = searchParams.get('tab');
const libraryId = location.pathname === '/livetv.html' ?
'livetv' : searchParams.get('topParentId');
const activeTab = searchParamsTab !== null ?
parseInt(searchParamsTab, 10) :
getDefaultTabIndex(location.pathname, libraryId);
// HACK: Force resizing to workaround upstream bug with tab resizing
// https://github.com/mui/material-ui/issues/24011
useEffect(() => {
handleResize();
}, [ isDrawerOpen ]);
const onTabClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
const tabIndex = event.currentTarget.dataset.tabIndex;
if (tabIndex) {
searchParams.set('tab', tabIndex);
setSearchParams(searchParams);
}
}, [ searchParams, setSearchParams ]);
return (
<Routes>
{
TabRoutes.map(route => (
<Route
key={route.path}
path={route.path}
element={
<Tabs
value={activeTab}
sx={{
width: '100%',
flexShrink: {
xs: 0,
lg: 'unset'
},
order: {
xs: 100,
lg: 'unset'
}
}}
variant={isBigScreen ? 'standard' : 'scrollable'}
centered={isBigScreen}
>
{
route.tabs.map(({ index, label }) => (
<Tab
key={`${route}-tab-${index}`}
label={label}
data-tab-index={`${index}`}
onClick={onTabClick}
/>
))
}
</Tabs>
}
/>
))
}
</Routes>
);
};
export default AppTabs;

View file

@ -0,0 +1,190 @@
import globalize from 'scripts/globalize';
import * as userSettings from 'scripts/settings/userSettings';
import { LibraryTab } from 'types/libraryTab';
interface TabDefinition {
index: number
label: string
value: LibraryTab
isDefault?: boolean
}
interface TabRoute {
path: string,
tabs: TabDefinition[]
}
/**
* Utility function to check if a path has tabs.
*/
export const isTabPath = (path: string) => (
TabRoutes.some(route => route.path === path)
);
/**
* Utility function to get the default tab index for a specified URL path and library.
*/
export const getDefaultTabIndex = (path: string, libraryId?: string | null) => {
if (!libraryId) return 0;
const tabs = TabRoutes.find(route => route.path === path)?.tabs ?? [];
const defaultTab = userSettings.get('landing-' + libraryId, false);
return tabs.find(tab => tab.value === defaultTab)?.index
?? tabs.find(tab => tab.isDefault)?.index
?? 0;
};
const TabRoutes: TabRoute[] = [
{
path: '/livetv.html',
tabs: [
{
index: 0,
label: globalize.translate('Programs'),
value: LibraryTab.Programs,
isDefault: true
},
{
index: 1,
label: globalize.translate('Guide'),
value: LibraryTab.Guide
},
{
index: 2,
label: globalize.translate('Channels'),
value: LibraryTab.Channels
},
{
index: 3,
label: globalize.translate('Recordings'),
value: LibraryTab.Recordings
},
{
index: 4,
label: globalize.translate('Schedule'),
value: LibraryTab.Schedule
},
{
index: 5,
label: globalize.translate('Series'),
value: LibraryTab.Series
}
]
},
{
path: '/movies.html',
tabs: [
{
index: 0,
label: globalize.translate('Movies'),
value: LibraryTab.Movies,
isDefault: true
},
{
index: 1,
label: globalize.translate('Suggestions'),
value: LibraryTab.Suggestions
},
{
index: 2,
label: globalize.translate('Trailers'),
value: LibraryTab.Trailers
},
{
index: 3,
label: globalize.translate('Favorites'),
value: LibraryTab.Favorites
},
{
index: 4,
label: globalize.translate('Collections'),
value: LibraryTab.Collections
},
{
index: 5,
label: globalize.translate('Genres'),
value: LibraryTab.Genres
}
]
},
{
path: '/music.html',
tabs: [
{
index: 0,
label: globalize.translate('Albums'),
value: LibraryTab.Albums,
isDefault: true
},
{
index: 1,
label: globalize.translate('Suggestions'),
value: LibraryTab.Suggestions
},
{
index: 2,
label: globalize.translate('HeaderAlbumArtists'),
value: LibraryTab.AlbumArtists
},
{
index: 3,
label: globalize.translate('Artists'),
value: LibraryTab.Artists
},
{
index: 4,
label: globalize.translate('Playlists'),
value: LibraryTab.Playlists
},
{
index: 5,
label: globalize.translate('Songs'),
value: LibraryTab.Songs
},
{
index: 6,
label: globalize.translate('Genres'),
value: LibraryTab.Genres
}
]
},
{
path: '/tv.html',
tabs: [
{
index: 0,
label: globalize.translate('Shows'),
value: LibraryTab.Shows,
isDefault: true
},
{
index: 1,
label: globalize.translate('Suggestions'),
value: LibraryTab.Suggestions
},
{
index: 2,
label: globalize.translate('TabUpcoming'),
value: LibraryTab.Upcoming
},
{
index: 3,
label: globalize.translate('Genres'),
value: LibraryTab.Genres
},
{
index: 4,
label: globalize.translate('TabNetworks'),
value: LibraryTab.Networks
},
{
index: 5,
label: globalize.translate('Episodes'),
value: LibraryTab.Episodes
}
]
}
];
export default TabRoutes;

View file

@ -0,0 +1,11 @@
import { AsyncRoute } from '../../../../components/router/AsyncRoute';
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'notificationsettings.html', page: 'dashboard/notifications' },
{ path: 'usernew.html', page: 'user/usernew' },
{ path: 'userprofiles.html', page: 'user/userprofiles' },
{ path: 'useredit.html', page: 'user/useredit' },
{ path: 'userlibraryaccess.html', page: 'user/userlibraryaccess' },
{ path: 'userparentalcontrol.html', page: 'user/userparentalcontrol' },
{ path: 'userpassword.html', page: 'user/userpassword' }
];

View file

@ -0,0 +1,2 @@
export * from './admin';
export * from './user';

View file

@ -0,0 +1,8 @@
import { AsyncRoute, AsyncRouteType } from '../../../../components/router/AsyncRoute';
export const ASYNC_USER_ROUTES: AsyncRoute[] = [
{ path: 'search.html', page: 'search' },
{ path: 'userprofile.html', page: 'user/userprofile' },
{ path: 'home.html', page: 'home', type: AsyncRouteType.Experimental },
{ path: 'movies.html', page: 'movies', type: AsyncRouteType.Experimental }
];

View file

@ -1,22 +1,20 @@
import React, { FunctionComponent, useCallback, useEffect, useMemo, useRef } from 'react'; import React, { FunctionComponent, useCallback, useEffect, useMemo, useRef } from 'react';
import globalize from '../scripts/globalize'; import { useSearchParams } from 'react-router-dom';
import LibraryMenu from '../scripts/libraryMenu';
import { clearBackdrop } from '../components/backdrop/backdrop';
import layoutManager from '../components/layoutManager';
import * as mainTabsManager from '../components/maintabsmanager';
import '../elements/emby-tabs/emby-tabs';
import '../elements/emby-button/emby-button';
import '../elements/emby-scroller/emby-scroller';
import Page from '../components/Page';
type IProps = { import globalize from '../../../scripts/globalize';
tab?: string; import LibraryMenu from '../../../scripts/libraryMenu';
} import { clearBackdrop } from '../../../components/backdrop/backdrop';
import layoutManager from '../../../components/layoutManager';
import * as mainTabsManager from '../../../components/maintabsmanager';
import '../../../elements/emby-tabs/emby-tabs';
import '../../../elements/emby-button/emby-button';
import '../../../elements/emby-scroller/emby-scroller';
import Page from '../../../components/Page';
type OnResumeOptions = { type OnResumeOptions = {
autoFocus?: boolean; autoFocus?: boolean;
refresh?: boolean refresh?: boolean
} };
type ControllerProps = { type ControllerProps = {
onResume: ( onResume: (
@ -25,17 +23,14 @@ type ControllerProps = {
refreshed: boolean; refreshed: boolean;
onPause: () => void; onPause: () => void;
destroy: () => void; destroy: () => void;
} };
const Home: FunctionComponent<IProps> = (props: IProps) => { const Home: FunctionComponent = () => {
const getDefaultTabIndex = () => { const [ searchParams ] = useSearchParams();
return 0; const initialTabIndex = parseInt(searchParams.get('tab') || '0', 10);
};
const tabController = useRef<ControllerProps | null>(); const tabController = useRef<ControllerProps | null>();
const currentTabIndex = useRef(parseInt(props.tab || getDefaultTabIndex().toString()));
const tabControllers = useMemo<ControllerProps[]>(() => [], []); const tabControllers = useMemo<ControllerProps[]>(() => [], []);
const initialTabIndex = useRef<number | null>(currentTabIndex.current);
const element = useRef<HTMLDivElement>(null); const element = useRef<HTMLDivElement>(null);
const setTitle = () => { const setTitle = () => {
@ -70,18 +65,18 @@ const Home: FunctionComponent<IProps> = (props: IProps) => {
depends = 'favorites'; depends = 'favorites';
} }
return import(/* webpackChunkName: "[request]" */ `../controllers/${depends}`).then(({ default: controllerFactory }) => { return import(/* webpackChunkName: "[request]" */ `../../../controllers/${depends}`).then(({ default: controllerFactory }) => {
let controller = tabControllers[index]; let controller = tabControllers[index];
if (!controller) { if (!controller) {
const tabContent = element.current?.querySelector(".tabContent[data-index='" + index + "']"); const tabContent = element.current?.querySelector(".tabContent[data-index='" + index + "']");
controller = new controllerFactory(tabContent, props); controller = new controllerFactory(tabContent, null);
tabControllers[index] = controller; tabControllers[index] = controller;
} }
return controller; return controller;
}); });
}, [props, tabControllers]); }, [ tabControllers ]);
const onViewDestroy = useCallback(() => { const onViewDestroy = useCallback(() => {
if (tabControllers) { if (tabControllers) {
@ -93,8 +88,7 @@ const Home: FunctionComponent<IProps> = (props: IProps) => {
} }
tabController.current = null; tabController.current = null;
initialTabIndex.current = null; }, [ tabControllers ]);
}, [tabControllers]);
const loadTab = useCallback((index: number, previousIndex: number | null) => { const loadTab = useCallback((index: number, previousIndex: number | null) => {
getTabController(index).then((controller) => { getTabController(index).then((controller) => {
@ -106,22 +100,23 @@ const Home: FunctionComponent<IProps> = (props: IProps) => {
}); });
controller.refreshed = true; controller.refreshed = true;
currentTabIndex.current = index;
tabController.current = controller; tabController.current = controller;
}).catch(err => {
console.error('[Home] failed to get tab controller', err);
}); });
}, [getTabController]); }, [ getTabController ]);
const onTabChange = useCallback((e: { detail: { selectedTabIndex: string; previousIndex: number | null }; }) => { const onTabChange = useCallback((e: { detail: { selectedTabIndex: string; previousIndex: number | null }; }) => {
const newIndex = parseInt(e.detail.selectedTabIndex); const newIndex = parseInt(e.detail.selectedTabIndex, 10);
const previousIndex = e.detail.previousIndex; const previousIndex = e.detail.previousIndex;
const previousTabController = previousIndex == null ? null : tabControllers[previousIndex]; const previousTabController = previousIndex == null ? null : tabControllers[previousIndex];
if (previousTabController && previousTabController.onPause) { if (previousTabController?.onPause) {
previousTabController.onPause(); previousTabController.onPause();
} }
loadTab(newIndex, previousIndex); loadTab(newIndex, previousIndex);
}, [loadTab, tabControllers]); }, [ loadTab, tabControllers ]);
const onResume = useCallback(() => { const onResume = useCallback(() => {
setTitle(); setTitle();
@ -130,30 +125,29 @@ const Home: FunctionComponent<IProps> = (props: IProps) => {
const currentTabController = tabController.current; const currentTabController = tabController.current;
if (!currentTabController) { if (!currentTabController) {
mainTabsManager.selectedTabIndex(initialTabIndex.current); mainTabsManager.selectedTabIndex(initialTabIndex);
} else if (currentTabController && currentTabController.onResume) { } else if (currentTabController?.onResume) {
currentTabController.onResume({}); currentTabController.onResume({});
} }
(document.querySelector('.skinHeader') as HTMLDivElement).classList.add('noHomeButtonHeader'); (document.querySelector('.skinHeader') as HTMLDivElement).classList.add('noHomeButtonHeader');
}, []); }, [ initialTabIndex ]);
const onPause = useCallback(() => { const onPause = useCallback(() => {
const currentTabController = tabController.current; const currentTabController = tabController.current;
if (currentTabController && currentTabController.onPause) { if (currentTabController?.onPause) {
currentTabController.onPause(); currentTabController.onPause();
} }
(document.querySelector('.skinHeader') as HTMLDivElement).classList.remove('noHomeButtonHeader'); (document.querySelector('.skinHeader') as HTMLDivElement).classList.remove('noHomeButtonHeader');
}, []); }, []);
useEffect(() => { useEffect(() => {
mainTabsManager.setTabs(element.current, currentTabIndex.current, getTabs, getTabContainers, null, onTabChange, false); mainTabsManager.setTabs(element.current, initialTabIndex, getTabs, getTabContainers, null, onTabChange, false);
onResume(); onResume();
return () => { return () => {
onPause(); onPause();
onViewDestroy();
}; };
}, [onPause, onResume, onTabChange, onViewDestroy]); }, [ initialTabIndex, onPause, onResume, onTabChange, onViewDestroy ]);
return ( return (
<div ref={element}> <div ref={element}>

View file

@ -1,4 +1,4 @@
import { LegacyRoute } from '.'; import { LegacyRoute } from '../../../../components/router/LegacyRoute';
export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
{ {
@ -103,18 +103,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
controller: 'dashboard/metadatanfo', controller: 'dashboard/metadatanfo',
view: 'dashboard/metadatanfo.html' view: 'dashboard/metadatanfo.html'
} }
}, {
path: 'notificationsetting.html',
pageProps: {
controller: 'dashboard/notifications/notification/index',
view: 'dashboard/notifications/notification/index.html'
}
}, {
path: 'notificationsettings.html',
pageProps: {
controller: 'dashboard/notifications/notifications/index',
view: 'dashboard/notifications/notifications/index.html'
}
}, { }, {
path: 'playbackconfiguration.html', path: 'playbackconfiguration.html',
pageProps: { pageProps: {

View file

@ -0,0 +1,3 @@
export * from './admin';
export * from './public';
export * from './user';

View file

@ -0,0 +1,81 @@
import { LegacyRoute } from '../../../../components/router/LegacyRoute';
export const LEGACY_PUBLIC_ROUTES: LegacyRoute[] = [
{
path: 'addserver.html',
pageProps: {
controller: 'session/addServer/index',
view: 'session/addServer/index.html'
}
},
{
path: 'selectserver.html',
pageProps: {
controller: 'session/selectServer/index',
view: 'session/selectServer/index.html'
}
},
{
path: 'login.html',
pageProps: {
controller: 'session/login/index',
view: 'session/login/index.html'
}
},
{
path: 'forgotpassword.html',
pageProps: {
controller: 'session/forgotPassword/index',
view: 'session/forgotPassword/index.html'
}
},
{
path: 'forgotpasswordpin.html',
pageProps: {
controller: 'session/resetPassword/index',
view: 'session/resetPassword/index.html'
}
},
{
path: 'wizardremoteaccess.html',
pageProps: {
controller: 'wizard/remote/index',
view: 'wizard/remote/index.html'
}
},
{
path: 'wizardfinish.html',
pageProps: {
controller: 'wizard/finish/index',
view: 'wizard/finish/index.html'
}
},
{
path: 'wizardlibrary.html',
pageProps: {
controller: 'dashboard/library',
view: 'wizard/library.html'
}
},
{
path: 'wizardsettings.html',
pageProps: {
controller: 'wizard/settings/index',
view: 'wizard/settings/index.html'
}
},
{
path: 'wizardstart.html',
pageProps: {
controller: 'wizard/start/index',
view: 'wizard/start/index.html'
}
},
{
path: 'wizarduser.html',
pageProps: {
controller: 'wizard/user/index',
view: 'wizard/user/index.html'
}
}
];

View file

@ -1,4 +1,4 @@
import { LegacyRoute } from '.'; import { LegacyRoute } from '../../../../components/router/LegacyRoute';
export const LEGACY_USER_ROUTES: LegacyRoute[] = [ export const LEGACY_USER_ROUTES: LegacyRoute[] = [
{ {

View file

@ -1,7 +1,7 @@
import React, { FC, useCallback } from 'react'; import React, { FC, useCallback } from 'react';
import ViewItemsContainer from '../../components/common/ViewItemsContainer'; import ViewItemsContainer from '../../../../components/common/ViewItemsContainer';
import { LibraryViewProps } from '../../types/interface'; import { LibraryViewProps } from '../../../../types/interface';
const CollectionsView: FC<LibraryViewProps> = ({ topParentId }) => { const CollectionsView: FC<LibraryViewProps> = ({ topParentId }) => {
const getBasekey = useCallback(() => { const getBasekey = useCallback(() => {

View file

@ -1,7 +1,7 @@
import React, { FC, useCallback } from 'react'; import React, { FC, useCallback } from 'react';
import ViewItemsContainer from '../../components/common/ViewItemsContainer'; import ViewItemsContainer from '../../../../components/common/ViewItemsContainer';
import { LibraryViewProps } from '../../types/interface'; import { LibraryViewProps } from '../../../../types/interface';
const FavoritesView: FC<LibraryViewProps> = ({ topParentId }) => { const FavoritesView: FC<LibraryViewProps> = ({ topParentId }) => {
const getBasekey = useCallback(() => { const getBasekey = useCallback(() => {

View file

@ -1,9 +1,9 @@
import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'; import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useCallback, useEffect, useState } from 'react'; import React, { FC, useCallback, useEffect, useState } from 'react';
import loading from '../../components/loading/loading'; import loading from '../../../../components/loading/loading';
import GenresItemsContainer from '../../components/common/GenresItemsContainer'; import GenresItemsContainer from '../../../../components/common/GenresItemsContainer';
import { LibraryViewProps } from '../../types/interface'; import { LibraryViewProps } from '../../../../types/interface';
const GenresView: FC<LibraryViewProps> = ({ topParentId }) => { const GenresView: FC<LibraryViewProps> = ({ topParentId }) => {
const [ itemsResult, setItemsResult ] = useState<BaseItemDtoQueryResult>({}); const [ itemsResult, setItemsResult ] = useState<BaseItemDtoQueryResult>({});
@ -23,6 +23,8 @@ const GenresView: FC<LibraryViewProps> = ({ topParentId }) => {
).then((result) => { ).then((result) => {
setItemsResult(result); setItemsResult(result);
loading.hide(); loading.hide();
}).catch(err => {
console.error('[GenresView] failed to fetch genres', err);
}); });
}, [topParentId]); }, [topParentId]);

View file

@ -1,7 +1,7 @@
import React, { FC, useCallback } from 'react'; import React, { FC, useCallback } from 'react';
import ViewItemsContainer from '../../components/common/ViewItemsContainer'; import ViewItemsContainer from '../../../../components/common/ViewItemsContainer';
import { LibraryViewProps } from '../../types/interface'; import { LibraryViewProps } from '../../../../types/interface';
const MoviesView: FC<LibraryViewProps> = ({ topParentId }) => { const MoviesView: FC<LibraryViewProps> = ({ topParentId }) => {
const getBasekey = useCallback(() => { const getBasekey = useCallback(() => {

View file

@ -1,15 +1,15 @@
import type { BaseItemDto, BaseItemDtoQueryResult, RecommendationDto } from '@jellyfin/sdk/lib/generated-client'; import type { BaseItemDto, BaseItemDtoQueryResult, RecommendationDto } from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import layoutManager from '../../components/layoutManager'; import layoutManager from '../../../../components/layoutManager';
import loading from '../../components/loading/loading'; import loading from '../../../../components/loading/loading';
import dom from '../../scripts/dom'; import dom from '../../../../scripts/dom';
import globalize from '../../scripts/globalize'; import globalize from '../../../../scripts/globalize';
import RecommendationContainer from '../../components/common/RecommendationContainer'; import RecommendationContainer from '../../../../components/common/RecommendationContainer';
import SectionContainer from '../../components/common/SectionContainer'; import SectionContainer from '../../../../components/common/SectionContainer';
import { LibraryViewProps } from '../../types/interface'; import { LibraryViewProps } from '../../../../types/interface';
const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => { const SuggestionsView: FC<LibraryViewProps> = ({ topParentId }) => {
const [ latestItems, setLatestItems ] = useState<BaseItemDto[]>([]); const [ latestItems, setLatestItems ] = useState<BaseItemDto[]>([]);
const [ resumeResult, setResumeResult ] = useState<BaseItemDtoQueryResult>({}); const [ resumeResult, setResumeResult ] = useState<BaseItemDtoQueryResult>({});
const [ recommendations, setRecommendations ] = useState<RecommendationDto[]>([]); const [ recommendations, setRecommendations ] = useState<RecommendationDto[]>([]);
@ -28,8 +28,10 @@ const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => {
}, [enableScrollX]); }, [enableScrollX]);
const autoFocus = useCallback((page) => { const autoFocus = useCallback((page) => {
import('../../components/autoFocuser').then(({default: autoFocuser}) => { import('../../../../components/autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(page); autoFocuser.autoFocus(page);
}).catch(err => {
console.error('[SuggestionsView] failed to load data', err);
}); });
}, []); }, []);
@ -55,6 +57,8 @@ const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => {
loading.hide(); loading.hide();
autoFocus(page); autoFocus(page);
}).catch(err => {
console.error('[SuggestionsView] failed to fetch items', err);
}); });
}, [autoFocus]); }, [autoFocus]);
@ -72,6 +76,8 @@ const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => {
setLatestItems(items); setLatestItems(items);
autoFocus(page); autoFocus(page);
}).catch(err => {
console.error('[SuggestionsView] failed to fetch latest items', err);
}); });
}, [autoFocus]); }, [autoFocus]);
@ -95,6 +101,8 @@ const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => {
setRecommendations(result); setRecommendations(result);
autoFocus(page); autoFocus(page);
}).catch(err => {
console.error('[SuggestionsView] failed to fetch recommendations', err);
}); });
}, [autoFocus]); }, [autoFocus]);
@ -143,8 +151,8 @@ const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => {
{!recommendations.length ? <div className='noItemsMessage centerMessage'> {!recommendations.length ? <div className='noItemsMessage centerMessage'>
<h1>{globalize.translate('MessageNothingHere')}</h1> <h1>{globalize.translate('MessageNothingHere')}</h1>
<p>{globalize.translate('MessageNoMovieSuggestionsAvailable')}</p> <p>{globalize.translate('MessageNoMovieSuggestionsAvailable')}</p>
</div> : recommendations.map((recommendation, index) => { </div> : recommendations.map(recommendation => {
return <RecommendationContainer key={index} getPortraitShape={getPortraitShape} enableScrollX={enableScrollX} recommendation={recommendation} />; return <RecommendationContainer key={recommendation.CategoryId} getPortraitShape={getPortraitShape} enableScrollX={enableScrollX} recommendation={recommendation} />;
})} })}
</div> </div>
); );

View file

@ -1,8 +1,8 @@
import React, { FC, useCallback } from 'react'; import React, { FC, useCallback } from 'react';
import ViewItemsContainer from '../../components/common/ViewItemsContainer'; import ViewItemsContainer from '../../../../components/common/ViewItemsContainer';
import { LibraryViewProps } from '../../types/interface'; import { LibraryViewProps } from '../../../../types/interface';
const TrailersView: FC<LibraryViewProps> = ({ topParentId }) => { const TrailersView: FC<LibraryViewProps> = ({ topParentId }) => {
const getBasekey = useCallback(() => { const getBasekey = useCallback(() => {

View file

@ -1,62 +1,28 @@
import '../../elements/emby-scroller/emby-scroller'; import '../../../../elements/emby-scroller/emby-scroller';
import '../../elements/emby-itemscontainer/emby-itemscontainer'; import '../../../../elements/emby-itemscontainer/emby-itemscontainer';
import '../../elements/emby-tabs/emby-tabs'; import '../../../../elements/emby-tabs/emby-tabs';
import '../../elements/emby-button/emby-button'; import '../../../../elements/emby-button/emby-button';
import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; import React, { FC, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useLocation, useSearchParams } from 'react-router-dom';
import * as mainTabsManager from '../../components/maintabsmanager'; import Page from '../../../../components/Page';
import Page from '../../components/Page'; import globalize from '../../../../scripts/globalize';
import globalize from '../../scripts/globalize'; import libraryMenu from '../../../../scripts/libraryMenu';
import libraryMenu from '../../scripts/libraryMenu';
import * as userSettings from '../../scripts/settings/userSettings';
import CollectionsView from './CollectionsView'; import CollectionsView from './CollectionsView';
import FavoritesView from './FavoritesView'; import FavoritesView from './FavoritesView';
import GenresView from './GenresView'; import GenresView from './GenresView';
import MoviesView from './MoviesView'; import MoviesView from './MoviesView';
import SuggestionsView from './SuggestionsView'; import SuggestionsView from './SuggestionsView';
import TrailersView from './TrailersView'; import TrailersView from './TrailersView';
import { getDefaultTabIndex } from '../../components/tabs/tabRoutes';
const getDefaultTabIndex = (folderId: string | null) => {
switch (userSettings.get('landing-' + folderId, false)) {
case 'suggestions':
return 1;
case 'favorites':
return 3;
case 'collections':
return 4;
case 'genres':
return 5;
default:
return 0;
}
};
const getTabs = () => {
return [{
name: globalize.translate('Movies')
}, {
name: globalize.translate('Suggestions')
}, {
name: globalize.translate('Trailers')
}, {
name: globalize.translate('Favorites')
}, {
name: globalize.translate('Collections')
}, {
name: globalize.translate('Genres')
}];
};
const Movies: FC = () => { const Movies: FC = () => {
const location = useLocation();
const [ searchParams ] = useSearchParams(); const [ searchParams ] = useSearchParams();
const currentTabIndex = parseInt(searchParams.get('tab') || getDefaultTabIndex(searchParams.get('topParentId')).toString()); const searchParamsTab = searchParams.get('tab');
const [ selectedIndex, setSelectedIndex ] = useState(currentTabIndex); const currentTabIndex = searchParamsTab !== null ? parseInt(searchParamsTab, 10) :
getDefaultTabIndex(location.pathname, searchParams.get('topParentId'));
const element = useRef<HTMLDivElement>(null); const element = useRef<HTMLDivElement>(null);
const getTabComponent = (index: number) => { const getTabComponent = (index: number) => {
@ -94,11 +60,6 @@ const Movies: FC = () => {
return component; return component;
}; };
const onTabChange = useCallback((e: { detail: { selectedTabIndex: string; }; }) => {
const newIndex = parseInt(e.detail.selectedTabIndex);
setSelectedIndex(newIndex);
}, []);
useEffect(() => { useEffect(() => {
const page = element.current; const page = element.current;
@ -106,7 +67,7 @@ const Movies: FC = () => {
console.error('Unexpected null reference'); console.error('Unexpected null reference');
return; return;
} }
mainTabsManager.setTabs(page, selectedIndex, getTabs, undefined, undefined, onTabChange);
if (!page.getAttribute('data-title')) { if (!page.getAttribute('data-title')) {
const parentId = searchParams.get('topParentId'); const parentId = searchParams.get('topParentId');
@ -114,13 +75,17 @@ const Movies: FC = () => {
window.ApiClient.getItem(window.ApiClient.getCurrentUserId(), parentId).then((item) => { window.ApiClient.getItem(window.ApiClient.getCurrentUserId(), parentId).then((item) => {
page.setAttribute('data-title', item.Name as string); page.setAttribute('data-title', item.Name as string);
libraryMenu.setTitle(item.Name); libraryMenu.setTitle(item.Name);
}).catch(err => {
console.error('[movies] failed to fetch library', err);
page.setAttribute('data-title', globalize.translate('Movies'));
libraryMenu.setTitle(globalize.translate('Movies'));
}); });
} else { } else {
page.setAttribute('data-title', globalize.translate('Movies')); page.setAttribute('data-title', globalize.translate('Movies'));
libraryMenu.setTitle(globalize.translate('Movies')); libraryMenu.setTitle(globalize.translate('Movies'));
} }
} }
}, [onTabChange, searchParams, selectedIndex]); }, [ searchParams ]);
return ( return (
<div ref={element}> <div ref={element}>
@ -129,7 +94,7 @@ const Movies: FC = () => {
className='mainAnimatedPage libraryPage backdropPage collectionEditorPage pageWithAbsoluteTabs withTabs' className='mainAnimatedPage libraryPage backdropPage collectionEditorPage pageWithAbsoluteTabs withTabs'
backDropType='movie' backDropType='movie'
> >
{getTabComponent(selectedIndex)} {getTabComponent(currentTabIndex)}
</Page> </Page>
</div> </div>

View file

@ -0,0 +1,53 @@
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
palette: {
mode: 'dark',
primary: {
main: '#00a4dc'
},
secondary: {
main: '#aa5cc3'
},
background: {
default: '#101010',
paper: '#202020'
},
action: {
selectedOpacity: 0.2
}
},
typography: {
fontFamily: '"Noto Sans", sans-serif',
button: {
textTransform: 'none'
}
},
components: {
MuiButton: {
defaultProps: {
variant: 'contained'
}
},
MuiFormControl: {
defaultProps: {
variant: 'filled'
}
},
MuiTextField: {
defaultProps: {
variant: 'filled'
}
},
MuiListSubheader: {
styleOverrides: {
root: {
// NOTE: Added for drawer subheaders, but maybe it won't work in other cases?
backgroundColor: 'inherit'
}
}
}
}
});
export default theme;

58
src/apps/stable/App.tsx Normal file
View file

@ -0,0 +1,58 @@
import React from 'react';
import { Navigate, Outlet, Route, Routes } from 'react-router-dom';
import AppHeader from 'components/AppHeader';
import Backdrop from 'components/Backdrop';
import ServerContentPage from 'components/ServerContentPage';
import ConnectionRequired from 'components/ConnectionRequired';
import { toAsyncPageRoute } from 'components/router/AsyncRoute';
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
import { ASYNC_ADMIN_ROUTES, ASYNC_USER_ROUTES } from './routes/asyncRoutes';
import { LEGACY_ADMIN_ROUTES, LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './routes/legacyRoutes';
const Layout = () => (
<>
<Backdrop />
<AppHeader />
<div className='mainAnimatedPages skinBody' />
<div className='skinBody'>
<Outlet />
</div>
</>
);
const StableApp = () => (
<Routes>
<Route element={<Layout />}>
{/* User routes */}
<Route path='/' element={<ConnectionRequired />}>
{ASYNC_USER_ROUTES.map(toAsyncPageRoute)}
{LEGACY_USER_ROUTES.map(toViewManagerPageRoute)}
</Route>
{/* Admin routes */}
<Route path='/' element={<ConnectionRequired isAdminRequired />}>
{ASYNC_ADMIN_ROUTES.map(toAsyncPageRoute)}
{LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute)}
<Route path='configurationpage' element={
<ServerContentPage view='/web/configurationpage' />
} />
</Route>
{/* Public routes */}
<Route path='/' element={<ConnectionRequired isUserRequired={false} />}>
<Route index element={<Navigate replace to='/home.html' />} />
{LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute)}
</Route>
{/* Suppress warnings for unhandled routes */}
<Route path='*' element={null} />
</Route>
</Routes>
);
export default StableApp;

View file

@ -0,0 +1,11 @@
import { AsyncRoute } from '../../../../components/router/AsyncRoute';
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'notificationsettings.html', page: 'dashboard/notifications' },
{ path: 'usernew.html', page: 'user/usernew' },
{ path: 'userprofiles.html', page: 'user/userprofiles' },
{ path: 'useredit.html', page: 'user/useredit' },
{ path: 'userlibraryaccess.html', page: 'user/userlibraryaccess' },
{ path: 'userparentalcontrol.html', page: 'user/userparentalcontrol' },
{ path: 'userpassword.html', page: 'user/userpassword' }
];

View file

@ -0,0 +1,2 @@
export * from './admin';
export * from './user';

View file

@ -0,0 +1,6 @@
import { AsyncRoute } from '../../../../components/router/AsyncRoute';
export const ASYNC_USER_ROUTES: AsyncRoute[] = [
{ path: 'search.html', page: 'search' },
{ path: 'userprofile.html', page: 'user/userprofile' }
];

View file

@ -0,0 +1,36 @@
import React from 'react';
import Page from 'components/Page';
import globalize from 'scripts/globalize';
const PluginLink = () => (
<div
dangerouslySetInnerHTML={{
__html: `<a
is='emby-linkbutton'
class='button-link'
href='#/addplugin.html?name=Webhook&guid=71552a5a5c5c4350a2aeebe451a30173'
>
${globalize.translate('GetThePlugin')}
</a>`
}}
/>
);
const Notifications = () => (
<Page
id='notificationSettingPage'
title={globalize.translate('Notifications')}
className='mainAnimatedPage type-interior'
>
<div className='content-primary'>
<h2>{globalize.translate('Notifications')}</h2>
<p>
{globalize.translate('NotificationsMovedMessage')}
</p>
<PluginLink />
</div>
</Page>
);
export default Notifications;

View file

@ -0,0 +1,185 @@
import { LegacyRoute } from '../../../../components/router/LegacyRoute';
export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
{
path: 'dashboard.html',
pageProps: {
controller: 'dashboard/dashboard',
view: 'dashboard/dashboard.html'
}
}, {
path: 'dashboardgeneral.html',
pageProps: {
controller: 'dashboard/general',
view: 'dashboard/general.html'
}
}, {
path: 'networking.html',
pageProps: {
controller: 'dashboard/networking',
view: 'dashboard/networking.html'
}
}, {
path: 'devices.html',
pageProps: {
controller: 'dashboard/devices/devices',
view: 'dashboard/devices/devices.html'
}
}, {
path: 'device.html',
pageProps: {
controller: 'dashboard/devices/device',
view: 'dashboard/devices/device.html'
}
}, {
path: 'quickConnect.html',
pageProps: {
controller: 'dashboard/quickConnect',
view: 'dashboard/quickConnect.html'
}
}, {
path: 'dlnaprofile.html',
pageProps: {
controller: 'dashboard/dlna/profile',
view: 'dashboard/dlna/profile.html'
}
}, {
path: 'dlnaprofiles.html',
pageProps: {
controller: 'dashboard/dlna/profiles',
view: 'dashboard/dlna/profiles.html'
}
}, {
path: 'dlnasettings.html',
pageProps: {
controller: 'dashboard/dlna/settings',
view: 'dashboard/dlna/settings.html'
}
}, {
path: 'addplugin.html',
pageProps: {
controller: 'dashboard/plugins/add/index',
view: 'dashboard/plugins/add/index.html'
}
}, {
path: 'library.html',
pageProps: {
controller: 'dashboard/library',
view: 'dashboard/library.html'
}
}, {
path: 'librarydisplay.html',
pageProps: {
controller: 'dashboard/librarydisplay',
view: 'dashboard/librarydisplay.html'
}
}, {
path: 'edititemmetadata.html',
pageProps: {
controller: 'edititemmetadata',
view: 'edititemmetadata.html'
}
}, {
path: 'encodingsettings.html',
pageProps: {
controller: 'dashboard/encodingsettings',
view: 'dashboard/encodingsettings.html'
}
}, {
path: 'log.html',
pageProps: {
controller: 'dashboard/logs',
view: 'dashboard/logs.html'
}
}, {
path: 'metadataimages.html',
pageProps: {
controller: 'dashboard/metadataImages',
view: 'dashboard/metadataimages.html'
}
}, {
path: 'metadatanfo.html',
pageProps: {
controller: 'dashboard/metadatanfo',
view: 'dashboard/metadatanfo.html'
}
}, {
path: 'playbackconfiguration.html',
pageProps: {
controller: 'dashboard/playback',
view: 'dashboard/playback.html'
}
}, {
path: 'availableplugins.html',
pageProps: {
controller: 'dashboard/plugins/available/index',
view: 'dashboard/plugins/available/index.html'
}
}, {
path: 'repositories.html',
pageProps: {
controller: 'dashboard/plugins/repositories/index',
view: 'dashboard/plugins/repositories/index.html'
}
}, {
path: 'livetvguideprovider.html',
pageProps: {
controller: 'livetvguideprovider',
view: 'livetvguideprovider.html'
}
}, {
path: 'livetvsettings.html',
pageProps: {
controller: 'livetvsettings',
view: 'livetvsettings.html'
}
}, {
path: 'livetvstatus.html',
pageProps: {
controller: 'livetvstatus',
view: 'livetvstatus.html'
}
}, {
path: 'livetvtuner.html',
pageProps: {
controller: 'livetvtuner',
view: 'livetvtuner.html'
}
}, {
path: 'installedplugins.html',
pageProps: {
controller: 'dashboard/plugins/installed/index',
view: 'dashboard/plugins/installed/index.html'
}
}, {
path: 'scheduledtask.html',
pageProps: {
controller: 'dashboard/scheduledtasks/scheduledtask',
view: 'dashboard/scheduledtasks/scheduledtask.html'
}
}, {
path: 'scheduledtasks.html',
pageProps: {
controller: 'dashboard/scheduledtasks/scheduledtasks',
view: 'dashboard/scheduledtasks/scheduledtasks.html'
}
}, {
path: 'serveractivity.html',
pageProps: {
controller: 'dashboard/serveractivity',
view: 'dashboard/serveractivity.html'
}
}, {
path: 'apikeys.html',
pageProps: {
controller: 'dashboard/apikeys',
view: 'dashboard/apikeys.html'
}
}, {
path: 'streamingsettings.html',
pageProps: {
view: 'dashboard/streaming.html',
controller: 'dashboard/streaming'
}
}
];

View file

@ -0,0 +1,3 @@
export * from './admin';
export * from './public';
export * from './user';

View file

@ -0,0 +1,81 @@
import { LegacyRoute } from '../../../../components/router/LegacyRoute';
export const LEGACY_PUBLIC_ROUTES: LegacyRoute[] = [
{
path: 'addserver.html',
pageProps: {
controller: 'session/addServer/index',
view: 'session/addServer/index.html'
}
},
{
path: 'selectserver.html',
pageProps: {
controller: 'session/selectServer/index',
view: 'session/selectServer/index.html'
}
},
{
path: 'login.html',
pageProps: {
controller: 'session/login/index',
view: 'session/login/index.html'
}
},
{
path: 'forgotpassword.html',
pageProps: {
controller: 'session/forgotPassword/index',
view: 'session/forgotPassword/index.html'
}
},
{
path: 'forgotpasswordpin.html',
pageProps: {
controller: 'session/resetPassword/index',
view: 'session/resetPassword/index.html'
}
},
{
path: 'wizardremoteaccess.html',
pageProps: {
controller: 'wizard/remote/index',
view: 'wizard/remote/index.html'
}
},
{
path: 'wizardfinish.html',
pageProps: {
controller: 'wizard/finish/index',
view: 'wizard/finish/index.html'
}
},
{
path: 'wizardlibrary.html',
pageProps: {
controller: 'dashboard/library',
view: 'wizard/library.html'
}
},
{
path: 'wizardsettings.html',
pageProps: {
controller: 'wizard/settings/index',
view: 'wizard/settings/index.html'
}
},
{
path: 'wizardstart.html',
pageProps: {
controller: 'wizard/start/index',
view: 'wizard/start/index.html'
}
},
{
path: 'wizarduser.html',
pageProps: {
controller: 'wizard/user/index',
view: 'wizard/user/index.html'
}
}
];

View file

@ -0,0 +1,108 @@
import { LegacyRoute } from '../../../../components/router/LegacyRoute';
export const LEGACY_USER_ROUTES: LegacyRoute[] = [
{
path: 'details',
pageProps: {
controller: 'itemDetails/index',
view: 'itemDetails/index.html'
}
}, {
path: 'list.html',
pageProps: {
controller: 'list',
view: 'list.html'
}
}, {
path: 'livetv.html',
pageProps: {
controller: 'livetv/livetvsuggested',
view: 'livetv.html'
}
}, {
path: 'music.html',
pageProps: {
controller: 'music/musicrecommended',
view: 'music/music.html'
}
}, {
path: 'mypreferencesmenu.html',
pageProps: {
controller: 'user/menu/index',
view: 'user/menu/index.html'
}
}, {
path: 'mypreferencescontrols.html',
pageProps: {
controller: 'user/controls/index',
view: 'user/controls/index.html'
}
}, {
path: 'mypreferencesdisplay.html',
pageProps: {
controller: 'user/display/index',
view: 'user/display/index.html'
}
}, {
path: 'mypreferenceshome.html',
pageProps: {
controller: 'user/home/index',
view: 'user/home/index.html'
}
}, {
path: 'mypreferencesquickconnect.html',
pageProps: {
controller: 'user/quickConnect/index',
view: 'user/quickConnect/index.html'
}
}, {
path: 'mypreferencesplayback.html',
pageProps: {
controller: 'user/playback/index',
view: 'user/playback/index.html'
}
}, {
path: 'mypreferencessubtitles.html',
pageProps: {
controller: 'user/subtitles/index',
view: 'user/subtitles/index.html'
}
}, {
path: 'tv.html',
pageProps: {
controller: 'shows/tvrecommended',
view: 'shows/tvrecommended.html'
}
}, {
path: 'video',
pageProps: {
controller: 'playback/video/index',
view: 'playback/video/index.html',
type: 'video-osd',
isFullscreen: true,
isNowPlayingBarEnabled: false,
isThemeMediaSupported: true
}
}, {
path: 'queue',
pageProps: {
controller: 'playback/queue/index',
view: 'playback/queue/index.html',
isFullscreen: true,
isNowPlayingBarEnabled: false,
isThemeMediaSupported: true
}
}, {
path: 'home.html',
pageProps: {
controller: 'home',
view: 'home.html'
}
}, {
path: 'movies.html',
pageProps: {
controller: 'movies/moviesrecommended',
view: 'movies/movies.html'
}
}
];

View file

@ -1,12 +1,12 @@
import React, { FunctionComponent, useState } from 'react'; import React, { FunctionComponent, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import Page from '../components/Page'; import Page from '../../../components/Page';
import SearchFields from '../components/search/SearchFields'; import SearchFields from '../../../components/search/SearchFields';
import SearchResults from '../components/search/SearchResults'; import SearchResults from '../../../components/search/SearchResults';
import SearchSuggestions from '../components/search/SearchSuggestions'; import SearchSuggestions from '../../../components/search/SearchSuggestions';
import LiveTVSearchResults from '../components/search/LiveTVSearchResults'; import LiveTVSearchResults from '../../../components/search/LiveTVSearchResults';
import globalize from '../scripts/globalize'; import globalize from '../../../scripts/globalize';
const Search: FunctionComponent = () => { const Search: FunctionComponent = () => {
const [ query, setQuery ] = useState<string>(); const [ query, setQuery ] = useState<string>();
@ -19,9 +19,8 @@ const Search: FunctionComponent = () => {
className='mainAnimatedPage libraryPage allLibraryPage noSecondaryNavPage' className='mainAnimatedPage libraryPage allLibraryPage noSecondaryNavPage'
> >
<SearchFields onSearch={setQuery} /> <SearchFields onSearch={setQuery} />
{!query && {!query
<SearchSuggestions && <SearchSuggestions
serverId={searchParams.get('serverId') || window.ApiClient.serverId()}
parentId={searchParams.get('parentId')} parentId={searchParams.get('parentId')}
/> />
} }

View file

@ -1,28 +1,43 @@
import type { SyncPlayUserAccessType, UserDto } from '@jellyfin/sdk/lib/generated-client'; import type { SyncPlayUserAccessType, UserDto } from '@jellyfin/sdk/lib/generated-client';
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react'; import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
import Dashboard from '../../utils/dashboard';
import globalize from '../../scripts/globalize';
import LibraryMenu from '../../scripts/libraryMenu';
import ButtonElement from '../../elements/ButtonElement';
import CheckBoxElement from '../../elements/CheckBoxElement';
import InputElement from '../../elements/InputElement';
import LinkEditUserPreferences from '../../components/dashboard/users/LinkEditUserPreferences';
import SectionTitleContainer from '../../elements/SectionTitleContainer';
import SectionTabs from '../../components/dashboard/users/SectionTabs';
import loading from '../../components/loading/loading';
import toast from '../../components/toast/toast';
import { getParameterByName } from '../../utils/url';
import escapeHTML from 'escape-html'; import escapeHTML from 'escape-html';
import SelectElement from '../../elements/SelectElement';
import Page from '../../components/Page'; import Dashboard from '../../../../utils/dashboard';
import globalize from '../../../../scripts/globalize';
import LibraryMenu from '../../../../scripts/libraryMenu';
import ButtonElement from '../../../../elements/ButtonElement';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
import InputElement from '../../../../elements/InputElement';
import LinkEditUserPreferences from '../../../../components/dashboard/users/LinkEditUserPreferences';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import { getParameterByName } from '../../../../utils/url';
import SelectElement from '../../../../elements/SelectElement';
import Page from '../../../../components/Page';
type ResetProvider = AuthProvider & { type ResetProvider = AuthProvider & {
checkedAttribute: string checkedAttribute: string
} };
type AuthProvider = { type AuthProvider = {
Name?: string; Name?: string;
Id?: string; Id?: string;
};
const getCheckedElementDataIds = (elements: NodeListOf<Element>) => (
Array.prototype.filter.call(elements, e => e.checked)
.map(e => e.getAttribute('data-id'))
);
function onSaveComplete() {
Dashboard.navigate('userprofiles.html')
.catch(err => {
console.error('[useredit] failed to navigate to user profile', err);
});
loading.hide();
toast(globalize.translate('SettingsSaved'));
} }
const UserEdit: FunctionComponent = () => { const UserEdit: FunctionComponent = () => {
@ -56,7 +71,7 @@ const UserEdit: FunctionComponent = () => {
} }
const fldSelectLoginProvider = page.querySelector('.fldSelectLoginProvider') as HTMLDivElement; const fldSelectLoginProvider = page.querySelector('.fldSelectLoginProvider') as HTMLDivElement;
providers.length > 1 ? fldSelectLoginProvider.classList.remove('hide') : fldSelectLoginProvider.classList.add('hide'); fldSelectLoginProvider.classList.toggle('hide', providers.length <= 1);
setAuthProviders(providers); setAuthProviders(providers);
@ -73,7 +88,7 @@ const UserEdit: FunctionComponent = () => {
} }
const fldSelectPasswordResetProvider = page.querySelector('.fldSelectPasswordResetProvider') as HTMLDivElement; const fldSelectPasswordResetProvider = page.querySelector('.fldSelectPasswordResetProvider') as HTMLDivElement;
providers.length > 1 ? fldSelectPasswordResetProvider.classList.remove('hide') : fldSelectPasswordResetProvider.classList.add('hide'); fldSelectPasswordResetProvider.classList.toggle('hide', providers.length <= 1);
setPasswordResetProviders(providers); setPasswordResetProviders(providers);
@ -121,6 +136,8 @@ const UserEdit: FunctionComponent = () => {
const chkEnableDeleteAllFolders = page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement; const chkEnableDeleteAllFolders = page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement;
chkEnableDeleteAllFolders.checked = user.Policy.EnableContentDeletion; chkEnableDeleteAllFolders.checked = user.Policy.EnableContentDeletion;
triggerChange(chkEnableDeleteAllFolders); triggerChange(chkEnableDeleteAllFolders);
}).catch(err => {
console.error('[useredit] failed to fetch channels', err);
}); });
}, []); }, []);
@ -134,18 +151,24 @@ const UserEdit: FunctionComponent = () => {
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/Providers')).then(function (providers) { window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/Providers')).then(function (providers) {
loadAuthProviders(user, providers); loadAuthProviders(user, providers);
}).catch(err => {
console.error('[useredit] failed to fetch auth providers', err);
}); });
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/PasswordResetProviders')).then(function (providers) { window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/PasswordResetProviders')).then(function (providers) {
loadPasswordResetProviders(user, providers); loadPasswordResetProviders(user, providers);
}).catch(err => {
console.error('[useredit] failed to fetch password reset providers', err);
}); });
window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', { window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', {
IsHidden: false IsHidden: false
})).then(function (folders) { })).then(function (folders) {
loadDeleteFolders(user, folders.Items); loadDeleteFolders(user, folders.Items);
}).catch(err => {
console.error('[useredit] failed to fetch media folders', err);
}); });
const disabledUserBanner = page.querySelector('.disabledUserBanner') as HTMLDivElement; const disabledUserBanner = page.querySelector('.disabledUserBanner') as HTMLDivElement;
user.Policy.IsDisabled ? disabledUserBanner.classList.remove('hide') : disabledUserBanner.classList.add('hide'); disabledUserBanner.classList.toggle('hide', !user.Policy.IsDisabled);
const txtUserName = page.querySelector('#txtUserName') as HTMLInputElement; const txtUserName = page.querySelector('#txtUserName') as HTMLInputElement;
txtUserName.disabled = false; txtUserName.disabled = false;
@ -159,6 +182,7 @@ const UserEdit: FunctionComponent = () => {
(page.querySelector('.chkIsAdmin') as HTMLInputElement).checked = user.Policy.IsAdministrator; (page.querySelector('.chkIsAdmin') as HTMLInputElement).checked = user.Policy.IsAdministrator;
(page.querySelector('.chkDisabled') as HTMLInputElement).checked = user.Policy.IsDisabled; (page.querySelector('.chkDisabled') as HTMLInputElement).checked = user.Policy.IsDisabled;
(page.querySelector('.chkIsHidden') as HTMLInputElement).checked = user.Policy.IsHidden; (page.querySelector('.chkIsHidden') as HTMLInputElement).checked = user.Policy.IsHidden;
(page.querySelector('.chkEnableCollectionManagement') as HTMLInputElement).checked = user.Policy.EnableCollectionManagement;
(page.querySelector('.chkRemoteControlSharedDevices') as HTMLInputElement).checked = user.Policy.EnableSharedDeviceControl; (page.querySelector('.chkRemoteControlSharedDevices') as HTMLInputElement).checked = user.Policy.EnableSharedDeviceControl;
(page.querySelector('.chkEnableRemoteControlOtherUsers') as HTMLInputElement).checked = user.Policy.EnableRemoteControlOfOtherUsers; (page.querySelector('.chkEnableRemoteControlOtherUsers') as HTMLInputElement).checked = user.Policy.EnableRemoteControlOfOtherUsers;
(page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked = user.Policy.EnableContentDownloading; (page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked = user.Policy.EnableContentDownloading;
@ -171,7 +195,7 @@ const UserEdit: FunctionComponent = () => {
(page.querySelector('.chkForceRemoteSourceTranscoding') as HTMLInputElement).checked = user.Policy.ForceRemoteSourceTranscoding; (page.querySelector('.chkForceRemoteSourceTranscoding') as HTMLInputElement).checked = user.Policy.ForceRemoteSourceTranscoding;
(page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked = user.Policy.EnableRemoteAccess == null || user.Policy.EnableRemoteAccess; (page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked = user.Policy.EnableRemoteAccess == null || user.Policy.EnableRemoteAccess;
(page.querySelector('#txtRemoteClientBitrateLimit') as HTMLInputElement).value = user.Policy.RemoteClientBitrateLimit > 0 ? (page.querySelector('#txtRemoteClientBitrateLimit') as HTMLInputElement).value = user.Policy.RemoteClientBitrateLimit > 0 ?
(user.Policy.RemoteClientBitrateLimit / 1e6).toLocaleString(undefined, {maximumFractionDigits: 6}) : ''; (user.Policy.RemoteClientBitrateLimit / 1e6).toLocaleString(undefined, { maximumFractionDigits: 6 }) : '';
(page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value = user.Policy.LoginAttemptsBeforeLockout || '0'; (page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value = user.Policy.LoginAttemptsBeforeLockout || '0';
(page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value = user.Policy.MaxActiveSessions || '0'; (page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value = user.Policy.MaxActiveSessions || '0';
if (window.ApiClient.isMinServerVersion('10.6.0')) { if (window.ApiClient.isMinServerVersion('10.6.0')) {
@ -184,6 +208,8 @@ const UserEdit: FunctionComponent = () => {
loading.show(); loading.show();
getUser().then(function (user) { getUser().then(function (user) {
loadUser(user); loadUser(user);
}).catch(err => {
console.error('[useredit] failed to load data', err);
}); });
}, [loadUser]); }, [loadUser]);
@ -197,19 +223,9 @@ const UserEdit: FunctionComponent = () => {
loadData(); loadData();
function onSaveComplete() {
Dashboard.navigate('userprofiles.html');
loading.hide();
toast(globalize.translate('SettingsSaved'));
}
const saveUser = (user: UserDto) => { const saveUser = (user: UserDto) => {
if (!user.Id) { if (!user.Id || !user.Policy) {
throw new Error('Unexpected null user.Id'); throw new Error('Unexpected null user id or policy');
}
if (!user.Policy) {
throw new Error('Unexpected null user.Policy');
} }
user.Name = (page.querySelector('#txtUserName') as HTMLInputElement).value; user.Name = (page.querySelector('#txtUserName') as HTMLInputElement).value;
@ -224,27 +240,25 @@ const UserEdit: FunctionComponent = () => {
user.Policy.EnableAudioPlaybackTranscoding = (page.querySelector('.chkEnableAudioPlaybackTranscoding') as HTMLInputElement).checked; user.Policy.EnableAudioPlaybackTranscoding = (page.querySelector('.chkEnableAudioPlaybackTranscoding') as HTMLInputElement).checked;
user.Policy.EnableVideoPlaybackTranscoding = (page.querySelector('.chkEnableVideoPlaybackTranscoding') as HTMLInputElement).checked; user.Policy.EnableVideoPlaybackTranscoding = (page.querySelector('.chkEnableVideoPlaybackTranscoding') as HTMLInputElement).checked;
user.Policy.EnablePlaybackRemuxing = (page.querySelector('.chkEnableVideoPlaybackRemuxing') as HTMLInputElement).checked; user.Policy.EnablePlaybackRemuxing = (page.querySelector('.chkEnableVideoPlaybackRemuxing') as HTMLInputElement).checked;
user.Policy.EnableCollectionManagement = (page.querySelector('.chkEnableCollectionManagement') as HTMLInputElement).checked;
user.Policy.ForceRemoteSourceTranscoding = (page.querySelector('.chkForceRemoteSourceTranscoding') as HTMLInputElement).checked; user.Policy.ForceRemoteSourceTranscoding = (page.querySelector('.chkForceRemoteSourceTranscoding') as HTMLInputElement).checked;
user.Policy.EnableContentDownloading = (page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked; user.Policy.EnableContentDownloading = (page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked;
user.Policy.EnableRemoteAccess = (page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked; user.Policy.EnableRemoteAccess = (page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked;
user.Policy.RemoteClientBitrateLimit = Math.floor(1e6 * parseFloat((page.querySelector('#txtRemoteClientBitrateLimit') as HTMLInputElement).value || '0')); user.Policy.RemoteClientBitrateLimit = Math.floor(1e6 * parseFloat((page.querySelector('#txtRemoteClientBitrateLimit') as HTMLInputElement).value || '0'));
user.Policy.LoginAttemptsBeforeLockout = parseInt((page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value || '0'); user.Policy.LoginAttemptsBeforeLockout = parseInt((page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value || '0', 10);
user.Policy.MaxActiveSessions = parseInt((page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value || '0'); user.Policy.MaxActiveSessions = parseInt((page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value || '0', 10);
user.Policy.AuthenticationProviderId = (page.querySelector('#selectLoginProvider') as HTMLSelectElement).value; user.Policy.AuthenticationProviderId = (page.querySelector('#selectLoginProvider') as HTMLSelectElement).value;
user.Policy.PasswordResetProviderId = (page.querySelector('#selectPasswordResetProvider') as HTMLSelectElement).value; user.Policy.PasswordResetProviderId = (page.querySelector('#selectPasswordResetProvider') as HTMLSelectElement).value;
user.Policy.EnableContentDeletion = (page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).checked; user.Policy.EnableContentDeletion = (page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).checked;
user.Policy.EnableContentDeletionFromFolders = user.Policy.EnableContentDeletion ? [] : Array.prototype.filter.call(page.querySelectorAll('.chkFolder'), function (c) { user.Policy.EnableContentDeletionFromFolders = user.Policy.EnableContentDeletion ? [] : getCheckedElementDataIds(page.querySelectorAll('.chkFolder'));
return c.checked; user.Policy.SyncPlayAccess = (page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value as SyncPlayUserAccessType;
}).map(function (c) {
return c.getAttribute('data-id'); window.ApiClient.updateUser(user).then(() => (
}); window.ApiClient.updateUserPolicy(user.Id || '', user.Policy || {})
if (window.ApiClient.isMinServerVersion('10.6.0')) { )).then(() => {
user.Policy.SyncPlayAccess = (page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value as SyncPlayUserAccessType; onSaveComplete();
} }).catch(err => {
window.ApiClient.updateUser(user).then(function () { console.error('[useredit] failed to update user', err);
window.ApiClient.updateUserPolicy(user.Id || '', user.Policy || {}).then(function () {
onSaveComplete();
});
}); });
}; };
@ -252,6 +266,8 @@ const UserEdit: FunctionComponent = () => {
loading.show(); loading.show();
getUser().then(function (result) { getUser().then(function (result) {
saveUser(result); saveUser(result);
}).catch(err => {
console.error('[useredit] failed to fetch user', err);
}); });
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -259,16 +275,13 @@ const UserEdit: FunctionComponent = () => {
}; };
(page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) { (page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
if (this.checked) { (page.querySelector('.deleteAccess') as HTMLDivElement).classList.toggle('hide', this.checked);
(page.querySelector('.deleteAccess') as HTMLDivElement).classList.add('hide');
} else {
(page.querySelector('.deleteAccess') as HTMLDivElement).classList.remove('hide');
}
}); });
window.ApiClient.getNamedConfiguration('network').then(function (config) { window.ApiClient.getNamedConfiguration('network').then(function (config) {
const fldRemoteAccess = page.querySelector('.fldRemoteAccess') as HTMLDivElement; (page.querySelector('.fldRemoteAccess') as HTMLDivElement).classList.toggle('hide', !config.EnableRemoteAccess);
config.EnableRemoteAccess ? fldRemoteAccess.classList.remove('hide') : fldRemoteAccess.classList.add('hide'); }).catch(err => {
console.error('[useredit] failed to load network config', err);
}); });
(page.querySelector('.editUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit); (page.querySelector('.editUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit);
@ -312,7 +325,7 @@ const UserEdit: FunctionComponent = () => {
<SectionTabs activeTab='useredit'/> <SectionTabs activeTab='useredit'/>
<div <div
className='lnkEditUserPreferencesContainer' className='lnkEditUserPreferencesContainer'
style={{paddingBottom: '1em'}} style={{ paddingBottom: '1em' }}
> >
<LinkEditUserPreferences <LinkEditUserPreferences
className= 'lnkEditUserPreferences button-link' className= 'lnkEditUserPreferences button-link'
@ -325,7 +338,7 @@ const UserEdit: FunctionComponent = () => {
<div> <div>
{globalize.translate('HeaderThisUserIsCurrentlyDisabled')} {globalize.translate('HeaderThisUserIsCurrentlyDisabled')}
</div> </div>
<div style={{marginTop: 5}}> <div style={{ marginTop: 5 }}>
{globalize.translate('MessageReenableUser')} {globalize.translate('MessageReenableUser')}
</div> </div>
</div> </div>
@ -375,11 +388,16 @@ const UserEdit: FunctionComponent = () => {
className='chkIsAdmin' className='chkIsAdmin'
title='OptionAllowUserToManageServer' title='OptionAllowUserToManageServer'
/> />
<CheckBoxElement
labelClassName='checkboxContainer'
className='chkEnableCollectionManagement'
title='AllowCollectionManagement'
/>
<div id='featureAccessFields' className='verticalSection'> <div id='featureAccessFields' className='verticalSection'>
<h2 className='paperListLabel'> <h2 className='paperListLabel'>
{globalize.translate('HeaderFeatureAccess')} {globalize.translate('HeaderFeatureAccess')}
</h2> </h2>
<div className='checkboxList paperList' style={{padding: '.5em 1em'}}> <div className='checkboxList paperList' style={{ padding: '.5em 1em' }}>
<CheckBoxElement <CheckBoxElement
className='chkEnableLiveTvAccess' className='chkEnableLiveTvAccess'
title='OptionAllowBrowsingLiveTv' title='OptionAllowBrowsingLiveTv'
@ -394,7 +412,7 @@ const UserEdit: FunctionComponent = () => {
<h2 className='paperListLabel'> <h2 className='paperListLabel'>
{globalize.translate('HeaderPlayback')} {globalize.translate('HeaderPlayback')}
</h2> </h2>
<div className='checkboxList paperList' style={{padding: '.5em 1em'}}> <div className='checkboxList paperList' style={{ padding: '.5em 1em' }}>
<CheckBoxElement <CheckBoxElement
className='chkEnableMediaPlayback' className='chkEnableMediaPlayback'
title='OptionAllowMediaPlayback' title='OptionAllowMediaPlayback'
@ -451,7 +469,7 @@ const UserEdit: FunctionComponent = () => {
</div> </div>
</div> </div>
<div className='verticalSection'> <div className='verticalSection'>
<h2 className='checkboxListLabel' style={{marginBottom: '1em'}}> <h2 className='checkboxListLabel' style={{ marginBottom: '1em' }}>
{globalize.translate('HeaderAllowMediaDeletionFrom')} {globalize.translate('HeaderAllowMediaDeletionFrom')}
</h2> </h2>
<div className='checkboxList paperList checkboxList-paperList'> <div className='checkboxList paperList checkboxList-paperList'>
@ -477,7 +495,7 @@ const UserEdit: FunctionComponent = () => {
<h2 className='checkboxListLabel'> <h2 className='checkboxListLabel'>
{globalize.translate('HeaderRemoteControl')} {globalize.translate('HeaderRemoteControl')}
</h2> </h2>
<div className='checkboxList paperList' style={{padding: '.5em 1em'}}> <div className='checkboxList paperList' style={{ padding: '.5em 1em' }}>
<CheckBoxElement <CheckBoxElement
className='chkEnableRemoteControlOtherUsers' className='chkEnableRemoteControlOtherUsers'
title='OptionAllowRemoteControlOthers' title='OptionAllowRemoteControlOthers'

View file

@ -1,24 +1,24 @@
import type { UserDto } from '@jellyfin/sdk/lib/generated-client'; import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react'; import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
import loading from '../../components/loading/loading'; import loading from '../../../../components/loading/loading';
import libraryMenu from '../../scripts/libraryMenu'; import libraryMenu from '../../../../scripts/libraryMenu';
import globalize from '../../scripts/globalize'; import globalize from '../../../../scripts/globalize';
import toast from '../../components/toast/toast'; import toast from '../../../../components/toast/toast';
import SectionTabs from '../../components/dashboard/users/SectionTabs'; import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
import ButtonElement from '../../elements/ButtonElement'; import ButtonElement from '../../../../elements/ButtonElement';
import { getParameterByName } from '../../utils/url'; import { getParameterByName } from '../../../../utils/url';
import SectionTitleContainer from '../../elements/SectionTitleContainer'; import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import AccessContainer from '../../components/dashboard/users/AccessContainer'; import AccessContainer from '../../../../components/dashboard/users/AccessContainer';
import CheckBoxElement from '../../elements/CheckBoxElement'; import CheckBoxElement from '../../../../elements/CheckBoxElement';
import Page from '../../components/Page'; import Page from '../../../../components/Page';
type ItemsArr = { type ItemsArr = {
Name?: string; Name?: string;
Id?: string; Id?: string;
AppName?: string; AppName?: string;
checkedAttribute?: string checkedAttribute?: string
} };
const UserLibraryAccess: FunctionComponent = () => { const UserLibraryAccess: FunctionComponent = () => {
const [ userName, setUserName ] = useState(''); const [ userName, setUserName ] = useState('');
@ -148,6 +148,8 @@ const UserLibraryAccess: FunctionComponent = () => {
const promise4 = window.ApiClient.getJSON(window.ApiClient.getUrl('Devices')); const promise4 = window.ApiClient.getJSON(window.ApiClient.getUrl('Devices'));
Promise.all([promise1, promise2, promise3, promise4]).then(function (responses) { Promise.all([promise1, promise2, promise3, promise4]).then(function (responses) {
loadUser(responses[0], responses[1].Items, responses[2].Items, responses[3].Items); loadUser(responses[0], responses[1].Items, responses[2].Items, responses[3].Items);
}).catch(err => {
console.error('[userlibraryaccess] failed to load data', err);
}); });
}, [loadUser]); }, [loadUser]);
@ -166,6 +168,8 @@ const UserLibraryAccess: FunctionComponent = () => {
const userId = getParameterByName('userId'); const userId = getParameterByName('userId');
window.ApiClient.getUser(userId).then(function (result) { window.ApiClient.getUser(userId).then(function (result) {
saveUser(result); saveUser(result);
}).catch(err => {
console.error('[userlibraryaccess] failed to fetch user', err);
}); });
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -203,6 +207,8 @@ const UserLibraryAccess: FunctionComponent = () => {
user.Policy.BlockedMediaFolders = null; user.Policy.BlockedMediaFolders = null;
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () { window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
onSaveComplete(); onSaveComplete();
}).catch(err => {
console.error('[userlibraryaccess] failed to update user policy', err);
}); });
}; };

View file

@ -1,25 +1,25 @@
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react'; import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
import Dashboard from '../../utils/dashboard'; import Dashboard from '../../../../utils/dashboard';
import globalize from '../../scripts/globalize'; import globalize from '../../../../scripts/globalize';
import loading from '../../components/loading/loading'; import loading from '../../../../components/loading/loading';
import toast from '../../components/toast/toast'; import toast from '../../../../components/toast/toast';
import SectionTitleContainer from '../../elements/SectionTitleContainer'; import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import InputElement from '../../elements/InputElement'; import InputElement from '../../../../elements/InputElement';
import ButtonElement from '../../elements/ButtonElement'; import ButtonElement from '../../../../elements/ButtonElement';
import AccessContainer from '../../components/dashboard/users/AccessContainer'; import AccessContainer from '../../../../components/dashboard/users/AccessContainer';
import CheckBoxElement from '../../elements/CheckBoxElement'; import CheckBoxElement from '../../../../elements/CheckBoxElement';
import Page from '../../components/Page'; import Page from '../../../../components/Page';
type userInput = { type userInput = {
Name?: string; Name?: string;
Password?: string; Password?: string;
} };
type ItemsArr = { type ItemsArr = {
Name?: string; Name?: string;
Id?: string; Id?: string;
} };
const UserNew: FunctionComponent = () => { const UserNew: FunctionComponent = () => {
const [ channelsItems, setChannelsItems ] = useState<ItemsArr[]>([]); const [ channelsItems, setChannelsItems ] = useState<ItemsArr[]>([]);
@ -93,6 +93,8 @@ const UserNew: FunctionComponent = () => {
loadMediaFolders(responses[0].Items); loadMediaFolders(responses[0].Items);
loadChannels(responses[1].Items); loadChannels(responses[1].Items);
loading.hide(); loading.hide();
}).catch(err => {
console.error('[usernew] failed to load data', err);
}); });
}, [loadChannels, loadMediaFolders]); }, [loadChannels, loadMediaFolders]);
@ -111,12 +113,8 @@ const UserNew: FunctionComponent = () => {
userInput.Name = (page.querySelector('#txtUsername') as HTMLInputElement).value; userInput.Name = (page.querySelector('#txtUsername') as HTMLInputElement).value;
userInput.Password = (page.querySelector('#txtPassword') as HTMLInputElement).value; userInput.Password = (page.querySelector('#txtPassword') as HTMLInputElement).value;
window.ApiClient.createUser(userInput).then(function (user) { window.ApiClient.createUser(userInput).then(function (user) {
if (!user.Id) { if (!user.Id || !user.Policy) {
throw new Error('Unexpected null user.Id'); throw new Error('Unexpected null user id or policy');
}
if (!user.Policy) {
throw new Error('Unexpected null user.Policy');
} }
user.Policy.EnableAllFolders = (page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked; user.Policy.EnableAllFolders = (page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked;
@ -142,7 +140,12 @@ const UserNew: FunctionComponent = () => {
} }
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () { window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
Dashboard.navigate('useredit.html?userId=' + user.Id); Dashboard.navigate('useredit.html?userId=' + user.Id)
.catch(err => {
console.error('[usernew] failed to navigate to edit user page', err);
});
}).catch(err => {
console.error('[usernew] failed to update user policy', err);
}); });
}, function () { }, function () {
toast(globalize.translate('ErrorDefault')); toast(globalize.translate('ErrorDefault'));

View file

@ -1,26 +1,27 @@
import type { AccessSchedule, ParentalRating, UserDto } from '@jellyfin/sdk/lib/generated-client'; import type { AccessSchedule, ParentalRating, UserDto } from '@jellyfin/sdk/lib/generated-client';
import { DynamicDayOfWeek } from '@jellyfin/sdk/lib/generated-client/models/dynamic-day-of-week'; import { DynamicDayOfWeek } from '@jellyfin/sdk/lib/generated-client/models/dynamic-day-of-week';
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react'; import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
import globalize from '../../scripts/globalize';
import LibraryMenu from '../../scripts/libraryMenu';
import AccessScheduleList from '../../components/dashboard/users/AccessScheduleList';
import BlockedTagList from '../../components/dashboard/users/BlockedTagList';
import ButtonElement from '../../elements/ButtonElement';
import SectionTitleContainer from '../../elements/SectionTitleContainer';
import SectionTabs from '../../components/dashboard/users/SectionTabs';
import loading from '../../components/loading/loading';
import toast from '../../components/toast/toast';
import { getParameterByName } from '../../utils/url';
import CheckBoxElement from '../../elements/CheckBoxElement';
import escapeHTML from 'escape-html'; import escapeHTML from 'escape-html';
import SelectElement from '../../elements/SelectElement';
import Page from '../../components/Page'; import globalize from '../../../../scripts/globalize';
import LibraryMenu from '../../../../scripts/libraryMenu';
import AccessScheduleList from '../../../../components/dashboard/users/AccessScheduleList';
import BlockedTagList from '../../../../components/dashboard/users/BlockedTagList';
import ButtonElement from '../../../../elements/ButtonElement';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import { getParameterByName } from '../../../../utils/url';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
import SelectElement from '../../../../elements/SelectElement';
import Page from '../../../../components/Page';
type UnratedItem = { type UnratedItem = {
name: string; name: string;
value: string; value: string;
checkedAttribute: string checkedAttribute: string
} };
const UserParentalControl: FunctionComponent = () => { const UserParentalControl: FunctionComponent = () => {
const [ userName, setUserName ] = useState(''); const [ userName, setUserName ] = useState('');
@ -142,7 +143,7 @@ const UserParentalControl: FunctionComponent = () => {
for (const btnDelete of accessScheduleList.querySelectorAll('.btnDelete')) { for (const btnDelete of accessScheduleList.querySelectorAll('.btnDelete')) {
btnDelete.addEventListener('click', function () { btnDelete.addEventListener('click', function () {
const index = parseInt(btnDelete.getAttribute('data-index') || '0', 10); const index = parseInt(btnDelete.getAttribute('data-index') ?? '0', 10);
schedules.splice(index, 1); schedules.splice(index, 1);
const newindex = schedules.filter(function (i: number) { const newindex = schedules.filter(function (i: number) {
return i != index; return i != index;
@ -196,6 +197,8 @@ const UserParentalControl: FunctionComponent = () => {
const promise2 = window.ApiClient.getParentalRatings(); const promise2 = window.ApiClient.getParentalRatings();
Promise.all([promise1, promise2]).then(function (responses) { Promise.all([promise1, promise2]).then(function (responses) {
loadUser(responses[0], responses[1]); loadUser(responses[0], responses[1]);
}).catch(err => {
console.error('[userparentalcontrol] failed to load data', err);
}); });
}, [loadUser]); }, [loadUser]);
@ -215,15 +218,11 @@ const UserParentalControl: FunctionComponent = () => {
}; };
const saveUser = (user: UserDto) => { const saveUser = (user: UserDto) => {
if (!user.Id) { if (!user.Id || !user.Policy) {
throw new Error('Unexpected null user.Id'); throw new Error('Unexpected null user id or policy');
} }
if (!user.Policy) { const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
throw new Error('Unexpected null user.Policy');
}
const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value || '0', 10);
user.Policy.MaxParentalRating = Number.isNaN(parentalRating) ? null : parentalRating; user.Policy.MaxParentalRating = Number.isNaN(parentalRating) ? null : parentalRating;
user.Policy.BlockUnratedItems = Array.prototype.filter.call(page.querySelectorAll('.chkUnratedItem'), function (i) { user.Policy.BlockUnratedItems = Array.prototype.filter.call(page.querySelectorAll('.chkUnratedItem'), function (i) {
return i.checked; return i.checked;
@ -234,12 +233,14 @@ const UserParentalControl: FunctionComponent = () => {
user.Policy.BlockedTags = getBlockedTagsFromPage(); user.Policy.BlockedTags = getBlockedTagsFromPage();
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () { window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
onSaveComplete(); onSaveComplete();
}).catch(err => {
console.error('[userparentalcontrol] failed to update user policy', err);
}); });
}; };
const showSchedulePopup = (schedule: AccessSchedule, index: number) => { const showSchedulePopup = (schedule: AccessSchedule, index: number) => {
schedule = schedule || {}; schedule = schedule || {};
import('../../components/accessSchedule/accessSchedule').then(({default: accessschedule}) => { import('../../../../components/accessSchedule/accessSchedule').then(({ default: accessschedule }) => {
accessschedule.show({ accessschedule.show({
schedule: schedule schedule: schedule
}).then(function (updatedSchedule) { }).then(function (updatedSchedule) {
@ -251,7 +252,11 @@ const UserParentalControl: FunctionComponent = () => {
schedules[index] = updatedSchedule; schedules[index] = updatedSchedule;
renderAccessSchedule(schedules); renderAccessSchedule(schedules);
}).catch(() => {
// access schedule closed
}); });
}).catch(err => {
console.error('[userparentalcontrol] failed to load access schedule', err);
}); });
}; };
@ -272,7 +277,7 @@ const UserParentalControl: FunctionComponent = () => {
}; };
const showBlockedTagPopup = () => { const showBlockedTagPopup = () => {
import('../../components/prompt/prompt').then(({default: prompt}) => { import('../../../../components/prompt/prompt').then(({ default: prompt }) => {
prompt({ prompt({
label: globalize.translate('LabelTag') label: globalize.translate('LabelTag')
}).then(function (value) { }).then(function (value) {
@ -282,7 +287,11 @@ const UserParentalControl: FunctionComponent = () => {
tags.push(value); tags.push(value);
loadBlockedTags(tags); loadBlockedTags(tags);
} }
}).catch(() => {
// prompt closed
}); });
}).catch(err => {
console.error('[userparentalcontrol] failed to load prompt', err);
}); });
}; };
@ -291,6 +300,8 @@ const UserParentalControl: FunctionComponent = () => {
const userId = getParameterByName('userId'); const userId = getParameterByName('userId');
window.ApiClient.getUser(userId).then(function (result) { window.ApiClient.getUser(userId).then(function (result) {
saveUser(result); saveUser(result);
}).catch(err => {
console.error('[userparentalcontrol] failed to fetch user', err);
}); });
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -367,7 +378,7 @@ const UserParentalControl: FunctionComponent = () => {
</div> </div>
</div> </div>
<br /> <br />
<div className='verticalSection' style={{marginBottom: '2em'}}> <div className='verticalSection' style={{ marginBottom: '2em' }}>
<SectionTitleContainer <SectionTitleContainer
SectionClassName='detailSectionHeader' SectionClassName='detailSectionHeader'
title={globalize.translate('LabelBlockContentWithTags')} title={globalize.translate('LabelBlockContentWithTags')}
@ -378,16 +389,16 @@ const UserParentalControl: FunctionComponent = () => {
btnIcon='add' btnIcon='add'
isLinkVisible={false} isLinkVisible={false}
/> />
<div className='blockedTags' style={{marginTop: '.5em'}}> <div className='blockedTags' style={{ marginTop: '.5em' }}>
{blockedTags.map((tag, index) => { {blockedTags.map(tag => {
return <BlockedTagList return <BlockedTagList
key={index} key={tag}
tag={tag} tag={tag}
/>; />;
})} })}
</div> </div>
</div> </div>
<div className='accessScheduleSection verticalSection' style={{marginBottom: '2em'}}> <div className='accessScheduleSection verticalSection' style={{ marginBottom: '2em' }}>
<SectionTitleContainer <SectionTitleContainer
title={globalize.translate('HeaderAccessSchedule')} title={globalize.translate('HeaderAccessSchedule')}
isBtnVisible={true} isBtnVisible={true}
@ -401,7 +412,7 @@ const UserParentalControl: FunctionComponent = () => {
<div className='accessScheduleList paperList'> <div className='accessScheduleList paperList'>
{accessSchedules.map((accessSchedule, index) => { {accessSchedules.map((accessSchedule, index) => {
return <AccessScheduleList return <AccessScheduleList
key={index} key={accessSchedule.Id}
index={index} index={index}
Id={accessSchedule.Id} Id={accessSchedule.Id}
DayOfWeek={accessSchedule.DayOfWeek} DayOfWeek={accessSchedule.DayOfWeek}

View file

@ -1,10 +1,11 @@
import React, { FunctionComponent, useCallback, useEffect, useState } from 'react'; import React, { FunctionComponent, useCallback, useEffect, useState } from 'react';
import SectionTabs from '../../components/dashboard/users/SectionTabs';
import UserPasswordForm from '../../components/dashboard/users/UserPasswordForm'; import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
import { getParameterByName } from '../../utils/url'; import UserPasswordForm from '../../../../components/dashboard/users/UserPasswordForm';
import SectionTitleContainer from '../../elements/SectionTitleContainer'; import { getParameterByName } from '../../../../utils/url';
import Page from '../../components/Page'; import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import loading from '../../components/loading/loading'; import Page from '../../../../components/Page';
import loading from '../../../../components/loading/loading';
const UserPassword: FunctionComponent = () => { const UserPassword: FunctionComponent = () => {
const userId = getParameterByName('userId'); const userId = getParameterByName('userId');
@ -18,6 +19,8 @@ const UserPassword: FunctionComponent = () => {
} }
setUserName(user.Name); setUserName(user.Name);
loading.hide(); loading.hide();
}).catch(err => {
console.error('[userpassword] failed to fetch user', err);
}); });
}, [userId]); }, [userId]);
useEffect(() => { useEffect(() => {

View file

@ -2,17 +2,17 @@ import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type'; import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
import React, { FunctionComponent, useEffect, useState, useRef, useCallback } from 'react'; import React, { FunctionComponent, useEffect, useState, useRef, useCallback } from 'react';
import Dashboard from '../../utils/dashboard'; import Dashboard from '../../../../utils/dashboard';
import globalize from '../../scripts/globalize'; import globalize from '../../../../scripts/globalize';
import LibraryMenu from '../../scripts/libraryMenu'; import LibraryMenu from '../../../../scripts/libraryMenu';
import { appHost } from '../../components/apphost'; import { appHost } from '../../../../components/apphost';
import confirm from '../../components/confirm/confirm'; import confirm from '../../../../components/confirm/confirm';
import ButtonElement from '../../elements/ButtonElement'; import ButtonElement from '../../../../elements/ButtonElement';
import UserPasswordForm from '../../components/dashboard/users/UserPasswordForm'; import UserPasswordForm from '../../../../components/dashboard/users/UserPasswordForm';
import loading from '../../components/loading/loading'; import loading from '../../../../components/loading/loading';
import toast from '../../components/toast/toast'; import toast from '../../../../components/toast/toast';
import { getParameterByName } from '../../utils/url'; import { getParameterByName } from '../../../../utils/url';
import Page from '../../components/Page'; import Page from '../../../../components/Page';
const UserProfile: FunctionComponent = () => { const UserProfile: FunctionComponent = () => {
const userId = getParameterByName('userId'); const userId = getParameterByName('userId');
@ -30,12 +30,8 @@ const UserProfile: FunctionComponent = () => {
loading.show(); loading.show();
window.ApiClient.getUser(userId).then(function (user) { window.ApiClient.getUser(userId).then(function (user) {
if (!user.Name) { if (!user.Name || !user.Id) {
throw new Error('Unexpected null user.Name'); throw new Error('Unexpected null user name or id');
}
if (!user.Id) {
throw new Error('Unexpected null user.Id');
} }
setUserName(user.Name); setUserName(user.Name);
@ -63,8 +59,12 @@ const UserProfile: FunctionComponent = () => {
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).classList.add('hide'); (page.querySelector('#btnDeleteImage') as HTMLButtonElement).classList.add('hide');
(page.querySelector('#btnAddImage') as HTMLButtonElement).classList.remove('hide'); (page.querySelector('#btnAddImage') as HTMLButtonElement).classList.remove('hide');
} }
}).catch(err => {
console.error('[userprofile] failed to get current user', err);
}); });
loading.hide(); loading.hide();
}).catch(err => {
console.error('[userprofile] failed to load data', err);
}); });
}, [userId]); }, [userId]);
@ -102,7 +102,7 @@ const UserProfile: FunctionComponent = () => {
const target = evt.target as HTMLInputElement; const target = evt.target as HTMLInputElement;
const file = (target.files as FileList)[0]; const file = (target.files as FileList)[0];
if (!file || !file.type.match('image.*')) { if (!file || !/image.*/.exec(file.type)) {
return false; return false;
} }
@ -114,6 +114,8 @@ const UserProfile: FunctionComponent = () => {
window.ApiClient.uploadUserImage(userId, ImageType.Primary, file).then(function () { window.ApiClient.uploadUserImage(userId, ImageType.Primary, file).then(function () {
loading.hide(); loading.hide();
reloadUser(); reloadUser();
}).catch(err => {
console.error('[userprofile] failed to upload image', err);
}); });
}; };
@ -129,7 +131,11 @@ const UserProfile: FunctionComponent = () => {
window.ApiClient.deleteUserImage(userId, ImageType.Primary).then(function () { window.ApiClient.deleteUserImage(userId, ImageType.Primary).then(function () {
loading.hide(); loading.hide();
reloadUser(); reloadUser();
}).catch(err => {
console.error('[userprofile] failed to delete image', err);
}); });
}).catch(() => {
// confirm dialog closed
}); });
}); });
@ -153,25 +159,25 @@ const UserProfile: FunctionComponent = () => {
<div ref={element} className='padded-left padded-right padded-bottom-page'> <div ref={element} className='padded-left padded-right padded-bottom-page'>
<div <div
className='readOnlyContent' className='readOnlyContent'
style={{margin: '0 auto', marginBottom: '1.8em', padding: '0 1em', display: 'flex', flexDirection: 'row', alignItems: 'center'}} style={{ margin: '0 auto', marginBottom: '1.8em', padding: '0 1em', display: 'flex', flexDirection: 'row', alignItems: 'center' }}
> >
<div <div
className='imagePlaceHolder' className='imagePlaceHolder'
style={{position: 'relative', display: 'inline-block', maxWidth: 200 }} style={{ position: 'relative', display: 'inline-block', maxWidth: 200 }}
> >
<input <input
id='uploadImage' id='uploadImage'
type='file' type='file'
accept='image/*' accept='image/*'
style={{position: 'absolute', right: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer'}} style={{ position: 'absolute', right: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer' }}
/> />
<div <div
id='image' id='image'
style={{width: 200, height: 200, backgroundRepeat: 'no-repeat', backgroundPosition: 'center', borderRadius: '100%', backgroundSize: 'cover'}} style={{ width: 200, height: 200, backgroundRepeat: 'no-repeat', backgroundPosition: 'center', borderRadius: '100%', backgroundSize: 'cover' }}
/> />
</div> </div>
<div style={{verticalAlign: 'top', margin: '1em 2em', display: 'flex', flexDirection: 'column', alignItems: 'center'}}> <div style={{ verticalAlign: 'top', margin: '1em 2em', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<h2 className='username' style={{margin: 0, fontSize: 'xx-large'}}> <h2 className='username' style={{ margin: 0, fontSize: 'xx-large' }}>
{userName} {userName}
</h2> </h2>
<br /> <br />

View file

@ -1,24 +1,25 @@
import type { UserDto } from '@jellyfin/sdk/lib/generated-client'; import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
import React, {FunctionComponent, useEffect, useState, useRef} from 'react'; import React, { FunctionComponent, useEffect, useState, useRef } from 'react';
import Dashboard from '../../utils/dashboard';
import globalize from '../../scripts/globalize'; import Dashboard from '../../../../utils/dashboard';
import loading from '../../components/loading/loading'; import globalize from '../../../../scripts/globalize';
import dom from '../../scripts/dom'; import loading from '../../../../components/loading/loading';
import confirm from '../../components/confirm/confirm'; import dom from '../../../../scripts/dom';
import UserCardBox from '../../components/dashboard/users/UserCardBox'; import confirm from '../../../../components/confirm/confirm';
import SectionTitleContainer from '../../elements/SectionTitleContainer'; import UserCardBox from '../../../../components/dashboard/users/UserCardBox';
import '../../elements/emby-button/emby-button'; import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import '../../elements/emby-button/paper-icon-button-light'; import '../../../../elements/emby-button/emby-button';
import '../../components/cardbuilder/card.scss'; import '../../../../elements/emby-button/paper-icon-button-light';
import '../../components/indicators/indicators.scss'; import '../../../../components/cardbuilder/card.scss';
import '../../assets/css/flexstyles.scss'; import '../../../../components/indicators/indicators.scss';
import Page from '../../components/Page'; import '../../../../styles/flexstyles.scss';
import Page from '../../../../components/Page';
type MenuEntry = { type MenuEntry = {
name?: string; name?: string;
id?: string; id?: string;
icon?: string; icon?: string;
} };
const UserProfiles: FunctionComponent = () => { const UserProfiles: FunctionComponent = () => {
const [ users, setUsers ] = useState<UserDto[]>([]); const [ users, setUsers ] = useState<UserDto[]>([]);
@ -30,6 +31,8 @@ const UserProfiles: FunctionComponent = () => {
window.ApiClient.getUsers().then(function (result) { window.ApiClient.getUsers().then(function (result) {
setUsers(result); setUsers(result);
loading.hide(); loading.hide();
}).catch(err => {
console.error('[userprofiles] failed to fetch users', err);
}); });
}; };
@ -75,29 +78,42 @@ const UserProfiles: FunctionComponent = () => {
icon: 'delete' icon: 'delete'
}); });
import('../../components/actionSheet/actionSheet').then(({default: actionsheet}) => { import('../../../../components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
actionsheet.show({ actionsheet.show({
items: menuItems, items: menuItems,
positionTo: card, positionTo: card,
callback: function (id: string) { callback: function (id: string) {
switch (id) { switch (id) {
case 'open': case 'open':
Dashboard.navigate('useredit.html?userId=' + userId); Dashboard.navigate('useredit.html?userId=' + userId)
.catch(err => {
console.error('[userprofiles] failed to navigate to user edit page', err);
});
break; break;
case 'access': case 'access':
Dashboard.navigate('userlibraryaccess.html?userId=' + userId); Dashboard.navigate('userlibraryaccess.html?userId=' + userId)
.catch(err => {
console.error('[userprofiles] failed to navigate to user library page', err);
});
break; break;
case 'parentalcontrol': case 'parentalcontrol':
Dashboard.navigate('userparentalcontrol.html?userId=' + userId); Dashboard.navigate('userparentalcontrol.html?userId=' + userId)
.catch(err => {
console.error('[userprofiles] failed to navigate to parental control page', err);
});
break; break;
case 'delete': case 'delete':
deleteUser(userId); deleteUser(userId);
} }
} }
}).catch(() => {
// action sheet closed
}); });
}).catch(err => {
console.error('[userprofiles] failed to load action sheet', err);
}); });
}; };
@ -113,7 +129,11 @@ const UserProfiles: FunctionComponent = () => {
loading.show(); loading.show();
window.ApiClient.deleteUser(id).then(function () { window.ApiClient.deleteUser(id).then(function () {
loadData(); loadData();
}).catch(err => {
console.error('[userprofiles] failed to delete user', err);
}); });
}).catch(() => {
// confirm dialog closed
}); });
}; };
@ -126,7 +146,10 @@ const UserProfiles: FunctionComponent = () => {
}); });
(page.querySelector('#btnAddUser') as HTMLButtonElement).addEventListener('click', function() { (page.querySelector('#btnAddUser') as HTMLButtonElement).addEventListener('click', function() {
Dashboard.navigate('usernew.html'); Dashboard.navigate('usernew.html')
.catch(err => {
console.error('[userprofiles] failed to navigate to new user page', err);
});
}); });
}, []); }, []);

View file

@ -0,0 +1,20 @@
import React, { useEffect } from 'react';
const AppHeader = () => {
useEffect(() => {
// Initialize the UI components after first render
import('../scripts/libraryMenu');
}, []);
return (
<>
<div className='mainDrawer hide'>
<div className='mainDrawer-scrollContainer scrollContainer focuscontainer-y' />
</div>
<div className='skinHeader focuscontainer-x' />
<div className='mainDrawerHandle' />
</>
);
};
export default AppHeader;

View file

@ -0,0 +1,17 @@
import React, { useEffect } from 'react';
const Backdrop = () => {
useEffect(() => {
// Initialize the UI components after first render
import('../scripts/autoBackdrops');
}, []);
return (
<>
<div className='backdropContainer' />
<div className='backgroundContainer' />
</>
);
};
export default Backdrop;

View file

@ -1,9 +1,9 @@
import React, { FunctionComponent, useEffect, useState } from 'react'; import React, { FunctionComponent, useCallback, useEffect, useState } from 'react';
import { Outlet, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import type { ConnectResponse } from 'jellyfin-apiclient'; import type { ConnectResponse } from 'jellyfin-apiclient';
import alert from './alert'; import alert from './alert';
import { appRouter } from './appRouter'; import { appRouter } from './router/appRouter';
import Loading from './loading/LoadingComponent'; import Loading from './loading/LoadingComponent';
import ServerConnections from './ServerConnections'; import ServerConnections from './ServerConnections';
import globalize from '../scripts/globalize'; import globalize from '../scripts/globalize';
@ -31,120 +31,138 @@ const ConnectionRequired: FunctionComponent<ConnectionRequiredProps> = ({
isUserRequired = true isUserRequired = true
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [ isLoading, setIsLoading ] = useState(true); const [ isLoading, setIsLoading ] = useState(true);
useEffect(() => { const bounce = useCallback(async (connectionResponse: ConnectResponse) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any switch (connectionResponse.State) {
const bounce = async (connectionResponse: ConnectResponse) => { case ConnectionState.SignedIn:
switch (connectionResponse.State) { // Already logged in, bounce to the home page
case ConnectionState.SignedIn: console.debug('[ConnectionRequired] already logged in, redirecting to home');
// Already logged in, bounce to the home page navigate(BounceRoutes.Home);
console.debug('[ConnectionRequired] already logged in, redirecting to home'); return;
navigate(BounceRoutes.Home); case ConnectionState.ServerSignIn:
return; // Bounce to the login page
case ConnectionState.ServerSignIn: if (location.pathname === BounceRoutes.Login) {
// Bounce to the login page setIsLoading(false);
} else {
console.debug('[ConnectionRequired] not logged in, redirecting to login page'); console.debug('[ConnectionRequired] not logged in, redirecting to login page');
navigate(BounceRoutes.Login, { navigate(`${BounceRoutes.Login}?serverid=${connectionResponse.ApiClient.serverId()}`);
state: { }
serverid: connectionResponse.ApiClient.serverId() return;
} case ConnectionState.ServerSelection:
// Bounce to select server page
console.debug('[ConnectionRequired] redirecting to select server page');
navigate(BounceRoutes.SelectServer);
return;
case ConnectionState.ServerUpdateNeeded:
// Show update needed message and bounce to select server page
try {
await alert({
text: globalize.translate('ServerUpdateNeeded', 'https://github.com/jellyfin/jellyfin'),
html: globalize.translate('ServerUpdateNeeded', '<a href="https://github.com/jellyfin/jellyfin">https://github.com/jellyfin/jellyfin</a>')
}); });
} catch (ex) {
console.warn('[ConnectionRequired] failed to show alert', ex);
}
console.debug('[ConnectionRequired] server update required, redirecting to select server page');
navigate(BounceRoutes.SelectServer);
return;
}
console.warn('[ConnectionRequired] unhandled connection state', connectionResponse.State);
}, [location.pathname, navigate]);
const handleIncompleteWizard = useCallback(async (firstConnection: ConnectResponse) => {
if (firstConnection.State === ConnectionState.ServerSignIn) {
// Verify the wizard is complete
try {
const infoResponse = await fetch(`${firstConnection.ApiClient.serverAddress()}/System/Info/Public`);
if (!infoResponse.ok) {
throw new Error('Public system info request failed');
}
const systemInfo = await infoResponse.json();
if (!systemInfo?.StartupWizardCompleted) {
// Update the current ApiClient
// TODO: Is there a better place to handle this?
ServerConnections.setLocalApiClient(firstConnection.ApiClient);
// Bounce to the wizard
console.info('[ConnectionRequired] startup wizard is not complete, redirecting there');
navigate(BounceRoutes.StartWizard);
return; return;
case ConnectionState.ServerSelection: }
// Bounce to select server page } catch (ex) {
console.debug('[ConnectionRequired] redirecting to select server page'); console.error('[ConnectionRequired] checking wizard status failed', ex);
navigate(BounceRoutes.SelectServer); return;
return; }
case ConnectionState.ServerUpdateNeeded: }
// Show update needed message and bounce to select server page
try { // Bounce to the correct page in the login flow
await alert({ bounce(firstConnection)
text: globalize.translate('ServerUpdateNeeded', 'https://github.com/jellyfin/jellyfin'), .catch(err => {
html: globalize.translate('ServerUpdateNeeded', '<a href="https://github.com/jellyfin/jellyfin">https://github.com/jellyfin/jellyfin</a>') console.error('[ConnectionRequired] failed to bounce', err);
});
}, [bounce, navigate]);
const validateUserAccess = useCallback(async () => {
const client = ServerConnections.currentApiClient();
// If this is a user route, ensure a user is logged in
if ((isAdminRequired || isUserRequired) && !client?.isLoggedIn()) {
try {
console.warn('[ConnectionRequired] unauthenticated user attempted to access user route');
bounce(await ServerConnections.connect())
.catch(err => {
console.error('[ConnectionRequired] failed to bounce', err);
});
} catch (ex) {
console.warn('[ConnectionRequired] error bouncing from user route', ex);
}
return;
}
// If this is an admin route, ensure the user has access
if (isAdminRequired) {
try {
const user = await client?.getCurrentUser();
if (!user?.Policy?.IsAdministrator) {
console.warn('[ConnectionRequired] normal user attempted to access admin route');
bounce(await ServerConnections.connect())
.catch(err => {
console.error('[ConnectionRequired] failed to bounce', err);
}); });
} catch (ex) {
console.warn('[ConnectionRequired] failed to show alert', ex);
}
console.debug('[ConnectionRequired] server update required, redirecting to select server page');
navigate(BounceRoutes.SelectServer);
return;
}
console.warn('[ConnectionRequired] unhandled connection state', connectionResponse.State);
};
const validateConnection = async () => {
// Check connection status on initial page load
const firstConnection = appRouter.firstConnectionResult;
appRouter.firstConnectionResult = null;
if (firstConnection && firstConnection.State !== ConnectionState.SignedIn) {
if (firstConnection.State === ConnectionState.ServerSignIn) {
// Verify the wizard is complete
try {
const infoResponse = await fetch(`${firstConnection.ApiClient.serverAddress()}/System/Info/Public`);
if (!infoResponse.ok) {
throw new Error('Public system info request failed');
}
const systemInfo = await infoResponse.json();
if (!systemInfo?.StartupWizardCompleted) {
// Update the current ApiClient
// TODO: Is there a better place to handle this?
ServerConnections.setLocalApiClient(firstConnection.ApiClient);
// Bounce to the wizard
console.info('[ConnectionRequired] startup wizard is not complete, redirecting there');
navigate(BounceRoutes.StartWizard);
return;
}
} catch (ex) {
console.error('[ConnectionRequired] checking wizard status failed', ex);
return;
}
}
// Bounce to the correct page in the login flow
bounce(firstConnection);
return;
}
// TODO: appRouter will call appHost.exit() if navigating back when you are already at the default route.
// This case will need to be handled elsewhere before appRouter can be killed.
const client = ServerConnections.currentApiClient();
// If this is a user route, ensure a user is logged in
if ((isAdminRequired || isUserRequired) && !client?.isLoggedIn()) {
try {
console.warn('[ConnectionRequired] unauthenticated user attempted to access user route');
bounce(await ServerConnections.connect());
} catch (ex) {
console.warn('[ConnectionRequired] error bouncing from user route', ex);
}
return;
}
// If this is an admin route, ensure the user has access
if (isAdminRequired) {
try {
const user = await client.getCurrentUser();
if (!user.Policy.IsAdministrator) {
console.warn('[ConnectionRequired] normal user attempted to access admin route');
bounce(await ServerConnections.connect());
return;
}
} catch (ex) {
console.warn('[ConnectionRequired] error bouncing from admin route', ex);
return; return;
} }
} catch (ex) {
console.warn('[ConnectionRequired] error bouncing from admin route', ex);
return;
} }
}
setIsLoading(false); setIsLoading(false);
}; }, [bounce, isAdminRequired, isUserRequired]);
validateConnection(); useEffect(() => {
}, [ isAdminRequired, isUserRequired, navigate ]); // TODO: appRouter will call appHost.exit() if navigating back when you are already at the default route.
// This case will need to be handled elsewhere before appRouter can be killed.
// Check connection status on initial page load
const firstConnection = appRouter.firstConnectionResult;
appRouter.firstConnectionResult = null;
if (firstConnection && firstConnection.State !== ConnectionState.SignedIn) {
handleIncompleteWizard(firstConnection)
.catch(err => {
console.error('[ConnectionRequired] failed to start wizard', err);
});
} else {
validateUserAccess()
.catch(err => {
console.error('[ConnectionRequired] failed to validate user access', err);
});
}
}, [handleIncompleteWizard, validateUserAccess]);
if (isLoading) { if (isLoading) {
return <Loading />; return <Loading />;

View file

@ -86,6 +86,10 @@ class ServerConnections extends ConnectionManager {
return this.localApiClient; return this.localApiClient;
} }
/**
* Gets the ApiClient that is currently connected.
* @returns {ApiClient|undefined} apiClient
*/
currentApiClient() { currentApiClient() {
let apiClient = this.getLocalApiClient(); let apiClient = this.getLocalApiClient();

View file

@ -0,0 +1,62 @@
import React, { FunctionComponent, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import ServerConnections from './ServerConnections';
import viewManager from './viewManager/viewManager';
import globalize from '../scripts/globalize';
import type { RestoreViewFailResponse } from '../types/viewManager';
interface ServerContentPageProps {
view: string
}
/**
* Page component that renders html content from a server request.
* Uses the ViewManager to dynamically load and execute the page JS.
*/
const ServerContentPage: FunctionComponent<ServerContentPageProps> = ({ view }) => {
const location = useLocation();
useEffect(() => {
const loadPage = () => {
const viewOptions = {
url: location.pathname + location.search,
state: location.state,
autoFocus: false,
options: {
supportsThemeMedia: false,
enableMediaControl: true
}
};
viewManager.tryRestoreView(viewOptions)
.catch(async (result?: RestoreViewFailResponse) => {
if (!result?.cancelled) {
const apiClient = ServerConnections.currentApiClient();
// Fetch the view html from the server and translate it
const viewHtml = await apiClient?.get(apiClient.getUrl(view + location.search))
.then((html: string) => globalize.translateHtml(html));
viewManager.loadView({
...viewOptions,
view: viewHtml
});
}
});
};
loadPage();
},
// location.state is NOT included as a dependency here since dialogs will update state while the current view stays the same
// eslint-disable-next-line react-hooks/exhaustive-deps
[
view,
location.pathname,
location.search
]);
return <></>;
};
export default ServerContentPage;

View file

@ -1,6 +1,3 @@
/* eslint-disable indent */
/** /**
* Module for controlling user parental control from. * Module for controlling user parental control from.
* @module components/accessSchedule/accessSchedule * @module components/accessSchedule/accessSchedule
@ -14,84 +11,82 @@ import '../../elements/emby-button/paper-icon-button-light';
import '../formdialog.scss'; import '../formdialog.scss';
import template from './accessSchedule.template.html'; import template from './accessSchedule.template.html';
function getDisplayTime(hours) { function getDisplayTime(hours) {
let minutes = 0; let minutes = 0;
const pct = hours % 1; const pct = hours % 1;
if (pct) { if (pct) {
minutes = parseInt(60 * pct); minutes = parseInt(60 * pct, 10);
}
return datetime.getDisplayTime(new Date(2000, 1, 1, hours, minutes, 0, 0));
} }
function populateHours(context) { return datetime.getDisplayTime(new Date(2000, 1, 1, hours, minutes, 0, 0));
let html = ''; }
for (let i = 0; i < 24; i++) { function populateHours(context) {
html += `<option value="${i}">${getDisplayTime(i)}</option>`; let html = '';
}
html += `<option value="24">${getDisplayTime(0)}</option>`; for (let i = 0; i < 24; i += 0.5) {
context.querySelector('#selectStart').innerHTML = html; html += `<option value="${i}">${getDisplayTime(i)}</option>`;
context.querySelector('#selectEnd').innerHTML = html;
} }
function loadSchedule(context, {DayOfWeek, StartHour, EndHour}) { html += `<option value="24">${getDisplayTime(0)}</option>`;
context.querySelector('#selectDay').value = DayOfWeek || 'Sunday'; context.querySelector('#selectStart').innerHTML = html;
context.querySelector('#selectStart').value = StartHour || 0; context.querySelector('#selectEnd').innerHTML = html;
context.querySelector('#selectEnd').value = EndHour || 0; }
function loadSchedule(context, { DayOfWeek, StartHour, EndHour }) {
context.querySelector('#selectDay').value = DayOfWeek || 'Sunday';
context.querySelector('#selectStart').value = StartHour || 0;
context.querySelector('#selectEnd').value = EndHour || 0;
}
function submitSchedule(context, options) {
const updatedSchedule = {
DayOfWeek: context.querySelector('#selectDay').value,
StartHour: context.querySelector('#selectStart').value,
EndHour: context.querySelector('#selectEnd').value
};
if (parseFloat(updatedSchedule.StartHour) >= parseFloat(updatedSchedule.EndHour)) {
alert(globalize.translate('ErrorStartHourGreaterThanEnd'));
return;
} }
function submitSchedule(context, options) { context.submitted = true;
const updatedSchedule = { options.schedule = Object.assign(options.schedule, updatedSchedule);
DayOfWeek: context.querySelector('#selectDay').value, dialogHelper.close(context);
StartHour: context.querySelector('#selectStart').value, }
EndHour: context.querySelector('#selectEnd').value
};
if (parseFloat(updatedSchedule.StartHour) >= parseFloat(updatedSchedule.EndHour)) { export function show(options) {
alert(globalize.translate('ErrorStartHourGreaterThanEnd')); return new Promise((resolve, reject) => {
return; const dlg = dialogHelper.createDialog({
} removeOnClose: true,
size: 'small'
context.submitted = true;
options.schedule = Object.assign(options.schedule, updatedSchedule);
dialogHelper.close(context);
}
export function show(options) {
return new Promise((resolve, reject) => {
const dlg = dialogHelper.createDialog({
removeOnClose: true,
size: 'small'
});
dlg.classList.add('formDialog');
let html = '';
html += globalize.translateHtml(template);
dlg.innerHTML = html;
populateHours(dlg);
loadSchedule(dlg, options.schedule);
dialogHelper.open(dlg);
dlg.addEventListener('close', () => {
if (dlg.submitted) {
resolve(options.schedule);
} else {
reject();
}
});
dlg.querySelector('.btnCancel').addEventListener('click', () => {
dialogHelper.close(dlg);
});
dlg.querySelector('form').addEventListener('submit', event => {
submitSchedule(dlg, options);
event.preventDefault();
return false;
});
}); });
} dlg.classList.add('formDialog');
let html = '';
/* eslint-enable indent */ html += globalize.translateHtml(template);
dlg.innerHTML = html;
populateHours(dlg);
loadSchedule(dlg, options.schedule);
dialogHelper.open(dlg);
dlg.addEventListener('close', () => {
if (dlg.submitted) {
resolve(options.schedule);
} else {
reject();
}
});
dlg.querySelector('.btnCancel').addEventListener('click', () => {
dialogHelper.close(dlg);
});
dlg.querySelector('form').addEventListener('submit', event => {
submitSchedule(dlg, options);
event.preventDefault();
return false;
});
});
}
export default { export default {
show: show show: show

View file

@ -6,7 +6,7 @@ import dom from '../../scripts/dom';
import '../../elements/emby-button/emby-button'; import '../../elements/emby-button/emby-button';
import './actionSheet.scss'; import './actionSheet.scss';
import 'material-design-icons-iconfont'; import 'material-design-icons-iconfont';
import '../../assets/css/scrollstyles.scss'; import '../../styles/scrollstyles.scss';
import '../../components/listview/listview.scss'; import '../../components/listview/listview.scss';
function getOffsets(elems) { function getOffsets(elems) {

View file

@ -11,130 +11,128 @@ import alert from './alert';
import { getLocale } from '../utils/dateFnsLocale.ts'; import { getLocale } from '../utils/dateFnsLocale.ts';
import { toBoolean } from '../utils/string.ts'; import { toBoolean } from '../utils/string.ts';
/* eslint-disable indent */ function getEntryHtml(entry, apiClient) {
let html = '';
html += '<div class="listItem listItem-border">';
let color = '#00a4dc';
let icon = 'notifications';
function getEntryHtml(entry, apiClient) { if (entry.Severity == 'Error' || entry.Severity == 'Fatal' || entry.Severity == 'Warn') {
let html = ''; color = '#cc0000';
html += '<div class="listItem listItem-border">'; icon = 'notification_important';
let color = '#00a4dc'; }
let icon = 'notifications';
if (entry.Severity == 'Error' || entry.Severity == 'Fatal' || entry.Severity == 'Warn') { if (entry.UserId && entry.UserPrimaryImageTag) {
color = '#cc0000'; html += '<span class="listItemIcon material-icons dvr" aria-hidden="true" style="width:2em!important;height:2em!important;padding:0;color:transparent;background-color:' + color + ";background-image:url('" + apiClient.getUserImageUrl(entry.UserId, {
icon = 'notification_important'; type: 'Primary',
} tag: entry.UserPrimaryImageTag
}) + "');background-repeat:no-repeat;background-position:center center;background-size: cover;\"></span>";
} else {
html += '<span class="listItemIcon material-icons ' + icon + '" aria-hidden="true" style="background-color:' + color + '"></span>';
}
if (entry.UserId && entry.UserPrimaryImageTag) { html += '<div class="listItemBody three-line">';
html += '<span class="listItemIcon material-icons dvr" aria-hidden="true" style="width:2em!important;height:2em!important;padding:0;color:transparent;background-color:' + color + ";background-image:url('" + apiClient.getUserImageUrl(entry.UserId, { html += '<div class="listItemBodyText">';
type: 'Primary', html += escapeHtml(entry.Name);
tag: entry.UserPrimaryImageTag html += '</div>';
}) + "');background-repeat:no-repeat;background-position:center center;background-size: cover;\"></span>"; html += '<div class="listItemBodyText secondary">';
} else { html += formatRelative(Date.parse(entry.Date), Date.now(), { locale: getLocale() });
html += '<span class="listItemIcon material-icons ' + icon + '" aria-hidden="true" style="background-color:' + color + '"></span>'; html += '</div>';
} html += '<div class="listItemBodyText secondary listItemBodyText-nowrap">';
html += escapeHtml(entry.ShortOverview || '');
html += '</div>';
html += '</div>';
html += '<div class="listItemBody three-line">'; if (entry.Overview) {
html += '<div class="listItemBodyText">'; html += `<button type="button" is="paper-icon-button-light" class="btnEntryInfo" data-id="${entry.Id}" title="${globalize.translate('Info')}">
html += escapeHtml(entry.Name);
html += '</div>';
html += '<div class="listItemBodyText secondary">';
html += formatRelative(Date.parse(entry.Date), Date.now(), { locale: getLocale() });
html += '</div>';
html += '<div class="listItemBodyText secondary listItemBodyText-nowrap">';
html += escapeHtml(entry.ShortOverview || '');
html += '</div>';
html += '</div>';
if (entry.Overview) {
html += `<button type="button" is="paper-icon-button-light" class="btnEntryInfo" data-id="${entry.Id}" title="${globalize.translate('Info')}">
<span class="material-icons info" aria-hidden="true"></span> <span class="material-icons info" aria-hidden="true"></span>
</button>`; </button>`;
}
html += '</div>';
return html;
} }
function renderList(elem, apiClient, result) { html += '</div>';
elem.innerHTML = result.Items.map(function (i) {
return getEntryHtml(i, apiClient); return html;
}).join(''); }
function renderList(elem, apiClient, result) {
elem.innerHTML = result.Items.map(function (i) {
return getEntryHtml(i, apiClient);
}).join('');
}
function reloadData(instance, elem, apiClient, startIndex, limit) {
if (startIndex == null) {
startIndex = parseInt(elem.getAttribute('data-activitystartindex') || '0', 10);
} }
function reloadData(instance, elem, apiClient, startIndex, limit) { limit = limit || parseInt(elem.getAttribute('data-activitylimit') || '7', 10);
if (startIndex == null) { const minDate = new Date();
startIndex = parseInt(elem.getAttribute('data-activitystartindex') || '0'); const hasUserId = toBoolean(elem.getAttribute('data-useractivity'), true);
}
limit = limit || parseInt(elem.getAttribute('data-activitylimit') || '7'); // TODO: Use date-fns
const minDate = new Date(); if (hasUserId) {
const hasUserId = toBoolean(elem.getAttribute('data-useractivity'), true); minDate.setTime(minDate.getTime() - 24 * 60 * 60 * 1000); // one day back
} else {
// TODO: Use date-fns minDate.setTime(minDate.getTime() - 7 * 24 * 60 * 60 * 1000); // one week back
if (hasUserId) {
minDate.setTime(minDate.getTime() - 24 * 60 * 60 * 1000); // one day back
} else {
minDate.setTime(minDate.getTime() - 7 * 24 * 60 * 60 * 1000); // one week back
}
ApiClient.getJSON(ApiClient.getUrl('System/ActivityLog/Entries', {
startIndex: startIndex,
limit: limit,
minDate: minDate.toISOString(),
hasUserId: hasUserId
})).then(function (result) {
elem.setAttribute('data-activitystartindex', startIndex);
elem.setAttribute('data-activitylimit', limit);
if (!startIndex) {
const activityContainer = dom.parentWithClass(elem, 'activityContainer');
if (activityContainer) {
if (result.Items.length) {
activityContainer.classList.remove('hide');
} else {
activityContainer.classList.add('hide');
}
}
}
instance.items = result.Items;
renderList(elem, apiClient, result);
});
} }
function onActivityLogUpdate(e, apiClient) { ApiClient.getJSON(ApiClient.getUrl('System/ActivityLog/Entries', {
const options = this.options; startIndex: startIndex,
limit: limit,
minDate: minDate.toISOString(),
hasUserId: hasUserId
})).then(function (result) {
elem.setAttribute('data-activitystartindex', startIndex);
elem.setAttribute('data-activitylimit', limit);
if (!startIndex) {
const activityContainer = dom.parentWithClass(elem, 'activityContainer');
if (options && options.serverId === apiClient.serverId()) { if (activityContainer) {
reloadData(this, options.element, apiClient); if (result.Items.length) {
} activityContainer.classList.remove('hide');
} } else {
activityContainer.classList.add('hide');
function onListClick(e) {
const btnEntryInfo = dom.parentWithClass(e.target, 'btnEntryInfo');
if (btnEntryInfo) {
const id = btnEntryInfo.getAttribute('data-id');
const items = this.items;
if (items) {
const item = items.filter(function (i) {
return i.Id.toString() === id;
})[0];
if (item) {
showItemOverview(item);
} }
} }
} }
}
function showItemOverview(item) { instance.items = result.Items;
alert({ renderList(elem, apiClient, result);
text: item.Overview });
}); }
function onActivityLogUpdate(e, apiClient) {
const options = this.options;
if (options && options.serverId === apiClient.serverId()) {
reloadData(this, options.element, apiClient);
} }
}
function onListClick(e) {
const btnEntryInfo = dom.parentWithClass(e.target, 'btnEntryInfo');
if (btnEntryInfo) {
const id = btnEntryInfo.getAttribute('data-id');
const items = this.items;
if (items) {
const item = items.filter(function (i) {
return i.Id.toString() === id;
})[0];
if (item) {
showItemOverview(item);
}
}
}
}
function showItemOverview(item) {
alert({
text: item.Overview
});
}
class ActivityLog { class ActivityLog {
constructor(options) { constructor(options) {
@ -169,5 +167,3 @@ class ActivityLog {
} }
export default ActivityLog; export default ActivityLog;
/* eslint-enable indent */

View file

@ -1,47 +1,43 @@
import { appRouter } from './appRouter'; import { appRouter } from './router/appRouter';
import browser from '../scripts/browser'; import browser from '../scripts/browser';
import dialog from './dialog/dialog'; import dialog from './dialog/dialog';
import globalize from '../scripts/globalize'; import globalize from '../scripts/globalize';
/* eslint-disable indent */ function useNativeAlert() {
// webOS seems to block modals
function useNativeAlert() { // Tizen 2.x seems to block modals
// webOS seems to block modals return !browser.web0s
// Tizen 2.x seems to block modals
return !browser.web0s
&& !(browser.tizenVersion && browser.tizenVersion < 3) && !(browser.tizenVersion && browser.tizenVersion < 3)
&& browser.tv && browser.tv
&& window.alert; && window.alert;
}
export default async function (text, title) {
let options;
if (typeof text === 'string') {
options = {
title: title,
text: text
};
} else {
options = text;
} }
export default async function (text, title) { await appRouter.ready();
let options;
if (typeof text === 'string') {
options = {
title: title,
text: text
};
} else {
options = text;
}
await appRouter.ready(); if (useNativeAlert()) {
alert((options.text || '').replaceAll('<br/>', '\n'));
return Promise.resolve();
} else {
const items = [];
if (useNativeAlert()) { items.push({
alert((options.text || '').replaceAll('<br/>', '\n')); name: globalize.translate('ButtonGotIt'),
return Promise.resolve(); id: 'ok',
} else { type: 'submit'
const items = []; });
items.push({ options.buttons = items;
name: globalize.translate('ButtonGotIt'), return dialog.show(options);
id: 'ok',
type: 'submit'
});
options.buttons = items;
return dialog.show(options);
}
} }
}
/* eslint-enable indent */

View file

@ -1,5 +1,3 @@
/* eslint-disable indent */
/** /**
* Module alphaPicker. * Module alphaPicker.
* @module components/alphaPicker/alphaPicker * @module components/alphaPicker/alphaPicker
@ -13,312 +11,311 @@ import './style.scss';
import '../../elements/emby-button/paper-icon-button-light'; import '../../elements/emby-button/paper-icon-button-light';
import 'material-design-icons-iconfont'; import 'material-design-icons-iconfont';
const selectedButtonClass = 'alphaPickerButton-selected'; const selectedButtonClass = 'alphaPickerButton-selected';
function focus() { function focus() {
const scope = this; const scope = this;
const selected = scope.querySelector(`.${selectedButtonClass}`); const selected = scope.querySelector(`.${selectedButtonClass}`);
if (selected) { if (selected) {
focusManager.focus(selected); focusManager.focus(selected);
} else { } else {
focusManager.autoFocus(scope, true); focusManager.autoFocus(scope, true);
} }
}
function getAlphaPickerButtonClassName(vertical) {
let alphaPickerButtonClassName = 'alphaPickerButton';
if (layoutManager.tv) {
alphaPickerButtonClassName += ' alphaPickerButton-tv';
} }
function getAlphaPickerButtonClassName(vertical) { if (vertical) {
let alphaPickerButtonClassName = 'alphaPickerButton'; alphaPickerButtonClassName += ' alphaPickerButton-vertical';
if (layoutManager.tv) {
alphaPickerButtonClassName += ' alphaPickerButton-tv';
}
if (vertical) {
alphaPickerButtonClassName += ' alphaPickerButton-vertical';
}
return alphaPickerButtonClassName;
} }
function getLetterButton(l, vertical) { return alphaPickerButtonClassName;
return `<button data-value="${l}" class="${getAlphaPickerButtonClassName(vertical)}">${l}</button>`; }
function getLetterButton(l, vertical) {
return `<button data-value="${l}" class="${getAlphaPickerButtonClassName(vertical)}">${l}</button>`;
}
function mapLetters(letters, vertical) {
return letters.map(l => {
return getLetterButton(l, vertical);
});
}
function render(element, options) {
element.classList.add('alphaPicker');
if (layoutManager.tv) {
element.classList.add('alphaPicker-tv');
} }
function mapLetters(letters, vertical) { const vertical = element.classList.contains('alphaPicker-vertical');
return letters.map(l => {
return getLetterButton(l, vertical); if (!vertical) {
}); element.classList.add('focuscontainer-x');
} }
function render(element, options) { let html = '';
element.classList.add('alphaPicker'); let letters;
if (layoutManager.tv) { const alphaPickerButtonClassName = getAlphaPickerButtonClassName(vertical);
element.classList.add('alphaPicker-tv');
}
const vertical = element.classList.contains('alphaPicker-vertical'); let rowClassName = 'alphaPickerRow';
if (!vertical) { if (vertical) {
element.classList.add('focuscontainer-x'); rowClassName += ' alphaPickerRow-vertical';
} }
let html = ''; html += `<div class="${rowClassName}">`;
let letters; if (options.mode === 'keyboard') {
html += `<button data-value=" " is="paper-icon-button-light" class="${alphaPickerButtonClassName}" aria-label="${globalize.translate('ButtonSpace')}"><span class="material-icons alphaPickerButtonIcon space_bar" aria-hidden="true"></span></button>`;
const alphaPickerButtonClassName = getAlphaPickerButtonClassName(vertical); } else {
letters = ['#'];
let rowClassName = 'alphaPickerRow';
if (vertical) {
rowClassName += ' alphaPickerRow-vertical';
}
html += `<div class="${rowClassName}">`;
if (options.mode === 'keyboard') {
html += `<button data-value=" " is="paper-icon-button-light" class="${alphaPickerButtonClassName}" aria-label="${globalize.translate('ButtonSpace')}"><span class="material-icons alphaPickerButtonIcon space_bar" aria-hidden="true"></span></button>`;
} else {
letters = ['#'];
html += mapLetters(letters, vertical).join('');
}
letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
html += mapLetters(letters, vertical).join(''); html += mapLetters(letters, vertical).join('');
if (options.mode === 'keyboard') {
html += `<button data-value="backspace" is="paper-icon-button-light" class="${alphaPickerButtonClassName}" aria-label="${globalize.translate('ButtonBackspace')}"><span class="material-icons alphaPickerButtonIcon backspace" aria-hidden="true"></span></button>`;
html += '</div>';
letters = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
html += `<div class="${rowClassName}">`;
html += '<br/>';
html += mapLetters(letters, vertical).join('');
html += '</div>';
} else {
html += '</div>';
}
element.innerHTML = html;
element.classList.add('focusable');
element.focus = focus;
} }
export class AlphaPicker { letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
constructor(options) { html += mapLetters(letters, vertical).join('');
const self = this;
this.options = options; if (options.mode === 'keyboard') {
html += `<button data-value="backspace" is="paper-icon-button-light" class="${alphaPickerButtonClassName}" aria-label="${globalize.translate('ButtonBackspace')}"><span class="material-icons alphaPickerButtonIcon backspace" aria-hidden="true"></span></button>`;
html += '</div>';
const element = options.element; letters = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
const itemsContainer = options.itemsContainer; html += `<div class="${rowClassName}">`;
const itemClass = options.itemClass; html += '<br/>';
html += mapLetters(letters, vertical).join('');
html += '</div>';
} else {
html += '</div>';
}
let itemFocusValue; element.innerHTML = html;
let itemFocusTimeout;
function onItemFocusTimeout() { element.classList.add('focusable');
itemFocusTimeout = null; element.focus = focus;
self.value(itemFocusValue); }
}
let alphaFocusedElement; export class AlphaPicker {
let alphaFocusTimeout; constructor(options) {
const self = this;
function onAlphaFocusTimeout() { this.options = options;
alphaFocusTimeout = null;
if (document.activeElement === alphaFocusedElement) { const element = options.element;
const value = alphaFocusedElement.getAttribute('data-value'); const itemsContainer = options.itemsContainer;
self.value(value, true); const itemClass = options.itemClass;
}
}
function onAlphaPickerInKeyboardModeClick(e) { let itemFocusValue;
const alphaPickerButton = dom.parentWithClass(e.target, 'alphaPickerButton'); let itemFocusTimeout;
if (alphaPickerButton) { function onItemFocusTimeout() {
const value = alphaPickerButton.getAttribute('data-value'); itemFocusTimeout = null;
self.value(itemFocusValue);
element.dispatchEvent(new CustomEvent('alphavalueclicked', {
cancelable: false,
detail: {
value
}
}));
}
}
function onAlphaPickerClick(e) {
const alphaPickerButton = dom.parentWithClass(e.target, 'alphaPickerButton');
if (alphaPickerButton) {
const value = alphaPickerButton.getAttribute('data-value');
if ((this._currentValue || '').toUpperCase() === value.toUpperCase()) {
this.value(null, true);
} else {
this.value(value, true);
}
}
}
function onAlphaPickerFocusIn(e) {
if (alphaFocusTimeout) {
clearTimeout(alphaFocusTimeout);
alphaFocusTimeout = null;
}
const alphaPickerButton = dom.parentWithClass(e.target, 'alphaPickerButton');
if (alphaPickerButton) {
alphaFocusedElement = alphaPickerButton;
alphaFocusTimeout = setTimeout(onAlphaFocusTimeout, 600);
}
}
function onItemsFocusIn(e) {
const item = dom.parentWithClass(e.target, itemClass);
if (item) {
const prefix = item.getAttribute('data-prefix');
if (prefix && prefix.length) {
itemFocusValue = prefix[0];
if (itemFocusTimeout) {
clearTimeout(itemFocusTimeout);
}
itemFocusTimeout = setTimeout(onItemFocusTimeout, 100);
}
}
}
this.enabled = function (enabled) {
if (enabled) {
if (itemsContainer) {
itemsContainer.addEventListener('focus', onItemsFocusIn, true);
}
if (options.mode === 'keyboard') {
element.addEventListener('click', onAlphaPickerInKeyboardModeClick);
}
if (options.valueChangeEvent !== 'click') {
element.addEventListener('focus', onAlphaPickerFocusIn, true);
} else {
element.addEventListener('click', onAlphaPickerClick.bind(this));
}
} else {
if (itemsContainer) {
itemsContainer.removeEventListener('focus', onItemsFocusIn, true);
}
element.removeEventListener('click', onAlphaPickerInKeyboardModeClick);
element.removeEventListener('focus', onAlphaPickerFocusIn, true);
element.removeEventListener('click', onAlphaPickerClick.bind(this));
}
};
render(element, options);
this.enabled(true);
this.visible(true);
} }
value(value, applyValue) { let alphaFocusedElement;
const element = this.options.element; let alphaFocusTimeout;
let btn;
let selected;
if (value !== undefined) { function onAlphaFocusTimeout() {
if (value != null) { alphaFocusTimeout = null;
value = value.toUpperCase();
this._currentValue = value;
if (this.options.mode !== 'keyboard') { if (document.activeElement === alphaFocusedElement) {
selected = element.querySelector(`.${selectedButtonClass}`); const value = alphaFocusedElement.getAttribute('data-value');
self.value(value, true);
try {
btn = element.querySelector(`.alphaPickerButton[data-value='${value}']`);
} catch (err) {
console.error('error in querySelector:', err);
}
if (btn && btn !== selected) {
btn.classList.add(selectedButtonClass);
}
if (selected && selected !== btn) {
selected.classList.remove(selectedButtonClass);
}
}
} else {
this._currentValue = value;
selected = element.querySelector(`.${selectedButtonClass}`);
if (selected) {
selected.classList.remove(selectedButtonClass);
}
}
} }
}
if (applyValue) { function onAlphaPickerInKeyboardModeClick(e) {
element.dispatchEvent(new CustomEvent('alphavaluechanged', { const alphaPickerButton = dom.parentWithClass(e.target, 'alphaPickerButton');
if (alphaPickerButton) {
const value = alphaPickerButton.getAttribute('data-value');
element.dispatchEvent(new CustomEvent('alphavalueclicked', {
cancelable: false, cancelable: false,
detail: { detail: {
value value
} }
})); }));
} }
return this._currentValue;
} }
on(name, fn) { function onAlphaPickerClick(e) {
const element = this.options.element; const alphaPickerButton = dom.parentWithClass(e.target, 'alphaPickerButton');
element.addEventListener(name, fn);
if (alphaPickerButton) {
const value = alphaPickerButton.getAttribute('data-value');
if ((this._currentValue || '').toUpperCase() === value.toUpperCase()) {
this.value(null, true);
} else {
this.value(value, true);
}
}
} }
off(name, fn) { function onAlphaPickerFocusIn(e) {
const element = this.options.element; if (alphaFocusTimeout) {
element.removeEventListener(name, fn); clearTimeout(alphaFocusTimeout);
alphaFocusTimeout = null;
}
const alphaPickerButton = dom.parentWithClass(e.target, 'alphaPickerButton');
if (alphaPickerButton) {
alphaFocusedElement = alphaPickerButton;
alphaFocusTimeout = setTimeout(onAlphaFocusTimeout, 600);
}
} }
updateControls(query) { function onItemsFocusIn(e) {
if (query.NameLessThan) { const item = dom.parentWithClass(e.target, itemClass);
this.value('#');
if (item) {
const prefix = item.getAttribute('data-prefix');
if (prefix?.length) {
itemFocusValue = prefix[0];
if (itemFocusTimeout) {
clearTimeout(itemFocusTimeout);
}
itemFocusTimeout = setTimeout(onItemFocusTimeout, 100);
}
}
}
this.enabled = function (enabled) {
if (enabled) {
if (itemsContainer) {
itemsContainer.addEventListener('focus', onItemsFocusIn, true);
}
if (options.mode === 'keyboard') {
element.addEventListener('click', onAlphaPickerInKeyboardModeClick);
}
if (options.valueChangeEvent !== 'click') {
element.addEventListener('focus', onAlphaPickerFocusIn, true);
} else {
element.addEventListener('click', onAlphaPickerClick.bind(this));
}
} else { } else {
this.value(query.NameStartsWith); if (itemsContainer) {
itemsContainer.removeEventListener('focus', onItemsFocusIn, true);
}
element.removeEventListener('click', onAlphaPickerInKeyboardModeClick);
element.removeEventListener('focus', onAlphaPickerFocusIn, true);
element.removeEventListener('click', onAlphaPickerClick.bind(this));
} }
};
this.visible(query.SortBy.indexOf('SortName') !== -1); render(element, options);
}
visible(visible) { this.enabled(true);
const element = this.options.element; this.visible(true);
element.style.visibility = visible ? 'visible' : 'hidden';
}
values() {
const element = this.options.element;
const elems = element.querySelectorAll('.alphaPickerButton');
const values = [];
for (let i = 0, length = elems.length; i < length; i++) {
values.push(elems[i].getAttribute('data-value'));
}
return values;
}
focus() {
const element = this.options.element;
focusManager.autoFocus(element, true);
}
destroy() {
const element = this.options.element;
this.enabled(false);
element.classList.remove('focuscontainer-x');
this.options = null;
}
} }
/* eslint-enable indent */ value(value, applyValue) {
const element = this.options.element;
let btn;
let selected;
if (value !== undefined) {
if (value != null) {
value = value.toUpperCase();
this._currentValue = value;
if (this.options.mode !== 'keyboard') {
selected = element.querySelector(`.${selectedButtonClass}`);
try {
btn = element.querySelector(`.alphaPickerButton[data-value='${value}']`);
} catch (err) {
console.error('error in querySelector:', err);
}
if (btn && btn !== selected) {
btn.classList.add(selectedButtonClass);
}
if (selected && selected !== btn) {
selected.classList.remove(selectedButtonClass);
}
}
} else {
this._currentValue = value;
selected = element.querySelector(`.${selectedButtonClass}`);
if (selected) {
selected.classList.remove(selectedButtonClass);
}
}
}
if (applyValue) {
element.dispatchEvent(new CustomEvent('alphavaluechanged', {
cancelable: false,
detail: {
value
}
}));
}
return this._currentValue;
}
on(name, fn) {
const element = this.options.element;
element.addEventListener(name, fn);
}
off(name, fn) {
const element = this.options.element;
element.removeEventListener(name, fn);
}
updateControls(query) {
if (query.NameLessThan) {
this.value('#');
} else {
this.value(query.NameStartsWith);
}
this.visible(query.SortBy.indexOf('SortName') !== -1);
}
visible(visible) {
const element = this.options.element;
element.style.visibility = visible ? 'visible' : 'hidden';
}
values() {
const element = this.options.element;
const elems = element.querySelectorAll('.alphaPickerButton');
const values = [];
for (let i = 0, length = elems.length; i < length; i++) {
values.push(elems[i].getAttribute('data-value'));
}
return values;
}
focus() {
const element = this.options.element;
focusManager.autoFocus(element, true);
}
destroy() {
const element = this.options.element;
this.enabled(false);
element.classList.remove('focuscontainer-x');
this.options = null;
}
}
export default AlphaPicker; export default AlphaPicker;

View file

@ -309,8 +309,8 @@ function askForExit() {
exitPromise = actionsheet.show({ exitPromise = actionsheet.show({
title: globalize.translate('MessageConfirmAppExit'), title: globalize.translate('MessageConfirmAppExit'),
items: [ items: [
{id: 'yes', name: globalize.translate('Yes')}, { id: 'yes', name: globalize.translate('Yes') },
{id: 'no', name: globalize.translate('No')} { id: 'no', name: globalize.translate('No') }
] ]
}).then(function (value) { }).then(function (value) {
if (value === 'yes') { if (value === 'yes') {
@ -366,20 +366,20 @@ export const appHost = {
}; };
}, },
deviceName: function () { deviceName: function () {
return window.NativeShell?.AppHost?.deviceName return window.NativeShell?.AppHost?.deviceName ?
? window.NativeShell.AppHost.deviceName() : getDeviceName(); window.NativeShell.AppHost.deviceName() : getDeviceName();
}, },
deviceId: function () { deviceId: function () {
return window.NativeShell?.AppHost?.deviceId return window.NativeShell?.AppHost?.deviceId ?
? window.NativeShell.AppHost.deviceId() : getDeviceId(); window.NativeShell.AppHost.deviceId() : getDeviceId();
}, },
appName: function () { appName: function () {
return window.NativeShell?.AppHost?.appName return window.NativeShell?.AppHost?.appName ?
? window.NativeShell.AppHost.appName() : appName; window.NativeShell.AppHost.appName() : appName;
}, },
appVersion: function () { appVersion: function () {
return window.NativeShell?.AppHost?.appVersion return window.NativeShell?.AppHost?.appVersion ?
? window.NativeShell.AppHost.appVersion() : Package.version; window.NativeShell.AppHost.appVersion() : Package.version;
}, },
getPushTokenInfo: function () { getPushTokenInfo: function () {
return {}; return {};

View file

@ -1,5 +1,3 @@
/* eslint-disable indent */
/** /**
* Module for performing auto-focus. * Module for performing auto-focus.
* @module components/autoFocuser * @module components/autoFocuser
@ -8,93 +6,91 @@
import focusManager from './focusManager'; import focusManager from './focusManager';
import layoutManager from './layoutManager'; import layoutManager from './layoutManager';
/** /**
* Previously selected element. * Previously selected element.
*/ */
let activeElement; let activeElement;
/** /**
* Returns _true_ if AutoFocuser is enabled. * Returns _true_ if AutoFocuser is enabled.
*/ */
export function isEnabled() { export function isEnabled() {
return layoutManager.tv; return layoutManager.tv;
} }
/** /**
* Start AutoFocuser. * Start AutoFocuser.
*/ */
export function enable() { export function enable() {
if (!isEnabled()) { if (!isEnabled()) {
return; return;
}
window.addEventListener('focusin', function (e) {
activeElement = e.target;
});
console.debug('AutoFocuser enabled');
} }
/** window.addEventListener('focusin', function (e) {
activeElement = e.target;
});
console.debug('AutoFocuser enabled');
}
/**
* Set focus on a suitable element, taking into account the previously selected. * Set focus on a suitable element, taking into account the previously selected.
* @param {HTMLElement} [container] - Element to limit scope. * @param {HTMLElement} [container] - Element to limit scope.
* @returns {HTMLElement} Focused element. * @returns {HTMLElement} Focused element.
*/ */
export function autoFocus(container) { export function autoFocus(container) {
if (!isEnabled()) { if (!isEnabled()) {
return null; return null;
}
container = container || document.body;
let candidates = [];
if (activeElement) {
// These elements are recreated
if (activeElement.classList.contains('btnPreviousPage')) {
candidates.push(container.querySelector('.btnPreviousPage'));
candidates.push(container.querySelector('.btnNextPage'));
} else if (activeElement.classList.contains('btnNextPage')) {
candidates.push(container.querySelector('.btnNextPage'));
candidates.push(container.querySelector('.btnPreviousPage'));
} else if (activeElement.classList.contains('btnSelectView')) {
candidates.push(container.querySelector('.btnSelectView'));
}
candidates.push(activeElement);
}
candidates = candidates.concat(Array.from(container.querySelectorAll('.btnPlay')));
let focusedElement;
candidates.every(function (element) {
if (focusManager.isCurrentlyFocusable(element)) {
focusManager.focus(element);
focusedElement = element;
return false;
}
return true;
});
if (!focusedElement) {
// FIXME: Multiple itemsContainers
const itemsContainer = container.querySelector('.itemsContainer');
if (itemsContainer) {
focusedElement = focusManager.autoFocus(itemsContainer);
}
}
if (!focusedElement) {
focusedElement = focusManager.autoFocus(container);
}
return focusedElement;
} }
/* eslint-enable indent */ container = container || document.body;
let candidates = [];
if (activeElement) {
// These elements are recreated
if (activeElement.classList.contains('btnPreviousPage')) {
candidates.push(container.querySelector('.btnPreviousPage'));
candidates.push(container.querySelector('.btnNextPage'));
} else if (activeElement.classList.contains('btnNextPage')) {
candidates.push(container.querySelector('.btnNextPage'));
candidates.push(container.querySelector('.btnPreviousPage'));
} else if (activeElement.classList.contains('btnSelectView')) {
candidates.push(container.querySelector('.btnSelectView'));
}
candidates.push(activeElement);
}
candidates = candidates.concat(Array.from(container.querySelectorAll('.btnPlay')));
let focusedElement;
candidates.every(function (element) {
if (focusManager.isCurrentlyFocusable(element)) {
focusManager.focus(element);
focusedElement = element;
return false;
}
return true;
});
if (!focusedElement) {
// FIXME: Multiple itemsContainers
const itemsContainer = container.querySelector('.itemsContainer');
if (itemsContainer) {
focusedElement = focusManager.autoFocus(itemsContainer);
}
}
if (!focusedElement) {
focusedElement = focusManager.autoFocus(container);
}
return focusedElement;
}
export default { export default {
isEnabled: isEnabled, isEnabled: isEnabled,

View file

@ -7,282 +7,278 @@ import ServerConnections from '../ServerConnections';
import './backdrop.scss'; import './backdrop.scss';
/* eslint-disable indent */ function enableAnimation() {
return !browser.slow;
}
function enableAnimation() { function enableRotation() {
return !browser.slow; return !browser.tv
}
function enableRotation() {
return !browser.tv
// Causes high cpu usage // Causes high cpu usage
&& !browser.firefox; && !browser.firefox;
} }
class Backdrop { class Backdrop {
load(url, parent, existingBackdropImage) { load(url, parent, existingBackdropImage) {
const img = new Image(); const img = new Image();
const self = this; const self = this;
img.onload = () => { img.onload = () => {
if (self.isDestroyed) { if (self.isDestroyed) {
return;
}
const backdropImage = document.createElement('div');
backdropImage.classList.add('backdropImage');
backdropImage.classList.add('displayingBackdropImage');
backdropImage.style.backgroundImage = `url('${url}')`;
backdropImage.setAttribute('data-url', url);
backdropImage.classList.add('backdropImageFadeIn');
parent.appendChild(backdropImage);
if (!enableAnimation()) {
if (existingBackdropImage && existingBackdropImage.parentNode) {
existingBackdropImage.parentNode.removeChild(existingBackdropImage);
}
internalBackdrop(true);
return;
}
const onAnimationComplete = () => {
dom.removeEventListener(backdropImage, dom.whichAnimationEvent(), onAnimationComplete, {
once: true
});
if (backdropImage === self.currentAnimatingElement) {
self.currentAnimatingElement = null;
}
if (existingBackdropImage && existingBackdropImage.parentNode) {
existingBackdropImage.parentNode.removeChild(existingBackdropImage);
}
};
dom.addEventListener(backdropImage, dom.whichAnimationEvent(), onAnimationComplete, {
once: true
});
internalBackdrop(true);
};
img.src = url;
}
cancelAnimation() {
const elem = this.currentAnimatingElement;
if (elem) {
elem.classList.remove('backdropImageFadeIn');
this.currentAnimatingElement = null;
}
}
destroy() {
this.isDestroyed = true;
this.cancelAnimation();
}
}
let backdropContainer;
function getBackdropContainer() {
if (!backdropContainer) {
backdropContainer = document.querySelector('.backdropContainer');
}
if (!backdropContainer) {
backdropContainer = document.createElement('div');
backdropContainer.classList.add('backdropContainer');
document.body.insertBefore(backdropContainer, document.body.firstChild);
}
return backdropContainer;
}
export function clearBackdrop(clearAll) {
clearRotation();
if (currentLoadingBackdrop) {
currentLoadingBackdrop.destroy();
currentLoadingBackdrop = null;
}
const elem = getBackdropContainer();
elem.innerHTML = '';
if (clearAll) {
hasExternalBackdrop = false;
}
internalBackdrop(false);
}
let backgroundContainer;
function getBackgroundContainer() {
if (!backgroundContainer) {
backgroundContainer = document.querySelector('.backgroundContainer');
}
return backgroundContainer;
}
function setBackgroundContainerBackgroundEnabled() {
if (hasInternalBackdrop || hasExternalBackdrop) {
getBackgroundContainer().classList.add('withBackdrop');
} else {
getBackgroundContainer().classList.remove('withBackdrop');
}
}
let hasInternalBackdrop;
function internalBackdrop(isEnabled) {
hasInternalBackdrop = isEnabled;
setBackgroundContainerBackgroundEnabled();
}
let hasExternalBackdrop;
export function externalBackdrop(isEnabled) {
hasExternalBackdrop = isEnabled;
setBackgroundContainerBackgroundEnabled();
}
let currentLoadingBackdrop;
function setBackdropImage(url) {
if (currentLoadingBackdrop) {
currentLoadingBackdrop.destroy();
currentLoadingBackdrop = null;
}
const elem = getBackdropContainer();
const existingBackdropImage = elem.querySelector('.displayingBackdropImage');
if (existingBackdropImage && existingBackdropImage.getAttribute('data-url') === url) {
if (existingBackdropImage.getAttribute('data-url') === url) {
return; return;
} }
existingBackdropImage.classList.remove('displayingBackdropImage');
}
const instance = new Backdrop(); const backdropImage = document.createElement('div');
instance.load(url, elem, existingBackdropImage); backdropImage.classList.add('backdropImage');
currentLoadingBackdrop = instance; backdropImage.classList.add('displayingBackdropImage');
} backdropImage.style.backgroundImage = `url('${url}')`;
backdropImage.setAttribute('data-url', url);
function getItemImageUrls(item, imageOptions) { backdropImage.classList.add('backdropImageFadeIn');
imageOptions = imageOptions || {}; parent.appendChild(backdropImage);
const apiClient = ServerConnections.getApiClient(item.ServerId); if (!enableAnimation()) {
if (item.BackdropImageTags && item.BackdropImageTags.length > 0) { if (existingBackdropImage && existingBackdropImage.parentNode) {
return item.BackdropImageTags.map((imgTag, index) => { existingBackdropImage.parentNode.removeChild(existingBackdropImage);
return apiClient.getScaledImageUrl(item.BackdropItemId || item.Id, Object.assign(imageOptions, { }
type: 'Backdrop', internalBackdrop(true);
tag: imgTag, return;
maxWidth: dom.getScreenWidth(), }
index: index
})); const onAnimationComplete = () => {
dom.removeEventListener(backdropImage, dom.whichAnimationEvent(), onAnimationComplete, {
once: true
});
if (backdropImage === self.currentAnimatingElement) {
self.currentAnimatingElement = null;
}
if (existingBackdropImage && existingBackdropImage.parentNode) {
existingBackdropImage.parentNode.removeChild(existingBackdropImage);
}
};
dom.addEventListener(backdropImage, dom.whichAnimationEvent(), onAnimationComplete, {
once: true
}); });
}
if (item.ParentBackdropItemId && item.ParentBackdropImageTags && item.ParentBackdropImageTags.length) { internalBackdrop(true);
return item.ParentBackdropImageTags.map((imgTag, index) => {
return apiClient.getScaledImageUrl(item.ParentBackdropItemId, Object.assign(imageOptions, {
type: 'Backdrop',
tag: imgTag,
maxWidth: dom.getScreenWidth(),
index: index
}));
});
}
return [];
}
function getImageUrls(items, imageOptions) {
const list = [];
const onImg = img => {
list.push(img);
}; };
for (let i = 0, length = items.length; i < length; i++) { img.src = url;
const itemImages = getItemImageUrls(items[i], imageOptions);
itemImages.forEach(onImg);
}
return list;
} }
function enabled() { cancelAnimation() {
return userSettings.enableBackdrops(); const elem = this.currentAnimatingElement;
} if (elem) {
elem.classList.remove('backdropImageFadeIn');
let rotationInterval; this.currentAnimatingElement = null;
let currentRotatingImages = [];
let currentRotationIndex = -1;
export function setBackdrops(items, imageOptions, enableImageRotation) {
if (enabled()) {
const images = getImageUrls(items, imageOptions);
if (images.length) {
startRotation(images, enableImageRotation);
} else {
clearBackdrop();
}
} }
} }
function startRotation(images, enableImageRotation) { destroy() {
if (isEqual(images, currentRotatingImages)) { this.isDestroyed = true;
this.cancelAnimation();
}
}
let backdropContainer;
function getBackdropContainer() {
if (!backdropContainer) {
backdropContainer = document.querySelector('.backdropContainer');
}
if (!backdropContainer) {
backdropContainer = document.createElement('div');
backdropContainer.classList.add('backdropContainer');
document.body.insertBefore(backdropContainer, document.body.firstChild);
}
return backdropContainer;
}
export function clearBackdrop(clearAll) {
clearRotation();
if (currentLoadingBackdrop) {
currentLoadingBackdrop.destroy();
currentLoadingBackdrop = null;
}
const elem = getBackdropContainer();
elem.innerHTML = '';
if (clearAll) {
hasExternalBackdrop = false;
}
internalBackdrop(false);
}
let backgroundContainer;
function getBackgroundContainer() {
if (!backgroundContainer) {
backgroundContainer = document.querySelector('.backgroundContainer');
}
return backgroundContainer;
}
function setBackgroundContainerBackgroundEnabled() {
if (hasInternalBackdrop || hasExternalBackdrop) {
getBackgroundContainer().classList.add('withBackdrop');
} else {
getBackgroundContainer().classList.remove('withBackdrop');
}
}
let hasInternalBackdrop;
function internalBackdrop(isEnabled) {
hasInternalBackdrop = isEnabled;
setBackgroundContainerBackgroundEnabled();
}
let hasExternalBackdrop;
export function externalBackdrop(isEnabled) {
hasExternalBackdrop = isEnabled;
setBackgroundContainerBackgroundEnabled();
}
let currentLoadingBackdrop;
function setBackdropImage(url) {
if (currentLoadingBackdrop) {
currentLoadingBackdrop.destroy();
currentLoadingBackdrop = null;
}
const elem = getBackdropContainer();
const existingBackdropImage = elem.querySelector('.displayingBackdropImage');
if (existingBackdropImage && existingBackdropImage.getAttribute('data-url') === url) {
if (existingBackdropImage.getAttribute('data-url') === url) {
return; return;
} }
existingBackdropImage.classList.remove('displayingBackdropImage');
clearRotation();
currentRotatingImages = images;
currentRotationIndex = -1;
if (images.length > 1 && enableImageRotation !== false && enableRotation()) {
rotationInterval = setInterval(onRotationInterval, 24000);
}
onRotationInterval();
} }
function onRotationInterval() { const instance = new Backdrop();
if (playbackManager.isPlayingLocally(['Video'])) { instance.load(url, elem, existingBackdropImage);
return; currentLoadingBackdrop = instance;
} }
let newIndex = currentRotationIndex + 1; function getItemImageUrls(item, imageOptions) {
if (newIndex >= currentRotatingImages.length) { imageOptions = imageOptions || {};
newIndex = 0;
}
currentRotationIndex = newIndex; const apiClient = ServerConnections.getApiClient(item.ServerId);
setBackdropImage(currentRotatingImages[newIndex]); if (item.BackdropImageTags && item.BackdropImageTags.length > 0) {
return item.BackdropImageTags.map((imgTag, index) => {
return apiClient.getScaledImageUrl(item.BackdropItemId || item.Id, Object.assign(imageOptions, {
type: 'Backdrop',
tag: imgTag,
maxWidth: dom.getScreenWidth(),
index: index
}));
});
} }
function clearRotation() { if (item.ParentBackdropItemId && item.ParentBackdropImageTags && item.ParentBackdropImageTags.length) {
const interval = rotationInterval; return item.ParentBackdropImageTags.map((imgTag, index) => {
if (interval) { return apiClient.getScaledImageUrl(item.ParentBackdropItemId, Object.assign(imageOptions, {
clearInterval(interval); type: 'Backdrop',
} tag: imgTag,
maxWidth: dom.getScreenWidth(),
rotationInterval = null; index: index
currentRotatingImages = []; }));
currentRotationIndex = -1; });
} }
export function setBackdrop(url, imageOptions) { return [];
if (url && typeof url !== 'string') { }
url = getImageUrls([url], imageOptions)[0];
}
if (url) { function getImageUrls(items, imageOptions) {
clearRotation(); const list = [];
setBackdropImage(url); const onImg = img => {
list.push(img);
};
for (let i = 0, length = items.length; i < length; i++) {
const itemImages = getItemImageUrls(items[i], imageOptions);
itemImages.forEach(onImg);
}
return list;
}
function enabled() {
return userSettings.enableBackdrops();
}
let rotationInterval;
let currentRotatingImages = [];
let currentRotationIndex = -1;
export function setBackdrops(items, imageOptions, enableImageRotation) {
if (enabled()) {
const images = getImageUrls(items, imageOptions);
if (images.length) {
startRotation(images, enableImageRotation);
} else { } else {
clearBackdrop(); clearBackdrop();
} }
} }
}
/* eslint-enable indent */ function startRotation(images, enableImageRotation) {
if (isEqual(images, currentRotatingImages)) {
return;
}
clearRotation();
currentRotatingImages = images;
currentRotationIndex = -1;
if (images.length > 1 && enableImageRotation !== false && enableRotation()) {
rotationInterval = setInterval(onRotationInterval, 24000);
}
onRotationInterval();
}
function onRotationInterval() {
if (playbackManager.isPlayingLocally(['Video'])) {
return;
}
let newIndex = currentRotationIndex + 1;
if (newIndex >= currentRotatingImages.length) {
newIndex = 0;
}
currentRotationIndex = newIndex;
setBackdropImage(currentRotatingImages[newIndex]);
}
function clearRotation() {
const interval = rotationInterval;
if (interval) {
clearInterval(interval);
}
rotationInterval = null;
currentRotatingImages = [];
currentRotationIndex = -1;
}
export function setBackdrop(url, imageOptions) {
if (url && typeof url !== 'string') {
url = getImageUrls([url], imageOptions)[0];
}
if (url) {
clearRotation();
setBackdropImage(url);
} else {
clearBackdrop();
}
}
/** /**
* @enum TransparencyLevel * @enum TransparencyLevel

View file

@ -114,7 +114,7 @@ button::-moz-focus-inner {
} }
.card.show-animation:focus > .cardBox { .card.show-animation:focus > .cardBox {
transform: scale(1.18, 1.18); transform: scale(1.07, 1.07);
} }
.cardBox-bottompadded { .cardBox-bottompadded {

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,3 @@
/* eslint-disable indent */
/** /**
* Module for building cards from item data. * Module for building cards from item data.
@ -12,123 +11,121 @@ import layoutManager from '../layoutManager';
import browser from '../../scripts/browser'; import browser from '../../scripts/browser';
import ServerConnections from '../ServerConnections'; import ServerConnections from '../ServerConnections';
const enableFocusTransform = !browser.slow && !browser.edge; const enableFocusTransform = !browser.slow && !browser.edge;
function buildChapterCardsHtml(item, chapters, options) { function buildChapterCardsHtml(item, chapters, options) {
// TODO move card creation code to Card component // TODO move card creation code to Card component
let className = 'card itemAction chapterCard'; let className = 'card itemAction chapterCard';
if (layoutManager.tv) { if (layoutManager.tv) {
className += ' show-focus'; className += ' show-focus';
if (enableFocusTransform) { if (enableFocusTransform) {
className += ' show-animation'; className += ' show-animation';
}
} }
const mediaStreams = ((item.MediaSources || [])[0] || {}).MediaStreams || [];
const videoStream = mediaStreams.filter(({Type}) => {
return Type === 'Video';
})[0] || {};
let shape = (options.backdropShape || 'backdrop');
if (videoStream.Width && videoStream.Height && (videoStream.Width / videoStream.Height) <= 1.2) {
shape = (options.squareShape || 'square');
}
className += ` ${shape}Card`;
if (options.block || options.rows) {
className += ' block';
}
let html = '';
let itemsInRow = 0;
const apiClient = ServerConnections.getApiClient(item.ServerId);
for (let i = 0, length = chapters.length; i < length; i++) {
if (options.rows && itemsInRow === 0) {
html += '<div class="cardColumn">';
}
const chapter = chapters[i];
html += buildChapterCard(item, apiClient, chapter, i, options, className, shape);
itemsInRow++;
if (options.rows && itemsInRow >= options.rows) {
itemsInRow = 0;
html += '</div>';
}
}
return html;
} }
function getImgUrl({Id}, {ImageTag}, index, maxWidth, apiClient) { const mediaStreams = ((item.MediaSources || [])[0] || {}).MediaStreams || [];
if (ImageTag) { const videoStream = mediaStreams.filter(({ Type }) => {
return apiClient.getScaledImageUrl(Id, { return Type === 'Video';
})[0] || {};
maxWidth: maxWidth, let shape = (options.backdropShape || 'backdrop');
tag: ImageTag,
type: 'Chapter',
index
});
}
return null; if (videoStream.Width && videoStream.Height && (videoStream.Width / videoStream.Height) <= 1.2) {
shape = (options.squareShape || 'square');
} }
function buildChapterCard(item, apiClient, chapter, index, {width, coverImage}, className, shape) { className += ` ${shape}Card`;
const imgUrl = getImgUrl(item, chapter, index, width || 400, apiClient);
let cardImageContainerClass = 'cardContent cardContent-shadow cardImageContainer chapterCardImageContainer'; if (options.block || options.rows) {
if (coverImage) { className += ' block';
cardImageContainerClass += ' coveredImage';
}
const dataAttributes = ` data-action="play" data-isfolder="${item.IsFolder}" data-id="${item.Id}" data-serverid="${item.ServerId}" data-type="${item.Type}" data-mediatype="${item.MediaType}" data-positionticks="${chapter.StartPositionTicks}"`;
let cardImageContainer = imgUrl ? (`<div class="${cardImageContainerClass} lazy" data-src="${imgUrl}">`) : (`<div class="${cardImageContainerClass}">`);
if (!imgUrl) {
cardImageContainer += '<span class="material-icons cardImageIcon local_movies" aria-hidden="true"></span>';
}
let nameHtml = '';
nameHtml += `<div class="cardText">${escapeHtml(chapter.Name)}</div>`;
nameHtml += `<div class="cardText">${datetime.getDisplayRunningTime(chapter.StartPositionTicks)}</div>`;
const cardBoxCssClass = 'cardBox';
const cardScalableClass = 'cardScalable';
return `<button type="button" class="${className}"${dataAttributes}><div class="${cardBoxCssClass}"><div class="${cardScalableClass}"><div class="cardPadder-${shape}"></div>${cardImageContainer}</div><div class="innerCardFooter">${nameHtml}</div></div></div></button>`;
} }
export function buildChapterCards(item, chapters, options) { let html = '';
if (options.parentContainer) { let itemsInRow = 0;
// Abort if the container has been disposed
if (!document.body.contains(options.parentContainer)) {
return;
}
if (chapters.length) { const apiClient = ServerConnections.getApiClient(item.ServerId);
options.parentContainer.classList.remove('hide');
} else { for (let i = 0, length = chapters.length; i < length; i++) {
options.parentContainer.classList.add('hide'); if (options.rows && itemsInRow === 0) {
return; html += '<div class="cardColumn">';
}
} }
const html = buildChapterCardsHtml(item, chapters, options); const chapter = chapters[i];
options.itemsContainer.innerHTML = html; html += buildChapterCard(item, apiClient, chapter, i, options, className, shape);
itemsInRow++;
imageLoader.lazyChildren(options.itemsContainer); if (options.rows && itemsInRow >= options.rows) {
itemsInRow = 0;
html += '</div>';
}
} }
/* eslint-enable indent */ return html;
}
function getImgUrl({ Id }, { ImageTag }, index, maxWidth, apiClient) {
if (ImageTag) {
return apiClient.getScaledImageUrl(Id, {
maxWidth: maxWidth,
tag: ImageTag,
type: 'Chapter',
index
});
}
return null;
}
function buildChapterCard(item, apiClient, chapter, index, { width, coverImage }, className, shape) {
const imgUrl = getImgUrl(item, chapter, index, width || 400, apiClient);
let cardImageContainerClass = 'cardContent cardContent-shadow cardImageContainer chapterCardImageContainer';
if (coverImage) {
cardImageContainerClass += ' coveredImage';
}
const dataAttributes = ` data-action="play" data-isfolder="${item.IsFolder}" data-id="${item.Id}" data-serverid="${item.ServerId}" data-type="${item.Type}" data-mediatype="${item.MediaType}" data-positionticks="${chapter.StartPositionTicks}"`;
let cardImageContainer = imgUrl ? (`<div class="${cardImageContainerClass} lazy" data-src="${imgUrl}">`) : (`<div class="${cardImageContainerClass}">`);
if (!imgUrl) {
cardImageContainer += '<span class="material-icons cardImageIcon local_movies" aria-hidden="true"></span>';
}
let nameHtml = '';
nameHtml += `<div class="cardText">${escapeHtml(chapter.Name)}</div>`;
nameHtml += `<div class="cardText">${datetime.getDisplayRunningTime(chapter.StartPositionTicks)}</div>`;
const cardBoxCssClass = 'cardBox';
const cardScalableClass = 'cardScalable';
return `<button type="button" class="${className}"${dataAttributes}><div class="${cardBoxCssClass}"><div class="${cardScalableClass}"><div class="cardPadder-${shape}"></div>${cardImageContainer}</div><div class="innerCardFooter">${nameHtml}</div></div></div></button>`;
}
export function buildChapterCards(item, chapters, options) {
if (options.parentContainer) {
// Abort if the container has been disposed
if (!document.body.contains(options.parentContainer)) {
return;
}
if (chapters.length) {
options.parentContainer.classList.remove('hide');
} else {
options.parentContainer.classList.add('hide');
return;
}
}
const html = buildChapterCardsHtml(item, chapters, options);
options.itemsContainer.innerHTML = html;
imageLoader.lazyChildren(options.itemsContainer);
}
export default { export default {
buildChapterCards: buildChapterCards buildChapterCards: buildChapterCards

View file

@ -1,4 +1,3 @@
/* eslint-disable indent */
/** /**
* Module for building cards from item data. * Module for building cards from item data.
@ -7,20 +6,18 @@
import cardBuilder from './cardBuilder'; import cardBuilder from './cardBuilder';
export function buildPeopleCards(items, options) { export function buildPeopleCards(items, options) {
options = Object.assign(options || {}, { options = Object.assign(options || {}, {
cardLayout: false, cardLayout: false,
centerText: true, centerText: true,
showTitle: true, showTitle: true,
cardFooterAside: 'none', cardFooterAside: 'none',
showPersonRoleOrType: true, showPersonRoleOrType: true,
cardCssClass: 'personCard', cardCssClass: 'personCard',
defaultCardImageIcon: 'person' defaultCardImageIcon: 'person'
}); });
cardBuilder.buildCards(items, options); cardBuilder.buildCards(items, options);
} }
/* eslint-enable indent */
export default { export default {
buildPeopleCards: buildPeopleCards buildPeopleCards: buildPeopleCards

View file

@ -3,7 +3,7 @@ import dom from '../../scripts/dom';
import dialogHelper from '../dialogHelper/dialogHelper'; import dialogHelper from '../dialogHelper/dialogHelper';
import loading from '../loading/loading'; import loading from '../loading/loading';
import layoutManager from '../layoutManager'; import layoutManager from '../layoutManager';
import { appRouter } from '../appRouter'; import { appRouter } from '../router/appRouter';
import globalize from '../../scripts/globalize'; import globalize from '../../scripts/globalize';
import '../../elements/emby-button/emby-button'; import '../../elements/emby-button/emby-button';
import '../../elements/emby-button/paper-icon-button-light'; import '../../elements/emby-button/paper-icon-button-light';
@ -12,258 +12,255 @@ import '../../elements/emby-input/emby-input';
import '../../elements/emby-select/emby-select'; import '../../elements/emby-select/emby-select';
import 'material-design-icons-iconfont'; import 'material-design-icons-iconfont';
import '../formdialog.scss'; import '../formdialog.scss';
import '../../assets/css/flexstyles.scss'; import '../../styles/flexstyles.scss';
import ServerConnections from '../ServerConnections'; import ServerConnections from '../ServerConnections';
import toast from '../toast/toast'; import toast from '../toast/toast';
/* eslint-disable indent */ let currentServerId;
let currentServerId; function onSubmit(e) {
loading.show();
function onSubmit(e) { const panel = dom.parentWithClass(this, 'dialog');
loading.show();
const panel = dom.parentWithClass(this, 'dialog'); const collectionId = panel.querySelector('#selectCollectionToAddTo').value;
const collectionId = panel.querySelector('#selectCollectionToAddTo').value; const apiClient = ServerConnections.getApiClient(currentServerId);
const apiClient = ServerConnections.getApiClient(currentServerId); if (collectionId) {
addToCollection(apiClient, panel, collectionId);
if (collectionId) { } else {
addToCollection(apiClient, panel, collectionId); createCollection(apiClient, panel);
} else {
createCollection(apiClient, panel);
}
e.preventDefault();
return false;
} }
function createCollection(apiClient, dlg) { e.preventDefault();
const url = apiClient.getUrl('Collections', { return false;
}
Name: dlg.querySelector('#txtNewCollectionName').value, function createCollection(apiClient, dlg) {
IsLocked: !dlg.querySelector('#chkEnableInternetMetadata').checked, const url = apiClient.getUrl('Collections', {
Ids: dlg.querySelector('.fldSelectedItemIds').value || ''
});
apiClient.ajax({ Name: dlg.querySelector('#txtNewCollectionName').value,
type: 'POST', IsLocked: !dlg.querySelector('#chkEnableInternetMetadata').checked,
url: url, Ids: dlg.querySelector('.fldSelectedItemIds').value || ''
dataType: 'json' });
}).then(result => { apiClient.ajax({
loading.hide(); type: 'POST',
url: url,
dataType: 'json'
const id = result.Id; }).then(result => {
loading.hide();
dlg.submitted = true; const id = result.Id;
dialogHelper.close(dlg);
redirectToCollection(apiClient, id);
});
}
function redirectToCollection(apiClient, id) { dlg.submitted = true;
appRouter.showItem(id, apiClient.serverId()); dialogHelper.close(dlg);
} redirectToCollection(apiClient, id);
});
}
function addToCollection(apiClient, dlg, id) { function redirectToCollection(apiClient, id) {
const url = apiClient.getUrl(`Collections/${id}/Items`, { appRouter.showItem(id, apiClient.serverId());
}
Ids: dlg.querySelector('.fldSelectedItemIds').value || '' function addToCollection(apiClient, dlg, id) {
}); const url = apiClient.getUrl(`Collections/${id}/Items`, {
apiClient.ajax({ Ids: dlg.querySelector('.fldSelectedItemIds').value || ''
type: 'POST', });
url: url
}).then(() => { apiClient.ajax({
loading.hide(); type: 'POST',
url: url
dlg.submitted = true; }).then(() => {
dialogHelper.close(dlg); loading.hide();
toast(globalize.translate('MessageItemsAdded')); dlg.submitted = true;
}); dialogHelper.close(dlg);
}
function triggerChange(select) { toast(globalize.translate('MessageItemsAdded'));
select.dispatchEvent(new CustomEvent('change', {})); });
} }
function populateCollections(panel) { function triggerChange(select) {
loading.show(); select.dispatchEvent(new CustomEvent('change', {}));
}
const select = panel.querySelector('#selectCollectionToAddTo'); function populateCollections(panel) {
loading.show();
panel.querySelector('.newCollectionInfo').classList.add('hide'); const select = panel.querySelector('#selectCollectionToAddTo');
const options = { panel.querySelector('.newCollectionInfo').classList.add('hide');
Recursive: true, const options = {
IncludeItemTypes: 'BoxSet',
SortBy: 'SortName',
EnableTotalRecordCount: false
};
const apiClient = ServerConnections.getApiClient(currentServerId); Recursive: true,
apiClient.getItems(apiClient.getCurrentUserId(), options).then(result => { IncludeItemTypes: 'BoxSet',
let html = ''; SortBy: 'SortName',
EnableTotalRecordCount: false
};
html += `<option value="">${globalize.translate('OptionNew')}</option>`; const apiClient = ServerConnections.getApiClient(currentServerId);
apiClient.getItems(apiClient.getCurrentUserId(), options).then(result => {
html += result.Items.map(i => {
return `<option value="${i.Id}">${escapeHtml(i.Name)}</option>`;
});
select.innerHTML = html;
select.value = '';
triggerChange(select);
loading.hide();
});
}
function getEditorHtml() {
let html = ''; let html = '';
html += '<div class="formDialogContent smoothScrollY" style="padding-top:2em;">'; html += `<option value="">${globalize.translate('OptionNew')}</option>`;
html += '<div class="dialogContentInner dialog-content-centered">';
html += '<form class="newCollectionForm" style="margin:auto;">';
html += '<div>'; html += result.Items.map(i => {
html += globalize.translate('NewCollectionHelp'); return `<option value="${i.Id}">${escapeHtml(i.Name)}</option>`;
html += '</div>';
html += '<div class="fldSelectCollection">';
html += '<br/>';
html += '<br/>';
html += '<div class="selectContainer">';
html += `<select is="emby-select" label="${globalize.translate('LabelCollection')}" id="selectCollectionToAddTo" autofocus></select>`;
html += '</div>';
html += '</div>';
html += '<div class="newCollectionInfo">';
html += '<div class="inputContainer">';
html += `<input is="emby-input" type="text" id="txtNewCollectionName" required="required" label="${globalize.translate('LabelName')}" />`;
html += `<div class="fieldDescription">${globalize.translate('NewCollectionNameExample')}</div>`;
html += '</div>';
html += '<label class="checkboxContainer">';
html += '<input is="emby-checkbox" type="checkbox" id="chkEnableInternetMetadata" />';
html += `<span>${globalize.translate('SearchForCollectionInternetMetadata')}</span>`;
html += '</label>';
// newCollectionInfo
html += '</div>';
html += '<div class="formDialogFooter">';
html += `<button is="emby-button" type="submit" class="raised btnSubmit block formDialogFooterItem button-submit">${globalize.translate('ButtonOk')}</button>`;
html += '</div>';
html += '<input type="hidden" class="fldSelectedItemIds" />';
html += '</form>';
html += '</div>';
html += '</div>';
return html;
}
function initEditor(content, items) {
content.querySelector('#selectCollectionToAddTo').addEventListener('change', function () {
if (this.value) {
content.querySelector('.newCollectionInfo').classList.add('hide');
content.querySelector('#txtNewCollectionName').removeAttribute('required');
} else {
content.querySelector('.newCollectionInfo').classList.remove('hide');
content.querySelector('#txtNewCollectionName').setAttribute('required', 'required');
}
}); });
content.querySelector('form').addEventListener('submit', onSubmit); select.innerHTML = html;
select.value = '';
triggerChange(select);
content.querySelector('.fldSelectedItemIds', content).value = items.join(','); loading.hide();
});
}
if (items.length) { function getEditorHtml() {
content.querySelector('.fldSelectCollection').classList.remove('hide'); let html = '';
populateCollections(content);
html += '<div class="formDialogContent smoothScrollY" style="padding-top:2em;">';
html += '<div class="dialogContentInner dialog-content-centered">';
html += '<form class="newCollectionForm" style="margin:auto;">';
html += '<div>';
html += globalize.translate('NewCollectionHelp');
html += '</div>';
html += '<div class="fldSelectCollection">';
html += '<br/>';
html += '<br/>';
html += '<div class="selectContainer">';
html += `<select is="emby-select" label="${globalize.translate('LabelCollection')}" id="selectCollectionToAddTo" autofocus></select>`;
html += '</div>';
html += '</div>';
html += '<div class="newCollectionInfo">';
html += '<div class="inputContainer">';
html += `<input is="emby-input" type="text" id="txtNewCollectionName" required="required" label="${globalize.translate('LabelName')}" />`;
html += `<div class="fieldDescription">${globalize.translate('NewCollectionNameExample')}</div>`;
html += '</div>';
html += '<label class="checkboxContainer">';
html += '<input is="emby-checkbox" type="checkbox" id="chkEnableInternetMetadata" />';
html += `<span>${globalize.translate('SearchForCollectionInternetMetadata')}</span>`;
html += '</label>';
// newCollectionInfo
html += '</div>';
html += '<div class="formDialogFooter">';
html += `<button is="emby-button" type="submit" class="raised btnSubmit block formDialogFooterItem button-submit">${globalize.translate('ButtonOk')}</button>`;
html += '</div>';
html += '<input type="hidden" class="fldSelectedItemIds" />';
html += '</form>';
html += '</div>';
html += '</div>';
return html;
}
function initEditor(content, items) {
content.querySelector('#selectCollectionToAddTo').addEventListener('change', function () {
if (this.value) {
content.querySelector('.newCollectionInfo').classList.add('hide');
content.querySelector('#txtNewCollectionName').removeAttribute('required');
} else { } else {
content.querySelector('.fldSelectCollection').classList.add('hide'); content.querySelector('.newCollectionInfo').classList.remove('hide');
content.querySelector('#txtNewCollectionName').setAttribute('required', 'required');
const selectCollectionToAddTo = content.querySelector('#selectCollectionToAddTo');
selectCollectionToAddTo.innerHTML = '';
selectCollectionToAddTo.value = '';
triggerChange(selectCollectionToAddTo);
} }
} });
function centerFocus(elem, horiz, on) { content.querySelector('form').addEventListener('submit', onSubmit);
import('../../scripts/scrollHelper').then((scrollHelper) => {
const fn = on ? 'on' : 'off'; content.querySelector('.fldSelectedItemIds', content).value = items.join(',');
scrollHelper.centerFocus[fn](elem, horiz);
if (items.length) {
content.querySelector('.fldSelectCollection').classList.remove('hide');
populateCollections(content);
} else {
content.querySelector('.fldSelectCollection').classList.add('hide');
const selectCollectionToAddTo = content.querySelector('#selectCollectionToAddTo');
selectCollectionToAddTo.innerHTML = '';
selectCollectionToAddTo.value = '';
triggerChange(selectCollectionToAddTo);
}
}
function centerFocus(elem, horiz, on) {
import('../../scripts/scrollHelper').then((scrollHelper) => {
const fn = on ? 'on' : 'off';
scrollHelper.centerFocus[fn](elem, horiz);
});
}
class CollectionEditor {
show(options) {
const items = options.items || {};
currentServerId = options.serverId;
const dialogOptions = {
removeOnClose: true,
scrollY: false
};
if (layoutManager.tv) {
dialogOptions.size = 'fullscreen';
} else {
dialogOptions.size = 'small';
}
const dlg = dialogHelper.createDialog(dialogOptions);
dlg.classList.add('formDialog');
let html = '';
const title = items.length ? globalize.translate('HeaderAddToCollection') : globalize.translate('NewCollection');
html += '<div class="formDialogHeader">';
html += `<button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1" title="${globalize.translate('ButtonBack')}"><span class="material-icons arrow_back" aria-hidden="true"></span></button>`;
html += '<h3 class="formDialogHeaderTitle">';
html += title;
html += '</h3>';
html += '</div>';
html += getEditorHtml();
dlg.innerHTML = html;
initEditor(dlg, items);
dlg.querySelector('.btnCancel').addEventListener('click', () => {
dialogHelper.close(dlg);
});
if (layoutManager.tv) {
centerFocus(dlg.querySelector('.formDialogContent'), false, true);
}
return dialogHelper.open(dlg).then(() => {
if (layoutManager.tv) {
centerFocus(dlg.querySelector('.formDialogContent'), false, false);
}
if (dlg.submitted) {
return Promise.resolve();
}
return Promise.reject();
}); });
} }
}
class CollectionEditor {
show(options) {
const items = options.items || {};
currentServerId = options.serverId;
const dialogOptions = {
removeOnClose: true,
scrollY: false
};
if (layoutManager.tv) {
dialogOptions.size = 'fullscreen';
} else {
dialogOptions.size = 'small';
}
const dlg = dialogHelper.createDialog(dialogOptions);
dlg.classList.add('formDialog');
let html = '';
const title = items.length ? globalize.translate('HeaderAddToCollection') : globalize.translate('NewCollection');
html += '<div class="formDialogHeader">';
html += `<button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1" title="${globalize.translate('ButtonBack')}"><span class="material-icons arrow_back" aria-hidden="true"></span></button>`;
html += '<h3 class="formDialogHeaderTitle">';
html += title;
html += '</h3>';
html += '</div>';
html += getEditorHtml();
dlg.innerHTML = html;
initEditor(dlg, items);
dlg.querySelector('.btnCancel').addEventListener('click', () => {
dialogHelper.close(dlg);
});
if (layoutManager.tv) {
centerFocus(dlg.querySelector('.formDialogContent'), false, true);
}
return dialogHelper.open(dlg).then(() => {
if (layoutManager.tv) {
centerFocus(dlg.querySelector('.formDialogContent'), false, false);
}
if (dlg.submitted) {
return Promise.resolve();
}
return Promise.reject();
});
}
}
/* eslint-enable indent */
export default CollectionEditor; export default CollectionEditor;

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