diff --git a/.ci/azure-pipelines-build.yml b/.ci/azure-pipelines-build.yml deleted file mode 100644 index d873172396..0000000000 --- a/.ci/azure-pipelines-build.yml +++ /dev/null @@ -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)' diff --git a/.ci/azure-pipelines-package.yml b/.ci/azure-pipelines-package.yml deleted file mode 100644 index 0081fbb907..0000000000 --- a/.ci/azure-pipelines-package.yml +++ /dev/null @@ -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)' diff --git a/.ci/azure-pipelines.yml b/.ci/azure-pipelines.yml deleted file mode 100644 index d3b77d41bc..0000000000 --- a/.ci/azure-pipelines.yml +++ /dev/null @@ -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 diff --git a/.copr b/.copr deleted file mode 120000 index 0c6c7bc547..0000000000 --- a/.copr +++ /dev/null @@ -1 +0,0 @@ -fedora/ \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index 84ba694073..9fb3eefd1a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,5 +8,5 @@ trim_trailing_whitespace = true insert_final_newline = true end_of_line = lf -[*.json] +[*.{json,yaml,yml}] indent_size = 2 diff --git a/.eslintrc.js b/.eslintrc.js index a43399e96c..a123939b44 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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'] diff --git a/.github/renovate.json b/.github/renovate.json index ecfce657aa..9bfb15c5e3 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -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" + ] } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d1f0f5626d..2594cf74fa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b7b091be6e..07ed9c73a9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -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 diff --git a/.github/workflows/pr-suggestions.yml b/.github/workflows/pr-suggestions.yml index 1bcb1d2d27..0921fb9247 100644 --- a/.github/workflows/pr-suggestions.yml +++ b/.github/workflows/pr-suggestions.yml @@ -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 }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c26f948d17..c88cc97032 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 }} diff --git a/.github/workflows/update-sdk.yml b/.github/workflows/update-sdk.yml new file mode 100644 index 0000000000..1c42abb998 --- /dev/null +++ b/.github/workflows/update-sdk.yml @@ -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 + author: jellyfin-bot + 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 diff --git a/.gitignore b/.gitignore index 52cd61ad14..d039edb955 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ config.json # ide .idea +.vs # log yarn-error.log diff --git a/.vscode/settings.json b/.vscode/settings.json index c54aff90bb..d2b2caa950 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "eslint.format.enable": true, "editor.formatOnSave": false diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 8bc658eb04..e03662ab02 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -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 diff --git a/build.sh b/build.sh deleted file mode 100755 index 65d0ac3cc8..0000000000 --- a/build.sh +++ /dev/null @@ -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 -p/--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 diff --git a/build.yaml b/build.yaml deleted file mode 100644 index 7b5b05ed8f..0000000000 --- a/build.yaml +++ /dev/null @@ -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 diff --git a/bump_version b/bump_version index 948f260bdf..03493de46d 100755 --- a/bump_version +++ b/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 " @@ -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 $( 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 -- 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 diff --git a/debian/changelog b/debian/changelog deleted file mode 100644 index bf68fb6941..0000000000 --- a/debian/changelog +++ /dev/null @@ -1,17 +0,0 @@ -jellyfin-web (10.8.0-1) unstable; urgency=medium - - * Forthcoming stable release - - -- Jellyfin Packaging Team Fri, 04 Dec 2020 21:58:23 -0500 - -jellyfin-web (10.7.0-1) unstable; urgency=medium - - * Forthcoming stable release - - -- Jellyfin Packaging Team 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 Mon, 16 Mar 2020 11:15:00 -0400 diff --git a/debian/compat b/debian/compat deleted file mode 100644 index 45a4fb75db..0000000000 --- a/debian/compat +++ /dev/null @@ -1 +0,0 @@ -8 diff --git a/debian/conffiles b/debian/conffiles deleted file mode 100644 index a4b2c557e9..0000000000 --- a/debian/conffiles +++ /dev/null @@ -1 +0,0 @@ -/usr/share/jellyfin/web/config.json diff --git a/debian/control b/debian/control deleted file mode 100644 index ce7b130efc..0000000000 --- a/debian/control +++ /dev/null @@ -1,16 +0,0 @@ -Source: jellyfin-web -Section: misc -Priority: optional -Maintainer: Jellyfin Team -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. diff --git a/debian/copyright b/debian/copyright deleted file mode 100644 index 85548075eb..0000000000 --- a/debian/copyright +++ /dev/null @@ -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 -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 - . - On Debian systems, the complete text of the GNU General - Public License version 2 can be found in "/usr/share/common-licenses/GPL-2". diff --git a/debian/gbp.conf b/debian/gbp.conf deleted file mode 100644 index 60b3d28723..0000000000 --- a/debian/gbp.conf +++ /dev/null @@ -1,6 +0,0 @@ -[DEFAULT] -pristine-tar = False -cleaner = fakeroot debian/rules clean - -[import-orig] -filter = [ ".git*", ".hg*", ".vs*", ".vscode*" ] diff --git a/debian/install b/debian/install deleted file mode 100644 index 584fe06a11..0000000000 --- a/debian/install +++ /dev/null @@ -1 +0,0 @@ -web usr/share/jellyfin/ diff --git a/debian/po/POTFILES.in b/debian/po/POTFILES.in deleted file mode 100644 index cef83a3407..0000000000 --- a/debian/po/POTFILES.in +++ /dev/null @@ -1 +0,0 @@ -[type: gettext/rfc822deb] templates diff --git a/debian/po/templates.pot b/debian/po/templates.pot deleted file mode 100644 index 2cdcae4173..0000000000 --- a/debian/po/templates.pot +++ /dev/null @@ -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 , 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 \n" -"Language-Team: LANGUAGE \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 "" diff --git a/debian/rules b/debian/rules deleted file mode 100755 index 091af0db11..0000000000 --- a/debian/rules +++ /dev/null @@ -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 diff --git a/debian/source/format b/debian/source/format deleted file mode 100644 index d3827e75a5..0000000000 --- a/debian/source/format +++ /dev/null @@ -1 +0,0 @@ -1.0 diff --git a/debian/source/options b/debian/source/options deleted file mode 100644 index b7adf56c67..0000000000 --- a/debian/source/options +++ /dev/null @@ -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' diff --git a/deployment/Dockerfile.centos b/deployment/Dockerfile.centos deleted file mode 100644 index 146956fe80..0000000000 --- a/deployment/Dockerfile.centos +++ /dev/null @@ -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"] diff --git a/deployment/Dockerfile.debian b/deployment/Dockerfile.debian deleted file mode 100644 index 1350804b5e..0000000000 --- a/deployment/Dockerfile.debian +++ /dev/null @@ -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"] diff --git a/deployment/Dockerfile.docker b/deployment/Dockerfile.docker deleted file mode 100644 index 46271bc9ae..0000000000 --- a/deployment/Dockerfile.docker +++ /dev/null @@ -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} diff --git a/deployment/Dockerfile.fedora b/deployment/Dockerfile.fedora deleted file mode 100644 index 9cb191ac06..0000000000 --- a/deployment/Dockerfile.fedora +++ /dev/null @@ -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"] diff --git a/deployment/Dockerfile.portable b/deployment/Dockerfile.portable deleted file mode 100644 index 6a3ce962f3..0000000000 --- a/deployment/Dockerfile.portable +++ /dev/null @@ -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"] diff --git a/deployment/build.centos b/deployment/build.centos deleted file mode 100755 index d48afc76a6..0000000000 --- a/deployment/build.centos +++ /dev/null @@ -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 <>jellyfin-web.spec -* $( LANG=C date '+%a %b %d %Y' ) Jellyfin Packaging Team -- 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 diff --git a/deployment/build.debian b/deployment/build.debian deleted file mode 100755 index c6f112cef4..0000000000 --- a/deployment/build.debian +++ /dev/null @@ -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 <changelog -jellyfin-web (${BUILD_ID}-unstable) unstable; urgency=medium - - * Jellyfin Web unstable build ${BUILD_ID} for merged PR #${PR_ID} - - -- Jellyfin Packaging Team $( 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 diff --git a/deployment/build.fedora b/deployment/build.fedora deleted file mode 100755 index 0806ba6ede..0000000000 --- a/deployment/build.fedora +++ /dev/null @@ -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 <>jellyfin-web.spec -* $( LANG=C date '+%a %b %d %Y' ) Jellyfin Packaging Team -- 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 diff --git a/deployment/build.portable b/deployment/build.portable deleted file mode 100755 index 8bf8a0d2af..0000000000 --- a/deployment/build.portable +++ /dev/null @@ -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 diff --git a/fedora/Makefile b/fedora/Makefile deleted file mode 100644 index adc5ecf672..0000000000 --- a/fedora/Makefile +++ /dev/null @@ -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) $< diff --git a/fedora/jellyfin-web.spec b/fedora/jellyfin-web.spec deleted file mode 100644 index fa1c1722e9..0000000000 --- a/fedora/jellyfin-web.spec +++ /dev/null @@ -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 -- Forthcoming stable release -* Mon Jul 27 2020 Jellyfin Packaging Team -- Forthcoming stable release -* Mon Mar 23 2020 Jellyfin Packaging Team -- Forthcoming stable release diff --git a/package-lock.json b/package-lock.json index cf80543d0a..ccf65f126f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "10.9.0", "license": "GPL-2.0-or-later", "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", @@ -17,14 +17,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", @@ -34,14 +36,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", @@ -52,12 +52,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" }, @@ -69,12 +72,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", @@ -86,7 +89,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", @@ -118,7 +121,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", @@ -1907,9 +1910,9 @@ "dev": true }, "node_modules/@babel/runtime": { - "version": "7.23.8", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.8.tgz", - "integrity": "sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.0.tgz", + "integrity": "sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -2951,9 +2954,9 @@ "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" }, "node_modules/@emotion/react": { - "version": "11.11.3", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.3.tgz", - "integrity": "sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA==", + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.4.tgz", + "integrity": "sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw==", "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", @@ -3540,28 +3543,28 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.3.tgz", - "integrity": "sha512-O0WKDOo0yhJuugCx6trZQj5jVJ9yR0ystG2JaNAemYUWce+pmM6WUEFIibnWyEJKdrDxhm75NoSRME35FNaM/Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", + "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", "dependencies": { - "@floating-ui/utils": "^0.2.0" + "@floating-ui/utils": "^0.2.1" } }, "node_modules/@floating-ui/dom": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.4.tgz", - "integrity": "sha512-jByEsHIY+eEdCjnTVu+E3ephzTOzkQ8hgUfGwos+bg7NlH33Zc5uO+QHz1mrQUOgIKKDD1RtS201P9NvAfq3XQ==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", + "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", "dependencies": { - "@floating-ui/core": "^1.5.3", + "@floating-ui/core": "^1.0.0", "@floating-ui/utils": "^0.2.0" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.6.tgz", - "integrity": "sha512-IB8aCRFxr8nFkdYZgH+Otd9EVQPJoynxeFRGTB8voPoZMRWo8XjYuCRgpI1btvuKY69XMiLnW+ym7zoBHM90Rw==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.8.tgz", + "integrity": "sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==", "dependencies": { - "@floating-ui/dom": "^1.5.4" + "@floating-ui/dom": "^1.6.1" }, "peerDependencies": { "react": ">=16.8.0", @@ -3636,10 +3639,15 @@ "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", "dev": true }, + "node_modules/@jellyfin/libass-wasm": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@jellyfin/libass-wasm/-/libass-wasm-4.2.1.tgz", + "integrity": "sha512-oWK2yz8fFlMXkIuxUc9g/bqN2h56AB+8b6vF/Ikns6WZ/nmcGJ/5lcVaLI4csE83yWgmco4gHO3HyJDsM9EXcQ==" + }, "node_modules/@jellyfin/sdk": { - "version": "0.0.0-unstable.202401060501", - "resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202401060501.tgz", - "integrity": "sha512-6+mTkcr62rUqF8BoZS8K2h87fV/JjMYPqZ45faytqecJIv3GMo2cJTtBKR1LrmPuAdKhC+/1ic5E7bxIK+P9gA==", + "version": "0.0.0-unstable.202403240502", + "resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202403240502.tgz", + "integrity": "sha512-EkRMiNpiwK0imPrKCuxPVmGGIZHF9gxId0mZ0BZpnSvY3Y0Gc5r+QB10lYRwKuQLRuHL4zDawsaCfk9BS+JKow==", "peerDependencies": { "axios": "^1.3.4" } @@ -3815,14 +3823,14 @@ } }, "node_modules/@mui/base": { - "version": "5.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.32.tgz", - "integrity": "sha512-4VptvYeLUYMJhZapWBkD50GmKfOc0XT381KJcTK3ncZYIl8MdBhpR6l8jOyeP5cixUPBJhstjrnmQEAHjCLriw==", + "version": "5.0.0-beta.37", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.37.tgz", + "integrity": "sha512-/o3anbb+DeCng8jNsd3704XtmmLDZju1Fo8R2o7ugrVtPQ/QpcqddwKNzKPZwa0J5T8YNW3ZVuHyQgbTnQLisQ==", "dependencies": { - "@babel/runtime": "^7.23.8", - "@floating-ui/react-dom": "^2.0.5", + "@babel/runtime": "^7.23.9", + "@floating-ui/react-dom": "^2.0.8", "@mui/types": "^7.2.13", - "@mui/utils": "^5.15.5", + "@mui/utils": "^5.15.11", "@popperjs/core": "^2.11.8", "clsx": "^2.1.0", "prop-types": "^15.8.1" @@ -3846,20 +3854,20 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.15.6", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.6.tgz", - "integrity": "sha512-0aoWS4qvk1uzm9JBs83oQmIMIQeTBUeqqu8u+3uo2tMznrB5fIKqQVCbCgq+4Tm4jG+5F7dIvnjvQ2aV7UKtdw==", + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.11.tgz", + "integrity": "sha512-JVrJ9Jo4gyU707ujnRzmE8ABBWpXd6FwL9GYULmwZRtfPg89ggXs/S3MStQkpJ1JRWfdLL6S5syXmgQGq5EDAw==", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" } }, "node_modules/@mui/icons-material": { - "version": "5.15.5", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.5.tgz", - "integrity": "sha512-qiql0fd1JY7TZ1wm1RldvU7sL8QUatE9OC12i/qm5rnm/caTFyAfOyTIR7qqxorsJvoZGyrzwoMkal6Ij9kM0A==", + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.11.tgz", + "integrity": "sha512-R5ZoQqnKpd+5Ew7mBygTFLxgYsQHPhgR3TDXSgIHYIjGzYuyPLmGLSdcPUoMdi6kxiYqHlpPj4NJxlbaFD0UHA==", "dependencies": { - "@babel/runtime": "^7.23.8" + "@babel/runtime": "^7.23.9" }, "engines": { "node": ">=12.0.0" @@ -3880,19 +3888,19 @@ } }, "node_modules/@mui/material": { - "version": "5.15.5", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.5.tgz", - "integrity": "sha512-2KfA39f/UWeQl0O22UJs3x1nG3chYlyu9wnux5vTnxUTLzkgYIzQIHaH+ZOGpv5JiZBMKktAPNfhqyhSaQ49qQ==", + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.11.tgz", + "integrity": "sha512-FA3eEuEZaDaxgN3CgfXezMWbCZ4VCeU/sv0F0/PK5n42qIgsPVD6q+j71qS7/62sp6wRFMHtDMpXRlN+tT/7NA==", "dependencies": { - "@babel/runtime": "^7.23.8", - "@mui/base": "5.0.0-beta.32", - "@mui/core-downloads-tracker": "^5.15.5", - "@mui/system": "^5.15.5", + "@babel/runtime": "^7.23.9", + "@mui/base": "5.0.0-beta.37", + "@mui/core-downloads-tracker": "^5.15.11", + "@mui/system": "^5.15.11", "@mui/types": "^7.2.13", - "@mui/utils": "^5.15.5", + "@mui/utils": "^5.15.11", "@types/react-transition-group": "^4.4.10", "clsx": "^2.1.0", - "csstype": "^3.1.2", + "csstype": "^3.1.3", "prop-types": "^15.8.1", "react-is": "^18.2.0", "react-transition-group": "^4.4.5" @@ -3929,12 +3937,12 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, "node_modules/@mui/private-theming": { - "version": "5.15.6", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.6.tgz", - "integrity": "sha512-ZBX9E6VNUSscUOtU8uU462VvpvBS7eFl5VfxAzTRVQBHflzL+5KtnGrebgf6Nd6cdvxa1o0OomiaxSKoN2XDmg==", + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.11.tgz", + "integrity": "sha512-jY/696SnSxSzO1u86Thym7ky5T9CgfidU3NFJjguldqK4f3Z5S97amZ6nffg8gTD0HBjY9scB+4ekqDEUmxZOA==", "dependencies": { - "@babel/runtime": "^7.23.8", - "@mui/utils": "^5.15.6", + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.15.11", "prop-types": "^15.8.1" }, "engines": { @@ -3955,13 +3963,13 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.15.6", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.6.tgz", - "integrity": "sha512-KAn8P8xP/WigFKMlEYUpU9z2o7jJnv0BG28Qu1dhNQVutsLVIFdRf5Nb+0ijp2qgtcmygQ0FtfRuXv5LYetZTg==", + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.11.tgz", + "integrity": "sha512-So21AhAngqo07ces4S/JpX5UaMU2RHXpEA6hNzI6IQjd/1usMPxpgK8wkGgTe3JKmC2KDmH8cvoycq5H3Ii7/w==", "dependencies": { - "@babel/runtime": "^7.23.8", + "@babel/runtime": "^7.23.9", "@emotion/cache": "^11.11.0", - "csstype": "^3.1.2", + "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "engines": { @@ -3986,17 +3994,17 @@ } }, "node_modules/@mui/system": { - "version": "5.15.6", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.6.tgz", - "integrity": "sha512-J01D//u8IfXvaEHMBQX5aO2l7Q+P15nt96c4NskX7yp5/+UuZP8XCQJhtBtLuj+M2LLyXHYGmCPeblsmmscP2Q==", + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.11.tgz", + "integrity": "sha512-9j35suLFq+MgJo5ktVSHPbkjDLRMBCV17NMBdEQurh6oWyGnLM4uhU4QGZZQ75o0vuhjJghOCA1jkO3+79wKsA==", "dependencies": { - "@babel/runtime": "^7.23.8", - "@mui/private-theming": "^5.15.6", - "@mui/styled-engine": "^5.15.6", + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.15.11", + "@mui/styled-engine": "^5.15.11", "@mui/types": "^7.2.13", - "@mui/utils": "^5.15.6", + "@mui/utils": "^5.15.11", "clsx": "^2.1.0", - "csstype": "^3.1.2", + "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "engines": { @@ -4038,11 +4046,11 @@ } }, "node_modules/@mui/utils": { - "version": "5.15.6", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.6.tgz", - "integrity": "sha512-qfEhf+zfU9aQdbzo1qrSWlbPQhH1nCgeYgwhOVnj9Bn39shJQitEnXpSQpSNag8+uty5Od6PxmlNKPTnPySRKA==", + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.11.tgz", + "integrity": "sha512-D6bwqprUa9Stf8ft0dcMqWyWDKEo7D+6pB1k8WajbqlYIRA8J8Kw9Ra7PSZKKePGBGWO+/xxrX1U8HpG/aXQCw==", "dependencies": { - "@babel/runtime": "^7.23.8", + "@babel/runtime": "^7.23.9", "@types/prop-types": "^15.7.11", "prop-types": "^15.8.1", "react-is": "^18.2.0" @@ -4070,9 +4078,9 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, "node_modules/@mui/x-data-grid": { - "version": "6.18.7", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.18.7.tgz", - "integrity": "sha512-K1A3pMUPxI4/Mt5A4vrK45fBBQK5rZvBVqRMrB5n8zX++Bj+WLWKvLTtfCmlriUtzuadr/Hl7Z+FDRXUJAx6qg==", + "version": "6.19.5", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.19.5.tgz", + "integrity": "sha512-jV1ZqwyFslKqFScSn4t+xc/tNxLHOeJjz3HoeK+Wdf5t3bPM69pg/jLeg8TmOkAUY62JmQKCLVmcGWiR3AqUKQ==", "dependencies": { "@babel/runtime": "^7.23.2", "@mui/utils": "^5.14.16", @@ -4583,9 +4591,9 @@ "dev": true }, "node_modules/@types/loadable__component": { - "version": "5.13.8", - "resolved": "https://registry.npmjs.org/@types/loadable__component/-/loadable__component-5.13.8.tgz", - "integrity": "sha512-0FF/WihuPkR5IFOHiBzC95bSACvgQNUQCuNy1WF8F/lCBBHgS2SxarIk4CTjWM10A72ovpmXZDRcuAXZNS+/kQ==", + "version": "5.13.9", + "resolved": "https://registry.npmjs.org/@types/loadable__component/-/loadable__component-5.13.9.tgz", + "integrity": "sha512-QWOtIkwZqHNdQj3nixQ8oyihQiTMKZLk/DNuvNxMSbTfxf47w+kqcbnxlUeBgAxdOtW0Dh48dTAIp83iJKtnrQ==", "dev": true, "dependencies": { "@types/react": "*" @@ -4705,6 +4713,15 @@ "@types/react": "^17" } }, + "node_modules/@types/react-lazy-load-image-component": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@types/react-lazy-load-image-component/-/react-lazy-load-image-component-1.6.3.tgz", + "integrity": "sha512-HsIsYz7yWWTh/bftdzGnijKD26JyofLRqM/RM80sxs7Gk13G83ew8R/ra2XzXuiZfjNEjAq/Va+NBHFF9ciwxA==", + "dependencies": { + "@types/react": "*", + "csstype": "^3.0.2" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -4759,9 +4776,9 @@ } }, "node_modules/@types/sortablejs": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.7.tgz", - "integrity": "sha512-PvgWCx1Lbgm88FdQ6S7OGvLIjWS66mudKPlfdrWil0TjsO5zmoZmzoKiiwRShs1dwPgrlkr0N4ewuy0/+QUXYQ==", + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.8.tgz", + "integrity": "sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==", "dev": true }, "node_modules/@types/unist": { @@ -5075,13 +5092,13 @@ "dev": true }, "node_modules/@vitest/expect": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.2.1.tgz", - "integrity": "sha512-/bqGXcHfyKgFWYwIgFr1QYDaR9e64pRKxgBNWNXPefPFRhgm+K3+a/dS0cUGEreWngets3dlr8w8SBRw2fCfFQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.0.tgz", + "integrity": "sha512-7bWt0vBTZj08B+Ikv70AnLRicohYwFgzNjFqo9SxxqHHxSlUJGSXmCRORhOnRMisiUryKMdvsi1n27Bc6jL9DQ==", "dev": true, "dependencies": { - "@vitest/spy": "1.2.1", - "@vitest/utils": "1.2.1", + "@vitest/spy": "1.3.0", + "@vitest/utils": "1.3.0", "chai": "^4.3.10" }, "funding": { @@ -5089,12 +5106,12 @@ } }, "node_modules/@vitest/runner": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.2.1.tgz", - "integrity": "sha512-zc2dP5LQpzNzbpaBt7OeYAvmIsRS1KpZQw4G3WM/yqSV1cQKNKwLGmnm79GyZZjMhQGlRcSFMImLjZaUQvNVZQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.0.tgz", + "integrity": "sha512-1Jb15Vo/Oy7mwZ5bXi7zbgszsdIBNjc4IqP8Jpr/8RdBC4nF1CTzIAn2dxYvpF1nGSseeL39lfLQ2uvs5u1Y9A==", "dev": true, "dependencies": { - "@vitest/utils": "1.2.1", + "@vitest/utils": "1.3.0", "p-limit": "^5.0.0", "pathe": "^1.1.1" }, @@ -5130,9 +5147,9 @@ } }, "node_modules/@vitest/snapshot": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.2.1.tgz", - "integrity": "sha512-Tmp/IcYEemKaqAYCS08sh0vORLJkMr0NRV76Gl8sHGxXT5151cITJCET20063wk0Yr/1koQ6dnmP6eEqezmd/Q==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.0.tgz", + "integrity": "sha512-swmktcviVVPYx9U4SEQXLV6AEY51Y6bZ14jA2yo6TgMxQ3h+ZYiO0YhAHGJNp0ohCFbPAis1R9kK0cvN6lDPQA==", "dev": true, "dependencies": { "magic-string": "^0.30.5", @@ -5144,9 +5161,9 @@ } }, "node_modules/@vitest/spy": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.2.1.tgz", - "integrity": "sha512-vG3a/b7INKH7L49Lbp0IWrG6sw9j4waWAucwnksPB1r1FTJgV7nkBByd9ufzu6VWya/QTvQW4V9FShZbZIB2UQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.0.tgz", + "integrity": "sha512-AkCU0ThZunMvblDpPKgjIi025UxR8V7MZ/g/EwmAGpjIujLVV2X6rGYGmxE2D4FJbAy0/ijdROHMWa2M/6JVMw==", "dev": true, "dependencies": { "tinyspy": "^2.2.0" @@ -5156,9 +5173,9 @@ } }, "node_modules/@vitest/utils": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.2.1.tgz", - "integrity": "sha512-bsH6WVZYe/J2v3+81M5LDU8kW76xWObKIURpPrOXm2pjBniBu2MERI/XP60GpS4PHU3jyK50LUutOwrx4CyHUg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.0.tgz", + "integrity": "sha512-/LibEY/fkaXQufi4GDlQZhikQsPO2entBKtfuyIpr1jV4DpaeasqkeHjhdOhU24vSHshcSuEyVlWdzvv2XmYCw==", "dev": true, "dependencies": { "diff-sequences": "^29.6.3", @@ -6341,9 +6358,9 @@ } }, "node_modules/browserslist": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", - "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", + "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", "dev": true, "funding": [ { @@ -6360,8 +6377,8 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001565", - "electron-to-chromium": "^1.4.601", + "caniuse-lite": "^1.0.30001587", + "electron-to-chromium": "^1.4.668", "node-releases": "^2.0.14", "update-browserslist-db": "^1.0.13" }, @@ -6546,9 +6563,9 @@ "dev": true }, "node_modules/caniuse-lite": { - "version": "1.0.30001579", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001579.tgz", - "integrity": "sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==", + "version": "1.0.30001596", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001596.tgz", + "integrity": "sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ==", "dev": true, "funding": [ { @@ -7493,13 +7510,13 @@ } }, "node_modules/cssnano": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.0.3.tgz", - "integrity": "sha512-MRq4CIj8pnyZpcI2qs6wswoYoDD1t0aL28n+41c1Ukcpm56m1h6mCexIHBGjfZfnTqtGSSCP4/fB1ovxgjBOiw==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.0.5.tgz", + "integrity": "sha512-tpTp/ukgrElwu3ESFY4IvWnGn8eTt8cJhC2aAbtA3lvUlxp6t6UPv8YCLjNnEGiFreT1O0LiOM1U3QyTBVFl2A==", "dev": true, "dependencies": { - "cssnano-preset-default": "^6.0.3", - "lilconfig": "^3.0.0" + "cssnano-preset-default": "^6.0.5", + "lilconfig": "^3.1.1" }, "engines": { "node": "^14 || ^16 || >=18.0" @@ -7513,40 +7530,41 @@ } }, "node_modules/cssnano-preset-default": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.0.3.tgz", - "integrity": "sha512-4y3H370aZCkT9Ev8P4SO4bZbt+AExeKhh8wTbms/X7OLDo5E7AYUUy6YPxa/uF5Grf+AJwNcCnxKhZynJ6luBA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.1.0.tgz", + "integrity": "sha512-4DUXZoDj+PI3fRl3MqMjl9DwLGjcsFP4qt+92nLUcN1RGfw2TY+GwNoG2B38Usu1BrcTs8j9pxNfSusmvtSjfg==", "dev": true, "dependencies": { + "browserslist": "^4.23.0", "css-declaration-sorter": "^7.1.1", - "cssnano-utils": "^4.0.1", + "cssnano-utils": "^4.0.2", "postcss-calc": "^9.0.1", - "postcss-colormin": "^6.0.2", - "postcss-convert-values": "^6.0.2", - "postcss-discard-comments": "^6.0.1", - "postcss-discard-duplicates": "^6.0.1", - "postcss-discard-empty": "^6.0.1", - "postcss-discard-overridden": "^6.0.1", - "postcss-merge-longhand": "^6.0.2", - "postcss-merge-rules": "^6.0.3", - "postcss-minify-font-values": "^6.0.1", - "postcss-minify-gradients": "^6.0.1", - "postcss-minify-params": "^6.0.2", - "postcss-minify-selectors": "^6.0.2", - "postcss-normalize-charset": "^6.0.1", - "postcss-normalize-display-values": "^6.0.1", - "postcss-normalize-positions": "^6.0.1", - "postcss-normalize-repeat-style": "^6.0.1", - "postcss-normalize-string": "^6.0.1", - "postcss-normalize-timing-functions": "^6.0.1", - "postcss-normalize-unicode": "^6.0.2", - "postcss-normalize-url": "^6.0.1", - "postcss-normalize-whitespace": "^6.0.1", - "postcss-ordered-values": "^6.0.1", - "postcss-reduce-initial": "^6.0.2", - "postcss-reduce-transforms": "^6.0.1", - "postcss-svgo": "^6.0.2", - "postcss-unique-selectors": "^6.0.2" + "postcss-colormin": "^6.1.0", + "postcss-convert-values": "^6.1.0", + "postcss-discard-comments": "^6.0.2", + "postcss-discard-duplicates": "^6.0.3", + "postcss-discard-empty": "^6.0.3", + "postcss-discard-overridden": "^6.0.2", + "postcss-merge-longhand": "^6.0.4", + "postcss-merge-rules": "^6.1.0", + "postcss-minify-font-values": "^6.0.3", + "postcss-minify-gradients": "^6.0.3", + "postcss-minify-params": "^6.1.0", + "postcss-minify-selectors": "^6.0.3", + "postcss-normalize-charset": "^6.0.2", + "postcss-normalize-display-values": "^6.0.2", + "postcss-normalize-positions": "^6.0.2", + "postcss-normalize-repeat-style": "^6.0.2", + "postcss-normalize-string": "^6.0.2", + "postcss-normalize-timing-functions": "^6.0.2", + "postcss-normalize-unicode": "^6.1.0", + "postcss-normalize-url": "^6.0.2", + "postcss-normalize-whitespace": "^6.0.2", + "postcss-ordered-values": "^6.0.2", + "postcss-reduce-initial": "^6.1.0", + "postcss-reduce-transforms": "^6.0.2", + "postcss-svgo": "^6.0.3", + "postcss-unique-selectors": "^6.0.3" }, "engines": { "node": "^14 || ^16 || >=18.0" @@ -7556,9 +7574,9 @@ } }, "node_modules/cssnano-utils": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-4.0.1.tgz", - "integrity": "sha512-6qQuYDqsGoiXssZ3zct6dcMxiqfT6epy7x4R0TQJadd4LWO3sPR6JH6ZByOvVLoZ6EdwPGgd7+DR1EmX3tiXQQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-4.0.2.tgz", + "integrity": "sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ==", "dev": true, "engines": { "node": "^14 || ^16 || >=18.0" @@ -7613,9 +7631,9 @@ } }, "node_modules/csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/currently-unhandled": { "version": "0.4.1", @@ -8120,9 +8138,9 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.4.623", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.623.tgz", - "integrity": "sha512-lKoz10iCYlP1WtRYdh5MvocQPWVRoI7ysp6qf18bmeBgR8abE6+I2CsfyNKztRDZvhdWc+krKT6wS7Neg8sw3A==", + "version": "1.4.697", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.697.tgz", + "integrity": "sha512-iPS+iUNUrqTkPRFjMYv1FGXIUYhj2K4rc/93nrDsDtQGMUqyRouCq/xABOSOljKbriEiwg0bEQHGaeD4OaU56g==", "dev": true }, "node_modules/emoji-regex": { @@ -9292,11 +9310,6 @@ "es5-ext": "~0.10.14" } }, - "node_modules/event-target-polyfill": { - "version": "0.0.3", - "resolved": "git+ssh://git@github.com/ThaUnknown/event-target-polyfill.git#5cb9a0ed6774af1b905b525964316911375726a7", - "license": "MIT" - }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -10925,9 +10938,9 @@ } }, "node_modules/hls.js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.1.tgz", - "integrity": "sha512-SsUSlpyjOGnwBhVrVEG6vRFPU2SAJ0gUqrFdGeo7YPbOC0vuWK0TDMyp7n3QiaBC/Wkic771uqPnnVdT8/x+3Q==" + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.7.tgz", + "integrity": "sha512-Hnyf7ojTBtXHeOW1/t6wCBJSiK1WpoKF9yg7juxldDx8u3iswrkPt2wbOA/1NiwU4j27DSIVoIEJRAhcdMef/A==" }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", @@ -12214,14 +12227,6 @@ "set-function-name": "^2.0.1" } }, - "node_modules/jassub": { - "version": "1.7.15", - "resolved": "https://registry.npmjs.org/jassub/-/jassub-1.7.15.tgz", - "integrity": "sha512-8yKAJc++Y1gNfATOPRo3APk0JUhshKl5l7bRkT6WkJ8XP4RvYfVPb6ieH6WDxsMq523exwGzNvjjPEEWT+Z1nQ==", - "dependencies": { - "rvfc-polyfill": "^1.0.7" - } - }, "node_modules/jellyfin-apiclient": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/jellyfin-apiclient/-/jellyfin-apiclient-1.11.0.tgz", @@ -12578,12 +12583,15 @@ } }, "node_modules/lilconfig": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.0.0.tgz", - "integrity": "sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", + "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", "dev": true, "engines": { "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" } }, "node_modules/lines-and-columns": { @@ -12671,8 +12679,7 @@ "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, "node_modules/lodash.memoize": { "version": "4.1.2", @@ -12686,6 +12693,11 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==" + }, "node_modules/lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", @@ -14354,14 +14366,14 @@ } }, "node_modules/postcss-colormin": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-6.0.2.tgz", - "integrity": "sha512-TXKOxs9LWcdYo5cgmcSHPkyrLAh86hX1ijmyy6J8SbOhyv6ua053M3ZAM/0j44UsnQNIWdl8gb5L7xX2htKeLw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-6.1.0.tgz", + "integrity": "sha512-x9yX7DOxeMAR+BgGVnNSAxmAj98NX/YxEMNFP+SDCEeNLb2r3i6Hh1ksMsnW8Ub5SLCpbescQqn9YEbE9554Sw==", "dev": true, "dependencies": { - "browserslist": "^4.22.2", + "browserslist": "^4.23.0", "caniuse-api": "^3.0.0", - "colord": "^2.9.1", + "colord": "^2.9.3", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -14372,12 +14384,12 @@ } }, "node_modules/postcss-convert-values": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-6.0.2.tgz", - "integrity": "sha512-aeBmaTnGQ+NUSVQT8aY0sKyAD/BaLJenEKZ03YK0JnDE1w1Rr8XShoxdal2V2H26xTJKr3v5haByOhJuyT4UYw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-6.1.0.tgz", + "integrity": "sha512-zx8IwP/ts9WvUM6NkVSkiU902QZL1bwPhaVaLynPtCsOTqp+ZKbNi+s6XJg3rfqpKGA/oc7Oxk5t8pOQJcwl/w==", "dev": true, "dependencies": { - "browserslist": "^4.22.2", + "browserslist": "^4.23.0", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -14497,9 +14509,9 @@ } }, "node_modules/postcss-discard-comments": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-6.0.1.tgz", - "integrity": "sha512-f1KYNPtqYLUeZGCHQPKzzFtsHaRuECe6jLakf/RjSRqvF5XHLZnM2+fXLhb8Qh/HBFHs3M4cSLb1k3B899RYIg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-6.0.2.tgz", + "integrity": "sha512-65w/uIqhSBBfQmYnG92FO1mWZjJ4GL5b8atm5Yw2UgrwD7HiNiSSNwJor1eCFGzUgYnN/iIknhNRVqjrrpuglw==", "dev": true, "engines": { "node": "^14 || ^16 || >=18.0" @@ -14509,9 +14521,9 @@ } }, "node_modules/postcss-discard-duplicates": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-6.0.1.tgz", - "integrity": "sha512-1hvUs76HLYR8zkScbwyJ8oJEugfPV+WchpnA+26fpJ7Smzs51CzGBHC32RS03psuX/2l0l0UKh2StzNxOrKCYg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-6.0.3.tgz", + "integrity": "sha512-+JA0DCvc5XvFAxwx6f/e68gQu/7Z9ud584VLmcgto28eB8FqSFZwtrLwB5Kcp70eIoWP/HXqz4wpo8rD8gpsTw==", "dev": true, "engines": { "node": "^14 || ^16 || >=18.0" @@ -14521,9 +14533,9 @@ } }, "node_modules/postcss-discard-empty": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-6.0.1.tgz", - "integrity": "sha512-yitcmKwmVWtNsrrRqGJ7/C0YRy53i0mjexBDQ9zYxDwTWVBgbU4+C9jIZLmQlTDT9zhml+u0OMFJh8+31krmOg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-6.0.3.tgz", + "integrity": "sha512-znyno9cHKQsK6PtxL5D19Fj9uwSzC2mB74cpT66fhgOadEUPyXFkbgwm5tvc3bt3NAy8ltE5MrghxovZRVnOjQ==", "dev": true, "engines": { "node": "^14 || ^16 || >=18.0" @@ -14533,9 +14545,9 @@ } }, "node_modules/postcss-discard-overridden": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-6.0.1.tgz", - "integrity": "sha512-qs0ehZMMZpSESbRkw1+inkf51kak6OOzNRaoLd/U7Fatp0aN2HQ1rxGOrJvYcRAN9VpX8kUF13R2ofn8OlvFVA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-6.0.2.tgz", + "integrity": "sha512-j87xzI4LUggC5zND7KdjsI25APtyMuynXZSujByMaav2roV6OZX+8AaCUcZSWqckZpjAjRyFDdpqybgjFO0HJQ==", "dev": true, "engines": { "node": "^14 || ^16 || >=18.0" @@ -15126,13 +15138,13 @@ "dev": true }, "node_modules/postcss-merge-longhand": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-6.0.2.tgz", - "integrity": "sha512-+yfVB7gEM8SrCo9w2lCApKIEzrTKl5yS1F4yGhV3kSim6JzbfLGJyhR1B6X+6vOT0U33Mgx7iv4X9MVWuaSAfw==", + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-6.0.4.tgz", + "integrity": "sha512-vAfWGcxUUGlFiPM3nDMZA+/Yo9sbpc3JNkcYZez8FfJDv41Dh7tAgA3QGVTocaHCZZL6aXPXPOaBMJsjujodsA==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0", - "stylehacks": "^6.0.2" + "stylehacks": "^6.1.0" }, "engines": { "node": "^14 || ^16 || >=18.0" @@ -15142,14 +15154,14 @@ } }, "node_modules/postcss-merge-rules": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-6.0.3.tgz", - "integrity": "sha512-yfkDqSHGohy8sGYIJwBmIGDv4K4/WrJPX355XrxQb/CSsT4Kc/RxDi6akqn5s9bap85AWgv21ArcUWwWdGNSHA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-6.1.0.tgz", + "integrity": "sha512-lER+W3Gr6XOvxOYk1Vi/6UsAgKMg6MDBthmvbNqi2XxAk/r9XfhdYZSigfWjuWWn3zYw2wLelvtM8XuAEFqRkA==", "dev": true, "dependencies": { - "browserslist": "^4.22.2", + "browserslist": "^4.23.0", "caniuse-api": "^3.0.0", - "cssnano-utils": "^4.0.1", + "cssnano-utils": "^4.0.2", "postcss-selector-parser": "^6.0.15" }, "engines": { @@ -15160,9 +15172,9 @@ } }, "node_modules/postcss-minify-font-values": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.0.1.tgz", - "integrity": "sha512-tIwmF1zUPoN6xOtA/2FgVk1ZKrLcCvE0dpZLtzyyte0j9zUeB8RTbCqrHZGjJlxOvNWKMYtunLrrl7HPOiR46w==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.0.3.tgz", + "integrity": "sha512-SmAeTA1We5rMnN3F8X9YBNo9bj9xB4KyDHnaNJnBfQIPi+60fNiR9OTRnIaMqkYzAQX0vObIw4Pn0vuKEOettg==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15175,13 +15187,13 @@ } }, "node_modules/postcss-minify-gradients": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-6.0.1.tgz", - "integrity": "sha512-M1RJWVjd6IOLPl1hYiOd5HQHgpp6cvJVLrieQYS9y07Yo8itAr6jaekzJphaJFR0tcg4kRewCk3kna9uHBxn/w==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-6.0.3.tgz", + "integrity": "sha512-4KXAHrYlzF0Rr7uc4VrfwDJ2ajrtNEpNEuLxFgwkhFZ56/7gaE4Nr49nLsQDZyUe+ds+kEhf+YAUolJiYXF8+Q==", "dev": true, "dependencies": { - "colord": "^2.9.1", - "cssnano-utils": "^4.0.1", + "colord": "^2.9.3", + "cssnano-utils": "^4.0.2", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -15192,13 +15204,13 @@ } }, "node_modules/postcss-minify-params": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-6.0.2.tgz", - "integrity": "sha512-zwQtbrPEBDj+ApELZ6QylLf2/c5zmASoOuA4DzolyVGdV38iR2I5QRMsZcHkcdkZzxpN8RS4cN7LPskOkTwTZw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-6.1.0.tgz", + "integrity": "sha512-bmSKnDtyyE8ujHQK0RQJDIKhQ20Jq1LYiez54WiaOoBtcSuflfK3Nm596LvbtlFcpipMjgClQGyGr7GAs+H1uA==", "dev": true, "dependencies": { - "browserslist": "^4.22.2", - "cssnano-utils": "^4.0.1", + "browserslist": "^4.23.0", + "cssnano-utils": "^4.0.2", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -15209,9 +15221,9 @@ } }, "node_modules/postcss-minify-selectors": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-6.0.2.tgz", - "integrity": "sha512-0b+m+w7OAvZejPQdN2GjsXLv5o0jqYHX3aoV0e7RBKPCsB7TYG5KKWBFhGnB/iP3213Ts8c5H4wLPLMm7z28Sg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-6.0.3.tgz", + "integrity": "sha512-IcV7ZQJcaXyhx4UBpWZMsinGs2NmiUC60rJSkyvjPCPqhNjVGsrJUM+QhAtCaikZ0w0/AbZuH4wVvF/YMuMhvA==", "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.15" @@ -15309,9 +15321,9 @@ } }, "node_modules/postcss-normalize-charset": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-6.0.1.tgz", - "integrity": "sha512-aW5LbMNRZ+oDV57PF9K+WI1Z8MPnF+A8qbajg/T8PP126YrGX1f9IQx21GI2OlGz7XFJi/fNi0GTbY948XJtXg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-6.0.2.tgz", + "integrity": "sha512-a8N9czmdnrjPHa3DeFlwqst5eaL5W8jYu3EBbTTkI5FHkfMhFZh1EGbku6jhHhIzTA6tquI2P42NtZ59M/H/kQ==", "dev": true, "engines": { "node": "^14 || ^16 || >=18.0" @@ -15321,9 +15333,9 @@ } }, "node_modules/postcss-normalize-display-values": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-6.0.1.tgz", - "integrity": "sha512-mc3vxp2bEuCb4LgCcmG1y6lKJu1Co8T+rKHrcbShJwUmKJiEl761qb/QQCfFwlrvSeET3jksolCR/RZuMURudw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-6.0.2.tgz", + "integrity": "sha512-8H04Mxsb82ON/aAkPeq8kcBbAtI5Q2a64X/mnRRfPXBq7XeogoQvReqxEfc0B4WPq1KimjezNC8flUtC3Qz6jg==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15336,9 +15348,9 @@ } }, "node_modules/postcss-normalize-positions": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-6.0.1.tgz", - "integrity": "sha512-HRsq8u/0unKNvm0cvwxcOUEcakFXqZ41fv3FOdPn916XFUrympjr+03oaLkuZENz3HE9RrQE9yU0Xv43ThWjQg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-6.0.2.tgz", + "integrity": "sha512-/JFzI441OAB9O7VnLA+RtSNZvQ0NCFZDOtp6QPFo1iIyawyXg0YI3CYM9HBy1WvwCRHnPep/BvI1+dGPKoXx/Q==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15351,9 +15363,9 @@ } }, "node_modules/postcss-normalize-repeat-style": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-6.0.1.tgz", - "integrity": "sha512-Gbb2nmCy6tTiA7Sh2MBs3fj9W8swonk6lw+dFFeQT68B0Pzwp1kvisJQkdV6rbbMSd9brMlS8I8ts52tAGWmGQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-6.0.2.tgz", + "integrity": "sha512-YdCgsfHkJ2jEXwR4RR3Tm/iOxSfdRt7jplS6XRh9Js9PyCR/aka/FCb6TuHT2U8gQubbm/mPmF6L7FY9d79VwQ==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15366,9 +15378,9 @@ } }, "node_modules/postcss-normalize-string": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-6.0.1.tgz", - "integrity": "sha512-5Fhx/+xzALJD9EI26Aq23hXwmv97Zfy2VFrt5PLT8lAhnBIZvmaT5pQk+NuJ/GWj/QWaKSKbnoKDGLbV6qnhXg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-6.0.2.tgz", + "integrity": "sha512-vQZIivlxlfqqMp4L9PZsFE4YUkWniziKjQWUtsxUiVsSSPelQydwS8Wwcuw0+83ZjPWNTl02oxlIvXsmmG+CiQ==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15381,9 +15393,9 @@ } }, "node_modules/postcss-normalize-timing-functions": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-6.0.1.tgz", - "integrity": "sha512-4zcczzHqmCU7L5dqTB9rzeqPWRMc0K2HoR+Bfl+FSMbqGBUcP5LRfgcH4BdRtLuzVQK1/FHdFoGT3F7rkEnY+g==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-6.0.2.tgz", + "integrity": "sha512-a+YrtMox4TBtId/AEwbA03VcJgtyW4dGBizPl7e88cTFULYsprgHWTbfyjSLyHeBcK/Q9JhXkt2ZXiwaVHoMzA==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15396,12 +15408,12 @@ } }, "node_modules/postcss-normalize-unicode": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-6.0.2.tgz", - "integrity": "sha512-Ff2VdAYCTGyMUwpevTZPZ4w0+mPjbZzLLyoLh/RMpqUqeQKZ+xMm31hkxBavDcGKcxm6ACzGk0nBfZ8LZkStKA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-6.1.0.tgz", + "integrity": "sha512-QVC5TQHsVj33otj8/JD869Ndr5Xcc/+fwRh4HAsFsAeygQQXm+0PySrKbr/8tkDKzW+EVT3QkqZMfFrGiossDg==", "dev": true, "dependencies": { - "browserslist": "^4.22.2", + "browserslist": "^4.23.0", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -15412,9 +15424,9 @@ } }, "node_modules/postcss-normalize-url": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-6.0.1.tgz", - "integrity": "sha512-jEXL15tXSvbjm0yzUV7FBiEXwhIa9H88JOXDGQzmcWoB4mSjZIsmtto066s2iW9FYuIrIF4k04HA2BKAOpbsaQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-6.0.2.tgz", + "integrity": "sha512-kVNcWhCeKAzZ8B4pv/DnrU1wNh458zBNp8dh4y5hhxih5RZQ12QWMuQrDgPRw3LRl8mN9vOVfHl7uhvHYMoXsQ==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15427,9 +15439,9 @@ } }, "node_modules/postcss-normalize-whitespace": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-6.0.1.tgz", - "integrity": "sha512-76i3NpWf6bB8UHlVuLRxG4zW2YykF9CTEcq/9LGAiz2qBuX5cBStadkk0jSkg9a9TCIXbMQz7yzrygKoCW9JuA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-6.0.2.tgz", + "integrity": "sha512-sXZ2Nj1icbJOKmdjXVT9pnyHQKiSAyuNQHSgRCUgThn2388Y9cGVDR+E9J9iAYbSbLHI+UUwLVl1Wzco/zgv0Q==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15464,12 +15476,12 @@ } }, "node_modules/postcss-ordered-values": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-6.0.1.tgz", - "integrity": "sha512-XXbb1O/MW9HdEhnBxitZpPFbIvDgbo9NK4c/5bOfiKpnIGZDoL2xd7/e6jW5DYLsWxBbs+1nZEnVgnjnlFViaA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-6.0.2.tgz", + "integrity": "sha512-VRZSOB+JU32RsEAQrO94QPkClGPKJEL/Z9PCBImXMhIeK5KAYo6slP/hBYlLgrCjFxyqvn5VC81tycFEDBLG1Q==", "dev": true, "dependencies": { - "cssnano-utils": "^4.0.1", + "cssnano-utils": "^4.0.2", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -15648,12 +15660,12 @@ } }, "node_modules/postcss-reduce-initial": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-6.0.2.tgz", - "integrity": "sha512-YGKalhNlCLcjcLvjU5nF8FyeCTkCO5UtvJEt0hrPZVCTtRLSOH4z00T1UntQPj4dUmIYZgMj8qK77JbSX95hSw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-6.1.0.tgz", + "integrity": "sha512-RarLgBK/CrL1qZags04oKbVbrrVK2wcxhvta3GCxrZO4zveibqbRPmm2VI8sSgCXwoUHEliRSbOfpR0b/VIoiw==", "dev": true, "dependencies": { - "browserslist": "^4.22.2", + "browserslist": "^4.23.0", "caniuse-api": "^3.0.0" }, "engines": { @@ -15664,9 +15676,9 @@ } }, "node_modules/postcss-reduce-transforms": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-6.0.1.tgz", - "integrity": "sha512-fUbV81OkUe75JM+VYO1gr/IoA2b/dRiH6HvMwhrIBSUrxq3jNZQZitSnugcTLDi1KkQh1eR/zi+iyxviUNBkcQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-6.0.2.tgz", + "integrity": "sha512-sB+Ya++3Xj1WaT9+5LOOdirAxP7dJZms3GRcYheSPi1PiTMigsxHAdkrbItHxwYHr4kt1zL7mmcHstgMYT+aiA==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" @@ -15921,9 +15933,9 @@ } }, "node_modules/postcss-svgo": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-6.0.2.tgz", - "integrity": "sha512-IH5R9SjkTkh0kfFOQDImyy1+mTCb+E830+9SV1O+AaDcoHTvfsvt6WwJeo7KwcHbFnevZVCsXhDmjFiGVuwqFQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-6.0.3.tgz", + "integrity": "sha512-dlrahRmxP22bX6iKEjOM+c8/1p+81asjKT+V5lrgOH944ryx/OHpclnIbGsKVd3uWOXFLYJwCVf0eEkJGvO96g==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0", @@ -15946,9 +15958,9 @@ } }, "node_modules/postcss-unique-selectors": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-6.0.2.tgz", - "integrity": "sha512-8IZGQ94nechdG7Y9Sh9FlIY2b4uS8/k8kdKRX040XHsS3B6d1HrJAkXrBSsSu4SuARruSsUjW3nlSw8BHkaAYQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-6.0.3.tgz", + "integrity": "sha512-NFXbYr8qdmCr/AFceaEfdcsKGCvWTeGO6QVC9h2GvtWgj0/0dklKQcaMMVzs6tr8bY+ase8hOtHW8OBTTRvS8A==", "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.15" @@ -16202,6 +16214,15 @@ "node": ">=0.10.0" } }, + "node_modules/react-blurhash": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/react-blurhash/-/react-blurhash-0.3.0.tgz", + "integrity": "sha512-XlKr4Ns1iYFRnk6DkAblNbAwN/bTJvxTVoxMvmTcURdc5oLoXZwqAF9N3LZUh/HT+QFlq5n6IS6VsDGsviYAiQ==", + "peerDependencies": { + "blurhash": "^2.0.3", + "react": ">=15" + } + }, "node_modules/react-dom": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", @@ -16220,6 +16241,19 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-lazy-load-image-component": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/react-lazy-load-image-component/-/react-lazy-load-image-component-1.6.0.tgz", + "integrity": "sha512-8KFkDTgjh+0+PVbH+cx0AgxLGbdTsxWMnxXzU5HEUztqewk9ufQAu8cstjZhyvtMIPsdMcPZfA0WAa7HtjQbBQ==", + "dependencies": { + "lodash.debounce": "^4.0.8", + "lodash.throttle": "^4.1.1" + }, + "peerDependencies": { + "react": "^15.x.x || ^16.x.x || ^17.x.x || ^18.x.x", + "react-dom": "^15.x.x || ^16.x.x || ^17.x.x || ^18.x.x" + } + }, "node_modules/react-router": { "version": "6.21.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.21.3.tgz", @@ -16857,11 +16891,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rvfc-polyfill": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/rvfc-polyfill/-/rvfc-polyfill-1.0.7.tgz", - "integrity": "sha512-seBl7J1J3/k0LuzW2T9fG6JIOpni5AbU+/87LA+zTYKgTVhsfShmS8K/yOo1eeEjGJHnAdkVAUUM+PEjN9Mpkw==" - }, "node_modules/safe-array-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", @@ -18122,28 +18151,22 @@ } }, "node_modules/strip-literal": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz", - "integrity": "sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.0.0.tgz", + "integrity": "sha512-f9vHgsCWBq2ugHAkGMiiYY+AYG0D/cbloKKg0nhaaaSNsujdGIpVXCNsrJpCKr5M0f4aI31mr13UjY6GAuXCKA==", "dev": true, "dependencies": { - "acorn": "^8.10.0" + "js-tokens": "^8.0.2" }, "funding": { "url": "https://github.com/sponsors/antfu" } }, - "node_modules/strip-literal/node_modules/acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-8.0.3.tgz", + "integrity": "sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==", + "dev": true }, "node_modules/style-loader": { "version": "3.3.4", @@ -18168,12 +18191,12 @@ "dev": true }, "node_modules/stylehacks": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.0.2.tgz", - "integrity": "sha512-00zvJGnCu64EpMjX8b5iCZ3us2Ptyw8+toEkb92VdmkEaRaSGBNKAoK6aWZckhXxmQP8zWiTaFaiMGIU8Ve8sg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.0.tgz", + "integrity": "sha512-ETErsPFgwlfYZ/CSjMO2Ddf+TsnkCVPBPaoB99Ro8WMAxf7cglzmFsRBhRmKObFjibtcvlNxFFPHuyr3sNlNUQ==", "dev": true, "dependencies": { - "browserslist": "^4.22.2", + "browserslist": "^4.23.0", "postcss-selector-parser": "^6.0.15" }, "engines": { @@ -21118,18 +21141,18 @@ "dev": true }, "node_modules/tinypool": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.1.tgz", - "integrity": "sha512-zBTCK0cCgRROxvs9c0CGK838sPkeokNGdQVUUwHAbynHFlmyJYj825f/oRs528HaIJ97lo0pLIlDUzwN+IorWg==", + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.2.tgz", + "integrity": "sha512-SUszKYe5wgsxnNOVlBYO6IC+8VGWdVGZWAqUxp3UErNBtptZvWbwyUOyzNL59zigz2rCA92QiL3wvG+JDSdJdQ==", "dev": true, "engines": { "node": ">=14.0.0" } }, "node_modules/tinyspy": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.0.tgz", - "integrity": "sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", "dev": true, "engines": { "node": ">=14.0.0" @@ -21900,6 +21923,20 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/usehooks-ts": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.14.0.tgz", + "integrity": "sha512-jnhrjTRJoJS7cFxz63tRYc5mzTKf/h+Ii8P0PDHymT9qDe4ZA2/gzDRmDR4WGausg5X8wMIdghwi3BBCN9JKow==", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -22028,9 +22065,9 @@ } }, "node_modules/vite-node": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.2.1.tgz", - "integrity": "sha512-fNzHmQUSOY+y30naohBvSW7pPn/xn3Ib/uqm+5wAJQJiqQsU0NBR78XdRJb04l4bOFKjpTWld0XAfkKlrDbySg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.0.tgz", + "integrity": "sha512-D/oiDVBw75XMnjAXne/4feCkCEwcbr2SU1bjAhCcfI5Bq3VoOHji8/wCPAfUkDIeohJ5nSZ39fNxM3dNZ6OBOA==", "dev": true, "dependencies": { "cac": "^6.7.14", @@ -22050,18 +22087,17 @@ } }, "node_modules/vitest": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.2.1.tgz", - "integrity": "sha512-TRph8N8rnSDa5M2wKWJCMnztCZS9cDcgVTQ6tsTFTG/odHJ4l5yNVqvbeDJYJRZ6is3uxaEpFs8LL6QM+YFSdA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.0.tgz", + "integrity": "sha512-V9qb276J1jjSx9xb75T2VoYXdO1UKi+qfflY7V7w93jzX7oA/+RtYE6TcifxksxsZvygSSMwu2Uw6di7yqDMwg==", "dev": true, "dependencies": { - "@vitest/expect": "1.2.1", - "@vitest/runner": "1.2.1", - "@vitest/snapshot": "1.2.1", - "@vitest/spy": "1.2.1", - "@vitest/utils": "1.2.1", + "@vitest/expect": "1.3.0", + "@vitest/runner": "1.3.0", + "@vitest/snapshot": "1.3.0", + "@vitest/spy": "1.3.0", + "@vitest/utils": "1.3.0", "acorn-walk": "^8.3.2", - "cac": "^6.7.14", "chai": "^4.3.10", "debug": "^4.3.4", "execa": "^8.0.1", @@ -22070,11 +22106,11 @@ "pathe": "^1.1.1", "picocolors": "^1.0.0", "std-env": "^3.5.0", - "strip-literal": "^1.3.0", + "strip-literal": "^2.0.0", "tinybench": "^2.5.1", - "tinypool": "^0.8.1", + "tinypool": "^0.8.2", "vite": "^5.0.0", - "vite-node": "1.2.1", + "vite-node": "1.3.0", "why-is-node-running": "^2.2.2" }, "bin": { @@ -22089,8 +22125,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "^1.0.0", - "@vitest/ui": "^1.0.0", + "@vitest/browser": "1.3.0", + "@vitest/ui": "1.3.0", "happy-dom": "*", "jsdom": "*" }, @@ -22184,9 +22220,9 @@ } }, "node_modules/vitest/node_modules/npm-run-path": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz", - "integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", "dev": true, "dependencies": { "path-key": "^4.0.0" @@ -24352,9 +24388,9 @@ "dev": true }, "@babel/runtime": { - "version": "7.23.8", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.8.tgz", - "integrity": "sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.0.tgz", + "integrity": "sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw==", "requires": { "regenerator-runtime": "^0.14.0" } @@ -24810,9 +24846,9 @@ "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" }, "@emotion/react": { - "version": "11.11.3", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.3.tgz", - "integrity": "sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA==", + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.4.tgz", + "integrity": "sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw==", "requires": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", @@ -25135,28 +25171,28 @@ "dev": true }, "@floating-ui/core": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.3.tgz", - "integrity": "sha512-O0WKDOo0yhJuugCx6trZQj5jVJ9yR0ystG2JaNAemYUWce+pmM6WUEFIibnWyEJKdrDxhm75NoSRME35FNaM/Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", + "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", "requires": { - "@floating-ui/utils": "^0.2.0" + "@floating-ui/utils": "^0.2.1" } }, "@floating-ui/dom": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.4.tgz", - "integrity": "sha512-jByEsHIY+eEdCjnTVu+E3ephzTOzkQ8hgUfGwos+bg7NlH33Zc5uO+QHz1mrQUOgIKKDD1RtS201P9NvAfq3XQ==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", + "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", "requires": { - "@floating-ui/core": "^1.5.3", + "@floating-ui/core": "^1.0.0", "@floating-ui/utils": "^0.2.0" } }, "@floating-ui/react-dom": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.6.tgz", - "integrity": "sha512-IB8aCRFxr8nFkdYZgH+Otd9EVQPJoynxeFRGTB8voPoZMRWo8XjYuCRgpI1btvuKY69XMiLnW+ym7zoBHM90Rw==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.8.tgz", + "integrity": "sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==", "requires": { - "@floating-ui/dom": "^1.5.4" + "@floating-ui/dom": "^1.6.1" } }, "@floating-ui/utils": { @@ -25217,10 +25253,15 @@ "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", "dev": true }, + "@jellyfin/libass-wasm": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@jellyfin/libass-wasm/-/libass-wasm-4.2.1.tgz", + "integrity": "sha512-oWK2yz8fFlMXkIuxUc9g/bqN2h56AB+8b6vF/Ikns6WZ/nmcGJ/5lcVaLI4csE83yWgmco4gHO3HyJDsM9EXcQ==" + }, "@jellyfin/sdk": { - "version": "0.0.0-unstable.202401060501", - "resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202401060501.tgz", - "integrity": "sha512-6+mTkcr62rUqF8BoZS8K2h87fV/JjMYPqZ45faytqecJIv3GMo2cJTtBKR1LrmPuAdKhC+/1ic5E7bxIK+P9gA==", + "version": "0.0.0-unstable.202403240502", + "resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202403240502.tgz", + "integrity": "sha512-EkRMiNpiwK0imPrKCuxPVmGGIZHF9gxId0mZ0BZpnSvY3Y0Gc5r+QB10lYRwKuQLRuHL4zDawsaCfk9BS+JKow==", "requires": {} }, "@jest/schemas": { @@ -25356,46 +25397,46 @@ } }, "@mui/base": { - "version": "5.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.32.tgz", - "integrity": "sha512-4VptvYeLUYMJhZapWBkD50GmKfOc0XT381KJcTK3ncZYIl8MdBhpR6l8jOyeP5cixUPBJhstjrnmQEAHjCLriw==", + "version": "5.0.0-beta.37", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.37.tgz", + "integrity": "sha512-/o3anbb+DeCng8jNsd3704XtmmLDZju1Fo8R2o7ugrVtPQ/QpcqddwKNzKPZwa0J5T8YNW3ZVuHyQgbTnQLisQ==", "requires": { - "@babel/runtime": "^7.23.8", - "@floating-ui/react-dom": "^2.0.5", + "@babel/runtime": "^7.23.9", + "@floating-ui/react-dom": "^2.0.8", "@mui/types": "^7.2.13", - "@mui/utils": "^5.15.5", + "@mui/utils": "^5.15.11", "@popperjs/core": "^2.11.8", "clsx": "^2.1.0", "prop-types": "^15.8.1" } }, "@mui/core-downloads-tracker": { - "version": "5.15.6", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.6.tgz", - "integrity": "sha512-0aoWS4qvk1uzm9JBs83oQmIMIQeTBUeqqu8u+3uo2tMznrB5fIKqQVCbCgq+4Tm4jG+5F7dIvnjvQ2aV7UKtdw==" + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.11.tgz", + "integrity": "sha512-JVrJ9Jo4gyU707ujnRzmE8ABBWpXd6FwL9GYULmwZRtfPg89ggXs/S3MStQkpJ1JRWfdLL6S5syXmgQGq5EDAw==" }, "@mui/icons-material": { - "version": "5.15.5", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.5.tgz", - "integrity": "sha512-qiql0fd1JY7TZ1wm1RldvU7sL8QUatE9OC12i/qm5rnm/caTFyAfOyTIR7qqxorsJvoZGyrzwoMkal6Ij9kM0A==", + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.11.tgz", + "integrity": "sha512-R5ZoQqnKpd+5Ew7mBygTFLxgYsQHPhgR3TDXSgIHYIjGzYuyPLmGLSdcPUoMdi6kxiYqHlpPj4NJxlbaFD0UHA==", "requires": { - "@babel/runtime": "^7.23.8" + "@babel/runtime": "^7.23.9" } }, "@mui/material": { - "version": "5.15.5", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.5.tgz", - "integrity": "sha512-2KfA39f/UWeQl0O22UJs3x1nG3chYlyu9wnux5vTnxUTLzkgYIzQIHaH+ZOGpv5JiZBMKktAPNfhqyhSaQ49qQ==", + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.11.tgz", + "integrity": "sha512-FA3eEuEZaDaxgN3CgfXezMWbCZ4VCeU/sv0F0/PK5n42qIgsPVD6q+j71qS7/62sp6wRFMHtDMpXRlN+tT/7NA==", "requires": { - "@babel/runtime": "^7.23.8", - "@mui/base": "5.0.0-beta.32", - "@mui/core-downloads-tracker": "^5.15.5", - "@mui/system": "^5.15.5", + "@babel/runtime": "^7.23.9", + "@mui/base": "5.0.0-beta.37", + "@mui/core-downloads-tracker": "^5.15.11", + "@mui/system": "^5.15.11", "@mui/types": "^7.2.13", - "@mui/utils": "^5.15.5", + "@mui/utils": "^5.15.11", "@types/react-transition-group": "^4.4.10", "clsx": "^2.1.0", - "csstype": "^3.1.2", + "csstype": "^3.1.3", "prop-types": "^15.8.1", "react-is": "^18.2.0", "react-transition-group": "^4.4.5" @@ -25409,38 +25450,38 @@ } }, "@mui/private-theming": { - "version": "5.15.6", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.6.tgz", - "integrity": "sha512-ZBX9E6VNUSscUOtU8uU462VvpvBS7eFl5VfxAzTRVQBHflzL+5KtnGrebgf6Nd6cdvxa1o0OomiaxSKoN2XDmg==", + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.11.tgz", + "integrity": "sha512-jY/696SnSxSzO1u86Thym7ky5T9CgfidU3NFJjguldqK4f3Z5S97amZ6nffg8gTD0HBjY9scB+4ekqDEUmxZOA==", "requires": { - "@babel/runtime": "^7.23.8", - "@mui/utils": "^5.15.6", + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.15.11", "prop-types": "^15.8.1" } }, "@mui/styled-engine": { - "version": "5.15.6", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.6.tgz", - "integrity": "sha512-KAn8P8xP/WigFKMlEYUpU9z2o7jJnv0BG28Qu1dhNQVutsLVIFdRf5Nb+0ijp2qgtcmygQ0FtfRuXv5LYetZTg==", + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.11.tgz", + "integrity": "sha512-So21AhAngqo07ces4S/JpX5UaMU2RHXpEA6hNzI6IQjd/1usMPxpgK8wkGgTe3JKmC2KDmH8cvoycq5H3Ii7/w==", "requires": { - "@babel/runtime": "^7.23.8", + "@babel/runtime": "^7.23.9", "@emotion/cache": "^11.11.0", - "csstype": "^3.1.2", + "csstype": "^3.1.3", "prop-types": "^15.8.1" } }, "@mui/system": { - "version": "5.15.6", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.6.tgz", - "integrity": "sha512-J01D//u8IfXvaEHMBQX5aO2l7Q+P15nt96c4NskX7yp5/+UuZP8XCQJhtBtLuj+M2LLyXHYGmCPeblsmmscP2Q==", + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.11.tgz", + "integrity": "sha512-9j35suLFq+MgJo5ktVSHPbkjDLRMBCV17NMBdEQurh6oWyGnLM4uhU4QGZZQ75o0vuhjJghOCA1jkO3+79wKsA==", "requires": { - "@babel/runtime": "^7.23.8", - "@mui/private-theming": "^5.15.6", - "@mui/styled-engine": "^5.15.6", + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.15.11", + "@mui/styled-engine": "^5.15.11", "@mui/types": "^7.2.13", - "@mui/utils": "^5.15.6", + "@mui/utils": "^5.15.11", "clsx": "^2.1.0", - "csstype": "^3.1.2", + "csstype": "^3.1.3", "prop-types": "^15.8.1" } }, @@ -25451,11 +25492,11 @@ "requires": {} }, "@mui/utils": { - "version": "5.15.6", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.6.tgz", - "integrity": "sha512-qfEhf+zfU9aQdbzo1qrSWlbPQhH1nCgeYgwhOVnj9Bn39shJQitEnXpSQpSNag8+uty5Od6PxmlNKPTnPySRKA==", + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.11.tgz", + "integrity": "sha512-D6bwqprUa9Stf8ft0dcMqWyWDKEo7D+6pB1k8WajbqlYIRA8J8Kw9Ra7PSZKKePGBGWO+/xxrX1U8HpG/aXQCw==", "requires": { - "@babel/runtime": "^7.23.8", + "@babel/runtime": "^7.23.9", "@types/prop-types": "^15.7.11", "prop-types": "^15.8.1", "react-is": "^18.2.0" @@ -25469,9 +25510,9 @@ } }, "@mui/x-data-grid": { - "version": "6.18.7", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.18.7.tgz", - "integrity": "sha512-K1A3pMUPxI4/Mt5A4vrK45fBBQK5rZvBVqRMrB5n8zX++Bj+WLWKvLTtfCmlriUtzuadr/Hl7Z+FDRXUJAx6qg==", + "version": "6.19.5", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.19.5.tgz", + "integrity": "sha512-jV1ZqwyFslKqFScSn4t+xc/tNxLHOeJjz3HoeK+Wdf5t3bPM69pg/jLeg8TmOkAUY62JmQKCLVmcGWiR3AqUKQ==", "requires": { "@babel/runtime": "^7.23.2", "@mui/utils": "^5.14.16", @@ -25822,9 +25863,9 @@ "dev": true }, "@types/loadable__component": { - "version": "5.13.8", - "resolved": "https://registry.npmjs.org/@types/loadable__component/-/loadable__component-5.13.8.tgz", - "integrity": "sha512-0FF/WihuPkR5IFOHiBzC95bSACvgQNUQCuNy1WF8F/lCBBHgS2SxarIk4CTjWM10A72ovpmXZDRcuAXZNS+/kQ==", + "version": "5.13.9", + "resolved": "https://registry.npmjs.org/@types/loadable__component/-/loadable__component-5.13.9.tgz", + "integrity": "sha512-QWOtIkwZqHNdQj3nixQ8oyihQiTMKZLk/DNuvNxMSbTfxf47w+kqcbnxlUeBgAxdOtW0Dh48dTAIp83iJKtnrQ==", "dev": true, "requires": { "@types/react": "*" @@ -25943,6 +25984,15 @@ "@types/react": "^17" } }, + "@types/react-lazy-load-image-component": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@types/react-lazy-load-image-component/-/react-lazy-load-image-component-1.6.3.tgz", + "integrity": "sha512-HsIsYz7yWWTh/bftdzGnijKD26JyofLRqM/RM80sxs7Gk13G83ew8R/ra2XzXuiZfjNEjAq/Va+NBHFF9ciwxA==", + "requires": { + "@types/react": "*", + "csstype": "^3.0.2" + } + }, "@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -25997,9 +26047,9 @@ } }, "@types/sortablejs": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.7.tgz", - "integrity": "sha512-PvgWCx1Lbgm88FdQ6S7OGvLIjWS66mudKPlfdrWil0TjsO5zmoZmzoKiiwRShs1dwPgrlkr0N4ewuy0/+QUXYQ==", + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.8.tgz", + "integrity": "sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==", "dev": true }, "@types/unist": { @@ -26202,23 +26252,23 @@ "dev": true }, "@vitest/expect": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.2.1.tgz", - "integrity": "sha512-/bqGXcHfyKgFWYwIgFr1QYDaR9e64pRKxgBNWNXPefPFRhgm+K3+a/dS0cUGEreWngets3dlr8w8SBRw2fCfFQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.0.tgz", + "integrity": "sha512-7bWt0vBTZj08B+Ikv70AnLRicohYwFgzNjFqo9SxxqHHxSlUJGSXmCRORhOnRMisiUryKMdvsi1n27Bc6jL9DQ==", "dev": true, "requires": { - "@vitest/spy": "1.2.1", - "@vitest/utils": "1.2.1", + "@vitest/spy": "1.3.0", + "@vitest/utils": "1.3.0", "chai": "^4.3.10" } }, "@vitest/runner": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.2.1.tgz", - "integrity": "sha512-zc2dP5LQpzNzbpaBt7OeYAvmIsRS1KpZQw4G3WM/yqSV1cQKNKwLGmnm79GyZZjMhQGlRcSFMImLjZaUQvNVZQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.0.tgz", + "integrity": "sha512-1Jb15Vo/Oy7mwZ5bXi7zbgszsdIBNjc4IqP8Jpr/8RdBC4nF1CTzIAn2dxYvpF1nGSseeL39lfLQ2uvs5u1Y9A==", "dev": true, "requires": { - "@vitest/utils": "1.2.1", + "@vitest/utils": "1.3.0", "p-limit": "^5.0.0", "pathe": "^1.1.1" }, @@ -26241,9 +26291,9 @@ } }, "@vitest/snapshot": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.2.1.tgz", - "integrity": "sha512-Tmp/IcYEemKaqAYCS08sh0vORLJkMr0NRV76Gl8sHGxXT5151cITJCET20063wk0Yr/1koQ6dnmP6eEqezmd/Q==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.0.tgz", + "integrity": "sha512-swmktcviVVPYx9U4SEQXLV6AEY51Y6bZ14jA2yo6TgMxQ3h+ZYiO0YhAHGJNp0ohCFbPAis1R9kK0cvN6lDPQA==", "dev": true, "requires": { "magic-string": "^0.30.5", @@ -26252,18 +26302,18 @@ } }, "@vitest/spy": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.2.1.tgz", - "integrity": "sha512-vG3a/b7INKH7L49Lbp0IWrG6sw9j4waWAucwnksPB1r1FTJgV7nkBByd9ufzu6VWya/QTvQW4V9FShZbZIB2UQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.0.tgz", + "integrity": "sha512-AkCU0ThZunMvblDpPKgjIi025UxR8V7MZ/g/EwmAGpjIujLVV2X6rGYGmxE2D4FJbAy0/ijdROHMWa2M/6JVMw==", "dev": true, "requires": { "tinyspy": "^2.2.0" } }, "@vitest/utils": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.2.1.tgz", - "integrity": "sha512-bsH6WVZYe/J2v3+81M5LDU8kW76xWObKIURpPrOXm2pjBniBu2MERI/XP60GpS4PHU3jyK50LUutOwrx4CyHUg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.0.tgz", + "integrity": "sha512-/LibEY/fkaXQufi4GDlQZhikQsPO2entBKtfuyIpr1jV4DpaeasqkeHjhdOhU24vSHshcSuEyVlWdzvv2XmYCw==", "dev": true, "requires": { "diff-sequences": "^29.6.3", @@ -27183,13 +27233,13 @@ } }, "browserslist": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", - "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", + "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", "dev": true, "requires": { - "caniuse-lite": "^1.0.30001565", - "electron-to-chromium": "^1.4.601", + "caniuse-lite": "^1.0.30001587", + "electron-to-chromium": "^1.4.668", "node-releases": "^2.0.14", "update-browserslist-db": "^1.0.13" }, @@ -27333,9 +27383,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001579", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001579.tgz", - "integrity": "sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==", + "version": "1.0.30001596", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001596.tgz", + "integrity": "sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ==", "dev": true }, "canvas": { @@ -27981,56 +28031,57 @@ "dev": true }, "cssnano": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.0.3.tgz", - "integrity": "sha512-MRq4CIj8pnyZpcI2qs6wswoYoDD1t0aL28n+41c1Ukcpm56m1h6mCexIHBGjfZfnTqtGSSCP4/fB1ovxgjBOiw==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.0.5.tgz", + "integrity": "sha512-tpTp/ukgrElwu3ESFY4IvWnGn8eTt8cJhC2aAbtA3lvUlxp6t6UPv8YCLjNnEGiFreT1O0LiOM1U3QyTBVFl2A==", "dev": true, "requires": { - "cssnano-preset-default": "^6.0.3", - "lilconfig": "^3.0.0" + "cssnano-preset-default": "^6.0.5", + "lilconfig": "^3.1.1" } }, "cssnano-preset-default": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.0.3.tgz", - "integrity": "sha512-4y3H370aZCkT9Ev8P4SO4bZbt+AExeKhh8wTbms/X7OLDo5E7AYUUy6YPxa/uF5Grf+AJwNcCnxKhZynJ6luBA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.1.0.tgz", + "integrity": "sha512-4DUXZoDj+PI3fRl3MqMjl9DwLGjcsFP4qt+92nLUcN1RGfw2TY+GwNoG2B38Usu1BrcTs8j9pxNfSusmvtSjfg==", "dev": true, "requires": { + "browserslist": "^4.23.0", "css-declaration-sorter": "^7.1.1", - "cssnano-utils": "^4.0.1", + "cssnano-utils": "^4.0.2", "postcss-calc": "^9.0.1", - "postcss-colormin": "^6.0.2", - "postcss-convert-values": "^6.0.2", - "postcss-discard-comments": "^6.0.1", - "postcss-discard-duplicates": "^6.0.1", - "postcss-discard-empty": "^6.0.1", - "postcss-discard-overridden": "^6.0.1", - "postcss-merge-longhand": "^6.0.2", - "postcss-merge-rules": "^6.0.3", - "postcss-minify-font-values": "^6.0.1", - "postcss-minify-gradients": "^6.0.1", - "postcss-minify-params": "^6.0.2", - "postcss-minify-selectors": "^6.0.2", - "postcss-normalize-charset": "^6.0.1", - "postcss-normalize-display-values": "^6.0.1", - "postcss-normalize-positions": "^6.0.1", - "postcss-normalize-repeat-style": "^6.0.1", - "postcss-normalize-string": "^6.0.1", - "postcss-normalize-timing-functions": "^6.0.1", - "postcss-normalize-unicode": "^6.0.2", - "postcss-normalize-url": "^6.0.1", - "postcss-normalize-whitespace": "^6.0.1", - "postcss-ordered-values": "^6.0.1", - "postcss-reduce-initial": "^6.0.2", - "postcss-reduce-transforms": "^6.0.1", - "postcss-svgo": "^6.0.2", - "postcss-unique-selectors": "^6.0.2" + "postcss-colormin": "^6.1.0", + "postcss-convert-values": "^6.1.0", + "postcss-discard-comments": "^6.0.2", + "postcss-discard-duplicates": "^6.0.3", + "postcss-discard-empty": "^6.0.3", + "postcss-discard-overridden": "^6.0.2", + "postcss-merge-longhand": "^6.0.4", + "postcss-merge-rules": "^6.1.0", + "postcss-minify-font-values": "^6.0.3", + "postcss-minify-gradients": "^6.0.3", + "postcss-minify-params": "^6.1.0", + "postcss-minify-selectors": "^6.0.3", + "postcss-normalize-charset": "^6.0.2", + "postcss-normalize-display-values": "^6.0.2", + "postcss-normalize-positions": "^6.0.2", + "postcss-normalize-repeat-style": "^6.0.2", + "postcss-normalize-string": "^6.0.2", + "postcss-normalize-timing-functions": "^6.0.2", + "postcss-normalize-unicode": "^6.1.0", + "postcss-normalize-url": "^6.0.2", + "postcss-normalize-whitespace": "^6.0.2", + "postcss-ordered-values": "^6.0.2", + "postcss-reduce-initial": "^6.1.0", + "postcss-reduce-transforms": "^6.0.2", + "postcss-svgo": "^6.0.3", + "postcss-unique-selectors": "^6.0.3" } }, "cssnano-utils": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-4.0.1.tgz", - "integrity": "sha512-6qQuYDqsGoiXssZ3zct6dcMxiqfT6epy7x4R0TQJadd4LWO3sPR6JH6ZByOvVLoZ6EdwPGgd7+DR1EmX3tiXQQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-4.0.2.tgz", + "integrity": "sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ==", "dev": true, "requires": {} }, @@ -28071,9 +28122,9 @@ } }, "csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "currently-unhandled": { "version": "0.4.1", @@ -28468,9 +28519,9 @@ "dev": true }, "electron-to-chromium": { - "version": "1.4.623", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.623.tgz", - "integrity": "sha512-lKoz10iCYlP1WtRYdh5MvocQPWVRoI7ysp6qf18bmeBgR8abE6+I2CsfyNKztRDZvhdWc+krKT6wS7Neg8sw3A==", + "version": "1.4.697", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.697.tgz", + "integrity": "sha512-iPS+iUNUrqTkPRFjMYv1FGXIUYhj2K4rc/93nrDsDtQGMUqyRouCq/xABOSOljKbriEiwg0bEQHGaeD4OaU56g==", "dev": true }, "emoji-regex": { @@ -29359,10 +29410,6 @@ "es5-ext": "~0.10.14" } }, - "event-target-polyfill": { - "version": "git+ssh://git@github.com/ThaUnknown/event-target-polyfill.git#5cb9a0ed6774af1b905b525964316911375726a7", - "from": "event-target-polyfill@github:ThaUnknown/event-target-polyfill" - }, "eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -30586,9 +30633,9 @@ } }, "hls.js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.1.tgz", - "integrity": "sha512-SsUSlpyjOGnwBhVrVEG6vRFPU2SAJ0gUqrFdGeo7YPbOC0vuWK0TDMyp7n3QiaBC/Wkic771uqPnnVdT8/x+3Q==" + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.7.tgz", + "integrity": "sha512-Hnyf7ojTBtXHeOW1/t6wCBJSiK1WpoKF9yg7juxldDx8u3iswrkPt2wbOA/1NiwU4j27DSIVoIEJRAhcdMef/A==" }, "hoist-non-react-statics": { "version": "3.3.2", @@ -31488,14 +31535,6 @@ "set-function-name": "^2.0.1" } }, - "jassub": { - "version": "1.7.15", - "resolved": "https://registry.npmjs.org/jassub/-/jassub-1.7.15.tgz", - "integrity": "sha512-8yKAJc++Y1gNfATOPRo3APk0JUhshKl5l7bRkT6WkJ8XP4RvYfVPb6ieH6WDxsMq523exwGzNvjjPEEWT+Z1nQ==", - "requires": { - "rvfc-polyfill": "^1.0.7" - } - }, "jellyfin-apiclient": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/jellyfin-apiclient/-/jellyfin-apiclient-1.11.0.tgz", @@ -31791,9 +31830,9 @@ } }, "lilconfig": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.0.0.tgz", - "integrity": "sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", + "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", "dev": true }, "lines-and-columns": { @@ -31866,8 +31905,7 @@ "lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, "lodash.memoize": { "version": "4.1.2", @@ -31881,6 +31919,11 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==" + }, "lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", @@ -33111,24 +33154,24 @@ } }, "postcss-colormin": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-6.0.2.tgz", - "integrity": "sha512-TXKOxs9LWcdYo5cgmcSHPkyrLAh86hX1ijmyy6J8SbOhyv6ua053M3ZAM/0j44UsnQNIWdl8gb5L7xX2htKeLw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-6.1.0.tgz", + "integrity": "sha512-x9yX7DOxeMAR+BgGVnNSAxmAj98NX/YxEMNFP+SDCEeNLb2r3i6Hh1ksMsnW8Ub5SLCpbescQqn9YEbE9554Sw==", "dev": true, "requires": { - "browserslist": "^4.22.2", + "browserslist": "^4.23.0", "caniuse-api": "^3.0.0", - "colord": "^2.9.1", + "colord": "^2.9.3", "postcss-value-parser": "^4.2.0" } }, "postcss-convert-values": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-6.0.2.tgz", - "integrity": "sha512-aeBmaTnGQ+NUSVQT8aY0sKyAD/BaLJenEKZ03YK0JnDE1w1Rr8XShoxdal2V2H26xTJKr3v5haByOhJuyT4UYw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-6.1.0.tgz", + "integrity": "sha512-zx8IwP/ts9WvUM6NkVSkiU902QZL1bwPhaVaLynPtCsOTqp+ZKbNi+s6XJg3rfqpKGA/oc7Oxk5t8pOQJcwl/w==", "dev": true, "requires": { - "browserslist": "^4.22.2", + "browserslist": "^4.23.0", "postcss-value-parser": "^4.2.0" } }, @@ -33178,30 +33221,30 @@ } }, "postcss-discard-comments": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-6.0.1.tgz", - "integrity": "sha512-f1KYNPtqYLUeZGCHQPKzzFtsHaRuECe6jLakf/RjSRqvF5XHLZnM2+fXLhb8Qh/HBFHs3M4cSLb1k3B899RYIg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-6.0.2.tgz", + "integrity": "sha512-65w/uIqhSBBfQmYnG92FO1mWZjJ4GL5b8atm5Yw2UgrwD7HiNiSSNwJor1eCFGzUgYnN/iIknhNRVqjrrpuglw==", "dev": true, "requires": {} }, "postcss-discard-duplicates": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-6.0.1.tgz", - "integrity": "sha512-1hvUs76HLYR8zkScbwyJ8oJEugfPV+WchpnA+26fpJ7Smzs51CzGBHC32RS03psuX/2l0l0UKh2StzNxOrKCYg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-6.0.3.tgz", + "integrity": "sha512-+JA0DCvc5XvFAxwx6f/e68gQu/7Z9ud584VLmcgto28eB8FqSFZwtrLwB5Kcp70eIoWP/HXqz4wpo8rD8gpsTw==", "dev": true, "requires": {} }, "postcss-discard-empty": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-6.0.1.tgz", - "integrity": "sha512-yitcmKwmVWtNsrrRqGJ7/C0YRy53i0mjexBDQ9zYxDwTWVBgbU4+C9jIZLmQlTDT9zhml+u0OMFJh8+31krmOg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-6.0.3.tgz", + "integrity": "sha512-znyno9cHKQsK6PtxL5D19Fj9uwSzC2mB74cpT66fhgOadEUPyXFkbgwm5tvc3bt3NAy8ltE5MrghxovZRVnOjQ==", "dev": true, "requires": {} }, "postcss-discard-overridden": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-6.0.1.tgz", - "integrity": "sha512-qs0ehZMMZpSESbRkw1+inkf51kak6OOzNRaoLd/U7Fatp0aN2HQ1rxGOrJvYcRAN9VpX8kUF13R2ofn8OlvFVA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-6.0.2.tgz", + "integrity": "sha512-j87xzI4LUggC5zND7KdjsI25APtyMuynXZSujByMaav2roV6OZX+8AaCUcZSWqckZpjAjRyFDdpqybgjFO0HJQ==", "dev": true, "requires": {} }, @@ -33598,62 +33641,62 @@ "dev": true }, "postcss-merge-longhand": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-6.0.2.tgz", - "integrity": "sha512-+yfVB7gEM8SrCo9w2lCApKIEzrTKl5yS1F4yGhV3kSim6JzbfLGJyhR1B6X+6vOT0U33Mgx7iv4X9MVWuaSAfw==", + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-6.0.4.tgz", + "integrity": "sha512-vAfWGcxUUGlFiPM3nDMZA+/Yo9sbpc3JNkcYZez8FfJDv41Dh7tAgA3QGVTocaHCZZL6aXPXPOaBMJsjujodsA==", "dev": true, "requires": { "postcss-value-parser": "^4.2.0", - "stylehacks": "^6.0.2" + "stylehacks": "^6.1.0" } }, "postcss-merge-rules": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-6.0.3.tgz", - "integrity": "sha512-yfkDqSHGohy8sGYIJwBmIGDv4K4/WrJPX355XrxQb/CSsT4Kc/RxDi6akqn5s9bap85AWgv21ArcUWwWdGNSHA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-6.1.0.tgz", + "integrity": "sha512-lER+W3Gr6XOvxOYk1Vi/6UsAgKMg6MDBthmvbNqi2XxAk/r9XfhdYZSigfWjuWWn3zYw2wLelvtM8XuAEFqRkA==", "dev": true, "requires": { - "browserslist": "^4.22.2", + "browserslist": "^4.23.0", "caniuse-api": "^3.0.0", - "cssnano-utils": "^4.0.1", + "cssnano-utils": "^4.0.2", "postcss-selector-parser": "^6.0.15" } }, "postcss-minify-font-values": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.0.1.tgz", - "integrity": "sha512-tIwmF1zUPoN6xOtA/2FgVk1ZKrLcCvE0dpZLtzyyte0j9zUeB8RTbCqrHZGjJlxOvNWKMYtunLrrl7HPOiR46w==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.0.3.tgz", + "integrity": "sha512-SmAeTA1We5rMnN3F8X9YBNo9bj9xB4KyDHnaNJnBfQIPi+60fNiR9OTRnIaMqkYzAQX0vObIw4Pn0vuKEOettg==", "dev": true, "requires": { "postcss-value-parser": "^4.2.0" } }, "postcss-minify-gradients": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-6.0.1.tgz", - "integrity": "sha512-M1RJWVjd6IOLPl1hYiOd5HQHgpp6cvJVLrieQYS9y07Yo8itAr6jaekzJphaJFR0tcg4kRewCk3kna9uHBxn/w==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-6.0.3.tgz", + "integrity": "sha512-4KXAHrYlzF0Rr7uc4VrfwDJ2ajrtNEpNEuLxFgwkhFZ56/7gaE4Nr49nLsQDZyUe+ds+kEhf+YAUolJiYXF8+Q==", "dev": true, "requires": { - "colord": "^2.9.1", - "cssnano-utils": "^4.0.1", + "colord": "^2.9.3", + "cssnano-utils": "^4.0.2", "postcss-value-parser": "^4.2.0" } }, "postcss-minify-params": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-6.0.2.tgz", - "integrity": "sha512-zwQtbrPEBDj+ApELZ6QylLf2/c5zmASoOuA4DzolyVGdV38iR2I5QRMsZcHkcdkZzxpN8RS4cN7LPskOkTwTZw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-6.1.0.tgz", + "integrity": "sha512-bmSKnDtyyE8ujHQK0RQJDIKhQ20Jq1LYiez54WiaOoBtcSuflfK3Nm596LvbtlFcpipMjgClQGyGr7GAs+H1uA==", "dev": true, "requires": { - "browserslist": "^4.22.2", - "cssnano-utils": "^4.0.1", + "browserslist": "^4.23.0", + "cssnano-utils": "^4.0.2", "postcss-value-parser": "^4.2.0" } }, "postcss-minify-selectors": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-6.0.2.tgz", - "integrity": "sha512-0b+m+w7OAvZejPQdN2GjsXLv5o0jqYHX3aoV0e7RBKPCsB7TYG5KKWBFhGnB/iP3213Ts8c5H4wLPLMm7z28Sg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-6.0.3.tgz", + "integrity": "sha512-IcV7ZQJcaXyhx4UBpWZMsinGs2NmiUC60rJSkyvjPCPqhNjVGsrJUM+QhAtCaikZ0w0/AbZuH4wVvF/YMuMhvA==", "dev": true, "requires": { "postcss-selector-parser": "^6.0.15" @@ -33706,80 +33749,80 @@ } }, "postcss-normalize-charset": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-6.0.1.tgz", - "integrity": "sha512-aW5LbMNRZ+oDV57PF9K+WI1Z8MPnF+A8qbajg/T8PP126YrGX1f9IQx21GI2OlGz7XFJi/fNi0GTbY948XJtXg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-6.0.2.tgz", + "integrity": "sha512-a8N9czmdnrjPHa3DeFlwqst5eaL5W8jYu3EBbTTkI5FHkfMhFZh1EGbku6jhHhIzTA6tquI2P42NtZ59M/H/kQ==", "dev": true, "requires": {} }, "postcss-normalize-display-values": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-6.0.1.tgz", - "integrity": "sha512-mc3vxp2bEuCb4LgCcmG1y6lKJu1Co8T+rKHrcbShJwUmKJiEl761qb/QQCfFwlrvSeET3jksolCR/RZuMURudw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-6.0.2.tgz", + "integrity": "sha512-8H04Mxsb82ON/aAkPeq8kcBbAtI5Q2a64X/mnRRfPXBq7XeogoQvReqxEfc0B4WPq1KimjezNC8flUtC3Qz6jg==", "dev": true, "requires": { "postcss-value-parser": "^4.2.0" } }, "postcss-normalize-positions": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-6.0.1.tgz", - "integrity": "sha512-HRsq8u/0unKNvm0cvwxcOUEcakFXqZ41fv3FOdPn916XFUrympjr+03oaLkuZENz3HE9RrQE9yU0Xv43ThWjQg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-6.0.2.tgz", + "integrity": "sha512-/JFzI441OAB9O7VnLA+RtSNZvQ0NCFZDOtp6QPFo1iIyawyXg0YI3CYM9HBy1WvwCRHnPep/BvI1+dGPKoXx/Q==", "dev": true, "requires": { "postcss-value-parser": "^4.2.0" } }, "postcss-normalize-repeat-style": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-6.0.1.tgz", - "integrity": "sha512-Gbb2nmCy6tTiA7Sh2MBs3fj9W8swonk6lw+dFFeQT68B0Pzwp1kvisJQkdV6rbbMSd9brMlS8I8ts52tAGWmGQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-6.0.2.tgz", + "integrity": "sha512-YdCgsfHkJ2jEXwR4RR3Tm/iOxSfdRt7jplS6XRh9Js9PyCR/aka/FCb6TuHT2U8gQubbm/mPmF6L7FY9d79VwQ==", "dev": true, "requires": { "postcss-value-parser": "^4.2.0" } }, "postcss-normalize-string": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-6.0.1.tgz", - "integrity": "sha512-5Fhx/+xzALJD9EI26Aq23hXwmv97Zfy2VFrt5PLT8lAhnBIZvmaT5pQk+NuJ/GWj/QWaKSKbnoKDGLbV6qnhXg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-6.0.2.tgz", + "integrity": "sha512-vQZIivlxlfqqMp4L9PZsFE4YUkWniziKjQWUtsxUiVsSSPelQydwS8Wwcuw0+83ZjPWNTl02oxlIvXsmmG+CiQ==", "dev": true, "requires": { "postcss-value-parser": "^4.2.0" } }, "postcss-normalize-timing-functions": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-6.0.1.tgz", - "integrity": "sha512-4zcczzHqmCU7L5dqTB9rzeqPWRMc0K2HoR+Bfl+FSMbqGBUcP5LRfgcH4BdRtLuzVQK1/FHdFoGT3F7rkEnY+g==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-6.0.2.tgz", + "integrity": "sha512-a+YrtMox4TBtId/AEwbA03VcJgtyW4dGBizPl7e88cTFULYsprgHWTbfyjSLyHeBcK/Q9JhXkt2ZXiwaVHoMzA==", "dev": true, "requires": { "postcss-value-parser": "^4.2.0" } }, "postcss-normalize-unicode": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-6.0.2.tgz", - "integrity": "sha512-Ff2VdAYCTGyMUwpevTZPZ4w0+mPjbZzLLyoLh/RMpqUqeQKZ+xMm31hkxBavDcGKcxm6ACzGk0nBfZ8LZkStKA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-6.1.0.tgz", + "integrity": "sha512-QVC5TQHsVj33otj8/JD869Ndr5Xcc/+fwRh4HAsFsAeygQQXm+0PySrKbr/8tkDKzW+EVT3QkqZMfFrGiossDg==", "dev": true, "requires": { - "browserslist": "^4.22.2", + "browserslist": "^4.23.0", "postcss-value-parser": "^4.2.0" } }, "postcss-normalize-url": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-6.0.1.tgz", - "integrity": "sha512-jEXL15tXSvbjm0yzUV7FBiEXwhIa9H88JOXDGQzmcWoB4mSjZIsmtto066s2iW9FYuIrIF4k04HA2BKAOpbsaQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-6.0.2.tgz", + "integrity": "sha512-kVNcWhCeKAzZ8B4pv/DnrU1wNh458zBNp8dh4y5hhxih5RZQ12QWMuQrDgPRw3LRl8mN9vOVfHl7uhvHYMoXsQ==", "dev": true, "requires": { "postcss-value-parser": "^4.2.0" } }, "postcss-normalize-whitespace": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-6.0.1.tgz", - "integrity": "sha512-76i3NpWf6bB8UHlVuLRxG4zW2YykF9CTEcq/9LGAiz2qBuX5cBStadkk0jSkg9a9TCIXbMQz7yzrygKoCW9JuA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-6.0.2.tgz", + "integrity": "sha512-sXZ2Nj1icbJOKmdjXVT9pnyHQKiSAyuNQHSgRCUgThn2388Y9cGVDR+E9J9iAYbSbLHI+UUwLVl1Wzco/zgv0Q==", "dev": true, "requires": { "postcss-value-parser": "^4.2.0" @@ -33793,12 +33836,12 @@ "requires": {} }, "postcss-ordered-values": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-6.0.1.tgz", - "integrity": "sha512-XXbb1O/MW9HdEhnBxitZpPFbIvDgbo9NK4c/5bOfiKpnIGZDoL2xd7/e6jW5DYLsWxBbs+1nZEnVgnjnlFViaA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-6.0.2.tgz", + "integrity": "sha512-VRZSOB+JU32RsEAQrO94QPkClGPKJEL/Z9PCBImXMhIeK5KAYo6slP/hBYlLgrCjFxyqvn5VC81tycFEDBLG1Q==", "dev": true, "requires": { - "cssnano-utils": "^4.0.1", + "cssnano-utils": "^4.0.2", "postcss-value-parser": "^4.2.0" } }, @@ -33905,19 +33948,19 @@ } }, "postcss-reduce-initial": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-6.0.2.tgz", - "integrity": "sha512-YGKalhNlCLcjcLvjU5nF8FyeCTkCO5UtvJEt0hrPZVCTtRLSOH4z00T1UntQPj4dUmIYZgMj8qK77JbSX95hSw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-6.1.0.tgz", + "integrity": "sha512-RarLgBK/CrL1qZags04oKbVbrrVK2wcxhvta3GCxrZO4zveibqbRPmm2VI8sSgCXwoUHEliRSbOfpR0b/VIoiw==", "dev": true, "requires": { - "browserslist": "^4.22.2", + "browserslist": "^4.23.0", "caniuse-api": "^3.0.0" } }, "postcss-reduce-transforms": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-6.0.1.tgz", - "integrity": "sha512-fUbV81OkUe75JM+VYO1gr/IoA2b/dRiH6HvMwhrIBSUrxq3jNZQZitSnugcTLDi1KkQh1eR/zi+iyxviUNBkcQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-6.0.2.tgz", + "integrity": "sha512-sB+Ya++3Xj1WaT9+5LOOdirAxP7dJZms3GRcYheSPi1PiTMigsxHAdkrbItHxwYHr4kt1zL7mmcHstgMYT+aiA==", "dev": true, "requires": { "postcss-value-parser": "^4.2.0" @@ -34087,9 +34130,9 @@ } }, "postcss-svgo": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-6.0.2.tgz", - "integrity": "sha512-IH5R9SjkTkh0kfFOQDImyy1+mTCb+E830+9SV1O+AaDcoHTvfsvt6WwJeo7KwcHbFnevZVCsXhDmjFiGVuwqFQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-6.0.3.tgz", + "integrity": "sha512-dlrahRmxP22bX6iKEjOM+c8/1p+81asjKT+V5lrgOH944ryx/OHpclnIbGsKVd3uWOXFLYJwCVf0eEkJGvO96g==", "dev": true, "requires": { "postcss-value-parser": "^4.2.0", @@ -34104,9 +34147,9 @@ "requires": {} }, "postcss-unique-selectors": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-6.0.2.tgz", - "integrity": "sha512-8IZGQ94nechdG7Y9Sh9FlIY2b4uS8/k8kdKRX040XHsS3B6d1HrJAkXrBSsSu4SuARruSsUjW3nlSw8BHkaAYQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-6.0.3.tgz", + "integrity": "sha512-NFXbYr8qdmCr/AFceaEfdcsKGCvWTeGO6QVC9h2GvtWgj0/0dklKQcaMMVzs6tr8bY+ase8hOtHW8OBTTRvS8A==", "dev": true, "requires": { "postcss-selector-parser": "^6.0.15" @@ -34295,6 +34338,12 @@ "object-assign": "^4.1.1" } }, + "react-blurhash": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/react-blurhash/-/react-blurhash-0.3.0.tgz", + "integrity": "sha512-XlKr4Ns1iYFRnk6DkAblNbAwN/bTJvxTVoxMvmTcURdc5oLoXZwqAF9N3LZUh/HT+QFlq5n6IS6VsDGsviYAiQ==", + "requires": {} + }, "react-dom": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", @@ -34310,6 +34359,15 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "react-lazy-load-image-component": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/react-lazy-load-image-component/-/react-lazy-load-image-component-1.6.0.tgz", + "integrity": "sha512-8KFkDTgjh+0+PVbH+cx0AgxLGbdTsxWMnxXzU5HEUztqewk9ufQAu8cstjZhyvtMIPsdMcPZfA0WAa7HtjQbBQ==", + "requires": { + "lodash.debounce": "^4.0.8", + "lodash.throttle": "^4.1.1" + } + }, "react-router": { "version": "6.21.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.21.3.tgz", @@ -34769,11 +34827,6 @@ "queue-microtask": "^1.2.2" } }, - "rvfc-polyfill": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/rvfc-polyfill/-/rvfc-polyfill-1.0.7.tgz", - "integrity": "sha512-seBl7J1J3/k0LuzW2T9fG6JIOpni5AbU+/87LA+zTYKgTVhsfShmS8K/yOo1eeEjGJHnAdkVAUUM+PEjN9Mpkw==" - }, "safe-array-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", @@ -35769,18 +35822,18 @@ "dev": true }, "strip-literal": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz", - "integrity": "sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.0.0.tgz", + "integrity": "sha512-f9vHgsCWBq2ugHAkGMiiYY+AYG0D/cbloKKg0nhaaaSNsujdGIpVXCNsrJpCKr5M0f4aI31mr13UjY6GAuXCKA==", "dev": true, "requires": { - "acorn": "^8.10.0" + "js-tokens": "^8.0.2" }, "dependencies": { - "acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "js-tokens": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-8.0.3.tgz", + "integrity": "sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==", "dev": true } } @@ -35799,12 +35852,12 @@ "dev": true }, "stylehacks": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.0.2.tgz", - "integrity": "sha512-00zvJGnCu64EpMjX8b5iCZ3us2Ptyw8+toEkb92VdmkEaRaSGBNKAoK6aWZckhXxmQP8zWiTaFaiMGIU8Ve8sg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.0.tgz", + "integrity": "sha512-ETErsPFgwlfYZ/CSjMO2Ddf+TsnkCVPBPaoB99Ro8WMAxf7cglzmFsRBhRmKObFjibtcvlNxFFPHuyr3sNlNUQ==", "dev": true, "requires": { - "browserslist": "^4.22.2", + "browserslist": "^4.23.0", "postcss-selector-parser": "^6.0.15" } }, @@ -38086,15 +38139,15 @@ "dev": true }, "tinypool": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.1.tgz", - "integrity": "sha512-zBTCK0cCgRROxvs9c0CGK838sPkeokNGdQVUUwHAbynHFlmyJYj825f/oRs528HaIJ97lo0pLIlDUzwN+IorWg==", + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.2.tgz", + "integrity": "sha512-SUszKYe5wgsxnNOVlBYO6IC+8VGWdVGZWAqUxp3UErNBtptZvWbwyUOyzNL59zigz2rCA92QiL3wvG+JDSdJdQ==", "dev": true }, "tinyspy": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.0.tgz", - "integrity": "sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", "dev": true }, "to-fast-properties": { @@ -38668,6 +38721,14 @@ "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", "requires": {} }, + "usehooks-ts": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.14.0.tgz", + "integrity": "sha512-jnhrjTRJoJS7cFxz63tRYc5mzTKf/h+Ii8P0PDHymT9qDe4ZA2/gzDRmDR4WGausg5X8wMIdghwi3BBCN9JKow==", + "requires": { + "lodash.debounce": "^4.0.8" + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -38736,9 +38797,9 @@ } }, "vite-node": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.2.1.tgz", - "integrity": "sha512-fNzHmQUSOY+y30naohBvSW7pPn/xn3Ib/uqm+5wAJQJiqQsU0NBR78XdRJb04l4bOFKjpTWld0XAfkKlrDbySg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.0.tgz", + "integrity": "sha512-D/oiDVBw75XMnjAXne/4feCkCEwcbr2SU1bjAhCcfI5Bq3VoOHji8/wCPAfUkDIeohJ5nSZ39fNxM3dNZ6OBOA==", "dev": true, "requires": { "cac": "^6.7.14", @@ -38749,18 +38810,17 @@ } }, "vitest": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.2.1.tgz", - "integrity": "sha512-TRph8N8rnSDa5M2wKWJCMnztCZS9cDcgVTQ6tsTFTG/odHJ4l5yNVqvbeDJYJRZ6is3uxaEpFs8LL6QM+YFSdA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.0.tgz", + "integrity": "sha512-V9qb276J1jjSx9xb75T2VoYXdO1UKi+qfflY7V7w93jzX7oA/+RtYE6TcifxksxsZvygSSMwu2Uw6di7yqDMwg==", "dev": true, "requires": { - "@vitest/expect": "1.2.1", - "@vitest/runner": "1.2.1", - "@vitest/snapshot": "1.2.1", - "@vitest/spy": "1.2.1", - "@vitest/utils": "1.2.1", + "@vitest/expect": "1.3.0", + "@vitest/runner": "1.3.0", + "@vitest/snapshot": "1.3.0", + "@vitest/spy": "1.3.0", + "@vitest/utils": "1.3.0", "acorn-walk": "^8.3.2", - "cac": "^6.7.14", "chai": "^4.3.10", "debug": "^4.3.4", "execa": "^8.0.1", @@ -38769,11 +38829,11 @@ "pathe": "^1.1.1", "picocolors": "^1.0.0", "std-env": "^3.5.0", - "strip-literal": "^1.3.0", + "strip-literal": "^2.0.0", "tinybench": "^2.5.1", - "tinypool": "^0.8.1", + "tinypool": "^0.8.2", "vite": "^5.0.0", - "vite-node": "1.2.1", + "vite-node": "1.3.0", "why-is-node-running": "^2.2.2" }, "dependencies": { @@ -38819,9 +38879,9 @@ "dev": true }, "npm-run-path": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz", - "integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", "dev": true, "requires": { "path-key": "^4.0.0" diff --git a/package.json b/package.json index 91b87c28f3..348ea6a6d5 100644 --- a/package.json +++ b/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 \"./\"", diff --git a/src/apps/dashboard/components/drawer/sections/DevicesDrawerSection.tsx b/src/apps/dashboard/components/drawer/sections/DevicesDrawerSection.tsx index 6cc7ab79fc..6d788f6b40 100644 --- a/src/apps/dashboard/components/drawer/sections/DevicesDrawerSection.tsx +++ b/src/apps/dashboard/components/drawer/sections/DevicesDrawerSection.tsx @@ -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 ( { - + - {isDlnaSectionOpen ? : } - - - - - - - - - - ); }; diff --git a/src/apps/dashboard/components/drawer/sections/ServerDrawerSection.tsx b/src/apps/dashboard/components/drawer/sections/ServerDrawerSection.tsx index 01e26ace84..5685afd3fd 100644 --- a/src/apps/dashboard/components/drawer/sections/ServerDrawerSection.tsx +++ b/src/apps/dashboard/components/drawer/sections/ServerDrawerSection.tsx @@ -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 = () => { + + + diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index 9abd6b75e3..5744b54844 100644 --- a/src/apps/dashboard/routes/_asyncRoutes.ts +++ b/src/apps/dashboard/routes/_asyncRoutes.ts @@ -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 } ]; diff --git a/src/apps/dashboard/routes/_legacyRoutes.ts b/src/apps/dashboard/routes/_legacyRoutes.ts index efdd543a42..6adf825dc3 100644 --- a/src/apps/dashboard/routes/_legacyRoutes.ts +++ b/src/apps/dashboard/routes/_legacyRoutes.ts @@ -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: { diff --git a/src/apps/dashboard/routes/_redirects.ts b/src/apps/dashboard/routes/_redirects.ts index 94211c79c2..b7fdf07218 100644 --- a/src/apps/dashboard/routes/_redirects.ts +++ b/src/apps/dashboard/routes/_redirects.ts @@ -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' }, diff --git a/src/apps/dashboard/routes/dlna.tsx b/src/apps/dashboard/routes/dlna.tsx new file mode 100644 index 0000000000..fec7d2fdb0 --- /dev/null +++ b/src/apps/dashboard/routes/dlna.tsx @@ -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 = () => ( + +
+

DLNA

+ + + {globalize.translate('DlnaMovedMessage')} + + + +
+
+); + +export default DlnaPage; diff --git a/src/apps/dashboard/routes/notifications.tsx b/src/apps/dashboard/routes/notifications.tsx index 6f673c753f..7642ee033b 100644 --- a/src/apps/dashboard/routes/notifications.tsx +++ b/src/apps/dashboard/routes/notifications.tsx @@ -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 = () => ( -
+
+ +
+ {allowedTags?.map(tag => { + return ; + })} +
+
{ />
{blockedTags.map(tag => { - return ; })}
diff --git a/src/apps/dashboard/routes/users/profile.tsx b/src/apps/dashboard/routes/users/profile.tsx index 11cda31fcd..7ef62f8261 100644 --- a/src/apps/dashboard/routes/users/profile.tsx +++ b/src/apps/dashboard/routes/users/profile.tsx @@ -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' /> +

{globalize.translate('HeaderFeatureAccess')} diff --git a/src/apps/experimental/components/drawers/DrawerHeaderLink.tsx b/src/apps/experimental/components/drawers/DrawerHeaderLink.tsx index 4515779763..a60dd2a6c7 100644 --- a/src/apps/experimental/components/drawers/DrawerHeaderLink.tsx +++ b/src/apps/experimental/components/drawers/DrawerHeaderLink.tsx @@ -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 ( diff --git a/src/apps/experimental/components/library/GenresItemsContainer.tsx b/src/apps/experimental/components/library/GenresItemsContainer.tsx index b3f3b5f7bf..a676a0c78b 100644 --- a/src/apps/experimental/components/library/GenresItemsContainer.tsx +++ b/src/apps/experimental/components/library/GenresItemsContainer.tsx @@ -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; diff --git a/src/apps/experimental/components/library/GenresSectionContainer.tsx b/src/apps/experimental/components/library/GenresSectionContainer.tsx index 13ba08ced6..39e81052eb 100644 --- a/src/apps/experimental/components/library/GenresSectionContainer.tsx +++ b/src/apps/experimental/components/library/GenresSectionContainer.tsx @@ -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 = ({ } return = ({ 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 }} diff --git a/src/apps/experimental/components/library/GenresView.tsx b/src/apps/experimental/components/library/GenresView.tsx index 9076c28c6d..50d8c68507 100644 --- a/src/apps/experimental/components/library/GenresView.tsx +++ b/src/apps/experimental/components/library/GenresView.tsx @@ -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; diff --git a/src/apps/experimental/components/library/ItemsView.tsx b/src/apps/experimental/components/library/ItemsView.tsx index 65b26ffcdc..93be9d1348 100644 --- a/src/apps/experimental/components/library/ItemsView.tsx +++ b/src/apps/experimental/components/library/ItemsView.tsx @@ -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 = ({ 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 = ({ 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 = ({ || 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 = ({ viewType ]); - const getItemsHtml = useCallback(() => { - let html = ''; + const getItems = useCallback(() => { + if (!itemsResult?.Items?.length) { + return ; + } if (libraryViewSettings.ViewMode === ViewMode.ListView) { - html = listview.getListViewHtml(getListOptions()); - } else { - html = cardBuilder.getCardsHtml( - itemsResult?.Items ?? [], - getCardOptions() + return ( + ); } - - if (!itemsResult?.Items?.length) { - html += '
'; - html += '

' + globalize.translate('MessageNothingHere') + '

'; - html += '

' + globalize.translate(noItemsMessage) + '

'; - html += '
'; - } - - return html; - }, [libraryViewSettings.ViewMode, itemsResult?.Items, getListOptions, getCardOptions, noItemsMessage]); + return ( + + ); + }, [ + libraryViewSettings.ViewMode, + itemsResult?.Items, + getListOptions, + getCardOptions, + noItemsMessage + ]); const totalRecordCount = itemsResult?.TotalRecordCount ?? 0; const items = itemsResult?.Items ?? []; @@ -289,8 +300,10 @@ const ItemsView: FC = ({ className={itemsContainerClass} parentId={parentId} reloadItems={refetch} - getItemsHtml={getItemsHtml} - /> + queryKey={['ItemsViewByType']} + > + {getItems()} + )} {isPaginationEnabled && ( diff --git a/src/apps/experimental/components/library/PageTabContent.tsx b/src/apps/experimental/components/library/PageTabContent.tsx index 9726f2ffa5..6cd99c5575 100644 --- a/src/apps/experimental/components/library/PageTabContent.tsx +++ b/src/apps/experimental/components/library/PageTabContent.tsx @@ -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; diff --git a/src/apps/experimental/components/library/ProgramsSectionView.tsx b/src/apps/experimental/components/library/ProgramsSectionView.tsx index ac39d899b7..960ba1e96f 100644 --- a/src/apps/experimental/components/library/ProgramsSectionView.tsx +++ b/src/apps/experimental/components/library/ProgramsSectionView.tsx @@ -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 = ({ 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 = ({ 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 = ({ sectionTitle={group.name} items={group.timerInfo ?? []} cardOptions={{ - shape: 'overflowBackdrop', + queryKey: ['Timers'], + shape: CardShape.BackdropOverflow, showTitle: true, showParentTitleOrTitle: true, showAirTime: true, diff --git a/src/apps/experimental/components/library/SectionContainer.tsx b/src/apps/experimental/components/library/SectionContainer.tsx index 18669452a0..8eb64c4e6f 100644 --- a/src/apps/experimental/components/library/SectionContainer.tsx +++ b/src/apps/experimental/components/library/SectionContainer.tsx @@ -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 = ({ sectionTitle, url, items, - cardOptions + cardOptions, + reloadItems }) => { - const element = useRef(null); - - useEffect(() => { - const itemsContainer = element.current?.querySelector('.itemsContainer'); - cardBuilder.buildCards(items, { - itemsContainer: itemsContainer, - parentContainer: element.current, - - ...cardOptions - }); - - imageLoader.lazyChildren(itemsContainer); - }, [cardOptions, items]); - return ( -
+
{url && items.length > 5 ? ( = ({ > + reloadItems={reloadItems} + queryKey={cardOptions.queryKey} + > + +
); diff --git a/src/apps/experimental/components/library/SuggestionsSectionView.tsx b/src/apps/experimental/components/library/SuggestionsSectionView.tsx index 039f49e4c6..ca3631e67d 100644 --- a/src/apps/experimental/components/library/SuggestionsSectionView.tsx +++ b/src/apps/experimental/components/library/SuggestionsSectionView.tsx @@ -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 = ({ ); break; } - return escapeHTML(title); + return title; }; return ( @@ -102,6 +102,7 @@ const SuggestionsSectionView: FC = ({ url={getRouteUrl(section)} cardOptions={{ ...section.cardOptions, + queryKey: ['SuggestionSectionWithItems'], showTitle: true, centerText: true, cardLayout: false, @@ -117,7 +118,8 @@ const SuggestionsSectionView: FC = ({ sectionTitle={getRecommendationTittle(recommendation)} items={recommendation.Items ?? []} cardOptions={{ - shape: 'overflowPortrait', + queryKey: ['MovieRecommendations'], + shape: CardShape.PortraitOverflow, showYear: true, scalable: true, overlayPlayButton: true, diff --git a/src/apps/experimental/components/library/UpcomingView.tsx b/src/apps/experimental/components/library/UpcomingView.tsx index bf6a6b0ace..874382d9e5 100644 --- a/src/apps/experimental/components/library/UpcomingView.tsx +++ b/src/apps/experimental/components/library/UpcomingView.tsx @@ -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 = ({ parentId }) => { const { isLoading, data: groupsUpcomingEpisodes } = useGetGroupsUpcomingEpisodes(parentId); @@ -29,7 +30,7 @@ const UpcomingView: FC = ({ parentId }) => { sectionTitle={group.name} items={group.items ?? []} cardOptions={{ - shape: 'overflowBackdrop', + shape: CardShape.BackdropOverflow, showLocationTypeIndicator: false, showParentTitle: true, preferThumb: true, diff --git a/src/apps/experimental/routes/asyncRoutes/user.ts b/src/apps/experimental/routes/asyncRoutes/user.ts index 9f0a74e272..e653bf172d 100644 --- a/src/apps/experimental/routes/asyncRoutes/user.ts +++ b/src/apps/experimental/routes/asyncRoutes/user.ts @@ -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 } ]; diff --git a/src/apps/experimental/routes/legacyRoutes/user.ts b/src/apps/experimental/routes/legacyRoutes/user.ts index e6e3fcdfcb..1547f68359 100644 --- a/src/apps/experimental/routes/legacyRoutes/user.ts +++ b/src/apps/experimental/routes/legacyRoutes/user.ts @@ -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: { diff --git a/src/apps/experimental/routes/user/display/DisplayPreferences.tsx b/src/apps/experimental/routes/user/display/DisplayPreferences.tsx new file mode 100644 index 0000000000..0645edbd4f --- /dev/null +++ b/src/apps/experimental/routes/user/display/DisplayPreferences.tsx @@ -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) { + const { user } = useApi(); + const { screensavers } = useScreensavers(); + const { themes } = useServerThemes(); + + return ( + + {globalize.translate('Display')} + + { appHost.supports('displaymode') && ( + + {globalize.translate('LabelDisplayMode')} + + + {globalize.translate('DisplayModeHelp')} + {globalize.translate('LabelPleaseRestart')} + + + ) } + + { themes.length > 0 && ( + + {globalize.translate('LabelTheme')} + + + ) } + + + + } + label={globalize.translate('DisableCustomCss')} + name='disableCustomCss' + /> + + {globalize.translate('LabelDisableCustomCss')} + + + + + + + {globalize.translate('LabelLocalCustomCss')} + + + + { themes.length > 0 && user?.Policy?.IsAdministrator && ( + + {globalize.translate('LabelDashboardTheme')} + + + ) } + + { screensavers.length > 0 && appHost.supports('screensaver') && ( + + + {globalize.translate('LabelScreensaver')} + + + + + + + {globalize.translate('LabelBackdropScreensaverIntervalHelp')} + + + + ) } + + + + } + label={globalize.translate('EnableFasterAnimations')} + name='enableFasterAnimation' + /> + + {globalize.translate('EnableFasterAnimationsHelp')} + + + + + + } + label={globalize.translate('EnableBlurHash')} + name='enableBlurHash' + /> + + {globalize.translate('EnableBlurHashHelp')} + + + + ); +} diff --git a/src/apps/experimental/routes/user/display/ItemDetailPreferences.tsx b/src/apps/experimental/routes/user/display/ItemDetailPreferences.tsx new file mode 100644 index 0000000000..00da9439de --- /dev/null +++ b/src/apps/experimental/routes/user/display/ItemDetailPreferences.tsx @@ -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) { + return ( + + {globalize.translate('ItemDetails')} + + + + } + label={globalize.translate('EnableDetailsBanner')} + name='enableItemDetailsBanner' + /> + + {globalize.translate('EnableDetailsBannerHelp')} + + + + ); +} diff --git a/src/apps/experimental/routes/user/display/LibraryPreferences.tsx b/src/apps/experimental/routes/user/display/LibraryPreferences.tsx new file mode 100644 index 0000000000..a73fa7138c --- /dev/null +++ b/src/apps/experimental/routes/user/display/LibraryPreferences.tsx @@ -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) { + return ( + + {globalize.translate('HeaderLibraries')} + + + + + {globalize.translate('LabelLibraryPageSizeHelp')} + + + + + + } + label={globalize.translate('Backdrops')} + name='enableLibraryBackdrops' + /> + + {globalize.translate('EnableBackdropsHelp')} + + + + + + } + label={globalize.translate('ThemeSongs')} + name='enableLibraryThemeSongs' + /> + + {globalize.translate('EnableThemeSongsHelp')} + + + + + + } + label={globalize.translate('ThemeVideos')} + name='enableLibraryThemeVideos' + /> + + {globalize.translate('EnableThemeVideosHelp')} + + + + + + } + label={globalize.translate('DisplayMissingEpisodesWithinSeasons')} + name='displayMissingEpisodes' + /> + + {globalize.translate('DisplayMissingEpisodesWithinSeasonsHelp')} + + + + ); +} diff --git a/src/apps/experimental/routes/user/display/LocalizationPreferences.tsx b/src/apps/experimental/routes/user/display/LocalizationPreferences.tsx new file mode 100644 index 0000000000..e406d0e38e --- /dev/null +++ b/src/apps/experimental/routes/user/display/LocalizationPreferences.tsx @@ -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) { + if (!appHost.supports('displaylanguage') && !datetime.supportsLocalization()) { + return null; + } + return ( + + {globalize.translate('Localization')} + + { appHost.supports('displaylanguage') && ( + + {globalize.translate('LabelDisplayLanguage')} + + + {globalize.translate('LabelDisplayLanguageHelp')} + { appHost.supports('externallinks') && ( + + {globalize.translate('LearnHowYouCanContribute')} + + ) } + + + ) } + + { datetime.supportsLocalization() && ( + + {globalize.translate('LabelDateTimeLocale')} + + + ) } + + ); +} diff --git a/src/apps/experimental/routes/user/display/NextUpPreferences.tsx b/src/apps/experimental/routes/user/display/NextUpPreferences.tsx new file mode 100644 index 0000000000..1c21012a1f --- /dev/null +++ b/src/apps/experimental/routes/user/display/NextUpPreferences.tsx @@ -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) { + return ( + + {globalize.translate('NextUp')} + + + + + {globalize.translate('LabelMaxDaysForNextUpHelp')} + + + + + + } + label={globalize.translate('EnableRewatchingNextUp')} + name='enableRewatchingInNextUp' + /> + + {globalize.translate('EnableRewatchingNextUpHelp')} + + + + + + } + label={globalize.translate('UseEpisodeImagesInNextUp')} + name='episodeImagesInNextUp' + /> + + {globalize.translate('UseEpisodeImagesInNextUpHelp')} + + + + ); +} diff --git a/src/apps/experimental/routes/user/display/constants.ts b/src/apps/experimental/routes/user/display/constants.ts new file mode 100644 index 0000000000..7ece9aa9c7 --- /dev/null +++ b/src/apps/experimental/routes/user/display/constants.ts @@ -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'); diff --git a/src/apps/experimental/routes/user/display/hooks/useDisplaySettingForm.ts b/src/apps/experimental/routes/user/display/hooks/useDisplaySettingForm.ts new file mode 100644 index 0000000000..d7986fa42c --- /dev/null +++ b/src/apps/experimental/routes/user/display/hooks/useDisplaySettingForm.ts @@ -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(); + + 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 + }; +} diff --git a/src/apps/experimental/routes/user/display/hooks/useDisplaySettings.ts b/src/apps/experimental/routes/user/display/hooks/useDisplaySettings.ts new file mode 100644 index 0000000000..3e4ca6455a --- /dev/null +++ b/src/apps/experimental/routes/user/display/hooks/useDisplaySettings.ts @@ -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(); + const [displaySettings, setDisplaySettings] = useState(); + 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; +} diff --git a/src/apps/experimental/routes/user/display/hooks/useScreensavers.ts b/src/apps/experimental/routes/user/display/hooks/useScreensavers.ts new file mode 100644 index 0000000000..8d9342552c --- /dev/null +++ b/src/apps/experimental/routes/user/display/hooks/useScreensavers.ts @@ -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(() => { + 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 ?? [] + }; +} diff --git a/src/apps/experimental/routes/user/display/hooks/useServerThemes.ts b/src/apps/experimental/routes/user/display/hooks/useServerThemes.ts new file mode 100644 index 0000000000..4a1cde3a1e --- /dev/null +++ b/src/apps/experimental/routes/user/display/hooks/useServerThemes.ts @@ -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(); + + 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 + }; +} diff --git a/src/apps/experimental/routes/user/display/index.tsx b/src/apps/experimental/routes/user/display/index.tsx new file mode 100644 index 0000000000..f30b48f5bc --- /dev/null +++ b/src/apps/experimental/routes/user/display/index.tsx @@ -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) => { + 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 ; + } + + return ( + +
+
+ + + + + + + + + +
+
+
+ ); +} diff --git a/src/apps/experimental/routes/user/display/types.ts b/src/apps/experimental/routes/user/display/types.ts new file mode 100644 index 0000000000..a5e08d2dd9 --- /dev/null +++ b/src/apps/experimental/routes/user/display/types.ts @@ -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; +} diff --git a/src/apps/stable/routes/search.tsx b/src/apps/stable/routes/search.tsx index c9285f6075..5c483c9a8c 100644 --- a/src/apps/stable/routes/search.tsx +++ b/src/apps/stable/routes/search.tsx @@ -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(); - 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 ( { title={globalize.translate('Search')} className='mainAnimatedPage libraryPage allLibraryPage noSecondaryNavPage' > - + {!query && } diff --git a/src/components/ServerConnections.js b/src/components/ServerConnections.js index 9bdb82fc60..70dcc931d0 100644 --- a/src/components/ServerConnections.js +++ b/src/components/ServerConnections.js @@ -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} 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); diff --git a/src/components/actionSheet/actionSheet.js b/src/components/actionSheet/actionSheet.ts similarity index 78% rename from src/components/actionSheet/actionSheet.js rename to src/components/actionSheet/actionSheet.ts index 1114908fcf..eacd66f421 100644 --- a/src/components/actionSheet/actionSheet.js +++ b/src/components/actionSheet/actionSheet.ts @@ -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 | 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'; } diff --git a/src/components/apphost.js b/src/components/apphost.js index 053b846374..742476195d 100644 --- a/src/components/apphost.js +++ b/src/components/apphost.js @@ -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 {}; diff --git a/src/components/cardbuilder/Card/Card.tsx b/src/components/cardbuilder/Card/Card.tsx new file mode 100644 index 0000000000..2b5314d4cc --- /dev/null +++ b/src/components/cardbuilder/Card/Card.tsx @@ -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 = ({ item = {}, cardOptions }) => { + const { getCardWrapperProps, getCardBoxProps } = useCard({ item, cardOptions } ); + const cardWrapperProps = getCardWrapperProps(); + const cardBoxProps = getCardBoxProps(); + return ( + + + + ); +}; + +export default Card; diff --git a/src/components/cardbuilder/Card/CardBox.tsx b/src/components/cardbuilder/Card/CardBox.tsx new file mode 100644 index 0000000000..a7fd41c0cd --- /dev/null +++ b/src/components/cardbuilder/Card/CardBox.tsx @@ -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 = ({ + item, + cardOptions, + className, + shape, + imgUrl, + blurhash, + forceName, + coveredImage, + overlayText +}) => { + return ( +
+
+
+ + {layoutManager.mobile && ( + + )} + + {layoutManager.desktop + && !cardOptions.disableHoverMenu && ( + + )} +
+ {!overlayText && ( + + )} +
+ ); +}; + +export default CardBox; + diff --git a/src/components/cardbuilder/Card/CardContent.tsx b/src/components/cardbuilder/Card/CardContent.tsx new file mode 100644 index 0000000000..eb8ee8a2eb --- /dev/null +++ b/src/components/cardbuilder/Card/CardContent.tsx @@ -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 = ({ + item, + cardOptions, + coveredImage, + overlayText, + imgUrl, + blurhash, + forceName +}) => { + const cardContentClass = classNames( + 'cardContent', + { [getDefaultBackgroundClass(item.Name)]: !imgUrl } + ); + + return ( +
+ +
+ ); +}; + +export default CardContent; diff --git a/src/components/cardbuilder/Card/CardFooterText.tsx b/src/components/cardbuilder/Card/CardFooterText.tsx new file mode 100644 index 0000000000..934aa5c8a7 --- /dev/null +++ b/src/components/cardbuilder/Card/CardFooterText.tsx @@ -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 = ({ logoUrl }) => ( + + + +); + +interface CardFooterTextProps { + item: ItemDto; + cardOptions: CardOptions; + forceName: boolean; + overlayText: boolean | undefined; + imgUrl: string | undefined; + footerClass: string | undefined; + progressBar?: React.JSX.Element | null; + logoUrl?: string; + isOuterFooter: boolean; +} + +const CardFooterText: FC = ({ + 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 ( + + {logoUrl && } + {shouldShowDetailsMenu(cardOptions, isOuterFooter) && ( + + )} + + {cardTextLines} + + {progressBar} + + ); +}; + +export default CardFooterText; diff --git a/src/components/cardbuilder/Card/CardHoverMenu.tsx b/src/components/cardbuilder/Card/CardHoverMenu.tsx new file mode 100644 index 0000000000..b79747034b --- /dev/null +++ b/src/components/cardbuilder/Card/CardHoverMenu.tsx @@ -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 = ({ + 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 ( + + + + {playbackManager.canPlay(item) && ( + + )} + + + {itemHelper.canMarkPlayed(item) && cardOptions.enablePlayedButton !== false && ( + + )} + + {itemHelper.canRate(item) && cardOptions.enableRatingButton !== false && ( + + )} + + + + + ); +}; + +export default CardHoverMenu; diff --git a/src/components/cardbuilder/Card/CardImageContainer.tsx b/src/components/cardbuilder/Card/CardImageContainer.tsx new file mode 100644 index 0000000000..db609f21e6 --- /dev/null +++ b/src/components/cardbuilder/Card/CardImageContainer.tsx @@ -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 = ({ + 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 ( +
+ {cardOptions.disableIndicators !== true && ( + + {indicator.getMediaSourceIndicator()} + + + {cardOptions.missingIndicator !== false + && indicator.getMissingIndicator()} + + {indicator.getTimerIndicator()} + {indicator.getTypeIndicator()} + + {cardOptions.showGroupCount ? + indicator.getChildCountIndicator() : + indicator.getPlayedIndicator()} + + {(item.Type === BaseItemKind.CollectionFolder + || item.CollectionType) + && item.RefreshProgress && ( + + )} + + + )} + + + + {overlayText && ( + + )} + + {!overlayText && indicator.getProgressBar()} +
+ ); +}; + +export default CardImageContainer; diff --git a/src/components/cardbuilder/Card/CardInnerFooter.tsx b/src/components/cardbuilder/Card/CardInnerFooter.tsx new file mode 100644 index 0000000000..e5908adc27 --- /dev/null +++ b/src/components/cardbuilder/Card/CardInnerFooter.tsx @@ -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 = ({ + item, + cardOptions, + imgUrl, + overlayText, + progressBar, + forceName +}) => { + const footerClass = classNames('innerCardFooter', { + fullInnerCardFooter: progressBar + }); + + return ( + + ); +}; + +export default CardInnerFooter; diff --git a/src/components/cardbuilder/Card/CardOuterFooter.tsx b/src/components/cardbuilder/Card/CardOuterFooter.tsx new file mode 100644 index 0000000000..3f6380aa9e --- /dev/null +++ b/src/components/cardbuilder/Card/CardOuterFooter.tsx @@ -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 = ({ 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 ( + + + ); +}; + +export default CardOuterFooter; diff --git a/src/components/cardbuilder/Card/CardOverlayButtons.tsx b/src/components/cardbuilder/Card/CardOverlayButtons.tsx new file mode 100644 index 0000000000..a00b194587 --- /dev/null +++ b/src/components/cardbuilder/Card/CardOverlayButtons.tsx @@ -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 = ({ + 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 ( + + + {cardOptions.centerPlayButton && ( + + )} + + + {sholudShowOverlayPlayButton(overlayPlayButton, item) && ( + + )} + + {cardOptions.overlayMoreButton && ( + + )} + + + ); +}; + +export default CardOverlayButtons; diff --git a/src/components/cardbuilder/Card/CardText.tsx b/src/components/cardbuilder/Card/CardText.tsx new file mode 100644 index 0000000000..b95a6adb03 --- /dev/null +++ b/src/components/cardbuilder/Card/CardText.tsx @@ -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 = ({ className, textLine }) => { + const { title, titleAction } = textLine; + const renderCardText = () => { + if (titleAction) { + return ( + + {titleAction.title} + + ); + } else { + return title; + } + }; + + return {renderCardText()}; +}; + +export default CardText; diff --git a/src/components/cardbuilder/Card/CardWrapper.tsx b/src/components/cardbuilder/Card/CardWrapper.tsx new file mode 100644 index 0000000000..4c8ec854ea --- /dev/null +++ b/src/components/cardbuilder/Card/CardWrapper.tsx @@ -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 = ({ + className, + dataAttributes, + children +}) => { + if (layoutManager.tv) { + return ( + + ); + } else { + return ( +
+ {children} +
+ ); + } +}; + +export default CardWrapper; diff --git a/src/components/cardbuilder/Card/Cards.tsx b/src/components/cardbuilder/Card/Cards.tsx new file mode 100644 index 0000000000..2ea6863954 --- /dev/null +++ b/src/components/cardbuilder/Card/Cards.tsx @@ -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 = ({ items, cardOptions }) => { + setCardData(items, cardOptions); + + const renderCards = () => + items.map((item) => ( + + )); + + return <>{renderCards()}; +}; + +export default Cards; diff --git a/src/components/cardbuilder/Card/cardHelper.ts b/src/components/cardbuilder/Card/cardHelper.ts new file mode 100644 index 0000000000..78af3b0ff9 --- /dev/null +++ b/src/components/cardbuilder/Card/cardHelper.ts @@ -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 + }; +} diff --git a/src/components/cardbuilder/Card/useCard.ts b/src/components/cardbuilder/Card/useCard.ts new file mode 100644 index 0000000000..6c031afa8d --- /dev/null +++ b/src/components/cardbuilder/Card/useCard.ts @@ -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; diff --git a/src/components/cardbuilder/Card/useCardImageUrl.ts b/src/components/cardbuilder/Card/useCardImageUrl.ts new file mode 100644 index 0000000000..4675c1d80b --- /dev/null +++ b/src/components/cardbuilder/Card/useCardImageUrl.ts @@ -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; diff --git a/src/components/cardbuilder/Card/useCardText.tsx b/src/components/cardbuilder/Card/useCardText.tsx new file mode 100644 index 0000000000..ff93662841 --- /dev/null +++ b/src/components/cardbuilder/Card/useCardText.tsx @@ -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( + + ); + + valid++; + if (maxLines && valid >= maxLines) { + break; + } + } + } + + if (forceLines) { + const linesLength = maxLines ?? Math.min(textLines.length, maxLines ?? textLines.length); + while (valid < linesLength) { + components.push( + +   + + ); + valid++; + } + } + + return components; + }; + + const cardTextLines = renderCardTextLines(); + + return { + cardTextLines + }; +} + +export default useCardText; diff --git a/src/components/cardbuilder/card.scss b/src/components/cardbuilder/card.scss index 28f55abe2d..731ef32a80 100644 --- a/src/components/cardbuilder/card.scss +++ b/src/components/cardbuilder/card.scss @@ -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; } diff --git a/src/components/cardbuilder/cardBuilder.js b/src/components/cardbuilder/cardBuilder.js index 811bac1851..acbe05c270 100644 --- a/src/components/cardbuilder/cardBuilder.js +++ b/src/components/cardbuilder/cardBuilder.js @@ -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); diff --git a/src/components/cardbuilder/cardBuilderUtils.ts b/src/components/cardbuilder/cardBuilderUtils.ts index d7215b190c..489d6c8abd 100644 --- a/src/components/cardbuilder/cardBuilderUtils.ts +++ b/src/components/cardbuilder/cardBuilderUtils.ts @@ -1,3 +1,4 @@ +import { CardShape } from '../../utils/card'; import { randomInt } from '../../utils/number'; import classNames from 'classnames'; @@ -10,10 +11,10 @@ const ASPECT_RATIOS = { /** * Determines if the item is live TV. - * @param {string} itemType - Item type to use for the check. + * @param {string | null | undefined} itemType - Item type to use for the check. * @returns {boolean} Flag showing if the item is live TV. */ -export const isUsingLiveTvNaming = (itemType: string): boolean => itemType === 'Program' || itemType === 'Timer' || itemType === 'Recording'; +export const isUsingLiveTvNaming = (itemType: string | null | undefined): boolean => itemType === 'Program' || itemType === 'Timer' || itemType === 'Recording'; /** * Resolves Card action to display @@ -54,15 +55,15 @@ export const isResizable = (windowWidth: number): boolean => { */ export const resolveMixedShapeByAspectRatio = (primaryImageAspectRatio: number | null | undefined) => { if (primaryImageAspectRatio === undefined || primaryImageAspectRatio === null) { - return 'mixedSquare'; + return CardShape.MixedSquare; } if (primaryImageAspectRatio >= 1.33) { - return 'mixedBackdrop'; + return CardShape.MixedBackdrop; } else if (primaryImageAspectRatio > 0.71) { - return 'mixedSquare'; + return CardShape.MixedSquare; } else { - return 'mixedPortrait'; + return CardShape.MixedPortrait; } }; diff --git a/src/components/common/DefaultIconText.tsx b/src/components/common/DefaultIconText.tsx new file mode 100644 index 0000000000..c6ea81b18e --- /dev/null +++ b/src/components/common/DefaultIconText.tsx @@ -0,0 +1,56 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; +import React, { type FC } from 'react'; +import Icon from '@mui/material/Icon'; +import imageHelper from 'utils/image'; +import DefaultName from './DefaultName'; +import type { ItemDto } from 'types/base/models/item-dto'; + +interface DefaultIconTextProps { + item: ItemDto; + defaultCardImageIcon?: string; +} + +const DefaultIconText: FC = ({ + item, + defaultCardImageIcon +}) => { + if (item.CollectionType) { + return ( + + ); + } + + if (item.Type && !(item.Type === BaseItemKind.TvChannel || item.Type === BaseItemKind.Studio )) { + return ( + + ); + } + + if (defaultCardImageIcon) { + return ( + + ); + } + + return ; +}; + +export default DefaultIconText; diff --git a/src/components/common/DefaultName.tsx b/src/components/common/DefaultName.tsx new file mode 100644 index 0000000000..44a7829531 --- /dev/null +++ b/src/components/common/DefaultName.tsx @@ -0,0 +1,22 @@ +import React, { type FC } from 'react'; +import Box from '@mui/material/Box'; +import itemHelper from 'components/itemHelper'; +import { isUsingLiveTvNaming } from '../cardbuilder/cardBuilderUtils'; +import type { ItemDto } from 'types/base/models/item-dto'; + +interface DefaultNameProps { + item: ItemDto; +} + +const DefaultName: FC = ({ item }) => { + const defaultName = isUsingLiveTvNaming(item.Type) ? + item.Name : + itemHelper.getDisplayName(item); + return ( + + {defaultName} + + ); +}; + +export default DefaultName; diff --git a/src/components/common/Image.tsx b/src/components/common/Image.tsx new file mode 100644 index 0000000000..8e26e78b24 --- /dev/null +++ b/src/components/common/Image.tsx @@ -0,0 +1,67 @@ +import React, { type FC, useCallback, useState } from 'react'; +import { BlurhashCanvas } from 'react-blurhash'; +import { LazyLoadImage } from 'react-lazy-load-image-component'; + +const imageStyle: React.CSSProperties = { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + width: '100%', + height: '100%', + zIndex: 0 +}; + +interface ImageProps { + imgUrl: string; + blurhash?: string; + containImage: boolean; +} + +const Image: FC = ({ + imgUrl, + blurhash, + containImage +}) => { + const [isLoaded, setIsLoaded] = useState(false); + const [isLoadStarted, setIsLoadStarted] = useState(false); + const handleLoad = useCallback(() => { + setIsLoaded(true); + }, []); + + const handleLoadStarted = useCallback(() => { + setIsLoadStarted(true); + }, []); + + return ( +
+ {!isLoaded && isLoadStarted && blurhash && ( + + )} + + +
+ ); +}; + +export default Image; diff --git a/src/components/common/InfoIconButton.tsx b/src/components/common/InfoIconButton.tsx new file mode 100644 index 0000000000..deefa0628b --- /dev/null +++ b/src/components/common/InfoIconButton.tsx @@ -0,0 +1,22 @@ +import React, { type FC } from 'react'; +import IconButton from '@mui/material/IconButton'; +import InfoIcon from '@mui/icons-material/Info'; +import globalize from 'scripts/globalize'; + +interface InfoIconButtonProps { + className?: string; +} + +const InfoIconButton: FC = ({ className }) => { + return ( + + + + ); +}; + +export default InfoIconButton; diff --git a/src/components/common/Media.tsx b/src/components/common/Media.tsx new file mode 100644 index 0000000000..99858356c0 --- /dev/null +++ b/src/components/common/Media.tsx @@ -0,0 +1,36 @@ +import { BaseItemKind, ImageType } from '@jellyfin/sdk/lib/generated-client'; +import React, { type FC } from 'react'; +import Image from './Image'; +import DefaultIconText from './DefaultIconText'; +import type { ItemDto } from 'types/base/models/item-dto'; + +interface MediaProps { + item: ItemDto; + imgUrl: string | undefined; + blurhash: string | undefined; + imageType?: ImageType + defaultCardImageIcon?: string +} + +const Media: FC = ({ + item, + imgUrl, + blurhash, + imageType, + defaultCardImageIcon +}) => { + return imgUrl ? ( + + ) : ( + + ); +}; + +export default Media; diff --git a/src/components/common/MoreVertIconButton.tsx b/src/components/common/MoreVertIconButton.tsx new file mode 100644 index 0000000000..c0a77088c5 --- /dev/null +++ b/src/components/common/MoreVertIconButton.tsx @@ -0,0 +1,23 @@ +import React, { type FC } from 'react'; +import IconButton from '@mui/material/IconButton'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import globalize from 'scripts/globalize'; + +interface MoreVertIconButtonProps { + className?: string; + iconClassName?: string; +} + +const MoreVertIconButton: FC = ({ className, iconClassName }) => { + return ( + + + + ); +}; + +export default MoreVertIconButton; diff --git a/src/components/common/NoItemsMessage.tsx b/src/components/common/NoItemsMessage.tsx new file mode 100644 index 0000000000..88f288c144 --- /dev/null +++ b/src/components/common/NoItemsMessage.tsx @@ -0,0 +1,25 @@ +import React, { type FC } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import globalize from 'scripts/globalize'; + +interface NoItemsMessageProps { + noItemsMessage?: string; +} + +const NoItemsMessage: FC = ({ + noItemsMessage = 'MessageNoItemsAvailable' +}) => { + return ( + + + {globalize.translate('MessageNothingHere')} + + + {globalize.translate(noItemsMessage)} + + + ); +}; + +export default NoItemsMessage; diff --git a/src/components/common/PlayArrowIconButton.tsx b/src/components/common/PlayArrowIconButton.tsx new file mode 100644 index 0000000000..d7ca732966 --- /dev/null +++ b/src/components/common/PlayArrowIconButton.tsx @@ -0,0 +1,25 @@ +import React, { type FC } from 'react'; +import IconButton from '@mui/material/IconButton'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import globalize from 'scripts/globalize'; + +interface PlayArrowIconButtonProps { + className: string; + action: string; + title: string; + iconClassName?: string; +} + +const PlayArrowIconButton: FC = ({ className, action, title, iconClassName }) => { + return ( + + + + ); +}; + +export default PlayArrowIconButton; diff --git a/src/components/common/PlaylistAddIconButton.tsx b/src/components/common/PlaylistAddIconButton.tsx new file mode 100644 index 0000000000..14fb2a83cd --- /dev/null +++ b/src/components/common/PlaylistAddIconButton.tsx @@ -0,0 +1,22 @@ +import React, { type FC } from 'react'; +import IconButton from '@mui/material/IconButton'; +import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd'; +import globalize from 'scripts/globalize'; + +interface PlaylistAddIconButtonProps { + className?: string; +} + +const PlaylistAddIconButton: FC = ({ className }) => { + return ( + + + + ); +}; + +export default PlaylistAddIconButton; diff --git a/src/components/common/RightIconButtons.tsx b/src/components/common/RightIconButtons.tsx new file mode 100644 index 0000000000..cfe65e451c --- /dev/null +++ b/src/components/common/RightIconButtons.tsx @@ -0,0 +1,24 @@ +import React, { type FC } from 'react'; +import IconButton from '@mui/material/IconButton'; + +interface RightIconButtonsProps { + className?: string; + id: string; + icon: string; + title: string; +} + +const RightIconButtons: FC = ({ className, id, title, icon }) => { + return ( + + {icon} + + ); +}; + +export default RightIconButtons; diff --git a/src/components/dashboard/playback/trickplay/LinkTrickplayAcceleration.tsx b/src/components/dashboard/playback/trickplay/LinkTrickplayAcceleration.tsx new file mode 100644 index 0000000000..a00b839c87 --- /dev/null +++ b/src/components/dashboard/playback/trickplay/LinkTrickplayAcceleration.tsx @@ -0,0 +1,33 @@ +import React, { type FunctionComponent } from 'react'; +import globalize from '../../../../scripts/globalize'; + +type IProps = { + title?: string; + className?: string; + href?: string; +}; + +const createLinkElement = ({ className, title, href }: IProps) => ({ + __html: ` + ${title} + ` +}); + +const LinkTrickplayAcceleration: FunctionComponent = ({ className, title, href }: IProps) => { + return ( +
+ ); +}; + +export default LinkTrickplayAcceleration; diff --git a/src/components/dashboard/users/BlockedTagList.tsx b/src/components/dashboard/users/TagList.tsx similarity index 75% rename from src/components/dashboard/users/BlockedTagList.tsx rename to src/components/dashboard/users/TagList.tsx index 5158a63e97..531ee2f6e6 100644 --- a/src/components/dashboard/users/BlockedTagList.tsx +++ b/src/components/dashboard/users/TagList.tsx @@ -2,10 +2,11 @@ import React, { FunctionComponent } from 'react'; import IconButtonElement from '../../../elements/IconButtonElement'; type IProps = { - tag?: string; + tag?: string, + tagType?: string; }; -const BlockedTagList: FunctionComponent = ({ tag }: IProps) => { +const TagList: FunctionComponent = ({ tag, tagType }: IProps) => { return (
@@ -16,7 +17,7 @@ const BlockedTagList: FunctionComponent = ({ tag }: IProps) => {
= ({ tag }: IProps) => { ); }; -export default BlockedTagList; +export default TagList; diff --git a/src/components/dashboard/users/UserCardBox.tsx b/src/components/dashboard/users/UserCardBox.tsx index e4bc40d2bf..8046be24ef 100644 --- a/src/components/dashboard/users/UserCardBox.tsx +++ b/src/components/dashboard/users/UserCardBox.tsx @@ -61,7 +61,7 @@ const UserCardBox: FunctionComponent = ({ user = {} }: IProps) => {
`; return ( -
+
diff --git a/src/components/htmlMediaHelper.js b/src/components/htmlMediaHelper.js index c63eb29db0..fa1b343caa 100644 --- a/src/components/htmlMediaHelper.js +++ b/src/components/htmlMediaHelper.js @@ -1,6 +1,7 @@ import appSettings from '../scripts/settings/appSettings' ; import browser from '../scripts/browser'; import Events from '../utils/events.ts'; +import { MediaError } from 'types/mediaError'; export function getSavedVolume() { return appSettings.get('volume') || 1; @@ -87,7 +88,7 @@ export function handleHlsJsMediaError(instance, reject) { if (reject) { reject(); } else { - onErrorInternal(instance, 'mediadecodeerror'); + onErrorInternal(instance, MediaError.FATAL_HLS_ERROR); } } } @@ -98,11 +99,7 @@ export function onErrorInternal(instance, type) { instance.destroyCustomTrack(instance._mediaElement); } - Events.trigger(instance, 'error', [ - { - type: type - } - ]); + Events.trigger(instance, 'error', [{ type }]); } export function isValidDuration(duration) { @@ -193,7 +190,7 @@ export function playWithPromise(elem, onErrorFn) { // swallow this error because the user can still click the play button on the video element return Promise.resolve(); } - return Promise.reject(); + return Promise.reject(e); }) .then(() => { onSuccessfulPlay(elem, onErrorFn); @@ -269,10 +266,10 @@ export function bindEventsToHlsPlayer(instance, hls, elem, onErrorFn, resolve, r hls.destroy(); if (reject) { - reject('servererror'); + reject(MediaError.SERVER_ERROR); reject = null; } else { - onErrorInternal(instance, 'servererror'); + onErrorInternal(instance, MediaError.SERVER_ERROR); } return; @@ -291,10 +288,10 @@ export function bindEventsToHlsPlayer(instance, hls, elem, onErrorFn, resolve, r hls.destroy(); if (reject) { - reject('network'); + reject(MediaError.NETWORK_ERROR); reject = null; } else { - onErrorInternal(instance, 'network'); + onErrorInternal(instance, MediaError.NETWORK_ERROR); } } else { console.debug('fatal network error encountered, try to recover'); @@ -318,7 +315,7 @@ export function bindEventsToHlsPlayer(instance, hls, elem, onErrorFn, resolve, r reject(); reject = null; } else { - onErrorInternal(instance, 'mediadecodeerror'); + onErrorInternal(instance, MediaError.FATAL_HLS_ERROR); } break; } diff --git a/src/components/indicators/indicators.scss b/src/components/indicators/indicators.scss index 29137a5df5..6e99a1c3c9 100644 --- a/src/components/indicators/indicators.scss +++ b/src/components/indicators/indicators.scss @@ -5,6 +5,14 @@ height: 0.28em; } +.itemLinearProgress { + width: 100%; + position: absolute; + left: 0; + bottom: 0; + border-radius: 100px; +} + .itemProgressBarForeground { position: absolute; top: 0; diff --git a/src/components/indicators/useIndicator.tsx b/src/components/indicators/useIndicator.tsx new file mode 100644 index 0000000000..a9ecc12bc8 --- /dev/null +++ b/src/components/indicators/useIndicator.tsx @@ -0,0 +1,261 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; +import { LocationType } from '@jellyfin/sdk/lib/generated-client/models/location-type'; +import React from 'react'; +import Box from '@mui/material/Box'; +import LinearProgress, { + linearProgressClasses +} from '@mui/material/LinearProgress'; +import FiberSmartRecordIcon from '@mui/icons-material/FiberSmartRecord'; +import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'; +import CheckIcon from '@mui/icons-material/Check'; +import VideocamIcon from '@mui/icons-material/Videocam'; +import FolderIcon from '@mui/icons-material/Folder'; +import PhotoAlbumIcon from '@mui/icons-material/PhotoAlbum'; +import PhotoIcon from '@mui/icons-material/Photo'; +import classNames from 'classnames'; +import datetime from 'scripts/datetime'; +import itemHelper from 'components/itemHelper'; +import AutoTimeProgressBar from 'elements/emby-progressbar/AutoTimeProgressBar'; +import type { NullableString } from 'types/base/common/shared/types'; +import type { ItemDto } from 'types/base/models/item-dto'; +import type { ProgressOptions } from 'types/progressOptions'; + +const TypeIcon = { + Video: , + Folder: , + PhotoAlbum: , + Photo: +}; + +const getTypeIcon = (itemType: NullableString) => { + return TypeIcon[itemType as keyof typeof TypeIcon]; +}; + +const enableProgressIndicator = ( + itemType: NullableString, + itemMediaType: NullableString +) => { + return ( + (itemMediaType === 'Video' && itemType !== BaseItemKind.TvChannel) + || itemType === BaseItemKind.AudioBook + || itemType === 'AudioPodcast' + ); +}; + +const enableAutoTimeProgressIndicator = ( + itemType: NullableString, + itemStartDate: NullableString, + itemEndDate: NullableString +) => { + return ( + (itemType === BaseItemKind.Program + || itemType === 'Timer' + || itemType === BaseItemKind.Recording) + && Boolean(itemStartDate) + && Boolean(itemEndDate) + ); +}; + +const enablePlayedIndicator = (item: ItemDto) => { + return itemHelper.canMarkPlayed(item); +}; + +const useIndicator = (item: ItemDto) => { + const getMediaSourceIndicator = () => { + const mediaSourceCount = item.MediaSourceCount ?? 0; + if (mediaSourceCount > 1) { + return {mediaSourceCount}; + } + + return null; + }; + + const getMissingIndicator = () => { + if ( + item.Type === BaseItemKind.Episode + && item.LocationType === LocationType.Virtual + ) { + if (item.PremiereDate) { + try { + const premiereDate = datetime + .parseISO8601Date(item.PremiereDate) + .getTime(); + if (premiereDate > new Date().getTime()) { + return Unaired; + } + } catch (err) { + console.error(err); + } + } + return Missing; + } + + return null; + }; + + const getTimerIndicator = (className?: string) => { + const indicatorIconClass = classNames('timerIndicator', className); + + let status; + + if (item.Type === 'SeriesTimer') { + return ; + } else if (item.TimerId || item.SeriesTimerId) { + status = item.Status || 'Cancelled'; + } else if (item.Type === 'Timer') { + status = item.Status; + } else { + return null; + } + + if (item.SeriesTimerId) { + return ( + + ); + } + + return ; + }; + + const getTypeIndicator = () => { + const icon = getTypeIcon(item.Type); + if (icon) { + return {icon}; + } + return null; + }; + + const getChildCountIndicator = () => { + const childCount = item.ChildCount ?? 0; + + if (childCount > 1) { + return ( + + {datetime.toLocaleString(item.ChildCount)} + + ); + } + + return null; + }; + + const getPlayedIndicator = () => { + if (enablePlayedIndicator(item)) { + const userData = item.UserData || {}; + if (userData.UnplayedItemCount) { + return ( + + {datetime.toLocaleString(userData.UnplayedItemCount)} + + ); + } + + if ( + (userData.PlayedPercentage + && userData.PlayedPercentage >= 100) + || userData.Played + ) { + return ( + + + + ); + } + } + + return null; + }; + + const getProgress = (pct: number, progressOptions?: ProgressOptions) => { + const progressBarClass = classNames( + 'itemLinearProgress', + progressOptions?.containerClass + ); + + return ( + + ); + }; + + const getProgressBar = (progressOptions?: ProgressOptions) => { + if ( + enableProgressIndicator(item.Type, item.MediaType) + && item.Type !== BaseItemKind.Recording + ) { + const playedPercentage = progressOptions?.userData?.PlayedPercentage ? + progressOptions.userData.PlayedPercentage : + item?.UserData?.PlayedPercentage; + if (playedPercentage && playedPercentage < 100) { + return getProgress(playedPercentage); + } + } + + if ( + enableAutoTimeProgressIndicator( + item.Type, + item.StartDate, + item.EndDate + ) + ) { + let startDate = 0; + let endDate = 1; + + try { + startDate = datetime.parseISO8601Date(item.StartDate).getTime(); + endDate = datetime.parseISO8601Date(item.EndDate).getTime(); + } catch (err) { + console.error(err); + } + + const now = new Date().getTime(); + const total = endDate - startDate; + const pct = 100 * ((now - startDate) / total); + + if (pct > 0 && pct < 100) { + const isRecording = + item.Type === 'Timer' + || item.Type === BaseItemKind.Recording + || Boolean(item.TimerId); + return ( + + ); + } + } + + return null; + }; + + return { + getProgress, + getProgressBar, + getMediaSourceIndicator, + getMissingIndicator, + getTimerIndicator, + getTypeIndicator, + getChildCountIndicator, + getPlayedIndicator + }; +}; + +export default useIndicator; diff --git a/src/components/itemContextMenu.js b/src/components/itemContextMenu.js index 69caf9cbce..c056b0d0ec 100644 --- a/src/components/itemContextMenu.js +++ b/src/components/itemContextMenu.js @@ -10,6 +10,24 @@ import { playbackManager } from './playback/playbackmanager'; import ServerConnections from './ServerConnections'; import toast from './toast/toast'; import * as userSettings from '../scripts/settings/userSettings'; +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; + +function getDeleteLabel(type) { + switch (type) { + case BaseItemKind.Series: + return globalize.translate('DeleteSeries'); + + case BaseItemKind.Episode: + return globalize.translate('DeleteEpisode'); + + case BaseItemKind.Playlist: + case BaseItemKind.BoxSet: + return globalize.translate('Delete'); + + default: + return globalize.translate('DeleteMedia'); + } +} export function getCommands(options) { const item = options.item; @@ -160,19 +178,11 @@ export function getCommands(options) { } if (item.CanDelete && options.deleteItem !== false) { - if (item.Type === 'Playlist' || item.Type === 'BoxSet') { - commands.push({ - name: globalize.translate('Delete'), - id: 'delete', - icon: 'delete' - }); - } else { - commands.push({ - name: globalize.translate('DeleteMedia'), - id: 'delete', - icon: 'delete' - }); - } + commands.push({ + name: getDeleteLabel(item.Type), + id: 'delete', + icon: 'delete' + }); } // Books are promoted to major download Button and therefor excluded in the context menu @@ -214,11 +224,7 @@ export function getCommands(options) { }); } - if (canEdit && item.MediaType === 'Video' && item.Type !== 'TvChannel' && item.Type !== 'Program' - && item.LocationType !== 'Virtual' - && !(item.Type === 'Recording' && item.Status !== 'Completed') - && options.editSubtitles !== false - ) { + if (itemHelper.canEditSubtitles(user, item) && options.editSubtitles !== false) { commands.push({ name: globalize.translate('EditSubtitles'), id: 'editsubtitles', @@ -339,7 +345,8 @@ function executeCommand(item, id, options) { break; case 'addtoplaylist': import('./playlisteditor/playlisteditor').then(({ default: PlaylistEditor }) => { - new PlaylistEditor({ + const playlistEditor = new PlaylistEditor(); + playlistEditor.show({ items: [itemId], serverId: serverId }).then(getResolveFunction(resolve, id, true), getResolveFunction(resolve, id)); diff --git a/src/components/itemHelper.js b/src/components/itemHelper.js index d763003fb9..498a8bab80 100644 --- a/src/components/itemHelper.js +++ b/src/components/itemHelper.js @@ -1,6 +1,10 @@ import { appHost } from './apphost'; import globalize from '../scripts/globalize'; import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type'; +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; +import { LocationType } from '@jellyfin/sdk/lib/generated-client/models/location-type'; +import { RecordingStatus } from '@jellyfin/sdk/lib/generated-client/models/recording-status'; +import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type'; export function getDisplayName(item, options = {}) { if (!item) { @@ -155,6 +159,33 @@ export function canEditImages (user, item) { return itemType !== 'Timer' && itemType !== 'SeriesTimer' && canEdit(user, item) && !isLocalItem(item); } +export function canEditSubtitles (user, item) { + if (item.MediaType !== MediaType.Video) { + return false; + } + const itemType = item.Type; + if (itemType === BaseItemKind.Recording && item.Status !== RecordingStatus.Completed) { + return false; + } + if (itemType === BaseItemKind.TvChannel + || itemType === BaseItemKind.Program + || itemType === 'Timer' + || itemType === 'SeriesTimer' + || itemType === BaseItemKind.UserRootFolder + || itemType === BaseItemKind.UserView + ) { + return false; + } + if (isLocalItem(item)) { + return false; + } + if (item.LocationType === LocationType.Virtual) { + return false; + } + return user.Policy.EnableSubtitleManagement + || user.Policy.IsAdministrator; +} + export function canShare (item, user) { if (item.Type === 'Program') { return false; @@ -300,6 +331,7 @@ export default { canIdentify: canIdentify, canEdit: canEdit, canEditImages: canEditImages, + canEditSubtitles, canShare: canShare, enableDateAddedDisplay: enableDateAddedDisplay, canMarkPlayed: canMarkPlayed, diff --git a/src/components/itemMediaInfo/itemMediaInfo.js b/src/components/itemMediaInfo/itemMediaInfo.js index a6ce545ee0..0049163411 100644 --- a/src/components/itemMediaInfo/itemMediaInfo.js +++ b/src/components/itemMediaInfo/itemMediaInfo.js @@ -84,6 +84,7 @@ function getMediaSourceHtml(user, item, version) { case 'Data': case 'Subtitle': case 'Video': + case 'Lyric': translateString = stream.Type; break; case 'EmbeddedImage': @@ -145,10 +146,10 @@ function getMediaSourceHtml(user, item, version) { if (stream.BitDepth) { attributes.push(createAttribute(globalize.translate('MediaInfoBitDepth'), `${stream.BitDepth} bit`)); } - if (stream.VideoRange) { + if (stream.VideoRange && stream.Type === 'Video') { attributes.push(createAttribute(globalize.translate('MediaInfoVideoRange'), stream.VideoRange)); } - if (stream.VideoRangeType) { + if (stream.VideoRangeType && stream.Type === 'Video') { attributes.push(createAttribute(globalize.translate('MediaInfoVideoRangeType'), stream.VideoRangeType)); } if (stream.VideoDoViTitle) { diff --git a/src/components/libraryoptionseditor/libraryoptionseditor.js b/src/components/libraryoptionseditor/libraryoptionseditor.js index 036dbf948b..c783ea12c5 100644 --- a/src/components/libraryoptionseditor/libraryoptionseditor.js +++ b/src/components/libraryoptionseditor/libraryoptionseditor.js @@ -391,8 +391,10 @@ export function setContentType(parent, contentType) { } if (contentType !== 'tvshows' && contentType !== 'movies' && contentType !== 'homevideos' && contentType !== 'musicvideos' && contentType !== 'mixed') { + parent.querySelector('.trickplaySettingsSection').classList.add('hide'); parent.querySelector('.chapterSettingsSection').classList.add('hide'); } else { + parent.querySelector('.trickplaySettingsSection').classList.remove('hide'); parent.querySelector('.chapterSettingsSection').classList.remove('hide'); } @@ -416,6 +418,8 @@ export function setContentType(parent, contentType) { } } + parent.querySelector('.chkUseReplayGainTagsContainer').classList.toggle('hide', contentType !== 'music'); + parent.querySelector('.chkEnableLUFSScanContainer').classList.toggle('hide', contentType !== 'music'); if (contentType === 'tvshows') { @@ -516,6 +520,9 @@ export function getLibraryOptions(parent) { EnablePhotos: parent.querySelector('.chkEnablePhotos').checked, EnableRealtimeMonitor: parent.querySelector('.chkEnableRealtimeMonitor').checked, EnableLUFSScan: parent.querySelector('.chkEnableLUFSScan').checked, + ExtractTrickplayImagesDuringLibraryScan: parent.querySelector('.chkExtractTrickplayDuringLibraryScan').checked, + EnableTrickplayImageExtraction: parent.querySelector('.chkExtractTrickplayImages').checked, + UseReplayGainTags: parent.querySelector('.chkUseReplayGainTags').checked, ExtractChapterImagesDuringLibraryScan: parent.querySelector('.chkExtractChaptersDuringLibraryScan').checked, EnableChapterImageExtraction: parent.querySelector('.chkExtractChapterImages').checked, EnableInternetProviders: true, @@ -579,6 +586,9 @@ export function setLibraryOptions(parent, options) { parent.querySelector('.chkEnablePhotos').checked = options.EnablePhotos; parent.querySelector('.chkEnableRealtimeMonitor').checked = options.EnableRealtimeMonitor; parent.querySelector('.chkEnableLUFSScan').checked = options.EnableLUFSScan; + parent.querySelector('.chkExtractTrickplayDuringLibraryScan').checked = options.ExtractTrickplayImagesDuringLibraryScan; + parent.querySelector('.chkExtractTrickplayImages').checked = options.EnableTrickplayImageExtraction; + parent.querySelector('.chkUseReplayGainTags').checked = options.UseReplayGainTags; parent.querySelector('.chkExtractChaptersDuringLibraryScan').checked = options.ExtractChapterImagesDuringLibraryScan; parent.querySelector('.chkExtractChapterImages').checked = options.EnableChapterImageExtraction; parent.querySelector('#chkSaveLocal').checked = options.SaveLocalMetadata; diff --git a/src/components/libraryoptionseditor/libraryoptionseditor.template.html b/src/components/libraryoptionseditor/libraryoptionseditor.template.html index 38ebe99310..5b25bc6f22 100644 --- a/src/components/libraryoptionseditor/libraryoptionseditor.template.html +++ b/src/components/libraryoptionseditor/libraryoptionseditor.template.html @@ -63,6 +63,14 @@
${LabelEnableRealtimeMonitorHelp}
+
+ +
${LabelUseReplayGainTagsHelp}
+
+
+
+

${Trickplay}

+
+ +
${ExtractTrickplayImagesHelp}
+
+ +
+ +
${LabelExtractTrickplayDuringLibraryScanHelp}
+
+
+

${HeaderChapterImages}

diff --git a/src/components/listview/List/List.tsx b/src/components/listview/List/List.tsx new file mode 100644 index 0000000000..8afe3503ba --- /dev/null +++ b/src/components/listview/List/List.tsx @@ -0,0 +1,32 @@ +import React, { type FC } from 'react'; +import useList from './useList'; +import ListContent from './ListContent'; +import ListWrapper from './ListWrapper'; +import type { ItemDto } from 'types/base/models/item-dto'; +import type { ListOptions } from 'types/listOptions'; +import '../../mediainfo/mediainfo.scss'; +import '../../guide/programs.scss'; + +interface ListProps { + index: number; + item: ItemDto; + listOptions?: ListOptions; +} + +const List: FC = ({ index, item, listOptions = {} }) => { + const { getListdWrapperProps, getListContentProps } = useList({ item, listOptions } ); + const listWrapperProps = getListdWrapperProps(); + const listContentProps = getListContentProps(); + + return ( + + + + ); +}; + +export default List; diff --git a/src/components/listview/List/ListContent.tsx b/src/components/listview/List/ListContent.tsx new file mode 100644 index 0000000000..6dba901dbf --- /dev/null +++ b/src/components/listview/List/ListContent.tsx @@ -0,0 +1,106 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; +import React, { type FC } from 'react'; +import DragHandleIcon from '@mui/icons-material/DragHandle'; +import Box from '@mui/material/Box'; + +import useIndicator from 'components/indicators/useIndicator'; +import PrimaryMediaInfo from '../../mediainfo/PrimaryMediaInfo'; +import ListContentWrapper from './ListContentWrapper'; +import ListItemBody from './ListItemBody'; +import ListImageContainer from './ListImageContainer'; +import ListViewUserDataButtons from './ListViewUserDataButtons'; + +import type { ItemDto } from 'types/base/models/item-dto'; +import type { ListOptions } from 'types/listOptions'; + +interface ListContentProps { + item: ItemDto; + listOptions: ListOptions; + enableContentWrapper?: boolean; + enableOverview?: boolean; + enableSideMediaInfo?: boolean; + clickEntireItem?: boolean; + action?: string; + isLargeStyle: boolean; + downloadWidth?: number; +} + +const ListContent: FC = ({ + item, + listOptions, + enableContentWrapper, + enableOverview, + enableSideMediaInfo, + clickEntireItem, + action, + isLargeStyle, + downloadWidth +}) => { + const indicator = useIndicator(item); + return ( + + + {!clickEntireItem && listOptions.dragHandle && ( + + )} + + {listOptions.image !== false && ( + + )} + + {listOptions.showIndexNumberLeft && ( + + {item.IndexNumber ??  } + + )} + + + + {listOptions.mediaInfo !== false && enableSideMediaInfo && ( + + )} + + {listOptions.recordButton + && (item.Type === 'Timer' || item.Type === BaseItemKind.Program) && ( + indicator.getTimerIndicator('listItemAside') + )} + + {!clickEntireItem && ( + + )} + + ); +}; + +export default ListContent; diff --git a/src/components/listview/List/ListContentWrapper.tsx b/src/components/listview/List/ListContentWrapper.tsx new file mode 100644 index 0000000000..59323dec73 --- /dev/null +++ b/src/components/listview/List/ListContentWrapper.tsx @@ -0,0 +1,34 @@ +import React, { type FC } from 'react'; +import Box from '@mui/material/Box'; + +interface ListContentWrapperProps { + itemOverview: string | null | undefined; + enableContentWrapper?: boolean; + enableOverview?: boolean; +} + +const ListContentWrapper: FC = ({ + itemOverview, + enableContentWrapper, + enableOverview, + children +}) => { + if (enableContentWrapper) { + return ( + <> + {children} + + {enableOverview && itemOverview && ( + + {itemOverview} + + )} + + ); + } else { + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{children}; + } +}; + +export default ListContentWrapper; diff --git a/src/components/listview/List/ListGroupHeaderWrapper.tsx b/src/components/listview/List/ListGroupHeaderWrapper.tsx new file mode 100644 index 0000000000..fd17d83120 --- /dev/null +++ b/src/components/listview/List/ListGroupHeaderWrapper.tsx @@ -0,0 +1,30 @@ +import React, { type FC } from 'react'; +import Typography from '@mui/material/Typography'; + +interface ListGroupHeaderWrapperProps { + index?: number; +} + +const ListGroupHeaderWrapper: FC = ({ + index, + children +}) => { + if (index === 0) { + return ( + + {children} + + ); + } else { + return ( + + {children} + + ); + } +}; + +export default ListGroupHeaderWrapper; diff --git a/src/components/listview/List/ListImageContainer.tsx b/src/components/listview/List/ListImageContainer.tsx new file mode 100644 index 0000000000..bebe97cb12 --- /dev/null +++ b/src/components/listview/List/ListImageContainer.tsx @@ -0,0 +1,103 @@ +import React, { type FC } from 'react'; +import classNames from 'classnames'; +import Box from '@mui/material/Box'; +import { useApi } from 'hooks/useApi'; +import useIndicator from '../../indicators/useIndicator'; +import layoutManager from '../../layoutManager'; +import { getDefaultBackgroundClass } from '../../cardbuilder/cardBuilderUtils'; +import { + canResume, + getChannelImageUrl, + getImageUrl +} from './listHelper'; + +import Media from 'components/common/Media'; +import PlayArrowIconButton from 'components/common/PlayArrowIconButton'; +import type { ItemDto } from 'types/base/models/item-dto'; +import type { ListOptions } from 'types/listOptions'; + +interface ListImageContainerProps { + item: ItemDto; + listOptions: ListOptions; + action?: string | null; + isLargeStyle: boolean; + clickEntireItem?: boolean; + downloadWidth?: number; +} + +const ListImageContainer: FC = ({ + item = {}, + listOptions, + action, + isLargeStyle, + clickEntireItem, + downloadWidth +}) => { + const { api } = useApi(); + const { getMediaSourceIndicator, getProgressBar, getPlayedIndicator } = useIndicator(item); + const imgInfo = listOptions.imageSource === 'channel' ? + getChannelImageUrl(item, api, downloadWidth) : + getImageUrl(item, api, downloadWidth); + + const defaultCardImageIcon = listOptions.defaultCardImageIcon; + const disableIndicators = listOptions.disableIndicators; + const imgUrl = imgInfo?.imgUrl; + const blurhash = imgInfo.blurhash; + + const imageClass = classNames( + 'listItemImage', + { 'listItemImage-large': isLargeStyle }, + { 'listItemImage-channel': listOptions.imageSource === 'channel' }, + { 'listItemImage-large-tv': isLargeStyle && layoutManager.tv }, + { itemAction: !clickEntireItem }, + { [getDefaultBackgroundClass(item.Name)]: !imgUrl } + ); + + const playOnImageClick = listOptions.imagePlayButton && !layoutManager.tv; + + const imageAction = playOnImageClick ? 'link' : action; + + const btnCssClass = + 'paper-icon-button-light listItemImageButton itemAction'; + + const mediaSourceIndicator = getMediaSourceIndicator(); + const playedIndicator = getPlayedIndicator(); + const progressBar = getProgressBar(); + const playbackPositionTicks = item?.UserData?.PlaybackPositionTicks; + + return ( + + + + + {disableIndicators !== true && mediaSourceIndicator} + + {playedIndicator && ( + + {playedIndicator} + + )} + + {playOnImageClick && ( + + )} + + {progressBar} + + ); +}; + +export default ListImageContainer; diff --git a/src/components/listview/List/ListItemBody.tsx b/src/components/listview/List/ListItemBody.tsx new file mode 100644 index 0000000000..847d46b4de --- /dev/null +++ b/src/components/listview/List/ListItemBody.tsx @@ -0,0 +1,65 @@ +import React, { type FC } from 'react'; +import classNames from 'classnames'; +import Box from '@mui/material/Box'; +import useListTextlines from './useListTextlines'; +import PrimaryMediaInfo from '../../mediainfo/PrimaryMediaInfo'; + +import type { ItemDto } from 'types/base/models/item-dto'; +import type { ListOptions } from 'types/listOptions'; + +interface ListItemBodyProps { + item: ItemDto; + listOptions: ListOptions; + action?: string | null; + isLargeStyle?: boolean; + clickEntireItem?: boolean; + enableContentWrapper?: boolean; + enableOverview?: boolean; + enableSideMediaInfo?: boolean; + getMissingIndicator: () => React.JSX.Element | null +} + +const ListItemBody: FC = ({ + item = {}, + listOptions = {}, + action, + isLargeStyle, + clickEntireItem, + enableContentWrapper, + enableOverview, + enableSideMediaInfo, + getMissingIndicator +}) => { + const { listTextLines } = useListTextlines({ item, listOptions, isLargeStyle }); + const cssClass = classNames( + 'listItemBody', + { 'itemAction': !clickEntireItem }, + { 'listItemBody-noleftpadding': listOptions.image === false } + ); + + return ( + + + {listTextLines} + + {listOptions.mediaInfo !== false && !enableSideMediaInfo && ( + + )} + + {!enableContentWrapper && enableOverview && item.Overview && ( + + {item.Overview} + + )} + + ); +}; + +export default ListItemBody; diff --git a/src/components/listview/List/ListTextWrapper.tsx b/src/components/listview/List/ListTextWrapper.tsx new file mode 100644 index 0000000000..675ebe99d4 --- /dev/null +++ b/src/components/listview/List/ListTextWrapper.tsx @@ -0,0 +1,30 @@ +import React, { type FC } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +interface ListTextWrapperProps { + index?: number; + isLargeStyle?: boolean; +} + +const ListTextWrapper: FC = ({ + index, + isLargeStyle, + children +}) => { + if (index === 0) { + if (isLargeStyle) { + return ( + + {children} + + ); + } else { + return {children}; + } + } else { + return {children}; + } +}; + +export default ListTextWrapper; diff --git a/src/components/listview/List/ListViewUserDataButtons.tsx b/src/components/listview/List/ListViewUserDataButtons.tsx new file mode 100644 index 0000000000..8a8b4ce901 --- /dev/null +++ b/src/components/listview/List/ListViewUserDataButtons.tsx @@ -0,0 +1,87 @@ +import React, { type FC } from 'react'; +import { Box } from '@mui/material'; +import itemHelper from '../../itemHelper'; +import PlayedButton from 'elements/emby-playstatebutton/PlayedButton'; +import FavoriteButton from 'elements/emby-ratingbutton/FavoriteButton'; +import PlaylistAddIconButton from '../../common/PlaylistAddIconButton'; +import InfoIconButton from '../../common/InfoIconButton'; +import RightIconButtons from '../../common/RightIconButtons'; +import MoreVertIconButton from '../../common/MoreVertIconButton'; + +import type { ItemDto } from 'types/base/models/item-dto'; +import type { ListOptions } from 'types/listOptions'; + +interface ListViewUserDataButtonsProps { + item: ItemDto; + listOptions: ListOptions; +} + +const ListViewUserDataButtons: FC = ({ + item = {}, + listOptions +}) => { + const { IsFavorite, Played } = item.UserData ?? {}; + + const renderRightButtons = () => { + return listOptions.rightButtons?.map((button, index) => ( + + )); + }; + + return ( + + {listOptions.addToListButton && ( + + + )} + {listOptions.infoButton && ( + + + ) } + + {listOptions.rightButtons && renderRightButtons()} + + {listOptions.enableUserDataButtons !== false && ( + <> + {itemHelper.canMarkPlayed(item) + && listOptions.enablePlayedButton !== false && ( + + )} + + {itemHelper.canRate(item) + && listOptions.enableRatingButton !== false && ( + + )} + + )} + + {listOptions.moreButton !== false && ( + + )} + + ); +}; + +export default ListViewUserDataButtons; diff --git a/src/components/listview/List/ListWrapper.tsx b/src/components/listview/List/ListWrapper.tsx new file mode 100644 index 0000000000..9b394f9839 --- /dev/null +++ b/src/components/listview/List/ListWrapper.tsx @@ -0,0 +1,48 @@ +import classNames from 'classnames'; +import React, { type FC } from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import layoutManager from '../../layoutManager'; +import type { DataAttributes } from 'types/dataAttributes'; + +interface ListWrapperProps { + index: number | undefined; + title?: string | null; + action?: string | null; + dataAttributes?: DataAttributes; + className?: string; +} + +const ListWrapper: FC = ({ + index, + action, + title, + className, + dataAttributes, + children +}) => { + if (layoutManager.tv) { + return ( + + ); + } else { + return ( + + {children} + + ); + } +}; + +export default ListWrapper; diff --git a/src/components/listview/List/Lists.tsx b/src/components/listview/List/Lists.tsx new file mode 100644 index 0000000000..1215851cc5 --- /dev/null +++ b/src/components/listview/List/Lists.tsx @@ -0,0 +1,56 @@ +import React, { type FC } from 'react'; +import { groupBy } from 'lodash-es'; +import Box from '@mui/material/Box'; +import { getIndex } from './listHelper'; +import ListGroupHeaderWrapper from './ListGroupHeaderWrapper'; +import List from './List'; + +import type { ItemDto } from 'types/base/models/item-dto'; +import type { ListOptions } from 'types/listOptions'; +import '../listview.scss'; + +interface ListsProps { + items: ItemDto[]; + listOptions?: ListOptions; +} + +const Lists: FC = ({ items = [], listOptions = {} }) => { + const groupedData = groupBy(items, (item) => { + if (listOptions.showIndex) { + return getIndex(item, listOptions); + } + return ''; + }); + + const renderListItem = (item: ItemDto, index: number) => { + return ( + + ); + }; + + return ( + <> + {Object.entries(groupedData).map( + ([itemGroupTitle, getItems], index) => ( + // eslint-disable-next-line react/no-array-index-key + + {itemGroupTitle && ( + + {itemGroupTitle} + + )} + {getItems.map((item) => renderListItem(item, index))} + + ) + )} + + ); +}; + +export default Lists; diff --git a/src/components/listview/List/listHelper.ts b/src/components/listview/List/listHelper.ts new file mode 100644 index 0000000000..d909feb568 --- /dev/null +++ b/src/components/listview/List/listHelper.ts @@ -0,0 +1,172 @@ +import { Api } from '@jellyfin/sdk'; +import { BaseItemKind, ImageType } from '@jellyfin/sdk/lib/generated-client'; +import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api'; +import globalize from 'scripts/globalize'; + +import type { ItemDto } from 'types/base/models/item-dto'; +import type { ListOptions } from 'types/listOptions'; + +const sortBySortName = (item: ItemDto): string => { + if (item.Type === BaseItemKind.Episode) { + return ''; + } + + // SortName + const name = (item.SortName ?? item.Name ?? '?')[0].toUpperCase(); + + const code = name.charCodeAt(0); + if (code < 65 || code > 90) { + return '#'; + } + + return name.toUpperCase(); +}; + +const sortByOfficialrating = (item: ItemDto): string => { + return item.OfficialRating ?? globalize.translate('Unrated'); +}; + +const sortByCommunityRating = (item: ItemDto): string => { + if (item.CommunityRating == null) { + return globalize.translate('Unrated'); + } + + return String(Math.floor(item.CommunityRating)); +}; + +const sortByCriticRating = (item: ItemDto): string => { + if (item.CriticRating == null) { + return globalize.translate('Unrated'); + } + + return String(Math.floor(item.CriticRating)); +}; + +const sortByAlbumArtist = (item: ItemDto): string => { + // SortName + if (!item.AlbumArtist) { + return ''; + } + + const name = item.AlbumArtist[0].toUpperCase(); + + const code = name.charCodeAt(0); + if (code < 65 || code > 90) { + return '#'; + } + + return name.toUpperCase(); +}; + +export function getIndex(item: ItemDto, listOptions: ListOptions): string { + if (listOptions.index === 'disc') { + return item.ParentIndexNumber == null ? + '' : + globalize.translate('ValueDiscNumber', item.ParentIndexNumber); + } + + const sortBy = (listOptions.sortBy ?? '').toLowerCase(); + + if (sortBy.startsWith('sortname')) { + return sortBySortName(item); + } + if (sortBy.startsWith('officialrating')) { + return sortByOfficialrating(item); + } + if (sortBy.startsWith('communityrating')) { + return sortByCommunityRating(item); + } + if (sortBy.startsWith('criticrating')) { + return sortByCriticRating(item); + } + if (sortBy.startsWith('albumartist')) { + return sortByAlbumArtist(item); + } + return ''; +} + +export function getImageUrl( + item: ItemDto, + api: Api | undefined, + size: number | undefined +) { + let imgTag; + let itemId; + const fillWidth = size; + const fillHeight = size; + const imgType = ImageType.Primary; + + if (item.ImageTags?.Primary) { + imgTag = item.ImageTags.Primary; + itemId = item.Id; + } else if (item.AlbumId && item.AlbumPrimaryImageTag) { + imgTag = item.AlbumPrimaryImageTag; + itemId = item.AlbumId; + } else if (item.SeriesId && item.SeriesPrimaryImageTag) { + imgTag = item.SeriesPrimaryImageTag; + itemId = item.SeriesId; + } else if (item.ParentPrimaryImageTag) { + imgTag = item.ParentPrimaryImageTag; + itemId = item.ParentPrimaryImageItemId; + } + + if (api && imgTag && imgType && itemId) { + const response = getImageApi(api).getItemImageUrlById(itemId, imgType, { + fillWidth: fillWidth, + fillHeight: fillHeight, + tag: imgTag + }); + + return { + imgUrl: response, + blurhash: item.ImageBlurHashes?.[imgType]?.[imgTag] + }; + } + + return { + imgUrl: undefined, + blurhash: undefined + }; +} + +export function getChannelImageUrl( + item: ItemDto, + api: Api | undefined, + size: number | undefined +) { + let imgTag; + let itemId; + const fillWidth = size; + const fillHeight = size; + const imgType = ImageType.Primary; + + if (item.ChannelId && item.ChannelPrimaryImageTag) { + imgTag = item.ChannelPrimaryImageTag; + itemId = item.ChannelId; + } + + if (api && imgTag && imgType && itemId) { + const response = api.getItemImageUrl(itemId, imgType, { + fillWidth: fillWidth, + fillHeight: fillHeight, + tag: imgTag + }); + + return { + imgUrl: response, + blurhash: item.ImageBlurHashes?.[imgType]?.[imgTag] + }; + } + + return { + imgUrl: undefined, + blurhash: undefined + }; +} + +export function canResume(PlaybackPositionTicks: number | undefined): boolean { + return Boolean( + PlaybackPositionTicks + && PlaybackPositionTicks > 0 + ); +} diff --git a/src/components/listview/List/useList.ts b/src/components/listview/List/useList.ts new file mode 100644 index 0000000000..196721a0dc --- /dev/null +++ b/src/components/listview/List/useList.ts @@ -0,0 +1,77 @@ +import classNames from 'classnames'; +import { getDataAttributes } from 'utils/items'; +import layoutManager from 'components/layoutManager'; + +import type { ItemDto } from 'types/base/models/item-dto'; +import type { ListOptions } from 'types/listOptions'; + +interface UseListProps { + item: ItemDto; + listOptions: ListOptions; +} + +function useList({ item, listOptions }: UseListProps) { + const action = listOptions.action ?? 'link'; + const isLargeStyle = listOptions.imageSize === 'large'; + const enableOverview = listOptions.enableOverview; + const clickEntireItem = !!layoutManager.tv; + const enableSideMediaInfo = listOptions.enableSideMediaInfo ?? true; + const enableContentWrapper = + listOptions.enableOverview && !layoutManager.tv; + const downloadWidth = isLargeStyle ? 500 : 80; + + const dataAttributes = getDataAttributes( + { + action, + itemServerId: item.ServerId, + itemId: item.Id, + collectionId: listOptions.collectionId, + playlistId: listOptions.playlistId, + itemChannelId: item.ChannelId, + itemType: item.Type, + itemMediaType: item.MediaType, + itemCollectionType: item.CollectionType, + itemIsFolder: item.IsFolder, + itemPlaylistItemId: item.PlaylistItemId + } + ); + + const listWrapperClass = classNames( + 'listItem', + { + 'listItem-border': + listOptions.border + ?? (listOptions.highlight !== false && !layoutManager.tv) + }, + { 'itemAction listItem-button': clickEntireItem }, + { 'listItem-focusscale': layoutManager.tv }, + { 'listItem-largeImage': isLargeStyle }, + { 'listItem-withContentWrapper': enableContentWrapper } + ); + + const getListdWrapperProps = () => ({ + className: listWrapperClass, + title: item.Name, + action, + dataAttributes + }); + + const getListContentProps = () => ({ + item, + listOptions, + enableContentWrapper, + enableOverview, + enableSideMediaInfo, + clickEntireItem, + action, + isLargeStyle, + downloadWidth + }); + + return { + getListdWrapperProps, + getListContentProps + }; +} + +export default useList; diff --git a/src/components/listview/List/useListTextlines.tsx b/src/components/listview/List/useListTextlines.tsx new file mode 100644 index 0000000000..490f94634d --- /dev/null +++ b/src/components/listview/List/useListTextlines.tsx @@ -0,0 +1,167 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; +import React from 'react'; +import itemHelper from '../../itemHelper'; +import datetime from 'scripts/datetime'; +import ListTextWrapper from './ListTextWrapper'; +import type { ItemDto } from 'types/base/models/item-dto'; +import type { ListOptions } from 'types/listOptions'; + +function getParentTitle( + showParentTitle: boolean | undefined, + item: ItemDto, + parentTitleWithTitle: boolean | undefined, + displayName: string | null | undefined +) { + let parentTitle = null; + if (showParentTitle) { + if (item.Type === BaseItemKind.Episode) { + parentTitle = item.SeriesName; + } else if (item.IsSeries || (item.EpisodeTitle && item.Name)) { + parentTitle = item.Name; + } + } + if (showParentTitle && parentTitleWithTitle) { + if (displayName) { + parentTitle += ' - '; + } + parentTitle = (parentTitle ?? '') + displayName; + } + return parentTitle; +} + +function getNameOrIndexWithName( + item: ItemDto, + listOptions: ListOptions, + showIndexNumber: boolean | undefined +) { + let displayName = itemHelper.getDisplayName(item, { + includeParentInfo: listOptions.includeParentInfoInTitle + }); + + if (showIndexNumber && item.IndexNumber != null) { + displayName = `${item.IndexNumber}. ${displayName}`; + } + return displayName; +} + +interface UseListTextlinesProps { + item: ItemDto; + listOptions?: ListOptions; + isLargeStyle?: boolean; +} + +function useListTextlines({ item = {}, listOptions = {}, isLargeStyle }: UseListTextlinesProps) { + const { + showProgramDateTime, + showProgramTime, + showChannel, + showParentTitle, + showIndexNumber, + parentTitleWithTitle, + artist + } = listOptions; + const textLines: string[] = []; + + const addTextLine = (text: string | null) => { + if (text) { + textLines.push(text); + } + }; + + const addProgramDateTime = () => { + if (showProgramDateTime) { + const programDateTime = datetime.toLocaleString( + datetime.parseISO8601Date(item.StartDate), + { + weekday: 'long', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + } + ); + addTextLine(programDateTime); + } + }; + + const addProgramTime = () => { + if (showProgramTime) { + const programTime = datetime.getDisplayTime( + datetime.parseISO8601Date(item.StartDate) + ); + addTextLine(programTime); + } + }; + + const addChannelName = () => { + if (showChannel && item.ChannelName) { + addTextLine(item.ChannelName); + } + }; + + const displayName = getNameOrIndexWithName(item, listOptions, showIndexNumber); + + const parentTitle = getParentTitle(showParentTitle, item, parentTitleWithTitle, displayName ); + + const addParentTitle = () => { + addTextLine(parentTitle ?? ''); + }; + + const addDisplayName = () => { + if (displayName && !parentTitleWithTitle) { + addTextLine(displayName); + } + }; + + const addAlbumArtistOrArtists = () => { + if (item.IsFolder && artist !== false) { + if (item.AlbumArtist && item.Type === BaseItemKind.MusicAlbum) { + addTextLine(item.AlbumArtist); + } + } else if (artist) { + const artistItems = item.ArtistItems; + if (artistItems && item.Type !== BaseItemKind.MusicAlbum) { + const artists = artistItems.map((a) => a.Name).join(', '); + addTextLine(artists); + } + } + }; + + const addCurrentProgram = () => { + if (item.Type === BaseItemKind.TvChannel && item.CurrentProgram) { + const currentProgram = itemHelper.getDisplayName( + item.CurrentProgram + ); + addTextLine(currentProgram); + } + }; + + addProgramDateTime(); + addProgramTime(); + addChannelName(); + addParentTitle(); + addDisplayName(); + addAlbumArtistOrArtists(); + addCurrentProgram(); + + const renderTextlines = (text: string, index: number) => { + return ( + + {text} + + ); + }; + + const listTextLines = textLines?.map((text, index) => renderTextlines(text, index)); + + return { + listTextLines + }; +} + +export default useListTextlines; diff --git a/src/components/listview/listview.scss b/src/components/listview/listview.scss index 2aafd936a2..ea829154bb 100644 --- a/src/components/listview/listview.scss +++ b/src/components/listview/listview.scss @@ -183,6 +183,7 @@ } .listItemImage .cardImageIcon { + margin: auto; font-size: 3em; } diff --git a/src/components/mediainfo/CaptionMediaInfo.tsx b/src/components/mediainfo/CaptionMediaInfo.tsx new file mode 100644 index 0000000000..497f9fae59 --- /dev/null +++ b/src/components/mediainfo/CaptionMediaInfo.tsx @@ -0,0 +1,25 @@ +import React, { type FC } from 'react'; +import classNames from 'classnames'; +import ClosedCaptionIcon from '@mui/icons-material/ClosedCaption'; +import Box from '@mui/material/Box'; + +interface CaptionMediaInfoProps { + className?: string; +} + +const CaptionMediaInfo: FC = ({ className }) => { + const cssClass = classNames( + 'mediaInfoItem', + 'mediaInfoText', + 'closedCaptionMediaInfoText', + className + ); + + return ( + + + + ); +}; + +export default CaptionMediaInfo; diff --git a/src/components/mediainfo/CriticRatingMediaInfo.tsx b/src/components/mediainfo/CriticRatingMediaInfo.tsx new file mode 100644 index 0000000000..8046c2a931 --- /dev/null +++ b/src/components/mediainfo/CriticRatingMediaInfo.tsx @@ -0,0 +1,25 @@ +import React, { type FC } from 'react'; +import classNames from 'classnames'; +import Box from '@mui/material/Box'; + +interface CriticRatingMediaInfoProps { + className?: string; + criticRating: number; +} + +const CriticRatingMediaInfo: FC = ({ + className, + criticRating +}) => { + const cssClass = classNames( + 'mediaInfoCriticRating', + 'mediaInfoItem', + criticRating >= 60 ? + 'mediaInfoCriticRatingFresh' : + 'mediaInfoCriticRatingRotten', + className + ); + return {criticRating}; +}; + +export default CriticRatingMediaInfo; diff --git a/src/components/mediainfo/EndsAt.tsx b/src/components/mediainfo/EndsAt.tsx new file mode 100644 index 0000000000..373e9817d7 --- /dev/null +++ b/src/components/mediainfo/EndsAt.tsx @@ -0,0 +1,31 @@ +import React, { type FC } from 'react'; +import classNames from 'classnames'; +import Box from '@mui/material/Box'; +import datetime from 'scripts/datetime'; +import globalize from 'scripts/globalize'; + +interface EndsAtProps { + className?: string; + runTimeTicks: number +} + +const EndsAt: FC = ({ runTimeTicks, className }) => { + const cssClass = classNames( + 'mediaInfoItem', + 'mediaInfoText', + 'endsAt', + className + ); + + const endTime = new Date().getTime() + (runTimeTicks / 10000); + const endDate = new Date(endTime); + const displayTime = datetime.getDisplayTime(endDate); + + return ( + + {globalize.translate('EndsAtValue', displayTime)} + + ); +}; + +export default EndsAt; diff --git a/src/components/mediainfo/MediaInfoItem.tsx b/src/components/mediainfo/MediaInfoItem.tsx new file mode 100644 index 0000000000..d38635ac2d --- /dev/null +++ b/src/components/mediainfo/MediaInfoItem.tsx @@ -0,0 +1,27 @@ +import React, { type FC } from 'react'; +import Box from '@mui/material/Box'; +import classNames from 'classnames'; +import type { MiscInfo } from 'types/mediaInfoItem'; + +interface MediaInfoItemProps { + className?: string; + miscInfo?: MiscInfo ; + +} + +const MediaInfoItem: FC = ({ className, miscInfo }) => { + const cssClass = classNames( + 'mediaInfoItem', + 'mediaInfoText', + className, + miscInfo?.cssClass + ); + + return ( + + {miscInfo?.text} + + ); +}; + +export default MediaInfoItem; diff --git a/src/components/mediainfo/PrimaryMediaInfo.tsx b/src/components/mediainfo/PrimaryMediaInfo.tsx new file mode 100644 index 0000000000..c68f823b9b --- /dev/null +++ b/src/components/mediainfo/PrimaryMediaInfo.tsx @@ -0,0 +1,103 @@ +import React, { type FC } from 'react'; +import classNames from 'classnames'; +import Box from '@mui/material/Box'; +import usePrimaryMediaInfo from './usePrimaryMediaInfo'; + +import MediaInfoItem from './MediaInfoItem'; +import StarIcons from './StarIcons'; +import CaptionMediaInfo from './CaptionMediaInfo'; +import CriticRatingMediaInfo from './CriticRatingMediaInfo'; +import EndsAt from './EndsAt'; +import type { ItemDto } from 'types/base/models/item-dto'; +import type { MiscInfo } from 'types/mediaInfoItem'; + +interface PrimaryMediaInfoProps { + className?: string; + item: ItemDto; + isYearEnabled?: boolean; + isContainerEnabled?: boolean; + isEpisodeTitleEnabled?: boolean; + isCriticRatingEnabled?: boolean; + isEndsAtEnabled?: boolean; + isOriginalAirDateEnabled?: boolean; + isRuntimeEnabled?: boolean; + isProgramIndicatorEnabled?: boolean; + isEpisodeTitleIndexNumberEnabled?: boolean; + isOfficialRatingEnabled?: boolean; + isStarRatingEnabled?: boolean; + isCaptionIndicatorEnabled?: boolean; + isMissingIndicatorEnabled?: boolean; + getMissingIndicator: () => React.JSX.Element | null +} + +const PrimaryMediaInfo: FC = ({ + className, + item, + isYearEnabled = false, + isContainerEnabled = false, + isEpisodeTitleEnabled = false, + isCriticRatingEnabled = false, + isEndsAtEnabled = false, + isOriginalAirDateEnabled = false, + isRuntimeEnabled = false, + isProgramIndicatorEnabled = false, + isEpisodeTitleIndexNumberEnabled = false, + isOfficialRatingEnabled = false, + isStarRatingEnabled = false, + isCaptionIndicatorEnabled = false, + isMissingIndicatorEnabled = false, + getMissingIndicator +}) => { + const miscInfo = usePrimaryMediaInfo({ + item, + isYearEnabled, + isContainerEnabled, + isEpisodeTitleEnabled, + isOriginalAirDateEnabled, + isRuntimeEnabled, + isProgramIndicatorEnabled, + isEpisodeTitleIndexNumberEnabled, + isOfficialRatingEnabled + }); + const { + StartDate, + HasSubtitles, + MediaType, + RunTimeTicks, + CommunityRating, + CriticRating + } = item; + + const cssClass = classNames(className); + + const renderMediaInfo = (info: MiscInfo | undefined, index: number) => ( + + ); + + return ( + + {miscInfo.map((info, index) => renderMediaInfo(info, index))} + + {isStarRatingEnabled && CommunityRating && ( + + )} + + {HasSubtitles && isCaptionIndicatorEnabled && } + + {CriticRating && isCriticRatingEnabled && ( + + )} + + {isEndsAtEnabled + && MediaType === 'Video' + && RunTimeTicks + && !StartDate && } + + {isMissingIndicatorEnabled && ( + getMissingIndicator() + )} + + ); +}; + +export default PrimaryMediaInfo; diff --git a/src/components/mediainfo/StarIcons.tsx b/src/components/mediainfo/StarIcons.tsx new file mode 100644 index 0000000000..0d38453d7e --- /dev/null +++ b/src/components/mediainfo/StarIcons.tsx @@ -0,0 +1,31 @@ +import React, { type FC } from 'react'; +import classNames from 'classnames'; +import StarIcon from '@mui/icons-material/Star'; +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; + +interface StarIconsProps { + className?: string; + communityRating: number; +} + +const StarIcons: FC = ({ className, communityRating }) => { + const theme = useTheme(); + const cssClass = classNames( + 'mediaInfoItem', + 'mediaInfoText', + 'starRatingContainer', + className + ); + + return ( + + + {communityRating.toFixed(1)} + + ); +}; + +export default StarIcons; diff --git a/src/components/mediainfo/usePrimaryMediaInfo.tsx b/src/components/mediainfo/usePrimaryMediaInfo.tsx new file mode 100644 index 0000000000..d41eea0ec3 --- /dev/null +++ b/src/components/mediainfo/usePrimaryMediaInfo.tsx @@ -0,0 +1,523 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; +import * as userSettings from 'scripts/settings/userSettings'; +import datetime from 'scripts/datetime'; +import globalize from 'scripts/globalize'; +import itemHelper from '../itemHelper'; +import type { NullableNumber, NullableString } from 'types/base/common/shared/types'; +import type { ItemDto } from 'types/base/models/item-dto'; +import type { MiscInfo } from 'types/mediaInfoItem'; + +function shouldShowFolderRuntime( + itemType: NullableString, + itemMediaType: NullableString +): boolean { + return ( + itemType === BaseItemKind.MusicAlbum + || itemMediaType === 'MusicArtist' + || itemType === BaseItemKind.Playlist + || itemMediaType === 'Playlist' + || itemMediaType === 'MusicGenre' + ); +} + +function addTrackCountOrItemCount( + showFolderRuntime: boolean, + itemSongCount: NullableNumber, + itemChildCount: NullableNumber, + itemRunTimeTicks: NullableNumber, + itemType: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + if (showFolderRuntime) { + const count = itemSongCount ?? itemChildCount; + if (count) { + addMiscInfo({ text: globalize.translate('TrackCount', count) }); + } + + if (itemRunTimeTicks) { + addMiscInfo({ text: datetime.getDisplayDuration(itemRunTimeTicks) }); + } + } else if (itemType === BaseItemKind.PhotoAlbum || itemType === BaseItemKind.BoxSet) { + const count = itemChildCount; + if (count) { + addMiscInfo({ text: globalize.translate('ItemCount', count) }); + } + } +} + +function addOriginalAirDateInfo( + itemType: NullableString, + itemMediaType: NullableString, + isOriginalAirDateEnabled: boolean, + itemPremiereDate: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + if ( + itemPremiereDate + && (itemType === BaseItemKind.Episode || itemMediaType === 'Photo') + && isOriginalAirDateEnabled + ) { + try { + //don't modify date to locale if episode. Only Dates (not times) are stored, or editable in the edit metadata dialog + const date = datetime.parseISO8601Date( + itemPremiereDate, + itemType !== BaseItemKind.Episode + ); + addMiscInfo({ text: datetime.toLocaleDateString(date) }); + } catch (e) { + console.error('error parsing date:', itemPremiereDate); + } + } +} + +function addSeriesTimerInfo( + itemType: NullableString, + itemRecordAnyTime: boolean | undefined, + itemStartDate: NullableString, + itemRecordAnyChannel: boolean | undefined, + itemChannelName: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + if (itemType === 'SeriesTimer') { + if (itemRecordAnyTime) { + addMiscInfo({ text: globalize.translate('Anytime') }); + } else { + addMiscInfo({ text: datetime.getDisplayTime(itemStartDate) }); + } + + if (itemRecordAnyChannel) { + addMiscInfo({ text: globalize.translate('AllChannels') }); + } else { + addMiscInfo({ + text: itemChannelName ?? globalize.translate('OneChannel') + }); + } + } +} + +function addProgramIndicatorInfo( + program: ItemDto | undefined, + addMiscInfo: (val: MiscInfo) => void +): void { + if ( + program?.IsLive + && userSettings.get('guide-indicator-live', false) === 'true' + ) { + addMiscInfo({ + text: globalize.translate('Live'), + cssClass: 'mediaInfoProgramAttribute liveTvProgram' + }); + } else if ( + program?.IsPremiere + && userSettings.get('guide-indicator-premiere', false) === 'true' + ) { + addMiscInfo({ + text: globalize.translate('Premiere'), + cssClass: 'mediaInfoProgramAttribute premiereTvProgram' + }); + } else if ( + program?.IsSeries + && !program?.IsRepeat + && userSettings.get('guide-indicator-new', false) === 'true' + ) { + addMiscInfo({ + text: globalize.translate('New'), + cssClass: 'mediaInfoProgramAttribute newTvProgram' + }); + } else if ( + program?.IsSeries + && program?.IsRepeat + && userSettings.get('guide-indicator-repeat', false) === 'true' + ) { + addMiscInfo({ + text: globalize.translate('Repeat'), + cssClass: 'mediaInfoProgramAttribute repeatTvProgram' + }); + } +} + +function addProgramIndicators( + item: ItemDto, + isYearEnabled: boolean, + isEpisodeTitleEnabled: boolean, + isOriginalAirDateEnabled: boolean, + isProgramIndicatorEnabled: boolean, + isEpisodeTitleIndexNumberEnabled: boolean, + addMiscInfo: (val: MiscInfo) => void +): void { + if (item.Type === BaseItemKind.Program || item.Type === 'Timer') { + let program = item; + if (item.Type === 'Timer' && item.ProgramInfo) { + program = item.ProgramInfo; + } + + if (isProgramIndicatorEnabled !== false) { + addProgramIndicatorInfo(program, addMiscInfo); + } + + addProgramTextInfo( + program, + isEpisodeTitleEnabled, + isEpisodeTitleIndexNumberEnabled, + isOriginalAirDateEnabled, + isYearEnabled, + addMiscInfo + ); + } +} + +function addProgramTextInfo( + program: ItemDto, + isEpisodeTitleEnabled: boolean, + isEpisodeTitleIndexNumberEnabled: boolean, + isOriginalAirDateEnabled: boolean, + isYearEnabled: boolean, + addMiscInfo: (val: MiscInfo) => void +): void { + if ((program?.IsSeries || program?.EpisodeTitle) + && isEpisodeTitleEnabled !== false) { + const text = itemHelper.getDisplayName(program, { + includeIndexNumber: isEpisodeTitleIndexNumberEnabled + }); + + if (text) { + addMiscInfo({ text: text }); + } + } else if ( + program?.ProductionYear + && ((program?.IsMovie && isOriginalAirDateEnabled !== false) + || isYearEnabled !== false) + ) { + addMiscInfo({ text: program.ProductionYear }); + } else if (program?.PremiereDate && isOriginalAirDateEnabled !== false) { + try { + const date = datetime.parseISO8601Date(program.PremiereDate); + const text = globalize.translate( + 'OriginalAirDateValue', + datetime.toLocaleDateString(date) + ); + addMiscInfo({ text: text }); + } catch (e) { + console.error('error parsing date:', program.PremiereDate); + } + } +} + +function addStartDateInfo( + itemStartDate: NullableString, + itemType: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + if ( + itemStartDate + && itemType !== BaseItemKind.Program + && itemType !== 'SeriesTimer' + && itemType !== 'Timer' + ) { + try { + const date = datetime.parseISO8601Date(itemStartDate); + addMiscInfo({ text: datetime.toLocaleDateString(date) }); + + if (itemType !== BaseItemKind.Recording) { + addMiscInfo({ text: datetime.getDisplayTime(date) }); + } + } catch (e) { + console.error('error parsing date:', itemStartDate); + } + } +} + +function addSeriesProductionYearInfo( + itemProductionYear: NullableNumber, + itemType: NullableString, + isYearEnabled: boolean, + itemStatus: NullableString, + itemEndDate: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + if (itemProductionYear && isYearEnabled && itemType === BaseItemKind.Series) { + if (itemStatus === 'Continuing') { + addMiscInfo({ + text: globalize.translate( + 'SeriesYearToPresent', + datetime.toLocaleString(itemProductionYear, { + useGrouping: false + }) + ) + }); + } else { + addproductionYearWithEndDate(itemProductionYear, itemEndDate, addMiscInfo); + } + } +} + +function addproductionYearWithEndDate( + itemProductionYear: number, + itemEndDate: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + let productionYear = datetime.toLocaleString(itemProductionYear, { + useGrouping: false + }); + + if (itemEndDate) { + try { + const endYear = datetime.toLocaleString( + datetime.parseISO8601Date(itemEndDate).getFullYear(), + { useGrouping: false } + ); + /* At this point, text will contain only the start year */ + if (endYear !== itemProductionYear) { + productionYear += `-${endYear}`; + } + } catch (e) { + console.error('error parsing date:', itemEndDate); + } + } + addMiscInfo({ text: productionYear }); +} + +function addYearInfo( + isYearEnabled: boolean, + itemType: NullableString, + itemMediaType: NullableString, + itemProductionYear: NullableNumber, + itemPremiereDate: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + if ( + isYearEnabled + && itemType !== BaseItemKind.Series + && itemType !== BaseItemKind.Episode + && itemType !== BaseItemKind.Person + && itemMediaType !== 'Photo' + && itemType !== BaseItemKind.Program + && itemType !== BaseItemKind.Season + ) { + if (itemProductionYear) { + addMiscInfo({ text: itemProductionYear }); + } else if (itemPremiereDate) { + try { + const text = datetime.toLocaleString( + datetime.parseISO8601Date(itemPremiereDate).getFullYear(), + { useGrouping: false } + ); + addMiscInfo({ text: text }); + } catch (e) { + console.error('error parsing date:', itemPremiereDate); + } + } + } +} + +function addVideo3DFormat( + itemVideo3DFormat: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + if (itemVideo3DFormat) { + addMiscInfo({ text: '3D' }); + } +} + +function addRunTimeInfo( + itemRunTimeTicks: NullableNumber, + itemType: NullableString, + showFolderRuntime: boolean, + isRuntimeEnabled: boolean, + addMiscInfo: (val: MiscInfo) => void +): void { + if ( + itemRunTimeTicks + && itemType !== BaseItemKind.Series + && itemType !== BaseItemKind.Program + && itemType !== 'Timer' + && itemType !== BaseItemKind.Book + && !showFolderRuntime + && isRuntimeEnabled + ) { + if (itemType === BaseItemKind.Audio) { + addMiscInfo({ text: datetime.getDisplayRunningTime(itemRunTimeTicks) }); + } else { + addMiscInfo({ text: datetime.getDisplayDuration(itemRunTimeTicks) }); + } + } +} + +function addOfficialRatingInfo( + itemOfficialRating: NullableString, + itemType: NullableString, + isOfficialRatingEnabled: boolean, + addMiscInfo: (val: MiscInfo) => void +): void { + if ( + itemOfficialRating + && isOfficialRatingEnabled + && itemType !== BaseItemKind.Season + && itemType !== BaseItemKind.Episode + ) { + addMiscInfo({ + text: itemOfficialRating, + cssClass: 'mediaInfoOfficialRating' + }); + } +} + +function addAudioContainer( + itemContainer: NullableString, + isContainerEnabled: boolean, + itemType: NullableString, + addMiscInfo: (val: MiscInfo) => void +): void { + if (itemContainer && isContainerEnabled && itemType === BaseItemKind.Audio) { + addMiscInfo({ text: itemContainer }); + } +} + +function addPhotoSize( + itemMediaType: NullableString, + itemWidth: NullableNumber, + itemHeight: NullableNumber, + addMiscInfo: (val: MiscInfo) => void +): void { + if (itemMediaType === 'Photo' && itemWidth && itemHeight) { + const size = `${itemWidth}x${itemHeight}`; + + addMiscInfo({ text: size }); + } +} + +interface UsePrimaryMediaInfoProps { + item: ItemDto; + isYearEnabled: boolean; + isContainerEnabled: boolean; + isEpisodeTitleEnabled: boolean; + isOriginalAirDateEnabled: boolean; + isRuntimeEnabled: boolean; + isProgramIndicatorEnabled: boolean; + isEpisodeTitleIndexNumberEnabled: boolean; + isOfficialRatingEnabled: boolean; +} + +function usePrimaryMediaInfo({ + item, + isYearEnabled = false, + isContainerEnabled = false, + isEpisodeTitleEnabled = false, + isOriginalAirDateEnabled = false, + isRuntimeEnabled = false, + isProgramIndicatorEnabled = false, + isEpisodeTitleIndexNumberEnabled = false, + isOfficialRatingEnabled = false +}: UsePrimaryMediaInfoProps) { + const { + EndDate, + Status, + StartDate, + ProductionYear, + Video3DFormat, + Type, + Width, + Height, + MediaType, + SongCount, + RecordAnyTime, + RecordAnyChannel, + ChannelName, + ChildCount, + RunTimeTicks, + PremiereDate, + OfficialRating, + Container + } = item; + + const miscInfo: MiscInfo[] = []; + + const addMiscInfo = (val: MiscInfo) => { + if (val) { + miscInfo.push(val); + } + }; + + const showFolderRuntime = shouldShowFolderRuntime(Type, MediaType); + + addTrackCountOrItemCount( + showFolderRuntime, + SongCount, + ChildCount, + RunTimeTicks, + Type, + addMiscInfo + ); + + addOriginalAirDateInfo( + Type, + MediaType, + isOriginalAirDateEnabled, + PremiereDate, + addMiscInfo + ); + + addSeriesTimerInfo( + Type, + RecordAnyTime, + StartDate, + RecordAnyChannel, + ChannelName, + addMiscInfo + ); + + addStartDateInfo(StartDate, Type, addMiscInfo); + + addSeriesProductionYearInfo( + ProductionYear, + Type, + isYearEnabled, + Status, + EndDate, + addMiscInfo + ); + + addProgramIndicators( + item, + isProgramIndicatorEnabled, + isEpisodeTitleEnabled, + isEpisodeTitleIndexNumberEnabled, + isOriginalAirDateEnabled, + isYearEnabled, + addMiscInfo + ); + + addYearInfo( + isYearEnabled, + Type, + MediaType, + ProductionYear, + PremiereDate, + addMiscInfo + ); + + addRunTimeInfo( + RunTimeTicks, + Type, + showFolderRuntime, + isRuntimeEnabled, + addMiscInfo + ); + + addOfficialRatingInfo( + OfficialRating, + Type, + isOfficialRatingEnabled, + addMiscInfo + ); + + addVideo3DFormat(Video3DFormat, addMiscInfo); + + addPhotoSize(MediaType, Width, Height, addMiscInfo); + + addAudioContainer(Container, isContainerEnabled, Type, addMiscInfo); + + return miscInfo; +} + +export default usePrimaryMediaInfo; diff --git a/src/components/metadataEditor/metadataEditor.js b/src/components/metadataEditor/metadataEditor.js index b1c9d721b3..25202a719b 100644 --- a/src/components/metadataEditor/metadataEditor.js +++ b/src/components/metadataEditor/metadataEditor.js @@ -153,6 +153,7 @@ function onSubmit(e) { DateCreated: getDateValue(form, '#txtDateAdded', 'DateCreated'), EndDate: getDateValue(form, '#txtEndDate', 'EndDate'), ProductionYear: form.querySelector('#txtProductionYear').value, + Height: form.querySelector('#selectHeight').value, AspectRatio: form.querySelector('#txtOriginalAspectRatio').value, Video3DFormat: form.querySelector('#select3dFormat').value, @@ -270,7 +271,7 @@ function showMoreMenu(context, button, user) { } else if (result.updated) { reload(context, item.Id, item.ServerId); } - }); + }).catch(() => { /* no-op */ }); }); } @@ -650,6 +651,12 @@ function setFieldVisibilities(context, item) { hideElement('#fldPlaceOfBirth'); } + if (item.MediaType === 'Video' && item.Type === 'TvChannel') { + showElement('#fldHeight'); + } else { + hideElement('#fldHeight'); + } + if (item.MediaType === 'Video' && item.Type !== 'TvChannel') { showElement('#fldOriginalAspectRatio'); } else { @@ -828,6 +835,8 @@ function fillItemInfo(context, item, parentalRatingOptions) { const placeofBirth = item.ProductionLocations?.length ? item.ProductionLocations[0] : ''; context.querySelector('#txtPlaceOfBirth').value = placeofBirth; + context.querySelector('#selectHeight').value = item.Height || ''; + context.querySelector('#txtOriginalAspectRatio').value = item.AspectRatio || ''; context.querySelector('#selectLanguage').value = item.PreferredMetadataLanguage || ''; diff --git a/src/components/metadataEditor/metadataEditor.template.html b/src/components/metadataEditor/metadataEditor.template.html index ce769bbba7..6c01c2fc07 100644 --- a/src/components/metadataEditor/metadataEditor.template.html +++ b/src/components/metadataEditor/metadataEditor.template.html @@ -142,6 +142,16 @@
+
+ +
diff --git a/src/components/multiSelect/multiSelect.js b/src/components/multiSelect/multiSelect.js index 48cbce0e1f..d69c7b33bd 100644 --- a/src/components/multiSelect/multiSelect.js +++ b/src/components/multiSelect/multiSelect.js @@ -6,7 +6,6 @@ import dom from '../../scripts/dom'; import './multiSelect.scss'; import ServerConnections from '../ServerConnections'; import alert from '../alert'; -import PlaylistEditor from '../playlisteditor/playlisteditor'; import confirm from '../confirm/confirm'; import itemHelper from '../itemHelper'; import datetime from '../../scripts/datetime'; @@ -269,9 +268,16 @@ function showMenuForSelectedItems(e) { dispatchNeedsRefresh(); break; case 'playlist': - new PlaylistEditor({ - items: items, - serverId: serverId + import('../playlisteditor/playlisteditor').then(({ default: PlaylistEditor }) => { + const playlistEditor = new PlaylistEditor(); + playlistEditor.show({ + items: items, + serverId: serverId + }).catch(() => { + // Dialog closed + }); + }).catch(err => { + console.error('[AddToPlaylist] failed to load playlist editor', err); }); hideSelections(); dispatchNeedsRefresh(); diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index cf8cf41c29..eb9d40a83b 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -1,3 +1,7 @@ +import { PlaybackErrorCode } from '@jellyfin/sdk/lib/generated-client/models/playback-error-code.js'; +import merge from 'lodash-es/merge'; +import Screenfull from 'screenfull'; + import Events from '../../utils/events.ts'; import datetime from '../../scripts/datetime'; import appSettings from '../../scripts/settings/appSettings'; @@ -8,14 +12,15 @@ import * as userSettings from '../../scripts/settings/userSettings'; import globalize from '../../scripts/globalize'; import loading from '../loading/loading'; import { appHost } from '../apphost'; -import Screenfull from 'screenfull'; import ServerConnections from '../ServerConnections'; import alert from '../alert'; import { PluginType } from '../../types/plugin.ts'; import { includesAny } from '../../utils/container.ts'; import { getItems } from '../../utils/jellyfin-apiclient/getItems.ts'; import { getItemBackdropImageUrl } from '../../utils/jellyfin-apiclient/backdropImage'; -import merge from 'lodash-es/merge'; + +import { MediaError } from 'types/mediaError'; +import { getMediaError } from 'utils/mediaError'; const UNLIMITED_ITEMS = -1; @@ -125,7 +130,7 @@ function getItemsForPlayback(serverId, query) { } else { query.Limit = query.Limit || 300; } - query.Fields = 'Chapters'; + query.Fields = ['Chapters', 'Trickplay']; query.ExcludeLocationTypes = 'Virtual'; query.EnableTotalRecordCount = false; query.CollapseBoxSetItems = false; @@ -588,9 +593,18 @@ function supportsDirectPlay(apiClient, item, mediaSource) { return Promise.resolve(false); } +/** + * @param {PlaybackManager} instance + * @param {import('@jellyfin/sdk/lib/generated-client/index.js').PlaybackInfoResponse} result + * @returns {boolean} + */ function validatePlaybackInfoResult(instance, result) { if (result.ErrorCode) { - showPlaybackInfoErrorMessage(instance, 'PlaybackError' + result.ErrorCode); + // NOTE: To avoid needing to retranslate the "NoCompatibleStream" message, + // we need to keep the key in the same format. + const errMessage = result.ErrorCode === PlaybackErrorCode.NoCompatibleStream ? + 'PlaybackErrorNoCompatibleStream' : `PlaybackError.${result.ErrorCode}`; + showPlaybackInfoErrorMessage(instance, errMessage); return false; } @@ -1720,7 +1734,8 @@ class PlaybackManager { streamInfo.resetSubtitleOffset = false; if (!streamInfo.url) { - showPlaybackInfoErrorMessage(self, 'PlaybackErrorNoCompatibleStream'); + cancelPlayback(); + showPlaybackInfoErrorMessage(self, `PlaybackError.${MediaError.NO_MEDIA_ERROR}`); return; } @@ -1768,8 +1783,8 @@ class PlaybackManager { playerData.isChangingStream = false; onPlaybackError.call(player, e, { - type: 'mediadecodeerror', - streamInfo: streamInfo + type: getMediaError(e), + streamInfo }); }); } @@ -1858,7 +1873,7 @@ class PlaybackManager { IsVirtualUnaired: false, IsMissing: false, UserId: apiClient.getCurrentUserId(), - Fields: 'Chapters' + Fields: ['Chapters', 'Trickplay'] }).then(function (episodesResult) { const originalResults = episodesResult.Items; const isSeries = firstItem.Type === 'Series'; @@ -1940,7 +1955,7 @@ class PlaybackManager { IsVirtualUnaired: false, IsMissing: false, UserId: apiClient.getCurrentUserId(), - Fields: 'Chapters' + Fields: ['Chapters', 'Trickplay'] }).then(function (episodesResult) { let foundItem = false; episodesResult.Items = episodesResult.Items.filter(function (e) { @@ -2179,7 +2194,7 @@ class PlaybackManager { // If it's still null then there's nothing to play if (!firstItem) { - showPlaybackInfoErrorMessage(self, 'PlaybackErrorNoCompatibleStream'); + showPlaybackInfoErrorMessage(self, `PlaybackError.${MediaError.NO_MEDIA_ERROR}`); return Promise.reject(); } @@ -2551,8 +2566,8 @@ class PlaybackManager { onPlaybackStarted(player, playOptions, streamInfo, mediaSource); setTimeout(function () { onPlaybackError.call(player, err, { - type: 'mediadecodeerror', - streamInfo: streamInfo + type: getMediaError(err), + streamInfo }); }, 100); }); @@ -2785,7 +2800,7 @@ class PlaybackManager { return mediaSource; } } else { - showPlaybackInfoErrorMessage(self, 'PlaybackErrorNoCompatibleStream'); + showPlaybackInfoErrorMessage(self, `PlaybackError.${MediaError.NO_MEDIA_ERROR}`); return Promise.reject(); } }); @@ -3194,22 +3209,32 @@ class PlaybackManager { } } + /** + * @param {object} streamInfo + * @param {MediaError} errorType + * @param {boolean} currentlyPreventsVideoStreamCopy + * @param {boolean} currentlyPreventsAudioStreamCopy + * @returns {boolean} Returns true if the stream should be retried by transcoding. + */ function enablePlaybackRetryWithTranscoding(streamInfo, errorType, currentlyPreventsVideoStreamCopy, currentlyPreventsAudioStreamCopy) { - // mediadecodeerror, medianotsupported, network, servererror return streamInfo.mediaSource.SupportsTranscoding && (!currentlyPreventsVideoStreamCopy || !currentlyPreventsAudioStreamCopy); } + /** + * Playback error handler. + * @param {Error} e + * @param {object} error + * @param {object} error.streamInfo + * @param {MediaError} error.type + */ function onPlaybackError(e, error) { const player = this; error = error || {}; - // network - // mediadecodeerror - // medianotsupported const errorType = error.type; - console.debug('playbackmanager playback error type: ' + (errorType || '')); + console.warn('[playbackmanager] onPlaybackError:', e, error); const streamInfo = error.streamInfo || getPlayerData(player).streamInfo; @@ -3235,8 +3260,7 @@ class PlaybackManager { Events.trigger(self, 'playbackerror', [errorType]); - const displayErrorCode = 'NoCompatibleStream'; - onPlaybackStopped.call(player, e, displayErrorCode); + onPlaybackStopped.call(player, e, `.${errorType}`); } function onPlaybackStopped(e, displayErrorCode) { diff --git a/src/components/playbackSettings/playbackSettings.js b/src/components/playbackSettings/playbackSettings.js index d351c9e49f..254389a9f6 100644 --- a/src/components/playbackSettings/playbackSettings.js +++ b/src/components/playbackSettings/playbackSettings.js @@ -179,6 +179,7 @@ function loadForm(context, user, userSettings, systemInfo, apiClient) { context.querySelector('.chkRememberAudioSelections').checked = user.Configuration.RememberAudioSelections || false; context.querySelector('.chkRememberSubtitleSelections').checked = user.Configuration.RememberSubtitleSelections || false; context.querySelector('.chkExternalVideoPlayer').checked = appSettings.enableSystemExternalPlayers(); + context.querySelector('.chkLimitSupportedVideoResolution').checked = appSettings.limitSupportedVideoResolution(); setMaxBitrateIntoField(context.querySelector('.selectVideoInNetworkQuality'), true, 'Video'); setMaxBitrateIntoField(context.querySelector('.selectVideoInternetQuality'), false, 'Video'); @@ -194,8 +195,8 @@ function loadForm(context, user, userSettings, systemInfo, apiClient) { selectChromecastVersion.innerHTML = ccAppsHtml; selectChromecastVersion.value = user.Configuration.CastReceiverId; - const selectLabelMaxVideoWidth = context.querySelector('.selectLabelMaxVideoWidth'); - selectLabelMaxVideoWidth.value = appSettings.maxVideoWidth(); + const selectMaxVideoWidth = context.querySelector('.selectMaxVideoWidth'); + selectMaxVideoWidth.value = appSettings.maxVideoWidth(); const selectSkipForwardLength = context.querySelector('.selectSkipForwardLength'); fillSkipLengths(selectSkipForwardLength); @@ -212,7 +213,8 @@ function saveUser(context, user, userSettingsInstance, apiClient) { appSettings.enableSystemExternalPlayers(context.querySelector('.chkExternalVideoPlayer').checked); appSettings.maxChromecastBitrate(context.querySelector('.selectChromecastVideoQuality').value); - appSettings.maxVideoWidth(context.querySelector('.selectLabelMaxVideoWidth').value); + appSettings.maxVideoWidth(context.querySelector('.selectMaxVideoWidth').value); + appSettings.limitSupportedVideoResolution(context.querySelector('.chkLimitSupportedVideoResolution').checked); setMaxBitrateFromField(context.querySelector('.selectVideoInNetworkQuality'), true, 'Video'); setMaxBitrateFromField(context.querySelector('.selectVideoInternetQuality'), false, 'Video'); @@ -309,7 +311,7 @@ function embed(options, self) { options.element.querySelector('.btnSave').classList.remove('hide'); } - options.element.querySelector('.selectLabelMaxVideoWidth').addEventListener('change', onMaxVideoWidthChange.bind(self)); + options.element.querySelector('.selectMaxVideoWidth').addEventListener('change', onMaxVideoWidthChange.bind(self)); self.loadData(); diff --git a/src/components/playbackSettings/playbackSettings.template.html b/src/components/playbackSettings/playbackSettings.template.html index 7223b7e7bb..6dc860260f 100644 --- a/src/components/playbackSettings/playbackSettings.template.html +++ b/src/components/playbackSettings/playbackSettings.template.html @@ -43,7 +43,7 @@
- @@ -54,6 +54,14 @@
+ +
+ +
${LimitSupportedVideoResolutionHelp}
+
diff --git a/src/components/playlisteditor/playlisteditor.js b/src/components/playlisteditor/playlisteditor.js index ee7cd2b41a..625135a472 100644 --- a/src/components/playlisteditor/playlisteditor.js +++ b/src/components/playlisteditor/playlisteditor.js @@ -222,7 +222,7 @@ function centerFocus(elem, horiz, on) { } export class PlaylistEditor { - constructor(options) { + show(options) { const items = options.items || {}; currentServerId = options.serverId; diff --git a/src/components/remotecontrol/remotecontrol.js b/src/components/remotecontrol/remotecontrol.js index 1120e03c02..9406e16e2b 100644 --- a/src/components/remotecontrol/remotecontrol.js +++ b/src/components/remotecontrol/remotecontrol.js @@ -704,15 +704,20 @@ export default function () { import('../playlisteditor/playlisteditor').then(({ default: PlaylistEditor }) => { getSaveablePlaylistItems().then(function (items) { const serverId = items.length ? items[0].ServerId : ApiClient.serverId(); - new PlaylistEditor({ + const playlistEditor = new PlaylistEditor(); + playlistEditor.show({ items: items.map(function (i) { return i.Id; }), serverId: serverId, enableAddToPlayQueue: false, defaultValue: 'new' + }).catch(() => { + // Dialog closed }); }); + }).catch(err => { + console.error('[savePlaylist] failed to load playlist editor', err); }); } diff --git a/src/components/scrollManager.js b/src/components/scrollManager.js index 49c2cebc32..2f0917391f 100644 --- a/src/components/scrollManager.js +++ b/src/components/scrollManager.js @@ -4,7 +4,7 @@ */ import dom from '../scripts/dom'; -import browser from '../scripts/browser'; +import appSettings from 'scripts/settings/appSettings'; import layoutManager from './layoutManager'; /** @@ -477,7 +477,7 @@ function doScroll(xScroller, scrollX, yScroller, scrollY, smooth) { * Returns true if smooth scroll must be used. */ function useSmoothScroll() { - return !!browser.tizen; + return appSettings.enableSmoothScroll(); } /** diff --git a/src/components/search/LiveTVSearchResults.tsx b/src/components/search/LiveTVSearchResults.tsx index 0e115977ca..f26464ff22 100644 --- a/src/components/search/LiveTVSearchResults.tsx +++ b/src/components/search/LiveTVSearchResults.tsx @@ -2,7 +2,8 @@ import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; import type { ApiClient } from 'jellyfin-apiclient'; import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type'; import classNames from 'classnames'; -import React, { FunctionComponent, useEffect, useState } from 'react'; +import React, { type FC, useCallback, useEffect, useState } from 'react'; +import { useDebounceValue } from 'usehooks-ts'; import globalize from '../../scripts/globalize'; import ServerConnections from '../ServerConnections'; @@ -30,7 +31,7 @@ type LiveTVSearchResultsProps = { /* * React component to display search result rows for live tv library search */ -const LiveTVSearchResults: FunctionComponent = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: LiveTVSearchResultsProps) => { +const LiveTVSearchResults: FC = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: LiveTVSearchResultsProps) => { const [ movies, setMovies ] = useState([]); const [ episodes, setEpisodes ] = useState([]); const [ sports, setSports ] = useState([]); @@ -38,23 +39,24 @@ const LiveTVSearchResults: FunctionComponent = ({ serv const [ news, setNews ] = useState([]); const [ programs, setPrograms ] = useState([]); const [ channels, setChannels ] = useState([]); + const [ debouncedQuery ] = useDebounceValue(query, 500); + + const getDefaultParameters = useCallback(() => ({ + ParentId: parentId, + searchTerm: debouncedQuery, + Limit: 24, + Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount', + Recursive: true, + EnableTotalRecordCount: false, + ImageTypeLimit: 1, + IncludePeople: false, + IncludeMedia: false, + IncludeGenres: false, + IncludeStudios: false, + IncludeArtists: false + }), [ parentId, debouncedQuery ]); useEffect(() => { - const getDefaultParameters = () => ({ - ParentId: parentId, - searchTerm: query, - Limit: 24, - Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount', - Recursive: true, - EnableTotalRecordCount: false, - ImageTypeLimit: 1, - IncludePeople: false, - IncludeMedia: false, - IncludeGenres: false, - IncludeStudios: false, - IncludeArtists: false - }); - const fetchItems = (apiClient: ApiClient, params = {}) => apiClient?.getItems( apiClient?.getCurrentUserId(), { @@ -73,65 +75,67 @@ const LiveTVSearchResults: FunctionComponent = ({ serv setPrograms([]); setChannels([]); - if (query && collectionType === CollectionType.Livetv) { - const apiClient = ServerConnections.getApiClient(serverId); - - // Movies row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsMovie: true - }) - .then(result => setMovies(result.Items || [])) - .catch(() => setMovies([])); - // Episodes row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsMovie: false, - IsSeries: true, - IsSports: false, - IsKids: false, - IsNews: false - }) - .then(result => setEpisodes(result.Items || [])) - .catch(() => setEpisodes([])); - // Sports row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsSports: true - }) - .then(result => setSports(result.Items || [])) - .catch(() => setSports([])); - // Kids row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsKids: true - }) - .then(result => setKids(result.Items || [])) - .catch(() => setKids([])); - // News row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsNews: true - }) - .then(result => setNews(result.Items || [])) - .catch(() => setNews([])); - // Programs row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsMovie: false, - IsSeries: false, - IsSports: false, - IsKids: false, - IsNews: false - }) - .then(result => setPrograms(result.Items || [])) - .catch(() => setPrograms([])); - // Channels row - fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' }) - .then(result => setChannels(result.Items || [])) - .catch(() => setChannels([])); + if (!debouncedQuery || collectionType !== CollectionType.Livetv) { + return; } - }, [collectionType, parentId, query, serverId]); + + const apiClient = ServerConnections.getApiClient(serverId); + + // Movies row + fetchItems(apiClient, { + IncludeItemTypes: 'LiveTvProgram', + IsMovie: true + }) + .then(result => setMovies(result.Items || [])) + .catch(() => setMovies([])); + // Episodes row + fetchItems(apiClient, { + IncludeItemTypes: 'LiveTvProgram', + IsMovie: false, + IsSeries: true, + IsSports: false, + IsKids: false, + IsNews: false + }) + .then(result => setEpisodes(result.Items || [])) + .catch(() => setEpisodes([])); + // Sports row + fetchItems(apiClient, { + IncludeItemTypes: 'LiveTvProgram', + IsSports: true + }) + .then(result => setSports(result.Items || [])) + .catch(() => setSports([])); + // Kids row + fetchItems(apiClient, { + IncludeItemTypes: 'LiveTvProgram', + IsKids: true + }) + .then(result => setKids(result.Items || [])) + .catch(() => setKids([])); + // News row + fetchItems(apiClient, { + IncludeItemTypes: 'LiveTvProgram', + IsNews: true + }) + .then(result => setNews(result.Items || [])) + .catch(() => setNews([])); + // Programs row + fetchItems(apiClient, { + IncludeItemTypes: 'LiveTvProgram', + IsMovie: false, + IsSeries: false, + IsSports: false, + IsKids: false, + IsNews: false + }) + .then(result => setPrograms(result.Items || [])) + .catch(() => setPrograms([])); + // Channels row + fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' }) + .then(result => setChannels(result.Items || [])) + .catch(() => setChannels([])); + }, [collectionType, debouncedQuery, getDefaultParameters, parentId, serverId]); return (
= ({ serv 'searchResults', 'padded-bottom-page', 'padded-top', - { 'hide': !query || collectionType !== CollectionType.Livetv } + { 'hide': !debouncedQuery || collectionType !== CollectionType.Livetv } )} > ({ - __html: `` -}); +import 'material-design-icons-iconfont'; -const normalizeInput = (value = '') => value.trim(); +import '../../styles/flexstyles.scss'; +import './searchfields.scss'; type SearchFieldsProps = { + query: string, onSearch?: (query: string) => void }; -// eslint-disable-next-line @typescript-eslint/no-empty-function -const SearchFields: FunctionComponent = ({ onSearch = () => {} }: SearchFieldsProps) => { - const element = useRef(null); - - const getSearchInput = () => element?.current?.querySelector('.searchfields-txtSearch'); - - const debouncedOnSearch = useMemo(() => debounce(onSearch, 400), [onSearch]); - - useEffect(() => { - getSearchInput()?.addEventListener('input', e => { - debouncedOnSearch(normalizeInput((e.target as HTMLInputElement).value)); - }); - getSearchInput()?.focus(); - - return () => { - debouncedOnSearch.cancel(); - }; - }, [debouncedOnSearch]); - +const SearchFields: FC = ({ + onSearch = () => { /* no-op */ }, + query +}: SearchFieldsProps) => { const onAlphaPicked = useCallback((e: Event) => { const value = (e as CustomEvent).detail.value; - const searchInput = getSearchInput(); - - if (!searchInput) { - console.error('Unexpected null reference'); - return; - } if (value === 'backspace') { - const currentValue = searchInput.value; - searchInput.value = currentValue.length ? currentValue.substring(0, currentValue.length - 1) : ''; + onSearch(query.length ? query.substring(0, query.length - 1) : ''); } else { - searchInput.value += value; + onSearch(query + value); } + }, [ onSearch, query ]); - searchInput.dispatchEvent(new CustomEvent('input', { bubbles: true })); - }, []); + const onChange = useCallback((e: ChangeEvent) => { + onSearch(e.target.value); + }, [ onSearch ]); return ( -
+
{layoutManager.tv && !browser.tv && diff --git a/src/components/search/SearchResults.tsx b/src/components/search/SearchResults.tsx index e267a4f5db..f7ab5909b3 100644 --- a/src/components/search/SearchResults.tsx +++ b/src/components/search/SearchResults.tsx @@ -1,8 +1,9 @@ import type { BaseItemDto, BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'; import type { ApiClient } from 'jellyfin-apiclient'; import classNames from 'classnames'; -import React, { FunctionComponent, useCallback, useEffect, useState } from 'react'; +import React, { type FC, useCallback, useEffect, useState } from 'react'; import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type'; +import { useDebounceValue } from 'usehooks-ts'; import globalize from '../../scripts/globalize'; import ServerConnections from '../ServerConnections'; @@ -30,7 +31,7 @@ const isTVShows = (collectionType: string) => collectionType === CollectionType. /* * React component to display search result rows for global search and non-live tv library search */ -const SearchResults: FunctionComponent = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: SearchResultsProps) => { +const SearchResults: FC = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: SearchResultsProps) => { const [ movies, setMovies ] = useState([]); const [ shows, setShows ] = useState([]); const [ episodes, setEpisodes ] = useState([]); @@ -47,11 +48,12 @@ const SearchResults: FunctionComponent = ({ serverId = windo const [ books, setBooks ] = useState([]); const [ people, setPeople ] = useState([]); const [ collections, setCollections ] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [ isLoading, setIsLoading ] = useState(false); + const [ debouncedQuery ] = useDebounceValue(query, 500); const getDefaultParameters = useCallback(() => ({ ParentId: parentId, - searchTerm: query, + searchTerm: debouncedQuery, Limit: 100, Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount', Recursive: true, @@ -62,7 +64,7 @@ const SearchResults: FunctionComponent = ({ serverId = windo IncludeGenres: false, IncludeStudios: false, IncludeArtists: false - }), [parentId, query]); + }), [ parentId, debouncedQuery ]); const fetchArtists = useCallback((apiClient: ApiClient, params = {}) => ( apiClient?.getArtists( @@ -97,6 +99,10 @@ const SearchResults: FunctionComponent = ({ serverId = windo ).then(ensureNonNullItems) ), [getDefaultParameters]); + useEffect(() => { + if (query) setIsLoading(true); + }, [ query ]); + useEffect(() => { // Reset state setMovies([]); @@ -116,13 +122,11 @@ const SearchResults: FunctionComponent = ({ serverId = windo setPeople([]); setCollections([]); - if (!query) { + if (!debouncedQuery) { setIsLoading(false); return; } - setIsLoading(true); - const apiClient = ServerConnections.getApiClient(serverId); const fetchPromises = []; @@ -230,7 +234,7 @@ const SearchResults: FunctionComponent = ({ serverId = windo console.error('An error occurred while fetching data:', error); setIsLoading(false); // Set loading to false even if an error occurs }); - }, [collectionType, fetchArtists, fetchItems, fetchPeople, query, serverId]); + }, [collectionType, fetchArtists, fetchItems, fetchPeople, debouncedQuery, serverId]); const allEmpty = [movies, shows, episodes, videos, programs, channels, playlists, artists, albums, songs, photoAlbums, photos, audioBooks, books, people, collections].every(arr => arr.length === 0); @@ -240,7 +244,7 @@ const SearchResults: FunctionComponent = ({ serverId = windo 'searchResults', 'padded-bottom-page', 'padded-top', - { 'hide': !query || collectionType === CollectionType.Livetv } + { 'hide': !debouncedQuery || collectionType === CollectionType.Livetv } )} > {isLoading ? ( @@ -335,8 +339,10 @@ const SearchResults: FunctionComponent = ({ serverId = windo cardOptions={{ coverImage: true }} /> - {allEmpty && query && !isLoading && ( -
{globalize.translate('SearchResultsEmpty', query)}
+ {allEmpty && debouncedQuery && !isLoading && ( +
+ {globalize.translate('SearchResultsEmpty', debouncedQuery)} +
)} )} diff --git a/src/components/search/searchfields.scss b/src/components/search/searchfields.scss index b93638bdf3..08d8515c86 100644 --- a/src/components/search/searchfields.scss +++ b/src/components/search/searchfields.scss @@ -9,14 +9,3 @@ font-size: 2em; align-self: flex-end; } - -.sorry-text { - font-size: 2em; - text-align: center; - font-family: inherit; - width: 100%; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} diff --git a/src/components/shortcuts.js b/src/components/shortcuts.js index 03418f3e21..cadb8cc20e 100644 --- a/src/components/shortcuts.js +++ b/src/components/shortcuts.js @@ -282,11 +282,15 @@ function executeAction(card, target, action) { function addToPlaylist(item) { import('./playlisteditor/playlisteditor').then(({ default: PlaylistEditor }) => { - new PlaylistEditor().show({ + const playlistEditor = new PlaylistEditor(); + playlistEditor.show({ items: [item.Id], serverId: item.ServerId - + }).catch(() => { + // Dialog closed }); + }).catch(err => { + console.error('[addToPlaylist] failed to load playlist editor', err); }); } diff --git a/src/controllers/dashboard/dashboard.html b/src/controllers/dashboard/dashboard.html index aed6bb206f..4dd260877c 100644 --- a/src/controllers/dashboard/dashboard.html +++ b/src/controllers/dashboard/dashboard.html @@ -8,16 +8,22 @@ -
-

-

+
+
${LabelServerName}
+
+
${LabelServerVersion}
+
+
${LabelWebVersion}
+
+
${LabelBuildVersion}
+
- -
-
-
-
-
-
-
-
- -
${OptionPlainStorageFoldersHelp}
-
-
- -
${OptionPlainVideoItemsHelp}
-
-
-
-
-
-
- -
${LabelEmbedAlbumArtDidlHelp}
-
-
- -
${LabelEnableSingleImageInDidlLimitHelp}
-
-
- -
${LabelAlbumArtHelp}
-
-
- -
${LabelAlbumArtMaxResHelp}
-
-
- -
${LabelAlbumArtMaxResHelp}
-
-
- -
${LabelIconMaxResHelp}
-
-
- -
${LabelIconMaxResHelp}
-
-
-
-
-
-

${HeaderProfileServerSettingsHelp}

-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
${LabelProtocolInfoHelp}
-
-
- -
${LabelXDlnaCapHelp}
-
-
- -
${LabelXDlnaDocHelp}
-
-
- -
${LabelSonyAggregationFlagsHelp}
-
-
-
-
-
-

${HeaderSubtitleProfilesHelp}

- -
-
-
-
-
-
-
-

${HeaderXmlDocumentAttributes}

- -
-
-
${XmlDocumentAttributeListHelp}
-
-
-
-
-
-

${HeaderDirectPlayProfileHelp}

- -
-
-
-
-

${HeaderTranscodingProfileHelp}

- -
-
-
-
-

${HeaderContainerProfileHelp}

- -
-
-
-
-

${HeaderCodecProfileHelp}

- -
-
-
-
-

${HeaderResponseProfileHelp}

- -
-
-
-
-
- - -
- -
-
-
-
-
-

${HeaderDirectPlayProfile}

-
-
-
- -
-
- -
${LabelProfileContainersHelp}
-
-
-
- -
${LabelProfileCodecsHelp}
-
-
-
-
- -
${LabelProfileCodecsHelp}
-
-
-

- - -

-
-
-
-
-
-
-

${HeaderTranscodingProfile}

-
-
-
- - - - -
-
- - -

- - -

-
-
-
-
-
-
-

${HeaderContainerProfile}

-
-
-

${HeaderContainerProfileHelp}

-
-
- -
-
- -
${LabelProfileContainersHelp}
-
-
- -

- - -

-
-
-
-
-
-
-

${HeaderCodecProfile}

-
-
-

${HeaderCodecProfileHelp}

-
- -
-
- -
${LabelProfileCodecsHelp}
-
-

- - -

-
-
-
-
-
-
-

${HeaderResponseProfile}

-
-
-
- -
-
- -
${LabelProfileContainersHelp}
-
-
-
- -
${LabelProfileCodecsHelp}
-
-
-
-
- -
${LabelProfileCodecsHelp}
-
-
-

- - -

-
-
-
-
-
-
-

${HeaderIdentificationHeader}

-
-
-
- -
-
- -
-
- -
-

- - -

-
-
-
-
-
-
-

${HeaderXmlDocumentAttribute}

-
-
-
- -
-
- -
-

- - -

-
-
-
-
-
-
-

${HeaderSubtitleProfile}

-
-
-
- -
${LabelSubtitleFormatHelp}
-
-
- -
-
- -
-

- - -

-
-
-
-
diff --git a/src/controllers/dashboard/dlna/profile.js b/src/controllers/dashboard/dlna/profile.js deleted file mode 100644 index 0f92a3200c..0000000000 --- a/src/controllers/dashboard/dlna/profile.js +++ /dev/null @@ -1,830 +0,0 @@ -import escapeHtml from 'escape-html'; -import 'jquery'; -import loading from '../../../components/loading/loading'; -import globalize from '../../../scripts/globalize'; -import '../../../elements/emby-select/emby-select'; -import '../../../elements/emby-button/emby-button'; -import '../../../elements/emby-input/emby-input'; -import '../../../elements/emby-checkbox/emby-checkbox'; -import '../../../components/listview/listview.scss'; -import Dashboard from '../../../utils/dashboard'; -import toast from '../../../components/toast/toast'; -import { getParameterByName } from '../../../utils/url.ts'; - -function loadProfile(page) { - loading.show(); - const promise1 = getProfile(); - const promise2 = ApiClient.getUsers(); - Promise.all([promise1, promise2]).then(function (responses) { - currentProfile = responses[0]; - renderProfile(page, currentProfile, responses[1]); - loading.hide(); - }); -} - -function getProfile() { - const id = getParameterByName('id'); - const url = id ? 'Dlna/Profiles/' + id : 'Dlna/Profiles/Default'; - return ApiClient.getJSON(ApiClient.getUrl(url)); -} - -function renderProfile(page, profile, users) { - $('#txtName', page).val(profile.Name); - $('.chkMediaType', page).each(function () { - this.checked = (profile.SupportedMediaTypes || '').split(',').indexOf(this.getAttribute('data-value')) != -1; - }); - $('#chkEnableAlbumArtInDidl', page).prop('checked', profile.EnableAlbumArtInDidl); - $('#chkEnableSingleImageLimit', page).prop('checked', profile.EnableSingleAlbumArtLimit); - renderXmlDocumentAttributes(page, profile.XmlRootAttributes || []); - const idInfo = profile.Identification || {}; - renderIdentificationHeaders(page, idInfo.Headers || []); - renderSubtitleProfiles(page, profile.SubtitleProfiles || []); - $('#txtInfoFriendlyName', page).val(profile.FriendlyName || ''); - $('#txtInfoModelName', page).val(profile.ModelName || ''); - $('#txtInfoModelNumber', page).val(profile.ModelNumber || ''); - $('#txtInfoModelDescription', page).val(profile.ModelDescription || ''); - $('#txtInfoModelUrl', page).val(profile.ModelUrl || ''); - $('#txtInfoManufacturer', page).val(profile.Manufacturer || ''); - $('#txtInfoManufacturerUrl', page).val(profile.ManufacturerUrl || ''); - $('#txtInfoSerialNumber', page).val(profile.SerialNumber || ''); - $('#txtIdFriendlyName', page).val(idInfo.FriendlyName || ''); - $('#txtIdModelName', page).val(idInfo.ModelName || ''); - $('#txtIdModelNumber', page).val(idInfo.ModelNumber || ''); - $('#txtIdModelDescription', page).val(idInfo.ModelDescription || ''); - $('#txtIdModelUrl', page).val(idInfo.ModelUrl || ''); - $('#txtIdManufacturer', page).val(idInfo.Manufacturer || ''); - $('#txtIdManufacturerUrl', page).val(idInfo.ManufacturerUrl || ''); - $('#txtIdSerialNumber', page).val(idInfo.SerialNumber || ''); - $('#txtIdDeviceDescription', page).val(idInfo.DeviceDescription || ''); - $('#txtAlbumArtPn', page).val(profile.AlbumArtPn || ''); - $('#txtAlbumArtMaxWidth', page).val(profile.MaxAlbumArtWidth || ''); - $('#txtAlbumArtMaxHeight', page).val(profile.MaxAlbumArtHeight || ''); - $('#txtIconMaxWidth', page).val(profile.MaxIconWidth || ''); - $('#txtIconMaxHeight', page).val(profile.MaxIconHeight || ''); - $('#chkIgnoreTranscodeByteRangeRequests', page).prop('checked', profile.IgnoreTranscodeByteRangeRequests); - $('#txtMaxAllowedBitrate', page).val(profile.MaxStreamingBitrate || ''); - $('#txtMusicStreamingTranscodingBitrate', page).val(profile.MusicStreamingTranscodingBitrate || ''); - $('#chkRequiresPlainFolders', page).prop('checked', profile.RequiresPlainFolders); - $('#chkRequiresPlainVideoItems', page).prop('checked', profile.RequiresPlainVideoItems); - $('#txtProtocolInfo', page).val(profile.ProtocolInfo || ''); - $('#txtXDlnaCap', page).val(profile.XDlnaCap || ''); - $('#txtXDlnaDoc', page).val(profile.XDlnaDoc || ''); - $('#txtSonyAggregationFlags', page).val(profile.SonyAggregationFlags || ''); - profile.DirectPlayProfiles = profile.DirectPlayProfiles || []; - profile.TranscodingProfiles = profile.TranscodingProfiles || []; - profile.ContainerProfiles = profile.ContainerProfiles || []; - profile.CodecProfiles = profile.CodecProfiles || []; - profile.ResponseProfiles = profile.ResponseProfiles || []; - const usersHtml = '' + users.map(function (u) { - return ''; - }).join(''); - $('#selectUser', page).html(usersHtml).val(profile.UserId || ''); - renderSubProfiles(page, profile); -} - -function renderIdentificationHeaders(page, headers) { - let index = 0; - const html = '
' + headers.map(function (h) { - let li = '
'; - li += ''; - li += '
'; - li += '

' + escapeHtml(h.Name + ': ' + (h.Value || '')) + '

'; - li += '
' + escapeHtml(h.Match || '') + '
'; - li += '
'; - li += ''; - li += '
'; - index++; - return li; - }).join('') + '
'; - const elem = $('.httpHeaderIdentificationList', page).html(html).trigger('create'); - $('.btnDeleteIdentificationHeader', elem).on('click', function () { - const itemIndex = parseInt(this.getAttribute('data-index'), 10); - currentProfile.Identification.Headers.splice(itemIndex, 1); - renderIdentificationHeaders(page, currentProfile.Identification.Headers); - }); -} - -function openPopup(elem) { - elem.classList.remove('hide'); -} - -function closePopup(elem) { - elem.classList.add('hide'); -} - -function editIdentificationHeader(page, header) { - isSubProfileNew = header == null; - header = header || {}; - currentSubProfile = header; - const popup = $('#identificationHeaderPopup', page); - $('#txtIdentificationHeaderName', popup).val(header.Name || ''); - $('#txtIdentificationHeaderValue', popup).val(header.Value || ''); - $('#selectMatchType', popup).val(header.Match || 'Equals'); - openPopup(popup[0]); -} - -function saveIdentificationHeader(page) { - currentSubProfile.Name = $('#txtIdentificationHeaderName', page).val(); - currentSubProfile.Value = $('#txtIdentificationHeaderValue', page).val(); - currentSubProfile.Match = $('#selectMatchType', page).val(); - - if (isSubProfileNew) { - currentProfile.Identification = currentProfile.Identification || {}; - currentProfile.Identification.Headers = currentProfile.Identification.Headers || []; - currentProfile.Identification.Headers.push(currentSubProfile); - } - - renderIdentificationHeaders(page, currentProfile.Identification.Headers); - currentSubProfile = null; - closePopup($('#identificationHeaderPopup', page)[0]); -} - -function renderXmlDocumentAttributes(page, attribute) { - const html = '
' + attribute.map(function (h) { - let li = '
'; - li += ''; - li += '
'; - li += '

' + escapeHtml(h.Name + ' = ' + (h.Value || '')) + '

'; - li += '
'; - li += ''; - li += '
'; - return li; - }).join('') + '
'; - const elem = $('.xmlDocumentAttributeList', page).html(html).trigger('create'); - $('.btnDeleteXmlAttribute', elem).on('click', function () { - const itemIndex = parseInt(this.getAttribute('data-index'), 10); - currentProfile.XmlRootAttributes.splice(itemIndex, 1); - renderXmlDocumentAttributes(page, currentProfile.XmlRootAttributes); - }); -} - -function editXmlDocumentAttribute(page, attribute) { - isSubProfileNew = attribute == null; - attribute = attribute || {}; - currentSubProfile = attribute; - const popup = $('#xmlAttributePopup', page); - $('#txtXmlAttributeName', popup).val(attribute.Name || ''); - $('#txtXmlAttributeValue', popup).val(attribute.Value || ''); - openPopup(popup[0]); -} - -function saveXmlDocumentAttribute(page) { - currentSubProfile.Name = $('#txtXmlAttributeName', page).val(); - currentSubProfile.Value = $('#txtXmlAttributeValue', page).val(); - - if (isSubProfileNew) { - currentProfile.XmlRootAttributes.push(currentSubProfile); - } - - renderXmlDocumentAttributes(page, currentProfile.XmlRootAttributes); - currentSubProfile = null; - closePopup($('#xmlAttributePopup', page)[0]); -} - -function renderSubtitleProfiles(page, profiles) { - let index = 0; - const html = '
' + profiles.map(function (h) { - let li = '
'; - li += ''; - li += '
'; - li += '

' + escapeHtml(h.Format || '') + '

'; - li += '
'; - li += ''; - li += '
'; - index++; - return li; - }).join('') + '
'; - const elem = $('.subtitleProfileList', page).html(html).trigger('create'); - $('.btnDeleteProfile', elem).on('click', function () { - const itemIndex = parseInt(this.getAttribute('data-index'), 10); - currentProfile.SubtitleProfiles.splice(itemIndex, 1); - renderSubtitleProfiles(page, currentProfile.SubtitleProfiles); - }); - $('.lnkEditSubProfile', elem).on('click', function () { - const itemIndex = parseInt(this.getAttribute('data-index'), 10); - editSubtitleProfile(page, currentProfile.SubtitleProfiles[itemIndex]); - }); -} - -function editSubtitleProfile(page, profile) { - isSubProfileNew = profile == null; - profile = profile || {}; - currentSubProfile = profile; - const popup = $('#subtitleProfilePopup', page); - $('#txtSubtitleProfileFormat', popup).val(profile.Format || ''); - $('#selectSubtitleProfileMethod', popup).val(profile.Method || ''); - $('#selectSubtitleProfileDidlMode', popup).val(profile.DidlMode || ''); - openPopup(popup[0]); -} - -function saveSubtitleProfile(page) { - currentSubProfile.Format = $('#txtSubtitleProfileFormat', page).val(); - currentSubProfile.Method = $('#selectSubtitleProfileMethod', page).val(); - currentSubProfile.DidlMode = $('#selectSubtitleProfileDidlMode', page).val(); - - if (isSubProfileNew) { - currentProfile.SubtitleProfiles.push(currentSubProfile); - } - - renderSubtitleProfiles(page, currentProfile.SubtitleProfiles); - currentSubProfile = null; - closePopup($('#subtitleProfilePopup', page)[0]); -} - -function renderSubProfiles(page, profile) { - renderDirectPlayProfiles(page, profile.DirectPlayProfiles); - renderTranscodingProfiles(page, profile.TranscodingProfiles); - renderContainerProfiles(page, profile.ContainerProfiles); - renderCodecProfiles(page, profile.CodecProfiles); - renderResponseProfiles(page, profile.ResponseProfiles); -} - -function saveDirectPlayProfile(page) { - currentSubProfile.Type = $('#selectDirectPlayProfileType', page).val(); - currentSubProfile.Container = $('#txtDirectPlayContainer', page).val(); - currentSubProfile.AudioCodec = $('#txtDirectPlayAudioCodec', page).val(); - currentSubProfile.VideoCodec = $('#txtDirectPlayVideoCodec', page).val(); - - if (isSubProfileNew) { - currentProfile.DirectPlayProfiles.push(currentSubProfile); - } - - renderSubProfiles(page, currentProfile); - currentSubProfile = null; - closePopup($('#popupEditDirectPlayProfile', page)[0]); -} - -function renderDirectPlayProfiles(page, profiles) { - let html = ''; - html += ''; - const elem = $('.directPlayProfiles', page).html(html).trigger('create'); - $('.btnDeleteProfile', elem).on('click', function () { - const index = this.getAttribute('data-profileindex'); - deleteDirectPlayProfile(page, index); - }); - $('.lnkEditSubProfile', elem).on('click', function () { - const index = parseInt(this.getAttribute('data-profileindex'), 10); - editDirectPlayProfile(page, currentProfile.DirectPlayProfiles[index]); - }); -} - -function deleteDirectPlayProfile(page, index) { - currentProfile.DirectPlayProfiles.splice(index, 1); - renderDirectPlayProfiles(page, currentProfile.DirectPlayProfiles); -} - -function editDirectPlayProfile(page, directPlayProfile) { - isSubProfileNew = directPlayProfile == null; - directPlayProfile = directPlayProfile || {}; - currentSubProfile = directPlayProfile; - const popup = $('#popupEditDirectPlayProfile', page); - $('#selectDirectPlayProfileType', popup).val(directPlayProfile.Type || 'Video').trigger('change'); - $('#txtDirectPlayContainer', popup).val(directPlayProfile.Container || ''); - $('#txtDirectPlayAudioCodec', popup).val(directPlayProfile.AudioCodec || ''); - $('#txtDirectPlayVideoCodec', popup).val(directPlayProfile.VideoCodec || ''); - openPopup(popup[0]); -} - -function renderTranscodingProfiles(page, profiles) { - let html = ''; - html += ''; - const elem = $('.transcodingProfiles', page).html(html).trigger('create'); - $('.btnDeleteProfile', elem).on('click', function () { - const index = this.getAttribute('data-profileindex'); - deleteTranscodingProfile(page, index); - }); - $('.lnkEditSubProfile', elem).on('click', function () { - const index = parseInt(this.getAttribute('data-profileindex'), 10); - editTranscodingProfile(page, currentProfile.TranscodingProfiles[index]); - }); -} - -function editTranscodingProfile(page, transcodingProfile) { - isSubProfileNew = transcodingProfile == null; - transcodingProfile = transcodingProfile || {}; - currentSubProfile = transcodingProfile; - const popup = $('#transcodingProfilePopup', page); - $('#selectTranscodingProfileType', popup).val(transcodingProfile.Type || 'Video').trigger('change'); - $('#txtTranscodingContainer', popup).val(transcodingProfile.Container || ''); - $('#txtTranscodingAudioCodec', popup).val(transcodingProfile.AudioCodec || ''); - $('#txtTranscodingVideoCodec', popup).val(transcodingProfile.VideoCodec || ''); - $('#selectTranscodingProtocol', popup).val(transcodingProfile.Protocol || 'Http'); - $('#chkEnableMpegtsM2TsMode', popup).prop('checked', transcodingProfile.EnableMpegtsM2TsMode || false); - $('#chkEstimateContentLength', popup).prop('checked', transcodingProfile.EstimateContentLength || false); - $('#chkReportByteRangeRequests', popup).prop('checked', transcodingProfile.TranscodeSeekInfo == 'Bytes'); - $('.radioTabButton:first', popup).trigger('click'); - openPopup(popup[0]); -} - -function deleteTranscodingProfile(page, index) { - currentProfile.TranscodingProfiles.splice(index, 1); - renderTranscodingProfiles(page, currentProfile.TranscodingProfiles); -} - -function saveTranscodingProfile(page) { - currentSubProfile.Type = $('#selectTranscodingProfileType', page).val(); - currentSubProfile.Container = $('#txtTranscodingContainer', page).val(); - currentSubProfile.AudioCodec = $('#txtTranscodingAudioCodec', page).val(); - currentSubProfile.VideoCodec = $('#txtTranscodingVideoCodec', page).val(); - currentSubProfile.Protocol = $('#selectTranscodingProtocol', page).val(); - currentSubProfile.Context = 'Streaming'; - currentSubProfile.EnableMpegtsM2TsMode = $('#chkEnableMpegtsM2TsMode', page).is(':checked'); - currentSubProfile.EstimateContentLength = $('#chkEstimateContentLength', page).is(':checked'); - currentSubProfile.TranscodeSeekInfo = $('#chkReportByteRangeRequests', page).is(':checked') ? 'Bytes' : 'Auto'; - - if (isSubProfileNew) { - currentProfile.TranscodingProfiles.push(currentSubProfile); - } - - renderSubProfiles(page, currentProfile); - currentSubProfile = null; - closePopup($('#transcodingProfilePopup', page)[0]); -} - -function renderContainerProfiles(page, profiles) { - let html = ''; - html += ''; - const elem = $('.containerProfiles', page).html(html).trigger('create'); - $('.btnDeleteProfile', elem).on('click', function () { - const index = this.getAttribute('data-profileindex'); - deleteContainerProfile(page, index); - }); - $('.lnkEditSubProfile', elem).on('click', function () { - const index = parseInt(this.getAttribute('data-profileindex'), 10); - editContainerProfile(page, currentProfile.ContainerProfiles[index]); - }); -} - -function deleteContainerProfile(page, index) { - currentProfile.ContainerProfiles.splice(index, 1); - renderContainerProfiles(page, currentProfile.ContainerProfiles); -} - -function editContainerProfile(page, containerProfile) { - isSubProfileNew = containerProfile == null; - containerProfile = containerProfile || {}; - currentSubProfile = containerProfile; - const popup = $('#containerProfilePopup', page); - $('#selectContainerProfileType', popup).val(containerProfile.Type || 'Video').trigger('change'); - $('#txtContainerProfileContainer', popup).val(containerProfile.Container || ''); - $('.radioTabButton:first', popup).trigger('click'); - openPopup(popup[0]); -} - -function saveContainerProfile(page) { - currentSubProfile.Type = $('#selectContainerProfileType', page).val(); - currentSubProfile.Container = $('#txtContainerProfileContainer', page).val(); - - if (isSubProfileNew) { - currentProfile.ContainerProfiles.push(currentSubProfile); - } - - renderSubProfiles(page, currentProfile); - currentSubProfile = null; - closePopup($('#containerProfilePopup', page)[0]); -} - -function renderCodecProfiles(page, profiles) { - let html = ''; - html += ''; - const elem = $('.codecProfiles', page).html(html).trigger('create'); - $('.btnDeleteProfile', elem).on('click', function () { - const index = this.getAttribute('data-profileindex'); - deleteCodecProfile(page, index); - }); - $('.lnkEditSubProfile', elem).on('click', function () { - const index = parseInt(this.getAttribute('data-profileindex'), 10); - editCodecProfile(page, currentProfile.CodecProfiles[index]); - }); -} - -function deleteCodecProfile(page, index) { - currentProfile.CodecProfiles.splice(index, 1); - renderCodecProfiles(page, currentProfile.CodecProfiles); -} - -function editCodecProfile(page, codecProfile) { - isSubProfileNew = codecProfile == null; - codecProfile = codecProfile || {}; - currentSubProfile = codecProfile; - const popup = $('#codecProfilePopup', page); - $('#selectCodecProfileType', popup).val(codecProfile.Type || 'Video').trigger('change'); - $('#txtCodecProfileCodec', popup).val(codecProfile.Codec || ''); - $('.radioTabButton:first', popup).trigger('click'); - openPopup(popup[0]); -} - -function saveCodecProfile(page) { - currentSubProfile.Type = $('#selectCodecProfileType', page).val(); - currentSubProfile.Codec = $('#txtCodecProfileCodec', page).val(); - - if (isSubProfileNew) { - currentProfile.CodecProfiles.push(currentSubProfile); - } - - renderSubProfiles(page, currentProfile); - currentSubProfile = null; - closePopup($('#codecProfilePopup', page)[0]); -} - -function renderResponseProfiles(page, profiles) { - let html = ''; - html += ''; - const elem = $('.mediaProfiles', page).html(html).trigger('create'); - $('.btnDeleteProfile', elem).on('click', function () { - const index = this.getAttribute('data-profileindex'); - deleteResponseProfile(page, index); - }); - $('.lnkEditSubProfile', elem).on('click', function () { - const index = parseInt(this.getAttribute('data-profileindex'), 10); - editResponseProfile(page, currentProfile.ResponseProfiles[index]); - }); -} - -function deleteResponseProfile(page, index) { - currentProfile.ResponseProfiles.splice(index, 1); - renderResponseProfiles(page, currentProfile.ResponseProfiles); -} - -function editResponseProfile(page, responseProfile) { - isSubProfileNew = responseProfile == null; - responseProfile = responseProfile || {}; - currentSubProfile = responseProfile; - const popup = $('#responseProfilePopup', page); - $('#selectResponseProfileType', popup).val(responseProfile.Type || 'Video').trigger('change'); - $('#txtResponseProfileContainer', popup).val(responseProfile.Container || ''); - $('#txtResponseProfileAudioCodec', popup).val(responseProfile.AudioCodec || ''); - $('#txtResponseProfileVideoCodec', popup).val(responseProfile.VideoCodec || ''); - $('.radioTabButton:first', popup).trigger('click'); - openPopup(popup[0]); -} - -function saveResponseProfile(page) { - currentSubProfile.Type = $('#selectResponseProfileType', page).val(); - currentSubProfile.Container = $('#txtResponseProfileContainer', page).val(); - currentSubProfile.AudioCodec = $('#txtResponseProfileAudioCodec', page).val(); - currentSubProfile.VideoCodec = $('#txtResponseProfileVideoCodec', page).val(); - - if (isSubProfileNew) { - currentProfile.ResponseProfiles.push(currentSubProfile); - } - - renderSubProfiles(page, currentProfile); - currentSubProfile = null; - closePopup($('#responseProfilePopup', page)[0]); -} - -function saveProfile(page, profile) { - updateProfile(page, profile); - const id = getParameterByName('id'); - - if (id) { - ApiClient.ajax({ - type: 'POST', - url: ApiClient.getUrl('Dlna/Profiles/' + id), - data: JSON.stringify(profile), - contentType: 'application/json' - }).then(function () { - toast(globalize.translate('SettingsSaved')); - }, Dashboard.processErrorResponse); - } else { - ApiClient.ajax({ - type: 'POST', - url: ApiClient.getUrl('Dlna/Profiles'), - data: JSON.stringify(profile), - contentType: 'application/json' - }).then(function () { - Dashboard.navigate('dashboard/dlna/profiles'); - }, Dashboard.processErrorResponse); - } - - loading.hide(); -} - -function updateProfile(page, profile) { - profile.Name = $('#txtName', page).val(); - profile.EnableAlbumArtInDidl = $('#chkEnableAlbumArtInDidl', page).is(':checked'); - profile.EnableSingleAlbumArtLimit = $('#chkEnableSingleImageLimit', page).is(':checked'); - profile.SupportedMediaTypes = $('.chkMediaType:checked', page).get().map(function (c) { - return c.getAttribute('data-value'); - }).join(','); - profile.Identification = profile.Identification || {}; - profile.FriendlyName = $('#txtInfoFriendlyName', page).val(); - profile.ModelName = $('#txtInfoModelName', page).val(); - profile.ModelNumber = $('#txtInfoModelNumber', page).val(); - profile.ModelDescription = $('#txtInfoModelDescription', page).val(); - profile.ModelUrl = $('#txtInfoModelUrl', page).val(); - profile.Manufacturer = $('#txtInfoManufacturer', page).val(); - profile.ManufacturerUrl = $('#txtInfoManufacturerUrl', page).val(); - profile.SerialNumber = $('#txtInfoSerialNumber', page).val(); - profile.Identification.FriendlyName = $('#txtIdFriendlyName', page).val(); - profile.Identification.ModelName = $('#txtIdModelName', page).val(); - profile.Identification.ModelNumber = $('#txtIdModelNumber', page).val(); - profile.Identification.ModelDescription = $('#txtIdModelDescription', page).val(); - profile.Identification.ModelUrl = $('#txtIdModelUrl', page).val(); - profile.Identification.Manufacturer = $('#txtIdManufacturer', page).val(); - profile.Identification.ManufacturerUrl = $('#txtIdManufacturerUrl', page).val(); - profile.Identification.SerialNumber = $('#txtIdSerialNumber', page).val(); - profile.Identification.DeviceDescription = $('#txtIdDeviceDescription', page).val(); - profile.AlbumArtPn = $('#txtAlbumArtPn', page).val(); - profile.MaxAlbumArtWidth = $('#txtAlbumArtMaxWidth', page).val(); - profile.MaxAlbumArtHeight = $('#txtAlbumArtMaxHeight', page).val(); - profile.MaxIconWidth = $('#txtIconMaxWidth', page).val(); - profile.MaxIconHeight = $('#txtIconMaxHeight', page).val(); - profile.RequiresPlainFolders = $('#chkRequiresPlainFolders', page).is(':checked'); - profile.RequiresPlainVideoItems = $('#chkRequiresPlainVideoItems', page).is(':checked'); - profile.IgnoreTranscodeByteRangeRequests = $('#chkIgnoreTranscodeByteRangeRequests', page).is(':checked'); - profile.MaxStreamingBitrate = $('#txtMaxAllowedBitrate', page).val(); - profile.MusicStreamingTranscodingBitrate = $('#txtMusicStreamingTranscodingBitrate', page).val(); - profile.ProtocolInfo = $('#txtProtocolInfo', page).val(); - profile.XDlnaCap = $('#txtXDlnaCap', page).val(); - profile.XDlnaDoc = $('#txtXDlnaDoc', page).val(); - profile.SonyAggregationFlags = $('#txtSonyAggregationFlags', page).val(); - profile.UserId = $('#selectUser', page).val(); -} - -let currentProfile; -let currentSubProfile; -let isSubProfileNew; -const allText = globalize.translate('All'); - -$(document).on('pageinit', '#dlnaProfilePage', function () { - const page = this; - $('.radioTabButton', page).on('click', function () { - $(this).siblings().removeClass('ui-btn-active'); - $(this).addClass('ui-btn-active'); - const value = this.tagName == 'A' ? this.getAttribute('data-value') : this.value; - const elem = $('.' + value, page); - elem.siblings('.tabContent').hide(); - elem.show(); - }); - $('#selectDirectPlayProfileType', page).on('change', function () { - if (this.value == 'Video') { - $('#fldDirectPlayVideoCodec', page).show(); - } else { - $('#fldDirectPlayVideoCodec', page).hide(); - } - - if (this.value == 'Photo') { - $('#fldDirectPlayAudioCodec', page).hide(); - } else { - $('#fldDirectPlayAudioCodec', page).show(); - } - }); - $('#selectTranscodingProfileType', page).on('change', function () { - if (this.value == 'Video') { - $('#fldTranscodingVideoCodec', page).show(); - $('#fldTranscodingProtocol', page).show(); - $('#fldEnableMpegtsM2TsMode', page).show(); - } else { - $('#fldTranscodingVideoCodec', page).hide(); - $('#fldTranscodingProtocol', page).hide(); - $('#fldEnableMpegtsM2TsMode', page).hide(); - } - - if (this.value == 'Photo') { - $('#fldTranscodingAudioCodec', page).hide(); - $('#fldEstimateContentLength', page).hide(); - $('#fldReportByteRangeRequests', page).hide(); - } else { - $('#fldTranscodingAudioCodec', page).show(); - $('#fldEstimateContentLength', page).show(); - $('#fldReportByteRangeRequests', page).show(); - } - }); - $('#selectResponseProfileType', page).on('change', function () { - if (this.value == 'Video') { - $('#fldResponseProfileVideoCodec', page).show(); - } else { - $('#fldResponseProfileVideoCodec', page).hide(); - } - - if (this.value == 'Photo') { - $('#fldResponseProfileAudioCodec', page).hide(); - } else { - $('#fldResponseProfileAudioCodec', page).show(); - } - }); - $('.btnAddDirectPlayProfile', page).on('click', function () { - editDirectPlayProfile(page); - }); - $('.btnAddTranscodingProfile', page).on('click', function () { - editTranscodingProfile(page); - }); - $('.btnAddContainerProfile', page).on('click', function () { - editContainerProfile(page); - }); - $('.btnAddCodecProfile', page).on('click', function () { - editCodecProfile(page); - }); - $('.btnAddResponseProfile', page).on('click', function () { - editResponseProfile(page); - }); - $('.btnAddIdentificationHttpHeader', page).on('click', function () { - editIdentificationHeader(page); - }); - $('.btnAddXmlDocumentAttribute', page).on('click', function () { - editXmlDocumentAttribute(page); - }); - $('.btnAddSubtitleProfile', page).on('click', function () { - editSubtitleProfile(page); - }); - $('.dlnaProfileForm').off('submit', DlnaProfilePage.onSubmit).on('submit', DlnaProfilePage.onSubmit); - $('.editDirectPlayProfileForm').off('submit', DlnaProfilePage.onDirectPlayFormSubmit).on('submit', DlnaProfilePage.onDirectPlayFormSubmit); - $('.transcodingProfileForm').off('submit', DlnaProfilePage.onTranscodingProfileFormSubmit).on('submit', DlnaProfilePage.onTranscodingProfileFormSubmit); - $('.containerProfileForm').off('submit', DlnaProfilePage.onContainerProfileFormSubmit).on('submit', DlnaProfilePage.onContainerProfileFormSubmit); - $('.codecProfileForm').off('submit', DlnaProfilePage.onCodecProfileFormSubmit).on('submit', DlnaProfilePage.onCodecProfileFormSubmit); - $('.editResponseProfileForm').off('submit', DlnaProfilePage.onResponseProfileFormSubmit).on('submit', DlnaProfilePage.onResponseProfileFormSubmit); - $('.identificationHeaderForm').off('submit', DlnaProfilePage.onIdentificationHeaderFormSubmit).on('submit', DlnaProfilePage.onIdentificationHeaderFormSubmit); - $('.xmlAttributeForm').off('submit', DlnaProfilePage.onXmlAttributeFormSubmit).on('submit', DlnaProfilePage.onXmlAttributeFormSubmit); - $('.subtitleProfileForm').off('submit', DlnaProfilePage.onSubtitleProfileFormSubmit).on('submit', DlnaProfilePage.onSubtitleProfileFormSubmit); -}).on('pageshow', '#dlnaProfilePage', function () { - const page = this; - $('#radioInfo', page).trigger('click'); - loadProfile(page); -}); -window.DlnaProfilePage = { - onSubmit: function () { - loading.show(); - saveProfile($(this).parents('.page'), currentProfile); - return false; - }, - onDirectPlayFormSubmit: function () { - saveDirectPlayProfile($(this).parents('.page')); - return false; - }, - onTranscodingProfileFormSubmit: function () { - saveTranscodingProfile($(this).parents('.page')); - return false; - }, - onContainerProfileFormSubmit: function () { - saveContainerProfile($(this).parents('.page')); - return false; - }, - onCodecProfileFormSubmit: function () { - saveCodecProfile($(this).parents('.page')); - return false; - }, - onResponseProfileFormSubmit: function () { - saveResponseProfile($(this).parents('.page')); - return false; - }, - onIdentificationHeaderFormSubmit: function () { - saveIdentificationHeader($(this).parents('.page')); - return false; - }, - onXmlAttributeFormSubmit: function () { - saveXmlDocumentAttribute($(this).parents('.page')); - return false; - }, - onSubtitleProfileFormSubmit: function () { - saveSubtitleProfile($(this).parents('.page')); - return false; - } -}; - diff --git a/src/controllers/dashboard/dlna/profiles.html b/src/controllers/dashboard/dlna/profiles.html deleted file mode 100644 index f1696632c9..0000000000 --- a/src/controllers/dashboard/dlna/profiles.html +++ /dev/null @@ -1,32 +0,0 @@ -
- -
-
- -
- -
-
-

${HeaderCustomDlnaProfiles}

- - - -
- -

${CustomDlnaProfilesHelp}

-
-
- - -
-
-

${HeaderSystemDlnaProfiles}

-
- -

${SystemDlnaProfilesHelp}

-
-
-
-
-
-
diff --git a/src/controllers/dashboard/dlna/profiles.js b/src/controllers/dashboard/dlna/profiles.js deleted file mode 100644 index f69a0c6bf0..0000000000 --- a/src/controllers/dashboard/dlna/profiles.js +++ /dev/null @@ -1,93 +0,0 @@ -import escapeHtml from 'escape-html'; -import 'jquery'; -import globalize from '../../../scripts/globalize'; -import loading from '../../../components/loading/loading'; -import libraryMenu from '../../../scripts/libraryMenu'; -import '../../../components/listview/listview.scss'; -import '../../../elements/emby-button/emby-button'; -import confirm from '../../../components/confirm/confirm'; - -function loadProfiles(page) { - loading.show(); - ApiClient.getJSON(ApiClient.getUrl('Dlna/ProfileInfos')).then(function (result) { - renderUserProfiles(page, result); - renderSystemProfiles(page, result); - loading.hide(); - }); -} - -function renderUserProfiles(page, profiles) { - renderProfiles(page, page.querySelector('.customProfiles'), profiles.filter(function (p) { - return p.Type == 'User'; - })); -} - -function renderSystemProfiles(page, profiles) { - renderProfiles(page, page.querySelector('.systemProfiles'), profiles.filter(function (p) { - return p.Type == 'System'; - })); -} - -function renderProfiles(page, element, profiles) { - let html = ''; - - if (profiles.length) { - html += '
'; - } - - for (let i = 0, length = profiles.length; i < length; i++) { - const profile = profiles[i]; - html += '
'; - html += ''; - html += ''; - - if (profile.Type == 'User') { - html += ''; - } - - html += '
'; - } - - if (profiles.length) { - html += '
'; - } - - element.innerHTML = html; - $('.btnDeleteProfile', element).on('click', function () { - const id = this.getAttribute('data-profileid'); - deleteProfile(page, id); - }); -} - -function deleteProfile(page, id) { - confirm(globalize.translate('MessageConfirmProfileDeletion'), globalize.translate('HeaderConfirmProfileDeletion')).then(function () { - loading.show(); - ApiClient.ajax({ - type: 'DELETE', - url: ApiClient.getUrl('Dlna/Profiles/' + id) - }).then(function () { - loading.hide(); - loadProfiles(page); - }); - }); -} - -function getTabs() { - return [{ - href: '#/dashboard/dlna', - name: globalize.translate('Settings') - }, { - href: '#/dashboard/dlna/profiles', - name: globalize.translate('TabProfiles') - }]; -} - -$(document).on('pageshow', '#dlnaProfilesPage', function () { - libraryMenu.setTabs('dlna', 1, getTabs); - loadProfiles(this); -}); - diff --git a/src/controllers/dashboard/dlna/settings.html b/src/controllers/dashboard/dlna/settings.html deleted file mode 100644 index 4bf5ffc81c..0000000000 --- a/src/controllers/dashboard/dlna/settings.html +++ /dev/null @@ -1,69 +0,0 @@ -
- -
-
- -
- -
-
-

${Settings}

- ${Help} -
-
- -
- -
${LabelEnableDlnaPlayToHelp}
-
- -
- -
${LabelEnableDlnaDebugLoggingHelp}
-
- -
- -
${LabelEnableDlnaClientDiscoveryIntervalHelp}
-
- -
- -
${LabelEnableDlnaServerHelp}
-
- -
- -
${LabelEnableBlastAliveMessagesHelp}
-
- -
- -
${LabelBlastMessageIntervalHelp}
-
-
- -
${LabelDefaultUserHelp}
-
-
- -
-
- -
-
-
diff --git a/src/controllers/dashboard/dlna/settings.js b/src/controllers/dashboard/dlna/settings.js deleted file mode 100644 index d12b6744af..0000000000 --- a/src/controllers/dashboard/dlna/settings.js +++ /dev/null @@ -1,60 +0,0 @@ -import escapeHtml from 'escape-html'; -import 'jquery'; -import loading from '../../../components/loading/loading'; -import libraryMenu from '../../../scripts/libraryMenu'; -import globalize from '../../../scripts/globalize'; -import Dashboard from '../../../utils/dashboard'; - -function loadPage(page, config, users) { - page.querySelector('#chkEnablePlayTo').checked = config.EnablePlayTo; - page.querySelector('#chkEnableDlnaDebugLogging').checked = config.EnableDebugLog; - $('#txtClientDiscoveryInterval', page).val(config.ClientDiscoveryIntervalSeconds); - $('#chkEnableServer', page).prop('checked', config.EnableServer); - $('#chkBlastAliveMessages', page).prop('checked', config.BlastAliveMessages); - $('#txtBlastInterval', page).val(config.BlastAliveMessageIntervalSeconds); - const usersHtml = users.map(function (u) { - return ''; - }).join(''); - $('#selectUser', page).html(usersHtml).val(config.DefaultUserId || ''); - loading.hide(); -} - -function onSubmit() { - loading.show(); - const form = this; - ApiClient.getNamedConfiguration('dlna').then(function (config) { - config.EnablePlayTo = form.querySelector('#chkEnablePlayTo').checked; - config.EnableDebugLog = form.querySelector('#chkEnableDlnaDebugLogging').checked; - config.ClientDiscoveryIntervalSeconds = $('#txtClientDiscoveryInterval', form).val(); - config.EnableServer = $('#chkEnableServer', form).is(':checked'); - config.BlastAliveMessages = $('#chkBlastAliveMessages', form).is(':checked'); - config.BlastAliveMessageIntervalSeconds = $('#txtBlastInterval', form).val(); - config.DefaultUserId = $('#selectUser', form).val(); - ApiClient.updateNamedConfiguration('dlna', config).then(Dashboard.processServerConfigurationUpdateResult); - }); - return false; -} - -function getTabs() { - return [{ - href: '#/dashboard/dlna', - name: globalize.translate('Settings') - }, { - href: '#/dashboard/dlna/profiles', - name: globalize.translate('TabProfiles') - }]; -} - -$(document).on('pageinit', '#dlnaSettingsPage', function () { - $('.dlnaSettingsForm').off('submit', onSubmit).on('submit', onSubmit); -}).on('pageshow', '#dlnaSettingsPage', function () { - libraryMenu.setTabs('dlna', 0, getTabs); - loading.show(); - const page = this; - const promise1 = ApiClient.getNamedConfiguration('dlna'); - const promise2 = ApiClient.getUsers(); - Promise.all([promise1, promise2]).then(function (responses) { - loadPage(page, responses[0], responses[1]); - }); -}); - diff --git a/src/controllers/dashboard/encodingsettings.html b/src/controllers/dashboard/encodingsettings.html index 330dac8a1a..af3e268dac 100644 --- a/src/controllers/dashboard/encodingsettings.html +++ b/src/controllers/dashboard/encodingsettings.html @@ -129,12 +129,18 @@ ${AllowHevcEncoding}
-
+
+
+ +
@@ -155,6 +161,16 @@
+
+
+ +
${AllowVideoToolboxTonemappingHelp}
+
+
+
+ +
+ +