Compare commits
334 Commits
resetFilte
...
feature/cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dd1de3d9c | ||
|
|
d14f15a2f8 | ||
|
|
de2a29621d | ||
|
|
f6fce9eae8 | ||
|
|
0b4b125519 | ||
|
|
c66db4e18d | ||
|
|
ea2abad3e1 | ||
|
|
6d8c8c0566 | ||
|
|
a2855c785e | ||
|
|
bf31a733a7 | ||
|
|
bf70fb80aa | ||
|
|
2acc6f360a | ||
|
|
a36eb7b546 | ||
|
|
fb6250d108 | ||
|
|
a82ae33aa3 | ||
|
|
32d916b420 | ||
|
|
014af0ebe9 | ||
|
|
9b80917cd1 | ||
|
|
238c5bbf58 | ||
|
|
264cdafaff | ||
|
|
1459a11320 | ||
|
|
ad3223cb77 | ||
|
|
445fe22f29 | ||
|
|
e28d70d34c | ||
|
|
9a207e9ba9 | ||
|
|
5db40d03ac | ||
|
|
ae58599bd0 | ||
|
|
e2ae48d8e5 | ||
|
|
bc39ee10ba | ||
|
|
603b5ed20c | ||
|
|
6bfff061ce | ||
|
|
44818f0c97 | ||
|
|
b3725e9dd5 | ||
|
|
ce22f8fe22 | ||
|
|
9f1370f242 | ||
|
|
b3913d7bb3 | ||
|
|
69d169e45f | ||
|
|
264eedc90a | ||
|
|
6fba30a0a9 | ||
|
|
3376a126de | ||
|
|
4e9c2e71a9 | ||
|
|
06f5442fc9 | ||
|
|
c478d6e307 | ||
|
|
cacb660ff8 | ||
|
|
4bdc0fd974 | ||
|
|
9af155b291 | ||
|
|
74f98bb120 | ||
|
|
e568ecbf30 | ||
|
|
1686788be5 | ||
|
|
43749273e4 | ||
|
|
b807ebfa4a | ||
|
|
8cc49df625 | ||
|
|
f2d2c5b26e | ||
|
|
5c444198ea | ||
|
|
dee5a1bcea | ||
|
|
3d55ce3724 | ||
|
|
3c6a5160a6 | ||
|
|
01200f3d70 | ||
|
|
39f971ffa4 | ||
|
|
e6141968d7 | ||
|
|
f445e53f7e | ||
|
|
d1379dce8a | ||
|
|
03c2cebbd3 | ||
|
|
ab0042d46f | ||
|
|
3c388fef92 | ||
|
|
9c76311936 | ||
|
|
f077e294a9 | ||
|
|
1c8f221006 | ||
|
|
a1d8bec051 | ||
|
|
000f89b99e | ||
|
|
83317879a8 | ||
|
|
7c0807680d | ||
|
|
053ce59352 | ||
|
|
b3833e7479 | ||
|
|
21d7dd86ea | ||
|
|
e2e679f0be | ||
|
|
993d370582 | ||
|
|
933e1b255b | ||
|
|
2c45c5ba4a | ||
|
|
cdde002ca6 | ||
|
|
19cb2e9977 | ||
|
|
fb7a1538d0 | ||
|
|
7491722364 | ||
|
|
d6c169321e | ||
|
|
6e2c62525a | ||
|
|
09dc3ae3a8 | ||
|
|
e102334812 | ||
|
|
907947c523 | ||
|
|
f3d7994b2a | ||
|
|
b9fdc61b6d | ||
|
|
37dcc07da5 | ||
|
|
e4e2c97bd5 | ||
|
|
6ce3e579c2 | ||
|
|
dbcac4c6f4 | ||
|
|
c11d630e42 | ||
|
|
7643885c6b | ||
|
|
92a1aa16dc | ||
|
|
4560d7c90f | ||
|
|
e97d658b3c | ||
|
|
7c0c2e088f | ||
|
|
0989a3034f | ||
|
|
17a1e2e94c | ||
|
|
b5382f0142 | ||
|
|
12079b9462 | ||
|
|
6a55ee3d71 | ||
|
|
6ee77f18bc | ||
|
|
db7498ed03 | ||
|
|
4f83e97592 | ||
|
|
4b072633fb | ||
|
|
0772f146b4 | ||
|
|
0bb8f7cb47 | ||
|
|
f7583a842b | ||
|
|
45bca06b2c | ||
|
|
c688faacb8 | ||
|
|
737b85b0b6 | ||
|
|
81698d5da7 | ||
|
|
64fbd6d3de | ||
|
|
fa7831bd1f | ||
|
|
c09237f4ce | ||
|
|
ad342a0b1e | ||
|
|
f1a77af8d3 | ||
|
|
c68dd09ebe | ||
|
|
daee19c4ac | ||
|
|
edb196c6b0 | ||
|
|
d0eabd3116 | ||
|
|
1189b6b84b | ||
|
|
e31e646b7b | ||
|
|
9b837ff89e | ||
|
|
5b0c88bd6b | ||
|
|
921d13517f | ||
|
|
22f0706789 | ||
|
|
b2951f0282 | ||
|
|
dfba17fdbc | ||
|
|
39777707b0 | ||
|
|
7606dfaf4b | ||
|
|
dae70c60e4 | ||
|
|
4f9a105921 | ||
|
|
1dc435986c | ||
|
|
fbbf879006 | ||
|
|
6dab926437 | ||
|
|
b183690db6 | ||
|
|
c2d94327d0 | ||
|
|
bf32030b23 | ||
|
|
91f210f378 | ||
|
|
7a9b8fe7ae | ||
|
|
e58063f457 | ||
|
|
62f61fa167 | ||
|
|
96024d3025 | ||
|
|
7281ce480d | ||
|
|
3bcaf84ecb | ||
|
|
4fa5176982 | ||
|
|
1ed047df3d | ||
|
|
d48e2c4cd7 | ||
|
|
7dc276ab51 | ||
|
|
a95599b60f | ||
|
|
dfd461cf4c | ||
|
|
3215be4cd8 | ||
|
|
e91a7556cf | ||
|
|
e6d57d8e89 | ||
|
|
2c2311415f | ||
|
|
16fd2a01aa | ||
|
|
0682ca3b99 | ||
|
|
d39c58675d | ||
|
|
5292162fdd | ||
|
|
f44b642514 | ||
|
|
d37b6304fa | ||
|
|
40fb2ddc93 | ||
|
|
184cc7e9d1 | ||
|
|
7d20728ae3 | ||
|
|
ac94190e0c | ||
|
|
cdf9613e08 | ||
|
|
d188880e7e | ||
|
|
0f6fcd8daf | ||
|
|
0e3384e7a4 | ||
|
|
b9769d9547 | ||
|
|
24860e373a | ||
|
|
b89a90ebf5 | ||
|
|
a98f740ad8 | ||
|
|
c6c951a377 | ||
|
|
a8af5c31cd | ||
|
|
b847506c1b | ||
|
|
776755a81c | ||
|
|
f936c9366f | ||
|
|
9b7d921845 | ||
|
|
b08df1ed80 | ||
|
|
9ebe4b7f57 | ||
|
|
9f6964fb51 | ||
|
|
3787889b41 | ||
|
|
91da2edae5 | ||
|
|
d5e54157ed | ||
|
|
ed8dbf1bd9 | ||
|
|
a19ccf5439 | ||
|
|
ae99ac8b03 | ||
|
|
e83279b69f | ||
|
|
5bf0b0314c | ||
|
|
5d1a19a65d | ||
|
|
ce24556dad | ||
|
|
2b92a87006 | ||
|
|
2a58eb8194 | ||
|
|
2b96e9d6c7 | ||
|
|
fd4c897642 | ||
|
|
76fbfbbe84 | ||
|
|
d26cc473a9 | ||
|
|
715b026b0f | ||
|
|
a3baf9a257 | ||
|
|
fbd480cd55 | ||
|
|
1435ea1560 | ||
|
|
4523b9f790 | ||
|
|
b691f62fc7 | ||
|
|
1d07721de8 | ||
|
|
5a1ca91bab | ||
|
|
70530a562c | ||
|
|
5b622a547d | ||
|
|
e2e9a5523d | ||
|
|
41e5b7b6bc | ||
|
|
1194cff68d | ||
|
|
35507a8303 | ||
|
|
4d59c20550 | ||
|
|
f974a39938 | ||
|
|
e9e56af092 | ||
|
|
3efd339d91 | ||
|
|
403d116338 | ||
|
|
0a9db2bda9 | ||
|
|
2ee0caab6a | ||
|
|
39ab3a52d8 | ||
|
|
49988dbd35 | ||
|
|
39278b1e4e | ||
|
|
d061871955 | ||
|
|
ec73f0e0fc | ||
|
|
58c43e72c0 | ||
|
|
3d75ba4a7e | ||
|
|
ec80e82625 | ||
|
|
f09ada7f87 | ||
|
|
b6be3c3866 | ||
|
|
527c25388e | ||
|
|
2d041661ce | ||
|
|
68d69351ea | ||
|
|
72e20c95ae | ||
|
|
8dab9a6f12 | ||
|
|
fc3ac97e75 | ||
|
|
73b23092ed | ||
|
|
3cf3a345db | ||
|
|
72392ec2ed | ||
|
|
e602b50e5b | ||
|
|
eaa0ca4b79 | ||
|
|
c8ca4f3bb4 | ||
|
|
36fa0fb9be | ||
|
|
3e30c04941 | ||
|
|
874a3cc727 | ||
|
|
8a0176eba2 | ||
|
|
f20aaa3195 | ||
|
|
abce5b1bea | ||
|
|
ad00b16069 | ||
|
|
cc16d73fac | ||
|
|
9c4bb658f6 | ||
|
|
77c2366dbe | ||
|
|
a32b2613ac | ||
|
|
429170bb65 | ||
|
|
b1e083f9c7 | ||
|
|
c93c25481d | ||
|
|
f10573ff46 | ||
|
|
c3d1f78e15 | ||
|
|
3667493bc2 | ||
|
|
5660931dd1 | ||
|
|
ab62a00574 | ||
|
|
af6b205781 | ||
|
|
a55eea3e62 | ||
|
|
9b2f036296 | ||
|
|
26c065c52d | ||
|
|
46a683a56b | ||
|
|
1586880776 | ||
|
|
3a747addbf | ||
|
|
1702604e32 | ||
|
|
ae56c9ee64 | ||
|
|
f87421bde8 | ||
|
|
5411a0a0e7 | ||
|
|
47889a5789 | ||
|
|
6c03684db5 | ||
|
|
98c1dfa597 | ||
|
|
ff42b28520 | ||
|
|
a516de5fc7 | ||
|
|
d5423d2d56 | ||
|
|
4b36146b34 | ||
|
|
37aa7b8b08 | ||
|
|
5346444689 | ||
|
|
473b8cb428 | ||
|
|
929c8b3cc7 | ||
|
|
b39360bf61 | ||
|
|
13f3f61b39 | ||
|
|
e225dce119 | ||
|
|
a238b5ef8a | ||
|
|
713bb551cf | ||
|
|
35082b8712 | ||
|
|
68eb5b9e36 | ||
|
|
966e69354a | ||
|
|
89c5119aed | ||
|
|
3a95e751d0 | ||
|
|
cc7799cf49 | ||
|
|
139ecd8146 | ||
|
|
eff386ffd8 | ||
|
|
b58ee4c1ba | ||
|
|
38fc5db9c2 | ||
|
|
2a59c296da | ||
|
|
fb58a759ac | ||
|
|
2e4dde35f4 | ||
|
|
952a83d282 | ||
|
|
a8f06c4fa8 | ||
|
|
2729f77aa8 | ||
|
|
ee717bab07 | ||
|
|
0606493bd9 | ||
|
|
4f0f1635be | ||
|
|
b78b5fc4f0 | ||
|
|
f97cbe0fc5 | ||
|
|
646773b30a | ||
|
|
6f615b7cd9 | ||
|
|
6c3a3a7205 | ||
|
|
df1626e95b | ||
|
|
e9cc027340 | ||
|
|
e8846f71a1 | ||
|
|
8c099c87fe | ||
|
|
79d2c178e9 | ||
|
|
a25295194f | ||
|
|
9562a188c4 | ||
|
|
d581dd9c68 | ||
|
|
19a28b441e | ||
|
|
ddae83d2ed | ||
|
|
b13942fbd5 | ||
|
|
e04c867424 | ||
|
|
eaf4b16abb | ||
|
|
82d9e465a3 | ||
|
|
a2222e4272 | ||
|
|
ca2d669924 | ||
|
|
db3ce49e9e | ||
|
|
e0e9853d49 |
@@ -101,6 +101,8 @@
|
||||
- [diegoeche](https://github.com/diegoeche)
|
||||
- [Free O'Toole](https://github.com/freeotoole)
|
||||
- [TheBosZ](https://github.com/thebosz)
|
||||
- [qm3jp](https://github.com/qm3jp)
|
||||
- [johnnyg](https://github.com/johnnyg)
|
||||
|
||||
## Emby Contributors
|
||||
|
||||
|
||||
70
package-lock.json
generated
70
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.11.0",
|
||||
"version": "10.11.6",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.11.0",
|
||||
"version": "10.11.6",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.14.0",
|
||||
@@ -18,7 +18,7 @@
|
||||
"@fontsource/noto-sans-sc": "5.2.6",
|
||||
"@fontsource/noto-sans-tc": "5.2.6",
|
||||
"@jellyfin/libass-wasm": "4.2.3",
|
||||
"@jellyfin/sdk": "0.0.0-unstable.202508300501",
|
||||
"@jellyfin/sdk": "0.12.0",
|
||||
"@jellyfin/ux-web": "1.0.0",
|
||||
"@mui/icons-material": "6.4.12",
|
||||
"@mui/material": "6.4.12",
|
||||
@@ -40,7 +40,7 @@
|
||||
"flv.js": "1.6.2",
|
||||
"headroom.js": "0.12.0",
|
||||
"history": "5.3.0",
|
||||
"hls.js": "1.6.9",
|
||||
"hls.js": "1.6.13",
|
||||
"intersection-observer": "0.12.2",
|
||||
"jellyfin-apiclient": "1.11.0",
|
||||
"jquery": "3.7.1",
|
||||
@@ -139,7 +139,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"npm": ">=9.6.4",
|
||||
"npm": ">=9.6.4 <11.0.0",
|
||||
"yarn": "YARN NO LONGER USED - use npm instead."
|
||||
},
|
||||
"optionalDependencies": {
|
||||
@@ -4129,12 +4129,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.202508300501",
|
||||
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202508300501.tgz",
|
||||
"integrity": "sha512-7pYdZxIQn/JTPmkEGiYmiGFbJ8S/fgA1EATz+lhOxL4nsCzf7b53DfNcL7eiYsUOFFThmDwJspUt6Y9FnimPPA==",
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.12.0.tgz",
|
||||
"integrity": "sha512-do3cks7TD316Qw27lBMHZQ7ufaS1MC8HMsQF5rFv5/DUInuwEOqWthqVyHl3sIjThOThF1zxQyE6OdpUl0dNUg==",
|
||||
"license": "MPL-2.0",
|
||||
"peerDependencies": {
|
||||
"axios": "^1.3.4"
|
||||
"axios": "^1.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jellyfin/ux-web": {
|
||||
@@ -7233,6 +7233,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 +7313,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 +8421,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 +9511,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 +11965,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": {
|
||||
@@ -12620,9 +12624,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.9",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.9.tgz",
|
||||
"integrity": "sha512-q7qPrri6GRwjcNd7EkFCmhiJ6PBIxeUsdxKbquBkQZpg9jAnp6zSAeN9eEWFlOB09J8JfzAQGoXL5ZEAltjO9g==",
|
||||
"version": "1.6.13",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.13.tgz",
|
||||
"integrity": "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
@@ -18228,6 +18232,7 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/proxy-polyfill": {
|
||||
@@ -27097,9 +27102,9 @@
|
||||
"integrity": "sha512-C0OlBxIr9NdeFESMTA/OVDqNSWtog6Mi7wwzwH12xbZpxsMD0RgCupUcIP7zZgcpTNecW3fZq5d6xVo7Q8HEJw=="
|
||||
},
|
||||
"@jellyfin/sdk": {
|
||||
"version": "0.0.0-unstable.202508300501",
|
||||
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202508300501.tgz",
|
||||
"integrity": "sha512-7pYdZxIQn/JTPmkEGiYmiGFbJ8S/fgA1EATz+lhOxL4nsCzf7b53DfNcL7eiYsUOFFThmDwJspUt6Y9FnimPPA==",
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.12.0.tgz",
|
||||
"integrity": "sha512-do3cks7TD316Qw27lBMHZQ7ufaS1MC8HMsQF5rFv5/DUInuwEOqWthqVyHl3sIjThOThF1zxQyE6OdpUl0dNUg==",
|
||||
"requires": {}
|
||||
},
|
||||
"@jellyfin/ux-web": {
|
||||
@@ -29097,13 +29102,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 +32395,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"
|
||||
}
|
||||
},
|
||||
@@ -32859,9 +32865,9 @@
|
||||
}
|
||||
},
|
||||
"hls.js": {
|
||||
"version": "1.6.9",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.9.tgz",
|
||||
"integrity": "sha512-q7qPrri6GRwjcNd7EkFCmhiJ6PBIxeUsdxKbquBkQZpg9jAnp6zSAeN9eEWFlOB09J8JfzAQGoXL5ZEAltjO9g=="
|
||||
"version": "1.6.13",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.13.tgz",
|
||||
"integrity": "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA=="
|
||||
},
|
||||
"hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.11.0",
|
||||
"version": "10.11.6",
|
||||
"description": "Web interface for Jellyfin",
|
||||
"repository": "https://github.com/jellyfin/jellyfin-web",
|
||||
"license": "GPL-2.0-or-later",
|
||||
@@ -84,7 +84,7 @@
|
||||
"@fontsource/noto-sans-sc": "5.2.6",
|
||||
"@fontsource/noto-sans-tc": "5.2.6",
|
||||
"@jellyfin/libass-wasm": "4.2.3",
|
||||
"@jellyfin/sdk": "0.0.0-unstable.202508300501",
|
||||
"@jellyfin/sdk": "0.12.0",
|
||||
"@jellyfin/ux-web": "1.0.0",
|
||||
"@mui/icons-material": "6.4.12",
|
||||
"@mui/material": "6.4.12",
|
||||
@@ -106,7 +106,7 @@
|
||||
"flv.js": "1.6.2",
|
||||
"headroom.js": "0.12.0",
|
||||
"history": "5.3.0",
|
||||
"hls.js": "1.6.9",
|
||||
"hls.js": "1.6.13",
|
||||
"intersection-observer": "0.12.2",
|
||||
"jellyfin-apiclient": "1.11.0",
|
||||
"jquery": "3.7.1",
|
||||
@@ -168,7 +168,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"npm": ">=9.6.4",
|
||||
"npm": ">=9.6.4 <11.0.0",
|
||||
"yarn": "YARN NO LONGER USED - use npm instead."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import Backdrop from 'components/Backdrop';
|
||||
import BangRedirect from 'components/router/BangRedirect';
|
||||
import { createRouterHistory } from 'components/router/routerHistory';
|
||||
import appTheme from 'themes/themes';
|
||||
import { ThemeStorageManager } from 'themes/themeStorageManager';
|
||||
|
||||
const layoutMode = localStorage.getItem('layout');
|
||||
const isExperimentalLayout = layoutMode === 'experimental';
|
||||
@@ -54,8 +55,7 @@ function RootAppLayout() {
|
||||
<ThemeProvider
|
||||
theme={appTheme}
|
||||
defaultMode='dark'
|
||||
// Disable mui's default saving to local storage
|
||||
storageManager={null}
|
||||
storageManager={ThemeStorageManager}
|
||||
>
|
||||
<Backdrop />
|
||||
<AppHeader isHidden={isExperimentalLayout || isNewLayoutPath} />
|
||||
|
||||
@@ -7,7 +7,7 @@ import isEqual from 'lodash-es/isEqual';
|
||||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { EventType } from 'types/eventType';
|
||||
import { EventType } from 'constants/eventType';
|
||||
import Events, { type Event } from 'utils/events';
|
||||
|
||||
interface AppTabsParams {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardHeader from '@mui/material/CardHeader';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
@@ -13,7 +13,6 @@ import { Link, To } from 'react-router-dom';
|
||||
|
||||
interface BaseCardProps {
|
||||
title?: string;
|
||||
secondaryTitle?: string;
|
||||
text?: string;
|
||||
image?: string | null;
|
||||
icon?: React.ReactNode;
|
||||
@@ -22,15 +21,30 @@ interface BaseCardProps {
|
||||
action?: boolean;
|
||||
actionRef?: React.MutableRefObject<HTMLButtonElement | null>;
|
||||
onActionClick?: () => void;
|
||||
height?: number;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
const BaseCard = ({ title, secondaryTitle, text, image, icon, to, onClick, action, actionRef, onActionClick }: BaseCardProps) => {
|
||||
const BaseCard = ({
|
||||
title,
|
||||
text,
|
||||
image,
|
||||
icon,
|
||||
to,
|
||||
onClick,
|
||||
action,
|
||||
actionRef,
|
||||
onActionClick,
|
||||
height,
|
||||
width
|
||||
}: BaseCardProps) => {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 240
|
||||
height: height || 240,
|
||||
width: width
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
@@ -62,30 +76,44 @@ const BaseCard = ({ title, secondaryTitle, text, image, icon, to, onClick, actio
|
||||
</Box>
|
||||
)}
|
||||
</CardActionArea>
|
||||
<CardHeader
|
||||
title={
|
||||
<Stack direction='row' spacing={1} alignItems='center'>
|
||||
<Typography sx={{
|
||||
<CardContent
|
||||
sx={{
|
||||
minHeight: 50,
|
||||
'&:last-child': {
|
||||
paddingBottom: 2,
|
||||
paddingRight: 1
|
||||
}
|
||||
}}>
|
||||
<Stack flexGrow={1} direction='row'>
|
||||
<Stack flexGrow={1}>
|
||||
<Typography gutterBottom sx={{
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{title}
|
||||
</Typography>
|
||||
{secondaryTitle && (
|
||||
<Typography variant='body2' color='text.secondary'>{secondaryTitle}</Typography>
|
||||
{text && (
|
||||
<Typography
|
||||
variant='body2'
|
||||
color='text.secondary'
|
||||
sx={{
|
||||
lineBreak: 'anywhere'
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
subheader={text}
|
||||
action={
|
||||
action ? (
|
||||
<IconButton ref={actionRef} onClick={onActionClick}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Box>
|
||||
{action ? (
|
||||
<IconButton ref={actionRef} onClick={onActionClick}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
30
src/apps/dashboard/components/Toast.tsx
Normal file
30
src/apps/dashboard/components/Toast.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import Snackbar, { SnackbarProps } from '@mui/material/Snackbar';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
const Toast = (props: SnackbarProps) => {
|
||||
const onCloseClick = useCallback((e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
props.onClose?.(e, 'clickaway');
|
||||
}, [ props ]);
|
||||
|
||||
const action = (
|
||||
<IconButton
|
||||
size='small'
|
||||
color='inherit'
|
||||
onClick={onCloseClick}
|
||||
>
|
||||
<CloseIcon fontSize='small' />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
autoHideDuration={3300}
|
||||
action={action}
|
||||
{ ...props }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
@@ -1,7 +1,8 @@
|
||||
import Box from '@mui/material/Box/Box';
|
||||
import Stack from '@mui/material/Stack/Stack';
|
||||
import type {} from '@mui/material/themeCssVarsAugmentation';
|
||||
import Typography from '@mui/material/Typography/Typography';
|
||||
import { type MRT_RowData, type MRT_TableInstance, MaterialReactTable } from 'material-react-table';
|
||||
import { type MRT_RowData, type MRT_TableInstance, type MRT_TableOptions, MaterialReactTable } from 'material-react-table';
|
||||
import React from 'react';
|
||||
|
||||
import Page, { type PageProps } from 'components/Page';
|
||||
@@ -12,7 +13,7 @@ interface TablePageProps<T extends MRT_RowData> extends PageProps {
|
||||
table: MRT_TableInstance<T>
|
||||
}
|
||||
|
||||
export const DEFAULT_TABLE_OPTIONS = {
|
||||
export const DEFAULT_TABLE_OPTIONS: Partial<MRT_TableOptions<MRT_RowData>> = {
|
||||
// Enable custom features
|
||||
enableColumnPinning: true,
|
||||
enableColumnResizing: true,
|
||||
|
||||
@@ -39,6 +39,7 @@ const ActivityLogWidget = () => {
|
||||
key={entry.Id}
|
||||
item={entry}
|
||||
displayShortOverview={true}
|
||||
to='/dashboard/activity?useractivity=true'
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
|
||||
@@ -31,6 +31,7 @@ const AlertsLogWidget = () => {
|
||||
key={entry.Id}
|
||||
item={entry}
|
||||
displayShortOverview={false}
|
||||
to='/dashboard/activity?useractivity=false'
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
|
||||
@@ -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/'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<div id="mediaLibraryPage" data-role="page" class="page type-interior mediaLibraryPage librarySectionPage fullWidthContent" data-title="${HeaderLibraries}">
|
||||
<div>
|
||||
<div class="content-primary">
|
||||
<div class="padded-top padded-bottom">
|
||||
<button is="emby-button" type="button" class="raised btnRefresh">
|
||||
<span>${ButtonScanAllLibraries}</span>
|
||||
</button>
|
||||
<progress max="100" min="0" style="display: inline-block; vertical-align: middle;" class="refreshProgress"></progress>
|
||||
</div>
|
||||
|
||||
<div id="divVirtualFolders"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,383 +0,0 @@
|
||||
import escapeHtml from 'escape-html';
|
||||
|
||||
import taskButton from 'scripts/taskbutton';
|
||||
import loading from 'components/loading/loading';
|
||||
import globalize from 'lib/globalize';
|
||||
import dom from 'utils/dom';
|
||||
import imageHelper from 'utils/image';
|
||||
import 'components/cardbuilder/card.scss';
|
||||
import 'elements/emby-itemrefreshindicator/emby-itemrefreshindicator';
|
||||
import { pageClassOn, pageIdOn } from 'utils/dashboard';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
|
||||
|
||||
function addVirtualFolder(page) {
|
||||
import('components/mediaLibraryCreator/mediaLibraryCreator').then(({ default: MediaLibraryCreator }) => {
|
||||
new MediaLibraryCreator({
|
||||
collectionTypeOptions: getCollectionTypeOptions().filter(function (f) {
|
||||
return !f.hidden;
|
||||
}),
|
||||
refresh: shouldRefreshLibraryAfterChanges(page)
|
||||
}).then(function (hasChanges) {
|
||||
if (hasChanges) {
|
||||
reloadLibrary(page);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function editVirtualFolder(page, virtualFolder) {
|
||||
import('components/mediaLibraryEditor/mediaLibraryEditor').then(({ default: MediaLibraryEditor }) => {
|
||||
new MediaLibraryEditor({
|
||||
refresh: shouldRefreshLibraryAfterChanges(page),
|
||||
library: virtualFolder
|
||||
}).then(function (hasChanges) {
|
||||
if (hasChanges) {
|
||||
reloadLibrary(page);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deleteVirtualFolder(page, virtualFolder) {
|
||||
let msg = globalize.translate('MessageAreYouSureYouWishToRemoveMediaFolder');
|
||||
|
||||
if (virtualFolder.Locations.length) {
|
||||
msg += '<br/><br/>' + globalize.translate('MessageTheFollowingLocationWillBeRemovedFromLibrary') + '<br/><br/>';
|
||||
msg += virtualFolder.Locations.join('<br/>');
|
||||
}
|
||||
|
||||
confirm({
|
||||
text: msg,
|
||||
title: globalize.translate('HeaderRemoveMediaFolder'),
|
||||
confirmText: globalize.translate('Delete'),
|
||||
primary: 'delete'
|
||||
}).then(function () {
|
||||
const refreshAfterChange = shouldRefreshLibraryAfterChanges(page);
|
||||
ApiClient.removeVirtualFolder(virtualFolder.Name, refreshAfterChange).then(function () {
|
||||
reloadLibrary(page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function refreshVirtualFolder(page, virtualFolder) {
|
||||
import('components/refreshdialog/refreshdialog').then(({ default: RefreshDialog }) => {
|
||||
new RefreshDialog({
|
||||
itemIds: [virtualFolder.ItemId],
|
||||
serverId: ApiClient.serverId(),
|
||||
mode: 'scan'
|
||||
}).show();
|
||||
});
|
||||
}
|
||||
|
||||
function renameVirtualFolder(page, virtualFolder) {
|
||||
import('components/prompt/prompt').then(({ default: prompt }) => {
|
||||
prompt({
|
||||
label: globalize.translate('LabelNewName'),
|
||||
description: globalize.translate('MessageRenameMediaFolder'),
|
||||
confirmText: globalize.translate('ButtonRename')
|
||||
}).then(function (newName) {
|
||||
if (newName && newName != virtualFolder.Name) {
|
||||
const refreshAfterChange = shouldRefreshLibraryAfterChanges(page);
|
||||
ApiClient.renameVirtualFolder(virtualFolder.Name, newName, refreshAfterChange).then(function () {
|
||||
reloadLibrary(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showCardMenu(page, elem, virtualFolders) {
|
||||
const card = dom.parentWithClass(elem, 'card');
|
||||
const index = parseInt(card.getAttribute('data-index'), 10);
|
||||
const virtualFolder = virtualFolders[index];
|
||||
const menuItems = [];
|
||||
menuItems.push({
|
||||
name: globalize.translate('EditImages'),
|
||||
id: 'editimages',
|
||||
icon: 'photo'
|
||||
});
|
||||
menuItems.push({
|
||||
name: globalize.translate('ManageLibrary'),
|
||||
id: 'edit',
|
||||
icon: 'folder'
|
||||
});
|
||||
menuItems.push({
|
||||
name: globalize.translate('ButtonRename'),
|
||||
id: 'rename',
|
||||
icon: 'mode_edit'
|
||||
});
|
||||
menuItems.push({
|
||||
name: globalize.translate('ScanLibrary'),
|
||||
id: 'refresh',
|
||||
icon: 'refresh'
|
||||
});
|
||||
menuItems.push({
|
||||
name: globalize.translate('ButtonRemove'),
|
||||
id: 'delete',
|
||||
icon: 'delete'
|
||||
});
|
||||
|
||||
import('components/actionSheet/actionSheet').then((actionsheet) => {
|
||||
actionsheet.show({
|
||||
items: menuItems,
|
||||
positionTo: elem,
|
||||
callback: function (resultId) {
|
||||
switch (resultId) {
|
||||
case 'edit':
|
||||
editVirtualFolder(page, virtualFolder);
|
||||
break;
|
||||
|
||||
case 'editimages':
|
||||
editImages(page, virtualFolder);
|
||||
break;
|
||||
|
||||
case 'rename':
|
||||
renameVirtualFolder(page, virtualFolder);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
deleteVirtualFolder(page, virtualFolder);
|
||||
break;
|
||||
|
||||
case 'refresh':
|
||||
refreshVirtualFolder(page, virtualFolder);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function reloadLibrary(page) {
|
||||
loading.show();
|
||||
ApiClient.getVirtualFolders().then(function (result) {
|
||||
reloadVirtualFolders(page, result);
|
||||
});
|
||||
}
|
||||
|
||||
function shouldRefreshLibraryAfterChanges(page) {
|
||||
return page.id === 'mediaLibraryPage';
|
||||
}
|
||||
|
||||
function reloadVirtualFolders(page, virtualFolders) {
|
||||
let html = '';
|
||||
virtualFolders.push({
|
||||
Name: globalize.translate('ButtonAddMediaLibrary'),
|
||||
icon: 'add_circle',
|
||||
Locations: [],
|
||||
showType: false,
|
||||
showLocations: false,
|
||||
showMenu: false,
|
||||
showNameWithIcon: false,
|
||||
elementId: 'addLibrary'
|
||||
});
|
||||
|
||||
for (let i = 0; i < virtualFolders.length; i++) {
|
||||
const virtualFolder = virtualFolders[i];
|
||||
html += getVirtualFolderHtml(page, virtualFolder, i);
|
||||
}
|
||||
|
||||
const divVirtualFolders = page.querySelector('#divVirtualFolders');
|
||||
divVirtualFolders.innerHTML = html;
|
||||
divVirtualFolders.classList.add('itemsContainer');
|
||||
divVirtualFolders.classList.add('vertical-wrap');
|
||||
const btnCardMenuElements = divVirtualFolders.querySelectorAll('.btnCardMenu');
|
||||
btnCardMenuElements.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
showCardMenu(page, btn, virtualFolders);
|
||||
});
|
||||
});
|
||||
divVirtualFolders.querySelector('#addLibrary').addEventListener('click', function () {
|
||||
addVirtualFolder(page);
|
||||
});
|
||||
|
||||
const libraryEditElements = divVirtualFolders.querySelectorAll('.editLibrary');
|
||||
libraryEditElements.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const card = dom.parentWithClass(btn, 'card');
|
||||
const index = parseInt(card.getAttribute('data-index'), 10);
|
||||
const virtualFolder = virtualFolders[index];
|
||||
|
||||
if (virtualFolder.ItemId) {
|
||||
editVirtualFolder(page, virtualFolder);
|
||||
}
|
||||
});
|
||||
});
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
function editImages(page, virtualFolder) {
|
||||
import('components/imageeditor/imageeditor').then((imageEditor) => {
|
||||
imageEditor.show({
|
||||
itemId: virtualFolder.ItemId,
|
||||
serverId: ApiClient.serverId()
|
||||
}).then(function () {
|
||||
reloadLibrary(page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getLink(text, url) {
|
||||
return globalize.translate(text, '<a is="emby-linkbutton" class="button-link" href="' + url + '" target="_blank" data-autohide="true">', '</a>');
|
||||
}
|
||||
|
||||
function getCollectionTypeOptions() {
|
||||
return [{
|
||||
name: '',
|
||||
value: ''
|
||||
}, {
|
||||
name: globalize.translate('Movies'),
|
||||
value: 'movies',
|
||||
message: getLink('MovieLibraryHelp', 'https://jellyfin.org/docs/general/server/media/movies')
|
||||
}, {
|
||||
name: globalize.translate('TabMusic'),
|
||||
value: 'music',
|
||||
message: getLink('MusicLibraryHelp', 'https://jellyfin.org/docs/general/server/media/music')
|
||||
}, {
|
||||
name: globalize.translate('Shows'),
|
||||
value: 'tvshows',
|
||||
message: getLink('TvLibraryHelp', 'https://jellyfin.org/docs/general/server/media/shows')
|
||||
}, {
|
||||
name: globalize.translate('Books'),
|
||||
value: 'books',
|
||||
message: getLink('BookLibraryHelp', 'https://jellyfin.org/docs/general/server/media/books')
|
||||
}, {
|
||||
name: globalize.translate('HomeVideosPhotos'),
|
||||
value: 'homevideos'
|
||||
}, {
|
||||
name: globalize.translate('MusicVideos'),
|
||||
value: 'musicvideos'
|
||||
}, {
|
||||
name: globalize.translate('MixedMoviesShows'),
|
||||
value: 'mixed',
|
||||
message: globalize.translate('MessageUnsetContentHelp')
|
||||
}];
|
||||
}
|
||||
|
||||
function getVirtualFolderHtml(page, virtualFolder, index) {
|
||||
let html = '';
|
||||
|
||||
const elementId = virtualFolder.elementId ? `id="${virtualFolder.elementId}" ` : '';
|
||||
html += '<div ' + elementId + 'class="card backdropCard scalableCard backdropCard-scalable" data-index="' + index + '" data-id="' + virtualFolder.ItemId + '">';
|
||||
|
||||
html += '<div class="cardBox visualCardBox">';
|
||||
html += '<div class="cardScalable visualCardBox-cardScalable">';
|
||||
html += '<div class="cardPadder cardPadder-backdrop"></div>';
|
||||
html += '<div class="cardContent">';
|
||||
let imgUrl = '';
|
||||
|
||||
if (virtualFolder.PrimaryImageItemId) {
|
||||
imgUrl = ApiClient.getScaledImageUrl(virtualFolder.PrimaryImageItemId, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.40),
|
||||
type: 'Primary'
|
||||
});
|
||||
}
|
||||
|
||||
let hasCardImageContainer;
|
||||
|
||||
if (imgUrl) {
|
||||
html += `<div class="cardImageContainer editLibrary ${imgUrl ? '' : getDefaultBackgroundClass()}" style="cursor:pointer">`;
|
||||
html += `<img src="${imgUrl}" style="width:100%" />`;
|
||||
hasCardImageContainer = true;
|
||||
} else if (!virtualFolder.showNameWithIcon) {
|
||||
html += `<div class="cardImageContainer editLibrary ${getDefaultBackgroundClass()}" style="cursor:pointer;">`;
|
||||
html += '<span class="cardImageIcon material-icons ' + (virtualFolder.icon || imageHelper.getLibraryIcon(virtualFolder.CollectionType)) + '" aria-hidden="true"></span>';
|
||||
hasCardImageContainer = true;
|
||||
}
|
||||
|
||||
if (hasCardImageContainer) {
|
||||
html += '<div class="cardIndicators backdropCardIndicators">';
|
||||
html += '<div is="emby-itemrefreshindicator"' + (virtualFolder.RefreshProgress || virtualFolder.RefreshStatus && virtualFolder.RefreshStatus !== 'Idle' ? '' : ' class="hide"') + ' data-progress="' + (virtualFolder.RefreshProgress || 0) + '" data-status="' + virtualFolder.RefreshStatus + '"></div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
if (!imgUrl && virtualFolder.showNameWithIcon) {
|
||||
html += '<h3 class="cardImageContainer addLibrary" style="position:absolute;top:0;left:0;right:0;bottom:0;cursor:pointer;flex-direction:column;">';
|
||||
html += '<span class="cardImageIcon material-icons ' + (virtualFolder.icon || imageHelper.getLibraryIcon(virtualFolder.CollectionType)) + '" aria-hidden="true"></span>';
|
||||
|
||||
if (virtualFolder.showNameWithIcon) {
|
||||
html += '<div style="margin:1em 0;position:width:100%;">';
|
||||
html += escapeHtml(virtualFolder.Name);
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</h3>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '<div class="cardFooter visualCardBox-cardFooter">'; // always show menu unless explicitly hidden
|
||||
|
||||
if (virtualFolder.showMenu !== false) {
|
||||
const dirTextAlign = globalize.getIsRTL() ? 'left' : 'right';
|
||||
html += '<div style="text-align:' + dirTextAlign + '; float:' + dirTextAlign + ';padding-top:5px;">';
|
||||
html += '<button type="button" is="paper-icon-button-light" class="btnCardMenu autoSize"><span class="material-icons more_vert" aria-hidden="true"></span></button>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += "<div class='cardText'>";
|
||||
|
||||
if (virtualFolder.showNameWithIcon) {
|
||||
html += ' ';
|
||||
} else {
|
||||
html += escapeHtml(virtualFolder.Name);
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
let typeName = getCollectionTypeOptions().filter(function (t) {
|
||||
return t.value == virtualFolder.CollectionType;
|
||||
})[0];
|
||||
typeName = typeName ? typeName.name : globalize.translate('Other');
|
||||
html += "<div class='cardText cardText-secondary'>";
|
||||
|
||||
if (virtualFolder.showType === false) {
|
||||
html += ' ';
|
||||
} else {
|
||||
html += typeName;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
if (virtualFolder.showLocations === false) {
|
||||
html += "<div class='cardText cardText-secondary'>";
|
||||
html += ' ';
|
||||
html += '</div>';
|
||||
} else if (virtualFolder.Locations.length && virtualFolder.Locations.length === 1) {
|
||||
html += "<div class='cardText cardText-secondary' dir='ltr' style='text-align:left;'>";
|
||||
html += virtualFolder.Locations[0];
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += "<div class='cardText cardText-secondary'>";
|
||||
html += globalize.translate('NumLocationsValue', virtualFolder.Locations.length);
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
pageClassOn('pageshow', 'mediaLibraryPage', function () {
|
||||
reloadLibrary(this);
|
||||
});
|
||||
pageIdOn('pageshow', 'mediaLibraryPage', function () {
|
||||
const page = this;
|
||||
taskButton({
|
||||
mode: 'on',
|
||||
progressElem: page.querySelector('.refreshProgress'),
|
||||
taskKey: 'RefreshLibrary',
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
});
|
||||
pageIdOn('pagebeforehide', 'mediaLibraryPage', function () {
|
||||
const page = this;
|
||||
taskButton({
|
||||
mode: 'off',
|
||||
progressElem: page.querySelector('.refreshProgress'),
|
||||
taskKey: 'RefreshLibrary',
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<div id="liveTvStatusPage" data-role="page" class="page type-interior liveTvSettingsPage" data-title="${LiveTV}">
|
||||
<div>
|
||||
<div class="content-primary">
|
||||
<div class="verticalSection verticalSection-extrabottompadding">
|
||||
<div class="verticalSection verticalSection-extrabottompadding">
|
||||
<div class="sectionTitleContainer sectionTitleContainer-cards">
|
||||
<h2 class="sectionTitle sectionTitle-cards">
|
||||
<span>${HeaderTunerDevices}</span>
|
||||
</h2>
|
||||
<button is="emby-button" type="button" class="fab btnAddDevice submit sectionTitleButton" style="margin-left:1em;" title="${Add}">
|
||||
<span class="material-icons add" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="devicesList itemsContainer vertical-wrap" data-hovermenu="false" data-multiselect="false" style="margin-top: .5em;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="readOnlyContent">
|
||||
<div class="verticalSection">
|
||||
<div class="sectionTitleContainer">
|
||||
<h2 class="sectionTitle">${HeaderGuideProviders}</h2>
|
||||
<button is="emby-button" type="button" class="fab btnAddProvider submit" style="margin-left:1em;" title="${Add}">
|
||||
<span class="material-icons add" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="providerList">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="button" class="raised btnRefresh block button-cancel">
|
||||
<span>${ButtonRefreshGuideData}</span>
|
||||
</button>
|
||||
<progress max="100" min="0" style="width: 100%;" class="refreshGuideProgress"></progress>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,338 +0,0 @@
|
||||
import 'jquery';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
import taskButton from 'scripts/taskbutton';
|
||||
import dom from 'utils/dom';
|
||||
import layoutManager from 'components/layoutManager';
|
||||
import loading from 'components/loading/loading';
|
||||
import browser from 'scripts/browser';
|
||||
import 'components/listview/listview.scss';
|
||||
import 'styles/flexstyles.scss';
|
||||
import 'elements/emby-itemscontainer/emby-itemscontainer';
|
||||
import 'components/cardbuilder/card.scss';
|
||||
import 'material-design-icons-iconfont';
|
||||
import 'elements/emby-button/emby-button';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
|
||||
|
||||
const enableFocusTransform = !browser.slow && !browser.edge;
|
||||
|
||||
function getDeviceHtml(device) {
|
||||
const padderClass = 'cardPadder-backdrop';
|
||||
let cssClass = 'card scalableCard backdropCard backdropCard-scalable';
|
||||
const cardBoxCssClass = 'cardBox visualCardBox';
|
||||
let html = '';
|
||||
|
||||
// TODO move card creation code to Card component
|
||||
|
||||
if (layoutManager.tv) {
|
||||
cssClass += ' show-focus';
|
||||
|
||||
if (enableFocusTransform) {
|
||||
cssClass += ' show-animation';
|
||||
}
|
||||
}
|
||||
|
||||
html += '<div type="button" class="' + cssClass + '" data-id="' + device.Id + '">';
|
||||
html += '<div class="' + cardBoxCssClass + '">';
|
||||
html += '<div class="cardScalable visualCardBox-cardScalable">';
|
||||
html += '<div class="' + padderClass + '"></div>';
|
||||
html += '<div class="cardContent searchImage">';
|
||||
html += `<div class="cardImageContainer coveredImage ${getDefaultBackgroundClass()}"><span class="cardImageIcon material-icons dvr" aria-hidden="true"></span></div>`;
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '<div class="cardFooter visualCardBox-cardFooter">';
|
||||
html += '<button is="paper-icon-button-light" class="itemAction btnCardOptions autoSize" data-action="menu"><span class="material-icons more_vert" aria-hidden="true"></span></button>';
|
||||
html += '<div class="cardText">' + (device.FriendlyName || getTunerName(device.Type)) + '</div>';
|
||||
html += '<div class="cardText cardText-secondary">';
|
||||
html += device.Url || ' ';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderDevices(page, devices) {
|
||||
page.querySelector('.devicesList').innerHTML = devices.map(getDeviceHtml).join('');
|
||||
}
|
||||
|
||||
function deleteDevice(page, id) {
|
||||
const message = globalize.translate('MessageConfirmDeleteTunerDevice');
|
||||
|
||||
confirm(message, globalize.translate('HeaderDeleteDevice')).then(function () {
|
||||
loading.show();
|
||||
ApiClient.ajax({
|
||||
type: 'DELETE',
|
||||
url: ApiClient.getUrl('LiveTv/TunerHosts', {
|
||||
Id: id
|
||||
})
|
||||
}).then(function () {
|
||||
reload(page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function reload(page) {
|
||||
loading.show();
|
||||
ApiClient.getNamedConfiguration('livetv').then(function (config) {
|
||||
renderDevices(page, config.TunerHosts);
|
||||
renderProviders(page, config.ListingProviders);
|
||||
});
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
function submitAddDeviceForm(page) {
|
||||
page.querySelector('.dlgAddDevice').close();
|
||||
loading.show();
|
||||
ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: ApiClient.getUrl('LiveTv/TunerHosts'),
|
||||
data: JSON.stringify({
|
||||
Type: page.querySelector('#selectTunerDeviceType').value,
|
||||
Url: page.querySelector('#txtDevicePath').value
|
||||
}),
|
||||
contentType: 'application/json'
|
||||
}).then(function () {
|
||||
reload(page);
|
||||
}, function () {
|
||||
Dashboard.alert({
|
||||
message: globalize.translate('ErrorAddingTunerDevice')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderProviders(page, providers) {
|
||||
let html = '';
|
||||
|
||||
if (providers.length) {
|
||||
html += '<div class="paperList">';
|
||||
|
||||
for (let i = 0, length = providers.length; i < length; i++) {
|
||||
const provider = providers[i];
|
||||
html += '<div class="listItem">';
|
||||
html += '<span class="listItemIcon material-icons dvr" aria-hidden="true"></span>';
|
||||
html += '<div class="listItemBody two-line">';
|
||||
html += '<a is="emby-linkbutton" style="display:block;padding:0;margin:0;text-align:left;" class="clearLink" href="' + getProviderConfigurationUrl(provider.Type) + '&id=' + provider.Id + '">';
|
||||
html += '<h3 class="listItemBodyText">';
|
||||
html += getProviderName(provider.Type);
|
||||
html += '</h3>';
|
||||
html += '<div class="listItemBodyText secondary">';
|
||||
html += provider.Path || provider.ListingsId || '';
|
||||
html += '</div>';
|
||||
html += '</a>';
|
||||
html += '</div>';
|
||||
html += '<button type="button" is="paper-icon-button-light" class="btnOptions" data-id="' + provider.Id + '"><span class="material-icons listItemAside more_vert" aria-hidden="true"></span></button>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
const elem = page.querySelector('.providerList');
|
||||
elem.innerHTML = html;
|
||||
if (elem.querySelector('.btnOptions')) {
|
||||
const btnOptionElements = elem.querySelectorAll('.btnOptions');
|
||||
btnOptionElements.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const id = this.getAttribute('data-id');
|
||||
showProviderOptions(page, id, btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function showProviderOptions(page, providerId, button) {
|
||||
const items = [];
|
||||
items.push({
|
||||
name: globalize.translate('Delete'),
|
||||
id: 'delete'
|
||||
});
|
||||
items.push({
|
||||
name: globalize.translate('MapChannels'),
|
||||
id: 'map'
|
||||
});
|
||||
|
||||
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
|
||||
actionsheet.show({
|
||||
items: items,
|
||||
positionTo: button
|
||||
}).then(function (id) {
|
||||
switch (id) {
|
||||
case 'delete':
|
||||
deleteProvider(page, providerId);
|
||||
break;
|
||||
|
||||
case 'map':
|
||||
mapChannels(page, providerId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mapChannels(page, providerId) {
|
||||
import('components/channelMapper/channelMapper').then(({ default: ChannelMapper }) => {
|
||||
new ChannelMapper({
|
||||
serverId: ApiClient.serverInfo().Id,
|
||||
providerId: providerId
|
||||
}).show();
|
||||
});
|
||||
}
|
||||
|
||||
function deleteProvider(page, id) {
|
||||
const message = globalize.translate('MessageConfirmDeleteGuideProvider');
|
||||
|
||||
confirm(message, globalize.translate('HeaderDeleteProvider')).then(function () {
|
||||
loading.show();
|
||||
ApiClient.ajax({
|
||||
type: 'DELETE',
|
||||
url: ApiClient.getUrl('LiveTv/ListingProviders', {
|
||||
Id: id
|
||||
})
|
||||
}).then(function () {
|
||||
reload(page);
|
||||
}, function () {
|
||||
reload(page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getTunerName(providerId) {
|
||||
switch (providerId.toLowerCase()) {
|
||||
case 'm3u':
|
||||
return 'M3U';
|
||||
case 'hdhomerun':
|
||||
return 'HDHomeRun';
|
||||
case 'hauppauge':
|
||||
return 'Hauppauge';
|
||||
case 'satip':
|
||||
return 'DVB';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderName(providerId) {
|
||||
switch (providerId.toLowerCase()) {
|
||||
case 'schedulesdirect':
|
||||
return 'Schedules Direct';
|
||||
case 'xmltv':
|
||||
return 'XMLTV';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderConfigurationUrl(providerId) {
|
||||
switch (providerId.toLowerCase()) {
|
||||
case 'xmltv':
|
||||
return '#/dashboard/livetv/guide?type=xmltv';
|
||||
case 'schedulesdirect':
|
||||
return '#/dashboard/livetv/guide?type=schedulesdirect';
|
||||
}
|
||||
}
|
||||
|
||||
function addProvider(button) {
|
||||
const menuItems = [];
|
||||
menuItems.push({
|
||||
name: 'Schedules Direct',
|
||||
id: 'SchedulesDirect'
|
||||
});
|
||||
menuItems.push({
|
||||
name: 'XMLTV',
|
||||
id: 'xmltv'
|
||||
});
|
||||
|
||||
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
|
||||
actionsheet.show({
|
||||
items: menuItems,
|
||||
positionTo: button,
|
||||
callback: function (id) {
|
||||
Dashboard.navigate(getProviderConfigurationUrl(id));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addDevice() {
|
||||
Dashboard.navigate('dashboard/livetv/tuner');
|
||||
}
|
||||
|
||||
function showDeviceMenu(button, tunerDeviceId) {
|
||||
const items = [];
|
||||
items.push({
|
||||
name: globalize.translate('Delete'),
|
||||
id: 'delete'
|
||||
});
|
||||
items.push({
|
||||
name: globalize.translate('Edit'),
|
||||
id: 'edit'
|
||||
});
|
||||
|
||||
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
|
||||
actionsheet.show({
|
||||
items: items,
|
||||
positionTo: button
|
||||
}).then(function (id) {
|
||||
switch (id) {
|
||||
case 'delete':
|
||||
deleteDevice(dom.parentWithClass(button, 'page'), tunerDeviceId);
|
||||
break;
|
||||
|
||||
case 'edit':
|
||||
Dashboard.navigate('dashboard/livetv/tuner?id=' + tunerDeviceId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onDevicesListClick(e) {
|
||||
const card = dom.parentWithClass(e.target, 'card');
|
||||
|
||||
if (card) {
|
||||
const id = card.getAttribute('data-id');
|
||||
const btnCardOptions = dom.parentWithClass(e.target, 'btnCardOptions');
|
||||
|
||||
if (btnCardOptions) {
|
||||
showDeviceMenu(btnCardOptions, id);
|
||||
} else {
|
||||
Dashboard.navigate('dashboard/livetv/tuner?id=' + id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$(document).on('pageinit', '#liveTvStatusPage', function () {
|
||||
const page = this;
|
||||
page.querySelector('.btnAddDevice').addEventListener('click', function () {
|
||||
addDevice();
|
||||
});
|
||||
if (page.querySelector('.formAddDevice')) {
|
||||
// NOTE: unused?
|
||||
page.querySelector('.formAddDevice').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
submitAddDeviceForm(page);
|
||||
});
|
||||
}
|
||||
page.querySelector('.btnAddProvider').addEventListener('click', function () {
|
||||
addProvider(this);
|
||||
});
|
||||
page.querySelector('.devicesList').addEventListener('click', onDevicesListClick);
|
||||
}).on('pageshow', '#liveTvStatusPage', function () {
|
||||
const page = this;
|
||||
reload(page);
|
||||
taskButton({
|
||||
mode: 'on',
|
||||
progressElem: page.querySelector('.refreshGuideProgress'),
|
||||
taskKey: 'RefreshGuide',
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
}).on('pagehide', '#liveTvStatusPage', function () {
|
||||
const page = this;
|
||||
taskButton({
|
||||
mode: 'off',
|
||||
progressElem: page.querySelector('.refreshGuideProgress'),
|
||||
taskKey: 'RefreshGuide',
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import Notifications from '@mui/icons-material/Notifications';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import formatRelative from 'date-fns/formatRelative';
|
||||
@@ -12,13 +11,15 @@ import { getLocale } from 'utils/dateFnsLocale';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import getLogLevelColor from '../utils/getLogLevelColor';
|
||||
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
|
||||
type ActivityListItemProps = {
|
||||
item: ActivityLogEntry;
|
||||
displayShortOverview: boolean;
|
||||
to: string;
|
||||
};
|
||||
|
||||
const ActivityListItem = ({ item, displayShortOverview }: ActivityListItemProps) => {
|
||||
const ActivityListItem = ({ item, displayShortOverview, to }: ActivityListItemProps) => {
|
||||
const relativeDate = useMemo(() => {
|
||||
if (item.Date) {
|
||||
return formatRelative(Date.parse(item.Date), Date.now(), { locale: getLocale() });
|
||||
@@ -29,7 +30,7 @@ const ActivityListItem = ({ item, displayShortOverview }: ActivityListItemProps)
|
||||
|
||||
return (
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton>
|
||||
<ListItemLink to={to}>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: getLogLevelColor(item.Severity || LogLevel.Information) + '.main' }}>
|
||||
<Notifications sx={{ color: '#fff' }} />
|
||||
@@ -37,14 +38,28 @@ const ActivityListItem = ({ item, displayShortOverview }: ActivityListItemProps)
|
||||
</ListItemAvatar>
|
||||
|
||||
<ListItemText
|
||||
primary={<Typography>{item.Name}</Typography>}
|
||||
primary={<Typography sx={{ whiteSpace: 'pre-wrap' }}>{item.Name}</Typography>}
|
||||
secondary={(
|
||||
<Stack>
|
||||
<Typography variant='body1' color='text.secondary'>
|
||||
<Typography
|
||||
sx={{
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
variant='body1'
|
||||
color='text.secondary'
|
||||
>
|
||||
{relativeDate}
|
||||
</Typography>
|
||||
{displayShortOverview && (
|
||||
<Typography variant='body1' color='text.secondary'>
|
||||
<Typography
|
||||
sx={{
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
variant='body1'
|
||||
color='text.secondary'
|
||||
>
|
||||
{item.ShortOverview}
|
||||
</Typography>
|
||||
)}
|
||||
@@ -52,7 +67,7 @@ const ActivityListItem = ({ item, displayShortOverview }: ActivityListItemProps)
|
||||
)}
|
||||
disableTypography
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import Box from '@mui/material/Box';
|
||||
import globalize from 'lib/globalize';
|
||||
import React, { FunctionComponent, useCallback } from 'react';
|
||||
import React, { FunctionComponent, useCallback, useState } from 'react';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import FormGroup from '@mui/material/FormGroup';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
@@ -16,7 +16,7 @@ import Checkbox from '@mui/material/Checkbox';
|
||||
import ContentCopy from '@mui/icons-material/ContentCopy';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { copy } from 'scripts/clipboard';
|
||||
import toast from 'components/toast/toast';
|
||||
import Toast from 'apps/dashboard/components/Toast';
|
||||
|
||||
type IProps = {
|
||||
backup: BackupManifestDto;
|
||||
@@ -25,10 +25,16 @@ type IProps = {
|
||||
};
|
||||
|
||||
const BackupInfoDialog: FunctionComponent<IProps> = ({ backup, open, onClose }: IProps) => {
|
||||
const [ isCopiedToastOpen, setIsCopiedToastOpen ] = useState(false);
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
setIsCopiedToastOpen(false);
|
||||
}, []);
|
||||
|
||||
const copyPath = useCallback(async () => {
|
||||
if (backup.Path) {
|
||||
await copy(backup.Path);
|
||||
toast({ text: globalize.translate('Copied') });
|
||||
setIsCopiedToastOpen(true);
|
||||
}
|
||||
}, [ backup.Path ]);
|
||||
|
||||
@@ -39,6 +45,11 @@ const BackupInfoDialog: FunctionComponent<IProps> = ({ backup, open, onClose }:
|
||||
maxWidth={'sm'}
|
||||
fullWidth
|
||||
>
|
||||
<Toast
|
||||
open={isCopiedToastOpen}
|
||||
onClose={handleToastClose}
|
||||
message={globalize.translate('Copied')}
|
||||
/>
|
||||
<DialogTitle>
|
||||
{backup.DateCreated}
|
||||
</DialogTitle>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getLibraryStructureApi } from '@jellyfin/sdk/lib/utils/api/library-structure-api';
|
||||
import { LibraryStructureApiRemoveVirtualFolderRequest } from '@jellyfin/sdk/lib/generated-client/api/library-structure-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
export const useRemoveVirtualFolder = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: LibraryStructureApiRemoveVirtualFolderRequest) => (
|
||||
getLibraryStructureApi(api!)
|
||||
.removeVirtualFolder(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ 'VirtualFolders' ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getLibraryStructureApi } from '@jellyfin/sdk/lib/utils/api/library-structure-api';
|
||||
import { LibraryStructureApiRenameVirtualFolderRequest } from '@jellyfin/sdk/lib/generated-client/api/library-structure-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
export const useRenameVirtualFolder = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: LibraryStructureApiRenameVirtualFolderRequest) => (
|
||||
getLibraryStructureApi(api!)
|
||||
.renameVirtualFolder(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ 'VirtualFolders' ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import { getLibraryStructureApi } from '@jellyfin/sdk/lib/utils/api/library-structure-api';
|
||||
|
||||
const fetchVirtualFolders = async (api: Api, options?: AxiosRequestConfig) => {
|
||||
const response = await getLibraryStructureApi(api).getVirtualFolders(options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useVirtualFolders = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [ 'VirtualFolders' ],
|
||||
queryFn: ({ signal }) => fetchVirtualFolders(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
237
src/apps/dashboard/features/libraries/components/LibraryCard.tsx
Normal file
237
src/apps/dashboard/features/libraries/components/LibraryCard.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { VirtualFolderInfo } from '@jellyfin/sdk/lib/generated-client/models/virtual-folder-info';
|
||||
import BaseCard from 'apps/dashboard/components/BaseCard';
|
||||
import getCollectionTypeOptions from '../utils/collectionTypeOptions';
|
||||
import globalize from 'lib/globalize';
|
||||
import Icon from '@mui/material/Icon';
|
||||
import { getLibraryIcon } from 'utils/image';
|
||||
import MediaLibraryEditor from 'components/mediaLibraryEditor/mediaLibraryEditor';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import Folder from '@mui/icons-material/Folder';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import imageeditor from 'components/imageeditor/imageeditor';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import InputDialog from 'components/InputDialog';
|
||||
import { useRenameVirtualFolder } from '../api/useRenameVirtualFolder';
|
||||
import RefreshDialog from 'components/refreshdialog/refreshdialog';
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
import { useRemoveVirtualFolder } from '../api/useRemoveVirtualFolder';
|
||||
import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
||||
import dom from 'utils/dom';
|
||||
|
||||
type LibraryCardProps = {
|
||||
virtualFolder: VirtualFolderInfo;
|
||||
};
|
||||
|
||||
const LibraryCard = ({ virtualFolder }: LibraryCardProps) => {
|
||||
const { api } = useApi();
|
||||
const actionRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [ anchorEl, setAnchorEl ] = useState<HTMLElement | null>(null);
|
||||
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
|
||||
const [ isRenameLibraryDialogOpen, setIsRenameLibraryDialogOpen ] = useState(false);
|
||||
const [ isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen ] = useState(false);
|
||||
const renameVirtualFolder = useRenameVirtualFolder();
|
||||
const removeVirtualFolder = useRemoveVirtualFolder();
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (virtualFolder.PrimaryImageItemId && virtualFolder.ItemId && api) {
|
||||
return getImageApi(api)
|
||||
.getItemImageUrlById(virtualFolder.ItemId, ImageType.Primary, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.40)
|
||||
});
|
||||
}
|
||||
}, [ api, virtualFolder ]);
|
||||
|
||||
const typeName = getCollectionTypeOptions().filter(function (t) {
|
||||
return t.value == virtualFolder.CollectionType;
|
||||
})[0]?.name || globalize.translate('Other');
|
||||
|
||||
const openRenameDialog = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
setIsRenameLibraryDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const hideRenameLibraryDialog = useCallback(() => {
|
||||
setIsRenameLibraryDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const onMenuClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const onActionClick = useCallback(() => {
|
||||
setAnchorEl(actionRef.current);
|
||||
setIsMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const renameLibrary = useCallback((newName: string) => {
|
||||
if (virtualFolder.Name) {
|
||||
renameVirtualFolder.mutate({
|
||||
refreshLibrary: true,
|
||||
newName: newName,
|
||||
name: virtualFolder.Name
|
||||
}, {
|
||||
onSettled: () => {
|
||||
hideRenameLibraryDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [ renameVirtualFolder, virtualFolder, hideRenameLibraryDialog ]);
|
||||
|
||||
const showRefreshDialog = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
|
||||
void new RefreshDialog({
|
||||
itemIds: [ virtualFolder.ItemId ],
|
||||
serverId: ServerConnections.currentApiClient()?.serverId(),
|
||||
mode: 'scan'
|
||||
}).show();
|
||||
}, [ virtualFolder ]);
|
||||
|
||||
const showMediaLibraryEditor = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
|
||||
const mediaLibraryEditor = new MediaLibraryEditor({
|
||||
library: virtualFolder
|
||||
}) as Promise<boolean>;
|
||||
|
||||
void mediaLibraryEditor.then((hasChanges: boolean) => {
|
||||
if (hasChanges) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['VirtualFolders']
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [ virtualFolder ]);
|
||||
|
||||
const showImageEditor = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
|
||||
void imageeditor.show({
|
||||
itemId: virtualFolder.ItemId,
|
||||
serverId: ServerConnections.currentApiClient()?.serverId()
|
||||
}).then(() => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['VirtualFolders']
|
||||
});
|
||||
}).catch(() => {
|
||||
/* pop up closed */
|
||||
});
|
||||
}, [ virtualFolder ]);
|
||||
|
||||
const showDeleteLibraryDialog = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
setIsConfirmDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const onCancelDeleteLibrary = useCallback(() => {
|
||||
setIsConfirmDeleteDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const onConfirmDeleteLibrary = useCallback(() => {
|
||||
if (virtualFolder.Name) {
|
||||
removeVirtualFolder.mutate({
|
||||
name: virtualFolder.Name,
|
||||
refreshLibrary: true
|
||||
}, {
|
||||
onSettled: () => {
|
||||
setIsConfirmDeleteDialogOpen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [ virtualFolder, removeVirtualFolder ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputDialog
|
||||
title={globalize.translate('ButtonRename')}
|
||||
open={isRenameLibraryDialogOpen}
|
||||
onClose={hideRenameLibraryDialog}
|
||||
label={globalize.translate('LabelNewName')}
|
||||
helperText={globalize.translate('MessageRenameMediaFolder')}
|
||||
initialText={virtualFolder.Name || ''}
|
||||
confirmButtonText={globalize.translate('ButtonRename')}
|
||||
onConfirm={renameLibrary}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={isConfirmDeleteDialogOpen}
|
||||
title={globalize.translate('HeaderRemoveMediaFolder')}
|
||||
text={
|
||||
globalize.translate('MessageAreYouSureYouWishToRemoveMediaFolder') + '\n\n'
|
||||
+ globalize.translate('MessageTheFollowingLocationWillBeRemovedFromLibrary') + '\n\n'
|
||||
+ virtualFolder.Locations?.join('\n')
|
||||
}
|
||||
confirmButtonText={globalize.translate('Delete')}
|
||||
confirmButtonColor='error'
|
||||
onConfirm={onConfirmDeleteLibrary}
|
||||
onCancel={onCancelDeleteLibrary}
|
||||
/>
|
||||
|
||||
<BaseCard
|
||||
title={virtualFolder.Name || ''}
|
||||
text={typeName}
|
||||
image={imageUrl}
|
||||
icon={<Icon sx={{ fontSize: 70 }}>{getLibraryIcon(virtualFolder.CollectionType)}</Icon>}
|
||||
action={true}
|
||||
actionRef={actionRef}
|
||||
onActionClick={onActionClick}
|
||||
onClick={showMediaLibraryEditor}
|
||||
height={260}
|
||||
/>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
<MenuItem onClick={showImageEditor}>
|
||||
<ListItemIcon>
|
||||
<ImageIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('EditImages')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={showMediaLibraryEditor}>
|
||||
<ListItemIcon>
|
||||
<Folder />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('ManageLibrary')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={openRenameDialog}>
|
||||
<ListItemIcon>
|
||||
<EditIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('ButtonRename')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={showRefreshDialog}>
|
||||
<ListItemIcon>
|
||||
<RefreshIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('ScanLibrary')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={showDeleteLibraryDialog}>
|
||||
<ListItemIcon>
|
||||
<DeleteIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('ButtonRemove')}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryCard;
|
||||
@@ -0,0 +1,31 @@
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
const getCollectionTypeOptions = () => {
|
||||
return [{
|
||||
name: '',
|
||||
value: ''
|
||||
}, {
|
||||
name: globalize.translate('Movies'),
|
||||
value: 'movies'
|
||||
}, {
|
||||
name: globalize.translate('TabMusic'),
|
||||
value: 'music'
|
||||
}, {
|
||||
name: globalize.translate('Shows'),
|
||||
value: 'tvshows'
|
||||
}, {
|
||||
name: globalize.translate('Books'),
|
||||
value: 'books'
|
||||
}, {
|
||||
name: globalize.translate('HomeVideosPhotos'),
|
||||
value: 'homevideos'
|
||||
}, {
|
||||
name: globalize.translate('MusicVideos'),
|
||||
value: 'musicvideos'
|
||||
}, {
|
||||
name: globalize.translate('MixedMoviesShows'),
|
||||
value: 'mixed'
|
||||
}];
|
||||
};
|
||||
|
||||
export default getCollectionTypeOptions;
|
||||
22
src/apps/dashboard/features/livetv/api/useDeleteProvider.ts
Normal file
22
src/apps/dashboard/features/livetv/api/useDeleteProvider.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api';
|
||||
import { LiveTvApiDeleteListingProviderRequest } from '@jellyfin/sdk/lib/generated-client/api/live-tv-api';
|
||||
|
||||
export const useDeleteProvider = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: LiveTvApiDeleteListingProviderRequest) => (
|
||||
getLiveTvApi(api!)
|
||||
.deleteListingProvider(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ 'NamedConfiguration', 'livetv' ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
22
src/apps/dashboard/features/livetv/api/useDeleteTuner.ts
Normal file
22
src/apps/dashboard/features/livetv/api/useDeleteTuner.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api';
|
||||
import { LiveTvApiDeleteTunerHostRequest } from '@jellyfin/sdk/lib/generated-client/api/live-tv-api';
|
||||
|
||||
export const useDeleteTuner = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: LiveTvApiDeleteTunerHostRequest) => (
|
||||
getLiveTvApi(api!)
|
||||
.deleteTunerHost(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ 'NamedConfiguration', 'livetv' ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
138
src/apps/dashboard/features/livetv/components/Provider.tsx
Normal file
138
src/apps/dashboard/features/livetv/components/Provider.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import type { ListingsProviderInfo } from '@jellyfin/sdk/lib/generated-client/models/listings-provider-info';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
import DvrIcon from '@mui/icons-material/Dvr';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import getProviderConfigurationUrl from '../utils/getProviderConfigurationUrl';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import getProviderName from '../utils/getProviderName';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
import globalize from 'lib/globalize';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import LocationSearchingIcon from '@mui/icons-material/LocationSearching';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ChannelMapper from 'components/channelMapper/channelMapper';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import { useDeleteProvider } from '../api/useDeleteProvider';
|
||||
|
||||
interface ProviderProps {
|
||||
provider: ListingsProviderInfo
|
||||
}
|
||||
|
||||
const Provider = ({ provider }: ProviderProps) => {
|
||||
const [ isDeleteProviderDialogOpen, setIsDeleteProviderDialogOpen ] = useState(false);
|
||||
const actionsRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [ anchorEl, setAnchorEl ] = useState<HTMLButtonElement | null>(null);
|
||||
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
|
||||
const deleteProvider = useDeleteProvider();
|
||||
|
||||
const showChannelMapper = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
void new ChannelMapper({
|
||||
serverId: ServerConnections.currentApiClient()?.serverId(),
|
||||
providerId: provider.Id
|
||||
}).show();
|
||||
}, [ provider ]);
|
||||
|
||||
const showContextMenu = useCallback(() => {
|
||||
setAnchorEl(actionsRef.current);
|
||||
setIsMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const showDeleteDialog = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
setIsDeleteProviderDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const onDeleteProviderDialogCancel = useCallback(() => {
|
||||
setIsDeleteProviderDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const onMenuClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const onConfirmDelete = useCallback(() => {
|
||||
if (provider.Id) {
|
||||
deleteProvider.mutate({
|
||||
id: provider.Id
|
||||
}, {
|
||||
onSettled: () => {
|
||||
setIsDeleteProviderDialogOpen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [ deleteProvider, provider ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
open={isDeleteProviderDialogOpen}
|
||||
title={globalize.translate('HeaderDeleteProvider')}
|
||||
text={globalize.translate('MessageConfirmDeleteGuideProvider')}
|
||||
onCancel={onDeleteProviderDialogCancel}
|
||||
onConfirm={onConfirmDelete}
|
||||
confirmButtonText={globalize.translate('Delete')}
|
||||
confirmButtonColor='error'
|
||||
/>
|
||||
<ListItem
|
||||
disablePadding key={provider.Id}
|
||||
secondaryAction={
|
||||
<IconButton ref={actionsRef} onClick={showContextMenu}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemLink to={getProviderConfigurationUrl(provider.Type || '') + '&id=' + provider.Id}>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||
<DvrIcon sx={{ color: '#fff' }} />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={getProviderName(provider.Type)}
|
||||
secondary={provider.Path || provider.ListingsId}
|
||||
slotProps={{
|
||||
primary: {
|
||||
variant: 'h3'
|
||||
},
|
||||
secondary: {
|
||||
variant: 'body1'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
<MenuItem onClick={showChannelMapper}>
|
||||
<ListItemIcon>
|
||||
<LocationSearchingIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('MapChannels')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={showDeleteDialog}>
|
||||
<ListItemIcon>
|
||||
<DeleteIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('Delete')}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Provider;
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import type { TunerHostInfo } from '@jellyfin/sdk/lib/generated-client/models/tuner-host-info';
|
||||
import BaseCard from 'apps/dashboard/components/BaseCard';
|
||||
import DvrIcon from '@mui/icons-material/Dvr';
|
||||
import getTunerName from '../utils/getTunerName';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import globalize from 'lib/globalize';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
import { useDeleteTuner } from '../api/useDeleteTuner';
|
||||
|
||||
interface TunerDeviceCardProps {
|
||||
tunerHost: TunerHostInfo;
|
||||
}
|
||||
|
||||
const TunerDeviceCard = ({ tunerHost }: TunerDeviceCardProps) => {
|
||||
const navigate = useNavigate();
|
||||
const actionRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [ anchorEl, setAnchorEl ] = useState<HTMLElement | null>(null);
|
||||
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
|
||||
const [ isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen ] = useState(false);
|
||||
const deleteTuner = useDeleteTuner();
|
||||
|
||||
const navigateToEditPage = useCallback(() => {
|
||||
navigate(`/dashboard/livetv/tuner?id=${tunerHost.Id}`);
|
||||
}, [ navigate, tunerHost ]);
|
||||
|
||||
const onDelete = useCallback(() => {
|
||||
if (tunerHost.Id) {
|
||||
deleteTuner.mutate({
|
||||
id: tunerHost.Id
|
||||
}, {
|
||||
onSettled: () => {
|
||||
setIsConfirmDeleteDialogOpen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [ deleteTuner, tunerHost ]);
|
||||
|
||||
const showDeleteDialog = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
setIsConfirmDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const onDeleteDialogClose = useCallback(() => {
|
||||
setIsConfirmDeleteDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const onActionClick = useCallback(() => {
|
||||
setAnchorEl(actionRef.current);
|
||||
setIsMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const onMenuClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
open={isConfirmDeleteDialogOpen}
|
||||
title={globalize.translate('HeaderDeleteDevice')}
|
||||
text={globalize.translate('MessageConfirmDeleteTunerDevice')}
|
||||
onCancel={onDeleteDialogClose}
|
||||
onConfirm={onDelete}
|
||||
confirmButtonColor='error'
|
||||
confirmButtonText={globalize.translate('Delete')}
|
||||
/>
|
||||
|
||||
<BaseCard
|
||||
title={tunerHost.FriendlyName || getTunerName(tunerHost.Type) || ''}
|
||||
text={tunerHost.Url || ''}
|
||||
icon={<DvrIcon sx={{ fontSize: 70 }} />}
|
||||
action={true}
|
||||
actionRef={actionRef}
|
||||
onActionClick={onActionClick}
|
||||
onClick={navigateToEditPage}
|
||||
/>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
<MenuItem onClick={navigateToEditPage}>
|
||||
<ListItemIcon>
|
||||
<EditIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('Edit')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={showDeleteDialog}>
|
||||
<ListItemIcon>
|
||||
<DeleteIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('Delete')}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TunerDeviceCard;
|
||||
@@ -0,0 +1,10 @@
|
||||
const getProviderConfigurationUrl = (providerId: string) => {
|
||||
switch (providerId?.toLowerCase()) {
|
||||
case 'xmltv':
|
||||
return '/dashboard/livetv/guide?type=xmltv';
|
||||
case 'schedulesdirect':
|
||||
return '/dashboard/livetv/guide?type=schedulesdirect';
|
||||
}
|
||||
};
|
||||
|
||||
export default getProviderConfigurationUrl;
|
||||
12
src/apps/dashboard/features/livetv/utils/getProviderName.ts
Normal file
12
src/apps/dashboard/features/livetv/utils/getProviderName.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
const getProviderName = (providerId: string | null | undefined) => {
|
||||
switch (providerId?.toLowerCase()) {
|
||||
case 'schedulesdirect':
|
||||
return 'Schedules Direct';
|
||||
case 'xmltv':
|
||||
return 'XMLTV';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
export default getProviderName;
|
||||
16
src/apps/dashboard/features/livetv/utils/getTunerName.ts
Normal file
16
src/apps/dashboard/features/livetv/utils/getTunerName.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
const getTunerName = (providerId: string | null | undefined) => {
|
||||
switch (providerId?.toLowerCase()) {
|
||||
case 'm3u':
|
||||
return 'M3U';
|
||||
case 'hdhomerun':
|
||||
return 'HDHomeRun';
|
||||
case 'hauppauge':
|
||||
return 'Hauppauge';
|
||||
case 'satip':
|
||||
return 'DVB';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
export default getTunerName;
|
||||
@@ -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();
|
||||
|
||||
@@ -8,7 +8,15 @@ const TaskProgress: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
|
||||
const progress = task.CurrentProgressPercentage;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', height: '1.2rem', mr: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '1.2rem',
|
||||
mr: 2,
|
||||
minWidth: '170px'
|
||||
}}
|
||||
>
|
||||
{progress != null ? (
|
||||
<>
|
||||
<Box sx={{ width: '100%', mr: 1 }}>
|
||||
|
||||
@@ -9,9 +9,11 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||
{ path: 'devices', type: AppType.Dashboard },
|
||||
{ path: 'settings', type: AppType.Dashboard },
|
||||
{ path: 'keys', type: AppType.Dashboard },
|
||||
{ path: 'libraries', type: AppType.Dashboard },
|
||||
{ path: 'libraries/display', type: AppType.Dashboard },
|
||||
{ path: 'libraries/metadata', type: AppType.Dashboard },
|
||||
{ path: 'libraries/nfo', type: AppType.Dashboard },
|
||||
{ path: 'livetv', type: AppType.Dashboard },
|
||||
{ path: 'livetv/recordings', type: AppType.Dashboard },
|
||||
{ path: 'logs', type: AppType.Dashboard },
|
||||
{ path: 'logs/:file', page: 'logs/file', type: AppType.Dashboard },
|
||||
|
||||
@@ -9,13 +9,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||
controller: 'networking',
|
||||
view: 'networking.html'
|
||||
}
|
||||
}, {
|
||||
path: 'libraries',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'library',
|
||||
view: 'library.html'
|
||||
}
|
||||
}, {
|
||||
path: 'livetv/guide',
|
||||
pageProps: {
|
||||
@@ -23,13 +16,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||
controller: 'livetvguideprovider',
|
||||
view: 'livetvguideprovider.html'
|
||||
}
|
||||
}, {
|
||||
path: 'livetv',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'livetvstatus',
|
||||
view: 'livetvstatus.html'
|
||||
}
|
||||
}, {
|
||||
path: 'livetv/tuner',
|
||||
pageProps: {
|
||||
|
||||
@@ -2,9 +2,10 @@ import parseISO from 'date-fns/parseISO';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
|
||||
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
|
||||
import { type MRT_ColumnDef, type MRT_Theme, useMaterialReactTable } from 'material-react-table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
|
||||
@@ -53,6 +54,8 @@ export const Component = () => {
|
||||
|
||||
const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails();
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const UserCell = getUserCell(users);
|
||||
|
||||
const activityParams = useMemo(() => ({
|
||||
@@ -156,8 +159,15 @@ export const Component = () => {
|
||||
}
|
||||
}, [ activityView, searchParams, setSearchParams ]);
|
||||
|
||||
// NOTE: We need to provide a custom theme due to a MRT bug causing the initial theme to always be used
|
||||
// https://github.com/KevinVandy/material-react-table/issues/1429
|
||||
const mrtTheme = useMemo<Partial<MRT_Theme>>(() => ({
|
||||
baseBackgroundColor: theme.palette.background.paper
|
||||
}), [ theme ]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...DEFAULT_TABLE_OPTIONS,
|
||||
mrtTheme,
|
||||
|
||||
columns,
|
||||
data: logEntries,
|
||||
|
||||
@@ -102,7 +102,7 @@ export const Component = () => {
|
||||
}).catch(() => {
|
||||
// Server is still down
|
||||
});
|
||||
}, 5000);
|
||||
}, 45000);
|
||||
|
||||
return () => {
|
||||
clearInterval(serverCheckInterval);
|
||||
|
||||
@@ -4,9 +4,10 @@ import Edit from '@mui/icons-material/Edit';
|
||||
import Box from '@mui/material/Box/Box';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import Tooltip from '@mui/material/Tooltip/Tooltip';
|
||||
import parseISO from 'date-fns/parseISO';
|
||||
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
|
||||
import { type MRT_ColumnDef, type MRT_Theme, useMaterialReactTable } from 'material-react-table';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
|
||||
@@ -41,6 +42,7 @@ export const Component = () => {
|
||||
data?.Items || []
|
||||
), [ data ]);
|
||||
const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails();
|
||||
const theme = useTheme();
|
||||
|
||||
const [ isDeleteConfirmOpen, setIsDeleteConfirmOpen ] = useState(false);
|
||||
const [ isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen ] = useState(false);
|
||||
@@ -137,8 +139,15 @@ export const Component = () => {
|
||||
}
|
||||
], [ UserCell, userNames ]);
|
||||
|
||||
// NOTE: We need to provide a custom theme due to a MRT bug causing the initial theme to always be used
|
||||
// https://github.com/KevinVandy/material-react-table/issues/1429
|
||||
const mrtTheme = useMemo<Partial<MRT_Theme>>(() => ({
|
||||
baseBackgroundColor: theme.palette.background.paper
|
||||
}), [ theme ]);
|
||||
|
||||
const mrTable = useMaterialReactTable({
|
||||
...DEFAULT_TABLE_OPTIONS,
|
||||
mrtTheme,
|
||||
|
||||
columns,
|
||||
data: devices,
|
||||
@@ -184,16 +193,25 @@ export const Component = () => {
|
||||
positionActionsColumn: 'last',
|
||||
displayColumnDefOptions: {
|
||||
'mrt-row-actions': {
|
||||
header: ''
|
||||
header: '',
|
||||
size: 100
|
||||
}
|
||||
},
|
||||
renderRowActions: ({ row, table }) => {
|
||||
const isDeletable = api && row.original.Id && api.deviceInfo.id === row.original.Id;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
'&&': {
|
||||
backgroundColor: 'transparent !important'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip title={globalize.translate('Edit')}>
|
||||
<IconButton
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => table.setEditingRow(row)}
|
||||
>
|
||||
<Edit />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -2,31 +2,34 @@ import type { AuthenticationInfo } from '@jellyfin/sdk/lib/generated-client/mode
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import parseISO from 'date-fns/parseISO';
|
||||
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { type MRT_ColumnDef, type MRT_Theme, useMaterialReactTable } from 'material-react-table';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
|
||||
import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/table/TablePage';
|
||||
import { useApiKeys } from 'apps/dashboard/features/keys/api/useApiKeys';
|
||||
import { useRevokeKey } from 'apps/dashboard/features/keys/api/useRevokeKey';
|
||||
import { useCreateKey } from 'apps/dashboard/features/keys/api/useCreateKey';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import prompt from 'components/prompt/prompt';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import globalize from 'lib/globalize';
|
||||
import InputDialog from 'components/InputDialog';
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
|
||||
export const Component = () => {
|
||||
const { api } = useApi();
|
||||
const [ isCreateApiKeyPromptOpen, setIsCreateApiKeyPromptOpen ] = useState(false);
|
||||
const [ isConfirmDeleteOpen, setIsConfirmDeleteOpen ] = useState(false);
|
||||
const [ apiKeyToDelete, setApiKeyToDelete ] = useState('');
|
||||
const { data, isLoading } = useApiKeys();
|
||||
const keys = useMemo(() => (
|
||||
data?.Items || []
|
||||
), [ data ]);
|
||||
const revokeKey = useRevokeKey();
|
||||
const createKey = useCreateKey();
|
||||
const theme = useTheme();
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<AuthenticationInfo>[]>(() => [
|
||||
{
|
||||
@@ -49,8 +52,15 @@ export const Component = () => {
|
||||
}
|
||||
], []);
|
||||
|
||||
// NOTE: We need to provide a custom theme due to a MRT bug causing the initial theme to always be used
|
||||
// https://github.com/KevinVandy/material-react-table/issues/1429
|
||||
const mrtTheme = useMemo<Partial<MRT_Theme>>(() => ({
|
||||
baseBackgroundColor: theme.palette.background.paper
|
||||
}), [ theme ]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...DEFAULT_TABLE_OPTIONS,
|
||||
mrtTheme,
|
||||
|
||||
columns,
|
||||
data: keys,
|
||||
@@ -96,41 +106,72 @@ export const Component = () => {
|
||||
});
|
||||
|
||||
const onRevokeKey = useCallback((accessToken: string) => {
|
||||
if (!api) return;
|
||||
|
||||
confirm(globalize.translate('MessageConfirmRevokeApiKey'), globalize.translate('HeaderConfirmRevokeApiKey')).then(function () {
|
||||
revokeKey.mutate({
|
||||
key: accessToken
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('[apikeys] failed to show confirmation dialog', err);
|
||||
});
|
||||
}, [api, revokeKey]);
|
||||
setApiKeyToDelete(accessToken);
|
||||
setIsConfirmDeleteOpen(true);
|
||||
}, []);
|
||||
|
||||
const showNewKeyPopup = useCallback(() => {
|
||||
if (!api) return;
|
||||
setIsCreateApiKeyPromptOpen(true);
|
||||
}, []);
|
||||
|
||||
prompt({
|
||||
title: globalize.translate('HeaderNewApiKey'),
|
||||
label: globalize.translate('LabelAppName'),
|
||||
description: globalize.translate('LabelAppNameExample')
|
||||
}).then((value) => {
|
||||
createKey.mutate({
|
||||
app: value
|
||||
});
|
||||
}).catch(() => {
|
||||
// popup closed
|
||||
const onCreateApiKeyPromptClose = useCallback(() => {
|
||||
setIsCreateApiKeyPromptOpen(false);
|
||||
}, []);
|
||||
|
||||
const onConfirmDelete = useCallback(() => {
|
||||
revokeKey.mutate({
|
||||
key: apiKeyToDelete
|
||||
}, {
|
||||
onSettled: () => {
|
||||
setApiKeyToDelete('');
|
||||
setIsConfirmDeleteOpen(false);
|
||||
}
|
||||
});
|
||||
}, [api, createKey]);
|
||||
}, [ revokeKey, apiKeyToDelete ]);
|
||||
|
||||
const onConfirmDeleteCancel = useCallback(() => {
|
||||
setApiKeyToDelete('');
|
||||
setIsConfirmDeleteOpen(false);
|
||||
}, []);
|
||||
|
||||
const onConfirmCreate = useCallback((name: string) => {
|
||||
createKey.mutate({
|
||||
app: name
|
||||
}, {
|
||||
onSettled: () => {
|
||||
setIsCreateApiKeyPromptOpen(false);
|
||||
}
|
||||
});
|
||||
}, [ createKey ]);
|
||||
|
||||
return (
|
||||
<TablePage
|
||||
id='apiKeysPage'
|
||||
title={globalize.translate('HeaderApiKeys')}
|
||||
subtitle={globalize.translate('HeaderApiKeysHelp')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
table={table}
|
||||
/>
|
||||
<>
|
||||
<ConfirmDialog
|
||||
open={isConfirmDeleteOpen}
|
||||
title={globalize.translate('HeaderConfirmRevokeApiKey')}
|
||||
text={globalize.translate('MessageConfirmRevokeApiKey')}
|
||||
confirmButtonColor='error'
|
||||
confirmButtonText={globalize.translate('Delete')}
|
||||
onConfirm={onConfirmDelete}
|
||||
onCancel={onConfirmDeleteCancel}
|
||||
/>
|
||||
<InputDialog
|
||||
open={isCreateApiKeyPromptOpen}
|
||||
title={globalize.translate('HeaderNewApiKey')}
|
||||
label={globalize.translate('LabelAppName')}
|
||||
helperText={globalize.translate('LabelAppNameExample')}
|
||||
confirmButtonText={globalize.translate('Create')}
|
||||
onConfirm={onConfirmCreate}
|
||||
onClose={onCreateApiKeyPromptClose}
|
||||
/>
|
||||
<TablePage
|
||||
id='apiKeysPage'
|
||||
title={globalize.translate('HeaderApiKeys')}
|
||||
subtitle={globalize.translate('HeaderApiKeysHelp')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
table={table}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
108
src/apps/dashboard/routes/libraries/index.tsx
Normal file
108
src/apps/dashboard/routes/libraries/index.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { useVirtualFolders } from 'apps/dashboard/features/libraries/api/useVirtualFolders';
|
||||
import useLiveTasks from 'apps/dashboard/features/tasks/hooks/useLiveTasks';
|
||||
import { useStartTask } from 'apps/dashboard/features/tasks/api/useStartTask';
|
||||
import TaskProgress from 'apps/dashboard/features/tasks/components/TaskProgress';
|
||||
import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import LibraryCard from 'apps/dashboard/features/libraries/components/LibraryCard';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import MediaLibraryCreator from 'components/mediaLibraryCreator/mediaLibraryCreator';
|
||||
import getCollectionTypeOptions from 'apps/dashboard/features/libraries/utils/collectionTypeOptions';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import Add from '@mui/icons-material/Add';
|
||||
|
||||
export const Component = () => {
|
||||
const { data: virtualFolders, isPending: isVirtualFoldersPending } = useVirtualFolders();
|
||||
const startTask = useStartTask();
|
||||
const { data: tasks, isPending: isLiveTasksPending } = useLiveTasks({ isHidden: false });
|
||||
|
||||
const librariesTask = useMemo(() => (
|
||||
tasks?.find((value) => value.Key === 'RefreshLibrary')
|
||||
), [ tasks ]);
|
||||
|
||||
const showMediaLibraryCreator = useCallback(() => {
|
||||
const mediaLibraryCreator = new MediaLibraryCreator({
|
||||
collectionTypeOptions: getCollectionTypeOptions(),
|
||||
refresh: true
|
||||
}) as Promise<boolean>;
|
||||
|
||||
void mediaLibraryCreator.then((hasChanges: boolean) => {
|
||||
if (hasChanges) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['VirtualFolders']
|
||||
});
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onScanLibraries = useCallback(() => {
|
||||
if (librariesTask?.Id) {
|
||||
startTask.mutate({
|
||||
taskId: librariesTask.Id
|
||||
});
|
||||
}
|
||||
}, [ startTask, librariesTask ]);
|
||||
|
||||
if (isVirtualFoldersPending || isLiveTasksPending) return <Loading />;
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='mediaLibraryPage'
|
||||
title={globalize.translate('HeaderLibraries')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
<Stack spacing={3} mt={2}>
|
||||
<Stack direction='row' alignItems={'center'} spacing={1.5}>
|
||||
<Button
|
||||
startIcon={<Add />}
|
||||
onClick={showMediaLibraryCreator}
|
||||
>
|
||||
{globalize.translate('ButtonAddMediaLibrary')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onScanLibraries}
|
||||
startIcon={<RefreshIcon />}
|
||||
loading={librariesTask && librariesTask.State !== TaskState.Idle}
|
||||
loadingPosition='start'
|
||||
variant='outlined'
|
||||
>
|
||||
{globalize.translate('ButtonScanAllLibraries')}
|
||||
</Button>
|
||||
{(librariesTask && librariesTask.State == TaskState.Running) && (
|
||||
<TaskProgress task={librariesTask} />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
{virtualFolders?.map(virtualFolder => (
|
||||
<Grid
|
||||
key={virtualFolder?.ItemId}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={3}
|
||||
lg={2.4}
|
||||
>
|
||||
<LibraryCard
|
||||
virtualFolder={virtualFolder}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'LibrariesPage';
|
||||
179
src/apps/dashboard/routes/livetv/index.tsx
Normal file
179
src/apps/dashboard/routes/livetv/index.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Page from 'components/Page';
|
||||
import { useNamedConfiguration } from 'hooks/useNamedConfiguration';
|
||||
import type { LiveTvOptions } from '@jellyfin/sdk/lib/generated-client/models/live-tv-options';
|
||||
import globalize from 'lib/globalize';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import TunerDeviceCard from 'apps/dashboard/features/livetv/components/TunerDeviceCard';
|
||||
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 { 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';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
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';
|
||||
|
||||
export const Component = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
data: config,
|
||||
isPending: isConfigPending,
|
||||
isError: isConfigError
|
||||
} = useNamedConfiguration<LiveTvOptions>(CONFIG_KEY);
|
||||
const {
|
||||
data: tasks,
|
||||
isPending: isTasksPending,
|
||||
isError: isTasksError
|
||||
} = useLiveTasks({ isHidden: false });
|
||||
const providerButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [ anchorEl, setAnchorEl ] = useState<HTMLButtonElement | null>(null);
|
||||
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
|
||||
const startTask = useStartTask();
|
||||
|
||||
const navigateToSchedulesDirect = useCallback(() => {
|
||||
navigate('/dashboard/livetv/guide?type=schedulesdirect');
|
||||
}, [ navigate ]);
|
||||
|
||||
const navigateToXMLTV = useCallback(() => {
|
||||
navigate('/dashboard/livetv/guide?type=xmltv');
|
||||
}, [ navigate ]);
|
||||
|
||||
const showProviderMenu = useCallback(() => {
|
||||
setAnchorEl(providerButtonRef.current);
|
||||
setIsMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const onMenuClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const refreshGuideTask = useMemo(() => (
|
||||
tasks?.find((value) => value.Key === 'RefreshGuide')
|
||||
), [ tasks ]);
|
||||
|
||||
const refreshGuideData = useCallback(() => {
|
||||
if (refreshGuideTask?.Id) {
|
||||
startTask.mutate({
|
||||
taskId: refreshGuideTask.Id
|
||||
});
|
||||
}
|
||||
}, [ startTask, refreshGuideTask ]);
|
||||
|
||||
if (isConfigPending || isTasksPending) return <Loading />;
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='liveTvStatusPage'
|
||||
title={globalize.translate('LiveTV')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
{(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>
|
||||
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
{config.TunerHosts?.map(tunerHost => (
|
||||
<Grid
|
||||
key={tunerHost.Id}
|
||||
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>
|
||||
|
||||
<Stack sx={{ alignSelf: 'flex-start' }} spacing={2}>
|
||||
<Stack direction='row' spacing={1.5}>
|
||||
<Button
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
startIcon={<AddIcon />}
|
||||
onClick={showProviderMenu}
|
||||
ref={providerButtonRef}
|
||||
>
|
||||
{globalize.translate('ButtonAddProvider')}
|
||||
</Button>
|
||||
<Button
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
startIcon={<RefreshIcon />}
|
||||
variant='outlined'
|
||||
onClick={refreshGuideData}
|
||||
loading={refreshGuideTask && refreshGuideTask.State === TaskState.Running}
|
||||
loadingPosition='start'
|
||||
>
|
||||
{globalize.translate('ButtonRefreshGuideData')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{(refreshGuideTask && refreshGuideTask.State === TaskState.Running) && (
|
||||
<TaskProgress task={refreshGuideTask} />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'LiveTvPage';
|
||||
@@ -1,6 +1,6 @@
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Page from 'components/Page';
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useServerLog } from 'apps/dashboard/features/logs/api/useServerLog';
|
||||
import Alert from '@mui/material/Alert';
|
||||
@@ -13,8 +13,8 @@ import Typography from '@mui/material/Typography';
|
||||
import ContentCopy from '@mui/icons-material/ContentCopy';
|
||||
import FileDownload from '@mui/icons-material/FileDownload';
|
||||
import globalize from 'lib/globalize';
|
||||
import toast from 'components/toast/toast';
|
||||
import { copy } from 'scripts/clipboard';
|
||||
import Toast from 'apps/dashboard/components/Toast';
|
||||
|
||||
export const Component = () => {
|
||||
const { file: fileName } = useParams();
|
||||
@@ -24,13 +24,18 @@ export const Component = () => {
|
||||
data: log,
|
||||
refetch
|
||||
} = useServerLog(fileName ?? '');
|
||||
const [ isCopiedToastOpen, setIsCopiedToastOpen ] = useState(false);
|
||||
|
||||
const retry = useCallback(() => refetch(), [refetch]);
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
setIsCopiedToastOpen(false);
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (log) {
|
||||
await copy(log);
|
||||
toast({ text: globalize.translate('CopyLogSuccess') });
|
||||
setIsCopiedToastOpen(true);
|
||||
}
|
||||
}, [log]);
|
||||
|
||||
@@ -52,7 +57,12 @@ export const Component = () => {
|
||||
title={fileName}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Container className='content-primary'>
|
||||
<Toast
|
||||
open={isCopiedToastOpen}
|
||||
onClose={handleToastClose}
|
||||
message={globalize.translate('CopyLogSuccess')}
|
||||
/>
|
||||
<Container className='content-primary' maxWidth={false}>
|
||||
<Box>
|
||||
<Typography variant='h1'>{fileName}</Typography>
|
||||
|
||||
@@ -96,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>
|
||||
</>
|
||||
|
||||
@@ -471,19 +471,21 @@ export const Component = () => {
|
||||
|
||||
{(hardwareAccelType === 'none' || isHwaSelected) && (
|
||||
<>
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('EnableTonemapping')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='EnableTonemapping'
|
||||
checked={config.EnableTonemapping}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate(isHwaSelected ? 'AllowTonemappingHelp' : 'AllowTonemappingSoftwareHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
{isHwaSelected && (
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('EnableTonemapping')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='EnableTonemapping'
|
||||
checked={config.EnableTonemapping}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('AllowTonemappingHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
name='TonemappingAlgorithm'
|
||||
|
||||
@@ -98,164 +98,166 @@ export const Component = () => {
|
||||
className='type-interior mainAnimatedPage'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
{isError ? (
|
||||
<Alert
|
||||
severity='error'
|
||||
sx={{ marginBottom: 2 }}
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction='row'
|
||||
sx={{
|
||||
flexWrap: {
|
||||
xs: 'wrap',
|
||||
sm: 'nowrap'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{globalize.translate('PluginsLoadError')}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction='row'
|
||||
<Typography
|
||||
variant='h1'
|
||||
component='span'
|
||||
sx={{
|
||||
flexWrap: {
|
||||
xs: 'wrap',
|
||||
sm: 'nowrap'
|
||||
flexGrow: 1,
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
>
|
||||
{globalize.translate('TabPlugins')}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
component={Link}
|
||||
to='/dashboard/plugins/repositories'
|
||||
variant='outlined'
|
||||
sx={{
|
||||
marginLeft: 2
|
||||
}}
|
||||
>
|
||||
{globalize.translate('ManageRepositories')}
|
||||
</Button>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'end',
|
||||
marginTop: {
|
||||
xs: 2,
|
||||
sm: 0
|
||||
},
|
||||
marginLeft: {
|
||||
xs: 0,
|
||||
sm: 2
|
||||
},
|
||||
width: {
|
||||
xs: '100%',
|
||||
sm: 'auto'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant='h1'
|
||||
component='span'
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
>
|
||||
{globalize.translate('TabPlugins')}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
component={Link}
|
||||
to='/dashboard/plugins/repositories'
|
||||
variant='outlined'
|
||||
sx={{
|
||||
marginLeft: 2
|
||||
}}
|
||||
>
|
||||
{globalize.translate('ManageRepositories')}
|
||||
</Button>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'end',
|
||||
marginTop: {
|
||||
xs: 2,
|
||||
sm: 0
|
||||
},
|
||||
marginLeft: {
|
||||
xs: 0,
|
||||
sm: 2
|
||||
},
|
||||
width: {
|
||||
xs: '100%',
|
||||
sm: 'auto'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SearchInput
|
||||
label={globalize.translate('Search')}
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Stack
|
||||
direction='row'
|
||||
spacing={1}
|
||||
sx={{
|
||||
marginLeft: '-1rem',
|
||||
marginRight: '-1rem',
|
||||
paddingLeft: '1rem',
|
||||
paddingRight: '1rem',
|
||||
paddingBottom: {
|
||||
xs: 1,
|
||||
md: 0.5
|
||||
},
|
||||
overflowX: 'auto'
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
color={status === PluginStatusOption.All ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.All)}
|
||||
label={globalize.translate('All')}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
color={status === PluginStatusOption.Available ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.Available)}
|
||||
label={globalize.translate('LabelAvailable')}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
color={status === PluginStatusOption.Installed ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.Installed)}
|
||||
label={globalize.translate('LabelInstalled')}
|
||||
/>
|
||||
|
||||
<Divider orientation='vertical' flexItem />
|
||||
|
||||
<Chip
|
||||
color={!category ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory('')}
|
||||
label={globalize.translate('All')}
|
||||
/>
|
||||
|
||||
{Object.values(PluginCategory).map(c => (
|
||||
<Chip
|
||||
key={c}
|
||||
color={category === c.toLowerCase() ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory(c.toLowerCase())}
|
||||
label={globalize.translate(CATEGORY_LABELS[c as PluginCategory])}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Divider />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{filteredPlugins.length > 0 ? (
|
||||
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
<Grid container spacing={2}>
|
||||
{filteredPlugins.map(plugin => (
|
||||
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
<Grid
|
||||
key={plugin.id}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={4}
|
||||
lg={3}
|
||||
xl={2}
|
||||
>
|
||||
<PluginCard
|
||||
plugin={plugin}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<NoPluginResults
|
||||
isFiltered={!!category || status !== PluginStatusOption.All}
|
||||
onViewAll={onViewAll}
|
||||
query={searchQuery}
|
||||
/>
|
||||
)}
|
||||
<SearchInput
|
||||
label={globalize.translate('Search')}
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{isError ? (
|
||||
<Alert
|
||||
severity='error'
|
||||
sx={{ marginBottom: 2 }}
|
||||
>
|
||||
{globalize.translate('PluginsLoadError')}
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Box>
|
||||
<Stack
|
||||
direction='row'
|
||||
spacing={1}
|
||||
sx={{
|
||||
marginLeft: '-1rem',
|
||||
marginRight: '-1rem',
|
||||
paddingLeft: '1rem',
|
||||
paddingRight: '1rem',
|
||||
paddingBottom: {
|
||||
xs: 1,
|
||||
md: 0.5
|
||||
},
|
||||
overflowX: 'auto'
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
color={status === PluginStatusOption.All ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.All)}
|
||||
label={globalize.translate('All')}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
color={status === PluginStatusOption.Available ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.Available)}
|
||||
label={globalize.translate('LabelAvailable')}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
color={status === PluginStatusOption.Installed ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.Installed)}
|
||||
label={globalize.translate('LabelInstalled')}
|
||||
/>
|
||||
|
||||
<Divider orientation='vertical' flexItem />
|
||||
|
||||
<Chip
|
||||
color={!category ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory('')}
|
||||
label={globalize.translate('All')}
|
||||
/>
|
||||
|
||||
{Object.values(PluginCategory).map(c => (
|
||||
<Chip
|
||||
key={c}
|
||||
color={category === c.toLowerCase() ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory(c.toLowerCase())}
|
||||
label={globalize.translate(CATEGORY_LABELS[c as PluginCategory])}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Divider />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{filteredPlugins.length > 0 ? (
|
||||
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
<Grid container spacing={2}>
|
||||
{filteredPlugins.map(plugin => (
|
||||
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
<Grid
|
||||
key={plugin.id}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={4}
|
||||
lg={3}
|
||||
xl={2}
|
||||
>
|
||||
<PluginCard
|
||||
plugin={plugin}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<NoPluginResults
|
||||
isFiltered={!!category || status !== PluginStatusOption.All}
|
||||
onViewAll={onViewAll}
|
||||
query={searchQuery}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -7,10 +7,11 @@ import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import RemoveCircleIcon from '@mui/icons-material/RemoveCircle';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import { MRT_ColumnDef, MRT_Table, useMaterialReactTable } from 'material-react-table';
|
||||
import { type MRT_ColumnDef, MRT_Table, type MRT_Theme, useMaterialReactTable } from 'material-react-table';
|
||||
import type { TaskTriggerInfo } from '@jellyfin/sdk/lib/generated-client/models/task-trigger-info';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import { useTask } from 'apps/dashboard/features/tasks/api/useTask';
|
||||
@@ -26,6 +27,7 @@ export const Component = () => {
|
||||
const [ isAddTriggerDialogOpen, setIsAddTriggerDialogOpen ] = useState(false);
|
||||
const [ isRemoveConfirmOpen, setIsRemoveConfirmOpen ] = useState(false);
|
||||
const [ pendingDeleteTrigger, setPendingDeleteTrigger ] = useState<TaskTriggerInfo | null>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
const onCloseRemoveConfirmDialog = useCallback(() => {
|
||||
setPendingDeleteTrigger(null);
|
||||
@@ -80,7 +82,15 @@ export const Component = () => {
|
||||
}
|
||||
], []);
|
||||
|
||||
// NOTE: We need to provide a custom theme due to a MRT bug causing the initial theme to always be used
|
||||
// https://github.com/KevinVandy/material-react-table/issues/1429
|
||||
const mrtTheme = useMemo<Partial<MRT_Theme>>(() => ({
|
||||
baseBackgroundColor: theme.palette.background.paper
|
||||
}), [ theme ]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
mrtTheme,
|
||||
|
||||
columns,
|
||||
data: task?.Triggers || [],
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import toast from '../../../../components/toast/toast';
|
||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||
import Button from '../../../../elements/emby-button/Button';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import AccessContainer from '../../../../components/dashboard/users/AccessContainer';
|
||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import Page from '../../../../components/Page';
|
||||
import Toast from 'apps/dashboard/components/Toast';
|
||||
|
||||
type ItemsArr = {
|
||||
Name?: string | null;
|
||||
@@ -23,6 +23,7 @@ type ItemsArr = {
|
||||
const UserLibraryAccess = () => {
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const [ isSettingsSavedToastOpen, setIsSettingsSavedToastOpen ] = useState(false);
|
||||
const [ userName, setUserName ] = useState('');
|
||||
const [channelsItems, setChannelsItems] = useState<ItemsArr[]>([]);
|
||||
const [mediaFoldersItems, setMediaFoldersItems] = useState<ItemsArr[]>([]);
|
||||
@@ -31,6 +32,10 @@ const UserLibraryAccess = () => {
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
setIsSettingsSavedToastOpen(false);
|
||||
}, []);
|
||||
|
||||
const triggerChange = (select: HTMLInputElement) => {
|
||||
const evt = new Event('change', { bubbles: false, cancelable: true });
|
||||
select.dispatchEvent(evt);
|
||||
@@ -220,7 +225,7 @@ const UserLibraryAccess = () => {
|
||||
|
||||
const onSaveComplete = () => {
|
||||
loading.hide();
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
setIsSettingsSavedToastOpen(true);
|
||||
};
|
||||
|
||||
(page.querySelector('.chkEnableAllDevices') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
||||
@@ -243,6 +248,11 @@ const UserLibraryAccess = () => {
|
||||
id='userLibraryAccessPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Toast
|
||||
open={isSettingsSavedToastOpen}
|
||||
onClose={handleToastClose}
|
||||
message={globalize.translate('SettingsSaved')}
|
||||
/>
|
||||
<div ref={element} className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
|
||||
@@ -4,13 +4,13 @@ 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 toast from '../../../../components/toast/toast';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import Input from '../../../../elements/emby-input/Input';
|
||||
import Button from '../../../../elements/emby-button/Button';
|
||||
import AccessContainer from '../../../../components/dashboard/users/AccessContainer';
|
||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import Page from '../../../../components/Page';
|
||||
import Toast from 'apps/dashboard/components/Toast';
|
||||
|
||||
type UserInput = {
|
||||
Name?: string;
|
||||
@@ -25,8 +25,13 @@ type ItemsArr = {
|
||||
const UserNew = () => {
|
||||
const [ channelsItems, setChannelsItems ] = useState<ItemsArr[]>([]);
|
||||
const [ mediaFoldersItems, setMediaFoldersItems ] = useState<ItemsArr[]>([]);
|
||||
const [ isErrorToastOpen, setIsErrorToastOpen ] = useState(false);
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
setIsErrorToastOpen(false);
|
||||
}, []);
|
||||
|
||||
const getItemsResult = (items: BaseItemDto[]) => {
|
||||
return items.map(item =>
|
||||
({
|
||||
@@ -150,7 +155,7 @@ const UserNew = () => {
|
||||
console.error('[usernew] failed to update user policy', err);
|
||||
});
|
||||
}, function () {
|
||||
toast(globalize.translate('ErrorDefault'));
|
||||
setIsErrorToastOpen(true);
|
||||
loading.hide();
|
||||
});
|
||||
};
|
||||
@@ -185,6 +190,11 @@ const UserNew = () => {
|
||||
id='newUserPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Toast
|
||||
open={isErrorToastOpen}
|
||||
onClose={handleToastClose}
|
||||
message={globalize.translate('ErrorDefault')}
|
||||
/>
|
||||
<div ref={element} className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
|
||||
import Dashboard from '../../../../utils/dashboard';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
@@ -14,6 +14,8 @@ 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 Toast from 'apps/dashboard/components/Toast';
|
||||
|
||||
type MenuEntry = {
|
||||
name?: string;
|
||||
@@ -22,10 +24,16 @@ type MenuEntry = {
|
||||
};
|
||||
|
||||
const UserProfiles = () => {
|
||||
const location = useLocation();
|
||||
const [ isSettingsSavedToastOpen, setIsSettingsSavedToastOpen ] = useState(false);
|
||||
const [ users, setUsers ] = useState<UserDto[]>([]);
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
setIsSettingsSavedToastOpen(false);
|
||||
}, []);
|
||||
|
||||
const loadData = () => {
|
||||
loading.show();
|
||||
window.ApiClient.getUsers().then(function (result) {
|
||||
@@ -39,6 +47,11 @@ const UserProfiles = () => {
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (location.state?.openSavedToast) {
|
||||
setIsSettingsSavedToastOpen(true);
|
||||
window.history.replaceState({}, '');
|
||||
}
|
||||
|
||||
if (!page) {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
@@ -161,6 +174,11 @@ const UserProfiles = () => {
|
||||
className='mainAnimatedPage type-interior userProfilesPage fullWidthContent'
|
||||
title={globalize.translate('HeaderUsers')}
|
||||
>
|
||||
<Toast
|
||||
open={isSettingsSavedToastOpen}
|
||||
onClose={handleToastClose}
|
||||
message={globalize.translate('SettingsSaved')}
|
||||
/>
|
||||
<div ref={element} className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
|
||||
@@ -12,12 +12,12 @@ import Button from '../../../../elements/emby-button/Button';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import toast from '../../../../components/toast/toast';
|
||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import SelectElement from '../../../../elements/SelectElement';
|
||||
import Page from '../../../../components/Page';
|
||||
import prompt from '../../../../components/prompt/prompt';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import Toast from 'apps/dashboard/components/Toast';
|
||||
|
||||
type NamedItem = {
|
||||
name: string;
|
||||
@@ -30,6 +30,7 @@ type UnratedNamedItem = NamedItem & {
|
||||
|
||||
function handleSaveUser(
|
||||
page: HTMLDivElement,
|
||||
parentalRatingsRef: React.MutableRefObject<ParentalRating[]>,
|
||||
getSchedulesFromPage: () => AccessSchedule[],
|
||||
getAllowedTagsFromPage: () => string[],
|
||||
getBlockedTagsFromPage: () => string[],
|
||||
@@ -42,8 +43,12 @@ function handleSaveUser(
|
||||
throw new Error('Unexpected null user id or policy');
|
||||
}
|
||||
|
||||
const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
|
||||
userPolicy.MaxParentalRating = Number.isNaN(parentalRating) ? null : parentalRating;
|
||||
const parentalRatingIndex = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
|
||||
const parentalRating = parentalRatingsRef.current[parentalRatingIndex] as ParentalRating;
|
||||
const score = parentalRating?.RatingScore?.score;
|
||||
const subScore = parentalRating?.RatingScore?.subScore;
|
||||
userPolicy.MaxParentalRating = Number.isNaN(score) ? null : score;
|
||||
userPolicy.MaxParentalSubRating = Number.isNaN(subScore) ? null : subScore;
|
||||
userPolicy.BlockUnratedItems = Array.prototype.filter
|
||||
.call(page.querySelectorAll('.chkUnratedItem'), i => i.checked)
|
||||
.map(i => i.getAttribute('data-itemtype'));
|
||||
@@ -69,33 +74,14 @@ const UserParentalControl = () => {
|
||||
const [ accessSchedules, setAccessSchedules ] = useState<AccessSchedule[]>([]);
|
||||
const [ allowedTags, setAllowedTags ] = useState<string[]>([]);
|
||||
const [ blockedTags, setBlockedTags ] = useState<string[]>([]);
|
||||
const [ isSettingsSavedToastOpen, setIsSettingsSavedToastOpen ] = useState(false);
|
||||
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
const parentalRatingsRef = useRef<ParentalRating[]>([]);
|
||||
|
||||
const populateRatings = useCallback((allParentalRatings: ParentalRating[]) => {
|
||||
let rating;
|
||||
const ratings: ParentalRating[] = [];
|
||||
|
||||
for (let i = 0, length = allParentalRatings.length; i < length; i++) {
|
||||
rating = allParentalRatings[i];
|
||||
|
||||
if (ratings.length) {
|
||||
const lastRating = ratings[ratings.length - 1];
|
||||
|
||||
if (lastRating.Value === rating.Value) {
|
||||
lastRating.Name += '/' + rating.Name;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
ratings.push({
|
||||
Name: rating.Name,
|
||||
Value: rating.Value
|
||||
});
|
||||
}
|
||||
|
||||
setParentalRatings(ratings);
|
||||
const handleToastClose = useCallback(() => {
|
||||
setIsSettingsSavedToastOpen(false);
|
||||
}, []);
|
||||
|
||||
const loadUnratedItems = useCallback((user: UserDto) => {
|
||||
@@ -161,16 +147,52 @@ const UserParentalControl = () => {
|
||||
|
||||
setAllowedTags(user.Policy?.AllowedTags || []);
|
||||
setBlockedTags(user.Policy?.BlockedTags || []);
|
||||
populateRatings(allParentalRatings);
|
||||
|
||||
let ratingValue = '';
|
||||
allParentalRatings.forEach(rating => {
|
||||
if (rating.Value != null && user.Policy?.MaxParentalRating != null && user.Policy.MaxParentalRating >= rating.Value) {
|
||||
ratingValue = `${rating.Value}`;
|
||||
// Build the grouped ratings array
|
||||
const ratings: ParentalRating[] = [];
|
||||
for (let i = 0, length = allParentalRatings.length; i < length; i++) {
|
||||
const rating = allParentalRatings[i];
|
||||
|
||||
if (ratings.length) {
|
||||
const lastRating = ratings[ratings.length - 1];
|
||||
|
||||
if (lastRating.RatingScore?.score === rating.RatingScore?.score && lastRating.RatingScore?.subScore == rating.RatingScore?.subScore) {
|
||||
lastRating.Name += '/' + rating.Name;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setMaxParentalRating(ratingValue);
|
||||
ratings.push(rating);
|
||||
}
|
||||
setParentalRatings(ratings);
|
||||
parentalRatingsRef.current = ratings;
|
||||
|
||||
// Find matching rating - first try exact match with score and subscore
|
||||
let ratingIndex = '';
|
||||
const userMaxRating = user.Policy?.MaxParentalRating;
|
||||
const userMaxSubRating = user.Policy?.MaxParentalSubRating;
|
||||
|
||||
if (userMaxRating != null) {
|
||||
// First try to find exact match with both score and subscore
|
||||
ratings.forEach((rating, index) => {
|
||||
if (rating.RatingScore?.score === userMaxRating
|
||||
&& rating.RatingScore?.subScore === userMaxSubRating) {
|
||||
ratingIndex = `${index}`;
|
||||
}
|
||||
});
|
||||
|
||||
// If no exact match found, fallback to score-only match
|
||||
if (!ratingIndex) {
|
||||
ratings.forEach((rating, index) => {
|
||||
if (rating.RatingScore?.score != null
|
||||
&& rating.RatingScore.score <= userMaxRating) {
|
||||
ratingIndex = `${index}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setMaxParentalRating(ratingIndex);
|
||||
|
||||
if (user.Policy?.IsAdministrator) {
|
||||
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.add('hide');
|
||||
@@ -179,7 +201,7 @@ const UserParentalControl = () => {
|
||||
}
|
||||
setAccessSchedules(user.Policy?.AccessSchedules || []);
|
||||
loading.hide();
|
||||
}, [libraryMenu, setAllowedTags, setBlockedTags, loadUnratedItems, populateRatings]);
|
||||
}, [libraryMenu, setAllowedTags, setBlockedTags, loadUnratedItems]);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
if (!userId) {
|
||||
@@ -283,10 +305,10 @@ const UserParentalControl = () => {
|
||||
|
||||
const onSaveComplete = () => {
|
||||
loading.hide();
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
setIsSettingsSavedToastOpen(true);
|
||||
};
|
||||
|
||||
const saveUser = handleSaveUser(page, getSchedulesFromPage, getAllowedTagsFromPage, getBlockedTagsFromPage, onSaveComplete);
|
||||
const saveUser = handleSaveUser(page, parentalRatingsRef, getSchedulesFromPage, getAllowedTagsFromPage, getBlockedTagsFromPage, onSaveComplete);
|
||||
|
||||
const onSubmit = (e: Event) => {
|
||||
if (!userId) {
|
||||
@@ -342,11 +364,11 @@ const UserParentalControl = () => {
|
||||
const optionMaxParentalRating = () => {
|
||||
let content = '';
|
||||
content += '<option value=\'\'></option>';
|
||||
for (const rating of parentalRatings) {
|
||||
if (rating.Value != null) {
|
||||
content += `<option value='${rating.Value}'>${escapeHTML(rating.Name)}</option>`;
|
||||
parentalRatings.forEach((rating, index) => {
|
||||
if (rating.RatingScore != null) {
|
||||
content += `<option value='${index}'>${escapeHTML(rating.Name)}</option>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
return content;
|
||||
};
|
||||
|
||||
@@ -370,6 +392,11 @@ const UserParentalControl = () => {
|
||||
id='userParentalControlPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Toast
|
||||
open={isSettingsSavedToastOpen}
|
||||
onClose={handleToastClose}
|
||||
message={globalize.translate('SettingsSaved')}
|
||||
/>
|
||||
<div ref={element} className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { BaseItemDto, NameIdPair, SyncPlayUserAccessType, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import escapeHTML from 'escape-html';
|
||||
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import Dashboard from '../../../../utils/dashboard';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import Button from '../../../../elements/emby-button/Button';
|
||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
@@ -12,7 +11,6 @@ import Input from '../../../../elements/emby-input/Input';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import toast from '../../../../components/toast/toast';
|
||||
import SelectElement from '../../../../elements/SelectElement';
|
||||
import Page from '../../../../components/Page';
|
||||
|
||||
@@ -25,16 +23,8 @@ const getCheckedElementDataIds = (elements: NodeListOf<Element>) => (
|
||||
.map(e => e.getAttribute('data-id'))
|
||||
);
|
||||
|
||||
function onSaveComplete() {
|
||||
Dashboard.navigate('/dashboard/users')
|
||||
.catch(err => {
|
||||
console.error('[useredit] failed to navigate to user profile', err);
|
||||
});
|
||||
loading.hide();
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
}
|
||||
|
||||
const UserEdit = () => {
|
||||
const navigate = useNavigate();
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const [ userDto, setUserDto ] = useState<UserDto>();
|
||||
@@ -228,7 +218,10 @@ const UserEdit = () => {
|
||||
window.ApiClient.updateUser(user).then(() => (
|
||||
window.ApiClient.updateUserPolicy(user.Id || '', user.Policy || { PasswordResetProviderId: '', AuthenticationProviderId: '' })
|
||||
)).then(() => {
|
||||
onSaveComplete();
|
||||
navigate('/dashboard/users', {
|
||||
state: { openSavedToast: true }
|
||||
});
|
||||
loading.hide();
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to update user', err);
|
||||
});
|
||||
|
||||
@@ -27,7 +27,18 @@ $mui-bp-xl: 1536px;
|
||||
padding-top: 3.25rem !important;
|
||||
}
|
||||
|
||||
// Fix backdrop position on mobile item details page
|
||||
.layout-mobile .itemBackdrop {
|
||||
margin-top: 0 !important;
|
||||
.layout-mobile {
|
||||
.itemBackdrop {
|
||||
// Fix backdrop position on mobile item details page
|
||||
margin-top: 0 !important;
|
||||
|
||||
// Add a subtle gradient over the backdrop to ensure the app bar buttons are visible
|
||||
&::before {
|
||||
display: block;
|
||||
content: "";
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: linear-gradient(180deg, rgba(32, 32, 32, 0.6) 0%, rgba(32, 32, 32, 0.2) 4rem, rgba(0, 0, 0, 0) 50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Cast from '@mui/icons-material/Cast';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import type {} from '@mui/material/themeCssVarsAugmentation';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
|
||||
import { playbackManager } from 'components/playback/playbackmanager';
|
||||
@@ -15,7 +15,6 @@ import RemotePlayMenu, { ID } from './menus/RemotePlayMenu';
|
||||
import RemotePlayActiveMenu, { ID as ACTIVE_ID } from './menus/RemotePlayActiveMenu';
|
||||
|
||||
const RemotePlayButton = () => {
|
||||
const theme = useTheme();
|
||||
const [ playerInfo, setPlayerInfo ] = useState(playbackManager.getPlayerInfo());
|
||||
|
||||
const updatePlayerInfo = useCallback(() => {
|
||||
@@ -70,9 +69,10 @@ const RemotePlayButton = () => {
|
||||
aria-haspopup='true'
|
||||
onClick={onRemotePlayActiveButtonClick}
|
||||
color='inherit'
|
||||
sx={{
|
||||
color: theme.palette.primary.main
|
||||
}}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
sx={(theme) => ({
|
||||
color: theme.vars.palette.primary.main
|
||||
})}
|
||||
>
|
||||
{playerInfo.deviceName || playerInfo.name}
|
||||
</Button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import Stack from '@mui/material/Stack';
|
||||
import React, { type FC } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { appRouter, PUBLIC_PATHS } from 'components/router/appRouter';
|
||||
import AppToolbar from 'components/toolbar/AppToolbar';
|
||||
import ServerButton from 'components/toolbar/ServerButton';
|
||||
|
||||
@@ -16,14 +17,6 @@ interface AppToolbarProps {
|
||||
onDrawerButtonClick: (event: React.MouseEvent<HTMLElement>) => void
|
||||
}
|
||||
|
||||
const PUBLIC_PATHS = [
|
||||
'/addserver',
|
||||
'/selectserver',
|
||||
'/login',
|
||||
'/forgotpassword',
|
||||
'/forgotpasswordpin'
|
||||
];
|
||||
|
||||
const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
|
||||
isDrawerAvailable,
|
||||
isDrawerOpen,
|
||||
@@ -34,6 +27,10 @@ const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
|
||||
// The video osd does not show the standard toolbar
|
||||
if (location.pathname === '/video') return null;
|
||||
|
||||
// Only show the back button in apps when appropriate
|
||||
const isBackButtonAvailable = window.NativeShell && appRouter.canGoBack(location.pathname);
|
||||
|
||||
// Check if the current path is a public path to hide user content
|
||||
const isPublicPath = PUBLIC_PATHS.includes(location.pathname);
|
||||
|
||||
return (
|
||||
@@ -48,6 +45,7 @@ const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
|
||||
isDrawerAvailable={isDrawerAvailable}
|
||||
isDrawerOpen={isDrawerOpen}
|
||||
onDrawerButtonClick={onDrawerButtonClick}
|
||||
isBackButtonAvailable={isBackButtonAvailable}
|
||||
isUserMenuAvailable={!isPublicPath}
|
||||
>
|
||||
{!isDrawerAvailable && (
|
||||
|
||||
@@ -221,9 +221,7 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||
const hasFilters = Object.values(libraryViewSettings.Filters ?? {}).some(
|
||||
(filter) => !!filter
|
||||
);
|
||||
const hasSortName = libraryViewSettings.SortBy.includes(
|
||||
ItemSortBy.SortName
|
||||
);
|
||||
const hasSortName = libraryViewSettings.SortBy !== ItemSortBy.Random;
|
||||
|
||||
const itemsContainerClass = classNames(
|
||||
'centered padded-left padded-right padded-right-withalphapicker',
|
||||
|
||||
@@ -22,6 +22,14 @@ type SortOption = {
|
||||
|
||||
type SortOptionsMapping = Record<string, SortOption[]>;
|
||||
|
||||
const collectionMovieOptions: SortOption[] = [
|
||||
{ label: 'Name', value: ItemSortBy.SortName },
|
||||
{ label: 'OptionCommunityRating', value: ItemSortBy.CommunityRating },
|
||||
{ label: 'OptionDateAdded', value: ItemSortBy.DateCreated },
|
||||
{ label: 'OptionParentalRating', value: ItemSortBy.OfficialRating },
|
||||
{ label: 'OptionReleaseDate', value: ItemSortBy.PremiereDate }
|
||||
];
|
||||
|
||||
const movieOrFavoriteOptions = [
|
||||
{ label: 'Name', value: ItemSortBy.SortName },
|
||||
{ label: 'OptionRandom', value: ItemSortBy.Random },
|
||||
@@ -43,6 +51,7 @@ const photosOrPhotoAlbumsOptions = [
|
||||
|
||||
const sortOptionsMapping: SortOptionsMapping = {
|
||||
[LibraryTab.Movies]: movieOrFavoriteOptions,
|
||||
[LibraryTab.Collections]: collectionMovieOptions,
|
||||
[LibraryTab.Favorites]: movieOrFavoriteOptions,
|
||||
[LibraryTab.Series]: [
|
||||
{ label: 'Name', value: ItemSortBy.SortName },
|
||||
|
||||
@@ -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,7 @@ async function loadDisplaySettings({
|
||||
maxDaysForNextUp: settings.maxDaysForNextUp(),
|
||||
screensaver: settings.screensaver() || 'none',
|
||||
screensaverInterval: settings.backdropScreensaverInterval(),
|
||||
theme: settings.theme()
|
||||
theme: settings.theme() || defaultTheme?.id || FALLBACK_THEME_ID
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -125,7 +131,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);
|
||||
|
||||
@@ -5,7 +5,7 @@ import globalize from '../../../lib/globalize';
|
||||
import { clearBackdrop } from '../../../components/backdrop/backdrop';
|
||||
import layoutManager from '../../../components/layoutManager';
|
||||
import Page from '../../../components/Page';
|
||||
import { EventType } from 'types/eventType';
|
||||
import { EventType } from 'constants/eventType';
|
||||
import Events from 'utils/events';
|
||||
|
||||
import '../../../elements/emby-tabs/emby-tabs';
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MovieSuggestionsSectionsView } from 'types/sections';
|
||||
const moviesTabContent: LibraryTabContent = {
|
||||
viewType: LibraryTab.Movies,
|
||||
collectionType: CollectionType.Movies,
|
||||
isBtnPlayAllEnabled: true,
|
||||
isBtnShuffleEnabled: true,
|
||||
itemType: [BaseItemKind.Movie]
|
||||
};
|
||||
@@ -20,7 +21,6 @@ const collectionsTabContent: LibraryTabContent = {
|
||||
collectionType: CollectionType.Movies,
|
||||
isBtnFilterEnabled: false,
|
||||
isBtnNewCollectionEnabled: true,
|
||||
isAlphabetPickerEnabled: false,
|
||||
itemType: [BaseItemKind.BoxSet],
|
||||
noItemsMessage: 'MessageNoCollectionsAvailable'
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import RemotePlayButton from 'apps/experimental/components/AppToolbar/RemotePlay
|
||||
import SyncPlayButton from 'apps/experimental/components/AppToolbar/SyncPlayButton';
|
||||
import AppToolbar from 'components/toolbar/AppToolbar';
|
||||
import ViewManagerPage from 'components/viewManager/ViewManagerPage';
|
||||
import { EventType } from 'types/eventType';
|
||||
import { EventType } from 'constants/eventType';
|
||||
import Events, { type Event } from 'utils/events';
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Options specifying if the player's native subtitle (cue) element should be used, a custom element (div), or allow
|
||||
* Jellyfin to choose automatically based on known browser support. Some browsers do not properly apply CSS styling to
|
||||
* the native subtitle element.
|
||||
*/
|
||||
export const SubtitleStylingOption = {
|
||||
Auto: 'Auto',
|
||||
Custom: 'Custom',
|
||||
Native: 'Native'
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export type SubtitleStylingOption = typeof SubtitleStylingOption[keyof typeof SubtitleStylingOption];
|
||||
@@ -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() {
|
||||
|
||||
43
src/apps/stable/features/playback/utils/subtitleStyles.ts
Normal file
43
src/apps/stable/features/playback/utils/subtitleStyles.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { SubtitleStylingOption } from 'apps/stable/features/playback/constants/subtitleStylingOption';
|
||||
import browser from 'scripts/browser';
|
||||
import type { UserSettings } from 'scripts/settings/userSettings';
|
||||
|
||||
// TODO: This type override should be removed when userSettings are properly typed
|
||||
interface SubtitleAppearanceSettings {
|
||||
subtitleStyling: SubtitleStylingOption
|
||||
}
|
||||
|
||||
export function useCustomSubtitles(userSettings: UserSettings) {
|
||||
const subtitleAppearance = userSettings.getSubtitleAppearanceSettings() as SubtitleAppearanceSettings;
|
||||
switch (subtitleAppearance.subtitleStyling) {
|
||||
case SubtitleStylingOption.Native:
|
||||
return false;
|
||||
case SubtitleStylingOption.Custom:
|
||||
return true;
|
||||
default:
|
||||
// after a system update, ps4 isn't showing anything when creating a track element dynamically
|
||||
// going to have to do it ourselves
|
||||
if (browser.ps4) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tizen 5 doesn't support displaying secondary subtitles
|
||||
if ((browser.tizenVersion && browser.tizenVersion >= 5) || browser.web0s) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (browser.edge) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// font-size styling does not seem to work natively in firefox. Switching to custom subtitles element for firefox.
|
||||
if (browser.firefox) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// iOS/macOS global caption settings are causing huge font-size and margins
|
||||
if (browser.safari) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -354,7 +354,7 @@ function getVirtualFolderHtml(page, virtualFolder, index) {
|
||||
html += '</div>';
|
||||
} else if (virtualFolder.Locations.length && virtualFolder.Locations.length === 1) {
|
||||
html += "<div class='cardText cardText-secondary' dir='ltr' style='text-align:left;'>";
|
||||
html += virtualFolder.Locations[0];
|
||||
html += escapeHtml(virtualFolder.Locations[0]);
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += "<div class='cardText cardText-secondary'>";
|
||||
|
||||
1
src/assets/img/devices/firetv.svg
Normal file
1
src/assets/img/devices/firetv.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Amazon Fire TV</title><path d="M20.196 15.12c.265.337-.294 1.73-.542 2.353-.077.19.085.266.257.123 1.106-.926 1.39-2.867 1.166-3.149-.226-.277-2.16-.516-3.341.314-.183.127-.151.304.05.279.665-.08 2.147-.257 2.41.08m-.858.981c-2.064 1.523-5.056 2.333-7.632 2.333-3.611 0-6.862-1.334-9.322-3.555-.194-.176-.02-.414.21-.28 2.655 1.545 5.939 2.477 9.328 2.477 2.287 0 4.803-.476 7.115-1.458.348-.147.642.231.3.483m2.034-3.155a.388.388 0 0 1-.201-.04c-.041-.026-.087-.1-.133-.225l-1.734-4.355a1.79 1.79 0 0 0-.046-.117.266.266 0 0 1-.023-.108c0-.084.049-.128.146-.128h.58c.098 0 .165.014.205.04.04.026.082.102.127.226l1.344 3.823 1.343-3.823c.046-.124.089-.2.128-.226a.402.402 0 0 1 .205-.04h.54c.1 0 .148.044.148.128a.3.3 0 0 1-.025.108c-.016.04-.032.078-.044.117l-1.727 4.355c-.045.124-.09.199-.132.225a.388.388 0 0 1-.201.04zm-3.644.068c-.929 0-1.392-.463-1.392-1.392V8.739h-.706c-.13 0-.197-.066-.197-.196v-.246a.22.22 0 0 1 .045-.147c.03-.031.086-.055.171-.067l.717-.09.127-1.215c.013-.13.082-.196.207-.196h.41c.13 0 .196.066.196.196v1.196h1.276c.13 0 .195.065.195.197v.372c0 .13-.064.196-.195.196h-1.276v2.834c0 .243.055.411.162.51.108.098.293.147.555.147.124 0 .277-.016.46-.049.099-.02.164-.03.197-.03.052 0 .088.014.108.044.02.03.029.077.029.142v.266a.366.366 0 0 1-.04.19c-.026.043-.078.078-.157.103a3.018 3.018 0 0 1-.892.118m-4.665-2.976c.006-.052.011-.137.011-.255 0-.399-.094-.698-.28-.901-.186-.204-.46-.306-.818-.306-.412 0-.732.123-.962.369-.228.245-.36.61-.392 1.093zm-.942 3.07c-.803 0-1.411-.222-1.824-.667-.412-.444-.616-1.102-.616-1.972 0-.83.204-1.475.616-1.937.413-.46.988-.691 1.728-.691.62 0 1.098.176 1.432.524.332.351.5.846.5 1.487 0 .21-.017.422-.05.638-.014.077-.034.13-.064.156-.029.027-.077.04-.142.04h-3.08c.013.563.154.977.418 1.245.265.268.674.403 1.23.403.196 0 .385-.014.564-.04a5.04 5.04 0 0 0 .682-.166l.117-.035a.284.284 0 0 1 .09-.016c.085 0 .125.06.125.177v.276c0 .085-.012.144-.037.18a.441.441 0 0 1-.167.114 3.38 3.38 0 0 1-.701.205 4.236 4.236 0 0 1-.82.079m-5.424-.147c-.13 0-.195-.066-.195-.197v-4.58c0-.13.064-.195.195-.195h.432c.064 0 .116.012.153.039.036.025.06.076.072.146l.07.55c.176-.19.343-.34.499-.452a1.725 1.725 0 0 1 1.02-.323c.079 0 .158.003.235.01.112.014.168.072.168.176v.53c0 .117-.058.177-.178.177-.058 0-.114-.004-.17-.01a1.638 1.638 0 0 0-.18-.01c-.524 0-.973.157-1.346.47v3.472c0 .131-.066.197-.195.197zm-2.249 0c-.13 0-.196-.066-.196-.197v-4.58c0-.13.066-.195.196-.195h.579c.13 0 .195.064.195.195v4.58c0 .131-.065.197-.195.197zm.295-5.856c-.19 0-.339-.054-.447-.16a.581.581 0 0 1-.161-.428c0-.176.054-.318.16-.426.11-.109.257-.163.448-.163.189 0 .337.054.446.163.107.108.16.25.16.426a.581.581 0 0 1-.16.427.608.608 0 0 1-.446.161m-3.625 5.856c-.132 0-.197-.066-.197-.197v-4.01H.195c-.13 0-.195-.066-.195-.197v-.245c0-.065.014-.114.043-.147.03-.033.088-.055.173-.07l.705-.087v-.804c0-1.091.523-1.638 1.57-1.638.248 0 .51.036.784.109.072.019.122.047.152.088.029.038.044.107.044.205v.255c0 .124-.048.186-.148.186-.058 0-.14-.01-.248-.029-.11-.02-.23-.03-.369-.03-.3 0-.51.057-.633.172-.121.115-.181.303-.181.564v.903h1.324c.131 0 .197.064.197.195v.373c0 .13-.066.197-.197.197H1.892v4.01c0 .131-.065.197-.196.197Z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
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 |
@@ -32,7 +32,7 @@ const ConfirmDialog: FC<ConfirmDialogProps> = ({
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
<DialogContentText sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{text}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
|
||||
@@ -11,13 +11,24 @@ import Stack from '@mui/material/Stack';
|
||||
interface InputDialogProps extends DialogProps {
|
||||
title: string;
|
||||
label: string;
|
||||
helperText?: string;
|
||||
initialText?: string;
|
||||
confirmButtonText?: string;
|
||||
onClose: () => void;
|
||||
onConfirm: (text: string) => void;
|
||||
};
|
||||
|
||||
const InputDialog = ({ open, title, label, onClose, confirmButtonText, onConfirm }: InputDialogProps) => {
|
||||
const [ text, setText ] = useState('');
|
||||
const InputDialog = ({
|
||||
open,
|
||||
title,
|
||||
label,
|
||||
helperText,
|
||||
initialText,
|
||||
onClose,
|
||||
confirmButtonText,
|
||||
onConfirm
|
||||
}: InputDialogProps) => {
|
||||
const [ text, setText ] = useState(initialText || '');
|
||||
|
||||
const onTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setText(e.target.value);
|
||||
@@ -37,7 +48,7 @@ const InputDialog = ({ open, title, label, onClose, confirmButtonText, onConfirm
|
||||
>
|
||||
{title && (
|
||||
<DialogTitle>
|
||||
{title}
|
||||
{title || ''}
|
||||
</DialogTitle>
|
||||
)}
|
||||
<DialogContent>
|
||||
@@ -45,6 +56,7 @@ const InputDialog = ({ open, title, label, onClose, confirmButtonText, onConfirm
|
||||
<TextField
|
||||
label={label}
|
||||
value={text}
|
||||
helperText={helperText}
|
||||
onChange={onTextChange}
|
||||
variant='standard'
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { FC } from 'react';
|
||||
import React, { type FC } from 'react';
|
||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import type {} from '@mui/material/themeCssVarsAugmentation';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
@@ -11,7 +11,6 @@ interface UserAvatarProps {
|
||||
|
||||
const UserAvatar: FC<UserAvatarProps> = ({ user }) => {
|
||||
const { api } = useApi();
|
||||
const theme = useTheme();
|
||||
|
||||
return user ? (
|
||||
<Avatar
|
||||
@@ -21,12 +20,13 @@ const UserAvatar: FC<UserAvatarProps> = ({ user }) => {
|
||||
`${api.basePath}/Users/${user.Id}/Images/Primary?tag=${user.PrimaryImageTag}` :
|
||||
undefined
|
||||
}
|
||||
sx={{
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
sx={(theme) => ({
|
||||
bgcolor: api && user.Id && user.PrimaryImageTag ?
|
||||
theme.palette.background.paper :
|
||||
theme.palette.primary.dark,
|
||||
theme.vars.palette.background.paper :
|
||||
theme.vars.palette.primary.dark,
|
||||
color: 'inherit'
|
||||
}}
|
||||
})}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -12,6 +12,8 @@ const appName = 'Jellyfin Web';
|
||||
const BrowserName = {
|
||||
tizen: 'Samsung Smart TV',
|
||||
web0s: 'LG Smart TV',
|
||||
titanos: 'Titan OS',
|
||||
vega: 'Vega OS',
|
||||
operaTv: 'Opera TV',
|
||||
xboxOne: 'Xbox One',
|
||||
ps4: 'Sony PS4',
|
||||
|
||||
@@ -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,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';
|
||||
@@ -12,6 +13,7 @@ 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 +67,8 @@ interface TextAction {
|
||||
}
|
||||
|
||||
export interface TextLine {
|
||||
title?: NullableString;
|
||||
titleAction?: TextAction;
|
||||
title?: NullableString | string[];
|
||||
titleAction?: TextAction | TextAction[];
|
||||
}
|
||||
|
||||
export function getTextActionButton(
|
||||
@@ -210,9 +212,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) ?
|
||||
|
||||
@@ -575,9 +575,15 @@ 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[0], null, serverId));
|
||||
const artistText = item.AlbumArtists
|
||||
.map(artist => {
|
||||
artist.ServerId = serverId;
|
||||
artist.Type = BaseItemKind.MusicArtist;
|
||||
artist.IsFolder = true;
|
||||
return getTextActionButton(artist);
|
||||
})
|
||||
.join(' / ');
|
||||
lines.push(artistText);
|
||||
} else {
|
||||
lines.push(escapeHtml(isUsingLiveTvNaming(item.Type) ? item.Name : (item.SeriesName || item.Series || item.Album || item.AlbumArtist || '')));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
margin: 0 !important;
|
||||
z-index: 999999 !important;
|
||||
z-index: 999998 !important;
|
||||
transition: opacity ease-out 0.2s;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,3 +1,5 @@
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
|
||||
import browser from '../scripts/browser';
|
||||
import { copy } from '../scripts/clipboard';
|
||||
import dom from '../utils/dom';
|
||||
@@ -10,9 +12,16 @@ import itemHelper, { canEditPlaylist } from './itemHelper';
|
||||
import { playbackManager } from './playback/playbackmanager';
|
||||
import toast from './toast/toast';
|
||||
import * as userSettings from '../scripts/settings/userSettings';
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import { AppFeature } from 'constants/appFeature';
|
||||
|
||||
/** Item types that support downloading all children. */
|
||||
const DOWNLOAD_ALL_TYPES = [
|
||||
BaseItemKind.BoxSet,
|
||||
BaseItemKind.MusicAlbum,
|
||||
BaseItemKind.Season,
|
||||
BaseItemKind.Series
|
||||
];
|
||||
|
||||
function getDeleteLabel(type) {
|
||||
switch (type) {
|
||||
case BaseItemKind.Series:
|
||||
@@ -172,7 +181,7 @@ export async function getCommands(options) {
|
||||
|
||||
if (appHost.supports(AppFeature.FileDownload)) {
|
||||
// CanDownload should probably be updated to return true for these items?
|
||||
if (user.Policy.EnableContentDownloading && (item.Type === 'Season' || item.Type == 'Series')) {
|
||||
if (user.Policy.EnableContentDownloading && DOWNLOAD_ALL_TYPES.includes(item.Type)) {
|
||||
commands.push({
|
||||
name: globalize.translate('DownloadAll'),
|
||||
id: 'downloadall',
|
||||
@@ -415,19 +424,21 @@ function executeCommand(item, id, options) {
|
||||
});
|
||||
break;
|
||||
case 'downloadall': {
|
||||
const downloadEpisodes = episodes => {
|
||||
const downloadItems = items => {
|
||||
import('../scripts/fileDownloader').then((fileDownloader) => {
|
||||
const downloads = episodes.map(episode => {
|
||||
const downloadHref = apiClient.getItemDownloadUrl(episode.Id);
|
||||
return {
|
||||
url: downloadHref,
|
||||
item: episode,
|
||||
itemId: episode.Id,
|
||||
serverId: serverId,
|
||||
title: episode.Name,
|
||||
filename: episode.Path.replace(/^.*[\\/]/, '')
|
||||
};
|
||||
});
|
||||
const downloads = items
|
||||
.filter(i => i.CanDownload)
|
||||
.map(i => {
|
||||
const downloadHref = apiClient.getItemDownloadUrl(i.Id);
|
||||
return {
|
||||
url: downloadHref,
|
||||
item: i,
|
||||
itemId: i.Id,
|
||||
serverId,
|
||||
title: i.Name,
|
||||
filename: i.Path.replace(/^.*[\\/]/, '')
|
||||
};
|
||||
});
|
||||
|
||||
fileDownloader.download(downloads);
|
||||
});
|
||||
@@ -441,17 +452,26 @@ function executeCommand(item, id, options) {
|
||||
});
|
||||
}
|
||||
)).then(seasonData => {
|
||||
downloadEpisodes(seasonData.map(season => season.Items).flat());
|
||||
downloadItems(seasonData.map(season => season.Items).flat());
|
||||
});
|
||||
};
|
||||
|
||||
if (item.Type === 'Season') {
|
||||
downloadSeasons([item]);
|
||||
} else if (item.Type === 'Series') {
|
||||
apiClient.getSeasons(item.Id, {
|
||||
userId: options.user.Id,
|
||||
Fields: 'ItemCounts'
|
||||
}).then(seasons => downloadSeasons(seasons.Items));
|
||||
switch (item.Type) {
|
||||
case BaseItemKind.BoxSet:
|
||||
case BaseItemKind.MusicAlbum:
|
||||
apiClient.getItems(options.user.Id, {
|
||||
ParentId: item.Id,
|
||||
Fields: 'CanDownload,Path'
|
||||
}).then(({ Items }) => downloadItems(Items));
|
||||
break;
|
||||
case BaseItemKind.Season:
|
||||
downloadSeasons([item]);
|
||||
break;
|
||||
case BaseItemKind.Series:
|
||||
apiClient.getSeasons(item.Id, {
|
||||
userId: options.user.Id,
|
||||
Fields: 'ItemCounts'
|
||||
}).then(seasons => downloadSeasons(seasons.Items));
|
||||
}
|
||||
|
||||
getResolveFunction(getResolveFunction(resolve, id), id)();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { type FC } from 'react';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import groupBy from 'lodash-es/groupBy';
|
||||
import Box from '@mui/material/Box';
|
||||
import { getIndex } from './listHelper';
|
||||
import ListGroupHeaderWrapper from './ListGroupHeaderWrapper';
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
import React, { type FC } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import Box from '@mui/material/Box';
|
||||
import datetime from 'scripts/datetime';
|
||||
import globalize from 'lib/globalize';
|
||||
import mediainfo from './mediainfo';
|
||||
|
||||
interface EndsAtProps {
|
||||
className?: string;
|
||||
runTimeTicks: number
|
||||
runTimeTicks: number;
|
||||
positionTicks?: number;
|
||||
}
|
||||
|
||||
const EndsAt: FC<EndsAtProps> = ({ runTimeTicks, className }) => {
|
||||
const EndsAt: FC<EndsAtProps> = ({ runTimeTicks, positionTicks, className }) => {
|
||||
const cssClass = classNames(
|
||||
'mediaInfoItem',
|
||||
'endsAt',
|
||||
className
|
||||
);
|
||||
|
||||
const endTime = new Date().getTime() + (runTimeTicks / 10000);
|
||||
const endDate = new Date(endTime);
|
||||
const displayTime = datetime.getDisplayTime(endDate);
|
||||
const displayTime = mediainfo.getEndsAtFromPosition(runTimeTicks, positionTicks, 1, true);
|
||||
|
||||
return (
|
||||
<Box className={cssClass}>
|
||||
{globalize.translate('EndsAtValue', displayTime)}
|
||||
{displayTime}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -71,6 +71,7 @@ const PrimaryMediaInfo: FC<PrimaryMediaInfoProps> = ({
|
||||
HasSubtitles,
|
||||
MediaType,
|
||||
RunTimeTicks,
|
||||
PlaybackPositionTicks,
|
||||
CommunityRating,
|
||||
CriticRating
|
||||
} = item;
|
||||
@@ -107,7 +108,7 @@ const PrimaryMediaInfo: FC<PrimaryMediaInfoProps> = ({
|
||||
&& MediaType === ItemMediaKind.Video
|
||||
&& RunTimeTicks
|
||||
&& !StartDate && (
|
||||
<EndsAt className={infoclass} runTimeTicks={RunTimeTicks} />
|
||||
<EndsAt className={infoclass} runTimeTicks={RunTimeTicks} positionTicks={PlaybackPositionTicks} />
|
||||
)}
|
||||
|
||||
{getMissingIndicator?.()}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { type FC } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import Box from '@mui/material/Box';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import type {} from '@mui/material/themeCssVarsAugmentation';
|
||||
|
||||
interface StarIconsProps {
|
||||
className?: string;
|
||||
@@ -10,7 +10,6 @@ interface StarIconsProps {
|
||||
}
|
||||
|
||||
const StarIcons: FC<StarIconsProps> = ({ className, communityRating }) => {
|
||||
const theme = useTheme();
|
||||
const cssClass = classNames(
|
||||
'mediaInfoItem',
|
||||
'starRatingContainer',
|
||||
@@ -21,9 +20,10 @@ const StarIcons: FC<StarIconsProps> = ({ className, communityRating }) => {
|
||||
<Box className={cssClass}>
|
||||
<StarIcon
|
||||
fontSize={'small'}
|
||||
sx={{
|
||||
color: theme.palette.starIcon.main
|
||||
}}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
sx={(theme) => ({
|
||||
color: theme.vars.palette.starIcon.main
|
||||
})}
|
||||
/>
|
||||
{communityRating.toFixed(1)}
|
||||
</Box>
|
||||
|
||||
@@ -321,11 +321,8 @@ export function getMediaInfoHtml(item, options = {}) {
|
||||
|
||||
export function getEndsAt(item) {
|
||||
if (item.MediaType === 'Video' && item.RunTimeTicks && !item.StartDate) {
|
||||
let endDate = new Date().getTime() + (item.RunTimeTicks / 10000);
|
||||
endDate = new Date(endDate);
|
||||
|
||||
const displayTime = datetime.getDisplayTime(endDate);
|
||||
return globalize.translate('EndsAtValue', displayTime);
|
||||
const positionTicks = item.UserData?.PlaybackPositionTicks;
|
||||
return getEndsAtFromPosition(item.RunTimeTicks, positionTicks, 1, true);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -589,17 +589,17 @@ function setFieldVisibilities(context, item) {
|
||||
hideElement('#fld3dFormat', context);
|
||||
}
|
||||
|
||||
if (item.Type === 'Audio') {
|
||||
if (item.Type === BaseItemKind.Audio || item.Type === BaseItemKind.MusicAlbum || item.Type === BaseItemKind.MusicVideo) {
|
||||
showElement('#fldArtist', context);
|
||||
showElement('#fldAlbumArtist', context);
|
||||
} else {
|
||||
hideElement('#fldArtist', context);
|
||||
hideElement('#fldAlbumArtist', context);
|
||||
}
|
||||
|
||||
if (item.Type === 'Audio' || item.Type === 'MusicVideo') {
|
||||
showElement('#fldArtist', context);
|
||||
if (item.Type === BaseItemKind.Audio || item.Type === BaseItemKind.MusicVideo) {
|
||||
showElement('#fldAlbum', context);
|
||||
} else {
|
||||
hideElement('#fldArtist', context);
|
||||
hideElement('#fldAlbum', context);
|
||||
}
|
||||
|
||||
@@ -970,7 +970,7 @@ function populatePeople(context, people) {
|
||||
html += '</div>';
|
||||
|
||||
if (person.Role && person.Role !== lastType) {
|
||||
html += '<div class="secondary">' + person.Role + '</div>';
|
||||
html += '<div class="secondary">' + escapeHtml(person.Role) + '</div>';
|
||||
} else {
|
||||
html += '<div class="secondary">' + globalize.translate(person.Type) + '</div>';
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ function show(person) {
|
||||
}
|
||||
});
|
||||
|
||||
let selectPersonTypeOptions = '<option value=""></option>';
|
||||
let selectPersonTypeOptions = '';
|
||||
for (const type of Object.values(PersonKind)) {
|
||||
if (type === PersonKind.Unknown) {
|
||||
continue;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind.js';
|
||||
import { PlaybackErrorCode } from '@jellyfin/sdk/lib/generated-client/models/playback-error-code.js';
|
||||
import { getMediaInfoApi } from '@jellyfin/sdk/lib/utils/api/media-info-api';
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import { ItemFilter } from '@jellyfin/sdk/lib/generated-client/models/item-filter';
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
|
||||
import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
|
||||
import { PlaybackErrorCode } from '@jellyfin/sdk/lib/generated-client/models/playback-error-code';
|
||||
import { getMediaInfoApi } from '@jellyfin/sdk/lib/utils/api/media-info-api';
|
||||
import merge from 'lodash-es/merge';
|
||||
import Screenfull from 'screenfull';
|
||||
|
||||
@@ -166,9 +168,9 @@ function createStreamInfoFromUrlItem(item) {
|
||||
function mergePlaybackQueries(obj1, obj2) {
|
||||
const query = merge({}, obj1, obj2);
|
||||
|
||||
const filters = query.Filters ? query.Filters.split(',') : [];
|
||||
if (filters.indexOf('IsNotFolder') === -1) {
|
||||
filters.push('IsNotFolder');
|
||||
const filters = query.Filters?.split(',') || [];
|
||||
if (!filters.includes(ItemFilter.IsNotFolder)) {
|
||||
filters.push(ItemFilter.IsNotFolder);
|
||||
}
|
||||
query.Filters = filters.join(',');
|
||||
return query;
|
||||
@@ -647,6 +649,7 @@ function normalizePlayOptions(playOptions) {
|
||||
|
||||
function truncatePlayOptions(playOptions) {
|
||||
return {
|
||||
aspectRatio: playOptions.aspectRatio,
|
||||
fullscreen: playOptions.fullscreen,
|
||||
mediaSourceId: playOptions.mediaSourceId,
|
||||
audioStreamIndex: playOptions.audioStreamIndex,
|
||||
@@ -1021,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;
|
||||
}
|
||||
|
||||
@@ -1419,6 +1422,28 @@ export class PlaybackManager {
|
||||
});
|
||||
};
|
||||
|
||||
self.isCrtShaderEnabled = function (player) {
|
||||
player = player || self._currentPlayer;
|
||||
return !!(getPlayerData(player).enableCrtShader);
|
||||
};
|
||||
|
||||
self.setCrtShader = function (enabled, shadowMask, player) {
|
||||
player = player || self._currentPlayer;
|
||||
const pd = getPlayerData(player);
|
||||
pd.enableCrtShader = !!enabled;
|
||||
if (shadowMask !== undefined && shadowMask !== null) {
|
||||
pd.crtShadowMask = shadowMask;
|
||||
}
|
||||
// When enabling CRT we must force full re-encode — the filter only runs
|
||||
// server-side via program_opencl and requires actual video decoding/encoding.
|
||||
// AllowVideoStreamCopy:false prevents the server from choosing codec:copy
|
||||
// (container remux), which would bypass the filter chain entirely.
|
||||
const streamParams = enabled
|
||||
? { EnableDirectPlay: false, EnableDirectStream: false, AllowVideoStreamCopy: false }
|
||||
: {};
|
||||
changeStream(player, getCurrentTicks(player), streamParams);
|
||||
};
|
||||
|
||||
self.isFullscreen = function (player) {
|
||||
player = player || self._currentPlayer;
|
||||
if (!player.isLocalPlayer || player.isFullscreen) {
|
||||
@@ -1828,64 +1853,75 @@ export class PlaybackManager {
|
||||
}
|
||||
|
||||
function getPlaybackPromise(firstItem, serverId, options, queryOptions, items) {
|
||||
const SortBy = options.shuffle ? ItemSortBy.Random : ItemSortBy.SortName;
|
||||
|
||||
switch (firstItem.Type) {
|
||||
case 'Program':
|
||||
case BaseItemKind.Program:
|
||||
return getItemsForPlayback(serverId, {
|
||||
Ids: firstItem.ChannelId
|
||||
});
|
||||
case 'Playlist':
|
||||
case BaseItemKind.Playlist:
|
||||
return getItemsForPlayback(serverId, {
|
||||
ParentId: firstItem.Id,
|
||||
SortBy: options.shuffle ? 'Random' : null
|
||||
SortBy: options.shuffle ? SortBy : undefined
|
||||
});
|
||||
case 'MusicArtist':
|
||||
case BaseItemKind.MusicArtist:
|
||||
|
||||
return getItemsForPlayback(serverId, mergePlaybackQueries({
|
||||
ArtistIds: firstItem.Id,
|
||||
Filters: 'IsNotFolder',
|
||||
Recursive: true,
|
||||
SortBy: options.shuffle ? 'Random' : 'Album,ParentIndexNumber,IndexNumber,SortName',
|
||||
MediaTypes: 'Audio'
|
||||
SortBy: options.shuffle ? SortBy : [
|
||||
ItemSortBy.Album,
|
||||
ItemSortBy.ParentIndexNumber,
|
||||
ItemSortBy.IndexNumber,
|
||||
ItemSortBy.SortName
|
||||
].join(','),
|
||||
MediaTypes: MediaType.Audio
|
||||
}, queryOptions));
|
||||
case 'PhotoAlbum':
|
||||
case BaseItemKind.PhotoAlbum:
|
||||
return getItemsForPlayback(serverId, mergePlaybackQueries({
|
||||
ParentId: firstItem.Id,
|
||||
Filters: 'IsNotFolder',
|
||||
// Setting this to true may cause some incorrect sorting
|
||||
Recursive: false,
|
||||
SortBy: options.shuffle ? 'Random' : 'SortName',
|
||||
SortBy,
|
||||
// Only include Photos because we do not handle mixed queues currently
|
||||
MediaTypes: 'Photo',
|
||||
MediaTypes: MediaType.Photo,
|
||||
Limit: UNLIMITED_ITEMS
|
||||
}, queryOptions));
|
||||
case 'MusicGenre':
|
||||
case BaseItemKind.MusicGenre:
|
||||
return getItemsForPlayback(serverId, mergePlaybackQueries({
|
||||
GenreIds: firstItem.Id,
|
||||
Filters: 'IsNotFolder',
|
||||
Recursive: true,
|
||||
SortBy: options.shuffle ? 'Random' : 'SortName',
|
||||
MediaTypes: 'Audio'
|
||||
SortBy,
|
||||
MediaTypes: MediaType.Audio
|
||||
}, queryOptions));
|
||||
case 'Genre':
|
||||
case BaseItemKind.Genre:
|
||||
return getItemsForPlayback(serverId, mergePlaybackQueries({
|
||||
GenreIds: firstItem.Id,
|
||||
ParentId: firstItem.ParentId,
|
||||
Filters: 'IsNotFolder',
|
||||
Recursive: true,
|
||||
SortBy: options.shuffle ? 'Random' : 'SortName',
|
||||
MediaTypes: 'Video'
|
||||
SortBy,
|
||||
MediaTypes: MediaType.Video
|
||||
}, queryOptions));
|
||||
case 'Studio':
|
||||
case BaseItemKind.Studio:
|
||||
return getItemsForPlayback(serverId, mergePlaybackQueries({
|
||||
StudioIds: firstItem.Id,
|
||||
Filters: 'IsNotFolder',
|
||||
Recursive: true,
|
||||
SortBy: options.shuffle ? 'Random' : 'SortName',
|
||||
MediaTypes: 'Video'
|
||||
SortBy,
|
||||
MediaTypes: MediaType.Video
|
||||
}, queryOptions));
|
||||
case 'Series':
|
||||
case 'Season':
|
||||
case BaseItemKind.Person:
|
||||
return getItemsForPlayback(serverId, mergePlaybackQueries({
|
||||
PersonIds: firstItem.Id,
|
||||
ParentId: firstItem.ParentId,
|
||||
Recursive: true,
|
||||
SortBy,
|
||||
MediaTypes: MediaType.Video
|
||||
}, queryOptions));
|
||||
case BaseItemKind.Series:
|
||||
case BaseItemKind.Season:
|
||||
return getSeriesOrSeasonPlaybackPromise(firstItem, options, items);
|
||||
case 'Episode':
|
||||
case BaseItemKind.Episode:
|
||||
return getEpisodePlaybackPromise(firstItem, options, items);
|
||||
}
|
||||
|
||||
@@ -1927,6 +1963,15 @@ export class PlaybackManager {
|
||||
MediaTypes: 'Photo',
|
||||
Limit: UNLIMITED_ITEMS
|
||||
}, queryOptions));
|
||||
} else if (firstItem.IsFolder && firstItem.CollectionType === 'musicvideos') {
|
||||
return getItemsForPlayback(serverId, mergePlaybackQueries({
|
||||
ParentId: firstItem.Id,
|
||||
Filters: 'IsFolder',
|
||||
Recursive: true,
|
||||
SortBy: options.shuffle ? 'Random' : 'SortName',
|
||||
MediaTypes: 'Video',
|
||||
Limit: UNLIMITED_ITEMS
|
||||
}, queryOptions));
|
||||
} else if (firstItem.IsFolder) {
|
||||
let sortBy = null;
|
||||
if (options.shuffle) {
|
||||
@@ -1957,28 +2002,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,
|
||||
@@ -2641,6 +2676,7 @@ export class PlaybackManager {
|
||||
const audioStreamIndex = playOptions.audioStreamIndex;
|
||||
const subtitleStreamIndex = playOptions.subtitleStreamIndex;
|
||||
const options = {
|
||||
aspectRatio: playOptions.aspectRatio,
|
||||
maxBitrate,
|
||||
startPosition,
|
||||
isPlayback: null,
|
||||
@@ -2698,7 +2734,7 @@ export class PlaybackManager {
|
||||
}
|
||||
|
||||
const streamInfo = createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition, player);
|
||||
|
||||
streamInfo.aspectRatio = playOptions.aspectRatio;
|
||||
streamInfo.fullscreen = playOptions.fullscreen;
|
||||
|
||||
const playerData = getPlayerData(player);
|
||||
@@ -2842,7 +2878,19 @@ export class PlaybackManager {
|
||||
|
||||
playMethod = mediaSource.SupportsDirectPlay ? 'DirectPlay' : 'DirectStream';
|
||||
} else if (mediaSource.SupportsTranscoding) {
|
||||
mediaUrl = apiClient.getUrl(mediaSource.TranscodingUrl);
|
||||
let transcodingUrl = mediaSource.TranscodingUrl;
|
||||
|
||||
// Append CRT shader options as plain query params so ParseStreamOptions
|
||||
// on the server picks them up (key must start lowercase, no streamOptions[] wrapper).
|
||||
if (getPlayerData(player).enableCrtShader) {
|
||||
transcodingUrl += '&crtShader=true';
|
||||
const maskVal = getPlayerData(player).crtShadowMask;
|
||||
if (maskVal !== undefined && maskVal !== null) {
|
||||
transcodingUrl += '&crtShadowMask=' + maskVal;
|
||||
}
|
||||
}
|
||||
|
||||
mediaUrl = apiClient.getUrl(transcodingUrl);
|
||||
|
||||
if (mediaSource.TranscodingSubProtocol === 'hls') {
|
||||
contentType = 'application/x-mpegURL';
|
||||
|
||||
@@ -4,6 +4,34 @@ import globalize from 'lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import qualityoptions from '../qualityOptions';
|
||||
|
||||
function showCrtMenu(player, btn) {
|
||||
const isEnabled = playbackManager.isCrtShaderEnabled(player);
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
name: globalize.translate('On'),
|
||||
id: 'crt_on',
|
||||
selected: isEnabled
|
||||
},
|
||||
{
|
||||
name: globalize.translate('Off'),
|
||||
id: 'crt_off',
|
||||
selected: !isEnabled
|
||||
}
|
||||
];
|
||||
|
||||
return actionsheet.show({
|
||||
items: menuItems,
|
||||
positionTo: btn
|
||||
}).then(function (id) {
|
||||
if (id === 'crt_on' && !isEnabled) {
|
||||
playbackManager.setCrtShader(true, null, player);
|
||||
} else if (id === 'crt_off' && isEnabled) {
|
||||
playbackManager.setCrtShader(false, null, player);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showQualityMenu(player, btn) {
|
||||
const videoStream = playbackManager.currentMediaSource(player).MediaStreams.filter(function (stream) {
|
||||
return stream.Type === 'Video';
|
||||
@@ -93,12 +121,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
|
||||
@@ -210,6 +242,16 @@ function showWithUser(options, player, user) {
|
||||
});
|
||||
}
|
||||
|
||||
if (options.quality && user?.Policy?.EnableVideoPlaybackTranscoding) {
|
||||
menuItems.push({
|
||||
name: 'CRT Shader',
|
||||
id: 'crtshader',
|
||||
asideText: playbackManager.isCrtShaderEnabled(player)
|
||||
? globalize.translate('On')
|
||||
: globalize.translate('Off')
|
||||
});
|
||||
}
|
||||
|
||||
const repeatMode = playbackManager.getRepeatMode(player);
|
||||
|
||||
if (supportedCommands.indexOf('SetRepeatMode') !== -1 && playbackManager.currentMediaSource(player).RunTimeTicks) {
|
||||
@@ -268,6 +310,8 @@ function handleSelectedOption(id, options, player) {
|
||||
return showPlaybackRateMenu(player, options.positionTo);
|
||||
case 'repeatmode':
|
||||
return showRepeatModeMenu(player, options.positionTo);
|
||||
case 'crtshader':
|
||||
return showCrtMenu(player, options.positionTo);
|
||||
case 'stats':
|
||||
if (options.onOption) {
|
||||
options.onOption('stats');
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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';
|
||||
import { EventType } from 'types/eventType';
|
||||
import { EventType } from 'constants/eventType';
|
||||
import './skipbutton.scss';
|
||||
import dom from 'utils/dom';
|
||||
import globalize from 'lib/globalize';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -16,7 +16,7 @@ import { history } from 'RootAppRouter';
|
||||
const START_PAGE_PATHS = ['/home', '/login', '/selectserver'];
|
||||
|
||||
/** Pages that do not require a user to be logged in to view. */
|
||||
const PUBLIC_PATHS = [
|
||||
export const PUBLIC_PATHS = [
|
||||
'/addserver',
|
||||
'/selectserver',
|
||||
'/login',
|
||||
@@ -121,9 +121,7 @@ class AppRouter {
|
||||
return this.baseRoute;
|
||||
}
|
||||
|
||||
canGoBack() {
|
||||
const path = history.location.pathname;
|
||||
|
||||
canGoBack(path = history.location.pathname) {
|
||||
if (
|
||||
!document.querySelector('.dialogContainer')
|
||||
&& START_PAGE_PATHS.includes(path)
|
||||
@@ -261,15 +259,15 @@ class AppRouter {
|
||||
}
|
||||
|
||||
if (item === 'recordedtv') {
|
||||
return '#/livetv?tab=3&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=3&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (item === 'nextup') {
|
||||
return '#/list?type=nextup&serverId=' + options.serverId;
|
||||
return '#/list?type=nextup&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (item === 'list') {
|
||||
let urlForList = '#/list?serverId=' + options.serverId + '&type=' + options.itemTypes;
|
||||
let urlForList = '#/list?serverId=' + serverId + '&type=' + options.itemTypes;
|
||||
|
||||
if (options.isFavorite) {
|
||||
urlForList += '&IsFavorite=true';
|
||||
@@ -304,49 +302,49 @@ class AppRouter {
|
||||
|
||||
if (item === 'livetv') {
|
||||
if (options.section === 'programs') {
|
||||
return '#/livetv?tab=0&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=0&serverId=' + serverId;
|
||||
}
|
||||
if (options.section === 'guide') {
|
||||
return '#/livetv?tab=1&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=1&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'movies') {
|
||||
return '#/list?type=Programs&IsMovie=true&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsMovie=true&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'shows') {
|
||||
return '#/list?type=Programs&IsSeries=true&IsMovie=false&IsNews=false&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsSeries=true&IsMovie=false&IsNews=false&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'sports') {
|
||||
return '#/list?type=Programs&IsSports=true&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsSports=true&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'kids') {
|
||||
return '#/list?type=Programs&IsKids=true&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsKids=true&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'news') {
|
||||
return '#/list?type=Programs&IsNews=true&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsNews=true&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'onnow') {
|
||||
return '#/list?type=Programs&IsAiring=true&serverId=' + options.serverId;
|
||||
return '#/list?type=Programs&IsAiring=true&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'channels') {
|
||||
return '#/livetv?tab=2&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=2&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'dvrschedule') {
|
||||
return '#/livetv?tab=4&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=4&serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (options.section === 'seriesrecording') {
|
||||
return '#/livetv?tab=5&serverId=' + options.serverId;
|
||||
return '#/livetv?tab=5&serverId=' + serverId;
|
||||
}
|
||||
|
||||
return '#/livetv?serverId=' + options.serverId;
|
||||
return '#/livetv?serverId=' + serverId;
|
||||
}
|
||||
|
||||
if (itemType == 'SeriesTimer') {
|
||||
|
||||
@@ -41,7 +41,7 @@ try {
|
||||
const opts = Object.defineProperty({}, 'behavior', {
|
||||
get: function () {
|
||||
supportsScrollToOptions = true;
|
||||
return null;
|
||||
return 'auto';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -390,7 +390,7 @@ export function onClick(e) {
|
||||
}
|
||||
}
|
||||
|
||||
if (action) {
|
||||
if (action && action !== 'none') {
|
||||
executeAction(card, actionElement, action);
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -40,6 +40,7 @@ function playThemeMedia(items, ownerId) {
|
||||
|
||||
playbackManager.play({
|
||||
items: currentThemeItems,
|
||||
aspectRatio: 'cover',
|
||||
fullscreen: false,
|
||||
enableRemotePlayers: false
|
||||
}).then(function () {
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
export enum EventType {
|
||||
HEADER_RENDERED = 'HEADER_RENDERED',
|
||||
SET_TABS = 'SET_TABS',
|
||||
SHOW_VIDEO_OSD = 'SHOW_VIDEO_OSD'
|
||||
SHOW_VIDEO_OSD = 'SHOW_VIDEO_OSD',
|
||||
THEME_CHANGE = 'THEME_CHANGE'
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
|
||||
|
||||
import cardBuilder from 'components/cardbuilder/cardBuilder';
|
||||
import focusManager from 'components/focusManager';
|
||||
import layoutManager from 'components/layoutManager';
|
||||
@@ -6,7 +9,6 @@ import dom from 'utils/dom';
|
||||
import globalize from 'lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import { getBackdropShape, getPortraitShape, getSquareShape } from 'utils/card';
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
|
||||
|
||||
import 'elements/emby-itemscontainer/emby-itemscontainer';
|
||||
import 'elements/emby-scroller/emby-scroller';
|
||||
@@ -34,6 +36,15 @@ function getSections() {
|
||||
overlayPlayButton: true,
|
||||
overlayText: false,
|
||||
centerText: true
|
||||
}, {
|
||||
name: 'HeaderSeasons',
|
||||
types: BaseItemKind.Season,
|
||||
shape: getPortraitShape(enableScrollX()),
|
||||
showTitle: true,
|
||||
showParentTitle: true,
|
||||
overlayPlayButton: true,
|
||||
overlayText: false,
|
||||
centerText: true
|
||||
}, {
|
||||
name: 'Episodes',
|
||||
types: 'Episode',
|
||||
|
||||
@@ -4,172 +4,186 @@
|
||||
<div class="detailLogo"></div>
|
||||
|
||||
<div class="detailPageWrapperContainer">
|
||||
<div class="detailPagePrimaryContainer padded-left padded-right">
|
||||
<div class="infoWrapper">
|
||||
<div class="detailImageContainer padded-left"></div>
|
||||
<div class="nameContainer"></div>
|
||||
<div class="itemMiscInfo itemMiscInfo-primary" style="margin-bottom: 0.6em;"></div>
|
||||
<div class="itemMiscInfo itemMiscInfo-secondary" style="margin-bottom: 0.6em;"></div>
|
||||
<div class="detailPagePrimaryContainer">
|
||||
<div class="detailImageContainer hide-mobile"></div>
|
||||
|
||||
<div class="detailRibbon padded-left padded-right">
|
||||
<div class="infoWrapper">
|
||||
<div class="detailImageContainer hide-desktop hide-tv"></div>
|
||||
<div class="nameContainer"></div>
|
||||
<div class="itemMiscInfo itemMiscInfo-primary" style="margin-bottom: 0.6em;"></div>
|
||||
<div class="itemMiscInfo itemMiscInfo-secondary" style="margin-bottom: 0.6em;"></div>
|
||||
</div>
|
||||
|
||||
<div class="mainDetailButtons focuscontainer-x">
|
||||
<button is="emby-button" type="button" class="button-flat btnPlay hide detailButton" title="${ButtonResume}" data-action="resume">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon play_arrow" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnReplay hide detailButton" title="${Play}" data-action="play">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon replay" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnDownload hide detailButton" title="${Download}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon get_app" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnPlayTrailer hide detailButton" title="${ButtonTrailer}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon theaters" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnInstantMix hide detailButton" title="${HeaderInstantMix}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon explore" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnShuffle hide detailButton" title="${Shuffle}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon shuffle" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnCancelSeriesTimer hide detailButton" title="${CancelSeries}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon delete" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnCancelTimer hide detailButton" title="${StopRecording}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon stop" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button is="emby-playstatebutton" type="button" class="button-flat btnPlaystate hide detailButton" title="">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon check" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button is="emby-ratingbutton" type="button" class="button-flat btnUserRating hide detailButton" title="${Rate}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon favorite" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnSplitVersions hide detailButton" title="${ButtonSplit}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon call_split" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnMoreCommands hide detailButton" title="${ButtonMore}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon more_vert" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mainDetailButtons focuscontainer-x">
|
||||
<button is="emby-button" type="button" class="button-flat btnPlay hide detailButton" title="${ButtonResume}" data-action="resume">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon play_arrow" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
<div class="detailPagePrimaryContent padded-right">
|
||||
<div class="detailSection">
|
||||
<form class="trackSelections hide focuscontainer-x">
|
||||
<div class="selectContainer selectSourceContainer hide trackSelectionFieldContainer flex-shrink-zero">
|
||||
<select is="emby-select" class="selectSource detailTrackSelect" label=""></select>
|
||||
</div>
|
||||
<div class="selectContainer selectVideoContainer hide trackSelectionFieldContainer flex-shrink-zero">
|
||||
<select is="emby-select" class="selectVideo detailTrackSelect" label=""></select>
|
||||
</div>
|
||||
<div class="selectContainer selectAudioContainer hide trackSelectionFieldContainer flex-shrink-zero">
|
||||
<select is="emby-select" class="selectAudio detailTrackSelect" label=""></select>
|
||||
</div>
|
||||
<div class="selectContainer selectSubtitlesContainer hide trackSelectionFieldContainer flex-shrink-zero">
|
||||
<select is="emby-select" class="selectSubtitles detailTrackSelect" label=""></select>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnReplay hide detailButton" title="${Play}" data-action="play">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon replay" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
<div class="recordingFields hide" style="margin: 0.5em 0 1.5em;"></div>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnDownload hide detailButton" title="${Download}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon get_app" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
<div class="detailSectionContent">
|
||||
<p class="itemGenres"></p>
|
||||
<h3 class="tagline"></h3>
|
||||
<p class="overview"></p>
|
||||
<div class="overview-controls">
|
||||
<a class="overview-expand hide" is="emby-linkbutton" href="#">${ShowMore}</a>
|
||||
</div>
|
||||
<p id="itemBirthday"></p>
|
||||
<p id="itemBirthLocation"></p>
|
||||
<p id="itemDeathDate"></p>
|
||||
<p id="seriesAirTime"></p>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnPlayTrailer hide detailButton" title="${ButtonTrailer}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon theaters" aria-hidden="true"></span>
|
||||
<div class="itemTags focuscontainer-x hide" style="margin: 0.7em 0; font-size: 92%;"></div>
|
||||
<div class="itemExternalLinks focuscontainer-x hide" style="margin: 0.7em 0; font-size: 92%;"></div>
|
||||
<div class="seriesRecordingEditor"></div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnInstantMix hide detailButton" title="${HeaderInstantMix}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon explore" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
<div class="itemDetailsGroup">
|
||||
<div class="detailsGroupItem genresGroup hide">
|
||||
<div class="genresLabel label"></div>
|
||||
<div class="genres content focuscontainer-x"></div>
|
||||
</div>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnShuffle hide detailButton" title="${Shuffle}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon shuffle" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
<div class="detailsGroupItem directorsGroup hide">
|
||||
<div class="directorsLabel label"></div>
|
||||
<div class="directors content focuscontainer-x"></div>
|
||||
</div>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnCancelSeriesTimer hide detailButton" title="${CancelSeries}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon delete" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
<div class="detailsGroupItem writersGroup hide">
|
||||
<div class="writersLabel label"></div>
|
||||
<div class="writers content focuscontainer-x"></div>
|
||||
</div>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnCancelTimer hide detailButton" title="${StopRecording}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon stop" aria-hidden="true"></span>
|
||||
<div class="detailsGroupItem studiosGroup hide">
|
||||
<div class="studiosLabel label"></div>
|
||||
<div class="studios content focuscontainer-x"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button is="emby-playstatebutton" type="button" class="button-flat btnPlaystate hide detailButton" title="">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon check" aria-hidden="true"></span>
|
||||
<div id="seriesTimerScheduleSection" class="verticalSection detailVerticalSection hide" style="margin-top: -3em;">
|
||||
<h2 class="sectionTitle">${Schedule}</h2>
|
||||
<div id="seriesTimerSchedule" is="emby-itemscontainer" class="itemsContainer vertical-list padded-right" data-contextmenu="false"></div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button is="emby-ratingbutton" type="button" class="button-flat btnUserRating hide detailButton" title="${Rate}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon favorite" aria-hidden="true"></span>
|
||||
</div>
|
||||
</button>
|
||||
<div class="collectionItems hide"></div>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnSplitVersions hide detailButton" title="${ButtonSplit}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon call_split" aria-hidden="true"></span>
|
||||
<div class="nextUpSection verticalSection detailVerticalSection hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards">${NextUp}</h2>
|
||||
<div is="emby-itemscontainer" class="nextUpItems vertical-wrap padded-right"></div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button is="emby-button" type="button" class="button-flat btnMoreCommands hide detailButton" title="${ButtonMore}">
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon more_vert" aria-hidden="true"></span>
|
||||
<div class="programGuideSection hide verticalSection detailVerticalSection">
|
||||
<div class="programGuide"></div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div id="listChildrenCollapsible" class="hide verticalSection detailVerticalSection">
|
||||
<h2 class="sectionTitle sectionTitle-cards hide">
|
||||
<span></span>
|
||||
</h2>
|
||||
<div id="childrenContent">
|
||||
<div is="emby-itemscontainer" class="itemsContainer padded-right" style="text-align: left;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detailPageSecondaryContainer padded-bottom-page">
|
||||
<div class="detailPageContent">
|
||||
<div class="detailPagePrimaryContent padded-right">
|
||||
<div class="detailSection">
|
||||
<form class="trackSelections hide focuscontainer-x">
|
||||
<div class="selectContainer selectSourceContainer hide trackSelectionFieldContainer flex-shrink-zero">
|
||||
<select is="emby-select" class="selectSource detailTrackSelect" label=""></select>
|
||||
</div>
|
||||
<div class="selectContainer selectVideoContainer hide trackSelectionFieldContainer flex-shrink-zero">
|
||||
<select is="emby-select" class="selectVideo detailTrackSelect" label=""></select>
|
||||
</div>
|
||||
<div class="selectContainer selectAudioContainer hide trackSelectionFieldContainer flex-shrink-zero">
|
||||
<select is="emby-select" class="selectAudio detailTrackSelect" label=""></select>
|
||||
</div>
|
||||
<div class="selectContainer selectSubtitlesContainer hide trackSelectionFieldContainer flex-shrink-zero">
|
||||
<select is="emby-select" class="selectSubtitles detailTrackSelect" label=""></select>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="recordingFields hide" style="margin: 0.5em 0 1.5em;"></div>
|
||||
|
||||
<div class="detailSectionContent">
|
||||
<p class="itemGenres"></p>
|
||||
<h3 class="tagline"></h3>
|
||||
<p class="overview"></p>
|
||||
<div class="overview-controls">
|
||||
<a class="overview-expand hide" is="emby-linkbutton" href="#">${ShowMore}</a>
|
||||
</div>
|
||||
<p id="itemBirthday"></p>
|
||||
<p id="itemBirthLocation"></p>
|
||||
<p id="itemDeathDate"></p>
|
||||
<p id="seriesAirTime"></p>
|
||||
|
||||
<div class="itemTags focuscontainer-x hide" style="margin: 0.7em 0; font-size: 92%;"></div>
|
||||
<div class="itemExternalLinks focuscontainer-x hide" style="margin: 0.7em 0; font-size: 92%;"></div>
|
||||
<div class="seriesRecordingEditor"></div>
|
||||
</div>
|
||||
|
||||
<div class="itemDetailsGroup">
|
||||
<div class="detailsGroupItem genresGroup hide">
|
||||
<div class="genresLabel label"></div>
|
||||
<div class="genres content focuscontainer-x"></div>
|
||||
</div>
|
||||
|
||||
<div class="detailsGroupItem directorsGroup hide">
|
||||
<div class="directorsLabel label"></div>
|
||||
<div class="directors content focuscontainer-x"></div>
|
||||
</div>
|
||||
|
||||
<div class="detailsGroupItem writersGroup hide">
|
||||
<div class="writersLabel label"></div>
|
||||
<div class="writers content focuscontainer-x"></div>
|
||||
</div>
|
||||
|
||||
<div class="detailsGroupItem studiosGroup hide">
|
||||
<div class="studiosLabel label"></div>
|
||||
<div class="studios content focuscontainer-x"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="seriesTimerScheduleSection" class="verticalSection detailVerticalSection hide" style="margin-top: -3em;">
|
||||
<h2 class="sectionTitle">${Schedule}</h2>
|
||||
<div id="seriesTimerSchedule" is="emby-itemscontainer" class="itemsContainer vertical-list padded-right" data-contextmenu="false"></div>
|
||||
</div>
|
||||
|
||||
<div class="collectionItems hide"></div>
|
||||
|
||||
<div class="nextUpSection verticalSection detailVerticalSection hide">
|
||||
<h2 class="sectionTitle sectionTitle-cards">${NextUp}</h2>
|
||||
<div is="emby-itemscontainer" class="nextUpItems vertical-wrap padded-right"></div>
|
||||
</div>
|
||||
|
||||
<div class="programGuideSection hide verticalSection detailVerticalSection">
|
||||
<div class="programGuide"></div>
|
||||
</div>
|
||||
|
||||
<div id="childrenCollapsible" class="hide verticalSection detailVerticalSection">
|
||||
<h2 class="childrenSectionHeader sectionTitle sectionTitle-cards hide">
|
||||
<span id="childrenTitle"></span>
|
||||
<h2 class="sectionTitle sectionTitle-cards hide">
|
||||
<span></span>
|
||||
</h2>
|
||||
<div id="childrenContent">
|
||||
<div is="emby-itemscontainer" class="childrenItemsContainer itemsContainer padded-right" style="text-align: left;"></div>
|
||||
<div>
|
||||
<div is="emby-itemscontainer" class="itemsContainer padded-right" style="text-align: left;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user