Compare commits

...

37 commits

Author SHA1 Message Date
Jellyfin Release Bot
61b1f89036 Bump version to 12.2 2026-10-05 18:51:40 -04:00
Bill Thornton
8e63a4d19e
Merge pull request #8591 from thornbill/update-mixed-library-icon 2026-10-05 17:57:11 -04:00
Bill Thornton
572c5b7ec2
Merge pull request #8592 from thornbill/disable-still-watching-default 2026-10-05 17:56:26 -04:00
Bill Thornton
f4bfac091b
Merge pull request #8573 from thornbill/fix-backdrop-removal 2026-10-05 17:55:59 -04:00
Bill Thornton
eedd461948 Fix library icon test 2026-10-02 17:58:43 -04:00
Bill Thornton
9974e5acff Disable the still watching prompt by default 2026-10-02 17:54:12 -04:00
Bill Thornton
f0b1704226 Update the mixed library icon 2026-10-02 17:46:16 -04:00
Bill Thornton
0eaa6e6939 Simplify backdrop logic 2026-10-02 17:39:58 -04:00
Bill Thornton
42e4153610 Fix backdrop removal race 2026-09-30 12:02:00 -04:00
enter-a-random-username
cf5f4bbb12
Remove 40 char search restriction (#8412) 2026-09-30 09:25:25 -04:00
Gokul G Krishna
88de8d5a9a
Hide playlist move options from users without edit access (#8570)
When a playlist is shared with public access, other users can view it
but not modify it. The item context menu still offered "Move to Top"
and "Move to Bottom" to them, and picking either failed with a 403
from the server.

"Remove from playlist" was already gated on canEditPlaylist, which
comes from the server's playlist user permissions. Move the two move
entries under the same check so they only show for users who can edit
the playlist, matching drag-to-reorder. Owners and editors see the
same menu as before.

Fixes #8436
2026-09-30 09:15:42 -04:00
moontwister
75a8d33fe8
Fix embedded VobSub subtitles not rendering (#8564)
The delivery URL has been named Stream.vobsub since the server started
matching the MKS profile by container, so the file name hint no longer
ends in .mks and libbitsub looks for an IDX file instead. Embedded
tracks have no path, so without the delivery URL they get the existing
subtitle.mks fallback.
2026-09-30 09:14:52 -04:00
Bill Thornton
0983ce70ca
Move reset filters button and add alphabet support (#8568)
* Move reset filters button and add alphabet support

* Change reset filter button color
2026-09-30 09:03:55 -04:00
Bill Thornton
d1bd735876
Merge pull request #8553 from thornbill/fix-live-program-badge
Fix live tv program badges being hidden on details page
2026-09-25 13:29:28 -04:00
Bill Thornton
83c9422703 Fix live tv program badges being hidden on details page 2026-09-25 11:42:42 -04:00
Bill Thornton
656667fc72
Merge pull request #8521 from thedreaddpirate/saveSubtitlesInternal
Exposes subtitle save path setting regardless if fetcher is installed
2026-09-25 10:44:26 -04:00
Bill Thornton
972a2e9418
Merge pull request #8550 from thornbill/fix-metadata-manager-theme
Fix metadata manager using mixed themes
2026-09-25 10:41:57 -04:00
Bill Thornton
4a47914aea
Merge pull request #8526 from heyhippari/fix/blurhash-transition 2026-09-25 01:01:01 -04:00
Hippari
ab61f96622
Fix blurhash to image transition 2026-09-25 01:01:05 +02:00
Bill Thornton
21585dc615 Fix metadata manager using mixed themes 2026-09-24 17:12:58 -04:00
Bill Thornton
ab8cdad5e0
Merge pull request #8522 from dmitrylyzo/fix-multiple-gamepads 2026-09-21 20:14:03 -04:00
Bill Thornton
5dfccb9260
Merge pull request #8519 from dmitrylyzo/tizen-drop-opus
Drop OPUS in MP4, TS and HLS-TS on Tizen
2026-09-21 17:51:51 -04:00
Bill Thornton
4cffcb7d34
Merge pull request #8533 from thornbill/item-details-xss
Fix track titles and external urls not being escaped
2026-09-21 17:49:25 -04:00
Bill Thornton
8a2e3f5c67 Fix track titles and external urls not being escaped 2026-09-21 16:22:49 -04:00
Dmitry Lyzo
008fbeefac Remember last used gamepad 2026-09-19 23:09:18 +03:00
TheDreadPirate
7a2050fb75 Updating contributors 2026-09-19 16:00:47 -04:00
TheDreadPirate
b634438f7a Adjusting layout to use existing header 2026-09-19 16:00:47 -04:00
TheDreadPirate
cfbaf2b83f Expose configuration for changing subtitle save behavior when no subtitle downloader is installed 2026-09-19 16:00:46 -04:00
Dmitry Lyzo
2c14c06904 Drop OPUS in MP4 and TS on Tizen 2026-09-19 17:46:36 +03:00
Dmitry Lyzo
0f87efad60 Drop OPUS in HLS-TS on Tizen
Technically, server already prohibits OPUS in HLS-TS.
2026-09-19 17:41:26 +03:00
Bill Thornton
351f87be32
Merge pull request #8493 from MBR-0001/fix-samsungbrowser-detection 2026-09-17 20:25:18 -04:00
Bill Thornton
48bf0dba9a
Merge pull request #8487 from theguymadmax/fix-photos-autoplaying 2026-09-17 20:24:11 -04:00
Bill Thornton
56933f723d
Merge pull request #8494 from thornbill/clear-queries-language
Clear query cache on user language change
2026-09-17 12:03:19 -04:00
MBR#0001
e99f756ecf
Add tests 2026-09-16 20:13:32 +02:00
Bill Thornton
97cc9e9322 Clear query cache on user language change 2026-09-16 12:39:39 -04:00
MBR#0001
2389ae1d98
Fix Samsung Browser detection
All samsung smart tvs already have "tv" in the UA so this is not needed
2026-09-16 18:34:42 +02:00
theguymadmax
21add08619 Fix photos autoplaying 2026-09-15 00:51:37 -04:00
29 changed files with 348 additions and 272 deletions

View file

@ -110,6 +110,7 @@
- [austinhardaway](https://github.com/austinhardaway)
- [Alex Dickens](https://github.com/alex-dicko)
- [shindouj](https://github.com/shindouj)
- [TheDreadPirate](https://github.com/thedreaddpirate)
## Emby Contributors

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "jellyfin-web",
"version": "12.1.0",
"version": "12.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jellyfin-web",
"version": "12.1.0",
"version": "12.2.0",
"license": "GPL-2.0-or-later",
"dependencies": {
"@emotion/react": "11.14.0",

View file

@ -1,6 +1,6 @@
{
"name": "jellyfin-web",
"version": "12.1.0",
"version": "12.2.0",
"description": "Web interface for Jellyfin",
"repository": "https://github.com/jellyfin/jellyfin-web",
"license": "GPL-2.0-or-later",

View file

@ -256,7 +256,7 @@ function renderVideoSelections(page, mediaSources) {
titleParts.push(v.Codec.toUpperCase());
}
return '<option value="' + v.Index + '" ' + selected + '>' + (v.DisplayTitle || titleParts.join(' ')) + '</option>';
return `<option value="${v.Index}" ${selected}>${escapeHtml(v.DisplayTitle || titleParts.join(' '))}</option>`;
}).join('');
select.setAttribute('disabled', 'disabled');
@ -279,7 +279,7 @@ function renderAudioSelections(page, mediaSources) {
const selectedId = mediaSource.DefaultAudioStreamIndex;
select.innerHTML = tracks.map(function (v) {
const selected = v.Index === selectedId ? ' selected' : '';
return '<option value="' + v.Index + '" ' + selected + '>' + v.DisplayTitle + '</option>';
return `<option value="${v.Index}" ${selected}>${escapeHtml(v.DisplayTitle)}</option>`;
}).join('');
if (tracks.length > 1) {
@ -309,7 +309,7 @@ function renderSubtitleSelections(page, mediaSources) {
let selected = selectedId === -1 ? ' selected' : '';
select.innerHTML = '<option value="-1">' + globalize.translate('Off') + '</option>' + tracks.map(function (v) {
selected = v.Index === selectedId ? ' selected' : '';
return '<option value="' + v.Index + '" ' + selected + '>' + v.DisplayTitle + '</option>';
return `<option value="${v.Index}" ${selected}>${escapeHtml(v.DisplayTitle)}</option>`;
}).join('');
if (tracks.length > 0) {
@ -729,12 +729,22 @@ function renderLinks(page, item) {
const links = [];
if (!layoutManager.tv && item.HomePageUrl) {
links.push(`<a is="emby-linkbutton" class="button-link" href="${item.HomePageUrl}" target="_blank">${globalize.translate('ButtonWebsite')}</a>`);
try {
const parsedUrl = new URL(item.HomePageUrl);
links.push(`<a is="emby-linkbutton" class="button-link" href="${parsedUrl.href}" target="_blank">${globalize.translate('ButtonWebsite')}</a>`);
} catch {
console.error('[renderLinks] Failed to parse home page URL', item.HomePageUrl);
}
}
if (item.ExternalUrls) {
for (const url of item.ExternalUrls) {
links.push(`<a is="emby-linkbutton" class="button-link" href="${url.Url}" target="_blank">${escapeHtml(url.Name)}</a>`);
try {
const parsedUrl = new URL(url.Url);
links.push(`<a is="emby-linkbutton" class="button-link" href="${parsedUrl.href}" target="_blank">${escapeHtml(url.Name)}</a>`);
} catch {
console.error('[renderLinks] Failed to parse external URL', url.Url);
}
}
}

View file

@ -50,7 +50,6 @@ const SearchFields: FC<SearchFieldsProps> = ({
data-keyboard='true'
placeholder={globalize.translate('Search')}
autoComplete='off'
maxLength={40}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
value={query}

View file

@ -10,9 +10,9 @@ import Theaters from '@mui/icons-material/Theaters';
import MusicVideo from '@mui/icons-material/MusicVideo';
import Book from '@mui/icons-material/Book';
import Queue from '@mui/icons-material/Queue';
import Quiz from '@mui/icons-material/Quiz';
import VideoLibrary from '@mui/icons-material/VideoLibrary';
import Folder from '@mui/icons-material/Folder';
import OndemandVideo from '@mui/icons-material/OndemandVideo';
import React, { FC } from 'react';
import { MetaView } from '../constants/metaView';
@ -51,7 +51,7 @@ const LibraryIcon: FC<LibraryIconProps> = ({
case CollectionType.Playlists:
return <Queue />;
case undefined:
return <Quiz />;
return <OndemandVideo />;
default:
return <Folder />;
}

View file

@ -1,4 +1,6 @@
import FilterAltOff from '@mui/icons-material/FilterAltOff';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import ButtonGroup from '@mui/material/ButtonGroup';
import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
@ -62,9 +64,8 @@ const LibraryToolbar: FC = () => {
const isPending = itemsResult?.isPending ?? true;
const totalRecordCount = itemsResult?.data?.TotalRecordCount ?? 0;
const items = itemsResult?.data?.Items ?? [];
const hasFilters = Object.values(viewSettings?.Filters ?? {}).some(
(filter) => !!filter
);
const hasFilterOptions = Object.values(viewSettings?.Filters ?? {}).some(f => !!f);
const hasFilters = !!viewSettings?.Alphabet || hasFilterOptions;
const { user } = useApi();
const canCreateCollections = user?.Policy?.IsAdministrator || user?.Policy?.EnableCollectionManagement;
@ -106,141 +107,172 @@ const LibraryToolbar: FC = () => {
<LibraryViewMenu />
{!MENU_ONLY_VIEWS.includes(viewType) && (
<Box
sx={{
display: 'flex',
flexGrow: {
xs: 1,
sm: 0
},
justifyContent: 'flex-end',
marginLeft: 1
}}
>
<Chip label={itemCountDisplay} />
</Box>
)}
{!MENU_ONLY_VIEWS.includes(viewType) && (
<Stack
direction='row'
spacing={1}
sx={{
justifyContent: {
xs: 'auto',
sm: 'flex-end'
},
flexBasis: {
xs: '100%',
sm: 'auto'
},
flexGrow: 1,
marginTop: {
xs: 1,
sm: 0.5
},
marginBottom: 0.5
}}
>
<>
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: {
xs: 'row-reverse',
sm: 'row'
},
flexGrow: {
xs: 1,
sm: 0
}
},
justifyContent: 'flex-start',
marginLeft: 1
}}
>
<Chip label={itemCountDisplay} />
{hasFilters && (
<Button
variant='text'
color='primary'
startIcon={<FilterAltOff />}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setLibraryViewSettings(prevState => ({
...prevState,
StartIndex: 0,
Alphabet: undefined,
Filters: undefined
}))}
sx={{
marginLeft: {
xs: 0,
sm: 1
},
marginRight: {
xs: 1,
sm: 0
}
}}
>
{globalize.translate('ResetFilters')}
</Button>
)}
</Box>
<Stack
direction='row'
spacing={1}
sx={{
justifyContent: {
xs: 'auto',
sm: 'flex-end'
},
flexBasis: {
xs: '100%',
sm: 'auto'
},
flexGrow: 1,
marginTop: {
xs: 1,
sm: 0.5
},
marginBottom: 0.5
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexGrow: {
xs: 1,
sm: 0
}
}}
>
<ButtonGroup
variant='contained'
size={isSmallScreen ? undefined : 'small'}
>
{isBtnPlayAllEnabled && (
<PlayAllButton
item={item}
items={items}
viewType={viewType}
collectionType={collectionType}
hasFilters={hasFilters}
isTextVisible={isSmallScreen}
libraryViewSettings={libraryViewSettings}
/>
)}
{isBtnShuffleEnabled && (
<ShuffleButton
item={item}
items={items}
viewType={viewType}
collectionType={collectionType}
hasFilters={hasFilters}
isTextVisible={isSmallScreen && !isBtnPlayAllEnabled}
libraryViewSettings={libraryViewSettings}
/>
)}
{isBtnQueueEnabled && item && playbackManager.canQueue(item) && (
<QueueButton
item={item}
items={items}
hasFilters={hasFilters}
isTextVisible={isSmallScreen && !isBtnPlayAllEnabled && !isBtnShuffleEnabled}
/>
)}
</ButtonGroup>
{isBtnNewCollectionEnabled && canCreateCollections && (
<NewCollectionButton isTextVisible={isSmallScreen} queryKey={allItemsQueryKey} />
)}
{isBtnNewPlaylistEnabled && (
<NewPlaylistButton isTextVisible={isSmallScreen} queryKey={allItemsQueryKey} />
)}
</Box>
<ButtonGroup
variant='contained'
color='inherit'
variant='text'
size={isSmallScreen ? undefined : 'small'}
>
{isBtnPlayAllEnabled && (
<PlayAllButton
item={item}
items={items}
{isBtnFilterEnabled && (
<FilterButton
parentId={parentId}
itemType={itemType ?? []}
viewType={viewType}
collectionType={collectionType}
hasFilters={hasFilters}
isTextVisible={isSmallScreen}
hasFilters={hasFilterOptions}
libraryViewSettings={libraryViewSettings}
setLibraryViewSettings={setLibraryViewSettings}
/>
)}
{isBtnShuffleEnabled && (
<ShuffleButton
item={item}
items={items}
{isBtnSortEnabled && (
<SortButton
viewType={viewType}
collectionType={collectionType}
hasFilters={hasFilters}
isTextVisible={isSmallScreen && !isBtnPlayAllEnabled}
libraryViewSettings={libraryViewSettings}
setLibraryViewSettings={setLibraryViewSettings}
/>
)}
{isBtnQueueEnabled && item && playbackManager.canQueue(item) && (
<QueueButton
item={item}
items={items}
hasFilters={hasFilters}
isTextVisible={isSmallScreen && !isBtnPlayAllEnabled && !isBtnShuffleEnabled}
{isBtnGridListEnabled && (
<ViewSettingsButton
viewType={viewType}
libraryViewSettings={libraryViewSettings}
setLibraryViewSettings={setLibraryViewSettings}
/>
)}
</ButtonGroup>
{isBtnNewCollectionEnabled && canCreateCollections && (
<NewCollectionButton isTextVisible={isSmallScreen} queryKey={allItemsQueryKey} />
)}
{isBtnNewPlaylistEnabled && (
<NewPlaylistButton isTextVisible={isSmallScreen} queryKey={allItemsQueryKey} />
)}
</Box>
<ButtonGroup
color='inherit'
variant='text'
size={isSmallScreen ? undefined : 'small'}
>
{isBtnFilterEnabled && (
<FilterButton
parentId={parentId}
itemType={itemType ?? []}
viewType={viewType}
hasFilters={hasFilters}
libraryViewSettings={libraryViewSettings}
{isPaginationEnabled && isUserPaginationEnabled && (
<Pagination
setLibraryViewSettings={setLibraryViewSettings}
index={startIndex}
pageSize={paginationLimit}
total={totalRecordCount}
disabled={isPending || !isPaginationRequired || itemsResult?.isPlaceholderData}
/>
)}
{isBtnSortEnabled && (
<SortButton
viewType={viewType}
libraryViewSettings={libraryViewSettings}
setLibraryViewSettings={setLibraryViewSettings}
/>
)}
{isBtnGridListEnabled && (
<ViewSettingsButton
viewType={viewType}
libraryViewSettings={libraryViewSettings}
setLibraryViewSettings={setLibraryViewSettings}
/>
)}
</ButtonGroup>
{isPaginationEnabled && isUserPaginationEnabled && (
<Pagination
setLibraryViewSettings={setLibraryViewSettings}
index={startIndex}
pageSize={paginationLimit}
total={totalRecordCount}
disabled={isPending || !isPaginationRequired || itemsResult?.isPlaceholderData}
/>
)}
</Stack>
</Stack>
</>
)}
</Toolbar>
);

View file

@ -2,7 +2,6 @@ import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-ite
import React, { FC, useCallback } from 'react';
import ArrowForwardIosSharpIcon from '@mui/icons-material/ArrowForwardIosSharp';
import FilterAlt from '@mui/icons-material/FilterAlt';
import Clear from '@mui/icons-material/Clear';
import Button from '@mui/material/Button';
import Popover from '@mui/material/Popover';
@ -115,16 +114,6 @@ const FilterButton: FC<FilterButtonProps> = ({
setAnchorEl(event.currentTarget);
}, []);
const handleResetFiltersClick = useCallback(() => {
if (hasFilters) {
setLibraryViewSettings((prevState) => ({
...prevState,
StartIndex: 0,
Filters: {}
}));
}
}, [hasFilters, setLibraryViewSettings]);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
@ -518,19 +507,6 @@ const FilterButton: FC<FilterButtonProps> = ({
</AccordionDetails>
</Accordion>
)}
<Button
disabled={!hasFilters}
title={globalize.translate('ResetFilters')}
aria-describedby={id}
onClick={handleResetFiltersClick}
fullWidth={true}
startIcon={<Clear />}
sx={{
justifyContent: 'right'
}}
>
{globalize.translate('ResetFilters')}
</Button>
</Popover>
</>
);

View file

@ -8,7 +8,10 @@ const Backdrop = () => {
return (
<>
<div className='backdropContainer' />
<div className='backdropContainer'>
<div id='backdropImage0' className='backdropImage' />
<div id='backdropImage1' className='backdropImage' />
</div>
<div className='backgroundContainer' />
</>
);

View file

@ -10,12 +10,24 @@ const QueryClientEventHandler: FC = () => {
const queryClient = useQueryClient();
const { user } = useApi();
const clearQueries = useCallback(() => (
queryClient.clear()
), [queryClient]);
const invalidateItemQueries = useCallback(() => (
queryClient.invalidateQueries({
queryKey: ['User', user?.Id, 'Items']
})
), [queryClient, user?.Id]);
useEffect(() => {
Events.on(document, EventType.LANGUAGE_CHANGE, clearQueries);
return () => {
Events.off(document, EventType.LANGUAGE_CHANGE, clearQueries);
};
}, [clearQueries]);
useEffect(() => {
Events.on(document, EventType.REFRESH_NEEDED, invalidateItemQueries);

View file

@ -2,6 +2,7 @@ import React, { type FC, useEffect, useState } from 'react';
import { useUserTheme } from 'hooks/useUserTheme';
import { getDefaultTheme } from 'scripts/settings/webSettings';
import themeManager from 'scripts/themeManager';
interface ThemeCssProps {
dashboard?: boolean
@ -19,7 +20,10 @@ const ThemeCss: FC<ThemeCssProps> = ({
useEffect(() => {
const id = dashboard ? dashboardTheme : theme;
if (id) setThemeUrl(getThemeUrl(id));
if (id) {
setThemeUrl(getThemeUrl(id));
void themeManager.setTheme(id);
}
}, [dashboard, dashboardTheme, theme]);
return (

View file

@ -16,7 +16,7 @@ function enableRotation() {
}
class Backdrop {
load(url, parent) {
load(url, slot) {
const img = new Image();
const self = this;
@ -25,14 +25,13 @@ class Backdrop {
return;
}
const backdropImage = document.createElement('div');
backdropImage.classList.add('backdropImage');
backdropImage.classList.add('displayingBackdropImage');
const backdropImage = document.getElementById(`backdropImage${slot}`);
backdropImage.style.backgroundImage = `url('${url}')`;
backdropImage.setAttribute('data-url', url);
backdropImage.classList.add('displayingBackdropImage', 'backdropImageFadeIn');
self.currentAnimatingElement = backdropImage;
backdropImage.classList.add('backdropImageFadeIn');
parent.appendChild(backdropImage);
document.getElementById(`backdropImage${1 - slot}`)?.classList.remove('displayingBackdropImage');
if (!enableAnimation()) {
internalBackdrop(true);
@ -46,6 +45,8 @@ class Backdrop {
if (backdropImage === self.currentAnimatingElement) {
self.currentAnimatingElement = null;
}
backdropImage?.classList.remove('backdropImageFadeIn');
};
dom.addEventListener(backdropImage, dom.whichAnimationEvent(), onAnimationComplete, {
@ -95,8 +96,12 @@ export function clearBackdrop(clearAll) {
currentLoadingBackdrop = null;
}
const elem = getBackdropContainer();
elem.innerHTML = '';
// Remove backdrop images from each slot element
document.querySelectorAll('.backdropImage').forEach(elem => {
elem.classList.remove('displayingBackdropImage', 'backdropImageFadeIn');
elem.style.backgroundImage = 'none';
elem.removeAttribute('data-url');
});
if (clearAll) {
hasExternalBackdrop = false;
@ -134,12 +139,8 @@ export function externalBackdrop(isEnabled) {
}
let currentLoadingBackdrop;
let currentBackdropSlot = 0;
function setBackdropImage(url) {
if (currentLoadingBackdrop) {
currentLoadingBackdrop.destroy();
currentLoadingBackdrop = null;
}
const elem = getBackdropContainer();
const existingBackdropImage = elem.querySelector('.displayingBackdropImage');
// If the current backdrop image is the same as the new one, do nothing
@ -148,8 +149,9 @@ function setBackdropImage(url) {
}
const instance = new Backdrop();
instance.load(url, elem);
instance.load(url, currentBackdropSlot);
currentLoadingBackdrop = instance;
currentBackdropSlot = 1 - currentBackdropSlot;
}
function getItemImageUrls(item, imageOptions) {
@ -244,14 +246,6 @@ function onRotationInterval() {
currentRotationIndex = newIndex;
const currentImage = currentRotatingImages[newIndex];
setBackdropImage(currentImage);
// Remove old images after a delay to allow fade-in animation (800ms) to complete
setTimeout(() => {
const oldImages = getBackdropContainer().querySelectorAll(`.backdropImage:not([data-url="${currentImage}"])`);
oldImages.forEach(img => {
img.remove();
});
}, 1600);
}
function clearRotation() {

View file

@ -33,3 +33,7 @@
opacity: 1;
}
}
.displayingBackdropImage {
z-index: 1;
}

View file

@ -27,21 +27,27 @@ const Image: FC<ImageProps> = ({
containImage
}) => {
const [isLoaded, setIsLoaded] = useState(false);
const [isTransitionComplete, setIsTransitionComplete] = useState(false);
const [isLoadStarted, setIsLoadStarted] = useState(false);
const fadeinDuration = userSettings.enableFastFadein() ? '0.1s' : '0.5s';
const transitionDuration = isLoaded ? fadeinDuration : 'none';
const handleLoad = useCallback(() => {
setIsLoaded(true);
}, []);
const handleTransitionEnd = useCallback(() => {
setIsTransitionComplete(true);
}, []);
const handleLoadStarted = useCallback(() => {
setIsLoadStarted(true);
}, []);
const fadeinDuration = userSettings.enableFastFadein() ? '0.1s' : '0.5s';
const transitionDuration = isLoaded ? fadeinDuration : 'none';
return (
<div>
{!isLoaded && isLoadStarted && blurhash && userSettings.enableBlurhash() && (
{!isTransitionComplete && isLoadStarted && blurhash && userSettings.enableBlurhash() && (
<BlurhashCanvas
hash={blurhash}
width= {20}
@ -64,6 +70,7 @@ const Image: FC<ImageProps> = ({
transition: transitionDuration
}}
onLoad={handleLoad}
onTransitionEnd={handleTransitionEnd}
beforeLoad={handleLoadStarted}
/>

View file

@ -216,15 +216,6 @@
flex-shrink: 0;
}
@media all and (max-width: 50em) {
.newTvProgram,
.liveTvProgram,
.premiereTvProgram,
.guideHdIcon {
display: none;
}
}
.channelPrograms + .channelPrograms,
.guide-channelHeaderCell + .guide-channelHeaderCell {
margin-top: -1px;
@ -271,6 +262,15 @@
contain: strict;
flex-grow: 1;
margin: 0 !important;
@media all and (max-width: 50em) {
.newTvProgram,
.liveTvProgram,
.premiereTvProgram,
.guideHdIcon {
display: none;
}
}
}
.guideProgramName {

View file

@ -306,28 +306,29 @@ export async function getCommands(options) {
});
}
// Only show playlist edit actions if the user can modify the playlist
if (item.PlaylistItemId && options.playlistId && options.canEditPlaylist) {
commands.push({
name: globalize.translate('RemoveFromPlaylist'),
id: 'removefromplaylist',
icon: 'playlist_remove'
});
}
if (item.PlaylistItemId && options.playlistId && item.PlaylistIndex > 0) {
commands.push({
name: globalize.translate('MoveToTop'),
id: 'movetotop',
icon: 'vertical_align_top'
});
}
if (item.PlaylistIndex > 0) {
commands.push({
name: globalize.translate('MoveToTop'),
id: 'movetotop',
icon: 'vertical_align_top'
});
}
if (item.PlaylistItemId && options.playlistId && item.PlaylistIndex < (item.PlaylistItemCount - 1)) {
commands.push({
name: globalize.translate('MoveToBottom'),
id: 'movetobottom',
icon: 'vertical_align_bottom'
});
if (item.PlaylistIndex < (item.PlaylistItemCount - 1)) {
commands.push({
name: globalize.translate('MoveToBottom'),
id: 'movetobottom',
icon: 'vertical_align_bottom'
});
}
}
if (options.collectionId) {

View file

@ -406,7 +406,7 @@ function populateMetadataSettings(parent, contentType) {
renderMediaSegmentProviders(parent, availableOptions, {});
renderImageFetchers(parent, availableOptions, {});
renderSimilarItemProviders(parent, availableOptions, {});
availableOptions.SubtitleFetchers.length ? parent.querySelector('.subtitleDownloadSettings').classList.remove('hide') : parent.querySelector('.subtitleDownloadSettings').classList.add('hide');
availableOptions.SubtitleFetchers.length ? parent.querySelector('.subtitleProviderSettings').classList.remove('hide') : parent.querySelector('.subtitleProviderSettings').classList.add('hide');
}).catch(() => {
return Promise.resolve();
});
@ -557,8 +557,10 @@ export function setContentType(parent, contentType) {
if (contentType === 'tvshows' || contentType === 'movies' || contentType === 'musicvideos' || contentType === 'mixed') {
parent.querySelector('.fldAllowEmbeddedSubtitlesContainer').classList.remove('hide');
parent.querySelector('.subtitleDownloadSettings').classList.remove('hide');
} else {
parent.querySelector('.fldAllowEmbeddedSubtitlesContainer').classList.add('hide');
parent.querySelector('.subtitleDownloadSettings').classList.add('hide');
}
if (contentType === 'music') {

View file

@ -166,45 +166,48 @@ <h2>${HeaderChapterImages}</h2>
<div class="subtitleDownloadSettings hide">
<h2>${HeaderSubtitleDownloads}</h2>
<div class="subtitleProviderSettings hide">
<div>
<h3 class="checkboxListLabel">${LabelDownloadLanguages}</h3>
<div class="subtitleDownloadLanguages paperList checkboxList" style="max-height: 10.5em; overflow-y: auto; padding: .5em 1em;">
</div>
</div>
<br />
<div>
<h3 class="checkboxListLabel">${LabelDownloadLanguages}</h3>
<div class="subtitleDownloadLanguages paperList checkboxList" style="max-height: 10.5em; overflow-y: auto; padding: .5em 1em;">
<div class="subtitleFetchers advanced" style="margin-bottom: 2em;">
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkRequirePerfectMatch" checked />
<span>${OptionRequirePerfectSubtitleMatch}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${OptionRequirePerfectSubtitleMatchHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription advanced">
<label>
<input is="emby-checkbox" type="checkbox" id="chkSkipIfAudioTrackPresent" />
<span>${LabelSkipIfAudioTrackPresent}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelSkipIfAudioTrackPresentHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription advanced">
<label>
<input is="emby-checkbox" type="checkbox" id="chkSkipIfGraphicalSubsPresent" />
<span>${LabelSkipIfGraphicalSubsPresent}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelSkipIfGraphicalSubsPresentHelp}</div>
</div>
</div>
<br />
<div class="subtitleFetchers advanced" style="margin-bottom: 2em;">
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="chkRequirePerfectMatch" checked />
<span>${OptionRequirePerfectSubtitleMatch}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${OptionRequirePerfectSubtitleMatchHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription advanced">
<label>
<input is="emby-checkbox" type="checkbox" id="chkSkipIfAudioTrackPresent" />
<span>${LabelSkipIfAudioTrackPresent}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelSkipIfAudioTrackPresentHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription advanced">
<label>
<input is="emby-checkbox" type="checkbox" id="chkSkipIfGraphicalSubsPresent" />
<span>${LabelSkipIfGraphicalSubsPresent}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelSkipIfGraphicalSubsPresentHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription advanced">
<label>
<input type="checkbox" is="emby-checkbox" id="chkSaveSubtitlesLocally" checked />
<span>${SaveSubtitlesIntoMediaFolders}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${SaveSubtitlesIntoMediaFoldersHelp}</div>
<div class="fldSaveSubtitlesSettings">
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkSaveSubtitlesLocally" checked />
<span>${SaveSubtitlesIntoMediaFolders}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${SaveSubtitlesIntoMediaFoldersHelp}</div>
</div>
</div>
</div>

View file

@ -38,7 +38,6 @@ import './elements/emby-button/emby-button';
import './components/playback/displayMirrorManager';
import './components/playback/playerSelectionMenu';
import './components/themeMediaPlayer';
import './scripts/autoThemes';
import './scripts/mouseManager';
import './scripts/screensavermanager';

View file

@ -181,7 +181,7 @@ function getTextTrackUrl(track, item, format) {
}
function getSubtitleFileNameHint(track) {
const candidates = [track?.Path, track?.DeliveryUrl];
const candidates = [track?.Path];
for (const candidate of candidates) {
if (!candidate) {
continue;

View file

@ -27,7 +27,8 @@ export default class PhotoPlayer {
interactive: true,
// playbackManager.shuffle has no options. So treat 'shuffle' as a 'play' action
autoplay: {
delay: userSettings.slideshowInterval() * 1000
delay: userSettings.slideshowInterval() * 1000,
enabled: !!(options.autoplay || options.shuffle)
},
user: result,
onClose: () => screenSaverManager.unblock()

View file

@ -1,25 +0,0 @@
import * as userSettings from './settings/userSettings';
import skinManager from './themeManager';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import { pageClassOn } from 'utils/dashboard';
import Events from 'utils/events.ts';
// Set the default theme when loading
skinManager.setTheme(userSettings.theme())
/* this keeps the scrollbar always present in all pages, so we avoid clipping while switching between pages
that need the scrollbar and pages that don't.
*/
.then(() => document.body.classList.add('force-scroll'));
// set the saved theme once a user authenticates
Events.on(ServerConnections, 'localusersignedin', () => {
skinManager.setTheme(userSettings.theme());
});
pageClassOn('viewbeforeshow', 'page', function () {
if (this.classList.contains('type-interior')) {
skinManager.setTheme(userSettings.dashboardTheme());
} else {
skinManager.setTheme(userSettings.theme());
}
});

View file

@ -10,10 +10,6 @@ function isTv(userAgent) {
return true;
}
if (userAgent.includes('samsungbrowser')) {
return true;
}
if (userAgent.includes('viera')) {
return true;
}

View file

@ -35,4 +35,22 @@ describe('Browser', () => {
expect(browser.xboxOne).toBe(true);
expect(browser.tv).toBe(true);
});
it('should identify Samsung Tizen TV devices', () => {
// 2024 Samsung TV (Tizen 8.0)
const browser = detectBrowser('Mozilla/5.0 (SMART-TV; Linux; Tizen 8.0) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/7.0 Chrome/108.0.5359.1 TV Safari/537.36');
expect(browser.tizen).toBe(true);
expect(browser.tizenVersion).toBe(8);
expect(browser.tv).toBe(true);
expect(browser.mobile).toBeFalsy();
});
it('should not identify Samsung Browser (Android) as a TV', () => {
// Ref: https://developer.samsung.com/browser/user-agent-string-format.html
const browser = detectBrowser('Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/117.0.0.0 Mobile Safari/537.36');
expect(browser.android).toBe(true);
expect(browser.mobile).toBe(true);
expect(browser.tizen).toBeFalsy();
expect(browser.tv).toBeFalsy();
});
});

View file

@ -526,6 +526,7 @@ export default function (options) {
};
let videoAudioCodecs = [];
let mkvAudioCodecs = [];
let hlsInTsVideoAudioCodecs = [];
let hlsInFmp4VideoAudioCodecs = [];
@ -562,12 +563,14 @@ export default function (options) {
// Prefer AAC, MP3 to other codecs when audio transcoding.
if (canPlayAacVideoAudio) {
videoAudioCodecs.push('aac');
mkvAudioCodecs.push('aac');
hlsInTsVideoAudioCodecs.push('aac');
hlsInFmp4VideoAudioCodecs.push('aac');
}
if (supportsMp3VideoAudio) {
videoAudioCodecs.push('mp3');
mkvAudioCodecs.push('mp3');
}
// Safari supports mp3 with HLS, but only in mpegts container, and the supportsMp3VideoAudio will return false.
@ -584,12 +587,14 @@ export default function (options) {
// Do not use AC3 for audio transcoding unless AAC and MP3 are not supported.
if (canPlayAc3VideoAudio) {
videoAudioCodecs.push('ac3');
mkvAudioCodecs.push('ac3');
if (browser.edgeChromium) {
hlsInFmp4VideoAudioCodecs.push('ac3');
}
if (canPlayEac3VideoAudio) {
videoAudioCodecs.push('eac3');
mkvAudioCodecs.push('eac3');
if (browser.edgeChromium) {
hlsInFmp4VideoAudioCodecs.push('eac3');
}
@ -608,6 +613,7 @@ export default function (options) {
if (supportsMp2VideoAudio) {
videoAudioCodecs.push('mp2');
mkvAudioCodecs.push('mp2');
hlsInTsVideoAudioCodecs.push('mp2');
hlsInFmp4VideoAudioCodecs.push('mp2');
}
@ -620,30 +626,37 @@ export default function (options) {
if (supportsDts) {
videoAudioCodecs.push('dca');
videoAudioCodecs.push('dts');
mkvAudioCodecs.push('dca', 'dts');
}
if (browser.tizen || browser.web0s) {
videoAudioCodecs.push('pcm_s16le');
videoAudioCodecs.push('pcm_s24le');
mkvAudioCodecs.push('pcm_s16le', 'pcm_s24le');
}
if (appSettings.enableTrueHd() || options.supportsTrueHd) {
videoAudioCodecs.push('truehd');
mkvAudioCodecs.push('truehd');
}
if (browser.tizen) {
videoAudioCodecs.push('aac_latm');
mkvAudioCodecs.push('aac_latm');
}
if (canPlayAudioFormat('opus')) {
videoAudioCodecs.push('opus');
webmAudioCodecs.push('opus');
if (browser.tizen) {
hlsInTsVideoAudioCodecs.push('opus');
// On Tizen 3/4/5, OPUS is supported only in MKV/WebM
// On Tizen 6, OPUS appears to be supported in MP4 as well, but not in TS (that requires refactoring of TS profile generation)
if (!browser.tizen) {
videoAudioCodecs.push('opus');
}
mkvAudioCodecs.push('opus');
webmAudioCodecs.push('opus');
hlsInFmp4VideoAudioCodecs.push('opus');
} else if (safariSupportsOpus) {
videoAudioCodecs.push('opus');
mkvAudioCodecs.push('opus');
webmAudioCodecs.push('opus');
hlsInFmp4VideoAudioCodecs.push('opus');
}
@ -651,11 +664,13 @@ export default function (options) {
// FLAC audio in video plays with a delay on Tizen
if (canPlayAudioFormat('flac') && !browser.tizen) {
videoAudioCodecs.push('flac');
mkvAudioCodecs.push('flac');
hlsInFmp4VideoAudioCodecs.push('flac');
}
if (canPlayAudioFormat('alac')) {
videoAudioCodecs.push('alac');
mkvAudioCodecs.push('alac');
hlsInFmp4VideoAudioCodecs.push('alac');
}
@ -663,6 +678,8 @@ export default function (options) {
return (options.disableVideoAudioCodecs || []).indexOf(c) === -1;
});
mkvAudioCodecs = mkvAudioCodecs.filter(c => !(options.disableVideoAudioCodecs || []).includes(c));
hlsInTsVideoAudioCodecs = hlsInTsVideoAudioCodecs.filter(function (c) {
return (options.disableHlsVideoAudioCodecs || []).indexOf(c) === -1;
});
@ -750,6 +767,7 @@ export default function (options) {
if ((!browser.safari && canPlayVp8) || browser.tizen) {
videoAudioCodecs.push('vorbis');
mkvAudioCodecs.push('vorbis');
}
if (webmVideoCodecs.length) {
@ -775,7 +793,7 @@ export default function (options) {
Container: 'mkv',
Type: 'Video',
VideoCodec: mp4VideoCodecs.join(','),
AudioCodec: videoAudioCodecs.join(',')
AudioCodec: mkvAudioCodecs.join(',')
});
}

View file

@ -50,6 +50,7 @@ const _GAMEPAD_LEFT_THUMBSTICK_LEFT_KEYCODE = 37;
const _GAMEPAD_LEFT_THUMBSTICK_RIGHT_KEYCODE = 39;
const _THUMB_STICK_THRESHOLD = 0.75;
let _lastGamepad = -1;
let _leftThumbstickUpPressed = false;
let _leftThumbstickDownPressed = false;
let _leftThumbstickLeftPressed = false;
@ -257,22 +258,38 @@ let inputLoopTimer;
function runInputLoop() {
// Get the latest gamepad state.
const gamepads = navigator.getGamepads(); /* eslint-disable-line compat/compat */
let lastGamepad = _lastGamepad;
_lastGamepad = -1;
if (!gamepads[lastGamepad]) lastGamepad = -1;
for (let i = 0, len = gamepads.length; i < len; i++) {
if (lastGamepad >= 0 && i !== lastGamepad) {
continue;
}
const gamepad = gamepads[i];
if (!gamepad) {
continue;
}
// Iterate through the axes
const axes = gamepad.axes;
const leftStickX = axes[0];
const leftStickY = axes[1];
if (leftStickX > _THUMB_STICK_THRESHOLD) { // Right
_lastGamepad = i;
_ButtonPressedState.setleftThumbstickRight(true);
} else if (leftStickX < -_THUMB_STICK_THRESHOLD) { // Left
_lastGamepad = i;
_ButtonPressedState.setleftThumbstickLeft(true);
} else if (leftStickY < -_THUMB_STICK_THRESHOLD) { // Up
_lastGamepad = i;
_ButtonPressedState.setleftThumbstickUp(true);
} else if (leftStickY > _THUMB_STICK_THRESHOLD) { // Down
_lastGamepad = i;
_ButtonPressedState.setleftThumbstickDown(true);
} else {
_ButtonPressedState.setleftThumbstickLeft(false);
@ -285,6 +302,8 @@ function runInputLoop() {
for (let j = 0, buttonsLen = buttons.length; j < buttonsLen; j++) {
if (ProcessedButtons.indexOf(j) !== -1) {
if (buttons[j].pressed) {
_lastGamepad = i;
switch (j) {
case _GAMEPAD_DPAD_UP_BUTTON_INDEX:
_ButtonPressedState.setdPadUp(true);
@ -347,6 +366,8 @@ function runInputLoop() {
}
}
}
if (_lastGamepad >= 0) break;
}
}

View file

@ -583,7 +583,7 @@ export class UserSettings {
return this.set('stillWatchingPrompt', val, false);
}
return this.get('stillWatchingPrompt', false) || StillWatchingOptions.Default;
return this.get('stillWatchingPrompt', false) || StillWatchingOptions.Disabled;
}
/**

View file

@ -89,7 +89,7 @@ describe('getLibraryIcon()', () => {
it('Should return the correct icon for nonstandard types', () => {
expect(getLibraryIcon(undefined))
.toBe('quiz');
.toBe('ondemand_video');
expect(getLibraryIcon('channels'))
.toBe('videocam');
});

View file

@ -121,7 +121,7 @@ export function getLibraryIcon(library: CollectionType | string | null | undefin
case 'channels':
return 'videocam';
case undefined:
return 'quiz';
return 'ondemand_video';
default:
return 'folder';
}