1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Merge pull request #6524 from viown/mui-trickplay

Convert trickplay to mui
This commit is contained in:
Bill Thornton 2025-02-20 17:06:19 -05:00 committed by GitHub
commit 3477e0930b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 242 additions and 296 deletions

View file

@ -9,10 +9,11 @@ import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
import { ActionData } from 'types/actionData';
import { useConfiguration } from 'hooks/useConfiguration';
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
import Loading from 'components/loading/LoadingComponent';
import ServerConnections from 'components/ServerConnections';
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
import { queryClient } from 'utils/query/queryClient';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
@ -36,6 +37,10 @@ export const action = async ({ request }: ActionFunctionArgs) => {
await getConfigurationApi(api)
.updateConfiguration({ serverConfiguration: config });
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
return {
isSaved: true
};

View file

@ -10,9 +10,10 @@ import Typography from '@mui/material/Typography';
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
import ServerConnections from 'components/ServerConnections';
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
import { useConfiguration } from 'hooks/useConfiguration';
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
import Loading from 'components/loading/LoadingComponent';
import { ActionData } from 'types/actionData';
import { queryClient } from 'utils/query/queryClient';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
@ -27,6 +28,10 @@ export const action = async ({ request }: ActionFunctionArgs) => {
await getConfigurationApi(api)
.updateConfiguration({ serverConfiguration: config });
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
return {
isSaved: true
};

View file

@ -1,325 +1,259 @@
import type { ServerConfiguration } from '@jellyfin/sdk/lib/generated-client/models/server-configuration';
import React from 'react';
import globalize from 'lib/globalize';
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
import Page from 'components/Page';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormControl from '@mui/material/FormControl';
import Switch from '@mui/material/Switch';
import Loading from 'components/loading/LoadingComponent';
import FormHelperText from '@mui/material/FormHelperText';
import MenuItem from '@mui/material/MenuItem';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import Alert from '@mui/material/Alert';
import ServerConnections from 'components/ServerConnections';
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
import { TrickplayScanBehavior } from '@jellyfin/sdk/lib/generated-client/models/trickplay-scan-behavior';
import { ProcessPriorityClass } from '@jellyfin/sdk/lib/generated-client/models/process-priority-class';
import React, { type FC, useCallback, useEffect, useRef } from 'react';
import { ActionData } from 'types/actionData';
import { queryClient } from 'utils/query/queryClient';
import globalize from '../../../../lib/globalize';
import Page from '../../../../components/Page';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import ButtonElement from '../../../../elements/ButtonElement';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
import SelectElement from '../../../../elements/SelectElement';
import InputElement from '../../../../elements/InputElement';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import ServerConnections from '../../../../components/ServerConnections';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
if (!api) throw new Error('No Api instance available');
function onSaveComplete() {
loading.hide();
toast(globalize.translate('SettingsSaved'));
}
const formData = await request.formData();
const data = Object.fromEntries(formData);
const PlaybackTrickplay: FC = () => {
const element = useRef<HTMLDivElement>(null);
const { data: config } = await getConfigurationApi(api).getConfiguration();
const loadConfig = useCallback((config: ServerConfiguration) => {
const page = element.current;
const options = config.TrickplayOptions;
const options = config.TrickplayOptions;
if (!options) throw new Error('Unexpected null TrickplayOptions');
if (!page) {
console.error('Unexpected null reference');
return;
}
options.EnableHwAcceleration = data.HwAcceleration?.toString() === 'on';
options.EnableHwEncoding = data.HwEncoding?.toString() === 'on';
options.EnableKeyFrameOnlyExtraction = data.KeyFrameOnlyExtraction?.toString() === 'on';
options.ScanBehavior = data.ScanBehavior.toString() as TrickplayScanBehavior;
options.ProcessPriority = data.ProcessPriority.toString() as ProcessPriorityClass;
options.Interval = parseInt(data.ImageInterval.toString() || '10000', 10);
options.WidthResolutions = data.WidthResolutions.toString().replace(' ', '').split(',').map(Number);
options.TileWidth = parseInt(data.TileWidth.toString() || '10', 10);
options.TileHeight = parseInt(data.TileHeight.toString() || '10', 10);
options.Qscale = parseInt(data.Qscale.toString() || '4', 10);
options.JpegQuality = parseInt(data.JpegQuality.toString() || '90', 10);
options.ProcessThreads = parseInt(data.TrickplayThreads.toString() || '1', 10);
(page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked = options?.EnableHwAcceleration || false;
(page.querySelector('.chkEnableHwEncoding') as HTMLInputElement).checked = options?.EnableHwEncoding || false;
(page.querySelector('.chkEnableKeyFrameOnlyExtraction') as HTMLInputElement).checked = options?.EnableKeyFrameOnlyExtraction || false;
(page.querySelector('#selectScanBehavior') as HTMLSelectElement).value = (options?.ScanBehavior || TrickplayScanBehavior.NonBlocking);
(page.querySelector('#selectProcessPriority') as HTMLSelectElement).value = (options?.ProcessPriority || ProcessPriorityClass.Normal);
(page.querySelector('#txtInterval') as HTMLInputElement).value = options?.Interval?.toString() || '10000';
(page.querySelector('#txtWidthResolutions') as HTMLInputElement).value = options?.WidthResolutions?.join(',') || '';
(page.querySelector('#txtTileWidth') as HTMLInputElement).value = options?.TileWidth?.toString() || '10';
(page.querySelector('#txtTileHeight') as HTMLInputElement).value = options?.TileHeight?.toString() || '10';
(page.querySelector('#txtQscale') as HTMLInputElement).value = options?.Qscale?.toString() || '4';
(page.querySelector('#txtJpegQuality') as HTMLInputElement).value = options?.JpegQuality?.toString() || '90';
(page.querySelector('#txtProcessThreads') as HTMLInputElement).value = options?.ProcessThreads?.toString() || '1';
await getConfigurationApi(api)
.updateConfiguration({ serverConfiguration: config });
loading.hide();
}, []);
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
const loadData = useCallback(() => {
loading.show();
ServerConnections.currentApiClient()?.getServerConfiguration().then(function (config) {
loadConfig(config);
}).catch(err => {
console.error('[PlaybackTrickplay] failed to fetch server config', err);
});
}, [loadConfig]);
useEffect(() => {
const page = element.current;
if (!page) {
console.error('Unexpected null reference');
return;
}
const saveConfig = (config: ServerConfiguration) => {
const apiClient = ServerConnections.currentApiClient();
if (!apiClient) {
console.error('[PlaybackTrickplay] No current apiclient instance');
return;
}
if (!config.TrickplayOptions) {
throw new Error('Unexpected null TrickplayOptions');
}
const options = config.TrickplayOptions;
options.EnableHwAcceleration = (page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked;
options.EnableHwEncoding = (page.querySelector('.chkEnableHwEncoding') as HTMLInputElement).checked;
options.EnableKeyFrameOnlyExtraction = (page.querySelector('.chkEnableKeyFrameOnlyExtraction') as HTMLInputElement).checked;
options.ScanBehavior = (page.querySelector('#selectScanBehavior') as HTMLSelectElement).value as TrickplayScanBehavior;
options.ProcessPriority = (page.querySelector('#selectProcessPriority') as HTMLSelectElement).value as ProcessPriorityClass;
options.Interval = Math.max(1, parseInt((page.querySelector('#txtInterval') as HTMLInputElement).value || '10000', 10));
options.WidthResolutions = (page.querySelector('#txtWidthResolutions') as HTMLInputElement).value.replace(' ', '').split(',').map(Number);
options.TileWidth = Math.max(1, parseInt((page.querySelector('#txtTileWidth') as HTMLInputElement).value || '10', 10));
options.TileHeight = Math.max(1, parseInt((page.querySelector('#txtTileHeight') as HTMLInputElement).value || '10', 10));
options.Qscale = Math.min(31, parseInt((page.querySelector('#txtQscale') as HTMLInputElement).value || '4', 10));
options.JpegQuality = Math.min(100, parseInt((page.querySelector('#txtJpegQuality') as HTMLInputElement).value || '90', 10));
options.ProcessThreads = parseInt((page.querySelector('#txtProcessThreads') as HTMLInputElement).value || '1', 10);
apiClient.updateServerConfiguration(config).then(() => {
onSaveComplete();
}).catch(err => {
console.error('[PlaybackTrickplay] failed to update config', err);
});
};
const onSubmit = (e: Event) => {
const apiClient = ServerConnections.currentApiClient();
if (!apiClient) {
console.error('[PlaybackTrickplay] No current apiclient instance');
return;
}
loading.show();
apiClient.getServerConfiguration().then(function (config) {
saveConfig(config);
}).catch(err => {
console.error('[PlaybackTrickplay] failed to fetch server config', err);
});
e.preventDefault();
e.stopPropagation();
return false;
};
(page.querySelector('.trickplayConfigurationForm') as HTMLFormElement).addEventListener('submit', onSubmit);
loadData();
}, [loadData]);
const optionScanBehavior = () => {
let content = '';
content += `<option value='NonBlocking'>${globalize.translate('NonBlockingScan')}</option>`;
content += `<option value='Blocking'>${globalize.translate('BlockingScan')}</option>`;
return content;
return {
isSaved: true
};
};
const optionProcessPriority = () => {
let content = '';
content += `<option value='High'>${globalize.translate('PriorityHigh')}</option>`;
content += `<option value='AboveNormal'>${globalize.translate('PriorityAboveNormal')}</option>`;
content += `<option value='Normal'>${globalize.translate('PriorityNormal')}</option>`;
content += `<option value='BelowNormal'>${globalize.translate('PriorityBelowNormal')}</option>`;
content += `<option value='Idle'>${globalize.translate('PriorityIdle')}</option>`;
return content;
};
export const Component = () => {
const navigation = useNavigation();
const actionData = useActionData() as ActionData | undefined;
const { data: defaultConfig, isPending } = useConfiguration();
const isSubmitting = navigation.state === 'submitting';
if (!defaultConfig || isPending) {
return <Loading />;
}
return (
<Page
id='trickplayConfigurationPage'
className='mainAnimatedPage type-interior playbackConfigurationPage'
className='mainAnimatedPage type-interior'
title={globalize.translate('Trickplay')}
>
<div ref={element} className='content-primary'>
<div className='verticalSection'>
<SectionTitleContainer
title={globalize.translate('Trickplay')}
/>
</div>
<Box className='content-primary'>
<Form method='POST'>
<Stack spacing={3}>
<Typography variant='h1'>
{globalize.translate('Trickplay')}
</Typography>
<form className='trickplayConfigurationForm'>
<div className='checkboxContainer checkboxContainer-withDescription'>
<CheckBoxElement
className='chkEnableHwAcceleration'
title='LabelTrickplayAccel'
{!isSubmitting && actionData?.isSaved && (
<Alert severity='success'>
{globalize.translate('SettingsSaved')}
</Alert>
)}
<FormControl>
<FormControlLabel
control={
<Switch
name='HwAcceleration'
defaultChecked={defaultConfig.TrickplayOptions?.EnableHwAcceleration}
/>
}
label={globalize.translate('LabelTrickplayAccel')}
/>
</FormControl>
<FormControl>
<FormControlLabel
control={
<Switch
name='HwEncoding'
defaultChecked={defaultConfig.TrickplayOptions?.EnableHwEncoding}
/>
}
label={globalize.translate('LabelTrickplayAccelEncoding')}
/>
<FormHelperText>{globalize.translate('LabelTrickplayAccelEncodingHelp')}</FormHelperText>
</FormControl>
<FormControl>
<FormControlLabel
control={
<Switch
name='KeyFrameOnlyExtraction'
defaultChecked={defaultConfig.TrickplayOptions?.EnableKeyFrameOnlyExtraction}
/>
}
label={globalize.translate('LabelTrickplayKeyFrameOnlyExtraction')}
/>
<FormHelperText>{globalize.translate('LabelTrickplayKeyFrameOnlyExtractionHelp')}</FormHelperText>
</FormControl>
<TextField
name='ScanBehavior'
select
defaultValue={defaultConfig.TrickplayOptions?.ScanBehavior}
label={globalize.translate('LabelScanBehavior')}
helperText={globalize.translate('LabelScanBehaviorHelp')}
>
<MenuItem value={TrickplayScanBehavior.NonBlocking}>{globalize.translate('NonBlockingScan')}</MenuItem>
<MenuItem value={TrickplayScanBehavior.Blocking}>{globalize.translate('BlockingScan')}</MenuItem>
</TextField>
<TextField
name='ProcessPriority'
select
defaultValue={defaultConfig.TrickplayOptions?.ProcessPriority}
label={globalize.translate('LabelProcessPriority')}
helperText={globalize.translate('LabelProcessPriorityHelp')}
>
<MenuItem value={ProcessPriorityClass.High}>{globalize.translate('PriorityHigh')}</MenuItem>
<MenuItem value={ProcessPriorityClass.AboveNormal}>{globalize.translate('PriorityAboveNormal')}</MenuItem>
<MenuItem value={ProcessPriorityClass.Normal}>{globalize.translate('PriorityNormal')}</MenuItem>
<MenuItem value={ProcessPriorityClass.BelowNormal}>{globalize.translate('PriorityBelowNormal')}</MenuItem>
<MenuItem value={ProcessPriorityClass.Idle}>{globalize.translate('PriorityIdle')}</MenuItem>
</TextField>
<TextField
label={globalize.translate('LabelImageInterval')}
name='ImageInterval'
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.Interval}
inputProps={{
min: 1,
required: true
}}
helperText={globalize.translate('LabelImageIntervalHelp')}
/>
</div>
<div className='checkboxContainer checkboxContainer-withDescription'>
<CheckBoxElement
className='chkEnableHwEncoding'
title='LabelTrickplayAccelEncoding'
<TextField
label={globalize.translate('LabelWidthResolutions')}
name='WidthResolutions'
defaultValue={defaultConfig.TrickplayOptions?.WidthResolutions}
inputProps={{
required: true,
pattern: '[0-9,]*'
}}
helperText={globalize.translate('LabelWidthResolutionsHelp')}
/>
<div className='fieldDescription checkboxFieldDescription'>
<div className='fieldDescription'>
{globalize.translate('LabelTrickplayAccelEncodingHelp')}
</div>
</div>
</div>
<div className='checkboxContainer checkboxContainer-withDescription'>
<CheckBoxElement
className='chkEnableKeyFrameOnlyExtraction'
title='LabelTrickplayKeyFrameOnlyExtraction'
<TextField
label={globalize.translate('LabelTileWidth')}
name='TileWidth'
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.TileWidth}
inputProps={{
min: 1,
required: true
}}
helperText={globalize.translate('LabelTileWidthHelp')}
/>
<div className='fieldDescription checkboxFieldDescription'>
<div className='fieldDescription'>
{globalize.translate('LabelTrickplayKeyFrameOnlyExtractionHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='selectContainer fldSelectScanBehavior'>
<SelectElement
id='selectScanBehavior'
label='LabelScanBehavior'
>
{optionScanBehavior()}
</SelectElement>
<div className='fieldDescription'>
{globalize.translate('LabelScanBehaviorHelp')}
</div>
</div>
</div>
<TextField
label={globalize.translate('LabelTileHeight')}
name='TileHeight'
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.TileHeight}
inputProps={{
min: 1,
required: true
}}
helperText={globalize.translate('LabelTileHeightHelp')}
/>
<div className='verticalSection'>
<div className='selectContainer fldSelectProcessPriority'>
<SelectElement
id='selectProcessPriority'
label='LabelProcessPriority'
>
{optionProcessPriority()}
</SelectElement>
<div className='fieldDescription'>
{globalize.translate('LabelProcessPriorityHelp')}
</div>
</div>
</div>
<TextField
label={globalize.translate('LabelJpegQuality')}
name='JpegQuality'
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.JpegQuality}
inputProps={{
min: 1,
max: 100,
required: true
}}
helperText={globalize.translate('LabelJpegQualityHelp')}
/>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtInterval'
label='LabelImageInterval'
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelImageIntervalHelp')}
</div>
</div>
</div>
<TextField
label={globalize.translate('LabelQscale')}
name='Qscale'
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.Qscale}
inputProps={{
min: 2,
max: 31,
required: true
}}
helperText={globalize.translate('LabelQscaleHelp')}
/>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='text'
id='txtWidthResolutions'
label='LabelWidthResolutions'
options={'required pattern="[0-9,]*"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelWidthResolutionsHelp')}
</div>
</div>
</div>
<TextField
label={globalize.translate('LabelTrickplayThreads')}
name='TrickplayThreads'
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.ProcessThreads}
inputProps={{
min: 0,
required: true
}}
helperText={globalize.translate('LabelTrickplayThreadsHelp')}
/>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtTileWidth'
label='LabelTileWidth'
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelTileWidthHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtTileHeight'
label='LabelTileHeight'
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelTileHeightHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtJpegQuality'
label='LabelJpegQuality'
options={'required inputMode="numeric" pattern="[0-9]*" min="1" max="100"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelJpegQualityHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtQscale'
label='LabelQscale'
options={'required inputMode="numeric" pattern="[0-9]*" min="2" max="31"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelQscaleHelp')}
</div>
</div>
</div>
<div className='verticalSection'>
<div className='inputContainer'>
<InputElement
type='number'
id='txtProcessThreads'
label='LabelTrickplayThreads'
options={'required inputMode="numeric" pattern="[0-9]*" min="0"'}
/>
<div className='fieldDescription'>
{globalize.translate('LabelTrickplayThreadsHelp')}
</div>
</div>
</div>
<div>
<ButtonElement
<Button
type='submit'
className='raised button-submit block'
title='Save'
/>
</div>
</form>
</div>
size='large'
>
{globalize.translate('Save')}
</Button>
</Stack>
</Form>
</Box>
</Page>
);
};
export default PlaybackTrickplay;
Component.displayName = 'TrickplayPage';

View file

@ -4,6 +4,8 @@ import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import type { AxiosRequestConfig } from 'axios';
export const QUERY_KEY = 'Configuration';
export const fetchConfiguration = async (api?: Api, options?: AxiosRequestConfig) => {
if (!api) {
console.error('[useLogOptions] No API instance available');
@ -19,7 +21,7 @@ export const useConfiguration = () => {
const { api } = useApi();
return useQuery({
queryKey: ['Configuration'],
queryKey: [QUERY_KEY],
queryFn: ({ signal }) => fetchConfiguration(api, { signal }),
enabled: !!api
});