Compare commits
301 Commits
release-10
...
renovate/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
971c4b00f2 | ||
|
|
73c1274011 | ||
|
|
6a70ed2ba7 | ||
|
|
0ecdde9f26 | ||
|
|
2b18fc3636 | ||
|
|
e7f6930ca3 | ||
|
|
42d1a0563f | ||
|
|
e0b27bd1a6 | ||
|
|
40ad1fc595 | ||
|
|
f4544a676f | ||
|
|
e9d0832cb2 | ||
|
|
b80b971231 | ||
|
|
977bfbfe73 | ||
|
|
97bfdddacc | ||
|
|
32cee8ac28 | ||
|
|
2b2672be70 | ||
|
|
ed4417b7de | ||
|
|
6be1cb58a5 | ||
|
|
a326654542 | ||
|
|
948d792677 | ||
|
|
b7f1a46841 | ||
|
|
ff42033d77 | ||
|
|
a1bc62d158 | ||
|
|
742918f39f | ||
|
|
08f8b2d2f7 | ||
|
|
4781f5e99f | ||
|
|
d4599dc02c | ||
|
|
522b7455f4 | ||
|
|
9766d77fd4 | ||
|
|
f39c9a3f57 | ||
|
|
1376d435b8 | ||
|
|
66013fe785 | ||
|
|
890d0a6d41 | ||
|
|
63d9b03106 | ||
|
|
dd20b323eb | ||
|
|
20dcfb4398 | ||
|
|
5bf657c57a | ||
|
|
5cf6b3e902 | ||
|
|
10625a0360 | ||
|
|
b2703d0c59 | ||
|
|
d3a2115db5 | ||
|
|
01f1e97f3b | ||
|
|
3909785249 | ||
|
|
3ca6229023 | ||
|
|
c7ec17bf09 | ||
|
|
f583c72e13 | ||
|
|
d0e37f6373 | ||
|
|
4b8f6d14a0 | ||
|
|
7ccc98ec7a | ||
|
|
e41ea5a293 | ||
|
|
193d610d0d | ||
|
|
1985c7deb7 | ||
|
|
3b1509afc0 | ||
|
|
0e97d3a7f8 | ||
|
|
42e6180700 | ||
|
|
bf72cd699a | ||
|
|
6ba590b59d | ||
|
|
dc7226eeea | ||
|
|
46ddff668b | ||
|
|
ca3cf922f9 | ||
|
|
122e6aadeb | ||
|
|
2abce18d1a | ||
|
|
0769eec314 | ||
|
|
3e93be0292 | ||
|
|
364841c24c | ||
|
|
15644cb097 | ||
|
|
19ba529a70 | ||
|
|
6d8dce739b | ||
|
|
da336b75be | ||
|
|
c9e04a33f5 | ||
|
|
e2c38ae3f1 | ||
|
|
32de578f50 | ||
|
|
768dba60bd | ||
|
|
0a6309de44 | ||
|
|
225ca1fd41 | ||
|
|
46223f4af3 | ||
|
|
d3793f02eb | ||
|
|
7bbfa02c4a | ||
|
|
3b5b1345fa | ||
|
|
eafc65c508 | ||
|
|
23b4ed4fee | ||
|
|
6cb53c7c13 | ||
|
|
21f708d3f4 | ||
|
|
56275a3a7b | ||
|
|
2a10d11253 | ||
|
|
5df49ca297 | ||
|
|
b527141177 | ||
|
|
269590adb5 | ||
|
|
2f16a16375 | ||
|
|
762f95cb72 | ||
|
|
ecb7a35425 | ||
|
|
f7cebe7381 | ||
|
|
024ea6b1f7 | ||
|
|
ae1f025557 | ||
|
|
068f2e691b | ||
|
|
86db4bd0e1 | ||
|
|
4008ec04b9 | ||
|
|
c32a3c8386 | ||
|
|
a8090af035 | ||
|
|
7e4eb2f43f | ||
|
|
3d721d9658 | ||
|
|
6c38e30e31 | ||
|
|
3a59ab4c32 | ||
|
|
c24b7376f8 | ||
|
|
a12dc57099 | ||
|
|
bef60736b0 | ||
|
|
a2fef21af2 | ||
|
|
c2df080ad8 | ||
|
|
56d23e13eb | ||
|
|
cb8b2836c2 | ||
|
|
7e663d57f1 | ||
|
|
cd8f3c4831 | ||
|
|
7dc51349de | ||
|
|
2ec16d73dc | ||
|
|
6c372f61f1 | ||
|
|
d9e4818c70 | ||
|
|
6c12efe202 | ||
|
|
3894236c46 | ||
|
|
f11ca9584a | ||
|
|
e1c761031c | ||
|
|
46616a1d25 | ||
|
|
e7b7938841 | ||
|
|
498efbe493 | ||
|
|
1b54ae6cac | ||
|
|
0efb74e0b7 | ||
|
|
468f3085d2 | ||
|
|
b575915f24 | ||
|
|
5bbcf2dd96 | ||
|
|
1f7ee737c3 | ||
|
|
96467b7c51 | ||
|
|
e1d9abc3f9 | ||
|
|
c272eba4f5 | ||
|
|
41bde8ecd8 | ||
|
|
a9ea556b15 | ||
|
|
20fbb8f24e | ||
|
|
d9adbd6bc8 | ||
|
|
c23b722426 | ||
|
|
fa122fc15a | ||
|
|
d9d44c49c1 | ||
|
|
4e4d211117 | ||
|
|
11860af730 | ||
|
|
761bd31519 | ||
|
|
652682bbe1 | ||
|
|
3ec4b58ff8 | ||
|
|
0a9e08cdab | ||
|
|
74240bd265 | ||
|
|
abcc625b60 | ||
|
|
13c49d5144 | ||
|
|
9673982c79 | ||
|
|
a2516723d4 | ||
|
|
1a7fe7ece0 | ||
|
|
699e749a49 | ||
|
|
571e699b7e | ||
|
|
6e06787a0a | ||
|
|
2d7a38c9cc | ||
|
|
a2e9231983 | ||
|
|
573c31032d | ||
|
|
2a9be36c7b | ||
|
|
c796529544 | ||
|
|
73d66d5612 | ||
|
|
3dd636d520 | ||
|
|
0c9e61fdc2 | ||
|
|
463b3559b1 | ||
|
|
d744639c22 | ||
|
|
bf8b002142 | ||
|
|
e64e1f6535 | ||
|
|
23184b3e18 | ||
|
|
611f9eb962 | ||
|
|
0bec3f2577 | ||
|
|
f1e6d8726c | ||
|
|
1a8e6d4add | ||
|
|
026349babb | ||
|
|
e1724f5ec1 | ||
|
|
7e89b5f564 | ||
|
|
77df7d5d85 | ||
|
|
afb4c4c830 | ||
|
|
1295d5592e | ||
|
|
c70fd69315 | ||
|
|
d4ff5a27de | ||
|
|
acf7b6889d | ||
|
|
1d1542c446 | ||
|
|
c5c5fc6743 | ||
|
|
20ce3da458 | ||
|
|
d1df6ae42b | ||
|
|
220ff70d30 | ||
|
|
0427ae6bb1 | ||
|
|
702412a425 | ||
|
|
f4883e19a8 | ||
|
|
2fae712ea9 | ||
|
|
0a545e1c7c | ||
|
|
530df76316 | ||
|
|
5b85e0e0ac | ||
|
|
b517c27bf6 | ||
|
|
7be11bc9d9 | ||
|
|
af10633a7d | ||
|
|
91cfc15e1c | ||
|
|
2687b3daf1 | ||
|
|
de2fe3f52d | ||
|
|
9b2ce2886e | ||
|
|
0a9ddb490d | ||
|
|
99fea57a95 | ||
|
|
ac2d059219 | ||
|
|
134848d082 | ||
|
|
95da2b3868 | ||
|
|
d1c1f74763 | ||
|
|
ca6204518a | ||
|
|
4c88d6b28b | ||
|
|
7edcf9501e | ||
|
|
741babbbc3 | ||
|
|
3616f5b81e | ||
|
|
5857c02921 | ||
|
|
1aa990f1bf | ||
|
|
bd8d0e786d | ||
|
|
d46c34a901 | ||
|
|
c02c9690a6 | ||
|
|
04df0b9106 | ||
|
|
90744a57ba | ||
|
|
78926c2bea | ||
|
|
76b704d897 | ||
|
|
5648423c12 | ||
|
|
f1cb49ec38 | ||
|
|
2454034d3f | ||
|
|
8a6a97a437 | ||
|
|
81663eec15 | ||
|
|
d74a148db4 | ||
|
|
a75f1124a6 | ||
|
|
4da2be5038 | ||
|
|
cbac146558 | ||
|
|
837f4e7479 | ||
|
|
625ab3fede | ||
|
|
290f8285c7 | ||
|
|
13f35d0e4b | ||
|
|
918af8fa65 | ||
|
|
d168815ba4 | ||
|
|
8026ae3137 | ||
|
|
bef489cba4 | ||
|
|
1d02d2e9d2 | ||
|
|
8c1958db46 | ||
|
|
c778f9cc2c | ||
|
|
ebf2e85af8 | ||
|
|
e30d2a324e | ||
|
|
f69e509898 | ||
|
|
a9955fcb35 | ||
|
|
430e8fb98c | ||
|
|
1e77ecffcc | ||
|
|
788475b7b8 | ||
|
|
c72f093f88 | ||
|
|
b5fb51bfa9 | ||
|
|
ce2958351b | ||
|
|
54a828c123 | ||
|
|
0cd8dcf946 | ||
|
|
c466497733 | ||
|
|
813f0a6399 | ||
|
|
9d17ef0dce | ||
|
|
b94f7021dc | ||
|
|
29bd1a2dc8 | ||
|
|
95f910cc6a | ||
|
|
531ceedcbb | ||
|
|
d2afde2e01 | ||
|
|
860fbbf371 | ||
|
|
37432ff513 | ||
|
|
0a14b8212d | ||
|
|
068a42c5bf | ||
|
|
7f2bd12e98 | ||
|
|
6ae937eab4 | ||
|
|
535104ac80 | ||
|
|
1915ad08e2 | ||
|
|
01e1345a89 | ||
|
|
258ee7bacf | ||
|
|
48eee02ead | ||
|
|
64870f9247 | ||
|
|
7ff6490028 | ||
|
|
a7d5b09bbd | ||
|
|
cc933c6678 | ||
|
|
751985cab3 | ||
|
|
e956a0a635 | ||
|
|
57a139d80c | ||
|
|
f701a1fcbd | ||
|
|
d0341fb3d8 | ||
|
|
c8fd928167 | ||
|
|
a8c93b3394 | ||
|
|
b5db940fc3 | ||
|
|
505cc64ef6 | ||
|
|
24477e8025 | ||
|
|
23097a4502 | ||
|
|
54bfe07a01 | ||
|
|
5464ee5ba4 | ||
|
|
9e284cc93e | ||
|
|
9741d0c603 | ||
|
|
6441aa0269 | ||
|
|
4373c8b058 | ||
|
|
4c14a8b529 | ||
|
|
8a7148164b | ||
|
|
780905e670 | ||
|
|
b0c9e11404 | ||
|
|
fee9b12f1b | ||
|
|
f510ad5874 | ||
|
|
2cdbbd3f2d | ||
|
|
5c8c86b766 | ||
|
|
4cccd63831 | ||
|
|
c1161c7c5a |
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -1 +1,5 @@
|
||||
* @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@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ inputs.commit }}
|
||||
show-progress: false
|
||||
|
||||
- name: Initialize CodeQL 🛠️
|
||||
uses: github/codeql-action/init@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
|
||||
uses: github/codeql-action/init@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3.31.4
|
||||
with:
|
||||
queries: security-and-quality
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild 📦
|
||||
uses: github/codeql-action/autobuild@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
|
||||
uses: github/codeql-action/autobuild@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3.31.4
|
||||
|
||||
- name: Perform CodeQL Analysis 🧪
|
||||
uses: github/codeql-action/analyze@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
|
||||
uses: github/codeql-action/analyze@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3.31.4
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
4
.github/workflows/__package.yml
vendored
4
.github/workflows/__package.yml
vendored
@@ -14,12 +14,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ inputs.commit || github.sha }}
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
8
.github/workflows/__quality_checks.yml
vendored
8
.github/workflows/__quality_checks.yml
vendored
@@ -14,13 +14,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ inputs.commit }}
|
||||
show-progress: false
|
||||
|
||||
- name: Scan
|
||||
uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
with:
|
||||
## Workaround from https://github.com/actions/dependency-review-action/issues/456
|
||||
## TODO: Remove when necessary
|
||||
@@ -42,13 +42,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout ⬇️
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ inputs.commit }}
|
||||
show-progress: false
|
||||
|
||||
- name: Setup node environment ⚙️
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
6
.github/workflows/pull_request.yml
vendored
6
.github/workflows/pull_request.yml
vendored
@@ -80,12 +80,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
@@ -95,6 +95,6 @@ jobs:
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run eslint
|
||||
uses: CatChen/eslint-suggestion-action@4dda35decf912ab18ea3e071acec2c6c2eda00b6 # v4.1.18
|
||||
uses: CatChen/eslint-suggestion-action@4ee415529307a8ca0260b4a3775484802523e5af # v4.1.19
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
126
package-lock.json
generated
126
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.11.0",
|
||||
"version": "10.12.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.11.0",
|
||||
"version": "10.12.0",
|
||||
"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.202510030502",
|
||||
"@jellyfin/sdk": "0.0.0-unstable.202512091852",
|
||||
"@jellyfin/ux-web": "1.0.0",
|
||||
"@mui/icons-material": "6.4.12",
|
||||
"@mui/material": "6.4.12",
|
||||
@@ -129,6 +129,7 @@
|
||||
"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",
|
||||
@@ -4129,12 +4130,12 @@
|
||||
"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.202510030502",
|
||||
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202510030502.tgz",
|
||||
"integrity": "sha512-khxWQ4dKirp03sLCkz8s+MrfAmJUGD4xTfVHRq3NOsgz8ueDH3qVxdya8YnV8U83p+bfZpGtnf1IAvyh+f959g==",
|
||||
"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==",
|
||||
"license": "MPL-2.0",
|
||||
"peerDependencies": {
|
||||
"axios": "^1.3.4"
|
||||
"axios": "^1.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jellyfin/ux-web": {
|
||||
@@ -7233,6 +7234,7 @@
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/atob": {
|
||||
@@ -7312,14 +7314,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
|
||||
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -8420,6 +8422,7 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
@@ -9509,6 +9512,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
@@ -11962,15 +11966,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
|
||||
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
@@ -12364,6 +12369,13 @@
|
||||
"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",
|
||||
@@ -23182,6 +23194,27 @@
|
||||
"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",
|
||||
@@ -23832,6 +23865,26 @@
|
||||
"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",
|
||||
@@ -27097,9 +27150,9 @@
|
||||
"integrity": "sha512-C0OlBxIr9NdeFESMTA/OVDqNSWtog6Mi7wwzwH12xbZpxsMD0RgCupUcIP7zZgcpTNecW3fZq5d6xVo7Q8HEJw=="
|
||||
},
|
||||
"@jellyfin/sdk": {
|
||||
"version": "0.0.0-unstable.202510030502",
|
||||
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202510030502.tgz",
|
||||
"integrity": "sha512-khxWQ4dKirp03sLCkz8s+MrfAmJUGD4xTfVHRq3NOsgz8ueDH3qVxdya8YnV8U83p+bfZpGtnf1IAvyh+f959g==",
|
||||
"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==",
|
||||
"requires": {}
|
||||
},
|
||||
"@jellyfin/ux-web": {
|
||||
@@ -29097,13 +29150,13 @@
|
||||
"dev": true
|
||||
},
|
||||
"axios": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
|
||||
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -32390,14 +32443,15 @@
|
||||
}
|
||||
},
|
||||
"form-data": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
|
||||
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
}
|
||||
},
|
||||
@@ -32678,6 +32732,12 @@
|
||||
"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",
|
||||
@@ -40112,6 +40172,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -40553,6 +40620,17 @@
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.11.0",
|
||||
"version": "10.12.0",
|
||||
"description": "Web interface for Jellyfin",
|
||||
"repository": "https://github.com/jellyfin/jellyfin-web",
|
||||
"license": "GPL-2.0-or-later",
|
||||
@@ -66,6 +66,7 @@
|
||||
"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",
|
||||
@@ -84,7 +85,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.202510030502",
|
||||
"@jellyfin/sdk": "0.0.0-unstable.202512091852",
|
||||
"@jellyfin/ux-web": "1.0.0",
|
||||
"@mui/icons-material": "6.4.12",
|
||||
"@mui/material": "6.4.12",
|
||||
@@ -161,7 +162,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,13 +13,16 @@ 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 appTheme from 'themes/themes';
|
||||
import { LayoutMode } from 'constants/layoutMode';
|
||||
import browser from 'scripts/browser';
|
||||
import appTheme from 'themes';
|
||||
import { ThemeStorageManager } from 'themes/themeStorageManager';
|
||||
|
||||
const layoutMode = localStorage.getItem('layout');
|
||||
const isExperimentalLayout = layoutMode === 'experimental';
|
||||
const layoutMode = browser.tv ? LayoutMode.Tv : localStorage.getItem(LAYOUT_SETTING_KEY);
|
||||
const isExperimentalLayout = !layoutMode || layoutMode === LayoutMode.Experimental;
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
|
||||
1
src/apiclient.d.ts
vendored
1
src/apiclient.d.ts
vendored
@@ -136,6 +136,7 @@ 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>;
|
||||
|
||||
@@ -94,7 +94,13 @@ const BaseCard = ({
|
||||
{title}
|
||||
</Typography>
|
||||
{text && (
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
<Typography
|
||||
variant='body2'
|
||||
color='text.secondary'
|
||||
sx={{
|
||||
lineBreak: 'anywhere'
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -5,13 +5,14 @@ import MusicNote from '@mui/icons-material/MusicNote';
|
||||
import MusicVideo from '@mui/icons-material/MusicVideo';
|
||||
import Tv from '@mui/icons-material/Tv';
|
||||
import VideoLibrary from '@mui/icons-material/VideoLibrary';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import SvgIcon from '@mui/material/SvgIcon';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useItemCounts } from 'apps/dashboard/features/metrics/api/useItemCounts';
|
||||
import MetricCard, { type MetricCardProps } from 'apps/dashboard/features/metrics/components/MetricCard';
|
||||
import globalize from 'lib/globalize';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
interface MetricDefinition {
|
||||
key: keyof ItemCounts
|
||||
@@ -75,23 +76,27 @@ const ItemCountsWidget = () => {
|
||||
}, [ counts, isPending ]);
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: 'stretch',
|
||||
marginTop: 2
|
||||
}}
|
||||
>
|
||||
{cards.map(card => (
|
||||
<Grid
|
||||
key={card.metrics.map(metric => metric.label).join('-')}
|
||||
size={{ xs: 12, sm: 6, lg: 4 }}
|
||||
>
|
||||
<MetricCard {...card} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
<Box>
|
||||
<Grid
|
||||
container
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: 'stretch'
|
||||
}}
|
||||
>
|
||||
{cards.map(card => (
|
||||
<Grid
|
||||
key={card.metrics.map(metric => metric.label).join('-')}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
lg={4}
|
||||
>
|
||||
<MetricCard {...card} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -15,9 +15,15 @@ type ServerInfoWidgetProps = {
|
||||
onScanLibrariesClick?: () => void;
|
||||
onRestartClick?: () => void;
|
||||
onShutdownClick?: () => void;
|
||||
isScanning?: boolean;
|
||||
};
|
||||
|
||||
const ServerInfoWidget = ({ onScanLibrariesClick, onRestartClick, onShutdownClick }: ServerInfoWidgetProps) => {
|
||||
const ServerInfoWidget = ({
|
||||
onScanLibrariesClick,
|
||||
onRestartClick,
|
||||
onShutdownClick,
|
||||
isScanning
|
||||
}: ServerInfoWidgetProps) => {
|
||||
const { data: systemInfo, isPending } = useSystemInfo();
|
||||
|
||||
return (
|
||||
@@ -63,6 +69,7 @@ const ServerInfoWidget = ({ onScanLibrariesClick, onRestartClick, onShutdownClic
|
||||
sx={{
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
disabled={isScanning}
|
||||
>
|
||||
{globalize.translate('ButtonScanAllLibraries')}
|
||||
</Button>
|
||||
|
||||
@@ -47,5 +47,8 @@ export const HelpLinks = [
|
||||
'/dashboard/users/profile'
|
||||
],
|
||||
url: 'https://jellyfin.org/docs/general/server/users/'
|
||||
}, {
|
||||
paths: ['/dashboard/backups'],
|
||||
url: 'https://jellyfin.org/docs/general/administration/backup-and-restore/'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -78,7 +78,6 @@ const TunerDeviceCard = ({ tunerHost }: TunerDeviceCardProps) => {
|
||||
title={tunerHost.FriendlyName || getTunerName(tunerHost.Type) || ''}
|
||||
text={tunerHost.Url || ''}
|
||||
icon={<DvrIcon sx={{ fontSize: 70 }} />}
|
||||
width={340}
|
||||
action={true}
|
||||
actionRef={actionRef}
|
||||
onActionClick={onActionClick}
|
||||
|
||||
@@ -12,7 +12,13 @@ const fetchServerLog = async (
|
||||
const response = await getSystemApi(api).getLogFile({ name }, options);
|
||||
|
||||
// FIXME: TypeScript SDK thinks it is returning a File but in reality it is a string
|
||||
return response.data as never as string;
|
||||
const data = response.data as never as string | object;
|
||||
|
||||
if (typeof data === 'object') {
|
||||
return JSON.stringify(data, null, 2);
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
};
|
||||
export const useServerLog = (name: string) => {
|
||||
const { api } = useApi();
|
||||
|
||||
21
src/apps/dashboard/features/users/api/useAuthProviders.ts
Normal file
21
src/apps/dashboard/features/users/api/useAuthProviders.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
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
|
||||
});
|
||||
};
|
||||
22
src/apps/dashboard/features/users/api/useChannels.ts
Normal file
22
src/apps/dashboard/features/users/api/useChannels.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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
|
||||
});
|
||||
};
|
||||
15
src/apps/dashboard/features/users/api/useCreateUser.ts
Normal file
15
src/apps/dashboard/features/users/api/useCreateUser.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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)
|
||||
)
|
||||
});
|
||||
};
|
||||
22
src/apps/dashboard/features/users/api/useDeleteUser.ts
Normal file
22
src/apps/dashboard/features/users/api/useDeleteUser.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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 ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
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
|
||||
});
|
||||
};
|
||||
22
src/apps/dashboard/features/users/api/useNetworkConfig.ts
Normal file
22
src/apps/dashboard/features/users/api/useNetworkConfig.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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
|
||||
});
|
||||
};
|
||||
21
src/apps/dashboard/features/users/api/useParentalRatings.ts
Normal file
21
src/apps/dashboard/features/users/api/useParentalRatings.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
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
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
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
|
||||
});
|
||||
};
|
||||
22
src/apps/dashboard/features/users/api/useUpdateUser.ts
Normal file
22
src/apps/dashboard/features/users/api/useUpdateUser.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
23
src/apps/dashboard/features/users/api/useUpdateUserPolicy.ts
Normal file
23
src/apps/dashboard/features/users/api/useUpdateUserPolicy.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
24
src/apps/dashboard/features/users/api/useUser.ts
Normal file
24
src/apps/dashboard/features/users/api/useUser.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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
|
||||
});
|
||||
};
|
||||
@@ -102,7 +102,7 @@ export const Component = () => {
|
||||
}).catch(() => {
|
||||
// Server is still down
|
||||
});
|
||||
}, 5000);
|
||||
}, 45000);
|
||||
|
||||
return () => {
|
||||
clearInterval(serverCheckInterval);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import Box from '@mui/material/Box';
|
||||
@@ -16,6 +16,7 @@ import RunningTasksWidget from '../components/widgets/RunningTasksWidget';
|
||||
import DevicesWidget from '../components/widgets/DevicesWidget';
|
||||
import { useStartTask } from '../features/tasks/api/useStartTask';
|
||||
import ItemCountsWidget from '../components/widgets/ItemCountsWidget';
|
||||
import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state';
|
||||
|
||||
export const Component = () => {
|
||||
const [ isRestartConfirmDialogOpen, setIsRestartConfirmDialogOpen ] = useState(false);
|
||||
@@ -26,6 +27,10 @@ export const Component = () => {
|
||||
|
||||
const { data: tasks } = useLiveTasks({ isHidden: false });
|
||||
|
||||
const librariesTask = useMemo(() => (
|
||||
tasks?.find((value) => value.Key === 'RefreshLibrary')
|
||||
), [ tasks ]);
|
||||
|
||||
const promptRestart = useCallback(() => {
|
||||
setIsRestartConfirmDialogOpen(true);
|
||||
}, []);
|
||||
@@ -94,6 +99,7 @@ export const Component = () => {
|
||||
onScanLibrariesClick={onScanLibraries}
|
||||
onRestartClick={promptRestart}
|
||||
onShutdownClick={promptShutdown}
|
||||
isScanning={librariesTask?.State !== TaskState.Idle}
|
||||
/>
|
||||
<ItemCountsWidget />
|
||||
<RunningTasksWidget tasks={tasks} />
|
||||
|
||||
@@ -29,7 +29,8 @@ export const Component = () => {
|
||||
|
||||
const showMediaLibraryCreator = useCallback(() => {
|
||||
const mediaLibraryCreator = new MediaLibraryCreator({
|
||||
collectionTypeOptions: getCollectionTypeOptions()
|
||||
collectionTypeOptions: getCollectionTypeOptions(),
|
||||
refresh: true
|
||||
}) as Promise<boolean>;
|
||||
|
||||
void mediaLibraryCreator.then((hasChanges: boolean) => {
|
||||
@@ -69,7 +70,7 @@ export const Component = () => {
|
||||
<Button
|
||||
onClick={onScanLibraries}
|
||||
startIcon={<RefreshIcon />}
|
||||
loading={librariesTask && librariesTask.State === TaskState.Running}
|
||||
loading={librariesTask && librariesTask.State !== TaskState.Idle}
|
||||
loadingPosition='start'
|
||||
variant='outlined'
|
||||
>
|
||||
|
||||
@@ -12,7 +12,7 @@ import useLiveTasks from 'apps/dashboard/features/tasks/hooks/useLiveTasks';
|
||||
import Button from '@mui/material/Button';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { Form, Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useStartTask } from 'apps/dashboard/features/tasks/api/useStartTask';
|
||||
import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state';
|
||||
import TaskProgress from 'apps/dashboard/features/tasks/components/TaskProgress';
|
||||
@@ -22,6 +22,7 @@ import ListItemText from '@mui/material/ListItemText';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import List from '@mui/material/List';
|
||||
import Provider from 'apps/dashboard/features/livetv/components/Provider';
|
||||
import Grid from '@mui/material/Grid';
|
||||
|
||||
const CONFIG_KEY = 'livetv';
|
||||
|
||||
@@ -81,33 +82,44 @@ export const Component = () => {
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
<Form>
|
||||
{(isConfigError || isTasksError) ? (
|
||||
<Alert severity='error'>{globalize.translate('HeaderError')}</Alert>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h2'>{globalize.translate('HeaderTunerDevices')}</Typography>
|
||||
{(isConfigError || isTasksError) ? (
|
||||
<Alert severity='error'>{globalize.translate('HeaderError')}</Alert>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h2'>{globalize.translate('HeaderTunerDevices')}</Typography>
|
||||
|
||||
<Button
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
startIcon={<AddIcon />}
|
||||
component={Link}
|
||||
to='/dashboard/livetv/tuner'
|
||||
>
|
||||
{globalize.translate('ButtonAddTunerDevice')}
|
||||
</Button>
|
||||
<Button
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
startIcon={<AddIcon />}
|
||||
component={Link}
|
||||
to='/dashboard/livetv/tuner'
|
||||
>
|
||||
{globalize.translate('ButtonAddTunerDevice')}
|
||||
</Button>
|
||||
|
||||
<Stack direction='row' spacing={2}>
|
||||
{ config.TunerHosts?.map(tunerHost => (
|
||||
<TunerDeviceCard
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
{config.TunerHosts?.map(tunerHost => (
|
||||
<Grid
|
||||
key={tunerHost.Id}
|
||||
tunerHost={tunerHost}
|
||||
/>
|
||||
)) }
|
||||
</Stack>
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={3}
|
||||
lg={2.4}
|
||||
>
|
||||
<TunerDeviceCard
|
||||
key={tunerHost.Id}
|
||||
tunerHost={tunerHost}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Typography variant='h2'>{globalize.translate('HeaderGuideProviders')}</Typography>
|
||||
<Typography variant='h2'>{globalize.translate('HeaderGuideProviders')}</Typography>
|
||||
|
||||
<Stack sx={{ alignSelf: 'flex-start' }} spacing={2}>
|
||||
<Stack direction='row' spacing={1.5}>
|
||||
<Button
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
@@ -132,32 +144,33 @@ export const Component = () => {
|
||||
{(refreshGuideTask && refreshGuideTask.State === TaskState.Running) && (
|
||||
<TaskProgress task={refreshGuideTask} />
|
||||
)}
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
<MenuItem onClick={navigateToSchedulesDirect}>
|
||||
<ListItemText>Schedules Direct</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={navigateToXMLTV}>
|
||||
<ListItemText>XMLTV</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{(config.ListingProviders && config.ListingProviders?.length > 0) && (
|
||||
<List sx={{ backgroundColor: 'background.paper' }}>
|
||||
{config.ListingProviders?.map(provider => (
|
||||
<Provider
|
||||
key={provider.Id}
|
||||
provider={provider}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
<MenuItem onClick={navigateToSchedulesDirect}>
|
||||
<ListItemText>Schedules Direct</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={navigateToXMLTV}>
|
||||
<ListItemText>XMLTV</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{(config.ListingProviders && config.ListingProviders?.length > 0) && (
|
||||
<List sx={{ backgroundColor: 'background.paper' }}>
|
||||
{config.ListingProviders?.map(provider => (
|
||||
<Provider
|
||||
key={provider.Id}
|
||||
provider={provider}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -62,7 +62,7 @@ export const Component = () => {
|
||||
onClose={handleToastClose}
|
||||
message={globalize.translate('CopyLogSuccess')}
|
||||
/>
|
||||
<Container className='content-primary'>
|
||||
<Container className='content-primary' maxWidth={false}>
|
||||
<Box>
|
||||
<Typography variant='h1'>{fileName}</Typography>
|
||||
|
||||
@@ -106,7 +106,14 @@ export const Component = () => {
|
||||
|
||||
<Paper sx={{ mt: 2 }}>
|
||||
<code>
|
||||
<pre style={{ overflow:'auto', margin: 0, padding: '16px' }}>{log}</pre>
|
||||
<pre style={{
|
||||
overflow:'auto',
|
||||
margin: 0,
|
||||
padding: '16px',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}>
|
||||
{log}
|
||||
</pre>
|
||||
</code>
|
||||
</Paper>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import type { BaseItemDto, CreateUserByName } 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';
|
||||
@@ -12,10 +11,11 @@ import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import Page from '../../../../components/Page';
|
||||
import Toast from 'apps/dashboard/components/Toast';
|
||||
|
||||
type UserInput = {
|
||||
Name?: string;
|
||||
Password?: string;
|
||||
};
|
||||
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 ItemsArr = {
|
||||
Name?: string | null;
|
||||
@@ -23,6 +23,7 @@ type ItemsArr = {
|
||||
};
|
||||
|
||||
const UserNew = () => {
|
||||
const navigate = useNavigate();
|
||||
const [ channelsItems, setChannelsItems ] = useState<ItemsArr[]>([]);
|
||||
const [ mediaFoldersItems, setMediaFoldersItems ] = useState<ItemsArr[]>([]);
|
||||
const [ isErrorToastOpen, setIsErrorToastOpen ] = useState(false);
|
||||
@@ -31,6 +32,11 @@ 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 =>
|
||||
@@ -49,9 +55,7 @@ const UserNew = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaFolders = getItemsResult(result);
|
||||
|
||||
setMediaFoldersItems(mediaFolders);
|
||||
setMediaFoldersItems(getItemsResult(result));
|
||||
|
||||
const folderAccess = page.querySelector('.folderAccess') as HTMLDivElement;
|
||||
folderAccess.dispatchEvent(new CustomEvent('create'));
|
||||
@@ -67,15 +71,15 @@ const UserNew = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const channels = getItemsResult(result);
|
||||
const channelItems = getItemsResult(result);
|
||||
|
||||
setChannelsItems(channels);
|
||||
setChannelsItems(channelItems);
|
||||
|
||||
const channelAccess = page.querySelector('.channelAccess') as HTMLDivElement;
|
||||
channelAccess.dispatchEvent(new CustomEvent('create'));
|
||||
|
||||
const channelAccessContainer = page.querySelector('.channelAccessContainer') as HTMLDivElement;
|
||||
channels.length ? channelAccessContainer.classList.remove('hide') : channelAccessContainer.classList.add('hide');
|
||||
channelItems.length ? channelAccessContainer.classList.remove('hide') : channelAccessContainer.classList.add('hide');
|
||||
|
||||
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).checked = false;
|
||||
}, []);
|
||||
@@ -87,22 +91,26 @@ 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;
|
||||
}
|
||||
|
||||
(page.querySelector('#txtUsername') as HTMLInputElement).value = '';
|
||||
(page.querySelector('#txtPassword') as HTMLInputElement).value = '';
|
||||
loadMediaFolders(mediaFolders?.Items);
|
||||
loadChannels(channels?.Items);
|
||||
loading.hide();
|
||||
}, [loadChannels, loadMediaFolders, mediaFolders, channels]);
|
||||
|
||||
useEffect(() => {
|
||||
loading.show();
|
||||
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]);
|
||||
if (isMediaFoldersSuccess && isChannelsSuccess) {
|
||||
loadUser();
|
||||
}
|
||||
}, [loadUser, isMediaFoldersSuccess, isChannelsSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
@@ -112,51 +120,54 @@ const UserNew = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
loadUser();
|
||||
|
||||
const saveUser = () => {
|
||||
const userInput: UserInput = {};
|
||||
userInput.Name = (page.querySelector('#txtUsername') as HTMLInputElement).value.trim();
|
||||
userInput.Password = (page.querySelector('#txtPassword') as HTMLInputElement).value;
|
||||
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;
|
||||
|
||||
window.ApiClient.createUser(userInput).then(function (user) {
|
||||
if (!user.Id || !user.Policy) {
|
||||
throw new Error('Unexpected null user id or policy');
|
||||
}
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
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');
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('[usernew] failed to update user policy', err);
|
||||
});
|
||||
}, function () {
|
||||
setIsErrorToastOpen(true);
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -168,22 +179,32 @@ const UserNew = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
||||
const enableAllChannelsChange = function (this: HTMLInputElement) {
|
||||
const channelAccessListContainer = page.querySelector('.channelAccessListContainer') as HTMLDivElement;
|
||||
this.checked ? channelAccessListContainer.classList.add('hide') : channelAccessListContainer.classList.remove('hide');
|
||||
});
|
||||
};
|
||||
|
||||
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
||||
const enableAllFoldersChange = function (this: HTMLInputElement) {
|
||||
const folderAccessListContainer = page.querySelector('.folderAccessListContainer') as HTMLDivElement;
|
||||
this.checked ? folderAccessListContainer.classList.add('hide') : folderAccessListContainer.classList.remove('hide');
|
||||
});
|
||||
};
|
||||
|
||||
(page.querySelector('.newUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
|
||||
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', function() {
|
||||
const onCancelClick = () => {
|
||||
window.history.back();
|
||||
});
|
||||
}, [loadUser]);
|
||||
};
|
||||
|
||||
(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]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
|
||||
import Dashboard from '../../../../utils/dashboard';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
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';
|
||||
@@ -14,8 +9,12 @@ import '../../../../components/cardbuilder/card.scss';
|
||||
import '../../../../components/indicators/indicators.scss';
|
||||
import '../../../../styles/flexstyles.scss';
|
||||
import Page from '../../../../components/Page';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } 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;
|
||||
@@ -26,24 +25,15 @@ 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;
|
||||
|
||||
@@ -57,8 +47,6 @@ const UserProfiles = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
loadData();
|
||||
|
||||
const showUserMenu = (elem: HTMLElement) => {
|
||||
const card = dom.parentWithClass(elem, 'card');
|
||||
const userId = card?.getAttribute('data-userid');
|
||||
@@ -99,28 +87,19 @@ const UserProfiles = () => {
|
||||
callback: function (id: string) {
|
||||
switch (id) {
|
||||
case 'open':
|
||||
Dashboard.navigate('/dashboard/users/profile?userId=' + userId)
|
||||
.catch(err => {
|
||||
console.error('[userprofiles] failed to navigate to user edit page', err);
|
||||
});
|
||||
navigate(`/dashboard/users/profile?userId=${userId}`);
|
||||
break;
|
||||
|
||||
case 'access':
|
||||
Dashboard.navigate('/dashboard/users/access?userId=' + userId)
|
||||
.catch(err => {
|
||||
console.error('[userprofiles] failed to navigate to user library page', err);
|
||||
});
|
||||
navigate(`/dashboard/users/access?userId=${userId}`);
|
||||
break;
|
||||
|
||||
case 'parentalcontrol':
|
||||
Dashboard.navigate('/dashboard/users/parentalcontrol?userId=' + userId)
|
||||
.catch(err => {
|
||||
console.error('[userprofiles] failed to navigate to parental control page', err);
|
||||
});
|
||||
navigate(`/dashboard/users/parentalcontrol?userId=${userId}`);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
deleteUser(userId, username);
|
||||
confirmDeleteUser(userId, username);
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
@@ -131,7 +110,7 @@ const UserProfiles = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const deleteUser = (id: string, username?: string | null) => {
|
||||
const confirmDeleteUser = (id: string, username?: string | null) => {
|
||||
const title = username ? globalize.translate('DeleteName', username) : globalize.translate('DeleteUser');
|
||||
const text = globalize.translate('DeleteUserConfirmation');
|
||||
|
||||
@@ -141,32 +120,38 @@ const UserProfiles = () => {
|
||||
confirmText: globalize.translate('Delete'),
|
||||
primary: 'delete'
|
||||
}).then(function () {
|
||||
loading.show();
|
||||
window.ApiClient.deleteUser(id).then(function () {
|
||||
loadData();
|
||||
}).catch(err => {
|
||||
console.error('[userprofiles] failed to delete user', err);
|
||||
deleteUser.mutate({
|
||||
userId: id
|
||||
});
|
||||
}).catch(() => {
|
||||
// confirm dialog closed
|
||||
});
|
||||
};
|
||||
|
||||
page.addEventListener('click', function (e) {
|
||||
const onPageClick = function (e: MouseEvent) {
|
||||
const btnUserMenu = dom.parentWithClass(e.target as HTMLElement, 'btnUserMenu');
|
||||
|
||||
if (btnUserMenu) {
|
||||
showUserMenu(btnUserMenu);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
(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);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
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 />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
@@ -192,7 +177,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,37 +1,21 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React 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 loading from '../../../../components/loading/loading';
|
||||
import { useUser } from 'apps/dashboard/features/users/api/useUser';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
|
||||
const UserPassword = () => {
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const [ userName, setUserName ] = useState('');
|
||||
const { data: user, isPending } = useUser(userId ? { userId: userId } : undefined);
|
||||
|
||||
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]);
|
||||
if (isPending || !user) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
@@ -41,13 +25,13 @@ const UserPassword = () => {
|
||||
<div className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={userName}
|
||||
title={user?.Name || undefined}
|
||||
/>
|
||||
</div>
|
||||
<SectionTabs activeTab='userpassword'/>
|
||||
<div className='readOnlyContent'>
|
||||
<UserPasswordForm
|
||||
userId={userId}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,14 @@ 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
|
||||
@@ -27,15 +35,22 @@ 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) => {
|
||||
@@ -43,17 +58,10 @@ 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);
|
||||
}, []);
|
||||
@@ -62,30 +70,26 @@ 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, mediaFolders: BaseItemDto[]) => {
|
||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Channels', {
|
||||
SupportsMediaDeletion: true
|
||||
})).then(function (channelsResult) {
|
||||
let isChecked;
|
||||
let checkedAttribute;
|
||||
const itemsArr: ResetProvider[] = [];
|
||||
const loadDeleteFolders = useCallback((page: HTMLDivElement, user: UserDto, folders: BaseItemDto[]) => {
|
||||
let isChecked;
|
||||
let checkedAttribute;
|
||||
const itemsArr: ResetProvider[] = [];
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
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 channel of channelsResult.Items) {
|
||||
if (channels?.Items) {
|
||||
for (const channel of channels.Items) {
|
||||
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(channel.Id || '') != -1;
|
||||
checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
itemsArr.push({
|
||||
@@ -93,16 +97,66 @@ const UserEdit = () => {
|
||||
checkedAttribute: checkedAttribute
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setDeleteFoldersAccess(itemsArr);
|
||||
setDeleteFoldersAccess(itemsArr);
|
||||
|
||||
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 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 loadUser = useCallback((user: UserDto) => {
|
||||
const page = element.current;
|
||||
@@ -112,24 +166,6 @@ 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);
|
||||
|
||||
@@ -139,7 +175,6 @@ 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;
|
||||
@@ -163,16 +198,22 @@ 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();
|
||||
}, [loadAuthProviders, loadPasswordResetProviders, loadDeleteFolders ]);
|
||||
}, [ libraryMenu ]);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
if (!userDto) {
|
||||
console.error('[profile] No user available');
|
||||
return;
|
||||
}
|
||||
loading.show();
|
||||
getUser().then(function (user) {
|
||||
loadUser(user);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to load data', err);
|
||||
});
|
||||
}, [loadUser]);
|
||||
loadUser(userDto);
|
||||
}, [userDto, loadUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isUserSuccess) {
|
||||
loadData();
|
||||
}
|
||||
}, [loadData, isUserSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
@@ -182,8 +223,6 @@ const UserEdit = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
loadData();
|
||||
|
||||
const saveUser = (user: UserDto) => {
|
||||
if (!user.Id || !user.Policy) {
|
||||
throw new Error('Unexpected null user id or policy');
|
||||
@@ -215,53 +254,58 @@ const UserEdit = () => {
|
||||
user.Policy.EnableContentDeletionFromFolders = user.Policy.EnableContentDeletion ? [] : getCheckedElementDataIds(page.querySelectorAll('.chkFolder'));
|
||||
user.Policy.SyncPlayAccess = (page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value as SyncPlayUserAccessType;
|
||||
|
||||
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);
|
||||
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 }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = (e: Event) => {
|
||||
loading.show();
|
||||
getUser().then(function (result) {
|
||||
saveUser(result);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch user', err);
|
||||
});
|
||||
if (userDto) {
|
||||
saveUser(userDto);
|
||||
}
|
||||
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);
|
||||
|
||||
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', function() {
|
||||
window.history.back();
|
||||
});
|
||||
}, [loadData]);
|
||||
return () => {
|
||||
(page.querySelector('.editUserProfileForm') as HTMLFormElement).removeEventListener('submit', onSubmit);
|
||||
(page.querySelector('#btnCancel') as HTMLButtonElement).removeEventListener('click', onBtnCancelClick);
|
||||
};
|
||||
}, [loadData, updateUser, userDto, updateUserPolicy, navigate]);
|
||||
|
||||
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>`;
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -15,6 +16,7 @@ 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';
|
||||
@@ -56,14 +58,19 @@ 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(() => {
|
||||
if (isExtraLargeScreen) return MAX_USER_VIEWS_XL;
|
||||
if (isLargeScreen) return MAX_USER_VIEWS_LG;
|
||||
return MAX_USER_VIEWS_MD;
|
||||
}, [ isExtraLargeScreen, isLargeScreen ]);
|
||||
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 ]);
|
||||
|
||||
const { user } = useApi();
|
||||
const {
|
||||
@@ -108,6 +115,21 @@ 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,6 +9,7 @@ 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';
|
||||
@@ -99,7 +100,7 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||
|
||||
if (viewType === LibraryTab.Songs) {
|
||||
listOptions.showParentTitle = true;
|
||||
listOptions.action = 'playallfromhere';
|
||||
listOptions.action = ItemAction.PlayAllFromHere;
|
||||
listOptions.smallIcon = true;
|
||||
listOptions.showArtist = true;
|
||||
listOptions.addToListButton = true;
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { type FC } from 'react';
|
||||
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 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 globalize from 'lib/globalize';
|
||||
import type { ParentId } from 'types/library';
|
||||
import type { Section, SectionType } from 'types/sections';
|
||||
import { CardShape } from 'utils/card';
|
||||
|
||||
interface ProgramsSectionViewProps {
|
||||
parentId: ParentId;
|
||||
@@ -92,7 +94,7 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
|
||||
showChannelName: false,
|
||||
cardLayout: true,
|
||||
centerText: false,
|
||||
action: 'edit',
|
||||
action: ItemAction.Edit,
|
||||
cardFooterAside: 'none',
|
||||
preferThumb: true,
|
||||
coverImage: true,
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -76,7 +77,7 @@ const PlayOrResumeButton: FC<PlayOrResumeButtonProps> = ({
|
||||
return (
|
||||
<IconButton
|
||||
className='button-flat btnPlayOrResume'
|
||||
data-action={isResumable ? 'resume' : 'play'}
|
||||
data-action={isResumable ? ItemAction.Resume : ItemAction.Play}
|
||||
title={
|
||||
isResumable ?
|
||||
globalize.translate('ButtonResume') :
|
||||
|
||||
@@ -12,6 +12,7 @@ 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';
|
||||
@@ -45,11 +46,10 @@ export function DisplayPreferences({ onChange, values }: Readonly<DisplayPrefere
|
||||
onChange={onChange}
|
||||
value={values.layout}
|
||||
>
|
||||
<MenuItem value='auto'>{globalize.translate('Auto')}</MenuItem>
|
||||
<MenuItem value='desktop'>{globalize.translate('Desktop')}</MenuItem>
|
||||
<MenuItem value='mobile'>{globalize.translate('Mobile')}</MenuItem>
|
||||
<MenuItem value='tv'>{globalize.translate('TV')}</MenuItem>
|
||||
<MenuItem value='experimental'>{globalize.translate('Experimental')}</MenuItem>
|
||||
<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>
|
||||
</Select>
|
||||
<FormHelperText component={Stack} id='display-settings-layout-description'>
|
||||
<span>{globalize.translate('DisplayModeHelp')}</span>
|
||||
@@ -169,6 +169,30 @@ 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'
|
||||
|
||||
@@ -10,6 +10,9 @@ import themeManager from 'scripts/themeManager';
|
||||
import { currentSettings, UserSettings } from 'scripts/settings/userSettings';
|
||||
|
||||
import type { DisplaySettingsValues } from '../types/displaySettingsValues';
|
||||
import { useThemes } from 'hooks/useThemes';
|
||||
import { Theme } from 'types/webConfig';
|
||||
import { FALLBACK_THEME_ID } from 'hooks/useUserTheme';
|
||||
|
||||
interface UseDisplaySettingsParams {
|
||||
userId?: string | null;
|
||||
@@ -20,6 +23,7 @@ export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {
|
||||
const [userSettings, setUserSettings] = useState<UserSettings>();
|
||||
const [displaySettings, setDisplaySettings] = useState<DisplaySettingsValues>();
|
||||
const { __legacyApiClient__, user: currentUser } = useApi();
|
||||
const { defaultTheme } = useThemes();
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || !currentUser || !__legacyApiClient__) {
|
||||
@@ -29,7 +33,7 @@ export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {
|
||||
setLoading(true);
|
||||
|
||||
void (async () => {
|
||||
const loadedSettings = await loadDisplaySettings({ api: __legacyApiClient__, currentUser, userId });
|
||||
const loadedSettings = await loadDisplaySettings({ api: __legacyApiClient__, currentUser, userId, defaultTheme });
|
||||
|
||||
setDisplaySettings(loadedSettings.displaySettings);
|
||||
setUserSettings(loadedSettings.userSettings);
|
||||
@@ -62,15 +66,17 @@ export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {
|
||||
}
|
||||
|
||||
interface LoadDisplaySettingsParams {
|
||||
currentUser: UserDto;
|
||||
userId?: string;
|
||||
api: ApiClient;
|
||||
currentUser: UserDto
|
||||
userId?: string
|
||||
api: ApiClient
|
||||
defaultTheme?: Theme
|
||||
}
|
||||
|
||||
async function loadDisplaySettings({
|
||||
currentUser,
|
||||
userId,
|
||||
api
|
||||
api,
|
||||
defaultTheme
|
||||
}: LoadDisplaySettingsParams) {
|
||||
const settings = (!userId || userId === currentUser?.Id) ? currentSettings : new UserSettings();
|
||||
const user = (!userId || userId === currentUser?.Id) ? currentUser : await api.getUser(userId);
|
||||
@@ -78,8 +84,8 @@ async function loadDisplaySettings({
|
||||
await settings.setUserInfo(userId, api);
|
||||
|
||||
const displaySettings = {
|
||||
customCss: settings.customCss(),
|
||||
dashboardTheme: settings.dashboardTheme() || 'auto',
|
||||
customCss: settings.customCss() || '',
|
||||
dashboardTheme: settings.dashboardTheme() || defaultTheme?.id || FALLBACK_THEME_ID,
|
||||
dateTimeLocale: settings.dateTimeLocale() || 'auto',
|
||||
disableCustomCss: Boolean(settings.disableCustomCss()),
|
||||
displayMissingEpisodes: user?.Configuration?.DisplayMissingEpisodes ?? false,
|
||||
@@ -97,7 +103,8 @@ async function loadDisplaySettings({
|
||||
maxDaysForNextUp: settings.maxDaysForNextUp(),
|
||||
screensaver: settings.screensaver() || 'none',
|
||||
screensaverInterval: settings.backdropScreensaverInterval(),
|
||||
theme: settings.theme()
|
||||
slideshowInterval: settings.slideshowInterval(),
|
||||
theme: settings.theme() || defaultTheme?.id || FALLBACK_THEME_ID
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -125,7 +132,7 @@ async function saveDisplaySettings({
|
||||
userSettings.language(normalizeValue(newDisplaySettings.language));
|
||||
}
|
||||
userSettings.customCss(normalizeValue(newDisplaySettings.customCss));
|
||||
userSettings.dashboardTheme(normalizeValue(newDisplaySettings.dashboardTheme));
|
||||
userSettings.dashboardTheme(newDisplaySettings.dashboardTheme);
|
||||
userSettings.dateTimeLocale(normalizeValue(newDisplaySettings.dateTimeLocale));
|
||||
userSettings.disableCustomCss(newDisplaySettings.disableCustomCss);
|
||||
userSettings.enableBlurhash(newDisplaySettings.enableBlurHash);
|
||||
|
||||
@@ -18,5 +18,6 @@ export interface DisplaySettingsValues {
|
||||
maxDaysForNextUp: number;
|
||||
screensaver: string;
|
||||
screensaverInterval: number;
|
||||
slideshowInterval: number;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
@@ -51,19 +51,20 @@ class MediaSessionSubscriber extends PlaybackSubscriber {
|
||||
}
|
||||
|
||||
private bindNavigatorSession() {
|
||||
/* eslint-disable compat/compat */
|
||||
navigator.mediaSession.setActionHandler('pause', this.onMediaSessionAction.bind(this));
|
||||
navigator.mediaSession.setActionHandler('play', this.onMediaSessionAction.bind(this));
|
||||
navigator.mediaSession.setActionHandler('stop', this.onMediaSessionAction.bind(this));
|
||||
navigator.mediaSession.setActionHandler('previoustrack', this.onMediaSessionAction.bind(this));
|
||||
navigator.mediaSession.setActionHandler('nexttrack', this.onMediaSessionAction.bind(this));
|
||||
navigator.mediaSession.setActionHandler('seekto', this.onMediaSessionAction.bind(this));
|
||||
const actions: MediaSessionAction[] = ['pause', 'play', 'previoustrack', 'nexttrack', 'stop', 'seekto'];
|
||||
|
||||
// iOS will only show next/prev track controls or seek controls
|
||||
if (!browser.iOS) {
|
||||
navigator.mediaSession.setActionHandler('seekbackward', this.onMediaSessionAction.bind(this));
|
||||
navigator.mediaSession.setActionHandler('seekforward', this.onMediaSessionAction.bind(this));
|
||||
if (!browser.iOS) actions.push('seekbackward', 'seekforward');
|
||||
|
||||
for (const action of actions) {
|
||||
try {
|
||||
/* eslint-disable-next-line compat/compat */
|
||||
navigator.mediaSession.setActionHandler(action, this.onMediaSessionAction.bind(this));
|
||||
} catch (err) {
|
||||
// NOTE: Some legacy (TV) browsers lack support for the stop and seekto actions
|
||||
console.warn(`[MediaSessionSubscriber] Failed to add "${action}" action handler`, err);
|
||||
}
|
||||
}
|
||||
/* eslint-enable compat/compat */
|
||||
}
|
||||
|
||||
private onMediaSessionAction(details: MediaSessionActionDetails) {
|
||||
|
||||
@@ -78,12 +78,14 @@ export abstract class PlaybackSubscriber {
|
||||
constructor(
|
||||
protected readonly playbackManager: PlaybackManager
|
||||
) {
|
||||
// Bind player events before invoking any player change handlers
|
||||
Events.on(playbackManager, PlaybackManagerEvent.PlayerChange, this.bindPlayerEvents.bind(this));
|
||||
|
||||
Object.entries(this.playbackManagerEvents).forEach(([event, handler]) => {
|
||||
if (handler) Events.on(playbackManager, event, handler);
|
||||
});
|
||||
|
||||
this.bindPlayerEvents();
|
||||
Events.on(playbackManager, PlaybackManagerEvent.PlayerChange, this.bindPlayerEvents.bind(this));
|
||||
}
|
||||
|
||||
private bindPlayerEvents() {
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
||||
import React, { FunctionComponent, useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import React, { FunctionComponent, useEffect, 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 Page from '../../../../components/Page';
|
||||
import { AppFeature } from 'constants/appFeature';
|
||||
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';
|
||||
|
||||
const UserProfile: FunctionComponent = () => {
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const [ userName, setUserName ] = useState('');
|
||||
const { data: user, isPending: isUserPending } = useUser(userId ? { userId: userId } : undefined);
|
||||
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
@@ -30,50 +32,38 @@ const UserProfile: FunctionComponent = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
console.error('[userprofile] missing user id');
|
||||
return;
|
||||
if (!user?.Name || !user?.Id) {
|
||||
throw new Error('Unexpected null user name or id');
|
||||
}
|
||||
|
||||
loading.show();
|
||||
window.ApiClient.getUser(userId).then(function (user) {
|
||||
if (!user.Name || !user.Id) {
|
||||
throw new Error('Unexpected null user name or id');
|
||||
}
|
||||
void libraryMenu.then(menu => menu.setTitle(user.Name));
|
||||
|
||||
setUserName(user.Name);
|
||||
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');
|
||||
}
|
||||
|
||||
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);
|
||||
let imageUrl = 'assets/img/avatar.png';
|
||||
if (user.PrimaryImageTag) {
|
||||
imageUrl = window.ApiClient.getUserImageUrl(user.Id, {
|
||||
tag: user.PrimaryImageTag,
|
||||
type: 'Primary'
|
||||
});
|
||||
loading.hide();
|
||||
}
|
||||
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('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 load data', err);
|
||||
console.error('[userprofile] failed to get current user', err);
|
||||
});
|
||||
}, [userId]);
|
||||
}, [user, libraryMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
@@ -125,7 +115,9 @@ const UserProfile: FunctionComponent = () => {
|
||||
userImage.style.backgroundImage = 'url(' + reader.result + ')';
|
||||
window.ApiClient.uploadUserImage(userId, ImageType.Primary, file).then(function () {
|
||||
loading.hide();
|
||||
reloadUser();
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['User']
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('[userprofile] failed to upload image', err);
|
||||
});
|
||||
@@ -134,7 +126,7 @@ const UserProfile: FunctionComponent = () => {
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).addEventListener('click', function () {
|
||||
const onDeleteImageClick = function () {
|
||||
if (!userId) {
|
||||
console.error('[userprofile] missing user id');
|
||||
return;
|
||||
@@ -147,25 +139,41 @@ const UserProfile: FunctionComponent = () => {
|
||||
loading.show();
|
||||
window.ApiClient.deleteUserImage(userId, ImageType.Primary).then(function () {
|
||||
loading.hide();
|
||||
reloadUser();
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['User']
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('[userprofile] failed to delete image', err);
|
||||
});
|
||||
}).catch(() => {
|
||||
// confirm dialog closed
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
(page.querySelector('#btnAddImage') as HTMLButtonElement).addEventListener('click', function () {
|
||||
const addImageClick = function () {
|
||||
const uploadImage = page.querySelector('#uploadImage') as HTMLInputElement;
|
||||
uploadImage.value = '';
|
||||
uploadImage.click();
|
||||
});
|
||||
};
|
||||
|
||||
(page.querySelector('#uploadImage') as HTMLInputElement).addEventListener('change', function (evt: Event) {
|
||||
setFiles(evt);
|
||||
});
|
||||
}, [reloadUser, userId]);
|
||||
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 />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
@@ -195,7 +203,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' }}>
|
||||
{userName}
|
||||
{user?.Name}
|
||||
</h2>
|
||||
<br />
|
||||
<Button
|
||||
@@ -213,7 +221,7 @@ const UserProfile: FunctionComponent = () => {
|
||||
</div>
|
||||
</div>
|
||||
<UserPasswordForm
|
||||
userId={userId}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
12
src/assets/img/devices/titanos.svg
Normal file
12
src/assets/img/devices/titanos.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="35" height="33" viewBox="0 0 35 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<script xmlns="" />
|
||||
<path
|
||||
d="M0.0146346 0.593029L0.764804 3.57722C0.809995 3.75303 0.945568 3.88826 1.12633 3.92432L14.765 6.91753C15.1897 7.01219 15.5106 7.37733 15.5377 7.81459L16.6585 23.9031C16.6675 24.0158 16.7127 24.1195 16.7895 24.2006L17.133 24.5567C17.2233 24.6514 17.3454 24.7055 17.4764 24.7055C17.6075 24.7055 17.7295 24.6514 17.8199 24.5567L18.1633 24.2006C18.2401 24.1195 18.2853 24.0158 18.2944 23.9031L19.4151 7.81459C19.4467 7.38184 19.7631 7.01219 20.1879 6.91753L33.8265 3.92432C34.0027 3.88375 34.1428 3.75303 34.188 3.57722L34.9382 0.593029C34.9789 0.430747 34.9382 0.268465 34.8162 0.146753C34.6987 0.0250412 34.536 -0.0245451 34.3733 0.0115177L17.6843 3.66738C17.5442 3.69893 17.3996 3.69893 17.2595 3.66738L0.579521 0.0160255C0.543368 0.00250199 0.511735 0.00250199 0.475582 0.00250199C0.349047 0.00250199 0.227032 0.0520882 0.13665 0.146753C0.0191537 0.268465 -0.0260373 0.430747 0.0146346 0.593029Z"
|
||||
fill="#fff" />
|
||||
<path
|
||||
d="M23.0349 32.301L23.8257 32.0936C23.9658 32.0576 24.0743 31.9494 24.0969 31.8096L27.7528 14.0397C27.8116 13.7062 28.0692 13.4357 28.4036 13.3545L31.7567 12.5476C31.8878 12.5161 31.9872 12.4169 32.0188 12.2862L32.8142 9.11267C32.8458 8.99095 32.8142 8.86474 32.7193 8.77458C32.6515 8.70696 32.5611 8.6709 32.4708 8.6709C32.4436 8.6709 32.412 8.6709 32.3849 8.67991L24.3319 10.6228C24.1782 10.6589 24.0698 10.7896 24.0607 10.9429L22.5965 31.9268C22.5875 32.0395 22.6327 32.1477 22.7231 32.2244C22.8089 32.2965 22.9264 32.3235 23.0394 32.2965L23.0349 32.301Z"
|
||||
fill="#fff" />
|
||||
<path
|
||||
d="M10.996 10.6274L2.94297 8.68455C2.91586 8.67554 2.88422 8.67554 2.85711 8.67554C2.76221 8.67554 2.67635 8.7116 2.60856 8.77922C2.51818 8.86937 2.48654 8.99109 2.51366 9.11731L3.30902 12.2908C3.34065 12.4216 3.44007 12.5207 3.57113 12.5523L6.92429 13.3592C7.25871 13.4403 7.51629 13.7108 7.57504 14.0489L11.231 31.8053C11.2581 31.9495 11.362 32.0622 11.4976 32.0938L12.2975 32.3011C12.406 32.3282 12.5189 32.3011 12.6048 32.2245C12.6907 32.1524 12.7404 32.0442 12.7313 31.927L11.2671 10.943C11.2581 10.7852 11.1451 10.659 10.996 10.6229V10.6274Z"
|
||||
fill="#fff" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -1,27 +1,12 @@
|
||||
import { appRouter } from './router/appRouter';
|
||||
import browser from '../scripts/browser';
|
||||
import dialog from './dialog/dialog';
|
||||
import globalize from '../lib/globalize';
|
||||
|
||||
export default async function (text, title) {
|
||||
// Modals seem to be blocked on Web OS and Tizen 2.x
|
||||
const canUseNativeAlert = !!(
|
||||
!browser.web0s
|
||||
&& !(browser.tizenVersion && (browser.tizenVersion < 3 || browser.tizenVersion >= 8))
|
||||
&& browser.tv
|
||||
&& window.alert
|
||||
);
|
||||
|
||||
const options = typeof text === 'string' ? { title, text } : text;
|
||||
|
||||
await appRouter.ready();
|
||||
|
||||
if (canUseNativeAlert) {
|
||||
alert((options.text || '').replaceAll('<br/>', '\n'));
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
options.buttons = [
|
||||
{
|
||||
name: globalize.translate('ButtonGotIt'),
|
||||
|
||||
@@ -6,12 +6,14 @@ 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';
|
||||
|
||||
const BrowserName = {
|
||||
tizen: 'Samsung Smart TV',
|
||||
web0s: 'LG Smart TV',
|
||||
titanos: 'Titan OS',
|
||||
operaTv: 'Opera TV',
|
||||
xboxOne: 'Xbox One',
|
||||
ps4: 'Sony PS4',
|
||||
@@ -180,7 +182,7 @@ function supportsFullscreen() {
|
||||
}
|
||||
|
||||
function getDefaultLayout() {
|
||||
return 'desktop';
|
||||
return LayoutMode.Experimental;
|
||||
}
|
||||
|
||||
function supportsHtmlMediaAutoplay() {
|
||||
@@ -370,7 +372,7 @@ export const appHost = {
|
||||
|
||||
return getDefaultLayout();
|
||||
},
|
||||
getDeviceProfile: getDeviceProfile,
|
||||
getDeviceProfile,
|
||||
init: function () {
|
||||
if (window.NativeShell) {
|
||||
return window.NativeShell.AppHost.init();
|
||||
|
||||
@@ -12,9 +12,7 @@ function enableAnimation() {
|
||||
}
|
||||
|
||||
function enableRotation() {
|
||||
return !browser.tv
|
||||
// Causes high cpu usage
|
||||
&& !browser.firefox;
|
||||
return !browser.tv;
|
||||
}
|
||||
|
||||
class Backdrop {
|
||||
@@ -236,7 +234,7 @@ export function setBackdropImages(images) {
|
||||
currentRotationIndex = -1;
|
||||
|
||||
if (images.length > 1 && enableRotation()) {
|
||||
rotationInterval = setInterval(onRotationInterval, 24000);
|
||||
rotationInterval = setInterval(onRotationInterval, 10000);
|
||||
}
|
||||
|
||||
onRotationInterval();
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
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: string;
|
||||
action: ItemAction;
|
||||
item: ItemDto;
|
||||
cardOptions: CardOptions;
|
||||
className: string;
|
||||
|
||||
@@ -2,12 +2,14 @@ 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';
|
||||
|
||||
@@ -15,7 +17,7 @@ import type { ItemDto } from 'types/base/models/item-dto';
|
||||
import type { CardOptions } from 'types/cardOptions';
|
||||
|
||||
interface CardHoverMenuProps {
|
||||
action: string,
|
||||
action: ItemAction,
|
||||
item: ItemDto;
|
||||
cardOptions: CardOptions;
|
||||
}
|
||||
@@ -29,7 +31,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,
|
||||
@@ -51,7 +53,7 @@ const CardHoverMenu: FC<CardHoverMenuProps> = ({
|
||||
{playbackManager.canPlay(item) && (
|
||||
<PlayArrowIconButton
|
||||
className={centerPlayButtonClass}
|
||||
action='play'
|
||||
action={ItemAction.Play}
|
||||
title='Play'
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,15 +2,17 @@ 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 PlayArrowIconButton from '../../common/PlayArrowIconButton';
|
||||
import MoreVertIconButton from '../../common/MoreVertIconButton';
|
||||
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
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 PlayArrowIconButton from '../../common/PlayArrowIconButton';
|
||||
import MoreVertIconButton from '../../common/MoreVertIconButton';
|
||||
|
||||
const sholudShowOverlayPlayButton = (
|
||||
overlayPlayButton: boolean | undefined,
|
||||
item: ItemDto
|
||||
@@ -78,7 +80,7 @@ const CardOverlayButtons: FC<CardOverlayButtonsProps> = ({
|
||||
{cardOptions.centerPlayButton && (
|
||||
<PlayArrowIconButton
|
||||
className={centerPlayButtonClass}
|
||||
action='play'
|
||||
action={ItemAction.Play}
|
||||
title='Play'
|
||||
/>
|
||||
)}
|
||||
@@ -87,7 +89,7 @@ const CardOverlayButtons: FC<CardOverlayButtonsProps> = ({
|
||||
{sholudShowOverlayPlayButton(overlayPlayButton, item) && (
|
||||
<PlayArrowIconButton
|
||||
className={btnCssClass}
|
||||
action='play'
|
||||
action={ItemAction.Play}
|
||||
title='Play'
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { type FC } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
import { ensureArray } from 'utils/array';
|
||||
|
||||
import type { TextLine } from './cardHelper';
|
||||
|
||||
interface CardTextProps {
|
||||
@@ -7,27 +10,33 @@ interface CardTextProps {
|
||||
textLine: TextLine;
|
||||
}
|
||||
|
||||
const SEPARATOR = ' / ';
|
||||
|
||||
const CardText: FC<CardTextProps> = ({ className, textLine }) => {
|
||||
const { title, titleAction } = textLine;
|
||||
// eslint-disable-next-line sonarjs/function-return-type
|
||||
const renderCardText = () => {
|
||||
if (titleAction) {
|
||||
return (
|
||||
<a
|
||||
className='itemAction textActionButton'
|
||||
href={titleAction.url}
|
||||
title={titleAction.title}
|
||||
{...titleAction.dataAttributes}
|
||||
>
|
||||
{titleAction.title}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
return title;
|
||||
}
|
||||
};
|
||||
|
||||
return <Box className={className}>{renderCardText()}</Box>;
|
||||
return (
|
||||
<Box className={className}>
|
||||
{titleAction ? (
|
||||
ensureArray(titleAction).map((action, i, arr) => (
|
||||
<>
|
||||
<a
|
||||
className='itemAction textActionButton'
|
||||
href={action.url}
|
||||
title={action.title}
|
||||
{...action.dataAttributes}
|
||||
>
|
||||
{action.title}
|
||||
</a>
|
||||
{/* If there are more items, add the separator */}
|
||||
{(i < arr.length - 1) && SEPARATOR}
|
||||
</>
|
||||
))
|
||||
) : (
|
||||
ensureArray(title).join(SEPARATOR)
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardText;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import type { BaseItemPerson } from '@jellyfin/sdk/lib/generated-client/models/base-item-person';
|
||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
||||
import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
|
||||
@@ -6,12 +7,14 @@ 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';
|
||||
import { getDataAttributes } from 'utils/items';
|
||||
import { ItemKind } from 'types/base/models/item-kind';
|
||||
import { ItemMediaKind } from 'types/base/models/item-media-kind';
|
||||
import { ensureArray } from 'utils/array';
|
||||
|
||||
import type { NullableNumber, NullableString } from 'types/base/common/shared/types';
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
@@ -65,8 +68,8 @@ interface TextAction {
|
||||
}
|
||||
|
||||
export interface TextLine {
|
||||
title?: NullableString;
|
||||
titleAction?: TextAction;
|
||||
title?: NullableString | string[];
|
||||
titleAction?: TextAction | TextAction[];
|
||||
}
|
||||
|
||||
export function getTextActionButton(
|
||||
@@ -86,7 +89,7 @@ export function getTextActionButton(
|
||||
|
||||
const dataAttributes = getDataAttributes(
|
||||
{
|
||||
action: 'link',
|
||||
action: ItemAction.Link,
|
||||
itemServerId: serverId ?? item.ServerId,
|
||||
itemId: item.Id,
|
||||
itemChannelId: item.ChannelId,
|
||||
@@ -210,9 +213,25 @@ function getParentTitle(
|
||||
item: ItemDto
|
||||
) {
|
||||
if (isOuterFooter && item.AlbumArtists?.length) {
|
||||
(item.AlbumArtists[0] as ItemDto).Type = ItemKind.MusicArtist;
|
||||
(item.AlbumArtists[0] as ItemDto).IsFolder = true;
|
||||
return getTextActionButton(item.AlbumArtists[0], null, serverId);
|
||||
return item.AlbumArtists
|
||||
.map(artist => {
|
||||
const artistItem: ItemDto = {
|
||||
...artist,
|
||||
Type: BaseItemKind.MusicArtist,
|
||||
IsFolder: true
|
||||
};
|
||||
return getTextActionButton(artistItem, null, serverId);
|
||||
})
|
||||
.reduce((acc, line) => ({
|
||||
title: [
|
||||
...ensureArray(acc.title),
|
||||
...ensureArray(line.title)
|
||||
],
|
||||
titleAction: [
|
||||
...ensureArray(acc.titleAction),
|
||||
...ensureArray(line.titleAction)
|
||||
]
|
||||
}), {});
|
||||
} else {
|
||||
return {
|
||||
title: isUsingLiveTvNaming(item.Type) ?
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
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;
|
||||
@@ -20,7 +22,7 @@ interface UseCardProps {
|
||||
|
||||
function useCard({ item, cardOptions }: UseCardProps) {
|
||||
const action = resolveAction({
|
||||
defaultAction: cardOptions.action ?? 'link',
|
||||
defaultAction: cardOptions.action ?? ItemAction.Link,
|
||||
isFolder: item.IsFolder ?? false,
|
||||
isPhoto: item.MediaType === ItemMediaKind.Photo
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -514,7 +515,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="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="${ItemAction.Menu}" title="${globalize.translate('ButtonMore')}"><span class="material-icons more_vert" aria-hidden="true"></span></button>`;
|
||||
}
|
||||
|
||||
const cssClass = options.centerText ? 'cardText cardTextCentered' : 'cardText';
|
||||
@@ -575,9 +576,14 @@ function getCardFooterText(item, apiClient, options, footerClass, progressHtml,
|
||||
if (showOtherText) {
|
||||
if (options.showParentTitle && parentTitleUnderneath) {
|
||||
if (flags.isOuterFooter && item.AlbumArtists?.length) {
|
||||
item.AlbumArtists[0].Type = 'MusicArtist';
|
||||
item.AlbumArtists[0].IsFolder = true;
|
||||
lines.push(getTextActionButton(item.AlbumArtists, null, serverId));
|
||||
const artistText = item.AlbumArtists
|
||||
.map(artist => {
|
||||
artist.Type = BaseItemKind.MusicArtist;
|
||||
artist.IsFolder = true;
|
||||
return getTextActionButton(artist, null, serverId);
|
||||
})
|
||||
.join(' / ');
|
||||
lines.push(artistText);
|
||||
} else {
|
||||
lines.push(escapeHtml(isUsingLiveTvNaming(item.Type) ? item.Name : (item.SeriesName || item.Series || item.Album || item.AlbumArtist || '')));
|
||||
}
|
||||
@@ -771,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="link">';
|
||||
let html = '<a href="' + url + '" ' + itemShortcuts.getShortcutAttributesHtml(item, serverId) + ' class="itemAction textActionButton" title="' + text + `" data-action="${ItemAction.Link}">`;
|
||||
html += text;
|
||||
html += '</a>';
|
||||
|
||||
@@ -880,7 +886,7 @@ function importRefreshIndicator() {
|
||||
*/
|
||||
function buildCard(index, item, apiClient, options) {
|
||||
const action = resolveAction({
|
||||
defaultAction: options.action || 'link',
|
||||
defaultAction: options.action || ItemAction.Link,
|
||||
isFolder: item.IsFolder,
|
||||
isPhoto: item.MediaType === 'Photo'
|
||||
});
|
||||
@@ -980,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="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="${ItemAction.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="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="${ItemAction.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="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="${ItemAction.Menu}" title="${globalize.translate('ButtonMore')}"><span class="material-icons cardOverlayButtonIcon more_vert" aria-hidden="true"></span></button>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1151,7 +1157,7 @@ function getHoverMenuHtml(item, action) {
|
||||
const btnCssClass = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light';
|
||||
|
||||
if (playbackManager.canPlay(item)) {
|
||||
html += '<button is="paper-icon-button-light" class="' + btnCssClass + ' cardOverlayFab-primary" data-action="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="${ItemAction.Resume}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover play_arrow" aria-hidden="true"></span></button>`;
|
||||
}
|
||||
|
||||
html += '<div class="cardOverlayButton-br flex">';
|
||||
@@ -1160,17 +1166,17 @@ function getHoverMenuHtml(item, action) {
|
||||
|
||||
if (itemHelper.canMarkPlayed(item)) {
|
||||
import('../../elements/emby-playstatebutton/emby-playstatebutton');
|
||||
html += '<button is="emby-playstatebutton" type="button" data-action="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="${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>`;
|
||||
}
|
||||
|
||||
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="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="${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="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 += `<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 += '</div>';
|
||||
html += '</div>';
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolveCardImageContainerCssClasses,
|
||||
resolveMixedShapeByAspectRatio
|
||||
} from './cardBuilderUtils';
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
|
||||
describe('getDesiredAspect', () => {
|
||||
test('"portrait" (case insensitive)', () => {
|
||||
@@ -441,11 +442,11 @@ describe('isResizable', () => {
|
||||
});
|
||||
|
||||
describe('resolveAction', () => {
|
||||
test('default action', () => expect(resolveAction({ defaultAction: 'link', isFolder: false, isPhoto: false })).toEqual('link'));
|
||||
test('default action', () => expect(resolveAction({ defaultAction: ItemAction.Link, isFolder: false, isPhoto: false })).toEqual(ItemAction.Link));
|
||||
|
||||
test('photo', () => expect(resolveAction({ defaultAction: 'link', isFolder: false, isPhoto: true })).toEqual('play'));
|
||||
test('photo', () => expect(resolveAction({ defaultAction: ItemAction.Link, isFolder: false, isPhoto: true })).toEqual(ItemAction.Play));
|
||||
|
||||
test('default action is "play" and is folder', () => expect(resolveAction({ defaultAction: 'play', isFolder: true, isPhoto: true })).toEqual('link'));
|
||||
test('default action is "play" and is folder', () => expect(resolveAction({ defaultAction: ItemAction.Play, isFolder: true, isPhoto: true })).toEqual(ItemAction.Link));
|
||||
});
|
||||
|
||||
describe('resolveMixedShapeByAspectRatio', () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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),
|
||||
@@ -20,12 +22,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: string, isFolder: boolean, isPhoto: boolean }): string => {
|
||||
if (opts.defaultAction === 'play' && opts.isFolder) {
|
||||
export const resolveAction = (opts: { defaultAction: ItemAction, isFolder: boolean, isPhoto: boolean }): ItemAction => {
|
||||
if (opts.defaultAction === ItemAction.Play && opts.isFolder) {
|
||||
// If this hard-coding is ever removed make sure to test nested photo albums
|
||||
return 'link';
|
||||
return ItemAction.Link;
|
||||
} else if (opts.isPhoto) {
|
||||
return 'play';
|
||||
return ItemAction.Play;
|
||||
} else {
|
||||
return opts.defaultAction;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 {
|
||||
@@ -11,7 +13,7 @@ const InfoIconButton: FC<InfoIconButtonProps> = ({ className }) => {
|
||||
return (
|
||||
<IconButton
|
||||
className={className}
|
||||
data-action='link'
|
||||
data-action={ItemAction.Link}
|
||||
title={globalize.translate('ButtonInfo')}
|
||||
>
|
||||
<InfoIcon />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 {
|
||||
@@ -12,7 +14,7 @@ const MoreVertIconButton: FC<MoreVertIconButtonProps> = ({ className, iconClassN
|
||||
return (
|
||||
<IconButton
|
||||
className={className}
|
||||
data-action='menu'
|
||||
data-action={ItemAction.Menu}
|
||||
title={globalize.translate('ButtonMore')}
|
||||
>
|
||||
<MoreVertIcon className={iconClassName} />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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: string;
|
||||
action: ItemAction;
|
||||
title: string;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 {
|
||||
@@ -11,7 +13,7 @@ const PlaylistAddIconButton: FC<PlaylistAddIconButtonProps> = ({ className }) =>
|
||||
return (
|
||||
<IconButton
|
||||
className={className}
|
||||
data-action='addtoplaylist'
|
||||
data-action={ItemAction.AddToPlaylist}
|
||||
title={globalize.translate('AddToPlaylist')}
|
||||
>
|
||||
<PlaylistAddIcon />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { type FC } from 'react';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
|
||||
interface RightIconButtonsProps {
|
||||
className?: string;
|
||||
id: string;
|
||||
@@ -12,7 +14,7 @@ const RightIconButtons: FC<RightIconButtonsProps> = ({ className, id, title, ico
|
||||
return (
|
||||
<IconButton
|
||||
className={className}
|
||||
data-action='custom'
|
||||
data-action={ItemAction.Custom}
|
||||
data-customaction={id}
|
||||
title={title}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import dialog from 'components/dialog/dialog';
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
import globalize from 'lib/globalize';
|
||||
import browser from 'scripts/browser';
|
||||
|
||||
interface OptionItem {
|
||||
id: string,
|
||||
@@ -18,34 +17,7 @@ interface ConfirmOptions {
|
||||
buttons?: OptionItem[]
|
||||
}
|
||||
|
||||
function shouldUseNativeConfirm() {
|
||||
// webOS seems to block modals
|
||||
// Tizen 2.x seems to block modals
|
||||
return !browser.web0s
|
||||
&& !(browser.tizenVersion && (browser.tizenVersion < 3 || browser.tizenVersion >= 8))
|
||||
&& browser.tv
|
||||
&& !!window.confirm;
|
||||
}
|
||||
|
||||
async function nativeConfirm(options: string | ConfirmOptions) {
|
||||
if (typeof options === 'string') {
|
||||
options = {
|
||||
text: options
|
||||
} as ConfirmOptions;
|
||||
}
|
||||
|
||||
const text = (options.text || '').replace(/<br\/>/g, '\n');
|
||||
await appRouter.ready();
|
||||
const result = window.confirm(text);
|
||||
|
||||
if (result) {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
return Promise.reject(new Error('Confirm dialog rejected'));
|
||||
}
|
||||
}
|
||||
|
||||
async function customConfirm(options: string | ConfirmOptions, title: string = '') {
|
||||
async function confirm(options: string | ConfirmOptions, title: string = '') {
|
||||
if (typeof options === 'string') {
|
||||
options = {
|
||||
title,
|
||||
@@ -80,6 +52,4 @@ async function customConfirm(options: string | ConfirmOptions, title: string = '
|
||||
});
|
||||
}
|
||||
|
||||
const confirm = shouldUseNativeConfirm() ? nativeConfirm : customConfirm;
|
||||
|
||||
export default confirm;
|
||||
|
||||
@@ -9,12 +9,11 @@ import Button from '../../../elements/emby-button/Button';
|
||||
import Input from '../../../elements/emby-input/Input';
|
||||
|
||||
type IProps = {
|
||||
userId: string | null;
|
||||
user: UserDto
|
||||
};
|
||||
|
||||
const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
|
||||
const UserPasswordForm: FunctionComponent<IProps> = ({ user }: IProps) => {
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
const user = useRef<UserDto>();
|
||||
const libraryMenu = useMemo(async () => ((await import('../../../scripts/libraryMenu')).default), []);
|
||||
|
||||
const loadUser = useCallback(async () => {
|
||||
@@ -25,22 +24,16 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: 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.current.Policy || !user.current.Configuration) {
|
||||
if (!user.Policy || !user.Configuration) {
|
||||
throw new Error('Unexpected null user policy or configuration');
|
||||
}
|
||||
|
||||
(await libraryMenu).setTitle(user.current.Name);
|
||||
(await libraryMenu).setTitle(user.Name);
|
||||
|
||||
if (user.current.HasConfiguredPassword) {
|
||||
if (!user.current.Policy?.IsAdministrator) {
|
||||
if (user.HasConfiguredPassword) {
|
||||
if (!user.Policy?.IsAdministrator) {
|
||||
(page.querySelector('#btnResetPassword') as HTMLDivElement).classList.remove('hide');
|
||||
}
|
||||
(page.querySelector('#fldCurrentPassword') as HTMLDivElement).classList.remove('hide');
|
||||
@@ -49,7 +42,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
|
||||
(page.querySelector('#fldCurrentPassword') as HTMLDivElement).classList.add('hide');
|
||||
}
|
||||
|
||||
const canChangePassword = loggedInUser?.Policy?.IsAdministrator || user.current.Policy.EnableUserPreferenceAccess;
|
||||
const canChangePassword = loggedInUser?.Policy?.IsAdministrator || user.Policy.EnableUserPreferenceAccess;
|
||||
(page.querySelector('.passwordSection') as HTMLDivElement).classList.toggle('hide', !canChangePassword);
|
||||
|
||||
import('../../autoFocuser').then(({ default: autoFocuser }) => {
|
||||
@@ -61,7 +54,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
|
||||
(page.querySelector('#txtCurrentPassword') as HTMLInputElement).value = '';
|
||||
(page.querySelector('#txtNewPassword') as HTMLInputElement).value = '';
|
||||
(page.querySelector('#txtNewPasswordConfirm') as HTMLInputElement).value = '';
|
||||
}, [userId]);
|
||||
}, [user, libraryMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
@@ -78,7 +71,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: 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.current?.Policy?.IsAdministrator) {
|
||||
} else if ((page.querySelector('#txtNewPassword') as HTMLInputElement).value == '' && user?.Policy?.IsAdministrator) {
|
||||
toast(globalize.translate('PasswordMissingSaveError'));
|
||||
} else {
|
||||
loading.show();
|
||||
@@ -90,7 +83,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
|
||||
};
|
||||
|
||||
const savePassword = () => {
|
||||
if (!userId) {
|
||||
if (!user.Id) {
|
||||
console.error('[UserPasswordForm.savePassword] missing user id');
|
||||
return;
|
||||
}
|
||||
@@ -104,7 +97,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
|
||||
currentPassword = '';
|
||||
}
|
||||
|
||||
window.ApiClient.updateUserPassword(userId, currentPassword, newPassword).then(function () {
|
||||
window.ApiClient.updateUserPassword(user.Id, currentPassword, newPassword).then(function () {
|
||||
loading.hide();
|
||||
toast(globalize.translate('PasswordSaved'));
|
||||
|
||||
@@ -121,26 +114,23 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: 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();
|
||||
window.ApiClient.resetUserPassword(userId).then(function () {
|
||||
loading.hide();
|
||||
Dashboard.alert({
|
||||
message: globalize.translate('PasswordResetComplete'),
|
||||
title: globalize.translate('ResetPassword')
|
||||
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);
|
||||
});
|
||||
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
|
||||
});
|
||||
@@ -148,7 +138,12 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
|
||||
|
||||
(page.querySelector('.updatePasswordForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
(page.querySelector('#btnResetPassword') as HTMLButtonElement).addEventListener('click', resetPassword);
|
||||
}, [loadUser, userId]);
|
||||
|
||||
return () => {
|
||||
(page.querySelector('.updatePasswordForm') as HTMLFormElement).removeEventListener('submit', onSubmit);
|
||||
(page.querySelector('#btnResetPassword') as HTMLButtonElement).removeEventListener('click', resetPassword);
|
||||
};
|
||||
}, [loadUser, user]);
|
||||
|
||||
return (
|
||||
<div ref={element}>
|
||||
|
||||
@@ -11,15 +11,6 @@ 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();
|
||||
}
|
||||
@@ -83,25 +74,14 @@ function getItem(cssClass, type, path, name) {
|
||||
return html;
|
||||
}
|
||||
|
||||
function getEditorHtml(options, systemInfo) {
|
||||
function getEditorHtml(options) {
|
||||
let html = '';
|
||||
html += '<div class="formDialogContent scrollY">';
|
||||
html += '<div class="dialogContentInner dialog-content-centered" style="padding-top:2em;">';
|
||||
if (!options.pathReadOnly) {
|
||||
const instruction = options.instruction ? `${escapeHtml(options.instruction)}<br/><br/>` : '';
|
||||
if (!options.pathReadOnly && options.instruction) {
|
||||
const 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;">';
|
||||
@@ -123,14 +103,6 @@ function getEditorHtml(options, systemInfo) {
|
||||
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>';
|
||||
@@ -209,12 +181,10 @@ 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, networkSharePath)
|
||||
).catch(() => { /* no-op */ });
|
||||
validatePath(path, options.validateWriteable, ApiClient)
|
||||
.then(options.callback(path))
|
||||
.catch(() => { /* no-op */ });
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -236,7 +206,6 @@ function getDefaultPath(options) {
|
||||
}
|
||||
}
|
||||
|
||||
let systemInfo;
|
||||
class DirectoryBrowser {
|
||||
currentDialog;
|
||||
|
||||
@@ -251,10 +220,8 @@ class DirectoryBrowser {
|
||||
if (options.includeFiles != null) {
|
||||
fileOptions.includeFiles = options.includeFiles;
|
||||
}
|
||||
Promise.all([getSystemInfo(), getDefaultPath(options)]).then(
|
||||
responses => {
|
||||
const fetchedSystemInfo = responses[0];
|
||||
const fetchedInitialPath = responses[1];
|
||||
getDefaultPath(options).then(
|
||||
fetchedInitialPath => {
|
||||
const dlg = dialogHelper.createDialog({
|
||||
size: 'small',
|
||||
removeOnClose: true,
|
||||
@@ -272,7 +239,7 @@ class DirectoryBrowser {
|
||||
html += escapeHtml(options.header || '') || globalize.translate('HeaderSelectPath');
|
||||
html += '</h3>';
|
||||
html += '</div>';
|
||||
html += getEditorHtml(options, fetchedSystemInfo);
|
||||
html += getEditorHtml(options);
|
||||
dlg.innerHTML = html;
|
||||
initEditor(dlg, options, fileOptions);
|
||||
dlg.addEventListener('close', onDialogClosed);
|
||||
@@ -282,10 +249,6 @@ 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,6 +89,7 @@ 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');
|
||||
@@ -112,6 +113,7 @@ 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;
|
||||
@@ -157,6 +159,7 @@ 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,7 +172,6 @@
|
||||
<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>
|
||||
@@ -214,6 +213,11 @@
|
||||
<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" />
|
||||
|
||||
@@ -53,7 +53,7 @@ function renderFilters(context, result, query) {
|
||||
const delimeter = '|';
|
||||
return (delimeter + (query.Tags || '') + delimeter).includes(delimeter + i + delimeter);
|
||||
});
|
||||
renderOptions(context, '.yearFilters', 'chkYearFilter', merge(result.Years, query.Years, ','), function (i) {
|
||||
renderOptions(context, '.yearFilters', 'chkYearFilter', merge(result.Years.map(String), query.Years, ','), function (i) {
|
||||
const delimeter = ',';
|
||||
return (delimeter + (query.Years || '') + delimeter).includes(delimeter + i + delimeter);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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';
|
||||
@@ -15,6 +18,7 @@ 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';
|
||||
@@ -26,6 +30,7 @@ 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) {
|
||||
@@ -441,7 +446,7 @@ function Guide(options) {
|
||||
|
||||
html += '<div class="' + outerCssClass + '" data-channelid="' + channel.Id + '">';
|
||||
|
||||
const clickAction = layoutManager.tv ? 'link' : 'programdialog';
|
||||
const clickAction = layoutManager.tv ? ItemAction.Link : ItemAction.ProgramDialog;
|
||||
|
||||
const categories = self.categoryOptions.categories || [];
|
||||
const displayMovieContent = !categories.length || categories.indexOf('movies') !== -1;
|
||||
@@ -607,7 +612,7 @@ function Guide(options) {
|
||||
title.push(channel.Name);
|
||||
}
|
||||
|
||||
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 + '">';
|
||||
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}">`;
|
||||
|
||||
if (hasChannelImage) {
|
||||
const url = apiClient.getScaledImageUrl(channel.Id, {
|
||||
|
||||
@@ -48,12 +48,6 @@ export function enableHlsJsPlayer(runTimeTicks, mediaType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Native HLS support in WebOS only plays stereo sound. hls.js works better, but works only on WebOS 4 or newer.
|
||||
// Using hls.js also seems to fix fast forward issues that native HLS has.
|
||||
if (browser.web0sVersion >= 4) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The native players on these devices support seeking live streams, no need to use hls.js here
|
||||
if (browser.tizen || browser.web0s) {
|
||||
return false;
|
||||
@@ -65,6 +59,12 @@ export function enableHlsJsPlayer(runTimeTicks, mediaType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Chromium 141+ brings native HLS support that does not support switching HDR/SDR playlists.
|
||||
// Always use hls.js to avoid falling back to transcoding from remuxing and client side tone-mapping.
|
||||
if (browser.chrome || browser.edgeChromium || browser.opera) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// simple playback should use the native support
|
||||
if (runTimeTicks) {
|
||||
return false;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
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';
|
||||
@@ -12,7 +16,6 @@ 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 = [
|
||||
@@ -387,6 +390,7 @@ 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
|
||||
@@ -411,9 +415,9 @@ function executeCommand(item, id, options) {
|
||||
break;
|
||||
case 'download':
|
||||
import('../scripts/fileDownloader').then((fileDownloader) => {
|
||||
const downloadHref = apiClient.getItemDownloadUrl(itemId);
|
||||
const url = getLibraryApi(api).getDownloadUrl({ itemId });
|
||||
fileDownloader.download([{
|
||||
url: downloadHref,
|
||||
url,
|
||||
item,
|
||||
itemId,
|
||||
serverId,
|
||||
@@ -429,9 +433,9 @@ function executeCommand(item, id, options) {
|
||||
const downloads = items
|
||||
.filter(i => i.CanDownload)
|
||||
.map(i => {
|
||||
const downloadHref = apiClient.getItemDownloadUrl(i.Id);
|
||||
const url = getLibraryApi(api).getDownloadUrl({ itemId: i.Id });
|
||||
return {
|
||||
url: downloadHref,
|
||||
url,
|
||||
item: i,
|
||||
itemId: i.Id,
|
||||
serverId,
|
||||
@@ -478,7 +482,7 @@ function executeCommand(item, id, options) {
|
||||
break;
|
||||
}
|
||||
case 'copy-stream': {
|
||||
const downloadHref = apiClient.getItemDownloadUrl(itemId);
|
||||
const downloadHref = getLibraryApi(api).getDownloadUrl({ itemId });
|
||||
copy(downloadHref).then(() => {
|
||||
toast(globalize.translate('CopyStreamURLSuccess'));
|
||||
}).catch(() => {
|
||||
|
||||
@@ -52,14 +52,6 @@ export function getDisplayName(item, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(item)) {
|
||||
if (item.length > 1) {
|
||||
return item.map(i => getDisplayName(i, options)).join(' / ');
|
||||
} else if (item.length === 1) {
|
||||
return item[0].Name;
|
||||
}
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
|
||||
@@ -133,8 +133,8 @@ function getMediaSourceHtml(user, item, version) {
|
||||
}
|
||||
attributes.push(createAttribute(globalize.translate('MediaInfoInterlaced'), (stream.IsInterlaced ? 'Yes' : 'No')));
|
||||
}
|
||||
if ((stream.AverageFrameRate || stream.RealFrameRate) && stream.Type === 'Video') {
|
||||
attributes.push(createAttribute(globalize.translate('MediaInfoFramerate'), (stream.AverageFrameRate || stream.RealFrameRate)));
|
||||
if (stream.ReferenceFrameRate && stream.Type === 'Video') {
|
||||
attributes.push(createAttribute(globalize.translate('MediaInfoFramerate'), stream.ReferenceFrameRate));
|
||||
}
|
||||
if (stream.ChannelLayout) {
|
||||
attributes.push(createAttribute(globalize.translate('MediaInfoLayout'), stream.ChannelLayout));
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
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,3 +1,4 @@
|
||||
import { LayoutMode } from 'constants/layoutMode';
|
||||
|
||||
import { appHost } from './apphost';
|
||||
import browser from '../scripts/browser';
|
||||
@@ -14,51 +15,47 @@ function setLayout(instance, layout, selectedLayout) {
|
||||
}
|
||||
}
|
||||
|
||||
export const SETTING_KEY = 'layout';
|
||||
|
||||
class LayoutManager {
|
||||
tv = false;
|
||||
mobile = false;
|
||||
desktop = false;
|
||||
experimental = false;
|
||||
|
||||
setLayout(layout, save) {
|
||||
if (!layout || layout === 'auto') {
|
||||
setLayout(layout = '', save = true) {
|
||||
const layoutValue = (!layout || layout === LayoutMode.Auto) ? '' : layout;
|
||||
|
||||
if (!layoutValue) {
|
||||
this.autoLayout();
|
||||
|
||||
if (save !== false) {
|
||||
appSettings.set('layout', '');
|
||||
}
|
||||
} else {
|
||||
setLayout(this, 'mobile', layout);
|
||||
setLayout(this, 'tv', layout);
|
||||
setLayout(this, 'desktop', layout);
|
||||
|
||||
this.experimental = layout === 'experimental';
|
||||
if (this.experimental) {
|
||||
const legacyLayoutMode = browser.mobile ? 'mobile' : this.defaultLayout || 'desktop';
|
||||
setLayout(this, legacyLayoutMode, legacyLayoutMode);
|
||||
}
|
||||
|
||||
if (save !== false) {
|
||||
appSettings.set('layout', layout);
|
||||
}
|
||||
setLayout(this, LayoutMode.Mobile, layoutValue);
|
||||
setLayout(this, LayoutMode.Tv, layoutValue);
|
||||
setLayout(this, LayoutMode.Desktop, layoutValue);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (save) appSettings.set(SETTING_KEY, layoutValue);
|
||||
|
||||
Events.trigger(this, 'modechange');
|
||||
}
|
||||
|
||||
getSavedLayout() {
|
||||
return appSettings.get('layout');
|
||||
return appSettings.get(SETTING_KEY);
|
||||
}
|
||||
|
||||
autoLayout() {
|
||||
// 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);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
init() {
|
||||
|
||||
@@ -4,6 +4,8 @@ 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';
|
||||
@@ -20,7 +22,7 @@ interface ListContentProps {
|
||||
enableOverview?: boolean;
|
||||
enableSideMediaInfo?: boolean;
|
||||
clickEntireItem?: boolean;
|
||||
action?: string;
|
||||
action?: ItemAction;
|
||||
isLargeStyle: boolean;
|
||||
downloadWidth?: number;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
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';
|
||||
@@ -11,15 +18,10 @@ 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?: string | null;
|
||||
action?: ItemAction | null;
|
||||
isLargeStyle: boolean;
|
||||
clickEntireItem?: boolean;
|
||||
downloadWidth?: number;
|
||||
@@ -55,7 +57,7 @@ const ListImageContainer: FC<ListImageContainerProps> = ({
|
||||
|
||||
const playOnImageClick = listOptions.imagePlayButton && !layoutManager.tv;
|
||||
|
||||
const imageAction = playOnImageClick ? 'link' : action;
|
||||
const imageAction = playOnImageClick ? ItemAction.Link : action;
|
||||
|
||||
const btnCssClass =
|
||||
'paper-icon-button-light listItemImageButton itemAction';
|
||||
@@ -85,7 +87,7 @@ const ListImageContainer: FC<ListImageContainerProps> = ({
|
||||
<PlayArrowIconButton
|
||||
className={btnCssClass}
|
||||
action={
|
||||
canResume(playbackPositionTicks) ? 'resume' : 'play'
|
||||
canResume(playbackPositionTicks) ? ItemAction.Resume : ItemAction.Play
|
||||
}
|
||||
title={
|
||||
canResume(playbackPositionTicks) ?
|
||||
|
||||
@@ -3,15 +3,16 @@ import classNames from 'classnames';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
import TextLines from 'components/common/textLines/TextLines';
|
||||
import PrimaryMediaInfo from '../../mediainfo/PrimaryMediaInfo';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
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?: string | null;
|
||||
action?: ItemAction | null;
|
||||
isLargeStyle?: boolean;
|
||||
clickEntireItem?: boolean;
|
||||
enableContentWrapper?: boolean;
|
||||
|
||||
@@ -2,13 +2,16 @@ 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 layoutManager from '../../layoutManager';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import type { DataAttributes } from 'types/dataAttributes';
|
||||
|
||||
import layoutManager from '../../layoutManager';
|
||||
|
||||
interface ListWrapperProps {
|
||||
index: number | undefined;
|
||||
title?: string | null;
|
||||
action?: string | null;
|
||||
action?: ItemAction | null;
|
||||
dataAttributes?: DataAttributes;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import classNames from 'classnames';
|
||||
import { getDataAttributes } from 'utils/items';
|
||||
|
||||
import layoutManager from 'components/layoutManager';
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import { getDataAttributes } from 'utils/items';
|
||||
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
import type { ListOptions } from 'types/listOptions';
|
||||
@@ -11,7 +13,7 @@ interface UseListProps {
|
||||
}
|
||||
|
||||
function useList({ item, listOptions }: UseListProps) {
|
||||
const action = listOptions.action ?? 'link';
|
||||
const action = listOptions.action ?? ItemAction.Link;
|
||||
const isLargeStyle = listOptions.imageSize === 'large';
|
||||
const enableOverview = listOptions.enableOverview;
|
||||
const clickEntireItem = !!layoutManager.tv;
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
* @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';
|
||||
@@ -13,12 +19,10 @@ 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') {
|
||||
@@ -165,7 +169,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="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="${ItemAction.Custom}" data-customaction="${button.id}" title="${button.title}"><span class="material-icons ${button.icon}" aria-hidden="true"></span></button>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
@@ -175,7 +179,7 @@ export function getListViewHtml(options) {
|
||||
const items = options.items;
|
||||
|
||||
let groupTitle = '';
|
||||
const action = options.action || 'link';
|
||||
const action = options.action || ItemAction.Link;
|
||||
|
||||
const isLargeStyle = options.imageSize === 'large';
|
||||
const enableOverview = options.enableOverview;
|
||||
@@ -277,7 +281,7 @@ export function getListViewHtml(options) {
|
||||
imageClass += ' itemAction';
|
||||
}
|
||||
|
||||
const imageAction = playOnImageClick ? 'link' : action;
|
||||
const imageAction = playOnImageClick ? ItemAction.Link : action;
|
||||
|
||||
if (imgUrl) {
|
||||
html += '<div data-action="' + imageAction + '" class="' + imageClass + ' lazy" data-src="' + imgUrl + '" item-icon>';
|
||||
@@ -298,7 +302,7 @@ export function getListViewHtml(options) {
|
||||
}
|
||||
|
||||
if (playOnImageClick) {
|
||||
html += '<button is="paper-icon-button-light" class="listItemImageButton itemAction" data-action="resume"><span class="material-icons listItemImageButton-icon play_arrow" aria-hidden="true"></span></button>';
|
||||
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>`;
|
||||
}
|
||||
|
||||
const progressHtml = indicators.getProgressBarHtml(item, {
|
||||
@@ -449,11 +453,11 @@ export function getListViewHtml(options) {
|
||||
|
||||
if (!clickEntireItem) {
|
||||
if (options.addToListButton) {
|
||||
html += '<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="addtoplaylist"><span class="material-icons playlist_add" aria-hidden="true"></span></button>';
|
||||
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>`;
|
||||
}
|
||||
|
||||
if (options.infoButton) {
|
||||
html += '<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="link"><span class="material-icons info_outline" aria-hidden="true"></span></button>';
|
||||
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>`;
|
||||
}
|
||||
|
||||
if (options.rightButtons) {
|
||||
@@ -474,7 +478,7 @@ export function getListViewHtml(options) {
|
||||
}
|
||||
|
||||
if (options.moreButton !== false) {
|
||||
html += '<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="menu"><span class="material-icons more_vert" aria-hidden="true"></span></button>';
|
||||
html += `<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 += '</div>';
|
||||
|
||||
@@ -122,9 +122,9 @@ function onAddButtonClick() {
|
||||
import('../directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
callback: function (path, networkSharePath) {
|
||||
callback: function (path) {
|
||||
if (path) {
|
||||
addMediaLocation(page, path, networkSharePath);
|
||||
addMediaLocation(page, path);
|
||||
}
|
||||
|
||||
picker.close();
|
||||
@@ -161,7 +161,7 @@ function renderPaths(page) {
|
||||
}
|
||||
}
|
||||
|
||||
function addMediaLocation(page, path, networkSharePath) {
|
||||
function addMediaLocation(page, path) {
|
||||
const pathLower = path.toLowerCase();
|
||||
const pathFilter = pathInfos.filter(p => {
|
||||
return p.Path.toLowerCase() == pathLower;
|
||||
@@ -172,10 +172,6 @@ function addMediaLocation(page, path, networkSharePath) {
|
||||
Path: path
|
||||
};
|
||||
|
||||
if (networkSharePath) {
|
||||
pathInfo.NetworkPath = networkSharePath;
|
||||
}
|
||||
|
||||
pathInfos.push(pathInfo);
|
||||
renderPaths(page);
|
||||
}
|
||||
|
||||
@@ -56,10 +56,10 @@ function onEditLibrary() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function addMediaLocation(page, path, networkSharePath) {
|
||||
function addMediaLocation(page, path) {
|
||||
const virtualFolder = currentOptions.library;
|
||||
const refreshAfterChange = currentOptions.refresh;
|
||||
ApiClient.addMediaPath(virtualFolder.Name, path, networkSharePath, refreshAfterChange).then(() => {
|
||||
ApiClient.addMediaPath(virtualFolder.Name, path, null, refreshAfterChange).then(() => {
|
||||
hasChanges = true;
|
||||
refreshLibraryFromServer(page);
|
||||
}, () => {
|
||||
@@ -67,11 +67,10 @@ function addMediaLocation(page, path, networkSharePath) {
|
||||
});
|
||||
}
|
||||
|
||||
function updateMediaLocation(page, path, networkSharePath) {
|
||||
function updateMediaLocation(page, path) {
|
||||
const virtualFolder = currentOptions.library;
|
||||
ApiClient.updateMediaPath(virtualFolder.Name, {
|
||||
Path: path,
|
||||
NetworkPath: networkSharePath
|
||||
Path: path
|
||||
}).then(() => {
|
||||
hasChanges = true;
|
||||
refreshLibraryFromServer(page);
|
||||
@@ -115,7 +114,7 @@ function onListItemClick(e) {
|
||||
return;
|
||||
}
|
||||
|
||||
showDirectoryBrowser(dom.parentWithClass(listItem, 'dlg-libraryeditor'), originalPath, pathInfo.NetworkPath);
|
||||
showDirectoryBrowser(dom.parentWithClass(listItem, 'dlg-libraryeditor'), originalPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,19 +173,18 @@ function onAddButtonClick() {
|
||||
showDirectoryBrowser(dom.parentWithClass(this, 'dlg-libraryeditor'));
|
||||
}
|
||||
|
||||
function showDirectoryBrowser(context, originalPath, networkPath) {
|
||||
function showDirectoryBrowser(context, originalPath) {
|
||||
import('../directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
pathReadOnly: originalPath != null,
|
||||
path: originalPath,
|
||||
networkSharePath: networkPath,
|
||||
callback: function (path, networkSharePath) {
|
||||
callback: function (path) {
|
||||
if (path) {
|
||||
if (originalPath) {
|
||||
updateMediaLocation(context, originalPath, networkSharePath);
|
||||
updateMediaLocation(context, originalPath);
|
||||
} else {
|
||||
addMediaLocation(context, path, networkSharePath);
|
||||
addMediaLocation(context, path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1024,7 +1024,7 @@ export class PlaybackManager {
|
||||
self.canPlay = function (item) {
|
||||
const itemType = item.Type;
|
||||
|
||||
if (itemType === 'PhotoAlbum' || itemType === 'MusicGenre' || itemType === 'Season' || itemType === 'Series' || itemType === 'BoxSet' || itemType === 'MusicAlbum' || itemType === 'MusicArtist' || itemType === 'Playlist') {
|
||||
if (itemType === 'Book' || itemType === 'PhotoAlbum' || itemType === 'MusicGenre' || itemType === 'Season' || itemType === 'Series' || itemType === 'BoxSet' || itemType === 'MusicAlbum' || itemType === 'MusicArtist' || itemType === 'Playlist') {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1980,28 +1980,18 @@ export class PlaybackManager {
|
||||
const startSeasonId = firstItem.Type === 'Season' ? items[options.startIndex || 0].Id : undefined;
|
||||
|
||||
const seasonId = (startSeasonId && items.length === 1) ? startSeasonId : undefined;
|
||||
const seriesId = firstItem.SeriesId || firstItem.Id;
|
||||
const SeriesId = firstItem.SeriesId || firstItem.Id;
|
||||
const UserId = apiClient.getCurrentUserId();
|
||||
|
||||
let startItemId;
|
||||
|
||||
// Start from a specific (the next unwatched) episode if we want to watch in order and have not chosen a specific season
|
||||
if (!options.shuffle && !seasonId) {
|
||||
const initialUnplayedEpisode = await getItems(apiClient, UserId, {
|
||||
SortBy: 'SeriesSortName,SortName',
|
||||
SortOrder: 'Ascending',
|
||||
IncludeItemTypes: 'Episode',
|
||||
Recursive: true,
|
||||
IsMissing: false,
|
||||
ParentId: seriesId,
|
||||
limit: 1,
|
||||
Filters: 'IsUnplayed'
|
||||
});
|
||||
|
||||
startItemId = initialUnplayedEpisode?.Items?.at(0)?.Id;
|
||||
const nextUp = await apiClient.getNextUpEpisodes({ SeriesId, UserId });
|
||||
startItemId = nextUp?.Items?.[0]?.Id;
|
||||
}
|
||||
|
||||
const episodesResult = await apiClient.getEpisodes(seriesId, {
|
||||
const episodesResult = await apiClient.getEpisodes(SeriesId, {
|
||||
IsVirtualUnaired: false,
|
||||
IsMissing: false,
|
||||
SeasonId: seasonId,
|
||||
@@ -3493,7 +3483,7 @@ export class PlaybackManager {
|
||||
const nextItemPlayOptions = nextItem ? (nextItem.item.playOptions || getDefaultPlayOptions()) : getDefaultPlayOptions();
|
||||
const newPlayer = nextItem ? getPlayer(nextItem.item, nextItemPlayOptions) : null;
|
||||
|
||||
if (newPlayer !== player) {
|
||||
if (!newPlayer) {
|
||||
data.streamInfo = null;
|
||||
destroyPlayer(player);
|
||||
removeCurrentPlayer(player);
|
||||
@@ -3501,12 +3491,21 @@ export class PlaybackManager {
|
||||
|
||||
if (errorOccurred) {
|
||||
showPlaybackInfoErrorMessage(self, 'PlaybackError' + displayErrorCode);
|
||||
} else if (nextItem) {
|
||||
} else if (newPlayer) {
|
||||
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
|
||||
}]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -93,12 +93,16 @@ function getQualitySecondaryText(player) {
|
||||
return stream.Type === 'Video';
|
||||
})[0];
|
||||
|
||||
const videoCodec = videoStream ? videoStream.Codec : null;
|
||||
const videoBitRate = videoStream ? videoStream.BitRate : null;
|
||||
const videoWidth = videoStream ? videoStream.Width : null;
|
||||
const videoHeight = videoStream ? videoStream.Height : null;
|
||||
|
||||
const options = qualityoptions.getVideoQualityOptions({
|
||||
currentMaxBitrate: playbackManager.getMaxStreamingBitrate(player),
|
||||
isAutomaticBitrateEnabled: playbackManager.enableAutomaticBitrateDetection(player),
|
||||
videoCodec,
|
||||
videoBitRate,
|
||||
videoWidth: videoWidth,
|
||||
videoHeight: videoHeight,
|
||||
enableAuto: true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PlaybackManager } from './playbackmanager';
|
||||
import { TICKS_PER_MILLISECOND, TICKS_PER_SECOND } from 'constants/time';
|
||||
import type { MediaSegmentDto } from '@jellyfin/sdk/lib/generated-client/models/media-segment-dto';
|
||||
import type { PlaybackStopInfo } from 'types/playbackStopInfo';
|
||||
import { PlaybackSubscriber } from 'apps/stable/features/playback/utils/playbackSubscriber';
|
||||
import { isInSegment } from 'apps/stable/features/playback/utils/mediaSegments';
|
||||
import Events, { type Event } from 'utils/events';
|
||||
@@ -188,10 +189,12 @@ class SkipSegment extends PlaybackSubscriber {
|
||||
}
|
||||
}
|
||||
|
||||
onPlaybackStop() {
|
||||
onPlaybackStop(_e: Event, playbackStopInfo: PlaybackStopInfo) {
|
||||
this.currentSegment = null;
|
||||
this.hideSkipButton();
|
||||
Events.off(document, EventType.SHOW_VIDEO_OSD, this.onOsdChanged);
|
||||
if (!playbackStopInfo.nextItem) {
|
||||
Events.off(document, EventType.SHOW_VIDEO_OSD, this.onOsdChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || videoStream.RealFrameRate;
|
||||
const originalFramerate = videoStream.ReferenceFrameRate;
|
||||
const transcodeFramerate = session.TranscodingInfo.Framerate;
|
||||
|
||||
if (!originalFramerate) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import browser from '../../scripts/browser';
|
||||
import dialogHelper from '../dialogHelper/dialogHelper';
|
||||
import layoutManager from '../layoutManager';
|
||||
import scrollHelper from '../../scripts/scrollHelper';
|
||||
@@ -92,33 +91,13 @@ export default (() => {
|
||||
});
|
||||
}
|
||||
|
||||
if ((browser.tv || browser.xboxOne) && window.confirm) {
|
||||
return options => {
|
||||
if (typeof options === 'string') {
|
||||
options = {
|
||||
label: '',
|
||||
text: options
|
||||
};
|
||||
}
|
||||
|
||||
const label = (options.label || '').replaceAll('<br/>', '\n');
|
||||
const result = prompt(label, options.text || '');
|
||||
|
||||
if (result) {
|
||||
return Promise.resolve(result);
|
||||
} else {
|
||||
return Promise.reject(result);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return options => {
|
||||
if (typeof options === 'string') {
|
||||
options = {
|
||||
title: '',
|
||||
text: options
|
||||
};
|
||||
}
|
||||
return showDialog(options);
|
||||
};
|
||||
}
|
||||
return options => {
|
||||
if (typeof options === 'string') {
|
||||
options = {
|
||||
title: '',
|
||||
text: options
|
||||
};
|
||||
}
|
||||
return showDialog(options);
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -16,6 +17,9 @@ 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';
|
||||
@@ -24,9 +28,6 @@ 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;
|
||||
@@ -208,7 +209,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="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="${ItemAction.Link}" class="cardImageContainer coveredImage ${getDefaultBackgroundClass(item.Name)} cardContent cardContent-shadow itemAction"><span class="cardImageIcon material-icons album" aria-hidden="true"></span></button></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -434,7 +435,7 @@ class AppRouter {
|
||||
|
||||
const layoutMode = localStorage.getItem('layout');
|
||||
|
||||
if (layoutMode === 'experimental' && item.CollectionType == CollectionType.Homevideos) {
|
||||
if (layoutMode === LayoutMode.Experimental && item.CollectionType == CollectionType.Homevideos) {
|
||||
url = '#/homevideos?topParentId=' + item.Id;
|
||||
|
||||
return url;
|
||||
|
||||
@@ -41,7 +41,7 @@ try {
|
||||
const opts = Object.defineProperty({}, 'behavior', {
|
||||
get: function () {
|
||||
supportsScrollToOptions = true;
|
||||
return null;
|
||||
return 'auto';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
/**
|
||||
* Module shortcuts.
|
||||
* @module components/shortcuts
|
||||
* "Shortcut" action handlers for BaseItems.
|
||||
*/
|
||||
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;
|
||||
@@ -231,95 +232,114 @@ function executeAction(card, target, action) {
|
||||
|
||||
const playableItemId = type === 'Program' ? item.ChannelId : item.Id;
|
||||
|
||||
if (item.MediaType === 'Photo' && action === 'link') {
|
||||
action = 'play';
|
||||
if (item.MediaType === 'Photo' && action === ItemAction.Link) {
|
||||
action = ItemAction.Play;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
switch (action) {
|
||||
case ItemAction.Link:
|
||||
appRouter.showItem(item, {
|
||||
context: card.getAttribute('data-context'),
|
||||
parentId: card.getAttribute('data-parentid')
|
||||
});
|
||||
} else {
|
||||
console.warn('Unable to play item', item);
|
||||
break;
|
||||
case ItemAction.ProgramDialog:
|
||||
showProgramDialog(item);
|
||||
break;
|
||||
case ItemAction.InstantMix:
|
||||
playbackManager.instantMix({
|
||||
Id: 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;
|
||||
}
|
||||
} else if (action === 'queue') {
|
||||
if (playbackManager.isPlaying()) {
|
||||
playbackManager.queue({
|
||||
ids: [playableItemId],
|
||||
serverId: serverId
|
||||
});
|
||||
toast(globalize.translate('MediaQueued'));
|
||||
} else {
|
||||
playbackManager.queue({
|
||||
ids: [playableItemId],
|
||||
serverId: serverId
|
||||
});
|
||||
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;
|
||||
}
|
||||
} 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
|
||||
} :
|
||||
{};
|
||||
case ItemAction.PlayMenu:
|
||||
showPlayMenu(card, target);
|
||||
break;
|
||||
case ItemAction.Edit:
|
||||
getItem(target).then(itemToEdit => {
|
||||
editItem(itemToEdit, 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');
|
||||
|
||||
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
|
||||
}));
|
||||
card.dispatchEvent(new CustomEvent(`action-${customAction}`, {
|
||||
detail: {
|
||||
playlistItemId: card.getAttribute('data-playlistitemid')
|
||||
},
|
||||
cancelable: false,
|
||||
bubbles: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +410,7 @@ export function onClick(e) {
|
||||
}
|
||||
}
|
||||
|
||||
if (action) {
|
||||
if (action && action !== ItemAction.None) {
|
||||
executeAction(card, actionElement, action);
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
* 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 dialogHelper from '../dialogHelper/dialogHelper';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
||||
import { randomInt } from 'utils/number';
|
||||
|
||||
import dialogHelper from '../dialogHelper/dialogHelper';
|
||||
import inputManager from '../../scripts/inputManager';
|
||||
import layoutManager from '../layoutManager';
|
||||
import focusManager from '../focusManager';
|
||||
@@ -15,8 +21,6 @@ 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.
|
||||
@@ -88,14 +92,15 @@ function getBackdropImageUrl(item, options, apiClient) {
|
||||
* @returns {string} URL of the item's image.
|
||||
*/
|
||||
function getImgUrl(item, user) {
|
||||
const apiClient = ServerConnections.getApiClient(item.ServerId);
|
||||
const apiClient = ServerConnections.getApiClient(item);
|
||||
const api = toApi(apiClient);
|
||||
const imageOptions = {};
|
||||
|
||||
if (item.BackdropImageTags?.length) {
|
||||
return getBackdropImageUrl(item, imageOptions, apiClient);
|
||||
} else {
|
||||
if (item.MediaType === 'Photo' && user?.Policy.EnableContentDownloading) {
|
||||
return apiClient.getItemDownloadUrl(item.Id);
|
||||
return getLibraryApi(api).getDownloadUrl({ itemId: item.Id });
|
||||
}
|
||||
imageOptions.type = 'Primary';
|
||||
return getImageUrl(item, imageOptions, apiClient);
|
||||
|
||||
@@ -80,11 +80,12 @@ function setFiles(page, files) {
|
||||
}
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const file = currentFile;
|
||||
|
||||
if (!isValidSubtitleFile(file)) {
|
||||
toast(globalize.translate('MessageSubtitleFileTypeAllowed'));
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -109,8 +110,6 @@ async function onSubmit(e) {
|
||||
hasChanges = true;
|
||||
dialogHelper.close(dlg);
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function initEditor(page) {
|
||||
|
||||
37
src/constants/itemAction.ts
Normal file
37
src/constants/itemAction.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/** 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'
|
||||
};
|
||||
13
src/constants/layoutMode.ts
Normal file
13
src/constants/layoutMode.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/** 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,88 +176,86 @@
|
||||
</div>
|
||||
</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 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>
|
||||
</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 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 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 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 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 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>
|
||||
</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 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 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 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 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 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>
|
||||
</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 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="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 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="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 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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -22,6 +23,7 @@ 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';
|
||||
@@ -34,6 +36,7 @@ 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';
|
||||
@@ -434,19 +437,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="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="${ItemAction.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="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="${ItemAction.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="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="${ItemAction.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="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="${ItemAction.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));
|
||||
}
|
||||
@@ -1982,7 +1985,7 @@ export default function (view, params) {
|
||||
return;
|
||||
}
|
||||
|
||||
playItem(item, item.UserData && mode === 'resume' ? item.UserData.PlaybackPositionTicks : 0);
|
||||
playItem(item, item.UserData && mode === ItemAction.Resume ? item.UserData.PlaybackPositionTicks : 0);
|
||||
}
|
||||
|
||||
function onPlayClick() {
|
||||
@@ -2026,9 +2029,10 @@ export default function (view, params) {
|
||||
}
|
||||
|
||||
function onDownloadClick() {
|
||||
const downloadHref = getApiClient().getItemDownloadUrl(currentItem.Id);
|
||||
const api = toApi(getApiClient());
|
||||
const url = getLibraryApi(api).getDownloadUrl({ itemId: currentItem.Id });
|
||||
download([{
|
||||
url: downloadHref,
|
||||
url,
|
||||
item: currentItem,
|
||||
itemId: currentItem.Id,
|
||||
serverId: currentItem.ServerId,
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import React, { type FC, useCallback } from 'react';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import globalize from 'lib/globalize';
|
||||
import { useTogglePlayedMutation } from 'hooks/useFetchItems';
|
||||
|
||||
@@ -59,22 +59,17 @@ 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}
|
||||
title={getTitle()}
|
||||
className={btnClass}
|
||||
className={className}
|
||||
size='small'
|
||||
onClick={onClick}
|
||||
>
|
||||
<CheckIcon className={iconClass} />
|
||||
<CheckIcon
|
||||
color={isPlayed ? 'error' : undefined}
|
||||
/>
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user