diff --git a/app/services/streaming/output-context.ts b/app/services/streaming/output-context.ts new file mode 100644 index 000000000000..87c57e8fbc43 --- /dev/null +++ b/app/services/streaming/output-context.ts @@ -0,0 +1,89 @@ +import { ERecordingState, EReplayBufferState, EStreamingState } from './streaming-api'; + +export type TStreamingDisplay = 'horizontal' | 'vertical'; +export type TStreamingOutputContext = + | TStreamingDisplay + | 'enhancedBroadcasting' + | 'stream' + | 'streamSecond'; +export type TStreamingContextType = 'streaming' | 'recording' | 'replayBuffer'; + +export interface IDisplayOutputStatus { + streaming: EStreamingState; + recording: ERecordingState; + replayBuffer: EReplayBufferState; +} + +type TDisplayNeedsOwnStream = (display: TStreamingDisplay) => boolean; + +export function isDisplayOutputContext( + contextName: TStreamingOutputContext, +): contextName is TStreamingDisplay { + return contextName === 'horizontal' || contextName === 'vertical'; +} + +export function isDisplayStreamingCoveredByEnhancedBroadcasting( + contextName: TStreamingOutputContext, + hasEnhancedBroadcastingStream: boolean, + displayNeedsNonEnhancedBroadcastingInstance: TDisplayNeedsOwnStream, +): contextName is TStreamingDisplay { + return ( + isDisplayOutputContext(contextName) && + hasEnhancedBroadcastingStream && + !displayNeedsNonEnhancedBroadcastingInstance(contextName) + ); +} + +export function shouldStopStreamingContext( + contextName: TStreamingOutputContext, + hasEnhancedBroadcastingStream: boolean, + displayNeedsNonEnhancedBroadcastingInstance: TDisplayNeedsOwnStream, +): boolean { + return !isDisplayStreamingCoveredByEnhancedBroadcasting( + contextName, + hasEnhancedBroadcastingStream, + displayNeedsNonEnhancedBroadcastingInstance, + ); +} + +export function canDestroyDisplayOutputContext( + contextName: TStreamingDisplay, + status: IDisplayOutputStatus, + hasEnhancedBroadcastingStream: boolean, + displayNeedsNonEnhancedBroadcastingInstance: TDisplayNeedsOwnStream, +): boolean { + const streamingIsOfflineOrCovered = + status.streaming === EStreamingState.Offline || + isDisplayStreamingCoveredByEnhancedBroadcasting( + contextName, + hasEnhancedBroadcastingStream, + displayNeedsNonEnhancedBroadcastingInstance, + ); + + return ( + status.replayBuffer === EReplayBufferState.Offline && + status.recording === ERecordingState.Offline && + streamingIsOfflineOrCovered + ); +} + +export function shouldStopDisplayContextBeforeDestroy( + contextName: TStreamingDisplay, + contextType: TStreamingContextType, + status: IDisplayOutputStatus, + hasEnhancedBroadcastingStream: boolean, + displayNeedsNonEnhancedBroadcastingInstance: TDisplayNeedsOwnStream, +): boolean { + if ( + contextType === 'streaming' && + isDisplayStreamingCoveredByEnhancedBroadcasting( + contextName, + hasEnhancedBroadcastingStream, + displayNeedsNonEnhancedBroadcastingInstance, + ) + ) { + return false; + } + + return status[contextType].toString() !== 'offline'; +} diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 047b98073659..68d49bb181f9 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -95,6 +95,13 @@ import { EOBSOutputType, EOBSOutputSignal, IOBSOutputSignalInfo } from 'services import { SignalsService } from 'services/signals-manager'; import { TSocketEvent } from 'services/websocket'; import { HighlighterService } from 'services/highlighter'; +import { + canDestroyDisplayOutputContext, + isDisplayOutputContext, + shouldStopDisplayContextBeforeDestroy as shouldStopDisplayOutputContextBeforeDestroy, + shouldStopStreamingContext as shouldStopStreamingOutputContext, + TStreamingDisplay, +} from './output-context'; type TOBSOutputType = 'streaming' | 'recording' | 'replayBuffer'; type TOutputContext = TDisplayType | 'enhancedBroadcasting' | 'stream' | 'streamSecond'; @@ -1853,6 +1860,7 @@ export class StreamingService contextNames.forEach(contextName => { const streaming = this.contexts[contextName].streaming; if (!streaming) return; + if (!this.shouldStopStreamingOutputContext(contextName)) return; const forceStop = force || @@ -3256,7 +3264,41 @@ export class StreamingService } private isDisplayContext(context: TOutputContext): context is TDisplayType { - return context === 'horizontal' || context === 'vertical'; + return isDisplayOutputContext(context); + } + + private hasEnhancedBroadcastingStreamingInstance() { + return this.isEnhancedBroadcastingStreaming(this.contexts.enhancedBroadcasting.streaming); + } + + private shouldStopStreamingOutputContext(contextName: TOutputContext) { + return shouldStopStreamingOutputContext( + contextName, + this.hasEnhancedBroadcastingStreamingInstance(), + display => this.displayNeedsNonEnhancedBroadcastingInstance(display), + ); + } + + private canDestroyDisplayOutputContext(contextName: TDisplayType) { + return canDestroyDisplayOutputContext( + contextName as TStreamingDisplay, + this.state.status[contextName], + this.hasEnhancedBroadcastingStreamingInstance(), + display => this.displayNeedsNonEnhancedBroadcastingInstance(display), + ); + } + + private shouldStopDisplayOutputContextBeforeDestroy( + contextName: TDisplayType, + contextType: keyof IOutputContext, + ) { + return shouldStopDisplayOutputContextBeforeDestroy( + contextName as TStreamingDisplay, + contextType, + this.state.status[contextName], + this.hasEnhancedBroadcastingStreamingInstance(), + display => this.displayNeedsNonEnhancedBroadcastingInstance(display), + ); } private isEnhancedBroadcastingStreaming( @@ -3975,7 +4017,8 @@ export class StreamingService if ( (contextName === 'horizontal' && skipHorizontal) || this.contexts[contextName].streaming === undefined || - this.contexts[contextName].streaming === null + this.contexts[contextName].streaming === null || + !this.shouldStopStreamingOutputContext(contextName) ) { continue; } @@ -4015,10 +4058,7 @@ export class StreamingService } // For the horizontal and vertical contexts, only destroy instances if all outputs are offline - const offline = - this.state.status[context].replayBuffer === EReplayBufferState.Offline && - this.state.status[context].recording === ERecordingState.Offline && - this.state.status[context].streaming === EStreamingState.Offline; + const offline = this.canDestroyDisplayOutputContext(context); if (offline || force) { await this.destroyOutputContextIfExists(context, 'replayBuffer'); @@ -4055,8 +4095,7 @@ export class StreamingService // Prevent errors by stopping an active context before destroying it if ( this.isDisplayContext(contextName) && - this.state.status[contextName][contextType] && - this.state.status[contextName][contextType].toString() !== 'offline' + this.shouldStopDisplayOutputContextBeforeDestroy(contextName, contextType) ) { this.contexts[contextName][contextType].stop(true); diff --git a/test/regular/streaming/output-context.ts b/test/regular/streaming/output-context.ts new file mode 100644 index 000000000000..0843a862d529 --- /dev/null +++ b/test/regular/streaming/output-context.ts @@ -0,0 +1,48 @@ +import test from 'ava'; +import { + ERecordingState, + EReplayBufferState, + EStreamingState, +} from '../../../app/services/streaming/streaming-api'; +import { + canDestroyDisplayOutputContext, + shouldStopDisplayContextBeforeDestroy, + shouldStopStreamingContext, +} from '../../../app/services/streaming/output-context'; + +const displayDoesNotNeedOwnStream = () => false; +const displayNeedsOwnStream = () => true; + +test('Enhanced Broadcasting-covered display streaming dependencies are not stop targets', t => { + t.false(shouldStopStreamingContext('horizontal', true, displayDoesNotNeedOwnStream)); + t.true(shouldStopStreamingContext('horizontal', false, displayDoesNotNeedOwnStream)); + t.true(shouldStopStreamingContext('horizontal', true, displayNeedsOwnStream)); + t.true( + shouldStopStreamingContext( + 'enhancedBroadcasting', + true, + displayDoesNotNeedOwnStream, + ), + ); +}); + +test('Enhanced Broadcasting-covered display contexts can destroy recording-only stream wrappers', t => { + const status = { + streaming: EStreamingState.Live, + recording: ERecordingState.Offline, + replayBuffer: EReplayBufferState.Offline, + }; + + t.true( + canDestroyDisplayOutputContext('horizontal', status, true, displayDoesNotNeedOwnStream), + ); + t.false( + shouldStopDisplayContextBeforeDestroy( + 'horizontal', + 'streaming', + status, + true, + displayDoesNotNeedOwnStream, + ), + ); +});