Skip to content

Commit

Permalink
Add dual output toggle to the source selector bar. (#5070)
Browse files Browse the repository at this point in the history
* Add dual output toggle to the source selector bar.

* Toggle off without open settings, fix selective recording dual output logic, fix toggle colors.

* Replace toggle title.

* Fix dual output toggle icon svg.

* Fix toggle dual output from video settings.

* Fixes for tests and code review notes.

* Remove subscription for selective recording message.
  • Loading branch information
michelinewu authored Jul 18, 2024
1 parent 719d7ca commit 5d658f6
Show file tree
Hide file tree
Showing 18 changed files with 316 additions and 129 deletions.
16 changes: 15 additions & 1 deletion app/components-react/editor/elements/SceneSelector.m.less
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
align-items: center;
min-width: 0;
// Fixes long scene collection names still pushing width even with overflow: hidden and text-overflow: ellipsis
max-width: 30vw;
max-width: 30vw;

&:hover {
cursor: pointer;
Expand Down Expand Up @@ -242,3 +242,17 @@
margin-left: 10px !important;
}
}

.icon-dual-output {
fill: var(--icon);
text-decoration: none;
align-items: center;

&:hover {
fill: var(--title);
}

&.selected {
fill: var(--icon-active);
}
}
137 changes: 131 additions & 6 deletions app/components-react/editor/elements/SourceSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState, useMemo } from 'react';
import React, { useEffect, useRef, useState, useMemo, Ref, RefObject } from 'react';
import pick from 'lodash/pick';
import { Tooltip, Tree } from 'antd';
import { message, Tooltip, Tree } from 'antd';
import { DataNode } from 'rc-tree/lib/interface';
import { TreeProps } from 'rc-tree/lib/Tree';
import cx from 'classnames';
Expand All @@ -19,6 +19,9 @@ import { DualOutputSourceSelector } from './DualOutputSourceSelector';
import { Services } from 'components-react/service-provider';
import { initStore, useController } from 'components-react/hooks/zustand';
import { useVuex } from 'components-react/hooks';
import * as remote from '@electron/remote';
import { AuthModal } from 'components-react/shared/AuthModal';
import Utils from 'services/utils';

interface ISourceMetadata {
id: string;
Expand Down Expand Up @@ -49,12 +52,14 @@ class SourceSelectorController {
private audioService = Services.AudioService;
private guestCamService = Services.GuestCamService;
private dualOutputService = Services.DualOutputService;
private userService = Services.UserService;

store = initStore({
expandedFoldersIds: [] as string[],
showModal: false,
});

nodeRefs = {};
nodeRefs: Dictionary<Ref<HTMLDivElement>> = {};

/**
* This property handles selection when expanding/collapsing folders
Expand Down Expand Up @@ -448,7 +453,9 @@ class SourceSelectorController {
s.expandedFoldersIds = s.expandedFoldersIds.concat(node.getPath().slice(0, -1));
});

this.nodeRefs[this.lastSelectedId]?.current?.scrollIntoView({ behavior: 'smooth' });
(this.nodeRefs[this.lastSelectedId] as RefObject<HTMLDivElement>)?.current?.scrollIntoView({
behavior: 'smooth',
});
}

/**
Expand Down Expand Up @@ -590,9 +597,22 @@ class SourceSelectorController {
this.streamingService.actions.setSelectiveRecording(
!this.streamingService.state.selectiveRecording,
);
if (!this.selectiveRecordingEnabled && this.isDualOutputActive) {
this.dualOutputService.actions.toggleDisplay(false, 'vertical');
if (this.isDualOutputActive) {
// selective recording only works with the horizontal display
// so toggle the vertical display to hide it
this.dualOutputService.actions.toggleDisplay(this.selectiveRecordingEnabled, 'vertical');
this.selectionService.views.globalSelection.filterDualOutputNodes();

// if the vertical display is hidden because of selective recording
// show an alert to the user notifying them that the vertical display is disabled
if (!this.selectiveRecordingEnabled) {
remote.dialog.showMessageBox({
title: 'Vertical Display Disabled',
message: $t(
'Dual Output can’t be displayed - Selective Recording only works with horizontal sources and disables editing the vertical output scene. Please disable selective recording from Sources to set up Dual Output.',
),
});
}
}
}

Expand Down Expand Up @@ -632,6 +652,70 @@ class SourceSelectorController {
return this.scene.getSelection(sceneNodeId);
}

toggleDualOutput() {
if (this.userService.isLoggedIn) {
if (Services.StreamingService.views.isMidStreamMode) {
message.error({
content: $t('Cannot toggle Dual Output while live.'),
className: styles.toggleError,
});
} else if (Services.TransitionsService.views.studioMode) {
message.error({
content: $t('Cannot toggle Dual Output while in Studio Mode.'),
className: styles.toggleError,
});
} else {
// only open video settings when toggling on dual output
const skipShowVideoSettings = this.dualOutputService.views.dualOutputMode === true;

this.dualOutputService.actions.setDualOutputMode(
!this.dualOutputService.views.dualOutputMode,
skipShowVideoSettings,
);
Services.UsageStatisticsService.recordFeatureUsage('DualOutput');
Services.UsageStatisticsService.recordAnalyticsEvent('DualOutput', {
type: 'ToggleOnDualOutput',
source: 'SourceSelector',
});

if (!this.dualOutputService.views.dualOutputMode && this.selectiveRecordingEnabled) {
// show warning message if selective recording is active
remote.dialog
.showMessageBox(Utils.getChildWindow(), {
title: 'Vertical Display Disabled',
message: $t(
'Dual Output can’t be displayed - Selective Recording only works with horizontal sources and disables editing the vertical output scene. Please disable selective recording from Sources to set up Dual Output.',
),
buttons: [$t('OK')],
})
.catch(() => {});
}
}
} else {
this.handleShowModal(true);
}
}

handleShowModal(status: boolean) {
Services.WindowsService.actions.updateStyleBlockers('main', status);
this.store.update('showModal', status);
}

handleAuth() {
this.userService.actions.showLogin();
const onboardingCompleted = Services.OnboardingService.onboardingCompleted.subscribe(() => {
Services.DualOutputService.actions.setDualOutputMode();
Services.SettingsService.actions.showSettings('Video');
onboardingCompleted.unsubscribe();
});
}

get dualOutputTitle() {
return !this.isDualOutputActive || !this.userService.isLoggedIn
? $t('Enable Dual Output to stream to horizontal & vertical platforms simultaneously')
: $t('Disable Dual Output');
}

get scene() {
const scene = getDefined(this.scenesService.views.activeScene);
return scene;
Expand All @@ -640,6 +724,7 @@ class SourceSelectorController {

function SourceSelector() {
const ctrl = useController(SourceSelectorCtx);
const showModal = ctrl.store.useState(s => s.showModal);
return (
<>
<StudioControls />
Expand All @@ -659,6 +744,12 @@ function SourceSelector() {
</Translate>
</HelpTip>
)}
<AuthModal
prompt={$t('Please log in to enable dual output. Would you like to log in now?')}
showModal={showModal}
handleShowModal={ctrl.handleShowModal}
handleAuth={ctrl.handleAuth}
/>
</>
);
}
Expand Down Expand Up @@ -687,6 +778,12 @@ function StudioControls() {
/>
</Tooltip>

<Tooltip title={ctrl.dualOutputTitle} placement="bottomRight">
<div className={cx('icon-button', 'icon-button--lg')} onClick={ctrl.toggleDualOutput}>
{<DualOutputIcon className={cx({ [styles.selected]: ctrl.isDualOutputActive })} />}
</div>
</Tooltip>

<Tooltip title={$t('Toggle Selective Recording')} placement="bottomRight">
<i
className={cx('icon-smart-record icon-button icon-button--lg', {
Expand Down Expand Up @@ -876,6 +973,34 @@ const TreeNode = React.forwardRef(

const mins = { x: 200, y: 120 };

function DualOutputIcon(p: { className: string }) {
return (
<svg
width="14"
height="14"
fill="none"
viewBox="0 0 14 14"
xmlns="http://www.w3.org/2000/svg"
className={cx(styles.iconDualOutput, p.className)}
>
<g clipPath="url(#clip0_26407_14549)">
<path d="M1.55556 0C0.697569 0 0 0.697569 0 1.55556V8.55556C0 9.41354 0.697569 10.1111 1.55556 10.1111H5.5V8.55556H1.55556V1.55556H12.4444V3H14V1.55556C14 0.697569 13.3024 0 12.4444 0H1.55556Z" />
<path d="M3.88889 10.8889H5.5V12.4444H3.88889C3.45868 12.4444 3.11111 12.0969 3.11111 11.6667C3.11111 11.2365 3.45868 10.8889 3.88889 10.8889Z" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.15385 4.41669C7.51743 4.41669 7 4.92118 7 5.54169V12.2917C7 12.9122 7.51743 13.4167 8.15385 13.4167H12.1923C12.8287 13.4167 13.3462 12.9122 13.3462 12.2917V5.54169C13.3462 4.92118 12.8287 4.41669 12.1923 4.41669H8.15385ZM10.581 11.8939C10.6892 11.9994 10.75 12.1425 10.75 12.2917C10.75 12.4409 10.6892 12.5839 10.581 12.6894C10.4728 12.7949 10.3261 12.8542 10.1731 12.8542C10.0201 12.8542 9.87333 12.7949 9.76513 12.6894C9.65694 12.5839 9.59615 12.4409 9.59615 12.2917C9.59615 12.1425 9.65694 11.9994 9.76513 11.8939C9.87333 11.7885 10.0201 11.7292 10.1731 11.7292C10.3261 11.7292 10.4728 11.7885 10.581 11.8939ZM8.15385 5.54169H12.1923V11.1667H8.15385V5.54169Z"
/>
</g>
<defs>
<clipPath id="clip0_26407_14549">
<rect width="14" height="14" fill="white" />
</clipPath>
</defs>
</svg>
);
}

export function SourceSelectorElement() {
const containerRef = useRef<HTMLDivElement>(null);
const { renderElement } = useBaseElement(<SourceSelector />, mins, containerRef.current);
Expand Down
11 changes: 10 additions & 1 deletion app/components-react/hooks/zustand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { StoreApi, useStore } from 'zustand';
import { createStore } from 'zustand/vanilla';
import { immer } from 'zustand/middleware/immer';
import React, { Context, useContext, useMemo } from 'react';
import { Draft } from 'immer';

/**
* Initializes a Zustand store with the provided initial state, utilizing immer middleware.
Expand All @@ -26,8 +27,16 @@ export function initStore<TState extends any>(initialStateDraft: TState) {
const useState = createBoundedUseStore(store);
(store as any).useState = useState;

const update = (key: keyof TState, value: any) =>
store.setState((s: Draft<TState>) => {
(s as any)[key] = value;
});
(store as any).update = update;

// ensure we have correct types
return store as typeof store & { useState: typeof useState } & Readonly<typeof initialStateDraft>;
return store as typeof store & { useState: typeof useState } & {
update: typeof update;
} & Readonly<typeof initialStateDraft>;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion app/components-react/root/ResizeBar.m.less
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@import "../../styles/index";
@import '../../styles/index';

.resize-bar {
position: absolute;
Expand Down Expand Up @@ -43,6 +43,9 @@
background-color: rgba(50, 50, 50, 0.5);
cursor: default;
}
&.unset {
z-index: 1 !important;
}
&:hover {
.resize-line {
background-color: var(--title);
Expand Down
11 changes: 10 additions & 1 deletion app/components-react/root/ResizeBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Resizable, ResizableProps } from 'react-resizable';
import cx from 'classnames';
import styles from './ResizeBar.m.less';
import { Services } from 'components-react/service-provider';
import { useVuex } from 'components-react/hooks';

interface ResizeBarProps {
// the side of the external container to stick ResizeBar to
Expand All @@ -29,6 +30,10 @@ interface ResizableData {
export default function ResizeBar(p: React.PropsWithChildren<ResizeBarProps>) {
const { WindowsService } = Services;

const v = useVuex(() => ({
hideStyleBlockers: WindowsService.state.main.hideStyleBlockers,
}));

let resizableProps: ResizableProps;

if (p.position === 'top') {
Expand Down Expand Up @@ -80,7 +85,11 @@ export default function ResizeBar(p: React.PropsWithChildren<ResizeBarProps>) {
transformScale={p.transformScale ?? 2}
{...resizableProps}
handle={
<div className={cx(styles.resizeBar, styles[p.position])}>
<div
className={cx(styles.resizeBar, styles[p.position], {
[styles['unset']]: v.hideStyleBlockers,
})}
>
<div className={styles.resizeLine} />
</div>
}
Expand Down
31 changes: 31 additions & 0 deletions app/components-react/shared/AuthModal.m.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.auth-modal-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1003 !important; // show above resize bars
display: flex;
justify-content: center;
align-items: center;
height: 80%;
width: 100%;
}

.auth-modal {
h2 {
align-self: flex-start;
}

.buttons {
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: 15px;
width: 100%;

button:not(:last-child) {
margin-right: 5px;
}
}
}
45 changes: 45 additions & 0 deletions app/components-react/shared/AuthModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React, { CSSProperties } from 'react';
import { Button, Form, Modal } from 'antd';
import styles from './AuthModal.m.less';
import { $t } from 'services/i18n';
import { Services } from 'components-react/service-provider';
import cx from 'classnames';

interface AuthModalProps {
showModal: boolean;
prompt: string;
handleAuth: () => void;
handleShowModal: (status: boolean) => void;
title?: string;
cancel?: string;
confirm?: string;
className?: string;
style?: CSSProperties;
id?: string;
}

export function AuthModal(p: AuthModalProps) {
const title = p?.title || Services.UserService.isLoggedIn ? $t('Log Out') : $t('Login');
const prompt = p?.prompt;
const confirm = p?.confirm || $t('Yes');
const cancel = p?.cancel || $t('No');

return (
<Modal
footer={null}
visible={p.showModal}
onCancel={() => p.handleShowModal(false)}
getContainer={false}
className={cx(styles.authModalWrapper, p?.className)}
>
<Form id={p?.id} className={styles.authModal}>
<h2>{title}</h2>
{prompt}
<div className={styles.buttons}>
<Button onClick={p.handleAuth}>{confirm}</Button>
<Button onClick={() => p.handleShowModal(false)}>{cancel}</Button>
</div>
</Form>
</Modal>
);
}
Loading

0 comments on commit 5d658f6

Please sign in to comment.