mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Merge branch 'master' into EnableLibrary
This commit is contained in:
commit
b84e9250fa
263 changed files with 10059 additions and 4684 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
|
1
.copr
1
.copr
|
@ -1 +0,0 @@
|
|||
fedora/
|
|
@ -8,5 +8,5 @@ trim_trailing_whitespace = true
|
|||
insert_final_newline = true
|
||||
end_of_line = lf
|
||||
|
||||
[*.json]
|
||||
[*.{json,yaml,yml}]
|
||||
indent_size = 2
|
||||
|
|
|
@ -261,7 +261,13 @@ module.exports = {
|
|||
'ServerNotifications': 'writable',
|
||||
'TaskButton': 'writable',
|
||||
'UserParentalControlPage': 'writable',
|
||||
'Windows': 'readonly'
|
||||
'Windows': 'readonly',
|
||||
// Build time definitions
|
||||
__JF_BUILD_VERSION__: 'readonly',
|
||||
__PACKAGE_JSON_NAME__: 'readonly',
|
||||
__PACKAGE_JSON_VERSION__: 'readonly',
|
||||
__USE_SYSTEM_FONTS__: 'readonly',
|
||||
__WEBPACK_SERVE__: 'readonly'
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/prefer-string-starts-ends-with': ['error']
|
||||
|
|
6
.github/renovate.json
vendored
6
.github/renovate.json
vendored
|
@ -1,4 +1,8 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["github>jellyfin/.github//renovate-presets/nodejs", ":semanticCommitsDisabled"]
|
||||
"extends": [
|
||||
"github>jellyfin/.github//renovate-presets/nodejs",
|
||||
":semanticCommitsDisabled",
|
||||
":dependencyDashboard"
|
||||
]
|
||||
}
|
||||
|
|
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
|
@ -31,6 +31,8 @@ jobs:
|
|||
run: npm ci --no-audit
|
||||
|
||||
- name: Run a production build
|
||||
env:
|
||||
JELLYFIN_VERSION: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: npm run build:production
|
||||
|
||||
- name: Update config.json for testing
|
||||
|
@ -56,7 +58,7 @@ jobs:
|
|||
- name: Save PR context
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
PR_SHA: ${{ github.sha }}
|
||||
PR_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
echo $PR_NUMBER > PR_number
|
||||
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
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0
|
||||
uses: github/codeql-action/init@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9
|
||||
with:
|
||||
languages: javascript
|
||||
queries: +security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0
|
||||
uses: github/codeql-action/autobuild@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0
|
||||
uses: github/codeql-action/analyze@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9
|
||||
|
|
2
.github/workflows/pr-suggestions.yml
vendored
2
.github/workflows/pr-suggestions.yml
vendored
|
@ -33,6 +33,6 @@ jobs:
|
|||
|
||||
- name: Run eslint
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||
uses: CatChen/eslint-suggestion-action@7bbf6d65396dbcc73d1e053d900eb5745988c11c # v3.1.2
|
||||
uses: CatChen/eslint-suggestion-action@0dd29587b3fce359fc31b72c4384cc12b8f0c22b # v3.1.4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
6
.github/workflows/publish.yml
vendored
6
.github/workflows/publish.yml
vendored
|
@ -20,7 +20,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Download workflow artifact
|
||||
uses: dawidd6/action-download-artifact@e7466d1a7587ed14867642c2ca74b5bcc1e19a2d # v3.0.0
|
||||
uses: dawidd6/action-download-artifact@09f2f74827fd3a8607589e5ad7f9398816f540fe # v3.1.4
|
||||
with:
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: jellyfin-web__prod
|
||||
|
@ -47,7 +47,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Get PR context
|
||||
uses: dawidd6/action-download-artifact@e7466d1a7587ed14867642c2ca74b5bcc1e19a2d # v3.0.0
|
||||
uses: dawidd6/action-download-artifact@09f2f74827fd3a8607589e5ad7f9398816f540fe # v3.1.4
|
||||
id: pr_context
|
||||
with:
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
|
@ -88,7 +88,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- 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:
|
||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
message: ${{ needs.compose-comment.outputs.msg }}
|
||||
|
|
52
.github/workflows/update-sdk.yml
vendored
Normal file
52
.github/workflows/update-sdk.yml
vendored
Normal file
|
@ -0,0 +1,52 @@
|
|||
name: Update the Jellyfin SDK
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 7 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: unstable-sdk-pr
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
with:
|
||||
ref: master
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
cache: npm
|
||||
|
||||
- name: Install latest unstable SDK
|
||||
run: |
|
||||
npm i --save @jellyfin/sdk@unstable
|
||||
VERSION=$(jq -r '.dependencies["@jellyfin/sdk"]' package.json)
|
||||
echo "JF_SDK_VERSION=${VERSION}" >> $GITHUB_ENV
|
||||
|
||||
- name: Open a pull request
|
||||
uses: peter-evans/create-pull-request@70a41aba780001da0a30141984ae2a0c95d8704e # v6.0.2
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
commit-message: Update @jellyfin/sdk to ${{env.JF_SDK_VERSION}}
|
||||
committer: jellyfin-bot <team@jellyfin.org>
|
||||
author: jellyfin-bot <team@jellyfin.org>
|
||||
branch: update-jf-sdk
|
||||
delete-branch: true
|
||||
title: Update @jellyfin/sdk to ${{env.JF_SDK_VERSION}}
|
||||
body: |
|
||||
**Changes**
|
||||
Updates to the latest unstable @jellyfin/sdk build
|
||||
labels: |
|
||||
dependencies
|
||||
npm
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -8,6 +8,7 @@ config.json
|
|||
|
||||
# ide
|
||||
.idea
|
||||
.vs
|
||||
|
||||
# log
|
||||
yarn-error.log
|
||||
|
|
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"eslint.format.enable": true,
|
||||
"editor.formatOnSave": false
|
||||
|
|
|
@ -80,6 +80,12 @@
|
|||
- [Rasmus Krämer](https://github.com/rasmuslos)
|
||||
- [ntarelix](https://github.com/ntarelix)
|
||||
- [btopherjohnson](https://github.com/btopherjohnson)
|
||||
- [András Maróy](https://github.com/andrasmaroy)
|
||||
- [Chris-Codes-It](https://github.com/Chris-Codes-It)
|
||||
- [Vedant](https://github.com/viktory36)
|
||||
- [GeorgeH005](https://github.com/GeorgeH005)
|
||||
- [JPUC1143](https://github.com/Jpuc1143)
|
||||
- [David Angel](https://github.com/davidangel)
|
||||
|
||||
## Emby Contributors
|
||||
|
||||
|
|
110
build.sh
110
build.sh
|
@ -1,110 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# build.sh - Build Jellyfin binary packages
|
||||
# Part of the Jellyfin Project
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
usage() {
|
||||
echo -e "build.sh - Build Jellyfin binary packages"
|
||||
echo -e "Usage:"
|
||||
echo -e " $0 -t/--type <BUILD_TYPE> -p/--platform <PLATFORM> [-k/--keep-artifacts] [-l/--list-platforms]"
|
||||
echo -e "Notes:"
|
||||
echo -e " * BUILD_TYPE can be one of: [native, docker] and must be specified"
|
||||
echo -e " * native: Build using the build script in the host OS"
|
||||
echo -e " * docker: Build using the build script in a standardized Docker container"
|
||||
echo -e " * PLATFORM can be any platform shown by -l/--list-platforms and must be specified"
|
||||
echo -e " * If -k/--keep-artifacts is specified, transient artifacts (e.g. Docker containers) will be"
|
||||
echo -e " retained after the build is finished; the source directory will still be cleaned"
|
||||
echo -e " * If -l/--list-platforms is specified, all other arguments are ignored; the script will print"
|
||||
echo -e " the list of supported platforms and exit"
|
||||
}
|
||||
|
||||
list_platforms() {
|
||||
declare -a platforms
|
||||
platforms=(
|
||||
$( find deployment -maxdepth 1 -mindepth 1 -name "build.*" | awk -F'.' '{ $1=""; printf $2; if ($3 != ""){ printf "." $3; }; if ($4 != ""){ printf "." $4; }; print ""; }' | sort )
|
||||
)
|
||||
echo -e "Valid platforms:"
|
||||
echo
|
||||
for platform in ${platforms[@]}; do
|
||||
echo -e "* ${platform} : $( grep '^#=' deployment/build.${platform} | sed 's/^#= //' )"
|
||||
done
|
||||
}
|
||||
|
||||
do_build_native() {
|
||||
export IS_DOCKER=NO
|
||||
deployment/build.${PLATFORM}
|
||||
}
|
||||
|
||||
do_build_docker() {
|
||||
if ! [ $(uname -m) = "x86_64" ]; then
|
||||
echo "Docker-based builds only support amd64-based cross-building; use a 'native' build instead."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f deployment/Dockerfile.${PLATFORM} ]]; then
|
||||
echo "Missing Dockerfile for platform ${PLATFORM}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ${KEEP_ARTIFACTS} == YES ]]; then
|
||||
docker_args=""
|
||||
else
|
||||
docker_args="--rm"
|
||||
fi
|
||||
|
||||
docker build . -t "jellyfin-builder.${PLATFORM}" -f deployment/Dockerfile.${PLATFORM}
|
||||
mkdir -p ${ARTIFACT_DIR}
|
||||
docker run $docker_args -v "${SOURCE_DIR}:/jellyfin" -v "${ARTIFACT_DIR}:/dist" "jellyfin-builder.${PLATFORM}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
key="$1"
|
||||
case $key in
|
||||
-t|--type)
|
||||
BUILD_TYPE="$2"
|
||||
shift
|
||||
shift
|
||||
;;
|
||||
-p|--platform)
|
||||
PLATFORM="$2"
|
||||
shift
|
||||
shift
|
||||
;;
|
||||
-k|--keep-artifacts)
|
||||
KEEP_ARTIFACTS=YES
|
||||
shift
|
||||
;;
|
||||
-l|--list-platforms)
|
||||
list_platforms
|
||||
exit 0
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z ${BUILD_TYPE} || -z ${PLATFORM} ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export SOURCE_DIR="$( pwd )"
|
||||
export ARTIFACT_DIR="${SOURCE_DIR}/../bin/${PLATFORM}"
|
||||
|
||||
# Determine build type
|
||||
case ${BUILD_TYPE} in
|
||||
native)
|
||||
do_build_native
|
||||
;;
|
||||
docker)
|
||||
do_build_docker
|
||||
;;
|
||||
esac
|
|
@ -1,9 +0,0 @@
|
|||
---
|
||||
# We just wrap `build` so this is really it
|
||||
name: "jellyfin-web"
|
||||
version: "10.8.0"
|
||||
packages:
|
||||
- debian.all
|
||||
- fedora.all
|
||||
- centos.all
|
||||
- portable
|
67
bump_version
67
bump_version
|
@ -7,7 +7,7 @@ set -o pipefail
|
|||
set -o xtrace
|
||||
|
||||
usage() {
|
||||
echo -e "bump_version - increase the shared version and generate changelogs"
|
||||
echo -e "bump_version - increase the shared version"
|
||||
echo -e ""
|
||||
echo -e "Usage:"
|
||||
echo -e " $ bump_version <new_version>"
|
||||
|
@ -18,75 +18,12 @@ if [[ -z $1 ]]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
build_file="./build.yaml"
|
||||
package_file="./package*.json"
|
||||
|
||||
new_version="$1"
|
||||
|
||||
old_version="$(
|
||||
grep "version:" ${build_file} \
|
||||
| sed -E 's/version: "([0-9\.]+[-a-z0-9]*)"/\1/'
|
||||
)"
|
||||
echo "Old version: ${old_version}"
|
||||
new_version_sed="$( cut -f1 -d'-' <<<"${new_version}" )"
|
||||
|
||||
# Bump the NPM version
|
||||
new_version_sed="$( cut -f1 -d'-' <<<"${new_version}" )"
|
||||
npm --no-git-tag-version --allow-same-version version v${new_version_sed}
|
||||
|
||||
# Set the build.yaml version to the specified new_version
|
||||
old_version_sed="$( sed 's/\./\\./g' <<<"${old_version}" )" # Escape the '.' chars
|
||||
sed -i "s/${old_version_sed}/${new_version_sed}/g" ${build_file}
|
||||
|
||||
|
||||
if [[ ${new_version} == *"-"* ]]; then
|
||||
new_version_pkg="$( sed 's/-/~/g' <<<"${new_version}" )"
|
||||
new_version_deb_sup=""
|
||||
else
|
||||
new_version_pkg="${new_version}"
|
||||
new_version_deb_sup="-1"
|
||||
fi
|
||||
|
||||
# Write out a temporary Debian changelog with our new stuff appended and some templated formatting
|
||||
debian_changelog_file="debian/changelog"
|
||||
debian_changelog_temp="$( mktemp )"
|
||||
# Create new temp file with our changelog
|
||||
echo -e "jellyfin-web (${new_version_pkg}${new_version_deb_sup}) unstable; urgency=medium
|
||||
|
||||
* New upstream version ${new_version}; release changelog at https://github.com/jellyfin/jellyfin-web/releases/tag/v${new_version}
|
||||
|
||||
-- Jellyfin Packaging Team <packaging@jellyfin.org> $( date --rfc-2822 )
|
||||
" >> ${debian_changelog_temp}
|
||||
cat ${debian_changelog_file} >> ${debian_changelog_temp}
|
||||
# Move into place
|
||||
mv ${debian_changelog_temp} ${debian_changelog_file}
|
||||
|
||||
# Write out a temporary Yum changelog with our new stuff prepended and some templated formatting
|
||||
fedora_spec_file="fedora/jellyfin-web.spec"
|
||||
fedora_changelog_temp="$( mktemp )"
|
||||
fedora_spec_temp_dir="$( mktemp -d )"
|
||||
fedora_spec_temp="${fedora_spec_temp_dir}/jellyfin-web.spec.tmp"
|
||||
# Make a copy of our spec file for hacking
|
||||
cp ${fedora_spec_file} ${fedora_spec_temp_dir}/
|
||||
pushd ${fedora_spec_temp_dir}
|
||||
# Split out the stuff before and after changelog
|
||||
csplit jellyfin-web.spec "/^%changelog/" # produces xx00 xx01
|
||||
# Update the version in xx00
|
||||
sed -i "s/${old_version_sed}/${new_version_pkg}/g" xx00
|
||||
# Remove the header from xx01
|
||||
sed -i '/^%changelog/d' xx01
|
||||
# Create new temp file with our changelog
|
||||
echo -e "%changelog
|
||||
* $( LANG=C date '+%a %b %d %Y' ) Jellyfin Packaging Team <packaging@jellyfin.org>
|
||||
- New upstream version ${new_version}; release changelog at https://github.com/jellyfin/jellyfin-web/releases/tag/v${new_version}" >> ${fedora_changelog_temp}
|
||||
cat xx01 >> ${fedora_changelog_temp}
|
||||
# Reassembble
|
||||
cat xx00 ${fedora_changelog_temp} > ${fedora_spec_temp}
|
||||
popd
|
||||
# Move into place
|
||||
mv ${fedora_spec_temp} ${fedora_spec_file}
|
||||
# Clean up
|
||||
rm -rf ${fedora_spec_temp_dir}
|
||||
|
||||
# Stage the changed files for commit
|
||||
git add .
|
||||
git status -v
|
||||
|
|
17
debian/changelog
vendored
17
debian/changelog
vendored
|
@ -1,17 +0,0 @@
|
|||
jellyfin-web (10.8.0-1) unstable; urgency=medium
|
||||
|
||||
* Forthcoming stable release
|
||||
|
||||
-- Jellyfin Packaging Team <packaging@jellyfin.org> Fri, 04 Dec 2020 21:58:23 -0500
|
||||
|
||||
jellyfin-web (10.7.0-1) unstable; urgency=medium
|
||||
|
||||
* Forthcoming stable release
|
||||
|
||||
-- Jellyfin Packaging Team <packaging@jellyfin.org> Mon, 27 Jul 2020 19:13:31 -0400
|
||||
|
||||
jellyfin-web (10.6.0-1) unstable; urgency=medium
|
||||
|
||||
* New upstream version 10.6.0; release changelog at https://github.com/jellyfin/jellyfin-web/releases/tag/v10.6.0
|
||||
|
||||
-- Jellyfin Packaging Team <packaging@jellyfin.org> Mon, 16 Mar 2020 11:15:00 -0400
|
1
debian/compat
vendored
1
debian/compat
vendored
|
@ -1 +0,0 @@
|
|||
8
|
1
debian/conffiles
vendored
1
debian/conffiles
vendored
|
@ -1 +0,0 @@
|
|||
/usr/share/jellyfin/web/config.json
|
16
debian/control
vendored
16
debian/control
vendored
|
@ -1,16 +0,0 @@
|
|||
Source: jellyfin-web
|
||||
Section: misc
|
||||
Priority: optional
|
||||
Maintainer: Jellyfin Team <team@jellyfin.org>
|
||||
Build-Depends: debhelper (>= 9),
|
||||
npm | nodejs
|
||||
Standards-Version: 3.9.4
|
||||
Homepage: https://jellyfin.org/
|
||||
Vcs-Git: https://github.org/jellyfin/jellyfin-web.git
|
||||
Vcs-Browser: https://github.org/jellyfin/jellyfin-web
|
||||
|
||||
Package: jellyfin-web
|
||||
Recommends: jellyfin-server
|
||||
Architecture: all
|
||||
Description: Jellyfin is the Free Software Media System.
|
||||
This package provides the Jellyfin web client.
|
28
debian/copyright
vendored
28
debian/copyright
vendored
|
@ -1,28 +0,0 @@
|
|||
Format: http://dep.debian.net/deps/dep5
|
||||
Upstream-Name: jellyfin-web
|
||||
Source: https://github.com/jellyfin/jellyfin-web
|
||||
|
||||
Files: *
|
||||
Copyright: 2018-2020 Jellyfin Team
|
||||
License: GPL-3.0
|
||||
|
||||
Files: debian/*
|
||||
Copyright: 2020 Joshua Boniface <joshua@boniface.me>
|
||||
License: GPL-3.0
|
||||
|
||||
License: GPL-3.0
|
||||
This package is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
.
|
||||
This package is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
.
|
||||
On Debian systems, the complete text of the GNU General
|
||||
Public License version 2 can be found in "/usr/share/common-licenses/GPL-2".
|
6
debian/gbp.conf
vendored
6
debian/gbp.conf
vendored
|
@ -1,6 +0,0 @@
|
|||
[DEFAULT]
|
||||
pristine-tar = False
|
||||
cleaner = fakeroot debian/rules clean
|
||||
|
||||
[import-orig]
|
||||
filter = [ ".git*", ".hg*", ".vs*", ".vscode*" ]
|
1
debian/install
vendored
1
debian/install
vendored
|
@ -1 +0,0 @@
|
|||
web usr/share/jellyfin/
|
1
debian/po/POTFILES.in
vendored
1
debian/po/POTFILES.in
vendored
|
@ -1 +0,0 @@
|
|||
[type: gettext/rfc822deb] templates
|
57
debian/po/templates.pot
vendored
57
debian/po/templates.pot
vendored
|
@ -1,57 +0,0 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: jellyfin-server\n"
|
||||
"Report-Msgid-Bugs-To: jellyfin-server@packages.debian.org\n"
|
||||
"POT-Creation-Date: 2015-06-12 20:51-0600\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=CHARSET\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#. Type: note
|
||||
#. Description
|
||||
#: ../templates:1001
|
||||
msgid "Jellyfin permission info:"
|
||||
msgstr ""
|
||||
|
||||
#. Type: note
|
||||
#. Description
|
||||
#: ../templates:1001
|
||||
msgid ""
|
||||
"Jellyfin by default runs under a user named \"jellyfin\". Please ensure that the "
|
||||
"user jellyfin has read and write access to any folders you wish to add to your "
|
||||
"library. Otherwise please run jellyfin under a different user."
|
||||
msgstr ""
|
||||
|
||||
#. Type: string
|
||||
#. Description
|
||||
#: ../templates:2001
|
||||
msgid "Username to run Jellyfin as:"
|
||||
msgstr ""
|
||||
|
||||
#. Type: string
|
||||
#. Description
|
||||
#: ../templates:2001
|
||||
msgid "The user that jellyfin will run as."
|
||||
msgstr ""
|
||||
|
||||
#. Type: note
|
||||
#. Description
|
||||
#: ../templates:3001
|
||||
msgid "Jellyfin still running"
|
||||
msgstr ""
|
||||
|
||||
#. Type: note
|
||||
#. Description
|
||||
#: ../templates:3001
|
||||
msgid "Jellyfin is currently running. Please close it and try again."
|
||||
msgstr ""
|
21
debian/rules
vendored
21
debian/rules
vendored
|
@ -1,21 +0,0 @@
|
|||
#! /usr/bin/make -f
|
||||
export DH_VERBOSE=1
|
||||
|
||||
%:
|
||||
dh $@
|
||||
|
||||
# disable "make check"
|
||||
override_dh_auto_test:
|
||||
|
||||
# disable stripping debugging symbols
|
||||
override_dh_clistrip:
|
||||
|
||||
override_dh_auto_build:
|
||||
npm ci --no-audit --unsafe-perm
|
||||
npm run build:production
|
||||
mv $(CURDIR)/dist $(CURDIR)/web
|
||||
|
||||
override_dh_auto_clean:
|
||||
test -d $(CURDIR)/dist && rm -rf '$(CURDIR)/dist' || true
|
||||
test -d $(CURDIR)/web && rm -rf '$(CURDIR)/web' || true
|
||||
test -d $(CURDIR)/node_modules && rm -rf '$(CURDIR)/node_modules' || true
|
1
debian/source/format
vendored
1
debian/source/format
vendored
|
@ -1 +0,0 @@
|
|||
1.0
|
7
debian/source/options
vendored
7
debian/source/options
vendored
|
@ -1,7 +0,0 @@
|
|||
tar-ignore='.git*'
|
||||
tar-ignore='**/.git'
|
||||
tar-ignore='**/.hg'
|
||||
tar-ignore='**/.vs'
|
||||
tar-ignore='**/.vscode'
|
||||
tar-ignore='deployment'
|
||||
tar-ignore='*.deb'
|
|
@ -1,28 +0,0 @@
|
|||
FROM quay.io/centos/centos:stream8
|
||||
|
||||
# Docker build arguments
|
||||
ARG SOURCE_DIR=/jellyfin
|
||||
ARG ARTIFACT_DIR=/dist
|
||||
|
||||
# Docker run environment
|
||||
ENV SOURCE_DIR=/jellyfin
|
||||
ENV ARTIFACT_DIR=/dist
|
||||
ENV IS_DOCKER=YES
|
||||
|
||||
# Prepare CentOS environment
|
||||
RUN yum update -y \
|
||||
&& yum install -y epel-release \
|
||||
&& yum install -y rpmdevtools git autoconf automake glibc-devel gcc-c++ make \
|
||||
&& yum install https://rpm.nodesource.com/pub_20.x/nodistro/repo/nodesource-release-nodistro-1.noarch.rpm -y \
|
||||
&& yum install nodejs -y --setopt=nodesource-nodejs.module_hotfixes=1 \
|
||||
&& yum clean all \
|
||||
&& rm -rf /var/cache/dnf
|
||||
|
||||
# Link to build script
|
||||
RUN ln -sf ${SOURCE_DIR}/deployment/build.centos /build.sh
|
||||
|
||||
VOLUME ${SOURCE_DIR}
|
||||
|
||||
VOLUME ${ARTIFACT_DIR}
|
||||
|
||||
ENTRYPOINT ["/build.sh"]
|
|
@ -1,30 +0,0 @@
|
|||
FROM debian:12
|
||||
|
||||
# Docker build arguments
|
||||
ARG SOURCE_DIR=/jellyfin
|
||||
ARG ARTIFACT_DIR=/dist
|
||||
|
||||
# Docker run environment
|
||||
ENV SOURCE_DIR=/jellyfin
|
||||
ENV ARTIFACT_DIR=/dist
|
||||
ENV DEB_BUILD_OPTIONS=noddebs
|
||||
ENV IS_DOCKER=YES
|
||||
|
||||
# Prepare Debian build environment
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y debhelper mmv git curl gnupg ca-certificates \
|
||||
&& mkdir -p /etc/apt/keyrings \
|
||||
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
||||
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
|
||||
|
||||
# Link to build script
|
||||
RUN ln -sf ${SOURCE_DIR}/deployment/build.debian /build.sh
|
||||
|
||||
VOLUME ${SOURCE_DIR}
|
||||
|
||||
VOLUME ${ARTIFACT_DIR}
|
||||
|
||||
ENTRYPOINT ["/build.sh"]
|
|
@ -1,13 +0,0 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
ARG SOURCE_DIR=/src
|
||||
ARG ARTIFACT_DIR=/jellyfin-web
|
||||
|
||||
RUN apk --no-cache add autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3
|
||||
|
||||
WORKDIR ${SOURCE_DIR}
|
||||
COPY . .
|
||||
|
||||
RUN npm ci --no-audit --unsafe-perm \
|
||||
&& npm run build:production \
|
||||
&& mv dist ${ARTIFACT_DIR}
|
|
@ -1,26 +0,0 @@
|
|||
FROM fedora:40
|
||||
|
||||
# Docker build arguments
|
||||
ARG SOURCE_DIR=/jellyfin
|
||||
ARG ARTIFACT_DIR=/dist
|
||||
|
||||
# Docker run environment
|
||||
ENV SOURCE_DIR=/jellyfin
|
||||
ENV ARTIFACT_DIR=/dist
|
||||
ENV IS_DOCKER=YES
|
||||
|
||||
# Prepare Fedora environment
|
||||
RUN dnf update -y \
|
||||
&& dnf install https://rpm.nodesource.com/pub_20.x/nodistro/repo/nodesource-release-nodistro-1.noarch.rpm -y \
|
||||
&& dnf install -y @buildsys-build rpmdevtools git dnf-plugins-core nodejs autoconf automake glibc-devel make --setopt=nodesource-nodejs.module_hotfixes=1 \
|
||||
&& dnf clean all \
|
||||
&& rm -rf /var/cache/dnf
|
||||
|
||||
# Link to build script
|
||||
RUN ln -sf ${SOURCE_DIR}/deployment/build.fedora /build.sh
|
||||
|
||||
VOLUME ${SOURCE_DIR}
|
||||
|
||||
VOLUME ${ARTIFACT_DIR}
|
||||
|
||||
ENTRYPOINT ["/build.sh"]
|
|
@ -1,29 +0,0 @@
|
|||
FROM debian:12
|
||||
|
||||
# Docker build arguments
|
||||
ARG SOURCE_DIR=/jellyfin
|
||||
ARG ARTIFACT_DIR=/dist
|
||||
|
||||
# Docker run environment
|
||||
ENV SOURCE_DIR=/jellyfin
|
||||
ENV ARTIFACT_DIR=/dist
|
||||
ENV IS_DOCKER=YES
|
||||
|
||||
# Prepare Debian build environment
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y mmv curl git gnupg ca-certificates \
|
||||
&& mkdir -p /etc/apt/keyrings \
|
||||
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
||||
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
|
||||
|
||||
# Link to build script
|
||||
RUN ln -sf ${SOURCE_DIR}/deployment/build.portable /build.sh
|
||||
|
||||
VOLUME ${SOURCE_DIR}
|
||||
|
||||
VOLUME ${ARTIFACT_DIR}
|
||||
|
||||
ENTRYPOINT ["/build.sh"]
|
|
@ -1,41 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o xtrace
|
||||
|
||||
# move to source directory
|
||||
pushd ${SOURCE_DIR}
|
||||
|
||||
cp -a package-lock.json /tmp/package-lock.json
|
||||
|
||||
# modify changelog to unstable configuration if IS_UNSTABLE
|
||||
if [[ ${IS_UNSTABLE} == 'yes' ]]; then
|
||||
pushd fedora
|
||||
|
||||
PR_ID=$( git log --grep 'Merge pull request' --oneline --single-worktree --first-parent | head -1 | grep --color=none -Eo '#[0-9]+' | tr -d '#' )
|
||||
|
||||
sed -i "s/Version:.*/Version: ${BUILD_ID}/" jellyfin-web.spec
|
||||
sed -i "/%changelog/q" jellyfin-web.spec
|
||||
|
||||
cat <<EOF >>jellyfin-web.spec
|
||||
* $( LANG=C date '+%a %b %d %Y' ) Jellyfin Packaging Team <packaging@jellyfin.org>
|
||||
- Jellyfin Web unstable build ${BUILD_ID} for merged PR #${PR_ID}
|
||||
EOF
|
||||
popd
|
||||
fi
|
||||
|
||||
# build rpm
|
||||
make -f fedora/Makefile srpm outdir=/root/rpmbuild/SRPMS
|
||||
rpmbuild --rebuild -bb /root/rpmbuild/SRPMS/jellyfin-*.src.rpm
|
||||
|
||||
# move the artifacts
|
||||
mv /root/rpmbuild/RPMS/noarch/jellyfin-*.rpm /root/rpmbuild/SRPMS/jellyfin-*.src.rpm ${ARTIFACT_DIR}/
|
||||
|
||||
if [[ ${IS_DOCKER} == YES ]]; then
|
||||
chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR}
|
||||
fi
|
||||
|
||||
rm -f fedora/jellyfin*.tar.gz
|
||||
cp -a /tmp/package-lock.json package-lock.json
|
||||
|
||||
popd
|
|
@ -1,39 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o xtrace
|
||||
|
||||
# move to source directory
|
||||
pushd ${SOURCE_DIR}
|
||||
|
||||
cp -a package-lock.json /tmp/package-lock.json
|
||||
|
||||
# modify changelog to unstable configuration if IS_UNSTABLE
|
||||
if [[ ${IS_UNSTABLE} == 'yes' ]]; then
|
||||
pushd debian
|
||||
|
||||
PR_ID=$( git log --grep 'Merge pull request' --oneline --single-worktree --first-parent | head -1 | grep --color=none -Eo '#[0-9]+' | tr -d '#' )
|
||||
|
||||
cat <<EOF >changelog
|
||||
jellyfin-web (${BUILD_ID}-unstable) unstable; urgency=medium
|
||||
|
||||
* Jellyfin Web unstable build ${BUILD_ID} for merged PR #${PR_ID}
|
||||
|
||||
-- Jellyfin Packaging Team <packaging@jellyfin.org> $( date --rfc-2822 )
|
||||
EOF
|
||||
popd
|
||||
fi
|
||||
|
||||
# build deb
|
||||
dpkg-buildpackage -us -uc --pre-clean --post-clean
|
||||
|
||||
mkdir -p ${ARTIFACT_DIR}
|
||||
mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} ${ARTIFACT_DIR}
|
||||
|
||||
cp -a /tmp/package-lock.json package-lock.json
|
||||
|
||||
if [[ ${IS_DOCKER} == YES ]]; then
|
||||
chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR}
|
||||
fi
|
||||
|
||||
popd
|
|
@ -1,41 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o xtrace
|
||||
|
||||
# move to source directory
|
||||
pushd ${SOURCE_DIR}
|
||||
|
||||
cp -a package-lock.json /tmp/package-lock.json
|
||||
|
||||
# modify changelog to unstable configuration if IS_UNSTABLE
|
||||
if [[ ${IS_UNSTABLE} == 'yes' ]]; then
|
||||
pushd fedora
|
||||
|
||||
PR_ID=$( git log --grep 'Merge pull request' --oneline --single-worktree --first-parent | head -1 | grep --color=none -Eo '#[0-9]+' | tr -d '#' )
|
||||
|
||||
sed -i "s/Version:.*/Version: ${BUILD_ID}/" jellyfin-web.spec
|
||||
sed -i "/%changelog/q" jellyfin-web.spec
|
||||
|
||||
cat <<EOF >>jellyfin-web.spec
|
||||
* $( LANG=C date '+%a %b %d %Y' ) Jellyfin Packaging Team <packaging@jellyfin.org>
|
||||
- Jellyfin Web unstable build ${BUILD_ID} for merged PR #${PR_ID}
|
||||
EOF
|
||||
popd
|
||||
fi
|
||||
|
||||
# build rpm
|
||||
make -f fedora/Makefile srpm outdir=/root/rpmbuild/SRPMS
|
||||
rpmbuild -rb /root/rpmbuild/SRPMS/jellyfin-*.src.rpm
|
||||
|
||||
# move the artifacts
|
||||
mv /root/rpmbuild/RPMS/noarch/jellyfin-*.rpm /root/rpmbuild/SRPMS/jellyfin-*.src.rpm ${ARTIFACT_DIR}
|
||||
|
||||
if [[ ${IS_DOCKER} == YES ]]; then
|
||||
chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR}
|
||||
fi
|
||||
|
||||
rm -f fedora/jellyfin*.tar.gz
|
||||
cp -a /tmp/package-lock.json package-lock.json
|
||||
|
||||
popd
|
|
@ -1,31 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o xtrace
|
||||
|
||||
# move to source directory
|
||||
pushd ${SOURCE_DIR}
|
||||
|
||||
# get version
|
||||
if [[ ${IS_UNSTABLE} == 'yes' ]]; then
|
||||
version="${BUILD_ID}"
|
||||
else
|
||||
version="$( grep "version:" ./build.yaml | sed -E 's/version: "([0-9\.]+.*)"/\1/' )"
|
||||
fi
|
||||
|
||||
# build archives
|
||||
npm ci --no-audit --unsafe-perm
|
||||
npm run build:production
|
||||
mv dist jellyfin-web_${version}
|
||||
tar -czf jellyfin-web_${version}_portable.tar.gz jellyfin-web_${version}
|
||||
rm -rf dist
|
||||
|
||||
# move the artifacts
|
||||
mkdir -p ${ARTIFACT_DIR}
|
||||
mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}
|
||||
|
||||
if [[ ${IS_DOCKER} == YES ]]; then
|
||||
chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR}
|
||||
fi
|
||||
|
||||
popd
|
|
@ -1,48 +0,0 @@
|
|||
DIR := $(dir $(lastword $(MAKEFILE_LIST)))
|
||||
# install git and npm
|
||||
$(info $(shell set -x; if [ "$$(id -u)" = "0" ]; then echo "Installing git"; dnf -y install git npm; fi))
|
||||
NAME := jellyfin-web
|
||||
VERSION := $(shell set -x; sed -ne '/^Version:/s/.* *//p' $(DIR)/$(NAME).spec)
|
||||
RELEASE := $(shell set -x; sed -ne '/^Release:/s/.* *\(.*\)%{.*}.*/\1/p' $(DIR)/$(NAME).spec)
|
||||
SRPM := jellyfin-web-$(subst -,~,$(VERSION))-$(RELEASE)$(shell rpm --eval %dist).src.rpm
|
||||
TARBALL :=$(NAME)-$(subst -,~,$(VERSION)).tar.gz
|
||||
|
||||
epel-7-x86_64_repos := https://rpm.nodesource.com/pub_20.x/nodistro/\$$basearch/
|
||||
|
||||
fed_ver := $(shell rpm -E %fedora)
|
||||
# fallback when not running on Fedora
|
||||
fed_ver ?= 36
|
||||
TARGET ?= fedora-$(fed_ver)-x86_64
|
||||
|
||||
outdir ?= $(PWD)/$(DIR)/
|
||||
|
||||
srpm: $(DIR)/$(SRPM)
|
||||
tarball: $(DIR)/$(TARBALL)
|
||||
|
||||
$(DIR)/$(TARBALL):
|
||||
cd $(DIR)/; \
|
||||
SOURCE_DIR=.. \
|
||||
WORKDIR="$${PWD}"; \
|
||||
version=$(VERSION); \
|
||||
tar \
|
||||
--transform "s,^\.,$(NAME)-$(subst -,~,$(VERSION))," \
|
||||
--exclude='.git*' \
|
||||
--exclude='**/.git' \
|
||||
--exclude='**/.hg' \
|
||||
--exclude=deployment \
|
||||
--exclude='*.deb' \
|
||||
--exclude='*.rpm' \
|
||||
--exclude=$(notdir $@) \
|
||||
-czf $(notdir $@) \
|
||||
-C $${SOURCE_DIR} ./
|
||||
|
||||
$(DIR)/$(SRPM): $(DIR)/$(TARBALL) $(DIR)/jellyfin-web.spec
|
||||
cd $(DIR)/; \
|
||||
rpmbuild -bs $(NAME).spec \
|
||||
--define "_sourcedir $$PWD/" \
|
||||
--define "_srcrpmdir $(outdir)"
|
||||
|
||||
rpms: $(DIR)/$(SRPM)
|
||||
mock $(addprefix --addrepo=, $($(TARGET)_repos)) \
|
||||
--enable-network \
|
||||
-r $(TARGET) $<
|
|
@ -1,58 +0,0 @@
|
|||
%global debug_package %{nil}
|
||||
|
||||
Name: jellyfin-web
|
||||
Version: 10.8.0
|
||||
Release: 2%{?dist}
|
||||
Summary: The Free Software Media System web client
|
||||
License: GPLv2
|
||||
URL: https://jellyfin.org
|
||||
# Jellyfin Server tarball created by `make -f .copr/Makefile srpm`, real URL ends with `v%%{version}.tar.gz`
|
||||
Source0: jellyfin-web-%{version}.tar.gz
|
||||
|
||||
BuildArch: noarch
|
||||
%if 0%{?rhel} > 0 && 0%{?rhel} < 8
|
||||
BuildRequires: nodejs
|
||||
%else
|
||||
BuildRequires: git
|
||||
# Nodejs 20 is required and npm >= 10 should bring in NodeJS 20
|
||||
# This requires the build environment to use the nodejs:20 module stream:
|
||||
# dnf module {install|switch-to}:web nodejs:20
|
||||
BuildRequires: npm >= 10
|
||||
%endif
|
||||
|
||||
%description
|
||||
Jellyfin is a free software media system that puts you in control of managing and streaming your media.
|
||||
|
||||
|
||||
%prep
|
||||
%autosetup -n jellyfin-web-%{version} -b 0
|
||||
|
||||
%if 0%{?rhel} > 0 && 0%{?rhel} < 8
|
||||
# Required for CentOS build
|
||||
chown root:root -R .
|
||||
%endif
|
||||
|
||||
|
||||
%build
|
||||
npm ci --no-audit --unsafe-perm
|
||||
npm run build:production
|
||||
|
||||
|
||||
%install
|
||||
%{__mkdir} -p %{buildroot}%{_libdir}/jellyfin/jellyfin-web
|
||||
%{__cp} -r dist/* %{buildroot}%{_libdir}/jellyfin/jellyfin-web
|
||||
|
||||
|
||||
%files
|
||||
%defattr(644,root,root,755)
|
||||
%{_libdir}/jellyfin/jellyfin-web
|
||||
%license LICENSE
|
||||
|
||||
|
||||
%changelog
|
||||
* Fri Dec 04 2020 Jellyfin Packaging Team <packaging@jellyfin.org>
|
||||
- Forthcoming stable release
|
||||
* Mon Jul 27 2020 Jellyfin Packaging Team <packaging@jellyfin.org>
|
||||
- Forthcoming stable release
|
||||
* Mon Mar 23 2020 Jellyfin Packaging Team <packaging@jellyfin.org>
|
||||
- Forthcoming stable release
|
1288
package-lock.json
generated
1288
package-lock.json
generated
File diff suppressed because it is too large
Load diff
31
package.json
31
package.json
|
@ -12,12 +12,12 @@
|
|||
"@babel/preset-env": "7.23.8",
|
||||
"@babel/preset-react": "7.23.3",
|
||||
"@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/markdown-it": "13.0.7",
|
||||
"@types/react": "17.0.75",
|
||||
"@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/parser": "5.62.0",
|
||||
"@uupaa/dynamic-import-polyfill": "1.0.2",
|
||||
|
@ -29,7 +29,7 @@
|
|||
"copy-webpack-plugin": "12.0.2",
|
||||
"cross-env": "7.0.3",
|
||||
"css-loader": "6.9.1",
|
||||
"cssnano": "6.0.3",
|
||||
"cssnano": "6.0.5",
|
||||
"es-check": "7.1.1",
|
||||
"eslint": "8.56.0",
|
||||
"eslint-plugin-compat": "4.2.0",
|
||||
|
@ -61,7 +61,7 @@
|
|||
"stylelint-scss": "5.3.2",
|
||||
"ts-loader": "9.5.1",
|
||||
"typescript": "5.3.3",
|
||||
"vitest": "1.2.1",
|
||||
"vitest": "1.3.0",
|
||||
"webpack": "5.89.0",
|
||||
"webpack-bundle-analyzer": "4.10.1",
|
||||
"webpack-cli": "5.1.4",
|
||||
|
@ -70,7 +70,7 @@
|
|||
"worker-loader": "3.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.11.3",
|
||||
"@emotion/react": "11.11.4",
|
||||
"@emotion/styled": "11.11.0",
|
||||
"@fontsource/noto-sans": "5.0.18",
|
||||
"@fontsource/noto-sans-hk": "5.0.17",
|
||||
|
@ -78,14 +78,16 @@
|
|||
"@fontsource/noto-sans-kr": "5.0.17",
|
||||
"@fontsource/noto-sans-sc": "5.0.17",
|
||||
"@fontsource/noto-sans-tc": "5.0.17",
|
||||
"@jellyfin/sdk": "unstable",
|
||||
"@jellyfin/libass-wasm": "4.2.1",
|
||||
"@jellyfin/sdk": "0.0.0-unstable.202403240502",
|
||||
"@loadable/component": "5.16.3",
|
||||
"@mui/icons-material": "5.15.5",
|
||||
"@mui/material": "5.15.5",
|
||||
"@mui/x-data-grid": "6.18.7",
|
||||
"@mui/icons-material": "5.15.11",
|
||||
"@mui/material": "5.15.11",
|
||||
"@mui/x-data-grid": "6.19.5",
|
||||
"@react-hook/resize-observer": "1.2.6",
|
||||
"@tanstack/react-query": "4.36.1",
|
||||
"@tanstack/react-query-devtools": "4.36.1",
|
||||
"@types/react-lazy-load-image-component": "1.6.3",
|
||||
"abortcontroller-polyfill": "1.7.5",
|
||||
"blurhash": "2.0.5",
|
||||
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
|
||||
|
@ -95,14 +97,12 @@
|
|||
"dompurify": "3.0.1",
|
||||
"epubjs": "0.3.93",
|
||||
"escape-html": "1.0.3",
|
||||
"event-target-polyfill": "github:ThaUnknown/event-target-polyfill",
|
||||
"fast-text-encoding": "1.0.6",
|
||||
"flv.js": "1.6.2",
|
||||
"headroom.js": "0.12.0",
|
||||
"history": "5.3.0",
|
||||
"hls.js": "1.5.1",
|
||||
"hls.js": "1.5.7",
|
||||
"intersection-observer": "0.12.2",
|
||||
"jassub": "1.7.15",
|
||||
"jellyfin-apiclient": "1.11.0",
|
||||
"jquery": "3.7.1",
|
||||
"jstree": "3.3.16",
|
||||
|
@ -113,12 +113,15 @@
|
|||
"native-promise-only": "0.8.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"react": "17.0.2",
|
||||
"react-blurhash": "0.3.0",
|
||||
"react-dom": "17.0.2",
|
||||
"react-lazy-load-image-component": "1.6.0",
|
||||
"react-router-dom": "6.21.3",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"screenfull": "6.0.2",
|
||||
"sortablejs": "1.15.2",
|
||||
"swiper": "11.0.5",
|
||||
"usehooks-ts": "2.14.0",
|
||||
"webcomponents.js": "0.7.24",
|
||||
"whatwg-fetch": "3.6.20"
|
||||
},
|
||||
|
@ -142,8 +145,8 @@
|
|||
"start": "npm run serve",
|
||||
"serve": "webpack serve --config webpack.dev.js",
|
||||
"build:analyze": "cross-env NODE_ENV=\"production\" webpack --config webpack.analyze.js",
|
||||
"build:development": "cross-env NODE_OPTIONS=\"--max_old_space_size=6144\" webpack --config webpack.dev.js",
|
||||
"build:production": "cross-env NODE_ENV=\"production\" NODE_OPTIONS=\"--max_old_space_size=6144\" webpack --config webpack.prod.js",
|
||||
"build:development": "webpack --config webpack.dev.js",
|
||||
"build:production": "cross-env NODE_ENV=\"production\" webpack --config webpack.prod.js",
|
||||
"build:check": "tsc --noEmit",
|
||||
"escheck": "es-check",
|
||||
"lint": "eslint \"./\"",
|
||||
|
|
|
@ -1,26 +1,15 @@
|
|||
import { Devices, Analytics, Input, ExpandLess, ExpandMore } from '@mui/icons-material';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import { Devices, Analytics, Input } from '@mui/icons-material';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
const DLNA_PATHS = [
|
||||
'/dashboard/dlna',
|
||||
'/dashboard/dlna/profiles'
|
||||
];
|
||||
|
||||
const DevicesDrawerSection = () => {
|
||||
const location = useLocation();
|
||||
|
||||
const isDlnaSectionOpen = DLNA_PATHS.includes(location.pathname);
|
||||
|
||||
return (
|
||||
<List
|
||||
aria-labelledby='devices-subheader'
|
||||
|
@ -47,24 +36,13 @@ const DevicesDrawerSection = () => {
|
|||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/dlna' selected={false}>
|
||||
<ListItemLink to='/dashboard/dlna'>
|
||||
<ListItemIcon>
|
||||
<Input />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={'DLNA'} />
|
||||
{isDlnaSectionOpen ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItemLink>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -21,7 +21,8 @@ const LIBRARY_PATHS = [
|
|||
const PLAYBACK_PATHS = [
|
||||
'/dashboard/playback/transcoding',
|
||||
'/dashboard/playback/resume',
|
||||
'/dashboard/playback/streaming'
|
||||
'/dashboard/playback/streaming',
|
||||
'/dashboard/playback/trickplay'
|
||||
];
|
||||
|
||||
const ServerDrawerSection = () => {
|
||||
|
@ -108,6 +109,9 @@ const ServerDrawerSection = () => {
|
|||
<ListItemLink to='/dashboard/playback/streaming' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('TabStreaming')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/dashboard/playback/trickplay' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('Trickplay')} />
|
||||
</ListItemLink>
|
||||
</List>
|
||||
</Collapse>
|
||||
</List>
|
||||
|
|
|
@ -2,11 +2,13 @@ import { AsyncRouteType, type AsyncRoute } from 'components/router/AsyncRoute';
|
|||
|
||||
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||
{ path: 'activity', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'dlna', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'notifications', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/access', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/add', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/parentalcontrol', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/password', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/profile', type: AsyncRouteType.Dashboard }
|
||||
{ path: 'users/profile', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'playback/trickplay', type: AsyncRouteType.Dashboard }
|
||||
];
|
||||
|
|
|
@ -31,24 +31,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
|||
controller: 'dashboard/devices/device',
|
||||
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',
|
||||
pageProps: {
|
||||
|
|
|
@ -8,8 +8,8 @@ export const REDIRECTS: Redirect[] = [
|
|||
{ from: 'dashboardgeneral.html', to: '/dashboard/settings' },
|
||||
{ from: 'device.html', to: '/dashboard/devices/edit' },
|
||||
{ from: 'devices.html', to: '/dashboard/devices' },
|
||||
{ from: 'dlnaprofile.html', to: '/dashboard/dlna/profiles/edit' },
|
||||
{ from: 'dlnaprofiles.html', to: '/dashboard/dlna/profiles' },
|
||||
{ from: 'dlnaprofile.html', to: '/dashboard/dlna' },
|
||||
{ from: 'dlnaprofiles.html', to: '/dashboard/dlna' },
|
||||
{ from: 'dlnasettings.html', to: '/dashboard/dlna' },
|
||||
{ from: 'edititemmetadata.html', to: '/metadata' },
|
||||
{ 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 { Link } from 'react-router-dom';
|
||||
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
const PluginLink = () => (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `<a
|
||||
is='emby-linkbutton'
|
||||
class='button-link'
|
||||
href='#/dashboard/plugins/add?name=Webhook&guid=71552a5a5c5c4350a2aeebe451a30173'
|
||||
>
|
||||
${globalize.translate('GetThePlugin')}
|
||||
</a>`
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const Notifications = () => (
|
||||
const NotificationsPage = () => (
|
||||
<Page
|
||||
id='notificationSettingPage'
|
||||
title={globalize.translate('Notifications')}
|
||||
|
@ -25,12 +15,20 @@ const Notifications = () => (
|
|||
>
|
||||
<div className='content-primary'>
|
||||
<h2>{globalize.translate('Notifications')}</h2>
|
||||
<p>
|
||||
{globalize.translate('NotificationsMovedMessage')}
|
||||
</p>
|
||||
<PluginLink />
|
||||
|
||||
<Alert severity='info'>
|
||||
<Box sx={{ marginBottom: 2 }}>
|
||||
{globalize.translate('NotificationsMovedMessage')}
|
||||
</Box>
|
||||
<Button
|
||||
component={Link}
|
||||
to='/dashboard/plugins/add?name=Webhook&guid=71552a5a5c5c4350a2aeebe451a30173'
|
||||
>
|
||||
{globalize.translate('GetThePlugin')}
|
||||
</Button>
|
||||
</Alert>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
|
||||
export default Notifications;
|
||||
export default NotificationsPage;
|
||||
|
|
305
src/apps/dashboard/routes/playback/trickplay.tsx
Normal file
305
src/apps/dashboard/routes/playback/trickplay.tsx
Normal file
|
@ -0,0 +1,305 @@
|
|||
import type { ProcessPriorityClass, ServerConfiguration, TrickplayScanBehavior } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { type FunctionComponent, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import globalize from '../../../../scripts/globalize';
|
||||
import Page from '../../../../components/Page';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import ButtonElement from '../../../../elements/ButtonElement';
|
||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import SelectElement from '../../../../elements/SelectElement';
|
||||
import InputElement from '../../../../elements/InputElement';
|
||||
import LinkTrickplayAcceleration from '../../../../components/dashboard/playback/trickplay/LinkTrickplayAcceleration';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import toast from '../../../../components/toast/toast';
|
||||
import ServerConnections from '../../../../components/ServerConnections';
|
||||
|
||||
function onSaveComplete() {
|
||||
loading.hide();
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
}
|
||||
|
||||
const PlaybackTrickplay: FunctionComponent = () => {
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadConfig = useCallback((config) => {
|
||||
const page = element.current;
|
||||
const options = config.TrickplayOptions;
|
||||
|
||||
if (!page) {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
|
||||
(page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked = options.EnableHwAcceleration;
|
||||
(page.querySelector('#selectScanBehavior') as HTMLSelectElement).value = options.ScanBehavior;
|
||||
(page.querySelector('#selectProcessPriority') as HTMLSelectElement).value = options.ProcessPriority;
|
||||
(page.querySelector('#txtInterval') as HTMLInputElement).value = options.Interval;
|
||||
(page.querySelector('#txtWidthResolutions') as HTMLInputElement).value = options.WidthResolutions.join(',');
|
||||
(page.querySelector('#txtTileWidth') as HTMLInputElement).value = options.TileWidth;
|
||||
(page.querySelector('#txtTileHeight') as HTMLInputElement).value = options.TileHeight;
|
||||
(page.querySelector('#txtQscale') as HTMLInputElement).value = options.Qscale;
|
||||
(page.querySelector('#txtJpegQuality') as HTMLInputElement).value = options.JpegQuality;
|
||||
(page.querySelector('#txtProcessThreads') as HTMLInputElement).value = options.ProcessThreads;
|
||||
|
||||
loading.hide();
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
loading.show();
|
||||
|
||||
ServerConnections.currentApiClient()?.getServerConfiguration().then(function (config) {
|
||||
loadConfig(config);
|
||||
}).catch(err => {
|
||||
console.error('[PlaybackTrickplay] failed to fetch server config', err);
|
||||
});
|
||||
}, [loadConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
|
||||
const saveConfig = (config: ServerConfiguration) => {
|
||||
const apiClient = ServerConnections.currentApiClient();
|
||||
|
||||
if (!apiClient) {
|
||||
console.error('[PlaybackTrickplay] No current apiclient instance');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.TrickplayOptions) {
|
||||
throw new Error('Unexpected null TrickplayOptions');
|
||||
}
|
||||
|
||||
const options = config.TrickplayOptions;
|
||||
options.EnableHwAcceleration = (page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked;
|
||||
options.ScanBehavior = (page.querySelector('#selectScanBehavior') as HTMLSelectElement).value as TrickplayScanBehavior;
|
||||
options.ProcessPriority = (page.querySelector('#selectProcessPriority') as HTMLSelectElement).value as ProcessPriorityClass;
|
||||
options.Interval = Math.max(1, parseInt((page.querySelector('#txtInterval') as HTMLInputElement).value || '10000', 10));
|
||||
options.WidthResolutions = (page.querySelector('#txtWidthResolutions') as HTMLInputElement).value.replace(' ', '').split(',').map(Number);
|
||||
options.TileWidth = Math.max(1, parseInt((page.querySelector('#txtTileWidth') as HTMLInputElement).value || '10', 10));
|
||||
options.TileHeight = Math.max(1, parseInt((page.querySelector('#txtTileHeight') as HTMLInputElement).value || '10', 10));
|
||||
options.Qscale = Math.min(31, parseInt((page.querySelector('#txtQscale') as HTMLInputElement).value || '4', 10));
|
||||
options.JpegQuality = Math.min(100, parseInt((page.querySelector('#txtJpegQuality') as HTMLInputElement).value || '90', 10));
|
||||
options.ProcessThreads = parseInt((page.querySelector('#txtProcessThreads') as HTMLInputElement).value || '1', 10);
|
||||
|
||||
apiClient.updateServerConfiguration(config).then(() => {
|
||||
onSaveComplete();
|
||||
}).catch(err => {
|
||||
console.error('[PlaybackTrickplay] failed to update config', err);
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = (e: Event) => {
|
||||
const apiClient = ServerConnections.currentApiClient();
|
||||
|
||||
if (!apiClient) {
|
||||
console.error('[PlaybackTrickplay] No current apiclient instance');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.show();
|
||||
apiClient.getServerConfiguration().then(function (config) {
|
||||
saveConfig(config);
|
||||
}).catch(err => {
|
||||
console.error('[PlaybackTrickplay] failed to fetch server config', err);
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
};
|
||||
|
||||
(page.querySelector('.trickplayConfigurationForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const optionScanBehavior = () => {
|
||||
let content = '';
|
||||
content += `<option value='NonBlocking'>${globalize.translate('NonBlockingScan')}</option>`;
|
||||
content += `<option value='Blocking'>${globalize.translate('BlockingScan')}</option>`;
|
||||
return content;
|
||||
};
|
||||
|
||||
const optionProcessPriority = () => {
|
||||
let content = '';
|
||||
content += `<option value='High'>${globalize.translate('PriorityHigh')}</option>`;
|
||||
content += `<option value='AboveNormal'>${globalize.translate('PriorityAboveNormal')}</option>`;
|
||||
content += `<option value='Normal'>${globalize.translate('PriorityNormal')}</option>`;
|
||||
content += `<option value='BelowNormal'>${globalize.translate('PriorityBelowNormal')}</option>`;
|
||||
content += `<option value='Idle'>${globalize.translate('PriorityIdle')}</option>`;
|
||||
return content;
|
||||
};
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='trickplayConfigurationPage'
|
||||
className='mainAnimatedPage type-interior playbackConfigurationPage'
|
||||
>
|
||||
<div ref={element} className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={globalize.translate('Trickplay')}
|
||||
isLinkVisible={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form className='trickplayConfigurationForm'>
|
||||
<div className='checkboxContainer checkboxContainer-withDescription'>
|
||||
<CheckBoxElement
|
||||
className='chkEnableHwAcceleration'
|
||||
title='LabelTrickplayAccel'
|
||||
/>
|
||||
<div className='fieldDescription checkboxFieldDescription'>
|
||||
<LinkTrickplayAcceleration
|
||||
title='LabelTrickplayAccelHelp'
|
||||
href='#/dashboard/playback/transcoding'
|
||||
className='button-link'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='selectContainer fldSelectScanBehavior'>
|
||||
<SelectElement
|
||||
id='selectScanBehavior'
|
||||
label='LabelScanBehavior'
|
||||
>
|
||||
{optionScanBehavior()}
|
||||
</SelectElement>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelScanBehaviorHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='selectContainer fldSelectProcessPriority'>
|
||||
<SelectElement
|
||||
id='selectProcessPriority'
|
||||
label='LabelProcessPriority'
|
||||
>
|
||||
{optionProcessPriority()}
|
||||
</SelectElement>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelProcessPriorityHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtInterval'
|
||||
label='LabelImageInterval'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelImageIntervalHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='text'
|
||||
id='txtWidthResolutions'
|
||||
label='LabelWidthResolutions'
|
||||
options={'required pattern="[0-9,]*"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelWidthResolutionsHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtTileWidth'
|
||||
label='LabelTileWidth'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelTileWidthHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtTileHeight'
|
||||
label='LabelTileHeight'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelTileHeightHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtJpegQuality'
|
||||
label='LabelJpegQuality'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="1" max="100"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelJpegQualityHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtQscale'
|
||||
label='LabelQscale'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="2" max="31"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelQscaleHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtProcessThreads'
|
||||
label='LabelTrickplayThreads'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="0"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelTrickplayThreadsHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ButtonElement
|
||||
type='submit'
|
||||
className='raised button-submit block'
|
||||
title='Save'
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaybackTrickplay;
|
|
@ -49,6 +49,7 @@ const UserProfiles: FunctionComponent = () => {
|
|||
const showUserMenu = (elem: HTMLElement) => {
|
||||
const card = dom.parentWithClass(elem, 'card');
|
||||
const userId = card?.getAttribute('data-userid');
|
||||
const username = card?.getAttribute('data-username');
|
||||
|
||||
if (!userId) {
|
||||
console.error('Unexpected null user id');
|
||||
|
@ -58,7 +59,7 @@ const UserProfiles: FunctionComponent = () => {
|
|||
const menuItems: MenuEntry[] = [];
|
||||
|
||||
menuItems.push({
|
||||
name: globalize.translate('ButtonOpen'),
|
||||
name: globalize.translate('ButtonEditUser'),
|
||||
id: 'open',
|
||||
icon: 'mode_edit'
|
||||
});
|
||||
|
@ -106,7 +107,7 @@ const UserProfiles: FunctionComponent = () => {
|
|||
break;
|
||||
|
||||
case 'delete':
|
||||
deleteUser(userId);
|
||||
deleteUser(userId, username);
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
|
@ -117,12 +118,13 @@ const UserProfiles: FunctionComponent = () => {
|
|||
});
|
||||
};
|
||||
|
||||
const deleteUser = (id: string) => {
|
||||
const msg = globalize.translate('DeleteUserConfirmation');
|
||||
const deleteUser = (id: string, username?: string | null) => {
|
||||
const title = username ? globalize.translate('DeleteName', username) : globalize.translate('DeleteUser');
|
||||
const text = globalize.translate('DeleteUserConfirmation');
|
||||
|
||||
confirm({
|
||||
title: globalize.translate('DeleteUser'),
|
||||
text: msg,
|
||||
title,
|
||||
text,
|
||||
confirmText: globalize.translate('Delete'),
|
||||
primary: 'delete'
|
||||
}).then(function () {
|
||||
|
|
|
@ -6,7 +6,7 @@ import escapeHTML from 'escape-html';
|
|||
import globalize from '../../../../scripts/globalize';
|
||||
import LibraryMenu from '../../../../scripts/libraryMenu';
|
||||
import AccessScheduleList from '../../../../components/dashboard/users/AccessScheduleList';
|
||||
import BlockedTagList from '../../../../components/dashboard/users/BlockedTagList';
|
||||
import TagList from '../../../../components/dashboard/users/TagList';
|
||||
import ButtonElement from '../../../../elements/ButtonElement';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||
|
@ -16,6 +16,8 @@ import { getParameterByName } from '../../../../utils/url';
|
|||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import SelectElement from '../../../../elements/SelectElement';
|
||||
import Page from '../../../../components/Page';
|
||||
import prompt from '../../../../components/prompt/prompt';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
|
||||
type UnratedItem = {
|
||||
name: string;
|
||||
|
@ -23,12 +25,44 @@ type UnratedItem = {
|
|||
checkedAttribute: string
|
||||
};
|
||||
|
||||
function handleSaveUser(
|
||||
page: HTMLDivElement,
|
||||
getSchedulesFromPage: () => AccessSchedule[],
|
||||
getAllowedTagsFromPage: () => string[],
|
||||
getBlockedTagsFromPage: () => string[],
|
||||
onSaveComplete: () => void
|
||||
) {
|
||||
return (user: UserDto) => {
|
||||
const userId = user.Id;
|
||||
const userPolicy = user.Policy;
|
||||
if (!userId || !userPolicy) {
|
||||
throw new Error('Unexpected null user id or policy');
|
||||
}
|
||||
|
||||
const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
|
||||
userPolicy.MaxParentalRating = Number.isNaN(parentalRating) ? null : parentalRating;
|
||||
userPolicy.BlockUnratedItems = Array.prototype.filter
|
||||
.call(page.querySelectorAll('.chkUnratedItem'), i => i.checked)
|
||||
.map(i => i.getAttribute('data-itemtype'));
|
||||
userPolicy.AccessSchedules = getSchedulesFromPage();
|
||||
userPolicy.AllowedTags = getAllowedTagsFromPage();
|
||||
userPolicy.BlockedTags = getBlockedTagsFromPage();
|
||||
ServerConnections.getCurrentApiClientAsync()
|
||||
.then(apiClient => apiClient.updateUserPolicy(userId, userPolicy))
|
||||
.then(() => onSaveComplete())
|
||||
.catch(err => {
|
||||
console.error('[userparentalcontrol] failed to update user policy', err);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const UserParentalControl: FunctionComponent = () => {
|
||||
const [ userName, setUserName ] = useState('');
|
||||
const [ parentalRatings, setParentalRatings ] = useState<ParentalRating[]>([]);
|
||||
const [ unratedItems, setUnratedItems ] = useState<UnratedItem[]>([]);
|
||||
const [ accessSchedules, setAccessSchedules ] = useState<AccessSchedule[]>([]);
|
||||
const [ blockedTags, setBlockedTags ] = useState([]);
|
||||
const [ allowedTags, setAllowedTags ] = useState<string[]>([]);
|
||||
const [ blockedTags, setBlockedTags ] = useState<string[]>([]);
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
@ -106,7 +140,28 @@ const UserParentalControl: FunctionComponent = () => {
|
|||
blockUnratedItems.dispatchEvent(new CustomEvent('create'));
|
||||
}, []);
|
||||
|
||||
const loadBlockedTags = useCallback((tags) => {
|
||||
const loadAllowedTags = useCallback((tags: string[]) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
|
||||
setAllowedTags(tags);
|
||||
|
||||
const allowedTagsElem = page.querySelector('.allowedTags') as HTMLDivElement;
|
||||
|
||||
for (const btnDeleteTag of allowedTagsElem.querySelectorAll('.btnDeleteTag')) {
|
||||
btnDeleteTag.addEventListener('click', function () {
|
||||
const tag = btnDeleteTag.getAttribute('data-tag');
|
||||
const newTags = tags.filter(t => t !== tag);
|
||||
loadAllowedTags(newTags);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadBlockedTags = useCallback((tags: string[]) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
|
@ -121,9 +176,7 @@ const UserParentalControl: FunctionComponent = () => {
|
|||
for (const btnDeleteTag of blockedTagsElem.querySelectorAll('.btnDeleteTag')) {
|
||||
btnDeleteTag.addEventListener('click', function () {
|
||||
const tag = btnDeleteTag.getAttribute('data-tag');
|
||||
const newTags = tags.filter(function (t: string) {
|
||||
return t != tag;
|
||||
});
|
||||
const newTags = tags.filter(t => t !== tag);
|
||||
loadBlockedTags(newTags);
|
||||
});
|
||||
}
|
||||
|
@ -145,15 +198,13 @@ const UserParentalControl: FunctionComponent = () => {
|
|||
btnDelete.addEventListener('click', function () {
|
||||
const index = parseInt(btnDelete.getAttribute('data-index') ?? '0', 10);
|
||||
schedules.splice(index, 1);
|
||||
const newindex = schedules.filter(function (i: number) {
|
||||
return i != index;
|
||||
});
|
||||
const newindex = schedules.filter((i: number) => i != index);
|
||||
renderAccessSchedule(newindex);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUser = useCallback((user, allParentalRatings) => {
|
||||
const loadUser = useCallback((user: UserDto, allParentalRatings: ParentalRating[]) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
|
@ -161,34 +212,33 @@ const UserParentalControl: FunctionComponent = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
setUserName(user.Name);
|
||||
setUserName(user.Name || '');
|
||||
LibraryMenu.setTitle(user.Name);
|
||||
loadUnratedItems(user);
|
||||
|
||||
loadBlockedTags(user.Policy.BlockedTags);
|
||||
loadAllowedTags(user.Policy?.AllowedTags || []);
|
||||
loadBlockedTags(user.Policy?.BlockedTags || []);
|
||||
populateRatings(allParentalRatings);
|
||||
|
||||
let ratingValue = '';
|
||||
|
||||
if (user.Policy.MaxParentalRating != null) {
|
||||
for (let i = 0, length = allParentalRatings.length; i < length; i++) {
|
||||
const rating = allParentalRatings[i];
|
||||
|
||||
if (user.Policy.MaxParentalRating >= rating.Value) {
|
||||
ratingValue = rating.Value;
|
||||
if (user.Policy?.MaxParentalRating) {
|
||||
allParentalRatings.forEach(rating => {
|
||||
if (rating.Value && user.Policy?.MaxParentalRating && user.Policy.MaxParentalRating >= rating.Value) {
|
||||
ratingValue = `${rating.Value}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
(page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value = ratingValue;
|
||||
|
||||
if (user.Policy.IsAdministrator) {
|
||||
if (user.Policy?.IsAdministrator) {
|
||||
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.add('hide');
|
||||
} else {
|
||||
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.remove('hide');
|
||||
}
|
||||
renderAccessSchedule(user.Policy.AccessSchedules || []);
|
||||
renderAccessSchedule(user.Policy?.AccessSchedules || []);
|
||||
loading.hide();
|
||||
}, [loadBlockedTags, loadUnratedItems, populateRatings, renderAccessSchedule]);
|
||||
}, [loadAllowedTags, loadBlockedTags, loadUnratedItems, populateRatings, renderAccessSchedule]);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
loading.show();
|
||||
|
@ -212,32 +262,6 @@ const UserParentalControl: FunctionComponent = () => {
|
|||
|
||||
loadData();
|
||||
|
||||
const onSaveComplete = () => {
|
||||
loading.hide();
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
};
|
||||
|
||||
const saveUser = (user: UserDto) => {
|
||||
if (!user.Id || !user.Policy) {
|
||||
throw new Error('Unexpected null user id or policy');
|
||||
}
|
||||
|
||||
const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
|
||||
user.Policy.MaxParentalRating = Number.isNaN(parentalRating) ? null : parentalRating;
|
||||
user.Policy.BlockUnratedItems = Array.prototype.filter.call(page.querySelectorAll('.chkUnratedItem'), function (i) {
|
||||
return i.checked;
|
||||
}).map(function (i) {
|
||||
return i.getAttribute('data-itemtype');
|
||||
});
|
||||
user.Policy.AccessSchedules = getSchedulesFromPage();
|
||||
user.Policy.BlockedTags = getBlockedTagsFromPage();
|
||||
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
|
||||
onSaveComplete();
|
||||
}).catch(err => {
|
||||
console.error('[userparentalcontrol] failed to update user policy', err);
|
||||
});
|
||||
};
|
||||
|
||||
const showSchedulePopup = (schedule: AccessSchedule, index: number) => {
|
||||
schedule = schedule || {};
|
||||
import('../../../../components/accessSchedule/accessSchedule').then(({ default: accessschedule }) => {
|
||||
|
@ -270,6 +294,27 @@ const UserParentalControl: FunctionComponent = () => {
|
|||
}) as AccessSchedule[];
|
||||
};
|
||||
|
||||
const getAllowedTagsFromPage = () => {
|
||||
return Array.prototype.map.call(page.querySelectorAll('.allowedTag'), function (elem) {
|
||||
return elem.getAttribute('data-tag');
|
||||
}) as string[];
|
||||
};
|
||||
|
||||
const showAllowedTagPopup = () => {
|
||||
prompt({
|
||||
label: globalize.translate('LabelTag')
|
||||
}).then(function (value) {
|
||||
const tags = getAllowedTagsFromPage();
|
||||
|
||||
if (tags.indexOf(value) == -1) {
|
||||
tags.push(value);
|
||||
loadAllowedTags(tags);
|
||||
}
|
||||
}).catch(() => {
|
||||
// prompt closed
|
||||
});
|
||||
};
|
||||
|
||||
const getBlockedTagsFromPage = () => {
|
||||
return Array.prototype.map.call(page.querySelectorAll('.blockedTag'), function (elem) {
|
||||
return elem.getAttribute('data-tag');
|
||||
|
@ -277,24 +322,27 @@ const UserParentalControl: FunctionComponent = () => {
|
|||
};
|
||||
|
||||
const showBlockedTagPopup = () => {
|
||||
import('../../../../components/prompt/prompt').then(({ default: prompt }) => {
|
||||
prompt({
|
||||
label: globalize.translate('LabelTag')
|
||||
}).then(function (value) {
|
||||
const tags = getBlockedTagsFromPage();
|
||||
prompt({
|
||||
label: globalize.translate('LabelTag')
|
||||
}).then(function (value) {
|
||||
const tags = getBlockedTagsFromPage();
|
||||
|
||||
if (tags.indexOf(value) == -1) {
|
||||
tags.push(value);
|
||||
loadBlockedTags(tags);
|
||||
}
|
||||
}).catch(() => {
|
||||
// prompt closed
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('[userparentalcontrol] failed to load prompt', err);
|
||||
if (tags.indexOf(value) == -1) {
|
||||
tags.push(value);
|
||||
loadBlockedTags(tags);
|
||||
}
|
||||
}).catch(() => {
|
||||
// prompt closed
|
||||
});
|
||||
};
|
||||
|
||||
const onSaveComplete = () => {
|
||||
loading.hide();
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
};
|
||||
|
||||
const saveUser = handleSaveUser(page, getSchedulesFromPage, getAllowedTagsFromPage, getBlockedTagsFromPage, onSaveComplete);
|
||||
|
||||
const onSubmit = (e: Event) => {
|
||||
loading.show();
|
||||
const userId = getParameterByName('userId');
|
||||
|
@ -318,12 +366,16 @@ const UserParentalControl: FunctionComponent = () => {
|
|||
}, -1);
|
||||
});
|
||||
|
||||
(page.querySelector('#btnAddAllowedTag') as HTMLButtonElement).addEventListener('click', function () {
|
||||
showAllowedTagPopup();
|
||||
});
|
||||
|
||||
(page.querySelector('#btnAddBlockedTag') as HTMLButtonElement).addEventListener('click', function () {
|
||||
showBlockedTagPopup();
|
||||
});
|
||||
|
||||
(page.querySelector('.userParentalControlForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
}, [loadBlockedTags, loadData, renderAccessSchedule]);
|
||||
}, [loadAllowedTags, loadBlockedTags, loadData, renderAccessSchedule]);
|
||||
|
||||
const optionMaxParentalRating = () => {
|
||||
let content = '';
|
||||
|
@ -378,6 +430,27 @@ const UserParentalControl: FunctionComponent = () => {
|
|||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div className='verticalSection' style={{ marginBottom: '2em' }}>
|
||||
<SectionTitleContainer
|
||||
SectionClassName='detailSectionHeader'
|
||||
title={globalize.translate('LabelAllowContentWithTags')}
|
||||
isBtnVisible={true}
|
||||
btnId='btnAddAllowedTag'
|
||||
btnClassName='fab submit sectionTitleButton'
|
||||
btnTitle='Add'
|
||||
btnIcon='add'
|
||||
isLinkVisible={false}
|
||||
/>
|
||||
<div className='allowedTags' style={{ marginTop: '.5em' }}>
|
||||
{allowedTags?.map(tag => {
|
||||
return <TagList
|
||||
key={tag}
|
||||
tag={tag}
|
||||
tagType='allowedTag'
|
||||
/>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className='verticalSection' style={{ marginBottom: '2em' }}>
|
||||
<SectionTitleContainer
|
||||
SectionClassName='detailSectionHeader'
|
||||
|
@ -391,9 +464,10 @@ const UserParentalControl: FunctionComponent = () => {
|
|||
/>
|
||||
<div className='blockedTags' style={{ marginTop: '.5em' }}>
|
||||
{blockedTags.map(tag => {
|
||||
return <BlockedTagList
|
||||
return <TagList
|
||||
key={tag}
|
||||
tag={tag}
|
||||
tagType='blockedTag'
|
||||
/>;
|
||||
})}
|
||||
</div>
|
||||
|
|
|
@ -182,6 +182,7 @@ const UserEdit: FunctionComponent = () => {
|
|||
(page.querySelector('.chkDisabled') as HTMLInputElement).checked = user.Policy.IsDisabled;
|
||||
(page.querySelector('.chkIsHidden') as HTMLInputElement).checked = user.Policy.IsHidden;
|
||||
(page.querySelector('.chkEnableCollectionManagement') as HTMLInputElement).checked = user.Policy.EnableCollectionManagement;
|
||||
(page.querySelector('.chkEnableSubtitleManagement') as HTMLInputElement).checked = user.Policy.EnableSubtitleManagement;
|
||||
(page.querySelector('.chkRemoteControlSharedDevices') as HTMLInputElement).checked = user.Policy.EnableSharedDeviceControl;
|
||||
(page.querySelector('.chkEnableRemoteControlOtherUsers') as HTMLInputElement).checked = user.Policy.EnableRemoteControlOfOtherUsers;
|
||||
(page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked = user.Policy.EnableContentDownloading;
|
||||
|
@ -240,6 +241,7 @@ const UserEdit: FunctionComponent = () => {
|
|||
user.Policy.EnableVideoPlaybackTranscoding = (page.querySelector('.chkEnableVideoPlaybackTranscoding') as HTMLInputElement).checked;
|
||||
user.Policy.EnablePlaybackRemuxing = (page.querySelector('.chkEnableVideoPlaybackRemuxing') as HTMLInputElement).checked;
|
||||
user.Policy.EnableCollectionManagement = (page.querySelector('.chkEnableCollectionManagement') as HTMLInputElement).checked;
|
||||
user.Policy.EnableSubtitleManagement = (page.querySelector('.chkEnableSubtitleManagement') as HTMLInputElement).checked;
|
||||
user.Policy.ForceRemoteSourceTranscoding = (page.querySelector('.chkForceRemoteSourceTranscoding') as HTMLInputElement).checked;
|
||||
user.Policy.EnableContentDownloading = (page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked;
|
||||
user.Policy.EnableRemoteAccess = (page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked;
|
||||
|
@ -392,6 +394,11 @@ const UserEdit: FunctionComponent = () => {
|
|||
className='chkEnableCollectionManagement'
|
||||
title='AllowCollectionManagement'
|
||||
/>
|
||||
<CheckBoxElement
|
||||
labelClassName='checkboxContainer'
|
||||
className='chkEnableSubtitleManagement'
|
||||
title='AllowSubtitleManagement'
|
||||
/>
|
||||
<div id='featureAccessFields' className='verticalSection'>
|
||||
<h2 className='paperListLabel'>
|
||||
{globalize.translate('HeaderFeatureAccess')}
|
||||
|
|
|
@ -3,15 +3,13 @@ import ListItemIcon from '@mui/material/ListItemIcon';
|
|||
import ListItemText from '@mui/material/ListItemText';
|
||||
import React from 'react';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { useSystemInfo } from 'hooks/useSystemInfo';
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
|
||||
import appIcon from 'assets/img/icon-transparent.png';
|
||||
|
||||
const DrawerHeaderLink = () => {
|
||||
const { api } = useApi();
|
||||
const { data: systemInfo } = useSystemInfo(api);
|
||||
const { data: systemInfo } = useSystemInfo();
|
||||
|
||||
return (
|
||||
<ListItemLink to='/'>
|
||||
|
|
|
@ -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 { useGetGenres } from 'hooks/useFetchItems';
|
||||
import globalize from 'scripts/globalize';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import GenresSectionContainer from './GenresSectionContainer';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import { ParentId } from 'types/library';
|
||||
import type { ParentId } from 'types/library';
|
||||
|
||||
interface GenresItemsContainerProps {
|
||||
parentId: ParentId;
|
||||
|
|
|
@ -1,18 +1,17 @@
|
|||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields';
|
||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
|
||||
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order';
|
||||
import escapeHTML from 'escape-html';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
import React, { type FC } from 'react';
|
||||
import { useGetItems } from 'hooks/useFetchItems';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
import SectionContainer from './SectionContainer';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import { ParentId } from 'types/library';
|
||||
import { CardShape } from 'utils/card';
|
||||
import type { ParentId } from 'types/library';
|
||||
|
||||
interface GenresSectionContainerProps {
|
||||
parentId: ParentId;
|
||||
|
@ -60,7 +59,7 @@ const GenresSectionContainer: FC<GenresSectionContainerProps> = ({
|
|||
}
|
||||
|
||||
return <SectionContainer
|
||||
sectionTitle={escapeHTML(genre.Name)}
|
||||
sectionTitle={genre.Name || ''}
|
||||
items={itemsResult?.Items || []}
|
||||
url={getRouteUrl(genre)}
|
||||
cardOptions={{
|
||||
|
@ -69,7 +68,7 @@ const GenresSectionContainer: FC<GenresSectionContainerProps> = ({
|
|||
showTitle: true,
|
||||
centerText: true,
|
||||
cardLayout: false,
|
||||
shape: collectionType === CollectionType.Music ? 'overflowSquare' : 'overflowPortrait',
|
||||
shape: collectionType === CollectionType.Music ? CardShape.SquareOverflow : CardShape.PortraitOverflow,
|
||||
showParentTitle: 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 GenresItemsContainer from './GenresItemsContainer';
|
||||
import { ParentId } from 'types/library';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import type { ParentId } from 'types/library';
|
||||
|
||||
interface GenresViewProps {
|
||||
parentId: ParentId;
|
||||
|
|
|
@ -1,17 +1,16 @@
|
|||
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 { 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 classNames from 'classnames';
|
||||
import { useLocalStorage } from 'hooks/useLocalStorage';
|
||||
import { useGetItem, useGetItemsViewByType } from 'hooks/useFetchItems';
|
||||
import { getDefaultLibraryViewSettings, getSettingsKey } from 'utils/items';
|
||||
import { CardShape } from 'utils/card';
|
||||
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 globalize from 'scripts/globalize';
|
||||
import ItemsContainer from 'elements/emby-itemscontainer/ItemsContainer';
|
||||
import AlphabetPicker from './AlphabetPicker';
|
||||
import FilterButton from './filter/FilterButton';
|
||||
|
@ -22,12 +21,13 @@ import QueueButton from './QueueButton';
|
|||
import ShuffleButton from './ShuffleButton';
|
||||
import SortButton from './SortButton';
|
||||
import GridListViewButton from './GridListViewButton';
|
||||
import { LibraryViewSettings, ParentId, ViewMode } from 'types/library';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import NoItemsMessage from 'components/common/NoItemsMessage';
|
||||
import Lists from 'components/listview/List/Lists';
|
||||
import Cards from 'components/cardbuilder/Card/Cards';
|
||||
import { LibraryTab } from 'types/libraryTab';
|
||||
|
||||
import { CardOptions } from 'types/cardOptions';
|
||||
import { ListOptions } from 'types/listOptions';
|
||||
import { type LibraryViewSettings, type ParentId, ViewMode } from 'types/library';
|
||||
import type { CardOptions } from 'types/cardOptions';
|
||||
import type { ListOptions } from 'types/listOptions';
|
||||
|
||||
interface ItemsViewProps {
|
||||
viewType: LibraryTab;
|
||||
|
@ -110,18 +110,18 @@ const ItemsView: FC<ItemsViewProps> = ({
|
|||
let preferLogo;
|
||||
|
||||
if (libraryViewSettings.ImageType === ImageType.Banner) {
|
||||
shape = 'banner';
|
||||
shape = CardShape.Banner;
|
||||
} else if (libraryViewSettings.ImageType === ImageType.Disc) {
|
||||
shape = 'square';
|
||||
shape = CardShape.Square;
|
||||
preferDisc = true;
|
||||
} else if (libraryViewSettings.ImageType === ImageType.Logo) {
|
||||
shape = 'backdrop';
|
||||
shape = CardShape.Backdrop;
|
||||
preferLogo = true;
|
||||
} else if (libraryViewSettings.ImageType === ImageType.Thumb) {
|
||||
shape = 'backdrop';
|
||||
shape = CardShape.Backdrop;
|
||||
preferThumb = true;
|
||||
} else {
|
||||
shape = 'auto';
|
||||
shape = CardShape.Auto;
|
||||
}
|
||||
|
||||
const cardOptions: CardOptions = {
|
||||
|
@ -135,9 +135,9 @@ const ItemsView: FC<ItemsViewProps> = ({
|
|||
preferThumb: preferThumb,
|
||||
preferDisc: preferDisc,
|
||||
preferLogo: preferLogo,
|
||||
overlayPlayButton: false,
|
||||
overlayMoreButton: true,
|
||||
overlayText: !libraryViewSettings.ShowTitle
|
||||
overlayText: !libraryViewSettings.ShowTitle,
|
||||
imageType: libraryViewSettings.ImageType,
|
||||
queryKey: ['ItemsViewByType']
|
||||
};
|
||||
|
||||
if (
|
||||
|
@ -146,20 +146,26 @@ const ItemsView: FC<ItemsViewProps> = ({
|
|||
|| viewType === LibraryTab.Episodes
|
||||
) {
|
||||
cardOptions.showParentTitle = libraryViewSettings.ShowTitle;
|
||||
cardOptions.overlayPlayButton = true;
|
||||
} else if (viewType === LibraryTab.Artists) {
|
||||
cardOptions.lines = 1;
|
||||
cardOptions.showYear = false;
|
||||
cardOptions.overlayPlayButton = true;
|
||||
} else if (viewType === LibraryTab.Channels) {
|
||||
cardOptions.shape = 'square';
|
||||
cardOptions.shape = CardShape.Square;
|
||||
cardOptions.showDetailsMenu = true;
|
||||
cardOptions.showCurrentProgram = true;
|
||||
cardOptions.showCurrentProgramTime = true;
|
||||
} else if (viewType === LibraryTab.SeriesTimers) {
|
||||
cardOptions.defaultShape = 'portrait';
|
||||
cardOptions.preferThumb = 'auto';
|
||||
cardOptions.shape = CardShape.Backdrop;
|
||||
cardOptions.showSeriesTimerTime = true;
|
||||
cardOptions.showSeriesTimerChannel = true;
|
||||
cardOptions.overlayMoreButton = true;
|
||||
cardOptions.lines = 3;
|
||||
} else if (viewType === LibraryTab.Movies) {
|
||||
cardOptions.overlayPlayButton = true;
|
||||
} else if (viewType === LibraryTab.Series || viewType === LibraryTab.Networks) {
|
||||
cardOptions.overlayMoreButton = true;
|
||||
}
|
||||
|
||||
return cardOptions;
|
||||
|
@ -172,27 +178,32 @@ const ItemsView: FC<ItemsViewProps> = ({
|
|||
viewType
|
||||
]);
|
||||
|
||||
const getItemsHtml = useCallback(() => {
|
||||
let html = '';
|
||||
const getItems = useCallback(() => {
|
||||
if (!itemsResult?.Items?.length) {
|
||||
return <NoItemsMessage noItemsMessage={noItemsMessage} />;
|
||||
}
|
||||
|
||||
if (libraryViewSettings.ViewMode === ViewMode.ListView) {
|
||||
html = listview.getListViewHtml(getListOptions());
|
||||
} else {
|
||||
html = cardBuilder.getCardsHtml(
|
||||
itemsResult?.Items ?? [],
|
||||
getCardOptions()
|
||||
return (
|
||||
<Lists
|
||||
items={itemsResult?.Items ?? []}
|
||||
listOptions={getListOptions()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!itemsResult?.Items?.length) {
|
||||
html += '<div class="noItemsMessage centerMessage">';
|
||||
html += '<h1>' + globalize.translate('MessageNothingHere') + '</h1>';
|
||||
html += '<p>' + globalize.translate(noItemsMessage) + '</p>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
return html;
|
||||
}, [libraryViewSettings.ViewMode, itemsResult?.Items, getListOptions, getCardOptions, noItemsMessage]);
|
||||
return (
|
||||
<Cards
|
||||
items={itemsResult?.Items ?? []}
|
||||
cardOptions={getCardOptions()}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
libraryViewSettings.ViewMode,
|
||||
itemsResult?.Items,
|
||||
getListOptions,
|
||||
getCardOptions,
|
||||
noItemsMessage
|
||||
]);
|
||||
|
||||
const totalRecordCount = itemsResult?.TotalRecordCount ?? 0;
|
||||
const items = itemsResult?.Items ?? [];
|
||||
|
@ -289,8 +300,10 @@ const ItemsView: FC<ItemsViewProps> = ({
|
|||
className={itemsContainerClass}
|
||||
parentId={parentId}
|
||||
reloadItems={refetch}
|
||||
getItemsHtml={getItemsHtml}
|
||||
/>
|
||||
queryKey={['ItemsViewByType']}
|
||||
>
|
||||
{getItems()}
|
||||
</ItemsContainer>
|
||||
)}
|
||||
|
||||
{isPaginationEnabled && (
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import React, { FC } from 'react';
|
||||
import React, { type FC } from 'react';
|
||||
import SuggestionsSectionView from './SuggestionsSectionView';
|
||||
import UpcomingView from './UpcomingView';
|
||||
import GenresView from './GenresView';
|
||||
import ItemsView from './ItemsView';
|
||||
import { LibraryTab } from 'types/libraryTab';
|
||||
import { ParentId } from 'types/library';
|
||||
import { LibraryTabContent } from 'types/libraryTabContent';
|
||||
import GuideView from './GuideView';
|
||||
import ProgramsSectionView from './ProgramsSectionView';
|
||||
import { LibraryTab } from 'types/libraryTab';
|
||||
import type { ParentId } from 'types/library';
|
||||
import type { LibraryTabContent } from 'types/libraryTabContent';
|
||||
|
||||
interface PageTabContentProps {
|
||||
parentId: ParentId;
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import React, { FC } from 'react';
|
||||
import React, { type FC } from 'react';
|
||||
import { useGetProgramsSectionsWithItems, useGetTimers } from 'hooks/useFetchItems';
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
import globalize from 'scripts/globalize';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import SectionContainer from './SectionContainer';
|
||||
import { ParentId } from 'types/library';
|
||||
import { Section, SectionType } from 'types/sections';
|
||||
import { CardShape } from 'utils/card';
|
||||
import type { ParentId } from 'types/library';
|
||||
import type { Section, SectionType } from 'types/sections';
|
||||
|
||||
interface ProgramsSectionViewProps {
|
||||
parentId: ParentId;
|
||||
|
@ -18,7 +19,7 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
|
|||
sectionType,
|
||||
isUpcomingRecordingsEnabled = false
|
||||
}) => {
|
||||
const { isLoading, data: sectionsWithItems } = useGetProgramsSectionsWithItems(parentId, sectionType);
|
||||
const { isLoading, data: sectionsWithItems, refetch } = useGetProgramsSectionsWithItems(parentId, sectionType);
|
||||
const {
|
||||
isLoading: isUpcomingRecordingsLoading,
|
||||
data: upcomingRecordings
|
||||
|
@ -60,8 +61,10 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
|
|||
sectionTitle={globalize.translate(section.name)}
|
||||
items={items ?? []}
|
||||
url={getRouteUrl(section)}
|
||||
reloadItems={refetch}
|
||||
cardOptions={{
|
||||
...section.cardOptions
|
||||
...section.cardOptions,
|
||||
queryKey: ['ProgramSectionWithItems']
|
||||
}}
|
||||
/>
|
||||
|
||||
|
@ -73,7 +76,8 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
|
|||
sectionTitle={group.name}
|
||||
items={group.timerInfo ?? []}
|
||||
cardOptions={{
|
||||
shape: 'overflowBackdrop',
|
||||
queryKey: ['Timers'],
|
||||
shape: CardShape.BackdropOverflow,
|
||||
showTitle: true,
|
||||
showParentTitleOrTitle: true,
|
||||
showAirTime: true,
|
||||
|
|
|
@ -1,43 +1,29 @@
|
|||
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 Scroller from 'elements/emby-scroller/Scroller';
|
||||
import LinkButton from 'elements/emby-button/LinkButton';
|
||||
import imageLoader from 'components/images/imageLoader';
|
||||
|
||||
import { CardOptions } from 'types/cardOptions';
|
||||
import Cards from 'components/cardbuilder/Card/Cards';
|
||||
import type { CardOptions } from 'types/cardOptions';
|
||||
|
||||
interface SectionContainerProps {
|
||||
url?: string;
|
||||
sectionTitle: string;
|
||||
items: BaseItemDto[] | TimerInfoDto[];
|
||||
cardOptions: CardOptions;
|
||||
reloadItems?: () => void;
|
||||
}
|
||||
|
||||
const SectionContainer: FC<SectionContainerProps> = ({
|
||||
sectionTitle,
|
||||
url,
|
||||
items,
|
||||
cardOptions
|
||||
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 (
|
||||
<div ref={element} className='verticalSection hide'>
|
||||
<div className='verticalSection'>
|
||||
<div className='sectionTitleContainer sectionTitleContainer-cards padded-left'>
|
||||
{url && items.length > 5 ? (
|
||||
<LinkButton
|
||||
|
@ -66,7 +52,11 @@ const SectionContainer: FC<SectionContainerProps> = ({
|
|||
>
|
||||
<ItemsContainer
|
||||
className='itemsContainer scrollSlider focuscontainer-x'
|
||||
/>
|
||||
reloadItems={reloadItems}
|
||||
queryKey={cardOptions.queryKey}
|
||||
>
|
||||
<Cards items={items} cardOptions={cardOptions} />
|
||||
</ItemsContainer>
|
||||
</Scroller>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import {
|
||||
RecommendationDto,
|
||||
type RecommendationDto,
|
||||
RecommendationType
|
||||
} from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { FC } from 'react';
|
||||
import escapeHTML from 'escape-html';
|
||||
import React, { type FC } from 'react';
|
||||
import {
|
||||
useGetMovieRecommendations,
|
||||
useGetSuggestionSectionsWithItems
|
||||
|
@ -12,8 +11,9 @@ import { appRouter } from 'components/router/appRouter';
|
|||
import globalize from 'scripts/globalize';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import SectionContainer from './SectionContainer';
|
||||
import { ParentId } from 'types/library';
|
||||
import { Section, SectionType } from 'types/sections';
|
||||
import { CardShape } from 'utils/card';
|
||||
import type { ParentId } from 'types/library';
|
||||
import type { Section, SectionType } from 'types/sections';
|
||||
|
||||
interface SuggestionsSectionViewProps {
|
||||
parentId: ParentId;
|
||||
|
@ -89,7 +89,7 @@ const SuggestionsSectionView: FC<SuggestionsSectionViewProps> = ({
|
|||
);
|
||||
break;
|
||||
}
|
||||
return escapeHTML(title);
|
||||
return title;
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -102,6 +102,7 @@ const SuggestionsSectionView: FC<SuggestionsSectionViewProps> = ({
|
|||
url={getRouteUrl(section)}
|
||||
cardOptions={{
|
||||
...section.cardOptions,
|
||||
queryKey: ['SuggestionSectionWithItems'],
|
||||
showTitle: true,
|
||||
centerText: true,
|
||||
cardLayout: false,
|
||||
|
@ -117,7 +118,8 @@ const SuggestionsSectionView: FC<SuggestionsSectionViewProps> = ({
|
|||
sectionTitle={getRecommendationTittle(recommendation)}
|
||||
items={recommendation.Items ?? []}
|
||||
cardOptions={{
|
||||
shape: 'overflowPortrait',
|
||||
queryKey: ['MovieRecommendations'],
|
||||
shape: CardShape.PortraitOverflow,
|
||||
showYear: true,
|
||||
scalable: 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 { useGetGroupsUpcomingEpisodes } from 'hooks/useFetchItems';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import globalize from 'scripts/globalize';
|
||||
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 { isLoading, data: groupsUpcomingEpisodes } = useGetGroupsUpcomingEpisodes(parentId);
|
||||
|
@ -29,7 +30,7 @@ const UpcomingView: FC<LibraryViewProps> = ({ parentId }) => {
|
|||
sectionTitle={group.name}
|
||||
items={group.items ?? []}
|
||||
cardOptions={{
|
||||
shape: 'overflowBackdrop',
|
||||
shape: CardShape.BackdropOverflow,
|
||||
showLocationTypeIndicator: false,
|
||||
showParentTitle: true,
|
||||
preferThumb: true,
|
||||
|
|
|
@ -8,5 +8,6 @@ export const ASYNC_USER_ROUTES: AsyncRoute[] = [
|
|||
{ path: 'movies.html', page: 'movies', type: AsyncRouteType.Experimental },
|
||||
{ path: 'tv.html', page: 'shows', type: AsyncRouteType.Experimental },
|
||||
{ path: 'music.html', page: 'music', type: AsyncRouteType.Experimental },
|
||||
{ path: 'livetv.html', page: 'livetv', type: AsyncRouteType.Experimental }
|
||||
{ path: 'livetv.html', page: 'livetv', type: AsyncRouteType.Experimental },
|
||||
{ path: 'mypreferencesdisplay.html', page: 'user/display', type: AsyncRouteType.Experimental }
|
||||
];
|
||||
|
|
|
@ -25,12 +25,6 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [
|
|||
controller: 'user/controls/index',
|
||||
view: 'user/controls/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'mypreferencesdisplay.html',
|
||||
pageProps: {
|
||||
controller: 'user/display/index',
|
||||
view: 'user/display/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'mypreferenceshome.html',
|
||||
pageProps: {
|
||||
|
|
203
src/apps/experimental/routes/user/display/DisplayPreferences.tsx
Normal file
203
src/apps/experimental/routes/user/display/DisplayPreferences.tsx
Normal file
|
@ -0,0 +1,203 @@
|
|||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import { appHost } from 'components/apphost';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import globalize from 'scripts/globalize';
|
||||
import { DisplaySettingsValues } from './types';
|
||||
import { useScreensavers } from './hooks/useScreensavers';
|
||||
import { useServerThemes } from './hooks/useServerThemes';
|
||||
|
||||
interface DisplayPreferencesProps {
|
||||
onChange: (event: SelectChangeEvent | React.SyntheticEvent) => void;
|
||||
values: DisplaySettingsValues;
|
||||
}
|
||||
|
||||
export function DisplayPreferences({ onChange, values }: Readonly<DisplayPreferencesProps>) {
|
||||
const { user } = useApi();
|
||||
const { screensavers } = useScreensavers();
|
||||
const { themes } = useServerThemes();
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h2'>{globalize.translate('Display')}</Typography>
|
||||
|
||||
{ appHost.supports('displaymode') && (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id='display-settings-layout-label'>{globalize.translate('LabelDisplayMode')}</InputLabel>
|
||||
<Select
|
||||
aria-describedby='display-settings-layout-description'
|
||||
inputProps={{
|
||||
name: 'layout'
|
||||
}}
|
||||
labelId='display-settings-layout-label'
|
||||
onChange={onChange}
|
||||
value={values.layout}
|
||||
>
|
||||
<MenuItem value='auto'>{globalize.translate('Auto')}</MenuItem>
|
||||
<MenuItem value='desktop'>{globalize.translate('Desktop')}</MenuItem>
|
||||
<MenuItem value='mobile'>{globalize.translate('Mobile')}</MenuItem>
|
||||
<MenuItem value='tv'>{globalize.translate('TV')}</MenuItem>
|
||||
<MenuItem value='experimental'>{globalize.translate('Experimental')}</MenuItem>
|
||||
</Select>
|
||||
<FormHelperText component={Stack} id='display-settings-layout-description'>
|
||||
<span>{globalize.translate('DisplayModeHelp')}</span>
|
||||
<span>{globalize.translate('LabelPleaseRestart')}</span>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
) }
|
||||
|
||||
{ themes.length > 0 && (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id='display-settings-theme-label'>{globalize.translate('LabelTheme')}</InputLabel>
|
||||
<Select
|
||||
inputProps={{
|
||||
name: 'theme'
|
||||
}}
|
||||
labelId='display-settings-theme-label'
|
||||
onChange={onChange}
|
||||
value={values.theme}
|
||||
>
|
||||
{ ...themes.map(({ id, name }) => (
|
||||
<MenuItem key={id} value={id}>{name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
) }
|
||||
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
aria-describedby='display-settings-disable-css-description'
|
||||
control={
|
||||
<Checkbox
|
||||
checked={values.disableCustomCss}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('DisableCustomCss')}
|
||||
name='disableCustomCss'
|
||||
/>
|
||||
<FormHelperText id='display-settings-disable-css-description'>
|
||||
{globalize.translate('LabelDisableCustomCss')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
aria-describedby='display-settings-custom-css-description'
|
||||
value={values.customCss}
|
||||
label={globalize.translate('LabelCustomCss')}
|
||||
multiline
|
||||
name='customCss'
|
||||
onChange={onChange}
|
||||
/>
|
||||
<FormHelperText id='display-settings-custom-css-description'>
|
||||
{globalize.translate('LabelLocalCustomCss')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{ themes.length > 0 && user?.Policy?.IsAdministrator && (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id='display-settings-dashboard-theme-label'>{globalize.translate('LabelDashboardTheme')}</InputLabel>
|
||||
<Select
|
||||
inputProps={{
|
||||
name: 'dashboardTheme'
|
||||
}}
|
||||
labelId='display-settings-dashboard-theme-label'
|
||||
onChange={ onChange }
|
||||
value={ values.dashboardTheme }
|
||||
>
|
||||
{ ...themes.map(({ id, name }) => (
|
||||
<MenuItem key={ id } value={ id }>{ name }</MenuItem>
|
||||
)) }
|
||||
</Select>
|
||||
</FormControl>
|
||||
) }
|
||||
|
||||
{ screensavers.length > 0 && appHost.supports('screensaver') && (
|
||||
<Fragment>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id='display-settings-screensaver-label'>{globalize.translate('LabelScreensaver')}</InputLabel>
|
||||
<Select
|
||||
inputProps={{
|
||||
name: 'screensaver'
|
||||
}}
|
||||
labelId='display-settings-screensaver-label'
|
||||
onChange={onChange}
|
||||
value={values.screensaver}
|
||||
>
|
||||
{ ...screensavers.map(({ id, name }) => (
|
||||
<MenuItem key={id} value={id}>{name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
aria-describedby='display-settings-screensaver-interval-description'
|
||||
value={values.screensaverInterval}
|
||||
inputProps={{
|
||||
inputMode: 'numeric',
|
||||
max: '3600',
|
||||
min: '1',
|
||||
pattern: '[0-9]',
|
||||
required: true,
|
||||
step: '1',
|
||||
type: 'number'
|
||||
}}
|
||||
label={globalize.translate('LabelBackdropScreensaverInterval')}
|
||||
name='screensaverInterval'
|
||||
onChange={onChange}
|
||||
/>
|
||||
<FormHelperText id='display-settings-screensaver-interval-description'>
|
||||
{globalize.translate('LabelBackdropScreensaverIntervalHelp')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Fragment>
|
||||
) }
|
||||
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
aria-describedby='display-settings-faster-animations-description'
|
||||
control={
|
||||
<Checkbox
|
||||
checked={values.enableFasterAnimation}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('EnableFasterAnimations')}
|
||||
name='enableFasterAnimation'
|
||||
/>
|
||||
<FormHelperText id='display-settings-faster-animations-description'>
|
||||
{globalize.translate('EnableFasterAnimationsHelp')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
aria-describedby='display-settings-blurhash-description'
|
||||
control={
|
||||
<Checkbox
|
||||
checked={values.enableBlurHash}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('EnableBlurHash')}
|
||||
name='enableBlurHash'
|
||||
/>
|
||||
<FormHelperText id='display-settings-blurhash-description'>
|
||||
{globalize.translate('EnableBlurHashHelp')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React from 'react';
|
||||
|
||||
import globalize from 'scripts/globalize';
|
||||
import { DisplaySettingsValues } from './types';
|
||||
|
||||
interface ItemDetailPreferencesProps {
|
||||
onChange: (event: React.SyntheticEvent) => void;
|
||||
values: DisplaySettingsValues;
|
||||
}
|
||||
|
||||
export function ItemDetailPreferences({ onChange, values }: Readonly<ItemDetailPreferencesProps>) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant='h2'>{globalize.translate('ItemDetails')}</Typography>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
aria-describedby='display-settings-item-details-banner-description'
|
||||
control={
|
||||
<Checkbox
|
||||
checked={values.enableItemDetailsBanner}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('EnableDetailsBanner')}
|
||||
name='enableItemDetailsBanner'
|
||||
/>
|
||||
<FormHelperText id='display-settings-item-details-banner-description'>
|
||||
{globalize.translate('EnableDetailsBannerHelp')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
);
|
||||
}
|
114
src/apps/experimental/routes/user/display/LibraryPreferences.tsx
Normal file
114
src/apps/experimental/routes/user/display/LibraryPreferences.tsx
Normal file
|
@ -0,0 +1,114 @@
|
|||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React from 'react';
|
||||
|
||||
import globalize from 'scripts/globalize';
|
||||
import { DisplaySettingsValues } from './types';
|
||||
|
||||
interface LibraryPreferencesProps {
|
||||
onChange: (event: React.SyntheticEvent) => void;
|
||||
values: DisplaySettingsValues;
|
||||
}
|
||||
|
||||
export function LibraryPreferences({ onChange, values }: Readonly<LibraryPreferencesProps>) {
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h2'>{globalize.translate('HeaderLibraries')}</Typography>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
aria-describedby='display-settings-lib-pagesize-description'
|
||||
inputProps={{
|
||||
type: 'number',
|
||||
inputMode: 'numeric',
|
||||
max: '1000',
|
||||
min: '0',
|
||||
pattern: '[0-9]',
|
||||
required: true,
|
||||
step: '1'
|
||||
}}
|
||||
value={values.libraryPageSize}
|
||||
label={globalize.translate('LabelLibraryPageSize')}
|
||||
name='libraryPageSize'
|
||||
onChange={onChange}
|
||||
/>
|
||||
<FormHelperText id='display-settings-lib-pagesize-description'>
|
||||
{globalize.translate('LabelLibraryPageSizeHelp')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
aria-describedby='display-settings-lib-backdrops-description'
|
||||
control={
|
||||
<Checkbox
|
||||
checked={values.enableLibraryBackdrops}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('Backdrops')}
|
||||
name='enableLibraryBackdrops'
|
||||
/>
|
||||
<FormHelperText id='display-settings-lib-backdrops-description'>
|
||||
{globalize.translate('EnableBackdropsHelp')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
aria-describedby='display-settings-lib-theme-songs-description'
|
||||
control={
|
||||
<Checkbox
|
||||
checked={values.enableLibraryThemeSongs}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('ThemeSongs')}
|
||||
name='enableLibraryThemeSongs'
|
||||
/>
|
||||
<FormHelperText id='display-settings-lib-theme-songs-description'>
|
||||
{globalize.translate('EnableThemeSongsHelp')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
aria-describedby='display-settings-lib-theme-videos-description'
|
||||
control={
|
||||
<Checkbox
|
||||
checked={values.enableLibraryThemeVideos}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('ThemeVideos')}
|
||||
name='enableLibraryThemeVideos'
|
||||
/>
|
||||
<FormHelperText id='display-settings-lib-theme-videos-description'>
|
||||
{globalize.translate('EnableThemeVideosHelp')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
aria-describedby='display-settings-show-missing-episodes-description'
|
||||
control={
|
||||
<Checkbox
|
||||
checked={values.displayMissingEpisodes}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('DisplayMissingEpisodesWithinSeasons')}
|
||||
name='displayMissingEpisodes'
|
||||
/>
|
||||
<FormHelperText id='display-settings-show-missing-episodes-description'>
|
||||
{globalize.translate('DisplayMissingEpisodesWithinSeasonsHelp')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
import FormControl from '@mui/material/FormControl';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Link from '@mui/material/Link';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React from 'react';
|
||||
|
||||
import { appHost } from 'components/apphost';
|
||||
import datetime from 'scripts/datetime';
|
||||
import globalize from 'scripts/globalize';
|
||||
import { DATE_LOCALE_OPTIONS, LANGUAGE_OPTIONS } from './constants';
|
||||
import { DisplaySettingsValues } from './types';
|
||||
|
||||
interface LocalizationPreferencesProps {
|
||||
onChange: (event: SelectChangeEvent) => void;
|
||||
values: DisplaySettingsValues;
|
||||
}
|
||||
|
||||
export function LocalizationPreferences({ onChange, values }: Readonly<LocalizationPreferencesProps>) {
|
||||
if (!appHost.supports('displaylanguage') && !datetime.supportsLocalization()) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h2'>{globalize.translate('Localization')}</Typography>
|
||||
|
||||
{ appHost.supports('displaylanguage') && (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id='display-settings-language-label'>{globalize.translate('LabelDisplayLanguage')}</InputLabel>
|
||||
<Select
|
||||
aria-describedby='display-settings-language-description'
|
||||
inputProps={{
|
||||
name: 'language'
|
||||
}}
|
||||
labelId='display-settings-language-label'
|
||||
onChange={onChange}
|
||||
value={values.language}
|
||||
>
|
||||
{ ...LANGUAGE_OPTIONS.map(({ value, label }) => (
|
||||
<MenuItem key={value } value={value}>{ label }</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText component={Stack} id='display-settings-language-description'>
|
||||
<span>{globalize.translate('LabelDisplayLanguageHelp')}</span>
|
||||
{ appHost.supports('externallinks') && (
|
||||
<Link
|
||||
href='https://github.com/jellyfin/jellyfin'
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
{globalize.translate('LearnHowYouCanContribute')}
|
||||
</Link>
|
||||
) }
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
) }
|
||||
|
||||
{ datetime.supportsLocalization() && (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id='display-settings-locale-label'>{globalize.translate('LabelDateTimeLocale')}</InputLabel>
|
||||
<Select
|
||||
inputProps={{
|
||||
name: 'dateTimeLocale'
|
||||
}}
|
||||
labelId='display-settings-locale-label'
|
||||
onChange={onChange}
|
||||
value={values.dateTimeLocale}
|
||||
>
|
||||
{...DATE_LOCALE_OPTIONS.map(({ value, label }) => (
|
||||
<MenuItem key={value} value={value}>{label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
) }
|
||||
</Stack>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React from 'react';
|
||||
|
||||
import globalize from 'scripts/globalize';
|
||||
import { DisplaySettingsValues } from './types';
|
||||
|
||||
interface NextUpPreferencesProps {
|
||||
onChange: (event: React.SyntheticEvent) => void;
|
||||
values: DisplaySettingsValues;
|
||||
}
|
||||
|
||||
export function NextUpPreferences({ onChange, values }: Readonly<NextUpPreferencesProps>) {
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h2'>{globalize.translate('NextUp')}</Typography>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
aria-describedby='display-settings-max-days-next-up-description'
|
||||
value={values.maxDaysForNextUp}
|
||||
inputProps={{
|
||||
type: 'number',
|
||||
inputMode: 'numeric',
|
||||
max: '1000',
|
||||
min: '0',
|
||||
pattern: '[0-9]',
|
||||
required: true,
|
||||
step: '1'
|
||||
}}
|
||||
label={globalize.translate('LabelMaxDaysForNextUp')}
|
||||
name='maxDaysForNextUp'
|
||||
onChange={onChange}
|
||||
/>
|
||||
<FormHelperText id='display-settings-max-days-next-up-description'>
|
||||
{globalize.translate('LabelMaxDaysForNextUpHelp')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
aria-describedby='display-settings-next-up-rewatching-description'
|
||||
control={
|
||||
<Checkbox
|
||||
checked={values.enableRewatchingInNextUp}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('EnableRewatchingNextUp')}
|
||||
name='enableRewatchingInNextUp'
|
||||
/>
|
||||
<FormHelperText id='display-settings-next-up-rewatching-description'>
|
||||
{globalize.translate('EnableRewatchingNextUpHelp')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
aria-describedby='display-settings-next-up-images-description'
|
||||
control={
|
||||
<Checkbox
|
||||
checked={values.episodeImagesInNextUp}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('UseEpisodeImagesInNextUp')}
|
||||
name='episodeImagesInNextUp'
|
||||
/>
|
||||
<FormHelperText id='display-settings-next-up-images-description'>
|
||||
{globalize.translate('UseEpisodeImagesInNextUpHelp')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
);
|
||||
}
|
79
src/apps/experimental/routes/user/display/constants.ts
Normal file
79
src/apps/experimental/routes/user/display/constants.ts
Normal file
|
@ -0,0 +1,79 @@
|
|||
import globalize from 'scripts/globalize';
|
||||
|
||||
export const LANGUAGE_OPTIONS = [
|
||||
{ value: 'auto', label: globalize.translate('Auto') },
|
||||
{ value: 'af', label: 'Afrikaans' },
|
||||
{ value: 'ar', label: 'العربية' },
|
||||
{ value: 'be-BY', label: 'Беларуская' },
|
||||
{ value: 'bg-BG', label: 'Български' },
|
||||
{ value: 'bn_BD', label: 'বাংলা (বাংলাদেশ)' },
|
||||
{ value: 'ca', label: 'Català' },
|
||||
{ value: 'cs', label: 'Čeština' },
|
||||
{ value: 'cy', label: 'Cymraeg' },
|
||||
{ value: 'da', label: 'Dansk' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'el', label: 'Ελληνικά' },
|
||||
{ value: 'en-GB', label: 'English (United Kingdom)' },
|
||||
{ value: 'en-US', label: 'English' },
|
||||
{ value: 'eo', label: 'Esperanto' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'es_419', label: 'Español americano' },
|
||||
{ value: 'es-AR', label: 'Español (Argentina)' },
|
||||
{ value: 'es_DO', label: 'Español (Dominicana)' },
|
||||
{ value: 'es-MX', label: 'Español (México)' },
|
||||
{ value: 'et', label: 'Eesti' },
|
||||
{ value: 'eu', label: 'Euskara' },
|
||||
{ value: 'fa', label: 'فارسی' },
|
||||
{ value: 'fi', label: 'Suomi' },
|
||||
{ value: 'fil', label: 'Filipino' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'fr-CA', label: 'Français (Canada)' },
|
||||
{ value: 'gl', label: 'Galego' },
|
||||
{ value: 'gsw', label: 'Schwiizerdütsch' },
|
||||
{ value: 'he', label: 'עִבְרִית' },
|
||||
{ value: 'hi-IN', label: 'हिन्दी' },
|
||||
{ value: 'hr', label: 'Hrvatski' },
|
||||
{ value: 'hu', label: 'Magyar' },
|
||||
{ value: 'id', label: 'Bahasa Indonesia' },
|
||||
{ value: 'is-IS', label: 'Íslenska' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'kk', label: 'Qazaqşa' },
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'lt-LT', label: 'Lietuvių' },
|
||||
{ value: 'lv', label: 'Latviešu' },
|
||||
{ value: 'mk', label: 'Македонски' },
|
||||
{ value: 'ml', label: 'മലയാളം' },
|
||||
{ value: 'mr', label: 'मराठी' },
|
||||
{ value: 'ms', label: 'Bahasa Melayu' },
|
||||
{ value: 'nb', label: 'Norsk bokmål' },
|
||||
{ value: 'ne', label: 'नेपाली' },
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'nn', label: 'Norsk nynorsk' },
|
||||
{ value: 'pa', label: 'ਪੰਜਾਬੀ' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
{ value: 'pr', label: 'Pirate' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'pt-BR', label: 'Português (Brasil)' },
|
||||
{ value: 'pt-PT', label: 'Português (Portugal)' },
|
||||
{ value: 'ro', label: 'Românește' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'sk', label: 'Slovenčina' },
|
||||
{ value: 'sl-SI', label: 'Slovenščina' },
|
||||
{ value: 'sq', label: 'Shqip' },
|
||||
{ value: 'sr', label: 'Српски' },
|
||||
{ value: 'sv', label: 'Svenska' },
|
||||
{ value: 'ta', label: 'தமிழ்' },
|
||||
{ value: 'te', label: 'తెలుగు' },
|
||||
{ value: 'th', label: 'ภาษาไทย' },
|
||||
{ value: 'tr', label: 'Türkçe' },
|
||||
{ value: 'uk', label: 'Українська' },
|
||||
{ value: 'ur_PK', label: ' اُردُو' },
|
||||
{ value: 'vi', label: 'Tiếng Việt' },
|
||||
{ value: 'zh-CN', label: '汉语 (简化字)' },
|
||||
{ value: 'zh-TW', label: '漢語 (繁体字)' },
|
||||
{ value: 'zh-HK', label: '廣東話 (香港)' }
|
||||
];
|
||||
|
||||
// NOTE: Option `Euskara` (eu) does not exist in legacy date locale options.
|
||||
export const DATE_LOCALE_OPTIONS = LANGUAGE_OPTIONS.filter(({ value }) => value !== 'eu');
|
|
@ -0,0 +1,46 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import toast from 'components/toast/toast';
|
||||
import globalize from 'scripts/globalize';
|
||||
import { DisplaySettingsValues } from '../types';
|
||||
import { useDisplaySettings } from './useDisplaySettings';
|
||||
|
||||
export function useDisplaySettingForm() {
|
||||
const [urlParams] = useSearchParams();
|
||||
const {
|
||||
displaySettings,
|
||||
loading,
|
||||
saveDisplaySettings
|
||||
} = useDisplaySettings({ userId: urlParams.get('userId') });
|
||||
const [formValues, setFormValues] = useState<DisplaySettingsValues>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && displaySettings && !formValues) {
|
||||
setFormValues(displaySettings);
|
||||
}
|
||||
}, [formValues, loading, displaySettings]);
|
||||
|
||||
const updateField = useCallback(({ name, value }) => {
|
||||
if (formValues) {
|
||||
setFormValues({
|
||||
...formValues,
|
||||
[name]: value
|
||||
});
|
||||
}
|
||||
}, [formValues, setFormValues]);
|
||||
|
||||
const submitChanges = useCallback(async () => {
|
||||
if (formValues) {
|
||||
await saveDisplaySettings(formValues);
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
}
|
||||
}, [formValues, saveDisplaySettings]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
values: formValues,
|
||||
submitChanges,
|
||||
updateField
|
||||
};
|
||||
}
|
|
@ -0,0 +1,159 @@
|
|||
import { UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import { ApiClient } from 'jellyfin-apiclient';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { appHost } from 'components/apphost';
|
||||
import layoutManager from 'components/layoutManager';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import themeManager from 'scripts/themeManager';
|
||||
import { currentSettings, UserSettings } from 'scripts/settings/userSettings';
|
||||
import { DisplaySettingsValues } from '../types';
|
||||
|
||||
interface UseDisplaySettingsParams {
|
||||
userId?: string | null;
|
||||
}
|
||||
|
||||
export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userSettings, setUserSettings] = useState<UserSettings>();
|
||||
const [displaySettings, setDisplaySettings] = useState<DisplaySettingsValues>();
|
||||
const { __legacyApiClient__, user: currentUser } = useApi();
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || !currentUser || !__legacyApiClient__) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
void (async () => {
|
||||
const loadedSettings = await loadDisplaySettings({ api: __legacyApiClient__, currentUser, userId });
|
||||
|
||||
setDisplaySettings(loadedSettings.displaySettings);
|
||||
setUserSettings(loadedSettings.userSettings);
|
||||
|
||||
setLoading(false);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
setLoading(false);
|
||||
};
|
||||
}, [__legacyApiClient__, currentUser, userId]);
|
||||
|
||||
const saveSettings = useCallback(async (newSettings: DisplaySettingsValues) => {
|
||||
if (!userId || !userSettings || !__legacyApiClient__) {
|
||||
return;
|
||||
}
|
||||
return saveDisplaySettings({
|
||||
api: __legacyApiClient__,
|
||||
newDisplaySettings: newSettings,
|
||||
userSettings,
|
||||
userId
|
||||
});
|
||||
}, [__legacyApiClient__, userSettings, userId]);
|
||||
|
||||
return {
|
||||
displaySettings,
|
||||
loading,
|
||||
saveDisplaySettings: saveSettings
|
||||
};
|
||||
}
|
||||
|
||||
interface LoadDisplaySettingsParams {
|
||||
currentUser: UserDto;
|
||||
userId?: string;
|
||||
api: ApiClient;
|
||||
}
|
||||
|
||||
async function loadDisplaySettings({
|
||||
currentUser,
|
||||
userId,
|
||||
api
|
||||
}: LoadDisplaySettingsParams) {
|
||||
const settings = (!userId || userId === currentUser?.Id) ? currentSettings : new UserSettings();
|
||||
const user = (!userId || userId === currentUser?.Id) ? currentUser : await api.getUser(userId);
|
||||
|
||||
await settings.setUserInfo(userId, api);
|
||||
|
||||
const displaySettings = {
|
||||
customCss: settings.customCss(),
|
||||
dashboardTheme: settings.dashboardTheme() || 'auto',
|
||||
dateTimeLocale: settings.dateTimeLocale() || 'auto',
|
||||
disableCustomCss: Boolean(settings.disableCustomCss()),
|
||||
displayMissingEpisodes: user?.Configuration?.DisplayMissingEpisodes ?? false,
|
||||
enableBlurHash: Boolean(settings.enableBlurhash()),
|
||||
enableFasterAnimation: Boolean(settings.enableFastFadein()),
|
||||
enableItemDetailsBanner: Boolean(settings.detailsBanner()),
|
||||
enableLibraryBackdrops: Boolean(settings.enableBackdrops()),
|
||||
enableLibraryThemeSongs: Boolean(settings.enableThemeSongs()),
|
||||
enableLibraryThemeVideos: Boolean(settings.enableThemeVideos()),
|
||||
enableRewatchingInNextUp: Boolean(settings.enableRewatchingInNextUp()),
|
||||
episodeImagesInNextUp: Boolean(settings.useEpisodeImagesInNextUpAndResume()),
|
||||
language: settings.language() || 'auto',
|
||||
layout: layoutManager.getSavedLayout() || 'auto',
|
||||
libraryPageSize: settings.libraryPageSize(),
|
||||
maxDaysForNextUp: settings.maxDaysForNextUp(),
|
||||
screensaver: settings.screensaver() || 'none',
|
||||
screensaverInterval: settings.backdropScreensaverInterval(),
|
||||
theme: settings.theme()
|
||||
};
|
||||
|
||||
return {
|
||||
displaySettings,
|
||||
userSettings: settings
|
||||
};
|
||||
}
|
||||
|
||||
interface SaveDisplaySettingsParams {
|
||||
api: ApiClient;
|
||||
newDisplaySettings: DisplaySettingsValues
|
||||
userSettings: UserSettings;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
async function saveDisplaySettings({
|
||||
api,
|
||||
newDisplaySettings,
|
||||
userSettings,
|
||||
userId
|
||||
}: SaveDisplaySettingsParams) {
|
||||
const user = await api.getUser(userId);
|
||||
|
||||
if (appHost.supports('displaylanguage')) {
|
||||
userSettings.language(normalizeValue(newDisplaySettings.language));
|
||||
}
|
||||
userSettings.customCss(normalizeValue(newDisplaySettings.customCss));
|
||||
userSettings.dashboardTheme(normalizeValue(newDisplaySettings.dashboardTheme));
|
||||
userSettings.dateTimeLocale(normalizeValue(newDisplaySettings.dateTimeLocale));
|
||||
userSettings.disableCustomCss(newDisplaySettings.disableCustomCss);
|
||||
userSettings.enableBlurhash(newDisplaySettings.enableBlurHash);
|
||||
userSettings.enableFastFadein(newDisplaySettings.enableFasterAnimation);
|
||||
userSettings.detailsBanner(newDisplaySettings.enableItemDetailsBanner);
|
||||
userSettings.enableBackdrops(newDisplaySettings.enableLibraryBackdrops);
|
||||
userSettings.enableThemeSongs(newDisplaySettings.enableLibraryThemeSongs);
|
||||
userSettings.enableThemeVideos(newDisplaySettings.enableLibraryThemeVideos);
|
||||
userSettings.enableRewatchingInNextUp(newDisplaySettings.enableRewatchingInNextUp);
|
||||
userSettings.useEpisodeImagesInNextUpAndResume(newDisplaySettings.episodeImagesInNextUp);
|
||||
userSettings.libraryPageSize(newDisplaySettings.libraryPageSize);
|
||||
userSettings.maxDaysForNextUp(newDisplaySettings.maxDaysForNextUp);
|
||||
userSettings.screensaver(normalizeValue(newDisplaySettings.screensaver));
|
||||
userSettings.backdropScreensaverInterval(newDisplaySettings.screensaverInterval);
|
||||
userSettings.theme(newDisplaySettings.theme);
|
||||
|
||||
layoutManager.setLayout(normalizeValue(newDisplaySettings.layout));
|
||||
|
||||
const promises = [
|
||||
themeManager.setTheme(userSettings.theme())
|
||||
];
|
||||
|
||||
if (user.Id && user.Configuration) {
|
||||
user.Configuration.DisplayMissingEpisodes = newDisplaySettings.displayMissingEpisodes;
|
||||
promises.push(api.updateUserConfiguration(user.Id, user.Configuration));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
function normalizeValue(value: string) {
|
||||
return /^(auto|none)$/.test(value) ? '' : value;
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { pluginManager } from 'components/pluginManager';
|
||||
import { Plugin, PluginType } from 'types/plugin';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
export function useScreensavers() {
|
||||
const screensavers = useMemo<Plugin[]>(() => {
|
||||
const installedScreensaverPlugins = pluginManager
|
||||
.ofType(PluginType.Screensaver)
|
||||
.map((plugin: Plugin) => ({
|
||||
...plugin,
|
||||
name: globalize.translate(plugin.name) as string
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'none',
|
||||
name: globalize.translate('None') as string,
|
||||
type: PluginType.Screensaver
|
||||
},
|
||||
...installedScreensaverPlugins
|
||||
];
|
||||
}, []);
|
||||
|
||||
return {
|
||||
screensavers: screensavers ?? []
|
||||
};
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import themeManager from 'scripts/themeManager';
|
||||
import { Theme } from 'types/webConfig';
|
||||
|
||||
export function useServerThemes() {
|
||||
const [themes, setThemes] = useState<Theme[]>();
|
||||
|
||||
useEffect(() => {
|
||||
async function getServerThemes() {
|
||||
const loadedThemes = await themeManager.getThemes();
|
||||
|
||||
setThemes(loadedThemes ?? []);
|
||||
}
|
||||
|
||||
if (!themes) {
|
||||
void getServerThemes();
|
||||
}
|
||||
// We've intentionally left the dependency array here to ensure that the effect happens only once.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const defaultTheme = useMemo(() => {
|
||||
if (!themes) return null;
|
||||
return themes.find((theme) => theme.default);
|
||||
}, [themes]);
|
||||
|
||||
return {
|
||||
themes: themes ?? [],
|
||||
defaultTheme
|
||||
};
|
||||
}
|
96
src/apps/experimental/routes/user/display/index.tsx
Normal file
96
src/apps/experimental/routes/user/display/index.tsx
Normal file
|
@ -0,0 +1,96 @@
|
|||
import Button from '@mui/material/Button';
|
||||
import { SelectChangeEvent } from '@mui/material/Select';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'scripts/globalize';
|
||||
import theme from 'themes/theme';
|
||||
import { DisplayPreferences } from './DisplayPreferences';
|
||||
import { ItemDetailPreferences } from './ItemDetailPreferences';
|
||||
import { LibraryPreferences } from './LibraryPreferences';
|
||||
import { LocalizationPreferences } from './LocalizationPreferences';
|
||||
import { NextUpPreferences } from './NextUpPreferences';
|
||||
import { useDisplaySettingForm } from './hooks/useDisplaySettingForm';
|
||||
import { DisplaySettingsValues } from './types';
|
||||
import LoadingComponent from 'components/loading/LoadingComponent';
|
||||
|
||||
export default function UserDisplayPreferences() {
|
||||
const {
|
||||
loading,
|
||||
submitChanges,
|
||||
updateField,
|
||||
values
|
||||
} = useDisplaySettingForm();
|
||||
|
||||
const handleSubmitForm = useCallback((e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
void submitChanges();
|
||||
}, [submitChanges]);
|
||||
|
||||
const handleFieldChange = useCallback((e: SelectChangeEvent | React.SyntheticEvent) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const fieldName = target.name as keyof DisplaySettingsValues;
|
||||
const fieldValue = target.type === 'checkbox' ? target.checked : target.value;
|
||||
|
||||
if (values?.[fieldName] !== fieldValue) {
|
||||
updateField({
|
||||
name: fieldName,
|
||||
value: fieldValue
|
||||
});
|
||||
}
|
||||
}, [updateField, values]);
|
||||
|
||||
if (loading || !values) {
|
||||
return <LoadingComponent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
className='libraryPage userPreferencesPage noSecondaryNavPage'
|
||||
id='displayPreferencesPage'
|
||||
title={globalize.translate('Display')}
|
||||
>
|
||||
<div className='settingsContainer padded-left padded-right padded-bottom-page'>
|
||||
<form
|
||||
onSubmit={handleSubmitForm}
|
||||
style={{ margin: 'auto' }}
|
||||
>
|
||||
<Stack spacing={4}>
|
||||
<LocalizationPreferences
|
||||
onChange={handleFieldChange}
|
||||
values={values}
|
||||
/>
|
||||
<DisplayPreferences
|
||||
onChange={handleFieldChange}
|
||||
values={values}
|
||||
/>
|
||||
<LibraryPreferences
|
||||
onChange={handleFieldChange}
|
||||
values={values}
|
||||
/>
|
||||
<NextUpPreferences
|
||||
onChange={handleFieldChange}
|
||||
values={values}
|
||||
/>
|
||||
<ItemDetailPreferences
|
||||
onChange={handleFieldChange}
|
||||
values={values}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
sx={{
|
||||
color: theme.palette.text.primary,
|
||||
fontSize: theme.typography.htmlFontSize,
|
||||
fontWeight: theme.typography.fontWeightBold
|
||||
}}
|
||||
>
|
||||
{globalize.translate('Save')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
22
src/apps/experimental/routes/user/display/types.ts
Normal file
22
src/apps/experimental/routes/user/display/types.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
export interface DisplaySettingsValues {
|
||||
customCss: string;
|
||||
dashboardTheme: string;
|
||||
dateTimeLocale: string;
|
||||
disableCustomCss: boolean;
|
||||
displayMissingEpisodes: boolean;
|
||||
enableBlurHash: boolean;
|
||||
enableFasterAnimation: boolean;
|
||||
enableItemDetailsBanner: boolean;
|
||||
enableLibraryBackdrops: boolean;
|
||||
enableLibraryThemeSongs: boolean;
|
||||
enableLibraryThemeVideos: boolean;
|
||||
enableRewatchingInNextUp: boolean;
|
||||
episodeImagesInNextUp: boolean;
|
||||
language: string;
|
||||
layout: string;
|
||||
libraryPageSize: number;
|
||||
maxDaysForNextUp: number;
|
||||
screensaver: string;
|
||||
screensaverInterval: number;
|
||||
theme: string;
|
||||
}
|
|
@ -1,16 +1,46 @@
|
|||
import React, { FunctionComponent, useState } from 'react';
|
||||
import React, { type FC, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import Page from '../../../components/Page';
|
||||
import SearchFields from '../../../components/search/SearchFields';
|
||||
import SearchResults from '../../../components/search/SearchResults';
|
||||
import SearchSuggestions from '../../../components/search/SearchSuggestions';
|
||||
import LiveTVSearchResults from '../../../components/search/LiveTVSearchResults';
|
||||
import globalize from '../../../scripts/globalize';
|
||||
import Page from 'components/Page';
|
||||
import SearchFields from 'components/search/SearchFields';
|
||||
import SearchResults from 'components/search/SearchResults';
|
||||
import SearchSuggestions from 'components/search/SearchSuggestions';
|
||||
import LiveTVSearchResults from 'components/search/LiveTVSearchResults';
|
||||
import { usePrevious } from 'hooks/usePrevious';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
const Search: FunctionComponent = () => {
|
||||
const [ query, setQuery ] = useState<string>();
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const COLLECTION_TYPE_PARAM = 'collectionType';
|
||||
const PARENT_ID_PARAM = 'parentId';
|
||||
const QUERY_PARAM = 'query';
|
||||
const SERVER_ID_PARAM = 'serverId';
|
||||
|
||||
const Search: FC = () => {
|
||||
const [ searchParams, setSearchParams ] = useSearchParams();
|
||||
const urlQuery = searchParams.get(QUERY_PARAM) || '';
|
||||
const [ query, setQuery ] = useState(urlQuery);
|
||||
const prevQuery = usePrevious(query, '');
|
||||
|
||||
useEffect(() => {
|
||||
if (query !== prevQuery) {
|
||||
if (query === '' && urlQuery !== '') {
|
||||
// The query input has been cleared; remove the url param
|
||||
searchParams.delete(QUERY_PARAM);
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
} else if (query !== urlQuery) {
|
||||
// Update the query url param value
|
||||
searchParams.set(QUERY_PARAM, query);
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
}
|
||||
} else if (query !== urlQuery) {
|
||||
// Update the query if the query url param has changed
|
||||
if (!urlQuery) {
|
||||
searchParams.delete(QUERY_PARAM);
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
}
|
||||
|
||||
setQuery(urlQuery);
|
||||
}
|
||||
}, [query, prevQuery, searchParams, setSearchParams, urlQuery]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
|
@ -18,22 +48,22 @@ const Search: FunctionComponent = () => {
|
|||
title={globalize.translate('Search')}
|
||||
className='mainAnimatedPage libraryPage allLibraryPage noSecondaryNavPage'
|
||||
>
|
||||
<SearchFields onSearch={setQuery} />
|
||||
<SearchFields query={query} onSearch={setQuery} />
|
||||
{!query
|
||||
&& <SearchSuggestions
|
||||
parentId={searchParams.get('parentId')}
|
||||
parentId={searchParams.get(PARENT_ID_PARAM)}
|
||||
/>
|
||||
}
|
||||
<SearchResults
|
||||
serverId={searchParams.get('serverId') || window.ApiClient.serverId()}
|
||||
parentId={searchParams.get('parentId')}
|
||||
collectionType={searchParams.get('collectionType')}
|
||||
serverId={searchParams.get(SERVER_ID_PARAM) || window.ApiClient.serverId()}
|
||||
parentId={searchParams.get(PARENT_ID_PARAM)}
|
||||
collectionType={searchParams.get(COLLECTION_TYPE_PARAM)}
|
||||
query={query}
|
||||
/>
|
||||
<LiveTVSearchResults
|
||||
serverId={searchParams.get('serverId') || window.ApiClient.serverId()}
|
||||
parentId={searchParams.get('parentId')}
|
||||
collectionType={searchParams.get('collectionType')}
|
||||
serverId={searchParams.get(SERVER_ID_PARAM) || window.ApiClient.serverId()}
|
||||
parentId={searchParams.get(PARENT_ID_PARAM)}
|
||||
collectionType={searchParams.get(COLLECTION_TYPE_PARAM)}
|
||||
query={query}
|
||||
/>
|
||||
</Page>
|
||||
|
|
|
@ -104,6 +104,18 @@ class ServerConnections extends ConnectionManager {
|
|||
return apiClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ApiClient that is currently connected or throws if not defined.
|
||||
* @async
|
||||
* @returns {Promise<ApiClient>} The current ApiClient instance.
|
||||
*/
|
||||
async getCurrentApiClientAsync() {
|
||||
const apiClient = this.currentApiClient();
|
||||
if (!apiClient) throw new Error('[ServerConnection] No current ApiClient instance');
|
||||
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
onLocalUserSignedIn(user) {
|
||||
const apiClient = this.getApiClient(user.ServerId);
|
||||
this.setLocalApiClient(apiClient);
|
||||
|
|
|
@ -9,8 +9,64 @@ import 'material-design-icons-iconfont';
|
|||
import '../../styles/scrollstyles.scss';
|
||||
import '../../components/listview/listview.scss';
|
||||
|
||||
function getOffsets(elems) {
|
||||
const results = [];
|
||||
interface OptionItem {
|
||||
asideText?: string;
|
||||
divider?: boolean;
|
||||
icon?: string;
|
||||
id?: string;
|
||||
innerText?: string;
|
||||
name?: string;
|
||||
secondaryText?: string;
|
||||
selected?: boolean;
|
||||
textContent?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
items: OptionItem[];
|
||||
border?: boolean;
|
||||
callback?: (id: string) => void;
|
||||
dialogClass?: string;
|
||||
enableHistory?: boolean;
|
||||
entryAnimationDuration?: number;
|
||||
entryAnimation?: string;
|
||||
exitAnimationDuration?: number;
|
||||
exitAnimation?: string;
|
||||
menuItemClass?: string;
|
||||
offsetLeft?: number;
|
||||
offsetTop?: number;
|
||||
positionTo?: Element | null;
|
||||
positionY?: string;
|
||||
resolveOnClick?: boolean | (string | null)[];
|
||||
shaded?: boolean;
|
||||
showCancel?: boolean;
|
||||
text?: string;
|
||||
timeout?: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface Offset {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface DialogOptions {
|
||||
autoFocus?: boolean;
|
||||
enableHistory?: boolean;
|
||||
entryAnimationDuration?: number;
|
||||
entryAnimation?: string;
|
||||
exitAnimationDuration?: number;
|
||||
exitAnimation?: string;
|
||||
modal?: boolean;
|
||||
removeOnClose?: boolean;
|
||||
scrollY?: boolean;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
function getOffsets(elems: Element[]): Offset[] {
|
||||
const results: Offset[] = [];
|
||||
|
||||
if (!document) {
|
||||
return results;
|
||||
|
@ -30,12 +86,12 @@ function getOffsets(elems) {
|
|||
return results;
|
||||
}
|
||||
|
||||
function getPosition(options, dlg) {
|
||||
function getPosition(positionTo: Element, options: Options, dlg: HTMLElement) {
|
||||
const windowSize = dom.getWindowSize();
|
||||
const windowHeight = windowSize.innerHeight;
|
||||
const windowWidth = windowSize.innerWidth;
|
||||
|
||||
const pos = getOffsets([options.positionTo])[0];
|
||||
const pos = getOffsets([positionTo])[0];
|
||||
|
||||
if (options.positionY !== 'top') {
|
||||
pos.top += (pos.height || 0) / 2;
|
||||
|
@ -71,19 +127,22 @@ function getPosition(options, dlg) {
|
|||
return pos;
|
||||
}
|
||||
|
||||
function centerFocus(elem, horiz, on) {
|
||||
function centerFocus(elem: Element, horiz: boolean, on: boolean) {
|
||||
import('../../scripts/scrollHelper').then((scrollHelper) => {
|
||||
const fn = on ? 'on' : 'off';
|
||||
scrollHelper.centerFocus[fn](elem, horiz);
|
||||
}).catch(e => {
|
||||
console.warn('Error in centerFocus', e);
|
||||
});
|
||||
}
|
||||
|
||||
export function show(options) {
|
||||
/* eslint-disable-next-line sonarjs/cognitive-complexity */
|
||||
export function show(options: Options) {
|
||||
// items
|
||||
// positionTo
|
||||
// showCancel
|
||||
// title
|
||||
const dialogOptions = {
|
||||
const dialogOptions: DialogOptions = {
|
||||
removeOnClose: true,
|
||||
enableHistory: options.enableHistory,
|
||||
scrollY: false
|
||||
|
@ -239,7 +298,10 @@ export function show(options) {
|
|||
dlg.innerHTML = html;
|
||||
|
||||
if (layoutManager.tv) {
|
||||
centerFocus(dlg.querySelector('.actionSheetScroller'), false, true);
|
||||
const scroller = dlg.querySelector('.actionSheetScroller');
|
||||
if (scroller) {
|
||||
centerFocus(scroller, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
const btnCloseActionSheet = dlg.querySelector('.btnCloseActionSheet');
|
||||
|
@ -249,9 +311,9 @@ export function show(options) {
|
|||
});
|
||||
}
|
||||
|
||||
let selectedId;
|
||||
let selectedId: string | null = null;
|
||||
|
||||
let timeout;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
if (options.timeout) {
|
||||
timeout = setTimeout(function () {
|
||||
dialogHelper.close(dlg);
|
||||
|
@ -259,16 +321,16 @@ export function show(options) {
|
|||
}
|
||||
|
||||
return new Promise(function (resolve, reject) {
|
||||
let isResolved;
|
||||
let isResolved = false;
|
||||
|
||||
dlg.addEventListener('click', function (e) {
|
||||
const actionSheetMenuItem = dom.parentWithClass(e.target, 'actionSheetMenuItem');
|
||||
const actionSheetMenuItem = dom.parentWithClass(e.target as HTMLElement, 'actionSheetMenuItem');
|
||||
|
||||
if (actionSheetMenuItem) {
|
||||
selectedId = actionSheetMenuItem.getAttribute('data-id');
|
||||
|
||||
if (options.resolveOnClick) {
|
||||
if (options.resolveOnClick.indexOf) {
|
||||
if (Array.isArray(options.resolveOnClick)) {
|
||||
if (options.resolveOnClick.indexOf(selectedId) !== -1) {
|
||||
resolve(selectedId);
|
||||
isResolved = true;
|
||||
|
@ -285,12 +347,15 @@ export function show(options) {
|
|||
|
||||
dlg.addEventListener('close', function () {
|
||||
if (layoutManager.tv) {
|
||||
centerFocus(dlg.querySelector('.actionSheetScroller'), false, false);
|
||||
const scroller = dlg.querySelector('.actionSheetScroller');
|
||||
if (scroller) {
|
||||
centerFocus(scroller, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
timeout = undefined;
|
||||
}
|
||||
|
||||
if (!isResolved) {
|
||||
|
@ -306,13 +371,15 @@ export function show(options) {
|
|||
}
|
||||
});
|
||||
|
||||
dialogHelper.open(dlg);
|
||||
dialogHelper.open(dlg).catch(e => {
|
||||
console.warn('DialogHelper.open error', e);
|
||||
});
|
||||
|
||||
const pos = options.positionTo && dialogOptions.size !== 'fullscreen' ? getPosition(options, dlg) : null;
|
||||
const pos = options.positionTo && dialogOptions.size !== 'fullscreen' ? getPosition(options.positionTo, options, dlg) : null;
|
||||
|
||||
if (pos) {
|
||||
dlg.style.position = 'fixed';
|
||||
dlg.style.margin = 0;
|
||||
dlg.style.margin = '0';
|
||||
dlg.style.left = pos.left + 'px';
|
||||
dlg.style.top = pos.top + 'px';
|
||||
}
|
|
@ -1,4 +1,3 @@
|
|||
import Package from '../../package.json';
|
||||
import appSettings from '../scripts/settings/appSettings';
|
||||
import browser from '../scripts/browser';
|
||||
import Events from '../utils/events.ts';
|
||||
|
@ -36,7 +35,7 @@ function getDeviceProfile(item) {
|
|||
let profile;
|
||||
|
||||
if (window.NativeShell) {
|
||||
profile = window.NativeShell.AppHost.getDeviceProfile(profileBuilder, Package.version);
|
||||
profile = window.NativeShell.AppHost.getDeviceProfile(profileBuilder, __PACKAGE_JSON_VERSION__);
|
||||
} else {
|
||||
const builderOpts = getBaseProfileOptions(item);
|
||||
profile = profileBuilder(builderOpts);
|
||||
|
@ -46,18 +45,27 @@ function getDeviceProfile(item) {
|
|||
const maxTranscodingVideoWidth = maxVideoWidth < 0 ? appHost.screen()?.maxAllowedWidth : maxVideoWidth;
|
||||
|
||||
if (maxTranscodingVideoWidth) {
|
||||
const conditionWidth = {
|
||||
Condition: 'LessThanEqual',
|
||||
Property: 'Width',
|
||||
Value: maxTranscodingVideoWidth.toString(),
|
||||
IsRequired: false
|
||||
};
|
||||
|
||||
if (appSettings.limitSupportedVideoResolution()) {
|
||||
profile.CodecProfiles.push({
|
||||
Type: 'Video',
|
||||
Conditions: [conditionWidth]
|
||||
});
|
||||
}
|
||||
|
||||
profile.TranscodingProfiles.forEach((transcodingProfile) => {
|
||||
if (transcodingProfile.Type === 'Video') {
|
||||
transcodingProfile.Conditions = (transcodingProfile.Conditions || []).filter((condition) => {
|
||||
return condition.Property !== 'Width';
|
||||
});
|
||||
|
||||
transcodingProfile.Conditions.push({
|
||||
Condition: 'LessThanEqual',
|
||||
Property: 'Width',
|
||||
Value: maxTranscodingVideoWidth.toString(),
|
||||
IsRequired: false
|
||||
});
|
||||
transcodingProfile.Conditions.push(conditionWidth);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -378,7 +386,7 @@ export const appHost = {
|
|||
},
|
||||
appVersion: function () {
|
||||
return window.NativeShell?.AppHost?.appVersion ?
|
||||
window.NativeShell.AppHost.appVersion() : Package.version;
|
||||
window.NativeShell.AppHost.appVersion() : __PACKAGE_JSON_VERSION__;
|
||||
},
|
||||
getPushTokenInfo: function () {
|
||||
return {};
|
||||
|
|
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;
|
87
src/components/cardbuilder/Card/CardFooterText.tsx
Normal file
87
src/components/cardbuilder/Card/CardFooterText.tsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
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';
|
||||
import Image from 'components/common/Image';
|
||||
|
||||
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 }) => (
|
||||
<Box className='cardFooterLogo'>
|
||||
<Image
|
||||
imgUrl={logoUrl}
|
||||
containImage
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
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: item.ProgramInfo || 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;
|
718
src/components/cardbuilder/Card/cardHelper.ts
Normal file
718
src/components/cardbuilder/Card/cardHelper.ts
Normal file
|
@ -0,0 +1,718 @@
|
|||
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 { 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 {
|
||||
const title = text || itemHelper.getDisplayName(item);
|
||||
|
||||
if (layoutManager.tv) {
|
||||
return {
|
||||
title
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
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 || '\u00A0' };
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.cardDefaultText {
|
||||
.cardImageContainer > .cardDefaultText {
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
font-size: 2em;
|
||||
|
@ -408,6 +408,7 @@ button::-moz-focus-inner {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
contain: layout style;
|
||||
z-index: 1;
|
||||
|
||||
[dir="ltr"] & {
|
||||
right: 0.225em;
|
||||
|
@ -852,7 +853,7 @@ button::-moz-focus-inner {
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
.cardOverlayFab-primary {
|
||||
.cardOverlayContainer > .cardOverlayFab-primary {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
font-size: 130%;
|
||||
padding: 0;
|
||||
|
@ -865,7 +866,7 @@ button::-moz-focus-inner {
|
|||
left: 50%;
|
||||
}
|
||||
|
||||
.cardOverlayFab-primary:hover {
|
||||
.cardOverlayContainer > .cardOverlayFab-primary:hover {
|
||||
transform: scale(1.4, 1.4);
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
|
|
@ -73,7 +73,7 @@ function getImageWidth(shape, screenWidth, isOrientationLandscape) {
|
|||
* @param {Object} items - A set of items.
|
||||
* @param {Object} options - Options for handling the items.
|
||||
*/
|
||||
function setCardData(items, options) {
|
||||
export function setCardData(items, options) {
|
||||
options.shape = options.shape || 'auto';
|
||||
|
||||
const primaryImageAspectRatio = imageLoader.getPrimaryImageAspectRatio(items);
|
||||
|
|
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