Compare commits

..

2 Commits

Author SHA1 Message Date
Bond_009
c8c2956cc2 Remove unused import 2024-08-16 17:24:00 +02:00
Bond_009
38dd4c5f43 Don't sort media streams for media info
This way it keeps a logical order i.e. video info first, followed by
audio and subtitles
2024-08-16 17:23:31 +02:00
363 changed files with 8617 additions and 21591 deletions

View File

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

View File

@@ -1,5 +1,4 @@
node_modules
coverage
dist
.idea
.vscode

View File

@@ -4,10 +4,10 @@ module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: [
'@stylistic',
'@typescript-eslint',
'react',
'import',
'eslint-comments',
'sonarjs'
],
env: {
@@ -20,14 +20,23 @@ module.exports = {
'eslint:recommended',
'plugin:react/recommended',
'plugin:import/errors',
'plugin:@eslint-community/eslint-comments/recommended',
'plugin:eslint-comments/recommended',
'plugin:compat/recommended',
'plugin:sonarjs/recommended'
],
rules: {
'array-callback-return': ['error', { 'checkForEach': true }],
'block-spacing': ['error'],
'brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
'comma-dangle': ['error', 'never'],
'comma-spacing': ['error'],
'curly': ['error', 'multi-line', 'consistent'],
'default-case-last': ['error'],
'eol-last': ['error'],
'indent': ['error', 4, { 'SwitchCase': 1 }],
'jsx-quotes': ['error', 'prefer-single'],
'keyword-spacing': ['error'],
'max-statements-per-line': ['error'],
'max-params': ['error', 7],
'new-cap': [
'error',
@@ -39,7 +48,10 @@ module.exports = {
'no-duplicate-imports': ['error'],
'no-empty-function': ['error'],
'no-extend-native': ['error'],
'no-floating-decimal': ['error'],
'no-lonely-if': ['error'],
'no-multi-spaces': ['error'],
'no-multiple-empty-lines': ['error', { 'max': 1 }],
'no-nested-ternary': ['error'],
'no-redeclare': ['off'],
'@typescript-eslint/no-redeclare': ['error', { builtinGlobals: false }],
@@ -50,6 +62,7 @@ module.exports = {
'no-shadow': ['off'],
'@typescript-eslint/no-shadow': ['error'],
'no-throw-literal': ['error'],
'no-trailing-spaces': ['error'],
'no-undef-init': ['error'],
'no-unneeded-ternary': ['error'],
'no-unused-expressions': ['off'],
@@ -61,12 +74,19 @@ module.exports = {
'no-var': ['error'],
'no-void': ['error', { 'allowAsStatement': true }],
'no-warning-comments': ['warn', { 'terms': ['fixme', 'hack', 'xxx'] }],
'object-curly-spacing': ['error', 'always'],
'one-var': ['error', 'never'],
'operator-linebreak': ['error', 'before', { overrides: { '?': 'after', ':': 'after', '=': 'after' } }],
'padded-blocks': ['error', 'never'],
'prefer-const': ['error', { 'destructuring': 'all' }],
'prefer-promise-reject-errors': ['warn', { 'allowEmptyReject': true }],
'@typescript-eslint/prefer-for-of': ['error'],
'@typescript-eslint/prefer-optional-chain': ['error'],
'quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': false }],
'radix': ['error'],
'@typescript-eslint/semi': ['error'],
'space-before-blocks': ['error'],
'space-infix-ops': 'error',
'yoda': 'error',
'react/jsx-filename-extension': ['error', { 'extensions': ['.jsx', '.tsx'] }],
@@ -78,28 +98,7 @@ module.exports = {
'sonarjs/no-inverted-boolean-check': ['error'],
// TODO: Enable the following rules and fix issues
'sonarjs/cognitive-complexity': ['off'],
'sonarjs/no-duplicate-string': ['off'],
'@stylistic/block-spacing': ['error'],
'@stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
'@stylistic/comma-dangle': ['error', 'never'],
'@stylistic/comma-spacing': ['error'],
'@stylistic/eol-last': ['error'],
'@stylistic/indent': ['error', 4, { 'SwitchCase': 1 }],
'@stylistic/jsx-quotes': ['error', 'prefer-single'],
'@stylistic/keyword-spacing': ['error'],
'@stylistic/max-statements-per-line': ['error'],
'@stylistic/no-floating-decimal': ['error'],
'@stylistic/no-multi-spaces': ['error'],
'@stylistic/no-multiple-empty-lines': ['error', { 'max': 1 }],
'@stylistic/no-trailing-spaces': ['error'],
'@stylistic/object-curly-spacing': ['error', 'always'],
'@stylistic/operator-linebreak': ['error', 'before', { overrides: { '?': 'after', ':': 'after', '=': 'after' } }],
'@stylistic/padded-blocks': ['error', 'never'],
'@stylistic/quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': false }],
'@stylistic/semi': ['error'],
'@stylistic/space-before-blocks': ['error'],
'@stylistic/space-infix-ops': ['error']
'sonarjs/no-duplicate-string': ['off']
},
settings: {
react: {
@@ -285,7 +284,7 @@ module.exports = {
'eslint:recommended',
'plugin:import/typescript',
'plugin:@typescript-eslint/recommended',
'plugin:@eslint-community/eslint-comments/recommended',
'plugin:eslint-comments/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended'

14
.github/renovate.json vendored
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,45 +0,0 @@
name: Packaging 📦
on:
workflow_call:
inputs:
commit:
required: false
type: string
jobs:
run-build-prod:
name: Run production build 🏗️
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.commit || github.sha }}
- name: Setup node environment
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: 20
cache: npm
check-latest: true
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run a production build
env:
JELLYFIN_VERSION: ${{ inputs.commit || github.sha }}
run: npm run build:production
- name: Update config.json for testing
run: |
jq '.multiserver=true | .servers=["https://demo.jellyfin.org/unstable"]' dist/config.json > dist/config.tmp.json
mv dist/config.tmp.json dist/config.json
- name: Upload artifact
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: frontend
path: dist

View File

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

View File

@@ -1,12 +1,20 @@
name: Automation 🎛️
name: Automation
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
on:
workflow_call:
push:
branches:
- master
pull_request_target:
jobs:
conflicts:
name: Merge conflict labeling 🏷️
name: Merge conflict labeling
runs-on: ubuntu-latest
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
steps:
- uses: eps1lon/actions-label-merge-conflict@1b1b1fcde06a9b3d089f3464c96417961dde1168 # v3.0.2
with:

129
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,129 @@
name: Build
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
on:
push:
branches: [ master, release* ]
pull_request_target:
branches: [ master, release* ]
workflow_dispatch:
jobs:
run-build-prod:
name: Run production build
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup node environment
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
with:
node-version: 20
check-latest: true
cache: npm
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run a production build
env:
JELLYFIN_VERSION: ${{ github.event.pull_request.head.sha || github.sha }}
run: npm run build:production
- name: Update config.json for testing
run: |
jq '.multiserver=true | .servers=["https://demo.jellyfin.org/unstable"]' dist/config.json > dist/config.tmp.json
mv dist/config.tmp.json dist/config.json
- name: Upload artifact
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: jellyfin-web__prod
path: dist
publish:
name: Deploy to Cloudflare Pages
runs-on: ubuntu-latest
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
needs:
- run-build-prod
permissions:
contents: read
deployments: write
steps:
- name: Add comment
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
if: ${{ github.event_name == 'pull_request_target' }}
with:
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
message: |
## Cloudflare Pages deployment
| **Latest commit** | <code>${{ github.event.pull_request.head.sha || github.sha }}</code> |
|-------------------|:-:|
| **Status** | 🔄 Deploying... |
| **Preview URL** | Not available |
| **Type** | 🔀 Preview |
pr_number: ${{ github.event.pull_request.number }}
comment_tag: CFPages-deployment
mode: recreate
- name: Download workflow artifact
uses: dawidd6/action-download-artifact@09f2f74827fd3a8607589e5ad7f9398816f540fe # v3.1.4
with:
name: jellyfin-web__prod
path: dist
- name: Publish to Cloudflare
id: cf
uses: cloudflare/wrangler-action@f84a562284fc78278ff9052435d9526f9c718361 # v3.7.0
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name=jellyfin-web --branch=${{
(github.event_name != 'pull_request_target' || github.event.pull_request.head.repo.full_name == github.repository)
&& (github.event.pull_request.head.ref || github.ref_name)
|| format('{0}/{1}', github.event.pull_request.head.repo.full_name, github.event.pull_request.head.ref)
}} --commit-hash=${{ github.event.pull_request.head.sha || github.sha }}
- name: Update status comment (Success)
if: ${{ github.event_name == 'pull_request_target' && success() }}
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
message: |
## Cloudflare Pages deployment
| **Latest commit** | <code>${{ github.event.pull_request.head.sha || github.sha }}</code> |
|-------------------|:-:|
| **Status** | ✅ Deployed! |
| **Preview URL** | ${{ steps.cf.outputs.deployment-url != '' && steps.cf.outputs.deployment-url || 'Not available' }} |
| **Type** | 🔀 Preview |
pr_number: ${{ github.event.pull_request.number }}
comment_tag: CFPages-deployment
mode: recreate
- name: Update status comment (Failure)
if: ${{ github.event_name == 'pull_request_target' && failure() }}
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
message: |
## Cloudflare Pages deployment
| **Latest commit** | <code>${{ github.event.pull_request.head.sha || github.sha }}</code> |
|-------------------|:-:|
| **Status** | ❌ Failure. Check workflow logs for details |
| **Preview URL** | Not available |
| **Type** | 🔀 Preview |
pr_number: ${{ github.event.pull_request.number }}
comment_tag: CFPages-deployment
mode: recreate

34
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: CodeQL
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
on:
push:
branches: [ master, release* ]
pull_request:
branches: [ master, release* ]
schedule:
- cron: '30 7 * * 6'
jobs:
codeql:
name: Run CodeQL
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Initialize CodeQL
uses: github/codeql-action/init@eb055d739abdc2e8de2e5f4ba1a8b246daa779aa # v3.26.0
with:
languages: javascript
queries: +security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@eb055d739abdc2e8de2e5f4ba1a8b246daa779aa # v3.26.0
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@eb055d739abdc2e8de2e5f4ba1a8b246daa779aa # v3.26.0

36
.github/workflows/commands.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Commands
on:
issue_comment:
types:
- created
- edited
jobs:
rebase:
name: Rebase
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '@jellyfin-bot rebase') && github.event.comment.author_association == 'MEMBER'
runs-on: ubuntu-latest
steps:
- name: Notify as seen
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
with:
token: ${{ secrets.JF_BOT_TOKEN }}
comment-id: ${{ github.event.comment.id }}
reactions: '+1'
- name: Checkout the latest code
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0
- name: Automatic Rebase
uses: cirrus-actions/rebase@b87d48154a87a85666003575337e27b8cd65f691 # 1.8
env:
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
- name: Comment on failure
if: failure()
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
with:
token: ${{ secrets.JF_BOT_TOKEN }}
issue-number: ${{ github.event.issue.number }}
body: |
I'm sorry @${{ github.event.comment.user.login }}, I'm afraid I can't do that.

36
.github/workflows/pr-suggestions.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: PR suggestions
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.id || github.run_id }}
cancel-in-progress: true
on:
pull_request_target:
branches: [ master, release* ]
jobs:
run-eslint:
name: Run eslint suggestions
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup node environment
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
with:
node-version: 20
check-latest: true
cache: npm
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run eslint
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
uses: CatChen/eslint-suggestion-action@bc82950fa97bb3e46d9cca16a8bf2ad3e3c010fc # v4.1.5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

View File

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

View File

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

123
.github/workflows/quality.yml vendored Normal file
View File

@@ -0,0 +1,123 @@
name: Quality checks
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
on:
push:
branches: [ master, release* ]
pull_request:
branches: [ master, release* ]
jobs:
run-escheck:
name: Run es-check
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Setup node environment
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
with:
node-version: 20
check-latest: true
cache: npm
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run a production build
run: npm run build:production
- name: Run es-check
run: npm run escheck
run-eslint:
name: Run eslint
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Setup node environment
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
with:
node-version: 20
check-latest: true
cache: npm
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run eslint
run: npx eslint --quiet "."
run-stylelint:
name: Run stylelint
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Setup node environment
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
with:
node-version: 20
check-latest: true
cache: npm
- name: Set up stylelint matcher
uses: xt0rted/stylelint-problem-matcher@34db1b874c0452909f0696aedef70b723870a583 # tag=v1
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run stylelint
run: npm run stylelint
run-tsc:
name: Run TypeScript build check
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Setup node environment
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
with:
node-version: 20
check-latest: true
cache: npm
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run tsc
run: npm run build:check
run-test:
name: Run tests
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Setup node environment
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
with:
node-version: 20
check-latest: true
cache: npm
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run test suite
run: npm run test

View File

@@ -1,9 +1,10 @@
name: Scheduled tasks 🕑
name: Stale Check
on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write

52
.github/workflows/update-sdk.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Update the Jellyfin SDK
on:
schedule:
- cron: '0 7 * * *'
workflow_dispatch:
concurrency:
group: unstable-sdk-pr
cancel-in-progress: true
jobs:
update:
runs-on: ubuntu-latest
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
steps:
- name: Check out Git repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
ref: master
token: ${{ secrets.JF_BOT_TOKEN }}
- name: Set up Node.js
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
with:
node-version: 20
check-latest: true
cache: npm
- name: Install latest unstable SDK
run: |
npm i --save @jellyfin/sdk@unstable
VERSION=$(jq -r '.dependencies["@jellyfin/sdk"]' package.json)
echo "JF_SDK_VERSION=${VERSION}" >> $GITHUB_ENV
- name: Open a pull request
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
with:
token: ${{ secrets.JF_BOT_TOKEN }}
commit-message: Update @jellyfin/sdk to ${{env.JF_SDK_VERSION}}
committer: jellyfin-bot <team@jellyfin.org>
author: jellyfin-bot <team@jellyfin.org>
branch: update-jf-sdk
delete-branch: true
title: Update @jellyfin/sdk to ${{env.JF_SDK_VERSION}}
body: |
**Changes**
Updates to the latest unstable @jellyfin/sdk build
labels: |
dependencies
npm

1
.nvmrc
View File

@@ -1 +0,0 @@
20

View File

@@ -92,7 +92,6 @@
- [Venkat Karasani](https://github.com/venkat-karasani)
- [Connor Smith](https://github.com/ConnorS1110)
- [iFraan](https://github.com/iFraan)
- [Ali](https://github.com/bu3alwa)
## Emby Contributors

View File

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

11665
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +1,58 @@
{
"name": "jellyfin-web",
"version": "10.10.7",
"version": "10.10.0",
"description": "Web interface for Jellyfin",
"repository": "https://github.com/jellyfin/jellyfin-web",
"license": "GPL-2.0-or-later",
"devDependencies": {
"@babel/core": "7.25.8",
"@babel/plugin-transform-modules-umd": "7.25.7",
"@babel/preset-env": "7.25.8",
"@babel/preset-react": "7.25.7",
"@eslint-community/eslint-plugin-eslint-comments": "4.4.0",
"@stylistic/eslint-plugin": "2.9.0",
"@babel/core": "7.24.9",
"@babel/plugin-transform-class-properties": "7.24.7",
"@babel/plugin-transform-modules-umd": "7.24.7",
"@babel/plugin-transform-private-methods": "7.24.7",
"@babel/preset-env": "7.24.8",
"@babel/preset-react": "7.24.7",
"@types/dompurify": "3.0.5",
"@types/escape-html": "1.0.4",
"@types/loadable__component": "5.13.9",
"@types/lodash-es": "4.17.12",
"@types/markdown-it": "14.1.2",
"@types/react": "18.3.11",
"@types/react-dom": "18.3.1",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"@types/sortablejs": "1.15.8",
"@typescript-eslint/eslint-plugin": "5.62.0",
"@typescript-eslint/parser": "5.62.0",
"@uupaa/dynamic-import-polyfill": "1.0.2",
"@vitest/coverage-v8": "2.1.3",
"autoprefixer": "10.4.20",
"babel-loader": "9.2.1",
"@vitest/coverage-v8": "2.0.5",
"autoprefixer": "10.4.19",
"babel-loader": "9.1.3",
"babel-plugin-dynamic-import-polyfill": "1.0.0",
"clean-webpack-plugin": "4.0.0",
"confusing-browser-globals": "1.0.11",
"copy-webpack-plugin": "12.0.2",
"cross-env": "7.0.3",
"css-loader": "7.1.2",
"cssnano": "7.0.6",
"cssnano": "7.0.4",
"es-check": "7.2.1",
"eslint": "8.57.1",
"eslint": "8.57.0",
"eslint-plugin-compat": "4.2.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jsx-a11y": "6.10.0",
"eslint-plugin-react": "7.37.1",
"eslint-plugin-eslint-comments": "3.2.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jsx-a11y": "6.9.0",
"eslint-plugin-react": "7.35.0",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-sonarjs": "0.25.1",
"expose-loader": "5.0.0",
"fork-ts-checker-webpack-plugin": "9.0.2",
"html-loader": "5.1.0",
"html-webpack-plugin": "5.6.0",
"jsdom": "25.0.1",
"mini-css-extract-plugin": "2.9.1",
"postcss": "8.4.47",
"jsdom": "24.1.1",
"mini-css-extract-plugin": "2.9.0",
"postcss": "8.4.40",
"postcss-loader": "8.1.1",
"postcss-preset-env": "10.0.7",
"postcss-preset-env": "9.6.0",
"postcss-scss": "4.0.9",
"sass": "1.79.5",
"sass-loader": "16.0.2",
"sass": "1.77.8",
"sass-loader": "15.0.0",
"source-map-loader": "5.0.0",
"speed-measure-webpack-plugin": "1.5.0",
"style-loader": "4.0.0",
@@ -60,75 +62,70 @@
"stylelint-order": "6.0.4",
"stylelint-scss": "5.3.2",
"ts-loader": "9.5.1",
"typescript": "5.6.3",
"vitest": "2.1.3",
"webpack": "5.95.0",
"typescript": "5.5.4",
"vitest": "2.0.5",
"webpack": "5.93.0",
"webpack-bundle-analyzer": "4.10.2",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.1.0",
"webpack-dev-server": "5.0.4",
"webpack-merge": "6.0.1",
"worker-loader": "3.0.8"
},
"dependencies": {
"@emotion/react": "11.13.3",
"@emotion/react": "11.13.0",
"@emotion/styled": "11.13.0",
"@fontsource/noto-sans": "5.1.0",
"@fontsource/noto-sans-hk": "5.1.0",
"@fontsource/noto-sans-jp": "5.1.0",
"@fontsource/noto-sans-kr": "5.1.0",
"@fontsource/noto-sans-sc": "5.1.0",
"@fontsource/noto-sans-tc": "5.1.0",
"@jellyfin/libass-wasm": "4.2.3",
"@jellyfin/sdk": "0.0.0-unstable.202410250501",
"@mui/icons-material": "5.16.7",
"@mui/material": "5.16.7",
"@mui/x-date-pickers": "7.20.0",
"@react-hook/resize-observer": "2.0.2",
"@tanstack/react-query": "5.59.13",
"@tanstack/react-query-devtools": "5.59.13",
"@fontsource/noto-sans": "5.0.22",
"@fontsource/noto-sans-hk": "5.0.20",
"@fontsource/noto-sans-jp": "5.0.19",
"@fontsource/noto-sans-kr": "5.0.19",
"@fontsource/noto-sans-sc": "5.0.20",
"@fontsource/noto-sans-tc": "5.0.20",
"@jellyfin/libass-wasm": "4.2.2",
"@jellyfin/sdk": "0.0.0-unstable.202408050429",
"@mui/icons-material": "5.15.19",
"@mui/material": "5.15.19",
"@mui/x-data-grid": "7.6.1",
"@react-hook/resize-observer": "2.0.1",
"@tanstack/react-query": "5.51.11",
"@tanstack/react-query-devtools": "5.51.11",
"@types/react-lazy-load-image-component": "1.6.4",
"abortcontroller-polyfill": "1.7.5",
"blurhash": "2.0.5",
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
"classnames": "2.5.1",
"core-js": "3.38.1",
"core-js": "3.37.1",
"date-fns": "2.30.0",
"dompurify": "2.5.7",
"dompurify": "3.0.1",
"epubjs": "0.3.93",
"escape-html": "1.0.3",
"fast-text-encoding": "1.0.6",
"flv.js": "1.6.2",
"headroom.js": "0.12.0",
"history": "5.3.0",
"hls.js": "1.5.16",
"hls.js": "1.5.13",
"intersection-observer": "0.12.2",
"jellyfin-apiclient": "1.11.0",
"jquery": "3.7.1",
"jstree": "3.3.17",
"libarchive.js": "2.0.2",
"libpgs": "0.8.1",
"jstree": "3.3.16",
"libarchive.js": "1.3.0",
"lodash-es": "4.17.21",
"markdown-it": "14.1.0",
"material-design-icons-iconfont": "6.7.0",
"material-react-table": "2.13.3",
"native-promise-only": "0.8.1",
"pdfjs-dist": "3.11.174",
"react": "18.3.1",
"react-blurhash": "0.3.0",
"react-dom": "18.3.1",
"react-lazy-load-image-component": "1.6.2",
"react-router-dom": "6.27.0",
"react-router-dom": "6.25.1",
"resize-observer-polyfill": "1.5.1",
"screenfull": "6.0.2",
"sortablejs": "1.15.3",
"swiper": "11.1.14",
"sortablejs": "1.15.2",
"swiper": "11.1.7",
"usehooks-ts": "3.1.0",
"webcomponents.js": "0.7.24",
"whatwg-fetch": "3.6.20"
},
"optionalDependencies": {
"sass-embedded": "1.79.5"
},
"browserslist": [
"last 2 Firefox versions",
"last 2 Chrome versions",
@@ -152,7 +149,6 @@
"build:development": "webpack --config webpack.dev.js",
"build:production": "cross-env NODE_ENV=\"production\" webpack --config webpack.prod.js",
"build:check": "tsc --noEmit",
"build:es-check": "npm run build:production && npm run escheck",
"escheck": "es-check",
"lint": "eslint \"./\"",
"test": "vitest --watch=false --config vite.config.ts",

View File

@@ -3,28 +3,19 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import React from 'react';
import { ApiProvider } from 'hooks/useApi';
import { UserSettingsProvider } from 'hooks/useUserSettings';
import { WebConfigProvider } from 'hooks/useWebConfig';
import browser from 'scripts/browser';
import { queryClient } from 'utils/query/queryClient';
import RootAppRouter from 'RootAppRouter';
const useReactQueryDevtools = window.Proxy // '@tanstack/query-devtools' requires 'Proxy', which cannot be polyfilled for legacy browsers
&& !browser.tv; // Don't use devtools on the TV as the navigation is weird
const RootApp = () => (
<QueryClientProvider client={queryClient}>
<ApiProvider>
<UserSettingsProvider>
<WebConfigProvider>
<RootAppRouter />
</WebConfigProvider>
</UserSettingsProvider>
<WebConfigProvider>
<RootAppRouter />
</WebConfigProvider>
</ApiProvider>
{useReactQueryDevtools && (
<ReactQueryDevtools initialIsOpen={false} />
)}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);

View File

@@ -12,7 +12,6 @@ import { EXPERIMENTAL_APP_ROUTES } from 'apps/experimental/routes/routes';
import { STABLE_APP_ROUTES } from 'apps/stable/routes/routes';
import AppHeader from 'components/AppHeader';
import Backdrop from 'components/Backdrop';
import BangRedirect from 'components/router/BangRedirect';
import { createRouterHistory } from 'components/router/routerHistory';
import UserThemeProvider from 'themes/UserThemeProvider';
@@ -24,11 +23,7 @@ const router = createHashRouter([
element: <RootAppLayout />,
children: [
...(isExperimentalLayout ? EXPERIMENTAL_APP_ROUTES : STABLE_APP_ROUTES),
...DASHBOARD_APP_ROUTES,
{
path: '!/*',
Component: BangRedirect
}
...DASHBOARD_APP_ROUTES
]
}
]);

6
src/apiclient.d.ts vendored
View File

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

View File

@@ -2,9 +2,7 @@ import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import { type Theme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import React, { FC, StrictMode, useCallback, useEffect, useState } from 'react';
import React, { FC, useCallback, useEffect, useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import AppBody from 'components/AppBody';
@@ -12,7 +10,6 @@ import AppToolbar from 'components/toolbar/AppToolbar';
import ElevationScroll from 'components/ElevationScroll';
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
import { useApi } from 'hooks/useApi';
import { useLocale } from 'hooks/useLocale';
import AppTabs from './components/AppTabs';
import AppDrawer from './components/drawer/AppDrawer';
@@ -26,7 +23,6 @@ export const Component: FC = () => {
const [ isDrawerActive, setIsDrawerActive ] = useState(false);
const location = useLocation();
const { user } = useApi();
const { dateFnsLocale } = useLocale();
const isMediumScreen = useMediaQuery((t: Theme) => t.breakpoints.up('md'));
const isDrawerAvailable = Boolean(user)
@@ -47,56 +43,52 @@ export const Component: FC = () => {
}, []);
return (
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={dateFnsLocale}>
<Box sx={{ display: 'flex' }}>
<StrictMode>
<ElevationScroll elevate={false}>
<AppBar
position='fixed'
sx={{
width: {
xs: '100%',
md: isDrawerAvailable ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%'
},
ml: {
xs: 0,
md: isDrawerAvailable ? DRAWER_WIDTH : 0
}
}}
>
<AppToolbar
isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onToggleDrawer}
>
<AppTabs isDrawerOpen={isDrawerOpen} />
</AppToolbar>
</AppBar>
</ElevationScroll>
{
isDrawerAvailable && (
<AppDrawer
open={isDrawerOpen}
onClose={onToggleDrawer}
onOpen={onToggleDrawer}
/>
)
}
</StrictMode>
<Box
component='main'
<Box sx={{ display: 'flex' }}>
<ElevationScroll elevate={false}>
<AppBar
position='fixed'
sx={{
width: '100%',
flexGrow: 1
width: {
xs: '100%',
md: isDrawerAvailable ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%'
},
ml: {
xs: 0,
md: isDrawerAvailable ? DRAWER_WIDTH : 0
}
}}
>
<AppBody>
<Outlet />
</AppBody>
</Box>
<AppToolbar
isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onToggleDrawer}
>
<AppTabs isDrawerOpen={isDrawerOpen} />
</AppToolbar>
</AppBar>
</ElevationScroll>
{
isDrawerAvailable && (
<AppDrawer
open={isDrawerOpen}
onClose={onToggleDrawer}
onOpen={onToggleDrawer}
/>
)
}
<Box
component='main'
sx={{
width: '100%',
flexGrow: 1
}}
>
<AppBody>
<Outlet />
</AppBody>
</Box>
</LocalizationProvider>
</Box>
);
};

View File

@@ -9,7 +9,7 @@ $drawer-width: 240px;
// Fix dashboard pages layout to work with drawer
.dashboardDocument {
.mainAnimatedPage:not(.metadataEditorPage) {
.mainAnimatedPage {
@media all and (min-width: $mui-bp-md) {
left: $drawer-width;
}
@@ -31,8 +31,4 @@ $drawer-width: 240px;
padding-top: 3.25rem;
}
}
.metadataEditorPage {
padding-top: 3.25rem !important;
}
}

View File

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

View File

@@ -0,0 +1,17 @@
import React, { type RefAttributes } from 'react';
import { Link } from 'react-router-dom';
import { GridActionsCellItem, type GridActionsCellItemProps } from '@mui/x-data-grid';
type GridActionsCellLinkProps = { to: string } & GridActionsCellItemProps & RefAttributes<HTMLButtonElement>;
/**
* Link component to use in mui's data-grid action column due to a current bug with passing props to custom link components.
* @see https://github.com/mui/mui-x/issues/4654
*/
const GridActionsCellLink = ({ to, ...props }: GridActionsCellLinkProps) => (
<Link to={to}>
<GridActionsCellItem {...props} />
</Link>
);
export default GridActionsCellLink;

View File

@@ -2,11 +2,10 @@ import { Dashboard, ExpandLess, ExpandMore, LibraryAdd, People, PlayCircle, Sett
import Collapse from '@mui/material/Collapse';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader';
import React, { type MouseEvent, useCallback, useState } from 'react';
import React from 'react';
import { useLocation } from 'react-router-dom';
import ListItemLink from 'components/ListItemLink';
@@ -29,20 +28,8 @@ const PLAYBACK_PATHS = [
const ServerDrawerSection = () => {
const location = useLocation();
const [ isLibrarySectionOpen, setIsLibrarySectionOpen ] = useState(LIBRARY_PATHS.includes(location.pathname));
const [ isPlaybackSectionOpen, setIsPlaybackSectionOpen ] = useState(PLAYBACK_PATHS.includes(location.pathname));
const onLibrarySectionClick = useCallback((e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsLibrarySectionOpen(isOpen => !isOpen);
}, []);
const onPlaybackSectionClick = useCallback((e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsPlaybackSectionOpen(isOpen => !isOpen);
}, []);
const isLibrarySectionOpen = LIBRARY_PATHS.includes(location.pathname);
const isPlaybackSectionOpen = PLAYBACK_PATHS.includes(location.pathname);
return (
<List
@@ -78,13 +65,13 @@ const ServerDrawerSection = () => {
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemButton onClick={onLibrarySectionClick}>
<ListItemLink to='/dashboard/libraries' selected={false}>
<ListItemIcon>
<LibraryAdd />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderLibraries')} />
{isLibrarySectionOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
</ListItemLink>
</ListItem>
<Collapse in={isLibrarySectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding>
@@ -95,7 +82,7 @@ const ServerDrawerSection = () => {
<ListItemText inset primary={globalize.translate('Display')} />
</ListItemLink>
<ListItemLink to='/dashboard/libraries/metadata' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('LabelMetadata')} />
<ListItemText inset primary={globalize.translate('Metadata')} />
</ListItemLink>
<ListItemLink to='/dashboard/libraries/nfo' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabNfoSettings')} />
@@ -103,13 +90,13 @@ const ServerDrawerSection = () => {
</List>
</Collapse>
<ListItem disablePadding>
<ListItemButton onClick={onPlaybackSectionClick}>
<ListItemLink to='/dashboard/playback/transcoding' selected={false}>
<ListItemIcon>
<PlayCircle />
</ListItemIcon>
<ListItemText primary={globalize.translate('TitlePlayback')} />
{isPlaybackSectionOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
</ListItemLink>
</ListItem>
<Collapse in={isPlaybackSectionOpen} timeout='auto' unmountOnExit>
<List component='div' disablePadding>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import Stack from '@mui/material/Stack/Stack';
import React, { type FC } from 'react';
import MarkdownBox from 'components/MarkdownBox';
import { getDisplayDateTime } from 'scripts/datetime';
import { parseISO8601Date, toLocaleString } from 'scripts/datetime';
import globalize from 'lib/globalize';
import type { PluginDetails } from '../types/PluginDetails';
@@ -32,7 +32,7 @@ const PluginRevisions: FC<PluginRevisionsProps> = ({
{version.version}
{version.timestamp && (<>
&nbsp;&mdash;&nbsp;
{getDisplayDateTime(version.timestamp)}
{toLocaleString(parseISO8601Date(version.timestamp))}
</>)}
</AccordionSummary>
<AccordionDetails>

View File

@@ -1,15 +0,0 @@
/** A mapping of category names used by the plugin repository to translation keys. */
export const CATEGORY_LABELS = {
Administration: 'HeaderAdmin',
Anime: 'Anime',
Authentication: 'LabelAuthProvider', // Legacy
Books: 'Books',
Channel: 'Channels', // Unused?
General: 'General',
LiveTV: 'LiveTV',
Metadata: 'LabelMetadata', // Legacy
MoviesAndShows: 'MoviesAndShows',
Music: 'TabMusic',
Subtitles: 'Subtitles',
Other: 'Other'
};

View File

@@ -0,0 +1,35 @@
import type { Redirect } from 'components/router/Redirect';
export const REDIRECTS: Redirect[] = [
{ from: 'apikeys.html', to: '/dashboard/keys' },
{ from: 'availableplugins.html', to: '/dashboard/plugins/catalog' },
{ from: 'dashboard.html', to: '/dashboard' },
{ from: 'dashboardgeneral.html', to: '/dashboard/settings' },
{ from: 'device.html', to: '/dashboard/devices/edit' },
{ from: 'devices.html', to: '/dashboard/devices' },
{ from: 'edititemmetadata.html', to: '/metadata' },
{ from: 'encodingsettings.html', to: '/dashboard/playback/transcoding' },
{ from: 'installedplugins.html', to: '/dashboard/plugins' },
{ from: 'library.html', to: '/dashboard/libraries' },
{ from: 'librarydisplay.html', to: '/dashboard/libraries/display' },
{ from: 'livetvguideprovider.html', to: '/dashboard/livetv/guide' },
{ from: 'livetvsettings.html', to: '/dashboard/recordings' },
{ from: 'livetvstatus.html', to: '/dashboard/livetv' },
{ from: 'livetvtuner.html', to: '/dashboard/livetv/tuner' },
{ from: 'log.html', to: '/dashboard/logs' },
{ from: 'metadataimages.html', to: '/dashboard/libraries/metadata' },
{ from: 'metadatanfo.html', to: '/dashboard/libraries/nfo' },
{ from: 'networking.html', to: '/dashboard/networking' },
{ from: 'playbackconfiguration.html', to: '/dashboard/playback/resume' },
{ from: 'repositories.html', to: '/dashboard/plugins/repositories' },
{ from: 'scheduledtask.html', to: '/dashboard/tasks/edit' },
{ from: 'scheduledtasks.html', to: '/dashboard/tasks' },
{ from: 'serveractivity.html', to: '/dashboard/activity' },
{ from: 'streamingsettings.html', to: '/dashboard/playback/streaming' },
{ from: 'useredit.html', to: '/dashboard/users/profile' },
{ from: 'userlibraryaccess.html', to: '/dashboard/users/access' },
{ from: 'usernew.html', to: '/dashboard/users/add' },
{ from: 'userparentalcontrol.html', to: '/dashboard/users/parentalcontrol' },
{ from: 'userpassword.html', to: '/dashboard/users/password' },
{ from: 'userprofiles.html', to: '/dashboard/users' }
];

View File

@@ -1,35 +1,35 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { getActivityLogApi } from '@jellyfin/sdk/lib/utils/api/activity-log-api';
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
import PermMedia from '@mui/icons-material/PermMedia';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Typography from '@mui/material/Typography';
import { type MRT_ColumnDef, MaterialReactTable, useMaterialReactTable } from 'material-react-table';
import { useSearchParams } from 'react-router-dom';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { Link, useSearchParams } from 'react-router-dom';
import { useLogEntires } from 'apps/dashboard/features/activity/api/useLogEntries';
import ActionsCell from 'apps/dashboard/features/activity/components/ActionsCell';
import LogLevelCell from 'apps/dashboard/features/activity/components/LogLevelCell';
import OverviewCell from 'apps/dashboard/features/activity/components/OverviewCell';
import UserAvatarButton from 'apps/dashboard/features/activity/components/UserAvatarButton';
import type { ActivityLogEntryCell } from 'apps/dashboard/features/activity/types/ActivityLogEntryCell';
import Page from 'components/Page';
import { useUsers } from 'hooks/useUsers';
import { parseISO8601Date, toLocaleString } from 'scripts/datetime';
import UserAvatar from 'components/UserAvatar';
import { useApi } from 'hooks/useApi';
import { parseISO8601Date, toLocaleDateString, toLocaleTimeString } from 'scripts/datetime';
import globalize from 'lib/globalize';
import { toBoolean } from 'utils/string';
type UsersRecords = Record<string, UserDto>;
import LogLevelChip from '../components/activityTable/LogLevelChip';
import OverviewCell from '../components/activityTable/OverviewCell';
import GridActionsCellLink from '../components/dataGrid/GridActionsCellLink';
const DEFAULT_PAGE_SIZE = 25;
const VIEW_PARAM = 'useractivity';
const enum ActivityView {
All = 'All',
User = 'User',
System = 'System'
All,
User,
System
}
const getActivityView = (param: string | null) => {
@@ -38,125 +38,114 @@ const getActivityView = (param: string | null) => {
return ActivityView.System;
};
const getUserCell = (users: UsersRecords) => function UserCell({ row }: ActivityLogEntryCell) {
return (
<UserAvatarButton user={row.original.UserId && users[row.original.UserId] || undefined} />
);
};
const getRowId = (row: ActivityLogEntry) => row.Id ?? -1;
const Activity = () => {
const { api } = useApi();
const [ searchParams, setSearchParams ] = useSearchParams();
const [ activityView, setActivityView ] = useState(
getActivityView(searchParams.get(VIEW_PARAM)));
const [ pagination, setPagination ] = useState({
pageIndex: 0,
const [ isLoading, setIsLoading ] = useState(true);
const [ paginationModel, setPaginationModel ] = useState({
page: 0,
pageSize: DEFAULT_PAGE_SIZE
});
const [ rowCount, setRowCount ] = useState(0);
const [ rows, setRows ] = useState<ActivityLogEntry[]>([]);
const [ users, setUsers ] = useState<Record<string, UserDto>>({});
const { data: usersData, isLoading: isUsersLoading } = useUsers();
const users: UsersRecords = useMemo(() => {
if (!usersData) return {};
return usersData.reduce<UsersRecords>((acc, user) => {
const userId = user.Id;
if (!userId) return acc;
return {
...acc,
[userId]: user
};
}, {});
}, [ usersData ]);
const userNames = useMemo(() => {
const names: string[] = [];
usersData?.forEach(user => {
if (user.Name) names.push(user.Name);
});
return names;
}, [ usersData ]);
const UserCell = getUserCell(users);
const activityParams = useMemo(() => ({
startIndex: pagination.pageIndex * pagination.pageSize,
limit: pagination.pageSize,
hasUserId: activityView !== ActivityView.All ? activityView === ActivityView.User : undefined
}), [activityView, pagination.pageIndex, pagination.pageSize]);
const { data: logEntries, isLoading: isLogEntriesLoading } = useLogEntires(activityParams);
const isLoading = isUsersLoading || isLogEntriesLoading;
const userColumn: MRT_ColumnDef<ActivityLogEntry>[] = useMemo(() =>
(activityView === ActivityView.System) ? [] : [{
id: 'User',
accessorFn: row => row.UserId && users[row.UserId]?.Name,
header: globalize.translate('LabelUser'),
size: 75,
Cell: UserCell,
enableResizing: false,
muiTableBodyCellProps: {
align: 'center'
},
filterVariant: 'multi-select',
filterSelectOptions: userNames
}], [ activityView, userNames, users, UserCell ]);
const columns = useMemo<MRT_ColumnDef<ActivityLogEntry>[]>(() => [
const userColDef: GridColDef[] = activityView !== ActivityView.System ? [
{
id: 'Date',
accessorFn: row => parseISO8601Date(row.Date),
header: globalize.translate('LabelTime'),
size: 160,
Cell: ({ cell }) => toLocaleString(cell.getValue<Date>()),
filterVariant: 'datetime-range'
},
{
accessorKey: 'Severity',
header: globalize.translate('LabelLevel'),
size: 90,
Cell: LogLevelCell,
enableResizing: false,
muiTableBodyCellProps: {
align: 'center'
},
filterVariant: 'multi-select',
filterSelectOptions: Object.values(LogLevel).map(level => globalize.translate(`LogLevel.${level}`))
},
...userColumn,
{
accessorKey: 'Name',
header: globalize.translate('LabelName'),
size: 270
},
{
id: 'Overview',
accessorFn: row => row.ShortOverview || row.Overview,
header: globalize.translate('LabelOverview'),
size: 170,
Cell: OverviewCell
},
{
accessorKey: 'Type',
header: globalize.translate('LabelType'),
size: 150
},
{
id: 'Actions',
accessorFn: row => row.ItemId,
header: '',
size: 60,
Cell: ActionsCell,
enableColumnActions: false,
enableColumnFilter: false,
enableResizing: false,
enableSorting: false
field: 'User',
headerName: globalize.translate('LabelUser'),
width: 60,
valueGetter: ( value, row ) => users[row.UserId]?.Name,
renderCell: ({ row }) => (
<IconButton
size='large'
color='inherit'
sx={{ padding: 0 }}
title={users[row.UserId]?.Name ?? undefined}
component={Link}
to={`/dashboard/users/profile?userId=${row.UserId}`}
>
<UserAvatar user={users[row.UserId]} />
</IconButton>
)
}
], [ userColumn ]);
] : [];
const columns: GridColDef[] = [
{
field: 'Date',
headerName: globalize.translate('LabelDate'),
width: 90,
type: 'date',
valueGetter: ( value ) => parseISO8601Date(value),
valueFormatter: ( value ) => toLocaleDateString(value)
},
{
field: 'Time',
headerName: globalize.translate('LabelTime'),
width: 100,
type: 'dateTime',
valueGetter: ( value, row ) => parseISO8601Date(row.Date),
valueFormatter: ( value ) => toLocaleTimeString(value)
},
{
field: 'Severity',
headerName: globalize.translate('LabelLevel'),
width: 110,
renderCell: ({ value }) => (
value ? (
<LogLevelChip level={value} />
) : undefined
)
},
...userColDef,
{
field: 'Name',
headerName: globalize.translate('LabelName'),
width: 300
},
{
field: 'Overview',
headerName: globalize.translate('LabelOverview'),
width: 200,
valueGetter: ( value, row ) => row.ShortOverview ?? row.Overview,
renderCell: ({ row }) => (
<OverviewCell {...row} />
)
},
{
field: 'Type',
headerName: globalize.translate('LabelType'),
width: 180
},
{
field: 'actions',
type: 'actions',
width: 50,
getActions: ({ row }) => {
const actions = [];
if (row.ItemId) {
actions.push(
<GridActionsCellLink
size='large'
icon={<PermMedia />}
label={globalize.translate('LabelMediaDetails')}
title={globalize.translate('LabelMediaDetails')}
to={`/details?id=${row.ItemId}`}
/>
);
}
return actions;
}
}
];
const onViewChange = useCallback((_e: React.MouseEvent<HTMLElement, MouseEvent>, newView: ActivityView | null) => {
if (newView !== null) {
@@ -164,6 +153,58 @@ const Activity = () => {
}
}, []);
useEffect(() => {
if (api) {
const fetchUsers = async () => {
const { data } = await getUserApi(api).getUsers();
const usersById: Record<string, UserDto> = {};
data.forEach(user => {
if (user.Id) {
usersById[user.Id] = user;
}
});
setUsers(usersById);
};
fetchUsers()
.catch(err => {
console.error('[activity] failed to fetch users', err);
});
}
}, [ api ]);
useEffect(() => {
if (api) {
const fetchActivity = async () => {
const params: {
startIndex: number,
limit: number,
hasUserId?: boolean
} = {
startIndex: paginationModel.page * paginationModel.pageSize,
limit: paginationModel.pageSize
};
if (activityView !== ActivityView.All) {
params.hasUserId = activityView === ActivityView.User;
}
const { data } = await getActivityLogApi(api)
.getLogEntries(params);
setRowCount(data.TotalRecordCount ?? 0);
setRows(data.Items ?? []);
setIsLoading(false);
};
setIsLoading(true);
fetchActivity()
.catch(err => {
console.error('[activity] failed to fetch activity log entries', err);
});
}
}, [ activityView, api, paginationModel ]);
useEffect(() => {
const currentViewParam = getActivityView(searchParams.get(VIEW_PARAM));
if (currentViewParam !== activityView) {
@@ -176,83 +217,56 @@ const Activity = () => {
}
}, [ activityView, searchParams, setSearchParams ]);
const table = useMaterialReactTable({
columns,
data: logEntries?.Items || [],
// Enable custom features
enableColumnPinning: true,
enableColumnResizing: true,
// Sticky header/footer
enableStickyFooter: true,
enableStickyHeader: true,
muiTableContainerProps: {
sx: {
maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer
}
},
// State
initialState: {
density: 'compact'
},
state: {
isLoading,
pagination
},
// Server pagination
manualPagination: true,
onPaginationChange: setPagination,
rowCount: logEntries?.TotalRecordCount || 0,
// Custom toolbar contents
renderTopToolbarCustomActions: () => (
<ToggleButtonGroup
size='small'
value={activityView}
onChange={onViewChange}
exclusive
>
<ToggleButton value={ActivityView.All}>
{globalize.translate('All')}
</ToggleButton>
<ToggleButton value={ActivityView.User}>
{globalize.translate('LabelUser')}
</ToggleButton>
<ToggleButton value={ActivityView.System}>
{globalize.translate('LabelSystem')}
</ToggleButton>
</ToggleButtonGroup>
)
});
return (
<Page
id='serverActivityPage'
title={globalize.translate('HeaderActivity')}
className='mainAnimatedPage type-interior'
>
<Box
className='content-primary'
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%'
}}
>
<div className='content-primary'>
<Box
sx={{
marginBottom: 1
display: 'flex',
alignItems: 'baseline',
marginY: 2
}}
>
<Typography variant='h2'>
{globalize.translate('HeaderActivity')}
</Typography>
<Box sx={{ flexGrow: 1 }}>
<Typography variant='h2'>
{globalize.translate('HeaderActivity')}
</Typography>
</Box>
<ToggleButtonGroup
value={activityView}
onChange={onViewChange}
exclusive
>
<ToggleButton value={ActivityView.All}>
{globalize.translate('All')}
</ToggleButton>
<ToggleButton value={ActivityView.User}>
{globalize.translate('LabelUser')}
</ToggleButton>
<ToggleButton value={ActivityView.System}>
{globalize.translate('LabelSystem')}
</ToggleButton>
</ToggleButtonGroup>
</Box>
<MaterialReactTable table={table} />
</Box>
<DataGrid
columns={columns}
rows={rows}
pageSizeOptions={[ 10, 25, 50, 100 ]}
paginationMode='server'
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
rowCount={rowCount}
getRowId={getRowId}
loading={isLoading}
sx={{
minHeight: 500
}}
/>
</div>
</Page>
);
};

View File

@@ -33,7 +33,6 @@ const PlaybackTrickplay: FC = () => {
(page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked = options?.EnableHwAcceleration || false;
(page.querySelector('.chkEnableHwEncoding') as HTMLInputElement).checked = options?.EnableHwEncoding || false;
(page.querySelector('.chkEnableKeyFrameOnlyExtraction') as HTMLInputElement).checked = options?.EnableKeyFrameOnlyExtraction || false;
(page.querySelector('#selectScanBehavior') as HTMLSelectElement).value = (options?.ScanBehavior || TrickplayScanBehavior.NonBlocking);
(page.querySelector('#selectProcessPriority') as HTMLSelectElement).value = (options?.ProcessPriority || ProcessPriorityClass.Normal);
(page.querySelector('#txtInterval') as HTMLInputElement).value = options?.Interval?.toString() || '10000';
@@ -80,7 +79,6 @@ const PlaybackTrickplay: FC = () => {
const options = config.TrickplayOptions;
options.EnableHwAcceleration = (page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked;
options.EnableHwEncoding = (page.querySelector('.chkEnableHwEncoding') as HTMLInputElement).checked;
options.EnableKeyFrameOnlyExtraction = (page.querySelector('.chkEnableKeyFrameOnlyExtraction') as HTMLInputElement).checked;
options.ScanBehavior = (page.querySelector('#selectScanBehavior') as HTMLSelectElement).value as TrickplayScanBehavior;
options.ProcessPriority = (page.querySelector('#selectProcessPriority') as HTMLSelectElement).value as ProcessPriorityClass;
options.Interval = Math.max(1, parseInt((page.querySelector('#txtInterval') as HTMLInputElement).value || '10000', 10));
@@ -172,17 +170,6 @@ const PlaybackTrickplay: FC = () => {
</div>
</div>
</div>
<div className='checkboxContainer checkboxContainer-withDescription'>
<CheckBoxElement
className='chkEnableKeyFrameOnlyExtraction'
title='LabelTrickplayKeyFrameOnlyExtraction'
/>
<div className='fieldDescription checkboxFieldDescription'>
<div className='fieldDescription'>
{globalize.translate('LabelTrickplayKeyFrameOnlyExtractionHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='selectContainer fldSelectScanBehavior'>

View File

@@ -304,7 +304,6 @@ const PluginPage: FC = () => {
return (
<Page
id='addPluginPage'
title={pluginDetails?.name || pluginName}
className='mainAnimatedPage type-interior'
>
<Container className='content-primary'>

View File

@@ -1,8 +1,9 @@
import type { BaseItemDto, DeviceInfoDto, UserDto } from '@jellyfin/sdk/lib/generated-client';
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import type { BaseItemDto, DeviceInfo, UserDto } from '@jellyfin/sdk/lib/generated-client';
import React, { useCallback, useEffect, useState, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import loading from '../../../../components/loading/loading';
import libraryMenu from '../../../../scripts/libraryMenu';
import globalize from '../../../../lib/globalize';
import toast from '../../../../components/toast/toast';
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
@@ -16,7 +17,6 @@ type ItemsArr = {
Name?: string | null;
Id?: string | null;
AppName?: string | null;
CustomName?: string | null;
checkedAttribute?: string
};
@@ -27,7 +27,6 @@ const UserLibraryAccess = () => {
const [channelsItems, setChannelsItems] = useState<ItemsArr[]>([]);
const [mediaFoldersItems, setMediaFoldersItems] = useState<ItemsArr[]>([]);
const [devicesItems, setDevicesItems] = useState<ItemsArr[]>([]);
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
const element = useRef<HTMLDivElement>(null);
@@ -96,7 +95,7 @@ const UserLibraryAccess = () => {
triggerChange(chkEnableAllChannels);
}, []);
const loadDevices = useCallback((user: UserDto, devices: DeviceInfoDto[]) => {
const loadDevices = useCallback((user: UserDto, devices: DeviceInfo[]) => {
const page = element.current;
if (!page) {
@@ -113,7 +112,6 @@ const UserLibraryAccess = () => {
Id: device.Id,
Name: device.Name,
AppName: device.AppName,
CustomName: device.CustomName,
checkedAttribute: checkedAttribute
});
}
@@ -131,9 +129,9 @@ const UserLibraryAccess = () => {
}
}, []);
const loadUser = useCallback((user: UserDto, mediaFolders: BaseItemDto[], channels: BaseItemDto[], devices: DeviceInfoDto[]) => {
const loadUser = useCallback((user: UserDto, mediaFolders: BaseItemDto[], channels: BaseItemDto[], devices: DeviceInfo[]) => {
setUserName(user.Name || '');
void libraryMenu.then(menu => menu.setTitle(user.Name));
libraryMenu.setTitle(user.Name);
loadChannels(user, channels);
loadMediaFolders(user, mediaFolders);
loadDevices(user, devices);
@@ -309,7 +307,7 @@ const UserLibraryAccess = () => {
key={Item.Id}
className='chkDevice'
itemId={Item.Id}
itemName={Item.CustomName || Item.Name}
itemName={Item.Name}
itemAppName={Item.AppName}
itemCheckedAttribute={Item.checkedAttribute}
/>

View File

@@ -2,10 +2,11 @@ import type { AccessSchedule, ParentalRating, UserDto } from '@jellyfin/sdk/lib/
import { UnratedItem } from '@jellyfin/sdk/lib/generated-client/models/unrated-item';
import { DynamicDayOfWeek } from '@jellyfin/sdk/lib/generated-client/models/dynamic-day-of-week';
import escapeHTML from 'escape-html';
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import React, { useCallback, useEffect, useState, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import globalize from '../../../../lib/globalize';
import LibraryMenu from '../../../../scripts/libraryMenu';
import AccessScheduleList from '../../../../components/dashboard/users/AccessScheduleList';
import TagList from '../../../../components/dashboard/users/TagList';
import ButtonElement from '../../../../elements/ButtonElement';
@@ -65,11 +66,9 @@ const UserParentalControl = () => {
const [ userName, setUserName ] = useState('');
const [ parentalRatings, setParentalRatings ] = useState<ParentalRating[]>([]);
const [ unratedItems, setUnratedItems ] = useState<UnratedNamedItem[]>([]);
const [ maxParentalRating, setMaxParentalRating ] = useState<string>();
const [ accessSchedules, setAccessSchedules ] = useState<AccessSchedule[]>([]);
const [ allowedTags, setAllowedTags ] = useState<string[]>([]);
const [ blockedTags, setBlockedTags ] = useState<string[]>([]);
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
const element = useRef<HTMLDivElement>(null);
@@ -147,6 +146,70 @@ const UserParentalControl = () => {
blockUnratedItems.dispatchEvent(new CustomEvent('create'));
}, []);
const loadAllowedTags = useCallback((tags: string[]) => {
const page = element.current;
if (!page) {
console.error('[userparentalcontrol] Unexpected null page reference');
return;
}
setAllowedTags(tags);
const allowedTagsElem = page.querySelector('.allowedTags') as HTMLDivElement;
for (const btnDeleteTag of allowedTagsElem.querySelectorAll('.btnDeleteTag')) {
btnDeleteTag.addEventListener('click', function () {
const tag = btnDeleteTag.getAttribute('data-tag');
const newTags = tags.filter(t => t !== tag);
loadAllowedTags(newTags);
});
}
}, []);
const loadBlockedTags = useCallback((tags: string[]) => {
const page = element.current;
if (!page) {
console.error('[userparentalcontrol] Unexpected null page reference');
return;
}
setBlockedTags(tags);
const blockedTagsElem = page.querySelector('.blockedTags') as HTMLDivElement;
for (const btnDeleteTag of blockedTagsElem.querySelectorAll('.btnDeleteTag')) {
btnDeleteTag.addEventListener('click', function () {
const tag = btnDeleteTag.getAttribute('data-tag');
const newTags = tags.filter(t => t !== tag);
loadBlockedTags(newTags);
});
}
}, []);
const renderAccessSchedule = useCallback((schedules: AccessSchedule[]) => {
const page = element.current;
if (!page) {
console.error('[userparentalcontrol] Unexpected null page reference');
return;
}
setAccessSchedules(schedules);
const accessScheduleList = page.querySelector('.accessScheduleList') as HTMLDivElement;
for (const btnDelete of accessScheduleList.querySelectorAll('.btnDelete')) {
btnDelete.addEventListener('click', function () {
const index = parseInt(btnDelete.getAttribute('data-index') ?? '0', 10);
schedules.splice(index, 1);
const newindex = schedules.filter((_, i) => i != index);
renderAccessSchedule(newindex);
});
}
}, []);
const loadUser = useCallback((user: UserDto, allParentalRatings: ParentalRating[]) => {
const page = element.current;
@@ -156,30 +219,32 @@ const UserParentalControl = () => {
}
setUserName(user.Name || '');
void libraryMenu.then(menu => menu.setTitle(user.Name));
LibraryMenu.setTitle(user.Name);
loadUnratedItems(user);
setAllowedTags(user.Policy?.AllowedTags || []);
setBlockedTags(user.Policy?.BlockedTags || []);
loadAllowedTags(user.Policy?.AllowedTags || []);
loadBlockedTags(user.Policy?.BlockedTags || []);
populateRatings(allParentalRatings);
let ratingValue = '';
allParentalRatings.forEach(rating => {
if (rating.Value != null && user.Policy?.MaxParentalRating != null && user.Policy.MaxParentalRating >= rating.Value) {
ratingValue = `${rating.Value}`;
}
});
if (user.Policy?.MaxParentalRating) {
allParentalRatings.forEach(rating => {
if (rating.Value && user.Policy?.MaxParentalRating && user.Policy.MaxParentalRating >= rating.Value) {
ratingValue = `${rating.Value}`;
}
});
}
setMaxParentalRating(ratingValue);
(page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value = String(ratingValue);
if (user.Policy?.IsAdministrator) {
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.add('hide');
} else {
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.remove('hide');
}
setAccessSchedules(user.Policy?.AccessSchedules || []);
renderAccessSchedule(user.Policy?.AccessSchedules || []);
loading.hide();
}, [libraryMenu, setAllowedTags, setBlockedTags, loadUnratedItems, populateRatings]);
}, [loadAllowedTags, loadBlockedTags, loadUnratedItems, populateRatings, renderAccessSchedule]);
const loadData = useCallback(() => {
if (!userId) {
@@ -220,7 +285,7 @@ const UserParentalControl = () => {
}
schedules[index] = updatedSchedule;
setAccessSchedules(schedules);
renderAccessSchedule(schedules);
}).catch(() => {
// access schedule closed
});
@@ -253,7 +318,7 @@ const UserParentalControl = () => {
if (tags.indexOf(value) == -1) {
tags.push(value);
setAllowedTags(tags);
loadAllowedTags(tags);
}
}).catch(() => {
// prompt closed
@@ -274,7 +339,7 @@ const UserParentalControl = () => {
if (tags.indexOf(value) == -1) {
tags.push(value);
setBlockedTags(tags);
loadBlockedTags(tags);
}
}).catch(() => {
// prompt closed
@@ -305,8 +370,7 @@ const UserParentalControl = () => {
return false;
};
// The following is still hacky and should migrate to pure react implementation for callbacks in the future
const accessSchedulesPopupCallback = function () {
(page.querySelector('#btnAddSchedule') as HTMLButtonElement).addEventListener('click', function () {
showSchedulePopup({
Id: 0,
UserId: '',
@@ -314,57 +378,28 @@ const UserParentalControl = () => {
StartHour: 0,
EndHour: 0
}, -1);
};
(page.querySelector('#btnAddSchedule') as HTMLButtonElement).addEventListener('click', accessSchedulesPopupCallback);
(page.querySelector('#btnAddAllowedTag') as HTMLButtonElement).addEventListener('click', showAllowedTagPopup);
(page.querySelector('#btnAddBlockedTag') as HTMLButtonElement).addEventListener('click', showBlockedTagPopup);
});
(page.querySelector('#btnAddAllowedTag') as HTMLButtonElement).addEventListener('click', function () {
showAllowedTagPopup();
});
(page.querySelector('#btnAddBlockedTag') as HTMLButtonElement).addEventListener('click', function () {
showBlockedTagPopup();
});
(page.querySelector('.userParentalControlForm') as HTMLFormElement).addEventListener('submit', onSubmit);
return () => {
(page.querySelector('#btnAddSchedule') as HTMLButtonElement).removeEventListener('click', accessSchedulesPopupCallback);
(page.querySelector('#btnAddAllowedTag') as HTMLButtonElement).removeEventListener('click', showAllowedTagPopup);
(page.querySelector('#btnAddBlockedTag') as HTMLButtonElement).removeEventListener('click', showBlockedTagPopup);
(page.querySelector('.userParentalControlForm') as HTMLFormElement).removeEventListener('submit', onSubmit);
};
}, [setAllowedTags, setBlockedTags, loadData, userId]);
useEffect(() => {
const page = element.current;
if (!page) {
console.error('[userparentalcontrol] Unexpected null page reference');
return;
}
(page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value = String(maxParentalRating);
}, [maxParentalRating, parentalRatings]);
}, [loadAllowedTags, loadBlockedTags, loadData, renderAccessSchedule]);
const optionMaxParentalRating = () => {
let content = '';
content += '<option value=\'\'></option>';
for (const rating of parentalRatings) {
if (rating.Value != null) {
content += `<option value='${rating.Value}'>${escapeHTML(rating.Name)}</option>`;
}
content += `<option value='${rating.Value}'>${escapeHTML(rating.Name)}</option>`;
}
return content;
};
const removeAllowedTagsCallback = useCallback((tag: string) => {
const newTags = allowedTags.filter(t => t !== tag);
setAllowedTags(newTags);
}, [allowedTags, setAllowedTags]);
const removeBlockedTagsTagsCallback = useCallback((tag: string) => {
const newTags = blockedTags.filter(t => t !== tag);
setBlockedTags(newTags);
}, [blockedTags, setBlockedTags]);
const removeScheduleCallback = useCallback((index: number) => {
const newSchedules = accessSchedules.filter((_e, i) => i != index);
setAccessSchedules(newSchedules);
}, [accessSchedules, setAccessSchedules]);
return (
<Page
id='userParentalControlPage'
@@ -429,7 +464,6 @@ const UserParentalControl = () => {
key={tag}
tag={tag}
tagType='allowedTag'
removeTagCallback={removeAllowedTagsCallback}
/>;
})}
</div>
@@ -454,7 +488,6 @@ const UserParentalControl = () => {
key={tag}
tag={tag}
tagType='blockedTag'
removeTagCallback={removeBlockedTagsTagsCallback}
/>;
})}
</div>
@@ -473,12 +506,11 @@ const UserParentalControl = () => {
<div className='accessScheduleList paperList'>
{accessSchedules.map((accessSchedule, index) => {
return <AccessScheduleList
key={`${accessSchedule.DayOfWeek}${accessSchedule.StartHour}${accessSchedule.EndHour}`}
key={accessSchedule.Id}
index={index}
DayOfWeek={accessSchedule.DayOfWeek}
StartHour={accessSchedule.StartHour}
EndHour={accessSchedule.EndHour}
removeScheduleCallback={removeScheduleCallback}
/>;
})}
</div>

View File

@@ -1,14 +1,15 @@
import type { BaseItemDto, NameIdPair, SyncPlayUserAccessType, UserDto } from '@jellyfin/sdk/lib/generated-client';
import escapeHTML from 'escape-html';
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import React, { useCallback, useEffect, useState, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import Dashboard from '../../../../utils/dashboard';
import globalize from '../../../../lib/globalize';
import LibraryMenu from '../../../../scripts/libraryMenu';
import ButtonElement from '../../../../elements/ButtonElement';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
import InputElement from '../../../../elements/InputElement';
import LinkButton from '../../../../elements/emby-button/LinkButton';
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';
@@ -37,11 +38,10 @@ function onSaveComplete() {
const UserEdit = () => {
const [ searchParams ] = useSearchParams();
const userId = searchParams.get('userId');
const [ userDto, setUserDto ] = useState<UserDto>();
const [ userName, setUserName ] = useState('');
const [ deleteFoldersAccess, setDeleteFoldersAccess ] = useState<ResetProvider[]>([]);
const [ authProviders, setAuthProviders ] = useState<NameIdPair[]>([]);
const [ passwordResetProviders, setPasswordResetProviders ] = useState<NameIdPair[]>([]);
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
const [ authenticationProviderId, setAuthenticationProviderId ] = useState('');
const [ passwordResetProviderId, setPasswordResetProviderId ] = useState('');
@@ -147,9 +147,10 @@ const UserEdit = () => {
txtUserName.disabled = false;
txtUserName.removeAttribute('disabled');
void libraryMenu.then(menu => menu.setTitle(user.Name));
setUserDto(user);
const lnkEditUserPreferences = page.querySelector('.lnkEditUserPreferences') as HTMLDivElement;
lnkEditUserPreferences.setAttribute('href', 'mypreferencesmenu.html?userId=' + user.Id);
LibraryMenu.setTitle(user.Name);
setUserName(user.Name || '');
(page.querySelector('#txtUserName') as HTMLInputElement).value = user.Name || '';
(page.querySelector('.chkIsAdmin') as HTMLInputElement).checked = !!user.Policy?.IsAdministrator;
(page.querySelector('.chkDisabled') as HTMLInputElement).checked = !!user.Policy?.IsDisabled;
@@ -169,9 +170,11 @@ const UserEdit = () => {
(page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked = user.Policy?.EnableRemoteAccess == null || user.Policy?.EnableRemoteAccess;
(page.querySelector('#txtRemoteClientBitrateLimit') as HTMLInputElement).value = user.Policy?.RemoteClientBitrateLimit && user.Policy?.RemoteClientBitrateLimit > 0 ?
(user.Policy?.RemoteClientBitrateLimit / 1e6).toLocaleString(undefined, { maximumFractionDigits: 6 }) : '';
(page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value = String(user.Policy?.LoginAttemptsBeforeLockout) || '-1';
(page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value = String(user.Policy?.MaxActiveSessions) || '0';
(page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value = String(user.Policy?.SyncPlayAccess);
(page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value = String(user.Policy?.MaxActiveSessions) || '0';
(page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value = String(user.Policy?.SyncPlayAccess) || '0';
if (window.ApiClient.isMinServerVersion('10.6.0')) {
(page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value = String(user.Policy?.SyncPlayAccess);
}
loading.hide();
}, [loadAuthProviders, loadPasswordResetProviders, loadDeleteFolders ]);
@@ -289,7 +292,7 @@ const UserEdit = () => {
<div ref={element} className='content-primary'>
<div className='verticalSection'>
<SectionTitleContainer
title={userDto?.Name || ''}
title={userName}
url='https://jellyfin.org/docs/general/server/users/'
/>
</div>
@@ -299,9 +302,10 @@ const UserEdit = () => {
className='lnkEditUserPreferencesContainer'
style={{ paddingBottom: '1em' }}
>
<LinkButton className='lnkEditUserPreferences button-link' href={userDto?.Id ? `mypreferencesmenu.html?userId=${userDto.Id}` : undefined}>
{globalize.translate('ButtonEditOtherUserPreferences')}
</LinkButton>
<LinkEditUserPreferences
className= 'lnkEditUserPreferences button-link'
title= 'ButtonEditOtherUserPreferences'
/>
</div>
<form className='editUserProfileForm'>
<div className='disabledUserBanner hide'>

View File

@@ -1,4 +1,4 @@
import React, { StrictMode, useCallback, useState } from 'react';
import React, { useCallback, useState } from 'react';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import { type Theme } from '@mui/material/styles';
@@ -29,40 +29,38 @@ export const Component = () => {
}, [ isDrawerActive, setIsDrawerActive ]);
return (
<Box sx={{ position: 'relative', display: 'flex', height: '100%' }}>
<StrictMode>
<ElevationScroll elevate={false}>
<AppBar
position='fixed'
sx={{
width: {
xs: '100%',
md: isDrawerAvailable ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%'
},
ml: {
xs: 0,
md: isDrawerAvailable ? DRAWER_WIDTH : 0
}
}}
>
<AppToolbar
isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onToggleDrawer}
/>
</AppBar>
</ElevationScroll>
<Box sx={{ position: 'relative', display: 'flex' }}>
<ElevationScroll elevate={false}>
<AppBar
position='fixed'
sx={{
width: {
xs: '100%',
md: isDrawerAvailable ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%'
},
ml: {
xs: 0,
md: isDrawerAvailable ? DRAWER_WIDTH : 0
}
}}
>
<AppToolbar
isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onToggleDrawer}
/>
</AppBar>
</ElevationScroll>
{
isDrawerAvailable && (
<AppDrawer
open={isDrawerOpen}
onClose={onToggleDrawer}
onOpen={onToggleDrawer}
/>
)
}
</StrictMode>
{
isDrawerAvailable && (
<AppDrawer
open={isDrawerOpen}
onClose={onToggleDrawer}
onOpen={onToggleDrawer}
/>
)
}
<Box
component='main'

View File

@@ -7,10 +7,6 @@ $mui-bp-xl: 1536px;
$drawer-width: 240px;
#reactRoot {
height: 100%;
}
// Fix main pages layout to work with drawer
.mainAnimatedPage {
@media all and (min-width: $mui-bp-md) {

View File

@@ -1,5 +1,4 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import Movie from '@mui/icons-material/Movie';
import MusicNote from '@mui/icons-material/MusicNote';
import Photo from '@mui/icons-material/Photo';
@@ -8,11 +7,11 @@ 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 Quiz from '@mui/icons-material/Quiz';
import VideoLibrary from '@mui/icons-material/VideoLibrary';
import Folder from '@mui/icons-material/Folder';
import React, { FC } from 'react';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
interface LibraryIconProps {
item: BaseItemDto
@@ -40,11 +39,9 @@ const LibraryIcon: FC<LibraryIconProps> = ({
case CollectionType.Books:
return <Book />;
case CollectionType.Boxsets:
return <VideoLibrary />;
return <Collections />;
case CollectionType.Playlists:
return <Queue />;
case undefined:
return <Quiz />;
default:
return <Folder />;
}

View File

@@ -2,7 +2,7 @@ import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/bas
import type { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import React, { FC } from 'react';
import { useGetGenres } from 'hooks/useFetchItems';
import NoItemsMessage from 'components/common/NoItemsMessage';
import globalize from 'lib/globalize';
import Loading from 'components/loading/LoadingComponent';
import GenresSectionContainer from './GenresSectionContainer';
import type { ParentId } from 'types/library';
@@ -25,18 +25,27 @@ const GenresItemsContainer: FC<GenresItemsContainerProps> = ({
}
if (!genresResult?.Items?.length) {
return <NoItemsMessage message='MessageNoGenresAvailable' />;
return (
<div className='noItemsMessage centerMessage'>
<h1>{globalize.translate('MessageNothingHere')}</h1>
<p>{globalize.translate('MessageNoGenresAvailable')}</p>
</div>
);
}
return genresResult.Items.map((genre) => (
<GenresSectionContainer
key={genre.Id}
collectionType={collectionType}
parentId={parentId}
itemType={itemType}
genre={genre}
/>
));
return (
<>
{genresResult.Items.map((genre) => (
<GenresSectionContainer
key={genre.Id}
collectionType={collectionType}
parentId={parentId}
itemType={itemType}
genre={genre}
/>
))}
</>
);
};
export default GenresItemsContainer;

View File

@@ -1,3 +1,4 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields';
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
@@ -8,16 +9,15 @@ import React, { type FC } from 'react';
import { useGetItems } from 'hooks/useFetchItems';
import Loading from 'components/loading/LoadingComponent';
import { appRouter } from 'components/router/appRouter';
import SectionContainer from 'components/common/SectionContainer';
import SectionContainer from './SectionContainer';
import { CardShape } from 'utils/card';
import type { ParentId } from 'types/library';
import type { ItemDto } from 'types/base/models/item-dto';
interface GenresSectionContainerProps {
parentId: ParentId;
collectionType: CollectionType | undefined;
itemType: BaseItemKind[];
genre: ItemDto;
genre: BaseItemDto;
}
const GenresSectionContainer: FC<GenresSectionContainerProps> = ({
@@ -47,7 +47,7 @@ const GenresSectionContainer: FC<GenresSectionContainerProps> = ({
const { isLoading, data: itemsResult } = useGetItems(getParametersOptions());
const getRouteUrl = (item: ItemDto) => {
const getRouteUrl = (item: BaseItemDto) => {
return appRouter.getRouteUrl(item, {
context: collectionType,
parentId: parentId
@@ -59,12 +59,9 @@ const GenresSectionContainer: FC<GenresSectionContainerProps> = ({
}
return <SectionContainer
key={genre.Name}
sectionHeaderProps={{
title: genre.Name || '',
url: getRouteUrl(genre)
}}
items={itemsResult?.Items}
sectionTitle={genre.Name || ''}
items={itemsResult?.Items || []}
url={getRouteUrl(genre)}
cardOptions={{
scalable: true,
overlayPlayButton: true,

View File

@@ -92,7 +92,7 @@ const ItemsView: FC<ItemsViewProps> = ({
listOptions.showParentTitle = true;
listOptions.action = 'playallfromhere';
listOptions.smallIcon = true;
listOptions.showArtist = true;
listOptions.artist = true;
listOptions.addToListButton = true;
} else if (viewType === LibraryTab.Albums) {
listOptions.sortBy = libraryViewSettings.SortBy;
@@ -181,7 +181,7 @@ const ItemsView: FC<ItemsViewProps> = ({
const getItems = useCallback(() => {
if (!itemsResult?.Items?.length) {
return <NoItemsMessage message={noItemsMessage} />;
return <NoItemsMessage noItemsMessage={noItemsMessage} />;
}
if (libraryViewSettings.ViewMode === ViewMode.ListView) {

View File

@@ -1,3 +1,4 @@
import type { BaseItemDto, SeriesTimerInfoDto } from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useCallback } from 'react';
import { IconButton } from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
@@ -7,11 +8,10 @@ import globalize from 'lib/globalize';
import { getFiltersQuery } from 'utils/items';
import { LibraryViewSettings } from 'types/library';
import { LibraryTab } from 'types/libraryTab';
import type { ItemDto } from 'types/base/models/item-dto';
interface PlayAllButtonProps {
item: ItemDto | undefined;
items: ItemDto[];
item: BaseItemDto | null | undefined;
items: BaseItemDto[] | SeriesTimerInfoDto[];
viewType: LibraryTab;
hasFilters: boolean;
libraryViewSettings: LibraryViewSettings
@@ -27,12 +27,10 @@ const PlayAllButton: FC<PlayAllButtonProps> = ({ item, items, viewType, hasFilte
SortBy: [libraryViewSettings.SortBy],
SortOrder: [libraryViewSettings.SortOrder]
}
}).catch(err => {
console.error('[PlayAllButton] failed to play', err);
});
} else {
playbackManager.play({
items,
items: items,
autoplay: true,
queryOptions: {
ParentId: item?.Id ?? undefined,
@@ -40,8 +38,7 @@ const PlayAllButton: FC<PlayAllButtonProps> = ({ item, items, viewType, hasFilte
SortBy: [libraryViewSettings.SortBy],
SortOrder: [libraryViewSettings.SortOrder]
}
}).catch(err => {
console.error('[PlayAllButton] failed to play', err);
});
}
}, [hasFilters, item, items, libraryViewSettings, viewType]);

View File

@@ -3,8 +3,7 @@ import { useGetProgramsSectionsWithItems, useGetTimers } from 'hooks/useFetchIte
import { appRouter } from 'components/router/appRouter';
import globalize from 'lib/globalize';
import Loading from 'components/loading/LoadingComponent';
import NoItemsMessage from 'components/common/NoItemsMessage';
import SectionContainer from 'components/common/SectionContainer';
import SectionContainer from './SectionContainer';
import { CardShape } from 'utils/card';
import type { ParentId } from 'types/library';
import type { Section, SectionType } from 'types/sections';
@@ -31,7 +30,14 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
}
if (!sectionsWithItems?.length && !upcomingRecordings?.length) {
return <NoItemsMessage />;
return (
<div className='noItemsMessage centerMessage'>
<h1>{globalize.translate('MessageNothingHere')}</h1>
<p>
{globalize.translate('MessageNoItemsAvailable')}
</p>
</div>
);
}
const getRouteUrl = (section: Section) => {
@@ -52,33 +58,23 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
{sectionsWithItems?.map(({ section, items }) => (
<SectionContainer
key={section.type}
sectionHeaderProps={{
title: globalize.translate(section.name),
url: getRouteUrl(section)
}}
itemsContainerProps={{
queryKey: ['ProgramSectionWithItems'],
reloadItems: refetch
}}
items={items}
sectionTitle={globalize.translate(section.name)}
items={items ?? []}
url={getRouteUrl(section)}
reloadItems={refetch}
cardOptions={{
...section.cardOptions,
queryKey: ['ProgramSectionWithItems']
}}
/>
))}
{upcomingRecordings?.map((group) => (
<SectionContainer
key={group.name}
sectionHeaderProps={{
title: group.name
}}
itemsContainerProps={{
queryKey: ['Timers'],
reloadItems: refetch
}}
items={group.timerInfo}
sectionTitle={group.name}
items={group.timerInfo ?? []}
cardOptions={{
queryKey: ['Timers'],
shape: CardShape.BackdropOverflow,

View File

@@ -1,14 +1,14 @@
import type { BaseItemDto, SeriesTimerInfoDto } from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useCallback } from 'react';
import { IconButton } from '@mui/material';
import QueueIcon from '@mui/icons-material/Queue';
import { playbackManager } from 'components/playback/playbackmanager';
import globalize from 'lib/globalize';
import type { ItemDto } from 'types/base/models/item-dto';
interface QueueButtonProps {
item: ItemDto | undefined
items: ItemDto[];
item: BaseItemDto | undefined
items: BaseItemDto[] | SeriesTimerInfoDto[];
hasFilters: boolean;
}
@@ -17,14 +17,10 @@ const QueueButton: FC<QueueButtonProps> = ({ item, items, hasFilters }) => {
if (item && !hasFilters) {
playbackManager.queue({
items: [item]
}).catch(err => {
console.error('[QueueButton] failed to add to queue', err);
});
} else {
playbackManager.queue({
items
}).catch(err => {
console.error('[QueueButton] failed to add to queue', err);
items: items
});
}
}, [hasFilters, item, items]);

View File

@@ -0,0 +1,65 @@
import type { BaseItemDto, TimerInfoDto } from '@jellyfin/sdk/lib/generated-client';
import React, { FC } from 'react';
import ItemsContainer from 'elements/emby-itemscontainer/ItemsContainer';
import Scroller from 'elements/emby-scroller/Scroller';
import LinkButton from 'elements/emby-button/LinkButton';
import Cards from 'components/cardbuilder/Card/Cards';
import type { CardOptions } from 'types/cardOptions';
interface SectionContainerProps {
url?: string;
sectionTitle: string;
items: BaseItemDto[] | TimerInfoDto[];
cardOptions: CardOptions;
reloadItems?: () => void;
}
const SectionContainer: FC<SectionContainerProps> = ({
sectionTitle,
url,
items,
cardOptions,
reloadItems
}) => {
return (
<div className='verticalSection'>
<div className='sectionTitleContainer sectionTitleContainer-cards padded-left'>
{url && items.length > 5 ? (
<LinkButton
className='more button-flat button-flat-mini sectionTitleTextButton btnMoreFromGenre'
href={url}
>
<h2 className='sectionTitle sectionTitle-cards'>
{sectionTitle}
</h2>
<span
className='material-icons chevron_right'
aria-hidden='true'
></span>
</LinkButton>
) : (
<h2 className='sectionTitle sectionTitle-cards'>
{sectionTitle}
</h2>
)}
</div>
<Scroller
className='padded-top-focusscale padded-bottom-focusscale'
isMouseWheelEnabled={false}
isCenterFocusEnabled={true}
>
<ItemsContainer
className='itemsContainer scrollSlider focuscontainer-x'
reloadItems={reloadItems}
queryKey={cardOptions.queryKey}
>
<Cards items={items} cardOptions={cardOptions} />
</ItemsContainer>
</Scroller>
</div>
);
};
export default SectionContainer;

View File

@@ -1,3 +1,4 @@
import type { BaseItemDto, SeriesTimerInfoDto } from '@jellyfin/sdk/lib/generated-client';
import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
import React, { FC, useCallback } from 'react';
import { IconButton } from '@mui/material';
@@ -8,11 +9,10 @@ import globalize from 'lib/globalize';
import { getFiltersQuery } from 'utils/items';
import { LibraryViewSettings } from 'types/library';
import { LibraryTab } from 'types/libraryTab';
import type { ItemDto } from 'types/base/models/item-dto';
interface ShuffleButtonProps {
item: ItemDto | undefined;
items: ItemDto[];
item: BaseItemDto | null | undefined;
items: BaseItemDto[] | SeriesTimerInfoDto[];
viewType: LibraryTab
hasFilters: boolean;
libraryViewSettings: LibraryViewSettings
@@ -24,15 +24,13 @@ const ShuffleButton: FC<ShuffleButtonProps> = ({ item, items, viewType, hasFilte
playbackManager.shuffle(item);
} else {
playbackManager.play({
items,
items: items,
autoplay: true,
queryOptions: {
ParentId: item?.Id ?? undefined,
...getFiltersQuery(viewType, libraryViewSettings),
SortBy: [ItemSortBy.Random]
}
}).catch(err => {
console.error('[ShuffleButton] failed to play', err);
});
}
}, [hasFilters, item, items, libraryViewSettings, viewType]);

View File

@@ -1,5 +1,7 @@
import type { RecommendationDto } from '@jellyfin/sdk/lib/generated-client/models/recommendation-dto';
import { RecommendationType } from '@jellyfin/sdk/lib/generated-client/models/recommendation-type';
import {
type RecommendationDto,
RecommendationType
} from '@jellyfin/sdk/lib/generated-client';
import React, { type FC } from 'react';
import {
useGetMovieRecommendations,
@@ -8,12 +10,10 @@ import {
import { appRouter } from 'components/router/appRouter';
import globalize from 'lib/globalize';
import Loading from 'components/loading/LoadingComponent';
import NoItemsMessage from 'components/common/NoItemsMessage';
import SectionContainer from '../../../../components/common/SectionContainer';
import SectionContainer from './SectionContainer';
import { CardShape } from 'utils/card';
import type { ParentId } from 'types/library';
import type { Section, SectionType } from 'types/sections';
import type { ItemDto } from 'types/base/models/item-dto';
interface SuggestionsSectionViewProps {
parentId: ParentId;
@@ -39,7 +39,12 @@ const SuggestionsSectionView: FC<SuggestionsSectionViewProps> = ({
}
if (!sectionsWithItems?.length && !movieRecommendationsItems?.length) {
return <NoItemsMessage />;
return (
<div className='noItemsMessage centerMessage'>
<h1>{globalize.translate('MessageNothingHere')}</h1>
<p>{globalize.translate('MessageNoItemsAvailable')}</p>
</div>
);
}
const getRouteUrl = (section: Section) => {
@@ -92,14 +97,9 @@ const SuggestionsSectionView: FC<SuggestionsSectionViewProps> = ({
{sectionsWithItems?.map(({ section, items }) => (
<SectionContainer
key={section.type}
sectionHeaderProps={{
title: globalize.translate(section.name),
url: getRouteUrl(section)
}}
itemsContainerProps={{
queryKey: ['SuggestionSectionWithItems']
}}
items={items}
sectionTitle={globalize.translate(section.name)}
items={items ?? []}
url={getRouteUrl(section)}
cardOptions={{
...section.cardOptions,
queryKey: ['SuggestionSectionWithItems'],
@@ -115,13 +115,8 @@ const SuggestionsSectionView: FC<SuggestionsSectionViewProps> = ({
<SectionContainer
// eslint-disable-next-line react/no-array-index-key
key={`${recommendation.CategoryId}-${index}`} // use a unique id return value may have duplicate id
sectionHeaderProps={{
title: getRecommendationTittle(recommendation)
}}
itemsContainerProps={{
queryKey: ['MovieRecommendations']
}}
items={recommendation.Items as ItemDto[]}
sectionTitle={getRecommendationTittle(recommendation)}
items={recommendation.Items ?? []}
cardOptions={{
queryKey: ['MovieRecommendations'],
shape: CardShape.PortraitOverflow,

View File

@@ -1,44 +1,49 @@
import React, { type FC } from 'react';
import Box from '@mui/material/Box';
import { useGetGroupsUpcomingEpisodes } from 'hooks/useFetchItems';
import Loading from 'components/loading/LoadingComponent';
import NoItemsMessage from 'components/common/NoItemsMessage';
import SectionContainer from 'components/common/SectionContainer';
import globalize from 'lib/globalize';
import SectionContainer from './SectionContainer';
import { CardShape } from 'utils/card';
import type { LibraryViewProps } from 'types/library';
const UpcomingView: FC<LibraryViewProps> = ({ parentId }) => {
const { isLoading, data: groupsUpcomingEpisodes } =
useGetGroupsUpcomingEpisodes(parentId);
const { isLoading, data: groupsUpcomingEpisodes } = useGetGroupsUpcomingEpisodes(parentId);
if (isLoading) return <Loading />;
if (!groupsUpcomingEpisodes?.length) {
return <NoItemsMessage message='MessagePleaseEnsureInternetMetadata' />;
}
return groupsUpcomingEpisodes?.map((group) => (
<SectionContainer
key={group.name}
sectionHeaderProps={{
title: group.name
}}
itemsContainerProps={{
queryKey: ['GroupsUpcomingEpisodes']
}}
items={group.items}
cardOptions={{
shape: CardShape.BackdropOverflow,
showLocationTypeIndicator: false,
showParentTitle: true,
preferThumb: true,
lazy: true,
showDetailsMenu: true,
missingIndicator: false,
cardLayout: false,
queryKey: ['GroupsUpcomingEpisodes']
}}
/>
));
return (
<Box>
{!groupsUpcomingEpisodes?.length ? (
<div className='noItemsMessage centerMessage'>
<h1>{globalize.translate('MessageNothingHere')}</h1>
<p>
{globalize.translate(
'MessagePleaseEnsureInternetMetadata'
)}
</p>
</div>
) : (
groupsUpcomingEpisodes?.map((group) => (
<SectionContainer
key={group.name}
sectionTitle={group.name}
items={group.items ?? []}
cardOptions={{
shape: CardShape.BackdropOverflow,
showLocationTypeIndicator: false,
showParentTitle: true,
preferThumb: true,
lazy: true,
showDetailsMenu: true,
missingIndicator: false,
cardLayout: false
}}
/>
))
)}
</Box>
);
};
export default UpcomingView;

View File

@@ -8,7 +8,6 @@ import { Route, Routes } from 'react-router-dom';
import TabRoutes from './tabRoutes';
import useCurrentTab from 'hooks/useCurrentTab';
import globalize from 'lib/globalize';
interface AppTabsParams {
isDrawerOpen: boolean
@@ -67,7 +66,7 @@ const AppTabs: FC<AppTabsParams> = ({
route.tabs.map(({ index, label }) => (
<Tab
key={`${route.path}-tab-${index}`}
label={globalize.translate(label)}
label={label}
data-tab-index={`${index}`}
onClick={onTabClick}
/>

View File

@@ -1,3 +1,4 @@
import globalize from 'lib/globalize';
import * as userSettings from 'scripts/settings/userSettings';
import { LibraryTab } from 'types/libraryTab';
@@ -40,33 +41,33 @@ const TabRoutes: TabRoute[] = [
tabs: [
{
index: 0,
label: 'Programs',
label: globalize.translate('Programs'),
value: LibraryTab.Programs,
isDefault: true
},
{
index: 1,
label: 'Guide',
label: globalize.translate('Guide'),
value: LibraryTab.Guide
},
{
index: 2,
label: 'Channels',
label: globalize.translate('Channels'),
value: LibraryTab.Channels
},
{
index: 3,
label: 'Recordings',
label: globalize.translate('Recordings'),
value: LibraryTab.Recordings
},
{
index: 4,
label: 'Schedule',
label: globalize.translate('Schedule'),
value: LibraryTab.Schedule
},
{
index: 5,
label: 'Series',
label: globalize.translate('Series'),
value: LibraryTab.SeriesTimers
}
]
@@ -76,33 +77,33 @@ const TabRoutes: TabRoute[] = [
tabs: [
{
index: 0,
label: 'Movies',
label: globalize.translate('Movies'),
value: LibraryTab.Movies,
isDefault: true
},
{
index: 1,
label: 'Suggestions',
label: globalize.translate('Suggestions'),
value: LibraryTab.Suggestions
},
{
index: 2,
label: 'Trailers',
label: globalize.translate('Trailers'),
value: LibraryTab.Trailers
},
{
index: 3,
label: 'Favorites',
label: globalize.translate('Favorites'),
value: LibraryTab.Favorites
},
{
index: 4,
label: 'Collections',
label: globalize.translate('Collections'),
value: LibraryTab.Collections
},
{
index: 5,
label: 'Genres',
label: globalize.translate('Genres'),
value: LibraryTab.Genres
}
]
@@ -112,38 +113,38 @@ const TabRoutes: TabRoute[] = [
tabs: [
{
index: 0,
label: 'Albums',
label: globalize.translate('Albums'),
value: LibraryTab.Albums,
isDefault: true
},
{
index: 1,
label: 'Suggestions',
label: globalize.translate('Suggestions'),
value: LibraryTab.Suggestions
},
{
index: 2,
label: 'HeaderAlbumArtists',
label: globalize.translate('HeaderAlbumArtists'),
value: LibraryTab.AlbumArtists
},
{
index: 3,
label: 'Artists',
label: globalize.translate('Artists'),
value: LibraryTab.Artists
},
{
index: 4,
label: 'Playlists',
label: globalize.translate('Playlists'),
value: LibraryTab.Playlists
},
{
index: 5,
label: 'Songs',
label: globalize.translate('Songs'),
value: LibraryTab.Songs
},
{
index: 6,
label: 'Genres',
label: globalize.translate('Genres'),
value: LibraryTab.Genres
}
]
@@ -153,33 +154,33 @@ const TabRoutes: TabRoute[] = [
tabs: [
{
index: 0,
label: 'Shows',
label: globalize.translate('Shows'),
value: LibraryTab.Series,
isDefault: true
},
{
index: 1,
label: 'Suggestions',
label: globalize.translate('Suggestions'),
value: LibraryTab.Suggestions
},
{
index: 2,
label: 'TabUpcoming',
label: globalize.translate('TabUpcoming'),
value: LibraryTab.Upcoming
},
{
index: 3,
label: 'Genres',
label: globalize.translate('Genres'),
value: LibraryTab.Genres
},
{
index: 4,
label: 'TabNetworks',
label: globalize.translate('TabNetworks'),
value: LibraryTab.Networks
},
{
index: 5,
label: 'Episodes',
label: globalize.translate('Episodes'),
value: LibraryTab.Episodes
}
]
@@ -189,19 +190,19 @@ const TabRoutes: TabRoute[] = [
tabs: [
{
index: 0,
label: 'Photos',
label: globalize.translate('Photos'),
value: LibraryTab.Photos,
isDefault: true
},
{
index: 1,
label: 'HeaderPhotoAlbums',
label: globalize.translate('HeaderPhotoAlbums'),
value: LibraryTab.PhotoAlbums,
isDefault: true
},
{
index: 2,
label: 'HeaderVideos',
label: globalize.translate('HeaderVideos'),
value: LibraryTab.Videos
}
]

View File

@@ -1,68 +0,0 @@
import React, { FC, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { IconButton } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import { useCancelSeriesTimer } from 'hooks/api/liveTvHooks';
import globalize from 'lib/globalize';
import loading from 'components/loading/loading';
import toast from 'components/toast/toast';
import confirm from 'components/confirm/confirm';
interface CancelSeriesTimerButtonProps {
itemId: string;
}
const CancelSeriesTimerButton: FC<CancelSeriesTimerButtonProps> = ({
itemId
}) => {
const navigate = useNavigate();
const cancelSeriesTimer = useCancelSeriesTimer();
const onCancelSeriesTimerClick = useCallback(() => {
confirm({
text: globalize.translate('MessageConfirmRecordingCancellation'),
primary: 'delete',
confirmText: globalize.translate('HeaderCancelSeries'),
cancelText: globalize.translate('HeaderKeepSeries')
})
.then(function () {
loading.show();
cancelSeriesTimer.mutate(
{
timerId: itemId
},
{
onSuccess: async () => {
toast(globalize.translate('SeriesCancelled'));
loading.hide();
navigate('/livetv.html');
},
onError: (err: unknown) => {
loading.hide();
toast(globalize.translate('MessageCancelSeriesTimerError'));
console.error(
'[cancelSeriesTimer] failed to cancel series timer',
err
);
}
}
);
})
.catch(() => {
// confirm dialog closed
});
}, [cancelSeriesTimer, navigate, itemId]);
return (
<IconButton
className='button-flat btnCancelSeriesTimer'
title={globalize.translate('CancelSeries')}
onClick={onCancelSeriesTimerClick}
>
<DeleteIcon />
</IconButton>
);
};
export default CancelSeriesTimerButton;

View File

@@ -1,60 +0,0 @@
import React, { FC, useCallback } from 'react';
import { IconButton } from '@mui/material';
import StopIcon from '@mui/icons-material/Stop';
import { useQueryClient } from '@tanstack/react-query';
import { useCancelTimer } from 'hooks/api/liveTvHooks';
import globalize from 'lib/globalize';
import loading from 'components/loading/loading';
import toast from 'components/toast/toast';
interface CancelTimerButtonProps {
timerId: string;
queryKey?: string[];
}
const CancelTimerButton: FC<CancelTimerButtonProps> = ({
timerId,
queryKey
}) => {
const queryClient = useQueryClient();
const cancelTimer = useCancelTimer();
const onCancelTimerClick = useCallback(() => {
loading.show();
cancelTimer.mutate(
{
timerId: timerId
},
{
onSuccess: async () => {
toast(globalize.translate('RecordingCancelled'));
loading.hide();
await queryClient.invalidateQueries({
queryKey
});
},
onError: (err: unknown) => {
loading.hide();
toast(globalize.translate('MessageCancelTimerError'));
console.error(
'[cancelTimer] failed to cancel timer',
err
);
}
}
);
}, [cancelTimer, queryClient, queryKey, timerId]);
return (
<IconButton
className='button-flat btnCancelTimer'
title={globalize.translate('StopRecording')}
onClick={onCancelTimerClick}
>
<StopIcon />
</IconButton>
);
};
export default CancelTimerButton;

View File

@@ -1,42 +0,0 @@
import React, { FC, useCallback } from 'react';
import { IconButton } from '@mui/material';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import { useGetDownload } from 'hooks/api/libraryHooks';
import globalize from 'lib/globalize';
import { download } from 'scripts/fileDownloader';
import type { NullableString } from 'types/base/common/shared/types';
interface DownloadButtonProps {
itemId: string;
itemServerId: NullableString,
itemName: NullableString,
itemPath: NullableString,
}
const DownloadButton: FC<DownloadButtonProps> = ({ itemId, itemServerId, itemName, itemPath }) => {
const { data: downloadHref } = useGetDownload({ itemId });
const onDownloadClick = useCallback(async () => {
download([
{
url: downloadHref,
itemId: itemId,
serverId: itemServerId,
title: itemName,
filename: itemPath?.replace(/^.*[\\/]/, '')
}
]);
}, [downloadHref, itemId, itemName, itemPath, itemServerId]);
return (
<IconButton
className='button-flat btnDownload'
title={globalize.translate('Download')}
onClick={onDownloadClick}
>
<FileDownloadIcon />
</IconButton>
);
};
export default DownloadButton;

View File

@@ -1,28 +0,0 @@
import React, { FC, useCallback } from 'react';
import { IconButton } from '@mui/material';
import ExploreIcon from '@mui/icons-material/Explore';
import { playbackManager } from 'components/playback/playbackmanager';
import globalize from 'lib/globalize';
import type { ItemDto } from 'types/base/models/item-dto';
interface InstantMixButtonProps {
item?: ItemDto;
}
const InstantMixButton: FC<InstantMixButtonProps> = ({ item }) => {
const onInstantMixClick = useCallback(() => {
playbackManager.instantMix(item);
}, [item]);
return (
<IconButton
className='button-flat btnInstantMix'
title={globalize.translate('HeaderInstantMix')}
onClick={onInstantMixClick}
>
<ExploreIcon />
</IconButton>
);
};
export default InstantMixButton;

View File

@@ -1,229 +0,0 @@
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { IconButton } from '@mui/material';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { useQueryClient } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { useGetItemByType } from '../../hooks/api/useGetItemByType';
import globalize from 'lib/globalize';
import itemContextMenu from 'components/itemContextMenu';
import { playbackManager } from 'components/playback/playbackmanager';
import { appRouter } from 'components/router/appRouter';
import { ItemKind } from 'types/base/models/item-kind';
import type { NullableString } from 'types/base/common/shared/types';
import type { ItemDto } from 'types/base/models/item-dto';
interface PlayAllFromHereOptions {
item: ItemDto;
items: ItemDto[];
serverId: NullableString;
queue?: boolean;
}
function playAllFromHere(opts: PlayAllFromHereOptions) {
const { item, items, serverId, queue } = opts;
const ids = [];
let foundCard = false;
let startIndex;
for (let i = 0, length = items?.length; i < length; i++) {
if (items[i] === item) {
foundCard = true;
startIndex = i;
}
if (foundCard || !queue) {
ids.push(items[i].Id);
}
}
if (!ids.length) {
return Promise.resolve();
}
if (queue) {
return playbackManager.queue({
ids,
serverId
});
} else {
return playbackManager.play({
ids,
serverId,
startIndex
});
}
}
export interface ContextMenuOpts {
open?: boolean;
play?: boolean;
playAllFromHere?: boolean;
queueAllFromHere?: boolean;
cancelTimer?: boolean;
record?: boolean;
deleteItem?: boolean;
shuffle?: boolean;
instantMix?: boolean;
share?: boolean;
stopPlayback?: boolean;
clearQueue?: boolean;
queue?: boolean;
playlist?: boolean;
edit?: boolean;
editImages?: boolean;
editSubtitles?: boolean;
identify?: boolean;
moremediainfo?: boolean;
openAlbum?: boolean;
openArtist?: boolean;
openLyrics?: boolean;
}
interface MoreCommandsButtonProps {
itemType: ItemKind;
selectedItemId?: string;
itemId?: string;
items?: ItemDto[] | null;
collectionId?: NullableString;
playlistId?: NullableString;
canEditPlaylist?: boolean;
itemPlaylistItemId?: NullableString;
contextMenuOpts?: ContextMenuOpts;
queryKey?: string[];
}
const MoreCommandsButton: FC<MoreCommandsButtonProps> = ({
itemType,
selectedItemId,
itemId,
collectionId,
playlistId,
canEditPlaylist,
itemPlaylistItemId,
contextMenuOpts,
items,
queryKey
}) => {
const { user } = useApi();
const queryClient = useQueryClient();
const { data: item } = useGetItemByType({
itemType,
itemId: selectedItemId || itemId || ''
});
const parentId = item?.SeasonId || item?.SeriesId || item?.ParentId;
const [ hasCommands, setHasCommands ] = useState(false);
const playlistItem = useMemo(() => {
let PlaylistItemId: string | null = null;
let PlaylistIndex = -1;
let PlaylistItemCount = 0;
if (playlistId) {
PlaylistItemId = itemPlaylistItemId || null;
if (items?.length) {
PlaylistItemCount = items.length;
PlaylistIndex = items.findIndex(listItem => listItem.PlaylistItemId === PlaylistItemId);
}
}
return { PlaylistItemId, PlaylistIndex, PlaylistItemCount };
}, [itemPlaylistItemId, items, playlistId]);
const defaultMenuOptions = useMemo(() => {
return {
item: {
...item,
...playlistItem
},
user: user,
play: true,
queue: true,
playAllFromHere: item?.Type === ItemKind.Season || !item?.IsFolder,
queueAllFromHere: !item?.IsFolder,
canEditPlaylist: canEditPlaylist,
playlistId: playlistId,
collectionId: collectionId,
...contextMenuOpts
};
}, [canEditPlaylist, collectionId, contextMenuOpts, item, playlistId, playlistItem, user]);
const onMoreCommandsClick = useCallback(
async (e: React.MouseEvent<HTMLElement>) => {
itemContextMenu
.show({
...defaultMenuOptions,
positionTo: e.currentTarget
})
.then(async function (result) {
if (result.command === 'playallfromhere') {
console.log('handleItemClick', {
item,
items: items || [],
serverId: item?.ServerId
});
playAllFromHere({
item: item || {},
items: items || [],
serverId: item?.ServerId
}).catch(err => {
console.error('[MoreCommandsButton] failed to play', err);
});
} else if (result.command === 'queueallfromhere') {
playAllFromHere({
item: item || {},
items: items || [],
serverId: item?.ServerId,
queue: true
}).catch(err => {
console.error('[MoreCommandsButton] failed to play', err);
});
} else if (result.deleted) {
if (result?.itemId !== itemId) {
await queryClient.invalidateQueries({
queryKey
});
} else if (parentId) {
appRouter.showItem(parentId, item?.ServerId);
} else {
await appRouter.goHome();
}
} else if (result.updated) {
await queryClient.invalidateQueries({
queryKey
});
}
})
.catch(() => {
/* no-op */
});
},
[defaultMenuOptions, item, itemId, items, parentId, queryClient, queryKey]
);
useEffect(() => {
const getCommands = async () => {
const commands = await itemContextMenu.getCommands(defaultMenuOptions);
setHasCommands(commands.length > 0);
};
void getCommands();
}, [ defaultMenuOptions ]);
if (item && hasCommands) {
return (
<IconButton
className='button-flat btnMoreCommands'
title={globalize.translate('ButtonMore')}
onClick={onMoreCommandsClick}
>
<MoreVertIcon />
</IconButton>
);
}
return null;
};
export default MoreCommandsButton;

View File

@@ -1,91 +0,0 @@
import React, { FC, useCallback, useMemo } from 'react';
import { IconButton } from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import ReplayIcon from '@mui/icons-material/Replay';
import { useQueryClient } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { getChannelQuery } from 'hooks/api/liveTvHooks/useGetChannel';
import globalize from 'lib/globalize';
import { playbackManager } from 'components/playback/playbackmanager';
import type { ItemDto } from 'types/base/models/item-dto';
import { ItemKind } from 'types/base/models/item-kind';
import itemHelper from 'components/itemHelper';
interface PlayOrResumeButtonProps {
item: ItemDto;
isResumable?: boolean;
selectedMediaSourceId?: string | null;
selectedAudioTrack?: number;
selectedSubtitleTrack?: number;
}
const PlayOrResumeButton: FC<PlayOrResumeButtonProps> = ({
item,
isResumable,
selectedMediaSourceId,
selectedAudioTrack,
selectedSubtitleTrack
}) => {
const apiContext = useApi();
const queryClient = useQueryClient();
const playOptions = useMemo(() => {
if (itemHelper.supportsMediaSourceSelection(item)) {
return {
startPositionTicks:
item.UserData && isResumable ?
item.UserData.PlaybackPositionTicks :
0,
mediaSourceId: selectedMediaSourceId,
audioStreamIndex: selectedAudioTrack || null,
subtitleStreamIndex: selectedSubtitleTrack
};
}
}, [
item,
isResumable,
selectedMediaSourceId,
selectedAudioTrack,
selectedSubtitleTrack
]);
const onPlayClick = useCallback(async () => {
if (item.Type === ItemKind.Program && item.ChannelId) {
const channel = await queryClient.fetchQuery(
getChannelQuery(apiContext, {
channelId: item.ChannelId
})
);
playbackManager.play({
items: [channel]
}).catch(err => {
console.error('[PlayOrResumeButton] failed to play', err);
});
return;
}
playbackManager.play({
items: [item],
...playOptions
}).catch(err => {
console.error('[PlayOrResumeButton] failed to play', err);
});
}, [apiContext, item, playOptions, queryClient]);
return (
<IconButton
className='button-flat btnPlayOrResume'
data-action={isResumable ? 'resume' : 'play'}
title={
isResumable ?
globalize.translate('ButtonResume') :
globalize.translate('Play')
}
onClick={onPlayClick}
>
{isResumable ? <ReplayIcon /> : <PlayArrowIcon />}
</IconButton>
);
};
export default PlayOrResumeButton;

View File

@@ -1,28 +0,0 @@
import React, { FC, useCallback } from 'react';
import { IconButton } from '@mui/material';
import TheatersIcon from '@mui/icons-material/Theaters';
import { playbackManager } from 'components/playback/playbackmanager';
import globalize from 'lib/globalize';
import type { ItemDto } from 'types/base/models/item-dto';
interface PlayTrailerButtonProps {
item?: ItemDto;
}
const PlayTrailerButton: FC<PlayTrailerButtonProps> = ({ item }) => {
const onPlayTrailerClick = useCallback(async () => {
await playbackManager.playTrailers(item);
}, [item]);
return (
<IconButton
className='button-flat btnPlayTrailer'
title={globalize.translate('ButtonTrailer')}
onClick={onPlayTrailerClick}
>
<TheatersIcon />
</IconButton>
);
};
export default PlayTrailerButton;

View File

@@ -1,29 +0,0 @@
import React, { FC, useCallback } from 'react';
import { IconButton } from '@mui/material';
import ShuffleIcon from '@mui/icons-material/Shuffle';
import { playbackManager } from 'components/playback/playbackmanager';
import globalize from 'lib/globalize';
import type { ItemDto } from 'types/base/models/item-dto';
interface ShuffleButtonProps {
item: ItemDto;
}
const ShuffleButton: FC<ShuffleButtonProps> = ({ item }) => {
const shuffle = useCallback(() => {
playbackManager.shuffle(item);
}, [item]);
return (
<IconButton
title={globalize.translate('Shuffle')}
className='button-flat btnShuffle'
onClick={shuffle}
>
<ShuffleIcon />
</IconButton>
);
};
export default ShuffleButton;

View File

@@ -1,68 +0,0 @@
import React, { FC, useCallback } from 'react';
import { IconButton } from '@mui/material';
import CallSplitIcon from '@mui/icons-material/CallSplit';
import { useQueryClient } from '@tanstack/react-query';
import { useDeleteAlternateSources } from 'hooks/api/videosHooks';
import globalize from 'lib/globalize';
import confirm from 'components/confirm/confirm';
import loading from 'components/loading/loading';
import toast from 'components/toast/toast';
interface SplitVersionsButtonProps {
paramId: string;
queryKey?: string[];
}
const SplitVersionsButton: FC<SplitVersionsButtonProps> = ({
paramId,
queryKey
}) => {
const queryClient = useQueryClient();
const deleteAlternateSources = useDeleteAlternateSources();
const splitVersions = useCallback(() => {
confirm({
title: globalize.translate('HeaderSplitMediaApart'),
text: globalize.translate('MessageConfirmSplitMediaSources')
})
.then(function () {
loading.show();
deleteAlternateSources.mutate(
{
itemId: paramId
},
{
onSuccess: async () => {
loading.hide();
await queryClient.invalidateQueries({
queryKey
});
},
onError: (err: unknown) => {
loading.hide();
toast(globalize.translate('MessageSplitVersionsError'));
console.error(
'[splitVersions] failed to split versions',
err
);
}
}
);
})
.catch(() => {
// confirm dialog closed
});
}, [deleteAlternateSources, paramId, queryClient, queryKey]);
return (
<IconButton
className='button-flat btnSplitVersions'
title={globalize.translate('ButtonSplit')}
onClick={splitVersions}
>
<CallSplitIcon />
</IconButton>
);
};
export default SplitVersionsButton;

View File

@@ -1,62 +0,0 @@
import type { AxiosRequestConfig } from 'axios';
import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api/user-library-api';
import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api';
import { useQuery } from '@tanstack/react-query';
import { type JellyfinApiContext, useApi } from 'hooks/useApi';
import type { ItemDto } from 'types/base/models/item-dto';
import { ItemKind } from 'types/base/models/item-kind';
const getItemByType = async (
apiContext: JellyfinApiContext,
itemType: ItemKind,
itemId: string,
options?: AxiosRequestConfig
) => {
const { api, user } = apiContext;
if (!api) throw new Error('[getItemByType] No API instance available');
if (!user?.Id) throw new Error('[getItemByType] No User ID provided');
let response;
switch (itemType) {
case ItemKind.Timer: {
response = await getLiveTvApi(api).getTimer(
{ timerId: itemId },
options
);
break;
}
case ItemKind.SeriesTimer:
response = await getLiveTvApi(api).getSeriesTimer(
{ timerId: itemId },
options
);
break;
default: {
response = await getUserLibraryApi(api).getItem(
{ userId: user.Id, itemId },
options
);
break;
}
}
return response.data as ItemDto;
};
interface UseGetItemByTypeProps {
itemType: ItemKind;
itemId: string;
}
export const useGetItemByType = ({
itemType,
itemId
}: UseGetItemByTypeProps) => {
const apiContext = useApi();
return useQuery({
queryKey: ['ItemByType', { itemType, itemId }],
queryFn: ({ signal }) =>
getItemByType(apiContext, itemType, itemId, { signal }),
enabled: !!apiContext.api && !!apiContext.user?.Id && !!itemId
});
};

View File

@@ -5,8 +5,6 @@ import globalize from '../../../lib/globalize';
import { clearBackdrop } from '../../../components/backdrop/backdrop';
import layoutManager from '../../../components/layoutManager';
import Page from '../../../components/Page';
import { EventType } from 'types/eventType';
import Events from 'utils/events';
import '../../../elements/emby-tabs/emby-tabs';
import '../../../elements/emby-button/emby-button';
@@ -34,8 +32,6 @@ const Home = () => {
const mainTabsManager = useMemo(() => import('../../../components/maintabsmanager'), []);
const tabController = useRef<ControllerProps | null>();
const tabControllers = useMemo<ControllerProps[]>(() => [], []);
const documentRef = useRef<Document>(document);
const element = useRef<HTMLDivElement>(null);
const setTitle = async () => {
@@ -126,7 +122,7 @@ const Home = () => {
} else if (currentTabController?.onResume) {
currentTabController.onResume({});
}
(documentRef.current.querySelector('.skinHeader') as HTMLDivElement).classList.add('noHomeButtonHeader');
(document.querySelector('.skinHeader') as HTMLDivElement).classList.add('noHomeButtonHeader');
}, [ initialTabIndex, mainTabsManager ]);
const onPause = useCallback(() => {
@@ -134,32 +130,17 @@ const Home = () => {
if (currentTabController?.onPause) {
currentTabController.onPause();
}
(documentRef.current.querySelector('.skinHeader') as HTMLDivElement).classList.remove('noHomeButtonHeader');
(document.querySelector('.skinHeader') as HTMLDivElement).classList.remove('noHomeButtonHeader');
}, []);
const renderHome = useCallback(() => {
void onSetTabs();
void onResume();
}, [ onResume, onSetTabs ]);
useEffect(() => {
if (documentRef.current?.querySelector('.headerTabs')) {
renderHome();
}
void onSetTabs();
void onResume();
return () => {
onPause();
};
}, [onPause, renderHome]);
useEffect(() => {
const doc = documentRef.current;
if (doc) Events.on(doc, EventType.HEADER_RENDERED, renderHome);
return () => {
if (doc) Events.off(doc, EventType.HEADER_RENDERED, renderHome);
};
}, [ renderHome ]);
}, [ onPause, onResume, onSetTabs ]);
return (
<div ref={element}>

View File

@@ -1,9 +1,12 @@
import React from 'react';
import { Navigate, RouteObject } from 'react-router-dom';
import { REDIRECTS } from 'apps/dashboard/routes/_redirects';
import ConnectionRequired from 'components/ConnectionRequired';
import { toAsyncPageRoute } from 'components/router/AsyncRoute';
import BangRedirect from 'components/router/BangRedirect';
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
import { toRedirectRoute } from 'components/router/Redirect';
import ErrorBoundary from 'components/router/ErrorBoundary';
import { ASYNC_USER_ROUTES } from './asyncRoutes';
@@ -35,5 +38,13 @@ export const EXPERIMENTAL_APP_ROUTES: RouteObject[] = [
{ index: true, element: <Navigate replace to='/home.html' /> },
...LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute)
]
}
},
{
path: '!/*',
Component: BangRedirect
},
/* Redirects for old paths */
...REDIRECTS.map(toRedirectRoute)
];

View File

@@ -15,8 +15,8 @@ import { useApi } from 'hooks/useApi';
import { useThemes } from 'hooks/useThemes';
import globalize from 'lib/globalize';
import { useScreensavers } from '../hooks/useScreensavers';
import type { DisplaySettingsValues } from '../types/displaySettingsValues';
import { DisplaySettingsValues } from './types';
import { useScreensavers } from './hooks/useScreensavers';
interface DisplayPreferencesProps {
onChange: (event: SelectChangeEvent | React.SyntheticEvent) => void;

View File

@@ -7,8 +7,7 @@ import Typography from '@mui/material/Typography';
import React from 'react';
import globalize from 'lib/globalize';
import type { DisplaySettingsValues } from '../types/displaySettingsValues';
import { DisplaySettingsValues } from './types';
interface ItemDetailPreferencesProps {
onChange: (event: React.SyntheticEvent) => void;

View File

@@ -8,8 +8,7 @@ import Typography from '@mui/material/Typography';
import React from 'react';
import globalize from 'lib/globalize';
import type { DisplaySettingsValues } from '../types/displaySettingsValues';
import { DisplaySettingsValues } from './types';
interface LibraryPreferencesProps {
onChange: (event: React.SyntheticEvent) => void;

View File

@@ -8,12 +8,11 @@ import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import React from 'react';
import { DATE_LOCALE_OPTIONS, LANGUAGE_OPTIONS } from 'apps/experimental/features/preferences/constants/locales';
import { appHost } from 'components/apphost';
import datetime from 'scripts/datetime';
import globalize from 'lib/globalize';
import type { DisplaySettingsValues } from '../types/displaySettingsValues';
import { DATE_LOCALE_OPTIONS, LANGUAGE_OPTIONS } from './constants';
import { DisplaySettingsValues } from './types';
interface LocalizationPreferencesProps {
onChange: (event: SelectChangeEvent) => void;

View File

@@ -8,8 +8,7 @@ import Typography from '@mui/material/Typography';
import React from 'react';
import globalize from 'lib/globalize';
import type { DisplaySettingsValues } from '../types/displaySettingsValues';
import { DisplaySettingsValues } from './types';
interface NextUpPreferencesProps {
onChange: (event: React.SyntheticEvent) => void;

View File

@@ -3,8 +3,7 @@ import { useSearchParams } from 'react-router-dom';
import toast from 'components/toast/toast';
import globalize from 'lib/globalize';
import type { DisplaySettingsValues } from '../types/displaySettingsValues';
import { DisplaySettingsValues } from '../types';
import { useDisplaySettings } from './useDisplaySettings';
type UpdateField = {

View File

@@ -1,4 +1,4 @@
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
import { UserDto } from '@jellyfin/sdk/lib/generated-client';
import { ApiClient } from 'jellyfin-apiclient';
import { useCallback, useEffect, useState } from 'react';
@@ -7,11 +7,10 @@ import layoutManager from 'components/layoutManager';
import { useApi } from 'hooks/useApi';
import themeManager from 'scripts/themeManager';
import { currentSettings, UserSettings } from 'scripts/settings/userSettings';
import type { DisplaySettingsValues } from '../types/displaySettingsValues';
import { DisplaySettingsValues } from '../types';
interface UseDisplaySettingsParams {
userId?: string | null;
userId?: string | null;
}
export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {

View File

@@ -3,16 +3,16 @@ import { SelectChangeEvent } from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import React, { useCallback } from 'react';
import { DisplayPreferences } from 'apps/experimental/features/preferences/components/DisplayPreferences';
import { ItemDetailPreferences } from 'apps/experimental/features/preferences/components/ItemDetailPreferences';
import { LibraryPreferences } from 'apps/experimental/features/preferences/components/LibraryPreferences';
import { useDisplaySettingForm } from 'apps/experimental/features/preferences/hooks/useDisplaySettingForm';
import { LocalizationPreferences } from 'apps/experimental/features/preferences/components/LocalizationPreferences';
import { NextUpPreferences } from 'apps/experimental/features/preferences/components/NextUpPreferences';
import type { DisplaySettingsValues } from 'apps/experimental/features/preferences/types/displaySettingsValues';
import LoadingComponent from 'components/loading/LoadingComponent';
import Page from 'components/Page';
import globalize from 'lib/globalize';
import { DisplayPreferences } from './DisplayPreferences';
import { ItemDetailPreferences } from './ItemDetailPreferences';
import { LibraryPreferences } from './LibraryPreferences';
import { LocalizationPreferences } from './LocalizationPreferences';
import { NextUpPreferences } from './NextUpPreferences';
import { useDisplaySettingForm } from './hooks/useDisplaySettingForm';
import { DisplaySettingsValues } from './types';
import LoadingComponent from 'components/loading/LoadingComponent';
export default function UserDisplayPreferences() {
const {

View File

@@ -1,8 +0,0 @@
/**
* Actions that are triggered for media segments.
*/
export enum MediaSegmentAction {
None = 'None',
AskToSkip = 'AskToSkip',
Skip = 'Skip'
}

View File

@@ -1,14 +0,0 @@
/**
* Events triggered by PlaybackManager.
*/
export enum PlaybackManagerEvent {
Pairing = 'pairing',
Paired = 'paired',
PairError = 'pairerror',
PlaybackCancelled = 'playbackcancelled',
PlaybackError = 'playbackerror',
PlaybackStart = 'playbackstart',
PlaybackStop = 'playbackstop',
PlayerChange = 'playerchange',
ReportPlayback = 'reportplayback'
}

View File

@@ -1,24 +0,0 @@
/**
* Events triggered by media player plugins.
* TODO: This list is incomplete
*/
export enum PlayerEvent {
Error = 'error',
FullscreenChange = 'fullscreenchange',
ItemStarted = 'itemstarted',
ItemStopped = 'itemstopped',
MediaStreamsChange = 'mediastreamschange',
Pause = 'pause',
PlaybackStart = 'playbackstart',
PlaybackStop = 'playbackstop',
PlaylistItemAdd = 'playlistitemadd',
PlaylistItemMove = 'playlistitemmove',
PlaylistItemRemove = 'playlistitemremove',
PromptSkip = 'promptskip',
RepeatModeChange = 'repeatmodechange',
ShuffleModeChange = 'shufflequeuemodechange',
Stopped = 'stopped',
TimeUpdate = 'timeupdate',
Unpause = 'unpause',
VolumeChange = 'volumechange'
}

View File

@@ -1,33 +0,0 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import type { MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client/models/media-source-info';
import type { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
import type { StreamInfo } from './streamInfo';
export interface ManagedPlayerStopInfo {
item: BaseItemDto
mediaSource: MediaSourceInfo
nextItem?: BaseItemDto | null
nextMediaType?: MediaType | null
positionMs?: number
}
export interface MovedItem {
newIndex: number
playlistItemId: string
}
export type PlayerErrorCode = string;
export interface PlayerStopInfo {
src?: URL | BaseItemDto
}
export interface PlayerError {
streamInfo?: StreamInfo
type: MediaError | string
}
export interface RemovedItems {
playlistItemIds: string[]
}

View File

@@ -1,34 +0,0 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import type { MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client/models/media-source-info';
import type { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
import type { PlayMethod } from '@jellyfin/sdk/lib/generated-client/models/play-method';
export interface StreamInfo {
ended?: boolean
fullscreen?: boolean
item?: BaseItemDto
lastMediaInfoQuery?: number
liveStreamId?: string
mediaSource?: MediaSourceInfo
mediaType?: MediaType
mimeType?: string
playMethod?: PlayMethod
playSessionId?: string
playbackStartTimeTicks?: number
playerStartPositionTicks?: number
resetSubtitleOffset?: boolean
started?: boolean
textTracks?: TrackInfo[]
title?: string
tracks?: TrackInfo[]
transcodingOffsetTicks?: number
url?: string
}
interface TrackInfo {
url: string
language: string
isDefault: boolean
index: number
format: string
}

View File

@@ -1,145 +0,0 @@
import type { Api } from '@jellyfin/sdk/lib/api';
import type { MediaSegmentDto } from '@jellyfin/sdk/lib/generated-client/models/media-segment-dto';
import { MediaSegmentType } from '@jellyfin/sdk/lib/generated-client/models/media-segment-type';
import { MediaSegmentsApi } from '@jellyfin/sdk/lib/generated-client/api/media-segments-api';
import type { PlaybackManager } from 'components/playback/playbackmanager';
import ServerConnections from 'components/ServerConnections';
import { TICKS_PER_MILLISECOND, TICKS_PER_SECOND } from 'constants/time';
import { currentSettings as userSettings } from 'scripts/settings/userSettings';
import type { PlayerState } from 'types/playbackStopInfo';
import type { Event } from 'utils/events';
import { toApi } from 'utils/jellyfin-apiclient/compat';
import { getMediaSegmentAction } from './mediaSegmentSettings';
import { findCurrentSegment } from './mediaSegments';
import { PlaybackSubscriber } from './playbackSubscriber';
import { MediaSegmentAction } from '../constants/mediaSegmentAction';
class MediaSegmentManager extends PlaybackSubscriber {
private hasSegments = false;
private isLastSegmentIgnored = false;
private lastSegmentIndex = 0;
private lastTime = -1;
private mediaSegmentTypeActions: Record<Partial<MediaSegmentType>, MediaSegmentAction> | undefined;
private mediaSegments: MediaSegmentDto[] = [];
private async fetchMediaSegments(api: Api, itemId: string, includeSegmentTypes: MediaSegmentType[]) {
// FIXME: Replace with SDK getMediaSegmentsApi function when available in stable
const mediaSegmentsApi = new MediaSegmentsApi(api.configuration, undefined, api.axiosInstance);
try {
const { data: mediaSegments } = await mediaSegmentsApi.getItemSegments({ itemId, includeSegmentTypes });
this.mediaSegments = mediaSegments.Items || [];
} catch (err) {
console.error('[MediaSegmentManager] failed to fetch segments', err);
this.mediaSegments = [];
}
}
skipSegment(mediaSegment: MediaSegmentDto) {
// Ignore segment if playback progress has passed the segment's start time
if (mediaSegment.StartTicks !== undefined && this.lastTime > mediaSegment.StartTicks) {
console.info('[MediaSegmentManager] ignoring skipping segment that has been seeked back into', mediaSegment);
this.isLastSegmentIgnored = true;
} else if (mediaSegment.EndTicks) {
// If there is an end time, seek to it
// Do not skip if duration < 1s to avoid slow stream changes
if (mediaSegment.StartTicks && mediaSegment.EndTicks - mediaSegment.StartTicks < TICKS_PER_SECOND) {
console.info('[MediaSegmentManager] ignoring skipping segment with duration <1s', mediaSegment);
this.isLastSegmentIgnored = true;
return;
}
console.debug('[MediaSegmentManager] skipping to %s ms', mediaSegment.EndTicks / TICKS_PER_MILLISECOND);
this.playbackManager.seek(mediaSegment.EndTicks, this.player);
} else {
// If there is no end time, skip to the next track
console.debug('[MediaSegmentManager] skipping to next item in queue');
this.playbackManager.nextTrack(this.player);
}
}
promptToSkip(mediaSegment: MediaSegmentDto) {
if (mediaSegment.StartTicks && mediaSegment.EndTicks
&& mediaSegment.EndTicks - mediaSegment.StartTicks < TICKS_PER_SECOND * 3) {
console.info('[MediaSegmentManager] ignoring segment prompt with duration <3s', mediaSegment);
this.isLastSegmentIgnored = true;
return;
}
this.playbackManager.promptToSkip(mediaSegment);
}
private performAction(mediaSegment: MediaSegmentDto) {
if (!this.mediaSegmentTypeActions || !mediaSegment.Type || !this.mediaSegmentTypeActions[mediaSegment.Type]) {
console.error('[MediaSegmentManager] segment type missing from action map', mediaSegment, this.mediaSegmentTypeActions);
return;
}
const action = this.mediaSegmentTypeActions[mediaSegment.Type];
if (action === MediaSegmentAction.Skip) {
this.skipSegment(mediaSegment);
} else if (action === MediaSegmentAction.AskToSkip) {
this.promptToSkip(mediaSegment);
}
}
onPlayerPlaybackStart(_e: Event, state: PlayerState) {
this.isLastSegmentIgnored = false;
this.lastSegmentIndex = 0;
this.lastTime = -1;
this.hasSegments = !!state.MediaSource?.HasSegments;
const itemId = state.MediaSource?.Id;
const serverId = state.NowPlayingItem?.ServerId || ServerConnections.currentApiClient()?.serverId();
if (!this.hasSegments || !serverId || !itemId) return;
// Get the user settings for media segment actions
this.mediaSegmentTypeActions = Object.values(MediaSegmentType)
.map(type => ({
type,
action: getMediaSegmentAction(userSettings, type)
}))
.filter(({ action }) => !!action && action !== MediaSegmentAction.None)
.reduce((acc, { type, action }) => {
if (action) acc[type] = action;
return acc;
}, {} as Record<Partial<MediaSegmentType>, MediaSegmentAction>);
if (!Object.keys(this.mediaSegmentTypeActions).length) {
console.info('[MediaSegmentManager] user has no media segment actions enabled');
return;
}
const api = toApi(ServerConnections.getApiClient(serverId));
void this.fetchMediaSegments(
api,
itemId,
Object.keys(this.mediaSegmentTypeActions).map(t => t as keyof typeof MediaSegmentType));
}
onPlayerTimeUpdate() {
if (this.hasSegments && this.mediaSegments.length) {
const time = this.playbackManager.currentTime(this.player) * TICKS_PER_MILLISECOND;
const currentSegmentDetails = findCurrentSegment(this.mediaSegments, time, this.lastSegmentIndex);
if (
// The current time falls within a segment
currentSegmentDetails
// and the last segment is not ignored or the segment index has changed
&& (!this.isLastSegmentIgnored || this.lastSegmentIndex !== currentSegmentDetails.index)
) {
console.debug(
'[MediaSegmentManager] found %s segment at %s ms',
currentSegmentDetails.segment.Type,
time / TICKS_PER_MILLISECOND,
currentSegmentDetails);
this.isLastSegmentIgnored = false;
this.performAction(currentSegmentDetails.segment);
this.lastSegmentIndex = currentSegmentDetails.index;
}
this.lastTime = time;
}
}
}
export const bindMediaSegmentManager = (playbackManager: PlaybackManager) => new MediaSegmentManager(playbackManager);

View File

@@ -1,20 +0,0 @@
import { MediaSegmentType } from '@jellyfin/sdk/lib/generated-client/models/media-segment-type';
import { UserSettings } from 'scripts/settings/userSettings';
import { MediaSegmentAction } from '../constants/mediaSegmentAction';
const PREFIX = 'segmentTypeAction';
const DEFAULT_ACTIONS: Partial<Record<MediaSegmentType, MediaSegmentAction>> = {
[MediaSegmentType.Intro]: MediaSegmentAction.AskToSkip,
[MediaSegmentType.Outro]: MediaSegmentAction.AskToSkip
};
export const getId = (type: MediaSegmentType) => `${PREFIX}__${type}`;
export function getMediaSegmentAction(userSettings: UserSettings, type: MediaSegmentType): MediaSegmentAction {
const action = userSettings.get(getId(type), false);
const defaultAction = DEFAULT_ACTIONS[type] || MediaSegmentAction.None;
return action ? action as MediaSegmentAction : defaultAction;
}

View File

@@ -1,68 +0,0 @@
import type { MediaSegmentDto } from '@jellyfin/sdk/lib/generated-client/models/media-segment-dto';
import { MediaSegmentType } from '@jellyfin/sdk/lib/generated-client/models/media-segment-type';
import { describe, expect, it } from 'vitest';
import { findCurrentSegment } from './mediaSegments';
const TEST_SEGMENTS: MediaSegmentDto[] = [
{
Id: 'intro',
Type: MediaSegmentType.Intro,
StartTicks: 0,
EndTicks: 10
},
{
Id: 'preview',
Type: MediaSegmentType.Preview,
StartTicks: 20,
EndTicks: 30
},
{
Id: 'recap',
Type: MediaSegmentType.Recap,
StartTicks: 30,
EndTicks: 40
},
{
Id: 'commercial',
Type: MediaSegmentType.Commercial,
StartTicks: 40,
EndTicks: 50
},
{
Id: 'outro',
Type: MediaSegmentType.Outro,
StartTicks: 50,
EndTicks: 60
}
];
describe('findCurrentSegment()', () => {
it('Should return the current segment', () => {
let segmentDetails = findCurrentSegment(TEST_SEGMENTS, 23);
expect(segmentDetails).toBeDefined();
expect(segmentDetails?.index).toBe(1);
expect(segmentDetails?.segment?.Id).toBe('preview');
segmentDetails = findCurrentSegment(TEST_SEGMENTS, 5, 1);
expect(segmentDetails).toBeDefined();
expect(segmentDetails?.index).toBe(0);
expect(segmentDetails?.segment?.Id).toBe('intro');
segmentDetails = findCurrentSegment(TEST_SEGMENTS, 42, 3);
expect(segmentDetails).toBeDefined();
expect(segmentDetails?.index).toBe(3);
expect(segmentDetails?.segment?.Id).toBe('commercial');
});
it('Should return undefined if not in a segment', () => {
let segmentDetails = findCurrentSegment(TEST_SEGMENTS, 16);
expect(segmentDetails).toBeUndefined();
segmentDetails = findCurrentSegment(TEST_SEGMENTS, 10, 1);
expect(segmentDetails).toBeUndefined();
segmentDetails = findCurrentSegment(TEST_SEGMENTS, 100);
expect(segmentDetails).toBeUndefined();
});
});

View File

@@ -1,41 +0,0 @@
import type { MediaSegmentDto } from '@jellyfin/sdk/lib/generated-client/models/media-segment-dto';
const isBeforeSegment = (segment: MediaSegmentDto, time: number, direction: number) => {
if (direction === -1) {
return (
typeof segment.EndTicks !== 'undefined'
&& segment.EndTicks <= time
);
}
return (
typeof segment.StartTicks !== 'undefined'
&& segment.StartTicks > time
);
};
export const isInSegment = (segment: MediaSegmentDto, time: number) => (
typeof segment.StartTicks !== 'undefined'
&& segment.StartTicks <= time
&& (typeof segment.EndTicks === 'undefined' || segment.EndTicks > time)
);
export const findCurrentSegment = (segments: MediaSegmentDto[], time: number, lastIndex = 0) => {
const lastSegment = segments[lastIndex];
if (isInSegment(lastSegment, time)) {
return { index: lastIndex, segment: lastSegment };
}
let direction = 1;
if (lastIndex > 0 && lastSegment.StartTicks && lastSegment.StartTicks > time) {
direction = -1;
}
for (
let index = lastIndex, segment = segments[index];
index >= 0 && index < segments.length;
index += direction, segment = segments[index]
) {
if (isBeforeSegment(segment, time, direction)) return;
if (isInSegment(segment, time)) return { index, segment };
}
};

View File

@@ -1,104 +0,0 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import type { MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client/models/media-source-info';
import type { PlaybackManager } from 'components/playback/playbackmanager';
import type { MediaError } from 'types/mediaError';
import type { PlayTarget } from 'types/playTarget';
import type { PlaybackStopInfo, PlayerState } from 'types/playbackStopInfo';
import type { Plugin } from 'types/plugin';
import Events, { type Event } from 'utils/events';
import { PlaybackManagerEvent } from '../constants/playbackManagerEvent';
import { PlayerEvent } from '../constants/playerEvent';
import type { ManagedPlayerStopInfo, MovedItem, PlayerError, PlayerErrorCode, PlayerStopInfo, RemovedItems } from '../types/callbacks';
import type { MediaSegmentDto } from '@jellyfin/sdk/lib/generated-client/models/media-segment-dto';
export interface PlaybackSubscriber {
onPlaybackCancelled?(e: Event): void
onPlaybackError?(e: Event, errorType: MediaError): void
onPlaybackStart?(e: Event, player: Plugin, state: PlayerState): void
onPlaybackStop?(e: Event, info: PlaybackStopInfo): void
onPlayerChange?(e: Event, player: Plugin, target: PlayTarget, previousPlayer: Plugin): void
onPromptSkip?(e: Event, mediaSegment: MediaSegmentDto): void
onPlayerError?(e: Event, error: PlayerError): void
onPlayerFullscreenChange?(e: Event): void
onPlayerItemStarted?(e: Event, item?: BaseItemDto, mediaSource?: MediaSourceInfo): void
onPlayerItemStopped?(e: Event, info: ManagedPlayerStopInfo): void
onPlayerMediaStreamsChange?(e: Event): void
onPlayerPause?(e: Event): void
onPlayerPlaybackStart?(e: Event, state: PlayerState): void
onPlayerPlaybackStop?(e: Event, state: PlayerState): void
onPlayerPlaylistItemAdd?(e: Event): void
onPlayerPlaylistItemMove?(e: Event, item: MovedItem): void
onPlayerPlaylistItemRemove?(e: Event, items?: RemovedItems): void
onPlayerRepeatModeChange?(e: Event): void
onPlayerShuffleModeChange?(e: Event): void
onPlayerStopped?(e: Event, info?: PlayerStopInfo | PlayerErrorCode): void
onPlayerTimeUpdate?(e: Event): void
onPlayerUnpause?(e: Event): void
onPlayerVolumeChange?(e: Event): void
onReportPlayback?(e: Event, isServerItem: boolean): void
}
export abstract class PlaybackSubscriber {
protected player: Plugin | undefined;
private readonly playbackManagerEvents = {
[PlaybackManagerEvent.PlaybackCancelled]: this.onPlaybackCancelled?.bind(this),
[PlaybackManagerEvent.PlaybackError]: this.onPlaybackError?.bind(this),
[PlaybackManagerEvent.PlaybackStart]: this.onPlaybackStart?.bind(this),
[PlaybackManagerEvent.PlaybackStop]: this.onPlaybackStop?.bind(this),
[PlaybackManagerEvent.PlayerChange]: this.onPlayerChange?.bind(this),
[PlaybackManagerEvent.ReportPlayback]: this.onReportPlayback?.bind(this)
};
private readonly playerEvents = {
[PlayerEvent.Error]: this.onPlayerError?.bind(this),
[PlayerEvent.FullscreenChange]: this.onPlayerFullscreenChange?.bind(this),
[PlayerEvent.ItemStarted]: this.onPlayerItemStarted?.bind(this),
[PlayerEvent.ItemStopped]: this.onPlayerItemStopped?.bind(this),
[PlayerEvent.MediaStreamsChange]: this.onPlayerMediaStreamsChange?.bind(this),
[PlayerEvent.Pause]: this.onPlayerPause?.bind(this),
[PlayerEvent.PlaybackStart]: this.onPlayerPlaybackStart?.bind(this),
[PlayerEvent.PlaybackStop]: this.onPlayerPlaybackStop?.bind(this),
[PlayerEvent.PlaylistItemAdd]: this.onPlayerPlaylistItemAdd?.bind(this),
[PlayerEvent.PlaylistItemMove]: this.onPlayerPlaylistItemMove?.bind(this),
[PlayerEvent.PlaylistItemRemove]: this.onPlayerPlaylistItemRemove?.bind(this),
[PlayerEvent.PromptSkip]: this.onPromptSkip?.bind(this),
[PlayerEvent.RepeatModeChange]: this.onPlayerRepeatModeChange?.bind(this),
[PlayerEvent.ShuffleModeChange]: this.onPlayerShuffleModeChange?.bind(this),
[PlayerEvent.Stopped]: this.onPlayerStopped?.bind(this),
[PlayerEvent.TimeUpdate]: this.onPlayerTimeUpdate?.bind(this),
[PlayerEvent.Unpause]: this.onPlayerUnpause?.bind(this),
[PlayerEvent.VolumeChange]: this.onPlayerVolumeChange?.bind(this)
};
constructor(
protected readonly playbackManager: PlaybackManager
) {
Object.entries(this.playbackManagerEvents).forEach(([event, handler]) => {
if (handler) Events.on(playbackManager, event, handler);
});
this.bindPlayerEvents();
Events.on(playbackManager, PlaybackManagerEvent.PlayerChange, this.bindPlayerEvents.bind(this));
}
private bindPlayerEvents() {
const newPlayer = this.playbackManager.getCurrentPlayer();
if (this.player === newPlayer) return;
if (this.player) {
Object.entries(this.playerEvents).forEach(([event, handler]) => {
if (handler) Events.off(this.player, event, handler);
});
}
this.player = newPlayer;
if (!this.player) return;
Object.entries(this.playerEvents).forEach(([event, handler]) => {
if (handler) Events.on(this.player, event, handler);
});
}
}

View File

@@ -0,0 +1,5 @@
import type { Redirect } from 'components/router/Redirect';
export const REDIRECTS: Redirect[] = [
{ from: 'mypreferencesquickconnect.html', to: '/quickconnect' }
];

View File

@@ -4,12 +4,15 @@ import React from 'react';
import ConnectionRequired from 'components/ConnectionRequired';
import { toAsyncPageRoute } from 'components/router/AsyncRoute';
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
import { toRedirectRoute } from 'components/router/Redirect';
import ErrorBoundary from 'components/router/ErrorBoundary';
import AppLayout from '../AppLayout';
import { REDIRECTS } from './_redirects';
import { ASYNC_USER_ROUTES } from './asyncRoutes';
import { LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './legacyRoutes';
import BangRedirect from 'components/router/BangRedirect';
export const STABLE_APP_ROUTES: RouteObject[] = [
{
@@ -30,5 +33,13 @@ export const STABLE_APP_ROUTES: RouteObject[] = [
{ index: true, element: <Navigate replace to='/home.html' /> },
...LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute)
]
}
},
{
path: '!/*',
Component: BangRedirect
},
/* Redirects for old paths */
...REDIRECTS.map(toRedirectRoute)
];

View File

@@ -1,10 +1,11 @@
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
import React, { FunctionComponent, useEffect, useState, useRef, useCallback, useMemo } from 'react';
import React, { FunctionComponent, useEffect, useState, useRef, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import Dashboard from '../../../../utils/dashboard';
import globalize from '../../../../lib/globalize';
import LibraryMenu from '../../../../scripts/libraryMenu';
import { appHost } from '../../../../components/apphost';
import confirm from '../../../../components/confirm/confirm';
import ButtonElement from '../../../../elements/ButtonElement';
@@ -17,7 +18,6 @@ const UserProfile: FunctionComponent = () => {
const [ searchParams ] = useSearchParams();
const userId = searchParams.get('userId');
const [ userName, setUserName ] = useState('');
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
const element = useRef<HTMLDivElement>(null);
@@ -41,7 +41,7 @@ const UserProfile: FunctionComponent = () => {
}
setUserName(user.Name);
void libraryMenu.then(menu => menu.setTitle(user.Name));
LibraryMenu.setTitle(user.Name);
let imageUrl = 'assets/img/avatar.png';
if (user.PrimaryImageTag) {

View File

@@ -3,6 +3,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import type { ConnectResponse } from 'jellyfin-apiclient';
import alert from './alert';
import { appRouter } from './router/appRouter';
import Loading from './loading/LoadingComponent';
import ServerConnections from './ServerConnections';
import globalize from '../lib/globalize';
@@ -148,27 +149,24 @@ const ConnectionRequired: FunctionComponent<ConnectionRequiredProps> = ({
}, [bounce, isAdminRequired, isUserRequired]);
useEffect(() => {
// Check connection status on initial page load
const apiClient = ServerConnections.currentApiClient();
const connection = Promise.resolve(ServerConnections.firstConnection ? null : ServerConnections.connect());
connection.then(firstConnection => {
console.debug('[ConnectionRequired] connection state', firstConnection?.State);
ServerConnections.firstConnection = true;
// 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.
if (firstConnection && firstConnection.State !== ConnectionState.SignedIn && !apiClient?.isLoggedIn()) {
handleIncompleteWizard(firstConnection)
.catch(err => {
console.error('[ConnectionRequired] could not start wizard', err);
});
} else {
validateUserAccess()
.catch(err => {
console.error('[ConnectionRequired] could not validate user access', err);
});
}
}).catch(err => {
console.error('[ConnectionRequired] failed to connect', err);
});
// 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) {

View File

@@ -33,7 +33,6 @@ class ServerConnections extends ConnectionManager {
constructor() {
super(...arguments);
this.localApiClient = null;
this.firstConnection = null;
// Set the apiclient minimum version to match the SDK
this._minServerVersion = MINIMUM_VERSION;

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