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 options = config.TrickplayOptions;
const page = element.current; if (!options) throw new Error('Unexpected null TrickplayOptions');
const options = config.TrickplayOptions;
if (!page) { options.EnableHwAcceleration = data.HwAcceleration?.toString() === 'on';
console.error('Unexpected null reference'); options.EnableHwEncoding = data.HwEncoding?.toString() === 'on';
return; 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; await getConfigurationApi(api)
(page.querySelector('.chkEnableHwEncoding') as HTMLInputElement).checked = options?.EnableHwEncoding || false; .updateConfiguration({ serverConfiguration: config });
(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(); void queryClient.invalidateQueries({
}, []); queryKey: [ QUERY_KEY ]
});
const loadData = useCallback(() => { return {
loading.show(); isSaved: true
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;
}; };
};
const optionProcessPriority = () => { export const Component = () => {
let content = ''; const navigation = useNavigation();
content += `<option value='High'>${globalize.translate('PriorityHigh')}</option>`; const actionData = useActionData() as ActionData | undefined;
content += `<option value='AboveNormal'>${globalize.translate('PriorityAboveNormal')}</option>`; const { data: defaultConfig, isPending } = useConfiguration();
content += `<option value='Normal'>${globalize.translate('PriorityNormal')}</option>`; const isSubmitting = navigation.state === 'submitting';
content += `<option value='BelowNormal'>${globalize.translate('PriorityBelowNormal')}</option>`;
content += `<option value='Idle'>${globalize.translate('PriorityIdle')}</option>`; if (!defaultConfig || isPending) {
return content; return <Loading />;
}; }
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' )}
<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'> <TextField
<CheckBoxElement label={globalize.translate('LabelWidthResolutions')}
className='chkEnableHwEncoding' name='WidthResolutions'
title='LabelTrickplayAccelEncoding' defaultValue={defaultConfig.TrickplayOptions?.WidthResolutions}
inputProps={{
required: true,
pattern: '[0-9,]*'
}}
helperText={globalize.translate('LabelWidthResolutionsHelp')}
/> />
<div className='fieldDescription checkboxFieldDescription'>
<div className='fieldDescription'> <TextField
{globalize.translate('LabelTrickplayAccelEncodingHelp')} label={globalize.translate('LabelTileWidth')}
</div> name='TileWidth'
</div> type='number'
</div> inputMode='numeric'
<div className='checkboxContainer checkboxContainer-withDescription'> defaultValue={defaultConfig.TrickplayOptions?.TileWidth}
<CheckBoxElement inputProps={{
className='chkEnableKeyFrameOnlyExtraction' min: 1,
title='LabelTrickplayKeyFrameOnlyExtraction' required: true
}}
helperText={globalize.translate('LabelTileWidthHelp')}
/> />
<div className='fieldDescription checkboxFieldDescription'>
<div className='fieldDescription'>
{globalize.translate('LabelTrickplayKeyFrameOnlyExtractionHelp')}
</div>
</div>
</div>
<div className='verticalSection'> <TextField
<div className='selectContainer fldSelectScanBehavior'> label={globalize.translate('LabelTileHeight')}
<SelectElement name='TileHeight'
id='selectScanBehavior' type='number'
label='LabelScanBehavior' inputMode='numeric'
> defaultValue={defaultConfig.TrickplayOptions?.TileHeight}
{optionScanBehavior()} inputProps={{
</SelectElement> min: 1,
<div className='fieldDescription'> required: true
{globalize.translate('LabelScanBehaviorHelp')} }}
</div> helperText={globalize.translate('LabelTileHeightHelp')}
</div> />
</div>
<div className='verticalSection'> <TextField
<div className='selectContainer fldSelectProcessPriority'> label={globalize.translate('LabelJpegQuality')}
<SelectElement name='JpegQuality'
id='selectProcessPriority' type='number'
label='LabelProcessPriority' inputMode='numeric'
> defaultValue={defaultConfig.TrickplayOptions?.JpegQuality}
{optionProcessPriority()} inputProps={{
</SelectElement> min: 1,
<div className='fieldDescription'> max: 100,
{globalize.translate('LabelProcessPriorityHelp')} required: true
</div> }}
</div> helperText={globalize.translate('LabelJpegQualityHelp')}
</div> />
<div className='verticalSection'> <TextField
<div className='inputContainer'> label={globalize.translate('LabelQscale')}
<InputElement name='Qscale'
type='number' type='number'
id='txtInterval' inputMode='numeric'
label='LabelImageInterval' defaultValue={defaultConfig.TrickplayOptions?.Qscale}
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'} inputProps={{
/> min: 2,
<div className='fieldDescription'> max: 31,
{globalize.translate('LabelImageIntervalHelp')} required: true
</div> }}
</div> helperText={globalize.translate('LabelQscaleHelp')}
</div> />
<div className='verticalSection'> <TextField
<div className='inputContainer'> label={globalize.translate('LabelTrickplayThreads')}
<InputElement name='TrickplayThreads'
type='text' type='number'
id='txtWidthResolutions' inputMode='numeric'
label='LabelWidthResolutions' defaultValue={defaultConfig.TrickplayOptions?.ProcessThreads}
options={'required pattern="[0-9,]*"'} inputProps={{
/> min: 0,
<div className='fieldDescription'> required: true
{globalize.translate('LabelWidthResolutionsHelp')} }}
</div> helperText={globalize.translate('LabelTrickplayThreadsHelp')}
</div> />
</div>
<div className='verticalSection'> <Button
<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
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
}); });