Compare commits
161 Commits
renovate/m
...
release-10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6e13a8fa6 | ||
|
|
6b6d7e5996 | ||
|
|
d03c5ca8ea | ||
|
|
de26159fab | ||
|
|
e4677df320 | ||
|
|
29a6a0fd50 | ||
|
|
e85fb0a679 | ||
|
|
a5a4190da0 | ||
|
|
89ab68f772 | ||
|
|
b430f10501 | ||
|
|
f4db8c05a7 | ||
|
|
8f622360e2 | ||
|
|
f15d1fd435 | ||
|
|
a837dd335f | ||
|
|
cfe005c9b5 | ||
|
|
d94f78a43e | ||
|
|
260e6d22bb | ||
|
|
1a17c259de | ||
|
|
0d58d9ed89 | ||
|
|
74b3aec8a9 | ||
|
|
828698d8be | ||
|
|
5638ee78b0 | ||
|
|
b0c3c33e6e | ||
|
|
12f4930509 | ||
|
|
6f24a18c31 | ||
|
|
8a53a3d713 | ||
|
|
b5decda242 | ||
|
|
2fad9d6277 | ||
|
|
ad6627be6e | ||
|
|
f57ba2df9f | ||
|
|
f26dd02871 | ||
|
|
cfc39f9983 | ||
|
|
67a0dcce43 | ||
|
|
cb65a18998 | ||
|
|
1ee69611b5 | ||
|
|
62fa040b19 | ||
|
|
0682a1aa55 | ||
|
|
c46637f58d | ||
|
|
6e27a05ca4 | ||
|
|
4e35132b48 | ||
|
|
ec5e41dab7 | ||
|
|
a385fb86fe | ||
|
|
0fdb3dafab | ||
|
|
9f0ae91fc5 | ||
|
|
e9728ab2dc | ||
|
|
81d4141c9e | ||
|
|
9e50bf6247 | ||
|
|
69016c6d51 | ||
|
|
ea2abad3e1 | ||
|
|
442a49ef41 | ||
|
|
6d8c8c0566 | ||
|
|
a2855c785e | ||
|
|
bf31a733a7 | ||
|
|
bf70fb80aa | ||
|
|
2acc6f360a | ||
|
|
a36eb7b546 | ||
|
|
fb6250d108 | ||
|
|
a82ae33aa3 | ||
|
|
32d916b420 | ||
|
|
014af0ebe9 | ||
|
|
9b80917cd1 | ||
|
|
238c5bbf58 | ||
|
|
264cdafaff | ||
|
|
1459a11320 | ||
|
|
e28d70d34c | ||
|
|
ba94f73921 | ||
|
|
6ae5802305 | ||
|
|
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
|
||||
|
||||
6
.github/workflows/pull_request.yml
vendored
6
.github/workflows/pull_request.yml
vendored
@@ -5,7 +5,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- release*
|
||||
@@ -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 }}
|
||||
|
||||
@@ -103,6 +103,8 @@
|
||||
- [TheBosZ](https://github.com/thebosz)
|
||||
- [qm3jp](https://github.com/qm3jp)
|
||||
- [johnnyg](https://github.com/johnnyg)
|
||||
- [lmaotrigine](https://github.com/lmaotrigine)
|
||||
- [bjorntp](https://github.com/bjorntp)
|
||||
|
||||
## Emby Contributors
|
||||
|
||||
|
||||
303
package-lock.json
generated
303
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.12.0",
|
||||
"version": "10.11.8",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.12.0",
|
||||
"version": "10.11.8",
|
||||
"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",
|
||||
@@ -54,11 +54,11 @@
|
||||
"native-promise-only": "0.8.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"proxy-polyfill": "0.3.2",
|
||||
"react": "19.2.3",
|
||||
"react": "18.3.1",
|
||||
"react-blurhash": "0.3.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dom": "18.3.1",
|
||||
"react-lazy-load-image-component": "1.6.3",
|
||||
"react-router-dom": "7.11.0",
|
||||
"react-router-dom": "6.30.1",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"screenfull": "6.0.2",
|
||||
"sortablejs": "1.15.6",
|
||||
@@ -81,8 +81,8 @@
|
||||
"@types/loadable__component": "5.13.9",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/markdown-it": "14.1.2",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react": "18.3.23",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@types/react-lazy-load-image-component": "1.6.4",
|
||||
"@types/sortablejs": "1.15.8",
|
||||
"@typescript-eslint/parser": "8.35.1",
|
||||
@@ -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",
|
||||
@@ -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"
|
||||
@@ -5087,6 +5086,15 @@
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||
"integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.40.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz",
|
||||
@@ -5888,22 +5896,23 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"version": "18.3.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
|
||||
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-lazy-load-image-component": {
|
||||
@@ -9145,10 +9154,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
},
|
||||
"node_modules/currently-unhandled": {
|
||||
"version": "0.4.1",
|
||||
@@ -12360,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",
|
||||
@@ -18231,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": {
|
||||
@@ -18350,10 +18352,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -18368,15 +18372,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.3"
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
@@ -18398,54 +18402,35 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
|
||||
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
|
||||
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
"@remix-run/router": "1.23.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
|
||||
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
|
||||
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.11.0"
|
||||
"@remix-run/router": "1.23.0",
|
||||
"react-router": "6.30.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router/node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
@@ -19431,10 +19416,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils": {
|
||||
"version": "4.3.2",
|
||||
@@ -19675,12 +19662,6 @@
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@@ -23206,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",
|
||||
@@ -23877,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",
|
||||
@@ -27162,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": {
|
||||
@@ -27635,6 +27575,11 @@
|
||||
"@react-hook/passive-layout-effect": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"@remix-run/router": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||
"integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA=="
|
||||
},
|
||||
"@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.40.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz",
|
||||
@@ -28172,17 +28117,18 @@
|
||||
"dev": true
|
||||
},
|
||||
"@types/react": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"version": "18.3.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
|
||||
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
|
||||
"requires": {
|
||||
"csstype": "^3.2.2"
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
@@ -30417,9 +30363,9 @@
|
||||
}
|
||||
},
|
||||
"csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
},
|
||||
"currently-unhandled": {
|
||||
"version": "0.4.1",
|
||||
@@ -32738,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",
|
||||
@@ -36758,9 +36698,12 @@
|
||||
}
|
||||
},
|
||||
"react": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"requires": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"react-blurhash": {
|
||||
"version": "0.3.0",
|
||||
@@ -36769,11 +36712,12 @@
|
||||
"requires": {}
|
||||
},
|
||||
"react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"requires": {
|
||||
"scheduler": "^0.27.0"
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
}
|
||||
},
|
||||
"react-is": {
|
||||
@@ -36791,27 +36735,20 @@
|
||||
}
|
||||
},
|
||||
"react-router": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
|
||||
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
|
||||
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
|
||||
"requires": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="
|
||||
}
|
||||
"@remix-run/router": "1.23.0"
|
||||
}
|
||||
},
|
||||
"react-router-dom": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
|
||||
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
|
||||
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
|
||||
"requires": {
|
||||
"react-router": "7.11.0"
|
||||
"@remix-run/router": "1.23.0",
|
||||
"react-router": "6.30.1"
|
||||
}
|
||||
},
|
||||
"react-transition-group": {
|
||||
@@ -37418,9 +37355,12 @@
|
||||
}
|
||||
},
|
||||
"scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"requires": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"schema-utils": {
|
||||
"version": "4.3.2",
|
||||
@@ -37613,11 +37553,6 @@
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"optional": true
|
||||
},
|
||||
"set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||
},
|
||||
"set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@@ -40183,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",
|
||||
@@ -40631,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",
|
||||
|
||||
17
package.json
17
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.12.0",
|
||||
"version": "10.11.8",
|
||||
"description": "Web interface for Jellyfin",
|
||||
"repository": "https://github.com/jellyfin/jellyfin-web",
|
||||
"license": "GPL-2.0-or-later",
|
||||
@@ -18,8 +18,8 @@
|
||||
"@types/loadable__component": "5.13.9",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/markdown-it": "14.1.2",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react": "18.3.23",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@types/react-lazy-load-image-component": "1.6.4",
|
||||
"@types/sortablejs": "1.15.8",
|
||||
"@typescript-eslint/parser": "8.35.1",
|
||||
@@ -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",
|
||||
@@ -121,11 +120,11 @@
|
||||
"native-promise-only": "0.8.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"proxy-polyfill": "0.3.2",
|
||||
"react": "19.2.3",
|
||||
"react": "18.3.1",
|
||||
"react-blurhash": "0.3.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dom": "18.3.1",
|
||||
"react-lazy-load-image-component": "1.6.3",
|
||||
"react-router-dom": "7.11.0",
|
||||
"react-router-dom": "6.30.1",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"screenfull": "6.0.2",
|
||||
"sortablejs": "1.15.6",
|
||||
@@ -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>;
|
||||
|
||||
@@ -85,7 +85,7 @@ const BaseCard = ({
|
||||
}
|
||||
}}>
|
||||
<Stack flexGrow={1} direction='row'>
|
||||
<Stack flexGrow={1}>
|
||||
<Stack flexGrow={1} sx={{ overflow: 'hidden' }}>
|
||||
<Typography gutterBottom sx={{
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
|
||||
@@ -13,6 +13,8 @@ const QUERY_PARAMS = {
|
||||
activeWithinSeconds: 960
|
||||
};
|
||||
|
||||
const FALLBACK_POLL_INTERVAL_MS = 2000;
|
||||
|
||||
const useLiveSessions = () => {
|
||||
const { __legacyApiClient__ } = useApi();
|
||||
|
||||
@@ -43,10 +45,19 @@ const useLiveSessions = () => {
|
||||
queryClient.setQueryData([ QUERY_KEY, QUERY_PARAMS ], updateSessions(info));
|
||||
};
|
||||
|
||||
const fallbackInterval = setInterval(() => {
|
||||
if (!__legacyApiClient__?.isMessageChannelOpen()) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
}
|
||||
}, FALLBACK_POLL_INTERVAL_MS);
|
||||
|
||||
__legacyApiClient__?.sendMessage(SessionMessageType.SessionsStart, '0,1500');
|
||||
Events.on(serverNotifications, SessionMessageType.Sessions, onSessionsUpdate);
|
||||
|
||||
return () => {
|
||||
clearInterval(fallbackInterval);
|
||||
__legacyApiClient__?.sendMessage(SessionMessageType.SessionsStop, null);
|
||||
Events.off(serverNotifications, SessionMessageType.Sessions, onSessionsUpdate);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,12 @@ import type { ItemDto } from 'types/base/models/item-dto';
|
||||
import type { PlayerState } from 'types/playbackStopInfo';
|
||||
import type { Event } from 'utils/events';
|
||||
|
||||
/** The default image resolutions to provide to the media session */
|
||||
const DEFAULT_IMAGE_SIZES = [96, 128, 192, 256, 384, 512];
|
||||
/** The default image resolutions to provide to the media session.
|
||||
*
|
||||
* Highest-to-lowest order matters; Firefox on Linux seems to use the first
|
||||
* image in the artwork array for its MPRIS interface. (#7630)
|
||||
*/
|
||||
const DEFAULT_IMAGE_SIZES = [512, 384, 256, 192, 128, 96];
|
||||
|
||||
const hasNavigatorSession = 'mediaSession' in navigator;
|
||||
const hasNativeShell = !!window.NativeShell;
|
||||
@@ -92,8 +96,14 @@ class MediaSessionSubscriber extends PlaybackSubscriber {
|
||||
|
||||
private onMediaSessionUpdate(
|
||||
{ type: action }: Event,
|
||||
state: PlayerState = this.playbackManager.getPlayerState(this.player)
|
||||
stateOverride?: PlayerState
|
||||
) {
|
||||
if (!this.player) {
|
||||
console.debug('[MediaSessionSubscriber] no active player; resetting media session');
|
||||
return resetMediaSession();
|
||||
}
|
||||
|
||||
const state: PlayerState = stateOverride || this.playbackManager.getPlayerState(this.player);
|
||||
const item = state.NowPlayingItem;
|
||||
|
||||
if (!item) {
|
||||
|
||||
@@ -66,6 +66,7 @@ export const useSearchItems = (
|
||||
includeItemTypes: itemTypes,
|
||||
parentId,
|
||||
searchTerm,
|
||||
isMissing: itemTypes.includes(BaseItemKind.Episode) && !user?.Configuration?.DisplayMissingEpisodes ? false : undefined,
|
||||
limit: 800
|
||||
},
|
||||
{ signal }
|
||||
|
||||
@@ -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 |
@@ -64,6 +64,7 @@ const ConnectionErrorPage: FC<ConnectionErrorPageProps> = ({
|
||||
id='connectionErrorPage'
|
||||
className='mainAnimatedPage standalonePage'
|
||||
isBackButtonEnabled={false}
|
||||
shouldAutoFocus
|
||||
>
|
||||
<div className='padded-left padded-right'>
|
||||
<h1>{title}</h1>
|
||||
|
||||
@@ -45,6 +45,12 @@ const ResponsiveDrawer: FC<PropsWithChildren<ResponsiveDrawerProps>> = ({
|
||||
<SwipeableDrawer
|
||||
anchor='left'
|
||||
open={open}
|
||||
sx={{
|
||||
'& .MuiDrawer-paper': {
|
||||
paddingBottom: '4.2rem', // Padding for now playing bar
|
||||
boxSizing: 'border-box'
|
||||
}
|
||||
}}
|
||||
onClose={onClose}
|
||||
onOpen={onOpen}
|
||||
// Disable swipe to open on iOS since it interferes with back navigation
|
||||
|
||||
@@ -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();
|
||||
@@ -456,5 +456,3 @@ if (window.addEventListener) {
|
||||
window.addEventListener('blur', onAppHidden);
|
||||
}
|
||||
|
||||
// load app host on module load
|
||||
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
|
||||
});
|
||||
|
||||
@@ -826,6 +826,13 @@ button::-moz-focus-inner {
|
||||
right: 0;
|
||||
user-select: none;
|
||||
border-radius: 0.2em;
|
||||
|
||||
/** Disable pointer events for the overlay container, but allow them for its children */
|
||||
pointer-events: none;
|
||||
|
||||
* {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.visualCardBox .blurhash-canvas,
|
||||
@@ -835,10 +842,6 @@ button::-moz-focus-inner {
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.card-hoverable:hover .cardOverlayContainer {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cardOverlayButton-hover {
|
||||
opacity: 0;
|
||||
transition: 0.2s;
|
||||
@@ -850,8 +853,12 @@ button::-moz-focus-inner {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.card-hoverable:hover .cardOverlayButton-hover {
|
||||
opacity: 1;
|
||||
.card-hoverable:focus-within,
|
||||
.card-hoverable:hover {
|
||||
.cardOverlayContainer,
|
||||
.cardOverlayButton-hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.cardOverlayContainer > .cardOverlayFab-primary {
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1017,7 +1017,7 @@ function buildCard(index, item, apiClient, options) {
|
||||
|
||||
cardImageContainerClose = '</div>';
|
||||
} else {
|
||||
const cardImageContainerAriaLabelAttribute = ` aria-label="${escapeHtml(item.Name)}"`;
|
||||
const cardImageContainerAriaLabelAttribute = ` aria-label="${escapeHtml(item.Name)}" role="img"`;
|
||||
|
||||
const url = appRouter.getRouteUrl(item);
|
||||
// Don't use the IMG tag with safari because it puts a white border around it
|
||||
@@ -1149,15 +1149,11 @@ function getHoverMenuHtml(item, action) {
|
||||
let html = '';
|
||||
|
||||
html += '<div class="cardOverlayContainer itemAction" data-action="' + action + '">';
|
||||
const url = appRouter.getRouteUrl(item, {
|
||||
serverId: item.ServerId || ServerConnections.currentApiClient().serverId()
|
||||
});
|
||||
html += '<a href="' + url + '" class="cardImageContainer"></a>';
|
||||
|
||||
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" title="${globalize.translate('Play')}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover play_arrow" aria-hidden="true"></span></button>`;
|
||||
}
|
||||
|
||||
html += '<div class="cardOverlayButton-br flex">';
|
||||
@@ -1166,17 +1162,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" title="${globalize.translate('Play')}"><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" title="${globalize.translate('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" title="${globalize.translate('ButtonInfo')}"><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" title="${globalize.translate('ButtonMore')}"><span class="material-icons more_vert" aria-hidden="true"></span></button>`;
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
@@ -502,5 +498,5 @@ export function getListViewHtml(options) {
|
||||
}
|
||||
|
||||
export default {
|
||||
getListViewHtml: getListViewHtml
|
||||
getListViewHtml
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1944,7 +1944,7 @@ export class PlaybackManager {
|
||||
} else if (firstItem.IsFolder && firstItem.CollectionType === 'musicvideos') {
|
||||
return getItemsForPlayback(serverId, mergePlaybackQueries({
|
||||
ParentId: firstItem.Id,
|
||||
Filters: 'IsFolder',
|
||||
Filters: 'IsNotFolder',
|
||||
Recursive: true,
|
||||
SortBy: options.shuffle ? 'Random' : 'SortName',
|
||||
MediaTypes: 'Video',
|
||||
@@ -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) {
|
||||
|
||||
@@ -189,7 +189,8 @@ function populatePlaylists(editorOptions: PlaylistEditorOptions, panel: DialogEl
|
||||
userId: apiClient.getCurrentUserId(),
|
||||
includeItemTypes: [ BaseItemKind.Playlist ],
|
||||
sortBy: [ ItemSortBy.SortName ],
|
||||
recursive: true
|
||||
recursive: true,
|
||||
enableUserData: false
|
||||
})
|
||||
.then(({ data }) => {
|
||||
return Promise.all((data.Items || []).map(item => {
|
||||
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ const FallbackRoute = () => {
|
||||
id='fallbackPage'
|
||||
title={globalize.translate('HeaderPageNotFound')}
|
||||
className='mainAnimatedPage libraryPage'
|
||||
shouldAutoFocus
|
||||
>
|
||||
<div className='padded-left padded-right'>
|
||||
<h1>{globalize.translate('HeaderPageNotFound')}</h1>
|
||||
|
||||
@@ -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 lyricsLineContainer" dir="auto"></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,
|
||||
|
||||
@@ -334,7 +334,7 @@ function getItems(instance, params, item, sortBy, startIndex, limit) {
|
||||
|
||||
if (sortBy === 'Random') {
|
||||
instance.queryRecursive = true;
|
||||
query.IncludeItemTypes = 'Video,Movie,Series,Music';
|
||||
query.IncludeItemTypes = 'Video,Movie,Series,Music,MusicVideo';
|
||||
query.Recursive = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div id="lyricPage" data-role="page" class="page lyricPage" data-backbutton="true">
|
||||
<div>
|
||||
<div class="lyricsContainer padded-bottom-page">
|
||||
<div class="lyricsContainer padded-bottom-page" dir="auto">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ function lyricHtmlReducer(htmlAccumulator, lyric, index) {
|
||||
const lyricTime = typeof lyric.Start !== 'undefined' ? `data-lyrictime="${lyric.Start}"` : '';
|
||||
|
||||
htmlAccumulator += `<${elem} class="lyricsLine ${classes.join(' ')}" id="lyricPosition${index}" ${lyricTime}>
|
||||
<bdi>${escapeHtml(lyric.Text)}</bdi>
|
||||
${escapeHtml(lyric.Text)}
|
||||
</${elem}>`;
|
||||
|
||||
return htmlAccumulator;
|
||||
|
||||
@@ -16,7 +16,11 @@ function handleConnectionResult(page, result) {
|
||||
break;
|
||||
}
|
||||
case ConnectionState.ServerSignIn:
|
||||
Dashboard.navigate('login?serverid=' + result.Servers[0].Id, false, 'none');
|
||||
if (result.SystemInfo.StartupWizardCompleted) {
|
||||
Dashboard.navigate('login?serverid=' + result.Servers[0].Id, false, 'none');
|
||||
} else {
|
||||
Dashboard.navigate('/wizard/start');
|
||||
}
|
||||
break;
|
||||
case ConnectionState.ServerSelection:
|
||||
Dashboard.navigate('selectserver', false, 'none');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -162,6 +162,10 @@ EmbyScrollButtonsPrototype.attachedCallback = function () {
|
||||
capture: false,
|
||||
passive: true
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollHandler();
|
||||
});
|
||||
};
|
||||
|
||||
EmbyScrollButtonsPrototype.detachedCallback = function () {
|
||||
|
||||
@@ -54,6 +54,8 @@ const fetchGetItems = async (
|
||||
|
||||
export const useGetItems = (parametersOptions: ItemsApiGetItemsRequest) => {
|
||||
const currentApi = useApi();
|
||||
const isRandom = Boolean(parametersOptions.sortBy?.includes(ItemSortBy.Random));
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
'Items',
|
||||
@@ -63,7 +65,9 @@ export const useGetItems = (parametersOptions: ItemsApiGetItemsRequest) => {
|
||||
],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchGetItems(currentApi, parametersOptions, { signal }),
|
||||
gcTime: parametersOptions.sortBy?.includes(ItemSortBy.Random) ? 0 : undefined,
|
||||
gcTime: isRandom ? Infinity : undefined,
|
||||
refetchOnMount: isRandom ? false : undefined,
|
||||
refetchOnWindowFocus: isRandom ? false : undefined,
|
||||
enabled: !!currentApi.api && !!currentApi.user?.Id
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getPlugins } from './scripts/settings/webSettings';
|
||||
import taskButton from './scripts/taskbutton';
|
||||
import { pageClassOn, serverAddress } from './utils/dashboard';
|
||||
import Events from './utils/events';
|
||||
import { initializeServerConnections } from './scripts/serverNotifications';
|
||||
|
||||
import RootApp from './RootApp';
|
||||
|
||||
@@ -37,7 +38,6 @@ import './components/themeMediaPlayer';
|
||||
import './scripts/autoThemes';
|
||||
import './scripts/mouseManager';
|
||||
import './scripts/screensavermanager';
|
||||
import './scripts/serverNotifications';
|
||||
|
||||
// Import site styles
|
||||
import './styles/site.scss';
|
||||
@@ -66,6 +66,9 @@ build: ${__JF_BUILD_VERSION__}`);
|
||||
document.querySelector('.skinHeader').classList.remove('noHeaderRight');
|
||||
});
|
||||
|
||||
// Initialize app host
|
||||
await appHost.init();
|
||||
|
||||
// Initialize the api client
|
||||
const serverUrl = await serverAddress();
|
||||
if (serverUrl) {
|
||||
@@ -107,6 +110,9 @@ build: ${__JF_BUILD_VERSION__}`);
|
||||
Events.on(apiClient, 'requestfail', appRouter.onRequestFail);
|
||||
});
|
||||
|
||||
// Start server notifications
|
||||
initializeServerConnections();
|
||||
|
||||
// Render the app
|
||||
await renderApp();
|
||||
|
||||
|
||||
@@ -152,8 +152,8 @@ const capabilities = Dashboard.capabilities(appHost);
|
||||
|
||||
export default new ServerConnections(
|
||||
credentialProvider,
|
||||
appHost.appName(),
|
||||
appHost.appVersion(),
|
||||
appHost.deviceName(),
|
||||
appHost.deviceId(),
|
||||
() => appHost.appName(),
|
||||
() => appHost.appVersion(),
|
||||
() => appHost.deviceName(),
|
||||
() => appHost.deviceId(),
|
||||
capabilities);
|
||||
|
||||
@@ -63,13 +63,15 @@ export default class ConnectionManager {
|
||||
// Set the minimum version to match the SDK
|
||||
self._minServerVersion = MINIMUM_VERSION;
|
||||
|
||||
self.appVersion = () => appVersion;
|
||||
self.appVersion = () => typeof appVersion === 'function' ? appVersion() : appVersion;
|
||||
|
||||
self.appName = () => appName;
|
||||
self.appName = () => typeof appName === 'function' ? appName() : appName;
|
||||
|
||||
self.capabilities = () => capabilities;
|
||||
|
||||
self.deviceId = () => deviceId;
|
||||
self.deviceName = () => typeof deviceName === 'function' ? deviceName() : deviceName;
|
||||
|
||||
self.deviceId = () => typeof deviceId === 'function' ? deviceId() : deviceId;
|
||||
|
||||
self.credentialProvider = () => credentialProvider;
|
||||
|
||||
@@ -137,7 +139,7 @@ export default class ConnectionManager {
|
||||
let apiClient = self.getApiClient(server.Id);
|
||||
|
||||
if (!apiClient) {
|
||||
apiClient = new ApiClient(serverUrl, appName, appVersion, deviceName, deviceId);
|
||||
apiClient = new ApiClient(serverUrl, self.appName(), self.appVersion(), self.deviceName(), self.deviceId());
|
||||
|
||||
self._apiClients.push(apiClient);
|
||||
|
||||
@@ -232,12 +234,12 @@ export default class ConnectionManager {
|
||||
headers: {
|
||||
[AUTHORIZATION_HEADER]: getAuthorizationHeader(
|
||||
{
|
||||
name: appName,
|
||||
version: appVersion
|
||||
name: self.appName(),
|
||||
version: self.appVersion()
|
||||
},
|
||||
{
|
||||
id: deviceId,
|
||||
name: deviceName
|
||||
id: self.deviceId(),
|
||||
name: self.deviceName()
|
||||
},
|
||||
server.AccessToken
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user