Compare commits
113 Commits
renovate/m
...
release-10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c66db4e18d | ||
|
|
ea2abad3e1 | ||
|
|
6d8c8c0566 | ||
|
|
a2855c785e | ||
|
|
bf31a733a7 | ||
|
|
bf70fb80aa | ||
|
|
2acc6f360a | ||
|
|
a36eb7b546 | ||
|
|
fb6250d108 | ||
|
|
a82ae33aa3 | ||
|
|
32d916b420 | ||
|
|
014af0ebe9 | ||
|
|
9b80917cd1 | ||
|
|
238c5bbf58 | ||
|
|
264cdafaff | ||
|
|
1459a11320 | ||
|
|
ad3223cb77 | ||
|
|
445fe22f29 | ||
|
|
e28d70d34c | ||
|
|
9a207e9ba9 | ||
|
|
5db40d03ac | ||
|
|
ae58599bd0 | ||
|
|
e2ae48d8e5 | ||
|
|
bc39ee10ba | ||
|
|
603b5ed20c | ||
|
|
6bfff061ce | ||
|
|
44818f0c97 | ||
|
|
b3725e9dd5 | ||
|
|
ce22f8fe22 | ||
|
|
9f1370f242 | ||
|
|
b3913d7bb3 | ||
|
|
69d169e45f | ||
|
|
264eedc90a | ||
|
|
6fba30a0a9 | ||
|
|
3376a126de | ||
|
|
4e9c2e71a9 | ||
|
|
06f5442fc9 | ||
|
|
c478d6e307 | ||
|
|
cacb660ff8 | ||
|
|
4bdc0fd974 | ||
|
|
9af155b291 | ||
|
|
74f98bb120 | ||
|
|
e568ecbf30 | ||
|
|
1686788be5 | ||
|
|
43749273e4 | ||
|
|
b807ebfa4a | ||
|
|
8cc49df625 | ||
|
|
f2d2c5b26e | ||
|
|
5c444198ea | ||
|
|
dee5a1bcea | ||
|
|
3d55ce3724 | ||
|
|
3c6a5160a6 | ||
|
|
01200f3d70 | ||
|
|
39f971ffa4 | ||
|
|
e6141968d7 | ||
|
|
f445e53f7e | ||
|
|
d1379dce8a | ||
|
|
03c2cebbd3 | ||
|
|
ab0042d46f | ||
|
|
3c388fef92 | ||
|
|
9c76311936 | ||
|
|
f077e294a9 | ||
|
|
1c8f221006 | ||
|
|
a1d8bec051 | ||
|
|
000f89b99e | ||
|
|
83317879a8 | ||
|
|
7c0807680d | ||
|
|
053ce59352 | ||
|
|
b3833e7479 | ||
|
|
21d7dd86ea | ||
|
|
e2e679f0be | ||
|
|
993d370582 | ||
|
|
933e1b255b | ||
|
|
2c45c5ba4a | ||
|
|
cdde002ca6 | ||
|
|
19cb2e9977 | ||
|
|
fb7a1538d0 | ||
|
|
7491722364 | ||
|
|
d6c169321e | ||
|
|
6e2c62525a | ||
|
|
09dc3ae3a8 | ||
|
|
e102334812 | ||
|
|
907947c523 | ||
|
|
f3d7994b2a | ||
|
|
b9fdc61b6d | ||
|
|
37dcc07da5 | ||
|
|
e4e2c97bd5 | ||
|
|
6ce3e579c2 | ||
|
|
dbcac4c6f4 | ||
|
|
c11d630e42 | ||
|
|
7643885c6b | ||
|
|
92a1aa16dc | ||
|
|
4560d7c90f | ||
|
|
e97d658b3c | ||
|
|
7c0c2e088f | ||
|
|
0989a3034f | ||
|
|
17a1e2e94c | ||
|
|
b5382f0142 | ||
|
|
12079b9462 | ||
|
|
6a55ee3d71 | ||
|
|
6ee77f18bc | ||
|
|
db7498ed03 | ||
|
|
4f83e97592 | ||
|
|
4b072633fb | ||
|
|
0772f146b4 | ||
|
|
0bb8f7cb47 | ||
|
|
f7583a842b | ||
|
|
45bca06b2c | ||
|
|
c688faacb8 | ||
|
|
737b85b0b6 | ||
|
|
81698d5da7 | ||
|
|
64fbd6d3de | ||
|
|
fa7831bd1f |
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -1,5 +1 @@
|
||||
* @jellyfin/web
|
||||
# Joshua must review all changes to bump_version
|
||||
bump_version @joshuaboniface
|
||||
# Core must approve all changes within the repo config
|
||||
.github/ @jellyfin/core
|
||||
|
||||
8
.github/workflows/__codeql.yml
vendored
8
.github/workflows/__codeql.yml
vendored
@@ -20,21 +20,21 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository ⬇️
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
ref: ${{ inputs.commit }}
|
||||
show-progress: false
|
||||
|
||||
- name: Initialize CodeQL 🛠️
|
||||
uses: github/codeql-action/init@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3.31.4
|
||||
uses: github/codeql-action/init@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
|
||||
with:
|
||||
queries: security-and-quality
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild 📦
|
||||
uses: github/codeql-action/autobuild@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3.31.4
|
||||
uses: github/codeql-action/autobuild@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
|
||||
|
||||
- name: Perform CodeQL Analysis 🧪
|
||||
uses: github/codeql-action/analyze@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3.31.4
|
||||
uses: github/codeql-action/analyze@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
2
.github/workflows/__package.yml
vendored
2
.github/workflows/__package.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
ref: ${{ inputs.commit || github.sha }}
|
||||
|
||||
|
||||
6
.github/workflows/__quality_checks.yml
vendored
6
.github/workflows/__quality_checks.yml
vendored
@@ -14,13 +14,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
ref: ${{ inputs.commit }}
|
||||
show-progress: false
|
||||
|
||||
- name: Scan
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3
|
||||
with:
|
||||
## Workaround from https://github.com/actions/dependency-review-action/issues/456
|
||||
## TODO: Remove when necessary
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout ⬇️
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
ref: ${{ inputs.commit }}
|
||||
show-progress: false
|
||||
|
||||
4
.github/workflows/pull_request.yml
vendored
4
.github/workflows/pull_request.yml
vendored
@@ -80,7 +80,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
@@ -95,6 +95,6 @@ jobs:
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run eslint
|
||||
uses: CatChen/eslint-suggestion-action@4ee415529307a8ca0260b4a3775484802523e5af # v4.1.19
|
||||
uses: CatChen/eslint-suggestion-action@4dda35decf912ab18ea3e071acec2c6c2eda00b6 # v4.1.18
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
267
package-lock.json
generated
267
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.12.0",
|
||||
"version": "10.11.6",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.12.0",
|
||||
"version": "10.11.6",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.14.0",
|
||||
@@ -18,7 +18,7 @@
|
||||
"@fontsource/noto-sans-sc": "5.2.6",
|
||||
"@fontsource/noto-sans-tc": "5.2.6",
|
||||
"@jellyfin/libass-wasm": "4.2.3",
|
||||
"@jellyfin/sdk": "0.0.0-unstable.202512091852",
|
||||
"@jellyfin/sdk": "0.12.0",
|
||||
"@jellyfin/ux-web": "1.0.0",
|
||||
"@mui/icons-material": "6.4.12",
|
||||
"@mui/material": "6.4.12",
|
||||
@@ -74,7 +74,7 @@
|
||||
"@babel/preset-react": "7.27.1",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.5.0",
|
||||
"@eslint/js": "9.30.1",
|
||||
"@stylistic/eslint-plugin": "5.6.1",
|
||||
"@stylistic/eslint-plugin": "4.4.1",
|
||||
"@stylistic/stylelint-plugin": "3.1.3",
|
||||
"@types/dompurify": "3.0.5",
|
||||
"@types/escape-html": "1.0.4",
|
||||
@@ -102,7 +102,7 @@
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-sonarjs": "3.0.4",
|
||||
"expose-loader": "5.0.1",
|
||||
"fast-glob": "3.3.3",
|
||||
@@ -129,7 +129,6 @@
|
||||
"ts-loader": "9.5.2",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "8.35.1",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "3.2.4",
|
||||
"webpack": "5.99.9",
|
||||
"webpack-bundle-analyzer": "4.10.2",
|
||||
@@ -3718,9 +3717,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
|
||||
"integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4130,9 +4129,9 @@
|
||||
"license": "LGPL-2.1-or-later AND (FTL OR GPL-2.0-or-later) AND MIT AND MIT-Modern-Variant AND ISC AND NTP AND Zlib AND BSL-1.0"
|
||||
},
|
||||
"node_modules/@jellyfin/sdk": {
|
||||
"version": "0.0.0-unstable.202512091852",
|
||||
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202512091852.tgz",
|
||||
"integrity": "sha512-N+QEsrKk4KculkV6KMBb7XpzTLWcXEzqTHbS+b4rov0VYVwR6DIsJkmUzB3hM2YZsrLIHEFKhFRy/r4itkFeHw==",
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.12.0.tgz",
|
||||
"integrity": "sha512-do3cks7TD316Qw27lBMHZQ7ufaS1MC8HMsQF5rFv5/DUInuwEOqWthqVyHl3sIjThOThF1zxQyE6OdpUl0dNUg==",
|
||||
"license": "MPL-2.0",
|
||||
"peerDependencies": {
|
||||
"axios": "^1.12.0"
|
||||
@@ -5384,18 +5383,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stylistic/eslint-plugin": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.6.1.tgz",
|
||||
"integrity": "sha512-JCs+MqoXfXrRPGbGmho/zGS/jMcn3ieKl/A8YImqib76C8kjgZwq5uUFzc30lJkMvcchuRn6/v8IApLxli3Jyw==",
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-4.4.1.tgz",
|
||||
"integrity": "sha512-CEigAk7eOLyHvdgmpZsKFwtiqS2wFwI1fn4j09IU9GmD4euFM4jEBAViWeCqaNLlbX2k2+A/Fq9cje4HQBXuJQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.0",
|
||||
"@typescript-eslint/types": "^8.47.0",
|
||||
"eslint-visitor-keys": "^4.2.1",
|
||||
"espree": "^10.4.0",
|
||||
"@typescript-eslint/utils": "^8.32.1",
|
||||
"eslint-visitor-keys": "^4.2.0",
|
||||
"espree": "^10.3.0",
|
||||
"estraverse": "^5.3.0",
|
||||
"picomatch": "^4.0.3"
|
||||
"picomatch": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -5404,20 +5402,6 @@
|
||||
"eslint": ">=9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz",
|
||||
"integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
@@ -5442,9 +5426,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@stylistic/eslint-plugin/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -10560,20 +10544,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-hooks": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
|
||||
"integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
|
||||
"integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/parser": "^7.24.4",
|
||||
"hermes-parser": "^0.25.1",
|
||||
"zod": "^3.25.0 || ^4.0.0",
|
||||
"zod-validation-error": "^3.5.0 || ^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
|
||||
@@ -12391,13 +12368,6 @@
|
||||
"integrity": "sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/globrex": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
|
||||
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gonzales-pe": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz",
|
||||
@@ -12635,23 +12605,6 @@
|
||||
"resolved": "https://registry.npmjs.org/headroom.js/-/headroom.js-0.12.0.tgz",
|
||||
"integrity": "sha512-iXnAafUm3FdzfJ91uixLws2hkKI1jC8bAKK/pt7XYr8Ie1jO7xbK48Ycpl9tUPyBgkzuj1p/PhJS0fy4E/5anA=="
|
||||
},
|
||||
"node_modules/hermes-estree": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||
"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hermes-parser": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
|
||||
"integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight-words": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/highlight-words/-/highlight-words-2.0.0.tgz",
|
||||
@@ -18279,6 +18232,7 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/proxy-polyfill": {
|
||||
@@ -23233,27 +23187,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfck": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
|
||||
"integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tsconfck": "bin/tsconfck.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfig-paths": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||
@@ -23904,26 +23837,6 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-tsconfig-paths": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
|
||||
"integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"globrex": "^0.1.2",
|
||||
"tsconfck": "^3.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/fdir": {
|
||||
"version": "6.4.4",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
|
||||
@@ -25022,29 +24935,6 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.2.tgz",
|
||||
"integrity": "sha512-b8L8yn4rIVfiXyHAmnr52/ZEpDumlT0bmxiq3Ws1ybrinhflGpt12Hvv54kYnEsGPRs6o/Ka3/ppA2OWY21IVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-validation-error": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
|
||||
"integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -26950,9 +26840,9 @@
|
||||
}
|
||||
},
|
||||
"@eslint-community/eslint-utils": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
|
||||
"integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"eslint-visitor-keys": "^3.4.3"
|
||||
@@ -27212,9 +27102,9 @@
|
||||
"integrity": "sha512-C0OlBxIr9NdeFESMTA/OVDqNSWtog6Mi7wwzwH12xbZpxsMD0RgCupUcIP7zZgcpTNecW3fZq5d6xVo7Q8HEJw=="
|
||||
},
|
||||
"@jellyfin/sdk": {
|
||||
"version": "0.0.0-unstable.202512091852",
|
||||
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202512091852.tgz",
|
||||
"integrity": "sha512-N+QEsrKk4KculkV6KMBb7XpzTLWcXEzqTHbS+b4rov0VYVwR6DIsJkmUzB3hM2YZsrLIHEFKhFRy/r4itkFeHw==",
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.12.0.tgz",
|
||||
"integrity": "sha512-do3cks7TD316Qw27lBMHZQ7ufaS1MC8HMsQF5rFv5/DUInuwEOqWthqVyHl3sIjThOThF1zxQyE6OdpUl0dNUg==",
|
||||
"requires": {}
|
||||
},
|
||||
"@jellyfin/ux-web": {
|
||||
@@ -27837,25 +27727,18 @@
|
||||
"dev": true
|
||||
},
|
||||
"@stylistic/eslint-plugin": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.6.1.tgz",
|
||||
"integrity": "sha512-JCs+MqoXfXrRPGbGmho/zGS/jMcn3ieKl/A8YImqib76C8kjgZwq5uUFzc30lJkMvcchuRn6/v8IApLxli3Jyw==",
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-4.4.1.tgz",
|
||||
"integrity": "sha512-CEigAk7eOLyHvdgmpZsKFwtiqS2wFwI1fn4j09IU9GmD4euFM4jEBAViWeCqaNLlbX2k2+A/Fq9cje4HQBXuJQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint-community/eslint-utils": "^4.9.0",
|
||||
"@typescript-eslint/types": "^8.47.0",
|
||||
"eslint-visitor-keys": "^4.2.1",
|
||||
"espree": "^10.4.0",
|
||||
"@typescript-eslint/utils": "^8.32.1",
|
||||
"eslint-visitor-keys": "^4.2.0",
|
||||
"espree": "^10.3.0",
|
||||
"estraverse": "^5.3.0",
|
||||
"picomatch": "^4.0.3"
|
||||
"picomatch": "^4.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": {
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz",
|
||||
"integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==",
|
||||
"dev": true
|
||||
},
|
||||
"eslint-visitor-keys": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
@@ -27869,9 +27752,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
@@ -31681,17 +31564,11 @@
|
||||
}
|
||||
},
|
||||
"eslint-plugin-react-hooks": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
|
||||
"integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
|
||||
"integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/parser": "^7.24.4",
|
||||
"hermes-parser": "^0.25.1",
|
||||
"zod": "^3.25.0 || ^4.0.0",
|
||||
"zod-validation-error": "^3.5.0 || ^4.0.0"
|
||||
}
|
||||
"requires": {}
|
||||
},
|
||||
"eslint-plugin-sonarjs": {
|
||||
"version": "3.0.4",
|
||||
@@ -32807,12 +32684,6 @@
|
||||
"integrity": "sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM=",
|
||||
"dev": true
|
||||
},
|
||||
"globrex": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
|
||||
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
|
||||
"dev": true
|
||||
},
|
||||
"gonzales-pe": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz",
|
||||
@@ -32980,21 +32851,6 @@
|
||||
"resolved": "https://registry.npmjs.org/headroom.js/-/headroom.js-0.12.0.tgz",
|
||||
"integrity": "sha512-iXnAafUm3FdzfJ91uixLws2hkKI1jC8bAKK/pt7XYr8Ie1jO7xbK48Ycpl9tUPyBgkzuj1p/PhJS0fy4E/5anA=="
|
||||
},
|
||||
"hermes-estree": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||
"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
|
||||
"dev": true
|
||||
},
|
||||
"hermes-parser": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
|
||||
"integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"highlight-words": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/highlight-words/-/highlight-words-2.0.0.tgz",
|
||||
@@ -40262,13 +40118,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"tsconfck": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
|
||||
"integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"tsconfig-paths": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||
@@ -40710,17 +40559,6 @@
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"vite-tsconfig-paths": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
|
||||
"integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"debug": "^4.1.1",
|
||||
"globrex": "^0.1.2",
|
||||
"tsconfck": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"vitest": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
@@ -41440,19 +41278,6 @@
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"dev": true
|
||||
},
|
||||
"zod": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.2.tgz",
|
||||
"integrity": "sha512-b8L8yn4rIVfiXyHAmnr52/ZEpDumlT0bmxiq3Ws1ybrinhflGpt12Hvv54kYnEsGPRs6o/Ka3/ppA2OWY21IVg==",
|
||||
"dev": true
|
||||
},
|
||||
"zod-validation-error": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
|
||||
"integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.12.0",
|
||||
"version": "10.11.6",
|
||||
"description": "Web interface for Jellyfin",
|
||||
"repository": "https://github.com/jellyfin/jellyfin-web",
|
||||
"license": "GPL-2.0-or-later",
|
||||
@@ -11,7 +11,7 @@
|
||||
"@babel/preset-react": "7.27.1",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.5.0",
|
||||
"@eslint/js": "9.30.1",
|
||||
"@stylistic/eslint-plugin": "5.6.1",
|
||||
"@stylistic/eslint-plugin": "4.4.1",
|
||||
"@stylistic/stylelint-plugin": "3.1.3",
|
||||
"@types/dompurify": "3.0.5",
|
||||
"@types/escape-html": "1.0.4",
|
||||
@@ -39,7 +39,7 @@
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-sonarjs": "3.0.4",
|
||||
"expose-loader": "5.0.1",
|
||||
"fast-glob": "3.3.3",
|
||||
@@ -66,7 +66,6 @@
|
||||
"ts-loader": "9.5.2",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "8.35.1",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "3.2.4",
|
||||
"webpack": "5.99.9",
|
||||
"webpack-bundle-analyzer": "4.10.2",
|
||||
@@ -85,7 +84,7 @@
|
||||
"@fontsource/noto-sans-sc": "5.2.6",
|
||||
"@fontsource/noto-sans-tc": "5.2.6",
|
||||
"@jellyfin/libass-wasm": "4.2.3",
|
||||
"@jellyfin/sdk": "0.0.0-unstable.202512091852",
|
||||
"@jellyfin/sdk": "0.12.0",
|
||||
"@jellyfin/ux-web": "1.0.0",
|
||||
"@mui/icons-material": "6.4.12",
|
||||
"@mui/material": "6.4.12",
|
||||
@@ -162,7 +161,7 @@
|
||||
"build:check": "tsc --noEmit",
|
||||
"build:es-check": "npm run build:production && npm run escheck",
|
||||
"escheck": "es-check",
|
||||
"lint": "eslint",
|
||||
"lint": "eslint \"./\"",
|
||||
"test": "vitest --watch=false --config vite.config.ts",
|
||||
"test:watch": "vitest --config vite.config.ts",
|
||||
"stylelint": "stylelint \"src/**/*.{css,scss}\""
|
||||
|
||||
@@ -13,16 +13,13 @@ import { STABLE_APP_ROUTES } from 'apps/stable/routes/routes';
|
||||
import { WIZARD_APP_ROUTES } from 'apps/wizard/routes/routes';
|
||||
import AppHeader from 'components/AppHeader';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import { SETTING_KEY as LAYOUT_SETTING_KEY } from 'components/layoutManager';
|
||||
import BangRedirect from 'components/router/BangRedirect';
|
||||
import { createRouterHistory } from 'components/router/routerHistory';
|
||||
import { LayoutMode } from 'constants/layoutMode';
|
||||
import browser from 'scripts/browser';
|
||||
import appTheme from 'themes';
|
||||
import appTheme from 'themes/themes';
|
||||
import { ThemeStorageManager } from 'themes/themeStorageManager';
|
||||
|
||||
const layoutMode = browser.tv ? LayoutMode.Tv : localStorage.getItem(LAYOUT_SETTING_KEY);
|
||||
const isExperimentalLayout = !layoutMode || layoutMode === LayoutMode.Experimental;
|
||||
const layoutMode = localStorage.getItem('layout');
|
||||
const isExperimentalLayout = layoutMode === 'experimental';
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
|
||||
1
src/apiclient.d.ts
vendored
1
src/apiclient.d.ts
vendored
@@ -136,7 +136,6 @@ declare module 'jellyfin-apiclient' {
|
||||
getInstantMixFromItem(itemId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getIntros(itemId: string): Promise<BaseItemDtoQueryResult>;
|
||||
getItemCounts(userId?: string): Promise<ItemCounts>;
|
||||
/** @deprecated This function returns a URL with a legacy auth parameter.*/
|
||||
getItemDownloadUrl(itemId: string): string;
|
||||
getItemImageInfos(itemId: string): Promise<ImageInfo[]>;
|
||||
getItems(userId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
const fetchAuthProviders = async (api: Api, options?: AxiosRequestConfig) => {
|
||||
const response = await getSessionApi(api).getAuthProviders(options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useAuthProviders = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [ 'AuthProviders' ],
|
||||
queryFn: ({ signal }) => fetchAuthProviders(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { getChannelsApi } from '@jellyfin/sdk/lib/utils/api/channels-api';
|
||||
import { ChannelsApiGetChannelsRequest } from '@jellyfin/sdk/lib/generated-client/api/channels-api';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
const fetchChannels = async (api: Api, params?: ChannelsApiGetChannelsRequest, options?: AxiosRequestConfig) => {
|
||||
const response = await getChannelsApi(api).getChannels(params, options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useChannels = (params?: ChannelsApiGetChannelsRequest) => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [ 'Channels' ],
|
||||
queryFn: ({ signal }) => fetchChannels(api!, params, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { UserApiCreateUserByNameRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
|
||||
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
export const useCreateUser = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: UserApiCreateUserByNameRequest) => (
|
||||
getUserApi(api!)
|
||||
.createUserByName(params)
|
||||
)
|
||||
});
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import { UserApiDeleteUserRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
|
||||
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { QUERY_KEY } from 'hooks/useUsers';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
export const useDeleteUser = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: UserApiDeleteUserRequest) => (
|
||||
getUserApi(api!)
|
||||
.deleteUser(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { LibraryApiGetMediaFoldersRequest } from '@jellyfin/sdk/lib/generated-client/api/library-api';
|
||||
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
const fetchLibraryMediaFolders = async (api: Api, params?: LibraryApiGetMediaFoldersRequest, options?: AxiosRequestConfig) => {
|
||||
const response = await getLibraryApi(api).getMediaFolders(params, options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useLibraryMediaFolders = (params?: LibraryApiGetMediaFoldersRequest) => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['LibraryMediaFolders'],
|
||||
queryFn: ({ signal }) => fetchLibraryMediaFolders(api!, params, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import type { NetworkConfiguration } from '@jellyfin/sdk/lib/generated-client/models/network-configuration';
|
||||
|
||||
const fetchNetworkConfig = async (api: Api, options?: AxiosRequestConfig) => {
|
||||
const response = await getConfigurationApi(api).getNamedConfiguration({ key: 'network' }, options);
|
||||
|
||||
return response.data as NetworkConfiguration;
|
||||
};
|
||||
|
||||
export const useNetworkConfig = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [ 'NetConfig' ],
|
||||
queryFn: ({ signal }) => fetchNetworkConfig(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { getLocalizationApi } from '@jellyfin/sdk/lib/utils/api/localization-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
const fetchParentalRatings = async (api: Api, options?: AxiosRequestConfig) => {
|
||||
const response = await getLocalizationApi(api).getParentalRatings(options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useParentalRatings = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['ParentalRatings'],
|
||||
queryFn: ({ signal }) => fetchParentalRatings(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
const fetchPasswordResetProviders = async (api: Api, options?: AxiosRequestConfig) => {
|
||||
const response = await getSessionApi(api).getPasswordResetProviders(options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const usePasswordResetProviders = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [ 'PasswordResetProviders' ],
|
||||
queryFn: ({ signal }) => fetchPasswordResetProviders(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import { UserApiUpdateUserRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
|
||||
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { QUERY_KEY } from './useUser';
|
||||
|
||||
export const useUpdateUser = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: UserApiUpdateUserRequest) => (
|
||||
getUserApi(api!)
|
||||
.updateUser(params)
|
||||
),
|
||||
onSuccess: (_, params) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [QUERY_KEY, params.userId]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { UserApiUpdateUserPolicyRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
|
||||
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { QUERY_KEY } from './useUser';
|
||||
|
||||
export const useUpdateUserPolicy = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: UserApiUpdateUserPolicyRequest) => (
|
||||
|
||||
getUserApi(api!)
|
||||
.updateUserPolicy(params)
|
||||
),
|
||||
onSuccess: (_, params) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [QUERY_KEY, params.userId]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { UserApiGetUserByIdRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
|
||||
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
export const QUERY_KEY = 'User';
|
||||
|
||||
const fetchUser = async (api: Api, params: UserApiGetUserByIdRequest, options?: AxiosRequestConfig) => {
|
||||
const response = await getUserApi(api).getUserById(params, options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useUser = (params?: UserApiGetUserByIdRequest) => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [ QUERY_KEY, params?.userId ],
|
||||
queryFn: ({ signal }) => fetchUser(api!, params!, { signal }),
|
||||
enabled: !!api && !!params
|
||||
});
|
||||
};
|
||||
@@ -98,164 +98,166 @@ export const Component = () => {
|
||||
className='type-interior mainAnimatedPage'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
{isError ? (
|
||||
<Alert
|
||||
severity='error'
|
||||
sx={{ marginBottom: 2 }}
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction='row'
|
||||
sx={{
|
||||
flexWrap: {
|
||||
xs: 'wrap',
|
||||
sm: 'nowrap'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{globalize.translate('PluginsLoadError')}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction='row'
|
||||
<Typography
|
||||
variant='h1'
|
||||
component='span'
|
||||
sx={{
|
||||
flexWrap: {
|
||||
xs: 'wrap',
|
||||
sm: 'nowrap'
|
||||
flexGrow: 1,
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
>
|
||||
{globalize.translate('TabPlugins')}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
component={Link}
|
||||
to='/dashboard/plugins/repositories'
|
||||
variant='outlined'
|
||||
sx={{
|
||||
marginLeft: 2
|
||||
}}
|
||||
>
|
||||
{globalize.translate('ManageRepositories')}
|
||||
</Button>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'end',
|
||||
marginTop: {
|
||||
xs: 2,
|
||||
sm: 0
|
||||
},
|
||||
marginLeft: {
|
||||
xs: 0,
|
||||
sm: 2
|
||||
},
|
||||
width: {
|
||||
xs: '100%',
|
||||
sm: 'auto'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant='h1'
|
||||
component='span'
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
>
|
||||
{globalize.translate('TabPlugins')}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
component={Link}
|
||||
to='/dashboard/plugins/repositories'
|
||||
variant='outlined'
|
||||
sx={{
|
||||
marginLeft: 2
|
||||
}}
|
||||
>
|
||||
{globalize.translate('ManageRepositories')}
|
||||
</Button>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'end',
|
||||
marginTop: {
|
||||
xs: 2,
|
||||
sm: 0
|
||||
},
|
||||
marginLeft: {
|
||||
xs: 0,
|
||||
sm: 2
|
||||
},
|
||||
width: {
|
||||
xs: '100%',
|
||||
sm: 'auto'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SearchInput
|
||||
label={globalize.translate('Search')}
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Stack
|
||||
direction='row'
|
||||
spacing={1}
|
||||
sx={{
|
||||
marginLeft: '-1rem',
|
||||
marginRight: '-1rem',
|
||||
paddingLeft: '1rem',
|
||||
paddingRight: '1rem',
|
||||
paddingBottom: {
|
||||
xs: 1,
|
||||
md: 0.5
|
||||
},
|
||||
overflowX: 'auto'
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
color={status === PluginStatusOption.All ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.All)}
|
||||
label={globalize.translate('All')}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
color={status === PluginStatusOption.Available ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.Available)}
|
||||
label={globalize.translate('LabelAvailable')}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
color={status === PluginStatusOption.Installed ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.Installed)}
|
||||
label={globalize.translate('LabelInstalled')}
|
||||
/>
|
||||
|
||||
<Divider orientation='vertical' flexItem />
|
||||
|
||||
<Chip
|
||||
color={!category ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory('')}
|
||||
label={globalize.translate('All')}
|
||||
/>
|
||||
|
||||
{Object.values(PluginCategory).map(c => (
|
||||
<Chip
|
||||
key={c}
|
||||
color={category === c.toLowerCase() ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory(c.toLowerCase())}
|
||||
label={globalize.translate(CATEGORY_LABELS[c as PluginCategory])}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Divider />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{filteredPlugins.length > 0 ? (
|
||||
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
<Grid container spacing={2}>
|
||||
{filteredPlugins.map(plugin => (
|
||||
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
<Grid
|
||||
key={plugin.id}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={4}
|
||||
lg={3}
|
||||
xl={2}
|
||||
>
|
||||
<PluginCard
|
||||
plugin={plugin}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<NoPluginResults
|
||||
isFiltered={!!category || status !== PluginStatusOption.All}
|
||||
onViewAll={onViewAll}
|
||||
query={searchQuery}
|
||||
/>
|
||||
)}
|
||||
<SearchInput
|
||||
label={globalize.translate('Search')}
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{isError ? (
|
||||
<Alert
|
||||
severity='error'
|
||||
sx={{ marginBottom: 2 }}
|
||||
>
|
||||
{globalize.translate('PluginsLoadError')}
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Box>
|
||||
<Stack
|
||||
direction='row'
|
||||
spacing={1}
|
||||
sx={{
|
||||
marginLeft: '-1rem',
|
||||
marginRight: '-1rem',
|
||||
paddingLeft: '1rem',
|
||||
paddingRight: '1rem',
|
||||
paddingBottom: {
|
||||
xs: 1,
|
||||
md: 0.5
|
||||
},
|
||||
overflowX: 'auto'
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
color={status === PluginStatusOption.All ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.All)}
|
||||
label={globalize.translate('All')}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
color={status === PluginStatusOption.Available ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.Available)}
|
||||
label={globalize.translate('LabelAvailable')}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
color={status === PluginStatusOption.Installed ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.Installed)}
|
||||
label={globalize.translate('LabelInstalled')}
|
||||
/>
|
||||
|
||||
<Divider orientation='vertical' flexItem />
|
||||
|
||||
<Chip
|
||||
color={!category ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory('')}
|
||||
label={globalize.translate('All')}
|
||||
/>
|
||||
|
||||
{Object.values(PluginCategory).map(c => (
|
||||
<Chip
|
||||
key={c}
|
||||
color={category === c.toLowerCase() ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory(c.toLowerCase())}
|
||||
label={globalize.translate(CATEGORY_LABELS[c as PluginCategory])}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Divider />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{filteredPlugins.length > 0 ? (
|
||||
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
<Grid container spacing={2}>
|
||||
{filteredPlugins.map(plugin => (
|
||||
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
<Grid
|
||||
key={plugin.id}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={4}
|
||||
lg={3}
|
||||
xl={2}
|
||||
>
|
||||
<PluginCard
|
||||
plugin={plugin}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<NoPluginResults
|
||||
isFiltered={!!category || status !== PluginStatusOption.All}
|
||||
onViewAll={onViewAll}
|
||||
query={searchQuery}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BaseItemDto, CreateUserByName } from '@jellyfin/sdk/lib/generated-client';
|
||||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||
|
||||
import Dashboard from '../../../../utils/dashboard';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
@@ -11,11 +12,10 @@ import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import Page from '../../../../components/Page';
|
||||
import Toast from 'apps/dashboard/components/Toast';
|
||||
|
||||
import { useLibraryMediaFolders } from 'apps/dashboard/features/users/api/useLibraryMediaFolders';
|
||||
import { useChannels } from 'apps/dashboard/features/users/api/useChannels';
|
||||
import { useUpdateUserPolicy } from 'apps/dashboard/features/users/api/useUpdateUserPolicy';
|
||||
import { useCreateUser } from 'apps/dashboard/features/users/api/useCreateUser';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
type UserInput = {
|
||||
Name?: string;
|
||||
Password?: string;
|
||||
};
|
||||
|
||||
type ItemsArr = {
|
||||
Name?: string | null;
|
||||
@@ -23,7 +23,6 @@ type ItemsArr = {
|
||||
};
|
||||
|
||||
const UserNew = () => {
|
||||
const navigate = useNavigate();
|
||||
const [ channelsItems, setChannelsItems ] = useState<ItemsArr[]>([]);
|
||||
const [ mediaFoldersItems, setMediaFoldersItems ] = useState<ItemsArr[]>([]);
|
||||
const [ isErrorToastOpen, setIsErrorToastOpen ] = useState(false);
|
||||
@@ -32,11 +31,6 @@ const UserNew = () => {
|
||||
const handleToastClose = useCallback(() => {
|
||||
setIsErrorToastOpen(false);
|
||||
}, []);
|
||||
const { data: mediaFolders, isSuccess: isMediaFoldersSuccess } = useLibraryMediaFolders();
|
||||
const { data: channels, isSuccess: isChannelsSuccess } = useChannels();
|
||||
|
||||
const createUser = useCreateUser();
|
||||
const updateUserPolicy = useUpdateUserPolicy();
|
||||
|
||||
const getItemsResult = (items: BaseItemDto[]) => {
|
||||
return items.map(item =>
|
||||
@@ -55,7 +49,9 @@ const UserNew = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setMediaFoldersItems(getItemsResult(result));
|
||||
const mediaFolders = getItemsResult(result);
|
||||
|
||||
setMediaFoldersItems(mediaFolders);
|
||||
|
||||
const folderAccess = page.querySelector('.folderAccess') as HTMLDivElement;
|
||||
folderAccess.dispatchEvent(new CustomEvent('create'));
|
||||
@@ -71,15 +67,15 @@ const UserNew = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const channelItems = getItemsResult(result);
|
||||
const channels = getItemsResult(result);
|
||||
|
||||
setChannelsItems(channelItems);
|
||||
setChannelsItems(channels);
|
||||
|
||||
const channelAccess = page.querySelector('.channelAccess') as HTMLDivElement;
|
||||
channelAccess.dispatchEvent(new CustomEvent('create'));
|
||||
|
||||
const channelAccessContainer = page.querySelector('.channelAccessContainer') as HTMLDivElement;
|
||||
channelItems.length ? channelAccessContainer.classList.remove('hide') : channelAccessContainer.classList.add('hide');
|
||||
channels.length ? channelAccessContainer.classList.remove('hide') : channelAccessContainer.classList.add('hide');
|
||||
|
||||
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).checked = false;
|
||||
}, []);
|
||||
@@ -91,26 +87,22 @@ const UserNew = () => {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
if (!mediaFolders?.Items) {
|
||||
console.error('[add] mediaFolders not available');
|
||||
return;
|
||||
}
|
||||
if (!channels?.Items) {
|
||||
console.error('[add] channels not available');
|
||||
return;
|
||||
}
|
||||
|
||||
loadMediaFolders(mediaFolders?.Items);
|
||||
loadChannels(channels?.Items);
|
||||
loading.hide();
|
||||
}, [loadChannels, loadMediaFolders, mediaFolders, channels]);
|
||||
|
||||
useEffect(() => {
|
||||
(page.querySelector('#txtUsername') as HTMLInputElement).value = '';
|
||||
(page.querySelector('#txtPassword') as HTMLInputElement).value = '';
|
||||
loading.show();
|
||||
if (isMediaFoldersSuccess && isChannelsSuccess) {
|
||||
loadUser();
|
||||
}
|
||||
}, [loadUser, isMediaFoldersSuccess, isChannelsSuccess]);
|
||||
const promiseFolders = window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', {
|
||||
IsHidden: false
|
||||
}));
|
||||
const promiseChannels = window.ApiClient.getJSON(window.ApiClient.getUrl('Channels'));
|
||||
Promise.all([promiseFolders, promiseChannels]).then(function (responses) {
|
||||
loadMediaFolders(responses[0].Items);
|
||||
loadChannels(responses[1].Items);
|
||||
loading.hide();
|
||||
}).catch(err => {
|
||||
console.error('[usernew] failed to load data', err);
|
||||
});
|
||||
}, [loadChannels, loadMediaFolders]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
@@ -120,54 +112,51 @@ const UserNew = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
loadUser();
|
||||
|
||||
const saveUser = () => {
|
||||
const userInput: CreateUserByName = {
|
||||
Name: (page.querySelector('#txtUsername') as HTMLInputElement).value,
|
||||
Password: (page.querySelector('#txtPassword') as HTMLInputElement).value
|
||||
};
|
||||
createUser.mutate({ createUserByName: userInput }, {
|
||||
onSuccess: (response) => {
|
||||
const user = response.data;
|
||||
const userInput: UserInput = {};
|
||||
userInput.Name = (page.querySelector('#txtUsername') as HTMLInputElement).value.trim();
|
||||
userInput.Password = (page.querySelector('#txtPassword') as HTMLInputElement).value;
|
||||
|
||||
if (!user.Id || !user.Policy) {
|
||||
throw new Error('Unexpected null user id or policy');
|
||||
}
|
||||
window.ApiClient.createUser(userInput).then(function (user) {
|
||||
if (!user.Id || !user.Policy) {
|
||||
throw new Error('Unexpected null user id or policy');
|
||||
}
|
||||
|
||||
user.Policy.EnableAllFolders = (page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked;
|
||||
user.Policy.EnabledFolders = [];
|
||||
user.Policy.EnableAllFolders = (page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked;
|
||||
user.Policy.EnabledFolders = [];
|
||||
|
||||
if (!user.Policy.EnableAllFolders) {
|
||||
user.Policy.EnabledFolders = Array.prototype.filter.call(page.querySelectorAll('.chkFolder'), function (i) {
|
||||
return i.checked;
|
||||
}).map(function (i) {
|
||||
return i.getAttribute('data-id');
|
||||
});
|
||||
}
|
||||
|
||||
user.Policy.EnableAllChannels = (page.querySelector('.chkEnableAllChannels') as HTMLInputElement).checked;
|
||||
user.Policy.EnabledChannels = [];
|
||||
|
||||
if (!user.Policy.EnableAllChannels) {
|
||||
user.Policy.EnabledChannels = Array.prototype.filter.call(page.querySelectorAll('.chkChannel'), function (i) {
|
||||
return i.checked;
|
||||
}).map(function (i) {
|
||||
return i.getAttribute('data-id');
|
||||
});
|
||||
}
|
||||
|
||||
updateUserPolicy.mutate({
|
||||
userId: user.Id,
|
||||
userPolicy: user.Policy
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
navigate(`/dashboard/users/profile?userId=${user.Id}`);
|
||||
},
|
||||
onError: () => {
|
||||
console.error('[usernew] failed to update user policy');
|
||||
setIsErrorToastOpen(true);
|
||||
}
|
||||
if (!user.Policy.EnableAllFolders) {
|
||||
user.Policy.EnabledFolders = Array.prototype.filter.call(page.querySelectorAll('.chkFolder'), function (i) {
|
||||
return i.checked;
|
||||
}).map(function (i) {
|
||||
return i.getAttribute('data-id');
|
||||
});
|
||||
}
|
||||
|
||||
user.Policy.EnableAllChannels = (page.querySelector('.chkEnableAllChannels') as HTMLInputElement).checked;
|
||||
user.Policy.EnabledChannels = [];
|
||||
|
||||
if (!user.Policy.EnableAllChannels) {
|
||||
user.Policy.EnabledChannels = Array.prototype.filter.call(page.querySelectorAll('.chkChannel'), function (i) {
|
||||
return i.checked;
|
||||
}).map(function (i) {
|
||||
return i.getAttribute('data-id');
|
||||
});
|
||||
}
|
||||
|
||||
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
|
||||
Dashboard.navigate('/dashboard/users/profile?userId=' + user.Id)
|
||||
.catch(err => {
|
||||
console.error('[usernew] failed to navigate to edit user page', err);
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('[usernew] failed to update user policy', err);
|
||||
});
|
||||
}, function () {
|
||||
setIsErrorToastOpen(true);
|
||||
loading.hide();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -179,32 +168,22 @@ const UserNew = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const enableAllChannelsChange = function (this: HTMLInputElement) {
|
||||
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
||||
const channelAccessListContainer = page.querySelector('.channelAccessListContainer') as HTMLDivElement;
|
||||
this.checked ? channelAccessListContainer.classList.add('hide') : channelAccessListContainer.classList.remove('hide');
|
||||
};
|
||||
});
|
||||
|
||||
const enableAllFoldersChange = function (this: HTMLInputElement) {
|
||||
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
||||
const folderAccessListContainer = page.querySelector('.folderAccessListContainer') as HTMLDivElement;
|
||||
this.checked ? folderAccessListContainer.classList.add('hide') : folderAccessListContainer.classList.remove('hide');
|
||||
};
|
||||
});
|
||||
|
||||
const onCancelClick = () => {
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).addEventListener('change', enableAllChannelsChange);
|
||||
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).addEventListener('change', enableAllFoldersChange);
|
||||
(page.querySelector('.newUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', onCancelClick);
|
||||
|
||||
return () => {
|
||||
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).removeEventListener('change', enableAllChannelsChange);
|
||||
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).removeEventListener('change', enableAllFoldersChange);
|
||||
(page.querySelector('.newUserProfileForm') as HTMLFormElement).removeEventListener('submit', onSubmit);
|
||||
(page.querySelector('#btnCancel') as HTMLButtonElement).removeEventListener('click', onCancelClick);
|
||||
};
|
||||
}, [loadUser, createUser, updateUserPolicy, navigate]);
|
||||
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', function() {
|
||||
window.history.back();
|
||||
});
|
||||
}, [loadUser]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
|
||||
import Dashboard from '../../../../utils/dashboard';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import dom from '../../../../utils/dom';
|
||||
import confirm from '../../../../components/confirm/confirm';
|
||||
import UserCardBox from '../../../../components/dashboard/users/UserCardBox';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
@@ -9,12 +14,8 @@ import '../../../../components/cardbuilder/card.scss';
|
||||
import '../../../../components/indicators/indicators.scss';
|
||||
import '../../../../styles/flexstyles.scss';
|
||||
import Page from '../../../../components/Page';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import Toast from 'apps/dashboard/components/Toast';
|
||||
import { useUsers } from 'hooks/useUsers';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import { useDeleteUser } from 'apps/dashboard/features/users/api/useDeleteUser';
|
||||
import dom from 'utils/dom';
|
||||
|
||||
type MenuEntry = {
|
||||
name?: string;
|
||||
@@ -25,15 +26,24 @@ type MenuEntry = {
|
||||
const UserProfiles = () => {
|
||||
const location = useLocation();
|
||||
const [ isSettingsSavedToastOpen, setIsSettingsSavedToastOpen ] = useState(false);
|
||||
const [ users, setUsers ] = useState<UserDto[]>([]);
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const { data: users, isPending } = useUsers();
|
||||
const deleteUser = useDeleteUser();
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
setIsSettingsSavedToastOpen(false);
|
||||
}, []);
|
||||
|
||||
const loadData = () => {
|
||||
loading.show();
|
||||
window.ApiClient.getUsers().then(function (result) {
|
||||
setUsers(result);
|
||||
loading.hide();
|
||||
}).catch(err => {
|
||||
console.error('[userprofiles] failed to fetch users', err);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
@@ -47,6 +57,8 @@ const UserProfiles = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
loadData();
|
||||
|
||||
const showUserMenu = (elem: HTMLElement) => {
|
||||
const card = dom.parentWithClass(elem, 'card');
|
||||
const userId = card?.getAttribute('data-userid');
|
||||
@@ -87,19 +99,28 @@ const UserProfiles = () => {
|
||||
callback: function (id: string) {
|
||||
switch (id) {
|
||||
case 'open':
|
||||
navigate(`/dashboard/users/profile?userId=${userId}`);
|
||||
Dashboard.navigate('/dashboard/users/profile?userId=' + userId)
|
||||
.catch(err => {
|
||||
console.error('[userprofiles] failed to navigate to user edit page', err);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'access':
|
||||
navigate(`/dashboard/users/access?userId=${userId}`);
|
||||
Dashboard.navigate('/dashboard/users/access?userId=' + userId)
|
||||
.catch(err => {
|
||||
console.error('[userprofiles] failed to navigate to user library page', err);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'parentalcontrol':
|
||||
navigate(`/dashboard/users/parentalcontrol?userId=${userId}`);
|
||||
Dashboard.navigate('/dashboard/users/parentalcontrol?userId=' + userId)
|
||||
.catch(err => {
|
||||
console.error('[userprofiles] failed to navigate to parental control page', err);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
confirmDeleteUser(userId, username);
|
||||
deleteUser(userId, username);
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
@@ -110,7 +131,7 @@ const UserProfiles = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDeleteUser = (id: string, username?: string | null) => {
|
||||
const deleteUser = (id: string, username?: string | null) => {
|
||||
const title = username ? globalize.translate('DeleteName', username) : globalize.translate('DeleteUser');
|
||||
const text = globalize.translate('DeleteUserConfirmation');
|
||||
|
||||
@@ -120,38 +141,32 @@ const UserProfiles = () => {
|
||||
confirmText: globalize.translate('Delete'),
|
||||
primary: 'delete'
|
||||
}).then(function () {
|
||||
deleteUser.mutate({
|
||||
userId: id
|
||||
loading.show();
|
||||
window.ApiClient.deleteUser(id).then(function () {
|
||||
loadData();
|
||||
}).catch(err => {
|
||||
console.error('[userprofiles] failed to delete user', err);
|
||||
});
|
||||
}).catch(() => {
|
||||
// confirm dialog closed
|
||||
});
|
||||
};
|
||||
|
||||
const onPageClick = function (e: MouseEvent) {
|
||||
page.addEventListener('click', function (e) {
|
||||
const btnUserMenu = dom.parentWithClass(e.target as HTMLElement, 'btnUserMenu');
|
||||
|
||||
if (btnUserMenu) {
|
||||
showUserMenu(btnUserMenu);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const onAddUserClick = function() {
|
||||
navigate('/dashboard/users/add');
|
||||
};
|
||||
|
||||
page.addEventListener('click', onPageClick);
|
||||
(page.querySelector('#btnAddUser') as HTMLButtonElement).addEventListener('click', onAddUserClick);
|
||||
|
||||
return () => {
|
||||
page.removeEventListener('click', onPageClick);
|
||||
(page.querySelector('#btnAddUser') as HTMLButtonElement).removeEventListener('click', onAddUserClick);
|
||||
};
|
||||
}, [navigate, deleteUser, location.state?.openSavedToast]);
|
||||
|
||||
if (isPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
(page.querySelector('#btnAddUser') as HTMLButtonElement).addEventListener('click', function() {
|
||||
Dashboard.navigate('/dashboard/users/add')
|
||||
.catch(err => {
|
||||
console.error('[userprofiles] failed to navigate to new user page', err);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Page
|
||||
@@ -177,7 +192,7 @@ const UserProfiles = () => {
|
||||
</div>
|
||||
|
||||
<div className='localUsers itemsContainer vertical-wrap'>
|
||||
{users?.map(user => {
|
||||
{users.map(user => {
|
||||
return <UserCardBox key={user.Id} user={user} />;
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||
import UserPasswordForm from '../../../../components/dashboard/users/UserPasswordForm';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import Page from '../../../../components/Page';
|
||||
import { useUser } from 'apps/dashboard/features/users/api/useUser';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
|
||||
const UserPassword = () => {
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const { data: user, isPending } = useUser(userId ? { userId: userId } : undefined);
|
||||
const [ userName, setUserName ] = useState('');
|
||||
|
||||
if (isPending || !user) {
|
||||
return <Loading />;
|
||||
}
|
||||
const loadUser = useCallback(() => {
|
||||
if (!userId) {
|
||||
console.error('[userpassword] missing user id');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.show();
|
||||
window.ApiClient.getUser(userId).then(function (user) {
|
||||
if (!user.Name) {
|
||||
throw new Error('Unexpected null user.Name');
|
||||
}
|
||||
setUserName(user.Name);
|
||||
loading.hide();
|
||||
}).catch(err => {
|
||||
console.error('[userpassword] failed to fetch user', err);
|
||||
});
|
||||
}, [userId]);
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
}, [loadUser]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
@@ -25,13 +41,13 @@ const UserPassword = () => {
|
||||
<div className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={user?.Name || undefined}
|
||||
title={userName}
|
||||
/>
|
||||
</div>
|
||||
<SectionTabs activeTab='userpassword'/>
|
||||
<div className='readOnlyContent'>
|
||||
<UserPasswordForm
|
||||
user={user}
|
||||
userId={userId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,14 +13,6 @@ import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import SelectElement from '../../../../elements/SelectElement';
|
||||
import Page from '../../../../components/Page';
|
||||
import { useUser } from 'apps/dashboard/features/users/api/useUser';
|
||||
import { useAuthProviders } from 'apps/dashboard/features/users/api/useAuthProviders';
|
||||
import { usePasswordResetProviders } from 'apps/dashboard/features/users/api/usePasswordResetProviders';
|
||||
import { useLibraryMediaFolders } from 'apps/dashboard/features/users/api/useLibraryMediaFolders';
|
||||
import { useChannels } from 'apps/dashboard/features/users/api/useChannels';
|
||||
import { useUpdateUser } from 'apps/dashboard/features/users/api/useUpdateUser';
|
||||
import { useUpdateUserPolicy } from 'apps/dashboard/features/users/api/useUpdateUserPolicy';
|
||||
import { useNetworkConfig } from 'apps/dashboard/features/users/api/useNetworkConfig';
|
||||
|
||||
type ResetProvider = BaseItemDto & {
|
||||
checkedAttribute: string
|
||||
@@ -35,22 +27,15 @@ const UserEdit = () => {
|
||||
const navigate = useNavigate();
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const [ userDto, setUserDto ] = useState<UserDto>();
|
||||
const [ deleteFoldersAccess, setDeleteFoldersAccess ] = useState<ResetProvider[]>([]);
|
||||
const [ authProviders, setAuthProviders ] = useState<NameIdPair[]>([]);
|
||||
const [ passwordResetProviders, setPasswordResetProviders ] = useState<NameIdPair[]>([]);
|
||||
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
|
||||
|
||||
const [ authenticationProviderId, setAuthenticationProviderId ] = useState('');
|
||||
const [ passwordResetProviderId, setPasswordResetProviderId ] = useState('');
|
||||
|
||||
const { data: userDto, isSuccess: isUserSuccess } = useUser(userId ? { userId: userId } : undefined);
|
||||
const { data: authProviders, isSuccess: isAuthProvidersSuccess } = useAuthProviders();
|
||||
const { data: passwordResetProviders, isSuccess: isPasswordResetProvidersSuccess } = usePasswordResetProviders();
|
||||
const { data: mediaFolders, isSuccess: isMediaFoldersSuccess } = useLibraryMediaFolders({ isHidden: false });
|
||||
const { data: channels, isSuccess: isChannelsSuccess } = useChannels({ supportsMediaDeletion: true });
|
||||
const { data: netConfig, isSuccess: isNetConfigSuccess } = useNetworkConfig();
|
||||
|
||||
const updateUser = useUpdateUser();
|
||||
const updateUserPolicy = useUpdateUserPolicy();
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
const triggerChange = (select: HTMLInputElement) => {
|
||||
@@ -58,10 +43,17 @@ const UserEdit = () => {
|
||||
select.dispatchEvent(evt);
|
||||
};
|
||||
|
||||
const getUser = () => {
|
||||
if (!userId) throw new Error('missing user id');
|
||||
return window.ApiClient.getUser(userId);
|
||||
};
|
||||
|
||||
const loadAuthProviders = useCallback((page: HTMLDivElement, user: UserDto, providers: NameIdPair[]) => {
|
||||
const fldSelectLoginProvider = page.querySelector('.fldSelectLoginProvider') as HTMLDivElement;
|
||||
fldSelectLoginProvider.classList.toggle('hide', providers.length <= 1);
|
||||
|
||||
setAuthProviders(providers);
|
||||
|
||||
const currentProviderId = user.Policy?.AuthenticationProviderId || '';
|
||||
setAuthenticationProviderId(currentProviderId);
|
||||
}, []);
|
||||
@@ -70,26 +62,30 @@ const UserEdit = () => {
|
||||
const fldSelectPasswordResetProvider = page.querySelector('.fldSelectPasswordResetProvider') as HTMLDivElement;
|
||||
fldSelectPasswordResetProvider.classList.toggle('hide', providers.length <= 1);
|
||||
|
||||
setPasswordResetProviders(providers);
|
||||
|
||||
const currentProviderId = user.Policy?.PasswordResetProviderId || '';
|
||||
setPasswordResetProviderId(currentProviderId);
|
||||
}, []);
|
||||
|
||||
const loadDeleteFolders = useCallback((page: HTMLDivElement, user: UserDto, folders: BaseItemDto[]) => {
|
||||
let isChecked;
|
||||
let checkedAttribute;
|
||||
const itemsArr: ResetProvider[] = [];
|
||||
const loadDeleteFolders = useCallback((page: HTMLDivElement, user: UserDto, mediaFolders: BaseItemDto[]) => {
|
||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Channels', {
|
||||
SupportsMediaDeletion: true
|
||||
})).then(function (channelsResult) {
|
||||
let isChecked;
|
||||
let checkedAttribute;
|
||||
const itemsArr: ResetProvider[] = [];
|
||||
|
||||
for (const mediaFolder of folders) {
|
||||
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(mediaFolder.Id || '') != -1;
|
||||
checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
itemsArr.push({
|
||||
...mediaFolder,
|
||||
checkedAttribute: checkedAttribute
|
||||
});
|
||||
}
|
||||
for (const mediaFolder of mediaFolders) {
|
||||
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(mediaFolder.Id || '') != -1;
|
||||
checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
itemsArr.push({
|
||||
...mediaFolder,
|
||||
checkedAttribute: checkedAttribute
|
||||
});
|
||||
}
|
||||
|
||||
if (channels?.Items) {
|
||||
for (const channel of channels.Items) {
|
||||
for (const channel of channelsResult.Items) {
|
||||
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(channel.Id || '') != -1;
|
||||
checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
itemsArr.push({
|
||||
@@ -97,66 +93,16 @@ const UserEdit = () => {
|
||||
checkedAttribute: checkedAttribute
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setDeleteFoldersAccess(itemsArr);
|
||||
setDeleteFoldersAccess(itemsArr);
|
||||
|
||||
const chkEnableDeleteAllFolders = page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement;
|
||||
chkEnableDeleteAllFolders.checked = user.Policy?.EnableContentDeletion || false;
|
||||
triggerChange(chkEnableDeleteAllFolders);
|
||||
}, [channels]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[useredit] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
if (userDto && isAuthProvidersSuccess && authProviders != null) {
|
||||
loadAuthProviders(page, userDto, authProviders);
|
||||
}
|
||||
}, [authProviders, isAuthProvidersSuccess, userDto, loadAuthProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[useredit] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
if (userDto && isPasswordResetProvidersSuccess && passwordResetProviders != null) {
|
||||
loadPasswordResetProviders(page, userDto, passwordResetProviders);
|
||||
}
|
||||
}, [passwordResetProviders, isPasswordResetProvidersSuccess, userDto, loadPasswordResetProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[useredit] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
if (userDto && isMediaFoldersSuccess && isChannelsSuccess && mediaFolders?.Items != null) {
|
||||
loadDeleteFolders(page, userDto, mediaFolders.Items);
|
||||
}
|
||||
}, [userDto, mediaFolders, isMediaFoldersSuccess, isChannelsSuccess, channels, loadDeleteFolders]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[useredit] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
if (netConfig && isNetConfigSuccess) {
|
||||
(page.querySelector('.fldRemoteAccess') as HTMLDivElement).classList.toggle('hide', !netConfig.EnableRemoteAccess);
|
||||
}
|
||||
}, [netConfig, isNetConfigSuccess]);
|
||||
const chkEnableDeleteAllFolders = page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement;
|
||||
chkEnableDeleteAllFolders.checked = user.Policy?.EnableContentDeletion || false;
|
||||
triggerChange(chkEnableDeleteAllFolders);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch channels', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadUser = useCallback((user: UserDto) => {
|
||||
const page = element.current;
|
||||
@@ -166,6 +112,24 @@ const UserEdit = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/Providers')).then(function (providers) {
|
||||
loadAuthProviders(page, user, providers);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch auth providers', err);
|
||||
});
|
||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/PasswordResetProviders')).then(function (providers) {
|
||||
loadPasswordResetProviders(page, user, providers);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch password reset providers', err);
|
||||
});
|
||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', {
|
||||
IsHidden: false
|
||||
})).then(function (folders) {
|
||||
loadDeleteFolders(page, user, folders.Items);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch media folders', err);
|
||||
});
|
||||
|
||||
const disabledUserBanner = page.querySelector('.disabledUserBanner') as HTMLDivElement;
|
||||
disabledUserBanner.classList.toggle('hide', !user.Policy?.IsDisabled);
|
||||
|
||||
@@ -175,6 +139,7 @@ const UserEdit = () => {
|
||||
|
||||
void libraryMenu.then(menu => menu.setTitle(user.Name));
|
||||
|
||||
setUserDto(user);
|
||||
(page.querySelector('#txtUserName') as HTMLInputElement).value = user.Name || '';
|
||||
(page.querySelector('.chkIsAdmin') as HTMLInputElement).checked = !!user.Policy?.IsAdministrator;
|
||||
(page.querySelector('.chkDisabled') as HTMLInputElement).checked = !!user.Policy?.IsDisabled;
|
||||
@@ -198,22 +163,16 @@ const UserEdit = () => {
|
||||
(page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value = String(user.Policy?.MaxActiveSessions) || '0';
|
||||
(page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value = String(user.Policy?.SyncPlayAccess);
|
||||
loading.hide();
|
||||
}, [ libraryMenu ]);
|
||||
}, [loadAuthProviders, loadPasswordResetProviders, loadDeleteFolders ]);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
if (!userDto) {
|
||||
console.error('[profile] No user available');
|
||||
return;
|
||||
}
|
||||
loading.show();
|
||||
loadUser(userDto);
|
||||
}, [userDto, loadUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isUserSuccess) {
|
||||
loadData();
|
||||
}
|
||||
}, [loadData, isUserSuccess]);
|
||||
getUser().then(function (user) {
|
||||
loadUser(user);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to load data', err);
|
||||
});
|
||||
}, [loadUser]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
@@ -223,6 +182,8 @@ const UserEdit = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
loadData();
|
||||
|
||||
const saveUser = (user: UserDto) => {
|
||||
if (!user.Id || !user.Policy) {
|
||||
throw new Error('Unexpected null user id or policy');
|
||||
@@ -254,58 +215,53 @@ const UserEdit = () => {
|
||||
user.Policy.EnableContentDeletionFromFolders = user.Policy.EnableContentDeletion ? [] : getCheckedElementDataIds(page.querySelectorAll('.chkFolder'));
|
||||
user.Policy.SyncPlayAccess = (page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value as SyncPlayUserAccessType;
|
||||
|
||||
updateUser.mutate({ userId: user.Id, userDto: user }, {
|
||||
onSuccess: () => {
|
||||
if (user.Id) {
|
||||
updateUserPolicy.mutate({
|
||||
userId: user.Id,
|
||||
userPolicy: user.Policy || { PasswordResetProviderId: '', AuthenticationProviderId: '' }
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
loading.hide();
|
||||
navigate('/dashboard/users', {
|
||||
state: { openSavedToast: true }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
window.ApiClient.updateUser(user).then(() => (
|
||||
window.ApiClient.updateUserPolicy(user.Id || '', user.Policy || { PasswordResetProviderId: '', AuthenticationProviderId: '' })
|
||||
)).then(() => {
|
||||
navigate('/dashboard/users', {
|
||||
state: { openSavedToast: true }
|
||||
});
|
||||
loading.hide();
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to update user', err);
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = (e: Event) => {
|
||||
loading.show();
|
||||
if (userDto) {
|
||||
saveUser(userDto);
|
||||
}
|
||||
getUser().then(function (result) {
|
||||
saveUser(result);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch user', err);
|
||||
});
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
};
|
||||
|
||||
const onBtnCancelClick = () => {
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
(page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
||||
(page.querySelector('.deleteAccess') as HTMLDivElement).classList.toggle('hide', this.checked);
|
||||
});
|
||||
|
||||
window.ApiClient.getNamedConfiguration('network').then(function (config) {
|
||||
(page.querySelector('.fldRemoteAccess') as HTMLDivElement).classList.toggle('hide', !config.EnableRemoteAccess);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to load network config', err);
|
||||
});
|
||||
|
||||
(page.querySelector('.editUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', onBtnCancelClick);
|
||||
|
||||
return () => {
|
||||
(page.querySelector('.editUserProfileForm') as HTMLFormElement).removeEventListener('submit', onSubmit);
|
||||
(page.querySelector('#btnCancel') as HTMLButtonElement).removeEventListener('click', onBtnCancelClick);
|
||||
};
|
||||
}, [loadData, updateUser, userDto, updateUserPolicy, navigate]);
|
||||
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', function() {
|
||||
window.history.back();
|
||||
});
|
||||
}, [loadData]);
|
||||
|
||||
const optionLoginProvider = authProviders?.map((provider) => {
|
||||
const optionLoginProvider = authProviders.map((provider) => {
|
||||
const selected = provider.Id === authenticationProviderId || authProviders.length < 2 ? ' selected' : '';
|
||||
return `<option value="${provider.Id}"${selected}>${escapeHTML(provider.Name)}</option>`;
|
||||
});
|
||||
|
||||
const optionPasswordResetProvider = passwordResetProviders?.map((provider) => {
|
||||
const optionPasswordResetProvider = passwordResetProviders.map((provider) => {
|
||||
const selected = provider.Id === passwordResetProviderId || passwordResetProviders.length < 2 ? ' selected' : '';
|
||||
return `<option value="${provider.Id}"${selected}>${escapeHTML(provider.Name)}</option>`;
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import Stack from '@mui/material/Stack';
|
||||
import React, { type FC } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { appRouter, PUBLIC_PATHS } from 'components/router/appRouter';
|
||||
import AppToolbar from 'components/toolbar/AppToolbar';
|
||||
import ServerButton from 'components/toolbar/ServerButton';
|
||||
|
||||
@@ -16,14 +17,6 @@ interface AppToolbarProps {
|
||||
onDrawerButtonClick: (event: React.MouseEvent<HTMLElement>) => void
|
||||
}
|
||||
|
||||
const PUBLIC_PATHS = [
|
||||
'/addserver',
|
||||
'/selectserver',
|
||||
'/login',
|
||||
'/forgotpassword',
|
||||
'/forgotpasswordpin'
|
||||
];
|
||||
|
||||
const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
|
||||
isDrawerAvailable,
|
||||
isDrawerOpen,
|
||||
@@ -34,6 +27,10 @@ const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
|
||||
// The video osd does not show the standard toolbar
|
||||
if (location.pathname === '/video') return null;
|
||||
|
||||
// Only show the back button in apps when appropriate
|
||||
const isBackButtonAvailable = window.NativeShell && appRouter.canGoBack(location.pathname);
|
||||
|
||||
// Check if the current path is a public path to hide user content
|
||||
const isPublicPath = PUBLIC_PATHS.includes(location.pathname);
|
||||
|
||||
return (
|
||||
@@ -48,6 +45,7 @@ const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
|
||||
isDrawerAvailable={isDrawerAvailable}
|
||||
isDrawerOpen={isDrawerOpen}
|
||||
onDrawerButtonClick={onDrawerButtonClick}
|
||||
isBackButtonAvailable={isBackButtonAvailable}
|
||||
isUserMenuAvailable={!isPublicPath}
|
||||
>
|
||||
{!isDrawerAvailable && (
|
||||
|
||||
@@ -3,7 +3,6 @@ import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collec
|
||||
import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
|
||||
import Favorite from '@mui/icons-material/Favorite';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import Icon from '@mui/material/Icon';
|
||||
import { Theme } from '@mui/material/styles';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
@@ -16,7 +15,6 @@ import { appRouter } from 'components/router/appRouter';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import useCurrentTab from 'hooks/useCurrentTab';
|
||||
import { useUserViews } from 'hooks/useUserViews';
|
||||
import { useWebConfig } from 'hooks/useWebConfig';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
import UserViewsMenu from './UserViewsMenu';
|
||||
@@ -58,19 +56,14 @@ const UserViewNav = () => {
|
||||
const libraryId = searchParams.get('topParentId') || searchParams.get('parentId');
|
||||
const collectionType = searchParams.get('collectionType');
|
||||
const { activeTab } = useCurrentTab();
|
||||
const webConfig = useWebConfig();
|
||||
|
||||
const isExtraLargeScreen = useMediaQuery((t: Theme) => t.breakpoints.up('xl'));
|
||||
const isLargeScreen = useMediaQuery((t: Theme) => t.breakpoints.up('lg'));
|
||||
const maxViews = useMemo(() => {
|
||||
let _maxViews = MAX_USER_VIEWS_MD;
|
||||
if (isExtraLargeScreen) _maxViews = MAX_USER_VIEWS_XL;
|
||||
else if (isLargeScreen) _maxViews = MAX_USER_VIEWS_LG;
|
||||
|
||||
const customLinks = (webConfig.menuLinks || []).length;
|
||||
|
||||
return _maxViews - customLinks;
|
||||
}, [ isExtraLargeScreen, isLargeScreen, webConfig.menuLinks ]);
|
||||
if (isExtraLargeScreen) return MAX_USER_VIEWS_XL;
|
||||
if (isLargeScreen) return MAX_USER_VIEWS_LG;
|
||||
return MAX_USER_VIEWS_MD;
|
||||
}, [ isExtraLargeScreen, isLargeScreen ]);
|
||||
|
||||
const { user } = useApi();
|
||||
const {
|
||||
@@ -115,21 +108,6 @@ const UserViewNav = () => {
|
||||
{globalize.translate(MetaView.Favorites.Name)}
|
||||
</Button>
|
||||
|
||||
{webConfig.menuLinks?.map(link => (
|
||||
<Button
|
||||
key={link.name}
|
||||
variant='text'
|
||||
color='inherit'
|
||||
startIcon={<Icon>{link.icon || 'link'}</Icon>}
|
||||
component='a'
|
||||
href={link.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{link.name}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{primaryViews?.map(view => (
|
||||
<Button
|
||||
key={view.Id}
|
||||
|
||||
@@ -9,7 +9,6 @@ import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import classNames from 'classnames';
|
||||
import React, { type FC, useCallback } from 'react';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { useLocalStorage } from 'hooks/useLocalStorage';
|
||||
import { useGetItemsViewByType } from 'hooks/useFetchItems';
|
||||
@@ -100,7 +99,7 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||
|
||||
if (viewType === LibraryTab.Songs) {
|
||||
listOptions.showParentTitle = true;
|
||||
listOptions.action = ItemAction.PlayAllFromHere;
|
||||
listOptions.action = 'playallfromhere';
|
||||
listOptions.smallIcon = true;
|
||||
listOptions.showArtist = true;
|
||||
listOptions.addToListButton = true;
|
||||
@@ -222,9 +221,7 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||
const hasFilters = Object.values(libraryViewSettings.Filters ?? {}).some(
|
||||
(filter) => !!filter
|
||||
);
|
||||
const hasSortName = libraryViewSettings.SortBy.includes(
|
||||
ItemSortBy.SortName
|
||||
);
|
||||
const hasSortName = libraryViewSettings.SortBy !== ItemSortBy.Random;
|
||||
|
||||
const itemsContainerClass = classNames(
|
||||
'centered padded-left padded-right padded-right-withalphapicker',
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
import NoItemsMessage from 'components/common/NoItemsMessage';
|
||||
import SectionContainer from 'components/common/SectionContainer';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { useGetProgramsSectionsWithItems, useGetTimers } from 'hooks/useFetchItems';
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
import globalize from 'lib/globalize';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import NoItemsMessage from 'components/common/NoItemsMessage';
|
||||
import SectionContainer from 'components/common/SectionContainer';
|
||||
import { CardShape } from 'utils/card';
|
||||
import type { ParentId } from 'types/library';
|
||||
import type { Section, SectionType } from 'types/sections';
|
||||
import { CardShape } from 'utils/card';
|
||||
|
||||
interface ProgramsSectionViewProps {
|
||||
parentId: ParentId;
|
||||
@@ -94,7 +92,7 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
|
||||
showChannelName: false,
|
||||
cardLayout: true,
|
||||
centerText: false,
|
||||
action: ItemAction.Edit,
|
||||
action: 'edit',
|
||||
cardFooterAside: 'none',
|
||||
preferThumb: true,
|
||||
coverImage: true,
|
||||
|
||||
@@ -22,6 +22,14 @@ type SortOption = {
|
||||
|
||||
type SortOptionsMapping = Record<string, SortOption[]>;
|
||||
|
||||
const collectionMovieOptions: SortOption[] = [
|
||||
{ label: 'Name', value: ItemSortBy.SortName },
|
||||
{ label: 'OptionCommunityRating', value: ItemSortBy.CommunityRating },
|
||||
{ label: 'OptionDateAdded', value: ItemSortBy.DateCreated },
|
||||
{ label: 'OptionParentalRating', value: ItemSortBy.OfficialRating },
|
||||
{ label: 'OptionReleaseDate', value: ItemSortBy.PremiereDate }
|
||||
];
|
||||
|
||||
const movieOrFavoriteOptions = [
|
||||
{ label: 'Name', value: ItemSortBy.SortName },
|
||||
{ label: 'OptionRandom', value: ItemSortBy.Random },
|
||||
@@ -43,6 +51,7 @@ const photosOrPhotoAlbumsOptions = [
|
||||
|
||||
const sortOptionsMapping: SortOptionsMapping = {
|
||||
[LibraryTab.Movies]: movieOrFavoriteOptions,
|
||||
[LibraryTab.Collections]: collectionMovieOptions,
|
||||
[LibraryTab.Favorites]: movieOrFavoriteOptions,
|
||||
[LibraryTab.Series]: [
|
||||
{ label: 'Name', value: ItemSortBy.SortName },
|
||||
|
||||
@@ -4,7 +4,6 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import ReplayIcon from '@mui/icons-material/Replay';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { getChannelQuery } from 'hooks/api/liveTvHooks/useGetChannel';
|
||||
import globalize from 'lib/globalize';
|
||||
@@ -77,7 +76,7 @@ const PlayOrResumeButton: FC<PlayOrResumeButtonProps> = ({
|
||||
return (
|
||||
<IconButton
|
||||
className='button-flat btnPlayOrResume'
|
||||
data-action={isResumable ? ItemAction.Resume : ItemAction.Play}
|
||||
data-action={isResumable ? 'resume' : 'play'}
|
||||
title={
|
||||
isResumable ?
|
||||
globalize.translate('ButtonResume') :
|
||||
|
||||
@@ -12,7 +12,6 @@ import React, { Fragment } from 'react';
|
||||
|
||||
import { appHost } from 'components/apphost';
|
||||
import { AppFeature } from 'constants/appFeature';
|
||||
import { LayoutMode } from 'constants/layoutMode';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { useThemes } from 'hooks/useThemes';
|
||||
import globalize from 'lib/globalize';
|
||||
@@ -46,10 +45,11 @@ export function DisplayPreferences({ onChange, values }: Readonly<DisplayPrefere
|
||||
onChange={onChange}
|
||||
value={values.layout}
|
||||
>
|
||||
<MenuItem value={LayoutMode.Auto}>{globalize.translate('Auto')}</MenuItem>
|
||||
<MenuItem value={LayoutMode.Desktop}>{globalize.translate('Desktop')}</MenuItem>
|
||||
<MenuItem value={LayoutMode.Mobile}>{globalize.translate('Mobile')}</MenuItem>
|
||||
<MenuItem value={LayoutMode.Tv}>{globalize.translate('TV')}</MenuItem>
|
||||
<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>
|
||||
@@ -169,30 +169,6 @@ export function DisplayPreferences({ onChange, values }: Readonly<DisplayPrefere
|
||||
</Fragment>
|
||||
) }
|
||||
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
aria-describedby='display-settings-slideshow-interval-description'
|
||||
value={values.slideshowInterval}
|
||||
label={globalize.translate('LabelSlideshowInterval')}
|
||||
name='slideshowInterval'
|
||||
onChange={onChange}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
inputMode: 'numeric',
|
||||
max: '3600',
|
||||
min: '1',
|
||||
pattern: '[0-9]',
|
||||
required: true,
|
||||
step: '1',
|
||||
type: 'number'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormHelperText id='display-settings-slideshow-interval-description'>
|
||||
{globalize.translate('LabelSlideshowIntervalHelp')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
aria-describedby='display-settings-faster-animations-description'
|
||||
|
||||
@@ -103,7 +103,6 @@ async function loadDisplaySettings({
|
||||
maxDaysForNextUp: settings.maxDaysForNextUp(),
|
||||
screensaver: settings.screensaver() || 'none',
|
||||
screensaverInterval: settings.backdropScreensaverInterval(),
|
||||
slideshowInterval: settings.slideshowInterval(),
|
||||
theme: settings.theme() || defaultTheme?.id || FALLBACK_THEME_ID
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,5 @@ export interface DisplaySettingsValues {
|
||||
maxDaysForNextUp: number;
|
||||
screensaver: string;
|
||||
screensaverInterval: number;
|
||||
slideshowInterval: number;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
||||
import React, { FunctionComponent, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import React, { FunctionComponent, useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import Dashboard from '../../../../utils/dashboard';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import { appHost } from '../../../../components/apphost';
|
||||
import confirm from '../../../../components/confirm/confirm';
|
||||
import Button from '../../../../elements/emby-button/Button';
|
||||
import UserPasswordForm from '../../../../components/dashboard/users/UserPasswordForm';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import toast from '../../../../components/toast/toast';
|
||||
import { useUser } from 'apps/dashboard/features/users/api/useUser';
|
||||
import loading from 'components/loading/loading';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import UserPasswordForm from 'components/dashboard/users/UserPasswordForm';
|
||||
import Page from 'components/Page';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Button from 'elements/emby-button/Button';
|
||||
import Page from '../../../../components/Page';
|
||||
import { AppFeature } from 'constants/appFeature';
|
||||
|
||||
const UserProfile: FunctionComponent = () => {
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const { data: user, isPending: isUserPending } = useUser(userId ? { userId: userId } : undefined);
|
||||
const [ userName, setUserName ] = useState('');
|
||||
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
@@ -32,38 +30,50 @@ const UserProfile: FunctionComponent = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user?.Name || !user?.Id) {
|
||||
throw new Error('Unexpected null user name or id');
|
||||
if (!userId) {
|
||||
console.error('[userprofile] missing user id');
|
||||
return;
|
||||
}
|
||||
|
||||
void libraryMenu.then(menu => menu.setTitle(user.Name));
|
||||
|
||||
let imageUrl = 'assets/img/avatar.png';
|
||||
if (user.PrimaryImageTag) {
|
||||
imageUrl = window.ApiClient.getUserImageUrl(user.Id, {
|
||||
tag: user.PrimaryImageTag,
|
||||
type: 'Primary'
|
||||
});
|
||||
}
|
||||
const userImage = (page.querySelector('#image') as HTMLDivElement);
|
||||
userImage.style.backgroundImage = 'url(' + imageUrl + ')';
|
||||
|
||||
Dashboard.getCurrentUser().then(function (loggedInUser: UserDto) {
|
||||
if (!user.Policy) {
|
||||
throw new Error('Unexpected null user.Policy');
|
||||
loading.show();
|
||||
window.ApiClient.getUser(userId).then(function (user) {
|
||||
if (!user.Name || !user.Id) {
|
||||
throw new Error('Unexpected null user name or id');
|
||||
}
|
||||
|
||||
setUserName(user.Name);
|
||||
void libraryMenu.then(menu => menu.setTitle(user.Name));
|
||||
|
||||
let imageUrl = 'assets/img/avatar.png';
|
||||
if (user.PrimaryImageTag) {
|
||||
(page.querySelector('#btnAddImage') as HTMLButtonElement).classList.add('hide');
|
||||
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).classList.remove('hide');
|
||||
} else if (appHost.supports('fileinput') && (loggedInUser?.Policy?.IsAdministrator || user.Policy.EnableUserPreferenceAccess)) {
|
||||
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).classList.add('hide');
|
||||
(page.querySelector('#btnAddImage') as HTMLButtonElement).classList.remove('hide');
|
||||
imageUrl = window.ApiClient.getUserImageUrl(user.Id, {
|
||||
tag: user.PrimaryImageTag,
|
||||
type: 'Primary'
|
||||
});
|
||||
}
|
||||
const userImage = (page.querySelector('#image') as HTMLDivElement);
|
||||
userImage.style.backgroundImage = 'url(' + imageUrl + ')';
|
||||
|
||||
Dashboard.getCurrentUser().then(function (loggedInUser: UserDto) {
|
||||
if (!user.Policy) {
|
||||
throw new Error('Unexpected null user.Policy');
|
||||
}
|
||||
|
||||
if (user.PrimaryImageTag) {
|
||||
(page.querySelector('#btnAddImage') as HTMLButtonElement).classList.add('hide');
|
||||
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).classList.remove('hide');
|
||||
} else if (appHost.supports(AppFeature.FileInput) && (loggedInUser?.Policy?.IsAdministrator || user.Policy.EnableUserPreferenceAccess)) {
|
||||
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).classList.add('hide');
|
||||
(page.querySelector('#btnAddImage') as HTMLButtonElement).classList.remove('hide');
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[userprofile] failed to get current user', err);
|
||||
});
|
||||
loading.hide();
|
||||
}).catch(err => {
|
||||
console.error('[userprofile] failed to get current user', err);
|
||||
console.error('[userprofile] failed to load data', err);
|
||||
});
|
||||
}, [user, libraryMenu]);
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
@@ -115,9 +125,7 @@ const UserProfile: FunctionComponent = () => {
|
||||
userImage.style.backgroundImage = 'url(' + reader.result + ')';
|
||||
window.ApiClient.uploadUserImage(userId, ImageType.Primary, file).then(function () {
|
||||
loading.hide();
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['User']
|
||||
});
|
||||
reloadUser();
|
||||
}).catch(err => {
|
||||
console.error('[userprofile] failed to upload image', err);
|
||||
});
|
||||
@@ -126,7 +134,7 @@ const UserProfile: FunctionComponent = () => {
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const onDeleteImageClick = function () {
|
||||
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).addEventListener('click', function () {
|
||||
if (!userId) {
|
||||
console.error('[userprofile] missing user id');
|
||||
return;
|
||||
@@ -139,41 +147,25 @@ const UserProfile: FunctionComponent = () => {
|
||||
loading.show();
|
||||
window.ApiClient.deleteUserImage(userId, ImageType.Primary).then(function () {
|
||||
loading.hide();
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['User']
|
||||
});
|
||||
reloadUser();
|
||||
}).catch(err => {
|
||||
console.error('[userprofile] failed to delete image', err);
|
||||
});
|
||||
}).catch(() => {
|
||||
// confirm dialog closed
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
const addImageClick = function () {
|
||||
(page.querySelector('#btnAddImage') as HTMLButtonElement).addEventListener('click', function () {
|
||||
const uploadImage = page.querySelector('#uploadImage') as HTMLInputElement;
|
||||
uploadImage.value = '';
|
||||
uploadImage.click();
|
||||
};
|
||||
});
|
||||
|
||||
const onUploadImage = (e: Event) => {
|
||||
setFiles(e);
|
||||
};
|
||||
|
||||
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).addEventListener('click', onDeleteImageClick);
|
||||
(page.querySelector('#btnAddImage') as HTMLButtonElement).addEventListener('click', addImageClick);
|
||||
(page.querySelector('#uploadImage') as HTMLInputElement).addEventListener('change', onUploadImage);
|
||||
|
||||
return () => {
|
||||
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).removeEventListener('click', onDeleteImageClick);
|
||||
(page.querySelector('#btnAddImage') as HTMLButtonElement).removeEventListener('click', addImageClick);
|
||||
(page.querySelector('#uploadImage') as HTMLInputElement).removeEventListener('change', onUploadImage);
|
||||
};
|
||||
}, [reloadUser, user, userId]);
|
||||
|
||||
if (isUserPending || !user) {
|
||||
return <Loading />;
|
||||
}
|
||||
(page.querySelector('#uploadImage') as HTMLInputElement).addEventListener('change', function (evt: Event) {
|
||||
setFiles(evt);
|
||||
});
|
||||
}, [reloadUser, userId]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
@@ -203,7 +195,7 @@ const UserProfile: FunctionComponent = () => {
|
||||
</div>
|
||||
<div style={{ verticalAlign: 'top', margin: '1em 2em', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<h2 className='username' style={{ margin: 0, fontSize: 'xx-large' }}>
|
||||
{user?.Name}
|
||||
{userName}
|
||||
</h2>
|
||||
<br />
|
||||
<Button
|
||||
@@ -221,7 +213,7 @@ const UserProfile: FunctionComponent = () => {
|
||||
</div>
|
||||
</div>
|
||||
<UserPasswordForm
|
||||
user={user}
|
||||
userId={userId}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
1
src/assets/img/devices/firetv.svg
Normal file
1
src/assets/img/devices/firetv.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Amazon Fire TV</title><path d="M20.196 15.12c.265.337-.294 1.73-.542 2.353-.077.19.085.266.257.123 1.106-.926 1.39-2.867 1.166-3.149-.226-.277-2.16-.516-3.341.314-.183.127-.151.304.05.279.665-.08 2.147-.257 2.41.08m-.858.981c-2.064 1.523-5.056 2.333-7.632 2.333-3.611 0-6.862-1.334-9.322-3.555-.194-.176-.02-.414.21-.28 2.655 1.545 5.939 2.477 9.328 2.477 2.287 0 4.803-.476 7.115-1.458.348-.147.642.231.3.483m2.034-3.155a.388.388 0 0 1-.201-.04c-.041-.026-.087-.1-.133-.225l-1.734-4.355a1.79 1.79 0 0 0-.046-.117.266.266 0 0 1-.023-.108c0-.084.049-.128.146-.128h.58c.098 0 .165.014.205.04.04.026.082.102.127.226l1.344 3.823 1.343-3.823c.046-.124.089-.2.128-.226a.402.402 0 0 1 .205-.04h.54c.1 0 .148.044.148.128a.3.3 0 0 1-.025.108c-.016.04-.032.078-.044.117l-1.727 4.355c-.045.124-.09.199-.132.225a.388.388 0 0 1-.201.04zm-3.644.068c-.929 0-1.392-.463-1.392-1.392V8.739h-.706c-.13 0-.197-.066-.197-.196v-.246a.22.22 0 0 1 .045-.147c.03-.031.086-.055.171-.067l.717-.09.127-1.215c.013-.13.082-.196.207-.196h.41c.13 0 .196.066.196.196v1.196h1.276c.13 0 .195.065.195.197v.372c0 .13-.064.196-.195.196h-1.276v2.834c0 .243.055.411.162.51.108.098.293.147.555.147.124 0 .277-.016.46-.049.099-.02.164-.03.197-.03.052 0 .088.014.108.044.02.03.029.077.029.142v.266a.366.366 0 0 1-.04.19c-.026.043-.078.078-.157.103a3.018 3.018 0 0 1-.892.118m-4.665-2.976c.006-.052.011-.137.011-.255 0-.399-.094-.698-.28-.901-.186-.204-.46-.306-.818-.306-.412 0-.732.123-.962.369-.228.245-.36.61-.392 1.093zm-.942 3.07c-.803 0-1.411-.222-1.824-.667-.412-.444-.616-1.102-.616-1.972 0-.83.204-1.475.616-1.937.413-.46.988-.691 1.728-.691.62 0 1.098.176 1.432.524.332.351.5.846.5 1.487 0 .21-.017.422-.05.638-.014.077-.034.13-.064.156-.029.027-.077.04-.142.04h-3.08c.013.563.154.977.418 1.245.265.268.674.403 1.23.403.196 0 .385-.014.564-.04a5.04 5.04 0 0 0 .682-.166l.117-.035a.284.284 0 0 1 .09-.016c.085 0 .125.06.125.177v.276c0 .085-.012.144-.037.18a.441.441 0 0 1-.167.114 3.38 3.38 0 0 1-.701.205 4.236 4.236 0 0 1-.82.079m-5.424-.147c-.13 0-.195-.066-.195-.197v-4.58c0-.13.064-.195.195-.195h.432c.064 0 .116.012.153.039.036.025.06.076.072.146l.07.55c.176-.19.343-.34.499-.452a1.725 1.725 0 0 1 1.02-.323c.079 0 .158.003.235.01.112.014.168.072.168.176v.53c0 .117-.058.177-.178.177-.058 0-.114-.004-.17-.01a1.638 1.638 0 0 0-.18-.01c-.524 0-.973.157-1.346.47v3.472c0 .131-.066.197-.195.197zm-2.249 0c-.13 0-.196-.066-.196-.197v-4.58c0-.13.066-.195.196-.195h.579c.13 0 .195.064.195.195v4.58c0 .131-.065.197-.195.197zm.295-5.856c-.19 0-.339-.054-.447-.16a.581.581 0 0 1-.161-.428c0-.176.054-.318.16-.426.11-.109.257-.163.448-.163.189 0 .337.054.446.163.107.108.16.25.16.426a.581.581 0 0 1-.16.427.608.608 0 0 1-.446.161m-3.625 5.856c-.132 0-.197-.066-.197-.197v-4.01H.195c-.13 0-.195-.066-.195-.197v-.245c0-.065.014-.114.043-.147.03-.033.088-.055.173-.07l.705-.087v-.804c0-1.091.523-1.638 1.57-1.638.248 0 .51.036.784.109.072.019.122.047.152.088.029.038.044.107.044.205v.255c0 .124-.048.186-.148.186-.058 0-.14-.01-.248-.029-.11-.02-.23-.03-.369-.03-.3 0-.51.057-.633.172-.121.115-.181.303-.181.564v.903h1.324c.131 0 .197.064.197.195v.373c0 .13-.066.197-.197.197H1.892v4.01c0 .131-.065.197-.196.197Z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -6,7 +6,6 @@ import * as webSettings from '../scripts/settings/webSettings';
|
||||
import globalize from '../lib/globalize';
|
||||
import profileBuilder from '../scripts/browserDeviceProfile';
|
||||
import { AppFeature } from 'constants/appFeature';
|
||||
import { LayoutMode } from 'constants/layoutMode';
|
||||
|
||||
const appName = 'Jellyfin Web';
|
||||
|
||||
@@ -14,6 +13,7 @@ const BrowserName = {
|
||||
tizen: 'Samsung Smart TV',
|
||||
web0s: 'LG Smart TV',
|
||||
titanos: 'Titan OS',
|
||||
vega: 'Vega OS',
|
||||
operaTv: 'Opera TV',
|
||||
xboxOne: 'Xbox One',
|
||||
ps4: 'Sony PS4',
|
||||
@@ -182,7 +182,7 @@ function supportsFullscreen() {
|
||||
}
|
||||
|
||||
function getDefaultLayout() {
|
||||
return LayoutMode.Experimental;
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
function supportsHtmlMediaAutoplay() {
|
||||
@@ -372,7 +372,7 @@ export const appHost = {
|
||||
|
||||
return getDefaultLayout();
|
||||
},
|
||||
getDeviceProfile,
|
||||
getDeviceProfile: getDeviceProfile,
|
||||
init: function () {
|
||||
if (window.NativeShell) {
|
||||
return window.NativeShell.AppHost.init();
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
import layoutManager from 'components/layoutManager';
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import { CardShape } from 'utils/card';
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
import type { CardOptions } from 'types/cardOptions';
|
||||
|
||||
import CardOverlayButtons from './CardOverlayButtons';
|
||||
import CardHoverMenu from './CardHoverMenu';
|
||||
import CardOuterFooter from './CardOuterFooter';
|
||||
import CardContent from './CardContent';
|
||||
import { CardShape } from 'utils/card';
|
||||
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
import type { CardOptions } from 'types/cardOptions';
|
||||
|
||||
interface CardBoxProps {
|
||||
action: ItemAction;
|
||||
action: string;
|
||||
item: ItemDto;
|
||||
cardOptions: CardOptions;
|
||||
className: string;
|
||||
|
||||
@@ -2,14 +2,12 @@ import React, { type FC } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
import itemHelper from 'components/itemHelper';
|
||||
import { playbackManager } from 'components/playback/playbackmanager';
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
|
||||
import PlayedButton from 'elements/emby-playstatebutton/PlayedButton';
|
||||
import FavoriteButton from 'elements/emby-ratingbutton/FavoriteButton';
|
||||
|
||||
import PlayArrowIconButton from '../../common/PlayArrowIconButton';
|
||||
import MoreVertIconButton from '../../common/MoreVertIconButton';
|
||||
|
||||
@@ -17,7 +15,7 @@ import type { ItemDto } from 'types/base/models/item-dto';
|
||||
import type { CardOptions } from 'types/cardOptions';
|
||||
|
||||
interface CardHoverMenuProps {
|
||||
action: ItemAction,
|
||||
action: string,
|
||||
item: ItemDto;
|
||||
cardOptions: CardOptions;
|
||||
}
|
||||
@@ -31,7 +29,7 @@ const CardHoverMenu: FC<CardHoverMenuProps> = ({
|
||||
parentId: cardOptions.parentId
|
||||
});
|
||||
const btnCssClass =
|
||||
'paper-icon-button-light cardOverlayButton cardOverlayButton-hover itemAction';
|
||||
'paper-icon-button-light cardOverlayButton cardOverlayButton-hover itemAction';
|
||||
|
||||
const centerPlayButtonClass = classNames(
|
||||
btnCssClass,
|
||||
@@ -53,7 +51,7 @@ const CardHoverMenu: FC<CardHoverMenuProps> = ({
|
||||
{playbackManager.canPlay(item) && (
|
||||
<PlayArrowIconButton
|
||||
className={centerPlayButtonClass}
|
||||
action={ItemAction.Play}
|
||||
action='play'
|
||||
title='Play'
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,17 +2,15 @@ import { LocationType } from '@jellyfin/sdk/lib/generated-client/models/location
|
||||
import React, { type FC } from 'react';
|
||||
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import PlayArrowIconButton from '../../common/PlayArrowIconButton';
|
||||
import MoreVertIconButton from '../../common/MoreVertIconButton';
|
||||
|
||||
import { ItemKind } from 'types/base/models/item-kind';
|
||||
import { ItemMediaKind } from 'types/base/models/item-media-kind';
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
import type { CardOptions } from 'types/cardOptions';
|
||||
|
||||
import PlayArrowIconButton from '../../common/PlayArrowIconButton';
|
||||
import MoreVertIconButton from '../../common/MoreVertIconButton';
|
||||
|
||||
const sholudShowOverlayPlayButton = (
|
||||
overlayPlayButton: boolean | undefined,
|
||||
item: ItemDto
|
||||
@@ -80,7 +78,7 @@ const CardOverlayButtons: FC<CardOverlayButtonsProps> = ({
|
||||
{cardOptions.centerPlayButton && (
|
||||
<PlayArrowIconButton
|
||||
className={centerPlayButtonClass}
|
||||
action={ItemAction.Play}
|
||||
action='play'
|
||||
title='Play'
|
||||
/>
|
||||
)}
|
||||
@@ -89,7 +87,7 @@ const CardOverlayButtons: FC<CardOverlayButtonsProps> = ({
|
||||
{sholudShowOverlayPlayButton(overlayPlayButton, item) && (
|
||||
<PlayArrowIconButton
|
||||
className={btnCssClass}
|
||||
action={ItemAction.Play}
|
||||
action='play'
|
||||
title='Play'
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
import layoutManager from 'components/layoutManager';
|
||||
import itemHelper from 'components/itemHelper';
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import globalize from 'lib/globalize';
|
||||
import datetime from 'scripts/datetime';
|
||||
import { isUsingLiveTvNaming } from '../cardBuilderUtils';
|
||||
@@ -89,7 +88,7 @@ export function getTextActionButton(
|
||||
|
||||
const dataAttributes = getDataAttributes(
|
||||
{
|
||||
action: ItemAction.Link,
|
||||
action: 'link',
|
||||
itemServerId: serverId ?? item.ServerId,
|
||||
itemId: item.Id,
|
||||
itemChannelId: item.ChannelId,
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import classNames from 'classnames';
|
||||
|
||||
import layoutManager from 'components/layoutManager';
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import { ItemKind } from 'types/base/models/item-kind';
|
||||
import { ItemMediaKind } from 'types/base/models/item-media-kind';
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
import type { CardOptions } from 'types/cardOptions';
|
||||
import { CardShape } from 'utils/card';
|
||||
import { getDataAttributes } from 'utils/items';
|
||||
|
||||
import useCardImageUrl from './useCardImageUrl';
|
||||
import {
|
||||
resolveAction,
|
||||
resolveMixedShapeByAspectRatio
|
||||
} from '../cardBuilderUtils';
|
||||
import { getDataAttributes } from 'utils/items';
|
||||
import { CardShape } from 'utils/card';
|
||||
import layoutManager from 'components/layoutManager';
|
||||
|
||||
import { ItemKind } from 'types/base/models/item-kind';
|
||||
import { ItemMediaKind } from 'types/base/models/item-media-kind';
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
import type { CardOptions } from 'types/cardOptions';
|
||||
|
||||
interface UseCardProps {
|
||||
item: ItemDto;
|
||||
@@ -22,7 +20,7 @@ interface UseCardProps {
|
||||
|
||||
function useCard({ item, cardOptions }: UseCardProps) {
|
||||
const action = resolveAction({
|
||||
defaultAction: cardOptions.action ?? ItemAction.Link,
|
||||
defaultAction: cardOptions.action ?? 'link',
|
||||
isFolder: item.IsFolder ?? false,
|
||||
isPhoto: item.MediaType === ItemMediaKind.Photo
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-ite
|
||||
import { PersonKind } from '@jellyfin/sdk/lib/generated-client/models/person-kind';
|
||||
import escapeHtml from 'escape-html';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import browser from 'scripts/browser';
|
||||
import datetime from 'scripts/datetime';
|
||||
import dom from 'utils/dom';
|
||||
@@ -515,7 +514,7 @@ function getCardFooterText(item, apiClient, options, footerClass, progressHtml,
|
||||
const showOtherText = flags.isOuterFooter ? !flags.overlayText : flags.overlayText;
|
||||
|
||||
if (flags.isOuterFooter && options.cardLayout && layoutManager.mobile && options.cardFooterAside !== 'none') {
|
||||
html += `<button is="paper-icon-button-light" class="itemAction btnCardOptions cardText-secondary" data-action="${ItemAction.Menu}" title="${globalize.translate('ButtonMore')}"><span class="material-icons more_vert" aria-hidden="true"></span></button>`;
|
||||
html += `<button is="paper-icon-button-light" class="itemAction btnCardOptions cardText-secondary" data-action="menu" title="${globalize.translate('ButtonMore')}"><span class="material-icons more_vert" aria-hidden="true"></span></button>`;
|
||||
}
|
||||
|
||||
const cssClass = options.centerText ? 'cardText cardTextCentered' : 'cardText';
|
||||
@@ -578,9 +577,10 @@ function getCardFooterText(item, apiClient, options, footerClass, progressHtml,
|
||||
if (flags.isOuterFooter && item.AlbumArtists?.length) {
|
||||
const artistText = item.AlbumArtists
|
||||
.map(artist => {
|
||||
artist.ServerId = serverId;
|
||||
artist.Type = BaseItemKind.MusicArtist;
|
||||
artist.IsFolder = true;
|
||||
return getTextActionButton(artist, null, serverId);
|
||||
return getTextActionButton(artist);
|
||||
})
|
||||
.join(' / ');
|
||||
lines.push(artistText);
|
||||
@@ -777,7 +777,7 @@ function getTextActionButton(item, text, serverId) {
|
||||
}
|
||||
|
||||
const url = appRouter.getRouteUrl(item);
|
||||
let html = '<a href="' + url + '" ' + itemShortcuts.getShortcutAttributesHtml(item, serverId) + ' class="itemAction textActionButton" title="' + text + `" data-action="${ItemAction.Link}">`;
|
||||
let html = '<a href="' + url + '" ' + itemShortcuts.getShortcutAttributesHtml(item, serverId) + ' class="itemAction textActionButton" title="' + text + '" data-action="link">';
|
||||
html += text;
|
||||
html += '</a>';
|
||||
|
||||
@@ -886,7 +886,7 @@ function importRefreshIndicator() {
|
||||
*/
|
||||
function buildCard(index, item, apiClient, options) {
|
||||
const action = resolveAction({
|
||||
defaultAction: options.action || ItemAction.Link,
|
||||
defaultAction: options.action || 'link',
|
||||
isFolder: item.IsFolder,
|
||||
isPhoto: item.MediaType === 'Photo'
|
||||
});
|
||||
@@ -986,15 +986,15 @@ function buildCard(index, item, apiClient, options) {
|
||||
const btnCssClass = 'cardOverlayButton cardOverlayButton-br itemAction';
|
||||
|
||||
if (options.centerPlayButton) {
|
||||
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass} cardOverlayButton-centered" data-action="${ItemAction.Play}" title="${globalize.translate('Play')}"><span class="material-icons cardOverlayButtonIcon play_arrow" aria-hidden="true"></span></button>`;
|
||||
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass} cardOverlayButton-centered" data-action="play" title="${globalize.translate('Play')}"><span class="material-icons cardOverlayButtonIcon play_arrow" aria-hidden="true"></span></button>`;
|
||||
}
|
||||
|
||||
if (overlayPlayButton && !item.IsPlaceHolder && (item.LocationType !== 'Virtual' || !item.MediaType || item.Type === 'Program') && item.Type !== 'Person') {
|
||||
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="${ItemAction.Play}" title="${globalize.translate('Play')}"><span class="material-icons cardOverlayButtonIcon play_arrow" aria-hidden="true"></span></button>`;
|
||||
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="play" title="${globalize.translate('Play')}"><span class="material-icons cardOverlayButtonIcon play_arrow" aria-hidden="true"></span></button>`;
|
||||
}
|
||||
|
||||
if (options.overlayMoreButton) {
|
||||
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="${ItemAction.Menu}" title="${globalize.translate('ButtonMore')}"><span class="material-icons cardOverlayButtonIcon more_vert" aria-hidden="true"></span></button>`;
|
||||
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="menu" title="${globalize.translate('ButtonMore')}"><span class="material-icons cardOverlayButtonIcon more_vert" aria-hidden="true"></span></button>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1157,7 +1157,7 @@ function getHoverMenuHtml(item, action) {
|
||||
const btnCssClass = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light';
|
||||
|
||||
if (playbackManager.canPlay(item)) {
|
||||
html += `<button is="paper-icon-button-light" class="${btnCssClass} cardOverlayFab-primary" data-action="${ItemAction.Resume}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover play_arrow" aria-hidden="true"></span></button>`;
|
||||
html += '<button is="paper-icon-button-light" class="' + btnCssClass + ' cardOverlayFab-primary" data-action="resume"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover play_arrow" aria-hidden="true"></span></button>';
|
||||
}
|
||||
|
||||
html += '<div class="cardOverlayButton-br flex">';
|
||||
@@ -1166,17 +1166,17 @@ function getHoverMenuHtml(item, action) {
|
||||
|
||||
if (itemHelper.canMarkPlayed(item)) {
|
||||
import('../../elements/emby-playstatebutton/emby-playstatebutton');
|
||||
html += `<button is="emby-playstatebutton" type="button" data-action="${ItemAction.None}" class="${btnCssClass}" data-id="${item.Id}" data-serverid="${item.ServerId}" data-itemtype="${item.Type}" data-played="${userData.Played}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check" aria-hidden="true"></span></button>`;
|
||||
html += '<button is="emby-playstatebutton" type="button" data-action="none" class="' + btnCssClass + '" data-id="' + item.Id + '" data-serverid="' + item.ServerId + '" data-itemtype="' + item.Type + '" data-played="' + (userData.Played) + '"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check" aria-hidden="true"></span></button>';
|
||||
}
|
||||
|
||||
if (itemHelper.canRate(item)) {
|
||||
const likes = userData.Likes == null ? '' : userData.Likes;
|
||||
|
||||
import('../../elements/emby-ratingbutton/emby-ratingbutton');
|
||||
html += `<button is="emby-ratingbutton" type="button" data-action="${ItemAction.None}" class="${btnCssClass}" data-id="${item.Id}" data-serverid="${item.ServerId}" data-itemtype="${item.Type}" data-likes="${likes}" data-isfavorite="${userData.IsFavorite}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite" aria-hidden="true"></span></button>`;
|
||||
html += '<button is="emby-ratingbutton" type="button" data-action="none" class="' + btnCssClass + '" data-id="' + item.Id + '" data-serverid="' + item.ServerId + '" data-itemtype="' + item.Type + '" data-likes="' + likes + '" data-isfavorite="' + (userData.IsFavorite) + '"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite" aria-hidden="true"></span></button>';
|
||||
}
|
||||
|
||||
html += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="${ItemAction.Menu}" title="${globalize.translate('ButtonMore')}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover more_vert" aria-hidden="true"></span></button>`;
|
||||
html += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="menu" title="${globalize.translate('ButtonMore')}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover more_vert" aria-hidden="true"></span></button>`;
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
resolveCardImageContainerCssClasses,
|
||||
resolveMixedShapeByAspectRatio
|
||||
} from './cardBuilderUtils';
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
|
||||
describe('getDesiredAspect', () => {
|
||||
test('"portrait" (case insensitive)', () => {
|
||||
@@ -442,11 +441,11 @@ describe('isResizable', () => {
|
||||
});
|
||||
|
||||
describe('resolveAction', () => {
|
||||
test('default action', () => expect(resolveAction({ defaultAction: ItemAction.Link, isFolder: false, isPhoto: false })).toEqual(ItemAction.Link));
|
||||
test('default action', () => expect(resolveAction({ defaultAction: 'link', isFolder: false, isPhoto: false })).toEqual('link'));
|
||||
|
||||
test('photo', () => expect(resolveAction({ defaultAction: ItemAction.Link, isFolder: false, isPhoto: true })).toEqual(ItemAction.Play));
|
||||
test('photo', () => expect(resolveAction({ defaultAction: 'link', isFolder: false, isPhoto: true })).toEqual('play'));
|
||||
|
||||
test('default action is "play" and is folder', () => expect(resolveAction({ defaultAction: ItemAction.Play, isFolder: true, isPhoto: true })).toEqual(ItemAction.Link));
|
||||
test('default action is "play" and is folder', () => expect(resolveAction({ defaultAction: 'play', isFolder: true, isPhoto: true })).toEqual('link'));
|
||||
});
|
||||
|
||||
describe('resolveMixedShapeByAspectRatio', () => {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { CardShape } from '../../utils/card';
|
||||
import { randomInt } from '../../utils/number';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import { CardShape } from 'utils/card';
|
||||
import { randomInt } from 'utils/number';
|
||||
|
||||
const ASPECT_RATIOS = {
|
||||
portrait: (2 / 3),
|
||||
backdrop: (16 / 9),
|
||||
@@ -22,12 +20,12 @@ export const isUsingLiveTvNaming = (itemType: string | null | undefined): boolea
|
||||
* Resolves Card action to display
|
||||
* @param opts options to determine the action to return
|
||||
*/
|
||||
export const resolveAction = (opts: { defaultAction: ItemAction, isFolder: boolean, isPhoto: boolean }): ItemAction => {
|
||||
if (opts.defaultAction === ItemAction.Play && opts.isFolder) {
|
||||
export const resolveAction = (opts: { defaultAction: string, isFolder: boolean, isPhoto: boolean }): string => {
|
||||
if (opts.defaultAction === 'play' && opts.isFolder) {
|
||||
// If this hard-coding is ever removed make sure to test nested photo albums
|
||||
return ItemAction.Link;
|
||||
return 'link';
|
||||
} else if (opts.isPhoto) {
|
||||
return ItemAction.Play;
|
||||
return 'play';
|
||||
} else {
|
||||
return opts.defaultAction;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React, { type FC } from 'react';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
interface InfoIconButtonProps {
|
||||
@@ -13,7 +11,7 @@ const InfoIconButton: FC<InfoIconButtonProps> = ({ className }) => {
|
||||
return (
|
||||
<IconButton
|
||||
className={className}
|
||||
data-action={ItemAction.Link}
|
||||
data-action='link'
|
||||
title={globalize.translate('ButtonInfo')}
|
||||
>
|
||||
<InfoIcon />
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React, { type FC } from 'react';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
interface MoreVertIconButtonProps {
|
||||
@@ -14,7 +12,7 @@ const MoreVertIconButton: FC<MoreVertIconButtonProps> = ({ className, iconClassN
|
||||
return (
|
||||
<IconButton
|
||||
className={className}
|
||||
data-action={ItemAction.Menu}
|
||||
data-action='menu'
|
||||
title={globalize.translate('ButtonMore')}
|
||||
>
|
||||
<MoreVertIcon className={iconClassName} />
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import React, { type FC } from 'react';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
interface PlayArrowIconButtonProps {
|
||||
className: string;
|
||||
action: ItemAction;
|
||||
action: string;
|
||||
title: string;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React, { type FC } from 'react';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
interface PlaylistAddIconButtonProps {
|
||||
@@ -13,7 +11,7 @@ const PlaylistAddIconButton: FC<PlaylistAddIconButtonProps> = ({ className }) =>
|
||||
return (
|
||||
<IconButton
|
||||
className={className}
|
||||
data-action={ItemAction.AddToPlaylist}
|
||||
data-action='addtoplaylist'
|
||||
title={globalize.translate('AddToPlaylist')}
|
||||
>
|
||||
<PlaylistAddIcon />
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React, { type FC } from 'react';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
|
||||
interface RightIconButtonsProps {
|
||||
className?: string;
|
||||
id: string;
|
||||
@@ -14,7 +12,7 @@ const RightIconButtons: FC<RightIconButtonsProps> = ({ className, id, title, ico
|
||||
return (
|
||||
<IconButton
|
||||
className={className}
|
||||
data-action={ItemAction.Custom}
|
||||
data-action='custom'
|
||||
data-customaction={id}
|
||||
title={title}
|
||||
>
|
||||
|
||||
@@ -9,11 +9,12 @@ import Button from '../../../elements/emby-button/Button';
|
||||
import Input from '../../../elements/emby-input/Input';
|
||||
|
||||
type IProps = {
|
||||
user: UserDto
|
||||
userId: string | null;
|
||||
};
|
||||
|
||||
const UserPasswordForm: FunctionComponent<IProps> = ({ user }: IProps) => {
|
||||
const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
const user = useRef<UserDto>();
|
||||
const libraryMenu = useMemo(async () => ((await import('../../../scripts/libraryMenu')).default), []);
|
||||
|
||||
const loadUser = useCallback(async () => {
|
||||
@@ -24,16 +25,22 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ user }: IProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
console.error('[UserPasswordForm] missing user id');
|
||||
return;
|
||||
}
|
||||
|
||||
user.current = await window.ApiClient.getUser(userId);
|
||||
const loggedInUser = await Dashboard.getCurrentUser();
|
||||
|
||||
if (!user.Policy || !user.Configuration) {
|
||||
if (!user.current.Policy || !user.current.Configuration) {
|
||||
throw new Error('Unexpected null user policy or configuration');
|
||||
}
|
||||
|
||||
(await libraryMenu).setTitle(user.Name);
|
||||
(await libraryMenu).setTitle(user.current.Name);
|
||||
|
||||
if (user.HasConfiguredPassword) {
|
||||
if (!user.Policy?.IsAdministrator) {
|
||||
if (user.current.HasConfiguredPassword) {
|
||||
if (!user.current.Policy?.IsAdministrator) {
|
||||
(page.querySelector('#btnResetPassword') as HTMLDivElement).classList.remove('hide');
|
||||
}
|
||||
(page.querySelector('#fldCurrentPassword') as HTMLDivElement).classList.remove('hide');
|
||||
@@ -42,7 +49,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ user }: IProps) => {
|
||||
(page.querySelector('#fldCurrentPassword') as HTMLDivElement).classList.add('hide');
|
||||
}
|
||||
|
||||
const canChangePassword = loggedInUser?.Policy?.IsAdministrator || user.Policy.EnableUserPreferenceAccess;
|
||||
const canChangePassword = loggedInUser?.Policy?.IsAdministrator || user.current.Policy.EnableUserPreferenceAccess;
|
||||
(page.querySelector('.passwordSection') as HTMLDivElement).classList.toggle('hide', !canChangePassword);
|
||||
|
||||
import('../../autoFocuser').then(({ default: autoFocuser }) => {
|
||||
@@ -54,7 +61,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ user }: IProps) => {
|
||||
(page.querySelector('#txtCurrentPassword') as HTMLInputElement).value = '';
|
||||
(page.querySelector('#txtNewPassword') as HTMLInputElement).value = '';
|
||||
(page.querySelector('#txtNewPasswordConfirm') as HTMLInputElement).value = '';
|
||||
}, [user, libraryMenu]);
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
@@ -71,7 +78,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ user }: IProps) => {
|
||||
const onSubmit = (e: Event) => {
|
||||
if ((page.querySelector('#txtNewPassword') as HTMLInputElement).value != (page.querySelector('#txtNewPasswordConfirm') as HTMLInputElement).value) {
|
||||
toast(globalize.translate('PasswordMatchError'));
|
||||
} else if ((page.querySelector('#txtNewPassword') as HTMLInputElement).value == '' && user?.Policy?.IsAdministrator) {
|
||||
} else if ((page.querySelector('#txtNewPassword') as HTMLInputElement).value == '' && user.current?.Policy?.IsAdministrator) {
|
||||
toast(globalize.translate('PasswordMissingSaveError'));
|
||||
} else {
|
||||
loading.show();
|
||||
@@ -83,7 +90,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ user }: IProps) => {
|
||||
};
|
||||
|
||||
const savePassword = () => {
|
||||
if (!user.Id) {
|
||||
if (!userId) {
|
||||
console.error('[UserPasswordForm.savePassword] missing user id');
|
||||
return;
|
||||
}
|
||||
@@ -97,7 +104,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ user }: IProps) => {
|
||||
currentPassword = '';
|
||||
}
|
||||
|
||||
window.ApiClient.updateUserPassword(user.Id, currentPassword, newPassword).then(function () {
|
||||
window.ApiClient.updateUserPassword(userId, currentPassword, newPassword).then(function () {
|
||||
loading.hide();
|
||||
toast(globalize.translate('PasswordSaved'));
|
||||
|
||||
@@ -114,23 +121,26 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ user }: IProps) => {
|
||||
};
|
||||
|
||||
const resetPassword = () => {
|
||||
if (!userId) {
|
||||
console.error('[UserPasswordForm.resetPassword] missing user id');
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = globalize.translate('PasswordResetConfirmation');
|
||||
confirm(msg, globalize.translate('ResetPassword')).then(function () {
|
||||
loading.show();
|
||||
if (user.Id) {
|
||||
window.ApiClient.resetUserPassword(user.Id).then(function () {
|
||||
loading.hide();
|
||||
Dashboard.alert({
|
||||
message: globalize.translate('PasswordResetComplete'),
|
||||
title: globalize.translate('ResetPassword')
|
||||
});
|
||||
loadUser().catch(err => {
|
||||
console.error('[UserPasswordForm] failed to load user', err);
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('[UserPasswordForm] failed to reset user password', err);
|
||||
window.ApiClient.resetUserPassword(userId).then(function () {
|
||||
loading.hide();
|
||||
Dashboard.alert({
|
||||
message: globalize.translate('PasswordResetComplete'),
|
||||
title: globalize.translate('ResetPassword')
|
||||
});
|
||||
}
|
||||
loadUser().catch(err => {
|
||||
console.error('[UserPasswordForm] failed to load user', err);
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('[UserPasswordForm] failed to reset user password', err);
|
||||
});
|
||||
}).catch(() => {
|
||||
// confirm dialog was closed
|
||||
});
|
||||
@@ -138,12 +148,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ user }: IProps) => {
|
||||
|
||||
(page.querySelector('.updatePasswordForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
(page.querySelector('#btnResetPassword') as HTMLButtonElement).addEventListener('click', resetPassword);
|
||||
|
||||
return () => {
|
||||
(page.querySelector('.updatePasswordForm') as HTMLFormElement).removeEventListener('submit', onSubmit);
|
||||
(page.querySelector('#btnResetPassword') as HTMLButtonElement).removeEventListener('click', resetPassword);
|
||||
};
|
||||
}, [loadUser, user]);
|
||||
}, [loadUser, userId]);
|
||||
|
||||
return (
|
||||
<div ref={element}>
|
||||
|
||||
@@ -11,6 +11,15 @@ import '../formdialog.scss';
|
||||
import '../../elements/emby-button/emby-button';
|
||||
import alert from '../alert';
|
||||
|
||||
function getSystemInfo() {
|
||||
return systemInfo ? Promise.resolve(systemInfo) : ApiClient.getPublicSystemInfo().then(
|
||||
info => {
|
||||
systemInfo = info;
|
||||
return info;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function onDialogClosed() {
|
||||
loading.hide();
|
||||
}
|
||||
@@ -74,14 +83,25 @@ function getItem(cssClass, type, path, name) {
|
||||
return html;
|
||||
}
|
||||
|
||||
function getEditorHtml(options) {
|
||||
function getEditorHtml(options, systemInfo) {
|
||||
let html = '';
|
||||
html += '<div class="formDialogContent scrollY">';
|
||||
html += '<div class="dialogContentInner dialog-content-centered" style="padding-top:2em;">';
|
||||
if (!options.pathReadOnly && options.instruction) {
|
||||
const instruction = `${escapeHtml(options.instruction)}<br/><br/>`;
|
||||
if (!options.pathReadOnly) {
|
||||
const instruction = options.instruction ? `${escapeHtml(options.instruction)}<br/><br/>` : '';
|
||||
html += '<div class="infoBanner" style="margin-bottom:1.5em;">';
|
||||
html += instruction;
|
||||
if (systemInfo.OperatingSystem.toLowerCase() === 'bsd') {
|
||||
html += '<br/>';
|
||||
html += '<br/>';
|
||||
html += globalize.translate('MessageDirectoryPickerBSDInstruction');
|
||||
html += '<br/>';
|
||||
} else if (systemInfo.OperatingSystem.toLowerCase() === 'linux') {
|
||||
html += '<br/>';
|
||||
html += '<br/>';
|
||||
html += globalize.translate('MessageDirectoryPickerLinuxInstruction');
|
||||
html += '<br/>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
html += '<form style="margin:auto;">';
|
||||
@@ -103,6 +123,14 @@ function getEditorHtml(options) {
|
||||
if (!readOnlyAttribute) {
|
||||
html += '<div class="results paperList" style="max-height: 200px; overflow-y: auto;"></div>';
|
||||
}
|
||||
if (options.enableNetworkSharePath) {
|
||||
html += '<div class="inputContainer" style="margin-top:2em;">';
|
||||
html += `<input is="emby-input" id="txtNetworkPath" type="text" label="${globalize.translate('LabelOptionalNetworkPath')}"/>`;
|
||||
html += '<div class="fieldDescription">';
|
||||
html += globalize.translate('LabelOptionalNetworkPathHelp', '<b>\\\\server</b>', '<b>\\\\192.168.1.101</b>');
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
html += '<div class="formDialogFooter">';
|
||||
html += `<button is="emby-button" type="submit" class="raised button-submit block formDialogFooterItem">${globalize.translate('ButtonOk')}</button>`;
|
||||
html += '</div>';
|
||||
@@ -181,10 +209,12 @@ function initEditor(content, options, fileOptions) {
|
||||
|
||||
content.querySelector('form').addEventListener('submit', function(e) {
|
||||
if (options.callback) {
|
||||
let networkSharePath = this.querySelector('#txtNetworkPath');
|
||||
networkSharePath = networkSharePath ? networkSharePath.value : null;
|
||||
const path = this.querySelector('#txtDirectoryPickerPath').value;
|
||||
validatePath(path, options.validateWriteable, ApiClient)
|
||||
.then(options.callback(path))
|
||||
.catch(() => { /* no-op */ });
|
||||
validatePath(path, options.validateWriteable, ApiClient).then(
|
||||
options.callback(path, networkSharePath)
|
||||
).catch(() => { /* no-op */ });
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -206,6 +236,7 @@ function getDefaultPath(options) {
|
||||
}
|
||||
}
|
||||
|
||||
let systemInfo;
|
||||
class DirectoryBrowser {
|
||||
currentDialog;
|
||||
|
||||
@@ -220,8 +251,10 @@ class DirectoryBrowser {
|
||||
if (options.includeFiles != null) {
|
||||
fileOptions.includeFiles = options.includeFiles;
|
||||
}
|
||||
getDefaultPath(options).then(
|
||||
fetchedInitialPath => {
|
||||
Promise.all([getSystemInfo(), getDefaultPath(options)]).then(
|
||||
responses => {
|
||||
const fetchedSystemInfo = responses[0];
|
||||
const fetchedInitialPath = responses[1];
|
||||
const dlg = dialogHelper.createDialog({
|
||||
size: 'small',
|
||||
removeOnClose: true,
|
||||
@@ -239,7 +272,7 @@ class DirectoryBrowser {
|
||||
html += escapeHtml(options.header || '') || globalize.translate('HeaderSelectPath');
|
||||
html += '</h3>';
|
||||
html += '</div>';
|
||||
html += getEditorHtml(options);
|
||||
html += getEditorHtml(options, fetchedSystemInfo);
|
||||
dlg.innerHTML = html;
|
||||
initEditor(dlg, options, fileOptions);
|
||||
dlg.addEventListener('close', onDialogClosed);
|
||||
@@ -249,6 +282,10 @@ class DirectoryBrowser {
|
||||
});
|
||||
this.currentDialog = dlg;
|
||||
dlg.querySelector('#txtDirectoryPickerPath').value = fetchedInitialPath;
|
||||
const txtNetworkPath = dlg.querySelector('#txtNetworkPath');
|
||||
if (txtNetworkPath) {
|
||||
txtNetworkPath.value = options.networkSharePath || '';
|
||||
}
|
||||
if (!options.pathReadOnly) {
|
||||
refreshDirectoryBrowser(dlg, fetchedInitialPath, fileOptions, true);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,6 @@ function loadForm(context, user, userSettings) {
|
||||
}
|
||||
|
||||
context.querySelector('.selectDashboardThemeContainer').classList.toggle('hide', !user.Policy.IsAdministrator);
|
||||
context.querySelector('.txtSlideshowIntervalContainer').classList.remove('hide');
|
||||
|
||||
if (appHost.supports(AppFeature.Screensaver)) {
|
||||
context.querySelector('.selectScreensaverContainer').classList.remove('hide');
|
||||
@@ -113,7 +112,6 @@ function loadForm(context, user, userSettings) {
|
||||
loadScreensavers(context, userSettings);
|
||||
|
||||
context.querySelector('#txtBackdropScreensaverInterval').value = userSettings.backdropScreensaverInterval();
|
||||
context.querySelector('#txtSlideshowInterval').value = userSettings.slideshowInterval();
|
||||
context.querySelector('#txtScreensaverTime').value = userSettings.screensaverTime();
|
||||
|
||||
context.querySelector('.chkDisplayMissingEpisodes').checked = user.Configuration.DisplayMissingEpisodes || false;
|
||||
@@ -159,7 +157,6 @@ function saveUser(context, user, userSettingsInstance, apiClient) {
|
||||
userSettingsInstance.dashboardTheme(context.querySelector('#selectDashboardTheme').value);
|
||||
userSettingsInstance.screensaver(context.querySelector('.selectScreensaver').value);
|
||||
userSettingsInstance.backdropScreensaverInterval(context.querySelector('#txtBackdropScreensaverInterval').value);
|
||||
userSettingsInstance.slideshowInterval(context.querySelector('#txtSlideshowInterval').value);
|
||||
userSettingsInstance.screensaverTime(context.querySelector('#txtScreensaverTime').value);
|
||||
|
||||
userSettingsInstance.libraryPageSize(context.querySelector('#txtLibraryPageSize').value);
|
||||
|
||||
@@ -172,6 +172,7 @@
|
||||
<option value="desktop">${Desktop}</option>
|
||||
<option value="mobile">${Mobile}</option>
|
||||
<option value="tv">${TV}</option>
|
||||
<option value="experimental">${Experimental}</option>
|
||||
</select>
|
||||
<div class="fieldDescription">${DisplayModeHelp}</div>
|
||||
<div class="fieldDescription">${LabelPleaseRestart}</div>
|
||||
@@ -213,11 +214,6 @@
|
||||
<div class="fieldDescription">${LabelBackdropScreensaverIntervalHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer hide txtSlideshowIntervalContainer inputContainer-withDescription">
|
||||
<input is="emby-input" type="number" id="txtSlideshowInterval" pattern="[0-9]*" required="required" min="1" max="3600" step="1" label="${LabelSlideshowInterval}" />
|
||||
<div class="fieldDescription">${LabelSlideshowIntervalHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkFadein" />
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import escapeHtml from 'escape-html';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
|
||||
import inputManager from '../../scripts/inputManager';
|
||||
import browser from '../../scripts/browser';
|
||||
import globalize from '../../lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import Events from '../../utils/events.ts';
|
||||
import scrollHelper from '../../scripts/scrollHelper';
|
||||
import serverNotifications from '../../scripts/serverNotifications';
|
||||
@@ -18,7 +15,6 @@ import imageLoader from '../images/imageLoader';
|
||||
import layoutManager from '../layoutManager';
|
||||
import itemShortcuts from '../shortcuts';
|
||||
import dom from '../../utils/dom';
|
||||
|
||||
import './guide.scss';
|
||||
import './programs.scss';
|
||||
import 'material-design-icons-iconfont';
|
||||
@@ -30,7 +26,6 @@ import '../../elements/emby-tabs/emby-tabs';
|
||||
import '../../elements/emby-scroller/emby-scroller';
|
||||
import '../../styles/flexstyles.scss';
|
||||
import 'webcomponents.js/webcomponents-lite';
|
||||
|
||||
import template from './tvguide.template.html';
|
||||
|
||||
function showViewSettings(instance) {
|
||||
@@ -446,7 +441,7 @@ function Guide(options) {
|
||||
|
||||
html += '<div class="' + outerCssClass + '" data-channelid="' + channel.Id + '">';
|
||||
|
||||
const clickAction = layoutManager.tv ? ItemAction.Link : ItemAction.ProgramDialog;
|
||||
const clickAction = layoutManager.tv ? 'link' : 'programdialog';
|
||||
|
||||
const categories = self.categoryOptions.categories || [];
|
||||
const displayMovieContent = !categories.length || categories.indexOf('movies') !== -1;
|
||||
@@ -612,7 +607,7 @@ function Guide(options) {
|
||||
title.push(channel.Name);
|
||||
}
|
||||
|
||||
html += `<button title="${escapeHtml(title.join(' '))}" type="button" class="${cssClass}" data-action="${ItemAction.Link}" data-isfolder="${channel.IsFolder}" data-id="${channel.Id}" data-serverid="${channel.ServerId}" data-type="${channel.Type}">`;
|
||||
html += '<button title="' + escapeHtml(title.join(' ')) + '" type="button" class="' + cssClass + '"' + ' data-action="link" data-isfolder="' + channel.IsFolder + '" data-id="' + channel.Id + '" data-serverid="' + channel.ServerId + '" data-type="' + channel.Type + '">';
|
||||
|
||||
if (hasChannelImage) {
|
||||
const url = apiClient.getScaledImageUrl(channel.Id, {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
|
||||
|
||||
import { AppFeature } from 'constants/appFeature';
|
||||
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
||||
|
||||
import browser from '../scripts/browser';
|
||||
import { copy } from '../scripts/clipboard';
|
||||
@@ -16,6 +12,7 @@ import itemHelper, { canEditPlaylist } from './itemHelper';
|
||||
import { playbackManager } from './playback/playbackmanager';
|
||||
import toast from './toast/toast';
|
||||
import * as userSettings from '../scripts/settings/userSettings';
|
||||
import { AppFeature } from 'constants/appFeature';
|
||||
|
||||
/** Item types that support downloading all children. */
|
||||
const DOWNLOAD_ALL_TYPES = [
|
||||
@@ -390,7 +387,6 @@ function executeCommand(item, id, options) {
|
||||
const itemId = item.Id;
|
||||
const serverId = item.ServerId;
|
||||
const apiClient = ServerConnections.getApiClient(serverId);
|
||||
const api = toApi(apiClient);
|
||||
|
||||
return new Promise(function (resolve, reject) {
|
||||
// eslint-disable-next-line sonarjs/max-switch-cases
|
||||
@@ -415,9 +411,9 @@ function executeCommand(item, id, options) {
|
||||
break;
|
||||
case 'download':
|
||||
import('../scripts/fileDownloader').then((fileDownloader) => {
|
||||
const url = getLibraryApi(api).getDownloadUrl({ itemId });
|
||||
const downloadHref = apiClient.getItemDownloadUrl(itemId);
|
||||
fileDownloader.download([{
|
||||
url,
|
||||
url: downloadHref,
|
||||
item,
|
||||
itemId,
|
||||
serverId,
|
||||
@@ -433,9 +429,9 @@ function executeCommand(item, id, options) {
|
||||
const downloads = items
|
||||
.filter(i => i.CanDownload)
|
||||
.map(i => {
|
||||
const url = getLibraryApi(api).getDownloadUrl({ itemId: i.Id });
|
||||
const downloadHref = apiClient.getItemDownloadUrl(i.Id);
|
||||
return {
|
||||
url,
|
||||
url: downloadHref,
|
||||
item: i,
|
||||
itemId: i.Id,
|
||||
serverId,
|
||||
@@ -482,7 +478,7 @@ function executeCommand(item, id, options) {
|
||||
break;
|
||||
}
|
||||
case 'copy-stream': {
|
||||
const downloadHref = getLibraryApi(api).getDownloadUrl({ itemId });
|
||||
const downloadHref = apiClient.getItemDownloadUrl(itemId);
|
||||
copy(downloadHref).then(() => {
|
||||
toast(globalize.translate('CopyStreamURLSuccess'));
|
||||
}).catch(() => {
|
||||
|
||||
@@ -133,8 +133,8 @@ function getMediaSourceHtml(user, item, version) {
|
||||
}
|
||||
attributes.push(createAttribute(globalize.translate('MediaInfoInterlaced'), (stream.IsInterlaced ? 'Yes' : 'No')));
|
||||
}
|
||||
if (stream.ReferenceFrameRate && stream.Type === 'Video') {
|
||||
attributes.push(createAttribute(globalize.translate('MediaInfoFramerate'), stream.ReferenceFrameRate));
|
||||
if ((stream.AverageFrameRate || stream.RealFrameRate) && stream.Type === 'Video') {
|
||||
attributes.push(createAttribute(globalize.translate('MediaInfoFramerate'), (stream.AverageFrameRate || stream.RealFrameRate)));
|
||||
}
|
||||
if (stream.ChannelLayout) {
|
||||
attributes.push(createAttribute(globalize.translate('MediaInfoLayout'), stream.ChannelLayout));
|
||||
|
||||
228
src/components/itemsrefresher.js
Normal file
228
src/components/itemsrefresher.js
Normal file
@@ -0,0 +1,228 @@
|
||||
import { playbackManager } from './playback/playbackmanager';
|
||||
import serverNotifications from '../scripts/serverNotifications';
|
||||
import Events from '../utils/events.ts';
|
||||
|
||||
function onUserDataChanged() {
|
||||
const instance = this;
|
||||
const eventsToMonitor = getEventsToMonitor(instance);
|
||||
|
||||
// TODO: Check user data change reason?
|
||||
if (eventsToMonitor.indexOf('markfavorite') !== -1
|
||||
|| eventsToMonitor.indexOf('markplayed') !== -1
|
||||
) {
|
||||
instance.notifyRefreshNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
function getEventsToMonitor(instance) {
|
||||
const options = instance.options;
|
||||
const monitor = options ? options.monitorEvents : null;
|
||||
if (monitor) {
|
||||
return monitor.split(',');
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function notifyTimerRefresh() {
|
||||
const instance = this;
|
||||
|
||||
if (getEventsToMonitor(instance).indexOf('timers') !== -1) {
|
||||
instance.notifyRefreshNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
function notifySeriesTimerRefresh() {
|
||||
const instance = this;
|
||||
if (getEventsToMonitor(instance).indexOf('seriestimers') !== -1) {
|
||||
instance.notifyRefreshNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
function onLibraryChanged(e, apiClient, data) {
|
||||
const instance = this;
|
||||
const eventsToMonitor = getEventsToMonitor(instance);
|
||||
if (eventsToMonitor.indexOf('seriestimers') !== -1 || eventsToMonitor.indexOf('timers') !== -1) {
|
||||
// yes this is an assumption
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsAdded = data.ItemsAdded || [];
|
||||
const itemsRemoved = data.ItemsRemoved || [];
|
||||
if (!itemsAdded.length && !itemsRemoved.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = instance.options || {};
|
||||
const parentId = options.parentId;
|
||||
if (parentId) {
|
||||
const foldersAddedTo = data.FoldersAddedTo || [];
|
||||
const foldersRemovedFrom = data.FoldersRemovedFrom || [];
|
||||
const collectionFolders = data.CollectionFolders || [];
|
||||
|
||||
if (foldersAddedTo.indexOf(parentId) === -1 && foldersRemovedFrom.indexOf(parentId) === -1 && collectionFolders.indexOf(parentId) === -1) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
instance.notifyRefreshNeeded();
|
||||
}
|
||||
|
||||
function onPlaybackStopped(e, stopInfo) {
|
||||
const instance = this;
|
||||
|
||||
const state = stopInfo.state;
|
||||
|
||||
const eventsToMonitor = getEventsToMonitor(instance);
|
||||
if (state.NowPlayingItem?.MediaType === 'Video') {
|
||||
if (eventsToMonitor.indexOf('videoplayback') !== -1) {
|
||||
instance.notifyRefreshNeeded(true);
|
||||
return;
|
||||
}
|
||||
} else if (state.NowPlayingItem?.MediaType === 'Audio' && eventsToMonitor.indexOf('audioplayback') !== -1) {
|
||||
instance.notifyRefreshNeeded(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function addNotificationEvent(instance, name, handler, owner) {
|
||||
const localHandler = handler.bind(instance);
|
||||
owner = owner || serverNotifications;
|
||||
Events.on(owner, name, localHandler);
|
||||
instance['event_' + name] = localHandler;
|
||||
}
|
||||
|
||||
function removeNotificationEvent(instance, name, owner) {
|
||||
const handler = instance['event_' + name];
|
||||
if (handler) {
|
||||
owner = owner || serverNotifications;
|
||||
Events.off(owner, name, handler);
|
||||
instance['event_' + name] = null;
|
||||
}
|
||||
}
|
||||
|
||||
class ItemsRefresher {
|
||||
constructor(options) {
|
||||
this.options = options || {};
|
||||
|
||||
addNotificationEvent(this, 'UserDataChanged', onUserDataChanged);
|
||||
addNotificationEvent(this, 'TimerCreated', notifyTimerRefresh);
|
||||
addNotificationEvent(this, 'SeriesTimerCreated', notifySeriesTimerRefresh);
|
||||
addNotificationEvent(this, 'TimerCancelled', notifyTimerRefresh);
|
||||
addNotificationEvent(this, 'SeriesTimerCancelled', notifySeriesTimerRefresh);
|
||||
addNotificationEvent(this, 'LibraryChanged', onLibraryChanged);
|
||||
addNotificationEvent(this, 'playbackstop', onPlaybackStopped, playbackManager);
|
||||
}
|
||||
|
||||
pause() {
|
||||
clearRefreshInterval(this, true);
|
||||
|
||||
this.paused = true;
|
||||
}
|
||||
|
||||
resume(options) {
|
||||
this.paused = false;
|
||||
|
||||
const refreshIntervalEndTime = this.refreshIntervalEndTime;
|
||||
if (refreshIntervalEndTime) {
|
||||
const remainingMs = refreshIntervalEndTime - new Date().getTime();
|
||||
if (remainingMs > 0 && !this.needsRefresh) {
|
||||
resetRefreshInterval(this, remainingMs);
|
||||
} else {
|
||||
this.needsRefresh = true;
|
||||
this.refreshIntervalEndTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.needsRefresh || (options?.refresh)) {
|
||||
return this.refreshItems();
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
refreshItems() {
|
||||
if (!this.fetchData) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.paused) {
|
||||
this.needsRefresh = true;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.needsRefresh = false;
|
||||
|
||||
return this.fetchData().then(onDataFetched.bind(this));
|
||||
}
|
||||
|
||||
notifyRefreshNeeded(isInForeground) {
|
||||
if (this.paused) {
|
||||
this.needsRefresh = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = this.refreshTimeout;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (isInForeground === true) {
|
||||
this.refreshItems();
|
||||
} else {
|
||||
this.refreshTimeout = setTimeout(this.refreshItems.bind(this), 10000);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
clearRefreshInterval(this);
|
||||
|
||||
removeNotificationEvent(this, 'UserDataChanged');
|
||||
removeNotificationEvent(this, 'TimerCreated');
|
||||
removeNotificationEvent(this, 'SeriesTimerCreated');
|
||||
removeNotificationEvent(this, 'TimerCancelled');
|
||||
removeNotificationEvent(this, 'SeriesTimerCancelled');
|
||||
removeNotificationEvent(this, 'LibraryChanged');
|
||||
removeNotificationEvent(this, 'playbackstop', playbackManager);
|
||||
|
||||
this.fetchData = null;
|
||||
this.options = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearRefreshInterval(instance, isPausing) {
|
||||
if (instance.refreshInterval) {
|
||||
clearInterval(instance.refreshInterval);
|
||||
instance.refreshInterval = null;
|
||||
|
||||
if (!isPausing) {
|
||||
instance.refreshIntervalEndTime = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetRefreshInterval(instance, intervalMs) {
|
||||
clearRefreshInterval(instance);
|
||||
|
||||
if (!intervalMs) {
|
||||
const options = instance.options;
|
||||
if (options) {
|
||||
intervalMs = options.refreshIntervalMs;
|
||||
}
|
||||
}
|
||||
|
||||
if (intervalMs) {
|
||||
instance.refreshInterval = setInterval(instance.notifyRefreshNeeded.bind(instance), intervalMs);
|
||||
instance.refreshIntervalEndTime = new Date().getTime() + intervalMs;
|
||||
}
|
||||
}
|
||||
|
||||
function onDataFetched(result) {
|
||||
resetRefreshInterval(this);
|
||||
|
||||
if (this.afterRefresh) {
|
||||
this.afterRefresh(result);
|
||||
}
|
||||
}
|
||||
|
||||
export default ItemsRefresher;
|
||||
@@ -1,4 +1,3 @@
|
||||
import { LayoutMode } from 'constants/layoutMode';
|
||||
|
||||
import { appHost } from './apphost';
|
||||
import browser from '../scripts/browser';
|
||||
@@ -15,47 +14,51 @@ function setLayout(instance, layout, selectedLayout) {
|
||||
}
|
||||
}
|
||||
|
||||
export const SETTING_KEY = 'layout';
|
||||
|
||||
class LayoutManager {
|
||||
tv = false;
|
||||
mobile = false;
|
||||
desktop = false;
|
||||
experimental = false;
|
||||
|
||||
setLayout(layout = '', save = true) {
|
||||
const layoutValue = (!layout || layout === LayoutMode.Auto) ? '' : layout;
|
||||
|
||||
if (!layoutValue) {
|
||||
setLayout(layout, save) {
|
||||
if (!layout || layout === 'auto') {
|
||||
this.autoLayout();
|
||||
|
||||
if (save !== false) {
|
||||
appSettings.set('layout', '');
|
||||
}
|
||||
} else {
|
||||
setLayout(this, LayoutMode.Mobile, layoutValue);
|
||||
setLayout(this, LayoutMode.Tv, layoutValue);
|
||||
setLayout(this, LayoutMode.Desktop, layoutValue);
|
||||
}
|
||||
setLayout(this, 'mobile', layout);
|
||||
setLayout(this, 'tv', layout);
|
||||
setLayout(this, 'desktop', layout);
|
||||
|
||||
console.debug('[LayoutManager] using layout mode', layoutValue);
|
||||
this.experimental = layoutValue === LayoutMode.Experimental;
|
||||
if (this.experimental) {
|
||||
const legacyLayoutMode = browser.mobile ? LayoutMode.Mobile : LayoutMode.Desktop;
|
||||
console.debug('[LayoutManager] using legacy layout mode', legacyLayoutMode);
|
||||
setLayout(this, legacyLayoutMode, legacyLayoutMode);
|
||||
}
|
||||
this.experimental = layout === 'experimental';
|
||||
if (this.experimental) {
|
||||
const legacyLayoutMode = browser.mobile ? 'mobile' : this.defaultLayout || 'desktop';
|
||||
setLayout(this, legacyLayoutMode, legacyLayoutMode);
|
||||
}
|
||||
|
||||
if (save) appSettings.set(SETTING_KEY, layoutValue);
|
||||
if (save !== false) {
|
||||
appSettings.set('layout', layout);
|
||||
}
|
||||
}
|
||||
|
||||
Events.trigger(this, 'modechange');
|
||||
}
|
||||
|
||||
getSavedLayout() {
|
||||
return appSettings.get(SETTING_KEY);
|
||||
return appSettings.get('layout');
|
||||
}
|
||||
|
||||
autoLayout() {
|
||||
// Take a guess at initial layout. The consuming app can override.
|
||||
// NOTE: The fallback to TV mode seems like an outdated choice. TVs should be detected properly or override the
|
||||
// default layout.
|
||||
this.setLayout(browser.tv ? LayoutMode.Tv : this.defaultLayout || LayoutMode.Tv, false);
|
||||
// Take a guess at initial layout. The consuming app can override
|
||||
if (browser.mobile) {
|
||||
this.setLayout('mobile', false);
|
||||
} else if (browser.tv || browser.xboxOne || browser.ps4) {
|
||||
this.setLayout('tv', false);
|
||||
} else {
|
||||
this.setLayout(this.defaultLayout || 'tv', false);
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
|
||||
@@ -4,8 +4,6 @@ import DragHandleIcon from '@mui/icons-material/DragHandle';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
import useIndicator from 'components/indicators/useIndicator';
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
|
||||
import PrimaryMediaInfo from '../../mediainfo/PrimaryMediaInfo';
|
||||
import ListContentWrapper from './ListContentWrapper';
|
||||
import ListItemBody from './ListItemBody';
|
||||
@@ -22,7 +20,7 @@ interface ListContentProps {
|
||||
enableOverview?: boolean;
|
||||
enableSideMediaInfo?: boolean;
|
||||
clickEntireItem?: boolean;
|
||||
action?: ItemAction;
|
||||
action?: string;
|
||||
isLargeStyle: boolean;
|
||||
downloadWidth?: number;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import React, { type FC } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
import Media from 'components/common/Media';
|
||||
import PlayArrowIconButton from 'components/common/PlayArrowIconButton';
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
import type { ListOptions } from 'types/listOptions';
|
||||
|
||||
import useIndicator from '../../indicators/useIndicator';
|
||||
import layoutManager from '../../layoutManager';
|
||||
import { getDefaultBackgroundClass } from '../../cardbuilder/cardBuilderUtils';
|
||||
@@ -18,10 +11,15 @@ import {
|
||||
getImageUrl
|
||||
} from './listHelper';
|
||||
|
||||
import Media from 'components/common/Media';
|
||||
import PlayArrowIconButton from 'components/common/PlayArrowIconButton';
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
import type { ListOptions } from 'types/listOptions';
|
||||
|
||||
interface ListImageContainerProps {
|
||||
item: ItemDto;
|
||||
listOptions: ListOptions;
|
||||
action?: ItemAction | null;
|
||||
action?: string | null;
|
||||
isLargeStyle: boolean;
|
||||
clickEntireItem?: boolean;
|
||||
downloadWidth?: number;
|
||||
@@ -57,7 +55,7 @@ const ListImageContainer: FC<ListImageContainerProps> = ({
|
||||
|
||||
const playOnImageClick = listOptions.imagePlayButton && !layoutManager.tv;
|
||||
|
||||
const imageAction = playOnImageClick ? ItemAction.Link : action;
|
||||
const imageAction = playOnImageClick ? 'link' : action;
|
||||
|
||||
const btnCssClass =
|
||||
'paper-icon-button-light listItemImageButton itemAction';
|
||||
@@ -87,7 +85,7 @@ const ListImageContainer: FC<ListImageContainerProps> = ({
|
||||
<PlayArrowIconButton
|
||||
className={btnCssClass}
|
||||
action={
|
||||
canResume(playbackPositionTicks) ? ItemAction.Resume : ItemAction.Play
|
||||
canResume(playbackPositionTicks) ? 'resume' : 'play'
|
||||
}
|
||||
title={
|
||||
canResume(playbackPositionTicks) ?
|
||||
|
||||
@@ -3,16 +3,15 @@ import classNames from 'classnames';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
import TextLines from 'components/common/textLines/TextLines';
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import PrimaryMediaInfo from '../../mediainfo/PrimaryMediaInfo';
|
||||
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
import type { ListOptions } from 'types/listOptions';
|
||||
|
||||
import PrimaryMediaInfo from '../../mediainfo/PrimaryMediaInfo';
|
||||
|
||||
interface ListItemBodyProps {
|
||||
item: ItemDto;
|
||||
listOptions: ListOptions;
|
||||
action?: ItemAction | null;
|
||||
action?: string | null;
|
||||
isLargeStyle?: boolean;
|
||||
clickEntireItem?: boolean;
|
||||
enableContentWrapper?: boolean;
|
||||
|
||||
@@ -2,16 +2,13 @@ import classNames from 'classnames';
|
||||
import React, { type FC, type PropsWithChildren } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import type { DataAttributes } from 'types/dataAttributes';
|
||||
|
||||
import layoutManager from '../../layoutManager';
|
||||
import type { DataAttributes } from 'types/dataAttributes';
|
||||
|
||||
interface ListWrapperProps {
|
||||
index: number | undefined;
|
||||
title?: string | null;
|
||||
action?: ItemAction | null;
|
||||
action?: string | null;
|
||||
dataAttributes?: DataAttributes;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import classNames from 'classnames';
|
||||
|
||||
import layoutManager from 'components/layoutManager';
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import { getDataAttributes } from 'utils/items';
|
||||
import layoutManager from 'components/layoutManager';
|
||||
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
import type { ListOptions } from 'types/listOptions';
|
||||
@@ -13,7 +11,7 @@ interface UseListProps {
|
||||
}
|
||||
|
||||
function useList({ item, listOptions }: UseListProps) {
|
||||
const action = listOptions.action ?? ItemAction.Link;
|
||||
const action = listOptions.action ?? 'link';
|
||||
const isLargeStyle = listOptions.imageSize === 'large';
|
||||
const enableOverview = listOptions.enableOverview;
|
||||
const clickEntireItem = !!layoutManager.tv;
|
||||
|
||||
@@ -4,13 +4,7 @@
|
||||
* @module components/listview/listview
|
||||
*/
|
||||
|
||||
import DOMPurify from 'dompurify';
|
||||
import escapeHtml from 'escape-html';
|
||||
import markdownIt from 'markdown-it';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
|
||||
import { getDefaultBackgroundClass } from '../cardbuilder/cardBuilderUtils';
|
||||
import itemHelper from '../itemHelper';
|
||||
import mediaInfo from '../mediainfo/mediainfo';
|
||||
import indicators from '../indicators/indicators';
|
||||
@@ -19,10 +13,12 @@ import globalize from '../../lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import datetime from '../../scripts/datetime';
|
||||
import cardBuilder from '../cardbuilder/cardBuilder';
|
||||
|
||||
import './listview.scss';
|
||||
import '../../elements/emby-ratingbutton/emby-ratingbutton';
|
||||
import '../../elements/emby-playstatebutton/emby-playstatebutton';
|
||||
import { getDefaultBackgroundClass } from '../cardbuilder/cardBuilderUtils';
|
||||
import markdownIt from 'markdown-it';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
function getIndex(item, options) {
|
||||
if (options.index === 'disc') {
|
||||
@@ -169,7 +165,7 @@ function getRightButtonsHtml(options) {
|
||||
for (let i = 0, length = options.rightButtons.length; i < length; i++) {
|
||||
const button = options.rightButtons[i];
|
||||
|
||||
html += `<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="${ItemAction.Custom}" data-customaction="${button.id}" title="${button.title}"><span class="material-icons ${button.icon}" aria-hidden="true"></span></button>`;
|
||||
html += `<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="custom" data-customaction="${button.id}" title="${button.title}"><span class="material-icons ${button.icon}" aria-hidden="true"></span></button>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
@@ -179,7 +175,7 @@ export function getListViewHtml(options) {
|
||||
const items = options.items;
|
||||
|
||||
let groupTitle = '';
|
||||
const action = options.action || ItemAction.Link;
|
||||
const action = options.action || 'link';
|
||||
|
||||
const isLargeStyle = options.imageSize === 'large';
|
||||
const enableOverview = options.enableOverview;
|
||||
@@ -281,7 +277,7 @@ export function getListViewHtml(options) {
|
||||
imageClass += ' itemAction';
|
||||
}
|
||||
|
||||
const imageAction = playOnImageClick ? ItemAction.Link : action;
|
||||
const imageAction = playOnImageClick ? 'link' : action;
|
||||
|
||||
if (imgUrl) {
|
||||
html += '<div data-action="' + imageAction + '" class="' + imageClass + ' lazy" data-src="' + imgUrl + '" item-icon>';
|
||||
@@ -302,7 +298,7 @@ export function getListViewHtml(options) {
|
||||
}
|
||||
|
||||
if (playOnImageClick) {
|
||||
html += `<button is="paper-icon-button-light" class="listItemImageButton itemAction" data-action="${ItemAction.Resume}"><span class="material-icons listItemImageButton-icon play_arrow" aria-hidden="true"></span></button>`;
|
||||
html += '<button is="paper-icon-button-light" class="listItemImageButton itemAction" data-action="resume"><span class="material-icons listItemImageButton-icon play_arrow" aria-hidden="true"></span></button>';
|
||||
}
|
||||
|
||||
const progressHtml = indicators.getProgressBarHtml(item, {
|
||||
@@ -453,11 +449,11 @@ export function getListViewHtml(options) {
|
||||
|
||||
if (!clickEntireItem) {
|
||||
if (options.addToListButton) {
|
||||
html += `<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="${ItemAction.AddToPlaylist}"><span class="material-icons playlist_add" aria-hidden="true"></span></button>`;
|
||||
html += '<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="addtoplaylist"><span class="material-icons playlist_add" aria-hidden="true"></span></button>';
|
||||
}
|
||||
|
||||
if (options.infoButton) {
|
||||
html += `<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="${ItemAction.Link}"><span class="material-icons info_outline" aria-hidden="true"></span></button>`;
|
||||
html += '<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="link"><span class="material-icons info_outline" aria-hidden="true"></span></button>';
|
||||
}
|
||||
|
||||
if (options.rightButtons) {
|
||||
@@ -478,7 +474,7 @@ export function getListViewHtml(options) {
|
||||
}
|
||||
|
||||
if (options.moreButton !== false) {
|
||||
html += `<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="${ItemAction.Menu}"><span class="material-icons more_vert" aria-hidden="true"></span></button>`;
|
||||
html += '<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="menu"><span class="material-icons more_vert" aria-hidden="true"></span></button>';
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
@@ -122,9 +122,9 @@ function onAddButtonClick() {
|
||||
import('../directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
callback: function (path) {
|
||||
callback: function (path, networkSharePath) {
|
||||
if (path) {
|
||||
addMediaLocation(page, path);
|
||||
addMediaLocation(page, path, networkSharePath);
|
||||
}
|
||||
|
||||
picker.close();
|
||||
@@ -161,7 +161,7 @@ function renderPaths(page) {
|
||||
}
|
||||
}
|
||||
|
||||
function addMediaLocation(page, path) {
|
||||
function addMediaLocation(page, path, networkSharePath) {
|
||||
const pathLower = path.toLowerCase();
|
||||
const pathFilter = pathInfos.filter(p => {
|
||||
return p.Path.toLowerCase() == pathLower;
|
||||
@@ -172,6 +172,10 @@ function addMediaLocation(page, path) {
|
||||
Path: path
|
||||
};
|
||||
|
||||
if (networkSharePath) {
|
||||
pathInfo.NetworkPath = networkSharePath;
|
||||
}
|
||||
|
||||
pathInfos.push(pathInfo);
|
||||
renderPaths(page);
|
||||
}
|
||||
|
||||
@@ -56,10 +56,10 @@ function onEditLibrary() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function addMediaLocation(page, path) {
|
||||
function addMediaLocation(page, path, networkSharePath) {
|
||||
const virtualFolder = currentOptions.library;
|
||||
const refreshAfterChange = currentOptions.refresh;
|
||||
ApiClient.addMediaPath(virtualFolder.Name, path, null, refreshAfterChange).then(() => {
|
||||
ApiClient.addMediaPath(virtualFolder.Name, path, networkSharePath, refreshAfterChange).then(() => {
|
||||
hasChanges = true;
|
||||
refreshLibraryFromServer(page);
|
||||
}, () => {
|
||||
@@ -67,10 +67,11 @@ function addMediaLocation(page, path) {
|
||||
});
|
||||
}
|
||||
|
||||
function updateMediaLocation(page, path) {
|
||||
function updateMediaLocation(page, path, networkSharePath) {
|
||||
const virtualFolder = currentOptions.library;
|
||||
ApiClient.updateMediaPath(virtualFolder.Name, {
|
||||
Path: path
|
||||
Path: path,
|
||||
NetworkPath: networkSharePath
|
||||
}).then(() => {
|
||||
hasChanges = true;
|
||||
refreshLibraryFromServer(page);
|
||||
@@ -114,7 +115,7 @@ function onListItemClick(e) {
|
||||
return;
|
||||
}
|
||||
|
||||
showDirectoryBrowser(dom.parentWithClass(listItem, 'dlg-libraryeditor'), originalPath);
|
||||
showDirectoryBrowser(dom.parentWithClass(listItem, 'dlg-libraryeditor'), originalPath, pathInfo.NetworkPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,18 +174,19 @@ function onAddButtonClick() {
|
||||
showDirectoryBrowser(dom.parentWithClass(this, 'dlg-libraryeditor'));
|
||||
}
|
||||
|
||||
function showDirectoryBrowser(context, originalPath) {
|
||||
function showDirectoryBrowser(context, originalPath, networkPath) {
|
||||
import('../directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
pathReadOnly: originalPath != null,
|
||||
path: originalPath,
|
||||
callback: function (path) {
|
||||
networkSharePath: networkPath,
|
||||
callback: function (path, networkSharePath) {
|
||||
if (path) {
|
||||
if (originalPath) {
|
||||
updateMediaLocation(context, originalPath);
|
||||
updateMediaLocation(context, originalPath, networkSharePath);
|
||||
} else {
|
||||
addMediaLocation(context, path);
|
||||
addMediaLocation(context, path, networkSharePath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3483,7 +3483,7 @@ export class PlaybackManager {
|
||||
const nextItemPlayOptions = nextItem ? (nextItem.item.playOptions || getDefaultPlayOptions()) : getDefaultPlayOptions();
|
||||
const newPlayer = nextItem ? getPlayer(nextItem.item, nextItemPlayOptions) : null;
|
||||
|
||||
if (!newPlayer) {
|
||||
if (newPlayer !== player) {
|
||||
data.streamInfo = null;
|
||||
destroyPlayer(player);
|
||||
removeCurrentPlayer(player);
|
||||
@@ -3491,21 +3491,12 @@ export class PlaybackManager {
|
||||
|
||||
if (errorOccurred) {
|
||||
showPlaybackInfoErrorMessage(self, 'PlaybackError' + displayErrorCode);
|
||||
} else if (newPlayer) {
|
||||
} else if (nextItem) {
|
||||
const apiClient = ServerConnections.getApiClient(nextItem.item.ServerId);
|
||||
|
||||
apiClient.getCurrentUser().then(function (user) {
|
||||
if (user.Configuration.EnableNextEpisodeAutoPlay || nextMediaType !== MediaType.Video) {
|
||||
self.nextTrack();
|
||||
|
||||
if (newPlayer !== player) {
|
||||
Events.trigger(self, 'playbackstop', [{
|
||||
player,
|
||||
state,
|
||||
nextItem,
|
||||
nextMediaType
|
||||
}]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ function getDisplayTranscodeFps(session, player) {
|
||||
const mediaSource = playbackManager.currentMediaSource(player) || {};
|
||||
const videoStream = (mediaSource.MediaStreams || []).find((s) => s.Type === 'Video') || {};
|
||||
|
||||
const originalFramerate = videoStream.ReferenceFrameRate;
|
||||
const originalFramerate = videoStream.ReferenceFrameRate || videoStream.RealFrameRate;
|
||||
const transcodeFramerate = session.TranscodingInfo.Framerate;
|
||||
|
||||
if (!originalFramerate) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import escapeHtml from 'escape-html';
|
||||
import { getImageUrl } from 'apps/stable/features/playback/utils/image';
|
||||
import { getItemTextLines } from 'apps/stable/features/playback/utils/itemText';
|
||||
import { AppFeature } from 'constants/appFeature';
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
|
||||
import datetime from '../../scripts/datetime';
|
||||
import { clearBackdrop, setBackdrops } from '../backdrop/backdrop';
|
||||
@@ -17,9 +16,6 @@ import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import layoutManager from '../layoutManager';
|
||||
import * as userSettings from '../../scripts/settings/userSettings';
|
||||
import itemContextMenu from '../itemContextMenu';
|
||||
import toast from '../toast/toast';
|
||||
import { appRouter } from '../router/appRouter';
|
||||
import { getDefaultBackgroundClass } from '../cardbuilder/cardBuilderUtils';
|
||||
|
||||
import '../cardbuilder/card.scss';
|
||||
import '../../elements/emby-button/emby-button';
|
||||
@@ -28,6 +24,9 @@ import '../../elements/emby-itemscontainer/emby-itemscontainer';
|
||||
import './remotecontrol.scss';
|
||||
import '../../elements/emby-ratingbutton/emby-ratingbutton';
|
||||
import '../../elements/emby-slider/emby-slider';
|
||||
import toast from '../toast/toast';
|
||||
import { appRouter } from '../router/appRouter';
|
||||
import { getDefaultBackgroundClass } from '../cardbuilder/cardBuilderUtils';
|
||||
|
||||
let showMuteButton = true;
|
||||
let showVolumeSlider = true;
|
||||
@@ -209,7 +208,7 @@ function setImageUrl(context, state, url) {
|
||||
context.querySelector('.nowPlayingPageImage').classList.toggle('nowPlayingPageImageAudio', item.Type === 'Audio');
|
||||
context.querySelector('.nowPlayingPageImage').classList.toggle('nowPlayingPageImagePoster', item.Type !== 'Audio');
|
||||
} else {
|
||||
imgContainer.innerHTML = `<div class="nowPlayingPageImageContainerNoAlbum"><button data-action="${ItemAction.Link}" class="cardImageContainer coveredImage ${getDefaultBackgroundClass(item.Name)} cardContent cardContent-shadow itemAction"><span class="cardImageIcon material-icons album" aria-hidden="true"></span></button></div>`;
|
||||
imgContainer.innerHTML = '<div class="nowPlayingPageImageContainerNoAlbum"><button data-action="link" class="cardImageContainer coveredImage ' + getDefaultBackgroundClass(item.Name) + ' cardContent cardContent-shadow itemAction"><span class="cardImageIcon material-icons album" aria-hidden="true"></span></button></div>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import itemHelper from '../itemHelper';
|
||||
import loading from '../loading/loading';
|
||||
import alert from '../alert';
|
||||
|
||||
import { LayoutMode } from 'constants/layoutMode';
|
||||
import { getItemQuery } from 'hooks/useItem';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
||||
@@ -17,7 +16,7 @@ import { history } from 'RootAppRouter';
|
||||
const START_PAGE_PATHS = ['/home', '/login', '/selectserver'];
|
||||
|
||||
/** Pages that do not require a user to be logged in to view. */
|
||||
const PUBLIC_PATHS = [
|
||||
export const PUBLIC_PATHS = [
|
||||
'/addserver',
|
||||
'/selectserver',
|
||||
'/login',
|
||||
@@ -122,9 +121,7 @@ class AppRouter {
|
||||
return this.baseRoute;
|
||||
}
|
||||
|
||||
canGoBack() {
|
||||
const path = history.location.pathname;
|
||||
|
||||
canGoBack(path = history.location.pathname) {
|
||||
if (
|
||||
!document.querySelector('.dialogContainer')
|
||||
&& START_PAGE_PATHS.includes(path)
|
||||
@@ -262,15 +259,15 @@ class AppRouter {
|
||||
}
|
||||
|
||||
if (item === 'recordedtv') {
|
||||
return '#/livetv?tab=3&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=3&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (item === 'nextup') {
|
||||
return '#/list?type=nextup&serverId=' + options.serverId;
|
||||
return '#/list?type=nextup&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (item === 'list') {
|
||||
let urlForList = '#/list?serverId=' + options.serverId + '&type=' + options.itemTypes;
|
||||
let urlForList = '#/list?serverId=' + serverId + '&type=' + options.itemTypes;
|
||||
|
||||
if (options.isFavorite) {
|
||||
urlForList += '&IsFavorite=true';
|
||||
@@ -305,49 +302,49 @@ class AppRouter {
|
||||
|
||||
if (item === 'livetv') {
|
||||
if (options.section === 'programs') {
|
||||
return '#/livetv?tab=0&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=0&serverId=' + serverId;
|
||||
}
|
||||
if (options.section === 'guide') {
|
||||
return '#/livetv?tab=1&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=1&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'movies') {
|
||||
return '#/list?type=Programs&IsMovie=true&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsMovie=true&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'shows') {
|
||||
return '#/list?type=Programs&IsSeries=true&IsMovie=false&IsNews=false&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsSeries=true&IsMovie=false&IsNews=false&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'sports') {
|
||||
return '#/list?type=Programs&IsSports=true&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsSports=true&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'kids') {
|
||||
return '#/list?type=Programs&IsKids=true&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsKids=true&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'news') {
|
||||
return '#/list?type=Programs&IsNews=true&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsNews=true&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'onnow') {
|
||||
return '#/list?type=Programs&IsAiring=true&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsAiring=true&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'channels') {
|
||||
return '#/livetv?tab=2&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=2&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'dvrschedule') {
|
||||
return '#/livetv?tab=4&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=4&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'seriesrecording') {
|
||||
return '#/livetv?tab=5&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=5&serverId=' + serverId;
|
||||
}
|
||||
|
||||
return '#/livetv?serverId=' + options.serverId;
|
||||
return '#/livetv?serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (itemType == 'SeriesTimer') {
|
||||
@@ -435,7 +432,7 @@ class AppRouter {
|
||||
|
||||
const layoutMode = localStorage.getItem('layout');
|
||||
|
||||
if (layoutMode === LayoutMode.Experimental && item.CollectionType == CollectionType.Homevideos) {
|
||||
if (layoutMode === 'experimental' && item.CollectionType == CollectionType.Homevideos) {
|
||||
url = '#/homevideos?topParentId=' + item.Id;
|
||||
|
||||
return url;
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
/**
|
||||
* "Shortcut" action handlers for BaseItems.
|
||||
* Module shortcuts.
|
||||
* @module components/shortcuts
|
||||
*/
|
||||
import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
||||
|
||||
import { playbackManager } from './playback/playbackmanager';
|
||||
import inputManager from '../scripts/inputManager';
|
||||
import { appRouter } from './router/appRouter';
|
||||
import globalize from '../lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import dom from '../utils/dom';
|
||||
import recordingHelper from './recordingcreator/recordinghelper';
|
||||
import toast from './toast/toast';
|
||||
import * as userSettings from '../scripts/settings/userSettings';
|
||||
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
||||
|
||||
function playAllFromHere(card, serverId, queue) {
|
||||
const parent = card.parentNode;
|
||||
@@ -232,114 +231,95 @@ function executeAction(card, target, action) {
|
||||
|
||||
const playableItemId = type === 'Program' ? item.ChannelId : item.Id;
|
||||
|
||||
if (item.MediaType === 'Photo' && action === ItemAction.Link) {
|
||||
action = ItemAction.Play;
|
||||
if (item.MediaType === 'Photo' && action === 'link') {
|
||||
action = 'play';
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case ItemAction.Link:
|
||||
appRouter.showItem(item, {
|
||||
context: card.getAttribute('data-context'),
|
||||
parentId: card.getAttribute('data-parentid')
|
||||
if (action === 'link') {
|
||||
appRouter.showItem(item, {
|
||||
context: card.getAttribute('data-context'),
|
||||
parentId: card.getAttribute('data-parentid')
|
||||
});
|
||||
} else if (action === 'programdialog') {
|
||||
showProgramDialog(item);
|
||||
} else if (action === 'instantmix') {
|
||||
playbackManager.instantMix({
|
||||
Id: playableItemId,
|
||||
ServerId: serverId
|
||||
});
|
||||
} else if (action === 'play' || action === 'resume') {
|
||||
const startPositionTicks = parseInt(card.getAttribute('data-positionticks') || '0', 10);
|
||||
const sortValues = userSettings.getSortValuesLegacy(sortParentId, 'SortName');
|
||||
|
||||
if (playbackManager.canPlay(item)) {
|
||||
playbackManager.play({
|
||||
ids: [playableItemId],
|
||||
startPositionTicks: startPositionTicks,
|
||||
serverId: serverId,
|
||||
queryOptions: {
|
||||
SortBy: sortValues.sortBy,
|
||||
SortOrder: sortValues.sortOrder
|
||||
}
|
||||
});
|
||||
break;
|
||||
case ItemAction.ProgramDialog:
|
||||
showProgramDialog(item);
|
||||
break;
|
||||
case ItemAction.InstantMix:
|
||||
playbackManager.instantMix({
|
||||
Id: playableItemId,
|
||||
ServerId: serverId
|
||||
} else {
|
||||
console.warn('Unable to play item', item);
|
||||
}
|
||||
} else if (action === 'queue') {
|
||||
if (playbackManager.isPlaying()) {
|
||||
playbackManager.queue({
|
||||
ids: [playableItemId],
|
||||
serverId: serverId
|
||||
});
|
||||
break;
|
||||
case ItemAction.Play:
|
||||
case ItemAction.Resume: {
|
||||
const startPositionTicks = parseInt(card.getAttribute('data-positionticks') || '0', 10);
|
||||
const sortValues = userSettings.getSortValuesLegacy(sortParentId, 'SortName');
|
||||
|
||||
if (playbackManager.canPlay(item)) {
|
||||
playbackManager.play({
|
||||
ids: [playableItemId],
|
||||
startPositionTicks: startPositionTicks,
|
||||
serverId: serverId,
|
||||
queryOptions: {
|
||||
SortBy: sortValues.sortBy,
|
||||
SortOrder: sortValues.sortOrder
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn('Unable to play item', item);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ItemAction.Queue:
|
||||
if (playbackManager.isPlaying()) {
|
||||
playbackManager.queue({
|
||||
ids: [playableItemId],
|
||||
serverId: serverId
|
||||
});
|
||||
toast(globalize.translate('MediaQueued'));
|
||||
} else {
|
||||
playbackManager.queue({
|
||||
ids: [playableItemId],
|
||||
serverId: serverId
|
||||
});
|
||||
}
|
||||
break;
|
||||
case ItemAction.PlayAllFromHere:
|
||||
playAllFromHere(card, serverId);
|
||||
break;
|
||||
case ItemAction.QueueAllFromHere:
|
||||
playAllFromHere(card, serverId, true);
|
||||
break;
|
||||
case ItemAction.SetPlaylistIndex:
|
||||
playbackManager.setCurrentPlaylistItem(card.getAttribute('data-playlistitemid'));
|
||||
break;
|
||||
case ItemAction.Record:
|
||||
onRecordCommand(serverId, id, type, card.getAttribute('data-timerid'), card.getAttribute('data-seriestimerid'));
|
||||
break;
|
||||
case ItemAction.Menu: {
|
||||
const options = target.getAttribute('data-playoptions') === 'false' ?
|
||||
{
|
||||
shuffle: false,
|
||||
instantMix: false,
|
||||
play: false,
|
||||
playAllFromHere: false,
|
||||
queue: false,
|
||||
queueAllFromHere: false
|
||||
} :
|
||||
{};
|
||||
|
||||
options.positionTo = target;
|
||||
|
||||
showContextMenu(card, options);
|
||||
break;
|
||||
}
|
||||
case ItemAction.PlayMenu:
|
||||
showPlayMenu(card, target);
|
||||
break;
|
||||
case ItemAction.Edit:
|
||||
getItem(target).then(itemToEdit => {
|
||||
editItem(itemToEdit, serverId);
|
||||
toast(globalize.translate('MediaQueued'));
|
||||
} else {
|
||||
playbackManager.queue({
|
||||
ids: [playableItemId],
|
||||
serverId: serverId
|
||||
});
|
||||
break;
|
||||
case ItemAction.PlayTrailer:
|
||||
getItem(target).then(playTrailer);
|
||||
break;
|
||||
case ItemAction.AddToPlaylist:
|
||||
getItem(target).then(addToPlaylist);
|
||||
break;
|
||||
case ItemAction.Custom: {
|
||||
const customAction = target.getAttribute('data-customaction');
|
||||
|
||||
card.dispatchEvent(new CustomEvent(`action-${customAction}`, {
|
||||
detail: {
|
||||
playlistItemId: card.getAttribute('data-playlistitemid')
|
||||
},
|
||||
cancelable: false,
|
||||
bubbles: true
|
||||
}));
|
||||
}
|
||||
} else if (action === 'playallfromhere') {
|
||||
playAllFromHere(card, serverId);
|
||||
} else if (action === 'queueallfromhere') {
|
||||
playAllFromHere(card, serverId, true);
|
||||
} else if (action === 'setplaylistindex') {
|
||||
playbackManager.setCurrentPlaylistItem(card.getAttribute('data-playlistitemid'));
|
||||
} else if (action === 'record') {
|
||||
onRecordCommand(serverId, id, type, card.getAttribute('data-timerid'), card.getAttribute('data-seriestimerid'));
|
||||
} else if (action === 'menu') {
|
||||
const options = target.getAttribute('data-playoptions') === 'false' ?
|
||||
{
|
||||
shuffle: false,
|
||||
instantMix: false,
|
||||
play: false,
|
||||
playAllFromHere: false,
|
||||
queue: false,
|
||||
queueAllFromHere: false
|
||||
} :
|
||||
{};
|
||||
|
||||
options.positionTo = target;
|
||||
|
||||
showContextMenu(card, options);
|
||||
} else if (action === 'playmenu') {
|
||||
showPlayMenu(card, target);
|
||||
} else if (action === 'edit') {
|
||||
getItem(target).then(itemToEdit => {
|
||||
editItem(itemToEdit, serverId);
|
||||
});
|
||||
} else if (action === 'playtrailer') {
|
||||
getItem(target).then(playTrailer);
|
||||
} else if (action === 'addtoplaylist') {
|
||||
getItem(target).then(addToPlaylist);
|
||||
} else if (action === 'custom') {
|
||||
const customAction = target.getAttribute('data-customaction');
|
||||
|
||||
card.dispatchEvent(new CustomEvent(`action-${customAction}`, {
|
||||
detail: {
|
||||
playlistItemId: card.getAttribute('data-playlistitemid')
|
||||
},
|
||||
cancelable: false,
|
||||
bubbles: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,7 +390,7 @@ export function onClick(e) {
|
||||
}
|
||||
}
|
||||
|
||||
if (action && action !== ItemAction.None) {
|
||||
if (action && action !== 'none') {
|
||||
executeAction(card, actionElement, action);
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
@@ -2,15 +2,9 @@
|
||||
* Image viewer component
|
||||
* @module components/slideshow/slideshow
|
||||
*/
|
||||
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
|
||||
import screenfull from 'screenfull';
|
||||
|
||||
import { AppFeature } from 'constants/appFeature';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
||||
import { randomInt } from 'utils/number';
|
||||
|
||||
import dialogHelper from '../dialogHelper/dialogHelper';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import inputManager from '../../scripts/inputManager';
|
||||
import layoutManager from '../layoutManager';
|
||||
import focusManager from '../focusManager';
|
||||
@@ -21,6 +15,8 @@ import dom from '../../utils/dom';
|
||||
import './style.scss';
|
||||
import 'material-design-icons-iconfont';
|
||||
import '../../elements/emby-button/paper-icon-button-light';
|
||||
import screenfull from 'screenfull';
|
||||
import { randomInt } from '../../utils/number.ts';
|
||||
|
||||
/**
|
||||
* Name of transition event.
|
||||
@@ -92,15 +88,14 @@ function getBackdropImageUrl(item, options, apiClient) {
|
||||
* @returns {string} URL of the item's image.
|
||||
*/
|
||||
function getImgUrl(item, user) {
|
||||
const apiClient = ServerConnections.getApiClient(item);
|
||||
const api = toApi(apiClient);
|
||||
const apiClient = ServerConnections.getApiClient(item.ServerId);
|
||||
const imageOptions = {};
|
||||
|
||||
if (item.BackdropImageTags?.length) {
|
||||
return getBackdropImageUrl(item, imageOptions, apiClient);
|
||||
} else {
|
||||
if (item.MediaType === 'Photo' && user?.Policy.EnableContentDownloading) {
|
||||
return getLibraryApi(api).getDownloadUrl({ itemId: item.Id });
|
||||
return apiClient.getItemDownloadUrl(item.Id);
|
||||
}
|
||||
imageOptions.type = 'Primary';
|
||||
return getImageUrl(item, imageOptions, apiClient);
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
/** Actions that can be performed on a BaseItem. */
|
||||
export enum ItemAction {
|
||||
/** Add the Item to a playlist. */
|
||||
AddToPlaylist = 'addtoplaylist',
|
||||
/** Trigger a custom action via an Event. */
|
||||
Custom = 'custom',
|
||||
/** Open an editor for the Item. */
|
||||
Edit = 'edit',
|
||||
/** Create an instant mix based on the Item. */
|
||||
InstantMix = 'instantmix',
|
||||
/** Open the details view for the Item. */
|
||||
Link = 'link',
|
||||
/** Open the context menu for the Item. */
|
||||
Menu = 'menu',
|
||||
/** Perform no action. Used to prevent a parent element's action being triggered. */
|
||||
None = 'none',
|
||||
/** Play the Item. */
|
||||
Play = 'play',
|
||||
/** Queue the Item and all subsequent Items and start playback. */
|
||||
PlayAllFromHere = 'playallfromhere',
|
||||
/** Open the play menu for the Item. */
|
||||
PlayMenu = 'playmenu',
|
||||
/** Play the trailer for the Item. */
|
||||
PlayTrailer = 'playtrailer',
|
||||
/** Open the program dialog for the Item. */
|
||||
ProgramDialog = 'programdialog',
|
||||
/** Queue the Item. */
|
||||
Queue = 'queue',
|
||||
/** Queue the Item and all subsequent Items. */
|
||||
QueueAllFromHere = 'queueallfromhere',
|
||||
/** Record the Item. */
|
||||
Record = 'record',
|
||||
/** Resume playback of the Item. */
|
||||
Resume = 'resume',
|
||||
/** Set this Item as the Item to be currently played from a playlist. */
|
||||
SetPlaylistIndex = 'setplaylistindex'
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
/** The different layout modes supported by the web app. */
|
||||
export enum LayoutMode {
|
||||
/** Automatic layout - the app chose the best layout for the detected device. */
|
||||
Auto = 'auto',
|
||||
/** The legacy desktop layout. */
|
||||
Desktop = 'desktop',
|
||||
/** The modern React based layout. */
|
||||
Experimental = 'experimental',
|
||||
/** The legacy mobile layout. */
|
||||
Mobile = 'mobile',
|
||||
/** The TV layout. */
|
||||
Tv = 'tv'
|
||||
};
|
||||
@@ -176,86 +176,88 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detailPageSecondaryContainer padded-left padded-bottom-page">
|
||||
<div id="childrenCollapsible" class="hide verticalSection detailVerticalSection">
|
||||
<h2 class="sectionTitle sectionTitle-cards hide">
|
||||
<span></span>
|
||||
</h2>
|
||||
<div>
|
||||
<div is="emby-itemscontainer" class="itemsContainer padded-right" style="text-align: left;"></div>
|
||||
<div class="detailPageSecondaryContainer padded-bottom-page">
|
||||
<div class="detailPageContent">
|
||||
<div id="childrenCollapsible" class="hide verticalSection detailVerticalSection">
|
||||
<h2 class="sectionTitle sectionTitle-cards hide">
|
||||
<span></span>
|
||||
</h2>
|
||||
<div>
|
||||
<div is="emby-itemscontainer" class="itemsContainer padded-right" style="text-align: left;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="additionalPartsCollapsible" class="verticalSection detailVerticalSection hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards padded-right">${HeaderAdditionalParts}</h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div id="additionalPartsContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
|
||||
<div id="additionalPartsCollapsible" class="verticalSection detailVerticalSection hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards padded-right">${HeaderAdditionalParts}</h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div id="additionalPartsContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection detailVerticalSection moreFromSeasonSection hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards padded-right"></h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
|
||||
<div class="verticalSection detailVerticalSection moreFromSeasonSection hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards padded-right"></h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="lyricsSection" class="verticalSection-extrabottompadding detailVerticalSection lyricsContainer hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards padded-right">${Lyrics}</h2>
|
||||
<div is="emby-itemscontainer" class="vertical-list itemsContainer"></div>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection detailVerticalSection moreFromArtistSection hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards padded-right"></h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
|
||||
<div id="lyricsSection" class="verticalSection-extrabottompadding detailVerticalSection lyricsContainer hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards padded-right">${Lyrics}</h2>
|
||||
<div is="emby-itemscontainer" class="vertical-list itemsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="castCollapsible" class="verticalSection detailVerticalSection hide">
|
||||
<h2 id="peopleHeader" class="sectionTitle sectionTitle-cards padded-right">${HeaderCastAndCrew}</h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div id="castContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
|
||||
<div class="verticalSection detailVerticalSection moreFromArtistSection hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards padded-right"></h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="guestCastCollapsible" class="verticalSection detailVerticalSection hide">
|
||||
<h2 id="guestCastHeader" class="sectionTitle sectionTitle-cards padded-right">${HeaderGuestCast}</h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div id="guestCastContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
|
||||
<div id="castCollapsible" class="verticalSection detailVerticalSection hide">
|
||||
<h2 id="peopleHeader" class="sectionTitle sectionTitle-cards padded-right">${HeaderCastAndCrew}</h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div id="castContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="seriesScheduleSection" class="verticalSection detailVerticalSection hide">
|
||||
<h2 class="sectionTitle padded-right">${HeaderUpcomingOnTV}</h2>
|
||||
<div id="seriesScheduleList" is="emby-itemscontainer" class="itemsContainer vertical-list padded-right"></div>
|
||||
</div>
|
||||
|
||||
<div id="specialsCollapsible" class="verticalSection detailVerticalSection hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards padded-right">${SpecialFeatures}</h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div id="specialsContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
|
||||
<div id="guestCastCollapsible" class="verticalSection detailVerticalSection hide">
|
||||
<h2 id="guestCastHeader" class="sectionTitle sectionTitle-cards padded-right">${HeaderGuestCast}</h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div id="guestCastContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="musicVideosCollapsible" class="verticalSection detailVerticalSection hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards padded-right">${MusicVideos}</h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div id="musicVideosContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
|
||||
<div id="seriesScheduleSection" class="verticalSection detailVerticalSection hide">
|
||||
<h2 class="sectionTitle padded-right">${HeaderUpcomingOnTV}</h2>
|
||||
<div id="seriesScheduleList" is="emby-itemscontainer" class="itemsContainer vertical-list padded-right"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="scenesCollapsible" class="verticalSection detailVerticalSection verticalSection-extrabottompadding hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards padded-right">${HeaderScenes}</h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div id="scenesContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
|
||||
<div id="specialsCollapsible" class="verticalSection detailVerticalSection hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards padded-right">${SpecialFeatures}</h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div id="specialsContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="similarCollapsible" class="verticalSection detailVerticalSection verticalSection-extrabottompadding hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards padded-right">${HeaderMoreLikeThis}</h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer similarContent"></div>
|
||||
<div id="musicVideosCollapsible" class="verticalSection detailVerticalSection hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards padded-right">${MusicVideos}</h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div id="musicVideosContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="scenesCollapsible" class="verticalSection detailVerticalSection verticalSection-extrabottompadding hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards padded-right">${HeaderScenes}</h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div id="scenesContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="similarCollapsible" class="verticalSection detailVerticalSection verticalSection-extrabottompadding hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards padded-right">${HeaderMoreLikeThis}</h2>
|
||||
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
|
||||
<div is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer similarContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import { PersonKind } from '@jellyfin/sdk/lib/generated-client/models/person-kind';
|
||||
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
|
||||
import { intervalToDuration } from 'date-fns';
|
||||
import DOMPurify from 'dompurify';
|
||||
import escapeHtml from 'escape-html';
|
||||
@@ -23,7 +22,6 @@ import { playbackManager } from 'components/playback/playbackmanager';
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
import itemShortcuts from 'components/shortcuts';
|
||||
import { AppFeature } from 'constants/appFeature';
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import globalize from 'lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import browser from 'scripts/browser';
|
||||
@@ -36,7 +34,6 @@ import { getPortraitShape, getSquareShape } from 'utils/card';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import Events from 'utils/events';
|
||||
import { getItemBackdropImageUrl } from 'utils/jellyfin-apiclient/backdropImage';
|
||||
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
||||
|
||||
import 'elements/emby-itemscontainer/emby-itemscontainer';
|
||||
import 'elements/emby-checkbox/emby-checkbox';
|
||||
@@ -437,19 +434,19 @@ function renderName(item, container, context) {
|
||||
parentNameHtml.push(getArtistLinksHtml(item.ArtistItems, item.ServerId, context));
|
||||
parentNameLast = true;
|
||||
} else if (item.SeriesName && item.Type === 'Episode') {
|
||||
parentNameHtml.push(`<a style="color:inherit;" class="button-link itemAction" is="emby-linkbutton" href="#" data-action="${ItemAction.Link}" data-id="${item.SeriesId}" data-serverid="${item.ServerId}" data-type="Series" data-isfolder="true">${escapeHtml(item.SeriesName)}</a>`);
|
||||
parentNameHtml.push(`<a style="color:inherit;" class="button-link itemAction" is="emby-linkbutton" href="#" data-action="link" data-id="${item.SeriesId}" data-serverid="${item.ServerId}" data-type="Series" data-isfolder="true">${escapeHtml(item.SeriesName)}</a>`);
|
||||
} else if (item.IsSeries || item.EpisodeTitle) {
|
||||
parentNameHtml.push(escapeHtml(item.Name));
|
||||
}
|
||||
|
||||
if (item.SeriesName && item.Type === 'Season') {
|
||||
parentNameHtml.push(`<a style="color:inherit;" class="button-link itemAction" is="emby-linkbutton" href="#" data-action="${ItemAction.Link}" data-id="${item.SeriesId}" data-serverid="${item.ServerId}" data-type="Series" data-isfolder="true">${escapeHtml(item.SeriesName)}</a>`);
|
||||
parentNameHtml.push(`<a style="color:inherit;" class="button-link itemAction" is="emby-linkbutton" href="#" data-action="link" data-id="${item.SeriesId}" data-serverid="${item.ServerId}" data-type="Series" data-isfolder="true">${escapeHtml(item.SeriesName)}</a>`);
|
||||
} else if (item.ParentIndexNumber != null && item.Type === 'Episode') {
|
||||
parentNameHtml.push(`<a style="color:inherit;" class="button-link itemAction" is="emby-linkbutton" href="#" data-action="${ItemAction.Link}" data-id="${item.SeasonId}" data-serverid="${item.ServerId}" data-type="Season" data-isfolder="true">${escapeHtml(item.SeasonName)}</a>`);
|
||||
parentNameHtml.push(`<a style="color:inherit;" class="button-link itemAction" is="emby-linkbutton" href="#" data-action="link" data-id="${item.SeasonId}" data-serverid="${item.ServerId}" data-type="Season" data-isfolder="true">${escapeHtml(item.SeasonName)}</a>`);
|
||||
} else if (item.ParentIndexNumber != null && item.IsSeries) {
|
||||
parentNameHtml.push(escapeHtml(item.SeasonName || 'S' + item.ParentIndexNumber));
|
||||
} else if (item.Album && item.AlbumId && (item.Type === 'MusicVideo' || item.Type === 'Audio')) {
|
||||
parentNameHtml.push(`<a style="color:inherit;" class="button-link itemAction" is="emby-linkbutton" href="#" data-action="${ItemAction.Link}" data-id="${item.AlbumId}" data-serverid="${item.ServerId}" data-type="MusicAlbum" data-isfolder="true">${escapeHtml(item.Album)}</a>`);
|
||||
parentNameHtml.push(`<a style="color:inherit;" class="button-link itemAction" is="emby-linkbutton" href="#" data-action="link" data-id="${item.AlbumId}" data-serverid="${item.ServerId}" data-type="MusicAlbum" data-isfolder="true">${escapeHtml(item.Album)}</a>`);
|
||||
} else if (item.Album) {
|
||||
parentNameHtml.push(escapeHtml(item.Album));
|
||||
}
|
||||
@@ -1985,7 +1982,7 @@ export default function (view, params) {
|
||||
return;
|
||||
}
|
||||
|
||||
playItem(item, item.UserData && mode === ItemAction.Resume ? item.UserData.PlaybackPositionTicks : 0);
|
||||
playItem(item, item.UserData && mode === 'resume' ? item.UserData.PlaybackPositionTicks : 0);
|
||||
}
|
||||
|
||||
function onPlayClick() {
|
||||
@@ -2029,10 +2026,9 @@ export default function (view, params) {
|
||||
}
|
||||
|
||||
function onDownloadClick() {
|
||||
const api = toApi(getApiClient());
|
||||
const url = getLibraryApi(api).getDownloadUrl({ itemId: currentItem.Id });
|
||||
const downloadHref = getApiClient().getItemDownloadUrl(currentItem.Id);
|
||||
download([{
|
||||
url,
|
||||
url: downloadHref,
|
||||
item: currentItem,
|
||||
itemId: currentItem.Id,
|
||||
serverId: currentItem.ServerId,
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import React, { type FC, useCallback } from 'react';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import globalize from 'lib/globalize';
|
||||
import { useTogglePlayedMutation } from 'hooks/useFetchItems';
|
||||
|
||||
@@ -59,17 +59,23 @@ const PlayedButton: FC<PlayedButtonProps> = ({
|
||||
}
|
||||
}, [itemId, togglePlayedMutation, isPlayed, queryClient, queryKey]);
|
||||
|
||||
const btnClass = classNames(
|
||||
className,
|
||||
{ 'playstatebutton-played': isPlayed }
|
||||
);
|
||||
|
||||
const iconClass = classNames(
|
||||
{ 'playstatebutton-icon-played': isPlayed }
|
||||
);
|
||||
return (
|
||||
<IconButton
|
||||
data-action={ItemAction.None}
|
||||
data-action='none'
|
||||
title={getTitle()}
|
||||
className={className}
|
||||
className={btnClass}
|
||||
size='small'
|
||||
onClick={onClick}
|
||||
>
|
||||
<CheckIcon
|
||||
color={isPlayed ? 'error' : undefined}
|
||||
/>
|
||||
<CheckIcon className={iconClass} />
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import classNames from 'classnames';
|
||||
import { useToggleFavoriteMutation } from 'hooks/useFetchItems';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
@@ -45,17 +45,24 @@ const FavoriteButton: FC<FavoriteButtonProps> = ({
|
||||
}
|
||||
}, [isFavorite, itemId, queryClient, queryKey, toggleFavoriteMutation]);
|
||||
|
||||
const btnClass = classNames(
|
||||
className,
|
||||
{ 'ratingbutton-withrating': isFavorite }
|
||||
);
|
||||
|
||||
const iconClass = classNames(
|
||||
{ 'ratingbutton-icon-withrating': isFavorite }
|
||||
);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
data-action={ItemAction.None}
|
||||
className={className}
|
||||
data-action='none'
|
||||
title={isFavorite ? globalize.translate('Favorite') : globalize.translate('AddToFavorites')}
|
||||
className={btnClass}
|
||||
size='small'
|
||||
onClick={onClick}
|
||||
>
|
||||
<FavoriteIcon
|
||||
color={isFavorite ? 'error' : undefined}
|
||||
/>
|
||||
<FavoriteIcon className={iconClass} />
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,8 +8,6 @@ import { useApi } from './useApi';
|
||||
|
||||
export type UsersRecords = Record<string, UserDto>;
|
||||
|
||||
export const QUERY_KEY = 'Users';
|
||||
|
||||
const fetchUsers = async (
|
||||
api: Api,
|
||||
requestParams?: UserApiGetUsersRequest,
|
||||
@@ -25,7 +23,7 @@ const fetchUsers = async (
|
||||
export const useUsers = (requestParams?: UserApiGetUsersRequest) => {
|
||||
const { api } = useApi();
|
||||
return useQuery({
|
||||
queryKey: [ QUERY_KEY ],
|
||||
queryKey: ['Users'],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchUsers(api!, requestParams, { signal }),
|
||||
enabled: !!api
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
|
||||
import Screenfull from 'screenfull';
|
||||
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import browser from 'scripts/browser';
|
||||
import TouchHelper from 'scripts/touchHelper';
|
||||
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
||||
import 'material-design-icons-iconfont';
|
||||
|
||||
import loading from '../../components/loading/loading';
|
||||
import keyboardnavigation from '../../scripts/keyboardNavigation';
|
||||
import dialogHelper from '../../components/dialogHelper/dialogHelper';
|
||||
import Screenfull from 'screenfull';
|
||||
import TableOfContents from './tableOfContents';
|
||||
import { translateHtml } from '../../lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import browser from 'scripts/browser';
|
||||
import * as userSettings from '../../scripts/settings/userSettings';
|
||||
import TouchHelper from 'scripts/touchHelper';
|
||||
import { PluginType } from '../../types/plugin.ts';
|
||||
import Events from '../../utils/events.ts';
|
||||
|
||||
import 'material-design-icons-iconfont';
|
||||
import '../../elements/emby-button/paper-icon-button-light';
|
||||
|
||||
import html from './template.html';
|
||||
@@ -327,14 +324,16 @@ export class BookPlayer {
|
||||
}
|
||||
};
|
||||
|
||||
const serverId = item.ServerId;
|
||||
const apiClient = ServerConnections.getApiClient(serverId);
|
||||
|
||||
if (!Screenfull.isEnabled) {
|
||||
document.getElementById('btnBookplayerFullscreen').display = 'none';
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
import('epubjs').then(({ default: epubjs }) => {
|
||||
const api = toApi(ServerConnections.getApiClient(item));
|
||||
const downloadHref = getLibraryApi(api).getDownloadUrl({ itemId: item.Id });
|
||||
const downloadHref = apiClient.getItemDownloadUrl(item.Id);
|
||||
const book = epubjs(downloadHref, { openAs: 'epub' });
|
||||
|
||||
// We need to calculate the height of the window beforehand because using 100% is not accurate when the dialog is opening.
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
|
||||
import { Archive } from 'libarchive.js';
|
||||
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
||||
|
||||
import loading from '../../components/loading/loading';
|
||||
import dialogHelper from '../../components/dialogHelper/dialogHelper';
|
||||
import keyboardnavigation from '../../scripts/keyboardNavigation';
|
||||
import { appRouter } from '../../components/router/appRouter';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import * as userSettings from '../../scripts/settings/userSettings';
|
||||
import { PluginType } from '../../types/plugin.ts';
|
||||
|
||||
@@ -291,12 +287,14 @@ export class ComicsPlayer {
|
||||
|
||||
loading.show();
|
||||
|
||||
const serverId = item.ServerId;
|
||||
const apiClient = ServerConnections.getApiClient(serverId);
|
||||
|
||||
Archive.init({
|
||||
workerUrl: appRouter.baseUrl() + '/libraries/worker-bundle.js'
|
||||
});
|
||||
|
||||
const api = toApi(ServerConnections.getApiClient(item));
|
||||
const downloadUrl = getLibraryApi(api).getDownloadUrl({ itemId: item.Id });
|
||||
const downloadUrl = apiClient.getItemDownloadUrl(item.Id);
|
||||
this.archiveSource = new ArchiveSource(downloadUrl);
|
||||
|
||||
//eslint-disable-next-line import/no-unresolved
|
||||
|
||||
@@ -1309,7 +1309,7 @@ export class HtmlVideoPlayer {
|
||||
dropAllAnimations: false,
|
||||
libassMemoryLimit: 40,
|
||||
libassGlyphLimit: 40,
|
||||
targetFps: videoStream?.ReferenceFrameRate || 24,
|
||||
targetFps: videoStream?.ReferenceFrameRate || videoStream?.RealFrameRate || 24,
|
||||
prescaleFactor: 0.8,
|
||||
prescaleHeightLimit: 1080,
|
||||
maxRenderHeight: 2160,
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
|
||||
|
||||
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
||||
|
||||
import loading from '../../components/loading/loading';
|
||||
import keyboardnavigation from '../../scripts/keyboardNavigation';
|
||||
import dialogHelper from '../../components/dialogHelper/dialogHelper';
|
||||
@@ -209,9 +205,11 @@ export class PdfPlayer {
|
||||
}
|
||||
};
|
||||
|
||||
const serverId = item.ServerId;
|
||||
const apiClient = ServerConnections.getApiClient(serverId);
|
||||
|
||||
return import('pdfjs-dist').then(({ GlobalWorkerOptions, getDocument }) => {
|
||||
const api = toApi(ServerConnections.getApiClient(item));
|
||||
const downloadHref = getLibraryApi(api).getDownloadUrl({ itemId: item.Id });
|
||||
const downloadHref = apiClient.getItemDownloadUrl(item.Id);
|
||||
|
||||
this.bindEvents();
|
||||
GlobalWorkerOptions.workerSrc = appRouter.baseUrl() + '/libraries/pdf.worker.js';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import * as userSettings from '../../scripts/settings/userSettings';
|
||||
import { PluginType } from 'types/plugin.ts';
|
||||
|
||||
export default class PhotoPlayer {
|
||||
@@ -25,9 +24,7 @@ export default class PhotoPlayer {
|
||||
interval: 11000,
|
||||
interactive: true,
|
||||
// playbackManager.shuffle has no options. So treat 'shuffle' as a 'play' action
|
||||
autoplay: {
|
||||
delay: userSettings.slideshowInterval() * 1000
|
||||
},
|
||||
autoplay: options.autoplay || options.shuffle,
|
||||
user: result
|
||||
});
|
||||
|
||||
|
||||
1
src/scripts/browser.d.ts
vendored
1
src/scripts/browser.d.ts
vendored
@@ -21,6 +21,7 @@ declare namespace browser {
|
||||
export let animate: boolean;
|
||||
export let hisense: boolean;
|
||||
export let tizen: boolean;
|
||||
export let vega: boolean;
|
||||
export let vidaa: boolean;
|
||||
export let web0s: boolean;
|
||||
export let titanos: boolean;
|
||||
|
||||
@@ -235,10 +235,10 @@ const uaMatch = function (ua) {
|
||||
}
|
||||
|
||||
return {
|
||||
browser: browser,
|
||||
version: version,
|
||||
browser,
|
||||
version,
|
||||
platform: platformMatch[0] || '',
|
||||
versionMajor: versionMajor
|
||||
versionMajor
|
||||
};
|
||||
};
|
||||
|
||||
@@ -283,10 +283,11 @@ export const detectBrowser = (userAgent = navigator.userAgent) => {
|
||||
browser.animate = typeof document !== 'undefined' && document.documentElement.animate != null;
|
||||
browser.hisense = normalizedUA.includes('hisense');
|
||||
browser.tizen = normalizedUA.includes('tizen') || window.tizen != null;
|
||||
browser.vega = normalizedUA.includes('kepler');
|
||||
browser.vidaa = normalizedUA.includes('vidaa');
|
||||
browser.web0s = isWeb0s(normalizedUA);
|
||||
|
||||
browser.tv = browser.ps4 || browser.xboxOne || isTv(normalizedUA);
|
||||
browser.tv = browser.ps4 || browser.vega || browser.xboxOne || isTv(normalizedUA);
|
||||
browser.operaTv = browser.tv && normalizedUA.includes('opr/');
|
||||
|
||||
browser.edgeUwp = (browser.edge || browser.edgeChromium) && (normalizedUA.includes('msapphost') || normalizedUA.includes('webview'));
|
||||
@@ -305,9 +306,15 @@ export const detectBrowser = (userAgent = navigator.userAgent) => {
|
||||
delete browser.chrome;
|
||||
delete browser.safari;
|
||||
} else if (browser.titanos) {
|
||||
// UserAgent string contains 'Opr' and 'Safari', but we only want 'titanos' to be true
|
||||
// UserAgent string contains 'Opr' and 'Safari', but we only want 'titanos' to be true
|
||||
delete browser.operaTv;
|
||||
delete browser.safari;
|
||||
} else if (browser.vega) {
|
||||
// UserAgent string contains 'Chrome' and 'Safari', but we only want 'vega' to be true
|
||||
delete browser.chrome;
|
||||
delete browser.safari;
|
||||
// UserAgent string contains 'Mobile Chrome', but it is a TV
|
||||
delete browser.mobile;
|
||||
} else {
|
||||
browser.orsay = normalizedUA.includes('smarthub');
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { detectBrowser } from './browser';
|
||||
|
||||
describe('Browser', () => {
|
||||
it('should identify TitanOS devices', async () => {
|
||||
it('should identify TitanOS devices', () => {
|
||||
// Ref: https://docs.titanos.tv/user-agents-specifications
|
||||
// Philips example
|
||||
let browser = detectBrowser('Mozilla/5.0 (Linux armv7l) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.4147.62 Safari/537.36 OPR/46.0.2207.0 OMI/4.24, TV_NT72690_2025_4K /<SW version> (Philips, <CTN>, wired) CE-HTML/1.0 NETTV/4.6.0.8 SignOn/2.0 SmartTvA/5.0.0 TitanOS/3.0 en Ginga');
|
||||
@@ -20,7 +20,17 @@ describe('Browser', () => {
|
||||
expect(browser.tv).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify Xbox devices', async () => {
|
||||
it('should identify Vega devices', () => {
|
||||
// Ref: https://developer.amazon.com/docs/vega/0.21/webview-development-best-practices-tv.html#avoid-relying-on-the-useragent
|
||||
const browser = detectBrowser('Mozilla/5.0 (Linux; Kepler 1.1; AFTCA002 user/1234; wv) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Chrome/130.0.6723.192 Safari/537.36');
|
||||
expect(browser.vega).toBe(true);
|
||||
expect(browser.chrome).toBeFalsy();
|
||||
expect(browser.safari).toBeFalsy();
|
||||
expect(browser.mobile).toBeFalsy();
|
||||
expect(browser.tv).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify Xbox devices', () => {
|
||||
const browser = detectBrowser('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0 WebView2 Xbox');
|
||||
expect(browser.xboxOne).toBe(true);
|
||||
expect(browser.tv).toBe(true);
|
||||
|
||||
@@ -24,6 +24,11 @@ function canPlayHevc(videoTestElement, options) {
|
||||
}
|
||||
|
||||
function canPlayAv1(videoTestElement) {
|
||||
// Xbox UWP WebView2 falsely reports AV1 support but cannot play it
|
||||
if (browser.xboxOne) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (browser.tizenVersion >= 5.5 || browser.web0sVersion >= 5) {
|
||||
return true;
|
||||
}
|
||||
@@ -170,6 +175,11 @@ function canPlayAudioFormat(format) {
|
||||
return true;
|
||||
}
|
||||
} else if (format === 'opus') {
|
||||
// Xbox UWP WebView2 falsely reports Opus support but cannot play it
|
||||
if (browser.xboxOne) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (browser.web0s) {
|
||||
// canPlayType lies about OPUS support
|
||||
return browser.web0sVersion >= 3.5;
|
||||
@@ -296,11 +306,6 @@ function supportedDolbyVisionProfileAv1(videoTestElement) {
|
||||
return videoTestElement.canPlayType?.('video/mp4; codecs="dav1.10.06"').replace(/no/, '');
|
||||
}
|
||||
|
||||
function supportsAnamorphicVideo() {
|
||||
// Tizen applies the aspect ratio correctly
|
||||
return browser.tizenVersion >= 6;
|
||||
}
|
||||
|
||||
function getDirectPlayProfileForVideoContainer(container, videoAudioCodecs, videoTestElement, options) {
|
||||
let supported = false;
|
||||
let profileContainer = container;
|
||||
@@ -1246,6 +1251,12 @@ export default function (options) {
|
||||
}
|
||||
|
||||
const h264CodecProfileConditions = [
|
||||
{
|
||||
Condition: 'NotEquals',
|
||||
Property: 'IsAnamorphic',
|
||||
Value: 'true',
|
||||
IsRequired: false
|
||||
},
|
||||
{
|
||||
Condition: 'EqualsAny',
|
||||
Property: 'VideoProfile',
|
||||
@@ -1267,6 +1278,12 @@ export default function (options) {
|
||||
];
|
||||
|
||||
const hevcCodecProfileConditions = [
|
||||
{
|
||||
Condition: 'NotEquals',
|
||||
Property: 'IsAnamorphic',
|
||||
Value: 'true',
|
||||
IsRequired: false
|
||||
},
|
||||
{
|
||||
Condition: 'EqualsAny',
|
||||
Property: 'VideoProfile',
|
||||
@@ -1297,6 +1314,12 @@ export default function (options) {
|
||||
];
|
||||
|
||||
const av1CodecProfileConditions = [
|
||||
{
|
||||
Condition: 'NotEquals',
|
||||
Property: 'IsAnamorphic',
|
||||
Value: 'true',
|
||||
IsRequired: false
|
||||
},
|
||||
{
|
||||
Condition: 'EqualsAny',
|
||||
Property: 'VideoProfile',
|
||||
@@ -1317,29 +1340,6 @@ export default function (options) {
|
||||
}
|
||||
];
|
||||
|
||||
if (!supportsAnamorphicVideo()) {
|
||||
h264CodecProfileConditions.push({
|
||||
Condition: 'NotEquals',
|
||||
Property: 'IsAnamorphic',
|
||||
Value: 'true',
|
||||
IsRequired: false
|
||||
});
|
||||
|
||||
hevcCodecProfileConditions.push({
|
||||
Condition: 'NotEquals',
|
||||
Property: 'IsAnamorphic',
|
||||
Value: 'true',
|
||||
IsRequired: false
|
||||
});
|
||||
|
||||
av1CodecProfileConditions.push({
|
||||
Condition: 'NotEquals',
|
||||
Property: 'IsAnamorphic',
|
||||
Value: 'true',
|
||||
IsRequired: false
|
||||
});
|
||||
}
|
||||
|
||||
if (!browser.edgeUwp && !browser.tizen && !browser.web0s) {
|
||||
h264CodecProfileConditions.push({
|
||||
Condition: 'NotEquals',
|
||||
|
||||
@@ -464,19 +464,6 @@ export class UserSettings {
|
||||
return parseInt(this.get('backdropScreensaverInterval', false), 10) || 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or set the interval between slides when using the slideshow.
|
||||
* @param {number|undefined} [val] - The interval between slides in seconds.
|
||||
* @return {number} The interval between slides in seconds.
|
||||
*/
|
||||
slideshowInterval(val) {
|
||||
if (val !== undefined) {
|
||||
return this.set('slideshowInterval', val.toString(), false);
|
||||
}
|
||||
|
||||
return parseInt(this.get('slideshowInterval', false), 10) || 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or set the amount of time it takes to activate the screensaver in seconds. Default 3 minutes.
|
||||
* @param {number|undefined} [val] - The amount of time it takes to activate the screensaver in seconds.
|
||||
@@ -718,7 +705,6 @@ export const skin = currentSettings.skin.bind(currentSettings);
|
||||
export const theme = currentSettings.theme.bind(currentSettings);
|
||||
export const screensaver = currentSettings.screensaver.bind(currentSettings);
|
||||
export const backdropScreensaverInterval = currentSettings.backdropScreensaverInterval.bind(currentSettings);
|
||||
export const slideshowInterval = currentSettings.slideshowInterval.bind(currentSettings);
|
||||
export const screensaverTime = currentSettings.screensaverTime.bind(currentSettings);
|
||||
export const libraryPageSize = currentSettings.libraryPageSize.bind(currentSettings);
|
||||
export const maxDaysForNextUp = currentSettings.maxDaysForNextUp.bind(currentSettings);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -121,7 +121,7 @@
|
||||
"HeaderConnectionFailure": "فشل في الاتصال",
|
||||
"HeaderContainerProfile": "عريضة الحاوية",
|
||||
"HeaderContainerProfileHelp": "تشير ملفات تعريف الحاوية إلى قيود الجهاز عند تشغيل تنسيقات معينة. إذا تم تطبيق قيود ، فسيتم تحويل ترميز الوسائط ، حتى إذا تم تكوين التنسيق للتشغيل المباشر.",
|
||||
"HeaderContinueWatching": "متابعة المشاهدة",
|
||||
"HeaderContinueWatching": "إستئناف المشاهدة",
|
||||
"HeaderCustomDlnaProfiles": "الحسابات المخصوصة",
|
||||
"HeaderDateIssued": "تاريخ الإصدار",
|
||||
"HeaderDefaultRecordingSettings": "إعدادات التسجيل الافتراضية",
|
||||
@@ -226,7 +226,7 @@
|
||||
"HeaderXmlSettings": "إعدادات xml",
|
||||
"HeaderYears": "السنوات",
|
||||
"ImportFavoriteChannelsHelp": "فقط القنوات التي علّمت في المفضلة على جهاز المولف ستورد.",
|
||||
"LabelAbortedByServerShutdown": "تم إهماله بسبب إغلاق الخادم",
|
||||
"LabelAbortedByServerShutdown": "(تم إهماله بسبب عملية إغلاق الخادم)",
|
||||
"LabelAccessDay": "يوم الأسبوع",
|
||||
"LabelAccessEnd": "وقت النهاية",
|
||||
"LabelAccessStart": "وقت البداية",
|
||||
@@ -558,7 +558,7 @@
|
||||
"OptionAllowRemoteControlOthers": "السماح بالتحكم في المستخدمين الآخرين عن بعد",
|
||||
"OptionAllowRemoteSharedDevices": "السماح بالتحكم في الأجهزة المشاركة عن بعد",
|
||||
"OptionAllowRemoteSharedDevicesHelp": "أجهزة Dlna ستعتبر مشاركة إلى أن يبدأ مستخدم ما بالتحكم بها.",
|
||||
"OptionAllowUserToManageServer": "اسمح لهذا المستخدم بالتحكم بالخادم",
|
||||
"OptionAllowUserToManageServer": "إسمح لهذا المستخدم بالتحكم بالخادم",
|
||||
"OptionAllowVideoPlaybackRemuxing": "تمكين تشغيل الفيديو الذي يحتاج إلى التحويل من غير تشفير",
|
||||
"OptionAllowVideoPlaybackTranscoding": "تمكين تشغيل الفيديو الذي يحتاج تشفيراً بينياً",
|
||||
"OptionAutomaticallyGroupSeries": "إدمج الحلقات الموزعة بين عدة مجلدات إلى مجلد واحد تلقائياً",
|
||||
@@ -748,7 +748,7 @@
|
||||
"Genres": "التصنيفات",
|
||||
"Folders": "المجلدات",
|
||||
"Favorites": "المفضلة",
|
||||
"Collections": "مجموعات",
|
||||
"Collections": "المجموعات",
|
||||
"Categories": "الفئات",
|
||||
"CancelSeries": "إلغاء المسلسل",
|
||||
"CancelRecording": "إلغاء التسجيل",
|
||||
@@ -770,7 +770,7 @@
|
||||
"AspectRatio": "النسبة البعدينية",
|
||||
"Ascending": "تصاعدي",
|
||||
"AsManyAsPossible": "أكبر عدد ممكن",
|
||||
"Artists": "فنانون",
|
||||
"Artists": "الفنانون",
|
||||
"Art": "فن",
|
||||
"Anytime": "أي وقت",
|
||||
"AnyLanguage": "أي لغة",
|
||||
@@ -803,7 +803,7 @@
|
||||
"ConfirmDeleteItems": "حذف هذه العناصر سوف يحذفها من نظام الملفات ومن مكتبة الوسائط. هل ترغب حقاً فى الاستمرار؟",
|
||||
"EveryNDays": "كل {0} يوم",
|
||||
"ConfirmDeleteItem": "حذف هذا العنصر سوف يحذفه من نظام الملفات ومن مكتبة الوسائط. هل ترغب حقاً فى الاستمرار؟",
|
||||
"DropShadow": "الظل المنسدل",
|
||||
"DropShadow": "ظل خلفي",
|
||||
"LabelDropShadow": "اسقاط الظل",
|
||||
"EditSubtitles": "تعديل الترجمات",
|
||||
"EditMetadata": "تعديل البيانات التعريفية",
|
||||
@@ -869,7 +869,7 @@
|
||||
"DirectStreamHelp2": "استهلاك الطاقة عن طريق البث المباشر عادةً يعتمد على ملف الصوت . فقط بث الفديو لن يشهد أي تغيير في الجودة.",
|
||||
"DirectStreamHelp1": "يَدعم جهازك تشغيل هذا النوع من المقاطع المرئية، ولكن إما ١. جهازك لا يدعم تشغيل إحدى المقاطع الصوتية التالية: (DTS، Dolby، TrueHD، إلخ)، أو، ٢. جهازك لا يدعم تشغيل هذا العدد من القنوات الصوتية. سيُنسخ المقطع المرئي إلى حاوية أخرى دون إعادة ترميزه، ولكن سيُعاد ترميز المقطع الصوتي ترميزا متناسب مع جهازك، ومن ثم سيُرسل إليك لمشاهدته.",
|
||||
"DetectingDevices": "يتم الكشف عن الأجهزة",
|
||||
"Desktop": "سطح مكتب (تراثي)",
|
||||
"Desktop": "سطح المكتب",
|
||||
"Descending": "تنازلي",
|
||||
"Depressed": "منخفض",
|
||||
"DeinterlaceMethodHelp": "حدد أي أسلوب تود استخدامه لفك التشابك عندما تُرمز باستخدام المعالج، أما عندما يدعم كرت الشاشة فك التشابك باستخدامه؛ فسيُستخدم عوضا عن هذا الأسلوب المُختار.",
|
||||
@@ -886,7 +886,7 @@
|
||||
"ButtonTogglePlaylist": "قائمة التشغيل",
|
||||
"BoxSet": "طقم",
|
||||
"ButtonSplit": "تقسيم",
|
||||
"AllowFfmpegThrottlingHelp": "عندما يتقدم الترميز أو إعادة المزج بشكل كافٍ عن موضع التشغيل الحالي، أوقف العملية مؤقتًا لتقليل استهلاك الموارد. هذا مفيد جدًا عند المشاهدة دون البحث المتكرر. أوقف هذه الميزة إذا واجهت مشاكل في التشغيل.",
|
||||
"AllowFfmpegThrottlingHelp": "عند تقدم اي تحويل كود او remux لمسافة مناسبة امام نقطة اعادة التشغيل الحالية، اوقف العملية حتى يتم استهلاك موارد أقل. هذا سوف يكون مفيد عندما يتم المشاهدة بدون التقدم بشكل مستمر. قم بإطفاء الخاصية هذه عندما تواجه مشاكل في إعادة التشغيل.",
|
||||
"InstallingPackage": "تثبيت {0} (الإصدار {1})",
|
||||
"Images": "الصور",
|
||||
"Identify": "التعرف على الوسائط",
|
||||
@@ -1488,7 +1488,7 @@
|
||||
"OptionSubstring": "سلسلة فرعية",
|
||||
"OptionForceRemoteSourceTranscoding": "فرض تحويل ترميز مصادر الوسائط البعيدة مثل البث التلفزيوني المباشر",
|
||||
"NoNewDevicesFound": "لم يتم العثور على أجهزة جديدة. لإضافة موالف جديد ، أغلق مربع الحوار هذا وأدخل معلومات الجهاز.",
|
||||
"Mobile": "نقّال (تراثي)",
|
||||
"Mobile": "متحرك",
|
||||
"Mixer": "خلاط",
|
||||
"MessageSyncPlayNoGroupsAvailable": "لا توجد مجموعات متاحة. ابدأ تشغيل شيء ما أولاً.",
|
||||
"MessageSyncPlayErrorNoActivePlayer": "لم يتم العثور على مشغل نشط. تم تعطيل SyncPlay.",
|
||||
@@ -1500,7 +1500,7 @@
|
||||
"MediaInfoColorTransfer": "نقل اللون",
|
||||
"MediaInfoColorSpace": "مساحة اللون",
|
||||
"MediaInfoColorPrimaries": "الألوان الأساسية",
|
||||
"LanNetworksHelp": "قائمة مفصولة بفواصل لعناوين IP أو مدخلات قناع الشبكة للشبكات التي ستُعتبر ضمن الشبكة المحلية عند فرض قيود النطاق الترددي والوصول عن بُعد. إذا تُركت فارغة، تُعتبر جميع عناوين RFC1918 محلية.",
|
||||
"LanNetworksHelp": "قائمة مفصولة بفواصل لعناوين IP أو إدخالات IP / قناع الشبكة للشبكات التي سيتم أخذها في الاعتبار على الشبكة المحلية عند فرض قيود النطاق الترددي. في حالة الضبط ، سيتم اعتبار جميع عناوين IP الأخرى على الشبكة الخارجية وستخضع لقيود النطاق الترددي الخارجي. إذا تُركت فارغة ، فسيتم اعتبار الشبكة الفرعية للخادم فقط على الشبكة المحلية.",
|
||||
"LabelVersion": "إصدار",
|
||||
"LabelUserRemoteClientBitrateLimitHelp": "تجاوز القيمة العامة الافتراضية المعينة في إعدادات الخادم ، راجع لوحة الاعدادت> التشغيل> تدفق.",
|
||||
"LabelTVHomeScreen": "الشاشة الرئيسية الخاصة بوضع التلفزيون",
|
||||
@@ -1531,7 +1531,7 @@
|
||||
"LabelLockItemToPreventChanges": "قفل هذا العنصر لمنع تغيير البيانات الوصفية مستقبلا",
|
||||
"LabelMaxDaysForNextUpHelp": "يضبط الحد الأقصى لعدد الأيام التي يجب أن يبقى فيها العرض في قائمة 'التالي' دون مشاهدته.",
|
||||
"LabelMaxDaysForNextUp": "الحد الأقصى للأيام في 'التالي'",
|
||||
"LabelLibraryPageSizeHelp": "حدّد عدد العناصر المعروضة على صفحة المكتبة. سيؤدي تعيين القيمة 0 إلى تعطيل الترقيم. يُرجى العلم أن تعيين هذه القيمة بـ 0 أو أي قيمة أكبر من 100 قد يؤدي إلى أخطاء وانخفاض في الأداء.",
|
||||
"LabelLibraryPageSizeHelp": "يضبط مقدار العناصر التي سيتم عرضها على صفحة المكتبة. قم بالتعيين إلى 0 لتعطيل الترحيل.",
|
||||
"LabelLibraryPageSize": "حجم صفحة المكتبة",
|
||||
"LabelKnownProxies": "الوكلاء المعروفون",
|
||||
"LabelKeepUpTo": "حافظ على ما يصل إلى",
|
||||
@@ -1918,7 +1918,7 @@
|
||||
"LimitSupportedVideoResolutionHelp": "استخدم ”الدقة القصوى المسموح بها لتحويل ترميز الفيديو“ كحد أقصى لدقة الفيديو المدعومة.",
|
||||
"LogLevel.Trace": "تتبع",
|
||||
"MediaSegmentProvidersHelp": "قم بتمكين وترتيب موفري قطاع الوسائط المفضلين لديك حسب الأولوية.",
|
||||
"MessageCancelSeriesTimerError": "حدث خطأ أثناء إلغاء المؤقت المُتسلسل",
|
||||
"MessageCancelSeriesTimerError": "حدث خطأ أثناء إلغاء المؤقت المتسلسل",
|
||||
"MessageCancelTimerError": "حدث خطأ أثناء إلغاء المؤقت",
|
||||
"MetadataNfoLoadError": "فشل في تحميل إعدادات NFO للبيانات الوصفية",
|
||||
"MoveToTop": "انتقل إلى الأعلى",
|
||||
@@ -1952,7 +1952,7 @@
|
||||
"MediaSegmentType.Recap": "ملخص",
|
||||
"MessageSplitVersionsError": "حدث خطأ أثناء تقسيم الإصدارات",
|
||||
"MoviesAndShows": "الأفلام و المسلسلات التلفزيونية",
|
||||
"Native": "أصلي",
|
||||
"Native": "اصلي",
|
||||
"PluginLoadRepoError": "حدث خطأ أثناء الحصول على تفاصيل المكون الإضافي من المستودع.",
|
||||
"ReadInputAtNativeFramerateHelp": "قد يؤدي تمكين هذا الخيار إلى حل بعض المشكلات، مثل توقف البث المباشر قبل الأوان.",
|
||||
"Regional": "إقليمي",
|
||||
@@ -2055,10 +2055,5 @@
|
||||
"LabelBundled": "حزمة",
|
||||
"LiveTVPageLoadError": "فشل تحميل صفحة التسجيلات",
|
||||
"MessageServerMismatch": "الخادم الذي تتصل به ليس هو نفسه الخادم الذي اتصلت به سابقًا على هذا العنوان. إذا كان هذا متوقعًا، يُرجى اختيار \"الاتصال على أي حال\". وإلا، فقد يكون اتصالك أو الخادم معرضًا للخطر.",
|
||||
"ViewAllPlugins": "عرض جميع الإضافات",
|
||||
"ButtonAddProvider": "إضافة مزود",
|
||||
"ButtonAddTunerDevice": "إضافة جهاز موالف",
|
||||
"ManageRepositories": "إدارة المستودعات",
|
||||
"LabelSlideshowInterval": "مدة عرض صور",
|
||||
"LabelSlideshowIntervalHelp": "هذا الوقت بالثواني لاستعراض كل صورة في العرض."
|
||||
"ViewAllPlugins": "عرض جميع الإضافات"
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"Default": "По подразбиране",
|
||||
"Delete": "Премахване",
|
||||
"DeleteMedia": "Изтриване на медията",
|
||||
"Desktop": "Работен плот (Стар)",
|
||||
"Desktop": "Работен плот",
|
||||
"DeviceAccessHelp": "Това се отнася само за устройства, които могат да бъдат различени и няма да попречи на достъп от мрежов четец. Филтрирането на потребителски устройства ще предотврати използването им докато не бъдат одобрени тук.",
|
||||
"Director": "Режисьор",
|
||||
"Directors": "Режисьори",
|
||||
@@ -414,7 +414,7 @@
|
||||
"MetadataManager": "Управление на метаданните",
|
||||
"MinutesAfter": "минути след",
|
||||
"MinutesBefore": "минути преди",
|
||||
"Mobile": "Мобилно устройство (Старо)",
|
||||
"Mobile": "Мобилно устройство",
|
||||
"Monday": "Понеделник",
|
||||
"MoreFromValue": "Още от {0}",
|
||||
"MoreUsersCanBeAddedLater": "Можете да добавите още потребители от таблото.",
|
||||
@@ -501,7 +501,7 @@
|
||||
"PreferEmbeddedTitlesOverFileNames": "Да се предпочитат вградените заглавия пред имената на файлове",
|
||||
"Premiere": "Премиера",
|
||||
"Premieres": "Премиери",
|
||||
"Primary": "Основен",
|
||||
"Primary": "Главно",
|
||||
"Producer": "Продуцент",
|
||||
"Programs": "Програми",
|
||||
"Quality": "Качество",
|
||||
@@ -657,7 +657,7 @@
|
||||
"ButtonFullscreen": "На цял екран",
|
||||
"AllowOnTheFlySubtitleExtraction": "Позволи моментално извличане на поднадписи",
|
||||
"AllowHWTranscodingHelp": "Позволява на тунера да прекодира моментално. Това може да помогне за редуциране на прекодирането от сървъра.",
|
||||
"Absolute": "Абсолютен",
|
||||
"Absolute": "Aбсолютен",
|
||||
"LabelLanNetworks": "Локални мрежи",
|
||||
"LabelKodiMetadataSaveImagePathsHelp": "Това е препоръчително, ако наименованието на изображенията не са съобразени с изискванията на Kodi.",
|
||||
"LabelKodiMetadataSaveImagePaths": "Записване на пътеките към изображенията в NFO файловете",
|
||||
@@ -973,7 +973,7 @@
|
||||
"LabelLoginDisclaimer": "Предупреждение при вход",
|
||||
"LabelLockItemToPreventChanges": "Заключи този елемент, за да предотвратиш бъдещи промени в метаданните",
|
||||
"LabelLineup": "Редица",
|
||||
"LabelLibraryPageSizeHelp": "Настройка на броя елементи показвани в една страница от библиотеката. Стойност 0 ще премахне ограничението. Имайте предвид, че стойност 0 или по-голяма от 100 може да доведе до проблеми с производителността.",
|
||||
"LabelLibraryPageSizeHelp": "Настройка на броя елементи показвани в една страница от библиотеката. Въведете 0, за да забраните номерирането.",
|
||||
"LabelLibraryPageSize": "Размер на страницата на библиотеката",
|
||||
"LabelKodiMetadataUser": "Запази данните за активността на потребителя в файл тип NFO за",
|
||||
"LabelKodiMetadataEnablePathSubstitutionHelp": "Активирай подмяната на пътя за изображения, използвайки конфигурираните настройки за подмяна на сървъра.",
|
||||
@@ -1117,7 +1117,7 @@
|
||||
"LiveBroadcasts": "Предавания на живо",
|
||||
"LeaveBlankToNotSetAPassword": "Можете да оставите това поле празно и да не задавате парола.",
|
||||
"LearnHowYouCanContribute": "Научете как можете да допринесете.",
|
||||
"LanNetworksHelp": "Списък, разделен със запетая, съдържащ ИП адреси или записи за ИП/мрежова маски , които ще се считат за локални, когато се налагат ограничения в трафика и отдалечения достъп. Ако полето е празно ще се счита, че всички RFC1918 адреси са локални.",
|
||||
"LanNetworksHelp": "Списък разделен със запетая съдържащ ИП адреси или записи за ИП/мрежова маски отнасящи се за мрежи ,които ще се считат за локални ,когато се налагат ограничения в честотната лента.Ако е зададено всички други ИП адреси ще се считат за принадлежащи към външни мрежи и за тях ще важат правилата за ограничения на външни ИП -та.Ако полето е празно ще се счита ,че само подмрежата на сървъра е част от локалната мрежа.",
|
||||
"LabelffmpegPathHelp": "Пътят към FFmpeg или папката, съдържаща FFmpeg.",
|
||||
"LabelffmpegPath": "Път към FFmpeg",
|
||||
"LabelZipCode": "Пощенски код",
|
||||
@@ -2050,9 +2050,5 @@
|
||||
"LabelTileHeight": "Височина на плочката",
|
||||
"LabelDirectStreamingInfo": "Информация за директно стриймване",
|
||||
"Clip": "Клип",
|
||||
"LabelTileWidthHelp": "Максимален брой изображения в плочка по хоризонтала.",
|
||||
"ButtonAddProvider": "Добави Източник",
|
||||
"ButtonAddTunerDevice": "Добави тунер",
|
||||
"LabelSlideshowInterval": "Интервал слайдшоу",
|
||||
"LabelSlideshowIntervalHelp": "Времето (в секунди) за което всяка картина се показва в слайдшоуто."
|
||||
"LabelTileWidthHelp": "Максимален брой изображения в плочка по хоризонтала."
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"Album": "অ্যালবাম",
|
||||
"AirDate": "উন্মুক্তের তারিখ",
|
||||
"AdditionalNotificationServices": "আরো নোটিফিকেশন সার্ভিস ইনস্টল করতে প্লাগিন ক্যাটালগে ব্রাউস করুন।",
|
||||
"AddedOnValue": "যোগ হয়েছে {0}",
|
||||
"AddedOnValue": "এডেড {0}",
|
||||
"AddToPlaylist": "প্লেলিস্টে অ্যাড করুন",
|
||||
"AddToPlayQueue": "প্লে কিউ তে অ্যাড করুন",
|
||||
"AddToCollection": "কালেকশন এ অ্যাড করুন",
|
||||
@@ -73,7 +73,7 @@
|
||||
"AllowOnTheFlySubtitleExtraction": "অনদাফ্লাই সাবটাইটেল বেরকরার অনুমতি",
|
||||
"AllowMediaConversion": "মিডিয়া কনভার্টের অনুমতি দিন",
|
||||
"AllowHWTranscodingHelp": "টিউনার কে স্ট্রিম ট্রান্সকোড করার সুযোগ দিন। এটি সার্ভার ট্রান্সকোডিং কমাতে সাহায্য করতে পারে।",
|
||||
"Aired": "উন্মুক্ত হয়েছে",
|
||||
"Aired": "উন্মুক্ত করন",
|
||||
"Add": "যোগ",
|
||||
"ButtonPreviousTrack": "পূর্ববর্তী ট্র্যাক",
|
||||
"ButtonResume": "রিসিউম",
|
||||
@@ -90,7 +90,7 @@
|
||||
"ConfirmDeleteItems": "এই আইটেমগুলি মোছা ফাইল এবং আপনার মিডিয়া লাইব্রেরি উভয় থেকে মুছে ফেলা হবে। আপনি কি তাই চান?",
|
||||
"ConfirmDeleteItem": "এই আইটেমটি মোছার ফলে এটি ফাইল সিস্টেম এবং আপনার মিডিয়া লাইব্রেরি উভয় থেকে মুছে যাবে। আপনি কি তাই চান?",
|
||||
"ConfirmDeleteImage": "ছবি মুছবেন?",
|
||||
"Absolute": "একদম",
|
||||
"Absolute": "গুণাতীত",
|
||||
"CriticRating": "ক্রিটিক রেটিং",
|
||||
"DefaultMetadataLangaugeDescription": "এগুলি আপনার প্রত্যাশিত এবং প্রতি লাইব্রেরির ভিত্তিতে কাস্টমাইজ করা যায়।",
|
||||
"ErrorDefault": "অনুরোধটি প্রক্রিয়াতে একটি সমস্যা হয়েছে । অনুগ্রহ করে একটু পরে আবার চেষ্টা করুন।",
|
||||
@@ -138,11 +138,11 @@
|
||||
"Authorize": "অনুমোদন",
|
||||
"AspectRatio": "এস্পেক্ট রেসিও",
|
||||
"ApiKeysCaption": "বর্তমানে অনুমোদিত এ.পি.আই. কী গুলোর তালিকা",
|
||||
"AllowTonemappingHelp": "টোন-ম্যাপিং HDR ভিডিওকে SDR-এ রূপান্তর করে, তবে ছবির বিস্তারিত ও রঙ যতটা সম্ভব অক্ষুণ্ণ রাখে। বর্তমানে এটি শুধু 10-bit HDR10, HLG এবং DoVi ভিডিওতে কাজ করে। এটি ব্যবহারের জন্য উপযুক্ত GPGPU রানটাইম প্রয়োজন।",
|
||||
"AllowTonemappingHelp": "টোন-ম্যাপিং একটি ভিডিওর গতিশীল পরিসরকে ছবির বিবরণ এবং রঙ বজায় রেখে HDR থেকে SDR-তে রূপান্তরিত করতে পারে। বর্তমানে শুধুমাত্র 10bit HDR10, HLG এবং DoVi ভিডিওগুলির সাথে কাজ করে৷ যার জন্য সংশ্লিষ্ট OpenCL বা CUDA প্রয়োজন।",
|
||||
"AllowOnTheFlySubtitleExtractionHelp": "এমবেডেড সাবটাইটেল ভিডিও থেকে আলাদা করে প্লেইন টেক্সটে ক্লায়েন্টদের কাছে বিতরণ করা যেতে পারে যাতে ভিডিও ট্রান্সকোডিং এড়ানো যায়। কিন্তু কিছু সিস্টেমে এটি দীর্ঘ সময় নিতে পারে এবং এই প্রক্রিয়াটি চলাকালী্ন, ভিডিও প্লেব্যাক থেমে থাকতে পারে। ক্লায়েন্ট ডিভাইস দ্বারা সমর্থিত না হলে ভিডিও ট্রান্সকোডিংয়ের সাথে এম্বেড করা সাবটাইটেলগুলিকে বার্ন করার জন্য অপশনটি ডিসেবল করুন.",
|
||||
"AllowMediaConversionHelp": "কনভার্ট মিডিয়া ফিচারটির জন্য অনুমোদন দিন বা বাতিল করুন।",
|
||||
"AllowFfmpegThrottlingHelp": "বর্তমান প্লেব্যাক অবস্থান থেকে ট্রান্সকোড বা রিমাক্স অনেকটা এগিয়ে গেলে, কম রিসোর্স ব্যবহার করার জন্য প্রক্রিয়াটি সাময়িকভাবে থামানো হবে। আপনি যদি ভিডিও চলাকালীন বেশি সামনে–পেছনে না যান, তাহলে এটি উপকারী। প্লেব্যাক সমস্যা হলে এই সেটিংটি বন্ধ রাখুন।",
|
||||
"AgeValue": "({0} বছর পুরোনো)",
|
||||
"AllowFfmpegThrottlingHelp": "যখন একটি ট্রান্সকোড বা রিমুক্স বর্তমান প্লেব্যাক অবস্থান থেকে যথেষ্ট এগিয়ে যায়, তখন প্রক্রিয়াটি থামানো যাতে এটি কম প্রসেস পাওয়ার গ্রহণ করে। প্রায়ই না টেনে (Seek) দেখার সময় এটি সবচেয়ে কার্যকর। আপনি যদি প্লেব্যাক সমস্যা অনুভব করেন তবে এটি বন্ধ করুন।",
|
||||
"AgeValue": "({0} বছর পুরান)",
|
||||
"AddToFavorites": "পছন্দের তালিকায় যোগ করুন",
|
||||
"ErrorDeletingItem": "সার্ভার থেকে আইটেমটি মুছে ফেলার সময় একটি ত্রুটি ছিল৷ অনুগ্রহ করে পরীক্ষা করুন যে জেলিফিন মিডিয়া ফোল্ডারে লেখার অ্যাক্সেস পেয়েছে এবং আবার চেষ্টা করুন৷",
|
||||
"ErrorAddingXmlTvFile": "XMLTV ফাইল অ্যাক্সেস করার সময় একটি ত্রুটি ছিল৷ অনুগ্রহ করে নিশ্চিত করুন যে ফাইলটি বিদ্যমান এবং আবার চেষ্টা করুন৷",
|
||||
@@ -326,7 +326,7 @@
|
||||
"ColorPrimaries": "কালার প্রাইমারি",
|
||||
"AllowCollectionManagement": "এই ব্যবহারকারীকে কালেকশন্স গুলি পরিচালনা করার অনুমতি দিন",
|
||||
"AllowSegmentDeletion": "অংশগুলি মুছে ফেলুন",
|
||||
"AllowSegmentDeletionHelp": "ক্লায়েন্ট ডাউনলোড করার পর পুরনো সেগমেন্টগুলো স্বয়ংক্রিয়ভাবে মুছে যাবে। এর ফলে পুরো ট্রান্সকোড করা ফাইল ডিস্কে রাখতে হয় না। প্লেব্যাক সমস্যা হলে এই সেটিংটি বন্ধ রাখুন।",
|
||||
"AllowSegmentDeletionHelp": "পুরানো বিভাগগুলি ক্লায়েন্টের কাছে পাঠানোর পরে মুছে ফেলুন। এটি ডিস্কে পুরো ট্রান্সকোডেড ফাইল সংরক্ষণ করতে বাধা দেয়। এটি কেবল থ্রোটলিং সক্ষম করে কাজ করবে। আপনি যদি প্লেব্যাক সমস্যার সম্মুখীন হন তবে এটি বন্ধ করুন।",
|
||||
"AirPlay": "এয়ারপ্লে",
|
||||
"AllowContentWithTagsHelp": "শুধুমাত্র নির্দিষ্ট ট্যাগগুলির মধ্যে অন্তত একটি সহ মিডিয়া দেখান।",
|
||||
"Alternate": "বিকল্প",
|
||||
@@ -336,10 +336,5 @@
|
||||
"AlwaysBurnInSubtitleWhenTranscodingHelp": "ট্রান্সকোডিং ট্রিগার হলে সমস্ত সাবটাইটেল বার্ন করুন। এটি কম ট্রান্সকোডিং গতির খরচে ট্রান্সকোডিংয়ের পরে সাবটাইটেল সিঙ্ক্রোনাইজেশন নিশ্চিত করে।এটি কম ট্রান্সকোডিং গতির খরচে ট্রান্সকোডিংয়ের পরে সাবটাইটেল সিঙ্ক্রোনাইজেশন নিশ্চিত করে।",
|
||||
"AlternateDVD": "বিকল্প DVD",
|
||||
"LabelThrottleDelaySeconds": "থ্রটল পরে",
|
||||
"AlwaysBurnInSubtitleWhenTranscoding": "ট্রান্সকোড করার সময় সর্বদা সাবটাইটেল বার্ন করুন",
|
||||
"LabelThrottleDelaySecondsHelp": "কত সেকেন্ড পর ট্রান্সকোডারের গতি সীমিত করা হবে তা নির্ধারণ করে। ক্লায়েন্টের বাফার ঠিকভাবে বজায় রাখতে এই সময়টি যথেষ্ট বড় হওয়া উচিত। থ্রটলিং চালু থাকলে তবেই এটি কার্যকর হবে।",
|
||||
"LabelSegmentKeepSeconds": "সেগমেন্ট সংরক্ষণের সময়",
|
||||
"LabelSegmentKeepSecondsHelp": "ক্লায়েন্ট ডাউনলোড করার পর কত সেকেন্ড পর্যন্ত সেগমেন্টগুলো সংরক্ষণ করা হবে তা নির্ধারণ করে। সেগমেন্ট মুছে ফেলার অপশন চালু থাকলেই এটি কাজ করবে।",
|
||||
"AlwaysRemuxFlacAudioFilesHelp": "যদি আপনার ব্রাউজার কোনো ফাইল প্লে করতে না পারে বা সময় গণনায় ভুল করে, তাহলে সমস্যা সমাধানের জন্য এই সেটিংটি চালু করুন।",
|
||||
"AlwaysRemuxMp3AudioFilesHelp": "ব্রাউজার যদি টাইমস্ট্যাম্প ভুলভাবে গণনা করে, তাহলে এটি চালু করুন।"
|
||||
"AlwaysBurnInSubtitleWhenTranscoding": "ট্রান্সকোড করার সময় সর্বদা সাবটাইটেল বার্ন করুন"
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
"MessageBrowsePluginCatalog": "Consulteu el nostre catàleg per a veure els complements disponibles.",
|
||||
"ButtonAddMediaLibrary": "Afegiu-hi una mediateca",
|
||||
"ButtonAddScheduledTaskTrigger": "Afegiu-hi un detonador",
|
||||
"ButtonAddServer": "Afegiu-hi un servidor",
|
||||
"ButtonAddUser": "Afegiu-hi un usuari",
|
||||
"ButtonAddServer": "Afegiu un servidor",
|
||||
"ButtonAddUser": "Afegeix un usuari",
|
||||
"ButtonArrowLeft": "Esquerra",
|
||||
"ButtonArrowRight": "Dreta",
|
||||
"ButtonBack": "Enrere",
|
||||
@@ -62,7 +62,7 @@
|
||||
"DeleteImageConfirmation": "N'esteu segur, que voleu suprimir aquesta imatge?",
|
||||
"DeleteMedia": "Elimina el contingut",
|
||||
"DeleteUser": "Esborra l'usuari",
|
||||
"Desktop": "Escriptori (antic)",
|
||||
"Desktop": "Escriptori",
|
||||
"DeviceAccessHelp": "Això només s'aplica a dispositius que poden ser identificats i no previndrà l'accés des del navegador. Filtrant l'accés de dispositius a l'usuari previndrà l'ús de nous dispositius fins que hagin estat aprovats aquí.",
|
||||
"Disconnect": "Desconnecta",
|
||||
"DisplayMissingEpisodesWithinSeasons": "Mostra també els episodis que no tingui a les temporades",
|
||||
@@ -95,7 +95,7 @@
|
||||
"HeaderActivity": "Activitat",
|
||||
"HeaderAddToCollection": "Agregació a una col·lecció",
|
||||
"HeaderAddToPlaylist": "Agregació a llista de reproducció",
|
||||
"HeaderAddUpdateImage": "Afegiu o actualitzeu la imatge",
|
||||
"HeaderAddUpdateImage": "Afegeix o actualitza la imatge",
|
||||
"HeaderAdditionalParts": "Parts addicionals",
|
||||
"HeaderApiKey": "Clau API",
|
||||
"HeaderApiKeys": "Claus API",
|
||||
@@ -280,7 +280,7 @@
|
||||
"LabelManufacturerUrl": "URL del fabricant",
|
||||
"LabelMaxBackdropsPerItem": "Nombre màxim d'imatges de fons per element",
|
||||
"LabelMaxParentalRating": "Classificació màxima permesa de control parental",
|
||||
"LabelMaxResumePercentage": "Percentatge màxim per a continuar",
|
||||
"LabelMaxResumePercentage": "Percentatge màxim per a reprendre",
|
||||
"LabelMaxResumePercentageHelp": "Es considerarà que s'ha reproduït del tot si s'atura després d'aquest temps.",
|
||||
"LabelMaxScreenshotsPerItem": "Nombre màxim de captures de pantalla per ítem:",
|
||||
"LabelMaxStreamingBitrateHelp": "Especifica un bitrate màxim quan es faci streaming.",
|
||||
@@ -292,9 +292,9 @@
|
||||
"LabelMetadataPathHelp": "Especifiqueu un directori personalitzat per a les imatges i metadades descarregades.",
|
||||
"LabelMethod": "Mètode",
|
||||
"LabelMinBackdropDownloadWidth": "Amplada mínima de descàrrega de la imatge de fons",
|
||||
"LabelMinResumeDuration": "Durada mínima per a continuar",
|
||||
"LabelMinResumeDuration": "Durada mínima per a reprendre",
|
||||
"LabelMinResumeDurationHelp": "La durada del vídeo mínima en segons que permetrà desar la ubicació de la reproducció i permetre reprendre-la.",
|
||||
"LabelMinResumePercentage": "Percentatge mínim per a continuar",
|
||||
"LabelMinResumePercentage": "Percentatge mínim per a reprendre",
|
||||
"LabelMinResumePercentageHelp": "Es considerarà que no s'ha reproduït si s'atura abans d'aquest temps.",
|
||||
"LabelMinScreenshotDownloadWidth": "Amplada mínima de descàrrega de la captura de pantalla:",
|
||||
"LabelModelDescription": "Descripció del model",
|
||||
@@ -369,9 +369,9 @@
|
||||
"LatestFromLibrary": "Novetats a {0}",
|
||||
"LibraryAccessHelp": "Trieu les mediateques que voleu compartir amb aquest usuari. Els administradors podran editar totes les carpetes mitjançant el gestor de metadades.",
|
||||
"Live": "Directe",
|
||||
"MarkPlayed": "Marca com a reproduït",
|
||||
"MarkUnplayed": "Marca com a no reproduït",
|
||||
"MaxParentalRatingHelp": "El contingut amb una classificació superior s'amagarà a aquest usuari.",
|
||||
"MarkPlayed": "Marcar com a reproduït",
|
||||
"MarkUnplayed": "Marcar com a no reproduït",
|
||||
"MaxParentalRatingHelp": "El contingut amb una classificació superior s'amagarà per aquest usuari.",
|
||||
"MediaInfoAspectRatio": "Relació d'aspecte",
|
||||
"MediaInfoChannels": "Canals",
|
||||
"MediaInfoDefault": "Per defecte",
|
||||
@@ -384,7 +384,7 @@
|
||||
"MessageConfirmProfileDeletion": "N'estàs segur d'eliminar aquest perfil?",
|
||||
"MessageConfirmRecordingCancellation": "N'esteu segur, que voleu cancel·lar l'enregistrament?",
|
||||
"MessageConfirmRestart": "N'esteu segur, que voleu reiniciar Jellyfin?",
|
||||
"MessageContactAdminToResetPassword": "Contacteu amb l'administrador per a restablir la contrasenya.",
|
||||
"MessageContactAdminToResetPassword": "Si us plau, contacta amb l'administrador del sistema per a reestablir la contrasenya.",
|
||||
"MessageDownloadQueued": "Descàrrega posada a la cua.",
|
||||
"MessageEnablingOptionLongerScans": "Habilitar aquesta opció pot ocasionar escanejos significativament més lents de la mediateca.",
|
||||
"MessageItemSaved": "Element desat.",
|
||||
@@ -399,10 +399,10 @@
|
||||
"MetadataManager": "Gestor de metadades",
|
||||
"MinutesAfter": "minuts després",
|
||||
"MinutesBefore": "minuts abans",
|
||||
"Mobile": "Mòbil (antic)",
|
||||
"Mobile": "Mòbil",
|
||||
"Monday": "Dilluns",
|
||||
"MoreFromValue": "Més de {0}",
|
||||
"MoreUsersCanBeAddedLater": "Després es poden afegir més usuaris des del tauler de control.",
|
||||
"MoreUsersCanBeAddedLater": "Es poden afegir més usuaris des del tauler de control.",
|
||||
"MoveLeft": "Mou a l'esquerra",
|
||||
"MoveRight": "Mou a la dreta",
|
||||
"Mute": "Silencia",
|
||||
@@ -410,7 +410,7 @@
|
||||
"Name": "Nom",
|
||||
"NewCollection": "Col·lecció nova",
|
||||
"NewCollectionHelp": "Les col·leccions us permeten crear agrupacions personalitzades de pel·lícules i altres continguts.",
|
||||
"NewCollectionNameExample": "Exemple: Col·lecció Guerra de les Galàxies",
|
||||
"NewCollectionNameExample": "Exemple: Star Wars - Col·leció",
|
||||
"NewEpisodes": "Episodis nous",
|
||||
"NewEpisodesOnly": "Només episodis nous",
|
||||
"MessageNoNextUpItems": "No se n'ha trobat cap. Comenceu a mirar les vostres sèries!",
|
||||
@@ -722,7 +722,7 @@
|
||||
"DisplayMissingEpisodesWithinSeasonsHelp": "Això també s'ha d'activar per a les mediateques de televisió a la configuració del servidor.",
|
||||
"DisplayInOtherHomeScreenSections": "Mostra a les seccions de la pantalla d'inici, com ara a 'Contingut multimèdia afegit darrerament' i 'Continueu mirant-ho'",
|
||||
"DisplayInMyMedia": "Mostra a la pantalla principal",
|
||||
"Display": "Visualització",
|
||||
"Display": "Pantalla",
|
||||
"Disc": "Disc",
|
||||
"Directors": "Directors",
|
||||
"Director": "Director",
|
||||
@@ -808,7 +808,7 @@
|
||||
"EveryXHours": "Cada {0} hores",
|
||||
"EveryHour": "Cada hora",
|
||||
"EnableTonemapping": "Habilita el mapatge de tons",
|
||||
"DisableCustomCss": "Desactiva el CSS personalitzat proporcionat pel servidor",
|
||||
"DisableCustomCss": "Desactiveu el CSS personalitzat proporcionat pel servidor",
|
||||
"Depressed": "Deprimit",
|
||||
"AudioBitDepthNotSupported": "La profunditat de bits d'àudio no és compatible",
|
||||
"VideoProfileNotSupported": "El perfil del còdec de vídeo no és compatible",
|
||||
@@ -967,7 +967,7 @@
|
||||
"NoSubtitlesHelp": "Els subtítols no es carregaran per defecte. Encara es poden activar de forma manual durant la reproducció.",
|
||||
"Normal": "Normal",
|
||||
"NoNewDevicesFound": "No s'han trobat dispositius nous. Per a afegir un nou sintonitzador, tanqueu aquest avís i introduïu la informació del dispositiu de forma manual.",
|
||||
"NoCreatedLibraries": "Sembla que encara no s'ha creat cap mediateca. {0} Crear-ne una ara? {1}",
|
||||
"NoCreatedLibraries": "Sembla que encara no s'ha creat cap mediateca. {0} Us agradaria crear-ne una ara? {1}",
|
||||
"No": "No",
|
||||
"NextUp": "A continuació",
|
||||
"NextTrack": "Salta al següent",
|
||||
@@ -977,11 +977,11 @@
|
||||
"Never": "Mai",
|
||||
"MusicVideo": "Videoclip",
|
||||
"MusicLibraryHelp": "Revisa la {0} guia de noms de música {1}.",
|
||||
"MusicArtist": "Artista de la música",
|
||||
"MusicArtist": "Artista musical",
|
||||
"MusicAlbum": "Àlbum de música",
|
||||
"MovieLibraryHelp": "Revisa la {0} guia de noms de pel·lícules {1}.",
|
||||
"Movie": "Pel·lícula",
|
||||
"MoreMediaInfo": "Informació del mitjà",
|
||||
"MoreMediaInfo": "Informació multimèdia",
|
||||
"MillisecondsUnit": "ms",
|
||||
"MetadataSettingChangeHelp": "Canviar la configuració de les metadades afectarà el nou contingut afegit a partir d'ara. Per a actualitzar el contingut existent, obriu la pantalla de detalls i feu clic al botó \"Actualitza\" o actualitzeu-lo utilitzant el \"Gestor de metadades\".",
|
||||
"Metadata": "Metadades",
|
||||
@@ -1028,9 +1028,9 @@
|
||||
"MessageFileReadError": "S'ha produït un error en llegir el fitxer. Torneu-ho a provar.",
|
||||
"MessageDirectoryPickerLinuxInstruction": "Per a Linux amb Arch Linux, CentOS, Debian, Fedora, openSUSE, o Ubuntu, heu de concedir a l'usuari del servei com a mínim l'accés de lectura als llocs d'emmagatzematge.",
|
||||
"MessageDirectoryPickerBSDInstruction": "Per a BSD, caldrà tenir configurat l'emmagatzematge del \"FreeNAS Jail\" perquè permeti a Jellyfin accedir al contingut multimèdia.",
|
||||
"MessageDeleteTaskTrigger": "N'esteu segur, que desitgeu eliminar aquest detonador de tasca?",
|
||||
"MessageCreateAccountAt": "Crea un compte a {0}",
|
||||
"MessageConfirmShutdown": "N'esteu segur, que desitgeu apagar el servidor?",
|
||||
"MessageDeleteTaskTrigger": "N'estàs segur que vols eliminar aquest llençador de tasques?",
|
||||
"MessageCreateAccountAt": "Crear un compte a {0}",
|
||||
"MessageConfirmShutdown": "Segur que vols apagar el servidor?",
|
||||
"MessageConfirmRevokeApiKey": "N'esteu segur, de voler revocar aquesta clau d'API? La connexió de l'aplicació d'aquest servidor s'interromprà abruptament.",
|
||||
"MessageConfirmRemoveMediaLocation": "N'esteu segur, que voleu suprimir aquesta ubicació?",
|
||||
"MessageConfirmDeleteTunerDevice": "N'esteu segur, que voleu eliminar aquest dispositiu?",
|
||||
@@ -1062,7 +1062,7 @@
|
||||
"MediaInfoBitrate": "Taxa de bits",
|
||||
"MediaInfoBitDepth": "Profunditat de bits",
|
||||
"MediaInfoAnamorphic": "Anamòrfic",
|
||||
"MapChannels": "Assigna canals",
|
||||
"MapChannels": "Assignar canals",
|
||||
"ManageRecording": "Maneig d'enregistrament",
|
||||
"ManageLibrary": "Gestió de la mediateca",
|
||||
"Logo": "Logotip",
|
||||
@@ -1217,7 +1217,7 @@
|
||||
"LabelMovieCategories": "Categories de pel·lícules",
|
||||
"LabelModelUrl": "Model URL",
|
||||
"LabelMinAudiobookResumeHelp": "Es considera que els títols no s'han començat si la reproducció s'atura abans d'aquest temps.",
|
||||
"LabelMinAudiobookResume": "Continuació mínima de l'audiollibre en minuts",
|
||||
"LabelMinAudiobookResume": "Represa mínima de l'audiollibre en minuts",
|
||||
"LabelMetadataSaversHelp": "Trieu els formats de fitxers a fer servir en desar les metadades.",
|
||||
"LabelMetadataSavers": "Desats de metadades",
|
||||
"LabelMetadataReadersHelp": "Classifica les fonts preferides de metadades locals per ordre de prioritat. El primer fitxer trobat serà llegit.",
|
||||
@@ -1234,14 +1234,14 @@
|
||||
"LabelLocalHttpServerPortNumberHelp": "El número de port TCP per al servidor HTTP.",
|
||||
"LabelLocalCustomCss": "Codi CSS personalitzat per a canvis d'estil que s'apliquen només a aquest client. És possible que vulgueu deshabilitar el servidor personalitzat de codi CSS.",
|
||||
"LabelLineup": "Programació",
|
||||
"LabelLibraryPageSizeHelp": "Estableix la quantitat d'elements a mostrar en una pàgina de la mediateca. Posar-hi un valor de 0 desactivarà la paginació. Tingueu en compte que posar-hi un 0 o qualsevol valor major de 100 pot comportar errors i una reducció del rendiment.",
|
||||
"LabelLibraryPageSizeHelp": "Estableix la quantitat d'elements a mostrar en una pàgina de la mediateca. Es posa a 0 per tal de desactivar la paginació.",
|
||||
"LabelLibraryPageSize": "Mida de la pàgina de la mediateca",
|
||||
"LabelLanNetworks": "Xarxes LAN",
|
||||
"LabelKodiMetadataUserHelp": "Desa les dades de visionament en fitxers NFO de manera que d'altres aplicacions les puguin fer servir.",
|
||||
"LabelKodiMetadataUser": "Desa les dades de visualització d'usuari en fitxers NFO per a",
|
||||
"LabelKodiMetadataSaveImagePathsHelp": "Això està recomanat si teniu fitxers d'imatge que no s'ajusten a les directrius de Kodi.",
|
||||
"LabelKodiMetadataEnableExtraThumbsHelp": "Quan es descarreguin imatges, es poden guardar tant a \"extrafanart\" com a \"extrathumbs\" per a assegurar la màxima compatibilitat amb les aparences de Kodi.",
|
||||
"LabelKodiMetadataEnableExtraThumbs": "Copia extrafanart a les vistes prèvies extres",
|
||||
"LabelKodiMetadataEnableExtraThumbs": "Còpia extrafanart al camp extrathumbs",
|
||||
"LabelKodiMetadataDateFormatHelp": "Totes les dates dins de fitxers NFO seran analitzades fent servir aquest format.",
|
||||
"LabelKnownProxies": "Servidors intermediaris coneguts",
|
||||
"LabelKidsCategories": "Categories infantils",
|
||||
@@ -1315,7 +1315,7 @@
|
||||
"LabelColorPrimaries": "Colors primaris",
|
||||
"LabelChromecastVersion": "Versió de Google Cast",
|
||||
"LabelCertificatePasswordHelp": "Si el vostre certificat requereix una contrasenya, ingresseu-la aquí.",
|
||||
"LabelCertificatePassword": "Contrasenya del certificat",
|
||||
"LabelCertificatePassword": "Verifica la contrasenya",
|
||||
"LabelBurnSubtitles": "Incrusta subtítols",
|
||||
"LabelBlockContentWithTags": "Bloqueja elements amb etiquetes",
|
||||
"LabelBlastMessageIntervalHelp": "Determina la durada en segons entre ràfegues de missatges en directe.",
|
||||
@@ -1546,7 +1546,7 @@
|
||||
"AgeValue": "({0} anys)",
|
||||
"LabelSyncPlaySettingsExtraTimeOffsetHelp": "Ajusteu manualment el temps de decalatge amb el dispositiu seleccionat per a la sincronització del temps. Ajusteu-ho amb cura.",
|
||||
"LabelSyncPlaySettingsDescription": "Canvia les preferències de SyncPlay",
|
||||
"LabelMaxDaysForNextUp": "Màxim de dies en \"A continuació\"",
|
||||
"LabelMaxDaysForNextUp": "Màxim de dies per \"A continuació\"",
|
||||
"LabelHardwareEncoding": "Codificació per maquinari",
|
||||
"ErrorPlayerNotFound": "No s'ha trobat cap reproductor per al fitxer multimèdia sol·licitat.",
|
||||
"SelectAll": "Selecciona-ho tot",
|
||||
@@ -1631,7 +1631,7 @@
|
||||
"LabelSyncPlaySettingsSyncCorrectionHelp": "Habilita la sincronització activa de la reproducció accelerant els continguts multimèdia o cercant la posició estimada. Desactiveu-ho en cas d'intermitències.",
|
||||
"ItemDetails": "Detalls de l'element",
|
||||
"GoogleCastUnsupported": "El Google Cast no és compatible",
|
||||
"EnableRewatchingNextUp": "Habilita tornar-ho a mirar",
|
||||
"EnableRewatchingNextUp": "Habilita tornar a mirar",
|
||||
"EnableRewatchingNextUpHelp": "Habilita la mostra d'episodis ja vists a les seccions \"Més\".",
|
||||
"Digital": "Digital",
|
||||
"CopyFailed": "No es pot copiar",
|
||||
@@ -1662,7 +1662,7 @@
|
||||
"LabelVppTonemappingBrightnessHelp": "Aplica el guany de brillantor al mapatge de tons VPP. El valor recomanat és 16.",
|
||||
"MediaInfoRpuPresentFlag": "Senyalització DV rpu predeterminada",
|
||||
"MediaInfoDvBlSignalCompatibilityId": "Identificador de compatibilitat del senyal DV bl",
|
||||
"Unreleased": "Encara no estrenades",
|
||||
"Unreleased": "Encara no s'ha estrenat",
|
||||
"OptionDateShowAdded": "Data d'addició de la sèrie",
|
||||
"RememberAudioSelectionsHelp": "Intenta establir la pista d'àudio amb la coincidència més semblant a l'últim vídeo.",
|
||||
"RememberSubtitleSelectionsHelp": "Intenta configurar la pista de subtítols amb la coincidència més semblant a l'últim vídeo.",
|
||||
@@ -1744,9 +1744,9 @@
|
||||
"LabelBackdropScreensaverInterval": "Interval de l'estalvi de pantalla de fons",
|
||||
"LabelBackdropScreensaverIntervalHelp": "El temps en segons entre els diferents fons quan s'utilitza l'estalvi de pantalla de fons.",
|
||||
"LabelSyncPlayNoGroups": "No hi ha grups disponibles",
|
||||
"GridView": "Disposició en graella",
|
||||
"GridView": "Vista de graella",
|
||||
"BackdropScreensaver": "Estalvis de pantalla de fons",
|
||||
"ListView": "Disposició en llista",
|
||||
"ListView": "Vista de llista",
|
||||
"LogLevel.Critical": "Crític",
|
||||
"LogLevel.Information": "Informació",
|
||||
"LogLevel.Debug": "Depuració",
|
||||
@@ -1758,7 +1758,7 @@
|
||||
"LabelLevel": "Nivell",
|
||||
"LabelMediaDetails": "Detalls del contingut multimèdia",
|
||||
"LogLevel.Trace": "Rastreig",
|
||||
"SearchResultsEmpty": "Ups! No s'han trobat cap resultat amb «{0}»",
|
||||
"SearchResultsEmpty": "Ups! No s'han trobat cap resultat per «{0}»",
|
||||
"Studio": "Estudi",
|
||||
"UnknownError": "S'ha produït un error desconegut.",
|
||||
"PleaseConfirmRepositoryInstallation": "Si us plau, pitgeu D'acord per a confirmar que heu llegit el text anterior i desitgeu continuar amb la instal·lació del repositori de complements.",
|
||||
@@ -1913,8 +1913,8 @@
|
||||
"PreviewLyrics": "Previsualitza lletres",
|
||||
"Reset": "Restableix",
|
||||
"MediaInfoRotation": "Rotació",
|
||||
"MoveToBottom": "Mou a sota",
|
||||
"MoveToTop": "Mou a dalt",
|
||||
"MoveToBottom": "Mou al fons",
|
||||
"MoveToTop": "Mou al cim",
|
||||
"EditLyrics": "Edició de la lletra",
|
||||
"HeaderAddLyrics": "Afegeix lletres",
|
||||
"HeaderPreviewLyrics": "Previsualitza lletres",
|
||||
@@ -1995,7 +1995,7 @@
|
||||
"CustomSubtitleStylingHelp": "L'estilització de subtítols funciona en la majoria dels dispositius, però suposa un cost addicional de rendiment.",
|
||||
"LabelSubtitleStyling": "Estilització de subtítols",
|
||||
"Native": "Nadiu",
|
||||
"NativeSubtitleStylingHelp": "Els subtítols estilitzats no funcionaran en alguns dispositius. Tot i això, no suposa cap sobrecàrrega de rendiment.",
|
||||
"NativeSubtitleStylingHelp": "L'estilització dels subtítols no funcionarà en alguns dispositius. Tot i això, no suposa cap sobrecàrrega de rendiment.",
|
||||
"LibraryNameInvalid": "El nom de la mediateca no pot estar buit.",
|
||||
"DeleteServerConfirmation": "N'esteu segur, que voleu eliminar aquest servidor?",
|
||||
"LabelDevice": "Dispositiu",
|
||||
@@ -2012,7 +2012,7 @@
|
||||
"StreamCountExceedsLimit": "El nombre de fluxos excedeix el límit",
|
||||
"RetryWithGlobalSearch": "Reintenta amb una cerca global",
|
||||
"LabelGroupShowsIntoCollections": "Agrupa sèries en col·leccions",
|
||||
"LabelGroupShowsIntoCollectionsHelp": "Les sèries que estiguin dins d'una col·lecció es mostraran com a un únic element agrupat quan es mostrin les llistes de sèries.",
|
||||
"LabelGroupShowsIntoCollectionsHelp": "Les sèries que estiguin dins d'una col·lecció es mostraran com un únic element agrupat quan es mostrin les llistes de sèries.",
|
||||
"CustomSplashScreenSize": "Les imatges personalitzades haurien de ser en 16x9 de ràtio d'aspecte i a una mida mínima de 1920x1080.",
|
||||
"DeleteCustomImage": "Elimina la imatge personalitzada",
|
||||
"ImageDeleteFailed": "No s'ha pogut eliminar la imatge",
|
||||
@@ -2065,9 +2065,5 @@
|
||||
"LabelAvailable": "Disponible",
|
||||
"LabelBundled": "Inclòs",
|
||||
"ManageRepositories": "Administració dels repositoris",
|
||||
"ViewAllPlugins": "Mostra tots els connectors",
|
||||
"ButtonAddProvider": "Afegiu-hi un proveïdor",
|
||||
"ButtonAddTunerDevice": "Afegiu-hi un dispositiu sintonitzador",
|
||||
"LabelSlideshowIntervalHelp": "El temps en segons que es mostra cada foto en les presentacions de diapositives.",
|
||||
"LabelSlideshowInterval": "Interval de les presentacions de diapositives"
|
||||
"ViewAllPlugins": "Mostra tots els connectors"
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
"DeleteMedia": "Odstranit média",
|
||||
"DeleteUser": "Odstranit uživatele",
|
||||
"DeleteUserConfirmation": "Jste si jist, že chcete smazat tohoto uživatele?",
|
||||
"Desktop": "Počítač (původní)",
|
||||
"Desktop": "PC",
|
||||
"DeviceAccessHelp": "Platí pouze pro zařízení, která mohou být jednoznačně identifikována. Těmto zařízením nebude bráněno v přístupu. Filtrování přístupu uživatelských zařízení bude bránit v užívání nových zařízení, dokud nebudou schváleny.",
|
||||
"DirectPlaying": "Přímé přehrání",
|
||||
"DirectStreamHelp1": "Video stopa je se zařízením kompatibilní, ale audio stopa je v nekompatibilním formátu (DTS, Dolby TrueHD atp.) nebo má nekompatibilní počet kanálů. Video bude před odesláním do zařízení za běhu bezztrátově přebaleno a pouze audio bude překódováno.",
|
||||
@@ -706,7 +706,7 @@
|
||||
"MetadataSettingChangeHelp": "Změna nastavení metadat bude mít vliv na obsah, který bude nově přidán v budoucnu. Chcete-li aktualizovat stávající obsah, otevřete obrazovku s podrobnostmi a klikněte na tlačítko 'Aktualizovat', nebo proveďte hromadnou aktualizaci pomocí Správce metadat.",
|
||||
"MinutesAfter": "minut po",
|
||||
"MinutesBefore": "minut předem",
|
||||
"Mobile": "Mobilní (zastaralý)",
|
||||
"Mobile": "Mobilní",
|
||||
"Monday": "Pondělí",
|
||||
"MoreFromValue": "Více od {0}",
|
||||
"MoreUsersCanBeAddedLater": "Další uživatele můžete přidat později na nástěnce serveru.",
|
||||
@@ -2071,7 +2071,5 @@
|
||||
"ManageRepositories": "Spravovat repozitáře",
|
||||
"ViewAllPlugins": "Zobrazit všechny zásuvné moduly",
|
||||
"ButtonAddProvider": "Přidat poskytovatele",
|
||||
"ButtonAddTunerDevice": "Přidat tuner",
|
||||
"LabelSlideshowInterval": "Interval mezi fotkami slideshow",
|
||||
"LabelSlideshowIntervalHelp": "Doba v sekundách po jakou je zobrazena každá fotka při slideshow."
|
||||
"ButtonAddTunerDevice": "Přidat tunerové zařízení"
|
||||
}
|
||||
|
||||
@@ -580,7 +580,7 @@
|
||||
"LabelZipCode": "Postnummer",
|
||||
"LabelffmpegPath": "FFmpeg sti",
|
||||
"LabelffmpegPathHelp": "Stien til FFmpeg applikationsfilen eller mappen indeholdende FFmpeg.",
|
||||
"LanNetworksHelp": "Komma separeret liste over IP adresser eller netmasker til netværk der vil blive anset for at være lokale når der bliver påtvunget båndbredde og fjernadgang restriktioner. Hvis blank, vil alle RFC1918 adresser blive set som lokale.",
|
||||
"LanNetworksHelp": "Komma separeret liste over IP adresser eller netmasker til netværk der vil blive anset for at være lokale når der bliver påtvunget båndbredde restriktioner. Hvis sat vil alle andre IP adresser bliver set som eksterne og blive underlagt båndbredde restriktioner. Hvis blank, er det kun serverens subnet der bliver betragtet som et lokalt netværk.",
|
||||
"LatestFromLibrary": "Nyligt tilføjet i {0}",
|
||||
"LibraryAccessHelp": "Vælg hvilke mediemapper der skal deles med denne bruger. Administratorer vil kunne redigere alle mapper ved hjælp af metadata-redskabet.",
|
||||
"LiveBroadcasts": "Live-udsending",
|
||||
@@ -948,7 +948,7 @@
|
||||
"BoxRear": "Boks (bagside)",
|
||||
"BurnSubtitlesHelp": "Afgør om serveren skal indbrænde undertekster. Undlader du dette, så vil ydelsen forbedres. Vælg Automatisk for at brænde billedbaserede formater (VobSub, PGS, SUB, IDX) og bestemte ASS- eller SSA-undertekster.",
|
||||
"ButtonInfo": "Information",
|
||||
"ButtonOk": "OK",
|
||||
"ButtonOk": "Ok",
|
||||
"ButtonPause": "Pause",
|
||||
"ButtonSend": "Send",
|
||||
"ButtonStart": "Start",
|
||||
@@ -967,7 +967,7 @@
|
||||
"DefaultSubtitlesHelp": "Undertekster bliver indlæst baseret på standard- og tvungne flag i de integrerede metadata. Sprogpræferencer bliver overvejet når flere valg er tilgængelige.",
|
||||
"Depressed": "Ikke Trykket",
|
||||
"Descending": "Faldene",
|
||||
"Desktop": "Skrivebord (Legacy)",
|
||||
"Desktop": "Skrivebord",
|
||||
"DirectPlaying": "Afspiller direkte",
|
||||
"DirectStreamHelp1": "Video strømmen er kompatibel med enheden, men har et ikke kompatibelt lyd format (DTS, TrueHD osv.) eller et forkert antal kanaler. Video stømmen vil blive genpakket tabsfrit før den streames til enheden. Kun lyd strømmen vil blive genpakket.",
|
||||
"DirectStreamHelp2": "Strømforbruget ved direkte streaming afhænger oftest af lydprofilen. Kun video er ukomprimeret.",
|
||||
@@ -1102,7 +1102,7 @@
|
||||
"MessageImageTypeNotSelected": "Vælg venligst en type af billede i drop-down menuen.",
|
||||
"MessagePlayAccessRestricted": "Afspilning af dette indhold er begrænset. Kontakt venligst server administratoren for mere information.",
|
||||
"Metadata": "Metadata",
|
||||
"Mobile": "Mobil (Legacy)",
|
||||
"Mobile": "Mobil",
|
||||
"Next": "Næste",
|
||||
"No": "Nej",
|
||||
"NoSubtitlesHelp": "Undertekster vil ikke blive indlæst som standard. De kan slåes til manuelt under afspilning.",
|
||||
@@ -1244,7 +1244,7 @@
|
||||
"LabelPlayerDimensions": "Afspillerdimensioner",
|
||||
"LabelPlayer": "Afspiller",
|
||||
"LabelPasswordResetProvider": "Udbyder til nulstilling as kodeord",
|
||||
"LabelLibraryPageSizeHelp": "Angiv antallet af objekter, der skal vises pr. biblioteksside. Vælg 0 for at deaktivere opdeling i sider. Bemærk at hvis denne sættes til 0 eller alt over 100 kan give problemer eller reduceret præstation.",
|
||||
"LabelLibraryPageSizeHelp": "Angiv antallet af objekter, der skal vises pr. biblioteksside. Vælg 0 for at deaktivere opdeling i sider.",
|
||||
"LabelLibraryPageSize": "Størrelse på biblioteksside",
|
||||
"LabelFolder": "Mappe",
|
||||
"LabelBaseUrl": "Basis-URL",
|
||||
@@ -2067,9 +2067,5 @@
|
||||
"ManageRepositories": "Administrer arkiver",
|
||||
"ViewAllPlugins": "Vis alle plugins",
|
||||
"LabelAvailable": "Tilgængelig",
|
||||
"LabelBundled": "Inkluderet",
|
||||
"ButtonAddProvider": "Tilføj Leverandør",
|
||||
"ButtonAddTunerDevice": "Tilføj Tuner Enhed",
|
||||
"LabelSlideshowInterval": "Billede Slideshow Interval",
|
||||
"LabelSlideshowIntervalHelp": "Mængden af tid i sekunder hvert billede er vist slideshows."
|
||||
"LabelBundled": "Inkluderet"
|
||||
}
|
||||
|
||||
@@ -774,7 +774,7 @@
|
||||
"MetadataSettingChangeHelp": "Das Verändern der Metadateneinstellungen hat nur Einfluss auf neu hinzugefügte Inhalte. Um eine Aktualisierung bereits hinzugefügter Inhalte durchzuführen, öffne bitte die Detailansicht und klicke die Aktualisieren-Schaltfläche. Die Massenaktualisierung kann im Metadaten-Manager durchgeführt werden.",
|
||||
"MinutesAfter": "Minuten nach",
|
||||
"MinutesBefore": "Minuten vor",
|
||||
"Mobile": "Mobil (Legacy)",
|
||||
"Mobile": "Smartphone",
|
||||
"Monday": "Montag",
|
||||
"MoreFromValue": "Mehr von {0}",
|
||||
"MoreUsersCanBeAddedLater": "Weitere Benutzer können später über das Dashboard hinzugefügt werden.",
|
||||
@@ -1104,7 +1104,7 @@
|
||||
"ChangingMetadataImageSettingsNewContent": "Änderungen an Metadaten- und Artwork-Einstellungen betreffen nur der Bibliothek neu hinzugefügte Inhalte. Um diese Änderungen auf existierende Medien anzuwenden, müssen die Metadaten manuell aktualisiert werden.",
|
||||
"CopyStreamURL": "Link zum Stream kopieren",
|
||||
"CopyStreamURLSuccess": "Link erfolgreich kopiert.",
|
||||
"Desktop": "Desktop (Legacy)",
|
||||
"Desktop": "Desktop",
|
||||
"Download": "Download",
|
||||
"Extras": "Extras",
|
||||
"FormatValue": "Format: {0}",
|
||||
@@ -1268,7 +1268,7 @@
|
||||
"Album": "Album",
|
||||
"BoxSet": "Box Set",
|
||||
"Yadif": "Noch ein Zeilenentflechtungsfilter (YADIF)",
|
||||
"LabelLibraryPageSizeHelp": "Stellt die Anzahl der auf einer Bibliotheksseite anzuzeigenden Elemente ein. Auf 0 stellen, um alle Elemente auf einer Seite anzuzeigen. Bitte beachte, dass 0 oder jeder Wert über 100 zu Fehlern oder Leistungseinbußen führen kann.",
|
||||
"LabelLibraryPageSizeHelp": "Setzt die Anzahl der auf einer Seite angezeigten Objekte. Auf 0 setzen, um alle Elemente auf einer Seite anzuzeigen.",
|
||||
"LabelLibraryPageSize": "Größe der Bibliotheksseiten",
|
||||
"DeinterlaceMethodHelp": "Wähle die Deinterlacing-Methode zum Transkodieren von Inhalten im Zeilensprungverfahren (Interlace). Sofern bei unterstützten Geräten Deinterlacing durch Hardwarebeschleunigung aktiviert ist, wird der Hardware-Deinterlacer anstelle dieser Einstellung verwendet.",
|
||||
"LabelDeinterlaceMethod": "Deinterlacing-Methode",
|
||||
@@ -2016,7 +2016,7 @@
|
||||
"StreamCountExceedsLimit": "Die Anzahl der Streams überschreitet das Limit",
|
||||
"RetryWithGlobalSearch": "Erneuter Versuch mit einer globalen Suche",
|
||||
"CustomSplashScreenSize": "Benutzerdefinierte Bilder sollten das Seitenverhältnis 16x9 und eine Mindestgröße von 1920x1080 haben.",
|
||||
"DeleteCustomImage": "Benutzerdefiniertes Bild löschen",
|
||||
"DeleteCustomImage": "Benutzerdefiniertes Bild gelöscht",
|
||||
"LabelGroupShowsIntoCollectionsHelp": "Serien in einer Sammlung werden bei der Anzeige von Serienlisten als ein gruppiertes Element dargestellt.",
|
||||
"ImageDeleteFailed": "Bild konnte nicht gelöscht werden",
|
||||
"LabelGroupShowsIntoCollections": "Serien in Kollektionen gruppieren",
|
||||
@@ -2069,9 +2069,5 @@
|
||||
"LabelAvailable": "Verfügbar",
|
||||
"LabelBundled": "Integriert",
|
||||
"ManageRepositories": "Repositories verwalten",
|
||||
"ViewAllPlugins": "Alle Plugins anzeigen",
|
||||
"ButtonAddTunerDevice": "Tuner-Gerät hinzufügen",
|
||||
"ButtonAddProvider": "Provider hinzufügen",
|
||||
"LabelSlideshowInterval": "Photo Diashow Intervall",
|
||||
"LabelSlideshowIntervalHelp": "Die Zeit in Sekunden die jedes Foto in Diashows angezeigt wird."
|
||||
"ViewAllPlugins": "Alle Plugins anzeigen"
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"MessageBrowsePluginCatalog": "Πλοηγηθείτε στον κατάλογο plugin μας για να δείτε τα διαθέσιμα plugins.",
|
||||
"BurnSubtitlesHelp": "Καθορίζει αν ο διακομιστής πρέπει να εγγράψει τους υπότιτλους. Η αποφυγή του θα βελτιώσει σημαντικά την απόδοση. Επιλέξτε Αυτόματο για να εγγράψετε μορφές βασισμένες σε εικόνες (VobSub, PGS, SUB, IDX, κ.λπ.) και ορισμένους υπότιτλους ASS / SSA.",
|
||||
"ButtonAddMediaLibrary": "Προσθήκη βιβλιοθήκης πολυμέσων",
|
||||
"ButtonAddScheduledTaskTrigger": "Προσθήκη εναύσματος",
|
||||
"ButtonAddScheduledTaskTrigger": "Προσθήκη διακόπτη",
|
||||
"ButtonAddServer": "Προσθήκη διακομιστή",
|
||||
"ButtonAddUser": "Προσθήκη χρήστη",
|
||||
"ButtonArrowLeft": "Αριστερά",
|
||||
@@ -126,7 +126,7 @@
|
||||
"DeleteUserConfirmation": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτή τον χρήστη;",
|
||||
"Depressed": "Μειώθηκε",
|
||||
"Descending": "Φθίνουσα",
|
||||
"Desktop": "Υπολογιστής (παλιά μέθοδος)",
|
||||
"Desktop": "Επιφάνεια εργασίας",
|
||||
"DetectingDevices": "Ανίχνευση συσκευών",
|
||||
"DeviceAccessHelp": "Αυτό ισχύει μόνο για συσκευές που μπορούν να αναγνωριστούν με μοναδικό τρόπο και δεν θα εμποδίσουν την πρόσβαση του προγράμματος περιήγησης. Το φιλτράρισμα της πρόσβασης των συσκευών χρήστη θα αποτρέψει τη χρήση νέων συσκευών μέχρι να εγκριθούν εδώ.",
|
||||
"DirectPlaying": "Απευθείας Αναπαραγωγή",
|
||||
@@ -679,7 +679,7 @@
|
||||
"MetadataSettingChangeHelp": "Η αλλαγή των ρυθμίσεων μεταδεδομένων θα επηρεάσει το νέο περιεχόμενο που θα προστίθεται από εδώ και στο εξής. Για να ανανεώσετε το υπάρχον περιεχόμενο, ανοίξτε την οθόνη λεπτομερειών και κάντε κλικ στο κουμπί ανανέωση ή εκτελέστε μαζικές ανανεώσεις χρησιμοποιώντας τον διαχειριστή μεταδεδομένων.",
|
||||
"MinutesAfter": "λεπτά μετά",
|
||||
"MinutesBefore": "λεπτά πριν",
|
||||
"Mobile": "Κινητό (παλιά μέθοδος)",
|
||||
"Mobile": "Κινητό",
|
||||
"Monday": "Δευτέρα",
|
||||
"MoreFromValue": "Περισσότερα από {0}",
|
||||
"MoreUsersCanBeAddedLater": "Περισσότεροι χρήστες μπορούν να προστεθούν αργότερα στον πίνακα ελέγχου.",
|
||||
@@ -757,7 +757,7 @@
|
||||
"OptionHideUserFromLoginHelp": "Χρήσιμο για ιδιωτικούς ή κρυφούς λογαριασμούς διαχειριστή. Ο χρήστης θα πρέπει να συνδεθεί χειροκίνητα εισάγοντας το όνομα χρήστη και τον κωδικό πρόσβασής του.",
|
||||
"OptionImdbRating": "Βαθμολογία IMDb",
|
||||
"OptionLikes": "Συμπαθεί",
|
||||
"OptionMax": "Μεγ.",
|
||||
"OptionMax": "Μέγιστο",
|
||||
"OptionMissingEpisode": "Ελλειπή Επεισόδια",
|
||||
"OptionNew": "Νέο…",
|
||||
"OptionOnInterval": "Σε ένα διάστημα",
|
||||
@@ -975,7 +975,7 @@
|
||||
"Yes": "Ναι",
|
||||
"Yesterday": "Εχθές",
|
||||
"LabelCancelled": "Ακυρώθηκε",
|
||||
"LabelBindToLocalNetworkAddress": "Σύνδεση σε διεύθυνση τοπικού δικτύου",
|
||||
"LabelBindToLocalNetworkAddress": "Σύνδεση σε τοπική διεύθυνση δικτύου",
|
||||
"LabelAuthProvider": "Πάροχος πιστοποίησης",
|
||||
"LabelAllowedRemoteAddressesMode": "Λειτουργία Φίλτρου Εξωτερικών Διευθύνσεων IP",
|
||||
"LabelAllowedRemoteAddresses": "Φίλτρο Εξωτερικών Διευθύνσεων IP",
|
||||
@@ -1013,7 +1013,7 @@
|
||||
"EnablePhotosHelp": "Οι εικόνες θα ανιχνεύονται και θα εμφανίζονται μαζί με τα άλλα αρχεία μέσων.",
|
||||
"EnablePhotos": "Εμφάνιση των φωτογραφιών",
|
||||
"DrmChannelsNotImported": "Κανάλια με DRM δεν θα εισαχθούν.",
|
||||
"ButtonOk": "ΟΚ",
|
||||
"ButtonOk": "Οκ",
|
||||
"ButtonNetwork": "Δίκτυο",
|
||||
"AllowOnTheFlySubtitleExtractionHelp": "Οι ενσωματωμένοι υπότιτλοι μπορούν να εξαχθούν από βίντεο και να σταλούν στους διαμεσολαβητές αναπαραγωγής σε απλό κείμενο για να αποφευχθούν μετατροπές βίντεο. Σε μερικά συστήματα αυτό μπορεί να πάρει πολύ ώρα και να κάνει το βίντεο να κολλάει κατά την διάρκεια της εξαγωγής. Απενεργοποιήστε το για να έχετε ενσωματωμένους υπότιτλους πάνω στο βίντεο όταν αυτοί δεν υποστηρίζονται από την συσκευή.",
|
||||
"AllowOnTheFlySubtitleExtraction": "Να επιτρέπεται η εξαγωγή υποτίτλων σε πραγματικό χρόνο",
|
||||
@@ -1201,7 +1201,7 @@
|
||||
"LabelAutomaticallyAddToCollection": "Αυτόματη προσθήκη στη συλλογή",
|
||||
"LabelAutomaticallyAddToCollectionHelp": "Όταν το λιγότερο 2 ταινίες έχουν το ίδιο όνομα συλλογής, θα προστεθούν αυτόματα στη συλλογή.",
|
||||
"DirectPlayError": "Υπήρξε ένα σφάλμα ξεκινώντας την απευθείας αναπαραγωγή",
|
||||
"LabelAirsBeforeEpisode": "Προβάλλεται πριν το επεισόδιο",
|
||||
"LabelAirsBeforeEpisode": "Προβάλλεται μετά το επεισόδιο",
|
||||
"LabelAudioCodec": "Κωδικοποιητής ήχου",
|
||||
"LabelCreateHttpPortMap": "Ενεργοποίηση αυτόματης χαρτογράφησης πόρτας για κίνηση HTTP όπως και HTTPS.",
|
||||
"LabelAudioSampleRate": "Ρυθμός δειγματοληψίας ήχου",
|
||||
@@ -1269,7 +1269,7 @@
|
||||
"LabelMaxMuxingQueueSize": "Μέγιστο μέγεθος ουράς πολυπλεξίας",
|
||||
"LabelKnownProxies": "Γνωστά proxies",
|
||||
"LabelKodiMetadataEnableExtraThumbs": "Αντιγράψτε το extrafanart στο πεδίο extrathumbs",
|
||||
"LabelLibraryPageSizeHelp": "Ορίστε τον αριθμό των στοιχείων που θα εμφανίζονται σε μια σελίδα βιβλιοθήκης. Ο ορισμός τιμής 0 θα απενεργοποιήσει την σελιδοποίηση. Παρακαλώ λάβετε υπόψη ότι ο ορισμός αυτής της τιμής σε 0 ή σε οποιαδήποτε τιμή μεγαλύτερη από 100 ενδέχεται να οδηγήσει σε σφάλματα και μειωμένη απόδοση.",
|
||||
"LabelLibraryPageSizeHelp": "Ορίστε τον αριθμό των στοιχείων που θα εμφανίζονται σε μια σελίδα βιβλιοθήκης. Ορίστε στο 0 για να απενεργοποιήσετε τη σελιδοποίηση.",
|
||||
"LabelMetadataDownloadersHelp": "Ενεργοποιήστε και ταξινομήστε τα προγράμματα λήψης μεταδεδομένων που προτιμάτε με σειρά προτεραιότητας. Τα προγράμματα λήψης χαμηλότερης προτεραιότητας θα χρησιμοποιηθούν μόνο για τη συμπλήρωση πληροφοριών που λείπουν.",
|
||||
"LabelMaxDaysForNextUpHelp": "Ορίστε τον μέγιστο αριθμό ημερών που μια σειρά θα πρέπει να παραμείνει στη λίστα \"Επόμενα\" χωρίς να την παρακολουθήσετε.",
|
||||
"LabelIconMaxResHelp": "Μέγιστη ανάλυση εικονιδίων που εκτίθενται μέσω της ιδιότητας 'upnp:icon'.",
|
||||
@@ -1313,7 +1313,7 @@
|
||||
"LabelUserAgent": "Πράκτορας χρήστη",
|
||||
"LabelXDlnaCap": "Αναγνωριστικό ικανότητας συσκευής",
|
||||
"LabelXDlnaDoc": "Αναγνωριστικό κατηγορίας συσκευής",
|
||||
"LanNetworksHelp": "Λίστα διευθύνσεων IP ή καταχωρίσεων IP/μάσκας δικτύου, διαχωρισμένων με κόμμα, για δίκτυα που θα ληφθούν υπόψη ως τοπικό δίκτυο κατά την επιβολή περιορισμών εύρους ζώνης και απομακρυσμένης πρόσβασης. Εάν μείνει κενό, όλες οι διευθύνσεις RFC1918 θεωρούνται τοπικές.",
|
||||
"LanNetworksHelp": "Λίστα διευθύνσεων IP διαχωρισμένη με κόμματα ή καταχωρίσεις IP/μάσκας δικτύου για δίκτυα που θα ληφθούν υπόψη στο τοπικό δίκτυο κατά την επιβολή περιορισμών εύρους ζώνης. Εάν οριστούν, όλες οι άλλες διευθύνσεις IP θα θεωρούνται ότι βρίσκονται στο εξωτερικό δίκτυο και θα υπόκεινται στους περιορισμούς εξωτερικού εύρους ζώνης. Εάν μείνει κενό, μόνο το υποδίκτυο του διακομιστή θεωρείται ότι βρίσκεται στο τοπικό δίκτυο.",
|
||||
"MediaInfoColorPrimaries": "Πρωταρχικά χρώματα",
|
||||
"LabelUserLoginAttemptsBeforeLockout": "Αποτυχημένες προσπάθειες σύνδεσης πριν κλειδωθεί ο χρήστης",
|
||||
"LabelUserMaxActiveSessions": "Μέγιστος αριθμός ταυτόχρονων περιόδων σύνδεσης χρήστη",
|
||||
@@ -2059,9 +2059,5 @@
|
||||
"PriorityBelowNormal": "Κάτω από το κανονικό",
|
||||
"HeaderServerMismatch": "Αναντιστοιχία server",
|
||||
"MessageServerMismatch": "Ο server στον οποίο συνδέεστε δεν είναι ο ίδιος server στον οποίο συνδεθήκατε προηγουμένως σε αυτήν τη διεύθυνση. Εάν αυτό είναι αναμενόμενο, επιλέξτε \"Σύνδεση ούτως ή άλλως\". Διαφορετικά, η σύνδεσή σας ή ο server ενδέχεται να έχουν παραβιαστεί.",
|
||||
"LabelTileWidth": "Πλάτος πλακιδίου",
|
||||
"ButtonAddProvider": "Προσθήκη παρόχου",
|
||||
"ButtonAddTunerDevice": "Προσθήκη συσκευής δέκτη",
|
||||
"LabelSlideshowIntervalHelp": "Ο χρόνος σε δευτερόλεπτα που κάθε φωτογραφία εμφανίζεται σε παρουσιάσεις διαφανειών.",
|
||||
"LabelSlideshowInterval": "Διάστημα παρουσίασης φωτογραφιών"
|
||||
"LabelTileWidth": "Πλάτος πλακιδίου"
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
"DeleteUserConfirmation": "Are you sure you wish to delete this user?",
|
||||
"Depressed": "Depressed",
|
||||
"Descending": "Descending",
|
||||
"Desktop": "Desktop (Legacy)",
|
||||
"Desktop": "Desktop",
|
||||
"DetectingDevices": "Detecting devices",
|
||||
"DeviceAccessHelp": "This only applies to devices that can be uniquely identified and will not prevent browser access. Filtering user device access will prevent them from using new devices until they've been approved here.",
|
||||
"DirectPlaying": "Direct playing",
|
||||
@@ -636,7 +636,7 @@
|
||||
"LibraryAccessHelp": "Select the libraries to share with this user. Administrators will be able to edit all folders using the metadata manager.",
|
||||
"LeaveBlankToNotSetAPassword": "You can leave this field blank to set no password.",
|
||||
"LearnHowYouCanContribute": "Learn how you can contribute.",
|
||||
"LanNetworksHelp": "Comma-separated list of IP addresses or IP/netmask entries for networks that will be considered on the local network when enforcing bandwidth and remote access restrictions. If left blank, all RFC1918 addresses are considered local.",
|
||||
"LanNetworksHelp": "Comma separated list of IP addresses or IP/netmask entries for networks that will be considered on local network when enforcing bandwidth restrictions. If set, all other IP addresses will be considered to be on the external network and will be subject to the external bandwidth restrictions. If left blank, only the server's subnet is considered to be on the local network.",
|
||||
"LabelffmpegPathHelp": "The path to the FFmpeg application file or folder containing FFmpeg.",
|
||||
"LabelffmpegPath": "FFmpeg path",
|
||||
"LabelYear": "Year",
|
||||
@@ -720,7 +720,7 @@
|
||||
"HeaderYears": "Years",
|
||||
"HeaderVideos": "Videos",
|
||||
"HeaderVideoTypes": "Video Types",
|
||||
"Series": "Programmes",
|
||||
"Series": "Programme",
|
||||
"NewEpisodes": "New episodes",
|
||||
"NewCollectionNameExample": "Example: Star Wars Collection",
|
||||
"MinutesAfter": "minutes after",
|
||||
@@ -1172,7 +1172,7 @@
|
||||
"News": "News",
|
||||
"MusicVideo": "Music Video",
|
||||
"MusicArtist": "Music Artist",
|
||||
"Mobile": "Mobile (Legacy)",
|
||||
"Mobile": "Mobile",
|
||||
"MessageYouHaveVersionInstalled": "You currently have version {0} installed.",
|
||||
"MessageUnableToConnectToServer": "We're unable to connect to the selected server right now. Please ensure it is running and try again.",
|
||||
"MessageTheFollowingLocationWillBeRemovedFromLibrary": "The following media locations will be removed from your library",
|
||||
@@ -1276,7 +1276,7 @@
|
||||
"AllowEmbeddedSubtitlesAllowTextOption": "Allow Text",
|
||||
"Person": "Person",
|
||||
"Movie": "Film",
|
||||
"LabelLibraryPageSizeHelp": "Set the amount of items to show on a library page. Setting a value of 0 will disable pagination. Please note that setting this to 0 or any value greater than 100 may lead to bugs and reduced performance.",
|
||||
"LabelLibraryPageSizeHelp": "Set the amount of items to show on a library page. Set to 0 in order to disable paging.",
|
||||
"LabelLibraryPageSize": "Library page size",
|
||||
"LabelDeinterlaceMethod": "Deinterlacing method",
|
||||
"Episode": "Episode",
|
||||
@@ -2069,9 +2069,5 @@
|
||||
"LabelBackupsUnavailable": "No backups available",
|
||||
"MessageServerMismatch": "The server you are connecting to is not the same server that you previously connected to at this address. If this is expected, please select \"Connect Anyway\". Otherwise, your connection or the server might be compromised.",
|
||||
"ConfirmDeleteRepository": "Delete repository?",
|
||||
"DeleteRepositoryConfirmation": "Are you sure you want to delete this repository?",
|
||||
"ButtonAddProvider": "Add Provider",
|
||||
"ButtonAddTunerDevice": "Add Tuner Device",
|
||||
"LabelSlideshowInterval": "Photo Slideshow Interval",
|
||||
"LabelSlideshowIntervalHelp": "The time in seconds each photo is displayed in slideshows."
|
||||
"DeleteRepositoryConfirmation": "Are you sure you want to delete this repository?"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user