From f00a11989a806b6c396ae858d239ff5b4591c64e Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Mon, 17 Mar 2025 13:54:32 -0400 Subject: [PATCH 1/4] Add splash screen preview and upload/delete --- .../plugins/components/PluginImage.tsx | 34 ---- src/apps/dashboard/routes/branding/index.tsx | 169 ++++++++++++++++-- src/apps/dashboard/routes/plugins/plugin.tsx | 6 +- src/components/Image.tsx | 66 +++++++ src/strings/en-us.json | 8 +- 5 files changed, 232 insertions(+), 51 deletions(-) delete mode 100644 src/apps/dashboard/features/plugins/components/PluginImage.tsx create mode 100644 src/components/Image.tsx diff --git a/src/apps/dashboard/features/plugins/components/PluginImage.tsx b/src/apps/dashboard/features/plugins/components/PluginImage.tsx deleted file mode 100644 index b2a839ea6..000000000 --- a/src/apps/dashboard/features/plugins/components/PluginImage.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import Paper from '@mui/material/Paper/Paper'; -import Skeleton from '@mui/material/Skeleton/Skeleton'; -import React, { type FC } from 'react'; - -interface PluginImageProps { - isLoading: boolean - alt?: string - url?: string -} - -const PluginImage: FC = ({ - isLoading, - alt, - url -}) => ( - - {isLoading && ( - - )} - {url && ( - {alt} - )} - -); - -export default PluginImage; diff --git a/src/apps/dashboard/routes/branding/index.tsx b/src/apps/dashboard/routes/branding/index.tsx index e510c98c3..58f23b481 100644 --- a/src/apps/dashboard/routes/branding/index.tsx +++ b/src/apps/dashboard/routes/branding/index.tsx @@ -1,5 +1,8 @@ import type { BrandingOptions } from '@jellyfin/sdk/lib/generated-client/models/branding-options'; import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api'; +import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api'; +import Delete from '@mui/icons-material/Delete'; +import Upload from '@mui/icons-material/Upload'; import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; @@ -8,18 +11,21 @@ import Stack from '@mui/material/Stack'; import Switch from '@mui/material/Switch'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; -import React, { useCallback, useState } from 'react'; -import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom'; +import React, { useCallback, useEffect, useState } from 'react'; +import { type ActionFunctionArgs, Form, useActionData, useNavigation, useSubmit } from 'react-router-dom'; import { getBrandingOptionsQuery, QUERY_KEY, useBrandingOptions } from 'apps/dashboard/features/branding/api/useBrandingOptions'; import Loading from 'components/loading/LoadingComponent'; +import Image from 'components/Image'; import Page from 'components/Page'; import ServerConnections from 'components/ServerConnections'; +import { useApi } from 'hooks/useApi'; import globalize from 'lib/globalize'; import { queryClient } from 'utils/query/queryClient'; import { ActionData } from 'types/actionData'; const BRANDING_CONFIG_KEY = 'branding'; +const SPLASHSCREEN_URL = '/Branding/Splashscreen'; const BrandingOption = { CustomCss: 'CustomCss', LoginDisclaimer: 'LoginDisclaimer', @@ -60,9 +66,11 @@ export const loader = () => { }; export const Component = () => { + const { api } = useApi(); const navigation = useNavigation(); const actionData = useActionData() as ActionData | undefined; const isSubmitting = navigation.state === 'submitting'; + const submit = useSubmit(); const { data: defaultBrandingOptions, @@ -70,12 +78,79 @@ export const Component = () => { } = useBrandingOptions(); const [ brandingOptions, setBrandingOptions ] = useState(defaultBrandingOptions || {}); - const setSplashscreenEnabled = useCallback((_: React.ChangeEvent, isEnabled: boolean) => { + const [ error, setError ] = useState(); + + const [ splashscreenUrl, setSplashscreenUrl ] = useState(); + useEffect(() => { + if (!api || isSubmitting) return; + + setSplashscreenUrl(api.getUri(SPLASHSCREEN_URL, { t: Date.now() })); + }, [ api, isSubmitting ]); + + const onSplashscreenDelete = useCallback(() => { + setError(undefined); + + if (!api) return; + + getImageApi(api) + .deleteCustomSplashscreen() + .then(() => { + setSplashscreenUrl(api.getUri(SPLASHSCREEN_URL, { t: Date.now() })); + }) + .catch(e => { + console.error('[BrandingPage] error deleting image', e); + setError('ImageDeleteFailed'); + }); + }, [ api ]); + + const onSplashscreenUpload = useCallback((event: React.ChangeEvent) => { + setError(undefined); + + const files = event.target.files; + + if (!api || !files) return false; + + const file = files[0]; + const reader = new FileReader(); + reader.onerror = e => { + console.error('[BrandingPage] error reading file', e); + setError('ImageUploadFailed'); + }; + reader.onabort = e => { + console.warn('[BrandingPage] aborted reading file', e); + setError('ImageUploadCancelled'); + }; + reader.onload = () => { + if (!reader.result) return; + + const dataUrl = reader.result.toString(); + // FIXME: TypeScript SDK thinks body should be a File but in reality it is a Base64 string + const body = dataUrl.split(',')[1] as never; + getImageApi(api) + .uploadCustomSplashscreen( + { body }, + { headers: { ['Content-Type']: file.type } } + ) + .then(() => { + setSplashscreenUrl(dataUrl); + }) + .catch(e => { + console.error('[BrandingPage] error uploading splashscreen', e); + setError('ImageUploadFailed'); + }); + }; + + reader.readAsDataURL(file); + }, [ api ]); + + const setSplashscreenEnabled = useCallback((event: React.ChangeEvent, isEnabled: boolean) => { setBrandingOptions({ ...brandingOptions, [BrandingOption.SplashscreenEnabled]: isEnabled }); - }, [ brandingOptions ]); + + submit(event.target.form); + }, [ brandingOptions, submit ]); const setBrandingOption = useCallback((event: React.ChangeEvent) => { if (Object.keys(BrandingOption).includes(event.target.name)) { @@ -86,6 +161,10 @@ export const Component = () => { } }, [ brandingOptions ]); + const onSubmit = useCallback(() => { + setError(undefined); + }, []); + if (isPending) return ; return ( @@ -95,7 +174,10 @@ export const Component = () => { className='mainAnimatedPage type-interior' > -
+ {globalize.translate('HeaderBranding')} @@ -107,16 +189,75 @@ export const Component = () => { )} - + {globalize.translate(error)} + + )} + + + + - } - label={globalize.translate('EnableSplashScreen')} - /> + + + + + } + label={globalize.translate('EnableSplashScreen')} + /> + + + {globalize.translate('CustomSplashScreenSize')} + + + + + + + { - diff --git a/src/components/Image.tsx b/src/components/Image.tsx new file mode 100644 index 000000000..84f792722 --- /dev/null +++ b/src/components/Image.tsx @@ -0,0 +1,66 @@ +import type { SvgIconComponent } from '@mui/icons-material'; +import ImageNotSupported from '@mui/icons-material/ImageNotSupported'; +import Box from '@mui/material/Box/Box'; +import Paper from '@mui/material/Paper/Paper'; +import Skeleton from '@mui/material/Skeleton/Skeleton'; +import React, { type FC } from 'react'; + +interface ImageProps { + isLoading: boolean + alt?: string + url?: string + aspectRatio?: number + FallbackIcon?: SvgIconComponent +} + +const Image: FC = ({ + isLoading, + alt, + url, + aspectRatio = 16 / 9, + FallbackIcon = ImageNotSupported +}) => ( + + {isLoading && ( + + )} + {url ? ( + {alt} + ) : ( + + + + )} + +); + +export default Image; diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 2d6678c32..30edb2993 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -194,6 +194,7 @@ "CriticRating": "Critics rating", "Cursive": "Cursive", "Custom": "Custom", + "CustomSplashScreenSize": "Custom images should be in a 16x9 aspect ratio at a minimum size of 1920x1080.", "CustomSubtitleStylingHelp": "Subtitle styling will work on most devices, but comes with an additional performance overhead.", "DailyAt": "Daily at {0}", "Data": "Data", @@ -208,6 +209,7 @@ "Delete": "Delete", "DeleteEntireSeries": "Delete {0} Episodes", "DeleteAll": "Delete All", + "DeleteCustomImage": "Delete Custom Image", "DeleteDeviceConfirmation": "Are you sure you wish to delete this device? It will reappear the next time a user signs in with it.", "DeleteDevicesConfirmation": "Are you sure you wish to delete all devices? All other sessions will be logged out. Devices will reappear the next time a user signs in.", "DeleteImage": "Delete Image", @@ -559,6 +561,9 @@ "IgnoreDtsHelp": "Disabling this option may resolve some issues, e.g. missing audio on channels with separate audio and video streams.", "Illustrator": "Illustrator", "Image": "Image", + "ImageDeleteFailed": "Image deletion failed", + "ImageUploadCancelled": "Image upload cancelled", + "ImageUploadFailed": "Image upload failed", "Images": "Images", "ImportFavoriteChannelsHelp": "Only channels that are marked as favorite on the tuner device will be imported.", "Inker": "Inker", @@ -1619,6 +1624,7 @@ "UnsupportedPlayback": "Jellyfin cannot decrypt content protected by DRM but all content will be tried regardless, including protected titles. Some files may appear completely black due to encryption or other unsupported features, such as interactive titles.", "Up": "Up", "Upload": "Upload", + "UploadCustomImage": "Upload Custom Image", "UseCustomTagDelimiters": "Use custom tag delimiter", "UseCustomTagDelimitersHelp": "Split artist/genre tags with custom characters.", "UseDoubleRateDeinterlacing": "Double the frame rate when deinterlacing", @@ -1757,7 +1763,7 @@ "ThemeSong": "Theme Song", "ThemeVideo": "Theme Video", "EnableEnhancedNvdecDecoderHelp": "Enhanced NVDEC implementation, disable this option to use CUVID if you encounter decoding errors.", - "EnableSplashScreen": "Enable the splash screen", + "EnableSplashScreen": "Enable the splash screen image", "LabelVppTonemappingBrightness": "VPP Tone mapping brightness gain", "LabelVppTonemappingBrightnessHelp": "Apply brightness gain in VPP tone mapping. The recommended value is 16.", "LabelVppTonemappingContrast": "VPP Tone mapping contrast gain", From 3adb945b67e710b4b8fee844dd041e600afb9e94 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Fri, 21 Mar 2025 11:30:04 -0400 Subject: [PATCH 2/4] Disable custom splashscreen and separate enabled state --- src/apps/dashboard/routes/branding/index.tsx | 88 ++++++++++++-------- 1 file changed, 51 insertions(+), 37 deletions(-) diff --git a/src/apps/dashboard/routes/branding/index.tsx b/src/apps/dashboard/routes/branding/index.tsx index 58f23b481..8e565127b 100644 --- a/src/apps/dashboard/routes/branding/index.tsx +++ b/src/apps/dashboard/routes/branding/index.tsx @@ -12,7 +12,7 @@ import Switch from '@mui/material/Switch'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import React, { useCallback, useEffect, useState } from 'react'; -import { type ActionFunctionArgs, Form, useActionData, useNavigation, useSubmit } from 'react-router-dom'; +import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom'; import { getBrandingOptionsQuery, QUERY_KEY, useBrandingOptions } from 'apps/dashboard/features/branding/api/useBrandingOptions'; import Loading from 'components/loading/LoadingComponent'; @@ -25,6 +25,7 @@ import { queryClient } from 'utils/query/queryClient'; import { ActionData } from 'types/actionData'; const BRANDING_CONFIG_KEY = 'branding'; +const ENABLE_CUSTOM_IMAGE = false; const SPLASHSCREEN_URL = '/Branding/Splashscreen'; const BrandingOption = { CustomCss: 'CustomCss', @@ -70,7 +71,6 @@ export const Component = () => { const navigation = useNavigation(); const actionData = useActionData() as ActionData | undefined; const isSubmitting = navigation.state === 'submitting'; - const submit = useSubmit(); const { data: defaultBrandingOptions, @@ -80,6 +80,7 @@ export const Component = () => { const [ error, setError ] = useState(); + const [ isSplashscreenEnabled, setIsSplashscreenEnabled ] = useState(brandingOptions.SplashscreenEnabled ?? false); const [ splashscreenUrl, setSplashscreenUrl ] = useState(); useEffect(() => { if (!api || isSubmitting) return; @@ -143,14 +144,22 @@ export const Component = () => { reader.readAsDataURL(file); }, [ api ]); - const setSplashscreenEnabled = useCallback((event: React.ChangeEvent, isEnabled: boolean) => { - setBrandingOptions({ - ...brandingOptions, - [BrandingOption.SplashscreenEnabled]: isEnabled - }); + const setSplashscreenEnabled = useCallback(async (_: React.ChangeEvent, isEnabled: boolean) => { + setIsSplashscreenEnabled(isEnabled); - submit(event.target.form); - }, [ brandingOptions, submit ]); + await getConfigurationApi(api!) + .updateNamedConfiguration({ + key: BRANDING_CONFIG_KEY, + body: JSON.stringify({ + ...defaultBrandingOptions, + SplashscreenEnabled: isEnabled + }) + }); + + void queryClient.invalidateQueries({ + queryKey: [ QUERY_KEY ] + }); + }, [ api, defaultBrandingOptions ]); const setBrandingOption = useCallback((event: React.ChangeEvent) => { if (Object.keys(BrandingOption).includes(event.target.name)) { @@ -206,7 +215,7 @@ export const Component = () => { { control={ } label={globalize.translate('EnableSplashScreen')} /> - - {globalize.translate('CustomSplashScreenSize')} - + {/* FIXME: Disabled due to https://github.com/jellyfin/jellyfin/issues/13744 */} + {ENABLE_CUSTOM_IMAGE && ( + <> + + {globalize.translate('CustomSplashScreenSize')} + - + - + + + )} From c4dc2c5a70b6754aa819ab9d0a09b2a4973dc8f0 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Fri, 21 Mar 2025 12:55:12 -0400 Subject: [PATCH 3/4] Replace toString with type assertion --- src/apps/dashboard/routes/branding/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/dashboard/routes/branding/index.tsx b/src/apps/dashboard/routes/branding/index.tsx index 8e565127b..493c222c6 100644 --- a/src/apps/dashboard/routes/branding/index.tsx +++ b/src/apps/dashboard/routes/branding/index.tsx @@ -124,7 +124,7 @@ export const Component = () => { reader.onload = () => { if (!reader.result) return; - const dataUrl = reader.result.toString(); + const dataUrl = reader.result as string; // readAsDataURL produces a string // FIXME: TypeScript SDK thinks body should be a File but in reality it is a Base64 string const body = dataUrl.split(',')[1] as never; getImageApi(api) From 0a40c4324b9932c4c22ee9ef3867794cd6adca29 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Tue, 1 Apr 2025 14:54:45 -0400 Subject: [PATCH 4/4] Enable custom branding image --- src/apps/dashboard/routes/branding/index.tsx | 58 +++++++++----------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/src/apps/dashboard/routes/branding/index.tsx b/src/apps/dashboard/routes/branding/index.tsx index 493c222c6..d69222bb1 100644 --- a/src/apps/dashboard/routes/branding/index.tsx +++ b/src/apps/dashboard/routes/branding/index.tsx @@ -25,7 +25,6 @@ import { queryClient } from 'utils/query/queryClient'; import { ActionData } from 'types/actionData'; const BRANDING_CONFIG_KEY = 'branding'; -const ENABLE_CUSTOM_IMAGE = false; const SPLASHSCREEN_URL = '/Branding/Splashscreen'; const BrandingOption = { CustomCss: 'CustomCss', @@ -237,39 +236,34 @@ export const Component = () => { label={globalize.translate('EnableSplashScreen')} /> - {/* FIXME: Disabled due to https://github.com/jellyfin/jellyfin/issues/13744 */} - {ENABLE_CUSTOM_IMAGE && ( - <> - - {globalize.translate('CustomSplashScreenSize')} - + + {globalize.translate('CustomSplashScreenSize')} + - + - - - )} +