Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Output settings form to react #5299

Open
wants to merge 22 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
95d4da5
Migrate Output settings to react. Styling WIP.
michelinewu Feb 4, 2025
7bc8088
Styling for collapse form sections. Tab form sections styling WIP.
michelinewu Feb 5, 2025
19b8f57
Advance form tabs.
michelinewu Feb 5, 2025
34bea75
WIP: Input Resolution List
michelinewu Feb 6, 2025
5104678
WIP
michelinewu Feb 6, 2025
7426b84
Added input resolution field to output settings form.
michelinewu Feb 6, 2025
322f2ee
WIP: input resolution field.
michelinewu Feb 7, 2025
d64fb63
WIP: obs form fields.
michelinewu Feb 7, 2025
be36d11
Fixes for form.
michelinewu Feb 10, 2025
af7d29d
Refactor to forward reference to components for validation.
michelinewu Feb 10, 2025
b82dc4a
Fix for uint field and css styling.
michelinewu Feb 11, 2025
608aeb8
WIP
michelinewu Feb 18, 2025
eb6cab9
Simple output settings panels open by default and styling fixes.
michelinewu Feb 18, 2025
9c9ff27
Refactor generic obs form tabbed group and refactor output settings f…
michelinewu Feb 25, 2025
29a0eb6
Fix file input field.
michelinewu Feb 25, 2025
8723ef6
Fix recording path selector in tests.
michelinewu Feb 25, 2025
12686dd
Fix selectors for tests.
michelinewu Feb 27, 2025
7c061db
Merge branch 'master' into mw_output_settings_react
michelinewu Feb 27, 2025
b8849ff
Merge branch 'master' into mw_output_settings_react
michelinewu Mar 21, 2025
3f9c5bb
Merge branch 'master' into mw_output_settings_react
michelinewu Mar 28, 2025
e8e22b4
Resolve file input merge conflict.
michelinewu Mar 28, 2025
d94bd98
Merge branch 'master' into mw_output_settings_react
gettinToasty Apr 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
446 changes: 416 additions & 30 deletions app/components-react/obs/ObsForm.tsx

Large diffs are not rendered by default.

17 changes: 12 additions & 5 deletions app/components-react/shared/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,20 @@ import React, { CSSProperties } from 'react';
import { Tabs as AntdTabs } from 'antd';
import { $t } from 'services/i18n';

interface TabData {
interface ITab {
label: string | JSX.Element;
key: string;
}

interface ITabs {
data?: TabData[];
tabs?: string[];
onChange?: (param?: any) => void;
style?: CSSProperties;
tabStyle?: CSSProperties;
}

export default function Tabs(p: ITabs) {
const dualOutputData = [
const dualOutputData: ITab[] = [
{
label: (
<span>
Expand All @@ -36,11 +36,18 @@ export default function Tabs(p: ITabs) {
},
];

const data = p?.data ?? dualOutputData;
const data = p?.tabs ? formatTabs(p.tabs) : dualOutputData;

function formatTabs(tabs: string[]): ITab[] {
return tabs.map((tab: string) => ({
label: $t(tab),
key: tab,
}));
}

return (
<AntdTabs defaultActiveKey={data[0].key} onChange={p?.onChange} style={p?.style}>
{data.map(tab => (
{data.map((tab: ITab) => (
<AntdTabs.TabPane tab={tab.label} key={tab.key} style={p?.tabStyle} />
))}
</AntdTabs>
Expand Down
66 changes: 37 additions & 29 deletions app/components-react/shared/inputs/FileInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,41 +17,46 @@ type TFileInputProps = TSlobsInputProps<
InputProps
>;

export const FileInput = InputComponent((p: TFileInputProps) => {
const { wrapperAttrs, inputAttrs } = useInput('file', p);
async function showFileDialog() {
if (p.save) {
const options: Electron.SaveDialogOptions = {
defaultPath: p.value,
filters: p.filters,
properties: [],
};
export async function showFileDialog(p: TFileInputProps) {
if (p.save) {
const options: Electron.SaveDialogOptions = {
defaultPath: p.value,
filters: p.filters,
properties: [],
};

const { filePath } = await remote.dialog.showSaveDialog(options);
const { filePath } = await remote.dialog.showSaveDialog(options);

if (filePath && p.onChange) {
p.onChange(filePath);
}
} else {
const options: Electron.OpenDialogOptions = {
defaultPath: p.value,
filters: p.filters,
properties: [],
};
if (filePath && p.onChange) {
p.onChange(filePath);
}
} else {
const options: Electron.OpenDialogOptions = {
defaultPath: p.value,
filters: p.filters,
properties: [],
};

if (p.directory && options.properties) {
options.properties.push('openDirectory');
} else if (options.properties) {
options.properties.push('openFile');
}
if (p.directory && options.properties) {
options.properties.push('openDirectory');
} else if (options.properties) {
options.properties.push('openFile');
}

const { filePaths } = await remote.dialog.showOpenDialog(options);
const { filePaths } = await remote.dialog.showOpenDialog(options);

if (filePaths[0] && p.onChange) {
p.onChange(filePaths[0]);
}
if (filePaths[0] && p.onChange) {
p.onChange(filePaths[0]);
}
}
}

export const FileInput = InputComponent((p: TFileInputProps) => {
const { wrapperAttrs, inputAttrs } = useInput('file', p);

function handleShowFileDialog() {
showFileDialog(p);
}

return (
<InputWrapper {...wrapperAttrs}>
Expand All @@ -61,7 +66,10 @@ export const FileInput = InputComponent((p: TFileInputProps) => {
value={p.value}
disabled
addonAfter={
<Button style={p.buttonContent ? { borderRadius: '4px' } : {}} onClick={showFileDialog}>
<Button
style={p.buttonContent ? { borderRadius: '4px' } : {}}
onClick={handleShowFileDialog}
>
{p.buttonContent || $t('Browse')}
</Button>
}
Expand Down
7 changes: 6 additions & 1 deletion app/components-react/shared/inputs/ListInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface ICustomListProps<TValue> {
onBeforeSearch?: (searchStr: string) => unknown;
options?: IListOption<TValue>[];
description?: string;
nolabel?: boolean;
}

// define a type for the component's props
Expand Down Expand Up @@ -87,7 +88,11 @@ export const ListInput = InputComponent(<T extends any>(p: TListInputProps<T>) =
const selectedOption = options?.find(opt => opt.value === p.value);

return (
<InputWrapper {...wrapperAttrs} extra={p?.description ?? selectedOption?.description}>
<InputWrapper
{...wrapperAttrs}
extra={p?.description ?? selectedOption?.description}
nolabel={p?.nolabel}
>
<Select
ref={$inputRef}
{...omit(inputAttrs, 'onChange')}
Expand Down
11 changes: 8 additions & 3 deletions app/components-react/shared/inputs/NumberInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ import InputWrapper from './InputWrapper';
import { InputNumberProps } from 'antd/lib/input-number';

// select which features from the antd lib we are going to use
const ANT_NUMBER_FEATURES = ['min', 'max', 'step'] as const;
export const ANT_NUMBER_FEATURES = ['min', 'max', 'step'] as const;

type TProps = TSlobsInputProps<{}, number, InputNumberProps, ValuesOf<typeof ANT_NUMBER_FEATURES>>;
export type TNumberInputProps = TSlobsInputProps<
{},
number,
InputNumberProps,
ValuesOf<typeof ANT_NUMBER_FEATURES>
>;

export const NumberInput = React.memo((p: TProps) => {
export const NumberInput = React.memo((p: TNumberInputProps) => {
const { inputAttrs, wrapperAttrs, originalOnChange } = useTextInput<typeof p, number>(
'number',
p,
Expand Down
3 changes: 2 additions & 1 deletion app/components-react/shared/inputs/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import InputWrapper from './InputWrapper';
import { InputProps } from 'antd/lib/input';

// select which features from the antd lib we are going to use
const ANT_INPUT_FEATURES = ['addonBefore', 'addonAfter', 'autoFocus', 'prefix'] as const;
// note: to add a submit button for the text input, pass in a button to the `addonAfter` or `addonBefore` prop
export const ANT_INPUT_FEATURES = ['addonBefore', 'addonAfter', 'autoFocus', 'prefix'] as const;

export type TTextInputProps = TSlobsInputProps<
{
Expand Down
22 changes: 17 additions & 5 deletions app/components-react/windows/settings/ObsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,24 @@ import { useObsSettings } from './useObsSettings';
import { ObsFormGroup } from '../../obs/ObsForm';
import Form from '../../shared/inputs/Form';
import css from './ObsSettings.m.less';
import Tabs from 'components-react/shared/Tabs';

export type IObsFormType = 'default' | 'tabs' | 'collapsible';

/**
* Renders a settings page
*/
export function ObsSettings(p: { page: string }) {
const { setPage } = useObsSettings();
const { setPage, setDisplay } = useObsSettings();
setPage(p.page);
const PageComponent = getPageComponent(p.page);

// TODO: Comment in when switched to new API
// const showTabs = ['Output', 'Audio', 'Advanced'].includes(p.page);
const showTabs = false;
return (
<div className={css.obsSettingsWindow}>
{showTabs && <Tabs onChange={setDisplay} />}
<PageComponent />
</div>
);
Expand All @@ -22,10 +30,14 @@ export function ObsSettings(p: { page: string }) {
/**
* Renders generic inputs from OBS
*/
export function ObsGenericSettingsForm() {
export function ObsGenericSettingsForm(p: { type?: IObsFormType }) {
const { settingsFormData, saveSettings } = useObsSettings();
return (
<ObsFormGroup value={settingsFormData} onChange={newSettings => saveSettings(newSettings)} />
<ObsFormGroup
value={settingsFormData}
onChange={newSettings => saveSettings(newSettings)}
type={p?.type}
/>
);
}

Expand All @@ -50,7 +62,7 @@ export function ObsSettingsSection(
*/
function getPageComponent(page: string) {
const componentName = Object.keys(pageComponents).find(componentName => {
return pageComponents[componentName].page === page;
return (pageComponents as Record<string, any>)[componentName].page === page;
});
return componentName ? pageComponents[componentName] : null;
return componentName ? (pageComponents as Record<string, any>)[componentName] : null;
}
96 changes: 96 additions & 0 deletions app/components-react/windows/settings/Output.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import React, { useMemo, useState } from 'react';
import { useObsSettings } from './useObsSettings';
import {
IObsSectionedFormGroupProps,
ObsCollapsibleFormGroup,
ObsForm,
} from 'components-react/obs/ObsForm';
import Tabs from 'components-react/shared/Tabs';
import { $t } from 'services/i18n';
import cloneDeep from 'lodash/cloneDeep';
import { TObsFormData } from 'components/obs/inputs/ObsInput';

export function OutputSettings() {
const { settingsFormData, saveSettings } = useObsSettings();

const type = settingsFormData[0].parameters[0].currentValue === 'Simple' ? 'collapsible' : 'tabs';

function onChange(formData: TObsFormData, ind: number) {
const newVal = cloneDeep(settingsFormData);
newVal[ind].parameters = formData;
saveSettings(newVal);
}
const sections = settingsFormData.filter(
section => section.parameters.filter(p => p.visible).length,
);

return (
<div className="form-groups" style={{ paddingBottom: '12px' }}>
{type === 'tabs' && <ObsTabbedOutputFormGroup sections={sections} onChange={onChange} />}

{type === 'collapsible' && (
<ObsCollapsibleFormGroup sections={sections} onChange={onChange} />
)}
</div>
);
}

export function ObsTabbedOutputFormGroup(p: IObsSectionedFormGroupProps) {
const tabs = useMemo(() => {
// combine all audio tracks into one tab
const filtered = p.sections
.filter(sectionProps => sectionProps.nameSubCategory !== 'Untitled')
.filter(sectionProps => !sectionProps.nameSubCategory.startsWith('Audio - Track'))
.map(sectionProps => sectionProps.nameSubCategory);

filtered.splice(2, 0, 'Audio');
return filtered;
}, [p.sections]);

const [currentTab, setCurrentTab] = useState(p.sections[1].nameSubCategory);

return (
<div className="section" key="tabbed-section" style={{ marginBottom: '24px' }}>
{p.sections.map((sectionProps, ind) => (
<div className="section-content" key={`${sectionProps.nameSubCategory}${ind}`}>
{sectionProps.nameSubCategory === 'Untitled' && (
<>
<ObsForm
value={sectionProps.parameters}
onChange={formData => p.onChange(formData, ind)}
/>
<Tabs tabs={tabs} onChange={setCurrentTab} style={{ marginBottom: '24px' }} />
</>
)}

{sectionProps.nameSubCategory === currentTab && (
<ObsForm
name={sectionProps.nameSubCategory}
value={sectionProps.parameters}
onChange={formData => p.onChange(formData, ind)}
/>
)}

{currentTab === 'Audio' && sectionProps.nameSubCategory.startsWith('Audio - Track') && (
<div
style={{
backgroundColor: 'var(--section-wrapper)',
padding: '15px',
marginBottom: '30px',
borderRadius: '5px',
}}
>
<h2 className="section-title">{$t(sectionProps.nameSubCategory)}</h2>
<ObsForm
value={sectionProps.parameters}
onChange={formData => p.onChange(formData, ind)}
/>
</div>
)}
</div>
))}
</div>
);
}

OutputSettings.page = 'Output';
2 changes: 1 addition & 1 deletion app/components-react/windows/settings/pages.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export * from './General';
export * from './Multistreaming';
export * from './Stream';
// 'Output',
export * from './Output';
export * from './Audio';
export * from './Video';
// 'Hotkeys',
Expand Down
6 changes: 6 additions & 0 deletions app/components-react/windows/settings/useObsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import React from 'react';
import { useModule, injectState } from 'slap';
import { Services } from '../../service-provider';
import { ISettingsSubCategory } from '../../../services/settings';
import { TDisplayType } from 'services/settings-v2';

/**
* A module for components in the SettingsWindow
*/
class ObsSettingsModule {
state = injectState({
page: '',
display: 'horizontal',
});

init() {
Expand All @@ -29,6 +31,10 @@ class ObsSettingsModule {
this.settingsService.setSettings(this.state.page, newSettings);
}

setDisplay(display: TDisplayType) {
this.state.setDisplay(display);
}

get settingsFormData() {
return this.settingsService.state[this.state.page]?.formData ?? [];
}
Expand Down
6 changes: 4 additions & 2 deletions app/components/obs/inputs/ObsInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export declare type TObsType =
| 'OBS_PROPERTY_EDITABLE_LIST'
| 'OBS_PROPERTY_BUTTON'
| 'OBS_PROPERTY_BITMASK'
| 'OBS_INPUT_RESOLUTION_LIST';
| 'OBS_INPUT_RESOLUTION_LIST'
| 'OBS_PROPERTY_UNIT';

/**
* OBS values that frontend application can change
Expand All @@ -51,6 +52,7 @@ export interface IObsInput<TValueType> {
visible?: boolean;
masked?: boolean;
type: TObsType;
subType?: TObsType | string;
}

export declare type TObsFormData = (IObsInput<TObsValue> | IObsListInput<TObsValue>)[];
Expand Down Expand Up @@ -449,7 +451,7 @@ export function setPropertiesFormData(

if (property.type === 'OBS_PROPERTY_FONT') {
settings['custom_font'] = (property.value as IObsFont).path;
delete settings[property.name]['path'];
delete (settings[property.name] as IObsFont).path;
}
});

Expand Down
Loading
Loading