Compare commits

...

113 Commits

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

* Fix Opera TV detection

* Move tv flag logic

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

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

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

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

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2025-10-29 18:52:37 +08:00
viown
fb7a1538d0 Fix tuner devices list 2025-10-29 08:22:49 +03:00
Bill Thornton
7491722364 Merge pull request #7272 from viown/use-legacy-grid-for-widgets-count 2025-10-29 00:30:57 -04:00
viown
d6c169321e Use legacy grid for item counts widget 2025-10-28 18:43:53 +03:00
Bill Thornton
6e2c62525a Merge pull request #7269 from viown/fix-log-viewer-client-logs 2025-10-28 08:01:51 -04:00
viown
09dc3ae3a8 Fix JSON in log viewer 2025-10-28 12:38:45 +03:00
Bill Thornton
e102334812 Merge pull request #7258 from nielsvanvelzen/titanos 2025-10-28 01:47:58 -04:00
Bill Thornton
907947c523 Merge pull request #7259 from nielsvanvelzen/no-native-dialog 2025-10-28 01:44:57 -04:00
Niels van Velzen
f3d7994b2a Replace indexOf usages with includes 2025-10-27 19:24:35 +01:00
Jellyfin Release Bot
b9fdc61b6d Bump version to 10.11.1 2025-10-26 22:02:12 -04:00
Niels van Velzen
37dcc07da5 Avoid native browser prompts 2025-10-26 15:14:49 +01:00
Niels van Velzen
e4e2c97bd5 Avoid native browser confirms 2025-10-26 15:14:40 +01:00
Niels van Velzen
6ce3e579c2 Update browser declaration 2025-10-26 15:09:37 +01:00
Niels van Velzen
dbcac4c6f4 Avoid native browser alerts 2025-10-26 15:02:41 +01:00
Niels van Velzen
c11d630e42 Add Titan OS detection 2025-10-26 14:54:39 +01:00
Bill Thornton
7643885c6b Merge pull request #7252 from bernarden/try-catch-on-all-actions
Wraps registration of all mediaSession action handlers in try catch.
2025-10-24 10:52:15 -04:00
Bernarden
92a1aa16dc Wraps registration of all mediaSession action handlers in try catch. 2025-10-24 18:29:24 +13:00
Bill Thornton
4560d7c90f Merge pull request #7250 from thornbill/sdk-0.12.0 2025-10-23 09:16:22 -04:00
Bill Thornton
e97d658b3c Update SDK to 0.12.0 stable 2025-10-23 08:14:52 -04:00
Bill Thornton
7c0c2e088f Merge pull request #7248 from thornbill/card-album-artists
Fix multiple album artists in card footer
2025-10-23 08:10:03 -04:00
Peaches_MLG
0989a3034f Fixed issue where waiting event is not being called correctly (#7245) 2025-10-22 21:10:26 -04:00
Peaches_MLG
17a1e2e94c Fix unpause and pause references in syncplay video player (#7227) 2025-10-22 21:06:13 -04:00
Bill Thornton
b5382f0142 Fix card links for multiple album artists in experimental layout 2025-10-22 14:59:40 -04:00
Bill Thornton
12079b9462 Merge pull request #7221 from SohamGanmote/fix/syncplay-speedto-sync-min
Fix: Add minimum value 0 for SyncPlay Settings SpeedToSync input
2025-10-22 13:07:14 -04:00
SohamGanmote
6a55ee3d71 Fix: Add proper validation for SyncPlay Settings inputs 2025-10-22 21:59:12 +05:30
Bill Thornton
6ee77f18bc Fix card links for multiple album artists 2025-10-22 11:00:54 -04:00
Bill Thornton
db7498ed03 Revert "Show all album artists on cards (#6929)"
This reverts commit 4f9a105921.
2025-10-22 10:51:33 -04:00
Bill Thornton
4f83e97592 Merge pull request #7233 from viown/increase-backup-interval
Increase restore check interval to 45s
2025-10-22 10:18:37 -04:00
Bill Thornton
4b072633fb Merge pull request #7241 from thornbill/tv-overflow
Revert scroller overflow change for tv layout
2025-10-22 10:15:35 -04:00
Bill Thornton
0772f146b4 Revert scroller overflow change for tv layout 2025-10-22 10:01:41 -04:00
Bill Thornton
0bb8f7cb47 Merge pull request #7224 from theguymadmax/fix-background-rotation
Enable backdrop image rotation in Firefox
2025-10-22 09:44:14 -04:00
Bill Thornton
f7583a842b Merge pull request #7240 from thornbill/try-stop-action
Handle browsers lacking stop media session action support
2025-10-22 09:29:45 -04:00
theguymadmax
45bca06b2c Enable backdrop image rotation in Firefox 2025-10-22 09:24:03 -04:00
Bill Thornton
c688faacb8 Handle browsers lacking stop media session action support 2025-10-22 09:20:17 -04:00
viown
737b85b0b6 Increase restore check interval to 45s 2025-10-21 15:47:29 +03:00
Bill Thornton
81698d5da7 Merge pull request #7219 from thornbill/forward-port-6583
Fix skip button not displaying correctly with OSD (#6583)
2025-10-20 10:53:15 -04:00
rlauuzo
64fbd6d3de Fix skip button not displaying correctly with OSD (#6583) 2025-10-20 10:13:08 -04:00
Jellyfin Release Bot
fa7831bd1f Bump version to 10.11.0 2025-10-19 20:45:15 -04:00
60 changed files with 767 additions and 608 deletions

54
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jellyfin-web",
"version": "10.11.0",
"version": "10.11.6",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "jellyfin-web",
"version": "10.11.0",
"version": "10.11.6",
"license": "GPL-2.0-or-later",
"dependencies": {
"@emotion/react": "11.14.0",
@@ -18,7 +18,7 @@
"@fontsource/noto-sans-sc": "5.2.6",
"@fontsource/noto-sans-tc": "5.2.6",
"@jellyfin/libass-wasm": "4.2.3",
"@jellyfin/sdk": "0.0.0-unstable.202510030502",
"@jellyfin/sdk": "0.12.0",
"@jellyfin/ux-web": "1.0.0",
"@mui/icons-material": "6.4.12",
"@mui/material": "6.4.12",
@@ -4129,12 +4129,12 @@
"license": "LGPL-2.1-or-later AND (FTL OR GPL-2.0-or-later) AND MIT AND MIT-Modern-Variant AND ISC AND NTP AND Zlib AND BSL-1.0"
},
"node_modules/@jellyfin/sdk": {
"version": "0.0.0-unstable.202510030502",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202510030502.tgz",
"integrity": "sha512-khxWQ4dKirp03sLCkz8s+MrfAmJUGD4xTfVHRq3NOsgz8ueDH3qVxdya8YnV8U83p+bfZpGtnf1IAvyh+f959g==",
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.12.0.tgz",
"integrity": "sha512-do3cks7TD316Qw27lBMHZQ7ufaS1MC8HMsQF5rFv5/DUInuwEOqWthqVyHl3sIjThOThF1zxQyE6OdpUl0dNUg==",
"license": "MPL-2.0",
"peerDependencies": {
"axios": "^1.3.4"
"axios": "^1.12.0"
}
},
"node_modules/@jellyfin/ux-web": {
@@ -7233,6 +7233,7 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT",
"peer": true
},
"node_modules/atob": {
@@ -7312,14 +7313,14 @@
}
},
"node_modules/axios": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
@@ -8420,6 +8421,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"peer": true,
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -9509,6 +9511,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.4.0"
@@ -11962,15 +11965,16 @@
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"peer": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
@@ -18228,6 +18232,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT",
"peer": true
},
"node_modules/proxy-polyfill": {
@@ -27097,9 +27102,9 @@
"integrity": "sha512-C0OlBxIr9NdeFESMTA/OVDqNSWtog6Mi7wwzwH12xbZpxsMD0RgCupUcIP7zZgcpTNecW3fZq5d6xVo7Q8HEJw=="
},
"@jellyfin/sdk": {
"version": "0.0.0-unstable.202510030502",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202510030502.tgz",
"integrity": "sha512-khxWQ4dKirp03sLCkz8s+MrfAmJUGD4xTfVHRq3NOsgz8ueDH3qVxdya8YnV8U83p+bfZpGtnf1IAvyh+f959g==",
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.12.0.tgz",
"integrity": "sha512-do3cks7TD316Qw27lBMHZQ7ufaS1MC8HMsQF5rFv5/DUInuwEOqWthqVyHl3sIjThOThF1zxQyE6OdpUl0dNUg==",
"requires": {}
},
"@jellyfin/ux-web": {
@@ -29097,13 +29102,13 @@
"dev": true
},
"axios": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"peer": true,
"requires": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
@@ -32390,14 +32395,15 @@
}
},
"form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"peer": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
}
},

View File

@@ -1,6 +1,6 @@
{
"name": "jellyfin-web",
"version": "10.11.0",
"version": "10.11.6",
"description": "Web interface for Jellyfin",
"repository": "https://github.com/jellyfin/jellyfin-web",
"license": "GPL-2.0-or-later",
@@ -84,7 +84,7 @@
"@fontsource/noto-sans-sc": "5.2.6",
"@fontsource/noto-sans-tc": "5.2.6",
"@jellyfin/libass-wasm": "4.2.3",
"@jellyfin/sdk": "0.0.0-unstable.202510030502",
"@jellyfin/sdk": "0.12.0",
"@jellyfin/ux-web": "1.0.0",
"@mui/icons-material": "6.4.12",
"@mui/material": "6.4.12",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1024,7 +1024,7 @@ export class PlaybackManager {
self.canPlay = function (item) {
const itemType = item.Type;
if (itemType === 'PhotoAlbum' || itemType === 'MusicGenre' || itemType === 'Season' || itemType === 'Series' || itemType === 'BoxSet' || itemType === 'MusicAlbum' || itemType === 'MusicArtist' || itemType === 'Playlist') {
if (itemType === 'Book' || itemType === 'PhotoAlbum' || itemType === 'MusicGenre' || itemType === 'Season' || itemType === 'Series' || itemType === 'BoxSet' || itemType === 'MusicAlbum' || itemType === 'MusicArtist' || itemType === 'Playlist') {
return true;
}
@@ -1980,28 +1980,18 @@ export class PlaybackManager {
const startSeasonId = firstItem.Type === 'Season' ? items[options.startIndex || 0].Id : undefined;
const seasonId = (startSeasonId && items.length === 1) ? startSeasonId : undefined;
const seriesId = firstItem.SeriesId || firstItem.Id;
const SeriesId = firstItem.SeriesId || firstItem.Id;
const UserId = apiClient.getCurrentUserId();
let startItemId;
// Start from a specific (the next unwatched) episode if we want to watch in order and have not chosen a specific season
if (!options.shuffle && !seasonId) {
const initialUnplayedEpisode = await getItems(apiClient, UserId, {
SortBy: 'SeriesSortName,SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Episode',
Recursive: true,
IsMissing: false,
ParentId: seriesId,
limit: 1,
Filters: 'IsUnplayed'
});
startItemId = initialUnplayedEpisode?.Items?.at(0)?.Id;
const nextUp = await apiClient.getNextUpEpisodes({ SeriesId, UserId });
startItemId = nextUp?.Items?.[0]?.Id;
}
const episodesResult = await apiClient.getEpisodes(seriesId, {
const episodesResult = await apiClient.getEpisodes(SeriesId, {
IsVirtualUnaired: false,
IsMissing: false,
SeasonId: seasonId,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,6 +69,7 @@ const PlayedButton: FC<PlayedButtonProps> = ({
);
return (
<IconButton
data-action='none'
title={getTitle()}
className={btnClass}
size='small'

View File

@@ -56,6 +56,7 @@ const FavoriteButton: FC<FavoriteButtonProps> = ({
return (
<IconButton
data-action='none'
title={isFavorite ? globalize.translate('Favorite') : globalize.translate('AddToFavorites')}
className={btnClass}
size='small'

View File

@@ -1,7 +1,7 @@
import { useThemes } from './useThemes';
import { useUserSettings } from './useUserSettings';
const FALLBACK_THEME_ID = 'dark';
export const FALLBACK_THEME_ID = 'dark';
export function useUserTheme() {
const { theme, dashboardTheme } = useUserSettings();

View File

@@ -765,6 +765,10 @@ const scrollerFactory = function (frame, options) {
}
}
} else {
if (layoutManager.tv) {
frame.style.overflow = 'hidden';
}
slideeElement.style['will-change'] = 'transform';
slideeElement.style.transition = 'transform ' + o.speed + 'ms ease-out';

View File

@@ -32,6 +32,7 @@ export class BookPlayer {
this.type = PluginType.MediaPlayer;
this.id = 'bookplayer';
this.priority = 1;
this.THEMES = THEMES;
if (!userSettings.theme() || userSettings.theme() === 'dark') {
this.theme = 'dark';
} else {

View File

@@ -22,12 +22,12 @@
#btnBookplayerToc {
float: left;
margin-left: 2vw;
margin: 0.5vh 0.5vh 0.5vh 2vw;
}
#btnBookplayerExit {
float: right;
margin-right: 2vw;
margin: 0.5vh 2vw 0.5vh 0.5vh;
}
.bookplayerErrorMsg {
@@ -46,13 +46,20 @@
width: fit-content;
max-height: 80%;
max-width: 60%;
padding-right: 50px;
padding-bottom: 15px;
.bookplayerButtonIcon {
color: black;
}
.bookplayerButton {
margin: 0.5vh;
}
.toc {
margin: 0;
padding: 30px;
}
.toc li {
margin-bottom: 5px;
@@ -80,3 +87,10 @@
}
}
}
@media (max-width: 60em) {
#dialogToc {
max-width: 100%;
max-height: 100%;
}
}

View File

@@ -1,5 +1,6 @@
import escapeHTML from 'escape-html';
import dialogHelper from '../../components/dialogHelper/dialogHelper';
import layoutManager from 'components/layoutManager';
export default class TableOfContents {
constructor(bookPlayer) {
@@ -57,7 +58,7 @@ export default class TableOfContents {
// remove parent directory reference from href to fix certain books
const link = chapter.href.startsWith('../') ? chapter.href.slice(3) : chapter.href;
itemHtml += `<a href="${escapeHTML(book.path.directory + link)}">${escapeHTML(chapter.label)}</a>`;
itemHtml += `<a style="color: ${layoutManager.mobile ? this.bookPlayer.THEMES[this.bookPlayer.theme].body.color : 'inherit'}" href="${escapeHTML(book.path.directory + link)}">${escapeHTML(chapter.label)}</a>`;
if (chapter.subitems?.length) {
const subHtml = chapter.subitems
@@ -85,7 +86,7 @@ export default class TableOfContents {
let tocHtml = '<div class="topRightActionButtons">';
tocHtml += '<button is="paper-icon-button-light" class="autoSize bookplayerButton btnBookplayerTocClose hide-mouse-idle-tv" tabindex="-1"><span class="material-icons bookplayerButtonIcon close" aria-hidden="true"></span></button>';
tocHtml += '</div>';
tocHtml += '<ul class="toc">';
tocHtml += `<ul style="background-color: ${layoutManager.mobile ? this.bookPlayer.THEMES[this.bookPlayer.theme].body.background : 'inherit'}" class="toc">`;
rendition.book.navigation.forEach((chapter) => {
tocHtml += this.chapterTocItem(rendition.book, chapter);
});

View File

@@ -1064,9 +1064,9 @@ export class HtmlVideoPlayer {
Events.trigger(this, 'pause');
};
onWaiting() {
onWaiting = () => {
Events.trigger(this, 'waiting');
}
};
/**
* @private

View File

@@ -96,8 +96,8 @@ class HtmlVideoPlayer extends NoActivePlayer {
Events.off(this.player, 'playbackstart', this._onPlaybackStart);
Events.off(this.player, 'playbackstop', this._onPlaybackStop);
Events.off(this.player, 'unpause', this._onPlayerUnpause);
Events.off(this.player, 'pause', this._onPlayerPause);
Events.off(this.player, 'unpause', this._onUnpause);
Events.off(this.player, 'pause', this._onPause);
Events.off(this.player, 'timeupdate', this._onTimeUpdate);
Events.off(this.player, 'playing', this._onPlaying);
Events.off(this.player, 'waiting', this._onWaiting);

View File

@@ -34,12 +34,12 @@
</div>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtMaxDelaySpeedToSync" pattern="[0-9]*"
label="${LabelSyncPlaySettingsMaxDelaySpeedToSync}" />
label="${LabelSyncPlaySettingsMaxDelaySpeedToSync}" min="0"/>
<div class="fieldDescription">${LabelSyncPlaySettingsMaxDelaySpeedToSyncHelp}</div>
</div>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtSpeedToSyncDuration" pattern="[0-9]*"
label="${LabelSyncPlaySettingsSpeedToSyncDuration}" />
label="${LabelSyncPlaySettingsSpeedToSyncDuration}" min="0"/>
<div class="fieldDescription">${LabelSyncPlaySettingsSpeedToSyncDurationHelp}</div>
</div>
@@ -53,7 +53,7 @@
</div>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtMinDelaySkipToSync" pattern="[0-9]*"
label="${LabelSyncPlaySettingsMinDelaySkipToSync}" />
label="${LabelSyncPlaySettingsMinDelaySkipToSync}" min="0"/>
<div class="fieldDescription">${LabelSyncPlaySettingsMinDelaySkipToSyncHelp}</div>
</div>
@@ -61,7 +61,7 @@
<h2 class="sectionTitle">${HeaderSyncPlayTimeSyncSettings}</h2>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtExtraTimeOffset" pattern="[0-9]*"
label="${LabelSyncPlaySettingsExtraTimeOffset}" />
label="${LabelSyncPlaySettingsExtraTimeOffset}" min="0"/>
<div class="fieldDescription">${LabelSyncPlaySettingsExtraTimeOffsetHelp}</div>
</div>
</form>

View File

@@ -21,8 +21,10 @@ declare namespace browser {
export let animate: boolean;
export let hisense: boolean;
export let tizen: boolean;
export let vega: boolean;
export let vidaa: boolean;
export let web0s: boolean;
export let titanos: boolean;
export let edgeUwp: boolean;
export let web0sVersion: number | undefined;
export let tizenVersion: number | undefined;
@@ -36,4 +38,6 @@ declare namespace browser {
export let iOSVersion: number | undefined;
}
export function detectBrowser(userAgent?: string): browser;
export default browser;

View File

@@ -1,32 +1,33 @@
function isTv() {
function isTv(userAgent) {
// This is going to be really difficult to get right
const userAgent = navigator.userAgent.toLowerCase();
// The OculusBrowsers userAgent also has the samsungbrowser defined but is not a tv.
if (userAgent.indexOf('oculusbrowser') !== -1) {
if (userAgent.includes('oculusbrowser')) {
return false;
}
if (userAgent.indexOf('tv') !== -1) {
if (userAgent.includes('tv')) {
return true;
}
if (userAgent.indexOf('samsungbrowser') !== -1) {
if (userAgent.includes('samsungbrowser')) {
return true;
}
if (userAgent.indexOf('viera') !== -1) {
if (userAgent.includes('viera')) {
return true;
}
return isWeb0s();
if (userAgent.includes('titanos')) {
return true;
}
return isWeb0s(userAgent);
}
function isWeb0s() {
const userAgent = navigator.userAgent.toLowerCase();
return userAgent.indexOf('netcast') !== -1
|| userAgent.indexOf('web0s') !== -1;
function isWeb0s(userAgent) {
return userAgent.includes('netcast')
|| userAgent.includes('web0s');
}
function isMobile(userAgent) {
@@ -42,10 +43,8 @@ function isMobile(userAgent) {
'opera mini'
];
const lower = userAgent.toLowerCase();
for (let i = 0, length = terms.length; i < length; i++) {
if (lower.indexOf(terms[i]) !== -1) {
for (const term of terms) {
if (userAgent.includes(term)) {
return true;
}
}
@@ -105,7 +104,7 @@ function web0sVersion(browser) {
if (browser.chrome) {
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.indexOf('netcast') !== -1) {
if (userAgent.includes('netcast')) {
// The built-in browser (NetCast) may have a version that doesn't correspond to the actual web engine
// Since there is no reliable way to detect webOS version, we return an undefined version
@@ -187,20 +186,20 @@ function supportsCssAnimation(allowPrefix) {
}
const uaMatch = function (ua) {
ua = ua.toLowerCase();
// Motorola Edge device UA triggers false positive for Edge browser
ua = ua.replace(/(motorola edge)/, '').trim();
const match = /(edg)[ /]([\w.]+)/.exec(ua)
|| /(edga)[ /]([\w.]+)/.exec(ua)
|| /(edgios)[ /]([\w.]+)/.exec(ua)
|| /(edge)[ /]([\w.]+)/.exec(ua)
|| /(titanos)[ /]([\w.]+)/.exec(ua)
|| /(opera)[ /]([\w.]+)/.exec(ua)
|| /(opr)[ /]([\w.]+)/.exec(ua)
|| /(chrome)[ /]([\w.]+)/.exec(ua)
|| /(safari)[ /]([\w.]+)/.exec(ua)
|| /(firefox)[ /]([\w.]+)/.exec(ua)
|| ua.indexOf('compatible') < 0 && /(mozilla)(?:.*? rv:([\w.]+)|)/.exec(ua)
|| !ua.includes('compatible') && /(mozilla)(?:.*? rv:([\w.]+)|)/.exec(ua)
|| [];
const versionMatch = /(version)[ /]([\w.]+)/.exec(ua);
@@ -209,6 +208,7 @@ const uaMatch = function (ua) {
|| /(iphone)/.exec(ua)
|| /(windows)/.exec(ua)
|| /(android)/.exec(ua)
|| /(titanos)/.exec(ua)
|| [];
let browser = match[1] || '';
@@ -235,102 +235,112 @@ const uaMatch = function (ua) {
}
return {
browser: browser,
version: version,
browser,
version,
platform: platformMatch[0] || '',
versionMajor: versionMajor
versionMajor
};
};
const userAgent = navigator.userAgent;
export const detectBrowser = (userAgent = navigator.userAgent) => {
const normalizedUA = userAgent.toLowerCase();
const matched = uaMatch(userAgent);
const browser = {};
const matched = uaMatch(normalizedUA);
const browser = {};
if (matched.browser) {
browser[matched.browser] = true;
browser.version = matched.version;
browser.versionMajor = matched.versionMajor;
}
if (matched.platform) {
browser[matched.platform] = true;
}
browser.edgeChromium = browser.edg || browser.edga || browser.edgios;
if (!browser.chrome && !browser.edgeChromium && !browser.edge && !browser.opera && userAgent.toLowerCase().indexOf('webkit') !== -1) {
browser.safari = true;
}
browser.osx = userAgent.toLowerCase().indexOf('mac os x') !== -1;
// This is a workaround to detect iPads on iOS 13+ that report as desktop Safari
// This may break in the future if Apple releases a touchscreen Mac
// https://forums.developer.apple.com/thread/119186
if (browser.osx && !browser.iphone && !browser.ipod && !browser.ipad && navigator.maxTouchPoints > 1) {
browser.ipad = true;
}
if (userAgent.toLowerCase().indexOf('playstation 4') !== -1) {
browser.ps4 = true;
browser.tv = true;
}
if (isMobile(userAgent)) {
browser.mobile = true;
}
if (userAgent.toLowerCase().indexOf('xbox') !== -1) {
browser.xboxOne = true;
browser.tv = true;
}
browser.animate = typeof document !== 'undefined' && document.documentElement.animate != null;
browser.hisense = userAgent.toLowerCase().includes('hisense');
browser.tizen = userAgent.toLowerCase().indexOf('tizen') !== -1 || window.tizen != null;
browser.vidaa = userAgent.toLowerCase().includes('vidaa');
browser.web0s = isWeb0s();
browser.edgeUwp = (browser.edge || browser.edgeChromium) && (userAgent.toLowerCase().indexOf('msapphost') !== -1 || userAgent.toLowerCase().indexOf('webview') !== -1);
if (browser.web0s) {
browser.web0sVersion = web0sVersion(browser);
// UserAgent string contains 'Chrome' and 'Safari', but we only want 'web0s' to be true
delete browser.chrome;
delete browser.safari;
} else if (browser.tizen) {
const v = RegExp(/Tizen (\d+).(\d+)/).exec(userAgent);
browser.tizenVersion = parseInt(v[1], 10) + parseInt(v[2], 10) / 10;
// UserAgent string contains 'Chrome' and 'Safari', but we only want 'tizen' to be true
delete browser.chrome;
delete browser.safari;
} else {
browser.orsay = userAgent.toLowerCase().indexOf('smarthub') !== -1;
}
browser.tv = isTv();
browser.operaTv = browser.tv && userAgent.toLowerCase().indexOf('opr/') !== -1;
if (browser.mobile || browser.tv) {
browser.slow = true;
}
if (typeof document !== 'undefined' && ('ontouchstart' in window) || (navigator.maxTouchPoints > 0)) {
browser.touch = true;
}
browser.keyboard = hasKeyboard(browser);
browser.supportsCssAnimation = supportsCssAnimation;
browser.iOS = browser.ipad || browser.iphone || browser.ipod;
if (browser.iOS) {
browser.iOSVersion = iOSversion();
if (browser.iOSVersion && browser.iOSVersion.length >= 2) {
browser.iOSVersion = browser.iOSVersion[0] + (browser.iOSVersion[1] / 10);
if (matched.browser) {
browser[matched.browser] = true;
browser.version = matched.version;
browser.versionMajor = matched.versionMajor;
}
}
export default browser;
if (matched.platform) {
browser[matched.platform] = true;
}
browser.edgeChromium = browser.edg || browser.edga || browser.edgios;
if (!browser.chrome && !browser.edgeChromium && !browser.edge && !browser.opera && normalizedUA.includes('webkit')) {
browser.safari = true;
}
browser.osx = normalizedUA.includes('mac os x');
// This is a workaround to detect iPads on iOS 13+ that report as desktop Safari
// This may break in the future if Apple releases a touchscreen Mac
// https://forums.developer.apple.com/thread/119186
if (browser.osx && !browser.iphone && !browser.ipod && !browser.ipad && navigator.maxTouchPoints > 1) {
browser.ipad = true;
}
if (isMobile(normalizedUA)) {
browser.mobile = true;
}
browser.ps4 = normalizedUA.includes('playstation 4');
browser.xboxOne = normalizedUA.includes('xbox');
browser.animate = typeof document !== 'undefined' && document.documentElement.animate != null;
browser.hisense = normalizedUA.includes('hisense');
browser.tizen = normalizedUA.includes('tizen') || window.tizen != null;
browser.vega = normalizedUA.includes('kepler');
browser.vidaa = normalizedUA.includes('vidaa');
browser.web0s = isWeb0s(normalizedUA);
browser.tv = browser.ps4 || browser.vega || browser.xboxOne || isTv(normalizedUA);
browser.operaTv = browser.tv && normalizedUA.includes('opr/');
browser.edgeUwp = (browser.edge || browser.edgeChromium) && (normalizedUA.includes('msapphost') || normalizedUA.includes('webview'));
if (browser.web0s) {
browser.web0sVersion = web0sVersion(browser);
// UserAgent string contains 'Chrome' and 'Safari', but we only want 'web0s' to be true
delete browser.chrome;
delete browser.safari;
} else if (browser.tizen) {
const v = RegExp(/Tizen (\d+).(\d+)/).exec(userAgent);
browser.tizenVersion = parseInt(v[1], 10) + parseInt(v[2], 10) / 10;
// UserAgent string contains 'Chrome' and 'Safari', but we only want 'tizen' to be true
delete browser.chrome;
delete browser.safari;
} else if (browser.titanos) {
// UserAgent string contains 'Opr' and 'Safari', but we only want 'titanos' to be true
delete browser.operaTv;
delete browser.safari;
} else if (browser.vega) {
// UserAgent string contains 'Chrome' and 'Safari', but we only want 'vega' to be true
delete browser.chrome;
delete browser.safari;
// UserAgent string contains 'Mobile Chrome', but it is a TV
delete browser.mobile;
} else {
browser.orsay = normalizedUA.includes('smarthub');
}
if (browser.mobile || browser.tv) {
browser.slow = true;
}
if (typeof document !== 'undefined' && ('ontouchstart' in window) || (navigator.maxTouchPoints > 0)) {
browser.touch = true;
}
browser.keyboard = hasKeyboard(browser);
browser.supportsCssAnimation = supportsCssAnimation;
browser.iOS = browser.ipad || browser.iphone || browser.ipod;
if (browser.iOS) {
browser.iOSVersion = iOSversion();
if (browser.iOSVersion && browser.iOSVersion.length >= 2) {
browser.iOSVersion = browser.iOSVersion[0] + (browser.iOSVersion[1] / 10);
}
}
return browser;
};
export default detectBrowser();

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { detectBrowser } from './browser';
describe('Browser', () => {
it('should identify TitanOS devices', () => {
// Ref: https://docs.titanos.tv/user-agents-specifications
// Philips example
let browser = detectBrowser('Mozilla/5.0 (Linux armv7l) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.4147.62 Safari/537.36 OPR/46.0.2207.0 OMI/4.24, TV_NT72690_2025_4K /<SW version> (Philips, <CTN>, wired) CE-HTML/1.0 NETTV/4.6.0.8 SignOn/2.0 SmartTvA/5.0.0 TitanOS/3.0 en Ginga');
expect(browser.titanos).toBe(true);
expect(browser.operaTv).toBeFalsy();
expect(browser.safari).toBeFalsy();
expect(browser.tv).toBe(true);
// JVC example
browser = detectBrowser('Mozilla/5.0 (Linux ) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.128 Safari/537.36 OMI/4.24.3.93.MIKE.227 Model/Vestel-MB190 VSTVB MB100 FVC/9.0 (VESTEL; MB190; ) HbbTV/1.7.1 (+DRM; VESTEL; MB190; 0.9.0.0; ; _TV__2025;) TitanOS/3.0 (Vestel MB190 VESTEL) SmartTvA/3.0.0');
expect(browser.titanos).toBe(true);
expect(browser.operaTv).toBeFalsy();
expect(browser.safari).toBeFalsy();
expect(browser.tv).toBe(true);
});
it('should identify Vega devices', () => {
// Ref: https://developer.amazon.com/docs/vega/0.21/webview-development-best-practices-tv.html#avoid-relying-on-the-useragent
const browser = detectBrowser('Mozilla/5.0 (Linux; Kepler 1.1; AFTCA002 user/1234; wv) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Chrome/130.0.6723.192 Safari/537.36');
expect(browser.vega).toBe(true);
expect(browser.chrome).toBeFalsy();
expect(browser.safari).toBeFalsy();
expect(browser.mobile).toBeFalsy();
expect(browser.tv).toBe(true);
});
it('should identify Xbox devices', () => {
const browser = detectBrowser('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0 WebView2 Xbox');
expect(browser.xboxOne).toBe(true);
expect(browser.tv).toBe(true);
});
});

View File

@@ -24,6 +24,11 @@ function canPlayHevc(videoTestElement, options) {
}
function canPlayAv1(videoTestElement) {
// Xbox UWP WebView2 falsely reports AV1 support but cannot play it
if (browser.xboxOne) {
return false;
}
if (browser.tizenVersion >= 5.5 || browser.web0sVersion >= 5) {
return true;
}
@@ -137,14 +142,6 @@ function supportsEac3(videoTestElement) {
}
function supportsAc3InHls(videoTestElement) {
// We use hls.js on WebOS 4 and newer and hls.js uses Media Sources Extensions (MSE) API.
// On WebOS MSE does support AC-3 and EAC-3 only on audio mp4 file but not on audiovideo mp4
// therefore until audio and video is not separated when generating stream and m3u8 this should
// return false.
if (browser.web0sVersion >= 4) {
return false;
}
if (browser.tizen || browser.web0s) {
return true;
}
@@ -178,6 +175,11 @@ function canPlayAudioFormat(format) {
return true;
}
} else if (format === 'opus') {
// Xbox UWP WebView2 falsely reports Opus support but cannot play it
if (browser.xboxOne) {
return false;
}
if (browser.web0s) {
// canPlayType lies about OPUS support
return browser.web0sVersion >= 3.5;
@@ -213,6 +215,13 @@ function testCanPlayMkv(videoTestElement) {
return true;
}
if (browser.firefox) {
// As of Firefox 145, its mkv support is buggy and causes playback issues because it would force preloading the
// whole mkv file before playback starts, which is extremely undesirable for streaming.
// See https://github.com/jellyfin/jellyfin/issues/15521
return false;
}
if (videoTestElement.canPlayType('video/x-matroska').replace(/no/, '')
|| videoTestElement.canPlayType('video/mkv').replace(/no/, '')) {
return true;
@@ -924,7 +933,7 @@ export default function (options) {
profile.ContainerProfiles = [];
if (browser.tizen) {
if (browser.tizenVersion < 6.5) {
// Tizen doesn't support more than 32 streams in a single file
profile.ContainerProfiles.push({
Type: 'Video',
@@ -1147,6 +1156,13 @@ export default function (options) {
hevcProfiles = 'main|main 10';
}
// hevc main10 level 6.2
if (videoTestElement.canPlayType('video/mp4; codecs="hvc1.2.4.L186"').replace(/no/, '')
|| videoTestElement.canPlayType('video/mp4; codecs="hev1.2.4.L186"').replace(/no/, '')) {
maxHevcLevel = 186;
hevcProfiles = 'main|main 10';
}
let maxAv1Level = 15; // level 5.3
const av1Profiles = 'main'; // av1 main covers 4:2:0 8 & 10 bits
@@ -1184,12 +1200,18 @@ export default function (options) {
}
if (supportsHdr10(options)) {
hevcVideoRangeTypes += '|HDR10';
vp9VideoRangeTypes += '|HDR10';
av1VideoRangeTypes += '|HDR10';
// HDR10+ videos can be safely played on all HDR10 capable devices, just without the dynamic metadata.
hevcVideoRangeTypes += '|HDR10|HDR10Plus';
vp9VideoRangeTypes += '|HDR10|HDR10Plus';
av1VideoRangeTypes += '|HDR10|HDR10Plus';
if (browser.tizenVersion >= 3 || browser.vidaa) {
hevcVideoRangeTypes += '|DOVIWithHDR10';
// Tizen TV does not support Dolby Vision at all, but it can safely play the HDR fallback.
// Advertising the support so that the server doesn't have to remux.
hevcVideoRangeTypes += '|DOVIWithHDR10|DOVIWithHDR10Plus|DOVIWithEL|DOVIWithELHDR10Plus|DOVIInvalid';
// Although no official tools exist to create AV1+DV files yet, some of our users managed to use community tools to create such files.
// These files should also be playable on Tizen TVs.
av1VideoRangeTypes += '|DOVIWithHDR10|DOVIWithHDR10Plus|DOVIWithEL|DOVIWithELHDR10Plus|DOVIInvalid';
}
}
@@ -1209,11 +1231,22 @@ export default function (options) {
hevcVideoRangeTypes += '|DOVI';
}
if (profiles.includes(8)) {
hevcVideoRangeTypes += '|DOVIWithHDR10|DOVIWithHLG|DOVIWithSDR';
hevcVideoRangeTypes += '|DOVIWithHDR10|DOVIWithHLG|DOVIWithSDR|DOVIWithHDR10Plus';
}
if (browser.web0s) {
// For webOS, we should allow direct play of some not fully supported DV profiles to avoid unnecessary remux/transcode
// webOS seems to be able to play the fallback of Profile 7 and most invalid profiles
hevcVideoRangeTypes += '|DOVIWithEL|DOVIWithELHDR10Plus|DOVIInvalid';
}
if (supportedDolbyVisionProfileAv1(videoTestElement)) {
av1VideoRangeTypes += '|DOVI|DOVIWithHDR10|DOVIWithHLG|DOVIWithSDR';
av1VideoRangeTypes += '|DOVI|DOVIWithHDR10|DOVIWithHLG|DOVIWithSDR|DOVIWithHDR10Plus';
if (browser.web0s) {
// For webOS, we should allow direct play of some not fully supported DV profiles to avoid unnecessary remux/transcode
// webOS seems to be able to play the fallback of Profile 7 and most invalid profiles
av1VideoRangeTypes += '|DOVIWithEL|DOVIWithELHDR10Plus|DOVIInvalid';
}
}
}

View File

@@ -7,7 +7,7 @@ import { toApi } from 'utils/jellyfin-apiclient/compat';
function getFetchPlaylistItemsFn(apiClient, itemId) {
return function () {
const query = {
Fields: 'PrimaryImageAspectRatio',
Fields: 'PrimaryImageAspectRatio,MediaSourceCount,Chapters,Trickplay',
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb',
UserId: apiClient.getCurrentUserId()
};

View File

@@ -501,6 +501,20 @@
background-size: contain;
background-repeat: no-repeat;
background-position: center center;
[dir="rtl"] & {
left: 25vw;
right: unset;
}
.layout-mobile &,
.layout-tv & {
display: none;
}
@media all and (max-width: 68.75em) {
display: none;
}
}
.criticReview:first-child {
@@ -841,7 +855,6 @@
.detailPageSecondaryContainer {
padding-top: 1.25em;
overflow: hidden;
.layout-desktop & {
flex-grow: 1;
@@ -902,6 +915,7 @@
[dir="rtl"] & {
left: unset;
float: right;
.layout-mobile &,
.layout-tv & {
@@ -940,19 +954,6 @@
}
}
.layout-mobile,
.layout-tv {
.detailLogo {
display: none;
}
}
@media all and (max-width: 68.75em) {
.detailLogo {
display: none;
}
}
.itemDetailImage {
width: 100% !important;
box-shadow: 0 0.1em 0.5em 0 rgba(0, 0, 0, 0.75);

View File

@@ -37,6 +37,9 @@
html {
@include fullpage;
/* Set the default font for cases we don't load the brand font */
font-family: Arial, Helvetica, sans-serif;
line-height: 1.35;
}

8
src/utils/array.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* Utility function that converts a value that can be a single item, array of items, null, or undefined to an array.
*/
export function ensureArray<T>(val: T | T[] | null | undefined): T[] {
if (val == null) return [];
if (Array.isArray(val)) return val;
return [ val ];
}

View File

@@ -31,6 +31,10 @@ function getWebDeviceIcon(browser: string | null | undefined) {
return BASE_DEVICE_IMAGE_URL + 'edge.svg';
case 'Internet Explorer':
return BASE_DEVICE_IMAGE_URL + 'msie.svg';
case 'Titan OS':
return BASE_DEVICE_IMAGE_URL + 'titanos.svg';
case 'Vega OS':
return BASE_DEVICE_IMAGE_URL + 'firetv.svg';
default:
return BASE_DEVICE_IMAGE_URL + 'html5.svg';
}

View File

@@ -196,7 +196,6 @@ export const getProgramSections = (): Section[] => {
apiMethod: SectionApiMethod.LiveTvPrograms,
type: SectionType.UpcomingEpisodes,
parametersOptions: {
isAiring: false,
hasAired: false,
isMovie: false,
isSports: false,
@@ -221,7 +220,6 @@ export const getProgramSections = (): Section[] => {
apiMethod: SectionApiMethod.LiveTvPrograms,
type: SectionType.UpcomingMovies,
parametersOptions: {
isAiring: false,
hasAired: false,
isMovie: true
},
@@ -242,7 +240,6 @@ export const getProgramSections = (): Section[] => {
apiMethod: SectionApiMethod.LiveTvPrograms,
type: SectionType.UpcomingSports,
parametersOptions: {
isAiring: false,
hasAired: false,
isSports: true
},
@@ -263,7 +260,6 @@ export const getProgramSections = (): Section[] => {
apiMethod: SectionApiMethod.LiveTvPrograms,
type: SectionType.UpcomingKids,
parametersOptions: {
isAiring: false,
hasAired: false,
isKids: true
},
@@ -284,7 +280,6 @@ export const getProgramSections = (): Section[] => {
apiMethod: SectionApiMethod.LiveTvPrograms,
type: SectionType.UpcomingNews,
parametersOptions: {
isAiring: false,
hasAired: false,
isNews: true
},