Compare commits

..

301 Commits

Author SHA1 Message Date
renovate[bot]
c31b4c83fa Update github/codeql-action action to v4 2025-12-31 18:53:17 +00:00
Warper
73c1274011 Translated using Weblate (Spanish (Latin America))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/es_419/
2025-12-31 04:05:52 +00:00
Warper
6a70ed2ba7 Translated using Weblate (Spanish (Latin America))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/es_419/
2025-12-31 02:05:52 +00:00
MrPlow
0ecdde9f26 Translated using Weblate (German)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/de/
2025-12-29 17:05:52 +00:00
SamuWhale
2b18fc3636 Translated using Weblate (Thai)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/th/
2025-12-29 15:05:52 +00:00
stunzzz
e7f6930ca3 Translated using Weblate (Indonesian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/id/
2025-12-29 15:05:52 +00:00
ilker
42d1a0563f Translated using Weblate (Turkish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/tr/
2025-12-29 11:04:35 +00:00
Joker
e0b27bd1a6 Translated using Weblate (German)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/de/
2025-12-29 11:04:35 +00:00
Bambus3000
40ad1fc595 Translated using Weblate (Norwegian Bokmål)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/nb_NO/
2025-12-28 23:05:52 +00:00
stunzzz
f4544a676f Translated using Weblate (Indonesian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/id/
2025-12-28 18:47:13 +00:00
Spillutvikler
e9d0832cb2 Translated using Weblate (Norwegian Bokmål)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/nb_NO/
2025-12-28 14:05:52 +00:00
Jonne Saloranta
b80b971231 Translated using Weblate (Finnish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/fi/
2025-12-27 18:05:52 +00:00
Chris Stormrider
977bfbfe73 Translated using Weblate (Greek)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/el/
2025-12-27 18:05:52 +00:00
Tales Costa
97bfdddacc Translated using Weblate (Portuguese (Brazil))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt_BR/
2025-12-27 13:05:52 +00:00
Tristan
32cee8ac28 Translated using Weblate (French)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/fr/
2025-12-27 02:05:53 +00:00
Tristan
2b2672be70 Translated using Weblate (French (Canada))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/fr_CA/
2025-12-27 02:05:52 +00:00
Eva Soler
ed4417b7de Translated using Weblate (English (United States))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/en_US/
2025-12-25 21:05:52 +00:00
Eva Soler
6be1cb58a5 Translated using Weblate (Spanish (Latin America))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/es_419/
2025-12-25 21:05:52 +00:00
Translation expert
a326654542 Translated using Weblate (Arabic)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ar/
2025-12-25 19:05:52 +00:00
Gallyam Biktashev
948d792677 Translated using Weblate (Russian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ru/
2025-12-22 19:05:52 +00:00
Warper
b7f1a46841 Translated using Weblate (Spanish (Latin America))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/es_419/
2025-12-20 02:55:08 +00:00
Warper
ff42033d77 Translated using Weblate (Spanish (Latin America))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/es_419/
2025-12-20 00:55:08 +00:00
Bill Thornton
a1bc62d158 Merge pull request #7321 from kinke/tizen_anamorphic
Tizen: Enable direct-play of anamorphic videos
2025-12-19 11:51:53 -05:00
Stefano Mazzoleni
742918f39f Translated using Weblate (Italian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/it/
2025-12-19 10:55:08 +00:00
Martin Kinkelin
08f8b2d2f7 Tizen: Enable direct-play of anamorphic videos
Anamorphic videos currently require a remux, which apparently breaks
the aspect ratio: https://github.com/jellyfin/jellyfin-tizen/issues/376

On my Tizen v9 TV, the web player handles the aspect ratio just fine
directly, correctly stretching the 720x472 pixels of some DVD source
to a 873x472 view. Tested with HEVC and H264 media.

Also got successful test reports on Tizen 6.0 and 6.5 TVs.
2025-12-19 11:38:48 +01:00
hoanghuy309
4781f5e99f Translated using Weblate (Vietnamese)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/vi/
2025-12-19 04:45:31 +00:00
Fabrizio Mansilla
d4599dc02c Translated using Weblate (Spanish (Argentina))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/es_AR/
2025-12-19 04:45:31 +00:00
Tyler Mills
522b7455f4 Add support for scanning tartgeted paths with lint npm script (#7436) 2025-12-18 16:42:26 -05:00
SmartLink Solutions Pvt. Ltd.
9766d77fd4 Translated using Weblate (Gujarati)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/gu/
2025-12-18 07:55:09 +00:00
Kityn
f39c9a3f57 Translated using Weblate (Polish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pl/
2025-12-18 07:55:09 +00:00
Michael
1376d435b8 Translated using Weblate (German)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/de/
2025-12-17 22:15:04 +00:00
sabretou
66013fe785 Translated using Weblate (Marathi)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/mr/
2025-12-17 01:55:12 +00:00
João Moura
890d0a6d41 Translated using Weblate (Portuguese (Portugal))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt_PT/
2025-12-16 16:55:08 +00:00
Jahidul Islam
63d9b03106 Translated using Weblate (Bengali (Bangladesh))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/bn_BD/
2025-12-16 11:55:08 +00:00
Chris
dd20b323eb Translated using Weblate (Swedish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sv/
2025-12-16 09:55:08 +00:00
HaloCelsius
20dcfb4398 Backport pull request #7135 from jellyfin-web/release-10.11.z
Fix incorrect quality text label in video player

Original-merge: 9a207e9ba9

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-12-15 17:50:21 -05:00
Anton Antonov
5bf657c57a Translated using Weblate (Bulgarian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/bg/
2025-12-15 20:55:08 +00:00
sabretou
5cf6b3e902 Translated using Weblate (Marathi)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/mr/
2025-12-15 16:55:09 +00:00
rimasx
10625a0360 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-12-15 11:55:09 +00:00
sabretou
b2703d0c59 Translated using Weblate (Marathi)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/mr/
2025-12-15 11:55:08 +00:00
Khalsior
d3a2115db5 Translated using Weblate (Italian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/it/
2025-12-15 11:55:08 +00:00
sabretou
01f1e97f3b Translated using Weblate (Marathi)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/mr/
2025-12-15 09:53:54 +00:00
alxhu
3909785249 Translated using Weblate (German)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/de/
2025-12-15 09:53:53 +00:00
Translation expert
3ca6229023 Translated using Weblate (Arabic)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ar/
2025-12-14 22:25:10 +00:00
yoga sree jagadam
c7ec17bf09 Translated using Weblate (Telugu)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/te/
2025-12-14 14:55:09 +00:00
Dino Horvat
f583c72e13 Translated using Weblate (Croatian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/hr/
2025-12-13 19:55:08 +00:00
Fabrizio Mansilla
d0e37f6373 Translated using Weblate (Spanish (Argentina))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/es_AR/
2025-12-13 06:55:09 +00:00
queeup
4b8f6d14a0 Translated using Weblate (Turkish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/tr/
2025-12-12 23:55:08 +00:00
dmitrylyzo
7ccc98ec7a Backport pull request #7415 from jellyfin-web/release-10.11.z
Fix the return value for 'behavior' property

Original-merge: e2ae48d8e5

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-12-12 13:32:38 -05:00
shempignon
e41ea5a293 Backport pull request #7409 from jellyfin-web/release-10.11.z
Fix subtitles need to be uploaded twice

Original-merge: bc39ee10ba

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-12-12 13:32:37 -05:00
Björn Tenje Persson
193d610d0d Translated using Weblate (Swedish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sv/
2025-12-12 13:55:07 +00:00
Björn Tenje Persson
1985c7deb7 Translated using Weblate (Swedish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sv/
2025-12-12 12:04:12 +00:00
Arnau Galofré
3b1509afc0 Translated using Weblate (Catalan)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ca/
2025-12-11 17:20:30 +00:00
Arnau Galofré
0e97d3a7f8 Translated using Weblate (Catalan)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ca/
2025-12-11 14:55:07 +00:00
Arnau Galofré
42e6180700 Translated using Weblate (Catalan)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ca/
2025-12-11 12:55:07 +00:00
Bill Thornton
bf72cd699a Merge pull request #7420 from thornbill/ded-refresher 2025-12-11 01:32:23 -05:00
Bill Thornton
6ba590b59d Remove dead itemsrefresher class 2025-12-10 17:36:14 -05:00
thornbill
dc7226eeea Backport pull request #7411 from jellyfin-web/release-10.11.z
Fix browser detection overwrites

Original-merge: 6bfff061ce

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-12-10 09:56:14 -05:00
Bill Thornton
46ddff668b Merge pull request #7412 from thornbill/download-sdk 2025-12-10 01:51:56 -05:00
Veldermon-rbg
ca3cf922f9 Translated using Weblate (Maori)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/mi/
2025-12-10 01:55:08 +00:00
dodog
122e6aadeb Translated using Weblate (Slovak)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sk/
2025-12-10 01:55:07 +00:00
Veldermon-rbg
2abce18d1a Added translation using Weblate (Maori) 2025-12-10 00:29:33 +00:00
Milo Ivir
0769eec314 Translated using Weblate (Croatian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/hr/
2025-12-09 23:55:07 +00:00
Bill Thornton
3e93be0292 Use sdk for item download url 2025-12-09 18:22:13 -05:00
TidosDK
364841c24c Translated using Weblate (Danish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/da/
2025-12-09 21:55:07 +00:00
Bill Thornton
15644cb097 Merge pull request #7406 from jellyfin/renovate/jellyfin-sdk-0.x
Update dependency @jellyfin/sdk to v0.0.0-unstable.202512091852
2025-12-09 14:27:14 -05:00
renovate[bot]
19ba529a70 Update dependency @jellyfin/sdk to v0.0.0-unstable.202512091852 2025-12-09 19:17:39 +00:00
psaintmalo
6d8dce739b Translated using Weblate (Spanish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/es/
2025-12-09 15:55:08 +00:00
Stefán Örvar Sigmundsson
da336b75be Translated using Weblate (English (United Kingdom))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/en_GB/
2025-12-09 15:55:07 +00:00
millallo
c9e04a33f5 Translated using Weblate (Italian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/it/
2025-12-09 12:55:09 +00:00
Andi Chandler
e2c38ae3f1 Translated using Weblate (English (United Kingdom))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/en_GB/
2025-12-09 12:55:09 +00:00
MrPlow
32de578f50 Translated using Weblate (German)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/de/
2025-12-09 12:55:08 +00:00
Gargotaire
768dba60bd Translated using Weblate (Catalan)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ca/
2025-12-09 07:33:17 +00:00
Thadah D. Denyse
0a6309de44 Translated using Weblate (Basque)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/eu/
2025-12-08 22:55:08 +00:00
無情天
225ca1fd41 Translated using Weblate (Chinese (Simplified Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hans/
2025-12-08 22:55:08 +00:00
Bill Thornton
46223f4af3 Merge pull request #7382 from thornbill/theme-structure
Refactor themes structure
2025-12-08 11:51:55 -05:00
Bill Thornton
d3793f02eb Refactor themes structure 2025-12-08 11:40:03 -05:00
nenadsuperzmaj
7bbfa02c4a Translated using Weblate (Serbian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sr/
2025-12-08 14:55:07 +00:00
Thunderstrike116
3b5b1345fa Translated using Weblate (Greek)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/el/
2025-12-08 10:55:07 +00:00
st7105
eafc65c508 Translated using Weblate (Russian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ru/
2025-12-08 08:55:08 +00:00
stelle
23b4ed4fee Translated using Weblate (Malay)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ms/
2025-12-08 08:55:07 +00:00
Fjuro
6cb53c7c13 Translated using Weblate (Czech)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/cs/
2025-12-07 23:55:07 +00:00
Bas
21f708d3f4 Translated using Weblate (Dutch)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/nl/
2025-12-07 21:52:43 +00:00
Kityn
56275a3a7b Translated using Weblate (Polish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pl/
2025-12-07 14:55:07 +00:00
Lukáš Kucharczyk
2a10d11253 Translated using Weblate (Czech)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/cs/
2025-12-07 08:09:43 +00:00
Bill Thornton
5df49ca297 Merge pull request #7279 from thornbill/item-details-padding 2025-12-06 18:16:53 -05:00
Bill Thornton
b527141177 Merge pull request #7342 from thornbill/directory-browser-cleanup 2025-12-06 18:16:31 -05:00
Bill Thornton
269590adb5 Merge pull request #7389 from thornbill/refactor-item-actions 2025-12-06 18:15:14 -05:00
Bill Thornton
2f16a16375 Merge pull request #7393 from thornbill/experimental-default 2025-12-06 18:14:40 -05:00
Bill Thornton
762f95cb72 Merge pull request #7394 from thornbill/experimental-links 2025-12-06 18:13:58 -05:00
Bill Thornton
ecb7a35425 Merge pull request #7395 from thornbill/reference-framerate 2025-12-06 18:13:03 -05:00
Dan Tsivinsky
f7cebe7381 Translated using Weblate (Russian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ru/
2025-12-06 19:25:12 +00:00
Bill Thornton
024ea6b1f7 Remove usages of deprecated framerate properties 2025-12-05 17:13:22 -05:00
Bill Thornton
ae1f025557 Add support for custom links in the experimental layout 2025-12-05 16:32:53 -05:00
Gargotaire
068f2e691b Translated using Weblate (Catalan)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ca/
2025-12-05 20:55:07 +00:00
Bill Thornton
86db4bd0e1 Update layout settings 2025-12-05 11:57:46 -05:00
Bill Thornton
4008ec04b9 Use experimental layout by default 2025-12-05 11:19:03 -05:00
Kityn
c32a3c8386 Translated using Weblate (Polish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pl/
2025-12-05 10:55:07 +00:00
Lukáš Kucharczyk
a8090af035 Translated using Weblate (Czech)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/cs/
2025-12-05 10:55:07 +00:00
Bill Thornton
7e4eb2f43f Merge pull request #6376 from imtsuki/add-hevc-186-check 2025-12-04 22:29:46 -05:00
Bill Thornton
3d721d9658 Merge pull request #5488 from kevgrig/issue5486
Pass the new player to nextTrack to allow mixed playlist playback
2025-12-04 18:04:01 -05:00
Bas
6c38e30e31 Translated using Weblate (Dutch)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/nl/
2025-12-04 22:55:07 +00:00
MaîtreGEEK
3a59ab4c32 Translated using Weblate (French)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/fr/
2025-12-04 22:55:07 +00:00
Bill Thornton
c24b7376f8 Merge pull request #7038 from nesnass/rn/slideshow-configurable-delay 2025-12-04 15:55:51 -05:00
Bill Thornton
a12dc57099 Update slideshow interval labels 2025-12-04 14:53:11 -05:00
Richard Nesnass
bef60736b0 Add translation keys to en-us.json 2025-12-04 13:43:08 -05:00
Richard Nesnass
a2fef21af2 New display setting to configure slideshow delay 2025-12-04 13:43:08 -05:00
Furqaan Dawood
c2df080ad8 Added translation using Weblate (Swahili) 2025-12-04 14:41:48 +00:00
Bill Thornton
56d23e13eb Fix vite path support 2025-12-03 17:53:19 -05:00
Bill Thornton
cb8b2836c2 Use enum for item actions 2025-12-03 17:31:12 -05:00
thornbill
7e663d57f1 Backport pull request #7387 from jellyfin-web/release-10.11.z
Fix card actions in experimental layout

Original-merge: 9f1370f242

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-12-03 16:21:39 -05:00
Bill Thornton
cd8f3c4831 Merge pull request #7386 from thornbill/base-theme-icon-color
Fix base theme icon color
2025-12-03 10:37:18 -05:00
Bill Thornton
7dc51349de Fix base theme icon color 2025-12-03 10:31:25 -05:00
theguymadmax
2ec16d73dc Backport pull request #7380 from jellyfin-web/release-10.11.z
Add help link to backups page

Original-merge: 69d169e45f

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-12-02 16:02:45 -05:00
Collin-Swish
6c372f61f1 Backport pull request #7379 from jellyfin-web/release-10.11.z
Fix duplicate years due to type mismatch

Original-merge: 6fba30a0a9

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-12-02 16:02:44 -05:00
thornbill
d9e4818c70 Backport pull request #7378 from jellyfin-web/release-10.11.z
Fix card and logo position for rtl languages

Original-merge: 06f5442fc9

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-12-02 16:02:42 -05:00
sildur
6c12efe202 Backport pull request #7376 from jellyfin-web/release-10.11.z
Bind PlayerChange before handlers to report correct isLocalPlayer

Original-merge: 4e9c2e71a9

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-12-02 16:02:41 -05:00
dkanada
3894236c46 Backport pull request #7344 from jellyfin-web/release-10.11.z
modify TOC and button layout in book player

Original-merge: 4bdc0fd974

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-12-02 16:02:40 -05:00
KSAm3lm
f11ca9584a Translated using Weblate (Arabic)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ar/
2025-12-02 15:55:07 +00:00
Hasan Abdulaal
e1c761031c Translated using Weblate (Arabic)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ar/
2025-12-02 11:55:08 +00:00
Prasanth Baskar
46616a1d25 Translated using Weblate (Tamil)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ta/
2025-12-02 02:07:34 +00:00
Bill Thornton
e7b7938841 Merge pull request #7347 from thornbill/base-theme-light 2025-12-01 15:54:57 -05:00
dodog
498efbe493 Translated using Weblate (Slovak)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sk/
2025-12-01 10:55:07 +00:00
rimasx
1b54ae6cac Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-11-29 20:32:05 +00:00
Jacky He
0efb74e0b7 Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant_HK/
2025-11-29 19:55:07 +00:00
Martín
468f3085d2 Added translation using Weblate (Occitan) 2025-11-28 20:01:22 +00:00
kallum
b575915f24 Translated using Weblate (English (United States))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/en_US/
2025-11-27 18:28:40 +00:00
John Doe
5bbcf2dd96 Translated using Weblate (Finnish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/fi/
2025-11-27 12:55:07 +00:00
John Doe
1f7ee737c3 Translated using Weblate (Finnish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/fi/
2025-11-27 11:19:19 +00:00
rimasx
96467b7c51 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-11-27 08:23:48 +00:00
kallum
e1d9abc3f9 Translated using Weblate (English (United States))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/en_US/
2025-11-27 04:55:08 +00:00
Bill Thornton
c272eba4f5 Fix base theme styles 2025-11-25 11:42:00 -05:00
dkanada
41bde8ecd8 Backport pull request #7345 from jellyfin-web/release-10.11.z
fix book playback in continue reading home section

Original-merge: 1686788be5

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-11-25 11:00:07 -05:00
Bill Thornton
a9ea556b15 Merge pull request #7089 from viown/ts-sdk-user
Migrate dashboard user pages to use TS SDK
2025-11-25 10:57:41 -05:00
binglebongledingledanglee
20fbb8f24e Translated using Weblate (Croatian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/hr/
2025-11-24 20:55:07 +00:00
Hestadgard
d9adbd6bc8 Translated using Weblate (Norwegian Bokmål)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/nb_NO/
2025-11-23 22:55:06 +00:00
Malik
c23b722426 Added translation using Weblate (Abkhazian) 2025-11-23 06:49:32 +00:00
Centauri
fa122fc15a Translated using Weblate (Danish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/da/
2025-11-22 14:55:06 +00:00
Bill Thornton
d9d44c49c1 Refactor light theme to use base theme 2025-11-21 17:57:27 -05:00
Oleksandr Yurov
4e4d211117 Translated using Weblate (Ukrainian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/uk/
2025-11-21 22:55:06 +00:00
Oatavandi
11860af730 Translated using Weblate (Tamil)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ta/
2025-11-21 17:55:06 +00:00
drip-droop
761bd31519 Translated using Weblate (Hindi)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/hi/
2025-11-20 13:55:08 +00:00
Bill Thornton
652682bbe1 Merge pull request #6151 from viown/fix-empty-message-box 2025-11-19 22:45:32 -05:00
Bill Thornton
3ec4b58ff8 Merge pull request #7343 from thornbill/base-theme
Refactor dark theme to base theme
2025-11-19 17:32:45 -05:00
Bill Thornton
0a9e08cdab Refactor dark theme to base theme 2025-11-19 16:55:12 -05:00
Bill Thornton
74240bd265 Merge pull request #7277 from thornbill/dark-theme-vars
Add css variable support for dark theme
2025-11-19 16:06:58 -05:00
viown
abcc625b60 Remove redundant check 2025-11-19 20:57:57 +03:00
viown
13c49d5144 Remove systemInfo from directory browser 2025-11-19 20:57:57 +03:00
viown
9673982c79 Add instruction 2025-11-19 20:57:57 +03:00
viown
a2516723d4 Hide empty message box if there isn't text 2025-11-19 20:57:57 +03:00
Bill Thornton
1a7fe7ece0 Merge pull request #7204 from jellyfin/renovate/ci-deps 2025-11-19 09:17:01 -05:00
Bill Thornton
699e749a49 Remove unused network share references 2025-11-19 09:09:31 -05:00
renovate[bot]
571e699b7e Update CI dependencies 2025-11-19 11:14:03 +00:00
Roi Gabay
6e06787a0a Translated using Weblate (Hebrew)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/he/
2025-11-18 21:55:06 +00:00
theguymadmax
2d7a38c9cc Backport pull request #7331 from jellyfin-web/release-10.11.z
Fix trickplay not displaying when content is played from a playlist

Original-merge: b807ebfa4a

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-11-17 12:13:17 -05:00
gnattu
a2e9231983 Backport pull request #7335 from jellyfin-web/release-10.11.z
Blacklist Firefox native mkv playback

Original-merge: f2d2c5b26e

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-11-17 11:14:19 -05:00
viown
573c31032d Backport pull request #7325 from jellyfin-web/release-10.11.z
Disable scan button when scanning

Original-merge: 3c6a5160a6

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-11-17 11:14:17 -05:00
theguymadmax
2a9be36c7b Backport pull request #7313 from jellyfin-web/release-10.11.z
Fix missing Live TV sections in experimental layout

Original-merge: 3d55ce3724

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-11-17 11:14:16 -05:00
viown
c796529544 Backport pull request #7312 from jellyfin-web/release-10.11.z
Enhance log viewer

Original-merge: e6141968d7

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-11-17 11:14:15 -05:00
viown
73d66d5612 Backport pull request #7306 from jellyfin-web/release-10.11.z
Add line break to card's secondary text

Original-merge: 1c8f221006

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-11-17 11:14:14 -05:00
thornbill
3dd636d520 Backport pull request #7301 from jellyfin-web/release-10.11.z
Fix first episode when playing a series

Original-merge: f077e294a9

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-11-17 11:14:12 -05:00
gnattu
0c9e61fdc2 Backport pull request #7298 from jellyfin-web/release-10.11.z
Relax dynamic HDR device Profile

Original-merge: f445e53f7e

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-11-17 11:14:11 -05:00
thornbill
463b3559b1 Backport pull request #7296 from jellyfin-web/release-10.11.z
Revert "Enable use of hls.js when LG WebOS 4 or newer is used."

Original-merge: 03c2cebbd3

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-11-17 11:14:10 -05:00
thornbill
d744639c22 Backport pull request #7295 from jellyfin-web/release-10.11.z
Fix default values in display settings

Original-merge: d1379dce8a

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-11-17 11:14:09 -05:00
theguymadmax
bf8b002142 Backport pull request #7290 from jellyfin-web/release-10.11.z
Fix library not refreshing after adding new media library

Original-merge: 21d7dd86ea

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-11-17 11:14:07 -05:00
kinke
e64e1f6535 Backport pull request #7283 from jellyfin-web/release-10.11.z
Restrict recently added max-32-streams limit to Tizen < v6.5

Original-merge: 39f971ffa4

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-11-17 11:14:06 -05:00
Gargotaire
23184b3e18 Translated using Weblate (Catalan)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ca/
2025-11-17 08:55:06 +00:00
Justin
611f9eb962 Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant_HK/
2025-11-16 17:10:05 +00:00
hoanghuy309
0bec3f2577 Translated using Weblate (Vietnamese)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/vi/
2025-11-15 06:48:17 +00:00
hoanghuy309
f1e6d8726c Translated using Weblate (Vietnamese)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/vi/
2025-11-15 03:25:52 +00:00
Gopinath Muruti
1a8e6d4add Translated using Weblate (Malay)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ms/
2025-11-15 02:16:51 +00:00
rimasx
026349babb Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-11-13 10:55:05 +00:00
Grant Alexander
e1724f5ec1 Translated using Weblate (English (United States))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/en_US/
2025-11-12 23:22:20 +00:00
gregcarmo
7e89b5f564 Translated using Weblate (Portuguese (Brazil))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt_BR/
2025-11-12 17:55:05 +00:00
Gargotaire
77df7d5d85 Translated using Weblate (Catalan)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ca/
2025-11-11 22:55:05 +00:00
Grant Alexander
afb4c4c830 Translated using Weblate (English (United States))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/en_US/
2025-11-11 18:32:09 +00:00
Hunam (Elie TD)
1295d5592e Translated using Weblate (English (United States))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/en_US/
2025-11-09 10:56:44 +00:00
Jacky He
c70fd69315 Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant_HK/
2025-11-08 14:08:05 +00:00
rimasx
d4ff5a27de Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-11-08 11:28:10 +00:00
rimasx
acf7b6889d Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-11-06 15:55:05 +00:00
Gargotaire
1d1542c446 Translated using Weblate (Catalan)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ca/
2025-11-05 23:55:05 +00:00
Jacky He
c5c5fc6743 Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant_HK/
2025-11-05 19:55:06 +00:00
Jacky He
20ce3da458 Translated using Weblate (Chinese (Simplified Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hans/
2025-11-05 19:55:06 +00:00
st7105
d1df6ae42b Translated using Weblate (Russian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ru/
2025-11-05 10:51:53 +00:00
Andrew
220ff70d30 Translated using Weblate (English (United States))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/en_US/
2025-11-04 23:51:54 +00:00
Jacky He
0427ae6bb1 Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant_HK/
2025-11-04 21:51:53 +00:00
Gargotaire
702412a425 Translated using Weblate (Catalan)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ca/
2025-11-04 19:51:53 +00:00
Gargotaire
f4883e19a8 Translated using Weblate (Catalan)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ca/
2025-11-03 22:51:53 +00:00
Dariottolo
2fae712ea9 Translated using Weblate (Italian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/it/
2025-11-03 19:51:53 +00:00
AfmanS
0a545e1c7c Translated using Weblate (Portuguese (Portugal))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt_PT/
2025-11-03 14:51:53 +00:00
nyanmisaka
530df76316 Backport pull request #7274 from jellyfin-web/release-10.11.z
Use hls.js instead of native HLS in Chromium

Original-merge: 2c45c5ba4a

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: Joshua M. Boniface <joshua@boniface.me>
2025-11-02 21:59:42 -05:00
viown
5b85e0e0ac Backport pull request #7272 from jellyfin-web/release-10.11.z
Use legacy grid for item counts widget

Original-merge: 7491722364

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: Joshua M. Boniface <joshua@boniface.me>
2025-11-02 21:59:41 -05:00
viown
b517c27bf6 Backport pull request #7271 from jellyfin-web/release-10.11.z
Fix tuner devices list

Original-merge: cdde002ca6

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: Joshua M. Boniface <joshua@boniface.me>
2025-11-02 21:59:40 -05:00
viown
7be11bc9d9 Backport pull request #7269 from jellyfin-web/release-10.11.z
Fix JSON in log viewer

Original-merge: 6e2c62525a

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: Joshua M. Boniface <joshua@boniface.me>
2025-11-02 21:59:39 -05:00
nielsvanvelzen
af10633a7d Backport pull request #7259 from jellyfin-web/release-10.11.z
Avoid native browser alerts

Original-merge: 907947c523

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: Joshua M. Boniface <joshua@boniface.me>
2025-11-02 21:59:38 -05:00
nielsvanvelzen
91cfc15e1c Backport pull request #7258 from jellyfin-web/release-10.11.z
Add Titan OS detection

Original-merge: e102334812

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: Joshua M. Boniface <joshua@boniface.me>
2025-11-02 21:59:37 -05:00
皇甫朝云
2687b3daf1 Translated using Weblate (Chinese (Simplified Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hans/
2025-11-02 12:51:53 +00:00
Jacky He
de2fe3f52d Translated using Weblate (Chinese (Traditional Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant/
2025-10-31 21:51:54 +00:00
Jacky He
9b2ce2886e Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant_HK/
2025-10-31 21:51:53 +00:00
Bill Thornton
0a9ddb490d Use consistent padding on item details screen 2025-10-31 14:22:06 -04:00
Bill Thornton
99fea57a95 Add css var support for dark theme 2025-10-31 09:41:04 -04:00
無情天
ac2d059219 Translated using Weblate (Chinese (Simplified Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hans/
2025-10-31 11:29:12 +00:00
Jacky He
134848d082 Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant_HK/
2025-10-30 22:24:50 +00:00
Jacky He
95da2b3868 Translated using Weblate (Chinese (Traditional Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant/
2025-10-30 20:52:01 +00:00
Jacky He
d1c1f74763 Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant_HK/
2025-10-30 20:52:00 +00:00
Jacky He
ca6204518a Translated using Weblate (Chinese (Simplified Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hans/
2025-10-30 20:51:53 +00:00
Jacky He
4c88d6b28b Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant_HK/
2025-10-30 19:34:57 +00:00
Jacky He
7edcf9501e Translated using Weblate (Chinese (Traditional Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant/
2025-10-30 19:34:52 +00:00
Jacky He
741babbbc3 Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant_HK/
2025-10-30 19:34:51 +00:00
Jacky He
3616f5b81e Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant_HK/
2025-10-30 18:51:54 +00:00
Jacky He
5857c02921 Translated using Weblate (Chinese (Traditional Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant/
2025-10-30 16:51:56 +00:00
Jacky He
1aa990f1bf Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant_HK/
2025-10-30 16:51:55 +00:00
Jacky He
bd8d0e786d Translated using Weblate (Chinese (Simplified Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hans/
2025-10-30 16:51:53 +00:00
Jacky He
d46c34a901 Translated using Weblate (Chinese (Traditional Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant/
2025-10-30 15:48:36 +00:00
Jacky He
c02c9690a6 Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant_HK/
2025-10-30 15:48:35 +00:00
Jacky He
04df0b9106 Translated using Weblate (Chinese (Simplified Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hans/
2025-10-30 15:48:33 +00:00
Jacky He
90744a57ba Translated using Weblate (Chinese (Traditional Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant/
2025-10-29 23:51:57 +00:00
Jacky He
78926c2bea Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant_HK/
2025-10-29 23:51:57 +00:00
Jacky He
76b704d897 Translated using Weblate (Chinese (Simplified Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hans/
2025-10-29 23:51:53 +00:00
Jacky He
5648423c12 Translated using Weblate (Chinese (Traditional Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant/
2025-10-29 22:47:06 +00:00
Jacky He
f1cb49ec38 Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant_HK/
2025-10-29 22:47:05 +00:00
Jacky He
2454034d3f Translated using Weblate (Chinese (Simplified Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hans/
2025-10-29 22:46:54 +00:00
Andi Chandler
8a6a97a437 Translated using Weblate (English (United Kingdom))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/en_GB/
2025-10-29 22:46:54 +00:00
Battseren Badral
81663eec15 Translated using Weblate (Mongolian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/mn/
2025-10-29 20:51:57 +00:00
Jacky He
d74a148db4 Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant_HK/
2025-10-29 20:51:54 +00:00
Rogal
a75f1124a6 Translated using Weblate (Polish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pl/
2025-10-29 20:51:53 +00:00
Grant Alexander
4da2be5038 Translated using Weblate (English (United States))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/en_US/
2025-10-29 15:19:29 +00:00
Grant Alexander
cbac146558 Translated using Weblate (English (United States))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/en_US/
2025-10-28 19:51:55 +00:00
Battseren Badral
837f4e7479 Translated using Weblate (Mongolian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/mn/
2025-10-28 19:51:53 +00:00
Grant Alexander
625ab3fede Added translation using Weblate (English (United States)) 2025-10-28 18:28:04 +00:00
Battseren Badral
290f8285c7 Translated using Weblate (Mongolian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/mn/
2025-10-28 18:28:03 +00:00
Jacky He
13f35d0e4b Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant_HK/
2025-10-27 22:51:57 +00:00
Pascal Wiesmann
918af8fa65 Translated using Weblate (Alemannic)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/gsw/
2025-10-27 22:51:52 +00:00
bernarden
d168815ba4 Backport pull request #7252 from jellyfin-web/release-10.11.z
Wraps registration of all mediaSession action handlers in try catch.

Original-merge: 7643885c6b

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: Joshua M. Boniface <joshua@boniface.me>
2025-10-27 15:47:14 -04:00
thornbill
8026ae3137 Backport pull request #7248 from jellyfin-web/release-10.11.z
Fix multiple album artists in card footer

Original-merge: 7c0c2e088f

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: Joshua M. Boniface <joshua@boniface.me>
2025-10-27 15:47:13 -04:00
PeachesMLG
bef489cba4 Backport pull request #7245 from jellyfin-web/release-10.11.z
Fixed issue where waiting event is not being called correctly

Original-merge: 0989a3034f

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: Joshua M. Boniface <joshua@boniface.me>
2025-10-27 15:47:11 -04:00
thornbill
1d02d2e9d2 Backport pull request #7241 from jellyfin-web/release-10.11.z
Revert scroller overflow change for tv layout

Original-merge: 4b072633fb

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: Joshua M. Boniface <joshua@boniface.me>
2025-10-27 15:47:10 -04:00
thornbill
8c1958db46 Backport pull request #7240 from jellyfin-web/release-10.11.z
Handle browsers lacking stop media session action support

Original-merge: f7583a842b

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: Joshua M. Boniface <joshua@boniface.me>
2025-10-27 15:47:09 -04:00
viown
c778f9cc2c Backport pull request #7233 from jellyfin-web/release-10.11.z
Increase restore check interval to 45s

Original-merge: 4f83e97592

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: Joshua M. Boniface <joshua@boniface.me>
2025-10-27 15:47:08 -04:00
PeachesMLG
ebf2e85af8 Backport pull request #7227 from jellyfin-web/release-10.11.z
Fix unpause and pause references in syncplay video player

Original-merge: 17a1e2e94c

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: Joshua M. Boniface <joshua@boniface.me>
2025-10-27 15:47:07 -04:00
theguymadmax
e30d2a324e Backport pull request #7224 from jellyfin-web/release-10.11.z
Enable backdrop image rotation in Firefox

Original-merge: 0bb8f7cb47

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: Joshua M. Boniface <joshua@boniface.me>
2025-10-27 15:47:05 -04:00
SohamGanmote
f69e509898 Backport pull request #7221 from jellyfin-web/release-10.11.z
Fix: Add minimum value 0 for SyncPlay Settings SpeedToSync input

Original-merge: 12079b9462

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: Joshua M. Boniface <joshua@boniface.me>
2025-10-27 15:47:04 -04:00
Tiago Filipe
a9955fcb35 Translated using Weblate (Portuguese)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt/
2025-10-27 18:51:53 +00:00
czlevi7
430e8fb98c Translated using Weblate (Romanian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ro/
2025-10-27 14:51:53 +00:00
Joshua M. Boniface
1e77ecffcc Merge pull request #7263 from joshuaboniface/codeowners
Update CODEOWNERS to capture bump_version
2025-10-27 09:24:10 -04:00
kreaxv
788475b7b8 Translated using Weblate (Mongolian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/mn/
2025-10-27 09:41:37 +00:00
Gargotaire
c72f093f88 Translated using Weblate (Catalan)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ca/
2025-10-27 07:51:53 +00:00
Joshua M. Boniface
b5fb51bfa9 Improve handling of .github dir 2025-10-26 22:17:42 -04:00
Joshua M. Boniface
ce2958351b Update CODEOWNERS to capture bump_version 2025-10-26 22:12:03 -04:00
Starbuck
54a828c123 Translated using Weblate (Hebrew)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/he/
2025-10-26 21:48:14 +00:00
j9x49
0cd8dcf946 Translated using Weblate (Hungarian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/hu/
2025-10-26 13:11:37 +00:00
MrPlow
c466497733 Translated using Weblate (German)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/de/
2025-10-26 13:11:35 +00:00
rimasx
813f0a6399 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-26 11:51:52 +00:00
皇甫朝云
9d17ef0dce Translated using Weblate (Chinese (Simplified Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hans/
2025-10-26 06:51:53 +00:00
Battseren Badral
b94f7021dc Translated using Weblate (Mongolian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/mn/
2025-10-25 17:51:54 +00:00
rimasx
29bd1a2dc8 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-25 06:51:52 +00:00
HanHwanHo
95f910cc6a Translated using Weblate (Korean)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ko/
2025-10-24 21:51:54 +00:00
RickThaD
531ceedcbb Translated using Weblate (French (Canada))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/fr_CA/
2025-10-24 15:54:17 +00:00
Vilhelm Prytz
d2afde2e01 Translated using Weblate (Swedish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sv/
2025-10-23 15:18:17 +00:00
rimasx
860fbbf371 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-23 12:51:26 +00:00
Lukáš Kucharczyk
37432ff513 Translated using Weblate (Czech)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/cs/
2025-10-23 12:51:26 +00:00
Dan Johansen
0a14b8212d Translated using Weblate (Danish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/da/
2025-10-23 09:04:23 +00:00
Anrijs Vitolins
068a42c5bf Translated using Weblate (Latvian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/lv/
2025-10-22 09:51:26 +00:00
Rostyslav
7f2bd12e98 Translated using Weblate (Ukrainian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/uk/
2025-10-22 06:07:25 +00:00
hoanghuy309
6ae937eab4 Translated using Weblate (Vietnamese)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/vi/
2025-10-22 02:51:26 +00:00
ZJC-GH
535104ac80 Translated using Weblate (Chinese (Traditional Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hant/
2025-10-21 19:51:27 +00:00
ZJC-GH
1915ad08e2 Translated using Weblate (Chinese (Simplified Han script))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/zh_Hans/
2025-10-21 19:51:26 +00:00
thornbill
01e1345a89 Backport pull request #7219 from jellyfin-web/release-10.11.z
Fix skip button not displaying correctly with OSD (#6583)

Original-merge: 81698d5da7

Merged-by: thornbill <thornbill@users.noreply.github.com>

Backported-by: thornbill <thornbill@users.noreply.github.com>
2025-10-21 15:51:05 -04:00
Jonas Engen Larsen
258ee7bacf Translated using Weblate (Norwegian Bokmål)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/nb_NO/
2025-10-21 15:20:50 +00:00
Grant Alexander
48eee02ead Translated using Weblate (Spanish (Mexico))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/es_MX/
2025-10-21 15:20:49 +00:00
maayan cohen
64870f9247 Translated using Weblate (Hebrew)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/he/
2025-10-21 10:31:17 +00:00
nextlooper42
7ff6490028 Translated using Weblate (Slovak)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sk/
2025-10-21 09:51:26 +00:00
hoanghuy309
a7d5b09bbd Translated using Weblate (Vietnamese)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/vi/
2025-10-21 06:24:41 +00:00
hoanghuy309
cc933c6678 Translated using Weblate (Vietnamese)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/vi/
2025-10-21 05:33:07 +00:00
Thunderstrike116
751985cab3 Translated using Weblate (Greek)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/el/
2025-10-20 23:19:34 +00:00
Bill Thornton
e956a0a635 Merge pull request #7203 from jellyfin/renovate/jellyfin-sdk-0.x
Update dependency @jellyfin/sdk to v0.0.0-unstable.202510201847
2025-10-20 15:00:21 -04:00
renovate[bot]
57a139d80c Update dependency @jellyfin/sdk to v0.0.0-unstable.202510201847 2025-10-20 18:50:48 +00:00
MrPlow
f701a1fcbd Translated using Weblate (German)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/de/
2025-10-20 18:45:46 +00:00
Thadah D. Denyse
d0341fb3d8 Translated using Weblate (Basque)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/eu/
2025-10-20 16:20:17 +00:00
Thunderstrike116
c8fd928167 Translated using Weblate (Greek)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/el/
2025-10-20 16:20:17 +00:00
Thadah D. Denyse
a8c93b3394 Translated using Weblate (Basque)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/eu/
2025-10-20 14:30:04 +00:00
Thadah D. Denyse
b5db940fc3 Translated using Weblate (Basque)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/eu/
2025-10-20 14:29:50 +00:00
Romulo Alves
505cc64ef6 Translated using Weblate (Portuguese (Brazil))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt_BR/
2025-10-20 13:51:26 +00:00
Aindriú Mac Giolla Eoin
24477e8025 Translated using Weblate (Irish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ga/
2025-10-20 12:10:13 +00:00
faquino
23097a4502 Translated using Weblate (Galician)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/gl/
2025-10-20 10:51:27 +00:00
faquino
54bfe07a01 Translated using Weblate (Spanish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/es/
2025-10-20 10:51:27 +00:00
hoanghuy309
5464ee5ba4 Translated using Weblate (Vietnamese)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/vi/
2025-10-20 08:39:58 +00:00
nenadsuperzmaj
9e284cc93e Translated using Weblate (Serbian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sr/
2025-10-20 07:51:27 +00:00
st7105
9741d0c603 Translated using Weblate (Russian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ru/
2025-10-20 07:51:26 +00:00
Yohann Nizon
6441aa0269 Translated using Weblate (French)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/fr/
2025-10-20 07:51:26 +00:00
Gargotaire
4373c8b058 Translated using Weblate (Catalan)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ca/
2025-10-20 07:51:26 +00:00
Jellyfin Release Bot
4c14a8b529 Bump version to 10.12.0 2025-10-19 20:45:17 -04:00
viown
8a7148164b Update src/apps/dashboard/routes/users/index.tsx
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-18 00:36:44 +03:00
viown
780905e670 Replace Dashboard.navigate with react router navigate 2025-10-18 00:32:40 +03:00
viown
b0c9e11404 Add abort signal to hooks 2025-10-18 00:29:05 +03:00
viown
fee9b12f1b Add libraryMenu as a dependency 2025-10-18 00:28:45 +03:00
viown
f510ad5874 Move user pages to TS SDK 2025-10-18 00:28:44 +03:00
kevgrig
2cdbbd3f2d Merge branch 'master' into issue5486 2025-07-10 21:40:51 -05:00
Kevin G
5c8c86b766 Update based on feedback
Signed-off-by: Kevin G <kevin@myplaceonline.com>
2025-07-10 21:40:30 -05:00
Kevin G
4cccd63831 Issue #5486: When changing player types, destroy old player
Signed-off-by: Kevin G <kevin@myplaceonline.com>
2025-04-14 12:28:11 -04:00
imtsuki
c1161c7c5a Add hevc level 6.2 check
Signed-off-by: imtsuki <me@qjx.app>
2024-12-13 20:37:00 +08:00
195 changed files with 4088 additions and 3028 deletions

4
.github/CODEOWNERS vendored
View File

@@ -1 +1,5 @@
* @jellyfin/web
# Joshua must review all changes to bump_version
bump_version @joshuaboniface
# Core must approve all changes within the repo config
.github/ @jellyfin/core

View File

@@ -20,21 +20,21 @@ jobs:
steps:
- name: Checkout repository ⬇️
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ inputs.commit }}
show-progress: false
- name: Initialize CodeQL 🛠️
uses: github/codeql-action/init@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
queries: security-and-quality
languages: ${{ matrix.language }}
- name: Autobuild 📦
uses: github/codeql-action/autobuild@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
- name: Perform CodeQL Analysis 🧪
uses: github/codeql-action/analyze@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
category: '/language:${{matrix.language}}'

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ inputs.commit || github.sha }}

View File

@@ -14,13 +14,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ inputs.commit }}
show-progress: false
- name: Scan
uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
with:
## Workaround from https://github.com/actions/dependency-review-action/issues/456
## TODO: Remove when necessary
@@ -42,7 +42,7 @@ jobs:
steps:
- name: Checkout ⬇️
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ inputs.commit }}
show-progress: false

View File

@@ -5,7 +5,7 @@ concurrency:
cancel-in-progress: true
on:
pull_request:
pull_request_target:
branches:
- master
- release*
@@ -80,7 +80,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ github.event.pull_request.head.sha }}
@@ -95,6 +95,6 @@ jobs:
run: npm ci --no-audit
- name: Run eslint
uses: CatChen/eslint-suggestion-action@4dda35decf912ab18ea3e071acec2c6c2eda00b6 # v4.1.18
uses: CatChen/eslint-suggestion-action@4ee415529307a8ca0260b4a3775484802523e5af # v4.1.19
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -103,8 +103,6 @@
- [TheBosZ](https://github.com/thebosz)
- [qm3jp](https://github.com/qm3jp)
- [johnnyg](https://github.com/johnnyg)
- [lmaotrigine](https://github.com/lmaotrigine)
- [bjorntp](https://github.com/bjorntp)
## Emby Contributors

92
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jellyfin-web",
"version": "10.11.8",
"version": "10.12.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "jellyfin-web",
"version": "10.11.8",
"version": "10.12.0",
"license": "GPL-2.0-or-later",
"dependencies": {
"@emotion/react": "11.14.0",
@@ -18,7 +18,7 @@
"@fontsource/noto-sans-sc": "5.2.6",
"@fontsource/noto-sans-tc": "5.2.6",
"@jellyfin/libass-wasm": "4.2.3",
"@jellyfin/sdk": "0.12.0",
"@jellyfin/sdk": "0.0.0-unstable.202512091852",
"@jellyfin/ux-web": "1.0.0",
"@mui/icons-material": "6.4.12",
"@mui/material": "6.4.12",
@@ -129,6 +129,7 @@
"ts-loader": "9.5.2",
"typescript": "5.8.3",
"typescript-eslint": "8.35.1",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack": "5.99.9",
"webpack-bundle-analyzer": "4.10.2",
@@ -4129,9 +4130,9 @@
"license": "LGPL-2.1-or-later AND (FTL OR GPL-2.0-or-later) AND MIT AND MIT-Modern-Variant AND ISC AND NTP AND Zlib AND BSL-1.0"
},
"node_modules/@jellyfin/sdk": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.12.0.tgz",
"integrity": "sha512-do3cks7TD316Qw27lBMHZQ7ufaS1MC8HMsQF5rFv5/DUInuwEOqWthqVyHl3sIjThOThF1zxQyE6OdpUl0dNUg==",
"version": "0.0.0-unstable.202512091852",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202512091852.tgz",
"integrity": "sha512-N+QEsrKk4KculkV6KMBb7XpzTLWcXEzqTHbS+b4rov0VYVwR6DIsJkmUzB3hM2YZsrLIHEFKhFRy/r4itkFeHw==",
"license": "MPL-2.0",
"peerDependencies": {
"axios": "^1.12.0"
@@ -12368,6 +12369,13 @@
"integrity": "sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM=",
"dev": true
},
"node_modules/globrex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
"dev": true,
"license": "MIT"
},
"node_modules/gonzales-pe": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz",
@@ -18232,7 +18240,6 @@
"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": {
@@ -23187,6 +23194,27 @@
"node": ">=8"
}
},
"node_modules/tsconfck": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
"integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==",
"dev": true,
"license": "MIT",
"bin": {
"tsconfck": "bin/tsconfck.js"
},
"engines": {
"node": "^18 || >=20"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -23837,6 +23865,26 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-tsconfig-paths": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
"integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"globrex": "^0.1.2",
"tsconfck": "^3.0.3"
},
"peerDependencies": {
"vite": "*"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
}
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
@@ -27102,9 +27150,9 @@
"integrity": "sha512-C0OlBxIr9NdeFESMTA/OVDqNSWtog6Mi7wwzwH12xbZpxsMD0RgCupUcIP7zZgcpTNecW3fZq5d6xVo7Q8HEJw=="
},
"@jellyfin/sdk": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.12.0.tgz",
"integrity": "sha512-do3cks7TD316Qw27lBMHZQ7ufaS1MC8HMsQF5rFv5/DUInuwEOqWthqVyHl3sIjThOThF1zxQyE6OdpUl0dNUg==",
"version": "0.0.0-unstable.202512091852",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202512091852.tgz",
"integrity": "sha512-N+QEsrKk4KculkV6KMBb7XpzTLWcXEzqTHbS+b4rov0VYVwR6DIsJkmUzB3hM2YZsrLIHEFKhFRy/r4itkFeHw==",
"requires": {}
},
"@jellyfin/ux-web": {
@@ -32684,6 +32732,12 @@
"integrity": "sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM=",
"dev": true
},
"globrex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
"dev": true
},
"gonzales-pe": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz",
@@ -40118,6 +40172,13 @@
}
}
},
"tsconfck": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
"integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==",
"dev": true,
"requires": {}
},
"tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -40559,6 +40620,17 @@
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
}
},
"vite-tsconfig-paths": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
"integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==",
"dev": true,
"requires": {
"debug": "^4.1.1",
"globrex": "^0.1.2",
"tsconfck": "^3.0.3"
}
},
"vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "jellyfin-web",
"version": "10.11.8",
"version": "10.12.0",
"description": "Web interface for Jellyfin",
"repository": "https://github.com/jellyfin/jellyfin-web",
"license": "GPL-2.0-or-later",
@@ -66,6 +66,7 @@
"ts-loader": "9.5.2",
"typescript": "5.8.3",
"typescript-eslint": "8.35.1",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack": "5.99.9",
"webpack-bundle-analyzer": "4.10.2",
@@ -84,7 +85,7 @@
"@fontsource/noto-sans-sc": "5.2.6",
"@fontsource/noto-sans-tc": "5.2.6",
"@jellyfin/libass-wasm": "4.2.3",
"@jellyfin/sdk": "0.12.0",
"@jellyfin/sdk": "0.0.0-unstable.202512091852",
"@jellyfin/ux-web": "1.0.0",
"@mui/icons-material": "6.4.12",
"@mui/material": "6.4.12",
@@ -161,7 +162,7 @@
"build:check": "tsc --noEmit",
"build:es-check": "npm run build:production && npm run escheck",
"escheck": "es-check",
"lint": "eslint \"./\"",
"lint": "eslint",
"test": "vitest --watch=false --config vite.config.ts",
"test:watch": "vitest --config vite.config.ts",
"stylelint": "stylelint \"src/**/*.{css,scss}\""

View File

@@ -13,13 +13,16 @@ import { STABLE_APP_ROUTES } from 'apps/stable/routes/routes';
import { WIZARD_APP_ROUTES } from 'apps/wizard/routes/routes';
import AppHeader from 'components/AppHeader';
import Backdrop from 'components/Backdrop';
import { SETTING_KEY as LAYOUT_SETTING_KEY } from 'components/layoutManager';
import BangRedirect from 'components/router/BangRedirect';
import { createRouterHistory } from 'components/router/routerHistory';
import appTheme from 'themes/themes';
import { LayoutMode } from 'constants/layoutMode';
import browser from 'scripts/browser';
import appTheme from 'themes';
import { ThemeStorageManager } from 'themes/themeStorageManager';
const layoutMode = localStorage.getItem('layout');
const isExperimentalLayout = layoutMode === 'experimental';
const layoutMode = browser.tv ? LayoutMode.Tv : localStorage.getItem(LAYOUT_SETTING_KEY);
const isExperimentalLayout = !layoutMode || layoutMode === LayoutMode.Experimental;
const router = createHashRouter([
{

1
src/apiclient.d.ts vendored
View File

@@ -136,6 +136,7 @@ declare module 'jellyfin-apiclient' {
getInstantMixFromItem(itemId: string, options?: any): Promise<BaseItemDtoQueryResult>;
getIntros(itemId: string): Promise<BaseItemDtoQueryResult>;
getItemCounts(userId?: string): Promise<ItemCounts>;
/** @deprecated This function returns a URL with a legacy auth parameter.*/
getItemDownloadUrl(itemId: string): string;
getItemImageInfos(itemId: string): Promise<ImageInfo[]>;
getItems(userId: string, options?: any): Promise<BaseItemDtoQueryResult>;

View File

@@ -85,7 +85,7 @@ const BaseCard = ({
}
}}>
<Stack flexGrow={1} direction='row'>
<Stack flexGrow={1} sx={{ overflow: 'hidden' }}>
<Stack flexGrow={1}>
<Typography gutterBottom sx={{
overflow: 'hidden',
whiteSpace: 'nowrap',

View File

@@ -13,8 +13,6 @@ const QUERY_PARAMS = {
activeWithinSeconds: 960
};
const FALLBACK_POLL_INTERVAL_MS = 2000;
const useLiveSessions = () => {
const { __legacyApiClient__ } = useApi();
@@ -45,19 +43,10 @@ const useLiveSessions = () => {
queryClient.setQueryData([ QUERY_KEY, QUERY_PARAMS ], updateSessions(info));
};
const fallbackInterval = setInterval(() => {
if (!__legacyApiClient__?.isMessageChannelOpen()) {
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
}
}, FALLBACK_POLL_INTERVAL_MS);
__legacyApiClient__?.sendMessage(SessionMessageType.SessionsStart, '0,1500');
Events.on(serverNotifications, SessionMessageType.Sessions, onSessionsUpdate);
return () => {
clearInterval(fallbackInterval);
__legacyApiClient__?.sendMessage(SessionMessageType.SessionsStop, null);
Events.off(serverNotifications, SessionMessageType.Sessions, onSessionsUpdate);
};

View File

@@ -0,0 +1,21 @@
import { Api } from '@jellyfin/sdk';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
import type { AxiosRequestConfig } from 'axios';
const fetchAuthProviders = async (api: Api, options?: AxiosRequestConfig) => {
const response = await getSessionApi(api).getAuthProviders(options);
return response.data;
};
export const useAuthProviders = () => {
const { api } = useApi();
return useQuery({
queryKey: [ 'AuthProviders' ],
queryFn: ({ signal }) => fetchAuthProviders(api!, { signal }),
enabled: !!api
});
};

View File

@@ -0,0 +1,22 @@
import { Api } from '@jellyfin/sdk';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { getChannelsApi } from '@jellyfin/sdk/lib/utils/api/channels-api';
import { ChannelsApiGetChannelsRequest } from '@jellyfin/sdk/lib/generated-client/api/channels-api';
import type { AxiosRequestConfig } from 'axios';
const fetchChannels = async (api: Api, params?: ChannelsApiGetChannelsRequest, options?: AxiosRequestConfig) => {
const response = await getChannelsApi(api).getChannels(params, options);
return response.data;
};
export const useChannels = (params?: ChannelsApiGetChannelsRequest) => {
const { api } = useApi();
return useQuery({
queryKey: [ 'Channels' ],
queryFn: ({ signal }) => fetchChannels(api!, params, { signal }),
enabled: !!api
});
};

View File

@@ -0,0 +1,15 @@
import { UserApiCreateUserByNameRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
export const useCreateUser = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: UserApiCreateUserByNameRequest) => (
getUserApi(api!)
.createUserByName(params)
)
});
};

View File

@@ -0,0 +1,22 @@
import { UserApiDeleteUserRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { QUERY_KEY } from 'hooks/useUsers';
import { queryClient } from 'utils/query/queryClient';
export const useDeleteUser = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: UserApiDeleteUserRequest) => (
getUserApi(api!)
.deleteUser(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
}
});
};

View File

@@ -0,0 +1,22 @@
import { Api } from '@jellyfin/sdk';
import { LibraryApiGetMediaFoldersRequest } from '@jellyfin/sdk/lib/generated-client/api/library-api';
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import type { AxiosRequestConfig } from 'axios';
const fetchLibraryMediaFolders = async (api: Api, params?: LibraryApiGetMediaFoldersRequest, options?: AxiosRequestConfig) => {
const response = await getLibraryApi(api).getMediaFolders(params, options);
return response.data;
};
export const useLibraryMediaFolders = (params?: LibraryApiGetMediaFoldersRequest) => {
const { api } = useApi();
return useQuery({
queryKey: ['LibraryMediaFolders'],
queryFn: ({ signal }) => fetchLibraryMediaFolders(api!, params, { signal }),
enabled: !!api
});
};

View File

@@ -0,0 +1,22 @@
import { Api } from '@jellyfin/sdk';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
import type { AxiosRequestConfig } from 'axios';
import type { NetworkConfiguration } from '@jellyfin/sdk/lib/generated-client/models/network-configuration';
const fetchNetworkConfig = async (api: Api, options?: AxiosRequestConfig) => {
const response = await getConfigurationApi(api).getNamedConfiguration({ key: 'network' }, options);
return response.data as NetworkConfiguration;
};
export const useNetworkConfig = () => {
const { api } = useApi();
return useQuery({
queryKey: [ 'NetConfig' ],
queryFn: ({ signal }) => fetchNetworkConfig(api!, { signal }),
enabled: !!api
});
};

View File

@@ -0,0 +1,21 @@
import { Api } from '@jellyfin/sdk';
import { getLocalizationApi } from '@jellyfin/sdk/lib/utils/api/localization-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import type { AxiosRequestConfig } from 'axios';
const fetchParentalRatings = async (api: Api, options?: AxiosRequestConfig) => {
const response = await getLocalizationApi(api).getParentalRatings(options);
return response.data;
};
export const useParentalRatings = () => {
const { api } = useApi();
return useQuery({
queryKey: ['ParentalRatings'],
queryFn: ({ signal }) => fetchParentalRatings(api!, { signal }),
enabled: !!api
});
};

View File

@@ -0,0 +1,21 @@
import { Api } from '@jellyfin/sdk';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
import type { AxiosRequestConfig } from 'axios';
const fetchPasswordResetProviders = async (api: Api, options?: AxiosRequestConfig) => {
const response = await getSessionApi(api).getPasswordResetProviders(options);
return response.data;
};
export const usePasswordResetProviders = () => {
const { api } = useApi();
return useQuery({
queryKey: [ 'PasswordResetProviders' ],
queryFn: ({ signal }) => fetchPasswordResetProviders(api!, { signal }),
enabled: !!api
});
};

View File

@@ -0,0 +1,22 @@
import { UserApiUpdateUserRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QUERY_KEY } from './useUser';
export const useUpdateUser = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: UserApiUpdateUserRequest) => (
getUserApi(api!)
.updateUser(params)
),
onSuccess: (_, params) => {
void queryClient.invalidateQueries({
queryKey: [QUERY_KEY, params.userId]
});
}
});
};

View File

@@ -0,0 +1,23 @@
import { UserApiUpdateUserPolicyRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QUERY_KEY } from './useUser';
export const useUpdateUserPolicy = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: UserApiUpdateUserPolicyRequest) => (
getUserApi(api!)
.updateUserPolicy(params)
),
onSuccess: (_, params) => {
void queryClient.invalidateQueries({
queryKey: [QUERY_KEY, params.userId]
});
}
});
};

View File

@@ -0,0 +1,24 @@
import { Api } from '@jellyfin/sdk';
import { UserApiGetUserByIdRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import type { AxiosRequestConfig } from 'axios';
export const QUERY_KEY = 'User';
const fetchUser = async (api: Api, params: UserApiGetUserByIdRequest, options?: AxiosRequestConfig) => {
const response = await getUserApi(api).getUserById(params, options);
return response.data;
};
export const useUser = (params?: UserApiGetUserByIdRequest) => {
const { api } = useApi();
return useQuery({
queryKey: [ QUERY_KEY, params?.userId ],
queryFn: ({ signal }) => fetchUser(api!, params!, { signal }),
enabled: !!api && !!params
});
};

View File

@@ -98,166 +98,164 @@ export const Component = () => {
className='type-interior mainAnimatedPage'
>
<Box className='content-primary'>
<Stack spacing={2}>
<Stack
direction='row'
sx={{
flexWrap: {
xs: 'wrap',
sm: 'nowrap'
}
}}
{isError ? (
<Alert
severity='error'
sx={{ marginBottom: 2 }}
>
<Typography
variant='h1'
component='span'
{globalize.translate('PluginsLoadError')}
</Alert>
) : (
<Stack spacing={2}>
<Stack
direction='row'
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'
flexWrap: {
xs: 'wrap',
sm: 'nowrap'
}
}}
>
<SearchInput
label={globalize.translate('Search')}
value={searchQuery}
onChange={onSearchChange}
/>
</Box>
</Stack>
<Typography
variant='h1'
component='span'
sx={{
flexGrow: 1,
verticalAlign: 'middle'
}}
>
{globalize.translate('TabPlugins')}
</Typography>
{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')}
/>
<Button
component={Link}
to='/dashboard/plugins/repositories'
variant='outlined'
sx={{
marginLeft: 2
}}
>
{globalize.translate('ManageRepositories')}
</Button>
<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
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>
{filteredPlugins.length > 0 ? (
<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>
<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>
);

View File

@@ -1,7 +1,6 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import type { BaseItemDto, CreateUserByName } from '@jellyfin/sdk/lib/generated-client';
import React, { useCallback, useEffect, useState, useRef } from 'react';
import Dashboard from '../../../../utils/dashboard';
import globalize from '../../../../lib/globalize';
import loading from '../../../../components/loading/loading';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
@@ -12,10 +11,11 @@ import CheckBoxElement from '../../../../elements/CheckBoxElement';
import Page from '../../../../components/Page';
import Toast from 'apps/dashboard/components/Toast';
type UserInput = {
Name?: string;
Password?: string;
};
import { useLibraryMediaFolders } from 'apps/dashboard/features/users/api/useLibraryMediaFolders';
import { useChannels } from 'apps/dashboard/features/users/api/useChannels';
import { useUpdateUserPolicy } from 'apps/dashboard/features/users/api/useUpdateUserPolicy';
import { useCreateUser } from 'apps/dashboard/features/users/api/useCreateUser';
import { useNavigate } from 'react-router-dom';
type ItemsArr = {
Name?: string | null;
@@ -23,6 +23,7 @@ type ItemsArr = {
};
const UserNew = () => {
const navigate = useNavigate();
const [ channelsItems, setChannelsItems ] = useState<ItemsArr[]>([]);
const [ mediaFoldersItems, setMediaFoldersItems ] = useState<ItemsArr[]>([]);
const [ isErrorToastOpen, setIsErrorToastOpen ] = useState(false);
@@ -31,6 +32,11 @@ const UserNew = () => {
const handleToastClose = useCallback(() => {
setIsErrorToastOpen(false);
}, []);
const { data: mediaFolders, isSuccess: isMediaFoldersSuccess } = useLibraryMediaFolders();
const { data: channels, isSuccess: isChannelsSuccess } = useChannels();
const createUser = useCreateUser();
const updateUserPolicy = useUpdateUserPolicy();
const getItemsResult = (items: BaseItemDto[]) => {
return items.map(item =>
@@ -49,9 +55,7 @@ const UserNew = () => {
return;
}
const mediaFolders = getItemsResult(result);
setMediaFoldersItems(mediaFolders);
setMediaFoldersItems(getItemsResult(result));
const folderAccess = page.querySelector('.folderAccess') as HTMLDivElement;
folderAccess.dispatchEvent(new CustomEvent('create'));
@@ -67,15 +71,15 @@ const UserNew = () => {
return;
}
const channels = getItemsResult(result);
const channelItems = getItemsResult(result);
setChannelsItems(channels);
setChannelsItems(channelItems);
const channelAccess = page.querySelector('.channelAccess') as HTMLDivElement;
channelAccess.dispatchEvent(new CustomEvent('create'));
const channelAccessContainer = page.querySelector('.channelAccessContainer') as HTMLDivElement;
channels.length ? channelAccessContainer.classList.remove('hide') : channelAccessContainer.classList.add('hide');
channelItems.length ? channelAccessContainer.classList.remove('hide') : channelAccessContainer.classList.add('hide');
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).checked = false;
}, []);
@@ -87,22 +91,26 @@ const UserNew = () => {
console.error('Unexpected null reference');
return;
}
if (!mediaFolders?.Items) {
console.error('[add] mediaFolders not available');
return;
}
if (!channels?.Items) {
console.error('[add] channels not available');
return;
}
(page.querySelector('#txtUsername') as HTMLInputElement).value = '';
(page.querySelector('#txtPassword') as HTMLInputElement).value = '';
loadMediaFolders(mediaFolders?.Items);
loadChannels(channels?.Items);
loading.hide();
}, [loadChannels, loadMediaFolders, mediaFolders, channels]);
useEffect(() => {
loading.show();
const promiseFolders = window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', {
IsHidden: false
}));
const promiseChannels = window.ApiClient.getJSON(window.ApiClient.getUrl('Channels'));
Promise.all([promiseFolders, promiseChannels]).then(function (responses) {
loadMediaFolders(responses[0].Items);
loadChannels(responses[1].Items);
loading.hide();
}).catch(err => {
console.error('[usernew] failed to load data', err);
});
}, [loadChannels, loadMediaFolders]);
if (isMediaFoldersSuccess && isChannelsSuccess) {
loadUser();
}
}, [loadUser, isMediaFoldersSuccess, isChannelsSuccess]);
useEffect(() => {
const page = element.current;
@@ -112,51 +120,54 @@ const UserNew = () => {
return;
}
loadUser();
const saveUser = () => {
const userInput: UserInput = {};
userInput.Name = (page.querySelector('#txtUsername') as HTMLInputElement).value.trim();
userInput.Password = (page.querySelector('#txtPassword') as HTMLInputElement).value;
const userInput: CreateUserByName = {
Name: (page.querySelector('#txtUsername') as HTMLInputElement).value,
Password: (page.querySelector('#txtPassword') as HTMLInputElement).value
};
createUser.mutate({ createUserByName: userInput }, {
onSuccess: (response) => {
const user = response.data;
window.ApiClient.createUser(userInput).then(function (user) {
if (!user.Id || !user.Policy) {
throw new Error('Unexpected null user id or policy');
}
if (!user.Id || !user.Policy) {
throw new Error('Unexpected null user id or policy');
}
user.Policy.EnableAllFolders = (page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked;
user.Policy.EnabledFolders = [];
user.Policy.EnableAllFolders = (page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked;
user.Policy.EnabledFolders = [];
if (!user.Policy.EnableAllFolders) {
user.Policy.EnabledFolders = Array.prototype.filter.call(page.querySelectorAll('.chkFolder'), function (i) {
return i.checked;
}).map(function (i) {
return i.getAttribute('data-id');
});
}
user.Policy.EnableAllChannels = (page.querySelector('.chkEnableAllChannels') as HTMLInputElement).checked;
user.Policy.EnabledChannels = [];
if (!user.Policy.EnableAllChannels) {
user.Policy.EnabledChannels = Array.prototype.filter.call(page.querySelectorAll('.chkChannel'), function (i) {
return i.checked;
}).map(function (i) {
return i.getAttribute('data-id');
});
}
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
Dashboard.navigate('/dashboard/users/profile?userId=' + user.Id)
.catch(err => {
console.error('[usernew] failed to navigate to edit user page', err);
if (!user.Policy.EnableAllFolders) {
user.Policy.EnabledFolders = Array.prototype.filter.call(page.querySelectorAll('.chkFolder'), function (i) {
return i.checked;
}).map(function (i) {
return i.getAttribute('data-id');
});
}).catch(err => {
console.error('[usernew] failed to update user policy', err);
});
}, function () {
setIsErrorToastOpen(true);
loading.hide();
}
user.Policy.EnableAllChannels = (page.querySelector('.chkEnableAllChannels') as HTMLInputElement).checked;
user.Policy.EnabledChannels = [];
if (!user.Policy.EnableAllChannels) {
user.Policy.EnabledChannels = Array.prototype.filter.call(page.querySelectorAll('.chkChannel'), function (i) {
return i.checked;
}).map(function (i) {
return i.getAttribute('data-id');
});
}
updateUserPolicy.mutate({
userId: user.Id,
userPolicy: user.Policy
}, {
onSuccess: () => {
navigate(`/dashboard/users/profile?userId=${user.Id}`);
},
onError: () => {
console.error('[usernew] failed to update user policy');
setIsErrorToastOpen(true);
}
});
}
});
};
@@ -168,22 +179,32 @@ const UserNew = () => {
return false;
};
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
const enableAllChannelsChange = function (this: HTMLInputElement) {
const channelAccessListContainer = page.querySelector('.channelAccessListContainer') as HTMLDivElement;
this.checked ? channelAccessListContainer.classList.add('hide') : channelAccessListContainer.classList.remove('hide');
});
};
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
const enableAllFoldersChange = function (this: HTMLInputElement) {
const folderAccessListContainer = page.querySelector('.folderAccessListContainer') as HTMLDivElement;
this.checked ? folderAccessListContainer.classList.add('hide') : folderAccessListContainer.classList.remove('hide');
});
};
(page.querySelector('.newUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit);
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', function() {
const onCancelClick = () => {
window.history.back();
});
}, [loadUser]);
};
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).addEventListener('change', enableAllChannelsChange);
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).addEventListener('change', enableAllFoldersChange);
(page.querySelector('.newUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit);
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', onCancelClick);
return () => {
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).removeEventListener('change', enableAllChannelsChange);
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).removeEventListener('change', enableAllFoldersChange);
(page.querySelector('.newUserProfileForm') as HTMLFormElement).removeEventListener('submit', onSubmit);
(page.querySelector('#btnCancel') as HTMLButtonElement).removeEventListener('click', onCancelClick);
};
}, [loadUser, createUser, updateUserPolicy, navigate]);
return (
<Page

View File

@@ -1,10 +1,5 @@
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import Dashboard from '../../../../utils/dashboard';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import globalize from '../../../../lib/globalize';
import loading from '../../../../components/loading/loading';
import dom from '../../../../utils/dom';
import confirm from '../../../../components/confirm/confirm';
import UserCardBox from '../../../../components/dashboard/users/UserCardBox';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
@@ -14,8 +9,12 @@ import '../../../../components/cardbuilder/card.scss';
import '../../../../components/indicators/indicators.scss';
import '../../../../styles/flexstyles.scss';
import Page from '../../../../components/Page';
import { useLocation } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import Toast from 'apps/dashboard/components/Toast';
import { useUsers } from 'hooks/useUsers';
import Loading from 'components/loading/LoadingComponent';
import { useDeleteUser } from 'apps/dashboard/features/users/api/useDeleteUser';
import dom from 'utils/dom';
type MenuEntry = {
name?: string;
@@ -26,24 +25,15 @@ type MenuEntry = {
const UserProfiles = () => {
const location = useLocation();
const [ isSettingsSavedToastOpen, setIsSettingsSavedToastOpen ] = useState(false);
const [ users, setUsers ] = useState<UserDto[]>([]);
const element = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const { data: users, isPending } = useUsers();
const deleteUser = useDeleteUser();
const handleToastClose = useCallback(() => {
setIsSettingsSavedToastOpen(false);
}, []);
const loadData = () => {
loading.show();
window.ApiClient.getUsers().then(function (result) {
setUsers(result);
loading.hide();
}).catch(err => {
console.error('[userprofiles] failed to fetch users', err);
});
};
useEffect(() => {
const page = element.current;
@@ -57,8 +47,6 @@ const UserProfiles = () => {
return;
}
loadData();
const showUserMenu = (elem: HTMLElement) => {
const card = dom.parentWithClass(elem, 'card');
const userId = card?.getAttribute('data-userid');
@@ -99,28 +87,19 @@ const UserProfiles = () => {
callback: function (id: string) {
switch (id) {
case 'open':
Dashboard.navigate('/dashboard/users/profile?userId=' + userId)
.catch(err => {
console.error('[userprofiles] failed to navigate to user edit page', err);
});
navigate(`/dashboard/users/profile?userId=${userId}`);
break;
case 'access':
Dashboard.navigate('/dashboard/users/access?userId=' + userId)
.catch(err => {
console.error('[userprofiles] failed to navigate to user library page', err);
});
navigate(`/dashboard/users/access?userId=${userId}`);
break;
case 'parentalcontrol':
Dashboard.navigate('/dashboard/users/parentalcontrol?userId=' + userId)
.catch(err => {
console.error('[userprofiles] failed to navigate to parental control page', err);
});
navigate(`/dashboard/users/parentalcontrol?userId=${userId}`);
break;
case 'delete':
deleteUser(userId, username);
confirmDeleteUser(userId, username);
}
}
}).catch(() => {
@@ -131,7 +110,7 @@ const UserProfiles = () => {
});
};
const deleteUser = (id: string, username?: string | null) => {
const confirmDeleteUser = (id: string, username?: string | null) => {
const title = username ? globalize.translate('DeleteName', username) : globalize.translate('DeleteUser');
const text = globalize.translate('DeleteUserConfirmation');
@@ -141,32 +120,38 @@ const UserProfiles = () => {
confirmText: globalize.translate('Delete'),
primary: 'delete'
}).then(function () {
loading.show();
window.ApiClient.deleteUser(id).then(function () {
loadData();
}).catch(err => {
console.error('[userprofiles] failed to delete user', err);
deleteUser.mutate({
userId: id
});
}).catch(() => {
// confirm dialog closed
});
};
page.addEventListener('click', function (e) {
const onPageClick = function (e: MouseEvent) {
const btnUserMenu = dom.parentWithClass(e.target as HTMLElement, 'btnUserMenu');
if (btnUserMenu) {
showUserMenu(btnUserMenu);
}
});
};
(page.querySelector('#btnAddUser') as HTMLButtonElement).addEventListener('click', function() {
Dashboard.navigate('/dashboard/users/add')
.catch(err => {
console.error('[userprofiles] failed to navigate to new user page', err);
});
});
}, []);
const onAddUserClick = function() {
navigate('/dashboard/users/add');
};
page.addEventListener('click', onPageClick);
(page.querySelector('#btnAddUser') as HTMLButtonElement).addEventListener('click', onAddUserClick);
return () => {
page.removeEventListener('click', onPageClick);
(page.querySelector('#btnAddUser') as HTMLButtonElement).removeEventListener('click', onAddUserClick);
};
}, [navigate, deleteUser, location.state?.openSavedToast]);
if (isPending) {
return <Loading />;
}
return (
<Page
@@ -192,7 +177,7 @@ const UserProfiles = () => {
</div>
<div className='localUsers itemsContainer vertical-wrap'>
{users.map(user => {
{users?.map(user => {
return <UserCardBox key={user.Id} user={user} />;
})}
</div>

View File

@@ -1,37 +1,21 @@
import React, { useCallback, useEffect, useState } from 'react';
import React from 'react';
import { useSearchParams } from 'react-router-dom';
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
import UserPasswordForm from '../../../../components/dashboard/users/UserPasswordForm';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import Page from '../../../../components/Page';
import loading from '../../../../components/loading/loading';
import { useUser } from 'apps/dashboard/features/users/api/useUser';
import Loading from 'components/loading/LoadingComponent';
const UserPassword = () => {
const [ searchParams ] = useSearchParams();
const userId = searchParams.get('userId');
const [ userName, setUserName ] = useState('');
const { data: user, isPending } = useUser(userId ? { userId: userId } : undefined);
const loadUser = useCallback(() => {
if (!userId) {
console.error('[userpassword] missing user id');
return;
}
loading.show();
window.ApiClient.getUser(userId).then(function (user) {
if (!user.Name) {
throw new Error('Unexpected null user.Name');
}
setUserName(user.Name);
loading.hide();
}).catch(err => {
console.error('[userpassword] failed to fetch user', err);
});
}, [userId]);
useEffect(() => {
loadUser();
}, [loadUser]);
if (isPending || !user) {
return <Loading />;
}
return (
<Page
@@ -41,13 +25,13 @@ const UserPassword = () => {
<div className='content-primary'>
<div className='verticalSection'>
<SectionTitleContainer
title={userName}
title={user?.Name || undefined}
/>
</div>
<SectionTabs activeTab='userpassword'/>
<div className='readOnlyContent'>
<UserPasswordForm
userId={userId}
user={user}
/>
</div>
</div>

View File

@@ -13,6 +13,14 @@ import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
import loading from '../../../../components/loading/loading';
import SelectElement from '../../../../elements/SelectElement';
import Page from '../../../../components/Page';
import { useUser } from 'apps/dashboard/features/users/api/useUser';
import { useAuthProviders } from 'apps/dashboard/features/users/api/useAuthProviders';
import { usePasswordResetProviders } from 'apps/dashboard/features/users/api/usePasswordResetProviders';
import { useLibraryMediaFolders } from 'apps/dashboard/features/users/api/useLibraryMediaFolders';
import { useChannels } from 'apps/dashboard/features/users/api/useChannels';
import { useUpdateUser } from 'apps/dashboard/features/users/api/useUpdateUser';
import { useUpdateUserPolicy } from 'apps/dashboard/features/users/api/useUpdateUserPolicy';
import { useNetworkConfig } from 'apps/dashboard/features/users/api/useNetworkConfig';
type ResetProvider = BaseItemDto & {
checkedAttribute: string
@@ -27,15 +35,22 @@ const UserEdit = () => {
const navigate = useNavigate();
const [ searchParams ] = useSearchParams();
const userId = searchParams.get('userId');
const [ userDto, setUserDto ] = useState<UserDto>();
const [ deleteFoldersAccess, setDeleteFoldersAccess ] = useState<ResetProvider[]>([]);
const [ authProviders, setAuthProviders ] = useState<NameIdPair[]>([]);
const [ passwordResetProviders, setPasswordResetProviders ] = useState<NameIdPair[]>([]);
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
const [ authenticationProviderId, setAuthenticationProviderId ] = useState('');
const [ passwordResetProviderId, setPasswordResetProviderId ] = useState('');
const { data: userDto, isSuccess: isUserSuccess } = useUser(userId ? { userId: userId } : undefined);
const { data: authProviders, isSuccess: isAuthProvidersSuccess } = useAuthProviders();
const { data: passwordResetProviders, isSuccess: isPasswordResetProvidersSuccess } = usePasswordResetProviders();
const { data: mediaFolders, isSuccess: isMediaFoldersSuccess } = useLibraryMediaFolders({ isHidden: false });
const { data: channels, isSuccess: isChannelsSuccess } = useChannels({ supportsMediaDeletion: true });
const { data: netConfig, isSuccess: isNetConfigSuccess } = useNetworkConfig();
const updateUser = useUpdateUser();
const updateUserPolicy = useUpdateUserPolicy();
const element = useRef<HTMLDivElement>(null);
const triggerChange = (select: HTMLInputElement) => {
@@ -43,17 +58,10 @@ const UserEdit = () => {
select.dispatchEvent(evt);
};
const getUser = () => {
if (!userId) throw new Error('missing user id');
return window.ApiClient.getUser(userId);
};
const loadAuthProviders = useCallback((page: HTMLDivElement, user: UserDto, providers: NameIdPair[]) => {
const fldSelectLoginProvider = page.querySelector('.fldSelectLoginProvider') as HTMLDivElement;
fldSelectLoginProvider.classList.toggle('hide', providers.length <= 1);
setAuthProviders(providers);
const currentProviderId = user.Policy?.AuthenticationProviderId || '';
setAuthenticationProviderId(currentProviderId);
}, []);
@@ -62,30 +70,26 @@ const UserEdit = () => {
const fldSelectPasswordResetProvider = page.querySelector('.fldSelectPasswordResetProvider') as HTMLDivElement;
fldSelectPasswordResetProvider.classList.toggle('hide', providers.length <= 1);
setPasswordResetProviders(providers);
const currentProviderId = user.Policy?.PasswordResetProviderId || '';
setPasswordResetProviderId(currentProviderId);
}, []);
const loadDeleteFolders = useCallback((page: HTMLDivElement, user: UserDto, mediaFolders: BaseItemDto[]) => {
window.ApiClient.getJSON(window.ApiClient.getUrl('Channels', {
SupportsMediaDeletion: true
})).then(function (channelsResult) {
let isChecked;
let checkedAttribute;
const itemsArr: ResetProvider[] = [];
const loadDeleteFolders = useCallback((page: HTMLDivElement, user: UserDto, folders: BaseItemDto[]) => {
let isChecked;
let checkedAttribute;
const itemsArr: ResetProvider[] = [];
for (const mediaFolder of mediaFolders) {
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(mediaFolder.Id || '') != -1;
checkedAttribute = isChecked ? ' checked="checked"' : '';
itemsArr.push({
...mediaFolder,
checkedAttribute: checkedAttribute
});
}
for (const mediaFolder of folders) {
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(mediaFolder.Id || '') != -1;
checkedAttribute = isChecked ? ' checked="checked"' : '';
itemsArr.push({
...mediaFolder,
checkedAttribute: checkedAttribute
});
}
for (const channel of channelsResult.Items) {
if (channels?.Items) {
for (const channel of channels.Items) {
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(channel.Id || '') != -1;
checkedAttribute = isChecked ? ' checked="checked"' : '';
itemsArr.push({
@@ -93,16 +97,66 @@ const UserEdit = () => {
checkedAttribute: checkedAttribute
});
}
}
setDeleteFoldersAccess(itemsArr);
setDeleteFoldersAccess(itemsArr);
const chkEnableDeleteAllFolders = page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement;
chkEnableDeleteAllFolders.checked = user.Policy?.EnableContentDeletion || false;
triggerChange(chkEnableDeleteAllFolders);
}).catch(err => {
console.error('[useredit] failed to fetch channels', err);
});
}, []);
const chkEnableDeleteAllFolders = page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement;
chkEnableDeleteAllFolders.checked = user.Policy?.EnableContentDeletion || false;
triggerChange(chkEnableDeleteAllFolders);
}, [channels]);
useEffect(() => {
const page = element.current;
if (!page) {
console.error('[useredit] Unexpected null page reference');
return;
}
if (userDto && isAuthProvidersSuccess && authProviders != null) {
loadAuthProviders(page, userDto, authProviders);
}
}, [authProviders, isAuthProvidersSuccess, userDto, loadAuthProviders]);
useEffect(() => {
const page = element.current;
if (!page) {
console.error('[useredit] Unexpected null page reference');
return;
}
if (userDto && isPasswordResetProvidersSuccess && passwordResetProviders != null) {
loadPasswordResetProviders(page, userDto, passwordResetProviders);
}
}, [passwordResetProviders, isPasswordResetProvidersSuccess, userDto, loadPasswordResetProviders]);
useEffect(() => {
const page = element.current;
if (!page) {
console.error('[useredit] Unexpected null page reference');
return;
}
if (userDto && isMediaFoldersSuccess && isChannelsSuccess && mediaFolders?.Items != null) {
loadDeleteFolders(page, userDto, mediaFolders.Items);
}
}, [userDto, mediaFolders, isMediaFoldersSuccess, isChannelsSuccess, channels, loadDeleteFolders]);
useEffect(() => {
const page = element.current;
if (!page) {
console.error('[useredit] Unexpected null page reference');
return;
}
if (netConfig && isNetConfigSuccess) {
(page.querySelector('.fldRemoteAccess') as HTMLDivElement).classList.toggle('hide', !netConfig.EnableRemoteAccess);
}
}, [netConfig, isNetConfigSuccess]);
const loadUser = useCallback((user: UserDto) => {
const page = element.current;
@@ -112,24 +166,6 @@ const UserEdit = () => {
return;
}
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/Providers')).then(function (providers) {
loadAuthProviders(page, user, providers);
}).catch(err => {
console.error('[useredit] failed to fetch auth providers', err);
});
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/PasswordResetProviders')).then(function (providers) {
loadPasswordResetProviders(page, user, providers);
}).catch(err => {
console.error('[useredit] failed to fetch password reset providers', err);
});
window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', {
IsHidden: false
})).then(function (folders) {
loadDeleteFolders(page, user, folders.Items);
}).catch(err => {
console.error('[useredit] failed to fetch media folders', err);
});
const disabledUserBanner = page.querySelector('.disabledUserBanner') as HTMLDivElement;
disabledUserBanner.classList.toggle('hide', !user.Policy?.IsDisabled);
@@ -139,7 +175,6 @@ const UserEdit = () => {
void libraryMenu.then(menu => menu.setTitle(user.Name));
setUserDto(user);
(page.querySelector('#txtUserName') as HTMLInputElement).value = user.Name || '';
(page.querySelector('.chkIsAdmin') as HTMLInputElement).checked = !!user.Policy?.IsAdministrator;
(page.querySelector('.chkDisabled') as HTMLInputElement).checked = !!user.Policy?.IsDisabled;
@@ -163,16 +198,22 @@ const UserEdit = () => {
(page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value = String(user.Policy?.MaxActiveSessions) || '0';
(page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value = String(user.Policy?.SyncPlayAccess);
loading.hide();
}, [loadAuthProviders, loadPasswordResetProviders, loadDeleteFolders ]);
}, [ libraryMenu ]);
const loadData = useCallback(() => {
if (!userDto) {
console.error('[profile] No user available');
return;
}
loading.show();
getUser().then(function (user) {
loadUser(user);
}).catch(err => {
console.error('[useredit] failed to load data', err);
});
}, [loadUser]);
loadUser(userDto);
}, [userDto, loadUser]);
useEffect(() => {
if (isUserSuccess) {
loadData();
}
}, [loadData, isUserSuccess]);
useEffect(() => {
const page = element.current;
@@ -182,8 +223,6 @@ const UserEdit = () => {
return;
}
loadData();
const saveUser = (user: UserDto) => {
if (!user.Id || !user.Policy) {
throw new Error('Unexpected null user id or policy');
@@ -215,53 +254,58 @@ const UserEdit = () => {
user.Policy.EnableContentDeletionFromFolders = user.Policy.EnableContentDeletion ? [] : getCheckedElementDataIds(page.querySelectorAll('.chkFolder'));
user.Policy.SyncPlayAccess = (page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value as SyncPlayUserAccessType;
window.ApiClient.updateUser(user).then(() => (
window.ApiClient.updateUserPolicy(user.Id || '', user.Policy || { PasswordResetProviderId: '', AuthenticationProviderId: '' })
)).then(() => {
navigate('/dashboard/users', {
state: { openSavedToast: true }
});
loading.hide();
}).catch(err => {
console.error('[useredit] failed to update user', err);
updateUser.mutate({ userId: user.Id, userDto: user }, {
onSuccess: () => {
if (user.Id) {
updateUserPolicy.mutate({
userId: user.Id,
userPolicy: user.Policy || { PasswordResetProviderId: '', AuthenticationProviderId: '' }
}, {
onSuccess: () => {
loading.hide();
navigate('/dashboard/users', {
state: { openSavedToast: true }
});
}
});
}
}
});
};
const onSubmit = (e: Event) => {
loading.show();
getUser().then(function (result) {
saveUser(result);
}).catch(err => {
console.error('[useredit] failed to fetch user', err);
});
if (userDto) {
saveUser(userDto);
}
e.preventDefault();
e.stopPropagation();
return false;
};
const onBtnCancelClick = () => {
window.history.back();
};
(page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
(page.querySelector('.deleteAccess') as HTMLDivElement).classList.toggle('hide', this.checked);
});
window.ApiClient.getNamedConfiguration('network').then(function (config) {
(page.querySelector('.fldRemoteAccess') as HTMLDivElement).classList.toggle('hide', !config.EnableRemoteAccess);
}).catch(err => {
console.error('[useredit] failed to load network config', err);
});
(page.querySelector('.editUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit);
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', onBtnCancelClick);
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', function() {
window.history.back();
});
}, [loadData]);
return () => {
(page.querySelector('.editUserProfileForm') as HTMLFormElement).removeEventListener('submit', onSubmit);
(page.querySelector('#btnCancel') as HTMLButtonElement).removeEventListener('click', onBtnCancelClick);
};
}, [loadData, updateUser, userDto, updateUserPolicy, navigate]);
const optionLoginProvider = authProviders.map((provider) => {
const optionLoginProvider = authProviders?.map((provider) => {
const selected = provider.Id === authenticationProviderId || authProviders.length < 2 ? ' selected' : '';
return `<option value="${provider.Id}"${selected}>${escapeHTML(provider.Name)}</option>`;
});
const optionPasswordResetProvider = passwordResetProviders.map((provider) => {
const optionPasswordResetProvider = passwordResetProviders?.map((provider) => {
const selected = provider.Id === passwordResetProviderId || passwordResetProviders.length < 2 ? ' selected' : '';
return `<option value="${provider.Id}"${selected}>${escapeHTML(provider.Name)}</option>`;
});

View File

@@ -2,7 +2,6 @@ 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';
@@ -17,6 +16,14 @@ interface AppToolbarProps {
onDrawerButtonClick: (event: React.MouseEvent<HTMLElement>) => void
}
const PUBLIC_PATHS = [
'/addserver',
'/selectserver',
'/login',
'/forgotpassword',
'/forgotpasswordpin'
];
const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
isDrawerAvailable,
isDrawerOpen,
@@ -27,10 +34,6 @@ 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 (
@@ -45,7 +48,6 @@ const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
isDrawerAvailable={isDrawerAvailable}
isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onDrawerButtonClick}
isBackButtonAvailable={isBackButtonAvailable}
isUserMenuAvailable={!isPublicPath}
>
{!isDrawerAvailable && (

View File

@@ -3,6 +3,7 @@ import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collec
import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
import Favorite from '@mui/icons-material/Favorite';
import Button from '@mui/material/Button/Button';
import Icon from '@mui/material/Icon';
import { Theme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import React, { useCallback, useMemo, useState } from 'react';
@@ -15,6 +16,7 @@ import { appRouter } from 'components/router/appRouter';
import { useApi } from 'hooks/useApi';
import useCurrentTab from 'hooks/useCurrentTab';
import { useUserViews } from 'hooks/useUserViews';
import { useWebConfig } from 'hooks/useWebConfig';
import globalize from 'lib/globalize';
import UserViewsMenu from './UserViewsMenu';
@@ -56,14 +58,19 @@ const UserViewNav = () => {
const libraryId = searchParams.get('topParentId') || searchParams.get('parentId');
const collectionType = searchParams.get('collectionType');
const { activeTab } = useCurrentTab();
const webConfig = useWebConfig();
const isExtraLargeScreen = useMediaQuery((t: Theme) => t.breakpoints.up('xl'));
const isLargeScreen = useMediaQuery((t: Theme) => t.breakpoints.up('lg'));
const maxViews = useMemo(() => {
if (isExtraLargeScreen) return MAX_USER_VIEWS_XL;
if (isLargeScreen) return MAX_USER_VIEWS_LG;
return MAX_USER_VIEWS_MD;
}, [ isExtraLargeScreen, isLargeScreen ]);
let _maxViews = MAX_USER_VIEWS_MD;
if (isExtraLargeScreen) _maxViews = MAX_USER_VIEWS_XL;
else if (isLargeScreen) _maxViews = MAX_USER_VIEWS_LG;
const customLinks = (webConfig.menuLinks || []).length;
return _maxViews - customLinks;
}, [ isExtraLargeScreen, isLargeScreen, webConfig.menuLinks ]);
const { user } = useApi();
const {
@@ -108,6 +115,21 @@ const UserViewNav = () => {
{globalize.translate(MetaView.Favorites.Name)}
</Button>
{webConfig.menuLinks?.map(link => (
<Button
key={link.name}
variant='text'
color='inherit'
startIcon={<Icon>{link.icon || 'link'}</Icon>}
component='a'
href={link.url}
target='_blank'
rel='noopener noreferrer'
>
{link.name}
</Button>
))}
{primaryViews?.map(view => (
<Button
key={view.Id}

View File

@@ -9,6 +9,7 @@ import useMediaQuery from '@mui/material/useMediaQuery';
import classNames from 'classnames';
import React, { type FC, useCallback } from 'react';
import { ItemAction } from 'constants/itemAction';
import { useApi } from 'hooks/useApi';
import { useLocalStorage } from 'hooks/useLocalStorage';
import { useGetItemsViewByType } from 'hooks/useFetchItems';
@@ -99,7 +100,7 @@ const ItemsView: FC<ItemsViewProps> = ({
if (viewType === LibraryTab.Songs) {
listOptions.showParentTitle = true;
listOptions.action = 'playallfromhere';
listOptions.action = ItemAction.PlayAllFromHere;
listOptions.smallIcon = true;
listOptions.showArtist = true;
listOptions.addToListButton = true;
@@ -221,7 +222,9 @@ const ItemsView: FC<ItemsViewProps> = ({
const hasFilters = Object.values(libraryViewSettings.Filters ?? {}).some(
(filter) => !!filter
);
const hasSortName = libraryViewSettings.SortBy !== ItemSortBy.Random;
const hasSortName = libraryViewSettings.SortBy.includes(
ItemSortBy.SortName
);
const itemsContainerClass = classNames(
'centered padded-left padded-right padded-right-withalphapicker',

View File

@@ -1,14 +1,16 @@
import React, { type FC } from 'react';
import { useApi } from 'hooks/useApi';
import { useGetProgramsSectionsWithItems, useGetTimers } from 'hooks/useFetchItems';
import { appRouter } from 'components/router/appRouter';
import globalize from 'lib/globalize';
import Loading from 'components/loading/LoadingComponent';
import NoItemsMessage from 'components/common/NoItemsMessage';
import SectionContainer from 'components/common/SectionContainer';
import { CardShape } from 'utils/card';
import Loading from 'components/loading/LoadingComponent';
import { appRouter } from 'components/router/appRouter';
import { ItemAction } from 'constants/itemAction';
import { useApi } from 'hooks/useApi';
import { useGetProgramsSectionsWithItems, useGetTimers } from 'hooks/useFetchItems';
import globalize from 'lib/globalize';
import type { ParentId } from 'types/library';
import type { Section, SectionType } from 'types/sections';
import { CardShape } from 'utils/card';
interface ProgramsSectionViewProps {
parentId: ParentId;
@@ -92,7 +94,7 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
showChannelName: false,
cardLayout: true,
centerText: false,
action: 'edit',
action: ItemAction.Edit,
cardFooterAside: 'none',
preferThumb: true,
coverImage: true,

View File

@@ -22,14 +22,6 @@ 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 },
@@ -51,7 +43,6 @@ const photosOrPhotoAlbumsOptions = [
const sortOptionsMapping: SortOptionsMapping = {
[LibraryTab.Movies]: movieOrFavoriteOptions,
[LibraryTab.Collections]: collectionMovieOptions,
[LibraryTab.Favorites]: movieOrFavoriteOptions,
[LibraryTab.Series]: [
{ label: 'Name', value: ItemSortBy.SortName },

View File

@@ -4,6 +4,7 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import ReplayIcon from '@mui/icons-material/Replay';
import { useQueryClient } from '@tanstack/react-query';
import { ItemAction } from 'constants/itemAction';
import { useApi } from 'hooks/useApi';
import { getChannelQuery } from 'hooks/api/liveTvHooks/useGetChannel';
import globalize from 'lib/globalize';
@@ -76,7 +77,7 @@ const PlayOrResumeButton: FC<PlayOrResumeButtonProps> = ({
return (
<IconButton
className='button-flat btnPlayOrResume'
data-action={isResumable ? 'resume' : 'play'}
data-action={isResumable ? ItemAction.Resume : ItemAction.Play}
title={
isResumable ?
globalize.translate('ButtonResume') :

View File

@@ -12,6 +12,7 @@ import React, { Fragment } from 'react';
import { appHost } from 'components/apphost';
import { AppFeature } from 'constants/appFeature';
import { LayoutMode } from 'constants/layoutMode';
import { useApi } from 'hooks/useApi';
import { useThemes } from 'hooks/useThemes';
import globalize from 'lib/globalize';
@@ -45,11 +46,10 @@ export function DisplayPreferences({ onChange, values }: Readonly<DisplayPrefere
onChange={onChange}
value={values.layout}
>
<MenuItem value='auto'>{globalize.translate('Auto')}</MenuItem>
<MenuItem value='desktop'>{globalize.translate('Desktop')}</MenuItem>
<MenuItem value='mobile'>{globalize.translate('Mobile')}</MenuItem>
<MenuItem value='tv'>{globalize.translate('TV')}</MenuItem>
<MenuItem value='experimental'>{globalize.translate('Experimental')}</MenuItem>
<MenuItem value={LayoutMode.Auto}>{globalize.translate('Auto')}</MenuItem>
<MenuItem value={LayoutMode.Desktop}>{globalize.translate('Desktop')}</MenuItem>
<MenuItem value={LayoutMode.Mobile}>{globalize.translate('Mobile')}</MenuItem>
<MenuItem value={LayoutMode.Tv}>{globalize.translate('TV')}</MenuItem>
</Select>
<FormHelperText component={Stack} id='display-settings-layout-description'>
<span>{globalize.translate('DisplayModeHelp')}</span>
@@ -169,6 +169,30 @@ export function DisplayPreferences({ onChange, values }: Readonly<DisplayPrefere
</Fragment>
) }
<FormControl fullWidth>
<TextField
aria-describedby='display-settings-slideshow-interval-description'
value={values.slideshowInterval}
label={globalize.translate('LabelSlideshowInterval')}
name='slideshowInterval'
onChange={onChange}
slotProps={{
htmlInput: {
inputMode: 'numeric',
max: '3600',
min: '1',
pattern: '[0-9]',
required: true,
step: '1',
type: 'number'
}
}}
/>
<FormHelperText id='display-settings-slideshow-interval-description'>
{globalize.translate('LabelSlideshowIntervalHelp')}
</FormHelperText>
</FormControl>
<FormControl fullWidth>
<FormControlLabel
aria-describedby='display-settings-faster-animations-description'

View File

@@ -103,6 +103,7 @@ async function loadDisplaySettings({
maxDaysForNextUp: settings.maxDaysForNextUp(),
screensaver: settings.screensaver() || 'none',
screensaverInterval: settings.backdropScreensaverInterval(),
slideshowInterval: settings.slideshowInterval(),
theme: settings.theme() || defaultTheme?.id || FALLBACK_THEME_ID
};

View File

@@ -18,5 +18,6 @@ export interface DisplaySettingsValues {
maxDaysForNextUp: number;
screensaver: string;
screensaverInterval: number;
slideshowInterval: number;
theme: string;
}

View File

@@ -11,12 +11,8 @@ import type { ItemDto } from 'types/base/models/item-dto';
import type { PlayerState } from 'types/playbackStopInfo';
import type { Event } from 'utils/events';
/** The default image resolutions to provide to the media session.
*
* Highest-to-lowest order matters; Firefox on Linux seems to use the first
* image in the artwork array for its MPRIS interface. (#7630)
*/
const DEFAULT_IMAGE_SIZES = [512, 384, 256, 192, 128, 96];
/** The default image resolutions to provide to the media session */
const DEFAULT_IMAGE_SIZES = [96, 128, 192, 256, 384, 512];
const hasNavigatorSession = 'mediaSession' in navigator;
const hasNativeShell = !!window.NativeShell;
@@ -96,14 +92,8 @@ class MediaSessionSubscriber extends PlaybackSubscriber {
private onMediaSessionUpdate(
{ type: action }: Event,
stateOverride?: PlayerState
state: PlayerState = this.playbackManager.getPlayerState(this.player)
) {
if (!this.player) {
console.debug('[MediaSessionSubscriber] no active player; resetting media session');
return resetMediaSession();
}
const state: PlayerState = stateOverride || this.playbackManager.getPlayerState(this.player);
const item = state.NowPlayingItem;
if (!item) {

View File

@@ -66,7 +66,6 @@ export const useSearchItems = (
includeItemTypes: itemTypes,
parentId,
searchTerm,
isMissing: itemTypes.includes(BaseItemKind.Episode) && !user?.Configuration?.DisplayMissingEpisodes ? false : undefined,
limit: 800
},
{ signal }

View File

@@ -1,23 +1,25 @@
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
import React, { FunctionComponent, useEffect, useState, useRef, useCallback, useMemo } from 'react';
import React, { FunctionComponent, useEffect, useRef, useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import Dashboard from '../../../../utils/dashboard';
import globalize from '../../../../lib/globalize';
import { appHost } from '../../../../components/apphost';
import confirm from '../../../../components/confirm/confirm';
import Button from '../../../../elements/emby-button/Button';
import UserPasswordForm from '../../../../components/dashboard/users/UserPasswordForm';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import Page from '../../../../components/Page';
import { AppFeature } from 'constants/appFeature';
import { useUser } from 'apps/dashboard/features/users/api/useUser';
import loading from 'components/loading/loading';
import { queryClient } from 'utils/query/queryClient';
import UserPasswordForm from 'components/dashboard/users/UserPasswordForm';
import Page from 'components/Page';
import Loading from 'components/loading/LoadingComponent';
import Button from 'elements/emby-button/Button';
const UserProfile: FunctionComponent = () => {
const [ searchParams ] = useSearchParams();
const userId = searchParams.get('userId');
const [ userName, setUserName ] = useState('');
const { data: user, isPending: isUserPending } = useUser(userId ? { userId: userId } : undefined);
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
const element = useRef<HTMLDivElement>(null);
@@ -30,50 +32,38 @@ const UserProfile: FunctionComponent = () => {
return;
}
if (!userId) {
console.error('[userprofile] missing user id');
return;
if (!user?.Name || !user?.Id) {
throw new Error('Unexpected null user name or id');
}
loading.show();
window.ApiClient.getUser(userId).then(function (user) {
if (!user.Name || !user.Id) {
throw new Error('Unexpected null user name or id');
}
void libraryMenu.then(menu => menu.setTitle(user.Name));
setUserName(user.Name);
void libraryMenu.then(menu => menu.setTitle(user.Name));
let imageUrl = 'assets/img/avatar.png';
if (user.PrimaryImageTag) {
imageUrl = window.ApiClient.getUserImageUrl(user.Id, {
tag: user.PrimaryImageTag,
type: 'Primary'
});
}
const userImage = (page.querySelector('#image') as HTMLDivElement);
userImage.style.backgroundImage = 'url(' + imageUrl + ')';
Dashboard.getCurrentUser().then(function (loggedInUser: UserDto) {
if (!user.Policy) {
throw new Error('Unexpected null user.Policy');
}
if (user.PrimaryImageTag) {
(page.querySelector('#btnAddImage') as HTMLButtonElement).classList.add('hide');
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).classList.remove('hide');
} else if (appHost.supports(AppFeature.FileInput) && (loggedInUser?.Policy?.IsAdministrator || user.Policy.EnableUserPreferenceAccess)) {
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).classList.add('hide');
(page.querySelector('#btnAddImage') as HTMLButtonElement).classList.remove('hide');
}
}).catch(err => {
console.error('[userprofile] failed to get current user', err);
let imageUrl = 'assets/img/avatar.png';
if (user.PrimaryImageTag) {
imageUrl = window.ApiClient.getUserImageUrl(user.Id, {
tag: user.PrimaryImageTag,
type: 'Primary'
});
loading.hide();
}
const userImage = (page.querySelector('#image') as HTMLDivElement);
userImage.style.backgroundImage = 'url(' + imageUrl + ')';
Dashboard.getCurrentUser().then(function (loggedInUser: UserDto) {
if (!user.Policy) {
throw new Error('Unexpected null user.Policy');
}
if (user.PrimaryImageTag) {
(page.querySelector('#btnAddImage') as HTMLButtonElement).classList.add('hide');
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).classList.remove('hide');
} else if (appHost.supports('fileinput') && (loggedInUser?.Policy?.IsAdministrator || user.Policy.EnableUserPreferenceAccess)) {
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).classList.add('hide');
(page.querySelector('#btnAddImage') as HTMLButtonElement).classList.remove('hide');
}
}).catch(err => {
console.error('[userprofile] failed to load data', err);
console.error('[userprofile] failed to get current user', err);
});
}, [userId]);
}, [user, libraryMenu]);
useEffect(() => {
const page = element.current;
@@ -125,7 +115,9 @@ const UserProfile: FunctionComponent = () => {
userImage.style.backgroundImage = 'url(' + reader.result + ')';
window.ApiClient.uploadUserImage(userId, ImageType.Primary, file).then(function () {
loading.hide();
reloadUser();
void queryClient.invalidateQueries({
queryKey: ['User']
});
}).catch(err => {
console.error('[userprofile] failed to upload image', err);
});
@@ -134,7 +126,7 @@ const UserProfile: FunctionComponent = () => {
reader.readAsDataURL(file);
};
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).addEventListener('click', function () {
const onDeleteImageClick = function () {
if (!userId) {
console.error('[userprofile] missing user id');
return;
@@ -147,25 +139,41 @@ const UserProfile: FunctionComponent = () => {
loading.show();
window.ApiClient.deleteUserImage(userId, ImageType.Primary).then(function () {
loading.hide();
reloadUser();
void queryClient.invalidateQueries({
queryKey: ['User']
});
}).catch(err => {
console.error('[userprofile] failed to delete image', err);
});
}).catch(() => {
// confirm dialog closed
});
});
};
(page.querySelector('#btnAddImage') as HTMLButtonElement).addEventListener('click', function () {
const addImageClick = function () {
const uploadImage = page.querySelector('#uploadImage') as HTMLInputElement;
uploadImage.value = '';
uploadImage.click();
});
};
(page.querySelector('#uploadImage') as HTMLInputElement).addEventListener('change', function (evt: Event) {
setFiles(evt);
});
}, [reloadUser, userId]);
const onUploadImage = (e: Event) => {
setFiles(e);
};
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).addEventListener('click', onDeleteImageClick);
(page.querySelector('#btnAddImage') as HTMLButtonElement).addEventListener('click', addImageClick);
(page.querySelector('#uploadImage') as HTMLInputElement).addEventListener('change', onUploadImage);
return () => {
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).removeEventListener('click', onDeleteImageClick);
(page.querySelector('#btnAddImage') as HTMLButtonElement).removeEventListener('click', addImageClick);
(page.querySelector('#uploadImage') as HTMLInputElement).removeEventListener('change', onUploadImage);
};
}, [reloadUser, user, userId]);
if (isUserPending || !user) {
return <Loading />;
}
return (
<Page
@@ -195,7 +203,7 @@ const UserProfile: FunctionComponent = () => {
</div>
<div style={{ verticalAlign: 'top', margin: '1em 2em', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<h2 className='username' style={{ margin: 0, fontSize: 'xx-large' }}>
{userName}
{user?.Name}
</h2>
<br />
<Button
@@ -213,7 +221,7 @@ const UserProfile: FunctionComponent = () => {
</div>
</div>
<UserPasswordForm
userId={userId}
user={user}
/>
</div>
</Page>

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -64,7 +64,6 @@ const ConnectionErrorPage: FC<ConnectionErrorPageProps> = ({
id='connectionErrorPage'
className='mainAnimatedPage standalonePage'
isBackButtonEnabled={false}
shouldAutoFocus
>
<div className='padded-left padded-right'>
<h1>{title}</h1>

View File

@@ -45,12 +45,6 @@ const ResponsiveDrawer: FC<PropsWithChildren<ResponsiveDrawerProps>> = ({
<SwipeableDrawer
anchor='left'
open={open}
sx={{
'& .MuiDrawer-paper': {
paddingBottom: '4.2rem', // Padding for now playing bar
boxSizing: 'border-box'
}
}}
onClose={onClose}
onOpen={onOpen}
// Disable swipe to open on iOS since it interferes with back navigation

View File

@@ -6,6 +6,7 @@ import * as webSettings from '../scripts/settings/webSettings';
import globalize from '../lib/globalize';
import profileBuilder from '../scripts/browserDeviceProfile';
import { AppFeature } from 'constants/appFeature';
import { LayoutMode } from 'constants/layoutMode';
const appName = 'Jellyfin Web';
@@ -13,7 +14,6 @@ const BrowserName = {
tizen: 'Samsung Smart TV',
web0s: 'LG Smart TV',
titanos: 'Titan OS',
vega: 'Vega OS',
operaTv: 'Opera TV',
xboxOne: 'Xbox One',
ps4: 'Sony PS4',
@@ -182,7 +182,7 @@ function supportsFullscreen() {
}
function getDefaultLayout() {
return 'desktop';
return LayoutMode.Experimental;
}
function supportsHtmlMediaAutoplay() {
@@ -372,7 +372,7 @@ export const appHost = {
return getDefaultLayout();
},
getDeviceProfile: getDeviceProfile,
getDeviceProfile,
init: function () {
if (window.NativeShell) {
return window.NativeShell.AppHost.init();
@@ -456,3 +456,5 @@ if (window.addEventListener) {
window.addEventListener('blur', onAppHidden);
}
// load app host on module load
appHost.init();

View File

@@ -1,17 +1,18 @@
import React, { type FC } from 'react';
import layoutManager from 'components/layoutManager';
import { ItemAction } from 'constants/itemAction';
import { CardShape } from 'utils/card';
import type { ItemDto } from 'types/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
import CardOverlayButtons from './CardOverlayButtons';
import CardHoverMenu from './CardHoverMenu';
import CardOuterFooter from './CardOuterFooter';
import CardContent from './CardContent';
import { CardShape } from 'utils/card';
import type { ItemDto } from 'types/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
interface CardBoxProps {
action: string;
action: ItemAction;
item: ItemDto;
cardOptions: CardOptions;
className: string;

View File

@@ -2,12 +2,14 @@ import React, { type FC } from 'react';
import Box from '@mui/material/Box';
import ButtonGroup from '@mui/material/ButtonGroup';
import classNames from 'classnames';
import { appRouter } from 'components/router/appRouter';
import itemHelper from 'components/itemHelper';
import { playbackManager } from 'components/playback/playbackmanager';
import { ItemAction } from 'constants/itemAction';
import PlayedButton from 'elements/emby-playstatebutton/PlayedButton';
import FavoriteButton from 'elements/emby-ratingbutton/FavoriteButton';
import PlayArrowIconButton from '../../common/PlayArrowIconButton';
import MoreVertIconButton from '../../common/MoreVertIconButton';
@@ -15,7 +17,7 @@ import type { ItemDto } from 'types/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
interface CardHoverMenuProps {
action: string,
action: ItemAction,
item: ItemDto;
cardOptions: CardOptions;
}
@@ -29,7 +31,7 @@ const CardHoverMenu: FC<CardHoverMenuProps> = ({
parentId: cardOptions.parentId
});
const btnCssClass =
'paper-icon-button-light cardOverlayButton cardOverlayButton-hover itemAction';
'paper-icon-button-light cardOverlayButton cardOverlayButton-hover itemAction';
const centerPlayButtonClass = classNames(
btnCssClass,
@@ -51,7 +53,7 @@ const CardHoverMenu: FC<CardHoverMenuProps> = ({
{playbackManager.canPlay(item) && (
<PlayArrowIconButton
className={centerPlayButtonClass}
action='play'
action={ItemAction.Play}
title='Play'
/>
)}

View File

@@ -2,15 +2,17 @@ import { LocationType } from '@jellyfin/sdk/lib/generated-client/models/location
import React, { type FC } from 'react';
import ButtonGroup from '@mui/material/ButtonGroup';
import classNames from 'classnames';
import { appRouter } from 'components/router/appRouter';
import PlayArrowIconButton from '../../common/PlayArrowIconButton';
import MoreVertIconButton from '../../common/MoreVertIconButton';
import { appRouter } from 'components/router/appRouter';
import { ItemAction } from 'constants/itemAction';
import { ItemKind } from 'types/base/models/item-kind';
import { ItemMediaKind } from 'types/base/models/item-media-kind';
import type { ItemDto } from 'types/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
import PlayArrowIconButton from '../../common/PlayArrowIconButton';
import MoreVertIconButton from '../../common/MoreVertIconButton';
const sholudShowOverlayPlayButton = (
overlayPlayButton: boolean | undefined,
item: ItemDto
@@ -78,7 +80,7 @@ const CardOverlayButtons: FC<CardOverlayButtonsProps> = ({
{cardOptions.centerPlayButton && (
<PlayArrowIconButton
className={centerPlayButtonClass}
action='play'
action={ItemAction.Play}
title='Play'
/>
)}
@@ -87,7 +89,7 @@ const CardOverlayButtons: FC<CardOverlayButtonsProps> = ({
{sholudShowOverlayPlayButton(overlayPlayButton, item) && (
<PlayArrowIconButton
className={btnCssClass}
action='play'
action={ItemAction.Play}
title='Play'
/>
)}

View File

@@ -7,6 +7,7 @@ import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
import { appRouter } from 'components/router/appRouter';
import layoutManager from 'components/layoutManager';
import itemHelper from 'components/itemHelper';
import { ItemAction } from 'constants/itemAction';
import globalize from 'lib/globalize';
import datetime from 'scripts/datetime';
import { isUsingLiveTvNaming } from '../cardBuilderUtils';
@@ -88,7 +89,7 @@ export function getTextActionButton(
const dataAttributes = getDataAttributes(
{
action: 'link',
action: ItemAction.Link,
itemServerId: serverId ?? item.ServerId,
itemId: item.Id,
itemChannelId: item.ChannelId,

View File

@@ -1,17 +1,19 @@
import classNames from 'classnames';
import layoutManager from 'components/layoutManager';
import { ItemAction } from 'constants/itemAction';
import { ItemKind } from 'types/base/models/item-kind';
import { ItemMediaKind } from 'types/base/models/item-media-kind';
import type { ItemDto } from 'types/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
import { CardShape } from 'utils/card';
import { getDataAttributes } from 'utils/items';
import useCardImageUrl from './useCardImageUrl';
import {
resolveAction,
resolveMixedShapeByAspectRatio
} from '../cardBuilderUtils';
import { getDataAttributes } from 'utils/items';
import { CardShape } from 'utils/card';
import layoutManager from 'components/layoutManager';
import { ItemKind } from 'types/base/models/item-kind';
import { ItemMediaKind } from 'types/base/models/item-media-kind';
import type { ItemDto } from 'types/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
interface UseCardProps {
item: ItemDto;
@@ -20,7 +22,7 @@ interface UseCardProps {
function useCard({ item, cardOptions }: UseCardProps) {
const action = resolveAction({
defaultAction: cardOptions.action ?? 'link',
defaultAction: cardOptions.action ?? ItemAction.Link,
isFolder: item.IsFolder ?? false,
isPhoto: item.MediaType === ItemMediaKind.Photo
});

View File

@@ -826,13 +826,6 @@ button::-moz-focus-inner {
right: 0;
user-select: none;
border-radius: 0.2em;
/** Disable pointer events for the overlay container, but allow them for its children */
pointer-events: none;
* {
pointer-events: auto;
}
}
.visualCardBox .blurhash-canvas,
@@ -842,6 +835,10 @@ button::-moz-focus-inner {
border-bottom-right-radius: 0;
}
.card-hoverable:hover .cardOverlayContainer {
opacity: 1;
}
.cardOverlayButton-hover {
opacity: 0;
transition: 0.2s;
@@ -853,12 +850,8 @@ button::-moz-focus-inner {
background: transparent !important;
}
.card-hoverable:focus-within,
.card-hoverable:hover {
.cardOverlayContainer,
.cardOverlayButton-hover {
opacity: 1;
}
.card-hoverable:hover .cardOverlayButton-hover {
opacity: 1;
}
.cardOverlayContainer > .cardOverlayFab-primary {

View File

@@ -8,6 +8,7 @@ import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-ite
import { PersonKind } from '@jellyfin/sdk/lib/generated-client/models/person-kind';
import escapeHtml from 'escape-html';
import { ItemAction } from 'constants/itemAction';
import browser from 'scripts/browser';
import datetime from 'scripts/datetime';
import dom from 'utils/dom';
@@ -514,7 +515,7 @@ function getCardFooterText(item, apiClient, options, footerClass, progressHtml,
const showOtherText = flags.isOuterFooter ? !flags.overlayText : flags.overlayText;
if (flags.isOuterFooter && options.cardLayout && layoutManager.mobile && options.cardFooterAside !== 'none') {
html += `<button is="paper-icon-button-light" class="itemAction btnCardOptions cardText-secondary" data-action="menu" title="${globalize.translate('ButtonMore')}"><span class="material-icons more_vert" aria-hidden="true"></span></button>`;
html += `<button is="paper-icon-button-light" class="itemAction btnCardOptions cardText-secondary" data-action="${ItemAction.Menu}" title="${globalize.translate('ButtonMore')}"><span class="material-icons more_vert" aria-hidden="true"></span></button>`;
}
const cssClass = options.centerText ? 'cardText cardTextCentered' : 'cardText';
@@ -577,10 +578,9 @@ function getCardFooterText(item, apiClient, options, footerClass, progressHtml,
if (flags.isOuterFooter && item.AlbumArtists?.length) {
const artistText = item.AlbumArtists
.map(artist => {
artist.ServerId = serverId;
artist.Type = BaseItemKind.MusicArtist;
artist.IsFolder = true;
return getTextActionButton(artist);
return getTextActionButton(artist, null, serverId);
})
.join(' / ');
lines.push(artistText);
@@ -777,7 +777,7 @@ function getTextActionButton(item, text, serverId) {
}
const url = appRouter.getRouteUrl(item);
let html = '<a href="' + url + '" ' + itemShortcuts.getShortcutAttributesHtml(item, serverId) + ' class="itemAction textActionButton" title="' + text + '" data-action="link">';
let html = '<a href="' + url + '" ' + itemShortcuts.getShortcutAttributesHtml(item, serverId) + ' class="itemAction textActionButton" title="' + text + `" data-action="${ItemAction.Link}">`;
html += text;
html += '</a>';
@@ -886,7 +886,7 @@ function importRefreshIndicator() {
*/
function buildCard(index, item, apiClient, options) {
const action = resolveAction({
defaultAction: options.action || 'link',
defaultAction: options.action || ItemAction.Link,
isFolder: item.IsFolder,
isPhoto: item.MediaType === 'Photo'
});
@@ -986,15 +986,15 @@ function buildCard(index, item, apiClient, options) {
const btnCssClass = 'cardOverlayButton cardOverlayButton-br itemAction';
if (options.centerPlayButton) {
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass} cardOverlayButton-centered" data-action="play" title="${globalize.translate('Play')}"><span class="material-icons cardOverlayButtonIcon play_arrow" aria-hidden="true"></span></button>`;
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass} cardOverlayButton-centered" data-action="${ItemAction.Play}" title="${globalize.translate('Play')}"><span class="material-icons cardOverlayButtonIcon play_arrow" aria-hidden="true"></span></button>`;
}
if (overlayPlayButton && !item.IsPlaceHolder && (item.LocationType !== 'Virtual' || !item.MediaType || item.Type === 'Program') && item.Type !== 'Person') {
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="play" title="${globalize.translate('Play')}"><span class="material-icons cardOverlayButtonIcon play_arrow" aria-hidden="true"></span></button>`;
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="${ItemAction.Play}" title="${globalize.translate('Play')}"><span class="material-icons cardOverlayButtonIcon play_arrow" aria-hidden="true"></span></button>`;
}
if (options.overlayMoreButton) {
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="menu" title="${globalize.translate('ButtonMore')}"><span class="material-icons cardOverlayButtonIcon more_vert" aria-hidden="true"></span></button>`;
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="${ItemAction.Menu}" title="${globalize.translate('ButtonMore')}"><span class="material-icons cardOverlayButtonIcon more_vert" aria-hidden="true"></span></button>`;
}
}
@@ -1017,7 +1017,7 @@ function buildCard(index, item, apiClient, options) {
cardImageContainerClose = '</div>';
} else {
const cardImageContainerAriaLabelAttribute = ` aria-label="${escapeHtml(item.Name)}" role="img"`;
const cardImageContainerAriaLabelAttribute = ` aria-label="${escapeHtml(item.Name)}"`;
const url = appRouter.getRouteUrl(item);
// Don't use the IMG tag with safari because it puts a white border around it
@@ -1149,11 +1149,15 @@ function getHoverMenuHtml(item, action) {
let html = '';
html += '<div class="cardOverlayContainer itemAction" data-action="' + action + '">';
const url = appRouter.getRouteUrl(item, {
serverId: item.ServerId || ServerConnections.currentApiClient().serverId()
});
html += '<a href="' + url + '" class="cardImageContainer"></a>';
const btnCssClass = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light';
if (playbackManager.canPlay(item)) {
html += `<button is="paper-icon-button-light" class="${btnCssClass} cardOverlayFab-primary" data-action="resume" title="${globalize.translate('Play')}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover play_arrow" aria-hidden="true"></span></button>`;
html += `<button is="paper-icon-button-light" class="${btnCssClass} cardOverlayFab-primary" data-action="${ItemAction.Resume}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover play_arrow" aria-hidden="true"></span></button>`;
}
html += '<div class="cardOverlayButton-br flex">';
@@ -1162,17 +1166,17 @@ function getHoverMenuHtml(item, action) {
if (itemHelper.canMarkPlayed(item)) {
import('../../elements/emby-playstatebutton/emby-playstatebutton');
html += '<button is="emby-playstatebutton" type="button" data-action="none" class="' + btnCssClass + '" data-id="' + item.Id + '" data-serverid="' + item.ServerId + '" data-itemtype="' + item.Type + '" data-played="' + (userData.Played) + '"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check" aria-hidden="true"></span></button>';
html += `<button is="emby-playstatebutton" type="button" data-action="${ItemAction.None}" class="${btnCssClass}" data-id="${item.Id}" data-serverid="${item.ServerId}" data-itemtype="${item.Type}" data-played="${userData.Played}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check" aria-hidden="true"></span></button>`;
}
if (itemHelper.canRate(item)) {
const likes = userData.Likes == null ? '' : userData.Likes;
import('../../elements/emby-ratingbutton/emby-ratingbutton');
html += '<button is="emby-ratingbutton" type="button" data-action="none" class="' + btnCssClass + '" data-id="' + item.Id + '" data-serverid="' + item.ServerId + '" data-itemtype="' + item.Type + '" data-likes="' + likes + '" data-isfavorite="' + (userData.IsFavorite) + '"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite" aria-hidden="true"></span></button>';
html += `<button is="emby-ratingbutton" type="button" data-action="${ItemAction.None}" class="${btnCssClass}" data-id="${item.Id}" data-serverid="${item.ServerId}" data-itemtype="${item.Type}" data-likes="${likes}" data-isfavorite="${userData.IsFavorite}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite" aria-hidden="true"></span></button>`;
}
html += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="menu" title="${globalize.translate('ButtonMore')}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover more_vert" aria-hidden="true"></span></button>`;
html += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="${ItemAction.Menu}" title="${globalize.translate('ButtonMore')}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover more_vert" aria-hidden="true"></span></button>`;
html += '</div>';
html += '</div>';

View File

@@ -11,6 +11,7 @@ import {
resolveCardImageContainerCssClasses,
resolveMixedShapeByAspectRatio
} from './cardBuilderUtils';
import { ItemAction } from 'constants/itemAction';
describe('getDesiredAspect', () => {
test('"portrait" (case insensitive)', () => {
@@ -441,11 +442,11 @@ describe('isResizable', () => {
});
describe('resolveAction', () => {
test('default action', () => expect(resolveAction({ defaultAction: 'link', isFolder: false, isPhoto: false })).toEqual('link'));
test('default action', () => expect(resolveAction({ defaultAction: ItemAction.Link, isFolder: false, isPhoto: false })).toEqual(ItemAction.Link));
test('photo', () => expect(resolveAction({ defaultAction: 'link', isFolder: false, isPhoto: true })).toEqual('play'));
test('photo', () => expect(resolveAction({ defaultAction: ItemAction.Link, isFolder: false, isPhoto: true })).toEqual(ItemAction.Play));
test('default action is "play" and is folder', () => expect(resolveAction({ defaultAction: 'play', isFolder: true, isPhoto: true })).toEqual('link'));
test('default action is "play" and is folder', () => expect(resolveAction({ defaultAction: ItemAction.Play, isFolder: true, isPhoto: true })).toEqual(ItemAction.Link));
});
describe('resolveMixedShapeByAspectRatio', () => {

View File

@@ -1,7 +1,9 @@
import { CardShape } from '../../utils/card';
import { randomInt } from '../../utils/number';
import classNames from 'classnames';
import { ItemAction } from 'constants/itemAction';
import { CardShape } from 'utils/card';
import { randomInt } from 'utils/number';
const ASPECT_RATIOS = {
portrait: (2 / 3),
backdrop: (16 / 9),
@@ -20,12 +22,12 @@ export const isUsingLiveTvNaming = (itemType: string | null | undefined): boolea
* Resolves Card action to display
* @param opts options to determine the action to return
*/
export const resolveAction = (opts: { defaultAction: string, isFolder: boolean, isPhoto: boolean }): string => {
if (opts.defaultAction === 'play' && opts.isFolder) {
export const resolveAction = (opts: { defaultAction: ItemAction, isFolder: boolean, isPhoto: boolean }): ItemAction => {
if (opts.defaultAction === ItemAction.Play && opts.isFolder) {
// If this hard-coding is ever removed make sure to test nested photo albums
return 'link';
return ItemAction.Link;
} else if (opts.isPhoto) {
return 'play';
return ItemAction.Play;
} else {
return opts.defaultAction;
}

View File

@@ -1,6 +1,8 @@
import React, { type FC } from 'react';
import IconButton from '@mui/material/IconButton';
import InfoIcon from '@mui/icons-material/Info';
import { ItemAction } from 'constants/itemAction';
import globalize from 'lib/globalize';
interface InfoIconButtonProps {
@@ -11,7 +13,7 @@ const InfoIconButton: FC<InfoIconButtonProps> = ({ className }) => {
return (
<IconButton
className={className}
data-action='link'
data-action={ItemAction.Link}
title={globalize.translate('ButtonInfo')}
>
<InfoIcon />

View File

@@ -1,6 +1,8 @@
import React, { type FC } from 'react';
import IconButton from '@mui/material/IconButton';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { ItemAction } from 'constants/itemAction';
import globalize from 'lib/globalize';
interface MoreVertIconButtonProps {
@@ -12,7 +14,7 @@ const MoreVertIconButton: FC<MoreVertIconButtonProps> = ({ className, iconClassN
return (
<IconButton
className={className}
data-action='menu'
data-action={ItemAction.Menu}
title={globalize.translate('ButtonMore')}
>
<MoreVertIcon className={iconClassName} />

View File

@@ -1,11 +1,13 @@
import React, { type FC } from 'react';
import IconButton from '@mui/material/IconButton';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import { ItemAction } from 'constants/itemAction';
import globalize from 'lib/globalize';
interface PlayArrowIconButtonProps {
className: string;
action: string;
action: ItemAction;
title: string;
iconClassName?: string;
}

View File

@@ -1,6 +1,8 @@
import React, { type FC } from 'react';
import IconButton from '@mui/material/IconButton';
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
import { ItemAction } from 'constants/itemAction';
import globalize from 'lib/globalize';
interface PlaylistAddIconButtonProps {
@@ -11,7 +13,7 @@ const PlaylistAddIconButton: FC<PlaylistAddIconButtonProps> = ({ className }) =>
return (
<IconButton
className={className}
data-action='addtoplaylist'
data-action={ItemAction.AddToPlaylist}
title={globalize.translate('AddToPlaylist')}
>
<PlaylistAddIcon />

View File

@@ -1,6 +1,8 @@
import React, { type FC } from 'react';
import IconButton from '@mui/material/IconButton';
import { ItemAction } from 'constants/itemAction';
interface RightIconButtonsProps {
className?: string;
id: string;
@@ -12,7 +14,7 @@ const RightIconButtons: FC<RightIconButtonsProps> = ({ className, id, title, ico
return (
<IconButton
className={className}
data-action='custom'
data-action={ItemAction.Custom}
data-customaction={id}
title={title}
>

View File

@@ -9,12 +9,11 @@ import Button from '../../../elements/emby-button/Button';
import Input from '../../../elements/emby-input/Input';
type IProps = {
userId: string | null;
user: UserDto
};
const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
const UserPasswordForm: FunctionComponent<IProps> = ({ user }: IProps) => {
const element = useRef<HTMLDivElement>(null);
const user = useRef<UserDto>();
const libraryMenu = useMemo(async () => ((await import('../../../scripts/libraryMenu')).default), []);
const loadUser = useCallback(async () => {
@@ -25,22 +24,16 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
return;
}
if (!userId) {
console.error('[UserPasswordForm] missing user id');
return;
}
user.current = await window.ApiClient.getUser(userId);
const loggedInUser = await Dashboard.getCurrentUser();
if (!user.current.Policy || !user.current.Configuration) {
if (!user.Policy || !user.Configuration) {
throw new Error('Unexpected null user policy or configuration');
}
(await libraryMenu).setTitle(user.current.Name);
(await libraryMenu).setTitle(user.Name);
if (user.current.HasConfiguredPassword) {
if (!user.current.Policy?.IsAdministrator) {
if (user.HasConfiguredPassword) {
if (!user.Policy?.IsAdministrator) {
(page.querySelector('#btnResetPassword') as HTMLDivElement).classList.remove('hide');
}
(page.querySelector('#fldCurrentPassword') as HTMLDivElement).classList.remove('hide');
@@ -49,7 +42,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
(page.querySelector('#fldCurrentPassword') as HTMLDivElement).classList.add('hide');
}
const canChangePassword = loggedInUser?.Policy?.IsAdministrator || user.current.Policy.EnableUserPreferenceAccess;
const canChangePassword = loggedInUser?.Policy?.IsAdministrator || user.Policy.EnableUserPreferenceAccess;
(page.querySelector('.passwordSection') as HTMLDivElement).classList.toggle('hide', !canChangePassword);
import('../../autoFocuser').then(({ default: autoFocuser }) => {
@@ -61,7 +54,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
(page.querySelector('#txtCurrentPassword') as HTMLInputElement).value = '';
(page.querySelector('#txtNewPassword') as HTMLInputElement).value = '';
(page.querySelector('#txtNewPasswordConfirm') as HTMLInputElement).value = '';
}, [userId]);
}, [user, libraryMenu]);
useEffect(() => {
const page = element.current;
@@ -78,7 +71,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
const onSubmit = (e: Event) => {
if ((page.querySelector('#txtNewPassword') as HTMLInputElement).value != (page.querySelector('#txtNewPasswordConfirm') as HTMLInputElement).value) {
toast(globalize.translate('PasswordMatchError'));
} else if ((page.querySelector('#txtNewPassword') as HTMLInputElement).value == '' && user.current?.Policy?.IsAdministrator) {
} else if ((page.querySelector('#txtNewPassword') as HTMLInputElement).value == '' && user?.Policy?.IsAdministrator) {
toast(globalize.translate('PasswordMissingSaveError'));
} else {
loading.show();
@@ -90,7 +83,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
};
const savePassword = () => {
if (!userId) {
if (!user.Id) {
console.error('[UserPasswordForm.savePassword] missing user id');
return;
}
@@ -104,7 +97,7 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
currentPassword = '';
}
window.ApiClient.updateUserPassword(userId, currentPassword, newPassword).then(function () {
window.ApiClient.updateUserPassword(user.Id, currentPassword, newPassword).then(function () {
loading.hide();
toast(globalize.translate('PasswordSaved'));
@@ -121,26 +114,23 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
};
const resetPassword = () => {
if (!userId) {
console.error('[UserPasswordForm.resetPassword] missing user id');
return;
}
const msg = globalize.translate('PasswordResetConfirmation');
confirm(msg, globalize.translate('ResetPassword')).then(function () {
loading.show();
window.ApiClient.resetUserPassword(userId).then(function () {
loading.hide();
Dashboard.alert({
message: globalize.translate('PasswordResetComplete'),
title: globalize.translate('ResetPassword')
if (user.Id) {
window.ApiClient.resetUserPassword(user.Id).then(function () {
loading.hide();
Dashboard.alert({
message: globalize.translate('PasswordResetComplete'),
title: globalize.translate('ResetPassword')
});
loadUser().catch(err => {
console.error('[UserPasswordForm] failed to load user', err);
});
}).catch(err => {
console.error('[UserPasswordForm] failed to reset user password', err);
});
loadUser().catch(err => {
console.error('[UserPasswordForm] failed to load user', err);
});
}).catch(err => {
console.error('[UserPasswordForm] failed to reset user password', err);
});
}
}).catch(() => {
// confirm dialog was closed
});
@@ -148,7 +138,12 @@ const UserPasswordForm: FunctionComponent<IProps> = ({ userId }: IProps) => {
(page.querySelector('.updatePasswordForm') as HTMLFormElement).addEventListener('submit', onSubmit);
(page.querySelector('#btnResetPassword') as HTMLButtonElement).addEventListener('click', resetPassword);
}, [loadUser, userId]);
return () => {
(page.querySelector('.updatePasswordForm') as HTMLFormElement).removeEventListener('submit', onSubmit);
(page.querySelector('#btnResetPassword') as HTMLButtonElement).removeEventListener('click', resetPassword);
};
}, [loadUser, user]);
return (
<div ref={element}>

View File

@@ -11,15 +11,6 @@ import '../formdialog.scss';
import '../../elements/emby-button/emby-button';
import alert from '../alert';
function getSystemInfo() {
return systemInfo ? Promise.resolve(systemInfo) : ApiClient.getPublicSystemInfo().then(
info => {
systemInfo = info;
return info;
}
);
}
function onDialogClosed() {
loading.hide();
}
@@ -83,25 +74,14 @@ function getItem(cssClass, type, path, name) {
return html;
}
function getEditorHtml(options, systemInfo) {
function getEditorHtml(options) {
let html = '';
html += '<div class="formDialogContent scrollY">';
html += '<div class="dialogContentInner dialog-content-centered" style="padding-top:2em;">';
if (!options.pathReadOnly) {
const instruction = options.instruction ? `${escapeHtml(options.instruction)}<br/><br/>` : '';
if (!options.pathReadOnly && options.instruction) {
const instruction = `${escapeHtml(options.instruction)}<br/><br/>`;
html += '<div class="infoBanner" style="margin-bottom:1.5em;">';
html += instruction;
if (systemInfo.OperatingSystem.toLowerCase() === 'bsd') {
html += '<br/>';
html += '<br/>';
html += globalize.translate('MessageDirectoryPickerBSDInstruction');
html += '<br/>';
} else if (systemInfo.OperatingSystem.toLowerCase() === 'linux') {
html += '<br/>';
html += '<br/>';
html += globalize.translate('MessageDirectoryPickerLinuxInstruction');
html += '<br/>';
}
html += '</div>';
}
html += '<form style="margin:auto;">';
@@ -123,14 +103,6 @@ function getEditorHtml(options, systemInfo) {
if (!readOnlyAttribute) {
html += '<div class="results paperList" style="max-height: 200px; overflow-y: auto;"></div>';
}
if (options.enableNetworkSharePath) {
html += '<div class="inputContainer" style="margin-top:2em;">';
html += `<input is="emby-input" id="txtNetworkPath" type="text" label="${globalize.translate('LabelOptionalNetworkPath')}"/>`;
html += '<div class="fieldDescription">';
html += globalize.translate('LabelOptionalNetworkPathHelp', '<b>\\\\server</b>', '<b>\\\\192.168.1.101</b>');
html += '</div>';
html += '</div>';
}
html += '<div class="formDialogFooter">';
html += `<button is="emby-button" type="submit" class="raised button-submit block formDialogFooterItem">${globalize.translate('ButtonOk')}</button>`;
html += '</div>';
@@ -209,12 +181,10 @@ function initEditor(content, options, fileOptions) {
content.querySelector('form').addEventListener('submit', function(e) {
if (options.callback) {
let networkSharePath = this.querySelector('#txtNetworkPath');
networkSharePath = networkSharePath ? networkSharePath.value : null;
const path = this.querySelector('#txtDirectoryPickerPath').value;
validatePath(path, options.validateWriteable, ApiClient).then(
options.callback(path, networkSharePath)
).catch(() => { /* no-op */ });
validatePath(path, options.validateWriteable, ApiClient)
.then(options.callback(path))
.catch(() => { /* no-op */ });
}
e.preventDefault();
e.stopPropagation();
@@ -236,7 +206,6 @@ function getDefaultPath(options) {
}
}
let systemInfo;
class DirectoryBrowser {
currentDialog;
@@ -251,10 +220,8 @@ class DirectoryBrowser {
if (options.includeFiles != null) {
fileOptions.includeFiles = options.includeFiles;
}
Promise.all([getSystemInfo(), getDefaultPath(options)]).then(
responses => {
const fetchedSystemInfo = responses[0];
const fetchedInitialPath = responses[1];
getDefaultPath(options).then(
fetchedInitialPath => {
const dlg = dialogHelper.createDialog({
size: 'small',
removeOnClose: true,
@@ -272,7 +239,7 @@ class DirectoryBrowser {
html += escapeHtml(options.header || '') || globalize.translate('HeaderSelectPath');
html += '</h3>';
html += '</div>';
html += getEditorHtml(options, fetchedSystemInfo);
html += getEditorHtml(options);
dlg.innerHTML = html;
initEditor(dlg, options, fileOptions);
dlg.addEventListener('close', onDialogClosed);
@@ -282,10 +249,6 @@ class DirectoryBrowser {
});
this.currentDialog = dlg;
dlg.querySelector('#txtDirectoryPickerPath').value = fetchedInitialPath;
const txtNetworkPath = dlg.querySelector('#txtNetworkPath');
if (txtNetworkPath) {
txtNetworkPath.value = options.networkSharePath || '';
}
if (!options.pathReadOnly) {
refreshDirectoryBrowser(dlg, fetchedInitialPath, fileOptions, true);
}

View File

@@ -89,6 +89,7 @@ function loadForm(context, user, userSettings) {
}
context.querySelector('.selectDashboardThemeContainer').classList.toggle('hide', !user.Policy.IsAdministrator);
context.querySelector('.txtSlideshowIntervalContainer').classList.remove('hide');
if (appHost.supports(AppFeature.Screensaver)) {
context.querySelector('.selectScreensaverContainer').classList.remove('hide');
@@ -112,6 +113,7 @@ function loadForm(context, user, userSettings) {
loadScreensavers(context, userSettings);
context.querySelector('#txtBackdropScreensaverInterval').value = userSettings.backdropScreensaverInterval();
context.querySelector('#txtSlideshowInterval').value = userSettings.slideshowInterval();
context.querySelector('#txtScreensaverTime').value = userSettings.screensaverTime();
context.querySelector('.chkDisplayMissingEpisodes').checked = user.Configuration.DisplayMissingEpisodes || false;
@@ -157,6 +159,7 @@ function saveUser(context, user, userSettingsInstance, apiClient) {
userSettingsInstance.dashboardTheme(context.querySelector('#selectDashboardTheme').value);
userSettingsInstance.screensaver(context.querySelector('.selectScreensaver').value);
userSettingsInstance.backdropScreensaverInterval(context.querySelector('#txtBackdropScreensaverInterval').value);
userSettingsInstance.slideshowInterval(context.querySelector('#txtSlideshowInterval').value);
userSettingsInstance.screensaverTime(context.querySelector('#txtScreensaverTime').value);
userSettingsInstance.libraryPageSize(context.querySelector('#txtLibraryPageSize').value);

View File

@@ -172,7 +172,6 @@
<option value="desktop">${Desktop}</option>
<option value="mobile">${Mobile}</option>
<option value="tv">${TV}</option>
<option value="experimental">${Experimental}</option>
</select>
<div class="fieldDescription">${DisplayModeHelp}</div>
<div class="fieldDescription">${LabelPleaseRestart}</div>
@@ -214,6 +213,11 @@
<div class="fieldDescription">${LabelBackdropScreensaverIntervalHelp}</div>
</div>
<div class="inputContainer hide txtSlideshowIntervalContainer inputContainer-withDescription">
<input is="emby-input" type="number" id="txtSlideshowInterval" pattern="[0-9]*" required="required" min="1" max="3600" step="1" label="${LabelSlideshowInterval}" />
<div class="fieldDescription">${LabelSlideshowIntervalHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkFadein" />

View File

@@ -1,8 +1,11 @@
import escapeHtml from 'escape-html';
import { ItemAction } from 'constants/itemAction';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import inputManager from '../../scripts/inputManager';
import browser from '../../scripts/browser';
import globalize from '../../lib/globalize';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import Events from '../../utils/events.ts';
import scrollHelper from '../../scripts/scrollHelper';
import serverNotifications from '../../scripts/serverNotifications';
@@ -15,6 +18,7 @@ import imageLoader from '../images/imageLoader';
import layoutManager from '../layoutManager';
import itemShortcuts from '../shortcuts';
import dom from '../../utils/dom';
import './guide.scss';
import './programs.scss';
import 'material-design-icons-iconfont';
@@ -26,6 +30,7 @@ import '../../elements/emby-tabs/emby-tabs';
import '../../elements/emby-scroller/emby-scroller';
import '../../styles/flexstyles.scss';
import 'webcomponents.js/webcomponents-lite';
import template from './tvguide.template.html';
function showViewSettings(instance) {
@@ -441,7 +446,7 @@ function Guide(options) {
html += '<div class="' + outerCssClass + '" data-channelid="' + channel.Id + '">';
const clickAction = layoutManager.tv ? 'link' : 'programdialog';
const clickAction = layoutManager.tv ? ItemAction.Link : ItemAction.ProgramDialog;
const categories = self.categoryOptions.categories || [];
const displayMovieContent = !categories.length || categories.indexOf('movies') !== -1;
@@ -607,7 +612,7 @@ function Guide(options) {
title.push(channel.Name);
}
html += '<button title="' + escapeHtml(title.join(' ')) + '" type="button" class="' + cssClass + '"' + ' data-action="link" data-isfolder="' + channel.IsFolder + '" data-id="' + channel.Id + '" data-serverid="' + channel.ServerId + '" data-type="' + channel.Type + '">';
html += `<button title="${escapeHtml(title.join(' '))}" type="button" class="${cssClass}" data-action="${ItemAction.Link}" data-isfolder="${channel.IsFolder}" data-id="${channel.Id}" data-serverid="${channel.ServerId}" data-type="${channel.Type}">`;
if (hasChannelImage) {
const url = apiClient.getScaledImageUrl(channel.Id, {

View File

@@ -1,4 +1,8 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
import { AppFeature } from 'constants/appFeature';
import { toApi } from 'utils/jellyfin-apiclient/compat';
import browser from '../scripts/browser';
import { copy } from '../scripts/clipboard';
@@ -12,7 +16,6 @@ import itemHelper, { canEditPlaylist } from './itemHelper';
import { playbackManager } from './playback/playbackmanager';
import toast from './toast/toast';
import * as userSettings from '../scripts/settings/userSettings';
import { AppFeature } from 'constants/appFeature';
/** Item types that support downloading all children. */
const DOWNLOAD_ALL_TYPES = [
@@ -387,6 +390,7 @@ function executeCommand(item, id, options) {
const itemId = item.Id;
const serverId = item.ServerId;
const apiClient = ServerConnections.getApiClient(serverId);
const api = toApi(apiClient);
return new Promise(function (resolve, reject) {
// eslint-disable-next-line sonarjs/max-switch-cases
@@ -411,9 +415,9 @@ function executeCommand(item, id, options) {
break;
case 'download':
import('../scripts/fileDownloader').then((fileDownloader) => {
const downloadHref = apiClient.getItemDownloadUrl(itemId);
const url = getLibraryApi(api).getDownloadUrl({ itemId });
fileDownloader.download([{
url: downloadHref,
url,
item,
itemId,
serverId,
@@ -429,9 +433,9 @@ function executeCommand(item, id, options) {
const downloads = items
.filter(i => i.CanDownload)
.map(i => {
const downloadHref = apiClient.getItemDownloadUrl(i.Id);
const url = getLibraryApi(api).getDownloadUrl({ itemId: i.Id });
return {
url: downloadHref,
url,
item: i,
itemId: i.Id,
serverId,
@@ -478,7 +482,7 @@ function executeCommand(item, id, options) {
break;
}
case 'copy-stream': {
const downloadHref = apiClient.getItemDownloadUrl(itemId);
const downloadHref = getLibraryApi(api).getDownloadUrl({ itemId });
copy(downloadHref).then(() => {
toast(globalize.translate('CopyStreamURLSuccess'));
}).catch(() => {

View File

@@ -133,8 +133,8 @@ function getMediaSourceHtml(user, item, version) {
}
attributes.push(createAttribute(globalize.translate('MediaInfoInterlaced'), (stream.IsInterlaced ? 'Yes' : 'No')));
}
if ((stream.AverageFrameRate || stream.RealFrameRate) && stream.Type === 'Video') {
attributes.push(createAttribute(globalize.translate('MediaInfoFramerate'), (stream.AverageFrameRate || stream.RealFrameRate)));
if (stream.ReferenceFrameRate && stream.Type === 'Video') {
attributes.push(createAttribute(globalize.translate('MediaInfoFramerate'), stream.ReferenceFrameRate));
}
if (stream.ChannelLayout) {
attributes.push(createAttribute(globalize.translate('MediaInfoLayout'), stream.ChannelLayout));

View File

@@ -1,228 +0,0 @@
import { playbackManager } from './playback/playbackmanager';
import serverNotifications from '../scripts/serverNotifications';
import Events from '../utils/events.ts';
function onUserDataChanged() {
const instance = this;
const eventsToMonitor = getEventsToMonitor(instance);
// TODO: Check user data change reason?
if (eventsToMonitor.indexOf('markfavorite') !== -1
|| eventsToMonitor.indexOf('markplayed') !== -1
) {
instance.notifyRefreshNeeded();
}
}
function getEventsToMonitor(instance) {
const options = instance.options;
const monitor = options ? options.monitorEvents : null;
if (monitor) {
return monitor.split(',');
}
return [];
}
function notifyTimerRefresh() {
const instance = this;
if (getEventsToMonitor(instance).indexOf('timers') !== -1) {
instance.notifyRefreshNeeded();
}
}
function notifySeriesTimerRefresh() {
const instance = this;
if (getEventsToMonitor(instance).indexOf('seriestimers') !== -1) {
instance.notifyRefreshNeeded();
}
}
function onLibraryChanged(e, apiClient, data) {
const instance = this;
const eventsToMonitor = getEventsToMonitor(instance);
if (eventsToMonitor.indexOf('seriestimers') !== -1 || eventsToMonitor.indexOf('timers') !== -1) {
// yes this is an assumption
return;
}
const itemsAdded = data.ItemsAdded || [];
const itemsRemoved = data.ItemsRemoved || [];
if (!itemsAdded.length && !itemsRemoved.length) {
return;
}
const options = instance.options || {};
const parentId = options.parentId;
if (parentId) {
const foldersAddedTo = data.FoldersAddedTo || [];
const foldersRemovedFrom = data.FoldersRemovedFrom || [];
const collectionFolders = data.CollectionFolders || [];
if (foldersAddedTo.indexOf(parentId) === -1 && foldersRemovedFrom.indexOf(parentId) === -1 && collectionFolders.indexOf(parentId) === -1) {
return;
}
}
instance.notifyRefreshNeeded();
}
function onPlaybackStopped(e, stopInfo) {
const instance = this;
const state = stopInfo.state;
const eventsToMonitor = getEventsToMonitor(instance);
if (state.NowPlayingItem?.MediaType === 'Video') {
if (eventsToMonitor.indexOf('videoplayback') !== -1) {
instance.notifyRefreshNeeded(true);
return;
}
} else if (state.NowPlayingItem?.MediaType === 'Audio' && eventsToMonitor.indexOf('audioplayback') !== -1) {
instance.notifyRefreshNeeded(true);
return;
}
}
function addNotificationEvent(instance, name, handler, owner) {
const localHandler = handler.bind(instance);
owner = owner || serverNotifications;
Events.on(owner, name, localHandler);
instance['event_' + name] = localHandler;
}
function removeNotificationEvent(instance, name, owner) {
const handler = instance['event_' + name];
if (handler) {
owner = owner || serverNotifications;
Events.off(owner, name, handler);
instance['event_' + name] = null;
}
}
class ItemsRefresher {
constructor(options) {
this.options = options || {};
addNotificationEvent(this, 'UserDataChanged', onUserDataChanged);
addNotificationEvent(this, 'TimerCreated', notifyTimerRefresh);
addNotificationEvent(this, 'SeriesTimerCreated', notifySeriesTimerRefresh);
addNotificationEvent(this, 'TimerCancelled', notifyTimerRefresh);
addNotificationEvent(this, 'SeriesTimerCancelled', notifySeriesTimerRefresh);
addNotificationEvent(this, 'LibraryChanged', onLibraryChanged);
addNotificationEvent(this, 'playbackstop', onPlaybackStopped, playbackManager);
}
pause() {
clearRefreshInterval(this, true);
this.paused = true;
}
resume(options) {
this.paused = false;
const refreshIntervalEndTime = this.refreshIntervalEndTime;
if (refreshIntervalEndTime) {
const remainingMs = refreshIntervalEndTime - new Date().getTime();
if (remainingMs > 0 && !this.needsRefresh) {
resetRefreshInterval(this, remainingMs);
} else {
this.needsRefresh = true;
this.refreshIntervalEndTime = null;
}
}
if (this.needsRefresh || (options?.refresh)) {
return this.refreshItems();
}
return Promise.resolve();
}
refreshItems() {
if (!this.fetchData) {
return Promise.resolve();
}
if (this.paused) {
this.needsRefresh = true;
return Promise.resolve();
}
this.needsRefresh = false;
return this.fetchData().then(onDataFetched.bind(this));
}
notifyRefreshNeeded(isInForeground) {
if (this.paused) {
this.needsRefresh = true;
return;
}
const timeout = this.refreshTimeout;
if (timeout) {
clearTimeout(timeout);
}
if (isInForeground === true) {
this.refreshItems();
} else {
this.refreshTimeout = setTimeout(this.refreshItems.bind(this), 10000);
}
}
destroy() {
clearRefreshInterval(this);
removeNotificationEvent(this, 'UserDataChanged');
removeNotificationEvent(this, 'TimerCreated');
removeNotificationEvent(this, 'SeriesTimerCreated');
removeNotificationEvent(this, 'TimerCancelled');
removeNotificationEvent(this, 'SeriesTimerCancelled');
removeNotificationEvent(this, 'LibraryChanged');
removeNotificationEvent(this, 'playbackstop', playbackManager);
this.fetchData = null;
this.options = null;
}
}
function clearRefreshInterval(instance, isPausing) {
if (instance.refreshInterval) {
clearInterval(instance.refreshInterval);
instance.refreshInterval = null;
if (!isPausing) {
instance.refreshIntervalEndTime = null;
}
}
}
function resetRefreshInterval(instance, intervalMs) {
clearRefreshInterval(instance);
if (!intervalMs) {
const options = instance.options;
if (options) {
intervalMs = options.refreshIntervalMs;
}
}
if (intervalMs) {
instance.refreshInterval = setInterval(instance.notifyRefreshNeeded.bind(instance), intervalMs);
instance.refreshIntervalEndTime = new Date().getTime() + intervalMs;
}
}
function onDataFetched(result) {
resetRefreshInterval(this);
if (this.afterRefresh) {
this.afterRefresh(result);
}
}
export default ItemsRefresher;

View File

@@ -1,3 +1,4 @@
import { LayoutMode } from 'constants/layoutMode';
import { appHost } from './apphost';
import browser from '../scripts/browser';
@@ -14,51 +15,47 @@ function setLayout(instance, layout, selectedLayout) {
}
}
export const SETTING_KEY = 'layout';
class LayoutManager {
tv = false;
mobile = false;
desktop = false;
experimental = false;
setLayout(layout, save) {
if (!layout || layout === 'auto') {
setLayout(layout = '', save = true) {
const layoutValue = (!layout || layout === LayoutMode.Auto) ? '' : layout;
if (!layoutValue) {
this.autoLayout();
if (save !== false) {
appSettings.set('layout', '');
}
} else {
setLayout(this, 'mobile', layout);
setLayout(this, 'tv', layout);
setLayout(this, 'desktop', layout);
this.experimental = layout === 'experimental';
if (this.experimental) {
const legacyLayoutMode = browser.mobile ? 'mobile' : this.defaultLayout || 'desktop';
setLayout(this, legacyLayoutMode, legacyLayoutMode);
}
if (save !== false) {
appSettings.set('layout', layout);
}
setLayout(this, LayoutMode.Mobile, layoutValue);
setLayout(this, LayoutMode.Tv, layoutValue);
setLayout(this, LayoutMode.Desktop, layoutValue);
}
console.debug('[LayoutManager] using layout mode', layoutValue);
this.experimental = layoutValue === LayoutMode.Experimental;
if (this.experimental) {
const legacyLayoutMode = browser.mobile ? LayoutMode.Mobile : LayoutMode.Desktop;
console.debug('[LayoutManager] using legacy layout mode', legacyLayoutMode);
setLayout(this, legacyLayoutMode, legacyLayoutMode);
}
if (save) appSettings.set(SETTING_KEY, layoutValue);
Events.trigger(this, 'modechange');
}
getSavedLayout() {
return appSettings.get('layout');
return appSettings.get(SETTING_KEY);
}
autoLayout() {
// Take a guess at initial layout. The consuming app can override
if (browser.mobile) {
this.setLayout('mobile', false);
} else if (browser.tv || browser.xboxOne || browser.ps4) {
this.setLayout('tv', false);
} else {
this.setLayout(this.defaultLayout || 'tv', false);
}
// Take a guess at initial layout. The consuming app can override.
// NOTE: The fallback to TV mode seems like an outdated choice. TVs should be detected properly or override the
// default layout.
this.setLayout(browser.tv ? LayoutMode.Tv : this.defaultLayout || LayoutMode.Tv, false);
}
init() {

View File

@@ -4,6 +4,8 @@ import DragHandleIcon from '@mui/icons-material/DragHandle';
import Box from '@mui/material/Box';
import useIndicator from 'components/indicators/useIndicator';
import { ItemAction } from 'constants/itemAction';
import PrimaryMediaInfo from '../../mediainfo/PrimaryMediaInfo';
import ListContentWrapper from './ListContentWrapper';
import ListItemBody from './ListItemBody';
@@ -20,7 +22,7 @@ interface ListContentProps {
enableOverview?: boolean;
enableSideMediaInfo?: boolean;
clickEntireItem?: boolean;
action?: string;
action?: ItemAction;
isLargeStyle: boolean;
downloadWidth?: number;
}

View File

@@ -1,7 +1,14 @@
import React, { type FC } from 'react';
import classNames from 'classnames';
import Box from '@mui/material/Box';
import Media from 'components/common/Media';
import PlayArrowIconButton from 'components/common/PlayArrowIconButton';
import { ItemAction } from 'constants/itemAction';
import { useApi } from 'hooks/useApi';
import type { ItemDto } from 'types/base/models/item-dto';
import type { ListOptions } from 'types/listOptions';
import useIndicator from '../../indicators/useIndicator';
import layoutManager from '../../layoutManager';
import { getDefaultBackgroundClass } from '../../cardbuilder/cardBuilderUtils';
@@ -11,15 +18,10 @@ import {
getImageUrl
} from './listHelper';
import Media from 'components/common/Media';
import PlayArrowIconButton from 'components/common/PlayArrowIconButton';
import type { ItemDto } from 'types/base/models/item-dto';
import type { ListOptions } from 'types/listOptions';
interface ListImageContainerProps {
item: ItemDto;
listOptions: ListOptions;
action?: string | null;
action?: ItemAction | null;
isLargeStyle: boolean;
clickEntireItem?: boolean;
downloadWidth?: number;
@@ -55,7 +57,7 @@ const ListImageContainer: FC<ListImageContainerProps> = ({
const playOnImageClick = listOptions.imagePlayButton && !layoutManager.tv;
const imageAction = playOnImageClick ? 'link' : action;
const imageAction = playOnImageClick ? ItemAction.Link : action;
const btnCssClass =
'paper-icon-button-light listItemImageButton itemAction';
@@ -85,7 +87,7 @@ const ListImageContainer: FC<ListImageContainerProps> = ({
<PlayArrowIconButton
className={btnCssClass}
action={
canResume(playbackPositionTicks) ? 'resume' : 'play'
canResume(playbackPositionTicks) ? ItemAction.Resume : ItemAction.Play
}
title={
canResume(playbackPositionTicks) ?

View File

@@ -3,15 +3,16 @@ import classNames from 'classnames';
import Box from '@mui/material/Box';
import TextLines from 'components/common/textLines/TextLines';
import PrimaryMediaInfo from '../../mediainfo/PrimaryMediaInfo';
import { ItemAction } from 'constants/itemAction';
import type { ItemDto } from 'types/base/models/item-dto';
import type { ListOptions } from 'types/listOptions';
import PrimaryMediaInfo from '../../mediainfo/PrimaryMediaInfo';
interface ListItemBodyProps {
item: ItemDto;
listOptions: ListOptions;
action?: string | null;
action?: ItemAction | null;
isLargeStyle?: boolean;
clickEntireItem?: boolean;
enableContentWrapper?: boolean;

View File

@@ -2,13 +2,16 @@ import classNames from 'classnames';
import React, { type FC, type PropsWithChildren } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import layoutManager from '../../layoutManager';
import { ItemAction } from 'constants/itemAction';
import type { DataAttributes } from 'types/dataAttributes';
import layoutManager from '../../layoutManager';
interface ListWrapperProps {
index: number | undefined;
title?: string | null;
action?: string | null;
action?: ItemAction | null;
dataAttributes?: DataAttributes;
className?: string;
}

View File

@@ -1,6 +1,8 @@
import classNames from 'classnames';
import { getDataAttributes } from 'utils/items';
import layoutManager from 'components/layoutManager';
import { ItemAction } from 'constants/itemAction';
import { getDataAttributes } from 'utils/items';
import type { ItemDto } from 'types/base/models/item-dto';
import type { ListOptions } from 'types/listOptions';
@@ -11,7 +13,7 @@ interface UseListProps {
}
function useList({ item, listOptions }: UseListProps) {
const action = listOptions.action ?? 'link';
const action = listOptions.action ?? ItemAction.Link;
const isLargeStyle = listOptions.imageSize === 'large';
const enableOverview = listOptions.enableOverview;
const clickEntireItem = !!layoutManager.tv;

View File

@@ -4,7 +4,13 @@
* @module components/listview/listview
*/
import DOMPurify from 'dompurify';
import escapeHtml from 'escape-html';
import markdownIt from 'markdown-it';
import { ItemAction } from 'constants/itemAction';
import { getDefaultBackgroundClass } from '../cardbuilder/cardBuilderUtils';
import itemHelper from '../itemHelper';
import mediaInfo from '../mediainfo/mediainfo';
import indicators from '../indicators/indicators';
@@ -13,12 +19,10 @@ import globalize from '../../lib/globalize';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import datetime from '../../scripts/datetime';
import cardBuilder from '../cardbuilder/cardBuilder';
import './listview.scss';
import '../../elements/emby-ratingbutton/emby-ratingbutton';
import '../../elements/emby-playstatebutton/emby-playstatebutton';
import { getDefaultBackgroundClass } from '../cardbuilder/cardBuilderUtils';
import markdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
function getIndex(item, options) {
if (options.index === 'disc') {
@@ -165,7 +169,7 @@ function getRightButtonsHtml(options) {
for (let i = 0, length = options.rightButtons.length; i < length; i++) {
const button = options.rightButtons[i];
html += `<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="custom" data-customaction="${button.id}" title="${button.title}"><span class="material-icons ${button.icon}" aria-hidden="true"></span></button>`;
html += `<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="${ItemAction.Custom}" data-customaction="${button.id}" title="${button.title}"><span class="material-icons ${button.icon}" aria-hidden="true"></span></button>`;
}
return html;
@@ -175,7 +179,7 @@ export function getListViewHtml(options) {
const items = options.items;
let groupTitle = '';
const action = options.action || 'link';
const action = options.action || ItemAction.Link;
const isLargeStyle = options.imageSize === 'large';
const enableOverview = options.enableOverview;
@@ -277,7 +281,7 @@ export function getListViewHtml(options) {
imageClass += ' itemAction';
}
const imageAction = playOnImageClick ? 'link' : action;
const imageAction = playOnImageClick ? ItemAction.Link : action;
if (imgUrl) {
html += '<div data-action="' + imageAction + '" class="' + imageClass + ' lazy" data-src="' + imgUrl + '" item-icon>';
@@ -298,7 +302,7 @@ export function getListViewHtml(options) {
}
if (playOnImageClick) {
html += `<button is="paper-icon-button-light" class="listItemImageButton itemAction" data-action="resume" title="${globalize.translate('Play')}"><span class="material-icons listItemImageButton-icon play_arrow" aria-hidden="true"></span></button>`;
html += `<button is="paper-icon-button-light" class="listItemImageButton itemAction" data-action="${ItemAction.Resume}"><span class="material-icons listItemImageButton-icon play_arrow" aria-hidden="true"></span></button>`;
}
const progressHtml = indicators.getProgressBarHtml(item, {
@@ -449,11 +453,11 @@ export function getListViewHtml(options) {
if (!clickEntireItem) {
if (options.addToListButton) {
html += `<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="addtoplaylist" title="${globalize.translate('AddToPlaylist')}"><span class="material-icons playlist_add" aria-hidden="true"></span></button>`;
html += `<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="${ItemAction.AddToPlaylist}"><span class="material-icons playlist_add" aria-hidden="true"></span></button>`;
}
if (options.infoButton) {
html += `<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="link" title="${globalize.translate('ButtonInfo')}"><span class="material-icons info_outline" aria-hidden="true"></span></button>`;
html += `<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="${ItemAction.Link}"><span class="material-icons info_outline" aria-hidden="true"></span></button>`;
}
if (options.rightButtons) {
@@ -474,7 +478,7 @@ export function getListViewHtml(options) {
}
if (options.moreButton !== false) {
html += `<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="menu" title="${globalize.translate('ButtonMore')}"><span class="material-icons more_vert" aria-hidden="true"></span></button>`;
html += `<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="${ItemAction.Menu}"><span class="material-icons more_vert" aria-hidden="true"></span></button>`;
}
}
html += '</div>';
@@ -498,5 +502,5 @@ export function getListViewHtml(options) {
}
export default {
getListViewHtml
getListViewHtml: getListViewHtml
};

View File

@@ -122,9 +122,9 @@ function onAddButtonClick() {
import('../directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser();
picker.show({
callback: function (path, networkSharePath) {
callback: function (path) {
if (path) {
addMediaLocation(page, path, networkSharePath);
addMediaLocation(page, path);
}
picker.close();
@@ -161,7 +161,7 @@ function renderPaths(page) {
}
}
function addMediaLocation(page, path, networkSharePath) {
function addMediaLocation(page, path) {
const pathLower = path.toLowerCase();
const pathFilter = pathInfos.filter(p => {
return p.Path.toLowerCase() == pathLower;
@@ -172,10 +172,6 @@ function addMediaLocation(page, path, networkSharePath) {
Path: path
};
if (networkSharePath) {
pathInfo.NetworkPath = networkSharePath;
}
pathInfos.push(pathInfo);
renderPaths(page);
}

View File

@@ -56,10 +56,10 @@ function onEditLibrary() {
return false;
}
function addMediaLocation(page, path, networkSharePath) {
function addMediaLocation(page, path) {
const virtualFolder = currentOptions.library;
const refreshAfterChange = currentOptions.refresh;
ApiClient.addMediaPath(virtualFolder.Name, path, networkSharePath, refreshAfterChange).then(() => {
ApiClient.addMediaPath(virtualFolder.Name, path, null, refreshAfterChange).then(() => {
hasChanges = true;
refreshLibraryFromServer(page);
}, () => {
@@ -67,11 +67,10 @@ function addMediaLocation(page, path, networkSharePath) {
});
}
function updateMediaLocation(page, path, networkSharePath) {
function updateMediaLocation(page, path) {
const virtualFolder = currentOptions.library;
ApiClient.updateMediaPath(virtualFolder.Name, {
Path: path,
NetworkPath: networkSharePath
Path: path
}).then(() => {
hasChanges = true;
refreshLibraryFromServer(page);
@@ -115,7 +114,7 @@ function onListItemClick(e) {
return;
}
showDirectoryBrowser(dom.parentWithClass(listItem, 'dlg-libraryeditor'), originalPath, pathInfo.NetworkPath);
showDirectoryBrowser(dom.parentWithClass(listItem, 'dlg-libraryeditor'), originalPath);
}
}
@@ -174,19 +173,18 @@ function onAddButtonClick() {
showDirectoryBrowser(dom.parentWithClass(this, 'dlg-libraryeditor'));
}
function showDirectoryBrowser(context, originalPath, networkPath) {
function showDirectoryBrowser(context, originalPath) {
import('../directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser();
picker.show({
pathReadOnly: originalPath != null,
path: originalPath,
networkSharePath: networkPath,
callback: function (path, networkSharePath) {
callback: function (path) {
if (path) {
if (originalPath) {
updateMediaLocation(context, originalPath, networkSharePath);
updateMediaLocation(context, originalPath);
} else {
addMediaLocation(context, path, networkSharePath);
addMediaLocation(context, path);
}
}

View File

@@ -1944,7 +1944,7 @@ export class PlaybackManager {
} else if (firstItem.IsFolder && firstItem.CollectionType === 'musicvideos') {
return getItemsForPlayback(serverId, mergePlaybackQueries({
ParentId: firstItem.Id,
Filters: 'IsNotFolder',
Filters: 'IsFolder',
Recursive: true,
SortBy: options.shuffle ? 'Random' : 'SortName',
MediaTypes: 'Video',
@@ -3483,7 +3483,7 @@ export class PlaybackManager {
const nextItemPlayOptions = nextItem ? (nextItem.item.playOptions || getDefaultPlayOptions()) : getDefaultPlayOptions();
const newPlayer = nextItem ? getPlayer(nextItem.item, nextItemPlayOptions) : null;
if (newPlayer !== player) {
if (!newPlayer) {
data.streamInfo = null;
destroyPlayer(player);
removeCurrentPlayer(player);
@@ -3491,12 +3491,21 @@ export class PlaybackManager {
if (errorOccurred) {
showPlaybackInfoErrorMessage(self, 'PlaybackError' + displayErrorCode);
} else if (nextItem) {
} else if (newPlayer) {
const apiClient = ServerConnections.getApiClient(nextItem.item.ServerId);
apiClient.getCurrentUser().then(function (user) {
if (user.Configuration.EnableNextEpisodeAutoPlay || nextMediaType !== MediaType.Video) {
self.nextTrack();
if (newPlayer !== player) {
Events.trigger(self, 'playbackstop', [{
player,
state,
nextItem,
nextMediaType
}]);
}
}
});
}

View File

@@ -204,7 +204,7 @@ function getDisplayTranscodeFps(session, player) {
const mediaSource = playbackManager.currentMediaSource(player) || {};
const videoStream = (mediaSource.MediaStreams || []).find((s) => s.Type === 'Video') || {};
const originalFramerate = videoStream.ReferenceFrameRate || videoStream.RealFrameRate;
const originalFramerate = videoStream.ReferenceFrameRate;
const transcodeFramerate = session.TranscodingInfo.Framerate;
if (!originalFramerate) {

View File

@@ -189,8 +189,7 @@ function populatePlaylists(editorOptions: PlaylistEditorOptions, panel: DialogEl
userId: apiClient.getCurrentUserId(),
includeItemTypes: [ BaseItemKind.Playlist ],
sortBy: [ ItemSortBy.SortName ],
recursive: true,
enableUserData: false
recursive: true
})
.then(({ data }) => {
return Promise.all((data.Items || []).map(item => {

View File

@@ -3,6 +3,7 @@ import escapeHtml from 'escape-html';
import { getImageUrl } from 'apps/stable/features/playback/utils/image';
import { getItemTextLines } from 'apps/stable/features/playback/utils/itemText';
import { AppFeature } from 'constants/appFeature';
import { ItemAction } from 'constants/itemAction';
import datetime from '../../scripts/datetime';
import { clearBackdrop, setBackdrops } from '../backdrop/backdrop';
@@ -16,6 +17,9 @@ import { ServerConnections } from 'lib/jellyfin-apiclient';
import layoutManager from '../layoutManager';
import * as userSettings from '../../scripts/settings/userSettings';
import itemContextMenu from '../itemContextMenu';
import toast from '../toast/toast';
import { appRouter } from '../router/appRouter';
import { getDefaultBackgroundClass } from '../cardbuilder/cardBuilderUtils';
import '../cardbuilder/card.scss';
import '../../elements/emby-button/emby-button';
@@ -24,9 +28,6 @@ import '../../elements/emby-itemscontainer/emby-itemscontainer';
import './remotecontrol.scss';
import '../../elements/emby-ratingbutton/emby-ratingbutton';
import '../../elements/emby-slider/emby-slider';
import toast from '../toast/toast';
import { appRouter } from '../router/appRouter';
import { getDefaultBackgroundClass } from '../cardbuilder/cardBuilderUtils';
let showMuteButton = true;
let showVolumeSlider = true;
@@ -208,7 +209,7 @@ function setImageUrl(context, state, url) {
context.querySelector('.nowPlayingPageImage').classList.toggle('nowPlayingPageImageAudio', item.Type === 'Audio');
context.querySelector('.nowPlayingPageImage').classList.toggle('nowPlayingPageImagePoster', item.Type !== 'Audio');
} else {
imgContainer.innerHTML = '<div class="nowPlayingPageImageContainerNoAlbum"><button data-action="link" class="cardImageContainer coveredImage ' + getDefaultBackgroundClass(item.Name) + ' cardContent cardContent-shadow itemAction"><span class="cardImageIcon material-icons album" aria-hidden="true"></span></button></div>';
imgContainer.innerHTML = `<div class="nowPlayingPageImageContainerNoAlbum"><button data-action="${ItemAction.Link}" class="cardImageContainer coveredImage ${getDefaultBackgroundClass(item.Name)} cardContent cardContent-shadow itemAction"><span class="cardImageIcon material-icons album" aria-hidden="true"></span></button></div>`;
}
}

View File

@@ -42,7 +42,6 @@ const FallbackRoute = () => {
id='fallbackPage'
title={globalize.translate('HeaderPageNotFound')}
className='mainAnimatedPage libraryPage'
shouldAutoFocus
>
<div className='padded-left padded-right'>
<h1>{globalize.translate('HeaderPageNotFound')}</h1>

View File

@@ -6,6 +6,7 @@ import itemHelper from '../itemHelper';
import loading from '../loading/loading';
import alert from '../alert';
import { LayoutMode } from 'constants/layoutMode';
import { getItemQuery } from 'hooks/useItem';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import { toApi } from 'utils/jellyfin-apiclient/compat';
@@ -16,7 +17,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. */
export const PUBLIC_PATHS = [
const PUBLIC_PATHS = [
'/addserver',
'/selectserver',
'/login',
@@ -121,7 +122,9 @@ class AppRouter {
return this.baseRoute;
}
canGoBack(path = history.location.pathname) {
canGoBack() {
const path = history.location.pathname;
if (
!document.querySelector('.dialogContainer')
&& START_PAGE_PATHS.includes(path)
@@ -259,15 +262,15 @@ class AppRouter {
}
if (item === 'recordedtv') {
return '#/livetv?tab=3&serverId=' + serverId;
return '#/livetv?tab=3&serverId=' + options.serverId;
}
if (item === 'nextup') {
return '#/list?type=nextup&serverId=' + serverId;
return '#/list?type=nextup&serverId=' + options.serverId;
}
if (item === 'list') {
let urlForList = '#/list?serverId=' + serverId + '&type=' + options.itemTypes;
let urlForList = '#/list?serverId=' + options.serverId + '&type=' + options.itemTypes;
if (options.isFavorite) {
urlForList += '&IsFavorite=true';
@@ -302,49 +305,49 @@ class AppRouter {
if (item === 'livetv') {
if (options.section === 'programs') {
return '#/livetv?tab=0&serverId=' + serverId;
return '#/livetv?tab=0&serverId=' + options.serverId;
}
if (options.section === 'guide') {
return '#/livetv?tab=1&serverId=' + serverId;
return '#/livetv?tab=1&serverId=' + options.serverId;
}
if (options.section === 'movies') {
return '#/list?type=Programs&IsMovie=true&serverId=' + serverId;
return '#/list?type=Programs&IsMovie=true&serverId=' + options.serverId;
}
if (options.section === 'shows') {
return '#/list?type=Programs&IsSeries=true&IsMovie=false&IsNews=false&serverId=' + serverId;
return '#/list?type=Programs&IsSeries=true&IsMovie=false&IsNews=false&serverId=' + options.serverId;
}
if (options.section === 'sports') {
return '#/list?type=Programs&IsSports=true&serverId=' + serverId;
return '#/list?type=Programs&IsSports=true&serverId=' + options.serverId;
}
if (options.section === 'kids') {
return '#/list?type=Programs&IsKids=true&serverId=' + serverId;
return '#/list?type=Programs&IsKids=true&serverId=' + options.serverId;
}
if (options.section === 'news') {
return '#/list?type=Programs&IsNews=true&serverId=' + serverId;
return '#/list?type=Programs&IsNews=true&serverId=' + options.serverId;
}
if (options.section === 'onnow') {
return '#/list?type=Programs&IsAiring=true&serverId=' + serverId;
return '#/list?type=Programs&IsAiring=true&serverId=' + options.serverId;
}
if (options.section === 'channels') {
return '#/livetv?tab=2&serverId=' + serverId;
return '#/livetv?tab=2&serverId=' + options.serverId;
}
if (options.section === 'dvrschedule') {
return '#/livetv?tab=4&serverId=' + serverId;
return '#/livetv?tab=4&serverId=' + options.serverId;
}
if (options.section === 'seriesrecording') {
return '#/livetv?tab=5&serverId=' + serverId;
return '#/livetv?tab=5&serverId=' + options.serverId;
}
return '#/livetv?serverId=' + serverId;
return '#/livetv?serverId=' + options.serverId;
}
if (itemType == 'SeriesTimer') {
@@ -432,7 +435,7 @@ class AppRouter {
const layoutMode = localStorage.getItem('layout');
if (layoutMode === 'experimental' && item.CollectionType == CollectionType.Homevideos) {
if (layoutMode === LayoutMode.Experimental && item.CollectionType == CollectionType.Homevideos) {
url = '#/homevideos?topParentId=' + item.Id;
return url;

View File

@@ -1,19 +1,20 @@
/**
* Module shortcuts.
* @module components/shortcuts
* "Shortcut" action handlers for BaseItems.
*/
import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api';
import { ItemAction } from 'constants/itemAction';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import { toApi } from 'utils/jellyfin-apiclient/compat';
import { playbackManager } from './playback/playbackmanager';
import inputManager from '../scripts/inputManager';
import { appRouter } from './router/appRouter';
import globalize from '../lib/globalize';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import dom from '../utils/dom';
import recordingHelper from './recordingcreator/recordinghelper';
import toast from './toast/toast';
import * as userSettings from '../scripts/settings/userSettings';
import { toApi } from 'utils/jellyfin-apiclient/compat';
function playAllFromHere(card, serverId, queue) {
const parent = card.parentNode;
@@ -231,95 +232,114 @@ function executeAction(card, target, action) {
const playableItemId = type === 'Program' ? item.ChannelId : item.Id;
if (item.MediaType === 'Photo' && action === 'link') {
action = 'play';
if (item.MediaType === 'Photo' && action === ItemAction.Link) {
action = ItemAction.Play;
}
if (action === 'link') {
appRouter.showItem(item, {
context: card.getAttribute('data-context'),
parentId: card.getAttribute('data-parentid')
});
} else if (action === 'programdialog') {
showProgramDialog(item);
} else if (action === 'instantmix') {
playbackManager.instantMix({
Id: playableItemId,
ServerId: serverId
});
} else if (action === 'play' || action === 'resume') {
const startPositionTicks = parseInt(card.getAttribute('data-positionticks') || '0', 10);
const sortValues = userSettings.getSortValuesLegacy(sortParentId, 'SortName');
if (playbackManager.canPlay(item)) {
playbackManager.play({
ids: [playableItemId],
startPositionTicks: startPositionTicks,
serverId: serverId,
queryOptions: {
SortBy: sortValues.sortBy,
SortOrder: sortValues.sortOrder
}
switch (action) {
case ItemAction.Link:
appRouter.showItem(item, {
context: card.getAttribute('data-context'),
parentId: card.getAttribute('data-parentid')
});
} else {
console.warn('Unable to play item', item);
break;
case ItemAction.ProgramDialog:
showProgramDialog(item);
break;
case ItemAction.InstantMix:
playbackManager.instantMix({
Id: playableItemId,
ServerId: serverId
});
break;
case ItemAction.Play:
case ItemAction.Resume: {
const startPositionTicks = parseInt(card.getAttribute('data-positionticks') || '0', 10);
const sortValues = userSettings.getSortValuesLegacy(sortParentId, 'SortName');
if (playbackManager.canPlay(item)) {
playbackManager.play({
ids: [playableItemId],
startPositionTicks: startPositionTicks,
serverId: serverId,
queryOptions: {
SortBy: sortValues.sortBy,
SortOrder: sortValues.sortOrder
}
});
} else {
console.warn('Unable to play item', item);
}
break;
}
} else if (action === 'queue') {
if (playbackManager.isPlaying()) {
playbackManager.queue({
ids: [playableItemId],
serverId: serverId
});
toast(globalize.translate('MediaQueued'));
} else {
playbackManager.queue({
ids: [playableItemId],
serverId: serverId
});
case ItemAction.Queue:
if (playbackManager.isPlaying()) {
playbackManager.queue({
ids: [playableItemId],
serverId: serverId
});
toast(globalize.translate('MediaQueued'));
} else {
playbackManager.queue({
ids: [playableItemId],
serverId: serverId
});
}
break;
case ItemAction.PlayAllFromHere:
playAllFromHere(card, serverId);
break;
case ItemAction.QueueAllFromHere:
playAllFromHere(card, serverId, true);
break;
case ItemAction.SetPlaylistIndex:
playbackManager.setCurrentPlaylistItem(card.getAttribute('data-playlistitemid'));
break;
case ItemAction.Record:
onRecordCommand(serverId, id, type, card.getAttribute('data-timerid'), card.getAttribute('data-seriestimerid'));
break;
case ItemAction.Menu: {
const options = target.getAttribute('data-playoptions') === 'false' ?
{
shuffle: false,
instantMix: false,
play: false,
playAllFromHere: false,
queue: false,
queueAllFromHere: false
} :
{};
options.positionTo = target;
showContextMenu(card, options);
break;
}
} else if (action === 'playallfromhere') {
playAllFromHere(card, serverId);
} else if (action === 'queueallfromhere') {
playAllFromHere(card, serverId, true);
} else if (action === 'setplaylistindex') {
playbackManager.setCurrentPlaylistItem(card.getAttribute('data-playlistitemid'));
} else if (action === 'record') {
onRecordCommand(serverId, id, type, card.getAttribute('data-timerid'), card.getAttribute('data-seriestimerid'));
} else if (action === 'menu') {
const options = target.getAttribute('data-playoptions') === 'false' ?
{
shuffle: false,
instantMix: false,
play: false,
playAllFromHere: false,
queue: false,
queueAllFromHere: false
} :
{};
case ItemAction.PlayMenu:
showPlayMenu(card, target);
break;
case ItemAction.Edit:
getItem(target).then(itemToEdit => {
editItem(itemToEdit, serverId);
});
break;
case ItemAction.PlayTrailer:
getItem(target).then(playTrailer);
break;
case ItemAction.AddToPlaylist:
getItem(target).then(addToPlaylist);
break;
case ItemAction.Custom: {
const customAction = target.getAttribute('data-customaction');
options.positionTo = target;
showContextMenu(card, options);
} else if (action === 'playmenu') {
showPlayMenu(card, target);
} else if (action === 'edit') {
getItem(target).then(itemToEdit => {
editItem(itemToEdit, serverId);
});
} else if (action === 'playtrailer') {
getItem(target).then(playTrailer);
} else if (action === 'addtoplaylist') {
getItem(target).then(addToPlaylist);
} else if (action === 'custom') {
const customAction = target.getAttribute('data-customaction');
card.dispatchEvent(new CustomEvent(`action-${customAction}`, {
detail: {
playlistItemId: card.getAttribute('data-playlistitemid')
},
cancelable: false,
bubbles: true
}));
card.dispatchEvent(new CustomEvent(`action-${customAction}`, {
detail: {
playlistItemId: card.getAttribute('data-playlistitemid')
},
cancelable: false,
bubbles: true
}));
}
}
}
@@ -390,7 +410,7 @@ export function onClick(e) {
}
}
if (action && action !== 'none') {
if (action && action !== ItemAction.None) {
executeAction(card, actionElement, action);
e.preventDefault();

View File

@@ -2,9 +2,15 @@
* Image viewer component
* @module components/slideshow/slideshow
*/
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
import screenfull from 'screenfull';
import { AppFeature } from 'constants/appFeature';
import dialogHelper from '../dialogHelper/dialogHelper';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import { toApi } from 'utils/jellyfin-apiclient/compat';
import { randomInt } from 'utils/number';
import dialogHelper from '../dialogHelper/dialogHelper';
import inputManager from '../../scripts/inputManager';
import layoutManager from '../layoutManager';
import focusManager from '../focusManager';
@@ -15,8 +21,6 @@ import dom from '../../utils/dom';
import './style.scss';
import 'material-design-icons-iconfont';
import '../../elements/emby-button/paper-icon-button-light';
import screenfull from 'screenfull';
import { randomInt } from '../../utils/number.ts';
/**
* Name of transition event.
@@ -88,14 +92,15 @@ function getBackdropImageUrl(item, options, apiClient) {
* @returns {string} URL of the item's image.
*/
function getImgUrl(item, user) {
const apiClient = ServerConnections.getApiClient(item.ServerId);
const apiClient = ServerConnections.getApiClient(item);
const api = toApi(apiClient);
const imageOptions = {};
if (item.BackdropImageTags?.length) {
return getBackdropImageUrl(item, imageOptions, apiClient);
} else {
if (item.MediaType === 'Photo' && user?.Policy.EnableContentDownloading) {
return apiClient.getItemDownloadUrl(item.Id);
return getLibraryApi(api).getDownloadUrl({ itemId: item.Id });
}
imageOptions.type = 'Primary';
return getImageUrl(item, imageOptions, apiClient);

View File

@@ -0,0 +1,37 @@
/** Actions that can be performed on a BaseItem. */
export enum ItemAction {
/** Add the Item to a playlist. */
AddToPlaylist = 'addtoplaylist',
/** Trigger a custom action via an Event. */
Custom = 'custom',
/** Open an editor for the Item. */
Edit = 'edit',
/** Create an instant mix based on the Item. */
InstantMix = 'instantmix',
/** Open the details view for the Item. */
Link = 'link',
/** Open the context menu for the Item. */
Menu = 'menu',
/** Perform no action. Used to prevent a parent element's action being triggered. */
None = 'none',
/** Play the Item. */
Play = 'play',
/** Queue the Item and all subsequent Items and start playback. */
PlayAllFromHere = 'playallfromhere',
/** Open the play menu for the Item. */
PlayMenu = 'playmenu',
/** Play the trailer for the Item. */
PlayTrailer = 'playtrailer',
/** Open the program dialog for the Item. */
ProgramDialog = 'programdialog',
/** Queue the Item. */
Queue = 'queue',
/** Queue the Item and all subsequent Items. */
QueueAllFromHere = 'queueallfromhere',
/** Record the Item. */
Record = 'record',
/** Resume playback of the Item. */
Resume = 'resume',
/** Set this Item as the Item to be currently played from a playlist. */
SetPlaylistIndex = 'setplaylistindex'
};

View File

@@ -0,0 +1,13 @@
/** The different layout modes supported by the web app. */
export enum LayoutMode {
/** Automatic layout - the app chose the best layout for the detected device. */
Auto = 'auto',
/** The legacy desktop layout. */
Desktop = 'desktop',
/** The modern React based layout. */
Experimental = 'experimental',
/** The legacy mobile layout. */
Mobile = 'mobile',
/** The TV layout. */
Tv = 'tv'
};

View File

@@ -176,88 +176,86 @@
</div>
</div>
<div class="detailPageSecondaryContainer padded-bottom-page">
<div class="detailPageContent">
<div id="childrenCollapsible" class="hide verticalSection detailVerticalSection">
<h2 class="sectionTitle sectionTitle-cards hide">
<span></span>
</h2>
<div>
<div is="emby-itemscontainer" class="itemsContainer padded-right" style="text-align: left;"></div>
</div>
<div class="detailPageSecondaryContainer padded-left padded-bottom-page">
<div id="childrenCollapsible" class="hide verticalSection detailVerticalSection">
<h2 class="sectionTitle sectionTitle-cards hide">
<span></span>
</h2>
<div>
<div is="emby-itemscontainer" class="itemsContainer padded-right" style="text-align: left;"></div>
</div>
</div>
<div id="additionalPartsCollapsible" class="verticalSection detailVerticalSection hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${HeaderAdditionalParts}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="additionalPartsContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
<div id="additionalPartsCollapsible" class="verticalSection detailVerticalSection hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${HeaderAdditionalParts}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="additionalPartsContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div class="verticalSection detailVerticalSection moreFromSeasonSection hide">
<h2 class="sectionTitle sectionTitle-cards padded-right"></h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
<div class="verticalSection detailVerticalSection moreFromSeasonSection hide">
<h2 class="sectionTitle sectionTitle-cards padded-right"></h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div id="lyricsSection" class="verticalSection-extrabottompadding detailVerticalSection lyricsContainer hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${Lyrics}</h2>
<div is="emby-itemscontainer" class="vertical-list lyricsLineContainer" dir="auto"></div>
<div id="lyricsSection" class="verticalSection-extrabottompadding detailVerticalSection lyricsContainer hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${Lyrics}</h2>
<div is="emby-itemscontainer" class="vertical-list itemsContainer"></div>
</div>
<div class="verticalSection detailVerticalSection moreFromArtistSection hide">
<h2 class="sectionTitle sectionTitle-cards padded-right"></h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div class="verticalSection detailVerticalSection moreFromArtistSection hide">
<h2 class="sectionTitle sectionTitle-cards padded-right"></h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
<div id="castCollapsible" class="verticalSection detailVerticalSection hide">
<h2 id="peopleHeader" class="sectionTitle sectionTitle-cards padded-right">${HeaderCastAndCrew}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="castContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div id="castCollapsible" class="verticalSection detailVerticalSection hide">
<h2 id="peopleHeader" class="sectionTitle sectionTitle-cards padded-right">${HeaderCastAndCrew}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="castContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
<div id="guestCastCollapsible" class="verticalSection detailVerticalSection hide">
<h2 id="guestCastHeader" class="sectionTitle sectionTitle-cards padded-right">${HeaderGuestCast}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="guestCastContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div id="guestCastCollapsible" class="verticalSection detailVerticalSection hide">
<h2 id="guestCastHeader" class="sectionTitle sectionTitle-cards padded-right">${HeaderGuestCast}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="guestCastContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
<div id="seriesScheduleSection" class="verticalSection detailVerticalSection hide">
<h2 class="sectionTitle padded-right">${HeaderUpcomingOnTV}</h2>
<div id="seriesScheduleList" is="emby-itemscontainer" class="itemsContainer vertical-list padded-right"></div>
</div>
<div id="specialsCollapsible" class="verticalSection detailVerticalSection hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${SpecialFeatures}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="specialsContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div id="seriesScheduleSection" class="verticalSection detailVerticalSection hide">
<h2 class="sectionTitle padded-right">${HeaderUpcomingOnTV}</h2>
<div id="seriesScheduleList" is="emby-itemscontainer" class="itemsContainer vertical-list padded-right"></div>
<div id="musicVideosCollapsible" class="verticalSection detailVerticalSection hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${MusicVideos}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="musicVideosContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div id="specialsCollapsible" class="verticalSection detailVerticalSection hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${SpecialFeatures}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="specialsContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
<div id="scenesCollapsible" class="verticalSection detailVerticalSection verticalSection-extrabottompadding hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${HeaderScenes}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="scenesContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div id="musicVideosCollapsible" class="verticalSection detailVerticalSection hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${MusicVideos}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="musicVideosContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div id="scenesCollapsible" class="verticalSection detailVerticalSection verticalSection-extrabottompadding hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${HeaderScenes}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="scenesContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div id="similarCollapsible" class="verticalSection detailVerticalSection verticalSection-extrabottompadding hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${HeaderMoreLikeThis}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer similarContent"></div>
</div>
<div id="similarCollapsible" class="verticalSection detailVerticalSection verticalSection-extrabottompadding hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${HeaderMoreLikeThis}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer similarContent"></div>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { PersonKind } from '@jellyfin/sdk/lib/generated-client/models/person-kind';
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
import { intervalToDuration } from 'date-fns';
import DOMPurify from 'dompurify';
import escapeHtml from 'escape-html';
@@ -22,6 +23,7 @@ import { playbackManager } from 'components/playback/playbackmanager';
import { appRouter } from 'components/router/appRouter';
import itemShortcuts from 'components/shortcuts';
import { AppFeature } from 'constants/appFeature';
import { ItemAction } from 'constants/itemAction';
import globalize from 'lib/globalize';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import browser from 'scripts/browser';
@@ -34,6 +36,7 @@ import { getPortraitShape, getSquareShape } from 'utils/card';
import Dashboard from 'utils/dashboard';
import Events from 'utils/events';
import { getItemBackdropImageUrl } from 'utils/jellyfin-apiclient/backdropImage';
import { toApi } from 'utils/jellyfin-apiclient/compat';
import 'elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-checkbox/emby-checkbox';
@@ -434,19 +437,19 @@ function renderName(item, container, context) {
parentNameHtml.push(getArtistLinksHtml(item.ArtistItems, item.ServerId, context));
parentNameLast = true;
} else if (item.SeriesName && item.Type === 'Episode') {
parentNameHtml.push(`<a style="color:inherit;" class="button-link itemAction" is="emby-linkbutton" href="#" data-action="link" data-id="${item.SeriesId}" data-serverid="${item.ServerId}" data-type="Series" data-isfolder="true">${escapeHtml(item.SeriesName)}</a>`);
parentNameHtml.push(`<a style="color:inherit;" class="button-link itemAction" is="emby-linkbutton" href="#" data-action="${ItemAction.Link}" data-id="${item.SeriesId}" data-serverid="${item.ServerId}" data-type="Series" data-isfolder="true">${escapeHtml(item.SeriesName)}</a>`);
} else if (item.IsSeries || item.EpisodeTitle) {
parentNameHtml.push(escapeHtml(item.Name));
}
if (item.SeriesName && item.Type === 'Season') {
parentNameHtml.push(`<a style="color:inherit;" class="button-link itemAction" is="emby-linkbutton" href="#" data-action="link" data-id="${item.SeriesId}" data-serverid="${item.ServerId}" data-type="Series" data-isfolder="true">${escapeHtml(item.SeriesName)}</a>`);
parentNameHtml.push(`<a style="color:inherit;" class="button-link itemAction" is="emby-linkbutton" href="#" data-action="${ItemAction.Link}" data-id="${item.SeriesId}" data-serverid="${item.ServerId}" data-type="Series" data-isfolder="true">${escapeHtml(item.SeriesName)}</a>`);
} else if (item.ParentIndexNumber != null && item.Type === 'Episode') {
parentNameHtml.push(`<a style="color:inherit;" class="button-link itemAction" is="emby-linkbutton" href="#" data-action="link" data-id="${item.SeasonId}" data-serverid="${item.ServerId}" data-type="Season" data-isfolder="true">${escapeHtml(item.SeasonName)}</a>`);
parentNameHtml.push(`<a style="color:inherit;" class="button-link itemAction" is="emby-linkbutton" href="#" data-action="${ItemAction.Link}" data-id="${item.SeasonId}" data-serverid="${item.ServerId}" data-type="Season" data-isfolder="true">${escapeHtml(item.SeasonName)}</a>`);
} else if (item.ParentIndexNumber != null && item.IsSeries) {
parentNameHtml.push(escapeHtml(item.SeasonName || 'S' + item.ParentIndexNumber));
} else if (item.Album && item.AlbumId && (item.Type === 'MusicVideo' || item.Type === 'Audio')) {
parentNameHtml.push(`<a style="color:inherit;" class="button-link itemAction" is="emby-linkbutton" href="#" data-action="link" data-id="${item.AlbumId}" data-serverid="${item.ServerId}" data-type="MusicAlbum" data-isfolder="true">${escapeHtml(item.Album)}</a>`);
parentNameHtml.push(`<a style="color:inherit;" class="button-link itemAction" is="emby-linkbutton" href="#" data-action="${ItemAction.Link}" data-id="${item.AlbumId}" data-serverid="${item.ServerId}" data-type="MusicAlbum" data-isfolder="true">${escapeHtml(item.Album)}</a>`);
} else if (item.Album) {
parentNameHtml.push(escapeHtml(item.Album));
}
@@ -1982,7 +1985,7 @@ export default function (view, params) {
return;
}
playItem(item, item.UserData && mode === 'resume' ? item.UserData.PlaybackPositionTicks : 0);
playItem(item, item.UserData && mode === ItemAction.Resume ? item.UserData.PlaybackPositionTicks : 0);
}
function onPlayClick() {
@@ -2026,9 +2029,10 @@ export default function (view, params) {
}
function onDownloadClick() {
const downloadHref = getApiClient().getItemDownloadUrl(currentItem.Id);
const api = toApi(getApiClient());
const url = getLibraryApi(api).getDownloadUrl({ itemId: currentItem.Id });
download([{
url: downloadHref,
url,
item: currentItem,
itemId: currentItem.Id,
serverId: currentItem.ServerId,

View File

@@ -334,7 +334,7 @@ function getItems(instance, params, item, sortBy, startIndex, limit) {
if (sortBy === 'Random') {
instance.queryRecursive = true;
query.IncludeItemTypes = 'Video,Movie,Series,Music,MusicVideo';
query.IncludeItemTypes = 'Video,Movie,Series,Music';
query.Recursive = true;
}

View File

@@ -1,6 +1,6 @@
<div id="lyricPage" data-role="page" class="page lyricPage" data-backbutton="true">
<div>
<div class="lyricsContainer padded-bottom-page" dir="auto">
<div class="lyricsContainer padded-bottom-page">
</div>
</div>
</div>

View File

@@ -32,7 +32,7 @@ function lyricHtmlReducer(htmlAccumulator, lyric, index) {
const lyricTime = typeof lyric.Start !== 'undefined' ? `data-lyrictime="${lyric.Start}"` : '';
htmlAccumulator += `<${elem} class="lyricsLine ${classes.join(' ')}" id="lyricPosition${index}" ${lyricTime}>
${escapeHtml(lyric.Text)}
<bdi>${escapeHtml(lyric.Text)}</bdi>
</${elem}>`;
return htmlAccumulator;

View File

@@ -16,11 +16,7 @@ function handleConnectionResult(page, result) {
break;
}
case ConnectionState.ServerSignIn:
if (result.SystemInfo.StartupWizardCompleted) {
Dashboard.navigate('login?serverid=' + result.Servers[0].Id, false, 'none');
} else {
Dashboard.navigate('/wizard/start');
}
Dashboard.navigate('login?serverid=' + result.Servers[0].Id, false, 'none');
break;
case ConnectionState.ServerSelection:
Dashboard.navigate('selectserver', false, 'none');

View File

@@ -3,8 +3,8 @@ import { useQueryClient } from '@tanstack/react-query';
import React, { type FC, useCallback } from 'react';
import IconButton from '@mui/material/IconButton';
import CheckIcon from '@mui/icons-material/Check';
import classNames from 'classnames';
import { ItemAction } from 'constants/itemAction';
import globalize from 'lib/globalize';
import { useTogglePlayedMutation } from 'hooks/useFetchItems';
@@ -59,23 +59,17 @@ const PlayedButton: FC<PlayedButtonProps> = ({
}
}, [itemId, togglePlayedMutation, isPlayed, queryClient, queryKey]);
const btnClass = classNames(
className,
{ 'playstatebutton-played': isPlayed }
);
const iconClass = classNames(
{ 'playstatebutton-icon-played': isPlayed }
);
return (
<IconButton
data-action='none'
data-action={ItemAction.None}
title={getTitle()}
className={btnClass}
className={className}
size='small'
onClick={onClick}
>
<CheckIcon className={iconClass} />
<CheckIcon
color={isPlayed ? 'error' : undefined}
/>
</IconButton>
);
};

View File

@@ -3,7 +3,7 @@ import { useQueryClient } from '@tanstack/react-query';
import IconButton from '@mui/material/IconButton';
import FavoriteIcon from '@mui/icons-material/Favorite';
import classNames from 'classnames';
import { ItemAction } from 'constants/itemAction';
import { useToggleFavoriteMutation } from 'hooks/useFetchItems';
import globalize from 'lib/globalize';
@@ -45,24 +45,17 @@ const FavoriteButton: FC<FavoriteButtonProps> = ({
}
}, [isFavorite, itemId, queryClient, queryKey, toggleFavoriteMutation]);
const btnClass = classNames(
className,
{ 'ratingbutton-withrating': isFavorite }
);
const iconClass = classNames(
{ 'ratingbutton-icon-withrating': isFavorite }
);
return (
<IconButton
data-action='none'
data-action={ItemAction.None}
className={className}
title={isFavorite ? globalize.translate('Favorite') : globalize.translate('AddToFavorites')}
className={btnClass}
size='small'
onClick={onClick}
>
<FavoriteIcon className={iconClass} />
<FavoriteIcon
color={isFavorite ? 'error' : undefined}
/>
</IconButton>
);
};

View File

@@ -162,10 +162,6 @@ EmbyScrollButtonsPrototype.attachedCallback = function () {
capture: false,
passive: true
});
requestAnimationFrame(() => {
this.scrollHandler();
});
};
EmbyScrollButtonsPrototype.detachedCallback = function () {

View File

@@ -54,8 +54,6 @@ const fetchGetItems = async (
export const useGetItems = (parametersOptions: ItemsApiGetItemsRequest) => {
const currentApi = useApi();
const isRandom = Boolean(parametersOptions.sortBy?.includes(ItemSortBy.Random));
return useQuery({
queryKey: [
'Items',
@@ -65,9 +63,7 @@ export const useGetItems = (parametersOptions: ItemsApiGetItemsRequest) => {
],
queryFn: ({ signal }) =>
fetchGetItems(currentApi, parametersOptions, { signal }),
gcTime: isRandom ? Infinity : undefined,
refetchOnMount: isRandom ? false : undefined,
refetchOnWindowFocus: isRandom ? false : undefined,
gcTime: parametersOptions.sortBy?.includes(ItemSortBy.Random) ? 0 : undefined,
enabled: !!currentApi.api && !!currentApi.user?.Id
});
};

View File

@@ -8,6 +8,8 @@ import { useApi } from './useApi';
export type UsersRecords = Record<string, UserDto>;
export const QUERY_KEY = 'Users';
const fetchUsers = async (
api: Api,
requestParams?: UserApiGetUsersRequest,
@@ -23,7 +25,7 @@ const fetchUsers = async (
export const useUsers = (requestParams?: UserApiGetUsersRequest) => {
const { api } = useApi();
return useQuery({
queryKey: ['Users'],
queryKey: [ QUERY_KEY ],
queryFn: ({ signal }) =>
fetchUsers(api!, requestParams, { signal }),
enabled: !!api

View File

@@ -22,7 +22,6 @@ import { getPlugins } from './scripts/settings/webSettings';
import taskButton from './scripts/taskbutton';
import { pageClassOn, serverAddress } from './utils/dashboard';
import Events from './utils/events';
import { initializeServerConnections } from './scripts/serverNotifications';
import RootApp from './RootApp';
@@ -38,6 +37,7 @@ import './components/themeMediaPlayer';
import './scripts/autoThemes';
import './scripts/mouseManager';
import './scripts/screensavermanager';
import './scripts/serverNotifications';
// Import site styles
import './styles/site.scss';
@@ -66,9 +66,6 @@ build: ${__JF_BUILD_VERSION__}`);
document.querySelector('.skinHeader').classList.remove('noHeaderRight');
});
// Initialize app host
await appHost.init();
// Initialize the api client
const serverUrl = await serverAddress();
if (serverUrl) {
@@ -110,9 +107,6 @@ build: ${__JF_BUILD_VERSION__}`);
Events.on(apiClient, 'requestfail', appRouter.onRequestFail);
});
// Start server notifications
initializeServerConnections();
// Render the app
await renderApp();

View File

@@ -152,8 +152,8 @@ const capabilities = Dashboard.capabilities(appHost);
export default new ServerConnections(
credentialProvider,
() => appHost.appName(),
() => appHost.appVersion(),
() => appHost.deviceName(),
() => appHost.deviceId(),
appHost.appName(),
appHost.appVersion(),
appHost.deviceName(),
appHost.deviceId(),
capabilities);

View File

@@ -63,15 +63,13 @@ export default class ConnectionManager {
// Set the minimum version to match the SDK
self._minServerVersion = MINIMUM_VERSION;
self.appVersion = () => typeof appVersion === 'function' ? appVersion() : appVersion;
self.appVersion = () => appVersion;
self.appName = () => typeof appName === 'function' ? appName() : appName;
self.appName = () => appName;
self.capabilities = () => capabilities;
self.deviceName = () => typeof deviceName === 'function' ? deviceName() : deviceName;
self.deviceId = () => typeof deviceId === 'function' ? deviceId() : deviceId;
self.deviceId = () => deviceId;
self.credentialProvider = () => credentialProvider;
@@ -139,7 +137,7 @@ export default class ConnectionManager {
let apiClient = self.getApiClient(server.Id);
if (!apiClient) {
apiClient = new ApiClient(serverUrl, self.appName(), self.appVersion(), self.deviceName(), self.deviceId());
apiClient = new ApiClient(serverUrl, appName, appVersion, deviceName, deviceId);
self._apiClients.push(apiClient);
@@ -234,12 +232,12 @@ export default class ConnectionManager {
headers: {
[AUTHORIZATION_HEADER]: getAuthorizationHeader(
{
name: self.appName(),
version: self.appVersion()
name: appName,
version: appVersion
},
{
id: self.deviceId(),
name: self.deviceName()
id: deviceId,
name: deviceName
},
server.AccessToken
)

View File

@@ -1,18 +1,21 @@
import 'material-design-icons-iconfont';
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
import Screenfull from 'screenfull';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import browser from 'scripts/browser';
import TouchHelper from 'scripts/touchHelper';
import { toApi } from 'utils/jellyfin-apiclient/compat';
import loading from '../../components/loading/loading';
import keyboardnavigation from '../../scripts/keyboardNavigation';
import dialogHelper from '../../components/dialogHelper/dialogHelper';
import Screenfull from 'screenfull';
import TableOfContents from './tableOfContents';
import { translateHtml } from '../../lib/globalize';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import browser from 'scripts/browser';
import * as userSettings from '../../scripts/settings/userSettings';
import TouchHelper from 'scripts/touchHelper';
import { PluginType } from '../../types/plugin.ts';
import Events from '../../utils/events.ts';
import 'material-design-icons-iconfont';
import '../../elements/emby-button/paper-icon-button-light';
import html from './template.html';
@@ -324,16 +327,14 @@ export class BookPlayer {
}
};
const serverId = item.ServerId;
const apiClient = ServerConnections.getApiClient(serverId);
if (!Screenfull.isEnabled) {
document.getElementById('btnBookplayerFullscreen').display = 'none';
}
return new Promise((resolve, reject) => {
import('epubjs').then(({ default: epubjs }) => {
const downloadHref = apiClient.getItemDownloadUrl(item.Id);
const api = toApi(ServerConnections.getApiClient(item));
const downloadHref = getLibraryApi(api).getDownloadUrl({ itemId: item.Id });
const book = epubjs(downloadHref, { openAs: 'epub' });
// We need to calculate the height of the window beforehand because using 100% is not accurate when the dialog is opening.

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