mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Merge branch 'master' into trickplay-new
This commit is contained in:
commit
2dfc0aa061
137 changed files with 5909 additions and 2829 deletions
|
@ -1,55 +0,0 @@
|
||||||
jobs:
|
|
||||||
- job: Build
|
|
||||||
displayName: 'Build'
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
Development:
|
|
||||||
BuildConfiguration: development
|
|
||||||
Production:
|
|
||||||
BuildConfiguration: production
|
|
||||||
|
|
||||||
pool:
|
|
||||||
vmImage: 'ubuntu-latest'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- task: NodeTool@0
|
|
||||||
displayName: 'Install Node'
|
|
||||||
inputs:
|
|
||||||
versionSpec: '20.x'
|
|
||||||
|
|
||||||
- task: Cache@2
|
|
||||||
displayName: 'Cache node_modules'
|
|
||||||
inputs:
|
|
||||||
key: 'npm | package-lock.json'
|
|
||||||
path: 'node_modules'
|
|
||||||
|
|
||||||
- script: 'npm ci --no-audit'
|
|
||||||
displayName: 'Install Dependencies'
|
|
||||||
|
|
||||||
- script: 'npm run build:development'
|
|
||||||
displayName: 'Build Development'
|
|
||||||
condition: eq(variables['BuildConfiguration'], 'development')
|
|
||||||
|
|
||||||
- script: 'npm run build:production'
|
|
||||||
displayName: 'Build Production'
|
|
||||||
condition: eq(variables['BuildConfiguration'], 'production')
|
|
||||||
|
|
||||||
- script: 'test -d dist'
|
|
||||||
displayName: 'Check Build'
|
|
||||||
|
|
||||||
- script: 'mv dist jellyfin-web'
|
|
||||||
displayName: 'Rename Directory'
|
|
||||||
|
|
||||||
- task: ArchiveFiles@2
|
|
||||||
displayName: 'Archive Directory'
|
|
||||||
inputs:
|
|
||||||
rootFolderOrFile: 'jellyfin-web'
|
|
||||||
includeRootFolder: true
|
|
||||||
archiveFile: 'jellyfin-web-$(BuildConfiguration)'
|
|
||||||
|
|
||||||
- task: PublishPipelineArtifact@1
|
|
||||||
displayName: 'Publish Release'
|
|
||||||
inputs:
|
|
||||||
targetPath: '$(Build.SourcesDirectory)/jellyfin-web-$(BuildConfiguration).zip'
|
|
||||||
artifactName: 'jellyfin-web-$(BuildConfiguration)'
|
|
|
@ -1,126 +0,0 @@
|
||||||
jobs:
|
|
||||||
- job: BuildPackage
|
|
||||||
displayName: 'Build Packages'
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
CentOS:
|
|
||||||
BuildConfiguration: centos
|
|
||||||
Debian:
|
|
||||||
BuildConfiguration: debian
|
|
||||||
Fedora:
|
|
||||||
BuildConfiguration: fedora
|
|
||||||
Portable:
|
|
||||||
BuildConfiguration: portable
|
|
||||||
|
|
||||||
pool:
|
|
||||||
vmImage: 'ubuntu-latest'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- script: echo "##vso[task.setvariable variable=JellyfinVersion]$( awk -F '/' '{ print $NF }' <<<'$(Build.SourceBranch)' | sed 's/^v//' )"
|
|
||||||
displayName: Set release version (stable)
|
|
||||||
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
|
|
||||||
|
|
||||||
- script: 'docker build -f deployment/Dockerfile.$(BuildConfiguration) -t jellyfin-web-$(BuildConfiguration) deployment'
|
|
||||||
displayName: 'Build Dockerfile'
|
|
||||||
condition: or(startsWith(variables['Build.SourceBranch'], 'refs/tags'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master'))
|
|
||||||
|
|
||||||
- script: 'docker image ls -a && docker run -v $(pwd)/deployment/dist:/dist -v $(pwd):/jellyfin -e IS_UNSTABLE="yes" -e BUILD_ID=$(Build.BuildNumber) jellyfin-web-$(BuildConfiguration)'
|
|
||||||
displayName: 'Run Dockerfile (unstable)'
|
|
||||||
condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
|
|
||||||
|
|
||||||
- script: 'docker image ls -a && docker run -v $(pwd)/deployment/dist:/dist -v $(pwd):/jellyfin -e IS_UNSTABLE="no" -e BUILD_ID=$(Build.BuildNumber) jellyfin-web-$(BuildConfiguration)'
|
|
||||||
displayName: 'Run Dockerfile (stable)'
|
|
||||||
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags')
|
|
||||||
|
|
||||||
- task: PublishPipelineArtifact@1
|
|
||||||
displayName: 'Publish Release'
|
|
||||||
condition: or(startsWith(variables['Build.SourceBranch'], 'refs/tags'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master'))
|
|
||||||
inputs:
|
|
||||||
targetPath: '$(Build.SourcesDirectory)/deployment/dist'
|
|
||||||
artifactName: 'jellyfin-web-$(BuildConfiguration)'
|
|
||||||
|
|
||||||
- task: SSH@0
|
|
||||||
displayName: 'Create target directory on repository server'
|
|
||||||
condition: or(startsWith(variables['Build.SourceBranch'], 'refs/tags'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master'))
|
|
||||||
inputs:
|
|
||||||
sshEndpoint: repository
|
|
||||||
runOptions: 'inline'
|
|
||||||
inline: 'mkdir -p /srv/repository/incoming/azure/$(Build.BuildNumber)/$(BuildConfiguration)'
|
|
||||||
|
|
||||||
- task: CopyFilesOverSSH@0
|
|
||||||
displayName: 'Upload artifacts to repository server'
|
|
||||||
condition: or(startsWith(variables['Build.SourceBranch'], 'refs/tags'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master'))
|
|
||||||
inputs:
|
|
||||||
sshEndpoint: repository
|
|
||||||
sourceFolder: '$(Build.SourcesDirectory)/deployment/dist'
|
|
||||||
contents: '**'
|
|
||||||
targetFolder: '/srv/repository/incoming/azure/$(Build.BuildNumber)/$(BuildConfiguration)'
|
|
||||||
|
|
||||||
- job: BuildDocker
|
|
||||||
displayName: 'Build Docker'
|
|
||||||
|
|
||||||
pool:
|
|
||||||
vmImage: 'ubuntu-latest'
|
|
||||||
|
|
||||||
variables:
|
|
||||||
- name: JellyfinVersion
|
|
||||||
value: 0.0.0
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- script: echo "##vso[task.setvariable variable=JellyfinVersion]$( awk -F '/' '{ print $NF }' <<<'$(Build.SourceBranch)' | sed 's/^v//' )"
|
|
||||||
displayName: Set release version (stable)
|
|
||||||
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags')
|
|
||||||
|
|
||||||
- task: Docker@2
|
|
||||||
displayName: 'Push Unstable Image'
|
|
||||||
condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
|
|
||||||
inputs:
|
|
||||||
repository: 'jellyfin/jellyfin-web'
|
|
||||||
command: buildAndPush
|
|
||||||
buildContext: '.'
|
|
||||||
Dockerfile: 'deployment/Dockerfile.docker'
|
|
||||||
containerRegistry: Docker Hub
|
|
||||||
tags: |
|
|
||||||
unstable-$(Build.BuildNumber)
|
|
||||||
unstable
|
|
||||||
|
|
||||||
- task: Docker@2
|
|
||||||
displayName: 'Push Stable Image'
|
|
||||||
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags')
|
|
||||||
inputs:
|
|
||||||
repository: 'jellyfin/jellyfin-web'
|
|
||||||
command: buildAndPush
|
|
||||||
buildContext: '.'
|
|
||||||
Dockerfile: 'deployment/Dockerfile.docker'
|
|
||||||
containerRegistry: Docker Hub
|
|
||||||
tags: |
|
|
||||||
stable-$(Build.BuildNumber)
|
|
||||||
$(JellyfinVersion)
|
|
||||||
|
|
||||||
- job: CollectArtifacts
|
|
||||||
displayName: 'Collect Artifacts'
|
|
||||||
dependsOn:
|
|
||||||
- BuildPackage
|
|
||||||
- BuildDocker
|
|
||||||
condition: and(succeeded('BuildPackage'), succeeded('BuildDocker'))
|
|
||||||
|
|
||||||
pool:
|
|
||||||
vmImage: 'ubuntu-latest'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- task: SSH@0
|
|
||||||
displayName: 'Update Unstable Repository'
|
|
||||||
condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
|
|
||||||
inputs:
|
|
||||||
sshEndpoint: repository
|
|
||||||
runOptions: 'inline'
|
|
||||||
inline: 'sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable'
|
|
||||||
|
|
||||||
- task: SSH@0
|
|
||||||
displayName: 'Update Stable Repository'
|
|
||||||
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags')
|
|
||||||
inputs:
|
|
||||||
sshEndpoint: repository
|
|
||||||
runOptions: 'inline'
|
|
||||||
inline: 'sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) $(Build.SourceBranch)'
|
|
|
@ -1,16 +0,0 @@
|
||||||
trigger:
|
|
||||||
batch: true
|
|
||||||
branches:
|
|
||||||
include:
|
|
||||||
- '*'
|
|
||||||
tags:
|
|
||||||
include:
|
|
||||||
- '*'
|
|
||||||
pr:
|
|
||||||
branches:
|
|
||||||
include:
|
|
||||||
- '*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
- template: azure-pipelines-build.yml
|
|
||||||
- template: azure-pipelines-package.yml
|
|
|
@ -8,5 +8,5 @@ trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
|
|
||||||
[*.json]
|
[*.{json,yaml,yml}]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
|
@ -31,6 +31,8 @@ jobs:
|
||||||
run: npm ci --no-audit
|
run: npm ci --no-audit
|
||||||
|
|
||||||
- name: Run a production build
|
- name: Run a production build
|
||||||
|
env:
|
||||||
|
JELLYFIN_VERSION: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
run: npm run build:production
|
run: npm run build:production
|
||||||
|
|
||||||
- name: Update config.json for testing
|
- name: Update config.json for testing
|
||||||
|
@ -56,7 +58,7 @@ jobs:
|
||||||
- name: Save PR context
|
- name: Save PR context
|
||||||
env:
|
env:
|
||||||
PR_NUMBER: ${{ github.event.number }}
|
PR_NUMBER: ${{ github.event.number }}
|
||||||
PR_SHA: ${{ github.sha }}
|
PR_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
run: |
|
run: |
|
||||||
echo $PR_NUMBER > PR_number
|
echo $PR_NUMBER > PR_number
|
||||||
echo $PR_SHA > PR_sha
|
echo $PR_SHA > PR_sha
|
||||||
|
|
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
|
@ -22,13 +22,13 @@ jobs:
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0
|
uses: github/codeql-action/init@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
|
||||||
with:
|
with:
|
||||||
languages: javascript
|
languages: javascript
|
||||||
queries: +security-extended
|
queries: +security-extended
|
||||||
|
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0
|
uses: github/codeql-action/autobuild@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0
|
uses: github/codeql-action/analyze@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
|
||||||
|
|
2
.github/workflows/pr-suggestions.yml
vendored
2
.github/workflows/pr-suggestions.yml
vendored
|
@ -33,6 +33,6 @@ jobs:
|
||||||
|
|
||||||
- name: Run eslint
|
- name: Run eslint
|
||||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||||
uses: CatChen/eslint-suggestion-action@7bbf6d65396dbcc73d1e053d900eb5745988c11c # v3.1.2
|
uses: CatChen/eslint-suggestion-action@8fb7db4e235f7af9fc434349a124034b681d99a3 # v3.1.3
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
6
.github/workflows/publish.yml
vendored
6
.github/workflows/publish.yml
vendored
|
@ -20,7 +20,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download workflow artifact
|
- name: Download workflow artifact
|
||||||
uses: dawidd6/action-download-artifact@e7466d1a7587ed14867642c2ca74b5bcc1e19a2d # v3.0.0
|
uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2
|
||||||
with:
|
with:
|
||||||
run_id: ${{ github.event.workflow_run.id }}
|
run_id: ${{ github.event.workflow_run.id }}
|
||||||
name: jellyfin-web__prod
|
name: jellyfin-web__prod
|
||||||
|
@ -47,7 +47,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Get PR context
|
- name: Get PR context
|
||||||
uses: dawidd6/action-download-artifact@e7466d1a7587ed14867642c2ca74b5bcc1e19a2d # v3.0.0
|
uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2
|
||||||
id: pr_context
|
id: pr_context
|
||||||
with:
|
with:
|
||||||
run_id: ${{ github.event.workflow_run.id }}
|
run_id: ${{ github.event.workflow_run.id }}
|
||||||
|
@ -88,7 +88,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Update job summary in PR comment
|
- name: Update job summary in PR comment
|
||||||
uses: thollander/actions-comment-pull-request@1d3973dc4b8e1399c0620d3f2b1aa5e795465308 # v2.4.3
|
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
|
||||||
with:
|
with:
|
||||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
message: ${{ needs.compose-comment.outputs.msg }}
|
message: ${{ needs.compose-comment.outputs.msg }}
|
||||||
|
|
3
.github/workflows/update-sdk.yml
vendored
3
.github/workflows/update-sdk.yml
vendored
|
@ -33,10 +33,9 @@ jobs:
|
||||||
npm i --save @jellyfin/sdk@unstable
|
npm i --save @jellyfin/sdk@unstable
|
||||||
VERSION=$(jq -r '.dependencies["@jellyfin/sdk"]' package.json)
|
VERSION=$(jq -r '.dependencies["@jellyfin/sdk"]' package.json)
|
||||||
echo "JF_SDK_VERSION=${VERSION}" >> $GITHUB_ENV
|
echo "JF_SDK_VERSION=${VERSION}" >> $GITHUB_ENV
|
||||||
git checkout package.json
|
|
||||||
|
|
||||||
- name: Open a pull request
|
- name: Open a pull request
|
||||||
uses: peter-evans/create-pull-request@v6
|
uses: peter-evans/create-pull-request@a4f52f8033a6168103c2538976c07b467e8163bc # v6.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
commit-message: Update @jellyfin/sdk to ${{env.JF_SDK_VERSION}}
|
commit-message: Update @jellyfin/sdk to ${{env.JF_SDK_VERSION}}
|
||||||
|
|
|
@ -79,6 +79,7 @@
|
||||||
- [Kevin Tan (Valius)](https://github.com/valius)
|
- [Kevin Tan (Valius)](https://github.com/valius)
|
||||||
- [Rasmus Krämer](https://github.com/rasmuslos)
|
- [Rasmus Krämer](https://github.com/rasmuslos)
|
||||||
- [ntarelix](https://github.com/ntarelix)
|
- [ntarelix](https://github.com/ntarelix)
|
||||||
|
- [András Maróy](https://github.com/andrasmaroy)
|
||||||
|
|
||||||
## Emby Contributors
|
## Emby Contributors
|
||||||
|
|
||||||
|
|
999
package-lock.json
generated
999
package-lock.json
generated
File diff suppressed because it is too large
Load diff
21
package.json
21
package.json
|
@ -12,12 +12,12 @@
|
||||||
"@babel/preset-env": "7.23.8",
|
"@babel/preset-env": "7.23.8",
|
||||||
"@babel/preset-react": "7.23.3",
|
"@babel/preset-react": "7.23.3",
|
||||||
"@types/escape-html": "1.0.4",
|
"@types/escape-html": "1.0.4",
|
||||||
"@types/loadable__component": "5.13.8",
|
"@types/loadable__component": "5.13.9",
|
||||||
"@types/lodash-es": "4.17.12",
|
"@types/lodash-es": "4.17.12",
|
||||||
"@types/markdown-it": "13.0.7",
|
"@types/markdown-it": "13.0.7",
|
||||||
"@types/react": "17.0.75",
|
"@types/react": "17.0.75",
|
||||||
"@types/react-dom": "17.0.25",
|
"@types/react-dom": "17.0.25",
|
||||||
"@types/sortablejs": "1.15.7",
|
"@types/sortablejs": "1.15.8",
|
||||||
"@typescript-eslint/eslint-plugin": "5.62.0",
|
"@typescript-eslint/eslint-plugin": "5.62.0",
|
||||||
"@typescript-eslint/parser": "5.62.0",
|
"@typescript-eslint/parser": "5.62.0",
|
||||||
"@uupaa/dynamic-import-polyfill": "1.0.2",
|
"@uupaa/dynamic-import-polyfill": "1.0.2",
|
||||||
|
@ -29,7 +29,7 @@
|
||||||
"copy-webpack-plugin": "12.0.2",
|
"copy-webpack-plugin": "12.0.2",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"css-loader": "6.9.1",
|
"css-loader": "6.9.1",
|
||||||
"cssnano": "6.0.3",
|
"cssnano": "6.0.5",
|
||||||
"es-check": "7.1.1",
|
"es-check": "7.1.1",
|
||||||
"eslint": "8.56.0",
|
"eslint": "8.56.0",
|
||||||
"eslint-plugin-compat": "4.2.0",
|
"eslint-plugin-compat": "4.2.0",
|
||||||
|
@ -70,7 +70,7 @@
|
||||||
"worker-loader": "3.0.8"
|
"worker-loader": "3.0.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "11.11.3",
|
"@emotion/react": "11.11.4",
|
||||||
"@emotion/styled": "11.11.0",
|
"@emotion/styled": "11.11.0",
|
||||||
"@fontsource/noto-sans": "5.0.18",
|
"@fontsource/noto-sans": "5.0.18",
|
||||||
"@fontsource/noto-sans-hk": "5.0.17",
|
"@fontsource/noto-sans-hk": "5.0.17",
|
||||||
|
@ -78,14 +78,15 @@
|
||||||
"@fontsource/noto-sans-kr": "5.0.17",
|
"@fontsource/noto-sans-kr": "5.0.17",
|
||||||
"@fontsource/noto-sans-sc": "5.0.17",
|
"@fontsource/noto-sans-sc": "5.0.17",
|
||||||
"@fontsource/noto-sans-tc": "5.0.17",
|
"@fontsource/noto-sans-tc": "5.0.17",
|
||||||
"@jellyfin/sdk": "unstable",
|
"@jellyfin/sdk": "0.0.0-unstable.202403040506",
|
||||||
"@loadable/component": "5.16.3",
|
"@loadable/component": "5.16.3",
|
||||||
"@mui/icons-material": "5.15.5",
|
"@mui/icons-material": "5.15.11",
|
||||||
"@mui/material": "5.15.5",
|
"@mui/material": "5.15.11",
|
||||||
"@mui/x-data-grid": "6.18.7",
|
"@mui/x-data-grid": "6.19.5",
|
||||||
"@react-hook/resize-observer": "1.2.6",
|
"@react-hook/resize-observer": "1.2.6",
|
||||||
"@tanstack/react-query": "4.36.1",
|
"@tanstack/react-query": "4.36.1",
|
||||||
"@tanstack/react-query-devtools": "4.36.1",
|
"@tanstack/react-query-devtools": "4.36.1",
|
||||||
|
"@types/react-lazy-load-image-component": "1.6.3",
|
||||||
"abortcontroller-polyfill": "1.7.5",
|
"abortcontroller-polyfill": "1.7.5",
|
||||||
"blurhash": "2.0.5",
|
"blurhash": "2.0.5",
|
||||||
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
|
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
|
||||||
|
@ -100,7 +101,7 @@
|
||||||
"flv.js": "1.6.2",
|
"flv.js": "1.6.2",
|
||||||
"headroom.js": "0.12.0",
|
"headroom.js": "0.12.0",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"hls.js": "1.5.1",
|
"hls.js": "1.5.7",
|
||||||
"intersection-observer": "0.12.2",
|
"intersection-observer": "0.12.2",
|
||||||
"jassub": "1.7.15",
|
"jassub": "1.7.15",
|
||||||
"jellyfin-apiclient": "1.11.0",
|
"jellyfin-apiclient": "1.11.0",
|
||||||
|
@ -113,7 +114,9 @@
|
||||||
"native-promise-only": "0.8.1",
|
"native-promise-only": "0.8.1",
|
||||||
"pdfjs-dist": "3.11.174",
|
"pdfjs-dist": "3.11.174",
|
||||||
"react": "17.0.2",
|
"react": "17.0.2",
|
||||||
|
"react-blurhash": "0.3.0",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
|
"react-lazy-load-image-component": "1.6.0",
|
||||||
"react-router-dom": "6.21.3",
|
"react-router-dom": "6.21.3",
|
||||||
"resize-observer-polyfill": "1.5.1",
|
"resize-observer-polyfill": "1.5.1",
|
||||||
"screenfull": "6.0.2",
|
"screenfull": "6.0.2",
|
||||||
|
|
|
@ -1,26 +1,15 @@
|
||||||
import { Devices, Analytics, Input, ExpandLess, ExpandMore } from '@mui/icons-material';
|
import { Devices, Analytics, Input } from '@mui/icons-material';
|
||||||
import Collapse from '@mui/material/Collapse';
|
|
||||||
import List from '@mui/material/List';
|
import List from '@mui/material/List';
|
||||||
import ListItem from '@mui/material/ListItem';
|
import ListItem from '@mui/material/ListItem';
|
||||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
import ListSubheader from '@mui/material/ListSubheader';
|
import ListSubheader from '@mui/material/ListSubheader';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
|
|
||||||
import ListItemLink from 'components/ListItemLink';
|
import ListItemLink from 'components/ListItemLink';
|
||||||
import globalize from 'scripts/globalize';
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
const DLNA_PATHS = [
|
|
||||||
'/dashboard/dlna',
|
|
||||||
'/dashboard/dlna/profiles'
|
|
||||||
];
|
|
||||||
|
|
||||||
const DevicesDrawerSection = () => {
|
const DevicesDrawerSection = () => {
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const isDlnaSectionOpen = DLNA_PATHS.includes(location.pathname);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
aria-labelledby='devices-subheader'
|
aria-labelledby='devices-subheader'
|
||||||
|
@ -47,24 +36,13 @@ const DevicesDrawerSection = () => {
|
||||||
</ListItemLink>
|
</ListItemLink>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem disablePadding>
|
<ListItem disablePadding>
|
||||||
<ListItemLink to='/dashboard/dlna' selected={false}>
|
<ListItemLink to='/dashboard/dlna'>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<Input />
|
<Input />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary={'DLNA'} />
|
<ListItemText primary={'DLNA'} />
|
||||||
{isDlnaSectionOpen ? <ExpandLess /> : <ExpandMore />}
|
|
||||||
</ListItemLink>
|
</ListItemLink>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<Collapse in={isDlnaSectionOpen} timeout='auto' unmountOnExit>
|
|
||||||
<List component='div' disablePadding>
|
|
||||||
<ListItemLink to='/dashboard/dlna' sx={{ pl: 4 }}>
|
|
||||||
<ListItemText inset primary={globalize.translate('Settings')} />
|
|
||||||
</ListItemLink>
|
|
||||||
<ListItemLink to='/dashboard/dlna/profiles' sx={{ pl: 4 }}>
|
|
||||||
<ListItemText inset primary={globalize.translate('TabProfiles')} />
|
|
||||||
</ListItemLink>
|
|
||||||
</List>
|
|
||||||
</Collapse>
|
|
||||||
</List>
|
</List>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { AsyncRouteType, type AsyncRoute } from 'components/router/AsyncRoute';
|
||||||
|
|
||||||
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||||
{ path: 'activity', type: AsyncRouteType.Dashboard },
|
{ path: 'activity', type: AsyncRouteType.Dashboard },
|
||||||
|
{ path: 'dlna', type: AsyncRouteType.Dashboard },
|
||||||
{ path: 'notifications', type: AsyncRouteType.Dashboard },
|
{ path: 'notifications', type: AsyncRouteType.Dashboard },
|
||||||
{ path: 'users', type: AsyncRouteType.Dashboard },
|
{ path: 'users', type: AsyncRouteType.Dashboard },
|
||||||
{ path: 'users/access', type: AsyncRouteType.Dashboard },
|
{ path: 'users/access', type: AsyncRouteType.Dashboard },
|
||||||
|
|
|
@ -31,24 +31,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||||
controller: 'dashboard/devices/device',
|
controller: 'dashboard/devices/device',
|
||||||
view: 'dashboard/devices/device.html'
|
view: 'dashboard/devices/device.html'
|
||||||
}
|
}
|
||||||
}, {
|
|
||||||
path: 'dlna/profiles/edit',
|
|
||||||
pageProps: {
|
|
||||||
controller: 'dashboard/dlna/profile',
|
|
||||||
view: 'dashboard/dlna/profile.html'
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
path: 'dlna/profiles',
|
|
||||||
pageProps: {
|
|
||||||
controller: 'dashboard/dlna/profiles',
|
|
||||||
view: 'dashboard/dlna/profiles.html'
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
path: 'dlna',
|
|
||||||
pageProps: {
|
|
||||||
controller: 'dashboard/dlna/settings',
|
|
||||||
view: 'dashboard/dlna/settings.html'
|
|
||||||
}
|
|
||||||
}, {
|
}, {
|
||||||
path: 'plugins/add',
|
path: 'plugins/add',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
|
|
@ -8,8 +8,8 @@ export const REDIRECTS: Redirect[] = [
|
||||||
{ from: 'dashboardgeneral.html', to: '/dashboard/settings' },
|
{ from: 'dashboardgeneral.html', to: '/dashboard/settings' },
|
||||||
{ from: 'device.html', to: '/dashboard/devices/edit' },
|
{ from: 'device.html', to: '/dashboard/devices/edit' },
|
||||||
{ from: 'devices.html', to: '/dashboard/devices' },
|
{ from: 'devices.html', to: '/dashboard/devices' },
|
||||||
{ from: 'dlnaprofile.html', to: '/dashboard/dlna/profiles/edit' },
|
{ from: 'dlnaprofile.html', to: '/dashboard/dlna' },
|
||||||
{ from: 'dlnaprofiles.html', to: '/dashboard/dlna/profiles' },
|
{ from: 'dlnaprofiles.html', to: '/dashboard/dlna' },
|
||||||
{ from: 'dlnasettings.html', to: '/dashboard/dlna' },
|
{ from: 'dlnasettings.html', to: '/dashboard/dlna' },
|
||||||
{ from: 'edititemmetadata.html', to: '/metadata' },
|
{ from: 'edititemmetadata.html', to: '/metadata' },
|
||||||
{ from: 'encodingsettings.html', to: '/dashboard/playback/transcoding' },
|
{ from: 'encodingsettings.html', to: '/dashboard/playback/transcoding' },
|
||||||
|
|
33
src/apps/dashboard/routes/dlna.tsx
Normal file
33
src/apps/dashboard/routes/dlna.tsx
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import Alert from '@mui/material/Alert/Alert';
|
||||||
|
import Box from '@mui/material/Box/Box';
|
||||||
|
import Button from '@mui/material/Button/Button';
|
||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Page from 'components/Page';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
|
const DlnaPage = () => (
|
||||||
|
<Page
|
||||||
|
id='dlnaSettingsPage'
|
||||||
|
title='DLNA'
|
||||||
|
className='mainAnimatedPage type-interior'
|
||||||
|
>
|
||||||
|
<div className='content-primary'>
|
||||||
|
<h2>DLNA</h2>
|
||||||
|
<Alert severity='info'>
|
||||||
|
<Box sx={{ marginBottom: 2 }}>
|
||||||
|
{globalize.translate('DlnaMovedMessage')}
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to='/dashboard/plugins/add?name=DLNA&guid=33eba9cd7da14720967fdd7dae7b74a1'
|
||||||
|
>
|
||||||
|
{globalize.translate('GetThePlugin')}
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DlnaPage;
|
|
@ -1,23 +1,13 @@
|
||||||
|
import Alert from '@mui/material/Alert/Alert';
|
||||||
|
import Box from '@mui/material/Box/Box';
|
||||||
|
import Button from '@mui/material/Button/Button';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import Page from 'components/Page';
|
import Page from 'components/Page';
|
||||||
import globalize from 'scripts/globalize';
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
const PluginLink = () => (
|
const NotificationsPage = () => (
|
||||||
<div
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `<a
|
|
||||||
is='emby-linkbutton'
|
|
||||||
class='button-link'
|
|
||||||
href='#/dashboard/plugins/add?name=Webhook&guid=71552a5a5c5c4350a2aeebe451a30173'
|
|
||||||
>
|
|
||||||
${globalize.translate('GetThePlugin')}
|
|
||||||
</a>`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const Notifications = () => (
|
|
||||||
<Page
|
<Page
|
||||||
id='notificationSettingPage'
|
id='notificationSettingPage'
|
||||||
title={globalize.translate('Notifications')}
|
title={globalize.translate('Notifications')}
|
||||||
|
@ -25,12 +15,20 @@ const Notifications = () => (
|
||||||
>
|
>
|
||||||
<div className='content-primary'>
|
<div className='content-primary'>
|
||||||
<h2>{globalize.translate('Notifications')}</h2>
|
<h2>{globalize.translate('Notifications')}</h2>
|
||||||
<p>
|
|
||||||
|
<Alert severity='info'>
|
||||||
|
<Box sx={{ marginBottom: 2 }}>
|
||||||
{globalize.translate('NotificationsMovedMessage')}
|
{globalize.translate('NotificationsMovedMessage')}
|
||||||
</p>
|
</Box>
|
||||||
<PluginLink />
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to='/dashboard/plugins/add?name=Webhook&guid=71552a5a5c5c4350a2aeebe451a30173'
|
||||||
|
>
|
||||||
|
{globalize.translate('GetThePlugin')}
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default Notifications;
|
export default NotificationsPage;
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||||
|
import type { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import { useGetGenres } from 'hooks/useFetchItems';
|
import { useGetGenres } from 'hooks/useFetchItems';
|
||||||
import globalize from 'scripts/globalize';
|
import globalize from 'scripts/globalize';
|
||||||
import Loading from 'components/loading/LoadingComponent';
|
import Loading from 'components/loading/LoadingComponent';
|
||||||
import GenresSectionContainer from './GenresSectionContainer';
|
import GenresSectionContainer from './GenresSectionContainer';
|
||||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
import type { ParentId } from 'types/library';
|
||||||
import { ParentId } from 'types/library';
|
|
||||||
|
|
||||||
interface GenresItemsContainerProps {
|
interface GenresItemsContainerProps {
|
||||||
parentId: ParentId;
|
parentId: ParentId;
|
||||||
|
|
|
@ -1,18 +1,17 @@
|
||||||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
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 { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields';
|
||||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
||||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||||
import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
|
import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
|
||||||
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order';
|
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order';
|
||||||
import escapeHTML from 'escape-html';
|
import React, { type FC } from 'react';
|
||||||
import React, { FC } from 'react';
|
|
||||||
|
|
||||||
import { useGetItems } from 'hooks/useFetchItems';
|
import { useGetItems } from 'hooks/useFetchItems';
|
||||||
import Loading from 'components/loading/LoadingComponent';
|
import Loading from 'components/loading/LoadingComponent';
|
||||||
import { appRouter } from 'components/router/appRouter';
|
import { appRouter } from 'components/router/appRouter';
|
||||||
import SectionContainer from './SectionContainer';
|
import SectionContainer from './SectionContainer';
|
||||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
import { CardShape } from 'utils/card';
|
||||||
import { ParentId } from 'types/library';
|
import type { ParentId } from 'types/library';
|
||||||
|
|
||||||
interface GenresSectionContainerProps {
|
interface GenresSectionContainerProps {
|
||||||
parentId: ParentId;
|
parentId: ParentId;
|
||||||
|
@ -60,7 +59,7 @@ const GenresSectionContainer: FC<GenresSectionContainerProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SectionContainer
|
return <SectionContainer
|
||||||
sectionTitle={escapeHTML(genre.Name)}
|
sectionTitle={genre.Name || ''}
|
||||||
items={itemsResult?.Items || []}
|
items={itemsResult?.Items || []}
|
||||||
url={getRouteUrl(genre)}
|
url={getRouteUrl(genre)}
|
||||||
cardOptions={{
|
cardOptions={{
|
||||||
|
@ -69,7 +68,7 @@ const GenresSectionContainer: FC<GenresSectionContainerProps> = ({
|
||||||
showTitle: true,
|
showTitle: true,
|
||||||
centerText: true,
|
centerText: true,
|
||||||
cardLayout: false,
|
cardLayout: false,
|
||||||
shape: collectionType === CollectionType.Music ? 'overflowSquare' : 'overflowPortrait',
|
shape: collectionType === CollectionType.Music ? CardShape.SquareOverflow : CardShape.PortraitOverflow,
|
||||||
showParentTitle: collectionType === CollectionType.Music,
|
showParentTitle: collectionType === CollectionType.Music,
|
||||||
showYear: collectionType !== CollectionType.Music
|
showYear: collectionType !== CollectionType.Music
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||||
|
import type { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import GenresItemsContainer from './GenresItemsContainer';
|
import GenresItemsContainer from './GenresItemsContainer';
|
||||||
import { ParentId } from 'types/library';
|
import type { ParentId } from 'types/library';
|
||||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
|
||||||
|
|
||||||
interface GenresViewProps {
|
interface GenresViewProps {
|
||||||
parentId: ParentId;
|
parentId: ParentId;
|
||||||
|
|
|
@ -1,17 +1,16 @@
|
||||||
import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||||
|
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client';
|
import { ImageType } from '@jellyfin/sdk/lib/generated-client';
|
||||||
import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
|
import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
|
||||||
import React, { FC, useCallback } from 'react';
|
import React, { type FC, useCallback } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useLocalStorage } from 'hooks/useLocalStorage';
|
import { useLocalStorage } from 'hooks/useLocalStorage';
|
||||||
import { useGetItem, useGetItemsViewByType } from 'hooks/useFetchItems';
|
import { useGetItem, useGetItemsViewByType } from 'hooks/useFetchItems';
|
||||||
import { getDefaultLibraryViewSettings, getSettingsKey } from 'utils/items';
|
import { getDefaultLibraryViewSettings, getSettingsKey } from 'utils/items';
|
||||||
|
import { CardShape } from 'utils/card';
|
||||||
import Loading from 'components/loading/LoadingComponent';
|
import Loading from 'components/loading/LoadingComponent';
|
||||||
import listview from 'components/listview/listview';
|
|
||||||
import cardBuilder from 'components/cardbuilder/cardBuilder';
|
|
||||||
import { playbackManager } from 'components/playback/playbackmanager';
|
import { playbackManager } from 'components/playback/playbackmanager';
|
||||||
import globalize from 'scripts/globalize';
|
|
||||||
import ItemsContainer from 'elements/emby-itemscontainer/ItemsContainer';
|
import ItemsContainer from 'elements/emby-itemscontainer/ItemsContainer';
|
||||||
import AlphabetPicker from './AlphabetPicker';
|
import AlphabetPicker from './AlphabetPicker';
|
||||||
import FilterButton from './filter/FilterButton';
|
import FilterButton from './filter/FilterButton';
|
||||||
|
@ -22,12 +21,13 @@ import QueueButton from './QueueButton';
|
||||||
import ShuffleButton from './ShuffleButton';
|
import ShuffleButton from './ShuffleButton';
|
||||||
import SortButton from './SortButton';
|
import SortButton from './SortButton';
|
||||||
import GridListViewButton from './GridListViewButton';
|
import GridListViewButton from './GridListViewButton';
|
||||||
import { LibraryViewSettings, ParentId, ViewMode } from 'types/library';
|
import NoItemsMessage from 'components/common/NoItemsMessage';
|
||||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
import Lists from 'components/listview/List/Lists';
|
||||||
|
import Cards from 'components/cardbuilder/Card/Cards';
|
||||||
import { LibraryTab } from 'types/libraryTab';
|
import { LibraryTab } from 'types/libraryTab';
|
||||||
|
import { type LibraryViewSettings, type ParentId, ViewMode } from 'types/library';
|
||||||
import { CardOptions } from 'types/cardOptions';
|
import type { CardOptions } from 'types/cardOptions';
|
||||||
import { ListOptions } from 'types/listOptions';
|
import type { ListOptions } from 'types/listOptions';
|
||||||
|
|
||||||
interface ItemsViewProps {
|
interface ItemsViewProps {
|
||||||
viewType: LibraryTab;
|
viewType: LibraryTab;
|
||||||
|
@ -110,18 +110,18 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||||
let preferLogo;
|
let preferLogo;
|
||||||
|
|
||||||
if (libraryViewSettings.ImageType === ImageType.Banner) {
|
if (libraryViewSettings.ImageType === ImageType.Banner) {
|
||||||
shape = 'banner';
|
shape = CardShape.Banner;
|
||||||
} else if (libraryViewSettings.ImageType === ImageType.Disc) {
|
} else if (libraryViewSettings.ImageType === ImageType.Disc) {
|
||||||
shape = 'square';
|
shape = CardShape.Square;
|
||||||
preferDisc = true;
|
preferDisc = true;
|
||||||
} else if (libraryViewSettings.ImageType === ImageType.Logo) {
|
} else if (libraryViewSettings.ImageType === ImageType.Logo) {
|
||||||
shape = 'backdrop';
|
shape = CardShape.Backdrop;
|
||||||
preferLogo = true;
|
preferLogo = true;
|
||||||
} else if (libraryViewSettings.ImageType === ImageType.Thumb) {
|
} else if (libraryViewSettings.ImageType === ImageType.Thumb) {
|
||||||
shape = 'backdrop';
|
shape = CardShape.Backdrop;
|
||||||
preferThumb = true;
|
preferThumb = true;
|
||||||
} else {
|
} else {
|
||||||
shape = 'auto';
|
shape = CardShape.Auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardOptions: CardOptions = {
|
const cardOptions: CardOptions = {
|
||||||
|
@ -135,9 +135,9 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||||
preferThumb: preferThumb,
|
preferThumb: preferThumb,
|
||||||
preferDisc: preferDisc,
|
preferDisc: preferDisc,
|
||||||
preferLogo: preferLogo,
|
preferLogo: preferLogo,
|
||||||
overlayPlayButton: false,
|
overlayText: !libraryViewSettings.ShowTitle,
|
||||||
overlayMoreButton: true,
|
imageType: libraryViewSettings.ImageType,
|
||||||
overlayText: !libraryViewSettings.ShowTitle
|
queryKey: ['ItemsViewByType']
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -146,20 +146,26 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||||
|| viewType === LibraryTab.Episodes
|
|| viewType === LibraryTab.Episodes
|
||||||
) {
|
) {
|
||||||
cardOptions.showParentTitle = libraryViewSettings.ShowTitle;
|
cardOptions.showParentTitle = libraryViewSettings.ShowTitle;
|
||||||
|
cardOptions.overlayPlayButton = true;
|
||||||
} else if (viewType === LibraryTab.Artists) {
|
} else if (viewType === LibraryTab.Artists) {
|
||||||
cardOptions.lines = 1;
|
cardOptions.lines = 1;
|
||||||
cardOptions.showYear = false;
|
cardOptions.showYear = false;
|
||||||
|
cardOptions.overlayPlayButton = true;
|
||||||
} else if (viewType === LibraryTab.Channels) {
|
} else if (viewType === LibraryTab.Channels) {
|
||||||
cardOptions.shape = 'square';
|
cardOptions.shape = CardShape.Square;
|
||||||
cardOptions.showDetailsMenu = true;
|
cardOptions.showDetailsMenu = true;
|
||||||
cardOptions.showCurrentProgram = true;
|
cardOptions.showCurrentProgram = true;
|
||||||
cardOptions.showCurrentProgramTime = true;
|
cardOptions.showCurrentProgramTime = true;
|
||||||
} else if (viewType === LibraryTab.SeriesTimers) {
|
} else if (viewType === LibraryTab.SeriesTimers) {
|
||||||
cardOptions.defaultShape = 'portrait';
|
cardOptions.shape = CardShape.Backdrop;
|
||||||
cardOptions.preferThumb = 'auto';
|
|
||||||
cardOptions.showSeriesTimerTime = true;
|
cardOptions.showSeriesTimerTime = true;
|
||||||
cardOptions.showSeriesTimerChannel = true;
|
cardOptions.showSeriesTimerChannel = true;
|
||||||
|
cardOptions.overlayMoreButton = true;
|
||||||
cardOptions.lines = 3;
|
cardOptions.lines = 3;
|
||||||
|
} else if (viewType === LibraryTab.Movies) {
|
||||||
|
cardOptions.overlayPlayButton = true;
|
||||||
|
} else if (viewType === LibraryTab.Series || viewType === LibraryTab.Networks) {
|
||||||
|
cardOptions.overlayMoreButton = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cardOptions;
|
return cardOptions;
|
||||||
|
@ -172,27 +178,32 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||||
viewType
|
viewType
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const getItemsHtml = useCallback(() => {
|
const getItems = useCallback(() => {
|
||||||
let html = '';
|
if (!itemsResult?.Items?.length) {
|
||||||
|
return <NoItemsMessage noItemsMessage={noItemsMessage} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (libraryViewSettings.ViewMode === ViewMode.ListView) {
|
if (libraryViewSettings.ViewMode === ViewMode.ListView) {
|
||||||
html = listview.getListViewHtml(getListOptions());
|
return (
|
||||||
} else {
|
<Lists
|
||||||
html = cardBuilder.getCardsHtml(
|
items={itemsResult?.Items ?? []}
|
||||||
itemsResult?.Items ?? [],
|
listOptions={getListOptions()}
|
||||||
getCardOptions()
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
if (!itemsResult?.Items?.length) {
|
<Cards
|
||||||
html += '<div class="noItemsMessage centerMessage">';
|
items={itemsResult?.Items ?? []}
|
||||||
html += '<h1>' + globalize.translate('MessageNothingHere') + '</h1>';
|
cardOptions={getCardOptions()}
|
||||||
html += '<p>' + globalize.translate(noItemsMessage) + '</p>';
|
/>
|
||||||
html += '</div>';
|
);
|
||||||
}
|
}, [
|
||||||
|
libraryViewSettings.ViewMode,
|
||||||
return html;
|
itemsResult?.Items,
|
||||||
}, [libraryViewSettings.ViewMode, itemsResult?.Items, getListOptions, getCardOptions, noItemsMessage]);
|
getListOptions,
|
||||||
|
getCardOptions,
|
||||||
|
noItemsMessage
|
||||||
|
]);
|
||||||
|
|
||||||
const totalRecordCount = itemsResult?.TotalRecordCount ?? 0;
|
const totalRecordCount = itemsResult?.TotalRecordCount ?? 0;
|
||||||
const items = itemsResult?.Items ?? [];
|
const items = itemsResult?.Items ?? [];
|
||||||
|
@ -289,8 +300,10 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||||
className={itemsContainerClass}
|
className={itemsContainerClass}
|
||||||
parentId={parentId}
|
parentId={parentId}
|
||||||
reloadItems={refetch}
|
reloadItems={refetch}
|
||||||
getItemsHtml={getItemsHtml}
|
queryKey={['ItemsViewByType']}
|
||||||
/>
|
>
|
||||||
|
{getItems()}
|
||||||
|
</ItemsContainer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPaginationEnabled && (
|
{isPaginationEnabled && (
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import React, { FC } from 'react';
|
import React, { type FC } from 'react';
|
||||||
import SuggestionsSectionView from './SuggestionsSectionView';
|
import SuggestionsSectionView from './SuggestionsSectionView';
|
||||||
import UpcomingView from './UpcomingView';
|
import UpcomingView from './UpcomingView';
|
||||||
import GenresView from './GenresView';
|
import GenresView from './GenresView';
|
||||||
import ItemsView from './ItemsView';
|
import ItemsView from './ItemsView';
|
||||||
import { LibraryTab } from 'types/libraryTab';
|
|
||||||
import { ParentId } from 'types/library';
|
|
||||||
import { LibraryTabContent } from 'types/libraryTabContent';
|
|
||||||
import GuideView from './GuideView';
|
import GuideView from './GuideView';
|
||||||
import ProgramsSectionView from './ProgramsSectionView';
|
import ProgramsSectionView from './ProgramsSectionView';
|
||||||
|
import { LibraryTab } from 'types/libraryTab';
|
||||||
|
import type { ParentId } from 'types/library';
|
||||||
|
import type { LibraryTabContent } from 'types/libraryTabContent';
|
||||||
|
|
||||||
interface PageTabContentProps {
|
interface PageTabContentProps {
|
||||||
parentId: ParentId;
|
parentId: ParentId;
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import React, { FC } from 'react';
|
import React, { type FC } from 'react';
|
||||||
import { useGetProgramsSectionsWithItems, useGetTimers } from 'hooks/useFetchItems';
|
import { useGetProgramsSectionsWithItems, useGetTimers } from 'hooks/useFetchItems';
|
||||||
import { appRouter } from 'components/router/appRouter';
|
import { appRouter } from 'components/router/appRouter';
|
||||||
import globalize from 'scripts/globalize';
|
import globalize from 'scripts/globalize';
|
||||||
import Loading from 'components/loading/LoadingComponent';
|
import Loading from 'components/loading/LoadingComponent';
|
||||||
import SectionContainer from './SectionContainer';
|
import SectionContainer from './SectionContainer';
|
||||||
import { ParentId } from 'types/library';
|
import { CardShape } from 'utils/card';
|
||||||
import { Section, SectionType } from 'types/sections';
|
import type { ParentId } from 'types/library';
|
||||||
|
import type { Section, SectionType } from 'types/sections';
|
||||||
|
|
||||||
interface ProgramsSectionViewProps {
|
interface ProgramsSectionViewProps {
|
||||||
parentId: ParentId;
|
parentId: ParentId;
|
||||||
|
@ -18,7 +19,7 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
|
||||||
sectionType,
|
sectionType,
|
||||||
isUpcomingRecordingsEnabled = false
|
isUpcomingRecordingsEnabled = false
|
||||||
}) => {
|
}) => {
|
||||||
const { isLoading, data: sectionsWithItems } = useGetProgramsSectionsWithItems(parentId, sectionType);
|
const { isLoading, data: sectionsWithItems, refetch } = useGetProgramsSectionsWithItems(parentId, sectionType);
|
||||||
const {
|
const {
|
||||||
isLoading: isUpcomingRecordingsLoading,
|
isLoading: isUpcomingRecordingsLoading,
|
||||||
data: upcomingRecordings
|
data: upcomingRecordings
|
||||||
|
@ -60,8 +61,10 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
|
||||||
sectionTitle={globalize.translate(section.name)}
|
sectionTitle={globalize.translate(section.name)}
|
||||||
items={items ?? []}
|
items={items ?? []}
|
||||||
url={getRouteUrl(section)}
|
url={getRouteUrl(section)}
|
||||||
|
reloadItems={refetch}
|
||||||
cardOptions={{
|
cardOptions={{
|
||||||
...section.cardOptions
|
...section.cardOptions,
|
||||||
|
queryKey: ['ProgramSectionWithItems']
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -73,7 +76,8 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
|
||||||
sectionTitle={group.name}
|
sectionTitle={group.name}
|
||||||
items={group.timerInfo ?? []}
|
items={group.timerInfo ?? []}
|
||||||
cardOptions={{
|
cardOptions={{
|
||||||
shape: 'overflowBackdrop',
|
queryKey: ['Timers'],
|
||||||
|
shape: CardShape.BackdropOverflow,
|
||||||
showTitle: true,
|
showTitle: true,
|
||||||
showParentTitleOrTitle: true,
|
showParentTitleOrTitle: true,
|
||||||
showAirTime: true,
|
showAirTime: true,
|
||||||
|
|
|
@ -1,43 +1,29 @@
|
||||||
import type { BaseItemDto, TimerInfoDto } from '@jellyfin/sdk/lib/generated-client';
|
import type { BaseItemDto, TimerInfoDto } from '@jellyfin/sdk/lib/generated-client';
|
||||||
import React, { FC, useEffect, useRef } from 'react';
|
import React, { FC } from 'react';
|
||||||
|
|
||||||
import cardBuilder from 'components/cardbuilder/cardBuilder';
|
|
||||||
import ItemsContainer from 'elements/emby-itemscontainer/ItemsContainer';
|
import ItemsContainer from 'elements/emby-itemscontainer/ItemsContainer';
|
||||||
import Scroller from 'elements/emby-scroller/Scroller';
|
import Scroller from 'elements/emby-scroller/Scroller';
|
||||||
import LinkButton from 'elements/emby-button/LinkButton';
|
import LinkButton from 'elements/emby-button/LinkButton';
|
||||||
import imageLoader from 'components/images/imageLoader';
|
import Cards from 'components/cardbuilder/Card/Cards';
|
||||||
|
import type { CardOptions } from 'types/cardOptions';
|
||||||
import { CardOptions } from 'types/cardOptions';
|
|
||||||
|
|
||||||
interface SectionContainerProps {
|
interface SectionContainerProps {
|
||||||
url?: string;
|
url?: string;
|
||||||
sectionTitle: string;
|
sectionTitle: string;
|
||||||
items: BaseItemDto[] | TimerInfoDto[];
|
items: BaseItemDto[] | TimerInfoDto[];
|
||||||
cardOptions: CardOptions;
|
cardOptions: CardOptions;
|
||||||
|
reloadItems?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SectionContainer: FC<SectionContainerProps> = ({
|
const SectionContainer: FC<SectionContainerProps> = ({
|
||||||
sectionTitle,
|
sectionTitle,
|
||||||
url,
|
url,
|
||||||
items,
|
items,
|
||||||
cardOptions
|
cardOptions,
|
||||||
|
reloadItems
|
||||||
}) => {
|
}) => {
|
||||||
const element = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const itemsContainer = element.current?.querySelector('.itemsContainer');
|
|
||||||
cardBuilder.buildCards(items, {
|
|
||||||
itemsContainer: itemsContainer,
|
|
||||||
parentContainer: element.current,
|
|
||||||
|
|
||||||
...cardOptions
|
|
||||||
});
|
|
||||||
|
|
||||||
imageLoader.lazyChildren(itemsContainer);
|
|
||||||
}, [cardOptions, items]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={element} className='verticalSection hide'>
|
<div className='verticalSection'>
|
||||||
<div className='sectionTitleContainer sectionTitleContainer-cards padded-left'>
|
<div className='sectionTitleContainer sectionTitleContainer-cards padded-left'>
|
||||||
{url && items.length > 5 ? (
|
{url && items.length > 5 ? (
|
||||||
<LinkButton
|
<LinkButton
|
||||||
|
@ -66,7 +52,11 @@ const SectionContainer: FC<SectionContainerProps> = ({
|
||||||
>
|
>
|
||||||
<ItemsContainer
|
<ItemsContainer
|
||||||
className='itemsContainer scrollSlider focuscontainer-x'
|
className='itemsContainer scrollSlider focuscontainer-x'
|
||||||
/>
|
reloadItems={reloadItems}
|
||||||
|
queryKey={cardOptions.queryKey}
|
||||||
|
>
|
||||||
|
<Cards items={items} cardOptions={cardOptions} />
|
||||||
|
</ItemsContainer>
|
||||||
</Scroller>
|
</Scroller>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import {
|
import {
|
||||||
RecommendationDto,
|
type RecommendationDto,
|
||||||
RecommendationType
|
RecommendationType
|
||||||
} from '@jellyfin/sdk/lib/generated-client';
|
} from '@jellyfin/sdk/lib/generated-client';
|
||||||
import React, { FC } from 'react';
|
import React, { type FC } from 'react';
|
||||||
import escapeHTML from 'escape-html';
|
|
||||||
import {
|
import {
|
||||||
useGetMovieRecommendations,
|
useGetMovieRecommendations,
|
||||||
useGetSuggestionSectionsWithItems
|
useGetSuggestionSectionsWithItems
|
||||||
|
@ -12,8 +11,9 @@ import { appRouter } from 'components/router/appRouter';
|
||||||
import globalize from 'scripts/globalize';
|
import globalize from 'scripts/globalize';
|
||||||
import Loading from 'components/loading/LoadingComponent';
|
import Loading from 'components/loading/LoadingComponent';
|
||||||
import SectionContainer from './SectionContainer';
|
import SectionContainer from './SectionContainer';
|
||||||
import { ParentId } from 'types/library';
|
import { CardShape } from 'utils/card';
|
||||||
import { Section, SectionType } from 'types/sections';
|
import type { ParentId } from 'types/library';
|
||||||
|
import type { Section, SectionType } from 'types/sections';
|
||||||
|
|
||||||
interface SuggestionsSectionViewProps {
|
interface SuggestionsSectionViewProps {
|
||||||
parentId: ParentId;
|
parentId: ParentId;
|
||||||
|
@ -89,7 +89,7 @@ const SuggestionsSectionView: FC<SuggestionsSectionViewProps> = ({
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return escapeHTML(title);
|
return title;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -102,6 +102,7 @@ const SuggestionsSectionView: FC<SuggestionsSectionViewProps> = ({
|
||||||
url={getRouteUrl(section)}
|
url={getRouteUrl(section)}
|
||||||
cardOptions={{
|
cardOptions={{
|
||||||
...section.cardOptions,
|
...section.cardOptions,
|
||||||
|
queryKey: ['SuggestionSectionWithItems'],
|
||||||
showTitle: true,
|
showTitle: true,
|
||||||
centerText: true,
|
centerText: true,
|
||||||
cardLayout: false,
|
cardLayout: false,
|
||||||
|
@ -117,7 +118,8 @@ const SuggestionsSectionView: FC<SuggestionsSectionViewProps> = ({
|
||||||
sectionTitle={getRecommendationTittle(recommendation)}
|
sectionTitle={getRecommendationTittle(recommendation)}
|
||||||
items={recommendation.Items ?? []}
|
items={recommendation.Items ?? []}
|
||||||
cardOptions={{
|
cardOptions={{
|
||||||
shape: 'overflowPortrait',
|
queryKey: ['MovieRecommendations'],
|
||||||
|
shape: CardShape.PortraitOverflow,
|
||||||
showYear: true,
|
showYear: true,
|
||||||
scalable: true,
|
scalable: true,
|
||||||
overlayPlayButton: true,
|
overlayPlayButton: true,
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import React, { FC } from 'react';
|
import React, { type FC } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import { useGetGroupsUpcomingEpisodes } from 'hooks/useFetchItems';
|
import { useGetGroupsUpcomingEpisodes } from 'hooks/useFetchItems';
|
||||||
import Loading from 'components/loading/LoadingComponent';
|
import Loading from 'components/loading/LoadingComponent';
|
||||||
import globalize from 'scripts/globalize';
|
import globalize from 'scripts/globalize';
|
||||||
import SectionContainer from './SectionContainer';
|
import SectionContainer from './SectionContainer';
|
||||||
import { LibraryViewProps } from 'types/library';
|
import { CardShape } from 'utils/card';
|
||||||
|
import type { LibraryViewProps } from 'types/library';
|
||||||
|
|
||||||
const UpcomingView: FC<LibraryViewProps> = ({ parentId }) => {
|
const UpcomingView: FC<LibraryViewProps> = ({ parentId }) => {
|
||||||
const { isLoading, data: groupsUpcomingEpisodes } = useGetGroupsUpcomingEpisodes(parentId);
|
const { isLoading, data: groupsUpcomingEpisodes } = useGetGroupsUpcomingEpisodes(parentId);
|
||||||
|
@ -29,7 +30,7 @@ const UpcomingView: FC<LibraryViewProps> = ({ parentId }) => {
|
||||||
sectionTitle={group.name}
|
sectionTitle={group.name}
|
||||||
items={group.items ?? []}
|
items={group.items ?? []}
|
||||||
cardOptions={{
|
cardOptions={{
|
||||||
shape: 'overflowBackdrop',
|
shape: CardShape.BackdropOverflow,
|
||||||
showLocationTypeIndicator: false,
|
showLocationTypeIndicator: false,
|
||||||
showParentTitle: true,
|
showParentTitle: true,
|
||||||
preferThumb: true,
|
preferThumb: true,
|
||||||
|
|
25
src/components/cardbuilder/Card/Card.tsx
Normal file
25
src/components/cardbuilder/Card/Card.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import useCard from './useCard';
|
||||||
|
import CardWrapper from './CardWrapper';
|
||||||
|
import CardBox from './CardBox';
|
||||||
|
|
||||||
|
import type { CardOptions } from 'types/cardOptions';
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
item?: ItemDto;
|
||||||
|
cardOptions: CardOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Card: FC<CardProps> = ({ item = {}, cardOptions }) => {
|
||||||
|
const { getCardWrapperProps, getCardBoxProps } = useCard({ item, cardOptions } );
|
||||||
|
const cardWrapperProps = getCardWrapperProps();
|
||||||
|
const cardBoxProps = getCardBoxProps();
|
||||||
|
return (
|
||||||
|
<CardWrapper {...cardWrapperProps}>
|
||||||
|
<CardBox {...cardBoxProps} />
|
||||||
|
</CardWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Card;
|
78
src/components/cardbuilder/Card/CardBox.tsx
Normal file
78
src/components/cardbuilder/Card/CardBox.tsx
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import layoutManager from 'components/layoutManager';
|
||||||
|
|
||||||
|
import CardOverlayButtons from './CardOverlayButtons';
|
||||||
|
import CardHoverMenu from './CardHoverMenu';
|
||||||
|
import CardOuterFooter from './CardOuterFooter';
|
||||||
|
import CardContent from './CardContent';
|
||||||
|
import { CardShape } from 'utils/card';
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { CardOptions } from 'types/cardOptions';
|
||||||
|
|
||||||
|
interface CardBoxProps {
|
||||||
|
item: ItemDto;
|
||||||
|
cardOptions: CardOptions;
|
||||||
|
className: string;
|
||||||
|
shape: CardShape | undefined;
|
||||||
|
imgUrl: string | undefined;
|
||||||
|
blurhash: string | undefined;
|
||||||
|
forceName: boolean;
|
||||||
|
coveredImage: boolean;
|
||||||
|
overlayText: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardBox: FC<CardBoxProps> = ({
|
||||||
|
item,
|
||||||
|
cardOptions,
|
||||||
|
className,
|
||||||
|
shape,
|
||||||
|
imgUrl,
|
||||||
|
blurhash,
|
||||||
|
forceName,
|
||||||
|
coveredImage,
|
||||||
|
overlayText
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className='cardScalable'>
|
||||||
|
<div className={`cardPadder cardPadder-${shape}`}></div>
|
||||||
|
<CardContent
|
||||||
|
item={item}
|
||||||
|
cardOptions={cardOptions}
|
||||||
|
coveredImage={coveredImage}
|
||||||
|
|
||||||
|
overlayText={overlayText}
|
||||||
|
imgUrl={imgUrl}
|
||||||
|
blurhash={blurhash}
|
||||||
|
forceName={forceName}
|
||||||
|
/>
|
||||||
|
{layoutManager.mobile && (
|
||||||
|
<CardOverlayButtons
|
||||||
|
item={item}
|
||||||
|
cardOptions={cardOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{layoutManager.desktop
|
||||||
|
&& !cardOptions.disableHoverMenu && (
|
||||||
|
<CardHoverMenu
|
||||||
|
item={item}
|
||||||
|
cardOptions={cardOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!overlayText && (
|
||||||
|
<CardOuterFooter
|
||||||
|
item={item}
|
||||||
|
cardOptions={cardOptions}
|
||||||
|
forceName={forceName}
|
||||||
|
overlayText={overlayText}
|
||||||
|
imgUrl={imgUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardBox;
|
||||||
|
|
50
src/components/cardbuilder/Card/CardContent.tsx
Normal file
50
src/components/cardbuilder/Card/CardContent.tsx
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { getDefaultBackgroundClass } from '../cardBuilderUtils';
|
||||||
|
import CardImageContainer from './CardImageContainer';
|
||||||
|
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { CardOptions } from 'types/cardOptions';
|
||||||
|
|
||||||
|
interface CardContentProps {
|
||||||
|
item: ItemDto;
|
||||||
|
cardOptions: CardOptions;
|
||||||
|
coveredImage: boolean;
|
||||||
|
overlayText: boolean | undefined;
|
||||||
|
imgUrl: string | undefined;
|
||||||
|
blurhash: string | undefined;
|
||||||
|
forceName: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardContent: FC<CardContentProps> = ({
|
||||||
|
item,
|
||||||
|
cardOptions,
|
||||||
|
coveredImage,
|
||||||
|
overlayText,
|
||||||
|
imgUrl,
|
||||||
|
blurhash,
|
||||||
|
forceName
|
||||||
|
}) => {
|
||||||
|
const cardContentClass = classNames(
|
||||||
|
'cardContent',
|
||||||
|
{ [getDefaultBackgroundClass(item.Name)]: !imgUrl }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cardContentClass}
|
||||||
|
>
|
||||||
|
<CardImageContainer
|
||||||
|
item={item}
|
||||||
|
cardOptions={cardOptions}
|
||||||
|
coveredImage={coveredImage}
|
||||||
|
overlayText={overlayText}
|
||||||
|
imgUrl={imgUrl}
|
||||||
|
blurhash={blurhash}
|
||||||
|
forceName={forceName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardContent;
|
81
src/components/cardbuilder/Card/CardFooterText.tsx
Normal file
81
src/components/cardbuilder/Card/CardFooterText.tsx
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import useCardText from './useCardText';
|
||||||
|
import layoutManager from 'components/layoutManager';
|
||||||
|
import MoreVertIconButton from '../../common/MoreVertIconButton';
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { CardOptions } from 'types/cardOptions';
|
||||||
|
|
||||||
|
const shouldShowDetailsMenu = (
|
||||||
|
cardOptions: CardOptions,
|
||||||
|
isOuterFooter: boolean
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
cardOptions.showDetailsMenu
|
||||||
|
&& isOuterFooter
|
||||||
|
&& cardOptions.cardLayout
|
||||||
|
&& layoutManager.mobile
|
||||||
|
&& cardOptions.cardFooterAside !== 'none'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LogoComponentProps {
|
||||||
|
logoUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LogoComponent: FC<LogoComponentProps> = ({ logoUrl }) => {
|
||||||
|
return <Box className='lazy cardFooterLogo' data-src={logoUrl} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CardFooterTextProps {
|
||||||
|
item: ItemDto;
|
||||||
|
cardOptions: CardOptions;
|
||||||
|
forceName: boolean;
|
||||||
|
overlayText: boolean | undefined;
|
||||||
|
imgUrl: string | undefined;
|
||||||
|
footerClass: string | undefined;
|
||||||
|
progressBar?: React.JSX.Element | null;
|
||||||
|
logoUrl?: string;
|
||||||
|
isOuterFooter: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardFooterText: FC<CardFooterTextProps> = ({
|
||||||
|
item,
|
||||||
|
cardOptions,
|
||||||
|
forceName,
|
||||||
|
imgUrl,
|
||||||
|
footerClass,
|
||||||
|
overlayText,
|
||||||
|
progressBar,
|
||||||
|
logoUrl,
|
||||||
|
isOuterFooter
|
||||||
|
}) => {
|
||||||
|
const { cardTextLines } = useCardText({
|
||||||
|
item,
|
||||||
|
cardOptions,
|
||||||
|
forceName,
|
||||||
|
imgUrl,
|
||||||
|
overlayText,
|
||||||
|
isOuterFooter,
|
||||||
|
cssClass: cardOptions.centerText ?
|
||||||
|
'cardText cardTextCentered' :
|
||||||
|
'cardText',
|
||||||
|
forceLines: !cardOptions.overlayText,
|
||||||
|
maxLines: cardOptions.lines
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={footerClass}>
|
||||||
|
{logoUrl && <LogoComponent logoUrl={logoUrl} />}
|
||||||
|
{shouldShowDetailsMenu(cardOptions, isOuterFooter) && (
|
||||||
|
<MoreVertIconButton className='itemAction btnCardOptions' />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cardTextLines}
|
||||||
|
|
||||||
|
{progressBar}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardFooterText;
|
82
src/components/cardbuilder/Card/CardHoverMenu.tsx
Normal file
82
src/components/cardbuilder/Card/CardHoverMenu.tsx
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { appRouter } from 'components/router/appRouter';
|
||||||
|
import itemHelper from 'components/itemHelper';
|
||||||
|
import { playbackManager } from 'components/playback/playbackmanager';
|
||||||
|
|
||||||
|
import PlayedButton from 'elements/emby-playstatebutton/PlayedButton';
|
||||||
|
import FavoriteButton from 'elements/emby-ratingbutton/FavoriteButton';
|
||||||
|
import PlayArrowIconButton from '../../common/PlayArrowIconButton';
|
||||||
|
import MoreVertIconButton from '../../common/MoreVertIconButton';
|
||||||
|
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { CardOptions } from 'types/cardOptions';
|
||||||
|
|
||||||
|
interface CardHoverMenuProps {
|
||||||
|
item: ItemDto;
|
||||||
|
cardOptions: CardOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardHoverMenu: FC<CardHoverMenuProps> = ({
|
||||||
|
item,
|
||||||
|
cardOptions
|
||||||
|
}) => {
|
||||||
|
const url = appRouter.getRouteUrl(item, {
|
||||||
|
parentId: cardOptions.parentId
|
||||||
|
});
|
||||||
|
const btnCssClass =
|
||||||
|
'paper-icon-button-light cardOverlayButton cardOverlayButton-hover itemAction';
|
||||||
|
|
||||||
|
const centerPlayButtonClass = classNames(
|
||||||
|
btnCssClass,
|
||||||
|
'cardOverlayFab-primary'
|
||||||
|
);
|
||||||
|
const { IsFavorite, Played } = item.UserData ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className='cardOverlayContainer'
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
aria-label={item.Name || ''}
|
||||||
|
className='cardImageContainer'
|
||||||
|
></a>
|
||||||
|
|
||||||
|
{playbackManager.canPlay(item) && (
|
||||||
|
<PlayArrowIconButton
|
||||||
|
className={centerPlayButtonClass}
|
||||||
|
action='play'
|
||||||
|
title='Play'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ButtonGroup className='cardOverlayButton-br flex'>
|
||||||
|
{itemHelper.canMarkPlayed(item) && cardOptions.enablePlayedButton !== false && (
|
||||||
|
<PlayedButton
|
||||||
|
className={btnCssClass}
|
||||||
|
isPlayed={Played}
|
||||||
|
itemId={item.Id}
|
||||||
|
itemType={item.Type}
|
||||||
|
queryKey={cardOptions.queryKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{itemHelper.canRate(item) && cardOptions.enableRatingButton !== false && (
|
||||||
|
<FavoriteButton
|
||||||
|
className={btnCssClass}
|
||||||
|
isFavorite={IsFavorite}
|
||||||
|
itemId={item.Id}
|
||||||
|
queryKey={cardOptions.queryKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MoreVertIconButton className={btnCssClass} />
|
||||||
|
</ButtonGroup>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardHoverMenu;
|
83
src/components/cardbuilder/Card/CardImageContainer.tsx
Normal file
83
src/components/cardbuilder/Card/CardImageContainer.tsx
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import useIndicator from 'components/indicators/useIndicator';
|
||||||
|
import RefreshIndicator from 'elements/emby-itemrefreshindicator/RefreshIndicator';
|
||||||
|
import Media from '../../common/Media';
|
||||||
|
import CardInnerFooter from './CardInnerFooter';
|
||||||
|
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { CardOptions } from 'types/cardOptions';
|
||||||
|
|
||||||
|
interface CardImageContainerProps {
|
||||||
|
item: ItemDto;
|
||||||
|
cardOptions: CardOptions;
|
||||||
|
coveredImage: boolean;
|
||||||
|
overlayText: boolean | undefined;
|
||||||
|
imgUrl: string | undefined;
|
||||||
|
blurhash: string | undefined;
|
||||||
|
forceName: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardImageContainer: FC<CardImageContainerProps> = ({
|
||||||
|
item,
|
||||||
|
cardOptions,
|
||||||
|
coveredImage,
|
||||||
|
overlayText,
|
||||||
|
imgUrl,
|
||||||
|
blurhash,
|
||||||
|
forceName
|
||||||
|
}) => {
|
||||||
|
const indicator = useIndicator(item);
|
||||||
|
const cardImageClass = classNames(
|
||||||
|
'cardImageContainer',
|
||||||
|
{ coveredImage: coveredImage },
|
||||||
|
{ 'coveredImage-contain': coveredImage && item.Type === BaseItemKind.TvChannel }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cardImageClass}>
|
||||||
|
{cardOptions.disableIndicators !== true && (
|
||||||
|
<Box className='indicators'>
|
||||||
|
{indicator.getMediaSourceIndicator()}
|
||||||
|
|
||||||
|
<Box className='cardIndicators'>
|
||||||
|
{cardOptions.missingIndicator !== false
|
||||||
|
&& indicator.getMissingIndicator()}
|
||||||
|
|
||||||
|
{indicator.getTimerIndicator()}
|
||||||
|
{indicator.getTypeIndicator()}
|
||||||
|
|
||||||
|
{cardOptions.showGroupCount ?
|
||||||
|
indicator.getChildCountIndicator() :
|
||||||
|
indicator.getPlayedIndicator()}
|
||||||
|
|
||||||
|
{(item.Type === BaseItemKind.CollectionFolder
|
||||||
|
|| item.CollectionType)
|
||||||
|
&& item.RefreshProgress && (
|
||||||
|
<RefreshIndicator item={item} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Media item={item} imgUrl={imgUrl} blurhash={blurhash} imageType={cardOptions.imageType} />
|
||||||
|
|
||||||
|
{overlayText && (
|
||||||
|
<CardInnerFooter
|
||||||
|
item={item}
|
||||||
|
cardOptions={cardOptions}
|
||||||
|
forceName={forceName}
|
||||||
|
overlayText={overlayText}
|
||||||
|
imgUrl={imgUrl}
|
||||||
|
progressBar={indicator.getProgressBar()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!overlayText && indicator.getProgressBar()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardImageContainer;
|
42
src/components/cardbuilder/Card/CardInnerFooter.tsx
Normal file
42
src/components/cardbuilder/Card/CardInnerFooter.tsx
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import CardFooterText from './CardFooterText';
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { CardOptions } from 'types/cardOptions';
|
||||||
|
|
||||||
|
interface CardInnerFooterProps {
|
||||||
|
item: ItemDto;
|
||||||
|
cardOptions: CardOptions;
|
||||||
|
imgUrl: string | undefined;
|
||||||
|
progressBar?: React.JSX.Element | null;
|
||||||
|
forceName: boolean;
|
||||||
|
overlayText: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardInnerFooter: FC<CardInnerFooterProps> = ({
|
||||||
|
item,
|
||||||
|
cardOptions,
|
||||||
|
imgUrl,
|
||||||
|
overlayText,
|
||||||
|
progressBar,
|
||||||
|
forceName
|
||||||
|
}) => {
|
||||||
|
const footerClass = classNames('innerCardFooter', {
|
||||||
|
fullInnerCardFooter: progressBar
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardFooterText
|
||||||
|
item={item}
|
||||||
|
cardOptions={cardOptions}
|
||||||
|
forceName={forceName}
|
||||||
|
overlayText={overlayText}
|
||||||
|
imgUrl={imgUrl}
|
||||||
|
footerClass={footerClass}
|
||||||
|
progressBar={progressBar}
|
||||||
|
isOuterFooter={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardInnerFooter;
|
45
src/components/cardbuilder/Card/CardOuterFooter.tsx
Normal file
45
src/components/cardbuilder/Card/CardOuterFooter.tsx
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import { getCardLogoUrl } from './cardHelper';
|
||||||
|
import CardFooterText from './CardFooterText';
|
||||||
|
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { CardOptions } from 'types/cardOptions';
|
||||||
|
|
||||||
|
interface CardOuterFooterProps {
|
||||||
|
item: ItemDto
|
||||||
|
cardOptions: CardOptions;
|
||||||
|
imgUrl: string | undefined;
|
||||||
|
forceName: boolean;
|
||||||
|
overlayText: boolean | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardOuterFooter: FC<CardOuterFooterProps> = ({ item, cardOptions, overlayText, imgUrl, forceName }) => {
|
||||||
|
const { api } = useApi();
|
||||||
|
const logoInfo = getCardLogoUrl(item, api, cardOptions);
|
||||||
|
const logoUrl = logoInfo.logoUrl;
|
||||||
|
|
||||||
|
const footerClass = classNames(
|
||||||
|
'cardFooter',
|
||||||
|
{ 'cardFooter-transparent': cardOptions.cardLayout },
|
||||||
|
{ 'cardFooter-withlogo': logoUrl }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardFooterText
|
||||||
|
item={item}
|
||||||
|
cardOptions={cardOptions}
|
||||||
|
forceName={forceName}
|
||||||
|
overlayText={overlayText}
|
||||||
|
imgUrl={imgUrl}
|
||||||
|
footerClass={footerClass}
|
||||||
|
progressBar={undefined}
|
||||||
|
logoUrl={logoUrl}
|
||||||
|
isOuterFooter={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardOuterFooter;
|
104
src/components/cardbuilder/Card/CardOverlayButtons.tsx
Normal file
104
src/components/cardbuilder/Card/CardOverlayButtons.tsx
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||||
|
import { LocationType } from '@jellyfin/sdk/lib/generated-client/models/location-type';
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { appRouter } from 'components/router/appRouter';
|
||||||
|
import PlayArrowIconButton from '../../common/PlayArrowIconButton';
|
||||||
|
import MoreVertIconButton from '../../common/MoreVertIconButton';
|
||||||
|
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { CardOptions } from 'types/cardOptions';
|
||||||
|
|
||||||
|
const sholudShowOverlayPlayButton = (
|
||||||
|
overlayPlayButton: boolean | undefined,
|
||||||
|
item: ItemDto
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
overlayPlayButton
|
||||||
|
&& !item.IsPlaceHolder
|
||||||
|
&& (item.LocationType !== LocationType.Virtual
|
||||||
|
|| !item.MediaType
|
||||||
|
|| item.Type === BaseItemKind.Program)
|
||||||
|
&& item.Type !== BaseItemKind.Person
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CardOverlayButtonsProps {
|
||||||
|
item: ItemDto;
|
||||||
|
cardOptions: CardOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardOverlayButtons: FC<CardOverlayButtonsProps> = ({
|
||||||
|
item,
|
||||||
|
cardOptions
|
||||||
|
}) => {
|
||||||
|
let overlayPlayButton = cardOptions.overlayPlayButton;
|
||||||
|
|
||||||
|
if (
|
||||||
|
overlayPlayButton == null
|
||||||
|
&& !cardOptions.overlayMoreButton
|
||||||
|
&& !cardOptions.overlayInfoButton
|
||||||
|
&& !cardOptions.cardLayout
|
||||||
|
) {
|
||||||
|
overlayPlayButton = item.MediaType === 'Video';
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = appRouter.getRouteUrl(item, {
|
||||||
|
parentId: cardOptions.parentId
|
||||||
|
});
|
||||||
|
|
||||||
|
const btnCssClass = classNames(
|
||||||
|
'paper-icon-button-light',
|
||||||
|
'cardOverlayButton',
|
||||||
|
'itemAction'
|
||||||
|
);
|
||||||
|
|
||||||
|
const centerPlayButtonClass = classNames(
|
||||||
|
btnCssClass,
|
||||||
|
'cardOverlayButton-centered'
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
aria-label={item.Name || ''}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
userSelect: 'none',
|
||||||
|
borderRadius: '0.2em'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
{cardOptions.centerPlayButton && (
|
||||||
|
<PlayArrowIconButton
|
||||||
|
className={centerPlayButtonClass}
|
||||||
|
action='play'
|
||||||
|
title='Play'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ButtonGroup className='cardOverlayButton-br'>
|
||||||
|
{sholudShowOverlayPlayButton(overlayPlayButton, item) && (
|
||||||
|
<PlayArrowIconButton
|
||||||
|
className={btnCssClass}
|
||||||
|
action='play'
|
||||||
|
title='Play'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cardOptions.overlayMoreButton && (
|
||||||
|
<MoreVertIconButton
|
||||||
|
className={btnCssClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ButtonGroup>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardOverlayButtons;
|
32
src/components/cardbuilder/Card/CardText.tsx
Normal file
32
src/components/cardbuilder/Card/CardText.tsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import type { TextLine } from './cardHelper';
|
||||||
|
|
||||||
|
interface CardTextProps {
|
||||||
|
className?: string;
|
||||||
|
textLine: TextLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardText: FC<CardTextProps> = ({ className, textLine }) => {
|
||||||
|
const { title, titleAction } = textLine;
|
||||||
|
const renderCardText = () => {
|
||||||
|
if (titleAction) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className='itemAction textActionButton'
|
||||||
|
href={titleAction.url}
|
||||||
|
title={titleAction.title}
|
||||||
|
{...titleAction.dataAttributes}
|
||||||
|
>
|
||||||
|
{titleAction.title}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Box className={className}>{renderCardText()}</Box>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardText;
|
30
src/components/cardbuilder/Card/CardWrapper.tsx
Normal file
30
src/components/cardbuilder/Card/CardWrapper.tsx
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import layoutManager from 'components/layoutManager';
|
||||||
|
import type { DataAttributes } from 'types/dataAttributes';
|
||||||
|
|
||||||
|
interface CardWrapperProps {
|
||||||
|
className: string;
|
||||||
|
dataAttributes: DataAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardWrapper: FC<CardWrapperProps> = ({
|
||||||
|
className,
|
||||||
|
dataAttributes,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
if (layoutManager.tv) {
|
||||||
|
return (
|
||||||
|
<button className={className} {...dataAttributes}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className={className} {...dataAttributes}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardWrapper;
|
24
src/components/cardbuilder/Card/Cards.tsx
Normal file
24
src/components/cardbuilder/Card/Cards.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import { setCardData } from '../cardBuilder';
|
||||||
|
import Card from './Card';
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { CardOptions } from 'types/cardOptions';
|
||||||
|
import '../card.scss';
|
||||||
|
|
||||||
|
interface CardsProps {
|
||||||
|
items: ItemDto[];
|
||||||
|
cardOptions: CardOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Cards: FC<CardsProps> = ({ items, cardOptions }) => {
|
||||||
|
setCardData(items, cardOptions);
|
||||||
|
|
||||||
|
const renderCards = () =>
|
||||||
|
items.map((item) => (
|
||||||
|
<Card key={item.Id} item={item} cardOptions={cardOptions} />
|
||||||
|
));
|
||||||
|
|
||||||
|
return <>{renderCards()}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Cards;
|
723
src/components/cardbuilder/Card/cardHelper.ts
Normal file
723
src/components/cardbuilder/Card/cardHelper.ts
Normal file
|
@ -0,0 +1,723 @@
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
|
BaseItemPerson,
|
||||||
|
ImageType
|
||||||
|
} from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
import { Api } from '@jellyfin/sdk';
|
||||||
|
import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
|
||||||
|
import escapeHTML from 'escape-html';
|
||||||
|
|
||||||
|
import { appRouter } from 'components/router/appRouter';
|
||||||
|
import layoutManager from 'components/layoutManager';
|
||||||
|
import itemHelper from 'components/itemHelper';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
import datetime from 'scripts/datetime';
|
||||||
|
|
||||||
|
import { isUsingLiveTvNaming } from '../cardBuilderUtils';
|
||||||
|
|
||||||
|
import type { NullableNumber, NullableString } from 'types/base/common/shared/types';
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { CardOptions } from 'types/cardOptions';
|
||||||
|
import type { DataAttributes } from 'types/dataAttributes';
|
||||||
|
import { getDataAttributes } from 'utils/items';
|
||||||
|
|
||||||
|
export function getCardLogoUrl(
|
||||||
|
item: ItemDto,
|
||||||
|
api: Api | undefined,
|
||||||
|
cardOptions: CardOptions
|
||||||
|
) {
|
||||||
|
let imgType;
|
||||||
|
let imgTag;
|
||||||
|
let itemId;
|
||||||
|
const logoHeight = 40;
|
||||||
|
|
||||||
|
if (cardOptions.showChannelLogo && item.ChannelPrimaryImageTag) {
|
||||||
|
imgType = ImageType.Primary;
|
||||||
|
imgTag = item.ChannelPrimaryImageTag;
|
||||||
|
itemId = item.ChannelId;
|
||||||
|
} else if (cardOptions.showLogo && item.ParentLogoImageTag) {
|
||||||
|
imgType = ImageType.Logo;
|
||||||
|
imgTag = item.ParentLogoImageTag;
|
||||||
|
itemId = item.ParentLogoItemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemId) {
|
||||||
|
itemId = item.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (api && imgTag && imgType && itemId) {
|
||||||
|
const response = getImageApi(api).getItemImageUrlById(itemId, imgType, {
|
||||||
|
height: logoHeight,
|
||||||
|
tag: imgTag
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
logoUrl: response
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
logoUrl: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextAction {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
dataAttributes: DataAttributes
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextLine {
|
||||||
|
title?: NullableString;
|
||||||
|
titleAction?: TextAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTextActionButton(
|
||||||
|
item: ItemDto,
|
||||||
|
text?: NullableString,
|
||||||
|
serverId?: NullableString
|
||||||
|
): TextLine {
|
||||||
|
if (!text) {
|
||||||
|
text = itemHelper.getDisplayName(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
text = escapeHTML(text);
|
||||||
|
|
||||||
|
if (layoutManager.tv) {
|
||||||
|
return {
|
||||||
|
title: text
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = appRouter.getRouteUrl(item);
|
||||||
|
|
||||||
|
const dataAttributes = getDataAttributes(
|
||||||
|
{
|
||||||
|
action: 'link',
|
||||||
|
itemServerId: serverId ?? item.ServerId,
|
||||||
|
itemId: item.Id,
|
||||||
|
itemChannelId: item.ChannelId,
|
||||||
|
itemType: item.Type,
|
||||||
|
itemMediaType: item.MediaType,
|
||||||
|
itemCollectionType: item.CollectionType,
|
||||||
|
itemIsFolder: item.IsFolder
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
titleAction: {
|
||||||
|
url,
|
||||||
|
title: text,
|
||||||
|
dataAttributes
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAirTimeText(
|
||||||
|
item: ItemDto,
|
||||||
|
showAirDateTime: boolean | undefined,
|
||||||
|
showAirEndTime: boolean | undefined
|
||||||
|
) {
|
||||||
|
let airTimeText = '';
|
||||||
|
|
||||||
|
if (item.StartDate) {
|
||||||
|
try {
|
||||||
|
let date = datetime.parseISO8601Date(item.StartDate);
|
||||||
|
|
||||||
|
if (showAirDateTime) {
|
||||||
|
airTimeText
|
||||||
|
+= datetime.toLocaleDateString(date, {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
}) + ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
airTimeText += datetime.getDisplayTime(date);
|
||||||
|
|
||||||
|
if (item.EndDate && showAirEndTime) {
|
||||||
|
date = datetime.parseISO8601Date(item.EndDate);
|
||||||
|
airTimeText += ' - ' + datetime.getDisplayTime(date);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('error parsing date: ' + item.StartDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return airTimeText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGenreOrStudio(itemType: NullableString) {
|
||||||
|
return itemType === BaseItemKind.Genre || itemType === BaseItemKind.Studio;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMusicGenreOrMusicArtist(
|
||||||
|
itemType: NullableString,
|
||||||
|
context: NullableString
|
||||||
|
) {
|
||||||
|
return itemType === BaseItemKind.MusicGenre || context === 'MusicArtist';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMovieCount(itemMovieCount: NullableNumber) {
|
||||||
|
if (itemMovieCount) {
|
||||||
|
return itemMovieCount === 1 ?
|
||||||
|
globalize.translate('ValueOneMovie') :
|
||||||
|
globalize.translate('ValueMovieCount', itemMovieCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeriesCount(itemSeriesCount: NullableNumber) {
|
||||||
|
if (itemSeriesCount) {
|
||||||
|
return itemSeriesCount === 1 ?
|
||||||
|
globalize.translate('ValueOneSeries') :
|
||||||
|
globalize.translate('ValueSeriesCount', itemSeriesCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEpisodeCount(itemEpisodeCount: NullableNumber) {
|
||||||
|
if (itemEpisodeCount) {
|
||||||
|
return itemEpisodeCount === 1 ?
|
||||||
|
globalize.translate('ValueOneEpisode') :
|
||||||
|
globalize.translate('ValueEpisodeCount', itemEpisodeCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlbumCount(itemAlbumCount: NullableNumber) {
|
||||||
|
if (itemAlbumCount) {
|
||||||
|
return itemAlbumCount === 1 ?
|
||||||
|
globalize.translate('ValueOneAlbum') :
|
||||||
|
globalize.translate('ValueAlbumCount', itemAlbumCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSongCount(itemSongCount: NullableNumber) {
|
||||||
|
if (itemSongCount) {
|
||||||
|
return itemSongCount === 1 ?
|
||||||
|
globalize.translate('ValueOneSong') :
|
||||||
|
globalize.translate('ValueSongCount', itemSongCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMusicVideoCount(itemMusicVideoCount: NullableNumber) {
|
||||||
|
if (itemMusicVideoCount) {
|
||||||
|
return itemMusicVideoCount === 1 ?
|
||||||
|
globalize.translate('ValueOneMusicVideo') :
|
||||||
|
globalize.translate('ValueMusicVideoCount', itemMusicVideoCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecursiveItemCount(itemRecursiveItemCount: NullableNumber) {
|
||||||
|
return itemRecursiveItemCount === 1 ?
|
||||||
|
globalize.translate('ValueOneEpisode') :
|
||||||
|
globalize.translate('ValueEpisodeCount', itemRecursiveItemCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParentTitle(
|
||||||
|
isOuterFooter: boolean,
|
||||||
|
serverId: NullableString,
|
||||||
|
item: ItemDto
|
||||||
|
) {
|
||||||
|
if (isOuterFooter && item.AlbumArtists?.length) {
|
||||||
|
(item.AlbumArtists[0] as BaseItemDto).Type = BaseItemKind.MusicArtist;
|
||||||
|
(item.AlbumArtists[0] as BaseItemDto).IsFolder = true;
|
||||||
|
return getTextActionButton(item.AlbumArtists[0], null, serverId);
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
title: isUsingLiveTvNaming(item.Type) ?
|
||||||
|
item.Name :
|
||||||
|
item.SeriesName
|
||||||
|
|| item.Series
|
||||||
|
|| item.Album
|
||||||
|
|| item.AlbumArtist
|
||||||
|
|| ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRunTimeTicks(itemRunTimeTicks: NullableNumber) {
|
||||||
|
if (itemRunTimeTicks) {
|
||||||
|
let minutes = itemRunTimeTicks / 600000000;
|
||||||
|
|
||||||
|
minutes = minutes || 1;
|
||||||
|
|
||||||
|
return globalize.translate('ValueMinutes', Math.round(minutes));
|
||||||
|
} else {
|
||||||
|
return globalize.translate('ValueMinutes', 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getItemCounts(cardOptions: CardOptions, item: ItemDto) {
|
||||||
|
const counts: string[] = [];
|
||||||
|
|
||||||
|
const addCount = (text: NullableString) => {
|
||||||
|
if (text) {
|
||||||
|
counts.push(text);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item.Type === BaseItemKind.Playlist) {
|
||||||
|
const runTimeTicksText = getRunTimeTicks(item.RunTimeTicks);
|
||||||
|
addCount(runTimeTicksText);
|
||||||
|
} else if (isGenreOrStudio(item.Type)) {
|
||||||
|
const movieCountText = getMovieCount(item.MovieCount);
|
||||||
|
addCount(movieCountText);
|
||||||
|
|
||||||
|
const seriesCountText = getSeriesCount(item.SeriesCount);
|
||||||
|
addCount(seriesCountText);
|
||||||
|
|
||||||
|
const episodeCountText = getEpisodeCount(item.EpisodeCount);
|
||||||
|
addCount(episodeCountText);
|
||||||
|
} else if (isMusicGenreOrMusicArtist(item.Type, cardOptions.context)) {
|
||||||
|
const albumCountText = getAlbumCount(item.AlbumCount);
|
||||||
|
addCount(albumCountText);
|
||||||
|
|
||||||
|
const songCountText = getSongCount(item.SongCount);
|
||||||
|
addCount(songCountText);
|
||||||
|
|
||||||
|
const musicVideoCountText = getMusicVideoCount(item.MusicVideoCount);
|
||||||
|
addCount(musicVideoCountText);
|
||||||
|
} else if (item.Type === BaseItemKind.Series) {
|
||||||
|
const recursiveItemCountText = getRecursiveItemCount(
|
||||||
|
item.RecursiveItemCount
|
||||||
|
);
|
||||||
|
addCount(recursiveItemCountText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowTitle(
|
||||||
|
showTitle: boolean | string | undefined,
|
||||||
|
itemType: NullableString
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
Boolean(showTitle)
|
||||||
|
|| itemType === BaseItemKind.PhotoAlbum
|
||||||
|
|| itemType === BaseItemKind.Folder
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowOtherText(
|
||||||
|
isOuterFooter: boolean,
|
||||||
|
overlayText: boolean | undefined
|
||||||
|
) {
|
||||||
|
return isOuterFooter ? !overlayText : overlayText;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowParentTitleUnderneath(
|
||||||
|
itemType: NullableString
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
itemType === BaseItemKind.MusicAlbum
|
||||||
|
|| itemType === BaseItemKind.Audio
|
||||||
|
|| itemType === BaseItemKind.MusicVideo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowMediaTitle(
|
||||||
|
titleAdded: boolean,
|
||||||
|
showTitle: boolean,
|
||||||
|
forceName: boolean,
|
||||||
|
cardOptions: CardOptions,
|
||||||
|
textLines: TextLine[]
|
||||||
|
) {
|
||||||
|
let showMediaTitle =
|
||||||
|
(showTitle && !titleAdded)
|
||||||
|
|| (cardOptions.showParentTitleOrTitle && !textLines.length);
|
||||||
|
if (!showMediaTitle && !titleAdded && (showTitle || forceName)) {
|
||||||
|
showMediaTitle = true;
|
||||||
|
}
|
||||||
|
return showMediaTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowExtraType(itemExtraType: NullableString) {
|
||||||
|
return itemExtraType && itemExtraType !== 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowSeriesYearOrYear(
|
||||||
|
showYear: string | boolean | undefined,
|
||||||
|
showSeriesYear: boolean | undefined
|
||||||
|
) {
|
||||||
|
return Boolean(showYear) || showSeriesYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowCurrentProgram(
|
||||||
|
showCurrentProgram: boolean | undefined,
|
||||||
|
itemType: NullableString
|
||||||
|
) {
|
||||||
|
return showCurrentProgram && itemType === BaseItemKind.TvChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowCurrentProgramTime(
|
||||||
|
showCurrentProgramTime: boolean | undefined,
|
||||||
|
itemType: NullableString
|
||||||
|
) {
|
||||||
|
return showCurrentProgramTime && itemType === BaseItemKind.TvChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowPersonRoleOrType(
|
||||||
|
showPersonRoleOrType: boolean | undefined,
|
||||||
|
item: ItemDto
|
||||||
|
) {
|
||||||
|
return showPersonRoleOrType && (item as BaseItemPerson).Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowParentTitle(
|
||||||
|
showParentTitle: boolean | undefined,
|
||||||
|
parentTitleUnderneath: boolean
|
||||||
|
) {
|
||||||
|
return showParentTitle && parentTitleUnderneath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOtherText(
|
||||||
|
cardOptions: CardOptions,
|
||||||
|
parentTitleUnderneath: boolean,
|
||||||
|
isOuterFooter: boolean,
|
||||||
|
item: ItemDto,
|
||||||
|
addTextLine: (val: TextLine) => void,
|
||||||
|
serverId: NullableString
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
shouldShowParentTitle(
|
||||||
|
cardOptions.showParentTitle,
|
||||||
|
parentTitleUnderneath
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
addTextLine(getParentTitle(isOuterFooter, serverId, item));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldShowExtraType(item.ExtraType)) {
|
||||||
|
addTextLine({ title: globalize.translate(item.ExtraType) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardOptions.showItemCounts) {
|
||||||
|
addTextLine({ title: getItemCounts(cardOptions, item) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardOptions.textLines) {
|
||||||
|
addTextLine({ title: getAdditionalLines(cardOptions.textLines, item) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardOptions.showSongCount) {
|
||||||
|
addTextLine({ title: getSongCount(item.SongCount) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardOptions.showPremiereDate) {
|
||||||
|
addTextLine({ title: getPremiereDate(item.PremiereDate) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
shouldShowSeriesYearOrYear(
|
||||||
|
cardOptions.showYear,
|
||||||
|
cardOptions.showSeriesYear
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
addTextLine({ title: getProductionYear(item) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardOptions.showRuntime) {
|
||||||
|
addTextLine({ title: getRunTime(item.RunTimeTicks) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardOptions.showAirTime) {
|
||||||
|
addTextLine({
|
||||||
|
title: getAirTimeText(
|
||||||
|
item,
|
||||||
|
cardOptions.showAirDateTime,
|
||||||
|
cardOptions.showAirEndTime
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardOptions.showChannelName) {
|
||||||
|
addTextLine(getChannelName(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldShowCurrentProgram(cardOptions.showCurrentProgram, item.Type)) {
|
||||||
|
addTextLine({ title: getCurrentProgramName(item.CurrentProgram) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
shouldShowCurrentProgramTime(
|
||||||
|
cardOptions.showCurrentProgramTime,
|
||||||
|
item.Type
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
addTextLine({ title: getCurrentProgramTime(item.CurrentProgram) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardOptions.showSeriesTimerTime) {
|
||||||
|
addTextLine({ title: getSeriesTimerTime(item) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardOptions.showSeriesTimerChannel) {
|
||||||
|
addTextLine({ title: getSeriesTimerChannel(item) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldShowPersonRoleOrType(cardOptions.showCurrentProgramTime, item)) {
|
||||||
|
addTextLine({
|
||||||
|
title: globalize.translate(
|
||||||
|
'PersonRole',
|
||||||
|
(item as BaseItemPerson).Role
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeriesTimerChannel(item: ItemDto) {
|
||||||
|
if (item.RecordAnyChannel) {
|
||||||
|
return globalize.translate('AllChannels');
|
||||||
|
} else {
|
||||||
|
return item.ChannelName || '' || globalize.translate('OneChannel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeriesTimerTime(item: ItemDto) {
|
||||||
|
if (item.RecordAnyTime) {
|
||||||
|
return globalize.translate('Anytime');
|
||||||
|
} else {
|
||||||
|
return datetime.getDisplayTime(item.StartDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentProgramTime(CurrentProgram: BaseItemDto | undefined) {
|
||||||
|
if (CurrentProgram) {
|
||||||
|
return getAirTimeText(CurrentProgram, false, true) || '';
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentProgramName(CurrentProgram: BaseItemDto | undefined) {
|
||||||
|
if (CurrentProgram) {
|
||||||
|
return CurrentProgram.Name;
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChannelName(item: ItemDto) {
|
||||||
|
if (item.ChannelId) {
|
||||||
|
return getTextActionButton(
|
||||||
|
{
|
||||||
|
Id: item.ChannelId,
|
||||||
|
ServerId: item.ServerId,
|
||||||
|
Name: item.ChannelName,
|
||||||
|
Type: BaseItemKind.TvChannel,
|
||||||
|
MediaType: item.MediaType,
|
||||||
|
IsFolder: false
|
||||||
|
},
|
||||||
|
item.ChannelName
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return { title: item.ChannelName || '' || ' ' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRunTime(itemRunTimeTicks: NullableNumber) {
|
||||||
|
if (itemRunTimeTicks) {
|
||||||
|
return datetime.getDisplayRunningTime(itemRunTimeTicks);
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPremiereDate(PremiereDate: string | null | undefined) {
|
||||||
|
if (PremiereDate) {
|
||||||
|
try {
|
||||||
|
return datetime.toLocaleDateString(
|
||||||
|
datetime.parseISO8601Date(PremiereDate),
|
||||||
|
{ weekday: 'long', month: 'long', day: 'numeric' }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdditionalLines(
|
||||||
|
textLines: (item: ItemDto) => (string | undefined)[],
|
||||||
|
item: ItemDto
|
||||||
|
) {
|
||||||
|
const additionalLines = textLines(item);
|
||||||
|
for (const additionalLine of additionalLines) {
|
||||||
|
return additionalLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProductionYear(item: ItemDto) {
|
||||||
|
const productionYear =
|
||||||
|
item.ProductionYear
|
||||||
|
&& datetime.toLocaleString(item.ProductionYear, {
|
||||||
|
useGrouping: false
|
||||||
|
});
|
||||||
|
if (item.Type === BaseItemKind.Series) {
|
||||||
|
if (item.Status === 'Continuing') {
|
||||||
|
return globalize.translate(
|
||||||
|
'SeriesYearToPresent',
|
||||||
|
productionYear || ''
|
||||||
|
);
|
||||||
|
} else if (item.EndDate && item.ProductionYear) {
|
||||||
|
const endYear = datetime.toLocaleString(
|
||||||
|
datetime.parseISO8601Date(item.EndDate).getFullYear(),
|
||||||
|
{ useGrouping: false }
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
productionYear
|
||||||
|
+ (endYear === productionYear ? '' : ' - ' + endYear)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return productionYear || '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return productionYear || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMediaTitle(cardOptions: CardOptions, item: ItemDto): TextLine {
|
||||||
|
const name =
|
||||||
|
cardOptions.showTitle === 'auto'
|
||||||
|
&& !item.IsFolder
|
||||||
|
&& item.MediaType === 'Photo' ?
|
||||||
|
'' :
|
||||||
|
itemHelper.getDisplayName(item, {
|
||||||
|
includeParentInfo: cardOptions.includeParentInfoInTitle
|
||||||
|
});
|
||||||
|
|
||||||
|
return getTextActionButton({
|
||||||
|
Id: item.Id,
|
||||||
|
ServerId: item.ServerId,
|
||||||
|
Name: name,
|
||||||
|
Type: item.Type,
|
||||||
|
CollectionType: item.CollectionType,
|
||||||
|
IsFolder: item.IsFolder
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParentTitleOrTitle(
|
||||||
|
isOuterFooter: boolean,
|
||||||
|
item: ItemDto,
|
||||||
|
setTitleAdded: (val: boolean) => void,
|
||||||
|
showTitle: boolean
|
||||||
|
): TextLine {
|
||||||
|
if (
|
||||||
|
isOuterFooter
|
||||||
|
&& item.Type === BaseItemKind.Episode
|
||||||
|
&& item.SeriesName
|
||||||
|
) {
|
||||||
|
if (item.SeriesId) {
|
||||||
|
return getTextActionButton({
|
||||||
|
Id: item.SeriesId,
|
||||||
|
ServerId: item.ServerId,
|
||||||
|
Name: item.SeriesName,
|
||||||
|
Type: BaseItemKind.Series,
|
||||||
|
IsFolder: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return { title: item.SeriesName };
|
||||||
|
}
|
||||||
|
} else if (isUsingLiveTvNaming(item.Type)) {
|
||||||
|
if (!item.EpisodeTitle && !item.IndexNumber) {
|
||||||
|
setTitleAdded(true);
|
||||||
|
}
|
||||||
|
return { title: item.Name };
|
||||||
|
} else {
|
||||||
|
const parentTitle =
|
||||||
|
item.SeriesName
|
||||||
|
|| item.Series
|
||||||
|
|| item.Album
|
||||||
|
|| item.AlbumArtist
|
||||||
|
|| '';
|
||||||
|
|
||||||
|
if (parentTitle || showTitle) {
|
||||||
|
return { title: parentTitle };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextLinesOpts {
|
||||||
|
isOuterFooter: boolean;
|
||||||
|
overlayText: boolean | undefined;
|
||||||
|
forceName: boolean;
|
||||||
|
item: ItemDto;
|
||||||
|
cardOptions: CardOptions;
|
||||||
|
imgUrl: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCardTextLines({
|
||||||
|
isOuterFooter,
|
||||||
|
overlayText,
|
||||||
|
forceName,
|
||||||
|
item,
|
||||||
|
cardOptions,
|
||||||
|
imgUrl
|
||||||
|
}: TextLinesOpts) {
|
||||||
|
const showTitle = shouldShowTitle(cardOptions.showTitle, item.Type);
|
||||||
|
const showOtherText = shouldShowOtherText(isOuterFooter, overlayText);
|
||||||
|
const serverId = item.ServerId || cardOptions.serverId;
|
||||||
|
let textLines: TextLine[] = [];
|
||||||
|
const parentTitleUnderneath = shouldShowParentTitleUnderneath(item.Type);
|
||||||
|
|
||||||
|
let titleAdded = false;
|
||||||
|
const addTextLine = (val: TextLine) => {
|
||||||
|
textLines.push(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTitleAdded = (val: boolean) => {
|
||||||
|
titleAdded = val;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
showOtherText
|
||||||
|
&& (cardOptions.showParentTitle || cardOptions.showParentTitleOrTitle)
|
||||||
|
&& !parentTitleUnderneath
|
||||||
|
) {
|
||||||
|
addTextLine(
|
||||||
|
getParentTitleOrTitle(isOuterFooter, item, setTitleAdded, showTitle)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showMediaTitle = shouldShowMediaTitle(
|
||||||
|
titleAdded,
|
||||||
|
showTitle,
|
||||||
|
forceName,
|
||||||
|
cardOptions,
|
||||||
|
textLines
|
||||||
|
);
|
||||||
|
|
||||||
|
if (showMediaTitle) {
|
||||||
|
addTextLine(getMediaTitle(cardOptions, item));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showOtherText) {
|
||||||
|
addOtherText(
|
||||||
|
cardOptions,
|
||||||
|
parentTitleUnderneath,
|
||||||
|
isOuterFooter,
|
||||||
|
item,
|
||||||
|
addTextLine,
|
||||||
|
serverId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(showTitle || !imgUrl)
|
||||||
|
&& forceName
|
||||||
|
&& overlayText
|
||||||
|
&& textLines.length === 1
|
||||||
|
) {
|
||||||
|
textLines = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlayText && showTitle) {
|
||||||
|
textLines = [{ title: item.Name }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
textLines
|
||||||
|
};
|
||||||
|
}
|
123
src/components/cardbuilder/Card/useCard.ts
Normal file
123
src/components/cardbuilder/Card/useCard.ts
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import useCardImageUrl from './useCardImageUrl';
|
||||||
|
import {
|
||||||
|
resolveAction,
|
||||||
|
resolveMixedShapeByAspectRatio
|
||||||
|
} from '../cardBuilderUtils';
|
||||||
|
import { getDataAttributes } from 'utils/items';
|
||||||
|
import { CardShape } from 'utils/card';
|
||||||
|
import layoutManager from 'components/layoutManager';
|
||||||
|
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { CardOptions } from 'types/cardOptions';
|
||||||
|
|
||||||
|
interface UseCardProps {
|
||||||
|
item: ItemDto;
|
||||||
|
cardOptions: CardOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCard({ item, cardOptions }: UseCardProps) {
|
||||||
|
const action = resolveAction({
|
||||||
|
defaultAction: cardOptions.action ?? 'link',
|
||||||
|
isFolder: item.IsFolder ?? false,
|
||||||
|
isPhoto: item.MediaType === 'Photo'
|
||||||
|
});
|
||||||
|
|
||||||
|
let shape = cardOptions.shape;
|
||||||
|
|
||||||
|
if (shape === CardShape.Mixed) {
|
||||||
|
shape = resolveMixedShapeByAspectRatio(item.PrimaryImageAspectRatio);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgInfo = useCardImageUrl({
|
||||||
|
item: item.ProgramInfo ?? item,
|
||||||
|
cardOptions,
|
||||||
|
shape
|
||||||
|
});
|
||||||
|
const imgUrl = imgInfo.imgUrl;
|
||||||
|
const blurhash = imgInfo.blurhash;
|
||||||
|
const forceName = imgInfo.forceName;
|
||||||
|
const coveredImage = cardOptions.coverImage ?? imgInfo.coverImage;
|
||||||
|
const overlayText = cardOptions.overlayText;
|
||||||
|
|
||||||
|
const nameWithPrefix = item.SortName ?? item.Name ?? '';
|
||||||
|
let prefix = nameWithPrefix.substring(
|
||||||
|
0,
|
||||||
|
Math.min(3, nameWithPrefix.length)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (prefix) {
|
||||||
|
prefix = prefix.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataAttributes = getDataAttributes(
|
||||||
|
{
|
||||||
|
action,
|
||||||
|
itemServerId: item.ServerId ?? cardOptions.serverId,
|
||||||
|
context: cardOptions.context,
|
||||||
|
parentId: cardOptions.parentId,
|
||||||
|
collectionId: cardOptions.collectionId,
|
||||||
|
playlistId: cardOptions.playlistId,
|
||||||
|
itemId: item.Id,
|
||||||
|
itemTimerId: item.TimerId,
|
||||||
|
itemSeriesTimerId: item.SeriesTimerId,
|
||||||
|
itemChannelId: item.ChannelId,
|
||||||
|
itemType: item.Type,
|
||||||
|
itemMediaType: item.MediaType,
|
||||||
|
itemCollectionType: item.CollectionType,
|
||||||
|
itemIsFolder: item.IsFolder,
|
||||||
|
itemPath: item.Path,
|
||||||
|
itemStartDate: item.StartDate,
|
||||||
|
itemEndDate: item.EndDate,
|
||||||
|
itemUserData: item.UserData,
|
||||||
|
prefix
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const cardClass = classNames(
|
||||||
|
'card',
|
||||||
|
{ [`${shape}Card`]: shape },
|
||||||
|
cardOptions.cardCssClass,
|
||||||
|
cardOptions.cardClass,
|
||||||
|
{ 'card-hoverable': layoutManager.desktop },
|
||||||
|
{ groupedCard: cardOptions.showChildCountIndicator && item.ChildCount },
|
||||||
|
{
|
||||||
|
'card-withuserdata':
|
||||||
|
item.Type !== BaseItemKind.MusicAlbum
|
||||||
|
&& item.Type !== BaseItemKind.MusicArtist
|
||||||
|
&& item.Type !== BaseItemKind.Audio
|
||||||
|
},
|
||||||
|
{ itemAction: layoutManager.tv }
|
||||||
|
);
|
||||||
|
|
||||||
|
const cardBoxClass = classNames(
|
||||||
|
'cardBox',
|
||||||
|
{ visualCardBox: cardOptions.cardLayout },
|
||||||
|
{ 'cardBox-bottompadded': !cardOptions.cardLayout }
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCardWrapperProps = () => ({
|
||||||
|
className: cardClass,
|
||||||
|
dataAttributes
|
||||||
|
});
|
||||||
|
|
||||||
|
const getCardBoxProps = () => ({
|
||||||
|
item,
|
||||||
|
cardOptions,
|
||||||
|
className: cardBoxClass,
|
||||||
|
shape,
|
||||||
|
imgUrl,
|
||||||
|
blurhash,
|
||||||
|
forceName,
|
||||||
|
coveredImage,
|
||||||
|
overlayText
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCardWrapperProps,
|
||||||
|
getCardBoxProps
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useCard;
|
298
src/components/cardbuilder/Card/useCardImageUrl.ts
Normal file
298
src/components/cardbuilder/Card/useCardImageUrl.ts
Normal file
|
@ -0,0 +1,298 @@
|
||||||
|
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||||
|
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
||||||
|
import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import { getDesiredAspect } from '../cardBuilderUtils';
|
||||||
|
import { CardShape } from 'utils/card';
|
||||||
|
import type { NullableNumber, NullableString } from 'types/base/common/shared/types';
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { CardOptions } from 'types/cardOptions';
|
||||||
|
|
||||||
|
function getPreferThumbInfo(item: ItemDto, cardOptions: CardOptions) {
|
||||||
|
let imgType;
|
||||||
|
let itemId;
|
||||||
|
let imgTag;
|
||||||
|
let forceName = false;
|
||||||
|
|
||||||
|
if (item.ImageTags?.Thumb) {
|
||||||
|
imgType = ImageType.Thumb;
|
||||||
|
imgTag = item.ImageTags.Thumb;
|
||||||
|
itemId = item.Id;
|
||||||
|
} else if (item.SeriesThumbImageTag && cardOptions.inheritThumb !== false) {
|
||||||
|
imgType = ImageType.Thumb;
|
||||||
|
imgTag = item.SeriesThumbImageTag;
|
||||||
|
itemId = item.SeriesId;
|
||||||
|
} else if (
|
||||||
|
item.ParentThumbItemId
|
||||||
|
&& cardOptions.inheritThumb !== false
|
||||||
|
&& item.MediaType !== 'Photo'
|
||||||
|
) {
|
||||||
|
imgType = ImageType.Thumb;
|
||||||
|
imgTag = item.ParentThumbImageTag;
|
||||||
|
itemId = item.ParentThumbItemId;
|
||||||
|
} else if (item.BackdropImageTags?.length) {
|
||||||
|
imgType = ImageType.Backdrop;
|
||||||
|
imgTag = item.BackdropImageTags[0];
|
||||||
|
itemId = item.Id;
|
||||||
|
forceName = true;
|
||||||
|
} else if (
|
||||||
|
item.ParentBackdropImageTags?.length
|
||||||
|
&& cardOptions.inheritThumb !== false
|
||||||
|
&& item.Type === BaseItemKind.Episode
|
||||||
|
) {
|
||||||
|
imgType = ImageType.Backdrop;
|
||||||
|
imgTag = item.ParentBackdropImageTags[0];
|
||||||
|
itemId = item.ParentBackdropItemId;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
itemId: itemId,
|
||||||
|
imgTag: imgTag,
|
||||||
|
imgType: imgType,
|
||||||
|
forceName: forceName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreferLogoInfo(item: ItemDto) {
|
||||||
|
let imgType;
|
||||||
|
let itemId;
|
||||||
|
let imgTag;
|
||||||
|
|
||||||
|
if (item.ImageTags?.Logo) {
|
||||||
|
imgType = ImageType.Logo;
|
||||||
|
imgTag = item.ImageTags.Logo;
|
||||||
|
itemId = item.Id;
|
||||||
|
} else if (item.ParentLogoImageTag && item.ParentLogoItemId) {
|
||||||
|
imgType = ImageType.Logo;
|
||||||
|
imgTag = item.ParentLogoImageTag;
|
||||||
|
itemId = item.ParentLogoItemId;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
itemId: itemId,
|
||||||
|
imgTag: imgTag,
|
||||||
|
imgType: imgType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCalculatedHeight(
|
||||||
|
itemWidth: NullableNumber,
|
||||||
|
itemPrimaryImageAspectRatio: NullableNumber
|
||||||
|
) {
|
||||||
|
if (itemWidth && itemPrimaryImageAspectRatio) {
|
||||||
|
return Math.round(itemWidth / itemPrimaryImageAspectRatio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isForceName(cardOptions: CardOptions) {
|
||||||
|
return !!(cardOptions.preferThumb && cardOptions.showTitle !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCoverImage(
|
||||||
|
itemPrimaryImageAspectRatio: NullableNumber,
|
||||||
|
uiAspect: NullableNumber
|
||||||
|
) {
|
||||||
|
if (itemPrimaryImageAspectRatio && uiAspect) {
|
||||||
|
return Math.abs(itemPrimaryImageAspectRatio - uiAspect) / uiAspect <= 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowPreferBanner(
|
||||||
|
imageTagsBanner: NullableString,
|
||||||
|
cardOptions: CardOptions,
|
||||||
|
shape: CardShape | undefined
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
(cardOptions.preferBanner || shape === CardShape.Banner)
|
||||||
|
&& Boolean(imageTagsBanner)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowPreferDisc(
|
||||||
|
imageTagsDisc: string | undefined,
|
||||||
|
cardOptions: CardOptions
|
||||||
|
): boolean {
|
||||||
|
return cardOptions.preferDisc === true && Boolean(imageTagsDisc);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowImageTagsPrimary(item: ItemDto): boolean {
|
||||||
|
return (
|
||||||
|
Boolean(item.ImageTags?.Primary) && (item.Type !== BaseItemKind.Episode || item.ChildCount !== 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowImageTagsThumb(item: ItemDto): boolean {
|
||||||
|
return item.Type === BaseItemKind.Season && Boolean(item.ImageTags?.Thumb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowSeriesThumbImageTag(
|
||||||
|
itemSeriesThumbImageTag: NullableString,
|
||||||
|
cardOptions: CardOptions
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
Boolean(itemSeriesThumbImageTag) && cardOptions.inheritThumb !== false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowParentThumbImageTag(
|
||||||
|
itemParentThumbItemId: NullableString,
|
||||||
|
cardOptions: CardOptions
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
Boolean(itemParentThumbItemId) && cardOptions.inheritThumb !== false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowParentBackdropImageTags(item: ItemDto): boolean {
|
||||||
|
return Boolean(item.AlbumId) && Boolean(item.AlbumPrimaryImageTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowPreferThumb(itemType: NullableString, cardOptions: CardOptions): boolean {
|
||||||
|
return Boolean(cardOptions.preferThumb) && !(itemType === BaseItemKind.Program || itemType === BaseItemKind.Episode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCardImageInfo(
|
||||||
|
item: ItemDto,
|
||||||
|
cardOptions: CardOptions,
|
||||||
|
shape: CardShape | undefined
|
||||||
|
) {
|
||||||
|
const width = cardOptions.width;
|
||||||
|
let height;
|
||||||
|
const primaryImageAspectRatio = item.PrimaryImageAspectRatio;
|
||||||
|
let forceName = false;
|
||||||
|
let imgTag;
|
||||||
|
let coverImage = false;
|
||||||
|
const uiAspect = getDesiredAspect(shape);
|
||||||
|
let imgType;
|
||||||
|
let itemId;
|
||||||
|
|
||||||
|
if (shouldShowPreferThumb(item.Type, cardOptions)) {
|
||||||
|
const preferThumbInfo = getPreferThumbInfo(item, cardOptions);
|
||||||
|
imgType = preferThumbInfo.imgType;
|
||||||
|
imgTag = preferThumbInfo.imgTag;
|
||||||
|
itemId = preferThumbInfo.itemId;
|
||||||
|
forceName = preferThumbInfo.forceName;
|
||||||
|
} else if (shouldShowPreferBanner(item.ImageTags?.Banner, cardOptions, shape)) {
|
||||||
|
imgType = ImageType.Banner;
|
||||||
|
imgTag = item.ImageTags?.Banner;
|
||||||
|
itemId = item.Id;
|
||||||
|
} else if (shouldShowPreferDisc(item.ImageTags?.Disc, cardOptions)) {
|
||||||
|
imgType = ImageType.Disc;
|
||||||
|
imgTag = item.ImageTags?.Disc;
|
||||||
|
itemId = item.Id;
|
||||||
|
} else if (cardOptions.preferLogo) {
|
||||||
|
const preferLogoInfo = getPreferLogoInfo(item);
|
||||||
|
imgType = preferLogoInfo.imgType;
|
||||||
|
imgTag = preferLogoInfo.imgType;
|
||||||
|
itemId = preferLogoInfo.itemId;
|
||||||
|
} else if (shouldShowImageTagsPrimary(item)) {
|
||||||
|
imgType = ImageType.Primary;
|
||||||
|
imgTag = item.ImageTags?.Primary;
|
||||||
|
itemId = item.Id;
|
||||||
|
height = getCalculatedHeight(width, primaryImageAspectRatio);
|
||||||
|
forceName = isForceName(cardOptions);
|
||||||
|
coverImage = isCoverImage(primaryImageAspectRatio, uiAspect);
|
||||||
|
} else if (item.SeriesPrimaryImageTag) {
|
||||||
|
imgType = ImageType.Primary;
|
||||||
|
imgTag = item.SeriesPrimaryImageTag;
|
||||||
|
itemId = item.SeriesId;
|
||||||
|
} else if (item.PrimaryImageTag) {
|
||||||
|
imgType = ImageType.Primary;
|
||||||
|
imgTag = item.PrimaryImageTag;
|
||||||
|
itemId = item.PrimaryImageItemId;
|
||||||
|
height = getCalculatedHeight(width, primaryImageAspectRatio);
|
||||||
|
forceName = isForceName(cardOptions);
|
||||||
|
coverImage = isCoverImage(primaryImageAspectRatio, uiAspect);
|
||||||
|
} else if (item.ParentPrimaryImageTag) {
|
||||||
|
imgType = ImageType.Primary;
|
||||||
|
imgTag = item.ParentPrimaryImageTag;
|
||||||
|
itemId = item.ParentPrimaryImageItemId;
|
||||||
|
} else if (shouldShowParentBackdropImageTags(item)) {
|
||||||
|
imgType = ImageType.Primary;
|
||||||
|
imgTag = item.AlbumPrimaryImageTag;
|
||||||
|
itemId = item.AlbumId;
|
||||||
|
height = getCalculatedHeight(width, primaryImageAspectRatio);
|
||||||
|
forceName = isForceName(cardOptions);
|
||||||
|
coverImage = isCoverImage(primaryImageAspectRatio, uiAspect);
|
||||||
|
} else if (shouldShowImageTagsThumb(item)) {
|
||||||
|
imgType = ImageType.Thumb;
|
||||||
|
imgTag = item.ImageTags?.Thumb;
|
||||||
|
itemId = item.Id;
|
||||||
|
} else if (item.BackdropImageTags?.length) {
|
||||||
|
imgType = ImageType.Backdrop;
|
||||||
|
imgTag = item.BackdropImageTags[0];
|
||||||
|
itemId = item.Id;
|
||||||
|
} else if (shouldShowSeriesThumbImageTag(item.SeriesThumbImageTag, cardOptions)) {
|
||||||
|
imgType = ImageType.Thumb;
|
||||||
|
imgTag = item.SeriesThumbImageTag;
|
||||||
|
itemId = item.SeriesId;
|
||||||
|
} else if (shouldShowParentThumbImageTag(item.ParentThumbItemId, cardOptions)) {
|
||||||
|
imgType = ImageType.Thumb;
|
||||||
|
imgTag = item.ParentThumbImageTag;
|
||||||
|
itemId = item.ParentThumbItemId;
|
||||||
|
} else if (
|
||||||
|
item.ParentBackdropImageTags?.length
|
||||||
|
&& cardOptions.inheritThumb !== false
|
||||||
|
) {
|
||||||
|
imgType = ImageType.Backdrop;
|
||||||
|
imgTag = item.ParentBackdropImageTags[0];
|
||||||
|
itemId = item.ParentBackdropItemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imgType,
|
||||||
|
imgTag,
|
||||||
|
itemId,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
forceName,
|
||||||
|
coverImage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseCardImageUrlProps {
|
||||||
|
item: ItemDto;
|
||||||
|
cardOptions: CardOptions;
|
||||||
|
shape: CardShape | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCardImageUrl({ item, cardOptions, shape }: UseCardImageUrlProps) {
|
||||||
|
const { api } = useApi();
|
||||||
|
const imgInfo = getCardImageInfo(item, cardOptions, shape);
|
||||||
|
|
||||||
|
let width = imgInfo.width;
|
||||||
|
let height = imgInfo.height;
|
||||||
|
const imgTag = imgInfo.imgTag;
|
||||||
|
const imgType = imgInfo.imgType;
|
||||||
|
const itemId = imgInfo.itemId;
|
||||||
|
const ratio = window.devicePixelRatio || 1;
|
||||||
|
let imgUrl;
|
||||||
|
let blurhash;
|
||||||
|
|
||||||
|
if (api && imgTag && imgType && itemId) {
|
||||||
|
if (width) {
|
||||||
|
width = Math.round(width * ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (height) {
|
||||||
|
height = Math.round(height * ratio);
|
||||||
|
}
|
||||||
|
imgUrl = getImageApi(api).getItemImageUrlById(itemId, imgType, {
|
||||||
|
quality: 96,
|
||||||
|
fillWidth: width,
|
||||||
|
fillHeight: height,
|
||||||
|
tag: imgTag
|
||||||
|
});
|
||||||
|
|
||||||
|
blurhash = item?.ImageBlurHashes?.[imgType]?.[imgTag];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imgUrl: imgUrl,
|
||||||
|
blurhash: blurhash,
|
||||||
|
forceName: imgInfo.forceName,
|
||||||
|
coverImage: imgInfo.coverImage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useCardImageUrl;
|
113
src/components/cardbuilder/Card/useCardText.tsx
Normal file
113
src/components/cardbuilder/Card/useCardText.tsx
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import layoutManager from 'components/layoutManager';
|
||||||
|
import CardText from './CardText';
|
||||||
|
import { getCardTextLines } from './cardHelper';
|
||||||
|
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { CardOptions } from 'types/cardOptions';
|
||||||
|
|
||||||
|
const enableRightMargin = (
|
||||||
|
isOuterFooter: boolean,
|
||||||
|
cardLayout: boolean | null | undefined,
|
||||||
|
centerText: boolean | undefined,
|
||||||
|
cardFooterAside: string | undefined
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
isOuterFooter
|
||||||
|
&& cardLayout
|
||||||
|
&& !centerText
|
||||||
|
&& cardFooterAside !== 'none'
|
||||||
|
&& layoutManager.mobile
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseCardTextProps {
|
||||||
|
item: ItemDto;
|
||||||
|
cardOptions: CardOptions;
|
||||||
|
forceName: boolean;
|
||||||
|
overlayText: boolean | undefined;
|
||||||
|
imgUrl: string | undefined;
|
||||||
|
isOuterFooter: boolean;
|
||||||
|
cssClass: string;
|
||||||
|
forceLines: boolean;
|
||||||
|
maxLines: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCardText({
|
||||||
|
item,
|
||||||
|
cardOptions,
|
||||||
|
forceName,
|
||||||
|
imgUrl,
|
||||||
|
overlayText,
|
||||||
|
isOuterFooter,
|
||||||
|
cssClass,
|
||||||
|
forceLines,
|
||||||
|
maxLines
|
||||||
|
}: UseCardTextProps) {
|
||||||
|
const { textLines } = getCardTextLines({
|
||||||
|
isOuterFooter,
|
||||||
|
overlayText,
|
||||||
|
forceName,
|
||||||
|
item,
|
||||||
|
cardOptions,
|
||||||
|
imgUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
const addRightMargin = enableRightMargin(
|
||||||
|
isOuterFooter,
|
||||||
|
cardOptions.cardLayout,
|
||||||
|
cardOptions.centerText,
|
||||||
|
cardOptions.cardFooterAside
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderCardTextLines = () => {
|
||||||
|
const components: React.ReactNode[] = [];
|
||||||
|
let valid = 0;
|
||||||
|
for (const textLine of textLines) {
|
||||||
|
const currentCssClass = classNames(
|
||||||
|
cssClass,
|
||||||
|
{
|
||||||
|
'cardText-secondary':
|
||||||
|
valid > 0 && isOuterFooter
|
||||||
|
},
|
||||||
|
{ 'cardText-first': valid === 0 && isOuterFooter },
|
||||||
|
{ 'cardText-rightmargin': addRightMargin }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (textLine) {
|
||||||
|
components.push(
|
||||||
|
<CardText key={valid} className={currentCssClass} textLine={textLine} />
|
||||||
|
);
|
||||||
|
|
||||||
|
valid++;
|
||||||
|
if (maxLines && valid >= maxLines) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forceLines) {
|
||||||
|
const linesLength = maxLines ?? Math.min(textLines.length, maxLines ?? textLines.length);
|
||||||
|
while (valid < linesLength) {
|
||||||
|
components.push(
|
||||||
|
<Box key={valid} className={cssClass}>
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
valid++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return components;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardTextLines = renderCardTextLines();
|
||||||
|
|
||||||
|
return {
|
||||||
|
cardTextLines
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useCardText;
|
|
@ -378,7 +378,7 @@ button::-moz-focus-inner {
|
||||||
margin-right: 2em;
|
margin-right: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardDefaultText {
|
.cardImageContainer > .cardDefaultText {
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
|
@ -408,6 +408,7 @@ button::-moz-focus-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
contain: layout style;
|
contain: layout style;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
[dir="ltr"] & {
|
[dir="ltr"] & {
|
||||||
right: 0.225em;
|
right: 0.225em;
|
||||||
|
@ -852,7 +853,7 @@ button::-moz-focus-inner {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardOverlayFab-primary {
|
.cardOverlayContainer > .cardOverlayFab-primary {
|
||||||
background-color: rgba(0, 0, 0, 0.7);
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
font-size: 130%;
|
font-size: 130%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -865,7 +866,7 @@ button::-moz-focus-inner {
|
||||||
left: 50%;
|
left: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardOverlayFab-primary:hover {
|
.cardOverlayContainer > .cardOverlayFab-primary:hover {
|
||||||
transform: scale(1.4, 1.4);
|
transform: scale(1.4, 1.4);
|
||||||
transition: 0.2s;
|
transition: 0.2s;
|
||||||
}
|
}
|
||||||
|
|
|
@ -73,7 +73,7 @@ function getImageWidth(shape, screenWidth, isOrientationLandscape) {
|
||||||
* @param {Object} items - A set of items.
|
* @param {Object} items - A set of items.
|
||||||
* @param {Object} options - Options for handling the items.
|
* @param {Object} options - Options for handling the items.
|
||||||
*/
|
*/
|
||||||
function setCardData(items, options) {
|
export function setCardData(items, options) {
|
||||||
options.shape = options.shape || 'auto';
|
options.shape = options.shape || 'auto';
|
||||||
|
|
||||||
const primaryImageAspectRatio = imageLoader.getPrimaryImageAspectRatio(items);
|
const primaryImageAspectRatio = imageLoader.getPrimaryImageAspectRatio(items);
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { CardShape } from '../../utils/card';
|
||||||
import { randomInt } from '../../utils/number';
|
import { randomInt } from '../../utils/number';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
@ -10,10 +11,10 @@ const ASPECT_RATIOS = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if the item is live TV.
|
* Determines if the item is live TV.
|
||||||
* @param {string} itemType - Item type to use for the check.
|
* @param {string | null | undefined} itemType - Item type to use for the check.
|
||||||
* @returns {boolean} Flag showing if the item is live TV.
|
* @returns {boolean} Flag showing if the item is live TV.
|
||||||
*/
|
*/
|
||||||
export const isUsingLiveTvNaming = (itemType: string): boolean => itemType === 'Program' || itemType === 'Timer' || itemType === 'Recording';
|
export const isUsingLiveTvNaming = (itemType: string | null | undefined): boolean => itemType === 'Program' || itemType === 'Timer' || itemType === 'Recording';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves Card action to display
|
* Resolves Card action to display
|
||||||
|
@ -54,15 +55,15 @@ export const isResizable = (windowWidth: number): boolean => {
|
||||||
*/
|
*/
|
||||||
export const resolveMixedShapeByAspectRatio = (primaryImageAspectRatio: number | null | undefined) => {
|
export const resolveMixedShapeByAspectRatio = (primaryImageAspectRatio: number | null | undefined) => {
|
||||||
if (primaryImageAspectRatio === undefined || primaryImageAspectRatio === null) {
|
if (primaryImageAspectRatio === undefined || primaryImageAspectRatio === null) {
|
||||||
return 'mixedSquare';
|
return CardShape.MixedSquare;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (primaryImageAspectRatio >= 1.33) {
|
if (primaryImageAspectRatio >= 1.33) {
|
||||||
return 'mixedBackdrop';
|
return CardShape.MixedBackdrop;
|
||||||
} else if (primaryImageAspectRatio > 0.71) {
|
} else if (primaryImageAspectRatio > 0.71) {
|
||||||
return 'mixedSquare';
|
return CardShape.MixedSquare;
|
||||||
} else {
|
} else {
|
||||||
return 'mixedPortrait';
|
return CardShape.MixedPortrait;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
56
src/components/common/DefaultIconText.tsx
Normal file
56
src/components/common/DefaultIconText.tsx
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import Icon from '@mui/material/Icon';
|
||||||
|
import imageHelper from 'utils/image';
|
||||||
|
import DefaultName from './DefaultName';
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
|
||||||
|
interface DefaultIconTextProps {
|
||||||
|
item: ItemDto;
|
||||||
|
defaultCardImageIcon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DefaultIconText: FC<DefaultIconTextProps> = ({
|
||||||
|
item,
|
||||||
|
defaultCardImageIcon
|
||||||
|
}) => {
|
||||||
|
if (item.CollectionType) {
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
className='cardImageIcon'
|
||||||
|
sx={{ color: 'inherit', fontSize: '5em' }}
|
||||||
|
aria-hidden='true'
|
||||||
|
>
|
||||||
|
{imageHelper.getLibraryIcon(item.CollectionType)}
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type && !(item.Type === BaseItemKind.TvChannel || item.Type === BaseItemKind.Studio )) {
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
className='cardImageIcon'
|
||||||
|
sx={{ color: 'inherit', fontSize: '5em' }}
|
||||||
|
aria-hidden='true'
|
||||||
|
>
|
||||||
|
{imageHelper.getItemTypeIcon(item.Type)}
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultCardImageIcon) {
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
className='cardImageIcon'
|
||||||
|
sx={{ color: 'inherit', fontSize: '5em' }}
|
||||||
|
aria-hidden='true'
|
||||||
|
>
|
||||||
|
{defaultCardImageIcon}
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DefaultName item={item} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DefaultIconText;
|
22
src/components/common/DefaultName.tsx
Normal file
22
src/components/common/DefaultName.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import itemHelper from 'components/itemHelper';
|
||||||
|
import { isUsingLiveTvNaming } from '../cardbuilder/cardBuilderUtils';
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
|
||||||
|
interface DefaultNameProps {
|
||||||
|
item: ItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DefaultName: FC<DefaultNameProps> = ({ item }) => {
|
||||||
|
const defaultName = isUsingLiveTvNaming(item.Type) ?
|
||||||
|
item.Name :
|
||||||
|
itemHelper.getDisplayName(item);
|
||||||
|
return (
|
||||||
|
<Box className='cardText cardDefaultText'>
|
||||||
|
{defaultName}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DefaultName;
|
67
src/components/common/Image.tsx
Normal file
67
src/components/common/Image.tsx
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import React, { type FC, useCallback, useState } from 'react';
|
||||||
|
import { BlurhashCanvas } from 'react-blurhash';
|
||||||
|
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
||||||
|
|
||||||
|
const imageStyle: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
zIndex: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ImageProps {
|
||||||
|
imgUrl: string;
|
||||||
|
blurhash?: string;
|
||||||
|
containImage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Image: FC<ImageProps> = ({
|
||||||
|
imgUrl,
|
||||||
|
blurhash,
|
||||||
|
containImage
|
||||||
|
}) => {
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
const [isLoadStarted, setIsLoadStarted] = useState(false);
|
||||||
|
const handleLoad = useCallback(() => {
|
||||||
|
setIsLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoadStarted = useCallback(() => {
|
||||||
|
setIsLoadStarted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{!isLoaded && isLoadStarted && blurhash && (
|
||||||
|
<BlurhashCanvas
|
||||||
|
hash={blurhash}
|
||||||
|
width= {20}
|
||||||
|
height={20}
|
||||||
|
punch={1}
|
||||||
|
style={{
|
||||||
|
...imageStyle,
|
||||||
|
borderRadius: '0.2em',
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<LazyLoadImage
|
||||||
|
key={imgUrl}
|
||||||
|
src={imgUrl}
|
||||||
|
style={{
|
||||||
|
...imageStyle,
|
||||||
|
objectFit: containImage ? 'contain' : 'cover'
|
||||||
|
}}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
beforeLoad={handleLoadStarted}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Image;
|
22
src/components/common/InfoIconButton.tsx
Normal file
22
src/components/common/InfoIconButton.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import InfoIcon from '@mui/icons-material/Info';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
|
interface InfoIconButtonProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfoIconButton: FC<InfoIconButtonProps> = ({ className }) => {
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
className={className}
|
||||||
|
data-action='link'
|
||||||
|
title={globalize.translate('ButtonInfo')}
|
||||||
|
>
|
||||||
|
<InfoIcon />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfoIconButton;
|
36
src/components/common/Media.tsx
Normal file
36
src/components/common/Media.tsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import { BaseItemKind, ImageType } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import Image from './Image';
|
||||||
|
import DefaultIconText from './DefaultIconText';
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
|
||||||
|
interface MediaProps {
|
||||||
|
item: ItemDto;
|
||||||
|
imgUrl: string | undefined;
|
||||||
|
blurhash: string | undefined;
|
||||||
|
imageType?: ImageType
|
||||||
|
defaultCardImageIcon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Media: FC<MediaProps> = ({
|
||||||
|
item,
|
||||||
|
imgUrl,
|
||||||
|
blurhash,
|
||||||
|
imageType,
|
||||||
|
defaultCardImageIcon
|
||||||
|
}) => {
|
||||||
|
return imgUrl ? (
|
||||||
|
<Image
|
||||||
|
imgUrl={imgUrl}
|
||||||
|
blurhash={blurhash}
|
||||||
|
containImage={item.Type === BaseItemKind.TvChannel || imageType === ImageType.Logo}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DefaultIconText
|
||||||
|
item={item}
|
||||||
|
defaultCardImageIcon={defaultCardImageIcon}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Media;
|
23
src/components/common/MoreVertIconButton.tsx
Normal file
23
src/components/common/MoreVertIconButton.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
|
interface MoreVertIconButtonProps {
|
||||||
|
className?: string;
|
||||||
|
iconClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MoreVertIconButton: FC<MoreVertIconButtonProps> = ({ className, iconClassName }) => {
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
className={className}
|
||||||
|
data-action='menu'
|
||||||
|
title={globalize.translate('ButtonMore')}
|
||||||
|
>
|
||||||
|
<MoreVertIcon className={iconClassName} />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MoreVertIconButton;
|
25
src/components/common/NoItemsMessage.tsx
Normal file
25
src/components/common/NoItemsMessage.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
|
interface NoItemsMessageProps {
|
||||||
|
noItemsMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NoItemsMessage: FC<NoItemsMessageProps> = ({
|
||||||
|
noItemsMessage = 'MessageNoItemsAvailable'
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Box className='noItemsMessage centerMessage'>
|
||||||
|
<Typography variant='h2'>
|
||||||
|
{globalize.translate('MessageNothingHere')}
|
||||||
|
</Typography>
|
||||||
|
<Typography paragraph variant='h2'>
|
||||||
|
{globalize.translate(noItemsMessage)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NoItemsMessage;
|
25
src/components/common/PlayArrowIconButton.tsx
Normal file
25
src/components/common/PlayArrowIconButton.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
|
interface PlayArrowIconButtonProps {
|
||||||
|
className: string;
|
||||||
|
action: string;
|
||||||
|
title: string;
|
||||||
|
iconClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayArrowIconButton: FC<PlayArrowIconButtonProps> = ({ className, action, title, iconClassName }) => {
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
className={className}
|
||||||
|
data-action={action}
|
||||||
|
title={globalize.translate(title)}
|
||||||
|
>
|
||||||
|
<PlayArrowIcon className={iconClassName} />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlayArrowIconButton;
|
22
src/components/common/PlaylistAddIconButton.tsx
Normal file
22
src/components/common/PlaylistAddIconButton.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
|
interface PlaylistAddIconButtonProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlaylistAddIconButton: FC<PlaylistAddIconButtonProps> = ({ className }) => {
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
className={className}
|
||||||
|
data-action='addtoplaylist'
|
||||||
|
title={globalize.translate('AddToPlaylist')}
|
||||||
|
>
|
||||||
|
<PlaylistAddIcon />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlaylistAddIconButton;
|
24
src/components/common/RightIconButtons.tsx
Normal file
24
src/components/common/RightIconButtons.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
|
||||||
|
interface RightIconButtonsProps {
|
||||||
|
className?: string;
|
||||||
|
id: string;
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RightIconButtons: FC<RightIconButtonsProps> = ({ className, id, title, icon }) => {
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
className={className}
|
||||||
|
data-action='custom'
|
||||||
|
data-customaction={id}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RightIconButtons;
|
|
@ -5,6 +5,14 @@
|
||||||
height: 0.28em;
|
height: 0.28em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.itemLinearProgress {
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
.itemProgressBarForeground {
|
.itemProgressBarForeground {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
261
src/components/indicators/useIndicator.tsx
Normal file
261
src/components/indicators/useIndicator.tsx
Normal file
|
@ -0,0 +1,261 @@
|
||||||
|
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||||
|
import { LocationType } from '@jellyfin/sdk/lib/generated-client/models/location-type';
|
||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import LinearProgress, {
|
||||||
|
linearProgressClasses
|
||||||
|
} from '@mui/material/LinearProgress';
|
||||||
|
import FiberSmartRecordIcon from '@mui/icons-material/FiberSmartRecord';
|
||||||
|
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
|
||||||
|
import CheckIcon from '@mui/icons-material/Check';
|
||||||
|
import VideocamIcon from '@mui/icons-material/Videocam';
|
||||||
|
import FolderIcon from '@mui/icons-material/Folder';
|
||||||
|
import PhotoAlbumIcon from '@mui/icons-material/PhotoAlbum';
|
||||||
|
import PhotoIcon from '@mui/icons-material/Photo';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import datetime from 'scripts/datetime';
|
||||||
|
import itemHelper from 'components/itemHelper';
|
||||||
|
import AutoTimeProgressBar from 'elements/emby-progressbar/AutoTimeProgressBar';
|
||||||
|
import type { NullableString } from 'types/base/common/shared/types';
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { ProgressOptions } from 'types/progressOptions';
|
||||||
|
|
||||||
|
const TypeIcon = {
|
||||||
|
Video: <VideocamIcon className='indicatorIcon' />,
|
||||||
|
Folder: <FolderIcon className='indicatorIcon' />,
|
||||||
|
PhotoAlbum: <PhotoAlbumIcon className='indicatorIcon' />,
|
||||||
|
Photo: <PhotoIcon className='indicatorIcon' />
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeIcon = (itemType: NullableString) => {
|
||||||
|
return TypeIcon[itemType as keyof typeof TypeIcon];
|
||||||
|
};
|
||||||
|
|
||||||
|
const enableProgressIndicator = (
|
||||||
|
itemType: NullableString,
|
||||||
|
itemMediaType: NullableString
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
(itemMediaType === 'Video' && itemType !== BaseItemKind.TvChannel)
|
||||||
|
|| itemType === BaseItemKind.AudioBook
|
||||||
|
|| itemType === 'AudioPodcast'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const enableAutoTimeProgressIndicator = (
|
||||||
|
itemType: NullableString,
|
||||||
|
itemStartDate: NullableString,
|
||||||
|
itemEndDate: NullableString
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
(itemType === BaseItemKind.Program
|
||||||
|
|| itemType === 'Timer'
|
||||||
|
|| itemType === BaseItemKind.Recording)
|
||||||
|
&& Boolean(itemStartDate)
|
||||||
|
&& Boolean(itemEndDate)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const enablePlayedIndicator = (item: ItemDto) => {
|
||||||
|
return itemHelper.canMarkPlayed(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useIndicator = (item: ItemDto) => {
|
||||||
|
const getMediaSourceIndicator = () => {
|
||||||
|
const mediaSourceCount = item.MediaSourceCount ?? 0;
|
||||||
|
if (mediaSourceCount > 1) {
|
||||||
|
return <Box className='mediaSourceIndicator'>{mediaSourceCount}</Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMissingIndicator = () => {
|
||||||
|
if (
|
||||||
|
item.Type === BaseItemKind.Episode
|
||||||
|
&& item.LocationType === LocationType.Virtual
|
||||||
|
) {
|
||||||
|
if (item.PremiereDate) {
|
||||||
|
try {
|
||||||
|
const premiereDate = datetime
|
||||||
|
.parseISO8601Date(item.PremiereDate)
|
||||||
|
.getTime();
|
||||||
|
if (premiereDate > new Date().getTime()) {
|
||||||
|
return <Box className='unairedIndicator'>Unaired</Box>;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <Box className='missingIndicator'>Missing</Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimerIndicator = (className?: string) => {
|
||||||
|
const indicatorIconClass = classNames('timerIndicator', className);
|
||||||
|
|
||||||
|
let status;
|
||||||
|
|
||||||
|
if (item.Type === 'SeriesTimer') {
|
||||||
|
return <FiberSmartRecordIcon className={indicatorIconClass} />;
|
||||||
|
} else if (item.TimerId || item.SeriesTimerId) {
|
||||||
|
status = item.Status || 'Cancelled';
|
||||||
|
} else if (item.Type === 'Timer') {
|
||||||
|
status = item.Status;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.SeriesTimerId) {
|
||||||
|
return (
|
||||||
|
<FiberSmartRecordIcon
|
||||||
|
className={`${indicatorIconClass} ${
|
||||||
|
status === 'Cancelled' ? 'timerIndicator-inactive' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FiberManualRecordIcon className={indicatorIconClass} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeIndicator = () => {
|
||||||
|
const icon = getTypeIcon(item.Type);
|
||||||
|
if (icon) {
|
||||||
|
return <Box className='indicator videoIndicator'>{icon}</Box>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChildCountIndicator = () => {
|
||||||
|
const childCount = item.ChildCount ?? 0;
|
||||||
|
|
||||||
|
if (childCount > 1) {
|
||||||
|
return (
|
||||||
|
<Box className='countIndicator indicator childCountIndicator'>
|
||||||
|
{datetime.toLocaleString(item.ChildCount)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlayedIndicator = () => {
|
||||||
|
if (enablePlayedIndicator(item)) {
|
||||||
|
const userData = item.UserData || {};
|
||||||
|
if (userData.UnplayedItemCount) {
|
||||||
|
return (
|
||||||
|
<Box className='countIndicator indicator unplayedItemCount'>
|
||||||
|
{datetime.toLocaleString(userData.UnplayedItemCount)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(userData.PlayedPercentage
|
||||||
|
&& userData.PlayedPercentage >= 100)
|
||||||
|
|| userData.Played
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Box className='playedIndicator indicator'>
|
||||||
|
<CheckIcon className='indicatorIcon' />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgress = (pct: number, progressOptions?: ProgressOptions) => {
|
||||||
|
const progressBarClass = classNames(
|
||||||
|
'itemLinearProgress',
|
||||||
|
progressOptions?.containerClass
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LinearProgress
|
||||||
|
className={progressBarClass}
|
||||||
|
variant='determinate'
|
||||||
|
value={pct}
|
||||||
|
sx={{
|
||||||
|
[`& .${linearProgressClasses.bar}`]: {
|
||||||
|
borderRadius: 5,
|
||||||
|
backgroundColor: '#00a4dc'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressBar = (progressOptions?: ProgressOptions) => {
|
||||||
|
if (
|
||||||
|
enableProgressIndicator(item.Type, item.MediaType)
|
||||||
|
&& item.Type !== BaseItemKind.Recording
|
||||||
|
) {
|
||||||
|
const playedPercentage = progressOptions?.userData?.PlayedPercentage ?
|
||||||
|
progressOptions.userData.PlayedPercentage :
|
||||||
|
item?.UserData?.PlayedPercentage;
|
||||||
|
if (playedPercentage && playedPercentage < 100) {
|
||||||
|
return getProgress(playedPercentage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
enableAutoTimeProgressIndicator(
|
||||||
|
item.Type,
|
||||||
|
item.StartDate,
|
||||||
|
item.EndDate
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
let startDate = 0;
|
||||||
|
let endDate = 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
startDate = datetime.parseISO8601Date(item.StartDate).getTime();
|
||||||
|
endDate = datetime.parseISO8601Date(item.EndDate).getTime();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const total = endDate - startDate;
|
||||||
|
const pct = 100 * ((now - startDate) / total);
|
||||||
|
|
||||||
|
if (pct > 0 && pct < 100) {
|
||||||
|
const isRecording =
|
||||||
|
item.Type === 'Timer'
|
||||||
|
|| item.Type === BaseItemKind.Recording
|
||||||
|
|| Boolean(item.TimerId);
|
||||||
|
return (
|
||||||
|
<AutoTimeProgressBar
|
||||||
|
pct={pct}
|
||||||
|
progressOptions={progressOptions}
|
||||||
|
isRecording={isRecording}
|
||||||
|
starTtime={startDate}
|
||||||
|
endTtime={endDate}
|
||||||
|
dataAutoMode='time'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getProgress,
|
||||||
|
getProgressBar,
|
||||||
|
getMediaSourceIndicator,
|
||||||
|
getMissingIndicator,
|
||||||
|
getTimerIndicator,
|
||||||
|
getTypeIndicator,
|
||||||
|
getChildCountIndicator,
|
||||||
|
getPlayedIndicator
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useIndicator;
|
32
src/components/listview/List/List.tsx
Normal file
32
src/components/listview/List/List.tsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import useList from './useList';
|
||||||
|
import ListContent from './ListContent';
|
||||||
|
import ListWrapper from './ListWrapper';
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { ListOptions } from 'types/listOptions';
|
||||||
|
import '../../mediainfo/mediainfo.scss';
|
||||||
|
import '../../guide/programs.scss';
|
||||||
|
|
||||||
|
interface ListProps {
|
||||||
|
index: number;
|
||||||
|
item: ItemDto;
|
||||||
|
listOptions?: ListOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const List: FC<ListProps> = ({ index, item, listOptions = {} }) => {
|
||||||
|
const { getListdWrapperProps, getListContentProps } = useList({ item, listOptions } );
|
||||||
|
const listWrapperProps = getListdWrapperProps();
|
||||||
|
const listContentProps = getListContentProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListWrapper
|
||||||
|
key={index}
|
||||||
|
index={index}
|
||||||
|
{...listWrapperProps}
|
||||||
|
>
|
||||||
|
<ListContent {...listContentProps} />
|
||||||
|
</ListWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default List;
|
106
src/components/listview/List/ListContent.tsx
Normal file
106
src/components/listview/List/ListContent.tsx
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import DragHandleIcon from '@mui/icons-material/DragHandle';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
|
||||||
|
import useIndicator from 'components/indicators/useIndicator';
|
||||||
|
import PrimaryMediaInfo from '../../mediainfo/PrimaryMediaInfo';
|
||||||
|
import ListContentWrapper from './ListContentWrapper';
|
||||||
|
import ListItemBody from './ListItemBody';
|
||||||
|
import ListImageContainer from './ListImageContainer';
|
||||||
|
import ListViewUserDataButtons from './ListViewUserDataButtons';
|
||||||
|
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { ListOptions } from 'types/listOptions';
|
||||||
|
|
||||||
|
interface ListContentProps {
|
||||||
|
item: ItemDto;
|
||||||
|
listOptions: ListOptions;
|
||||||
|
enableContentWrapper?: boolean;
|
||||||
|
enableOverview?: boolean;
|
||||||
|
enableSideMediaInfo?: boolean;
|
||||||
|
clickEntireItem?: boolean;
|
||||||
|
action?: string;
|
||||||
|
isLargeStyle: boolean;
|
||||||
|
downloadWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListContent: FC<ListContentProps> = ({
|
||||||
|
item,
|
||||||
|
listOptions,
|
||||||
|
enableContentWrapper,
|
||||||
|
enableOverview,
|
||||||
|
enableSideMediaInfo,
|
||||||
|
clickEntireItem,
|
||||||
|
action,
|
||||||
|
isLargeStyle,
|
||||||
|
downloadWidth
|
||||||
|
}) => {
|
||||||
|
const indicator = useIndicator(item);
|
||||||
|
return (
|
||||||
|
<ListContentWrapper
|
||||||
|
itemOverview={item.Overview}
|
||||||
|
enableContentWrapper={enableContentWrapper}
|
||||||
|
enableOverview={enableOverview}
|
||||||
|
>
|
||||||
|
|
||||||
|
{!clickEntireItem && listOptions.dragHandle && (
|
||||||
|
<DragHandleIcon className='listViewDragHandle listItemIcon listItemIcon-transparent' />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{listOptions.image !== false && (
|
||||||
|
<ListImageContainer
|
||||||
|
item={item}
|
||||||
|
listOptions={listOptions}
|
||||||
|
action={action}
|
||||||
|
isLargeStyle={isLargeStyle}
|
||||||
|
clickEntireItem={clickEntireItem}
|
||||||
|
downloadWidth={downloadWidth}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{listOptions.showIndexNumberLeft && (
|
||||||
|
<Box className='listItem-indexnumberleft'>
|
||||||
|
{item.IndexNumber ?? <span> </span>}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ListItemBody
|
||||||
|
item={item}
|
||||||
|
listOptions={listOptions}
|
||||||
|
action={action}
|
||||||
|
enableContentWrapper={enableContentWrapper}
|
||||||
|
enableOverview={enableOverview}
|
||||||
|
enableSideMediaInfo={enableSideMediaInfo}
|
||||||
|
getMissingIndicator={indicator.getMissingIndicator}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{listOptions.mediaInfo !== false && enableSideMediaInfo && (
|
||||||
|
<PrimaryMediaInfo
|
||||||
|
className='secondary listItemMediaInfo'
|
||||||
|
item={item}
|
||||||
|
isRuntimeEnabled={true}
|
||||||
|
isStarRatingEnabled={true}
|
||||||
|
isCaptionIndicatorEnabled={true}
|
||||||
|
isEpisodeTitleEnabled={true}
|
||||||
|
isOfficialRatingEnabled={true}
|
||||||
|
getMissingIndicator={indicator.getMissingIndicator}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{listOptions.recordButton
|
||||||
|
&& (item.Type === 'Timer' || item.Type === BaseItemKind.Program) && (
|
||||||
|
indicator.getTimerIndicator('listItemAside')
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!clickEntireItem && (
|
||||||
|
<ListViewUserDataButtons
|
||||||
|
item={item}
|
||||||
|
listOptions={listOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ListContentWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListContent;
|
34
src/components/listview/List/ListContentWrapper.tsx
Normal file
34
src/components/listview/List/ListContentWrapper.tsx
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
|
||||||
|
interface ListContentWrapperProps {
|
||||||
|
itemOverview: string | null | undefined;
|
||||||
|
enableContentWrapper?: boolean;
|
||||||
|
enableOverview?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListContentWrapper: FC<ListContentWrapperProps> = ({
|
||||||
|
itemOverview,
|
||||||
|
enableContentWrapper,
|
||||||
|
enableOverview,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
if (enableContentWrapper) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box className='listItem-content'>{children}</Box>
|
||||||
|
|
||||||
|
{enableOverview && itemOverview && (
|
||||||
|
<Box className='listItem-bottomoverview secondary'>
|
||||||
|
<bdi>{itemOverview}</bdi>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListContentWrapper;
|
30
src/components/listview/List/ListGroupHeaderWrapper.tsx
Normal file
30
src/components/listview/List/ListGroupHeaderWrapper.tsx
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
|
interface ListGroupHeaderWrapperProps {
|
||||||
|
index?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListGroupHeaderWrapper: FC<ListGroupHeaderWrapperProps> = ({
|
||||||
|
index,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
if (index === 0) {
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
className='listGroupHeader listGroupHeader-first'
|
||||||
|
variant='h2'
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Typography className='listGroupHeader' variant='h2'>
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListGroupHeaderWrapper;
|
103
src/components/listview/List/ListImageContainer.tsx
Normal file
103
src/components/listview/List/ListImageContainer.tsx
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import useIndicator from '../../indicators/useIndicator';
|
||||||
|
import layoutManager from '../../layoutManager';
|
||||||
|
import { getDefaultBackgroundClass } from '../../cardbuilder/cardBuilderUtils';
|
||||||
|
import {
|
||||||
|
canResume,
|
||||||
|
getChannelImageUrl,
|
||||||
|
getImageUrl
|
||||||
|
} from './listHelper';
|
||||||
|
|
||||||
|
import Media from 'components/common/Media';
|
||||||
|
import PlayArrowIconButton from 'components/common/PlayArrowIconButton';
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { ListOptions } from 'types/listOptions';
|
||||||
|
|
||||||
|
interface ListImageContainerProps {
|
||||||
|
item: ItemDto;
|
||||||
|
listOptions: ListOptions;
|
||||||
|
action?: string | null;
|
||||||
|
isLargeStyle: boolean;
|
||||||
|
clickEntireItem?: boolean;
|
||||||
|
downloadWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListImageContainer: FC<ListImageContainerProps> = ({
|
||||||
|
item = {},
|
||||||
|
listOptions,
|
||||||
|
action,
|
||||||
|
isLargeStyle,
|
||||||
|
clickEntireItem,
|
||||||
|
downloadWidth
|
||||||
|
}) => {
|
||||||
|
const { api } = useApi();
|
||||||
|
const { getMediaSourceIndicator, getProgressBar, getPlayedIndicator } = useIndicator(item);
|
||||||
|
const imgInfo = listOptions.imageSource === 'channel' ?
|
||||||
|
getChannelImageUrl(item, api, downloadWidth) :
|
||||||
|
getImageUrl(item, api, downloadWidth);
|
||||||
|
|
||||||
|
const defaultCardImageIcon = listOptions.defaultCardImageIcon;
|
||||||
|
const disableIndicators = listOptions.disableIndicators;
|
||||||
|
const imgUrl = imgInfo?.imgUrl;
|
||||||
|
const blurhash = imgInfo.blurhash;
|
||||||
|
|
||||||
|
const imageClass = classNames(
|
||||||
|
'listItemImage',
|
||||||
|
{ 'listItemImage-large': isLargeStyle },
|
||||||
|
{ 'listItemImage-channel': listOptions.imageSource === 'channel' },
|
||||||
|
{ 'listItemImage-large-tv': isLargeStyle && layoutManager.tv },
|
||||||
|
{ itemAction: !clickEntireItem },
|
||||||
|
{ [getDefaultBackgroundClass(item.Name)]: !imgUrl }
|
||||||
|
);
|
||||||
|
|
||||||
|
const playOnImageClick = listOptions.imagePlayButton && !layoutManager.tv;
|
||||||
|
|
||||||
|
const imageAction = playOnImageClick ? 'link' : action;
|
||||||
|
|
||||||
|
const btnCssClass =
|
||||||
|
'paper-icon-button-light listItemImageButton itemAction';
|
||||||
|
|
||||||
|
const mediaSourceIndicator = getMediaSourceIndicator();
|
||||||
|
const playedIndicator = getPlayedIndicator();
|
||||||
|
const progressBar = getProgressBar();
|
||||||
|
const playbackPositionTicks = item?.UserData?.PlaybackPositionTicks;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
data-action={imageAction}
|
||||||
|
className={imageClass}
|
||||||
|
>
|
||||||
|
|
||||||
|
<Media item={item} imgUrl={imgUrl} blurhash={blurhash} defaultCardImageIcon={defaultCardImageIcon} />
|
||||||
|
|
||||||
|
{disableIndicators !== true && mediaSourceIndicator}
|
||||||
|
|
||||||
|
{playedIndicator && (
|
||||||
|
<Box className='indicators listItemIndicators'>
|
||||||
|
{playedIndicator}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{playOnImageClick && (
|
||||||
|
<PlayArrowIconButton
|
||||||
|
className={btnCssClass}
|
||||||
|
action={
|
||||||
|
canResume(playbackPositionTicks) ? 'resume' : 'play'
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
canResume(playbackPositionTicks) ?
|
||||||
|
'ButtonResume' :
|
||||||
|
'Play'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{progressBar}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListImageContainer;
|
65
src/components/listview/List/ListItemBody.tsx
Normal file
65
src/components/listview/List/ListItemBody.tsx
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import useListTextlines from './useListTextlines';
|
||||||
|
import PrimaryMediaInfo from '../../mediainfo/PrimaryMediaInfo';
|
||||||
|
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { ListOptions } from 'types/listOptions';
|
||||||
|
|
||||||
|
interface ListItemBodyProps {
|
||||||
|
item: ItemDto;
|
||||||
|
listOptions: ListOptions;
|
||||||
|
action?: string | null;
|
||||||
|
isLargeStyle?: boolean;
|
||||||
|
clickEntireItem?: boolean;
|
||||||
|
enableContentWrapper?: boolean;
|
||||||
|
enableOverview?: boolean;
|
||||||
|
enableSideMediaInfo?: boolean;
|
||||||
|
getMissingIndicator: () => React.JSX.Element | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListItemBody: FC<ListItemBodyProps> = ({
|
||||||
|
item = {},
|
||||||
|
listOptions = {},
|
||||||
|
action,
|
||||||
|
isLargeStyle,
|
||||||
|
clickEntireItem,
|
||||||
|
enableContentWrapper,
|
||||||
|
enableOverview,
|
||||||
|
enableSideMediaInfo,
|
||||||
|
getMissingIndicator
|
||||||
|
}) => {
|
||||||
|
const { listTextLines } = useListTextlines({ item, listOptions, isLargeStyle });
|
||||||
|
const cssClass = classNames(
|
||||||
|
'listItemBody',
|
||||||
|
{ 'itemAction': !clickEntireItem },
|
||||||
|
{ 'listItemBody-noleftpadding': listOptions.image === false }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box data-action={action} className={cssClass}>
|
||||||
|
|
||||||
|
{listTextLines}
|
||||||
|
|
||||||
|
{listOptions.mediaInfo !== false && !enableSideMediaInfo && (
|
||||||
|
<PrimaryMediaInfo
|
||||||
|
className='secondary listItemMediaInfo listItemBodyText'
|
||||||
|
item={item}
|
||||||
|
isEpisodeTitleEnabled={true}
|
||||||
|
isOriginalAirDateEnabled={true}
|
||||||
|
isCaptionIndicatorEnabled={true}
|
||||||
|
getMissingIndicator={getMissingIndicator}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!enableContentWrapper && enableOverview && item.Overview && (
|
||||||
|
<Box className='secondary listItem-overview listItemBodyText'>
|
||||||
|
<bdi>{item.Overview}</bdi>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListItemBody;
|
30
src/components/listview/List/ListTextWrapper.tsx
Normal file
30
src/components/listview/List/ListTextWrapper.tsx
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
|
interface ListTextWrapperProps {
|
||||||
|
index?: number;
|
||||||
|
isLargeStyle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListTextWrapper: FC<ListTextWrapperProps> = ({
|
||||||
|
index,
|
||||||
|
isLargeStyle,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
if (index === 0) {
|
||||||
|
if (isLargeStyle) {
|
||||||
|
return (
|
||||||
|
<Typography className='listItemBodyText' variant='h2'>
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <Box className='listItemBodyText'>{children}</Box>;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return <Box className='secondary listItemBodyText'>{children}</Box>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListTextWrapper;
|
87
src/components/listview/List/ListViewUserDataButtons.tsx
Normal file
87
src/components/listview/List/ListViewUserDataButtons.tsx
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
import itemHelper from '../../itemHelper';
|
||||||
|
import PlayedButton from 'elements/emby-playstatebutton/PlayedButton';
|
||||||
|
import FavoriteButton from 'elements/emby-ratingbutton/FavoriteButton';
|
||||||
|
import PlaylistAddIconButton from '../../common/PlaylistAddIconButton';
|
||||||
|
import InfoIconButton from '../../common/InfoIconButton';
|
||||||
|
import RightIconButtons from '../../common/RightIconButtons';
|
||||||
|
import MoreVertIconButton from '../../common/MoreVertIconButton';
|
||||||
|
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { ListOptions } from 'types/listOptions';
|
||||||
|
|
||||||
|
interface ListViewUserDataButtonsProps {
|
||||||
|
item: ItemDto;
|
||||||
|
listOptions: ListOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListViewUserDataButtons: FC<ListViewUserDataButtonsProps> = ({
|
||||||
|
item = {},
|
||||||
|
listOptions
|
||||||
|
}) => {
|
||||||
|
const { IsFavorite, Played } = item.UserData ?? {};
|
||||||
|
|
||||||
|
const renderRightButtons = () => {
|
||||||
|
return listOptions.rightButtons?.map((button, index) => (
|
||||||
|
<RightIconButtons
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
key={index}
|
||||||
|
className='listItemButton itemAction'
|
||||||
|
id={button.id}
|
||||||
|
title={button.title}
|
||||||
|
icon={button.icon}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className='listViewUserDataButtons'>
|
||||||
|
{listOptions.addToListButton && (
|
||||||
|
<PlaylistAddIconButton
|
||||||
|
className='paper-icon-button-light listItemButton itemAction'
|
||||||
|
/>
|
||||||
|
|
||||||
|
)}
|
||||||
|
{listOptions.infoButton && (
|
||||||
|
<InfoIconButton
|
||||||
|
className='paper-icon-button-light listItemButton itemAction'
|
||||||
|
/>
|
||||||
|
|
||||||
|
) }
|
||||||
|
|
||||||
|
{listOptions.rightButtons && renderRightButtons()}
|
||||||
|
|
||||||
|
{listOptions.enableUserDataButtons !== false && (
|
||||||
|
<>
|
||||||
|
{itemHelper.canMarkPlayed(item)
|
||||||
|
&& listOptions.enablePlayedButton !== false && (
|
||||||
|
<PlayedButton
|
||||||
|
className='listItemButton'
|
||||||
|
isPlayed={Played}
|
||||||
|
itemId={item.Id}
|
||||||
|
itemType={item.Type}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{itemHelper.canRate(item)
|
||||||
|
&& listOptions.enableRatingButton !== false && (
|
||||||
|
<FavoriteButton
|
||||||
|
className='listItemButton'
|
||||||
|
isFavorite={IsFavorite}
|
||||||
|
itemId={item.Id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{listOptions.moreButton !== false && (
|
||||||
|
<MoreVertIconButton
|
||||||
|
className='paper-icon-button-light listItemButton itemAction'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListViewUserDataButtons;
|
48
src/components/listview/List/ListWrapper.tsx
Normal file
48
src/components/listview/List/ListWrapper.tsx
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import layoutManager from '../../layoutManager';
|
||||||
|
import type { DataAttributes } from 'types/dataAttributes';
|
||||||
|
|
||||||
|
interface ListWrapperProps {
|
||||||
|
index: number | undefined;
|
||||||
|
title?: string | null;
|
||||||
|
action?: string | null;
|
||||||
|
dataAttributes?: DataAttributes;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListWrapper: FC<ListWrapperProps> = ({
|
||||||
|
index,
|
||||||
|
action,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
dataAttributes,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
if (layoutManager.tv) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-index={index}
|
||||||
|
className={classNames(
|
||||||
|
className,
|
||||||
|
'itemAction listItem-button listItem-focusscale'
|
||||||
|
)}
|
||||||
|
data-action={action}
|
||||||
|
aria-label={title || ''}
|
||||||
|
{...dataAttributes}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Box data-index={index} className={className} {...dataAttributes}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListWrapper;
|
56
src/components/listview/List/Lists.tsx
Normal file
56
src/components/listview/List/Lists.tsx
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import { groupBy } from 'lodash-es';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { getIndex } from './listHelper';
|
||||||
|
import ListGroupHeaderWrapper from './ListGroupHeaderWrapper';
|
||||||
|
import List from './List';
|
||||||
|
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { ListOptions } from 'types/listOptions';
|
||||||
|
import '../listview.scss';
|
||||||
|
|
||||||
|
interface ListsProps {
|
||||||
|
items: ItemDto[];
|
||||||
|
listOptions?: ListOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Lists: FC<ListsProps> = ({ items = [], listOptions = {} }) => {
|
||||||
|
const groupedData = groupBy(items, (item) => {
|
||||||
|
if (listOptions.showIndex) {
|
||||||
|
return getIndex(item, listOptions);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderListItem = (item: ItemDto, index: number) => {
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
key={`${item.Id}-${index}`}
|
||||||
|
index={index}
|
||||||
|
item={item}
|
||||||
|
listOptions={listOptions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Object.entries(groupedData).map(
|
||||||
|
([itemGroupTitle, getItems], index) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<Box key={index}>
|
||||||
|
{itemGroupTitle && (
|
||||||
|
<ListGroupHeaderWrapper index={index}>
|
||||||
|
{itemGroupTitle}
|
||||||
|
</ListGroupHeaderWrapper>
|
||||||
|
)}
|
||||||
|
{getItems.map((item) => renderListItem(item, index))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Lists;
|
172
src/components/listview/List/listHelper.ts
Normal file
172
src/components/listview/List/listHelper.ts
Normal file
|
@ -0,0 +1,172 @@
|
||||||
|
import { Api } from '@jellyfin/sdk';
|
||||||
|
import { BaseItemKind, ImageType } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { ListOptions } from 'types/listOptions';
|
||||||
|
|
||||||
|
const sortBySortName = (item: ItemDto): string => {
|
||||||
|
if (item.Type === BaseItemKind.Episode) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortName
|
||||||
|
const name = (item.SortName ?? item.Name ?? '?')[0].toUpperCase();
|
||||||
|
|
||||||
|
const code = name.charCodeAt(0);
|
||||||
|
if (code < 65 || code > 90) {
|
||||||
|
return '#';
|
||||||
|
}
|
||||||
|
|
||||||
|
return name.toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortByOfficialrating = (item: ItemDto): string => {
|
||||||
|
return item.OfficialRating ?? globalize.translate('Unrated');
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortByCommunityRating = (item: ItemDto): string => {
|
||||||
|
if (item.CommunityRating == null) {
|
||||||
|
return globalize.translate('Unrated');
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(Math.floor(item.CommunityRating));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortByCriticRating = (item: ItemDto): string => {
|
||||||
|
if (item.CriticRating == null) {
|
||||||
|
return globalize.translate('Unrated');
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(Math.floor(item.CriticRating));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortByAlbumArtist = (item: ItemDto): string => {
|
||||||
|
// SortName
|
||||||
|
if (!item.AlbumArtist) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = item.AlbumArtist[0].toUpperCase();
|
||||||
|
|
||||||
|
const code = name.charCodeAt(0);
|
||||||
|
if (code < 65 || code > 90) {
|
||||||
|
return '#';
|
||||||
|
}
|
||||||
|
|
||||||
|
return name.toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getIndex(item: ItemDto, listOptions: ListOptions): string {
|
||||||
|
if (listOptions.index === 'disc') {
|
||||||
|
return item.ParentIndexNumber == null ?
|
||||||
|
'' :
|
||||||
|
globalize.translate('ValueDiscNumber', item.ParentIndexNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortBy = (listOptions.sortBy ?? '').toLowerCase();
|
||||||
|
|
||||||
|
if (sortBy.startsWith('sortname')) {
|
||||||
|
return sortBySortName(item);
|
||||||
|
}
|
||||||
|
if (sortBy.startsWith('officialrating')) {
|
||||||
|
return sortByOfficialrating(item);
|
||||||
|
}
|
||||||
|
if (sortBy.startsWith('communityrating')) {
|
||||||
|
return sortByCommunityRating(item);
|
||||||
|
}
|
||||||
|
if (sortBy.startsWith('criticrating')) {
|
||||||
|
return sortByCriticRating(item);
|
||||||
|
}
|
||||||
|
if (sortBy.startsWith('albumartist')) {
|
||||||
|
return sortByAlbumArtist(item);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getImageUrl(
|
||||||
|
item: ItemDto,
|
||||||
|
api: Api | undefined,
|
||||||
|
size: number | undefined
|
||||||
|
) {
|
||||||
|
let imgTag;
|
||||||
|
let itemId;
|
||||||
|
const fillWidth = size;
|
||||||
|
const fillHeight = size;
|
||||||
|
const imgType = ImageType.Primary;
|
||||||
|
|
||||||
|
if (item.ImageTags?.Primary) {
|
||||||
|
imgTag = item.ImageTags.Primary;
|
||||||
|
itemId = item.Id;
|
||||||
|
} else if (item.AlbumId && item.AlbumPrimaryImageTag) {
|
||||||
|
imgTag = item.AlbumPrimaryImageTag;
|
||||||
|
itemId = item.AlbumId;
|
||||||
|
} else if (item.SeriesId && item.SeriesPrimaryImageTag) {
|
||||||
|
imgTag = item.SeriesPrimaryImageTag;
|
||||||
|
itemId = item.SeriesId;
|
||||||
|
} else if (item.ParentPrimaryImageTag) {
|
||||||
|
imgTag = item.ParentPrimaryImageTag;
|
||||||
|
itemId = item.ParentPrimaryImageItemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (api && imgTag && imgType && itemId) {
|
||||||
|
const response = getImageApi(api).getItemImageUrlById(itemId, imgType, {
|
||||||
|
fillWidth: fillWidth,
|
||||||
|
fillHeight: fillHeight,
|
||||||
|
tag: imgTag
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
imgUrl: response,
|
||||||
|
blurhash: item.ImageBlurHashes?.[imgType]?.[imgTag]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imgUrl: undefined,
|
||||||
|
blurhash: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChannelImageUrl(
|
||||||
|
item: ItemDto,
|
||||||
|
api: Api | undefined,
|
||||||
|
size: number | undefined
|
||||||
|
) {
|
||||||
|
let imgTag;
|
||||||
|
let itemId;
|
||||||
|
const fillWidth = size;
|
||||||
|
const fillHeight = size;
|
||||||
|
const imgType = ImageType.Primary;
|
||||||
|
|
||||||
|
if (item.ChannelId && item.ChannelPrimaryImageTag) {
|
||||||
|
imgTag = item.ChannelPrimaryImageTag;
|
||||||
|
itemId = item.ChannelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (api && imgTag && imgType && itemId) {
|
||||||
|
const response = api.getItemImageUrl(itemId, imgType, {
|
||||||
|
fillWidth: fillWidth,
|
||||||
|
fillHeight: fillHeight,
|
||||||
|
tag: imgTag
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
imgUrl: response,
|
||||||
|
blurhash: item.ImageBlurHashes?.[imgType]?.[imgTag]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imgUrl: undefined,
|
||||||
|
blurhash: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canResume(PlaybackPositionTicks: number | undefined): boolean {
|
||||||
|
return Boolean(
|
||||||
|
PlaybackPositionTicks
|
||||||
|
&& PlaybackPositionTicks > 0
|
||||||
|
);
|
||||||
|
}
|
77
src/components/listview/List/useList.ts
Normal file
77
src/components/listview/List/useList.ts
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { getDataAttributes } from 'utils/items';
|
||||||
|
import layoutManager from 'components/layoutManager';
|
||||||
|
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { ListOptions } from 'types/listOptions';
|
||||||
|
|
||||||
|
interface UseListProps {
|
||||||
|
item: ItemDto;
|
||||||
|
listOptions: ListOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useList({ item, listOptions }: UseListProps) {
|
||||||
|
const action = listOptions.action ?? 'link';
|
||||||
|
const isLargeStyle = listOptions.imageSize === 'large';
|
||||||
|
const enableOverview = listOptions.enableOverview;
|
||||||
|
const clickEntireItem = !!layoutManager.tv;
|
||||||
|
const enableSideMediaInfo = listOptions.enableSideMediaInfo ?? true;
|
||||||
|
const enableContentWrapper =
|
||||||
|
listOptions.enableOverview && !layoutManager.tv;
|
||||||
|
const downloadWidth = isLargeStyle ? 500 : 80;
|
||||||
|
|
||||||
|
const dataAttributes = getDataAttributes(
|
||||||
|
{
|
||||||
|
action,
|
||||||
|
itemServerId: item.ServerId,
|
||||||
|
itemId: item.Id,
|
||||||
|
collectionId: listOptions.collectionId,
|
||||||
|
playlistId: listOptions.playlistId,
|
||||||
|
itemChannelId: item.ChannelId,
|
||||||
|
itemType: item.Type,
|
||||||
|
itemMediaType: item.MediaType,
|
||||||
|
itemCollectionType: item.CollectionType,
|
||||||
|
itemIsFolder: item.IsFolder,
|
||||||
|
itemPlaylistItemId: item.PlaylistItemId
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const listWrapperClass = classNames(
|
||||||
|
'listItem',
|
||||||
|
{
|
||||||
|
'listItem-border':
|
||||||
|
listOptions.border
|
||||||
|
?? (listOptions.highlight !== false && !layoutManager.tv)
|
||||||
|
},
|
||||||
|
{ 'itemAction listItem-button': clickEntireItem },
|
||||||
|
{ 'listItem-focusscale': layoutManager.tv },
|
||||||
|
{ 'listItem-largeImage': isLargeStyle },
|
||||||
|
{ 'listItem-withContentWrapper': enableContentWrapper }
|
||||||
|
);
|
||||||
|
|
||||||
|
const getListdWrapperProps = () => ({
|
||||||
|
className: listWrapperClass,
|
||||||
|
title: item.Name,
|
||||||
|
action,
|
||||||
|
dataAttributes
|
||||||
|
});
|
||||||
|
|
||||||
|
const getListContentProps = () => ({
|
||||||
|
item,
|
||||||
|
listOptions,
|
||||||
|
enableContentWrapper,
|
||||||
|
enableOverview,
|
||||||
|
enableSideMediaInfo,
|
||||||
|
clickEntireItem,
|
||||||
|
action,
|
||||||
|
isLargeStyle,
|
||||||
|
downloadWidth
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getListdWrapperProps,
|
||||||
|
getListContentProps
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useList;
|
167
src/components/listview/List/useListTextlines.tsx
Normal file
167
src/components/listview/List/useListTextlines.tsx
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||||
|
import React from 'react';
|
||||||
|
import itemHelper from '../../itemHelper';
|
||||||
|
import datetime from 'scripts/datetime';
|
||||||
|
import ListTextWrapper from './ListTextWrapper';
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { ListOptions } from 'types/listOptions';
|
||||||
|
|
||||||
|
function getParentTitle(
|
||||||
|
showParentTitle: boolean | undefined,
|
||||||
|
item: ItemDto,
|
||||||
|
parentTitleWithTitle: boolean | undefined,
|
||||||
|
displayName: string | null | undefined
|
||||||
|
) {
|
||||||
|
let parentTitle = null;
|
||||||
|
if (showParentTitle) {
|
||||||
|
if (item.Type === BaseItemKind.Episode) {
|
||||||
|
parentTitle = item.SeriesName;
|
||||||
|
} else if (item.IsSeries || (item.EpisodeTitle && item.Name)) {
|
||||||
|
parentTitle = item.Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showParentTitle && parentTitleWithTitle) {
|
||||||
|
if (displayName) {
|
||||||
|
parentTitle += ' - ';
|
||||||
|
}
|
||||||
|
parentTitle = (parentTitle ?? '') + displayName;
|
||||||
|
}
|
||||||
|
return parentTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNameOrIndexWithName(
|
||||||
|
item: ItemDto,
|
||||||
|
listOptions: ListOptions,
|
||||||
|
showIndexNumber: boolean | undefined
|
||||||
|
) {
|
||||||
|
let displayName = itemHelper.getDisplayName(item, {
|
||||||
|
includeParentInfo: listOptions.includeParentInfoInTitle
|
||||||
|
});
|
||||||
|
|
||||||
|
if (showIndexNumber && item.IndexNumber != null) {
|
||||||
|
displayName = `${item.IndexNumber}. ${displayName}`;
|
||||||
|
}
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseListTextlinesProps {
|
||||||
|
item: ItemDto;
|
||||||
|
listOptions?: ListOptions;
|
||||||
|
isLargeStyle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useListTextlines({ item = {}, listOptions = {}, isLargeStyle }: UseListTextlinesProps) {
|
||||||
|
const {
|
||||||
|
showProgramDateTime,
|
||||||
|
showProgramTime,
|
||||||
|
showChannel,
|
||||||
|
showParentTitle,
|
||||||
|
showIndexNumber,
|
||||||
|
parentTitleWithTitle,
|
||||||
|
artist
|
||||||
|
} = listOptions;
|
||||||
|
const textLines: string[] = [];
|
||||||
|
|
||||||
|
const addTextLine = (text: string | null) => {
|
||||||
|
if (text) {
|
||||||
|
textLines.push(text);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addProgramDateTime = () => {
|
||||||
|
if (showProgramDateTime) {
|
||||||
|
const programDateTime = datetime.toLocaleString(
|
||||||
|
datetime.parseISO8601Date(item.StartDate),
|
||||||
|
{
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
addTextLine(programDateTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addProgramTime = () => {
|
||||||
|
if (showProgramTime) {
|
||||||
|
const programTime = datetime.getDisplayTime(
|
||||||
|
datetime.parseISO8601Date(item.StartDate)
|
||||||
|
);
|
||||||
|
addTextLine(programTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addChannelName = () => {
|
||||||
|
if (showChannel && item.ChannelName) {
|
||||||
|
addTextLine(item.ChannelName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayName = getNameOrIndexWithName(item, listOptions, showIndexNumber);
|
||||||
|
|
||||||
|
const parentTitle = getParentTitle(showParentTitle, item, parentTitleWithTitle, displayName );
|
||||||
|
|
||||||
|
const addParentTitle = () => {
|
||||||
|
addTextLine(parentTitle ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDisplayName = () => {
|
||||||
|
if (displayName && !parentTitleWithTitle) {
|
||||||
|
addTextLine(displayName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addAlbumArtistOrArtists = () => {
|
||||||
|
if (item.IsFolder && artist !== false) {
|
||||||
|
if (item.AlbumArtist && item.Type === BaseItemKind.MusicAlbum) {
|
||||||
|
addTextLine(item.AlbumArtist);
|
||||||
|
}
|
||||||
|
} else if (artist) {
|
||||||
|
const artistItems = item.ArtistItems;
|
||||||
|
if (artistItems && item.Type !== BaseItemKind.MusicAlbum) {
|
||||||
|
const artists = artistItems.map((a) => a.Name).join(', ');
|
||||||
|
addTextLine(artists);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCurrentProgram = () => {
|
||||||
|
if (item.Type === BaseItemKind.TvChannel && item.CurrentProgram) {
|
||||||
|
const currentProgram = itemHelper.getDisplayName(
|
||||||
|
item.CurrentProgram
|
||||||
|
);
|
||||||
|
addTextLine(currentProgram);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addProgramDateTime();
|
||||||
|
addProgramTime();
|
||||||
|
addChannelName();
|
||||||
|
addParentTitle();
|
||||||
|
addDisplayName();
|
||||||
|
addAlbumArtistOrArtists();
|
||||||
|
addCurrentProgram();
|
||||||
|
|
||||||
|
const renderTextlines = (text: string, index: number) => {
|
||||||
|
return (
|
||||||
|
<ListTextWrapper
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
key={index}
|
||||||
|
index={index}
|
||||||
|
isLargeStyle={isLargeStyle}
|
||||||
|
>
|
||||||
|
<bdi>{text}</bdi>
|
||||||
|
</ListTextWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const listTextLines = textLines?.map((text, index) => renderTextlines(text, index));
|
||||||
|
|
||||||
|
return {
|
||||||
|
listTextLines
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useListTextlines;
|
|
@ -183,6 +183,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.listItemImage .cardImageIcon {
|
.listItemImage .cardImageIcon {
|
||||||
|
margin: auto;
|
||||||
font-size: 3em;
|
font-size: 3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
25
src/components/mediainfo/CaptionMediaInfo.tsx
Normal file
25
src/components/mediainfo/CaptionMediaInfo.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import ClosedCaptionIcon from '@mui/icons-material/ClosedCaption';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
|
||||||
|
interface CaptionMediaInfoProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CaptionMediaInfo: FC<CaptionMediaInfoProps> = ({ className }) => {
|
||||||
|
const cssClass = classNames(
|
||||||
|
'mediaInfoItem',
|
||||||
|
'mediaInfoText',
|
||||||
|
'closedCaptionMediaInfoText',
|
||||||
|
className
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={cssClass}>
|
||||||
|
<ClosedCaptionIcon fontSize={'small'} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CaptionMediaInfo;
|
25
src/components/mediainfo/CriticRatingMediaInfo.tsx
Normal file
25
src/components/mediainfo/CriticRatingMediaInfo.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
|
||||||
|
interface CriticRatingMediaInfoProps {
|
||||||
|
className?: string;
|
||||||
|
criticRating: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CriticRatingMediaInfo: FC<CriticRatingMediaInfoProps> = ({
|
||||||
|
className,
|
||||||
|
criticRating
|
||||||
|
}) => {
|
||||||
|
const cssClass = classNames(
|
||||||
|
'mediaInfoCriticRating',
|
||||||
|
'mediaInfoItem',
|
||||||
|
criticRating >= 60 ?
|
||||||
|
'mediaInfoCriticRatingFresh' :
|
||||||
|
'mediaInfoCriticRatingRotten',
|
||||||
|
className
|
||||||
|
);
|
||||||
|
return <Box className={cssClass}>{criticRating}</Box>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CriticRatingMediaInfo;
|
31
src/components/mediainfo/EndsAt.tsx
Normal file
31
src/components/mediainfo/EndsAt.tsx
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import datetime from 'scripts/datetime';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
|
interface EndsAtProps {
|
||||||
|
className?: string;
|
||||||
|
runTimeTicks: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const EndsAt: FC<EndsAtProps> = ({ runTimeTicks, className }) => {
|
||||||
|
const cssClass = classNames(
|
||||||
|
'mediaInfoItem',
|
||||||
|
'mediaInfoText',
|
||||||
|
'endsAt',
|
||||||
|
className
|
||||||
|
);
|
||||||
|
|
||||||
|
const endTime = new Date().getTime() + (runTimeTicks / 10000);
|
||||||
|
const endDate = new Date(endTime);
|
||||||
|
const displayTime = datetime.getDisplayTime(endDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={cssClass}>
|
||||||
|
{globalize.translate('EndsAtValue', displayTime)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EndsAt;
|
27
src/components/mediainfo/MediaInfoItem.tsx
Normal file
27
src/components/mediainfo/MediaInfoItem.tsx
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import type { MiscInfo } from 'types/mediaInfoItem';
|
||||||
|
|
||||||
|
interface MediaInfoItemProps {
|
||||||
|
className?: string;
|
||||||
|
miscInfo?: MiscInfo ;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const MediaInfoItem: FC<MediaInfoItemProps> = ({ className, miscInfo }) => {
|
||||||
|
const cssClass = classNames(
|
||||||
|
'mediaInfoItem',
|
||||||
|
'mediaInfoText',
|
||||||
|
className,
|
||||||
|
miscInfo?.cssClass
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={cssClass}>
|
||||||
|
{miscInfo?.text}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MediaInfoItem;
|
103
src/components/mediainfo/PrimaryMediaInfo.tsx
Normal file
103
src/components/mediainfo/PrimaryMediaInfo.tsx
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import usePrimaryMediaInfo from './usePrimaryMediaInfo';
|
||||||
|
|
||||||
|
import MediaInfoItem from './MediaInfoItem';
|
||||||
|
import StarIcons from './StarIcons';
|
||||||
|
import CaptionMediaInfo from './CaptionMediaInfo';
|
||||||
|
import CriticRatingMediaInfo from './CriticRatingMediaInfo';
|
||||||
|
import EndsAt from './EndsAt';
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { MiscInfo } from 'types/mediaInfoItem';
|
||||||
|
|
||||||
|
interface PrimaryMediaInfoProps {
|
||||||
|
className?: string;
|
||||||
|
item: ItemDto;
|
||||||
|
isYearEnabled?: boolean;
|
||||||
|
isContainerEnabled?: boolean;
|
||||||
|
isEpisodeTitleEnabled?: boolean;
|
||||||
|
isCriticRatingEnabled?: boolean;
|
||||||
|
isEndsAtEnabled?: boolean;
|
||||||
|
isOriginalAirDateEnabled?: boolean;
|
||||||
|
isRuntimeEnabled?: boolean;
|
||||||
|
isProgramIndicatorEnabled?: boolean;
|
||||||
|
isEpisodeTitleIndexNumberEnabled?: boolean;
|
||||||
|
isOfficialRatingEnabled?: boolean;
|
||||||
|
isStarRatingEnabled?: boolean;
|
||||||
|
isCaptionIndicatorEnabled?: boolean;
|
||||||
|
isMissingIndicatorEnabled?: boolean;
|
||||||
|
getMissingIndicator: () => React.JSX.Element | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const PrimaryMediaInfo: FC<PrimaryMediaInfoProps> = ({
|
||||||
|
className,
|
||||||
|
item,
|
||||||
|
isYearEnabled = false,
|
||||||
|
isContainerEnabled = false,
|
||||||
|
isEpisodeTitleEnabled = false,
|
||||||
|
isCriticRatingEnabled = false,
|
||||||
|
isEndsAtEnabled = false,
|
||||||
|
isOriginalAirDateEnabled = false,
|
||||||
|
isRuntimeEnabled = false,
|
||||||
|
isProgramIndicatorEnabled = false,
|
||||||
|
isEpisodeTitleIndexNumberEnabled = false,
|
||||||
|
isOfficialRatingEnabled = false,
|
||||||
|
isStarRatingEnabled = false,
|
||||||
|
isCaptionIndicatorEnabled = false,
|
||||||
|
isMissingIndicatorEnabled = false,
|
||||||
|
getMissingIndicator
|
||||||
|
}) => {
|
||||||
|
const miscInfo = usePrimaryMediaInfo({
|
||||||
|
item,
|
||||||
|
isYearEnabled,
|
||||||
|
isContainerEnabled,
|
||||||
|
isEpisodeTitleEnabled,
|
||||||
|
isOriginalAirDateEnabled,
|
||||||
|
isRuntimeEnabled,
|
||||||
|
isProgramIndicatorEnabled,
|
||||||
|
isEpisodeTitleIndexNumberEnabled,
|
||||||
|
isOfficialRatingEnabled
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
StartDate,
|
||||||
|
HasSubtitles,
|
||||||
|
MediaType,
|
||||||
|
RunTimeTicks,
|
||||||
|
CommunityRating,
|
||||||
|
CriticRating
|
||||||
|
} = item;
|
||||||
|
|
||||||
|
const cssClass = classNames(className);
|
||||||
|
|
||||||
|
const renderMediaInfo = (info: MiscInfo | undefined, index: number) => (
|
||||||
|
<MediaInfoItem key={index} miscInfo={info} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={cssClass}>
|
||||||
|
{miscInfo.map((info, index) => renderMediaInfo(info, index))}
|
||||||
|
|
||||||
|
{isStarRatingEnabled && CommunityRating && (
|
||||||
|
<StarIcons communityRating={CommunityRating} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{HasSubtitles && isCaptionIndicatorEnabled && <CaptionMediaInfo />}
|
||||||
|
|
||||||
|
{CriticRating && isCriticRatingEnabled && (
|
||||||
|
<CriticRatingMediaInfo criticRating={CriticRating} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEndsAtEnabled
|
||||||
|
&& MediaType === 'Video'
|
||||||
|
&& RunTimeTicks
|
||||||
|
&& !StartDate && <EndsAt runTimeTicks={RunTimeTicks} />}
|
||||||
|
|
||||||
|
{isMissingIndicatorEnabled && (
|
||||||
|
getMissingIndicator()
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrimaryMediaInfo;
|
31
src/components/mediainfo/StarIcons.tsx
Normal file
31
src/components/mediainfo/StarIcons.tsx
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import StarIcon from '@mui/icons-material/Star';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
interface StarIconsProps {
|
||||||
|
className?: string;
|
||||||
|
communityRating: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StarIcons: FC<StarIconsProps> = ({ className, communityRating }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const cssClass = classNames(
|
||||||
|
'mediaInfoItem',
|
||||||
|
'mediaInfoText',
|
||||||
|
'starRatingContainer',
|
||||||
|
className
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={cssClass}>
|
||||||
|
<StarIcon fontSize={'small'} sx={{
|
||||||
|
color: theme.palette.starIcon.main
|
||||||
|
}} />
|
||||||
|
{communityRating.toFixed(1)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StarIcons;
|
523
src/components/mediainfo/usePrimaryMediaInfo.tsx
Normal file
523
src/components/mediainfo/usePrimaryMediaInfo.tsx
Normal file
|
@ -0,0 +1,523 @@
|
||||||
|
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||||
|
import * as userSettings from 'scripts/settings/userSettings';
|
||||||
|
import datetime from 'scripts/datetime';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
import itemHelper from '../itemHelper';
|
||||||
|
import type { NullableNumber, NullableString } from 'types/base/common/shared/types';
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
import type { MiscInfo } from 'types/mediaInfoItem';
|
||||||
|
|
||||||
|
function shouldShowFolderRuntime(
|
||||||
|
itemType: NullableString,
|
||||||
|
itemMediaType: NullableString
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
itemType === BaseItemKind.MusicAlbum
|
||||||
|
|| itemMediaType === 'MusicArtist'
|
||||||
|
|| itemType === BaseItemKind.Playlist
|
||||||
|
|| itemMediaType === 'Playlist'
|
||||||
|
|| itemMediaType === 'MusicGenre'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTrackCountOrItemCount(
|
||||||
|
showFolderRuntime: boolean,
|
||||||
|
itemSongCount: NullableNumber,
|
||||||
|
itemChildCount: NullableNumber,
|
||||||
|
itemRunTimeTicks: NullableNumber,
|
||||||
|
itemType: NullableString,
|
||||||
|
addMiscInfo: (val: MiscInfo) => void
|
||||||
|
): void {
|
||||||
|
if (showFolderRuntime) {
|
||||||
|
const count = itemSongCount ?? itemChildCount;
|
||||||
|
if (count) {
|
||||||
|
addMiscInfo({ text: globalize.translate('TrackCount', count) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemRunTimeTicks) {
|
||||||
|
addMiscInfo({ text: datetime.getDisplayDuration(itemRunTimeTicks) });
|
||||||
|
}
|
||||||
|
} else if (itemType === BaseItemKind.PhotoAlbum || itemType === BaseItemKind.BoxSet) {
|
||||||
|
const count = itemChildCount;
|
||||||
|
if (count) {
|
||||||
|
addMiscInfo({ text: globalize.translate('ItemCount', count) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOriginalAirDateInfo(
|
||||||
|
itemType: NullableString,
|
||||||
|
itemMediaType: NullableString,
|
||||||
|
isOriginalAirDateEnabled: boolean,
|
||||||
|
itemPremiereDate: NullableString,
|
||||||
|
addMiscInfo: (val: MiscInfo) => void
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
itemPremiereDate
|
||||||
|
&& (itemType === BaseItemKind.Episode || itemMediaType === 'Photo')
|
||||||
|
&& isOriginalAirDateEnabled
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
//don't modify date to locale if episode. Only Dates (not times) are stored, or editable in the edit metadata dialog
|
||||||
|
const date = datetime.parseISO8601Date(
|
||||||
|
itemPremiereDate,
|
||||||
|
itemType !== BaseItemKind.Episode
|
||||||
|
);
|
||||||
|
addMiscInfo({ text: datetime.toLocaleDateString(date) });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('error parsing date:', itemPremiereDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSeriesTimerInfo(
|
||||||
|
itemType: NullableString,
|
||||||
|
itemRecordAnyTime: boolean | undefined,
|
||||||
|
itemStartDate: NullableString,
|
||||||
|
itemRecordAnyChannel: boolean | undefined,
|
||||||
|
itemChannelName: NullableString,
|
||||||
|
addMiscInfo: (val: MiscInfo) => void
|
||||||
|
): void {
|
||||||
|
if (itemType === 'SeriesTimer') {
|
||||||
|
if (itemRecordAnyTime) {
|
||||||
|
addMiscInfo({ text: globalize.translate('Anytime') });
|
||||||
|
} else {
|
||||||
|
addMiscInfo({ text: datetime.getDisplayTime(itemStartDate) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemRecordAnyChannel) {
|
||||||
|
addMiscInfo({ text: globalize.translate('AllChannels') });
|
||||||
|
} else {
|
||||||
|
addMiscInfo({
|
||||||
|
text: itemChannelName ?? globalize.translate('OneChannel')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProgramIndicatorInfo(
|
||||||
|
program: ItemDto | undefined,
|
||||||
|
addMiscInfo: (val: MiscInfo) => void
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
program?.IsLive
|
||||||
|
&& userSettings.get('guide-indicator-live', false) === 'true'
|
||||||
|
) {
|
||||||
|
addMiscInfo({
|
||||||
|
text: globalize.translate('Live'),
|
||||||
|
cssClass: 'mediaInfoProgramAttribute liveTvProgram'
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
program?.IsPremiere
|
||||||
|
&& userSettings.get('guide-indicator-premiere', false) === 'true'
|
||||||
|
) {
|
||||||
|
addMiscInfo({
|
||||||
|
text: globalize.translate('Premiere'),
|
||||||
|
cssClass: 'mediaInfoProgramAttribute premiereTvProgram'
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
program?.IsSeries
|
||||||
|
&& !program?.IsRepeat
|
||||||
|
&& userSettings.get('guide-indicator-new', false) === 'true'
|
||||||
|
) {
|
||||||
|
addMiscInfo({
|
||||||
|
text: globalize.translate('New'),
|
||||||
|
cssClass: 'mediaInfoProgramAttribute newTvProgram'
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
program?.IsSeries
|
||||||
|
&& program?.IsRepeat
|
||||||
|
&& userSettings.get('guide-indicator-repeat', false) === 'true'
|
||||||
|
) {
|
||||||
|
addMiscInfo({
|
||||||
|
text: globalize.translate('Repeat'),
|
||||||
|
cssClass: 'mediaInfoProgramAttribute repeatTvProgram'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProgramIndicators(
|
||||||
|
item: ItemDto,
|
||||||
|
isYearEnabled: boolean,
|
||||||
|
isEpisodeTitleEnabled: boolean,
|
||||||
|
isOriginalAirDateEnabled: boolean,
|
||||||
|
isProgramIndicatorEnabled: boolean,
|
||||||
|
isEpisodeTitleIndexNumberEnabled: boolean,
|
||||||
|
addMiscInfo: (val: MiscInfo) => void
|
||||||
|
): void {
|
||||||
|
if (item.Type === BaseItemKind.Program || item.Type === 'Timer') {
|
||||||
|
let program = item;
|
||||||
|
if (item.Type === 'Timer' && item.ProgramInfo) {
|
||||||
|
program = item.ProgramInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProgramIndicatorEnabled !== false) {
|
||||||
|
addProgramIndicatorInfo(program, addMiscInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
addProgramTextInfo(
|
||||||
|
program,
|
||||||
|
isEpisodeTitleEnabled,
|
||||||
|
isEpisodeTitleIndexNumberEnabled,
|
||||||
|
isOriginalAirDateEnabled,
|
||||||
|
isYearEnabled,
|
||||||
|
addMiscInfo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProgramTextInfo(
|
||||||
|
program: ItemDto,
|
||||||
|
isEpisodeTitleEnabled: boolean,
|
||||||
|
isEpisodeTitleIndexNumberEnabled: boolean,
|
||||||
|
isOriginalAirDateEnabled: boolean,
|
||||||
|
isYearEnabled: boolean,
|
||||||
|
addMiscInfo: (val: MiscInfo) => void
|
||||||
|
): void {
|
||||||
|
if ((program?.IsSeries || program?.EpisodeTitle)
|
||||||
|
&& isEpisodeTitleEnabled !== false) {
|
||||||
|
const text = itemHelper.getDisplayName(program, {
|
||||||
|
includeIndexNumber: isEpisodeTitleIndexNumberEnabled
|
||||||
|
});
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
addMiscInfo({ text: text });
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
program?.ProductionYear
|
||||||
|
&& ((program?.IsMovie && isOriginalAirDateEnabled !== false)
|
||||||
|
|| isYearEnabled !== false)
|
||||||
|
) {
|
||||||
|
addMiscInfo({ text: program.ProductionYear });
|
||||||
|
} else if (program?.PremiereDate && isOriginalAirDateEnabled !== false) {
|
||||||
|
try {
|
||||||
|
const date = datetime.parseISO8601Date(program.PremiereDate);
|
||||||
|
const text = globalize.translate(
|
||||||
|
'OriginalAirDateValue',
|
||||||
|
datetime.toLocaleDateString(date)
|
||||||
|
);
|
||||||
|
addMiscInfo({ text: text });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('error parsing date:', program.PremiereDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStartDateInfo(
|
||||||
|
itemStartDate: NullableString,
|
||||||
|
itemType: NullableString,
|
||||||
|
addMiscInfo: (val: MiscInfo) => void
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
itemStartDate
|
||||||
|
&& itemType !== BaseItemKind.Program
|
||||||
|
&& itemType !== 'SeriesTimer'
|
||||||
|
&& itemType !== 'Timer'
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const date = datetime.parseISO8601Date(itemStartDate);
|
||||||
|
addMiscInfo({ text: datetime.toLocaleDateString(date) });
|
||||||
|
|
||||||
|
if (itemType !== BaseItemKind.Recording) {
|
||||||
|
addMiscInfo({ text: datetime.getDisplayTime(date) });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('error parsing date:', itemStartDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSeriesProductionYearInfo(
|
||||||
|
itemProductionYear: NullableNumber,
|
||||||
|
itemType: NullableString,
|
||||||
|
isYearEnabled: boolean,
|
||||||
|
itemStatus: NullableString,
|
||||||
|
itemEndDate: NullableString,
|
||||||
|
addMiscInfo: (val: MiscInfo) => void
|
||||||
|
): void {
|
||||||
|
if (itemProductionYear && isYearEnabled && itemType === BaseItemKind.Series) {
|
||||||
|
if (itemStatus === 'Continuing') {
|
||||||
|
addMiscInfo({
|
||||||
|
text: globalize.translate(
|
||||||
|
'SeriesYearToPresent',
|
||||||
|
datetime.toLocaleString(itemProductionYear, {
|
||||||
|
useGrouping: false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addproductionYearWithEndDate(itemProductionYear, itemEndDate, addMiscInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addproductionYearWithEndDate(
|
||||||
|
itemProductionYear: number,
|
||||||
|
itemEndDate: NullableString,
|
||||||
|
addMiscInfo: (val: MiscInfo) => void
|
||||||
|
): void {
|
||||||
|
let productionYear = datetime.toLocaleString(itemProductionYear, {
|
||||||
|
useGrouping: false
|
||||||
|
});
|
||||||
|
|
||||||
|
if (itemEndDate) {
|
||||||
|
try {
|
||||||
|
const endYear = datetime.toLocaleString(
|
||||||
|
datetime.parseISO8601Date(itemEndDate).getFullYear(),
|
||||||
|
{ useGrouping: false }
|
||||||
|
);
|
||||||
|
/* At this point, text will contain only the start year */
|
||||||
|
if (endYear !== itemProductionYear) {
|
||||||
|
productionYear += `-${endYear}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('error parsing date:', itemEndDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addMiscInfo({ text: productionYear });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addYearInfo(
|
||||||
|
isYearEnabled: boolean,
|
||||||
|
itemType: NullableString,
|
||||||
|
itemMediaType: NullableString,
|
||||||
|
itemProductionYear: NullableNumber,
|
||||||
|
itemPremiereDate: NullableString,
|
||||||
|
addMiscInfo: (val: MiscInfo) => void
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
isYearEnabled
|
||||||
|
&& itemType !== BaseItemKind.Series
|
||||||
|
&& itemType !== BaseItemKind.Episode
|
||||||
|
&& itemType !== BaseItemKind.Person
|
||||||
|
&& itemMediaType !== 'Photo'
|
||||||
|
&& itemType !== BaseItemKind.Program
|
||||||
|
&& itemType !== BaseItemKind.Season
|
||||||
|
) {
|
||||||
|
if (itemProductionYear) {
|
||||||
|
addMiscInfo({ text: itemProductionYear });
|
||||||
|
} else if (itemPremiereDate) {
|
||||||
|
try {
|
||||||
|
const text = datetime.toLocaleString(
|
||||||
|
datetime.parseISO8601Date(itemPremiereDate).getFullYear(),
|
||||||
|
{ useGrouping: false }
|
||||||
|
);
|
||||||
|
addMiscInfo({ text: text });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('error parsing date:', itemPremiereDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addVideo3DFormat(
|
||||||
|
itemVideo3DFormat: NullableString,
|
||||||
|
addMiscInfo: (val: MiscInfo) => void
|
||||||
|
): void {
|
||||||
|
if (itemVideo3DFormat) {
|
||||||
|
addMiscInfo({ text: '3D' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRunTimeInfo(
|
||||||
|
itemRunTimeTicks: NullableNumber,
|
||||||
|
itemType: NullableString,
|
||||||
|
showFolderRuntime: boolean,
|
||||||
|
isRuntimeEnabled: boolean,
|
||||||
|
addMiscInfo: (val: MiscInfo) => void
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
itemRunTimeTicks
|
||||||
|
&& itemType !== BaseItemKind.Series
|
||||||
|
&& itemType !== BaseItemKind.Program
|
||||||
|
&& itemType !== 'Timer'
|
||||||
|
&& itemType !== BaseItemKind.Book
|
||||||
|
&& !showFolderRuntime
|
||||||
|
&& isRuntimeEnabled
|
||||||
|
) {
|
||||||
|
if (itemType === BaseItemKind.Audio) {
|
||||||
|
addMiscInfo({ text: datetime.getDisplayRunningTime(itemRunTimeTicks) });
|
||||||
|
} else {
|
||||||
|
addMiscInfo({ text: datetime.getDisplayDuration(itemRunTimeTicks) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOfficialRatingInfo(
|
||||||
|
itemOfficialRating: NullableString,
|
||||||
|
itemType: NullableString,
|
||||||
|
isOfficialRatingEnabled: boolean,
|
||||||
|
addMiscInfo: (val: MiscInfo) => void
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
itemOfficialRating
|
||||||
|
&& isOfficialRatingEnabled
|
||||||
|
&& itemType !== BaseItemKind.Season
|
||||||
|
&& itemType !== BaseItemKind.Episode
|
||||||
|
) {
|
||||||
|
addMiscInfo({
|
||||||
|
text: itemOfficialRating,
|
||||||
|
cssClass: 'mediaInfoOfficialRating'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAudioContainer(
|
||||||
|
itemContainer: NullableString,
|
||||||
|
isContainerEnabled: boolean,
|
||||||
|
itemType: NullableString,
|
||||||
|
addMiscInfo: (val: MiscInfo) => void
|
||||||
|
): void {
|
||||||
|
if (itemContainer && isContainerEnabled && itemType === BaseItemKind.Audio) {
|
||||||
|
addMiscInfo({ text: itemContainer });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPhotoSize(
|
||||||
|
itemMediaType: NullableString,
|
||||||
|
itemWidth: NullableNumber,
|
||||||
|
itemHeight: NullableNumber,
|
||||||
|
addMiscInfo: (val: MiscInfo) => void
|
||||||
|
): void {
|
||||||
|
if (itemMediaType === 'Photo' && itemWidth && itemHeight) {
|
||||||
|
const size = `${itemWidth}x${itemHeight}`;
|
||||||
|
|
||||||
|
addMiscInfo({ text: size });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsePrimaryMediaInfoProps {
|
||||||
|
item: ItemDto;
|
||||||
|
isYearEnabled: boolean;
|
||||||
|
isContainerEnabled: boolean;
|
||||||
|
isEpisodeTitleEnabled: boolean;
|
||||||
|
isOriginalAirDateEnabled: boolean;
|
||||||
|
isRuntimeEnabled: boolean;
|
||||||
|
isProgramIndicatorEnabled: boolean;
|
||||||
|
isEpisodeTitleIndexNumberEnabled: boolean;
|
||||||
|
isOfficialRatingEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePrimaryMediaInfo({
|
||||||
|
item,
|
||||||
|
isYearEnabled = false,
|
||||||
|
isContainerEnabled = false,
|
||||||
|
isEpisodeTitleEnabled = false,
|
||||||
|
isOriginalAirDateEnabled = false,
|
||||||
|
isRuntimeEnabled = false,
|
||||||
|
isProgramIndicatorEnabled = false,
|
||||||
|
isEpisodeTitleIndexNumberEnabled = false,
|
||||||
|
isOfficialRatingEnabled = false
|
||||||
|
}: UsePrimaryMediaInfoProps) {
|
||||||
|
const {
|
||||||
|
EndDate,
|
||||||
|
Status,
|
||||||
|
StartDate,
|
||||||
|
ProductionYear,
|
||||||
|
Video3DFormat,
|
||||||
|
Type,
|
||||||
|
Width,
|
||||||
|
Height,
|
||||||
|
MediaType,
|
||||||
|
SongCount,
|
||||||
|
RecordAnyTime,
|
||||||
|
RecordAnyChannel,
|
||||||
|
ChannelName,
|
||||||
|
ChildCount,
|
||||||
|
RunTimeTicks,
|
||||||
|
PremiereDate,
|
||||||
|
OfficialRating,
|
||||||
|
Container
|
||||||
|
} = item;
|
||||||
|
|
||||||
|
const miscInfo: MiscInfo[] = [];
|
||||||
|
|
||||||
|
const addMiscInfo = (val: MiscInfo) => {
|
||||||
|
if (val) {
|
||||||
|
miscInfo.push(val);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showFolderRuntime = shouldShowFolderRuntime(Type, MediaType);
|
||||||
|
|
||||||
|
addTrackCountOrItemCount(
|
||||||
|
showFolderRuntime,
|
||||||
|
SongCount,
|
||||||
|
ChildCount,
|
||||||
|
RunTimeTicks,
|
||||||
|
Type,
|
||||||
|
addMiscInfo
|
||||||
|
);
|
||||||
|
|
||||||
|
addOriginalAirDateInfo(
|
||||||
|
Type,
|
||||||
|
MediaType,
|
||||||
|
isOriginalAirDateEnabled,
|
||||||
|
PremiereDate,
|
||||||
|
addMiscInfo
|
||||||
|
);
|
||||||
|
|
||||||
|
addSeriesTimerInfo(
|
||||||
|
Type,
|
||||||
|
RecordAnyTime,
|
||||||
|
StartDate,
|
||||||
|
RecordAnyChannel,
|
||||||
|
ChannelName,
|
||||||
|
addMiscInfo
|
||||||
|
);
|
||||||
|
|
||||||
|
addStartDateInfo(StartDate, Type, addMiscInfo);
|
||||||
|
|
||||||
|
addSeriesProductionYearInfo(
|
||||||
|
ProductionYear,
|
||||||
|
Type,
|
||||||
|
isYearEnabled,
|
||||||
|
Status,
|
||||||
|
EndDate,
|
||||||
|
addMiscInfo
|
||||||
|
);
|
||||||
|
|
||||||
|
addProgramIndicators(
|
||||||
|
item,
|
||||||
|
isProgramIndicatorEnabled,
|
||||||
|
isEpisodeTitleEnabled,
|
||||||
|
isEpisodeTitleIndexNumberEnabled,
|
||||||
|
isOriginalAirDateEnabled,
|
||||||
|
isYearEnabled,
|
||||||
|
addMiscInfo
|
||||||
|
);
|
||||||
|
|
||||||
|
addYearInfo(
|
||||||
|
isYearEnabled,
|
||||||
|
Type,
|
||||||
|
MediaType,
|
||||||
|
ProductionYear,
|
||||||
|
PremiereDate,
|
||||||
|
addMiscInfo
|
||||||
|
);
|
||||||
|
|
||||||
|
addRunTimeInfo(
|
||||||
|
RunTimeTicks,
|
||||||
|
Type,
|
||||||
|
showFolderRuntime,
|
||||||
|
isRuntimeEnabled,
|
||||||
|
addMiscInfo
|
||||||
|
);
|
||||||
|
|
||||||
|
addOfficialRatingInfo(
|
||||||
|
OfficialRating,
|
||||||
|
Type,
|
||||||
|
isOfficialRatingEnabled,
|
||||||
|
addMiscInfo
|
||||||
|
);
|
||||||
|
|
||||||
|
addVideo3DFormat(Video3DFormat, addMiscInfo);
|
||||||
|
|
||||||
|
addPhotoSize(MediaType, Width, Height, addMiscInfo);
|
||||||
|
|
||||||
|
addAudioContainer(Container, isContainerEnabled, Type, addMiscInfo);
|
||||||
|
|
||||||
|
return miscInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default usePrimaryMediaInfo;
|
|
@ -153,6 +153,7 @@ function onSubmit(e) {
|
||||||
DateCreated: getDateValue(form, '#txtDateAdded', 'DateCreated'),
|
DateCreated: getDateValue(form, '#txtDateAdded', 'DateCreated'),
|
||||||
EndDate: getDateValue(form, '#txtEndDate', 'EndDate'),
|
EndDate: getDateValue(form, '#txtEndDate', 'EndDate'),
|
||||||
ProductionYear: form.querySelector('#txtProductionYear').value,
|
ProductionYear: form.querySelector('#txtProductionYear').value,
|
||||||
|
Height: form.querySelector('#selectHeight').value,
|
||||||
AspectRatio: form.querySelector('#txtOriginalAspectRatio').value,
|
AspectRatio: form.querySelector('#txtOriginalAspectRatio').value,
|
||||||
Video3DFormat: form.querySelector('#select3dFormat').value,
|
Video3DFormat: form.querySelector('#select3dFormat').value,
|
||||||
|
|
||||||
|
@ -650,6 +651,12 @@ function setFieldVisibilities(context, item) {
|
||||||
hideElement('#fldPlaceOfBirth');
|
hideElement('#fldPlaceOfBirth');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.MediaType === 'Video' && item.Type === 'TvChannel') {
|
||||||
|
showElement('#fldHeight');
|
||||||
|
} else {
|
||||||
|
hideElement('#fldHeight');
|
||||||
|
}
|
||||||
|
|
||||||
if (item.MediaType === 'Video' && item.Type !== 'TvChannel') {
|
if (item.MediaType === 'Video' && item.Type !== 'TvChannel') {
|
||||||
showElement('#fldOriginalAspectRatio');
|
showElement('#fldOriginalAspectRatio');
|
||||||
} else {
|
} else {
|
||||||
|
@ -828,6 +835,8 @@ function fillItemInfo(context, item, parentalRatingOptions) {
|
||||||
const placeofBirth = item.ProductionLocations?.length ? item.ProductionLocations[0] : '';
|
const placeofBirth = item.ProductionLocations?.length ? item.ProductionLocations[0] : '';
|
||||||
context.querySelector('#txtPlaceOfBirth').value = placeofBirth;
|
context.querySelector('#txtPlaceOfBirth').value = placeofBirth;
|
||||||
|
|
||||||
|
context.querySelector('#selectHeight').value = item.Height || '';
|
||||||
|
|
||||||
context.querySelector('#txtOriginalAspectRatio').value = item.AspectRatio || '';
|
context.querySelector('#txtOriginalAspectRatio').value = item.AspectRatio || '';
|
||||||
|
|
||||||
context.querySelector('#selectLanguage').value = item.PreferredMetadataLanguage || '';
|
context.querySelector('#selectLanguage').value = item.PreferredMetadataLanguage || '';
|
||||||
|
|
|
@ -142,6 +142,16 @@
|
||||||
<select is="emby-select" id="selectCustomRating" label="${LabelCustomRating}"></select>
|
<select is="emby-select" id="selectCustomRating" label="${LabelCustomRating}"></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="fldHeight" class="selectContainer hide">
|
||||||
|
<select is="emby-select" id="selectHeight" label="${MediaInfoResolution}">
|
||||||
|
<option value="0"></option>
|
||||||
|
<option value="480">${ChannelResolutionSD}</option>
|
||||||
|
<option value="576">${ChannelResolutionSDPAL}</option>
|
||||||
|
<option value="720">${ChannelResolutionHD}</option>
|
||||||
|
<option value="1080">${ChannelResolutionFullHD}</option>
|
||||||
|
<option value="2160">${ChannelResolutionUHD4K}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="inlineForm">
|
<div class="inlineForm">
|
||||||
<div id="fldOriginalAspectRatio" class="inputContainer hide">
|
<div id="fldOriginalAspectRatio" class="inputContainer hide">
|
||||||
<input is="emby-input" id="txtOriginalAspectRatio" type="text" label="${LabelOriginalAspectRatio}" />
|
<input is="emby-input" id="txtOriginalAspectRatio" type="text" label="${LabelOriginalAspectRatio}" />
|
||||||
|
|
|
@ -1,584 +0,0 @@
|
||||||
<div id="dlnaProfilePage" data-role="page" class="page type-interior dlnaPage withTabs">
|
|
||||||
<div data-role="content">
|
|
||||||
<div class="content-primary">
|
|
||||||
<form class="dlnaProfileForm" style="max-width: 650px;">
|
|
||||||
<div class="verticalSection">
|
|
||||||
<div class="sectionTitleContainer flex align-items-center">
|
|
||||||
<h2 class="sectionTitle">${HeaderProfileInformation}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div data-role="controlgroup" data-type="horizontal" data-mini="true">
|
|
||||||
<a href="#" is="emby-linkbutton" data-role="button" class="radioTabButton" id="radioInfo" data-value="tabInfo">${ButtonInfo}</a>
|
|
||||||
<a href="#" is="emby-linkbutton" data-role="button" class="radioTabButton" id="radioDirectPlay" data-value="tabDirectPlayProfiles">${TabDirectPlay}</a>
|
|
||||||
<a href="#" is="emby-linkbutton" data-role="button" class="radioTabButton" id="radioTranscoding" data-value="tabTranscodingProfiles">${Transcoding}</a>
|
|
||||||
<a href="#" is="emby-linkbutton" data-role="button" class="radioTabButton" id="radioContainers" data-value="tabContainerProfiles">${TabContainers}</a>
|
|
||||||
<a href="#" is="emby-linkbutton" data-role="button" class="radioTabButton" id="radioCodecs" data-value="tabCodecProfiles">${TabCodecs}</a>
|
|
||||||
<a href="#" is="emby-linkbutton" data-role="button" class="radioTabButton" id="radioMediaProfiles" data-value="tabMediaProfiles">${TabResponses}</a>
|
|
||||||
</div>
|
|
||||||
<br />
|
|
||||||
<div class="tabContent tabInfo">
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtName" required="required" label="${LabelName}" />
|
|
||||||
</div>
|
|
||||||
<div class="selectContainer">
|
|
||||||
<select is="emby-select" id="selectUser" label="${LabelUserLibrary}"></select>
|
|
||||||
<div class="fieldDescription">${LabelUserLibraryHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="checkboxListLabel">${LabelSupportedMediaTypes}</h3>
|
|
||||||
<div class="checkboxList paperList checkboxList-paperList">
|
|
||||||
<label>
|
|
||||||
<input is="emby-checkbox" type="checkbox" id="chkAudio" data-value="Audio" class="chkMediaType" />
|
|
||||||
<span>${Audio}</span>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input is="emby-checkbox" type="checkbox" id="chkPhoto" data-value="Photo" class="chkMediaType" />
|
|
||||||
<span>${Photo}</span>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input is="emby-checkbox" type="checkbox" id="chkVideo" data-value="Video" class="chkMediaType" />
|
|
||||||
<span>${Video}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<br />
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="number" id="txtMaxAllowedBitrate" pattern="[0-9]*" min="1" label="${LabelMaxStreamingBitrate}" />
|
|
||||||
<div class="fieldDescription">${LabelMaxStreamingBitrateHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="number" id="txtMusicStreamingTranscodingBitrate" pattern="[0-9]*" min="1" label="${LabelMusicStreamingTranscodingBitrate}" />
|
|
||||||
<div class="fieldDescription">${LabelMusicStreamingTranscodingBitrateHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:none;">
|
|
||||||
<label for="chkIgnoreTranscodeByteRangeRequests">${OptionIgnoreTranscodeByteRangeRequests}</label>
|
|
||||||
<input type="checkbox" id="chkIgnoreTranscodeByteRangeRequests" data-mini="true" />
|
|
||||||
<div class="fieldDescription">${OptionIgnoreTranscodeByteRangeRequestsHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div is="emby-collapse" title="${HeaderIdentification}">
|
|
||||||
<div class="collapseContent">
|
|
||||||
<h3>${HeaderIdentificationCriteriaHelp}</h3>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtIdFriendlyName" label="${LabelFriendlyName}" />
|
|
||||||
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtIdManufacturer" label="${LabelManufacturer}" />
|
|
||||||
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtIdManufacturerUrl" label="${LabelManufacturerUrl}" />
|
|
||||||
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtIdModelName" label="${LabelModelName}" />
|
|
||||||
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtIdModelNumber" label="${LabelModelNumber}" />
|
|
||||||
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtIdModelDesription" label="${LabelModelDescription}" />
|
|
||||||
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtIdModelUrl" label="${LabelModelUrl}" />
|
|
||||||
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtIdSerialNumber" label="${LabelSerialNumber}" />
|
|
||||||
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtIdDeviceDescription" label="${LabelDeviceDescription}" />
|
|
||||||
<div class="fieldDescription">${LabelIdentificationFieldHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 style="vertical-align:middle;display:inline-block;">${HeaderHttpHeaders}</h2>
|
|
||||||
<button is="emby-button" type="button" class="fab btnAddIdentificationHttpHeader submit sectionTitleButton" style="margin-left:1em;" title="${Add}">
|
|
||||||
<span class="material-icons add" aria-hidden="true"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="httpHeaderIdentificationList"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div is="emby-collapse" title="${Display}">
|
|
||||||
<div class="collapseContent">
|
|
||||||
<br />
|
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
||||||
<label>
|
|
||||||
<input is="emby-checkbox" type="checkbox" id="chkRequiresPlainFolders" />
|
|
||||||
<span>${OptionPlainStorageFolders}</span>
|
|
||||||
</label>
|
|
||||||
<div class="fieldDescription checkboxFieldDescription">${OptionPlainStorageFoldersHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
||||||
<label>
|
|
||||||
<input is="emby-checkbox" type="checkbox" id="chkRequiresPlainVideoItems" />
|
|
||||||
<span>${OptionPlainVideoItems}</span>
|
|
||||||
</label>
|
|
||||||
<div class="fieldDescription checkboxFieldDescription">${OptionPlainVideoItemsHelp}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div is="emby-collapse" title="${HeaderImageSettings}">
|
|
||||||
<div class="collapseContent">
|
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
||||||
<label>
|
|
||||||
<input is="emby-checkbox" type="checkbox" id="chkEnableAlbumArtInDidl" data-mini="true" />
|
|
||||||
<span>${LabelEmbedAlbumArtDidl}</span>
|
|
||||||
</label>
|
|
||||||
<div class="fieldDescription checkboxFieldDescription">${LabelEmbedAlbumArtDidlHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
||||||
<label>
|
|
||||||
<input is="emby-checkbox" type="checkbox" id="chkEnableSingleImageLimit" data-mini="true" />
|
|
||||||
<span>${LabelEnableSingleImageInDidlLimit}</span>
|
|
||||||
</label>
|
|
||||||
<div class="fieldDescription checkboxFieldDescription">${LabelEnableSingleImageInDidlLimitHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input type="text" is="emby-input" id="txtAlbumArtPn" label="${LabelAlbumArtPN}" />
|
|
||||||
<div class="fieldDescription">${LabelAlbumArtHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input type="number" is="emby-input" id="txtAlbumArtMaxWidth" pattern="[0-9]*" min="1" label="${LabelAlbumArtMaxWidth}" />
|
|
||||||
<div class="fieldDescription">${LabelAlbumArtMaxResHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input type="number" is="emby-input" id="txtAlbumArtMaxHeight" pattern="[0-9]*" min="1" label="${LabelAlbumArtMaxHeight}" />
|
|
||||||
<div class="fieldDescription">${LabelAlbumArtMaxResHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input type="number" is="emby-input" id="txtIconMaxWidth" pattern="[0-9]*" min="1" label="${LabelIconMaxWidth}" />
|
|
||||||
<div class="fieldDescription">${LabelIconMaxResHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input type="number" is="emby-input" id="txtIconMaxHeight" pattern="[0-9]*" min="1" label="${LabelIconMaxHeight}" />
|
|
||||||
<div class="fieldDescription">${LabelIconMaxResHelp}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div is="emby-collapse" title="${HeaderServerSettings}">
|
|
||||||
<div class="collapseContent">
|
|
||||||
<p>${HeaderProfileServerSettingsHelp}</p>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtInfoFriendlyName" label="${LabelFriendlyName}" />
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtInfoManufacturer" label="${LabelManufacturer}" />
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtInfoManufacturerUrl" label="${LabelManufacturerUrl}" />
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtInfoModelName" label="${LabelModelName}" />
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtInfoModelNumber" label="${LabelModelNumber}" />
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtInfoModelDesription" label="${LabelModelDescription}" />
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtInfoModelUrl" label="${LabelModelUrl}" />
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtInfoSerialNumber" label="${LabelSerialNumber}" />
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtProtocolInfo" label="${LabelProtocolInfo}" />
|
|
||||||
<div class="fieldDescription">${LabelProtocolInfoHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtXDlnaCap" label="${LabelXDlnaCap}" />
|
|
||||||
<div class="fieldDescription">${LabelXDlnaCapHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtXDlnaDoc" label="${LabelXDlnaDoc}" />
|
|
||||||
<div class="fieldDescription">${LabelXDlnaDocHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtSonyAggregationFlags" label="${LabelSonyAggregationFlags}" />
|
|
||||||
<div class="fieldDescription">${LabelSonyAggregationFlagsHelp}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div is="emby-collapse" title="${HeaderSubtitleProfiles}">
|
|
||||||
<div class="collapseContent">
|
|
||||||
<p>${HeaderSubtitleProfilesHelp}</p>
|
|
||||||
<button is="emby-button" type="button" class="raised submit block btnAddSubtitleProfile">
|
|
||||||
<span>${Add}</span>
|
|
||||||
</button>
|
|
||||||
<div class="subtitleProfileList"></div>
|
|
||||||
<br />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div is="emby-collapse" title="${HeaderXmlSettings}">
|
|
||||||
<div class="collapseContent">
|
|
||||||
<div>
|
|
||||||
<h2 style="vertical-align:middle;display:inline-block;">${HeaderXmlDocumentAttributes}</h2>
|
|
||||||
<button is="emby-button" type="button" class="fab btnAddXmlDocumentAttribute submit sectionTitleButton" style="margin-left:1em;" title="${Add}">
|
|
||||||
<span class="material-icons add" aria-hidden="true"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="xmlDocumentAttributeList"></div>
|
|
||||||
<div class="fieldDescription">${XmlDocumentAttributeListHelp}</div>
|
|
||||||
<br />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="tabContent tabDirectPlayProfiles">
|
|
||||||
<p>${HeaderDirectPlayProfileHelp}</p>
|
|
||||||
<button is="emby-button" class="raised submit block btnAddDirectPlayProfile" type="button" data-mini="true" data-icon="plus">${New}</button>
|
|
||||||
<br />
|
|
||||||
<div class="directPlayProfiles"></div>
|
|
||||||
</div>
|
|
||||||
<div class="tabContent tabTranscodingProfiles">
|
|
||||||
<p>${HeaderTranscodingProfileHelp}</p>
|
|
||||||
<button is="emby-button" class="raised submit block btnAddTranscodingProfile" type="button" data-mini="true" data-icon="plus">${New}</button>
|
|
||||||
<br />
|
|
||||||
<div class="transcodingProfiles"></div>
|
|
||||||
</div>
|
|
||||||
<div class="tabContent tabContainerProfiles">
|
|
||||||
<p>${HeaderContainerProfileHelp}</p>
|
|
||||||
<button is="emby-button" class="raised submit block btnAddContainerProfile" type="button" data-mini="true" data-icon="plus">${New}</button>
|
|
||||||
<br />
|
|
||||||
<div class="containerProfiles"></div>
|
|
||||||
</div>
|
|
||||||
<div class="tabContent tabCodecProfiles">
|
|
||||||
<p>${HeaderCodecProfileHelp}</p>
|
|
||||||
<button is="emby-button" class="raised submit block btnAddCodecProfile" type="button" data-icon="plus">${New}</button>
|
|
||||||
<br />
|
|
||||||
<div class="codecProfiles"></div>
|
|
||||||
</div>
|
|
||||||
<div class="tabContent tabMediaProfiles">
|
|
||||||
<p>${HeaderResponseProfileHelp}</p>
|
|
||||||
<button is="emby-button" class="raised submit block btnAddResponseProfile" type="button" data-mini="true" data-icon="plus">${New}</button>
|
|
||||||
<br />
|
|
||||||
<div class="mediaProfiles"></div>
|
|
||||||
</div>
|
|
||||||
<br />
|
|
||||||
<div>
|
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block">
|
|
||||||
<span>${Save}</span>
|
|
||||||
</button>
|
|
||||||
<button is="emby-button" type="button" class="button-cancel raised block" onclick="Dashboard.navigate('dashboard/dlna/profiles');">
|
|
||||||
<span>${ButtonCancel}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div data-role="popup" id="popupEditDirectPlayProfile" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%;">
|
|
||||||
<form class="editDirectPlayProfileForm" style="padding:1em;">
|
|
||||||
<div class="ui-bar-a">
|
|
||||||
<h3 class="sectionTitle">${HeaderDirectPlayProfile}</h3>
|
|
||||||
</div>
|
|
||||||
<div data-role="content">
|
|
||||||
<div class="selectContainer">
|
|
||||||
<select id="selectDirectPlayProfileType" name="selectDirectPlayProfileType" is="emby-select" label="${LabelType}">
|
|
||||||
<option value="Audio">${Audio}</option>
|
|
||||||
<option value="Photo">${Photo}</option>
|
|
||||||
<option value="Video">${Video}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtDirectPlayContainer" label="${LabelProfileContainer}" />
|
|
||||||
<div class="fieldDescription">${LabelProfileContainersHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div id="fldDirectPlayVideoCodec" style="margin: 1em 0;">
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtDirectPlayVideoCodec" label="${LabelProfileVideoCodecs}" />
|
|
||||||
<div class="fieldDescription">${LabelProfileCodecsHelp}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="fldDirectPlayAudioCodec" style="margin: 1em 0 2em;">
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtDirectPlayAudioCodec" label="${LabelProfileAudioCodecs}" />
|
|
||||||
<div class="fieldDescription">${LabelProfileCodecsHelp}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check" data-mini="true">
|
|
||||||
<span>${ButtonOk}</span>
|
|
||||||
</button>
|
|
||||||
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');" data-mini="true">
|
|
||||||
<span>${ButtonCancel}</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div data-role="popup" id="transcodingProfilePopup" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%;">
|
|
||||||
<form class="transcodingProfileForm" style="padding:1em;">
|
|
||||||
<div class="ui-bar-a">
|
|
||||||
<h3 class="sectionTitle">${HeaderTranscodingProfile}</h3>
|
|
||||||
</div>
|
|
||||||
<div data-role="content">
|
|
||||||
<div data-role="controlgroup" data-type="horizontal" data-mini="true">
|
|
||||||
<input type="radio" name="radioTranscodingTab" class="radioTabButton" id="radioTranscodingBasics" value="tabTranscodingBasics">
|
|
||||||
<label for="radioTranscodingBasics">${ButtonInfo}</label>
|
|
||||||
<input type="radio" name="radioTranscodingTab" class="radioTabButton" id="radioTranscodingAdvanced" value="tabTranscodingAdvanced">
|
|
||||||
<label for="radioTranscodingAdvanced">${TabAdvanced}</label>
|
|
||||||
</div>
|
|
||||||
<br />
|
|
||||||
<div class="tabContent tabTranscodingBasics" style="display: none;">
|
|
||||||
<div class="selectContainer">
|
|
||||||
<select id="selectTranscodingProfileType" name="selectTranscodingProfileType" is="emby-select" label="${LabelType}">
|
|
||||||
<option value="Audio">${Audio}</option>
|
|
||||||
<option value="Photo">${Photo}</option>
|
|
||||||
<option value="Video">${Video}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="fldTranscodingProtocol" style="margin: 1em 0;">
|
|
||||||
<div class="selectContainer">
|
|
||||||
<select id="selectTranscodingProtocol" name="selectTranscodingProtocol" is="emby-select" label="${LabelProtocol}">
|
|
||||||
<option value="Http">${OptionProtocolHttp}</option>
|
|
||||||
<option value="Hls">${OptionProtocolHls}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtTranscodingContainer" label="${LabelProfileContainer}"; required="required" />
|
|
||||||
</div>
|
|
||||||
<div id="fldTranscodingVideoCodec" style="margin: 1em 0;">
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtTranscodingVideoCodec" label="${LabelVideoCodec}" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="fldTranscodingAudioCodec" style="margin: 1em 0;">
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtTranscodingAudioCodec" label="${LabelAudioCodec}" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="tabContent tabTranscodingAdvanced" style="display: none;">
|
|
||||||
<div id="fldEnableMpegtsM2TsMode" style="margin: 1em 0;">
|
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
||||||
<label>
|
|
||||||
<input is="emby-checkbox" type="checkbox" id="chkEnableMpegtsM2TsMode" />
|
|
||||||
<span>${OptionEnableM2tsMode}</span>
|
|
||||||
</label>
|
|
||||||
<div class="fieldDescription checkboxFieldDescription">${OptionEnableM2tsModeHelp}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="fldEstimateContentLength" style="margin: 1em 0;">
|
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
||||||
<label>
|
|
||||||
<input is="emby-checkbox" type="checkbox" id="chkEstimateContentLength" />
|
|
||||||
<span>${OptionEstimateContentLength}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="fldReportByteRangeRequests" style="margin: 1em 0;">
|
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
||||||
<label>
|
|
||||||
<input is="emby-checkbox" type="checkbox" id="chkReportByteRangeRequests" />
|
|
||||||
<span>${OptionReportByteRangeSeekingWhenTranscoding}</span>
|
|
||||||
</label>
|
|
||||||
<div class="fieldDescription checkboxFieldDescription">${OptionReportByteRangeSeekingWhenTranscodingHelp}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check" data-mini="true">
|
|
||||||
<span>${ButtonOk}</span>
|
|
||||||
</button>
|
|
||||||
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');" data-mini="true">
|
|
||||||
<span>${ButtonCancel}</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div data-role="popup" id="containerProfilePopup" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%;">
|
|
||||||
<form class="containerProfileForm" style="padding:1em;">
|
|
||||||
<div class="ui-bar-a">
|
|
||||||
<h3 class="sectionTitle">${HeaderContainerProfile}</h3>
|
|
||||||
</div>
|
|
||||||
<div data-role="content">
|
|
||||||
<p>${HeaderContainerProfileHelp}</p>
|
|
||||||
<div class="tabContent tabContainerBasics">
|
|
||||||
<div class="selectContainer">
|
|
||||||
<select id="selectContainerProfileType" name="selectContainerProfileType" is="emby-select" label="${LabelType}">
|
|
||||||
<option value="Audio">${Audio}</option>
|
|
||||||
<option value="Photo">${Photo}</option>
|
|
||||||
<option value="Video">${Video}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtContainerProfileContainer" label="${LabelProfileContainer}" />
|
|
||||||
<div class="fieldDescription">${LabelProfileContainersHelp}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="tabContent tabContainerConditions" style="display: none;"></div>
|
|
||||||
<p>
|
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check" data-mini="true">
|
|
||||||
<span>${ButtonOk}</span>
|
|
||||||
</button>
|
|
||||||
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');" data-mini="true">
|
|
||||||
<span>${ButtonCancel}</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div data-role="popup" id="codecProfilePopup" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%;">
|
|
||||||
<form class="codecProfileForm" style="padding:1em;">
|
|
||||||
<div class="ui-bar-a">
|
|
||||||
<h3 class="sectionTitle">${HeaderCodecProfile}</h3>
|
|
||||||
</div>
|
|
||||||
<div data-role="content">
|
|
||||||
<p>${HeaderCodecProfileHelp}</p>
|
|
||||||
<div class="selectContainer">
|
|
||||||
<select id="selectCodecProfileType" name="selectCodecProfileType" is="emby-select" label="${LabelType}">
|
|
||||||
<option value="Video">${Video}</option>
|
|
||||||
<option value="VideoAudio">${VideoAudio}</option>
|
|
||||||
<option value="Audio">${Audio}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtCodecProfileCodec" label="${LabelProfileCodecs}" />
|
|
||||||
<div class="fieldDescription">${LabelProfileCodecsHelp}</div>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check" data-mini="true">
|
|
||||||
<span>${ButtonOk}</span>
|
|
||||||
</button>
|
|
||||||
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');" data-mini="true">
|
|
||||||
<span>${ButtonCancel}</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div data-role="popup" id="responseProfilePopup" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%;">
|
|
||||||
<form class="editResponseProfileForm" style="padding:1em;">
|
|
||||||
<div class="ui-bar-a">
|
|
||||||
<h3 class="sectionTitle">${HeaderResponseProfile}</h3>
|
|
||||||
</div>
|
|
||||||
<div data-role="content">
|
|
||||||
<div class="selectContainer">
|
|
||||||
<select id="selectResponseProfileType" name="selectResponseProfileType" is="emby-select" label="${LabelType}">
|
|
||||||
<option value="Audio">${Audio}</option>
|
|
||||||
<option value="Photo">${Photo}</option>
|
|
||||||
<option value="Video">${Video}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtResponseProfileContainer" label="${LabelProfileContainer}" />
|
|
||||||
<div class="fieldDescription">${LabelProfileContainersHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div id="fldResponseProfileVideoCodec" style="margin: 1em 0;">
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtResponseProfileVideoCodec" label="${LabelProfileVideoCodecs}" />
|
|
||||||
<div class="fieldDescription">${LabelProfileCodecsHelp}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="fldResponseProfileAudioCodec" style="margin: 1em 0 2em;">
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtResponseProfileAudioCodec" label="${LabelProfileAudioCodecs}" />
|
|
||||||
<div class="fieldDescription">${LabelProfileCodecsHelp}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check" data-mini="true">
|
|
||||||
<span>${ButtonOk}</span>
|
|
||||||
</button>
|
|
||||||
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');" data-mini="true">
|
|
||||||
<span>${ButtonCancel}</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div data-role="popup" id="identificationHeaderPopup" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%;">
|
|
||||||
<form class="identificationHeaderForm" style="padding:1em;">
|
|
||||||
<div class="ui-bar-a">
|
|
||||||
<h3 class="sectionTitle">${HeaderIdentificationHeader}</h3>
|
|
||||||
</div>
|
|
||||||
<div data-role="content">
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtIdentificationHeaderName" label="${LabelName}" />
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtIdentificationHeaderValue" label="${LabelValue}" />
|
|
||||||
</div>
|
|
||||||
<div class="selectContainer">
|
|
||||||
<select id="selectMatchType" name="selectMatchType" is="emby-select" label="${LabelMatchType}">
|
|
||||||
<option value="Equals">${OptionEquals}</option>
|
|
||||||
<option value="Regex">${OptionRegex}</option>
|
|
||||||
<option value="Substring">${OptionSubstring}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check" data-mini="true">
|
|
||||||
<span>${ButtonOk}</span>
|
|
||||||
</button>
|
|
||||||
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');" data-mini="true">
|
|
||||||
<span>${ButtonCancel}</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div data-role="popup" id="xmlAttributePopup" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%;">
|
|
||||||
<form class="xmlAttributeForm" style="padding:1em;">
|
|
||||||
<div class="ui-bar-a">
|
|
||||||
<h3 class="sectionTitle">${HeaderXmlDocumentAttribute}</h3>
|
|
||||||
</div>
|
|
||||||
<div data-role="content">
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtXmlAttributeName" label="${LabelName}" />
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtXmlAttributeValue" label="${LabelValue}" />
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check" data-mini="true">
|
|
||||||
<span>${ButtonOk}</span>
|
|
||||||
</button>
|
|
||||||
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');" data-mini="true">
|
|
||||||
<span>${ButtonCancel}</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div data-role="popup" id="subtitleProfilePopup" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%;">
|
|
||||||
<form class="subtitleProfileForm" style="padding:1em;">
|
|
||||||
<div class="ui-bar-a">
|
|
||||||
<h3 class="sectionTitle">${HeaderSubtitleProfile}</h3>
|
|
||||||
</div>
|
|
||||||
<div data-role="content">
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="text" id="txtSubtitleProfileFormat" label="${LabelFormat}" />
|
|
||||||
<div class="fieldDescription">${LabelSubtitleFormatHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="selectContainer">
|
|
||||||
<select id="selectSubtitleProfileMethod" name="selectSubtitleProfileMethod" is="emby-select" label="${LabelMethod}">
|
|
||||||
<option value="Embed">${OptionEmbedSubtitles}</option>
|
|
||||||
<option value="External">${OptionExternallyDownloaded}</option>
|
|
||||||
<option value="Hls">${OptionHlsSegmentedSubtitles}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="selectContainer">
|
|
||||||
<select id="selectSubtitleProfileDidlMode" name="selectSubtitleProfileDidlMode" is="emby-select" label="${LabelDidlMode}">
|
|
||||||
<option value="">${OptionResElement}</option>
|
|
||||||
<option value="CaptionInfoEx">${OptionCaptionInfoExSamsung}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check" data-mini="true">
|
|
||||||
<span>${ButtonOk}</span>
|
|
||||||
</button>
|
|
||||||
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');" data-mini="true">
|
|
||||||
<span>${ButtonCancel}</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,830 +0,0 @@
|
||||||
import escapeHtml from 'escape-html';
|
|
||||||
import 'jquery';
|
|
||||||
import loading from '../../../components/loading/loading';
|
|
||||||
import globalize from '../../../scripts/globalize';
|
|
||||||
import '../../../elements/emby-select/emby-select';
|
|
||||||
import '../../../elements/emby-button/emby-button';
|
|
||||||
import '../../../elements/emby-input/emby-input';
|
|
||||||
import '../../../elements/emby-checkbox/emby-checkbox';
|
|
||||||
import '../../../components/listview/listview.scss';
|
|
||||||
import Dashboard from '../../../utils/dashboard';
|
|
||||||
import toast from '../../../components/toast/toast';
|
|
||||||
import { getParameterByName } from '../../../utils/url.ts';
|
|
||||||
|
|
||||||
function loadProfile(page) {
|
|
||||||
loading.show();
|
|
||||||
const promise1 = getProfile();
|
|
||||||
const promise2 = ApiClient.getUsers();
|
|
||||||
Promise.all([promise1, promise2]).then(function (responses) {
|
|
||||||
currentProfile = responses[0];
|
|
||||||
renderProfile(page, currentProfile, responses[1]);
|
|
||||||
loading.hide();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProfile() {
|
|
||||||
const id = getParameterByName('id');
|
|
||||||
const url = id ? 'Dlna/Profiles/' + id : 'Dlna/Profiles/Default';
|
|
||||||
return ApiClient.getJSON(ApiClient.getUrl(url));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderProfile(page, profile, users) {
|
|
||||||
$('#txtName', page).val(profile.Name);
|
|
||||||
$('.chkMediaType', page).each(function () {
|
|
||||||
this.checked = (profile.SupportedMediaTypes || '').split(',').indexOf(this.getAttribute('data-value')) != -1;
|
|
||||||
});
|
|
||||||
$('#chkEnableAlbumArtInDidl', page).prop('checked', profile.EnableAlbumArtInDidl);
|
|
||||||
$('#chkEnableSingleImageLimit', page).prop('checked', profile.EnableSingleAlbumArtLimit);
|
|
||||||
renderXmlDocumentAttributes(page, profile.XmlRootAttributes || []);
|
|
||||||
const idInfo = profile.Identification || {};
|
|
||||||
renderIdentificationHeaders(page, idInfo.Headers || []);
|
|
||||||
renderSubtitleProfiles(page, profile.SubtitleProfiles || []);
|
|
||||||
$('#txtInfoFriendlyName', page).val(profile.FriendlyName || '');
|
|
||||||
$('#txtInfoModelName', page).val(profile.ModelName || '');
|
|
||||||
$('#txtInfoModelNumber', page).val(profile.ModelNumber || '');
|
|
||||||
$('#txtInfoModelDescription', page).val(profile.ModelDescription || '');
|
|
||||||
$('#txtInfoModelUrl', page).val(profile.ModelUrl || '');
|
|
||||||
$('#txtInfoManufacturer', page).val(profile.Manufacturer || '');
|
|
||||||
$('#txtInfoManufacturerUrl', page).val(profile.ManufacturerUrl || '');
|
|
||||||
$('#txtInfoSerialNumber', page).val(profile.SerialNumber || '');
|
|
||||||
$('#txtIdFriendlyName', page).val(idInfo.FriendlyName || '');
|
|
||||||
$('#txtIdModelName', page).val(idInfo.ModelName || '');
|
|
||||||
$('#txtIdModelNumber', page).val(idInfo.ModelNumber || '');
|
|
||||||
$('#txtIdModelDescription', page).val(idInfo.ModelDescription || '');
|
|
||||||
$('#txtIdModelUrl', page).val(idInfo.ModelUrl || '');
|
|
||||||
$('#txtIdManufacturer', page).val(idInfo.Manufacturer || '');
|
|
||||||
$('#txtIdManufacturerUrl', page).val(idInfo.ManufacturerUrl || '');
|
|
||||||
$('#txtIdSerialNumber', page).val(idInfo.SerialNumber || '');
|
|
||||||
$('#txtIdDeviceDescription', page).val(idInfo.DeviceDescription || '');
|
|
||||||
$('#txtAlbumArtPn', page).val(profile.AlbumArtPn || '');
|
|
||||||
$('#txtAlbumArtMaxWidth', page).val(profile.MaxAlbumArtWidth || '');
|
|
||||||
$('#txtAlbumArtMaxHeight', page).val(profile.MaxAlbumArtHeight || '');
|
|
||||||
$('#txtIconMaxWidth', page).val(profile.MaxIconWidth || '');
|
|
||||||
$('#txtIconMaxHeight', page).val(profile.MaxIconHeight || '');
|
|
||||||
$('#chkIgnoreTranscodeByteRangeRequests', page).prop('checked', profile.IgnoreTranscodeByteRangeRequests);
|
|
||||||
$('#txtMaxAllowedBitrate', page).val(profile.MaxStreamingBitrate || '');
|
|
||||||
$('#txtMusicStreamingTranscodingBitrate', page).val(profile.MusicStreamingTranscodingBitrate || '');
|
|
||||||
$('#chkRequiresPlainFolders', page).prop('checked', profile.RequiresPlainFolders);
|
|
||||||
$('#chkRequiresPlainVideoItems', page).prop('checked', profile.RequiresPlainVideoItems);
|
|
||||||
$('#txtProtocolInfo', page).val(profile.ProtocolInfo || '');
|
|
||||||
$('#txtXDlnaCap', page).val(profile.XDlnaCap || '');
|
|
||||||
$('#txtXDlnaDoc', page).val(profile.XDlnaDoc || '');
|
|
||||||
$('#txtSonyAggregationFlags', page).val(profile.SonyAggregationFlags || '');
|
|
||||||
profile.DirectPlayProfiles = profile.DirectPlayProfiles || [];
|
|
||||||
profile.TranscodingProfiles = profile.TranscodingProfiles || [];
|
|
||||||
profile.ContainerProfiles = profile.ContainerProfiles || [];
|
|
||||||
profile.CodecProfiles = profile.CodecProfiles || [];
|
|
||||||
profile.ResponseProfiles = profile.ResponseProfiles || [];
|
|
||||||
const usersHtml = '<option></option>' + users.map(function (u) {
|
|
||||||
return '<option value="' + u.Id + '">' + escapeHtml(u.Name) + '</option>';
|
|
||||||
}).join('');
|
|
||||||
$('#selectUser', page).html(usersHtml).val(profile.UserId || '');
|
|
||||||
renderSubProfiles(page, profile);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderIdentificationHeaders(page, headers) {
|
|
||||||
let index = 0;
|
|
||||||
const html = '<div class="paperList">' + headers.map(function (h) {
|
|
||||||
let li = '<div class="listItem">';
|
|
||||||
li += '<span class="material-icons listItemIcon info" aria-hidden="true"></span>';
|
|
||||||
li += '<div class="listItemBody">';
|
|
||||||
li += '<h3 class="listItemBodyText">' + escapeHtml(h.Name + ': ' + (h.Value || '')) + '</h3>';
|
|
||||||
li += '<div class="listItemBodyText secondary">' + escapeHtml(h.Match || '') + '</div>';
|
|
||||||
li += '</div>';
|
|
||||||
li += '<button type="button" is="paper-icon-button-light" class="btnDeleteIdentificationHeader listItemButton" data-index="' + index + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
|
|
||||||
li += '</div>';
|
|
||||||
index++;
|
|
||||||
return li;
|
|
||||||
}).join('') + '</div>';
|
|
||||||
const elem = $('.httpHeaderIdentificationList', page).html(html).trigger('create');
|
|
||||||
$('.btnDeleteIdentificationHeader', elem).on('click', function () {
|
|
||||||
const itemIndex = parseInt(this.getAttribute('data-index'), 10);
|
|
||||||
currentProfile.Identification.Headers.splice(itemIndex, 1);
|
|
||||||
renderIdentificationHeaders(page, currentProfile.Identification.Headers);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPopup(elem) {
|
|
||||||
elem.classList.remove('hide');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePopup(elem) {
|
|
||||||
elem.classList.add('hide');
|
|
||||||
}
|
|
||||||
|
|
||||||
function editIdentificationHeader(page, header) {
|
|
||||||
isSubProfileNew = header == null;
|
|
||||||
header = header || {};
|
|
||||||
currentSubProfile = header;
|
|
||||||
const popup = $('#identificationHeaderPopup', page);
|
|
||||||
$('#txtIdentificationHeaderName', popup).val(header.Name || '');
|
|
||||||
$('#txtIdentificationHeaderValue', popup).val(header.Value || '');
|
|
||||||
$('#selectMatchType', popup).val(header.Match || 'Equals');
|
|
||||||
openPopup(popup[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveIdentificationHeader(page) {
|
|
||||||
currentSubProfile.Name = $('#txtIdentificationHeaderName', page).val();
|
|
||||||
currentSubProfile.Value = $('#txtIdentificationHeaderValue', page).val();
|
|
||||||
currentSubProfile.Match = $('#selectMatchType', page).val();
|
|
||||||
|
|
||||||
if (isSubProfileNew) {
|
|
||||||
currentProfile.Identification = currentProfile.Identification || {};
|
|
||||||
currentProfile.Identification.Headers = currentProfile.Identification.Headers || [];
|
|
||||||
currentProfile.Identification.Headers.push(currentSubProfile);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderIdentificationHeaders(page, currentProfile.Identification.Headers);
|
|
||||||
currentSubProfile = null;
|
|
||||||
closePopup($('#identificationHeaderPopup', page)[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderXmlDocumentAttributes(page, attribute) {
|
|
||||||
const html = '<div class="paperList">' + attribute.map(function (h) {
|
|
||||||
let li = '<div class="listItem">';
|
|
||||||
li += '<span class="material-icons listItemIcon info" aria-hidden="true"></span>';
|
|
||||||
li += '<div class="listItemBody">';
|
|
||||||
li += '<h3 class="listItemBodyText">' + escapeHtml(h.Name + ' = ' + (h.Value || '')) + '</h3>';
|
|
||||||
li += '</div>';
|
|
||||||
li += '<button type="button" is="paper-icon-button-light" class="btnDeleteXmlAttribute listItemButton" data-index="0"><span class="material-icons delete" aria-hidden="true"></span></button>';
|
|
||||||
li += '</div>';
|
|
||||||
return li;
|
|
||||||
}).join('') + '</div>';
|
|
||||||
const elem = $('.xmlDocumentAttributeList', page).html(html).trigger('create');
|
|
||||||
$('.btnDeleteXmlAttribute', elem).on('click', function () {
|
|
||||||
const itemIndex = parseInt(this.getAttribute('data-index'), 10);
|
|
||||||
currentProfile.XmlRootAttributes.splice(itemIndex, 1);
|
|
||||||
renderXmlDocumentAttributes(page, currentProfile.XmlRootAttributes);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function editXmlDocumentAttribute(page, attribute) {
|
|
||||||
isSubProfileNew = attribute == null;
|
|
||||||
attribute = attribute || {};
|
|
||||||
currentSubProfile = attribute;
|
|
||||||
const popup = $('#xmlAttributePopup', page);
|
|
||||||
$('#txtXmlAttributeName', popup).val(attribute.Name || '');
|
|
||||||
$('#txtXmlAttributeValue', popup).val(attribute.Value || '');
|
|
||||||
openPopup(popup[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveXmlDocumentAttribute(page) {
|
|
||||||
currentSubProfile.Name = $('#txtXmlAttributeName', page).val();
|
|
||||||
currentSubProfile.Value = $('#txtXmlAttributeValue', page).val();
|
|
||||||
|
|
||||||
if (isSubProfileNew) {
|
|
||||||
currentProfile.XmlRootAttributes.push(currentSubProfile);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderXmlDocumentAttributes(page, currentProfile.XmlRootAttributes);
|
|
||||||
currentSubProfile = null;
|
|
||||||
closePopup($('#xmlAttributePopup', page)[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSubtitleProfiles(page, profiles) {
|
|
||||||
let index = 0;
|
|
||||||
const html = '<div class="paperList">' + profiles.map(function (h) {
|
|
||||||
let li = '<div class="listItem lnkEditSubProfile" data-index="' + index + '">';
|
|
||||||
li += '<span class="material-icons listItemIcon info" aria-hidden="true"></span>';
|
|
||||||
li += '<div class="listItemBody">';
|
|
||||||
li += '<h3 class="listItemBodyText">' + escapeHtml(h.Format || '') + '</h3>';
|
|
||||||
li += '</div>';
|
|
||||||
li += '<button type="button" is="paper-icon-button-light" class="btnDeleteProfile listItemButton" data-index="' + index + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
|
|
||||||
li += '</div>';
|
|
||||||
index++;
|
|
||||||
return li;
|
|
||||||
}).join('') + '</div>';
|
|
||||||
const elem = $('.subtitleProfileList', page).html(html).trigger('create');
|
|
||||||
$('.btnDeleteProfile', elem).on('click', function () {
|
|
||||||
const itemIndex = parseInt(this.getAttribute('data-index'), 10);
|
|
||||||
currentProfile.SubtitleProfiles.splice(itemIndex, 1);
|
|
||||||
renderSubtitleProfiles(page, currentProfile.SubtitleProfiles);
|
|
||||||
});
|
|
||||||
$('.lnkEditSubProfile', elem).on('click', function () {
|
|
||||||
const itemIndex = parseInt(this.getAttribute('data-index'), 10);
|
|
||||||
editSubtitleProfile(page, currentProfile.SubtitleProfiles[itemIndex]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function editSubtitleProfile(page, profile) {
|
|
||||||
isSubProfileNew = profile == null;
|
|
||||||
profile = profile || {};
|
|
||||||
currentSubProfile = profile;
|
|
||||||
const popup = $('#subtitleProfilePopup', page);
|
|
||||||
$('#txtSubtitleProfileFormat', popup).val(profile.Format || '');
|
|
||||||
$('#selectSubtitleProfileMethod', popup).val(profile.Method || '');
|
|
||||||
$('#selectSubtitleProfileDidlMode', popup).val(profile.DidlMode || '');
|
|
||||||
openPopup(popup[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveSubtitleProfile(page) {
|
|
||||||
currentSubProfile.Format = $('#txtSubtitleProfileFormat', page).val();
|
|
||||||
currentSubProfile.Method = $('#selectSubtitleProfileMethod', page).val();
|
|
||||||
currentSubProfile.DidlMode = $('#selectSubtitleProfileDidlMode', page).val();
|
|
||||||
|
|
||||||
if (isSubProfileNew) {
|
|
||||||
currentProfile.SubtitleProfiles.push(currentSubProfile);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSubtitleProfiles(page, currentProfile.SubtitleProfiles);
|
|
||||||
currentSubProfile = null;
|
|
||||||
closePopup($('#subtitleProfilePopup', page)[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSubProfiles(page, profile) {
|
|
||||||
renderDirectPlayProfiles(page, profile.DirectPlayProfiles);
|
|
||||||
renderTranscodingProfiles(page, profile.TranscodingProfiles);
|
|
||||||
renderContainerProfiles(page, profile.ContainerProfiles);
|
|
||||||
renderCodecProfiles(page, profile.CodecProfiles);
|
|
||||||
renderResponseProfiles(page, profile.ResponseProfiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveDirectPlayProfile(page) {
|
|
||||||
currentSubProfile.Type = $('#selectDirectPlayProfileType', page).val();
|
|
||||||
currentSubProfile.Container = $('#txtDirectPlayContainer', page).val();
|
|
||||||
currentSubProfile.AudioCodec = $('#txtDirectPlayAudioCodec', page).val();
|
|
||||||
currentSubProfile.VideoCodec = $('#txtDirectPlayVideoCodec', page).val();
|
|
||||||
|
|
||||||
if (isSubProfileNew) {
|
|
||||||
currentProfile.DirectPlayProfiles.push(currentSubProfile);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSubProfiles(page, currentProfile);
|
|
||||||
currentSubProfile = null;
|
|
||||||
closePopup($('#popupEditDirectPlayProfile', page)[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDirectPlayProfiles(page, profiles) {
|
|
||||||
let html = '';
|
|
||||||
html += '<ul data-role="listview" data-inset="true" data-split-icon="delete">';
|
|
||||||
let currentType;
|
|
||||||
|
|
||||||
for (const [index, profile] of profiles.entries()) {
|
|
||||||
if (profile.Type !== currentType) {
|
|
||||||
html += '<li data-role="list-divider">' + profile.Type + '</li>';
|
|
||||||
currentType = profile.Type;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '<div>';
|
|
||||||
html += '<a is="emby-linkbutton" href="#" class="lnkEditSubProfile" data-profileindex="' + index + '">';
|
|
||||||
html += '<p>' + globalize.translate('ValueContainer', profile.Container || allText) + '</p>';
|
|
||||||
|
|
||||||
if (profile.Type == 'Video') {
|
|
||||||
html += '<p>' + globalize.translate('ValueVideoCodec', profile.VideoCodec || allText) + '</p>';
|
|
||||||
html += '<p>' + globalize.translate('ValueAudioCodec', profile.AudioCodec || allText) + '</p>';
|
|
||||||
} else if (profile.Type == 'Audio') {
|
|
||||||
html += '<p>' + globalize.translate('ValueCodec', profile.AudioCodec || allText) + '</p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</a>';
|
|
||||||
html += '<button type="button" is="paper-icon-button-light" class="btnDeleteProfile listItemButton" data-profileindex="' + index + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</ul>';
|
|
||||||
const elem = $('.directPlayProfiles', page).html(html).trigger('create');
|
|
||||||
$('.btnDeleteProfile', elem).on('click', function () {
|
|
||||||
const index = this.getAttribute('data-profileindex');
|
|
||||||
deleteDirectPlayProfile(page, index);
|
|
||||||
});
|
|
||||||
$('.lnkEditSubProfile', elem).on('click', function () {
|
|
||||||
const index = parseInt(this.getAttribute('data-profileindex'), 10);
|
|
||||||
editDirectPlayProfile(page, currentProfile.DirectPlayProfiles[index]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteDirectPlayProfile(page, index) {
|
|
||||||
currentProfile.DirectPlayProfiles.splice(index, 1);
|
|
||||||
renderDirectPlayProfiles(page, currentProfile.DirectPlayProfiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
function editDirectPlayProfile(page, directPlayProfile) {
|
|
||||||
isSubProfileNew = directPlayProfile == null;
|
|
||||||
directPlayProfile = directPlayProfile || {};
|
|
||||||
currentSubProfile = directPlayProfile;
|
|
||||||
const popup = $('#popupEditDirectPlayProfile', page);
|
|
||||||
$('#selectDirectPlayProfileType', popup).val(directPlayProfile.Type || 'Video').trigger('change');
|
|
||||||
$('#txtDirectPlayContainer', popup).val(directPlayProfile.Container || '');
|
|
||||||
$('#txtDirectPlayAudioCodec', popup).val(directPlayProfile.AudioCodec || '');
|
|
||||||
$('#txtDirectPlayVideoCodec', popup).val(directPlayProfile.VideoCodec || '');
|
|
||||||
openPopup(popup[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTranscodingProfiles(page, profiles) {
|
|
||||||
let html = '';
|
|
||||||
html += '<ul data-role="listview" data-inset="true" data-split-icon="delete">';
|
|
||||||
let currentType;
|
|
||||||
|
|
||||||
for (let i = 0, length = profiles.length; i < length; i++) {
|
|
||||||
const profile = profiles[i];
|
|
||||||
|
|
||||||
if (profile.Type !== currentType) {
|
|
||||||
html += '<li data-role="list-divider">' + profile.Type + '</li>';
|
|
||||||
currentType = profile.Type;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '<div>';
|
|
||||||
html += '<a is="emby-linkbutton" href="#" class="lnkEditSubProfile" data-profileindex="' + i + '">';
|
|
||||||
html += '<p>Protocol: ' + (profile.Protocol || 'Http') + '</p>';
|
|
||||||
html += '<p>' + globalize.translate('ValueContainer', profile.Container || allText) + '</p>';
|
|
||||||
|
|
||||||
if (profile.Type == 'Video') {
|
|
||||||
html += '<p>' + globalize.translate('ValueVideoCodec', profile.VideoCodec || allText) + '</p>';
|
|
||||||
html += '<p>' + globalize.translate('ValueAudioCodec', profile.AudioCodec || allText) + '</p>';
|
|
||||||
} else if (profile.Type == 'Audio') {
|
|
||||||
html += '<p>' + globalize.translate('ValueCodec', profile.AudioCodec || allText) + '</p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</a>';
|
|
||||||
html += '<button type="button" is="paper-icon-button-light" class="btnDeleteProfile listItemButton" data-profileindex="' + i + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</ul>';
|
|
||||||
const elem = $('.transcodingProfiles', page).html(html).trigger('create');
|
|
||||||
$('.btnDeleteProfile', elem).on('click', function () {
|
|
||||||
const index = this.getAttribute('data-profileindex');
|
|
||||||
deleteTranscodingProfile(page, index);
|
|
||||||
});
|
|
||||||
$('.lnkEditSubProfile', elem).on('click', function () {
|
|
||||||
const index = parseInt(this.getAttribute('data-profileindex'), 10);
|
|
||||||
editTranscodingProfile(page, currentProfile.TranscodingProfiles[index]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function editTranscodingProfile(page, transcodingProfile) {
|
|
||||||
isSubProfileNew = transcodingProfile == null;
|
|
||||||
transcodingProfile = transcodingProfile || {};
|
|
||||||
currentSubProfile = transcodingProfile;
|
|
||||||
const popup = $('#transcodingProfilePopup', page);
|
|
||||||
$('#selectTranscodingProfileType', popup).val(transcodingProfile.Type || 'Video').trigger('change');
|
|
||||||
$('#txtTranscodingContainer', popup).val(transcodingProfile.Container || '');
|
|
||||||
$('#txtTranscodingAudioCodec', popup).val(transcodingProfile.AudioCodec || '');
|
|
||||||
$('#txtTranscodingVideoCodec', popup).val(transcodingProfile.VideoCodec || '');
|
|
||||||
$('#selectTranscodingProtocol', popup).val(transcodingProfile.Protocol || 'Http');
|
|
||||||
$('#chkEnableMpegtsM2TsMode', popup).prop('checked', transcodingProfile.EnableMpegtsM2TsMode || false);
|
|
||||||
$('#chkEstimateContentLength', popup).prop('checked', transcodingProfile.EstimateContentLength || false);
|
|
||||||
$('#chkReportByteRangeRequests', popup).prop('checked', transcodingProfile.TranscodeSeekInfo == 'Bytes');
|
|
||||||
$('.radioTabButton:first', popup).trigger('click');
|
|
||||||
openPopup(popup[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteTranscodingProfile(page, index) {
|
|
||||||
currentProfile.TranscodingProfiles.splice(index, 1);
|
|
||||||
renderTranscodingProfiles(page, currentProfile.TranscodingProfiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveTranscodingProfile(page) {
|
|
||||||
currentSubProfile.Type = $('#selectTranscodingProfileType', page).val();
|
|
||||||
currentSubProfile.Container = $('#txtTranscodingContainer', page).val();
|
|
||||||
currentSubProfile.AudioCodec = $('#txtTranscodingAudioCodec', page).val();
|
|
||||||
currentSubProfile.VideoCodec = $('#txtTranscodingVideoCodec', page).val();
|
|
||||||
currentSubProfile.Protocol = $('#selectTranscodingProtocol', page).val();
|
|
||||||
currentSubProfile.Context = 'Streaming';
|
|
||||||
currentSubProfile.EnableMpegtsM2TsMode = $('#chkEnableMpegtsM2TsMode', page).is(':checked');
|
|
||||||
currentSubProfile.EstimateContentLength = $('#chkEstimateContentLength', page).is(':checked');
|
|
||||||
currentSubProfile.TranscodeSeekInfo = $('#chkReportByteRangeRequests', page).is(':checked') ? 'Bytes' : 'Auto';
|
|
||||||
|
|
||||||
if (isSubProfileNew) {
|
|
||||||
currentProfile.TranscodingProfiles.push(currentSubProfile);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSubProfiles(page, currentProfile);
|
|
||||||
currentSubProfile = null;
|
|
||||||
closePopup($('#transcodingProfilePopup', page)[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderContainerProfiles(page, profiles) {
|
|
||||||
let html = '';
|
|
||||||
html += '<ul data-role="listview" data-inset="true" data-split-icon="delete">';
|
|
||||||
let currentType;
|
|
||||||
|
|
||||||
for (let i = 0, length = profiles.length; i < length; i++) {
|
|
||||||
const profile = profiles[i];
|
|
||||||
|
|
||||||
if (profile.Type !== currentType) {
|
|
||||||
html += '<li data-role="list-divider">' + profile.Type + '</li>';
|
|
||||||
currentType = profile.Type;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '<div>';
|
|
||||||
html += '<a is="emby-linkbutton" href="#" class="lnkEditSubProfile" data-profileindex="' + i + '">';
|
|
||||||
html += '<p>' + globalize.translate('ValueContainer', profile.Container || allText) + '</p>';
|
|
||||||
|
|
||||||
if (profile.Conditions?.length) {
|
|
||||||
html += '<p>';
|
|
||||||
html += globalize.translate('ValueConditions', profile.Conditions.map(function (c) {
|
|
||||||
return c.Property;
|
|
||||||
}).join(', '));
|
|
||||||
html += '</p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</a>';
|
|
||||||
html += '<button type="button" is="paper-icon-button-light" class="btnDeleteProfile listItemButton" data-profileindex="' + i + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</ul>';
|
|
||||||
const elem = $('.containerProfiles', page).html(html).trigger('create');
|
|
||||||
$('.btnDeleteProfile', elem).on('click', function () {
|
|
||||||
const index = this.getAttribute('data-profileindex');
|
|
||||||
deleteContainerProfile(page, index);
|
|
||||||
});
|
|
||||||
$('.lnkEditSubProfile', elem).on('click', function () {
|
|
||||||
const index = parseInt(this.getAttribute('data-profileindex'), 10);
|
|
||||||
editContainerProfile(page, currentProfile.ContainerProfiles[index]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteContainerProfile(page, index) {
|
|
||||||
currentProfile.ContainerProfiles.splice(index, 1);
|
|
||||||
renderContainerProfiles(page, currentProfile.ContainerProfiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
function editContainerProfile(page, containerProfile) {
|
|
||||||
isSubProfileNew = containerProfile == null;
|
|
||||||
containerProfile = containerProfile || {};
|
|
||||||
currentSubProfile = containerProfile;
|
|
||||||
const popup = $('#containerProfilePopup', page);
|
|
||||||
$('#selectContainerProfileType', popup).val(containerProfile.Type || 'Video').trigger('change');
|
|
||||||
$('#txtContainerProfileContainer', popup).val(containerProfile.Container || '');
|
|
||||||
$('.radioTabButton:first', popup).trigger('click');
|
|
||||||
openPopup(popup[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveContainerProfile(page) {
|
|
||||||
currentSubProfile.Type = $('#selectContainerProfileType', page).val();
|
|
||||||
currentSubProfile.Container = $('#txtContainerProfileContainer', page).val();
|
|
||||||
|
|
||||||
if (isSubProfileNew) {
|
|
||||||
currentProfile.ContainerProfiles.push(currentSubProfile);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSubProfiles(page, currentProfile);
|
|
||||||
currentSubProfile = null;
|
|
||||||
closePopup($('#containerProfilePopup', page)[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCodecProfiles(page, profiles) {
|
|
||||||
let html = '';
|
|
||||||
html += '<ul data-role="listview" data-inset="true" data-split-icon="delete">';
|
|
||||||
let currentType;
|
|
||||||
|
|
||||||
for (let i = 0, length = profiles.length; i < length; i++) {
|
|
||||||
const profile = profiles[i];
|
|
||||||
const type = profile.Type.replace('VideoAudio', 'Video Audio');
|
|
||||||
|
|
||||||
if (type !== currentType) {
|
|
||||||
html += '<li data-role="list-divider">' + type + '</li>';
|
|
||||||
currentType = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '<div>';
|
|
||||||
html += '<a is="emby-linkbutton" href="#" class="lnkEditSubProfile" data-profileindex="' + i + '">';
|
|
||||||
html += '<p>' + globalize.translate('ValueCodec', profile.Codec || allText) + '</p>';
|
|
||||||
|
|
||||||
if (profile.Conditions?.length) {
|
|
||||||
html += '<p>';
|
|
||||||
html += globalize.translate('ValueConditions', profile.Conditions.map(function (c) {
|
|
||||||
return c.Property;
|
|
||||||
}).join(', '));
|
|
||||||
html += '</p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</a>';
|
|
||||||
html += '<button type="button" is="paper-icon-button-light" class="btnDeleteProfile listItemButton" data-profileindex="' + i + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</ul>';
|
|
||||||
const elem = $('.codecProfiles', page).html(html).trigger('create');
|
|
||||||
$('.btnDeleteProfile', elem).on('click', function () {
|
|
||||||
const index = this.getAttribute('data-profileindex');
|
|
||||||
deleteCodecProfile(page, index);
|
|
||||||
});
|
|
||||||
$('.lnkEditSubProfile', elem).on('click', function () {
|
|
||||||
const index = parseInt(this.getAttribute('data-profileindex'), 10);
|
|
||||||
editCodecProfile(page, currentProfile.CodecProfiles[index]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteCodecProfile(page, index) {
|
|
||||||
currentProfile.CodecProfiles.splice(index, 1);
|
|
||||||
renderCodecProfiles(page, currentProfile.CodecProfiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
function editCodecProfile(page, codecProfile) {
|
|
||||||
isSubProfileNew = codecProfile == null;
|
|
||||||
codecProfile = codecProfile || {};
|
|
||||||
currentSubProfile = codecProfile;
|
|
||||||
const popup = $('#codecProfilePopup', page);
|
|
||||||
$('#selectCodecProfileType', popup).val(codecProfile.Type || 'Video').trigger('change');
|
|
||||||
$('#txtCodecProfileCodec', popup).val(codecProfile.Codec || '');
|
|
||||||
$('.radioTabButton:first', popup).trigger('click');
|
|
||||||
openPopup(popup[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveCodecProfile(page) {
|
|
||||||
currentSubProfile.Type = $('#selectCodecProfileType', page).val();
|
|
||||||
currentSubProfile.Codec = $('#txtCodecProfileCodec', page).val();
|
|
||||||
|
|
||||||
if (isSubProfileNew) {
|
|
||||||
currentProfile.CodecProfiles.push(currentSubProfile);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSubProfiles(page, currentProfile);
|
|
||||||
currentSubProfile = null;
|
|
||||||
closePopup($('#codecProfilePopup', page)[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderResponseProfiles(page, profiles) {
|
|
||||||
let html = '';
|
|
||||||
html += '<ul data-role="listview" data-inset="true" data-split-icon="delete">';
|
|
||||||
let currentType;
|
|
||||||
|
|
||||||
for (let i = 0, length = profiles.length; i < length; i++) {
|
|
||||||
const profile = profiles[i];
|
|
||||||
|
|
||||||
if (profile.Type !== currentType) {
|
|
||||||
html += '<li data-role="list-divider">' + profile.Type + '</li>';
|
|
||||||
currentType = profile.Type;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '<div>';
|
|
||||||
html += '<a is="emby-linkbutton" href="#" class="lnkEditSubProfile" data-profileindex="' + i + '">';
|
|
||||||
html += '<p>' + globalize.translate('ValueContainer', profile.Container || allText) + '</p>';
|
|
||||||
|
|
||||||
if (profile.Type == 'Video') {
|
|
||||||
html += '<p>' + globalize.translate('ValueVideoCodec', profile.VideoCodec || allText) + '</p>';
|
|
||||||
html += '<p>' + globalize.translate('ValueAudioCodec', profile.AudioCodec || allText) + '</p>';
|
|
||||||
} else if (profile.Type == 'Audio') {
|
|
||||||
html += '<p>' + globalize.translate('ValueCodec', profile.AudioCodec || allText) + '</p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profile.Conditions?.length) {
|
|
||||||
html += '<p>';
|
|
||||||
html += globalize.translate('ValueConditions', profile.Conditions.map(function (c) {
|
|
||||||
return c.Property;
|
|
||||||
}).join(', '));
|
|
||||||
html += '</p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</a>';
|
|
||||||
html += '<button type="button" is="paper-icon-button-light" class="btnDeleteProfile listItemButton" data-profileindex="' + i + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</ul>';
|
|
||||||
const elem = $('.mediaProfiles', page).html(html).trigger('create');
|
|
||||||
$('.btnDeleteProfile', elem).on('click', function () {
|
|
||||||
const index = this.getAttribute('data-profileindex');
|
|
||||||
deleteResponseProfile(page, index);
|
|
||||||
});
|
|
||||||
$('.lnkEditSubProfile', elem).on('click', function () {
|
|
||||||
const index = parseInt(this.getAttribute('data-profileindex'), 10);
|
|
||||||
editResponseProfile(page, currentProfile.ResponseProfiles[index]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteResponseProfile(page, index) {
|
|
||||||
currentProfile.ResponseProfiles.splice(index, 1);
|
|
||||||
renderResponseProfiles(page, currentProfile.ResponseProfiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
function editResponseProfile(page, responseProfile) {
|
|
||||||
isSubProfileNew = responseProfile == null;
|
|
||||||
responseProfile = responseProfile || {};
|
|
||||||
currentSubProfile = responseProfile;
|
|
||||||
const popup = $('#responseProfilePopup', page);
|
|
||||||
$('#selectResponseProfileType', popup).val(responseProfile.Type || 'Video').trigger('change');
|
|
||||||
$('#txtResponseProfileContainer', popup).val(responseProfile.Container || '');
|
|
||||||
$('#txtResponseProfileAudioCodec', popup).val(responseProfile.AudioCodec || '');
|
|
||||||
$('#txtResponseProfileVideoCodec', popup).val(responseProfile.VideoCodec || '');
|
|
||||||
$('.radioTabButton:first', popup).trigger('click');
|
|
||||||
openPopup(popup[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveResponseProfile(page) {
|
|
||||||
currentSubProfile.Type = $('#selectResponseProfileType', page).val();
|
|
||||||
currentSubProfile.Container = $('#txtResponseProfileContainer', page).val();
|
|
||||||
currentSubProfile.AudioCodec = $('#txtResponseProfileAudioCodec', page).val();
|
|
||||||
currentSubProfile.VideoCodec = $('#txtResponseProfileVideoCodec', page).val();
|
|
||||||
|
|
||||||
if (isSubProfileNew) {
|
|
||||||
currentProfile.ResponseProfiles.push(currentSubProfile);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSubProfiles(page, currentProfile);
|
|
||||||
currentSubProfile = null;
|
|
||||||
closePopup($('#responseProfilePopup', page)[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveProfile(page, profile) {
|
|
||||||
updateProfile(page, profile);
|
|
||||||
const id = getParameterByName('id');
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
ApiClient.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: ApiClient.getUrl('Dlna/Profiles/' + id),
|
|
||||||
data: JSON.stringify(profile),
|
|
||||||
contentType: 'application/json'
|
|
||||||
}).then(function () {
|
|
||||||
toast(globalize.translate('SettingsSaved'));
|
|
||||||
}, Dashboard.processErrorResponse);
|
|
||||||
} else {
|
|
||||||
ApiClient.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: ApiClient.getUrl('Dlna/Profiles'),
|
|
||||||
data: JSON.stringify(profile),
|
|
||||||
contentType: 'application/json'
|
|
||||||
}).then(function () {
|
|
||||||
Dashboard.navigate('dashboard/dlna/profiles');
|
|
||||||
}, Dashboard.processErrorResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateProfile(page, profile) {
|
|
||||||
profile.Name = $('#txtName', page).val();
|
|
||||||
profile.EnableAlbumArtInDidl = $('#chkEnableAlbumArtInDidl', page).is(':checked');
|
|
||||||
profile.EnableSingleAlbumArtLimit = $('#chkEnableSingleImageLimit', page).is(':checked');
|
|
||||||
profile.SupportedMediaTypes = $('.chkMediaType:checked', page).get().map(function (c) {
|
|
||||||
return c.getAttribute('data-value');
|
|
||||||
}).join(',');
|
|
||||||
profile.Identification = profile.Identification || {};
|
|
||||||
profile.FriendlyName = $('#txtInfoFriendlyName', page).val();
|
|
||||||
profile.ModelName = $('#txtInfoModelName', page).val();
|
|
||||||
profile.ModelNumber = $('#txtInfoModelNumber', page).val();
|
|
||||||
profile.ModelDescription = $('#txtInfoModelDescription', page).val();
|
|
||||||
profile.ModelUrl = $('#txtInfoModelUrl', page).val();
|
|
||||||
profile.Manufacturer = $('#txtInfoManufacturer', page).val();
|
|
||||||
profile.ManufacturerUrl = $('#txtInfoManufacturerUrl', page).val();
|
|
||||||
profile.SerialNumber = $('#txtInfoSerialNumber', page).val();
|
|
||||||
profile.Identification.FriendlyName = $('#txtIdFriendlyName', page).val();
|
|
||||||
profile.Identification.ModelName = $('#txtIdModelName', page).val();
|
|
||||||
profile.Identification.ModelNumber = $('#txtIdModelNumber', page).val();
|
|
||||||
profile.Identification.ModelDescription = $('#txtIdModelDescription', page).val();
|
|
||||||
profile.Identification.ModelUrl = $('#txtIdModelUrl', page).val();
|
|
||||||
profile.Identification.Manufacturer = $('#txtIdManufacturer', page).val();
|
|
||||||
profile.Identification.ManufacturerUrl = $('#txtIdManufacturerUrl', page).val();
|
|
||||||
profile.Identification.SerialNumber = $('#txtIdSerialNumber', page).val();
|
|
||||||
profile.Identification.DeviceDescription = $('#txtIdDeviceDescription', page).val();
|
|
||||||
profile.AlbumArtPn = $('#txtAlbumArtPn', page).val();
|
|
||||||
profile.MaxAlbumArtWidth = $('#txtAlbumArtMaxWidth', page).val();
|
|
||||||
profile.MaxAlbumArtHeight = $('#txtAlbumArtMaxHeight', page).val();
|
|
||||||
profile.MaxIconWidth = $('#txtIconMaxWidth', page).val();
|
|
||||||
profile.MaxIconHeight = $('#txtIconMaxHeight', page).val();
|
|
||||||
profile.RequiresPlainFolders = $('#chkRequiresPlainFolders', page).is(':checked');
|
|
||||||
profile.RequiresPlainVideoItems = $('#chkRequiresPlainVideoItems', page).is(':checked');
|
|
||||||
profile.IgnoreTranscodeByteRangeRequests = $('#chkIgnoreTranscodeByteRangeRequests', page).is(':checked');
|
|
||||||
profile.MaxStreamingBitrate = $('#txtMaxAllowedBitrate', page).val();
|
|
||||||
profile.MusicStreamingTranscodingBitrate = $('#txtMusicStreamingTranscodingBitrate', page).val();
|
|
||||||
profile.ProtocolInfo = $('#txtProtocolInfo', page).val();
|
|
||||||
profile.XDlnaCap = $('#txtXDlnaCap', page).val();
|
|
||||||
profile.XDlnaDoc = $('#txtXDlnaDoc', page).val();
|
|
||||||
profile.SonyAggregationFlags = $('#txtSonyAggregationFlags', page).val();
|
|
||||||
profile.UserId = $('#selectUser', page).val();
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentProfile;
|
|
||||||
let currentSubProfile;
|
|
||||||
let isSubProfileNew;
|
|
||||||
const allText = globalize.translate('All');
|
|
||||||
|
|
||||||
$(document).on('pageinit', '#dlnaProfilePage', function () {
|
|
||||||
const page = this;
|
|
||||||
$('.radioTabButton', page).on('click', function () {
|
|
||||||
$(this).siblings().removeClass('ui-btn-active');
|
|
||||||
$(this).addClass('ui-btn-active');
|
|
||||||
const value = this.tagName == 'A' ? this.getAttribute('data-value') : this.value;
|
|
||||||
const elem = $('.' + value, page);
|
|
||||||
elem.siblings('.tabContent').hide();
|
|
||||||
elem.show();
|
|
||||||
});
|
|
||||||
$('#selectDirectPlayProfileType', page).on('change', function () {
|
|
||||||
if (this.value == 'Video') {
|
|
||||||
$('#fldDirectPlayVideoCodec', page).show();
|
|
||||||
} else {
|
|
||||||
$('#fldDirectPlayVideoCodec', page).hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.value == 'Photo') {
|
|
||||||
$('#fldDirectPlayAudioCodec', page).hide();
|
|
||||||
} else {
|
|
||||||
$('#fldDirectPlayAudioCodec', page).show();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$('#selectTranscodingProfileType', page).on('change', function () {
|
|
||||||
if (this.value == 'Video') {
|
|
||||||
$('#fldTranscodingVideoCodec', page).show();
|
|
||||||
$('#fldTranscodingProtocol', page).show();
|
|
||||||
$('#fldEnableMpegtsM2TsMode', page).show();
|
|
||||||
} else {
|
|
||||||
$('#fldTranscodingVideoCodec', page).hide();
|
|
||||||
$('#fldTranscodingProtocol', page).hide();
|
|
||||||
$('#fldEnableMpegtsM2TsMode', page).hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.value == 'Photo') {
|
|
||||||
$('#fldTranscodingAudioCodec', page).hide();
|
|
||||||
$('#fldEstimateContentLength', page).hide();
|
|
||||||
$('#fldReportByteRangeRequests', page).hide();
|
|
||||||
} else {
|
|
||||||
$('#fldTranscodingAudioCodec', page).show();
|
|
||||||
$('#fldEstimateContentLength', page).show();
|
|
||||||
$('#fldReportByteRangeRequests', page).show();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$('#selectResponseProfileType', page).on('change', function () {
|
|
||||||
if (this.value == 'Video') {
|
|
||||||
$('#fldResponseProfileVideoCodec', page).show();
|
|
||||||
} else {
|
|
||||||
$('#fldResponseProfileVideoCodec', page).hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.value == 'Photo') {
|
|
||||||
$('#fldResponseProfileAudioCodec', page).hide();
|
|
||||||
} else {
|
|
||||||
$('#fldResponseProfileAudioCodec', page).show();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$('.btnAddDirectPlayProfile', page).on('click', function () {
|
|
||||||
editDirectPlayProfile(page);
|
|
||||||
});
|
|
||||||
$('.btnAddTranscodingProfile', page).on('click', function () {
|
|
||||||
editTranscodingProfile(page);
|
|
||||||
});
|
|
||||||
$('.btnAddContainerProfile', page).on('click', function () {
|
|
||||||
editContainerProfile(page);
|
|
||||||
});
|
|
||||||
$('.btnAddCodecProfile', page).on('click', function () {
|
|
||||||
editCodecProfile(page);
|
|
||||||
});
|
|
||||||
$('.btnAddResponseProfile', page).on('click', function () {
|
|
||||||
editResponseProfile(page);
|
|
||||||
});
|
|
||||||
$('.btnAddIdentificationHttpHeader', page).on('click', function () {
|
|
||||||
editIdentificationHeader(page);
|
|
||||||
});
|
|
||||||
$('.btnAddXmlDocumentAttribute', page).on('click', function () {
|
|
||||||
editXmlDocumentAttribute(page);
|
|
||||||
});
|
|
||||||
$('.btnAddSubtitleProfile', page).on('click', function () {
|
|
||||||
editSubtitleProfile(page);
|
|
||||||
});
|
|
||||||
$('.dlnaProfileForm').off('submit', DlnaProfilePage.onSubmit).on('submit', DlnaProfilePage.onSubmit);
|
|
||||||
$('.editDirectPlayProfileForm').off('submit', DlnaProfilePage.onDirectPlayFormSubmit).on('submit', DlnaProfilePage.onDirectPlayFormSubmit);
|
|
||||||
$('.transcodingProfileForm').off('submit', DlnaProfilePage.onTranscodingProfileFormSubmit).on('submit', DlnaProfilePage.onTranscodingProfileFormSubmit);
|
|
||||||
$('.containerProfileForm').off('submit', DlnaProfilePage.onContainerProfileFormSubmit).on('submit', DlnaProfilePage.onContainerProfileFormSubmit);
|
|
||||||
$('.codecProfileForm').off('submit', DlnaProfilePage.onCodecProfileFormSubmit).on('submit', DlnaProfilePage.onCodecProfileFormSubmit);
|
|
||||||
$('.editResponseProfileForm').off('submit', DlnaProfilePage.onResponseProfileFormSubmit).on('submit', DlnaProfilePage.onResponseProfileFormSubmit);
|
|
||||||
$('.identificationHeaderForm').off('submit', DlnaProfilePage.onIdentificationHeaderFormSubmit).on('submit', DlnaProfilePage.onIdentificationHeaderFormSubmit);
|
|
||||||
$('.xmlAttributeForm').off('submit', DlnaProfilePage.onXmlAttributeFormSubmit).on('submit', DlnaProfilePage.onXmlAttributeFormSubmit);
|
|
||||||
$('.subtitleProfileForm').off('submit', DlnaProfilePage.onSubtitleProfileFormSubmit).on('submit', DlnaProfilePage.onSubtitleProfileFormSubmit);
|
|
||||||
}).on('pageshow', '#dlnaProfilePage', function () {
|
|
||||||
const page = this;
|
|
||||||
$('#radioInfo', page).trigger('click');
|
|
||||||
loadProfile(page);
|
|
||||||
});
|
|
||||||
window.DlnaProfilePage = {
|
|
||||||
onSubmit: function () {
|
|
||||||
loading.show();
|
|
||||||
saveProfile($(this).parents('.page'), currentProfile);
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
onDirectPlayFormSubmit: function () {
|
|
||||||
saveDirectPlayProfile($(this).parents('.page'));
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
onTranscodingProfileFormSubmit: function () {
|
|
||||||
saveTranscodingProfile($(this).parents('.page'));
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
onContainerProfileFormSubmit: function () {
|
|
||||||
saveContainerProfile($(this).parents('.page'));
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
onCodecProfileFormSubmit: function () {
|
|
||||||
saveCodecProfile($(this).parents('.page'));
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
onResponseProfileFormSubmit: function () {
|
|
||||||
saveResponseProfile($(this).parents('.page'));
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
onIdentificationHeaderFormSubmit: function () {
|
|
||||||
saveIdentificationHeader($(this).parents('.page'));
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
onXmlAttributeFormSubmit: function () {
|
|
||||||
saveXmlDocumentAttribute($(this).parents('.page'));
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
onSubtitleProfileFormSubmit: function () {
|
|
||||||
saveSubtitleProfile($(this).parents('.page'));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
<div id="dlnaProfilesPage" data-role="page" class="page type-interior dlnaPage withTabs">
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="content-primary">
|
|
||||||
|
|
||||||
<div class="readOnlyContent">
|
|
||||||
|
|
||||||
<div class="verticalSection verticalSection-extrabottompadding">
|
|
||||||
<div class="sectionTitleContainer flex align-items-center">
|
|
||||||
<h2 class="sectionTitle">${HeaderCustomDlnaProfiles}</h2>
|
|
||||||
<a is="emby-linkbutton" href="#/dashboard/dlna/profiles/edit" class="fab submit" style="margin:0 0 0 1em">
|
|
||||||
<span class="material-icons add" aria-hidden="true"></span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>${CustomDlnaProfilesHelp}</p>
|
|
||||||
<div class="customProfiles"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="verticalSection">
|
|
||||||
<div class="sectionTitleContainer flex align-items-center">
|
|
||||||
<h2 class="sectionTitle">${HeaderSystemDlnaProfiles}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>${SystemDlnaProfilesHelp}</p>
|
|
||||||
<div class="systemProfiles"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,93 +0,0 @@
|
||||||
import escapeHtml from 'escape-html';
|
|
||||||
import 'jquery';
|
|
||||||
import globalize from '../../../scripts/globalize';
|
|
||||||
import loading from '../../../components/loading/loading';
|
|
||||||
import libraryMenu from '../../../scripts/libraryMenu';
|
|
||||||
import '../../../components/listview/listview.scss';
|
|
||||||
import '../../../elements/emby-button/emby-button';
|
|
||||||
import confirm from '../../../components/confirm/confirm';
|
|
||||||
|
|
||||||
function loadProfiles(page) {
|
|
||||||
loading.show();
|
|
||||||
ApiClient.getJSON(ApiClient.getUrl('Dlna/ProfileInfos')).then(function (result) {
|
|
||||||
renderUserProfiles(page, result);
|
|
||||||
renderSystemProfiles(page, result);
|
|
||||||
loading.hide();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderUserProfiles(page, profiles) {
|
|
||||||
renderProfiles(page, page.querySelector('.customProfiles'), profiles.filter(function (p) {
|
|
||||||
return p.Type == 'User';
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSystemProfiles(page, profiles) {
|
|
||||||
renderProfiles(page, page.querySelector('.systemProfiles'), profiles.filter(function (p) {
|
|
||||||
return p.Type == 'System';
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderProfiles(page, element, profiles) {
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
if (profiles.length) {
|
|
||||||
html += '<div class="paperList">';
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0, length = profiles.length; i < length; i++) {
|
|
||||||
const profile = profiles[i];
|
|
||||||
html += '<div class="listItem listItem-border">';
|
|
||||||
html += '<span class="listItemIcon material-icons live_tv" aria-hidden="true"></span>';
|
|
||||||
html += '<div class="listItemBody two-line">';
|
|
||||||
html += "<a is='emby-linkbutton' style='padding:0;margin:0;' data-ripple='false' class='clearLink' href='#/dashboard/dlna/profiles/edit?id=" + profile.Id + "'>";
|
|
||||||
html += '<div>' + escapeHtml(profile.Name) + '</div>';
|
|
||||||
html += '</a>';
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
if (profile.Type == 'User') {
|
|
||||||
html += '<button type="button" is="paper-icon-button-light" class="btnDeleteProfile" data-profileid="' + profile.Id + '" title="' + globalize.translate('Delete') + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profiles.length) {
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
element.innerHTML = html;
|
|
||||||
$('.btnDeleteProfile', element).on('click', function () {
|
|
||||||
const id = this.getAttribute('data-profileid');
|
|
||||||
deleteProfile(page, id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteProfile(page, id) {
|
|
||||||
confirm(globalize.translate('MessageConfirmProfileDeletion'), globalize.translate('HeaderConfirmProfileDeletion')).then(function () {
|
|
||||||
loading.show();
|
|
||||||
ApiClient.ajax({
|
|
||||||
type: 'DELETE',
|
|
||||||
url: ApiClient.getUrl('Dlna/Profiles/' + id)
|
|
||||||
}).then(function () {
|
|
||||||
loading.hide();
|
|
||||||
loadProfiles(page);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTabs() {
|
|
||||||
return [{
|
|
||||||
href: '#/dashboard/dlna',
|
|
||||||
name: globalize.translate('Settings')
|
|
||||||
}, {
|
|
||||||
href: '#/dashboard/dlna/profiles',
|
|
||||||
name: globalize.translate('TabProfiles')
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).on('pageshow', '#dlnaProfilesPage', function () {
|
|
||||||
libraryMenu.setTabs('dlna', 1, getTabs);
|
|
||||||
loadProfiles(this);
|
|
||||||
});
|
|
||||||
|
|
|
@ -1,69 +0,0 @@
|
||||||
<div id="dlnaSettingsPage" data-role="page" class="page type-interior withTabs">
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="content-primary">
|
|
||||||
|
|
||||||
<form class="dlnaSettingsForm">
|
|
||||||
|
|
||||||
<div class="verticalSection">
|
|
||||||
<div class="sectionTitleContainer flex align-items-center">
|
|
||||||
<h2 class="sectionTitle">${Settings}</h2>
|
|
||||||
<a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton" target="_blank" href="https://jellyfin.org/docs/general/networking/dlna">${Help}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" is="emby-checkbox" id="chkEnablePlayTo" />
|
|
||||||
<span>${LabelEnableDlnaPlayTo}</span>
|
|
||||||
</label>
|
|
||||||
<div class="fieldDescription checkboxFieldDescription">${LabelEnableDlnaPlayToHelp}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" is="emby-checkbox" id="chkEnableDlnaDebugLogging" />
|
|
||||||
<span>${LabelEnableDlnaDebugLogging}</span>
|
|
||||||
</label>
|
|
||||||
<div class="fieldDescription checkboxFieldDescription">${LabelEnableDlnaDebugLoggingHelp}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="number" id="txtClientDiscoveryInterval" min="1" max="300" label="${LabelEnableDlnaClientDiscoveryInterval}" />
|
|
||||||
<div class="fieldDescription">${LabelEnableDlnaClientDiscoveryIntervalHelp}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" is="emby-checkbox" id="chkEnableServer" />
|
|
||||||
<span>${LabelEnableDlnaServer}</span>
|
|
||||||
</label>
|
|
||||||
<div class="fieldDescription checkboxFieldDescription">${LabelEnableDlnaServerHelp}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" is="emby-checkbox" id="chkBlastAliveMessages" />
|
|
||||||
<span>${LabelEnableBlastAliveMessages}</span>
|
|
||||||
</label>
|
|
||||||
<div class="fieldDescription checkboxFieldDescription">${LabelEnableBlastAliveMessagesHelp}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inputContainer">
|
|
||||||
<input is="emby-input" type="number" id="txtBlastInterval" min="1" max="3600" label="${LabelBlastMessageInterval}" />
|
|
||||||
<div class="fieldDescription">${LabelBlastMessageIntervalHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="selectContainer">
|
|
||||||
<select is="emby-select" id="selectUser" data-mini="true" label="${LabelDefaultUser}"></select>
|
|
||||||
<div class="fieldDescription">${LabelDefaultUserHelp}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block">
|
|
||||||
<span>${Save}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,60 +0,0 @@
|
||||||
import escapeHtml from 'escape-html';
|
|
||||||
import 'jquery';
|
|
||||||
import loading from '../../../components/loading/loading';
|
|
||||||
import libraryMenu from '../../../scripts/libraryMenu';
|
|
||||||
import globalize from '../../../scripts/globalize';
|
|
||||||
import Dashboard from '../../../utils/dashboard';
|
|
||||||
|
|
||||||
function loadPage(page, config, users) {
|
|
||||||
page.querySelector('#chkEnablePlayTo').checked = config.EnablePlayTo;
|
|
||||||
page.querySelector('#chkEnableDlnaDebugLogging').checked = config.EnableDebugLog;
|
|
||||||
$('#txtClientDiscoveryInterval', page).val(config.ClientDiscoveryIntervalSeconds);
|
|
||||||
$('#chkEnableServer', page).prop('checked', config.EnableServer);
|
|
||||||
$('#chkBlastAliveMessages', page).prop('checked', config.BlastAliveMessages);
|
|
||||||
$('#txtBlastInterval', page).val(config.BlastAliveMessageIntervalSeconds);
|
|
||||||
const usersHtml = users.map(function (u) {
|
|
||||||
return '<option value="' + u.Id + '">' + escapeHtml(u.Name) + '</option>';
|
|
||||||
}).join('');
|
|
||||||
$('#selectUser', page).html(usersHtml).val(config.DefaultUserId || '');
|
|
||||||
loading.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSubmit() {
|
|
||||||
loading.show();
|
|
||||||
const form = this;
|
|
||||||
ApiClient.getNamedConfiguration('dlna').then(function (config) {
|
|
||||||
config.EnablePlayTo = form.querySelector('#chkEnablePlayTo').checked;
|
|
||||||
config.EnableDebugLog = form.querySelector('#chkEnableDlnaDebugLogging').checked;
|
|
||||||
config.ClientDiscoveryIntervalSeconds = $('#txtClientDiscoveryInterval', form).val();
|
|
||||||
config.EnableServer = $('#chkEnableServer', form).is(':checked');
|
|
||||||
config.BlastAliveMessages = $('#chkBlastAliveMessages', form).is(':checked');
|
|
||||||
config.BlastAliveMessageIntervalSeconds = $('#txtBlastInterval', form).val();
|
|
||||||
config.DefaultUserId = $('#selectUser', form).val();
|
|
||||||
ApiClient.updateNamedConfiguration('dlna', config).then(Dashboard.processServerConfigurationUpdateResult);
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTabs() {
|
|
||||||
return [{
|
|
||||||
href: '#/dashboard/dlna',
|
|
||||||
name: globalize.translate('Settings')
|
|
||||||
}, {
|
|
||||||
href: '#/dashboard/dlna/profiles',
|
|
||||||
name: globalize.translate('TabProfiles')
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).on('pageinit', '#dlnaSettingsPage', function () {
|
|
||||||
$('.dlnaSettingsForm').off('submit', onSubmit).on('submit', onSubmit);
|
|
||||||
}).on('pageshow', '#dlnaSettingsPage', function () {
|
|
||||||
libraryMenu.setTabs('dlna', 0, getTabs);
|
|
||||||
loading.show();
|
|
||||||
const page = this;
|
|
||||||
const promise1 = ApiClient.getNamedConfiguration('dlna');
|
|
||||||
const promise2 = ApiClient.getUsers();
|
|
||||||
Promise.all([promise1, promise2]).then(function (responses) {
|
|
||||||
loadPage(page, responses[0], responses[1]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
@ -36,7 +36,7 @@ function handleConnectionResult(page, result) {
|
||||||
|
|
||||||
function submitServer(page) {
|
function submitServer(page) {
|
||||||
loading.show();
|
loading.show();
|
||||||
const host = page.querySelector('#txtServerHost').value;
|
const host = page.querySelector('#txtServerHost').value.replace(/\/+$/, '');
|
||||||
ServerConnections.connectToAddress(host, {
|
ServerConnections.connectToAddress(host, {
|
||||||
enableAutoLogin: appSettings.enableAutoLogin()
|
enableAutoLogin: appSettings.enableAutoLogin()
|
||||||
}).then(function(result) {
|
}).then(function(result) {
|
||||||
|
|
92
src/elements/emby-itemrefreshindicator/RefreshIndicator.tsx
Normal file
92
src/elements/emby-itemrefreshindicator/RefreshIndicator.tsx
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
import React, { type FC, useCallback, useEffect, useState } from 'react';
|
||||||
|
import Events, { Event } from 'utils/events';
|
||||||
|
import serverNotifications from 'scripts/serverNotifications';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import CircularProgress, {
|
||||||
|
CircularProgressProps
|
||||||
|
} from '@mui/material/CircularProgress';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { toPercent } from 'utils/number';
|
||||||
|
import { getCurrentDateTimeLocale } from 'scripts/globalize';
|
||||||
|
import type { ItemDto } from 'types/base/models/item-dto';
|
||||||
|
|
||||||
|
function CircularProgressWithLabel(
|
||||||
|
props: CircularProgressProps & { value: number }
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||||
|
<CircularProgress variant='determinate' {...props} />
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
position: 'absolute',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant='caption'
|
||||||
|
component='div'
|
||||||
|
color='text.secondary'
|
||||||
|
>
|
||||||
|
{toPercent(props.value / 100, getCurrentDateTimeLocale())}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshIndicatorProps {
|
||||||
|
item: ItemDto;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RefreshIndicator: FC<RefreshIndicatorProps> = ({ item, className }) => {
|
||||||
|
const [progress, setProgress] = useState(item.RefreshProgress || 0);
|
||||||
|
|
||||||
|
const onRefreshProgress = useCallback((_e: Event, apiClient, info) => {
|
||||||
|
if (info.ItemId === item?.Id) {
|
||||||
|
setProgress(parseFloat(info.Progress));
|
||||||
|
}
|
||||||
|
}, [item?.Id]);
|
||||||
|
|
||||||
|
const unbindEvents = useCallback(() => {
|
||||||
|
Events.off(serverNotifications, 'RefreshProgress', onRefreshProgress);
|
||||||
|
}, [onRefreshProgress]);
|
||||||
|
|
||||||
|
const bindEvents = useCallback(() => {
|
||||||
|
unbindEvents();
|
||||||
|
|
||||||
|
if (item?.Id) {
|
||||||
|
Events.on(serverNotifications, 'RefreshProgress', onRefreshProgress);
|
||||||
|
}
|
||||||
|
}, [item?.Id, onRefreshProgress, unbindEvents]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bindEvents();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unbindEvents();
|
||||||
|
};
|
||||||
|
}, [bindEvents, item.Id, unbindEvents]);
|
||||||
|
|
||||||
|
const progressringClass = classNames(
|
||||||
|
'progressring',
|
||||||
|
className,
|
||||||
|
{ 'hide': !progress || progress >= 100 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={progressringClass}>
|
||||||
|
<CircularProgressWithLabel value={Math.floor(progress)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RefreshIndicator;
|
|
@ -1,13 +1,11 @@
|
||||||
import type {
|
import type {
|
||||||
LibraryUpdateInfo,
|
LibraryUpdateInfo
|
||||||
SeriesTimerInfoDto,
|
|
||||||
TimerInfoDto,
|
|
||||||
UserItemDataDto
|
|
||||||
} from '@jellyfin/sdk/lib/generated-client';
|
} from '@jellyfin/sdk/lib/generated-client';
|
||||||
import React, { FC, useCallback, useEffect, useRef } from 'react';
|
import React, { type FC, useCallback, useEffect, useRef } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box } from '@mui/material';
|
import Box from '@mui/material/Box';
|
||||||
import Sortable from 'sortablejs';
|
import Sortable from 'sortablejs';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { usePlaylistsMoveItemMutation } from 'hooks/useFetchItems';
|
import { usePlaylistsMoveItemMutation } from 'hooks/useFetchItems';
|
||||||
import Events, { Event } from 'utils/events';
|
import Events, { Event } from 'utils/events';
|
||||||
import serverNotifications from 'scripts/serverNotifications';
|
import serverNotifications from 'scripts/serverNotifications';
|
||||||
|
@ -21,7 +19,7 @@ import itemShortcuts from 'components/shortcuts';
|
||||||
import MultiSelect from 'components/multiSelect/multiSelect';
|
import MultiSelect from 'components/multiSelect/multiSelect';
|
||||||
import loading from 'components/loading/loading';
|
import loading from 'components/loading/loading';
|
||||||
import focusManager from 'components/focusManager';
|
import focusManager from 'components/focusManager';
|
||||||
import { ParentId } from 'types/library';
|
import type { ParentId } from 'types/library';
|
||||||
|
|
||||||
function disableEvent(e: MouseEvent) {
|
function disableEvent(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -40,11 +38,11 @@ interface ItemsContainerProps {
|
||||||
isContextMenuEnabled?: boolean;
|
isContextMenuEnabled?: boolean;
|
||||||
isMultiSelectEnabled?: boolean;
|
isMultiSelectEnabled?: boolean;
|
||||||
isDragreOrderEnabled?: boolean;
|
isDragreOrderEnabled?: boolean;
|
||||||
dataMonitor?: string;
|
eventsToMonitor?: string[];
|
||||||
parentId?: ParentId;
|
parentId?: ParentId;
|
||||||
reloadItems?: () => void;
|
reloadItems?: () => void;
|
||||||
getItemsHtml?: () => string;
|
getItemsHtml?: () => string;
|
||||||
children?: React.ReactNode;
|
queryKey?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const ItemsContainer: FC<ItemsContainerProps> = ({
|
const ItemsContainer: FC<ItemsContainerProps> = ({
|
||||||
|
@ -52,12 +50,14 @@ const ItemsContainer: FC<ItemsContainerProps> = ({
|
||||||
isContextMenuEnabled,
|
isContextMenuEnabled,
|
||||||
isMultiSelectEnabled,
|
isMultiSelectEnabled,
|
||||||
isDragreOrderEnabled,
|
isDragreOrderEnabled,
|
||||||
dataMonitor,
|
eventsToMonitor = [],
|
||||||
parentId,
|
parentId,
|
||||||
|
queryKey,
|
||||||
reloadItems,
|
reloadItems,
|
||||||
getItemsHtml,
|
getItemsHtml,
|
||||||
children
|
children
|
||||||
}) => {
|
}) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { mutateAsync: playlistsMoveItemMutation } = usePlaylistsMoveItemMutation();
|
const { mutateAsync: playlistsMoveItemMutation } = usePlaylistsMoveItemMutation();
|
||||||
const itemsContainerRef = useRef<HTMLDivElement>(null);
|
const itemsContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const multiSelectref = useRef<MultiSelect | null>(null);
|
const multiSelectref = useRef<MultiSelect | null>(null);
|
||||||
|
@ -172,6 +172,14 @@ const ItemsContainer: FC<ItemsContainerProps> = ({
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const invalidateQueries = useCallback(async () => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey,
|
||||||
|
type: 'all',
|
||||||
|
refetchType: 'active'
|
||||||
|
});
|
||||||
|
}, [queryClient, queryKey]);
|
||||||
|
|
||||||
const notifyRefreshNeeded = useCallback(
|
const notifyRefreshNeeded = useCallback(
|
||||||
(isInForeground: boolean) => {
|
(isInForeground: boolean) => {
|
||||||
if (!reloadItems) return;
|
if (!reloadItems) return;
|
||||||
|
@ -184,144 +192,37 @@ const ItemsContainer: FC<ItemsContainerProps> = ({
|
||||||
[reloadItems]
|
[reloadItems]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getEventsToMonitor = useCallback(() => {
|
const onUserDataChanged = useCallback(async () => {
|
||||||
const monitor = dataMonitor;
|
await invalidateQueries();
|
||||||
if (monitor) {
|
|
||||||
return monitor.split(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}, [dataMonitor]);
|
|
||||||
|
|
||||||
const onUserDataChanged = useCallback(
|
|
||||||
(_e: Event, userData: UserItemDataDto) => {
|
|
||||||
const itemsContainer = itemsContainerRef.current as HTMLDivElement;
|
|
||||||
|
|
||||||
import('../../components/cardbuilder/cardBuilder')
|
|
||||||
.then((cardBuilder) => {
|
|
||||||
cardBuilder.onUserDataChanged(userData, itemsContainer);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(
|
|
||||||
'[onUserDataChanged] failed to load onUserData Changed',
|
|
||||||
err
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const eventsToMonitor = getEventsToMonitor();
|
|
||||||
if (
|
|
||||||
eventsToMonitor.indexOf('markfavorite') !== -1
|
|
||||||
|| eventsToMonitor.indexOf('markplayed') !== -1
|
|
||||||
) {
|
|
||||||
notifyRefreshNeeded(false);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[getEventsToMonitor, notifyRefreshNeeded]
|
[invalidateQueries]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onTimerCreated = useCallback(
|
const onTimerCreated = useCallback(async () => {
|
||||||
(_e: Event, data: TimerInfoDto) => {
|
await invalidateQueries();
|
||||||
const itemsContainer = itemsContainerRef.current as HTMLDivElement;
|
|
||||||
const eventsToMonitor = getEventsToMonitor();
|
|
||||||
if (eventsToMonitor.indexOf('timers') !== -1) {
|
|
||||||
notifyRefreshNeeded(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const programId = data.ProgramId;
|
|
||||||
// This could be null, not supported by all tv providers
|
|
||||||
const newTimerId = data.Id;
|
|
||||||
if (programId && newTimerId) {
|
|
||||||
import('../../components/cardbuilder/cardBuilder')
|
|
||||||
.then((cardBuilder) => {
|
|
||||||
cardBuilder.onTimerCreated(
|
|
||||||
programId,
|
|
||||||
newTimerId,
|
|
||||||
itemsContainer
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(
|
|
||||||
'[onTimerCreated] failed to load onTimer Created',
|
|
||||||
err
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[getEventsToMonitor, notifyRefreshNeeded]
|
[invalidateQueries]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSeriesTimerCreated = useCallback(() => {
|
const onSeriesTimerCreated = useCallback(async () => {
|
||||||
const eventsToMonitor = getEventsToMonitor();
|
await invalidateQueries();
|
||||||
if (eventsToMonitor.indexOf('seriestimers') !== -1) {
|
}, [invalidateQueries]);
|
||||||
notifyRefreshNeeded(false);
|
|
||||||
}
|
|
||||||
}, [getEventsToMonitor, notifyRefreshNeeded]);
|
|
||||||
|
|
||||||
const onTimerCancelled = useCallback(
|
const onTimerCancelled = useCallback(async () => {
|
||||||
(_e: Event, data: TimerInfoDto) => {
|
await invalidateQueries();
|
||||||
const itemsContainer = itemsContainerRef.current as HTMLDivElement;
|
|
||||||
const eventsToMonitor = getEventsToMonitor();
|
|
||||||
if (eventsToMonitor.indexOf('timers') !== -1) {
|
|
||||||
notifyRefreshNeeded(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timerId = data.Id;
|
|
||||||
|
|
||||||
if (timerId) {
|
|
||||||
import('../../components/cardbuilder/cardBuilder')
|
|
||||||
.then((cardBuilder) => {
|
|
||||||
cardBuilder.onTimerCancelled(timerId, itemsContainer);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(
|
|
||||||
'[onTimerCancelled] failed to load onTimer Cancelled',
|
|
||||||
err
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[getEventsToMonitor, notifyRefreshNeeded]
|
[invalidateQueries]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSeriesTimerCancelled = useCallback(
|
const onSeriesTimerCancelled = useCallback(async () => {
|
||||||
(_e: Event, data: SeriesTimerInfoDto) => {
|
await invalidateQueries();
|
||||||
const itemsContainer = itemsContainerRef.current as HTMLDivElement;
|
|
||||||
const eventsToMonitor = getEventsToMonitor();
|
|
||||||
if (eventsToMonitor.indexOf('seriestimers') !== -1) {
|
|
||||||
notifyRefreshNeeded(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelledTimerId = data.Id;
|
|
||||||
|
|
||||||
if (cancelledTimerId) {
|
|
||||||
import('../../components/cardbuilder/cardBuilder')
|
|
||||||
.then((cardBuilder) => {
|
|
||||||
cardBuilder.onSeriesTimerCancelled(
|
|
||||||
cancelledTimerId,
|
|
||||||
itemsContainer
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(
|
|
||||||
'[onSeriesTimerCancelled] failed to load onSeriesTimer Cancelled',
|
|
||||||
err
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[getEventsToMonitor, notifyRefreshNeeded]
|
[invalidateQueries]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onLibraryChanged = useCallback(
|
const onLibraryChanged = useCallback(
|
||||||
(_e: Event, data: LibraryUpdateInfo) => {
|
(_e: Event, apiClient, data: LibraryUpdateInfo) => {
|
||||||
const eventsToMonitor = getEventsToMonitor();
|
if (eventsToMonitor.includes('seriestimers') || eventsToMonitor.includes('timers')) {
|
||||||
if (
|
|
||||||
eventsToMonitor.indexOf('seriestimers') !== -1
|
|
||||||
|| eventsToMonitor.indexOf('timers') !== -1
|
|
||||||
) {
|
|
||||||
// yes this is an assumption
|
// yes this is an assumption
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -348,32 +249,31 @@ const ItemsContainer: FC<ItemsContainerProps> = ({
|
||||||
|
|
||||||
notifyRefreshNeeded(false);
|
notifyRefreshNeeded(false);
|
||||||
},
|
},
|
||||||
[getEventsToMonitor, notifyRefreshNeeded, parentId]
|
[eventsToMonitor, notifyRefreshNeeded, parentId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPlaybackStopped = useCallback(
|
const onPlaybackStopped = useCallback(
|
||||||
(_e: Event, stopInfo) => {
|
(_e: Event, apiClient, stopInfo) => {
|
||||||
const state = stopInfo.state;
|
const state = stopInfo.state;
|
||||||
|
|
||||||
const eventsToMonitor = getEventsToMonitor();
|
|
||||||
if (
|
if (
|
||||||
state.NowPlayingItem
|
state.NowPlayingItem
|
||||||
&& state.NowPlayingItem.MediaType === 'Video'
|
&& state.NowPlayingItem.MediaType === 'Video'
|
||||||
) {
|
) {
|
||||||
if (eventsToMonitor.indexOf('videoplayback') !== -1) {
|
if (eventsToMonitor.includes('videoplayback')) {
|
||||||
notifyRefreshNeeded(true);
|
notifyRefreshNeeded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
state.NowPlayingItem
|
state.NowPlayingItem
|
||||||
&& state.NowPlayingItem.MediaType === 'Audio'
|
&& state.NowPlayingItem.MediaType === 'Audio'
|
||||||
&& eventsToMonitor.indexOf('audioplayback') !== -1
|
&& eventsToMonitor.includes('videoplayback')
|
||||||
) {
|
) {
|
||||||
notifyRefreshNeeded(true);
|
notifyRefreshNeeded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getEventsToMonitor, notifyRefreshNeeded]
|
[eventsToMonitor, notifyRefreshNeeded]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setFocus = useCallback(
|
const setFocus = useCallback(
|
||||||
|
@ -418,9 +318,8 @@ const ItemsContainer: FC<ItemsContainerProps> = ({
|
||||||
|
|
||||||
if (getItemsHtml) {
|
if (getItemsHtml) {
|
||||||
itemsContainer.innerHTML = getItemsHtml();
|
itemsContainer.innerHTML = getItemsHtml();
|
||||||
}
|
|
||||||
|
|
||||||
imageLoader.lazyChildren(itemsContainer);
|
imageLoader.lazyChildren(itemsContainer);
|
||||||
|
}
|
||||||
|
|
||||||
if (hasActiveElement) {
|
if (hasActiveElement) {
|
||||||
setFocus(itemsContainer, focusId);
|
setFocus(itemsContainer, focusId);
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client';
|
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||||
import React, { FC, useCallback } from 'react';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import React, { type FC, useCallback } from 'react';
|
||||||
import CheckIcon from '@mui/icons-material/Check';
|
import CheckIcon from '@mui/icons-material/Check';
|
||||||
import { IconButton } from '@mui/material';
|
import { IconButton } from '@mui/material';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
@ -10,28 +11,30 @@ interface PlayedButtonProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
isPlayed : boolean | undefined;
|
isPlayed : boolean | undefined;
|
||||||
itemId: string | null | undefined;
|
itemId: string | null | undefined;
|
||||||
itemType: string | null | undefined
|
itemType: string | null | undefined,
|
||||||
|
queryKey?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlayedButton: FC<PlayedButtonProps> = ({
|
const PlayedButton: FC<PlayedButtonProps> = ({
|
||||||
className,
|
className,
|
||||||
isPlayed = false,
|
isPlayed = false,
|
||||||
itemId,
|
itemId,
|
||||||
itemType
|
itemType,
|
||||||
|
queryKey
|
||||||
}) => {
|
}) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { mutateAsync: togglePlayedMutation } = useTogglePlayedMutation();
|
const { mutateAsync: togglePlayedMutation } = useTogglePlayedMutation();
|
||||||
const [playedState, setPlayedState] = React.useState<boolean>(isPlayed);
|
|
||||||
|
|
||||||
const getTitle = useCallback(() => {
|
const getTitle = useCallback(() => {
|
||||||
let buttonTitle;
|
let buttonTitle;
|
||||||
if (itemType !== BaseItemKind.AudioBook) {
|
if (itemType !== BaseItemKind.AudioBook) {
|
||||||
buttonTitle = playedState ? globalize.translate('Watched') : globalize.translate('MarkPlayed');
|
buttonTitle = isPlayed ? globalize.translate('Watched') : globalize.translate('MarkPlayed');
|
||||||
} else {
|
} else {
|
||||||
buttonTitle = playedState ? globalize.translate('Played') : globalize.translate('MarkPlayed');
|
buttonTitle = isPlayed ? globalize.translate('Played') : globalize.translate('MarkPlayed');
|
||||||
}
|
}
|
||||||
|
|
||||||
return buttonTitle;
|
return buttonTitle;
|
||||||
}, [playedState, itemType]);
|
}, [itemType, isPlayed]);
|
||||||
|
|
||||||
const onClick = useCallback(async () => {
|
const onClick = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -39,23 +42,29 @@ const PlayedButton: FC<PlayedButtonProps> = ({
|
||||||
throw new Error('Item has no Id');
|
throw new Error('Item has no Id');
|
||||||
}
|
}
|
||||||
|
|
||||||
const _isPlayed = await togglePlayedMutation({
|
await togglePlayedMutation({
|
||||||
itemId,
|
itemId,
|
||||||
playedState
|
isPlayed
|
||||||
|
},
|
||||||
|
{ onSuccess: async() => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey,
|
||||||
|
type: 'all',
|
||||||
|
refetchType: 'active'
|
||||||
});
|
});
|
||||||
setPlayedState(!!_isPlayed);
|
} });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
}, [playedState, itemId, togglePlayedMutation]);
|
}, [itemId, togglePlayedMutation, isPlayed, queryClient, queryKey]);
|
||||||
|
|
||||||
const btnClass = classNames(
|
const btnClass = classNames(
|
||||||
className,
|
className,
|
||||||
{ 'playstatebutton-played': playedState }
|
{ 'playstatebutton-played': isPlayed }
|
||||||
);
|
);
|
||||||
|
|
||||||
const iconClass = classNames(
|
const iconClass = classNames(
|
||||||
{ 'playstatebutton-icon-played': playedState }
|
{ 'playstatebutton-icon-played': isPlayed }
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|
79
src/elements/emby-progressbar/AutoTimeProgressBar.tsx
Normal file
79
src/elements/emby-progressbar/AutoTimeProgressBar.tsx
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import React, { type FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import LinearProgress, { linearProgressClasses } from '@mui/material/LinearProgress';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
import type { ProgressOptions } from 'types/progressOptions';
|
||||||
|
|
||||||
|
interface AutoTimeProgressBarProps {
|
||||||
|
pct: number;
|
||||||
|
starTtime: number;
|
||||||
|
endTtime: number;
|
||||||
|
isRecording: boolean;
|
||||||
|
dataAutoMode?: string;
|
||||||
|
progressOptions?: ProgressOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AutoTimeProgressBar: FC<AutoTimeProgressBarProps> = ({
|
||||||
|
pct,
|
||||||
|
dataAutoMode,
|
||||||
|
isRecording,
|
||||||
|
starTtime,
|
||||||
|
endTtime,
|
||||||
|
progressOptions
|
||||||
|
}) => {
|
||||||
|
const [progress, setProgress] = useState(pct);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const onAutoTimeProgress = useCallback(() => {
|
||||||
|
const start = parseInt(starTtime.toString(), 10);
|
||||||
|
const end = parseInt(endTtime.toString(), 10);
|
||||||
|
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const total = end - start;
|
||||||
|
let percentage = 100 * ((now - start) / total);
|
||||||
|
|
||||||
|
percentage = Math.min(100, percentage);
|
||||||
|
percentage = Math.max(0, percentage);
|
||||||
|
|
||||||
|
setProgress(percentage);
|
||||||
|
}, [endTtime, starTtime]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataAutoMode === 'time') {
|
||||||
|
timerRef.current = setInterval(onAutoTimeProgress, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [dataAutoMode, onAutoTimeProgress]);
|
||||||
|
|
||||||
|
const progressBarClass = classNames(
|
||||||
|
'itemLinearProgress',
|
||||||
|
progressOptions?.containerClass
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LinearProgress
|
||||||
|
className={progressBarClass}
|
||||||
|
variant='determinate'
|
||||||
|
value={progress}
|
||||||
|
sx={{
|
||||||
|
[`& .${linearProgressClasses.bar}`]: {
|
||||||
|
borderRadius: 5,
|
||||||
|
backgroundColor: isRecording ? theme.palette.error.main : theme.palette.primary.main
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AutoTimeProgressBar;
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { FC, useCallback } from 'react';
|
import React, { type FC, useCallback } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import FavoriteIcon from '@mui/icons-material/Favorite';
|
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||||
import { IconButton } from '@mui/material';
|
import { IconButton } from '@mui/material';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
@ -8,16 +9,18 @@ import globalize from 'scripts/globalize';
|
||||||
interface FavoriteButtonProps {
|
interface FavoriteButtonProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
isFavorite: boolean | undefined;
|
isFavorite: boolean | undefined;
|
||||||
itemId: string | null | undefined
|
itemId: string | null | undefined;
|
||||||
|
queryKey?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const FavoriteButton: FC<FavoriteButtonProps> = ({
|
const FavoriteButton: FC<FavoriteButtonProps> = ({
|
||||||
className,
|
className,
|
||||||
isFavorite = false,
|
isFavorite = false,
|
||||||
itemId
|
itemId,
|
||||||
|
queryKey
|
||||||
}) => {
|
}) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { mutateAsync: toggleFavoriteMutation } = useToggleFavoriteMutation();
|
const { mutateAsync: toggleFavoriteMutation } = useToggleFavoriteMutation();
|
||||||
const [favoriteState, setFavoriteState] = React.useState<boolean>(isFavorite);
|
|
||||||
|
|
||||||
const onClick = useCallback(async () => {
|
const onClick = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -25,28 +28,34 @@ const FavoriteButton: FC<FavoriteButtonProps> = ({
|
||||||
throw new Error('Item has no Id');
|
throw new Error('Item has no Id');
|
||||||
}
|
}
|
||||||
|
|
||||||
const _isFavorite = await toggleFavoriteMutation({
|
await toggleFavoriteMutation({
|
||||||
itemId,
|
itemId,
|
||||||
favoriteState
|
isFavorite
|
||||||
|
},
|
||||||
|
{ onSuccess: async() => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey,
|
||||||
|
type: 'all',
|
||||||
|
refetchType: 'active'
|
||||||
});
|
});
|
||||||
setFavoriteState(!!_isFavorite);
|
} });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
}, [favoriteState, itemId, toggleFavoriteMutation]);
|
}, [isFavorite, itemId, queryClient, queryKey, toggleFavoriteMutation]);
|
||||||
|
|
||||||
const btnClass = classNames(
|
const btnClass = classNames(
|
||||||
className,
|
className,
|
||||||
{ 'ratingbutton-withrating': favoriteState }
|
{ 'ratingbutton-withrating': isFavorite }
|
||||||
);
|
);
|
||||||
|
|
||||||
const iconClass = classNames(
|
const iconClass = classNames(
|
||||||
{ 'ratingbutton-icon-withrating': favoriteState }
|
{ 'ratingbutton-icon-withrating': isFavorite }
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
title={favoriteState ? globalize.translate('Favorite') : globalize.translate('AddToFavorites')}
|
title={isFavorite ? globalize.translate('Favorite') : globalize.translate('AddToFavorites')}
|
||||||
className={btnClass}
|
className={btnClass}
|
||||||
size='small'
|
size='small'
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { type FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import scrollerFactory from '../../libraries/scroller';
|
import scrollerFactory from '../../libraries/scroller';
|
||||||
import globalize from '../../scripts/globalize';
|
import globalize from '../../scripts/globalize';
|
||||||
import IconButton from '../emby-button/IconButton';
|
import IconButton from '../emby-button/IconButton';
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { type FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import useElementSize from 'hooks/useElementSize';
|
import useElementSize from 'hooks/useElementSize';
|
||||||
import layoutManager from '../../components/layoutManager';
|
import layoutManager from '../../components/layoutManager';
|
||||||
|
|
|
@ -376,10 +376,12 @@ export const useGetItemsViewByType = (
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
'ItemsViewByType',
|
'ItemsViewByType',
|
||||||
|
{
|
||||||
viewType,
|
viewType,
|
||||||
parentId,
|
parentId,
|
||||||
itemType,
|
itemType,
|
||||||
libraryViewSettings
|
libraryViewSettings
|
||||||
|
}
|
||||||
],
|
],
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
fetchGetItemsViewByType(
|
fetchGetItemsViewByType(
|
||||||
|
@ -526,17 +528,17 @@ export const useGetGroupsUpcomingEpisodes = (parentId: ParentId) => {
|
||||||
|
|
||||||
interface ToggleFavoriteMutationProp {
|
interface ToggleFavoriteMutationProp {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
favoriteState: boolean
|
isFavorite: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchUpdateFavoriteStatus = async (
|
const fetchUpdateFavoriteStatus = async (
|
||||||
currentApi: JellyfinApiContext,
|
currentApi: JellyfinApiContext,
|
||||||
itemId: string,
|
itemId: string,
|
||||||
favoriteState: boolean
|
isFavorite: boolean
|
||||||
) => {
|
) => {
|
||||||
const { api, user } = currentApi;
|
const { api, user } = currentApi;
|
||||||
if (api && user?.Id) {
|
if (api && user?.Id) {
|
||||||
if (favoriteState) {
|
if (isFavorite) {
|
||||||
const response = await getUserLibraryApi(api).unmarkFavoriteItem({
|
const response = await getUserLibraryApi(api).unmarkFavoriteItem({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
itemId: itemId
|
itemId: itemId
|
||||||
|
@ -555,24 +557,24 @@ const fetchUpdateFavoriteStatus = async (
|
||||||
export const useToggleFavoriteMutation = () => {
|
export const useToggleFavoriteMutation = () => {
|
||||||
const currentApi = useApi();
|
const currentApi = useApi();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ itemId, favoriteState }: ToggleFavoriteMutationProp) =>
|
mutationFn: ({ itemId, isFavorite }: ToggleFavoriteMutationProp) =>
|
||||||
fetchUpdateFavoriteStatus(currentApi, itemId, favoriteState )
|
fetchUpdateFavoriteStatus(currentApi, itemId, isFavorite )
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TogglePlayedMutationProp {
|
interface TogglePlayedMutationProp {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
playedState: boolean
|
isPlayed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchUpdatePlayedState = async (
|
const fetchUpdatePlayedState = async (
|
||||||
currentApi: JellyfinApiContext,
|
currentApi: JellyfinApiContext,
|
||||||
itemId: string,
|
itemId: string,
|
||||||
playedState: boolean
|
isPlayed: boolean
|
||||||
) => {
|
) => {
|
||||||
const { api, user } = currentApi;
|
const { api, user } = currentApi;
|
||||||
if (api && user?.Id) {
|
if (api && user?.Id) {
|
||||||
if (playedState) {
|
if (isPlayed) {
|
||||||
const response = await getPlaystateApi(api).markUnplayedItem({
|
const response = await getPlaystateApi(api).markUnplayedItem({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
itemId: itemId
|
itemId: itemId
|
||||||
|
@ -591,8 +593,8 @@ const fetchUpdatePlayedState = async (
|
||||||
export const useTogglePlayedMutation = () => {
|
export const useTogglePlayedMutation = () => {
|
||||||
const currentApi = useApi();
|
const currentApi = useApi();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ itemId, playedState }: TogglePlayedMutationProp) =>
|
mutationFn: ({ itemId, isPlayed }: TogglePlayedMutationProp) =>
|
||||||
fetchUpdatePlayedState(currentApi, itemId, playedState )
|
fetchUpdatePlayedState(currentApi, itemId, isPlayed )
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -676,7 +678,7 @@ const fetchGetTimers = async (
|
||||||
export const useGetTimers = (isUpcomingRecordingsEnabled: boolean, indexByDate?: boolean) => {
|
export const useGetTimers = (isUpcomingRecordingsEnabled: boolean, indexByDate?: boolean) => {
|
||||||
const currentApi = useApi();
|
const currentApi = useApi();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['Timers', isUpcomingRecordingsEnabled, indexByDate],
|
queryKey: ['Timers', { isUpcomingRecordingsEnabled, indexByDate }],
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
isUpcomingRecordingsEnabled ? fetchGetTimers(currentApi, indexByDate, { signal }) : []
|
isUpcomingRecordingsEnabled ? fetchGetTimers(currentApi, indexByDate, { signal }) : []
|
||||||
});
|
});
|
||||||
|
@ -830,7 +832,7 @@ const fetchGetSectionItems = async (
|
||||||
],
|
],
|
||||||
parentId: parentId ?? undefined,
|
parentId: parentId ?? undefined,
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: [ImageType.Primary],
|
enableImageTypes: [ImageType.Primary, ImageType.Thumb],
|
||||||
...section.parametersOptions
|
...section.parametersOptions
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -882,7 +884,6 @@ const getSectionsWithItems = async (
|
||||||
const updatedSectionWithItems: SectionWithItems[] = [];
|
const updatedSectionWithItems: SectionWithItems[] = [];
|
||||||
|
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
try {
|
|
||||||
const items = await fetchGetSectionItems(
|
const items = await fetchGetSectionItems(
|
||||||
currentApi, parentId, section, options
|
currentApi, parentId, section, options
|
||||||
);
|
);
|
||||||
|
@ -893,9 +894,6 @@ const getSectionsWithItems = async (
|
||||||
items
|
items
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error occurred for section ${section.type}: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedSectionWithItems;
|
return updatedSectionWithItems;
|
||||||
|
@ -908,7 +906,7 @@ export const useGetSuggestionSectionsWithItems = (
|
||||||
const currentApi = useApi();
|
const currentApi = useApi();
|
||||||
const sections = getSuggestionSections();
|
const sections = getSuggestionSections();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['SuggestionSectionWithItems', suggestionSectionType],
|
queryKey: ['SuggestionSectionWithItems', { suggestionSectionType }],
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
getSectionsWithItems(currentApi, parentId, sections, suggestionSectionType, { signal }),
|
getSectionsWithItems(currentApi, parentId, sections, suggestionSectionType, { signal }),
|
||||||
enabled: !!parentId
|
enabled: !!parentId
|
||||||
|
@ -922,9 +920,8 @@ export const useGetProgramsSectionsWithItems = (
|
||||||
const currentApi = useApi();
|
const currentApi = useApi();
|
||||||
const sections = getProgramSections();
|
const sections = getProgramSections();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['ProgramSectionWithItems', programSectionType],
|
queryKey: ['ProgramSectionWithItems', { programSectionType }],
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) => getSectionsWithItems(currentApi, parentId, sections, programSectionType, { signal })
|
||||||
getSectionsWithItems(currentApi, parentId, sections, programSectionType, { signal })
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -194,7 +194,8 @@ function supportsHdr10(options) {
|
||||||
|| browser.web0s
|
|| browser.web0s
|
||||||
|| browser.safari && ((browser.iOS && browser.iOSVersion >= 11) || browser.osx)
|
|| browser.safari && ((browser.iOS && browser.iOSVersion >= 11) || browser.osx)
|
||||||
// Chrome mobile and Firefox have no client side tone-mapping
|
// Chrome mobile and Firefox have no client side tone-mapping
|
||||||
// Edge Chromium on Nvidia is known to have color issues on 10-bit video
|
// Edge Chromium 121+ fixed the tone-mapping color issue on Nvidia
|
||||||
|
|| browser.edgeChromium && (browser.versionMajor >= 121)
|
||||||
|| browser.chrome && !browser.mobile
|
|| browser.chrome && !browser.mobile
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -394,8 +395,7 @@ export function canPlaySecondaryAudio(videoTestElement) {
|
||||||
&& !browser.firefox
|
&& !browser.firefox
|
||||||
// It seems to work on Tizen 5.5+ (2020, Chrome 69+). See https://developer.tizen.org/forums/web-application-development/video-tag-not-work-audiotracks
|
// It seems to work on Tizen 5.5+ (2020, Chrome 69+). See https://developer.tizen.org/forums/web-application-development/video-tag-not-work-audiotracks
|
||||||
&& (browser.tizenVersion >= 5.5 || !browser.tizen)
|
&& (browser.tizenVersion >= 5.5 || !browser.tizen)
|
||||||
// Assume webOS 5+ (2020, Chrome 68+) supports secondary audio like Tizen 5.5+
|
&& (browser.web0sVersion >= 4.0 || !browser.web0sVersion);
|
||||||
&& (browser.web0sVersion >= 5.0 || !browser.web0sVersion);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function (options) {
|
export default function (options) {
|
||||||
|
@ -573,8 +573,8 @@ export default function (options) {
|
||||||
const hlsInFmp4VideoCodecs = [];
|
const hlsInFmp4VideoCodecs = [];
|
||||||
|
|
||||||
if (canPlayAv1(videoTestElement)
|
if (canPlayAv1(videoTestElement)
|
||||||
&& !browser.mobile && (browser.edgeChromium || browser.firefox || browser.chrome)) {
|
&& (browser.safari || (!browser.mobile && (browser.edgeChromium || browser.firefox || browser.chrome)))) {
|
||||||
// disable av1 on mobile since it can be very slow software decoding
|
// disable av1 on non-safari mobile browsers since it can be very slow software decoding
|
||||||
hlsInFmp4VideoCodecs.push('av1');
|
hlsInFmp4VideoCodecs.push('av1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -591,11 +591,7 @@ export default function (options) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canPlayHevc(videoTestElement, options)) {
|
if (canPlayHevc(videoTestElement, options)) {
|
||||||
// safari is lying on HDR and 60fps videos, use fMP4 instead
|
|
||||||
if (!browser.safari) {
|
|
||||||
mp4VideoCodecs.push('hevc');
|
mp4VideoCodecs.push('hevc');
|
||||||
}
|
|
||||||
|
|
||||||
if (browser.tizen || browser.web0s) {
|
if (browser.tizen || browser.web0s) {
|
||||||
hlsInTsVideoCodecs.push('hevc');
|
hlsInTsVideoCodecs.push('hevc');
|
||||||
}
|
}
|
||||||
|
@ -619,13 +615,21 @@ export default function (options) {
|
||||||
|
|
||||||
if (canPlayVp9) {
|
if (canPlayVp9) {
|
||||||
mp4VideoCodecs.push('vp9');
|
mp4VideoCodecs.push('vp9');
|
||||||
|
// webm support is unreliable on safari 17
|
||||||
|
if (!browser.safari
|
||||||
|
|| (browser.safari && browser.versionMajor >= 15 && browser.versionMajor < 17)) {
|
||||||
webmVideoCodecs.push('vp9');
|
webmVideoCodecs.push('vp9');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (canPlayAv1(videoTestElement)) {
|
if (canPlayAv1(videoTestElement)) {
|
||||||
mp4VideoCodecs.push('av1');
|
mp4VideoCodecs.push('av1');
|
||||||
|
// webm support is unreliable on safari 17
|
||||||
|
if (!browser.safari
|
||||||
|
|| (browser.safari && browser.versionMajor >= 15 && browser.versionMajor < 17)) {
|
||||||
webmVideoCodecs.push('av1');
|
webmVideoCodecs.push('av1');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (canPlayVp8 || browser.tizen) {
|
if (canPlayVp8 || browser.tizen) {
|
||||||
videoAudioCodecs.push('vorbis');
|
videoAudioCodecs.push('vorbis');
|
||||||
|
@ -1106,6 +1110,25 @@ export default function (options) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safari quirks for HEVC direct-play
|
||||||
|
if (browser.safari) {
|
||||||
|
// Only hvc1 & dvh1 tags are supported
|
||||||
|
hevcCodecProfileConditions.push({
|
||||||
|
Condition: 'EqualsAny',
|
||||||
|
Property: 'VideoCodecTag',
|
||||||
|
Value: 'hvc1|dvh1',
|
||||||
|
IsRequired: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Framerate above 60fps is not supported
|
||||||
|
hevcCodecProfileConditions.push({
|
||||||
|
Condition: 'LessThanEqual',
|
||||||
|
Property: 'VideoFramerate',
|
||||||
|
Value: '60',
|
||||||
|
IsRequired: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// On iOS 12.x, for TS container max h264 level is 4.2
|
// On iOS 12.x, for TS container max h264 level is 4.2
|
||||||
if (browser.iOS && browser.iOSVersion < 13) {
|
if (browser.iOS && browser.iOSVersion < 13) {
|
||||||
const codecProfile = {
|
const codecProfile = {
|
||||||
|
|
|
@ -1722,5 +1722,6 @@
|
||||||
"LabelEnableAudioVbrHelp": "معدل البِت المتغير ينتج على جودة أفضل مقارنة بمعدل البت المتوسط، ولكن في بعض الحالات النادرة قد يسبب مشاكل في التخزين المؤقت والتوافق.",
|
"LabelEnableAudioVbrHelp": "معدل البِت المتغير ينتج على جودة أفضل مقارنة بمعدل البت المتوسط، ولكن في بعض الحالات النادرة قد يسبب مشاكل في التخزين المؤقت والتوافق.",
|
||||||
"LabelSegmentKeepSecondsHelp": "الزمن بالثواني الذي يجب الاحتفاظ به للشرائح قبل أن يتم الكتابة فوقها. يجب أن يكون أكبر من \"بعد الخنق\". يعمل هذا ألأعداد فقط إذا كان حذف الشرائح مفعلًا.",
|
"LabelSegmentKeepSecondsHelp": "الزمن بالثواني الذي يجب الاحتفاظ به للشرائح قبل أن يتم الكتابة فوقها. يجب أن يكون أكبر من \"بعد الخنق\". يعمل هذا ألأعداد فقط إذا كان حذف الشرائح مفعلًا.",
|
||||||
"AiTranslated": "مترجمة من قبل ذكاء اسطناعي",
|
"AiTranslated": "مترجمة من قبل ذكاء اسطناعي",
|
||||||
"SelectAudioNormalizationHelp": "كسب الالبوم-تعديل الصوت لكل مسار لكي يعملون بنفس مستوى- كسب الالبوم- تعديل مستوى الصوت لكل المسارات في البوم واحد مع ابقاء على النطاق الديناميكي للألبوم."
|
"SelectAudioNormalizationHelp": "كسب الالبوم-تعديل الصوت لكل مسار لكي يعملون بنفس مستوى- كسب الالبوم- تعديل مستوى الصوت لكل المسارات في البوم واحد مع ابقاء على النطاق الديناميكي للألبوم.",
|
||||||
|
"ButtonEditUser": "تعديل مستخدم"
|
||||||
}
|
}
|
||||||
|
|
|
@ -231,7 +231,7 @@
|
||||||
"LabelCollection": "Поредица",
|
"LabelCollection": "Поредица",
|
||||||
"LabelCommunityRating": "Обществена оценка",
|
"LabelCommunityRating": "Обществена оценка",
|
||||||
"LabelContentType": "Тип на съдържанието",
|
"LabelContentType": "Тип на съдържанието",
|
||||||
"LabelCountry": "Държава",
|
"LabelCountry": "Държава/Регион",
|
||||||
"LabelCriticRating": "Оценка на критиците",
|
"LabelCriticRating": "Оценка на критиците",
|
||||||
"LabelCurrentPassword": "Текуща парола",
|
"LabelCurrentPassword": "Текуща парола",
|
||||||
"LabelCustomCertificatePath": "Ръчно задаване на пътя към SSL сертификата",
|
"LabelCustomCertificatePath": "Ръчно задаване на пътя към SSL сертификата",
|
||||||
|
@ -1464,7 +1464,7 @@
|
||||||
"LabelMaxDaysForNextUpHelp": "Задайте максималния брой дни, през които едно шоу би трябвало да остане в списъка „Next Up“, без да го гледате.",
|
"LabelMaxDaysForNextUpHelp": "Задайте максималния брой дни, през които едно шоу би трябвало да остане в списъка „Next Up“, без да го гледате.",
|
||||||
"LabelEnableAudioVbr": "Разреши VBR звуково кодиране",
|
"LabelEnableAudioVbr": "Разреши VBR звуково кодиране",
|
||||||
"HeaderPerformance": "Производителност",
|
"HeaderPerformance": "Производителност",
|
||||||
"LabelChapterImageResolutionHelp": "Резолюцията на извлечените снимки към раздела.",
|
"LabelChapterImageResolutionHelp": "Резолюцията на извлечените снимки към раздела. Промяна в тези настройки няма да повлияе на съществуващите тестови раздели.",
|
||||||
"AllowCollectionManagement": "Позволи този потребител да управлява колекции",
|
"AllowCollectionManagement": "Позволи този потребител да управлява колекции",
|
||||||
"LabelDummyChapterDuration": "Интервал",
|
"LabelDummyChapterDuration": "Интервал",
|
||||||
"LabelDummyChapterCount": "Граница",
|
"LabelDummyChapterCount": "Граница",
|
||||||
|
@ -1488,5 +1488,19 @@
|
||||||
"LabelDeveloper": "Програмист",
|
"LabelDeveloper": "Програмист",
|
||||||
"LabelDate": "Дата",
|
"LabelDate": "Дата",
|
||||||
"GridView": "Грид изглед",
|
"GridView": "Грид изглед",
|
||||||
"HeaderRecordingMetadataSaving": "Запис на Мета-данни"
|
"HeaderRecordingMetadataSaving": "Запис на Мета-данни",
|
||||||
|
"ButtonEditUser": "Редактиране на потребител",
|
||||||
|
"ChannelResolutionSD": "SD",
|
||||||
|
"ChannelResolutionHD": "Висока резолюция",
|
||||||
|
"ChannelResolutionFullHD": "Пълна висока резолюция",
|
||||||
|
"ChannelResolutionUHD4K": "4К Резолюция",
|
||||||
|
"HeaderDummyChapter": "Кадъри от видео сегмент",
|
||||||
|
"HeaderEpisodesStatus": "Статус на епизод",
|
||||||
|
"LabelMediaDetails": "Детайли на медия",
|
||||||
|
"HeaderAllRecordings": "Всички записи",
|
||||||
|
"LabelLevel": "Ниво",
|
||||||
|
"LabelSelectAudioNormalization": "Аудио нормализация",
|
||||||
|
"LabelBuildVersion": "Софтуерна версия",
|
||||||
|
"LabelEnableLUFSScanHelp": "Нормализаране на силата на звука на всички аудио файлове. Това ще увеличи времети за сканиране на библиотеката и ще използва допъкнителни ресурси.",
|
||||||
|
"HeaderGuestCast": "Гостуващи актьори"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1318,8 +1318,8 @@
|
||||||
"LabelSyncPlayTimeOffset": "Časový rozdíl mezi serverem:",
|
"LabelSyncPlayTimeOffset": "Časový rozdíl mezi serverem:",
|
||||||
"HeaderSyncPlayEnabled": "Synchronizace přehrávání povolena",
|
"HeaderSyncPlayEnabled": "Synchronizace přehrávání povolena",
|
||||||
"HeaderSyncPlaySelectGroup": "Připojit ke skupině",
|
"HeaderSyncPlaySelectGroup": "Připojit ke skupině",
|
||||||
"EnableDetailsBannerHelp": "Zobrazí obrázek ve vrchní části detailu položky.",
|
"EnableDetailsBannerHelp": "Zobrazí banner v horní části stránky s detailem položky.",
|
||||||
"EnableDetailsBanner": "Obrázek detailu",
|
"EnableDetailsBanner": "Zobrazit banner na stránce s detailem",
|
||||||
"ShowMore": "Zobrazit více",
|
"ShowMore": "Zobrazit více",
|
||||||
"ShowLess": "Zobrazit méně",
|
"ShowLess": "Zobrazit méně",
|
||||||
"EnableBlurHashHelp": "Obrázky, které se ještě načítají, budou zobrazeny pomocí jedinečných zástupných obrázků.",
|
"EnableBlurHashHelp": "Obrázky, které se ještě načítají, budou zobrazeny pomocí jedinečných zástupných obrázků.",
|
||||||
|
@ -1785,5 +1785,12 @@
|
||||||
"HeaderAllRecordings": "Všechny nahrávky",
|
"HeaderAllRecordings": "Všechny nahrávky",
|
||||||
"LabelBuildVersion": "Verze sestavení",
|
"LabelBuildVersion": "Verze sestavení",
|
||||||
"LabelServerVersion": "Verze serveru",
|
"LabelServerVersion": "Verze serveru",
|
||||||
"LabelWebVersion": "Verze webu"
|
"LabelWebVersion": "Verze webu",
|
||||||
|
"ButtonEditUser": "Upravit uživatele",
|
||||||
|
"DlnaMovedMessage": "Funkce DLNA byla přesunuta do zásuvného modulu.",
|
||||||
|
"ChannelResolutionSD": "SD",
|
||||||
|
"ChannelResolutionSDPAL": "SD (PAL)",
|
||||||
|
"ChannelResolutionHD": "HD",
|
||||||
|
"ChannelResolutionFullHD": "Full HD",
|
||||||
|
"ChannelResolutionUHD4K": "UHD (4K)"
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue