1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00
This commit is contained in:
Bill Thornton 2025-03-30 11:03:15 -04:00 committed by GitHub
commit 39bc16034e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 248 additions and 53 deletions

View file

@ -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<PluginImageProps> = ({
isLoading,
alt,
url
}) => (
<Paper sx={{ width: '100%', aspectRatio: 16 / 9, overflow: 'hidden' }}>
{isLoading && (
<Skeleton
variant='rectangular'
width='100%'
height='100%'
/>
)}
{url && (
<img
src={url}
alt={alt}
width='100%'
/>
)}
</Paper>
);
export default PluginImage;

View file

@ -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,22 @@ 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 React, { useCallback, useEffect, useState } from 'react';
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';
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 ENABLE_CUSTOM_IMAGE = false;
const SPLASHSCREEN_URL = '/Branding/Splashscreen';
const BrandingOption = {
CustomCss: 'CustomCss',
LoginDisclaimer: 'LoginDisclaimer',
@ -60,6 +67,7 @@ export const loader = () => {
};
export const Component = () => {
const { api } = useApi();
const navigation = useNavigation();
const actionData = useActionData() as ActionData | undefined;
const isSubmitting = navigation.state === 'submitting';
@ -70,12 +78,88 @@ export const Component = () => {
} = useBrandingOptions();
const [ brandingOptions, setBrandingOptions ] = useState(defaultBrandingOptions || {});
const setSplashscreenEnabled = useCallback((_: React.ChangeEvent<HTMLInputElement>, isEnabled: boolean) => {
setBrandingOptions({
...brandingOptions,
[BrandingOption.SplashscreenEnabled]: isEnabled
const [ error, setError ] = useState<string>();
const [ isSplashscreenEnabled, setIsSplashscreenEnabled ] = useState(brandingOptions.SplashscreenEnabled ?? false);
const [ splashscreenUrl, setSplashscreenUrl ] = useState<string>();
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<HTMLInputElement>) => {
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 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)
.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(async (_: React.ChangeEvent<HTMLInputElement>, isEnabled: boolean) => {
setIsSplashscreenEnabled(isEnabled);
await getConfigurationApi(api!)
.updateNamedConfiguration({
key: BRANDING_CONFIG_KEY,
body: JSON.stringify({
...defaultBrandingOptions,
SplashscreenEnabled: isEnabled
})
});
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
}, [ brandingOptions ]);
}, [ api, defaultBrandingOptions ]);
const setBrandingOption = useCallback((event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
if (Object.keys(BrandingOption).includes(event.target.name)) {
@ -86,6 +170,10 @@ export const Component = () => {
}
}, [ brandingOptions ]);
const onSubmit = useCallback(() => {
setError(undefined);
}, []);
if (isPending) return <Loading />;
return (
@ -95,7 +183,10 @@ export const Component = () => {
className='mainAnimatedPage type-interior'
>
<Box className='content-primary'>
<Form method='POST'>
<Form
method='POST'
onSubmit={onSubmit}
>
<Stack spacing={3}>
<Typography variant='h1'>
{globalize.translate('HeaderBranding')}
@ -107,16 +198,80 @@ export const Component = () => {
</Alert>
)}
<FormControlLabel
control={
<Switch
name={BrandingOption.SplashscreenEnabled}
checked={brandingOptions?.SplashscreenEnabled}
onChange={setSplashscreenEnabled}
{error && (
<Alert severity='error'>
{globalize.translate(error)}
</Alert>
)}
<Stack
direction={{
xs: 'column',
sm: 'row'
}}
spacing={3}
>
<Box sx={{ flex: '1 1 0' }}>
<Image
isLoading={false}
url={
isSplashscreenEnabled ?
splashscreenUrl :
undefined
}
/>
}
label={globalize.translate('EnableSplashScreen')}
/>
</Box>
<Stack
spacing={{ xs: 3, sm: 2 }}
sx={{ flex: '1 1 0' }}
>
<FormControlLabel
control={
<Switch
name={BrandingOption.SplashscreenEnabled}
checked={isSplashscreenEnabled}
onChange={setSplashscreenEnabled}
/>
}
label={globalize.translate('EnableSplashScreen')}
/>
{/* FIXME: Disabled due to https://github.com/jellyfin/jellyfin/issues/13744 */}
{ENABLE_CUSTOM_IMAGE && (
<>
<Typography variant='body2'>
{globalize.translate('CustomSplashScreenSize')}
</Typography>
<Button
component='label'
variant='outlined'
startIcon={<Upload />}
disabled={!isSplashscreenEnabled}
>
<input
type='file'
accept='image/*'
hidden
onChange={onSplashscreenUpload}
/>
{globalize.translate('UploadCustomImage')}
</Button>
<Button
variant='outlined'
color='error'
startIcon={<Delete />}
disabled={!isSplashscreenEnabled}
onClick={onSplashscreenDelete}
>
{globalize.translate('DeleteCustomImage')}
</Button>
</>
)}
</Stack>
</Stack>
<TextField
fullWidth

View file

@ -12,6 +12,7 @@ import Switch from '@mui/material/Switch/Switch';
import Typography from '@mui/material/Typography/Typography';
import Delete from '@mui/icons-material/Delete';
import Download from '@mui/icons-material/Download';
import Extension from '@mui/icons-material/Extension';
import Settings from '@mui/icons-material/Settings';
import React, { type FC, useState, useCallback, useMemo } from 'react';
import { useSearchParams, Link as RouterLink, useParams } from 'react-router-dom';
@ -25,12 +26,12 @@ import { useInstallPackage } from 'apps/dashboard/features/plugins/api/useInstal
import { usePackageInfo } from 'apps/dashboard/features/plugins/api/usePackageInfo';
import { usePlugins } from 'apps/dashboard/features/plugins/api/usePlugins';
import { useUninstallPlugin } from 'apps/dashboard/features/plugins/api/useUninstallPlugin';
import PluginImage from 'apps/dashboard/features/plugins/components/PluginImage';
import PluginDetailsTable from 'apps/dashboard/features/plugins/components/PluginDetailsTable';
import PluginRevisions from 'apps/dashboard/features/plugins/components/PluginRevisions';
import type { PluginDetails } from 'apps/dashboard/features/plugins/types/PluginDetails';
import ConfirmDialog from 'components/ConfirmDialog';
import Image from 'components/Image';
import Page from 'components/Page';
import { useApi } from 'hooks/useApi';
import globalize from 'lib/globalize';
@ -332,10 +333,11 @@ const PluginPage: FC = () => {
</Grid>
<Grid item lg={4} sx={{ display: { xs: 'none', lg: 'initial' } }}>
<PluginImage
<Image
isLoading={isLoading}
alt={pluginDetails?.name}
url={pluginDetails?.imageUrl}
FallbackIcon={Extension}
/>
</Grid>

66
src/components/Image.tsx Normal file
View file

@ -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<ImageProps> = ({
isLoading,
alt,
url,
aspectRatio = 16 / 9,
FallbackIcon = ImageNotSupported
}) => (
<Paper
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
aspectRatio,
overflow: 'hidden'
}}
>
{isLoading && (
<Skeleton
variant='rectangular'
width='100%'
height='100%'
/>
)}
{url ? (
<img
src={url}
alt={alt}
width='100%'
/>
) : (
<Box
sx={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<FallbackIcon
sx={{
height: '25%',
width: 'auto'
}}
/>
</Box>
)}
</Paper>
);
export default Image;

View file

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