Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions app/components-react/windows/go-live/useGoLiveSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions app/services/platforms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ export interface IPlatformService {

setupStreamShiftStream?: (options: IGoLiveSettings) => Promise<void>;

setupLiveOutputStream?: (options: IGoLiveSettings) => Promise<void>;

postNotification?: (message: string) => void;

formatError?: (e: any, platform: TPlatform, errorType?: TStreamErrorType) => never;
Expand Down
86 changes: 53 additions & 33 deletions app/services/platforms/twitch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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');
Expand Down Expand Up @@ -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: {
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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<void> {
// Live output editing not compatible with enhanced broadcasting, so disable it here
this.settingsService.setEnhancedBroadcasting(false);
}
Comment on lines +519 to +522

fetchFollowers(): Promise<number> {
return this.requestTwitch<{ total: number }>({
url: `${this.apiBase}/helix/users/follows?to_id=${this.twitchId}`,
Expand Down
49 changes: 20 additions & 29 deletions app/services/streaming/streaming-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,26 +582,6 @@ export class StreamInfoView<T extends Object> extends ViewHandler<T> {
);
}

/**
* 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:
Expand All @@ -618,10 +598,10 @@ export class StreamInfoView<T extends Object> extends ViewHandler<T> {
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);
}
Expand Down Expand Up @@ -657,13 +637,19 @@ export class StreamInfoView<T extends Object> extends ViewHandler<T> {
);
}

// TODO: cleanup — dead code, no callers. Diagnostics uses the identically named
// `OutputSettingsService.getIsEnhancedBroadcasting`, not this one. Delete it.
getIsEnhancedBroadcasting(): boolean {
return Services.SettingsService.isEnhancedBroadcasting();
}

/**
* 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
Expand Down Expand Up @@ -1021,12 +1007,17 @@ export class StreamInfoView<T extends Object> extends ViewHandler<T> {
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 {
Expand Down
16 changes: 12 additions & 4 deletions app/services/streaming/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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'
Expand Down
Loading