1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Merge branch 'master' into remove-dom-exception-code-property

This commit is contained in:
JP Roisin 2024-04-02 12:34:22 +01:00 committed by GitHub
commit 002d994466
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
141 changed files with 8245 additions and 5697 deletions

1
.copr
View file

@ -1 +0,0 @@
fedora/

View file

@ -264,6 +264,8 @@ module.exports = {
'Windows': 'readonly', 'Windows': 'readonly',
// Build time definitions // Build time definitions
__JF_BUILD_VERSION__: 'readonly', __JF_BUILD_VERSION__: 'readonly',
__PACKAGE_JSON_NAME__: 'readonly',
__PACKAGE_JSON_VERSION__: 'readonly',
__USE_SYSTEM_FONTS__: 'readonly', __USE_SYSTEM_FONTS__: 'readonly',
__WEBPACK_SERVE__: 'readonly' __WEBPACK_SERVE__: 'readonly'
}, },

View file

@ -1,4 +1,8 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$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"
]
} }

View file

@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.repository == 'jellyfin/jellyfin-web' }} if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
steps: steps:
- uses: eps1lon/actions-label-merge-conflict@fd1f295ee7443d13745804bc49fe158e240f6c6e # tag=v2.1.0 - uses: eps1lon/actions-label-merge-conflict@e62d7a53ff8be8b97684bffb6cfbbf3fc1115e2e # v3.0.0
with: with:
dirtyLabel: 'merge conflict' dirtyLabel: 'merge conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.' commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'

View file

@ -22,13 +22,13 @@ jobs:
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6 uses: github/codeql-action/init@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9
with: with:
languages: javascript languages: javascript
queries: +security-extended queries: +security-extended
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6 uses: github/codeql-action/autobuild@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6 uses: github/codeql-action/analyze@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9

View file

@ -33,6 +33,6 @@ jobs:
- name: Run eslint - name: Run eslint
if: ${{ github.repository == 'jellyfin/jellyfin-web' }} if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
uses: CatChen/eslint-suggestion-action@8fb7db4e235f7af9fc434349a124034b681d99a3 # v3.1.3 uses: CatChen/eslint-suggestion-action@34e2a6c4193eba18a7a20710b5ae37850fc984c3 # v3.1.5
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -20,7 +20,7 @@ jobs:
steps: steps:
- name: Download workflow artifact - name: Download workflow artifact
uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2 uses: dawidd6/action-download-artifact@09f2f74827fd3a8607589e5ad7f9398816f540fe # v3.1.4
with: with:
run_id: ${{ github.event.workflow_run.id }} run_id: ${{ github.event.workflow_run.id }}
name: jellyfin-web__prod name: jellyfin-web__prod
@ -47,7 +47,7 @@ jobs:
steps: steps:
- name: Get PR context - name: Get PR context
uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2 uses: dawidd6/action-download-artifact@09f2f74827fd3a8607589e5ad7f9398816f540fe # v3.1.4
id: pr_context id: pr_context
with: with:
run_id: ${{ github.event.workflow_run.id }} run_id: ${{ github.event.workflow_run.id }}

View file

@ -35,7 +35,7 @@ jobs:
echo "JF_SDK_VERSION=${VERSION}" >> $GITHUB_ENV echo "JF_SDK_VERSION=${VERSION}" >> $GITHUB_ENV
- name: Open a pull request - name: Open a pull request
uses: peter-evans/create-pull-request@a4f52f8033a6168103c2538976c07b467e8163bc # v6.0.1 uses: peter-evans/create-pull-request@70a41aba780001da0a30141984ae2a0c95d8704e # v6.0.2
with: with:
token: ${{ secrets.JF_BOT_TOKEN }} token: ${{ secrets.JF_BOT_TOKEN }}
commit-message: Update @jellyfin/sdk to ${{env.JF_SDK_VERSION}} commit-message: Update @jellyfin/sdk to ${{env.JF_SDK_VERSION}}

1
.gitignore vendored
View file

@ -8,6 +8,7 @@ config.json
# ide # ide
.idea .idea
.vs
# log # log
yarn-error.log yarn-error.log

View file

@ -79,7 +79,13 @@
- [Kevin Tan (Valius)](https://github.com/valius) - [Kevin Tan (Valius)](https://github.com/valius)
- [Rasmus Krämer](https://github.com/rasmuslos) - [Rasmus Krämer](https://github.com/rasmuslos)
- [ntarelix](https://github.com/ntarelix) - [ntarelix](https://github.com/ntarelix)
- [btopherjohnson](https://github.com/btopherjohnson)
- [András Maróy](https://github.com/andrasmaroy) - [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 ## Emby Contributors

110
build.sh
View file

@ -1,110 +0,0 @@
#!/usr/bin/env bash
# build.sh - Build Jellyfin binary packages
# Part of the Jellyfin Project
set -o errexit
set -o pipefail
usage() {
echo -e "build.sh - Build Jellyfin binary packages"
echo -e "Usage:"
echo -e " $0 -t/--type <BUILD_TYPE> -p/--platform <PLATFORM> [-k/--keep-artifacts] [-l/--list-platforms]"
echo -e "Notes:"
echo -e " * BUILD_TYPE can be one of: [native, docker] and must be specified"
echo -e " * native: Build using the build script in the host OS"
echo -e " * docker: Build using the build script in a standardized Docker container"
echo -e " * PLATFORM can be any platform shown by -l/--list-platforms and must be specified"
echo -e " * If -k/--keep-artifacts is specified, transient artifacts (e.g. Docker containers) will be"
echo -e " retained after the build is finished; the source directory will still be cleaned"
echo -e " * If -l/--list-platforms is specified, all other arguments are ignored; the script will print"
echo -e " the list of supported platforms and exit"
}
list_platforms() {
declare -a platforms
platforms=(
$( find deployment -maxdepth 1 -mindepth 1 -name "build.*" | awk -F'.' '{ $1=""; printf $2; if ($3 != ""){ printf "." $3; }; if ($4 != ""){ printf "." $4; }; print ""; }' | sort )
)
echo -e "Valid platforms:"
echo
for platform in ${platforms[@]}; do
echo -e "* ${platform} : $( grep '^#=' deployment/build.${platform} | sed 's/^#= //' )"
done
}
do_build_native() {
export IS_DOCKER=NO
deployment/build.${PLATFORM}
}
do_build_docker() {
if ! [ $(uname -m) = "x86_64" ]; then
echo "Docker-based builds only support amd64-based cross-building; use a 'native' build instead."
exit 1
fi
if [[ ! -f deployment/Dockerfile.${PLATFORM} ]]; then
echo "Missing Dockerfile for platform ${PLATFORM}"
exit 1
fi
if [[ ${KEEP_ARTIFACTS} == YES ]]; then
docker_args=""
else
docker_args="--rm"
fi
docker build . -t "jellyfin-builder.${PLATFORM}" -f deployment/Dockerfile.${PLATFORM}
mkdir -p ${ARTIFACT_DIR}
docker run $docker_args -v "${SOURCE_DIR}:/jellyfin" -v "${ARTIFACT_DIR}:/dist" "jellyfin-builder.${PLATFORM}"
}
while [[ $# -gt 0 ]]; do
key="$1"
case $key in
-t|--type)
BUILD_TYPE="$2"
shift
shift
;;
-p|--platform)
PLATFORM="$2"
shift
shift
;;
-k|--keep-artifacts)
KEEP_ARTIFACTS=YES
shift
;;
-l|--list-platforms)
list_platforms
exit 0
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option $1"
usage
exit 1
;;
esac
done
if [[ -z ${BUILD_TYPE} || -z ${PLATFORM} ]]; then
usage
exit 1
fi
export SOURCE_DIR="$( pwd )"
export ARTIFACT_DIR="${SOURCE_DIR}/../bin/${PLATFORM}"
# Determine build type
case ${BUILD_TYPE} in
native)
do_build_native
;;
docker)
do_build_docker
;;
esac

View file

@ -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

View file

@ -7,7 +7,7 @@ set -o pipefail
set -o xtrace set -o xtrace
usage() { usage() {
echo -e "bump_version - increase the shared version and generate changelogs" echo -e "bump_version - increase the shared version"
echo -e "" echo -e ""
echo -e "Usage:" echo -e "Usage:"
echo -e " $ bump_version <new_version>" echo -e " $ bump_version <new_version>"
@ -18,75 +18,12 @@ if [[ -z $1 ]]; then
exit 1 exit 1
fi fi
build_file="./build.yaml"
package_file="./package*.json"
new_version="$1" new_version="$1"
new_version_sed="$( cut -f1 -d'-' <<<"${new_version}" )"
old_version="$(
grep "version:" ${build_file} \
| sed -E 's/version: "([0-9\.]+[-a-z0-9]*)"/\1/'
)"
echo "Old version: ${old_version}"
# Bump the NPM 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} npm --no-git-tag-version --allow-same-version version v${new_version_sed}
# Set the build.yaml version to the specified new_version
old_version_sed="$( sed 's/\./\\./g' <<<"${old_version}" )" # Escape the '.' chars
sed -i "s/${old_version_sed}/${new_version_sed}/g" ${build_file}
if [[ ${new_version} == *"-"* ]]; then
new_version_pkg="$( sed 's/-/~/g' <<<"${new_version}" )"
new_version_deb_sup=""
else
new_version_pkg="${new_version}"
new_version_deb_sup="-1"
fi
# Write out a temporary Debian changelog with our new stuff appended and some templated formatting
debian_changelog_file="debian/changelog"
debian_changelog_temp="$( mktemp )"
# Create new temp file with our changelog
echo -e "jellyfin-web (${new_version_pkg}${new_version_deb_sup}) unstable; urgency=medium
* New upstream version ${new_version}; release changelog at https://github.com/jellyfin/jellyfin-web/releases/tag/v${new_version}
-- Jellyfin Packaging Team <packaging@jellyfin.org> $( date --rfc-2822 )
" >> ${debian_changelog_temp}
cat ${debian_changelog_file} >> ${debian_changelog_temp}
# Move into place
mv ${debian_changelog_temp} ${debian_changelog_file}
# Write out a temporary Yum changelog with our new stuff prepended and some templated formatting
fedora_spec_file="fedora/jellyfin-web.spec"
fedora_changelog_temp="$( mktemp )"
fedora_spec_temp_dir="$( mktemp -d )"
fedora_spec_temp="${fedora_spec_temp_dir}/jellyfin-web.spec.tmp"
# Make a copy of our spec file for hacking
cp ${fedora_spec_file} ${fedora_spec_temp_dir}/
pushd ${fedora_spec_temp_dir}
# Split out the stuff before and after changelog
csplit jellyfin-web.spec "/^%changelog/" # produces xx00 xx01
# Update the version in xx00
sed -i "s/${old_version_sed}/${new_version_pkg}/g" xx00
# Remove the header from xx01
sed -i '/^%changelog/d' xx01
# Create new temp file with our changelog
echo -e "%changelog
* $( LANG=C date '+%a %b %d %Y' ) Jellyfin Packaging Team <packaging@jellyfin.org>
- New upstream version ${new_version}; release changelog at https://github.com/jellyfin/jellyfin-web/releases/tag/v${new_version}" >> ${fedora_changelog_temp}
cat xx01 >> ${fedora_changelog_temp}
# Reassembble
cat xx00 ${fedora_changelog_temp} > ${fedora_spec_temp}
popd
# Move into place
mv ${fedora_spec_temp} ${fedora_spec_file}
# Clean up
rm -rf ${fedora_spec_temp_dir}
# Stage the changed files for commit # Stage the changed files for commit
git add . git add .
git status -v git status -v

17
debian/changelog vendored
View file

@ -1,17 +0,0 @@
jellyfin-web (10.8.0-1) unstable; urgency=medium
* Forthcoming stable release
-- Jellyfin Packaging Team <packaging@jellyfin.org> Fri, 04 Dec 2020 21:58:23 -0500
jellyfin-web (10.7.0-1) unstable; urgency=medium
* Forthcoming stable release
-- Jellyfin Packaging Team <packaging@jellyfin.org> Mon, 27 Jul 2020 19:13:31 -0400
jellyfin-web (10.6.0-1) unstable; urgency=medium
* New upstream version 10.6.0; release changelog at https://github.com/jellyfin/jellyfin-web/releases/tag/v10.6.0
-- Jellyfin Packaging Team <packaging@jellyfin.org> Mon, 16 Mar 2020 11:15:00 -0400

1
debian/compat vendored
View file

@ -1 +0,0 @@
8

1
debian/conffiles vendored
View file

@ -1 +0,0 @@
/usr/share/jellyfin/web/config.json

16
debian/control vendored
View file

@ -1,16 +0,0 @@
Source: jellyfin-web
Section: misc
Priority: optional
Maintainer: Jellyfin Team <team@jellyfin.org>
Build-Depends: debhelper (>= 9),
npm | nodejs
Standards-Version: 3.9.4
Homepage: https://jellyfin.org/
Vcs-Git: https://github.org/jellyfin/jellyfin-web.git
Vcs-Browser: https://github.org/jellyfin/jellyfin-web
Package: jellyfin-web
Recommends: jellyfin-server
Architecture: all
Description: Jellyfin is the Free Software Media System.
This package provides the Jellyfin web client.

28
debian/copyright vendored
View file

@ -1,28 +0,0 @@
Format: http://dep.debian.net/deps/dep5
Upstream-Name: jellyfin-web
Source: https://github.com/jellyfin/jellyfin-web
Files: *
Copyright: 2018-2020 Jellyfin Team
License: GPL-3.0
Files: debian/*
Copyright: 2020 Joshua Boniface <joshua@boniface.me>
License: GPL-3.0
License: GPL-3.0
This package is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
.
This package is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>
.
On Debian systems, the complete text of the GNU General
Public License version 2 can be found in "/usr/share/common-licenses/GPL-2".

6
debian/gbp.conf vendored
View file

@ -1,6 +0,0 @@
[DEFAULT]
pristine-tar = False
cleaner = fakeroot debian/rules clean
[import-orig]
filter = [ ".git*", ".hg*", ".vs*", ".vscode*" ]

1
debian/install vendored
View file

@ -1 +0,0 @@
web usr/share/jellyfin/

View file

@ -1 +0,0 @@
[type: gettext/rfc822deb] templates

View file

@ -1,57 +0,0 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: jellyfin-server\n"
"Report-Msgid-Bugs-To: jellyfin-server@packages.debian.org\n"
"POT-Creation-Date: 2015-06-12 20:51-0600\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: 8bit\n"
#. Type: note
#. Description
#: ../templates:1001
msgid "Jellyfin permission info:"
msgstr ""
#. Type: note
#. Description
#: ../templates:1001
msgid ""
"Jellyfin by default runs under a user named \"jellyfin\". Please ensure that the "
"user jellyfin has read and write access to any folders you wish to add to your "
"library. Otherwise please run jellyfin under a different user."
msgstr ""
#. Type: string
#. Description
#: ../templates:2001
msgid "Username to run Jellyfin as:"
msgstr ""
#. Type: string
#. Description
#: ../templates:2001
msgid "The user that jellyfin will run as."
msgstr ""
#. Type: note
#. Description
#: ../templates:3001
msgid "Jellyfin still running"
msgstr ""
#. Type: note
#. Description
#: ../templates:3001
msgid "Jellyfin is currently running. Please close it and try again."
msgstr ""

21
debian/rules vendored
View file

@ -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

View file

@ -1 +0,0 @@
1.0

View file

@ -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'

View file

@ -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"]

View file

@ -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"]

View file

@ -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}

View file

@ -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"]

View file

@ -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"]

View file

@ -1,41 +0,0 @@
#!/bin/bash
set -o errexit
set -o xtrace
# move to source directory
pushd ${SOURCE_DIR}
cp -a package-lock.json /tmp/package-lock.json
# modify changelog to unstable configuration if IS_UNSTABLE
if [[ ${IS_UNSTABLE} == 'yes' ]]; then
pushd fedora
PR_ID=$( git log --grep 'Merge pull request' --oneline --single-worktree --first-parent | head -1 | grep --color=none -Eo '#[0-9]+' | tr -d '#' )
sed -i "s/Version:.*/Version: ${BUILD_ID}/" jellyfin-web.spec
sed -i "/%changelog/q" jellyfin-web.spec
cat <<EOF >>jellyfin-web.spec
* $( LANG=C date '+%a %b %d %Y' ) Jellyfin Packaging Team <packaging@jellyfin.org>
- Jellyfin Web unstable build ${BUILD_ID} for merged PR #${PR_ID}
EOF
popd
fi
# build rpm
make -f fedora/Makefile srpm outdir=/root/rpmbuild/SRPMS
rpmbuild --rebuild -bb /root/rpmbuild/SRPMS/jellyfin-*.src.rpm
# move the artifacts
mv /root/rpmbuild/RPMS/noarch/jellyfin-*.rpm /root/rpmbuild/SRPMS/jellyfin-*.src.rpm ${ARTIFACT_DIR}/
if [[ ${IS_DOCKER} == YES ]]; then
chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR}
fi
rm -f fedora/jellyfin*.tar.gz
cp -a /tmp/package-lock.json package-lock.json
popd

View file

@ -1,39 +0,0 @@
#!/bin/bash
set -o errexit
set -o xtrace
# move to source directory
pushd ${SOURCE_DIR}
cp -a package-lock.json /tmp/package-lock.json
# modify changelog to unstable configuration if IS_UNSTABLE
if [[ ${IS_UNSTABLE} == 'yes' ]]; then
pushd debian
PR_ID=$( git log --grep 'Merge pull request' --oneline --single-worktree --first-parent | head -1 | grep --color=none -Eo '#[0-9]+' | tr -d '#' )
cat <<EOF >changelog
jellyfin-web (${BUILD_ID}-unstable) unstable; urgency=medium
* Jellyfin Web unstable build ${BUILD_ID} for merged PR #${PR_ID}
-- Jellyfin Packaging Team <packaging@jellyfin.org> $( date --rfc-2822 )
EOF
popd
fi
# build deb
dpkg-buildpackage -us -uc --pre-clean --post-clean
mkdir -p ${ARTIFACT_DIR}
mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} ${ARTIFACT_DIR}
cp -a /tmp/package-lock.json package-lock.json
if [[ ${IS_DOCKER} == YES ]]; then
chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR}
fi
popd

View file

@ -1,41 +0,0 @@
#!/bin/bash
set -o errexit
set -o xtrace
# move to source directory
pushd ${SOURCE_DIR}
cp -a package-lock.json /tmp/package-lock.json
# modify changelog to unstable configuration if IS_UNSTABLE
if [[ ${IS_UNSTABLE} == 'yes' ]]; then
pushd fedora
PR_ID=$( git log --grep 'Merge pull request' --oneline --single-worktree --first-parent | head -1 | grep --color=none -Eo '#[0-9]+' | tr -d '#' )
sed -i "s/Version:.*/Version: ${BUILD_ID}/" jellyfin-web.spec
sed -i "/%changelog/q" jellyfin-web.spec
cat <<EOF >>jellyfin-web.spec
* $( LANG=C date '+%a %b %d %Y' ) Jellyfin Packaging Team <packaging@jellyfin.org>
- Jellyfin Web unstable build ${BUILD_ID} for merged PR #${PR_ID}
EOF
popd
fi
# build rpm
make -f fedora/Makefile srpm outdir=/root/rpmbuild/SRPMS
rpmbuild -rb /root/rpmbuild/SRPMS/jellyfin-*.src.rpm
# move the artifacts
mv /root/rpmbuild/RPMS/noarch/jellyfin-*.rpm /root/rpmbuild/SRPMS/jellyfin-*.src.rpm ${ARTIFACT_DIR}
if [[ ${IS_DOCKER} == YES ]]; then
chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR}
fi
rm -f fedora/jellyfin*.tar.gz
cp -a /tmp/package-lock.json package-lock.json
popd

View file

@ -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

View file

@ -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) $<

View file

@ -1,58 +0,0 @@
%global debug_package %{nil}
Name: jellyfin-web
Version: 10.8.0
Release: 2%{?dist}
Summary: The Free Software Media System web client
License: GPLv2
URL: https://jellyfin.org
# Jellyfin Server tarball created by `make -f .copr/Makefile srpm`, real URL ends with `v%%{version}.tar.gz`
Source0: jellyfin-web-%{version}.tar.gz
BuildArch: noarch
%if 0%{?rhel} > 0 && 0%{?rhel} < 8
BuildRequires: nodejs
%else
BuildRequires: git
# Nodejs 20 is required and npm >= 10 should bring in NodeJS 20
# This requires the build environment to use the nodejs:20 module stream:
# dnf module {install|switch-to}:web nodejs:20
BuildRequires: npm >= 10
%endif
%description
Jellyfin is a free software media system that puts you in control of managing and streaming your media.
%prep
%autosetup -n jellyfin-web-%{version} -b 0
%if 0%{?rhel} > 0 && 0%{?rhel} < 8
# Required for CentOS build
chown root:root -R .
%endif
%build
npm ci --no-audit --unsafe-perm
npm run build:production
%install
%{__mkdir} -p %{buildroot}%{_libdir}/jellyfin/jellyfin-web
%{__cp} -r dist/* %{buildroot}%{_libdir}/jellyfin/jellyfin-web
%files
%defattr(644,root,root,755)
%{_libdir}/jellyfin/jellyfin-web
%license LICENSE
%changelog
* Fri Dec 04 2020 Jellyfin Packaging Team <packaging@jellyfin.org>
- Forthcoming stable release
* Mon Jul 27 2020 Jellyfin Packaging Team <packaging@jellyfin.org>
- Forthcoming stable release
* Mon Mar 23 2020 Jellyfin Packaging Team <packaging@jellyfin.org>
- Forthcoming stable release

8844
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,80 +5,81 @@
"repository": "https://github.com/jellyfin/jellyfin-web", "repository": "https://github.com/jellyfin/jellyfin-web",
"license": "GPL-2.0-or-later", "license": "GPL-2.0-or-later",
"devDependencies": { "devDependencies": {
"@babel/core": "7.23.7", "@babel/core": "7.24.3",
"@babel/plugin-proposal-class-properties": "7.18.6", "@babel/plugin-proposal-class-properties": "7.18.6",
"@babel/plugin-proposal-private-methods": "7.18.6", "@babel/plugin-proposal-private-methods": "7.18.6",
"@babel/plugin-transform-modules-umd": "7.23.3", "@babel/plugin-transform-modules-umd": "7.24.1",
"@babel/preset-env": "7.23.8", "@babel/preset-env": "7.24.3",
"@babel/preset-react": "7.23.3", "@babel/preset-react": "7.24.1",
"@types/escape-html": "1.0.4", "@types/escape-html": "1.0.4",
"@types/loadable__component": "5.13.9", "@types/loadable__component": "5.13.9",
"@types/lodash-es": "4.17.12", "@types/lodash-es": "4.17.12",
"@types/markdown-it": "13.0.7", "@types/markdown-it": "13.0.7",
"@types/react": "17.0.75", "@types/react": "17.0.79",
"@types/react-dom": "17.0.25", "@types/react-dom": "17.0.25",
"@types/sortablejs": "1.15.8", "@types/sortablejs": "1.15.8",
"@typescript-eslint/eslint-plugin": "5.62.0", "@typescript-eslint/eslint-plugin": "5.62.0",
"@typescript-eslint/parser": "5.62.0", "@typescript-eslint/parser": "5.62.0",
"@uupaa/dynamic-import-polyfill": "1.0.2", "@uupaa/dynamic-import-polyfill": "1.0.2",
"autoprefixer": "10.4.17", "autoprefixer": "10.4.19",
"babel-loader": "9.1.3", "babel-loader": "9.1.3",
"babel-plugin-dynamic-import-polyfill": "1.0.0", "babel-plugin-dynamic-import-polyfill": "1.0.0",
"clean-webpack-plugin": "4.0.0", "clean-webpack-plugin": "4.0.0",
"confusing-browser-globals": "1.0.11", "confusing-browser-globals": "1.0.11",
"copy-webpack-plugin": "12.0.2", "copy-webpack-plugin": "12.0.2",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"css-loader": "6.9.1", "css-loader": "6.10.0",
"cssnano": "6.0.5", "cssnano": "6.1.1",
"es-check": "7.1.1", "es-check": "7.1.1",
"eslint": "8.56.0", "eslint": "8.57.0",
"eslint-plugin-compat": "4.2.0", "eslint-plugin-compat": "4.2.0",
"eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-eslint-comments": "3.2.0",
"eslint-plugin-import": "2.29.1", "eslint-plugin-import": "2.29.1",
"eslint-plugin-jsx-a11y": "6.8.0", "eslint-plugin-jsx-a11y": "6.8.0",
"eslint-plugin-react": "7.33.2", "eslint-plugin-react": "7.34.1",
"eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-sonarjs": "0.23.0", "eslint-plugin-sonarjs": "0.24.0",
"expose-loader": "4.1.0", "expose-loader": "4.1.0",
"fork-ts-checker-webpack-plugin": "9.0.2", "fork-ts-checker-webpack-plugin": "9.0.2",
"html-loader": "4.2.0", "html-loader": "4.2.0",
"html-webpack-plugin": "5.6.0", "html-webpack-plugin": "5.6.0",
"jsdom": "23.2.0", "jsdom": "23.2.0",
"mini-css-extract-plugin": "2.7.7", "mini-css-extract-plugin": "2.8.1",
"postcss": "8.4.33", "postcss": "8.4.38",
"postcss-loader": "7.3.4", "postcss-loader": "7.3.4",
"postcss-preset-env": "9.3.0", "postcss-preset-env": "9.5.2",
"postcss-scss": "4.0.9", "postcss-scss": "4.0.9",
"sass": "1.70.0", "sass": "1.72.0",
"sass-loader": "13.3.3", "sass-loader": "13.3.3",
"source-map-loader": "4.0.2", "source-map-loader": "4.0.2",
"speed-measure-webpack-plugin": "1.5.0", "speed-measure-webpack-plugin": "1.5.0",
"style-loader": "3.3.4", "style-loader": "3.3.4",
"stylelint": "15.11.0", "stylelint": "15.11.0",
"stylelint-config-rational-order": "0.1.2", "stylelint-config-rational-order": "0.1.2",
"stylelint-no-browser-hacks": "1.2.1", "stylelint-no-browser-hacks": "1.3.0",
"stylelint-order": "6.0.4", "stylelint-order": "6.0.4",
"stylelint-scss": "5.3.2", "stylelint-scss": "5.3.2",
"ts-loader": "9.5.1", "ts-loader": "9.5.1",
"typescript": "5.3.3", "typescript": "5.4.3",
"vitest": "1.3.0", "vitest": "1.4.0",
"webpack": "5.89.0", "webpack": "5.91.0",
"webpack-bundle-analyzer": "4.10.1", "webpack-bundle-analyzer": "4.10.1",
"webpack-cli": "5.1.4", "webpack-cli": "5.1.4",
"webpack-dev-server": "4.15.1", "webpack-dev-server": "4.15.2",
"webpack-merge": "5.10.0", "webpack-merge": "5.10.0",
"worker-loader": "3.0.8" "worker-loader": "3.0.8"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "11.11.4", "@emotion/react": "11.11.4",
"@emotion/styled": "11.11.0", "@emotion/styled": "11.11.0",
"@fontsource/noto-sans": "5.0.18", "@fontsource/noto-sans": "5.0.21",
"@fontsource/noto-sans-hk": "5.0.17", "@fontsource/noto-sans-hk": "5.0.18",
"@fontsource/noto-sans-jp": "5.0.17", "@fontsource/noto-sans-jp": "5.0.18",
"@fontsource/noto-sans-kr": "5.0.17", "@fontsource/noto-sans-kr": "5.0.18",
"@fontsource/noto-sans-sc": "5.0.17", "@fontsource/noto-sans-sc": "5.0.18",
"@fontsource/noto-sans-tc": "5.0.17", "@fontsource/noto-sans-tc": "5.0.18",
"@jellyfin/sdk": "0.0.0-unstable.202403100501", "@jellyfin/libass-wasm": "4.2.1",
"@jellyfin/sdk": "0.0.0-unstable.202403310501",
"@loadable/component": "5.16.3", "@loadable/component": "5.16.3",
"@mui/icons-material": "5.15.11", "@mui/icons-material": "5.15.11",
"@mui/material": "5.15.11", "@mui/material": "5.15.11",
@ -91,25 +92,23 @@
"blurhash": "2.0.5", "blurhash": "2.0.5",
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz", "classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
"classnames": "2.5.1", "classnames": "2.5.1",
"core-js": "3.35.1", "core-js": "3.36.1",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"dompurify": "3.0.1", "dompurify": "3.0.1",
"epubjs": "0.3.93", "epubjs": "0.3.93",
"escape-html": "1.0.3", "escape-html": "1.0.3",
"event-target-polyfill": "github:ThaUnknown/event-target-polyfill",
"fast-text-encoding": "1.0.6", "fast-text-encoding": "1.0.6",
"flv.js": "1.6.2", "flv.js": "1.6.2",
"headroom.js": "0.12.0", "headroom.js": "0.12.0",
"history": "5.3.0", "history": "5.3.0",
"hls.js": "1.5.7", "hls.js": "1.5.7",
"intersection-observer": "0.12.2", "intersection-observer": "0.12.2",
"jassub": "1.7.15",
"jellyfin-apiclient": "1.11.0", "jellyfin-apiclient": "1.11.0",
"jquery": "3.7.1", "jquery": "3.7.1",
"jstree": "3.3.16", "jstree": "3.3.16",
"libarchive.js": "1.3.0", "libarchive.js": "1.3.0",
"lodash-es": "4.17.21", "lodash-es": "4.17.21",
"markdown-it": "14.0.0", "markdown-it": "14.1.0",
"material-design-icons-iconfont": "6.7.0", "material-design-icons-iconfont": "6.7.0",
"native-promise-only": "0.8.1", "native-promise-only": "0.8.1",
"pdfjs-dist": "3.11.174", "pdfjs-dist": "3.11.174",
@ -117,12 +116,12 @@
"react-blurhash": "0.3.0", "react-blurhash": "0.3.0",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-lazy-load-image-component": "1.6.0", "react-lazy-load-image-component": "1.6.0",
"react-router-dom": "6.21.3", "react-router-dom": "6.22.3",
"resize-observer-polyfill": "1.5.1", "resize-observer-polyfill": "1.5.1",
"screenfull": "6.0.2", "screenfull": "6.0.2",
"sortablejs": "1.15.2", "sortablejs": "1.15.2",
"swiper": "11.0.5", "swiper": "11.0.7",
"usehooks-ts": "2.14.0", "usehooks-ts": "2.16.0",
"webcomponents.js": "0.7.24", "webcomponents.js": "0.7.24",
"whatwg-fetch": "3.6.20" "whatwg-fetch": "3.6.20"
}, },
@ -146,8 +145,8 @@
"start": "npm run serve", "start": "npm run serve",
"serve": "webpack serve --config webpack.dev.js", "serve": "webpack serve --config webpack.dev.js",
"build:analyze": "cross-env NODE_ENV=\"production\" webpack --config webpack.analyze.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:development": "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:production": "cross-env NODE_ENV=\"production\" webpack --config webpack.prod.js",
"build:check": "tsc --noEmit", "build:check": "tsc --noEmit",
"escheck": "es-check", "escheck": "es-check",
"lint": "eslint \"./\"", "lint": "eslint \"./\"",

View file

@ -21,7 +21,8 @@ const LIBRARY_PATHS = [
const PLAYBACK_PATHS = [ const PLAYBACK_PATHS = [
'/dashboard/playback/transcoding', '/dashboard/playback/transcoding',
'/dashboard/playback/resume', '/dashboard/playback/resume',
'/dashboard/playback/streaming' '/dashboard/playback/streaming',
'/dashboard/playback/trickplay'
]; ];
const ServerDrawerSection = () => { const ServerDrawerSection = () => {
@ -108,6 +109,9 @@ const ServerDrawerSection = () => {
<ListItemLink to='/dashboard/playback/streaming' sx={{ pl: 4 }}> <ListItemLink to='/dashboard/playback/streaming' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('TabStreaming')} /> <ListItemText inset primary={globalize.translate('TabStreaming')} />
</ListItemLink> </ListItemLink>
<ListItemLink to='/dashboard/playback/trickplay' sx={{ pl: 4 }}>
<ListItemText inset primary={globalize.translate('Trickplay')} />
</ListItemLink>
</List> </List>
</Collapse> </Collapse>
</List> </List>

View file

@ -9,5 +9,6 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'users/add', type: AsyncRouteType.Dashboard }, { path: 'users/add', type: AsyncRouteType.Dashboard },
{ path: 'users/parentalcontrol', type: AsyncRouteType.Dashboard }, { path: 'users/parentalcontrol', type: AsyncRouteType.Dashboard },
{ path: 'users/password', 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 }
]; ];

View file

@ -0,0 +1,305 @@
import type { ProcessPriorityClass, ServerConfiguration, TrickplayScanBehavior } from '@jellyfin/sdk/lib/generated-client';
import React, { type FunctionComponent, useCallback, useEffect, useRef } from 'react';
import globalize from '../../../../scripts/globalize';
import Page from '../../../../components/Page';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import ButtonElement from '../../../../elements/ButtonElement';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
import SelectElement from '../../../../elements/SelectElement';
import InputElement from '../../../../elements/InputElement';
import LinkTrickplayAcceleration from '../../../../components/dashboard/playback/trickplay/LinkTrickplayAcceleration';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import ServerConnections from '../../../../components/ServerConnections';
function onSaveComplete() {
loading.hide();
toast(globalize.translate('SettingsSaved'));
}
const PlaybackTrickplay: FunctionComponent = () => {
const element = useRef<HTMLDivElement>(null);
const loadConfig = useCallback((config) => {
const page = element.current;
const options = config.TrickplayOptions;
if (!page) {
console.error('Unexpected null reference');
return;
}
(page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked = options.EnableHwAcceleration;
(page.querySelector('#selectScanBehavior') as HTMLSelectElement).value = options.ScanBehavior;
(page.querySelector('#selectProcessPriority') as HTMLSelectElement).value = options.ProcessPriority;
(page.querySelector('#txtInterval') as HTMLInputElement).value = options.Interval;
(page.querySelector('#txtWidthResolutions') as HTMLInputElement).value = options.WidthResolutions.join(',');
(page.querySelector('#txtTileWidth') as HTMLInputElement).value = options.TileWidth;
(page.querySelector('#txtTileHeight') as HTMLInputElement).value = options.TileHeight;
(page.querySelector('#txtQscale') as HTMLInputElement).value = options.Qscale;
(page.querySelector('#txtJpegQuality') as HTMLInputElement).value = options.JpegQuality;
(page.querySelector('#txtProcessThreads') as HTMLInputElement).value = options.ProcessThreads;
loading.hide();
}, []);
const loadData = useCallback(() => {
loading.show();
ServerConnections.currentApiClient()?.getServerConfiguration().then(function (config) {
loadConfig(config);
}).catch(err => {
console.error('[PlaybackTrickplay] failed to fetch server config', err);
});
}, [loadConfig]);
useEffect(() => {
const page = element.current;
if (!page) {
console.error('Unexpected null reference');
return;
}
const saveConfig = (config: ServerConfiguration) => {
const apiClient = ServerConnections.currentApiClient();
if (!apiClient) {
console.error('[PlaybackTrickplay] No current apiclient instance');
return;
}
if (!config.TrickplayOptions) {
throw new Error('Unexpected null TrickplayOptions');
}
const options = config.TrickplayOptions;
options.EnableHwAcceleration = (page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked;
options.ScanBehavior = (page.querySelector('#selectScanBehavior') as HTMLSelectElement).value as TrickplayScanBehavior;
options.ProcessPriority = (page.querySelector('#selectProcessPriority') as HTMLSelectElement).value as ProcessPriorityClass;
options.Interval = Math.max(1, parseInt((page.querySelector('#txtInterval') as HTMLInputElement).value || '10000', 10));
options.WidthResolutions = (page.querySelector('#txtWidthResolutions') as HTMLInputElement).value.replace(' ', '').split(',').map(Number);
options.TileWidth = Math.max(1, parseInt((page.querySelector('#txtTileWidth') as HTMLInputElement).value || '10', 10));
options.TileHeight = Math.max(1, parseInt((page.querySelector('#txtTileHeight') as HTMLInputElement).value || '10', 10));
options.Qscale = Math.min(31, parseInt((page.querySelector('#txtQscale') as HTMLInputElement).value || '4', 10));
options.JpegQuality = Math.min(100, parseInt((page.querySelector('#txtJpegQuality') as HTMLInputElement).value || '90', 10));
options.ProcessThreads = parseInt((page.querySelector('#txtProcessThreads') as HTMLInputElement).value || '1', 10);
apiClient.updateServerConfiguration(config).then(() => {
onSaveComplete();
}).catch(err => {
console.error('[PlaybackTrickplay] failed to update config', err);
});
};
const onSubmit = (e: Event) => {
const apiClient = ServerConnections.currentApiClient();
if (!apiClient) {
console.error('[PlaybackTrickplay] No current apiclient instance');
return;
}
loading.show();
apiClient.getServerConfiguration().then(function (config) {
saveConfig(config);
}).catch(err => {
console.error('[PlaybackTrickplay] failed to fetch server config', err);
});
e.preventDefault();
e.stopPropagation();
return false;
};
(page.querySelector('.trickplayConfigurationForm') as HTMLFormElement).addEventListener('submit', onSubmit);
loadData();
}, [loadData]);
const optionScanBehavior = () => {
let content = '';
content += `<option value='NonBlocking'>${globalize.translate('NonBlockingScan')}</option>`;
content += `<option value='Blocking'>${globalize.translate('BlockingScan')}</option>`;
return content;
};
const optionProcessPriority = () => {
let content = '';
content += `<option value='High'>${globalize.translate('PriorityHigh')}</option>`;
content += `<option value='AboveNormal'>${globalize.translate('PriorityAboveNormal')}</option>`;
content += `<option value='Normal'>${globalize.translate('PriorityNormal')}</option>`;
content += `<option value='BelowNormal'>${globalize.translate('PriorityBelowNormal')}</option>`;
content += `<option value='Idle'>${globalize.translate('PriorityIdle')}</option>`;
return content;
};
return (
<Page
id='trickplayConfigurationPage'
className='mainAnimatedPage type-interior playbackConfigurationPage'
>
<div ref={element} className='content-primary'>
<div className='verticalSection'>
<SectionTitleContainer
title={globalize.translate('Trickplay')}
isLinkVisible={false}
/>
</div>
<form className='trickplayConfigurationForm'>
<div className='checkboxContainer checkboxContainer-withDescription'>
<CheckBoxElement
className='chkEnableHwAcceleration'
title='LabelTrickplayAccel'
/>
<div className='fieldDescription checkboxFieldDescription'>
<LinkTrickplayAcceleration
title='LabelTrickplayAccelHelp'
href='#/dashboard/playback/transcoding'
className='button-link'
/>
</div>
</div>
<div className='verticalSection'>
<div className='selectContainer fldSelectScanBehavior'>
<SelectElement
id='selectScanBehavior'
label='LabelScanBehavior'
>
{optionScanBehavior()}
</SelectElement>
<div className='fieldDescription'>
{globalize.translate('LabelScanBehaviorHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='selectContainer fldSelectProcessPriority'>
<SelectElement
id='selectProcessPriority'
label='LabelProcessPriority'
>
{optionProcessPriority()}
</SelectElement>
<div className='fieldDescription'>
{globalize.translate('LabelProcessPriorityHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtInterval'
label='LabelImageInterval'
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelImageIntervalHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='text'
id='txtWidthResolutions'
label='LabelWidthResolutions'
options={'required pattern="[0-9,]*"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelWidthResolutionsHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtTileWidth'
label='LabelTileWidth'
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelTileWidthHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtTileHeight'
label='LabelTileHeight'
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelTileHeightHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtJpegQuality'
label='LabelJpegQuality'
options={'required inputMode="numeric" pattern="[0-9]*" min="1" max="100"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelJpegQualityHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtQscale'
label='LabelQscale'
options={'required inputMode="numeric" pattern="[0-9]*" min="2" max="31"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelQscaleHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtProcessThreads'
label='LabelTrickplayThreads'
options={'required inputMode="numeric" pattern="[0-9]*" min="0"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelTrickplayThreadsHelp')}
</div>
</div>
</div>
<div>
<ButtonElement
type='submit'
className='raised button-submit block'
title='Save'
/>
</div>
</form>
</div>
</Page>
);
};
export default PlaybackTrickplay;

View file

@ -49,6 +49,7 @@ const UserProfiles: FunctionComponent = () => {
const showUserMenu = (elem: HTMLElement) => { const showUserMenu = (elem: HTMLElement) => {
const card = dom.parentWithClass(elem, 'card'); const card = dom.parentWithClass(elem, 'card');
const userId = card?.getAttribute('data-userid'); const userId = card?.getAttribute('data-userid');
const username = card?.getAttribute('data-username');
if (!userId) { if (!userId) {
console.error('Unexpected null user id'); console.error('Unexpected null user id');
@ -106,7 +107,7 @@ const UserProfiles: FunctionComponent = () => {
break; break;
case 'delete': case 'delete':
deleteUser(userId); deleteUser(userId, username);
} }
} }
}).catch(() => { }).catch(() => {
@ -117,12 +118,13 @@ const UserProfiles: FunctionComponent = () => {
}); });
}; };
const deleteUser = (id: string) => { const deleteUser = (id: string, username?: string | null) => {
const msg = globalize.translate('DeleteUserConfirmation'); const title = username ? globalize.translate('DeleteName', username) : globalize.translate('DeleteUser');
const text = globalize.translate('DeleteUserConfirmation');
confirm({ confirm({
title: globalize.translate('DeleteUser'), title,
text: msg, text,
confirmText: globalize.translate('Delete'), confirmText: globalize.translate('Delete'),
primary: 'delete' primary: 'delete'
}).then(function () { }).then(function () {

View file

@ -6,7 +6,7 @@ import escapeHTML from 'escape-html';
import globalize from '../../../../scripts/globalize'; import globalize from '../../../../scripts/globalize';
import LibraryMenu from '../../../../scripts/libraryMenu'; import LibraryMenu from '../../../../scripts/libraryMenu';
import AccessScheduleList from '../../../../components/dashboard/users/AccessScheduleList'; import AccessScheduleList from '../../../../components/dashboard/users/AccessScheduleList';
import BlockedTagList from '../../../../components/dashboard/users/BlockedTagList'; import TagList from '../../../../components/dashboard/users/TagList';
import ButtonElement from '../../../../elements/ButtonElement'; import ButtonElement from '../../../../elements/ButtonElement';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer'; import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import SectionTabs from '../../../../components/dashboard/users/SectionTabs'; import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
@ -16,6 +16,8 @@ import { getParameterByName } from '../../../../utils/url';
import CheckBoxElement from '../../../../elements/CheckBoxElement'; import CheckBoxElement from '../../../../elements/CheckBoxElement';
import SelectElement from '../../../../elements/SelectElement'; import SelectElement from '../../../../elements/SelectElement';
import Page from '../../../../components/Page'; import Page from '../../../../components/Page';
import prompt from '../../../../components/prompt/prompt';
import ServerConnections from 'components/ServerConnections';
type UnratedItem = { type UnratedItem = {
name: string; name: string;
@ -23,12 +25,44 @@ type UnratedItem = {
checkedAttribute: string checkedAttribute: string
}; };
function handleSaveUser(
page: HTMLDivElement,
getSchedulesFromPage: () => AccessSchedule[],
getAllowedTagsFromPage: () => string[],
getBlockedTagsFromPage: () => string[],
onSaveComplete: () => void
) {
return (user: UserDto) => {
const userId = user.Id;
const userPolicy = user.Policy;
if (!userId || !userPolicy) {
throw new Error('Unexpected null user id or policy');
}
const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
userPolicy.MaxParentalRating = Number.isNaN(parentalRating) ? null : parentalRating;
userPolicy.BlockUnratedItems = Array.prototype.filter
.call(page.querySelectorAll('.chkUnratedItem'), i => i.checked)
.map(i => i.getAttribute('data-itemtype'));
userPolicy.AccessSchedules = getSchedulesFromPage();
userPolicy.AllowedTags = getAllowedTagsFromPage();
userPolicy.BlockedTags = getBlockedTagsFromPage();
ServerConnections.getCurrentApiClientAsync()
.then(apiClient => apiClient.updateUserPolicy(userId, userPolicy))
.then(() => onSaveComplete())
.catch(err => {
console.error('[userparentalcontrol] failed to update user policy', err);
});
};
}
const UserParentalControl: FunctionComponent = () => { const UserParentalControl: FunctionComponent = () => {
const [ userName, setUserName ] = useState(''); const [ userName, setUserName ] = useState('');
const [ parentalRatings, setParentalRatings ] = useState<ParentalRating[]>([]); const [ parentalRatings, setParentalRatings ] = useState<ParentalRating[]>([]);
const [ unratedItems, setUnratedItems ] = useState<UnratedItem[]>([]); const [ unratedItems, setUnratedItems ] = useState<UnratedItem[]>([]);
const [ accessSchedules, setAccessSchedules ] = useState<AccessSchedule[]>([]); const [ accessSchedules, setAccessSchedules ] = useState<AccessSchedule[]>([]);
const [ blockedTags, setBlockedTags ] = useState([]); const [ allowedTags, setAllowedTags ] = useState<string[]>([]);
const [ blockedTags, setBlockedTags ] = useState<string[]>([]);
const element = useRef<HTMLDivElement>(null); const element = useRef<HTMLDivElement>(null);
@ -106,7 +140,28 @@ const UserParentalControl: FunctionComponent = () => {
blockUnratedItems.dispatchEvent(new CustomEvent('create')); blockUnratedItems.dispatchEvent(new CustomEvent('create'));
}, []); }, []);
const loadBlockedTags = useCallback((tags) => { const loadAllowedTags = useCallback((tags: string[]) => {
const page = element.current;
if (!page) {
console.error('Unexpected null reference');
return;
}
setAllowedTags(tags);
const allowedTagsElem = page.querySelector('.allowedTags') as HTMLDivElement;
for (const btnDeleteTag of allowedTagsElem.querySelectorAll('.btnDeleteTag')) {
btnDeleteTag.addEventListener('click', function () {
const tag = btnDeleteTag.getAttribute('data-tag');
const newTags = tags.filter(t => t !== tag);
loadAllowedTags(newTags);
});
}
}, []);
const loadBlockedTags = useCallback((tags: string[]) => {
const page = element.current; const page = element.current;
if (!page) { if (!page) {
@ -121,9 +176,7 @@ const UserParentalControl: FunctionComponent = () => {
for (const btnDeleteTag of blockedTagsElem.querySelectorAll('.btnDeleteTag')) { for (const btnDeleteTag of blockedTagsElem.querySelectorAll('.btnDeleteTag')) {
btnDeleteTag.addEventListener('click', function () { btnDeleteTag.addEventListener('click', function () {
const tag = btnDeleteTag.getAttribute('data-tag'); const tag = btnDeleteTag.getAttribute('data-tag');
const newTags = tags.filter(function (t: string) { const newTags = tags.filter(t => t !== tag);
return t != tag;
});
loadBlockedTags(newTags); loadBlockedTags(newTags);
}); });
} }
@ -145,15 +198,13 @@ const UserParentalControl: FunctionComponent = () => {
btnDelete.addEventListener('click', function () { btnDelete.addEventListener('click', function () {
const index = parseInt(btnDelete.getAttribute('data-index') ?? '0', 10); const index = parseInt(btnDelete.getAttribute('data-index') ?? '0', 10);
schedules.splice(index, 1); schedules.splice(index, 1);
const newindex = schedules.filter(function (i: number) { const newindex = schedules.filter((i: number) => i != index);
return i != index;
});
renderAccessSchedule(newindex); renderAccessSchedule(newindex);
}); });
} }
}, []); }, []);
const loadUser = useCallback((user, allParentalRatings) => { const loadUser = useCallback((user: UserDto, allParentalRatings: ParentalRating[]) => {
const page = element.current; const page = element.current;
if (!page) { if (!page) {
@ -161,34 +212,33 @@ const UserParentalControl: FunctionComponent = () => {
return; return;
} }
setUserName(user.Name); setUserName(user.Name || '');
LibraryMenu.setTitle(user.Name); LibraryMenu.setTitle(user.Name);
loadUnratedItems(user); loadUnratedItems(user);
loadBlockedTags(user.Policy.BlockedTags); loadAllowedTags(user.Policy?.AllowedTags || []);
loadBlockedTags(user.Policy?.BlockedTags || []);
populateRatings(allParentalRatings); populateRatings(allParentalRatings);
let ratingValue = ''; let ratingValue = '';
if (user.Policy?.MaxParentalRating) {
if (user.Policy.MaxParentalRating != null) { allParentalRatings.forEach(rating => {
for (let i = 0, length = allParentalRatings.length; i < length; i++) { if (rating.Value && user.Policy?.MaxParentalRating && user.Policy.MaxParentalRating >= rating.Value) {
const rating = allParentalRatings[i]; ratingValue = `${rating.Value}`;
if (user.Policy.MaxParentalRating >= rating.Value) {
ratingValue = rating.Value;
}
} }
});
} }
(page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value = ratingValue; (page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value = ratingValue;
if (user.Policy.IsAdministrator) { if (user.Policy?.IsAdministrator) {
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.add('hide'); (page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.add('hide');
} else { } else {
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.remove('hide'); (page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.remove('hide');
} }
renderAccessSchedule(user.Policy.AccessSchedules || []); renderAccessSchedule(user.Policy?.AccessSchedules || []);
loading.hide(); loading.hide();
}, [loadBlockedTags, loadUnratedItems, populateRatings, renderAccessSchedule]); }, [loadAllowedTags, loadBlockedTags, loadUnratedItems, populateRatings, renderAccessSchedule]);
const loadData = useCallback(() => { const loadData = useCallback(() => {
loading.show(); loading.show();
@ -212,32 +262,6 @@ const UserParentalControl: FunctionComponent = () => {
loadData(); loadData();
const onSaveComplete = () => {
loading.hide();
toast(globalize.translate('SettingsSaved'));
};
const saveUser = (user: UserDto) => {
if (!user.Id || !user.Policy) {
throw new Error('Unexpected null user id or policy');
}
const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
user.Policy.MaxParentalRating = Number.isNaN(parentalRating) ? null : parentalRating;
user.Policy.BlockUnratedItems = Array.prototype.filter.call(page.querySelectorAll('.chkUnratedItem'), function (i) {
return i.checked;
}).map(function (i) {
return i.getAttribute('data-itemtype');
});
user.Policy.AccessSchedules = getSchedulesFromPage();
user.Policy.BlockedTags = getBlockedTagsFromPage();
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
onSaveComplete();
}).catch(err => {
console.error('[userparentalcontrol] failed to update user policy', err);
});
};
const showSchedulePopup = (schedule: AccessSchedule, index: number) => { const showSchedulePopup = (schedule: AccessSchedule, index: number) => {
schedule = schedule || {}; schedule = schedule || {};
import('../../../../components/accessSchedule/accessSchedule').then(({ default: accessschedule }) => { import('../../../../components/accessSchedule/accessSchedule').then(({ default: accessschedule }) => {
@ -270,6 +294,27 @@ const UserParentalControl: FunctionComponent = () => {
}) as AccessSchedule[]; }) as AccessSchedule[];
}; };
const getAllowedTagsFromPage = () => {
return Array.prototype.map.call(page.querySelectorAll('.allowedTag'), function (elem) {
return elem.getAttribute('data-tag');
}) as string[];
};
const showAllowedTagPopup = () => {
prompt({
label: globalize.translate('LabelTag')
}).then(function (value) {
const tags = getAllowedTagsFromPage();
if (tags.indexOf(value) == -1) {
tags.push(value);
loadAllowedTags(tags);
}
}).catch(() => {
// prompt closed
});
};
const getBlockedTagsFromPage = () => { const getBlockedTagsFromPage = () => {
return Array.prototype.map.call(page.querySelectorAll('.blockedTag'), function (elem) { return Array.prototype.map.call(page.querySelectorAll('.blockedTag'), function (elem) {
return elem.getAttribute('data-tag'); return elem.getAttribute('data-tag');
@ -277,7 +322,6 @@ const UserParentalControl: FunctionComponent = () => {
}; };
const showBlockedTagPopup = () => { const showBlockedTagPopup = () => {
import('../../../../components/prompt/prompt').then(({ default: prompt }) => {
prompt({ prompt({
label: globalize.translate('LabelTag') label: globalize.translate('LabelTag')
}).then(function (value) { }).then(function (value) {
@ -290,11 +334,15 @@ const UserParentalControl: FunctionComponent = () => {
}).catch(() => { }).catch(() => {
// prompt closed // prompt closed
}); });
}).catch(err => {
console.error('[userparentalcontrol] failed to load prompt', err);
});
}; };
const onSaveComplete = () => {
loading.hide();
toast(globalize.translate('SettingsSaved'));
};
const saveUser = handleSaveUser(page, getSchedulesFromPage, getAllowedTagsFromPage, getBlockedTagsFromPage, onSaveComplete);
const onSubmit = (e: Event) => { const onSubmit = (e: Event) => {
loading.show(); loading.show();
const userId = getParameterByName('userId'); const userId = getParameterByName('userId');
@ -318,12 +366,16 @@ const UserParentalControl: FunctionComponent = () => {
}, -1); }, -1);
}); });
(page.querySelector('#btnAddAllowedTag') as HTMLButtonElement).addEventListener('click', function () {
showAllowedTagPopup();
});
(page.querySelector('#btnAddBlockedTag') as HTMLButtonElement).addEventListener('click', function () { (page.querySelector('#btnAddBlockedTag') as HTMLButtonElement).addEventListener('click', function () {
showBlockedTagPopup(); showBlockedTagPopup();
}); });
(page.querySelector('.userParentalControlForm') as HTMLFormElement).addEventListener('submit', onSubmit); (page.querySelector('.userParentalControlForm') as HTMLFormElement).addEventListener('submit', onSubmit);
}, [loadBlockedTags, loadData, renderAccessSchedule]); }, [loadAllowedTags, loadBlockedTags, loadData, renderAccessSchedule]);
const optionMaxParentalRating = () => { const optionMaxParentalRating = () => {
let content = ''; let content = '';
@ -378,6 +430,30 @@ const UserParentalControl: FunctionComponent = () => {
</div> </div>
</div> </div>
<br /> <br />
<div className='verticalSection' style={{ marginBottom: '2em' }}>
<SectionTitleContainer
SectionClassName='detailSectionHeader'
title={globalize.translate('LabelAllowContentWithTags')}
isBtnVisible={true}
btnId='btnAddAllowedTag'
btnClassName='fab submit sectionTitleButton'
btnTitle='Add'
btnIcon='add'
isLinkVisible={false}
/>
<div className='fieldDescription'>
{globalize.translate('AllowContentWithTagsHelp')}
</div>
<div className='allowedTags' style={{ marginTop: '.5em' }}>
{allowedTags?.map(tag => {
return <TagList
key={tag}
tag={tag}
tagType='allowedTag'
/>;
})}
</div>
</div>
<div className='verticalSection' style={{ marginBottom: '2em' }}> <div className='verticalSection' style={{ marginBottom: '2em' }}>
<SectionTitleContainer <SectionTitleContainer
SectionClassName='detailSectionHeader' SectionClassName='detailSectionHeader'
@ -389,11 +465,15 @@ const UserParentalControl: FunctionComponent = () => {
btnIcon='add' btnIcon='add'
isLinkVisible={false} isLinkVisible={false}
/> />
<div className='fieldDescription'>
{globalize.translate('BlockContentWithTagsHelp')}
</div>
<div className='blockedTags' style={{ marginTop: '.5em' }}> <div className='blockedTags' style={{ marginTop: '.5em' }}>
{blockedTags.map(tag => { {blockedTags.map(tag => {
return <BlockedTagList return <TagList
key={tag} key={tag}
tag={tag} tag={tag}
tagType='blockedTag'
/>; />;
})} })}
</div> </div>

View file

@ -182,6 +182,7 @@ const UserEdit: FunctionComponent = () => {
(page.querySelector('.chkDisabled') as HTMLInputElement).checked = user.Policy.IsDisabled; (page.querySelector('.chkDisabled') as HTMLInputElement).checked = user.Policy.IsDisabled;
(page.querySelector('.chkIsHidden') as HTMLInputElement).checked = user.Policy.IsHidden; (page.querySelector('.chkIsHidden') as HTMLInputElement).checked = user.Policy.IsHidden;
(page.querySelector('.chkEnableCollectionManagement') as HTMLInputElement).checked = user.Policy.EnableCollectionManagement; (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('.chkRemoteControlSharedDevices') as HTMLInputElement).checked = user.Policy.EnableSharedDeviceControl;
(page.querySelector('.chkEnableRemoteControlOtherUsers') as HTMLInputElement).checked = user.Policy.EnableRemoteControlOfOtherUsers; (page.querySelector('.chkEnableRemoteControlOtherUsers') as HTMLInputElement).checked = user.Policy.EnableRemoteControlOfOtherUsers;
(page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked = user.Policy.EnableContentDownloading; (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.EnableVideoPlaybackTranscoding = (page.querySelector('.chkEnableVideoPlaybackTranscoding') as HTMLInputElement).checked;
user.Policy.EnablePlaybackRemuxing = (page.querySelector('.chkEnableVideoPlaybackRemuxing') as HTMLInputElement).checked; user.Policy.EnablePlaybackRemuxing = (page.querySelector('.chkEnableVideoPlaybackRemuxing') as HTMLInputElement).checked;
user.Policy.EnableCollectionManagement = (page.querySelector('.chkEnableCollectionManagement') 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.ForceRemoteSourceTranscoding = (page.querySelector('.chkForceRemoteSourceTranscoding') as HTMLInputElement).checked;
user.Policy.EnableContentDownloading = (page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked; user.Policy.EnableContentDownloading = (page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked;
user.Policy.EnableRemoteAccess = (page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked; user.Policy.EnableRemoteAccess = (page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked;
@ -392,6 +394,11 @@ const UserEdit: FunctionComponent = () => {
className='chkEnableCollectionManagement' className='chkEnableCollectionManagement'
title='AllowCollectionManagement' title='AllowCollectionManagement'
/> />
<CheckBoxElement
labelClassName='checkboxContainer'
className='chkEnableSubtitleManagement'
title='AllowSubtitleManagement'
/>
<div id='featureAccessFields' className='verticalSection'> <div id='featureAccessFields' className='verticalSection'>
<h2 className='paperListLabel'> <h2 className='paperListLabel'>
{globalize.translate('HeaderFeatureAccess')} {globalize.translate('HeaderFeatureAccess')}

View file

@ -8,5 +8,6 @@ export const ASYNC_USER_ROUTES: AsyncRoute[] = [
{ path: 'movies.html', page: 'movies', type: AsyncRouteType.Experimental }, { path: 'movies.html', page: 'movies', type: AsyncRouteType.Experimental },
{ path: 'tv.html', page: 'shows', type: AsyncRouteType.Experimental }, { path: 'tv.html', page: 'shows', type: AsyncRouteType.Experimental },
{ path: 'music.html', page: 'music', 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 }
]; ];

View file

@ -25,12 +25,6 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [
controller: 'user/controls/index', controller: 'user/controls/index',
view: 'user/controls/index.html' view: 'user/controls/index.html'
} }
}, {
path: 'mypreferencesdisplay.html',
pageProps: {
controller: 'user/display/index',
view: 'user/display/index.html'
}
}, { }, {
path: 'mypreferenceshome.html', path: 'mypreferenceshome.html',
pageProps: { pageProps: {

View file

@ -0,0 +1,203 @@
import Checkbox from '@mui/material/Checkbox';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormHelperText from '@mui/material/FormHelperText';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import Select, { SelectChangeEvent } from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import React, { Fragment } from 'react';
import { appHost } from 'components/apphost';
import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
import { DisplaySettingsValues } from './types';
import { useScreensavers } from './hooks/useScreensavers';
import { useServerThemes } from './hooks/useServerThemes';
interface DisplayPreferencesProps {
onChange: (event: SelectChangeEvent | React.SyntheticEvent) => void;
values: DisplaySettingsValues;
}
export function DisplayPreferences({ onChange, values }: Readonly<DisplayPreferencesProps>) {
const { user } = useApi();
const { screensavers } = useScreensavers();
const { themes } = useServerThemes();
return (
<Stack spacing={3}>
<Typography variant='h2'>{globalize.translate('Display')}</Typography>
{ appHost.supports('displaymode') && (
<FormControl fullWidth>
<InputLabel id='display-settings-layout-label'>{globalize.translate('LabelDisplayMode')}</InputLabel>
<Select
aria-describedby='display-settings-layout-description'
inputProps={{
name: 'layout'
}}
labelId='display-settings-layout-label'
onChange={onChange}
value={values.layout}
>
<MenuItem value='auto'>{globalize.translate('Auto')}</MenuItem>
<MenuItem value='desktop'>{globalize.translate('Desktop')}</MenuItem>
<MenuItem value='mobile'>{globalize.translate('Mobile')}</MenuItem>
<MenuItem value='tv'>{globalize.translate('TV')}</MenuItem>
<MenuItem value='experimental'>{globalize.translate('Experimental')}</MenuItem>
</Select>
<FormHelperText component={Stack} id='display-settings-layout-description'>
<span>{globalize.translate('DisplayModeHelp')}</span>
<span>{globalize.translate('LabelPleaseRestart')}</span>
</FormHelperText>
</FormControl>
) }
{ themes.length > 0 && (
<FormControl fullWidth>
<InputLabel id='display-settings-theme-label'>{globalize.translate('LabelTheme')}</InputLabel>
<Select
inputProps={{
name: 'theme'
}}
labelId='display-settings-theme-label'
onChange={onChange}
value={values.theme}
>
{ ...themes.map(({ id, name }) => (
<MenuItem key={id} value={id}>{name}</MenuItem>
))}
</Select>
</FormControl>
) }
<FormControl fullWidth>
<FormControlLabel
aria-describedby='display-settings-disable-css-description'
control={
<Checkbox
checked={values.disableCustomCss}
onChange={onChange}
/>
}
label={globalize.translate('DisableCustomCss')}
name='disableCustomCss'
/>
<FormHelperText id='display-settings-disable-css-description'>
{globalize.translate('LabelDisableCustomCss')}
</FormHelperText>
</FormControl>
<FormControl fullWidth>
<TextField
aria-describedby='display-settings-custom-css-description'
value={values.customCss}
label={globalize.translate('LabelCustomCss')}
multiline
name='customCss'
onChange={onChange}
/>
<FormHelperText id='display-settings-custom-css-description'>
{globalize.translate('LabelLocalCustomCss')}
</FormHelperText>
</FormControl>
{ themes.length > 0 && user?.Policy?.IsAdministrator && (
<FormControl fullWidth>
<InputLabel id='display-settings-dashboard-theme-label'>{globalize.translate('LabelDashboardTheme')}</InputLabel>
<Select
inputProps={{
name: 'dashboardTheme'
}}
labelId='display-settings-dashboard-theme-label'
onChange={ onChange }
value={ values.dashboardTheme }
>
{ ...themes.map(({ id, name }) => (
<MenuItem key={ id } value={ id }>{ name }</MenuItem>
)) }
</Select>
</FormControl>
) }
{ screensavers.length > 0 && appHost.supports('screensaver') && (
<Fragment>
<FormControl fullWidth>
<InputLabel id='display-settings-screensaver-label'>{globalize.translate('LabelScreensaver')}</InputLabel>
<Select
inputProps={{
name: 'screensaver'
}}
labelId='display-settings-screensaver-label'
onChange={onChange}
value={values.screensaver}
>
{ ...screensavers.map(({ id, name }) => (
<MenuItem key={id} value={id}>{name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
<TextField
aria-describedby='display-settings-screensaver-interval-description'
value={values.screensaverInterval}
inputProps={{
inputMode: 'numeric',
max: '3600',
min: '1',
pattern: '[0-9]',
required: true,
step: '1',
type: 'number'
}}
label={globalize.translate('LabelBackdropScreensaverInterval')}
name='screensaverInterval'
onChange={onChange}
/>
<FormHelperText id='display-settings-screensaver-interval-description'>
{globalize.translate('LabelBackdropScreensaverIntervalHelp')}
</FormHelperText>
</FormControl>
</Fragment>
) }
<FormControl fullWidth>
<FormControlLabel
aria-describedby='display-settings-faster-animations-description'
control={
<Checkbox
checked={values.enableFasterAnimation}
onChange={onChange}
/>
}
label={globalize.translate('EnableFasterAnimations')}
name='enableFasterAnimation'
/>
<FormHelperText id='display-settings-faster-animations-description'>
{globalize.translate('EnableFasterAnimationsHelp')}
</FormHelperText>
</FormControl>
<FormControl fullWidth>
<FormControlLabel
aria-describedby='display-settings-blurhash-description'
control={
<Checkbox
checked={values.enableBlurHash}
onChange={onChange}
/>
}
label={globalize.translate('EnableBlurHash')}
name='enableBlurHash'
/>
<FormHelperText id='display-settings-blurhash-description'>
{globalize.translate('EnableBlurHashHelp')}
</FormHelperText>
</FormControl>
</Stack>
);
}

View file

@ -0,0 +1,40 @@
import Checkbox from '@mui/material/Checkbox';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormHelperText from '@mui/material/FormHelperText';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import React from 'react';
import globalize from 'scripts/globalize';
import { DisplaySettingsValues } from './types';
interface ItemDetailPreferencesProps {
onChange: (event: React.SyntheticEvent) => void;
values: DisplaySettingsValues;
}
export function ItemDetailPreferences({ onChange, values }: Readonly<ItemDetailPreferencesProps>) {
return (
<Stack spacing={2}>
<Typography variant='h2'>{globalize.translate('ItemDetails')}</Typography>
<FormControl fullWidth>
<FormControlLabel
aria-describedby='display-settings-item-details-banner-description'
control={
<Checkbox
checked={values.enableItemDetailsBanner}
onChange={onChange}
/>
}
label={globalize.translate('EnableDetailsBanner')}
name='enableItemDetailsBanner'
/>
<FormHelperText id='display-settings-item-details-banner-description'>
{globalize.translate('EnableDetailsBannerHelp')}
</FormHelperText>
</FormControl>
</Stack>
);
}

View file

@ -0,0 +1,114 @@
import Checkbox from '@mui/material/Checkbox';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormHelperText from '@mui/material/FormHelperText';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import React from 'react';
import globalize from 'scripts/globalize';
import { DisplaySettingsValues } from './types';
interface LibraryPreferencesProps {
onChange: (event: React.SyntheticEvent) => void;
values: DisplaySettingsValues;
}
export function LibraryPreferences({ onChange, values }: Readonly<LibraryPreferencesProps>) {
return (
<Stack spacing={3}>
<Typography variant='h2'>{globalize.translate('HeaderLibraries')}</Typography>
<FormControl fullWidth>
<TextField
aria-describedby='display-settings-lib-pagesize-description'
inputProps={{
type: 'number',
inputMode: 'numeric',
max: '1000',
min: '0',
pattern: '[0-9]',
required: true,
step: '1'
}}
value={values.libraryPageSize}
label={globalize.translate('LabelLibraryPageSize')}
name='libraryPageSize'
onChange={onChange}
/>
<FormHelperText id='display-settings-lib-pagesize-description'>
{globalize.translate('LabelLibraryPageSizeHelp')}
</FormHelperText>
</FormControl>
<FormControl fullWidth>
<FormControlLabel
aria-describedby='display-settings-lib-backdrops-description'
control={
<Checkbox
checked={values.enableLibraryBackdrops}
onChange={onChange}
/>
}
label={globalize.translate('Backdrops')}
name='enableLibraryBackdrops'
/>
<FormHelperText id='display-settings-lib-backdrops-description'>
{globalize.translate('EnableBackdropsHelp')}
</FormHelperText>
</FormControl>
<FormControl fullWidth>
<FormControlLabel
aria-describedby='display-settings-lib-theme-songs-description'
control={
<Checkbox
checked={values.enableLibraryThemeSongs}
onChange={onChange}
/>
}
label={globalize.translate('ThemeSongs')}
name='enableLibraryThemeSongs'
/>
<FormHelperText id='display-settings-lib-theme-songs-description'>
{globalize.translate('EnableThemeSongsHelp')}
</FormHelperText>
</FormControl>
<FormControl fullWidth>
<FormControlLabel
aria-describedby='display-settings-lib-theme-videos-description'
control={
<Checkbox
checked={values.enableLibraryThemeVideos}
onChange={onChange}
/>
}
label={globalize.translate('ThemeVideos')}
name='enableLibraryThemeVideos'
/>
<FormHelperText id='display-settings-lib-theme-videos-description'>
{globalize.translate('EnableThemeVideosHelp')}
</FormHelperText>
</FormControl>
<FormControl fullWidth>
<FormControlLabel
aria-describedby='display-settings-show-missing-episodes-description'
control={
<Checkbox
checked={values.displayMissingEpisodes}
onChange={onChange}
/>
}
label={globalize.translate('DisplayMissingEpisodesWithinSeasons')}
name='displayMissingEpisodes'
/>
<FormHelperText id='display-settings-show-missing-episodes-description'>
{globalize.translate('DisplayMissingEpisodesWithinSeasonsHelp')}
</FormHelperText>
</FormControl>
</Stack>
);
}

View file

@ -0,0 +1,80 @@
import FormControl from '@mui/material/FormControl';
import FormHelperText from '@mui/material/FormHelperText';
import InputLabel from '@mui/material/InputLabel';
import Link from '@mui/material/Link';
import MenuItem from '@mui/material/MenuItem';
import Select, { SelectChangeEvent } from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import React from 'react';
import { appHost } from 'components/apphost';
import datetime from 'scripts/datetime';
import globalize from 'scripts/globalize';
import { DATE_LOCALE_OPTIONS, LANGUAGE_OPTIONS } from './constants';
import { DisplaySettingsValues } from './types';
interface LocalizationPreferencesProps {
onChange: (event: SelectChangeEvent) => void;
values: DisplaySettingsValues;
}
export function LocalizationPreferences({ onChange, values }: Readonly<LocalizationPreferencesProps>) {
if (!appHost.supports('displaylanguage') && !datetime.supportsLocalization()) {
return null;
}
return (
<Stack spacing={3}>
<Typography variant='h2'>{globalize.translate('Localization')}</Typography>
{ appHost.supports('displaylanguage') && (
<FormControl fullWidth>
<InputLabel id='display-settings-language-label'>{globalize.translate('LabelDisplayLanguage')}</InputLabel>
<Select
aria-describedby='display-settings-language-description'
inputProps={{
name: 'language'
}}
labelId='display-settings-language-label'
onChange={onChange}
value={values.language}
>
{ ...LANGUAGE_OPTIONS.map(({ value, label }) => (
<MenuItem key={value } value={value}>{ label }</MenuItem>
))}
</Select>
<FormHelperText component={Stack} id='display-settings-language-description'>
<span>{globalize.translate('LabelDisplayLanguageHelp')}</span>
{ appHost.supports('externallinks') && (
<Link
href='https://github.com/jellyfin/jellyfin'
rel='noopener noreferrer'
target='_blank'
>
{globalize.translate('LearnHowYouCanContribute')}
</Link>
) }
</FormHelperText>
</FormControl>
) }
{ datetime.supportsLocalization() && (
<FormControl fullWidth>
<InputLabel id='display-settings-locale-label'>{globalize.translate('LabelDateTimeLocale')}</InputLabel>
<Select
inputProps={{
name: 'dateTimeLocale'
}}
labelId='display-settings-locale-label'
onChange={onChange}
value={values.dateTimeLocale}
>
{...DATE_LOCALE_OPTIONS.map(({ value, label }) => (
<MenuItem key={value} value={value}>{label}</MenuItem>
))}
</Select>
</FormControl>
) }
</Stack>
);
}

View file

@ -0,0 +1,80 @@
import Checkbox from '@mui/material/Checkbox';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormHelperText from '@mui/material/FormHelperText';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import React from 'react';
import globalize from 'scripts/globalize';
import { DisplaySettingsValues } from './types';
interface NextUpPreferencesProps {
onChange: (event: React.SyntheticEvent) => void;
values: DisplaySettingsValues;
}
export function NextUpPreferences({ onChange, values }: Readonly<NextUpPreferencesProps>) {
return (
<Stack spacing={3}>
<Typography variant='h2'>{globalize.translate('NextUp')}</Typography>
<FormControl fullWidth>
<TextField
aria-describedby='display-settings-max-days-next-up-description'
value={values.maxDaysForNextUp}
inputProps={{
type: 'number',
inputMode: 'numeric',
max: '1000',
min: '0',
pattern: '[0-9]',
required: true,
step: '1'
}}
label={globalize.translate('LabelMaxDaysForNextUp')}
name='maxDaysForNextUp'
onChange={onChange}
/>
<FormHelperText id='display-settings-max-days-next-up-description'>
{globalize.translate('LabelMaxDaysForNextUpHelp')}
</FormHelperText>
</FormControl>
<FormControl fullWidth>
<FormControlLabel
aria-describedby='display-settings-next-up-rewatching-description'
control={
<Checkbox
checked={values.enableRewatchingInNextUp}
onChange={onChange}
/>
}
label={globalize.translate('EnableRewatchingNextUp')}
name='enableRewatchingInNextUp'
/>
<FormHelperText id='display-settings-next-up-rewatching-description'>
{globalize.translate('EnableRewatchingNextUpHelp')}
</FormHelperText>
</FormControl>
<FormControl fullWidth>
<FormControlLabel
aria-describedby='display-settings-next-up-images-description'
control={
<Checkbox
checked={values.episodeImagesInNextUp}
onChange={onChange}
/>
}
label={globalize.translate('UseEpisodeImagesInNextUp')}
name='episodeImagesInNextUp'
/>
<FormHelperText id='display-settings-next-up-images-description'>
{globalize.translate('UseEpisodeImagesInNextUpHelp')}
</FormHelperText>
</FormControl>
</Stack>
);
}

View file

@ -0,0 +1,79 @@
import globalize from 'scripts/globalize';
export const LANGUAGE_OPTIONS = [
{ value: 'auto', label: globalize.translate('Auto') },
{ value: 'af', label: 'Afrikaans' },
{ value: 'ar', label: 'العربية' },
{ value: 'be-BY', label: 'Беларуская' },
{ value: 'bg-BG', label: 'Български' },
{ value: 'bn_BD', label: 'বাংলা (বাংলাদেশ)' },
{ value: 'ca', label: 'Català' },
{ value: 'cs', label: 'Čeština' },
{ value: 'cy', label: 'Cymraeg' },
{ value: 'da', label: 'Dansk' },
{ value: 'de', label: 'Deutsch' },
{ value: 'el', label: 'Ελληνικά' },
{ value: 'en-GB', label: 'English (United Kingdom)' },
{ value: 'en-US', label: 'English' },
{ value: 'eo', label: 'Esperanto' },
{ value: 'es', label: 'Español' },
{ value: 'es_419', label: 'Español americano' },
{ value: 'es-AR', label: 'Español (Argentina)' },
{ value: 'es_DO', label: 'Español (Dominicana)' },
{ value: 'es-MX', label: 'Español (México)' },
{ value: 'et', label: 'Eesti' },
{ value: 'eu', label: 'Euskara' },
{ value: 'fa', label: 'فارسی' },
{ value: 'fi', label: 'Suomi' },
{ value: 'fil', label: 'Filipino' },
{ value: 'fr', label: 'Français' },
{ value: 'fr-CA', label: 'Français (Canada)' },
{ value: 'gl', label: 'Galego' },
{ value: 'gsw', label: 'Schwiizerdütsch' },
{ value: 'he', label: 'עִבְרִית' },
{ value: 'hi-IN', label: 'हिन्दी' },
{ value: 'hr', label: 'Hrvatski' },
{ value: 'hu', label: 'Magyar' },
{ value: 'id', label: 'Bahasa Indonesia' },
{ value: 'is-IS', label: 'Íslenska' },
{ value: 'it', label: 'Italiano' },
{ value: 'ja', label: '日本語' },
{ value: 'kk', label: 'Qazaqşa' },
{ value: 'ko', label: '한국어' },
{ value: 'lt-LT', label: 'Lietuvių' },
{ value: 'lv', label: 'Latviešu' },
{ value: 'mk', label: 'Македонски' },
{ value: 'ml', label: 'മലയാളം' },
{ value: 'mr', label: 'मराठी' },
{ value: 'ms', label: 'Bahasa Melayu' },
{ value: 'nb', label: 'Norsk bokmål' },
{ value: 'ne', label: 'नेपाली' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'nn', label: 'Norsk nynorsk' },
{ value: 'pa', label: 'ਪੰਜਾਬੀ' },
{ value: 'pl', label: 'Polski' },
{ value: 'pr', label: 'Pirate' },
{ value: 'pt', label: 'Português' },
{ value: 'pt-BR', label: 'Português (Brasil)' },
{ value: 'pt-PT', label: 'Português (Portugal)' },
{ value: 'ro', label: 'Românește' },
{ value: 'ru', label: 'Русский' },
{ value: 'sk', label: 'Slovenčina' },
{ value: 'sl-SI', label: 'Slovenščina' },
{ value: 'sq', label: 'Shqip' },
{ value: 'sr', label: 'Српски' },
{ value: 'sv', label: 'Svenska' },
{ value: 'ta', label: 'தமிழ்' },
{ value: 'te', label: 'తెలుగు' },
{ value: 'th', label: 'ภาษาไทย' },
{ value: 'tr', label: 'Türkçe' },
{ value: 'uk', label: 'Українська' },
{ value: 'ur_PK', label: ' اُردُو' },
{ value: 'vi', label: 'Tiếng Việt' },
{ value: 'zh-CN', label: '汉语 (简化字)' },
{ value: 'zh-TW', label: '漢語 (繁体字)' },
{ value: 'zh-HK', label: '廣東話 (香港)' }
];
// NOTE: Option `Euskara` (eu) does not exist in legacy date locale options.
export const DATE_LOCALE_OPTIONS = LANGUAGE_OPTIONS.filter(({ value }) => value !== 'eu');

View file

@ -0,0 +1,46 @@
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import toast from 'components/toast/toast';
import globalize from 'scripts/globalize';
import { DisplaySettingsValues } from '../types';
import { useDisplaySettings } from './useDisplaySettings';
export function useDisplaySettingForm() {
const [urlParams] = useSearchParams();
const {
displaySettings,
loading,
saveDisplaySettings
} = useDisplaySettings({ userId: urlParams.get('userId') });
const [formValues, setFormValues] = useState<DisplaySettingsValues>();
useEffect(() => {
if (!loading && displaySettings && !formValues) {
setFormValues(displaySettings);
}
}, [formValues, loading, displaySettings]);
const updateField = useCallback(({ name, value }) => {
if (formValues) {
setFormValues({
...formValues,
[name]: value
});
}
}, [formValues, setFormValues]);
const submitChanges = useCallback(async () => {
if (formValues) {
await saveDisplaySettings(formValues);
toast(globalize.translate('SettingsSaved'));
}
}, [formValues, saveDisplaySettings]);
return {
loading,
values: formValues,
submitChanges,
updateField
};
}

View file

@ -0,0 +1,159 @@
import { UserDto } from '@jellyfin/sdk/lib/generated-client';
import { ApiClient } from 'jellyfin-apiclient';
import { useCallback, useEffect, useState } from 'react';
import { appHost } from 'components/apphost';
import layoutManager from 'components/layoutManager';
import { useApi } from 'hooks/useApi';
import themeManager from 'scripts/themeManager';
import { currentSettings, UserSettings } from 'scripts/settings/userSettings';
import { DisplaySettingsValues } from '../types';
interface UseDisplaySettingsParams {
userId?: string | null;
}
export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {
const [loading, setLoading] = useState(true);
const [userSettings, setUserSettings] = useState<UserSettings>();
const [displaySettings, setDisplaySettings] = useState<DisplaySettingsValues>();
const { __legacyApiClient__, user: currentUser } = useApi();
useEffect(() => {
if (!userId || !currentUser || !__legacyApiClient__) {
return;
}
setLoading(true);
void (async () => {
const loadedSettings = await loadDisplaySettings({ api: __legacyApiClient__, currentUser, userId });
setDisplaySettings(loadedSettings.displaySettings);
setUserSettings(loadedSettings.userSettings);
setLoading(false);
})();
return () => {
setLoading(false);
};
}, [__legacyApiClient__, currentUser, userId]);
const saveSettings = useCallback(async (newSettings: DisplaySettingsValues) => {
if (!userId || !userSettings || !__legacyApiClient__) {
return;
}
return saveDisplaySettings({
api: __legacyApiClient__,
newDisplaySettings: newSettings,
userSettings,
userId
});
}, [__legacyApiClient__, userSettings, userId]);
return {
displaySettings,
loading,
saveDisplaySettings: saveSettings
};
}
interface LoadDisplaySettingsParams {
currentUser: UserDto;
userId?: string;
api: ApiClient;
}
async function loadDisplaySettings({
currentUser,
userId,
api
}: LoadDisplaySettingsParams) {
const settings = (!userId || userId === currentUser?.Id) ? currentSettings : new UserSettings();
const user = (!userId || userId === currentUser?.Id) ? currentUser : await api.getUser(userId);
await settings.setUserInfo(userId, api);
const displaySettings = {
customCss: settings.customCss(),
dashboardTheme: settings.dashboardTheme() || 'auto',
dateTimeLocale: settings.dateTimeLocale() || 'auto',
disableCustomCss: Boolean(settings.disableCustomCss()),
displayMissingEpisodes: user?.Configuration?.DisplayMissingEpisodes ?? false,
enableBlurHash: Boolean(settings.enableBlurhash()),
enableFasterAnimation: Boolean(settings.enableFastFadein()),
enableItemDetailsBanner: Boolean(settings.detailsBanner()),
enableLibraryBackdrops: Boolean(settings.enableBackdrops()),
enableLibraryThemeSongs: Boolean(settings.enableThemeSongs()),
enableLibraryThemeVideos: Boolean(settings.enableThemeVideos()),
enableRewatchingInNextUp: Boolean(settings.enableRewatchingInNextUp()),
episodeImagesInNextUp: Boolean(settings.useEpisodeImagesInNextUpAndResume()),
language: settings.language() || 'auto',
layout: layoutManager.getSavedLayout() || 'auto',
libraryPageSize: settings.libraryPageSize(),
maxDaysForNextUp: settings.maxDaysForNextUp(),
screensaver: settings.screensaver() || 'none',
screensaverInterval: settings.backdropScreensaverInterval(),
theme: settings.theme()
};
return {
displaySettings,
userSettings: settings
};
}
interface SaveDisplaySettingsParams {
api: ApiClient;
newDisplaySettings: DisplaySettingsValues
userSettings: UserSettings;
userId: string;
}
async function saveDisplaySettings({
api,
newDisplaySettings,
userSettings,
userId
}: SaveDisplaySettingsParams) {
const user = await api.getUser(userId);
if (appHost.supports('displaylanguage')) {
userSettings.language(normalizeValue(newDisplaySettings.language));
}
userSettings.customCss(normalizeValue(newDisplaySettings.customCss));
userSettings.dashboardTheme(normalizeValue(newDisplaySettings.dashboardTheme));
userSettings.dateTimeLocale(normalizeValue(newDisplaySettings.dateTimeLocale));
userSettings.disableCustomCss(newDisplaySettings.disableCustomCss);
userSettings.enableBlurhash(newDisplaySettings.enableBlurHash);
userSettings.enableFastFadein(newDisplaySettings.enableFasterAnimation);
userSettings.detailsBanner(newDisplaySettings.enableItemDetailsBanner);
userSettings.enableBackdrops(newDisplaySettings.enableLibraryBackdrops);
userSettings.enableThemeSongs(newDisplaySettings.enableLibraryThemeSongs);
userSettings.enableThemeVideos(newDisplaySettings.enableLibraryThemeVideos);
userSettings.enableRewatchingInNextUp(newDisplaySettings.enableRewatchingInNextUp);
userSettings.useEpisodeImagesInNextUpAndResume(newDisplaySettings.episodeImagesInNextUp);
userSettings.libraryPageSize(newDisplaySettings.libraryPageSize);
userSettings.maxDaysForNextUp(newDisplaySettings.maxDaysForNextUp);
userSettings.screensaver(normalizeValue(newDisplaySettings.screensaver));
userSettings.backdropScreensaverInterval(newDisplaySettings.screensaverInterval);
userSettings.theme(newDisplaySettings.theme);
layoutManager.setLayout(normalizeValue(newDisplaySettings.layout));
const promises = [
themeManager.setTheme(userSettings.theme())
];
if (user.Id && user.Configuration) {
user.Configuration.DisplayMissingEpisodes = newDisplaySettings.displayMissingEpisodes;
promises.push(api.updateUserConfiguration(user.Id, user.Configuration));
}
await Promise.all(promises);
}
function normalizeValue(value: string) {
return /^(auto|none)$/.test(value) ? '' : value;
}

View file

@ -0,0 +1,29 @@
import { useMemo } from 'react';
import { pluginManager } from 'components/pluginManager';
import { Plugin, PluginType } from 'types/plugin';
import globalize from 'scripts/globalize';
export function useScreensavers() {
const screensavers = useMemo<Plugin[]>(() => {
const installedScreensaverPlugins = pluginManager
.ofType(PluginType.Screensaver)
.map((plugin: Plugin) => ({
...plugin,
name: globalize.translate(plugin.name) as string
}));
return [
{
id: 'none',
name: globalize.translate('None') as string,
type: PluginType.Screensaver
},
...installedScreensaverPlugins
];
}, []);
return {
screensavers: screensavers ?? []
};
}

View file

@ -0,0 +1,32 @@
import { useEffect, useMemo, useState } from 'react';
import themeManager from 'scripts/themeManager';
import { Theme } from 'types/webConfig';
export function useServerThemes() {
const [themes, setThemes] = useState<Theme[]>();
useEffect(() => {
async function getServerThemes() {
const loadedThemes = await themeManager.getThemes();
setThemes(loadedThemes ?? []);
}
if (!themes) {
void getServerThemes();
}
// We've intentionally left the dependency array here to ensure that the effect happens only once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const defaultTheme = useMemo(() => {
if (!themes) return null;
return themes.find((theme) => theme.default);
}, [themes]);
return {
themes: themes ?? [],
defaultTheme
};
}

View file

@ -0,0 +1,96 @@
import Button from '@mui/material/Button';
import { SelectChangeEvent } from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import React, { useCallback } from 'react';
import Page from 'components/Page';
import globalize from 'scripts/globalize';
import theme from 'themes/theme';
import { DisplayPreferences } from './DisplayPreferences';
import { ItemDetailPreferences } from './ItemDetailPreferences';
import { LibraryPreferences } from './LibraryPreferences';
import { LocalizationPreferences } from './LocalizationPreferences';
import { NextUpPreferences } from './NextUpPreferences';
import { useDisplaySettingForm } from './hooks/useDisplaySettingForm';
import { DisplaySettingsValues } from './types';
import LoadingComponent from 'components/loading/LoadingComponent';
export default function UserDisplayPreferences() {
const {
loading,
submitChanges,
updateField,
values
} = useDisplaySettingForm();
const handleSubmitForm = useCallback((e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
void submitChanges();
}, [submitChanges]);
const handleFieldChange = useCallback((e: SelectChangeEvent | React.SyntheticEvent) => {
const target = e.target as HTMLInputElement;
const fieldName = target.name as keyof DisplaySettingsValues;
const fieldValue = target.type === 'checkbox' ? target.checked : target.value;
if (values?.[fieldName] !== fieldValue) {
updateField({
name: fieldName,
value: fieldValue
});
}
}, [updateField, values]);
if (loading || !values) {
return <LoadingComponent />;
}
return (
<Page
className='libraryPage userPreferencesPage noSecondaryNavPage'
id='displayPreferencesPage'
title={globalize.translate('Display')}
>
<div className='settingsContainer padded-left padded-right padded-bottom-page'>
<form
onSubmit={handleSubmitForm}
style={{ margin: 'auto' }}
>
<Stack spacing={4}>
<LocalizationPreferences
onChange={handleFieldChange}
values={values}
/>
<DisplayPreferences
onChange={handleFieldChange}
values={values}
/>
<LibraryPreferences
onChange={handleFieldChange}
values={values}
/>
<NextUpPreferences
onChange={handleFieldChange}
values={values}
/>
<ItemDetailPreferences
onChange={handleFieldChange}
values={values}
/>
<Button
type='submit'
sx={{
color: theme.palette.text.primary,
fontSize: theme.typography.htmlFontSize,
fontWeight: theme.typography.fontWeightBold
}}
>
{globalize.translate('Save')}
</Button>
</Stack>
</form>
</div>
</Page>
);
}

View file

@ -0,0 +1,22 @@
export interface DisplaySettingsValues {
customCss: string;
dashboardTheme: string;
dateTimeLocale: string;
disableCustomCss: boolean;
displayMissingEpisodes: boolean;
enableBlurHash: boolean;
enableFasterAnimation: boolean;
enableItemDetailsBanner: boolean;
enableLibraryBackdrops: boolean;
enableLibraryThemeSongs: boolean;
enableLibraryThemeVideos: boolean;
enableRewatchingInNextUp: boolean;
episodeImagesInNextUp: boolean;
language: string;
layout: string;
libraryPageSize: number;
maxDaysForNextUp: number;
screensaver: string;
screensaverInterval: number;
theme: string;
}

View file

@ -104,6 +104,18 @@ class ServerConnections extends ConnectionManager {
return apiClient; return apiClient;
} }
/**
* Gets the ApiClient that is currently connected or throws if not defined.
* @async
* @returns {Promise<ApiClient>} The current ApiClient instance.
*/
async getCurrentApiClientAsync() {
const apiClient = this.currentApiClient();
if (!apiClient) throw new Error('[ServerConnection] No current ApiClient instance');
return apiClient;
}
onLocalUserSignedIn(user) { onLocalUserSignedIn(user) {
const apiClient = this.getApiClient(user.ServerId); const apiClient = this.getApiClient(user.ServerId);
this.setLocalApiClient(apiClient); this.setLocalApiClient(apiClient);

View file

@ -1,4 +1,3 @@
import Package from '../../package.json';
import appSettings from '../scripts/settings/appSettings'; import appSettings from '../scripts/settings/appSettings';
import browser from '../scripts/browser'; import browser from '../scripts/browser';
import Events from '../utils/events.ts'; import Events from '../utils/events.ts';
@ -36,7 +35,7 @@ function getDeviceProfile(item) {
let profile; let profile;
if (window.NativeShell) { if (window.NativeShell) {
profile = window.NativeShell.AppHost.getDeviceProfile(profileBuilder, Package.version); profile = window.NativeShell.AppHost.getDeviceProfile(profileBuilder, __PACKAGE_JSON_VERSION__);
} else { } else {
const builderOpts = getBaseProfileOptions(item); const builderOpts = getBaseProfileOptions(item);
profile = profileBuilder(builderOpts); profile = profileBuilder(builderOpts);
@ -46,18 +45,27 @@ function getDeviceProfile(item) {
const maxTranscodingVideoWidth = maxVideoWidth < 0 ? appHost.screen()?.maxAllowedWidth : maxVideoWidth; const maxTranscodingVideoWidth = maxVideoWidth < 0 ? appHost.screen()?.maxAllowedWidth : maxVideoWidth;
if (maxTranscodingVideoWidth) { 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) => { profile.TranscodingProfiles.forEach((transcodingProfile) => {
if (transcodingProfile.Type === 'Video') { if (transcodingProfile.Type === 'Video') {
transcodingProfile.Conditions = (transcodingProfile.Conditions || []).filter((condition) => { transcodingProfile.Conditions = (transcodingProfile.Conditions || []).filter((condition) => {
return condition.Property !== 'Width'; return condition.Property !== 'Width';
}); });
transcodingProfile.Conditions.push({ transcodingProfile.Conditions.push(conditionWidth);
Condition: 'LessThanEqual',
Property: 'Width',
Value: maxTranscodingVideoWidth.toString(),
IsRequired: false
});
} }
}); });
} }
@ -378,7 +386,7 @@ export const appHost = {
}, },
appVersion: function () { appVersion: function () {
return window.NativeShell?.AppHost?.appVersion ? return window.NativeShell?.AppHost?.appVersion ?
window.NativeShell.AppHost.appVersion() : Package.version; window.NativeShell.AppHost.appVersion() : __PACKAGE_JSON_VERSION__;
}, },
getPushTokenInfo: function () { getPushTokenInfo: function () {
return {}; return {};

View file

@ -5,6 +5,7 @@ import layoutManager from 'components/layoutManager';
import MoreVertIconButton from '../../common/MoreVertIconButton'; import MoreVertIconButton from '../../common/MoreVertIconButton';
import type { ItemDto } from 'types/base/models/item-dto'; import type { ItemDto } from 'types/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions'; import type { CardOptions } from 'types/cardOptions';
import Image from 'components/common/Image';
const shouldShowDetailsMenu = ( const shouldShowDetailsMenu = (
cardOptions: CardOptions, cardOptions: CardOptions,
@ -23,9 +24,14 @@ interface LogoComponentProps {
logoUrl: string; logoUrl: string;
} }
const LogoComponent: FC<LogoComponentProps> = ({ logoUrl }) => { const LogoComponent: FC<LogoComponentProps> = ({ logoUrl }) => (
return <Box className='lazy cardFooterLogo' data-src={logoUrl} />; <Box className='cardFooterLogo'>
}; <Image
imgUrl={logoUrl}
containImage
/>
</Box>
);
interface CardFooterTextProps { interface CardFooterTextProps {
item: ItemDto; item: ItemDto;
@ -51,7 +57,7 @@ const CardFooterText: FC<CardFooterTextProps> = ({
isOuterFooter isOuterFooter
}) => { }) => {
const { cardTextLines } = useCardText({ const { cardTextLines } = useCardText({
item, item: item.ProgramInfo || item,
cardOptions, cardOptions,
forceName, forceName,
imgUrl, imgUrl,

View file

@ -6,7 +6,6 @@ import {
} from '@jellyfin/sdk/lib/generated-client'; } from '@jellyfin/sdk/lib/generated-client';
import { Api } from '@jellyfin/sdk'; import { Api } from '@jellyfin/sdk';
import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api'; import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
import escapeHTML from 'escape-html';
import { appRouter } from 'components/router/appRouter'; import { appRouter } from 'components/router/appRouter';
import layoutManager from 'components/layoutManager'; import layoutManager from 'components/layoutManager';
@ -78,15 +77,11 @@ export function getTextActionButton(
text?: NullableString, text?: NullableString,
serverId?: NullableString serverId?: NullableString
): TextLine { ): TextLine {
if (!text) { const title = text || itemHelper.getDisplayName(item);
text = itemHelper.getDisplayName(item);
}
text = escapeHTML(text);
if (layoutManager.tv) { if (layoutManager.tv) {
return { return {
title: text title
}; };
} }
@ -108,7 +103,7 @@ export function getTextActionButton(
return { return {
titleAction: { titleAction: {
url, url,
title: text, title,
dataAttributes dataAttributes
} }
}; };
@ -510,7 +505,7 @@ function getChannelName(item: ItemDto) {
item.ChannelName item.ChannelName
); );
} else { } else {
return { title: item.ChannelName || '' || '&nbsp;' }; return { title: item.ChannelName || '\u00A0' };
} }
} }

View file

@ -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: `<a
is="emby-linkbutton"
rel="noopener noreferrer"
class="${className}"
href="${href}"
>
${title}
</a>`
});
const LinkTrickplayAcceleration: FunctionComponent<IProps> = ({ className, title, href }: IProps) => {
return (
<div
dangerouslySetInnerHTML={createLinkElement({
className,
title: globalize.translate(title),
href
})}
/>
);
};
export default LinkTrickplayAcceleration;

View file

@ -2,10 +2,11 @@ import React, { FunctionComponent } from 'react';
import IconButtonElement from '../../../elements/IconButtonElement'; import IconButtonElement from '../../../elements/IconButtonElement';
type IProps = { type IProps = {
tag?: string; tag?: string,
tagType?: string;
}; };
const BlockedTagList: FunctionComponent<IProps> = ({ tag }: IProps) => { const TagList: FunctionComponent<IProps> = ({ tag, tagType }: IProps) => {
return ( return (
<div className='paperList'> <div className='paperList'>
<div className='listItem'> <div className='listItem'>
@ -16,7 +17,7 @@ const BlockedTagList: FunctionComponent<IProps> = ({ tag }: IProps) => {
</div> </div>
<IconButtonElement <IconButtonElement
is='paper-icon-button-light' is='paper-icon-button-light'
className='blockedTag btnDeleteTag listItemButton' className={`${tagType} btnDeleteTag listItemButton`}
title='Delete' title='Delete'
icon='delete' icon='delete'
dataTag={tag} dataTag={tag}
@ -26,4 +27,4 @@ const BlockedTagList: FunctionComponent<IProps> = ({ tag }: IProps) => {
); );
}; };
export default BlockedTagList; export default TagList;

View file

@ -61,7 +61,7 @@ const UserCardBox: FunctionComponent<IProps> = ({ user = {} }: IProps) => {
</div>`; </div>`;
return ( return (
<div data-userid={user.Id} className={cssClass}> <div data-userid={user.Id} data-username={user.Name} className={cssClass}>
<div className='cardBox visualCardBox'> <div className='cardBox visualCardBox'>
<div className='cardScalable visualCardBox-cardScalable'> <div className='cardScalable visualCardBox-cardScalable'>
<div className='cardPadder cardPadder-square'></div> <div className='cardPadder cardPadder-square'></div>

View file

@ -1,6 +1,7 @@
import appSettings from '../scripts/settings/appSettings' ; import appSettings from '../scripts/settings/appSettings' ;
import browser from '../scripts/browser'; import browser from '../scripts/browser';
import Events from '../utils/events.ts'; import Events from '../utils/events.ts';
import { MediaError } from 'types/mediaError';
export function getSavedVolume() { export function getSavedVolume() {
return appSettings.get('volume') || 1; return appSettings.get('volume') || 1;
@ -87,7 +88,7 @@ export function handleHlsJsMediaError(instance, reject) {
if (reject) { if (reject) {
reject(); reject();
} else { } else {
onErrorInternal(instance, 'mediadecodeerror'); onErrorInternal(instance, MediaError.FATAL_HLS_ERROR);
} }
} }
} }
@ -98,11 +99,7 @@ export function onErrorInternal(instance, type) {
instance.destroyCustomTrack(instance._mediaElement); instance.destroyCustomTrack(instance._mediaElement);
} }
Events.trigger(instance, 'error', [ Events.trigger(instance, 'error', [{ type }]);
{
type: type
}
]);
} }
export function isValidDuration(duration) { 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 // swallow this error because the user can still click the play button on the video element
return Promise.resolve(); return Promise.resolve();
} }
return Promise.reject(); return Promise.reject(e);
}) })
.then(() => { .then(() => {
onSuccessfulPlay(elem, onErrorFn); onSuccessfulPlay(elem, onErrorFn);
@ -269,10 +266,10 @@ export function bindEventsToHlsPlayer(instance, hls, elem, onErrorFn, resolve, r
hls.destroy(); hls.destroy();
if (reject) { if (reject) {
reject('servererror'); reject(MediaError.SERVER_ERROR);
reject = null; reject = null;
} else { } else {
onErrorInternal(instance, 'servererror'); onErrorInternal(instance, MediaError.SERVER_ERROR);
} }
return; return;
@ -291,10 +288,10 @@ export function bindEventsToHlsPlayer(instance, hls, elem, onErrorFn, resolve, r
hls.destroy(); hls.destroy();
if (reject) { if (reject) {
reject('network'); reject(MediaError.NETWORK_ERROR);
reject = null; reject = null;
} else { } else {
onErrorInternal(instance, 'network'); onErrorInternal(instance, MediaError.NETWORK_ERROR);
} }
} else { } else {
console.debug('fatal network error encountered, try to recover'); console.debug('fatal network error encountered, try to recover');
@ -318,7 +315,7 @@ export function bindEventsToHlsPlayer(instance, hls, elem, onErrorFn, resolve, r
reject(); reject();
reject = null; reject = null;
} else { } else {
onErrorInternal(instance, 'mediadecodeerror'); onErrorInternal(instance, MediaError.FATAL_HLS_ERROR);
} }
break; break;
} }

View file

@ -10,6 +10,24 @@ import { playbackManager } from './playback/playbackmanager';
import ServerConnections from './ServerConnections'; import ServerConnections from './ServerConnections';
import toast from './toast/toast'; import toast from './toast/toast';
import * as userSettings from '../scripts/settings/userSettings'; 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) { export function getCommands(options) {
const item = options.item; const item = options.item;
@ -160,19 +178,11 @@ export function getCommands(options) {
} }
if (item.CanDelete && options.deleteItem !== false) { if (item.CanDelete && options.deleteItem !== false) {
if (item.Type === 'Playlist' || item.Type === 'BoxSet') {
commands.push({ commands.push({
name: globalize.translate('Delete'), name: getDeleteLabel(item.Type),
id: 'delete', id: 'delete',
icon: 'delete' icon: 'delete'
}); });
} else {
commands.push({
name: globalize.translate('DeleteMedia'),
id: 'delete',
icon: 'delete'
});
}
} }
// Books are promoted to major download Button and therefor excluded in the context menu // 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' if (itemHelper.canEditSubtitles(user, item) && options.editSubtitles !== false) {
&& item.LocationType !== 'Virtual'
&& !(item.Type === 'Recording' && item.Status !== 'Completed')
&& options.editSubtitles !== false
) {
commands.push({ commands.push({
name: globalize.translate('EditSubtitles'), name: globalize.translate('EditSubtitles'),
id: 'editsubtitles', id: 'editsubtitles',

View file

@ -1,6 +1,10 @@
import { appHost } from './apphost'; import { appHost } from './apphost';
import globalize from '../scripts/globalize'; import globalize from '../scripts/globalize';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type'; 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 = {}) { export function getDisplayName(item, options = {}) {
if (!item) { if (!item) {
@ -155,6 +159,33 @@ export function canEditImages (user, item) {
return itemType !== 'Timer' && itemType !== 'SeriesTimer' && canEdit(user, item) && !isLocalItem(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) { export function canShare (item, user) {
if (item.Type === 'Program') { if (item.Type === 'Program') {
return false; return false;
@ -300,6 +331,7 @@ export default {
canIdentify: canIdentify, canIdentify: canIdentify,
canEdit: canEdit, canEdit: canEdit,
canEditImages: canEditImages, canEditImages: canEditImages,
canEditSubtitles,
canShare: canShare, canShare: canShare,
enableDateAddedDisplay: enableDateAddedDisplay, enableDateAddedDisplay: enableDateAddedDisplay,
canMarkPlayed: canMarkPlayed, canMarkPlayed: canMarkPlayed,

View file

@ -84,6 +84,7 @@ function getMediaSourceHtml(user, item, version) {
case 'Data': case 'Data':
case 'Subtitle': case 'Subtitle':
case 'Video': case 'Video':
case 'Lyric':
translateString = stream.Type; translateString = stream.Type;
break; break;
case 'EmbeddedImage': case 'EmbeddedImage':
@ -145,10 +146,10 @@ function getMediaSourceHtml(user, item, version) {
if (stream.BitDepth) { if (stream.BitDepth) {
attributes.push(createAttribute(globalize.translate('MediaInfoBitDepth'), `${stream.BitDepth} bit`)); 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)); 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)); attributes.push(createAttribute(globalize.translate('MediaInfoVideoRangeType'), stream.VideoRangeType));
} }
if (stream.VideoDoViTitle) { if (stream.VideoDoViTitle) {

View file

@ -391,8 +391,10 @@ export function setContentType(parent, contentType) {
} }
if (contentType !== 'tvshows' && contentType !== 'movies' && contentType !== 'homevideos' && contentType !== 'musicvideos' && contentType !== 'mixed') { if (contentType !== 'tvshows' && contentType !== 'movies' && contentType !== 'homevideos' && contentType !== 'musicvideos' && contentType !== 'mixed') {
parent.querySelector('.trickplaySettingsSection').classList.add('hide');
parent.querySelector('.chapterSettingsSection').classList.add('hide'); parent.querySelector('.chapterSettingsSection').classList.add('hide');
} else { } else {
parent.querySelector('.trickplaySettingsSection').classList.remove('hide');
parent.querySelector('.chapterSettingsSection').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'); parent.querySelector('.chkEnableLUFSScanContainer').classList.toggle('hide', contentType !== 'music');
if (contentType === 'tvshows') { if (contentType === 'tvshows') {
@ -511,10 +515,14 @@ function setImageOptionsIntoOptions(options) {
export function getLibraryOptions(parent) { export function getLibraryOptions(parent) {
const options = { const options = {
Enabled: parent.querySelector('.chkEnabled').checked,
EnableArchiveMediaFiles: false, EnableArchiveMediaFiles: false,
EnablePhotos: parent.querySelector('.chkEnablePhotos').checked, EnablePhotos: parent.querySelector('.chkEnablePhotos').checked,
EnableRealtimeMonitor: parent.querySelector('.chkEnableRealtimeMonitor').checked, EnableRealtimeMonitor: parent.querySelector('.chkEnableRealtimeMonitor').checked,
EnableLUFSScan: parent.querySelector('.chkEnableLUFSScan').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, ExtractChapterImagesDuringLibraryScan: parent.querySelector('.chkExtractChaptersDuringLibraryScan').checked,
EnableChapterImageExtraction: parent.querySelector('.chkExtractChapterImages').checked, EnableChapterImageExtraction: parent.querySelector('.chkExtractChapterImages').checked,
EnableInternetProviders: true, EnableInternetProviders: true,
@ -574,9 +582,13 @@ export function setLibraryOptions(parent, options) {
parent.querySelector('#selectCountry').value = options.MetadataCountryCode || ''; parent.querySelector('#selectCountry').value = options.MetadataCountryCode || '';
parent.querySelector('#selectAutoRefreshInterval').value = options.AutomaticRefreshIntervalDays || '0'; parent.querySelector('#selectAutoRefreshInterval').value = options.AutomaticRefreshIntervalDays || '0';
parent.querySelector('#txtSeasonZeroName').value = options.SeasonZeroDisplayName || 'Specials'; parent.querySelector('#txtSeasonZeroName').value = options.SeasonZeroDisplayName || 'Specials';
parent.querySelector('.chkEnabled').checked = options.Enabled;
parent.querySelector('.chkEnablePhotos').checked = options.EnablePhotos; parent.querySelector('.chkEnablePhotos').checked = options.EnablePhotos;
parent.querySelector('.chkEnableRealtimeMonitor').checked = options.EnableRealtimeMonitor; parent.querySelector('.chkEnableRealtimeMonitor').checked = options.EnableRealtimeMonitor;
parent.querySelector('.chkEnableLUFSScan').checked = options.EnableLUFSScan; 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('.chkExtractChaptersDuringLibraryScan').checked = options.ExtractChapterImagesDuringLibraryScan;
parent.querySelector('.chkExtractChapterImages').checked = options.EnableChapterImageExtraction; parent.querySelector('.chkExtractChapterImages').checked = options.EnableChapterImageExtraction;
parent.querySelector('#chkSaveLocal').checked = options.SaveLocalMetadata; parent.querySelector('#chkSaveLocal').checked = options.SaveLocalMetadata;

View file

@ -1,4 +1,12 @@
<h2>${HeaderLibrarySettings}</h2> <h2>${HeaderLibrarySettings}</h2>
<div class="checkboxContainer checkboxContainer-withDescription chkEnabledContainer">
<label>
<input type="checkbox" is="emby-checkbox" class="chkEnabled" checked />
<span>${EnableLibrary}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${EnableLibraryHelp}</div>
</div>
<div class="selectContainer fldMetadataLanguage hide"> <div class="selectContainer fldMetadataLanguage hide">
<select is="emby-select" id="selectLanguage" label="${LabelMetadataDownloadLanguage}"></select> <select is="emby-select" id="selectLanguage" label="${LabelMetadataDownloadLanguage}"></select>
</div> </div>
@ -55,6 +63,14 @@
<div class="fieldDescription checkboxFieldDescription">${LabelEnableRealtimeMonitorHelp}</div> <div class="fieldDescription checkboxFieldDescription">${LabelEnableRealtimeMonitorHelp}</div>
</div> </div>
<div class="checkboxContainer checkboxContainer-withDescription chkUseReplayGainTagsContainer advanced">
<label>
<input type="checkbox" is="emby-checkbox" class="chkUseReplayGainTags" checked />
<span>${LabelUseReplayGainTags}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelUseReplayGainTagsHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription chkEnableLUFSScanContainer advanced"> <div class="checkboxContainer checkboxContainer-withDescription chkEnableLUFSScanContainer advanced">
<label> <label>
<input type="checkbox" is="emby-checkbox" class="chkEnableLUFSScan" checked /> <input type="checkbox" is="emby-checkbox" class="chkEnableLUFSScan" checked />
@ -104,6 +120,25 @@
<div class="fieldDescription checkboxFieldDescription">${OptionAutomaticallyGroupSeriesHelp}</div> <div class="fieldDescription checkboxFieldDescription">${OptionAutomaticallyGroupSeriesHelp}</div>
</div> </div>
<div class="trickplaySettingsSection hide">
<h2>${Trickplay}</h2>
<div class="checkboxContainer checkboxContainer-withDescription fldExtractTrickplayImages">
<label>
<input type="checkbox" is="emby-checkbox" class="chkExtractTrickplayImages" />
<span>${OptionExtractTrickplayImage}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${ExtractTrickplayImagesHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription fldExtractTrickplayDuringLibraryScan advanced">
<label>
<input type="checkbox" is="emby-checkbox" class="chkExtractTrickplayDuringLibraryScan" />
<span>${LabelExtractTrickplayDuringLibraryScan}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelExtractTrickplayDuringLibraryScanHelp}</div>
</div>
</div>
<div class="chapterSettingsSection hide"> <div class="chapterSettingsSection hide">
<h2>${HeaderChapterImages}</h2> <h2>${HeaderChapterImages}</h2>
<div class="checkboxContainer checkboxContainer-withDescription fldExtractChapterImages"> <div class="checkboxContainer checkboxContainer-withDescription fldExtractChapterImages">

View file

@ -22,6 +22,7 @@ import ServerConnections from '../ServerConnections';
import toast from '../toast/toast'; import toast from '../toast/toast';
import { appRouter } from '../router/appRouter'; import { appRouter } from '../router/appRouter';
import template from './metadataEditor.template.html'; import template from './metadataEditor.template.html';
import { SeriesStatus } from '@jellyfin/sdk/lib/generated-client';
let currentContext; let currentContext;
let metadataEditorInfo; let metadataEditorInfo;
@ -271,7 +272,7 @@ function showMoreMenu(context, button, user) {
} else if (result.updated) { } else if (result.updated) {
reload(context, item.Id, item.ServerId); reload(context, item.Id, item.ServerId);
} }
}); }).catch(() => { /* no-op */ });
}); });
} }
@ -886,10 +887,10 @@ function populateRatings(allParentalRatings, select, currentValue) {
function populateStatus(select) { function populateStatus(select) {
let html = ''; let html = '';
html += '<option value=""></option>';
html += "<option value=''></option>"; html += `<option value="${SeriesStatus.Continuing}">${escapeHtml(globalize.translate('Continuing'))}</option>`;
html += "<option value='Continuing'>" + globalize.translate('Continuing') + '</option>'; html += `<option value="${SeriesStatus.Ended}">${escapeHtml(globalize.translate('Ended'))}</option>`;
html += "<option value='Ended'>" + globalize.translate('Ended') + '</option>'; html += `<option value="${SeriesStatus.Unreleased}">${escapeHtml(globalize.translate('Unreleased'))}</option>`;
select.innerHTML = html; select.innerHTML = html;
} }

View file

@ -7,6 +7,7 @@ import { playbackManager } from '../playback/playbackmanager';
import nowPlayingHelper from '../playback/nowplayinghelper'; import nowPlayingHelper from '../playback/nowplayinghelper';
import { appHost } from '../apphost'; import { appHost } from '../apphost';
import dom from '../../scripts/dom'; import dom from '../../scripts/dom';
import globalize from 'scripts/globalize';
import itemContextMenu from '../itemContextMenu'; import itemContextMenu from '../itemContextMenu';
import '../../elements/emby-button/paper-icon-button-light'; import '../../elements/emby-button/paper-icon-button-light';
import '../../elements/emby-ratingbutton/emby-ratingbutton'; import '../../elements/emby-ratingbutton/emby-ratingbutton';
@ -59,13 +60,13 @@ function getNowPlayingBarHtml() {
// The onclicks are needed due to the return false above // The onclicks are needed due to the return false above
html += '<div class="nowPlayingBarCenter" dir="ltr">'; html += '<div class="nowPlayingBarCenter" dir="ltr">';
html += '<button is="paper-icon-button-light" class="previousTrackButton mediaButton"><span class="material-icons skip_previous" aria-hidden="true"></span></button>'; html += `<button is="paper-icon-button-light" class="previousTrackButton mediaButton" title="${globalize.translate('ButtonPreviousTrack')}"><span class="material-icons skip_previous" aria-hidden="true"></span></button>`;
html += '<button is="paper-icon-button-light" class="playPauseButton mediaButton"><span class="material-icons pause" aria-hidden="true"></span></button>'; html += `<button is="paper-icon-button-light" class="playPauseButton mediaButton" title="${globalize.translate('ButtonPause')}"><span class="material-icons pause" aria-hidden="true"></span></button>`;
html += '<button is="paper-icon-button-light" class="stopButton mediaButton"><span class="material-icons stop" aria-hidden="true"></span></button>'; html += `<button is="paper-icon-button-light" class="stopButton mediaButton" title="${globalize.translate('ButtonStop')}"><span class="material-icons stop" aria-hidden="true"></span></button>`;
if (!layoutManager.mobile) { if (!layoutManager.mobile) {
html += '<button is="paper-icon-button-light" class="nextTrackButton mediaButton"><span class="material-icons skip_next" aria-hidden="true"></span></button>'; html += `<button is="paper-icon-button-light" class="nextTrackButton mediaButton" title="${globalize.translate('ButtonNextTrack')}"><span class="material-icons skip_next" aria-hidden="true"></span></button>`;
} }
html += '<div class="nowPlayingBarCurrentTime"></div>'; html += '<div class="nowPlayingBarCurrentTime"></div>';
@ -73,25 +74,25 @@ function getNowPlayingBarHtml() {
html += '<div class="nowPlayingBarRight">'; html += '<div class="nowPlayingBarRight">';
html += '<button is="paper-icon-button-light" class="muteButton mediaButton"><span class="material-icons volume_up" aria-hidden="true"></span></button>'; html += `<button is="paper-icon-button-light" class="muteButton mediaButton" title="${globalize.translate('Mute')}"><span class="material-icons volume_up" aria-hidden="true"></span></button>`;
html += '<div class="sliderContainer nowPlayingBarVolumeSliderContainer hide" style="width:9em;vertical-align:middle;display:inline-flex;">'; html += '<div class="sliderContainer nowPlayingBarVolumeSliderContainer hide" style="width:9em;vertical-align:middle;display:inline-flex;">';
html += '<input type="range" is="emby-slider" pin step="1" min="0" max="100" value="0" class="slider-medium-thumb nowPlayingBarVolumeSlider"/>'; html += '<input type="range" is="emby-slider" pin step="1" min="0" max="100" value="0" class="slider-medium-thumb nowPlayingBarVolumeSlider"/>';
html += '</div>'; html += '</div>';
html += '<button is="paper-icon-button-light" class="btnAirPlay mediaButton"><span class="material-icons airplay" aria-hidden="true"></span></button>'; html += `<button is="paper-icon-button-light" class="btnAirPlay mediaButton" title="${globalize.translate('AirPlay')}"><span class="material-icons airplay" aria-hidden="true"></span></button>`;
html += '<button is="paper-icon-button-light" class="toggleRepeatButton mediaButton"><span class="material-icons repeat" aria-hidden="true"></span></button>'; html += `<button is="paper-icon-button-light" class="toggleRepeatButton mediaButton" title="${globalize.translate('Repeat')}"><span class="material-icons repeat" aria-hidden="true"></span></button>`;
html += '<button is="paper-icon-button-light" class="btnShuffleQueue mediaButton"><span class="material-icons shuffle" aria-hidden="true"></span></button>'; html += `<button is="paper-icon-button-light" class="btnShuffleQueue mediaButton" title="${globalize.translate('Shuffle')}"><span class="material-icons shuffle" aria-hidden="true"></span></button>`;
html += '<div class="nowPlayingBarUserDataButtons">'; html += '<div class="nowPlayingBarUserDataButtons">';
html += '</div>'; html += '</div>';
html += '<button is="paper-icon-button-light" class="playPauseButton mediaButton"><span class="material-icons pause" aria-hidden="true"></span></button>'; html += `<button is="paper-icon-button-light" class="playPauseButton mediaButton" title="${globalize.translate('ButtonPause')}"><span class="material-icons pause" aria-hidden="true"></span></button>`;
if (layoutManager.mobile) { if (layoutManager.mobile) {
html += '<button is="paper-icon-button-light" class="nextTrackButton mediaButton"><span class="material-icons skip_next" aria-hidden="true"></span></button>'; html += `<button is="paper-icon-button-light" class="nextTrackButton mediaButton" title="${globalize.translate('ButtonNextTrack')}"><span class="material-icons skip_next" aria-hidden="true"></span></button>`;
} else { } else {
html += '<button is="paper-icon-button-light" class="btnToggleContextMenu mediaButton"><span class="material-icons more_vert" aria-hidden="true"></span></button>'; html += `<button is="paper-icon-button-light" class="btnToggleContextMenu mediaButton" title="${globalize.translate('ButtonMore')}"><span class="material-icons more_vert" aria-hidden="true"></span></button>`;
} }
html += '</div>'; html += '</div>';
@ -317,6 +318,7 @@ function updatePlayPauseState(isPaused) {
const icon = button.querySelector('.material-icons'); const icon = button.querySelector('.material-icons');
icon.classList.remove('play_arrow', 'pause'); icon.classList.remove('play_arrow', 'pause');
icon.classList.add(isPaused ? 'play_arrow' : 'pause'); icon.classList.add(isPaused ? 'play_arrow' : 'pause');
button.title = globalize.translate(isPaused ? 'Play' : 'ButtonPause');
}); });
} }
} }
@ -424,6 +426,7 @@ function updatePlayerVolumeState(isMuted, volumeLevel) {
const muteButtonIcon = muteButton.querySelector('.material-icons'); const muteButtonIcon = muteButton.querySelector('.material-icons');
muteButtonIcon.classList.remove('volume_off', 'volume_up'); muteButtonIcon.classList.remove('volume_off', 'volume_up');
muteButtonIcon.classList.add(isMuted ? 'volume_off' : 'volume_up'); muteButtonIcon.classList.add(isMuted ? 'volume_off' : 'volume_up');
muteButton.title = globalize.translate(isMuted ? 'Unmute' : 'Mute');
if (supportedCommands.indexOf('SetVolume') === -1) { if (supportedCommands.indexOf('SetVolume') === -1) {
showVolumeSlider = false; showVolumeSlider = false;

View file

@ -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 Events from '../../utils/events.ts';
import datetime from '../../scripts/datetime'; import datetime from '../../scripts/datetime';
import appSettings from '../../scripts/settings/appSettings'; import appSettings from '../../scripts/settings/appSettings';
@ -8,14 +12,15 @@ import * as userSettings from '../../scripts/settings/userSettings';
import globalize from '../../scripts/globalize'; import globalize from '../../scripts/globalize';
import loading from '../loading/loading'; import loading from '../loading/loading';
import { appHost } from '../apphost'; import { appHost } from '../apphost';
import Screenfull from 'screenfull';
import ServerConnections from '../ServerConnections'; import ServerConnections from '../ServerConnections';
import alert from '../alert'; import alert from '../alert';
import { PluginType } from '../../types/plugin.ts'; import { PluginType } from '../../types/plugin.ts';
import { includesAny } from '../../utils/container.ts'; import { includesAny } from '../../utils/container.ts';
import { getItems } from '../../utils/jellyfin-apiclient/getItems.ts'; import { getItems } from '../../utils/jellyfin-apiclient/getItems.ts';
import { getItemBackdropImageUrl } from '../../utils/jellyfin-apiclient/backdropImage'; 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; const UNLIMITED_ITEMS = -1;
@ -125,7 +130,7 @@ function getItemsForPlayback(serverId, query) {
} else { } else {
query.Limit = query.Limit || 300; query.Limit = query.Limit || 300;
} }
query.Fields = 'Chapters'; query.Fields = ['Chapters', 'Trickplay'];
query.ExcludeLocationTypes = 'Virtual'; query.ExcludeLocationTypes = 'Virtual';
query.EnableTotalRecordCount = false; query.EnableTotalRecordCount = false;
query.CollapseBoxSetItems = false; query.CollapseBoxSetItems = false;
@ -588,9 +593,18 @@ function supportsDirectPlay(apiClient, item, mediaSource) {
return Promise.resolve(false); return Promise.resolve(false);
} }
/**
* @param {PlaybackManager} instance
* @param {import('@jellyfin/sdk/lib/generated-client/index.js').PlaybackInfoResponse} result
* @returns {boolean}
*/
function validatePlaybackInfoResult(instance, result) { function validatePlaybackInfoResult(instance, result) {
if (result.ErrorCode) { 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; return false;
} }
@ -1720,7 +1734,8 @@ class PlaybackManager {
streamInfo.resetSubtitleOffset = false; streamInfo.resetSubtitleOffset = false;
if (!streamInfo.url) { if (!streamInfo.url) {
showPlaybackInfoErrorMessage(self, 'PlaybackErrorNoCompatibleStream'); cancelPlayback();
showPlaybackInfoErrorMessage(self, `PlaybackError.${MediaError.NO_MEDIA_ERROR}`);
return; return;
} }
@ -1768,8 +1783,8 @@ class PlaybackManager {
playerData.isChangingStream = false; playerData.isChangingStream = false;
onPlaybackError.call(player, e, { onPlaybackError.call(player, e, {
type: 'mediadecodeerror', type: getMediaError(e),
streamInfo: streamInfo streamInfo
}); });
}); });
} }
@ -1858,7 +1873,7 @@ class PlaybackManager {
IsVirtualUnaired: false, IsVirtualUnaired: false,
IsMissing: false, IsMissing: false,
UserId: apiClient.getCurrentUserId(), UserId: apiClient.getCurrentUserId(),
Fields: 'Chapters' Fields: ['Chapters', 'Trickplay']
}).then(function (episodesResult) { }).then(function (episodesResult) {
const originalResults = episodesResult.Items; const originalResults = episodesResult.Items;
const isSeries = firstItem.Type === 'Series'; const isSeries = firstItem.Type === 'Series';
@ -1940,7 +1955,7 @@ class PlaybackManager {
IsVirtualUnaired: false, IsVirtualUnaired: false,
IsMissing: false, IsMissing: false,
UserId: apiClient.getCurrentUserId(), UserId: apiClient.getCurrentUserId(),
Fields: 'Chapters' Fields: ['Chapters', 'Trickplay']
}).then(function (episodesResult) { }).then(function (episodesResult) {
let foundItem = false; let foundItem = false;
episodesResult.Items = episodesResult.Items.filter(function (e) { 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 it's still null then there's nothing to play
if (!firstItem) { if (!firstItem) {
showPlaybackInfoErrorMessage(self, 'PlaybackErrorNoCompatibleStream'); showPlaybackInfoErrorMessage(self, `PlaybackError.${MediaError.NO_MEDIA_ERROR}`);
return Promise.reject(); return Promise.reject();
} }
@ -2551,8 +2566,8 @@ class PlaybackManager {
onPlaybackStarted(player, playOptions, streamInfo, mediaSource); onPlaybackStarted(player, playOptions, streamInfo, mediaSource);
setTimeout(function () { setTimeout(function () {
onPlaybackError.call(player, err, { onPlaybackError.call(player, err, {
type: 'mediadecodeerror', type: getMediaError(err),
streamInfo: streamInfo streamInfo
}); });
}, 100); }, 100);
}); });
@ -2785,7 +2800,7 @@ class PlaybackManager {
return mediaSource; return mediaSource;
} }
} else { } else {
showPlaybackInfoErrorMessage(self, 'PlaybackErrorNoCompatibleStream'); showPlaybackInfoErrorMessage(self, `PlaybackError.${MediaError.NO_MEDIA_ERROR}`);
return Promise.reject(); 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) { function enablePlaybackRetryWithTranscoding(streamInfo, errorType, currentlyPreventsVideoStreamCopy, currentlyPreventsAudioStreamCopy) {
// mediadecodeerror, medianotsupported, network, servererror
return streamInfo.mediaSource.SupportsTranscoding return streamInfo.mediaSource.SupportsTranscoding
&& (!currentlyPreventsVideoStreamCopy || !currentlyPreventsAudioStreamCopy); && (!currentlyPreventsVideoStreamCopy || !currentlyPreventsAudioStreamCopy);
} }
/**
* Playback error handler.
* @param {Error} e
* @param {object} error
* @param {object} error.streamInfo
* @param {MediaError} error.type
*/
function onPlaybackError(e, error) { function onPlaybackError(e, error) {
const player = this; const player = this;
error = error || {}; error = error || {};
// network
// mediadecodeerror
// medianotsupported
const errorType = error.type; const errorType = error.type;
console.debug('playbackmanager playback error type: ' + (errorType || '')); console.warn('[playbackmanager] onPlaybackError:', e, error);
const streamInfo = error.streamInfo || getPlayerData(player).streamInfo; const streamInfo = error.streamInfo || getPlayerData(player).streamInfo;
@ -3235,8 +3260,7 @@ class PlaybackManager {
Events.trigger(self, 'playbackerror', [errorType]); Events.trigger(self, 'playbackerror', [errorType]);
const displayErrorCode = 'NoCompatibleStream'; onPlaybackStopped.call(player, e, `.${errorType}`);
onPlaybackStopped.call(player, e, displayErrorCode);
} }
function onPlaybackStopped(e, displayErrorCode) { function onPlaybackStopped(e, displayErrorCode) {

View file

@ -179,6 +179,7 @@ function loadForm(context, user, userSettings, systemInfo, apiClient) {
context.querySelector('.chkRememberAudioSelections').checked = user.Configuration.RememberAudioSelections || false; context.querySelector('.chkRememberAudioSelections').checked = user.Configuration.RememberAudioSelections || false;
context.querySelector('.chkRememberSubtitleSelections').checked = user.Configuration.RememberSubtitleSelections || false; context.querySelector('.chkRememberSubtitleSelections').checked = user.Configuration.RememberSubtitleSelections || false;
context.querySelector('.chkExternalVideoPlayer').checked = appSettings.enableSystemExternalPlayers(); context.querySelector('.chkExternalVideoPlayer').checked = appSettings.enableSystemExternalPlayers();
context.querySelector('.chkLimitSupportedVideoResolution').checked = appSettings.limitSupportedVideoResolution();
setMaxBitrateIntoField(context.querySelector('.selectVideoInNetworkQuality'), true, 'Video'); setMaxBitrateIntoField(context.querySelector('.selectVideoInNetworkQuality'), true, 'Video');
setMaxBitrateIntoField(context.querySelector('.selectVideoInternetQuality'), false, 'Video'); setMaxBitrateIntoField(context.querySelector('.selectVideoInternetQuality'), false, 'Video');
@ -194,8 +195,8 @@ function loadForm(context, user, userSettings, systemInfo, apiClient) {
selectChromecastVersion.innerHTML = ccAppsHtml; selectChromecastVersion.innerHTML = ccAppsHtml;
selectChromecastVersion.value = user.Configuration.CastReceiverId; selectChromecastVersion.value = user.Configuration.CastReceiverId;
const selectLabelMaxVideoWidth = context.querySelector('.selectLabelMaxVideoWidth'); const selectMaxVideoWidth = context.querySelector('.selectMaxVideoWidth');
selectLabelMaxVideoWidth.value = appSettings.maxVideoWidth(); selectMaxVideoWidth.value = appSettings.maxVideoWidth();
const selectSkipForwardLength = context.querySelector('.selectSkipForwardLength'); const selectSkipForwardLength = context.querySelector('.selectSkipForwardLength');
fillSkipLengths(selectSkipForwardLength); fillSkipLengths(selectSkipForwardLength);
@ -212,7 +213,8 @@ function saveUser(context, user, userSettingsInstance, apiClient) {
appSettings.enableSystemExternalPlayers(context.querySelector('.chkExternalVideoPlayer').checked); appSettings.enableSystemExternalPlayers(context.querySelector('.chkExternalVideoPlayer').checked);
appSettings.maxChromecastBitrate(context.querySelector('.selectChromecastVideoQuality').value); 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('.selectVideoInNetworkQuality'), true, 'Video');
setMaxBitrateFromField(context.querySelector('.selectVideoInternetQuality'), false, '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('.btnSave').classList.remove('hide');
} }
options.element.querySelector('.selectLabelMaxVideoWidth').addEventListener('change', onMaxVideoWidthChange.bind(self)); options.element.querySelector('.selectMaxVideoWidth').addEventListener('change', onMaxVideoWidthChange.bind(self));
self.loadData(); self.loadData();

View file

@ -43,7 +43,7 @@
</div> </div>
<div class="selectContainer"> <div class="selectContainer">
<select is="emby-select" class="selectLabelMaxVideoWidth" label="${LabelMaxVideoResolution}"> <select is="emby-select" class="selectMaxVideoWidth" label="${LabelMaxVideoResolution}">
<option value="0">${Auto}</option> <option value="0">${Auto}</option>
<option value="-1">${ScreenResolution}</option> <option value="-1">${ScreenResolution}</option>
<option value="640">360p</option> <option value="640">360p</option>
@ -54,6 +54,14 @@
<option value="7680">8K</option> <option value="7680">8K</option>
</select> </select>
</div> </div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" class="chkLimitSupportedVideoResolution" />
<span>${LimitSupportedVideoResolution}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LimitSupportedVideoResolutionHelp}</div>
</div>
</div> </div>
<div class="verticalSection verticalSection-extrabottompadding musicQualitySection hide"> <div class="verticalSection verticalSection-extrabottompadding musicQualitySection hide">

View file

@ -4,7 +4,7 @@
*/ */
import dom from '../scripts/dom'; import dom from '../scripts/dom';
import browser from '../scripts/browser'; import appSettings from 'scripts/settings/appSettings';
import layoutManager from './layoutManager'; import layoutManager from './layoutManager';
/** /**
@ -477,7 +477,7 @@ function doScroll(xScroller, scrollX, yScroller, scrollY, smooth) {
* Returns true if smooth scroll must be used. * Returns true if smooth scroll must be used.
*/ */
function useSmoothScroll() { function useSmoothScroll() {
return !!browser.tizen; return appSettings.enableSmoothScroll();
} }
/** /**

View file

@ -34,7 +34,7 @@ const SearchSuggestions: FunctionComponent<SearchSuggestionsProps> = ({ parentId
useEffect(() => { useEffect(() => {
if (api && user?.Id) { if (api && user?.Id) {
getItemsApi(api) getItemsApi(api)
.getItemsByUserId({ .getItems({
userId: user.Id, userId: user.Id,
sortBy: [ItemSortBy.IsFavoriteOrLiked, ItemSortBy.Random], sortBy: [ItemSortBy.IsFavoriteOrLiked, ItemSortBy.Random],
includeItemTypes: [BaseItemKind.Movie, BaseItemKind.Series, BaseItemKind.MusicArtist], includeItemTypes: [BaseItemKind.Movie, BaseItemKind.Series, BaseItemKind.MusicArtist],

View file

@ -26,8 +26,6 @@ import { getSystemInfoQuery } from 'hooks/useSystemInfo';
import { toApi } from 'utils/jellyfin-apiclient/compat'; import { toApi } from 'utils/jellyfin-apiclient/compat';
import { queryClient } from 'utils/query/queryClient'; import { queryClient } from 'utils/query/queryClient';
import { version as WEB_VERSION } from '../../../package.json';
import '../../elements/emby-button/emby-button'; import '../../elements/emby-button/emby-button';
import '../../elements/emby-itemscontainer/emby-itemscontainer'; import '../../elements/emby-itemscontainer/emby-itemscontainer';
@ -210,7 +208,7 @@ function refreshActiveRecordings(view, apiClient) {
function reloadSystemInfo(view, apiClient) { function reloadSystemInfo(view, apiClient) {
view.querySelector('#buildVersion').innerText = __JF_BUILD_VERSION__; view.querySelector('#buildVersion').innerText = __JF_BUILD_VERSION__;
view.querySelector('#webVersion').innerText = WEB_VERSION; view.querySelector('#webVersion').innerText = __PACKAGE_JSON_VERSION__;
queryClient queryClient
.fetchQuery(getSystemInfoQuery(toApi(apiClient))) .fetchQuery(getSystemInfoQuery(toApi(apiClient)))

View file

@ -47,15 +47,15 @@
<span>MPEG1</span> <span>MPEG1</span>
</label> </label>
<label> <label>
<input type="checkbox" is="emby-checkbox" class="chkDecodeCodec" data-codec="mpeg2video" data-types="amf,nvenc,qsv,vaapi,rkmpp,videotoolbox" /> <input type="checkbox" is="emby-checkbox" class="chkDecodeCodec" data-codec="mpeg2video" data-types="amf,nvenc,qsv,vaapi,rkmpp" />
<span>MPEG2</span> <span>MPEG2</span>
</label> </label>
<label> <label>
<input type="checkbox" is="emby-checkbox" class="chkDecodeCodec" data-codec="mpeg4" data-types="nvenc,rkmpp,videotoolbox" /> <input type="checkbox" is="emby-checkbox" class="chkDecodeCodec" data-codec="mpeg4" data-types="nvenc,rkmpp" />
<span>MPEG4</span> <span>MPEG4</span>
</label> </label>
<label> <label>
<input type="checkbox" is="emby-checkbox" class="chkDecodeCodec" data-codec="vc1" data-types="amf,nvenc,qsv,vaapi,videotoolbox" /> <input type="checkbox" is="emby-checkbox" class="chkDecodeCodec" data-codec="vc1" data-types="amf,nvenc,qsv,vaapi" />
<span>VC1</span> <span>VC1</span>
</label> </label>
<label> <label>
@ -123,6 +123,8 @@
</div> </div>
<div class="checkboxListContainer"> <div class="checkboxListContainer">
<h3 class="checkboxListLabel">${LabelEncodingFormatOptions}</h3>
<div class="fieldDescription">${EncodingFormatHelp}</div>
<div class="checkboxList"> <div class="checkboxList">
<label> <label>
<input type="checkbox" is="emby-checkbox" id="chkAllowHevcEncoding" /> <input type="checkbox" is="emby-checkbox" id="chkAllowHevcEncoding" />
@ -135,6 +137,12 @@
<span>${AllowAv1Encoding}</span> <span>${AllowAv1Encoding}</span>
</label> </label>
</div> </div>
<div class="checkboxList">
<label>
<input type="checkbox" is="emby-checkbox" id="chkAllowMjpegEncoding" />
<span>${AllowMjpegEncoding}</span>
</label>
</div>
</div> </div>
<div class="vppTonemappingOptions hide"> <div class="vppTonemappingOptions hide">
@ -155,6 +163,16 @@
</div> </div>
</div> </div>
<div class="videoToolboxTonemappingOptions hide">
<div class="checkboxListContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkVideoToolboxTonemapping" />
<span>${EnableVideoToolboxTonemapping}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${AllowVideoToolboxTonemappingHelp}</div>
</div>
</div>
<div class="tonemappingOptions hide"> <div class="tonemappingOptions hide">
<div class="checkboxListContainer checkboxContainer-withDescription"> <div class="checkboxListContainer checkboxContainer-withDescription">
<label> <label>
@ -235,9 +253,8 @@
<div class="inputContainer fldEncoderPath"> <div class="inputContainer fldEncoderPath">
<div style="display: flex; align-items: center;"> <div style="display: flex; align-items: center;">
<div style="flex-grow:1;"> <div style="flex-grow:1;">
<input is="emby-input" class="txtEncoderPath" label="${LabelffmpegPath}" autocomplete="off" dir="ltr" /> <input is="emby-input" class="txtEncoderPath" label="${LabelffmpegPath}" autocomplete="off" dir="ltr" disabled/>
</div> </div>
<button type="button" is="paper-icon-button-light" id="btnSelectEncoderPath" class="emby-input-iconbutton"><span class="material-icons search" aria-hidden="true"></span></button>
</div> </div>
<div class="fieldDescription"> <div class="fieldDescription">
<div>${LabelffmpegPathHelp}</div> <div>${LabelffmpegPathHelp}</div>

View file

@ -19,6 +19,7 @@ function loadPage(page, config, systemInfo) {
page.querySelector('#chkHardwareEncoding').checked = config.EnableHardwareEncoding; page.querySelector('#chkHardwareEncoding').checked = config.EnableHardwareEncoding;
page.querySelector('#chkAllowHevcEncoding').checked = config.AllowHevcEncoding; page.querySelector('#chkAllowHevcEncoding').checked = config.AllowHevcEncoding;
page.querySelector('#chkAllowAv1Encoding').checked = config.AllowAv1Encoding; page.querySelector('#chkAllowAv1Encoding').checked = config.AllowAv1Encoding;
page.querySelector('#chkAllowMjpegEncoding').checked = config.AllowMjpegEncoding;
$('#selectVideoDecoder', page).val(config.HardwareAccelerationType); $('#selectVideoDecoder', page).val(config.HardwareAccelerationType);
$('#selectThreadCount', page).val(config.EncodingThreadCount); $('#selectThreadCount', page).val(config.EncodingThreadCount);
page.querySelector('#chkEnableAudioVbr').checked = config.EnableAudioVbr; page.querySelector('#chkEnableAudioVbr').checked = config.EnableAudioVbr;
@ -32,6 +33,7 @@ function loadPage(page, config, systemInfo) {
$('#txtVaapiDevice', page).val(config.VaapiDevice || ''); $('#txtVaapiDevice', page).val(config.VaapiDevice || '');
page.querySelector('#chkTonemapping').checked = config.EnableTonemapping; page.querySelector('#chkTonemapping').checked = config.EnableTonemapping;
page.querySelector('#chkVppTonemapping').checked = config.EnableVppTonemapping; page.querySelector('#chkVppTonemapping').checked = config.EnableVppTonemapping;
page.querySelector('#chkVideoToolboxTonemapping').checked = config.EnableVideoToolboxTonemapping;
page.querySelector('#selectTonemappingAlgorithm').value = config.TonemappingAlgorithm; page.querySelector('#selectTonemappingAlgorithm').value = config.TonemappingAlgorithm;
page.querySelector('#selectTonemappingMode').value = config.TonemappingMode; page.querySelector('#selectTonemappingMode').value = config.TonemappingMode;
page.querySelector('#selectTonemappingRange').value = config.TonemappingRange; page.querySelector('#selectTonemappingRange').value = config.TonemappingRange;
@ -93,6 +95,7 @@ function onSubmit() {
config.VaapiDevice = $('#txtVaapiDevice', form).val(); config.VaapiDevice = $('#txtVaapiDevice', form).val();
config.EnableTonemapping = form.querySelector('#chkTonemapping').checked; config.EnableTonemapping = form.querySelector('#chkTonemapping').checked;
config.EnableVppTonemapping = form.querySelector('#chkVppTonemapping').checked; config.EnableVppTonemapping = form.querySelector('#chkVppTonemapping').checked;
config.EnableVideoToolboxTonemapping = form.querySelector('#chkVideoToolboxTonemapping').checked;
config.TonemappingAlgorithm = form.querySelector('#selectTonemappingAlgorithm').value; config.TonemappingAlgorithm = form.querySelector('#selectTonemappingAlgorithm').value;
config.TonemappingMode = form.querySelector('#selectTonemappingMode').value; config.TonemappingMode = form.querySelector('#selectTonemappingMode').value;
config.TonemappingRange = form.querySelector('#selectTonemappingRange').value; config.TonemappingRange = form.querySelector('#selectTonemappingRange').value;
@ -125,6 +128,7 @@ function onSubmit() {
config.EnableHardwareEncoding = form.querySelector('#chkHardwareEncoding').checked; config.EnableHardwareEncoding = form.querySelector('#chkHardwareEncoding').checked;
config.AllowHevcEncoding = form.querySelector('#chkAllowHevcEncoding').checked; config.AllowHevcEncoding = form.querySelector('#chkAllowHevcEncoding').checked;
config.AllowAv1Encoding = form.querySelector('#chkAllowAv1Encoding').checked; config.AllowAv1Encoding = form.querySelector('#chkAllowAv1Encoding').checked;
config.AllowMjpegEncoding = form.querySelector('#chkAllowMjpegEncoding').checked;
ApiClient.updateNamedConfiguration('encoding', config).then(function () { ApiClient.updateNamedConfiguration('encoding', config).then(function () {
updateEncoder(form); updateEncoder(form);
}, function () { }, function () {
@ -175,6 +179,9 @@ function getTabs() {
}, { }, {
href: '#/dashboard/playback/streaming', href: '#/dashboard/playback/streaming',
name: globalize.translate('TabStreaming') name: globalize.translate('TabStreaming')
}, {
href: '#/dashboard/playback/trickplay',
name: globalize.translate('Trickplay')
}]; }];
} }
@ -206,7 +213,7 @@ $(document).on('pageinit', '#encodingSettingsPage', function () {
page.querySelector('.fld10bitHevcVp9HwDecoding').classList.add('hide'); page.querySelector('.fld10bitHevcVp9HwDecoding').classList.add('hide');
} }
if (this.value == 'amf' || this.value == 'nvenc' || this.value == 'qsv' || this.value == 'vaapi' || this.value == 'rkmpp') { if (this.value == 'amf' || this.value == 'nvenc' || this.value == 'qsv' || this.value == 'vaapi' || this.value == 'rkmpp' || this.value == 'videotoolbox') {
page.querySelector('.tonemappingOptions').classList.remove('hide'); page.querySelector('.tonemappingOptions').classList.remove('hide');
} else { } else {
page.querySelector('.tonemappingOptions').classList.add('hide'); page.querySelector('.tonemappingOptions').classList.add('hide');
@ -218,6 +225,12 @@ $(document).on('pageinit', '#encodingSettingsPage', function () {
page.querySelector('.fldIntelLp').classList.add('hide'); page.querySelector('.fldIntelLp').classList.add('hide');
} }
if (this.value === 'videotoolbox') {
page.querySelector('.videoToolboxTonemappingOptions').classList.remove('hide');
} else {
page.querySelector('.videoToolboxTonemappingOptions').classList.add('hide');
}
if (systemInfo.OperatingSystem.toLowerCase() === 'linux' && (this.value == 'qsv' || this.value == 'vaapi')) { if (systemInfo.OperatingSystem.toLowerCase() === 'linux' && (this.value == 'qsv' || this.value == 'vaapi')) {
page.querySelector('.vppTonemappingOptions').classList.remove('hide'); page.querySelector('.vppTonemappingOptions').classList.remove('hide');
} else { } else {
@ -244,21 +257,6 @@ $(document).on('pageinit', '#encodingSettingsPage', function () {
setDecodingCodecsVisible(page, this.value); setDecodingCodecsVisible(page, this.value);
}); });
$('#btnSelectEncoderPath', page).on('click.selectDirectory', function () {
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser();
picker.show({
includeFiles: true,
callback: function (path) {
if (path) {
$('.txtEncoderPath', page).val(path);
}
picker.close();
}
});
});
});
$('#btnSelectTranscodingTempPath', page).on('click.selectDirectory', function () { $('#btnSelectTranscodingTempPath', page).on('click.selectDirectory', function () {
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => { import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser(); const picker = new DirectoryBrowser();

View file

@ -39,6 +39,9 @@ function getTabs() {
}, { }, {
href: '#/dashboard/playback/streaming', href: '#/dashboard/playback/streaming',
name: globalize.translate('TabStreaming') name: globalize.translate('TabStreaming')
}, {
href: '#/dashboard/playback/trickplay',
name: globalize.translate('Trickplay')
}]; }];
} }
@ -52,4 +55,3 @@ $(document).on('pageinit', '#playbackConfigurationPage', function () {
loadPage(page, config); loadPage(page, config);
}); });
}); });

View file

@ -3,11 +3,14 @@ import markdownIt from 'markdown-it';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import loading from '../../../../components/loading/loading'; import loading from '../../../../components/loading/loading';
import globalize from '../../../../scripts/globalize'; import globalize from '../../../../scripts/globalize';
import '../../../../elements/emby-button/emby-button';
import Dashboard from '../../../../utils/dashboard'; import Dashboard from '../../../../utils/dashboard';
import alert from '../../../../components/alert'; import alert from '../../../../components/alert';
import confirm from '../../../../components/confirm/confirm'; import confirm from '../../../../components/confirm/confirm';
import 'elements/emby-button/emby-button';
import 'elements/emby-collapse/emby-collapse';
import 'elements/emby-select/emby-select';
function populateHistory(packageInfo, page) { function populateHistory(packageInfo, page) {
let html = ''; let html = '';
const length = Math.min(packageInfo.versions.length, 10); const length = Math.min(packageInfo.versions.length, 10);

View file

@ -30,6 +30,9 @@ function getTabs() {
}, { }, {
href: '#/dashboard/playback/streaming', href: '#/dashboard/playback/streaming',
name: globalize.translate('TabStreaming') name: globalize.translate('TabStreaming')
}, {
href: '#/dashboard/playback/trickplay',
name: globalize.translate('Trickplay')
}]; }];
} }

View file

@ -7,6 +7,7 @@ import ServerConnections from 'components/ServerConnections';
import dom from 'scripts/dom'; import dom from 'scripts/dom';
import globalize from 'scripts/globalize'; import globalize from 'scripts/globalize';
import { getBackdropShape, getPortraitShape, getSquareShape } from 'utils/card'; import { getBackdropShape, getPortraitShape, getSquareShape } from 'utils/card';
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
import 'elements/emby-itemscontainer/emby-itemscontainer'; import 'elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-scroller/emby-scroller'; import 'elements/emby-scroller/emby-scroller';
@ -133,7 +134,7 @@ function getFetchDataFn(section) {
return function () { return function () {
const apiClient = this.apiClient; const apiClient = this.apiClient;
const options = { const options = {
SortBy: 'SeriesName,SortName', SortBy: [ItemSortBy.SeriesSortName, ItemSortBy.SortName].join(','),
SortOrder: 'Ascending', SortOrder: 'Ascending',
Filters: 'IsFavorite', Filters: 'IsFavorite',
Recursive: true, Recursive: true,

View file

@ -13,6 +13,7 @@ import '../elements/emby-scroller/emby-scroller';
import ServerConnections from '../components/ServerConnections'; import ServerConnections from '../components/ServerConnections';
import LibraryMenu from '../scripts/libraryMenu'; import LibraryMenu from '../scripts/libraryMenu';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type'; import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
function getInitialLiveTvQuery(instance, params, startIndex = 0, limit = 300) { function getInitialLiveTvQuery(instance, params, startIndex = 0, limit = 300) {
const query = { const query = {
@ -223,7 +224,7 @@ function updateAlphaPickerState(instance) {
if (alphaPicker) { if (alphaPicker) {
const values = instance.getSortValues(); const values = instance.getSortValues();
if (values.sortBy.indexOf('SortName') !== -1) { if (values.sortBy.indexOf(ItemSortBy.SortName) !== -1) {
alphaPicker.classList.remove('hide'); alphaPicker.classList.remove('hide');
instance.itemsContainer.parentNode.classList.add('padded-right-withalphapicker'); instance.itemsContainer.parentNode.classList.add('padded-right-withalphapicker');
} else { } else {
@ -981,7 +982,7 @@ class ItemsView {
return sortNameOption.value; return sortNameOption.value;
} }
return 'IsFolder,' + sortNameOption.value; return `${ItemSortBy.IsFolder},${sortNameOption.value}`;
} }
getSortMenuOptions() { getSortMenuOptions() {
@ -990,7 +991,7 @@ class ItemsView {
if (this.params.type === 'Programs') { if (this.params.type === 'Programs') {
sortBy.push({ sortBy.push({
name: globalize.translate('AirDate'), name: globalize.translate('AirDate'),
value: 'StartDate,SortName' value: [ItemSortBy.StartDate, ItemSortBy.SortName].join(',')
}); });
} }
@ -1015,7 +1016,7 @@ class ItemsView {
if (this.params.type !== 'Programs') { if (this.params.type !== 'Programs') {
sortBy.push({ sortBy.push({
name: globalize.translate('DateAdded'), name: globalize.translate('DateAdded'),
value: 'DateCreated,SortName' value: [ItemSortBy.DateCreated, ItemSortBy.SortName].join(',')
}); });
} }
@ -1029,13 +1030,13 @@ class ItemsView {
option = this.getNameSortOption(this.params); option = this.getNameSortOption(this.params);
sortBy.push({ sortBy.push({
name: globalize.translate('Folders'), name: globalize.translate('Folders'),
value: 'IsFolder,' + option.value value: `${ItemSortBy.IsFolder},${option.value}`
}); });
} }
sortBy.push({ sortBy.push({
name: globalize.translate('ParentalRating'), name: globalize.translate('ParentalRating'),
value: 'OfficialRating,SortName' value: [ItemSortBy.OfficialRating, ItemSortBy.SortName].join(',')
}); });
option = this.getPlayCountSortOption(); option = this.getPlayCountSortOption();
@ -1045,11 +1046,11 @@ class ItemsView {
sortBy.push({ sortBy.push({
name: globalize.translate('ReleaseDate'), name: globalize.translate('ReleaseDate'),
value: 'ProductionYear,PremiereDate,SortName' value: [ItemSortBy.ProductionYear, ItemSortBy.PremiereDate, ItemSortBy.SortName].join(',')
}); });
sortBy.push({ sortBy.push({
name: globalize.translate('Runtime'), name: globalize.translate('Runtime'),
value: 'Runtime,SortName' value: [ItemSortBy.Runtime, ItemSortBy.SortName].join(',')
}); });
return sortBy; return sortBy;
} }
@ -1058,13 +1059,13 @@ class ItemsView {
if (params.type === 'Episode') { if (params.type === 'Episode') {
return { return {
name: globalize.translate('Name'), name: globalize.translate('Name'),
value: 'SeriesName,SortName' value: [ItemSortBy.SeriesSortName, ItemSortBy.SortName].join(',')
}; };
} }
return { return {
name: globalize.translate('Name'), name: globalize.translate('Name'),
value: 'SortName' value: ItemSortBy.SortName
}; };
} }
@ -1075,7 +1076,7 @@ class ItemsView {
return { return {
name: globalize.translate('PlayCount'), name: globalize.translate('PlayCount'),
value: 'PlayCount,SortName' value: [ItemSortBy.PlayCount, ItemSortBy.SortName].join(',')
}; };
} }
@ -1086,7 +1087,7 @@ class ItemsView {
return { return {
name: globalize.translate('DatePlayed'), name: globalize.translate('DatePlayed'),
value: 'DatePlayed,SortName' value: [ItemSortBy.DatePlayed, ItemSortBy.SortName].join(',')
}; };
} }
@ -1097,14 +1098,14 @@ class ItemsView {
return { return {
name: globalize.translate('CriticRating'), name: globalize.translate('CriticRating'),
value: 'CriticRating,SortName' value: [ItemSortBy.CriticRating, ItemSortBy.SortName].join(',')
}; };
} }
getCommunityRatingSortOption() { getCommunityRatingSortOption() {
return { return {
name: globalize.translate('CommunityRating'), name: globalize.translate('CommunityRating'),
value: 'CommunityRating,SortName' value: [ItemSortBy.CommunityRating, ItemSortBy.SortName].join(',')
}; };
} }

View file

@ -146,6 +146,26 @@ export default function (view) {
btnUserRating.classList.add('hide'); btnUserRating.classList.add('hide');
btnUserRating.setItem(null); btnUserRating.setItem(null);
} }
// Update trickplay data
trickplayResolution = null;
const mediaSourceId = currentPlayer.streamInfo.mediaSource.Id;
const trickplayResolutions = item.Trickplay?.[mediaSourceId];
if (trickplayResolutions) {
// Prefer highest resolution <= 20% of total screen resolution width
let bestWidth;
const maxWidth = window.screen.width * window.devicePixelRatio * 0.2;
for (const [, info] of Object.entries(trickplayResolutions)) {
if (!bestWidth
|| (info.Width < bestWidth && bestWidth > maxWidth) // Objects not guaranteed to be sorted in any order, first width might be > maxWidth.
|| (info.Width > bestWidth && info.Width <= maxWidth)) {
bestWidth = info.Width;
}
}
if (bestWidth) trickplayResolution = trickplayResolutions[bestWidth];
}
} }
function getDisplayTimeWithoutAmPm(date, showSeconds) { function getDisplayTimeWithoutAmPm(date, showSeconds) {
@ -1356,6 +1376,81 @@ export default function (view) {
resetIdle(); resetIdle();
} }
function updateTrickplayBubbleHtml(apiClient, trickplayInfo, item, mediaSourceId, bubble, positionTicks) {
let doFullUpdate = false;
let chapterThumbContainer = bubble.querySelector('.chapterThumbContainer');
let chapterThumb;
let chapterThumbText;
// Create bubble elements if they don't already exist
if (chapterThumbContainer) {
chapterThumb = chapterThumbContainer.querySelector('.chapterThumb');
chapterThumbText = chapterThumbContainer.querySelector('.chapterThumbText');
} else {
doFullUpdate = true;
chapterThumbContainer = document.createElement('div');
chapterThumbContainer.classList.add('chapterThumbContainer');
chapterThumbContainer.style.overflow = 'hidden';
const chapterThumbWrapper = document.createElement('div');
chapterThumbWrapper.classList.add('chapterThumbWrapper');
chapterThumbWrapper.style.overflow = 'hidden';
chapterThumbWrapper.style.position = 'relative';
chapterThumbWrapper.style.width = trickplayInfo.Width + 'px';
chapterThumbWrapper.style.height = trickplayInfo.Height + 'px';
chapterThumbContainer.appendChild(chapterThumbWrapper);
chapterThumb = document.createElement('img');
chapterThumb.classList.add('chapterThumb');
chapterThumb.style.position = 'absolute';
chapterThumb.style.width = 'unset';
chapterThumb.style.minWidth = 'unset';
chapterThumb.style.height = 'unset';
chapterThumb.style.minHeight = 'unset';
chapterThumbWrapper.appendChild(chapterThumb);
const chapterThumbTextContainer = document.createElement('div');
chapterThumbTextContainer.classList.add('chapterThumbTextContainer');
chapterThumbContainer.appendChild(chapterThumbTextContainer);
chapterThumbText = document.createElement('h2');
chapterThumbText.classList.add('chapterThumbText');
chapterThumbTextContainer.appendChild(chapterThumbText);
}
// Update trickplay values
const currentTimeMs = positionTicks / 10_000;
const currentTile = Math.floor(currentTimeMs / trickplayInfo.Interval);
const tileSize = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
const tileOffset = currentTile % tileSize;
const index = Math.floor(currentTile / tileSize);
const tileOffsetX = tileOffset % trickplayInfo.TileWidth;
const tileOffsetY = Math.floor(tileOffset / trickplayInfo.TileWidth);
const offsetX = -(tileOffsetX * trickplayInfo.Width);
const offsetY = -(tileOffsetY * trickplayInfo.Height);
const imgSrc = apiClient.getUrl('Videos/' + item.Id + '/Trickplay/' + trickplayInfo.Width + '/' + index + '.jpg', {
api_key: apiClient.accessToken(),
MediaSourceId: mediaSourceId
});
if (chapterThumb.src != imgSrc) chapterThumb.src = imgSrc;
chapterThumb.style.left = offsetX + 'px';
chapterThumb.style.top = offsetY + 'px';
chapterThumbText.textContent = datetime.getDisplayRunningTime(positionTicks);
// Set bubble innerHTML if container isn't part of DOM
if (doFullUpdate) {
bubble.innerHTML = chapterThumbContainer.outerHTML;
}
return true;
}
function getImgUrl(item, chapter, index, maxWidth, apiClient) { function getImgUrl(item, chapter, index, maxWidth, apiClient) {
if (chapter.ImageTag) { if (chapter.ImageTag) {
return apiClient.getScaledImageUrl(item.Id, { return apiClient.getScaledImageUrl(item.Id, {
@ -1455,6 +1550,7 @@ export default function (view) {
let programEndDateMs = 0; let programEndDateMs = 0;
let playbackStartTimeTicks = 0; let playbackStartTimeTicks = 0;
let subtitleSyncOverlay; let subtitleSyncOverlay;
let trickplayResolution = null;
const nowPlayingVolumeSlider = view.querySelector('.osdVolumeSlider'); const nowPlayingVolumeSlider = view.querySelector('.osdVolumeSlider');
const nowPlayingVolumeSliderContainer = view.querySelector('.osdVolumeSliderContainer'); const nowPlayingVolumeSliderContainer = view.querySelector('.osdVolumeSliderContainer');
const nowPlayingPositionSlider = view.querySelector('.osdPositionSlider'); const nowPlayingPositionSlider = view.querySelector('.osdPositionSlider');
@ -1681,6 +1777,25 @@ export default function (view) {
} }
}); });
nowPlayingPositionSlider.updateBubbleHtml = function(bubble, value) {
showOsd();
const item = currentItem;
const ticks = currentRuntimeTicks * value / 100;
if (trickplayResolution && item?.Trickplay) {
return updateTrickplayBubbleHtml(
ServerConnections.getApiClient(item.ServerId),
trickplayResolution,
item,
currentPlayer.streamInfo.mediaSource.Id,
bubble,
ticks);
}
return false;
};
nowPlayingPositionSlider.getBubbleHtml = function (value) { nowPlayingPositionSlider.getBubbleHtml = function (value) {
showOsd(); showOsd();
if (enableProgressByTimeOfDay) { if (enableProgressByTimeOfDay) {

View file

@ -13,6 +13,7 @@ import { LibraryTab } from 'types/libraryTab';
import { getBackdropShape } from 'utils/card'; import { getBackdropShape } from 'utils/card';
import Dashboard from 'utils/dashboard'; import Dashboard from 'utils/dashboard';
import Events from 'utils/events'; import Events from 'utils/events';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import 'elements/emby-itemscontainer/emby-itemscontainer'; import 'elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-button/emby-button'; import 'elements/emby-button/emby-button';
@ -332,7 +333,7 @@ export default function (view, params) {
function onInputCommand(e) { function onInputCommand(e) {
if (e.detail.command === 'search') { if (e.detail.command === 'search') {
e.preventDefault(); e.preventDefault();
Dashboard.navigate('search.html?collectionType=tv&parentId=' + params.topParentId); Dashboard.navigate(`search.html?collectionType=${CollectionType.Tvshows}&parentId=${params.topParentId}`);
} }
} }

View file

@ -14,6 +14,13 @@
<div class="fieldDescription checkboxFieldDescription">${EnableGamepadHelp}</div> <div class="fieldDescription checkboxFieldDescription">${EnableGamepadHelp}</div>
<div class="fieldDescription checkboxFieldDescription">${LabelPleaseRestart}</div> <div class="fieldDescription checkboxFieldDescription">${LabelPleaseRestart}</div>
</div> </div>
<div class="checkboxContainer checkboxContainer-withDescription smoothScrollContainer hide">
<label>
<input type="checkbox" is="emby-checkbox" class="chkSmoothScroll" />
<span>${EnableSmoothScroll}</span>
</label>
</div>
</div> </div>
<button is="emby-button" type="submit" class="raised button-submit block btnSave hide"> <button is="emby-button" type="submit" class="raised button-submit block btnSave hide">

View file

@ -1,3 +1,4 @@
import layoutManager from 'components/layoutManager';
import toast from '../../../components/toast/toast'; import toast from '../../../components/toast/toast';
import globalize from '../../../scripts/globalize'; import globalize from '../../../scripts/globalize';
import appSettings from '../../../scripts/settings/appSettings'; import appSettings from '../../../scripts/settings/appSettings';
@ -6,6 +7,7 @@ import Events from '../../../utils/events.ts';
export default function (view) { export default function (view) {
function submit(e) { function submit(e) {
appSettings.enableGamepad(view.querySelector('.chkEnableGamepad').checked); appSettings.enableGamepad(view.querySelector('.chkEnableGamepad').checked);
appSettings.enableSmoothScroll(view.querySelector('.chkSmoothScroll').checked);
toast(globalize.translate('SettingsSaved')); toast(globalize.translate('SettingsSaved'));
@ -17,7 +19,11 @@ export default function (view) {
} }
view.addEventListener('viewshow', function () { view.addEventListener('viewshow', function () {
view.querySelector('.smoothScrollContainer').classList.toggle('hide', !layoutManager.tv);
view.querySelector('.chkEnableGamepad').checked = appSettings.enableGamepad(); view.querySelector('.chkEnableGamepad').checked = appSettings.enableGamepad();
view.querySelector('.chkSmoothScroll').checked = appSettings.enableSmoothScroll();
view.querySelector('form').addEventListener('submit', submit); view.querySelector('form').addEventListener('submit', submit);
view.querySelector('.btnSave').classList.remove('hide'); view.querySelector('.btnSave').classList.remove('hide');

View file

@ -161,6 +161,10 @@ function updateBubble(range, percent, value, bubble) {
let html; let html;
if (range.updateBubbleHtml?.(bubble, value)) {
return;
}
if (range.getBubbleHtml) { if (range.getBubbleHtml) {
html = range.getBubbleHtml(percent, value); html = range.getBubbleHtml(percent, value);
} else { } else {

2
src/global.d.ts vendored
View file

@ -16,6 +16,8 @@ export declare global {
} }
const __JF_BUILD_VERSION__: string; const __JF_BUILD_VERSION__: string;
const __PACKAGE_JSON_NAME__: string;
const __PACKAGE_JSON_VERSION__: string;
const __USE_SYSTEM_FONTS__: string; const __USE_SYSTEM_FONTS__: string;
const __WEBPACK_SERVE__: string; const __WEBPACK_SERVE__: string;
} }

View file

@ -59,6 +59,9 @@ function loadCoreDictionary() {
} }
function init() { function init() {
// Log current version to console to help out with issue triage and debugging
console.log(`${__PACKAGE_JSON_NAME__} version ${__PACKAGE_JSON_VERSION__} build ${__JF_BUILD_VERSION__}`);
// This is used in plugins // This is used in plugins
window.Events = Events; window.Events = Events;
window.TaskButton = taskButton; window.TaskButton = taskButton;

View file

@ -5,6 +5,7 @@ import profileBuilder from '../../scripts/browserDeviceProfile';
import { getIncludeCorsCredentials } from '../../scripts/settings/webSettings'; import { getIncludeCorsCredentials } from '../../scripts/settings/webSettings';
import { PluginType } from '../../types/plugin.ts'; import { PluginType } from '../../types/plugin.ts';
import Events from '../../utils/events.ts'; import Events from '../../utils/events.ts';
import { MediaError } from 'types/mediaError';
function getDefaultProfile() { function getDefaultProfile() {
return profileBuilder({}); return profileBuilder({});
@ -343,7 +344,7 @@ class HtmlAudioPlayer {
return; return;
case 2: case 2:
// MEDIA_ERR_NETWORK // MEDIA_ERR_NETWORK
type = 'network'; type = MediaError.NETWORK_ERROR;
break; break;
case 3: case 3:
// MEDIA_ERR_DECODE // MEDIA_ERR_DECODE
@ -351,12 +352,12 @@ class HtmlAudioPlayer {
htmlMediaHelper.handleHlsJsMediaError(self); htmlMediaHelper.handleHlsJsMediaError(self);
return; return;
} else { } else {
type = 'mediadecodeerror'; type = MediaError.MEDIA_DECODE_ERROR;
} }
break; break;
case 4: case 4:
// MEDIA_ERR_SRC_NOT_SUPPORTED // MEDIA_ERR_SRC_NOT_SUPPORTED
type = 'medianotsupported'; type = MediaError.MEDIA_NOT_SUPPORTED;
break; break;
default: default:
// seeing cases where Edge is firing error events with no error code // seeing cases where Edge is firing error events with no error code

View file

@ -37,6 +37,7 @@ import Events from '../../utils/events.ts';
import { includesAny } from '../../utils/container.ts'; import { includesAny } from '../../utils/container.ts';
import { isHls } from '../../utils/mediaSource.ts'; import { isHls } from '../../utils/mediaSource.ts';
import debounce from 'lodash-es/debounce'; import debounce from 'lodash-es/debounce';
import { MediaError } from 'types/mediaError';
/** /**
* Returns resolved URL. * Returns resolved URL.
@ -217,7 +218,7 @@ export class HtmlVideoPlayer {
*/ */
#currentAssRenderer; #currentAssRenderer;
/** /**
* @type {null | undefined} * @type {number | undefined}
*/ */
#customTrackIndex; #customTrackIndex;
/** /**
@ -443,6 +444,7 @@ export class HtmlVideoPlayer {
startPosition: options.playerStartPositionTicks / 10000000, startPosition: options.playerStartPositionTicks / 10000000,
manifestLoadingTimeOut: 20000, manifestLoadingTimeOut: 20000,
maxBufferLength: maxBufferLength, maxBufferLength: maxBufferLength,
videoPreference: { preferHDR: true },
xhrSetup(xhr) { xhrSetup(xhr) {
xhr.withCredentials = includeCorsCredentials; xhr.withCredentials = includeCorsCredentials;
} }
@ -519,7 +521,7 @@ export class HtmlVideoPlayer {
if (enableHlsJsPlayer(options.mediaSource.RunTimeTicks, 'Video') && isHls(options.mediaSource)) { if (enableHlsJsPlayer(options.mediaSource.RunTimeTicks, 'Video') && isHls(options.mediaSource)) {
return this.setSrcWithHlsJs(elem, options, val); return this.setSrcWithHlsJs(elem, options, val);
} else if (options.playMethod !== 'Transcode' && options.mediaSource.Container === 'flv') { } else if (options.playMethod !== 'Transcode' && options.mediaSource.Container?.toUpperCase() === 'FLV') {
return this.setSrcWithFlvJs(elem, options, val); return this.setSrcWithFlvJs(elem, options, val);
} else { } else {
elem.autoplay = true; elem.autoplay = true;
@ -982,6 +984,8 @@ export class HtmlVideoPlayer {
seekOnPlaybackStart(this, e.target, this._currentPlayOptions.playerStartPositionTicks, () => { seekOnPlaybackStart(this, e.target, this._currentPlayOptions.playerStartPositionTicks, () => {
if (this.#currentAssRenderer) { if (this.#currentAssRenderer) {
this.#currentAssRenderer.timeOffset = (this._currentPlayOptions.transcodingOffsetTicks || 0) / 10000000 + this.#currentTrackOffset; this.#currentAssRenderer.timeOffset = (this._currentPlayOptions.transcodingOffsetTicks || 0) / 10000000 + this.#currentTrackOffset;
this.#currentAssRenderer.resize();
this.#currentAssRenderer.resetRenderAheadCache(false);
} }
}); });
@ -1018,7 +1022,7 @@ export class HtmlVideoPlayer {
// Only trigger this if there is media info // Only trigger this if there is media info
// Avoid triggering in situations where it might not actually have a video stream (audio only live tv channel) // Avoid triggering in situations where it might not actually have a video stream (audio only live tv channel)
if (!mediaSource || mediaSource.RunTimeTicks) { if (!mediaSource || mediaSource.RunTimeTicks) {
onErrorInternal(this, 'mediadecodeerror'); onErrorInternal(this, MediaError.NO_MEDIA_ERROR);
} }
} }
} }
@ -1070,7 +1074,7 @@ export class HtmlVideoPlayer {
return; return;
case 2: case 2:
// MEDIA_ERR_NETWORK // MEDIA_ERR_NETWORK
type = 'network'; type = MediaError.NETWORK_ERROR;
break; break;
case 3: case 3:
// MEDIA_ERR_DECODE // MEDIA_ERR_DECODE
@ -1078,12 +1082,12 @@ export class HtmlVideoPlayer {
handleHlsJsMediaError(this); handleHlsJsMediaError(this);
return; return;
} else { } else {
type = 'mediadecodeerror'; type = MediaError.MEDIA_DECODE_ERROR;
} }
break; break;
case 4: case 4:
// MEDIA_ERR_SRC_NOT_SUPPORTED // MEDIA_ERR_SRC_NOT_SUPPORTED
type = 'medianotsupported'; type = MediaError.MEDIA_NOT_SUPPORTED;
break; break;
default: default:
// seeing cases where Edge is firing error events with no error code // seeing cases where Edge is firing error events with no error code
@ -1168,9 +1172,9 @@ export class HtmlVideoPlayer {
this.#currentClock = null; this.#currentClock = null;
this._currentAspectRatio = null; this._currentAspectRatio = null;
const jassub = this.#currentAssRenderer; const octopus = this.#currentAssRenderer;
if (jassub) { if (octopus) {
jassub.destroy(); octopus.dispose();
} }
this.#currentAssRenderer = null; this.#currentAssRenderer = null;
} }
@ -1259,43 +1263,36 @@ export class HtmlVideoPlayer {
const fallbackFontList = apiClient.getUrl('/FallbackFont/Fonts', { const fallbackFontList = apiClient.getUrl('/FallbackFont/Fonts', {
api_key: apiClient.accessToken() api_key: apiClient.accessToken()
}); });
// TODO: replace with `event-target-polyfill` once https://github.com/benlesh/event-target-polyfill/pull/12 or 11 is merged const htmlVideoPlayer = this;
import('event-target-polyfill').then(() => { import('@jellyfin/libass-wasm').then(({ default: SubtitlesOctopus }) => {
import('jassub').then(({ default: JASSUB }) => {
// test SIMD support
JASSUB._test();
const options = { const options = {
video: videoElement, video: videoElement,
subUrl: getTextTrackUrl(track, item), subUrl: getTextTrackUrl(track, item),
fonts: avaliableFonts, fonts: avaliableFonts,
fallbackFont: 'liberation sans', workerUrl: `${appRouter.baseUrl()}/libraries/subtitles-octopus-worker.js`,
availableFonts: { 'liberation sans': `${appRouter.baseUrl()}/default.woff2` }, legacyWorkerUrl: `${appRouter.baseUrl()}/libraries/subtitles-octopus-worker-legacy.js`,
// Disabled eslint compat, but is safe as corejs3 polyfills URL onError() {
// eslint-disable-next-line compat/compat // HACK: Clear JavascriptSubtitlesOctopus: it gets disposed when an error occurs
workerUrl: new URL('jassub/dist/jassub-worker.js', import.meta.url).href, htmlVideoPlayer.#currentAssRenderer = null;
// eslint-disable-next-line compat/compat
wasmUrl: new URL('jassub/dist/jassub-worker.wasm', import.meta.url).href, // HACK: Give JavascriptSubtitlesOctopus time to dispose itself
// eslint-disable-next-line compat/compat setTimeout(() => {
legacyWasmUrl: new URL('jassub/dist/jassub-worker.wasm.js', import.meta.url).href, onErrorInternal(this, MediaError.ASS_RENDER_ERROR);
// eslint-disable-next-line compat/compat }, 0);
modernWasmUrl : new URL('jassub/dist/jassub-worker-modern.wasm', import.meta.url).href, },
timeOffset: (this._currentPlayOptions.transcodingOffsetTicks || 0) / 10000000, timeOffset: (this._currentPlayOptions.transcodingOffsetTicks || 0) / 10000000,
// new jassub options; override all, even defaults
blendMode: 'js', // new octopus options; override all, even defaults
asyncRender: true, renderMode: 'wasm-blend',
offscreenRender: true,
// RVFC is polyfilled everywhere, but webOS 2 reports polyfill API's as functional even tho they aren't
onDemandRender: browser.web0sVersion !== 2,
useLocalFonts: true,
dropAllAnimations: false, dropAllAnimations: false,
dropAllBlur: !JASSUB._supportsSIMD,
libassMemoryLimit: 40, libassMemoryLimit: 40,
libassGlyphLimit: 40, libassGlyphLimit: 40,
targetFps: 24, targetFps: 24,
prescaleFactor: 0.8, prescaleFactor: 0.8,
prescaleHeightLimit: 1080, prescaleHeightLimit: 1080,
maxRenderHeight: 2160 maxRenderHeight: 2160,
resizeVariation: 0.2,
renderAhead: 90
}; };
Promise.all([ Promise.all([
@ -1307,12 +1304,6 @@ export class HtmlVideoPlayer {
options.workerUrl = workerUrl; options.workerUrl = workerUrl;
options.legacyWorkerUrl = legacyWorkerUrl; options.legacyWorkerUrl = legacyWorkerUrl;
const cleanup = () => {
this.#currentAssRenderer.destroy();
this.#currentAssRenderer = null;
onErrorInternal(this, 'mediadecodeerror');
};
if (config.EnableFallbackFont) { if (config.EnableFallbackFont) {
apiClient.getJSON(fallbackFontList).then((fontFiles = []) => { apiClient.getJSON(fallbackFontList).then((fontFiles = []) => {
fontFiles.forEach(font => { fontFiles.forEach(font => {
@ -1321,16 +1312,13 @@ export class HtmlVideoPlayer {
}); });
avaliableFonts.push(fontUrl); avaliableFonts.push(fontUrl);
}); });
this.#currentAssRenderer = new JASSUB(options); this.#currentAssRenderer = new SubtitlesOctopus(options);
this.#currentAssRenderer.addEventListener('error', cleanup, { once: true });
}); });
} else { } else {
this.#currentAssRenderer = new JASSUB(options); this.#currentAssRenderer = new SubtitlesOctopus(options);
this.#currentAssRenderer.addEventListener('error', cleanup, { once: true });
} }
}); });
}); });
});
} }
/** /**

View file

@ -17,7 +17,7 @@
z-index: 1000; z-index: 1000;
} }
.videoPlayerContainer .JASSUB { .videoPlayerContainer .libassjs-canvas-parent {
order: -1; order: -1;
} }

View file

@ -84,7 +84,7 @@ export function getItemsForPlayback(apiClient, query) {
}); });
} else { } else {
query.Limit = query.Limit || 300; query.Limit = query.Limit || 300;
query.Fields = 'Chapters'; query.Fields = ['Chapters', 'Trickplay'];
query.ExcludeLocationTypes = 'Virtual'; query.ExcludeLocationTypes = 'Virtual';
query.EnableTotalRecordCount = false; query.EnableTotalRecordCount = false;
query.CollapseBoxSetItems = false; query.CollapseBoxSetItems = false;
@ -200,7 +200,7 @@ export function translateItemsForPlayback(apiClient, items, options) {
IsVirtualUnaired: false, IsVirtualUnaired: false,
IsMissing: false, IsMissing: false,
UserId: apiClient.getCurrentUserId(), UserId: apiClient.getCurrentUserId(),
Fields: 'Chapters' Fields: ['Chapters', 'Trickplay']
}).then(function (episodesResult) { }).then(function (episodesResult) {
let foundItem = false; let foundItem = false;
episodesResult.Items = episodesResult.Items.filter(function (e) { episodesResult.Items = episodesResult.Items.filter(function (e) {

View file

@ -1,6 +1,6 @@
import browser from './browser';
import appSettings from './settings/appSettings'; import appSettings from './settings/appSettings';
import * as userSettings from './settings/userSettings'; import * as userSettings from './settings/userSettings';
import browser from './browser';
function canPlayH264(videoTestElement) { function canPlayH264(videoTestElement) {
return !!(videoTestElement.canPlayType?.('video/mp4; codecs="avc1.42E01E, mp4a.40.2"').replace(/no/, '')); return !!(videoTestElement.canPlayType?.('video/mp4; codecs="avc1.42E01E, mp4a.40.2"').replace(/no/, ''));
@ -68,7 +68,7 @@ function canPlayNativeHls() {
} }
function canPlayNativeHlsInFmp4() { function canPlayNativeHlsInFmp4() {
if (browser.tizenVersion >= 3 || browser.web0sVersion >= 3.5) { if (browser.tizenVersion >= 5 || browser.web0sVersion >= 3.5) {
return true; return true;
} }
@ -210,12 +210,22 @@ function supportsDolbyVision(options) {
); );
} }
function canPlayDolbyVisionHevc(videoTestElement) { function supportedDolbyVisionProfilesHevc(videoTestElement) {
// Profiles 5/7/8 4k@60fps const supportedProfiles = [];
return !!videoTestElement.canPlayType // Profiles 5/8 4k@60fps
&& (videoTestElement.canPlayType('video/mp4; codecs="dvh1.05.09"').replace(/no/, '') if (videoTestElement.canPlayType) {
&& videoTestElement.canPlayType('video/mp4; codecs="dvh1.07.09"').replace(/no/, '') if (videoTestElement
&& videoTestElement.canPlayType('video/mp4; codecs="dvh1.08.09"').replace(/no/, '')); .canPlayType('video/mp4; codecs="dvh1.05.09"')
.replace(/no/, '')) {
supportedProfiles.push(5);
}
if (videoTestElement
.canPlayType('video/mp4; codecs="dvh1.08.09"')
.replace(/no/, '')) {
supportedProfiles.push(8);
}
}
return supportedProfiles;
} }
function getDirectPlayProfileForVideoContainer(container, videoAudioCodecs, videoTestElement, options) { function getDirectPlayProfileForVideoContainer(container, videoAudioCodecs, videoTestElement, options) {
@ -942,9 +952,15 @@ export default function (options) {
av1VideoRangeTypes += '|HLG'; av1VideoRangeTypes += '|HLG';
} }
if (supportsDolbyVision(options) && canPlayDolbyVisionHevc(videoTestElement)) { if (supportsDolbyVision(options)) {
const profiles = supportedDolbyVisionProfilesHevc(videoTestElement);
if (profiles.includes(5)) {
hevcVideoRangeTypes += '|DOVI'; hevcVideoRangeTypes += '|DOVI';
} }
if (profiles.includes(8)) {
hevcVideoRangeTypes += '|DOVIWithHDR10|DOVIWithHLG|DOVIWithSDR';
}
}
const h264CodecProfileConditions = [ const h264CodecProfileConditions = [
{ {
@ -1131,7 +1147,7 @@ export default function (options) {
// On iOS 12.x, for TS container max h264 level is 4.2 // On iOS 12.x, for TS container max h264 level is 4.2
if (browser.iOS && browser.iOSVersion < 13) { if (browser.iOS && browser.iOSVersion < 13) {
const codecProfile = { const codecProfileTS = {
Type: 'Video', Type: 'Video',
Codec: 'h264', Codec: 'h264',
Container: 'ts', Container: 'ts',
@ -1140,14 +1156,32 @@ export default function (options) {
}) })
}; };
codecProfile.Conditions.push({ codecProfileTS.Conditions.push({
Condition: 'LessThanEqual', Condition: 'LessThanEqual',
Property: 'VideoLevel', Property: 'VideoLevel',
Value: '42', Value: '42',
IsRequired: false IsRequired: false
}); });
profile.CodecProfiles.push(codecProfile); profile.CodecProfiles.push(codecProfileTS);
const codecProfileMp4 = {
Type: 'Video',
Codec: 'h264',
Container: 'mp4',
Conditions: h264CodecProfileConditions.filter((condition) => {
return condition.Property !== 'VideoLevel';
})
};
codecProfileMp4.Conditions.push({
Condition: 'LessThanEqual',
Property: 'VideoLevel',
Value: '42',
IsRequired: false
});
profile.CodecProfiles.push(codecProfileMp4);
} }
profile.CodecProfiles.push({ profile.CodecProfiles.push({
@ -1156,6 +1190,24 @@ export default function (options) {
Conditions: h264CodecProfileConditions Conditions: h264CodecProfileConditions
}); });
if (browser.web0s && supportsDolbyVision(options)) {
// Disallow direct playing of DOVI media in containers not mp4.
// This paired with the "Prefer fMP4-HLS Container" client playback setting enables DOVI playback on webOS.
profile.CodecProfiles.push({
Type: 'Video',
Container: '-mp4',
Codec: 'hevc',
Conditions: [
{
Condition: 'EqualsAny',
Property: 'VideoRangeType',
Value: 'SDR|HDR10|HLG',
IsRequired: false
}
]
});
}
profile.CodecProfiles.push({ profile.CodecProfiles.push({
Type: 'Video', Type: 'Video',
Codec: 'hevc', Codec: 'hevc',
@ -1232,4 +1284,3 @@ export default function (options) {
return profile; return profile;
} }

View file

@ -4,25 +4,38 @@ import { appRouter } from '../components/router/appRouter';
import globalize from './globalize'; import globalize from './globalize';
import ServerConnections from '../components/ServerConnections'; import ServerConnections from '../components/ServerConnections';
import alert from '../components/alert'; import alert from '../components/alert';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
function alertText(options) { function alertText(options) {
return alert(options); return alert(options);
} }
function getDeletionConfirmContent(item) {
if (item.Type === BaseItemKind.Series) {
const totalEpisodes = item.RecursiveItemCount;
return {
title: globalize.translate('HeaderDeleteSeries'),
text: globalize.translate('ConfirmDeleteSeries', totalEpisodes),
confirmText: globalize.translate('DeleteEntireSeries', totalEpisodes),
primary: 'delete'
};
}
return {
title: globalize.translate('HeaderDeleteItem'),
text: globalize.translate('ConfirmDeleteItem'),
confirmText: globalize.translate('Delete'),
primary: 'delete'
};
}
export function deleteItem(options) { export function deleteItem(options) {
const item = options.item; const item = options.item;
const parentId = item.SeasonId || item.SeriesId || item.ParentId; const parentId = item.SeasonId || item.SeriesId || item.ParentId;
const apiClient = ServerConnections.getApiClient(item.ServerId); const apiClient = ServerConnections.getApiClient(item.ServerId);
return confirm({ return confirm(getDeletionConfirmContent(item)).then(function () {
title: globalize.translate('HeaderDeleteItem'),
text: globalize.translate('ConfirmDeleteItem'),
confirmText: globalize.translate('Delete'),
primary: 'delete'
}).then(function () {
return apiClient.deleteItem(item.Id).then(function () { return apiClient.deleteItem(item.Id).then(function () {
if (options.navigate) { if (options.navigate) {
if (parentId) { if (parentId) {

View file

@ -1,3 +1,4 @@
import browser from 'scripts/browser';
import Events from '../../utils/events.ts'; import Events from '../../utils/events.ts';
import { toBoolean } from '../../utils/string.ts'; import { toBoolean } from '../../utils/string.ts';
@ -31,6 +32,19 @@ class AppSettings {
return toBoolean(this.get('enableGamepad'), false); return toBoolean(this.get('enableGamepad'), false);
} }
/**
* Get or set 'Enable smooth scroll' state.
* @param {boolean|undefined} val - Flag to enable 'Enable smooth scroll' or undefined.
* @return {boolean} 'Enable smooth scroll' state.
*/
enableSmoothScroll(val) {
if (val !== undefined) {
return this.set('enableSmoothScroll', val.toString());
}
return toBoolean(this.get('enableSmoothScroll'), !!browser.tizen);
}
enableSystemExternalPlayers(val) { enableSystemExternalPlayers(val) {
if (val !== undefined) { if (val !== undefined) {
this.set('enableSystemExternalPlayers', val.toString()); this.set('enableSystemExternalPlayers', val.toString());
@ -105,6 +119,19 @@ class AppSettings {
return parseInt(this.get('maxVideoWidth') || '0', 10) || 0; return parseInt(this.get('maxVideoWidth') || '0', 10) || 0;
} }
/**
* Get or set 'Limit maximum supported video resolution' state.
* @param {boolean|undefined} val - Flag to enable 'Limit maximum supported video resolution' or undefined.
* @return {boolean} 'Limit maximum supported video resolution' state.
*/
limitSupportedVideoResolution(val) {
if (val !== undefined) {
return this.set('limitSupportedVideoResolution', val.toString());
}
return toBoolean(this.get('limitSupportedVideoResolution'), false);
}
set(name, value, userId) { set(name, value, userId) {
const currentValue = this.get(name, userId); const currentValue = this.get(name, userId);
localStorage.setItem(this.#getKey(name, userId), value); localStorage.setItem(this.#getKey(name, userId), value);

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