diff --git a/.gitignore b/.gitignore index 4492164776..52cd61ad14 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ config.json # ide .idea -.vscode # log yarn-error.log diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000..b308e58914 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..c54aff90bb --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + }, + "eslint.format.enable": true, + "editor.formatOnSave": false +} diff --git a/deployment/Dockerfile.fedora b/deployment/Dockerfile.fedora index 05ff5a7ae7..90df6f8584 100644 --- a/deployment/Dockerfile.fedora +++ b/deployment/Dockerfile.fedora @@ -1,4 +1,4 @@ -FROM fedora:33 +FROM fedora:36 # Docker build arguments ARG SOURCE_DIR=/jellyfin @@ -11,7 +11,7 @@ ENV IS_DOCKER=YES # Prepare Fedora environment RUN dnf update -y \ - && dnf install -y @buildsys-build rpmdevtools git dnf-plugins-core nodejs autoconf automake glibc-devel + && dnf install -y @buildsys-build rpmdevtools git dnf-plugins-core nodejs autoconf automake glibc-devel make # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.fedora /build.sh diff --git a/fedora/Makefile b/fedora/Makefile index 344c10b626..c094073bc8 100644 --- a/fedora/Makefile +++ b/fedora/Makefile @@ -9,8 +9,12 @@ TARBALL :=$(NAME)-$(subst -,~,$(VERSION)).tar.gz epel-7-x86_64_repos := https://rpm.nodesource.com/pub_16.x/el/\$$releasever/\$$basearch/ +fed_ver := $(shell rpm -E %fedora) +# fallback when not running on Fedora +fed_ver ?= 36 +TARGET ?= fedora-$(fed_ver)-x86_64 + outdir ?= $(PWD)/$(DIR)/ -TARGET ?= fedora-35-x86_64 srpm: $(DIR)/$(SRPM) tarball: $(DIR)/$(TARBALL) diff --git a/fedora/jellyfin-web.spec b/fedora/jellyfin-web.spec index 3a4e0aeace..2c48737ed5 100644 --- a/fedora/jellyfin-web.spec +++ b/fedora/jellyfin-web.spec @@ -4,7 +4,7 @@ Name: jellyfin-web Version: 10.8.0 Release: 1%{?dist} Summary: The Free Software Media System web client -License: GPLv3 +License: GPLv2 URL: https://jellyfin.org # Jellyfin Server tarball created by `make -f .copr/Makefile srpm`, real URL ends with `v%%{version}.tar.gz` Source0: jellyfin-web-%{version}.tar.gz @@ -17,9 +17,6 @@ BuildRequires: git BuildRequires: npm %endif -# Disable Automatic Dependency Processing -AutoReqProv: no - %description Jellyfin is a free software media system that puts you in control of managing and streaming your media. @@ -27,22 +24,26 @@ Jellyfin is a free software media system that puts you in control of managing an %prep %autosetup -n jellyfin-web-%{version} -b 0 -%build - -%install %if 0%{?rhel} > 0 && 0%{?rhel} < 8 # Required for CentOS build chown root:root -R . %endif + + +%build npm ci --no-audit --unsafe-perm -%{__mkdir} -p %{buildroot}%{_datadir} -mv dist %{buildroot}%{_datadir}/jellyfin-web -%{__install} -D -m 0644 LICENSE %{buildroot}%{_datadir}/licenses/jellyfin/LICENSE + + +%install +%{__mkdir} -p %{buildroot}%{_libdir}/jellyfin/jellyfin-web +%{__cp} -r dist/* %{buildroot}%{_libdir}/jellyfin/jellyfin-web + %files %defattr(644,root,root,755) -%{_datadir}/jellyfin-web -%{_datadir}/licenses/jellyfin/LICENSE +%{_libdir}/jellyfin/jellyfin-web +%license LICENSE + %changelog * Fri Dec 04 2020 Jellyfin Packaging Team diff --git a/package-lock.json b/package-lock.json index 2e45ef0966..47c5765a95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10760,6 +10760,23 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "react-router": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.3.0.tgz", + "integrity": "sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==", + "requires": { + "history": "^5.2.0" + } + }, + "react-router-dom": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.3.0.tgz", + "integrity": "sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==", + "requires": { + "history": "^5.2.0", + "react-router": "6.3.0" + } + }, "read-file-stdin": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/read-file-stdin/-/read-file-stdin-0.2.1.tgz", diff --git a/package.json b/package.json index 8c14b329f1..022483d232 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "pdfjs-dist": "2.12.313", "react": "17.0.2", "react-dom": "17.0.2", + "react-router-dom": "6.3.0", "resize-observer-polyfill": "1.5.1", "screenfull": "6.0.0", "sortablejs": "1.14.0", diff --git a/src/components/ConnectionRequired.tsx b/src/components/ConnectionRequired.tsx new file mode 100644 index 0000000000..7ec089af50 --- /dev/null +++ b/src/components/ConnectionRequired.tsx @@ -0,0 +1,169 @@ +import React, { FunctionComponent, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import alert from './alert'; +import { appRouter } from './appRouter'; +import loading from './loading/loading'; +import ServerConnections from './ServerConnections'; +import globalize from '../scripts/globalize'; + +enum BounceRoutes { + Home = '/home.html', + Login = '/login.html', + SelectServer = '/selectserver.html', + StartWizard = '/wizardstart.html' +} + +// TODO: This should probably be in the SDK +enum ConnectionState { + SignedIn = 'SignedIn', + ServerSignIn = 'ServerSignIn', + ServerSelection = 'ServerSelection', + ServerUpdateNeeded = 'ServerUpdateNeeded' +} + +type ConnectionRequiredProps = { + isAdminRequired?: boolean, + isUserRequired?: boolean +}; + +/** + * A component that ensures a server connection has been established. + * Additional parameters exist to verify a user or admin have authenticated. + * If a condition fails, this component will navigate to the appropriate page. + */ +const ConnectionRequired: FunctionComponent = ({ + children, + isAdminRequired = false, + isUserRequired = true +}) => { + const navigate = useNavigate(); + + const [ isLoading, setIsLoading ] = useState(true); + + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bounce = async (connectionResponse: any) => { + switch (connectionResponse.State) { + case ConnectionState.SignedIn: + // Already logged in, bounce to the home page + console.debug('[ConnectionRequired] already logged in, redirecting to home'); + navigate(BounceRoutes.Home); + return; + case ConnectionState.ServerSignIn: + // Bounce to the login page + console.debug('[ConnectionRequired] not logged in, redirecting to login page'); + navigate(BounceRoutes.Login, { + state: { + serverid: connectionResponse.ApiClient.serverId() + } + }); + return; + case ConnectionState.ServerSelection: + // Bounce to select server page + console.debug('[ConnectionRequired] redirecting to select server page'); + navigate(BounceRoutes.SelectServer); + return; + case ConnectionState.ServerUpdateNeeded: + // Show update needed message and bounce to select server page + try { + await alert({ + text: globalize.translate('ServerUpdateNeeded', 'https://github.com/jellyfin/jellyfin'), + html: globalize.translate('ServerUpdateNeeded', 'https://github.com/jellyfin/jellyfin') + }); + } catch (ex) { + console.warn('[ConnectionRequired] failed to show alert', ex); + } + console.debug('[ConnectionRequired] server update required, redirecting to select server page'); + navigate(BounceRoutes.SelectServer); + return; + } + + console.warn('[ConnectionRequired] unhandled connection state', connectionResponse.State); + }; + + const validateConnection = async () => { + // Check connection status on initial page load + const firstConnection = appRouter.firstConnectionResult; + appRouter.firstConnectionResult = null; + + if (firstConnection && firstConnection.State !== ConnectionState.SignedIn) { + if (firstConnection.State === ConnectionState.ServerSignIn) { + // Verify the wizard is complete + try { + const infoResponse = await fetch(`${firstConnection.ApiClient.serverAddress()}/System/Info/Public`); + if (!infoResponse.ok) { + throw new Error('Public system info request failed'); + } + const systemInfo = await infoResponse.json(); + if (!systemInfo?.StartupWizardCompleted) { + // Bounce to the wizard + console.info('[ConnectionRequired] startup wizard is not complete, redirecting there'); + navigate(BounceRoutes.StartWizard); + return; + } + } catch (ex) { + console.error('[ConnectionRequired] checking wizard status failed', ex); + return; + } + } + + // Bounce to the correct page in the login flow + bounce(firstConnection); + return; + } + + // TODO: appRouter will call appHost.exit() if navigating back when you are already at the default route. + // This case will need to be handled elsewhere before appRouter can be killed. + + const client = ServerConnections.currentApiClient(); + + // If this is a user route, ensure a user is logged in + if ((isAdminRequired || isUserRequired) && !client?.isLoggedIn()) { + try { + console.warn('[ConnectionRequired] unauthenticated user attempted to access user route'); + bounce(await ServerConnections.connect()); + } catch (ex) { + console.warn('[ConnectionRequired] error bouncing from user route', ex); + } + return; + } + + // If this is an admin route, ensure the user has access + if (isAdminRequired) { + try { + const user = await client.getCurrentUser(); + if (!user.Policy.IsAdministrator) { + console.warn('[ConnectionRequired] normal user attempted to access admin route'); + bounce(await ServerConnections.connect()); + return; + } + } catch (ex) { + console.warn('[ConnectionRequired] error bouncing from admin route', ex); + return; + } + } + + setIsLoading(false); + }; + + loading.show(); + validateConnection(); + }, [ isAdminRequired, isUserRequired, navigate ]); + + useEffect(() => { + if (!isLoading) { + loading.hide(); + } + }, [ isLoading ]); + + if (isLoading) { + return null; + } + + return ( + <>{children} + ); +}; + +export default ConnectionRequired; diff --git a/src/components/HistoryRouter.tsx b/src/components/HistoryRouter.tsx new file mode 100644 index 0000000000..21e1efe0fe --- /dev/null +++ b/src/components/HistoryRouter.tsx @@ -0,0 +1,48 @@ +import React, { useLayoutEffect } from 'react'; +import { HistoryRouterProps, Router } from 'react-router-dom'; +import { Update } from 'history'; + +/** Strips leading "!" from paths */ +const normalizePath = (pathname: string) => pathname.replace(/^!/, ''); + +/** + * A slightly customized version of the HistoryRouter from react-router-dom. + * We need to use HistoryRouter to have a shared history state between react-router and appRouter, but it does not seem + * to be properly exported in the upstream package. + * We also needed some customizations to handle #! routes. + * Refs: https://github.com/remix-run/react-router/blob/v6.3.0/packages/react-router-dom/index.tsx#L222 + */ +export function HistoryRouter({ basename, children, history }: HistoryRouterProps) { + const [state, setState] = React.useState({ + action: history.action, + location: history.location + }); + + useLayoutEffect(() => { + const onHistoryChange = (update: Update) => { + if (update.location.pathname.startsWith('!')) { + // When the location changes, we need to check for #! paths and replace the location with the "!" stripped + history.replace(normalizePath(update.location.pathname), update.location.state); + } else { + setState(update); + } + }; + + history.listen(onHistoryChange); + }, [ history ]); + + return ( + + ); +} diff --git a/src/components/Page.tsx b/src/components/Page.tsx new file mode 100644 index 0000000000..be1253740f --- /dev/null +++ b/src/components/Page.tsx @@ -0,0 +1,69 @@ +import React, { FunctionComponent, HTMLAttributes, useEffect, useRef } from 'react'; + +import viewManager from './viewManager/viewManager'; + +type PageProps = { + id: string, // id is required for libraryMenu + title?: string, + isBackButtonEnabled?: boolean, + isNowPlayingBarEnabled?: boolean, + isThemeMediaSupported?: boolean +}; + +/** + * Page component that handles hiding active non-react views, triggering the required events for + * navigation and appRouter state updates, and setting the correct classes and data attributes. + */ +const Page: FunctionComponent> = ({ + children, + id, + className = '', + title, + isBackButtonEnabled = true, + isNowPlayingBarEnabled = true, + isThemeMediaSupported = false +}) => { + const element = useRef(null); + + useEffect(() => { + // hide active non-react views + viewManager.hideView(); + }, []); + + useEffect(() => { + const event = { + bubbles: true, + cancelable: false, + detail: { + isRestored: false, + options: { + enableMediaControl: isNowPlayingBarEnabled, + supportsThemeMedia: isThemeMediaSupported + } + } + }; + // viewbeforeshow - switches between the admin dashboard and standard themes + element.current?.dispatchEvent(new CustomEvent('viewbeforeshow', event)); + // pagebeforeshow - hides tabs on tables pages in libraryMenu + element.current?.dispatchEvent(new CustomEvent('pagebeforeshow', event)); + // viewshow - updates state of appRouter + element.current?.dispatchEvent(new CustomEvent('viewshow', event)); + // pageshow - updates header/navigation in libraryMenu + element.current?.dispatchEvent(new CustomEvent('pageshow', event)); + }, [ element, isNowPlayingBarEnabled, isThemeMediaSupported ]); + + return ( +
+ {children} +
+ ); +}; + +export default Page; diff --git a/src/components/appRouter.js b/src/components/appRouter.js index 3b5f356653..cab40043b1 100644 --- a/src/components/appRouter.js +++ b/src/components/appRouter.js @@ -122,7 +122,11 @@ class AppRouter { isBack: action === Action.Pop }); } else { - console.warn('[appRouter] "%s" route not found', normalizedPath, location); + console.info('[appRouter] "%s" route not found', normalizedPath, location); + this.currentRouteInfo = { + route: {}, + path: normalizedPath + location.search + }; } } @@ -139,7 +143,7 @@ class AppRouter { Events.on(apiClient, 'requestfail', this.onRequestFail); }); - ServerConnections.connect().then(result => { + return ServerConnections.connect().then(result => { this.firstConnectionResult = result; // Handle the initial route diff --git a/src/components/apphost.js b/src/components/apphost.js index 337e346df2..49f7d4e796 100644 --- a/src/components/apphost.js +++ b/src/components/apphost.js @@ -39,7 +39,8 @@ function getDeviceProfile(item) { profile = profileBuilder(builderOpts); } - const maxTranscodingVideoWidth = appHost.screen()?.maxAllowedWidth; + const maxVideoWidth = appSettings.maxVideoWidth(); + const maxTranscodingVideoWidth = maxVideoWidth < 0 ? appHost.screen()?.maxAllowedWidth : maxVideoWidth; if (maxTranscodingVideoWidth) { profile.TranscodingProfiles.forEach((transcodingProfile) => { diff --git a/src/components/htmlMediaHelper.js b/src/components/htmlMediaHelper.js index a7a9961e1c..3bb29ca912 100644 --- a/src/components/htmlMediaHelper.js +++ b/src/components/htmlMediaHelper.js @@ -131,7 +131,8 @@ import { Events } from 'jellyfin-apiclient'; } function setCurrentTimeIfNeeded(element, seconds) { - if (Math.abs(element.currentTime || 0, seconds) <= 1) { + // If it's worth skipping (1 sec or less of a difference) + if (Math.abs((element.currentTime || 0) - seconds) >= 1) { element.currentTime = seconds; } } diff --git a/src/components/imageDownloader/imageDownloader.js b/src/components/imageDownloader/imageDownloader.js index 5f3eb50ced..b88eea6688 100644 --- a/src/components/imageDownloader/imageDownloader.js +++ b/src/components/imageDownloader/imageDownloader.js @@ -33,10 +33,10 @@ import template from './imageDownloader.template.html'; let selectedProvider; let browsableParentId; - function getBaseRemoteOptions(page) { + function getBaseRemoteOptions(page, forceCurrentItemId = false) { const options = {}; - if (page.querySelector('#chkShowParentImages').checked && browsableParentId) { + if (!forceCurrentItemId && page.querySelector('#chkShowParentImages').checked && browsableParentId) { options.itemId = browsableParentId; } else { options.itemId = currentItemId; @@ -140,7 +140,7 @@ import template from './imageDownloader.template.html'; } function downloadRemoteImage(page, apiClient, url, type, provider) { - const options = getBaseRemoteOptions(page); + const options = getBaseRemoteOptions(page, true); options.Type = type; options.ImageUrl = url; diff --git a/src/components/itemMediaInfo/itemMediaInfo.js b/src/components/itemMediaInfo/itemMediaInfo.js index bf3e4f0932..520dccaa4d 100644 --- a/src/components/itemMediaInfo/itemMediaInfo.js +++ b/src/components/itemMediaInfo/itemMediaInfo.js @@ -113,7 +113,7 @@ const attributeDelimiterHtml = layoutManager.tv ? '' : ': 0) { attributes.push(createAttribute(globalize.translate('MediaInfoLevel'), stream.Level)); } if (stream.Width || stream.Height) { @@ -128,7 +128,7 @@ const attributeDelimiterHtml = layoutManager.tv ? '' : ': : : = ({ serverId, parentId, collectionType }: SearchProps) => { - const [ query, setQuery ] = useState(); - - return ( - <> - - {!query && - - } - - - - ); -}; - -export default SearchPage; diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index 6aae77d834..5d0f371f7d 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -2281,7 +2281,7 @@ class PlaybackManager { score += 1; if (prevRelIndex == newRelIndex) score += 1; - if (prevStream.Title && prevStream.Title == stream.Title) + if (prevStream.DisplayTitle && prevStream.DisplayTitle == stream.DisplayTitle) score += 2; if (prevStream.Language && prevStream.Language != 'und' && prevStream.Language == stream.Language) score += 2; @@ -2306,7 +2306,7 @@ class PlaybackManager { } } - function autoSetNextTracks(prevSource, mediaSource) { + function autoSetNextTracks(prevSource, mediaSource, audio, subtitle) { try { if (!prevSource) return; @@ -2315,18 +2315,13 @@ class PlaybackManager { return; } - if (typeof prevSource.DefaultAudioStreamIndex != 'number' - || typeof prevSource.DefaultSubtitleStreamIndex != 'number') - return; - - if (typeof mediaSource.DefaultAudioStreamIndex != 'number' - || typeof mediaSource.DefaultSubtitleStreamIndex != 'number') { - console.warn('AutoSet - No stream indexes (but prevSource has them)'); - return; + if (audio && typeof prevSource.DefaultAudioStreamIndex == 'number') { + rankStreamType(prevSource.DefaultAudioStreamIndex, prevSource, mediaSource, 'Audio'); } - rankStreamType(prevSource.DefaultAudioStreamIndex, prevSource, mediaSource, 'Audio'); - rankStreamType(prevSource.DefaultSubtitleStreamIndex, prevSource, mediaSource, 'Subtitle'); + if (subtitle && typeof prevSource.DefaultSubtitleStreamIndex == 'number') { + rankStreamType(prevSource.DefaultSubtitleStreamIndex, prevSource, mediaSource, 'Subtitle'); + } } catch (e) { console.error(`AutoSet - Caught unexpected error: ${e}`); } @@ -2390,9 +2385,9 @@ class PlaybackManager { // this reference was only needed by sendPlaybackListToPlayer playOptions.items = null; - return getPlaybackMediaSource(player, apiClient, deviceProfile, maxBitrate, item, startPosition, mediaSourceId, audioStreamIndex, subtitleStreamIndex).then(function (mediaSource) { - if (userSettings.enableSetUsingLastTracks()) - autoSetNextTracks(prevSource, mediaSource); + return getPlaybackMediaSource(player, apiClient, deviceProfile, maxBitrate, item, startPosition, mediaSourceId, audioStreamIndex, subtitleStreamIndex).then(async (mediaSource) => { + const user = await apiClient.getCurrentUser(); + autoSetNextTracks(prevSource, mediaSource, user.Configuration.RememberAudioSelections, user.Configuration.RememberSubtitleSelections); const streamInfo = createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition, player); diff --git a/src/components/playbackSettings/playbackSettings.js b/src/components/playbackSettings/playbackSettings.js index 0de42c6c18..7c959c0a44 100644 --- a/src/components/playbackSettings/playbackSettings.js +++ b/src/components/playbackSettings/playbackSettings.js @@ -41,7 +41,7 @@ import template from './playbackSettings.template.html'; select.innerHTML = html; } - function setMaxBitrateIntoField(select, isInNetwork, mediatype) { + function fillQuality(select, isInNetwork, mediatype, maxVideoWidth) { const options = mediatype === 'Audio' ? qualityoptions.getAudioQualityOptions({ currentMaxBitrate: appSettings.maxStreamingBitrate(isInNetwork, mediatype), @@ -52,7 +52,8 @@ import template from './playbackSettings.template.html'; currentMaxBitrate: appSettings.maxStreamingBitrate(isInNetwork, mediatype), isAutomaticBitrateEnabled: appSettings.enableAutomaticBitrateDetection(isInNetwork, mediatype), - enableAuto: true + enableAuto: true, + maxVideoWidth }); @@ -60,6 +61,10 @@ import template from './playbackSettings.template.html'; // render empty string instead of 0 for the auto option return ``; }).join(''); + } + + function setMaxBitrateIntoField(select, isInNetwork, mediatype) { + fillQuality(select, isInNetwork, mediatype); if (appSettings.enableAutomaticBitrateDetection(isInNetwork, mediatype)) { select.value = ''; @@ -68,12 +73,13 @@ import template from './playbackSettings.template.html'; } } - function fillChromecastQuality(select) { + function fillChromecastQuality(select, maxVideoWidth) { const options = qualityoptions.getVideoQualityOptions({ currentMaxBitrate: appSettings.maxChromecastBitrate(), isAutomaticBitrateEnabled: !appSettings.maxChromecastBitrate(), - enableAuto: true + enableAuto: true, + maxVideoWidth }); select.innerHTML = options.map(i => { @@ -180,7 +186,8 @@ import template from './playbackSettings.template.html'; context.querySelector('.chkPreferFmp4HlsContainer').checked = userSettings.preferFmp4HlsContainer(); context.querySelector('.chkEnableCinemaMode').checked = userSettings.enableCinemaMode(); context.querySelector('.chkEnableNextVideoOverlay').checked = userSettings.enableNextVideoInfoOverlay(); - context.querySelector('.chkSetUsingLastTracks').checked = userSettings.enableSetUsingLastTracks(); + context.querySelector('.chkRememberAudioSelections').checked = user.Configuration.RememberAudioSelections || false; + context.querySelector('.chkRememberSubtitleSelections').checked = user.Configuration.RememberSubtitleSelections || false; context.querySelector('.chkExternalVideoPlayer').checked = appSettings.enableSystemExternalPlayers(); setMaxBitrateIntoField(context.querySelector('.selectVideoInNetworkQuality'), true, 'Video'); @@ -192,6 +199,9 @@ import template from './playbackSettings.template.html'; const selectChromecastVersion = context.querySelector('.selectChromecastVersion'); selectChromecastVersion.value = userSettings.chromecastVersion(); + const selectLabelMaxVideoWidth = context.querySelector('.selectLabelMaxVideoWidth'); + selectLabelMaxVideoWidth.value = appSettings.maxVideoWidth(); + const selectSkipForwardLength = context.querySelector('.selectSkipForwardLength'); fillSkipLengths(selectSkipForwardLength); selectSkipForwardLength.value = userSettings.skipForwardLength(); @@ -209,6 +219,7 @@ import template from './playbackSettings.template.html'; appSettings.enableSystemExternalPlayers(context.querySelector('.chkExternalVideoPlayer').checked); appSettings.maxChromecastBitrate(context.querySelector('.selectChromecastVideoQuality').value); + appSettings.maxVideoWidth(context.querySelector('.selectLabelMaxVideoWidth').value); setMaxBitrateFromField(context.querySelector('.selectVideoInNetworkQuality'), true, 'Video'); setMaxBitrateFromField(context.querySelector('.selectVideoInternetQuality'), false, 'Video'); @@ -222,7 +233,8 @@ import template from './playbackSettings.template.html'; userSettingsInstance.enableCinemaMode(context.querySelector('.chkEnableCinemaMode').checked); userSettingsInstance.enableNextVideoInfoOverlay(context.querySelector('.chkEnableNextVideoOverlay').checked); - userSettingsInstance.enableSetUsingLastTracks(context.querySelector('.chkSetUsingLastTracks').checked); + user.Configuration.RememberAudioSelections = context.querySelector('.chkRememberAudioSelections').checked; + user.Configuration.RememberSubtitleSelections = context.querySelector('.chkRememberSubtitleSelections').checked; userSettingsInstance.chromecastVersion(context.querySelector('.selectChromecastVersion').value); userSettingsInstance.skipForwardLength(context.querySelector('.selectSkipForwardLength').value); userSettingsInstance.skipBackLength(context.querySelector('.selectSkipBackLength').value); @@ -247,6 +259,36 @@ import template from './playbackSettings.template.html'; }); } + function setSelectValue(select, value, defaultValue) { + select.value = value; + + if (select.selectedIndex < 0) { + select.value = defaultValue; + } + } + + function onMaxVideoWidthChange(e) { + const context = this.options.element; + + const selectVideoInNetworkQuality = context.querySelector('.selectVideoInNetworkQuality'); + const selectVideoInternetQuality = context.querySelector('.selectVideoInternetQuality'); + const selectChromecastVideoQuality = context.querySelector('.selectChromecastVideoQuality'); + + const selectVideoInNetworkQualityValue = selectVideoInNetworkQuality.value; + const selectVideoInternetQualityValue = selectVideoInternetQuality.value; + const selectChromecastVideoQualityValue = selectChromecastVideoQuality.value; + + const maxVideoWidth = parseInt(e.target.value || '0', 10) || 0; + + fillQuality(selectVideoInNetworkQuality, true, 'Video', maxVideoWidth); + fillQuality(selectVideoInternetQuality, false, 'Video', maxVideoWidth); + fillChromecastQuality(selectChromecastVideoQuality, maxVideoWidth); + + setSelectValue(selectVideoInNetworkQuality, selectVideoInNetworkQualityValue, ''); + setSelectValue(selectVideoInternetQuality, selectVideoInternetQualityValue, ''); + setSelectValue(selectChromecastVideoQuality, selectChromecastVideoQualityValue, ''); + } + function onSubmit(e) { const self = this; const apiClient = ServerConnections.getApiClient(self.options.serverId); @@ -274,6 +316,8 @@ import template from './playbackSettings.template.html'; options.element.querySelector('.btnSave').classList.remove('hide'); } + options.element.querySelector('.selectLabelMaxVideoWidth').addEventListener('change', onMaxVideoWidthChange.bind(self)); + self.loadData(); if (options.autoFocus) { diff --git a/src/components/playbackSettings/playbackSettings.template.html b/src/components/playbackSettings/playbackSettings.template.html index 19782334e1..82c7483b9b 100644 --- a/src/components/playbackSettings/playbackSettings.template.html +++ b/src/components/playbackSettings/playbackSettings.template.html @@ -41,6 +41,19 @@
+ +
+ +
@@ -84,10 +97,18 @@
-
${SetUsingLastTracksHelp}
+
${RememberAudioSelectionsHelp}
+
+ +
+ +
${RememberSubtitleSelectionsHelp}
diff --git a/src/components/playerstats/playerstats.js b/src/components/playerstats/playerstats.js index acd7d63b25..df15dac19d 100644 --- a/src/components/playerstats/playerstats.js +++ b/src/components/playerstats/playerstats.js @@ -269,31 +269,10 @@ import ServerConnections from '../ServerConnections'; }); } - if (videoStream.VideoRange) { + if (videoStream.VideoRangeType) { sessionStats.push({ - label: globalize.translate('LabelVideoRange'), - value: videoStream.VideoRange - }); - } - - if (videoStream.ColorSpace) { - sessionStats.push({ - label: globalize.translate('LabelColorSpace'), - value: videoStream.ColorSpace - }); - } - - if (videoStream.ColorTransfer) { - sessionStats.push({ - label: globalize.translate('LabelColorTransfer'), - value: videoStream.ColorTransfer - }); - } - - if (videoStream.ColorPrimaries) { - sessionStats.push({ - label: globalize.translate('LabelColorPrimaries'), - value: videoStream.ColorPrimaries + label: globalize.translate('LabelVideoRangeType'), + value: videoStream.VideoRangeType }); } diff --git a/src/components/qualityOptions.js b/src/components/qualityOptions.js index a185a5f5fa..bf59ad1669 100644 --- a/src/components/qualityOptions.js +++ b/src/components/qualityOptions.js @@ -1,5 +1,6 @@ import { appHost } from '../components/apphost'; import globalize from '../scripts/globalize'; +import appSettings from '../scripts/settings/appSettings'; export function getVideoQualityOptions(options) { const maxStreamingBitrate = options.currentMaxBitrate; @@ -12,7 +13,9 @@ export function getVideoQualityOptions(options) { videoWidth = videoHeight * (16 / 9); } - const hostScreenWidth = appHost.screen()?.maxAllowedWidth || 4096; + const maxVideoWidth = options.maxVideoWidth == null ? appSettings.maxVideoWidth() : options.maxVideoWidth; + + const hostScreenWidth = (maxVideoWidth < 0 ? appHost.screen()?.maxAllowedWidth : maxVideoWidth) || 4096; const maxAllowedWidth = videoWidth || 4096; const qualityOptions = []; diff --git a/src/components/search/LiveTVSearchResults.tsx b/src/components/search/LiveTVSearchResults.tsx index 67378bc138..b79a24c3be 100644 --- a/src/components/search/LiveTVSearchResults.tsx +++ b/src/components/search/LiveTVSearchResults.tsx @@ -21,8 +21,8 @@ const CARD_OPTIONS = { type LiveTVSearchResultsProps = { serverId?: string; - parentId?: string; - collectionType?: string; + parentId?: string | null; + collectionType?: string | null; query?: string; } diff --git a/src/components/search/SearchResults.tsx b/src/components/search/SearchResults.tsx index 9f6d30a708..a54e568a3c 100644 --- a/src/components/search/SearchResults.tsx +++ b/src/components/search/SearchResults.tsx @@ -9,8 +9,8 @@ import SearchResultsRow from './SearchResultsRow'; type SearchResultsProps = { serverId?: string; - parentId?: string; - collectionType?: string; + parentId?: string | null; + collectionType?: string | null; query?: string; } diff --git a/src/components/search/SearchSuggestions.tsx b/src/components/search/SearchSuggestions.tsx index fee361a162..abc24eb7f6 100644 --- a/src/components/search/SearchSuggestions.tsx +++ b/src/components/search/SearchSuggestions.tsx @@ -22,7 +22,7 @@ const createSuggestionLink = ({ name, href }: { name: string, href: string }) => type SearchSuggestionsProps = { serverId?: string; - parentId?: string; + parentId?: string | null; } const SearchSuggestions: FunctionComponent = ({ serverId = window.ApiClient.serverId(), parentId }: SearchSuggestionsProps) => { diff --git a/src/components/subtitlesettings/subtitlesettings.js b/src/components/subtitlesettings/subtitlesettings.js index 067d8f9e40..c201200399 100644 --- a/src/components/subtitlesettings/subtitlesettings.js +++ b/src/components/subtitlesettings/subtitlesettings.js @@ -32,7 +32,7 @@ function getSubtitleAppearanceObject(context) { appearanceSettings.dropShadow = context.querySelector('#selectDropShadow').value; appearanceSettings.font = context.querySelector('#selectFont').value; appearanceSettings.textBackground = context.querySelector('#inputTextBackground').value; - appearanceSettings.textColor = context.querySelector('#inputTextColor').value; + appearanceSettings.textColor = layoutManager.tv ? context.querySelector('#selectTextColor').value : context.querySelector('#inputTextColor').value; appearanceSettings.verticalPosition = context.querySelector('#sliderVerticalPosition').value; return appearanceSettings; @@ -57,6 +57,7 @@ function loadForm(context, user, userSettings, appearanceSettings, apiClient) { context.querySelector('#selectTextWeight').value = appearanceSettings.textWeight || 'normal'; context.querySelector('#selectDropShadow').value = appearanceSettings.dropShadow || ''; context.querySelector('#inputTextBackground').value = appearanceSettings.textBackground || 'transparent'; + context.querySelector('#selectTextColor').value = appearanceSettings.textColor || '#ffffff'; context.querySelector('#inputTextColor').value = appearanceSettings.textColor || '#ffffff'; context.querySelector('#selectFont').value = appearanceSettings.font || ''; context.querySelector('#sliderVerticalPosition').value = appearanceSettings.verticalPosition; @@ -171,6 +172,7 @@ function embed(options, self) { options.element.querySelector('#selectTextWeight').addEventListener('change', onAppearanceFieldChange); options.element.querySelector('#selectDropShadow').addEventListener('change', onAppearanceFieldChange); options.element.querySelector('#selectFont').addEventListener('change', onAppearanceFieldChange); + options.element.querySelector('#selectTextColor').addEventListener('change', onAppearanceFieldChange); options.element.querySelector('#inputTextColor').addEventListener('change', onAppearanceFieldChange); options.element.querySelector('#inputTextBackground').addEventListener('change', onAppearanceFieldChange); @@ -201,6 +203,10 @@ function embed(options, self) { sliderVerticalPosition.classList.add('focusable'); sliderVerticalPosition.enableKeyboardDragging(); }, 0); + + // Replace color picker + dom.parentWithTag(options.element.querySelector('#inputTextColor'), 'DIV').classList.add('hide'); + dom.parentWithTag(options.element.querySelector('#selectTextColor'), 'DIV').classList.remove('hide'); } options.element.querySelector('.chkPreview').addEventListener('change', (e) => { diff --git a/src/components/subtitlesettings/subtitlesettings.template.html b/src/components/subtitlesettings/subtitlesettings.template.html index 941cd937d9..685b03997e 100644 --- a/src/components/subtitlesettings/subtitlesettings.template.html +++ b/src/components/subtitlesettings/subtitlesettings.template.html @@ -95,8 +95,21 @@
-
- +
+ +
+ +
+
diff --git a/src/components/viewManager/viewManager.js b/src/components/viewManager/viewManager.js index 501bd928ae..5073cccf8c 100644 --- a/src/components/viewManager/viewManager.js +++ b/src/components/viewManager/viewManager.js @@ -147,6 +147,15 @@ class ViewManager { }); } + hideView() { + if (currentView) { + dispatchViewEvent(currentView, null, 'viewbeforehide'); + dispatchViewEvent(currentView, null, 'viewhide'); + currentView.classList.add('hide'); + currentView = null; + } + } + tryRestoreView(options, onViewChanging) { if (options.cancel) { return Promise.reject({ cancelled: true }); diff --git a/src/controllers/dashboard/encodingsettings.html b/src/controllers/dashboard/encodingsettings.html index adb9ef62c6..048a957b4e 100644 --- a/src/controllers/dashboard/encodingsettings.html +++ b/src/controllers/dashboard/encodingsettings.html @@ -126,13 +126,24 @@
-
- -
${AllowVppTonemappingHelp}
+
+
+ +
${AllowVppTonemappingHelp}
+
+
+ +
${LabelVppTonemappingBrightnessHelp}
+
+
+ +
${LabelVppTonemappingContrastHelp}
+
+