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