diff --git a/app/components-react/windows/go-live/useGoLiveSettings.ts b/app/components-react/windows/go-live/useGoLiveSettings.ts index 83d7604a92e9..cf10ab54d79f 100644 --- a/app/components-react/windows/go-live/useGoLiveSettings.ts +++ b/app/components-react/windows/go-live/useGoLiveSettings.ts @@ -336,10 +336,13 @@ export class GoLiveSettingsModule { /** * Fetch settings for each platform */ - async prepopulate() { + async prepopulate(options?: { preserveCommonFields?: boolean }) { const { StreamingService, RestreamService, DualOutputService } = Services; const { isMultiplatformMode } = StreamingService.views; + // Snapshot the common fields `updateSettings` below replaces every platform's settings + const editedCommonFields = options?.preserveCommonFields ? this.state.commonFields : undefined; + this.state.setNeedPrepopulate(true); await StreamingService.actions.return.prepopulateInfo(); // TODO investigate mutation order issue @@ -399,6 +402,15 @@ export class GoLiveSettingsModule { this.state.updateSettings(settings); + // Prepopulating rebuilds each platform's settings from the service, which drops a title or + // description the user has typed but not submitted. Put the typed values back. + if (editedCommonFields) { + this.state.updateCommonFields({ + title: editedCommonFields.title || this.state.commonFields.title, + description: editedCommonFields.description || this.state.commonFields.description, + }); + } + /* If the user was in dual output before but doesn't have restream * we should disable one of the platforms if they have two enabled */ @@ -500,7 +512,10 @@ export class GoLiveSettingsModule { } this.save(this.state.settings); - this.prepopulate(); + + // Keep whatever the user has typed into the shared title/description. Every other caller of + // `prepopulate` is a window opening, where the fetched values should win instead. + this.prepopulate({ preserveCommonFields: true }); } switchCustomDestination(destInd: number, enabled: boolean) { diff --git a/app/services/platforms/index.ts b/app/services/platforms/index.ts index 1e2e89e03633..071f35537e9a 100644 --- a/app/services/platforms/index.ts +++ b/app/services/platforms/index.ts @@ -215,6 +215,8 @@ export interface IPlatformService { setupStreamShiftStream?: (options: IGoLiveSettings) => Promise; + setupLiveOutputStream?: (options: IGoLiveSettings) => Promise; + postNotification?: (message: string) => void; formatError?: (e: any, platform: TPlatform, errorType?: TStreamErrorType) => never; diff --git a/app/services/platforms/twitch.ts b/app/services/platforms/twitch.ts index 81aa3b36dda0..f7808b2d0e50 100644 --- a/app/services/platforms/twitch.ts +++ b/app/services/platforms/twitch.ts @@ -239,6 +239,27 @@ export class TwitchService return; } + const channelInfo = goLiveSettings?.platforms.twitch; + + // Resolve enhanced broadcasting before the stream key is written below. The key is only sent + // to the display's OBS context when Twitch is not going out through restream, and that + // depends on whether this stream is an enhanced broadcast — deciding afterwards means the + // check answers for the previous stream instead of this one. + if (channelInfo) { + if (this.streamingService.views.isLiveOutputEditingEnabled) { + await this.setupLiveOutputStream(goLiveSettings); + } else if (channelInfo.display === 'both') { + await this.setupDualStream(goLiveSettings); + } else { + // Update enhanced broadcasting setting based on go live settings + this.settingsService.setEnhancedBroadcasting(channelInfo.isEnhancedBroadcasting); + } + } else if (this.streamingService.views.isTwitchDualStreamEnabled) { + // Failsafe to guarantee that enhanced broadcasting is enabled if dual streaming is active + + await this.setupDualStream(goLiveSettings); + } + if ( this.streamSettingsService.protectedModeEnabled && this.streamSettingsService.isSafeToModifyStreamKey() @@ -265,34 +286,8 @@ export class TwitchService } } - if (goLiveSettings) { - const channelInfo = goLiveSettings?.platforms.twitch; - - if (channelInfo) { - if (channelInfo?.display === 'both') { - try { - await this.setupDualStream(goLiveSettings); - } catch (e: unknown) { - console.error('Error setting up dual stream:', e); - } - } else if (this.streamingService.views.isLiveOutputEditingEnabled) { - // When live output editing is enabled enhanced broadcasting won't work because it - // uses restream, which is incompatible with enhanced broadcasting. - this.settingsService.setEnhancedBroadcasting(false); - } else { - // Update enhanced broadcasting setting based on go live settings - this.settingsService.setEnhancedBroadcasting(channelInfo.isEnhancedBroadcasting); - } - - await this.putChannelInfo(channelInfo); - } - } else if (this.streamingService.views.isTwitchDualStreamEnabled) { - // Failsafe to guarantee that enhanced broadcasting is enabled if dual streaming is active - try { - await this.setupDualStream(goLiveSettings); - } catch (e: unknown) { - console.error('Error setting up dual stream:', e); - } + if (channelInfo) { + await this.putChannelInfo(channelInfo); } this.setPlatformContext('twitch'); @@ -456,6 +451,9 @@ export class TwitchService return; } + // Stream shift not compatible with enhanced broadcasting + this.settingsService.setEnhancedBroadcasting(false); + const [channelInfo] = await Promise.all([ this.requestTwitch<{ data: { @@ -467,7 +465,7 @@ export class TwitchService }[]; }>(`${this.apiBase}/helix/channels?broadcaster_id=${this.twitchId}`).then(json => { return { - title: settings?.stream_title ?? json.data[0].title, + title: json.data[0].title, game: json.data[0].game_name, gameId: json.data[0].game_id, gameName: json.data[0].game_name, @@ -481,7 +479,22 @@ export class TwitchService ]); const title = settings?.stream_title ?? channelInfo.title; - const game = settings?.game_id ?? channelInfo.game; + + // Stream Shift reports the category as an id, but `game` and `gameName` hold the category + // *name* everywhere else in this service — the Go Live form renders `game` directly. Resolve + // the id to a name so a shifted stream doesn't show a bare number as its category. + let game = channelInfo.game; + let gameId = channelInfo.gameId; + + if (settings?.game_id) { + gameId = settings.game_id; + try { + game = (await this.fetchGame(settings.game_id)).name; + } catch (e: unknown) { + console.error('Stream Shift: could not resolve game name for id', settings.game_id, e); + game = channelInfo.game; + } + } const tags: string[] = this.twitchTagsService.views.hasTags ? this.twitchTagsService.views.tags @@ -491,16 +504,23 @@ export class TwitchService tags, title, game, - gameId: channelInfo.gameId, - gameName: channelInfo.gameName, + gameId, + gameName: game, isBrandedContent: channelInfo.is_branded_content, - isEnhancedBroadcasting: this.settingsService.isEnhancedBroadcasting(), + // The user's persisted preference, not the OBS runtime flag. Stream shift already forced the OBS flag off, + // so reading the OBS runtime flag here would overwrite the preference with `false` every time a stream is shifted. + isEnhancedBroadcasting: this.state.settings.isEnhancedBroadcasting, contentClassificationLabels: channelInfo.content_classification_labels, }); this.setPlatformContext('twitch'); } + async setupLiveOutputStream(options?: IGoLiveSettings): Promise { + // Live output editing not compatible with enhanced broadcasting, so disable it here + this.settingsService.setEnhancedBroadcasting(false); + } + fetchFollowers(): Promise { return this.requestTwitch<{ total: number }>({ url: `${this.apiBase}/helix/users/follows?to_id=${this.twitchId}`, diff --git a/app/services/streaming/streaming-view.ts b/app/services/streaming/streaming-view.ts index e448a1cf1287..db44915b379b 100644 --- a/app/services/streaming/streaming-view.ts +++ b/app/services/streaming/streaming-view.ts @@ -582,26 +582,6 @@ export class StreamInfoView extends ViewHandler { ); } - /** - * Validate the display when live output editing is enabled - * @remark Used to ensure a platform with the `both` display, used for dual streaming, uses the - * default display instead. Reads `savedLiveOutputEditing` instead of `isLiveOutputEditingEnabled` - * to avoid the circular dependency: settings → savedSettings → getSavedPlatformSettings → settings - * @param display - The display saved for the platform - * @remark Use the dual output mode service state to prevent circular references - * @warning The `get` prefix is required. This class is passed to `injectState` in - * `useGoLiveSettings`, and slap registers any method not named `get*`/`is*`/`should*` as a - * mutation. Calling a mutation from a getter dispatches it during the component snapshot, - * which re-enters `updateUI` and recurses until the stack overflows. - */ - private getValidatedDisplay(display?: TDisplayOutput): TDisplayType { - if (!display || display === 'both' || !this.dualOutputView.dualOutputMode) { - return 'horizontal'; - } - - return display as TDisplayType; - } - get shouldSetupDualOutput(): boolean { if (this.dualOutputView.dualOutputMode) return true; // Read from state to avoid circular dependency: @@ -618,10 +598,10 @@ export class StreamInfoView extends ViewHandler { const p = platforms[platform as TPlatform]; if (!p?.enabled || !this.isPlatformLinked(platform as TPlatform)) continue; - // Note: this is to prevent an error where the platform doesn't go live because the display is set to 'both' - // in dual output mode when live output editing is enabled. It should never happen but to prevent errors indexing - // `platformDisplays`, default a platform without a display to horizontal - const display = this.getValidatedDisplay(p.display); + const display = p.display ?? 'horizontal'; + + // Any enabled platform with 'both' display automatically enables dual output mode + if (display === 'both') return true; platformDisplays[display].push(platform as TPlatform); } @@ -657,6 +637,8 @@ export class StreamInfoView extends ViewHandler { ); } + // TODO: cleanup — dead code, no callers. Diagnostics uses the identically named + // `OutputSettingsService.getIsEnhancedBroadcasting`, not this one. Delete it. getIsEnhancedBroadcasting(): boolean { return Services.SettingsService.isEnhancedBroadcasting(); } @@ -664,6 +646,10 @@ export class StreamInfoView extends ViewHandler { /** * Check for multistreaming with Twitch enhanced broadcasting */ + // TODO: cleanup — this is a method rather than a getter, so it is unmemoized, and every call + // reaches native OBS through `SettingsService.isEnhancedBroadcasting()`. It runs on each go + // live from both `twitch.beforeGoLive` and `createEnhancedBroadcastDualOutput`. Convert to a + // getter, or read the per-stream `StreamingService.state.enhancedBroadcasting` decision. isEnhancedBroadcastingMultistream(): boolean { // Enhanced broadcasting is not available while live output editing is enabled because it uses // its own video context and stream, which cannot be edited mid-stream @@ -1021,12 +1007,17 @@ export class StreamInfoView extends ViewHandler { settings['liveVideoId'] = ''; } - // Make sure platforms assigned to the vertical display in dual output mode still go live in single output mode - // Note: This is a check to ensure that the display is valid when live output editing is enabled. If the display - // is set to 'both', it will be defaulted to 'horizontal' for single output mode. + // make sure platforms assigned to the vertical display in dual output mode still go live in + // single output mode + // Note: `both` is deliberately passed through. It must not be collapsed here, because this + // value seeds the Go Live window and is written straight back by `save()`, so coercing it + // would overwrite the user's saved dual stream choice. Live output editing's inability to + // dual stream is enforced where the display is used, not where it is stored. + // The `?? 'horizontal'` matters: without it a platform with no saved display yields + // `undefined` here, and callers that index by display rather than defaulting it break. const display = - this.isDualOutputMode && savedDestinations && savedDestinations[platform]?.display - ? this.getValidatedDisplay(savedDestinations[platform]?.display) + this.isDualOutputMode && savedDestinations + ? savedDestinations[platform]?.display ?? 'horizontal' : 'horizontal'; return { diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 95930eb3a026..3275e1b941ca 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -851,9 +851,12 @@ export class StreamingService // in osn is what actually determines if the stream will use enhanced broadcasting. if (platform === 'twitch') { // Enhanced broadcasting is unavailable while live output editing is enabled because it - // uses its own video context and stream, which cannot be edited mid-stream + // uses its own video context and stream, which cannot be edited mid-stream. + // It is also unavailable during a stream shift, which always goes out through the + // restream service. const isEnhancedBroadcasting = !this.views.isLiveOutputEditingEnabled && + !this.views.isStreamShiftMode && (this.views.isTwitchDualStreamEnabled || settings.platforms.twitch?.isEnhancedBroadcasting || false); @@ -2587,9 +2590,14 @@ export class StreamingService } private async createEnhancedBroadcastMultistream() { - const display = this.settingsService.views.values.Stream.server.includes('streamlabs') - ? 'horizontal' - : 'vertical'; + // The enhanced broadcasting instance carries Twitch, so it has to use the canvas Twitch is + // assigned to. Outside dual output mode there is only the horizontal canvas. + // Note: do not infer this from the horizontal display's ingest server. When both displays + // are being restreamed, the horizontal server is a Streamlabs ingest whether or not Twitch + // is on that display, so Twitch on the vertical display would be sent landscape. + const display = this.views.isDualOutputMode + ? this.views.getPlatformDisplayType('twitch') + : 'horizontal'; const outputSettings = display === 'horizontal'