Compare commits
2 Commits
release-10
...
sortmedias
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8c2956cc2 | ||
|
|
38dd4c5f43 |
@@ -5,6 +5,7 @@
|
||||
"not": [
|
||||
"./dist/libraries/pdf.worker.js",
|
||||
"./dist/libraries/worker-bundle.js",
|
||||
"./dist/libraries/wasm-gen/libarchive.js",
|
||||
"./dist/serviceworker.js"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
node_modules
|
||||
coverage
|
||||
dist
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
49
.eslintrc.js
49
.eslintrc.js
@@ -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
14
.github/renovate.json
vendored
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
40
.github/workflows/__codeql.yml
vendored
40
.github/workflows/__codeql.yml
vendored
@@ -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}}'
|
||||
59
.github/workflows/__deploy.yml
vendored
59
.github/workflows/__deploy.yml
vendored
@@ -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 }}
|
||||
65
.github/workflows/__job_messages.yml
vendored
65
.github/workflows/__job_messages.yml
vendored
@@ -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 }}
|
||||
45
.github/workflows/__package.yml
vendored
45
.github/workflows/__package.yml
vendored
@@ -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
|
||||
61
.github/workflows/__quality_checks.yml
vendored
61
.github/workflows/__quality_checks.yml
vendored
@@ -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 }}
|
||||
@@ -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
129
.github/workflows/build.yml
vendored
Normal 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
34
.github/workflows/codeql.yml
vendored
Normal 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
36
.github/workflows/commands.yml
vendored
Normal 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
36
.github/workflows/pr-suggestions.yml
vendored
Normal 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 }}
|
||||
100
.github/workflows/pull_request.yml
vendored
100
.github/workflows/pull_request.yml
vendored
@@ -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 }}
|
||||
58
.github/workflows/push.yml
vendored
58
.github/workflows/push.yml
vendored
@@ -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
123
.github/workflows/quality.yml
vendored
Normal 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
|
||||
@@ -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
52
.github/workflows/update-sdk.yml
vendored
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
11665
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
108
package.json
108
package.json
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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
6
src/apiclient.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 && (<>
|
||||
—
|
||||
{getDisplayDateTime(version.timestamp)}
|
||||
{toLocaleString(parseISO8601Date(version.timestamp))}
|
||||
</>)}
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
35
src/apps/dashboard/routes/_redirects.ts
Normal file
35
src/apps/dashboard/routes/_redirects.ts
Normal 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' }
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -304,7 +304,6 @@ const PluginPage: FC = () => {
|
||||
return (
|
||||
<Page
|
||||
id='addPluginPage'
|
||||
title={pluginDetails?.name || pluginName}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Container className='content-primary'>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
@@ -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}>
|
||||
|
||||
@@ -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)
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 = {
|
||||
@@ -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) {
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* Actions that are triggered for media segments.
|
||||
*/
|
||||
export enum MediaSegmentAction {
|
||||
None = 'None',
|
||||
AskToSkip = 'AskToSkip',
|
||||
Skip = 'Skip'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
5
src/apps/stable/routes/_redirects.ts
Normal file
5
src/apps/stable/routes/_redirects.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { Redirect } from 'components/router/Redirect';
|
||||
|
||||
export const REDIRECTS: Redirect[] = [
|
||||
{ from: 'mypreferencesquickconnect.html', to: '/quickconnect' }
|
||||
];
|
||||
@@ -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)
|
||||
];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user