From cf72bc5d6198348736f347a97aebdb6b439cd8b2 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 4 Mar 2025 12:09:30 -0500
Subject: [PATCH 01/25] Patch api migration from dual output recording.

---
 app/services/recording-mode.ts                |  17 +-
 .../settings/output/output-settings.ts        | 125 +++-
 app/services/streaming/streaming-api.ts       |   4 +
 app/services/streaming/streaming.ts           | 551 ++++++++++++++++--
 4 files changed, 644 insertions(+), 53 deletions(-)

diff --git a/app/services/recording-mode.ts b/app/services/recording-mode.ts
index fc605b727dc8..e687521dde55 100644
--- a/app/services/recording-mode.ts
+++ b/app/services/recording-mode.ts
@@ -8,7 +8,7 @@ import { ELayout, ELayoutElement, LayoutService } from './layout';
 import { ScenesService } from './scenes';
 import { EObsSimpleEncoder, SettingsService } from './settings';
 import { AnchorPoint, ScalableRectangle } from 'util/ScalableRectangle';
-import { VideoSettingsService } from './settings-v2/video';
+import { TDisplayType, VideoSettingsService } from './settings-v2/video';
 import { ENotificationType, NotificationsService } from 'services/notifications';
 import { DefaultHardwareService } from './hardware';
 import { RunInLoadingMode } from './app/app-decorators';
@@ -18,6 +18,7 @@ import { NavigationService, UsageStatisticsService, SharedStorageService } from
 import { getPlatformService } from 'services/platforms';
 import { IYoutubeUploadResponse } from 'services/platforms/youtube/uploader';
 import { YoutubeService } from 'services/platforms/youtube';
+import { capitalize } from 'lodash';
 
 export interface IRecordingEntry {
   timestamp: string;
@@ -180,12 +181,22 @@ export class RecordingModeService extends PersistentStatefulService<IRecordingMo
     }, 10 * 1000);
   }
 
-  addRecordingEntry(filename: string) {
+  /**
+   * Add entry to recording history and show notification
+   * @param filename - name of file to show in recording history
+   * @param showNotification - primarily used when recording in dual output mode to only show the notification once
+   */
+  addRecordingEntry(filename: string, display?: TDisplayType) {
     const timestamp = moment().format();
     this.ADD_RECORDING_ENTRY(timestamp, filename);
+
+    const message = display
+      ? $t(`A new ${capitalize(display)} Recording has been completed. Click for more info`)
+      : $t('A new Recording has been completed. Click for more info');
+
     this.notificationsService.actions.push({
       type: ENotificationType.SUCCESS,
-      message: $t('A new Recording has been completed. Click for more info'),
+      message,
       action: this.jsonrpcService.createRequest(
         Service.getResourceId(this),
         'showRecordingHistory',
diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts
index 1b446463e78a..517cb19db660 100644
--- a/app/services/settings/output/output-settings.ts
+++ b/app/services/settings/output/output-settings.ts
@@ -5,7 +5,7 @@ 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 { ERecordingQuality, ERecordingFormat } from 'obs-studio-node';
 
 /**
  * list of encoders for simple mode
@@ -230,6 +230,110 @@ export class OutputSettingsService extends Service {
     };
   }
 
+  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;
+
+    const oldQualityName = this.settingsService.findSettingValue(output, 'Recording', 'RecQuality');
+    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 +588,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 9baec256eb06..a03010d67696 100644
--- a/app/services/streaming/streaming-api.ts
+++ b/app/services/streaming/streaming-api.ts
@@ -97,9 +97,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.ts b/app/services/streaming/streaming.ts
index eebc89536b12..b2020bd600e0 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1,6 +1,18 @@
 import Vue from 'vue';
 import { mutation, StatefulService } from 'services/core/stateful-service';
-import { EOutputCode, Global, NodeObs } from '../../../obs-api';
+import {
+  AdvancedRecordingFactory,
+  AudioEncoderFactory,
+  AudioTrackFactory,
+  EOutputCode,
+  Global,
+  IAdvancedRecording,
+  ISimpleRecording,
+  NodeObs,
+  SimpleRecordingFactory,
+  VideoEncoderFactory,
+  EOutputSignal,
+} from '../../../obs-api';
 import { Inject } from 'services/core/injector';
 import moment from 'moment';
 import padStart from 'lodash/padStart';
@@ -55,7 +67,6 @@ import { MarkersService } from 'services/markers';
 import { byOS, OS } from 'util/operating-systems';
 import { DualOutputService } from 'services/dual-output';
 import { capitalize } from 'lodash';
-import { TikTokService } from 'services/platforms/tiktok';
 import { YoutubeService } from 'app-services';
 
 enum EOBSOutputType {
@@ -76,6 +87,12 @@ enum EOBSOutputSignal {
   WriteError = 'writing_error',
 }
 
+enum EOutputSignalState {
+  Start = 'start',
+  Stop = 'stop',
+  Stopping = 'stopping',
+  Wrote = 'wrote',
+}
 export interface IOBSOutputSignalInfo {
   type: EOBSOutputType;
   signal: EOBSOutputSignal;
@@ -101,7 +118,6 @@ export class StreamingService
   @Inject() private videoSettingsService: VideoSettingsService;
   @Inject() private markersService: MarkersService;
   @Inject() private dualOutputService: DualOutputService;
-  @Inject() private tikTokService: TikTokService;
   @Inject() private youtubeService: YoutubeService;
 
   streamingStatusChange = new Subject<EStreamingState>();
@@ -115,17 +131,25 @@ export class StreamingService
 
   // Dummy subscription for stream deck
   streamingStateChange = new Subject<void>();
+  private recordingStopped = new Subject();
 
   powerSaveId: number;
 
   private resolveStartStreaming: Function = () => {};
   private rejectStartStreaming: Function = () => {};
 
+  private horizontalRecording: ISimpleRecording | IAdvancedRecording = null;
+  private verticalRecording: ISimpleRecording | IAdvancedRecording = 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,
@@ -155,7 +179,7 @@ export class StreamingService
   init() {
     NodeObs.OBS_service_connectOutputSignals((info: IOBSOutputSignalInfo) => {
       this.signalInfoChanged.next(info);
-      this.handleOBSOutputSignal(info);
+      this.handleOBSV2OutputSignal(info);
     });
 
     // watch for StreamInfoView at emit `streamInfoChanged` event if something has been hanged there
@@ -214,12 +238,12 @@ export class StreamingService
         } catch (e: unknown) {
           // cast all PLATFORM_REQUEST_FAILED errors to PREPOPULATE_FAILED
           if (e instanceof StreamError) {
-            const type =
+            e.type =
               (e.type as TStreamErrorType) === 'PLATFORM_REQUEST_FAILED'
                 ? 'PREPOPULATE_FAILED'
                 : e.type || 'UNKNOWN_ERROR';
-            const error = this.handleTypedStreamError(e, type, `Failed to prepopulate ${platform}`);
-            this.setError(error, platform);
+
+            this.setError(e, platform);
           } else {
             this.setError('PREPOPULATE_FAILED', platform);
           }
@@ -262,34 +286,6 @@ export class StreamingService
     return false;
   }
 
-  /**
-   * set platform stream settings
-   */
-  async handleSetupPlatform(
-    platform: TPlatform,
-    settings: IGoLiveSettings,
-    unattendedMode: boolean,
-    assignContext: boolean = false,
-  ) {
-    const service = getPlatformService(platform);
-    try {
-      // don't update settings for twitch in unattendedMode
-      const settingsForPlatform =
-        !assignContext && platform === 'twitch' && unattendedMode ? undefined : settings;
-
-      if (assignContext) {
-        const display = settings.platforms[platform]?.display;
-        await this.runCheck(platform, () => service.beforeGoLive(settingsForPlatform, display));
-      } else {
-        await this.runCheck(platform, () =>
-          service.beforeGoLive(settingsForPlatform, 'horizontal'),
-        );
-      }
-    } catch (e: unknown) {
-      this.handleSetupPlatformError(e, platform);
-    }
-  }
-
   /**
    * Make a transition to Live
    */
@@ -928,10 +924,11 @@ 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
 
+      // stream horizontal and stream vertical
       const horizontalContext = this.videoSettingsService.contexts.horizontal;
       const verticalContext = this.videoSettingsService.contexts.vertical;
 
@@ -943,10 +940,21 @@ export class StreamingService
           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');
+
+            // 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);
+            }
+
             signalChanged.unsubscribe();
           }
         }
@@ -961,12 +969,12 @@ export class StreamingService
       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;
@@ -1050,6 +1058,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();
             }
           },
@@ -1063,7 +1075,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();
       }
 
@@ -1082,8 +1097,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);
@@ -1107,15 +1129,207 @@ export class StreamingService
   }
 
   toggleRecording() {
-    if (this.state.recordingStatus === ERecordingState.Recording) {
-      NodeObs.OBS_service_stopRecording();
+    // stop recording
+    if (
+      this.state.recordingStatus === ERecordingState.Recording &&
+      this.state.verticalRecordingStatus === ERecordingState.Recording
+    ) {
+      // stop recroding 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.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
+        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 as any)[key] = (settings as any)[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 as IAdvancedRecording).outputWidth = this.dualOutputService.views.videoSettings[
+        display
+      ].outputWidth;
+      (recording as IAdvancedRecording).outputHeight = this.dualOutputService.views.videoSettings[
+        display
+      ].outputHeight;
+
+      const trackName = `track${index}`;
+      const track = AudioTrackFactory.create(160, trackName);
+      AudioTrackFactory.setAtIndex(track, index);
+    } else {
+      (recording as ISimpleRecording).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<ERecordingState>)[info.signal];
+
+    // We received a signal we didn't recognize
+    if (!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 (this.state.recordingStatus === ERecordingState.Offline) {
-      NodeObs.OBS_service_startRecording();
+    if (nextState === ERecordingState.Wrote) {
+      const fileName =
+        display === 'vertical'
+          ? this.verticalRecording.lastFile()
+          : this.horizontalRecording.lastFile();
+
+      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.handleV2OutputCode(info);
       return;
     }
+
+    const time = new Date().toISOString();
+    this.SET_RECORDING_STATUS(nextState, time, display);
+    this.recordingStatusChange.next(nextState);
+
+    this.handleV2OutputCode(info);
   }
 
   splitFile() {
@@ -1225,6 +1439,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));
   }
 
@@ -1272,6 +1493,223 @@ export class StreamingService
   private streamErrorUserMessage = '';
   private streamErrorReportMessage = '';
 
+  private handleOBSV2OutputSignal(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 && isVerticalDisplayStartSignal) ||
+      (this.views.isDualOutputMode && info.signal === EOBSOutputSignal.Start);
+
+    const time = new Date().toISOString();
+
+    if (info.type === EOBSOutputType.Streaming) {
+      if (info.signal === EOBSOutputSignal.Start && shouldResolve) {
+        this.SET_STREAMING_STATUS(EStreamingState.Live, time);
+        this.resolveStartStreaming();
+        this.streamingStatusChange.next(EStreamingState.Live);
+
+        let streamEncoderInfo: Partial<IOutputSettings> = {};
+        let game: string = '';
+
+        try {
+          streamEncoderInfo = this.outputSettingsService.getSettings();
+          game = this.views.game;
+        } catch (e: unknown) {
+          console.error('Error fetching stream encoder info: ', e);
+        }
+
+        const eventMetadata: Dictionary<any> = {
+          ...streamEncoderInfo,
+          game,
+        };
+
+        if (this.videoEncodingOptimizationService.state.useOptimizedProfile) {
+          eventMetadata.useOptimizedProfile = true;
+        }
+
+        const streamSettings = this.streamSettingsService.settings;
+
+        eventMetadata.streamType = streamSettings.streamType;
+        eventMetadata.platform = streamSettings.platform;
+        eventMetadata.server = streamSettings.server;
+
+        this.usageStatisticsService.recordEvent('stream_start', eventMetadata);
+        this.usageStatisticsService.recordAnalyticsEvent('StreamingStatus', {
+          code: info.code,
+          status: EStreamingState.Live,
+          service: streamSettings.service,
+        });
+        this.usageStatisticsService.recordFeatureUsage('Streaming');
+      } else if (info.signal === EOBSOutputSignal.Starting && shouldResolve) {
+        this.SET_STREAMING_STATUS(EStreamingState.Starting, time);
+        this.streamingStatusChange.next(EStreamingState.Starting);
+      } else if (info.signal === EOBSOutputSignal.Stop) {
+        this.SET_STREAMING_STATUS(EStreamingState.Offline, time);
+        this.RESET_STREAM_INFO();
+        this.rejectStartStreaming();
+        this.streamingStatusChange.next(EStreamingState.Offline);
+        this.usageStatisticsService.recordAnalyticsEvent('StreamingStatus', {
+          code: info.code,
+          status: EStreamingState.Offline,
+        });
+      } else if (info.signal === EOBSOutputSignal.Stopping) {
+        this.sendStreamEndEvent();
+        this.SET_STREAMING_STATUS(EStreamingState.Ending, time);
+        this.streamingStatusChange.next(EStreamingState.Ending);
+      } else if (info.signal === EOBSOutputSignal.Reconnect) {
+        this.SET_STREAMING_STATUS(EStreamingState.Reconnecting);
+        this.streamingStatusChange.next(EStreamingState.Reconnecting);
+        this.sendReconnectingNotification();
+      } else if (info.signal === EOBSOutputSignal.ReconnectSuccess) {
+        this.SET_STREAMING_STATUS(EStreamingState.Live);
+        this.streamingStatusChange.next(EStreamingState.Live);
+        this.clearReconnectingNotification();
+      }
+    } else if (info.type === EOBSOutputType.ReplayBuffer) {
+      const nextState: EReplayBufferState = ({
+        [EOBSOutputSignal.Start]: EReplayBufferState.Running,
+        [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping,
+        [EOBSOutputSignal.Stop]: EReplayBufferState.Offline,
+        [EOBSOutputSignal.Wrote]: EReplayBufferState.Running,
+        [EOBSOutputSignal.WriteError]: EReplayBufferState.Running,
+      } as Dictionary<EReplayBufferState>)[info.signal];
+
+      if (nextState) {
+        this.SET_REPLAY_BUFFER_STATUS(nextState, time);
+        this.replayBufferStatusChange.next(nextState);
+      }
+
+      if (info.signal === EOBSOutputSignal.Wrote) {
+        this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', {
+          status: 'wrote',
+          code: info.code,
+        });
+        this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay());
+      }
+    }
+    this.handleV2OutputCode(info);
+  }
+
+  private handleV2OutputCode(info: IOBSOutputSignalInfo | EOutputSignal) {
+    if (info.code) {
+      if (this.outputErrorOpen) {
+        console.warn('Not showing error message because existing window is open.', info);
+        return;
+      }
+
+      let errorText = '';
+      let extendedErrorText = '';
+      let linkToDriverInfo = false;
+      let showNativeErrorMessage = false;
+
+      if (info.code === EOutputCode.BadPath) {
+        errorText = $t(
+          'Invalid Path or Connection URL.  Please check your settings to confirm that they are valid.',
+        );
+      } else if (info.code === EOutputCode.ConnectFailed) {
+        errorText = $t(
+          'Failed to connect to the streaming server.  Please check your internet connection.',
+        );
+      } else if (info.code === EOutputCode.Disconnected) {
+        errorText = $t(
+          'Disconnected from the streaming server.  Please check your internet connection.',
+        );
+      } else if (info.code === EOutputCode.InvalidStream) {
+        errorText = $t(
+          'Could not access the specified channel or stream key. Please log out and back in to refresh your credentials. If the problem persists, there may be a problem connecting to the server.',
+        );
+      } else if (info.code === EOutputCode.NoSpace) {
+        errorText = $t('There is not sufficient disk space to continue recording.');
+      } else if (info.code === EOutputCode.Unsupported) {
+        errorText =
+          $t(
+            'The output format is either unsupported or does not support more than one audio track.  ',
+          ) + $t('Please check your settings and try again.');
+      } else if (info.code === EOutputCode.OutdatedDriver) {
+        linkToDriverInfo = true;
+        errorText = $t(
+          'An error occurred with the output. This is usually caused by out of date video drivers. Please ensure your Nvidia or AMD drivers are up to date and try again.',
+        );
+      } else {
+        // -4 is used for generic unknown messages in OBS. Both -4 and any other code
+        // we don't recognize should fall into this branch and show a generic error.
+        errorText = $t(
+          'An error occurred with the output. Please check your streaming and recording settings.',
+        );
+        if (info.error) {
+          showNativeErrorMessage = true;
+          extendedErrorText = errorText + '\n\n' + $t('System error message:') + info.error + '"';
+        }
+      }
+      const buttons = [$t('OK')];
+
+      const title = {
+        [EOBSOutputType.Streaming]: $t('Streaming Error'),
+        [EOBSOutputType.Recording]: $t('Recording Error'),
+        [EOBSOutputType.ReplayBuffer]: $t('Replay Buffer Error'),
+      }[info.type];
+
+      if (linkToDriverInfo) buttons.push($t('Learn More'));
+      if (showNativeErrorMessage) buttons.push($t('More'));
+
+      this.outputErrorOpen = true;
+      const errorType = 'error';
+      remote.dialog
+        .showMessageBox(Utils.getMainWindow(), {
+          buttons,
+          title,
+          type: errorType,
+          message: errorText,
+        })
+        .then(({ response }) => {
+          if (linkToDriverInfo && response === 1) {
+            this.outputErrorOpen = false;
+            remote.shell.openExternal(
+              'https://howto.streamlabs.com/streamlabs-obs-19/nvidia-graphics-driver-clean-install-tutorial-7000',
+            );
+          } else {
+            let expectedResponse = 1;
+            if (linkToDriverInfo) {
+              expectedResponse = 2;
+            }
+            if (showNativeErrorMessage && response === expectedResponse) {
+              const buttons = [$t('OK')];
+              remote.dialog
+                .showMessageBox({
+                  buttons,
+                  title,
+                  type: errorType,
+                  message: extendedErrorText,
+                })
+                .then(({ response }) => {
+                  this.outputErrorOpen = false;
+                })
+                .catch(() => {
+                  this.outputErrorOpen = false;
+                });
+            } else {
+              this.outputErrorOpen = false;
+            }
+          }
+        })
+        .catch(() => {
+          this.outputErrorOpen = false;
+        });
+      this.windowsService.actions.closeChildWindow();
+    }
+  }
+
   private handleOBSOutputSignal(info: IOBSOutputSignalInfo) {
     console.debug('OBS Output signal: ', info);
 
@@ -1643,9 +2081,24 @@ export class StreamingService
   }
 
   @mutation()
-  private SET_RECORDING_STATUS(status: ERecordingState, time: string) {
-    this.state.recordingStatus = status;
-    this.state.recordingStatusTime = time;
+  private SET_VERTICAL_STREAMING_STATUS(status: EStreamingState, time?: string) {
+    this.state.streamingStatus = status;
+    if (time) this.state.streamingStatusTime = time;
+  }
+
+  @mutation()
+  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()

From 7441051b1fe8c7cfc195ac31f8da4bf6662d45c1 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 4 Mar 2025 17:37:32 -0500
Subject: [PATCH 02/25] WIP: Migrate replay buffer.

---
 .../api/external-api/streaming/streaming.ts   |   6 +
 app/services/streaming/streaming-api.ts       |   2 +
 app/services/streaming/streaming.ts           | 218 ++++++++++++++++--
 3 files changed, 209 insertions(+), 17 deletions(-)

diff --git a/app/services/api/external-api/streaming/streaming.ts b/app/services/api/external-api/streaming/streaming.ts
index b1d8623d67f8..42d2c33ee163 100644
--- a/app/services/api/external-api/streaming/streaming.ts
+++ b/app/services/api/external-api/streaming/streaming.ts
@@ -23,6 +23,7 @@ enum ERecordingState {
   Starting = 'starting',
   Recording = 'recording',
   Stopping = 'stopping',
+  Start = 'start',
   Wrote = 'wrote',
 }
 
@@ -34,6 +35,7 @@ enum EReplayBufferState {
   Stopping = 'stopping',
   Offline = 'offline',
   Saving = 'saving',
+  Wrote = 'wrote',
 }
 
 /**
@@ -42,9 +44,13 @@ enum EReplayBufferState {
  */
 interface IStreamingState {
   streamingStatus: EStreamingState;
+  verticalStreamingStatus: EStreamingState;
   streamingStatusTime: string;
+  verticalStreamingStatusTime: string;
   recordingStatus: ERecordingState;
+  verticalRecordingStatus: ERecordingState;
   recordingStatusTime: string;
+  verticalRecordingStatusTime: string;
   replayBufferStatus: EReplayBufferState;
   replayBufferStatusTime: string;
   streamErrorCreated?: string;
diff --git a/app/services/streaming/streaming-api.ts b/app/services/streaming/streaming-api.ts
index a03010d67696..d02f29d11809 100644
--- a/app/services/streaming/streaming-api.ts
+++ b/app/services/streaming/streaming-api.ts
@@ -26,6 +26,7 @@ export enum ERecordingState {
   Starting = 'starting',
   Recording = 'recording',
   Stopping = 'stopping',
+  Start = 'start',
   Wrote = 'wrote',
 }
 
@@ -34,6 +35,7 @@ export enum EReplayBufferState {
   Stopping = 'stopping',
   Offline = 'offline',
   Saving = 'saving',
+  Wrote = 'wrote',
 }
 
 export interface IStreamInfo {
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index b2020bd600e0..ed08653954cb 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -12,6 +12,10 @@ import {
   SimpleRecordingFactory,
   VideoEncoderFactory,
   EOutputSignal,
+  SimpleReplayBufferFactory,
+  ISimpleReplayBuffer,
+  AdvancedReplayBufferFactory,
+  IAdvancedReplayBuffer,
 } from '../../../obs-api';
 import { Inject } from 'services/core/injector';
 import moment from 'moment';
@@ -66,8 +70,9 @@ 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 { capitalize } from 'lodash';
+import { capitalize, isFunction } from 'lodash';
 import { YoutubeService } from 'app-services';
+import path from 'path';
 
 enum EOBSOutputType {
   Streaming = 'streaming',
@@ -80,18 +85,29 @@ enum EOBSOutputSignal {
   Start = 'start',
   Stopping = 'stopping',
   Stop = 'stop',
+  Activate = 'activate',
   Deactivate = 'deactivate',
   Reconnect = 'reconnect',
   ReconnectSuccess = 'reconnect_success',
   Wrote = 'wrote',
+  Writing = 'writing',
   WriteError = 'writing_error',
 }
 
 enum EOutputSignalState {
+  Saving = 'saving',
+  Starting = 'starting',
   Start = 'start',
-  Stop = 'stop',
   Stopping = 'stopping',
+  Stop = 'stop',
+  Activate = 'activate',
+  Deactivate = 'deactivate',
+  Reconnect = 'reconnect',
+  ReconnectSuccess = 'reconnect_success',
+  Running = 'running',
   Wrote = 'wrote',
+  Writing = 'writing',
+  WriteError = 'writing_error',
 }
 export interface IOBSOutputSignalInfo {
   type: EOBSOutputType;
@@ -141,6 +157,9 @@ export class StreamingService
   private horizontalRecording: ISimpleRecording | IAdvancedRecording = null;
   private verticalRecording: ISimpleRecording | IAdvancedRecording = null;
 
+  private horizontalReplayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer = null;
+  private verticalReplayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer = null;
+
   static initialState: IStreamingServiceState = {
     streamingStatus: EStreamingState.Offline,
     verticalStreamingStatus: EStreamingState.Offline,
@@ -1134,7 +1153,7 @@ export class StreamingService
       this.state.recordingStatus === ERecordingState.Recording &&
       this.state.verticalRecordingStatus === ERecordingState.Recording
     ) {
-      // stop recroding both displays
+      // stop recording both displays
       let time = new Date().toISOString();
 
       if (this.verticalRecording !== null) {
@@ -1227,7 +1246,8 @@ export class StreamingService
 
     // set signal handler
     recording.signalHandler = async signal => {
-      await this.handleRecordingSignal(signal, display);
+      console.log('recording signal', signal);
+      await this.handleSignal(signal, display);
     };
 
     // handle unique properties (including audio)
@@ -1264,6 +1284,7 @@ export class StreamingService
       [EOutputSignalState.Stopping]: ERecordingState.Stopping,
       [EOutputSignalState.Wrote]: ERecordingState.Wrote,
     } as Dictionary<ERecordingState>)[info.signal];
+    console.log('handleRecordingSignal nextState', nextState, 'signal', info.signal);
 
     // We received a signal we didn't recognize
     if (!nextState) return;
@@ -1338,26 +1359,189 @@ export class StreamingService
     }
   }
 
-  startReplayBuffer() {
+  private async handleSignal(info: EOutputSignal, display: TDisplayType) {
+    if (info.type === EOBSOutputType.Recording) {
+      await this.handleRecordingSignal(info, display);
+      return;
+    }
+
+    if (info.type === EOBSOutputType.ReplayBuffer) {
+      this.handleReplayBufferSignal(info, display);
+      return;
+    }
+
+    if (info.type === EOBSOutputType.Streaming) {
+      this.handleStreamingSignal(info, display);
+      return;
+    }
+  }
+
+  private handleReplayBufferSignal(info: EOutputSignal, display: TDisplayType) {
+    // map signals to status
+    const nextState: EReplayBufferState = ({
+      [EOBSOutputSignal.Start]: EReplayBufferState.Running,
+      [EOBSOutputSignal.Writing]: EReplayBufferState.Saving,
+      [EOBSOutputSignal.Wrote]: EReplayBufferState.Running,
+      [EOBSOutputSignal.Stop]: EReplayBufferState.Offline,
+    } as Dictionary<EReplayBufferState>)[info.signal];
+    console.log('handleReplayBufferSignal nextState', nextState, 'signal', info.signal);
+
+    if (nextState) {
+      const time = new Date().toISOString();
+      this.SET_REPLAY_BUFFER_STATUS(nextState, time);
+      this.replayBufferStatusChange.next(nextState);
+    }
+
+    if (info.signal === EOBSOutputSignal.Wrote) {
+      this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', {
+        status: 'wrote',
+        code: info.code,
+      });
+      this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay());
+    }
+  }
+
+  // TODO migrate streaming to new API
+  private handleStreamingSignal(info: EOutputSignal, display: TDisplayType) {
+    // map signals to status
+  }
+
+  startReplayBuffer(display: TDisplayType = 'horizontal') {
+    if (this.state.replayBufferStatus !== EReplayBufferState.Offline) return;
+
+    const mode = this.outputSettingsService.getSettings().mode;
+    if (!this.horizontalRecording) return;
+
     if (this.state.replayBufferStatus !== EReplayBufferState.Offline) return;
 
-    this.usageStatisticsService.recordFeatureUsage('ReplayBuffer');
-    NodeObs.OBS_service_startReplayBuffer();
+    if (this.horizontalReplayBuffer) {
+      if (mode === 'Advanced') {
+        AdvancedReplayBufferFactory.destroy(this.horizontalReplayBuffer as IAdvancedReplayBuffer);
+      } else {
+        SimpleReplayBufferFactory.destroy(this.horizontalReplayBuffer as ISimpleReplayBuffer);
+      }
+    }
+
+    if (mode === 'Advanced') {
+      this.horizontalReplayBuffer = AdvancedReplayBufferFactory.create();
+    } else {
+      const replayBuffer = SimpleReplayBufferFactory.create();
+      const recordingSettings = this.outputSettingsService.getSimpleRecordingSettings();
+
+      // const advancedSettings = this.outputSettingsService.getAdvancedRecordingSettings();
+      // console.log('recordingSettings', recordingSettings);
+      // console.log('advancedSettings', advancedSettings);
+      // recordingSettings {
+      //   path: 'C:\\Users\\miche\\Videos',
+      //   format: 'mp4',
+      //   quality: 1,
+      //   encoder: 'jim_nvenc',
+      //   lowCPU: false,
+      //   overwrite: false,
+      //   noSpace: false
+      // }
+      // advancedSettings {
+      //   path: undefined,
+      //   format: 'mp4',
+      //   encoder: 'jim_nvenc',
+      //   overwrite: false,
+      //   noSpace: undefined,
+      //   rescaling: undefined,
+      //   mixer: undefined,
+      //   useStreamEncoders: false
+      // }
+
+      replayBuffer.path = recordingSettings.path;
+      replayBuffer.format = recordingSettings.format;
+      replayBuffer.overwrite = recordingSettings.overwrite;
+      replayBuffer.noSpace = recordingSettings.noSpace;
+      replayBuffer.video = this.videoSettingsService.contexts[display]; // TODO: Add vertical context
+      replayBuffer.duration = 60;
+      replayBuffer.prefix = 'Prefix';
+      replayBuffer.suffix = 'Suffix';
+      replayBuffer.usesStream = true;
+      replayBuffer.recording = this.horizontalRecording as ISimpleRecording;
+      replayBuffer.signalHandler = async signal => {
+        console.log('replay buffer signal', signal);
+        await this.handleSignal(signal, 'horizontal');
+      };
+
+      this.horizontalReplayBuffer = replayBuffer;
+      this.horizontalReplayBuffer.start();
+      this.usageStatisticsService.recordFeatureUsage('ReplayBuffer');
+    }
   }
 
-  stopReplayBuffer() {
-    if (this.state.replayBufferStatus === EReplayBufferState.Running) {
-      NodeObs.OBS_service_stopReplayBuffer(false);
-    } else if (this.state.replayBufferStatus === EReplayBufferState.Stopping) {
-      NodeObs.OBS_service_stopReplayBuffer(true);
+  stopReplayBuffer(display: TDisplayType = 'horizontal') {
+    if (this.state.replayBufferStatus === EReplayBufferState.Offline) return;
+    const forceStop = this.state.replayBufferStatus === EReplayBufferState.Stopping;
+    console.log('stopReplayBuffer mode', this.outputSettingsService.getSettings().mode);
+    console.log(
+      'stopReplayBuffer before destroy this.horizontalReplayBuffer',
+      this.horizontalReplayBuffer,
+    );
+    console.log(
+      'stopReplayBuffer before destroy this.verticalReplayBuffer',
+      this.verticalReplayBuffer,
+    );
+
+    if (this.outputSettingsService.getSettings().mode === 'Advanced') {
+      if (display === 'horizontal' && this.horizontalReplayBuffer) {
+        // TODO: save before stopping
+        // this.horizontalReplayBuffer.save();
+        this.horizontalReplayBuffer.stop(forceStop);
+        AdvancedReplayBufferFactory.destroy(this.horizontalReplayBuffer as IAdvancedReplayBuffer);
+        this.horizontalReplayBuffer = null;
+      }
+
+      if (display === 'vertical' && this.verticalReplayBuffer) {
+        // TODO: save before stopping
+        // this.verticalReplayBuffer.save();
+        this.verticalReplayBuffer.stop(forceStop);
+        AdvancedReplayBufferFactory.destroy(this.verticalReplayBuffer as IAdvancedReplayBuffer);
+        this.verticalReplayBuffer = null;
+      }
+    } else {
+      if (display === 'horizontal' && this.horizontalReplayBuffer) {
+        // TODO: save before stopping
+        // this.horizontalReplayBuffer.save();
+        this.horizontalReplayBuffer.stop(forceStop);
+        SimpleReplayBufferFactory.destroy(this.horizontalReplayBuffer as ISimpleReplayBuffer);
+        this.horizontalReplayBuffer = null;
+      }
+
+      if (display === 'vertical' && this.verticalReplayBuffer) {
+        // TODO: save before stopping
+        // this.horizontalReplayBuffer.save();
+        this.verticalReplayBuffer.stop(forceStop);
+        SimpleReplayBufferFactory.destroy(this.verticalReplayBuffer as ISimpleReplayBuffer);
+        this.verticalReplayBuffer = null;
+      }
     }
+
+    console.log(
+      'stopReplayBuffer after destroy this.horizontalReplayBuffer',
+      this.horizontalReplayBuffer,
+    );
+    console.log(
+      'stopReplayBuffer after destroy this.verticalReplayBuffer',
+      this.verticalReplayBuffer,
+    );
+
+    // TODO: Determine why replay buffer stop signals are missing
+    // temporarily set replay buffer status to offline
+    this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Offline);
   }
 
-  saveReplay() {
-    if (this.state.replayBufferStatus === EReplayBufferState.Running) {
-      this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Saving);
-      this.replayBufferStatusChange.next(EReplayBufferState.Saving);
-      NodeObs.OBS_service_processReplayBufferHotkey();
+  saveReplay(display: TDisplayType = 'horizontal') {
+    if (display === 'horizontal' && this.horizontalReplayBuffer) {
+      this.horizontalReplayBuffer.save();
+      return;
+    }
+
+    if (display === 'vertical' && this.verticalReplayBuffer) {
+      this.verticalReplayBuffer.save();
+      return;
     }
   }
 

From f4f12501698ae684464b7bd99dfbc4bb2a48d3ed Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Wed, 5 Mar 2025 11:05:39 -0500
Subject: [PATCH 03/25] Fix replay buffer signals.

---
 app/components-react/root/StudioFooter.tsx    |  2 +-
 .../settings/output/output-settings.ts        | 25 +++++
 app/services/streaming/streaming.ts           | 99 +++++++++++--------
 3 files changed, 82 insertions(+), 44 deletions(-)

diff --git a/app/components-react/root/StudioFooter.tsx b/app/components-react/root/StudioFooter.tsx
index 3f57fe081afc..a54d47b5f941 100644
--- a/app/components-react/root/StudioFooter.tsx
+++ b/app/components-react/root/StudioFooter.tsx
@@ -46,7 +46,7 @@ export default function StudioFooterComponent() {
     replayBufferStopping: StreamingService.state.replayBufferStatus === EReplayBufferState.Stopping,
     replayBufferSaving: StreamingService.state.replayBufferStatus === EReplayBufferState.Saving,
     recordingModeEnabled: RecordingModeService.views.isRecordingModeEnabled,
-    replayBufferEnabled: SettingsService.views.values.Output.RecRB,
+    replayBufferEnabled: StreamingService.state.recordingStatus !== ERecordingState.Offline,
   }));
 
   function performanceIconClassName() {
diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts
index 517cb19db660..e910d0664b90 100644
--- a/app/services/settings/output/output-settings.ts
+++ b/app/services/settings/output/output-settings.ts
@@ -282,6 +282,22 @@ export class OutputSettingsService extends Service {
       'FileNameWithoutSpace',
     );
 
+    const prefix: string = this.settingsService.findSettingValue(
+      output,
+      'Recording',
+      'RecRBPrefix',
+    );
+    const suffix: string = this.settingsService.findSettingValue(
+      output,
+      'Recording',
+      'RecRBSuffix',
+    );
+    const duration: number = this.settingsService.findSettingValue(
+      output,
+      'Stream Delay',
+      'DelaySec',
+    );
+
     return {
       path,
       format,
@@ -290,6 +306,9 @@ export class OutputSettingsService extends Service {
       lowCPU,
       overwrite,
       noSpace,
+      prefix,
+      suffix,
+      duration,
     };
   }
 
@@ -301,6 +320,9 @@ export class OutputSettingsService extends Service {
     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 prefix = this.settingsService.findSettingValue(output, 'Recording', 'RecRBPrefix');
+    const suffix = this.settingsService.findSettingValue(output, 'Recording', 'RecRBSuffix');
+    const duration = this.settingsService.findSettingValue(output, 'Stream Delay', 'DelaySec');
     const useStreamEncoders =
       this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder') === 'none';
 
@@ -330,6 +352,9 @@ export class OutputSettingsService extends Service {
       noSpace,
       rescaling,
       mixer,
+      prefix,
+      suffix,
+      duration,
       useStreamEncoders,
     };
   }
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index ed08653954cb..592064d2f998 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1232,6 +1232,8 @@ export class StreamingService
         ? this.outputSettingsService.getAdvancedRecordingSettings()
         : this.outputSettingsService.getSimpleRecordingSettings();
 
+    console.log('createRecording advanced settings', settings);
+
     // assign settings
     Object.keys(settings).forEach(key => {
       if (key === 'encoder') {
@@ -1382,6 +1384,7 @@ export class StreamingService
       [EOBSOutputSignal.Start]: EReplayBufferState.Running,
       [EOBSOutputSignal.Writing]: EReplayBufferState.Saving,
       [EOBSOutputSignal.Wrote]: EReplayBufferState.Running,
+      [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping,
       [EOBSOutputSignal.Stop]: EReplayBufferState.Offline,
     } as Dictionary<EReplayBufferState>)[info.signal];
     console.log('handleReplayBufferSignal nextState', nextState, 'signal', info.signal);
@@ -1399,6 +1402,35 @@ export class StreamingService
       });
       this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay());
     }
+
+    if (info.signal === EOBSOutputSignal.Stop) {
+      this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', {
+        status: 'stop',
+        code: info.code,
+      });
+
+      if (this.outputSettingsService.getSettings().mode === 'Advanced') {
+        if (display === 'horizontal' && this.horizontalReplayBuffer) {
+          AdvancedReplayBufferFactory.destroy(this.horizontalReplayBuffer as IAdvancedReplayBuffer);
+          this.horizontalReplayBuffer = null;
+        }
+
+        if (display === 'vertical' && this.verticalReplayBuffer) {
+          AdvancedReplayBufferFactory.destroy(this.verticalReplayBuffer as IAdvancedReplayBuffer);
+          this.verticalReplayBuffer = null;
+        }
+      } else {
+        if (display === 'horizontal' && this.horizontalReplayBuffer) {
+          SimpleReplayBufferFactory.destroy(this.horizontalReplayBuffer as ISimpleReplayBuffer);
+          this.horizontalReplayBuffer = null;
+        }
+
+        if (display === 'vertical' && this.verticalReplayBuffer) {
+          SimpleReplayBufferFactory.destroy(this.verticalReplayBuffer as ISimpleReplayBuffer);
+          this.verticalReplayBuffer = null;
+        }
+      }
+    }
   }
 
   // TODO migrate streaming to new API
@@ -1424,41 +1456,38 @@ export class StreamingService
 
     if (mode === 'Advanced') {
       this.horizontalReplayBuffer = AdvancedReplayBufferFactory.create();
+      const recordingSettings = this.outputSettingsService.getAdvancedRecordingSettings();
+
+      this.horizontalReplayBuffer.path = recordingSettings.path;
+      this.horizontalReplayBuffer.format = recordingSettings.format;
+      this.horizontalReplayBuffer.overwrite = recordingSettings.overwrite;
+      this.horizontalReplayBuffer.noSpace = recordingSettings.noSpace;
+      this.horizontalReplayBuffer.duration = recordingSettings.duration;
+      this.horizontalReplayBuffer.video = this.videoSettingsService.contexts[display];
+      this.horizontalReplayBuffer.prefix = recordingSettings.prefix;
+      this.horizontalReplayBuffer.suffix = recordingSettings.suffix;
+      this.horizontalReplayBuffer.usesStream = recordingSettings.useStreamEncoders;
+      this.horizontalReplayBuffer.mixer = recordingSettings.mixer;
+      this.horizontalReplayBuffer.recording = this.horizontalRecording as IAdvancedRecording;
+      this.horizontalReplayBuffer.signalHandler = async signal => {
+        console.log('replay buffer signal', signal);
+        await this.handleSignal(signal, display);
+      };
+
+      this.horizontalReplayBuffer.start();
+      this.usageStatisticsService.recordFeatureUsage('ReplayBuffer');
     } else {
       const replayBuffer = SimpleReplayBufferFactory.create();
       const recordingSettings = this.outputSettingsService.getSimpleRecordingSettings();
 
-      // const advancedSettings = this.outputSettingsService.getAdvancedRecordingSettings();
-      // console.log('recordingSettings', recordingSettings);
-      // console.log('advancedSettings', advancedSettings);
-      // recordingSettings {
-      //   path: 'C:\\Users\\miche\\Videos',
-      //   format: 'mp4',
-      //   quality: 1,
-      //   encoder: 'jim_nvenc',
-      //   lowCPU: false,
-      //   overwrite: false,
-      //   noSpace: false
-      // }
-      // advancedSettings {
-      //   path: undefined,
-      //   format: 'mp4',
-      //   encoder: 'jim_nvenc',
-      //   overwrite: false,
-      //   noSpace: undefined,
-      //   rescaling: undefined,
-      //   mixer: undefined,
-      //   useStreamEncoders: false
-      // }
-
       replayBuffer.path = recordingSettings.path;
       replayBuffer.format = recordingSettings.format;
       replayBuffer.overwrite = recordingSettings.overwrite;
       replayBuffer.noSpace = recordingSettings.noSpace;
-      replayBuffer.video = this.videoSettingsService.contexts[display]; // TODO: Add vertical context
-      replayBuffer.duration = 60;
-      replayBuffer.prefix = 'Prefix';
-      replayBuffer.suffix = 'Suffix';
+      replayBuffer.video = this.videoSettingsService.contexts[display];
+      replayBuffer.duration = recordingSettings.duration;
+      replayBuffer.prefix = recordingSettings.prefix;
+      replayBuffer.suffix = recordingSettings.suffix;
       replayBuffer.usesStream = true;
       replayBuffer.recording = this.horizontalRecording as ISimpleRecording;
       replayBuffer.signalHandler = async signal => {
@@ -1487,35 +1516,19 @@ export class StreamingService
 
     if (this.outputSettingsService.getSettings().mode === 'Advanced') {
       if (display === 'horizontal' && this.horizontalReplayBuffer) {
-        // TODO: save before stopping
-        // this.horizontalReplayBuffer.save();
         this.horizontalReplayBuffer.stop(forceStop);
-        AdvancedReplayBufferFactory.destroy(this.horizontalReplayBuffer as IAdvancedReplayBuffer);
-        this.horizontalReplayBuffer = null;
       }
 
       if (display === 'vertical' && this.verticalReplayBuffer) {
-        // TODO: save before stopping
-        // this.verticalReplayBuffer.save();
         this.verticalReplayBuffer.stop(forceStop);
-        AdvancedReplayBufferFactory.destroy(this.verticalReplayBuffer as IAdvancedReplayBuffer);
-        this.verticalReplayBuffer = null;
       }
     } else {
       if (display === 'horizontal' && this.horizontalReplayBuffer) {
-        // TODO: save before stopping
-        // this.horizontalReplayBuffer.save();
         this.horizontalReplayBuffer.stop(forceStop);
-        SimpleReplayBufferFactory.destroy(this.horizontalReplayBuffer as ISimpleReplayBuffer);
-        this.horizontalReplayBuffer = null;
       }
 
       if (display === 'vertical' && this.verticalReplayBuffer) {
-        // TODO: save before stopping
-        // this.horizontalReplayBuffer.save();
         this.verticalReplayBuffer.stop(forceStop);
-        SimpleReplayBufferFactory.destroy(this.verticalReplayBuffer as ISimpleReplayBuffer);
-        this.verticalReplayBuffer = null;
       }
     }
 

From 4ce8c632097e7ce3cba824376a968a302a87f296 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Wed, 5 Mar 2025 13:32:02 -0500
Subject: [PATCH 04/25] Refactor to make less DRY and added shutdown for output
 contexts.

---
 app/services/app/app.ts                       |   3 +
 .../settings/output/output-settings.ts        |  10 +-
 app/services/streaming/streaming-api.ts       |  10 +
 app/services/streaming/streaming.ts           | 309 +++++++++++-------
 4 files changed, 218 insertions(+), 114 deletions(-)

diff --git a/app/services/app/app.ts b/app/services/app/app.ts
index 58a0fff6cd2f..b4c40d678e5e 100644
--- a/app/services/app/app.ts
+++ b/app/services/app/app.ts
@@ -44,6 +44,7 @@ import { DualOutputService } from 'services/dual-output';
 import { OS, getOS } from 'util/operating-systems';
 import * as remote from '@electron/remote';
 import { RealmService } from 'services/realm';
+import { StreamingService } from 'services/streaming';
 
 interface IAppState {
   loading: boolean;
@@ -96,6 +97,7 @@ export class AppService extends StatefulService<IAppState> {
   @Inject() private videoSettingsService: VideoSettingsService;
   @Inject() private dualOutputService: DualOutputService;
   @Inject() private realmService: RealmService;
+  @Inject() private streamingService: StreamingService;
 
   static initialState: IAppState = {
     loading: true,
@@ -192,6 +194,7 @@ export class AppService extends StatefulService<IAppState> {
       this.shutdownStarted.next();
       this.keyListenerService.shutdown();
       this.platformAppsService.unloadAllApps();
+      this.streamingService.shutdown();
       await this.usageStatisticsService.flushEvents();
       this.windowsService.shutdown();
       this.ipcServerService.stopListening();
diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts
index e910d0664b90..2aa5bb86a715 100644
--- a/app/services/settings/output/output-settings.ts
+++ b/app/services/settings/output/output-settings.ts
@@ -317,7 +317,6 @@ export class OutputSettingsService extends Service {
     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 prefix = this.settingsService.findSettingValue(output, 'Recording', 'RecRBPrefix');
@@ -326,6 +325,15 @@ export class OutputSettingsService extends Service {
     const useStreamEncoders =
       this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder') === 'none';
 
+    const convertedEncoderName:
+      | EObsSimpleEncoder.x264_lowcpu
+      | EObsAdvancedEncoder = this.convertEncoderToNewAPI(this.getSettings().recording.encoder);
+
+    const encoder: EObsAdvancedEncoder =
+      convertedEncoderName === EObsSimpleEncoder.x264_lowcpu
+        ? EObsAdvancedEncoder.obs_x264
+        : convertedEncoderName;
+
     const format = this.settingsService.findValidListValue(
       output,
       'Recording',
diff --git a/app/services/streaming/streaming-api.ts b/app/services/streaming/streaming-api.ts
index d02f29d11809..d11fcd98517a 100644
--- a/app/services/streaming/streaming-api.ts
+++ b/app/services/streaming/streaming-api.ts
@@ -97,7 +97,17 @@ export interface IPlatformFlags {
   video?: IVideo;
 }
 
+export interface IOutputStatus {
+  streaming: EStreamingState;
+  streamingTime: string;
+  recording: ERecordingState;
+  recordingTime: string;
+  replayBuffer: EReplayBufferState;
+  replayBufferTime: string;
+}
+
 export interface IStreamingServiceState {
+  status: { [display: string]: IOutputStatus };
   streamingStatus: EStreamingState;
   verticalStreamingStatus: EStreamingState;
   streamingStatusTime: string;
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index 592064d2f998..77a323ad49b8 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -16,6 +16,10 @@ import {
   ISimpleReplayBuffer,
   AdvancedReplayBufferFactory,
   IAdvancedReplayBuffer,
+  ISimpleStreaming,
+  IAdvancedStreaming,
+  AdvancedStreamingFactory,
+  SimpleStreamingFactory,
 } from '../../../obs-api';
 import { Inject } from 'services/core/injector';
 import moment from 'moment';
@@ -117,6 +121,12 @@ export interface IOBSOutputSignalInfo {
   service: string; // 'default' | 'vertical'
 }
 
+interface IOutputContext {
+  streaming: ISimpleStreaming | IAdvancedStreaming;
+  recording: ISimpleRecording | IAdvancedRecording;
+  replayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer;
+}
+
 export class StreamingService
   extends StatefulService<IStreamingServiceState>
   implements IStreamingServiceApi {
@@ -154,13 +164,42 @@ export class StreamingService
   private resolveStartStreaming: Function = () => {};
   private rejectStartStreaming: Function = () => {};
 
-  private horizontalRecording: ISimpleRecording | IAdvancedRecording = null;
-  private verticalRecording: ISimpleRecording | IAdvancedRecording = null;
-
-  private horizontalReplayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer = null;
-  private verticalReplayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer = null;
+  private contexts: {
+    [name: string]: IOutputContext;
+    horizontal: IOutputContext;
+    vertical: IOutputContext;
+  } = {
+    horizontal: {
+      streaming: null,
+      recording: null,
+      replayBuffer: null,
+    },
+    vertical: {
+      streaming: null,
+      recording: null,
+      replayBuffer: null,
+    },
+  };
 
   static initialState: IStreamingServiceState = {
+    status: {
+      horizontal: {
+        streaming: EStreamingState.Offline,
+        streamingTime: new Date().toISOString(),
+        recording: ERecordingState.Offline,
+        recordingTime: new Date().toISOString(),
+        replayBuffer: EReplayBufferState.Offline,
+        replayBufferTime: new Date().toISOString(),
+      },
+      vertical: {
+        streaming: EStreamingState.Offline,
+        streamingTime: new Date().toISOString(),
+        recording: ERecordingState.Offline,
+        recordingTime: new Date().toISOString(),
+        replayBuffer: EReplayBufferState.Offline,
+        replayBufferTime: new Date().toISOString(),
+      },
+    },
     streamingStatus: EStreamingState.Offline,
     verticalStreamingStatus: EStreamingState.Offline,
     streamingStatusTime: new Date().toISOString(),
@@ -960,7 +999,7 @@ export class StreamingService
             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) {
+            if (this.state.status.vertical.streaming !== EStreamingState.Offline) {
               this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline);
             }
           }
@@ -970,7 +1009,7 @@ export class StreamingService
 
             // Refactor when move streaming to new API
             const time = new Date().toISOString();
-            if (this.state.verticalStreamingStatus === EStreamingState.Offline) {
+            if (this.state.status.vertical.streaming === EStreamingState.Offline) {
               this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Live, time);
             }
 
@@ -1078,7 +1117,7 @@ export class StreamingService
             ) {
               NodeObs.OBS_service_stopStreaming(false, 'vertical');
               // Refactor when move streaming to new API
-              if (this.state.verticalStreamingStatus !== EStreamingState.Offline) {
+              if (this.state.status.vertical.streaming !== EStreamingState.Offline) {
                 this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline);
               }
               signalChanged.unsubscribe();
@@ -1096,7 +1135,7 @@ export class StreamingService
       const keepRecording = this.streamSettingsService.settings.keepRecordingWhenStreamStops;
       const isRecording =
         this.state.recordingStatus === ERecordingState.Recording ||
-        this.state.verticalRecordingStatus === ERecordingState.Recording;
+        this.state.status.vertical.recording === ERecordingState.Recording;
       if (!keepRecording && isRecording) {
         this.toggleRecording();
       }
@@ -1118,11 +1157,11 @@ export class StreamingService
     if (this.state.streamingStatus === EStreamingState.Ending) {
       if (
         this.views.isDualOutputMode &&
-        this.state.verticalRecordingStatus === ERecordingState.Offline
+        this.state.status.vertical.recording === ERecordingState.Offline
       ) {
         NodeObs.OBS_service_stopStreaming(true, 'horizontal');
         // Refactor when move streaming to new API
-        if (this.state.verticalStreamingStatus !== EStreamingState.Offline) {
+        if (this.state.status.vertical.streaming !== EStreamingState.Offline) {
           this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline);
         }
         NodeObs.OBS_service_stopStreaming(true, 'vertical');
@@ -1151,12 +1190,12 @@ export class StreamingService
     // stop recording
     if (
       this.state.recordingStatus === ERecordingState.Recording &&
-      this.state.verticalRecordingStatus === ERecordingState.Recording
+      this.state.status.vertical.recording === ERecordingState.Recording
     ) {
       // stop recording both displays
       let time = new Date().toISOString();
 
-      if (this.verticalRecording !== null) {
+      if (this.contexts.vertical.recording !== null) {
         const recordingStopped = this.recordingStopped.subscribe(async () => {
           await new Promise(resolve =>
             // sleep for 2 seconds to allow a different time stamp to be generated
@@ -1165,8 +1204,8 @@ export class StreamingService
             setTimeout(() => {
               time = new Date().toISOString();
               this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'horizontal');
-              if (this.horizontalRecording !== null) {
-                this.horizontalRecording.stop();
+              if (this.contexts.horizontal.recording !== null) {
+                this.contexts.horizontal.recording.stop();
               }
             }, 2000),
           );
@@ -1174,33 +1213,33 @@ export class StreamingService
         });
 
         this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'vertical');
-        this.verticalRecording.stop();
+        this.contexts.vertical.recording.stop();
         this.recordingStopped.next();
       }
 
       return;
     } else if (
-      this.state.verticalRecordingStatus === ERecordingState.Recording &&
-      this.verticalRecording !== null
+      this.state.status.vertical.recording === ERecordingState.Recording &&
+      this.contexts.vertical.recording !== null
     ) {
       // stop recording vertical display
       const time = new Date().toISOString();
       this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'vertical');
-      this.verticalRecording.stop();
+      this.contexts.vertical.recording.stop();
     } else if (
       this.state.recordingStatus === ERecordingState.Recording &&
-      this.horizontalRecording !== null
+      this.contexts.horizontal.recording !== null
     ) {
       const time = new Date().toISOString();
       // stop recording horizontal display
       this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'horizontal');
-      this.horizontalRecording.stop();
+      this.contexts.horizontal.recording.stop();
     }
 
     // start recording
     if (
       this.state.recordingStatus === ERecordingState.Offline &&
-      this.state.verticalRecordingStatus === ERecordingState.Offline
+      this.state.status.vertical.recording === ERecordingState.Offline
     ) {
       if (this.views.isDualOutputMode) {
         if (this.state.streamingStatus !== EStreamingState.Offline) {
@@ -1220,6 +1259,8 @@ export class StreamingService
   }
 
   private createRecording(display: TDisplayType, index: number) {
+    this.destroyRecordingContextIfExists(display);
+
     const mode = this.outputSettingsService.getSettings().mode;
 
     const recording =
@@ -1236,6 +1277,9 @@ export class StreamingService
 
     // assign settings
     Object.keys(settings).forEach(key => {
+      console.log('(settings as any)[key]', key, (settings as any)[key]);
+      if ((settings as any)[key] === undefined) return;
+
       if (key === 'encoder') {
         recording.videoEncoder = VideoEncoderFactory.create(settings.encoder, 'video-encoder');
       } else {
@@ -1245,6 +1289,7 @@ export class StreamingService
 
     // assign context
     recording.video = this.videoSettingsService.contexts[display];
+    console.log('assigned context');
 
     // set signal handler
     recording.signalHandler = async signal => {
@@ -1252,6 +1297,8 @@ export class StreamingService
       await this.handleSignal(signal, display);
     };
 
+    console.log('assign signal handler');
+
     // handle unique properties (including audio)
     if (mode === 'Advanced') {
       (recording as IAdvancedRecording).outputWidth = this.dualOutputService.views.videoSettings[
@@ -1264,17 +1311,27 @@ export class StreamingService
       const trackName = `track${index}`;
       const track = AudioTrackFactory.create(160, trackName);
       AudioTrackFactory.setAtIndex(track, index);
+
+      console.log('assigned audio track');
+
+      const videoEncoder = recording.videoEncoder;
+      const stream = AdvancedStreamingFactory.create() as IAdvancedStreaming;
+      stream.video = this.videoSettingsService.contexts[display];
+      stream.videoEncoder = videoEncoder;
+      recording.videoEncoder = videoEncoder;
+      recording.streaming = stream;
     } else {
       (recording as ISimpleRecording).audioEncoder = AudioEncoderFactory.create();
     }
 
     // save in state
     if (display === 'vertical') {
-      this.verticalRecording = recording;
-      this.verticalRecording.start();
+      this.contexts.vertical.recording = recording;
+      this.contexts.vertical.recording.start();
     } else {
-      this.horizontalRecording = recording;
-      this.horizontalRecording.start();
+      this.contexts.horizontal.recording = recording;
+      console.log('this.contexts.horizontal.recording', this.contexts.horizontal.recording);
+      this.contexts.horizontal.recording.start();
     }
   }
 
@@ -1305,8 +1362,8 @@ export class StreamingService
     if (nextState === ERecordingState.Wrote) {
       const fileName =
         display === 'vertical'
-          ? this.verticalRecording.lastFile()
-          : this.horizontalRecording.lastFile();
+          ? this.contexts.vertical.recording.lastFile()
+          : this.contexts.horizontal.recording.lastFile();
 
       const parsedName = byOS({
         [OS.Mac]: fileName,
@@ -1322,23 +1379,7 @@ export class StreamingService
       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;
-        }
-      }
+      this.destroyRecordingContextIfExists(display);
 
       const time = new Date().toISOString();
       this.SET_RECORDING_STATUS(ERecordingState.Offline, time, display);
@@ -1409,27 +1450,7 @@ export class StreamingService
         code: info.code,
       });
 
-      if (this.outputSettingsService.getSettings().mode === 'Advanced') {
-        if (display === 'horizontal' && this.horizontalReplayBuffer) {
-          AdvancedReplayBufferFactory.destroy(this.horizontalReplayBuffer as IAdvancedReplayBuffer);
-          this.horizontalReplayBuffer = null;
-        }
-
-        if (display === 'vertical' && this.verticalReplayBuffer) {
-          AdvancedReplayBufferFactory.destroy(this.verticalReplayBuffer as IAdvancedReplayBuffer);
-          this.verticalReplayBuffer = null;
-        }
-      } else {
-        if (display === 'horizontal' && this.horizontalReplayBuffer) {
-          SimpleReplayBufferFactory.destroy(this.horizontalReplayBuffer as ISimpleReplayBuffer);
-          this.horizontalReplayBuffer = null;
-        }
-
-        if (display === 'vertical' && this.verticalReplayBuffer) {
-          SimpleReplayBufferFactory.destroy(this.verticalReplayBuffer as ISimpleReplayBuffer);
-          this.verticalReplayBuffer = null;
-        }
-      }
+      this.destroyReplayBufferContextIfExists(display);
     }
   }
 
@@ -1442,39 +1463,34 @@ export class StreamingService
     if (this.state.replayBufferStatus !== EReplayBufferState.Offline) return;
 
     const mode = this.outputSettingsService.getSettings().mode;
-    if (!this.horizontalRecording) return;
+    if (!this.contexts.horizontal.recording) return;
 
     if (this.state.replayBufferStatus !== EReplayBufferState.Offline) return;
 
-    if (this.horizontalReplayBuffer) {
-      if (mode === 'Advanced') {
-        AdvancedReplayBufferFactory.destroy(this.horizontalReplayBuffer as IAdvancedReplayBuffer);
-      } else {
-        SimpleReplayBufferFactory.destroy(this.horizontalReplayBuffer as ISimpleReplayBuffer);
-      }
-    }
+    this.destroyReplayBufferContextIfExists(display);
 
     if (mode === 'Advanced') {
-      this.horizontalReplayBuffer = AdvancedReplayBufferFactory.create();
+      this.contexts.horizontal.replayBuffer = AdvancedReplayBufferFactory.create();
       const recordingSettings = this.outputSettingsService.getAdvancedRecordingSettings();
 
-      this.horizontalReplayBuffer.path = recordingSettings.path;
-      this.horizontalReplayBuffer.format = recordingSettings.format;
-      this.horizontalReplayBuffer.overwrite = recordingSettings.overwrite;
-      this.horizontalReplayBuffer.noSpace = recordingSettings.noSpace;
-      this.horizontalReplayBuffer.duration = recordingSettings.duration;
-      this.horizontalReplayBuffer.video = this.videoSettingsService.contexts[display];
-      this.horizontalReplayBuffer.prefix = recordingSettings.prefix;
-      this.horizontalReplayBuffer.suffix = recordingSettings.suffix;
-      this.horizontalReplayBuffer.usesStream = recordingSettings.useStreamEncoders;
-      this.horizontalReplayBuffer.mixer = recordingSettings.mixer;
-      this.horizontalReplayBuffer.recording = this.horizontalRecording as IAdvancedRecording;
-      this.horizontalReplayBuffer.signalHandler = async signal => {
+      this.contexts.horizontal.replayBuffer.path = recordingSettings.path;
+      this.contexts.horizontal.replayBuffer.format = recordingSettings.format;
+      this.contexts.horizontal.replayBuffer.overwrite = recordingSettings.overwrite;
+      this.contexts.horizontal.replayBuffer.noSpace = recordingSettings.noSpace;
+      this.contexts.horizontal.replayBuffer.duration = recordingSettings.duration;
+      this.contexts.horizontal.replayBuffer.video = this.videoSettingsService.contexts[display];
+      this.contexts.horizontal.replayBuffer.prefix = recordingSettings.prefix;
+      this.contexts.horizontal.replayBuffer.suffix = recordingSettings.suffix;
+      this.contexts.horizontal.replayBuffer.usesStream = recordingSettings.useStreamEncoders;
+      this.contexts.horizontal.replayBuffer.mixer = recordingSettings.mixer;
+      this.contexts.horizontal.replayBuffer.recording = this.contexts.horizontal
+        .recording as IAdvancedRecording;
+      this.contexts.horizontal.replayBuffer.signalHandler = async signal => {
         console.log('replay buffer signal', signal);
         await this.handleSignal(signal, display);
       };
 
-      this.horizontalReplayBuffer.start();
+      this.contexts.horizontal.replayBuffer.start();
       this.usageStatisticsService.recordFeatureUsage('ReplayBuffer');
     } else {
       const replayBuffer = SimpleReplayBufferFactory.create();
@@ -1489,14 +1505,14 @@ export class StreamingService
       replayBuffer.prefix = recordingSettings.prefix;
       replayBuffer.suffix = recordingSettings.suffix;
       replayBuffer.usesStream = true;
-      replayBuffer.recording = this.horizontalRecording as ISimpleRecording;
+      replayBuffer.recording = this.contexts.horizontal.recording as ISimpleRecording;
       replayBuffer.signalHandler = async signal => {
         console.log('replay buffer signal', signal);
         await this.handleSignal(signal, 'horizontal');
       };
 
-      this.horizontalReplayBuffer = replayBuffer;
-      this.horizontalReplayBuffer.start();
+      this.contexts.horizontal.replayBuffer = replayBuffer;
+      this.contexts.horizontal.replayBuffer.start();
       this.usageStatisticsService.recordFeatureUsage('ReplayBuffer');
     }
   }
@@ -1506,39 +1522,39 @@ export class StreamingService
     const forceStop = this.state.replayBufferStatus === EReplayBufferState.Stopping;
     console.log('stopReplayBuffer mode', this.outputSettingsService.getSettings().mode);
     console.log(
-      'stopReplayBuffer before destroy this.horizontalReplayBuffer',
-      this.horizontalReplayBuffer,
+      'stopReplayBuffer before destroy this.contexts.horizontal.replayBuffer',
+      this.contexts.horizontal.replayBuffer,
     );
     console.log(
       'stopReplayBuffer before destroy this.verticalReplayBuffer',
-      this.verticalReplayBuffer,
+      this.contexts.vertical.replayBuffer,
     );
 
     if (this.outputSettingsService.getSettings().mode === 'Advanced') {
-      if (display === 'horizontal' && this.horizontalReplayBuffer) {
-        this.horizontalReplayBuffer.stop(forceStop);
+      if (display === 'horizontal' && this.contexts.horizontal.replayBuffer) {
+        this.contexts.horizontal.replayBuffer.stop(forceStop);
       }
 
-      if (display === 'vertical' && this.verticalReplayBuffer) {
-        this.verticalReplayBuffer.stop(forceStop);
+      if (display === 'vertical' && this.contexts.vertical.replayBuffer) {
+        this.contexts.vertical.replayBuffer.stop(forceStop);
       }
     } else {
-      if (display === 'horizontal' && this.horizontalReplayBuffer) {
-        this.horizontalReplayBuffer.stop(forceStop);
+      if (display === 'horizontal' && this.contexts.horizontal.replayBuffer) {
+        this.contexts.horizontal.replayBuffer.stop(forceStop);
       }
 
-      if (display === 'vertical' && this.verticalReplayBuffer) {
-        this.verticalReplayBuffer.stop(forceStop);
+      if (display === 'vertical' && this.contexts.vertical.replayBuffer) {
+        this.contexts.vertical.replayBuffer.stop(forceStop);
       }
     }
 
     console.log(
-      'stopReplayBuffer after destroy this.horizontalReplayBuffer',
-      this.horizontalReplayBuffer,
+      'stopReplayBuffer after destroy this.contexts.horizontal.replayBuffer',
+      this.contexts.horizontal.replayBuffer,
     );
     console.log(
       'stopReplayBuffer after destroy this.verticalReplayBuffer',
-      this.verticalReplayBuffer,
+      this.contexts.vertical.replayBuffer,
     );
 
     // TODO: Determine why replay buffer stop signals are missing
@@ -1547,13 +1563,13 @@ export class StreamingService
   }
 
   saveReplay(display: TDisplayType = 'horizontal') {
-    if (display === 'horizontal' && this.horizontalReplayBuffer) {
-      this.horizontalReplayBuffer.save();
+    if (display === 'horizontal' && this.contexts.horizontal.replayBuffer) {
+      this.contexts.horizontal.replayBuffer.save();
       return;
     }
 
-    if (display === 'vertical' && this.verticalReplayBuffer) {
-      this.verticalReplayBuffer.save();
+    if (display === 'vertical' && this.contexts.vertical.replayBuffer) {
+      this.contexts.vertical.replayBuffer.save();
       return;
     }
   }
@@ -1640,8 +1656,8 @@ export class StreamingService
     // 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));
+    } else if (this.state.status.vertical.recording !== ERecordingState.Offline) {
+      return this.formattedDurationSince(moment(this.state.status.vertical.recordingTime));
     }
     return this.formattedDurationSince(moment(this.state.recordingStatusTime));
   }
@@ -2271,6 +2287,73 @@ export class StreamingService
     return fetch(request);
   }
 
+  /**
+   * Shut down the streaming service
+   *
+   * @remark Each streaming/recording/replay buffer context must be destroyed
+   * on app shutdown to prevent errors.
+   */
+  shutdown() {
+    for (const display in this.contexts) {
+      this.destroyStreamingContextIfExists(display);
+      this.destroyRecordingContextIfExists(display);
+      this.destroyReplayBufferContextIfExists(display);
+    }
+  }
+
+  /**
+   * Destroy the streaming context for a given display
+   * @remark Will just return if the context is null
+   * @param display - The display to destroy the streaming context for
+   */
+  private destroyStreamingContextIfExists(display: TDisplayType | string) {
+    if (!this.contexts[display] || !this.contexts[display].streaming) return;
+
+    if (this.outputSettingsService.getSettings().mode === 'Advanced') {
+      AdvancedStreamingFactory.destroy(this.contexts[display].streaming as IAdvancedStreaming);
+    } else {
+      SimpleStreamingFactory.destroy(this.contexts[display].streaming as ISimpleStreaming);
+    }
+
+    this.contexts[display].streaming = null;
+  }
+
+  /**
+   * Destroy the recording context for a given display
+   * @remark Will just return if the context is null
+   * @param display - The display to destroy the recording context for
+   */
+  private destroyRecordingContextIfExists(display: TDisplayType | string) {
+    if (!this.contexts[display] || !this.contexts[display].recording) return;
+
+    if (this.outputSettingsService.getSettings().mode === 'Advanced') {
+      AdvancedRecordingFactory.destroy(this.contexts[display].recording as IAdvancedRecording);
+    } else {
+      SimpleRecordingFactory.destroy(this.contexts[display].recording as ISimpleRecording);
+    }
+
+    this.contexts[display].recording = null;
+  }
+
+  /**
+   * Destroy the replay buffer context for a given display
+   * @remark Will just return if the context is null
+   * @param display - The display to destroy the replay buffer context for
+   */
+  private destroyReplayBufferContextIfExists(display: TDisplayType | string) {
+    if (!this.contexts[display] || !this.contexts[display].replayBuffer) return;
+
+    if (this.outputSettingsService.getSettings().mode === 'Advanced') {
+      AdvancedReplayBufferFactory.destroy(
+        this.contexts[display].replayBuffer as IAdvancedReplayBuffer,
+      );
+    } else {
+      SimpleReplayBufferFactory.destroy(this.contexts[display].replayBuffer as ISimpleReplayBuffer);
+    }
+
+    this.contexts[display].replayBuffer = null;
+  }
+
   @mutation()
   private SET_STREAMING_STATUS(status: EStreamingState, time?: string) {
     this.state.streamingStatus = status;
@@ -2290,8 +2373,8 @@ export class StreamingService
     display: TDisplayType = 'horizontal',
   ) {
     if (display === 'vertical') {
-      this.state.verticalRecordingStatus = status;
-      this.state.verticalRecordingStatusTime = time;
+      this.state.status.vertical.recording = status;
+      this.state.status.vertical.recordingTime = time;
     } else {
       this.state.recordingStatus = status;
       this.state.recordingStatusTime = time;

From 605feec54bc42956a2a88b52427b4e7ebb95ad75 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Wed, 5 Mar 2025 13:46:57 -0500
Subject: [PATCH 05/25] Refactor destroy contexts.

---
 app/services/streaming/streaming.ts | 100 +++++++++++++---------------
 1 file changed, 48 insertions(+), 52 deletions(-)

diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index 77a323ad49b8..e870db032ed3 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1259,7 +1259,7 @@ export class StreamingService
   }
 
   private createRecording(display: TDisplayType, index: number) {
-    this.destroyRecordingContextIfExists(display);
+    this.destroyOutputContextIfExists(display, 'recording');
 
     const mode = this.outputSettingsService.getSettings().mode;
 
@@ -1379,7 +1379,7 @@ export class StreamingService
       await this.markersService.exportCsv(parsedName);
 
       // destroy recording factory instances
-      this.destroyRecordingContextIfExists(display);
+      this.destroyOutputContextIfExists(display, 'recording');
 
       const time = new Date().toISOString();
       this.SET_RECORDING_STATUS(ERecordingState.Offline, time, display);
@@ -1450,7 +1450,7 @@ export class StreamingService
         code: info.code,
       });
 
-      this.destroyReplayBufferContextIfExists(display);
+      this.destroyOutputContextIfExists(display, 'replayBuffer');
     }
   }
 
@@ -1467,7 +1467,7 @@ export class StreamingService
 
     if (this.state.replayBufferStatus !== EReplayBufferState.Offline) return;
 
-    this.destroyReplayBufferContextIfExists(display);
+    this.destroyOutputContextIfExists(display, 'replayBuffer');
 
     if (mode === 'Advanced') {
       this.contexts.horizontal.replayBuffer = AdvancedReplayBufferFactory.create();
@@ -2294,64 +2294,60 @@ export class StreamingService
    * on app shutdown to prevent errors.
    */
   shutdown() {
-    for (const display in this.contexts) {
-      this.destroyStreamingContextIfExists(display);
-      this.destroyRecordingContextIfExists(display);
-      this.destroyReplayBufferContextIfExists(display);
-    }
-  }
-
-  /**
-   * Destroy the streaming context for a given display
-   * @remark Will just return if the context is null
-   * @param display - The display to destroy the streaming context for
-   */
-  private destroyStreamingContextIfExists(display: TDisplayType | string) {
-    if (!this.contexts[display] || !this.contexts[display].streaming) return;
-
-    if (this.outputSettingsService.getSettings().mode === 'Advanced') {
-      AdvancedStreamingFactory.destroy(this.contexts[display].streaming as IAdvancedStreaming);
-    } else {
-      SimpleStreamingFactory.destroy(this.contexts[display].streaming as ISimpleStreaming);
-    }
-
-    this.contexts[display].streaming = null;
-  }
-
-  /**
-   * Destroy the recording context for a given display
-   * @remark Will just return if the context is null
-   * @param display - The display to destroy the recording context for
-   */
-  private destroyRecordingContextIfExists(display: TDisplayType | string) {
-    if (!this.contexts[display] || !this.contexts[display].recording) return;
-
-    if (this.outputSettingsService.getSettings().mode === 'Advanced') {
-      AdvancedRecordingFactory.destroy(this.contexts[display].recording as IAdvancedRecording);
-    } else {
-      SimpleRecordingFactory.destroy(this.contexts[display].recording as ISimpleRecording);
-    }
-
-    this.contexts[display].recording = null;
+    Object.keys(this.contexts).forEach(display => {
+      Object.keys(this.contexts[display]).forEach((contextType: keyof IOutputContext) => {
+        this.destroyOutputContextIfExists(display, contextType);
+      });
+    });
   }
 
   /**
-   * Destroy the replay buffer context for a given display
+   * Destroy the streaming context for a given display and output
    * @remark Will just return if the context is null
-   * @param display - The display to destroy the replay buffer context for
+   * @param display - The display to destroy the output context for
+   * @param contextType - The name of the output context to destroy
    */
-  private destroyReplayBufferContextIfExists(display: TDisplayType | string) {
-    if (!this.contexts[display] || !this.contexts[display].replayBuffer) return;
+  private destroyOutputContextIfExists(
+    display: TDisplayType | string,
+    contextType: keyof IOutputContext,
+  ) {
+    if (!this.contexts[display] || !this.contexts[display][contextType]) return;
 
     if (this.outputSettingsService.getSettings().mode === 'Advanced') {
-      AdvancedReplayBufferFactory.destroy(
-        this.contexts[display].replayBuffer as IAdvancedReplayBuffer,
-      );
+      switch (contextType) {
+        case 'streaming':
+          AdvancedStreamingFactory.destroy(
+            this.contexts[display][contextType] as IAdvancedStreaming,
+          );
+          break;
+        case 'recording':
+          AdvancedRecordingFactory.destroy(
+            this.contexts[display][contextType] as IAdvancedRecording,
+          );
+          break;
+        case 'replayBuffer':
+          AdvancedReplayBufferFactory.destroy(
+            this.contexts[display][contextType] as IAdvancedReplayBuffer,
+          );
+          break;
+      }
     } else {
-      SimpleReplayBufferFactory.destroy(this.contexts[display].replayBuffer as ISimpleReplayBuffer);
+      switch (contextType) {
+        case 'streaming':
+          SimpleStreamingFactory.destroy(this.contexts[display][contextType] as ISimpleStreaming);
+          break;
+        case 'recording':
+          SimpleRecordingFactory.destroy(this.contexts[display][contextType] as ISimpleRecording);
+          break;
+        case 'replayBuffer':
+          SimpleReplayBufferFactory.destroy(
+            this.contexts[display][contextType] as ISimpleReplayBuffer,
+          );
+          break;
+      }
     }
 
-    this.contexts[display].replayBuffer = null;
+    this.contexts[display][contextType] = null;
   }
 
   @mutation()

From baf5af6f859345e3e99733159ec92fb4a46e9196 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Wed, 5 Mar 2025 14:37:52 -0500
Subject: [PATCH 06/25] Advanced recording works.

---
 .../settings/output/output-settings.ts        | 147 ++++++++++++++++++
 app/services/streaming/streaming.ts           |  43 ++---
 2 files changed, 170 insertions(+), 20 deletions(-)

diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts
index 2aa5bb86a715..e9cecdfc29e5 100644
--- a/app/services/settings/output/output-settings.ts
+++ b/app/services/settings/output/output-settings.ts
@@ -230,6 +230,128 @@ export class OutputSettingsService extends Service {
     };
   }
 
+  /**
+   * Get recording settings
+   * @remark Primarily used for setting up the recording output context,
+   * this function will automatically return either the simple or advanced
+   * settings based on the current mode.
+   * @returns settings for the recording
+   */
+  getRecordingSettings() {
+    const output = this.settingsService.state.Output.formData;
+    const advanced = this.settingsService.state.Advanced.formData;
+
+    console.log('output', JSON.stringify(output, null, 2));
+    console.log('advanced', JSON.stringify(advanced, null, 2));
+
+    const mode: TOutputSettingsMode = this.settingsService.findSettingValue(
+      output,
+      'Untitled',
+      'Mode',
+    );
+
+    const pathKey = mode === 'Advanced' ? 'RecFilePath' : 'FilePath';
+    const path: string = this.settingsService.findSettingValue(output, 'Recording', pathKey);
+
+    const format: ERecordingFormat = this.settingsService.findValidListValue(
+      output,
+      'Recording',
+      'RecFormat',
+    ) as ERecordingFormat;
+
+    const oldQualityName = this.settingsService.findSettingValue(output, 'Recording', 'RecQuality');
+    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 videoEncoder: 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',
+    );
+
+    const prefix: string = this.settingsService.findSettingValue(
+      output,
+      'Recording',
+      'RecRBPrefix',
+    );
+    const suffix: string = this.settingsService.findSettingValue(
+      output,
+      'Recording',
+      'RecRBSuffix',
+    );
+    const duration: number = this.settingsService.findSettingValue(
+      output,
+      'Stream Delay',
+      'DelaySec',
+    );
+
+    if (mode === 'Advanced') {
+      const mixer = this.settingsService.findSettingValue(output, 'Recording', 'RecTracks');
+      const rescaling = this.settingsService.findSettingValue(output, 'Recording', 'RecRescale');
+      const useStreamEncoders =
+        this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder') === 'none';
+
+      // advanced settings
+      return {
+        path,
+        format,
+        overwrite,
+        noSpace,
+        mixer,
+        rescaling,
+        useStreamEncoders,
+        videoEncoder,
+        prefix,
+        suffix,
+        duration,
+      };
+    } else {
+      // simple settings
+      return {
+        path,
+        format,
+        quality,
+        videoEncoder,
+        lowCPU,
+        overwrite,
+        noSpace,
+        prefix,
+        suffix,
+        duration,
+      };
+    }
+  }
+
   getSimpleRecordingSettings() {
     const output = this.settingsService.state.Output.formData;
     const advanced = this.settingsService.state.Advanced.formData;
@@ -311,6 +433,31 @@ export class OutputSettingsService extends Service {
       duration,
     };
   }
+  // simple
+  // recording.path = path.join(path.normalize(__dirname), '..', 'osnData');
+  // recording.format = ERecordingFormat.MOV;
+  // recording.quality = ERecordingQuality.HighQuality;
+  // recording.video = obs.defaultVideoContext;
+  // recording.videoEncoder =
+  //     osn.VideoEncoderFactory.create('obs_x264', 'video-encoder');
+  // recording.lowCPU = true;
+  // recording.audioEncoder = osn.AudioEncoderFactory.create();
+  // recording.overwrite = true;
+  // recording.noSpace = false;
+
+  // advanced
+  // recording.path = path.join(path.normalize(__dirname), '..', 'osnData');
+  // recording.format = ERecordingFormat.MOV;
+  // recording.videoEncoder =
+  //     osn.VideoEncoderFactory.create('obs_x264', 'video-encoder');
+  // recording.overwrite = true;
+  // recording.noSpace = false;
+  // recording.video = obs.defaultVideoContext;
+  // recording.mixer = 7;
+  // recording.rescaling = true;
+  // recording.outputWidth = 1920;
+  // recording.outputHeight = 1080;
+  // recording.useStreamEncoders = false;
 
   getAdvancedRecordingSettings() {
     const output = this.settingsService.state.Output.formData;
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index e870db032ed3..cb2b1aeb6b70 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1268,10 +1268,7 @@ export class StreamingService
         ? (AdvancedRecordingFactory.create() as IAdvancedRecording)
         : (SimpleRecordingFactory.create() as ISimpleRecording);
 
-    const settings =
-      mode === 'Advanced'
-        ? this.outputSettingsService.getAdvancedRecordingSettings()
-        : this.outputSettingsService.getSimpleRecordingSettings();
+    const settings = this.outputSettingsService.getRecordingSettings();
 
     console.log('createRecording advanced settings', settings);
 
@@ -1280,8 +1277,8 @@ export class StreamingService
       console.log('(settings as any)[key]', key, (settings as any)[key]);
       if ((settings as any)[key] === undefined) return;
 
-      if (key === 'encoder') {
-        recording.videoEncoder = VideoEncoderFactory.create(settings.encoder, 'video-encoder');
+      if (key === 'videoEncoder') {
+        recording.videoEncoder = VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder');
       } else {
         (recording as any)[key] = (settings as any)[key];
       }
@@ -1301,23 +1298,32 @@ export class StreamingService
 
     // handle unique properties (including audio)
     if (mode === 'Advanced') {
-      (recording as IAdvancedRecording).outputWidth = this.dualOutputService.views.videoSettings[
-        display
-      ].outputWidth;
-      (recording as IAdvancedRecording).outputHeight = this.dualOutputService.views.videoSettings[
-        display
-      ].outputHeight;
+      // output resolutions
+      const resolution = this.videoSettingsService.outputResolutions[display];
+      (recording as IAdvancedRecording).outputWidth = resolution.outputWidth;
+      (recording as IAdvancedRecording).outputHeight = resolution.outputHeight;
 
+      // audio track
       const trackName = `track${index}`;
       const track = AudioTrackFactory.create(160, trackName);
       AudioTrackFactory.setAtIndex(track, index);
 
       console.log('assigned audio track');
 
+      // streaming object
       const videoEncoder = recording.videoEncoder;
       const stream = AdvancedStreamingFactory.create() as IAdvancedStreaming;
+      stream.enforceServiceBitrate = false;
+      stream.enableTwitchVOD = false;
+      stream.audioTrack = index;
+      // stream.twitchTrack = 3;
+      stream.rescaling = ((recording as unknown) as IAdvancedStreaming).rescaling;
+      stream.outputWidth = ((recording as unknown) as IAdvancedStreaming).outputWidth;
+      stream.outputHeight = ((recording as unknown) as IAdvancedStreaming).outputHeight;
       stream.video = this.videoSettingsService.contexts[display];
       stream.videoEncoder = videoEncoder;
+
+      this.contexts[display].streaming = stream;
       recording.videoEncoder = videoEncoder;
       recording.streaming = stream;
     } else {
@@ -1325,14 +1331,9 @@ export class StreamingService
     }
 
     // save in state
-    if (display === 'vertical') {
-      this.contexts.vertical.recording = recording;
-      this.contexts.vertical.recording.start();
-    } else {
-      this.contexts.horizontal.recording = recording;
-      console.log('this.contexts.horizontal.recording', this.contexts.horizontal.recording);
-      this.contexts.horizontal.recording.start();
-    }
+    this.contexts[display].recording = recording;
+    console.log('this.contexts[display].recording', this.contexts[display].recording);
+    this.contexts[display].recording.start();
   }
 
   private async handleRecordingSignal(info: EOutputSignal, display: TDisplayType) {
@@ -1380,6 +1381,8 @@ export class StreamingService
 
       // destroy recording factory instances
       this.destroyOutputContextIfExists(display, 'recording');
+      // TODO: remove
+      this.destroyOutputContextIfExists(display, 'streaming');
 
       const time = new Date().toISOString();
       this.SET_RECORDING_STATUS(ERecordingState.Offline, time, display);

From ab0b68a1bfdf932ea77ea0a2acdf771d3a855541 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Wed, 5 Mar 2025 15:07:16 -0500
Subject: [PATCH 07/25] Refactor to use new status handler.

---
 app/services/streaming/streaming.ts | 146 ++++++++++------------------
 1 file changed, 54 insertions(+), 92 deletions(-)

diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index cb2b1aeb6b70..f14a3b0d8b17 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -915,7 +915,7 @@ export class StreamingService
   }
 
   get isReplayBufferActive() {
-    return this.state.replayBufferStatus !== EReplayBufferState.Offline;
+    return this.state.status.horizontal.replayBuffer !== EReplayBufferState.Offline;
   }
 
   get isIdle(): boolean {
@@ -1037,7 +1037,10 @@ export class StreamingService
 
     const replayWhenStreaming = this.streamSettingsService.settings.replayBufferWhileStreaming;
 
-    if (replayWhenStreaming && this.state.replayBufferStatus === EReplayBufferState.Offline) {
+    if (
+      replayWhenStreaming &&
+      this.state.status.horizontal.replayBuffer === EReplayBufferState.Offline
+    ) {
       this.startReplayBuffer();
     }
 
@@ -1141,7 +1144,10 @@ export class StreamingService
       }
 
       const keepReplaying = this.streamSettingsService.settings.keepReplayBufferStreamStops;
-      if (!keepReplaying && this.state.replayBufferStatus === EReplayBufferState.Running) {
+      if (
+        !keepReplaying &&
+        this.state.status.horizontal.replayBuffer === EReplayBufferState.Running
+      ) {
         this.stopReplayBuffer();
       }
 
@@ -1203,7 +1209,7 @@ export class StreamingService
             // 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');
+              this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', time);
               if (this.contexts.horizontal.recording !== null) {
                 this.contexts.horizontal.recording.stop();
               }
@@ -1212,7 +1218,7 @@ export class StreamingService
           recordingStopped.unsubscribe();
         });
 
-        this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'vertical');
+        this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', time);
         this.contexts.vertical.recording.stop();
         this.recordingStopped.next();
       }
@@ -1224,7 +1230,7 @@ export class StreamingService
     ) {
       // stop recording vertical display
       const time = new Date().toISOString();
-      this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'vertical');
+      this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', time);
       this.contexts.vertical.recording.stop();
     } else if (
       this.state.recordingStatus === ERecordingState.Recording &&
@@ -1232,7 +1238,7 @@ export class StreamingService
     ) {
       const time = new Date().toISOString();
       // stop recording horizontal display
-      this.SET_RECORDING_STATUS(ERecordingState.Stopping, time, 'horizontal');
+      this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', time);
       this.contexts.horizontal.recording.stop();
     }
 
@@ -1270,11 +1276,8 @@ export class StreamingService
 
     const settings = this.outputSettingsService.getRecordingSettings();
 
-    console.log('createRecording advanced settings', settings);
-
     // assign settings
     Object.keys(settings).forEach(key => {
-      console.log('(settings as any)[key]', key, (settings as any)[key]);
       if ((settings as any)[key] === undefined) return;
 
       if (key === 'videoEncoder') {
@@ -1286,7 +1289,6 @@ export class StreamingService
 
     // assign context
     recording.video = this.videoSettingsService.contexts[display];
-    console.log('assigned context');
 
     // set signal handler
     recording.signalHandler = async signal => {
@@ -1294,8 +1296,6 @@ export class StreamingService
       await this.handleSignal(signal, display);
     };
 
-    console.log('assign signal handler');
-
     // handle unique properties (including audio)
     if (mode === 'Advanced') {
       // output resolutions
@@ -1308,9 +1308,8 @@ export class StreamingService
       const track = AudioTrackFactory.create(160, trackName);
       AudioTrackFactory.setAtIndex(track, index);
 
-      console.log('assigned audio track');
-
       // streaming object
+      // TODO: move to its own function
       const videoEncoder = recording.videoEncoder;
       const stream = AdvancedStreamingFactory.create() as IAdvancedStreaming;
       stream.enforceServiceBitrate = false;
@@ -1332,7 +1331,6 @@ export class StreamingService
 
     // save in state
     this.contexts[display].recording = recording;
-    console.log('this.contexts[display].recording', this.contexts[display].recording);
     this.contexts[display].recording.start();
   }
 
@@ -1344,7 +1342,6 @@ export class StreamingService
       [EOutputSignalState.Stopping]: ERecordingState.Stopping,
       [EOutputSignalState.Wrote]: ERecordingState.Wrote,
     } as Dictionary<ERecordingState>)[info.signal];
-    console.log('handleRecordingSignal nextState', nextState, 'signal', info.signal);
 
     // We received a signal we didn't recognize
     if (!nextState) return;
@@ -1381,11 +1378,11 @@ export class StreamingService
 
       // destroy recording factory instances
       this.destroyOutputContextIfExists(display, 'recording');
-      // TODO: remove
+      // TODO: is this necessary?
       this.destroyOutputContextIfExists(display, 'streaming');
 
       const time = new Date().toISOString();
-      this.SET_RECORDING_STATUS(ERecordingState.Offline, time, display);
+      this.SET_RECORDING_STATUS(ERecordingState.Offline, display, time);
       this.recordingStatusChange.next(ERecordingState.Offline);
 
       this.handleV2OutputCode(info);
@@ -1393,7 +1390,7 @@ export class StreamingService
     }
 
     const time = new Date().toISOString();
-    this.SET_RECORDING_STATUS(nextState, time, display);
+    this.SET_RECORDING_STATUS(nextState, display, time);
     this.recordingStatusChange.next(nextState);
 
     this.handleV2OutputCode(info);
@@ -1435,7 +1432,7 @@ export class StreamingService
 
     if (nextState) {
       const time = new Date().toISOString();
-      this.SET_REPLAY_BUFFER_STATUS(nextState, time);
+      this.SET_REPLAY_BUFFER_STATUS(nextState, display, time);
       this.replayBufferStatusChange.next(nextState);
     }
 
@@ -1463,18 +1460,18 @@ export class StreamingService
   }
 
   startReplayBuffer(display: TDisplayType = 'horizontal') {
-    if (this.state.replayBufferStatus !== EReplayBufferState.Offline) return;
+    if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return;
 
     const mode = this.outputSettingsService.getSettings().mode;
     if (!this.contexts.horizontal.recording) return;
 
-    if (this.state.replayBufferStatus !== EReplayBufferState.Offline) return;
+    if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return;
 
     this.destroyOutputContextIfExists(display, 'replayBuffer');
 
     if (mode === 'Advanced') {
       this.contexts.horizontal.replayBuffer = AdvancedReplayBufferFactory.create();
-      const recordingSettings = this.outputSettingsService.getAdvancedRecordingSettings();
+      const recordingSettings = this.outputSettingsService.getRecordingSettings();
 
       this.contexts.horizontal.replayBuffer.path = recordingSettings.path;
       this.contexts.horizontal.replayBuffer.format = recordingSettings.format;
@@ -1497,7 +1494,7 @@ export class StreamingService
       this.usageStatisticsService.recordFeatureUsage('ReplayBuffer');
     } else {
       const replayBuffer = SimpleReplayBufferFactory.create();
-      const recordingSettings = this.outputSettingsService.getSimpleRecordingSettings();
+      const recordingSettings = this.outputSettingsService.getRecordingSettings();
 
       replayBuffer.path = recordingSettings.path;
       replayBuffer.format = recordingSettings.format;
@@ -1521,60 +1518,19 @@ export class StreamingService
   }
 
   stopReplayBuffer(display: TDisplayType = 'horizontal') {
-    if (this.state.replayBufferStatus === EReplayBufferState.Offline) return;
-    const forceStop = this.state.replayBufferStatus === EReplayBufferState.Stopping;
-    console.log('stopReplayBuffer mode', this.outputSettingsService.getSettings().mode);
-    console.log(
-      'stopReplayBuffer before destroy this.contexts.horizontal.replayBuffer',
-      this.contexts.horizontal.replayBuffer,
-    );
-    console.log(
-      'stopReplayBuffer before destroy this.verticalReplayBuffer',
-      this.contexts.vertical.replayBuffer,
-    );
-
-    if (this.outputSettingsService.getSettings().mode === 'Advanced') {
-      if (display === 'horizontal' && this.contexts.horizontal.replayBuffer) {
-        this.contexts.horizontal.replayBuffer.stop(forceStop);
-      }
-
-      if (display === 'vertical' && this.contexts.vertical.replayBuffer) {
-        this.contexts.vertical.replayBuffer.stop(forceStop);
-      }
-    } else {
-      if (display === 'horizontal' && this.contexts.horizontal.replayBuffer) {
-        this.contexts.horizontal.replayBuffer.stop(forceStop);
-      }
-
-      if (display === 'vertical' && this.contexts.vertical.replayBuffer) {
-        this.contexts.vertical.replayBuffer.stop(forceStop);
-      }
+    if (
+      !this.contexts[display].replayBuffer ||
+      this.state.status[display].replayBuffer === EReplayBufferState.Offline
+    ) {
+      return;
     }
-
-    console.log(
-      'stopReplayBuffer after destroy this.contexts.horizontal.replayBuffer',
-      this.contexts.horizontal.replayBuffer,
-    );
-    console.log(
-      'stopReplayBuffer after destroy this.verticalReplayBuffer',
-      this.contexts.vertical.replayBuffer,
-    );
-
-    // TODO: Determine why replay buffer stop signals are missing
-    // temporarily set replay buffer status to offline
-    this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Offline);
+    const forceStop = this.state.status[display].replayBuffer === EReplayBufferState.Stopping;
+    this.contexts[display].replayBuffer.stop(forceStop);
   }
 
   saveReplay(display: TDisplayType = 'horizontal') {
-    if (display === 'horizontal' && this.contexts.horizontal.replayBuffer) {
-      this.contexts.horizontal.replayBuffer.save();
-      return;
-    }
-
-    if (display === 'vertical' && this.contexts.vertical.replayBuffer) {
-      this.contexts.vertical.replayBuffer.save();
-      return;
-    }
+    if (!this.contexts[display].replayBuffer) return;
+    this.contexts[display].replayBuffer.save();
   }
 
   /**
@@ -1802,7 +1758,7 @@ export class StreamingService
       } as Dictionary<EReplayBufferState>)[info.signal];
 
       if (nextState) {
-        this.SET_REPLAY_BUFFER_STATUS(nextState, time);
+        this.SET_REPLAY_BUFFER_STATUS(nextState, 'horizontal', time);
         this.replayBufferStatusChange.next(nextState);
       }
 
@@ -2048,7 +2004,7 @@ export class StreamingService
         return;
       }
 
-      this.SET_RECORDING_STATUS(nextState, time);
+      this.SET_RECORDING_STATUS(nextState, 'horizontal', time);
       this.recordingStatusChange.next(nextState);
     } else if (info.type === EOBSOutputType.ReplayBuffer) {
       const nextState: EReplayBufferState = ({
@@ -2060,7 +2016,7 @@ export class StreamingService
       } as Dictionary<EReplayBufferState>)[info.signal];
 
       if (nextState) {
-        this.SET_REPLAY_BUFFER_STATUS(nextState, time);
+        this.SET_REPLAY_BUFFER_STATUS(nextState, 'horizontal', time);
         this.replayBufferStatusChange.next(nextState);
       }
 
@@ -2366,24 +2322,30 @@ export class StreamingService
   }
 
   @mutation()
-  private SET_RECORDING_STATUS(
-    status: ERecordingState,
-    time: string,
-    display: TDisplayType = 'horizontal',
-  ) {
-    if (display === 'vertical') {
-      this.state.status.vertical.recording = status;
-      this.state.status.vertical.recordingTime = time;
-    } else {
-      this.state.recordingStatus = status;
-      this.state.recordingStatusTime = time;
-    }
+  private SET_RECORDING_STATUS(status: ERecordingState, display: TDisplayType, time: string) {
+    // while recording and the replay buffer are in the v2 API and streaming is in the old API
+    // we need to duplicate tracking the replay buffer status
+    this.state.status[display].recording = status;
+    this.state.status[display].recordingTime = time;
+    this.state.recordingStatus = status;
+    this.state.recordingStatusTime = time;
   }
 
   @mutation()
-  private SET_REPLAY_BUFFER_STATUS(status: EReplayBufferState, time?: string) {
+  private SET_REPLAY_BUFFER_STATUS(
+    status: EReplayBufferState,
+    display: TDisplayType,
+    time?: string,
+  ) {
+    // while recording and the replay buffer are in the v2 API and streaming is in the old API
+    // we need to duplicate tracking the replay buffer status
+    this.state.status[display].replayBuffer = status;
     this.state.replayBufferStatus = status;
-    if (time) this.state.replayBufferStatusTime = time;
+
+    if (time) {
+      this.state.status[display].replayBufferTime = time;
+      this.state.replayBufferStatusTime = time;
+    }
   }
 
   @mutation()

From 6bab2633ebb1b5f9e170d905fc00af568570a1d1 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Wed, 5 Mar 2025 16:27:24 -0500
Subject: [PATCH 08/25] Handling for dependent output instances.

---
 app/i18n/en-US/streaming.json       |   3 +-
 app/services/streaming/streaming.ts | 181 +++++++++++++++++-----------
 2 files changed, 113 insertions(+), 71 deletions(-)

diff --git a/app/i18n/en-US/streaming.json b/app/i18n/en-US/streaming.json
index 0d8c57b9106d..625039fd835a 100644
--- a/app/i18n/en-US/streaming.json
+++ b/app/i18n/en-US/streaming.json
@@ -288,5 +288,6 @@
   "Please try again. If the issue persists, you can stream directly to a single platform instead or click the button below to bypass and go live.": "Please try again. If the issue persists, you can stream directly to a single platform instead or click the button below to bypass and go live.",
   "Issues": "Issues",
   "Multistream Error": "Multistream Error",
-  "One of destinations might have incomplete permissions. Reconnect the destinations in settings and try again.": "One of destinations might have incomplete permissions. Reconnect the destinations in settings and try again."
+  "One of destinations might have incomplete permissions. Reconnect the destinations in settings and try again.": "One of destinations might have incomplete permissions. Reconnect the destinations in settings and try again.",
+  "A new Highlight has been saved. Click to edit in the Highlighter": "A new Highlight has been saved. Click to edit in the Highlighter"
 }
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index f14a3b0d8b17..6f6cb0743cd9 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1,5 +1,6 @@
 import Vue from 'vue';
 import { mutation, StatefulService } from 'services/core/stateful-service';
+import { Service } from 'services/core/service';
 import {
   AdvancedRecordingFactory,
   AudioEncoderFactory,
@@ -74,9 +75,10 @@ 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 { capitalize, isFunction } from 'lodash';
+import { capitalize } from 'lodash';
 import { YoutubeService } from 'app-services';
-import path from 'path';
+import { JsonrpcService } from '../api/jsonrpc';
+import { NavigationService } from 'services/navigation';
 
 enum EOBSOutputType {
   Streaming = 'streaming',
@@ -145,6 +147,8 @@ export class StreamingService
   @Inject() private markersService: MarkersService;
   @Inject() private dualOutputService: DualOutputService;
   @Inject() private youtubeService: YoutubeService;
+  @Inject() private jsonrpcService: JsonrpcService;
+  @Inject() private navigationService: NavigationService;
 
   streamingStatusChange = new Subject<EStreamingState>();
   recordingStatusChange = new Subject<ERecordingState>();
@@ -1311,7 +1315,9 @@ export class StreamingService
       // streaming object
       // TODO: move to its own function
       const videoEncoder = recording.videoEncoder;
-      const stream = AdvancedStreamingFactory.create() as IAdvancedStreaming;
+      const stream =
+        (this.contexts[display].streaming as IAdvancedStreaming) ??
+        (AdvancedStreamingFactory.create() as IAdvancedStreaming);
       stream.enforceServiceBitrate = false;
       stream.enableTwitchVOD = false;
       stream.audioTrack = index;
@@ -1368,7 +1374,7 @@ export class StreamingService
         [OS.Windows]: fileName.replace(/\//, '\\'),
       });
 
-      // in dual output mode, each confirmation should be labelled for each display
+      // In dual output mode, each confirmation should be labelled for each display
       if (this.views.isDualOutputMode) {
         this.recordingModeService.addRecordingEntry(parsedName, display);
       } else {
@@ -1376,10 +1382,13 @@ export class StreamingService
       }
       await this.markersService.exportCsv(parsedName);
 
-      // destroy recording factory instances
+      // destroy recording instance
       this.destroyOutputContextIfExists(display, 'recording');
-      // TODO: is this necessary?
-      this.destroyOutputContextIfExists(display, 'streaming');
+      // Also destroy the streaming instance if it was only created for recording
+      // Note: this is only the case when recording without streaming in advanced mode
+      if (this.state.status[display].streaming === EStreamingState.Offline) {
+        this.destroyOutputContextIfExists(display, 'streaming');
+      }
 
       const time = new Date().toISOString();
       this.SET_RECORDING_STATUS(ERecordingState.Offline, display, time);
@@ -1441,6 +1450,19 @@ export class StreamingService
         status: 'wrote',
         code: info.code,
       });
+
+      const message = $t('A new Highlight has been saved. Click to edit in the Highlighter');
+
+      this.notificationsService.actions.push({
+        type: ENotificationType.SUCCESS,
+        message,
+        action: this.jsonrpcService.createRequest(Service.getResourceId(this), 'showHighlighter'),
+      });
+
+      console.log('NodeObs.OBS_service_getLastReplay()', NodeObs.OBS_service_getLastReplay());
+      console.log('this.contexts[display].replayBuffer', this.contexts[display].replayBuffer);
+      console.log('this.contexts[display].recording', this.contexts[display].recording);
+
       this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay());
     }
 
@@ -1454,6 +1476,10 @@ export class StreamingService
     }
   }
 
+  showHighlighter() {
+    this.navigationService.navigate('Highlighter');
+  }
+
   // TODO migrate streaming to new API
   private handleStreamingSignal(info: EOutputSignal, display: TDisplayType) {
     // map signals to status
@@ -1463,39 +1489,51 @@ export class StreamingService
     if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return;
 
     const mode = this.outputSettingsService.getSettings().mode;
-    if (!this.contexts.horizontal.recording) return;
+    if (!this.contexts[display].recording) return;
 
     if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return;
 
     this.destroyOutputContextIfExists(display, 'replayBuffer');
 
+    // the replay buffer must have a recording instance to reference
+    if (
+      this.state.streamingStatus !== EStreamingState.Offline &&
+      !this.contexts[display].recording
+    ) {
+      this.createRecording(display, 1);
+    }
+
     if (mode === 'Advanced') {
-      this.contexts.horizontal.replayBuffer = AdvancedReplayBufferFactory.create();
+      const replayBuffer = AdvancedReplayBufferFactory.create() as IAdvancedReplayBuffer;
       const recordingSettings = this.outputSettingsService.getRecordingSettings();
 
-      this.contexts.horizontal.replayBuffer.path = recordingSettings.path;
-      this.contexts.horizontal.replayBuffer.format = recordingSettings.format;
-      this.contexts.horizontal.replayBuffer.overwrite = recordingSettings.overwrite;
-      this.contexts.horizontal.replayBuffer.noSpace = recordingSettings.noSpace;
-      this.contexts.horizontal.replayBuffer.duration = recordingSettings.duration;
-      this.contexts.horizontal.replayBuffer.video = this.videoSettingsService.contexts[display];
-      this.contexts.horizontal.replayBuffer.prefix = recordingSettings.prefix;
-      this.contexts.horizontal.replayBuffer.suffix = recordingSettings.suffix;
-      this.contexts.horizontal.replayBuffer.usesStream = recordingSettings.useStreamEncoders;
-      this.contexts.horizontal.replayBuffer.mixer = recordingSettings.mixer;
-      this.contexts.horizontal.replayBuffer.recording = this.contexts.horizontal
-        .recording as IAdvancedRecording;
-      this.contexts.horizontal.replayBuffer.signalHandler = async signal => {
+      // console.log('Advanced recordingSettings', JSON.stringify(recordingSettings, null, 2));
+
+      replayBuffer.path = recordingSettings.path;
+      replayBuffer.format = recordingSettings.format;
+      replayBuffer.overwrite = recordingSettings.overwrite;
+      replayBuffer.noSpace = recordingSettings.noSpace;
+      replayBuffer.duration = recordingSettings.duration;
+      replayBuffer.video = this.videoSettingsService.contexts[display];
+      replayBuffer.prefix = recordingSettings.prefix;
+      replayBuffer.suffix = recordingSettings.suffix;
+      replayBuffer.usesStream = recordingSettings.useStreamEncoders;
+      replayBuffer.mixer = recordingSettings.mixer;
+      replayBuffer.recording = this.contexts[display].recording as IAdvancedRecording;
+      replayBuffer.signalHandler = async signal => {
         console.log('replay buffer signal', signal);
         await this.handleSignal(signal, display);
       };
 
-      this.contexts.horizontal.replayBuffer.start();
+      this.contexts[display].replayBuffer = replayBuffer;
+      this.contexts[display].replayBuffer.start();
       this.usageStatisticsService.recordFeatureUsage('ReplayBuffer');
     } else {
-      const replayBuffer = SimpleReplayBufferFactory.create();
+      const replayBuffer = SimpleReplayBufferFactory.create() as ISimpleReplayBuffer;
       const recordingSettings = this.outputSettingsService.getRecordingSettings();
 
+      // console.log('Simple recordingSettings', JSON.stringify(recordingSettings, null, 2));
+
       replayBuffer.path = recordingSettings.path;
       replayBuffer.format = recordingSettings.format;
       replayBuffer.overwrite = recordingSettings.overwrite;
@@ -1505,14 +1543,14 @@ export class StreamingService
       replayBuffer.prefix = recordingSettings.prefix;
       replayBuffer.suffix = recordingSettings.suffix;
       replayBuffer.usesStream = true;
-      replayBuffer.recording = this.contexts.horizontal.recording as ISimpleRecording;
+      replayBuffer.recording = this.contexts[display].recording as ISimpleRecording;
       replayBuffer.signalHandler = async signal => {
         console.log('replay buffer signal', signal);
-        await this.handleSignal(signal, 'horizontal');
+        await this.handleSignal(signal, display);
       };
 
-      this.contexts.horizontal.replayBuffer = replayBuffer;
-      this.contexts.horizontal.replayBuffer.start();
+      this.contexts[display].replayBuffer = replayBuffer;
+      this.contexts[display].replayBuffer.start();
       this.usageStatisticsService.recordFeatureUsage('ReplayBuffer');
     }
   }
@@ -1529,6 +1567,7 @@ export class StreamingService
   }
 
   saveReplay(display: TDisplayType = 'horizontal') {
+    console.log('this.contexts[display].replayBuffer', this.contexts[display].replayBuffer);
     if (!this.contexts[display].replayBuffer) return;
     this.contexts[display].replayBuffer.save();
   }
@@ -1748,28 +1787,29 @@ export class StreamingService
         this.streamingStatusChange.next(EStreamingState.Live);
         this.clearReconnectingNotification();
       }
-    } else if (info.type === EOBSOutputType.ReplayBuffer) {
-      const nextState: EReplayBufferState = ({
-        [EOBSOutputSignal.Start]: EReplayBufferState.Running,
-        [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping,
-        [EOBSOutputSignal.Stop]: EReplayBufferState.Offline,
-        [EOBSOutputSignal.Wrote]: EReplayBufferState.Running,
-        [EOBSOutputSignal.WriteError]: EReplayBufferState.Running,
-      } as Dictionary<EReplayBufferState>)[info.signal];
-
-      if (nextState) {
-        this.SET_REPLAY_BUFFER_STATUS(nextState, 'horizontal', time);
-        this.replayBufferStatusChange.next(nextState);
-      }
-
-      if (info.signal === EOBSOutputSignal.Wrote) {
-        this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', {
-          status: 'wrote',
-          code: info.code,
-        });
-        this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay());
-      }
     }
+    // else if (info.type === EOBSOutputType.ReplayBuffer) {
+    //   const nextState: EReplayBufferState = ({
+    //     [EOBSOutputSignal.Start]: EReplayBufferState.Running,
+    //     [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping,
+    //     [EOBSOutputSignal.Stop]: EReplayBufferState.Offline,
+    //     [EOBSOutputSignal.Wrote]: EReplayBufferState.Running,
+    //     [EOBSOutputSignal.WriteError]: EReplayBufferState.Running,
+    //   } as Dictionary<EReplayBufferState>)[info.signal];
+
+    //   if (nextState) {
+    //     this.SET_REPLAY_BUFFER_STATUS(nextState, 'horizontal', time);
+    //     this.replayBufferStatusChange.next(nextState);
+    //   }
+
+    //   if (info.signal === EOBSOutputSignal.Wrote) {
+    //     this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', {
+    //       status: 'wrote',
+    //       code: info.code,
+    //     });
+    //     this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay());
+    //   }
+    // }
     this.handleV2OutputCode(info);
   }
 
@@ -2006,28 +2046,29 @@ export class StreamingService
 
       this.SET_RECORDING_STATUS(nextState, 'horizontal', time);
       this.recordingStatusChange.next(nextState);
-    } else if (info.type === EOBSOutputType.ReplayBuffer) {
-      const nextState: EReplayBufferState = ({
-        [EOBSOutputSignal.Start]: EReplayBufferState.Running,
-        [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping,
-        [EOBSOutputSignal.Stop]: EReplayBufferState.Offline,
-        [EOBSOutputSignal.Wrote]: EReplayBufferState.Running,
-        [EOBSOutputSignal.WriteError]: EReplayBufferState.Running,
-      } as Dictionary<EReplayBufferState>)[info.signal];
-
-      if (nextState) {
-        this.SET_REPLAY_BUFFER_STATUS(nextState, 'horizontal', time);
-        this.replayBufferStatusChange.next(nextState);
-      }
-
-      if (info.signal === EOBSOutputSignal.Wrote) {
-        this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', {
-          status: 'wrote',
-          code: info.code,
-        });
-        this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay());
-      }
     }
+    // else if (info.type === EOBSOutputType.ReplayBuffer) {
+    //   const nextState: EReplayBufferState = ({
+    //     [EOBSOutputSignal.Start]: EReplayBufferState.Running,
+    //     [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping,
+    //     [EOBSOutputSignal.Stop]: EReplayBufferState.Offline,
+    //     [EOBSOutputSignal.Wrote]: EReplayBufferState.Running,
+    //     [EOBSOutputSignal.WriteError]: EReplayBufferState.Running,
+    //   } as Dictionary<EReplayBufferState>)[info.signal];
+
+    //   if (nextState) {
+    //     this.SET_REPLAY_BUFFER_STATUS(nextState, 'horizontal', time);
+    //     this.replayBufferStatusChange.next(nextState);
+    //   }
+
+    //   if (info.signal === EOBSOutputSignal.Wrote) {
+    //     this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', {
+    //       status: 'wrote',
+    //       code: info.code,
+    //     });
+    //     this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay());
+    //   }
+    // }
 
     if (info.code) {
       if (this.outputErrorOpen) {

From 0873a4ca738cdd033c6cc61c01d0428d466b723b Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Wed, 5 Mar 2025 16:53:52 -0500
Subject: [PATCH 09/25] WIP: Streaming with replay buffer.

---
 app/components-react/root/StudioFooter.tsx |   2 +-
 app/services/streaming/streaming.ts        | 122 +++------------------
 2 files changed, 17 insertions(+), 107 deletions(-)

diff --git a/app/components-react/root/StudioFooter.tsx b/app/components-react/root/StudioFooter.tsx
index a54d47b5f941..3f57fe081afc 100644
--- a/app/components-react/root/StudioFooter.tsx
+++ b/app/components-react/root/StudioFooter.tsx
@@ -46,7 +46,7 @@ export default function StudioFooterComponent() {
     replayBufferStopping: StreamingService.state.replayBufferStatus === EReplayBufferState.Stopping,
     replayBufferSaving: StreamingService.state.replayBufferStatus === EReplayBufferState.Saving,
     recordingModeEnabled: RecordingModeService.views.isRecordingModeEnabled,
-    replayBufferEnabled: StreamingService.state.recordingStatus !== ERecordingState.Offline,
+    replayBufferEnabled: SettingsService.views.values.Output.RecRB,
   }));
 
   function performanceIconClassName() {
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index 6f6cb0743cd9..269df9cd40c1 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1352,6 +1352,14 @@ export class StreamingService
     // We received a signal we didn't recognize
     if (!nextState) return;
 
+    if (info.signal === EOBSOutputSignal.Start) {
+      this.usageStatisticsService.recordFeatureUsage('Recording');
+      this.usageStatisticsService.recordAnalyticsEvent('RecordingStatus', {
+        status: nextState,
+        code: info.code,
+      });
+    }
+
     if (nextState === ERecordingState.Recording) {
       const mode = this.views.isDualOutputMode ? 'dual' : 'single';
       this.usageStatisticsService.recordFeatureUsage('Recording');
@@ -1364,10 +1372,7 @@ export class StreamingService
     }
 
     if (nextState === ERecordingState.Wrote) {
-      const fileName =
-        display === 'vertical'
-          ? this.contexts.vertical.recording.lastFile()
-          : this.contexts.horizontal.recording.lastFile();
+      const fileName = this.contexts[display].recording.lastFile();
 
       const parsedName = byOS({
         [OS.Mac]: fileName,
@@ -1390,9 +1395,7 @@ export class StreamingService
         this.destroyOutputContextIfExists(display, 'streaming');
       }
 
-      const time = new Date().toISOString();
-      this.SET_RECORDING_STATUS(ERecordingState.Offline, display, time);
-      this.recordingStatusChange.next(ERecordingState.Offline);
+      this.latestRecordingPath.next(fileName);
 
       this.handleV2OutputCode(info);
       return;
@@ -1437,7 +1440,6 @@ export class StreamingService
       [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping,
       [EOBSOutputSignal.Stop]: EReplayBufferState.Offline,
     } as Dictionary<EReplayBufferState>)[info.signal];
-    console.log('handleReplayBufferSignal nextState', nextState, 'signal', info.signal);
 
     if (nextState) {
       const time = new Date().toISOString();
@@ -1459,11 +1461,7 @@ export class StreamingService
         action: this.jsonrpcService.createRequest(Service.getResourceId(this), 'showHighlighter'),
       });
 
-      console.log('NodeObs.OBS_service_getLastReplay()', NodeObs.OBS_service_getLastReplay());
-      console.log('this.contexts[display].replayBuffer', this.contexts[display].replayBuffer);
-      console.log('this.contexts[display].recording', this.contexts[display].recording);
-
-      this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay());
+      this.replayBufferFileWrite.next(this.contexts[display].replayBuffer.lastFile());
     }
 
     if (info.signal === EOBSOutputSignal.Stop) {
@@ -1488,13 +1486,6 @@ export class StreamingService
   startReplayBuffer(display: TDisplayType = 'horizontal') {
     if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return;
 
-    const mode = this.outputSettingsService.getSettings().mode;
-    if (!this.contexts[display].recording) return;
-
-    if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return;
-
-    this.destroyOutputContextIfExists(display, 'replayBuffer');
-
     // the replay buffer must have a recording instance to reference
     if (
       this.state.streamingStatus !== EStreamingState.Offline &&
@@ -1503,12 +1494,14 @@ export class StreamingService
       this.createRecording(display, 1);
     }
 
+    const mode = this.outputSettingsService.getSettings().mode;
+
+    this.destroyOutputContextIfExists(display, 'replayBuffer');
+
     if (mode === 'Advanced') {
       const replayBuffer = AdvancedReplayBufferFactory.create() as IAdvancedReplayBuffer;
       const recordingSettings = this.outputSettingsService.getRecordingSettings();
 
-      // console.log('Advanced recordingSettings', JSON.stringify(recordingSettings, null, 2));
-
       replayBuffer.path = recordingSettings.path;
       replayBuffer.format = recordingSettings.format;
       replayBuffer.overwrite = recordingSettings.overwrite;
@@ -1532,8 +1525,6 @@ export class StreamingService
       const replayBuffer = SimpleReplayBufferFactory.create() as ISimpleReplayBuffer;
       const recordingSettings = this.outputSettingsService.getRecordingSettings();
 
-      // console.log('Simple recordingSettings', JSON.stringify(recordingSettings, null, 2));
-
       replayBuffer.path = recordingSettings.path;
       replayBuffer.format = recordingSettings.format;
       replayBuffer.overwrite = recordingSettings.overwrite;
@@ -1567,7 +1558,6 @@ export class StreamingService
   }
 
   saveReplay(display: TDisplayType = 'horizontal') {
-    console.log('this.contexts[display].replayBuffer', this.contexts[display].replayBuffer);
     if (!this.contexts[display].replayBuffer) return;
     this.contexts[display].replayBuffer.save();
   }
@@ -1788,28 +1778,7 @@ export class StreamingService
         this.clearReconnectingNotification();
       }
     }
-    // else if (info.type === EOBSOutputType.ReplayBuffer) {
-    //   const nextState: EReplayBufferState = ({
-    //     [EOBSOutputSignal.Start]: EReplayBufferState.Running,
-    //     [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping,
-    //     [EOBSOutputSignal.Stop]: EReplayBufferState.Offline,
-    //     [EOBSOutputSignal.Wrote]: EReplayBufferState.Running,
-    //     [EOBSOutputSignal.WriteError]: EReplayBufferState.Running,
-    //   } as Dictionary<EReplayBufferState>)[info.signal];
-
-    //   if (nextState) {
-    //     this.SET_REPLAY_BUFFER_STATUS(nextState, 'horizontal', time);
-    //     this.replayBufferStatusChange.next(nextState);
-    //   }
-
-    //   if (info.signal === EOBSOutputSignal.Wrote) {
-    //     this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', {
-    //       status: 'wrote',
-    //       code: info.code,
-    //     });
-    //     this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay());
-    //   }
-    // }
+
     this.handleV2OutputCode(info);
   }
 
@@ -2009,66 +1978,7 @@ export class StreamingService
         this.streamingStatusChange.next(EStreamingState.Live);
         this.clearReconnectingNotification();
       }
-    } else if (info.type === EOBSOutputType.Recording) {
-      const nextState: ERecordingState = ({
-        [EOBSOutputSignal.Start]: ERecordingState.Recording,
-        [EOBSOutputSignal.Starting]: ERecordingState.Starting,
-        [EOBSOutputSignal.Stop]: ERecordingState.Offline,
-        [EOBSOutputSignal.Stopping]: ERecordingState.Stopping,
-        [EOBSOutputSignal.Wrote]: ERecordingState.Wrote,
-      } as Dictionary<ERecordingState>)[info.signal];
-
-      // We received a signal we didn't recognize
-      if (!nextState) return;
-
-      if (info.signal === EOBSOutputSignal.Start) {
-        this.usageStatisticsService.recordFeatureUsage('Recording');
-        this.usageStatisticsService.recordAnalyticsEvent('RecordingStatus', {
-          status: nextState,
-          code: info.code,
-        });
-      }
-
-      if (info.signal === EOBSOutputSignal.Wrote) {
-        const filename = NodeObs.OBS_service_getLastRecording();
-        const parsedFilename = byOS({
-          [OS.Mac]: filename,
-          [OS.Windows]: filename.replace(/\//, '\\'),
-        });
-        this.recordingModeService.actions.addRecordingEntry(parsedFilename);
-        this.markersService.actions.exportCsv(parsedFilename);
-        this.recordingModeService.addRecordingEntry(parsedFilename);
-        this.latestRecordingPath.next(filename);
-        // Wrote signals come after Offline, so we return early here
-        // to not falsely set our state out of Offline
-        return;
-      }
-
-      this.SET_RECORDING_STATUS(nextState, 'horizontal', time);
-      this.recordingStatusChange.next(nextState);
     }
-    // else if (info.type === EOBSOutputType.ReplayBuffer) {
-    //   const nextState: EReplayBufferState = ({
-    //     [EOBSOutputSignal.Start]: EReplayBufferState.Running,
-    //     [EOBSOutputSignal.Stopping]: EReplayBufferState.Stopping,
-    //     [EOBSOutputSignal.Stop]: EReplayBufferState.Offline,
-    //     [EOBSOutputSignal.Wrote]: EReplayBufferState.Running,
-    //     [EOBSOutputSignal.WriteError]: EReplayBufferState.Running,
-    //   } as Dictionary<EReplayBufferState>)[info.signal];
-
-    //   if (nextState) {
-    //     this.SET_REPLAY_BUFFER_STATUS(nextState, 'horizontal', time);
-    //     this.replayBufferStatusChange.next(nextState);
-    //   }
-
-    //   if (info.signal === EOBSOutputSignal.Wrote) {
-    //     this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', {
-    //       status: 'wrote',
-    //       code: info.code,
-    //     });
-    //     this.replayBufferFileWrite.next(NodeObs.OBS_service_getLastReplay());
-    //   }
-    // }
 
     if (info.code) {
       if (this.outputErrorOpen) {

From aa2def2c8caa0eb79b792a7b4bdb5833a8146516 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Fri, 7 Mar 2025 16:10:22 -0500
Subject: [PATCH 10/25] Fix Advanced recording. WIP: Replay buffer lag on stop.

---
 app/components-react/root/StudioFooter.tsx    |   8 +-
 .../platform-apps/api/modules/replay.ts       |   4 +-
 .../settings/output/output-settings.ts        | 101 ++++++++-
 app/services/streaming/streaming-view.ts      |  10 +-
 app/services/streaming/streaming.ts           | 200 +++++++++++-------
 5 files changed, 229 insertions(+), 94 deletions(-)

diff --git a/app/components-react/root/StudioFooter.tsx b/app/components-react/root/StudioFooter.tsx
index 3f57fe081afc..e6747c9342fd 100644
--- a/app/components-react/root/StudioFooter.tsx
+++ b/app/components-react/root/StudioFooter.tsx
@@ -42,9 +42,9 @@ export default function StudioFooterComponent() {
       StreamingService.views.supports('stream-schedule') &&
       !RecordingModeService.views.isRecordingModeEnabled,
     streamQuality: PerformanceService.views.streamQuality,
-    replayBufferOffline: StreamingService.state.replayBufferStatus === EReplayBufferState.Offline,
-    replayBufferStopping: StreamingService.state.replayBufferStatus === EReplayBufferState.Stopping,
-    replayBufferSaving: StreamingService.state.replayBufferStatus === EReplayBufferState.Saving,
+    replayBufferOffline: StreamingService.views.replayBufferStatus === EReplayBufferState.Offline,
+    replayBufferStopping: StreamingService.views.replayBufferStatus === EReplayBufferState.Stopping,
+    replayBufferSaving: StreamingService.views.replayBufferStatus === EReplayBufferState.Saving,
     recordingModeEnabled: RecordingModeService.views.isRecordingModeEnabled,
     replayBufferEnabled: SettingsService.views.values.Output.RecRB,
   }));
@@ -197,7 +197,7 @@ function RecordingButton() {
   const { StreamingService } = Services;
   const { isRecording, recordingStatus } = useVuex(() => ({
     isRecording: StreamingService.views.isRecording,
-    recordingStatus: StreamingService.state.recordingStatus,
+    recordingStatus: StreamingService.state.status.horizontal.recordingTime,
   }));
 
   function toggleRecording() {
diff --git a/app/services/platform-apps/api/modules/replay.ts b/app/services/platform-apps/api/modules/replay.ts
index 1d8b58ab3e90..21ed83f8af31 100644
--- a/app/services/platform-apps/api/modules/replay.ts
+++ b/app/services/platform-apps/api/modules/replay.ts
@@ -107,8 +107,8 @@ export class ReplayModule extends Module {
 
   private serializeState(): IReplayBufferState {
     return {
-      status: this.streamingService.state.replayBufferStatus,
-      statusTime: this.streamingService.state.replayBufferStatusTime,
+      status: this.streamingService.state.status.horizontal.replayBuffer,
+      statusTime: this.streamingService.state.status.horizontal.replayBufferTime,
     };
   }
 }
diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts
index e9cecdfc29e5..73e971707c10 100644
--- a/app/services/settings/output/output-settings.ts
+++ b/app/services/settings/output/output-settings.ts
@@ -240,10 +240,6 @@ export class OutputSettingsService extends Service {
   getRecordingSettings() {
     const output = this.settingsService.state.Output.formData;
     const advanced = this.settingsService.state.Advanced.formData;
-
-    console.log('output', JSON.stringify(output, null, 2));
-    console.log('advanced', JSON.stringify(advanced, null, 2));
-
     const mode: TOutputSettingsMode = this.settingsService.findSettingValue(
       output,
       'Untitled',
@@ -352,6 +348,103 @@ export class OutputSettingsService extends Service {
     }
   }
 
+  getReplayBufferSettings() {
+    const output = this.settingsService.state.Output.formData;
+    const advanced = this.settingsService.state.Advanced.formData;
+
+    const mode: TOutputSettingsMode = this.settingsService.findSettingValue(
+      output,
+      'Untitled',
+      'Mode',
+    );
+
+    const pathKey = mode === 'Advanced' ? 'RecFilePath' : 'FilePath';
+    const path: string = this.settingsService.findSettingValue(output, 'Recording', pathKey);
+
+    const format: ERecordingFormat = this.settingsService.findValidListValue(
+      output,
+      'Recording',
+      'RecFormat',
+    ) as ERecordingFormat;
+
+    const oldQualityName = this.settingsService.findSettingValue(output, 'Recording', 'RecQuality');
+    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 overwrite: boolean = this.settingsService.findSettingValue(
+      advanced,
+      'Recording',
+      'OverwriteIfExists',
+    );
+
+    const noSpace: boolean = this.settingsService.findSettingValue(
+      output,
+      'Recording',
+      'FileNameWithoutSpace',
+    );
+
+    const prefix: string = this.settingsService.findSettingValue(
+      output,
+      'Recording',
+      'RecRBPrefix',
+    );
+    const suffix: string = this.settingsService.findSettingValue(
+      output,
+      'Recording',
+      'RecRBSuffix',
+    );
+    const duration: number = this.settingsService.findSettingValue(
+      output,
+      'Stream Delay',
+      'DelaySec',
+    );
+
+    const useStreamEncoders =
+      this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder') === 'none';
+
+    if (mode === 'Advanced') {
+      const mixer = this.settingsService.findSettingValue(output, 'Recording', 'RecTracks');
+
+      // advanced settings
+      return {
+        path,
+        format,
+        overwrite,
+        noSpace,
+        mixer,
+        useStreamEncoders,
+        prefix,
+        suffix,
+        duration,
+      };
+    } else {
+      // simple settings
+      return {
+        path,
+        format,
+        overwrite,
+        noSpace,
+        prefix,
+        suffix,
+        duration,
+        useStreamEncoders,
+      };
+    }
+  }
+
   getSimpleRecordingSettings() {
     const output = this.settingsService.state.Output.formData;
     const advanced = this.settingsService.state.Advanced.formData;
diff --git a/app/services/streaming/streaming-view.ts b/app/services/streaming/streaming-view.ts
index e363ae5459d3..d0b122b9803b 100644
--- a/app/services/streaming/streaming-view.ts
+++ b/app/services/streaming/streaming-view.ts
@@ -545,11 +545,7 @@ export class StreamInfoView<T extends Object> extends ViewHandler<T> {
   }
 
   get isRecording() {
-    return this.streamingState.recordingStatus !== ERecordingState.Offline;
-  }
-
-  get isReplayBufferActive() {
-    return this.streamingState.replayBufferStatus !== EReplayBufferState.Offline;
+    return this.streamingState.status.horizontal.recording !== ERecordingState.Offline;
   }
 
   get isIdle(): boolean {
@@ -564,4 +560,8 @@ export class StreamInfoView<T extends Object> extends ViewHandler<T> {
   get selectiveRecording() {
     return this.streamingState.selectiveRecording;
   }
+
+  get replayBufferStatus() {
+    return this.streamingState.status.horizontal.replayBuffer;
+  }
 }
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index 269df9cd40c1..eb9a15d9803e 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -915,7 +915,7 @@ export class StreamingService
   }
 
   get isRecording() {
-    return this.state.recordingStatus !== ERecordingState.Offline;
+    return this.state.status.horizontal.recording !== ERecordingState.Offline;
   }
 
   get isReplayBufferActive() {
@@ -1034,7 +1034,10 @@ export class StreamingService
 
       const recordWhenStreaming = this.streamSettingsService.settings.recordWhenStreaming;
 
-      if (recordWhenStreaming && this.state.recordingStatus === ERecordingState.Offline) {
+      if (
+        recordWhenStreaming &&
+        this.state.status.horizontal.recording === ERecordingState.Offline
+      ) {
         this.toggleRecording();
       }
     }
@@ -1141,7 +1144,7 @@ export class StreamingService
 
       const keepRecording = this.streamSettingsService.settings.keepRecordingWhenStreamStops;
       const isRecording =
-        this.state.recordingStatus === ERecordingState.Recording ||
+        this.state.status.horizontal.recording === ERecordingState.Recording ||
         this.state.status.vertical.recording === ERecordingState.Recording;
       if (!keepRecording && isRecording) {
         this.toggleRecording();
@@ -1197,9 +1200,16 @@ export class StreamingService
   }
 
   toggleRecording() {
+    console.log('this.state.status.horizontal.recording', this.state.status.horizontal.recording);
+    console.log('this.contexts.horizontal.recording', this.contexts.horizontal.recording);
+    console.log(
+      'this.state.status.horizontal.recording',
+      this.state.status.horizontal.replayBuffer,
+    );
+    console.log('this.contexts.horizontal.recording', this.contexts.horizontal.replayBuffer);
     // stop recording
     if (
-      this.state.recordingStatus === ERecordingState.Recording &&
+      this.state.status.horizontal.recording === ERecordingState.Recording &&
       this.state.status.vertical.recording === ERecordingState.Recording
     ) {
       // stop recording both displays
@@ -1237,7 +1247,7 @@ export class StreamingService
       this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', time);
       this.contexts.vertical.recording.stop();
     } else if (
-      this.state.recordingStatus === ERecordingState.Recording &&
+      this.state.status.horizontal.recording === ERecordingState.Recording &&
       this.contexts.horizontal.recording !== null
     ) {
       const time = new Date().toISOString();
@@ -1248,7 +1258,7 @@ export class StreamingService
 
     // start recording
     if (
-      this.state.recordingStatus === ERecordingState.Offline &&
+      this.state.status.horizontal.recording === ERecordingState.Offline &&
       this.state.status.vertical.recording === ERecordingState.Offline
     ) {
       if (this.views.isDualOutputMode) {
@@ -1268,7 +1278,7 @@ export class StreamingService
     }
   }
 
-  private createRecording(display: TDisplayType, index: number) {
+  private createRecording(display: TDisplayType, index: number, skipStart: boolean = false) {
     this.destroyOutputContextIfExists(display, 'recording');
 
     const mode = this.outputSettingsService.getSettings().mode;
@@ -1315,9 +1325,10 @@ export class StreamingService
       // streaming object
       // TODO: move to its own function
       const videoEncoder = recording.videoEncoder;
-      const stream =
-        (this.contexts[display].streaming as IAdvancedStreaming) ??
-        (AdvancedStreamingFactory.create() as IAdvancedStreaming);
+
+      this.validateOrCreateOutputInstance(mode, display, 'streaming');
+
+      const stream = this.contexts[display].streaming as IAdvancedStreaming;
       stream.enforceServiceBitrate = false;
       stream.enableTwitchVOD = false;
       stream.audioTrack = index;
@@ -1337,6 +1348,12 @@ export class StreamingService
 
     // save in state
     this.contexts[display].recording = recording;
+
+    // The replay buffer requires a recording instance. If the user is streaming but not recording,
+    // a recording instance still needs to be created but does not need to be started.
+    if (skipStart) return;
+
+    // start recording
     this.contexts[display].recording.start();
   }
 
@@ -1346,7 +1363,7 @@ export class StreamingService
       [EOutputSignalState.Start]: ERecordingState.Recording,
       [EOutputSignalState.Stop]: ERecordingState.Offline,
       [EOutputSignalState.Stopping]: ERecordingState.Stopping,
-      [EOutputSignalState.Wrote]: ERecordingState.Wrote,
+      [EOutputSignalState.Wrote]: ERecordingState.Offline,
     } as Dictionary<ERecordingState>)[info.signal];
 
     // We received a signal we didn't recognize
@@ -1371,7 +1388,7 @@ export class StreamingService
       });
     }
 
-    if (nextState === ERecordingState.Wrote) {
+    if (info.signal === EOutputSignalState.Wrote) {
       const fileName = this.contexts[display].recording.lastFile();
 
       const parsedName = byOS({
@@ -1396,9 +1413,6 @@ export class StreamingService
       }
 
       this.latestRecordingPath.next(fileName);
-
-      this.handleV2OutputCode(info);
-      return;
     }
 
     const time = new Date().toISOString();
@@ -1408,9 +1422,12 @@ export class StreamingService
     this.handleV2OutputCode(info);
   }
 
-  splitFile() {
-    if (this.state.recordingStatus === ERecordingState.Recording) {
-      NodeObs.OBS_service_splitFile();
+  splitFile(display: TDisplayType = 'horizontal') {
+    if (
+      this.state.status.horizontal.recording === ERecordingState.Recording &&
+      this.contexts[display].recording
+    ) {
+      this.contexts[display].recording.splitFile();
     }
   }
 
@@ -1441,12 +1458,6 @@ export class StreamingService
       [EOBSOutputSignal.Stop]: EReplayBufferState.Offline,
     } as Dictionary<EReplayBufferState>)[info.signal];
 
-    if (nextState) {
-      const time = new Date().toISOString();
-      this.SET_REPLAY_BUFFER_STATUS(nextState, display, time);
-      this.replayBufferStatusChange.next(nextState);
-    }
-
     if (info.signal === EOBSOutputSignal.Wrote) {
       this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', {
         status: 'wrote',
@@ -1472,6 +1483,12 @@ export class StreamingService
 
       this.destroyOutputContextIfExists(display, 'replayBuffer');
     }
+
+    if (nextState) {
+      const time = new Date().toISOString();
+      this.SET_REPLAY_BUFFER_STATUS(nextState, display, time);
+      this.replayBufferStatusChange.next(nextState);
+    }
   }
 
   showHighlighter() {
@@ -1486,64 +1503,44 @@ export class StreamingService
   startReplayBuffer(display: TDisplayType = 'horizontal') {
     if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return;
 
-    // the replay buffer must have a recording instance to reference
-    if (
-      this.state.streamingStatus !== EStreamingState.Offline &&
-      !this.contexts[display].recording
-    ) {
-      this.createRecording(display, 1);
-    }
+    this.createReplayBuffer(display);
+  }
+
+  createReplayBuffer(display: TDisplayType = 'horizontal') {
+    this.destroyOutputContextIfExists(display, 'replayBuffer');
 
     const mode = this.outputSettingsService.getSettings().mode;
 
-    this.destroyOutputContextIfExists(display, 'replayBuffer');
+    // A replay buffer requires a recording instance
+    this.validateOrCreateOutputInstance(mode, display, 'recording');
 
-    if (mode === 'Advanced') {
-      const replayBuffer = AdvancedReplayBufferFactory.create() as IAdvancedReplayBuffer;
-      const recordingSettings = this.outputSettingsService.getRecordingSettings();
-
-      replayBuffer.path = recordingSettings.path;
-      replayBuffer.format = recordingSettings.format;
-      replayBuffer.overwrite = recordingSettings.overwrite;
-      replayBuffer.noSpace = recordingSettings.noSpace;
-      replayBuffer.duration = recordingSettings.duration;
-      replayBuffer.video = this.videoSettingsService.contexts[display];
-      replayBuffer.prefix = recordingSettings.prefix;
-      replayBuffer.suffix = recordingSettings.suffix;
-      replayBuffer.usesStream = recordingSettings.useStreamEncoders;
-      replayBuffer.mixer = recordingSettings.mixer;
-      replayBuffer.recording = this.contexts[display].recording as IAdvancedRecording;
-      replayBuffer.signalHandler = async signal => {
-        console.log('replay buffer signal', signal);
-        await this.handleSignal(signal, display);
-      };
-
-      this.contexts[display].replayBuffer = replayBuffer;
-      this.contexts[display].replayBuffer.start();
-      this.usageStatisticsService.recordFeatureUsage('ReplayBuffer');
-    } else {
-      const replayBuffer = SimpleReplayBufferFactory.create() as ISimpleReplayBuffer;
-      const recordingSettings = this.outputSettingsService.getRecordingSettings();
-
-      replayBuffer.path = recordingSettings.path;
-      replayBuffer.format = recordingSettings.format;
-      replayBuffer.overwrite = recordingSettings.overwrite;
-      replayBuffer.noSpace = recordingSettings.noSpace;
-      replayBuffer.video = this.videoSettingsService.contexts[display];
-      replayBuffer.duration = recordingSettings.duration;
-      replayBuffer.prefix = recordingSettings.prefix;
-      replayBuffer.suffix = recordingSettings.suffix;
-      replayBuffer.usesStream = true;
-      replayBuffer.recording = this.contexts[display].recording as ISimpleRecording;
-      replayBuffer.signalHandler = async signal => {
-        console.log('replay buffer signal', signal);
-        await this.handleSignal(signal, display);
-      };
-
-      this.contexts[display].replayBuffer = replayBuffer;
-      this.contexts[display].replayBuffer.start();
-      this.usageStatisticsService.recordFeatureUsage('ReplayBuffer');
-    }
+    const settings = this.outputSettingsService.getRecordingSettings();
+    const replayBuffer =
+      mode === 'Advanced'
+        ? (AdvancedReplayBufferFactory.create() as IAdvancedReplayBuffer)
+        : (SimpleReplayBufferFactory.create() as ISimpleReplayBuffer);
+
+    // assign settings
+    Object.keys(settings).forEach(key => {
+      if ((settings as any)[key] === undefined) return;
+      (replayBuffer as any)[key] = (settings as any)[key];
+    });
+
+    replayBuffer.recording =
+      mode === 'Advanced'
+        ? (this.contexts[display].recording as IAdvancedRecording)
+        : (this.contexts[display].recording as ISimpleRecording);
+
+    this.contexts[display].replayBuffer = replayBuffer;
+
+    this.contexts[display].replayBuffer.video = this.videoSettingsService.contexts[display];
+    this.contexts[display].replayBuffer.signalHandler = async signal => {
+      console.log('replay buffer signal', signal);
+      await this.handleSignal(signal, display);
+    };
+
+    this.contexts[display].replayBuffer.start();
+    this.usageStatisticsService.recordFeatureUsage('ReplayBuffer');
   }
 
   stopReplayBuffer(display: TDisplayType = 'horizontal') {
@@ -1554,7 +1551,15 @@ export class StreamingService
       return;
     }
     const forceStop = this.state.status[display].replayBuffer === EReplayBufferState.Stopping;
+
     this.contexts[display].replayBuffer.stop(forceStop);
+
+    // In the case that the user is streaming but not recording, a recording instance
+    // was created for the replay buffer. If the replay buffer is stopped, the recording
+    // instance should be destroyed.
+    if (this.state.status.horizontal.recording === ERecordingState.Offline) {
+      this.destroyOutputContextIfExists(display, 'recording');
+    }
   }
 
   saveReplay(display: TDisplayType = 'horizontal') {
@@ -1562,6 +1567,39 @@ export class StreamingService
     this.contexts[display].replayBuffer.save();
   }
 
+  validateOrCreateOutputInstance(
+    mode: 'Simple' | 'Advanced',
+    display: TDisplayType,
+    type: 'streaming' | 'recording',
+  ) {
+    if (this.contexts[display][type]) {
+      // Check for a property that only exists on the output type's advanced instance
+      // Note: the properties below were chosen arbitrarily
+      const isAdvancedOutputInstance =
+        type === 'streaming'
+          ? this.contexts[display][type].hasOwnProperty('rescaling')
+          : this.contexts[display][type].hasOwnProperty('useStreamEncoders');
+
+      if (
+        (mode === 'Simple' && isAdvancedOutputInstance) ||
+        (mode === 'Advanced' && !isAdvancedOutputInstance)
+      ) {
+        this.destroyOutputContextIfExists(display, type);
+      } else {
+        return;
+      }
+    }
+
+    // Create new instance if it does not exist or was destroyed
+    if (type === 'streaming') {
+      // TODO: create streaming instance
+      this.contexts[display].streaming =
+        mode === 'Advanced' ? AdvancedStreamingFactory.create() : SimpleStreamingFactory.create();
+    } else {
+      this.createRecording(display, 1, true);
+    }
+  }
+
   /**
    * Show the GoLiveWindow
    * Prefill fields with data if `prepopulateOptions` provided
@@ -1642,7 +1680,7 @@ 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) {
+    if (this.state.status.horizontal.recording !== ERecordingState.Offline) {
       this.formattedDurationSince(moment(this.state.recordingStatusTime));
     } else if (this.state.status.vertical.recording !== ERecordingState.Offline) {
       return this.formattedDurationSince(moment(this.state.status.vertical.recordingTime));
@@ -2223,6 +2261,10 @@ export class StreamingService
   ) {
     if (!this.contexts[display] || !this.contexts[display][contextType]) return;
 
+    if (this.state.status[display][contextType].toString() !== 'offline') {
+      this.contexts[display][contextType].stop();
+    }
+
     if (this.outputSettingsService.getSettings().mode === 'Advanced') {
       switch (contextType) {
         case 'streaming':

From aa8cf693d8f35529ef73187c4bd2bfc3d1a9ea2c Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Fri, 7 Mar 2025 17:24:51 -0500
Subject: [PATCH 11/25] Fixed advanced replay buffer.

---
 app/services/streaming/streaming.ts | 88 +++++++++++++++++++----------
 1 file changed, 59 insertions(+), 29 deletions(-)

diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index eb9a15d9803e..0f2a3b90ebf2 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -22,6 +22,7 @@ import {
   AdvancedStreamingFactory,
   SimpleStreamingFactory,
 } from '../../../obs-api';
+import * as obs from '../../../obs-api';
 import { Inject } from 'services/core/injector';
 import moment from 'moment';
 import padStart from 'lodash/padStart';
@@ -1206,7 +1207,6 @@ export class StreamingService
       'this.state.status.horizontal.recording',
       this.state.status.horizontal.replayBuffer,
     );
-    console.log('this.contexts.horizontal.recording', this.contexts.horizontal.replayBuffer);
     // stop recording
     if (
       this.state.status.horizontal.recording === ERecordingState.Recording &&
@@ -1243,17 +1243,14 @@ export class StreamingService
       this.contexts.vertical.recording !== null
     ) {
       // stop recording vertical display
-      const time = new Date().toISOString();
-      this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', time);
       this.contexts.vertical.recording.stop();
+      return;
     } else if (
       this.state.status.horizontal.recording === ERecordingState.Recording &&
       this.contexts.horizontal.recording !== null
     ) {
-      const time = new Date().toISOString();
-      // stop recording horizontal display
-      this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', time);
-      this.contexts.horizontal.recording.stop();
+      this.contexts.horizontal.recording.stop(true);
+      return;
     }
 
     // start recording
@@ -1301,15 +1298,6 @@ export class StreamingService
       }
     });
 
-    // assign context
-    recording.video = this.videoSettingsService.contexts[display];
-
-    // set signal handler
-    recording.signalHandler = async signal => {
-      console.log('recording signal', signal);
-      await this.handleSignal(signal, display);
-    };
-
     // handle unique properties (including audio)
     if (mode === 'Advanced') {
       // output resolutions
@@ -1338,6 +1326,10 @@ export class StreamingService
       stream.outputHeight = ((recording as unknown) as IAdvancedStreaming).outputHeight;
       stream.video = this.videoSettingsService.contexts[display];
       stream.videoEncoder = videoEncoder;
+      stream.signalHandler = async signal => {
+        console.log('streaming signal', signal);
+        await this.handleSignal(signal, display);
+      };
 
       this.contexts[display].streaming = stream;
       recording.videoEncoder = videoEncoder;
@@ -1347,8 +1339,19 @@ export class StreamingService
     }
 
     // save in state
+    // this.contexts[display].recording =
+    //   mode === 'Advanced' ? (recording as IAdvancedRecording) : (recording as ISimpleRecording);
     this.contexts[display].recording = recording;
 
+    // assign context
+    this.contexts[display].recording.video = this.videoSettingsService.contexts[display];
+
+    // set signal handler
+    this.contexts[display].recording.signalHandler = async signal => {
+      console.log('recording signal', signal);
+      await this.handleSignal(signal, display);
+    };
+
     // The replay buffer requires a recording instance. If the user is streaming but not recording,
     // a recording instance still needs to be created but does not need to be started.
     if (skipStart) return;
@@ -1405,11 +1408,13 @@ export class StreamingService
       await this.markersService.exportCsv(parsedName);
 
       // destroy recording instance
-      this.destroyOutputContextIfExists(display, 'recording');
+      await this.destroyOutputContextIfExists(display, 'recording');
       // Also destroy the streaming instance if it was only created for recording
       // Note: this is only the case when recording without streaming in advanced mode
       if (this.state.status[display].streaming === EStreamingState.Offline) {
-        this.destroyOutputContextIfExists(display, 'streaming');
+        console.log('destroying streaming instance');
+
+        await this.destroyOutputContextIfExists(display, 'streaming');
       }
 
       this.latestRecordingPath.next(fileName);
@@ -1438,7 +1443,7 @@ export class StreamingService
     }
 
     if (info.type === EOBSOutputType.ReplayBuffer) {
-      this.handleReplayBufferSignal(info, display);
+      await this.handleReplayBufferSignal(info, display);
       return;
     }
 
@@ -1448,7 +1453,7 @@ export class StreamingService
     }
   }
 
-  private handleReplayBufferSignal(info: EOutputSignal, display: TDisplayType) {
+  private async handleReplayBufferSignal(info: EOutputSignal, display: TDisplayType) {
     // map signals to status
     const nextState: EReplayBufferState = ({
       [EOBSOutputSignal.Start]: EReplayBufferState.Running,
@@ -1481,7 +1486,7 @@ export class StreamingService
         code: info.code,
       });
 
-      this.destroyOutputContextIfExists(display, 'replayBuffer');
+      await this.destroyOutputContextIfExists(display, 'replayBuffer');
     }
 
     if (nextState) {
@@ -1498,10 +1503,13 @@ export class StreamingService
   // TODO migrate streaming to new API
   private handleStreamingSignal(info: EOutputSignal, display: TDisplayType) {
     // map signals to status
+    console.log('streaming signal info', info);
   }
 
   startReplayBuffer(display: TDisplayType = 'horizontal') {
     if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return;
+    // change the replay buffer status for the loading animation
+    // this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Running, display);
 
     this.createReplayBuffer(display);
   }
@@ -1531,7 +1539,10 @@ export class StreamingService
         ? (this.contexts[display].recording as IAdvancedRecording)
         : (this.contexts[display].recording as ISimpleRecording);
 
-    this.contexts[display].replayBuffer = replayBuffer;
+    this.contexts[display].replayBuffer =
+      mode === 'Advanced'
+        ? (replayBuffer as IAdvancedReplayBuffer)
+        : (replayBuffer as ISimpleReplayBuffer);
 
     this.contexts[display].replayBuffer.video = this.videoSettingsService.contexts[display];
     this.contexts[display].replayBuffer.signalHandler = async signal => {
@@ -1550,9 +1561,12 @@ export class StreamingService
     ) {
       return;
     }
+
     const forceStop = this.state.status[display].replayBuffer === EReplayBufferState.Stopping;
 
     this.contexts[display].replayBuffer.stop(forceStop);
+    // change the replay buffer status for the loading animation
+    // this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Stopping, display);
 
     // In the case that the user is streaming but not recording, a recording instance
     // was created for the replay buffer. If the replay buffer is stopped, the recording
@@ -1564,10 +1578,12 @@ export class StreamingService
 
   saveReplay(display: TDisplayType = 'horizontal') {
     if (!this.contexts[display].replayBuffer) return;
+    // change the replay buffer status for the loading animation
+    // this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Saving, display);
     this.contexts[display].replayBuffer.save();
   }
 
-  validateOrCreateOutputInstance(
+  private validateOrCreateOutputInstance(
     mode: 'Simple' | 'Advanced',
     display: TDisplayType,
     type: 'streaming' | 'recording',
@@ -1577,8 +1593,8 @@ export class StreamingService
       // Note: the properties below were chosen arbitrarily
       const isAdvancedOutputInstance =
         type === 'streaming'
-          ? this.contexts[display][type].hasOwnProperty('rescaling')
-          : this.contexts[display][type].hasOwnProperty('useStreamEncoders');
+          ? this.isAdvancedStreaming(this.contexts[display][type])
+          : this.isAdvancedRecording(this.contexts[display][type]);
 
       if (
         (mode === 'Simple' && isAdvancedOutputInstance) ||
@@ -1600,6 +1616,14 @@ export class StreamingService
     }
   }
 
+  isAdvancedStreaming(instance: any): instance is IAdvancedStreaming {
+    return 'rescaling' in instance;
+  }
+
+  isAdvancedRecording(instance: any): instance is IAdvancedRecording {
+    return 'useStreamEncoders' in instance;
+  }
+
   /**
    * Show the GoLiveWindow
    * Prefill fields with data if `prepopulateOptions` provided
@@ -2243,8 +2267,8 @@ export class StreamingService
    */
   shutdown() {
     Object.keys(this.contexts).forEach(display => {
-      Object.keys(this.contexts[display]).forEach((contextType: keyof IOutputContext) => {
-        this.destroyOutputContextIfExists(display, contextType);
+      Object.keys(this.contexts[display]).forEach(async (contextType: keyof IOutputContext) => {
+        await this.destroyOutputContextIfExists(display, contextType);
       });
     });
   }
@@ -2254,12 +2278,16 @@ export class StreamingService
    * @remark Will just return if the context is null
    * @param display - The display to destroy the output context for
    * @param contextType - The name of the output context to destroy
+   * @returns A promise that resolves to true if the context was destroyed, false
+   * if the context did not exist
    */
-  private destroyOutputContextIfExists(
+  private async destroyOutputContextIfExists(
     display: TDisplayType | string,
     contextType: keyof IOutputContext,
   ) {
-    if (!this.contexts[display] || !this.contexts[display][contextType]) return;
+    if (!this.contexts[display] || !this.contexts[display][contextType]) {
+      return Promise.resolve(false);
+    }
 
     if (this.state.status[display][contextType].toString() !== 'offline') {
       this.contexts[display][contextType].stop();
@@ -2300,6 +2328,8 @@ export class StreamingService
     }
 
     this.contexts[display][contextType] = null;
+
+    return Promise.resolve(true);
   }
 
   @mutation()

From 32e2d87d6fb1c581f1df6465bcaa26a400b0de99 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Fri, 7 Mar 2025 17:45:39 -0500
Subject: [PATCH 12/25] Remove unnecessary async.

---
 app/services/streaming/streaming.ts | 50 ++++++++++++++++-------------
 1 file changed, 27 insertions(+), 23 deletions(-)

diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index 0f2a3b90ebf2..f5296e853e60 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1201,12 +1201,6 @@ export class StreamingService
   }
 
   toggleRecording() {
-    console.log('this.state.status.horizontal.recording', this.state.status.horizontal.recording);
-    console.log('this.contexts.horizontal.recording', this.contexts.horizontal.recording);
-    console.log(
-      'this.state.status.horizontal.recording',
-      this.state.status.horizontal.replayBuffer,
-    );
     // stop recording
     if (
       this.state.status.horizontal.recording === ERecordingState.Recording &&
@@ -1243,14 +1237,17 @@ export class StreamingService
       this.contexts.vertical.recording !== null
     ) {
       // stop recording vertical display
+      // change the recording status for the loading animation
+      this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', new Date().toISOString());
       this.contexts.vertical.recording.stop();
-      return;
     } else if (
       this.state.status.horizontal.recording === ERecordingState.Recording &&
       this.contexts.horizontal.recording !== null
     ) {
+      // stop recording horizontal display
+      // change the recording status for the loading animation
+      this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', new Date().toISOString());
       this.contexts.horizontal.recording.stop(true);
-      return;
     }
 
     // start recording
@@ -1339,8 +1336,6 @@ export class StreamingService
     }
 
     // save in state
-    // this.contexts[display].recording =
-    //   mode === 'Advanced' ? (recording as IAdvancedRecording) : (recording as ISimpleRecording);
     this.contexts[display].recording = recording;
 
     // assign context
@@ -1408,13 +1403,11 @@ export class StreamingService
       await this.markersService.exportCsv(parsedName);
 
       // destroy recording instance
-      await this.destroyOutputContextIfExists(display, 'recording');
+      this.destroyOutputContextIfExists(display, 'recording');
       // Also destroy the streaming instance if it was only created for recording
       // Note: this is only the case when recording without streaming in advanced mode
       if (this.state.status[display].streaming === EStreamingState.Offline) {
-        console.log('destroying streaming instance');
-
-        await this.destroyOutputContextIfExists(display, 'streaming');
+        this.destroyOutputContextIfExists(display, 'streaming');
       }
 
       this.latestRecordingPath.next(fileName);
@@ -1486,7 +1479,7 @@ export class StreamingService
         code: info.code,
       });
 
-      await this.destroyOutputContextIfExists(display, 'replayBuffer');
+      this.destroyOutputContextIfExists(display, 'replayBuffer');
     }
 
     if (nextState) {
@@ -1494,6 +1487,8 @@ export class StreamingService
       this.SET_REPLAY_BUFFER_STATUS(nextState, display, time);
       this.replayBufferStatusChange.next(nextState);
     }
+
+    this.handleV2OutputCode(info);
   }
 
   showHighlighter() {
@@ -1566,7 +1561,7 @@ export class StreamingService
 
     this.contexts[display].replayBuffer.stop(forceStop);
     // change the replay buffer status for the loading animation
-    // this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Stopping, display);
+    this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Stopping, display, new Date().toISOString());
 
     // In the case that the user is streaming but not recording, a recording instance
     // was created for the replay buffer. If the replay buffer is stopped, the recording
@@ -1579,7 +1574,7 @@ export class StreamingService
   saveReplay(display: TDisplayType = 'horizontal') {
     if (!this.contexts[display].replayBuffer) return;
     // change the replay buffer status for the loading animation
-    // this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Saving, display);
+    this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Saving, display, new Date().toISOString());
     this.contexts[display].replayBuffer.save();
   }
 
@@ -2281,16 +2276,27 @@ export class StreamingService
    * @returns A promise that resolves to true if the context was destroyed, false
    * if the context did not exist
    */
-  private async destroyOutputContextIfExists(
+  private destroyOutputContextIfExists(
     display: TDisplayType | string,
     contextType: keyof IOutputContext,
   ) {
-    if (!this.contexts[display] || !this.contexts[display][contextType]) {
-      return Promise.resolve(false);
-    }
+    if (!this.contexts[display] || !this.contexts[display][contextType]) return;
 
     if (this.state.status[display][contextType].toString() !== 'offline') {
       this.contexts[display][contextType].stop();
+
+      // change the status to offline for the UI
+      switch (contextType) {
+        case 'streaming':
+          this.state.status[display][contextType] = EStreamingState.Offline;
+          break;
+        case 'recording':
+          this.state.status[display][contextType] = ERecordingState.Offline;
+          break;
+        case 'replayBuffer':
+          this.state.status[display][contextType] = EReplayBufferState.Offline;
+          break;
+      }
     }
 
     if (this.outputSettingsService.getSettings().mode === 'Advanced') {
@@ -2328,8 +2334,6 @@ export class StreamingService
     }
 
     this.contexts[display][contextType] = null;
-
-    return Promise.resolve(true);
   }
 
   @mutation()

From 026d6bb9eb58bcd63268e3959666faf816148902 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 11 Mar 2025 16:52:11 -0400
Subject: [PATCH 13/25] Move create stream to own function. Fix replay buffer
 state update issue. Add error handling.

---
 app/components-react/root/StudioFooter.tsx    |   6 +-
 app/i18n/en-US/streaming.json                 |   3 +-
 app/services/highlighter/index.ts             |  14 +
 .../settings/output/output-settings.ts        |  83 ++
 app/services/streaming/stream-error.ts        |   7 +-
 app/services/streaming/streaming-view.ts      |   1 -
 app/services/streaming/streaming.ts           | 788 ++++++++----------
 7 files changed, 472 insertions(+), 430 deletions(-)

diff --git a/app/components-react/root/StudioFooter.tsx b/app/components-react/root/StudioFooter.tsx
index e6747c9342fd..b9ba49ddfeb0 100644
--- a/app/components-react/root/StudioFooter.tsx
+++ b/app/components-react/root/StudioFooter.tsx
@@ -43,8 +43,8 @@ export default function StudioFooterComponent() {
       !RecordingModeService.views.isRecordingModeEnabled,
     streamQuality: PerformanceService.views.streamQuality,
     replayBufferOffline: StreamingService.views.replayBufferStatus === EReplayBufferState.Offline,
-    replayBufferStopping: StreamingService.views.replayBufferStatus === EReplayBufferState.Stopping,
-    replayBufferSaving: StreamingService.views.replayBufferStatus === EReplayBufferState.Saving,
+    replayBufferStopping: StreamingService.state.replayBufferStatus === EReplayBufferState.Stopping,
+    replayBufferSaving: StreamingService.state.replayBufferStatus === EReplayBufferState.Saving,
     recordingModeEnabled: RecordingModeService.views.isRecordingModeEnabled,
     replayBufferEnabled: SettingsService.views.values.Output.RecRB,
   }));
@@ -83,7 +83,7 @@ export default function StudioFooterComponent() {
   }
 
   function toggleReplayBuffer() {
-    if (StreamingService.state.replayBufferStatus === EReplayBufferState.Offline) {
+    if (replayBufferOffline) {
       StreamingService.actions.startReplayBuffer();
     } else {
       StreamingService.actions.stopReplayBuffer();
diff --git a/app/i18n/en-US/streaming.json b/app/i18n/en-US/streaming.json
index 625039fd835a..a95e176685f4 100644
--- a/app/i18n/en-US/streaming.json
+++ b/app/i18n/en-US/streaming.json
@@ -289,5 +289,6 @@
   "Issues": "Issues",
   "Multistream Error": "Multistream Error",
   "One of destinations might have incomplete permissions. Reconnect the destinations in settings and try again.": "One of destinations might have incomplete permissions. Reconnect the destinations in settings and try again.",
-  "A new Highlight has been saved. Click to edit in the Highlighter": "A new Highlight has been saved. Click to edit in the Highlighter"
+  "A new Highlight has been saved. Click to edit in the Highlighter": "A new Highlight has been saved. Click to edit in the Highlighter",
+  "An unknown %{type} error occurred.": "An unknown %{type} error occurred."
 }
diff --git a/app/services/highlighter/index.ts b/app/services/highlighter/index.ts
index 414d58cd1198..e74ce62dd3db 100644
--- a/app/services/highlighter/index.ts
+++ b/app/services/highlighter/index.ts
@@ -352,6 +352,16 @@ export class HighlighterService extends PersistentStatefulService<IHighlighterSt
     }
 
     this.handleStreamingChanges();
+
+    this.streamingService.replayBufferFileWrite.subscribe(async clipPath => {
+      const message = $t('A new Highlight has been saved. Click to edit in the Highlighter');
+
+      this.notificationsService.actions.push({
+        type: ENotificationType.SUCCESS,
+        message,
+        action: this.jsonrpcService.createRequest(Service.getResourceId(this), 'showHighlighter'),
+      });
+    });
   }
 
   private handleStreamingChanges() {
@@ -1490,4 +1500,8 @@ export class HighlighterService extends PersistentStatefulService<IHighlighterSt
 
     return id;
   }
+
+  showHighlighter() {
+    this.navigationService.navigate('Highlighter');
+  }
 }
diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts
index 73e971707c10..1dee9b443eb2 100644
--- a/app/services/settings/output/output-settings.ts
+++ b/app/services/settings/output/output-settings.ts
@@ -230,6 +230,89 @@ export class OutputSettingsService extends Service {
     };
   }
 
+  /**
+   * Get recording settings
+   * @remark Primarily used for setting up the recording output context,
+   * this function will automatically return either the simple or advanced
+   * settings based on the current mode.
+   * @returns settings for the recording
+   */
+  getStreamingSettings() {
+    const output = this.settingsService.state.Output.formData;
+
+    const mode: TOutputSettingsMode = this.settingsService.findSettingValue(
+      output,
+      'Untitled',
+      'Mode',
+    );
+
+    const oldQualityName = this.settingsService.findSettingValue(output, 'Recording', 'RecQuality');
+    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 videoEncoder = obsEncoderToEncoderFamily(
+      this.settingsService.findSettingValue(output, 'Streaming', 'Encoder') ||
+        this.settingsService.findSettingValue(output, 'Streaming', 'StreamEncoder'),
+    ) as EEncoderFamily;
+
+    const enforceBitrateKey = mode === 'Advanced' ? 'ApplyServiceSettings' : 'EnforceBitrate';
+    const enforceServiceBitrate = this.settingsService.findSettingValue(
+      output,
+      'Streaming',
+      enforceBitrateKey,
+    );
+
+    const enableTwitchVOD = this.settingsService.findSettingValue(
+      output,
+      'Streaming',
+      'VodTrackEnabled',
+    );
+
+    const useAdvanced = this.settingsService.findSettingValue(output, 'Streaming', 'UseAdvanced');
+    console.log('useAdvanced', useAdvanced);
+
+    const customEncSettings = this.settingsService.findSettingValue(
+      output,
+      'Streaming',
+      'x264Settings',
+    );
+
+    const rescaling = this.settingsService.findSettingValue(output, 'Recording', 'RecRescale');
+
+    if (mode === 'Advanced') {
+      const twitchTrack = 3; // 3 in the tests, 2 in the description
+
+      return {
+        videoEncoder,
+        enforceServiceBitrate,
+        enableTwitchVOD,
+        twitchTrack,
+        rescaling,
+      };
+    } else {
+      return {
+        videoEncoder,
+        enforceServiceBitrate,
+        enableTwitchVOD,
+        useAdvanced,
+        customEncSettings,
+      };
+    }
+  }
+
   /**
    * Get recording settings
    * @remark Primarily used for setting up the recording output context,
diff --git a/app/services/streaming/stream-error.ts b/app/services/streaming/stream-error.ts
index 4658ccef5086..1e67549516d0 100644
--- a/app/services/streaming/stream-error.ts
+++ b/app/services/streaming/stream-error.ts
@@ -376,10 +376,11 @@ export function formatUnknownErrorMessage(
     } catch (error: unknown) {
       // if it's not JSON, it is the message itself
       // don't show blocked message to user
-      if (!info.split(' ').includes('blocked')) {
+      if (info.split(' ').includes('blocked')) {
+        messages.user.push(errorTypes['UNKNOWN_STREAMING_ERROR_WITH_MESSAGE'].message);
         messages.user.push(info);
       } else {
-        messages.user.push(errorTypes['UNKNOWN_STREAMING_ERROR_WITH_MESSAGE'].message);
+        messages.user.push(info);
       }
 
       // always add non-JSON info to diag report
@@ -393,7 +394,7 @@ export function formatUnknownErrorMessage(
         let error;
         let platform;
 
-        if (typeof info.error === 'string') {
+        if (typeof info.error === 'string' && info.error !== '') {
           /*
            * Try to parse error as JSON as originally done, however, if it's just a string
            * (such as in the case of invalid path and many other unknown -4 errors we've
diff --git a/app/services/streaming/streaming-view.ts b/app/services/streaming/streaming-view.ts
index d0b122b9803b..04ace797f528 100644
--- a/app/services/streaming/streaming-view.ts
+++ b/app/services/streaming/streaming-view.ts
@@ -4,7 +4,6 @@ import {
   IStreamSettings,
   EStreamingState,
   ERecordingState,
-  EReplayBufferState,
 } from './streaming-api';
 import { StreamSettingsService, ICustomStreamDestination } from '../settings/streaming';
 import { UserService } from '../user';
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index f5296e853e60..66ba1794b6ac 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1,6 +1,5 @@
 import Vue from 'vue';
 import { mutation, StatefulService } from 'services/core/stateful-service';
-import { Service } from 'services/core/service';
 import {
   AdvancedRecordingFactory,
   AudioEncoderFactory,
@@ -21,8 +20,11 @@ import {
   IAdvancedStreaming,
   AdvancedStreamingFactory,
   SimpleStreamingFactory,
+  ServiceFactory,
+  DelayFactory,
+  ReconnectFactory,
+  NetworkFactory,
 } from '../../../obs-api';
-import * as obs from '../../../obs-api';
 import { Inject } from 'services/core/injector';
 import moment from 'moment';
 import padStart from 'lodash/padStart';
@@ -78,8 +80,6 @@ import { byOS, OS } from 'util/operating-systems';
 import { DualOutputService } from 'services/dual-output';
 import { capitalize } from 'lodash';
 import { YoutubeService } from 'app-services';
-import { JsonrpcService } from '../api/jsonrpc';
-import { NavigationService } from 'services/navigation';
 
 enum EOBSOutputType {
   Streaming = 'streaming',
@@ -87,6 +87,13 @@ enum EOBSOutputType {
   ReplayBuffer = 'replay-buffer',
 }
 
+const outputType = (type: EOBSOutputType) =>
+  ({
+    [EOBSOutputType.Streaming]: $t('Streaming'),
+    [EOBSOutputType.Recording]: $t('Recording'),
+    [EOBSOutputType.ReplayBuffer]: $t('Replay Buffer'),
+  }[type]);
+
 enum EOBSOutputSignal {
   Starting = 'starting',
   Start = 'start',
@@ -148,8 +155,6 @@ export class StreamingService
   @Inject() private markersService: MarkersService;
   @Inject() private dualOutputService: DualOutputService;
   @Inject() private youtubeService: YoutubeService;
-  @Inject() private jsonrpcService: JsonrpcService;
-  @Inject() private navigationService: NavigationService;
 
   streamingStatusChange = new Subject<EStreamingState>();
   recordingStatusChange = new Subject<ERecordingState>();
@@ -242,7 +247,7 @@ export class StreamingService
   init() {
     NodeObs.OBS_service_connectOutputSignals((info: IOBSOutputSignalInfo) => {
       this.signalInfoChanged.next(info);
-      this.handleOBSV2OutputSignal(info);
+      this.handleOBSOutputSignal(info);
     });
 
     // watch for StreamInfoView at emit `streamInfoChanged` event if something has been hanged there
@@ -1272,6 +1277,12 @@ export class StreamingService
     }
   }
 
+  /**
+   * Create a recording instance for the given display
+   * @param display - The display to create the recording for
+   * @param index - The index of the audio track
+   * @param skipStart - Whether to skip starting the recording. This is used when creating a recording instance for the replay buffer
+   */
   private createRecording(display: TDisplayType, index: number, skipStart: boolean = false) {
     this.destroyOutputContextIfExists(display, 'recording');
 
@@ -1288,8 +1299,11 @@ export class StreamingService
     Object.keys(settings).forEach(key => {
       if ((settings as any)[key] === undefined) return;
 
+      // share the video encoder with the streaming instance if it exists
       if (key === 'videoEncoder') {
-        recording.videoEncoder = VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder');
+        recording.videoEncoder =
+          this.contexts[display].streaming?.videoEncoder ??
+          VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder');
       } else {
         (recording as any)[key] = (settings as any)[key];
       }
@@ -1303,34 +1317,12 @@ export class StreamingService
       (recording as IAdvancedRecording).outputHeight = resolution.outputHeight;
 
       // audio track
-      const trackName = `track${index}`;
-      const track = AudioTrackFactory.create(160, trackName);
-      AudioTrackFactory.setAtIndex(track, index);
-
-      // streaming object
-      // TODO: move to its own function
-      const videoEncoder = recording.videoEncoder;
+      this.createAudioTrack(index);
 
+      // streaming instance
       this.validateOrCreateOutputInstance(mode, display, 'streaming');
-
-      const stream = this.contexts[display].streaming as IAdvancedStreaming;
-      stream.enforceServiceBitrate = false;
-      stream.enableTwitchVOD = false;
-      stream.audioTrack = index;
-      // stream.twitchTrack = 3;
-      stream.rescaling = ((recording as unknown) as IAdvancedStreaming).rescaling;
-      stream.outputWidth = ((recording as unknown) as IAdvancedStreaming).outputWidth;
-      stream.outputHeight = ((recording as unknown) as IAdvancedStreaming).outputHeight;
-      stream.video = this.videoSettingsService.contexts[display];
-      stream.videoEncoder = videoEncoder;
-      stream.signalHandler = async signal => {
-        console.log('streaming signal', signal);
-        await this.handleSignal(signal, display);
-      };
-
-      this.contexts[display].streaming = stream;
-      recording.videoEncoder = videoEncoder;
-      recording.streaming = stream;
+      (recording as IAdvancedRecording).streaming = this.contexts[display]
+        .streaming as IAdvancedStreaming;
     } else {
       (recording as ISimpleRecording).audioEncoder = AudioEncoderFactory.create();
     }
@@ -1355,6 +1347,130 @@ export class StreamingService
     this.contexts[display].recording.start();
   }
 
+  /**
+   * Create a streaming instance for the given display
+   * @param display - The display to create the streaming for
+   * @param index - The index of the audio track
+   * @param skipStart - Whether to skip starting the streaming. This is used when creating a streaming instance for advanced recording
+   */
+  private createStreaming(display: TDisplayType, index: number, skipStart: boolean = false) {
+    this.destroyOutputContextIfExists(display, 'streaming');
+
+    const mode = this.outputSettingsService.getSettings().mode;
+
+    const settings = this.outputSettingsService.getStreamingSettings();
+
+    console.log('createStreaming settings', settings);
+
+    const stream =
+      mode === 'Advanced'
+        ? (AdvancedStreamingFactory.create() as IAdvancedStreaming)
+        : (SimpleStreamingFactory.create() as ISimpleStreaming);
+
+    // assign settings
+    Object.keys(settings).forEach(key => {
+      if ((settings as any)[key] === undefined) return;
+
+      // share the video encoder with the recording instance if it exists
+      if (key === 'videoEncoder') {
+        stream.videoEncoder =
+          this.contexts[display].recording?.videoEncoder ??
+          VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder');
+      } else {
+        (stream as any)[key] = (settings as any)[key];
+      }
+    });
+
+    if (this.isAdvancedStreaming(stream)) {
+      const resolution = this.videoSettingsService.outputResolutions[display];
+      stream.outputWidth = resolution.outputWidth;
+      stream.outputHeight = resolution.outputHeight;
+
+      // stream audio track
+      this.createAudioTrack(index);
+      stream.audioTrack = index;
+
+      // Twitch VOD audio track
+      if (stream.enableTwitchVOD && stream.twitchTrack) {
+        this.createAudioTrack(stream.twitchTrack);
+      } else if (stream.enableTwitchVOD) {
+        // do not use the same audio track for the VOD as the stream
+        stream.twitchTrack = index + 1;
+        this.createAudioTrack(stream.twitchTrack);
+      }
+    } else {
+      stream.audioEncoder =
+        (this.contexts[display].recording as ISimpleRecording)?.audioEncoder ??
+        AudioEncoderFactory.create();
+    }
+
+    stream.video = this.videoSettingsService.contexts[display];
+    stream.signalHandler = async signal => {
+      console.log('streaming signal', signal);
+      await this.handleSignal(signal, display);
+    };
+
+    this.contexts[display].streaming = stream;
+
+    if (skipStart) return;
+
+    // TODO: fully implement streaming
+    this.contexts[display].streaming.service = ServiceFactory.legacySettings;
+    this.contexts[display].streaming.delay = DelayFactory.create();
+    this.contexts[display].streaming.reconnect = ReconnectFactory.create();
+    this.contexts[display].streaming.network = NetworkFactory.create();
+
+    this.contexts[display].streaming.start();
+  }
+
+  /**
+   * Signal handler for the Factory API for streaming, recording, and replay buffer
+   * @param info - The signal info
+   * @param display - The context to handle the signal for
+   */
+  private async handleSignal(info: EOutputSignal, display: TDisplayType) {
+    if (info.code !== EOutputCode.Success) {
+      // handle errors before attempting anything else
+      console.error('Output Signal Error:', info);
+
+      if (!info.error || info.error === '') {
+        info.error = $t('An unknown %{type} error occurred.', {
+          type: outputType(info.type as EOBSOutputType),
+        });
+      }
+
+      this.handleFactoryOutputError(info, display);
+    } else if (info.type === EOBSOutputType.Streaming) {
+      this.handleStreamingSignal(info, display);
+    } else if (info.type === EOBSOutputType.Recording) {
+      await this.handleRecordingSignal(info, display);
+    } else if (info.type === EOBSOutputType.ReplayBuffer) {
+      await this.handleReplayBufferSignal(info, display);
+    } else {
+      console.debug('Unknown Output Signal or Error:', info);
+    }
+  }
+
+  private handleStreamingSignal(info: EOutputSignal, display: TDisplayType) {
+    // map signals to status
+    console.log('streaming signal info', info);
+
+    // const nextState: EStreamingState = ({
+    //   [EOBSOutputSignal.Start]: EStreamingState.Starting,
+    //   [EOBSOutputSignal.Stop]: EStreamingState.Offline,
+    //   [EOBSOutputSignal.Stopping]: EStreamingState.Ending,
+    //   [EOBSOutputSignal.Deactivate]: EStreamingState.Offline,
+    // } as Dictionary<EStreamingState>)[info.signal];
+
+    // EOBSOutputSignal.Starting;
+    // EOBSOutputSignal.Activate;
+    // EOBSOutputSignal.Start;
+    // EOBSOutputSignal.Stopping;
+    // EOBSOutputSignal.Stop;
+
+    this.handleFactoryOutputError(info, display);
+  }
+
   private async handleRecordingSignal(info: EOutputSignal, display: TDisplayType) {
     // map signals to status
     const nextState: ERecordingState = ({
@@ -1416,34 +1532,6 @@ export class StreamingService
     const time = new Date().toISOString();
     this.SET_RECORDING_STATUS(nextState, display, time);
     this.recordingStatusChange.next(nextState);
-
-    this.handleV2OutputCode(info);
-  }
-
-  splitFile(display: TDisplayType = 'horizontal') {
-    if (
-      this.state.status.horizontal.recording === ERecordingState.Recording &&
-      this.contexts[display].recording
-    ) {
-      this.contexts[display].recording.splitFile();
-    }
-  }
-
-  private async handleSignal(info: EOutputSignal, display: TDisplayType) {
-    if (info.type === EOBSOutputType.Recording) {
-      await this.handleRecordingSignal(info, display);
-      return;
-    }
-
-    if (info.type === EOBSOutputType.ReplayBuffer) {
-      await this.handleReplayBufferSignal(info, display);
-      return;
-    }
-
-    if (info.type === EOBSOutputType.Streaming) {
-      this.handleStreamingSignal(info, display);
-      return;
-    }
   }
 
   private async handleReplayBufferSignal(info: EOutputSignal, display: TDisplayType) {
@@ -1456,20 +1544,19 @@ export class StreamingService
       [EOBSOutputSignal.Stop]: EReplayBufferState.Offline,
     } as Dictionary<EReplayBufferState>)[info.signal];
 
+    // We received a signal we didn't recognize
+    if (!nextState) return;
+
+    const time = new Date().toISOString();
+    this.SET_REPLAY_BUFFER_STATUS(nextState, display, time);
+    this.replayBufferStatusChange.next(nextState);
+
     if (info.signal === EOBSOutputSignal.Wrote) {
       this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', {
         status: 'wrote',
         code: info.code,
       });
 
-      const message = $t('A new Highlight has been saved. Click to edit in the Highlighter');
-
-      this.notificationsService.actions.push({
-        type: ENotificationType.SUCCESS,
-        message,
-        action: this.jsonrpcService.createRequest(Service.getResourceId(this), 'showHighlighter'),
-      });
-
       this.replayBufferFileWrite.next(this.contexts[display].replayBuffer.lastFile());
     }
 
@@ -1479,27 +1566,27 @@ export class StreamingService
         code: info.code,
       });
 
-      this.destroyOutputContextIfExists(display, 'replayBuffer');
-    }
+      // In the case that the user is streaming but not recording, a recording instance
+      // was created for the replay buffer. If the replay buffer is stopped, the recording
+      // instance should be destroyed.
+      if (this.state.status.horizontal.recording === ERecordingState.Offline) {
+        this.destroyOutputContextIfExists(display, 'recording');
+      }
 
-    if (nextState) {
-      const time = new Date().toISOString();
-      this.SET_REPLAY_BUFFER_STATUS(nextState, display, time);
-      this.replayBufferStatusChange.next(nextState);
+      this.destroyOutputContextIfExists(display, 'replayBuffer');
     }
-
-    this.handleV2OutputCode(info);
   }
 
-  showHighlighter() {
-    this.navigationService.navigate('Highlighter');
+  splitFile(display: TDisplayType = 'horizontal') {
+    if (
+      this.state.status.horizontal.recording === ERecordingState.Recording &&
+      this.contexts[display].recording
+    ) {
+      this.contexts[display].recording.splitFile();
+    }
   }
 
   // TODO migrate streaming to new API
-  private handleStreamingSignal(info: EOutputSignal, display: TDisplayType) {
-    // map signals to status
-    console.log('streaming signal info', info);
-  }
 
   startReplayBuffer(display: TDisplayType = 'horizontal') {
     if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return;
@@ -1562,13 +1649,6 @@ export class StreamingService
     this.contexts[display].replayBuffer.stop(forceStop);
     // change the replay buffer status for the loading animation
     this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Stopping, display, new Date().toISOString());
-
-    // In the case that the user is streaming but not recording, a recording instance
-    // was created for the replay buffer. If the replay buffer is stopped, the recording
-    // instance should be destroyed.
-    if (this.state.status.horizontal.recording === ERecordingState.Offline) {
-      this.destroyOutputContextIfExists(display, 'recording');
-    }
   }
 
   saveReplay(display: TDisplayType = 'horizontal') {
@@ -1596,21 +1676,28 @@ export class StreamingService
         (mode === 'Advanced' && !isAdvancedOutputInstance)
       ) {
         this.destroyOutputContextIfExists(display, type);
-      } else {
-        return;
       }
     }
 
     // Create new instance if it does not exist or was destroyed
     if (type === 'streaming') {
       // TODO: create streaming instance
-      this.contexts[display].streaming =
-        mode === 'Advanced' ? AdvancedStreamingFactory.create() : SimpleStreamingFactory.create();
+      this.createStreaming(display, 1, true);
     } else {
       this.createRecording(display, 1, true);
     }
   }
 
+  /**
+   * Create an audio track
+   * @param index - index of the audio track to create
+   */
+  createAudioTrack(index: number) {
+    const trackName = `track${index}`;
+    const track = AudioTrackFactory.create(160, trackName);
+    AudioTrackFactory.setAtIndex(track, index);
+  }
+
   isAdvancedStreaming(instance: any): instance is IAdvancedStreaming {
     return 'rescaling' in instance;
   }
@@ -1619,6 +1706,18 @@ export class StreamingService
     return 'useStreamEncoders' in instance;
   }
 
+  handleFactoryOutputError(info: EOutputSignal, display: TDisplayType) {
+    const legacyInfo = {
+      type: info.type as EOBSOutputType,
+      signal: info.signal as EOBSOutputSignal,
+      code: info.code as EOutputCode,
+      error: info.error,
+      service: display as string,
+    } as IOBSOutputSignalInfo;
+
+    this.handleOBSOutputError(legacyInfo);
+  }
+
   /**
    * Show the GoLiveWindow
    * Prefill fields with data if `prepopulateOptions` provided
@@ -1751,7 +1850,7 @@ export class StreamingService
   private streamErrorUserMessage = '';
   private streamErrorReportMessage = '';
 
-  private handleOBSV2OutputSignal(info: IOBSOutputSignalInfo) {
+  private handleOBSOutputSignal(info: IOBSOutputSignalInfo) {
     console.debug('OBS Output signal: ', info);
 
     /*
@@ -1761,8 +1860,10 @@ export class StreamingService
      * - Dual output mode: when vertical display is second destination,
      *   resolve after horizontal stream started
      */
+    const display = this.convertSignalToDisplay(info.service);
+
     const isVerticalDisplayStartSignal =
-      info.service === 'vertical' && info.signal === EOBSOutputSignal.Start;
+      display === 'vertical' && info.signal === EOBSOutputSignal.Start;
 
     const shouldResolve =
       !this.views.isDualOutputMode ||
@@ -1773,7 +1874,7 @@ export class StreamingService
 
     if (info.type === EOBSOutputType.Streaming) {
       if (info.signal === EOBSOutputSignal.Start && shouldResolve) {
-        this.SET_STREAMING_STATUS(EStreamingState.Live, time);
+        this.SET_STREAMING_STATUS(EStreamingState.Live, display, time);
         this.resolveStartStreaming();
         this.streamingStatusChange.next(EStreamingState.Live);
 
@@ -1810,10 +1911,10 @@ export class StreamingService
         });
         this.usageStatisticsService.recordFeatureUsage('Streaming');
       } else if (info.signal === EOBSOutputSignal.Starting && shouldResolve) {
-        this.SET_STREAMING_STATUS(EStreamingState.Starting, time);
+        this.SET_STREAMING_STATUS(EStreamingState.Starting, display, time);
         this.streamingStatusChange.next(EStreamingState.Starting);
       } else if (info.signal === EOBSOutputSignal.Stop) {
-        this.SET_STREAMING_STATUS(EStreamingState.Offline, time);
+        this.SET_STREAMING_STATUS(EStreamingState.Offline, display, time);
         this.RESET_STREAM_INFO();
         this.rejectStartStreaming();
         this.streamingStatusChange.next(EStreamingState.Offline);
@@ -1823,223 +1924,125 @@ export class StreamingService
         });
       } else if (info.signal === EOBSOutputSignal.Stopping) {
         this.sendStreamEndEvent();
-        this.SET_STREAMING_STATUS(EStreamingState.Ending, time);
+        this.SET_STREAMING_STATUS(EStreamingState.Ending, display, time);
         this.streamingStatusChange.next(EStreamingState.Ending);
       } else if (info.signal === EOBSOutputSignal.Reconnect) {
-        this.SET_STREAMING_STATUS(EStreamingState.Reconnecting);
+        this.SET_STREAMING_STATUS(EStreamingState.Reconnecting, display);
         this.streamingStatusChange.next(EStreamingState.Reconnecting);
         this.sendReconnectingNotification();
       } else if (info.signal === EOBSOutputSignal.ReconnectSuccess) {
-        this.SET_STREAMING_STATUS(EStreamingState.Live);
+        this.SET_STREAMING_STATUS(EStreamingState.Live, display);
         this.streamingStatusChange.next(EStreamingState.Live);
         this.clearReconnectingNotification();
       }
     }
 
-    this.handleV2OutputCode(info);
+    if (info.code === EOutputCode.Success) return;
+    this.handleOBSOutputError(info);
   }
 
-  private handleV2OutputCode(info: IOBSOutputSignalInfo | EOutputSignal) {
-    if (info.code) {
-      if (this.outputErrorOpen) {
-        console.warn('Not showing error message because existing window is open.', info);
-        return;
-      }
-
-      let errorText = '';
-      let extendedErrorText = '';
-      let linkToDriverInfo = false;
-      let showNativeErrorMessage = false;
-
-      if (info.code === EOutputCode.BadPath) {
-        errorText = $t(
-          'Invalid Path or Connection URL.  Please check your settings to confirm that they are valid.',
-        );
-      } else if (info.code === EOutputCode.ConnectFailed) {
-        errorText = $t(
-          'Failed to connect to the streaming server.  Please check your internet connection.',
-        );
-      } else if (info.code === EOutputCode.Disconnected) {
-        errorText = $t(
-          'Disconnected from the streaming server.  Please check your internet connection.',
-        );
-      } else if (info.code === EOutputCode.InvalidStream) {
-        errorText = $t(
-          'Could not access the specified channel or stream key. Please log out and back in to refresh your credentials. If the problem persists, there may be a problem connecting to the server.',
-        );
-      } else if (info.code === EOutputCode.NoSpace) {
-        errorText = $t('There is not sufficient disk space to continue recording.');
-      } else if (info.code === EOutputCode.Unsupported) {
-        errorText =
-          $t(
-            'The output format is either unsupported or does not support more than one audio track.  ',
-          ) + $t('Please check your settings and try again.');
-      } else if (info.code === EOutputCode.OutdatedDriver) {
-        linkToDriverInfo = true;
-        errorText = $t(
-          'An error occurred with the output. This is usually caused by out of date video drivers. Please ensure your Nvidia or AMD drivers are up to date and try again.',
-        );
-      } else {
-        // -4 is used for generic unknown messages in OBS. Both -4 and any other code
-        // we don't recognize should fall into this branch and show a generic error.
-        errorText = $t(
-          'An error occurred with the output. Please check your streaming and recording settings.',
-        );
-        if (info.error) {
-          showNativeErrorMessage = true;
-          extendedErrorText = errorText + '\n\n' + $t('System error message:') + info.error + '"';
-        }
-      }
-      const buttons = [$t('OK')];
-
-      const title = {
-        [EOBSOutputType.Streaming]: $t('Streaming Error'),
-        [EOBSOutputType.Recording]: $t('Recording Error'),
-        [EOBSOutputType.ReplayBuffer]: $t('Replay Buffer Error'),
-      }[info.type];
-
-      if (linkToDriverInfo) buttons.push($t('Learn More'));
-      if (showNativeErrorMessage) buttons.push($t('More'));
-
-      this.outputErrorOpen = true;
-      const errorType = 'error';
-      remote.dialog
-        .showMessageBox(Utils.getMainWindow(), {
-          buttons,
-          title,
-          type: errorType,
-          message: errorText,
-        })
-        .then(({ response }) => {
-          if (linkToDriverInfo && response === 1) {
-            this.outputErrorOpen = false;
-            remote.shell.openExternal(
-              'https://howto.streamlabs.com/streamlabs-obs-19/nvidia-graphics-driver-clean-install-tutorial-7000',
-            );
-          } else {
-            let expectedResponse = 1;
-            if (linkToDriverInfo) {
-              expectedResponse = 2;
-            }
-            if (showNativeErrorMessage && response === expectedResponse) {
-              const buttons = [$t('OK')];
-              remote.dialog
-                .showMessageBox({
-                  buttons,
-                  title,
-                  type: errorType,
-                  message: extendedErrorText,
-                })
-                .then(({ response }) => {
-                  this.outputErrorOpen = false;
-                })
-                .catch(() => {
-                  this.outputErrorOpen = false;
-                });
-            } else {
-              this.outputErrorOpen = false;
-            }
-          }
-        })
-        .catch(() => {
-          this.outputErrorOpen = false;
-        });
-      this.windowsService.actions.closeChildWindow();
+  /**
+   * Convert the signal from IOBSOutputSignalInfo to the display type
+   * @remark This is required to facilitate special handling for each display in dual output mode
+   * @param service - String representing the name of the service returned from the API
+   * @returns - The display type
+   */
+  private convertSignalToDisplay(service: string): TDisplayType {
+    switch (service) {
+      case 'vertical':
+        return 'vertical';
+      case 'horizontal':
+        return 'horizontal';
+      case 'default':
+        return 'horizontal';
+      default:
+        return 'horizontal';
     }
   }
 
-  private handleOBSOutputSignal(info: IOBSOutputSignalInfo) {
+  private handleOBSOutputError(info: IOBSOutputSignalInfo) {
     console.debug('OBS Output signal: ', info);
 
-    const shouldResolve =
-      !this.views.isDualOutputMode || (this.views.isDualOutputMode && info.service === 'vertical');
+    if (!info.code) return;
+    if ((info.code as EOutputCode) === EOutputCode.Success) return;
 
-    const time = new Date().toISOString();
+    if (this.outputErrorOpen) {
+      console.warn('Not showing error message because existing window is open.', info);
 
-    if (info.type === EOBSOutputType.Streaming) {
-      if (info.signal === EOBSOutputSignal.Start && shouldResolve) {
-        this.SET_STREAMING_STATUS(EStreamingState.Live, time);
-        this.resolveStartStreaming();
-        this.streamingStatusChange.next(EStreamingState.Live);
+      const messages = formatUnknownErrorMessage(
+        info,
+        this.streamErrorUserMessage,
+        this.streamErrorReportMessage,
+      );
 
-        let streamEncoderInfo: Partial<IOutputSettings> = {};
-        let game: string = '';
+      this.streamErrorCreated.next(messages.report);
 
-        try {
-          streamEncoderInfo = this.outputSettingsService.getSettings();
-          game = this.views.game;
-        } catch (e: unknown) {
-          console.error('Error fetching stream encoder info: ', e);
-        }
-
-        const eventMetadata: Dictionary<any> = {
-          ...streamEncoderInfo,
-          game,
-        };
+      return;
+    }
 
-        if (this.videoEncodingOptimizationService.state.useOptimizedProfile) {
-          eventMetadata.useOptimizedProfile = true;
-        }
+    let errorText = this.streamErrorUserMessage;
+    let details = '';
+    let linkToDriverInfo = false;
+    let showNativeErrorMessage = false;
+    let diagReportMessage = this.streamErrorUserMessage;
 
-        const streamSettings = this.streamSettingsService.settings;
+    if (info.code === EOutputCode.BadPath) {
+      errorText = $t(
+        'Invalid Path or Connection URL.  Please check your settings to confirm that they are valid.',
+      );
+      diagReportMessage = diagReportMessage.concat(errorText);
+    } else if (info.code === EOutputCode.ConnectFailed) {
+      errorText = $t(
+        'Failed to connect to the streaming server.  Please check your internet connection.',
+      );
+      diagReportMessage = diagReportMessage.concat(errorText);
+    } else if (info.code === EOutputCode.Disconnected) {
+      errorText = $t(
+        'Disconnected from the streaming server.  Please check your internet connection.',
+      );
+      diagReportMessage = diagReportMessage.concat(errorText);
+    } else if (info.code === EOutputCode.InvalidStream) {
+      errorText = $t(
+        'Could not access the specified channel or stream key. Please log out and back in to refresh your credentials. If the problem persists, there may be a problem connecting to the server.',
+      );
+      diagReportMessage = diagReportMessage.concat(errorText);
+    } else if (info.code === EOutputCode.NoSpace) {
+      errorText = $t('There is not sufficient disk space to continue recording.');
+      diagReportMessage = diagReportMessage.concat(errorText);
+    } else if (info.code === EOutputCode.Unsupported) {
+      errorText =
+        $t(
+          'The output format is either unsupported or does not support more than one audio track.  ',
+        ) + $t('Please check your settings and try again.');
+      diagReportMessage = diagReportMessage.concat(errorText);
+    } else if (info.code === EOutputCode.OutdatedDriver) {
+      linkToDriverInfo = true;
+      errorText = $t(
+        'An error occurred with the output. This is usually caused by out of date video drivers. Please ensure your Nvidia or AMD drivers are up to date and try again.',
+      );
+      diagReportMessage = diagReportMessage.concat(errorText);
+    } else {
+      // -4 is used for generic unknown messages in OBS. Both -4 and any other code
+      // we don't recognize should fall into this branch and show a generic error.
 
-        eventMetadata.streamType = streamSettings.streamType;
-        eventMetadata.platform = streamSettings.platform;
-        eventMetadata.server = streamSettings.server;
-        eventMetadata.outputMode = this.views.isDualOutputMode ? 'dual' : 'single';
-        eventMetadata.platforms = this.views.protectedModeEnabled
-          ? [
-              ...this.views.enabledPlatforms,
-              /*
-               * This is to be consistent with `stream_end`, unsure what multiple `custom_rtmp`'s
-               * provide on their own without URL, but it could be a privacy or payload size issue.
-               */
-              ...this.views.customDestinations.filter(d => d.enabled).map(_ => 'custom_rtmp'),
-            ]
-          : ['custom_rtmp'];
-
-        if (eventMetadata.platforms.includes('youtube')) {
-          eventMetadata.streamId = this.youtubeService.state.streamId;
-          eventMetadata.broadcastId = this.youtubeService.state.settings?.broadcastId;
-        }
+      if (!this.userService.isLoggedIn) {
+        const messages = formatStreamErrorMessage('LOGGED_OUT_ERROR');
 
-        this.usageStatisticsService.recordEvent('stream_start', eventMetadata);
-        this.usageStatisticsService.recordAnalyticsEvent('StreamingStatus', {
-          code: info.code,
-          status: EStreamingState.Live,
-          service: streamSettings.service,
-        });
-        this.usageStatisticsService.recordFeatureUsage('Streaming');
-      } else if (info.signal === EOBSOutputSignal.Starting && shouldResolve) {
-        this.SET_STREAMING_STATUS(EStreamingState.Starting, time);
-        this.streamingStatusChange.next(EStreamingState.Starting);
-      } else if (info.signal === EOBSOutputSignal.Stop) {
-        this.SET_STREAMING_STATUS(EStreamingState.Offline, time);
-        this.RESET_STREAM_INFO();
-        this.rejectStartStreaming();
-        this.streamingStatusChange.next(EStreamingState.Offline);
-        this.usageStatisticsService.recordAnalyticsEvent('StreamingStatus', {
-          code: info.code,
-          status: EStreamingState.Offline,
-        });
-      } else if (info.signal === EOBSOutputSignal.Stopping) {
-        this.sendStreamEndEvent();
-        this.SET_STREAMING_STATUS(EStreamingState.Ending, time);
-        this.streamingStatusChange.next(EStreamingState.Ending);
-      } else if (info.signal === EOBSOutputSignal.Reconnect) {
-        this.SET_STREAMING_STATUS(EStreamingState.Reconnecting);
-        this.streamingStatusChange.next(EStreamingState.Reconnecting);
-        this.sendReconnectingNotification();
-      } else if (info.signal === EOBSOutputSignal.ReconnectSuccess) {
-        this.SET_STREAMING_STATUS(EStreamingState.Live);
-        this.streamingStatusChange.next(EStreamingState.Live);
-        this.clearReconnectingNotification();
-      }
-    }
+        errorText = messages.user;
+        diagReportMessage = messages.report;
+        if (messages.details) details = messages.details;
 
-    if (info.code) {
-      if (this.outputErrorOpen) {
-        console.warn('Not showing error message because existing window is open.', info);
+        showNativeErrorMessage = details !== '';
+      } else {
+        if (
+          !info.error ||
+          (info.error && typeof info.error !== 'string') ||
+          (info.error && info.error === '')
+        ) {
+          info.error = $t('An unknown %{type} error occurred.', {
+            type: outputType(info.type),
+          });
+        }
 
         const messages = formatUnknownErrorMessage(
           info,
@@ -2047,144 +2050,78 @@ export class StreamingService
           this.streamErrorReportMessage,
         );
 
-        this.streamErrorCreated.next(messages.report);
+        errorText = messages.user;
+        diagReportMessage = messages.report;
+        if (messages.details) details = messages.details;
 
-        return;
-      }
-
-      let errorText = this.streamErrorUserMessage;
-      let details = '';
-      let linkToDriverInfo = false;
-      let showNativeErrorMessage = false;
-      let diagReportMessage = this.streamErrorUserMessage;
-
-      if (info.code === EOutputCode.BadPath) {
-        errorText = $t(
-          'Invalid Path or Connection URL.  Please check your settings to confirm that they are valid.',
-        );
-        diagReportMessage = diagReportMessage.concat(errorText);
-      } else if (info.code === EOutputCode.ConnectFailed) {
-        errorText = $t(
-          'Failed to connect to the streaming server.  Please check your internet connection.',
-        );
-        diagReportMessage = diagReportMessage.concat(errorText);
-      } else if (info.code === EOutputCode.Disconnected) {
-        errorText = $t(
-          'Disconnected from the streaming server.  Please check your internet connection.',
-        );
-        diagReportMessage = diagReportMessage.concat(errorText);
-      } else if (info.code === EOutputCode.InvalidStream) {
-        errorText = $t(
-          'Could not access the specified channel or stream key. Please log out and back in to refresh your credentials. If the problem persists, there may be a problem connecting to the server.',
-        );
-        diagReportMessage = diagReportMessage.concat(errorText);
-      } else if (info.code === EOutputCode.NoSpace) {
-        errorText = $t('There is not sufficient disk space to continue recording.');
-        diagReportMessage = diagReportMessage.concat(errorText);
-      } else if (info.code === EOutputCode.Unsupported) {
-        errorText =
-          $t(
-            'The output format is either unsupported or does not support more than one audio track.  ',
-          ) + $t('Please check your settings and try again.');
-        diagReportMessage = diagReportMessage.concat(errorText);
-      } else if (info.code === EOutputCode.OutdatedDriver) {
-        linkToDriverInfo = true;
-        errorText = $t(
-          'An error occurred with the output. This is usually caused by out of date video drivers. Please ensure your Nvidia or AMD drivers are up to date and try again.',
-        );
-        diagReportMessage = diagReportMessage.concat(errorText);
-      } else {
-        // -4 is used for generic unknown messages in OBS. Both -4 and any other code
-        // we don't recognize should fall into this branch and show a generic error.
-
-        if (!this.userService.isLoggedIn) {
-          const messages = formatStreamErrorMessage('LOGGED_OUT_ERROR');
-
-          errorText = messages.user;
-          diagReportMessage = messages.report;
-          if (messages.details) details = messages.details;
-
-          showNativeErrorMessage = details !== '';
-        } else if (info.error && typeof info.error === 'string') {
-          const messages = formatUnknownErrorMessage(
-            info,
-            this.streamErrorUserMessage,
-            this.streamErrorReportMessage,
-          );
-
-          errorText = messages.user;
-          diagReportMessage = messages.report;
-          if (messages.details) details = messages.details;
-
-          showNativeErrorMessage = details !== '';
-        }
+        showNativeErrorMessage = details !== '';
       }
+    }
 
-      const buttons = [$t('OK')];
+    const buttons = [$t('OK')];
 
-      const title = {
-        [EOBSOutputType.Streaming]: $t('Streaming Error'),
-        [EOBSOutputType.Recording]: $t('Recording Error'),
-        [EOBSOutputType.ReplayBuffer]: $t('Replay Buffer Error'),
-      }[info.type];
+    const title = {
+      [EOBSOutputType.Streaming]: $t('Streaming Error'),
+      [EOBSOutputType.Recording]: $t('Recording Error'),
+      [EOBSOutputType.ReplayBuffer]: $t('Replay Buffer Error'),
+    }[info.type];
 
-      if (linkToDriverInfo) buttons.push($t('Learn More'));
-      if (showNativeErrorMessage) {
-        buttons.push($t('More'));
-      }
+    if (linkToDriverInfo) buttons.push($t('Learn More'));
+    if (showNativeErrorMessage) {
+      buttons.push($t('More'));
+    }
 
-      this.outputErrorOpen = true;
-      const errorType = 'error';
-      remote.dialog
-        .showMessageBox(Utils.getMainWindow(), {
-          buttons,
-          title,
-          type: errorType,
-          message: errorText,
-        })
-        .then(({ response }) => {
-          if (linkToDriverInfo && response === 1) {
-            this.outputErrorOpen = false;
-            remote.shell.openExternal(
-              'https://howto.streamlabs.com/streamlabs-obs-19/nvidia-graphics-driver-clean-install-tutorial-7000',
-            );
+    this.outputErrorOpen = true;
+    const errorType = 'error';
+    remote.dialog
+      .showMessageBox(Utils.getMainWindow(), {
+        buttons,
+        title,
+        type: errorType,
+        message: errorText,
+      })
+      .then(({ response }) => {
+        if (linkToDriverInfo && response === 1) {
+          this.outputErrorOpen = false;
+          remote.shell.openExternal(
+            'https://howto.streamlabs.com/streamlabs-obs-19/nvidia-graphics-driver-clean-install-tutorial-7000',
+          );
+        } else {
+          let expectedResponse = 1;
+          if (linkToDriverInfo) {
+            expectedResponse = 2;
+          }
+          if (showNativeErrorMessage && response === expectedResponse) {
+            const buttons = [$t('OK')];
+            remote.dialog
+              .showMessageBox({
+                buttons,
+                title,
+                type: errorType,
+                message: details,
+              })
+              .then(({ response }) => {
+                this.outputErrorOpen = false;
+                this.streamErrorUserMessage = '';
+                this.streamErrorReportMessage = '';
+              })
+              .catch(() => {
+                this.outputErrorOpen = false;
+              });
           } else {
-            let expectedResponse = 1;
-            if (linkToDriverInfo) {
-              expectedResponse = 2;
-            }
-            if (showNativeErrorMessage && response === expectedResponse) {
-              const buttons = [$t('OK')];
-              remote.dialog
-                .showMessageBox({
-                  buttons,
-                  title,
-                  type: errorType,
-                  message: details,
-                })
-                .then(({ response }) => {
-                  this.outputErrorOpen = false;
-                  this.streamErrorUserMessage = '';
-                  this.streamErrorReportMessage = '';
-                })
-                .catch(() => {
-                  this.outputErrorOpen = false;
-                });
-            } else {
-              this.outputErrorOpen = false;
-            }
+            this.outputErrorOpen = false;
           }
-        })
-        .catch(() => {
-          this.outputErrorOpen = false;
-        });
+        }
+      })
+      .catch(() => {
+        this.outputErrorOpen = false;
+      });
 
-      this.windowsService.actions.closeChildWindow();
+    this.windowsService.actions.closeChildWindow();
 
-      // pass streaming error to diag report
-      if (info.type === EOBSOutputType.Streaming || !this.userService.isLoggedIn) {
-        this.streamErrorCreated.next(diagReportMessage);
-      }
+    // pass streaming error to diag report
+    if (info.type === EOBSOutputType.Streaming || !this.userService.isLoggedIn) {
+      this.streamErrorCreated.next(diagReportMessage);
     }
   }
 
@@ -2263,7 +2200,7 @@ export class StreamingService
   shutdown() {
     Object.keys(this.contexts).forEach(display => {
       Object.keys(this.contexts[display]).forEach(async (contextType: keyof IOutputContext) => {
-        await this.destroyOutputContextIfExists(display, contextType);
+        this.destroyOutputContextIfExists(display, contextType);
       });
     });
   }
@@ -2337,9 +2274,16 @@ export class StreamingService
   }
 
   @mutation()
-  private SET_STREAMING_STATUS(status: EStreamingState, time?: string) {
+  private SET_STREAMING_STATUS(status: EStreamingState, display: TDisplayType, time?: string) {
+    // while recording and the replay buffer are in the v2 API and streaming is in the old API
+    // we need to duplicate tracking the replay buffer status
     this.state.streamingStatus = status;
-    if (time) this.state.streamingStatusTime = time;
+    this.state.status[display].streaming = status;
+
+    if (time) {
+      this.state.streamingStatusTime = time;
+      this.state.status[display].streamingTime = time;
+    }
   }
 
   @mutation()

From 2e3934b55db50645092c7f9b824ba7061f4e6dca Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Wed, 12 Mar 2025 11:31:12 -0400
Subject: [PATCH 14/25] Fix using Advanced settings.

---
 app/components-react/root/StudioFooter.tsx    |  8 +--
 .../settings/output/output-settings.ts        | 28 +++-------
 app/services/streaming/streaming.ts           | 52 ++++++++++++++-----
 3 files changed, 50 insertions(+), 38 deletions(-)

diff --git a/app/components-react/root/StudioFooter.tsx b/app/components-react/root/StudioFooter.tsx
index b9ba49ddfeb0..33e244cb5819 100644
--- a/app/components-react/root/StudioFooter.tsx
+++ b/app/components-react/root/StudioFooter.tsx
@@ -43,8 +43,10 @@ export default function StudioFooterComponent() {
       !RecordingModeService.views.isRecordingModeEnabled,
     streamQuality: PerformanceService.views.streamQuality,
     replayBufferOffline: StreamingService.views.replayBufferStatus === EReplayBufferState.Offline,
-    replayBufferStopping: StreamingService.state.replayBufferStatus === EReplayBufferState.Stopping,
-    replayBufferSaving: StreamingService.state.replayBufferStatus === EReplayBufferState.Saving,
+    replayBufferStopping:
+      StreamingService.state.status.horizontal.replayBuffer === EReplayBufferState.Stopping,
+    replayBufferSaving:
+      StreamingService.state.status.horizontal.replayBuffer === EReplayBufferState.Saving,
     recordingModeEnabled: RecordingModeService.views.isRecordingModeEnabled,
     replayBufferEnabled: SettingsService.views.values.Output.RecRB,
   }));
@@ -197,7 +199,7 @@ function RecordingButton() {
   const { StreamingService } = Services;
   const { isRecording, recordingStatus } = useVuex(() => ({
     isRecording: StreamingService.views.isRecording,
-    recordingStatus: StreamingService.state.status.horizontal.recordingTime,
+    recordingStatus: StreamingService.state.status.horizontal.recording,
   }));
 
   function toggleRecording() {
diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts
index 1dee9b443eb2..716c5d0af900 100644
--- a/app/services/settings/output/output-settings.ts
+++ b/app/services/settings/output/output-settings.ts
@@ -246,27 +246,14 @@ export class OutputSettingsService extends Service {
       'Mode',
     );
 
-    const oldQualityName = this.settingsService.findSettingValue(output, 'Recording', 'RecQuality');
-    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 videoEncoder = obsEncoderToEncoderFamily(
-      this.settingsService.findSettingValue(output, 'Streaming', 'Encoder') ||
-        this.settingsService.findSettingValue(output, 'Streaming', 'StreamEncoder'),
-    ) as EEncoderFamily;
+    const videoEncoder: EObsAdvancedEncoder =
+      convertedEncoderName === EObsSimpleEncoder.x264_lowcpu
+        ? EObsAdvancedEncoder.obs_x264
+        : convertedEncoderName;
 
     const enforceBitrateKey = mode === 'Advanced' ? 'ApplyServiceSettings' : 'EnforceBitrate';
     const enforceServiceBitrate = this.settingsService.findSettingValue(
@@ -282,7 +269,6 @@ export class OutputSettingsService extends Service {
     );
 
     const useAdvanced = this.settingsService.findSettingValue(output, 'Streaming', 'UseAdvanced');
-    console.log('useAdvanced', useAdvanced);
 
     const customEncSettings = this.settingsService.findSettingValue(
       output,
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index 66ba1794b6ac..62a7fd510c14 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1010,7 +1010,7 @@ export class StreamingService
             NodeObs.OBS_service_stopStreaming(true, 'vertical');
             // Refactor when move streaming to new API
             if (this.state.status.vertical.streaming !== EStreamingState.Offline) {
-              this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline);
+              this.SET_STREAMING_STATUS(EStreamingState.Offline, 'vertical');
             }
           }
 
@@ -1020,7 +1020,7 @@ export class StreamingService
             // Refactor when move streaming to new API
             const time = new Date().toISOString();
             if (this.state.status.vertical.streaming === EStreamingState.Offline) {
-              this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Live, time);
+              this.SET_STREAMING_STATUS(EStreamingState.Live, 'vertical', time);
             }
 
             signalChanged.unsubscribe();
@@ -1134,7 +1134,7 @@ export class StreamingService
               NodeObs.OBS_service_stopStreaming(false, 'vertical');
               // Refactor when move streaming to new API
               if (this.state.status.vertical.streaming !== EStreamingState.Offline) {
-                this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline);
+                this.SET_STREAMING_STATUS(EStreamingState.Offline, 'vertical');
               }
               signalChanged.unsubscribe();
             }
@@ -1181,7 +1181,7 @@ export class StreamingService
         NodeObs.OBS_service_stopStreaming(true, 'horizontal');
         // Refactor when move streaming to new API
         if (this.state.status.vertical.streaming !== EStreamingState.Offline) {
-          this.SET_VERTICAL_STREAMING_STATUS(EStreamingState.Offline);
+          this.SET_STREAMING_STATUS(EStreamingState.Offline, 'vertical');
         }
         NodeObs.OBS_service_stopStreaming(true, 'vertical');
       } else {
@@ -1566,14 +1566,44 @@ export class StreamingService
         code: info.code,
       });
 
-      // In the case that the user is streaming but not recording, a recording instance
-      // was created for the replay buffer. If the replay buffer is stopped, the recording
-      // instance should be destroyed.
-      if (this.state.status.horizontal.recording === ERecordingState.Offline) {
+      // There are a few cases where a recording and streaming instance are created for the replay buffer.
+      // In these cases, the created recording and streaming instances should be destroyed
+      // when the replay buffer is stopped.
+      // 1. Simple Replay Buffer: When using the replay buffer without recording or streaming,
+      //    a simple recording instance is created for the replay buffer.
+      // 2. Simple Replay Buffer: When using the replay butter while streaming but not recording,
+      //    a simple recording instance is created for the replay buffer.
+      // 3. Advanced Replay Buffer: When using the replay buffer without recording or streaming,
+      //    an advanced recording instance is created for the replay buffer. This advanced recording
+      //    instance will create an advanced streaming instance if it does not exist.
+      // 4. Advanced Replay Buffer: When using the replay buffer while streaming but not recording,
+      //    a recording instance is created for the replay buffer. If the replay buffer is stopped,
+      //    the recording instance should be destroyed.
+
+      // destroy any recording instances created for use with the replay buffer
+      if (this.state.status[display].recording === ERecordingState.Offline) {
         this.destroyOutputContextIfExists(display, 'recording');
+        console.log('destroyed recording instance for replay buffer');
+      }
+
+      // destroy any streaming instances created by the recording instance for use with the replay buffer
+      // Note: this is only the case when recording without streaming in advanced mode
+      if (this.state.status[display].streaming === EStreamingState.Offline) {
+        this.destroyOutputContextIfExists(display, 'streaming');
+        console.log('destroyed streaming instance for replay buffer');
       }
 
       this.destroyOutputContextIfExists(display, 'replayBuffer');
+
+      // THE BELOW WORKS
+      // // In the case that the user is streaming but not recording, a recording instance
+      // // was created for the replay buffer. If the replay buffer is stopped, the recording
+      // // instance should be destroyed.
+      // if (this.state.status.horizontal.recording === ERecordingState.Offline) {
+      //   this.destroyOutputContextIfExists(display, 'recording');
+      // }
+
+      // this.destroyOutputContextIfExists(display, 'replayBuffer');
     }
   }
 
@@ -2286,12 +2316,6 @@ export class StreamingService
     }
   }
 
-  @mutation()
-  private SET_VERTICAL_STREAMING_STATUS(status: EStreamingState, time?: string) {
-    this.state.streamingStatus = status;
-    if (time) this.state.streamingStatusTime = time;
-  }
-
   @mutation()
   private SET_RECORDING_STATUS(status: ERecordingState, display: TDisplayType, time: string) {
     // while recording and the replay buffer are in the v2 API and streaming is in the old API

From d06ce47fc85f7ea64ba247d842f443d569f7c1e1 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Wed, 12 Mar 2025 12:40:30 -0400
Subject: [PATCH 15/25] Only destroy replay buffer on app close.

---
 app/services/streaming/streaming.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index 62a7fd510c14..92c25ae4d7d2 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1593,7 +1593,7 @@ export class StreamingService
         console.log('destroyed streaming instance for replay buffer');
       }
 
-      this.destroyOutputContextIfExists(display, 'replayBuffer');
+      // this.destroyOutputContextIfExists(display, 'replayBuffer');
 
       // THE BELOW WORKS
       // // In the case that the user is streaming but not recording, a recording instance

From 9932d37a1552859a24d180545fb1dcb05f30218e Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Mon, 24 Mar 2025 12:27:42 -0400
Subject: [PATCH 16/25] Added Advanced Recording test.

---
 .../pages/RecordingHistory.tsx                |  6 +-
 .../root/NotificationsArea.tsx                |  2 +-
 test/helpers/modules/settings/settings.ts     | 31 +++----
 test/regular/recording.ts                     | 86 +++++++++++++++----
 4 files changed, 93 insertions(+), 32 deletions(-)

diff --git a/app/components-react/pages/RecordingHistory.tsx b/app/components-react/pages/RecordingHistory.tsx
index e7a34e3812e3..f018e0c1998e 100644
--- a/app/components-react/pages/RecordingHistory.tsx
+++ b/app/components-react/pages/RecordingHistory.tsx
@@ -293,7 +293,11 @@ export function RecordingHistory() {
             <div className={styles.recording} key={recording.timestamp}>
               <span style={{ marginRight: '8px' }}>{formattedTimestamp(recording.timestamp)}</span>
               <Tooltip title={$t('Show in folder')}>
-                <span onClick={() => showFile(recording.filename)} className={styles.filename}>
+                <span
+                  data-test="filename"
+                  onClick={() => showFile(recording.filename)}
+                  className={styles.filename}
+                >
                   {recording.filename}
                 </span>
               </Tooltip>
diff --git a/app/components-react/root/NotificationsArea.tsx b/app/components-react/root/NotificationsArea.tsx
index 4307621658fe..5d27c49d630f 100644
--- a/app/components-react/root/NotificationsArea.tsx
+++ b/app/components-react/root/NotificationsArea.tsx
@@ -48,7 +48,7 @@ class NotificationsModule {
 
     message
       .info({
-        content: <MessageNode notif={notif} />,
+        content: <MessageNode notif={notif} data-testid="notification" />,
         duration: notif.lifeTime === -1 ? 0 : notif.lifeTime / 1000,
         key: `${notif.message}${notif.date}`,
         onClick: () => this.clickNotif(),
diff --git a/test/helpers/modules/settings/settings.ts b/test/helpers/modules/settings/settings.ts
index 98f98d627edc..da59998ca731 100644
--- a/test/helpers/modules/settings/settings.ts
+++ b/test/helpers/modules/settings/settings.ts
@@ -1,17 +1,9 @@
-import {
-  click,
-  clickButton,
-  clickCheckbox,
-  focusChild,
-  useChildWindow,
-  useMainWindow,
-} from '../core';
+import { click, clickButton, focusChild, useChildWindow, useMainWindow } from '../core';
 import { mkdtemp } from 'fs-extra';
 import { tmpdir } from 'os';
 import * as path from 'path';
 import { setInputValue } from '../forms/base';
-import { FormMonkey } from '../../form-monkey';
-import { TExecutionContext } from '../../../helpers/webdriver';
+import { setFormDropdown } from '../../webdriver/forms';
 
 /**
  * Open the settings window with a given category selected
@@ -35,12 +27,21 @@ export async function showSettingsWindow(category: string, cb?: () => Promise<un
 /**
  * Set recording path to a temp dir
  */
-export async function setTemporaryRecordingPath(): Promise<string> {
+export async function setTemporaryRecordingPath(advanced: boolean = false): Promise<string> {
   const tmpDir = await mkdtemp(path.join(tmpdir(), 'slobs-recording-'));
-  await showSettingsWindow('Output', async () => {
-    await setInputValue('[data-name="FilePath"] input', tmpDir);
-    await clickButton('Done');
-  });
+
+  if (advanced) {
+    await showSettingsWindow('Output', async () => {
+      await setFormDropdown('Output Mode', 'Advanced');
+      await clickButton('Recording');
+      await setInputValue('[data-name="RecFilePath"] input', tmpDir);
+    });
+  } else {
+    await showSettingsWindow('Output', async () => {
+      await setInputValue('[data-name="FilePath"] input', tmpDir);
+      await clickButton('Done');
+    });
+  }
   return tmpDir;
 }
 
diff --git a/test/regular/recording.ts b/test/regular/recording.ts
index 551db9d4d377..2a88d5d71070 100644
--- a/test/regular/recording.ts
+++ b/test/regular/recording.ts
@@ -1,5 +1,5 @@
 import { readdir } from 'fs-extra';
-import { test, useWebdriver } from '../helpers/webdriver';
+import { test, TExecutionContext, useWebdriver } from '../helpers/webdriver';
 import { sleep } from '../helpers/sleep';
 import { startRecording, stopRecording } from '../helpers/modules/streaming';
 import { FormMonkey } from '../helpers/form-monkey';
@@ -8,29 +8,42 @@ import {
   setTemporaryRecordingPath,
   showSettingsWindow,
 } from '../helpers/modules/settings/settings';
-import { clickButton, focusMain } from '../helpers/modules/core';
+import {
+  clickButton,
+  clickWhenDisplayed,
+  focusMain,
+  getNumElements,
+  waitForDisplayed,
+} from '../helpers/modules/core';
 import { logIn } from '../helpers/webdriver/user';
 import { toggleDualOutputMode } from '../helpers/modules/dual-output';
+import { setFormDropdown } from '../helpers/webdriver/forms';
+import { setInputValue } from '../helpers/modules/forms';
 
+// not a react hook
+// eslint-disable-next-line react-hooks/rules-of-hooks
 useWebdriver();
 
 /**
- * Recording with one context active (horizontal)
+ * Iterate over all formats and record a 0.5s video in each.
+ * @param t - AVA test context
+ * @param advanced - whether to use advanced settings
+ * @returns number of formats
  */
-
-test('Recording', async t => {
-  const tmpDir = await setTemporaryRecordingPath();
-
-  // low resolution reduces CPU usage
-  await setOutputResolution('100x100');
-
+async function createRecordingFiles(advanced: boolean = false): Promise<number> {
   const formats = ['flv', 'mp4', 'mov', 'mkv', 'ts', 'm3u8'];
 
   // Record 0.5s video in every format
   for (const format of formats) {
     await showSettingsWindow('Output', async () => {
-      const form = new FormMonkey(t);
-      await form.setInputValue(await form.getInputSelectorByTitle('Recording Format'), format);
+      await sleep(2000);
+      if (advanced) {
+        await clickButton('Recording');
+      }
+
+      await sleep(2000);
+      await setFormDropdown('Recording Format', format);
+      await sleep(2000);
       await clickButton('Done');
     });
 
@@ -39,15 +52,52 @@ test('Recording', async t => {
     await sleep(500);
     await stopRecording();
 
-    // Wait to ensure that output setting are editable
-    await sleep(500);
+    // Confirm notification has been shown
+    await focusMain();
+    await clickWhenDisplayed('span=A new Recording has been completed. Click for more info');
+    await waitForDisplayed('h1=Recordings', { timeout: 1000 });
   }
 
+  return Promise.resolve(formats.length);
+}
+
+async function validateRecordingFiles(t: TExecutionContext, tmpDir: string, numFormats: number) {
   // Check that every file was created
   const files = await readdir(tmpDir);
 
+  const numFiles = files.length;
+
   // 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(numFiles >= numFormats, `Files that were created:\n${files.join('\n')}`);
+  waitForDisplayed('h1=Recordings');
+
+  const numRecordings = await getNumElements('[data-test=filename]');
+  t.is(numRecordings, numFiles, 'Number of recordings in history matches number of files recorded');
+}
+
+/**
+ * Recording with one context active (horizontal)
+ */
+
+test('Recording', async t => {
+  // low resolution reduces CPU usage
+  await setOutputResolution('100x100');
+  const tmpDir = await setTemporaryRecordingPath();
+
+  // Simple Recording
+  const numFormats = await createRecordingFiles();
+  await validateRecordingFiles(t, tmpDir, numFormats);
+});
+
+test('Advanced Recording', async t => {
+  // low resolution reduces CPU usage
+  await setOutputResolution('100x100');
+
+  // Advanced Recording
+  const tmpAdvDir = await setTemporaryRecordingPath(true);
+  const numFiles = await createRecordingFiles(true);
+  console.log('numFiles', numFiles);
+  await validateRecordingFiles(t, tmpAdvDir, numFiles);
 });
 
 /**
@@ -60,6 +110,12 @@ test('Recording with two contexts active', async t => {
   const tmpDir = await setTemporaryRecordingPath();
   // low resolution reduces CPU usage
   await setOutputResolution('100x100');
+  await showSettingsWindow('Output', async () => {
+    await setFormDropdown('Output Mode', 'Advanced');
+    await clickButton('Recording');
+    await setInputValue('[data-name="RecFilePath"] input', tmpDir);
+  });
+
   const formats = ['flv', 'mp4', 'mov', 'mkv', 'ts', 'm3u8'];
   // Record 0.5s video in every format
   for (const format of formats) {

From 4d4d247e5a58491c33176414e3a196f8a54c996f Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Mon, 24 Mar 2025 13:38:08 -0400
Subject: [PATCH 17/25] Fix Advanced Recording.

---
 app/components-react/root/StudioFooter.tsx    |   7 +-
 .../api/external-api/streaming/streaming.ts   |   2 +-
 app/services/highlighter/index.ts             |   2 +
 .../settings/output/output-settings.ts        |  23 +-
 app/services/settings/settings.ts             |   5 +-
 app/services/streaming/streaming.ts           | 514 +++++++++++++-----
 test/regular/recording.ts                     |   3 -
 7 files changed, 396 insertions(+), 160 deletions(-)

diff --git a/app/components-react/root/StudioFooter.tsx b/app/components-react/root/StudioFooter.tsx
index 33e244cb5819..8619c748836c 100644
--- a/app/components-react/root/StudioFooter.tsx
+++ b/app/components-react/root/StudioFooter.tsx
@@ -4,7 +4,7 @@ import { EStreamQuality } from '../../services/performance';
 import { EStreamingState, EReplayBufferState, ERecordingState } from '../../services/streaming';
 import { Services } from '../service-provider';
 import { $t } from '../../services/i18n';
-import { useVuex } from '../hooks';
+import { useDebounce, useVuex } from '../hooks';
 import styles from './StudioFooter.m.less';
 import PerformanceMetrics from '../shared/PerformanceMetrics';
 import TestWidgets from './TestWidgets';
@@ -42,7 +42,8 @@ export default function StudioFooterComponent() {
       StreamingService.views.supports('stream-schedule') &&
       !RecordingModeService.views.isRecordingModeEnabled,
     streamQuality: PerformanceService.views.streamQuality,
-    replayBufferOffline: StreamingService.views.replayBufferStatus === EReplayBufferState.Offline,
+    replayBufferOffline:
+      StreamingService.state.status.horizontal.replayBuffer === EReplayBufferState.Offline,
     replayBufferStopping:
       StreamingService.state.status.horizontal.replayBuffer === EReplayBufferState.Stopping,
     replayBufferSaving:
@@ -216,7 +217,7 @@ function RecordingButton() {
         >
           <button
             className={cx(styles.recordButton, 'record-button', { active: isRecording })}
-            onClick={toggleRecording}
+            onClick={useDebounce(200, toggleRecording)}
           >
             <span>
               {recordingStatus === ERecordingState.Stopping ? (
diff --git a/app/services/api/external-api/streaming/streaming.ts b/app/services/api/external-api/streaming/streaming.ts
index 42d2c33ee163..9982daf46bad 100644
--- a/app/services/api/external-api/streaming/streaming.ts
+++ b/app/services/api/external-api/streaming/streaming.ts
@@ -121,7 +121,7 @@ export class StreamingService implements ISerializable {
   /**
    * Toggles recording.
    */
-  toggleRecording(): void {
+  toggleRecording(): Promise<void> {
     return this.streamingService.toggleRecording();
   }
 
diff --git a/app/services/highlighter/index.ts b/app/services/highlighter/index.ts
index 65835f3d3cca..e49ba6b8f21d 100644
--- a/app/services/highlighter/index.ts
+++ b/app/services/highlighter/index.ts
@@ -354,6 +354,8 @@ export class HighlighterService extends PersistentStatefulService<IHighlighterSt
 
     this.handleStreamingChanges();
 
+    if (!this.userService.isLoggedIn) return;
+
     this.streamingService.replayBufferFileWrite.subscribe(async clipPath => {
       const message = $t('A new Highlight has been saved. Click to edit in the Highlighter');
 
diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts
index 716c5d0af900..7002fd093b95 100644
--- a/app/services/settings/output/output-settings.ts
+++ b/app/services/settings/output/output-settings.ts
@@ -5,8 +5,17 @@ import { HighlighterService } from 'services/highlighter';
 import { Inject } from 'services/core/injector';
 import { Dictionary } from 'vuex';
 import { AudioService } from 'app-services';
-import { ERecordingQuality, ERecordingFormat } from 'obs-studio-node';
-
+import {
+  ERecordingQuality,
+  ERecordingFormat,
+  ISimpleStreaming,
+  IAdvancedStreaming,
+} from 'obs-studio-node';
+
+export type IStreamingOutputSettings = Omit<
+  Partial<ISimpleStreaming | IAdvancedStreaming>,
+  'videoEncoder'
+>;
 /**
  * list of encoders for simple mode
  */
@@ -467,19 +476,15 @@ export class OutputSettingsService extends Service {
 
     const prefix: string = this.settingsService.findSettingValue(
       output,
-      'Recording',
+      'Replay Buffer',
       'RecRBPrefix',
     );
     const suffix: string = this.settingsService.findSettingValue(
       output,
-      'Recording',
+      'Replay Buffer',
       'RecRBSuffix',
     );
-    const duration: number = this.settingsService.findSettingValue(
-      output,
-      'Stream Delay',
-      'DelaySec',
-    );
+    const duration: number = this.settingsService.findSettingValue(output, 'Output', 'RecRBTime');
 
     const useStreamEncoders =
       this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder') === 'none';
diff --git a/app/services/settings/settings.ts b/app/services/settings/settings.ts
index 6958e4d6ac3e..4efead0ed7b0 100644
--- a/app/services/settings/settings.ts
+++ b/app/services/settings/settings.ts
@@ -150,7 +150,7 @@ class SettingsViews extends ViewHandler<ISettingsServiceState> {
     const settingsValues: Partial<ISettingsValues> = {};
 
     for (const groupName in this.state) {
-      this.state[groupName].formData.forEach(subGroup => {
+      this.state[groupName].formData.forEach((subGroup: ISettingsSubCategory) => {
         subGroup.parameters.forEach(parameter => {
           (settingsValues as any)[groupName] =
             settingsValues[groupName as keyof ISettingsValues] || {};
@@ -264,6 +264,7 @@ export class SettingsService extends StatefulService<ISettingsServiceState> {
   private videoEncodingOptimizationService: VideoEncodingOptimizationService;
 
   audioRefreshed = new Subject();
+  // settingsUpdated = new Subject<DeepPartial<ISettingsValues>>();
 
   get views() {
     return new SettingsViews(this.state);
@@ -623,6 +624,8 @@ export class SettingsService extends StatefulService<ISettingsServiceState> {
 
       this.setSettings(categoryName, formSubCategories);
     });
+
+    // this.settingsUpdated.next(patch);
   }
 
   private setAudioSettings(settingsData: ISettingsSubCategory[]) {
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index aa50c1608ddc..67be07a2ea88 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -131,7 +131,18 @@ export interface IOBSOutputSignalInfo {
   service: string; // 'default' | 'vertical'
 }
 
+type TOBSOutputType = 'streaming' | 'recording' | 'replayBuffer';
+
 interface IOutputContext {
+  // simpleStreaming: ISimpleStreaming;
+  // simpleReplayBuffer: ISimpleReplayBuffer;
+  // simpleRecording: ISimpleRecording;
+  // advancedStreaming: IAdvancedStreaming;
+  // advancedRecording: IAdvancedRecording;
+  // advancedReplayBuffer: IAdvancedReplayBuffer;
+  // streaming: ISimpleStreaming | IAdvancedStreaming;
+  // recording: ISimpleRecording | IAdvancedRecording;
+  // replayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer;
   streaming: ISimpleStreaming | IAdvancedStreaming;
   recording: ISimpleRecording | IAdvancedRecording;
   replayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer;
@@ -271,6 +282,10 @@ export class StreamingService
         deep: true,
       },
     );
+
+    // this.settingsService.settingsUpdated.subscribe(() => {
+    //   this.updateOutputInstance();
+    // });
   }
 
   get views() {
@@ -1055,7 +1070,8 @@ export class StreamingService
 
       if (
         recordWhenStreaming &&
-        this.state.status.horizontal.recording === ERecordingState.Offline
+        this.state.status.horizontal.recording === ERecordingState.Offline &&
+        this.state.status.vertical.recording === ERecordingState.Offline
       ) {
         this.toggleRecording();
       }
@@ -1065,7 +1081,8 @@ export class StreamingService
 
     if (
       replayWhenStreaming &&
-      this.state.status.horizontal.replayBuffer === EReplayBufferState.Offline
+      this.state.status.horizontal.replayBuffer === EReplayBufferState.Offline &&
+      this.state.status.vertical.replayBuffer === EReplayBufferState.Offline
     ) {
       this.startReplayBuffer();
     }
@@ -1218,7 +1235,7 @@ export class StreamingService
     this.toggleRecording();
   }
 
-  toggleRecording() {
+  async toggleRecording() {
     // stop recording
     if (
       this.state.status.horizontal.recording === ERecordingState.Recording &&
@@ -1277,17 +1294,20 @@ export class StreamingService
         if (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);
+          await this.createRecording('vertical', 2, true);
         } else {
           // Otherwise, record both displays in dual output mode
-          this.createRecording('vertical', 2);
-          this.createRecording('horizontal', 1);
+          await this.createRecording('vertical', 2, true);
+          await this.createRecording('horizontal', 1, true);
         }
       } else {
         // In single output mode, recording only the horizontal display
-        this.createRecording('horizontal', 1);
+        await this.createRecording('horizontal', 1, true);
       }
     }
+    this.logContexts('horizontal', '*** toggleRecording');
+    this.logContexts('vertical', '+++ toggleRecording');
+    console.log('\n\n');
   }
 
   /**
@@ -1296,68 +1316,81 @@ export class StreamingService
    * @param index - The index of the audio track
    * @param skipStart - Whether to skip starting the recording. This is used when creating a recording instance for the replay buffer
    */
-  private createRecording(display: TDisplayType, index: number, skipStart: boolean = false) {
-    this.destroyOutputContextIfExists(display, 'recording');
-
+  private async createRecording(display: TDisplayType, index: number, start: boolean = false) {
     const mode = this.outputSettingsService.getSettings().mode;
-
-    const recording =
-      mode === 'Advanced'
-        ? (AdvancedRecordingFactory.create() as IAdvancedRecording)
-        : (SimpleRecordingFactory.create() as ISimpleRecording);
-
     const settings = this.outputSettingsService.getRecordingSettings();
 
-    // assign settings
-    Object.keys(settings).forEach(key => {
-      if ((settings as any)[key] === undefined) return;
-
-      // share the video encoder with the streaming instance if it exists
-      if (key === 'videoEncoder') {
-        recording.videoEncoder =
-          this.contexts[display].streaming?.videoEncoder ??
-          VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder');
-      } else {
-        (recording as any)[key] = (settings as any)[key];
-      }
-    });
+    // recordings must have a streaming instance
+    this.validateOrCreateOutputInstance(mode, display, 'streaming');
 
     // handle unique properties (including audio)
     if (mode === 'Advanced') {
+      const recording = AdvancedRecordingFactory.create() as IAdvancedRecording;
+      const stream = this.contexts[display].streaming as IAdvancedStreaming;
+
+      Object.keys(settings).forEach(key => {
+        if ((settings as any)[key] === undefined) return;
+
+        // share the video encoder with the streaming instance if it exists
+        if (key === 'videoEncoder') {
+          recording.videoEncoder =
+            stream?.videoEncoder ??
+            VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder');
+        } else {
+          (recording as any)[key] = (settings as any)[key];
+        }
+      });
+
       // output resolutions
       const resolution = this.videoSettingsService.outputResolutions[display];
-      (recording as IAdvancedRecording).outputWidth = resolution.outputWidth;
-      (recording as IAdvancedRecording).outputHeight = resolution.outputHeight;
+      recording.outputWidth = resolution.outputWidth;
+      recording.outputHeight = resolution.outputHeight;
 
       // audio track
       this.createAudioTrack(index);
 
-      // streaming instance
-      this.validateOrCreateOutputInstance(mode, display, 'streaming');
-      (recording as IAdvancedRecording).streaming = this.contexts[display]
-        .streaming as IAdvancedStreaming;
+      recording.streaming = stream;
+      this.contexts[display].recording = recording as IAdvancedRecording;
     } else {
-      (recording as ISimpleRecording).audioEncoder = AudioEncoderFactory.create();
-    }
+      const recording = SimpleRecordingFactory.create() as ISimpleRecording;
+      const stream = this.contexts[display].streaming as ISimpleStreaming;
+
+      Object.keys(settings).forEach(key => {
+        if ((settings as any)[key] === undefined) return;
 
-    // save in state
-    this.contexts[display].recording = recording;
+        // share the video encoder with the streaming instance if it exists
+        if (key === 'videoEncoder') {
+          recording.videoEncoder =
+            stream?.videoEncoder ??
+            VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder');
+        } else {
+          (recording as any)[key] = (settings as any)[key];
+        }
+      });
+
+      recording.streaming = stream;
+      recording.audioEncoder = AudioEncoderFactory.create();
+      this.contexts[display].recording = recording as ISimpleRecording;
+    }
 
     // assign context
     this.contexts[display].recording.video = this.videoSettingsService.contexts[display];
 
     // set signal handler
-    this.contexts[display].recording.signalHandler = async signal => {
+    this.contexts[display].recording.signalHandler = async (signal: EOutputSignal) => {
       console.log('recording signal', signal);
       await this.handleSignal(signal, display);
     };
 
     // The replay buffer requires a recording instance. If the user is streaming but not recording,
     // a recording instance still needs to be created but does not need to be started.
-    if (skipStart) return;
+    if (start) {
+      // start recording
+      this.contexts[display].recording.start();
+    }
 
-    // start recording
-    this.contexts[display].recording.start();
+    this.logContexts(display, 'createRecording created ');
+    return Promise.resolve(this.contexts[display].recording);
   }
 
   /**
@@ -1366,22 +1399,18 @@ export class StreamingService
    * @param index - The index of the audio track
    * @param skipStart - Whether to skip starting the streaming. This is used when creating a streaming instance for advanced recording
    */
-  private createStreaming(display: TDisplayType, index: number, skipStart: boolean = false) {
-    this.destroyOutputContextIfExists(display, 'streaming');
-
+  private async createStreaming(display: TDisplayType, index: number, start: boolean = false) {
     const mode = this.outputSettingsService.getSettings().mode;
 
     const settings = this.outputSettingsService.getStreamingSettings();
 
-    console.log('createStreaming settings', settings);
-
     const stream =
       mode === 'Advanced'
         ? (AdvancedStreamingFactory.create() as IAdvancedStreaming)
         : (SimpleStreamingFactory.create() as ISimpleStreaming);
 
     // assign settings
-    Object.keys(settings).forEach(key => {
+    Object.keys(settings).forEach((key: keyof Partial<ISimpleStreaming>) => {
       if ((settings as any)[key] === undefined) return;
 
       // share the video encoder with the recording instance if it exists
@@ -1398,11 +1427,9 @@ export class StreamingService
       const resolution = this.videoSettingsService.outputResolutions[display];
       stream.outputWidth = resolution.outputWidth;
       stream.outputHeight = resolution.outputHeight;
-
       // stream audio track
       this.createAudioTrack(index);
       stream.audioTrack = index;
-
       // Twitch VOD audio track
       if (stream.enableTwitchVOD && stream.twitchTrack) {
         this.createAudioTrack(stream.twitchTrack);
@@ -1411,29 +1438,39 @@ export class StreamingService
         stream.twitchTrack = index + 1;
         this.createAudioTrack(stream.twitchTrack);
       }
+
+      this.contexts[display].streaming = stream as IAdvancedStreaming;
+    } else if (this.isSimpleStreaming(stream)) {
+      stream.audioEncoder = AudioEncoderFactory.create();
+      this.contexts[display].streaming = stream as ISimpleStreaming;
     } else {
-      stream.audioEncoder =
-        (this.contexts[display].recording as ISimpleRecording)?.audioEncoder ??
-        AudioEncoderFactory.create();
+      throwStreamError(
+        'UNKNOWN_STREAMING_ERROR_WITH_MESSAGE',
+        {},
+        'Unable to create streaming instance',
+      );
     }
 
-    stream.video = this.videoSettingsService.contexts[display];
-    stream.signalHandler = async signal => {
+    this.contexts[display].streaming.video = this.videoSettingsService.contexts[display];
+    this.contexts[display].streaming.signalHandler = async signal => {
       console.log('streaming signal', signal);
       await this.handleSignal(signal, display);
     };
 
-    this.contexts[display].streaming = stream;
-
-    if (skipStart) return;
-
-    // TODO: fully implement streaming
     this.contexts[display].streaming.service = ServiceFactory.legacySettings;
     this.contexts[display].streaming.delay = DelayFactory.create();
     this.contexts[display].streaming.reconnect = ReconnectFactory.create();
     this.contexts[display].streaming.network = NetworkFactory.create();
 
-    this.contexts[display].streaming.start();
+    if (start) {
+      this.contexts[display].streaming.start();
+    }
+
+    console.log(
+      'createdStreaming this.contexts[display].streaming',
+      this.contexts[display].streaming,
+    );
+    return Promise.resolve(this.contexts[display].streaming);
   }
 
   /**
@@ -1531,13 +1568,12 @@ export class StreamingService
       }
       await this.markersService.exportCsv(parsedName);
 
-      // destroy recording instance
+      // Finally, all actions are completed and the recording context can be destroyed
       this.destroyOutputContextIfExists(display, 'recording');
+
       // Also destroy the streaming instance if it was only created for recording
       // Note: this is only the case when recording without streaming in advanced mode
-      if (this.state.status[display].streaming === EStreamingState.Offline) {
-        this.destroyOutputContextIfExists(display, 'streaming');
-      }
+      this.destroyOutputContextIfExists(display, 'streaming', true);
 
       this.latestRecordingPath.next(fileName);
     }
@@ -1558,7 +1594,10 @@ export class StreamingService
     } as Dictionary<EReplayBufferState>)[info.signal];
 
     // We received a signal we didn't recognize
-    if (!nextState) return;
+    if (!nextState) {
+      this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Offline, display);
+      return;
+    }
 
     const time = new Date().toISOString();
     this.SET_REPLAY_BUFFER_STATUS(nextState, display, time);
@@ -1593,61 +1632,49 @@ export class StreamingService
       //    a recording instance is created for the replay buffer. If the replay buffer is stopped,
       //    the recording instance should be destroyed.
 
-      // destroy any recording instances created for use with the replay buffer
-      if (this.state.status[display].recording === ERecordingState.Offline) {
-        this.destroyOutputContextIfExists(display, 'recording');
-        console.log('destroyed recording instance for replay buffer');
-      }
-
-      // destroy any streaming instances created by the recording instance for use with the replay buffer
-      // Note: this is only the case when recording without streaming in advanced mode
-      if (this.state.status[display].streaming === EStreamingState.Offline) {
-        this.destroyOutputContextIfExists(display, 'streaming');
-        console.log('destroyed streaming instance for replay buffer');
-      }
-
-      this.destroyOutputContextIfExists(display, 'replayBuffer');
-
       // THE BELOW WORKS
-      // // In the case that the user is streaming but not recording, a recording instance
-      // // was created for the replay buffer. If the replay buffer is stopped, the recording
-      // // instance should be destroyed.
-      // if (this.state.status.horizontal.recording === ERecordingState.Offline) {
-      //   this.destroyOutputContextIfExists(display, 'recording');
-      // }
+      // In the case that the user is streaming but not recording, a recording instance
+      // was created for the replay buffer. If the replay buffer is stopped, the recording
+      // instance should be destroyed.
 
-      // this.destroyOutputContextIfExists(display, 'replayBuffer');
+      // if (offline) {
+      //   this.destroyOutputContextIfExists(display, 'streaming');
+      // }
+      // this.contexts[display].replayBuffer.recording = null;
+      // this.contexts[display].replayBuffer.streaming = null;
+      this.destroyOutputContextIfExists(display, 'replayBuffer');
+      this.destroyOutputContextIfExists(display, 'recording');
+      this.destroyOutputContextIfExists(display, 'streaming');
     }
   }
 
   splitFile(display: TDisplayType = 'horizontal') {
     if (
-      this.state.status.horizontal.recording === ERecordingState.Recording &&
+      this.state.status[display].recording === ERecordingState.Recording &&
       this.contexts[display].recording
     ) {
       this.contexts[display].recording.splitFile();
     }
   }
 
-  // TODO migrate streaming to new API
-
   startReplayBuffer(display: TDisplayType = 'horizontal') {
     if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return;
     // change the replay buffer status for the loading animation
-    // this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Running, display);
+    this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Running, display);
 
     this.createReplayBuffer(display);
   }
 
   createReplayBuffer(display: TDisplayType = 'horizontal') {
-    this.destroyOutputContextIfExists(display, 'replayBuffer');
+    // this.destroyOutputContextIfExists(display, 'replayBuffer');
 
     const mode = this.outputSettingsService.getSettings().mode;
 
-    // A replay buffer requires a recording instance
+    // A replay buffer requires a recording instance and a streaming instance
     this.validateOrCreateOutputInstance(mode, display, 'recording');
+    this.validateOrCreateOutputInstance(mode, display, 'streaming');
 
-    const settings = this.outputSettingsService.getRecordingSettings();
+    const settings = this.outputSettingsService.getReplayBufferSettings();
     const replayBuffer =
       mode === 'Advanced'
         ? (AdvancedReplayBufferFactory.create() as IAdvancedReplayBuffer)
@@ -1659,21 +1686,39 @@ export class StreamingService
       (replayBuffer as any)[key] = (settings as any)[key];
     });
 
-    replayBuffer.recording =
-      mode === 'Advanced'
-        ? (this.contexts[display].recording as IAdvancedRecording)
-        : (this.contexts[display].recording as ISimpleRecording);
-
-    this.contexts[display].replayBuffer =
-      mode === 'Advanced'
-        ? (replayBuffer as IAdvancedReplayBuffer)
-        : (replayBuffer as ISimpleReplayBuffer);
-
-    this.contexts[display].replayBuffer.video = this.videoSettingsService.contexts[display];
-    this.contexts[display].replayBuffer.signalHandler = async signal => {
-      console.log('replay buffer signal', signal);
-      await this.handleSignal(signal, display);
-    };
+    // replayBuffer.recording =
+    //   mode === 'Advanced'
+    //     ? (this.contexts[display].recording as IAdvancedRecording)
+    //     : (this.contexts[display].recording as ISimpleRecording);
+
+    // // replayBuffer.recording.signalHandler = async signal => {
+    // //   console.log('replay buffer recording signal', signal);
+    // //   await this.handleSignal(signal, display);
+    // // };
+
+    // replayBuffer.streaming =
+    //   mode === 'Advanced'
+    //     ? (this.contexts[display].streaming as IAdvancedStreaming)
+    //     : (this.contexts[display].streaming as ISimpleStreaming);
+
+    // // this.contexts[display].replayBuffer =
+    // //   mode === 'Advanced'
+    // //     ? (replayBuffer as IAdvancedReplayBuffer)
+    // //     : (replayBuffer as ISimpleReplayBuffer);
+
+    // if (this.isAdvancedReplayBuffer(replayBuffer)) {
+    //   (this.contexts[display]
+    //     .replayBuffer as IAdvancedReplayBuffer) = replayBuffer as IAdvancedReplayBuffer;
+    // } else {
+    //   (this.contexts[display]
+    //     .replayBuffer as ISimpleReplayBuffer) = replayBuffer as ISimpleReplayBuffer;
+    // }
+
+    // this.contexts[display].replayBuffer.video = this.videoSettingsService.contexts[display];
+    // this.contexts[display].replayBuffer.signalHandler = async signal => {
+    //   console.log('replay buffer signal', signal);
+    //   await this.handleSignal(signal, display);
+    // };
 
     this.contexts[display].replayBuffer.start();
     this.usageStatisticsService.recordFeatureUsage('ReplayBuffer');
@@ -1701,7 +1746,72 @@ export class StreamingService
     this.contexts[display].replayBuffer.save();
   }
 
-  private validateOrCreateOutputInstance(
+  private updateOutputInstance() {
+    if (
+      this.contexts.horizontal.streaming &&
+      this.state.status.horizontal.streaming !== EStreamingState.Offline
+    ) {
+      const settings = this.outputSettingsService.getStreamingSettings() as Omit<
+        Partial<ISimpleStreaming | IAdvancedStreaming>,
+        'videoEncoder'
+      >;
+      const outputInstance: ISimpleStreaming | IAdvancedStreaming = this.contexts.horizontal
+        .streaming;
+
+      Object.keys(settings).forEach(
+        (key: keyof Omit<Partial<ISimpleStreaming | IAdvancedStreaming>, 'videoEncoder'>) => {
+          if ((settings as any)[key] === undefined) return;
+          if (outputInstance[key] !== settings[key]) {
+            (this.contexts.horizontal.streaming as any)[key] = (settings as any)[key];
+          }
+        },
+      );
+    }
+
+    if (
+      this.contexts.horizontal.recording &&
+      this.state.status.horizontal.recording !== ERecordingState.Offline
+    ) {
+      const settings = this.outputSettingsService.getRecordingSettings() as Omit<
+        Partial<ISimpleRecording | IAdvancedRecording>,
+        'videoEncoder'
+      >;
+
+      const outputInstance: ISimpleRecording | IAdvancedRecording = this.contexts.horizontal
+        .recording;
+      Object.keys(settings).forEach(
+        (key: keyof Omit<Partial<ISimpleRecording | IAdvancedRecording>, 'videoEncoder'>) => {
+          if ((settings as any)[key] === undefined) return;
+          if (outputInstance[key] !== settings[key]) {
+            (this.contexts.horizontal.recording as any)[key] = (settings as any)[key];
+          }
+        },
+      );
+    }
+
+    if (
+      this.contexts.horizontal.replayBuffer &&
+      this.state.status.horizontal.replayBuffer !== EReplayBufferState.Offline
+    ) {
+      const settings = this.outputSettingsService.getReplayBufferSettings() as Omit<
+        Partial<ISimpleReplayBuffer | IAdvancedReplayBuffer>,
+        'videoEncoder'
+      >;
+
+      const outputInstance: ISimpleReplayBuffer | IAdvancedReplayBuffer = this.contexts.horizontal
+        .replayBuffer;
+      Object.keys(settings).forEach(
+        (key: keyof Omit<Partial<ISimpleReplayBuffer | IAdvancedReplayBuffer>, 'videoEncoder'>) => {
+          if ((settings as any)[key] === undefined) return;
+          if (outputInstance[key] !== settings[key]) {
+            (this.contexts.horizontal.replayBuffer as any)[key] = (settings as any)[key];
+          }
+        },
+      );
+    }
+  }
+
+  private async validateOrCreateOutputInstance(
     mode: 'Simple' | 'Advanced',
     display: TDisplayType,
     type: 'streaming' | 'recording',
@@ -1724,10 +1834,9 @@ export class StreamingService
 
     // Create new instance if it does not exist or was destroyed
     if (type === 'streaming') {
-      // TODO: create streaming instance
-      this.createStreaming(display, 1, true);
+      await this.createStreaming(display, 1);
     } else {
-      this.createRecording(display, 1, true);
+      await this.createRecording(display, 1);
     }
   }
 
@@ -1741,14 +1850,48 @@ export class StreamingService
     AudioTrackFactory.setAtIndex(track, index);
   }
 
-  isAdvancedStreaming(instance: any): instance is IAdvancedStreaming {
+  isAdvancedStreaming(
+    instance: ISimpleStreaming | IAdvancedStreaming | null,
+  ): instance is IAdvancedStreaming {
+    if (!instance) return false;
     return 'rescaling' in instance;
   }
 
-  isAdvancedRecording(instance: any): instance is IAdvancedRecording {
+  isAdvancedRecording(
+    instance: ISimpleRecording | IAdvancedRecording | null,
+  ): instance is IAdvancedRecording {
+    if (!instance) return false;
     return 'useStreamEncoders' in instance;
   }
 
+  isAdvancedReplayBuffer(
+    instance: ISimpleReplayBuffer | IAdvancedReplayBuffer | null,
+  ): instance is IAdvancedReplayBuffer {
+    if (!instance) return false;
+    return 'mixer' in instance;
+  }
+
+  isSimpleStreaming(
+    instance: ISimpleStreaming | IAdvancedStreaming | null,
+  ): instance is ISimpleStreaming {
+    if (!instance) return false;
+    return 'useAdvanced' in instance;
+  }
+
+  isSimpleRecording(
+    instance: ISimpleRecording | IAdvancedRecording | null,
+  ): instance is ISimpleRecording {
+    if (!instance) return false;
+    return 'lowCPU' in instance;
+  }
+
+  isSimpleReplayBuffer(
+    instance: ISimpleReplayBuffer | IAdvancedReplayBuffer | null,
+  ): instance is ISimpleReplayBuffer {
+    if (!instance) return false;
+    return !('mixer' in instance);
+  }
+
   handleFactoryOutputError(info: EOutputSignal, display: TDisplayType) {
     const legacyInfo = {
       type: info.type as EOBSOutputType,
@@ -2253,67 +2396,152 @@ export class StreamingService
    * @remark Will just return if the context is null
    * @param display - The display to destroy the output context for
    * @param contextType - The name of the output context to destroy
+   * @param confirmOffline - If true, the context will be destroyed regardless
+   * of the status of the other outputs. Default is false.
    * @returns A promise that resolves to true if the context was destroyed, false
    * if the context did not exist
    */
   private destroyOutputContextIfExists(
     display: TDisplayType | string,
     contextType: keyof IOutputContext,
+    confirmOffline: boolean = false,
   ) {
+    this.logContexts(display as TDisplayType, 'destroyOutputContextIfExists');
+    // if the context does not exist there is nothing to destroy
     if (!this.contexts[display] || !this.contexts[display][contextType]) return;
 
+    const offline =
+      this.state.status[display].streaming === EStreamingState.Offline &&
+      this.state.status[display].recording === ERecordingState.Offline &&
+      this.state.status[display].replayBuffer === EReplayBufferState.Offline;
+
+    if (confirmOffline && !offline) return;
+
+    // prevent errors by stopping an active context before destroying it
     if (this.state.status[display][contextType].toString() !== 'offline') {
       this.contexts[display][contextType].stop();
 
       // change the status to offline for the UI
       switch (contextType) {
         case 'streaming':
-          this.state.status[display][contextType] = EStreamingState.Offline;
-          break;
-        case 'recording':
-          this.state.status[display][contextType] = ERecordingState.Offline;
-          break;
-        case 'replayBuffer':
-          this.state.status[display][contextType] = EReplayBufferState.Offline;
-          break;
-      }
-    }
-
-    if (this.outputSettingsService.getSettings().mode === 'Advanced') {
-      switch (contextType) {
-        case 'streaming':
-          AdvancedStreamingFactory.destroy(
-            this.contexts[display][contextType] as IAdvancedStreaming,
+          this.SET_STREAMING_STATUS(
+            EStreamingState.Offline,
+            display as TDisplayType,
+            new Date().toISOString(),
           );
           break;
         case 'recording':
-          AdvancedRecordingFactory.destroy(
-            this.contexts[display][contextType] as IAdvancedRecording,
+          this.SET_RECORDING_STATUS(
+            ERecordingState.Offline,
+            display as TDisplayType,
+            new Date().toISOString(),
           );
           break;
         case 'replayBuffer':
-          AdvancedReplayBufferFactory.destroy(
-            this.contexts[display][contextType] as IAdvancedReplayBuffer,
+          this.SET_REPLAY_BUFFER_STATUS(
+            EReplayBufferState.Offline,
+            display as TDisplayType,
+            new Date().toISOString(),
           );
           break;
       }
-    } else {
+    }
+
+    // identify the output's factory in order to destroy the context
+    if (this.outputSettingsService.getSettings().mode === 'Advanced') {
       switch (contextType) {
         case 'streaming':
-          SimpleStreamingFactory.destroy(this.contexts[display][contextType] as ISimpleStreaming);
+          this.isAdvancedStreaming(this.contexts[display][contextType])
+            ? AdvancedStreamingFactory.destroy(
+                this.contexts[display][contextType] as IAdvancedStreaming,
+              )
+            : SimpleStreamingFactory.destroy(
+                this.contexts[display][contextType] as ISimpleStreaming,
+              );
           break;
         case 'recording':
-          SimpleRecordingFactory.destroy(this.contexts[display][contextType] as ISimpleRecording);
+          this.isAdvancedRecording(this.contexts[display][contextType])
+            ? AdvancedRecordingFactory.destroy(
+                this.contexts[display][contextType] as IAdvancedRecording,
+              )
+            : SimpleRecordingFactory.destroy(
+                this.contexts[display][contextType] as ISimpleRecording,
+              );
           break;
         case 'replayBuffer':
-          SimpleReplayBufferFactory.destroy(
-            this.contexts[display][contextType] as ISimpleReplayBuffer,
-          );
+          this.isAdvancedReplayBuffer(this.contexts[display][contextType])
+            ? AdvancedReplayBufferFactory.destroy(
+                this.contexts[display][contextType] as IAdvancedReplayBuffer,
+              )
+            : SimpleReplayBufferFactory.destroy(
+                this.contexts[display][contextType] as ISimpleReplayBuffer,
+              );
           break;
       }
+
+      // switch (contextType) {
+      //   case 'streaming':
+      //     AdvancedStreamingFactory.destroy(
+      //       this.contexts[display][contextType] as IAdvancedStreaming,
+      //     );
+      //     break;
+      //   case 'recording':
+      //     AdvancedRecordingFactory.destroy(
+      //       this.contexts[display][contextType] as IAdvancedRecording,
+      //     );
+      //     break;
+      //   case 'replayBuffer':
+      //     AdvancedReplayBufferFactory.destroy(
+      //       this.contexts[display][contextType] as IAdvancedReplayBuffer,
+      //     );
+      //     break;
+      // }
+      // } else {
+      //   switch (contextType) {
+      //     case 'streaming':
+      //       SimpleStreamingFactory.destroy(this.contexts[display][contextType] as ISimpleStreaming);
+      //       break;
+      //     case 'recording':
+      //       SimpleRecordingFactory.destroy(this.contexts[display][contextType] as ISimpleRecording);
+      //       break;
+      //     case 'replayBuffer':
+      //       SimpleReplayBufferFactory.destroy(
+      //         this.contexts[display][contextType] as ISimpleReplayBuffer,
+      //       );
+      //       break;
+      //   }
     }
 
     this.contexts[display][contextType] = null;
+
+    console.log(
+      'destroyed this.contexts[display][contextType]',
+      display,
+      contextType,
+      this.contexts[display][contextType],
+    );
+  }
+
+  logContexts(display: TDisplayType, label?: string) {
+    const mode = this.outputSettingsService.getSettings().mode;
+
+    console.log(
+      display,
+      [label, 'this.contexts[display].recording'].join(' '),
+      this.isAdvancedRecording(this.contexts[display].recording)
+        ? (this.contexts[display].recording as IAdvancedRecording)
+        : (this.contexts[display].recording as ISimpleRecording),
+    );
+    console.log(
+      display,
+      [label, 'this.contexts[display].streaming'].join(' '),
+      this.contexts[display].streaming,
+    );
+    console.log(
+      display,
+      [label, 'this.contexts[display].replayBuffer'].join(' '),
+      this.contexts[display].replayBuffer,
+    );
   }
 
   @mutation()
diff --git a/test/regular/recording.ts b/test/regular/recording.ts
index 2a88d5d71070..9839cd26f74b 100644
--- a/test/regular/recording.ts
+++ b/test/regular/recording.ts
@@ -36,14 +36,11 @@ async function createRecordingFiles(advanced: boolean = false): Promise<number>
   // Record 0.5s video in every format
   for (const format of formats) {
     await showSettingsWindow('Output', async () => {
-      await sleep(2000);
       if (advanced) {
         await clickButton('Recording');
       }
 
-      await sleep(2000);
       await setFormDropdown('Recording Format', format);
-      await sleep(2000);
       await clickButton('Done');
     });
 

From 4c0af7fa45bcee051387ca153016dda21471b7ec Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 25 Mar 2025 17:03:05 -0400
Subject: [PATCH 18/25] Fix reference issue with Replay Buffer. WIP: Recording
 while streaming.

---
 app/services/highlighter/index.ts   |   2 +-
 app/services/streaming/streaming.ts | 169 ++++++++++++++++------------
 2 files changed, 100 insertions(+), 71 deletions(-)

diff --git a/app/services/highlighter/index.ts b/app/services/highlighter/index.ts
index e49ba6b8f21d..8cb7be96b8be 100644
--- a/app/services/highlighter/index.ts
+++ b/app/services/highlighter/index.ts
@@ -359,7 +359,7 @@ export class HighlighterService extends PersistentStatefulService<IHighlighterSt
     this.streamingService.replayBufferFileWrite.subscribe(async clipPath => {
       const message = $t('A new Highlight has been saved. Click to edit in the Highlighter');
 
-      this.notificationsService.actions.push({
+      this.notificationsService.push({
         type: ENotificationType.SUCCESS,
         message,
         action: this.jsonrpcService.createRequest(Service.getResourceId(this), 'showHighlighter'),
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index fc99098d8280..f01ec087b2ed 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1073,7 +1073,7 @@ export class StreamingService
         this.state.status.horizontal.recording === ERecordingState.Offline &&
         this.state.status.vertical.recording === ERecordingState.Offline
       ) {
-        this.toggleRecording();
+        await this.toggleRecording();
       }
     }
 
@@ -1185,7 +1185,7 @@ export class StreamingService
         this.state.status.horizontal.recording === ERecordingState.Recording ||
         this.state.status.vertical.recording === ERecordingState.Recording;
       if (!keepRecording && isRecording) {
-        this.toggleRecording();
+        await this.toggleRecording();
       }
 
       const keepReplaying = this.streamSettingsService.settings.keepReplayBufferStreamStops;
@@ -1276,7 +1276,8 @@ export class StreamingService
       // stop recording vertical display
       // change the recording status for the loading animation
       this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', new Date().toISOString());
-      this.contexts.vertical.recording.stop();
+      this.contexts.vertical.recording.stop(true);
+      return;
     } else if (
       this.state.status.horizontal.recording === ERecordingState.Recording &&
       this.contexts.horizontal.recording !== null
@@ -1284,7 +1285,22 @@ export class StreamingService
       // stop recording horizontal display
       // change the recording status for the loading animation
       this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', new Date().toISOString());
-      this.contexts.horizontal.recording.stop(true);
+
+      console.log('stopping horizontal');
+
+      console.log('this.state.status.horizontal.recording', this.state.status.horizontal.recording);
+
+      this.logContexts('horizontal', '*** stopRecording');
+
+      if (this.isAdvancedRecording(this.contexts.horizontal.recording)) {
+        const recording = this.contexts.horizontal.recording as IAdvancedRecording;
+        recording.stop(true);
+      } else if (this.isSimpleRecording(this.contexts.horizontal.recording)) {
+        const recording = this.contexts.horizontal.recording as ISimpleRecording;
+        recording.stop(true);
+      }
+
+      return;
     }
 
     // start recording
@@ -1310,6 +1326,8 @@ export class StreamingService
     this.logContexts('horizontal', '*** toggleRecording');
     this.logContexts('vertical', '+++ toggleRecording');
     console.log('\n\n');
+
+    Promise.resolve();
   }
 
   /**
@@ -1325,6 +1343,11 @@ export class StreamingService
     // recordings must have a streaming instance
     this.validateOrCreateOutputInstance(mode, display, 'streaming');
 
+    const signalHandler = async (signal: EOutputSignal) => {
+      console.log('recording signal', signal);
+      await this.handleSignal(signal, display);
+    };
+
     // handle unique properties (including audio)
     if (mode === 'Advanced') {
       const recording = AdvancedRecordingFactory.create() as IAdvancedRecording;
@@ -1379,10 +1402,7 @@ export class StreamingService
     this.contexts[display].recording.video = this.videoSettingsService.contexts[display];
 
     // set signal handler
-    this.contexts[display].recording.signalHandler = async (signal: EOutputSignal) => {
-      console.log('recording signal', signal);
-      await this.handleSignal(signal, display);
-    };
+    this.contexts[display].recording.signalHandler = signalHandler;
 
     // The replay buffer requires a recording instance. If the user is streaming but not recording,
     // a recording instance still needs to be created but does not need to be started.
@@ -1532,6 +1552,10 @@ export class StreamingService
       [EOutputSignalState.Wrote]: ERecordingState.Offline,
     } as Dictionary<ERecordingState>)[info.signal];
 
+    console.log(
+      'received recording signal. current status is ',
+      this.state.status[display].recording,
+    );
     // We received a signal we didn't recognize
     if (!nextState) return;
 
@@ -1571,15 +1595,21 @@ export class StreamingService
       await this.markersService.exportCsv(parsedName);
 
       // Finally, all actions are completed and the recording context can be destroyed
-      this.destroyOutputContextIfExists(display, 'recording');
+      await this.destroyOutputContextIfExists(display, 'recording');
 
-      // Also destroy the streaming instance if it was only created for recording
-      // Note: this is only the case when recording without streaming in advanced mode
-      this.destroyOutputContextIfExists(display, 'streaming', true);
+      // Also destroy the streaming instance if it is not live and not being used by the replay buffer
+      const offline =
+        this.state.status[display].replayBuffer === EReplayBufferState.Offline &&
+        this.state.status[display].streaming === EStreamingState.Offline;
+
+      if (offline) {
+        await this.destroyOutputContextIfExists(display, 'streaming');
+      }
 
       this.latestRecordingPath.next(fileName);
     }
 
+    console.log('recording status nextState', nextState);
     const time = new Date().toISOString();
     this.SET_RECORDING_STATUS(nextState, display, time);
     this.recordingStatusChange.next(nextState);
@@ -1639,14 +1669,15 @@ export class StreamingService
       // was created for the replay buffer. If the replay buffer is stopped, the recording
       // instance should be destroyed.
 
-      // if (offline) {
-      //   this.destroyOutputContextIfExists(display, 'streaming');
-      // }
-      // this.contexts[display].replayBuffer.recording = null;
-      // this.contexts[display].replayBuffer.streaming = null;
-      this.destroyOutputContextIfExists(display, 'replayBuffer');
-      this.destroyOutputContextIfExists(display, 'recording');
-      this.destroyOutputContextIfExists(display, 'streaming');
+      const offline =
+        this.state.status[display].recording === ERecordingState.Offline &&
+        this.state.status[display].streaming === EStreamingState.Offline;
+
+      if (offline) {
+        await this.destroyOutputContextIfExists(display, 'replayBuffer');
+        await this.destroyOutputContextIfExists(display, 'recording');
+        await this.destroyOutputContextIfExists(display, 'streaming');
+      }
     }
   }
 
@@ -1659,22 +1690,26 @@ export class StreamingService
     }
   }
 
-  startReplayBuffer(display: TDisplayType = 'horizontal') {
+  startReplayBuffer(display: TDisplayType = 'horizontal'): void {
     if (this.state.status[display].replayBuffer !== EReplayBufferState.Offline) return;
     // change the replay buffer status for the loading animation
     this.SET_REPLAY_BUFFER_STATUS(EReplayBufferState.Running, display);
 
-    this.createReplayBuffer(display);
+    Promise.resolve(this.createReplayBuffer(display));
   }
 
-  createReplayBuffer(display: TDisplayType = 'horizontal') {
-    // this.destroyOutputContextIfExists(display, 'replayBuffer');
-
+  /**
+   * Create Replay Buffer
+   * @remark Create a replay buffer instance for the given display using the Factory API.
+   * Currently there are no cases where a replay buffer is not started immediately after creation.
+   * @param display - The display to create the replay buffer for
+   */
+  private async createReplayBuffer(display: TDisplayType = 'horizontal') {
     const mode = this.outputSettingsService.getSettings().mode;
 
     // A replay buffer requires a recording instance and a streaming instance
-    this.validateOrCreateOutputInstance(mode, display, 'recording');
-    this.validateOrCreateOutputInstance(mode, display, 'streaming');
+    await this.validateOrCreateOutputInstance(mode, display, 'recording');
+    await this.validateOrCreateOutputInstance(mode, display, 'streaming');
 
     const settings = this.outputSettingsService.getReplayBufferSettings();
     const replayBuffer =
@@ -1688,39 +1723,39 @@ export class StreamingService
       (replayBuffer as any)[key] = (settings as any)[key];
     });
 
-    // replayBuffer.recording =
-    //   mode === 'Advanced'
-    //     ? (this.contexts[display].recording as IAdvancedRecording)
-    //     : (this.contexts[display].recording as ISimpleRecording);
-
-    // // replayBuffer.recording.signalHandler = async signal => {
-    // //   console.log('replay buffer recording signal', signal);
-    // //   await this.handleSignal(signal, display);
-    // // };
-
-    // replayBuffer.streaming =
-    //   mode === 'Advanced'
-    //     ? (this.contexts[display].streaming as IAdvancedStreaming)
-    //     : (this.contexts[display].streaming as ISimpleStreaming);
-
-    // // this.contexts[display].replayBuffer =
-    // //   mode === 'Advanced'
-    // //     ? (replayBuffer as IAdvancedReplayBuffer)
-    // //     : (replayBuffer as ISimpleReplayBuffer);
-
-    // if (this.isAdvancedReplayBuffer(replayBuffer)) {
-    //   (this.contexts[display]
-    //     .replayBuffer as IAdvancedReplayBuffer) = replayBuffer as IAdvancedReplayBuffer;
-    // } else {
-    //   (this.contexts[display]
-    //     .replayBuffer as ISimpleReplayBuffer) = replayBuffer as ISimpleReplayBuffer;
-    // }
-
-    // this.contexts[display].replayBuffer.video = this.videoSettingsService.contexts[display];
-    // this.contexts[display].replayBuffer.signalHandler = async signal => {
-    //   console.log('replay buffer signal', signal);
-    //   await this.handleSignal(signal, display);
-    // };
+    const signalHandler = async (signal: EOutputSignal) => {
+      console.log('replay buffer signal', signal);
+      await this.handleSignal(signal, display);
+    };
+
+    if (this.isAdvancedReplayBuffer(replayBuffer)) {
+      const recording = this.contexts[display].recording as IAdvancedRecording;
+      const streaming = this.contexts[display].streaming as IAdvancedStreaming;
+      recording.signalHandler = signalHandler;
+      streaming.signalHandler = signalHandler;
+      replayBuffer.recording = recording;
+      replayBuffer.streaming = streaming;
+
+      this.contexts[display].replayBuffer = replayBuffer as IAdvancedReplayBuffer;
+    } else if (this.isSimpleReplayBuffer(replayBuffer)) {
+      const recording = this.contexts[display].recording as ISimpleRecording;
+      const streaming = this.contexts[display].streaming as ISimpleStreaming;
+      recording.signalHandler = signalHandler;
+      streaming.signalHandler = signalHandler;
+      replayBuffer.recording = recording;
+      replayBuffer.streaming = streaming;
+
+      this.contexts[display].replayBuffer = replayBuffer as ISimpleReplayBuffer;
+    } else {
+      throwStreamError(
+        'UNKNOWN_STREAMING_ERROR_WITH_MESSAGE',
+        {},
+        'Unable to create replay buffer instance',
+      );
+    }
+
+    this.contexts[display].replayBuffer.video = this.videoSettingsService.contexts[display];
+    this.contexts[display].replayBuffer.signalHandler = signalHandler;
 
     this.contexts[display].replayBuffer.start();
     this.usageStatisticsService.recordFeatureUsage('ReplayBuffer');
@@ -1830,7 +1865,7 @@ export class StreamingService
         (mode === 'Simple' && isAdvancedOutputInstance) ||
         (mode === 'Advanced' && !isAdvancedOutputInstance)
       ) {
-        this.destroyOutputContextIfExists(display, type);
+        await this.destroyOutputContextIfExists(display, type);
       }
     }
 
@@ -2403,22 +2438,14 @@ export class StreamingService
    * @returns A promise that resolves to true if the context was destroyed, false
    * if the context did not exist
    */
-  private destroyOutputContextIfExists(
+  private async destroyOutputContextIfExists(
     display: TDisplayType | string,
     contextType: keyof IOutputContext,
-    confirmOffline: boolean = false,
   ) {
     this.logContexts(display as TDisplayType, 'destroyOutputContextIfExists');
     // if the context does not exist there is nothing to destroy
     if (!this.contexts[display] || !this.contexts[display][contextType]) return;
 
-    const offline =
-      this.state.status[display].streaming === EStreamingState.Offline &&
-      this.state.status[display].recording === ERecordingState.Offline &&
-      this.state.status[display].replayBuffer === EReplayBufferState.Offline;
-
-    if (confirmOffline && !offline) return;
-
     // prevent errors by stopping an active context before destroying it
     if (this.state.status[display][contextType].toString() !== 'offline') {
       this.contexts[display][contextType].stop();
@@ -2522,6 +2549,8 @@ export class StreamingService
       contextType,
       this.contexts[display][contextType],
     );
+
+    return Promise.resolve();
   }
 
   logContexts(display: TDisplayType, label?: string) {

From 5f26aac9970919307782fad0874625b29d462871 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 25 Mar 2025 17:28:22 -0400
Subject: [PATCH 19/25] Assign output references directly and remove logs.

---
 app/services/streaming/streaming.ts | 91 +++++------------------------
 1 file changed, 15 insertions(+), 76 deletions(-)

diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index f01ec087b2ed..c3efcbdd6fa4 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -679,7 +679,6 @@ export class StreamingService
           ? 'SETTINGS_UPDATE_FAILED'
           : e.type || 'UNKNOWN_ERROR';
       this.setError(e, platform);
-      console.log('handleSetupPlatformError e', e);
     } else {
       this.setError('SETTINGS_UPDATE_FAILED', platform);
     }
@@ -1268,7 +1267,7 @@ export class StreamingService
         this.recordingStopped.next();
       }
 
-      return;
+      return Promise.resolve();
     } else if (
       this.state.status.vertical.recording === ERecordingState.Recording &&
       this.contexts.vertical.recording !== null
@@ -1276,8 +1275,9 @@ export class StreamingService
       // stop recording vertical display
       // change the recording status for the loading animation
       this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', new Date().toISOString());
+
       this.contexts.vertical.recording.stop(true);
-      return;
+      return Promise.resolve();
     } else if (
       this.state.status.horizontal.recording === ERecordingState.Recording &&
       this.contexts.horizontal.recording !== null
@@ -1286,12 +1286,6 @@ export class StreamingService
       // change the recording status for the loading animation
       this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', new Date().toISOString());
 
-      console.log('stopping horizontal');
-
-      console.log('this.state.status.horizontal.recording', this.state.status.horizontal.recording);
-
-      this.logContexts('horizontal', '*** stopRecording');
-
       if (this.isAdvancedRecording(this.contexts.horizontal.recording)) {
         const recording = this.contexts.horizontal.recording as IAdvancedRecording;
         recording.stop(true);
@@ -1300,7 +1294,7 @@ export class StreamingService
         recording.stop(true);
       }
 
-      return;
+      return Promise.resolve();
     }
 
     // start recording
@@ -1323,9 +1317,6 @@ export class StreamingService
         await this.createRecording('horizontal', 1, true);
       }
     }
-    this.logContexts('horizontal', '*** toggleRecording');
-    this.logContexts('vertical', '+++ toggleRecording');
-    console.log('\n\n');
 
     Promise.resolve();
   }
@@ -1351,7 +1342,6 @@ export class StreamingService
     // handle unique properties (including audio)
     if (mode === 'Advanced') {
       const recording = AdvancedRecordingFactory.create() as IAdvancedRecording;
-      const stream = this.contexts[display].streaming as IAdvancedStreaming;
 
       Object.keys(settings).forEach(key => {
         if ((settings as any)[key] === undefined) return;
@@ -1359,7 +1349,7 @@ export class StreamingService
         // share the video encoder with the streaming instance if it exists
         if (key === 'videoEncoder') {
           recording.videoEncoder =
-            stream?.videoEncoder ??
+            (this.contexts[display].streaming as IAdvancedStreaming)?.videoEncoder ??
             VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder');
         } else {
           (recording as any)[key] = (settings as any)[key];
@@ -1374,26 +1364,24 @@ export class StreamingService
       // audio track
       this.createAudioTrack(index);
 
-      recording.streaming = stream;
+      recording.streaming = this.contexts[display].streaming as IAdvancedStreaming;
       this.contexts[display].recording = recording as IAdvancedRecording;
     } else {
       const recording = SimpleRecordingFactory.create() as ISimpleRecording;
-      const stream = this.contexts[display].streaming as ISimpleStreaming;
-
       Object.keys(settings).forEach(key => {
         if ((settings as any)[key] === undefined) return;
 
         // share the video encoder with the streaming instance if it exists
         if (key === 'videoEncoder') {
           recording.videoEncoder =
-            stream?.videoEncoder ??
+            (this.contexts[display].streaming as ISimpleStreaming)?.videoEncoder ??
             VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder');
         } else {
           (recording as any)[key] = (settings as any)[key];
         }
       });
 
-      recording.streaming = stream;
+      recording.streaming = this.contexts[display].streaming as ISimpleStreaming;
       recording.audioEncoder = AudioEncoderFactory.create();
       this.contexts[display].recording = recording as ISimpleRecording;
     }
@@ -1411,7 +1399,6 @@ export class StreamingService
       this.contexts[display].recording.start();
     }
 
-    this.logContexts(display, 'createRecording created ');
     return Promise.resolve(this.contexts[display].recording);
   }
 
@@ -1488,10 +1475,6 @@ export class StreamingService
       this.contexts[display].streaming.start();
     }
 
-    console.log(
-      'createdStreaming this.contexts[display].streaming',
-      this.contexts[display].streaming,
-    );
     return Promise.resolve(this.contexts[display].streaming);
   }
 
@@ -1525,7 +1508,6 @@ export class StreamingService
 
   private handleStreamingSignal(info: EOutputSignal, display: TDisplayType) {
     // map signals to status
-    console.log('streaming signal info', info);
 
     // const nextState: EStreamingState = ({
     //   [EOBSOutputSignal.Start]: EStreamingState.Starting,
@@ -1552,10 +1534,6 @@ export class StreamingService
       [EOutputSignalState.Wrote]: ERecordingState.Offline,
     } as Dictionary<ERecordingState>)[info.signal];
 
-    console.log(
-      'received recording signal. current status is ',
-      this.state.status[display].recording,
-    );
     // We received a signal we didn't recognize
     if (!nextState) return;
 
@@ -1594,22 +1572,20 @@ export class StreamingService
       }
       await this.markersService.exportCsv(parsedName);
 
-      // Finally, all actions are completed and the recording context can be destroyed
-      await this.destroyOutputContextIfExists(display, 'recording');
-
-      // Also destroy the streaming instance if it is not live and not being used by the replay buffer
+      // Only destroy instances if all outputs are offline
       const offline =
         this.state.status[display].replayBuffer === EReplayBufferState.Offline &&
         this.state.status[display].streaming === EStreamingState.Offline;
 
       if (offline) {
+        await this.destroyOutputContextIfExists(display, 'replayBuffer');
+        await this.destroyOutputContextIfExists(display, 'recording');
         await this.destroyOutputContextIfExists(display, 'streaming');
       }
 
       this.latestRecordingPath.next(fileName);
     }
 
-    console.log('recording status nextState', nextState);
     const time = new Date().toISOString();
     this.SET_RECORDING_STATUS(nextState, display, time);
     this.recordingStatusChange.next(nextState);
@@ -1650,25 +1626,6 @@ export class StreamingService
         code: info.code,
       });
 
-      // There are a few cases where a recording and streaming instance are created for the replay buffer.
-      // In these cases, the created recording and streaming instances should be destroyed
-      // when the replay buffer is stopped.
-      // 1. Simple Replay Buffer: When using the replay buffer without recording or streaming,
-      //    a simple recording instance is created for the replay buffer.
-      // 2. Simple Replay Buffer: When using the replay butter while streaming but not recording,
-      //    a simple recording instance is created for the replay buffer.
-      // 3. Advanced Replay Buffer: When using the replay buffer without recording or streaming,
-      //    an advanced recording instance is created for the replay buffer. This advanced recording
-      //    instance will create an advanced streaming instance if it does not exist.
-      // 4. Advanced Replay Buffer: When using the replay buffer while streaming but not recording,
-      //    a recording instance is created for the replay buffer. If the replay buffer is stopped,
-      //    the recording instance should be destroyed.
-
-      // THE BELOW WORKS
-      // In the case that the user is streaming but not recording, a recording instance
-      // was created for the replay buffer. If the replay buffer is stopped, the recording
-      // instance should be destroyed.
-
       const offline =
         this.state.status[display].recording === ERecordingState.Offline &&
         this.state.status[display].streaming === EStreamingState.Offline;
@@ -1729,21 +1686,13 @@ export class StreamingService
     };
 
     if (this.isAdvancedReplayBuffer(replayBuffer)) {
-      const recording = this.contexts[display].recording as IAdvancedRecording;
-      const streaming = this.contexts[display].streaming as IAdvancedStreaming;
-      recording.signalHandler = signalHandler;
-      streaming.signalHandler = signalHandler;
-      replayBuffer.recording = recording;
-      replayBuffer.streaming = streaming;
+      replayBuffer.recording = this.contexts[display].recording as IAdvancedRecording;
+      replayBuffer.streaming = this.contexts[display].streaming as IAdvancedStreaming;
 
       this.contexts[display].replayBuffer = replayBuffer as IAdvancedReplayBuffer;
     } else if (this.isSimpleReplayBuffer(replayBuffer)) {
-      const recording = this.contexts[display].recording as ISimpleRecording;
-      const streaming = this.contexts[display].streaming as ISimpleStreaming;
-      recording.signalHandler = signalHandler;
-      streaming.signalHandler = signalHandler;
-      replayBuffer.recording = recording;
-      replayBuffer.streaming = streaming;
+      replayBuffer.recording = this.contexts[display].recording as ISimpleRecording;
+      replayBuffer.streaming = this.contexts[display].streaming as ISimpleStreaming;
 
       this.contexts[display].replayBuffer = replayBuffer as ISimpleReplayBuffer;
     } else {
@@ -2442,7 +2391,6 @@ export class StreamingService
     display: TDisplayType | string,
     contextType: keyof IOutputContext,
   ) {
-    this.logContexts(display as TDisplayType, 'destroyOutputContextIfExists');
     // if the context does not exist there is nothing to destroy
     if (!this.contexts[display] || !this.contexts[display][contextType]) return;
 
@@ -2543,19 +2491,10 @@ export class StreamingService
 
     this.contexts[display][contextType] = null;
 
-    console.log(
-      'destroyed this.contexts[display][contextType]',
-      display,
-      contextType,
-      this.contexts[display][contextType],
-    );
-
     return Promise.resolve();
   }
 
   logContexts(display: TDisplayType, label?: string) {
-    const mode = this.outputSettingsService.getSettings().mode;
-
     console.log(
       display,
       [label, 'this.contexts[display].recording'].join(' '),

From 501b19c6e4261a3c059e881ed5acaf49067748c3 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 25 Mar 2025 18:08:20 -0400
Subject: [PATCH 20/25] Fix recording reference issue when switching between
 simple and advanced.

---
 .../api/external-api/streaming/streaming.ts   |  2 +-
 app/services/streaming/streaming.ts           | 58 +++++++++----------
 2 files changed, 27 insertions(+), 33 deletions(-)

diff --git a/app/services/api/external-api/streaming/streaming.ts b/app/services/api/external-api/streaming/streaming.ts
index 9982daf46bad..42d2c33ee163 100644
--- a/app/services/api/external-api/streaming/streaming.ts
+++ b/app/services/api/external-api/streaming/streaming.ts
@@ -121,7 +121,7 @@ export class StreamingService implements ISerializable {
   /**
    * Toggles recording.
    */
-  toggleRecording(): Promise<void> {
+  toggleRecording(): void {
     return this.streamingService.toggleRecording();
   }
 
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index c3efcbdd6fa4..2c1bc891862b 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1236,7 +1236,7 @@ export class StreamingService
     this.toggleRecording();
   }
 
-  async toggleRecording() {
+  toggleRecording() {
     // stop recording
     if (
       this.state.status.horizontal.recording === ERecordingState.Recording &&
@@ -1267,7 +1267,7 @@ export class StreamingService
         this.recordingStopped.next();
       }
 
-      return Promise.resolve();
+      return;
     } else if (
       this.state.status.vertical.recording === ERecordingState.Recording &&
       this.contexts.vertical.recording !== null
@@ -1277,7 +1277,7 @@ export class StreamingService
       this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', new Date().toISOString());
 
       this.contexts.vertical.recording.stop(true);
-      return Promise.resolve();
+      return;
     } else if (
       this.state.status.horizontal.recording === ERecordingState.Recording &&
       this.contexts.horizontal.recording !== null
@@ -1285,16 +1285,8 @@ export class StreamingService
       // stop recording horizontal display
       // change the recording status for the loading animation
       this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', new Date().toISOString());
-
-      if (this.isAdvancedRecording(this.contexts.horizontal.recording)) {
-        const recording = this.contexts.horizontal.recording as IAdvancedRecording;
-        recording.stop(true);
-      } else if (this.isSimpleRecording(this.contexts.horizontal.recording)) {
-        const recording = this.contexts.horizontal.recording as ISimpleRecording;
-        recording.stop(true);
-      }
-
-      return Promise.resolve();
+      this.contexts.horizontal.recording.stop(true);
+      return;
     }
 
     // start recording
@@ -1306,19 +1298,17 @@ export class StreamingService
         if (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.
-          await this.createRecording('vertical', 2, true);
+          this.createRecording('vertical', 2, true);
         } else {
           // Otherwise, record both displays in dual output mode
-          await this.createRecording('vertical', 2, true);
-          await this.createRecording('horizontal', 1, true);
+          this.createRecording('vertical', 2, true);
+          this.createRecording('horizontal', 1, true);
         }
       } else {
         // In single output mode, recording only the horizontal display
-        await this.createRecording('horizontal', 1, true);
+        this.createRecording('horizontal', 1, true);
       }
     }
-
-    Promise.resolve();
   }
 
   /**
@@ -1332,12 +1322,7 @@ export class StreamingService
     const settings = this.outputSettingsService.getRecordingSettings();
 
     // recordings must have a streaming instance
-    this.validateOrCreateOutputInstance(mode, display, 'streaming');
-
-    const signalHandler = async (signal: EOutputSignal) => {
-      console.log('recording signal', signal);
-      await this.handleSignal(signal, display);
-    };
+    await this.validateOrCreateOutputInstance(mode, display, 'streaming');
 
     // handle unique properties (including audio)
     if (mode === 'Advanced') {
@@ -1390,7 +1375,13 @@ export class StreamingService
     this.contexts[display].recording.video = this.videoSettingsService.contexts[display];
 
     // set signal handler
-    this.contexts[display].recording.signalHandler = signalHandler;
+    this.contexts[display].recording.signalHandler = async (signal: EOutputSignal) => {
+      console.log('recording signal', signal);
+      this.logContexts(display, 'recording signal');
+      console.log('\n\n');
+
+      await this.handleSignal(signal, display);
+    };
 
     // The replay buffer requires a recording instance. If the user is streaming but not recording,
     // a recording instance still needs to be created but does not need to be started.
@@ -1463,6 +1454,8 @@ export class StreamingService
     this.contexts[display].streaming.video = this.videoSettingsService.contexts[display];
     this.contexts[display].streaming.signalHandler = async signal => {
       console.log('streaming signal', signal);
+      this.logContexts(display, 'streaming signal');
+      console.log('\n\n');
       await this.handleSignal(signal, display);
     };
 
@@ -1680,11 +1673,6 @@ export class StreamingService
       (replayBuffer as any)[key] = (settings as any)[key];
     });
 
-    const signalHandler = async (signal: EOutputSignal) => {
-      console.log('replay buffer signal', signal);
-      await this.handleSignal(signal, display);
-    };
-
     if (this.isAdvancedReplayBuffer(replayBuffer)) {
       replayBuffer.recording = this.contexts[display].recording as IAdvancedRecording;
       replayBuffer.streaming = this.contexts[display].streaming as IAdvancedStreaming;
@@ -1704,7 +1692,12 @@ export class StreamingService
     }
 
     this.contexts[display].replayBuffer.video = this.videoSettingsService.contexts[display];
-    this.contexts[display].replayBuffer.signalHandler = signalHandler;
+    this.contexts[display].replayBuffer.signalHandler = async (signal: EOutputSignal) => {
+      console.log('replay buffer signal', signal);
+      this.logContexts(display, 'replay buffer signal');
+      console.log('\n\n');
+      await this.handleSignal(signal, display);
+    };
 
     this.contexts[display].replayBuffer.start();
     this.usageStatisticsService.recordFeatureUsage('ReplayBuffer');
@@ -1805,6 +1798,7 @@ export class StreamingService
     if (this.contexts[display][type]) {
       // Check for a property that only exists on the output type's advanced instance
       // Note: the properties below were chosen arbitrarily
+
       const isAdvancedOutputInstance =
         type === 'streaming'
           ? this.isAdvancedStreaming(this.contexts[display][type])

From 7c73b61d618a850829108ecd673671e465c9fe46 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 25 Mar 2025 18:40:49 -0400
Subject: [PATCH 21/25] Update replay buffer context duration.

---
 .../settings/output/output-settings.ts        |  2 +
 app/services/settings/settings.ts             |  4 +-
 app/services/streaming/streaming.ts           | 91 +++----------------
 3 files changed, 15 insertions(+), 82 deletions(-)

diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts
index 7002fd093b95..06b7d33606c6 100644
--- a/app/services/settings/output/output-settings.ts
+++ b/app/services/settings/output/output-settings.ts
@@ -479,11 +479,13 @@ export class OutputSettingsService extends Service {
       'Replay Buffer',
       'RecRBPrefix',
     );
+
     const suffix: string = this.settingsService.findSettingValue(
       output,
       'Replay Buffer',
       'RecRBSuffix',
     );
+
     const duration: number = this.settingsService.findSettingValue(output, 'Output', 'RecRBTime');
 
     const useStreamEncoders =
diff --git a/app/services/settings/settings.ts b/app/services/settings/settings.ts
index a0a1334bff33..aa5d471f0071 100644
--- a/app/services/settings/settings.ts
+++ b/app/services/settings/settings.ts
@@ -260,7 +260,7 @@ export class SettingsService extends StatefulService<ISettingsServiceState> {
   private videoEncodingOptimizationService: VideoEncodingOptimizationService;
 
   audioRefreshed = new Subject();
-  // settingsUpdated = new Subject<DeepPartial<ISettingsValues>>();
+  settingsUpdated = new Subject<DeepPartial<ISettingsValues>>();
 
   get views() {
     return new SettingsViews(this.state);
@@ -622,7 +622,7 @@ export class SettingsService extends StatefulService<ISettingsServiceState> {
       this.setSettings(categoryName, formSubCategories);
     });
 
-    // this.settingsUpdated.next(patch);
+    this.settingsUpdated.next(patch);
   }
 
   private setAudioSettings(settingsData: ISettingsSubCategory[]) {
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index 2c1bc891862b..b50db6e371bc 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -28,7 +28,7 @@ import {
 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 {
@@ -134,15 +134,6 @@ export interface IOBSOutputSignalInfo {
 type TOBSOutputType = 'streaming' | 'recording' | 'replayBuffer';
 
 interface IOutputContext {
-  // simpleStreaming: ISimpleStreaming;
-  // simpleReplayBuffer: ISimpleReplayBuffer;
-  // simpleRecording: ISimpleRecording;
-  // advancedStreaming: IAdvancedStreaming;
-  // advancedRecording: IAdvancedRecording;
-  // advancedReplayBuffer: IAdvancedReplayBuffer;
-  // streaming: ISimpleStreaming | IAdvancedStreaming;
-  // recording: ISimpleRecording | IAdvancedRecording;
-  // replayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer;
   streaming: ISimpleStreaming | IAdvancedStreaming;
   recording: ISimpleRecording | IAdvancedRecording;
   replayBuffer: ISimpleReplayBuffer | IAdvancedReplayBuffer;
@@ -166,6 +157,7 @@ export class StreamingService
   @Inject() private markersService: MarkersService;
   @Inject() private dualOutputService: DualOutputService;
   @Inject() private youtubeService: YoutubeService;
+  @Inject() private settingsService: SettingsService;
 
   streamingStatusChange = new Subject<EStreamingState>();
   recordingStatusChange = new Subject<ERecordingState>();
@@ -283,9 +275,15 @@ export class StreamingService
       },
     );
 
-    // this.settingsService.settingsUpdated.subscribe(() => {
-    //   this.updateOutputInstance();
-    // });
+    this.settingsService.settingsUpdated.subscribe(patch => {
+      // TODO: write a more versatile handler for settings updates
+      // For now, only handle updating the replay buffer duration
+      if (patch.Output?.RecRBTime && this.contexts.horizontal.replayBuffer) {
+        console.log('settingsUpdated patch', JSON.stringify(patch, null, 2));
+        this.contexts.horizontal.replayBuffer.duration = patch.Output.RecRBTime;
+        this.logContexts('horizontal', 'replayBuffer update');
+      }
+    });
   }
 
   get views() {
@@ -1725,71 +1723,6 @@ export class StreamingService
     this.contexts[display].replayBuffer.save();
   }
 
-  private updateOutputInstance() {
-    if (
-      this.contexts.horizontal.streaming &&
-      this.state.status.horizontal.streaming !== EStreamingState.Offline
-    ) {
-      const settings = this.outputSettingsService.getStreamingSettings() as Omit<
-        Partial<ISimpleStreaming | IAdvancedStreaming>,
-        'videoEncoder'
-      >;
-      const outputInstance: ISimpleStreaming | IAdvancedStreaming = this.contexts.horizontal
-        .streaming;
-
-      Object.keys(settings).forEach(
-        (key: keyof Omit<Partial<ISimpleStreaming | IAdvancedStreaming>, 'videoEncoder'>) => {
-          if ((settings as any)[key] === undefined) return;
-          if (outputInstance[key] !== settings[key]) {
-            (this.contexts.horizontal.streaming as any)[key] = (settings as any)[key];
-          }
-        },
-      );
-    }
-
-    if (
-      this.contexts.horizontal.recording &&
-      this.state.status.horizontal.recording !== ERecordingState.Offline
-    ) {
-      const settings = this.outputSettingsService.getRecordingSettings() as Omit<
-        Partial<ISimpleRecording | IAdvancedRecording>,
-        'videoEncoder'
-      >;
-
-      const outputInstance: ISimpleRecording | IAdvancedRecording = this.contexts.horizontal
-        .recording;
-      Object.keys(settings).forEach(
-        (key: keyof Omit<Partial<ISimpleRecording | IAdvancedRecording>, 'videoEncoder'>) => {
-          if ((settings as any)[key] === undefined) return;
-          if (outputInstance[key] !== settings[key]) {
-            (this.contexts.horizontal.recording as any)[key] = (settings as any)[key];
-          }
-        },
-      );
-    }
-
-    if (
-      this.contexts.horizontal.replayBuffer &&
-      this.state.status.horizontal.replayBuffer !== EReplayBufferState.Offline
-    ) {
-      const settings = this.outputSettingsService.getReplayBufferSettings() as Omit<
-        Partial<ISimpleReplayBuffer | IAdvancedReplayBuffer>,
-        'videoEncoder'
-      >;
-
-      const outputInstance: ISimpleReplayBuffer | IAdvancedReplayBuffer = this.contexts.horizontal
-        .replayBuffer;
-      Object.keys(settings).forEach(
-        (key: keyof Omit<Partial<ISimpleReplayBuffer | IAdvancedReplayBuffer>, 'videoEncoder'>) => {
-          if ((settings as any)[key] === undefined) return;
-          if (outputInstance[key] !== settings[key]) {
-            (this.contexts.horizontal.replayBuffer as any)[key] = (settings as any)[key];
-          }
-        },
-      );
-    }
-  }
-
   private async validateOrCreateOutputInstance(
     mode: 'Simple' | 'Advanced',
     display: TDisplayType,
@@ -1797,8 +1730,6 @@ export class StreamingService
   ) {
     if (this.contexts[display][type]) {
       // Check for a property that only exists on the output type's advanced instance
-      // Note: the properties below were chosen arbitrarily
-
       const isAdvancedOutputInstance =
         type === 'streaming'
           ? this.isAdvancedStreaming(this.contexts[display][type])

From 1498c0782cdc233bd4276c898574770c55671e1f Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Wed, 26 Mar 2025 16:24:58 -0400
Subject: [PATCH 22/25] Fix replay buffer properties.

---
 .../settings/output/output-settings.ts        | 37 ++++---------------
 app/services/streaming/streaming.ts           | 18 ++++-----
 2 files changed, 17 insertions(+), 38 deletions(-)

diff --git a/app/services/settings/output/output-settings.ts b/app/services/settings/output/output-settings.ts
index 06b7d33606c6..64df24096cd9 100644
--- a/app/services/settings/output/output-settings.ts
+++ b/app/services/settings/output/output-settings.ts
@@ -257,7 +257,7 @@ export class OutputSettingsService extends Service {
 
     const convertedEncoderName:
       | EObsSimpleEncoder.x264_lowcpu
-      | EObsAdvancedEncoder = this.convertEncoderToNewAPI(this.getSettings().recording.encoder);
+      | EObsAdvancedEncoder = this.convertEncoderToNewAPI(this.getSettings().streaming.encoder);
 
     const videoEncoder: EObsAdvancedEncoder =
       convertedEncoderName === EObsSimpleEncoder.x264_lowcpu
@@ -475,18 +475,22 @@ export class OutputSettingsService extends Service {
     );
 
     const prefix: string = this.settingsService.findSettingValue(
-      output,
+      advanced,
       'Replay Buffer',
       'RecRBPrefix',
     );
 
     const suffix: string = this.settingsService.findSettingValue(
-      output,
+      advanced,
       'Replay Buffer',
       'RecRBSuffix',
     );
 
-    const duration: number = this.settingsService.findSettingValue(output, 'Output', 'RecRBTime');
+    const duration: number = this.settingsService.findSettingValue(
+      output,
+      'Replay Buffer',
+      'RecRBTime',
+    );
 
     const useStreamEncoders =
       this.settingsService.findSettingValue(output, 'Recording', 'RecEncoder') === 'none';
@@ -602,31 +606,6 @@ export class OutputSettingsService extends Service {
       duration,
     };
   }
-  // simple
-  // recording.path = path.join(path.normalize(__dirname), '..', 'osnData');
-  // recording.format = ERecordingFormat.MOV;
-  // recording.quality = ERecordingQuality.HighQuality;
-  // recording.video = obs.defaultVideoContext;
-  // recording.videoEncoder =
-  //     osn.VideoEncoderFactory.create('obs_x264', 'video-encoder');
-  // recording.lowCPU = true;
-  // recording.audioEncoder = osn.AudioEncoderFactory.create();
-  // recording.overwrite = true;
-  // recording.noSpace = false;
-
-  // advanced
-  // recording.path = path.join(path.normalize(__dirname), '..', 'osnData');
-  // recording.format = ERecordingFormat.MOV;
-  // recording.videoEncoder =
-  //     osn.VideoEncoderFactory.create('obs_x264', 'video-encoder');
-  // recording.overwrite = true;
-  // recording.noSpace = false;
-  // recording.video = obs.defaultVideoContext;
-  // recording.mixer = 7;
-  // recording.rescaling = true;
-  // recording.outputWidth = 1920;
-  // recording.outputHeight = 1080;
-  // recording.useStreamEncoders = false;
 
   getAdvancedRecordingSettings() {
     const output = this.settingsService.state.Output.formData;
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index b50db6e371bc..9b11dd13cd6b 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1331,9 +1331,10 @@ export class StreamingService
 
         // share the video encoder with the streaming instance if it exists
         if (key === 'videoEncoder') {
-          recording.videoEncoder =
-            (this.contexts[display].streaming as IAdvancedStreaming)?.videoEncoder ??
-            VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder');
+          recording.videoEncoder = VideoEncoderFactory.create(
+            settings.videoEncoder,
+            'video-encoder',
+          );
         } else {
           (recording as any)[key] = (settings as any)[key];
         }
@@ -1356,9 +1357,10 @@ export class StreamingService
 
         // share the video encoder with the streaming instance if it exists
         if (key === 'videoEncoder') {
-          recording.videoEncoder =
-            (this.contexts[display].streaming as ISimpleStreaming)?.videoEncoder ??
-            VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder');
+          recording.videoEncoder = VideoEncoderFactory.create(
+            settings.videoEncoder,
+            'video-encoder',
+          );
         } else {
           (recording as any)[key] = (settings as any)[key];
         }
@@ -1413,9 +1415,7 @@ export class StreamingService
 
       // share the video encoder with the recording instance if it exists
       if (key === 'videoEncoder') {
-        stream.videoEncoder =
-          this.contexts[display].recording?.videoEncoder ??
-          VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder');
+        stream.videoEncoder = VideoEncoderFactory.create(settings.videoEncoder, 'video-encoder');
       } else {
         (stream as any)[key] = (settings as any)[key];
       }

From dc35413102220c7a3287e93cfb0932d8044221a0 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Thu, 27 Mar 2025 20:03:21 -0400
Subject: [PATCH 23/25] Fix extra instances on stream with replay buffer and
 recording.

---
 app/services/streaming/streaming.ts | 123 ++++++++++------------------
 1 file changed, 45 insertions(+), 78 deletions(-)

diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index 9b11dd13cd6b..c4cfe64bc7e3 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -279,9 +279,7 @@ export class StreamingService
       // TODO: write a more versatile handler for settings updates
       // For now, only handle updating the replay buffer duration
       if (patch.Output?.RecRBTime && this.contexts.horizontal.replayBuffer) {
-        console.log('settingsUpdated patch', JSON.stringify(patch, null, 2));
         this.contexts.horizontal.replayBuffer.duration = patch.Output.RecRBTime;
-        this.logContexts('horizontal', 'replayBuffer update');
       }
     });
   }
@@ -1296,15 +1294,19 @@ export class StreamingService
         if (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, true);
+          this.validateOrCreateOutputInstance('vertical', 'recording', 2);
+          this.contexts.vertical.recording.start();
         } else {
           // Otherwise, record both displays in dual output mode
-          this.createRecording('vertical', 2, true);
-          this.createRecording('horizontal', 1, true);
+          this.validateOrCreateOutputInstance('vertical', 'recording', 2);
+          this.validateOrCreateOutputInstance('horizontal', 'recording', 1);
+          this.contexts.vertical.recording.start();
+          this.contexts.horizontal.recording.start();
         }
       } else {
         // In single output mode, recording only the horizontal display
-        this.createRecording('horizontal', 1, true);
+        this.validateOrCreateOutputInstance('horizontal', 'recording', 1);
+        this.contexts.horizontal.recording.start();
       }
     }
   }
@@ -1313,14 +1315,14 @@ export class StreamingService
    * Create a recording instance for the given display
    * @param display - The display to create the recording for
    * @param index - The index of the audio track
-   * @param skipStart - Whether to skip starting the recording. This is used when creating a recording instance for the replay buffer
+   * @param start - Whether to skip starting the recording. This is used when creating a recording instance for the replay buffer
    */
-  private async createRecording(display: TDisplayType, index: number, start: boolean = false) {
+  private async createRecording(display: TDisplayType, index: number) {
     const mode = this.outputSettingsService.getSettings().mode;
     const settings = this.outputSettingsService.getRecordingSettings();
 
     // recordings must have a streaming instance
-    await this.validateOrCreateOutputInstance(mode, display, 'streaming');
+    await this.validateOrCreateOutputInstance(display, 'streaming', index);
 
     // handle unique properties (including audio)
     if (mode === 'Advanced') {
@@ -1376,20 +1378,9 @@ export class StreamingService
 
     // set signal handler
     this.contexts[display].recording.signalHandler = async (signal: EOutputSignal) => {
-      console.log('recording signal', signal);
-      this.logContexts(display, 'recording signal');
-      console.log('\n\n');
-
       await this.handleSignal(signal, display);
     };
 
-    // The replay buffer requires a recording instance. If the user is streaming but not recording,
-    // a recording instance still needs to be created but does not need to be started.
-    if (start) {
-      // start recording
-      this.contexts[display].recording.start();
-    }
-
     return Promise.resolve(this.contexts[display].recording);
   }
 
@@ -1397,9 +1388,11 @@ export class StreamingService
    * Create a streaming instance for the given display
    * @param display - The display to create the streaming for
    * @param index - The index of the audio track
-   * @param skipStart - Whether to skip starting the streaming. This is used when creating a streaming instance for advanced recording
+   * @param start - Whether to skip starting the streaming. This is used when creating a streaming instance for advanced recording
    */
   private async createStreaming(display: TDisplayType, index: number, start: boolean = false) {
+    console.log('========> CREATE STREAMING <========');
+
     const mode = this.outputSettingsService.getSettings().mode;
 
     const settings = this.outputSettingsService.getStreamingSettings();
@@ -1451,9 +1444,6 @@ export class StreamingService
 
     this.contexts[display].streaming.video = this.videoSettingsService.contexts[display];
     this.contexts[display].streaming.signalHandler = async signal => {
-      console.log('streaming signal', signal);
-      this.logContexts(display, 'streaming signal');
-      console.log('\n\n');
       await this.handleSignal(signal, display);
     };
 
@@ -1656,8 +1646,8 @@ export class StreamingService
     const mode = this.outputSettingsService.getSettings().mode;
 
     // A replay buffer requires a recording instance and a streaming instance
-    await this.validateOrCreateOutputInstance(mode, display, 'recording');
-    await this.validateOrCreateOutputInstance(mode, display, 'streaming');
+    await this.validateOrCreateOutputInstance(display, 'recording', 1);
+    await this.validateOrCreateOutputInstance(display, 'streaming', 1);
 
     const settings = this.outputSettingsService.getReplayBufferSettings();
     const replayBuffer =
@@ -1691,9 +1681,6 @@ export class StreamingService
 
     this.contexts[display].replayBuffer.video = this.videoSettingsService.contexts[display];
     this.contexts[display].replayBuffer.signalHandler = async (signal: EOutputSignal) => {
-      console.log('replay buffer signal', signal);
-      this.logContexts(display, 'replay buffer signal');
-      console.log('\n\n');
       await this.handleSignal(signal, display);
     };
 
@@ -1724,33 +1711,45 @@ export class StreamingService
   }
 
   private async validateOrCreateOutputInstance(
-    mode: 'Simple' | 'Advanced',
     display: TDisplayType,
     type: 'streaming' | 'recording',
+    index: number,
   ) {
-    if (this.contexts[display][type]) {
-      // Check for a property that only exists on the output type's advanced instance
-      const isAdvancedOutputInstance =
-        type === 'streaming'
-          ? this.isAdvancedStreaming(this.contexts[display][type])
-          : this.isAdvancedRecording(this.contexts[display][type]);
+    const mode = this.outputSettingsService.getSettings().mode;
+    const validOutput = this.validateOutputInstance(mode, display, type);
 
-      if (
-        (mode === 'Simple' && isAdvancedOutputInstance) ||
-        (mode === 'Advanced' && !isAdvancedOutputInstance)
-      ) {
-        await this.destroyOutputContextIfExists(display, type);
-      }
-    }
+    // If the instance matches the mode, return to validate it
+    if (validOutput) return;
+
+    await this.destroyOutputContextIfExists(display, type);
 
-    // Create new instance if it does not exist or was destroyed
     if (type === 'streaming') {
-      await this.createStreaming(display, 1);
+      await this.createStreaming(display, index);
     } else {
-      await this.createRecording(display, 1);
+      await this.createRecording(display, index);
     }
   }
 
+  private validateOutputInstance(
+    mode: 'Simple' | 'Advanced',
+    display: TDisplayType,
+    type: 'streaming' | 'recording',
+  ) {
+    if (!this.contexts[display][type]) return false;
+
+    const isAdvancedOutput =
+      type === 'streaming'
+        ? this.isAdvancedStreaming(this.contexts[display][type])
+        : this.isAdvancedRecording(this.contexts[display][type]);
+
+    const isSimpleOutput =
+      type === 'streaming'
+        ? this.isSimpleStreaming(this.contexts[display][type])
+        : this.isSimpleRecording(this.contexts[display][type]);
+
+    return (mode === 'Advanced' && isAdvancedOutput) || (mode === 'Simple' && isSimpleOutput);
+  }
+
   /**
    * Create an audio track
    * @param index - index of the audio track to create
@@ -2380,38 +2379,6 @@ export class StreamingService
               );
           break;
       }
-
-      // switch (contextType) {
-      //   case 'streaming':
-      //     AdvancedStreamingFactory.destroy(
-      //       this.contexts[display][contextType] as IAdvancedStreaming,
-      //     );
-      //     break;
-      //   case 'recording':
-      //     AdvancedRecordingFactory.destroy(
-      //       this.contexts[display][contextType] as IAdvancedRecording,
-      //     );
-      //     break;
-      //   case 'replayBuffer':
-      //     AdvancedReplayBufferFactory.destroy(
-      //       this.contexts[display][contextType] as IAdvancedReplayBuffer,
-      //     );
-      //     break;
-      // }
-      // } else {
-      //   switch (contextType) {
-      //     case 'streaming':
-      //       SimpleStreamingFactory.destroy(this.contexts[display][contextType] as ISimpleStreaming);
-      //       break;
-      //     case 'recording':
-      //       SimpleRecordingFactory.destroy(this.contexts[display][contextType] as ISimpleRecording);
-      //       break;
-      //     case 'replayBuffer':
-      //       SimpleReplayBufferFactory.destroy(
-      //         this.contexts[display][contextType] as ISimpleReplayBuffer,
-      //       );
-      //       break;
-      //   }
     }
 
     this.contexts[display][contextType] = null;

From ece5456188d0712566151b3b0ccc7563b9785d92 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Thu, 27 Mar 2025 20:45:58 -0400
Subject: [PATCH 24/25] Fix for replay buffer.

---
 app/services/streaming/streaming.ts | 41 +++++++++++++++--------------
 1 file changed, 21 insertions(+), 20 deletions(-)

diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index c4cfe64bc7e3..205172e8ddaa 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1294,19 +1294,15 @@ export class StreamingService
         if (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.validateOrCreateOutputInstance('vertical', 'recording', 2);
-          this.contexts.vertical.recording.start();
+          this.validateOrCreateOutputInstance('vertical', 'recording', 2, true);
         } else {
           // Otherwise, record both displays in dual output mode
-          this.validateOrCreateOutputInstance('vertical', 'recording', 2);
-          this.validateOrCreateOutputInstance('horizontal', 'recording', 1);
-          this.contexts.vertical.recording.start();
-          this.contexts.horizontal.recording.start();
+          this.validateOrCreateOutputInstance('vertical', 'recording', 2, true);
+          this.validateOrCreateOutputInstance('horizontal', 'recording', 1, true);
         }
       } else {
         // In single output mode, recording only the horizontal display
-        this.validateOrCreateOutputInstance('horizontal', 'recording', 1);
-        this.contexts.horizontal.recording.start();
+        this.validateOrCreateOutputInstance('horizontal', 'recording', 1, true);
       }
     }
   }
@@ -1315,9 +1311,8 @@ export class StreamingService
    * Create a recording instance for the given display
    * @param display - The display to create the recording for
    * @param index - The index of the audio track
-   * @param start - Whether to skip starting the recording. This is used when creating a recording instance for the replay buffer
    */
-  private async createRecording(display: TDisplayType, index: number) {
+  private async createRecording(display: TDisplayType, index: number, start?: boolean) {
     const mode = this.outputSettingsService.getSettings().mode;
     const settings = this.outputSettingsService.getRecordingSettings();
 
@@ -1381,6 +1376,10 @@ export class StreamingService
       await this.handleSignal(signal, display);
     };
 
+    if (start) {
+      this.contexts[display].recording.start();
+    }
+
     return Promise.resolve(this.contexts[display].recording);
   }
 
@@ -1388,11 +1387,8 @@ export class StreamingService
    * Create a streaming instance for the given display
    * @param display - The display to create the streaming for
    * @param index - The index of the audio track
-   * @param start - Whether to skip starting the streaming. This is used when creating a streaming instance for advanced recording
    */
-  private async createStreaming(display: TDisplayType, index: number, start: boolean = false) {
-    console.log('========> CREATE STREAMING <========');
-
+  private async createStreaming(display: TDisplayType, index: number, start?: boolean) {
     const mode = this.outputSettingsService.getSettings().mode;
 
     const settings = this.outputSettingsService.getStreamingSettings();
@@ -1588,10 +1584,6 @@ export class StreamingService
       return;
     }
 
-    const time = new Date().toISOString();
-    this.SET_REPLAY_BUFFER_STATUS(nextState, display, time);
-    this.replayBufferStatusChange.next(nextState);
-
     if (info.signal === EOBSOutputSignal.Wrote) {
       this.usageStatisticsService.recordAnalyticsEvent('ReplayBufferStatus', {
         status: 'wrote',
@@ -1617,6 +1609,10 @@ export class StreamingService
         await this.destroyOutputContextIfExists(display, 'streaming');
       }
     }
+
+    const time = new Date().toISOString();
+    this.SET_REPLAY_BUFFER_STATUS(nextState, display, time);
+    this.replayBufferStatusChange.next(nextState);
   }
 
   splitFile(display: TDisplayType = 'horizontal') {
@@ -1714,19 +1710,24 @@ export class StreamingService
     display: TDisplayType,
     type: 'streaming' | 'recording',
     index: number,
+    start?: boolean,
   ) {
     const mode = this.outputSettingsService.getSettings().mode;
     const validOutput = this.validateOutputInstance(mode, display, type);
 
     // If the instance matches the mode, return to validate it
+    if (validOutput && start) {
+      this.contexts[display][type]?.start();
+      return;
+    }
     if (validOutput) return;
 
     await this.destroyOutputContextIfExists(display, type);
 
     if (type === 'streaming') {
-      await this.createStreaming(display, index);
+      await this.createStreaming(display, index, start);
     } else {
-      await this.createRecording(display, index);
+      await this.createRecording(display, index, start);
     }
   }
 

From d6b2483d71f4610c377dc3e64c0d397c3301f442 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Fri, 28 Mar 2025 15:58:18 -0400
Subject: [PATCH 25/25] Comment out dual output recording.

---
 app/services/streaming/streaming.ts | 158 +++++++++++++++++-----------
 1 file changed, 97 insertions(+), 61 deletions(-)

diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index 205172e8ddaa..3ae1e1da5628 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1233,48 +1233,11 @@ export class StreamingService
   }
 
   toggleRecording() {
-    // stop recording
+    /**
+     * START SINGLE OUTPUT RECORDING
+     * Note: Comment out the below and comment in the dual output recording code block to enable dual output recording
+     */
     if (
-      this.state.status.horizontal.recording === ERecordingState.Recording &&
-      this.state.status.vertical.recording === ERecordingState.Recording
-    ) {
-      // stop recording both displays
-      let time = new Date().toISOString();
-
-      if (this.contexts.vertical.recording !== 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, 'horizontal', time);
-              if (this.contexts.horizontal.recording !== null) {
-                this.contexts.horizontal.recording.stop();
-              }
-            }, 2000),
-          );
-          recordingStopped.unsubscribe();
-        });
-
-        this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', time);
-        this.contexts.vertical.recording.stop();
-        this.recordingStopped.next();
-      }
-
-      return;
-    } else if (
-      this.state.status.vertical.recording === ERecordingState.Recording &&
-      this.contexts.vertical.recording !== null
-    ) {
-      // stop recording vertical display
-      // change the recording status for the loading animation
-      this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', new Date().toISOString());
-
-      this.contexts.vertical.recording.stop(true);
-      return;
-    } else if (
       this.state.status.horizontal.recording === ERecordingState.Recording &&
       this.contexts.horizontal.recording !== null
     ) {
@@ -1283,28 +1246,101 @@ export class StreamingService
       this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', new Date().toISOString());
       this.contexts.horizontal.recording.stop(true);
       return;
+    } else if (this.state.status.horizontal.recording === ERecordingState.Offline) {
+      this.validateOrCreateOutputInstance('horizontal', 'recording', 1, true);
+    } else {
+      throwStreamError(
+        'UNKNOWN_STREAMING_ERROR_WITH_MESSAGE',
+        {},
+        'Unable to create replay buffer instance',
+      );
     }
 
-    // start recording
-    if (
-      this.state.status.horizontal.recording === ERecordingState.Offline &&
-      this.state.status.vertical.recording === ERecordingState.Offline
-    ) {
-      if (this.views.isDualOutputMode) {
-        if (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.validateOrCreateOutputInstance('vertical', 'recording', 2, true);
-        } else {
-          // Otherwise, record both displays in dual output mode
-          this.validateOrCreateOutputInstance('vertical', 'recording', 2, true);
-          this.validateOrCreateOutputInstance('horizontal', 'recording', 1, true);
-        }
-      } else {
-        // In single output mode, recording only the horizontal display
-        this.validateOrCreateOutputInstance('horizontal', 'recording', 1, true);
-      }
-    }
+    /**
+     * END SINGLE OUTPUT RECORDING
+     */
+
+    /**
+     * START DUAL OUTPUT RECORDING BELOW
+     * Note: Comment in the below and comment out the single output recording code block to enable dual output recording
+     */
+
+    // stop recording
+    // if (
+    //   this.state.status.horizontal.recording === ERecordingState.Recording &&
+    //   this.state.status.vertical.recording === ERecordingState.Recording
+    // ) {
+    //   // stop recording both displays
+    //   let time = new Date().toISOString();
+
+    //   if (this.contexts.vertical.recording !== 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, 'horizontal', time);
+    //           if (this.contexts.horizontal.recording !== null) {
+    //             this.contexts.horizontal.recording.stop();
+    //           }
+    //         }, 2000),
+    //       );
+    //       recordingStopped.unsubscribe();
+    //     });
+
+    //     this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', time);
+    //     this.contexts.vertical.recording.stop();
+    //     this.recordingStopped.next();
+    //   }
+
+    //   return;
+    // } else if (
+    //   this.state.status.vertical.recording === ERecordingState.Recording &&
+    //   this.contexts.vertical.recording !== null
+    // ) {
+    //   // stop recording vertical display
+    //   // change the recording status for the loading animation
+    //   this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'vertical', new Date().toISOString());
+
+    //   this.contexts.vertical.recording.stop(true);
+    //   return;
+    // } else if (
+    //   this.state.status.horizontal.recording === ERecordingState.Recording &&
+    //   this.contexts.horizontal.recording !== null
+    // ) {
+    //   // stop recording horizontal display
+    //   // change the recording status for the loading animation
+    //   this.SET_RECORDING_STATUS(ERecordingState.Stopping, 'horizontal', new Date().toISOString());
+    //   this.contexts.horizontal.recording.stop(true);
+    //   return;
+    // }
+
+    // // start recording
+    // if (
+    //   this.state.status.horizontal.recording === ERecordingState.Offline &&
+    //   this.state.status.vertical.recording === ERecordingState.Offline
+    // ) {
+    //   if (this.views.isDualOutputMode) {
+    //     if (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.validateOrCreateOutputInstance('vertical', 'recording', 2, true);
+    //     } else {
+    //       // Otherwise, record both displays in dual output mode
+    //       this.validateOrCreateOutputInstance('vertical', 'recording', 2, true);
+    //       this.validateOrCreateOutputInstance('horizontal', 'recording', 1, true);
+    //     }
+    //   } else {
+    //     // In single output mode, recording only the horizontal display
+    //     this.validateOrCreateOutputInstance('horizontal', 'recording', 1, true);
+    //   }
+    // }
+
+    /**
+     * END DUAL OUTPUT RECORDING
+     */
   }
 
   /**