Compare commits

...

316 Commits

Author SHA1 Message Date
renovate[bot]
2c0f18c474 Update tanstack-query monorepo 2026-01-05 16:44:33 +00:00
Dzmitry Zubialevich
9073ad587d Translated using Weblate (Belarusian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/be/
2026-01-05 12:05:53 +00:00
Dzmitry Zubialevich
2bf602f8b3 Translated using Weblate (Belarusian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/be/
2026-01-05 10:05:53 +00:00
Translation expert
b9a21c45b5 Translated using Weblate (Arabic)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ar/
2026-01-05 07:05:53 +00:00
Fjuro
ae62467478 Translated using Weblate (Czech)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/cs/
2026-01-04 20:50:17 +00:00
ljaksys
5cfbfd946f Translated using Weblate (Portuguese (Brazil))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt_BR/
2026-01-04 16:05:52 +00:00
aky
92b96971cb Translated using Weblate (Korean)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ko/
2026-01-02 14:06:27 +00:00
aky
1995eccb4b Translated using Weblate (Korean)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ko/
2026-01-02 13:50:20 +00:00
Dzmitry Zubialevich
e4e4f550c8 Translated using Weblate (Belarusian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/be/
2026-01-02 13:33:45 +00:00
MrPlow
37e5b9abea Translated using Weblate (German)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/de/
2026-01-02 12:19:01 +00:00
AfmanS
38b199ad3f Translated using Weblate (Portuguese (Portugal))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt_PT/
2026-01-02 11:39:29 +00:00
Dzmitry Zubialevich
04f02010d5 Translated using Weblate (Belarusian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/be/
2026-01-02 11:39:28 +00:00
Dzmitry Zubialevich
3003d964df Translated using Weblate (Belarusian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/be/
2026-01-02 09:05:52 +00:00
stunzzz
e11d8cba4e Translated using Weblate (Indonesian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/id/
2026-01-02 06:05:52 +00:00
rimasx
64bf4e70ee Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2026-01-01 20:33:18 +00:00
Warper
201c98829b Translated using Weblate (Spanish (Latin America))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/es_419/
2026-01-01 01:05:52 +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
205 changed files with 4657 additions and 3311 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@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3.31.4
with:
queries: security-and-quality
languages: ${{ matrix.language }}
- name: Autobuild 📦
uses: github/codeql-action/autobuild@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
uses: github/codeql-action/autobuild@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3.31.4
- name: Perform CodeQL Analysis 🧪
uses: github/codeql-action/analyze@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
uses: github/codeql-action/analyze@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3.31.4
with:
category: '/language:${{matrix.language}}'

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

@@ -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 }}

188
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jellyfin-web",
"version": "10.11.0",
"version": "10.12.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "jellyfin-web",
"version": "10.11.0",
"version": "10.12.0",
"license": "GPL-2.0-or-later",
"dependencies": {
"@emotion/react": "11.14.0",
@@ -18,14 +18,14 @@
"@fontsource/noto-sans-sc": "5.2.6",
"@fontsource/noto-sans-tc": "5.2.6",
"@jellyfin/libass-wasm": "4.2.3",
"@jellyfin/sdk": "0.0.0-unstable.202510030502",
"@jellyfin/sdk": "0.0.0-unstable.202512091852",
"@jellyfin/ux-web": "1.0.0",
"@mui/icons-material": "6.4.12",
"@mui/material": "6.4.12",
"@mui/x-date-pickers": "7.29.4",
"@react-hook/resize-observer": "2.0.2",
"@tanstack/react-query": "5.80.10",
"@tanstack/react-query-devtools": "5.80.10",
"@tanstack/react-query": "5.90.15",
"@tanstack/react-query-devtools": "5.91.2",
"abortcontroller-polyfill": "1.7.8",
"blurhash": "2.0.5",
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
@@ -129,6 +129,7 @@
"ts-loader": "9.5.2",
"typescript": "5.8.3",
"typescript-eslint": "8.35.1",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack": "5.99.9",
"webpack-bundle-analyzer": "4.10.2",
@@ -4129,12 +4130,12 @@
"license": "LGPL-2.1-or-later AND (FTL OR GPL-2.0-or-later) AND MIT AND MIT-Modern-Variant AND ISC AND NTP AND Zlib AND BSL-1.0"
},
"node_modules/@jellyfin/sdk": {
"version": "0.0.0-unstable.202510030502",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202510030502.tgz",
"integrity": "sha512-khxWQ4dKirp03sLCkz8s+MrfAmJUGD4xTfVHRq3NOsgz8ueDH3qVxdya8YnV8U83p+bfZpGtnf1IAvyh+f959g==",
"version": "0.0.0-unstable.202512091852",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202512091852.tgz",
"integrity": "sha512-N+QEsrKk4KculkV6KMBb7XpzTLWcXEzqTHbS+b4rov0VYVwR6DIsJkmUzB3hM2YZsrLIHEFKhFRy/r4itkFeHw==",
"license": "MPL-2.0",
"peerDependencies": {
"axios": "^1.3.4"
"axios": "^1.12.0"
}
},
"node_modules/@jellyfin/ux-web": {
@@ -5488,9 +5489,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.80.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.80.10.tgz",
"integrity": "sha512-mUNQOtzxkjL6jLbyChZoSBP6A5gQDVRUiPvW+/zw/9ftOAz+H754zCj3D8PwnzPKyHzGkQ9JbH48ukhym9LK1Q==",
"version": "5.90.15",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.15.tgz",
"integrity": "sha512-mInIZNUZftbERE+/Hbtswfse49uUQwch46p+27gP9DWJL927UjnaWEF2t3RMOqBcXbfMdcNkPe06VyUIAZTV1g==",
"license": "MIT",
"funding": {
"type": "github",
@@ -5498,9 +5499,9 @@
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.80.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.80.0.tgz",
"integrity": "sha512-D6gH4asyjaoXrCOt5vG5Og/YSj0D/TxwNQgtLJIgWbhbWCC/emu2E92EFoVHh4ppVWg1qT2gKHvKyQBEFZhCuA==",
"version": "5.92.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz",
"integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==",
"license": "MIT",
"funding": {
"type": "github",
@@ -5508,12 +5509,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.80.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.80.10.tgz",
"integrity": "sha512-6zM098J8sLy9oU60XAdzUlAH4wVzoMVsWUWiiE/Iz4fd67PplxeyL4sw/MPcVJJVhbwGGXCsHn9GrQt2mlAzig==",
"version": "5.90.15",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.15.tgz",
"integrity": "sha512-uQvnDDcTOgJouNtAyrgRej+Azf0U5WDov3PXmHFUBc+t1INnAYhIlpZtCGNBLwCN41b43yO7dPNZu8xWkUFBwQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.80.10"
"@tanstack/query-core": "5.90.15"
},
"funding": {
"type": "github",
@@ -5524,19 +5525,19 @@
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.80.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.80.10.tgz",
"integrity": "sha512-6JL63fSc7kxyGOLV2w466SxhMn/m7LZk/ximQciy6OpVt+n2A8Mq3S0QwhIzfm4WEwLK/F3OELfzRToQburnYA==",
"version": "5.91.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz",
"integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.80.0"
"@tanstack/query-devtools": "5.92.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.80.10",
"@tanstack/react-query": "^5.90.14",
"react": "^18 || ^19"
}
},
@@ -7233,6 +7234,7 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT",
"peer": true
},
"node_modules/atob": {
@@ -7312,14 +7314,14 @@
}
},
"node_modules/axios": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
@@ -8420,6 +8422,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"peer": true,
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -9509,6 +9512,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.4.0"
@@ -11962,15 +11966,16 @@
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"peer": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
@@ -12364,6 +12369,13 @@
"integrity": "sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM=",
"dev": true
},
"node_modules/globrex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
"dev": true,
"license": "MIT"
},
"node_modules/gonzales-pe": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz",
@@ -23182,6 +23194,27 @@
"node": ">=8"
}
},
"node_modules/tsconfck": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
"integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==",
"dev": true,
"license": "MIT",
"bin": {
"tsconfck": "bin/tsconfck.js"
},
"engines": {
"node": "^18 || >=20"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -23832,6 +23865,26 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-tsconfig-paths": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
"integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"globrex": "^0.1.2",
"tsconfck": "^3.0.3"
},
"peerDependencies": {
"vite": "*"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
}
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
@@ -27097,9 +27150,9 @@
"integrity": "sha512-C0OlBxIr9NdeFESMTA/OVDqNSWtog6Mi7wwzwH12xbZpxsMD0RgCupUcIP7zZgcpTNecW3fZq5d6xVo7Q8HEJw=="
},
"@jellyfin/sdk": {
"version": "0.0.0-unstable.202510030502",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202510030502.tgz",
"integrity": "sha512-khxWQ4dKirp03sLCkz8s+MrfAmJUGD4xTfVHRq3NOsgz8ueDH3qVxdya8YnV8U83p+bfZpGtnf1IAvyh+f959g==",
"version": "0.0.0-unstable.202512091852",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202512091852.tgz",
"integrity": "sha512-N+QEsrKk4KculkV6KMBb7XpzTLWcXEzqTHbS+b4rov0VYVwR6DIsJkmUzB3hM2YZsrLIHEFKhFRy/r4itkFeHw==",
"requires": {}
},
"@jellyfin/ux-web": {
@@ -27787,29 +27840,29 @@
}
},
"@tanstack/query-core": {
"version": "5.80.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.80.10.tgz",
"integrity": "sha512-mUNQOtzxkjL6jLbyChZoSBP6A5gQDVRUiPvW+/zw/9ftOAz+H754zCj3D8PwnzPKyHzGkQ9JbH48ukhym9LK1Q=="
"version": "5.90.15",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.15.tgz",
"integrity": "sha512-mInIZNUZftbERE+/Hbtswfse49uUQwch46p+27gP9DWJL927UjnaWEF2t3RMOqBcXbfMdcNkPe06VyUIAZTV1g=="
},
"@tanstack/query-devtools": {
"version": "5.80.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.80.0.tgz",
"integrity": "sha512-D6gH4asyjaoXrCOt5vG5Og/YSj0D/TxwNQgtLJIgWbhbWCC/emu2E92EFoVHh4ppVWg1qT2gKHvKyQBEFZhCuA=="
"version": "5.92.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz",
"integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ=="
},
"@tanstack/react-query": {
"version": "5.80.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.80.10.tgz",
"integrity": "sha512-6zM098J8sLy9oU60XAdzUlAH4wVzoMVsWUWiiE/Iz4fd67PplxeyL4sw/MPcVJJVhbwGGXCsHn9GrQt2mlAzig==",
"version": "5.90.15",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.15.tgz",
"integrity": "sha512-uQvnDDcTOgJouNtAyrgRej+Azf0U5WDov3PXmHFUBc+t1INnAYhIlpZtCGNBLwCN41b43yO7dPNZu8xWkUFBwQ==",
"requires": {
"@tanstack/query-core": "5.80.10"
"@tanstack/query-core": "5.90.15"
}
},
"@tanstack/react-query-devtools": {
"version": "5.80.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.80.10.tgz",
"integrity": "sha512-6JL63fSc7kxyGOLV2w466SxhMn/m7LZk/ximQciy6OpVt+n2A8Mq3S0QwhIzfm4WEwLK/F3OELfzRToQburnYA==",
"version": "5.91.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz",
"integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==",
"requires": {
"@tanstack/query-devtools": "5.80.0"
"@tanstack/query-devtools": "5.92.0"
}
},
"@tanstack/react-table": {
@@ -29097,13 +29150,13 @@
"dev": true
},
"axios": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"peer": true,
"requires": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
@@ -32390,14 +32443,15 @@
}
},
"form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"peer": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
}
},
@@ -32678,6 +32732,12 @@
"integrity": "sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM=",
"dev": true
},
"globrex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
"dev": true
},
"gonzales-pe": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz",
@@ -40112,6 +40172,13 @@
}
}
},
"tsconfck": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
"integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==",
"dev": true,
"requires": {}
},
"tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -40553,6 +40620,17 @@
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
}
},
"vite-tsconfig-paths": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
"integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==",
"dev": true,
"requires": {
"debug": "^4.1.1",
"globrex": "^0.1.2",
"tsconfck": "^3.0.3"
}
},
"vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "jellyfin-web",
"version": "10.11.0",
"version": "10.12.0",
"description": "Web interface for Jellyfin",
"repository": "https://github.com/jellyfin/jellyfin-web",
"license": "GPL-2.0-or-later",
@@ -66,6 +66,7 @@
"ts-loader": "9.5.2",
"typescript": "5.8.3",
"typescript-eslint": "8.35.1",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack": "5.99.9",
"webpack-bundle-analyzer": "4.10.2",
@@ -84,14 +85,14 @@
"@fontsource/noto-sans-sc": "5.2.6",
"@fontsource/noto-sans-tc": "5.2.6",
"@jellyfin/libass-wasm": "4.2.3",
"@jellyfin/sdk": "0.0.0-unstable.202510030502",
"@jellyfin/sdk": "0.0.0-unstable.202512091852",
"@jellyfin/ux-web": "1.0.0",
"@mui/icons-material": "6.4.12",
"@mui/material": "6.4.12",
"@mui/x-date-pickers": "7.29.4",
"@react-hook/resize-observer": "2.0.2",
"@tanstack/react-query": "5.80.10",
"@tanstack/react-query-devtools": "5.80.10",
"@tanstack/react-query": "5.90.15",
"@tanstack/react-query-devtools": "5.91.2",
"abortcontroller-polyfill": "1.7.8",
"blurhash": "2.0.5",
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
@@ -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

@@ -94,7 +94,13 @@ const BaseCard = ({
{title}
</Typography>
{text && (
<Typography variant='body2' color='text.secondary'>
<Typography
variant='body2'
color='text.secondary'
sx={{
lineBreak: 'anywhere'
}}
>
{text}
</Typography>
)}

View File

@@ -5,13 +5,14 @@ import MusicNote from '@mui/icons-material/MusicNote';
import MusicVideo from '@mui/icons-material/MusicVideo';
import Tv from '@mui/icons-material/Tv';
import VideoLibrary from '@mui/icons-material/VideoLibrary';
import Grid from '@mui/material/Grid2';
import Grid from '@mui/material/Grid';
import SvgIcon from '@mui/material/SvgIcon';
import React, { useMemo } from 'react';
import { useItemCounts } from 'apps/dashboard/features/metrics/api/useItemCounts';
import MetricCard, { type MetricCardProps } from 'apps/dashboard/features/metrics/components/MetricCard';
import globalize from 'lib/globalize';
import Box from '@mui/material/Box';
interface MetricDefinition {
key: keyof ItemCounts
@@ -75,23 +76,27 @@ const ItemCountsWidget = () => {
}, [ counts, isPending ]);
return (
<Grid
container
spacing={2}
sx={{
alignItems: 'stretch',
marginTop: 2
}}
>
{cards.map(card => (
<Grid
key={card.metrics.map(metric => metric.label).join('-')}
size={{ xs: 12, sm: 6, lg: 4 }}
>
<MetricCard {...card} />
</Grid>
))}
</Grid>
<Box>
<Grid
container
spacing={2}
sx={{
alignItems: 'stretch'
}}
>
{cards.map(card => (
<Grid
key={card.metrics.map(metric => metric.label).join('-')}
item
xs={12}
sm={6}
lg={4}
>
<MetricCard {...card} />
</Grid>
))}
</Grid>
</Box>
);
};

View File

@@ -15,9 +15,15 @@ type ServerInfoWidgetProps = {
onScanLibrariesClick?: () => void;
onRestartClick?: () => void;
onShutdownClick?: () => void;
isScanning?: boolean;
};
const ServerInfoWidget = ({ onScanLibrariesClick, onRestartClick, onShutdownClick }: ServerInfoWidgetProps) => {
const ServerInfoWidget = ({
onScanLibrariesClick,
onRestartClick,
onShutdownClick,
isScanning
}: ServerInfoWidgetProps) => {
const { data: systemInfo, isPending } = useSystemInfo();
return (
@@ -63,6 +69,7 @@ const ServerInfoWidget = ({ onScanLibrariesClick, onRestartClick, onShutdownClic
sx={{
fontWeight: 'bold'
}}
disabled={isScanning}
>
{globalize.translate('ButtonScanAllLibraries')}
</Button>

View File

@@ -47,5 +47,8 @@ export const HelpLinks = [
'/dashboard/users/profile'
],
url: 'https://jellyfin.org/docs/general/server/users/'
}, {
paths: ['/dashboard/backups'],
url: 'https://jellyfin.org/docs/general/administration/backup-and-restore/'
}
];

View File

@@ -78,7 +78,6 @@ const TunerDeviceCard = ({ tunerHost }: TunerDeviceCardProps) => {
title={tunerHost.FriendlyName || getTunerName(tunerHost.Type) || ''}
text={tunerHost.Url || ''}
icon={<DvrIcon sx={{ fontSize: 70 }} />}
width={340}
action={true}
actionRef={actionRef}
onActionClick={onActionClick}

View File

@@ -12,7 +12,13 @@ const fetchServerLog = async (
const response = await getSystemApi(api).getLogFile({ name }, options);
// FIXME: TypeScript SDK thinks it is returning a File but in reality it is a string
return response.data as never as string;
const data = response.data as never as string | object;
if (typeof data === 'object') {
return JSON.stringify(data, null, 2);
} else {
return data;
}
};
export const useServerLog = (name: string) => {
const { api } = useApi();

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

@@ -102,7 +102,7 @@ export const Component = () => {
}).catch(() => {
// Server is still down
});
}, 5000);
}, 45000);
return () => {
clearInterval(serverCheckInterval);

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import Page from 'components/Page';
import globalize from 'lib/globalize';
import Box from '@mui/material/Box';
@@ -16,6 +16,7 @@ import RunningTasksWidget from '../components/widgets/RunningTasksWidget';
import DevicesWidget from '../components/widgets/DevicesWidget';
import { useStartTask } from '../features/tasks/api/useStartTask';
import ItemCountsWidget from '../components/widgets/ItemCountsWidget';
import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state';
export const Component = () => {
const [ isRestartConfirmDialogOpen, setIsRestartConfirmDialogOpen ] = useState(false);
@@ -26,6 +27,10 @@ export const Component = () => {
const { data: tasks } = useLiveTasks({ isHidden: false });
const librariesTask = useMemo(() => (
tasks?.find((value) => value.Key === 'RefreshLibrary')
), [ tasks ]);
const promptRestart = useCallback(() => {
setIsRestartConfirmDialogOpen(true);
}, []);
@@ -94,6 +99,7 @@ export const Component = () => {
onScanLibrariesClick={onScanLibraries}
onRestartClick={promptRestart}
onShutdownClick={promptShutdown}
isScanning={librariesTask?.State !== TaskState.Idle}
/>
<ItemCountsWidget />
<RunningTasksWidget tasks={tasks} />

View File

@@ -29,7 +29,8 @@ export const Component = () => {
const showMediaLibraryCreator = useCallback(() => {
const mediaLibraryCreator = new MediaLibraryCreator({
collectionTypeOptions: getCollectionTypeOptions()
collectionTypeOptions: getCollectionTypeOptions(),
refresh: true
}) as Promise<boolean>;
void mediaLibraryCreator.then((hasChanges: boolean) => {
@@ -69,7 +70,7 @@ export const Component = () => {
<Button
onClick={onScanLibraries}
startIcon={<RefreshIcon />}
loading={librariesTask && librariesTask.State === TaskState.Running}
loading={librariesTask && librariesTask.State !== TaskState.Idle}
loadingPosition='start'
variant='outlined'
>

View File

@@ -12,7 +12,7 @@ import useLiveTasks from 'apps/dashboard/features/tasks/hooks/useLiveTasks';
import Button from '@mui/material/Button';
import RefreshIcon from '@mui/icons-material/Refresh';
import AddIcon from '@mui/icons-material/Add';
import { Form, Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { useStartTask } from 'apps/dashboard/features/tasks/api/useStartTask';
import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state';
import TaskProgress from 'apps/dashboard/features/tasks/components/TaskProgress';
@@ -22,6 +22,7 @@ import ListItemText from '@mui/material/ListItemText';
import Alert from '@mui/material/Alert';
import List from '@mui/material/List';
import Provider from 'apps/dashboard/features/livetv/components/Provider';
import Grid from '@mui/material/Grid';
const CONFIG_KEY = 'livetv';
@@ -81,33 +82,44 @@ export const Component = () => {
className='mainAnimatedPage type-interior'
>
<Box className='content-primary'>
<Form>
{(isConfigError || isTasksError) ? (
<Alert severity='error'>{globalize.translate('HeaderError')}</Alert>
) : (
<Stack spacing={3}>
<Typography variant='h2'>{globalize.translate('HeaderTunerDevices')}</Typography>
{(isConfigError || isTasksError) ? (
<Alert severity='error'>{globalize.translate('HeaderError')}</Alert>
) : (
<Stack spacing={3}>
<Typography variant='h2'>{globalize.translate('HeaderTunerDevices')}</Typography>
<Button
sx={{ alignSelf: 'flex-start' }}
startIcon={<AddIcon />}
component={Link}
to='/dashboard/livetv/tuner'
>
{globalize.translate('ButtonAddTunerDevice')}
</Button>
<Button
sx={{ alignSelf: 'flex-start' }}
startIcon={<AddIcon />}
component={Link}
to='/dashboard/livetv/tuner'
>
{globalize.translate('ButtonAddTunerDevice')}
</Button>
<Stack direction='row' spacing={2}>
{ config.TunerHosts?.map(tunerHost => (
<TunerDeviceCard
<Box>
<Grid container spacing={2}>
{config.TunerHosts?.map(tunerHost => (
<Grid
key={tunerHost.Id}
tunerHost={tunerHost}
/>
)) }
</Stack>
item
xs={12}
sm={6}
md={3}
lg={2.4}
>
<TunerDeviceCard
key={tunerHost.Id}
tunerHost={tunerHost}
/>
</Grid>
))}
</Grid>
</Box>
<Typography variant='h2'>{globalize.translate('HeaderGuideProviders')}</Typography>
<Typography variant='h2'>{globalize.translate('HeaderGuideProviders')}</Typography>
<Stack sx={{ alignSelf: 'flex-start' }} spacing={2}>
<Stack direction='row' spacing={1.5}>
<Button
sx={{ alignSelf: 'flex-start' }}
@@ -132,32 +144,33 @@ export const Component = () => {
{(refreshGuideTask && refreshGuideTask.State === TaskState.Running) && (
<TaskProgress task={refreshGuideTask} />
)}
<Menu
anchorEl={anchorEl}
open={isMenuOpen}
onClose={onMenuClose}
>
<MenuItem onClick={navigateToSchedulesDirect}>
<ListItemText>Schedules Direct</ListItemText>
</MenuItem>
<MenuItem onClick={navigateToXMLTV}>
<ListItemText>XMLTV</ListItemText>
</MenuItem>
</Menu>
{(config.ListingProviders && config.ListingProviders?.length > 0) && (
<List sx={{ backgroundColor: 'background.paper' }}>
{config.ListingProviders?.map(provider => (
<Provider
key={provider.Id}
provider={provider}
/>
))}
</List>
)}
</Stack>
)}
</Form>
<Menu
anchorEl={anchorEl}
open={isMenuOpen}
onClose={onMenuClose}
>
<MenuItem onClick={navigateToSchedulesDirect}>
<ListItemText>Schedules Direct</ListItemText>
</MenuItem>
<MenuItem onClick={navigateToXMLTV}>
<ListItemText>XMLTV</ListItemText>
</MenuItem>
</Menu>
{(config.ListingProviders && config.ListingProviders?.length > 0) && (
<List sx={{ backgroundColor: 'background.paper' }}>
{config.ListingProviders?.map(provider => (
<Provider
key={provider.Id}
provider={provider}
/>
))}
</List>
)}
</Stack>
)}
</Box>
</Page>
);

View File

@@ -62,7 +62,7 @@ export const Component = () => {
onClose={handleToastClose}
message={globalize.translate('CopyLogSuccess')}
/>
<Container className='content-primary'>
<Container className='content-primary' maxWidth={false}>
<Box>
<Typography variant='h1'>{fileName}</Typography>
@@ -106,7 +106,14 @@ export const Component = () => {
<Paper sx={{ mt: 2 }}>
<code>
<pre style={{ overflow:'auto', margin: 0, padding: '16px' }}>{log}</pre>
<pre style={{
overflow:'auto',
margin: 0,
padding: '16px',
whiteSpace: 'pre-wrap'
}}>
{log}
</pre>
</code>
</Paper>
</>

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

@@ -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;

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

@@ -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

@@ -10,6 +10,9 @@ import themeManager from 'scripts/themeManager';
import { currentSettings, UserSettings } from 'scripts/settings/userSettings';
import type { DisplaySettingsValues } from '../types/displaySettingsValues';
import { useThemes } from 'hooks/useThemes';
import { Theme } from 'types/webConfig';
import { FALLBACK_THEME_ID } from 'hooks/useUserTheme';
interface UseDisplaySettingsParams {
userId?: string | null;
@@ -20,6 +23,7 @@ export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {
const [userSettings, setUserSettings] = useState<UserSettings>();
const [displaySettings, setDisplaySettings] = useState<DisplaySettingsValues>();
const { __legacyApiClient__, user: currentUser } = useApi();
const { defaultTheme } = useThemes();
useEffect(() => {
if (!userId || !currentUser || !__legacyApiClient__) {
@@ -29,7 +33,7 @@ export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {
setLoading(true);
void (async () => {
const loadedSettings = await loadDisplaySettings({ api: __legacyApiClient__, currentUser, userId });
const loadedSettings = await loadDisplaySettings({ api: __legacyApiClient__, currentUser, userId, defaultTheme });
setDisplaySettings(loadedSettings.displaySettings);
setUserSettings(loadedSettings.userSettings);
@@ -62,15 +66,17 @@ export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {
}
interface LoadDisplaySettingsParams {
currentUser: UserDto;
userId?: string;
api: ApiClient;
currentUser: UserDto
userId?: string
api: ApiClient
defaultTheme?: Theme
}
async function loadDisplaySettings({
currentUser,
userId,
api
api,
defaultTheme
}: LoadDisplaySettingsParams) {
const settings = (!userId || userId === currentUser?.Id) ? currentSettings : new UserSettings();
const user = (!userId || userId === currentUser?.Id) ? currentUser : await api.getUser(userId);
@@ -78,8 +84,8 @@ async function loadDisplaySettings({
await settings.setUserInfo(userId, api);
const displaySettings = {
customCss: settings.customCss(),
dashboardTheme: settings.dashboardTheme() || 'auto',
customCss: settings.customCss() || '',
dashboardTheme: settings.dashboardTheme() || defaultTheme?.id || FALLBACK_THEME_ID,
dateTimeLocale: settings.dateTimeLocale() || 'auto',
disableCustomCss: Boolean(settings.disableCustomCss()),
displayMissingEpisodes: user?.Configuration?.DisplayMissingEpisodes ?? false,
@@ -97,7 +103,8 @@ async function loadDisplaySettings({
maxDaysForNextUp: settings.maxDaysForNextUp(),
screensaver: settings.screensaver() || 'none',
screensaverInterval: settings.backdropScreensaverInterval(),
theme: settings.theme()
slideshowInterval: settings.slideshowInterval(),
theme: settings.theme() || defaultTheme?.id || FALLBACK_THEME_ID
};
return {
@@ -125,7 +132,7 @@ async function saveDisplaySettings({
userSettings.language(normalizeValue(newDisplaySettings.language));
}
userSettings.customCss(normalizeValue(newDisplaySettings.customCss));
userSettings.dashboardTheme(normalizeValue(newDisplaySettings.dashboardTheme));
userSettings.dashboardTheme(newDisplaySettings.dashboardTheme);
userSettings.dateTimeLocale(normalizeValue(newDisplaySettings.dateTimeLocale));
userSettings.disableCustomCss(newDisplaySettings.disableCustomCss);
userSettings.enableBlurhash(newDisplaySettings.enableBlurHash);

View File

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

View File

@@ -51,19 +51,20 @@ class MediaSessionSubscriber extends PlaybackSubscriber {
}
private bindNavigatorSession() {
/* eslint-disable compat/compat */
navigator.mediaSession.setActionHandler('pause', this.onMediaSessionAction.bind(this));
navigator.mediaSession.setActionHandler('play', this.onMediaSessionAction.bind(this));
navigator.mediaSession.setActionHandler('stop', this.onMediaSessionAction.bind(this));
navigator.mediaSession.setActionHandler('previoustrack', this.onMediaSessionAction.bind(this));
navigator.mediaSession.setActionHandler('nexttrack', this.onMediaSessionAction.bind(this));
navigator.mediaSession.setActionHandler('seekto', this.onMediaSessionAction.bind(this));
const actions: MediaSessionAction[] = ['pause', 'play', 'previoustrack', 'nexttrack', 'stop', 'seekto'];
// iOS will only show next/prev track controls or seek controls
if (!browser.iOS) {
navigator.mediaSession.setActionHandler('seekbackward', this.onMediaSessionAction.bind(this));
navigator.mediaSession.setActionHandler('seekforward', this.onMediaSessionAction.bind(this));
if (!browser.iOS) actions.push('seekbackward', 'seekforward');
for (const action of actions) {
try {
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.setActionHandler(action, this.onMediaSessionAction.bind(this));
} catch (err) {
// NOTE: Some legacy (TV) browsers lack support for the stop and seekto actions
console.warn(`[MediaSessionSubscriber] Failed to add "${action}" action handler`, err);
}
}
/* eslint-enable compat/compat */
}
private onMediaSessionAction(details: MediaSessionActionDetails) {

View File

@@ -78,12 +78,14 @@ export abstract class PlaybackSubscriber {
constructor(
protected readonly playbackManager: PlaybackManager
) {
// Bind player events before invoking any player change handlers
Events.on(playbackManager, PlaybackManagerEvent.PlayerChange, this.bindPlayerEvents.bind(this));
Object.entries(this.playbackManagerEvents).forEach(([event, handler]) => {
if (handler) Events.on(playbackManager, event, handler);
});
this.bindPlayerEvents();
Events.on(playbackManager, PlaybackManagerEvent.PlayerChange, this.bindPlayerEvents.bind(this));
}
private bindPlayerEvents() {

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

@@ -0,0 +1,12 @@
<svg width="35" height="33" viewBox="0 0 35 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<script xmlns="" />
<path
d="M0.0146346 0.593029L0.764804 3.57722C0.809995 3.75303 0.945568 3.88826 1.12633 3.92432L14.765 6.91753C15.1897 7.01219 15.5106 7.37733 15.5377 7.81459L16.6585 23.9031C16.6675 24.0158 16.7127 24.1195 16.7895 24.2006L17.133 24.5567C17.2233 24.6514 17.3454 24.7055 17.4764 24.7055C17.6075 24.7055 17.7295 24.6514 17.8199 24.5567L18.1633 24.2006C18.2401 24.1195 18.2853 24.0158 18.2944 23.9031L19.4151 7.81459C19.4467 7.38184 19.7631 7.01219 20.1879 6.91753L33.8265 3.92432C34.0027 3.88375 34.1428 3.75303 34.188 3.57722L34.9382 0.593029C34.9789 0.430747 34.9382 0.268465 34.8162 0.146753C34.6987 0.0250412 34.536 -0.0245451 34.3733 0.0115177L17.6843 3.66738C17.5442 3.69893 17.3996 3.69893 17.2595 3.66738L0.579521 0.0160255C0.543368 0.00250199 0.511735 0.00250199 0.475582 0.00250199C0.349047 0.00250199 0.227032 0.0520882 0.13665 0.146753C0.0191537 0.268465 -0.0260373 0.430747 0.0146346 0.593029Z"
fill="#fff" />
<path
d="M23.0349 32.301L23.8257 32.0936C23.9658 32.0576 24.0743 31.9494 24.0969 31.8096L27.7528 14.0397C27.8116 13.7062 28.0692 13.4357 28.4036 13.3545L31.7567 12.5476C31.8878 12.5161 31.9872 12.4169 32.0188 12.2862L32.8142 9.11267C32.8458 8.99095 32.8142 8.86474 32.7193 8.77458C32.6515 8.70696 32.5611 8.6709 32.4708 8.6709C32.4436 8.6709 32.412 8.6709 32.3849 8.67991L24.3319 10.6228C24.1782 10.6589 24.0698 10.7896 24.0607 10.9429L22.5965 31.9268C22.5875 32.0395 22.6327 32.1477 22.7231 32.2244C22.8089 32.2965 22.9264 32.3235 23.0394 32.2965L23.0349 32.301Z"
fill="#fff" />
<path
d="M10.996 10.6274L2.94297 8.68455C2.91586 8.67554 2.88422 8.67554 2.85711 8.67554C2.76221 8.67554 2.67635 8.7116 2.60856 8.77922C2.51818 8.86937 2.48654 8.99109 2.51366 9.11731L3.30902 12.2908C3.34065 12.4216 3.44007 12.5207 3.57113 12.5523L6.92429 13.3592C7.25871 13.4403 7.51629 13.7108 7.57504 14.0489L11.231 31.8053C11.2581 31.9495 11.362 32.0622 11.4976 32.0938L12.2975 32.3011C12.406 32.3282 12.5189 32.3011 12.6048 32.2245C12.6907 32.1524 12.7404 32.0442 12.7313 31.927L11.2671 10.943C11.2581 10.7852 11.1451 10.659 10.996 10.6229V10.6274Z"
fill="#fff" />
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,27 +1,12 @@
import { appRouter } from './router/appRouter';
import browser from '../scripts/browser';
import dialog from './dialog/dialog';
import globalize from '../lib/globalize';
export default async function (text, title) {
// Modals seem to be blocked on Web OS and Tizen 2.x
const canUseNativeAlert = !!(
!browser.web0s
&& !(browser.tizenVersion && (browser.tizenVersion < 3 || browser.tizenVersion >= 8))
&& browser.tv
&& window.alert
);
const options = typeof text === 'string' ? { title, text } : text;
await appRouter.ready();
if (canUseNativeAlert) {
alert((options.text || '').replaceAll('<br/>', '\n'));
return Promise.resolve();
}
options.buttons = [
{
name: globalize.translate('ButtonGotIt'),

View File

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

View File

@@ -12,9 +12,7 @@ function enableAnimation() {
}
function enableRotation() {
return !browser.tv
// Causes high cpu usage
&& !browser.firefox;
return !browser.tv;
}
class Backdrop {
@@ -236,7 +234,7 @@ export function setBackdropImages(images) {
currentRotationIndex = -1;
if (images.length > 1 && enableRotation()) {
rotationInterval = setInterval(onRotationInterval, 24000);
rotationInterval = setInterval(onRotationInterval, 10000);
}
onRotationInterval();

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

@@ -1,5 +1,8 @@
import React, { type FC } from 'react';
import Box from '@mui/material/Box';
import { ensureArray } from 'utils/array';
import type { TextLine } from './cardHelper';
interface CardTextProps {
@@ -7,27 +10,33 @@ interface CardTextProps {
textLine: TextLine;
}
const SEPARATOR = ' / ';
const CardText: FC<CardTextProps> = ({ className, textLine }) => {
const { title, titleAction } = textLine;
// eslint-disable-next-line sonarjs/function-return-type
const renderCardText = () => {
if (titleAction) {
return (
<a
className='itemAction textActionButton'
href={titleAction.url}
title={titleAction.title}
{...titleAction.dataAttributes}
>
{titleAction.title}
</a>
);
} else {
return title;
}
};
return <Box className={className}>{renderCardText()}</Box>;
return (
<Box className={className}>
{titleAction ? (
ensureArray(titleAction).map((action, i, arr) => (
<>
<a
className='itemAction textActionButton'
href={action.url}
title={action.title}
{...action.dataAttributes}
>
{action.title}
</a>
{/* If there are more items, add the separator */}
{(i < arr.length - 1) && SEPARATOR}
</>
))
) : (
ensureArray(title).join(SEPARATOR)
)}
</Box>
);
};
export default CardText;

View File

@@ -1,4 +1,5 @@
import { Api } from '@jellyfin/sdk';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import type { BaseItemPerson } from '@jellyfin/sdk/lib/generated-client/models/base-item-person';
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
@@ -6,12 +7,14 @@ import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
import { appRouter } from 'components/router/appRouter';
import layoutManager from 'components/layoutManager';
import itemHelper from 'components/itemHelper';
import { ItemAction } from 'constants/itemAction';
import globalize from 'lib/globalize';
import datetime from 'scripts/datetime';
import { isUsingLiveTvNaming } from '../cardBuilderUtils';
import { getDataAttributes } from 'utils/items';
import { ItemKind } from 'types/base/models/item-kind';
import { ItemMediaKind } from 'types/base/models/item-media-kind';
import { ensureArray } from 'utils/array';
import type { NullableNumber, NullableString } from 'types/base/common/shared/types';
import type { ItemDto } from 'types/base/models/item-dto';
@@ -65,8 +68,8 @@ interface TextAction {
}
export interface TextLine {
title?: NullableString;
titleAction?: TextAction;
title?: NullableString | string[];
titleAction?: TextAction | TextAction[];
}
export function getTextActionButton(
@@ -86,7 +89,7 @@ export function getTextActionButton(
const dataAttributes = getDataAttributes(
{
action: 'link',
action: ItemAction.Link,
itemServerId: serverId ?? item.ServerId,
itemId: item.Id,
itemChannelId: item.ChannelId,
@@ -210,9 +213,25 @@ function getParentTitle(
item: ItemDto
) {
if (isOuterFooter && item.AlbumArtists?.length) {
(item.AlbumArtists[0] as ItemDto).Type = ItemKind.MusicArtist;
(item.AlbumArtists[0] as ItemDto).IsFolder = true;
return getTextActionButton(item.AlbumArtists[0], null, serverId);
return item.AlbumArtists
.map(artist => {
const artistItem: ItemDto = {
...artist,
Type: BaseItemKind.MusicArtist,
IsFolder: true
};
return getTextActionButton(artistItem, null, serverId);
})
.reduce((acc, line) => ({
title: [
...ensureArray(acc.title),
...ensureArray(line.title)
],
titleAction: [
...ensureArray(acc.titleAction),
...ensureArray(line.titleAction)
]
}), {});
} else {
return {
title: isUsingLiveTvNaming(item.Type) ?

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

@@ -8,6 +8,7 @@ import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-ite
import { PersonKind } from '@jellyfin/sdk/lib/generated-client/models/person-kind';
import escapeHtml from 'escape-html';
import { ItemAction } from 'constants/itemAction';
import browser from 'scripts/browser';
import datetime from 'scripts/datetime';
import dom from 'utils/dom';
@@ -514,7 +515,7 @@ function getCardFooterText(item, apiClient, options, footerClass, progressHtml,
const showOtherText = flags.isOuterFooter ? !flags.overlayText : flags.overlayText;
if (flags.isOuterFooter && options.cardLayout && layoutManager.mobile && options.cardFooterAside !== 'none') {
html += `<button is="paper-icon-button-light" class="itemAction btnCardOptions cardText-secondary" data-action="menu" title="${globalize.translate('ButtonMore')}"><span class="material-icons more_vert" aria-hidden="true"></span></button>`;
html += `<button is="paper-icon-button-light" class="itemAction btnCardOptions cardText-secondary" data-action="${ItemAction.Menu}" title="${globalize.translate('ButtonMore')}"><span class="material-icons more_vert" aria-hidden="true"></span></button>`;
}
const cssClass = options.centerText ? 'cardText cardTextCentered' : 'cardText';
@@ -575,9 +576,14 @@ function getCardFooterText(item, apiClient, options, footerClass, progressHtml,
if (showOtherText) {
if (options.showParentTitle && parentTitleUnderneath) {
if (flags.isOuterFooter && item.AlbumArtists?.length) {
item.AlbumArtists[0].Type = 'MusicArtist';
item.AlbumArtists[0].IsFolder = true;
lines.push(getTextActionButton(item.AlbumArtists, null, serverId));
const artistText = item.AlbumArtists
.map(artist => {
artist.Type = BaseItemKind.MusicArtist;
artist.IsFolder = true;
return getTextActionButton(artist, null, serverId);
})
.join(' / ');
lines.push(artistText);
} else {
lines.push(escapeHtml(isUsingLiveTvNaming(item.Type) ? item.Name : (item.SeriesName || item.Series || item.Album || item.AlbumArtist || '')));
}
@@ -771,7 +777,7 @@ function getTextActionButton(item, text, serverId) {
}
const url = appRouter.getRouteUrl(item);
let html = '<a href="' + url + '" ' + itemShortcuts.getShortcutAttributesHtml(item, serverId) + ' class="itemAction textActionButton" title="' + text + '" data-action="link">';
let html = '<a href="' + url + '" ' + itemShortcuts.getShortcutAttributesHtml(item, serverId) + ' class="itemAction textActionButton" title="' + text + `" data-action="${ItemAction.Link}">`;
html += text;
html += '</a>';
@@ -880,7 +886,7 @@ function importRefreshIndicator() {
*/
function buildCard(index, item, apiClient, options) {
const action = resolveAction({
defaultAction: options.action || 'link',
defaultAction: options.action || ItemAction.Link,
isFolder: item.IsFolder,
isPhoto: item.MediaType === 'Photo'
});
@@ -980,15 +986,15 @@ function buildCard(index, item, apiClient, options) {
const btnCssClass = 'cardOverlayButton cardOverlayButton-br itemAction';
if (options.centerPlayButton) {
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass} cardOverlayButton-centered" data-action="play" title="${globalize.translate('Play')}"><span class="material-icons cardOverlayButtonIcon play_arrow" aria-hidden="true"></span></button>`;
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass} cardOverlayButton-centered" data-action="${ItemAction.Play}" title="${globalize.translate('Play')}"><span class="material-icons cardOverlayButtonIcon play_arrow" aria-hidden="true"></span></button>`;
}
if (overlayPlayButton && !item.IsPlaceHolder && (item.LocationType !== 'Virtual' || !item.MediaType || item.Type === 'Program') && item.Type !== 'Person') {
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="play" title="${globalize.translate('Play')}"><span class="material-icons cardOverlayButtonIcon play_arrow" aria-hidden="true"></span></button>`;
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="${ItemAction.Play}" title="${globalize.translate('Play')}"><span class="material-icons cardOverlayButtonIcon play_arrow" aria-hidden="true"></span></button>`;
}
if (options.overlayMoreButton) {
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="menu" title="${globalize.translate('ButtonMore')}"><span class="material-icons cardOverlayButtonIcon more_vert" aria-hidden="true"></span></button>`;
overlayButtons += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="${ItemAction.Menu}" title="${globalize.translate('ButtonMore')}"><span class="material-icons cardOverlayButtonIcon more_vert" aria-hidden="true"></span></button>`;
}
}
@@ -1151,7 +1157,7 @@ function getHoverMenuHtml(item, action) {
const btnCssClass = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light';
if (playbackManager.canPlay(item)) {
html += '<button is="paper-icon-button-light" class="' + btnCssClass + ' cardOverlayFab-primary" data-action="resume"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover play_arrow" aria-hidden="true"></span></button>';
html += `<button is="paper-icon-button-light" class="${btnCssClass} cardOverlayFab-primary" data-action="${ItemAction.Resume}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover play_arrow" aria-hidden="true"></span></button>`;
}
html += '<div class="cardOverlayButton-br flex">';
@@ -1160,17 +1166,17 @@ function getHoverMenuHtml(item, action) {
if (itemHelper.canMarkPlayed(item)) {
import('../../elements/emby-playstatebutton/emby-playstatebutton');
html += '<button is="emby-playstatebutton" type="button" data-action="none" class="' + btnCssClass + '" data-id="' + item.Id + '" data-serverid="' + item.ServerId + '" data-itemtype="' + item.Type + '" data-played="' + (userData.Played) + '"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check" aria-hidden="true"></span></button>';
html += `<button is="emby-playstatebutton" type="button" data-action="${ItemAction.None}" class="${btnCssClass}" data-id="${item.Id}" data-serverid="${item.ServerId}" data-itemtype="${item.Type}" data-played="${userData.Played}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check" aria-hidden="true"></span></button>`;
}
if (itemHelper.canRate(item)) {
const likes = userData.Likes == null ? '' : userData.Likes;
import('../../elements/emby-ratingbutton/emby-ratingbutton');
html += '<button is="emby-ratingbutton" type="button" data-action="none" class="' + btnCssClass + '" data-id="' + item.Id + '" data-serverid="' + item.ServerId + '" data-itemtype="' + item.Type + '" data-likes="' + likes + '" data-isfavorite="' + (userData.IsFavorite) + '"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite" aria-hidden="true"></span></button>';
html += `<button is="emby-ratingbutton" type="button" data-action="${ItemAction.None}" class="${btnCssClass}" data-id="${item.Id}" data-serverid="${item.ServerId}" data-itemtype="${item.Type}" data-likes="${likes}" data-isfavorite="${userData.IsFavorite}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite" aria-hidden="true"></span></button>`;
}
html += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="menu" title="${globalize.translate('ButtonMore')}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover more_vert" aria-hidden="true"></span></button>`;
html += `<button is="paper-icon-button-light" class="${btnCssClass}" data-action="${ItemAction.Menu}" title="${globalize.translate('ButtonMore')}"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover more_vert" aria-hidden="true"></span></button>`;
html += '</div>';
html += '</div>';

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

@@ -1,7 +1,6 @@
import dialog from 'components/dialog/dialog';
import { appRouter } from 'components/router/appRouter';
import globalize from 'lib/globalize';
import browser from 'scripts/browser';
interface OptionItem {
id: string,
@@ -18,34 +17,7 @@ interface ConfirmOptions {
buttons?: OptionItem[]
}
function shouldUseNativeConfirm() {
// webOS seems to block modals
// Tizen 2.x seems to block modals
return !browser.web0s
&& !(browser.tizenVersion && (browser.tizenVersion < 3 || browser.tizenVersion >= 8))
&& browser.tv
&& !!window.confirm;
}
async function nativeConfirm(options: string | ConfirmOptions) {
if (typeof options === 'string') {
options = {
text: options
} as ConfirmOptions;
}
const text = (options.text || '').replace(/<br\/>/g, '\n');
await appRouter.ready();
const result = window.confirm(text);
if (result) {
return Promise.resolve();
} else {
return Promise.reject(new Error('Confirm dialog rejected'));
}
}
async function customConfirm(options: string | ConfirmOptions, title: string = '') {
async function confirm(options: string | ConfirmOptions, title: string = '') {
if (typeof options === 'string') {
options = {
title,
@@ -80,6 +52,4 @@ async function customConfirm(options: string | ConfirmOptions, title: string = '
});
}
const confirm = shouldUseNativeConfirm() ? nativeConfirm : customConfirm;
export default confirm;

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

@@ -53,7 +53,7 @@ function renderFilters(context, result, query) {
const delimeter = '|';
return (delimeter + (query.Tags || '') + delimeter).includes(delimeter + i + delimeter);
});
renderOptions(context, '.yearFilters', 'chkYearFilter', merge(result.Years, query.Years, ','), function (i) {
renderOptions(context, '.yearFilters', 'chkYearFilter', merge(result.Years.map(String), query.Years, ','), function (i) {
const delimeter = ',';
return (delimeter + (query.Years || '') + delimeter).includes(delimeter + i + delimeter);
});

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

@@ -48,12 +48,6 @@ export function enableHlsJsPlayer(runTimeTicks, mediaType) {
return false;
}
// Native HLS support in WebOS only plays stereo sound. hls.js works better, but works only on WebOS 4 or newer.
// Using hls.js also seems to fix fast forward issues that native HLS has.
if (browser.web0sVersion >= 4) {
return true;
}
// The native players on these devices support seeking live streams, no need to use hls.js here
if (browser.tizen || browser.web0s) {
return false;
@@ -65,6 +59,12 @@ export function enableHlsJsPlayer(runTimeTicks, mediaType) {
return true;
}
// Chromium 141+ brings native HLS support that does not support switching HDR/SDR playlists.
// Always use hls.js to avoid falling back to transcoding from remuxing and client side tone-mapping.
if (browser.chrome || browser.edgeChromium || browser.opera) {
return true;
}
// simple playback should use the native support
if (runTimeTicks) {
return false;

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

@@ -52,14 +52,6 @@ export function getDisplayName(item, options = {}) {
}
}
if (Array.isArray(item)) {
if (item.length > 1) {
return item.map(i => getDisplayName(i, options)).join(' / ');
} else if (item.length === 1) {
return item[0].Name;
}
}
return name;
}

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"><span class="material-icons listItemImageButton-icon play_arrow" aria-hidden="true"></span></button>';
html += `<button is="paper-icon-button-light" class="listItemImageButton itemAction" data-action="${ItemAction.Resume}"><span class="material-icons listItemImageButton-icon play_arrow" aria-hidden="true"></span></button>`;
}
const progressHtml = indicators.getProgressBarHtml(item, {
@@ -449,11 +453,11 @@ export function getListViewHtml(options) {
if (!clickEntireItem) {
if (options.addToListButton) {
html += '<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="addtoplaylist"><span class="material-icons playlist_add" aria-hidden="true"></span></button>';
html += `<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="${ItemAction.AddToPlaylist}"><span class="material-icons playlist_add" aria-hidden="true"></span></button>`;
}
if (options.infoButton) {
html += '<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="link"><span class="material-icons info_outline" aria-hidden="true"></span></button>';
html += `<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="${ItemAction.Link}"><span class="material-icons info_outline" aria-hidden="true"></span></button>`;
}
if (options.rightButtons) {
@@ -474,7 +478,7 @@ export function getListViewHtml(options) {
}
if (options.moreButton !== false) {
html += '<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="menu"><span class="material-icons more_vert" aria-hidden="true"></span></button>';
html += `<button is="paper-icon-button-light" class="listItemButton itemAction" data-action="${ItemAction.Menu}"><span class="material-icons more_vert" aria-hidden="true"></span></button>`;
}
}
html += '</div>';

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

@@ -1024,7 +1024,7 @@ export class PlaybackManager {
self.canPlay = function (item) {
const itemType = item.Type;
if (itemType === 'PhotoAlbum' || itemType === 'MusicGenre' || itemType === 'Season' || itemType === 'Series' || itemType === 'BoxSet' || itemType === 'MusicAlbum' || itemType === 'MusicArtist' || itemType === 'Playlist') {
if (itemType === 'Book' || itemType === 'PhotoAlbum' || itemType === 'MusicGenre' || itemType === 'Season' || itemType === 'Series' || itemType === 'BoxSet' || itemType === 'MusicAlbum' || itemType === 'MusicArtist' || itemType === 'Playlist') {
return true;
}
@@ -1980,28 +1980,18 @@ export class PlaybackManager {
const startSeasonId = firstItem.Type === 'Season' ? items[options.startIndex || 0].Id : undefined;
const seasonId = (startSeasonId && items.length === 1) ? startSeasonId : undefined;
const seriesId = firstItem.SeriesId || firstItem.Id;
const SeriesId = firstItem.SeriesId || firstItem.Id;
const UserId = apiClient.getCurrentUserId();
let startItemId;
// Start from a specific (the next unwatched) episode if we want to watch in order and have not chosen a specific season
if (!options.shuffle && !seasonId) {
const initialUnplayedEpisode = await getItems(apiClient, UserId, {
SortBy: 'SeriesSortName,SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Episode',
Recursive: true,
IsMissing: false,
ParentId: seriesId,
limit: 1,
Filters: 'IsUnplayed'
});
startItemId = initialUnplayedEpisode?.Items?.at(0)?.Id;
const nextUp = await apiClient.getNextUpEpisodes({ SeriesId, UserId });
startItemId = nextUp?.Items?.[0]?.Id;
}
const episodesResult = await apiClient.getEpisodes(seriesId, {
const episodesResult = await apiClient.getEpisodes(SeriesId, {
IsVirtualUnaired: false,
IsMissing: false,
SeasonId: seasonId,
@@ -3493,7 +3483,7 @@ export class PlaybackManager {
const nextItemPlayOptions = nextItem ? (nextItem.item.playOptions || getDefaultPlayOptions()) : getDefaultPlayOptions();
const newPlayer = nextItem ? getPlayer(nextItem.item, nextItemPlayOptions) : null;
if (newPlayer !== player) {
if (!newPlayer) {
data.streamInfo = null;
destroyPlayer(player);
removeCurrentPlayer(player);
@@ -3501,12 +3491,21 @@ export class PlaybackManager {
if (errorOccurred) {
showPlaybackInfoErrorMessage(self, 'PlaybackError' + displayErrorCode);
} else if (nextItem) {
} else if (newPlayer) {
const apiClient = ServerConnections.getApiClient(nextItem.item.ServerId);
apiClient.getCurrentUser().then(function (user) {
if (user.Configuration.EnableNextEpisodeAutoPlay || nextMediaType !== MediaType.Video) {
self.nextTrack();
if (newPlayer !== player) {
Events.trigger(self, 'playbackstop', [{
player,
state,
nextItem,
nextMediaType
}]);
}
}
});
}

View File

@@ -93,12 +93,16 @@ function getQualitySecondaryText(player) {
return stream.Type === 'Video';
})[0];
const videoCodec = videoStream ? videoStream.Codec : null;
const videoBitRate = videoStream ? videoStream.BitRate : null;
const videoWidth = videoStream ? videoStream.Width : null;
const videoHeight = videoStream ? videoStream.Height : null;
const options = qualityoptions.getVideoQualityOptions({
currentMaxBitrate: playbackManager.getMaxStreamingBitrate(player),
isAutomaticBitrateEnabled: playbackManager.enableAutomaticBitrateDetection(player),
videoCodec,
videoBitRate,
videoWidth: videoWidth,
videoHeight: videoHeight,
enableAuto: true

View File

@@ -1,6 +1,7 @@
import { PlaybackManager } from './playbackmanager';
import { TICKS_PER_MILLISECOND, TICKS_PER_SECOND } from 'constants/time';
import type { MediaSegmentDto } from '@jellyfin/sdk/lib/generated-client/models/media-segment-dto';
import type { PlaybackStopInfo } from 'types/playbackStopInfo';
import { PlaybackSubscriber } from 'apps/stable/features/playback/utils/playbackSubscriber';
import { isInSegment } from 'apps/stable/features/playback/utils/mediaSegments';
import Events, { type Event } from 'utils/events';
@@ -188,10 +189,12 @@ class SkipSegment extends PlaybackSubscriber {
}
}
onPlaybackStop() {
onPlaybackStop(_e: Event, playbackStopInfo: PlaybackStopInfo) {
this.currentSegment = null;
this.hideSkipButton();
Events.off(document, EventType.SHOW_VIDEO_OSD, this.onOsdChanged);
if (!playbackStopInfo.nextItem) {
Events.off(document, EventType.SHOW_VIDEO_OSD, this.onOsdChanged);
}
}
}

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

@@ -1,4 +1,3 @@
import browser from '../../scripts/browser';
import dialogHelper from '../dialogHelper/dialogHelper';
import layoutManager from '../layoutManager';
import scrollHelper from '../../scripts/scrollHelper';
@@ -92,33 +91,13 @@ export default (() => {
});
}
if ((browser.tv || browser.xboxOne) && window.confirm) {
return options => {
if (typeof options === 'string') {
options = {
label: '',
text: options
};
}
const label = (options.label || '').replaceAll('<br/>', '\n');
const result = prompt(label, options.text || '');
if (result) {
return Promise.resolve(result);
} else {
return Promise.reject(result);
}
};
} else {
return options => {
if (typeof options === 'string') {
options = {
title: '',
text: options
};
}
return showDialog(options);
};
}
return options => {
if (typeof options === 'string') {
options = {
title: '',
text: options
};
}
return showDialog(options);
};
})();

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

@@ -6,6 +6,7 @@ import itemHelper from '../itemHelper';
import loading from '../loading/loading';
import alert from '../alert';
import { LayoutMode } from 'constants/layoutMode';
import { getItemQuery } from 'hooks/useItem';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import { toApi } from 'utils/jellyfin-apiclient/compat';
@@ -434,7 +435,7 @@ class AppRouter {
const layoutMode = localStorage.getItem('layout');
if (layoutMode === 'experimental' && item.CollectionType == CollectionType.Homevideos) {
if (layoutMode === LayoutMode.Experimental && item.CollectionType == CollectionType.Homevideos) {
url = '#/homevideos?topParentId=' + item.Id;
return url;

View File

@@ -41,7 +41,7 @@ try {
const opts = Object.defineProperty({}, 'behavior', {
get: function () {
supportsScrollToOptions = true;
return null;
return 'auto';
}
});

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) {
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

@@ -80,11 +80,12 @@ function setFiles(page, files) {
}
async function onSubmit(e) {
e.preventDefault();
const file = currentFile;
if (!isValidSubtitleFile(file)) {
toast(globalize.translate('MessageSubtitleFileTypeAllowed'));
e.preventDefault();
return;
}
@@ -109,8 +110,6 @@ async function onSubmit(e) {
hasChanges = true;
dialogHelper.close(dlg);
});
e.preventDefault();
}
function initEditor(page) {

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 itemsContainer"></div>
<div id="lyricsSection" class="verticalSection-extrabottompadding detailVerticalSection lyricsContainer hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${Lyrics}</h2>
<div is="emby-itemscontainer" class="vertical-list itemsContainer"></div>
</div>
<div class="verticalSection detailVerticalSection moreFromArtistSection hide">
<h2 class="sectionTitle sectionTitle-cards padded-right"></h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div class="verticalSection detailVerticalSection moreFromArtistSection hide">
<h2 class="sectionTitle sectionTitle-cards padded-right"></h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
<div id="castCollapsible" class="verticalSection detailVerticalSection hide">
<h2 id="peopleHeader" class="sectionTitle sectionTitle-cards padded-right">${HeaderCastAndCrew}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="castContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div id="castCollapsible" class="verticalSection detailVerticalSection hide">
<h2 id="peopleHeader" class="sectionTitle sectionTitle-cards padded-right">${HeaderCastAndCrew}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="castContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
<div id="guestCastCollapsible" class="verticalSection detailVerticalSection hide">
<h2 id="guestCastHeader" class="sectionTitle sectionTitle-cards padded-right">${HeaderGuestCast}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="guestCastContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div id="guestCastCollapsible" class="verticalSection detailVerticalSection hide">
<h2 id="guestCastHeader" class="sectionTitle sectionTitle-cards padded-right">${HeaderGuestCast}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="guestCastContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
<div id="seriesScheduleSection" class="verticalSection detailVerticalSection hide">
<h2 class="sectionTitle padded-right">${HeaderUpcomingOnTV}</h2>
<div id="seriesScheduleList" is="emby-itemscontainer" class="itemsContainer vertical-list padded-right"></div>
</div>
<div id="specialsCollapsible" class="verticalSection detailVerticalSection hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${SpecialFeatures}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="specialsContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div id="seriesScheduleSection" class="verticalSection detailVerticalSection hide">
<h2 class="sectionTitle padded-right">${HeaderUpcomingOnTV}</h2>
<div id="seriesScheduleList" is="emby-itemscontainer" class="itemsContainer vertical-list padded-right"></div>
<div id="musicVideosCollapsible" class="verticalSection detailVerticalSection hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${MusicVideos}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="musicVideosContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div id="specialsCollapsible" class="verticalSection detailVerticalSection hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${SpecialFeatures}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="specialsContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
<div id="scenesCollapsible" class="verticalSection detailVerticalSection verticalSection-extrabottompadding hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${HeaderScenes}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="scenesContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div id="musicVideosCollapsible" class="verticalSection detailVerticalSection hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${MusicVideos}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="musicVideosContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div id="scenesCollapsible" class="verticalSection detailVerticalSection verticalSection-extrabottompadding hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${HeaderScenes}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="scenesContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer"></div>
</div>
</div>
<div id="similarCollapsible" class="verticalSection detailVerticalSection verticalSection-extrabottompadding hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${HeaderMoreLikeThis}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer similarContent"></div>
</div>
<div id="similarCollapsible" class="verticalSection detailVerticalSection verticalSection-extrabottompadding hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${HeaderMoreLikeThis}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer similarContent"></div>
</div>
</div>
</div>

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

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

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