Compare commits

..

161 Commits

Author SHA1 Message Date
mani
f6e13a8fa6 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-04-07 14:14:43 +02:00
Jellyfin Release Bot
6b6d7e5996 Bump version to 10.11.8 2026-04-05 15:08:18 -04:00
Bill Thornton
d03c5ca8ea Merge pull request #7796 from nielsvanvelzen/eager-api-init
Load device information lazily in connection manager
2026-04-04 17:19:03 -04:00
Niels van Velzen
de26159fab Initialize server notifications after apphost init 2026-04-03 17:40:07 +02:00
Niels van Velzen
e4677df320 Load device information lazily in connection manager 2026-04-03 17:39:39 +02:00
Jellyfin Release Bot
29a6a0fd50 Bump version to 10.11.7 2026-03-31 19:33:14 -04:00
Bill Thornton
e85fb0a679 Merge pull request #7261 from kevgrig/issue7260
Improve performance by disabling unnecessary user data when retrieving list of playlists
2026-03-27 11:18:25 -04:00
Kevin G
a5a4190da0 Improve performance by disabling unnecessary user data when retrieving list of playlists
Signed-off-by: Kevin G <kevin@myplaceonline.com>
2026-03-27 11:05:38 -04:00
Bill Thornton
89ab68f772 Merge pull request #7632 from theguymadmax/fix-music-video-playback
Fix music video random playback
2026-03-12 01:31:59 -04:00
Bill Thornton
b430f10501 Merge pull request #7645 from thornbill/playback-exit-crash
Fix crashes on playback exit
2026-03-12 01:19:39 -04:00
Bill Thornton
f4db8c05a7 Merge pull request #7391 from bjorntp/7374
Fix carousel buttons for "My Media"  appearing needlessly
2026-03-12 01:00:32 -04:00
Bill Thornton
8f622360e2 Merge pull request #7641 from dmitrylyzo/fix-autofocus
Add autofocus to ConnectionErrorPage and FallbackRoute
2026-03-12 00:53:56 -04:00
Bill Thornton
f15d1fd435 Fix this reference 2026-03-11 08:29:56 -04:00
Björn Tenje Persson
a837dd335f Added call to scrollHandler in scrollbuttons callback function 2026-03-08 09:41:21 +01:00
Bill Thornton
cfe005c9b5 Add media element check to renderTracksEvents 2026-03-02 14:41:26 -05:00
Bill Thornton
d94f78a43e Fix crashes on playback exit 2026-03-02 13:00:52 -05:00
Dmitry Lyzo
260e6d22bb Add autofocus to ConnectionErrorPage and FallbackRoute 2026-03-02 16:46:07 +03:00
theguymadmax
1a17c259de Fix music video random playback 2026-02-26 22:32:54 -05:00
Bill Thornton
0d58d9ed89 Merge pull request #7631 from agausmann/artwork-size-order
Order MediaMetadata.artwork from highest resolution to smallest
2026-02-26 17:34:10 -05:00
Adam Gausmann
74b3aec8a9 Order MediaMetadata.artwork from highest resolution to smallest
Fixes #7630
2026-02-26 11:50:42 -06:00
Bill Thornton
828698d8be Merge pull request #7565 from viown/add-polling-fallback 2026-02-26 09:30:54 -05:00
Bill Thornton
5638ee78b0 Merge pull request #7529 from viown/fix-now-playing-hiding-drawer
Add padding to mobile drawer
2026-02-25 09:24:28 -05:00
Andrew Rabert
b0c3c33e6e Mitigate pull_request_target privilege escalation
Hotfix — replaces pull_request_target with pull_request to stop
granting write permissions and secrets to fork PRs. Some workflows
will break; can be fixed properly later.
2026-02-20 19:10:53 -05:00
viown
12f4930509 Add polling fallback to sessions 2026-02-06 10:53:53 +03:00
Bill Thornton
6f24a18c31 Merge pull request #7528 from lmaotrigine/missing-episode-search 2026-01-31 15:03:27 -05:00
Bill Thornton
8a53a3d713 Merge pull request #7547 from JPVenson/rebase/xbox#109 2026-01-29 11:39:36 -05:00
JPVenson
b5decda242 typo 2026-01-29 14:33:14 +00:00
JPVenson
2fad9d6277 fixes jellyfin-xbox#109 2026-01-29 14:33:09 +00:00
lmaotrigine
ad6627be6e use includes instead of indexOf
Co-authored-by: viown <48097677+viown@users.noreply.github.com>
2026-01-29 00:45:59 +14:00
Isis Ebsen
f57ba2df9f update CONTRIBUTORS 2026-01-28 10:22:26 +00:00
Isis Ebsen
f26dd02871 Use display missing episodes setting in search
Re-implements #5661
2026-01-28 10:22:24 +00:00
Bill Thornton
cfc39f9983 Merge pull request #7514 from thornbill/card-a11y
Fix card and item list accessibility issues
2026-01-27 16:01:55 -05:00
Bill Thornton
67a0dcce43 Merge pull request #7541 from thornbill/refetch-random
Fix random sort items refetching
2026-01-27 15:57:27 -05:00
Bill Thornton
cb65a18998 Merge pull request #7526 from antaljanosbenjamin/allow-to-play-dolby-vision-with-fallbacks-on-webos
Allow to play Dolby Vision with fallback on webOS
2026-01-27 15:48:17 -05:00
Bill Thornton
1ee69611b5 Merge pull request #7518 from nielsvanvelzen/clientnames
Add client names to image.ts
2026-01-27 15:43:24 -05:00
Bill Thornton
62fa040b19 Merge pull request #7516 from nielsvanvelzen/nvv-wait-for-apphost
Wait for app host to initialize before proceeding
2026-01-27 15:42:24 -05:00
Bill Thornton
0682a1aa55 Merge pull request #7504 from viown/navigate-to-wizard-on-server-add
Navigate to wizard if not completed on server add
2026-01-27 15:41:04 -05:00
Bill Thornton
c46637f58d Merge pull request #7461 from ebkalderon/align-bidi-and-rtl-lyrics-correctly
Fix alignment of music lyrics containing RTL or bidi text
2026-01-27 15:39:19 -05:00
Bill Thornton
6e27a05ca4 Fix random sort items refetching 2026-01-27 15:33:30 -05:00
Niels van Velzen
4e35132b48 Merge pull request #7475 from FelixRoediger/fixLibraryMenuButtonDisappearing
Fix library menu button disappearing when name is too long
2026-01-27 11:32:56 +01:00
Felix Rödiger
ec5e41dab7 Fix library menu button disappearing when name is too long 2026-01-26 18:30:56 +01:00
viown
a385fb86fe Add padding to mobile drawer 2026-01-26 14:00:54 +03:00
Eyal Kalderon
0fdb3dafab Address review feedback: add .lyricsLineContainer class 2026-01-24 19:24:58 -05:00
János Benjamin Antal
9f0ae91fc5 Allow to play Dolby Vision with fallback on webOS 2026-01-24 15:52:49 +01:00
Niels van Velzen
e9728ab2dc Add client names to image.ts 2026-01-22 15:57:34 +01:00
Niels van Velzen
81d4141c9e Wait for app host to initialize before proceeding 2026-01-22 15:18:39 +01:00
Bill Thornton
9e50bf6247 Improve card accessibility 2026-01-21 17:01:17 -05:00
Bill Thornton
69016c6d51 Add titles to listview buttons 2026-01-21 17:00:46 -05:00
Jellyfin Release Bot
ea2abad3e1 Bump version to 10.11.6 2026-01-18 20:03:02 -05:00
viown
442a49ef41 Navigate to startup wizard if not completed 2026-01-18 06:19:46 +03: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
theguymadmax
e28d70d34c Enable alphapicker in more sort by options 2026-01-02 23:02:49 -05:00
Eyal Kalderon
ba94f73921 Correctly align lyrics containing bidirectional text (details page)
This commit improves the visual presentation of music lyrics in the
`itemDetails` controller such that they better handle bidirectional
text. With these changes, LTR/RTL/bi-di lyrics should be handled
correctly, even in cases where Jellyfin Web's overall text direction
differs from the lyrics being displayed.
2025-12-30 14:23:33 -05:00
Eyal Kalderon
6ae5802305 Correctly align lyrics containing bidirectional text (lyrics page)
This commit updates the live lyrics page to correctly horzintally-align
each `lyricsLine` according to the "primary language" being used. This
superior output is achieved by removing the per-`lyricsLine` `<bdi>`
elements and instead applying `dir="auto"` to the parent
`lyricsContainer` instead. This completely isolates the text direction
of the `lyricsContainer` from the rest of the Jellyfin UI, ensuring that
its contents always look correct regardless of the directionality of the
rest of the interface.

See below for more info on the global `dir=` HTML attribute:

https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir
2025-12-30 13:22:29 -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
195 changed files with 3132 additions and 4203 deletions

4
.github/CODEOWNERS vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

303
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jellyfin-web",
"version": "10.12.0",
"version": "10.11.8",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "jellyfin-web",
"version": "10.12.0",
"version": "10.11.8",
"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.202512091852",
"@jellyfin/sdk": "0.12.0",
"@jellyfin/ux-web": "1.0.0",
"@mui/icons-material": "6.4.12",
"@mui/material": "6.4.12",
@@ -54,11 +54,11 @@
"native-promise-only": "0.8.1",
"pdfjs-dist": "3.11.174",
"proxy-polyfill": "0.3.2",
"react": "19.2.3",
"react": "18.3.1",
"react-blurhash": "0.3.0",
"react-dom": "19.2.3",
"react-dom": "18.3.1",
"react-lazy-load-image-component": "1.6.3",
"react-router-dom": "7.11.0",
"react-router-dom": "6.30.1",
"resize-observer-polyfill": "1.5.1",
"screenfull": "6.0.2",
"sortablejs": "1.15.6",
@@ -81,8 +81,8 @@
"@types/loadable__component": "5.13.9",
"@types/lodash-es": "4.17.12",
"@types/markdown-it": "14.1.2",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/react": "18.3.23",
"@types/react-dom": "18.3.7",
"@types/react-lazy-load-image-component": "1.6.4",
"@types/sortablejs": "1.15.8",
"@typescript-eslint/parser": "8.35.1",
@@ -129,7 +129,6 @@
"ts-loader": "9.5.2",
"typescript": "5.8.3",
"typescript-eslint": "8.35.1",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack": "5.99.9",
"webpack-bundle-analyzer": "4.10.2",
@@ -4130,9 +4129,9 @@
"license": "LGPL-2.1-or-later AND (FTL OR GPL-2.0-or-later) AND MIT AND MIT-Modern-Variant AND ISC AND NTP AND Zlib AND BSL-1.0"
},
"node_modules/@jellyfin/sdk": {
"version": "0.0.0-unstable.202512091852",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202512091852.tgz",
"integrity": "sha512-N+QEsrKk4KculkV6KMBb7XpzTLWcXEzqTHbS+b4rov0VYVwR6DIsJkmUzB3hM2YZsrLIHEFKhFRy/r4itkFeHw==",
"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.12.0"
@@ -5087,6 +5086,15 @@
"react": ">=18"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
"integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz",
@@ -5888,22 +5896,23 @@
"dev": true
},
"node_modules/@types/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"version": "18.3.23",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
"@types/prop-types": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
"@types/react": "^18.0.0"
}
},
"node_modules/@types/react-lazy-load-image-component": {
@@ -9145,10 +9154,9 @@
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/currently-unhandled": {
"version": "0.4.1",
@@ -12360,13 +12368,6 @@
"integrity": "sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM=",
"dev": true
},
"node_modules/globrex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
"dev": true,
"license": "MIT"
},
"node_modules/gonzales-pe": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz",
@@ -18231,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": {
@@ -18350,10 +18352,12 @@
}
},
"node_modules/react": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
@@ -18368,15 +18372,15 @@
}
},
"node_modules/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"dependencies": {
"scheduler": "^0.27.0"
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^19.2.3"
"react": "^18.3.1"
}
},
"node_modules/react-is": {
@@ -18398,54 +18402,35 @@
}
},
"node_modules/react-router": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
"version": "6.30.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
"@remix-run/router": "1.23.0"
},
"engines": {
"node": ">=20.0.0"
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
"react": ">=16.8"
}
},
"node_modules/react-router-dom": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
"version": "6.30.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
"license": "MIT",
"dependencies": {
"react-router": "7.11.0"
"@remix-run/router": "1.23.0",
"react-router": "6.30.1"
},
"engines": {
"node": ">=20.0.0"
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-router/node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/react-transition-group": {
@@ -19431,10 +19416,12 @@
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/schema-utils": {
"version": "4.3.2",
@@ -19675,12 +19662,6 @@
"license": "ISC",
"optional": true
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -23206,27 +23187,6 @@
"node": ">=8"
}
},
"node_modules/tsconfck": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
"integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==",
"dev": true,
"license": "MIT",
"bin": {
"tsconfck": "bin/tsconfck.js"
},
"engines": {
"node": "^18 || >=20"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -23877,26 +23837,6 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-tsconfig-paths": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
"integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"globrex": "^0.1.2",
"tsconfck": "^3.0.3"
},
"peerDependencies": {
"vite": "*"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
}
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
@@ -27162,9 +27102,9 @@
"integrity": "sha512-C0OlBxIr9NdeFESMTA/OVDqNSWtog6Mi7wwzwH12xbZpxsMD0RgCupUcIP7zZgcpTNecW3fZq5d6xVo7Q8HEJw=="
},
"@jellyfin/sdk": {
"version": "0.0.0-unstable.202512091852",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202512091852.tgz",
"integrity": "sha512-N+QEsrKk4KculkV6KMBb7XpzTLWcXEzqTHbS+b4rov0VYVwR6DIsJkmUzB3hM2YZsrLIHEFKhFRy/r4itkFeHw==",
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.12.0.tgz",
"integrity": "sha512-do3cks7TD316Qw27lBMHZQ7ufaS1MC8HMsQF5rFv5/DUInuwEOqWthqVyHl3sIjThOThF1zxQyE6OdpUl0dNUg==",
"requires": {}
},
"@jellyfin/ux-web": {
@@ -27635,6 +27575,11 @@
"@react-hook/passive-layout-effect": "^1.2.0"
}
},
"@remix-run/router": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
"integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA=="
},
"@rollup/rollup-android-arm-eabi": {
"version": "4.40.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz",
@@ -28172,17 +28117,18 @@
"dev": true
},
"@types/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"version": "18.3.23",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
"requires": {
"csstype": "^3.2.2"
"@types/prop-types": "*",
"csstype": "^3.0.2"
}
},
"@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"requires": {}
},
@@ -30417,9 +30363,9 @@
}
},
"csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"currently-unhandled": {
"version": "0.4.1",
@@ -32738,12 +32684,6 @@
"integrity": "sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM=",
"dev": true
},
"globrex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
"dev": true
},
"gonzales-pe": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz",
@@ -36758,9 +36698,12 @@
}
},
"react": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"requires": {
"loose-envify": "^1.1.0"
}
},
"react-blurhash": {
"version": "0.3.0",
@@ -36769,11 +36712,12 @@
"requires": {}
},
"react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"requires": {
"scheduler": "^0.27.0"
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
}
},
"react-is": {
@@ -36791,27 +36735,20 @@
}
},
"react-router": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
"version": "6.30.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
"requires": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"dependencies": {
"cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="
}
"@remix-run/router": "1.23.0"
}
},
"react-router-dom": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
"version": "6.30.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
"requires": {
"react-router": "7.11.0"
"@remix-run/router": "1.23.0",
"react-router": "6.30.1"
}
},
"react-transition-group": {
@@ -37418,9 +37355,12 @@
}
},
"scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"requires": {
"loose-envify": "^1.1.0"
}
},
"schema-utils": {
"version": "4.3.2",
@@ -37613,11 +37553,6 @@
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"optional": true
},
"set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
},
"set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -40183,13 +40118,6 @@
}
}
},
"tsconfck": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
"integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==",
"dev": true,
"requires": {}
},
"tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -40631,17 +40559,6 @@
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
}
},
"vite-tsconfig-paths": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
"integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==",
"dev": true,
"requires": {
"debug": "^4.1.1",
"globrex": "^0.1.2",
"tsconfck": "^3.0.3"
}
},
"vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "jellyfin-web",
"version": "10.12.0",
"version": "10.11.8",
"description": "Web interface for Jellyfin",
"repository": "https://github.com/jellyfin/jellyfin-web",
"license": "GPL-2.0-or-later",
@@ -18,8 +18,8 @@
"@types/loadable__component": "5.13.9",
"@types/lodash-es": "4.17.12",
"@types/markdown-it": "14.1.2",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/react": "18.3.23",
"@types/react-dom": "18.3.7",
"@types/react-lazy-load-image-component": "1.6.4",
"@types/sortablejs": "1.15.8",
"@typescript-eslint/parser": "8.35.1",
@@ -66,7 +66,6 @@
"ts-loader": "9.5.2",
"typescript": "5.8.3",
"typescript-eslint": "8.35.1",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack": "5.99.9",
"webpack-bundle-analyzer": "4.10.2",
@@ -85,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.202512091852",
"@jellyfin/sdk": "0.12.0",
"@jellyfin/ux-web": "1.0.0",
"@mui/icons-material": "6.4.12",
"@mui/material": "6.4.12",
@@ -121,11 +120,11 @@
"native-promise-only": "0.8.1",
"pdfjs-dist": "3.11.174",
"proxy-polyfill": "0.3.2",
"react": "19.2.3",
"react": "18.3.1",
"react-blurhash": "0.3.0",
"react-dom": "19.2.3",
"react-dom": "18.3.1",
"react-lazy-load-image-component": "1.6.3",
"react-router-dom": "7.11.0",
"react-router-dom": "6.30.1",
"resize-observer-polyfill": "1.5.1",
"screenfull": "6.0.2",
"sortablejs": "1.15.6",
@@ -162,7 +161,7 @@
"build:check": "tsc --noEmit",
"build:es-check": "npm run build:production && npm run escheck",
"escheck": "es-check",
"lint": "eslint",
"lint": "eslint \"./\"",
"test": "vitest --watch=false --config vite.config.ts",
"test:watch": "vitest --config vite.config.ts",
"stylelint": "stylelint \"src/**/*.{css,scss}\""

View File

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

1
src/apiclient.d.ts vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -9,7 +9,6 @@ import useMediaQuery from '@mui/material/useMediaQuery';
import classNames from 'classnames';
import React, { type FC, useCallback } from 'react';
import { ItemAction } from 'constants/itemAction';
import { useApi } from 'hooks/useApi';
import { useLocalStorage } from 'hooks/useLocalStorage';
import { useGetItemsViewByType } from 'hooks/useFetchItems';
@@ -100,7 +99,7 @@ const ItemsView: FC<ItemsViewProps> = ({
if (viewType === LibraryTab.Songs) {
listOptions.showParentTitle = true;
listOptions.action = ItemAction.PlayAllFromHere;
listOptions.action = 'playallfromhere';
listOptions.smallIcon = true;
listOptions.showArtist = true;
listOptions.addToListButton = true;
@@ -222,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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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