diff --git a/app/app-services.ts b/app/app-services.ts index 305ee1ffe135..3690dc67ed3c 100644 --- a/app/app-services.ts +++ b/app/app-services.ts @@ -123,6 +123,7 @@ import { EditorCommandsService } from './services/editor-commands'; import { EditorService } from 'services/editor'; import { PerformanceService } from './services/performance'; import { SourcesService } from './services/sources'; +import { SelectionService } from './services/selection'; import { StreamingService } from './services/streaming'; import { StreamSettingsService } from './services/settings/streaming'; import { RestreamService } from './services/restream'; @@ -190,6 +191,7 @@ export const AppServices = { EditorService, PerformanceService, SourcesService, + SelectionService, PatchNotesService, VideoService, ChatService, diff --git a/app/components-react/editor/elements/SceneSelector.m.less b/app/components-react/editor/elements/SceneSelector.m.less index 10dfbb1cb178..b70e912d9a8b 100644 --- a/app/components-react/editor/elements/SceneSelector.m.less +++ b/app/components-react/editor/elements/SceneSelector.m.less @@ -15,6 +15,18 @@ top: 0 !important; height: 100%; } + + :global(.active) { + color: var(--icon-active); + } + + :global(.disabled) { + opacity: 0.7; + + &:hover { + cursor: not-allowed; + } + } } :global(.no-top-padding) { @@ -38,7 +50,7 @@ } } -.active-scene { +.active-scene, .source-title { display: block; overflow: hidden; white-space: nowrap; @@ -48,6 +60,7 @@ .scenes-container { background: var(--section); border-radius: 4px; + height: 100%; &:global(.os-host) { overflow: hidden !important; @@ -69,6 +82,7 @@ :global(.ant-tree-node-content-wrapper) { padding-left: 16px !important; + display: flex; } :global(.ant-tree-node-selected) { @@ -112,3 +126,54 @@ } } } + +.source-icons { + display: flex; + align-items: center; + margin-right: 16px; + + i { + margin-left: 8px; + } +} + +.source-title-container { + display: flex; + align-items: center; + + > i { + margin-right: 8px; + } +} + +.source-title { + margin-right: auto; +} + +.sources-container { + :global(.ant-tree-switcher) { + width: 0; + display: block; + z-index: 1; + left: 10px; + } + + :global(.ant-tree-node-content-wrapper) { + padding-left: 32px !important; + display: flex; + } + + :global(.ant-tree-title) { + display: block; + width: 100%; + } + + :global(.fa-folder) { + &:hover { + &::before { + content: "\f07c"; + } + color: var(--title); + } + } +} diff --git a/app/components-react/editor/elements/SourceSelector.tsx b/app/components-react/editor/elements/SourceSelector.tsx new file mode 100644 index 000000000000..e87ed1dcaaea --- /dev/null +++ b/app/components-react/editor/elements/SourceSelector.tsx @@ -0,0 +1,600 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Tooltip, Tree } from 'antd'; +import { DataNode } from 'rc-tree/lib/interface'; +import { TreeProps } from 'rc-tree/lib/Tree'; +import cx from 'classnames'; +import { inject, injectState, injectWatch, mutation, useModule } from 'slap'; +import { SourcesService, SourceDisplayData } from 'services/sources'; +import { ScenesService, ISceneItem, TSceneNode, isItem } from 'services/scenes'; +import { SelectionService } from 'services/selection'; +import { EditMenu } from 'util/menus/EditMenu'; +import { WidgetDisplayData } from 'services/widgets'; +import { $t } from 'services/i18n'; +import { EditorCommandsService } from 'services/editor-commands'; +import { EPlaceType } from 'services/editor-commands/commands/reorder-nodes'; +import { AudioService } from 'services/audio'; +import { StreamingService } from 'services/streaming'; +import { EDismissable } from 'services/dismissables'; +import { assertIsDefined, getDefined } from 'util/properties-type-guards'; +import useBaseElement from './hooks'; +import styles from './SceneSelector.m.less'; +import Scrollable from 'components-react/shared/Scrollable'; +import HelpTip from 'components-react/shared/HelpTip'; +import Translate from 'components-react/shared/Translate'; + +interface ISourceMetadata { + id: string; + title: string; + icon: string; + isVisible: boolean; + isLocked: boolean; + isStreamVisible: boolean; + isRecordingVisible: boolean; + isFolder: boolean; + parentId?: string; +} + +class SourceSelectorModule { + private scenesService = inject(ScenesService); + private sourcesService = inject(SourcesService); + private selectionService = inject(SelectionService); + private editorCommandsService = inject(EditorCommandsService); + private streamingService = inject(StreamingService); + private audioService = inject(AudioService); + + sourcesTooltip = $t('The building blocks of your scene. Also contains widgets.'); + addSourceTooltip = $t('Add a new Source to your Scene. Includes widgets.'); + removeSourcesTooltip = $t('Remove Sources from your Scene.'); + openSourcePropertiesTooltip = $t('Open the Source Properties.'); + addGroupTooltip = $t('Add a Group so you can move multiple Sources at the same time.'); + + state = injectState({ + expandedFoldersIds: [] as string[], + }); + + nodeRefs = {}; + + callCameFromInsideTheHouse = false; + + getTreeData(nodeData: ISourceMetadata[]) { + // recursive function for transforming SceneNode[] to a Tree format of Antd.Tree + const getTreeNodes = (sceneNodes: ISourceMetadata[]): DataNode[] => { + return sceneNodes.map(sceneNode => { + if (!this.nodeRefs[sceneNode.id]) this.nodeRefs[sceneNode.id] = React.createRef(); + + let children; + if (sceneNode.isFolder) { + children = getTreeNodes(nodeData.filter(n => n.parentId === sceneNode.id)); + } + return { + title: ( + this.toggleVisibility(sceneNode.id)} + toggleLock={() => this.toggleLock(sceneNode.id)} + selectiveRecordingEnabled={this.selectiveRecordingEnabled} + isStreamVisible={sceneNode.isStreamVisible} + isRecordingVisible={sceneNode.isRecordingVisible} + cycleSelectiveRecording={() => this.cycleSelectiveRecording(sceneNode.id)} + ref={this.nodeRefs[sceneNode.id]} + onDoubleClick={() => this.sourceProperties(sceneNode.id)} + /> + ), + isLeaf: !children, + key: sceneNode.id, + switcherIcon: , + children, + }; + }); + }; + + return getTreeNodes(nodeData.filter(n => !n.parentId)); + } + + get nodeData(): ISourceMetadata[] { + return this.scene.getNodes().map(node => { + const itemsForNode = this.getItemsForNode(node.id); + const isVisible = itemsForNode.some(i => i.visible); + const isLocked = itemsForNode.every(i => i.locked); + const isRecordingVisible = itemsForNode.every(i => i.recordingVisible); + const isStreamVisible = itemsForNode.every(i => i.streamVisible); + + const isFolder = !isItem(node); + return { + id: node.id, + title: this.getNameForNode(node), + icon: this.determineIcon(!isFolder, isFolder ? node.id : node.sourceId), + isVisible, + isLocked, + isRecordingVisible, + isStreamVisible, + parentId: node.parentId, + isFolder, + }; + }); + } + + // TODO: Clean this up. These only access state, no helpers + getNameForNode(node: TSceneNode) { + if (isItem(node)) { + return this.sourcesService.state.sources[node.sourceId].name; + } + + return node.name; + } + + isSelected(node: TSceneNode) { + return this.selectionService.state.selectedIds.includes(node.id); + } + + determineIcon(isLeaf: boolean, sourceId: string) { + if (!isLeaf) { + return this.state.expandedFoldersIds.includes(sourceId) + ? 'fas fa-folder-open' + : 'fa fa-folder'; + } + + const source = this.sourcesService.state.sources[sourceId]; + + if (source.propertiesManagerType === 'streamlabels') { + return 'fas fa-file-alt'; + } + + if (source.propertiesManagerType === 'widget') { + const widgetType = this.sourcesService.views + .getSource(sourceId)! + .getPropertiesManagerSettings().widgetType; + + assertIsDefined(widgetType); + + return WidgetDisplayData()[widgetType]?.icon || 'icon-error'; + } + + return SourceDisplayData()[source.type]?.icon || 'fas fa-file'; + } + + addSource() { + if (this.scenesService.views.activeScene) { + this.sourcesService.showShowcase(); + } + } + + addFolder() { + if (this.scenesService.views.activeScene) { + let itemsToGroup: string[] = []; + let parentId: string = ''; + if (this.selectionService.views.globalSelection.canGroupIntoFolder()) { + itemsToGroup = this.selectionService.views.globalSelection.getIds(); + const parent = this.selectionService.views.globalSelection.getClosestParent(); + if (parent) parentId = parent.id; + } + this.scenesService.showNameFolder({ + itemsToGroup, + parentId, + sceneId: this.scenesService.views.activeScene.id, + }); + } + } + + showContextMenu(sceneNodeId?: string, event?: React.MouseEvent) { + const sceneNode = this.scene.getNode(sceneNodeId || ''); + let sourceId: string = ''; + + if (sceneNode) { + sourceId = sceneNode.isFolder() ? sceneNode.getItems()[0]?.sourceId : sceneNode.sourceId; + } + + if (sceneNode && !sceneNode.isSelected()) sceneNode.select(); + const menuOptions = sceneNode + ? { selectedSceneId: this.scene.id, showSceneItemMenu: true, selectedSourceId: sourceId } + : { selectedSceneId: this.scene.id }; + + const menu = new EditMenu(menuOptions); + menu.popup(); + event && event.stopPropagation(); + } + + removeItems() { + this.selectionService.views.globalSelection.remove(); + } + + sourceProperties(nodeId: string) { + const node = + this.scenesService.views.getSceneNode(nodeId) || + this.selectionService.views.globalSelection.getNodes()[0]; + + if (!node) return; + + const item = node.isItem() ? node : node.getNestedItems()[0]; + + if (!item) return; + + if (item.type === 'scene') { + this.scenesService.actions.makeSceneActive(item.sourceId); + return; + } + + if (!item.video) { + this.audioService.actions.showAdvancedSettings(item.sourceId); + return; + } + + this.sourcesService.actions.showSourceProperties(item.sourceId); + } + + canShowProperties(): boolean { + if (this.activeItemIds.length === 0) return false; + const sceneNode = this.scene.state.nodes.find( + n => n.id === this.selectionService.state.lastSelectedId, + ); + return !!(sceneNode && sceneNode.sceneNodeType === 'item' + ? this.sourcesService.views.getSource(sceneNode.sourceId)?.hasProps() + : false); + } + + determinePlacement(info: Parameters['onDrop']>[0]) { + if (!info.dropToGap && !info.node.isLeaf) return EPlaceType.Inside; + const dropPos = info.node.pos.split('-'); + const delta = info.dropPosition - Number(dropPos[dropPos.length - 1]); + + return delta > 0 ? EPlaceType.After : EPlaceType.Before; + } + + async handleSort(info: Parameters['onDrop']>[0]) { + const targetNodes = + this.activeItemIds.length > 0 && this.activeItemIds.includes(info.dragNode.key as string) + ? this.activeItemIds + : (info.dragNodesKeys as string[]); + const nodesToDrop = this.scene.getSelection(targetNodes); + const destNode = this.scene.getNode(info.node.key as string); + const placement = this.determinePlacement(info); + + if (!nodesToDrop || !destNode) return; + await this.editorCommandsService.actions.return.executeCommand( + 'ReorderNodesCommand', + nodesToDrop, + destNode?.id, + placement, + ); + } + + makeActive(info: { + selected: boolean; + node: DataNode; + selectedNodes: DataNode[]; + nativeEvent: MouseEvent; + }) { + this.callCameFromInsideTheHouse = true; + let ids: string[] = [info.node.key as string]; + + if (info.nativeEvent.ctrlKey) { + ids = this.activeItemIds.concat(ids); + } else if (info.nativeEvent.shiftKey) { + // Logic for multi-select + const idx1 = this.nodeData.findIndex( + i => i.id === this.activeItemIds[this.activeItemIds.length - 1], + ); + const idx2 = this.nodeData.findIndex(i => i.id === info.node.key); + const swapIdx = idx1 > idx2; + ids = this.nodeData.map(i => i.id).slice(swapIdx ? idx2 : idx1, swapIdx ? idx1 : idx2 + 1); + } + + this.selectionService.views.globalSelection.select(ids); + } + + @mutation() + toggleFolder(nodeId: string) { + if (this.state.expandedFoldersIds.includes(nodeId)) { + this.state.expandedFoldersIds.splice(this.state.expandedFoldersIds.indexOf(nodeId), 1); + } else { + this.state.expandedFoldersIds.push(nodeId); + } + } + + canShowActions(sceneNodeId: string) { + return this.getItemsForNode(sceneNodeId).length > 0; + } + + get lastSelectedId() { + return this.selectionService.state.lastSelectedId; + } + + watchSelected = injectWatch(() => this.lastSelectedId, this.expandSelectedFolders); + + async expandSelectedFolders() { + if (this.callCameFromInsideTheHouse) { + this.callCameFromInsideTheHouse = false; + return; + } + const node = this.scene.getNode(this.lastSelectedId); + if (!node || this.selectionService.state.selectedIds.length > 1) return; + this.state.setExpandedFoldersIds( + this.state.expandedFoldersIds.concat(node.getPath().slice(0, -1)), + ); + + this.nodeRefs[this.lastSelectedId].current.scrollIntoView({ behavior: 'smooth' }); + } + + get activeItemIds() { + return this.selectionService.state.selectedIds; + } + + get activeItems() { + return this.selectionService.views.globalSelection.getItems(); + } + + toggleVisibility(sceneNodeId: string) { + const selection = this.scene.getSelection(sceneNodeId); + const visible = !selection.isVisible(); + this.editorCommandsService.actions.executeCommand('HideItemsCommand', selection, !visible); + } + + // Required for performance. Using Selection is too slow (Service Helpers) + getItemsForNode(sceneNodeId: string): ISceneItem[] { + const node = getDefined(this.scene.state.nodes.find(n => n.id === sceneNodeId)); + + if (node.sceneNodeType === 'item') { + return [node]; + } + + const children = this.scene.state.nodes.filter(n => n.parentId === sceneNodeId); + let childrenItems: ISceneItem[] = []; + + children.forEach(c => (childrenItems = childrenItems.concat(this.getItemsForNode(c.id)))); + + return childrenItems; + } + + get selectiveRecordingEnabled() { + return this.streamingService.state.selectiveRecording; + } + + get streamingServiceIdle() { + return this.streamingService.isIdle; + } + + get replayBufferActive() { + return this.streamingService.isReplayBufferActive; + } + + get selectiveRecordingLocked() { + return this.replayBufferActive || !this.streamingServiceIdle; + } + + toggleSelectiveRecording() { + if (this.selectiveRecordingLocked) return; + this.streamingService.actions.setSelectiveRecording( + !this.streamingService.state.selectiveRecording, + ); + } + + cycleSelectiveRecording(sceneNodeId: string) { + const selection = this.scene.getSelection(sceneNodeId); + if (selection.isLocked()) return; + if (selection.isStreamVisible() && selection.isRecordingVisible()) { + selection.setRecordingVisible(false); + } else if (selection.isStreamVisible()) { + selection.setStreamVisible(false); + selection.setRecordingVisible(true); + } else { + selection.setStreamVisible(true); + selection.setRecordingVisible(true); + } + } + + toggleLock(sceneNodeId: string) { + const selection = this.scene.getSelection(sceneNodeId); + const locked = !selection.isLocked(); + selection.setSettings({ locked }); + } + + get scene() { + const scene = getDefined(this.scenesService.views.activeScene); + return scene; + } +} + +function SourceSelector() { + const { nodeData } = useModule(SourceSelectorModule); + return ( + <> + + + {nodeData.some(node => node.isFolder) && ( + + icon', + )} + > + + + + )} + + ); +} + +function StudioControls() { + const { + sourcesTooltip, + addGroupTooltip, + addSourceTooltip, + removeSourcesTooltip, + openSourcePropertiesTooltip, + selectiveRecordingEnabled, + selectiveRecordingLocked, + activeItemIds, + addSource, + addFolder, + removeItems, + toggleSelectiveRecording, + canShowProperties, + sourceProperties, + } = useModule(SourceSelectorModule); + + return ( +
+
+ + {$t('Sources')} + +
+ + + + + + + + + + + + + + + + + + + sourceProperties(activeItemIds[0])} + /> + +
+ ); +} + +function ItemsTree() { + const { + nodeData, + getTreeData, + activeItemIds, + expandedFoldersIds, + selectiveRecordingEnabled, + showContextMenu, + makeActive, + toggleFolder, + handleSort, + } = useModule(SourceSelectorModule); + + // Force a rerender when the state of selective recording changes + const [selectiveRecordingToggled, setSelectiveRecordingToggled] = useState(false); + useEffect(() => setSelectiveRecordingToggled(!selectiveRecordingToggled), [ + selectiveRecordingEnabled, + ]); + + const treeData = getTreeData(nodeData); + + return ( + showContextMenu('', e)} + > + makeActive(info)} + onExpand={(selectedKeys, info) => toggleFolder(info.node.key as string)} + onRightClick={info => showContextMenu(info.node.key as string, info.event)} + onDrop={handleSort} + treeData={treeData} + draggable + multiple + blockNode + showIcon + /> + + ); +} + +const TreeNode = React.forwardRef( + ( + p: { + title: string; + id: string; + isLocked: boolean; + isVisible: boolean; + isStreamVisible: boolean; + isRecordingVisible: boolean; + selectiveRecordingEnabled: boolean; + toggleVisibility: (ev: unknown) => unknown; + toggleLock: (ev: unknown) => unknown; + cycleSelectiveRecording: (ev: unknown) => void; + onDoubleClick: () => void; + }, + ref: React.RefObject, + ) => { + function selectiveRecordingMetadata() { + if (p.isStreamVisible && p.isRecordingVisible) { + return { icon: 'icon-smart-record', tooltip: $t('Visible on both Stream and Recording') }; + } + return p.isStreamVisible + ? { icon: 'icon-broadcast', tooltip: $t('Only visible on Stream') } + : { icon: 'icon-studio', tooltip: $t('Only visible on Recording') }; + } + + return ( +
+ {p.title} + {p.selectiveRecordingEnabled && ( + + + + )} + + +
+ ); + }, +); + +export default function SourceSelectorElement() { + const containerRef = useRef(null); + const { renderElement } = useBaseElement( + , + { x: 200, y: 120 }, + containerRef.current, + ); + + return ( +
+ {renderElement()} +
+ ); +} diff --git a/app/components-react/index.ts b/app/components-react/index.ts index 992906362f52..4b4fd9db7d20 100644 --- a/app/components-react/index.ts +++ b/app/components-react/index.ts @@ -48,6 +48,7 @@ import LayoutEditor from './pages/layout-editor/LayoutEditor'; import Projector from './windows/Projector'; import SceneSelector from './editor/elements/SceneSelector'; import AddSource from './windows/AddSource'; +import SourceSelector from './editor/elements/SourceSelector'; import SideNav from './sidebar/SideNav'; import WelcomeToPrime from './windows/WelcomeToPrime'; import Notifications from './windows/Notifications'; @@ -112,6 +113,7 @@ export const components = { LayoutEditor: createRoot(LayoutEditor), SceneSelector: createRoot(SceneSelector), AddSource, + SourceSelector: createRoot(SourceSelector), SideNav, WelcomeToPrime, Notifications, diff --git a/app/components-react/widgets/DonationTicker.tsx b/app/components-react/widgets/DonationTicker.tsx index 2e290e304ac4..afc307859ce5 100644 --- a/app/components-react/widgets/DonationTicker.tsx +++ b/app/components-react/widgets/DonationTicker.tsx @@ -11,6 +11,7 @@ import { TextInput, SliderInput, } from '../shared/inputs'; +import { injectFormBinding } from 'slap'; interface IDonationTickerState extends IWidgetState { data: { diff --git a/app/components/editor/elements/index.ts b/app/components/editor/elements/index.ts index 92bd8335d25c..e0a3cf5fb959 100644 --- a/app/components/editor/elements/index.ts +++ b/app/components/editor/elements/index.ts @@ -1,5 +1,4 @@ export { default as Mixer } from './Mixer'; -export { default as SourceSelector } from './SourceSelectorElement'; export { MiniFeed, SceneSelector, @@ -8,4 +7,5 @@ export { RecordingPreview, StreamPreview, DisplayElement as Display, + SourceSelector, } from 'components/shared/ReactComponentList'; diff --git a/app/components/shared/ReactComponentList.tsx b/app/components/shared/ReactComponentList.tsx index 1e823f9c402e..10bdb2846bce 100644 --- a/app/components/shared/ReactComponentList.tsx +++ b/app/components/shared/ReactComponentList.tsx @@ -318,6 +318,15 @@ export class StreamPreview extends ReactComponent {} }) export class SceneSelector extends ReactComponent {} +@Component({ + props: { + name: { default: 'SourceSelector' }, + wrapperStyles: { default: () => ({ height: '100%' }) }, + mins: { default: () => ({ x: 200, y: 120 }) }, + }, +}) +export class SourceSelector extends ReactComponent {} + @Component({ props: { name: { default: 'SideNav' }, diff --git a/app/i18n/en-US/sources.json b/app/i18n/en-US/sources.json index a15437df8708..40e4fba2c4c3 100644 --- a/app/i18n/en-US/sources.json +++ b/app/i18n/en-US/sources.json @@ -207,5 +207,7 @@ "Dynamic, live alerts": "Dynamic, live alerts", "Display recent events": "Display recent events", "Viewer shoutouts": "Viewer shoutouts", - "Capture an application window": "Capture an application window" + "Capture an application window": "Capture an application window", + "Folder Expansion": "Folder Expansion", + "Wondering how to expand your folders? Just click on the icon": "Wondering how to expand your folders? Just click on the icon" } \ No newline at end of file diff --git a/app/services/api/external-api/audio/audio-source.ts b/app/services/api/external-api/audio/audio-source.ts index 231fd3f9a88e..ee2f1aa63aaa 100644 --- a/app/services/api/external-api/audio/audio-source.ts +++ b/app/services/api/external-api/audio/audio-source.ts @@ -36,7 +36,7 @@ export interface IAudioSourceModel { * API for audio source management. Provides operations related to a single * audio source like set deflection and mute / unmute audio source. */ -@ServiceHelper() +@ServiceHelper('AudioService') export class AudioSource implements ISerializable { @Inject() private audioService: InternalAudioService; @Inject() private sourcesService: InternalSourcesService; diff --git a/app/services/api/external-api/scenes/scene-item-folder.ts b/app/services/api/external-api/scenes/scene-item-folder.ts index 1dab6d241317..1033fb3571db 100644 --- a/app/services/api/external-api/scenes/scene-item-folder.ts +++ b/app/services/api/external-api/scenes/scene-item-folder.ts @@ -20,7 +20,7 @@ export interface ISceneItemFolderModel extends ISceneNodeModel { * other nodes based on this folder. For further scene item folder operations * see {@link SceneNode}, {@link Scene} and {@link SourcesService}. */ -@ServiceHelper() +@ServiceHelper('ScenesService') export class SceneItemFolder extends SceneNode implements ISceneItemFolderModel { @Fallback() private sceneFolder: InternalSceneItemFolder; @InjectFromExternalApi() private sourcesService: SourcesService; diff --git a/app/services/api/external-api/scenes/scene-item.ts b/app/services/api/external-api/scenes/scene-item.ts index c37de601d23e..abe7e2d3ef70 100644 --- a/app/services/api/external-api/scenes/scene-item.ts +++ b/app/services/api/external-api/scenes/scene-item.ts @@ -146,7 +146,7 @@ export interface ISceneItemActions { * {@link SceneNode} and {@link Scene}. For source related operations see * {@link SourcesService}. */ -@ServiceHelper() +@ServiceHelper('ScenesService') export class SceneItem extends SceneNode implements ISceneItemActions, ISceneItemModel { @Fallback() private sceneItem: InternalSceneItem; @InjectFromExternalApi() private sourcesService: SourcesService; diff --git a/app/services/api/external-api/scenes/scene.ts b/app/services/api/external-api/scenes/scene.ts index 0e7f14474041..369a391b8961 100644 --- a/app/services/api/external-api/scenes/scene.ts +++ b/app/services/api/external-api/scenes/scene.ts @@ -26,7 +26,7 @@ export interface ISceneModel { * creating, reordering and removing sources from this scene. For more general * scene operations see {@link ScenesService}. */ -@ServiceHelper() +@ServiceHelper('ScenesService') export class Scene implements ISceneModel, ISerializable { @InjectFromExternalApi() private scenesService: ScenesService; @InjectFromExternalApi() private sourcesService: SourcesService; diff --git a/app/services/api/external-api/scenes/selection.ts b/app/services/api/external-api/scenes/selection.ts index 0504c0efd942..d28257fef3f3 100644 --- a/app/services/api/external-api/scenes/selection.ts +++ b/app/services/api/external-api/scenes/selection.ts @@ -28,7 +28,7 @@ export interface ISelectionModel { * * Use {@link Scene.getSelection} to fetch a new {@link Selection} object. */ -@ServiceHelper() +@ServiceHelper('SelectionService') export class Selection implements ISceneItemActions, ISerializable { @InjectFromExternalApi() private sourcesService: SourcesService; @InjectFromExternalApi() private scenesService: ScenesService; diff --git a/app/services/api/external-api/sources/source.ts b/app/services/api/external-api/sources/source.ts index fe13ec4ef81a..9bb55e854e4e 100644 --- a/app/services/api/external-api/sources/source.ts +++ b/app/services/api/external-api/sources/source.ts @@ -35,7 +35,7 @@ export interface ISourceModel { * renaming the source or updating settings and properties form data. For more * scene related operations see {@link SceneNode} and {@link Scene}. */ -@ServiceHelper() +@ServiceHelper('SourcesService') export class Source implements ISourceModel, ISerializable { @Inject('SourcesService') private internalSourcesService: InternalSourcesService; @Fallback() private source: InternalSource; diff --git a/app/services/audio/audio.ts b/app/services/audio/audio.ts index 95208ef3244b..127e50da8ffa 100644 --- a/app/services/audio/audio.ts +++ b/app/services/audio/audio.ts @@ -331,7 +331,7 @@ export class AudioService extends StatefulService { } } -@ServiceHelper() +@ServiceHelper('AudioService') export class AudioSource implements IAudioSourceApi { name: string; sourceId: string; diff --git a/app/services/core/service-helper.ts b/app/services/core/service-helper.ts index 272a6a02d553..7e40301f917d 100644 --- a/app/services/core/service-helper.ts +++ b/app/services/core/service-helper.ts @@ -14,8 +14,9 @@ import { inheritMutations } from './stateful-service'; import Utils from 'services/utils'; -export function ServiceHelper() { +export function ServiceHelper(parentServiceName: string) { return function (constr: T) { + constr['_isHelperFor'] = parentServiceName; const klass = class extends constr { constructor(...args: any[]) { super(...args); diff --git a/app/services/core/stateful-service.ts b/app/services/core/stateful-service.ts index a2b3a7d8e626..baad9a227c67 100644 --- a/app/services/core/stateful-service.ts +++ b/app/services/core/stateful-service.ts @@ -20,8 +20,10 @@ function registerMutation( descriptor: PropertyDescriptor, options: IMutationOptions = {}, ) { - const serviceName = target.constructor.name; - const mutationName = `${serviceName}.${methodName}`; + const serviceName = target.constructor._isHelperFor ?? target.constructor.name; + const mutationName = target.constructor._isHelperFor + ? `${serviceName}.${target.constructor.name}.${methodName}` + : `${serviceName}.${methodName}`; const opts: IMutationOptions = { unsafe: false, sync: true, ...options }; target.originalMethods = target.originalMethods || {}; @@ -152,7 +154,7 @@ export function getModule(ModuleContainer: any): Module { // filter inherited mutations for (const mutationName in prototypeMutations) { const serviceName = mutationName.split('.')[0]; - if (serviceName !== ModuleContainer.name) continue; + if (serviceName !== (ModuleContainer._isHelperFor ?? ModuleContainer.name)) continue; mutations[mutationName] = prototypeMutations[mutationName]; } diff --git a/app/services/dismissables.ts b/app/services/dismissables.ts index 74d25844a975..0b10c2b34632 100644 --- a/app/services/dismissables.ts +++ b/app/services/dismissables.ts @@ -10,6 +10,7 @@ export enum EDismissable { FacebookNeedPermissionsTip = 'facebook_need_permissions_tip', HighlighterNotification = 'highlighter_notification', GuestCamFirstTimeModal = 'guest_cam_first_time', + SourceSelectorFolders = 'source_selector_folders', } interface IDismissablesServiceState { diff --git a/app/services/hotkeys.ts b/app/services/hotkeys.ts index 2a3ec286fdc4..cce897810cfc 100644 --- a/app/services/hotkeys.ts +++ b/app/services/hotkeys.ts @@ -688,7 +688,7 @@ export class HotkeysService extends StatefulService { /** * Represents a single bindable hotkey */ -@ServiceHelper() +@ServiceHelper('HotkeysService') export class Hotkey implements IHotkey { actionName: string; sceneId?: string; diff --git a/app/services/scenes/scene-folder.ts b/app/services/scenes/scene-folder.ts index 525f1c621445..22bde4004eab 100644 --- a/app/services/scenes/scene-folder.ts +++ b/app/services/scenes/scene-folder.ts @@ -11,7 +11,7 @@ import { ServiceHelper } from 'services/core'; import compact from 'lodash/compact'; import { assertIsDefined } from '../../util/properties-type-guards'; -@ServiceHelper() +@ServiceHelper('ScenesService') export class SceneItemFolder extends SceneItemNode { name: string; sceneNodeType: TSceneNodeType = 'folder'; diff --git a/app/services/scenes/scene-item.ts b/app/services/scenes/scene-item.ts index f819183a4e62..194787d6c540 100644 --- a/app/services/scenes/scene-item.ts +++ b/app/services/scenes/scene-item.ts @@ -33,7 +33,7 @@ import { assertIsDefined } from '../../util/properties-type-guards'; * all of the information about that source, and * how it fits in to the given scene */ -@ServiceHelper() +@ServiceHelper('ScenesService') export class SceneItem extends SceneItemNode { sourceId: string; name: string; diff --git a/app/services/scenes/scene.ts b/app/services/scenes/scene.ts index 0d44ac79f85e..3adfd2da8946 100644 --- a/app/services/scenes/scene.ts +++ b/app/services/scenes/scene.ts @@ -30,7 +30,7 @@ export interface ISceneHierarchy extends ISceneItemNode { children: ISceneHierarchy[]; } -@ServiceHelper() +@ServiceHelper('ScenesService') export class Scene { id: string; name: string; diff --git a/app/services/selection/global-selection.ts b/app/services/selection/global-selection.ts index d65554fe4ab3..de68fa697a11 100644 --- a/app/services/selection/global-selection.ts +++ b/app/services/selection/global-selection.ts @@ -14,7 +14,7 @@ import * as remote from '@electron/remote'; * store in the SelectionService, and selecting items * actually selects them in OBS. */ -@ServiceHelper() +@ServiceHelper('SelectionService') export class GlobalSelection extends Selection { @Inject() selectionService: SelectionService; @Inject() editorCommandsService: EditorCommandsService; diff --git a/app/services/selection/selection.ts b/app/services/selection/selection.ts index 4c6041aba68e..09f6a90224fe 100644 --- a/app/services/selection/selection.ts +++ b/app/services/selection/selection.ts @@ -20,7 +20,7 @@ import { ISelectionState, TNodesList } from './index'; /** * Helper for working with multiple sceneItems */ -@ServiceHelper() +@ServiceHelper('SelectionService') export class Selection { @Inject() scenesService: ScenesService; diff --git a/app/services/sources/source.ts b/app/services/sources/source.ts index ef1ea17dd693..996304997af5 100644 --- a/app/services/sources/source.ts +++ b/app/services/sources/source.ts @@ -18,7 +18,7 @@ import omit from 'lodash/omit'; import { assertIsDefined } from '../../util/properties-type-guards'; import { SourceFiltersService } from '../source-filters'; -@ServiceHelper() +@ServiceHelper('SourcesService') export class Source implements ISourceApi { sourceId: string; name: string; diff --git a/app/services/widgets/widget-source.ts b/app/services/widgets/widget-source.ts index a5b375d69818..b6c0f258be8b 100644 --- a/app/services/widgets/widget-source.ts +++ b/app/services/widgets/widget-source.ts @@ -5,7 +5,7 @@ import { IWidgetSource, WidgetType, IWidgetData } from './index'; import { WidgetSettingsService } from 'services/widgets'; import Utils from '../utils'; -@ServiceHelper() +@ServiceHelper('WidgetsService') export class WidgetSource implements IWidgetSource { @Inject() private sourcesService: SourcesService; @Inject() private widgetsService: WidgetsService; diff --git a/test/helpers/modules/sources.ts b/test/helpers/modules/sources.ts index ccaf53e70b14..a43f0cc606e0 100644 --- a/test/helpers/modules/sources.ts +++ b/test/helpers/modules/sources.ts @@ -14,7 +14,7 @@ import { dialogDismiss } from '../spectron/dialog'; import { contextMenuClick } from '../spectron/context-menu'; async function clickSourceAction(selector: string) { - const $el = await (await (await select('h2=Sources')).$('..')).$(selector); + const $el = await (await select('[data-name=sourcesControls]')).$(selector); await $el.click(); } @@ -32,15 +32,15 @@ export async function clickSourceProperties() { } export async function selectSource(name: string) { - await click(`.item-title=${name}`); + await click(`[data-name="${name}"]`); } export async function selectTestSource() { - await click('.item-title*=__'); + await click('[data-name*=__]'); } export async function rightClickSource(name: string) { - await (await select(`.item-title=${name}`)).click({ button: 'right' }); + await (await select(`[data-name="${name}"]`)).click({ button: 'right' }); } export async function openSourceProperties(name: string) { @@ -101,16 +101,16 @@ export async function openRenameWindow(sourceName: string) { } export async function sourceIsExisting(sourceName: string) { - return await isDisplayed(`.item-title=${sourceName}`); + return await isDisplayed(`[data-name="${sourceName}"]`); } export async function waitForSourceExist(sourceName: string, invert = false) { - return (await select(`.item-title=${sourceName}`)).waitForExist({ + return (await select(`[data-name="${sourceName}"]`)).waitForExist({ timeout: 5000, reverse: invert, }); } export async function testSourceExists() { - return (await select('.item-title*=__')).isExisting(); + return (await select('[data-name*=__]')).isExisting(); } diff --git a/test/regular/onboarding.ts b/test/regular/onboarding.ts index df86b516726f..29695e29587a 100644 --- a/test/regular/onboarding.ts +++ b/test/regular/onboarding.ts @@ -53,7 +53,7 @@ test('Go through the onboarding and autoconfig', async t => { // await sleep(1000); // } - await (await app.client.$('h2=Sources')).waitForDisplayed({ timeout: 60000 }); + await (await app.client.$('span=Sources')).waitForDisplayed({ timeout: 60000 }); // success? - t.true(await (await app.client.$('h2=Sources')).isDisplayed(), 'Sources selector is visible'); + t.true(await (await app.client.$('span=Sources')).isDisplayed(), 'Sources selector is visible'); }); diff --git a/test/regular/selective-recording.ts b/test/regular/selective-recording.ts index 2b34d0631d6f..139b28bef09f 100644 --- a/test/regular/selective-recording.ts +++ b/test/regular/selective-recording.ts @@ -23,31 +23,25 @@ test('Selective Recording', async t => { // Toggle selective recording await focusMain(); - await (await client.$('.studio-controls-top .icon-smart-record')).click(); + await (await client.$('[data-name=sourcesControls] .icon-smart-record')).click(); // Check that selective recording icon is active - await (await client.$('.icon-smart-record.icon--active')).waitForExist(); + await (await client.$('.icon-smart-record.active')).waitForExist(); // Check that browser source has a selective recording toggle - t.true( - await ( - await client.$('.sl-vue-tree-sidebar .source-selector-action.icon-smart-record') - ).isExisting(), - ); + t.true(await (await client.$('[data-role=source] .icon-smart-record')).isExisting()); // Cycle selective recording mode on browser source - await (await client.$('.sl-vue-tree-sidebar .source-selector-action.icon-smart-record')).click(); + await (await client.$('[data-role=source] .icon-smart-record')).click(); // Check that source is set to stream only - await ( - await client.$('.sl-vue-tree-sidebar .source-selector-action.icon-broadcast') - ).waitForExist(); + await (await client.$('[data-role=source] .icon-broadcast')).waitForExist(); // Cycle selective recording mode to record only - await (await client.$('.sl-vue-tree-sidebar .source-selector-action.icon-broadcast')).click(); + await (await client.$('[data-role=source] .icon-broadcast')).click(); // Check that source is set to record only - await (await client.$('.sl-vue-tree-sidebar .source-selector-action.icon-studio')).waitForExist(); + await (await client.$('[data-role=source] .icon-studio')).waitForExist(); // Start recording and wait await (await client.$('.record-button')).click(); diff --git a/test/regular/themes.ts b/test/regular/themes.ts index 8444be1b134e..85e3296f50b3 100644 --- a/test/regular/themes.ts +++ b/test/regular/themes.ts @@ -55,6 +55,6 @@ test.skip('Installing a theme', async (t: TExecutionContext) => { // Should've populated sources (this checks Starting Soon scene sources) for (const source of ['Starting']) { - t.true(await isDisplayed(`span.item-title=${source}`), `Source ${source} should exist`); + t.true(await isDisplayed(`[data-name="${source}"]`), `Source ${source} should exist`); } }); diff --git a/test/screentest/tests/onboarding.ts b/test/screentest/tests/onboarding.ts index a517fb16e421..302461c8ac29 100644 --- a/test/screentest/tests/onboarding.ts +++ b/test/screentest/tests/onboarding.ts @@ -44,7 +44,7 @@ test('Onboarding steps', async t => { await (await app.client.$('div=Choose Free')).click(); // success? - await (await app.client.$('h2=Sources')).waitForDisplayed({ timeout: 60000 }); + await (await app.client.$('span=Sources')).waitForDisplayed({ timeout: 60000 }); await makeScreenshots(t, 'Onboarding completed'); t.pass(); }); @@ -76,7 +76,7 @@ test('OBS Importer', async t => { await (await client.$('h2=Start')).click(); // success? - await (await client.$('h2=Sources')).waitForDisplayed({ timeout: 60000 }); + await (await client.$('span=Sources')).waitForDisplayed({ timeout: 60000 }); await makeScreenshots(t, 'Import from OBS is completed'); t.pass(); }); diff --git a/test/stress/index.ts b/test/stress/index.ts index 1a4ca1843285..c4e0a069b366 100644 --- a/test/stress/index.ts +++ b/test/stress/index.ts @@ -37,7 +37,7 @@ async function getSceneElements(t: TExecutionContext) { } async function getSourceElements(t: TExecutionContext) { - return (await (await t.context.app.client.$('h2=Sources')).$('../..')).$$( + return (await (await t.context.app.client.$('span=Sources')).$('../..')).$$( '.sl-vue-tree-node-item', ); }