Compare commits

...

334 Commits

Author SHA1 Message Date
mani
3dd1de3d9c Fix CRT shader: use plain &crtShader=true query param in TranscodingUrl
Some checks failed
Push & Release 🌍 / Automation 🎛️ (push) Has been cancelled
Push & Release 🌍 / Unstable release 🚀⚠️ (push) Has been cancelled
Push & Release 🌍 / Quality checks 👌🧪 (push) Has been cancelled
Push & Release 🌍 / GitHub CodeQL 🔬 (push) Has been cancelled
Push & Release 🌍 / Deploy 🚀 (push) Has been cancelled
Previous approach used streamOptions[crtShader]=true (wrong format).
ParseStreamOptions on the server reads IQueryCollection directly and
stores keys verbatim — so streamOptions[crtShader] becomes the key,
not crtShader, and TryGetValue("crtShader") always returns false.

Plain &crtShader=true works because ParseStreamOptions adds any
lowercase-starting query param directly to the StreamOptions dict.

Also remove the dead PlaybackInfoDto.StreamOptions code — that DTO
has no StreamOptions field, so it was silently ignored.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 22:23:24 +01:00
mani
d14f15a2f8 Fix CRT shader: pass streamOptions via PlaybackInfo body, not URL
Some checks failed
Push & Release 🌍 / Automation 🎛️ (push) Has been cancelled
Push & Release 🌍 / Unstable release 🚀⚠️ (push) Has been cancelled
Push & Release 🌍 / Quality checks 👌🧪 (push) Has been cancelled
Push & Release 🌍 / GitHub CodeQL 🔬 (push) Has been cancelled
Push & Release 🌍 / Deploy 🚀 (push) Has been cancelled
The previous approach of appending streamOptions[crtShader]=true to
the transcoding URL didn't work — the server only reads StreamOptions
from the PlaybackInfo request body (PlaybackInfoDto), not from the
streaming endpoint URL query string.

Pass streamOptions as part of the getPlaybackInfo query so the server
embeds them in the TranscodingUrl. Also propagate crtShadowMask the
same way. Remove the broken URL-appending code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 21:36:59 +01:00
mani
de2a29621d Fix CRT shader: add AllowVideoStreamCopy=false to force re-encode
Some checks failed
Push & Release 🌍 / GitHub CodeQL 🔬 (push) Has been cancelled
Push & Release 🌍 / Deploy 🚀 (push) Has been cancelled
Push & Release 🌍 / Automation 🎛️ (push) Has been cancelled
Push & Release 🌍 / Unstable release 🚀⚠️ (push) Has been cancelled
Push & Release 🌍 / Quality checks 👌🧪 (push) Has been cancelled
Without this the server chooses codec:copy (container remux MKV→HLS)
which runs no filter chain at all. The CRT shader requires actual
video decoding and re-encoding through program_opencl.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 02:51:45 +01:00
mani
f6fce9eae8 Fix CRT shader: force transcoding when enabling filter
When setCrtShader(true) was called the stream restart passed an empty
params object to changeStream(), which left EnableDirectPlay/EnableDirectStream
undefined. Due to JavaScript loose equality (undefined != null === false)
these were never sent to the server, so the server could return a Direct Play
MediaSource and our streamOptions[crtShader] append on TranscodingUrl was
never reached — resulting in no visual effect.

Fix: pass EnableDirectPlay:false, EnableDirectStream:false when enabling CRT
to force the server to transcode. When disabling, empty params restore the
default negotiation so the server can resume direct play if eligible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:19:31 +01:00
mani
0b4b125519 Add CRT shader toggle to player settings menu
Some checks failed
Push & Release 🌍 / Automation 🎛️ (push) Has been cancelled
Push & Release 🌍 / Unstable release 🚀⚠️ (push) Has been cancelled
Push & Release 🌍 / Quality checks 👌🧪 (push) Has been cancelled
Push & Release 🌍 / GitHub CodeQL 🔬 (push) Has been cancelled
Push & Release 🌍 / Deploy 🚀 (push) Has been cancelled
Adds a per-session CRT-Lottes shader toggle in the video player settings
menu (shown when transcoding is available). Toggling restarts the stream
with streamOptions[crtShader]=true appended to the TranscodingUrl, which
the server picks up to apply the OpenCL CRT post-processing filter.

- playersettingsmenu: showCrtMenu() (On/Off actionsheet), menu entry with
  current state as aside text, handleSelectedOption case
- playbackmanager: isCrtShaderEnabled(), setCrtShader(enabled, shadowMask)
  methods; createStreamInfo appends CRT stream options to TranscodingUrl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 13:53:56 +01:00
mani
c66db4e18d Merge tag 'v10.11.6' into release-10.11.z
Some checks failed
Push & Release 🌍 / Automation 🎛️ (push) Has been cancelled
Push & Release 🌍 / Unstable release 🚀⚠️ (push) Has been cancelled
Push & Release 🌍 / Quality checks 👌🧪 (push) Has been cancelled
Push & Release 🌍 / GitHub CodeQL 🔬 (push) Has been cancelled
Push & Release 🌍 / Deploy 🚀 (push) Has been cancelled
2026-01-20 14:18:42 +01:00
Jellyfin Release Bot
ea2abad3e1 Bump version to 10.11.6 2026-01-18 20:03:02 -05:00
Bill Thornton
6d8c8c0566 Merge pull request #7500 from thornbill/bring-back-back
Restore back button in experimental layout for apps
2026-01-16 13:09:38 -05:00
Bill Thornton
a2855c785e Merge pull request #7499 from thornbill/default-font
Add default font family for fallback
2026-01-16 11:39:23 -05:00
Bill Thornton
bf31a733a7 Restore back button in experimental layout for apps 2026-01-16 11:32:45 -05:00
Bill Thornton
bf70fb80aa Merge pull request #7498 from theguymadmax/add-sort-to-boxsets
Add sort options to movie collections in the experimental layout
2026-01-16 10:46:40 -05:00
Bill Thornton
2acc6f360a Add default font family for fallback 2026-01-16 10:29:45 -05:00
theguymadmax
a36eb7b546 Add sort options to movie collections 2026-01-15 18:59:15 -05:00
Bill Thornton
fb6250d108 Merge pull request #7493 from thornbill/plugin-error
Improve error handling on plugin page
2026-01-15 13:38:44 -05:00
Bill Thornton
a82ae33aa3 Merge pull request #7496 from thornbill/no-server-id
Fix missing server id
2026-01-15 13:35:58 -05:00
Bill Thornton
32d916b420 Fix missing server id 2026-01-14 17:35:15 -05:00
Bill Thornton
014af0ebe9 Merge pull request #7470 from theguymadmax/add-alphapicker-to-sort-options
Enable AlphaPicker for non-random sorts in experimental layout
2026-01-14 16:57:34 -05:00
Bill Thornton
9b80917cd1 Merge pull request #7495 from thornbill/vegan-support
Add Vega OS detection
2026-01-14 16:52:29 -05:00
Bill Thornton
238c5bbf58 Add vega os name and icon for dashboard 2026-01-13 13:39:01 -05:00
Bill Thornton
264cdafaff Add vega os detection 2026-01-13 13:33:13 -05:00
Bill Thornton
1459a11320 Improve error handling on plugin page 2026-01-13 10:26:25 -05:00
mani
ad3223cb77 Fix Xbox UWP codec detection: disable AV1 and Opus
Some checks failed
Push & Release 🌍 / Automation 🎛️ (push) Has been cancelled
Push & Release 🌍 / Unstable release 🚀⚠️ (push) Has been cancelled
Push & Release 🌍 / Quality checks 👌🧪 (push) Has been cancelled
Push & Release 🌍 / GitHub CodeQL 🔬 (push) Has been cancelled
Push & Release 🌍 / Deploy 🚀 (push) Has been cancelled
2026-01-09 00:50:06 +01:00
Dante Tyler
445fe22f29 Fix quality secondary text incoherency
Some checks failed
Push & Release 🌍 / Automation 🎛️ (push) Has been cancelled
Push & Release 🌍 / Unstable release 🚀⚠️ (push) Has been cancelled
Push & Release 🌍 / Quality checks 👌🧪 (push) Has been cancelled
Push & Release 🌍 / GitHub CodeQL 🔬 (push) Has been cancelled
Push & Release 🌍 / Deploy 🚀 (push) Has been cancelled
Parse correct context to the quality options object. This correctly returns the selected option name at index 0
2026-01-08 23:37:06 +01:00
theguymadmax
e28d70d34c Enable alphapicker in more sort by options 2026-01-02 23:02:49 -05:00
Bill Thornton
9a207e9ba9 Merge pull request #7135 from HaloCelsius/fix/inconsistent-quality-text
Fix incorrect quality text label in video player
2025-12-15 17:42:57 -05:00
Dante Tyler
5db40d03ac Fix quality secondary text incoherency
Parse correct context to the quality options object. This correctly returns the selected option name at index 0
2025-12-15 17:37:21 -05:00
Jellyfin Release Bot
ae58599bd0 Bump version to 10.11.5 2025-12-14 21:44:17 -05:00
Bill Thornton
e2ae48d8e5 Merge pull request #7415 from dmitrylyzo/fix-scroll-options-behavior
Fix the return value for 'behavior' property
2025-12-12 13:30:48 -05:00
Bill Thornton
bc39ee10ba Merge pull request #7409 from shempignon/master
Fix subtitles need to be uploaded twice
2025-12-12 13:29:39 -05:00
Shempignon
603b5ed20c Issue #7194: Subtitles need to be uploaded twice 2025-12-10 18:51:54 +01:00
Bill Thornton
6bfff061ce Fix browser detection overwrites (#7411)
* Fix browser detection overwrites

* Fix Opera TV detection

* Move tv flag logic

* Fix indentation
2025-12-10 09:54:08 -05:00
Dmitry Lyzo
44818f0c97 Fix the return value for 'behavior' property
The provided value 'null' is not a valid enum value of type ScrollBehavior.
2025-12-10 12:27:38 +03:00
Bill Thornton
b3725e9dd5 Merge pull request #7392 from thornbill/backport-6376 2025-12-05 10:13:29 -05:00
imtsuki
ce22f8fe22 Add hevc level 6.2 check
Signed-off-by: imtsuki <me@qjx.app>
2025-12-05 10:01:10 -05:00
Bill Thornton
9f1370f242 Merge pull request #7387 from thornbill/fix-exp-card-actions
Fix card actions in experimental layout
2025-12-03 16:20:25 -05:00
Bill Thornton
b3913d7bb3 Fix card actions in experimental layout 2025-12-03 12:18:21 -05:00
Bill Thornton
69d169e45f Merge pull request #7380 from theguymadmax/add-help-to-backup
Add help link to backups page
2025-12-02 16:01:43 -05:00
theguymadmax
264eedc90a Add help link to backups page 2025-12-02 11:19:29 -05:00
Bill Thornton
6fba30a0a9 Merge pull request #7379 from Collin-Swish/fix-duplicate-years 2025-12-01 23:24:32 -05:00
Collin Swisher
3376a126de Fix duplicate years due to type mismatch 2025-12-01 21:53:47 -06:00
Roberto Romero
4e9c2e71a9 Bind PlayerChange before handlers to report correct isLocalPlayer (#7376)
PlayerChange was firing before the subscriber rebound its player, so the
first media session update could send `isLocalPlayer: false` (player undefined)
and Android treated playback as remote (cast volume UI). Rewire PlaybackSubscriber
so PlayerChange binds the current player before invoking handlers, ensuring media
session updates always have a bound player and report the correct local/remote
state.

Fixes: https://github.com/jellyfin/jellyfin-android/issues/1745
Fixes: https://github.com/jellyfin/jellyfin-android/issues/1854
2025-12-01 21:23:24 -05:00
Bill Thornton
06f5442fc9 Merge pull request #7378 from thornbill/fix-rtl-details 2025-12-01 15:24:47 -05:00
Bill Thornton
c478d6e307 Fix card and logo position for rtl languages 2025-12-01 12:44:46 -05:00
Jellyfin Release Bot
cacb660ff8 Bump version to 10.11.4 2025-11-30 21:33:35 -05:00
Bill Thornton
4bdc0fd974 Merge pull request #7344 from dkanada/book-player-layout 2025-11-27 02:22:31 -05:00
dkanada
9af155b291 keep existing dialog style on desktop layout 2025-11-27 16:00:39 +09:00
dkanada
74f98bb120 use book player theme in TOC element 2025-11-27 16:00:19 +09:00
dkanada
e568ecbf30 modify TOC and button layout in book player 2025-11-27 15:52:01 +09:00
Bill Thornton
1686788be5 Merge pull request #7345 from dkanada/fix-continue-reading
fix book playback in continue reading home section
2025-11-25 10:59:13 -05:00
dkanada
43749273e4 fix book playback in continue reading home section 2025-11-24 14:32:14 +09:00
Bill Thornton
b807ebfa4a Merge pull request #7331 from theguymadmax/fix-trickplays-on-playlists
Fix trickplay not displaying when content is played from a playlist
2025-11-17 12:11:50 -05:00
Jellyfin Release Bot
8cc49df625 Bump version to 10.11.3 2025-11-16 17:40:09 -05:00
Bill Thornton
f2d2c5b26e Merge pull request #7335 from gnattu/blacklist-firefox-mkv-support 2025-11-15 13:10:38 -05:00
gnattu
5c444198ea Blacklist Firefox native mkv playback 2025-11-16 01:42:01 +08:00
theguymadmax
dee5a1bcea Fix trickplay not displaying when content is played from a playlist 2025-11-14 01:36:10 -05:00
Bill Thornton
3d55ce3724 Merge pull request #7313 from theguymadmax/fix-livetv-programs-section
Fix missing Live TV sections in experimental layout
2025-11-12 21:24:25 -05:00
Bill Thornton
3c6a5160a6 Merge pull request #7325 from viown/disable-scan-when-scanning 2025-11-12 12:18:18 -05:00
viown
01200f3d70 Disable scan button when scanning 2025-11-12 18:32:39 +03:00
Bill Thornton
39f971ffa4 Merge pull request #7283 from kinke/tizen_max_streams
Restrict recently added max-32-streams limit to Tizen < v6.5
2025-11-10 16:37:28 -05:00
Bill Thornton
e6141968d7 Merge pull request #7312 from viown/enhance-log-viewer
Enhance log viewer
2025-11-10 16:34:18 -05:00
Bill Thornton
f445e53f7e Merge pull request #7298 from gnattu/relax-webox-range-profile 2025-11-10 05:11:22 -05:00
Bill Thornton
d1379dce8a Merge pull request #7295 from thornbill/fix-display-form 2025-11-09 23:29:29 -05:00
Bill Thornton
03c2cebbd3 Merge pull request #7296 from thornbill/revert-hlsjs-webos 2025-11-09 23:29:11 -05:00
theguymadmax
ab0042d46f Fix Live TV program sections in experimental layout 2025-11-08 11:43:19 -05:00
viown
3c388fef92 Enhance log viewer 2025-11-08 08:15:59 +03:00
gnattu
9c76311936 Also add av1 ranges to relaxed profile 2025-11-07 10:35:05 +08:00
Bill Thornton
f077e294a9 Merge pull request #7301 from thornbill/fix-series-play
Fix first episode when playing a series
2025-11-06 11:26:31 -05:00
Bill Thornton
1c8f221006 Merge pull request #7306 from viown/fix-tuner-device-card-size
Add line break to card's secondary text
2025-11-06 09:24:26 -05:00
viown
a1d8bec051 Add line break to card's secondary text 2025-11-06 06:08:00 +03:00
Martin Kinkelin
000f89b99e Tizen: Restrict recently added max-32-streams limit to Tizen < v6.5
v10.11 introduced that limit for Tizen in general, causing all formerly
direct-playable media with more than 32 streams to be remuxed - one
cause of regression #7231.

According to https://github.com/jellyfin/jellyfin-tizen/issues/289#issuecomment-2581270727,
Tizen v5 only supports max 32 streams. This limit seems to have been
lifted in v6.5, according to https://github.com/jellyfin/jellyfin-web/issues/7231#issuecomment-3474685133.
I'm using Tizen v9 and don't have any problems with around 100 streams.
2025-11-06 00:34:18 +01:00
Bill Thornton
83317879a8 Fix first episode when playing a series 2025-11-05 14:10:12 -05:00
gnattu
7c0807680d Fix comment
Co-authored-by: Tim Eisele <Tim_Eisele@web.de>
2025-11-05 19:26:57 +08:00
gnattu
053ce59352 Relax dynamic HDR device Profile
HDR10+ are designed to be backward-compatible with HDR10 which means we can just advertise HDR10+ as supported for HDR10 supported clients. This will avoid the server to force a remux to remove the HDR10+ metadata to ensure compatibility. At least the server-side removal is not needed for major web browsers and the tvs that uses the web client.

For TVs, we need to relax the device profile even more by exposing not-supported but can be played ranges. This is due to the HLS support on TVs are often lacking features and the remux on server side would end up removing more than just the dynamic range. I don't personally own any of the TVs, so I'm not sure if this relaxed profile would break anything though.
2025-11-05 18:45:51 +08:00
Bill Thornton
b3833e7479 Revert "Enable use of hls.js when LG WebOS 4 or newer is used."
This reverts commit 85fc9de45f.
2025-11-04 11:22:44 -05:00
Bill Thornton
21d7dd86ea Merge pull request #7290 from theguymadmax/library-refresh-fix
Fix library not refreshing after adding new media library
2025-11-04 11:09:41 -05:00
Bill Thornton
e2e679f0be Fix default values in display settings 2025-11-04 10:46:34 -05:00
theguymadmax
993d370582 Fix library not refreshing after adding new media library 2025-11-02 22:57:34 -05:00
Jellyfin Release Bot
933e1b255b Bump version to 10.11.2 2025-11-02 21:28:58 -05:00
Bill Thornton
2c45c5ba4a Merge pull request #7274 from nyanmisaka/disable-native-hls-on-chromium 2025-10-29 18:38:14 -04:00
Bill Thornton
cdde002ca6 Merge pull request #7271 from viown/fix-tuner-device-list 2025-10-29 18:36:43 -04:00
nyanmisaka
19cb2e9977 Using HLS.js instead of native HLS in Chromium
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.

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2025-10-29 18:52:37 +08:00
viown
fb7a1538d0 Fix tuner devices list 2025-10-29 08:22:49 +03:00
Bill Thornton
7491722364 Merge pull request #7272 from viown/use-legacy-grid-for-widgets-count 2025-10-29 00:30:57 -04:00
viown
d6c169321e Use legacy grid for item counts widget 2025-10-28 18:43:53 +03:00
Bill Thornton
6e2c62525a Merge pull request #7269 from viown/fix-log-viewer-client-logs 2025-10-28 08:01:51 -04:00
viown
09dc3ae3a8 Fix JSON in log viewer 2025-10-28 12:38:45 +03:00
Bill Thornton
e102334812 Merge pull request #7258 from nielsvanvelzen/titanos 2025-10-28 01:47:58 -04:00
Bill Thornton
907947c523 Merge pull request #7259 from nielsvanvelzen/no-native-dialog 2025-10-28 01:44:57 -04:00
Niels van Velzen
f3d7994b2a Replace indexOf usages with includes 2025-10-27 19:24:35 +01:00
Jellyfin Release Bot
b9fdc61b6d Bump version to 10.11.1 2025-10-26 22:02:12 -04:00
Niels van Velzen
37dcc07da5 Avoid native browser prompts 2025-10-26 15:14:49 +01:00
Niels van Velzen
e4e2c97bd5 Avoid native browser confirms 2025-10-26 15:14:40 +01:00
Niels van Velzen
6ce3e579c2 Update browser declaration 2025-10-26 15:09:37 +01:00
Niels van Velzen
dbcac4c6f4 Avoid native browser alerts 2025-10-26 15:02:41 +01:00
Niels van Velzen
c11d630e42 Add Titan OS detection 2025-10-26 14:54:39 +01:00
Bill Thornton
7643885c6b Merge pull request #7252 from bernarden/try-catch-on-all-actions
Wraps registration of all mediaSession action handlers in try catch.
2025-10-24 10:52:15 -04:00
Bernarden
92a1aa16dc Wraps registration of all mediaSession action handlers in try catch. 2025-10-24 18:29:24 +13:00
Bill Thornton
4560d7c90f Merge pull request #7250 from thornbill/sdk-0.12.0 2025-10-23 09:16:22 -04:00
Bill Thornton
e97d658b3c Update SDK to 0.12.0 stable 2025-10-23 08:14:52 -04:00
Bill Thornton
7c0c2e088f Merge pull request #7248 from thornbill/card-album-artists
Fix multiple album artists in card footer
2025-10-23 08:10:03 -04:00
Peaches_MLG
0989a3034f Fixed issue where waiting event is not being called correctly (#7245) 2025-10-22 21:10:26 -04:00
Peaches_MLG
17a1e2e94c Fix unpause and pause references in syncplay video player (#7227) 2025-10-22 21:06:13 -04:00
Bill Thornton
b5382f0142 Fix card links for multiple album artists in experimental layout 2025-10-22 14:59:40 -04:00
Bill Thornton
12079b9462 Merge pull request #7221 from SohamGanmote/fix/syncplay-speedto-sync-min
Fix: Add minimum value 0 for SyncPlay Settings SpeedToSync input
2025-10-22 13:07:14 -04:00
SohamGanmote
6a55ee3d71 Fix: Add proper validation for SyncPlay Settings inputs 2025-10-22 21:59:12 +05:30
Bill Thornton
6ee77f18bc Fix card links for multiple album artists 2025-10-22 11:00:54 -04:00
Bill Thornton
db7498ed03 Revert "Show all album artists on cards (#6929)"
This reverts commit 4f9a105921.
2025-10-22 10:51:33 -04:00
Bill Thornton
4f83e97592 Merge pull request #7233 from viown/increase-backup-interval
Increase restore check interval to 45s
2025-10-22 10:18:37 -04:00
Bill Thornton
4b072633fb Merge pull request #7241 from thornbill/tv-overflow
Revert scroller overflow change for tv layout
2025-10-22 10:15:35 -04:00
Bill Thornton
0772f146b4 Revert scroller overflow change for tv layout 2025-10-22 10:01:41 -04:00
Bill Thornton
0bb8f7cb47 Merge pull request #7224 from theguymadmax/fix-background-rotation
Enable backdrop image rotation in Firefox
2025-10-22 09:44:14 -04:00
Bill Thornton
f7583a842b Merge pull request #7240 from thornbill/try-stop-action
Handle browsers lacking stop media session action support
2025-10-22 09:29:45 -04:00
theguymadmax
45bca06b2c Enable backdrop image rotation in Firefox 2025-10-22 09:24:03 -04:00
Bill Thornton
c688faacb8 Handle browsers lacking stop media session action support 2025-10-22 09:20:17 -04:00
viown
737b85b0b6 Increase restore check interval to 45s 2025-10-21 15:47:29 +03:00
Bill Thornton
81698d5da7 Merge pull request #7219 from thornbill/forward-port-6583
Fix skip button not displaying correctly with OSD (#6583)
2025-10-20 10:53:15 -04:00
rlauuzo
64fbd6d3de Fix skip button not displaying correctly with OSD (#6583) 2025-10-20 10:13:08 -04:00
Jellyfin Release Bot
fa7831bd1f Bump version to 10.11.0 2025-10-19 20:45:15 -04:00
queeup
c09237f4ce Translated using Weblate (Turkish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/tr/
2025-10-19 21:29:50 +00:00
Kityn
ad342a0b1e Translated using Weblate (Polish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pl/
2025-10-19 17:51:26 +00:00
Helak
f1a77af8d3 Translated using Weblate (Czech)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/cs/
2025-10-19 17:51:26 +00:00
Bas
c68dd09ebe Translated using Weblate (Dutch)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/nl/
2025-10-19 15:13:19 +00:00
Bill Thornton
daee19c4ac Merge pull request #7082 from viown/react-livetv 2025-10-19 10:37:52 -04:00
Bill Thornton
edb196c6b0 Merge pull request #7202 from viown/fix-clipped-delete-button 2025-10-19 10:34:24 -04:00
rimasx
d0eabd3116 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-19 12:51:26 +00:00
Fjuro
1189b6b84b Translated using Weblate (Czech)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/cs/
2025-10-19 09:38:51 +00:00
Bill Thornton
e31e646b7b Merge pull request #7121 from jellyfin/renovate/hls.js-1.x 2025-10-18 16:44:58 -04:00
renovate[bot]
9b837ff89e Update dependency hls.js to v1.6.13 2025-10-18 20:37:23 +00:00
Bill Thornton
5b0c88bd6b Merge pull request #7210 from thornbill/set-npm-version 2025-10-18 16:18:35 -04:00
Bill Thornton
921d13517f Set maximum npm version 2025-10-18 16:06:46 -04:00
myrad2267
22f0706789 Translated using Weblate (French)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/fr/
2025-10-18 17:51:26 +00:00
myrad2267
b2951f0282 Translated using Weblate (French (Canada))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/fr_CA/
2025-10-18 17:51:26 +00:00
viown
dfba17fdbc Fix clipped delete button in devices page 2025-10-18 15:51:52 +03:00
viown
39777707b0 Use loading state for refresh guide 2025-10-18 15:39:52 +03:00
rimasx
7606dfaf4b Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-18 09:51:26 +00:00
皇甫朝云
dae70c60e4 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-18 09:51:26 +00:00
JohnCaveson
4f9a105921 Show all album artists on cards (#6929)
* Remove indexing and accept a full array of albumartists into the function

* removing console.debug

* Use Array.isArray for array type check

* Fix missing paren

---------

Co-authored-by: greergoodman6@gmail.com <ggoodman@DESKTOP-R652T9J.localdomain>
Co-authored-by: Bill Thornton <thornbill@users.noreply.github.com>
2025-10-18 02:56:42 -04:00
Kityn
1dc435986c Translated using Weblate (Polish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pl/
2025-10-18 06:51:26 +00:00
John Garland
fbbf879006 Fix ends at not always accounting for playback position (#6965)
* fix: "Ends At" not always accounting for playback position

fixes #6964

* Update contributors

* Remove redundant `?? 0`

* Remove redundant assignments
2025-10-18 02:47:52 -04:00
Bas
6dab926437 Translated using Weblate (Dutch)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/nl/
2025-10-18 00:45:20 +00:00
Renato Uštar
b183690db6 Translated using Weblate (Croatian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/hr/
2025-10-18 00:45:20 +00:00
Renato Uštar
c2d94327d0 Translated using Weblate (Croatian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/hr/
2025-10-17 22:55:33 +00:00
Bill Thornton
bf32030b23 Merge pull request #7200 from thornbill/page-size-warning 2025-10-17 18:55:29 -04:00
Renato Uštar
91f210f378 Translated using Weblate (Croatian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/hr/
2025-10-17 22:24:09 +00:00
Bill Thornton
7a9b8fe7ae Add warning for page size setting 2025-10-17 17:12:38 -04:00
viown
e58063f457 Add actions context menu 2025-10-18 00:04:53 +03:00
viown
62f61fa167 Add provider list 2025-10-18 00:04:53 +03:00
viown
96024d3025 Migrate Live TV page to React 2025-10-18 00:04:52 +03:00
viown
7281ce480d Migrate libraries page to React (#7078)
* Migrate libraries page to React

* Fix aspect ratio for library card images

* Fix loading

* Use image url utility from TS SDK

* Add width prop to BaseCard

* Apply review feedback
2025-10-17 16:52:29 -04:00
Bill Thornton
3bcaf84ecb Merge pull request #7177 from thornbill/scroller-overflow
Show scroller content that overflows
2025-10-17 16:43:17 -04:00
Bill Thornton
4fa5176982 Use float for item details poster (#7195)
* Use float for item details poster

* Add list view children to primary content

* Move additional sections to primary container

* Add series to list view children

* Fix order of primary content sections
2025-10-17 16:42:40 -04:00
JA
1ed047df3d Prevent navigation during horizontal scroll (#6915)
* Prevent unwanted navigation history changes during horizontal scroll on trackpads by evaluating scroll start/end.

Prevent unwanted navigation history changes during horizontal scroll on trackpads by evaluating scroll start/end.

* Fix code reviews and change event listener opts in all implementacion of scroll navegation wheelEvent, scrollHandler

Fix code reviews and change event listener opts in all implementacion of scroll navegation wheelEvent, scrollHandler

* remove line space

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-17 16:41:42 -04:00
Bas
d48e2c4cd7 Translated using Weblate (Dutch)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/nl/
2025-10-17 14:51:26 +00:00
Roi Gabay
7dc276ab51 Translated using Weblate (Hebrew)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/he/
2025-10-17 14:51:26 +00:00
rimasx
a95599b60f Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-17 11:51:26 +00:00
rimasx
dfd461cf4c Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-17 06:43:48 +00:00
Bill Thornton
3215be4cd8 Merge pull request #7196 from thornbill/role-xss
Fix xss for person roles

Credit for discovery to Carlos García-Olalla Olivera
2025-10-16 09:36:03 -04:00
Romulo Alves
e91a7556cf Translated using Weblate (Portuguese (Brazil))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt_BR/
2025-10-16 09:51:26 +00:00
Bill Thornton
e6d57d8e89 Merge pull request #7197 from thornbill/require-person-type 2025-10-15 22:06:27 -04:00
Bill Thornton
2c2311415f Remove blank person type option 2025-10-15 16:43:49 -04:00
Bill Thornton
16fd2a01aa Fix xss for person roles 2025-10-15 14:31:58 -04:00
Tiago Filipe
0682ca3b99 Translated using Weblate (Portuguese)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt/
2025-10-15 01:51:25 +00:00
Ziga Zajc
d39c58675d Translated using Weblate (Slovenian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sl/
2025-10-14 15:51:25 +00:00
rimasx
5292162fdd Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-14 11:51:25 +00:00
Bill Thornton
f44b642514 Merge pull request #7183 from thornbill/theme-videos
Improve theme video support
2025-10-13 16:25:02 -04:00
Bill Thornton
d37b6304fa Merge pull request #7190 from theguymadmax/fix-more-from
Fix "More From" section to show all albums by album artist
2025-10-13 15:36:20 -04:00
theguymadmax
40fb2ddc93 Apply suggestion from @thornbill
Co-authored-by: Bill Thornton <thornbill@users.noreply.github.com>
2025-10-13 15:13:35 -04:00
theguymadmax
184cc7e9d1 Fix "More From" section to show all albums by album artist 2025-10-13 11:42:29 -04:00
kreaxv
7d20728ae3 Translated using Weblate (Mongolian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/mn/
2025-10-13 11:46:00 +00:00
GolanGitHub
ac94190e0c Translated using Weblate (Spanish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/es/
2025-10-13 11:46:00 +00:00
kreaxv
cdf9613e08 Translated using Weblate (Mongolian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/mn/
2025-10-13 08:51:25 +00:00
rimasx
d188880e7e Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-13 08:51:25 +00:00
kreaxv
0f6fcd8daf Translated using Weblate (Mongolian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/mn/
2025-10-13 06:51:33 +00:00
spicy-weasel
0e3384e7a4 Translated using Weblate (Tamil)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ta/
2025-10-13 06:51:26 +00:00
faquino
b9769d9547 Translated using Weblate (Galician)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/gl/
2025-10-13 06:51:26 +00:00
faquino
24860e373a Translated using Weblate (Spanish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/es/
2025-10-13 06:51:26 +00:00
MFride1
b89a90ebf5 Translated using Weblate (Hebrew)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/he/
2025-10-12 23:00:45 +00:00
Gargotaire
a98f740ad8 Translated using Weblate (Catalan)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ca/
2025-10-12 16:51:25 +00:00
rimasx
c6c951a377 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-12 11:51:25 +00:00
rimasx
a8af5c31cd Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-12 08:51:25 +00:00
rimasx
b847506c1b Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-12 05:51:25 +00:00
rimasx
776755a81c Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-11 10:51:25 +00:00
rimasx
f936c9366f Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-11 08:51:25 +00:00
rimasx
9b7d921845 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-11 06:55:15 +00:00
rimasx
b08df1ed80 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-11 05:51:25 +00:00
Bill Thornton
9ebe4b7f57 Merge pull request #7175 from thornbill/app-bar-visibility 2025-10-10 17:57:11 -04:00
rimasx
9f6964fb51 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-10 20:51:25 +00:00
nextlooper42
3787889b41 Translated using Weblate (Slovak)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sk/
2025-10-10 16:51:25 +00:00
rimasx
91da2edae5 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-10 12:14:26 +00:00
Muhannad Alnemer
d5e54157ed Translated using Weblate (Arabic)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ar/
2025-10-10 10:51:25 +00:00
Ärik
ed8dbf1bd9 Translated using Weblate (Swedish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sv/
2025-10-10 06:51:25 +00:00
Bill Thornton
a19ccf5439 Merge pull request #7105 from viown/use-mui-dialogs-for-api-keys
Use mui dialogs for api keys page
2025-10-10 01:42:53 -04:00
theguymadmax
ae99ac8b03 Add Play All and Shuffle buttons to Music Videos Libraries (#6866)
* Add play all & shuffle to Music Videos

* Update comments

---------

Co-authored-by: Max <no@example.com>
2025-10-10 01:10:04 -04:00
皇甫朝云
e83279b69f 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-10 02:51:25 +00:00
Jagadam Dinesh Reddy
5bf0b0314c Merge pull request #7127 from jagadam97/enable-screenSaver-in-player
Enable screen saver in player if the video is paused
2025-10-09 16:47:14 -04:00
Bas
5d1a19a65d Translated using Weblate (Dutch)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/nl/
2025-10-09 19:51:25 +00:00
Bill Thornton
ce24556dad Merge pull request #7178 from thornbill/movie-collections-alpha
Enable alphapicker in movie collections tab
2025-10-09 15:38:03 -04:00
Bill Thornton
2b92a87006 Merge pull request #7028 from theguymadmax/add-play-button-movies
Add Play All button to movies
2025-10-09 15:33:46 -04:00
Bill Thornton
2a58eb8194 Merge pull request #7181 from theguymadmax/movie-tab-col-alpha
Enable alphapicker in movie collections tab - standard layout
2025-10-09 14:56:40 -04:00
Bill Thornton
2b96e9d6c7 Fix loading indicator showing for theme videos 2025-10-09 14:02:26 -04:00
Bill Thornton
fd4c897642 Use cover aspect ratio for theme videos 2025-10-09 13:49:57 -04:00
faquino
76fbfbbe84 Translated using Weblate (Galician)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/gl/
2025-10-09 12:39:37 +00:00
rimasx
d26cc473a9 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-09 11:51:25 +00:00
faquino
715b026b0f Translated using Weblate (Galician)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/gl/
2025-10-09 11:51:25 +00:00
rimasx
a3baf9a257 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-09 07:51:25 +00:00
theguymadmax
fbd480cd55 Enable alphapicker in movie collections tab (standard layout) 2025-10-08 20:21:43 -05:00
oddib
1435ea1560 Translated using Weblate (Norwegian Bokmål)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/nb_NO/
2025-10-08 19:31:50 +00:00
Bill Thornton
4523b9f790 Enable alphapicker in movie collections tab 2025-10-08 12:52:51 -04:00
Bill Thornton
b691f62fc7 Show scroller content that overflows 2025-10-08 12:39:37 -04:00
Bill Thornton
1d07721de8 Merge pull request #7174 from YouKnowBlom/fix-resetpassword-dialog
Prevent dialog backdrop from displaying above dialog content
2025-10-08 10:27:26 -04:00
jeremydobber
5a1ca91bab Translated using Weblate (Hungarian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/hu/
2025-10-08 14:22:48 +00:00
jeremydobber
70530a562c Translated using Weblate (Spanish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/es/
2025-10-08 14:22:48 +00:00
Bill Thornton
5b622a547d Fix app bar visibility on item details page 2025-10-08 10:06:22 -04:00
viown
e2e9a5523d Use mui dialogs for api keys page 2025-10-08 17:00:54 +03:00
Azurite
41e5b7b6bc Translated using Weblate (Japanese)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ja/
2025-10-08 05:51:25 +00:00
Milo Ivir
1194cff68d Translated using Weblate (Croatian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/hr/
2025-10-07 19:45:36 +00:00
Milo Ivir
35507a8303 Translated using Weblate (Croatian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/hr/
2025-10-07 18:51:25 +00:00
rimasx
4d59c20550 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-07 11:51:25 +00:00
Andreas B
f974a39938 FIX: prevent dialog backdrop from appearing above dialog content 2025-10-07 11:45:06 +02:00
rimasx
e9e56af092 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-07 05:37:03 +00:00
rimasx
3efd339d91 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-06 20:51:25 +00:00
Adrián HM
403d116338 Translated using Weblate (Spanish (Mexico))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/es_MX/
2025-10-06 18:32:53 +00:00
Nicolas N
0a9db2bda9 Translated using Weblate (Haitian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ht/
2025-10-06 14:51:25 +00:00
rimasx
2ee0caab6a Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-06 14:51:25 +00:00
rimasx
39ab3a52d8 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-06 12:51:25 +00:00
Thadah D. Denyse
49988dbd35 Translated using Weblate (Basque)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/eu/
2025-10-06 10:40:50 +00:00
rimasx
39278b1e4e Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-06 10:40:50 +00:00
Aindriú Mac Giolla Eoin
d061871955 Translated using Weblate (Irish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ga/
2025-10-06 08:38:23 +00:00
Francesco Lo Faro
ec73f0e0fc Translated using Weblate (Italian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/it/
2025-10-06 07:51:25 +00:00
Khalsior
58c43e72c0 Translated using Weblate (Italian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/it/
2025-10-06 07:51:25 +00:00
rimasx
3d75ba4a7e Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-06 06:40:24 +00:00
nenadsuperzmaj
ec80e82625 Translated using Weblate (Serbian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sr/
2025-10-06 06:40:24 +00:00
Gallyam Biktashev
f09ada7f87 Translated using Weblate (Russian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ru/
2025-10-06 06:40:24 +00:00
Francesco Lo Faro
b6be3c3866 Translated using Weblate (Italian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/it/
2025-10-06 06:40:23 +00:00
Blackspirits
527c25388e Translated using Weblate (Portuguese)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt/
2025-10-05 17:51:25 +00:00
Blackspirits
2d041661ce Translated using Weblate (Portuguese (Portugal))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt_PT/
2025-10-05 17:51:25 +00:00
khanthaphot
68d69351ea Translated using Weblate (Thai)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/th/
2025-10-05 12:22:45 +00:00
rimasx
72e20c95ae Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/et/
2025-10-05 11:21:53 +00:00
khanthaphot
8dab9a6f12 Translated using Weblate (Thai)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/th/
2025-10-05 11:21:53 +00:00
AfmanS
fc3ac97e75 Translated using Weblate (Portuguese (Portugal))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt_PT/
2025-10-05 07:51:25 +00:00
MrPlow
73b23092ed Translated using Weblate (German)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/de/
2025-10-05 07:51:25 +00:00
Pavel Miniutka
3cf3a345db Translated using Weblate (Belarusian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/be/
2025-10-05 07:51:25 +00:00
Pavel Miniutka
72392ec2ed Translated using Weblate (Belarusian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/be/
2025-10-04 22:51:24 +00:00
Bill Thornton
e602b50e5b Merge pull request #7150 from thornbill/download-music-album 2025-10-04 16:14:06 -04:00
Bill Thornton
eaa0ca4b79 Merge pull request #7170 from thornbill/fix-css-theming 2025-10-04 14:08:09 -04:00
nenadsuperzmaj
c8ca4f3bb4 Translated using Weblate (Serbian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sr/
2025-10-04 15:51:25 +00:00
felix920506
36fa0fb9be 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-04 15:51:25 +00:00
hoanghuy309
3e30c04941 Translated using Weblate (Vietnamese)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/vi/
2025-10-04 09:51:25 +00:00
Bill Thornton
874a3cc727 Merge pull request #7128 from viown/redirect-to-devices-on-activity-click 2025-10-03 22:30:28 -04:00
S1NJED
8a0176eba2 Fix pdf extension check (#7124)
* fix: lowercase item path for pdf extension

Signed-off-by: sinjed <oooguuh@gmail.com>

* lint: single quote

Signed-off-by: sinjed <oooguuh@gmail.com>

---------

Signed-off-by: sinjed <oooguuh@gmail.com>
2025-10-03 16:33:55 -04:00
Bill Thornton
f20aaa3195 Merge pull request #7167 from nyanmisaka/fix-tonemap-box-in-sw
Fix the tonemap box not hidden in software transcoding
2025-10-03 14:29:54 -04:00
Bill Thornton
abce5b1bea Move event types to constants 2025-10-03 14:12:05 -04:00
Bill Thornton
ad00b16069 Fix text in activity list overflowing (#7112) 2025-10-03 12:18:08 -04:00
renovate[bot]
cc16d73fac Update dependency @jellyfin/sdk to v0.0.0-unstable.202510030502 (#7153)
* Update dependency @jellyfin/sdk to v0.0.0-unstable.202510030502

* Fix lockfile

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Bill Thornton <thornbill@users.noreply.github.com>
2025-10-03 12:15:59 -04:00
Klomer
9c4bb658f6 Translated using Weblate (Breton)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/br/
2025-10-03 14:51:24 +00:00
Bill Thornton
77c2366dbe Apply unsaved changes 2025-10-03 10:34:15 -04:00
Bill Thornton
a32b2613ac Fix material react table theming 2025-10-03 09:46:25 -04:00
Klomer
429170bb65 Added translation using Weblate (Breton) 2025-10-03 13:21:01 +00:00
nyanmisaka
b1e083f9c7 Drop redundant AllowTonemappingSoftwareHelp string
Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2025-10-03 00:23:07 +08:00
Bill Thornton
c93c25481d Fix components using default theme 2025-10-02 11:56:41 -04:00
nyanmisaka
f10573ff46 Fix the tonemap box not hidden in software transcoding
a3872ff is not included in b3de4af

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2025-10-02 17:30:06 +08:00
Arham
c3d1f78e15 Translated using Weblate (Urdu (Pakistan))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ur_PK/
2025-10-02 08:51:24 +00:00
Arham
3667493bc2 Translated using Weblate (Urdu (Pakistan))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ur_PK/
2025-10-02 06:51:25 +00:00
無情天
5660931dd1 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-02 01:14:39 +00:00
myrad2267
ab62a00574 Translated using Weblate (French)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/fr/
2025-10-01 11:50:02 +00:00
myrad2267
af6b205781 Translated using Weblate (French (Canada))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/fr_CA/
2025-10-01 11:50:01 +00:00
Kityn
a55eea3e62 Translated using Weblate (Polish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pl/
2025-10-01 06:11:06 +00:00
Bas
9b2f036296 Translated using Weblate (Dutch)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/nl/
2025-09-30 19:51:24 +00:00
Helak
26c065c52d Translated using Weblate (Czech)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/cs/
2025-09-30 17:51:26 +00:00
Bas
46a683a56b Translated using Weblate (Dutch)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/nl/
2025-09-30 17:51:26 +00:00
queeup
1586880776 Translated using Weblate (Turkish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/tr/
2025-09-30 17:51:26 +00:00
Bill Thornton
3a747addbf Merge pull request #7088 from viown/replace-toast-with-snackbar-on-dashboard
Replace toast with snackbar on dashboard
2025-09-30 13:13:03 -04:00
viown
1702604e32 Replace toast with snackbar on dashboard 2025-09-30 13:09:21 -04:00
Bas
ae56c9ee64 Translated using Weblate (Dutch)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/nl/
2025-09-30 16:48:43 +00:00
linkandzelda91
f87421bde8 Update LanNetworksHelp description for clarity (#7118)
* Update LanNetworksHelp description for clarity

previously, it indicated that 
"If left blank, only the server's subnet is considered to be on the local network."
I tested this and afaik it's wrong, leaving it blank sets all RFC1918 addresses to local.

* Update src/strings/en-us.json

Co-authored-by: viown <48097677+viown@users.noreply.github.com>

---------

Co-authored-by: viown <48097677+viown@users.noreply.github.com>
2025-09-30 12:43:36 -04:00
Emmanuel Eytan
5411a0a0e7 Changed the erroneous spelling of the OK button in localizations. (#7125)
* Changed the erroneous spelling of the OK button in localizations.

* Only keeping the en-us modification.

* Extra comma removed.
2025-09-30 12:40:02 -04:00
Bill Thornton
47889a5789 Only download supported items 2025-09-30 12:25:04 -04:00
Bill Thornton
6c03684db5 Add support for download all for collections 2025-09-30 12:08:47 -04:00
Bill Thornton
98c1dfa597 Add support for downloading all songs in albums 2025-09-30 12:08:47 -04:00
Bill Thornton
ff42b28520 Merge pull request #6862 from qm3jp/fix-person-playback
Fix play all & shuffle for Person view
2025-09-30 09:06:46 -04:00
Bill Thornton
a516de5fc7 Use enum values in getPlaybackPromise 2025-09-30 08:24:00 -04:00
Aubrey Benedetti
d5423d2d56 Add qm3jp to contributors list 2025-09-30 01:19:30 -04:00
Aubrey Benedetti
4b36146b34 Fix play all & shuffle not working on Person 2025-09-30 01:19:30 -04:00
Bill Thornton
37aa7b8b08 Merge pull request #7156 from thornbill/fav-seasons
Add favorite seasons
2025-09-30 01:18:46 -04:00
Bill Thornton
5346444689 Merge pull request #7155 from thornbill/edit-artist-album
Allow editing artists for music albums
2025-09-30 01:17:52 -04:00
Bill Thornton
473b8cb428 Add favorite seasons 2025-09-30 01:15:02 -04:00
Bill Thornton
929c8b3cc7 Allow editing artists for music albums 2025-09-30 01:14:06 -04:00
hoanghuy309
b39360bf61 Translated using Weblate (Vietnamese)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/vi/
2025-09-30 01:02:18 +00:00
Bill Thornton
13f3f61b39 Merge pull request #7157 from thornbill/fix-browser-type 2025-09-29 18:02:28 -04:00
Bill Thornton
e225dce119 Fix browser type definition 2025-09-29 17:00:52 -04:00
Nicolas N
a238b5ef8a Translated using Weblate (Haitian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ht/
2025-09-28 06:51:24 +00:00
Bill Thornton
713bb551cf Merge pull request #7145 from Shadowghost/set-parental-subrating 2025-09-27 17:59:28 -04:00
Bill Thornton
35082b8712 Merge pull request #7114 from dmitrylyzo/fix-xss-wizard-dashboard 2025-09-27 17:58:07 -04:00
Joshua M. Boniface
68eb5b9e36 Merge pull request #7141 from thornbill/subtitle-styling
Extract native/custom subtitle element logic to separate typescript file
2025-09-27 17:52:56 -04:00
daswesen123
966e69354a Translated using Weblate (English (Pirate))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/en@pirate/
2025-09-26 23:51:24 +00:00
Chris Stormrider
89c5119aed Translated using Weblate (Greek)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/el/
2025-09-26 09:58:49 +00:00
Shadowghost
3a95e751d0 Fixup 2025-09-24 01:17:58 +02:00
Bill Thornton
cc7799cf49 Use custom subtitle element in safari 2025-09-23 11:38:46 -04:00
Bill Thornton
139ecd8146 Add typing for browser.js 2025-09-23 11:38:46 -04:00
Bill Thornton
eff386ffd8 Remove unnecessary dynamic imports 2025-09-23 11:38:46 -04:00
Bill Thornton
b58ee4c1ba Refactor native subtitle styling check 2025-09-23 11:38:46 -04:00
Blackspirits
38fc5db9c2 Translated using Weblate (Portuguese)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt/
2025-09-23 14:52:32 +00:00
Blackspirits
2a59c296da Translated using Weblate (Portuguese (Portugal))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt_PT/
2025-09-23 14:52:31 +00:00
Shadowghost
fb58a759ac Fix grouping again 2025-09-23 15:10:05 +02:00
Shadowghost
2e4dde35f4 Fix grouping 2025-09-23 14:30:07 +02:00
Shadowghost
952a83d282 Set MaxParentalRating and MaxParentalSubRating when setting parental controls 2025-09-23 14:24:19 +02:00
Looooke
a8f06c4fa8 Translated using Weblate (Alemannic)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/gsw/
2025-09-22 22:16:08 +00:00
Blackspirits
2729f77aa8 Translated using Weblate (Portuguese)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt/
2025-09-22 19:07:43 +00:00
Blackspirits
ee717bab07 Translated using Weblate (Portuguese (Portugal))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt_PT/
2025-09-22 19:07:42 +00:00
Jan Zachar
0606493bd9 Translated using Weblate (Spanish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/es/
2025-09-22 12:04:49 +00:00
Jan Zachar
4f0f1635be Translated using Weblate (Belarusian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/be/
2025-09-22 12:04:49 +00:00
Thadah D. Denyse
b78b5fc4f0 Translated using Weblate (Basque)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/eu/
2025-09-22 11:51:24 +00:00
Jan Zachar
f97cbe0fc5 Translated using Weblate (Belarusian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/be/
2025-09-22 11:51:23 +00:00
itoudium
646773b30a Translated using Weblate (Japanese)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ja/
2025-09-21 10:45:38 +00:00
queeup
6f615b7cd9 Translated using Weblate (Turkish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/tr/
2025-09-21 10:45:38 +00:00
yoga sree jagadam
6c3a3a7205 Translated using Weblate (Telugu)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/te/
2025-09-16 14:51:23 +00:00
Aindriú Mac Giolla Eoin
df1626e95b Translated using Weblate (Irish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ga/
2025-09-15 15:51:23 +00:00
Hit360D
e9cc027340 Translated using Weblate (Hindi)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/hi/
2025-09-15 06:30:34 +00:00
Robbie Jones
e8846f71a1 Translated using Weblate (English (United Kingdom))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/en_GB/
2025-09-14 05:09:03 +00:00
Gargotaire
8c099c87fe Translated using Weblate (Catalan)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ca/
2025-09-13 20:33:19 +00:00
Gargotaire
79d2c178e9 Translated using Weblate (Catalan)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/ca/
2025-09-13 19:55:51 +00:00
nenadsuperzmaj
a25295194f Translated using Weblate (Serbian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sr/
2025-09-12 20:38:01 +00:00
nenadsuperzmaj
9562a188c4 Translated using Weblate (Serbian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sr/
2025-09-12 18:09:53 +00:00
nenadsuperzmaj
d581dd9c68 Translated using Weblate (Serbian)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sr/
2025-09-12 17:51:23 +00:00
Plexiglass Ageless
19a28b441e Translated using Weblate (French)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/fr/
2025-09-11 14:51:23 +00:00
Bill Thornton
ddae83d2ed Merge pull request #7133 from thornbill/fix-lodash-import
Fix lodash import for tree-shaking
2025-09-10 16:35:11 -04:00
Bill Thornton
b13942fbd5 Fix lodash import for tree-shaking 2025-09-10 16:27:18 -04:00
Ärik
e04c867424 Translated using Weblate (Swedish)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/sv/
2025-09-10 20:14:33 +00:00
Looooke
eaf4b16abb Translated using Weblate (Alemannic)
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/gsw/
2025-09-09 21:51:23 +00:00
Lucas
82d9e465a3 Translated using Weblate (Portuguese (Brazil))
Translation: Jellyfin/Jellyfin Web
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-web/pt_BR/
2025-09-09 05:34:22 +00:00
viown
a2222e4272 Go to activities on activity item click 2025-09-05 16:44:14 +03:00
Dmitry Lyzo
ca2d669924 Fix XSS at wizard and dashboard library 2025-08-27 12:01:06 +03:00
viown
db3ce49e9e Fix text in activity list overflowing 2025-08-26 12:19:38 +03:00
theguymadmax
e0e9853d49 Add play all button to movies 2025-07-18 17:15:11 -04:00
188 changed files with 5904 additions and 2519 deletions

View File

@@ -101,6 +101,8 @@
- [diegoeche](https://github.com/diegoeche)
- [Free O'Toole](https://github.com/freeotoole)
- [TheBosZ](https://github.com/thebosz)
- [qm3jp](https://github.com/qm3jp)
- [johnnyg](https://github.com/johnnyg)
## Emby Contributors

70
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jellyfin-web",
"version": "10.11.0",
"version": "10.11.6",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "jellyfin-web",
"version": "10.11.0",
"version": "10.11.6",
"license": "GPL-2.0-or-later",
"dependencies": {
"@emotion/react": "11.14.0",
@@ -18,7 +18,7 @@
"@fontsource/noto-sans-sc": "5.2.6",
"@fontsource/noto-sans-tc": "5.2.6",
"@jellyfin/libass-wasm": "4.2.3",
"@jellyfin/sdk": "0.0.0-unstable.202508300501",
"@jellyfin/sdk": "0.12.0",
"@jellyfin/ux-web": "1.0.0",
"@mui/icons-material": "6.4.12",
"@mui/material": "6.4.12",
@@ -40,7 +40,7 @@
"flv.js": "1.6.2",
"headroom.js": "0.12.0",
"history": "5.3.0",
"hls.js": "1.6.9",
"hls.js": "1.6.13",
"intersection-observer": "0.12.2",
"jellyfin-apiclient": "1.11.0",
"jquery": "3.7.1",
@@ -139,7 +139,7 @@
},
"engines": {
"node": ">=20.0.0",
"npm": ">=9.6.4",
"npm": ">=9.6.4 <11.0.0",
"yarn": "YARN NO LONGER USED - use npm instead."
},
"optionalDependencies": {
@@ -4129,12 +4129,12 @@
"license": "LGPL-2.1-or-later AND (FTL OR GPL-2.0-or-later) AND MIT AND MIT-Modern-Variant AND ISC AND NTP AND Zlib AND BSL-1.0"
},
"node_modules/@jellyfin/sdk": {
"version": "0.0.0-unstable.202508300501",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202508300501.tgz",
"integrity": "sha512-7pYdZxIQn/JTPmkEGiYmiGFbJ8S/fgA1EATz+lhOxL4nsCzf7b53DfNcL7eiYsUOFFThmDwJspUt6Y9FnimPPA==",
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.12.0.tgz",
"integrity": "sha512-do3cks7TD316Qw27lBMHZQ7ufaS1MC8HMsQF5rFv5/DUInuwEOqWthqVyHl3sIjThOThF1zxQyE6OdpUl0dNUg==",
"license": "MPL-2.0",
"peerDependencies": {
"axios": "^1.3.4"
"axios": "^1.12.0"
}
},
"node_modules/@jellyfin/ux-web": {
@@ -7233,6 +7233,7 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT",
"peer": true
},
"node_modules/atob": {
@@ -7312,14 +7313,14 @@
}
},
"node_modules/axios": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
@@ -8420,6 +8421,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"peer": true,
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -9509,6 +9511,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.4.0"
@@ -11962,15 +11965,16 @@
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"peer": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
@@ -12620,9 +12624,9 @@
}
},
"node_modules/hls.js": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.9.tgz",
"integrity": "sha512-q7qPrri6GRwjcNd7EkFCmhiJ6PBIxeUsdxKbquBkQZpg9jAnp6zSAeN9eEWFlOB09J8JfzAQGoXL5ZEAltjO9g==",
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.13.tgz",
"integrity": "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA==",
"license": "Apache-2.0"
},
"node_modules/hoist-non-react-statics": {
@@ -18228,6 +18232,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT",
"peer": true
},
"node_modules/proxy-polyfill": {
@@ -27097,9 +27102,9 @@
"integrity": "sha512-C0OlBxIr9NdeFESMTA/OVDqNSWtog6Mi7wwzwH12xbZpxsMD0RgCupUcIP7zZgcpTNecW3fZq5d6xVo7Q8HEJw=="
},
"@jellyfin/sdk": {
"version": "0.0.0-unstable.202508300501",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202508300501.tgz",
"integrity": "sha512-7pYdZxIQn/JTPmkEGiYmiGFbJ8S/fgA1EATz+lhOxL4nsCzf7b53DfNcL7eiYsUOFFThmDwJspUt6Y9FnimPPA==",
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.12.0.tgz",
"integrity": "sha512-do3cks7TD316Qw27lBMHZQ7ufaS1MC8HMsQF5rFv5/DUInuwEOqWthqVyHl3sIjThOThF1zxQyE6OdpUl0dNUg==",
"requires": {}
},
"@jellyfin/ux-web": {
@@ -29097,13 +29102,13 @@
"dev": true
},
"axios": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"peer": true,
"requires": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
@@ -32390,14 +32395,15 @@
}
},
"form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"peer": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
}
},
@@ -32859,9 +32865,9 @@
}
},
"hls.js": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.9.tgz",
"integrity": "sha512-q7qPrri6GRwjcNd7EkFCmhiJ6PBIxeUsdxKbquBkQZpg9jAnp6zSAeN9eEWFlOB09J8JfzAQGoXL5ZEAltjO9g=="
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.13.tgz",
"integrity": "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA=="
},
"hoist-non-react-statics": {
"version": "3.3.2",

View File

@@ -1,6 +1,6 @@
{
"name": "jellyfin-web",
"version": "10.11.0",
"version": "10.11.6",
"description": "Web interface for Jellyfin",
"repository": "https://github.com/jellyfin/jellyfin-web",
"license": "GPL-2.0-or-later",
@@ -84,7 +84,7 @@
"@fontsource/noto-sans-sc": "5.2.6",
"@fontsource/noto-sans-tc": "5.2.6",
"@jellyfin/libass-wasm": "4.2.3",
"@jellyfin/sdk": "0.0.0-unstable.202508300501",
"@jellyfin/sdk": "0.12.0",
"@jellyfin/ux-web": "1.0.0",
"@mui/icons-material": "6.4.12",
"@mui/material": "6.4.12",
@@ -106,7 +106,7 @@
"flv.js": "1.6.2",
"headroom.js": "0.12.0",
"history": "5.3.0",
"hls.js": "1.6.9",
"hls.js": "1.6.13",
"intersection-observer": "0.12.2",
"jellyfin-apiclient": "1.11.0",
"jquery": "3.7.1",
@@ -168,7 +168,7 @@
},
"engines": {
"node": ">=20.0.0",
"npm": ">=9.6.4",
"npm": ">=9.6.4 <11.0.0",
"yarn": "YARN NO LONGER USED - use npm instead."
}
}

View File

@@ -16,6 +16,7 @@ import Backdrop from 'components/Backdrop';
import BangRedirect from 'components/router/BangRedirect';
import { createRouterHistory } from 'components/router/routerHistory';
import appTheme from 'themes/themes';
import { ThemeStorageManager } from 'themes/themeStorageManager';
const layoutMode = localStorage.getItem('layout');
const isExperimentalLayout = layoutMode === 'experimental';
@@ -54,8 +55,7 @@ function RootAppLayout() {
<ThemeProvider
theme={appTheme}
defaultMode='dark'
// Disable mui's default saving to local storage
storageManager={null}
storageManager={ThemeStorageManager}
>
<Backdrop />
<AppHeader isHidden={isExperimentalLayout || isNewLayoutPath} />

View File

@@ -7,7 +7,7 @@ import isEqual from 'lodash-es/isEqual';
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { EventType } from 'types/eventType';
import { EventType } from 'constants/eventType';
import Events, { type Event } from 'utils/events';
interface AppTabsParams {

View File

@@ -1,7 +1,7 @@
import React from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader';
import CardContent from '@mui/material/CardContent';
import CardMedia from '@mui/material/CardMedia';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
@@ -13,7 +13,6 @@ import { Link, To } from 'react-router-dom';
interface BaseCardProps {
title?: string;
secondaryTitle?: string;
text?: string;
image?: string | null;
icon?: React.ReactNode;
@@ -22,15 +21,30 @@ interface BaseCardProps {
action?: boolean;
actionRef?: React.MutableRefObject<HTMLButtonElement | null>;
onActionClick?: () => void;
height?: number;
width?: number;
};
const BaseCard = ({ title, secondaryTitle, text, image, icon, to, onClick, action, actionRef, onActionClick }: BaseCardProps) => {
const BaseCard = ({
title,
text,
image,
icon,
to,
onClick,
action,
actionRef,
onActionClick,
height,
width
}: BaseCardProps) => {
return (
<Card
sx={{
display: 'flex',
flexDirection: 'column',
height: 240
height: height || 240,
width: width
}}
>
<CardActionArea
@@ -62,30 +76,44 @@ const BaseCard = ({ title, secondaryTitle, text, image, icon, to, onClick, actio
</Box>
)}
</CardActionArea>
<CardHeader
title={
<Stack direction='row' spacing={1} alignItems='center'>
<Typography sx={{
<CardContent
sx={{
minHeight: 50,
'&:last-child': {
paddingBottom: 2,
paddingRight: 1
}
}}>
<Stack flexGrow={1} direction='row'>
<Stack flexGrow={1}>
<Typography gutterBottom sx={{
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis'
}}>
{title}
</Typography>
{secondaryTitle && (
<Typography variant='body2' color='text.secondary'>{secondaryTitle}</Typography>
{text && (
<Typography
variant='body2'
color='text.secondary'
sx={{
lineBreak: 'anywhere'
}}
>
{text}
</Typography>
)}
</Stack>
}
subheader={text}
action={
action ? (
<IconButton ref={actionRef} onClick={onActionClick}>
<MoreVertIcon />
</IconButton>
) : null
}
/>
<Box>
{action ? (
<IconButton ref={actionRef} onClick={onActionClick}>
<MoreVertIcon />
</IconButton>
) : null}
</Box>
</Stack>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,30 @@
import React, { useCallback } from 'react';
import Snackbar, { SnackbarProps } from '@mui/material/Snackbar';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
const Toast = (props: SnackbarProps) => {
const onCloseClick = useCallback((e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
props.onClose?.(e, 'clickaway');
}, [ props ]);
const action = (
<IconButton
size='small'
color='inherit'
onClick={onCloseClick}
>
<CloseIcon fontSize='small' />
</IconButton>
);
return (
<Snackbar
autoHideDuration={3300}
action={action}
{ ...props }
/>
);
};
export default Toast;

View File

@@ -1,7 +1,8 @@
import Box from '@mui/material/Box/Box';
import Stack from '@mui/material/Stack/Stack';
import type {} from '@mui/material/themeCssVarsAugmentation';
import Typography from '@mui/material/Typography/Typography';
import { type MRT_RowData, type MRT_TableInstance, MaterialReactTable } from 'material-react-table';
import { type MRT_RowData, type MRT_TableInstance, type MRT_TableOptions, MaterialReactTable } from 'material-react-table';
import React from 'react';
import Page, { type PageProps } from 'components/Page';
@@ -12,7 +13,7 @@ interface TablePageProps<T extends MRT_RowData> extends PageProps {
table: MRT_TableInstance<T>
}
export const DEFAULT_TABLE_OPTIONS = {
export const DEFAULT_TABLE_OPTIONS: Partial<MRT_TableOptions<MRT_RowData>> = {
// Enable custom features
enableColumnPinning: true,
enableColumnResizing: true,

View File

@@ -39,6 +39,7 @@ const ActivityLogWidget = () => {
key={entry.Id}
item={entry}
displayShortOverview={true}
to='/dashboard/activity?useractivity=true'
/>
))}
</List>

View File

@@ -31,6 +31,7 @@ const AlertsLogWidget = () => {
key={entry.Id}
item={entry}
displayShortOverview={false}
to='/dashboard/activity?useractivity=false'
/>
))}
</List>

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

@@ -1,14 +0,0 @@
<div id="mediaLibraryPage" data-role="page" class="page type-interior mediaLibraryPage librarySectionPage fullWidthContent" data-title="${HeaderLibraries}">
<div>
<div class="content-primary">
<div class="padded-top padded-bottom">
<button is="emby-button" type="button" class="raised btnRefresh">
<span>${ButtonScanAllLibraries}</span>
</button>
<progress max="100" min="0" style="display: inline-block; vertical-align: middle;" class="refreshProgress"></progress>
</div>
<div id="divVirtualFolders"></div>
</div>
</div>
</div>

View File

@@ -1,383 +0,0 @@
import escapeHtml from 'escape-html';
import taskButton from 'scripts/taskbutton';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import dom from 'utils/dom';
import imageHelper from 'utils/image';
import 'components/cardbuilder/card.scss';
import 'elements/emby-itemrefreshindicator/emby-itemrefreshindicator';
import { pageClassOn, pageIdOn } from 'utils/dashboard';
import confirm from 'components/confirm/confirm';
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
function addVirtualFolder(page) {
import('components/mediaLibraryCreator/mediaLibraryCreator').then(({ default: MediaLibraryCreator }) => {
new MediaLibraryCreator({
collectionTypeOptions: getCollectionTypeOptions().filter(function (f) {
return !f.hidden;
}),
refresh: shouldRefreshLibraryAfterChanges(page)
}).then(function (hasChanges) {
if (hasChanges) {
reloadLibrary(page);
}
});
});
}
function editVirtualFolder(page, virtualFolder) {
import('components/mediaLibraryEditor/mediaLibraryEditor').then(({ default: MediaLibraryEditor }) => {
new MediaLibraryEditor({
refresh: shouldRefreshLibraryAfterChanges(page),
library: virtualFolder
}).then(function (hasChanges) {
if (hasChanges) {
reloadLibrary(page);
}
});
});
}
function deleteVirtualFolder(page, virtualFolder) {
let msg = globalize.translate('MessageAreYouSureYouWishToRemoveMediaFolder');
if (virtualFolder.Locations.length) {
msg += '<br/><br/>' + globalize.translate('MessageTheFollowingLocationWillBeRemovedFromLibrary') + '<br/><br/>';
msg += virtualFolder.Locations.join('<br/>');
}
confirm({
text: msg,
title: globalize.translate('HeaderRemoveMediaFolder'),
confirmText: globalize.translate('Delete'),
primary: 'delete'
}).then(function () {
const refreshAfterChange = shouldRefreshLibraryAfterChanges(page);
ApiClient.removeVirtualFolder(virtualFolder.Name, refreshAfterChange).then(function () {
reloadLibrary(page);
});
});
}
function refreshVirtualFolder(page, virtualFolder) {
import('components/refreshdialog/refreshdialog').then(({ default: RefreshDialog }) => {
new RefreshDialog({
itemIds: [virtualFolder.ItemId],
serverId: ApiClient.serverId(),
mode: 'scan'
}).show();
});
}
function renameVirtualFolder(page, virtualFolder) {
import('components/prompt/prompt').then(({ default: prompt }) => {
prompt({
label: globalize.translate('LabelNewName'),
description: globalize.translate('MessageRenameMediaFolder'),
confirmText: globalize.translate('ButtonRename')
}).then(function (newName) {
if (newName && newName != virtualFolder.Name) {
const refreshAfterChange = shouldRefreshLibraryAfterChanges(page);
ApiClient.renameVirtualFolder(virtualFolder.Name, newName, refreshAfterChange).then(function () {
reloadLibrary(page);
});
}
});
});
}
function showCardMenu(page, elem, virtualFolders) {
const card = dom.parentWithClass(elem, 'card');
const index = parseInt(card.getAttribute('data-index'), 10);
const virtualFolder = virtualFolders[index];
const menuItems = [];
menuItems.push({
name: globalize.translate('EditImages'),
id: 'editimages',
icon: 'photo'
});
menuItems.push({
name: globalize.translate('ManageLibrary'),
id: 'edit',
icon: 'folder'
});
menuItems.push({
name: globalize.translate('ButtonRename'),
id: 'rename',
icon: 'mode_edit'
});
menuItems.push({
name: globalize.translate('ScanLibrary'),
id: 'refresh',
icon: 'refresh'
});
menuItems.push({
name: globalize.translate('ButtonRemove'),
id: 'delete',
icon: 'delete'
});
import('components/actionSheet/actionSheet').then((actionsheet) => {
actionsheet.show({
items: menuItems,
positionTo: elem,
callback: function (resultId) {
switch (resultId) {
case 'edit':
editVirtualFolder(page, virtualFolder);
break;
case 'editimages':
editImages(page, virtualFolder);
break;
case 'rename':
renameVirtualFolder(page, virtualFolder);
break;
case 'delete':
deleteVirtualFolder(page, virtualFolder);
break;
case 'refresh':
refreshVirtualFolder(page, virtualFolder);
}
}
});
});
}
function reloadLibrary(page) {
loading.show();
ApiClient.getVirtualFolders().then(function (result) {
reloadVirtualFolders(page, result);
});
}
function shouldRefreshLibraryAfterChanges(page) {
return page.id === 'mediaLibraryPage';
}
function reloadVirtualFolders(page, virtualFolders) {
let html = '';
virtualFolders.push({
Name: globalize.translate('ButtonAddMediaLibrary'),
icon: 'add_circle',
Locations: [],
showType: false,
showLocations: false,
showMenu: false,
showNameWithIcon: false,
elementId: 'addLibrary'
});
for (let i = 0; i < virtualFolders.length; i++) {
const virtualFolder = virtualFolders[i];
html += getVirtualFolderHtml(page, virtualFolder, i);
}
const divVirtualFolders = page.querySelector('#divVirtualFolders');
divVirtualFolders.innerHTML = html;
divVirtualFolders.classList.add('itemsContainer');
divVirtualFolders.classList.add('vertical-wrap');
const btnCardMenuElements = divVirtualFolders.querySelectorAll('.btnCardMenu');
btnCardMenuElements.forEach(function (btn) {
btn.addEventListener('click', function () {
showCardMenu(page, btn, virtualFolders);
});
});
divVirtualFolders.querySelector('#addLibrary').addEventListener('click', function () {
addVirtualFolder(page);
});
const libraryEditElements = divVirtualFolders.querySelectorAll('.editLibrary');
libraryEditElements.forEach(function (btn) {
btn.addEventListener('click', function () {
const card = dom.parentWithClass(btn, 'card');
const index = parseInt(card.getAttribute('data-index'), 10);
const virtualFolder = virtualFolders[index];
if (virtualFolder.ItemId) {
editVirtualFolder(page, virtualFolder);
}
});
});
loading.hide();
}
function editImages(page, virtualFolder) {
import('components/imageeditor/imageeditor').then((imageEditor) => {
imageEditor.show({
itemId: virtualFolder.ItemId,
serverId: ApiClient.serverId()
}).then(function () {
reloadLibrary(page);
});
});
}
function getLink(text, url) {
return globalize.translate(text, '<a is="emby-linkbutton" class="button-link" href="' + url + '" target="_blank" data-autohide="true">', '</a>');
}
function getCollectionTypeOptions() {
return [{
name: '',
value: ''
}, {
name: globalize.translate('Movies'),
value: 'movies',
message: getLink('MovieLibraryHelp', 'https://jellyfin.org/docs/general/server/media/movies')
}, {
name: globalize.translate('TabMusic'),
value: 'music',
message: getLink('MusicLibraryHelp', 'https://jellyfin.org/docs/general/server/media/music')
}, {
name: globalize.translate('Shows'),
value: 'tvshows',
message: getLink('TvLibraryHelp', 'https://jellyfin.org/docs/general/server/media/shows')
}, {
name: globalize.translate('Books'),
value: 'books',
message: getLink('BookLibraryHelp', 'https://jellyfin.org/docs/general/server/media/books')
}, {
name: globalize.translate('HomeVideosPhotos'),
value: 'homevideos'
}, {
name: globalize.translate('MusicVideos'),
value: 'musicvideos'
}, {
name: globalize.translate('MixedMoviesShows'),
value: 'mixed',
message: globalize.translate('MessageUnsetContentHelp')
}];
}
function getVirtualFolderHtml(page, virtualFolder, index) {
let html = '';
const elementId = virtualFolder.elementId ? `id="${virtualFolder.elementId}" ` : '';
html += '<div ' + elementId + 'class="card backdropCard scalableCard backdropCard-scalable" data-index="' + index + '" data-id="' + virtualFolder.ItemId + '">';
html += '<div class="cardBox visualCardBox">';
html += '<div class="cardScalable visualCardBox-cardScalable">';
html += '<div class="cardPadder cardPadder-backdrop"></div>';
html += '<div class="cardContent">';
let imgUrl = '';
if (virtualFolder.PrimaryImageItemId) {
imgUrl = ApiClient.getScaledImageUrl(virtualFolder.PrimaryImageItemId, {
maxWidth: Math.round(dom.getScreenWidth() * 0.40),
type: 'Primary'
});
}
let hasCardImageContainer;
if (imgUrl) {
html += `<div class="cardImageContainer editLibrary ${imgUrl ? '' : getDefaultBackgroundClass()}" style="cursor:pointer">`;
html += `<img src="${imgUrl}" style="width:100%" />`;
hasCardImageContainer = true;
} else if (!virtualFolder.showNameWithIcon) {
html += `<div class="cardImageContainer editLibrary ${getDefaultBackgroundClass()}" style="cursor:pointer;">`;
html += '<span class="cardImageIcon material-icons ' + (virtualFolder.icon || imageHelper.getLibraryIcon(virtualFolder.CollectionType)) + '" aria-hidden="true"></span>';
hasCardImageContainer = true;
}
if (hasCardImageContainer) {
html += '<div class="cardIndicators backdropCardIndicators">';
html += '<div is="emby-itemrefreshindicator"' + (virtualFolder.RefreshProgress || virtualFolder.RefreshStatus && virtualFolder.RefreshStatus !== 'Idle' ? '' : ' class="hide"') + ' data-progress="' + (virtualFolder.RefreshProgress || 0) + '" data-status="' + virtualFolder.RefreshStatus + '"></div>';
html += '</div>';
html += '</div>';
}
if (!imgUrl && virtualFolder.showNameWithIcon) {
html += '<h3 class="cardImageContainer addLibrary" style="position:absolute;top:0;left:0;right:0;bottom:0;cursor:pointer;flex-direction:column;">';
html += '<span class="cardImageIcon material-icons ' + (virtualFolder.icon || imageHelper.getLibraryIcon(virtualFolder.CollectionType)) + '" aria-hidden="true"></span>';
if (virtualFolder.showNameWithIcon) {
html += '<div style="margin:1em 0;position:width:100%;">';
html += escapeHtml(virtualFolder.Name);
html += '</div>';
}
html += '</h3>';
}
html += '</div>';
html += '</div>';
html += '<div class="cardFooter visualCardBox-cardFooter">'; // always show menu unless explicitly hidden
if (virtualFolder.showMenu !== false) {
const dirTextAlign = globalize.getIsRTL() ? 'left' : 'right';
html += '<div style="text-align:' + dirTextAlign + '; float:' + dirTextAlign + ';padding-top:5px;">';
html += '<button type="button" is="paper-icon-button-light" class="btnCardMenu autoSize"><span class="material-icons more_vert" aria-hidden="true"></span></button>';
html += '</div>';
}
html += "<div class='cardText'>";
if (virtualFolder.showNameWithIcon) {
html += '&nbsp;';
} else {
html += escapeHtml(virtualFolder.Name);
}
html += '</div>';
let typeName = getCollectionTypeOptions().filter(function (t) {
return t.value == virtualFolder.CollectionType;
})[0];
typeName = typeName ? typeName.name : globalize.translate('Other');
html += "<div class='cardText cardText-secondary'>";
if (virtualFolder.showType === false) {
html += '&nbsp;';
} else {
html += typeName;
}
html += '</div>';
if (virtualFolder.showLocations === false) {
html += "<div class='cardText cardText-secondary'>";
html += '&nbsp;';
html += '</div>';
} else if (virtualFolder.Locations.length && virtualFolder.Locations.length === 1) {
html += "<div class='cardText cardText-secondary' dir='ltr' style='text-align:left;'>";
html += virtualFolder.Locations[0];
html += '</div>';
} else {
html += "<div class='cardText cardText-secondary'>";
html += globalize.translate('NumLocationsValue', virtualFolder.Locations.length);
html += '</div>';
}
html += '</div>';
html += '</div>';
html += '</div>';
return html;
}
pageClassOn('pageshow', 'mediaLibraryPage', function () {
reloadLibrary(this);
});
pageIdOn('pageshow', 'mediaLibraryPage', function () {
const page = this;
taskButton({
mode: 'on',
progressElem: page.querySelector('.refreshProgress'),
taskKey: 'RefreshLibrary',
button: page.querySelector('.btnRefresh')
});
});
pageIdOn('pagebeforehide', 'mediaLibraryPage', function () {
const page = this;
taskButton({
mode: 'off',
progressElem: page.querySelector('.refreshProgress'),
taskKey: 'RefreshLibrary',
button: page.querySelector('.btnRefresh')
});
});

View File

@@ -1,40 +0,0 @@
<div id="liveTvStatusPage" data-role="page" class="page type-interior liveTvSettingsPage" data-title="${LiveTV}">
<div>
<div class="content-primary">
<div class="verticalSection verticalSection-extrabottompadding">
<div class="verticalSection verticalSection-extrabottompadding">
<div class="sectionTitleContainer sectionTitleContainer-cards">
<h2 class="sectionTitle sectionTitle-cards">
<span>${HeaderTunerDevices}</span>
</h2>
<button is="emby-button" type="button" class="fab btnAddDevice submit sectionTitleButton" style="margin-left:1em;" title="${Add}">
<span class="material-icons add" aria-hidden="true"></span>
</button>
</div>
<div class="devicesList itemsContainer vertical-wrap" data-hovermenu="false" data-multiselect="false" style="margin-top: .5em;"></div>
</div>
</div>
<div class="readOnlyContent">
<div class="verticalSection">
<div class="sectionTitleContainer">
<h2 class="sectionTitle">${HeaderGuideProviders}</h2>
<button is="emby-button" type="button" class="fab btnAddProvider submit" style="margin-left:1em;" title="${Add}">
<span class="material-icons add" aria-hidden="true"></span>
</button>
</div>
<div class="providerList">
</div>
<div>
<button is="emby-button" type="button" class="raised btnRefresh block button-cancel">
<span>${ButtonRefreshGuideData}</span>
</button>
<progress max="100" min="0" style="width: 100%;" class="refreshGuideProgress"></progress>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,338 +0,0 @@
import 'jquery';
import globalize from 'lib/globalize';
import taskButton from 'scripts/taskbutton';
import dom from 'utils/dom';
import layoutManager from 'components/layoutManager';
import loading from 'components/loading/loading';
import browser from 'scripts/browser';
import 'components/listview/listview.scss';
import 'styles/flexstyles.scss';
import 'elements/emby-itemscontainer/emby-itemscontainer';
import 'components/cardbuilder/card.scss';
import 'material-design-icons-iconfont';
import 'elements/emby-button/emby-button';
import Dashboard from 'utils/dashboard';
import confirm from 'components/confirm/confirm';
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
const enableFocusTransform = !browser.slow && !browser.edge;
function getDeviceHtml(device) {
const padderClass = 'cardPadder-backdrop';
let cssClass = 'card scalableCard backdropCard backdropCard-scalable';
const cardBoxCssClass = 'cardBox visualCardBox';
let html = '';
// TODO move card creation code to Card component
if (layoutManager.tv) {
cssClass += ' show-focus';
if (enableFocusTransform) {
cssClass += ' show-animation';
}
}
html += '<div type="button" class="' + cssClass + '" data-id="' + device.Id + '">';
html += '<div class="' + cardBoxCssClass + '">';
html += '<div class="cardScalable visualCardBox-cardScalable">';
html += '<div class="' + padderClass + '"></div>';
html += '<div class="cardContent searchImage">';
html += `<div class="cardImageContainer coveredImage ${getDefaultBackgroundClass()}"><span class="cardImageIcon material-icons dvr" aria-hidden="true"></span></div>`;
html += '</div>';
html += '</div>';
html += '<div class="cardFooter visualCardBox-cardFooter">';
html += '<button is="paper-icon-button-light" class="itemAction btnCardOptions autoSize" data-action="menu"><span class="material-icons more_vert" aria-hidden="true"></span></button>';
html += '<div class="cardText">' + (device.FriendlyName || getTunerName(device.Type)) + '</div>';
html += '<div class="cardText cardText-secondary">';
html += device.Url || '&nbsp;';
html += '</div>';
html += '</div>';
html += '</div>';
html += '</div>';
return html;
}
function renderDevices(page, devices) {
page.querySelector('.devicesList').innerHTML = devices.map(getDeviceHtml).join('');
}
function deleteDevice(page, id) {
const message = globalize.translate('MessageConfirmDeleteTunerDevice');
confirm(message, globalize.translate('HeaderDeleteDevice')).then(function () {
loading.show();
ApiClient.ajax({
type: 'DELETE',
url: ApiClient.getUrl('LiveTv/TunerHosts', {
Id: id
})
}).then(function () {
reload(page);
});
});
}
function reload(page) {
loading.show();
ApiClient.getNamedConfiguration('livetv').then(function (config) {
renderDevices(page, config.TunerHosts);
renderProviders(page, config.ListingProviders);
});
loading.hide();
}
function submitAddDeviceForm(page) {
page.querySelector('.dlgAddDevice').close();
loading.show();
ApiClient.ajax({
type: 'POST',
url: ApiClient.getUrl('LiveTv/TunerHosts'),
data: JSON.stringify({
Type: page.querySelector('#selectTunerDeviceType').value,
Url: page.querySelector('#txtDevicePath').value
}),
contentType: 'application/json'
}).then(function () {
reload(page);
}, function () {
Dashboard.alert({
message: globalize.translate('ErrorAddingTunerDevice')
});
});
}
function renderProviders(page, providers) {
let html = '';
if (providers.length) {
html += '<div class="paperList">';
for (let i = 0, length = providers.length; i < length; i++) {
const provider = providers[i];
html += '<div class="listItem">';
html += '<span class="listItemIcon material-icons dvr" aria-hidden="true"></span>';
html += '<div class="listItemBody two-line">';
html += '<a is="emby-linkbutton" style="display:block;padding:0;margin:0;text-align:left;" class="clearLink" href="' + getProviderConfigurationUrl(provider.Type) + '&id=' + provider.Id + '">';
html += '<h3 class="listItemBodyText">';
html += getProviderName(provider.Type);
html += '</h3>';
html += '<div class="listItemBodyText secondary">';
html += provider.Path || provider.ListingsId || '';
html += '</div>';
html += '</a>';
html += '</div>';
html += '<button type="button" is="paper-icon-button-light" class="btnOptions" data-id="' + provider.Id + '"><span class="material-icons listItemAside more_vert" aria-hidden="true"></span></button>';
html += '</div>';
}
html += '</div>';
}
const elem = page.querySelector('.providerList');
elem.innerHTML = html;
if (elem.querySelector('.btnOptions')) {
const btnOptionElements = elem.querySelectorAll('.btnOptions');
btnOptionElements.forEach(function (btn) {
btn.addEventListener('click', function () {
const id = this.getAttribute('data-id');
showProviderOptions(page, id, btn);
});
});
}
}
function showProviderOptions(page, providerId, button) {
const items = [];
items.push({
name: globalize.translate('Delete'),
id: 'delete'
});
items.push({
name: globalize.translate('MapChannels'),
id: 'map'
});
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
actionsheet.show({
items: items,
positionTo: button
}).then(function (id) {
switch (id) {
case 'delete':
deleteProvider(page, providerId);
break;
case 'map':
mapChannels(page, providerId);
}
});
});
}
function mapChannels(page, providerId) {
import('components/channelMapper/channelMapper').then(({ default: ChannelMapper }) => {
new ChannelMapper({
serverId: ApiClient.serverInfo().Id,
providerId: providerId
}).show();
});
}
function deleteProvider(page, id) {
const message = globalize.translate('MessageConfirmDeleteGuideProvider');
confirm(message, globalize.translate('HeaderDeleteProvider')).then(function () {
loading.show();
ApiClient.ajax({
type: 'DELETE',
url: ApiClient.getUrl('LiveTv/ListingProviders', {
Id: id
})
}).then(function () {
reload(page);
}, function () {
reload(page);
});
});
}
function getTunerName(providerId) {
switch (providerId.toLowerCase()) {
case 'm3u':
return 'M3U';
case 'hdhomerun':
return 'HDHomeRun';
case 'hauppauge':
return 'Hauppauge';
case 'satip':
return 'DVB';
default:
return 'Unknown';
}
}
function getProviderName(providerId) {
switch (providerId.toLowerCase()) {
case 'schedulesdirect':
return 'Schedules Direct';
case 'xmltv':
return 'XMLTV';
default:
return 'Unknown';
}
}
function getProviderConfigurationUrl(providerId) {
switch (providerId.toLowerCase()) {
case 'xmltv':
return '#/dashboard/livetv/guide?type=xmltv';
case 'schedulesdirect':
return '#/dashboard/livetv/guide?type=schedulesdirect';
}
}
function addProvider(button) {
const menuItems = [];
menuItems.push({
name: 'Schedules Direct',
id: 'SchedulesDirect'
});
menuItems.push({
name: 'XMLTV',
id: 'xmltv'
});
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
actionsheet.show({
items: menuItems,
positionTo: button,
callback: function (id) {
Dashboard.navigate(getProviderConfigurationUrl(id));
}
});
});
}
function addDevice() {
Dashboard.navigate('dashboard/livetv/tuner');
}
function showDeviceMenu(button, tunerDeviceId) {
const items = [];
items.push({
name: globalize.translate('Delete'),
id: 'delete'
});
items.push({
name: globalize.translate('Edit'),
id: 'edit'
});
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
actionsheet.show({
items: items,
positionTo: button
}).then(function (id) {
switch (id) {
case 'delete':
deleteDevice(dom.parentWithClass(button, 'page'), tunerDeviceId);
break;
case 'edit':
Dashboard.navigate('dashboard/livetv/tuner?id=' + tunerDeviceId);
}
});
});
}
function onDevicesListClick(e) {
const card = dom.parentWithClass(e.target, 'card');
if (card) {
const id = card.getAttribute('data-id');
const btnCardOptions = dom.parentWithClass(e.target, 'btnCardOptions');
if (btnCardOptions) {
showDeviceMenu(btnCardOptions, id);
} else {
Dashboard.navigate('dashboard/livetv/tuner?id=' + id);
}
}
}
$(document).on('pageinit', '#liveTvStatusPage', function () {
const page = this;
page.querySelector('.btnAddDevice').addEventListener('click', function () {
addDevice();
});
if (page.querySelector('.formAddDevice')) {
// NOTE: unused?
page.querySelector('.formAddDevice').addEventListener('submit', function (e) {
e.preventDefault();
submitAddDeviceForm(page);
});
}
page.querySelector('.btnAddProvider').addEventListener('click', function () {
addProvider(this);
});
page.querySelector('.devicesList').addEventListener('click', onDevicesListClick);
}).on('pageshow', '#liveTvStatusPage', function () {
const page = this;
reload(page);
taskButton({
mode: 'on',
progressElem: page.querySelector('.refreshGuideProgress'),
taskKey: 'RefreshGuide',
button: page.querySelector('.btnRefresh')
});
}).on('pagehide', '#liveTvStatusPage', function () {
const page = this;
taskButton({
mode: 'off',
progressElem: page.querySelector('.refreshGuideProgress'),
taskKey: 'RefreshGuide',
button: page.querySelector('.btnRefresh')
});
});

View File

@@ -4,7 +4,6 @@ import Notifications from '@mui/icons-material/Notifications';
import Avatar from '@mui/material/Avatar';
import ListItem from '@mui/material/ListItem';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Typography from '@mui/material/Typography';
import formatRelative from 'date-fns/formatRelative';
@@ -12,13 +11,15 @@ import { getLocale } from 'utils/dateFnsLocale';
import Stack from '@mui/material/Stack';
import getLogLevelColor from '../utils/getLogLevelColor';
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
import ListItemLink from 'components/ListItemLink';
type ActivityListItemProps = {
item: ActivityLogEntry;
displayShortOverview: boolean;
to: string;
};
const ActivityListItem = ({ item, displayShortOverview }: ActivityListItemProps) => {
const ActivityListItem = ({ item, displayShortOverview, to }: ActivityListItemProps) => {
const relativeDate = useMemo(() => {
if (item.Date) {
return formatRelative(Date.parse(item.Date), Date.now(), { locale: getLocale() });
@@ -29,7 +30,7 @@ const ActivityListItem = ({ item, displayShortOverview }: ActivityListItemProps)
return (
<ListItem disablePadding>
<ListItemButton>
<ListItemLink to={to}>
<ListItemAvatar>
<Avatar sx={{ bgcolor: getLogLevelColor(item.Severity || LogLevel.Information) + '.main' }}>
<Notifications sx={{ color: '#fff' }} />
@@ -37,14 +38,28 @@ const ActivityListItem = ({ item, displayShortOverview }: ActivityListItemProps)
</ListItemAvatar>
<ListItemText
primary={<Typography>{item.Name}</Typography>}
primary={<Typography sx={{ whiteSpace: 'pre-wrap' }}>{item.Name}</Typography>}
secondary={(
<Stack>
<Typography variant='body1' color='text.secondary'>
<Typography
sx={{
textOverflow: 'ellipsis',
overflow: 'hidden'
}}
variant='body1'
color='text.secondary'
>
{relativeDate}
</Typography>
{displayShortOverview && (
<Typography variant='body1' color='text.secondary'>
<Typography
sx={{
textOverflow: 'ellipsis',
overflow: 'hidden'
}}
variant='body1'
color='text.secondary'
>
{item.ShortOverview}
</Typography>
)}
@@ -52,7 +67,7 @@ const ActivityListItem = ({ item, displayShortOverview }: ActivityListItemProps)
)}
disableTypography
/>
</ListItemButton>
</ListItemLink>
</ListItem>
);
};

View File

@@ -7,7 +7,7 @@ import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import Box from '@mui/material/Box';
import globalize from 'lib/globalize';
import React, { FunctionComponent, useCallback } from 'react';
import React, { FunctionComponent, useCallback, useState } from 'react';
import Stack from '@mui/material/Stack';
import FormGroup from '@mui/material/FormGroup';
import FormControl from '@mui/material/FormControl';
@@ -16,7 +16,7 @@ import Checkbox from '@mui/material/Checkbox';
import ContentCopy from '@mui/icons-material/ContentCopy';
import IconButton from '@mui/material/IconButton';
import { copy } from 'scripts/clipboard';
import toast from 'components/toast/toast';
import Toast from 'apps/dashboard/components/Toast';
type IProps = {
backup: BackupManifestDto;
@@ -25,10 +25,16 @@ type IProps = {
};
const BackupInfoDialog: FunctionComponent<IProps> = ({ backup, open, onClose }: IProps) => {
const [ isCopiedToastOpen, setIsCopiedToastOpen ] = useState(false);
const handleToastClose = useCallback(() => {
setIsCopiedToastOpen(false);
}, []);
const copyPath = useCallback(async () => {
if (backup.Path) {
await copy(backup.Path);
toast({ text: globalize.translate('Copied') });
setIsCopiedToastOpen(true);
}
}, [ backup.Path ]);
@@ -39,6 +45,11 @@ const BackupInfoDialog: FunctionComponent<IProps> = ({ backup, open, onClose }:
maxWidth={'sm'}
fullWidth
>
<Toast
open={isCopiedToastOpen}
onClose={handleToastClose}
message={globalize.translate('Copied')}
/>
<DialogTitle>
{backup.DateCreated}
</DialogTitle>

View File

@@ -0,0 +1,21 @@
import { getLibraryStructureApi } from '@jellyfin/sdk/lib/utils/api/library-structure-api';
import { LibraryStructureApiRemoveVirtualFolderRequest } from '@jellyfin/sdk/lib/generated-client/api/library-structure-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
export const useRemoveVirtualFolder = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: LibraryStructureApiRemoveVirtualFolderRequest) => (
getLibraryStructureApi(api!)
.removeVirtualFolder(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ 'VirtualFolders' ]
});
}
});
};

View File

@@ -0,0 +1,21 @@
import { getLibraryStructureApi } from '@jellyfin/sdk/lib/utils/api/library-structure-api';
import { LibraryStructureApiRenameVirtualFolderRequest } from '@jellyfin/sdk/lib/generated-client/api/library-structure-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
export const useRenameVirtualFolder = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: LibraryStructureApiRenameVirtualFolderRequest) => (
getLibraryStructureApi(api!)
.renameVirtualFolder(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ 'VirtualFolders' ]
});
}
});
};

View File

@@ -0,0 +1,21 @@
import { Api } from '@jellyfin/sdk';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import type { AxiosRequestConfig } from 'axios';
import { getLibraryStructureApi } from '@jellyfin/sdk/lib/utils/api/library-structure-api';
const fetchVirtualFolders = async (api: Api, options?: AxiosRequestConfig) => {
const response = await getLibraryStructureApi(api).getVirtualFolders(options);
return response.data;
};
export const useVirtualFolders = () => {
const { api } = useApi();
return useQuery({
queryKey: [ 'VirtualFolders' ],
queryFn: ({ signal }) => fetchVirtualFolders(api!, { signal }),
enabled: !!api
});
};

View File

@@ -0,0 +1,237 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { VirtualFolderInfo } from '@jellyfin/sdk/lib/generated-client/models/virtual-folder-info';
import BaseCard from 'apps/dashboard/components/BaseCard';
import getCollectionTypeOptions from '../utils/collectionTypeOptions';
import globalize from 'lib/globalize';
import Icon from '@mui/material/Icon';
import { getLibraryIcon } from 'utils/image';
import MediaLibraryEditor from 'components/mediaLibraryEditor/mediaLibraryEditor';
import { queryClient } from 'utils/query/queryClient';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import Folder from '@mui/icons-material/Folder';
import ImageIcon from '@mui/icons-material/Image';
import EditIcon from '@mui/icons-material/Edit';
import RefreshIcon from '@mui/icons-material/Refresh';
import DeleteIcon from '@mui/icons-material/Delete';
import ListItemText from '@mui/material/ListItemText';
import imageeditor from 'components/imageeditor/imageeditor';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import InputDialog from 'components/InputDialog';
import { useRenameVirtualFolder } from '../api/useRenameVirtualFolder';
import RefreshDialog from 'components/refreshdialog/refreshdialog';
import ConfirmDialog from 'components/ConfirmDialog';
import { useRemoveVirtualFolder } from '../api/useRemoveVirtualFolder';
import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
import { useApi } from 'hooks/useApi';
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
import dom from 'utils/dom';
type LibraryCardProps = {
virtualFolder: VirtualFolderInfo;
};
const LibraryCard = ({ virtualFolder }: LibraryCardProps) => {
const { api } = useApi();
const actionRef = useRef<HTMLButtonElement | null>(null);
const [ anchorEl, setAnchorEl ] = useState<HTMLElement | null>(null);
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
const [ isRenameLibraryDialogOpen, setIsRenameLibraryDialogOpen ] = useState(false);
const [ isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen ] = useState(false);
const renameVirtualFolder = useRenameVirtualFolder();
const removeVirtualFolder = useRemoveVirtualFolder();
const imageUrl = useMemo(() => {
if (virtualFolder.PrimaryImageItemId && virtualFolder.ItemId && api) {
return getImageApi(api)
.getItemImageUrlById(virtualFolder.ItemId, ImageType.Primary, {
maxWidth: Math.round(dom.getScreenWidth() * 0.40)
});
}
}, [ api, virtualFolder ]);
const typeName = getCollectionTypeOptions().filter(function (t) {
return t.value == virtualFolder.CollectionType;
})[0]?.name || globalize.translate('Other');
const openRenameDialog = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
setIsRenameLibraryDialogOpen(true);
}, []);
const hideRenameLibraryDialog = useCallback(() => {
setIsRenameLibraryDialogOpen(false);
}, []);
const onMenuClose = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
}, []);
const onActionClick = useCallback(() => {
setAnchorEl(actionRef.current);
setIsMenuOpen(true);
}, []);
const renameLibrary = useCallback((newName: string) => {
if (virtualFolder.Name) {
renameVirtualFolder.mutate({
refreshLibrary: true,
newName: newName,
name: virtualFolder.Name
}, {
onSettled: () => {
hideRenameLibraryDialog();
}
});
}
}, [ renameVirtualFolder, virtualFolder, hideRenameLibraryDialog ]);
const showRefreshDialog = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
void new RefreshDialog({
itemIds: [ virtualFolder.ItemId ],
serverId: ServerConnections.currentApiClient()?.serverId(),
mode: 'scan'
}).show();
}, [ virtualFolder ]);
const showMediaLibraryEditor = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
const mediaLibraryEditor = new MediaLibraryEditor({
library: virtualFolder
}) as Promise<boolean>;
void mediaLibraryEditor.then((hasChanges: boolean) => {
if (hasChanges) {
void queryClient.invalidateQueries({
queryKey: ['VirtualFolders']
});
}
});
}, [ virtualFolder ]);
const showImageEditor = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
void imageeditor.show({
itemId: virtualFolder.ItemId,
serverId: ServerConnections.currentApiClient()?.serverId()
}).then(() => {
void queryClient.invalidateQueries({
queryKey: ['VirtualFolders']
});
}).catch(() => {
/* pop up closed */
});
}, [ virtualFolder ]);
const showDeleteLibraryDialog = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
setIsConfirmDeleteDialogOpen(true);
}, []);
const onCancelDeleteLibrary = useCallback(() => {
setIsConfirmDeleteDialogOpen(false);
}, []);
const onConfirmDeleteLibrary = useCallback(() => {
if (virtualFolder.Name) {
removeVirtualFolder.mutate({
name: virtualFolder.Name,
refreshLibrary: true
}, {
onSettled: () => {
setIsConfirmDeleteDialogOpen(false);
}
});
}
}, [ virtualFolder, removeVirtualFolder ]);
return (
<>
<InputDialog
title={globalize.translate('ButtonRename')}
open={isRenameLibraryDialogOpen}
onClose={hideRenameLibraryDialog}
label={globalize.translate('LabelNewName')}
helperText={globalize.translate('MessageRenameMediaFolder')}
initialText={virtualFolder.Name || ''}
confirmButtonText={globalize.translate('ButtonRename')}
onConfirm={renameLibrary}
/>
<ConfirmDialog
open={isConfirmDeleteDialogOpen}
title={globalize.translate('HeaderRemoveMediaFolder')}
text={
globalize.translate('MessageAreYouSureYouWishToRemoveMediaFolder') + '\n\n'
+ globalize.translate('MessageTheFollowingLocationWillBeRemovedFromLibrary') + '\n\n'
+ virtualFolder.Locations?.join('\n')
}
confirmButtonText={globalize.translate('Delete')}
confirmButtonColor='error'
onConfirm={onConfirmDeleteLibrary}
onCancel={onCancelDeleteLibrary}
/>
<BaseCard
title={virtualFolder.Name || ''}
text={typeName}
image={imageUrl}
icon={<Icon sx={{ fontSize: 70 }}>{getLibraryIcon(virtualFolder.CollectionType)}</Icon>}
action={true}
actionRef={actionRef}
onActionClick={onActionClick}
onClick={showMediaLibraryEditor}
height={260}
/>
<Menu
anchorEl={anchorEl}
open={isMenuOpen}
onClose={onMenuClose}
>
<MenuItem onClick={showImageEditor}>
<ListItemIcon>
<ImageIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('EditImages')}</ListItemText>
</MenuItem>
<MenuItem onClick={showMediaLibraryEditor}>
<ListItemIcon>
<Folder />
</ListItemIcon>
<ListItemText>{globalize.translate('ManageLibrary')}</ListItemText>
</MenuItem>
<MenuItem onClick={openRenameDialog}>
<ListItemIcon>
<EditIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('ButtonRename')}</ListItemText>
</MenuItem>
<MenuItem onClick={showRefreshDialog}>
<ListItemIcon>
<RefreshIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('ScanLibrary')}</ListItemText>
</MenuItem>
<MenuItem onClick={showDeleteLibraryDialog}>
<ListItemIcon>
<DeleteIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('ButtonRemove')}</ListItemText>
</MenuItem>
</Menu>
</>
);
};
export default LibraryCard;

View File

@@ -0,0 +1,31 @@
import globalize from 'lib/globalize';
const getCollectionTypeOptions = () => {
return [{
name: '',
value: ''
}, {
name: globalize.translate('Movies'),
value: 'movies'
}, {
name: globalize.translate('TabMusic'),
value: 'music'
}, {
name: globalize.translate('Shows'),
value: 'tvshows'
}, {
name: globalize.translate('Books'),
value: 'books'
}, {
name: globalize.translate('HomeVideosPhotos'),
value: 'homevideos'
}, {
name: globalize.translate('MusicVideos'),
value: 'musicvideos'
}, {
name: globalize.translate('MixedMoviesShows'),
value: 'mixed'
}];
};
export default getCollectionTypeOptions;

View File

@@ -0,0 +1,22 @@
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api';
import { LiveTvApiDeleteListingProviderRequest } from '@jellyfin/sdk/lib/generated-client/api/live-tv-api';
export const useDeleteProvider = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: LiveTvApiDeleteListingProviderRequest) => (
getLiveTvApi(api!)
.deleteListingProvider(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ 'NamedConfiguration', 'livetv' ]
});
}
});
};

View File

@@ -0,0 +1,22 @@
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api';
import { LiveTvApiDeleteTunerHostRequest } from '@jellyfin/sdk/lib/generated-client/api/live-tv-api';
export const useDeleteTuner = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: LiveTvApiDeleteTunerHostRequest) => (
getLiveTvApi(api!)
.deleteTunerHost(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ 'NamedConfiguration', 'livetv' ]
});
}
});
};

View File

@@ -0,0 +1,138 @@
import React, { useCallback, useRef, useState } from 'react';
import type { ListingsProviderInfo } from '@jellyfin/sdk/lib/generated-client/models/listings-provider-info';
import Avatar from '@mui/material/Avatar';
import ListItem from '@mui/material/ListItem';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import ListItemLink from 'components/ListItemLink';
import DvrIcon from '@mui/icons-material/Dvr';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import getProviderConfigurationUrl from '../utils/getProviderConfigurationUrl';
import ListItemText from '@mui/material/ListItemText';
import getProviderName from '../utils/getProviderName';
import IconButton from '@mui/material/IconButton';
import ConfirmDialog from 'components/ConfirmDialog';
import globalize from 'lib/globalize';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import LocationSearchingIcon from '@mui/icons-material/LocationSearching';
import DeleteIcon from '@mui/icons-material/Delete';
import ChannelMapper from 'components/channelMapper/channelMapper';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import { useDeleteProvider } from '../api/useDeleteProvider';
interface ProviderProps {
provider: ListingsProviderInfo
}
const Provider = ({ provider }: ProviderProps) => {
const [ isDeleteProviderDialogOpen, setIsDeleteProviderDialogOpen ] = useState(false);
const actionsRef = useRef<HTMLButtonElement | null>(null);
const [ anchorEl, setAnchorEl ] = useState<HTMLButtonElement | null>(null);
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
const deleteProvider = useDeleteProvider();
const showChannelMapper = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
void new ChannelMapper({
serverId: ServerConnections.currentApiClient()?.serverId(),
providerId: provider.Id
}).show();
}, [ provider ]);
const showContextMenu = useCallback(() => {
setAnchorEl(actionsRef.current);
setIsMenuOpen(true);
}, []);
const showDeleteDialog = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
setIsDeleteProviderDialogOpen(true);
}, []);
const onDeleteProviderDialogCancel = useCallback(() => {
setIsDeleteProviderDialogOpen(false);
}, []);
const onMenuClose = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
}, []);
const onConfirmDelete = useCallback(() => {
if (provider.Id) {
deleteProvider.mutate({
id: provider.Id
}, {
onSettled: () => {
setIsDeleteProviderDialogOpen(false);
}
});
}
}, [ deleteProvider, provider ]);
return (
<>
<ConfirmDialog
open={isDeleteProviderDialogOpen}
title={globalize.translate('HeaderDeleteProvider')}
text={globalize.translate('MessageConfirmDeleteGuideProvider')}
onCancel={onDeleteProviderDialogCancel}
onConfirm={onConfirmDelete}
confirmButtonText={globalize.translate('Delete')}
confirmButtonColor='error'
/>
<ListItem
disablePadding key={provider.Id}
secondaryAction={
<IconButton ref={actionsRef} onClick={showContextMenu}>
<MoreVertIcon />
</IconButton>
}
>
<ListItemLink to={getProviderConfigurationUrl(provider.Type || '') + '&id=' + provider.Id}>
<ListItemAvatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>
<DvrIcon sx={{ color: '#fff' }} />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={getProviderName(provider.Type)}
secondary={provider.Path || provider.ListingsId}
slotProps={{
primary: {
variant: 'h3'
},
secondary: {
variant: 'body1'
}
}}
/>
</ListItemLink>
</ListItem>
<Menu
anchorEl={anchorEl}
open={isMenuOpen}
onClose={onMenuClose}
>
<MenuItem onClick={showChannelMapper}>
<ListItemIcon>
<LocationSearchingIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('MapChannels')}</ListItemText>
</MenuItem>
<MenuItem onClick={showDeleteDialog}>
<ListItemIcon>
<DeleteIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('Delete')}</ListItemText>
</MenuItem>
</Menu>
</>
);
};
export default Provider;

View File

@@ -0,0 +1,109 @@
import React, { useCallback, useRef, useState } from 'react';
import type { TunerHostInfo } from '@jellyfin/sdk/lib/generated-client/models/tuner-host-info';
import BaseCard from 'apps/dashboard/components/BaseCard';
import DvrIcon from '@mui/icons-material/Dvr';
import getTunerName from '../utils/getTunerName';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import ListItemText from '@mui/material/ListItemText';
import globalize from 'lib/globalize';
import { useNavigate } from 'react-router-dom';
import ConfirmDialog from 'components/ConfirmDialog';
import { useDeleteTuner } from '../api/useDeleteTuner';
interface TunerDeviceCardProps {
tunerHost: TunerHostInfo;
}
const TunerDeviceCard = ({ tunerHost }: TunerDeviceCardProps) => {
const navigate = useNavigate();
const actionRef = useRef<HTMLButtonElement | null>(null);
const [ anchorEl, setAnchorEl ] = useState<HTMLElement | null>(null);
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
const [ isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen ] = useState(false);
const deleteTuner = useDeleteTuner();
const navigateToEditPage = useCallback(() => {
navigate(`/dashboard/livetv/tuner?id=${tunerHost.Id}`);
}, [ navigate, tunerHost ]);
const onDelete = useCallback(() => {
if (tunerHost.Id) {
deleteTuner.mutate({
id: tunerHost.Id
}, {
onSettled: () => {
setIsConfirmDeleteDialogOpen(false);
}
});
}
}, [ deleteTuner, tunerHost ]);
const showDeleteDialog = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
setIsConfirmDeleteDialogOpen(true);
}, []);
const onDeleteDialogClose = useCallback(() => {
setIsConfirmDeleteDialogOpen(false);
}, []);
const onActionClick = useCallback(() => {
setAnchorEl(actionRef.current);
setIsMenuOpen(true);
}, []);
const onMenuClose = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
}, []);
return (
<>
<ConfirmDialog
open={isConfirmDeleteDialogOpen}
title={globalize.translate('HeaderDeleteDevice')}
text={globalize.translate('MessageConfirmDeleteTunerDevice')}
onCancel={onDeleteDialogClose}
onConfirm={onDelete}
confirmButtonColor='error'
confirmButtonText={globalize.translate('Delete')}
/>
<BaseCard
title={tunerHost.FriendlyName || getTunerName(tunerHost.Type) || ''}
text={tunerHost.Url || ''}
icon={<DvrIcon sx={{ fontSize: 70 }} />}
action={true}
actionRef={actionRef}
onActionClick={onActionClick}
onClick={navigateToEditPage}
/>
<Menu
anchorEl={anchorEl}
open={isMenuOpen}
onClose={onMenuClose}
>
<MenuItem onClick={navigateToEditPage}>
<ListItemIcon>
<EditIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('Edit')}</ListItemText>
</MenuItem>
<MenuItem onClick={showDeleteDialog}>
<ListItemIcon>
<DeleteIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('Delete')}</ListItemText>
</MenuItem>
</Menu>
</>
);
};
export default TunerDeviceCard;

View File

@@ -0,0 +1,10 @@
const getProviderConfigurationUrl = (providerId: string) => {
switch (providerId?.toLowerCase()) {
case 'xmltv':
return '/dashboard/livetv/guide?type=xmltv';
case 'schedulesdirect':
return '/dashboard/livetv/guide?type=schedulesdirect';
}
};
export default getProviderConfigurationUrl;

View File

@@ -0,0 +1,12 @@
const getProviderName = (providerId: string | null | undefined) => {
switch (providerId?.toLowerCase()) {
case 'schedulesdirect':
return 'Schedules Direct';
case 'xmltv':
return 'XMLTV';
default:
return 'Unknown';
}
};
export default getProviderName;

View File

@@ -0,0 +1,16 @@
const getTunerName = (providerId: string | null | undefined) => {
switch (providerId?.toLowerCase()) {
case 'm3u':
return 'M3U';
case 'hdhomerun':
return 'HDHomeRun';
case 'hauppauge':
return 'Hauppauge';
case 'satip':
return 'DVB';
default:
return 'Unknown';
}
};
export default getTunerName;

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

@@ -8,7 +8,15 @@ const TaskProgress: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
const progress = task.CurrentProgressPercentage;
return (
<Box sx={{ display: 'flex', alignItems: 'center', height: '1.2rem', mr: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
height: '1.2rem',
mr: 2,
minWidth: '170px'
}}
>
{progress != null ? (
<>
<Box sx={{ width: '100%', mr: 1 }}>

View File

@@ -9,9 +9,11 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'devices', type: AppType.Dashboard },
{ path: 'settings', type: AppType.Dashboard },
{ path: 'keys', type: AppType.Dashboard },
{ path: 'libraries', type: AppType.Dashboard },
{ path: 'libraries/display', type: AppType.Dashboard },
{ path: 'libraries/metadata', type: AppType.Dashboard },
{ path: 'libraries/nfo', type: AppType.Dashboard },
{ path: 'livetv', type: AppType.Dashboard },
{ path: 'livetv/recordings', type: AppType.Dashboard },
{ path: 'logs', type: AppType.Dashboard },
{ path: 'logs/:file', page: 'logs/file', type: AppType.Dashboard },

View File

@@ -9,13 +9,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
controller: 'networking',
view: 'networking.html'
}
}, {
path: 'libraries',
pageProps: {
appType: AppType.Dashboard,
controller: 'library',
view: 'library.html'
}
}, {
path: 'livetv/guide',
pageProps: {
@@ -23,13 +16,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
controller: 'livetvguideprovider',
view: 'livetvguideprovider.html'
}
}, {
path: 'livetv',
pageProps: {
appType: AppType.Dashboard,
controller: 'livetvstatus',
view: 'livetvstatus.html'
}
}, {
path: 'livetv/tuner',
pageProps: {

View File

@@ -2,9 +2,10 @@ import parseISO from 'date-fns/parseISO';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
import { useTheme } from '@mui/material/styles';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
import { type MRT_ColumnDef, type MRT_Theme, useMaterialReactTable } from 'material-react-table';
import { useSearchParams } from 'react-router-dom';
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
@@ -53,6 +54,8 @@ export const Component = () => {
const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails();
const theme = useTheme();
const UserCell = getUserCell(users);
const activityParams = useMemo(() => ({
@@ -156,8 +159,15 @@ export const Component = () => {
}
}, [ activityView, searchParams, setSearchParams ]);
// NOTE: We need to provide a custom theme due to a MRT bug causing the initial theme to always be used
// https://github.com/KevinVandy/material-react-table/issues/1429
const mrtTheme = useMemo<Partial<MRT_Theme>>(() => ({
baseBackgroundColor: theme.palette.background.paper
}), [ theme ]);
const table = useMaterialReactTable({
...DEFAULT_TABLE_OPTIONS,
mrtTheme,
columns,
data: logEntries,

View File

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

View File

@@ -4,9 +4,10 @@ import Edit from '@mui/icons-material/Edit';
import Box from '@mui/material/Box/Box';
import Button from '@mui/material/Button/Button';
import IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip/Tooltip';
import parseISO from 'date-fns/parseISO';
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
import { type MRT_ColumnDef, type MRT_Theme, useMaterialReactTable } from 'material-react-table';
import React, { useCallback, useMemo, useState } from 'react';
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
@@ -41,6 +42,7 @@ export const Component = () => {
data?.Items || []
), [ data ]);
const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails();
const theme = useTheme();
const [ isDeleteConfirmOpen, setIsDeleteConfirmOpen ] = useState(false);
const [ isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen ] = useState(false);
@@ -137,8 +139,15 @@ export const Component = () => {
}
], [ UserCell, userNames ]);
// NOTE: We need to provide a custom theme due to a MRT bug causing the initial theme to always be used
// https://github.com/KevinVandy/material-react-table/issues/1429
const mrtTheme = useMemo<Partial<MRT_Theme>>(() => ({
baseBackgroundColor: theme.palette.background.paper
}), [ theme ]);
const mrTable = useMaterialReactTable({
...DEFAULT_TABLE_OPTIONS,
mrtTheme,
columns,
data: devices,
@@ -184,16 +193,25 @@ export const Component = () => {
positionActionsColumn: 'last',
displayColumnDefOptions: {
'mrt-row-actions': {
header: ''
header: '',
size: 100
}
},
renderRowActions: ({ row, table }) => {
const isDeletable = api && row.original.Id && api.deviceInfo.id === row.original.Id;
return (
<Box sx={{ display: 'flex', gap: 1 }}>
<Box
sx={{
display: 'flex',
gap: 1,
'&&': {
backgroundColor: 'transparent !important'
}
}}
>
<Tooltip title={globalize.translate('Edit')}>
<IconButton
// eslint-disable-next-line react/jsx-no-bind
// eslint-disable-next-line react/jsx-no-bind
onClick={() => table.setEditingRow(row)}
>
<Edit />

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

@@ -2,31 +2,34 @@ import type { AuthenticationInfo } from '@jellyfin/sdk/lib/generated-client/mode
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import parseISO from 'date-fns/parseISO';
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
import React, { useCallback, useMemo } from 'react';
import { type MRT_ColumnDef, type MRT_Theme, useMaterialReactTable } from 'material-react-table';
import React, { useCallback, useMemo, useState } from 'react';
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/table/TablePage';
import { useApiKeys } from 'apps/dashboard/features/keys/api/useApiKeys';
import { useRevokeKey } from 'apps/dashboard/features/keys/api/useRevokeKey';
import { useCreateKey } from 'apps/dashboard/features/keys/api/useCreateKey';
import confirm from 'components/confirm/confirm';
import prompt from 'components/prompt/prompt';
import { useApi } from 'hooks/useApi';
import globalize from 'lib/globalize';
import InputDialog from 'components/InputDialog';
import ConfirmDialog from 'components/ConfirmDialog';
export const Component = () => {
const { api } = useApi();
const [ isCreateApiKeyPromptOpen, setIsCreateApiKeyPromptOpen ] = useState(false);
const [ isConfirmDeleteOpen, setIsConfirmDeleteOpen ] = useState(false);
const [ apiKeyToDelete, setApiKeyToDelete ] = useState('');
const { data, isLoading } = useApiKeys();
const keys = useMemo(() => (
data?.Items || []
), [ data ]);
const revokeKey = useRevokeKey();
const createKey = useCreateKey();
const theme = useTheme();
const columns = useMemo<MRT_ColumnDef<AuthenticationInfo>[]>(() => [
{
@@ -49,8 +52,15 @@ export const Component = () => {
}
], []);
// NOTE: We need to provide a custom theme due to a MRT bug causing the initial theme to always be used
// https://github.com/KevinVandy/material-react-table/issues/1429
const mrtTheme = useMemo<Partial<MRT_Theme>>(() => ({
baseBackgroundColor: theme.palette.background.paper
}), [ theme ]);
const table = useMaterialReactTable({
...DEFAULT_TABLE_OPTIONS,
mrtTheme,
columns,
data: keys,
@@ -96,41 +106,72 @@ export const Component = () => {
});
const onRevokeKey = useCallback((accessToken: string) => {
if (!api) return;
confirm(globalize.translate('MessageConfirmRevokeApiKey'), globalize.translate('HeaderConfirmRevokeApiKey')).then(function () {
revokeKey.mutate({
key: accessToken
});
}).catch(err => {
console.error('[apikeys] failed to show confirmation dialog', err);
});
}, [api, revokeKey]);
setApiKeyToDelete(accessToken);
setIsConfirmDeleteOpen(true);
}, []);
const showNewKeyPopup = useCallback(() => {
if (!api) return;
setIsCreateApiKeyPromptOpen(true);
}, []);
prompt({
title: globalize.translate('HeaderNewApiKey'),
label: globalize.translate('LabelAppName'),
description: globalize.translate('LabelAppNameExample')
}).then((value) => {
createKey.mutate({
app: value
});
}).catch(() => {
// popup closed
const onCreateApiKeyPromptClose = useCallback(() => {
setIsCreateApiKeyPromptOpen(false);
}, []);
const onConfirmDelete = useCallback(() => {
revokeKey.mutate({
key: apiKeyToDelete
}, {
onSettled: () => {
setApiKeyToDelete('');
setIsConfirmDeleteOpen(false);
}
});
}, [api, createKey]);
}, [ revokeKey, apiKeyToDelete ]);
const onConfirmDeleteCancel = useCallback(() => {
setApiKeyToDelete('');
setIsConfirmDeleteOpen(false);
}, []);
const onConfirmCreate = useCallback((name: string) => {
createKey.mutate({
app: name
}, {
onSettled: () => {
setIsCreateApiKeyPromptOpen(false);
}
});
}, [ createKey ]);
return (
<TablePage
id='apiKeysPage'
title={globalize.translate('HeaderApiKeys')}
subtitle={globalize.translate('HeaderApiKeysHelp')}
className='mainAnimatedPage type-interior'
table={table}
/>
<>
<ConfirmDialog
open={isConfirmDeleteOpen}
title={globalize.translate('HeaderConfirmRevokeApiKey')}
text={globalize.translate('MessageConfirmRevokeApiKey')}
confirmButtonColor='error'
confirmButtonText={globalize.translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onConfirmDeleteCancel}
/>
<InputDialog
open={isCreateApiKeyPromptOpen}
title={globalize.translate('HeaderNewApiKey')}
label={globalize.translate('LabelAppName')}
helperText={globalize.translate('LabelAppNameExample')}
confirmButtonText={globalize.translate('Create')}
onConfirm={onConfirmCreate}
onClose={onCreateApiKeyPromptClose}
/>
<TablePage
id='apiKeysPage'
title={globalize.translate('HeaderApiKeys')}
subtitle={globalize.translate('HeaderApiKeysHelp')}
className='mainAnimatedPage type-interior'
table={table}
/>
</>
);
};

View File

@@ -0,0 +1,108 @@
import React, { useCallback, useMemo } from 'react';
import Page from 'components/Page';
import globalize from 'lib/globalize';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import { useVirtualFolders } from 'apps/dashboard/features/libraries/api/useVirtualFolders';
import useLiveTasks from 'apps/dashboard/features/tasks/hooks/useLiveTasks';
import { useStartTask } from 'apps/dashboard/features/tasks/api/useStartTask';
import TaskProgress from 'apps/dashboard/features/tasks/components/TaskProgress';
import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state';
import Grid from '@mui/material/Grid';
import LibraryCard from 'apps/dashboard/features/libraries/components/LibraryCard';
import Loading from 'components/loading/LoadingComponent';
import MediaLibraryCreator from 'components/mediaLibraryCreator/mediaLibraryCreator';
import getCollectionTypeOptions from 'apps/dashboard/features/libraries/utils/collectionTypeOptions';
import { queryClient } from 'utils/query/queryClient';
import RefreshIcon from '@mui/icons-material/Refresh';
import Add from '@mui/icons-material/Add';
export const Component = () => {
const { data: virtualFolders, isPending: isVirtualFoldersPending } = useVirtualFolders();
const startTask = useStartTask();
const { data: tasks, isPending: isLiveTasksPending } = useLiveTasks({ isHidden: false });
const librariesTask = useMemo(() => (
tasks?.find((value) => value.Key === 'RefreshLibrary')
), [ tasks ]);
const showMediaLibraryCreator = useCallback(() => {
const mediaLibraryCreator = new MediaLibraryCreator({
collectionTypeOptions: getCollectionTypeOptions(),
refresh: true
}) as Promise<boolean>;
void mediaLibraryCreator.then((hasChanges: boolean) => {
if (hasChanges) {
void queryClient.invalidateQueries({
queryKey: ['VirtualFolders']
});
}
});
}, []);
const onScanLibraries = useCallback(() => {
if (librariesTask?.Id) {
startTask.mutate({
taskId: librariesTask.Id
});
}
}, [ startTask, librariesTask ]);
if (isVirtualFoldersPending || isLiveTasksPending) return <Loading />;
return (
<Page
id='mediaLibraryPage'
title={globalize.translate('HeaderLibraries')}
className='mainAnimatedPage type-interior'
>
<Box className='content-primary'>
<Stack spacing={3} mt={2}>
<Stack direction='row' alignItems={'center'} spacing={1.5}>
<Button
startIcon={<Add />}
onClick={showMediaLibraryCreator}
>
{globalize.translate('ButtonAddMediaLibrary')}
</Button>
<Button
onClick={onScanLibraries}
startIcon={<RefreshIcon />}
loading={librariesTask && librariesTask.State !== TaskState.Idle}
loadingPosition='start'
variant='outlined'
>
{globalize.translate('ButtonScanAllLibraries')}
</Button>
{(librariesTask && librariesTask.State == TaskState.Running) && (
<TaskProgress task={librariesTask} />
)}
</Stack>
<Box>
<Grid container spacing={2}>
{virtualFolders?.map(virtualFolder => (
<Grid
key={virtualFolder?.ItemId}
item
xs={12}
sm={6}
md={3}
lg={2.4}
>
<LibraryCard
virtualFolder={virtualFolder}
/>
</Grid>
))}
</Grid>
</Box>
</Stack>
</Box>
</Page>
);
};
Component.displayName = 'LibrariesPage';

View File

@@ -0,0 +1,179 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Page from 'components/Page';
import { useNamedConfiguration } from 'hooks/useNamedConfiguration';
import type { LiveTvOptions } from '@jellyfin/sdk/lib/generated-client/models/live-tv-options';
import globalize from 'lib/globalize';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Loading from 'components/loading/LoadingComponent';
import TunerDeviceCard from 'apps/dashboard/features/livetv/components/TunerDeviceCard';
import useLiveTasks from 'apps/dashboard/features/tasks/hooks/useLiveTasks';
import Button from '@mui/material/Button';
import RefreshIcon from '@mui/icons-material/Refresh';
import AddIcon from '@mui/icons-material/Add';
import { Link, useNavigate } from 'react-router-dom';
import { useStartTask } from 'apps/dashboard/features/tasks/api/useStartTask';
import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state';
import TaskProgress from 'apps/dashboard/features/tasks/components/TaskProgress';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemText from '@mui/material/ListItemText';
import Alert from '@mui/material/Alert';
import List from '@mui/material/List';
import Provider from 'apps/dashboard/features/livetv/components/Provider';
import Grid from '@mui/material/Grid';
const CONFIG_KEY = 'livetv';
export const Component = () => {
const navigate = useNavigate();
const {
data: config,
isPending: isConfigPending,
isError: isConfigError
} = useNamedConfiguration<LiveTvOptions>(CONFIG_KEY);
const {
data: tasks,
isPending: isTasksPending,
isError: isTasksError
} = useLiveTasks({ isHidden: false });
const providerButtonRef = useRef<HTMLButtonElement | null>(null);
const [ anchorEl, setAnchorEl ] = useState<HTMLButtonElement | null>(null);
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
const startTask = useStartTask();
const navigateToSchedulesDirect = useCallback(() => {
navigate('/dashboard/livetv/guide?type=schedulesdirect');
}, [ navigate ]);
const navigateToXMLTV = useCallback(() => {
navigate('/dashboard/livetv/guide?type=xmltv');
}, [ navigate ]);
const showProviderMenu = useCallback(() => {
setAnchorEl(providerButtonRef.current);
setIsMenuOpen(true);
}, []);
const onMenuClose = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
}, []);
const refreshGuideTask = useMemo(() => (
tasks?.find((value) => value.Key === 'RefreshGuide')
), [ tasks ]);
const refreshGuideData = useCallback(() => {
if (refreshGuideTask?.Id) {
startTask.mutate({
taskId: refreshGuideTask.Id
});
}
}, [ startTask, refreshGuideTask ]);
if (isConfigPending || isTasksPending) return <Loading />;
return (
<Page
id='liveTvStatusPage'
title={globalize.translate('LiveTV')}
className='mainAnimatedPage type-interior'
>
<Box className='content-primary'>
{(isConfigError || isTasksError) ? (
<Alert severity='error'>{globalize.translate('HeaderError')}</Alert>
) : (
<Stack spacing={3}>
<Typography variant='h2'>{globalize.translate('HeaderTunerDevices')}</Typography>
<Button
sx={{ alignSelf: 'flex-start' }}
startIcon={<AddIcon />}
component={Link}
to='/dashboard/livetv/tuner'
>
{globalize.translate('ButtonAddTunerDevice')}
</Button>
<Box>
<Grid container spacing={2}>
{config.TunerHosts?.map(tunerHost => (
<Grid
key={tunerHost.Id}
item
xs={12}
sm={6}
md={3}
lg={2.4}
>
<TunerDeviceCard
key={tunerHost.Id}
tunerHost={tunerHost}
/>
</Grid>
))}
</Grid>
</Box>
<Typography variant='h2'>{globalize.translate('HeaderGuideProviders')}</Typography>
<Stack sx={{ alignSelf: 'flex-start' }} spacing={2}>
<Stack direction='row' spacing={1.5}>
<Button
sx={{ alignSelf: 'flex-start' }}
startIcon={<AddIcon />}
onClick={showProviderMenu}
ref={providerButtonRef}
>
{globalize.translate('ButtonAddProvider')}
</Button>
<Button
sx={{ alignSelf: 'flex-start' }}
startIcon={<RefreshIcon />}
variant='outlined'
onClick={refreshGuideData}
loading={refreshGuideTask && refreshGuideTask.State === TaskState.Running}
loadingPosition='start'
>
{globalize.translate('ButtonRefreshGuideData')}
</Button>
</Stack>
{(refreshGuideTask && refreshGuideTask.State === TaskState.Running) && (
<TaskProgress task={refreshGuideTask} />
)}
</Stack>
<Menu
anchorEl={anchorEl}
open={isMenuOpen}
onClose={onMenuClose}
>
<MenuItem onClick={navigateToSchedulesDirect}>
<ListItemText>Schedules Direct</ListItemText>
</MenuItem>
<MenuItem onClick={navigateToXMLTV}>
<ListItemText>XMLTV</ListItemText>
</MenuItem>
</Menu>
{(config.ListingProviders && config.ListingProviders?.length > 0) && (
<List sx={{ backgroundColor: 'background.paper' }}>
{config.ListingProviders?.map(provider => (
<Provider
key={provider.Id}
provider={provider}
/>
))}
</List>
)}
</Stack>
)}
</Box>
</Page>
);
};
Component.displayName = 'LiveTvPage';

View File

@@ -1,6 +1,6 @@
import Loading from 'components/loading/LoadingComponent';
import Page from 'components/Page';
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useServerLog } from 'apps/dashboard/features/logs/api/useServerLog';
import Alert from '@mui/material/Alert';
@@ -13,8 +13,8 @@ import Typography from '@mui/material/Typography';
import ContentCopy from '@mui/icons-material/ContentCopy';
import FileDownload from '@mui/icons-material/FileDownload';
import globalize from 'lib/globalize';
import toast from 'components/toast/toast';
import { copy } from 'scripts/clipboard';
import Toast from 'apps/dashboard/components/Toast';
export const Component = () => {
const { file: fileName } = useParams();
@@ -24,13 +24,18 @@ export const Component = () => {
data: log,
refetch
} = useServerLog(fileName ?? '');
const [ isCopiedToastOpen, setIsCopiedToastOpen ] = useState(false);
const retry = useCallback(() => refetch(), [refetch]);
const handleToastClose = useCallback(() => {
setIsCopiedToastOpen(false);
}, []);
const copyToClipboard = useCallback(async () => {
if (log) {
await copy(log);
toast({ text: globalize.translate('CopyLogSuccess') });
setIsCopiedToastOpen(true);
}
}, [log]);
@@ -52,7 +57,12 @@ export const Component = () => {
title={fileName}
className='mainAnimatedPage type-interior'
>
<Container className='content-primary'>
<Toast
open={isCopiedToastOpen}
onClose={handleToastClose}
message={globalize.translate('CopyLogSuccess')}
/>
<Container className='content-primary' maxWidth={false}>
<Box>
<Typography variant='h1'>{fileName}</Typography>
@@ -96,7 +106,14 @@ export const Component = () => {
<Paper sx={{ mt: 2 }}>
<code>
<pre style={{ overflow:'auto', margin: 0, padding: '16px' }}>{log}</pre>
<pre style={{
overflow:'auto',
margin: 0,
padding: '16px',
whiteSpace: 'pre-wrap'
}}>
{log}
</pre>
</code>
</Paper>
</>

View File

@@ -471,19 +471,21 @@ export const Component = () => {
{(hardwareAccelType === 'none' || isHwaSelected) && (
<>
<FormControl>
<FormControlLabel
label={globalize.translate('EnableTonemapping')}
control={
<Checkbox
name='EnableTonemapping'
checked={config.EnableTonemapping}
onChange={onCheckboxChange}
/>
}
/>
<FormHelperText>{globalize.translate(isHwaSelected ? 'AllowTonemappingHelp' : 'AllowTonemappingSoftwareHelp')}</FormHelperText>
</FormControl>
{isHwaSelected && (
<FormControl>
<FormControlLabel
label={globalize.translate('EnableTonemapping')}
control={
<Checkbox
name='EnableTonemapping'
checked={config.EnableTonemapping}
onChange={onCheckboxChange}
/>
}
/>
<FormHelperText>{globalize.translate('AllowTonemappingHelp')}</FormHelperText>
</FormControl>
)}
<TextField
name='TonemappingAlgorithm'

View File

@@ -98,164 +98,166 @@ export const Component = () => {
className='type-interior mainAnimatedPage'
>
<Box className='content-primary'>
{isError ? (
<Alert
severity='error'
sx={{ marginBottom: 2 }}
<Stack spacing={2}>
<Stack
direction='row'
sx={{
flexWrap: {
xs: 'wrap',
sm: 'nowrap'
}
}}
>
{globalize.translate('PluginsLoadError')}
</Alert>
) : (
<Stack spacing={2}>
<Stack
direction='row'
<Typography
variant='h1'
component='span'
sx={{
flexWrap: {
xs: 'wrap',
sm: 'nowrap'
flexGrow: 1,
verticalAlign: 'middle'
}}
>
{globalize.translate('TabPlugins')}
</Typography>
<Button
component={Link}
to='/dashboard/plugins/repositories'
variant='outlined'
sx={{
marginLeft: 2
}}
>
{globalize.translate('ManageRepositories')}
</Button>
<Box
sx={{
display: 'flex',
justifyContent: 'end',
marginTop: {
xs: 2,
sm: 0
},
marginLeft: {
xs: 0,
sm: 2
},
width: {
xs: '100%',
sm: 'auto'
}
}}
>
<Typography
variant='h1'
component='span'
sx={{
flexGrow: 1,
verticalAlign: 'middle'
}}
>
{globalize.translate('TabPlugins')}
</Typography>
<Button
component={Link}
to='/dashboard/plugins/repositories'
variant='outlined'
sx={{
marginLeft: 2
}}
>
{globalize.translate('ManageRepositories')}
</Button>
<Box
sx={{
display: 'flex',
justifyContent: 'end',
marginTop: {
xs: 2,
sm: 0
},
marginLeft: {
xs: 0,
sm: 2
},
width: {
xs: '100%',
sm: 'auto'
}
}}
>
<SearchInput
label={globalize.translate('Search')}
value={searchQuery}
onChange={onSearchChange}
/>
</Box>
</Stack>
<Box>
<Stack
direction='row'
spacing={1}
sx={{
marginLeft: '-1rem',
marginRight: '-1rem',
paddingLeft: '1rem',
paddingRight: '1rem',
paddingBottom: {
xs: 1,
md: 0.5
},
overflowX: 'auto'
}}
>
<Chip
color={status === PluginStatusOption.All ? 'primary' : undefined}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setStatus(PluginStatusOption.All)}
label={globalize.translate('All')}
/>
<Chip
color={status === PluginStatusOption.Available ? 'primary' : undefined}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setStatus(PluginStatusOption.Available)}
label={globalize.translate('LabelAvailable')}
/>
<Chip
color={status === PluginStatusOption.Installed ? 'primary' : undefined}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setStatus(PluginStatusOption.Installed)}
label={globalize.translate('LabelInstalled')}
/>
<Divider orientation='vertical' flexItem />
<Chip
color={!category ? 'primary' : undefined}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setCategory('')}
label={globalize.translate('All')}
/>
{Object.values(PluginCategory).map(c => (
<Chip
key={c}
color={category === c.toLowerCase() ? 'primary' : undefined}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setCategory(c.toLowerCase())}
label={globalize.translate(CATEGORY_LABELS[c as PluginCategory])}
/>
))}
</Stack>
<Divider />
</Box>
<Box>
{filteredPlugins.length > 0 ? (
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
// eslint-disable-next-line @typescript-eslint/no-deprecated
<Grid container spacing={2}>
{filteredPlugins.map(plugin => (
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
// eslint-disable-next-line @typescript-eslint/no-deprecated
<Grid
key={plugin.id}
item
xs={12}
sm={6}
md={4}
lg={3}
xl={2}
>
<PluginCard
plugin={plugin}
/>
</Grid>
))}
</Grid>
) : (
<NoPluginResults
isFiltered={!!category || status !== PluginStatusOption.All}
onViewAll={onViewAll}
query={searchQuery}
/>
)}
<SearchInput
label={globalize.translate('Search')}
value={searchQuery}
onChange={onSearchChange}
/>
</Box>
</Stack>
)}
{isError ? (
<Alert
severity='error'
sx={{ marginBottom: 2 }}
>
{globalize.translate('PluginsLoadError')}
</Alert>
) : (
<>
<Box>
<Stack
direction='row'
spacing={1}
sx={{
marginLeft: '-1rem',
marginRight: '-1rem',
paddingLeft: '1rem',
paddingRight: '1rem',
paddingBottom: {
xs: 1,
md: 0.5
},
overflowX: 'auto'
}}
>
<Chip
color={status === PluginStatusOption.All ? 'primary' : undefined}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setStatus(PluginStatusOption.All)}
label={globalize.translate('All')}
/>
<Chip
color={status === PluginStatusOption.Available ? 'primary' : undefined}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setStatus(PluginStatusOption.Available)}
label={globalize.translate('LabelAvailable')}
/>
<Chip
color={status === PluginStatusOption.Installed ? 'primary' : undefined}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setStatus(PluginStatusOption.Installed)}
label={globalize.translate('LabelInstalled')}
/>
<Divider orientation='vertical' flexItem />
<Chip
color={!category ? 'primary' : undefined}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setCategory('')}
label={globalize.translate('All')}
/>
{Object.values(PluginCategory).map(c => (
<Chip
key={c}
color={category === c.toLowerCase() ? 'primary' : undefined}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setCategory(c.toLowerCase())}
label={globalize.translate(CATEGORY_LABELS[c as PluginCategory])}
/>
))}
</Stack>
<Divider />
</Box>
<Box>
{filteredPlugins.length > 0 ? (
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
// eslint-disable-next-line @typescript-eslint/no-deprecated
<Grid container spacing={2}>
{filteredPlugins.map(plugin => (
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
// eslint-disable-next-line @typescript-eslint/no-deprecated
<Grid
key={plugin.id}
item
xs={12}
sm={6}
md={4}
lg={3}
xl={2}
>
<PluginCard
plugin={plugin}
/>
</Grid>
))}
</Grid>
) : (
<NoPluginResults
isFiltered={!!category || status !== PluginStatusOption.All}
onViewAll={onViewAll}
query={searchQuery}
/>
)}
</Box>
</>
)}
</Stack>
</Box>
</Page>
);

View File

@@ -7,10 +7,11 @@ import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AddIcon from '@mui/icons-material/Add';
import IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip';
import RemoveCircleIcon from '@mui/icons-material/RemoveCircle';
import Loading from 'components/loading/LoadingComponent';
import { MRT_ColumnDef, MRT_Table, useMaterialReactTable } from 'material-react-table';
import { type MRT_ColumnDef, MRT_Table, type MRT_Theme, useMaterialReactTable } from 'material-react-table';
import type { TaskTriggerInfo } from '@jellyfin/sdk/lib/generated-client/models/task-trigger-info';
import globalize from '../../../../lib/globalize';
import { useTask } from 'apps/dashboard/features/tasks/api/useTask';
@@ -26,6 +27,7 @@ export const Component = () => {
const [ isAddTriggerDialogOpen, setIsAddTriggerDialogOpen ] = useState(false);
const [ isRemoveConfirmOpen, setIsRemoveConfirmOpen ] = useState(false);
const [ pendingDeleteTrigger, setPendingDeleteTrigger ] = useState<TaskTriggerInfo | null>(null);
const theme = useTheme();
const onCloseRemoveConfirmDialog = useCallback(() => {
setPendingDeleteTrigger(null);
@@ -80,7 +82,15 @@ export const Component = () => {
}
], []);
// NOTE: We need to provide a custom theme due to a MRT bug causing the initial theme to always be used
// https://github.com/KevinVandy/material-react-table/issues/1429
const mrtTheme = useMemo<Partial<MRT_Theme>>(() => ({
baseBackgroundColor: theme.palette.background.paper
}), [ theme ]);
const table = useMaterialReactTable({
mrtTheme,
columns,
data: task?.Triggers || [],

View File

@@ -4,13 +4,13 @@ import { useSearchParams } from 'react-router-dom';
import loading from '../../../../components/loading/loading';
import globalize from '../../../../lib/globalize';
import toast from '../../../../components/toast/toast';
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
import Button from '../../../../elements/emby-button/Button';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import AccessContainer from '../../../../components/dashboard/users/AccessContainer';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
import Page from '../../../../components/Page';
import Toast from 'apps/dashboard/components/Toast';
type ItemsArr = {
Name?: string | null;
@@ -23,6 +23,7 @@ type ItemsArr = {
const UserLibraryAccess = () => {
const [ searchParams ] = useSearchParams();
const userId = searchParams.get('userId');
const [ isSettingsSavedToastOpen, setIsSettingsSavedToastOpen ] = useState(false);
const [ userName, setUserName ] = useState('');
const [channelsItems, setChannelsItems] = useState<ItemsArr[]>([]);
const [mediaFoldersItems, setMediaFoldersItems] = useState<ItemsArr[]>([]);
@@ -31,6 +32,10 @@ const UserLibraryAccess = () => {
const element = useRef<HTMLDivElement>(null);
const handleToastClose = useCallback(() => {
setIsSettingsSavedToastOpen(false);
}, []);
const triggerChange = (select: HTMLInputElement) => {
const evt = new Event('change', { bubbles: false, cancelable: true });
select.dispatchEvent(evt);
@@ -220,7 +225,7 @@ const UserLibraryAccess = () => {
const onSaveComplete = () => {
loading.hide();
toast(globalize.translate('SettingsSaved'));
setIsSettingsSavedToastOpen(true);
};
(page.querySelector('.chkEnableAllDevices') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
@@ -243,6 +248,11 @@ const UserLibraryAccess = () => {
id='userLibraryAccessPage'
className='mainAnimatedPage type-interior'
>
<Toast
open={isSettingsSavedToastOpen}
onClose={handleToastClose}
message={globalize.translate('SettingsSaved')}
/>
<div ref={element} className='content-primary'>
<div className='verticalSection'>
<SectionTitleContainer

View File

@@ -4,13 +4,13 @@ import React, { useCallback, useEffect, useState, useRef } from 'react';
import Dashboard from '../../../../utils/dashboard';
import globalize from '../../../../lib/globalize';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import Input from '../../../../elements/emby-input/Input';
import Button from '../../../../elements/emby-button/Button';
import AccessContainer from '../../../../components/dashboard/users/AccessContainer';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
import Page from '../../../../components/Page';
import Toast from 'apps/dashboard/components/Toast';
type UserInput = {
Name?: string;
@@ -25,8 +25,13 @@ type ItemsArr = {
const UserNew = () => {
const [ channelsItems, setChannelsItems ] = useState<ItemsArr[]>([]);
const [ mediaFoldersItems, setMediaFoldersItems ] = useState<ItemsArr[]>([]);
const [ isErrorToastOpen, setIsErrorToastOpen ] = useState(false);
const element = useRef<HTMLDivElement>(null);
const handleToastClose = useCallback(() => {
setIsErrorToastOpen(false);
}, []);
const getItemsResult = (items: BaseItemDto[]) => {
return items.map(item =>
({
@@ -150,7 +155,7 @@ const UserNew = () => {
console.error('[usernew] failed to update user policy', err);
});
}, function () {
toast(globalize.translate('ErrorDefault'));
setIsErrorToastOpen(true);
loading.hide();
});
};
@@ -185,6 +190,11 @@ const UserNew = () => {
id='newUserPage'
className='mainAnimatedPage type-interior'
>
<Toast
open={isErrorToastOpen}
onClose={handleToastClose}
message={globalize.translate('ErrorDefault')}
/>
<div ref={element} className='content-primary'>
<div className='verticalSection'>
<SectionTitleContainer

View File

@@ -1,5 +1,5 @@
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import Dashboard from '../../../../utils/dashboard';
import globalize from '../../../../lib/globalize';
@@ -14,6 +14,8 @@ import '../../../../components/cardbuilder/card.scss';
import '../../../../components/indicators/indicators.scss';
import '../../../../styles/flexstyles.scss';
import Page from '../../../../components/Page';
import { useLocation } from 'react-router-dom';
import Toast from 'apps/dashboard/components/Toast';
type MenuEntry = {
name?: string;
@@ -22,10 +24,16 @@ type MenuEntry = {
};
const UserProfiles = () => {
const location = useLocation();
const [ isSettingsSavedToastOpen, setIsSettingsSavedToastOpen ] = useState(false);
const [ users, setUsers ] = useState<UserDto[]>([]);
const element = useRef<HTMLDivElement>(null);
const handleToastClose = useCallback(() => {
setIsSettingsSavedToastOpen(false);
}, []);
const loadData = () => {
loading.show();
window.ApiClient.getUsers().then(function (result) {
@@ -39,6 +47,11 @@ const UserProfiles = () => {
useEffect(() => {
const page = element.current;
if (location.state?.openSavedToast) {
setIsSettingsSavedToastOpen(true);
window.history.replaceState({}, '');
}
if (!page) {
console.error('Unexpected null reference');
return;
@@ -161,6 +174,11 @@ const UserProfiles = () => {
className='mainAnimatedPage type-interior userProfilesPage fullWidthContent'
title={globalize.translate('HeaderUsers')}
>
<Toast
open={isSettingsSavedToastOpen}
onClose={handleToastClose}
message={globalize.translate('SettingsSaved')}
/>
<div ref={element} className='content-primary'>
<div className='verticalSection'>
<SectionTitleContainer

View File

@@ -12,12 +12,12 @@ import Button from '../../../../elements/emby-button/Button';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
import SelectElement from '../../../../elements/SelectElement';
import Page from '../../../../components/Page';
import prompt from '../../../../components/prompt/prompt';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import Toast from 'apps/dashboard/components/Toast';
type NamedItem = {
name: string;
@@ -30,6 +30,7 @@ type UnratedNamedItem = NamedItem & {
function handleSaveUser(
page: HTMLDivElement,
parentalRatingsRef: React.MutableRefObject<ParentalRating[]>,
getSchedulesFromPage: () => AccessSchedule[],
getAllowedTagsFromPage: () => string[],
getBlockedTagsFromPage: () => string[],
@@ -42,8 +43,12 @@ function handleSaveUser(
throw new Error('Unexpected null user id or policy');
}
const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
userPolicy.MaxParentalRating = Number.isNaN(parentalRating) ? null : parentalRating;
const parentalRatingIndex = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
const parentalRating = parentalRatingsRef.current[parentalRatingIndex] as ParentalRating;
const score = parentalRating?.RatingScore?.score;
const subScore = parentalRating?.RatingScore?.subScore;
userPolicy.MaxParentalRating = Number.isNaN(score) ? null : score;
userPolicy.MaxParentalSubRating = Number.isNaN(subScore) ? null : subScore;
userPolicy.BlockUnratedItems = Array.prototype.filter
.call(page.querySelectorAll('.chkUnratedItem'), i => i.checked)
.map(i => i.getAttribute('data-itemtype'));
@@ -69,33 +74,14 @@ const UserParentalControl = () => {
const [ accessSchedules, setAccessSchedules ] = useState<AccessSchedule[]>([]);
const [ allowedTags, setAllowedTags ] = useState<string[]>([]);
const [ blockedTags, setBlockedTags ] = useState<string[]>([]);
const [ isSettingsSavedToastOpen, setIsSettingsSavedToastOpen ] = useState(false);
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
const element = useRef<HTMLDivElement>(null);
const parentalRatingsRef = useRef<ParentalRating[]>([]);
const populateRatings = useCallback((allParentalRatings: ParentalRating[]) => {
let rating;
const ratings: ParentalRating[] = [];
for (let i = 0, length = allParentalRatings.length; i < length; i++) {
rating = allParentalRatings[i];
if (ratings.length) {
const lastRating = ratings[ratings.length - 1];
if (lastRating.Value === rating.Value) {
lastRating.Name += '/' + rating.Name;
continue;
}
}
ratings.push({
Name: rating.Name,
Value: rating.Value
});
}
setParentalRatings(ratings);
const handleToastClose = useCallback(() => {
setIsSettingsSavedToastOpen(false);
}, []);
const loadUnratedItems = useCallback((user: UserDto) => {
@@ -161,16 +147,52 @@ const UserParentalControl = () => {
setAllowedTags(user.Policy?.AllowedTags || []);
setBlockedTags(user.Policy?.BlockedTags || []);
populateRatings(allParentalRatings);
let ratingValue = '';
allParentalRatings.forEach(rating => {
if (rating.Value != null && user.Policy?.MaxParentalRating != null && user.Policy.MaxParentalRating >= rating.Value) {
ratingValue = `${rating.Value}`;
// Build the grouped ratings array
const ratings: ParentalRating[] = [];
for (let i = 0, length = allParentalRatings.length; i < length; i++) {
const rating = allParentalRatings[i];
if (ratings.length) {
const lastRating = ratings[ratings.length - 1];
if (lastRating.RatingScore?.score === rating.RatingScore?.score && lastRating.RatingScore?.subScore == rating.RatingScore?.subScore) {
lastRating.Name += '/' + rating.Name;
continue;
}
}
});
setMaxParentalRating(ratingValue);
ratings.push(rating);
}
setParentalRatings(ratings);
parentalRatingsRef.current = ratings;
// Find matching rating - first try exact match with score and subscore
let ratingIndex = '';
const userMaxRating = user.Policy?.MaxParentalRating;
const userMaxSubRating = user.Policy?.MaxParentalSubRating;
if (userMaxRating != null) {
// First try to find exact match with both score and subscore
ratings.forEach((rating, index) => {
if (rating.RatingScore?.score === userMaxRating
&& rating.RatingScore?.subScore === userMaxSubRating) {
ratingIndex = `${index}`;
}
});
// If no exact match found, fallback to score-only match
if (!ratingIndex) {
ratings.forEach((rating, index) => {
if (rating.RatingScore?.score != null
&& rating.RatingScore.score <= userMaxRating) {
ratingIndex = `${index}`;
}
});
}
}
setMaxParentalRating(ratingIndex);
if (user.Policy?.IsAdministrator) {
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.add('hide');
@@ -179,7 +201,7 @@ const UserParentalControl = () => {
}
setAccessSchedules(user.Policy?.AccessSchedules || []);
loading.hide();
}, [libraryMenu, setAllowedTags, setBlockedTags, loadUnratedItems, populateRatings]);
}, [libraryMenu, setAllowedTags, setBlockedTags, loadUnratedItems]);
const loadData = useCallback(() => {
if (!userId) {
@@ -283,10 +305,10 @@ const UserParentalControl = () => {
const onSaveComplete = () => {
loading.hide();
toast(globalize.translate('SettingsSaved'));
setIsSettingsSavedToastOpen(true);
};
const saveUser = handleSaveUser(page, getSchedulesFromPage, getAllowedTagsFromPage, getBlockedTagsFromPage, onSaveComplete);
const saveUser = handleSaveUser(page, parentalRatingsRef, getSchedulesFromPage, getAllowedTagsFromPage, getBlockedTagsFromPage, onSaveComplete);
const onSubmit = (e: Event) => {
if (!userId) {
@@ -342,11 +364,11 @@ const UserParentalControl = () => {
const optionMaxParentalRating = () => {
let content = '';
content += '<option value=\'\'></option>';
for (const rating of parentalRatings) {
if (rating.Value != null) {
content += `<option value='${rating.Value}'>${escapeHTML(rating.Name)}</option>`;
parentalRatings.forEach((rating, index) => {
if (rating.RatingScore != null) {
content += `<option value='${index}'>${escapeHTML(rating.Name)}</option>`;
}
}
});
return content;
};
@@ -370,6 +392,11 @@ const UserParentalControl = () => {
id='userParentalControlPage'
className='mainAnimatedPage type-interior'
>
<Toast
open={isSettingsSavedToastOpen}
onClose={handleToastClose}
message={globalize.translate('SettingsSaved')}
/>
<div ref={element} className='content-primary'>
<div className='verticalSection'>
<SectionTitleContainer

View File

@@ -1,9 +1,8 @@
import type { BaseItemDto, NameIdPair, SyncPlayUserAccessType, UserDto } from '@jellyfin/sdk/lib/generated-client';
import escapeHTML from 'escape-html';
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import Dashboard from '../../../../utils/dashboard';
import globalize from '../../../../lib/globalize';
import Button from '../../../../elements/emby-button/Button';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
@@ -12,7 +11,6 @@ import Input from '../../../../elements/emby-input/Input';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import SelectElement from '../../../../elements/SelectElement';
import Page from '../../../../components/Page';
@@ -25,16 +23,8 @@ const getCheckedElementDataIds = (elements: NodeListOf<Element>) => (
.map(e => e.getAttribute('data-id'))
);
function onSaveComplete() {
Dashboard.navigate('/dashboard/users')
.catch(err => {
console.error('[useredit] failed to navigate to user profile', err);
});
loading.hide();
toast(globalize.translate('SettingsSaved'));
}
const UserEdit = () => {
const navigate = useNavigate();
const [ searchParams ] = useSearchParams();
const userId = searchParams.get('userId');
const [ userDto, setUserDto ] = useState<UserDto>();
@@ -228,7 +218,10 @@ const UserEdit = () => {
window.ApiClient.updateUser(user).then(() => (
window.ApiClient.updateUserPolicy(user.Id || '', user.Policy || { PasswordResetProviderId: '', AuthenticationProviderId: '' })
)).then(() => {
onSaveComplete();
navigate('/dashboard/users', {
state: { openSavedToast: true }
});
loading.hide();
}).catch(err => {
console.error('[useredit] failed to update user', err);
});

View File

@@ -27,7 +27,18 @@ $mui-bp-xl: 1536px;
padding-top: 3.25rem !important;
}
// Fix backdrop position on mobile item details page
.layout-mobile .itemBackdrop {
margin-top: 0 !important;
.layout-mobile {
.itemBackdrop {
// Fix backdrop position on mobile item details page
margin-top: 0 !important;
// Add a subtle gradient over the backdrop to ensure the app bar buttons are visible
&::before {
display: block;
content: "";
height: 100%;
width: 100%;
background: linear-gradient(180deg, rgba(32, 32, 32, 0.6) 0%, rgba(32, 32, 32, 0.2) 4rem, rgba(0, 0, 0, 0) 50%);
}
}
}

View File

@@ -4,7 +4,7 @@ import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Cast from '@mui/icons-material/Cast';
import IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles';
import type {} from '@mui/material/themeCssVarsAugmentation';
import Tooltip from '@mui/material/Tooltip';
import { playbackManager } from 'components/playback/playbackmanager';
@@ -15,7 +15,6 @@ import RemotePlayMenu, { ID } from './menus/RemotePlayMenu';
import RemotePlayActiveMenu, { ID as ACTIVE_ID } from './menus/RemotePlayActiveMenu';
const RemotePlayButton = () => {
const theme = useTheme();
const [ playerInfo, setPlayerInfo ] = useState(playbackManager.getPlayerInfo());
const updatePlayerInfo = useCallback(() => {
@@ -70,9 +69,10 @@ const RemotePlayButton = () => {
aria-haspopup='true'
onClick={onRemotePlayActiveButtonClick}
color='inherit'
sx={{
color: theme.palette.primary.main
}}
// eslint-disable-next-line react/jsx-no-bind
sx={(theme) => ({
color: theme.vars.palette.primary.main
})}
>
{playerInfo.deviceName || playerInfo.name}
</Button>

View File

@@ -2,6 +2,7 @@ import Stack from '@mui/material/Stack';
import React, { type FC } from 'react';
import { useLocation } from 'react-router-dom';
import { appRouter, PUBLIC_PATHS } from 'components/router/appRouter';
import AppToolbar from 'components/toolbar/AppToolbar';
import ServerButton from 'components/toolbar/ServerButton';
@@ -16,14 +17,6 @@ interface AppToolbarProps {
onDrawerButtonClick: (event: React.MouseEvent<HTMLElement>) => void
}
const PUBLIC_PATHS = [
'/addserver',
'/selectserver',
'/login',
'/forgotpassword',
'/forgotpasswordpin'
];
const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
isDrawerAvailable,
isDrawerOpen,
@@ -34,6 +27,10 @@ const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
// The video osd does not show the standard toolbar
if (location.pathname === '/video') return null;
// Only show the back button in apps when appropriate
const isBackButtonAvailable = window.NativeShell && appRouter.canGoBack(location.pathname);
// Check if the current path is a public path to hide user content
const isPublicPath = PUBLIC_PATHS.includes(location.pathname);
return (
@@ -48,6 +45,7 @@ const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
isDrawerAvailable={isDrawerAvailable}
isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onDrawerButtonClick}
isBackButtonAvailable={isBackButtonAvailable}
isUserMenuAvailable={!isPublicPath}
>
{!isDrawerAvailable && (

View File

@@ -221,9 +221,7 @@ const ItemsView: FC<ItemsViewProps> = ({
const hasFilters = Object.values(libraryViewSettings.Filters ?? {}).some(
(filter) => !!filter
);
const hasSortName = libraryViewSettings.SortBy.includes(
ItemSortBy.SortName
);
const hasSortName = libraryViewSettings.SortBy !== ItemSortBy.Random;
const itemsContainerClass = classNames(
'centered padded-left padded-right padded-right-withalphapicker',

View File

@@ -22,6 +22,14 @@ type SortOption = {
type SortOptionsMapping = Record<string, SortOption[]>;
const collectionMovieOptions: SortOption[] = [
{ label: 'Name', value: ItemSortBy.SortName },
{ label: 'OptionCommunityRating', value: ItemSortBy.CommunityRating },
{ label: 'OptionDateAdded', value: ItemSortBy.DateCreated },
{ label: 'OptionParentalRating', value: ItemSortBy.OfficialRating },
{ label: 'OptionReleaseDate', value: ItemSortBy.PremiereDate }
];
const movieOrFavoriteOptions = [
{ label: 'Name', value: ItemSortBy.SortName },
{ label: 'OptionRandom', value: ItemSortBy.Random },
@@ -43,6 +51,7 @@ const photosOrPhotoAlbumsOptions = [
const sortOptionsMapping: SortOptionsMapping = {
[LibraryTab.Movies]: movieOrFavoriteOptions,
[LibraryTab.Collections]: collectionMovieOptions,
[LibraryTab.Favorites]: movieOrFavoriteOptions,
[LibraryTab.Series]: [
{ label: 'Name', value: ItemSortBy.SortName },

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,7 @@ async function loadDisplaySettings({
maxDaysForNextUp: settings.maxDaysForNextUp(),
screensaver: settings.screensaver() || 'none',
screensaverInterval: settings.backdropScreensaverInterval(),
theme: settings.theme()
theme: settings.theme() || defaultTheme?.id || FALLBACK_THEME_ID
};
return {
@@ -125,7 +131,7 @@ async function saveDisplaySettings({
userSettings.language(normalizeValue(newDisplaySettings.language));
}
userSettings.customCss(normalizeValue(newDisplaySettings.customCss));
userSettings.dashboardTheme(normalizeValue(newDisplaySettings.dashboardTheme));
userSettings.dashboardTheme(newDisplaySettings.dashboardTheme);
userSettings.dateTimeLocale(normalizeValue(newDisplaySettings.dateTimeLocale));
userSettings.disableCustomCss(newDisplaySettings.disableCustomCss);
userSettings.enableBlurhash(newDisplaySettings.enableBlurHash);

View File

@@ -5,7 +5,7 @@ import globalize from '../../../lib/globalize';
import { clearBackdrop } from '../../../components/backdrop/backdrop';
import layoutManager from '../../../components/layoutManager';
import Page from '../../../components/Page';
import { EventType } from 'types/eventType';
import { EventType } from 'constants/eventType';
import Events from 'utils/events';
import '../../../elements/emby-tabs/emby-tabs';

View File

@@ -11,6 +11,7 @@ import { MovieSuggestionsSectionsView } from 'types/sections';
const moviesTabContent: LibraryTabContent = {
viewType: LibraryTab.Movies,
collectionType: CollectionType.Movies,
isBtnPlayAllEnabled: true,
isBtnShuffleEnabled: true,
itemType: [BaseItemKind.Movie]
};
@@ -20,7 +21,6 @@ const collectionsTabContent: LibraryTabContent = {
collectionType: CollectionType.Movies,
isBtnFilterEnabled: false,
isBtnNewCollectionEnabled: true,
isAlphabetPickerEnabled: false,
itemType: [BaseItemKind.BoxSet],
noItemsMessage: 'MessageNoCollectionsAvailable'
};

View File

@@ -6,7 +6,7 @@ import RemotePlayButton from 'apps/experimental/components/AppToolbar/RemotePlay
import SyncPlayButton from 'apps/experimental/components/AppToolbar/SyncPlayButton';
import AppToolbar from 'components/toolbar/AppToolbar';
import ViewManagerPage from 'components/viewManager/ViewManagerPage';
import { EventType } from 'types/eventType';
import { EventType } from 'constants/eventType';
import Events, { type Event } from 'utils/events';
/**

View File

@@ -0,0 +1,13 @@
/**
* Options specifying if the player's native subtitle (cue) element should be used, a custom element (div), or allow
* Jellyfin to choose automatically based on known browser support. Some browsers do not properly apply CSS styling to
* the native subtitle element.
*/
export const SubtitleStylingOption = {
Auto: 'Auto',
Custom: 'Custom',
Native: 'Native'
} as const;
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type SubtitleStylingOption = typeof SubtitleStylingOption[keyof typeof SubtitleStylingOption];

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

@@ -0,0 +1,43 @@
import { SubtitleStylingOption } from 'apps/stable/features/playback/constants/subtitleStylingOption';
import browser from 'scripts/browser';
import type { UserSettings } from 'scripts/settings/userSettings';
// TODO: This type override should be removed when userSettings are properly typed
interface SubtitleAppearanceSettings {
subtitleStyling: SubtitleStylingOption
}
export function useCustomSubtitles(userSettings: UserSettings) {
const subtitleAppearance = userSettings.getSubtitleAppearanceSettings() as SubtitleAppearanceSettings;
switch (subtitleAppearance.subtitleStyling) {
case SubtitleStylingOption.Native:
return false;
case SubtitleStylingOption.Custom:
return true;
default:
// after a system update, ps4 isn't showing anything when creating a track element dynamically
// going to have to do it ourselves
if (browser.ps4) {
return true;
}
// Tizen 5 doesn't support displaying secondary subtitles
if ((browser.tizenVersion && browser.tizenVersion >= 5) || browser.web0s) {
return true;
}
if (browser.edge) {
return true;
}
// font-size styling does not seem to work natively in firefox. Switching to custom subtitles element for firefox.
if (browser.firefox) {
return true;
}
// iOS/macOS global caption settings are causing huge font-size and margins
if (browser.safari) return true;
return false;
}
}

View File

@@ -354,7 +354,7 @@ function getVirtualFolderHtml(page, virtualFolder, index) {
html += '</div>';
} else if (virtualFolder.Locations.length && virtualFolder.Locations.length === 1) {
html += "<div class='cardText cardText-secondary' dir='ltr' style='text-align:left;'>";
html += virtualFolder.Locations[0];
html += escapeHtml(virtualFolder.Locations[0]);
html += '</div>';
} else {
html += "<div class='cardText cardText-secondary'>";

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Amazon Fire TV</title><path d="M20.196 15.12c.265.337-.294 1.73-.542 2.353-.077.19.085.266.257.123 1.106-.926 1.39-2.867 1.166-3.149-.226-.277-2.16-.516-3.341.314-.183.127-.151.304.05.279.665-.08 2.147-.257 2.41.08m-.858.981c-2.064 1.523-5.056 2.333-7.632 2.333-3.611 0-6.862-1.334-9.322-3.555-.194-.176-.02-.414.21-.28 2.655 1.545 5.939 2.477 9.328 2.477 2.287 0 4.803-.476 7.115-1.458.348-.147.642.231.3.483m2.034-3.155a.388.388 0 0 1-.201-.04c-.041-.026-.087-.1-.133-.225l-1.734-4.355a1.79 1.79 0 0 0-.046-.117.266.266 0 0 1-.023-.108c0-.084.049-.128.146-.128h.58c.098 0 .165.014.205.04.04.026.082.102.127.226l1.344 3.823 1.343-3.823c.046-.124.089-.2.128-.226a.402.402 0 0 1 .205-.04h.54c.1 0 .148.044.148.128a.3.3 0 0 1-.025.108c-.016.04-.032.078-.044.117l-1.727 4.355c-.045.124-.09.199-.132.225a.388.388 0 0 1-.201.04zm-3.644.068c-.929 0-1.392-.463-1.392-1.392V8.739h-.706c-.13 0-.197-.066-.197-.196v-.246a.22.22 0 0 1 .045-.147c.03-.031.086-.055.171-.067l.717-.09.127-1.215c.013-.13.082-.196.207-.196h.41c.13 0 .196.066.196.196v1.196h1.276c.13 0 .195.065.195.197v.372c0 .13-.064.196-.195.196h-1.276v2.834c0 .243.055.411.162.51.108.098.293.147.555.147.124 0 .277-.016.46-.049.099-.02.164-.03.197-.03.052 0 .088.014.108.044.02.03.029.077.029.142v.266a.366.366 0 0 1-.04.19c-.026.043-.078.078-.157.103a3.018 3.018 0 0 1-.892.118m-4.665-2.976c.006-.052.011-.137.011-.255 0-.399-.094-.698-.28-.901-.186-.204-.46-.306-.818-.306-.412 0-.732.123-.962.369-.228.245-.36.61-.392 1.093zm-.942 3.07c-.803 0-1.411-.222-1.824-.667-.412-.444-.616-1.102-.616-1.972 0-.83.204-1.475.616-1.937.413-.46.988-.691 1.728-.691.62 0 1.098.176 1.432.524.332.351.5.846.5 1.487 0 .21-.017.422-.05.638-.014.077-.034.13-.064.156-.029.027-.077.04-.142.04h-3.08c.013.563.154.977.418 1.245.265.268.674.403 1.23.403.196 0 .385-.014.564-.04a5.04 5.04 0 0 0 .682-.166l.117-.035a.284.284 0 0 1 .09-.016c.085 0 .125.06.125.177v.276c0 .085-.012.144-.037.18a.441.441 0 0 1-.167.114 3.38 3.38 0 0 1-.701.205 4.236 4.236 0 0 1-.82.079m-5.424-.147c-.13 0-.195-.066-.195-.197v-4.58c0-.13.064-.195.195-.195h.432c.064 0 .116.012.153.039.036.025.06.076.072.146l.07.55c.176-.19.343-.34.499-.452a1.725 1.725 0 0 1 1.02-.323c.079 0 .158.003.235.01.112.014.168.072.168.176v.53c0 .117-.058.177-.178.177-.058 0-.114-.004-.17-.01a1.638 1.638 0 0 0-.18-.01c-.524 0-.973.157-1.346.47v3.472c0 .131-.066.197-.195.197zm-2.249 0c-.13 0-.196-.066-.196-.197v-4.58c0-.13.066-.195.196-.195h.579c.13 0 .195.064.195.195v4.58c0 .131-.065.197-.195.197zm.295-5.856c-.19 0-.339-.054-.447-.16a.581.581 0 0 1-.161-.428c0-.176.054-.318.16-.426.11-.109.257-.163.448-.163.189 0 .337.054.446.163.107.108.16.25.16.426a.581.581 0 0 1-.16.427.608.608 0 0 1-.446.161m-3.625 5.856c-.132 0-.197-.066-.197-.197v-4.01H.195c-.13 0-.195-.066-.195-.197v-.245c0-.065.014-.114.043-.147.03-.033.088-.055.173-.07l.705-.087v-.804c0-1.091.523-1.638 1.57-1.638.248 0 .51.036.784.109.072.019.122.047.152.088.029.038.044.107.044.205v.255c0 .124-.048.186-.148.186-.058 0-.14-.01-.248-.029-.11-.02-.23-.03-.369-.03-.3 0-.51.057-.633.172-.121.115-.181.303-.181.564v.903h1.324c.131 0 .197.064.197.195v.373c0 .13-.066.197-.197.197H1.892v4.01c0 .131-.065.197-.196.197Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

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

@@ -32,7 +32,7 @@ const ConfirmDialog: FC<ConfirmDialogProps> = ({
{title}
</DialogTitle>
<DialogContent>
<DialogContentText>
<DialogContentText sx={{ whiteSpace: 'pre-wrap' }}>
{text}
</DialogContentText>
</DialogContent>

View File

@@ -11,13 +11,24 @@ import Stack from '@mui/material/Stack';
interface InputDialogProps extends DialogProps {
title: string;
label: string;
helperText?: string;
initialText?: string;
confirmButtonText?: string;
onClose: () => void;
onConfirm: (text: string) => void;
};
const InputDialog = ({ open, title, label, onClose, confirmButtonText, onConfirm }: InputDialogProps) => {
const [ text, setText ] = useState('');
const InputDialog = ({
open,
title,
label,
helperText,
initialText,
onClose,
confirmButtonText,
onConfirm
}: InputDialogProps) => {
const [ text, setText ] = useState(initialText || '');
const onTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
@@ -37,7 +48,7 @@ const InputDialog = ({ open, title, label, onClose, confirmButtonText, onConfirm
>
{title && (
<DialogTitle>
{title}
{title || ''}
</DialogTitle>
)}
<DialogContent>
@@ -45,6 +56,7 @@ const InputDialog = ({ open, title, label, onClose, confirmButtonText, onConfirm
<TextField
label={label}
value={text}
helperText={helperText}
onChange={onTextChange}
variant='standard'
/>

View File

@@ -1,7 +1,7 @@
import React, { FC } from 'react';
import React, { type FC } from 'react';
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
import Avatar from '@mui/material/Avatar';
import { useTheme } from '@mui/material/styles';
import type {} from '@mui/material/themeCssVarsAugmentation';
import { useApi } from 'hooks/useApi';
@@ -11,7 +11,6 @@ interface UserAvatarProps {
const UserAvatar: FC<UserAvatarProps> = ({ user }) => {
const { api } = useApi();
const theme = useTheme();
return user ? (
<Avatar
@@ -21,12 +20,13 @@ const UserAvatar: FC<UserAvatarProps> = ({ user }) => {
`${api.basePath}/Users/${user.Id}/Images/Primary?tag=${user.PrimaryImageTag}` :
undefined
}
sx={{
// eslint-disable-next-line react/jsx-no-bind
sx={(theme) => ({
bgcolor: api && user.Id && user.PrimaryImageTag ?
theme.palette.background.paper :
theme.palette.primary.dark,
theme.vars.palette.background.paper :
theme.vars.palette.primary.dark,
color: 'inherit'
}}
})}
/>
) : null;
};

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

@@ -12,6 +12,8 @@ const appName = 'Jellyfin Web';
const BrowserName = {
tizen: 'Samsung Smart TV',
web0s: 'LG Smart TV',
titanos: 'Titan OS',
vega: 'Vega OS',
operaTv: 'Opera TV',
xboxOne: 'Xbox One',
ps4: 'Sony PS4',

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,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';
@@ -12,6 +13,7 @@ import { isUsingLiveTvNaming } from '../cardBuilderUtils';
import { getDataAttributes } from 'utils/items';
import { ItemKind } from 'types/base/models/item-kind';
import { ItemMediaKind } from 'types/base/models/item-media-kind';
import { ensureArray } from 'utils/array';
import type { NullableNumber, NullableString } from 'types/base/common/shared/types';
import type { ItemDto } from 'types/base/models/item-dto';
@@ -65,8 +67,8 @@ interface TextAction {
}
export interface TextLine {
title?: NullableString;
titleAction?: TextAction;
title?: NullableString | string[];
titleAction?: TextAction | TextAction[];
}
export function getTextActionButton(
@@ -210,9 +212,25 @@ function getParentTitle(
item: ItemDto
) {
if (isOuterFooter && item.AlbumArtists?.length) {
(item.AlbumArtists[0] as ItemDto).Type = ItemKind.MusicArtist;
(item.AlbumArtists[0] as ItemDto).IsFolder = true;
return getTextActionButton(item.AlbumArtists[0], null, serverId);
return item.AlbumArtists
.map(artist => {
const artistItem: ItemDto = {
...artist,
Type: BaseItemKind.MusicArtist,
IsFolder: true
};
return getTextActionButton(artistItem, null, serverId);
})
.reduce((acc, line) => ({
title: [
...ensureArray(acc.title),
...ensureArray(line.title)
],
titleAction: [
...ensureArray(acc.titleAction),
...ensureArray(line.titleAction)
]
}), {});
} else {
return {
title: isUsingLiveTvNaming(item.Type) ?

View File

@@ -575,9 +575,15 @@ function getCardFooterText(item, apiClient, options, footerClass, progressHtml,
if (showOtherText) {
if (options.showParentTitle && parentTitleUnderneath) {
if (flags.isOuterFooter && item.AlbumArtists?.length) {
item.AlbumArtists[0].Type = 'MusicArtist';
item.AlbumArtists[0].IsFolder = true;
lines.push(getTextActionButton(item.AlbumArtists[0], null, serverId));
const artistText = item.AlbumArtists
.map(artist => {
artist.ServerId = serverId;
artist.Type = BaseItemKind.MusicArtist;
artist.IsFolder = true;
return getTextActionButton(artist);
})
.join(' / ');
lines.push(artistText);
} else {
lines.push(escapeHtml(isUsingLiveTvNaming(item.Type) ? item.Name : (item.SeriesName || item.Series || item.Album || item.AlbumArtist || '')));
}

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

@@ -148,7 +148,7 @@
left: 0 !important;
right: 0 !important;
margin: 0 !important;
z-index: 999999 !important;
z-index: 999998 !important;
transition: opacity ease-out 0.2s;
will-change: opacity;
}

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

@@ -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,3 +1,5 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import browser from '../scripts/browser';
import { copy } from '../scripts/clipboard';
import dom from '../utils/dom';
@@ -10,9 +12,16 @@ import itemHelper, { canEditPlaylist } from './itemHelper';
import { playbackManager } from './playback/playbackmanager';
import toast from './toast/toast';
import * as userSettings from '../scripts/settings/userSettings';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { AppFeature } from 'constants/appFeature';
/** Item types that support downloading all children. */
const DOWNLOAD_ALL_TYPES = [
BaseItemKind.BoxSet,
BaseItemKind.MusicAlbum,
BaseItemKind.Season,
BaseItemKind.Series
];
function getDeleteLabel(type) {
switch (type) {
case BaseItemKind.Series:
@@ -172,7 +181,7 @@ export async function getCommands(options) {
if (appHost.supports(AppFeature.FileDownload)) {
// CanDownload should probably be updated to return true for these items?
if (user.Policy.EnableContentDownloading && (item.Type === 'Season' || item.Type == 'Series')) {
if (user.Policy.EnableContentDownloading && DOWNLOAD_ALL_TYPES.includes(item.Type)) {
commands.push({
name: globalize.translate('DownloadAll'),
id: 'downloadall',
@@ -415,19 +424,21 @@ function executeCommand(item, id, options) {
});
break;
case 'downloadall': {
const downloadEpisodes = episodes => {
const downloadItems = items => {
import('../scripts/fileDownloader').then((fileDownloader) => {
const downloads = episodes.map(episode => {
const downloadHref = apiClient.getItemDownloadUrl(episode.Id);
return {
url: downloadHref,
item: episode,
itemId: episode.Id,
serverId: serverId,
title: episode.Name,
filename: episode.Path.replace(/^.*[\\/]/, '')
};
});
const downloads = items
.filter(i => i.CanDownload)
.map(i => {
const downloadHref = apiClient.getItemDownloadUrl(i.Id);
return {
url: downloadHref,
item: i,
itemId: i.Id,
serverId,
title: i.Name,
filename: i.Path.replace(/^.*[\\/]/, '')
};
});
fileDownloader.download(downloads);
});
@@ -441,17 +452,26 @@ function executeCommand(item, id, options) {
});
}
)).then(seasonData => {
downloadEpisodes(seasonData.map(season => season.Items).flat());
downloadItems(seasonData.map(season => season.Items).flat());
});
};
if (item.Type === 'Season') {
downloadSeasons([item]);
} else if (item.Type === 'Series') {
apiClient.getSeasons(item.Id, {
userId: options.user.Id,
Fields: 'ItemCounts'
}).then(seasons => downloadSeasons(seasons.Items));
switch (item.Type) {
case BaseItemKind.BoxSet:
case BaseItemKind.MusicAlbum:
apiClient.getItems(options.user.Id, {
ParentId: item.Id,
Fields: 'CanDownload,Path'
}).then(({ Items }) => downloadItems(Items));
break;
case BaseItemKind.Season:
downloadSeasons([item]);
break;
case BaseItemKind.Series:
apiClient.getSeasons(item.Id, {
userId: options.user.Id,
Fields: 'ItemCounts'
}).then(seasons => downloadSeasons(seasons.Items));
}
getResolveFunction(getResolveFunction(resolve, id), id)();

View File

@@ -1,5 +1,5 @@
import React, { type FC } from 'react';
import { groupBy } from 'lodash-es';
import groupBy from 'lodash-es/groupBy';
import Box from '@mui/material/Box';
import { getIndex } from './listHelper';
import ListGroupHeaderWrapper from './ListGroupHeaderWrapper';

View File

@@ -1,28 +1,26 @@
import React, { type FC } from 'react';
import classNames from 'classnames';
import Box from '@mui/material/Box';
import datetime from 'scripts/datetime';
import globalize from 'lib/globalize';
import mediainfo from './mediainfo';
interface EndsAtProps {
className?: string;
runTimeTicks: number
runTimeTicks: number;
positionTicks?: number;
}
const EndsAt: FC<EndsAtProps> = ({ runTimeTicks, className }) => {
const EndsAt: FC<EndsAtProps> = ({ runTimeTicks, positionTicks, className }) => {
const cssClass = classNames(
'mediaInfoItem',
'endsAt',
className
);
const endTime = new Date().getTime() + (runTimeTicks / 10000);
const endDate = new Date(endTime);
const displayTime = datetime.getDisplayTime(endDate);
const displayTime = mediainfo.getEndsAtFromPosition(runTimeTicks, positionTicks, 1, true);
return (
<Box className={cssClass}>
{globalize.translate('EndsAtValue', displayTime)}
{displayTime}
</Box>
);
};

View File

@@ -71,6 +71,7 @@ const PrimaryMediaInfo: FC<PrimaryMediaInfoProps> = ({
HasSubtitles,
MediaType,
RunTimeTicks,
PlaybackPositionTicks,
CommunityRating,
CriticRating
} = item;
@@ -107,7 +108,7 @@ const PrimaryMediaInfo: FC<PrimaryMediaInfoProps> = ({
&& MediaType === ItemMediaKind.Video
&& RunTimeTicks
&& !StartDate && (
<EndsAt className={infoclass} runTimeTicks={RunTimeTicks} />
<EndsAt className={infoclass} runTimeTicks={RunTimeTicks} positionTicks={PlaybackPositionTicks} />
)}
{getMissingIndicator?.()}

View File

@@ -2,7 +2,7 @@ import React, { type FC } from 'react';
import classNames from 'classnames';
import StarIcon from '@mui/icons-material/Star';
import Box from '@mui/material/Box';
import { useTheme } from '@mui/material/styles';
import type {} from '@mui/material/themeCssVarsAugmentation';
interface StarIconsProps {
className?: string;
@@ -10,7 +10,6 @@ interface StarIconsProps {
}
const StarIcons: FC<StarIconsProps> = ({ className, communityRating }) => {
const theme = useTheme();
const cssClass = classNames(
'mediaInfoItem',
'starRatingContainer',
@@ -21,9 +20,10 @@ const StarIcons: FC<StarIconsProps> = ({ className, communityRating }) => {
<Box className={cssClass}>
<StarIcon
fontSize={'small'}
sx={{
color: theme.palette.starIcon.main
}}
// eslint-disable-next-line react/jsx-no-bind
sx={(theme) => ({
color: theme.vars.palette.starIcon.main
})}
/>
{communityRating.toFixed(1)}
</Box>

View File

@@ -321,11 +321,8 @@ export function getMediaInfoHtml(item, options = {}) {
export function getEndsAt(item) {
if (item.MediaType === 'Video' && item.RunTimeTicks && !item.StartDate) {
let endDate = new Date().getTime() + (item.RunTimeTicks / 10000);
endDate = new Date(endDate);
const displayTime = datetime.getDisplayTime(endDate);
return globalize.translate('EndsAtValue', displayTime);
const positionTicks = item.UserData?.PlaybackPositionTicks;
return getEndsAtFromPosition(item.RunTimeTicks, positionTicks, 1, true);
}
return null;

View File

@@ -589,17 +589,17 @@ function setFieldVisibilities(context, item) {
hideElement('#fld3dFormat', context);
}
if (item.Type === 'Audio') {
if (item.Type === BaseItemKind.Audio || item.Type === BaseItemKind.MusicAlbum || item.Type === BaseItemKind.MusicVideo) {
showElement('#fldArtist', context);
showElement('#fldAlbumArtist', context);
} else {
hideElement('#fldArtist', context);
hideElement('#fldAlbumArtist', context);
}
if (item.Type === 'Audio' || item.Type === 'MusicVideo') {
showElement('#fldArtist', context);
if (item.Type === BaseItemKind.Audio || item.Type === BaseItemKind.MusicVideo) {
showElement('#fldAlbum', context);
} else {
hideElement('#fldArtist', context);
hideElement('#fldAlbum', context);
}
@@ -970,7 +970,7 @@ function populatePeople(context, people) {
html += '</div>';
if (person.Role && person.Role !== lastType) {
html += '<div class="secondary">' + person.Role + '</div>';
html += '<div class="secondary">' + escapeHtml(person.Role) + '</div>';
} else {
html += '<div class="secondary">' + globalize.translate(person.Type) + '</div>';
}

View File

@@ -62,7 +62,7 @@ function show(person) {
}
});
let selectPersonTypeOptions = '<option value=""></option>';
let selectPersonTypeOptions = '';
for (const type of Object.values(PersonKind)) {
if (type === PersonKind.Unknown) {
continue;

View File

@@ -1,7 +1,9 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind.js';
import { PlaybackErrorCode } from '@jellyfin/sdk/lib/generated-client/models/playback-error-code.js';
import { getMediaInfoApi } from '@jellyfin/sdk/lib/utils/api/media-info-api';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { ItemFilter } from '@jellyfin/sdk/lib/generated-client/models/item-filter';
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
import { PlaybackErrorCode } from '@jellyfin/sdk/lib/generated-client/models/playback-error-code';
import { getMediaInfoApi } from '@jellyfin/sdk/lib/utils/api/media-info-api';
import merge from 'lodash-es/merge';
import Screenfull from 'screenfull';
@@ -166,9 +168,9 @@ function createStreamInfoFromUrlItem(item) {
function mergePlaybackQueries(obj1, obj2) {
const query = merge({}, obj1, obj2);
const filters = query.Filters ? query.Filters.split(',') : [];
if (filters.indexOf('IsNotFolder') === -1) {
filters.push('IsNotFolder');
const filters = query.Filters?.split(',') || [];
if (!filters.includes(ItemFilter.IsNotFolder)) {
filters.push(ItemFilter.IsNotFolder);
}
query.Filters = filters.join(',');
return query;
@@ -647,6 +649,7 @@ function normalizePlayOptions(playOptions) {
function truncatePlayOptions(playOptions) {
return {
aspectRatio: playOptions.aspectRatio,
fullscreen: playOptions.fullscreen,
mediaSourceId: playOptions.mediaSourceId,
audioStreamIndex: playOptions.audioStreamIndex,
@@ -1021,7 +1024,7 @@ export class PlaybackManager {
self.canPlay = function (item) {
const itemType = item.Type;
if (itemType === 'PhotoAlbum' || itemType === 'MusicGenre' || itemType === 'Season' || itemType === 'Series' || itemType === 'BoxSet' || itemType === 'MusicAlbum' || itemType === 'MusicArtist' || itemType === 'Playlist') {
if (itemType === 'Book' || itemType === 'PhotoAlbum' || itemType === 'MusicGenre' || itemType === 'Season' || itemType === 'Series' || itemType === 'BoxSet' || itemType === 'MusicAlbum' || itemType === 'MusicArtist' || itemType === 'Playlist') {
return true;
}
@@ -1419,6 +1422,28 @@ export class PlaybackManager {
});
};
self.isCrtShaderEnabled = function (player) {
player = player || self._currentPlayer;
return !!(getPlayerData(player).enableCrtShader);
};
self.setCrtShader = function (enabled, shadowMask, player) {
player = player || self._currentPlayer;
const pd = getPlayerData(player);
pd.enableCrtShader = !!enabled;
if (shadowMask !== undefined && shadowMask !== null) {
pd.crtShadowMask = shadowMask;
}
// When enabling CRT we must force full re-encode — the filter only runs
// server-side via program_opencl and requires actual video decoding/encoding.
// AllowVideoStreamCopy:false prevents the server from choosing codec:copy
// (container remux), which would bypass the filter chain entirely.
const streamParams = enabled
? { EnableDirectPlay: false, EnableDirectStream: false, AllowVideoStreamCopy: false }
: {};
changeStream(player, getCurrentTicks(player), streamParams);
};
self.isFullscreen = function (player) {
player = player || self._currentPlayer;
if (!player.isLocalPlayer || player.isFullscreen) {
@@ -1828,64 +1853,75 @@ export class PlaybackManager {
}
function getPlaybackPromise(firstItem, serverId, options, queryOptions, items) {
const SortBy = options.shuffle ? ItemSortBy.Random : ItemSortBy.SortName;
switch (firstItem.Type) {
case 'Program':
case BaseItemKind.Program:
return getItemsForPlayback(serverId, {
Ids: firstItem.ChannelId
});
case 'Playlist':
case BaseItemKind.Playlist:
return getItemsForPlayback(serverId, {
ParentId: firstItem.Id,
SortBy: options.shuffle ? 'Random' : null
SortBy: options.shuffle ? SortBy : undefined
});
case 'MusicArtist':
case BaseItemKind.MusicArtist:
return getItemsForPlayback(serverId, mergePlaybackQueries({
ArtistIds: firstItem.Id,
Filters: 'IsNotFolder',
Recursive: true,
SortBy: options.shuffle ? 'Random' : 'Album,ParentIndexNumber,IndexNumber,SortName',
MediaTypes: 'Audio'
SortBy: options.shuffle ? SortBy : [
ItemSortBy.Album,
ItemSortBy.ParentIndexNumber,
ItemSortBy.IndexNumber,
ItemSortBy.SortName
].join(','),
MediaTypes: MediaType.Audio
}, queryOptions));
case 'PhotoAlbum':
case BaseItemKind.PhotoAlbum:
return getItemsForPlayback(serverId, mergePlaybackQueries({
ParentId: firstItem.Id,
Filters: 'IsNotFolder',
// Setting this to true may cause some incorrect sorting
Recursive: false,
SortBy: options.shuffle ? 'Random' : 'SortName',
SortBy,
// Only include Photos because we do not handle mixed queues currently
MediaTypes: 'Photo',
MediaTypes: MediaType.Photo,
Limit: UNLIMITED_ITEMS
}, queryOptions));
case 'MusicGenre':
case BaseItemKind.MusicGenre:
return getItemsForPlayback(serverId, mergePlaybackQueries({
GenreIds: firstItem.Id,
Filters: 'IsNotFolder',
Recursive: true,
SortBy: options.shuffle ? 'Random' : 'SortName',
MediaTypes: 'Audio'
SortBy,
MediaTypes: MediaType.Audio
}, queryOptions));
case 'Genre':
case BaseItemKind.Genre:
return getItemsForPlayback(serverId, mergePlaybackQueries({
GenreIds: firstItem.Id,
ParentId: firstItem.ParentId,
Filters: 'IsNotFolder',
Recursive: true,
SortBy: options.shuffle ? 'Random' : 'SortName',
MediaTypes: 'Video'
SortBy,
MediaTypes: MediaType.Video
}, queryOptions));
case 'Studio':
case BaseItemKind.Studio:
return getItemsForPlayback(serverId, mergePlaybackQueries({
StudioIds: firstItem.Id,
Filters: 'IsNotFolder',
Recursive: true,
SortBy: options.shuffle ? 'Random' : 'SortName',
MediaTypes: 'Video'
SortBy,
MediaTypes: MediaType.Video
}, queryOptions));
case 'Series':
case 'Season':
case BaseItemKind.Person:
return getItemsForPlayback(serverId, mergePlaybackQueries({
PersonIds: firstItem.Id,
ParentId: firstItem.ParentId,
Recursive: true,
SortBy,
MediaTypes: MediaType.Video
}, queryOptions));
case BaseItemKind.Series:
case BaseItemKind.Season:
return getSeriesOrSeasonPlaybackPromise(firstItem, options, items);
case 'Episode':
case BaseItemKind.Episode:
return getEpisodePlaybackPromise(firstItem, options, items);
}
@@ -1927,6 +1963,15 @@ export class PlaybackManager {
MediaTypes: 'Photo',
Limit: UNLIMITED_ITEMS
}, queryOptions));
} else if (firstItem.IsFolder && firstItem.CollectionType === 'musicvideos') {
return getItemsForPlayback(serverId, mergePlaybackQueries({
ParentId: firstItem.Id,
Filters: 'IsFolder',
Recursive: true,
SortBy: options.shuffle ? 'Random' : 'SortName',
MediaTypes: 'Video',
Limit: UNLIMITED_ITEMS
}, queryOptions));
} else if (firstItem.IsFolder) {
let sortBy = null;
if (options.shuffle) {
@@ -1957,28 +2002,18 @@ export class PlaybackManager {
const startSeasonId = firstItem.Type === 'Season' ? items[options.startIndex || 0].Id : undefined;
const seasonId = (startSeasonId && items.length === 1) ? startSeasonId : undefined;
const seriesId = firstItem.SeriesId || firstItem.Id;
const SeriesId = firstItem.SeriesId || firstItem.Id;
const UserId = apiClient.getCurrentUserId();
let startItemId;
// Start from a specific (the next unwatched) episode if we want to watch in order and have not chosen a specific season
if (!options.shuffle && !seasonId) {
const initialUnplayedEpisode = await getItems(apiClient, UserId, {
SortBy: 'SeriesSortName,SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Episode',
Recursive: true,
IsMissing: false,
ParentId: seriesId,
limit: 1,
Filters: 'IsUnplayed'
});
startItemId = initialUnplayedEpisode?.Items?.at(0)?.Id;
const nextUp = await apiClient.getNextUpEpisodes({ SeriesId, UserId });
startItemId = nextUp?.Items?.[0]?.Id;
}
const episodesResult = await apiClient.getEpisodes(seriesId, {
const episodesResult = await apiClient.getEpisodes(SeriesId, {
IsVirtualUnaired: false,
IsMissing: false,
SeasonId: seasonId,
@@ -2641,6 +2676,7 @@ export class PlaybackManager {
const audioStreamIndex = playOptions.audioStreamIndex;
const subtitleStreamIndex = playOptions.subtitleStreamIndex;
const options = {
aspectRatio: playOptions.aspectRatio,
maxBitrate,
startPosition,
isPlayback: null,
@@ -2698,7 +2734,7 @@ export class PlaybackManager {
}
const streamInfo = createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition, player);
streamInfo.aspectRatio = playOptions.aspectRatio;
streamInfo.fullscreen = playOptions.fullscreen;
const playerData = getPlayerData(player);
@@ -2842,7 +2878,19 @@ export class PlaybackManager {
playMethod = mediaSource.SupportsDirectPlay ? 'DirectPlay' : 'DirectStream';
} else if (mediaSource.SupportsTranscoding) {
mediaUrl = apiClient.getUrl(mediaSource.TranscodingUrl);
let transcodingUrl = mediaSource.TranscodingUrl;
// Append CRT shader options as plain query params so ParseStreamOptions
// on the server picks them up (key must start lowercase, no streamOptions[] wrapper).
if (getPlayerData(player).enableCrtShader) {
transcodingUrl += '&crtShader=true';
const maskVal = getPlayerData(player).crtShadowMask;
if (maskVal !== undefined && maskVal !== null) {
transcodingUrl += '&crtShadowMask=' + maskVal;
}
}
mediaUrl = apiClient.getUrl(transcodingUrl);
if (mediaSource.TranscodingSubProtocol === 'hls') {
contentType = 'application/x-mpegURL';

View File

@@ -4,6 +4,34 @@ import globalize from 'lib/globalize';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import qualityoptions from '../qualityOptions';
function showCrtMenu(player, btn) {
const isEnabled = playbackManager.isCrtShaderEnabled(player);
const menuItems = [
{
name: globalize.translate('On'),
id: 'crt_on',
selected: isEnabled
},
{
name: globalize.translate('Off'),
id: 'crt_off',
selected: !isEnabled
}
];
return actionsheet.show({
items: menuItems,
positionTo: btn
}).then(function (id) {
if (id === 'crt_on' && !isEnabled) {
playbackManager.setCrtShader(true, null, player);
} else if (id === 'crt_off' && isEnabled) {
playbackManager.setCrtShader(false, null, player);
}
});
}
function showQualityMenu(player, btn) {
const videoStream = playbackManager.currentMediaSource(player).MediaStreams.filter(function (stream) {
return stream.Type === 'Video';
@@ -93,12 +121,16 @@ function getQualitySecondaryText(player) {
return stream.Type === 'Video';
})[0];
const videoCodec = videoStream ? videoStream.Codec : null;
const videoBitRate = videoStream ? videoStream.BitRate : null;
const videoWidth = videoStream ? videoStream.Width : null;
const videoHeight = videoStream ? videoStream.Height : null;
const options = qualityoptions.getVideoQualityOptions({
currentMaxBitrate: playbackManager.getMaxStreamingBitrate(player),
isAutomaticBitrateEnabled: playbackManager.enableAutomaticBitrateDetection(player),
videoCodec,
videoBitRate,
videoWidth: videoWidth,
videoHeight: videoHeight,
enableAuto: true
@@ -210,6 +242,16 @@ function showWithUser(options, player, user) {
});
}
if (options.quality && user?.Policy?.EnableVideoPlaybackTranscoding) {
menuItems.push({
name: 'CRT Shader',
id: 'crtshader',
asideText: playbackManager.isCrtShaderEnabled(player)
? globalize.translate('On')
: globalize.translate('Off')
});
}
const repeatMode = playbackManager.getRepeatMode(player);
if (supportedCommands.indexOf('SetRepeatMode') !== -1 && playbackManager.currentMediaSource(player).RunTimeTicks) {
@@ -268,6 +310,8 @@ function handleSelectedOption(id, options, player) {
return showPlaybackRateMenu(player, options.positionTo);
case 'repeatmode':
return showRepeatModeMenu(player, options.positionTo);
case 'crtshader':
return showCrtMenu(player, options.positionTo);
case 'stats':
if (options.onOption) {
options.onOption('stats');

View File

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

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

@@ -16,7 +16,7 @@ import { history } from 'RootAppRouter';
const START_PAGE_PATHS = ['/home', '/login', '/selectserver'];
/** Pages that do not require a user to be logged in to view. */
const PUBLIC_PATHS = [
export const PUBLIC_PATHS = [
'/addserver',
'/selectserver',
'/login',
@@ -121,9 +121,7 @@ class AppRouter {
return this.baseRoute;
}
canGoBack() {
const path = history.location.pathname;
canGoBack(path = history.location.pathname) {
if (
!document.querySelector('.dialogContainer')
&& START_PAGE_PATHS.includes(path)
@@ -261,15 +259,15 @@ class AppRouter {
}
if (item === 'recordedtv') {
return '#/livetv?tab=3&serverId=' + options.serverId;
return '#/livetv?tab=3&serverId=' + serverId;
}
if (item === 'nextup') {
return '#/list?type=nextup&serverId=' + options.serverId;
return '#/list?type=nextup&serverId=' + serverId;
}
if (item === 'list') {
let urlForList = '#/list?serverId=' + options.serverId + '&type=' + options.itemTypes;
let urlForList = '#/list?serverId=' + serverId + '&type=' + options.itemTypes;
if (options.isFavorite) {
urlForList += '&IsFavorite=true';
@@ -304,49 +302,49 @@ class AppRouter {
if (item === 'livetv') {
if (options.section === 'programs') {
return '#/livetv?tab=0&serverId=' + options.serverId;
return '#/livetv?tab=0&serverId=' + serverId;
}
if (options.section === 'guide') {
return '#/livetv?tab=1&serverId=' + options.serverId;
return '#/livetv?tab=1&serverId=' + serverId;
}
if (options.section === 'movies') {
return '#/list?type=Programs&IsMovie=true&serverId=' + options.serverId;
return '#/list?type=Programs&IsMovie=true&serverId=' + serverId;
}
if (options.section === 'shows') {
return '#/list?type=Programs&IsSeries=true&IsMovie=false&IsNews=false&serverId=' + options.serverId;
return '#/list?type=Programs&IsSeries=true&IsMovie=false&IsNews=false&serverId=' + serverId;
}
if (options.section === 'sports') {
return '#/list?type=Programs&IsSports=true&serverId=' + options.serverId;
return '#/list?type=Programs&IsSports=true&serverId=' + serverId;
}
if (options.section === 'kids') {
return '#/list?type=Programs&IsKids=true&serverId=' + options.serverId;
return '#/list?type=Programs&IsKids=true&serverId=' + serverId;
}
if (options.section === 'news') {
return '#/list?type=Programs&IsNews=true&serverId=' + options.serverId;
return '#/list?type=Programs&IsNews=true&serverId=' + serverId;
}
if (options.section === 'onnow') {
return '#/list?type=Programs&IsAiring=true&serverId=' + options.serverId;
return '#/list?type=Programs&IsAiring=true&serverId=' + serverId;
}
if (options.section === 'channels') {
return '#/livetv?tab=2&serverId=' + options.serverId;
return '#/livetv?tab=2&serverId=' + serverId;
}
if (options.section === 'dvrschedule') {
return '#/livetv?tab=4&serverId=' + options.serverId;
return '#/livetv?tab=4&serverId=' + serverId;
}
if (options.section === 'seriesrecording') {
return '#/livetv?tab=5&serverId=' + options.serverId;
return '#/livetv?tab=5&serverId=' + serverId;
}
return '#/livetv?serverId=' + options.serverId;
return '#/livetv?serverId=' + serverId;
}
if (itemType == 'SeriesTimer') {

View File

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

View File

@@ -390,7 +390,7 @@ export function onClick(e) {
}
}
if (action) {
if (action && action !== 'none') {
executeAction(card, actionElement, action);
e.preventDefault();

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

@@ -40,6 +40,7 @@ function playThemeMedia(items, ownerId) {
playbackManager.play({
items: currentThemeItems,
aspectRatio: 'cover',
fullscreen: false,
enableRemotePlayers: false
}).then(function () {

View File

@@ -4,5 +4,6 @@
export enum EventType {
HEADER_RENDERED = 'HEADER_RENDERED',
SET_TABS = 'SET_TABS',
SHOW_VIDEO_OSD = 'SHOW_VIDEO_OSD'
SHOW_VIDEO_OSD = 'SHOW_VIDEO_OSD',
THEME_CHANGE = 'THEME_CHANGE'
}

View File

@@ -1,3 +1,6 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import focusManager from 'components/focusManager';
import layoutManager from 'components/layoutManager';
@@ -6,7 +9,6 @@ import dom from 'utils/dom';
import globalize from 'lib/globalize';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import { getBackdropShape, getPortraitShape, getSquareShape } from 'utils/card';
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
import 'elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-scroller/emby-scroller';
@@ -34,6 +36,15 @@ function getSections() {
overlayPlayButton: true,
overlayText: false,
centerText: true
}, {
name: 'HeaderSeasons',
types: BaseItemKind.Season,
shape: getPortraitShape(enableScrollX()),
showTitle: true,
showParentTitle: true,
overlayPlayButton: true,
overlayText: false,
centerText: true
}, {
name: 'Episodes',
types: 'Episode',

View File

@@ -4,172 +4,186 @@
<div class="detailLogo"></div>
<div class="detailPageWrapperContainer">
<div class="detailPagePrimaryContainer padded-left padded-right">
<div class="infoWrapper">
<div class="detailImageContainer padded-left"></div>
<div class="nameContainer"></div>
<div class="itemMiscInfo itemMiscInfo-primary" style="margin-bottom: 0.6em;"></div>
<div class="itemMiscInfo itemMiscInfo-secondary" style="margin-bottom: 0.6em;"></div>
<div class="detailPagePrimaryContainer">
<div class="detailImageContainer hide-mobile"></div>
<div class="detailRibbon padded-left padded-right">
<div class="infoWrapper">
<div class="detailImageContainer hide-desktop hide-tv"></div>
<div class="nameContainer"></div>
<div class="itemMiscInfo itemMiscInfo-primary" style="margin-bottom: 0.6em;"></div>
<div class="itemMiscInfo itemMiscInfo-secondary" style="margin-bottom: 0.6em;"></div>
</div>
<div class="mainDetailButtons focuscontainer-x">
<button is="emby-button" type="button" class="button-flat btnPlay hide detailButton" title="${ButtonResume}" data-action="resume">
<div class="detailButton-content">
<span class="material-icons detailButton-icon play_arrow" aria-hidden="true"></span>
</div>
</button>
<button is="emby-button" type="button" class="button-flat btnReplay hide detailButton" title="${Play}" data-action="play">
<div class="detailButton-content">
<span class="material-icons detailButton-icon replay" aria-hidden="true"></span>
</div>
</button>
<button is="emby-button" type="button" class="button-flat btnDownload hide detailButton" title="${Download}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon get_app" aria-hidden="true"></span>
</div>
</button>
<button is="emby-button" type="button" class="button-flat btnPlayTrailer hide detailButton" title="${ButtonTrailer}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon theaters" aria-hidden="true"></span>
</div>
</button>
<button is="emby-button" type="button" class="button-flat btnInstantMix hide detailButton" title="${HeaderInstantMix}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon explore" aria-hidden="true"></span>
</div>
</button>
<button is="emby-button" type="button" class="button-flat btnShuffle hide detailButton" title="${Shuffle}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon shuffle" aria-hidden="true"></span>
</div>
</button>
<button is="emby-button" type="button" class="button-flat btnCancelSeriesTimer hide detailButton" title="${CancelSeries}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon delete" aria-hidden="true"></span>
</div>
</button>
<button is="emby-button" type="button" class="button-flat btnCancelTimer hide detailButton" title="${StopRecording}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon stop" aria-hidden="true"></span>
</div>
</button>
<button is="emby-playstatebutton" type="button" class="button-flat btnPlaystate hide detailButton" title="">
<div class="detailButton-content">
<span class="material-icons detailButton-icon check" aria-hidden="true"></span>
</div>
</button>
<button is="emby-ratingbutton" type="button" class="button-flat btnUserRating hide detailButton" title="${Rate}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon favorite" aria-hidden="true"></span>
</div>
</button>
<button is="emby-button" type="button" class="button-flat btnSplitVersions hide detailButton" title="${ButtonSplit}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon call_split" aria-hidden="true"></span>
</div>
</button>
<button is="emby-button" type="button" class="button-flat btnMoreCommands hide detailButton" title="${ButtonMore}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon more_vert" aria-hidden="true"></span>
</div>
</button>
</div>
</div>
<div class="mainDetailButtons focuscontainer-x">
<button is="emby-button" type="button" class="button-flat btnPlay hide detailButton" title="${ButtonResume}" data-action="resume">
<div class="detailButton-content">
<span class="material-icons detailButton-icon play_arrow" aria-hidden="true"></span>
</div>
</button>
<div class="detailPagePrimaryContent padded-right">
<div class="detailSection">
<form class="trackSelections hide focuscontainer-x">
<div class="selectContainer selectSourceContainer hide trackSelectionFieldContainer flex-shrink-zero">
<select is="emby-select" class="selectSource detailTrackSelect" label=""></select>
</div>
<div class="selectContainer selectVideoContainer hide trackSelectionFieldContainer flex-shrink-zero">
<select is="emby-select" class="selectVideo detailTrackSelect" label=""></select>
</div>
<div class="selectContainer selectAudioContainer hide trackSelectionFieldContainer flex-shrink-zero">
<select is="emby-select" class="selectAudio detailTrackSelect" label=""></select>
</div>
<div class="selectContainer selectSubtitlesContainer hide trackSelectionFieldContainer flex-shrink-zero">
<select is="emby-select" class="selectSubtitles detailTrackSelect" label=""></select>
</div>
</form>
<button is="emby-button" type="button" class="button-flat btnReplay hide detailButton" title="${Play}" data-action="play">
<div class="detailButton-content">
<span class="material-icons detailButton-icon replay" aria-hidden="true"></span>
</div>
</button>
<div class="recordingFields hide" style="margin: 0.5em 0 1.5em;"></div>
<button is="emby-button" type="button" class="button-flat btnDownload hide detailButton" title="${Download}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon get_app" aria-hidden="true"></span>
</div>
</button>
<div class="detailSectionContent">
<p class="itemGenres"></p>
<h3 class="tagline"></h3>
<p class="overview"></p>
<div class="overview-controls">
<a class="overview-expand hide" is="emby-linkbutton" href="#">${ShowMore}</a>
</div>
<p id="itemBirthday"></p>
<p id="itemBirthLocation"></p>
<p id="itemDeathDate"></p>
<p id="seriesAirTime"></p>
<button is="emby-button" type="button" class="button-flat btnPlayTrailer hide detailButton" title="${ButtonTrailer}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon theaters" aria-hidden="true"></span>
<div class="itemTags focuscontainer-x hide" style="margin: 0.7em 0; font-size: 92%;"></div>
<div class="itemExternalLinks focuscontainer-x hide" style="margin: 0.7em 0; font-size: 92%;"></div>
<div class="seriesRecordingEditor"></div>
</div>
</button>
<button is="emby-button" type="button" class="button-flat btnInstantMix hide detailButton" title="${HeaderInstantMix}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon explore" aria-hidden="true"></span>
</div>
</button>
<div class="itemDetailsGroup">
<div class="detailsGroupItem genresGroup hide">
<div class="genresLabel label"></div>
<div class="genres content focuscontainer-x"></div>
</div>
<button is="emby-button" type="button" class="button-flat btnShuffle hide detailButton" title="${Shuffle}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon shuffle" aria-hidden="true"></span>
</div>
</button>
<div class="detailsGroupItem directorsGroup hide">
<div class="directorsLabel label"></div>
<div class="directors content focuscontainer-x"></div>
</div>
<button is="emby-button" type="button" class="button-flat btnCancelSeriesTimer hide detailButton" title="${CancelSeries}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon delete" aria-hidden="true"></span>
</div>
</button>
<div class="detailsGroupItem writersGroup hide">
<div class="writersLabel label"></div>
<div class="writers content focuscontainer-x"></div>
</div>
<button is="emby-button" type="button" class="button-flat btnCancelTimer hide detailButton" title="${StopRecording}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon stop" aria-hidden="true"></span>
<div class="detailsGroupItem studiosGroup hide">
<div class="studiosLabel label"></div>
<div class="studios content focuscontainer-x"></div>
</div>
</div>
</button>
<button is="emby-playstatebutton" type="button" class="button-flat btnPlaystate hide detailButton" title="">
<div class="detailButton-content">
<span class="material-icons detailButton-icon check" aria-hidden="true"></span>
<div id="seriesTimerScheduleSection" class="verticalSection detailVerticalSection hide" style="margin-top: -3em;">
<h2 class="sectionTitle">${Schedule}</h2>
<div id="seriesTimerSchedule" is="emby-itemscontainer" class="itemsContainer vertical-list padded-right" data-contextmenu="false"></div>
</div>
</button>
<button is="emby-ratingbutton" type="button" class="button-flat btnUserRating hide detailButton" title="${Rate}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon favorite" aria-hidden="true"></span>
</div>
</button>
<div class="collectionItems hide"></div>
<button is="emby-button" type="button" class="button-flat btnSplitVersions hide detailButton" title="${ButtonSplit}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon call_split" aria-hidden="true"></span>
<div class="nextUpSection verticalSection detailVerticalSection hide">
<h2 class="sectionTitle sectionTitle-cards">${NextUp}</h2>
<div is="emby-itemscontainer" class="nextUpItems vertical-wrap padded-right"></div>
</div>
</button>
<button is="emby-button" type="button" class="button-flat btnMoreCommands hide detailButton" title="${ButtonMore}">
<div class="detailButton-content">
<span class="material-icons detailButton-icon more_vert" aria-hidden="true"></span>
<div class="programGuideSection hide verticalSection detailVerticalSection">
<div class="programGuide"></div>
</div>
</button>
<div id="listChildrenCollapsible" class="hide verticalSection detailVerticalSection">
<h2 class="sectionTitle sectionTitle-cards hide">
<span></span>
</h2>
<div id="childrenContent">
<div is="emby-itemscontainer" class="itemsContainer padded-right" style="text-align: left;"></div>
</div>
</div>
</div>
</div>
</div>
<div class="detailPageSecondaryContainer padded-bottom-page">
<div class="detailPageContent">
<div class="detailPagePrimaryContent padded-right">
<div class="detailSection">
<form class="trackSelections hide focuscontainer-x">
<div class="selectContainer selectSourceContainer hide trackSelectionFieldContainer flex-shrink-zero">
<select is="emby-select" class="selectSource detailTrackSelect" label=""></select>
</div>
<div class="selectContainer selectVideoContainer hide trackSelectionFieldContainer flex-shrink-zero">
<select is="emby-select" class="selectVideo detailTrackSelect" label=""></select>
</div>
<div class="selectContainer selectAudioContainer hide trackSelectionFieldContainer flex-shrink-zero">
<select is="emby-select" class="selectAudio detailTrackSelect" label=""></select>
</div>
<div class="selectContainer selectSubtitlesContainer hide trackSelectionFieldContainer flex-shrink-zero">
<select is="emby-select" class="selectSubtitles detailTrackSelect" label=""></select>
</div>
</form>
<div class="recordingFields hide" style="margin: 0.5em 0 1.5em;"></div>
<div class="detailSectionContent">
<p class="itemGenres"></p>
<h3 class="tagline"></h3>
<p class="overview"></p>
<div class="overview-controls">
<a class="overview-expand hide" is="emby-linkbutton" href="#">${ShowMore}</a>
</div>
<p id="itemBirthday"></p>
<p id="itemBirthLocation"></p>
<p id="itemDeathDate"></p>
<p id="seriesAirTime"></p>
<div class="itemTags focuscontainer-x hide" style="margin: 0.7em 0; font-size: 92%;"></div>
<div class="itemExternalLinks focuscontainer-x hide" style="margin: 0.7em 0; font-size: 92%;"></div>
<div class="seriesRecordingEditor"></div>
</div>
<div class="itemDetailsGroup">
<div class="detailsGroupItem genresGroup hide">
<div class="genresLabel label"></div>
<div class="genres content focuscontainer-x"></div>
</div>
<div class="detailsGroupItem directorsGroup hide">
<div class="directorsLabel label"></div>
<div class="directors content focuscontainer-x"></div>
</div>
<div class="detailsGroupItem writersGroup hide">
<div class="writersLabel label"></div>
<div class="writers content focuscontainer-x"></div>
</div>
<div class="detailsGroupItem studiosGroup hide">
<div class="studiosLabel label"></div>
<div class="studios content focuscontainer-x"></div>
</div>
</div>
</div>
</div>
<div id="seriesTimerScheduleSection" class="verticalSection detailVerticalSection hide" style="margin-top: -3em;">
<h2 class="sectionTitle">${Schedule}</h2>
<div id="seriesTimerSchedule" is="emby-itemscontainer" class="itemsContainer vertical-list padded-right" data-contextmenu="false"></div>
</div>
<div class="collectionItems hide"></div>
<div class="nextUpSection verticalSection detailVerticalSection hide">
<h2 class="sectionTitle sectionTitle-cards">${NextUp}</h2>
<div is="emby-itemscontainer" class="nextUpItems vertical-wrap padded-right"></div>
</div>
<div class="programGuideSection hide verticalSection detailVerticalSection">
<div class="programGuide"></div>
</div>
<div id="childrenCollapsible" class="hide verticalSection detailVerticalSection">
<h2 class="childrenSectionHeader sectionTitle sectionTitle-cards hide">
<span id="childrenTitle"></span>
<h2 class="sectionTitle sectionTitle-cards hide">
<span></span>
</h2>
<div id="childrenContent">
<div is="emby-itemscontainer" class="childrenItemsContainer itemsContainer padded-right" style="text-align: left;"></div>
<div>
<div is="emby-itemscontainer" class="itemsContainer padded-right" style="text-align: left;"></div>
</div>
</div>

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