diff --git a/app/components-react/pages/RecordingHistory.tsx b/app/components-react/pages/RecordingHistory.tsx index 03cecb60fdb9..cc91a5cb5a05 100644 --- a/app/components-react/pages/RecordingHistory.tsx +++ b/app/components-react/pages/RecordingHistory.tsx @@ -192,7 +192,10 @@ export function RecordingHistory() {
{formattedTimestamp(recording.timestamp)} - showFile(recording.filename)} className={styles.filename}> + showFile(recording.filename)} + className={cx(styles.filename, 'file')} + > {recording.filename} diff --git a/app/components-react/root/StartStreamingButton.tsx b/app/components-react/root/StartStreamingButton.tsx index 33b0bfcbafe5..34f159719d4b 100644 --- a/app/components-react/root/StartStreamingButton.tsx +++ b/app/components-react/root/StartStreamingButton.tsx @@ -18,7 +18,8 @@ export default function StartStreamingButton(p: { disabled?: boolean }) { } = Services; const { streamingStatus, delayEnabled, delaySeconds } = useVuex(() => ({ - streamingStatus: StreamingService.state.streamingStatus, + streamingStatus: + StreamingService.state.streamingStatus || StreamingService.state.verticalStreamingStatus, delayEnabled: StreamingService.views.delayEnabled, delaySeconds: StreamingService.views.delaySeconds, })); diff --git a/app/components-react/root/StudioEditor.m.less b/app/components-react/root/StudioEditor.m.less index b93fde82744e..647e528a5f6a 100644 --- a/app/components-react/root/StudioEditor.m.less +++ b/app/components-react/root/StudioEditor.m.less @@ -245,6 +245,21 @@ text-decoration: none; } } + + .stream-icon { + margin-right: 0px; + color: var(--icon-active); + } + + .record-icon { + color: var(--red); + font-size: 18px; + margin-top: 3px; + } + + .hidden { + opacity: 0; + } } .progress-bar { diff --git a/app/components-react/root/StudioEditor.tsx b/app/components-react/root/StudioEditor.tsx index 361844b859fc..03b7c85f9295 100644 --- a/app/components-react/root/StudioEditor.tsx +++ b/app/components-react/root/StudioEditor.tsx @@ -347,30 +347,58 @@ function StudioModeControls(p: { stacked: boolean }) { } function DualOutputControls(p: { stacked: boolean }) { + const { SettingsService, DualOutputService, StreamingService } = Services; function openSettingsWindow() { - Services.SettingsService.actions.showSettings('Video'); + SettingsService.actions.showSettings('Video'); } - const showHorizontal = Services.DualOutputService.views.showHorizontalDisplay; - const showVertical = - Services.DualOutputService.views.showVerticalDisplay && - !Services.StreamingService.state.selectiveRecording; + const v = useVuex(() => ({ + showHorizontal: DualOutputService.views.showHorizontalDisplay, + showVertical: + DualOutputService.views.showVerticalDisplay && !StreamingService.state.selectiveRecording, + isHorizontalRecording: StreamingService.views.isHorizontalRecording, + isVerticalRecording: StreamingService.views.isVerticalRecording, + isHorizontalStreaming: StreamingService.views.isStreaming, + isVerticalStreaming: StreamingService.views.isVerticalStreaming, + })); + + const horizontalIconVisible = v.isHorizontalStreaming || v.isHorizontalRecording; + const verticalIconVisible = v.isVerticalStreaming || v.isVerticalRecording; + + /** + * Note for the streaming and recording icons: + * To maintain the horizontal and vertical header icon and text positioning centered, + * conditionally change the opacity of the streaming and recording icons. + * For the horizontal recording, to maintain the same margin of the streaming and recording icons + * swap the icons shown conditionally so that when only recording, the recording icon shows next to + * the header text. + */ return (
- {showHorizontal && ( + {v.showHorizontal && (
{$t('Horizontal Output')} +
)} - {showVertical && ( + {v.showVertical && (
{$t('Vertical Output')} +
)}
@@ -380,6 +408,35 @@ function DualOutputControls(p: { stacked: boolean }) { ); } +function DualOutputIcons(p: { className: string; showStream: boolean; showRecord: boolean }) { + return ( + <> + {!p.showStream && p.showRecord ? ( + + ) : ( + <> + + + + )} + + ); +} + function DualOutputProgressBar(p: { sceneId: string }) { const { DualOutputService, ScenesService } = Services; diff --git a/app/components-react/root/StudioFooter.tsx b/app/components-react/root/StudioFooter.tsx index 039c08e56913..e87052c95967 100644 --- a/app/components-react/root/StudioFooter.tsx +++ b/app/components-react/root/StudioFooter.tsx @@ -181,9 +181,11 @@ export default function StudioFooterComponent() { function RecordingButton() { const { StreamingService } = Services; - const { isRecording, recordingStatus } = useVuex(() => ({ + const { isRecording, recordingStopping } = useVuex(() => ({ isRecording: StreamingService.views.isRecording, - recordingStatus: StreamingService.state.recordingStatus, + recordingStopping: + StreamingService.state.recordingStatus === ERecordingState.Stopping || + StreamingService.state.verticalRecordingStatus === ERecordingState.Stopping, })); function toggleRecording() { @@ -202,13 +204,7 @@ function RecordingButton() { className={cx(styles.recordButton, 'record-button', { active: isRecording })} onClick={toggleRecording} > - - {recordingStatus === ERecordingState.Stopping ? ( - - ) : ( - <>REC - )} - + {recordingStopping ? : <>REC}
@@ -221,7 +217,7 @@ function RecordingTimer() { const [recordingTime, setRecordingTime] = useState(''); const { isRecording } = useVuex(() => ({ - isRecording: StreamingService.views.isRecording, + isRecording: StreamingService.views.isRecording || StreamingService.views.isVerticalRecording, })); useEffect(() => { diff --git a/app/components-react/windows/go-live/StreamOptions.m.less b/app/components-react/windows/go-live/StreamOptions.m.less new file mode 100644 index 000000000000..357a11c6f695 --- /dev/null +++ b/app/components-react/windows/go-live/StreamOptions.m.less @@ -0,0 +1,68 @@ +@import '../../../styles/index.less'; +@import './GoLive.m.less'; + +.stream-options { + display: flex; + flex-direction: column; + align-self: flex-end; + width: 100%; +} + +.settings-row { + width: 100%; + margin: 0px !important; + padding: 15px; + display: flex; + justify-content: space-between; + align-items: center; + align-self: flex-end; + + :global(.ant-col) { + padding: 0px; + } +} + +.switcher-label { + flex-grow: 1; +} + +.recording-switcher { + justify-self: flex-end; + color: var(--background); + + :global(.ant-form-item-label) { + display: none; + } + + :global(.ant-form-item-control-input) { + min-height: unset; + } + + :global(.ant-switch-small) { + min-width: 36px; + height: 21px; + line-height: 21px; + } + + :global(.ant-switch-small .ant-switch-handle) { + width: 15px; + height: 15px; + top: 3px; + left: 3px; + } + + :global(.ant-switch-small.ant-switch-checked .ant-switch-handle) { + left: calc(100% - 15px - 3px) !important; + } + + :global(.ant-switch-inner i) { + color: var(--background); + display: flex; + align-self: center; + padding-right: 2px; + } + + &:global(.ant-row.ant-form-item) { + margin-bottom: 0px !important; + } +} diff --git a/app/components-react/windows/go-live/StreamOptions.tsx b/app/components-react/windows/go-live/StreamOptions.tsx new file mode 100644 index 000000000000..6d882c947e9a --- /dev/null +++ b/app/components-react/windows/go-live/StreamOptions.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import styles from './StreamOptions.m.less'; +import { Services } from 'components-react/service-provider'; +import { $t } from 'services/i18n'; +import { Row, Select } from 'antd'; +import { SwitchInput } from 'components-react/shared/inputs'; +import { useVuex } from 'components-react/hooks'; +import { ERecordingQuality } from 'obs-studio-node'; + +/** + * Renders options for the stream + * - Vertical Recording Settings + **/ +export default function StreamOptions() { + const { DualOutputService } = Services; + + const { Option } = Select; + + const recordingQualities = [ + { + quality: ERecordingQuality.HighQuality, + name: $t('High, Medium File Size'), + }, + { + quality: ERecordingQuality.HigherQuality, + name: $t('Indistinguishable, Large File Size'), + }, + { + quality: ERecordingQuality.Lossless, + name: $t('Lossless, Tremendously Large File Size'), + }, + ]; + + const v = useVuex(() => ({ + recordVertical: DualOutputService.views.recordVertical, + setRecordVertical: DualOutputService.actions.return.setRecordVertical, + recordingQuality: DualOutputService.views.recordingQuality, + setRecordingQuality: DualOutputService.actions.setDualOutputRecordingQuality, + })); + + return ( +
+ +
+ {$t('Recording Quality')} +
+ +
+ +
{$t('Vertical Recording Only')}
+ } + /> +
+
+ ); +} diff --git a/app/components-react/windows/go-live/dual-output/DualOutputGoLive.m.less b/app/components-react/windows/go-live/dual-output/DualOutputGoLive.m.less index 9d1c919d07b4..3736042389d3 100644 --- a/app/components-react/windows/go-live/dual-output/DualOutputGoLive.m.less +++ b/app/components-react/windows/go-live/dual-output/DualOutputGoLive.m.less @@ -13,6 +13,9 @@ .left-column { height: 100%; padding: 0px !important; + display: flex !important; + flex-direction: column; + justify-content: space-between; &:global(.ant-col) { padding: 10px; diff --git a/app/components-react/windows/go-live/dual-output/DualOutputGoLiveSettings.tsx b/app/components-react/windows/go-live/dual-output/DualOutputGoLiveSettings.tsx index 961068413833..f42ebd934310 100644 --- a/app/components-react/windows/go-live/dual-output/DualOutputGoLiveSettings.tsx +++ b/app/components-react/windows/go-live/dual-output/DualOutputGoLiveSettings.tsx @@ -13,6 +13,7 @@ import Spinner from 'components-react/shared/Spinner'; import GoLiveError from '../GoLiveError'; import UserSettingsUltra from './UserSettingsUltra'; import UserSettingsNonUltra from './UserSettingsNonUltra'; +import StreamOptions from '../StreamOptions'; /** * Renders settings for starting the stream @@ -42,10 +43,11 @@ export default function DualOutputGoLiveSettings() { {/*LEFT COLUMN*/} - + {isPrime && } {!isPrime && } + {/*RIGHT COLUMN*/} diff --git a/app/components-react/windows/go-live/useGoLiveSettings.ts b/app/components-react/windows/go-live/useGoLiveSettings.ts index e287f3f9082f..6eb5931c4de6 100644 --- a/app/components-react/windows/go-live/useGoLiveSettings.ts +++ b/app/components-react/windows/go-live/useGoLiveSettings.ts @@ -222,6 +222,23 @@ export class GoLiveSettingsModule { * Determine if all dual output go live requirements are fulfilled */ getCanStreamDualOutput() { + // if not streaming, recording must be toggled on + if (Services.DualOutputService.views.recordVertical) { + // cannot have vertical recording toggled on with the primary platform or streaming to vertical + const primaryPlatform = this.state.primaryPlatform; + if (primaryPlatform) { + const primaryPlatformDestination = Services.DualOutputService.views.getPlatformDisplay( + primaryPlatform, + ); + if (primaryPlatformDestination === 'vertical') { + return false; + } + } + + return true; + } + + // confirm both displays have a platform or destination selected when streaming with dual output const platformDisplays = Services.StreamingService.views.activeDisplayPlatforms; // determine which enabled custom destinations use which displays @@ -248,11 +265,19 @@ export class GoLiveSettingsModule { */ async validate() { if (Services.DualOutputService.views.dualOutputMode && !this.getCanStreamDualOutput()) { - message.error( - $t( - 'To use Dual Output you must stream to at least one horizontal and one vertical platform.', - ), - ); + if (Services.DualOutputService.views.recordVertical) { + message.error( + $t( + 'Switch primary destination to horizontal output to proceed or toggle vertical recording off.', + ), + ); + } else { + message.error( + $t( + 'To use Dual Output you must stream to at least one horizontal and one vertical platform.', + ), + ); + } return false; } diff --git a/app/components-react/windows/settings/Video.tsx b/app/components-react/windows/settings/Video.tsx index c91f1dbedf11..43350840f759 100644 --- a/app/components-react/windows/settings/Video.tsx +++ b/app/components-react/windows/settings/Video.tsx @@ -514,6 +514,7 @@ export function VideoSettings() { name="video-settings" />
+ Streamlabs Ultra.": "Upgrade your stream with premium overlays with Streamlabs Ultra." + "Vertical Recording Only": "Vertical Recording Only", + "Upgrade your stream with premium overlays with Streamlabs Ultra.": "Upgrade your stream with premium overlays with Streamlabs Ultra.", + "Switch primary destination to horizontal output to proceed or toggle vertical recording off.": "Switch primary destination to horizontal output to proceed or toggle vertical recording off.", + "High, Medium File Size": "High, Medium File Size", + "Indistinguishable, Large File Size": "Indistinguishable, Large File Size", + "Lossless, Tremendously Large File Size": "Lossless, Tremendously Large File Size", + "A new %{displayType} Recording has been completed. Click for more info": "A new %{displayType} Recording has been completed. Click for more info" } diff --git a/app/services/api/external-api/streaming/streaming.ts b/app/services/api/external-api/streaming/streaming.ts index a54c6eb89324..08b5266521a5 100644 --- a/app/services/api/external-api/streaming/streaming.ts +++ b/app/services/api/external-api/streaming/streaming.ts @@ -17,12 +17,18 @@ enum EStreamingState { /** * Possible recording states. + * @remark For consistency with the values in the new API, the properties `start` and `stop` are added. + * The old API has some different values. Because we use the old API for single output and the new API + * for dual output, we currently keep both as options. After fully migrating to the new API, the old API + * values will be removed. */ enum ERecordingState { Offline = 'offline', Starting = 'starting', + Start = 'start', Recording = 'recording', Stopping = 'stopping', + Stop = 'stop', Wrote = 'wrote', } diff --git a/app/services/dual-output/dual-output.ts b/app/services/dual-output/dual-output.ts index a8c24ad89e27..b9f170171f0e 100644 --- a/app/services/dual-output/dual-output.ts +++ b/app/services/dual-output/dual-output.ts @@ -12,7 +12,7 @@ import { EPlaceType } from 'services/editor-commands/commands/reorder-nodes'; import { EditorCommandsService } from 'services/editor-commands'; import { Subject } from 'rxjs'; import { TOutputOrientation } from 'services/restream'; -import { IVideoInfo } from 'obs-studio-node'; +import { IVideoInfo, ERecordingQuality } from 'obs-studio-node'; import { ICustomStreamDestination, StreamSettingsService } from 'services/settings/streaming'; import { ISceneCollectionsManifestEntry, @@ -21,7 +21,7 @@ import { import { UserService } from 'services/user'; import { SelectionService } from 'services/selection'; import { StreamingService } from 'services/streaming'; -import { SettingsService } from 'services/settings'; +import { OutputSettingsService, SettingsService } from 'services/settings'; import { RunInLoadingMode } from 'services/app/app-decorators'; interface IDisplayVideoSettings { @@ -33,15 +33,25 @@ interface IDisplayVideoSettings { vertical: boolean; }; } + +interface IRecordingQuality { + singleOutput: ERecordingQuality; + dualOutput: ERecordingQuality; +} interface IDualOutputServiceState { displays: TDisplayType[]; platformSettings: TDualOutputPlatformSettings; destinationSettings: Dictionary; dualOutputMode: boolean; + recordVertical: boolean; + streamVertical: boolean; videoSettings: IDisplayVideoSettings; + recordingQuality: IRecordingQuality; isLoading: boolean; } +export type TStreamMode = 'single' | 'dual'; + class DualOutputViews extends ViewHandler { @Inject() private scenesService: ScenesService; @Inject() private videoSettingsService: VideoSettingsService; @@ -60,6 +70,14 @@ class DualOutputViews extends ViewHandler { return this.state.dualOutputMode; } + get recordVertical(): boolean { + return this.state.recordVertical; + } + + get streamVertical(): boolean { + return this.state.streamVertical; + } + get activeCollection(): ISceneCollectionsManifestEntry { return this.sceneCollectionsService.activeCollection; } @@ -142,6 +160,10 @@ class DualOutputViews extends ViewHandler { return this.activeDisplays.vertical && !this.activeDisplays.horizontal; } + get recordingQuality() { + return this.state.recordingQuality; + } + getPlatformDisplay(platform: TPlatform) { return this.state.platformSettings[platform].display; } @@ -250,7 +272,7 @@ class DualOutputViews extends ViewHandler { const verticalHasDestinations = platformDisplays.vertical.length > 0 || destinationDisplays.vertical.length > 0; - return horizontalHasDestinations && verticalHasDestinations; + return this.state.recordVertical || (horizontalHasDestinations && verticalHasDestinations); } /** @@ -279,12 +301,15 @@ export class DualOutputService extends PersistentStatefulService { } get shouldGoLiveWithRestream() { - return this.streamInfo.isMultiplatformMode || this.streamInfo.isDualOutputMode; + const verticalSecondDestination = this.streamingService.views.isVerticalSecondDestination; + return ( + this.streamInfo.isMultiplatformMode || + (this.streamInfo.isDualOutputMode && !verticalSecondDestination) + ); } /** diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts index 1b446463e78a..59be63455f8a 100644 --- a/app/services/settings/output/output-settings.ts +++ b/app/services/settings/output/output-settings.ts @@ -4,8 +4,8 @@ import { VideoSettingsService } from 'services/settings-v2/video'; import { HighlighterService } from 'services/highlighter'; import { Inject } from 'services/core/injector'; import { Dictionary } from 'vuex'; -import { AudioService } from 'app-services'; -import { parse } from 'path'; +import { AudioService, DualOutputService } from 'app-services'; +import { ERecordingQuality, ERecordingFormat } from 'obs-studio-node'; /** * list of encoders for simple mode @@ -185,6 +185,7 @@ export class OutputSettingsService extends Service { @Inject() private audioService: AudioService; @Inject() private videoSettingsService: VideoSettingsService; @Inject() private highlighterService: HighlighterService; + @Inject() private dualOutputService: DualOutputService; /** * returns unified settings for the Streaming and Recording encoder @@ -230,6 +231,119 @@ export class OutputSettingsService extends Service { }; } + getRecordingQuality() { + const output = this.settingsService.state.Output.formData; + return this.settingsService.findSettingValue(output, 'Recording', 'RecQuality'); + } + + getSimpleRecordingSettings() { + const output = this.settingsService.state.Output.formData; + const advanced = this.settingsService.state.Advanced.formData; + + const path: string = this.settingsService.findSettingValue(output, 'Recording', 'FilePath'); + + const format: ERecordingFormat = this.settingsService.findValidListValue( + output, + 'Recording', + 'RecFormat', + ) as ERecordingFormat; + + let oldQualityName = this.settingsService.findSettingValue(output, 'Recording', 'RecQuality'); + // brittle, remove when backend fix implemented + if (oldQualityName === 'Stream') { + oldQualityName = this.dualOutputService.views.recordingQuality; + } + let quality: ERecordingQuality = ERecordingQuality.HigherQuality; + switch (oldQualityName) { + case 'Small': + quality = ERecordingQuality.HighQuality; + break; + case 'HQ': + quality = ERecordingQuality.HigherQuality; + break; + case 'Lossless': + quality = ERecordingQuality.Lossless; + break; + case 'Stream': + quality = ERecordingQuality.Stream; + break; + } + + const convertedEncoderName: + | EObsSimpleEncoder.x264_lowcpu + | EObsAdvancedEncoder = this.convertEncoderToNewAPI(this.getSettings().recording.encoder); + + const encoder: EObsAdvancedEncoder = + convertedEncoderName === EObsSimpleEncoder.x264_lowcpu + ? EObsAdvancedEncoder.obs_x264 + : convertedEncoderName; + + const lowCPU: boolean = convertedEncoderName === EObsSimpleEncoder.x264_lowcpu; + + const overwrite: boolean = this.settingsService.findSettingValue( + advanced, + 'Recording', + 'OverwriteIfExists', + ); + + const noSpace: boolean = this.settingsService.findSettingValue( + output, + 'Recording', + 'FileNameWithoutSpace', + ); + + return { + path, + format, + quality, + encoder, + lowCPU, + overwrite, + noSpace, + }; + } + + getAdvancedRecordingSettings() { + const output = this.settingsService.state.Output.formData; + const advanced = this.settingsService.state.Advanced.formData; + + const path = this.settingsService.findSettingValue(output, 'Recording', 'RecFilePath'); + const encoder = this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder'); + const rescaling = this.settingsService.findSettingValue(output, 'Recording', 'RecRescale'); + const mixer = this.settingsService.findSettingValue(output, 'Recording', 'RecTracks'); + const useStreamEncoders = + this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder') === 'none'; + + const format = this.settingsService.findValidListValue( + output, + 'Recording', + 'RecFormat', + ) as ERecordingFormat; + + const overwrite = this.settingsService.findSettingValue( + advanced, + 'Recording', + 'OverwriteIfExists', + ); + + const noSpace = this.settingsService.findSettingValue( + output, + 'Recording', + 'RecFileNameWithoutSpace', + ); + + return { + path, + format, + encoder, + overwrite, + noSpace, + rescaling, + mixer, + useStreamEncoders, + }; + } + private getStreamingEncoderSettings( output: ISettingsSubCategory[], video: ISettingsSubCategory[], @@ -484,4 +598,23 @@ export class OutputSettingsService extends Service { this.settingsService.setSettingValue('Output', 'Recbitrate', settingsPatch.bitrate); } } + + convertEncoderToNewAPI( + encoder: EObsSimpleEncoder | string, + ): EObsSimpleEncoder.x264_lowcpu | EObsAdvancedEncoder { + switch (encoder) { + case EObsSimpleEncoder.x264: + return EObsAdvancedEncoder.obs_x264; + case EObsSimpleEncoder.nvenc: + return EObsAdvancedEncoder.ffmpeg_nvenc; + case EObsSimpleEncoder.amd: + return EObsAdvancedEncoder.amd_amf_h264; + case EObsSimpleEncoder.qsv: + return EObsAdvancedEncoder.obs_qsv11; + case EObsSimpleEncoder.jim_nvenc: + return EObsAdvancedEncoder.jim_nvenc; + case EObsSimpleEncoder.x264_lowcpu: + return EObsSimpleEncoder.x264_lowcpu; + } + } } diff --git a/app/services/streaming/streaming-api.ts b/app/services/streaming/streaming-api.ts index 357ce89c956e..15b1806d34dc 100644 --- a/app/services/streaming/streaming-api.ts +++ b/app/services/streaming/streaming-api.ts @@ -9,6 +9,7 @@ import { ITiktokStartStreamOptions } from '../platforms/tiktok'; import { ITrovoStartStreamOptions } from '../platforms/trovo'; import { IVideo } from 'obs-studio-node'; import { ITwitterStartStreamOptions } from 'services/platforms/twitter'; +import { TPlatform } from 'services/platforms'; export enum EStreamingState { Offline = 'offline', @@ -79,6 +80,7 @@ export interface IGoLiveSettings extends IStreamSettings { youtube?: Partial; facebook?: Partial; }; + primaryPlatform?: TPlatform; } export interface IPlatformFlags { @@ -89,9 +91,13 @@ export interface IPlatformFlags { export interface IStreamingServiceState { streamingStatus: EStreamingState; + verticalStreamingStatus: EStreamingState; streamingStatusTime: string; + verticalStreamingStatusTime: string; recordingStatus: ERecordingState; + verticalRecordingStatus: ERecordingState; recordingStatusTime: string; + verticalRecordingStatusTime: string; replayBufferStatus: EReplayBufferState; replayBufferStatusTime: string; selectiveRecording: boolean; diff --git a/app/services/streaming/streaming-view.ts b/app/services/streaming/streaming-view.ts index ce642e028753..17dc5eb5b127 100644 --- a/app/services/streaming/streaming-view.ts +++ b/app/services/streaming/streaming-view.ts @@ -153,10 +153,20 @@ export class StreamInfoView extends ViewHandler { * Returns a list of enabled for streaming platforms from the given settings object */ getEnabledPlatforms(platforms: IStreamSettings['platforms']): TPlatform[] { - return Object.keys(platforms).filter( + const enabled = Object.keys(platforms).filter( (platform: TPlatform) => this.linkedPlatforms.includes(platform) && platforms[platform]?.enabled, ) as TPlatform[]; + + // currently in dual output mode, when recording the vertical display + // it cannot also be streamed so does not need to be set up + if (this.isDualOutputMode && this.dualOutputView.recordVertical) { + return enabled.filter( + platform => this.dualOutputView.getPlatformDisplay(platform) === 'horizontal', + ); + } + + return enabled; } /** @@ -178,6 +188,14 @@ export class StreamInfoView extends ViewHandler { return this.dualOutputView.dualOutputMode && this.userView.isLoggedIn; } + get isVerticalSecondDestination(): boolean { + return ( + this.isDualOutputMode && + this.dualOutputView.recordVertical && + this.streamingState.streamingStatus !== EStreamingState.Offline + ); + } + get shouldMultistreamDisplay(): { horizontal: boolean; vertical: boolean } { const numHorizontal = this.activeDisplayPlatforms.horizontal.length + @@ -278,6 +296,7 @@ export class StreamInfoView extends ViewHandler { advancedMode: !!this.streamSettingsView.state.goLiveSettings?.advancedMode, optimizedProfile: undefined, customDestinations: savedGoLiveSettings?.customDestinations || [], + primaryPlatform: this.userView.state.auth?.primaryPlatform, }; } @@ -467,10 +486,22 @@ export class StreamInfoView extends ViewHandler { return this.streamingState.streamingStatus !== EStreamingState.Offline; } + get isVerticalStreaming() { + return this.streamingState.verticalStreamingStatus !== EStreamingState.Offline; + } + get isRecording() { + return this.isHorizontalRecording || this.isVerticalRecording; + } + + get isHorizontalRecording() { return this.streamingState.recordingStatus !== ERecordingState.Offline; } + get isVerticalRecording() { + return this.streamingState.verticalRecordingStatus !== ERecordingState.Offline; + } + get isReplayBufferActive() { return this.streamingState.replayBufferStatus !== EReplayBufferState.Offline; } diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index bbeae310dd6a..ea2aaea77e3d 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -1,10 +1,23 @@ import Vue from 'vue'; import { mutation, StatefulService } from 'services/core/stateful-service'; -import { EOutputCode, Global, NodeObs } from '../../../obs-api'; +import { + EOutputCode, + Global, + NodeObs, + AudioEncoderFactory, + SimpleRecordingFactory, + VideoEncoderFactory, + ISimpleRecording, + IAdvancedRecording, + AdvancedRecordingFactory, + EOutputSignal, + AudioTrackFactory, + ERecordingQuality, +} from '../../../obs-api'; import { Inject } from 'services/core/injector'; import moment from 'moment'; import padStart from 'lodash/padStart'; -import { IOutputSettings, OutputSettingsService } from 'services/settings'; +import { IOutputSettings, OutputSettingsService, SettingsService } from 'services/settings'; import { WindowsService } from 'services/windows'; import { Subject } from 'rxjs'; import { @@ -45,7 +58,7 @@ import * as remote from '@electron/remote'; import { RecordingModeService } from 'services/recording-mode'; import { MarkersService } from 'services/markers'; import { byOS, OS } from 'util/operating-systems'; -import { DualOutputService } from 'services/dual-output'; +import { DualOutputService, TStreamMode } from 'services/dual-output'; enum EOBSOutputType { Streaming = 'streaming', @@ -65,6 +78,13 @@ enum EOBSOutputSignal { WriteError = 'writing_error', } +enum EOutputSignalState { + Start = 'start', + Stop = 'stop', + Stopping = 'stopping', + Wrote = 'wrote', +} + interface IOBSOutputSignalInfo { type: EOBSOutputType; signal: EOBSOutputSignal; @@ -90,6 +110,7 @@ export class StreamingService @Inject() private videoSettingsService: VideoSettingsService; @Inject() private markersService: MarkersService; @Inject() private dualOutputService: DualOutputService; + @Inject() private settingsService: SettingsService; streamingStatusChange = new Subject(); recordingStatusChange = new Subject(); @@ -100,17 +121,25 @@ export class StreamingService // Dummy subscription for stream deck streamingStateChange = new Subject(); + private recordingStopped = new Subject(); powerSaveId: number; private resolveStartStreaming: Function = () => {}; private rejectStartStreaming: Function = () => {}; + private horizontalRecording: ISimpleRecording | IAdvancedRecording | null = null; + private verticalRecording: ISimpleRecording | IAdvancedRecording | null = null; + static initialState: IStreamingServiceState = { streamingStatus: EStreamingState.Offline, + verticalStreamingStatus: EStreamingState.Offline, streamingStatusTime: new Date().toISOString(), + verticalStreamingStatusTime: new Date().toISOString(), recordingStatus: ERecordingState.Offline, + verticalRecordingStatus: ERecordingState.Offline, recordingStatusTime: new Date().toISOString(), + verticalRecordingStatusTime: new Date().toString(), replayBufferStatus: EReplayBufferState.Offline, replayBufferStatusTime: new Date().toISOString(), selectiveRecording: false, @@ -724,7 +753,10 @@ export class StreamingService } get isRecording() { - return this.state.recordingStatus !== ERecordingState.Offline; + return ( + this.state.recordingStatus !== ERecordingState.Offline || + this.state.verticalRecordingStatus !== ERecordingState.Offline + ); } get isReplayBufferActive() { @@ -792,45 +824,78 @@ export class StreamingService this.powerSaveId = remote.powerSaveBlocker.start('prevent-display-sleep'); - // start streaming + // handle start streaming and recording if (this.views.isDualOutputMode) { // start dual output + if (this.dualOutputService.views.recordVertical) { + // stream horizontal and record vertical + const horizontalContext = this.videoSettingsService.contexts.horizontal; + NodeObs.OBS_service_setVideoInfo(horizontalContext, 'horizontal'); - const horizontalContext = this.videoSettingsService.contexts.horizontal; - const verticalContext = this.videoSettingsService.contexts.vertical; + const signalChanged = this.signalInfoChanged.subscribe( + async (signalInfo: IOBSOutputSignalInfo) => { + if (signalInfo.service === 'default') { + if (signalInfo.signal === EOBSOutputSignal.Start) { + // Refactor when migrate to new API, sleep to allow state to update + await new Promise(resolve => setTimeout(resolve, 300)); + this.toggleRecording(); + signalChanged.unsubscribe(); + } + } + }, + ); + NodeObs.OBS_service_startStreaming('horizontal'); + } else { + // stream horizontal and stream vertical + const horizontalContext = this.videoSettingsService.contexts.horizontal; + const verticalContext = this.videoSettingsService.contexts.vertical; - NodeObs.OBS_service_setVideoInfo(horizontalContext, 'horizontal'); - NodeObs.OBS_service_setVideoInfo(verticalContext, 'vertical'); + NodeObs.OBS_service_setVideoInfo(horizontalContext, 'horizontal'); + NodeObs.OBS_service_setVideoInfo(verticalContext, 'vertical'); - const signalChanged = this.signalInfoChanged.subscribe((signalInfo: IOBSOutputSignalInfo) => { - if (signalInfo.service === 'default') { - if (signalInfo.code !== 0) { - NodeObs.OBS_service_stopStreaming(true, 'horizontal'); - NodeObs.OBS_service_stopStreaming(true, 'vertical'); - } + const signalChanged = this.signalInfoChanged.subscribe( + (signalInfo: IOBSOutputSignalInfo) => { + if (signalInfo.service === 'default') { + if (signalInfo.code !== 0) { + NodeObs.OBS_service_stopStreaming(true, 'horizontal'); + NodeObs.OBS_service_stopStreaming(true, 'vertical'); + // Refactor when move streaming to new API + if (this.state.verticalStreamingStatus !== EStreamingState.Offline) { + this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline); + } + } - if (signalInfo.signal === EOBSOutputSignal.Start) { - NodeObs.OBS_service_startStreaming('vertical'); - signalChanged.unsubscribe(); - } - } - }); + if (signalInfo.signal === EOBSOutputSignal.Start) { + NodeObs.OBS_service_startStreaming('vertical'); + + // Refactor when move streaming to new API + const time = new Date().toISOString(); + if (this.state.verticalStreamingStatus === EStreamingState.Offline) { + this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Live, time); + } - NodeObs.OBS_service_startStreaming('horizontal'); - // sleep for 1 second to allow the first stream to start - await new Promise(resolve => setTimeout(resolve, 1000)); + signalChanged.unsubscribe(); + } + } + }, + ); + + NodeObs.OBS_service_startStreaming('horizontal'); + // sleep for 1 second to allow the first stream to start + await new Promise(resolve => setTimeout(resolve, 1000)); + } } else { // start single output const horizontalContext = this.videoSettingsService.contexts.horizontal; NodeObs.OBS_service_setVideoInfo(horizontalContext, 'horizontal'); NodeObs.OBS_service_startStreaming(); - } - const recordWhenStreaming = this.streamSettingsService.settings.recordWhenStreaming; + const recordWhenStreaming = this.streamSettingsService.settings.recordWhenStreaming; - if (recordWhenStreaming && this.state.recordingStatus === ERecordingState.Offline) { - this.toggleRecording(); + if (recordWhenStreaming && this.state.recordingStatus === ERecordingState.Offline) { + this.toggleRecording(); + } } const replayWhenStreaming = this.streamSettingsService.settings.replayBufferWhileStreaming; @@ -906,7 +971,7 @@ export class StreamingService remote.powerSaveBlocker.stop(this.powerSaveId); } - if (this.views.isDualOutputMode) { + if (this.views.isDualOutputMode && !this.dualOutputService.views.recordVertical) { const signalChanged = this.signalInfoChanged.subscribe( (signalInfo: IOBSOutputSignalInfo) => { if ( @@ -914,6 +979,10 @@ export class StreamingService signalInfo.signal === EOBSOutputSignal.Deactivate ) { NodeObs.OBS_service_stopStreaming(false, 'vertical'); + // Refactor when move streaming to new API + if (this.state.verticalStreamingStatus !== EStreamingState.Offline) { + this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline); + } signalChanged.unsubscribe(); } }, @@ -927,7 +996,10 @@ export class StreamingService } const keepRecording = this.streamSettingsService.settings.keepRecordingWhenStreamStops; - if (!keepRecording && this.state.recordingStatus === ERecordingState.Recording) { + const isRecording = + this.state.recordingStatus === ERecordingState.Recording || + this.state.verticalRecordingStatus === ERecordingState.Recording; + if (!keepRecording && isRecording) { this.toggleRecording(); } @@ -946,8 +1018,15 @@ export class StreamingService } if (this.state.streamingStatus === EStreamingState.Ending) { - if (this.views.isDualOutputMode) { + if ( + this.views.isDualOutputMode && + this.state.verticalRecordingStatus === ERecordingState.Offline + ) { NodeObs.OBS_service_stopStreaming(true, 'horizontal'); + // Refactor when move streaming to new API + if (this.state.verticalStreamingStatus !== EStreamingState.Offline) { + this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline); + } NodeObs.OBS_service_stopStreaming(true, 'vertical'); } else { NodeObs.OBS_service_stopStreaming(true); @@ -970,7 +1049,7 @@ export class StreamingService this.toggleRecording(); } - toggleRecording() { + recordWithOldAPI() { if (this.state.recordingStatus === ERecordingState.Recording) { NodeObs.OBS_service_stopRecording(); return; @@ -982,6 +1061,311 @@ export class StreamingService } } + /** + * Part of a workaround to set recording quality on the frontend to avoid a backend bug + * @remark TODO: remove when migrate output and stream settings to new API + * Because of a bug with the new API and having the `Same as stream` setting, temporarily store and set + * the recording quality on the frontend + * @param mode - single output or dual output + */ + beforeStartRecording(mode: TStreamMode) { + /** + * When going live in dual output mode, `Same as stream` is a not valid setting because of a bug in the backend in the new API. + * To prevent errors, if the recording quality set on the old API object is `Same as Stream` + * temporarily change the recording quality set on the old API object to the same as the recording quality + * stored in the dual output service. + + * To further prevent errors, until the output and stream settings are migrated to the new API, + * in single output mode, use the old api + */ + if (mode === 'dual') { + // recording quality stored on the front in the dual output service + const dualOutputSingleRecordingQuality = this.dualOutputService.views.recordingQuality + .dualOutput; + + // recording quality set on the old API object + const singleOutputRecordingQuality = this.outputSettingsService.getRecordingQuality(); + + if (singleOutputRecordingQuality === ERecordingQuality.Stream) { + // store the old API setting in the frontend + this.dualOutputService.setDualOutputRecordingQuality( + 'single', + dualOutputSingleRecordingQuality, + ); + + // match the old API setting to the dual output setting + this.settingsService.setSettingValue( + 'Recording', + 'ReqQuality', + dualOutputSingleRecordingQuality, + ); + } + } else { + // recording quality stored on the front in the dual output service + const dualOutputSingleRecordingQuality = this.dualOutputService.views.recordingQuality + .dualOutput; + + // recording quality set on the old API object + const singleOutputRecordingQuality = this.outputSettingsService.getRecordingQuality(); + + // if needed, in single output mode restore the `Same as stream` setting on the old API + if ( + singleOutputRecordingQuality !== dualOutputSingleRecordingQuality && + dualOutputSingleRecordingQuality === ERecordingQuality.Stream + ) { + // when going live in single output mode, `Same as stream` is a valid setting + this.settingsService.setSettingValue('Recording', 'ReqQuality', ERecordingQuality.Stream); + } + } + } + + /** + * Start/stop recording + * @remark for single output mode use the old API until output and stream settings are migrated to the new API + */ + toggleRecording() { + const recordingMode = this.views.isDualOutputMode ? 'dual' : 'single'; + this.beforeStartRecording(recordingMode); + + if (!this.views.isDualOutputMode) { + // single output mode + this.recordWithOldAPI(); + } else { + // dual output mode + + // stop recording + if ( + this.state.recordingStatus === ERecordingState.Recording && + this.state.verticalRecordingStatus === ERecordingState.Recording + ) { + // stop recording both displays + let time = new Date().toISOString(); + + if (this.verticalRecording !== null) { + const recordingStopped = this.recordingStopped.subscribe(async () => { + await new Promise(resolve => + // sleep for 2 seconds to allow a different time stamp to be generated + // because the recording history uses the time stamp as keys + // if the same time stamp is used, the entry will be replaced in the recording history + setTimeout(() => { + time = new Date().toISOString(); + this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'horizontal'); + if (this.horizontalRecording !== null) { + this.horizontalRecording.stop(); + } + }, 2000), + ); + recordingStopped.unsubscribe(); + }); + + this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'vertical'); + this.verticalRecording.stop(); + this.recordingStopped.next(); + } + + return; + } else if ( + this.state.verticalRecordingStatus === ERecordingState.Recording && + this.verticalRecording !== null + ) { + // stop recording vertical display + const time = new Date().toISOString(); + this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'vertical'); + this.verticalRecording.stop(); + } else if ( + this.state.recordingStatus === ERecordingState.Recording && + this.horizontalRecording !== null + ) { + const time = new Date().toISOString(); + // stop recording horizontal display + this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'horizontal'); + this.horizontalRecording.stop(); + } + + // start recording + if ( + this.state.recordingStatus === ERecordingState.Offline && + this.state.verticalRecordingStatus === ERecordingState.Offline + ) { + if (this.views.isDualOutputMode) { + if ( + this.dualOutputService.views.recordVertical && + this.state.streamingStatus !== EStreamingState.Offline + ) { + // In dual output mode, if the streaming status is starting then this call to toggle recording came from the function to toggle streaming. + // In this case, only stream the horizontal display (don't record the horizontal display) and record the vertical display. + this.createRecording('vertical', 2); + } else { + // Otherwise, record both displays in dual output mode + this.createRecording('vertical', 2); + this.createRecording('horizontal', 1); + } + } else { + // In single output mode, recording only the horizontal display + // TODO: remove after migrate output and stream settings to new api + this.recordWithOldAPI(); + // this.createRecording('horizontal', 1); + } + } + } + } + + private createRecording(display: TDisplayType, index: number) { + const mode = this.outputSettingsService.getSettings().mode; + + const recording = + mode === 'Advanced' + ? (AdvancedRecordingFactory.create() as IAdvancedRecording) + : (SimpleRecordingFactory.create() as ISimpleRecording); + + const settings = + mode === 'Advanced' + ? this.outputSettingsService.getAdvancedRecordingSettings() + : this.outputSettingsService.getSimpleRecordingSettings(); + + // assign settings + Object.keys(settings).forEach(key => { + if (key === 'encoder') { + recording.videoEncoder = VideoEncoderFactory.create(settings.encoder, 'video-encoder'); + } else { + recording[key] = settings[key]; + } + }); + + // assign context + recording.video = this.videoSettingsService.contexts[display]; + + // set signal handler + recording.signalHandler = async signal => { + await this.handleRecordingSignal(signal, display); + }; + + // handle unique properties (including audio) + if (mode === 'Advanced') { + recording['outputWidth'] = this.dualOutputService.views.videoSettings[display].outputWidth; + recording['outputHeight'] = this.dualOutputService.views.videoSettings[display].outputHeight; + + const trackName = `track${index}`; + const track = AudioTrackFactory.create(160, trackName); + AudioTrackFactory.setAtIndex(track, index); + } else { + recording['audioEncoder'] = AudioEncoderFactory.create(); + } + + // save in state + if (display === 'vertical') { + this.verticalRecording = recording; + this.verticalRecording.start(); + } else { + this.horizontalRecording = recording; + this.horizontalRecording.start(); + } + } + + private async handleRecordingSignal(info: EOutputSignal, display: TDisplayType) { + // map signals to status + const nextState: ERecordingState = ({ + [EOutputSignalState.Start]: ERecordingState.Recording, + [EOutputSignalState.Stop]: ERecordingState.Offline, + [EOutputSignalState.Stopping]: ERecordingState.Stopping, + [EOutputSignalState.Wrote]: ERecordingState.Wrote, + } as Dictionary)[info.signal]; + + // We received a signal we didn't recognize + if (!nextState) { + console.error('Received unrecognized signal: ', nextState); + return; + } + + if (nextState === ERecordingState.Recording) { + const mode = this.views.isDualOutputMode ? 'dual' : 'single'; + this.usageStatisticsService.recordFeatureUsage('Recording'); + this.usageStatisticsService.recordAnalyticsEvent('RecordingStatus', { + status: ERecordingState.Recording, + code: info.code, + mode, + display, + }); + } + + if (nextState === ERecordingState.Wrote) { + const fileName = await this.getFileName(display); + + const parsedName = byOS({ + [OS.Mac]: fileName, + [OS.Windows]: fileName.replace(/\//, '\\'), + }); + + // in dual output mode, each confirmation should be labelled for each display + if (this.views.isDualOutputMode) { + this.recordingModeService.addRecordingEntry(parsedName, display); + } else { + this.recordingModeService.addRecordingEntry(parsedName); + } + await this.markersService.exportCsv(parsedName); + + // destroy recording factory instances + if (this.outputSettingsService.getSettings().mode === 'Advanced') { + if (display === 'horizontal' && this.horizontalRecording) { + AdvancedRecordingFactory.destroy(this.horizontalRecording as IAdvancedRecording); + this.horizontalRecording = null; + } else if (display === 'vertical' && this.verticalRecording) { + AdvancedRecordingFactory.destroy(this.verticalRecording as IAdvancedRecording); + this.verticalRecording = null; + } + } else { + if (display === 'horizontal' && this.horizontalRecording) { + SimpleRecordingFactory.destroy(this.horizontalRecording as ISimpleRecording); + this.horizontalRecording = null; + } else if (display === 'vertical' && this.verticalRecording) { + SimpleRecordingFactory.destroy(this.verticalRecording as ISimpleRecording); + this.verticalRecording = null; + } + } + + const time = new Date().toISOString(); + this.SET_RECORDING_STATUS(ERecordingState.Offline, time, display); + this.recordingStatusChange.next(ERecordingState.Offline); + + this.handleOutputCode(info); + return; + } + + const time = new Date().toISOString(); + this.SET_RECORDING_STATUS(nextState, time, display); + this.recordingStatusChange.next(nextState); + + this.handleOutputCode(info); + } + + /** + * Gets the filename of the recording + * @remark In the new API, there can be a delay between the wrote signal + * and the finalizing of the file in the directory. The existence of the file name + * is a signifier that it has been finalized. In order for the file name to populate + * in the recording history, make sure the file has been finalized + * @param display - determines which recording object to use + * @returns string file name + */ + async getFileName(display: TDisplayType): Promise { + return await new Promise(resolve => { + const wroteInterval = setInterval(() => { + // confirm vertical recording exists + let filename = ''; + if (display === 'vertical' && this.verticalRecording) { + filename = this.verticalRecording.lastFile(); + } else if (display === 'horizontal' && this.horizontalRecording) { + filename = this.horizontalRecording.lastFile(); + } + + if (filename !== '') { + resolve(filename); + clearInterval(wroteInterval); + } + }, 300); + }); + } + splitFile() { if (this.state.recordingStatus === ERecordingState.Recording) { NodeObs.OBS_service_splitFile(); @@ -1089,6 +1473,13 @@ export class StreamingService } get formattedDurationInCurrentRecordingState() { + // in dual output mode, if using vertical recording as the second destination + // display the vertical recording status time + if (this.state.recordingStatus !== ERecordingState.Offline) { + this.formattedDurationSince(moment(this.state.recordingStatusTime)); + } else if (this.state.verticalRecordingStatus !== ERecordingState.Offline) { + return this.formattedDurationSince(moment(this.state.verticalRecordingStatusTime)); + } return this.formattedDurationSince(moment(this.state.recordingStatusTime)); } @@ -1137,8 +1528,22 @@ export class StreamingService private handleOBSOutputSignal(info: IOBSOutputSignalInfo) { console.debug('OBS Output signal: ', info); + /* + * Resolve when: + * - Single output mode: always resolve + * - Dual output mode: after vertical stream started + * - Dual output mode: when vertical display is second destination, + * resolve after horizontal stream started + */ + const isVerticalDisplayStartSignal = + info.service === 'vertical' && info.signal === EOBSOutputSignal.Start; + const shouldResolve = - !this.views.isDualOutputMode || (this.views.isDualOutputMode && info.service === 'vertical'); + !this.views.isDualOutputMode || + (this.views.isDualOutputMode && isVerticalDisplayStartSignal) || + (this.views.isDualOutputMode && + info.signal === EOBSOutputSignal.Start && + this.dualOutputService.views.recordVertical); const time = new Date().toISOString(); @@ -1206,6 +1611,10 @@ export class StreamingService this.clearReconnectingNotification(); } } else if (info.type === EOBSOutputType.Recording) { + // TODO: remove when migrate to output and stream settings implemented + // Currently, single output mode uses the old api while dual output mode uses the new api. + // This listens for the signals in the new API while the signal handler used in toggleRecording + // is for the new API const nextState: ERecordingState = ({ [EOBSOutputSignal.Start]: ERecordingState.Recording, [EOBSOutputSignal.Starting]: ERecordingState.Starting, @@ -1215,7 +1624,10 @@ export class StreamingService } as Dictionary)[info.signal]; // We received a signal we didn't recognize - if (!nextState) return; + if (!nextState) { + console.error('Received unrecognized signal: ', nextState); + return; + } if (info.signal === EOBSOutputSignal.Start) { this.usageStatisticsService.recordFeatureUsage('Recording'); @@ -1263,7 +1675,10 @@ export class StreamingService this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay()); } } + this.handleOutputCode(info); + } + private handleOutputCode(info: IOBSOutputSignalInfo | EOutputSignal) { if (info.code) { if (this.outputErrorOpen) { console.warn('Not showing error message because existing window is open.', info); @@ -1437,10 +1852,29 @@ export class StreamingService if (time) this.state.streamingStatusTime = time; } + /** + * Refactor when streaming moved to the new api + * Currently only used in dual output mode to show streaming status of the vertical display + */ + @mutation() + private SET_VERTICAL_STREAMING_STATUS(status: EStreamingState, time?: string) { + this.state.verticalStreamingStatus = status; + if (time) this.state.verticalStreamingStatusTime = time; + } + @mutation() - private SET_RECORDING_STATUS(status: ERecordingState, time: string) { - this.state.recordingStatus = status; - this.state.recordingStatusTime = time; + private SET_RECORDING_STATUS( + status: ERecordingState, + time: string, + display: TDisplayType = 'horizontal', + ) { + if (display === 'vertical') { + this.state.verticalRecordingStatus = status; + this.state.verticalRecordingStatusTime = time; + } else { + this.state.recordingStatus = status; + this.state.recordingStatusTime = time; + } } @mutation() diff --git a/test/helpers/form-monkey.ts b/test/helpers/form-monkey.ts index 7e26cf15aa61..f357db196ed3 100644 --- a/test/helpers/form-monkey.ts +++ b/test/helpers/form-monkey.ts @@ -567,11 +567,18 @@ export class FormMonkey { /** * returns selector for the input element by a title */ - async getInputSelectorByTitle(inputTitle: string): Promise { + async getInputSelectorByTitle(inputTitle: string, isDataName: boolean = false): Promise { const name = await this.getInputNameByTitle(inputTitle); return `[data-role="input"][data-name="${name}"]`; } + /** + * returns selector for the input element by a title + */ + async getInputSelectorByName(name: string): Promise { + return `[data-name="${name}"]`; + } + /** * returns name for the input element by a title */ @@ -604,7 +611,7 @@ export function selectTitle(optionTitle: string): FNValueSetter { await form.waitForLoading(input.name); // click on the first option - await click( `${input.selector} .multiselect__element`); + await click(`${input.selector} .multiselect__element`); }; } diff --git a/test/helpers/modules/core.ts b/test/helpers/modules/core.ts index 29c6791eedb2..ea45342fa9aa 100644 --- a/test/helpers/modules/core.ts +++ b/test/helpers/modules/core.ts @@ -68,6 +68,12 @@ export async function clickCheckbox(dataName: string) { await $checkbox.click(); } +// @@@ TODO WIP +export async function clickRadio(radioText: string) { + const $radio = await select(`input[type="radio"]=${radioText}`); + await $radio.click(); +} + // OTHER SHORTCUTS export async function hoverElement(selector: string, duration?: number) { diff --git a/test/helpers/modules/streaming.ts b/test/helpers/modules/streaming.ts index ddf1b21ca097..1267c8f6d184 100644 --- a/test/helpers/modules/streaming.ts +++ b/test/helpers/modules/streaming.ts @@ -7,7 +7,8 @@ import { getApiClient } from '../api-client'; import { click, clickButton, - focusChild, getFocusedWindowId, + focusChild, + getFocusedWindowId, isDisplayed, select, selectButton, @@ -118,6 +119,10 @@ export async function chatIsVisible() { export async function startRecording() { await click('.record-button'); + // Refactor when migrate output and stream settings to new API + // currently, because of the data mapping from the old api + // that is needed to work with the new api, it take a little longer to start the recording + await sleep(500); await waitForDisplayed('.record-button.active'); } diff --git a/test/regular/recording.ts b/test/regular/recording.ts index 551db9d4d377..632728791a41 100644 --- a/test/regular/recording.ts +++ b/test/regular/recording.ts @@ -1,23 +1,103 @@ import { readdir } from 'fs-extra'; -import { test, useWebdriver } from '../helpers/webdriver'; +import { ITestContext, test, useWebdriver } from '../helpers/webdriver'; import { sleep } from '../helpers/sleep'; -import { startRecording, stopRecording } from '../helpers/modules/streaming'; +import { + clickGoLive, + prepareToGoLive, + startRecording, + stopRecording, +} from '../helpers/modules/streaming'; import { FormMonkey } from '../helpers/form-monkey'; import { setOutputResolution, setTemporaryRecordingPath, showSettingsWindow, } from '../helpers/modules/settings/settings'; -import { clickButton, focusMain } from '../helpers/modules/core'; +import { + clickButton, + focusMain, + selectElements, + focusChild, + closeWindow, +} from '../helpers/modules/core'; import { logIn } from '../helpers/webdriver/user'; +import { logIn as multiLogIn } from '../helpers/modules/user'; import { toggleDualOutputMode } from '../helpers/modules/dual-output'; +import { showPage } from '../helpers/modules/navigation'; +import { setFormDropdown } from '../helpers/webdriver/forms'; +import { ExecutionContext } from 'ava'; useWebdriver(); +async function confirmFilesCreated( + t: ExecutionContext, + tmpDir: string, + numCreatedFiles: number, +) { + const recordings = await selectElements('span.file'); + t.true(recordings.length === numCreatedFiles); + + // check that every files was created with correct file name + const files = await readdir(tmpDir); + t.true(recordings.length === files.length); + const fileNames = files.map(file => file.split('/').pop()); + recordings.forEach(async recording => { + const recordingName = await recording.getText(); + t.true(fileNames.includes[recordingName]); + }); +} + +async function recordAndConfirmRecordings( + t: ExecutionContext, + tmpDir: string, + qualities: string[], + setSettings: (setting: string) => Promise, + numExpectedRecordings: number, +) { + for (const quality of qualities) { + await setSettings(quality); + await focusMain(); + await startRecording(); + await sleep(500); + await stopRecording(); + + // Wait to ensure that output setting are editable + await sleep(500); + } + + // Check that every file was created + const files = await readdir(tmpDir); + + console.log('files length ', files.length); + + // M3U8 creates multiple TS files in addition to the catalog itself. + t.true(files.length === numExpectedRecordings, `Files that were created:\n${files.join('\n')}`); +} + +async function prepareRecordDualOutput(t: ExecutionContext): Promise { + await multiLogIn('twitch', { multistream: true }); + await toggleDualOutputMode(); + + const tmpDir = await setTemporaryRecordingPath(); + // low resolution reduces CPU usage + await setOutputResolution('100x100'); + + await showSettingsWindow('Output', async () => { + await setFormDropdown('Recording Quality', 'High Quality, Medium File Size'); + await sleep(500); + await clickButton('Done'); + }); + + // Recording mode: record horizontal and vertical displays + await focusMain(); + + return tmpDir; +} + /** - * Recording with one context active (horizontal) + * Records all formats in single output mode with a vanilla scene collection + * and dual output mode with a dual output scene collection */ - test('Recording', async t => { const tmpDir = await setTemporaryRecordingPath(); @@ -33,7 +113,6 @@ test('Recording', async t => { await form.setInputValue(await form.getInputSelectorByTitle('Recording Format'), format); await clickButton('Done'); }); - await focusMain(); await startRecording(); await sleep(500); @@ -44,23 +123,20 @@ test('Recording', async t => { } // Check that every file was created - const files = await readdir(tmpDir); + const singleOutputFiles = await readdir(tmpDir); // M3U8 creates multiple TS files in addition to the catalog itself. - t.true(files.length >= formats.length, `Files that were created:\n${files.join('\n')}`); -}); + t.true( + singleOutputFiles.length >= formats.length, + `Files that were created:\n${singleOutputFiles.join('\n')}`, + ); -/** - * Recording with two contexts active (horizontal and vertical) - * should produce no different results than with one context. - */ -test('Recording with two contexts active', async t => { + /** + * Recording in Dual Output Mode + */ await logIn(t); await toggleDualOutputMode(); - const tmpDir = await setTemporaryRecordingPath(); - // low resolution reduces CPU usage - await setOutputResolution('100x100'); - const formats = ['flv', 'mp4', 'mov', 'mkv', 'ts', 'm3u8']; + // Record 0.5s video in every format for (const format of formats) { await showSettingsWindow('Output', async () => { @@ -68,15 +144,183 @@ test('Recording with two contexts active', async t => { await form.setInputValue(await form.getInputSelectorByTitle('Recording Format'), format); await clickButton('Done'); }); + await focusMain(); await startRecording(); - await sleep(1000); + await sleep(500); await stopRecording(); - // Wait to ensure that output setting are editable - await sleep(1000); } + // Check that every file was created - const files = await readdir(tmpDir); + const dualOutputFiles = (await readdir(tmpDir)) + .filter(file => !singleOutputFiles.includes(file)) + .map(file => file); // M3U8 creates multiple TS files in addition to the catalog itself. - t.true(files.length >= formats.length, `Files that were created:\n${files.join('\n')}`); + t.true( + dualOutputFiles.length >= formats.length, + `Files that were created:\n${dualOutputFiles.join('\n')}`, + ); +}); + +/** + * Recording in dual output mode with a dual output scene collection + */ +test('Recording Dual Output', async t => { + await logIn(t); + await toggleDualOutputMode(); + + const tmpDir = await setTemporaryRecordingPath(); + // low resolution reduces CPU usage + await setOutputResolution('100x100'); + + await showSettingsWindow('Output', async () => { + await setFormDropdown('Recording Quality', 'High Quality, Medium File Size'); + await sleep(500); + await clickButton('Done'); + }); + + // Recording mode: record horizontal and vertical displays + await focusMain(); + await startRecording(); + // Wait for recording to start + await sleep(500); + + // Record icons show in both headers + // Icons are conditionally shown/hidden prevent rendering issues with the icon visibility shifting the title text + const streamIcons = await selectElements('i.icon-stream.visible'); + const recordIcons = await selectElements('i.icon-record.visible'); + + t.true(streamIcons.length === 0); + t.true(recordIcons.length === 2); + + await stopRecording(); + // Wait to ensure that both video files are finalized + await sleep(500); + + await focusMain(); + + // Generated two recordings + await showPage('Recordings'); + await confirmFilesCreated(t, tmpDir, 2); +}); + +test.skip('Recording and Streaming Dual Output', async t => { + await clickGoLive(); + await focusChild(); + + // select(`input[type="radio"] + const streamIcons = await selectElements('input[type=radio]'); + console.log('streamIcons ', streamIcons.values()); + // TODO: toggle radio button for streaming + // await clickRadio('Vertical'); + + // const tmpDir = await prepareRecordDualOutput(t); + + // console.log('tmpDir ', tmpDir); + + // // Start stream + // await prepareToGoLive(); + // await clickGoLive(); + // await focusChild(); + // await clickRadio('Vertical'); + // await goLive(); + // // Wait for stream to start + // await sleep(500); + + // // Start recording + // await startRecording(); + // // Wait for recording to start + // await sleep(500); + + // // Record icons show in both headers + // // Icons are conditionally shown/hidden prevent rendering issues with the icon visibility shifting the title text + // const streamIcons = await selectElements('i.icon-stream.visible'); + // const recordIcons = await selectElements('i.icon-record.visible'); + + // t.true(streamIcons.length === 2); + // t.true(recordIcons.length === 2); + + // await stopRecording(); + // // Wait to ensure that both video files are finalized + // await sleep(500); + + // await stopStream(); + // await sleep(500); + + // // await focusMain(); + // // Record icons are hidden + // // recordIcons = await selectElements('i.icon-record.visible'); + // // t.true(recordIcons.length === 2); + // // console.log('2 recordIcons ', recordIcons.length); + + // // Generated two recordings + // await showPage('Recordings'); + // await confirmFilesCreated(t, tmpDir, 2); +}); + +/** + * Records all file quality in single output mode with a vanilla scene collection + * and dual output mode with a dual output scene collection + * + * TODO: refactor for `Same as stream` as valid dual output quality when backend fix is implemented + */ +test('Recording File Quality', async t => { + const tmpDir = await setTemporaryRecordingPath(); + + // low resolution reduces CPU usage + await setOutputResolution('100x100'); + + const singleOutputQualities = [ + 'High Quality, Medium File Size', + 'Indistinguishable Quality, Large File Size', + 'Lossless Quality, Tremendously Large File Size', + 'Same as stream', + ]; + + // `Same as stream` is currently not a valid option when recording dual output + const dualOutputQualities = [ + 'High, Medium File Size', + 'Indistinguishable, Large File Size', + 'Lossless, Tremendously Large File Size', + ]; + + // single output recording quality + await recordAndConfirmRecordings( + t, + tmpDir, + singleOutputQualities, + async (quality: string) => { + await showSettingsWindow('Output', async () => { + await setFormDropdown('Recording Quality', quality); + await clickButton('Done'); + }); + }, + singleOutputQualities.length, + ); + + // dual output recording quality + await logIn(t); + await toggleDualOutputMode(); + await prepareToGoLive(); + await focusMain(); + + // the single output recordings already exist in the directory + // so account for them in the number of expected recordings + const numExpectedRecordings = singleOutputQualities.length + dualOutputQualities.length * 2; + + await recordAndConfirmRecordings( + t, + tmpDir, + dualOutputQualities, + async (quality: string) => { + await clickGoLive(); + await focusChild(); + const form = new FormMonkey(t); + await form.setInputValue('[data-name="recording-quality"]', quality); + await closeWindow('child'); + }, + numExpectedRecordings, + ); + + await toggleDualOutputMode(); }); diff --git a/test/regular/selective-recording.ts b/test/regular/selective-recording.ts index da43e5e0c7de..e309fbb6ab75 100644 --- a/test/regular/selective-recording.ts +++ b/test/regular/selective-recording.ts @@ -4,8 +4,11 @@ import { addSource } from '../helpers/modules/sources'; import { setOutputResolution, setTemporaryRecordingPath, + showSettingsWindow, } from '../helpers/modules/settings/settings'; -import { focusMain } from '../helpers/modules/core'; +import { clickButton, focusMain } from '../helpers/modules/core'; +import { sleep } from '../helpers/sleep'; +import { setFormDropdown } from '../helpers/webdriver/forms'; useWebdriver();