From f6172078f5c314e9cf3d67338a708bc215add4dd Mon Sep 17 00:00:00 2001 From: Alexander Nazarov Date: Wed, 25 Sep 2024 20:05:10 -0700 Subject: [PATCH 01/97] remove useModule from volmeters (#5139) --- .../editor/elements/mixer/GLVolmeters.tsx | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/app/components-react/editor/elements/mixer/GLVolmeters.tsx b/app/components-react/editor/elements/mixer/GLVolmeters.tsx index 8b1f2a056850..9d95c7ab7ff5 100644 --- a/app/components-react/editor/elements/mixer/GLVolmeters.tsx +++ b/app/components-react/editor/elements/mixer/GLVolmeters.tsx @@ -1,13 +1,11 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { IVolmeter } from 'services/audio'; -import { Subscription } from 'rxjs'; import electron, { ipcRenderer } from 'electron'; import difference from 'lodash/difference'; import { compileShader, createProgram } from 'util/webgl/utils'; import vShaderSrc from 'util/webgl/shaders/volmeter.vert'; import fShaderSrc from 'util/webgl/shaders/volmeter.frag'; import { Services } from 'components-react/service-provider'; -import { injectWatch, useModule } from 'slap'; import { assertIsDefined, getDefined } from 'util/properties-type-guards'; // Configuration @@ -47,13 +45,20 @@ interface IVolmeterSubscription { * Component that renders the volume for audio sources via WebGL */ export default function GLVolmeters() { - const { setupNewCanvas } = useModule(GLVolmetersModule); const canvasRef = useRef(null); + // init controller on mount + const controller = useMemo(() => { + const controller = new GLVolmetersController(); + controller.init(); + return controller; + }, []); + // start rendering volmeters when the canvas is ready useEffect(() => { assertIsDefined(canvasRef.current); - setupNewCanvas(canvasRef.current); + controller.setupNewCanvas(canvasRef.current); + return () => controller.beforeDestroy(); // cleanup on unmount }, []); return ( @@ -75,9 +80,10 @@ export default function GLVolmeters() { ); } -class GLVolmetersModule { +class GLVolmetersController { private customizationService = Services.CustomizationService; private audioService = Services.AudioService; + private sourcesService = Services.SourcesService; subscriptions: Dictionary = {}; @@ -113,7 +119,6 @@ class GLVolmetersModule { private workerId: number; private requestedFrameId: number; private bgMultiplier = this.customizationService.isDarkTheme ? 0.2 : 0.5; - private customizationServiceSubscription: Subscription = null!; init() { this.workerId = electron.ipcRenderer.sendSync('getWorkerWindowId'); @@ -133,12 +138,6 @@ class GLVolmetersModule { }); } - // update volmeters subscriptions when audio sources change - watchAudioSources = injectWatch( - () => this.audioSources, - () => this.subscribeVolmeters(), - ); - /** * add or remove subscription for volmeters depending on current scene */ @@ -217,7 +216,6 @@ class GLVolmetersModule { // cancel next frame rendering cancelAnimationFrame(this.requestedFrameId); - this.customizationServiceSubscription.unsubscribe(); } setupNewCanvas($canvasEl: HTMLCanvasElement) { From 6334fb30a554f7742f2f296be22be12b36d9c92b Mon Sep 17 00:00:00 2001 From: Timothy Lau Date: Fri, 27 Sep 2024 03:32:33 +0800 Subject: [PATCH 02/97] Fix typo in variable names (#5012) --- app/components-react/windows/source-showcase/SourceGrid.tsx | 2 +- app/services/guest-cam/index.ts | 2 +- app/services/incremental-rollout.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components-react/windows/source-showcase/SourceGrid.tsx b/app/components-react/windows/source-showcase/SourceGrid.tsx index ba5492412b2a..4bd387d7479e 100644 --- a/app/components-react/windows/source-showcase/SourceGrid.tsx +++ b/app/components-react/windows/source-showcase/SourceGrid.tsx @@ -74,7 +74,7 @@ export default function SourceGrid(p: { activeTab: string }) { const availableSources = useMemo(() => { const guestCamAvailable = (IncrementalRolloutService.views.featureIsEnabled(EAvailableFeatures.guestCamBeta) || - IncrementalRolloutService.views.featureIsEnabled(EAvailableFeatures.guestCaProduction)) && + IncrementalRolloutService.views.featureIsEnabled(EAvailableFeatures.guestCamProduction)) && UserService.views.isLoggedIn; return SourcesService.getAvailableSourcesTypesList().filter(type => { diff --git a/app/services/guest-cam/index.ts b/app/services/guest-cam/index.ts index adb9b832d73b..951371f747b3 100644 --- a/app/services/guest-cam/index.ts +++ b/app/services/guest-cam/index.ts @@ -392,7 +392,7 @@ export class GuestCamService extends StatefulService { this.dismissablesService.dismiss(EDismissable.CollabCamRollout); } else if ( this.incrementalRolloutService.views.featureIsEnabled( - EAvailableFeatures.guestCaProduction, + EAvailableFeatures.guestCamProduction, ) && this.dismissablesService.views.shouldShow(EDismissable.CollabCamRollout) ) { diff --git a/app/services/incremental-rollout.ts b/app/services/incremental-rollout.ts index 8a52d76f7b89..3371aac17b36 100644 --- a/app/services/incremental-rollout.ts +++ b/app/services/incremental-rollout.ts @@ -26,7 +26,7 @@ export enum EAvailableFeatures { * availability at launch. */ guestCamBeta = 'slobs--guest-join', - guestCaProduction = 'slobs--guest-join-prod', + guestCamProduction = 'slobs--guest-join-prod', } interface IIncrementalRolloutServiceState { From ffeef0f7c2590d6f9a010c6a176f4d6c32a4b348 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Fri, 27 Sep 2024 13:50:07 -0400 Subject: [PATCH 03/97] Add dual output to stream data. (#5140) --- app/services/streaming/streaming.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 5fe691d95efb..5ad0d5779fd3 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -1314,6 +1314,7 @@ export class StreamingService 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, @@ -1570,6 +1571,7 @@ export class StreamingService data.viewerCounts = {}; data.duration = Math.round(moment().diff(moment(this.state.streamingStatusTime)) / 1000); data.game = this.views.game; + data.outputMode = this.views.isDualOutputMode ? 'dual' : 'single'; if (this.views.protectedModeEnabled) { data.platforms = this.views.enabledPlatforms; From f83d12475bc8269f1814abb0918d3527797ab2e4 Mon Sep 17 00:00:00 2001 From: Alexander Nazarov Date: Fri, 27 Sep 2024 14:23:39 -0700 Subject: [PATCH 04/97] Get rid of useModule in Appearence settings (#5143) * Get rid of use modules * fix eslint --- app/components-react/shared/inputs/inputs.ts | 32 +++++++++++++++++++ .../windows/settings/Appearance.tsx | 18 +++-------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/app/components-react/shared/inputs/inputs.ts b/app/components-react/shared/inputs/inputs.ts index c45055ba2011..075c6bab471e 100644 --- a/app/components-react/shared/inputs/inputs.ts +++ b/app/components-react/shared/inputs/inputs.ts @@ -329,6 +329,7 @@ export function useTextInput< } /** + * @deprecated use bindFormState instead * 2-way binding util for inputs * * @example @@ -355,6 +356,37 @@ export function createBinding = { + [K in keyof TState]: { + name: K; + value: TState[K]; + onChange: (newVal: TState[K]) => unknown; + }; +} & + TExtraProps; + +export function bindFormState( + getFormState: () => TFormState, + updateFormState: (statePatch: Partial) => unknown, + extraProps?: TExtraProps, +) { + const formState = getFormState(); + const result = {} as any; + for (const fieldName in formState) { + result[fieldName] = { + name: fieldName, + value: formState[fieldName], + onChange: (value: any) => { + updateFormState({ [fieldName]: value } as Partial); + }, + }; + } + + extraProps ?? Object.assign(result, extraProps); + + return result as TFormBindings; +} + function createValidationRules(type: TInputType, inputProps: IInputCommonProps) { const rules = inputProps.rules ? [...inputProps.rules] : []; if (inputProps.required) { diff --git a/app/components-react/windows/settings/Appearance.tsx b/app/components-react/windows/settings/Appearance.tsx index 1613fe4de07b..9e982157e800 100644 --- a/app/components-react/windows/settings/Appearance.tsx +++ b/app/components-react/windows/settings/Appearance.tsx @@ -5,8 +5,6 @@ import { Row, Col, Select } from 'antd'; import { CheckboxInput, ListInput, SliderInput, SwitchInput } from '../../shared/inputs'; import { getDefined } from '../../../util/properties-type-guards'; import { ObsSettingsSection } from './ObsSettings'; -import * as remote from '@electron/remote'; -import { injectFormBinding, useModule } from 'slap'; import { ENavName, EMenuItemKey, IAppMenuItem, menuTitles } from 'services/side-nav'; import { useVuex } from 'components-react/hooks'; import styles from './Appearance.m.less'; @@ -16,6 +14,7 @@ import Scrollable from 'components-react/shared/Scrollable'; import UltraIcon from 'components-react/shared/UltraIcon'; import { CustomizationState } from 'services/customization'; import { useRealmObject } from 'components-react/hooks/realm'; +import { bindFormState } from 'components-react/shared/inputs'; const { Option } = Select; @@ -33,17 +32,10 @@ export function AppearanceSettings() { // Hooks up reactivity for Customization state useRealmObject(CustomizationService.state); - const { bind } = useModule(() => { - function getSettings() { - return CustomizationService.state.toObject() as CustomizationState; - } - - function setSettings(newSettings: CustomizationState) { - CustomizationService.actions.setSettings(newSettings as any); - } - - return { bind: injectFormBinding(getSettings, setSettings) }; - }); + const bind = bindFormState( + () => CustomizationService.state.toObject() as CustomizationState, + (newSettings: CustomizationState) => CustomizationService.setSettings(newSettings as any), + ); const { compactView, From f33a1b83160f8f44a056bae52b32e7500a53bf8f Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Mon, 30 Sep 2024 12:28:18 -0400 Subject: [PATCH 05/97] Add audience controls. (#5124) * Add audience controls. * Remove dummy data. Bug fix for vertically assigned platforms to go live with horizontal in single output mode. * Fix audience control conditional. --- .../shared/inputs/RadioInput.tsx | 7 ++- .../platforms/TiktokEditStreamInfo.tsx | 15 +++++- app/i18n/en-US/streaming.json | 3 +- app/services/platforms/tiktok.ts | 47 +++++++++++++++++++ app/services/platforms/tiktok/api.ts | 17 +++++++ app/services/streaming/streaming-view.ts | 6 +++ 6 files changed, 92 insertions(+), 3 deletions(-) diff --git a/app/components-react/shared/inputs/RadioInput.tsx b/app/components-react/shared/inputs/RadioInput.tsx index 77dfadbfdca3..ed9fb49b3c8e 100644 --- a/app/components-react/shared/inputs/RadioInput.tsx +++ b/app/components-react/shared/inputs/RadioInput.tsx @@ -8,7 +8,12 @@ type TRadioInputProps = TSlobsInputProps< label?: string; nolabel?: boolean; nomargin?: boolean; - options: { value: string; label: string; description?: string; defaultValue?: string }[]; + options: { + value: string | number; + label: string; + description?: string; + defaultValue?: string; + }[]; buttons?: boolean; direction?: 'vertical' | 'horizontal'; disabled?: boolean; diff --git a/app/components-react/windows/go-live/platforms/TiktokEditStreamInfo.tsx b/app/components-react/windows/go-live/platforms/TiktokEditStreamInfo.tsx index ce1b40d315d5..cca2a4e24cfc 100644 --- a/app/components-react/windows/go-live/platforms/TiktokEditStreamInfo.tsx +++ b/app/components-react/windows/go-live/platforms/TiktokEditStreamInfo.tsx @@ -8,7 +8,7 @@ import PlatformSettingsLayout, { IPlatformComponentParams } from './PlatformSett import * as remote from '@electron/remote'; import { CommonPlatformFields } from '../CommonPlatformFields'; import { ITikTokStartStreamOptions } from 'services/platforms/tiktok'; -import { TextInput, createBinding } from 'components-react/shared/inputs'; +import { RadioInput, TextInput, createBinding } from 'components-react/shared/inputs'; import InfoBanner from 'components-react/shared/InfoBanner'; import GameSelector from '../GameSelector'; import { EDismissable } from 'services/dismissables'; @@ -21,6 +21,7 @@ export function TikTokEditStreamInfo(p: IPlatformComponentParams<'tiktok'>) { const ttSettings = p.value; const approved = TikTokService.scope === 'approved'; const denied = TikTokService.scope === 'denied' && !TikTokService.promptReapply; + const controls = TikTokService.audienceControls; function updateSettings(patch: Partial) { p.onChange({ ...ttSettings, ...patch }); @@ -44,6 +45,18 @@ export function TikTokEditStreamInfo(p: IPlatformComponentParams<'tiktok'>) { requiredFields={
} /> {approved && } + {!controls.disable && ( + + )} {!approved && } ); diff --git a/app/i18n/en-US/streaming.json b/app/i18n/en-US/streaming.json index e43aabfed138..7d4a3397a6a9 100644 --- a/app/i18n/en-US/streaming.json +++ b/app/i18n/en-US/streaming.json @@ -254,5 +254,6 @@ "Disable Dual Output": "Disable Dual Output", "You can now reply to Twitch, YouTube and Facebook messages in Multistream chat. Click to learn more.": "You can now reply to Twitch, YouTube and Facebook messages in Multistream chat. Click to learn more.", "Multistream Chat Platform Support": "Multistream Chat Platform Support", - "You have selected %{colorFormat} as Color Format. Formats other than NV12 and P010 are commonly used for recording, and might incur high CPU usage or the streaming platform might not support it. Go to Settings -> Advanced -> Video to review.": "You have selected %{colorFormat} as Color Format. Formats other than NV12 and P010 are commonly used for recording, and might incur high CPU usage or the streaming platform might not support it. Go to Settings -> Advanced -> Video to review." + "You have selected %{colorFormat} as Color Format. Formats other than NV12 and P010 are commonly used for recording, and might incur high CPU usage or the streaming platform might not support it. Go to Settings -> Advanced -> Video to review.": "You have selected %{colorFormat} as Color Format. Formats other than NV12 and P010 are commonly used for recording, and might incur high CPU usage or the streaming platform might not support it. Go to Settings -> Advanced -> Video to review.", + "TikTok Audience": "TikTok Audience" } diff --git a/app/services/platforms/tiktok.ts b/app/services/platforms/tiktok.ts index 686af68b8e9f..068ef9689edb 100644 --- a/app/services/platforms/tiktok.ts +++ b/app/services/platforms/tiktok.ts @@ -25,6 +25,7 @@ import { ITikTokStartStreamResponse, TTikTokLiveScopeTypes, ITikTokGamesData, + ITikTokAudienceControlsInfo, } from './tiktok/api'; import { $t, I18nService } from 'services/i18n'; import { getDefined } from 'util/properties-type-guards'; @@ -35,6 +36,7 @@ import { UsageStatisticsService } from 'services/usage-statistics'; import { DiagnosticsService } from 'services/diagnostics'; import { ENotificationType, NotificationsService } from 'services/notifications'; import { JsonrpcService } from '../api/jsonrpc'; + interface ITikTokServiceState extends IPlatformState { settings: ITikTokStartStreamSettings; broadcastId: string; @@ -42,7 +44,9 @@ interface ITikTokServiceState extends IPlatformState { error?: string | null; gameName: string; dateDenied?: string | null; + audienceControlsInfo: ITikTokAudienceControls; } + interface ITikTokStartStreamSettings { serverUrl: string; streamKey: string; @@ -50,17 +54,26 @@ interface ITikTokStartStreamSettings { liveScope: TTikTokLiveScopeTypes; game: string; display: TDisplayType; + audienceType?: string; video?: IVideo; mode?: TOutputOrientation; } +interface ITikTokAudienceControls { + disable: boolean; + audienceType: string; + types: { value: string; label: string }[]; +} + export interface ITikTokStartStreamOptions { title: string; serverUrl: string; streamKey: string; display: TDisplayType; game: string; + audienceType?: string; } + interface ITikTokRequestHeaders extends Dictionary { Accept: string; 'Content-Type': string; @@ -85,6 +98,7 @@ export class TikTokService broadcastId: '', username: '', gameName: '', + audienceControlsInfo: { disable: true, audienceType: '0', types: [] }, }; @Inject() windowsService: WindowsService; @@ -155,6 +169,10 @@ export class TikTokService return 0; } + get audienceControls() { + return this.state.audienceControlsInfo; + } + async beforeGoLive(goLiveSettings: IGoLiveSettings, display?: TDisplayType) { // return an approved dummy account when testing if (Utils.isTestMode() && this.getHasScope('approved')) { @@ -326,6 +344,7 @@ export class TikTokService const host = this.hostsService.streamlabs; const url = `https://${host}/api/v5/slobs/tiktok/stream/start`; const headers = authorizedHeaders(this.userService.apiToken!); + const body = new FormData(); body.append('title', opts.title); body.append('device_platform', getOS()); @@ -334,6 +353,10 @@ export class TikTokService const game = opts.game === this.defaultGame.id ? '' : opts.game; body.append('category', game); + if (opts?.audienceType) { + body.append('audience_type', opts.audienceType); + } + const request = new Request(url, { headers, method: 'POST', body }); return jfetch(request); @@ -387,6 +410,10 @@ export class TikTokService const status = response as ITikTokLiveScopeResponse; + if (status?.audience_controls_info) { + this.setAudienceControls(status.audience_controls_info); + } + if (status?.user) { const scope = this.convertScope(status.reason); this.SET_USERNAME(status.user.username); @@ -718,6 +745,21 @@ export class TikTokService this.SET_GAME_NAME(gameName); } + setAudienceControls(audienceControlsInfo: ITikTokAudienceControlsInfo) { + // convert audience types to match the ListInput component options + const types = audienceControlsInfo.types.map(type => ({ + value: type.key.toString(), + label: type.label, + })); + const audienceType = audienceControlsInfo.info_type.toString(); + + this.SET_AUDIENCE_CONTROLS({ + ...audienceControlsInfo, + audienceType, + types, + }); + } + @mutation() SET_LIVE_SCOPE(scope: TTikTokLiveScopeTypes) { this.state.settings.liveScope = scope; @@ -742,4 +784,9 @@ export class TikTokService protected SET_DENIED_DATE(date?: string) { this.state.dateDenied = date ?? null; } + + @mutation() + protected SET_AUDIENCE_CONTROLS(audienceControlsInfo: ITikTokAudienceControls) { + this.state.audienceControlsInfo = audienceControlsInfo; + } } diff --git a/app/services/platforms/tiktok/api.ts b/app/services/platforms/tiktok/api.ts index a8fc3bb06fe4..f3b5e0afac74 100644 --- a/app/services/platforms/tiktok/api.ts +++ b/app/services/platforms/tiktok/api.ts @@ -25,6 +25,11 @@ export enum ETikTokLiveScopeReason { APPROVED_OBS = 2, } +export enum ETikTokAudienceType { + ALL = 0, + MATURE = 1, +} + export type TTikTokLiveScopeTypes = 'approved' | 'denied' | 'legacy' | 'relog'; export interface ITikTokLiveScopeResponse { @@ -33,6 +38,7 @@ export interface ITikTokLiveScopeResponse { can_be_live?: boolean; user?: ITikTokUserData; info?: any[] | null[] | undefined[] | ITikTokGame[] | ITikTokGamesData | any; + audience_controls_info: ITikTokAudienceControlsInfo; } export interface ITikTokGamesData extends ITikTokLiveScopeResponse { @@ -49,6 +55,17 @@ interface ITikTokGame { game_mask_id: string; } +export interface ITikTokAudienceControlsInfo { + disable: boolean; + info_type: ETikTokAudienceType; + types: ITikTokAudienceControlType[]; +} + +export interface ITikTokAudienceControlType { + key: ETikTokAudienceType; + label: string; +} + export interface ITikTokUserData { open_id?: string; union_id?: string; diff --git a/app/services/streaming/streaming-view.ts b/app/services/streaming/streaming-view.ts index da5c50907db9..ad8743d34e41 100644 --- a/app/services/streaming/streaming-view.ts +++ b/app/services/streaming/streaming-view.ts @@ -483,8 +483,14 @@ 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 + const display = this.isDualOutputMode + ? this.dualOutputView.getPlatformDisplay(platform) + : 'horizontal'; + return { ...settings, + display, enabled, useCustomFields, }; From 73a7575b5629751dc7223db276989fb98591db11 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Tue, 1 Oct 2024 14:38:11 -0400 Subject: [PATCH 06/97] Fix audience controls and generate stream key logic. (#5148) * Fix audience controls and generate stream key logic. * fix(build): strict nulls --------- Co-authored-by: Adrian Perez --- .../platforms/TiktokEditStreamInfo.tsx | 15 +++++++------ app/services/platforms/tiktok.ts | 21 +++++++++++++------ app/services/platforms/tiktok/api.ts | 9 +++++++- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/app/components-react/windows/go-live/platforms/TiktokEditStreamInfo.tsx b/app/components-react/windows/go-live/platforms/TiktokEditStreamInfo.tsx index cca2a4e24cfc..cfaf8c0b00b3 100644 --- a/app/components-react/windows/go-live/platforms/TiktokEditStreamInfo.tsx +++ b/app/components-react/windows/go-live/platforms/TiktokEditStreamInfo.tsx @@ -45,7 +45,7 @@ export function TikTokEditStreamInfo(p: IPlatformComponentParams<'tiktok'>) { requiredFields={
} /> {approved && } - {!controls.disable && ( + {approved && !controls.disable && ( - {!p.denied && ( - - )} + +
+ {/* ctrl.toggleViewerCount()} /> + */} {viewerCount} {Number(viewerCount) >= 0 && {$t('viewers')}}
From 13a45ba9934200628e07cfa579853a544d9258e5 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Thu, 10 Oct 2024 18:59:34 -0400 Subject: [PATCH 12/97] Fix themes tracking. (#5158) * Fix themes tracking. * Add comment. --- app/components-react/sidebar/FeaturesNav.tsx | 5 ++++- app/services/side-nav/menu-data.ts | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/components-react/sidebar/FeaturesNav.tsx b/app/components-react/sidebar/FeaturesNav.tsx index 29bfc7c9fc60..964efafd97ba 100644 --- a/app/components-react/sidebar/FeaturesNav.tsx +++ b/app/components-react/sidebar/FeaturesNav.tsx @@ -35,7 +35,10 @@ export default function FeaturesNav() { if (!UserService.views.isLoggedIn && !loggedOutMenuItemTargets.includes(page)) return; if (trackingTarget) { - UsageStatisticsService.actions.recordClick('SideNav2', trackingTarget); + // NOTE: For themes, the submenu items are tracked instead of the menu item + // to distinguish between theme feature usage + const target = trackingTarget === 'themes' && type ? type : trackingTarget; + UsageStatisticsService.actions.recordClick('SideNav2', target); } if (type) { diff --git a/app/services/side-nav/menu-data.ts b/app/services/side-nav/menu-data.ts index 61eefebc4776..f8ec9e0eb4e9 100644 --- a/app/services/side-nav/menu-data.ts +++ b/app/services/side-nav/menu-data.ts @@ -330,21 +330,21 @@ export const SideBarSubMenuItems = (): TSubMenuItems => ({ key: ESubMenuItemKey.Scene, target: 'BrowseOverlays', type: 'overlays', - trackingTarget: 'overlays', + trackingTarget: 'themes', isExpanded: false, }, [ESubMenuItemKey.Widget]: { key: ESubMenuItemKey.Widget, target: 'BrowseOverlays', type: 'widget-themes', - trackingTarget: 'widget-themes', + trackingTarget: 'themes', isExpanded: false, }, [ESubMenuItemKey.Sites]: { key: ESubMenuItemKey.Sites, target: 'BrowseOverlays', type: 'site-themes', - trackingTarget: 'site-themes', + trackingTarget: 'themes', isActive: false, isExpanded: false, }, From e978bfc82e17209ac2c4acf7a8f51811675faffd Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Thu, 10 Oct 2024 16:51:07 -0700 Subject: [PATCH 13/97] feature(onboarding): design and steps improvements (#5141) * feature(onboarding): remove streamer knowledge mode and tips * test(onboarding): fix tests * test: attempt to fix obs importer * test(onboarding): fix login/signup scenarios * feat(onboarding): start on Ultra page redesign * feat(onboarding): redesign themes step * feat(onboarding): add headers, new icon, etc * test(onboarding): fix setup mic title changed * chore(onboarding): restore Ultra gradient * chore(onboarding): remove icon from "and more..." on Ultra * chore(onboarding): use 5 themes --- .../pages/onboarding/Common.m.less | 9 +- .../pages/onboarding/HardwareSetup.tsx | 6 +- .../pages/onboarding/Onboarding.tsx | 28 +-- .../pages/onboarding/Prime.tsx | 8 + .../onboarding/StreamingOrRecording.m.less | 23 +- .../pages/onboarding/StreamingOrRecording.tsx | 76 +------ .../pages/onboarding/ThemeSelector.m.less | 44 +++- .../pages/onboarding/ThemeSelector.tsx | 172 +++++++++----- .../pages/onboarding/Tips.m.less | 33 --- .../pages/onboarding/Tips.tsx | 135 ----------- .../pages/onboarding/steps.ts | 1 - .../shared/StreamlabsDesktopIcon.tsx | 68 ++++++ .../shared/UltraComparison.m.less | 53 ++--- .../shared/UltraComparison.tsx | 210 ++++++------------ app/i18n/en-US/onboarding.json | 23 +- app/services/onboarding.ts | 102 ++------- app/services/onboarding/knowledge-mode.ts | 23 -- app/services/onboarding/theme-metadata.ts | 5 + test/helpers/modules/onboarding.ts | 5 +- test/regular/obs-importer.ts | 13 +- test/regular/onboarding.ts | 124 +---------- 21 files changed, 390 insertions(+), 771 deletions(-) delete mode 100644 app/components-react/pages/onboarding/Tips.m.less delete mode 100644 app/components-react/pages/onboarding/Tips.tsx create mode 100644 app/components-react/shared/StreamlabsDesktopIcon.tsx delete mode 100644 app/services/onboarding/knowledge-mode.ts diff --git a/app/components-react/pages/onboarding/Common.m.less b/app/components-react/pages/onboarding/Common.m.less index c6c5e43fed5c..8a1bcd68e437 100644 --- a/app/components-react/pages/onboarding/Common.m.less +++ b/app/components-react/pages/onboarding/Common.m.less @@ -12,6 +12,12 @@ margin-top: 144px; } +.subtitle-container { + display: flex; + justify-content: center; + margin-bottom: 35px; +} + .option-card { position: relative; background: var(--teal); @@ -35,7 +41,8 @@ margin-bottom: 0; } - svg, i { + svg, + i { position: absolute; bottom: -50%; right: 0; diff --git a/app/components-react/pages/onboarding/HardwareSetup.tsx b/app/components-react/pages/onboarding/HardwareSetup.tsx index 6c60d013f27a..9291d48e4b9f 100644 --- a/app/components-react/pages/onboarding/HardwareSetup.tsx +++ b/app/components-react/pages/onboarding/HardwareSetup.tsx @@ -84,8 +84,12 @@ export function HardwareSetup() { }} >

- {$t('Set Up Mic and Webcam')} + {$t('Set up your mic & webcam')}

+
+ {$t('Connect your most essential devices now or later on.')} +
+
{!!v.videoDevices.length && ( diff --git a/app/components-react/pages/onboarding/Onboarding.tsx b/app/components-react/pages/onboarding/Onboarding.tsx index 712913b46f00..7efef1dbdc08 100644 --- a/app/components-react/pages/onboarding/Onboarding.tsx +++ b/app/components-react/pages/onboarding/Onboarding.tsx @@ -8,11 +8,9 @@ import cx from 'classnames'; import { $t } from 'services/i18n'; import * as stepComponents from './steps'; import Utils from 'services/utils'; -import { IOnboardingStep, ONBOARDING_STEPS, StreamerKnowledgeMode } from 'services/onboarding'; +import { IOnboardingStep, ONBOARDING_STEPS } from 'services/onboarding'; import Scrollable from 'components-react/shared/Scrollable'; -import StreamlabsDesktopLogo from 'components-react/shared/StreamlabsDesktopLogo'; -import StreamlabsLogo from 'components-react/shared/StreamlabsLogo'; -import StreamlabsUltraLogo from 'components-react/shared/StreamlabsUltraLogo'; +import StreamlabsDesktopIcon from 'components-react/shared/StreamlabsDesktopIcon'; import { SkipContext } from './OnboardingContext'; export default function Onboarding() { @@ -135,23 +133,11 @@ function Footer({ currentStep, totalSteps, onSkip, isProcessing, currentStepInde } function TopBarLogo({ component }: { component: string }) { - switch (component) { - case 'StreamingOrRecording': - return ; - case 'Prime': - return ; - default: - return ; - } + return ; } function TopBar() { const component = useModule(OnboardingModule).currentStep.component; - // We decided to skip the top bar for Theme Selection as the cards are big and make Footer overlap - if (component === 'ThemeSelector') { - return <>; - } - return (
step.isPreboarding).length; } - get streamerKnowledgeMode() { - return this.OnboardingService.views.streamerKnowledgeMode; - } - get isLogin() { return this.OnboardingService.state.options.isLogin; } @@ -260,10 +242,6 @@ export class OnboardingModule { this.OnboardingService.setImport('twitch'); } - setStreamerKnowledgeMode(mode: StreamerKnowledgeMode | null) { - this.OnboardingService.setStreamerKnowledgeMode(mode); - } - finish() { if (!this.singletonStep) { this.UsageStatisticsService.actions.recordShown('Onboarding', 'completed'); diff --git a/app/components-react/pages/onboarding/Prime.tsx b/app/components-react/pages/onboarding/Prime.tsx index 2bfd6ede9987..138f2d685f63 100644 --- a/app/components-react/pages/onboarding/Prime.tsx +++ b/app/components-react/pages/onboarding/Prime.tsx @@ -86,6 +86,14 @@ export function Prime() { return (
+

+ {$t('Choose your plan')} +

+ +
+ {$t('Choose the best plan to fit your content creation needs.')} +
+
diff --git a/app/components-react/pages/onboarding/StreamingOrRecording.m.less b/app/components-react/pages/onboarding/StreamingOrRecording.m.less index 104b28cf3134..fe99732e683c 100644 --- a/app/components-react/pages/onboarding/StreamingOrRecording.m.less +++ b/app/components-react/pages/onboarding/StreamingOrRecording.m.less @@ -1,4 +1,4 @@ -@import "../../../styles/index"; +@import '../../../styles/index'; @import '../../../styles/badges'; .logo { @@ -44,24 +44,6 @@ width: 100%; } -.streamer-knowledge-mode-container { - display: none; - - &.active { - display: block; - } - - .option { - flex-direction: column; - - & > div { - flex-direction: row; - display: flex; - align-items: center; - } - } -} - .option { text-align: center; display: flex; @@ -156,8 +138,9 @@ @media (max-height: 750px) { .footer { img { - transform: scale(0.5) translate(-50%, 50%) + transform: scale(0.5) translate(-50%, 50%); } + svg { transform: rotateY(180deg) translateX(40%); } diff --git a/app/components-react/pages/onboarding/StreamingOrRecording.tsx b/app/components-react/pages/onboarding/StreamingOrRecording.tsx index 86613b4c4b69..4c91b76bf868 100644 --- a/app/components-react/pages/onboarding/StreamingOrRecording.tsx +++ b/app/components-react/pages/onboarding/StreamingOrRecording.tsx @@ -8,17 +8,11 @@ import cx from 'classnames'; import { confirmAsync } from 'components-react/modals'; import { Services } from 'components-react/service-provider'; import { useModule } from 'slap'; -import { StreamerKnowledgeMode } from 'services/onboarding'; export function StreamingOrRecording() { - const { - next, - setRecordingMode, - UsageStatisticsService, - streamerKnowledgeMode, - setStreamerKnowledgeMode, - isRecordingModeEnabled, - } = useModule(OnboardingModule); + const { next, setRecordingMode, UsageStatisticsService, isRecordingModeEnabled } = useModule( + OnboardingModule, + ); const [active, setActive] = useState<'streaming' | 'recording' | null>(null); async function onContinue() { @@ -39,7 +33,6 @@ export function StreamingOrRecording() { if (!result) return; - setStreamerKnowledgeMode(null); setRecordingMode(true); } @@ -55,17 +48,10 @@ export function StreamingOrRecording() { UsageStatisticsService.actions.recordClick('StreamingOrRecording', active); - UsageStatisticsService.actions.recordClick( - 'StreamingOrRecording', - streamerKnowledgeMode || 'knowledgeModeNotSelected', - ); - next(); } - const hasSelectedStreamerKnowledgeMode = streamerKnowledgeMode != null; - const shouldShowContinue = - (active === 'streaming' && hasSelectedStreamerKnowledgeMode) || active === 'recording'; + const shouldShowContinue = active === 'streaming' || active === 'recording'; return (
@@ -93,53 +79,6 @@ export function StreamingOrRecording() {

{$t('Recording Only')}

-
- -

{$t('What type of creator are you?')}

- -
-
setStreamerKnowledgeMode(StreamerKnowledgeMode.BEGINNER)} - > -
- -

{$t('Beginner')}

-
-

{$t('I want to be guided step by step.')}

-
-
setStreamerKnowledgeMode(StreamerKnowledgeMode.INTERMEDIATE)} - > -
- -

{$t('Intermediate')}

-
-

{$t('I need a little help getting started')}

-
-
setStreamerKnowledgeMode(StreamerKnowledgeMode.ADVANCED)} - > -
- -

{$t('Advanced')}

-
-

{$t('I like to setup things myself')}

-
-
-
- + {bigPreview && detailTheme && ( + <> +
+

{$t('Other Themes')}

+ + {getFilteredMetadata().map(theme => ( +
focusTheme(theme)} + key={theme.data.name} + > + +
{theme.data.name}
+
+ ))}
-
- {}} - /> - {previewImages(detailTheme).map(src => { - return ( - setBigPreview(src)} - /> - ); - })} +
+
+ +
+

{detailTheme.data.name}

+ %{designerName}', { + designerName: detailTheme.data.designer.name, + })} + > + { + if (detailTheme.data.designer.website) { + remote.shell.openExternal(detailTheme.data.designer.website); + } + }} + /> + +
+ +
+
+ {}} + /> + {previewImages(detailTheme).map(src => { + return ( + setBigPreview(src)} + /> + ); + })} +
-
+ )}
) : ( diff --git a/app/components-react/pages/onboarding/Tips.m.less b/app/components-react/pages/onboarding/Tips.m.less deleted file mode 100644 index 4e2da8b94e33..000000000000 --- a/app/components-react/pages/onboarding/Tips.m.less +++ /dev/null @@ -1,33 +0,0 @@ -.container { - display: flex; - flex-direction: column; - align-items: center; - flex: 1; - min-height: 90%; - justify-content: space-around; - - ul { - list-style-type: none; - padding: 0; - - li { - border-radius: 12px; - padding: 20px; - background-color: var(--border); - margin-bottom: 24px; - padding-right: 32px; - font-size: 18px; - - i { - margin-right: 8px; - } - - a { - font-weight: normal; - text-decoration: underline; - color: var(--link); - } - } - } -} - diff --git a/app/components-react/pages/onboarding/Tips.tsx b/app/components-react/pages/onboarding/Tips.tsx deleted file mode 100644 index 48aa739ce2f6..000000000000 --- a/app/components-react/pages/onboarding/Tips.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import React, { useMemo } from 'react'; -import { Button } from 'antd'; -import { shell } from '@electron/remote'; -import { OnboardingModule } from './Onboarding'; -import { useModule } from 'slap'; -import { StreamerKnowledgeMode } from 'services/onboarding'; -import { $t } from 'services/i18n'; -import Translate from 'components-react/shared/Translate'; -import commonStyles from './Common.m.less'; -import styles from './Tips.m.less'; - -const openExternalLink: React.MouseEventHandler = evt => { - evt.preventDefault(); - shell.openExternal(evt.currentTarget.href); -}; - -const linkProps = { slot: 'link', onClick: openExternalLink }; -const dashboardUrl = 'https://streamlabs.com/dashboard'; -const allStarsUrl = `${dashboardUrl}#/allstars`; - -function AllStarsTip() { - return ( -
  • - - on your dashboard', - )} - > - - -
  • - ); -} - -function BeginnerTips() { - return ( - <> -
  • - - getting started guide')} - > - - -
  • -
  • - - this troubleshooting guide')}> - - -
  • - -
  • - - Streamer University', - )} - > - - -
  • - - ); -} - -function IntermediateTips() { - return ( - <> -
  • - - Streamlabs Dashboard')} - > - - -
  • - -
  • - - Creator resource hub for everything you need', - )} - > - - -
  • - - ); -} - -export function Tips() { - const { streamerKnowledgeMode, next } = useModule(OnboardingModule); - - const title = - streamerKnowledgeMode === StreamerKnowledgeMode.BEGINNER - ? $t('Tips to run your first stream like a Pro:') - : $t('Tips to get the most out of your experience:'); - - const tips = useMemo(() => { - if (streamerKnowledgeMode === StreamerKnowledgeMode.BEGINNER) { - return ; - } else if (streamerKnowledgeMode === StreamerKnowledgeMode.INTERMEDIATE) { - return ; - } else { - // Should never be called as step has a cond filter thus this component will never be rendered - throw new Error('Unknown streamer knowledge mode'); - } - }, [streamerKnowledgeMode]); - - return ( -
    -
    - {title} -
    - -
      {tips}
    - - -
    - ); -} diff --git a/app/components-react/pages/onboarding/steps.ts b/app/components-react/pages/onboarding/steps.ts index 895d9c8df1ba..5502d1fd3748 100644 --- a/app/components-react/pages/onboarding/steps.ts +++ b/app/components-react/pages/onboarding/steps.ts @@ -8,4 +8,3 @@ export * from './ThemeSelector'; export * from './Optimize'; export * from './Prime'; export * from './StreamingOrRecording'; -export * from './Tips'; diff --git a/app/components-react/shared/StreamlabsDesktopIcon.tsx b/app/components-react/shared/StreamlabsDesktopIcon.tsx new file mode 100644 index 000000000000..d29226c204ee --- /dev/null +++ b/app/components-react/shared/StreamlabsDesktopIcon.tsx @@ -0,0 +1,68 @@ +import React from 'react'; + +export default function StreamlabsDesktopLogo() { + return ( +
    + + + + + + + + + + + +
    + ); +} diff --git a/app/components-react/shared/UltraComparison.m.less b/app/components-react/shared/UltraComparison.m.less index 14eef5ac5a3b..831bb4b8f37b 100644 --- a/app/components-react/shared/UltraComparison.m.less +++ b/app/components-react/shared/UltraComparison.m.less @@ -23,21 +23,6 @@ } } -.table-header { - padding: 12px; - border-bottom: 1px solid var(--border); - display: flex; - align-items: center; - - i { - margin-right: 8px; - } - - i:global(.icon-question) { - margin-left: auto; - } -} - .card-container { width: 350px; margin: 0 20px; @@ -79,19 +64,38 @@ flex-direction: column; justify-content: center; align-items: center; - border-bottom: 1px solid var(--background); span { font-size: 13px; } } +.subheader { + margin-bottom: 8px; + width: 100%; + padding: 0 18px; + + span { + display: block; + + &:nth-child(1) { + font-weight: bold; + } + } +} + +.features { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + padding: 0 12px; +} + .row { padding: 12px; display: flex; - justify-content: center; align-items: center; - border-bottom: 1px solid var(--background); &:last-child { padding-bottom: 36px; @@ -105,6 +109,7 @@ padding: 14px; width: 90%; background: var(--button); + font-weight: bold; } .prime-card-container { @@ -115,8 +120,8 @@ h1 { background: var(--ultra); -webkit-background-clip: text; + background-clip: text; -webkit-text-fill-color: transparent; - width: 80px; font-weight: 700; } } @@ -126,8 +131,9 @@ border-bottom: none; } -.prime-row { - border-bottom: 1px solid rgba(127,127,127,0.3) +.prime-button { + background: var(--ultra); + color: var(--section); } .prime-backing { @@ -140,11 +146,6 @@ opacity: 0.08; } -.prime-button { - background: var(--ultra); - color: var(--section); -} - .whisper { font-style: italic; opacity: 0.7; diff --git a/app/components-react/shared/UltraComparison.tsx b/app/components-react/shared/UltraComparison.tsx index 4082bc983964..6094236506c8 100644 --- a/app/components-react/shared/UltraComparison.tsx +++ b/app/components-react/shared/UltraComparison.tsx @@ -6,82 +6,40 @@ import { Services } from 'components-react/service-provider'; import UltraIcon from 'components-react/shared/UltraIcon'; import { Tooltip } from 'antd'; -interface ITableHeader { - text: string; - icon: string | JSX.Element; - tooltip?: string; - whisper?: string; -} - interface IUltraComparisonProps { onSkip?: () => void; condensed?: boolean; - tableHeaders?: ITableHeader[]; - tableData?: { - standard: { text: string; key?: string }[]; - prime: { text: string; key?: string }[]; + featureData?: { + standard: { text: string; icon?: string }[]; + ultra: { text: string; icon?: string }[]; }; refl: string; } export function UltraComparison(p: IUltraComparisonProps) { const { MagicLinkService } = Services; - const tableHeaders = p.tableHeaders || [ - { text: $t('Streamlabs Desktop'), icon: 'icon-desktop' }, - { text: $t('Themes and Overlays'), icon: 'icon-themes' }, - { text: $t('Multistream'), icon: 'icon-multistream' }, - { text: $t('Dual Output'), icon: }, - { text: $t('Seamless Creator Workflow'), icon: 'icon-user' }, - { text: $t('Desktop App Store'), icon: 'icon-store' }, - { text: $t('Collab Cam'), icon: 'icon-team-2' }, - { text: $t('Cloudbot'), icon: 'icon-cloudbot' }, - { text: $t('Alerts and Widgets'), icon: 'icon-alert-box' }, - { text: $t('Streamlabs Mobile'), icon: 'icon-phone' }, - { text: $t('Streamlabs Console'), icon: 'icon-console' }, - { text: $t('Tips'), icon: 'icon-donation-settings' }, - { text: $t('Storage'), icon: 'icon-cloud-backup' }, - { - text: $t('All Streamlabs Pro Tools'), - icon: 'icon-streamlabs', - whisper: 'Cross Clip, Talk Studio, Video Editor, Podcast Editor', - }, - ]; - const tableData = p.tableData || { + + const featureData = p.featureData || { standard: [ - { text: '✓', key: 'check1' }, - { text: $t('Access to Free Overlays and Themes') }, - { text: '—', key: 'dash1' }, - { text: $t('%{destinationsNumber} Destinations', { destinationsNumber: '2' }) }, - { text: '✓', key: 'check2' }, - { text: $t('Limited Free Apps') }, - { text: $t('Add 1 Guest') }, - { text: $t('Basic Chatbot') }, - { text: '✓', key: 'check3' }, - { text: '✓', key: 'check4' }, - { text: '—', key: 'dash2' }, - { text: $t('No-fee Tipping') }, - { text: '1GB' }, - { text: $t('Basic Features') }, + { icon: 'icon-broadcast', text: $t('Go live to one platform') }, + { icon: 'icon-balance', text: $t('Tipping (no Streamlabs fee)') }, + { icon: 'icon-widgets', text: $t('Alerts & other Widgets') }, + { icon: 'icon-record', text: $t('Recording') }, + { icon: 'icon-smart-record', text: $t('Selective Recording') }, + { icon: 'icon-editor-3', text: $t('Game Overlay') }, + { icon: 'icon-dual-output', text: $t('Dual Output (1 platform + TikTok)') }, + { text: $t('And many more free features') }, ], - prime: [ - { text: '✓', key: 'check1' }, - { - text: p.condensed - ? $t('Access to All Overlays and Themes') - : $t('Access to All Overlays and Themes (%{themeNumber})', { themeNumber: '1000+' }), - }, - { text: '✓', key: 'check2' }, - { text: $t('%{destinationsNumber} Destinations', { destinationsNumber: '3+' }) }, - { text: '✓', key: 'check3' }, - { text: $t('Access Full App Library (%{appNumber})', { appNumber: '60+' }) }, - { text: $t('Add Up To 11 Guests or Cameras') }, - { text: $t('Custom Named Chatbot') }, - { text: '✓', key: 'check4' }, - { text: '✓ ' + $t('+ Disconnect Protection') }, - { text: '✓', key: 'check5' }, - { text: $t('Custom Tip Page and Domain') }, - { text: '10GB' }, - { text: $t('Pro Upgrade') }, + ultra: [ + { icon: 'icon-streamlabs', text: $t('All free features') }, + { icon: 'icon-multistream', text: $t('Multistream to multiple platforms') }, + { icon: 'icon-design', text: $t('Premium Stream Overlays') }, + { icon: 'icon-themes', text: $t('Alert Box & Widget Themes') }, + { icon: 'icon-store', text: $t('Access all App Store Apps') }, + { icon: 'icon-dual-output', text: $t('Dual Output (3+ destinations)') }, + { icon: 'icon-team', text: $t('Collab Cam up to 11 guests') }, + { icon: 'icon-ultra', text: $t('Pro tier across the rest of the suite') }, + { text: $t('And many more Ultra features') }, ], }; @@ -93,21 +51,10 @@ export function UltraComparison(p: IUltraComparisonProps) {
    -
    - {tableHeaders.map(header => ( - - ))} -

    - {$t('Starter')} + {$t('Free')}

    -

    {$t('Free')}

    - - {p.condensed - ? $t('Always and forever free') - : $t('Everything you need to go live. Always and forever free.')} - -
    {$t('Choose Starter')}
    -
    - {tableData.standard.map(data => ( -
    - {data.text} +
    + {$t('Everything you need to go live.')} + {$t('Always and forever free')}
    - ))} +
    + {$t('Choose Free')} +
    +
    + {featureData.standard.map(data => ( +
    + {data.icon && } + {data.text} +
    + ))} +
    +
    -
    +

    - Ultra + Streamlabs Ultra

    -

    - {$t('%{monthlyPrice}/mo or %{yearlyPrice}/year', { - monthlyPrice: '$19', - yearlyPrice: '$149', - })} -

    - - {p.condensed - ? $t('Everything in Starter plus:') - : $t('Includes everything in Starter plus:')} - -
    {$t('Choose Ultra')}
    -
    - {tableData.prime.map(data => ( -
    - {data.text} +
    + {$t('Premium features for your stream.')} + + {$t('%{monthlyPrice}/mo or %{yearlyPrice}/year', { + monthlyPrice: '$19', + yearlyPrice: '$149', + })} + +
    +
    + {$t('Choose Ultra')}
    - ))} +
    +
    + {featureData.ultra.map(data => ( +
    + {data.icon && } + {data.text} +
    + ))} +
    ); } - -function TableHeader(p: { header: ITableHeader }) { - const cell = ( -
    - {typeof p.header.icon === 'string' ? : p.header.icon} - - {p.header.text} - {p.header.whisper &&
    {p.header.whisper}
    } -
    - {p.header.tooltip && } -
    - ); - - if (p.header.tooltip) { - return ( - - {cell} - - ); - } else return cell; -} - -const DualOutputIcon = ({ color = '#bdc2c4' }) => ( - - - -); diff --git a/app/i18n/en-US/onboarding.json b/app/i18n/en-US/onboarding.json index ed41c121e107..0bb80fb7888b 100644 --- a/app/i18n/en-US/onboarding.json +++ b/app/i18n/en-US/onboarding.json @@ -168,5 +168,26 @@ "Tips to run your first stream like a Pro:": "Tips to run your first stream like a Pro:", "Set yourself up for success with our getting started guide": "Set yourself up for success with our getting started guide", "Prevent crashes with this troubleshooting guide": "Prevent crashes with this troubleshooting guide", - "Learn more about streaming through our free Streamer University": "Learn more about streaming through our free Streamer University" + "Learn more about streaming through our free Streamer University": "Learn more about streaming through our free Streamer University", + "Recording": "Recording", + "Game Overlay": "Game Overlay", + "Dual Output (1 platform + TikTok)": "Dual Output (1 platform + TikTok)", + "Alert Box & Widget Themes": "Alert Box & Widget Themes", + "Access all App Store Apps": "Access all App Store Apps", + "Dual Output (3+ destinations)": "Dual Output (3+ destinations)", + "Collab Cam up to 11 guests": "Collab Cam up to 11 guests", + "Pro tier across the rest of the suite": "Pro tier across the rest of the suite", + "And many more Ultra features": "And many more Ultra features", + "Everything you need to go live.": "Everything you need to go live.", + "Always and forever free": "Aways and forever free", + "Choose Ultra": "Choose Ultra", + "Premium features for your stream.": "Premium features for your stream.", + "Other Themes": "Other Themes", + "by %{designerName}": "by %{designerName}", + "Setup your mic & webcam": "Setup your mic & webcam", + "Connect your most essential devices now or later on.": "Connect your most essential devices now or later on.", + "Choose your plan": "Choose your plan", + "Choose the best plan to fit your content creation needs.": "Choose the best plan to fit your content creation needs.", + "Add your first theme": "Add your first theme", + "Try your first theme now, browse hundreds of more themes later on": "Try your first theme now, browse hundreds of more themes later on" } diff --git a/app/services/onboarding.ts b/app/services/onboarding.ts index a035e6e47326..950eb6c2de54 100644 --- a/app/services/onboarding.ts +++ b/app/services/onboarding.ts @@ -15,12 +15,7 @@ import Utils from './utils'; import { RecordingModeService } from './recording-mode'; import { THEME_METADATA, IThemeMetadata } from './onboarding/theme-metadata'; export type { IThemeMetadata } from './onboarding/theme-metadata'; -import { - StreamerKnowledgeMode, - isBeginnerOrIntermediateOrUnselected, -} from './onboarding/knowledge-mode'; import { TwitchStudioImporterService } from './ts-importer'; -export { StreamerKnowledgeMode } from './onboarding/knowledge-mode'; enum EOnboardingSteps { MacPermissions = 'MacPermissions', @@ -34,7 +29,6 @@ enum EOnboardingSteps { // temporarily disable auto config until migrate to new api // Optimize = 'Optimize', Prime = 'Prime', - Tips = 'Tips', } const isMac = () => process.platform === OS.Mac; @@ -123,16 +117,9 @@ export const ONBOARDING_STEPS = () => ({ cond: ({ isUltra }: OnboardingStepContext) => !isUltra, isSkippable: true, }, - [EOnboardingSteps.Tips]: { - component: 'Tips' as const, - hideButton: true, - cond: isBeginnerOrIntermediateOrUnselected, - isSkippable: false, - }, }); export interface OnboardingStepContext { - streamerKnowledgeMode: StreamerKnowledgeMode | null; isPartialSLAuth: boolean; existingSceneCollections: boolean; isObsInstalled: boolean; @@ -178,7 +165,6 @@ interface IOnboardingServiceState { options: IOnboardingOptions; importedFrom: 'obs' | 'twitch'; existingSceneCollections: boolean; - streamerKnowledgeMode: StreamerKnowledgeMode | null; } class OnboardingViews extends ViewHandler { @@ -204,13 +190,10 @@ class OnboardingViews extends ViewHandler { ).isTwitchStudioInstalled(); const recordingModeEnabled = this.getServiceViews(RecordingModeService).isRecordingModeEnabled; - const streamerKnowledgeMode = this.streamerKnowledgeMode; - const { existingSceneCollections, importedFrom } = this.state; const { isLoggedIn, isPrime: isUltra } = userViews; const ctx: OnboardingStepContext = { - streamerKnowledgeMode, recordingModeEnabled, existingSceneCollections, importedFrom, @@ -223,72 +206,27 @@ class OnboardingViews extends ViewHandler { isLoggedIn && getPlatformService(userViews.platform?.type)?.hasCapability('themes'), }; - return this.getStepsForMode(streamerKnowledgeMode)(ctx); + return this.makeSteps(ctx); } get totalSteps() { return this.steps.length; } - getStepsForMode(mode: StreamerKnowledgeMode) { + makeSteps(ctx: OnboardingStepContext) { const { getSteps } = this; - switch (mode) { - case StreamerKnowledgeMode.BEGINNER: - return getSteps([ - EOnboardingSteps.MacPermissions, - EOnboardingSteps.StreamingOrRecording, - EOnboardingSteps.Connect, - EOnboardingSteps.PrimaryPlatformSelect, - EOnboardingSteps.FreshOrImport, - EOnboardingSteps.ObsImport, - EOnboardingSteps.HardwareSetup, - EOnboardingSteps.ThemeSelector, - EOnboardingSteps.Prime, - EOnboardingSteps.Tips, - ]); - case StreamerKnowledgeMode.INTERMEDIATE: - /* - * Yes, these are the same as beginner, only inner screens are supposed to differ, - * but the one screen that was provided is currently disabled (Optimizer). - * Nevertheless, this sets the foundation for future changes. - */ - return getSteps([ - EOnboardingSteps.MacPermissions, - EOnboardingSteps.StreamingOrRecording, - EOnboardingSteps.Connect, - EOnboardingSteps.PrimaryPlatformSelect, - EOnboardingSteps.FreshOrImport, - EOnboardingSteps.ObsImport, - EOnboardingSteps.HardwareSetup, - EOnboardingSteps.ThemeSelector, - EOnboardingSteps.Prime, - EOnboardingSteps.Tips, - ]); - case StreamerKnowledgeMode.ADVANCED: - return getSteps([ - EOnboardingSteps.MacPermissions, - EOnboardingSteps.StreamingOrRecording, - EOnboardingSteps.Connect, - EOnboardingSteps.PrimaryPlatformSelect, - EOnboardingSteps.FreshOrImport, - EOnboardingSteps.ObsImport, - EOnboardingSteps.HardwareSetup, - EOnboardingSteps.Prime, - ]); - default: - return getSteps([ - EOnboardingSteps.MacPermissions, - EOnboardingSteps.StreamingOrRecording, - EOnboardingSteps.Connect, - EOnboardingSteps.PrimaryPlatformSelect, - EOnboardingSteps.FreshOrImport, - EOnboardingSteps.ObsImport, - EOnboardingSteps.HardwareSetup, - EOnboardingSteps.ThemeSelector, - EOnboardingSteps.Prime, - ]); - } + return getSteps([ + EOnboardingSteps.MacPermissions, + EOnboardingSteps.StreamingOrRecording, + EOnboardingSteps.Connect, + EOnboardingSteps.PrimaryPlatformSelect, + EOnboardingSteps.FreshOrImport, + EOnboardingSteps.ObsImport, + EOnboardingSteps.HardwareSetup, + EOnboardingSteps.ThemeSelector, + EOnboardingSteps.Prime, + ])(ctx); } getSteps(stepNames: EOnboardingSteps[]) { @@ -308,10 +246,6 @@ class OnboardingViews extends ViewHandler { }, [] as IOnboardingStep[]); }; } - - get streamerKnowledgeMode() { - return this.state.streamerKnowledgeMode; - } } export class OnboardingService extends StatefulService { @@ -324,7 +258,6 @@ export class OnboardingService extends StatefulService }, importedFrom: null, existingSceneCollections: false, - streamerKnowledgeMode: null, }; localStorageKey = 'UserHasBeenOnboarded'; @@ -351,11 +284,6 @@ export class OnboardingService extends StatefulService this.state.existingSceneCollections = val; } - @mutation() - SET_STREAMER_KNOWLEDGE_MODE(val: StreamerKnowledgeMode) { - this.state.streamerKnowledgeMode = val; - } - async fetchThemeData(id: string) { const url = `https://overlays.streamlabs.com/api/overlay/${id}`; return jfetch(url); @@ -400,10 +328,6 @@ export class OnboardingService extends StatefulService this.SET_EXISTING_COLLECTIONS(this.existingSceneCollections); } - setStreamerKnowledgeMode(val: StreamerKnowledgeMode | null) { - this.SET_STREAMER_KNOWLEDGE_MODE(val); - } - start(options: Partial = {}) { const actualOptions: IOnboardingOptions = { isLogin: false, diff --git a/app/services/onboarding/knowledge-mode.ts b/app/services/onboarding/knowledge-mode.ts deleted file mode 100644 index ee168ed49c3d..000000000000 --- a/app/services/onboarding/knowledge-mode.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { OnboardingStepContext } from '../onboarding'; - -export enum StreamerKnowledgeMode { - BEGINNER = 'BEGINNER', - INTERMEDIATE = 'INTERMEDIATE', - ADVANCED = 'ADVANCED', -} - -export const isBeginnerOrIntermediateOrUnselected = ({ - streamerKnowledgeMode, -}: OnboardingStepContext) => - !streamerKnowledgeMode || - [StreamerKnowledgeMode.BEGINNER, StreamerKnowledgeMode.INTERMEDIATE].includes( - streamerKnowledgeMode, - ); - -export const isIntermediateOrAdvancedOrUnselected = ({ - streamerKnowledgeMode, -}: OnboardingStepContext) => - !streamerKnowledgeMode || - [StreamerKnowledgeMode.INTERMEDIATE, StreamerKnowledgeMode.ADVANCED].includes( - streamerKnowledgeMode, - ); diff --git a/app/services/onboarding/theme-metadata.ts b/app/services/onboarding/theme-metadata.ts index 77da4d97c46b..d06d06d5d70e 100644 --- a/app/services/onboarding/theme-metadata.ts +++ b/app/services/onboarding/theme-metadata.ts @@ -3,6 +3,11 @@ export interface IThemeMetadata { id: number; name: string; custom_images: Dictionary; + designer: { + name: string; + avatar: string; + website: string; + }; }; } diff --git a/test/helpers/modules/onboarding.ts b/test/helpers/modules/onboarding.ts index ce15e1c6a106..4d7e4cf79b47 100644 --- a/test/helpers/modules/onboarding.ts +++ b/test/helpers/modules/onboarding.ts @@ -6,7 +6,6 @@ export async function skipOnboarding() { if (!(await isDisplayed('h2=Live Streaming'))) return; // Uses advanced onboarding await click('h2=Live Streaming'); - await click('h2=Advanced'); await click('button=Continue'); // Auth await click('button=Skip'); @@ -14,7 +13,9 @@ export async function skipOnboarding() { await clickIfDisplayed('div=Start Fresh'); // Hardware setup await click('button=Skip'); - // Ultra + // Themes await click('button=Skip'); + // Ultra + await clickIfDisplayed('div[data-testid=choose-free-plan-btn]'); }); } diff --git a/test/regular/obs-importer.ts b/test/regular/obs-importer.ts index b2bdfd0ef0a9..3295d3db0598 100644 --- a/test/regular/obs-importer.ts +++ b/test/regular/obs-importer.ts @@ -48,7 +48,6 @@ test('OBS Importer', async t => { if (!(await isDisplayed('h2=Live Streaming'))) return; await click('h2=Live Streaming'); - await click('h2=Advanced'); await click('button=Continue'); await click('button=Skip'); @@ -58,15 +57,6 @@ test('OBS Importer', async t => { await click('button=Skip'); */ - /* - * TODO: "Advanced" flow doesn't have a login, but we couldn't get this to pass - * when trying to go through the Intermediate flow which does have login. - * After fixing everything step-related there, it was stuck on the loader after - * switching to the Widgets collection. - * Since going through Onboarding as Intermediate (or any other mode) is already - * covered by their own tests, we're faking login here while remaining on the - * Advanced flow. We need the login for widget assertions below to pass. - */ await logIn(t, 'twitch', { prime: false }, false, true); await sleep(1000); @@ -75,7 +65,8 @@ test('OBS Importer', async t => { await click('div=Start'); // skip Ultra - await waitForDisplayed('div=Choose Starter'); + await waitForDisplayed('div[data-testid=choose-free-plan-btn]'); + // skip Themes await click('button=Skip'); await waitForDisplayed('[data-name=SceneSelector]'); diff --git a/test/regular/onboarding.ts b/test/regular/onboarding.ts index b22f712676f4..2c71fb9166fb 100644 --- a/test/regular/onboarding.ts +++ b/test/regular/onboarding.ts @@ -20,7 +20,6 @@ test('Go through onboarding login and signup', async t => { if (!(await isDisplayed('h2=Live Streaming'))) return; await click('h2=Live Streaming'); - await click('h2=Beginner'); await click('button=Continue'); t.true(await isDisplayed('h1=Sign Up'), 'Shows signup page by default'); @@ -51,15 +50,13 @@ test('Go through onboarding login and signup', async t => { t.true(await isDisplayed('a=Sign up'), 'Has a link to go back to Sign Up'); }); -test('Go through onboarding as beginner user', async t => { +test('Go through onboarding', async t => { const app = t.context.app; await focusMain(); if (!(await isDisplayed('h2=Live Streaming'))) return; await click('h2=Live Streaming'); - // Choose Beginner onboarding - await click('h2=Beginner'); await click('button=Continue'); // Click on Login on the signup page, then wait for the auth screen to appear @@ -76,7 +73,7 @@ test('Go through onboarding as beginner user', async t => { await clickIfDisplayed('div=Start Fresh'); // Skip hardware config - await waitForDisplayed('h1=Set Up Mic and Webcam'); + await waitForDisplayed('h1=Set up your mic & webcam'); await clickIfDisplayed('button=Skip'); // Skip picking a theme @@ -85,111 +82,12 @@ test('Go through onboarding as beginner user', async t => { // Skip purchasing prime // TODO: is this timeout because of autoconfig? - await waitForDisplayed('div=Choose Starter', { timeout: 60000 }); - await click('div=Choose Starter'); - - // Click Get Started after seeing tips - t.true( - await isDisplayed('span=Set yourself up for success with our getting started guide'), - 'Shows beginner tips', - ); - await clickIfDisplayed('button=Get Started'); - - await waitForDisplayed('span=Sources', { timeout: 60000 }); - - // success? - t.true(await (await app.client.$('span=Sources')).isDisplayed(), 'Sources selector is visible'); -}); - -// TODO: this is the same as beginner as of the current flow, aside page diffs just asserts tips are different -test('Go through onboarding as intermediate user', async t => { - const app = t.context.app; - await focusMain(); - - if (!(await isDisplayed('h2=Live Streaming'))) return; - - await click('h2=Live Streaming'); - // Choose Intermediate onboarding - await click('h2=Intermediate'); - await click('button=Continue'); - - // Click on Login on the signup page, then wait for the auth screen to appear - await click('a=Login'); - await isDisplayed('button=Log in with Twitch'); - - await logIn(t, 'twitch', { prime: false }, false, true); - await sleep(1000); - - // We seem to skip the login step after login internally - await clickIfDisplayed('button=Skip'); - - // Don't Import from OBS - await clickIfDisplayed('div=Start Fresh'); - - // Skip hardware config - await waitForDisplayed('h1=Set Up Mic and Webcam'); - await clickIfDisplayed('button=Skip'); - - // Skip picking a theme - await waitForDisplayed('h1=Add an Overlay'); - await clickIfDisplayed('button=Skip'); - - // Skip purchasing prime - // TODO: is this timeout because of autoconfig? - await waitForDisplayed('div=Choose Starter', { timeout: 60000 }); - await click('div=Choose Starter'); - - // Click Get Started after seeing tips - t.true( - await isDisplayed('span=Set up your alerts and widgets on Streamlabs Dashboard'), - 'Shows intermediate tips', - ); - await clickIfDisplayed('button=Get Started'); + await waitForDisplayed('div[data-testid=choose-free-plan-btn]', { timeout: 60000 }); + await click('div=[data-testid=choose-free-plan-btn]'); await waitForDisplayed('span=Sources', { timeout: 60000 }); // success? - // prettier-ignore - t.true(await (await app.client.$('span=Sources')).isDisplayed(), 'Sources selector is visible'); -}); - -test('Go through onboarding as advanced user', async t => { - const app = t.context.app; - await focusMain(); - - if (!(await isDisplayed('h2=Live Streaming'))) return; - - await click('h2=Live Streaming'); - // Choose Advanced onboarding - await click('h2=Advanced'); - await click('button=Continue'); - - // Click on Login on the signup page, then wait for the auth screen to appear - await click('a=Login'); - await isDisplayed('button=Log in with Twitch'); - - await logIn(t, 'twitch', { prime: false }, false, true); - await sleep(1000); - - // We seem to skip the login step after login internally - await clickIfDisplayed('button=Skip'); - - // Don't Import from OBS - await clickIfDisplayed('div=Start Fresh'); - - // Skip hardware config - await waitForDisplayed('h1=Set Up Mic and Webcam'); - await clickIfDisplayed('button=Skip'); - - // Skip purchasing prime - // TODO: is this timeout because of autoconfig? - await waitForDisplayed('div=Choose Starter', { timeout: 60000 }); - await click('div=Choose Starter'); - - await waitForDisplayed('span=Sources', { timeout: 60000 }); - - // success? - // prettier-ignore t.true(await (await app.client.$('span=Sources')).isDisplayed(), 'Sources selector is visible'); }); @@ -201,7 +99,6 @@ test.skip('Go through the onboarding and autoconfig', async t => { if (!(await isDisplayed('h2=Live Streaming'))) return; await click('h2=Live Streaming'); - await click('h2=Beginner'); await click('button=Continue'); // Click on Login on the signup page, then wait for the auth screen to appear @@ -219,7 +116,7 @@ test.skip('Go through the onboarding and autoconfig', async t => { await clickIfDisplayed('div=Start Fresh'); // Skip hardware config - await waitForDisplayed('h1=Set Up Mic and Webcam'); + await waitForDisplayed('h1=Set up your mic & webcam'); await clickIfDisplayed('button=Skip'); // Skip picking a theme @@ -233,15 +130,8 @@ test.skip('Go through the onboarding and autoconfig', async t => { // Skip purchasing prime // TODO: is this timeout because of autoconfig? - await waitForDisplayed('div=Choose Starter', { timeout: 60000 }); - await click('div=Choose Starter'); - - // Click Get Started after seeing tips - t.true( - await isDisplayed('span=Set yourself up for success with our getting started guide'), - 'Shows beginner tips', - ); - await clickIfDisplayed('button=Get Started'); + await waitForDisplayed('div[data-testid=choose-free-plan-btn]', { timeout: 60000 }); + await click('div[data-testid=choose-free-plan-btn]'); await waitForDisplayed('span=Sources', { timeout: 60000 }); From 876b4a184c2de44f1b634ab28023c4b1e4b6e419 Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Fri, 11 Oct 2024 13:15:10 -0700 Subject: [PATCH 14/97] feat: add collectibles API for adding source inside embed (#5135) * feat: add collectibles API for adding source inside embed Adds guest API methods for listing scenes and adding a collectible to a specific scene. This is convenience for create source depending on its type. * feat(collectibles): add boolean for active scene * fix(collectibles): add staging URL to validation * fix(collectibles): invert boolean logic * feat(collectibles): require passing source name * feat(collectibles): start with file download * refactor(collectibles): very minor cleanup * fix(collectibles): incomplete URL sanitization in check Even though we control domain and caller, I'd rather do it properly. It seems like the overlay host validation uses the same method. * fix(collectibles): ensure media dir exists, details follow On a fresh cache, where the user has no files that have triggered media backup before, this directory does not exist, so attempting to download a collectible to that directory fails. This ensures the directory exists prior to downloading. Unfortunately we had to make that method public since we're not using `MediaBackupService.downloadFile` here. * fix(collectibles): not found scenes did not throw Somewhere we regressed and not found scenes were no longer throwing an error but returning undefined silently due to the `?` operator. Refactors creation method out of the view model, while something else was causing issues earlier, it no longer seems to be the case and we can correctly access the scene returned from `this.views.getScene()` on the service, what remained was a serialization error, which we removed by returning `sceneItemId` from it, as our API defines. * feat(collectibles): add vertical nodes --- app/components-react/pages/BrowseOverlays.tsx | 120 +++++++++++++++++- app/services/media-backup.ts | 4 +- app/services/scenes/scenes.ts | 20 ++- 3 files changed, 135 insertions(+), 9 deletions(-) diff --git a/app/components-react/pages/BrowseOverlays.tsx b/app/components-react/pages/BrowseOverlays.tsx index 6c70718d61dd..acca75db084e 100644 --- a/app/components-react/pages/BrowseOverlays.tsx +++ b/app/components-react/pages/BrowseOverlays.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import path from 'path'; import urlLib from 'url'; import { Service } from 'services'; import Utils from 'services/utils'; @@ -6,7 +7,7 @@ import { ENotificationType } from 'services/notifications'; import { $t } from 'services/i18n'; import BrowserView from 'components-react/shared/BrowserView'; import { GuestApiHandler } from 'util/guest-api-handler'; -import { IDownloadProgress } from 'util/requests'; +import { downloadFile, IDownloadProgress } from 'util/requests'; import * as remote from '@electron/remote'; import { Services } from 'components-react/service-provider'; @@ -24,13 +25,18 @@ export default function BrowseOverlays(p: { NotificationsService, JsonrpcService, RestreamService, + MediaBackupService, } = Services; const [downloading, setDownloading] = useState(false); const [overlaysUrl, setOverlaysUrl] = useState(''); useEffect(() => { async function getOverlaysUrl() { - const url = await UserService.actions.return.overlaysUrl(p.params?.type, p.params?.id, p.params?.install); + const url = await UserService.actions.return.overlaysUrl( + p.params?.type, + p.params?.id, + p.params?.install, + ); if (!url) return; setOverlaysUrl(url); } @@ -43,6 +49,8 @@ export default function BrowseOverlays(p: { installOverlay, installWidgets, installOverlayAndWidgets, + getScenes, + addCollectibleToScene, eligibleToRestream: () => { // assume all users are eligible return Promise.resolve(true); @@ -75,7 +83,7 @@ export default function BrowseOverlays(p: { try { await installOverlayBase(url, name, progressCallback, mergePlatform); NavigationService.actions.navigate('Studio'); - } catch(e) { + } catch (e) { // If the overlay requires platform merge, navigate to the platform merge page if (e.message === 'REQUIRES_PLATFORM_MERGE') { NavigationService.actions.navigate('PlatformMerge', { overlayUrl: url, overlayName: name }); @@ -85,13 +93,108 @@ export default function BrowseOverlays(p: { } } + /** + * Get a list of scenes in the active scene collection + * + * @returns An array of scenes with items including only `id` and `name` + */ + async function getScenes() { + return ScenesService.views.scenes.map(scene => ({ + id: scene.id, + name: scene.name, + isActiveScene: scene.id === ScenesService.views.activeSceneId, + })); + } + + /** + * Adds a collectible to a scene. + * + * Collectibles are just a CDN URL of an image or video, this API provides + * embedded pages with a convenience method for creating sources based on those. + * + * @param name - Name of the collectible, used as source name + * @param sceneId - ID of the scene where the collectible will be added to + * @param assetURL - CDN URL of the collectible asset + * @param type - Type of source that will be created, `image` or `video` + * + * @returns string - ID of the scene item that was created for the source. + * @throws When type is not image or video. + * @throws When URL is a not a Streamlabs CDN URL. + * @throws When scene for the provided scene ID can't be found. + * @throws When it fails to create the source. + * + * @remarks When using a gif, the type should be set to `video` due to some + * inconsistencies we found with image source, namely around playback being + * shoppy or sometimes not displaying at all. Granted, we tested remote files + * at the start, so this might not be true for local files which are now downloaded. + */ + async function addCollectibleToScene( + name: string, + sceneId: string, + assetURL: string, + type: 'image' | 'video', + ) { + if (!['image', 'video'].includes(type)) { + throw new Error("Unsupported type. Use 'image' or 'video'"); + } + + if ( + !hasValidHost(assetURL, [ + 'cdn.streamlabs.com', + 'streamlabs-marketplace-staging.streamlabs.com', + ]) + ) { + throw new Error('Invalid asset URL'); + } + + // TODO: find or create enum + const sourceType = type === 'video' ? 'ffmpeg_source' : 'image_source'; + + const sourceName = name; + + const filename = path.basename(assetURL); + + // On a fresh cache with login and not restarting the app this + // directory might not exist, based on testing + await MediaBackupService.actions.return.ensureMediaDirectory(); + + const dest = path.join(MediaBackupService.mediaDirectory, filename); + + // TODO: refactor all this + // TODO: test if media backup is working automatically or we need changes + let localFile; + + try { + await downloadFile(assetURL, dest); + localFile = dest; + } catch { + throw new Error('Error downloading file to local system'); + } + + const sourceSettings = + type === 'video' ? { looping: true, local_file: localFile } : { file: localFile }; + + return ScenesService.actions.return.createAndAddSource( + sceneId, + sourceName, + sourceType, + sourceSettings, + ); + } + + function hasValidHost(url: string, trustedHosts: string[]) { + const host = new urlLib.URL(url).hostname; + return trustedHosts.includes(host); + } + async function installOverlayBase( url: string, name: string, progressCallback?: (progress: IDownloadProgress) => void, - mergePlatform = false + mergePlatform = false, ) { return new Promise((resolve, reject) => { + // TODO: refactor to use hasValidHost const host = new urlLib.URL(url).hostname; const trustedHosts = ['cdn.streamlabs.com']; if (!trustedHosts.includes(host)) { @@ -115,7 +218,8 @@ export default function BrowseOverlays(p: { } else { setDownloading(true); const sub = SceneCollectionsService.downloadProgress.subscribe(progressCallback); - SceneCollectionsService.actions.return.installOverlay(url, name) + SceneCollectionsService.actions.return + .installOverlay(url, name) .then(() => { sub.unsubscribe(); setDownloading(false); @@ -161,7 +265,11 @@ export default function BrowseOverlays(p: { } } - async function installOverlayAndWidgets(overlayUrl: string, overlayName: string, widgetUrls: string[]) { + async function installOverlayAndWidgets( + overlayUrl: string, + overlayName: string, + widgetUrls: string[], + ) { try { await installOverlayBase(overlayUrl, overlayName); await installWidgetsBase(widgetUrls); diff --git a/app/services/media-backup.ts b/app/services/media-backup.ts index ab5a32620e91..c4ac73ba74c0 100644 --- a/app/services/media-backup.ts +++ b/app/services/media-backup.ts @@ -319,13 +319,13 @@ export class MediaBackupService extends StatefulService { return { Authorization: `Bearer ${this.userService.apiToken}` }; } - private ensureMediaDirectory() { + ensureMediaDirectory() { if (!fs.existsSync(this.mediaDirectory)) { fs.mkdirSync(this.mediaDirectory); } } - private get mediaDirectory() { + get mediaDirectory() { return path.join(this.appService.appDataDirectory, 'Media'); } diff --git a/app/services/scenes/scenes.ts b/app/services/scenes/scenes.ts index bf05c1682bc9..adc875822c5e 100644 --- a/app/services/scenes/scenes.ts +++ b/app/services/scenes/scenes.ts @@ -13,7 +13,7 @@ import namingHelpers from 'util/NamingHelpers'; import uuid from 'uuid/v4'; import { DualOutputService } from 'services/dual-output'; import { TDisplayType } from 'services/settings-v2/video'; -import { InitAfter, ViewHandler } from 'services/core'; +import { ExecuteInWorkerProcess, InitAfter, ViewHandler } from 'services/core'; export type TSceneNodeModel = ISceneItem | ISceneItemFolder; @@ -442,6 +442,24 @@ export class ScenesService extends StatefulService { return this.state; } + createAndAddSource( + sceneId: string, + sourceName: string, + sourceType: TSourceType, + settings: Dictionary, + ) { + const scene = this.views.getScene(sceneId); + if (!scene) { + throw new Error(`Can't find scene with ID: ${sceneId}`); + } + + const sceneItem = scene.createAndAddSource(sourceName, sourceType, settings); + + this.dualOutputService.createPartnerNode(sceneItem); + + return sceneItem.sceneItemId; + } + // TODO: Remove all of this in favor of the new "views" methods // getScene(id: string): Scene | null { // return !this.state.scenes[id] ? null : new Scene(id); From e8d0bc8d9d40874772905ae063f02f1ccda15c32 Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Tue, 15 Oct 2024 13:37:03 -0700 Subject: [PATCH 15/97] Revert "chore: remove viewer count icon and toggle (#5155)" (#5161) This reverts commit 8dd9e81b3cf36d1f9ab9e54c59db3b46506eddbd. --- app/components-react/root/LiveDock.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/components-react/root/LiveDock.tsx b/app/components-react/root/LiveDock.tsx index eb35d4e937db..f25375c4fbcd 100644 --- a/app/components-react/root/LiveDock.tsx +++ b/app/components-react/root/LiveDock.tsx @@ -396,7 +396,6 @@ function LiveDock(p: { onLeft: boolean }) { {elapsedStreamTime}
    - {/* ctrl.toggleViewerCount()} /> - */} {viewerCount} {Number(viewerCount) >= 0 && {$t('viewers')}}
    From d07f7f398ef21c3f5eeaf3c6d38d1bb180cc044f Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Tue, 15 Oct 2024 14:00:19 -0700 Subject: [PATCH 16/97] fix(onboarding): icon for YouTube is small (#5156) * fix(onboarding): icon for YouTube is small * fix: use CSS we're not using that var --- .../pages/onboarding/Connect.m.less | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/components-react/pages/onboarding/Connect.m.less b/app/components-react/pages/onboarding/Connect.m.less index 840081166342..7c980e595400 100644 --- a/app/components-react/pages/onboarding/Connect.m.less +++ b/app/components-react/pages/onboarding/Connect.m.less @@ -31,8 +31,9 @@ } @media (max-height: 600px) { - & > h1, - & > p { + + &>h1, + &>p { display: none; } } @@ -56,6 +57,19 @@ display: flex; align-items: center; justify-content: center; + + :global(.button--youtube) { + overflow: hidden; + } + + :global(.youtube) { + max-height: 160px !important; + max-width: 200px !important; + width: 200px !important; + height: 92px !important; + background-size: cover; + background-position-x: -33px; + } } .loginButton { From a39c4677dedc951f6cc008e8fa843b4a70e1f527 Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Tue, 15 Oct 2024 15:03:32 -0700 Subject: [PATCH 17/97] feat(game-capture): enable Steam/overlays in auto-capture (#5157) --- app/services/sources/properties-managers/default-manager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/services/sources/properties-managers/default-manager.ts b/app/services/sources/properties-managers/default-manager.ts index 09d56f9b8f9e..ffb26a58a2b8 100644 --- a/app/services/sources/properties-managers/default-manager.ts +++ b/app/services/sources/properties-managers/default-manager.ts @@ -181,6 +181,7 @@ export class DefaultManager extends PropertiesManager { window_placeholder_image: getSharedResource('capture-placeholder.png'), window_placeholder_waiting_message: $t('Looking for a game to capture'), window_placeholder_missing_message: $t('Specified window is not a game'), + capture_overlays: true, }); } } From be9f2c7703a5944aba11a3fafdc897254868af82 Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Tue, 15 Oct 2024 15:03:41 -0700 Subject: [PATCH 18/97] feat(analytics): track News clicks on Notifications window (#5160) --- .../windows/notifications/News.tsx | 10 +++++++++- app/services/announcements.ts | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/components-react/windows/notifications/News.tsx b/app/components-react/windows/notifications/News.tsx index 7d0331f2076e..95d28f217d8f 100644 --- a/app/components-react/windows/notifications/News.tsx +++ b/app/components-react/windows/notifications/News.tsx @@ -9,7 +9,13 @@ import styles from './News.m.less'; import { useRealmObject } from 'components-react/hooks/realm'; export default function News() { - const { WindowsService, SettingsService, NavigationService, AnnouncementsService } = Services; + const { + WindowsService, + SettingsService, + NavigationService, + AnnouncementsService, + UsageStatisticsService, + } = Services; const newsItems = useRealmObject(AnnouncementsService.currentAnnouncements).news; @@ -23,6 +29,8 @@ export default function News() { function handleClick(item: IAnnouncementsInfo) { return () => { + AnnouncementsService.actions.closeNews(item.id); + if (item.linkTarget === 'slobs') { if (item.link === 'Settings') { SettingsService.showSettings(item.params?.category); diff --git a/app/services/announcements.ts b/app/services/announcements.ts index 96d85550781f..98761fd9030c 100644 --- a/app/services/announcements.ts +++ b/app/services/announcements.ts @@ -258,6 +258,21 @@ export class AnnouncementsService extends Service { } } + async closeNews(newsId: number) { + const endpoint = 'api/v5/slobs/announcement/close'; + const req = this.formRequest(endpoint, { + method: 'POST', + headers: new Headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + clientId: this.userService.getLocalUserId(), + announcementId: newsId, + clickType: 'action', + }), + }); + + return jfetch(req); + } + async closeBanner(clickType: 'action' | 'dismissal') { const endpoint = 'api/v5/slobs/announcement/close'; const req = this.formRequest(endpoint, { From 56bf04d3f56df06777055d38c44b49dc3a3cba7b Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Wed, 16 Oct 2024 11:07:53 -0700 Subject: [PATCH 19/97] feat(build): convenience for packaging build for beta, details follow (#5154) Use `yarn package:beta` to package an app build pointing to beta. * Does not require modifying code anymore. * Does not require user intervention (i.e. setting env vars before running the app). * Executable name is renamed to "Streamlabs OBS for Beta". * Forces local bundles, without it your development branch would likely be overwritten by the updater using remote bundles. * Compiles in development mode so that compilation is faster and we can troubleshoot non-minified logs. * Tested on Windows, Mac PR welcome :) --- app/services/utils.ts | 2 +- electron-builder/beta.config.js | 9 +++++++++ electron-builder/force-local-bundles | 2 ++ package.json | 1 + updater/bundle-updater.ts | 5 +++++ webpack.base.config.js | 25 +++++++++++++++---------- 6 files changed, 33 insertions(+), 11 deletions(-) create mode 100644 electron-builder/beta.config.js create mode 100644 electron-builder/force-local-bundles diff --git a/app/services/utils.ts b/app/services/utils.ts index 6893b150133e..d41df39a581e 100644 --- a/app/services/utils.ts +++ b/app/services/utils.ts @@ -114,7 +114,7 @@ export default class Utils { } static shouldUseBeta(): boolean { - return Utils.env.SLD_USE_BETA as boolean; + return (process.env.SLD_COMPILE_FOR_BETA || Utils.env.SLD_USE_BETA) as boolean; } /** diff --git a/electron-builder/beta.config.js b/electron-builder/beta.config.js new file mode 100644 index 000000000000..fdb148444235 --- /dev/null +++ b/electron-builder/beta.config.js @@ -0,0 +1,9 @@ +const base = require('./base.config'); + +base.win.extraFiles.push({ + from: 'electron-builder/force-local-bundles', + to: 'force-local-bundles', +}); +base.win.executableName = 'Streamlabs OBS for Beta'; + +module.exports = base; diff --git a/electron-builder/force-local-bundles b/electron-builder/force-local-bundles new file mode 100644 index 000000000000..37c0a781d29f --- /dev/null +++ b/electron-builder/force-local-bundles @@ -0,0 +1,2 @@ +This does nothing, the presence of this file means the updater for beta build +will use local bundles. diff --git a/package.json b/package.json index 9d879b2ea72a..ece99634c75b 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "package:mac": "yarn generate-agreement:mac && rimraf dist && electron-builder build -m --x64 --config electron-builder/base.config.js", "package:mac-arm64": "yarn generate-agreement:mac && rimraf dist && electron-builder build -m --arm64 --config electron-builder/base.config.js", "package:preview": "yarn generate-agreement && rimraf dist && electron-builder build -w --x64 --config electron-builder/preview.config.js", + "package:beta": "cross-env SLD_COMPILE_FOR_BETA=1 yarn compile && yarn generate-agreement && rimraf dist && electron-builder build -w --x64 --config electron-builder/beta.config.js", "eslint": "eslint \"{app,guest-api,obs-api,updater}/**/*.ts\" main.js", "test": "tsc -p test && ava -v --timeout=3m ./test-dist/test/regular/**/*.js", "test:file": "tsc -p test && ava -v --timeout=60m", diff --git a/updater/bundle-updater.ts b/updater/bundle-updater.ts index 81653dd81b5a..af67e0beff9d 100644 --- a/updater/bundle-updater.ts +++ b/updater/bundle-updater.ts @@ -299,6 +299,11 @@ module.exports = async (basePath: string) => { useLocalBundles = true; } + const forceLocalBundles = path.join(basePath, '../../force-local-bundles'); + if (fs.existsSync(forceLocalBundles)) { + useLocalBundles = true; + } + const localManifest: IManifest = require(path.join(`${basePath}/bundles/manifest.json`)); console.log('Local bundle info:', localManifest); diff --git a/webpack.base.config.js b/webpack.base.config.js index eb631244455d..a27b06cae4a5 100644 --- a/webpack.base.config.js +++ b/webpack.base.config.js @@ -10,16 +10,21 @@ const plugins = []; const commit = cp.execSync('git rev-parse --short HEAD').toString().replace('\n', ''); -plugins.push( - new webpack.DefinePlugin({ - SLOBS_BUNDLE_ID: JSON.stringify(commit), - SLD_SENTRY_FRONTEND_DSN: JSON.stringify(process.env.SLD_SENTRY_FRONTEND_DSN ?? ''), - SLD_SENTRY_BACKEND_SERVER_URL: JSON.stringify(process.env.SLD_SENTRY_BACKEND_SERVER_URL ?? ''), - SLD_SENTRY_BACKEND_SERVER_PREVIEW_URL: JSON.stringify( - process.env.SLD_SENTRY_BACKEND_SERVER_PREVIEW_URL ?? '', - ), - }), -); +const envDef = { + SLOBS_BUNDLE_ID: JSON.stringify(commit), + SLD_SENTRY_FRONTEND_DSN: JSON.stringify(process.env.SLD_SENTRY_FRONTEND_DSN ?? ''), + SLD_SENTRY_BACKEND_SERVER_URL: JSON.stringify(process.env.SLD_SENTRY_BACKEND_SERVER_URL ?? ''), + SLD_SENTRY_BACKEND_SERVER_PREVIEW_URL: JSON.stringify( + process.env.SLD_SENTRY_BACKEND_SERVER_PREVIEW_URL ?? '', + ), +}; + +if (process.env.SLD_COMPILE_FOR_BETA) { + console.log('Compiling build with forced beta SL host.'); + envDef['process.env.SLD_COMPILE_FOR_BETA'] = JSON.stringify(true); +} + +plugins.push(new webpack.DefinePlugin(envDef)); plugins.push( new WebpackManifestPlugin({ From d2861aeb4cea0c58825f9270075f5befdcbcb56a Mon Sep 17 00:00:00 2001 From: Ava Creeth Date: Wed, 16 Oct 2024 11:52:27 -0700 Subject: [PATCH 20/97] add twitch to scripts allow list (#5162) --- app/services/platform-apps/container-manager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/services/platform-apps/container-manager.ts b/app/services/platform-apps/container-manager.ts index 5ca9bf22c05f..c28a7c46b888 100644 --- a/app/services/platform-apps/container-manager.ts +++ b/app/services/platform-apps/container-manager.ts @@ -332,6 +332,7 @@ export class PlatformContainerManager { 'static.twitchcdn.net', 'www.google.com', 'www.gstatic.com', + 'assets.twitch.tv', ]; const parsed = url.parse(details.url); From 710eaa4a2a0e4951224797b703d54336d2af73bf Mon Sep 17 00:00:00 2001 From: Christopher Date: Thu, 17 Oct 2024 12:07:25 -0700 Subject: [PATCH 21/97] handle external url links on app store (#5164) --- app/components-react/pages/PlatformAppStore.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/components-react/pages/PlatformAppStore.tsx b/app/components-react/pages/PlatformAppStore.tsx index d7014690151b..6df6f0699073 100644 --- a/app/components-react/pages/PlatformAppStore.tsx +++ b/app/components-react/pages/PlatformAppStore.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import Utils from 'services/utils'; +import urlLib from 'url'; import BrowserView from 'components-react/shared/BrowserView'; import { GuestApiHandler } from 'util/guest-api-handler'; import * as remote from '@electron/remote'; @@ -27,6 +28,16 @@ export default function PlatformAppStore(p: { params: { appId?: string; type?: s navigateToApp, }); + view.webContents.setWindowOpenHandler(details => { + const protocol = urlLib.parse(details.url).protocol; + + if (protocol === 'http:' || protocol === 'https:') { + remote.shell.openExternal(details.url); + } + + return { action: 'deny' }; + }); + view.webContents.on('did-finish-load', () => { if (Utils.isDevMode()) { view.webContents.openDevTools(); From 657e7fa6bd4b3f0072cad0c9b647a24fa7654f3b Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Thu, 17 Oct 2024 14:52:00 -0700 Subject: [PATCH 22/97] feat(overlays): preserve audio devices when installing new themes (#5163) * feat(overlays): preserve audio devices when installing new themes * fix: missed an operator --- .../scene-collections/scene-collections.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/app/services/scene-collections/scene-collections.ts b/app/services/scene-collections/scene-collections.ts index 4165b75a6441..71d40390cc57 100644 --- a/app/services/scene-collections/scene-collections.ts +++ b/app/services/scene-collections/scene-collections.ts @@ -391,6 +391,16 @@ export class SceneCollectionsService extends Service implements ISceneCollection */ @RunInLoadingMode() async loadOverlay(filePath: string, name: string) { + // Save the current audio devices for Desktop Audio and Mic so when we + // install a new overlay they're preserved. + // TODO: this only works if the user sources have the default names + + // We always pass a desktop audio device in, since we might've found a bug that + // when installing a new overlay the device is not set and while it seems + // to behave correctly, it is blank on device properties. + const desktopAudioDevice = this.getDeviceIdFor('Desktop Audio') || 'default'; + const micDevice = this.getDeviceIdFor('Mic/Aux'); + await this.deloadCurrentApplicationState(); const id: string = uuid(); @@ -399,7 +409,7 @@ export class SceneCollectionsService extends Service implements ISceneCollection try { await this.overlaysPersistenceService.loadOverlay(filePath); - this.setupDefaultAudio(); + this.setupDefaultAudio(desktopAudioDevice, micDevice); } catch (e: unknown) { // We tried really really hard :( console.error('Overlay installation failed', e); @@ -411,6 +421,10 @@ export class SceneCollectionsService extends Service implements ISceneCollection await this.save(); } + private getDeviceIdFor(sourceName: 'Desktop Audio' | 'Mic/Aux'): string | undefined { + return this.sourcesService.views.getSourcesByName(sourceName)[0]?.getSettings()?.device_id; + } + /** * Based on the provided name, suggest a new name that does * not conflict with any current name. @@ -685,14 +699,14 @@ export class SceneCollectionsService extends Service implements ISceneCollection /** * Creates the default audio sources */ - private setupDefaultAudio() { + private setupDefaultAudio(desktopAudioDevice?: string, micDevice?: string) { // On macOS, most users will not have an audio capture device, so // we do not create it automatically. if (getOS() === OS.Windows) { this.sourcesService.createSource( 'Desktop Audio', byOS({ [OS.Windows]: 'wasapi_output_capture', [OS.Mac]: 'coreaudio_output_capture' }), - {}, + { device_id: desktopAudioDevice }, { channel: E_AUDIO_CHANNELS.OUTPUT_1 }, ); } @@ -703,7 +717,7 @@ export class SceneCollectionsService extends Service implements ISceneCollection this.sourcesService.createSource( 'Mic/Aux', byOS({ [OS.Windows]: 'wasapi_input_capture', [OS.Mac]: 'coreaudio_input_capture' }), - { device_id: defaultId }, + { device_id: micDevice || defaultId }, { channel: E_AUDIO_CHANNELS.INPUT_1 }, ); } From b74cfe2a3ee6415ae45f3347c93e9bf3d012fdf2 Mon Sep 17 00:00:00 2001 From: gettinToasty Date: Fri, 18 Oct 2024 09:56:49 -0700 Subject: [PATCH 23/97] Remove reference to deprecated thumbnail editor (#5166) --- .../windows/go-live/platforms/YoutubeEditStreamInfo.tsx | 7 ------- app/i18n/en-US/youtube.json | 3 +-- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/app/components-react/windows/go-live/platforms/YoutubeEditStreamInfo.tsx b/app/components-react/windows/go-live/platforms/YoutubeEditStreamInfo.tsx index c2428906e43f..63e173b554cf 100644 --- a/app/components-react/windows/go-live/platforms/YoutubeEditStreamInfo.tsx +++ b/app/components-react/windows/go-live/platforms/YoutubeEditStreamInfo.tsx @@ -71,12 +71,6 @@ export const YoutubeEditStreamInfo = InputComponent((p: IPlatformComponentParams }); }, [broadcastId]); - function openThumbnailsEditor() { - remote.shell.openExternal( - 'https://streamlabs.com/dashboard#/prime/thumbnails?refl=slobs-thumbnail-editor', - ); - } - function fieldIsDisabled(fieldName: keyof IYoutubeStartStreamOptions): boolean { // selfDeclaredMadeForKids can be set only on the broadcast creating step if (broadcastId && fieldName === 'selfDeclaredMadeForKids') { @@ -153,7 +147,6 @@ export const YoutubeEditStreamInfo = InputComponent((p: IPlatformComponentParams {$t('Try our new thumbnail editor')}
    } {...bind.thumbnail} /> diff --git a/app/i18n/en-US/youtube.json b/app/i18n/en-US/youtube.json index 90d83682a765..943f07fa39c4 100644 --- a/app/i18n/en-US/youtube.json +++ b/app/i18n/en-US/youtube.json @@ -39,6 +39,5 @@ "DVR controls enable the viewer to control the video playback experience by pausing, rewinding, or fast forwarding content": "DVR controls enable the viewer to control the video playback experience by pausing, rewinding, or fast forwarding content", "Auto-start is disabled for your broadcast. You should manually publish your stream from Youtube Studio": "Auto-start is disabled for your broadcast. You should manually publish your stream from Youtube Studio", "Made for kids": "Made for kids", - "Features like personalized ads and live chat won't be available on live streams made for kids.": "Features like personalized ads and live chat won't be available on live streams made for kids.", - "Try our new thumbnail editor": "Try our new thumbnail editor" + "Features like personalized ads and live chat won't be available on live streams made for kids.": "Features like personalized ads and live chat won't be available on live streams made for kids." } From 2e78c1cb973c5443e2c56ad9ee5b1b05290c6f6e Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Fri, 18 Oct 2024 12:11:30 -0700 Subject: [PATCH 24/97] fix(volmeters): missing bars on app load or device switch (#5165) * fix(volmeters): missing bars on app load or device switch When Desktop Audio (and other sources presumably) haven't played audio we don't get bars in the mixer until some app starts playing audio to it, as a result on many scenarios on app load or source device switch we'd get missing volmeter bars with just the source name which looks janky. I'd like to revisit the rendering of volmeters at some point entirely, since first impressions seems like it's drawing several times a second regardless of whether events are received or not and, in this case, not even rendering at all if none are received, but for now this is a hack that sets a valid peak on every new subscription to ensure we draw at least once. * chore: typo --- .../editor/elements/mixer/GLVolmeters.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/app/components-react/editor/elements/mixer/GLVolmeters.tsx b/app/components-react/editor/elements/mixer/GLVolmeters.tsx index 9d95c7ab7ff5..3123520d2bfd 100644 --- a/app/components-react/editor/elements/mixer/GLVolmeters.tsx +++ b/app/components-react/editor/elements/mixer/GLVolmeters.tsx @@ -163,13 +163,19 @@ class GLVolmetersController { subscription.lastEventTime = performance.now(); }; + const IDLE_PEAK = -60; + const INITIAL_PEAKS = [IDLE_PEAK, IDLE_PEAK]; + // create a subscription object this.subscriptions[sourceId] = { sourceId, // Assume 2 channels until we know otherwise. This prevents too much // visual jank as the volmeters are initializing. channelsCount: 2, - currentPeaks: [], + // HACK: Initialize currentPeaks to an idle-ish peak, if the source + // has never emitted any events, volmeters won't get drawn and we get + // a missing bar on app load or source device switch. + currentPeaks: INITIAL_PEAKS, prevPeaks: [], interpolatedPeaks: [], lastEventTime: 0, @@ -280,14 +286,7 @@ class GLVolmetersController { // Vertex geometry for a unit square // eslint-disable-next-line - const positions = [ - 0, 0, - 0, 1, - 1, 0, - 1, 0, - 0, 1, - 1, 1, - ]; + const positions = [0, 0, 0, 1, 1, 0, 1, 0, 0, 1, 1, 1]; this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(positions), this.gl.STATIC_DRAW); @@ -324,7 +323,10 @@ class GLVolmetersController { private setColorUniform(uniform: string, color: number[]) { const location = this.gl.getUniformLocation(this.program, uniform); // eslint-disable-next-line - this.gl.uniform3fv(location, color.map(c => c / 255)); + this.gl.uniform3fv( + location, + color.map(c => c / 255), + ); } private setCanvasSize() { From cc92d3f88d00dca823040ebee6d38f92cb1c6283 Mon Sep 17 00:00:00 2001 From: gettinToasty Date: Fri, 18 Oct 2024 13:26:22 -0700 Subject: [PATCH 25/97] Recording History QOL (#5167) * Add edit options modal * Add remove function * Fix css * Fix edit text color * Add missing translations --- .../pages/RecordingHistory.m.less | 44 ++++++ .../pages/RecordingHistory.tsx | 137 +++++++++++++++--- app/i18n/en-US/promotional-copy.json | 5 +- app/services/recording-mode.ts | 13 +- 4 files changed, 177 insertions(+), 22 deletions(-) diff --git a/app/components-react/pages/RecordingHistory.m.less b/app/components-react/pages/RecordingHistory.m.less index 218f940d8de2..aeac43c88e28 100644 --- a/app/components-react/pages/RecordingHistory.m.less +++ b/app/components-react/pages/RecordingHistory.m.less @@ -97,3 +97,47 @@ text-decoration: underline; color: var(--paragraph); } + +.edit-cell { + background: var(--background); + height: 220px; + width: 220px; + border-radius: 16px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; + padding: 16px; + text-align: center; + + span { + font-size: 12px; + } + span.edit-title { + font-size: 16; + color: var(--title); + } + + img { + width: 80px; + height: 80px; + padding: 12px; + } + + &:hover { + cursor: pointer; + background: var(--section-alt); + } +} + +.close-icon { + height: 20px; + width: 20px; + position: absolute; + top: 24px; + right: 24px; + + &:hover { + cursor: pointer; + } +} diff --git a/app/components-react/pages/RecordingHistory.tsx b/app/components-react/pages/RecordingHistory.tsx index 2a04e8b41c4e..88365c9cfd36 100644 --- a/app/components-react/pages/RecordingHistory.tsx +++ b/app/components-react/pages/RecordingHistory.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useMemo } from 'react'; +import cx from 'classnames'; import * as remote from '@electron/remote'; import { Tooltip } from 'antd'; import { $t } from 'services/i18n'; @@ -12,6 +13,14 @@ import { Services } from '../service-provider'; import { initStore, useController } from '../hooks/zustand'; import { useVuex } from '../hooks'; import Translate from 'components-react/shared/Translate'; +import { $i } from 'services/utils'; +import { IRecordingEntry } from 'services/recording-mode'; + +interface IRecordingHistoryStore { + showSLIDModal: boolean; + showEditModal: boolean; + fileEdited: IRecordingEntry | null; +} const RecordingHistoryCtx = React.createContext(null); @@ -20,7 +29,11 @@ class RecordingHistoryController { private UserService = Services.UserService; private SharedStorageService = Services.SharedStorageService; private NotificationsService = Services.NotificationsService; - store = initStore({ showSLIDModal: false }); + store = initStore({ + showSLIDModal: false, + showEditModal: false, + fileEdited: null, + }); get recordings() { return this.RecordingModeService.views.sortedRecordings; @@ -41,19 +54,14 @@ class RecordingHistoryController { get uploadOptions() { const opts = [ { - label: $t('Clip'), - value: 'crossclip', - icon: 'icon-editor-7', - }, - { - label: $t('Subtitle'), - value: 'typestudio', - icon: 'icon-mic', + label: $t('Edit'), + value: 'edit', + icon: 'icon-trim', }, { - label: $t('Edit'), - value: 'videoeditor', - icon: 'icon-play-round', + label: '', + value: 'remove', + icon: 'icon-trash', }, ]; if (this.hasYoutube) { @@ -67,6 +75,31 @@ class RecordingHistoryController { return opts; } + get editOptions() { + return [ + { + value: 'videoeditor', + label: 'Video Editor', + description: $t('Edit video professionally from your browser with Video Editor'), + src: 'video-editor.png', + }, + { + value: 'crossclip', + label: 'Cross Clip', + description: $t( + 'Turn your videos into mobile-friendly short-form TikToks, Reels, and Shorts with Cross Clip', + ), + src: 'crossclip.png', + }, + { + value: 'typestudio', + label: 'Podcast Edtior', + description: $t('Polish your videos with text-based and AI powered Podcast Editor'), + src: 'podcast-editor.png', + }, + ]; + } + postError(message: string) { this.NotificationsService.actions.push({ message, @@ -75,14 +108,18 @@ class RecordingHistoryController { }); } - handleSelect(filename: string, platform: string) { + handleSelect(recording: IRecordingEntry, platform: string) { if (this.uploadInfo.uploading) { this.postError($t('Upload already in progress')); return; } - if (platform === 'youtube') return this.uploadToYoutube(filename); + if (platform === 'youtube') return this.uploadToYoutube(recording.filename); + if (platform === 'remove') return this.removeEntry(recording.timestamp); if (this.hasSLID) { - this.uploadToStorage(filename, platform); + this.store.setState(s => { + s.showEditModal = true; + s.fileEdited = recording; + }); } else { this.store.setState(s => { s.showSLIDModal = true; @@ -106,6 +143,10 @@ class RecordingHistoryController { remote.shell.openExternal(this.SharedStorageService.views.getPlatformLink(platform, id)); } + removeEntry(timestamp: string) { + this.RecordingModeService.actions.removeRecordingEntry(timestamp); + } + showFile(filename: string) { remote.shell.showItemInFolder(filename); } @@ -149,15 +190,15 @@ export function RecordingHistory() { Services.SettingsService.actions.showSettings('Hotkeys'); } - function UploadActions(p: { filename: string }) { + function UploadActions(p: { recording: IRecordingEntry }) { return ( {uploadOptions.map(opt => ( handleSelect(p.filename, opt.value)} + style={{ color: `var(--${opt.value === 'edit' ? 'teal' : 'title'})` }} + onClick={() => handleSelect(p.recording, opt.value)} >   @@ -190,17 +231,75 @@ export function RecordingHistory() { {recording.filename} - {uploadOptions.length > 0 && } + {uploadOptions.length > 0 && }
    ))} + {!hasSLID && } ); } +function EditModal() { + const { store, editOptions, uploadToStorage } = useController(RecordingHistoryCtx); + const showEditModal = store.useState(s => s.showEditModal); + const recording = store.useState(s => s.fileEdited); + + function close() { + store.setState(s => { + s.showEditModal = false; + s.fileEdited = null; + }); + } + + function editFile(platform: string) { + if (!recording) throw new Error('File not found'); + + uploadToStorage(recording.filename, platform); + close(); + } + + if (!showEditModal) return <>; + + return ( +
    + + <> +

    {$t('Choose how to edit your recording')}

    + +
    + {editOptions.map(editOpt => ( +
    editFile(editOpt.value)} + > + + {editOpt.label} + {editOpt.description} +
    + ))} +
    + +
    +
    + ); +} + function SLIDModal() { const { store } = useController(RecordingHistoryCtx); const showSLIDModal = store.useState(s => s.showSLIDModal); diff --git a/app/i18n/en-US/promotional-copy.json b/app/i18n/en-US/promotional-copy.json index e551d33fdbdf..712ca1d1b9ab 100644 --- a/app/i18n/en-US/promotional-copy.json +++ b/app/i18n/en-US/promotional-copy.json @@ -71,5 +71,8 @@ "1 Hour Videos + 250GB Storage + More": "1 Hour Videos + 250GB Storage + More", "Highest Profit Margins": "Highest Profit Margins", "%{monthlyPrice}/mo or %{yearlyPrice}/year": "%{monthlyPrice}/mo or %{yearlyPrice}/year", - "Text-based editing of VOD content": "Text-based editing of VOD content" + "Text-based editing of VOD content": "Text-based editing of VOD content", + "Polish your videos with text-based and AI powered Podcast Editor": "Polish your videos with text-based and AI powered Podcast Editor", + "Turn your videos into mobile-friendly short-form TikToks, Reels, and Shorts with Cross Clip": "Turn your videos into mobile-friendly short-form TikToks, Reels, and Shorts with Cross Clip", + "Edit video professionally from your browser with Video Editor": "Edit video professionally from your browser with Video Editor" } diff --git a/app/services/recording-mode.ts b/app/services/recording-mode.ts index d12da9ed7835..fc605b727dc8 100644 --- a/app/services/recording-mode.ts +++ b/app/services/recording-mode.ts @@ -19,7 +19,7 @@ import { getPlatformService } from 'services/platforms'; import { IYoutubeUploadResponse } from 'services/platforms/youtube/uploader'; import { YoutubeService } from 'services/platforms/youtube'; -interface IRecordingEntry { +export interface IRecordingEntry { timestamp: string; filename: string; } @@ -193,10 +193,14 @@ export class RecordingModeService extends PersistentStatefulService = {}; Object.keys(this.state.recordingHistory).forEach(timestamp => { if (moment(timestamp).isAfter(oneMonthAgo)) { prunedEntries[timestamp] = this.state.recordingHistory[timestamp]; @@ -339,6 +343,11 @@ export class RecordingModeService extends PersistentStatefulService) { this.state.recordingHistory = entries; From 7047b726a3c491b15c1dc17e9d4ec6b5c21a1db7 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Fri, 18 Oct 2024 19:06:34 -0400 Subject: [PATCH 26/97] Fix go live checklist icon colors. (#5172) --- .../windows/go-live/GoLiveChecklist.m.less | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/components-react/windows/go-live/GoLiveChecklist.m.less b/app/components-react/windows/go-live/GoLiveChecklist.m.less index 137395800cf1..52a0bb73bf98 100644 --- a/app/components-react/windows/go-live/GoLiveChecklist.m.less +++ b/app/components-react/windows/go-live/GoLiveChecklist.m.less @@ -1,4 +1,4 @@ -@import "../../../styles/index.less"; +@import '../../../styles/index.less'; .container { display: flex; @@ -8,11 +8,10 @@ height: 100%; } - // make timeline icons and text bigger .container :global(.ant-timeline-item-tail) { - top: 15px; - height: calc(100% - 20px); + top: 15px; + height: calc(100% - 20px); } .container :global(.ant-timeline-item) { padding-bottom: 30px; @@ -22,7 +21,7 @@ font-size: 25px; margin-top: 8px; - &:not(:global(.anticon-loading)){ + &:not(.anticon-loading, .anticon-close-circle) { color: var(--primary); // add a bounce animation :global { @@ -40,17 +39,18 @@ position: relative; top: -18px; left: 7px; - :global{ - animation: 0.8s ease-out scaleAndFadeOut + :global { + animation: 0.8s ease-out scaleAndFadeOut; } } } } -.container :global(.ant-timeline .anticon-close-circle) { + +.container :global(.ant-timeline .anticon.anticon-close-circle) { color: var(--red); } -.container :global(.ant-timeline .anticon-loading) { +.container :global(.ant-timeline .anticon.anticon-loading) { color: orange; margin-top: -2px; } @@ -78,7 +78,7 @@ // add animation to the success message .success h1 { - :global{ + :global { animation: 0.8s ease-out bounce; } } From 623ef368c6a5d3f9b5b697fa09a91488e7d81db1 Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Mon, 21 Oct 2024 10:04:28 -0700 Subject: [PATCH 27/97] fix(streaming): loading animation on Go Live window (#5173) --- .../windows/go-live/GoLiveChecklist.m.less | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/components-react/windows/go-live/GoLiveChecklist.m.less b/app/components-react/windows/go-live/GoLiveChecklist.m.less index 52a0bb73bf98..a42fe4763bb0 100644 --- a/app/components-react/windows/go-live/GoLiveChecklist.m.less +++ b/app/components-react/windows/go-live/GoLiveChecklist.m.less @@ -13,6 +13,7 @@ top: 15px; height: calc(100% - 20px); } + .container :global(.ant-timeline-item) { padding-bottom: 30px; } @@ -21,12 +22,14 @@ font-size: 25px; margin-top: 8px; - &:not(.anticon-loading, .anticon-close-circle) { + &:not(:global(.anticon-loading)) { color: var(--primary); + // add a bounce animation :global { animation: 0.8s ease-out bounce; } + // add a fade animation &:after { content: ' '; @@ -39,6 +42,7 @@ position: relative; top: -18px; left: 7px; + :global { animation: 0.8s ease-out scaleAndFadeOut; } @@ -47,11 +51,11 @@ } .container :global(.ant-timeline .anticon.anticon-close-circle) { - color: var(--red); + color: var(--red) !important; } -.container :global(.ant-timeline .anticon.anticon-loading) { - color: orange; +.container :global(.ant-timeline .anticon-loading) { + color: orange !important; margin-top: -2px; } @@ -65,6 +69,7 @@ :global(.ant-timeline-item-content) { color: var(--midtone); } + &.done :global(.ant-timeline-item-content) { color: var(--paragraph); } From a3ff09d553796040ad2a43d6ee397e8f5da941ba Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Mon, 21 Oct 2024 13:58:19 -0700 Subject: [PATCH 28/97] fix(collectibles): only create partner nodes if dual output enabled (#5168) * fix(collectibles): only create partner nodes if dual output enabled Unconditionally creating partner nodes when dual output is disables makes the source appear twice in the editor, due to that vertical node being created. Side effect of this is that if the user adds a collectible and only then enables dual output, they'll probably have to wait till a full app launch for it to show on the vertical display. * fix: schedule creation with new dual output observable * fix: unsubscribe early if we switch scene collections before enabling DO --- app/services/dual-output/dual-output.ts | 2 ++ app/services/scenes/scenes.ts | 21 ++++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/services/dual-output/dual-output.ts b/app/services/dual-output/dual-output.ts index 4ce13e217d10..c2112e4eebc2 100644 --- a/app/services/dual-output/dual-output.ts +++ b/app/services/dual-output/dual-output.ts @@ -330,6 +330,7 @@ export class DualOutputService extends PersistentStatefulService(); collectionHandled = new Subject<{ [sceneId: string]: Dictionary } | null>(); + dualOutputModeChanged = new Subject(); get views() { return new DualOutputViews(this.state); @@ -427,6 +428,7 @@ export class DualOutputService extends PersistentStatefulService { @InitAfter('DualOutputService') export class ScenesService extends StatefulService { @Inject() private dualOutputService: DualOutputService; + @Inject() private sceneCollectionsService: SceneCollectionsService; static initialState: IScenesState = { activeSceneId: '', @@ -455,7 +458,23 @@ export class ScenesService extends StatefulService { const sceneItem = scene.createAndAddSource(sourceName, sourceType, settings); - this.dualOutputService.createPartnerNode(sceneItem); + const createVerticalNode = () => this.dualOutputService.createPartnerNode(sceneItem); + + if (this.dualOutputService.state.dualOutputMode) { + createVerticalNode(); + } else { + // Schedule vertical node to be created if the user toggles on dual output in the same session + this.dualOutputService.dualOutputModeChanged + .pipe( + // If we switch collections before we enable dual output drop it + // we don't wanna create nodes on inactive scene collections + takeUntil(this.sceneCollectionsService.collectionWillSwitch), + filter(gotEnabled => !!gotEnabled), + take(1), + tap(createVerticalNode), + ) + .subscribe(); + } return sceneItem.sceneItemId; } From 5ad555c19d5df2bdb125d493c9cc91ac3a17146a Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Mon, 21 Oct 2024 15:52:53 -0700 Subject: [PATCH 29/97] fix(onboarding): remove Tips from type (#5174) --- app/services/onboarding.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/services/onboarding.ts b/app/services/onboarding.ts index 950eb6c2de54..f85d2964ae9a 100644 --- a/app/services/onboarding.ts +++ b/app/services/onboarding.ts @@ -143,8 +143,7 @@ export interface IOnboardingStep { | 'HardwareSetup' | 'ThemeSelector' | 'Optimize' - | 'Prime' - | 'Tips'; + | 'Prime'; hideButton?: boolean; label?: string; isPreboarding?: boolean; From 618eccf1e44d15e5fd029720518f908062d37f52 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Mon, 21 Oct 2024 18:53:06 -0400 Subject: [PATCH 30/97] Fix streamlabels line break. (#5171) * Prevent undefined or null error when updating sources. * Fix for stream labels line break. * Fix display render issue. --- app/components-react/windows/SourceProperties.tsx | 7 ++++++- app/services/sources/sources.ts | 3 +++ app/services/streamlabels/index.ts | 10 +++++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/components-react/windows/SourceProperties.tsx b/app/components-react/windows/SourceProperties.tsx index de67be074e28..1c2b4b778304 100644 --- a/app/components-react/windows/SourceProperties.tsx +++ b/app/components-react/windows/SourceProperties.tsx @@ -62,7 +62,12 @@ export default function SourceProperties() { return ( } + fixedChild={ + source && + !hideStyleBlockers && ( + + ) + } > { private UPDATE_SOURCE(sourcePatch: TPatch) { if (this.state.sources[sourcePatch.id]) { Object.assign(this.state.sources[sourcePatch.id], sourcePatch); + } else if (this.state.temporarySources[sourcePatch.id]) { + Object.assign(this.state.temporarySources[sourcePatch.id], sourcePatch); } else { + this.state.temporarySources[sourcePatch.id] = {} as ISource; Object.assign(this.state.temporarySources[sourcePatch.id], sourcePatch); } } diff --git a/app/services/streamlabels/index.ts b/app/services/streamlabels/index.ts index 78310b574757..ad8d955a3f91 100644 --- a/app/services/streamlabels/index.ts +++ b/app/services/streamlabels/index.ts @@ -248,6 +248,10 @@ export class StreamlabelsService extends StatefulService { if (settings.format) { - settings.format = this.unescapeNewline(settings.format); + settings.format = this.escapeNewline(settings.format); + } + + if (settings.item_separator) { + settings.item_separator = this.unescapeNewline(settings.item_separator); } this.settings[statname] = { From 09e85ad7b909dfd001f506f1883430ce3ca59e3b Mon Sep 17 00:00:00 2001 From: gettinToasty Date: Mon, 21 Oct 2024 16:22:26 -0700 Subject: [PATCH 31/97] Update Remote Control API (#5087) * Build connected devices out * Change host to staging * Fix toggle logic * Init tcp service earilier * Add http to link * Add modules to url * Add proper value to toggle input * Add message listener * Add message emit * Fix disconnect device * Add callbacks to event emitters * Port remote control api to its own service * Add logging to event listening and emitting * Return response in callback * Revert tcp-server changes * Use callback argument * Add event message sending and attempt deviceConnected fix * Fix disconnect, add login guard clause * Switch toggle to horizontal layout * Add disconnect handler * Change host * Improve UI --------- Co-authored-by: gettinToasty --- app/app-services.ts | 3 + .../windows/settings/RemoteControl.m.less | 22 ++ .../windows/settings/RemoteControl.tsx | 170 +++-------- app/i18n/en-US/remote-control.json | 6 +- app/services/api/remote-control-api.ts | 266 ++++++++++++++++++ app/services/api/tcp-server/tcp-server.ts | 1 - 6 files changed, 342 insertions(+), 126 deletions(-) create mode 100644 app/components-react/windows/settings/RemoteControl.m.less create mode 100644 app/services/api/remote-control-api.ts diff --git a/app/app-services.ts b/app/app-services.ts index 66054c92f724..e3513c8c0d2b 100644 --- a/app/app-services.ts +++ b/app/app-services.ts @@ -81,6 +81,7 @@ export { InstagramService } from 'services/platforms/instagram'; export { UsageStatisticsService } from './services/usage-statistics'; export { GameOverlayService } from 'services/game-overlay'; export { SharedStorageService } from 'services/integrations/shared-storage'; +export { RemoteControlService } from 'services/api/remote-control-api'; export { MediaGalleryService } from 'services/media-gallery'; export { MediaBackupService } from 'services/media-backup'; @@ -205,6 +206,7 @@ import { SharedStorageService } from 'services/integrations/shared-storage'; import { RealmService } from 'services/realm'; import { InstagramService } from 'services/platforms/instagram'; import { TwitchStudioImporterService } from 'services/ts-importer'; +import { RemoteControlService } from 'services/api/remote-control-api'; import { UrlService } from 'services/hosts'; export const AppServices = { @@ -284,5 +286,6 @@ export const AppServices = { MarkersService, SharedStorageService, RealmService, + RemoteControlService, UrlService, }; diff --git a/app/components-react/windows/settings/RemoteControl.m.less b/app/components-react/windows/settings/RemoteControl.m.less new file mode 100644 index 000000000000..9fc71b2baa42 --- /dev/null +++ b/app/components-react/windows/settings/RemoteControl.m.less @@ -0,0 +1,22 @@ + +.device { + padding: 8px; + display: flex; + justify-content: space-between; + border-radius: 4px; +} + +.device:nth-child(even) { + background-color: var(--background); +} + +.disconnect { + color: var(--red); + text-decoration: underline; +} + +.whisper { + font-style: italic; + opacity: 0.7; + display: block; +} diff --git a/app/components-react/windows/settings/RemoteControl.tsx b/app/components-react/windows/settings/RemoteControl.tsx index 71a1bca1e694..f0914cfa1a70 100644 --- a/app/components-react/windows/settings/RemoteControl.tsx +++ b/app/components-react/windows/settings/RemoteControl.tsx @@ -1,148 +1,70 @@ -import React, { CSSProperties } from 'react'; +import React from 'react'; import { ObsSettingsSection } from './ObsSettings'; import { $t } from '../../../services/i18n'; -import QRCode from 'qrcode.react'; import { Services } from '../../service-provider'; -import Form from '../../shared/inputs/Form'; -import { TextInput } from '../../shared/inputs'; -import { Button, Col, Row, Space } from 'antd'; -import Utils from '../../../services/utils'; -import { injectState, mutation, useModule } from 'slap'; +import { SwitchInput } from '../../shared/inputs'; +import { IConnectedDevice } from 'services/api/remote-control-api'; +import styles from './RemoteControl.m.less'; +import { useRealmObject } from 'components-react/hooks/realm'; +import { useVuex } from 'components-react/hooks'; -const QRCODE_SIZE = 350; - -class RemoteControlModule { - state = injectState({ - qrcodeIsVisible: false, - detailsIsVisible: false, - qrCodeData: { - addresses: [] as string[], - port: 0, - token: '', - version: '', - }, - }); - - private updateNetworkInterval: number; - - init() { - this.refreshQrcodeData(); - this.updateNetworkInterval = window.setInterval(() => this.refreshQrcodeData(), 1000); - } - - destroy() { - clearInterval(this.updateNetworkInterval); - } - - get qrCodeValue() { - if (!this.state.qrcodeIsVisible) return 'This is totally fake data'; - const encodedData = encodeURIComponent(JSON.stringify(this.state.qrCodeData)); - return `https://streamlabs.page.link/?link=https://streamlabs.com/mobile-app&data=${encodedData}&apn=com.streamlabs.slobsrc&isi=1476615877&ibi=com.streamlabs.slobsrc&utm_source=slobs`; - } - - private get TcpServerService() { - return Services.TcpServerService; - } +export function RemoteControlSettings() { + const { RemoteControlService, UserService } = Services; - showQrCode() { - this.TcpServerService.enableWebsoketsRemoteConnections(); - this.state.setQrcodeIsVisible(true); - } + const connectedDevices = useRealmObject(RemoteControlService.connectedDevices).devices; + const enabled = useRealmObject(RemoteControlService.state).enabled; - @mutation() - showDetails() { - this.state.detailsIsVisible = true; - } + const { isLoggedIn } = useVuex(() => ({ isLoggedIn: UserService.views.isLoggedIn })); - generateToken() { - this.TcpServerService.actions.generateToken(); + function handleToggle() { + if (enabled) { + RemoteControlService.actions.disconnect(); + } else { + RemoteControlService.actions.createStreamlabsRemoteConnection(); + } } - private refreshQrcodeData() { - const settings = this.TcpServerService.state; - const addresses = this.TcpServerService.getIPAddresses() - .filter(address => !address.internal) - .map(address => address.address); - - this.state.setQrCodeData({ - addresses, - token: settings.token, - port: settings.websockets.port, - version: Utils.env.SLOBS_VERSION, - }); + function disconnectDevice(device: IConnectedDevice) { + RemoteControlService.actions.disconnectDevice(device.socketId); } -} - -export function RemoteControlSettings() { - const { - qrcodeIsVisible, - detailsIsVisible, - qrCodeData, - qrCodeValue, - showQrCode, - showDetails, - generateToken, - } = useModule(RemoteControlModule); - - const colStyle: CSSProperties = { - width: `${QRCODE_SIZE}px`, - height: `${QRCODE_SIZE}px`, - backgroundColor: 'white', - fontSize: '20px', - display: 'flex', - alignItems: 'center', - textAlign: 'center', - marginBottom: '16px', - }; - - const qrStyles: React.CSSProperties = qrcodeIsVisible - ? {} - : { filter: 'blur(10px)', position: 'absolute', clip: 'rect(5px, 345px, 345px, 5px)' }; return (
    {$t( - 'The free Streamlabs Controller app allows you to control Streamlabs Desktop from your iOS or Android device. Scan the QR code below to begin.', + 'The free Streamlabs Controller app allows you to control Streamlabs Desktop from your iOS or Android device. You must be logged in to use this feature.', )}

    - - - - {!qrcodeIsVisible && ( - - {$t("Don't show this code on stream. Click to reveal")} - - )} - - - - {qrcodeIsVisible && ( - - )} - - {qrcodeIsVisible && detailsIsVisible && ( -
    - {/* TODO: use password input */} - {$t('Generate new')}} +
    + {isLoggedIn && ( + - - - - )} + )} + + {enabled && ( +
    + {$t('Connected Devices')} + {connectedDevices.length < 1 && ( + {$t('No devices connected')} + )} + {connectedDevices.map(device => ( +
    + {device.deviceName} + disconnectDevice(device)}> + {$t('Disconnect')} + +
    + ))} +
    + )} +
    ); } diff --git a/app/i18n/en-US/remote-control.json b/app/i18n/en-US/remote-control.json index 7ac768e3d39c..6b6973294d76 100644 --- a/app/i18n/en-US/remote-control.json +++ b/app/i18n/en-US/remote-control.json @@ -6,5 +6,9 @@ "Port": "Port", "IP addresses": "IP addresses", "Click to show": "Click to show", - "The free Streamlabs Controller app allows you to control Streamlabs Desktop from your iOS or Android device. Scan the QR code below to begin.": "The free Streamlabs Controller app allows you to control Streamlabs Desktop from your iOS or Android device. Scan the QR code below to begin." + "The free Streamlabs Controller app allows you to control Streamlabs Desktop from your iOS or Android device. You must be logged in to use this feature.": "The free Streamlabs Controller app allows you to control Streamlabs Desktop from your iOS or Android device. You must be logged in to use this feature.", + "Allow remote connections": "Allow remote connections", + "Connected Devices": "Connected Devices", + "Disconnect": "Disconnect", + "No devices connected": "No devices connected" } diff --git a/app/services/api/remote-control-api.ts b/app/services/api/remote-control-api.ts new file mode 100644 index 000000000000..03bf3765c872 --- /dev/null +++ b/app/services/api/remote-control-api.ts @@ -0,0 +1,266 @@ +import os from 'os'; +import { authorizedHeaders, jfetch } from 'util/requests'; +import { importSocketIOClient } from 'util/slow-imports'; +import { ObjectSchema } from 'realm'; +import { InitAfter, Inject, Service } from 'services/core'; +import { RealmObject } from 'services/realm'; +import { ExternalApiService, HostsService, UserService } from 'app-services'; +import { + JsonrpcService, + E_JSON_RPC_ERROR, + IJsonRpcRequest, + IJsonRpcResponse, + IJsonRpcEvent, +} from 'services/api/jsonrpc/index'; + +export interface IConnectedDevice { + socketId: string; + deviceName: string; + clientType: string; +} + +interface ISLRemoteResponse { + success: boolean; + message: 'OK'; + data: { + url: string; + token: string; + }; +} + +class ConnectedDevice extends RealmObject { + socketId: string; + deviceName: string; + clientType: string; + + static schema: ObjectSchema = { + name: 'ConnectedDevice', + embedded: true, + properties: { + socketId: 'string', + deviceName: 'string', + clientType: 'string', + }, + }; +} + +ConnectedDevice.register(); + +class RemoteControlEphemeralState extends RealmObject { + devices: IConnectedDevice[]; + + static schema: ObjectSchema = { + name: 'RemoteControlEphemeralState', + properties: { + devices: { + type: 'list', + objectType: 'ConnectedDevice', + default: [] as ConnectedDevice[], + }, + }, + }; +} + +RemoteControlEphemeralState.register(); + +class RemoteControlPresistentState extends RealmObject { + enabled: boolean; + + static schema: ObjectSchema = { + name: 'RemoteControlPersistentState', + properties: { + enabled: { type: 'bool', default: false }, + }, + }; +} + +RemoteControlPresistentState.register({ persist: true }); + +@InitAfter('UserService') +export class RemoteControlService extends Service { + @Inject() hostsService: HostsService; + @Inject() userService: UserService; + @Inject() externalApiService: ExternalApiService; + @Inject() jsonRpcService: JsonrpcService; + + state = RemoteControlPresistentState.inject(); + connectedDevices = RemoteControlEphemeralState.inject(); + + socket: SocketIOClient.Socket; + + init() { + super.init(); + this.userService.userLogin.subscribe(() => { + if (this.state.enabled) this.createStreamlabsRemoteConnection(); + }); + this.externalApiService.serviceEvent.subscribe(event => { + this.sendMessage(event); + }); + } + + disconnect() { + this.setEnableRemoteConnection(false); + this.socket.disconnect(); + this.socket = undefined; + this.setConnectedDevices([]); + } + + disconnectDevice(socketId: string) { + if (this.socket) { + this.socket.emit('disconnectDevice', { socketId }, (response: any) => { + if (!response.error) { + this.removeConnectedDevice(socketId); + } + }); + } + } + + async createStreamlabsRemoteConnection() { + if (!this.userService.isLoggedIn) return; + this.setEnableRemoteConnection(true); + const io = await importSocketIOClient(); + const url = `https://${ + this.hostsService.streamlabs + }/api/v5/slobs/modules/mobile-remote-io/config?device_name=${os.hostname()}`; + const headers = authorizedHeaders(this.userService.apiToken); + + const resp: ISLRemoteResponse = await jfetch(new Request(url, { headers })); + if (resp.success) { + const socket = io.default(`${resp.data.url}?token=${resp.data.token}`, { + transports: ['websocket'], + }); + + socket.emit('getDevices', {}, (devices: IConnectedDevice[]) => { + this.setConnectedDevices(devices); + }); + + this.socket = socket; + this.listen(); + } + } + + listen() { + if (this.socket) { + this.socket.on('message', (data: Buffer, callback: Function) => { + const response = this.requestHandler(data.toString()); + callback(this.formatEvent(response)); + }); + + this.socket.on('deviceConnected', (device: IConnectedDevice) => { + const devices = this.connectedDevices.devices; + if (devices.find(d => d.socketId === device.socketId)) return; + this.setConnectedDevices(devices.concat([device])); + }); + + this.socket.on('deviceDisconnected', (device: IConnectedDevice) => { + this.removeConnectedDevice(device.socketId); + }); + + this.socket.on('error', (e: unknown) => { + throw e; + }); + + this.socket.on('disconnect', (reason: string) => { + if (reason !== 'io client disconnect') { + this.createStreamlabsRemoteConnection(); + } + }); + } + } + + sendMessage(event: IJsonRpcResponse) { + if (this.socket) { + try { + this.socket.emit('message', this.formatEvent(event), (response: any) => { + if (response.error) throw response.error; + }); + } catch (e: unknown) { + console.error('Unable to send message', e); + } + } + } + + private requestHandler(data: string) { + const requests = data.split('\n'); + + for (const requestString of requests) { + if (!requestString) return; + try { + const request: IJsonRpcRequest = JSON.parse(requestString); + + const errorMessage = this.validateRequest(request); + + if (errorMessage) { + const errorResponse = this.jsonRpcService.createError(request, { + code: E_JSON_RPC_ERROR.INVALID_PARAMS, + message: errorMessage, + }); + return errorResponse; + } + + // Prevent access to certain particularly sensitive services + const protectedResources = ['FileManagerService']; + + if (protectedResources.includes(request.params.resource)) { + const err = this.jsonRpcService.createError(request, { + code: E_JSON_RPC_ERROR.INTERNAL_JSON_RPC_ERROR, + message: 'The requested resource is not available.', + }); + return err; + } + + const response = this.externalApiService.executeServiceRequest(request); + + return response; + } catch (e: unknown) { + const errorResponse = this.jsonRpcService.createError(null, { + code: E_JSON_RPC_ERROR.INVALID_REQUEST, + message: + 'Make sure that the request is valid json. ' + + 'If request string contains multiple requests, ensure requests are separated ' + + 'by a single newline character LF ( ASCII code 10)', + }); + + // Disconnect and stop processing requests + // IMPORTANT: For security reasons it is important we immediately stop + // processing requests that don't look will well formed JSON RPC calls. + // Without this check, it is possible to send normal HTTP requests + // from an unprivileged web page and make calls to this API. + this.disconnect(); + return errorResponse; + } + } + } + + private formatEvent(event: IJsonRpcResponse) { + return `${JSON.stringify(event)}\n`; + } + + private validateRequest(request: IJsonRpcRequest): string { + let message = ''; + if (!request.id) message += ' id is required;'; + if (!request.params) message += ' params is required;'; + if (request.params && !request.params.resource) message += ' resource is required;'; + return message; + } + + setEnableRemoteConnection(val: boolean) { + this.state.db.write(() => { + this.state.enabled = val; + }); + } + + setConnectedDevices(devices: IConnectedDevice[]) { + this.connectedDevices.db.write(() => { + this.connectedDevices.devices = devices.filter(device => device.deviceName !== os.hostname()); + }); + } + + removeConnectedDevice(socketId: string) { + this.connectedDevices.db.write(() => { + this.connectedDevices.devices = this.connectedDevices.devices.filter( + d => d.socketId !== socketId, + ); + }); + } +} diff --git a/app/services/api/tcp-server/tcp-server.ts b/app/services/api/tcp-server/tcp-server.ts index 5544667efc03..d79bd608be92 100644 --- a/app/services/api/tcp-server/tcp-server.ts +++ b/app/services/api/tcp-server/tcp-server.ts @@ -13,7 +13,6 @@ import { import { IIPAddressDescription, ITcpServerServiceApi, ITcpServersSettings } from './tcp-server-api'; import { UsageStatisticsService } from 'services/usage-statistics'; import { ExternalApiService } from '../external-api'; -import { SceneCollectionsService } from 'services/scene-collections'; // eslint-disable-next-line no-undef import WritableStream = NodeJS.WritableStream; import { $t } from 'services/i18n'; From 472c4b580622b6deca53324c4425e0ea5a709ec2 Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Tue, 22 Oct 2024 15:55:18 -0700 Subject: [PATCH 32/97] feat(editor): less dense source and scene items (#5178) * feat(editor): less dense source and scene items * fix(editor): icon type issues * refactor(editor): flex --- .../editor/elements/SceneSelector.m.less | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/app/components-react/editor/elements/SceneSelector.m.less b/app/components-react/editor/elements/SceneSelector.m.less index 1996a7ff3fae..35f55feda9c0 100644 --- a/app/components-react/editor/elements/SceneSelector.m.less +++ b/app/components-react/editor/elements/SceneSelector.m.less @@ -6,7 +6,7 @@ align-items: center; justify-content: flex-end; - > div:last-child { + >div:last-child { top: 44px !important; height: calc(100% - 44px); } @@ -34,7 +34,7 @@ } :global(.no-top-padding) { - .top-container > div:last-child { + .top-container>div:last-child { top: 32px !important; height: calc(100% - 32px); } @@ -99,9 +99,15 @@ border-radius: 0 !important; } + :global(.ant-tree-treenode) { + padding: 0 !important; + } + :global(.ant-tree-node-content-wrapper) { padding-left: 16px !important; display: flex; + padding-top: 4px !important; + padding-bottom: 4px !important; } :global(.ant-tree-node-selected) { @@ -165,7 +171,7 @@ display: flex; align-items: center; - > i { + >i { margin-right: 8px; opacity: 0; } @@ -196,7 +202,8 @@ .sources-container { :global(.ant-tree-switcher) { width: 0; - display: block; + display: flex; + align-items: center; z-index: 1; left: 10px; } @@ -204,6 +211,12 @@ :global(.ant-tree-node-content-wrapper) { padding-left: 32px !important; display: flex; + padding-top: 4px !important; + padding-bottom: 4px !important; + } + + :global(.ant-tree-treenode) { + padding: 0 !important; } :global(.ant-tree-title) { @@ -216,9 +229,16 @@ &::before { content: '\f07c'; } + color: var(--title); } } + + :global(.ant-tree-switcher i) { + vertical-align: middle; + // Icomoon icons have needed this all along + display: inline-block; + } } .tree-mask { @@ -231,6 +251,7 @@ .toggle-error { padding: 0px !important; text-align: unset !important; + :global(.ant-message-notice-content) { padding: 4px 16px; } From fe3ac4b94c1e2af643a3ea3a5ce665f343cbe768 Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Tue, 22 Oct 2024 15:55:28 -0700 Subject: [PATCH 33/97] fix(sources): icons missing for MacOS sources (#5179) --- app/services/sources/sources-data.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/services/sources/sources-data.ts b/app/services/sources/sources-data.ts index 5d85854156ce..4599313ca388 100644 --- a/app/services/sources/sources-data.ts +++ b/app/services/sources/sources-data.ts @@ -181,7 +181,7 @@ export const SourceDisplayData = (): { [key: string]: ISourceDisplayData } => ({ vlc_source: { name: $t('VLC Source'), description: $t('Add playlists of videos to your scene.'), - icon: 'fas fa-file', + icon: 'fas fa-play', }, coreaudio_input_capture: { name: $t('Audio Input Capture'), @@ -190,7 +190,7 @@ export const SourceDisplayData = (): { [key: string]: ISourceDisplayData } => ({ ), demoFilename: 'audio-input.png', supportList: [$t('Built in microphones'), $t('USB microphones'), $t('Other USB devices')], - icon: 'fas fa-file', + icon: 'icon-mic', }, coreaudio_output_capture: { name: $t('Audio Output Capture'), @@ -199,7 +199,7 @@ export const SourceDisplayData = (): { [key: string]: ISourceDisplayData } => ({ ), demoFilename: 'audio-output.png', supportList: [$t('Desktop audio')], - icon: 'fas fa-file', + icon: 'icon-audio', }, av_capture_input: { name: $t('Video Capture Device'), @@ -210,7 +210,7 @@ export const SourceDisplayData = (): { [key: string]: ISourceDisplayData } => ({ $t('Logitech webcam'), $t('Capture cards (Elgato, Avermedia, BlackMagic)'), ], - icon: 'fas fa-file', + icon: 'icon-webcam', }, display_capture: { name: $t('Display Capture'), @@ -224,7 +224,7 @@ export const SourceDisplayData = (): { [key: string]: ISourceDisplayData } => ({ description: $t("Capture a game you're playing on your computer."), demoFilename: 'game-capture.png', supportList: [$t('Built in works with most modern computer games')], - icon: 'fas fa-file', + icon: 'fas fa-gamepad', }, audio_line: { name: $t('JACK Input Client'), @@ -242,7 +242,7 @@ export const SourceDisplayData = (): { [key: string]: ISourceDisplayData } => ({ name: $t('Instant Replay'), description: $t('Automatically plays your most recently captured replay in your stream.'), demoFilename: 'media.png', - icon: 'fas fa-file', + icon: 'icon-replay-buffer', }, icon_library: { name: $t('Custom Icon'), From d8649be365577b39496de9344f02f9c61ed6006c Mon Sep 17 00:00:00 2001 From: gettinToasty Date: Thu, 24 Oct 2024 16:20:04 -0700 Subject: [PATCH 34/97] Fix onboarding test (#5185) --- test/helpers/modules/core.ts | 5 +++++ test/regular/onboarding.ts | 38 ++++++++++++++++++------------------ 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/test/helpers/modules/core.ts b/test/helpers/modules/core.ts index 977d2fbc9ffa..b2c0b493e73a 100644 --- a/test/helpers/modules/core.ts +++ b/test/helpers/modules/core.ts @@ -50,6 +50,11 @@ export async function clickIfDisplayed(selectorOrEl: TSelectorOrEl) { } } +export async function clickWhenDisplayed(selectorOrEl: TSelectorOrEl, options?: WaitForOptions) { + await waitForDisplayed(selectorOrEl, options); + await click(selectorOrEl); +} + export async function clickText(text: string) { await (await select(`*=${text}`)).click(); } diff --git a/test/regular/onboarding.ts b/test/regular/onboarding.ts index 2c71fb9166fb..4c9d8dfc6fbd 100644 --- a/test/regular/onboarding.ts +++ b/test/regular/onboarding.ts @@ -1,5 +1,5 @@ import { test, useWebdriver } from '../helpers/webdriver'; -import { logIn } from '../helpers/webdriver/user'; +import { logIn, withPoolUser } from '../helpers/webdriver/user'; import { sleep } from '../helpers/sleep'; import { click, @@ -7,6 +7,7 @@ import { focusMain, isDisplayed, waitForDisplayed, + clickWhenDisplayed, } from '../helpers/modules/core'; // not a react hook @@ -52,6 +53,7 @@ test('Go through onboarding login and signup', async t => { test('Go through onboarding', async t => { const app = t.context.app; + await focusMain(); if (!(await isDisplayed('h2=Live Streaming'))) return; @@ -63,32 +65,30 @@ test('Go through onboarding', async t => { await click('a=Login'); await isDisplayed('button=Log in with Twitch'); - await logIn(t, 'twitch', { prime: false }, false, true); - await sleep(1000); + const user = await logIn(t, 'twitch', { prime: false }, false, true); + await sleep(1000); // We seem to skip the login step after login internally await clickIfDisplayed('button=Skip'); - // Don't Import from OBS - await clickIfDisplayed('div=Start Fresh'); + // Finish onboarding flow + await withPoolUser(user, async () => { + // Skip hardware config + await waitForDisplayed('h1=Set up your mic & webcam'); + await clickIfDisplayed('button=Skip'); - // Skip hardware config - await waitForDisplayed('h1=Set up your mic & webcam'); - await clickIfDisplayed('button=Skip'); + // Skip picking a theme + await waitForDisplayed('h1=Add your first theme'); + await clickIfDisplayed('button=Skip'); - // Skip picking a theme - await waitForDisplayed('h1=Add an Overlay'); - await clickIfDisplayed('button=Skip'); + // Skip purchasing prime + await clickWhenDisplayed('div[data-testid=choose-free-plan-btn]', { timeout: 60000 }); - // Skip purchasing prime - // TODO: is this timeout because of autoconfig? - await waitForDisplayed('div[data-testid=choose-free-plan-btn]', { timeout: 60000 }); - await click('div=[data-testid=choose-free-plan-btn]'); + await waitForDisplayed('span=Sources', { timeout: 60000 }); - await waitForDisplayed('span=Sources', { timeout: 60000 }); - - // success? - t.true(await (await app.client.$('span=Sources')).isDisplayed(), 'Sources selector is visible'); + // editor successfully loaded + t.true(await (await app.client.$('span=Sources')).isDisplayed(), 'Sources selector is visible'); + }); }); // TODO: this test is the same as beginner except with autoconfig, make specific assertions here once re-enabled From dcb9be80f86eb8f2e52a18a07d9d700a1d02e68c Mon Sep 17 00:00:00 2001 From: gettinToasty Date: Fri, 25 Oct 2024 09:22:59 -0700 Subject: [PATCH 35/97] Add section to RemoteControl for third party use (#5182) * Add section to RemoteControl for third party use * Fix token --- .../windows/settings/RemoteControl.m.less | 6 + .../windows/settings/RemoteControl.tsx | 136 +++++++++++++----- app/i18n/en-US/remote-control.json | 12 +- app/services/api/tcp-server/tcp-server.ts | 13 ++ 4 files changed, 126 insertions(+), 41 deletions(-) diff --git a/app/components-react/windows/settings/RemoteControl.m.less b/app/components-react/windows/settings/RemoteControl.m.less index 9fc71b2baa42..bd275449a0ed 100644 --- a/app/components-react/windows/settings/RemoteControl.m.less +++ b/app/components-react/windows/settings/RemoteControl.m.less @@ -20,3 +20,9 @@ opacity: 0.7; display: block; } + +.websockets-form { + input { + cursor: text !important; + } +} diff --git a/app/components-react/windows/settings/RemoteControl.tsx b/app/components-react/windows/settings/RemoteControl.tsx index f0914cfa1a70..e8f5022c818d 100644 --- a/app/components-react/windows/settings/RemoteControl.tsx +++ b/app/components-react/windows/settings/RemoteControl.tsx @@ -1,20 +1,26 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { Button } from 'antd'; import { ObsSettingsSection } from './ObsSettings'; import { $t } from '../../../services/i18n'; import { Services } from '../../service-provider'; -import { SwitchInput } from '../../shared/inputs'; +import { SwitchInput, TextInput } from '../../shared/inputs'; import { IConnectedDevice } from 'services/api/remote-control-api'; import styles from './RemoteControl.m.less'; import { useRealmObject } from 'components-react/hooks/realm'; import { useVuex } from 'components-react/hooks'; export function RemoteControlSettings() { - const { RemoteControlService, UserService } = Services; + const { RemoteControlService, UserService, TcpServerService } = Services; const connectedDevices = useRealmObject(RemoteControlService.connectedDevices).devices; const enabled = useRealmObject(RemoteControlService.state).enabled; - const { isLoggedIn } = useVuex(() => ({ isLoggedIn: UserService.views.isLoggedIn })); + const { isLoggedIn, websocketsEnabled, token, port } = useVuex(() => ({ + isLoggedIn: UserService.views.isLoggedIn, + websocketsEnabled: TcpServerService.state.websockets.enabled, + token: TcpServerService.state.token, + port: TcpServerService.state.websockets.port, + })); function handleToggle() { if (enabled) { @@ -24,48 +30,104 @@ export function RemoteControlSettings() { } } + function handleSocket() { + if (websocketsEnabled) { + TcpServerService.actions.disableWebsocketsRemoteConnections(); + } else { + TcpServerService.actions.enableWebsoketsRemoteConnections(); + } + } + + function getIPAddresses() { + return TcpServerService.getIPAddresses() + .filter(address => !address.internal) + .map(address => address.address) + .join(', '); + } + + function generateToken() { + TcpServerService.actions.generateToken(); + } + function disconnectDevice(device: IConnectedDevice) { RemoteControlService.actions.disconnectDevice(device.socketId); } return ( - -
    - {$t( - 'The free Streamlabs Controller app allows you to control Streamlabs Desktop from your iOS or Android device. You must be logged in to use this feature.', - )} -
    -
    -
    + <> + +
    + {$t( + 'The free Streamlabs Controller app allows you to control Streamlabs Desktop from your iOS or Android device. You must be logged in to use this feature.', + )} +
    +
    +
    -
    - {isLoggedIn && ( +
    + {isLoggedIn && ( + + )} + + {enabled && ( +
    + {$t('Connected Devices')} + {connectedDevices.length < 1 && ( + {$t('No devices connected')} + )} + {connectedDevices.map(device => ( +
    + {device.deviceName} + disconnectDevice(device)}> + {$t('Disconnect')} + +
    + ))} +
    + )} +
    + + +
    + {$t( + 'Some third party applications connect to Streamlabs Desktop via websockets connection. Toggle this to allow such connections and display connection info.', + )} +
    + + +   + {$t('Warning: Displaying this portion on stream may leak sensitive information.')} + +
    +
    +
    +
    - )} - - {enabled && ( -
    - {$t('Connected Devices')} - {connectedDevices.length < 1 && ( - {$t('No devices connected')} - )} - {connectedDevices.map(device => ( -
    - {device.deviceName} - disconnectDevice(device)}> - {$t('Disconnect')} - -
    - ))} -
    - )} -
    -
    + {websocketsEnabled && ( +
    + + + {$t('Generate new')}} + /> +
    + )} +
    +
    + ); } diff --git a/app/i18n/en-US/remote-control.json b/app/i18n/en-US/remote-control.json index 6b6973294d76..3dc43ee3130a 100644 --- a/app/i18n/en-US/remote-control.json +++ b/app/i18n/en-US/remote-control.json @@ -1,14 +1,18 @@ { "Show details": "Show details", "Hide details": "Hide details", - "API token": "API token", + "API Token": "API Token", "Generate new": "Generate new", "Port": "Port", - "IP addresses": "IP addresses", + "IP Addresses": "IP Addresses", "Click to show": "Click to show", "The free Streamlabs Controller app allows you to control Streamlabs Desktop from your iOS or Android device. You must be logged in to use this feature.": "The free Streamlabs Controller app allows you to control Streamlabs Desktop from your iOS or Android device. You must be logged in to use this feature.", - "Allow remote connections": "Allow remote connections", + "Allow Controller app connections": "Allow Controller app connections", "Connected Devices": "Connected Devices", "Disconnect": "Disconnect", - "No devices connected": "No devices connected" + "No devices connected": "No devices connected", + "Some third party applications connect to Streamlabs Desktop via websockets connection. Toggle this to allow such connections and display connection info.": "Some third party applications connect to Streamlabs Desktop via websockets connection. Toggle this to allow such connections and display connection info.", + "Warning: Displaying this portion on stream may leak sensitive information.": "Warning: Displaying this portion on stream may leak sensitive information.", + "Allow third party connections": "Allow third party connections", + "Host": "Host" } diff --git a/app/services/api/tcp-server/tcp-server.ts b/app/services/api/tcp-server/tcp-server.ts index d79bd608be92..d1f42deb03fe 100644 --- a/app/services/api/tcp-server/tcp-server.ts +++ b/app/services/api/tcp-server/tcp-server.ts @@ -135,6 +135,19 @@ export class TcpServerService this.listen(); } + disableWebsocketsRemoteConnections() { + this.stopListening(); + // update websockets settings + const defaultWebsoketsSettings = this.getDefaultSettings().websockets; + this.setSettings({ + websockets: { + ...defaultWebsoketsSettings, + }, + }); + + this.listen(); + } + getDefaultSettings(): ITcpServersSettings { return TcpServerService.defaultState; } From c479ef6c7929a496dc0089dcadd9d8c6a8e87625 Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Fri, 25 Oct 2024 10:58:45 -0700 Subject: [PATCH 36/97] chore(onboarding): update themes (#5184) * chore(onboarding): update themes * refactor(onboarding): extra defensive for missing designer * fix(onboarding): wrong IDs given --- .../pages/onboarding/ThemeSelector.tsx | 38 ++++++++++--------- app/services/onboarding/theme-metadata.ts | 21 ++++++---- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/app/components-react/pages/onboarding/ThemeSelector.tsx b/app/components-react/pages/onboarding/ThemeSelector.tsx index 32a934a395c8..24c55865c560 100644 --- a/app/components-react/pages/onboarding/ThemeSelector.tsx +++ b/app/components-react/pages/onboarding/ThemeSelector.tsx @@ -132,26 +132,30 @@ export function ThemeSelector() {
    - + {detailTheme.data.designer && ( + + )}

    {detailTheme.data.name}

    - %{designerName}', { - designerName: detailTheme.data.designer.name, - })} - > - %{designerName}', { + designerName: detailTheme.data.designer.name, })} - onClick={() => { - if (detailTheme.data.designer.website) { - remote.shell.openExternal(detailTheme.data.designer.website); - } - }} - /> - + > + { + if (detailTheme.data.designer?.website) { + remote.shell.openExternal(detailTheme.data.designer.website); + } + }} + /> + + )}
    +
    diff --git a/app/components/windows/Troubleshooter.vue.ts b/app/components/windows/Troubleshooter.vue.ts index ebc9a7206935..1c3dcd7213b2 100644 --- a/app/components/windows/Troubleshooter.vue.ts +++ b/app/components/windows/Troubleshooter.vue.ts @@ -13,6 +13,7 @@ import { StreamingService } from 'services/streaming'; import { TObsFormData } from '../obs/inputs/ObsInput'; import GenericFormGroups from '../obs/inputs/GenericFormGroups.vue'; import { StartStreamingButton } from 'components/shared/ReactComponentList'; +import { CustomizationService } from 'services/customization'; @Component({ components: { ModalLayout, GenericFormGroups, StartStreamingButton }, @@ -22,6 +23,7 @@ export default class Troubleshooter extends Vue { @Inject() private settingsService: SettingsService; @Inject() private windowsService!: WindowsService; @Inject() streamingService: StreamingService; + @Inject() customizationService: CustomizationService; issueCode = this.windowsService.getChildWindowQueryParams().issueCode as TIssueCode; @@ -52,6 +54,10 @@ export default class Troubleshooter extends Vue { return this.settingsService.state.Output.formData.map(hideParamsForCategory); } + get performanceMode() { + return this.customizationService.state.performanceMode; + } + showSettings() { this.settingsService.showSettings(); } @@ -75,6 +81,12 @@ export default class Troubleshooter extends Vue { destroyed() { this.subscription.unsubscribe(); } + + enablePerformanceMode() { + this.customizationService.setSettings({ + performanceMode: true, + }); + } } const paramsToShow = ['server', 'VBitrate', 'ABitrate']; diff --git a/app/components/windows/settings/Settings.vue.ts b/app/components/windows/settings/Settings.vue.ts index bb8b97f7f4a2..5d3eae6f3715 100644 --- a/app/components/windows/settings/Settings.vue.ts +++ b/app/components/windows/settings/Settings.vue.ts @@ -16,6 +16,7 @@ import VirtualWebcamSettings from './VirtualWebcamSettings'; import { MagicLinkService } from 'services/magic-link'; import { UserService } from 'services/user'; import { DismissablesService, EDismissable } from 'services/dismissables'; +import { DualOutputService } from 'services/dual-output'; import Scrollable from 'components/shared/Scrollable'; import { ObsSettings, @@ -57,6 +58,7 @@ export default class Settings extends Vue { @Inject() magicLinkService: MagicLinkService; @Inject() userService: UserService; @Inject() dismissablesService: DismissablesService; + @Inject() dualOutputService: DualOutputService; $refs: { settingsContainer: HTMLElement & SearchablePages }; @@ -273,6 +275,7 @@ export default class Settings extends Vue { }) .then(({ response }) => { if (response === 0) { + this.dualOutputService.setDualOutputMode(false, true); this.userService.logOut(); } }); diff --git a/app/i18n/en-US/onboarding.json b/app/i18n/en-US/onboarding.json index 0bb80fb7888b..8e9fc007797e 100644 --- a/app/i18n/en-US/onboarding.json +++ b/app/i18n/en-US/onboarding.json @@ -189,5 +189,6 @@ "Choose your plan": "Choose your plan", "Choose the best plan to fit your content creation needs.": "Choose the best plan to fit your content creation needs.", "Add your first theme": "Add your first theme", - "Try your first theme now, browse hundreds of more themes later on": "Try your first theme now, browse hundreds of more themes later on" + "Try your first theme now, browse hundreds of more themes later on.": "Try your first theme now, browse hundreds of more themes later on.", + "Set up your mic & webcam": "Set up your mic & webcam" } diff --git a/app/i18n/en-US/overlays.json b/app/i18n/en-US/overlays.json index 8025ca7e8c72..e00b95543f68 100644 --- a/app/i18n/en-US/overlays.json +++ b/app/i18n/en-US/overlays.json @@ -16,5 +16,8 @@ "Assign Vertical Sources to Horizontal Display": "Assign Vertical Sources to Horizontal Display", "The below will create a copy of the active scene collection, set the copy as the active collection, and then apply the function.": "The below will create a copy of the active scene collection, set the copy as the active collection, and then apply the function.", "Manage Dual Output Scene": "Manage Dual Output Scene", - "Unable to convert dual output collection.": "Unable to convert dual output collection." + "Unable to convert dual output collection.": "Unable to convert dual output collection.", + "default_width": "default_width", + "default_height": "default_height", + "GameCapture.WindowInternalMode": "GameCapture.WindowInternalMode" } diff --git a/app/i18n/en-US/streaming.json b/app/i18n/en-US/streaming.json index 8076f9022a9b..31c032a7cd4e 100644 --- a/app/i18n/en-US/streaming.json +++ b/app/i18n/en-US/streaming.json @@ -257,5 +257,23 @@ "You can now reply to Twitch, YouTube and Facebook messages in Multistream chat. Click to learn more.": "You can now reply to Twitch, YouTube and Facebook messages in Multistream chat. Click to learn more.", "Multistream Chat Platform Support": "Multistream Chat Platform Support", "You have selected %{colorFormat} as Color Format. Formats other than NV12 and P010 are commonly used for recording, and might incur high CPU usage or the streaming platform might not support it. Go to Settings -> Advanced -> Video to review.": "You have selected %{colorFormat} as Color Format. Formats other than NV12 and P010 are commonly used for recording, and might incur high CPU usage or the streaming platform might not support it. Go to Settings -> Advanced -> Video to review.", + "Selective Recording Enabled": "Selective Recording Enabled", + "Selective Recording only works with horizontal sources and disables editing the vertical output scene. Please disable Selective Recording to go live with Dual Output.": "Selective Recording only works with horizontal sources and disables editing the vertical output scene. Please disable Selective Recording to go live with Dual Output.", + "Studio Mode Enabled": "Studio Mode Enabled", + "Cannot toggle Dual Output while in Studio Mode. Please disable Studio Mode to go live with Dual Output.": "Cannot toggle Dual Output while in Studio Mode. Please disable Studio Mode to go live with Dual Output.", + "Confirm Horizontal and Vertical Platforms": "Confirm Horizontal and Vertical Platforms", + "All platforms are currently assigned to the display. To use Dual Output you must stream to one horizontal and one vertical platform. Do you want to go live in single output mode with the Horizontal display?": "All platforms are currently assigned to the display. To use Dual Output you must stream to one horizontal and one vertical platform. Do you want to go live in single output mode with the Horizontal display?", + "Streamlabs has detected high CPU usage in Dual Output mode": "Streamlabs has detected high CPU usage in Dual Output mode", + "System resource overuse.": "System resource overuse.", + "To mitigate hide one of outputs or right click in editor to enable Performance Mode.": "To mitigate hide one of outputs or right click in editor to enable Performance Mode.", + "This problem could also be due to high CPU usage from other applications or unsuitable encoder settings.": "This problem could also be due to high CPU usage from other applications or unsuitable encoder settings.", + "When this happens, Streamlabs does not have any resources left over.": "When this happens, Streamlabs does not have any resources left over.", + "Hide one or both of the displays in Editor's Scene section": "Hide one or both of the displays in Editor's Scene section", + "Ensure that you don't have any other applications open that are heavy on your CPU": "Ensure that you don't have any other applications open that are heavy on your CPU", + "Enable Performance Mode": "Enable Performance Mode", + "High CPU Usage": "High CPU Usage", + "Detect CPU usage in Dual Output mode": "Detect CPU usage in Dual Output mode", + "High CPU Usage: %{percentage}% used": "High CPU Usage: %{percentage}% used", + "High CPU Usage: Detected": "High CPU Usage: Detected", "TikTok Audience": "TikTok Audience" } diff --git a/app/services/api/external-api/notifications/notifications.ts b/app/services/api/external-api/notifications/notifications.ts index b95d86a82f3b..83dcad92f9f0 100644 --- a/app/services/api/external-api/notifications/notifications.ts +++ b/app/services/api/external-api/notifications/notifications.ts @@ -22,6 +22,7 @@ enum ENotificationSubType { LAGGED = 'LAGGED', SKIPPED = 'SKIPPED', NEWS = 'NEWS', + CPU = 'CPU', } /** diff --git a/app/services/api/external-api/scene-collections/scene-collections.ts b/app/services/api/external-api/scene-collections/scene-collections.ts index 0265475a374e..f573779ab1ef 100644 --- a/app/services/api/external-api/scene-collections/scene-collections.ts +++ b/app/services/api/external-api/scene-collections/scene-collections.ts @@ -67,6 +67,10 @@ export class SceneCollectionsService { }; } + get newUserFirstLogin(): boolean { + return this.sceneCollectionsService.newUserFirstLogin; + } + /** * Provides the scene collection's schema including all scenes, scene nodes * and sources. This operation is expensive and should be avoided if possible. diff --git a/app/services/dual-output/dual-output-data.ts b/app/services/dual-output/dual-output-data.ts deleted file mode 100644 index dbf37ce3f05b..000000000000 --- a/app/services/dual-output/dual-output-data.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { $t } from 'services/i18n'; -import { TPlatform, EPlatform } from 'services/platforms'; -import { TDisplayType } from 'services/settings-v2'; - -export enum EOutputDisplayType { - Horizontal = 'horizontal', - Vertical = 'vertical', -} -export interface IDualOutputPlatformSetting { - platform: TPlatform; - display: EOutputDisplayType; -} - -export interface IDualOutputDestinationSetting { - destination: string; - display: TDisplayType; -} - -export type TDualOutputPlatformSettings = { - [Platform in EPlatform]: IDualOutputPlatformSetting; -}; - -export type TDisplayPlatforms = { - [Display in EOutputDisplayType]: TPlatform[]; -}; - -export type TDisplayDestinations = { - [Display in EOutputDisplayType]: string[]; -}; - -export const DualOutputPlatformSettings: TDualOutputPlatformSettings = { - [EPlatform.Twitch]: { - platform: EPlatform.Twitch, - display: EOutputDisplayType.Horizontal, - }, - [EPlatform.YouTube]: { - platform: EPlatform.YouTube, - display: EOutputDisplayType.Horizontal, - }, - [EPlatform.Facebook]: { - platform: EPlatform.Facebook, - display: EOutputDisplayType.Horizontal, - }, - [EPlatform.TikTok]: { - platform: EPlatform.TikTok, - display: EOutputDisplayType.Vertical, - }, - [EPlatform.Trovo]: { - platform: EPlatform.Trovo, - display: EOutputDisplayType.Horizontal, - }, - [EPlatform.Twitter]: { - platform: EPlatform.Twitter, - display: EOutputDisplayType.Horizontal, - }, - [EPlatform.Instagram]: { - platform: EPlatform.Instagram, - display: EOutputDisplayType.Vertical, - }, -}; - -export const displayLabels = (display: EOutputDisplayType | string) => - ({ - [EOutputDisplayType.Horizontal]: $t('Horizontal'), - [EOutputDisplayType.Vertical]: $t('Vertical'), - }[display]); diff --git a/app/services/dual-output/dual-output.ts b/app/services/dual-output/dual-output.ts index c2112e4eebc2..4906d86612d7 100644 --- a/app/services/dual-output/dual-output.ts +++ b/app/services/dual-output/dual-output.ts @@ -1,12 +1,4 @@ import { PersistentStatefulService, InitAfter, Inject, ViewHandler, mutation } from 'services/core'; -import { - TDualOutputPlatformSettings, - DualOutputPlatformSettings, - IDualOutputDestinationSetting, - TDisplayPlatforms, - IDualOutputPlatformSetting, - TDisplayDestinations, -} from './dual-output-data'; import { verticalDisplayData } from '../settings-v2/default-settings-data'; import { ScenesService, SceneItem, TSceneNode } from 'services/scenes'; import { TDisplayType, VideoSettingsService } from 'services/settings-v2/video'; @@ -25,10 +17,14 @@ import { UserService } from 'services/user'; import { SelectionService, Selection } from 'services/selection'; import { StreamingService } from 'services/streaming'; import { SettingsService } from 'services/settings'; +import { SourcesService, TSourceType } from 'services/sources'; +import { WidgetsService, WidgetType } from 'services/widgets'; import { RunInLoadingMode } from 'services/app/app-decorators'; import compact from 'lodash/compact'; import invert from 'lodash/invert'; import forEachRight from 'lodash/forEachRight'; +import { byOS, OS } from 'util/operating-systems'; +import { DefaultHardwareService } from 'services/hardware/default-hardware'; interface IDisplayVideoSettings { horizontal: IVideoInfo; @@ -39,13 +35,24 @@ interface IDisplayVideoSettings { }; } interface IDualOutputServiceState { - platformSettings: TDualOutputPlatformSettings; - destinationSettings: Dictionary; dualOutputMode: boolean; videoSettings: IDisplayVideoSettings; isLoading: boolean; } +enum EOutputDisplayType { + Horizontal = 'horizontal', + Vertical = 'vertical', +} + +export type TDisplayPlatforms = { + [Display in EOutputDisplayType]: TPlatform[]; +}; + +export type TDisplayDestinations = { + [Display in EOutputDisplayType]: string[]; +}; + class DualOutputViews extends ViewHandler { @Inject() private scenesService: ScenesService; @Inject() private videoSettingsService: VideoSettingsService; @@ -104,24 +111,8 @@ class DualOutputViews extends ViewHandler { return Object.entries(nodeMaps).length > 0; } - get platformSettings() { - return this.state.platformSettings; - } - - get destinationSettings() { - return this.state.destinationSettings; - } - getEnabledTargets(destinationId: 'name' | 'url' = 'url') { - const platforms = Object.entries(this.platformSettings).reduce( - (displayPlatforms: TDisplayPlatforms, [key, val]: [string, IDualOutputPlatformSetting]) => { - if (val && this.streamingService.views.enabledPlatforms.includes(val.platform)) { - displayPlatforms[val.display].push(val.platform); - } - return displayPlatforms; - }, - { horizontal: [], vertical: [] }, - ); + const platforms = this.streamingService.views.activeDisplayPlatforms; /** * Returns the enabled destinations according to their assigned display @@ -176,7 +167,7 @@ class DualOutputViews extends ViewHandler { } getPlatformDisplay(platform: TPlatform) { - return this.state.platformSettings[platform].display; + return this.streamingService.views.settings.platforms[platform]?.display; } getPlatformContext(platform: TPlatform) { @@ -274,18 +265,6 @@ class DualOutputViews extends ViewHandler { return this.scenesService.views.getNodeVisibility(id, sceneId ?? this.activeSceneId); } - getCanStreamDualOutput() { - const platformDisplays = this.streamingService.views.activeDisplayPlatforms; - const destinationDisplays = this.streamingService.views.activeDisplayDestinations; - - const horizontalHasDestinations = - platformDisplays.horizontal.length > 0 || destinationDisplays.horizontal.length > 0; - const verticalHasDestinations = - platformDisplays.vertical.length > 0 || destinationDisplays.vertical.length > 0; - - return horizontalHasDestinations && verticalHasDestinations; - } - /** * Confirm if a scene has a node map for dual output. * @remark If the scene collection does not have the scene node maps property in the @@ -312,10 +291,11 @@ export class DualOutputService extends PersistentStatefulService { @@ -608,7 +601,7 @@ export class DualOutputService extends PersistentStatefulService s?.type === type); + + if (!webCam) { + const cam = scene.createAndAddSource('Webcam', type, { display: 'horizontal' }); + this.createPartnerNode(cam); + } else { + const cam = scene.addSource(webCam.sourceId, { display: 'horizontal' }); + this.createPartnerNode(cam); + } + + // add alert box widget + this.widgetsService.createWidget(WidgetType.AlertBox, 'Alert Box'); + + // toggle dual output mode and vertical display + this.toggleDisplay(true, 'vertical'); + this.toggleDualOutputMode(true); + + this.collectionHandled.next(); + } + /** * Show/hide displays * @@ -841,34 +876,6 @@ export class DualOutputService extends PersistentStatefulService @Inject() userService: UserService; @Inject() sceneCollectionsService: SceneCollectionsService; @Inject() outputSettingsService: OutputSettingsService; + @Inject() dualOutputService: DualOutputService; @mutation() SET_OPTIONS(options: Partial) { @@ -315,6 +317,40 @@ export class OnboardingService extends StatefulService ); } + get createDefaultNewUserScene() { + // If the first login status was set in the scene collections service, + // determine if the user installed a theme during onboarding + if (this.sceneCollectionsService.newUserFirstLogin && !this.existingSceneCollections) { + return true; + } + + // Skip checking creation date for accounts in when testing + if (Utils.isTestMode()) return false; + + // If the user does not have a creation date, they are a new user so + // determine if the user installed a theme during onboarding + const creationDate = this.userService.state?.createdAt; + if (!creationDate) { + return this.existingSceneCollections === false; + } + + // Otherwise, check if the user is within the first 6 hours of their + // account creation date/time. This is last resort very rough check to determine + // if the user is a new user. Not ideal but better than nothing. + const now = new Date().getTime(); + const creationTime = new Date(creationDate).getTime(); + const millisecondsInAnHour = 1000 * 60 * 60; + + const isWithinCreationDateRange = + creationTime < now && creationTime - now < millisecondsInAnHour * 6; + + return ( + !isWithinCreationDateRange && + this.sceneCollectionsService.newUserFirstLogin && + !this.existingSceneCollections + ); + } + init() { this.setExistingCollections(); } @@ -357,8 +393,21 @@ export class OnboardingService extends StatefulService }); } - this.navigationService.navigate('Studio'); + // On their first login, users should have dual output mode enabled by default. + // If the user has not selected a scene collection during onboarding, add a few + // default sources to the default scene collection. + if (this.createDefaultNewUserScene) { + this.dualOutputService.setupDefaultSources(); + this.sceneCollectionsService.newUserFirstLogin = false; + } + + if (this.sceneCollectionsService.newUserFirstLogin && this.existingSceneCollections) { + this.dualOutputService.setDualOutputMode(true, true); + this.sceneCollectionsService.newUserFirstLogin = false; + } + this.onboardingCompleted.next(); + this.navigationService.navigate('Studio'); } get isTwitchAuthed() { diff --git a/app/services/performance.ts b/app/services/performance.ts index 81d26d22a99a..7f16d61fb28e 100644 --- a/app/services/performance.ts +++ b/app/services/performance.ts @@ -14,6 +14,7 @@ import { TroubleshooterService, TIssueCode } from 'services/troubleshooter'; import { $t } from 'services/i18n'; import { StreamingService, EStreamingState } from 'services/streaming'; import { VideoSettingsService } from 'services/settings-v2/video'; +import { DualOutputService } from 'services/dual-output'; import { UsageStatisticsService } from './usage-statistics'; interface IPerformanceState { @@ -44,6 +45,7 @@ interface INextStats { framesRendered: number; laggedFactor: number; droppedFramesFactor: number; + cpu: number; } // How frequently parformance stats should be updated @@ -54,6 +56,8 @@ const NOTIFICATION_THROTTLE_INTERVAL = 2 * 60 * 1000; const SAMPLING_DURATION = 2 * 60 * 1000; // How many samples we should take const NUMBER_OF_SAMPLES = Math.round(SAMPLING_DURATION / STATS_UPDATE_INTERVAL); +// Limit on interval between CPU usage notifications +const CPU_NOTIFICATION_THROTTLE_INTERVAL = 10 * 60 * 1000; interface IMonitorState { framesLagged: number; @@ -110,6 +114,7 @@ export class PerformanceService extends StatefulService { @Inject() private streamingService: StreamingService; @Inject() private usageStatisticsService: UsageStatisticsService; @Inject() private videoSettingsService: VideoSettingsService; + @Inject() private dualOutputService: DualOutputService; static initialState: IPerformanceState = { CPU: 0, @@ -128,6 +133,7 @@ export class PerformanceService extends StatefulService { private historicalDroppedFrames: number[] = []; private historicalSkippedFrames: number[] = []; private historicalLaggedFrames: number[] = []; + private historicalCPU: number[] = []; private shutdown = false; private statsRequestInProgress = false; @@ -142,7 +148,7 @@ export class PerformanceService extends StatefulService { @mutation() private SET_PERFORMANCE_STATS(stats: Partial) { - Object.keys(stats).forEach(stat => { + Object.keys(stats).forEach((stat: keyof Partial) => { Vue.set(this.state, stat, stats[stat]); }); } @@ -201,6 +207,7 @@ export class PerformanceService extends StatefulService { this.streamStartRenderedFrames = obs.Global.totalFrames; this.streamStartEncodedFrames = this.videoSettingsService.contexts.horizontal.encodedFrames; this.streamStartTime = new Date(); + this.historicalCPU = []; } stopStreamQualityMonitoring() { @@ -216,12 +223,16 @@ export class PerformanceService extends StatefulService { 100; const streamDropped = this.state.percentageDroppedFrames; const streamDuration = new Date().getTime() - this.streamStartTime.getTime(); + const averageCPU = this.averageFactor(this.historicalCPU); + const streamType = this.dualOutputService.views.dualOutputMode ? 'dual' : 'single'; this.usageStatisticsService.recordAnalyticsEvent('StreamPerformance', { streamLagged, streamSkipped, streamDropped, streamDuration, + averageCPU, + streamType, }); } @@ -243,6 +254,14 @@ export class PerformanceService extends StatefulService { this.addSample(this.historicalSkippedFrames, nextStats.skippedFactor); this.addSample(this.historicalLaggedFrames, nextStats.laggedFactor); + // only track CPU when live in dual output mode + if ( + this.dualOutputService.views.dualOutputMode && + this.streamingService.views.isMidStreamMode + ) { + this.addSample(this.historicalCPU, nextStats.cpu); + } + this.sendNotifications(currentStats, nextStats); this.SET_PERFORMANCE_STATS({ @@ -266,6 +285,8 @@ export class PerformanceService extends StatefulService { const droppedFramesFactor = this.state.percentageDroppedFrames / 100; + const cpu = this.state.CPU; + return { framesSkipped, framesEncoded, @@ -274,6 +295,7 @@ export class PerformanceService extends StatefulService { framesRendered, laggedFactor, droppedFramesFactor, + cpu, }; } @@ -289,7 +311,6 @@ export class PerformanceService extends StatefulService { } checkNotification(target: number, record: number[]) { - if (record.length < NUMBER_OF_SAMPLES) return false; return this.averageFactor(record) >= target; } @@ -324,20 +345,37 @@ export class PerformanceService extends StatefulService { ) { this.pushDroppedFramesNotify(this.averageFactor(this.historicalDroppedFrames)); } + + // only show CPU usage notifications when live in dual output mode + if ( + this.dualOutputService.views.dualOutputMode && + this.streamingService.views.isMidStreamMode && + troubleshooterSettings.dualOutputCpuEnabled && + this.state.CPU > troubleshooterSettings.dualOutputCpuThreshold * 100 + ) { + this.pushDualOutputHighCPUNotify(this.state.CPU); + } } - @throttle(NOTIFICATION_THROTTLE_INTERVAL) - private pushSkippedFramesNotify(factor: number) { - const code: TIssueCode = 'FRAMES_SKIPPED'; + @throttle(CPU_NOTIFICATION_THROTTLE_INTERVAL) + private pushDualOutputHighCPUNotify(factor: number) { + const code: TIssueCode = 'HIGH_CPU_USAGE'; + + const message = + factor > 50 + ? $t('High CPU Usage: Detected') + : $t('High CPU Usage: %{percentage}% used', { + percentage: factor.toFixed(1), + }); + this.notificationsService.push({ code, type: ENotificationType.WARNING, data: factor, lifeTime: 2 * 60 * 1000, showTime: true, - subType: ENotificationSubType.SKIPPED, - // tslint:disable-next-line:prefer-template - message: $t('Skipped frames detected:') + Math.round(factor * 100) + '% over last 2 minutes', + subType: ENotificationSubType.CPU, + message, action: this.jsonrpcService.createRequest( Service.getResourceId(this.troubleshooterService), 'showTroubleshooter', @@ -384,6 +422,26 @@ export class PerformanceService extends StatefulService { }); } + @throttle(NOTIFICATION_THROTTLE_INTERVAL) + private pushSkippedFramesNotify(factor: number) { + const code: TIssueCode = 'FRAMES_SKIPPED'; + this.notificationsService.push({ + code, + type: ENotificationType.WARNING, + data: factor, + lifeTime: 2 * 60 * 1000, + showTime: true, + subType: ENotificationSubType.SKIPPED, + // tslint:disable-next-line:prefer-template + message: $t('Skipped frames detected:') + Math.round(factor * 100) + '% over last 2 minutes', + action: this.jsonrpcService.createRequest( + Service.getResourceId(this.troubleshooterService), + 'showTroubleshooter', + code, + ), + }); + } + stop() { this.shutdown = true; } diff --git a/app/services/platform-apps/api/modules/notifications.ts b/app/services/platform-apps/api/modules/notifications.ts index 88a4e28e1099..d7c539eadc23 100644 --- a/app/services/platform-apps/api/modules/notifications.ts +++ b/app/services/platform-apps/api/modules/notifications.ts @@ -3,7 +3,7 @@ import { Inject } from 'services/core/injector'; import { Subject } from 'rxjs'; import { INotificationsServiceApi } from 'services/notifications'; -type TIssueCode = 'FRAMES_LAGGED' | 'FRAMES_SKIPPED' | 'FRAMES_DROPPED'; +type TIssueCode = 'FRAMES_LAGGED' | 'FRAMES_SKIPPED' | 'FRAMES_DROPPED' | 'HIGH_CPU_USAGE'; enum ENotificationType { INFO = 'INFO', @@ -18,6 +18,7 @@ enum ENotificationSubType { LAGGED = 'LAGGED', SKIPPED = 'SKIPPED', NEWS = 'NEWS', + CPU = 'CPU', } interface INotificationOptions { diff --git a/app/services/scene-collections/nodes/overlays/webcam.ts b/app/services/scene-collections/nodes/overlays/webcam.ts index b669b9f7297e..a47a58dfba69 100644 --- a/app/services/scene-collections/nodes/overlays/webcam.ts +++ b/app/services/scene-collections/nodes/overlays/webcam.ts @@ -54,7 +54,11 @@ export class WebcamNode extends Node { if (context.existing) { resolution = byOS({ [OS.Windows]: () => - this.resStringToResolution(input.settings['resolution'], input.settings['resolution']), + this.resStringToResolution( + input.settings['resolution'], + input.settings['resolution'], + context.sceneItem, + ), [OS.Mac]: () => { const selectedResolution = (input.properties.get( 'preset', @@ -63,6 +67,7 @@ export class WebcamNode extends Node { return this.resStringToResolution( selectedResolution.name as string, selectedResolution.value as string, + context.sceneItem, ); }, }); @@ -131,15 +136,25 @@ export class WebcamNode extends Node { [OS.Windows]: () => { input.update({ video_device_id: device, res_type: 1 }); - return (input.properties.get('resolution') as IListProperty).details.items.map(item => { - return this.resStringToResolution(item.value as string, item.value as string); - }); + return (input.properties.get('resolution') as IListProperty).details.items.map( + resString => { + return this.resStringToResolution( + resString.value as string, + resString.value as string, + item, + ); + }, + ); }, [OS.Mac]: () => { input.update({ device, use_preset: true }); - return (input.properties.get('preset') as IListProperty).details.items.map(item => { - return this.resStringToResolution(item.name as string, item.value as string); + return (input.properties.get('preset') as IListProperty).details.items.map(resString => { + return this.resStringToResolution( + resString.name as string, + resString.value as string, + item, + ); }); }, }); @@ -209,7 +224,12 @@ export class WebcamNode extends Node { }); } - resStringToResolution(resString: string, value: string): IResolution { + resStringToResolution(resString: string, value: string, sceneItem: SceneItem): IResolution { + if (!resString) { + console.error('No resolution string found. Performing initial setup instead.'); + return this.performInitialSetup(sceneItem); + } + const parts = resString.split('x'); return { value, diff --git a/app/services/scene-collections/nodes/scene-items.ts b/app/services/scene-collections/nodes/scene-items.ts index 23567ed719d5..a1aa2876d014 100644 --- a/app/services/scene-collections/nodes/scene-items.ts +++ b/app/services/scene-collections/nodes/scene-items.ts @@ -143,9 +143,7 @@ export class SceneItemsNode extends Node { // but if the scene item already has a display assigned, skip it if (this.dualOutputService.views.hasNodeMap(context.scene.id)) { // nodes must be assigned to a context, so if it doesn't exist, establish it - if (!this.videoSettingsService.contexts.vertical) { - this.videoSettingsService.establishVideoContext('vertical'); - } + this.videoSettingsService.validateVideoContext(); const nodeMap = this.dualOutputService.views.sceneNodeMaps[context.scene.id]; diff --git a/app/services/scene-collections/scene-collections.ts b/app/services/scene-collections/scene-collections.ts index 71d40390cc57..d43bd2617a08 100644 --- a/app/services/scene-collections/scene-collections.ts +++ b/app/services/scene-collections/scene-collections.ts @@ -101,7 +101,6 @@ export class SceneCollectionsService extends Service implements ISceneCollection collectionWillSwitch = new Subject(); collectionUpdated = new Subject(); collectionInitialized = new Subject(); - collectionActivated = new Subject(); /** * Whether a valid collection is currently loaded. @@ -114,6 +113,11 @@ export class SceneCollectionsService extends Service implements ISceneCollection */ private syncPending = false; + /** + * Used to handle actions for users on their first login + */ + newUserFirstLogin = false; + /** * Does not use the standard init function so we can have asynchronous * initialization. @@ -602,6 +606,12 @@ export class SceneCollectionsService extends Service implements ISceneCollection await root.load(); this.hotkeysService.bindHotkeys(); + + // Users who selected a theme during onboarding should have it loaded in dual output mode by default + if (this.newUserFirstLogin) { + this.dualOutputService.setDualOutputMode(true, true); + this.newUserFirstLogin = false; + } } /** @@ -824,6 +834,17 @@ export class SceneCollectionsService extends Service implements ISceneCollection const serverCollections = (await this.serverApi.fetchSceneCollections()).data; + // A user who has never logged in before and did not install a + // theme during onboarding will have no collections. To prevent + // special handling of the default theme for a user who installed + // a theme during onboarding. NOTE: this will be set to false after + // onboarding in the dual output service + if (!serverCollections || serverCollections.length === 0) { + this.newUserFirstLogin = true; + } else { + this.newUserFirstLogin = false; + } + let failed = false; const collectionsToInsert = []; @@ -1032,9 +1053,7 @@ export class SceneCollectionsService extends Service implements ISceneCollection */ initNodeMaps(sceneNodeMap?: { [sceneId: string]: Dictionary }) { - if (!this.videoSettingsService.contexts.vertical) { - this.videoSettingsService.establishVideoContext('vertical'); - } + this.videoSettingsService.validateVideoContext(); if (!this.activeCollection) return; diff --git a/app/services/scenes/scene-item.ts b/app/services/scenes/scene-item.ts index bfabfcb49792..91f85f5b6d95 100644 --- a/app/services/scenes/scene-item.ts +++ b/app/services/scenes/scene-item.ts @@ -298,8 +298,8 @@ export class SceneItem extends SceneItemNode { const display = customSceneItem?.display ?? this?.display ?? 'horizontal'; // guarantee vertical context exists to prevent null errors - if (display === 'vertical' && !this.videoSettingsService.contexts.vertical) { - this.videoSettingsService.establishVideoContext('vertical'); + if (display === 'vertical') { + this.videoSettingsService.validateVideoContext('vertical'); } const context = this.videoSettingsService.contexts[display]; diff --git a/app/services/settings-v2/video.ts b/app/services/settings-v2/video.ts index 4330238d9036..34d98b42e804 100644 --- a/app/services/settings-v2/video.ts +++ b/app/services/settings-v2/video.ts @@ -294,17 +294,31 @@ export class VideoSettingsService extends StatefulService { Video.video = this.state.horizontal; Video.legacySettings = this.state.horizontal; - // ensure vertical context as the same fps settings as the horizontal context if (display === 'vertical') { + // ensure vertical context as the same fps settings as the horizontal context const updated = this.syncFPSSettings(); if (updated) { this.settingsService.refreshVideoSettings(); } + + // ensure that the v1 video resolution settings are the same as the horizontal context + this.settingsService.setSettingValue('Video', 'Base', `${this.baseWidth}x${this.baseHeight}`); + this.settingsService.setSettingValue( + 'Video', + 'Output', + `${this.outputResolutions.horizontal.outputWidth}x${this.outputResolutions.horizontal.outputHeight}`, + ); } return !!this.contexts[display]; } + validateVideoContext(display: TDisplayType = 'vertical') { + if (!this.contexts[display]) { + this.establishVideoContext(display); + } + } + createDefaultFps(display: TDisplayType = 'horizontal') { this.setVideoSetting('fpsNum', 30, display); this.setVideoSetting('fpsDen', 1, display); diff --git a/app/services/streaming/streaming-api.ts b/app/services/streaming/streaming-api.ts index 896538f7321a..c49898d5af15 100644 --- a/app/services/streaming/streaming-api.ts +++ b/app/services/streaming/streaming-api.ts @@ -10,6 +10,7 @@ import { ITrovoStartStreamOptions } from '../platforms/trovo'; import { IVideo } from 'obs-studio-node'; import { ITwitterStartStreamOptions } from 'services/platforms/twitter'; import { IInstagramStartStreamOptions } from 'services/platforms/instagram'; +import { TDisplayType } from 'services/settings-v2'; export enum EStreamingState { Offline = 'offline', @@ -87,6 +88,7 @@ export interface IGoLiveSettings extends IStreamSettings { export interface IPlatformFlags { enabled: boolean; useCustomFields: boolean; + display?: TDisplayType; video?: IVideo; } diff --git a/app/services/streaming/streaming-view.ts b/app/services/streaming/streaming-view.ts index ad8743d34e41..011e0caa70a3 100644 --- a/app/services/streaming/streaming-view.ts +++ b/app/services/streaming/streaming-view.ts @@ -9,12 +9,7 @@ import { import { StreamSettingsService, ICustomStreamDestination } from '../settings/streaming'; import { UserService } from '../user'; import { RestreamService, TOutputOrientation } from '../restream'; -import { - DualOutputService, - TDisplayPlatforms, - IDualOutputPlatformSetting, - TDisplayDestinations, -} from '../dual-output'; +import { DualOutputService, TDisplayPlatforms, TDisplayDestinations } from '../dual-output'; import { getPlatformService, TPlatform, TPlatformCapability, platformList } from '../platforms'; import { TwitterService } from '../../app-services'; import cloneDeep from 'lodash/cloneDeep'; @@ -22,7 +17,6 @@ import difference from 'lodash/difference'; import { Services } from '../../components-react/service-provider'; import { getDefined } from '../../util/properties-type-guards'; import { TDisplayType } from 'services/settings-v2'; -import compact from 'lodash/compact'; /** * The stream info view is responsible for keeping @@ -183,16 +177,35 @@ export class StreamInfoView extends ViewHandler { return this.dualOutputView.dualOutputMode && this.userView.isLoggedIn; } - get shouldMultistreamDisplay(): { horizontal: boolean; vertical: boolean } { - const numHorizontal = - this.activeDisplayPlatforms.horizontal.length + - this.activeDisplayDestinations.horizontal.length; - const numVertical = - this.activeDisplayPlatforms.vertical.length + this.activeDisplayDestinations.vertical.length; + getShouldMultistreamDisplay( + settings?: IGoLiveSettings, + ): { horizontal: boolean; vertical: boolean } { + const platforms = settings?.platforms || this.settings.platforms; + const customDestinations = settings?.customDestinations || this.customDestinations; + + const platformDisplays = { horizontal: [] as TPlatform[], vertical: [] as TPlatform[] }; + + for (const platform in platforms) { + if (platforms[platform as TPlatform]?.enabled) { + const display = platforms[platform as TPlatform]?.display ?? 'horizontal'; + platformDisplays[display].push(platform as TPlatform); + } + } + + // determine which enabled custom destinations use which displays + const destinationDisplays = customDestinations.reduce( + (displays: TDisplayDestinations, destination: ICustomStreamDestination) => { + if (destination.enabled && destination?.display) { + displays[destination.display].push(destination.name); + } + return displays; + }, + { horizontal: [], vertical: [] }, + ); return { - horizontal: numHorizontal > 1, - vertical: numVertical > 1, + horizontal: platformDisplays.horizontal.length + destinationDisplays.horizontal.length > 1, + vertical: platformDisplays.vertical.length + destinationDisplays.vertical.length > 1, }; } @@ -200,17 +213,14 @@ export class StreamInfoView extends ViewHandler { * Returns the enabled platforms according to their assigned display */ get activeDisplayPlatforms(): TDisplayPlatforms { - const enabledPlatforms = this.enabledPlatforms; + const platformDisplays = { horizontal: [] as TPlatform[], vertical: [] as TPlatform[] }; - return Object.entries(this.dualOutputView.platformSettings).reduce( - (displayPlatforms: TDisplayPlatforms, [key, val]: [string, IDualOutputPlatformSetting]) => { - if (val && enabledPlatforms.includes(val.platform)) { - displayPlatforms[val.display].push(val.platform); - } - return displayPlatforms; - }, - { horizontal: [], vertical: [] }, - ); + for (const platform in this.enabledPlatforms) { + const display = this.settings.platforms[platform as TPlatform]?.display ?? 'horizontal'; + platformDisplays[display].push(platform as TPlatform); + } + + return platformDisplays; } /** @@ -230,11 +240,36 @@ export class StreamInfoView extends ViewHandler { ); } - /** - * Returns the display for a given platform - */ - getPlatformDisplay(platform: TPlatform) { - return this.dualOutputView.getPlatformDisplay(platform); + getCanStreamDualOutput(settings?: IGoLiveSettings): boolean { + const platforms = settings?.platforms || this.settings.platforms; + const customDestinations = settings?.customDestinations || this.customDestinations; + + const platformDisplays = { horizontal: [] as TPlatform[], vertical: [] as TPlatform[] }; + + for (const platform in platforms) { + if (platforms[platform as TPlatform]?.enabled) { + const display = platforms[platform as TPlatform]?.display ?? 'horizontal'; + platformDisplays[display].push(platform as TPlatform); + } + } + + // determine which enabled custom destinations use which displays + const destinationDisplays = customDestinations.reduce( + (displays: TDisplayDestinations, destination: ICustomStreamDestination) => { + if (destination.enabled && destination?.display) { + displays[destination.display].push(destination.name); + } + return displays; + }, + { horizontal: [], vertical: [] }, + ); + // determine if both displays are selected for active platforms + const horizontalHasDestinations = + platformDisplays.horizontal.length > 0 || destinationDisplays.horizontal.length > 0; + const verticalHasDestinations = + platformDisplays.vertical.length > 0 || destinationDisplays.vertical.length > 0; + + return horizontalHasDestinations && verticalHasDestinations; } get isMidStreamMode(): boolean { @@ -484,9 +519,10 @@ export class StreamInfoView extends ViewHandler { } // make sure platforms assigned to the vertical display in dual output mode still go live in single output mode - const display = this.isDualOutputMode - ? this.dualOutputView.getPlatformDisplay(platform) - : 'horizontal'; + const display = + this.isDualOutputMode && savedDestinations + ? savedDestinations[platform]?.display + : 'horizontal'; return { ...settings, @@ -524,4 +560,8 @@ export class StreamInfoView extends ViewHandler { get hasDestinations() { return this.enabledPlatforms.length > 0 || this.customDestinations.length > 0; } + + get selectiveRecording() { + return this.streamingState.selectiveRecording; + } } diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 4e813c84af0a..b2a81b6731b0 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -274,8 +274,8 @@ export class StreamingService !assignContext && platform === 'twitch' && unattendedMode ? undefined : settings; if (assignContext) { - const context = this.views.getPlatformDisplay(platform); - await this.runCheck(platform, () => service.beforeGoLive(settingsForPlatform, context)); + const display = settings.platforms[platform]?.display; + await this.runCheck(platform, () => service.beforeGoLive(settingsForPlatform, display)); } else { await this.runCheck(platform, () => service.beforeGoLive(settingsForPlatform, 'horizontal'), @@ -313,22 +313,21 @@ export class StreamingService /** * Set custom destination stream settings */ - if (this.views.isDualOutputMode) { - // set custom destination mode and video context before setting settings - settings.customDestinations.forEach(destination => { - // only update enabled custom destinations - if (!destination.enabled) return; - - if (!destination.display) { - // set display to horizontal by default if it does not exist - destination.display = 'horizontal'; - } + settings.customDestinations.forEach(destination => { + // only update enabled custom destinations + if (!destination.enabled) return; - const display = destination.display; - destination.video = this.videoSettingsService.contexts[display]; - destination.mode = this.views.getDisplayContextName(display); - }); - } + if (!destination.display) { + // set display to horizontal by default if it does not exist + destination.display = 'horizontal'; + } + + // preserve user's dual output display setting but correctly go live to custom destinations in single output mode + const display = this.views.isDualOutputMode ? destination.display : 'horizontal'; + + destination.video = this.videoSettingsService.contexts[display]; + destination.mode = this.views.getDisplayContextName(display); + }); // save enabled platforms to reuse setting with the next app start this.streamSettingsService.setSettings({ goLiveSettings: settings }); @@ -417,7 +416,7 @@ export class StreamingService }); // if needed, set up multistreaming for dual output - const shouldMultistreamDisplay = this.views.shouldMultistreamDisplay; + const shouldMultistreamDisplay = this.views.getShouldMultistreamDisplay(settings); const destinationDisplays = this.views.activeDisplayDestinations; @@ -553,8 +552,8 @@ export class StreamingService // in dual output mode, assign context by settings // in single output mode, assign context to 'horizontal' by default - const context = this.views.isDualOutputMode - ? this.views.getPlatformDisplay(platform) + const display = this.views.isDualOutputMode + ? settings.platforms[platform]?.display : 'horizontal'; try { @@ -564,7 +563,7 @@ export class StreamingService ? undefined : settings; - await this.runCheck(platform, () => service.beforeGoLive(settingsForPlatform, context)); + await this.runCheck(platform, () => service.beforeGoLive(settingsForPlatform, display)); } catch (e: unknown) { this.handleSetupPlatformError(e, platform); @@ -872,7 +871,10 @@ export class StreamingService // Dual output cannot be toggled while live if (this.state.streamingStatus !== EStreamingState.Offline) return; - if (enabled) this.usageStatisticsService.recordFeatureUsage('DualOutput'); + if (enabled) { + this.dualOutputService.actions.setDualOutputMode(true, true); + this.usageStatisticsService.recordFeatureUsage('DualOutput'); + } this.SET_DUAL_OUTPUT_MODE(enabled); } @@ -981,7 +983,7 @@ export class StreamingService } async toggleStreaming(options?: TStartStreamOptions, force = false) { - if (this.views.isDualOutputMode && !this.dualOutputService.views.getCanStreamDualOutput()) { + if (this.views.isDualOutputMode && !this.views.getCanStreamDualOutput() && this.isIdle) { this.notificationsService.actions.push({ message: $t('Set up Go Live Settings for Dual Output Mode in the Go Live window.'), type: ENotificationType.WARNING, diff --git a/app/services/troubleshooter/troubleshooter-api.ts b/app/services/troubleshooter/troubleshooter-api.ts index 7b1574fb7b2d..8cbbd8ab16ba 100644 --- a/app/services/troubleshooter/troubleshooter-api.ts +++ b/app/services/troubleshooter/troubleshooter-api.ts @@ -7,9 +7,11 @@ export interface ITroubleshooterSettings { laggedThreshold: number; droppedEnabled: boolean; droppedThreshold: number; + dualOutputCpuEnabled: boolean; + dualOutputCpuThreshold: number; } -export type TIssueCode = 'FRAMES_LAGGED' | 'FRAMES_SKIPPED' | 'FRAMES_DROPPED'; +export type TIssueCode = 'FRAMES_LAGGED' | 'FRAMES_SKIPPED' | 'FRAMES_DROPPED' | 'HIGH_CPU_USAGE'; export interface ITroubleshooterServiceApi { getSettings(): ITroubleshooterSettings; diff --git a/app/services/troubleshooter/troubleshooter.ts b/app/services/troubleshooter/troubleshooter.ts index 291b02d6ec08..6eb1c4abea2a 100644 --- a/app/services/troubleshooter/troubleshooter.ts +++ b/app/services/troubleshooter/troubleshooter.ts @@ -25,6 +25,8 @@ export class TroubleshooterService laggedThreshold: 0.25, droppedEnabled: true, droppedThreshold: 0.25, + dualOutputCpuEnabled: true, + dualOutputCpuThreshold: 0.3, }, }; @@ -103,6 +105,28 @@ export class TroubleshooterService enabled: true, usePercentages: true, }, + + >{ + value: settings.dualOutputCpuEnabled, + name: 'dualOutputCpuEnabled', + description: $t('Detect CPU usage in Dual Output mode'), + type: 'OBS_PROPERTY_BOOL', + visible: true, + enabled: true, + }, + + { + value: settings.dualOutputCpuThreshold, + name: 'dualOutputCpuThreshold', + description: $t('CPU usage threshold in Dual Output mode'), + type: 'OBS_PROPERTY_SLIDER', + minVal: 0, + maxVal: 1, + stepVal: 0.01, + visible: settings.dualOutputCpuEnabled, + enabled: true, + usePercentages: true, + }, ]; } diff --git a/app/services/user/index.ts b/app/services/user/index.ts index 96d1e964498d..eea94b5ec905 100644 --- a/app/services/user/index.ts +++ b/app/services/user/index.ts @@ -493,10 +493,21 @@ export class UserService extends PersistentStatefulService { * to do this because Twitch adds a captcha when we try to * actually log in from integration tests. */ - async testingFakeAuth(auth: IUserAuth, isOnboardingTest: boolean) { + async testingFakeAuth( + auth: IUserAuth, + isOnboardingTest: boolean = false, + isNewUser: boolean = false, + ) { + if (!Utils.isTestMode()) return; + const service = getPlatformService(auth.primaryPlatform); this.streamSettingsService.resetStreamSettings(); await this.login(service, auth); + + if (isNewUser) { + this.sceneCollectionsService.newUserFirstLogin = true; + } + if (!isOnboardingTest) this.onboardingService.finish(); } diff --git a/app/themes.g.less b/app/themes.g.less index 8e16c2ea8040..6ec8bb81cd2f 100644 --- a/app/themes.g.less +++ b/app/themes.g.less @@ -152,7 +152,7 @@ --studio-tabs: @dark-2; --logged-in: @lavender-dark; --prime-button: @dark-2; - --tooltip-hover: @dark-4; + --tooltip-hover: @dark-5; // 3rd Party Colors --tiktok: @black; diff --git a/test/helpers/modules/core.ts b/test/helpers/modules/core.ts index b2c0b493e73a..cf4529bd34b0 100644 --- a/test/helpers/modules/core.ts +++ b/test/helpers/modules/core.ts @@ -73,6 +73,14 @@ export async function clickCheckbox(dataName: string) { await $checkbox.click(); } +export async function selectAsyncAlert(title: string) { + await (await getClient().$('span.ant-modal-confirm-title')).waitForExist(); + const alert = await select('span.ant-modal-confirm-title'); + if ((await alert.getText()) === title) { + return alert; + } +} + // OTHER SHORTCUTS export async function hoverElement(selector: string, duration?: number) { diff --git a/test/helpers/webdriver/user.ts b/test/helpers/webdriver/user.ts index 8535778b851e..74ee564e928c 100644 --- a/test/helpers/webdriver/user.ts +++ b/test/helpers/webdriver/user.ts @@ -113,6 +113,7 @@ export async function logIn( features?: ITestUserFeatures, // if not set, pick a random user's account from user-pool waitForUI = true, isOnboardingTest = false, + isNewUser = false, ): Promise { if (user) throw new Error('User already logged in'); @@ -122,7 +123,7 @@ export async function logIn( throw new Error('Setup env variable SLOBS_TEST_USER_POOL_TOKEN to run this test'); } - await loginWithAuthInfo(t, user, waitForUI, isOnboardingTest); + await loginWithAuthInfo(t, user, waitForUI, isOnboardingTest, isNewUser); return user; } @@ -164,6 +165,7 @@ export async function loginWithAuthInfo( userInfo: ITestUser | IDummyTestUser, waitForUI = true, isOnboardingTest = false, + isNewUser = false, ) { const authInfo = { widgetToken: user.widgetToken, @@ -182,7 +184,9 @@ export async function loginWithAuthInfo( }; await focusWindow('worker'); const api = await getApiClient(); - await api.getResource('UserService').testingFakeAuth(authInfo, isOnboardingTest); + await api + .getResource('UserService') + .testingFakeAuth(authInfo, isOnboardingTest, isNewUser); await focusMain(); if (!waitForUI) return true; return await isLoggedIn(t); @@ -232,14 +236,20 @@ export async function withPoolUser(user: ITestUser, fn: () => Promise) { * // ... * } */ -export const withUser = (platform?: TPlatform, features?: ITestUserFeatures) => async ( +export const withUser = ( + platform?: TPlatform, + features?: ITestUserFeatures, + waitForUI = true, + isOnboardingTest = false, + isNewUser = false, +) => async ( t: ExecutionContext, implementation: ( t: ExecutionContext, user: ITestUser | IDummyTestUser, ) => Promise, ) => { - const user = await logIn(t, platform, features); + const user = await logIn(t, platform, features, waitForUI, isOnboardingTest, isNewUser); try { await implementation(t, user); diff --git a/test/regular/onboarding.ts b/test/regular/onboarding.ts index 5d8409d5c64b..8fa83e5f940c 100644 --- a/test/regular/onboarding.ts +++ b/test/regular/onboarding.ts @@ -1,19 +1,145 @@ -import { test, useWebdriver } from '../helpers/webdriver'; -import { logIn, logOut, withPoolUser } from '../helpers/webdriver/user'; +import { test, TExecutionContext, useWebdriver } from '../helpers/webdriver'; +import { logIn, withPoolUser } from '../helpers/webdriver/user'; import { sleep } from '../helpers/sleep'; import { click, clickIfDisplayed, + clickWhenDisplayed, focusMain, isDisplayed, + getNumElements, waitForDisplayed, - clickWhenDisplayed, } from '../helpers/modules/core'; +import { getApiClient } from '../helpers/api-client'; +import { ScenesService } from '../../app/services/api/external-api/scenes'; + +/** + * Testing default sources for onboarding and new users + * @remark New users on their first login have special handling. To optimize testing, + * some of the cases are tested within existing tests. + * + * CASE 1: Old user logged in during onboarding, no theme installed (Go through onboarding) + * CASE 2: Old user logged in during onboarding, theme installed (Go through onboarding and install theme) + * CASE 3: New user logged in during onboarding, no theme installed (Go through onboarding as a new user) + * CASE 4: New user logged in during onboarding, theme installed (Go through onboarding as a new user and install theme) + * CASE 5: No user logged in during onboarding, no theme installed, then log in new user (Login new user after onboarding skipped) + * CASE 6: No user logged in during onboarding, theme installed, then log in new user (Login new user after onboarding skipped and theme installed) + * CASE 7: No user logged in during onboarding, no theme installed, then log in an old user (Scene-collections cloud-backup) <- tested in the cloud-backup test + */ // not a react hook // eslint-disable-next-line react-hooks/rules-of-hooks -useWebdriver({ skipOnboarding: false }); +useWebdriver({ skipOnboarding: false, noSync: true }); + +async function confirmDefaultSources(t: TExecutionContext, hasDefaultSources = true) { + const api = await getApiClient(); + const scenesService = api.getResource('ScenesService'); + const defaultSources = ['Game Capture', 'Webcam', 'Alert Box']; + const numDefaultSources = defaultSources.length; + + const numSceneItems = scenesService.activeScene + .getItems() + .map(item => item.getModel()) + .reduce((sources, item) => { + // only track number of sources that should be + if (sources[item.sourceId] && defaultSources.includes(item.name)) { + sources[item.sourceId] += 1; + } else { + sources[item.sourceId] = 1; + } + return sources; + }, {} as { [sourceId: string]: number }); + + if (hasDefaultSources) { + // dual output scene collections should have 2 scene items that share a single source + for (const [sourceId, count] of Object.entries(numSceneItems)) { + t.is(count, 2, `Scene has dual output source ${sourceId}`); + } + + t.is(Object.keys(numSceneItems).length, numDefaultSources, 'Scene has correct default sources'); + } else { + // overlays installed during onboarding should have default sources more or less sources than the defaults + const numDefaultSources = Object.keys(numSceneItems).filter( + name => defaultSources.includes(name) && numSceneItems[name] > 1, + ).length; + + t.not(Object.keys(numSceneItems).length, numDefaultSources, 'Scene has no default sources'); + } +} + +/* + * Helper function to go through the onboarding flow through the login step + * @remark This function is a simplification of the `Go through onboarding` test + * @param t - Test execution context + * @param installTheme - Whether to install a theme during onboarding + * @param fn - Function to run after onboarding is complete + */ +async function goThroughOnboarding( + t: TExecutionContext, + login = false, + newUser = false, + installTheme = false, + fn: () => Promise, +) { + await focusMain(); + + if (!(await isDisplayed('h2=Live Streaming'))) return; + + await click('h2=Live Streaming'); + await click('button=Continue'); + + await click('a=Login'); + + // Complete login + if (login) { + await isDisplayed('button=Log in with Twitch'); + const user = await logIn(t, 'twitch', { prime: false }, false, true, newUser); + await sleep(1000); + + // We seem to skip the login step after login internally + await clickIfDisplayed('button=Skip'); + + // Finish onboarding flow + await withPoolUser(user, async () => { + await finishOnboarding(installTheme); + await fn(); + }); + } else { + // skip login + await clickIfDisplayed('button=Skip'); + await finishOnboarding(installTheme); + await fn(); + } + + t.pass(); +} + +/* + * Helper function to go through the onboarding flow from the login step to the end + * @param installTheme - Whether to install a theme during onboarding + */ +async function finishOnboarding(installTheme = false) { + // Skip hardware config + await waitForDisplayed('h1=Set up your mic & webcam'); + await clickIfDisplayed('button=Skip'); + + // Theme install + if (installTheme) { + await waitForDisplayed('h1=Add your first theme'); + await clickWhenDisplayed('button=Install'); + await waitForDisplayed('span=100%'); + } else { + await waitForDisplayed('h1=Add your first theme'); + await clickIfDisplayed('button=Skip'); + } + // Skip purchasing prime + await clickWhenDisplayed('div[data-testid=choose-free-plan-btn]', { timeout: 60000 }); + + await isDisplayed('span=Sources'); +} + +// CASE 1: Old user logged in during onboarding, no theme installed test('Go through onboarding', async t => { await focusMain(); @@ -36,9 +162,9 @@ test('Go through onboarding', async t => { t.true(await isDisplayed('button=Log in with TikTok'), 'Shows TikTok button'); // Check for all the login icons - t.true(await isDisplayed('[datatest-id=platform-icon-button-trovo]'), 'Shows Trovo button'); - t.true(await isDisplayed('[datatest-id=platform-icon-button-dlive]'), 'Shows Dlive button'); - t.true(await isDisplayed('[datatest-id=platform-icon-button-nimotv]'), 'Shows NimoTV button'); + t.true(await isDisplayed('[data-testid=platform-icon-button-trovo]'), 'Shows Trovo button'); + t.true(await isDisplayed('[data-testid=platform-icon-button-dlive]'), 'Shows Dlive button'); + t.true(await isDisplayed('[data-testid=platform-icon-button-nimotv]'), 'Shows NimoTV button'); t.true(await isDisplayed('a=Sign up'), 'Has a link to go back to Sign Up'); @@ -63,12 +189,102 @@ test('Go through onboarding', async t => { await clickWhenDisplayed('div[data-testid=choose-free-plan-btn]', { timeout: 60000 }); t.true(await isDisplayed('span=Sources'), 'Sources selector is visible'); + + // Confirm sources and dual output status + t.is( + await getNumElements('div[data-role=source]'), + 0, + 'Old user onboarded without theme has no sources', + ); + t.true(await isDisplayed('i[data-testid=dual-output-inactive]'), 'Dual output not enabled'); + }); + + t.pass(); +}); + +// CASE 2: New user not logged in during onboarding, theme installed +// CASE 6: No user logged in during onboarding, theme installed, then log in new user +// NOTE: Skipped when running remotely but this test is functional +test.skip('Go through onboarding and install theme', async t => { + const login = false; + const newUser = true; + const installTheme = true; + + await goThroughOnboarding(t, login, newUser, installTheme, async () => { + // Confirm sources and dual output status + t.not(await getNumElements('div[data-role=source]'), 0, 'Theme installed before login'); + t.true(await isDisplayed('i[data-testid=dual-output-inactive]'), 'Dual output not enabled'); + + // login new user after onboarding + await clickIfDisplayed('li[data-testid=nav-auth]'); + + await isDisplayed('button=Log in with Twitch'); + await logIn(t, 'twitch', { prime: false }, false, false, true); + await sleep(1000); + + // Confirm switched to scene with default sources and dual output status + await confirmDefaultSources(t); + t.true(await isDisplayed('i[data-testid=dual-output-active]'), 'Dual output enabled.'); + }); + + t.pass(); +}); + +// CASE 3: New user logged in during onboarding, no theme installed +test('Go through onboarding as a new user', async t => { + const login = true; + const newUser = true; + const installTheme = false; + + await goThroughOnboarding(t, login, newUser, installTheme, async () => { + // Confirm sources and dual output status + await confirmDefaultSources(t); + t.true(await isDisplayed('i[data-testid=dual-output-active]'), 'Dual output enabled.'); + }); + + t.pass(); +}); + +// CASE 4: New user logged in during onboarding, theme installed +// NOTE: Skipped when running remotely but this test is functional +test.skip('Go through onboarding as a new user and install theme', async t => { + const login = true; + const newUser = true; + const installTheme = true; + const hasDefaultSources = false; + + await goThroughOnboarding(t, login, newUser, installTheme, async () => { + // Confirm sources and dual output status + await confirmDefaultSources(t, hasDefaultSources); + t.false(await isDisplayed('i[data-testid=dual-output-inactive]'), 'Dual output enabled.'); + }); + + t.pass(); +}); + +// CASE 5: No user logged in during onboarding, no theme installed, then log in new user +test('Login new user after onboarding skipped', async t => { + const login = false; + const newUser = false; + const installTheme = false; + + await goThroughOnboarding(t, login, newUser, installTheme, async () => { + // login new user after onboarding + await clickIfDisplayed('li[data-testid=nav-auth]'); + + await isDisplayed('button=Log in with Twitch'); + await logIn(t, 'twitch', { prime: false }, false, false, true); + await sleep(1000); + + // Confirm switched to scene with default sources and dual output status + await confirmDefaultSources(t); + t.true(await isDisplayed('i[data-testid=dual-output-active]'), 'Dual output enabled.'); }); t.pass(); }); -// TODO: this test is the same as beginner except with autoconfig, make specific assertions here once re-enabled +// TODO: refactor to updated onboarding flow and make specific assertions here once re-enabled test.skip('Go through the onboarding and autoconfig', async t => { const app = t.context.app; await focusMain(); diff --git a/test/regular/services/scene-collections/cloud-backup.ts b/test/regular/services/scene-collections/cloud-backup.ts index ca98a72b0b87..22d4e3a4cc11 100644 --- a/test/regular/services/scene-collections/cloud-backup.ts +++ b/test/regular/services/scene-collections/cloud-backup.ts @@ -4,11 +4,13 @@ import { getApiClient } from '../../../helpers/api-client'; import { logIn, loginWithAuthInfo } from '../../../helpers/webdriver/user'; import { SceneCollectionsService } from '../../../../app/services/api/external-api/scene-collections'; +// not a react hook +// eslint-disable-next-line react-hooks/rules-of-hooks useWebdriver({ noSync: false }); test('Scene-collections cloud-backup', async t => { // log-in and save the credentials - const authInfo = await logIn(t); + const authInfo = await logIn(t, 'twitch', {}, true, true); // create an new empty collection const api = await getApiClient(); diff --git a/test/regular/streaming/dual-output.ts b/test/regular/streaming/dual-output.ts index 22551b582e33..bce600bbc823 100644 --- a/test/regular/streaming/dual-output.ts +++ b/test/regular/streaming/dual-output.ts @@ -9,6 +9,7 @@ import { focusChild, focusMain, isDisplayed, + selectAsyncAlert, waitForDisplayed, } from '../../helpers/modules/core'; import { logIn } from '../../helpers/modules/user'; @@ -23,6 +24,8 @@ import { withUser } from '../../helpers/webdriver/user'; import { SceneBuilder } from '../../helpers/scene-builder'; import { getApiClient } from '../../helpers/api-client'; +// not a react hook +// eslint-disable-next-line react-hooks/rules-of-hooks useWebdriver(); /** @@ -108,8 +111,12 @@ test( // cannot use dual output mode with only one platform linked await submit(); - await waitForDisplayed( - 'div=To use Dual Output you must stream to at least one horizontal and one vertical platform.', + + t.true( + await ( + await selectAsyncAlert('Confirm Horizontal and Vertical Platforms') + ).waitForDisplayed(), + 'Alert is open', ); t.pass(); @@ -130,8 +137,11 @@ test( // cannot use dual output mode with all platforms assigned to one display await submit(); - await waitForDisplayed( - 'div=To use Dual Output you must stream to at least one horizontal and one vertical platform.', + t.true( + await ( + await selectAsyncAlert('Confirm Horizontal and Vertical Platforms') + ).waitForDisplayed(), + 'Alert is open', ); t.pass(); diff --git a/test/regular/streaming/multistream.ts b/test/regular/streaming/multistream.ts index 20cbeb309487..23f2bf71add2 100644 --- a/test/regular/streaming/multistream.ts +++ b/test/regular/streaming/multistream.ts @@ -13,12 +13,16 @@ import { logIn } from '../../helpers/modules/user'; import { releaseUserInPool, reserveUserFromPool, withUser } from '../../helpers/webdriver/user'; import { showSettingsWindow } from '../../helpers/modules/settings/settings'; import { test, useWebdriver } from '../../helpers/webdriver'; +import { sleep } from '../../helpers/sleep'; +// not a react hook +// eslint-disable-next-line react-hooks/rules-of-hooks useWebdriver(); async function enableAllPlatforms() { for (const platform of ['twitch', 'youtube', 'trovo']) { await fillForm({ [platform]: true }); + await sleep(500); await waitForSettingsWindowLoaded(); } } @@ -118,7 +122,7 @@ test('Custom stream destinations', async t => { await click('span=Add Destination'); await fillForm({ - name: `MyCustomDest`, + name: 'MyCustomDest', url: 'rtmp://live.twitch.tv/app/', streamKey: user.streamKey, }); diff --git a/test/regular/streaming/tiktok.ts b/test/regular/streaming/tiktok.ts index cdc9ea6f4297..9edbb33093f9 100644 --- a/test/regular/streaming/tiktok.ts +++ b/test/regular/streaming/tiktok.ts @@ -18,6 +18,8 @@ import { IDummyTestUser, tikTokUsers } from '../../data/dummy-accounts'; import { TTikTokLiveScopeTypes } from 'services/platforms/tiktok/api'; import { isDisplayed, waitForDisplayed } from '../../helpers/modules/core'; +// not a react hook +// eslint-disable-next-line react-hooks/rules-of-hooks useWebdriver(); test('Streaming to TikTok', withUser('twitch', { multistream: false, prime: false }), async t => { @@ -50,14 +52,14 @@ test('Streaming to TikTok', withUser('twitch', { multistream: false, prime: fals title: 'Test stream', twitchGame: 'Fortnite', }); - await submit(); - await waitForDisplayed('span=Update settings for TikTok'); - await waitForStreamStart(); - await stopStream(); + // await submit(); + // await waitForDisplayed('span=Update settings for TikTok'); + // await waitForStreamStart(); + // await stopStream(); // test all other tiktok statuses + // await testLiveScope(t, 'legacy'); await testLiveScope(t, 'denied'); - await testLiveScope(t, 'legacy'); await testLiveScope(t, 'relog'); t.pass(); @@ -76,15 +78,26 @@ async function testLiveScope(t: TExecutionContext, scope: TTikTokLiveScopeTypes) // denied scope should show prompt to remerge TikTok account if (scope === 'relog') { skipCheckingErrorsInLog(); - t.true(await isDisplayed('div=Failed to update TikTok account', { timeout: 1000 })); + + t.true( + await isDisplayed('div=Failed to update TikTok account', { timeout: 3000 }), + 'TikTok remerge error shown', + ); return; } - await waitForSettingsWindowLoaded(); + if (scope === 'denied') { + await waitForSettingsWindowLoaded(); + await submit(); + + t.true( + await isDisplayed('span=Streaming to TikTok not approved.'), + 'TikTok denied error shown', + ); + + return; + } - await fillForm({ - tiktok: true, - }); await waitForSettingsWindowLoaded(); await waitForDisplayed('div[data-name="tiktok-settings"]'); From 64ad484dcd593f05de6d320d16ce0d9abe4bc6d9 Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Wed, 20 Nov 2024 13:56:53 -0800 Subject: [PATCH 59/97] fix(settings): external merge causes settings window invalid state (#5221) --- .../windows/settings/Stream.tsx | 29 ++++++++++++------- .../windows/settings/Settings.vue.ts | 14 ++++++--- app/services/user/index.ts | 28 ++++++++++++++++++ 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/app/components-react/windows/settings/Stream.tsx b/app/components-react/windows/settings/Stream.tsx index 371ddea3fae8..5291cac81238 100644 --- a/app/components-react/windows/settings/Stream.tsx +++ b/app/components-react/windows/settings/Stream.tsx @@ -39,18 +39,27 @@ function censorEmail(str: string) { */ class StreamSettingsModule { constructor() { + const showMessage = (msg: string, success: boolean) => { + message.config({ + duration: 6, + maxCount: 1, + }); + + if (success) { + message.success(msg); + } else { + message.error(msg); + } + }; Services.UserService.refreshedLinkedAccounts.subscribe( (res: { success: boolean; message: string }) => { - message.config({ - duration: 6, - maxCount: 1, - }); - - if (res.success) { - message.success(res.message); - } else { - message.error(res.message); - } + const doShowMessage = () => showMessage(res.message, res.success); + /* + * Since the settings window pops out anyways (presumably because of + * using `message`make sure it is at least on the right page, as opposed + * to in an infinite loading blank window state. + */ + doShowMessage(); }, ); } diff --git a/app/components/windows/settings/Settings.vue.ts b/app/components/windows/settings/Settings.vue.ts index 5d3eae6f3715..6c87aabfc88b 100644 --- a/app/components/windows/settings/Settings.vue.ts +++ b/app/components/windows/settings/Settings.vue.ts @@ -175,10 +175,16 @@ export default class Settings extends Vue { } getInitialCategoryName() { - if (this.windowsService.state.child.queryParams) { - return this.windowsService.state.child.queryParams.categoryName || 'General'; - } - return 'General'; + /* Some sort of race condition, perhaps `WindowsService` creating + * the window, and *only* after updating its options, results in + * accessing state here to be empty for `state.child.queryParams` + * which is what this method used to use, unless the child window + * has already been displayed once? + * + * Switching to this method call seems to solve the issue, plus we + * shouldn't be accessing state directly regardless. + */ + return this.windowsService.getChildWindowQueryParams()?.categoryName ?? 'General'; } get categoryNames() { diff --git a/app/services/user/index.ts b/app/services/user/index.ts index eea94b5ec905..727b7292af99 100644 --- a/app/services/user/index.ts +++ b/app/services/user/index.ts @@ -464,11 +464,14 @@ export class UserService extends PersistentStatefulService { ? $t('Successfully merged account') : $t('Successfully unlinked account'); + await this.showStreamSettingsIfNeeded(); + this.windowsService.actions.setWindowOnTop('all'); this.refreshedLinkedAccounts.next({ success: true, message }); } if (event.type === 'account_merge_error') { + await this.showStreamSettingsIfNeeded(); this.windowsService.actions.setWindowOnTop('all'); this.refreshedLinkedAccounts.next({ success: false, message: $t('Account merge error') }); } @@ -484,6 +487,31 @@ export class UserService extends PersistentStatefulService { }); } + /* + * Since we're displaying the child window in all cases, it might've + * been closed when we get this event, so no component was rendered into + * it and instead shows an empty blank window with a loading spinner. + * It could also never been created (or a component rendered into it + * at least), both cases resulted in that invalid state. + * + * If the child window is closed, and we get one of these user events, + * (refer to callers), show Settings -> Stream which in our case should + * displays user accounts. + */ + async showStreamSettingsIfNeeded() { + if (this.windowsService.state.child && !this.windowsService.state.child.isShown) { + this.settingsService.showSettings('Stream'); + /* TODO: added a sleep here so on first child window create + * we still get to see messages (i.e Stream settings). + * Otherwise subscriber is called late, since this is a normal + * subject. + * TODO: should we convert to `BehaviorSubject` or whatever was + * it the one that replays events for new subscribers? + */ + await Utils.sleep(500); + } + } + get views() { return new UserViews(this.state); } From 530ed92061c17d40fa2e7089f627b88a1df02722 Mon Sep 17 00:00:00 2001 From: gettinToasty Date: Thu, 21 Nov 2024 15:09:39 -0800 Subject: [PATCH 60/97] Remove Dual Output defaulting on (#5224) * Remove Dual Output defaulting on * Fix missing spots * Skip new tests --- .../scene-collections/scene-collections.ts | 4 -- app/services/onboarding.ts | 50 +------------------ .../scene-collections/nodes/scene-items.ts | 4 +- .../scene-collections/scene-collections.ts | 26 ++-------- app/services/scenes/scene-item.ts | 4 +- app/services/user/index.ts | 4 -- test/regular/onboarding.ts | 4 +- 7 files changed, 11 insertions(+), 85 deletions(-) diff --git a/app/services/api/external-api/scene-collections/scene-collections.ts b/app/services/api/external-api/scene-collections/scene-collections.ts index f573779ab1ef..0265475a374e 100644 --- a/app/services/api/external-api/scene-collections/scene-collections.ts +++ b/app/services/api/external-api/scene-collections/scene-collections.ts @@ -67,10 +67,6 @@ export class SceneCollectionsService { }; } - get newUserFirstLogin(): boolean { - return this.sceneCollectionsService.newUserFirstLogin; - } - /** * Provides the scene collection's schema including all scenes, scene nodes * and sources. This operation is expensive and should be avoided if possible. diff --git a/app/services/onboarding.ts b/app/services/onboarding.ts index 9ccff42b346f..839147189739 100644 --- a/app/services/onboarding.ts +++ b/app/services/onboarding.ts @@ -317,40 +317,6 @@ export class OnboardingService extends StatefulService ); } - get createDefaultNewUserScene() { - // If the first login status was set in the scene collections service, - // determine if the user installed a theme during onboarding - if (this.sceneCollectionsService.newUserFirstLogin && !this.existingSceneCollections) { - return true; - } - - // Skip checking creation date for accounts in when testing - if (Utils.isTestMode()) return false; - - // If the user does not have a creation date, they are a new user so - // determine if the user installed a theme during onboarding - const creationDate = this.userService.state?.createdAt; - if (!creationDate) { - return this.existingSceneCollections === false; - } - - // Otherwise, check if the user is within the first 6 hours of their - // account creation date/time. This is last resort very rough check to determine - // if the user is a new user. Not ideal but better than nothing. - const now = new Date().getTime(); - const creationTime = new Date(creationDate).getTime(); - const millisecondsInAnHour = 1000 * 60 * 60; - - const isWithinCreationDateRange = - creationTime < now && creationTime - now < millisecondsInAnHour * 6; - - return ( - !isWithinCreationDateRange && - this.sceneCollectionsService.newUserFirstLogin && - !this.existingSceneCollections - ); - } - init() { this.setExistingCollections(); } @@ -392,22 +358,8 @@ export class OnboardingService extends StatefulService streaming: { outputResolution }, }); } - - // On their first login, users should have dual output mode enabled by default. - // If the user has not selected a scene collection during onboarding, add a few - // default sources to the default scene collection. - if (this.createDefaultNewUserScene) { - this.dualOutputService.setupDefaultSources(); - this.sceneCollectionsService.newUserFirstLogin = false; - } - - if (this.sceneCollectionsService.newUserFirstLogin && this.existingSceneCollections) { - this.dualOutputService.setDualOutputMode(true, true); - this.sceneCollectionsService.newUserFirstLogin = false; - } - - this.onboardingCompleted.next(); this.navigationService.navigate('Studio'); + this.onboardingCompleted.next(); } get isTwitchAuthed() { diff --git a/app/services/scene-collections/nodes/scene-items.ts b/app/services/scene-collections/nodes/scene-items.ts index a1aa2876d014..23567ed719d5 100644 --- a/app/services/scene-collections/nodes/scene-items.ts +++ b/app/services/scene-collections/nodes/scene-items.ts @@ -143,7 +143,9 @@ export class SceneItemsNode extends Node { // but if the scene item already has a display assigned, skip it if (this.dualOutputService.views.hasNodeMap(context.scene.id)) { // nodes must be assigned to a context, so if it doesn't exist, establish it - this.videoSettingsService.validateVideoContext(); + if (!this.videoSettingsService.contexts.vertical) { + this.videoSettingsService.establishVideoContext('vertical'); + } const nodeMap = this.dualOutputService.views.sceneNodeMaps[context.scene.id]; diff --git a/app/services/scene-collections/scene-collections.ts b/app/services/scene-collections/scene-collections.ts index d43bd2617a08..c0b0cd368748 100644 --- a/app/services/scene-collections/scene-collections.ts +++ b/app/services/scene-collections/scene-collections.ts @@ -113,11 +113,6 @@ export class SceneCollectionsService extends Service implements ISceneCollection */ private syncPending = false; - /** - * Used to handle actions for users on their first login - */ - newUserFirstLogin = false; - /** * Does not use the standard init function so we can have asynchronous * initialization. @@ -606,12 +601,6 @@ export class SceneCollectionsService extends Service implements ISceneCollection await root.load(); this.hotkeysService.bindHotkeys(); - - // Users who selected a theme during onboarding should have it loaded in dual output mode by default - if (this.newUserFirstLogin) { - this.dualOutputService.setDualOutputMode(true, true); - this.newUserFirstLogin = false; - } } /** @@ -834,17 +823,6 @@ export class SceneCollectionsService extends Service implements ISceneCollection const serverCollections = (await this.serverApi.fetchSceneCollections()).data; - // A user who has never logged in before and did not install a - // theme during onboarding will have no collections. To prevent - // special handling of the default theme for a user who installed - // a theme during onboarding. NOTE: this will be set to false after - // onboarding in the dual output service - if (!serverCollections || serverCollections.length === 0) { - this.newUserFirstLogin = true; - } else { - this.newUserFirstLogin = false; - } - let failed = false; const collectionsToInsert = []; @@ -1057,7 +1035,9 @@ export class SceneCollectionsService extends Service implements ISceneCollection if (!this.activeCollection) return; - this.stateService.initNodeMaps(sceneNodeMap); + if (!this.videoSettingsService.contexts.vertical) { + this.videoSettingsService.establishVideoContext('vertical'); + } } /** diff --git a/app/services/scenes/scene-item.ts b/app/services/scenes/scene-item.ts index 91f85f5b6d95..bfabfcb49792 100644 --- a/app/services/scenes/scene-item.ts +++ b/app/services/scenes/scene-item.ts @@ -298,8 +298,8 @@ export class SceneItem extends SceneItemNode { const display = customSceneItem?.display ?? this?.display ?? 'horizontal'; // guarantee vertical context exists to prevent null errors - if (display === 'vertical') { - this.videoSettingsService.validateVideoContext('vertical'); + if (display === 'vertical' && !this.videoSettingsService.contexts.vertical) { + this.videoSettingsService.establishVideoContext('vertical'); } const context = this.videoSettingsService.contexts[display]; diff --git a/app/services/user/index.ts b/app/services/user/index.ts index 727b7292af99..7542fb11090e 100644 --- a/app/services/user/index.ts +++ b/app/services/user/index.ts @@ -532,10 +532,6 @@ export class UserService extends PersistentStatefulService { this.streamSettingsService.resetStreamSettings(); await this.login(service, auth); - if (isNewUser) { - this.sceneCollectionsService.newUserFirstLogin = true; - } - if (!isOnboardingTest) this.onboardingService.finish(); } diff --git a/test/regular/onboarding.ts b/test/regular/onboarding.ts index 8fa83e5f940c..14abb94e8bce 100644 --- a/test/regular/onboarding.ts +++ b/test/regular/onboarding.ts @@ -231,7 +231,7 @@ test.skip('Go through onboarding and install theme', async t => { }); // CASE 3: New user logged in during onboarding, no theme installed -test('Go through onboarding as a new user', async t => { +test.skip('Go through onboarding as a new user', async t => { const login = true; const newUser = true; const installTheme = false; @@ -263,7 +263,7 @@ test.skip('Go through onboarding as a new user and install theme', async t => { }); // CASE 5: No user logged in during onboarding, no theme installed, then log in new user -test('Login new user after onboarding skipped', async t => { +test.skip('Login new user after onboarding skipped', async t => { const login = false; const newUser = false; const installTheme = false; From 1e5695d30dae8f2d5863c05fd5cbf4a3087d2981 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Fri, 22 Nov 2024 17:12:19 -0500 Subject: [PATCH 61/97] TikTok RTMP streaming fix. (#5228) * Fix scope conditional and add missing string. * Legacy user error handling. --- .../windows/go-live/GoLiveError.tsx | 1 - .../windows/go-live/useGoLiveSettings.ts | 13 ++++++++--- app/i18n/en-US/tiktok.json | 3 ++- app/services/platforms/tiktok.ts | 21 +++++++++++++----- test/regular/streaming/tiktok.ts | 22 ++++++++++++------- 5 files changed, 41 insertions(+), 19 deletions(-) diff --git a/app/components-react/windows/go-live/GoLiveError.tsx b/app/components-react/windows/go-live/GoLiveError.tsx index bdfb4e9960f4..d9fa614ac29e 100644 --- a/app/components-react/windows/go-live/GoLiveError.tsx +++ b/app/components-react/windows/go-live/GoLiveError.tsx @@ -21,7 +21,6 @@ export default function GoLiveError() { NavigationService, WindowsService, MagicLinkService, - TikTokService, } = Services; // take an error from the global state diff --git a/app/components-react/windows/go-live/useGoLiveSettings.ts b/app/components-react/windows/go-live/useGoLiveSettings.ts index f3e9ffee9797..98b958de5885 100644 --- a/app/components-react/windows/go-live/useGoLiveSettings.ts +++ b/app/components-react/windows/go-live/useGoLiveSettings.ts @@ -294,9 +294,16 @@ export class GoLiveSettingsModule { */ async validate() { // tiktok live authorization error - if (this.state.isEnabled('tiktok') && !Services.TikTokService.liveStreamingEnabled) { - message.error($t('Streaming to TikTok not approved.')); - return false; + if ( + this.state.isEnabled('tiktok') && + (Services.TikTokService.neverApplied || Services.TikTokService.denied) + ) { + // TODO: this is a patch to allow users to attempt to go live with rtmp regardless of tiktok status + return message.info( + $t("Couldn't confirm TikTok Live Access. Apply for Live Permissions below"), + 2, + () => true, + ); } try { diff --git a/app/i18n/en-US/tiktok.json b/app/i18n/en-US/tiktok.json index a93b95091558..041dceea7915 100644 --- a/app/i18n/en-US/tiktok.json +++ b/app/i18n/en-US/tiktok.json @@ -23,5 +23,6 @@ "Only 32 characters of your title will display on TikTok": "Only 32 characters of your title will display on TikTok", "You may be eligible for TikTok Live Access. Apply here.": "You may be eligible for TikTok Live Access. Apply here.", "Click to view TikTok Replay in your browser.": "Click to view TikTok Replay in your browser.", - "TikTok Stream Error": "TikTok Stream Error" + "TikTok Stream Error": "TikTok Stream Error", + "Couldn't confirm TikTok Live Access. Apply for Live Permissions below": "Couldn't confirm TikTok Live Access. Apply for Live Permissions below" } diff --git a/app/services/platforms/tiktok.ts b/app/services/platforms/tiktok.ts index 702a29101ac6..6373d00f1483 100644 --- a/app/services/platforms/tiktok.ts +++ b/app/services/platforms/tiktok.ts @@ -145,10 +145,22 @@ export class TikTokService return this.state.settings.liveScope === 'approved'; } + get neverApplied(): boolean { + return this.state.settings.liveScope === 'never-applied'; + } + get denied(): boolean { return this.state.settings.liveScope === 'denied'; } + get legacy(): boolean { + return this.state.settings.liveScope === 'legacy'; + } + + get relog(): boolean { + return this.state.settings.liveScope === 'relog'; + } + get defaultGame(): IGame { return { id: 'tiktok-other', name: 'Other' }; } @@ -431,11 +443,6 @@ export class TikTokService this.SET_DENIED_DATE(timestamp); return EPlatformCallResult.TikTokStreamScopeMissing; } - - // show prompt to apply if user has never applied - if (applicationStatus === 'never-applied') { - return EPlatformCallResult.TikTokStreamScopeMissing; - } } if (status?.user) { @@ -693,7 +700,9 @@ export class TikTokService } convertScope(scope: number, applicationStatus?: string): TTikTokLiveScopeTypes { - if (applicationStatus === 'never_applied') return 'never-applied'; + if (applicationStatus === 'never_applied' && scope !== ETikTokLiveScopeReason.APPROVED_OBS) { + return 'never-applied'; + } switch (scope) { case ETikTokLiveScopeReason.APPROVED: { diff --git a/test/regular/streaming/tiktok.ts b/test/regular/streaming/tiktok.ts index 9edbb33093f9..0d9adbf59d2f 100644 --- a/test/regular/streaming/tiktok.ts +++ b/test/regular/streaming/tiktok.ts @@ -52,13 +52,13 @@ test('Streaming to TikTok', withUser('twitch', { multistream: false, prime: fals title: 'Test stream', twitchGame: 'Fortnite', }); - // await submit(); - // await waitForDisplayed('span=Update settings for TikTok'); - // await waitForStreamStart(); - // await stopStream(); + await submit(); + await waitForDisplayed('span=Update settings for TikTok'); + await waitForStreamStart(); + await stopStream(); // test all other tiktok statuses - // await testLiveScope(t, 'legacy'); + await testLiveScope(t, 'legacy'); await testLiveScope(t, 'denied'); await testLiveScope(t, 'relog'); @@ -91,19 +91,25 @@ async function testLiveScope(t: TExecutionContext, scope: TTikTokLiveScopeTypes) await submit(); t.true( - await isDisplayed('span=Streaming to TikTok not approved.'), + await isDisplayed( + "span=Couldn't confirm TikTok Live Access. Apply for Live Permissions below", + { timeout: 3000 }, + ), 'TikTok denied error shown', ); + await waitForDisplayed('span=Update settings for TikTok'); + await waitForStreamStart(); + await stopStream(); + return; } + // test legacy scope await waitForSettingsWindowLoaded(); await waitForDisplayed('div[data-name="tiktok-settings"]'); const settings = { - title: 'Test stream', - twitchGame: 'Fortnite', serverUrl: user.serverUrl, streamKey: user.streamKey, }; From 8975393c80e467bb3b8755203783d29812d74c7e Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Fri, 22 Nov 2024 14:15:47 -0800 Subject: [PATCH 62/97] feat(streaming): expand viewer count clickable area, add hover cursor (#5226) --- app/components-react/root/LiveDock.m.less | 2 ++ app/components-react/root/LiveDock.tsx | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/components-react/root/LiveDock.m.less b/app/components-react/root/LiveDock.m.less index 352724c52351..eeda0415cca0 100644 --- a/app/components-react/root/LiveDock.m.less +++ b/app/components-react/root/LiveDock.m.less @@ -123,6 +123,8 @@ } &:hover { + cursor: pointer; + .live-dock-viewer-count-toggle { opacity: 1; } diff --git a/app/components-react/root/LiveDock.tsx b/app/components-react/root/LiveDock.tsx index f25375c4fbcd..9c0bf61bcf61 100644 --- a/app/components-react/root/LiveDock.tsx +++ b/app/components-react/root/LiveDock.tsx @@ -395,13 +395,12 @@ function LiveDock(p: { onLeft: boolean }) { {liveText} {elapsedStreamTime} -
    +
    ctrl.toggleViewerCount()}> ctrl.toggleViewerCount()} /> {viewerCount} {Number(viewerCount) >= 0 && {$t('viewers')}} From ac48fea04fdbfc23f97bae08070a40093b1ff21b Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Fri, 22 Nov 2024 18:17:23 -0500 Subject: [PATCH 63/97] Add info. (#5229) --- app/components-react/editor/elements/SourceSelector.tsx | 4 ++++ app/components-react/shared/DualOutputToggle.tsx | 6 ++++++ app/components-react/windows/settings/Video.tsx | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/app/components-react/editor/elements/SourceSelector.tsx b/app/components-react/editor/elements/SourceSelector.tsx index bd43a6161706..b30263af1c09 100644 --- a/app/components-react/editor/elements/SourceSelector.tsx +++ b/app/components-react/editor/elements/SourceSelector.tsx @@ -53,6 +53,7 @@ class SourceSelectorController { private guestCamService = Services.GuestCamService; private dualOutputService = Services.DualOutputService; private userService = Services.UserService; + private tiktokService = Services.TikTokService; store = initStore({ expandedFoldersIds: [] as string[], @@ -676,6 +677,9 @@ class SourceSelectorController { Services.UsageStatisticsService.recordAnalyticsEvent('DualOutput', { type: 'ToggleOnDualOutput', source: 'SourceSelector', + isPrime: this.userService.isPrime, + platforms: this.streamingService.views.linkedPlatforms, + tiktokStatus: this.tiktokService.scope, }); if (!this.dualOutputService.views.dualOutputMode && this.selectiveRecordingEnabled) { diff --git a/app/components-react/shared/DualOutputToggle.tsx b/app/components-react/shared/DualOutputToggle.tsx index 115f3f4ca0e3..f495702abe95 100644 --- a/app/components-react/shared/DualOutputToggle.tsx +++ b/app/components-react/shared/DualOutputToggle.tsx @@ -25,12 +25,15 @@ export default function DualOutputToggle(p: IDualOutputToggleProps) { DualOutputService, StreamingService, UsageStatisticsService, + UserService, + TikTokService, } = Services; const v = useVuex(() => ({ dualOutputMode: DualOutputService.views.dualOutputMode, studioMode: TransitionsService.views.studioMode, selectiveRecording: StreamingService.state.selectiveRecording, + isPrime: UserService.state.isPrime, })); const label = v.dualOutputMode ? $t('Disable Dual Output') : $t('Enable Dual Output'); @@ -61,6 +64,9 @@ export default function DualOutputToggle(p: IDualOutputToggleProps) { UsageStatisticsService.recordAnalyticsEvent('DualOutput', { type: 'ToggleOnDualOutput', source: 'GoLiveWindow', + isPrime: v.isPrime, + platforms: StreamingService.views.linkedPlatforms, + tiktokStatus: TikTokService.scope, }); } } diff --git a/app/components-react/windows/settings/Video.tsx b/app/components-react/windows/settings/Video.tsx index 435c9a957f19..b31ca2facabb 100644 --- a/app/components-react/windows/settings/Video.tsx +++ b/app/components-react/windows/settings/Video.tsx @@ -59,6 +59,7 @@ class VideoSettingsModule { userService = Services.UserService; dualOutputService = Services.DualOutputService; streamingService = Services.StreamingService; + tiktokService = Services.TikTokService; get display(): TDisplayType { return this.state.display; @@ -504,6 +505,9 @@ class VideoSettingsModule { Services.UsageStatisticsService.recordAnalyticsEvent('DualOutput', { type: 'ToggleOnDualOutput', source: 'VideoSettings', + isPrime: this.userService.isPrime, + platforms: this.streamingService.views.linkedPlatforms, + tiktokStatus: this.tiktokService.scope, }); } } From 7578ce1533a5cf3c1ea6cccba5d7c860ceed3e92 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Mon, 25 Nov 2024 15:17:41 -0500 Subject: [PATCH 64/97] Fix dual output stream data. (#5230) --- app/services/streaming/streaming-view.ts | 16 ++++++++-------- app/services/streaming/streaming.ts | 10 ++++++---- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/app/services/streaming/streaming-view.ts b/app/services/streaming/streaming-view.ts index 011e0caa70a3..e363ae5459d3 100644 --- a/app/services/streaming/streaming-view.ts +++ b/app/services/streaming/streaming-view.ts @@ -213,14 +213,14 @@ export class StreamInfoView extends ViewHandler { * Returns the enabled platforms according to their assigned display */ get activeDisplayPlatforms(): TDisplayPlatforms { - const platformDisplays = { horizontal: [] as TPlatform[], vertical: [] as TPlatform[] }; - - for (const platform in this.enabledPlatforms) { - const display = this.settings.platforms[platform as TPlatform]?.display ?? 'horizontal'; - platformDisplays[display].push(platform as TPlatform); - } - - return platformDisplays; + return this.enabledPlatforms.reduce( + (displayPlatforms: TDisplayPlatforms, platform: TPlatform) => { + const display = this.settings.platforms[platform]?.display ?? 'horizontal'; + displayPlatforms[display].push(platform); + return displayPlatforms; + }, + { horizontal: [], vertical: [] }, + ); } /** diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index b2a81b6731b0..1f0773b22733 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -395,11 +395,13 @@ export class StreamingService * SET DUAL OUTPUT SETTINGS */ if (this.views.isDualOutputMode) { - const horizontalStream: string[] = this.views.activeDisplayDestinations.horizontal; - horizontalStream.concat(this.views.activeDisplayPlatforms.horizontal as string[]); + const horizontalDestinations: string[] = this.views.activeDisplayDestinations.horizontal; + const horizontalPlatforms: TPlatform[] = this.views.activeDisplayPlatforms.horizontal; + const horizontalStream = horizontalDestinations.concat(horizontalPlatforms as string[]); - const verticalStream: string[] = this.views.activeDisplayDestinations.vertical; - verticalStream.concat(this.views.activeDisplayPlatforms.vertical as string[]); + const verticalDestinations: string[] = this.views.activeDisplayDestinations.vertical; + const verticalPlatforms: TPlatform[] = this.views.activeDisplayPlatforms.vertical; + const verticalStream = verticalDestinations.concat(verticalPlatforms as string[]); const allPlatforms = this.views.enabledPlatforms; const allDestinations = this.views.customDestinations From c0c4ce3052d13f90d60f7e6dfb9f32222b016f3e Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Tue, 26 Nov 2024 13:12:10 -0800 Subject: [PATCH 65/97] fix(widgets): stream boss fails to load settings (#5231) --- app/services/widgets/widgets-data.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/widgets/widgets-data.ts b/app/services/widgets/widgets-data.ts index f4733ea65fce..32235b64ecaa 100644 --- a/app/services/widgets/widgets-data.ts +++ b/app/services/widgets/widgets-data.ts @@ -371,7 +371,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.StreamBoss]: { name: 'Stream Boss', - humanType: 'stream_boss', + humanType: 'streamboss', url(host, token) { return `https://${host}/widgets/streamboss?token=${token}`; }, From a94e0a965193c28a57ae2ddabf31c79306e40c5c Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Tue, 26 Nov 2024 18:57:20 -0500 Subject: [PATCH 66/97] Add missing strings. (#5234) --- app/i18n/en-US/overlays.json | 6 +++++- app/i18n/en-US/troubleshooter.json | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/i18n/en-US/overlays.json b/app/i18n/en-US/overlays.json index e00b95543f68..b33b0f7b8626 100644 --- a/app/i18n/en-US/overlays.json +++ b/app/i18n/en-US/overlays.json @@ -19,5 +19,9 @@ "Unable to convert dual output collection.": "Unable to convert dual output collection.", "default_width": "default_width", "default_height": "default_height", - "GameCapture.WindowInternalMode": "GameCapture.WindowInternalMode" + "GameCapture.WindowInternalMode": "GameCapture.WindowInternalMode", + "The below will create a copy of the active scene collection, set the copy as the active collection, and then remove all vertical sources.": "The below will create a copy of the active scene collection, set the copy as the active collection, and then remove all vertical sources.", + "Show Components Library": "Show Components Library", + "Convert Dual Output Scene Collection": "Convert Dual Output Scene Collection", + "Repair Scene Collection": "Repair Scene Collection" } diff --git a/app/i18n/en-US/troubleshooter.json b/app/i18n/en-US/troubleshooter.json index 58b395907ea2..d8bcdacff9d5 100644 --- a/app/i18n/en-US/troubleshooter.json +++ b/app/i18n/en-US/troubleshooter.json @@ -22,5 +22,6 @@ "Enable VSync in your game": "Enable VSync in your game", "Disable FreeSync or GSync in your Driver": "Disable FreeSync or GSync in your Driver", "Lower graphics settings until you stop lagging frames": "Lower graphics settings until you stop lagging frames", - "Disable hardware decoding under any media sources(This will slightly increase cpu over gpu)": "Disable hardware decoding under any media sources(This will slightly increase cpu over gpu)" + "Disable hardware decoding under any media sources(This will slightly increase cpu over gpu)": "Disable hardware decoding under any media sources(This will slightly increase cpu over gpu)", + "CPU usage threshold in Dual Output mode": "CPU usage threshold in Dual Output mode" } From 2885807715f3b8535ec401660c9d459f724562e4 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Tue, 26 Nov 2024 19:16:59 -0500 Subject: [PATCH 67/97] Fix unknown stream error diag report message. (#5233) --- app/services/diagnostics.ts | 42 ++++++++++++++++++++++++----- app/services/streaming/streaming.ts | 16 ++++++----- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/app/services/diagnostics.ts b/app/services/diagnostics.ts index 8659c1d2b0fd..f6ac105968bb 100644 --- a/app/services/diagnostics.ts +++ b/app/services/diagnostics.ts @@ -31,7 +31,7 @@ import * as remote from '@electron/remote'; import { AppService } from 'services/app'; import fs from 'fs'; import path from 'path'; -import { TPlatform } from './platforms'; +import { platformList, TPlatform } from './platforms'; import { TDisplayType } from './settings-v2'; interface IStreamDiagnosticInfo { @@ -329,11 +329,36 @@ export class DiagnosticsService extends PersistentStatefulService { + if (/^\d+$/.test(platform)) { + const index = parseInt(platform, 10); + return platformList[index]; + } + return platform; + }); + + return JSON.stringify(names).slice(1, -1); + } + private formatSimpleOutputInfo() { const settings = this.outputSettingsService.getSettings(); const values = this.settingsService.views.values.Output; @@ -458,7 +483,8 @@ export class DiagnosticsService extends PersistentStatefulService { + const platforms = this.validatePlatforms(s?.platforms); + if ( s?.type === 'Single Output' && - s?.platforms.includes('tiktok') && + platforms.includes('tiktok') && s?.error.split(' ').at(-1) === '422' ) { this.logProblem( @@ -1005,7 +1033,7 @@ export class DiagnosticsService extends PersistentStatefulService Date: Wed, 27 Nov 2024 15:57:00 -0500 Subject: [PATCH 68/97] Restore default sources for new users. (#5232) * Restore default sources for new users. * Improve convert single output to dual output test. * Fix strictnulls. --- .../scene-collections/scene-collections.ts | 10 ++ app/services/dual-output/dual-output.ts | 53 --------- app/services/onboarding.ts | 36 ++++++- .../scene-collections/nodes/scene-items.ts | 4 +- .../scene-collections/scene-collections.ts | 75 ++++++++++++- app/services/scenes/scene-item.ts | 4 +- app/services/settings-v2/video.ts | 4 +- app/services/user/index.ts | 4 + test/regular/api/dual-output.ts | 101 ++++++++++++++---- test/regular/onboarding.ts | 20 ++-- 10 files changed, 218 insertions(+), 93 deletions(-) diff --git a/app/services/api/external-api/scene-collections/scene-collections.ts b/app/services/api/external-api/scene-collections/scene-collections.ts index 0265475a374e..5c509f0ab0a2 100644 --- a/app/services/api/external-api/scene-collections/scene-collections.ts +++ b/app/services/api/external-api/scene-collections/scene-collections.ts @@ -67,6 +67,16 @@ export class SceneCollectionsService { }; } + /** + * Gets whether or not this is a new user's first login. This is set + * on load when the app attempts to sync scene collections with the + * cloud for the first time and signifies that no scene collections + * were found. + */ + get newUserFirstLogin(): boolean { + return this.sceneCollectionsService.newUserFirstLogin; + } + /** * Provides the scene collection's schema including all scenes, scene nodes * and sources. This operation is expensive and should be avoided if possible. diff --git a/app/services/dual-output/dual-output.ts b/app/services/dual-output/dual-output.ts index 4906d86612d7..e6e120e9c356 100644 --- a/app/services/dual-output/dual-output.ts +++ b/app/services/dual-output/dual-output.ts @@ -797,59 +797,6 @@ export class DualOutputService extends PersistentStatefulService s?.type === type); - - if (!webCam) { - const cam = scene.createAndAddSource('Webcam', type, { display: 'horizontal' }); - this.createPartnerNode(cam); - } else { - const cam = scene.addSource(webCam.sourceId, { display: 'horizontal' }); - this.createPartnerNode(cam); - } - - // add alert box widget - this.widgetsService.createWidget(WidgetType.AlertBox, 'Alert Box'); - - // toggle dual output mode and vertical display - this.toggleDisplay(true, 'vertical'); - this.toggleDualOutputMode(true); - - this.collectionHandled.next(); - } - /** * Show/hide displays * diff --git a/app/services/onboarding.ts b/app/services/onboarding.ts index 839147189739..a59c0dcb1bd3 100644 --- a/app/services/onboarding.ts +++ b/app/services/onboarding.ts @@ -294,7 +294,7 @@ export class OnboardingService extends StatefulService return await Promise.all(Object.keys(this.themeMetadata).map(id => this.fetchThemeData(id))); } - get themeMetadata() { + get themeMetadata(): { [id: number]: string } { return this.userService.views.isPrime ? THEME_METADATA.PAID : THEME_METADATA.FREE; } @@ -317,6 +317,33 @@ export class OnboardingService extends StatefulService ); } + get shouldAddDefaultSources() { + // Add default sources if the user did not install a theme during onboarding + if (!this.existingSceneCollections) return true; + + // Skip checking creation date for accounts in when testing + if (Utils.isTestMode()) return false; + + // If the user does not have a creation date, they are a new user. Check to see if this + // new user has installed a theme during onboarding + const creationDate = this.userService.state?.createdAt; + if (!creationDate && !this.existingSceneCollections) { + return true; + } + + // Otherwise, check if the user is within the first 6 hours of their + // account creation date/time. This is last resort very rough check to determine + // if the user is a new user. Not ideal but better than nothing. + const now = new Date().getTime(); + const creationTime = new Date(creationDate).getTime(); + const millisecondsInAnHour = 1000 * 60 * 60; + + const isWithinCreationDateRange = + creationTime < now && creationTime - now < millisecondsInAnHour * 6; + + return isWithinCreationDateRange && !this.existingSceneCollections; + } + init() { this.setExistingCollections(); } @@ -358,6 +385,13 @@ export class OnboardingService extends StatefulService streaming: { outputResolution }, }); } + + // On their first login, new users should have a few default sources if they did not + // select a scene collection during onboarding. + if (this.sceneCollectionsService.newUserFirstLogin) { + this.sceneCollectionsService.setupDefaultSources(this.shouldAddDefaultSources); + } + this.navigationService.navigate('Studio'); this.onboardingCompleted.next(); } diff --git a/app/services/scene-collections/nodes/scene-items.ts b/app/services/scene-collections/nodes/scene-items.ts index 23567ed719d5..a1aa2876d014 100644 --- a/app/services/scene-collections/nodes/scene-items.ts +++ b/app/services/scene-collections/nodes/scene-items.ts @@ -143,9 +143,7 @@ export class SceneItemsNode extends Node { // but if the scene item already has a display assigned, skip it if (this.dualOutputService.views.hasNodeMap(context.scene.id)) { // nodes must be assigned to a context, so if it doesn't exist, establish it - if (!this.videoSettingsService.contexts.vertical) { - this.videoSettingsService.establishVideoContext('vertical'); - } + this.videoSettingsService.validateVideoContext(); const nodeMap = this.dualOutputService.views.sceneNodeMaps[context.scene.id]; diff --git a/app/services/scene-collections/scene-collections.ts b/app/services/scene-collections/scene-collections.ts index c0b0cd368748..7ae970e878bd 100644 --- a/app/services/scene-collections/scene-collections.ts +++ b/app/services/scene-collections/scene-collections.ts @@ -14,7 +14,7 @@ import { SceneFiltersNode } from './nodes/scene-filters'; import path from 'path'; import { parse } from './parse'; import { ScenesService, TSceneNode } from 'services/scenes'; -import { SourcesService } from 'services/sources'; +import { SourcesService, TSourceType } from 'services/sources'; import { E_AUDIO_CHANNELS } from 'services/audio'; import { AppService } from 'services/app'; import { RunInLoadingMode } from 'services/app/app-decorators'; @@ -44,6 +44,7 @@ import { GuestCamNode } from './nodes/guest-cam'; import { DualOutputService } from 'services/dual-output'; import { NodeMapNode } from './nodes/node-map'; import { VideoSettingsService } from 'services/settings-v2'; +import { WidgetsService, WidgetType } from 'services/widgets'; const uuid = window['require']('uuid/v4'); @@ -94,6 +95,7 @@ export class SceneCollectionsService extends Service implements ISceneCollection @Inject() dualOutputService: DualOutputService; @Inject() videoSettingsService: VideoSettingsService; @Inject() private defaultHardwareService: DefaultHardwareService; + @Inject() private widgetsService: WidgetsService; collectionAdded = new Subject(); collectionRemoved = new Subject(); @@ -113,6 +115,11 @@ export class SceneCollectionsService extends Service implements ISceneCollection */ private syncPending = false; + /** + * Used to handle actions for users on their first login + */ + newUserFirstLogin = false; + /** * Does not use the standard init function so we can have asynchronous * initialization. @@ -601,6 +608,11 @@ export class SceneCollectionsService extends Service implements ISceneCollection await root.load(); this.hotkeysService.bindHotkeys(); + + // Users who selected a theme during onboarding should skip adding default sources + if (this.newUserFirstLogin) { + this.newUserFirstLogin = false; + } } /** @@ -823,6 +835,17 @@ export class SceneCollectionsService extends Service implements ISceneCollection const serverCollections = (await this.serverApi.fetchSceneCollections()).data; + // A user who has never logged in before and did not install a + // theme during onboarding will have no collections. To prevent + // special handling of the default theme for a user who installed + // a theme during onboarding. NOTE: this will be set to false after + // onboarding in the dual output service + if (!serverCollections || serverCollections.length === 0) { + this.newUserFirstLogin = true; + } else { + this.newUserFirstLogin = false; + } + let failed = false; const collectionsToInsert = []; @@ -1019,6 +1042,52 @@ export class SceneCollectionsService extends Service implements ISceneCollection return this.userService.isLoggedIn && !this.appService.state.argv.includes('--nosync'); } + /** + * Creates default sources for new users + * @remark New users should be in single output mode and have a few default sources. + */ + setupDefaultSources(shouldAddDefaultSources: boolean) { + if (!shouldAddDefaultSources) { + this.newUserFirstLogin = false; + return; + } + + const scene = + this.scenesService.views.activeScene ?? + this.scenesService.createScene('Scene', { makeActive: true }); + + if (!scene) { + console.error('Default scene not found, failed to create default sources.'); + return; + } + + // add game capture source + scene.createAndAddSource('Game Capture', 'game_capture', {}, { display: 'horizontal' }); + + // add webcam source + const type = byOS({ + [OS.Windows]: 'dshow_input', + [OS.Mac]: 'av_capture_input', + }) as TSourceType; + + const defaultSource = this.defaultHardwareService.state.defaultVideoDevice; + + const webCam = defaultSource + ? this.sourcesService.views.getSource(defaultSource) + : this.sourcesService.views.sources.find(s => s?.type === type); + + if (!webCam) { + scene.createAndAddSource('Webcam', type, { display: 'horizontal' }); + } else { + scene.addSource(webCam.sourceId, { display: 'horizontal' }); + } + + // add alert box widget + this.widgetsService.createWidget(WidgetType.AlertBox, 'Alert Box'); + + this.newUserFirstLogin = false; + } + /** * Add a scene node map * @@ -1035,9 +1104,7 @@ export class SceneCollectionsService extends Service implements ISceneCollection if (!this.activeCollection) return; - if (!this.videoSettingsService.contexts.vertical) { - this.videoSettingsService.establishVideoContext('vertical'); - } + this.stateService.initNodeMaps(sceneNodeMap); } /** diff --git a/app/services/scenes/scene-item.ts b/app/services/scenes/scene-item.ts index bfabfcb49792..91f85f5b6d95 100644 --- a/app/services/scenes/scene-item.ts +++ b/app/services/scenes/scene-item.ts @@ -298,8 +298,8 @@ export class SceneItem extends SceneItemNode { const display = customSceneItem?.display ?? this?.display ?? 'horizontal'; // guarantee vertical context exists to prevent null errors - if (display === 'vertical' && !this.videoSettingsService.contexts.vertical) { - this.videoSettingsService.establishVideoContext('vertical'); + if (display === 'vertical') { + this.videoSettingsService.validateVideoContext('vertical'); } const context = this.videoSettingsService.contexts[display]; diff --git a/app/services/settings-v2/video.ts b/app/services/settings-v2/video.ts index 34d98b42e804..2a31f6d191f5 100644 --- a/app/services/settings-v2/video.ts +++ b/app/services/settings-v2/video.ts @@ -461,8 +461,10 @@ export class VideoSettingsService extends StatefulService { fpsSettings.forEach((setting: keyof IVideoInfo) => { const hasSameVideoSetting = - this.contexts.horizontal.video[setting as string] === verticalVideoSetting; + this.contexts.horizontal.video[setting as keyof IVideoInfo] === + verticalVideoSetting[setting as keyof IVideoInfo]; let shouldUpdate = hasSameVideoSetting; + // if the vertical context has been established, also compare legacy settings if (this.contexts.vertical) { const hasSameLegacySetting = diff --git a/app/services/user/index.ts b/app/services/user/index.ts index 7542fb11090e..727b7292af99 100644 --- a/app/services/user/index.ts +++ b/app/services/user/index.ts @@ -532,6 +532,10 @@ export class UserService extends PersistentStatefulService { this.streamSettingsService.resetStreamSettings(); await this.login(service, auth); + if (isNewUser) { + this.sceneCollectionsService.newUserFirstLogin = true; + } + if (!isOnboardingTest) this.onboardingService.finish(); } diff --git a/test/regular/api/dual-output.ts b/test/regular/api/dual-output.ts index 6a66bee8e399..7b439778f8df 100644 --- a/test/regular/api/dual-output.ts +++ b/test/regular/api/dual-output.ts @@ -1,11 +1,55 @@ import { DualOutputService } from 'services/dual-output'; import { getApiClient } from '../../helpers/api-client'; import { test, useWebdriver, TExecutionContext } from '../../helpers/webdriver'; -import { ScenesService } from 'services/scenes'; +import { ScenesService, Scene, SceneItem } from 'services/scenes'; import { VideoSettingsService } from 'services/settings-v2/video'; +// not a react hook +// eslint-disable-next-line react-hooks/rules-of-hooks useWebdriver(); +function confirmDualOutputSources(t: TExecutionContext, scene: Scene) { + const numSceneItems = scene + .getItems() + .map(item => item.getModel()) + .reduce((sources, item) => { + // only track number of sources that should be + if (sources[item.sourceId]) { + sources[item.sourceId] += 1; + } else { + sources[item.sourceId] = 1; + } + return sources; + }, {} as { [sourceId: string]: number }); + + // dual output scene collections should have and even number of scene items + // because a dual output scene item scene item is a pair of horizontal and vertical + // nodes that share a single source. + for (const [sourceId, count] of Object.entries(numSceneItems)) { + t.is(count % 2, 0, `Scene does not have dual output source ${sourceId}`); + } +} + +function confirmVerticalSceneItem( + t: TExecutionContext, + scene: Scene, + horizontalSceneItem: SceneItem, + verticalSceneItemId: string, +) { + const verticalSceneItem = scene.getItem(verticalSceneItemId); + t.is( + verticalSceneItem?.display, + 'vertical', + `Vertical scene item ${verticalSceneItem.id} display is correct`, + ); + + t.is( + verticalSceneItem?.sourceId, + horizontalSceneItem.sourceId, + `Vertical scene item ${verticalSceneItem.id} and horizontal scene item ${horizontalSceneItem.id} share the same source`, + ); +} + test('Convert single output collection to dual output', async (t: TExecutionContext) => { const client = await getApiClient(); const scenesService = client.getResource('ScenesService'); @@ -30,32 +74,49 @@ test('Convert single output collection to dual output', async (t: TExecutionCont dualOutputService.convertSingleOutputToDualOutputCollection(); const sceneNodeMaps = (await client.fetchNextEvent()).data; - t.not(sceneNodeMaps, null); + t.not(sceneNodeMaps, null, 'Dual output scene collection has node maps.'); const nodeMap = sceneNodeMaps[scene.id]; const verticalContext = videoSettingsService.contexts.vertical; + const sceneItems = scene.getItems(); - scene.getItems().forEach(sceneItem => { - const item = { - id: sceneItem.id, - sourceId: sceneItem.sourceId, - display: sceneItem.display, - }; + // confirm dual output collection length is double the single output collection length + const dualOutputLength = sceneItems.length; + t.is(singleOutputLength * 2, dualOutputLength); - // confirm source and entry in node map + // confirm that converting the single output collection to a dual output collection did not add sources + confirmDualOutputSources(t, scene); + + // confirm scene items are in node map, have the correct source, and the correct video context + sceneItems.forEach(sceneItem => { if (sceneItem?.display === 'horizontal') { - const verticalItem = scene.getItem(nodeMap[sceneItem.id]); - t.is(verticalItem?.display, 'vertical'); - t.is(verticalItem?.sourceId, sceneItem.sourceId); - } + const verticalNodeId = nodeMap[sceneItem.id]; + t.truthy(verticalNodeId, `Vertical node id exists for horizontal scene item ${sceneItem.id}`); - // confirm video context - const context = sceneItem?.display === 'vertical' ? verticalContext : horizontalContext; - t.deepEqual(sceneItem?.output, context); - }); + // confirm properties for vertical scene item + confirmVerticalSceneItem(t, scene, sceneItem, verticalNodeId); - const dualOutputLength = scene.getItems().length; + // confirm video context for horizontal scene item + t.deepEqual( + sceneItem?.output, + horizontalContext, + `Horizontal scene item ${sceneItem.id} has correct video context`, + ); + } else { + const horizontalNodeId = Object.keys(nodeMap).find( + nodeId => nodeMap[nodeId] === sceneItem.id, + ); + t.truthy( + horizontalNodeId, + `Horizontal node id exists for vertical scene item ${sceneItem.id}`, + ); - // confirm dual output collection length is double the single output collection length - t.is(singleOutputLength * 2, dualOutputLength); + // confirm video context for vertical scene item + t.deepEqual( + sceneItem?.output, + verticalContext, + `Vertical scene item ${sceneItem.id} has correct video context`, + ); + } + }); }); diff --git a/test/regular/onboarding.ts b/test/regular/onboarding.ts index 14abb94e8bce..30aecafac6b8 100644 --- a/test/regular/onboarding.ts +++ b/test/regular/onboarding.ts @@ -51,9 +51,11 @@ async function confirmDefaultSources(t: TExecutionContext, hasDefaultSources = t }, {} as { [sourceId: string]: number }); if (hasDefaultSources) { - // dual output scene collections should have 2 scene items that share a single source + // confirm this is a single output scene collection by confirming that each source + // is only used by a single scene item. This is because dual output scene collection + // scene items share a single source. for (const [sourceId, count] of Object.entries(numSceneItems)) { - t.is(count, 2, `Scene has dual output source ${sourceId}`); + t.is(count, 1, `Scene has only once scene item with source ${sourceId}`); } t.is(Object.keys(numSceneItems).length, numDefaultSources, 'Scene has correct default sources'); @@ -213,7 +215,7 @@ test.skip('Go through onboarding and install theme', async t => { await goThroughOnboarding(t, login, newUser, installTheme, async () => { // Confirm sources and dual output status t.not(await getNumElements('div[data-role=source]'), 0, 'Theme installed before login'); - t.true(await isDisplayed('i[data-testid=dual-output-inactive]'), 'Dual output not enabled'); + t.true(await isDisplayed('i[data-testid=dual-output-inactive]'), 'Single output enabled'); // login new user after onboarding await clickIfDisplayed('li[data-testid=nav-auth]'); @@ -224,14 +226,14 @@ test.skip('Go through onboarding and install theme', async t => { // Confirm switched to scene with default sources and dual output status await confirmDefaultSources(t); - t.true(await isDisplayed('i[data-testid=dual-output-active]'), 'Dual output enabled.'); + t.true(await isDisplayed('i[data-testid=dual-output-inactive]'), 'Single output enabled.'); }); t.pass(); }); // CASE 3: New user logged in during onboarding, no theme installed -test.skip('Go through onboarding as a new user', async t => { +test('Go through onboarding as a new user', async t => { const login = true; const newUser = true; const installTheme = false; @@ -239,7 +241,7 @@ test.skip('Go through onboarding as a new user', async t => { await goThroughOnboarding(t, login, newUser, installTheme, async () => { // Confirm sources and dual output status await confirmDefaultSources(t); - t.true(await isDisplayed('i[data-testid=dual-output-active]'), 'Dual output enabled.'); + t.true(await isDisplayed('i[data-testid=dual-output-inactive]'), 'Single output enabled.'); }); t.pass(); @@ -256,14 +258,14 @@ test.skip('Go through onboarding as a new user and install theme', async t => { await goThroughOnboarding(t, login, newUser, installTheme, async () => { // Confirm sources and dual output status await confirmDefaultSources(t, hasDefaultSources); - t.false(await isDisplayed('i[data-testid=dual-output-inactive]'), 'Dual output enabled.'); + t.true(await isDisplayed('i[data-testid=dual-output-inactive]'), 'Single output enabled.'); }); t.pass(); }); // CASE 5: No user logged in during onboarding, no theme installed, then log in new user -test.skip('Login new user after onboarding skipped', async t => { +test('Login new user after onboarding skipped', async t => { const login = false; const newUser = false; const installTheme = false; @@ -278,7 +280,7 @@ test.skip('Login new user after onboarding skipped', async t => { // Confirm switched to scene with default sources and dual output status await confirmDefaultSources(t); - t.true(await isDisplayed('i[data-testid=dual-output-active]'), 'Dual output enabled.'); + t.true(await isDisplayed('i[data-testid=dual-output-inactive]'), 'Dual output not enabled.'); }); t.pass(); From da1ebc4b8ce13414c17403012bbab9ad48a555b6 Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Thu, 5 Dec 2024 17:39:48 +0000 Subject: [PATCH 69/97] feat: primary chat switcher without restream (#5238) Show primary chat switcher on dual output for all users. Remove check that ensured multistream was enabled when in dual output mode, as long as the user has two supported platforms enabled they should be able to switch chats. --- app/components-react/windows/go-live/EditStreamWindow.tsx | 3 +-- app/components-react/windows/go-live/GoLiveSettings.tsx | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/app/components-react/windows/go-live/EditStreamWindow.tsx b/app/components-react/windows/go-live/EditStreamWindow.tsx index c00481e46b91..2fcd6b4f9db1 100644 --- a/app/components-react/windows/go-live/EditStreamWindow.tsx +++ b/app/components-react/windows/go-live/EditStreamWindow.tsx @@ -31,7 +31,6 @@ export default function EditStreamWindow() { form, enabledPlatforms, hasMultiplePlatforms, - isRestreamEnabled, primaryChat, setPrimaryChat, } = useGoLiveSettingsRoot({ isUpdateMode: true }); @@ -89,7 +88,7 @@ export default function EditStreamWindow() { ); } - const shouldShowPrimaryChatSwitcher = isRestreamEnabled && hasMultiplePlatforms; + const shouldShowPrimaryChatSwitcher = hasMultiplePlatforms; return ( diff --git a/app/components-react/windows/go-live/GoLiveSettings.tsx b/app/components-react/windows/go-live/GoLiveSettings.tsx index b04b85225c80..dc8d5bd6ccc1 100644 --- a/app/components-react/windows/go-live/GoLiveSettings.tsx +++ b/app/components-react/windows/go-live/GoLiveSettings.tsx @@ -37,7 +37,6 @@ export default function DualOutputGoLiveSettings() { isLoading, isPrime, isDualOutputMode, - isRestreamEnabled, canAddDestinations, canUseOptimizedProfile, showSelector, @@ -89,9 +88,8 @@ export default function DualOutputGoLiveSettings() { const shouldShowLeftCol = isDualOutputMode ? true : protectedModeEnabled; const shouldShowAddDestButton = canAddDestinations && isPrime; - const shouldShowPrimaryChatSwitcher = isDualOutputMode - ? isRestreamEnabled && hasMultiplePlatforms - : hasMultiplePlatforms; + const shouldShowPrimaryChatSwitcher = hasMultiplePlatforms; + // TODO: make sure this doesn't jank the UI const leftPaneHeight = shouldShowPrimaryChatSwitcher ? '81%' : '100%'; From 84050ce347f9c4ad2b97e07bfc3305896b257295 Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Thu, 5 Dec 2024 18:52:49 +0000 Subject: [PATCH 70/97] fix: toggling behavior for Tiktok after Dual Output (#5239) ref: https://app.asana.com/0/1207748235152481/1208813184366087/f --- .../windows/go-live/DestinationSwitchers.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/app/components-react/windows/go-live/DestinationSwitchers.tsx b/app/components-react/windows/go-live/DestinationSwitchers.tsx index 6fcb5c21e5d7..4d2ea1ecb6fd 100644 --- a/app/components-react/windows/go-live/DestinationSwitchers.tsx +++ b/app/components-react/windows/go-live/DestinationSwitchers.tsx @@ -82,9 +82,18 @@ export function DestinationSwitchers(p: { showSelector?: boolean }) { * - Remove TikTok from the list without removing the other active platform if we're disabling TikTok itself. */ if (platform === 'tiktok') { - enabledPlatformsRef.current = enabled - ? enabledPlatformsRef.current - : enabledPlatformsRef.current.filter(platform => platform !== 'tiktok'); + if (enabled) { + /* + * If we had two platforms, none of which were tiktok, we still need to limit + * that to 1 platform without restreaming. + * This could happen when coming from having dual output enabled to off. + */ + enabledPlatformsRef.current = enabledPlatformsRef.current.slice(0, 1); + } else { + enabledPlatformsRef.current = enabledPlatformsRef.current.filter( + platform => platform !== 'tiktok', + ); + } } else { /* * Clearing this list ensures that when a new platform is selected, instead of enabling 2 platforms From 121e3d7d591302cc98929331d5ab7d934629d540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kalth=C3=B6fer?= Date: Thu, 5 Dec 2024 21:49:55 +0100 Subject: [PATCH 71/97] feat: update highlighter (#5236) * old but new highlighter # Conflicts: # app/components-react/pages/RecordingHistory.tsx * replaced inline css with classes * clips view cleanup * fix defining function in hook * removed updater + new preview modal * fix hotkey label issue * clean css settings view * removed unnessesary filtering * fixed deletion issues * fixed padding issues * audio fix * added translations * define whats saved in localstorage * order change * fix Implicit any in catch clause * fix linting errors * another linting fix * removed ai-stuff * Fix highlighter test --------- Co-authored-by: marvinoffers Co-authored-by: marvinoffers Co-authored-by: Sean Beyer --- .../highlighter/ClipPreview.m.less | 106 +++++ .../highlighter/ClipPreview.tsx | 199 ++++---- .../highlighter/ClipTrimmer.tsx | 4 +- .../highlighter/ClipsFilter.tsx | 34 ++ .../highlighter/ClipsView.m.less | 71 +++ .../highlighter/ClipsView.tsx | 387 +++++++++++++++ .../highlighter/ClipsViewModal.tsx | 128 +++++ .../highlighter/EditingControls.tsx | 170 +++++++ .../highlighter/ExportModal.tsx | 26 +- .../highlighter/MiniClipPreview.tsx | 31 ++ .../highlighter/PreviewModal.tsx | 12 +- .../highlighter/SettingsView.m.less | 85 ++++ .../highlighter/SettingsView.tsx | 199 ++++++++ app/components-react/highlighter/utils.ts | 217 +++++++++ app/components-react/pages/Highlighter.tsx | 414 ++-------------- app/components-react/shared/HotkeyBinding.tsx | 7 +- .../shared/inputs/SwitchInput.tsx | 12 +- app/i18n/en-US/highlighter.json | 12 +- .../ai-highlighter/ai-highlighter.ts | 49 ++ app/services/highlighter/audio-mixer.ts | 10 +- app/services/highlighter/clip.ts | 27 +- app/services/highlighter/frame-source.ts | 42 +- app/services/highlighter/index.ts | 448 ++++++++++++++---- app/services/navigation.ts | 10 +- test/regular/highlighter.ts | 1 + 25 files changed, 2092 insertions(+), 609 deletions(-) create mode 100644 app/components-react/highlighter/ClipPreview.m.less create mode 100644 app/components-react/highlighter/ClipsFilter.tsx create mode 100644 app/components-react/highlighter/ClipsView.m.less create mode 100644 app/components-react/highlighter/ClipsView.tsx create mode 100644 app/components-react/highlighter/ClipsViewModal.tsx create mode 100644 app/components-react/highlighter/EditingControls.tsx create mode 100644 app/components-react/highlighter/MiniClipPreview.tsx create mode 100644 app/components-react/highlighter/SettingsView.m.less create mode 100644 app/components-react/highlighter/SettingsView.tsx create mode 100644 app/components-react/highlighter/utils.ts create mode 100644 app/services/highlighter/ai-highlighter/ai-highlighter.ts diff --git a/app/components-react/highlighter/ClipPreview.m.less b/app/components-react/highlighter/ClipPreview.m.less new file mode 100644 index 000000000000..10a6aa22c253 --- /dev/null +++ b/app/components-react/highlighter/ClipPreview.m.less @@ -0,0 +1,106 @@ +@import '../../styles/index'; + +.highlighted { + background-color: #4f5e65; +} + +.preview-clip { + background-color: #2b383f; + border-radius: 16px; + display: flex; + gap: 16px; + overflow: hidden; +} + +.preview-image { + object-fit: none; + border-radius: 10px; +} + +.deleted-preview { + border-radius: 10px; + background: black; + vertical-align: middle; + display: inline-block; + position: relative; +} + +.deleted-icon { + position: absolute; + text-align: center; + width: 100%; + font-size: 72px; + top: 27%; +} + +.enable-button { + position: absolute; + top: 10px; + left: 10px; +} + +.preview-clip-moving { + position: absolute; + bottom: 10px; + padding-left: 10px; + padding-right: 10px; + left: 0; + width: 320px; + height: 130px; + justify-content: end; + display: flex; + align-items: center; + flex-direction: column; + gap: 8px; + pointer-events: none; + background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.8)); + transform: translateY(48px); + transition: transform 100ms; +} + +.preview-clip:hover { + .preview-clip-moving { + transform: translateY(12px); + padding-bottom: 12px; + } + .preview-clip-bottom-bar { + opacity: 1; + } +} + +.duration-info { + display: flex; +} + +.duration-label { + padding: 4px 6px; + background-color: #00000070; + border-radius: 4px; + color: white; +} + +.controls-container { + display: flex; + width: 100%; + justify-content: space-between; +} + +.highlighter-icon { + font-size: 19px; + transform: translateY(1px); +} + +.preview-clip-bottom-bar { + display: flex; + width: 100%; + justify-content: space-between; + opacity: 0; + transition: opacity 100ms; +} + +.action-button { + display: flex; + gap: 8px; + align-items: center; + pointer-events: auto; +} diff --git a/app/components-react/highlighter/ClipPreview.tsx b/app/components-react/highlighter/ClipPreview.tsx index eee00f0e4307..b38050eaded5 100644 --- a/app/components-react/highlighter/ClipPreview.tsx +++ b/app/components-react/highlighter/ClipPreview.tsx @@ -1,133 +1,122 @@ -import { IClip } from 'services/highlighter'; +import { TClip } from 'services/highlighter'; import { SCRUB_HEIGHT, SCRUB_WIDTH, SCRUB_FRAMES } from 'services/highlighter/constants'; -import React, { useMemo, useState } from 'react'; -import path from 'path'; +import React, { useState } from 'react'; import { Services } from 'components-react/service-provider'; import { BoolButtonInput } from 'components-react/shared/inputs/BoolButtonInput'; -import styles from '../pages/Highlighter.m.less'; -import cx from 'classnames'; -import { Tooltip } from 'antd'; +import styles from './ClipPreview.m.less'; +import { Button } from 'antd'; import { $t } from 'services/i18n'; +import { useVuex } from 'components-react/hooks'; export default function ClipPreview(props: { - clip: IClip; - showTrim: () => void; - showRemove: () => void; + clipId: string; + streamId: string | undefined; + emitShowTrim: () => void; + emitShowRemove: () => void; }) { const { HighlighterService } = Services; - const [scrubFrame, setScrubFrame] = useState(0); - const filename = useMemo(() => { - return path.basename(props.clip.path); - }, [props.clip.path]); - // Deleted clips always show as disabled - const enabled = props.clip.deleted ? false : props.clip.enabled; + const v = useVuex(() => ({ + clip: HighlighterService.views.clipsDictionary[props.clipId] as TClip, + })); + + const [scrubFrame, setScrubFrame] = useState(0); + const clipThumbnail = v.clip.scrubSprite || ''; + const enabled = v.clip.deleted ? false : v.clip.enabled; + + if (!v.clip) { + return
    deleted
    ; + } function mouseMove(e: React.MouseEvent) { const frameIdx = Math.floor((e.nativeEvent.offsetX / SCRUB_WIDTH) * SCRUB_FRAMES); - if (scrubFrame !== frameIdx) { setScrubFrame(frameIdx); } } function setEnabled(enabled: boolean) { - HighlighterService.actions.enableClip(props.clip.path, enabled); + HighlighterService.actions.enableClip(v.clip.path, enabled); } return ( -
    - {!props.clip.deleted && ( - - )} - {props.clip.deleted && ( -
    - +
    + {!v.clip.deleted && ( + -
    - )} - - - -
    - {/* TODO: Let's not use the same icon as studio mode */} - - + +
    + )} + + - - - - -
    -
    - {`${props.clip.deleted ? '[DELETED] ' : ''}${filename}`} + +
    +
    +
    + + {formatSecondsToHMS(v.clip.duration! - (v.clip.startTrim + v.clip.endTrim) || 0)} + +
    +
    + +
    +
    +
    + + +
    +
    ); } + +export function formatSecondsToHMS(seconds: number): string { + const totalSeconds = Math.round(seconds); + if (totalSeconds === 0) { + return '0s'; + } + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const remainingSeconds = totalSeconds % 60; + return `${hours !== 0 ? hours.toString() + 'h ' : ''} ${ + minutes !== 0 ? minutes.toString() + 'm ' : '' + }${remainingSeconds !== 0 ? remainingSeconds.toString() + 's' : ''}`; +} diff --git a/app/components-react/highlighter/ClipTrimmer.tsx b/app/components-react/highlighter/ClipTrimmer.tsx index d4b7af978327..a2b892c08505 100644 --- a/app/components-react/highlighter/ClipTrimmer.tsx +++ b/app/components-react/highlighter/ClipTrimmer.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState, RefObject } from 'react'; -import { IClip } from 'services/highlighter'; +import { TClip } from 'services/highlighter'; import { SCRUB_FRAMES, SCRUB_HEIGHT, SCRUB_WIDTH } from 'services/highlighter/constants'; import { Services } from 'components-react/service-provider'; import times from 'lodash/times'; @@ -27,7 +27,7 @@ function useStateRef(initialValue: T): [RefObject, (newValue: T) => void] ]; } -export default function ClipTrimmer(props: { clip: IClip }) { +export default function ClipTrimmer(props: { clip: TClip }) { const { HighlighterService, UsageStatisticsService } = Services; const videoRef = useRef(null); const timelineRef = useRef(null); diff --git a/app/components-react/highlighter/ClipsFilter.tsx b/app/components-react/highlighter/ClipsFilter.tsx new file mode 100644 index 000000000000..ec275dc195e1 --- /dev/null +++ b/app/components-react/highlighter/ClipsFilter.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Tabs, Button, Dropdown, Menu } from 'antd'; +import { FilterOutlined } from '@ant-design/icons'; + +const { TabPane } = Tabs; + +interface ClipsFilterProps { + activeFilter: string; + onFilterChange: (filter: string) => void; +} + +export default function ClipsFilter({ activeFilter, onFilterChange }: ClipsFilterProps) { + const additionalFiltersMenu = ( + + Filter by Duration + Filter by Date + + ); + + return ( +
    + + + + + + + + +
    + ); +} diff --git a/app/components-react/highlighter/ClipsView.m.less b/app/components-react/highlighter/ClipsView.m.less new file mode 100644 index 000000000000..50c069546b28 --- /dev/null +++ b/app/components-react/highlighter/ClipsView.m.less @@ -0,0 +1,71 @@ +@import '../../styles/index'; + +.clips-view-root { + position: relative; + width: 100%; + display: flex; + + :global(.ant-modal-wrap), + :global(.ant-modal-mask) { + position: absolute; + } + :global(.os-content-glue) { + width: 100% !important; + height: 100% !important; + } +} + +.container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +.header { + padding: 20px; + display: flex; + gap: 8px; + align-items: center; + cursor: pointer; +} + +.backButton { + padding-top: 2px; + border: none; + background: none; +} + +.title { + margin: 0; +} + +.clips-controls { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 0 20px 2px; + padding-top: 16px; +} + +.clips-container { + flex-grow: 1; + padding: 20px; +} + +.clip-loading-indicator { + width: 100%; + font-size: 16px; + margin: auto; + display: grid; + place-content: center; +} + +.clip-item { + border-radius: 18px; + margin: 10px 20px 10px 0; + display: inline-block; + background-color: #111111; + border-radius: 16px; +} diff --git a/app/components-react/highlighter/ClipsView.tsx b/app/components-react/highlighter/ClipsView.tsx new file mode 100644 index 000000000000..38082f9fbc15 --- /dev/null +++ b/app/components-react/highlighter/ClipsView.tsx @@ -0,0 +1,387 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import * as remote from '@electron/remote'; +import { Services } from 'components-react/service-provider'; +import styles from './ClipsView.m.less'; +import { EHighlighterView, IAiClip, IViewState, TClip } from 'services/highlighter'; +import { ReactSortable } from 'react-sortablejs'; +import Scrollable from 'components-react/shared/Scrollable'; +import { EditingControls } from './EditingControls'; +import { + aiFilterClips, + getCombinedClipsDuration, + sortClipsByOrder, + useOptimizedHover, +} from './utils'; +import ClipsViewModal from './ClipsViewModal'; +import { useVuex } from 'components-react/hooks'; +import { Button } from 'antd'; +import { SUPPORTED_FILE_TYPES } from 'services/highlighter/constants'; +import { $t } from 'services/i18n'; +import path from 'path'; +import ClipPreview from './ClipPreview'; +export type TModalClipsView = 'trim' | 'export' | 'preview' | 'remove'; + +interface IClipsViewProps { + id: string | undefined; + streamTitle: string | undefined; +} + +export default function ClipsView({ + props, + emitSetView, +}: { + props: IClipsViewProps; + emitSetView: (data: IViewState) => void; +}) { + const { HighlighterService, UsageStatisticsService } = Services; + const clipsAmount = useVuex(() => HighlighterService.views.clips.length); + const [clips, setClips] = useState<{ + ordered: { id: string }[]; + orderedFiltered: { id: string }[]; + }>({ ordered: [], orderedFiltered: [] }); + + const [activeFilter, setActiveFilter] = useState('all'); // Currently not using the setActiveFilter option + + const [clipsLoaded, setClipsLoaded] = useState(false); + const loadClips = useCallback(async (id: string | undefined) => { + await HighlighterService.actions.return.loadClips(id); + setClipsLoaded(true); + }, []); + + useEffect(() => { + setClipsLoaded(false); + setClips( + sortAndFilterClips( + HighlighterService.getClips(HighlighterService.views.clips, props.id), + props.id, + activeFilter, + ), + ); + loadClips(props.id); + }, [props.id, clipsAmount]); + + useEffect(() => { + setClips( + sortAndFilterClips( + HighlighterService.getClips(HighlighterService.views.clips, props.id), + props.id, + activeFilter, + ), + ); + }, [activeFilter]); + + useEffect(() => UsageStatisticsService.actions.recordFeatureUsage('Highlighter'), []); + + const [modal, setModal] = useState<{ modal: TModalClipsView; inspectedPathId?: string } | null>( + null, + ); + + function setClipOrder(listClips: { id: string }[], streamId: string | undefined) { + const newOrderOfSomeItems = listClips.map(c => c.id); + const allItemArray = clips.ordered.map(c => c.id); + const newClipArray = createFinalSortedArray(newOrderOfSomeItems, allItemArray); + const oldClipArray = clips.ordered.map(c => c.id); + + if (JSON.stringify(newClipArray) === JSON.stringify(oldClipArray)) { + return; + } else { + if (streamId) { + newClipArray.forEach((clipId, index) => { + const existingClip = HighlighterService.views.clipsDictionary[clipId]; + let updatedStreamInfo; + if (existingClip) { + updatedStreamInfo = { + ...existingClip.streamInfo, + [streamId]: { + ...existingClip.streamInfo?.[streamId], + orderPosition: index, + }, + }; + } + + HighlighterService.actions.UPDATE_CLIP({ + path: clipId, + streamInfo: updatedStreamInfo, + }); + }); + } else { + newClipArray.forEach((clip, index) => { + const clipPath = clip; + HighlighterService.actions.UPDATE_CLIP({ + path: clipPath, + globalOrderPosition: index, + }); + }); + } + + const updatedClips = newClipArray.map( + clipId => HighlighterService.views.clipsDictionary[clipId], + ); + + setClips({ + ordered: newClipArray.map(c => ({ id: c })), + orderedFiltered: filterClipsBySource(updatedClips, activeFilter).map(c => ({ id: c.path })), + }); + return; + } + } + function onDrop(e: React.DragEvent, streamId: string | undefined) { + const extensions = SUPPORTED_FILE_TYPES.map(e => `.${e}`); + const files: string[] = []; + let fi = e.dataTransfer.files.length; + while (fi--) { + const file = e.dataTransfer.files.item(fi)?.path; + if (file) files.push(file); + } + + const filtered = files.filter(f => extensions.includes(path.parse(f).ext)); + + if (filtered.length) { + HighlighterService.actions.addClips( + filtered.map(path => ({ path })), + streamId, + 'Manual', + ); + } + + e.preventDefault(); + e.stopPropagation(); + } + + const containerRef = useOptimizedHover(); + + function getClipsView( + streamId: string | undefined, + sortedList: { id: string }[], + sortedFilteredList: { id: string }[], + ) { + return ( +
    onDrop(event, streamId)} + > +
    +
    + +

    emitSetView({ view: EHighlighterView.SETTINGS })} + > + {props.streamTitle ?? $t('All highlight clips')} +

    +
    + {sortedList.length === 0 ? ( + /** Better empty state will come with ai PR */ +
    + {$t('No clips found')} +
    +
    + { + setClips( + sortAndFilterClips( + HighlighterService.getClips(HighlighterService.views.clips, props.id), + props.id, + activeFilter, + ), + ); + }} + /> +
    +
    + ) : ( + <> + {clipsLoaded ? ( + <> +
    + { + setClips( + sortAndFilterClips( + HighlighterService.getClips(HighlighterService.views.clips, props.id), + props.id, + activeFilter, + ), + ); + }} + /> +
    + + setClipOrder(clips, props.id)} + animation={200} + filter=".sortable-ignore" + onMove={e => { + return e.related.className.indexOf('sortable-ignore') === -1; + }} + > + {sortedFilteredList.map(({ id }) => { + const clip = HighlighterService.views.clipsDictionary[id]; + return ( +
    + { + setModal({ modal: 'trim', inspectedPathId: id }); + }} + emitShowRemove={() => { + setModal({ modal: 'remove', inspectedPathId: id }); + }} + streamId={streamId} + /> +
    + ); + })} +
    +
    + + ) : ( + + )} + + )} +
    + { + setModal({ modal }); + }} + /> + setModal(null)} + deleteClip={(clipId, streamId) => + setClips( + sortAndFilterClips( + HighlighterService.getClips(HighlighterService.views.clips, props.id).filter( + clip => clip.path !== clipId, + ), + streamId, + 'all', + ), + ) + } + /> +
    + ); + } + + return getClipsView( + props.id, + clips.ordered.map(clip => ({ id: clip.id })), + clips.orderedFiltered.map(clip => ({ id: clip.id })), + ); +} + +function AddClip({ + streamId, + addedClips, +}: { + streamId: string | undefined; + addedClips: () => void; +}) { + const { HighlighterService } = Services; + + async function openClips() { + const selections = await remote.dialog.showOpenDialog(remote.getCurrentWindow(), { + properties: ['openFile', 'multiSelections'], + filters: [{ name: $t('Video Files'), extensions: SUPPORTED_FILE_TYPES }], + }); + + if (selections && selections.filePaths) { + await HighlighterService.actions.return.addClips( + selections.filePaths.map(path => ({ path })), + streamId, + 'Manual', + ); + await HighlighterService.actions.return.loadClips(streamId); + addedClips(); + } + } + return ( + + ); +} + +function ClipsLoadingView({ streamId }: { streamId: string | undefined }) { + const { HighlighterService } = Services; + const clips = useVuex(() => + HighlighterService.getClips(HighlighterService.views.clips, streamId), + ); + + return ( +
    +

    {$t('Loading')}

    +

    + {clips.filter(clip => clip.loaded).length}/{clips.length} Clips +

    +
    + ); +} + +export function clipsToStringArray(clips: TClip[]): { id: string }[] { + return clips.map(c => ({ id: c.path })); +} + +export function createFinalSortedArray( + newOrderOfSomeItems: string[], + allItemArray: string[], +): string[] { + const finalArray: (string | null)[] = new Array(allItemArray.length).fill(null); + const itemsNotInNewOrder = allItemArray.filter(item => !newOrderOfSomeItems.includes(item)); + + itemsNotInNewOrder.forEach(item => { + const index = allItemArray.indexOf(item); + finalArray[index] = item; + }); + + let newOrderIndex = 0; + for (let i = 0; i < finalArray.length; i++) { + if (finalArray[i] === null) { + finalArray[i] = newOrderOfSomeItems[newOrderIndex]; + newOrderIndex++; + } + } + + return finalArray.filter((item): item is string => item !== null); +} + +export function filterClipsBySource(clips: TClip[], filter: string) { + return clips.filter(clip => { + switch (filter) { + case 'ai': + return clip.source === 'AiClip'; + case 'manual': + return clip.source === 'Manual' || clip.source === 'ReplayBuffer'; + case 'all': + default: + return true; + } + }); +} +export function sortAndFilterClips(clips: TClip[], streamId: string | undefined, filter: string) { + const orderedClips = sortClipsByOrder(clips, streamId); + const filteredClips = filterClipsBySource(orderedClips, filter); + const ordered = orderedClips.map(clip => ({ id: clip.path })); + const orderedFiltered = filteredClips.map(clip => ({ + id: clip.path, + })); + + return { ordered, orderedFiltered }; +} diff --git a/app/components-react/highlighter/ClipsViewModal.tsx b/app/components-react/highlighter/ClipsViewModal.tsx new file mode 100644 index 000000000000..9ca8b5f04ee6 --- /dev/null +++ b/app/components-react/highlighter/ClipsViewModal.tsx @@ -0,0 +1,128 @@ +import { useVuex } from 'components-react/hooks'; +import { Services } from 'components-react/service-provider'; +import React, { useEffect, useState } from 'react'; +import { TModalClipsView } from './ClipsView'; +import { TClip } from 'services/highlighter'; +import styles from './ClipsView.m.less'; +import ClipTrimmer from 'components-react/highlighter/ClipTrimmer'; +import { Modal, Alert, Button } from 'antd'; +import ExportModal from 'components-react/highlighter/ExportModal'; + +import { $t } from 'services/i18n'; +import PreviewModal from './PreviewModal'; + +export default function ClipsViewModal({ + streamId, + modal, + onClose, + deleteClip, +}: { + streamId: string | undefined; + modal: { modal: TModalClipsView; inspectedPathId?: string } | null; + onClose: () => void; + deleteClip: (clipPath: string, streamId: string | undefined) => void; +}) { + const { HighlighterService } = Services; + const v = useVuex(() => ({ + exportInfo: HighlighterService.views.exportInfo, + uploadInfo: HighlighterService.views.uploadInfo, + error: HighlighterService.views.error, + })); + const [showModal, rawSetShowModal] = useState(null); + const [modalWidth, setModalWidth] = useState('700px'); + const [inspectedClip, setInspectedClip] = useState(null); + + useEffect(() => { + if (modal?.inspectedPathId) { + setInspectedClip(HighlighterService.views.clipsDictionary[modal.inspectedPathId]); + } + if (modal?.modal) { + setShowModal(modal.modal); + } + }, [modal]); + + function setShowModal(modal: TModalClipsView | null) { + rawSetShowModal(modal); + + if (modal) { + setModalWidth( + { + trim: '60%', + preview: '700px', + export: '700px', + remove: '400px', + }[modal], + ); + } + } + function closeModal() { + // Do not allow closing export modal while export/upload operations are in progress + if (v.exportInfo.exporting) return; + if (v.uploadInfo.uploading) return; + + setInspectedClip(null); + setShowModal(null); + onClose(); + if (v.error) HighlighterService.actions.dismissError(); + } + + return ( + + {!!v.error && } + {inspectedClip && showModal === 'trim' && } + {showModal === 'export' && } + {showModal === 'preview' && } + {inspectedClip && showModal === 'remove' && ( + + )} + + ); +} + +function RemoveClip(p: { + clip: TClip; + streamId: string | undefined; + close: () => void; + deleteClip: (clipPath: string, streamId: string | undefined) => void; +}) { + const { HighlighterService } = Services; + + return ( +
    +

    {$t('Remove the clip?')}

    +

    + {$t( + 'Are you sure you want to remove the clip? You will need to manually import it again to reverse this action.', + )} +

    + + +
    + ); +} diff --git a/app/components-react/highlighter/EditingControls.tsx b/app/components-react/highlighter/EditingControls.tsx new file mode 100644 index 000000000000..1375d0cfc48d --- /dev/null +++ b/app/components-react/highlighter/EditingControls.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import Form from 'components-react/shared/inputs/Form'; +import { SliderInput, FileInput, SwitchInput } from 'components-react/shared/inputs'; +import { Button } from 'antd'; +import path from 'path'; +import Scrollable from 'components-react/shared/Scrollable'; +import Animate from 'rc-animate'; +import TransitionSelector from 'components-react/highlighter/TransitionSelector'; +import { $t } from 'services/i18n'; +import { TModalClipsView } from './ClipsView'; +import { useVuex } from 'components-react/hooks'; +import { Services } from 'components-react/service-provider'; +import { SUPPORTED_FILE_TYPES } from 'services/highlighter/constants'; +import { Clip } from 'services/highlighter/clip'; + +export function EditingControls({ + emitSetShowModal, +}: { + emitSetShowModal: (modal: TModalClipsView | null) => void; +}) { + const { HighlighterService } = Services; + + const v = useVuex(() => ({ + transition: HighlighterService.views.transition, + audio: HighlighterService.views.audio, + video: HighlighterService.views.video, + error: HighlighterService.views.error, + })); + + function setTransitionDuration(duration: number) { + HighlighterService.actions.setTransition({ duration }); + } + + function setMusicEnabled(enabled: boolean) { + HighlighterService.actions.setAudio({ musicEnabled: enabled }); + } + + const musicExtensions = ['mp3', 'wav', 'flac']; + const videoExtensions = SUPPORTED_FILE_TYPES; + + function setMusicFile(file: string) { + if (!musicExtensions.map(e => `.${e}`).includes(path.parse(file).ext)) return; + HighlighterService.actions.setAudio({ musicPath: file }); + } + + async function setVideoFile(file: string, type: 'intro' | 'outro') { + if (!videoExtensions.map(e => `.${e}`).includes(path.parse(file).ext)) return; + const tempClip = new Clip(file); + await tempClip.init(); + HighlighterService.actions.setVideo({ [type]: { path: file, duration: tempClip.duration } }); + } + function removeVideoFile(type: 'intro' | 'outro') { + HighlighterService.actions.setVideo({ [type]: { path: '', duration: null } }); + } + + function setMusicVolume(volume: number) { + HighlighterService.actions.setAudio({ musicVolume: volume }); + } + + return ( + + + + `${v}s`} + /> + +
    + { + setVideoFile(e, 'intro'); + }} + /> + {v.video.intro.path && ( +
    +
    + )} +
    +
    + { + setVideoFile(e, 'outro'); + }} + /> + {v.video.outro.path && ( +
    +
    + )} +
    + + + {v.audio.musicEnabled && ( +
    + + `${v}%`} + /> +
    + )} +
    + + + +
    + ); +} diff --git a/app/components-react/highlighter/ExportModal.tsx b/app/components-react/highlighter/ExportModal.tsx index d0b9c56f4747..5ad46dba9989 100644 --- a/app/components-react/highlighter/ExportModal.tsx +++ b/app/components-react/highlighter/ExportModal.tsx @@ -44,8 +44,8 @@ class ExportController { this.service.actions.setExportFile(exportFile); } - exportCurrentFile() { - this.service.actions.export(); + exportCurrentFile(streamId: string | undefined) { + this.service.actions.export(false, streamId); } cancelExport() { @@ -63,24 +63,30 @@ class ExportController { export const ExportModalCtx = React.createContext(null); -export default function ExportModalProvider(p: { close: () => void }) { +export default function ExportModalProvider({ + close, + streamId, +}: { + close: () => void; + streamId: string | undefined; +}) { const controller = useMemo(() => new ExportController(), []); return ( - + ); } -function ExportModal(p: { close: () => void }) { +function ExportModal({ close, streamId }: { close: () => void; streamId: string | undefined }) { const { exportInfo, dismissError } = useController(ExportModalCtx); // Clear all errors when this component unmounts useEffect(dismissError, []); if (exportInfo.exporting) return ; - if (!exportInfo.exported) return ; - return ; + if (!exportInfo.exported) return ; + return ; } function ExportProgress() { @@ -122,7 +128,7 @@ function ExportProgress() { ); } -function ExportOptions(p: { close: () => void }) { +function ExportOptions({ close, streamId }: { close: () => void; streamId: string | undefined }) { const { UsageStatisticsService } = Services; const { exportInfo, @@ -219,7 +225,7 @@ function ExportOptions(p: { close: () => void }) { /> )}
    - +
    +
    + + +
    +
    +
    +

    {$t('Manual Highlighter')}

    +

    + {$t( + 'The hotkey highlighter allows you to clip the best moments during your livestream manually and edit them together afterwards.', + )} +

    +
    + {!v.isStreaming && !correctlyConfigured && ( +
    + {correctlyConfigured + ? completedStepHeading($t('Configure the replay buffer')) + : incompleteStepHeading($t('Configure the replay buffer'))} + {correctlyConfigured ? ( +
    {$t('The replay buffer is correctly configured')}
    + ) : ( + + )} +
    + )} + {v.isStreaming && ( +
    +

    {$t('End your stream to change the Hotkey or the replay duration.')}

    +
    + )} + + {!v.isStreaming && ( +
    + {hotkey?.bindings.length + ? completedStepHeading($t('Set a hotkey to capture replays')) + : incompleteStepHeading($t('Set a hotkey to capture replays'))} + {hotkey && ( + { + const newHotkey = { ...hotkey }; + newHotkey.bindings.splice(0, 1, binding); + setHotkey(newHotkey); + hotkeyRef.current = newHotkey; + }} + /> + )} +
    + )} + {!v.isStreaming && ( +
    + {completedStepHeading($t('Adjust replay duration'))} +
    + `${v}s`} + /> + +
    + )} +
    +
    +
    + +
    +
    +
    +
    + ); +} diff --git a/app/components-react/highlighter/utils.ts b/app/components-react/highlighter/utils.ts new file mode 100644 index 000000000000..b0ed652476d9 --- /dev/null +++ b/app/components-react/highlighter/utils.ts @@ -0,0 +1,217 @@ +import moment from 'moment'; +import { IAiClip, TClip } from 'services/highlighter'; +import { useRef, useEffect, useCallback } from 'react'; +import styles from './ClipsView.m.less'; +import { EHighlighterInputTypes } from 'services/highlighter/ai-highlighter/ai-highlighter'; +export const isAiClip = (clip: TClip): clip is IAiClip => clip.source === 'AiClip'; + +export function sortClipsByOrder(clips: TClip[], streamId: string | undefined): TClip[] { + let sortedClips; + + if (streamId) { + const clipsWithOrder = clips + .filter(c => c.streamInfo?.[streamId]?.orderPosition !== undefined && c.deleted !== true) + .sort( + (a: TClip, b: TClip) => + a.streamInfo![streamId]!.orderPosition - b.streamInfo![streamId]!.orderPosition, + ); + + const clipsWithOutOrder = clips.filter( + c => + (c.streamInfo === undefined || + c.streamInfo[streamId] === undefined || + c.streamInfo[streamId]?.orderPosition === undefined) && + c.deleted !== true, + ); + + sortedClips = [...clipsWithOrder, ...clipsWithOutOrder]; + } else { + sortedClips = clips + .filter(c => c.deleted !== true) + .sort((a: TClip, b: TClip) => a.globalOrderPosition - b.globalOrderPosition); + } + + return sortedClips; +} + +export const useOptimizedHover = () => { + const containerRef = useRef(null); + const lastHoveredId = useRef(null); + + const handleHover = useCallback((event: MouseEvent) => { + const target = event.target as HTMLElement; + const clipElement = target.closest('[data-clip-id]'); + const clipId = clipElement?.getAttribute('data-clip-id'); + + if (clipId === lastHoveredId.current) return; // Exit if hovering over the same element + + if (lastHoveredId.current) { + // Remove highlight from previously hovered elements + document + .querySelectorAll(`[data-clip-id="${lastHoveredId.current}"]`) + .forEach(el => el instanceof HTMLElement && el.classList.remove(styles.highlighted)); + } + + if (clipId) { + // Add highlight to newly hovered elements + document + .querySelectorAll(`[data-clip-id="${clipId}"]`) + .forEach(el => el instanceof HTMLElement && el.classList.add(styles.highlighted)); + lastHoveredId.current = clipId; + } else { + lastHoveredId.current = null; + } + }, []); + + useEffect(() => { + const container = containerRef.current; + if (container) { + container.addEventListener('mousemove', handleHover, { passive: true }); + container.addEventListener('mouseleave', handleHover, { passive: true }); + + return () => { + container.removeEventListener('mousemove', handleHover); + container.removeEventListener('mouseleave', handleHover); + }; + } + }, [handleHover]); + + return containerRef; +}; + +export interface IFilterOptions { + rounds: number[]; + targetDuration: number; + includeAllEvents: boolean; +} + +export function aiFilterClips( + clips: TClip[], + streamId: string | undefined, + options: IFilterOptions, +): TClip[] { + const { rounds, targetDuration, includeAllEvents } = options; + + const selectedRounds = + rounds.length === 1 && rounds[0] === 0 + ? [ + ...new Set( + clips + .filter(clip => clip.source === 'AiClip') + .map(clip => (clip as IAiClip).aiInfo.metadata?.round), + ), + ] + : rounds; + + // console.log('selectedRounds', selectedRounds); + + // Sort rounds by score (descending) + const sortedRounds = selectedRounds.sort( + (a, b) => getRoundScore(b, clips) - getRoundScore(a, clips), + ); + + // console.log('sortedRounds by rooundScore', sortedRounds); + + let clipsFromRounds: TClip[] = []; + + let totalDuration = 0; + for (let i = 0; i < sortedRounds.length; ++i) { + if (totalDuration > targetDuration) { + // console.log(`Duration: ${totalDuration} more than target: ${targetDuration}`); + break; + } else { + // console.log(`Duration: ${totalDuration} less than target: ${targetDuration}`); + //Todo M: how do sort? Per round or all together and then the rounds are in the stream order again? + const roundIndex = sortedRounds[i]; + // console.log('include round ', roundIndex); + + const roundClips = sortClipsByOrder(getClipsOfRound(roundIndex, clips), streamId); + // console.log( + // 'roundClips before adding:', + // roundClips.map(c => ({ + // duration: c.duration, + // })), + // ); + + clipsFromRounds = [...clipsFromRounds, ...roundClips]; + + // console.log( + // 'clipsFromRounds after adding:', + // clipsFromRounds.map(c => ({ + // duration: c.duration, + // })), + // ); + totalDuration = getCombinedClipsDuration(clipsFromRounds); + // console.log('new totalDuration:', totalDuration); + } + // console.log('clipsFromRounds', clipsFromRounds); + } + const contextTypes = [ + EHighlighterInputTypes.DEPLOY, + EHighlighterInputTypes.DEATH, + EHighlighterInputTypes.VICTORY, + ]; + const clipsSortedByScore = clipsFromRounds + .filter( + clips => !(clips as IAiClip).aiInfo.inputs.some(input => contextTypes.includes(input.type)), + ) + .sort((a, b) => (a as IAiClip).aiInfo.score - (b as IAiClip).aiInfo.score); + // console.log( + // 'clipsSortedByScore', + // clipsSortedByScore.map(clip => { + // return { + // score: (clip as IAiClip).aiInfo.score, + // inputs: JSON.stringify((clip as IAiClip).aiInfo.inputs), + // }; + // }), + // ); + // console.log('clipsFromRounds', clipsFromRounds); + + const filteredClips: TClip[] = clipsFromRounds; + let currentDuration = getCombinedClipsDuration(filteredClips); + + // console.log('remove clipswise to get closer to target'); + + const BUFFER_SEC = 0; + while (currentDuration > targetDuration + BUFFER_SEC) { + // console.log('ruuun currentDuration', currentDuration); + if (clipsSortedByScore === undefined || clipsSortedByScore.length === 0) { + break; + } + + const clipToRemove = clipsSortedByScore[0]; + clipsSortedByScore.splice(0, 1); // remove from our sorted array + + const index = filteredClips.findIndex(clip => clip.path === clipToRemove.path); + + if (index > -1) { + filteredClips.splice(index, 1); // 2nd parameter means remove one item only + currentDuration = getCombinedClipsDuration(filteredClips); + // console.log( + // 'removed, new currentDuration:', + // currentDuration, + // 'target:', + // targetDuration + BUFFER_SEC, + // ); + } + } + + return filteredClips; +} + +export function getCombinedClipsDuration(clips: TClip[]): number { + return clips.reduce((sum, clip) => sum + (clip.duration || 0), 0); +} + +function getClipsOfRound(round: number, clips: TClip[]): TClip[] { + return clips.filter( + clip => clip.source === 'AiClip' && (clip as IAiClip).aiInfo.metadata.round === round, + ); +} + +function getRoundScore(round: number, clips: TClip[]): number { + return getClipsOfRound(round, clips).reduce( + (sum, clip) => sum + ((clip as IAiClip).aiInfo?.score || 0), + 0, + ); +} diff --git a/app/components-react/pages/Highlighter.tsx b/app/components-react/pages/Highlighter.tsx index 6a10c09ca5f4..02552861033c 100644 --- a/app/components-react/pages/Highlighter.tsx +++ b/app/components-react/pages/Highlighter.tsx @@ -1,381 +1,69 @@ +import SettingsView from 'components-react/highlighter/SettingsView'; import { useVuex } from 'components-react/hooks'; -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; +import { EHighlighterView, IViewState } from 'services/highlighter'; import { Services } from 'components-react/service-provider'; -import styles from './Highlighter.m.less'; -import { IClip } from 'services/highlighter'; -import ClipPreview from 'components-react/highlighter/ClipPreview'; -import ClipTrimmer from 'components-react/highlighter/ClipTrimmer'; -import { ReactSortable } from 'react-sortablejs'; -import Form from 'components-react/shared/inputs/Form'; -import isEqual from 'lodash/isEqual'; -import { SliderInput, FileInput, SwitchInput } from 'components-react/shared/inputs'; -import { Modal, Button, Alert } from 'antd'; -import ExportModal from 'components-react/highlighter/ExportModal'; -import PreviewModal from 'components-react/highlighter/PreviewModal'; -import BlankSlate from 'components-react/highlighter/BlankSlate'; -import { SCRUB_HEIGHT, SCRUB_WIDTH, SUPPORTED_FILE_TYPES } from 'services/highlighter/constants'; -import path from 'path'; -import Scrollable from 'components-react/shared/Scrollable'; -import { IHotkey } from 'services/hotkeys'; -import { getBindingString } from 'components-react/shared/HotkeyBinding'; -import Animate from 'rc-animate'; -import TransitionSelector from 'components-react/highlighter/TransitionSelector'; -import { $t } from 'services/i18n'; -import * as remote from '@electron/remote'; +import ClipsView from 'components-react/highlighter/ClipsView'; -type TModal = 'trim' | 'export' | 'preview' | 'remove'; +export default function Highlighter(props: { params?: { view: string } }) { + const openViewFromParams = props?.params?.view || ''; -export default function Highlighter() { - const { HighlighterService, HotkeysService, UsageStatisticsService } = Services; + const { HighlighterService } = Services; const v = useVuex(() => ({ - clips: HighlighterService.views.clips as IClip[], - exportInfo: HighlighterService.views.exportInfo, - uploadInfo: HighlighterService.views.uploadInfo, - loadedCount: HighlighterService.views.loadedCount, - loaded: HighlighterService.views.loaded, - transition: HighlighterService.views.transition, dismissedTutorial: HighlighterService.views.dismissedTutorial, - audio: HighlighterService.views.audio, - error: HighlighterService.views.error, + clips: HighlighterService.views.clips, })); - const [showModal, rawSetShowModal] = useState(null); - const [modalWidth, setModalWidth] = useState('700px'); - const [hotkey, setHotkey] = useState(null); - const [showTutorial, setShowTutorial] = useState(false); - - useEffect(() => { - if (v.clips.length) { - HighlighterService.actions.loadClips(); - setShowTutorial(false); - } - }, [v.clips.length]); - - useEffect(() => { - HotkeysService.actions.return.getGeneralHotkeyByName('SAVE_REPLAY').then(hotkey => { - if (hotkey) setHotkey(hotkey); - }); - }, []); - - useEffect(() => UsageStatisticsService.actions.recordFeatureUsage('Highlighter'), []); - - // This is kind of weird, but ensures that modals stay the right - // size while the closing animation is played. This is why modal - // width has its own state. This makes sure we always set the right - // size whenever displaying a modal. - function setShowModal(modal: TModal | null) { - rawSetShowModal(modal); - - if (modal) { - setModalWidth( - { - trim: '60%', - preview: '700px', - export: '700px', - remove: '400px', - }[modal], - ); - } - } - - function getLoadingView() { - return ( -
    -

    Loading

    - {v.loadedCount}/{v.clips.length} Clips -
    - ); - } - - function getControls() { - function setTransitionDuration(duration: number) { - HighlighterService.actions.setTransition({ duration }); - } - - function setMusicEnabled(enabled: boolean) { - HighlighterService.actions.setAudio({ musicEnabled: enabled }); - } - - const musicExtensions = ['mp3', 'wav', 'flac']; - - function setMusicFile(file: string) { - if (!musicExtensions.map(e => `.${e}`).includes(path.parse(file).ext)) return; - HighlighterService.actions.setAudio({ musicPath: file }); - } - - function setMusicVolume(volume: number) { - HighlighterService.actions.setAudio({ musicVolume: volume }); - } + const [viewState, setViewState] = useState( + v.clips.length === 0 + ? { view: EHighlighterView.SETTINGS } + : { view: EHighlighterView.CLIPS, id: undefined }, + ); - return ( - -
    - - `${v}s`} + switch (viewState.view) { + case EHighlighterView.CLIPS: + return ( + <> + { + setViewFromEmit(data); + }} + props={{ + id: viewState.id, + streamTitle: HighlighterService.views.highlightedStreams.find( + s => s.id === viewState.id, + )?.title, + }} /> - + ); + default: + return ( + <> + { + HighlighterService.actions.dismissTutorial(); + }} + emitSetView={data => setViewFromEmit(data)} /> - - {v.audio.musicEnabled && ( -
    - - `${v}%`} - /> -
    - )} -
    - - - -
    - ); - } - - function setClipOrder(clips: { id: string }[]) { - // ReactDraggable fires setList on mount. To avoid sync IPC, - // we only fire off a request if the order changed. - const oldOrder = v.clips.map(c => c.path); - const newOrder = clips.filter(c => c.id !== 'add').map(c => c.id); - - if (!isEqual(oldOrder, newOrder)) { - // Intentionally synchronous to avoid visual jank on drop - HighlighterService.setOrder(newOrder); - } - } - - const [inspectedClipPath, setInspectedClipPath] = useState(null); - let inspectedClip: IClip | null; - - if (inspectedClipPath) { - inspectedClip = v.clips.find(c => c.path === inspectedClipPath) ?? null; - } - - function closeModal() { - // Do not allow closing export modal while export/upload operations are in progress - if (v.exportInfo.exporting) return; - if (v.uploadInfo.uploading) return; - - setInspectedClipPath(null); - setShowModal(null); - - if (v.error) HighlighterService.actions.dismissError(); + + ); } - function getClipsView() { - const clipList = [{ id: 'add', filtered: true }, ...v.clips.map(c => ({ id: c.path }))]; - - function onDrop(e: React.DragEvent) { - const extensions = SUPPORTED_FILE_TYPES.map(e => `.${e}`); - const files: string[] = []; - let fi = e.dataTransfer.files.length; - while (fi--) { - const file = e.dataTransfer.files.item(fi)?.path; - if (file) files.push(file); - } - - const filtered = files.filter(f => extensions.includes(path.parse(f).ext)); - - if (filtered.length) { - HighlighterService.actions.addClips(filtered); - } - - e.stopPropagation(); + function setViewFromEmit(data: IViewState) { + if (data.view === EHighlighterView.CLIPS) { + setView({ + view: data.view, + id: data.id, + }); + } else { + setView({ + view: data.view, + }); } - - return ( -
    - -
    -
    -

    {$t('Highlighter')}

    -

    {$t('Drag & drop to reorder clips.')}

    -
    -
    - {hotkey && hotkey.bindings[0] && ( - {getBindingString(hotkey.bindings[0])} - )} - -
    -
    - { - return e.related.className.indexOf('sortable-ignore') === -1; - }} - > -
    - -
    - {v.clips.map(clip => { - return ( -
    - { - setInspectedClipPath(clip.path); - setShowModal('trim'); - }} - showRemove={() => { - setInspectedClipPath(clip.path); - setShowModal('remove'); - }} - /> -
    - ); - })} -
    -
    - {getControls()} - - {!!v.error && } - {inspectedClip && showModal === 'trim' && } - {showModal === 'export' && } - {showModal === 'preview' && } - {inspectedClip && showModal === 'remove' && ( - - )} - -
    - ); } - if ((!v.clips.length && !v.dismissedTutorial && !v.error) || showTutorial) { - return ( - { - setShowTutorial(false); - HighlighterService.actions.dismissTutorial(); - }} - /> - ); + function setView(view: IViewState) { + setViewState(view); } - if (!v.loaded) return getLoadingView(); - - return getClipsView(); -} - -function AddClip() { - const { HighlighterService } = Services; - - async function openClips() { - const selections = await remote.dialog.showOpenDialog(remote.getCurrentWindow(), { - properties: ['openFile', 'multiSelections'], - filters: [{ name: $t('Video Files'), extensions: SUPPORTED_FILE_TYPES }], - }); - - if (selections && selections.filePaths) { - HighlighterService.actions.addClips(selections.filePaths); - } - } - - return ( -
    -
    - - {$t('Add Clip')} -
    -

    {$t('Drag & drop or click to add clips')}

    -
    - ); -} - -function RemoveClip(p: { clip: IClip; close: () => void }) { - const { HighlighterService } = Services; - - return ( -
    -

    {$t('Remove the clip?')}

    -

    - {$t( - 'Are you sure you want to remove the clip? You will need to manually import it again to reverse this action.', - )} -

    - - -
    - ); } diff --git a/app/components-react/shared/HotkeyBinding.tsx b/app/components-react/shared/HotkeyBinding.tsx index 8bfa720ec1e3..3f5a337e5adf 100644 --- a/app/components-react/shared/HotkeyBinding.tsx +++ b/app/components-react/shared/HotkeyBinding.tsx @@ -65,12 +65,15 @@ export default function HotkeyBinding(p: { hotkey: IHotkey; binding: IBinding | null; onBind: (binding: IBinding) => void; + style?: React.CSSProperties; + showLabel?: boolean; }) { const { MarkersService, DualOutputService } = Services; const [focused, setFocused] = useState(false); const inputRef = useRef(null); + const hotKeyLabel = p.showLabel !== false ? : <>; const showDualOutputLabel = DualOutputService.views.dualOutputMode && p?.hotkey.actionName !== 'SWITCH_TO_SCENE' && @@ -150,8 +153,8 @@ export default function HotkeyBinding(p: {
    : } + style={{ width: 400, ...p.style }} + label={showDualOutputLabel ? : hotKeyLabel} value={getHotkeyString(p.binding, focused)} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} diff --git a/app/components-react/shared/inputs/SwitchInput.tsx b/app/components-react/shared/inputs/SwitchInput.tsx index ae9b42b49666..884117e9370b 100644 --- a/app/components-react/shared/inputs/SwitchInput.tsx +++ b/app/components-react/shared/inputs/SwitchInput.tsx @@ -10,7 +10,12 @@ import cx from 'classnames'; const ANT_SWITCH_FEATURES = ['checkedChildren', 'unCheckedChildren'] as const; export type TSwitchInputProps = TSlobsInputProps< - { inputRef?: React.Ref; style?: React.CSSProperties; name?: string }, + { + inputRef?: React.Ref; + style?: React.CSSProperties; + name?: string; + size?: 'small' | 'default'; + }, boolean, SwitchProps, ValuesOf @@ -18,6 +23,7 @@ export type TSwitchInputProps = TSlobsInputProps< export const SwitchInput = InputComponent((p: TSwitchInputProps) => { const { wrapperAttrs, inputAttrs } = useInput('switch', p, ANT_SWITCH_FEATURES); + const { size = 'small' } = p; /* * The horizontal styling shifts the label to follow the switch. @@ -27,7 +33,7 @@ export const SwitchInput = InputComponent((p: TSwitchInputProps) => { { ) : ( - + ); }); diff --git a/app/i18n/en-US/highlighter.json b/app/i18n/en-US/highlighter.json index 226ba73defa6..92bdb190184c 100644 --- a/app/i18n/en-US/highlighter.json +++ b/app/i18n/en-US/highlighter.json @@ -101,5 +101,15 @@ "Clip": "Clip", "Subtitle": "Subtitle", "Record your screen with Streamlabs Desktop. Once recording is complete, it will be displayed here. Access your files or edit further with Streamlabs tools.": "Record your screen with Streamlabs Desktop. Once recording is complete, it will be displayed here. Access your files or edit further with Streamlabs tools.", - "Recordings": "Recordings" + "Recordings": "Recordings", + "Manual Highlighter": "Manual Highlighter", + "The hotkey highlighter allows you to clip the best moments during your livestream manually and edit them together afterwards.": "The hotkey highlighter allows you to clip the best moments during your livestream manually and edit them together afterwards.", + "End your stream to change the Hotkey or the replay duration.": "End your stream to change the Hotkey or the replay duration.", + "No clips found": "No clips found", + "All highlight clips": "All highlight clips", + "Loading": "Loading", + "Trim": "Trim", + "Intro": "Intro", + "Outro": "Outro", + "All clips": "All clips" } \ No newline at end of file diff --git a/app/services/highlighter/ai-highlighter/ai-highlighter.ts b/app/services/highlighter/ai-highlighter/ai-highlighter.ts new file mode 100644 index 000000000000..6164dc16874b --- /dev/null +++ b/app/services/highlighter/ai-highlighter/ai-highlighter.ts @@ -0,0 +1,49 @@ +import * as child from 'child_process'; +import EventEmitter from 'events'; +import { duration } from 'moment'; + +export enum EHighlighterInputTypes { + KILL = 'kill', + KNOCKED = 'knocked', + GAME_SEQUENCE = 'game_sequence', + GAME_START = 'start_game', + GAME_END = 'end_game', + VOICE_ACTIVITY = 'voice_activity', + DEATH = 'death', + VICTORY = 'victory', + DEPLOY = 'deploy', + META_DURATION = 'meta_duration', + LOW_HEALTH = 'low_health', + PLAYER_KNOCKED = 'player_knocked', +} + +export type DeathMetadata = { + place: number; +}; +export interface IHighlighterInput { + start_time: number; + end_time?: number; + type: EHighlighterInputTypes; + origin: string; + metadata?: DeathMetadata | any; +} +export interface IHighlight { + start_time: number; + end_time: number; + input_types: EHighlighterInputTypes[]; + inputs: IHighlighterInput[]; + score: number; + metadata: { round: number }; +} + +export type EHighlighterMessageTypes = + | 'progress' + | 'inputs' + | 'inputs_partial' + | 'highlights' + | 'highlights_partial'; + +export interface IHighlighterMessage { + type: EHighlighterMessageTypes; + json: {}; +} diff --git a/app/services/highlighter/audio-mixer.ts b/app/services/highlighter/audio-mixer.ts index 220ab7718e98..10c3a02d46eb 100644 --- a/app/services/highlighter/audio-mixer.ts +++ b/app/services/highlighter/audio-mixer.ts @@ -19,13 +19,11 @@ export class AudioMixer { const args = [...inputArgs]; - const filterGraph = `amix=inputs=${this.inputs.length}:duration=first:weights=${this.inputs - .map(i => i.volume) - .join(' ')}`; + const inputMap = this.inputs.map((_, index) => `[${index}:a]`).join(''); - this.inputs.forEach((input, index) => { - args.push('-map', `${index}:a`); - }); + const filterGraph = `${inputMap}amix=inputs=${ + this.inputs.length + }:duration=first:weights=${this.inputs.map(i => i.volume).join(' ')}`; args.push('-filter_complex', filterGraph); diff --git a/app/services/highlighter/clip.ts b/app/services/highlighter/clip.ts index 17875f978a31..b419e02c7739 100644 --- a/app/services/highlighter/clip.ts +++ b/app/services/highlighter/clip.ts @@ -1,9 +1,10 @@ import execa from 'execa'; import { FrameSource } from './frame-source'; import { AudioSource } from './audio-source'; -import { FFPROBE_EXE } from './constants'; +import { FFPROBE_EXE, SCRUB_SPRITE_DIRECTORY } from './constants'; import fs from 'fs'; import { IExportOptions } from '.'; +import path from 'path'; export class Clip { frameSource: FrameSource; @@ -44,7 +45,7 @@ export class Clip { * to start reading from the file again. */ async reset(options: IExportOptions) { - this.deleted = !(await this.fileExists()); + this.deleted = !(await this.fileExists(this.sourcePath)); if (this.deleted) return; if (!this.duration) await this.readDuration(); @@ -68,9 +69,9 @@ export class Clip { /** * Checks if the underlying file exists and is readable */ - private async fileExists() { + private async fileExists(file: string) { return new Promise(resolve => { - fs.access(this.sourcePath, fs.constants.R_OK, e => { + fs.access(file, fs.constants.R_OK, e => { if (e) { resolve(false); } else { @@ -83,7 +84,23 @@ export class Clip { private async doInit() { await this.reset({ fps: 30, width: 1280, height: 720, preset: 'ultrafast' }); if (this.deleted) return; - await this.frameSource.exportScrubbingSprite(); + if (this.frameSource) { + try { + const parsed = path.parse(this.sourcePath); + const scrubPath = path.join(SCRUB_SPRITE_DIRECTORY, `${parsed.name}-scrub.jpg`); + + const scrubFileExists = await this.fileExists(scrubPath); + if (scrubFileExists) { + this.frameSource.scrubJpg = scrubPath; + } else { + await this.frameSource.exportScrubbingSprite(scrubPath); + } + } catch (error: unknown) { + console.log('err', error); + } + } else { + console.log('No Framesource'); + } } private async readDuration() { diff --git a/app/services/highlighter/frame-source.ts b/app/services/highlighter/frame-source.ts index fe87973b02fa..adb576d29833 100644 --- a/app/services/highlighter/frame-source.ts +++ b/app/services/highlighter/frame-source.ts @@ -42,15 +42,19 @@ export class FrameSource { public readonly options: IExportOptions, ) {} - async exportScrubbingSprite() { - const parsed = path.parse(this.sourcePath); - this.scrubJpg = path.join(SCRUB_SPRITE_DIRECTORY, `${parsed.name}-scrub.jpg`); + async exportScrubbingSprite(path: string) { + this.scrubJpg = path; /* eslint-disable */ const args = [ - '-i', this.sourcePath, - '-vf', `scale=${SCRUB_WIDTH}:${SCRUB_HEIGHT},fps=${SCRUB_FRAMES / this.duration},tile=${SCRUB_FRAMES}x1`, - '-frames:v', '1', + '-i', + this.sourcePath, + '-vf', + `scale=${SCRUB_WIDTH}:${SCRUB_HEIGHT},fps=${ + SCRUB_FRAMES / this.duration + },tile=${SCRUB_FRAMES}x1`, + '-frames:v', + '1', '-y', this.scrubJpg, ]; @@ -62,15 +66,23 @@ export class FrameSource { private startFfmpeg() { /* eslint-disable */ const args = [ - '-ss', this.startTrim.toString(), - '-i', this.sourcePath, - '-t', (this.duration - this.startTrim - this.endTrim).toString(), - '-vf', `fps=${this.options.fps},scale=${this.options.width}:${this.options.height}`, - '-map', 'v:0', - '-vcodec', 'rawvideo', - '-pix_fmt', 'rgba', - '-f', 'image2pipe', - '-' + '-ss', + this.startTrim.toString(), + '-i', + this.sourcePath, + '-t', + (this.duration - this.startTrim - this.endTrim).toString(), + '-vf', + `fps=${this.options.fps},scale=${this.options.width}:${this.options.height}`, + '-map', + 'v:0', + '-vcodec', + 'rawvideo', + '-pix_fmt', + 'rgba', + '-f', + 'image2pipe', + '-', ]; /* eslint-enable */ diff --git a/app/services/highlighter/index.ts b/app/services/highlighter/index.ts index 3c09c16ba89a..c992a37e6b18 100644 --- a/app/services/highlighter/index.ts +++ b/app/services/highlighter/index.ts @@ -1,4 +1,11 @@ -import { mutation, StatefulService, ViewHandler, Inject, InitAfter, Service } from 'services/core'; +import { + mutation, + ViewHandler, + Inject, + InitAfter, + Service, + PersistentStatefulService, +} from 'services/core'; import path from 'path'; import Vue from 'vue'; import fs from 'fs-extra'; @@ -31,8 +38,18 @@ import { ENotificationType, NotificationsService } from 'services/notifications' import { JsonrpcService } from 'services/api/jsonrpc'; import { NavigationService } from 'services/navigation'; import { SharedStorageService } from 'services/integrations/shared-storage'; +import { EHighlighterInputTypes } from './ai-highlighter/ai-highlighter'; +export type TStreamInfo = + | { + orderPosition: number; + initialStartTime?: number; + initialEndTime?: number; + } + | undefined; // initialTimesInStream + +const isAiClip = (clip: TClip): clip is IAiClip => clip.source === 'AiClip'; -export interface IClip { +interface IBaseClip { path: string; loaded: boolean; enabled: boolean; @@ -41,7 +58,93 @@ export interface IClip { endTrim: number; duration?: number; deleted: boolean; - source: 'ReplayBuffer' | 'Manual'; + globalOrderPosition: number; + streamInfo: + | { + [streamId: string]: TStreamInfo; + } + | undefined; +} +interface IReplayBufferClip extends IBaseClip { + source: 'ReplayBuffer'; +} + +interface IManualClip extends IBaseClip { + source: 'Manual'; +} + +export interface IAiClip extends IBaseClip { + source: 'AiClip'; + aiInfo: IAiClipInfo; +} + +export interface IDeathMetadata { + place: number; +} +export interface IKillMetadata { + bot_kill: boolean; +} + +export interface IInput { + type: EHighlighterInputTypes; + metadata?: IDeathMetadata | IKillMetadata; +} + +export interface IAiClipInfo { + inputs: IInput[]; + score: number; + metadata: { round: number }; +} + +export type TClip = IAiClip | IReplayBufferClip | IManualClip; + +export enum EHighlighterView { + CLIPS = 'clips', + SETTINGS = 'settings', +} + +interface TClipsViewState { + view: EHighlighterView.CLIPS; + id: string | undefined; +} + +interface ISettingsViewState { + view: EHighlighterView.SETTINGS; +} + +export type IViewState = TClipsViewState | ISettingsViewState; + +// TODO: Need to clean up all of this +export interface StreamInfoForAiHighlighter { + id: string; + game: string; + title?: string; +} + +export interface INewClipData { + path: string; + aiClipInfo: IAiClipInfo; + startTime: number; + endTime: number; + startTrim: number; + endTrim: number; +} +export interface IHighlightedStream { + id: string; + game: string; + title: string; + date: string; + state: { + type: + | 'initialized' + | 'detection-in-progress' + | 'error' + | 'detection-finished' + | 'detection-canceled-by-user'; + progress: number; + }; + abortController?: AbortController; + path: string; } export enum EExportStep { @@ -96,15 +199,29 @@ export interface IAudioInfo { musicVolume: number; } +export interface IIntroInfo { + path: string; + duration: number | null; +} +export interface IOutroInfo { + path: string; + duration: number | null; +} +export interface IVideoInfo { + intro: IIntroInfo; + outro: IOutroInfo; +} + interface IHighligherState { - clips: Dictionary; - clipOrder: string[]; + clips: Dictionary; transition: ITransitionInfo; + video: IVideoInfo; audio: IAudioInfo; export: IExportInfo; upload: IUploadInfo; dismissedTutorial: boolean; error: string; + highlightedStreams: IHighlightedStream[]; } // Capitalization is not consistent because it matches with the @@ -225,10 +342,17 @@ export interface IExportOptions { class HighligherViews extends ViewHandler { /** - * Returns an array of clips in their display order + * Returns an array of clips */ get clips() { - return this.state.clipOrder.map(p => this.state.clips[p]); + return Object.values(this.state.clips); + } + get clipsDictionary() { + return this.state.clips; + } + + get highlightedStreams() { + return this.state.highlightedStreams; } /** @@ -264,6 +388,10 @@ class HighligherViews extends ViewHandler { return this.state.audio; } + get video() { + return this.state.video; + } + get transitionDuration() { return this.transition.type === 'None' ? 0 : this.state.transition.duration; } @@ -291,14 +419,17 @@ class HighligherViews extends ViewHandler { } @InitAfter('StreamingService') -export class HighlighterService extends StatefulService { - static initialState: IHighligherState = { +export class HighlighterService extends PersistentStatefulService { + static defaultState: IHighligherState = { clips: {}, - clipOrder: [], transition: { type: 'fade', duration: 1, }, + video: { + intro: { path: '', duration: null }, + outro: { path: '', duration: null }, + }, audio: { musicEnabled: false, musicPath: '', @@ -328,8 +459,19 @@ export class HighlighterService extends StatefulService { }, dismissedTutorial: false, error: '', + highlightedStreams: [], }; + static filter(state: IHighligherState) { + return { + ...this.defaultState, + clips: state.clips, + highlightedStreams: state.highlightedStreams, + video: state.video, + audio: state.audio, + transition: state.transition, + }; + } @Inject() streamingService: StreamingService; @Inject() userService: UserService; @Inject() usageStatisticsService: UsageStatisticsService; @@ -348,14 +490,13 @@ export class HighlighterService extends StatefulService { directoryCleared = false; @mutation() - ADD_CLIP(clip: IClip) { + ADD_CLIP(clip: TClip) { Vue.set(this.state.clips, clip.path, clip); - this.state.clipOrder.push(clip.path); this.state.export.exported = false; } @mutation() - UPDATE_CLIP(clip: Partial & { path: string }) { + UPDATE_CLIP(clip: Partial & { path: string }) { Vue.set(this.state.clips, clip.path, { ...this.state.clips[clip.path], ...clip, @@ -366,13 +507,6 @@ export class HighlighterService extends StatefulService { @mutation() REMOVE_CLIP(clipPath: string) { Vue.delete(this.state.clips, clipPath); - this.state.clipOrder = this.state.clipOrder.filter(c => c !== clipPath); - this.state.export.exported = false; - } - - @mutation() - SET_ORDER(order: string[]) { - this.state.clipOrder = order; this.state.export.exported = false; } @@ -423,6 +557,15 @@ export class HighlighterService extends StatefulService { this.state.export.exported = false; } + @mutation() + SET_VIDEO_INFO(videoInfo: Partial) { + this.state.video = { + ...this.state.video, + ...videoInfo, + }; + this.state.export.exported = false; + } + @mutation() DISMISS_TUTORIAL() { this.state.dismissedTutorial = true; @@ -437,7 +580,31 @@ export class HighlighterService extends StatefulService { return new HighligherViews(this.state); } - init() { + async init() { + super.init(); + + //Check if files are existent, if not, delete + this.views.clips.forEach(c => { + if (!this.fileExists(c.path)) { + this.removeClip(c.path, undefined); + } + }); + + if (this.views.exportInfo.exporting) { + this.SET_EXPORT_INFO({ + exporting: false, + error: null, + cancelRequested: false, + }); + } + + this.views.clips.forEach(c => { + this.UPDATE_CLIP({ + path: c.path, + loaded: false, + }); + }); + try { // On some very very small number of systems, we won't be able to fetch // the videos path from the system. @@ -484,34 +651,14 @@ export class HighlighterService extends StatefulService { // path.join(CLIP_DIR, '2021-06-08 16-40-14.mp4'), // path.join(CLIP_DIR, '2021-05-25 08-56-03.mp4'), ]; - - clipsToLoad.forEach(c => { - this.ADD_CLIP({ - path: c, - loaded: false, - enabled: true, - startTrim: 0, - endTrim: 0, - deleted: false, - source: 'Manual', - }); - }); } else { - this.streamingService.replayBufferFileWrite.subscribe(clipPath => { - this.ADD_CLIP({ - path: clipPath, - loaded: false, - enabled: true, - startTrim: 0, - endTrim: 0, - deleted: false, - source: 'ReplayBuffer', - }); - }); - let streamStarted = false; - this.streamingService.streamingStatusChange.subscribe(status => { + this.streamingService.replayBufferFileWrite.subscribe(async clipPath => { + this.addClips([{ path: clipPath }], undefined, 'ReplayBuffer'); + }); + + this.streamingService.streamingStatusChange.subscribe(async status => { if (status === EStreamingState.Live) { streamStarted = true; } @@ -553,21 +700,34 @@ export class HighlighterService extends StatefulService { }); } - addClips(paths: string[]) { - paths.forEach(path => { - // Don't allow adding the same clip twice - if (this.state.clips[path]) return; + addClips( + newClips: { path: string; startTime?: number; endTime?: number }[], + streamId: string | undefined, + source: 'Manual' | 'ReplayBuffer', + ) { + // streamId is used for ai highlighter - this.ADD_CLIP({ - path, - loaded: false, - enabled: true, - startTrim: 0, - endTrim: 0, - deleted: false, - source: 'Manual', - }); + newClips.forEach((clipData, index) => { + const getHighestGlobalOrderPosition = this.getClips(this.views.clips, undefined).length; + + if (this.state.clips[clipData.path]) { + // Clip exists already + return; + } else { + this.ADD_CLIP({ + path: clipData.path, + loaded: false, + enabled: true, + startTrim: 0, + endTrim: 0, + deleted: false, + source, + globalOrderPosition: index + getHighestGlobalOrderPosition + 1, + streamInfo: undefined, + }); + } }); + return; } enableClip(path: string, enabled: boolean) { @@ -576,6 +736,12 @@ export class HighlighterService extends StatefulService { enabled, }); } + disableClip(path: string) { + this.UPDATE_CLIP({ + path, + enabled: false, + }); + } setStartTrim(path: string, trim: number) { this.UPDATE_CLIP({ @@ -591,12 +757,15 @@ export class HighlighterService extends StatefulService { }); } - removeClip(path: string) { + removeClip(path: string, streamId: string | undefined) { + const clip: TClip = this.state.clips[path]; + if (!clip) { + console.warn(`Clip not found for path: ${path}`); + return; + } this.REMOVE_CLIP(path); - } - - setOrder(order: string[]) { - this.SET_ORDER(order); + this.removeScrubFile(clip.scrubSprite); + delete this.clips[path]; } setTransition(transition: Partial) { @@ -607,6 +776,10 @@ export class HighlighterService extends StatefulService { this.SET_AUDIO_INFO(audio); } + setVideo(video: Partial) { + this.SET_VIDEO_INFO(video); + } + setExportFile(file: string) { this.SET_EXPORT_INFO({ file }); } @@ -633,18 +806,35 @@ export class HighlighterService extends StatefulService { this.DISMISS_TUTORIAL(); } - fileExists(file: string) { + fileExists(file: string): boolean { return fs.existsSync(file); } - async loadClips() { + async removeScrubFile(clipPath: string | undefined) { + if (!clipPath) { + console.warn('No scrub file path provided'); + return; + } + try { + await fs.remove(clipPath); + } catch (error: unknown) { + console.error('Error removing scrub file', error); + } + } + + async loadClips(streamInfoId?: string | undefined) { + const clipsToLoad: TClip[] = this.getClips(this.views.clips, streamInfoId); + await this.ensureScrubDirectory(); - // Ensure we have a Clip class for every clip in the store - // Also make sure they are the correct format - this.views.clips.forEach(c => { - if (!SUPPORTED_FILE_TYPES.map(e => `.${e}`).includes(path.parse(c.path).ext)) { - this.REMOVE_CLIP(c.path); + for (const clip of clipsToLoad) { + if (!this.fileExists(clip.path)) { + this.removeClip(clip.path, streamInfoId); + return; + } + + if (!SUPPORTED_FILE_TYPES.map(e => `.${e}`).includes(path.parse(clip.path).ext)) { + this.removeClip(clip.path, streamInfoId); this.SET_ERROR( $t( 'One or more clips could not be imported because they were not recorded in a supported file format.', @@ -652,11 +842,12 @@ export class HighlighterService extends StatefulService { ); } - this.clips[c.path] = this.clips[c.path] ?? new Clip(c.path); - }); + this.clips[clip.path] = this.clips[clip.path] ?? new Clip(clip.path); + } + //TODO M: tracking type not correct await pmap( - this.views.clips.filter(c => !c.loaded), + clipsToLoad.filter(c => !c.loaded), c => this.clips[c.path].init(), { concurrency: os.cpus().length, @@ -665,7 +856,6 @@ export class HighlighterService extends StatefulService { type: 'ClipImport', source: completed.source, }); - this.UPDATE_CLIP({ path: completed.path, loaded: true, @@ -676,15 +866,20 @@ export class HighlighterService extends StatefulService { }, }, ); + return; } private async ensureScrubDirectory() { - // We clear this out once per application run - if (this.directoryCleared) return; - this.directoryCleared = true; - - await fs.remove(SCRUB_SPRITE_DIRECTORY); - await fs.mkdir(SCRUB_SPRITE_DIRECTORY); + try { + try { + //If possible to read, directory exists, if not, catch and mkdir + await fs.readdir(SCRUB_SPRITE_DIRECTORY); + } catch (error: unknown) { + await fs.mkdir(SCRUB_SPRITE_DIRECTORY); + } + } catch (error: unknown) { + console.log('Error creating scrub sprite directory'); + } } cancelExport() { @@ -695,9 +890,19 @@ export class HighlighterService extends StatefulService { * Exports the video using the currently configured settings * Return true if the video was exported, or false if not. */ - async export(preview = false) { - if (!this.views.loaded) { - console.error('Highlighter: Export called while clips are not fully loaded!'); + async export(preview = false, streamId: string | undefined = undefined) { + await this.loadClips(streamId); + + if ( + !this.views.clips + .filter(c => { + if (!c.enabled) return false; + if (!streamId) return true; + return c.streamInfo && c.streamInfo[streamId] !== undefined; + }) + .every(clip => clip.loaded) + ) { + console.error('Highlighter: Export called while clips are not fully loaded!: '); return; } @@ -706,17 +911,51 @@ export class HighlighterService extends StatefulService { return; } - let clips = this.views.clips - .filter(c => c.enabled) - .map(c => { - const clip = this.clips[c.path]; + let clips: Clip[] = []; + if (streamId) { + clips = this.getClips(this.views.clips, streamId) + .filter(clip => clip.enabled && clip.streamInfo && clip.streamInfo[streamId] !== undefined) + .sort( + (a: TClip, b: TClip) => + (a.streamInfo?.[streamId]?.orderPosition ?? 0) - + (b.streamInfo?.[streamId]?.orderPosition ?? 0), + ) + .map(c => { + const clip = this.clips[c.path]; + + clip.startTrim = c.startTrim; + clip.endTrim = c.endTrim; + + return clip; + }); + } else { + clips = this.views.clips + .filter(c => c.enabled) + .sort((a: TClip, b: TClip) => a.globalOrderPosition - b.globalOrderPosition) + .map(c => { + const clip = this.clips[c.path]; - // Set trims on the frame source - clip.startTrim = c.startTrim; - clip.endTrim = c.endTrim; + clip.startTrim = c.startTrim; + clip.endTrim = c.endTrim; - return clip; - }); + return clip; + }); + } + + if (this.views.video.intro.path) { + const intro: Clip = new Clip(this.views.video.intro.path); + await intro.init(); + intro.startTrim = 0; + intro.endTrim = 0; + clips.unshift(intro); + } + if (this.views.video.outro.path) { + const outro = new Clip(this.views.video.outro.path); + await outro.init(); + outro.startTrim = 0; + outro.endTrim = 0; + clips.push(outro); + } const exportOptions: IExportOptions = preview ? { width: 1280 / 4, height: 720 / 4, fps: 30, preset: 'ultrafast' } @@ -909,6 +1148,8 @@ export class HighlighterService extends StatefulService { } } } catch (e: unknown) { + console.error(e); + Sentry.withScope(scope => { scope.setTag('feature', 'highlighter'); console.error('Highlighter export error', e); @@ -1076,4 +1317,25 @@ export class HighlighterService extends StatefulService { clearUpload() { this.CLEAR_UPLOAD(); } + + getClips(clips: TClip[], streamId?: string): TClip[] { + const inputClips = clips.filter(clip => clip.path !== 'add'); + let wantedClips; + + if (streamId) { + wantedClips = inputClips.filter(clip => clip.streamInfo?.[streamId]); + } else { + wantedClips = inputClips; + } + + const outputClips = wantedClips.filter(c => this.fileExists(c.path)); + if (outputClips.length !== wantedClips.length) { + wantedClips + .filter(c => !this.fileExists(c.path)) + .forEach(clip => { + this.removeClip(clip.path, streamId); + }); + } + return outputClips; + } } diff --git a/app/services/navigation.ts b/app/services/navigation.ts index 48375389d079..fa1676015f2f 100644 --- a/app/services/navigation.ts +++ b/app/services/navigation.ts @@ -2,6 +2,7 @@ import { StatefulService, mutation } from './core/stateful-service'; import { Subject } from 'rxjs'; import { Inject } from 'services/core'; import { SideNavService } from 'app-services'; +import { EMenuItemKey } from './side-nav'; export type TAppPage = | 'Studio' @@ -34,7 +35,14 @@ export class NavigationService extends StatefulService { navigated = new Subject(); - navigate(page: TAppPage, params: Dictionary = {}) { + navigate( + page: TAppPage, + params: Dictionary = {}, + setMenuItem: EMenuItemKey | undefined = undefined, + ) { + if (setMenuItem) { + this.sideNavService.setCurrentMenuItem(setMenuItem); + } this.NAVIGATE(page, params); this.navigated.next(this.state); } diff --git a/test/regular/highlighter.ts b/test/regular/highlighter.ts index 8bc4ed2e26b8..6786638b61f3 100644 --- a/test/regular/highlighter.ts +++ b/test/regular/highlighter.ts @@ -33,6 +33,7 @@ test('Highlighter save and export', async t => { await stopStream(); await focusMain(); + await clickButton('All clips'); await clickButton('Export'); const fileName = 'MyTestVideo.mp4'; const exportLocation = path.resolve(recordingDir, fileName); From 01dc2fcd6b7b174a325ddeaaa9a97417fdf2e023 Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Fri, 6 Dec 2024 14:54:58 -0800 Subject: [PATCH 72/97] fix(dual-output): disable extra platforms when needed after mode switch (#5244) * refactor(dual-output): remove unused services * fix(dual-output): disable extra platforms when needed after mode switch * chore: add note on toggling behavior --- .../windows/go-live/DestinationSwitchers.tsx | 1 + .../windows/go-live/useGoLiveSettings.ts | 23 ++++++++++++++++++- app/services/dual-output/dual-output.ts | 3 --- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/app/components-react/windows/go-live/DestinationSwitchers.tsx b/app/components-react/windows/go-live/DestinationSwitchers.tsx index 4d2ea1ecb6fd..6685e4d0dda3 100644 --- a/app/components-react/windows/go-live/DestinationSwitchers.tsx +++ b/app/components-react/windows/go-live/DestinationSwitchers.tsx @@ -87,6 +87,7 @@ export function DestinationSwitchers(p: { showSelector?: boolean }) { * If we had two platforms, none of which were tiktok, we still need to limit * that to 1 platform without restreaming. * This could happen when coming from having dual output enabled to off. + * TODO: this might not be needed after #5244, keeping here for a while for extra care */ enabledPlatformsRef.current = enabledPlatformsRef.current.slice(0, 1); } else { diff --git a/app/components-react/windows/go-live/useGoLiveSettings.ts b/app/components-react/windows/go-live/useGoLiveSettings.ts index 98b958de5885..964386aa6deb 100644 --- a/app/components-react/windows/go-live/useGoLiveSettings.ts +++ b/app/components-react/windows/go-live/useGoLiveSettings.ts @@ -163,7 +163,7 @@ export class GoLiveSettingsModule { * Fetch settings for each platform */ async prepopulate() { - const { StreamingService } = Services; + const { StreamingService, RestreamService, DualOutputService } = Services; const { isMultiplatformMode } = StreamingService.views; this.state.setNeedPrepopulate(true); @@ -198,6 +198,27 @@ export class GoLiveSettingsModule { } this.state.updateSettings(settings); + + /* If the user was in dual output before but doesn't have restream + * we should disable one of the platforms if they have 2 + * ref: https://app.asana.com/0/1207748235152481/1208813184366087/f + */ + const { dualOutputMode } = DualOutputService.state; + const { canEnableRestream } = RestreamService.views; + + // Tiktok can stay active + const enabledPlatforms = this.state.enabledPlatforms.filter(platform => platform !== 'tiktok'); + + if (!dualOutputMode && !canEnableRestream && enabledPlatforms.length > 1) { + /* Find the platform that was set as primary chat to remain enabled, + * if for some reason we fail to find it default to the last selected platform + */ + const platform = + enabledPlatforms.find(platform => platform === this.primaryChat) || + enabledPlatforms[enabledPlatforms.length - 1]; + + this.switchPlatforms([platform]); + } } get isPrime() { diff --git a/app/services/dual-output/dual-output.ts b/app/services/dual-output/dual-output.ts index e6e120e9c356..7feb569f0238 100644 --- a/app/services/dual-output/dual-output.ts +++ b/app/services/dual-output/dual-output.ts @@ -291,9 +291,6 @@ export class DualOutputService extends PersistentStatefulService Date: Fri, 6 Dec 2024 15:04:15 -0800 Subject: [PATCH 73/97] feat: shareable stream links (#5237) * feat: shareable stream links * fix: prevent antd tooltip placement switch * fix(streaming): YouTube and TikTok icons on share stream links * chore(streaming): remove stream link button titles since we have tooltips --- app/components-react/root/LiveDock.tsx | 2 + .../root/ShareStreamLink.m.less | 15 +++ app/components-react/root/ShareStreamLink.tsx | 110 ++++++++++++++++++ app/i18n/en-US/streaming.json | 6 +- 4 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 app/components-react/root/ShareStreamLink.m.less create mode 100644 app/components-react/root/ShareStreamLink.tsx diff --git a/app/components-react/root/LiveDock.tsx b/app/components-react/root/LiveDock.tsx index 9c0bf61bcf61..279e28b769c5 100644 --- a/app/components-react/root/LiveDock.tsx +++ b/app/components-react/root/LiveDock.tsx @@ -18,6 +18,7 @@ import { useVuex } from 'components-react/hooks'; import { useRealmObject } from 'components-react/hooks/realm'; import { $i } from 'services/utils'; import { TikTokChatInfo } from './TiktokChatInfo'; +import { ShareStreamLink } from './ShareStreamLink'; const LiveDockCtx = React.createContext(null); @@ -427,6 +428,7 @@ function LiveDock(p: { onLeft: boolean }) { ctrl.openPlatformStream()} className="icon-studio" /> )} + {isStreaming && } {isPlatform(['youtube', 'facebook', 'tiktok']) && isStreaming && ( { + const [expanded, setExpanded] = useState(false); + + const toggleExpanded = () => setExpanded(expanded => !expanded); + const { StreamingService } = Services; + + const items = StreamingService.views.enabledPlatforms.map(platform => { + const service = getPlatformService(platform); + const streamPageUrl = service.streamPageUrl; + + if (!streamPageUrl) { + return; + } + + const tooltip = $t('Copy %{platform} link', { + platform: StreamingService.views.getPlatformDisplayName(platform), + }); + + return ( + + - )} - - )} - {!v.isStreaming && ( -
    - {completedStepHeading($t('Adjust replay duration'))} -
    - {$t('Set the duration of captured replays. You can always trim them down later.')} -
    - - `${v}s`} - /> - -
    - )} - {!v.isStreaming && ( -
    - {hotkey?.bindings.length - ? completedStepHeading($t('Set a hotkey to capture replays')) - : incompleteStepHeading($t('Set a hotkey to capture replays'))} - {hotkey && ( - { - const newHotkey = { ...hotkey }; - newHotkey.bindings.splice(0, 1, binding); - setHotkey(newHotkey); - hotkeyRef.current = newHotkey; - }} - /> - )} -
    - )} -
    - {incompleteStepHeading($t('Capture a replay'))} - {!!hotkey?.bindings.length && ( -
    - -
    - )} - {!hotkey?.bindings.length && ( -
    - {$t('Start streaming and capture a replay. Check back here after your stream.')} -
    - )} -
    - {$t('Or, import a clip from your computer')} - -
    -
    - - - - ); -} diff --git a/app/components-react/highlighter/ClipPreview.m.less b/app/components-react/highlighter/ClipPreview.m.less index 10a6aa22c253..1c3e75046124 100644 --- a/app/components-react/highlighter/ClipPreview.m.less +++ b/app/components-react/highlighter/ClipPreview.m.less @@ -87,7 +87,7 @@ .highlighter-icon { font-size: 19px; - transform: translateY(1px); + transform: translateX(-6px); } .preview-clip-bottom-bar { @@ -104,3 +104,16 @@ align-items: center; pointer-events: auto; } + +.round-tag { + padding: 4px 6px; + background-color: #00000070; + border-radius: 4px; + color: white; +} + +.flame-hypescore-wrapper { + position: absolute; + top: 7px; + right: 9px; +} diff --git a/app/components-react/highlighter/ClipPreview.tsx b/app/components-react/highlighter/ClipPreview.tsx index b38050eaded5..24a63b9bce86 100644 --- a/app/components-react/highlighter/ClipPreview.tsx +++ b/app/components-react/highlighter/ClipPreview.tsx @@ -6,6 +6,8 @@ import { BoolButtonInput } from 'components-react/shared/inputs/BoolButtonInput' import styles from './ClipPreview.m.less'; import { Button } from 'antd'; import { $t } from 'services/i18n'; +import { isAiClip } from './utils'; +import { InputEmojiSection } from './InputEmojiSection'; import { useVuex } from 'components-react/hooks'; export default function ClipPreview(props: { @@ -24,7 +26,7 @@ export default function ClipPreview(props: { const enabled = v.clip.deleted ? false : v.clip.enabled; if (!v.clip) { - return
    deleted
    ; + return <>deleted; } function mouseMove(e: React.MouseEvent) { @@ -66,6 +68,9 @@ export default function ClipPreview(props: { )} +
    + {isAiClip(v.clip) && } +
    + {/* left */}
    {formatSecondsToHMS(v.clip.duration! - (v.clip.startTrim + v.clip.endTrim) || 0)}
    -
    - + {/* right */} +
    +
    + {isAiClip(v.clip) ? ( + + ) : ( +
    + +
    + )} +
    + {isAiClip(v.clip) && v.clip.aiInfo?.metadata?.round && ( +
    {`Round: ${v.clip.aiInfo.metadata.round}`}
    + )}
    @@ -120,3 +148,25 @@ export function formatSecondsToHMS(seconds: number): string { minutes !== 0 ? minutes.toString() + 'm ' : '' }${remainingSeconds !== 0 ? remainingSeconds.toString() + 's' : ''}`; } + +function FlameHypeScore({ score }: { score: number }) { + if (score === undefined) { + return <>; + } + const normalizedScore = Math.min(1, Math.max(0, score)); + const fullFlames = Math.ceil(normalizedScore * 5); + + return ( +
    + {Array.from({ length: fullFlames }).map((_, index) => ( + 🔥 + ))} + + {Array.from({ length: 5 - fullFlames }).map((_, index) => ( + + 🔥 + + ))} +
    + ); +} diff --git a/app/components-react/highlighter/ClipTrimmer.tsx b/app/components-react/highlighter/ClipTrimmer.tsx index a2b892c08505..d88a6bca706d 100644 --- a/app/components-react/highlighter/ClipTrimmer.tsx +++ b/app/components-react/highlighter/ClipTrimmer.tsx @@ -151,14 +151,17 @@ export default function ClipTrimmer(props: { clip: TClip }) { function stopDragging() { if (isDragging.current === 'start') { HighlighterService.actions.setStartTrim(props.clip.path, localStartTrim); - UsageStatisticsService.actions.recordAnalyticsEvent('Highlighter', { type: 'Trim' }); } else if (isDragging.current === 'end') { HighlighterService.actions.setEndTrim(props.clip.path, localEndTrim); - UsageStatisticsService.actions.recordAnalyticsEvent('Highlighter', { type: 'Trim' }); } isDragging.current = null; playAt(localStartTrim); + + UsageStatisticsService.actions.recordAnalyticsEvent( + HighlighterService.state.useAiHighlighter ? 'AIHighlighter' : 'Highlighter', + { type: 'Trim' }, + ); } const scrubHeight = 100; diff --git a/app/components-react/highlighter/ClipsView.tsx b/app/components-react/highlighter/ClipsView.tsx index 38082f9fbc15..870cd0fd9dfe 100644 --- a/app/components-react/highlighter/ClipsView.tsx +++ b/app/components-react/highlighter/ClipsView.tsx @@ -3,6 +3,7 @@ import * as remote from '@electron/remote'; import { Services } from 'components-react/service-provider'; import styles from './ClipsView.m.less'; import { EHighlighterView, IAiClip, IViewState, TClip } from 'services/highlighter'; +import ClipPreview, { formatSecondsToHMS } from 'components-react/highlighter/ClipPreview'; import { ReactSortable } from 'react-sortablejs'; import Scrollable from 'components-react/shared/Scrollable'; import { EditingControls } from './EditingControls'; @@ -18,7 +19,10 @@ import { Button } from 'antd'; import { SUPPORTED_FILE_TYPES } from 'services/highlighter/constants'; import { $t } from 'services/i18n'; import path from 'path'; -import ClipPreview from './ClipPreview'; +import MiniClipPreview from './MiniClipPreview'; +import HighlightGenerator from './HighlightGenerator'; +import { EAvailableFeatures } from 'services/incremental-rollout'; + export type TModalClipsView = 'trim' | 'export' | 'preview' | 'remove'; interface IClipsViewProps { @@ -33,7 +37,10 @@ export default function ClipsView({ props: IClipsViewProps; emitSetView: (data: IViewState) => void; }) { - const { HighlighterService, UsageStatisticsService } = Services; + const { HighlighterService, UsageStatisticsService, IncrementalRolloutService } = Services; + const aiHighlighterEnabled = IncrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.aiHighlighter, + ); const clipsAmount = useVuex(() => HighlighterService.views.clips.length); const [clips, setClips] = useState<{ ordered: { id: string }[]; @@ -48,26 +55,18 @@ export default function ClipsView({ setClipsLoaded(true); }, []); + const getClips = useCallback(() => { + return HighlighterService.getClips(HighlighterService.views.clips, props.id); + }, [props.id]); + useEffect(() => { setClipsLoaded(false); - setClips( - sortAndFilterClips( - HighlighterService.getClips(HighlighterService.views.clips, props.id), - props.id, - activeFilter, - ), - ); + setClips(sortAndFilterClips(getClips(), props.id, activeFilter)); loadClips(props.id); }, [props.id, clipsAmount]); useEffect(() => { - setClips( - sortAndFilterClips( - HighlighterService.getClips(HighlighterService.views.clips, props.id), - props.id, - activeFilter, - ), - ); + setClips(sortAndFilterClips(getClips(), props.id, activeFilter)); }, [activeFilter]); useEffect(() => UsageStatisticsService.actions.recordFeatureUsage('Highlighter'), []); @@ -119,8 +118,10 @@ export default function ClipsView({ ); setClips({ - ordered: newClipArray.map(c => ({ id: c })), - orderedFiltered: filterClipsBySource(updatedClips, activeFilter).map(c => ({ id: c.path })), + ordered: newClipArray.map(clipPath => ({ id: clipPath })), + orderedFiltered: filterClipsBySource(updatedClips, activeFilter).map(clip => ({ + id: clip.path, + })), }); return; } @@ -165,13 +166,25 @@ export default function ClipsView({

    emitSetView({ view: EHighlighterView.SETTINGS })} + onClick={() => + emitSetView( + streamId + ? { view: EHighlighterView.STREAM } + : { view: EHighlighterView.SETTINGS }, + ) + } > {props.streamTitle ?? $t('All highlight clips')}

    @@ -185,13 +198,7 @@ export default function ClipsView({ { - setClips( - sortAndFilterClips( - HighlighterService.getClips(HighlighterService.views.clips, props.id), - props.id, - activeFilter, - ), - ); + setClips(sortAndFilterClips(getClips(), props.id, activeFilter)); }} />
    @@ -204,15 +211,38 @@ export default function ClipsView({ { - setClips( - sortAndFilterClips( - HighlighterService.getClips(HighlighterService.views.clips, props.id), - props.id, - activeFilter, - ), - ); + setClips(sortAndFilterClips(getClips(), props.id, activeFilter)); }} /> + {streamId && + aiHighlighterEnabled && + HighlighterService.getClips(HighlighterService.views.clips, props.id) + .filter(clip => clip.source === 'AiClip') + .every(clip => (clip as IAiClip).aiInfo.metadata?.round) && ( + { + const clips = HighlighterService.getClips( + HighlighterService.views.clips, + props.id, + ); + const filteredClips = aiFilterClips(clips, streamId, filterOptions); + const filteredClipPaths = new Set(filteredClips.map(c => c.path)); + + clips.forEach(clip => { + const shouldBeEnabled = filteredClipPaths.has(clip.path); + const isEnabled = clip.enabled; + + if (shouldBeEnabled && !isEnabled) { + HighlighterService.enableClip(clip.path, true); + } else if (!shouldBeEnabled && isEnabled) { + HighlighterService.disableClip(clip.path); + } + }); + }} + combinedClipsDuration={getCombinedClipsDuration(getClips())} + roundDetails={HighlighterService.getRoundDetails(getClips())} + /> + )}
    + HighlighterService.getClips(HighlighterService.views.clips, streamId), + ); + + const totalDuration = clips + .filter(clip => clip.enabled) + .reduce((acc, clip) => acc + clip.duration! - clip.startTrim! - clip.endTrim!, 0); + + return {formatSecondsToHMS(totalDuration)}; +} + function AddClip({ streamId, addedClips, diff --git a/app/components-react/highlighter/ClipsViewModal.tsx b/app/components-react/highlighter/ClipsViewModal.tsx index 9ca8b5f04ee6..9b4aa8aa30a9 100644 --- a/app/components-react/highlighter/ClipsViewModal.tsx +++ b/app/components-react/highlighter/ClipsViewModal.tsx @@ -7,7 +7,6 @@ import styles from './ClipsView.m.less'; import ClipTrimmer from 'components-react/highlighter/ClipTrimmer'; import { Modal, Alert, Button } from 'antd'; import ExportModal from 'components-react/highlighter/ExportModal'; - import { $t } from 'services/i18n'; import PreviewModal from './PreviewModal'; diff --git a/app/components-react/highlighter/EditingControls.tsx b/app/components-react/highlighter/EditingControls.tsx index 1375d0cfc48d..ad9397c5f1a4 100644 --- a/app/components-react/highlighter/EditingControls.tsx +++ b/app/components-react/highlighter/EditingControls.tsx @@ -11,7 +11,7 @@ import { TModalClipsView } from './ClipsView'; import { useVuex } from 'components-react/hooks'; import { Services } from 'components-react/service-provider'; import { SUPPORTED_FILE_TYPES } from 'services/highlighter/constants'; -import { Clip } from 'services/highlighter/clip'; +import { RenderingClip } from 'services/highlighter/clip'; export function EditingControls({ emitSetShowModal, @@ -45,7 +45,7 @@ export function EditingControls({ async function setVideoFile(file: string, type: 'intro' | 'outro') { if (!videoExtensions.map(e => `.${e}`).includes(path.parse(file).ext)) return; - const tempClip = new Clip(file); + const tempClip = new RenderingClip(file); await tempClip.init(); HighlighterService.actions.setVideo({ [type]: { path: file, duration: tempClip.duration } }); } diff --git a/app/components-react/highlighter/ExportModal.m.less b/app/components-react/highlighter/ExportModal.m.less index 42ce669e2680..0d3302d9ddd9 100644 --- a/app/components-react/highlighter/ExportModal.m.less +++ b/app/components-react/highlighter/ExportModal.m.less @@ -1,5 +1,5 @@ .crossclip-container { - height: 300px; + min-height: 300px; display: flex; flex-direction: column; align-items: center; @@ -15,8 +15,8 @@ .sign-up-title { text-align: center; - font-size: 32px; - font-weight: 300; + font-size: 32px; + font-weight: 300; } .log-in { diff --git a/app/components-react/highlighter/ExportModal.tsx b/app/components-react/highlighter/ExportModal.tsx index 5ad46dba9989..a2d30f159afb 100644 --- a/app/components-react/highlighter/ExportModal.tsx +++ b/app/components-react/highlighter/ExportModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo } from 'react'; -import { EExportStep, TFPS, TResolution, TPreset } from 'services/highlighter'; +import { EExportStep, TFPS, TResolution, TPreset, TOrientation } from 'services/highlighter'; import { Services } from 'components-react/service-provider'; import { FileInput, TextInput, ListInput } from 'components-react/shared/inputs'; import Form from 'components-react/shared/inputs/Form'; @@ -23,10 +23,19 @@ class ExportController { get exportInfo() { return this.service.views.exportInfo; } + getStreamTitle(streamId?: string) { + return ( + this.service.views.highlightedStreams.find(stream => stream.id === streamId)?.title || + 'My Video' + ); + } dismissError() { return this.service.actions.dismissError(); } + resetExportedState() { + return this.service.actions.resetExportedState(); + } setResolution(value: string) { this.service.actions.setResolution(parseInt(value, 10) as TResolution); @@ -44,8 +53,8 @@ class ExportController { this.service.actions.setExportFile(exportFile); } - exportCurrentFile(streamId: string | undefined) { - this.service.actions.export(false, streamId); + exportCurrentFile(streamId: string | undefined, orientation: TOrientation = 'horizontal') { + this.service.actions.export(false, streamId, orientation); } cancelExport() { @@ -79,14 +88,31 @@ export default function ExportModalProvider({ } function ExportModal({ close, streamId }: { close: () => void; streamId: string | undefined }) { - const { exportInfo, dismissError } = useController(ExportModalCtx); + const { exportInfo, dismissError, resetExportedState, getStreamTitle } = useController( + ExportModalCtx, + ); + const [videoName, setVideoName] = useState(getStreamTitle(streamId) + ' - highlights'); + + const unmount = () => { + dismissError(); + resetExportedState(); + }; // Clear all errors when this component unmounts - useEffect(dismissError, []); + useEffect(() => unmount, []); if (exportInfo.exporting) return ; - if (!exportInfo.exported) return ; - return ; + if (!exportInfo.exported) { + return ( + + ); + } + return ; } function ExportProgress() { @@ -128,7 +154,17 @@ function ExportProgress() { ); } -function ExportOptions({ close, streamId }: { close: () => void; streamId: string | undefined }) { +function ExportOptions({ + close, + streamId, + videoName, + onVideoNameChange, +}: { + close: () => void; + streamId: string | undefined; + videoName: string; + onVideoNameChange: (name: string) => void; +}) { const { UsageStatisticsService } = Services; const { exportInfo, @@ -139,10 +175,12 @@ function ExportOptions({ close, streamId }: { close: () => void; streamId: strin fileExists, setExport, exportCurrentFile, - store, + getStreamTitle, } = useController(ExportModalCtx); - const videoName = store.useState(s => s.videoName); + // Video name and export file are kept in sync + const [exportFile, setExportFile] = useState(getExportFileFromVideoName(videoName)); + function getExportFileFromVideoName(videoName: string) { const parsed = path.parse(exportInfo.file); const sanitized = videoName.replace(/[/\\?%*:|"<>\.,;=#]/g, ''); @@ -152,8 +190,27 @@ function ExportOptions({ close, streamId }: { close: () => void; streamId: strin function getVideoNameFromExportFile(exportFile: string) { return path.parse(exportFile).name; } - // Video name and export file are kept in sync - const [exportFile, setExportFile] = useState(getExportFileFromVideoName(videoName)); + + async function startExport(orientation: TOrientation) { + if (await fileExists(exportFile)) { + if ( + !(await confirmAsync({ + title: $t('Overwite File?'), + content: $t('%{filename} already exists. Would you like to overwrite it?', { + filename: path.basename(exportFile), + }), + okText: $t('Overwrite'), + })) + ) { + return; + } + } + + UsageStatisticsService.actions.recordFeatureUsage('HighlighterExport'); + + setExport(exportFile); + exportCurrentFile(streamId, orientation); + } return (
    @@ -163,9 +220,7 @@ function ExportOptions({ close, streamId }: { close: () => void; streamId: strin label={$t('Video Name')} value={videoName} onInput={name => { - store.setState(s => { - s.videoName = name; - }); + onVideoNameChange(name); setExportFile(getExportFileFromVideoName(name)); }} uncontrolled={false} @@ -178,9 +233,7 @@ function ExportOptions({ close, streamId }: { close: () => void; streamId: strin value={exportFile} onChange={file => { setExportFile(file); - store.setState(s => { - s.videoName = getVideoNameFromExportFile(file); - }); + onVideoNameChange(getVideoNameFromExportFile(file)); }} /> void; streamId: strin - +
    @@ -259,9 +294,8 @@ function ExportOptions({ close, streamId }: { close: () => void; streamId: strin ); } -function PlatformSelect(p: { onClose: () => void }) { - const { store, clearUpload } = useController(ExportModalCtx); - const videoName = store.useState(s => s.videoName); +function PlatformSelect({ onClose, videoName }: { onClose: () => void; videoName: string }) { + const { store, clearUpload, getStreamTitle } = useController(ExportModalCtx); const { UserService } = Services; const { isYoutubeLinked } = useVuex(() => ({ isYoutubeLinked: !!UserService.state.auth?.platforms.youtube, @@ -291,8 +325,8 @@ function PlatformSelect(p: { onClose: () => void }) { nowrap options={platformOptions} /> - {platform === 'youtube' && } - {platform !== 'youtube' && } + {platform === 'youtube' && } + {platform !== 'youtube' && } ); } diff --git a/app/components-react/highlighter/HighlightGenerator.m.less b/app/components-react/highlighter/HighlightGenerator.m.less new file mode 100644 index 000000000000..fa504f2f6e4b --- /dev/null +++ b/app/components-react/highlighter/HighlightGenerator.m.less @@ -0,0 +1,48 @@ +.wrapper { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 16px; + background: var(--border); + border-radius: 8px; + width: fit-content; +} + +.dropdown { + color: white; +} +.option { + width: 100%; + padding: 8px 12px; + margin-bottom: 4px; + border-radius: 8px; + overflow: hidden; + opacity: 1; +} + +.tag { + padding: 3px 8px; + width: fit-content; + font-size: 12px; + background-color: var(--border); + border-radius: 4px; + margin-right: 4px; + color: #ffffff; +} + +.info-tag { + border-radius: 4px; + color: #ffffff; + background-color: #3a484f; + padding: 2px 6px; +} + +.reset-button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 16px; + padding: 0; +} diff --git a/app/components-react/highlighter/HighlightGenerator.tsx b/app/components-react/highlighter/HighlightGenerator.tsx new file mode 100644 index 000000000000..6e1d93188527 --- /dev/null +++ b/app/components-react/highlighter/HighlightGenerator.tsx @@ -0,0 +1,185 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Button, Select, Checkbox, Typography } from 'antd'; +import { DownOutlined, RobotOutlined } from '@ant-design/icons'; +import { IFilterOptions } from './utils'; +import { IInput } from 'services/highlighter'; +import { getPlacementFromInputs, InputEmojiSection } from './InputEmojiSection'; +import { EHighlighterInputTypes } from 'services/highlighter/ai-highlighter/ai-highlighter'; +import styles from './HighlightGenerator.m.less'; +import { formatSecondsToHMS } from './ClipPreview'; +import { $t } from 'services/i18n'; +const { Option } = Select; + +const selectStyles = { + width: '220px', + borderRadius: '8px', +}; + +const dropdownStyles = { + borderRadius: '10px', + padding: '4px 4px', +}; + +const checkboxStyles = { + borderRadius: '8px', + width: '100%', +}; + +export default function HighlightGenerator({ + combinedClipsDuration, + roundDetails, + emitSetFilter, +}: { + combinedClipsDuration: number; // Maximum duration the highlight reel can be long - only used to restrict the targetDuration options + roundDetails: { + round: number; + inputs: IInput[]; + duration: number; + hypeScore: number; + }[]; + emitSetFilter: (filter: IFilterOptions) => void; +}) { + // console.log('reHIGHUI'); + + const [selectedRounds, setSelectedRounds] = useState([0]); + const [filterType, setFilterType] = useState<'duration' | 'hypescore'>('duration'); + const [targetDuration, setTargetDuration] = useState(combinedClipsDuration + 100); + const options = [ + { + value: 1, + label: $t('%{duration} minute', { duration: 1 }), + }, + ...[2, 5, 10, 12, 15, 20, 30].map(value => ({ + value, + label: $t('%{duration} minutes', { duration: value }), + })), + ]; + const filteredOptions = options.filter(option => option.value * 60 <= combinedClipsDuration); + const isFirstRender = useRef(true); + useEffect(() => { + // To not emit on first render + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + + emitSetFilter({ + rounds: selectedRounds, + targetDuration: filterType === 'duration' ? targetDuration * 60 : 9999, + includeAllEvents: true, + }); + }, [selectedRounds, filterType, targetDuration]); + + function roundDropdownDetails(roundDetails: { + round: number; + inputs: IInput[]; + duration: number; + hypeScore: number; + }) { + const combinedKillAndKnocked = roundDetails.inputs.reduce((count, input) => { + if ( + input.type === EHighlighterInputTypes.KILL || + input.type === EHighlighterInputTypes.KNOCKED + ) { + return count + 1; + } + return count; + }, 0); + const won = roundDetails.inputs.some(input => input.type === EHighlighterInputTypes.VICTORY); + let rank = null; + if (!won) { + rank = getPlacementFromInputs(roundDetails.inputs); + } + return ( +
    +
    Round {roundDetails.round}
    +
    +
    {combinedKillAndKnocked} 🔫
    + {won ? ( +
    1st 🏆
    + ) : ( +
    {`${rank ? '#' + rank : ''} 🪦`}
    + )} +
    {`${roundDetails.hypeScore} 🔥`}
    +
    {`${formatSecondsToHMS(roundDetails.duration)}`}
    +
    +
    + ); + } + + return ( +
    +

    + 🤖 {$t('Create highlight video of')} +

    + +

    {$t('with a duration of')}

    + +
    + ); +} diff --git a/app/components-react/highlighter/InputEmojiSection.m.less b/app/components-react/highlighter/InputEmojiSection.m.less new file mode 100644 index 000000000000..c1d7d0f50749 --- /dev/null +++ b/app/components-react/highlighter/InputEmojiSection.m.less @@ -0,0 +1,3 @@ +.description { + text-wrap: nowrap; +} diff --git a/app/components-react/highlighter/InputEmojiSection.tsx b/app/components-react/highlighter/InputEmojiSection.tsx new file mode 100644 index 000000000000..6ce9326fd2c2 --- /dev/null +++ b/app/components-react/highlighter/InputEmojiSection.tsx @@ -0,0 +1,215 @@ +import React from 'react'; +import { IAiClip, IDeathMetadata, IInput, IKillMetadata, TClip } from 'services/highlighter'; +import { isAiClip } from './utils'; +import { EHighlighterInputTypes } from 'services/highlighter/ai-highlighter/ai-highlighter'; +import styles from './InputEmojiSection.m.less'; + +interface TypeWording { + emoji: string; + description: string; +} +const TYPE_WORDING_MAP: Record TypeWording> = { + [EHighlighterInputTypes.KILL]: count => ({ + emoji: '🔫', + description: count > 1 ? 'eliminations' : 'elimination', + }), + [EHighlighterInputTypes.KNOCKED]: count => ({ + emoji: '🥊', + description: count > 1 ? 'knocks' : 'knocked', + }), + [EHighlighterInputTypes.DEATH]: count => ({ + emoji: '🪦', + description: count > 1 ? 'deaths' : 'death', + }), + [EHighlighterInputTypes.VICTORY]: count => ({ + emoji: '🏆', + description: count > 1 ? 'wins' : 'win', + }), + [EHighlighterInputTypes.DEPLOY]: count => ({ + emoji: '🪂', + description: count > 1 ? 'deploys' : 'deploy', + }), + [EHighlighterInputTypes.PLAYER_KNOCKED]: () => ({ + emoji: '😵', + description: 'got knocked', + }), + BOT_KILL: count => ({ + emoji: '🤖', + description: count > 1 ? 'bot eliminations' : 'bot elimination', + }), + rounds: count => ({ + emoji: '🏁', + description: count === 0 || count > 1 ? `rounds ${count === 0 ? 'detected' : ''}` : 'round', + }), +}; + +function getTypeWordingFromType( + type: string, + count: number, +): { emoji: string; description: string } { + return TYPE_WORDING_MAP[type]?.(count) ?? { emoji: '', description: '?' }; +} + +function getInputTypeCount(clips: TClip[]): { [type: string]: number } { + const typeCounts: { [type: string]: number } = {}; + if (clips.length === 0) { + return typeCounts; + } + clips.forEach(clip => { + if (isAiClip(clip)) { + clip.aiInfo.inputs?.forEach(input => { + const type = input.type; + if (type === EHighlighterInputTypes.KILL) { + if ((input?.metadata as IKillMetadata)?.bot_kill === true) { + const currentCount = typeCounts['BOT_KILL']; + typeCounts['BOT_KILL'] = currentCount ? currentCount + 1 : 1; + return; + } + } + if (typeCounts[type]) { + typeCounts[type] += 1; + } else { + typeCounts[type] = 1; + } + }); + } + }); + return typeCounts; +} +function isDeath(type: string): boolean { + return type === EHighlighterInputTypes.DEATH; +} + +function getGamePlacement(clips: TClip[]): number | null { + const deathClip = clips.find( + clip => + isAiClip(clip) && + clip.aiInfo.inputs.some(input => input.type === EHighlighterInputTypes.DEATH), + ) as IAiClip; + + return getPlacementFromInputs(deathClip.aiInfo.inputs); +} +function getAmountOfRounds(clips: TClip[]): number { + const rounds: number[] = []; + clips.filter(isAiClip).forEach(clip => { + rounds.push(clip.aiInfo.metadata?.round || 1); + }); + return Math.max(0, ...rounds); +} + +export function getPlacementFromInputs(inputs: IInput[]): number | null { + const deathInput = inputs.find(input => input.type === EHighlighterInputTypes.DEATH); + return (deathInput?.metadata as IDeathMetadata)?.place || null; +} + +export function InputEmojiSection({ + clips, + includeRounds, + includeDeploy, + showCount, + showDescription, +}: { + clips: TClip[]; + includeRounds: boolean; + includeDeploy: boolean; + showCount?: boolean; + showDescription?: boolean; +}): JSX.Element { + const excludeTypes = [ + EHighlighterInputTypes.GAME_SEQUENCE, + EHighlighterInputTypes.GAME_START, + EHighlighterInputTypes.GAME_END, + EHighlighterInputTypes.VOICE_ACTIVITY, + EHighlighterInputTypes.META_DURATION, + EHighlighterInputTypes.LOW_HEALTH, + ]; + + const inputTypeMap = Object.entries(getInputTypeCount(clips)); + const filteredInputTypeMap = inputTypeMap.filter( + ([type]) => + !excludeTypes.includes(type as EHighlighterInputTypes) && + (inputTypeMap.length <= 2 || includeDeploy || type !== 'deploy'), + ); + + return ( +
    + {includeRounds && } + {filteredInputTypeMap.map(([type, count]) => ( + + ))} + + {inputTypeMap.length > 3 ? '...' : ''} +
    + ); +} + +export function RoundTag({ clips }: { clips: TClip[] }): JSX.Element { + const rounds = getAmountOfRounds(clips); + const { emoji, description } = getTypeWordingFromType('rounds', rounds); + return ( +
    + {emoji} + + {rounds} {description} + +
    + ); +} + +export function AiMomentTag({ + type, + count, + clips, + showCount, + showDescription, + includeRounds, +}: { + type: string; + count: number; + clips: TClip[]; + showCount?: boolean; + showDescription?: boolean; + includeRounds?: boolean; +}): JSX.Element { + const { emoji, description } = getTypeWordingFromType(type, count); + return ( +
    + {emoji} + {(showCount !== false || showDescription !== false) && ( + + {showCount !== false && `${count} `} + {showDescription !== false && description} + {!includeRounds && isDeath(type) && getGamePlacement(clips) + ? '#' + getGamePlacement(clips) + : ''} + + )} +
    + ); +} + +export function ManualClipTag({ clips }: { clips: TClip[] }): JSX.Element { + const manualClips = clips.filter( + clip => clip.source === 'ReplayBuffer' || clip.source === 'Manual', + ); + if (manualClips.length === 0) { + return <>; + } + return ( +
    + 🎬 + {`${manualClips.length} ${ + manualClips.length === 1 ? 'manual' : 'manuals' + }`} +
    + ); +} diff --git a/app/components-react/highlighter/PreviewModal.m.less b/app/components-react/highlighter/PreviewModal.m.less new file mode 100644 index 000000000000..486e76d206ca --- /dev/null +++ b/app/components-react/highlighter/PreviewModal.m.less @@ -0,0 +1,29 @@ +.timeline { + width: 100%; + padding-left: 8px; + padding-right: 8px; + display: flex; + overflow-x: auto; +} + +.timeline-item { + cursor: pointer; + border-radius: 6px; + width: fit-content; + border: solid 2px transparent; +} + +.timeline-item-wrapper { + display: flex; + gap: 4px; + padding-bottom: 8px; + justify-content: center; +} + +.video-player { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} diff --git a/app/components-react/highlighter/PreviewModal.tsx b/app/components-react/highlighter/PreviewModal.tsx index b8425f807600..f6331cf1e9ae 100644 --- a/app/components-react/highlighter/PreviewModal.tsx +++ b/app/components-react/highlighter/PreviewModal.tsx @@ -1,9 +1,11 @@ -import { useVuex } from 'components-react/hooks'; -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Services } from 'components-react/service-provider'; -import { Progress, Alert } from 'antd'; import { $t } from 'services/i18n'; - +import { TClip } from 'services/highlighter'; +import { sortClipsByOrder } from './utils'; +import MiniClipPreview from './MiniClipPreview'; +import { PauseButton, PlayButton } from './StreamCard'; +import styles from './PreviewModal.m.less'; export default function PreviewModal({ close, streamId, @@ -11,76 +13,274 @@ export default function PreviewModal({ close: () => void; streamId: string | undefined; }) { + if (streamId === undefined) { + close(); + console.error('streamId is required'); + } const { HighlighterService } = Services; - const v = useVuex(() => ({ - exportInfo: HighlighterService.views.exportInfo, - })); + const clips = HighlighterService.getClips(HighlighterService.views.clips, streamId); + const { intro, outro } = HighlighterService.views.video; + const audioSettings = HighlighterService.views.audio; + const [currentClipIndex, setCurrentClipIndex] = useState(0); + const currentClipIndexRef = useRef(0); + const sortedClips = [...sortClipsByOrder(clips, streamId).filter(c => c.enabled)]; - useEffect(() => { - HighlighterService.actions.export(true, streamId); + const playlist = [ + ...(intro.duration + ? [ + { + src: intro.path, + path: intro.path, + start: 0, + end: intro.duration!, + type: 'video/mp4', + }, + ] + : []), + ...sortedClips.map((clip: TClip) => ({ + src: clip.path + `#t=${clip.startTrim},${clip.duration! - clip.endTrim}`, + path: clip.path, + start: clip.startTrim, + end: clip.duration! - clip.endTrim, + type: 'video/mp4', + })), + ...(outro.duration && outro.path + ? [ + { + src: outro.path, + path: outro.path, + start: 0, + end: outro.duration!, + type: 'video/mp4', + }, + ] + : []), + ]; + const videoPlayer = useRef(null); + const containerRef = useRef(null); + const audio = useRef(null); + const isChangingClip = useRef(false); + const [isPlaying, setIsPlaying] = useState(true); - return () => HighlighterService.actions.cancelExport(); - }, []); + function isRoughlyEqual(a: number, b: number, tolerance: number = 0.3): boolean { + return Math.abs(a - b) <= tolerance; + } - // Clear all errors when this component unmounts useEffect(() => { - return () => HighlighterService.actions.dismissError(); - }, []); + if (!videoPlayer.current) { + return; + } + //Pause gets also triggered when the video ends. We dont want to change the clip in that case + const nextClip = () => { + if (!isChangingClip.current) { + isChangingClip.current = true; + + setCurrentClipIndex(prevIndex => { + const newIndex = (prevIndex + 1) % playlist.length; + + videoPlayer.current!.src = playlist[currentClipIndex].src; + videoPlayer.current!.load(); + + playAudio(newIndex, newIndex === prevIndex + 1); + + return newIndex; + }); + + setTimeout(() => { + isChangingClip.current = false; + }, 500); + } + }; + + const handleEnded = () => { + nextClip(); + }; - // Kind of hacky but used to know if we ever were exporting at any point - const didStartExport = useRef(false); - if (v.exportInfo.exporting) didStartExport.current = true; + const handlePause = () => { + // sometimes player fires paused event before ended, in this case we need to compare timestamps + // and check if we are at the end of the clip + const currentTime = videoPlayer.current!.currentTime; + const endTime = playlist[currentClipIndexRef.current].end; + + if (currentTime >= endTime || isRoughlyEqual(currentTime, endTime)) { + nextClip(); + } + }; + + const handlePlay = () => { + setIsPlaying(true); + }; + + const handleAudioEnd = () => { + audio.current!.currentTime = 0; + audio.current!.play().catch(e => console.error('Error playing audio:', e)); + }; + + videoPlayer.current.addEventListener('ended', handleEnded); + videoPlayer.current.addEventListener('play', handlePlay); + videoPlayer.current.addEventListener('pause', handlePause); + + if (audioSettings.musicEnabled && audioSettings.musicPath && playlist.length > 0) { + audio.current = new Audio(audioSettings.musicPath); + audio.current.volume = audioSettings.musicVolume / 100; + audio.current.autoplay = true; + audio.current.addEventListener('ended', handleAudioEnd); + } + + return () => { + videoPlayer.current?.removeEventListener('ended', handleEnded); + videoPlayer.current?.removeEventListener('play', handlePlay); + videoPlayer.current?.removeEventListener('pause', handlePause); + if (audio.current) { + audio.current.pause(); + audio.current.removeEventListener('ended', handleAudioEnd); + audio.current = null; + } + }; + }, [playlist.length, videoPlayer.current]); useEffect(() => { - // Close the window immediately if we stopped exporting due to cancel - if (!v.exportInfo.exporting && v.exportInfo.cancelRequested && didStartExport.current) { - close(); + currentClipIndexRef.current = currentClipIndex; + if (videoPlayer.current === null || playlist.length === 0) { + return; + } + videoPlayer.current!.src = playlist[currentClipIndex].src; + videoPlayer.current!.load(); + videoPlayer.current!.play().catch(e => console.error('Error playing video:', e)); + + // currently its done by querying DOM, don't want to store a giant array of refs + // that wont be used otherwise + document.getElementById('preview-' + currentClipIndex)?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }); + }, [currentClipIndex]); + + function togglePlay() { + const currentPlayer = videoPlayer.current; + if (currentPlayer?.paused) { + currentPlayer.play().catch(e => console.error('Error playing video:', e)); + if (audio.current && audio.current.currentTime > 0) { + audio.current?.play().catch(e => console.error('Error playing audio:', e)); + } + } else { + setIsPlaying(false); + currentPlayer?.pause(); + audio.current?.pause(); } - }, [v.exportInfo.exporting, v.exportInfo.cancelRequested]); + } + + function playPauseButton() { + if (isPlaying) { + return ; + } else { + return ; + } + } + + function jumpToClip(index: number) { + if (currentClipIndex === index) { + return; + } + setCurrentClipIndex(index); + + const clip = playlist[index]; + videoPlayer.current!.src = clip.src; + videoPlayer.current!.load(); + + playAudio(index); + } + + function playAudio(index: number, continuation = false) { + // if its a continuation of a previous segment, no need to seek + // and introduce playback lag + if (continuation || !audio.current) { + return; + } + + // clips don't have absolute timestamps, we need to calculate the start time + // in relation to previous clips + const startTime = playlist + .filter((_, i) => i < index) + .reduce((acc, curr) => acc + (curr.end - curr.start), 0); + + if (startTime < audio.current!.duration) { + audio.current!.currentTime = startTime; + } else { + const start = startTime % audio.current!.duration; + audio.current!.currentTime = start; + // audio.current?.pause(); + } + audio.current!.play().catch(e => console.error('Error playing audio:', e)); + } + + const handleScroll = (event: { deltaY: any }) => { + if (containerRef.current) { + containerRef.current.scrollLeft += event.deltaY; + } + }; + + if (playlist.length === 0) { + return ( +
    +

    {$t('Preview')}

    +

    Select at least one clip to preview your video

    +
    + ); + } return (
    -

    {$t('Render Preview')}

    +

    {$t('Preview')}

    - {$t( - 'The render preview shows a low-quality preview of the final rendered video. The final exported video will be higher resolution, framerate, and quality.', - )} + This is just a preview of your highlight reel. Loading times between clips are possible.

    - {v.exportInfo.exporting && ( - - )} - {v.exportInfo.exporting && v.exportInfo.cancelRequested && {$t('Canceling...')}} - {v.exportInfo.exporting &&
    } - {v.exportInfo.exporting && ( - - )} - {!v.exportInfo.exporting && v.exportInfo.error && ( - - )} - {!v.exportInfo.exporting && - !v.exportInfo.cancelRequested && - !v.exportInfo.error && - didStartExport.current && ( -
    ); } diff --git a/app/components-react/highlighter/SettingsView.m.less b/app/components-react/highlighter/SettingsView.m.less index bfe4255e162c..fc9ea0c282ce 100644 --- a/app/components-react/highlighter/SettingsView.m.less +++ b/app/components-react/highlighter/SettingsView.m.less @@ -19,12 +19,20 @@ .inner-scroll-wrapper { display: flex; - background-color: #09161d; + background-color: var(--section); padding: 56px; border-radius: 24px; gap: 24px; } +.headerbar-tag { + margin: 0; + margin-left: 4px; + font-size: 14px; + padding: 2px 6px; + border-radius: 4px; + background-color: var(--section); +} .card-wrapper { display: flex; flex-direction: column; @@ -39,12 +47,24 @@ flex-direction: column; gap: 24px; position: relative; - background-color: #17242d; + background-color: var(--border); padding: 40px; border-radius: 16px; border: solid 2px #2b5bd7; } +.recommended-corner { + position: absolute; + right: 0; + bottom: 0; + border-radius: 16px 0 9px 0; + padding: 8px; + padding-bottom: 5px; + background-color: #2b5bd7; + color: white; + height: fit-content; +} + .manual-card { display: flex; flex-direction: column; @@ -61,9 +81,8 @@ } .setting-section { - background-color: #17242d; + background-color: var(--border); h3 { - color: #bdc2c4; } padding: 24px; @@ -83,3 +102,9 @@ background-size: contain; background-repeat: no-repeat; } + +.card-headerbar { + display: flex; + align-items: center; + gap: 8px; +} diff --git a/app/components-react/highlighter/SettingsView.tsx b/app/components-react/highlighter/SettingsView.tsx index 77339085970e..e5193866be2d 100644 --- a/app/components-react/highlighter/SettingsView.tsx +++ b/app/components-react/highlighter/SettingsView.tsx @@ -12,6 +12,7 @@ import Scrollable from 'components-react/shared/Scrollable'; import styles from './SettingsView.m.less'; import { $t } from 'services/i18n'; import { EHighlighterView, IViewState } from 'services/highlighter'; +import { EAvailableFeatures } from 'services/incremental-rollout'; export default function SettingsView({ emitSetView, @@ -20,13 +21,23 @@ export default function SettingsView({ emitSetView: (data: IViewState) => void; close: () => void; }) { - const { HotkeysService, SettingsService, StreamingService, HighlighterService } = Services; + const { + HotkeysService, + SettingsService, + StreamingService, + HighlighterService, + IncrementalRolloutService, + } = Services; + const aiHighlighterEnabled = IncrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.aiHighlighter, + ); const [hotkey, setHotkey] = useState(null); const hotkeyRef = useRef(null); const v = useVuex(() => ({ settingsValues: SettingsService.views.values, isStreaming: StreamingService.isStreaming, + useAiHighlighter: HighlighterService.views.useAiHighlighter, })); const correctlyConfigured = @@ -97,6 +108,10 @@ export default function SettingsView({ SettingsService.actions.setSettingsPatch({ Output: { RecRBTime: time } }); } + function toggleUseAiHighlighter() { + HighlighterService.actions.toggleAiHighlighter(); + } + return (
    @@ -109,9 +124,14 @@ export default function SettingsView({

    + {aiHighlighterEnabled && ( + + )} {/* New button coming with next PR */}
    @@ -119,11 +139,38 @@ export default function SettingsView({
    + {aiHighlighterEnabled && ( +
    +
    +
    + +

    {$t('AI Highlighter')}

    +

    {$t('For Fortnite streams (Beta)')}

    +
    +
    + +

    + {$t( + 'Automatically capture the best moments from your livestream and turn them into a highlight video.', + )} +

    + + +
    {$t('Recommended')}
    +
    + )}
    -

    {$t('Manual Highlighter')}

    +

    + {aiHighlighterEnabled ? 'Or use the manual highlighter ' : 'Manual highlighter'} +

    {$t( - 'The hotkey highlighter allows you to clip the best moments during your livestream manually and edit them together afterwards.', + 'Manually capture the best moments from your livestream with a hotkey command, and automatically turn them into a highlight video.', )}

    diff --git a/app/components-react/highlighter/StreamCard.m.less b/app/components-react/highlighter/StreamCard.m.less new file mode 100644 index 000000000000..849a491aca4e --- /dev/null +++ b/app/components-react/highlighter/StreamCard.m.less @@ -0,0 +1,183 @@ +@import '../../styles/index'; + +.stream-card { + overflow: hidden; + background: var(--border); + display: flex; + flex-direction: column; + border-radius: 10px; + gap: 16px; + height: fit-content; + width: 422px; + cursor: pointer; +} + +.clips-amount { + position: absolute; + top: 50%; + left: 50%; + color: white; + text-align: center; + font-size: 14px; + z-index: 99; + display: flex; + gap: 3px; + padding-right: 3px; + text-shadow: 0px 0px 6px black; + transform: translate(-24px, 15px); +} + +.centered-overlay-item { + position: absolute; + top: 50%; + left: 50%; + color: white; + text-align: center; + font-size: 14px; + z-index: 99; + display: flex; + gap: 3px; + padding-right: 3px; + transform: translate(-50%, -50%); +} + +.thumbnail-wrapper { + position: relative; + + --thumbWith: 192px; + --thumbHeight: 108px; + overflow-x: clip; + + width: calc(var(--thumbWith) * 2.2); + height: calc(var(--thumbHeight) * 2.2); + background: var(--Day-Colors-Dark-4, #4f5e65); +} + +.thumbnail-wrapper-small { + position: absolute; + --thumbWith: 192px; + --thumbHeight: 108px; + overflow-x: clip; + border-radius: 4px; + + width: calc(var(--thumbWith) * 0.35); // Destroys the aspect ratio but is nicer in the ui + height: calc(var(--thumbHeight) * 0.4); + border: 2px #22292d solid; +} + +.progressbar-background { + display: flex; + position: relative; + justify-content: space-between; + width: 100%; + height: 40px; + border-radius: 4px; + overflow: hidden; + background: var(--Day-Colors-Dark-4, #4f5e65); +} + +.progressbar-progress { + height: 100%; + width: 100%; + transform: scaleX(0); + + background: var(--Night-Colors-Light-2, #f5f8fa); +} + +.progressbar-text { + height: 40px; + display: flex; + align-items: center; + padding-left: 16px; + position: absolute; + color: black; + font-size: 16px; + z-index: 1; +} + +.loader { + border: 2px solid #f3f3f3; /* Light grey */ + border-top: 2px solid #3e3e3e; /* Blue */ + border-radius: 50%; + width: 16px; + height: 16px; + animation: spin 2s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.delete-button { + display: flex; + top: 8px; + left: 8px; + gap: 8px; + align-items: center; + opacity: 0; + backdrop-filter: blur(6px); +} + +.stream-card:hover .delete-button { + opacity: 1; +} + +.streaminfo-wrapper { + display: flex; + flex-direction: column; + gap: 8px; + padding: 20px; + padding-top: 0px; +} + +.streamcard-title { + margin: 0; + width: 275px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.title-date-wrapper { + display: flex; + flex-direction: column; + gap: 4px; + height: fit-content; +} + +.title-rotated-clips-wrapper { + display: flex; + justify-content: space-between; + gap: 8px; + height: fit-content; +} + +.emoji-wrapper { + padding-top: 6px; + padding-bottom: 6px; + margin: 0; + display: flex; + gap: 8px; + justify-content: start; +} + +.cancel-button { + border: none; + background-color: transparent; + color: black; + display: flex; + position: absolute; + right: 0; + align-items: center; +} + +.button-bar-wrapper { + display: flex; + gap: 4px; + justify-content: space-between; +} diff --git a/app/components-react/highlighter/StreamCard.tsx b/app/components-react/highlighter/StreamCard.tsx new file mode 100644 index 000000000000..ff287fe06945 --- /dev/null +++ b/app/components-react/highlighter/StreamCard.tsx @@ -0,0 +1,366 @@ +import React from 'react'; +import { + EAiDetectionState, + EHighlighterView, + IHighlightedStream, + IViewState, + StreamInfoForAiHighlighter, + TClip, +} from 'services/highlighter'; +import styles from './StreamCard.m.less'; +import { Button } from 'antd'; +import { Services } from 'components-react/service-provider'; +import { isAiClip } from './utils'; +import { useVuex } from 'components-react/hooks'; +import { InputEmojiSection } from './InputEmojiSection'; +import { $t } from 'services/i18n'; + +export default function StreamCard({ + streamId, + clipsOfStreamAreLoading, + emitSetView, + emitGeneratePreview, + emitExportVideo, + emitRemoveStream, + emitCancelHighlightGeneration, +}: { + streamId: string; + clipsOfStreamAreLoading: string | null; + emitSetView: (data: IViewState) => void; + emitGeneratePreview: () => void; + emitExportVideo: () => void; + emitRemoveStream: () => void; + emitCancelHighlightGeneration: () => void; +}) { + const { HighlighterService } = Services; + const clips = useVuex(() => + HighlighterService.views.clips + .filter(c => c.streamInfo?.[streamId]) + .map(clip => { + if (isAiClip(clip) && (clip.aiInfo as any).moments) { + clip.aiInfo.inputs = (clip.aiInfo as any).moments; + } + return clip; + }), + ); + const stream = useVuex(() => + HighlighterService.views.highlightedStreams.find(s => s.id === streamId), + ); + if (!stream) { + return <>; + } + + function showStreamClips() { + if (stream?.state.type !== EAiDetectionState.IN_PROGRESS) { + emitSetView({ view: EHighlighterView.CLIPS, id: stream?.id }); + } + } + + return ( +
    { + showStreamClips(); + }} + > + +
    +
    +
    +

    {stream.title}

    +

    {new Date(stream.date).toDateString()}

    +
    + +
    +

    + {stream.state.type === EAiDetectionState.FINISHED ? ( + + ) : ( +
    + )} +

    + { + HighlighterService.actions.restartAiDetection(stream.path, stream); + }} + emitSetView={emitSetView} + /> +
    +
    + ); +} + +function ActionBar({ + stream, + clips, + clipsOfStreamAreLoading, + emitCancelHighlightGeneration, + emitExportVideo, + emitShowStreamClips, + emitRestartAiDetection, + emitSetView, +}: { + stream: IHighlightedStream; + clips: TClip[]; + clipsOfStreamAreLoading: string | null; + emitCancelHighlightGeneration: () => void; + emitExportVideo: () => void; + emitShowStreamClips: () => void; + emitRestartAiDetection: () => void; + emitSetView: (data: IViewState) => void; +}): JSX.Element { + function getFailedText(state: EAiDetectionState): string { + switch (state) { + case EAiDetectionState.ERROR: + return $t('Highlights failed'); + case EAiDetectionState.CANCELED_BY_USER: + return $t('Highlights cancelled'); + default: + return ''; + } + } + + // In Progress + if (stream?.state.type === EAiDetectionState.IN_PROGRESS) { + return ( +
    +
    {$t('Searching for highlights...')}
    +
    + + +
    + ); + } + + // If finished + if (stream && clips.length > 0) { + return ( +
    + + + {/* TODO: What clips should be included when user clicks this button + bring normal export modal in here */} + +
    + ); + } + + //if failed or no clips + return ( +
    +
    + {getFailedText(stream.state.type)} +
    +
    + {stream?.state.type === EAiDetectionState.CANCELED_BY_USER ? ( + + ) : ( + + )} +
    +
    + ); +} + +export function Thumbnail({ + clips, + clipsOfStreamAreLoading, + stream, + emitGeneratePreview, + emitCancelHighlightGeneration, + emitRemoveStream, +}: { + clips: TClip[]; + clipsOfStreamAreLoading: string | null; + stream: IHighlightedStream; + emitGeneratePreview: () => void; + emitCancelHighlightGeneration: () => void; + emitRemoveStream: () => void; +}) { + function getThumbnailText(state: EAiDetectionState): JSX.Element | string { + if (clipsOfStreamAreLoading === stream?.id) { + return
    ; + } + + if (clips.length > 0) { + return ; + } + switch (state) { + case EAiDetectionState.IN_PROGRESS: + return $t('Searching for highlights...'); + case EAiDetectionState.FINISHED: + if (clips.length === 0) { + return $t('Not enough highlights found'); + } + return ; + case EAiDetectionState.CANCELED_BY_USER: + return $t('Highlights cancelled'); + case EAiDetectionState.ERROR: + return $t('Highlights cancelled'); + default: + return ''; + } + } + + return ( +
    + + { + if (stream.state.type !== EAiDetectionState.IN_PROGRESS) { + emitGeneratePreview(); + e.stopPropagation(); + } + }} + style={{ height: '100%' }} + src={ + clips.find(clip => clip?.streamInfo?.[stream.id]?.orderPosition === 0)?.scrubSprite || + clips.find(clip => clip.scrubSprite)?.scrubSprite + } + alt="" + /> +
    +
    { + if (stream.state.type !== EAiDetectionState.IN_PROGRESS) { + emitGeneratePreview(); + e.stopPropagation(); + } + }} + > + {getThumbnailText(stream.state.type)} +
    +
    +
    + ); +} + +export function RotatedClips({ clips }: { clips: TClip[] }) { + return ( +
    + {clips.length > 0 ? ( +
    +
    + {clips.length} + clips +
    + {clips.slice(0, 3).map((clip, index) => ( +
    + +
    + ))} +
    + ) : ( + '' + )} +
    + ); +} + +export const PlayButton = () => ( + + + +); +export const PauseButton = () => ( + + + + +); diff --git a/app/components-react/highlighter/StreamView.m.less b/app/components-react/highlighter/StreamView.m.less new file mode 100644 index 000000000000..03d1a01412ff --- /dev/null +++ b/app/components-react/highlighter/StreamView.m.less @@ -0,0 +1,76 @@ +@import '../../styles/index'; + +.stream-view-root { + position: relative; + + :global(.ant-modal-wrap), + :global(.ant-modal-mask) { + position: absolute; + } + + :global(.os-content-glue) { + width: 100% !important; + height: 100% !important; + } +} + +.streams-wrapper { + display: grid; + grid-template-columns: auto auto auto; + + gap: 16px; +} + +.stream-view-root { + position: relative; + + :global(.ant-modal-wrap), + :global(.ant-modal-mask) { + position: absolute; + } + + :global(.os-content-glue) { + width: 100% !important; + height: 100% !important; + } +} + +.upload-wrapper { + display: flex; + padding: 16px; + padding-left: 22px; + margin-top: -17px; //1px bcs of border + align-items: center; + gap: 16px; + border-radius: 8px; + border: 1px dashed var(--Day-Colors-Dark-4, #4f5e65); +} + +.manual-upload-wrapper { + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; + padding: 8px; +} +.title-input-wrapper { + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; +} + +.streamcards-wrapper { + width: 100%; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.period-divider { + border-bottom: 1px solid var(--border); + margin: 20px 0; + padding-bottom: 10px; + font-size: 18px; + font-weight: bold; +} diff --git a/app/components-react/highlighter/StreamView.tsx b/app/components-react/highlighter/StreamView.tsx new file mode 100644 index 000000000000..121ec780f436 --- /dev/null +++ b/app/components-react/highlighter/StreamView.tsx @@ -0,0 +1,402 @@ +import { useVuex } from 'components-react/hooks'; +import React, { useRef, useState } from 'react'; +import { Services } from 'components-react/service-provider'; +import styles from './StreamView.m.less'; +import { EHighlighterView, IViewState, StreamInfoForAiHighlighter } from 'services/highlighter'; +import isEqual from 'lodash/isEqual'; +import { Modal, Button, Alert } from 'antd'; +import ExportModal from 'components-react/highlighter/ExportModal'; +import { SUPPORTED_FILE_TYPES } from 'services/highlighter/constants'; +import Scrollable from 'components-react/shared/Scrollable'; +import { $t } from 'services/i18n'; +import * as remote from '@electron/remote'; +import uuid from 'uuid'; +import StreamCard from './StreamCard'; +import path from 'path'; +import PreviewModal from './PreviewModal'; +import moment from 'moment'; + +type TModalStreamView = + | { type: 'export'; id: string | undefined } + | { type: 'preview'; id: string | undefined } + | { type: 'upload' } + | { type: 'remove'; id: string | undefined } + | null; + +export default function StreamView({ emitSetView }: { emitSetView: (data: IViewState) => void }) { + const { HighlighterService, HotkeysService, UsageStatisticsService } = Services; + const v = useVuex(() => ({ + exportInfo: HighlighterService.views.exportInfo, + error: HighlighterService.views.error, + uploadInfo: HighlighterService.views.uploadInfo, + })); + + // Below is only used because useVueX doesnt work as expected + // there probably is a better way to do this + const currentStreams = useRef<{ id: string; date: string }[]>(); + const highlightedStreams = useVuex(() => { + const newStreamIds = [ + ...HighlighterService.views.highlightedStreams.map(stream => { + return { id: stream.id, date: stream.date }; + }), + ]; + + if (currentStreams.current === undefined || !isEqual(currentStreams.current, newStreamIds)) { + currentStreams.current = newStreamIds; + } + return currentStreams.current.sort( + (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), + ); + }); + + const currentAiDetectionState = useRef(); + + const aiDetectionInProgress = useVuex(() => { + const newDetectionInProgress = HighlighterService.views.highlightedStreams.some( + stream => stream.state.type === 'detection-in-progress', + ); + + if ( + currentAiDetectionState.current === undefined || + !isEqual(currentAiDetectionState.current, newDetectionInProgress) + ) { + currentAiDetectionState.current = newDetectionInProgress; + } + return currentAiDetectionState.current; + }); + + const [showModal, rawSetShowModal] = useState(null); + const [modalWidth, setModalWidth] = useState('700px'); + const [clipsOfStreamAreLoading, setClipsOfStreamAreLoading] = useState(null); + + // This is kind of weird, but ensures that modals stay the right + // size while the closing animation is played. This is why modal + // width has its own state. This makes sure we always set the right + // size whenever displaying a modal. + function setShowModal(modal: TModalStreamView | null) { + rawSetShowModal(modal); + + if (modal && modal.type) { + setModalWidth( + { + trim: '60%', + preview: '700px', + export: '700px', + remove: '400px', + upload: '400px', + }[modal.type], + ); + } + } + + async function previewVideo(id: string) { + setClipsOfStreamAreLoading(id); + + try { + await HighlighterService.actions.return.loadClips(id); + setClipsOfStreamAreLoading(null); + rawSetShowModal({ type: 'preview', id }); + } catch (error: unknown) { + console.error('Error loading clips for preview export', error); + setClipsOfStreamAreLoading(null); + } + } + + async function exportVideo(id: string) { + setClipsOfStreamAreLoading(id); + + try { + await HighlighterService.actions.return.loadClips(id); + setClipsOfStreamAreLoading(null); + rawSetShowModal({ type: 'export', id }); + } catch (error: unknown) { + console.error('Error loading clips for export', error); + setClipsOfStreamAreLoading(null); + } + } + + function ImportStreamModal({ close }: { close: () => void }) { + const { HighlighterService } = Services; + const [inputValue, setInputValue] = useState(''); + + function handleInputChange(event: any) { + setInputValue(event.target.value); + } + + async function startAiDetection(title: string) { + const streamInfo: StreamInfoForAiHighlighter = { + id: 'manual_' + uuid(), + title, + game: 'Fortnite', + }; + + let filePath: string[] | undefined = []; + + try { + filePath = await importStreamFromDevice(); + if (filePath && filePath.length > 0) { + HighlighterService.actions.flow(filePath[0], streamInfo); + close(); + } else { + // No file selected + } + } catch (error: unknown) { + console.error('Error importing file from device', error); + } + } + + return ( + <> +
    +
    +

    {$t('Import Fortnite stream')}

    + +
    +
    + + +
    +
    + + ); + } + + async function importStreamFromDevice() { + const selections = await remote.dialog.showOpenDialog(remote.getCurrentWindow(), { + properties: ['openFile'], + filters: [{ name: $t('Video Files'), extensions: SUPPORTED_FILE_TYPES }], + }); + + if (selections && selections.filePaths) { + return selections.filePaths; + } + } + + function closeModal() { + // Do not allow closing export modal while export/upload operations are in progress + if (v.exportInfo.exporting) return; + if (v.uploadInfo.uploading) return; + + setShowModal(null); + + if (v.error) HighlighterService.actions.dismissError(); + } + + function onDrop(e: React.DragEvent) { + const extensions = SUPPORTED_FILE_TYPES.map(e => `.${e}`); + const files: string[] = []; + let fi = e.dataTransfer.files.length; + while (fi--) { + const file = e.dataTransfer.files.item(fi)?.path; + if (file) files.push(file); + } + + const filtered = files.filter(f => extensions.includes(path.parse(f).ext)); + if (filtered.length) { + const StreamInfoForAiHighlighter: StreamInfoForAiHighlighter = { + id: 'manual_' + uuid(), + game: 'Fortnite', + }; + HighlighterService.actions.flow(filtered[0], StreamInfoForAiHighlighter); + } + + e.preventDefault(); + e.stopPropagation(); + } + + return ( +
    onDrop(event)} + > +
    +
    +

    My stream highlights

    +
    +
    +
    !aiDetectionInProgress && setShowModal({ type: 'upload' })} + > + + {$t('Select your Fornite recording')} + +
    + +
    +
    + + + {highlightedStreams.length === 0 ? ( + <>No highlight clips created from streams // TODO: Add empty state + ) : ( + Object.entries(groupStreamsByTimePeriod(highlightedStreams)).map( + ([period, streams]) => + streams.length > 0 && ( + +
    {period}
    +
    + {streams.map(stream => ( + emitSetView(data)} + emitGeneratePreview={() => previewVideo(stream.id)} + emitExportVideo={() => exportVideo(stream.id)} + emitRemoveStream={() => setShowModal({ type: 'remove', id: stream.id })} + clipsOfStreamAreLoading={clipsOfStreamAreLoading} + emitCancelHighlightGeneration={() => { + HighlighterService.actions.cancelHighlightGeneration(stream.id); + }} + /> + ))} +
    +
    + ), + ) + )} +
    + + + {!!v.error && } + {showModal?.type === 'upload' && } + {showModal?.type === 'export' && } + {showModal?.type === 'preview' && ( + + )} + {showModal?.type === 'remove' && ( + + )} + +
    + ); +} + +function RemoveStream(p: { streamId: string | undefined; close: () => void }) { + const { HighlighterService } = Services; + + return ( +
    +

    {$t('Delete highlighted stream?')}

    +

    + {$t( + 'Are you sure you want to delete this stream and all its associated clips? This action cannot be undone.', + )} +

    + + +
    + ); +} + +export function groupStreamsByTimePeriod(streams: { id: string; date: string }[]) { + const now = moment(); + const groups: { [key: string]: typeof streams } = { + Today: [], + Yesterday: [], + 'This week': [], + 'Last week': [], + 'This month': [], + 'Last month': [], + }; + const monthGroups: { [key: string]: typeof streams } = {}; + + streams.forEach(stream => { + const streamDate = moment(stream.date); + if (streamDate.isSame(now, 'day')) { + groups['Today'].push(stream); + } else if (streamDate.isSame(now.clone().subtract(1, 'day'), 'day')) { + groups['Yesterday'].push(stream); + } else if (streamDate.isSame(now, 'week')) { + groups['This week'].push(stream); + } else if (streamDate.isSame(now.clone().subtract(1, 'week'), 'week')) { + groups['Last week'].push(stream); + } else if (streamDate.isSame(now, 'month')) { + groups['This month'].push(stream); + } else if (streamDate.isSame(now.clone().subtract(1, 'month'), 'month')) { + groups['Last month'].push(stream); + } else { + const monthKey = streamDate.format('MMMM YYYY'); + if (!monthGroups[monthKey]) { + monthGroups[monthKey] = []; + } + monthGroups[monthKey].push(stream); + } + }); + + return { ...groups, ...monthGroups }; +} + +function FortniteIcon(): JSX.Element { + return ( + + + + + + + + + + + + + + + + + + + ); +} diff --git a/app/components-react/highlighter/UpdateModal.m.less b/app/components-react/highlighter/UpdateModal.m.less new file mode 100644 index 000000000000..bfae124209ad --- /dev/null +++ b/app/components-react/highlighter/UpdateModal.m.less @@ -0,0 +1,51 @@ +.overlay { + position: fixed; + top: 20; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(23, 36, 45, 0.3); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + background-color: #09161d; + padding: 52px; + border-radius: 16px; + width: 400px; + text-align: center; +} + +.title { + font-size: 20px; + color: #ffffff; + font-style: normal; + font-weight: 500; + margin: 0 0 20px 0; +} + +.subtitle { + font-size: 14px; + color: #ffffff; + font-style: normal; + font-weight: 400; + margin: 0 0 36px 0; +} + +.progressBarContainer { + background-color: #2b383f; + border-radius: 8px; + overflow: hidden; + height: 12px; + margin: 20px 0; +} + +.progressBar { + background-color: #80f5d2; + height: 100%; + width: 0; +} diff --git a/app/components-react/highlighter/UpdateModal.tsx b/app/components-react/highlighter/UpdateModal.tsx new file mode 100644 index 000000000000..026448c85e47 --- /dev/null +++ b/app/components-react/highlighter/UpdateModal.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import styles from './UpdateModal.m.less'; + +export default function Modal({ + version, + progress, + isVisible, +}: { + version: string; + progress: number; + isVisible: boolean; +}) { + if (!isVisible) return null; + + let subtitle; + if (progress >= 100) { + subtitle =

    Installing...

    ; + } else { + subtitle =

    {Math.round(progress)}% complete

    ; + } + + return ( +
    +
    +

    Downloading version {version}

    + {subtitle} +
    +
    +
    +
    +
    + ); +} diff --git a/app/components-react/pages/Highlighter.tsx b/app/components-react/pages/Highlighter.tsx index 02552861033c..e3007af82817 100644 --- a/app/components-react/pages/Highlighter.tsx +++ b/app/components-react/pages/Highlighter.tsx @@ -3,27 +3,60 @@ import { useVuex } from 'components-react/hooks'; import React, { useState } from 'react'; import { EHighlighterView, IViewState } from 'services/highlighter'; import { Services } from 'components-react/service-provider'; +import StreamView from 'components-react/highlighter/StreamView'; import ClipsView from 'components-react/highlighter/ClipsView'; +import UpdateModal from 'components-react/highlighter/UpdateModal'; +import { EAvailableFeatures } from 'services/incremental-rollout'; export default function Highlighter(props: { params?: { view: string } }) { - const openViewFromParams = props?.params?.view || ''; - - const { HighlighterService } = Services; + const { HighlighterService, IncrementalRolloutService } = Services; + const aiHighlighterEnabled = IncrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.aiHighlighter, + ); const v = useVuex(() => ({ - dismissedTutorial: HighlighterService.views.dismissedTutorial, - clips: HighlighterService.views.clips, + useAiHighlighter: HighlighterService.views.useAiHighlighter, + isUpdaterRunning: HighlighterService.views.isUpdaterRunning, + highlighterVersion: HighlighterService.views.highlighterVersion, + progress: HighlighterService.views.updaterProgress, + clipsAmount: HighlighterService.views.clips.length, + streamAmount: HighlighterService.views.highlightedStreams.length, })); - const [viewState, setViewState] = useState( - v.clips.length === 0 - ? { view: EHighlighterView.SETTINGS } - : { view: EHighlighterView.CLIPS, id: undefined }, + let initialViewState: IViewState; + + if (v.streamAmount > 0 && v.clipsAmount > 0 && aiHighlighterEnabled) { + initialViewState = { view: EHighlighterView.STREAM }; + } else if (v.clipsAmount > 0) { + initialViewState = { view: EHighlighterView.CLIPS, id: undefined }; + } else { + initialViewState = { view: EHighlighterView.SETTINGS }; + } + + const [viewState, setViewState] = useState(initialViewState); + const updaterModal = ( + ); switch (viewState.view) { + case EHighlighterView.STREAM: + return ( + <> + {aiHighlighterEnabled && updaterModal} + { + setViewFromEmit(data); + }} + /> + + ); case EHighlighterView.CLIPS: return ( <> + {aiHighlighterEnabled && updaterModal} { setViewFromEmit(data); @@ -40,6 +73,7 @@ export default function Highlighter(props: { params?: { view: string } }) { default: return ( <> + {aiHighlighterEnabled && updaterModal} { HighlighterService.actions.dismissTutorial(); diff --git a/app/components-react/pages/RecordingHistory.tsx b/app/components-react/pages/RecordingHistory.tsx index 88365c9cfd36..2549b0902dcf 100644 --- a/app/components-react/pages/RecordingHistory.tsx +++ b/app/components-react/pages/RecordingHistory.tsx @@ -13,8 +13,12 @@ import { Services } from '../service-provider'; import { initStore, useController } from '../hooks/zustand'; import { useVuex } from '../hooks'; import Translate from 'components-react/shared/Translate'; +import uuid from 'uuid/v4'; +import { EMenuItemKey } from 'services/side-nav'; import { $i } from 'services/utils'; import { IRecordingEntry } from 'services/recording-mode'; +import { EAiDetectionState, EHighlighterView } from 'services/highlighter'; +import { EAvailableFeatures } from 'services/incremental-rollout'; interface IRecordingHistoryStore { showSLIDModal: boolean; @@ -29,6 +33,9 @@ class RecordingHistoryController { private UserService = Services.UserService; private SharedStorageService = Services.SharedStorageService; private NotificationsService = Services.NotificationsService; + private HighlighterService = Services.HighlighterService; + private NavigationService = Services.NavigationService; + private IncrementalRolloutService = Services.IncrementalRolloutService; store = initStore({ showSLIDModal: false, showEditModal: false, @@ -51,8 +58,19 @@ class RecordingHistoryController { return this.RecordingModeService.state.uploadInfo; } + get aiDetectionInProgress() { + return this.HighlighterService.views.highlightedStreams.some( + stream => stream.state.type === EAiDetectionState.IN_PROGRESS, + ); + } + get uploadOptions() { const opts = [ + { + label: `${$t('Get highlights (Fortnite only))')}`, + value: 'highlighter', + icon: 'icon-highlighter', + }, { label: $t('Edit'), value: 'edit', @@ -113,6 +131,20 @@ class RecordingHistoryController { this.postError($t('Upload already in progress')); return; } + if (platform === 'highlighter') { + if (this.aiDetectionInProgress) return; + this.HighlighterService.actions.flow(recording.filename, { + game: 'forntnite', + id: 'rec_' + uuid(), + }); + this.NavigationService.actions.navigate( + 'Highlighter', + { view: EHighlighterView.STREAM }, + EMenuItemKey.Highlighter, + ); + return; + } + if (platform === 'youtube') return this.uploadToYoutube(recording.filename); if (platform === 'remove') return this.removeEntry(recording.timestamp); if (this.hasSLID) { @@ -168,8 +200,12 @@ export default function RecordingHistoryPage() { export function RecordingHistory() { const controller = useController(RecordingHistoryCtx); const { formattedTimestamp, showFile, handleSelect, postError } = controller; - const { uploadInfo, uploadOptions, recordings, hasSLID } = useVuex(() => ({ + const aiHighlighterEnabled = Services.IncrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.aiHighlighter, + ); + const { uploadInfo, uploadOptions, recordings, hasSLID, aiDetectionInProgress } = useVuex(() => ({ recordings: controller.recordings, + aiDetectionInProgress: controller.aiDetectionInProgress, uploadOptions: controller.uploadOptions, uploadInfo: controller.uploadInfo, hasSLID: controller.hasSLID, @@ -193,18 +229,32 @@ export function RecordingHistory() { function UploadActions(p: { recording: IRecordingEntry }) { return ( - {uploadOptions.map(opt => ( - handleSelect(p.recording, opt.value)} - > - -   - {opt.label} - - ))} + {uploadOptions + .map(option => { + if (option.value === 'highlighter' && !aiHighlighterEnabled) { + return null; + } + return ( + handleSelect(p.recording, option.value)} + > + +   + {option.label} + + ); + }) + .filter(Boolean)} ); } diff --git a/app/components-react/sidebar/FeaturesNav.tsx b/app/components-react/sidebar/FeaturesNav.tsx index 964efafd97ba..803ef1a87349 100644 --- a/app/components-react/sidebar/FeaturesNav.tsx +++ b/app/components-react/sidebar/FeaturesNav.tsx @@ -260,7 +260,15 @@ function FeaturesNavItem(p: { handleNavigation: (menuItem: IMenuItem, key?: string) => void; className?: string; }) { - const { SideNavService, TransitionsService, DualOutputService } = Services; + const { + SideNavService, + TransitionsService, + DualOutputService, + IncrementalRolloutService, + } = Services; + const aiHighlighterEnabled = IncrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.aiHighlighter, + ); const { isSubMenuItem, menuItem, handleNavigation, className } = p; const { currentMenuItem, isOpen, studioMode, dualOutputMode } = useVuex(() => ({ @@ -312,7 +320,14 @@ function FeaturesNavItem(p: { } }} > - {title} +
    + {title} + {menuItem.key === EMenuItemKey.Highlighter && aiHighlighterEnabled && ( +
    +

    beta

    +
    + )} +
    ); } diff --git a/app/components-react/sidebar/SideNav.m.less b/app/components-react/sidebar/SideNav.m.less index 3fa209dc7a92..ce81f89a9884 100644 --- a/app/components-react/sidebar/SideNav.m.less +++ b/app/components-react/sidebar/SideNav.m.less @@ -248,3 +248,11 @@ button.sidenav-button { padding: 4px 16px; } } + +.beta-tag { + border-radius: 4px; + background-color: var(--button); + color: var(--paragraph); + padding: 2px 6px; + margin-left: 8px; +} diff --git a/app/components-react/windows/go-live/AiHighlighterToggle.m.less b/app/components-react/windows/go-live/AiHighlighterToggle.m.less new file mode 100644 index 000000000000..6e2a61e538d3 --- /dev/null +++ b/app/components-react/windows/go-live/AiHighlighterToggle.m.less @@ -0,0 +1,58 @@ +@import '../../../styles/index'; + +.ai-highlighter-box { + background-color: #000; + display: flex; + flex-direction: column; + max-width: 66.666666667%; + justify-content: space-between; + width: 100%; + border: 2px solid var(--Day-Colors-Dark-Blue, #2b5bd7); + padding: 16px 20px 16px 20px; + align-items: center; + border-radius: 16px; +} + +.headline-wrapper { + cursor: pointer; + display: flex; + justify-content: space-between; + width: 100%; + align-items: center; +} + +.image { + width: 182px; + height: 187px; + display: grid; + place-content: center; + background-image: url(https://slobs-cdn.streamlabs.com/media/highlighter-image.png); + background-position: center; + background-size: contain; + background-repeat: no-repeat; +} +.toggle-text-wrapper { + display: flex; + flex-grow: 1; + flex-direction: column; + justify-content: space-between; +} +.beta-tag { + padding: 4px; + border-radius: 4px; + padding-top: 1px; + padding-bottom: 1px; + padding-left: 6px; + padding-right: 6px; + width: fit-content; + margin-bottom: 8px; + background-color: #2b5bd7; +} + +.expanded-wrapper { + display: flex; + justify-content: space-between; + gap: 16px; + margin-top: 16px; + width: 100%; +} diff --git a/app/components-react/windows/go-live/AiHighlighterToggle.tsx b/app/components-react/windows/go-live/AiHighlighterToggle.tsx new file mode 100644 index 000000000000..b763534cda5a --- /dev/null +++ b/app/components-react/windows/go-live/AiHighlighterToggle.tsx @@ -0,0 +1,101 @@ +import { SwitchInput } from 'components-react/shared/inputs/SwitchInput'; +import React, { useEffect, useRef, useState } from 'react'; +import styles from './AiHighlighterToggle.m.less'; + +import { Services } from 'components-react/service-provider'; +import Highlighter from 'components-react/pages/Highlighter'; +import { useVuex } from 'components-react/hooks'; +import { DownOutlined, UpOutlined } from '@ant-design/icons'; + +export default function AiHighlighterToggle({ + game, + cardIsExpanded, +}: { + game: string | undefined; + cardIsExpanded: boolean; +}) { + //TODO M: Probably good way to integrate the highlighter in to GoLiveSettings + const { HighlighterService } = Services; + const useHighlighter = useVuex(() => HighlighterService.views.useAiHighlighter); + + function getInitialExpandedState() { + if (game === 'Fortnite') { + return true; + } else { + if (useHighlighter) { + return true; + } else { + return cardIsExpanded; + } + } + } + const initialExpandedState = getInitialExpandedState(); + const [isExpanded, setIsExpanded] = useState(initialExpandedState); + + useEffect(() => { + if (game === 'Fortnite') { + setIsExpanded(true); + } + if (game !== 'Fortnite' && game !== undefined && useHighlighter) { + HighlighterService.actions.setAiHighlighter(false); + } + }, [game]); + + return ( +
    + {game === undefined || game === 'Fortnite' ? ( +
    +
    + +
    +
    setIsExpanded(!isExpanded)}> +

    + Streaming Fortnite? Try AI Highlighter! +

    + {isExpanded ? ( + + ) : ( + + )} +
    + {isExpanded ? ( + <> +
    +
    +
    +

    + Auto-create +
    highlights +

    +
    Beta
    +
    + HighlighterService.actions.toggleAiHighlighter()} + /> +
    +
    +
    + + ) : ( + <> + )} +
    +
    + ) : ( + <> + )} +
    + ); +} diff --git a/app/components-react/windows/go-live/CommonPlatformFields.tsx b/app/components-react/windows/go-live/CommonPlatformFields.tsx index aee5eac88d9b..d76d2d4f014a 100644 --- a/app/components-react/windows/go-live/CommonPlatformFields.tsx +++ b/app/components-react/windows/go-live/CommonPlatformFields.tsx @@ -7,6 +7,8 @@ import InputWrapper from '../../shared/inputs/InputWrapper'; import Animate from 'rc-animate'; import { TLayoutMode } from './platforms/PlatformSettingsLayout'; import { Services } from '../../service-provider'; +import AiHighlighterToggle from './AiHighlighterToggle'; +import { EAvailableFeatures } from 'services/incremental-rollout'; interface ICommonPlatformSettings { title: string; @@ -22,6 +24,7 @@ interface IProps { layoutMode?: TLayoutMode; value: ICommonPlatformSettings; descriptionIsRequired?: boolean; + enabledPlatforms?: TPlatform[]; onChange: (newValue: ICommonPlatformSettings) => unknown; } @@ -55,6 +58,9 @@ export const CommonPlatformFields = InputComponent((rawProps: IProps) => { } const view = Services.StreamingService.views; + const aiHighlighterEnabled = Services.IncrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.aiHighlighter, + ); const hasCustomCheckbox = p.layoutMode === 'multiplatformAdvanced'; const fieldsAreVisible = !hasCustomCheckbox || p.value.useCustomFields; const descriptionIsRequired = @@ -125,6 +131,10 @@ export const CommonPlatformFields = InputComponent((rawProps: IProps) => { required={descriptionIsRequired} /> )} + + {aiHighlighterEnabled && enabledPlatforms && !enabledPlatforms.includes('twitch') && ( + + )}
    )} diff --git a/app/components-react/windows/go-live/PlatformSettings.tsx b/app/components-react/windows/go-live/PlatformSettings.tsx index ee6da64d6ac5..aa0cefef76db 100644 --- a/app/components-react/windows/go-live/PlatformSettings.tsx +++ b/app/components-react/windows/go-live/PlatformSettings.tsx @@ -80,6 +80,7 @@ export default function PlatformSettings() { descriptionIsRequired={descriptionIsRequired} value={commonFields} onChange={updateCommonFields} + enabledPlatforms={enabledPlatforms} /> )} diff --git a/app/components-react/windows/go-live/platforms/TwitchEditStreamInfo.tsx b/app/components-react/windows/go-live/platforms/TwitchEditStreamInfo.tsx index ae2b94eca6b4..d3c5ddff9d26 100644 --- a/app/components-react/windows/go-live/platforms/TwitchEditStreamInfo.tsx +++ b/app/components-react/windows/go-live/platforms/TwitchEditStreamInfo.tsx @@ -12,10 +12,15 @@ import Message from '../../../shared/Message'; import { Row, Col, Select } from 'antd'; import { IListOption } from 'components/shared/inputs'; import TwitchContentClassificationInput from './TwitchContentClassificationInput'; +import AiHighlighterToggle from '../AiHighlighterToggle'; +import { Services } from 'components-react/service-provider'; +import { EAvailableFeatures } from 'services/incremental-rollout'; export function TwitchEditStreamInfo(p: IPlatformComponentParams<'twitch'>) { const twSettings = p.value; - + const aiHighlighterEnabled = Services.IncrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.aiHighlighter, + ); function updateSettings(patch: Partial) { p.onChange({ ...twSettings, ...patch }); } @@ -44,7 +49,14 @@ export function TwitchEditStreamInfo(p: IPlatformComponentParams<'twitch'>) { onChange={updateSettings} /> } - requiredFields={} + requiredFields={ + + + {aiHighlighterEnabled && ( + + )} + + } optionalFields={optionalFields} /> diff --git a/app/i18n/en-US/highlighter.json b/app/i18n/en-US/highlighter.json index 92bdb190184c..b9660aaac5ec 100644 --- a/app/i18n/en-US/highlighter.json +++ b/app/i18n/en-US/highlighter.json @@ -103,7 +103,7 @@ "Record your screen with Streamlabs Desktop. Once recording is complete, it will be displayed here. Access your files or edit further with Streamlabs tools.": "Record your screen with Streamlabs Desktop. Once recording is complete, it will be displayed here. Access your files or edit further with Streamlabs tools.", "Recordings": "Recordings", "Manual Highlighter": "Manual Highlighter", - "The hotkey highlighter allows you to clip the best moments during your livestream manually and edit them together afterwards.": "The hotkey highlighter allows you to clip the best moments during your livestream manually and edit them together afterwards.", + "Manually capture the best moments from your livestream with a hotkey command, and automatically turn them into a highlight video.": "Manually capture the best moments from your livestream with a hotkey command, and automatically turn them into a highlight video.", "End your stream to change the Hotkey or the replay duration.": "End your stream to change the Hotkey or the replay duration.", "No clips found": "No clips found", "All highlight clips": "All highlight clips", @@ -111,5 +111,34 @@ "Trim": "Trim", "Intro": "Intro", "Outro": "Outro", - "All clips": "All clips" + "All Clips": "All Clips", + "Export highlight reel": "Export highlight reel", + "Restart": "Restart", + "Add Clips": "Add Clips", + "Edit Clips": "Edit Clips", + "Searching for highlights...": "Searching for highlights...", + "Not enough highlights found": "Not enough highlights found", + "Highlights cancelled": "Highlights cancelled", + "Highlights failed": "Highlights failed", + "Import Fortnite stream": "Import Fortnite stream", + "Select video to start import": "Select video to start import", + "Select your Fornite recording": "Select your Fornite recording", + "Settings": "Settings", + "Delete highlighted stream?": "Delete highlighted stream?", + "Are you sure you want to delete this stream and all its associated clips? This action cannot be undone.": "Are you sure you want to delete this stream and all its associated clips? This action cannot be undone.", + "Set a title for your stream": "Set a title for your stream", + "Create highlight video of": "Create highlight video of", + "All rounds": "All rounds", + "unlimited": "unlimited", + "%{duration} minutes": "%{duration} minutes", + "%{duration} minute": "%{duration} minute", + "with a duration of": "with a duration of", + "AI Highlighter": "AI Highlighter", + "For Fortnite streams (Beta)": "For Fortnite streams (Beta)", + "Automatically capture the best moments from your livestream and turn them into a highlight video.": "Automatically capture the best moments from your livestream and turn them into a highlight video.", + "Recommended": "Recommended", + "Stream Highlights": "Stream Highlights", + "Export Vertical": "Export Vertical", + "Export Horizontal": "Export Horizontal", + "Get highlights (Fortnite only))": "Get highlights (Fortnite only))" } \ No newline at end of file diff --git a/app/services/highlighter/ai-highlighter/ai-highlighter.ts b/app/services/highlighter/ai-highlighter/ai-highlighter.ts index 6164dc16874b..467fb919c3ff 100644 --- a/app/services/highlighter/ai-highlighter/ai-highlighter.ts +++ b/app/services/highlighter/ai-highlighter/ai-highlighter.ts @@ -1,6 +1,9 @@ import * as child from 'child_process'; import EventEmitter from 'events'; +import { AiHighlighterUpdater } from './updater'; import { duration } from 'moment'; +import { ICoordinates } from '..'; +import kill from 'tree-kill'; export enum EHighlighterInputTypes { KILL = 'kill', @@ -16,7 +19,6 @@ export enum EHighlighterInputTypes { LOW_HEALTH = 'low_health', PLAYER_KNOCKED = 'player_knocked', } - export type DeathMetadata = { place: number; }; @@ -33,7 +35,7 @@ export interface IHighlight { input_types: EHighlighterInputTypes[]; inputs: IHighlighterInput[]; score: number; - metadata: { round: number }; + metadata: { round: number; webcam_coordinates: ICoordinates }; } export type EHighlighterMessageTypes = @@ -41,9 +43,225 @@ export type EHighlighterMessageTypes = | 'inputs' | 'inputs_partial' | 'highlights' - | 'highlights_partial'; + | 'milestone'; export interface IHighlighterMessage { type: EHighlighterMessageTypes; json: {}; } +interface IHighlighterProgressMessage { + progress: number; +} + +export interface IHighlighterMilestone { + name: string; + weight: number; + data: IHighlighterMessage[] | null; +} + +const START_TOKEN = '>>>>'; +const END_TOKEN = '<<<<'; + +// Buffer management class to handle split messages +class MessageBufferHandler { + private buffer: string = ''; + + hasCompleteMessage(): boolean { + const hasStart = this.buffer.includes(START_TOKEN); + const hasEnd = this.buffer.includes(END_TOKEN); + return hasStart && hasEnd; + } + + isMessageComplete(message: string): boolean { + const combined = this.buffer + message; + const hasStart = combined.includes(START_TOKEN); + const hasEnd = combined.includes(END_TOKEN); + return hasStart && hasEnd; + } + + appendToBuffer(message: string) { + this.buffer += message; + } + + extractCompleteMessages(): string[] { + const messages = []; + while (this.hasCompleteMessage()) { + const start = this.buffer.indexOf(START_TOKEN); + const end = this.buffer.indexOf(END_TOKEN); + + if (start !== -1 && end !== -1 && start < end) { + const completeMessage = this.buffer.substring(start, end + END_TOKEN.length); + // Clear the buffer of the extracted message + this.buffer = this.buffer.substring(end + END_TOKEN.length); + messages.push(completeMessage); + } else { + // Message not complete + } + } + return messages; + } + + clear() { + this.buffer = ''; + } +} + +export function getHighlightClips( + videoUri: string, + renderHighlights: (highlightClips: IHighlight[]) => void, + cancelSignal: AbortSignal, + progressUpdate?: (progress: number) => void, + milestonesPath?: string, + milestoneUpdate?: (milestone: IHighlighterMilestone) => void, +): Promise { + return new Promise((resolve, reject) => { + console.log(`Get highlight clips for ${videoUri}`); + + const partialInputsRendered = false; + console.log('Start Ai analysis'); + + const childProcess: child.ChildProcess = AiHighlighterUpdater.startHighlighterProcess( + videoUri, + milestonesPath, + ); + const messageBuffer = new MessageBufferHandler(); + + if (cancelSignal) { + cancelSignal.addEventListener('abort', () => { + console.log('ending highlighter process'); + messageBuffer.clear(); + kill(childProcess.pid!, 'SIGINT'); + reject(new Error('Highlight generation canceled')); + }); + } + + childProcess.stdout?.on('data', (data: Buffer) => { + const message = data.toString(); + messageBuffer.appendToBuffer(message); + + // Try to extract a complete message + const completeMessages = messageBuffer.extractCompleteMessages(); + + for (const completeMessage of completeMessages) { + // messageBuffer.clear(); + const aiHighlighterMessage = parseAiHighlighterMessage(completeMessage); + if (typeof aiHighlighterMessage === 'string' || aiHighlighterMessage instanceof String) { + console.log('message type of string', aiHighlighterMessage); + } else if (aiHighlighterMessage) { + switch (aiHighlighterMessage.type) { + case 'progress': + progressUpdate?.((aiHighlighterMessage.json as IHighlighterProgressMessage).progress); + break; + case 'highlights': + if (!partialInputsRendered) { + console.log('call Render highlights:'); + renderHighlights?.(aiHighlighterMessage.json as IHighlight[]); + } + resolve(aiHighlighterMessage.json as IHighlight[]); + break; + case 'milestone': + milestoneUpdate?.(aiHighlighterMessage.json as IHighlighterMilestone); + break; + default: + // console.log('\n\n'); + // console.log('Unrecognized message type:', aiHighlighterMessage); + // console.log('\n\n'); + break; + } + } + } + }); + + childProcess.stderr?.on('data', (data: Buffer) => { + console.log('Debug logs:', data.toString()); + }); + + childProcess.on('error', error => { + messageBuffer.clear(); + reject(new Error(`Child process threw an error. Error message: ${error.message}.`)); + }); + + childProcess.on('exit', (code, signal) => { + messageBuffer.clear(); + reject(new Error(`Child process exited with code ${code} and signal ${signal}`)); + }); + }); +} + +function parseAiHighlighterMessage(messageString: string): IHighlighterMessage | string | null { + try { + if (messageString.includes(START_TOKEN) && messageString.includes(END_TOKEN)) { + const start = messageString.indexOf(START_TOKEN); + const end = messageString.indexOf(END_TOKEN); + const jsonString = messageString.substring(start, end).replace(START_TOKEN, ''); + // console.log('Json string:', jsonString); + + const aiHighlighterMessage = JSON.parse(jsonString) as IHighlighterMessage; + // console.log('Parsed ai highlighter message:', aiHighlighterMessage); + return aiHighlighterMessage; + } else { + return messageString; + } + } catch (error: unknown) { + console.log('Error parsing ai highlighter message:', error); + return null; + } +} + +export class ProgressTracker { + PRE_DURATION = 10; + POST_DURATION = 10; + progress = 0; + + onChangeCallback: (progress: number) => void; + + preInterval: NodeJS.Timeout; + postInterval: NodeJS.Timeout; + postStarted = false; + constructor(onChange = (progress: number) => {}) { + this.startPreTimer(); + this.onChangeCallback = onChange; + } + + startPreTimer() { + this.progress = 0; + this.preInterval = this.addOnePerSecond(this.PRE_DURATION); + } + + startPostTimer() { + if (!this.postStarted) { + this.postInterval = this.addOnePerSecond(this.POST_DURATION); + this.postStarted = true; + } + } + destroy() { + this.preInterval && clearInterval(this.preInterval); + this.postInterval && clearInterval(this.postInterval); + } + + updateProgressFromHighlighter(highlighterProgress: number) { + this.preInterval && clearInterval(this.preInterval); + const adjustedProgress = + highlighterProgress * ((100 - this.PRE_DURATION - this.POST_DURATION) / 100) + + this.PRE_DURATION; + + this.progress = adjustedProgress; + this.onChangeCallback(this.progress); + if (highlighterProgress === 100) { + this.startPostTimer(); + } + } + + addOnePerSecond(duration: number) { + let passedSeconds = 0; + const interval = setInterval(() => { + passedSeconds += 1; + this.progress += 1; + this.onChangeCallback(this.progress); + if (passedSeconds >= duration) { + clearInterval(interval); + } + }, 1000); + return interval; + } +} diff --git a/app/services/highlighter/ai-highlighter/updater.ts b/app/services/highlighter/ai-highlighter/updater.ts new file mode 100644 index 000000000000..f69fe0b2a217 --- /dev/null +++ b/app/services/highlighter/ai-highlighter/updater.ts @@ -0,0 +1,239 @@ +import { promises as fs, createReadStream, existsSync } from 'fs'; +import path from 'path'; +import { getSharedResource } from 'util/get-shared-resource'; +import { downloadFile, IDownloadProgress, jfetch } from 'util/requests'; +import crypto from 'crypto'; +import { pipeline } from 'stream/promises'; +import { importExtractZip } from 'util/slow-imports'; +import { spawn } from 'child_process'; +import { FFMPEG_EXE } from '../constants'; +import Utils from '../../utils'; + +interface IAIHighlighterManifest { + version: string; + platform: string; + url: string; + size: number; + checksum: string; + timestamp: number; +} + +/** + * Checks for updates to the AI Highlighter and updates the local installation + * if necessary. + * + * Responsible for storing the manifest and updating the highlighter binary, and maintains + * the paths to the highlighter binary and manifest. + */ +export class AiHighlighterUpdater { + private basepath: string; + private manifestPath: string; + private manifest: IAIHighlighterManifest | null; + private isCurrentlyUpdating: boolean = false; + private versionChecked: boolean = false; + + public currentUpdate: Promise | null = null; + + constructor() { + this.basepath = getSharedResource('ai-highlighter'); + this.manifestPath = path.resolve(this.basepath, 'manifest.json'); + } + + /** + * Spawn the AI Highlighter process that would process the video + */ + static startHighlighterProcess(videoUri: string, milestonesPath?: string) { + const isDev = Utils.isDevMode(); + if (isDev) { + const rootPath = '../highlighter-api/'; + const command = [ + 'run', + 'python', + `${rootPath}/highlighter_api/cli.py`, + videoUri, + '--ffmpeg_path', + FFMPEG_EXE, + '--loglevel', + 'debug', + ]; + + if (milestonesPath) { + command.push('--milestones_file'); + command.push(milestonesPath); + } + + return spawn('poetry', command, { + cwd: rootPath, + }); + } + + const highlighterBinaryPath = path.resolve( + getSharedResource('ai-highlighter'), + 'bin', + 'app.exe', + ); + + const command = [videoUri, '--ffmpeg_path', FFMPEG_EXE]; + if (milestonesPath) { + command.push('--milestones_file'); + command.push(milestonesPath); + } + + return spawn(highlighterBinaryPath, command); + } + + /** + * Check if an update is currently in progress + */ + public get updateInProgress(): boolean { + return this.isCurrentlyUpdating; + } + + /** + * Get version that is about to be installed + */ + public get version(): string | null { + return this.manifest?.version || null; + } + + /** + * Check if AI Highlighter requires an update + */ + public async isNewVersionAvailable(): Promise { + // check if updater checked version in current session already + if (this.versionChecked) { + return false; + } + + this.versionChecked = true; + console.log('checking for highlighter updates...'); + // fetch the latest version of the manifest for win x86_64 target + const newManifest = await jfetch( + new Request('https://cdn-highlighter-builds.streamlabs.com/manifest_win_x86_64.json'), + ); + this.manifest = newManifest; + + // if manifest.json does not exist, an initial download is required + if (!existsSync(this.manifestPath)) { + console.log('manifest.json not found, initial download required'); + return true; + } + + // read the current manifest + const currentManifest = JSON.parse( + await fs.readFile(this.manifestPath, 'utf-8'), + ) as IAIHighlighterManifest; + + if (newManifest.version !== currentManifest.version) { + console.log( + `new highlighter version available. ${currentManifest.version} -> ${newManifest.version}`, + ); + return true; + } + + console.log('highlighter is up to date'); + return false; + } + + /** + * Update highlighter to the latest version + */ + public async update(progressCallback?: (progress: IDownloadProgress) => void): Promise { + // if (Utils.isDevMode()) { + // console.log('skipping update in dev mode'); + // return; + // } + try { + this.isCurrentlyUpdating = true; + this.currentUpdate = this.performUpdate(progressCallback); + await this.currentUpdate; + } finally { + this.isCurrentlyUpdating = false; + } + } + + private async performUpdate(progressCallback: (progress: IDownloadProgress) => void) { + if (!this.manifest) { + throw new Error('Manifest not found, cannot update'); + } + + if (!existsSync(this.basepath)) { + await fs.mkdir(this.basepath); + } + + const zipPath = path.resolve(this.basepath, 'ai-highlighter.zip'); + console.log('downloading new version of AI Highlighter...'); + + // in case if some leftover zip file exists for incomplete update + if (existsSync(zipPath)) { + await fs.rm(zipPath); + } + + // download the new version + await downloadFile(this.manifest.url, zipPath, progressCallback); + console.log('download complete'); + + // verify the checksum + const checksum = await this.sha256(zipPath); + if (checksum !== this.manifest.checksum) { + throw new Error('Checksum verification failed'); + } + + console.log('unzipping archive...'); + const unzipPath = path.resolve(this.basepath, 'bin-' + this.manifest.version); + // delete leftover unzipped files in case something happened before + if (existsSync(unzipPath)) { + await fs.rm(unzipPath, { recursive: true }); + } + + // unzip archive and delete the zip after + await this.unzip(zipPath, unzipPath); + await fs.rm(zipPath); + console.log('unzip complete'); + + // swap with the new version + const binPath = path.resolve(this.basepath, 'bin'); + const outdateVersionPresent = existsSync(binPath); + + // backup the ouotdated version in case something goes bad + if (outdateVersionPresent) { + console.log('backing up outdated version...'); + await fs.rename(binPath, path.resolve(this.basepath, 'bin.bkp')); + } + console.log('swapping new version...'); + await fs.rename(unzipPath, binPath); + + // cleanup + console.log('cleaning up...'); + if (outdateVersionPresent) { + await fs.rm(path.resolve(this.basepath, 'bin.bkp'), { recursive: true }); + } + + console.log('updating manifest...'); + await fs.writeFile(this.manifestPath, JSON.stringify(this.manifest)); + console.log('update complete'); + } + + private async sha256(file: string): Promise { + const hash = crypto.createHash('sha256'); + const stream = createReadStream(file); + + await pipeline(stream, hash); + + return hash.digest('hex'); + } + + private async unzip(zipPath: string, unzipPath: string): Promise { + // extract the new version + const extractZip = (await importExtractZip()).default; + return new Promise((resolve, reject) => { + extractZip(zipPath, { dir: unzipPath }, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } +} diff --git a/app/services/highlighter/audio-crossfader.ts b/app/services/highlighter/audio-crossfader.ts index 5943b8689e21..e9fb705a67be 100644 --- a/app/services/highlighter/audio-crossfader.ts +++ b/app/services/highlighter/audio-crossfader.ts @@ -1,13 +1,13 @@ import execa from 'execa'; import fs from 'fs'; import { FFMPEG_EXE } from './constants'; -import { Clip } from './clip'; +import { RenderingClip } from './clip'; import { AudioMixError } from './errors'; export class AudioCrossfader { constructor( public readonly outputPath: string, - public readonly clips: Clip[], + public readonly clips: RenderingClip[], public readonly transitionDuration: number, ) {} diff --git a/app/services/highlighter/clip.ts b/app/services/highlighter/clip.ts index b419e02c7739..abc77a7f428b 100644 --- a/app/services/highlighter/clip.ts +++ b/app/services/highlighter/clip.ts @@ -6,7 +6,7 @@ import fs from 'fs'; import { IExportOptions } from '.'; import path from 'path'; -export class Clip { +export class RenderingClip { frameSource: FrameSource; audioSource: AudioSource; diff --git a/app/services/highlighter/frame-writer.ts b/app/services/highlighter/frame-writer.ts index f540c4fc3292..0f62b2da13e3 100644 --- a/app/services/highlighter/frame-writer.ts +++ b/app/services/highlighter/frame-writer.ts @@ -19,39 +19,65 @@ export class FrameWriter { /* eslint-disable */ const args = [ // Video Input - '-f', 'rawvideo', - '-vcodec', 'rawvideo', - '-pix_fmt', 'rgba', - '-s', `${this.options.width}x${this.options.height}`, - '-r', `${this.options.fps}`, - '-i', '-', + '-f', + 'rawvideo', + '-vcodec', + 'rawvideo', + '-pix_fmt', + 'rgba', + '-s', + `${this.options.width}x${this.options.height}`, + '-r', + `${this.options.fps}`, + '-i', + '-', // Audio Input - '-i', this.audioInput, + '-i', + this.audioInput, // Input Mapping - '-map', '0:v:0', - '-map', '1:a:0', + '-map', + '0:v:0', + '-map', + '1:a:0', // Filters - '-af', `afade=type=out:duration=${FADE_OUT_DURATION}:start_time=${Math.max(this.duration - (FADE_OUT_DURATION + 0.2), 0)}`, - '-vf', `format=yuv420p,fade=type=out:duration=${FADE_OUT_DURATION}:start_time=${Math.max(this.duration - (FADE_OUT_DURATION + 0.2), 0)}`, - - // Video Output - '-vcodec', 'libx264', - '-profile:v', 'high', - '-preset:v', this.options.preset, - '-crf', '18', - '-movflags', 'faststart', + '-af', + `afade=type=out:duration=${FADE_OUT_DURATION}:start_time=${Math.max( + this.duration - (FADE_OUT_DURATION + 0.2), + 0, + )}`, + ]; - // Audio Output - '-acodec', 'aac', - '-b:a', '128k', + this.addVideoFilters(args); + + args.push( + ...[ + // Video Output + '-vcodec', + 'libx264', + '-profile:v', + 'high', + '-preset:v', + this.options.preset, + '-crf', + '18', + '-movflags', + 'faststart', + + // Audio Output + '-acodec', + 'aac', + '-b:a', + '128k', + + '-y', + this.outputPath, + ], + ); - '-y', this.outputPath, - ]; /* eslint-enable */ - this.ffmpeg = execa(FFMPEG_EXE, args, { encoding: null, buffer: false, @@ -76,6 +102,18 @@ export class FrameWriter { }); } + private addVideoFilters(args: string[]) { + const fadeFilter = `format=yuv420p,fade=type=out:duration=${FADE_OUT_DURATION}:start_time=${Math.max( + this.duration - (FADE_OUT_DURATION + 0.2), + 0, + )}`; + if (this.options.complexFilter) { + args.push('-vf', this.options.complexFilter + `[final]${fadeFilter}`); + } else { + args.push('-vf', fadeFilter); + } + } + async writeNextFrame(frameBuffer: Buffer) { if (!this.ffmpeg) this.startFfmpeg(); diff --git a/app/services/highlighter/index.ts b/app/services/highlighter/index.ts index c992a37e6b18..0856e5854232 100644 --- a/app/services/highlighter/index.ts +++ b/app/services/highlighter/index.ts @@ -20,9 +20,16 @@ import { } from 'services/platforms/youtube/uploader'; import { YoutubeService } from 'services/platforms/youtube'; import os from 'os'; -import { CLIP_DIR, SCRUB_SPRITE_DIRECTORY, SUPPORTED_FILE_TYPES, TEST_MODE } from './constants'; +import { + CLIP_DIR, + FFMPEG_EXE, + SCRUB_SPRITE_DIRECTORY, + SUPPORTED_FILE_TYPES, + TEST_MODE, + FFPROBE_EXE, +} from './constants'; import { pmap } from 'util/pmap'; -import { Clip } from './clip'; +import { RenderingClip } from './clip'; import { AudioCrossfader } from './audio-crossfader'; import { FrameWriter } from './frame-writer'; import { Transitioner } from './transitioner'; @@ -38,7 +45,23 @@ import { ENotificationType, NotificationsService } from 'services/notifications' import { JsonrpcService } from 'services/api/jsonrpc'; import { NavigationService } from 'services/navigation'; import { SharedStorageService } from 'services/integrations/shared-storage'; -import { EHighlighterInputTypes } from './ai-highlighter/ai-highlighter'; +import execa from 'execa'; +import moment from 'moment'; +import { + EHighlighterInputTypes, + getHighlightClips, + IHighlight, + IHighlighterInput, + IHighlighterMilestone, + ProgressTracker, +} from './ai-highlighter/ai-highlighter'; +import uuid from 'uuid'; +import { EMenuItemKey } from 'services/side-nav'; +import { AiHighlighterUpdater } from './ai-highlighter/updater'; +import { IDownloadProgress } from 'util/requests'; +import { IncrementalRolloutService } from 'app-services'; +import { EAvailableFeatures } from 'services/incremental-rollout'; +import { getSharedResource } from 'util/get-shared-resource'; export type TStreamInfo = | { orderPosition: number; @@ -49,6 +72,15 @@ export type TStreamInfo = const isAiClip = (clip: TClip): clip is IAiClip => clip.source === 'AiClip'; +// types for highlighter video operations +export type TOrientation = 'horizontal' | 'vertical'; +export interface ICoordinates { + x1: number; + y1: number; + x2: number; + y2: number; +} + interface IBaseClip { path: string; loaded: boolean; @@ -93,13 +125,17 @@ export interface IInput { export interface IAiClipInfo { inputs: IInput[]; score: number; - metadata: { round: number }; + metadata: { + round: number; + webcam_coordinates: ICoordinates; + }; } export type TClip = IAiClip | IReplayBufferClip | IManualClip; export enum EHighlighterView { CLIPS = 'clips', + STREAM = 'stream', SETTINGS = 'settings', } @@ -107,18 +143,27 @@ interface TClipsViewState { view: EHighlighterView.CLIPS; id: string | undefined; } +interface IStreamViewState { + view: EHighlighterView.STREAM; +} interface ISettingsViewState { view: EHighlighterView.SETTINGS; } -export type IViewState = TClipsViewState | ISettingsViewState; +export type IViewState = TClipsViewState | IStreamViewState | ISettingsViewState; + +export interface StreamMilestones { + streamId: string; + milestones: IHighlighterMilestone[]; +} // TODO: Need to clean up all of this export interface StreamInfoForAiHighlighter { id: string; game: string; title?: string; + milestonesPath?: string; } export interface INewClipData { @@ -129,18 +174,22 @@ export interface INewClipData { startTrim: number; endTrim: number; } + +export enum EAiDetectionState { + INITIALIZED = 'initialized', + IN_PROGRESS = 'detection-in-progress', + ERROR = 'error', + FINISHED = 'detection-finished', + CANCELED_BY_USER = 'detection-canceled-by-user', +} + export interface IHighlightedStream { id: string; game: string; title: string; date: string; state: { - type: - | 'initialized' - | 'detection-in-progress' - | 'error' - | 'detection-finished' - | 'detection-canceled-by-user'; + type: EAiDetectionState; progress: number; }; abortController?: AbortController; @@ -212,7 +261,7 @@ export interface IVideoInfo { outro: IOutroInfo; } -interface IHighligherState { +interface IHighlighterState { clips: Dictionary; transition: ITransitionInfo; video: IVideoInfo; @@ -221,7 +270,11 @@ interface IHighligherState { upload: IUploadInfo; dismissedTutorial: boolean; error: string; + useAiHighlighter: boolean; highlightedStreams: IHighlightedStream[]; + updaterProgress: number; + isUpdaterRunning: boolean; + highlighterVersion: string; } // Capitalization is not consistent because it matches with the @@ -338,9 +391,10 @@ export interface IExportOptions { width: number; height: number; preset: TPreset; + complexFilter?: string; } -class HighligherViews extends ViewHandler { +class HighlighterViews extends ViewHandler { /** * Returns an array of clips */ @@ -351,6 +405,16 @@ class HighligherViews extends ViewHandler { return this.state.clips; } + /** + * Returns wether or not the AiHighlighter should be used + */ + get useAiHighlighter() { + return this.state.useAiHighlighter; + } + + /** + * Returns wether or not the AiHighlighter should be used + */ get highlightedStreams() { return this.state.highlightedStreams; } @@ -408,6 +472,18 @@ class HighligherViews extends ViewHandler { return this.state.error; } + get highlighterVersion() { + return this.state.highlighterVersion; + } + + get isUpdaterRunning() { + return this.state.isUpdaterRunning; + } + + get updaterProgress() { + return this.state.updaterProgress; + } + /** * Takes a filepath to a video and returns a file:// url with a random * component to prevent the browser from caching it and missing changes. @@ -419,8 +495,18 @@ class HighligherViews extends ViewHandler { } @InitAfter('StreamingService') -export class HighlighterService extends PersistentStatefulService { - static defaultState: IHighligherState = { +export class HighlighterService extends PersistentStatefulService { + @Inject() streamingService: StreamingService; + @Inject() userService: UserService; + @Inject() usageStatisticsService: UsageStatisticsService; + @Inject() dismissablesService: DismissablesService; + @Inject() notificationsService: NotificationsService; + @Inject() jsonrpcService: JsonrpcService; + @Inject() navigationService: NavigationService; + @Inject() sharedStorageService: SharedStorageService; + @Inject() incrementalRolloutService: IncrementalRolloutService; + + static defaultState: IHighlighterState = { clips: {}, transition: { type: 'fade', @@ -459,10 +545,18 @@ export class HighlighterService extends PersistentStatefulService = {}; + renderingClips: Dictionary = {}; directoryCleared = false; @@ -576,12 +663,78 @@ export class HighlighterService extends PersistentStatefulService stream.id !== updatedStreamInfo.id, + ); + this.state.highlightedStreams = [...keepAsIs, updatedStreamInfo]; + } + + @mutation() + REMOVE_HIGHLIGHTED_STREAM(id: string) { + this.state.highlightedStreams = this.state.highlightedStreams.filter( + stream => stream.id !== id, + ); + } + + @mutation() + SET_UPDATER_PROGRESS(progress: number) { + this.state.updaterProgress = progress; + } + + @mutation() + SET_UPDATER_STATE(isRunning: boolean) { + this.state.isUpdaterRunning = isRunning; + } + + @mutation() + SET_HIGHLIGHTER_VERSION(version: string) { + this.state.highlighterVersion = version; + } + get views() { - return new HighligherViews(this.state); + return new HighlighterViews(this.state); } async init() { super.init(); + this.aiHighlighterEnabled = this.incrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.aiHighlighter, + ); + + if (this.aiHighlighterEnabled && !this.aiHighlighterUpdater) { + this.aiHighlighterUpdater = new AiHighlighterUpdater(); + } + + // check if ai highlighter is activated and we need to update it + if ( + this.aiHighlighterEnabled && + this.views.useAiHighlighter && + (await this.aiHighlighterUpdater.isNewVersionAvailable()) + ) { + await this.startUpdater(); + } + + // + this.views.clips.forEach(clip => { + if (isAiClip(clip) && (clip.aiInfo as any).moments) { + clip.aiInfo.inputs = (clip.aiInfo as any).moments; + delete (clip.aiInfo as any).moments; + } + }); //Check if files are existent, if not, delete this.views.clips.forEach(c => { @@ -598,6 +751,16 @@ export class HighlighterService extends PersistentStatefulService stream.state.type === 'detection-in-progress') + .forEach(stream => { + this.UPDATE_HIGHLIGHTED_STREAM({ + ...stream, + state: { type: EAiDetectionState.CANCELED_BY_USER, progress: 0 }, + }); + }); + this.views.clips.forEach(c => { this.UPDATE_CLIP({ path: c.path, @@ -617,50 +780,54 @@ export class HighlighterService extends PersistentStatefulService { - this.addClips([{ path: clipPath }], undefined, 'ReplayBuffer'); + const streamId = streamInfo?.id || undefined; + let endTime: number | undefined; + + if (streamId) { + endTime = moment().diff(aiRecordingStartTime, 'seconds'); + } else { + endTime = undefined; + } + + const REPLAY_BUFFER_DURATION = 20; // TODO M: Replace with settingsservice + const startTime = Math.max(0, endTime ? endTime - REPLAY_BUFFER_DURATION : 0); + + this.addClips([{ path: clipPath, startTime, endTime }], streamId, 'ReplayBuffer'); }); this.streamingService.streamingStatusChange.subscribe(async status => { if (status === EStreamingState.Live) { - streamStarted = true; + streamStarted = true; // console.log('live', this.streamingService.views.settings.platforms.twitch.title); + + if (this.views.useAiHighlighter === false) { + console.log('HighlighterService: Game:', this.streamingService.views.game); + // console.log('Highlighter not enabled or not Fortnite'); + return; + } + + // console.log('recording Alreadyt running?:', this.streamingService.views.isRecording); + + if (this.streamingService.views.isRecording) { + // console.log('Recording is already running'); + } else { + this.streamingService.toggleRecording(); + } + streamInfo = { + id: 'fromStreamRecording' + uuid(), + title: this.streamingService.views.settings.platforms.twitch?.title, + game: this.streamingService.views.game, + }; + aiRecordingInProgress = true; + aiRecordingStartTime = moment(); } if (status === EStreamingState.Offline) { @@ -681,13 +848,40 @@ export class HighlighterService extends PersistentStatefulService { + if (!aiRecordingInProgress) { + return; + } + + aiRecordingInProgress = false; + this.flow(path, streamInfo); + + this.navigationService.actions.navigate( + 'Highlighter', + { view: 'stream' }, + EMenuItemKey.Highlighter, + ); }); } } @@ -695,9 +889,12 @@ export class HighlighterService extends PersistentStatefulService { - const getHighestGlobalOrderPosition = this.getClips(this.views.clips, undefined).length; + const currentClips = this.getClips(this.views.clips, streamId); + const allClips = this.getClips(this.views.clips, undefined); + const getHighestGlobalOrderPosition = allClips.length; + + let newStreamInfo: { [key: string]: TStreamInfo } = {}; + if (source === 'Manual') { + if (streamId) { + currentClips.forEach(clip => { + if (clip?.streamInfo?.[streamId] === undefined) { + return; + } + + const updatedStreamInfo = { + ...clip.streamInfo, + [streamId]: { + ...clip.streamInfo[streamId], + orderPosition: clip.streamInfo[streamId]!.orderPosition + 1, + }, + }; + // update streaminfo position + this.UPDATE_CLIP({ + path: clip.path, + streamInfo: updatedStreamInfo, + }); + }); + + // Update globalOrderPosition of all other items as well + allClips.forEach(clip => { + this.UPDATE_CLIP({ + path: clip.path, + globalOrderPosition: clip.globalOrderPosition + 1, + }); + }); + + newStreamInfo = { + [streamId]: { + orderPosition: 0 + index, + }, + }; + } else { + // If no streamId currentCLips = allClips + currentClips.forEach(clip => { + this.UPDATE_CLIP({ + path: clip.path, + globalOrderPosition: clip.globalOrderPosition + 1, + }); + }); + } + } else { + if (streamId) { + newStreamInfo = { + [streamId]: { + orderPosition: index + currentClips.length + 1, + initialStartTime: clipData.startTime, + initialEndTime: clipData.endTime, + }, + }; + } + } if (this.state.clips[clipData.path]) { - // Clip exists already + //Add new newStreamInfo, wont be added if no streamId is available + const updatedStreamInfo = { + ...this.state.clips[clipData.path].streamInfo, + ...newStreamInfo, + }; + + this.UPDATE_CLIP({ + path: clipData.path, + streamInfo: updatedStreamInfo, + }); return; } else { this.ADD_CLIP({ @@ -722,14 +984,80 @@ export class HighlighterService extends PersistentStatefulService { + // Don't allow adding the same clip twice for ai clips + if (this.state.clips[clip.path]) return; + + const streamInfo: { [key: string]: TStreamInfo } = { + [newStreamInfo.id]: { + // Orderposition will get overwritten by sortStreamClipsByStartTime after creation + orderPosition: + index + currentHighestOrderPosition + (currentHighestOrderPosition === 0 ? 0 : 1), + initialStartTime: clip.startTime, + initialEndTime: clip.endTime, + }, + }; + + this.ADD_CLIP({ + path: clip.path, + loaded: false, + enabled: true, + startTrim: clip.startTrim, + endTrim: clip.endTrim, + deleted: false, + source: 'AiClip', + aiInfo: clip.aiClipInfo, + globalOrderPosition: + index + getHighestGlobalOrderPosition + (getHighestGlobalOrderPosition === 0 ? 0 : 1), + streamInfo, + }); + }); + this.sortStreamClipsByStartTime(this.views.clips, newStreamInfo); + await this.loadClips(newStreamInfo.id); + } + + // This sorts all clips (replayBuffer and aiClips) by initialStartTime + // That will assure that replayBuffer clips are also sorted in correctly in the stream + sortStreamClipsByStartTime(clips: TClip[], newStreamInfo: StreamInfoForAiHighlighter) { + const allClips = this.getClips(clips, newStreamInfo.id); + + const sortedClips = allClips.sort( + (a, b) => + (a.streamInfo?.[newStreamInfo.id]?.initialStartTime || 0) - + (b.streamInfo?.[newStreamInfo.id]?.initialStartTime || 0), + ); + + // Update order positions based on the sorted order + sortedClips.forEach((clip, index) => { + this.UPDATE_CLIP({ + path: clip.path, + streamInfo: { + [newStreamInfo.id]: { + ...(clip.streamInfo?.[newStreamInfo.id] ?? {}), + orderPosition: index, + }, + }, + }); + }); + return; + } + enableClip(path: string, enabled: boolean) { this.UPDATE_CLIP({ path, @@ -763,9 +1091,46 @@ export class HighlighterService extends PersistentStatefulService 1 + ) { + const updatedStreamInfo = { ...clip.streamInfo }; + delete updatedStreamInfo[streamId]; + + this.UPDATE_CLIP({ + path: clip.path, + streamInfo: updatedStreamInfo, + }); + } else { + this.REMOVE_CLIP(path); + this.removeScrubFile(clip.scrubSprite); + delete this.renderingClips[path]; + } + + if (clip.streamInfo !== undefined || streamId !== undefined) { + // if we are passing a streamId, only check if we need to remove the specific streamIds stream + // If we are not passing a streamId, check if we need to remove the streams the clip was part of + const ids: string[] = streamId ? [streamId] : Object.keys(clip.streamInfo ?? {}); + const length = this.views.clips.length; + + ids.forEach(id => { + let found = false; + if (length !== 0) { + for (let i = 0; i < length; i++) { + if (this.views.clips[i].streamInfo?.[id] !== undefined) { + found = true; + break; + } + } + } + if (!found) { + this.REMOVE_HIGHLIGHTED_STREAM(id); + } + }); + } } setTransition(transition: Partial) { @@ -780,6 +1145,9 @@ export class HighlighterService extends PersistentStatefulService(resolve => { + this.ADD_HIGHLIGHTED_STREAM(streamInfo); + setTimeout(() => { + resolve(); + }, 2000); + }); + } + + updateStream(streamInfo: IHighlightedStream) { + this.UPDATE_HIGHLIGHTED_STREAM(streamInfo); + } + + removeStream(streamId: string) { + this.REMOVE_HIGHLIGHTED_STREAM(streamId); + + //Remove clips from stream + const clipsToRemove = this.getClips(this.views.clips, streamId); + clipsToRemove.forEach(clip => { + this.removeClip(clip.path, streamId); + }); + } + async removeScrubFile(clipPath: string | undefined) { if (!clipPath) { console.warn('No scrub file path provided'); @@ -822,9 +1214,25 @@ export class HighlighterService extends PersistentStatefulService !c.loaded), - c => this.clips[c.path].init(), + c => this.renderingClips[c.path].init(), { concurrency: os.cpus().length, onProgress: completed => { - this.usageStatisticsService.recordAnalyticsEvent('Highlighter', { - type: 'ClipImport', - source: completed.source, - }); + this.usageStatisticsService.recordAnalyticsEvent( + this.views.useAiHighlighter ? 'AIHighlighter' : 'Highlighter', + { + type: 'ClipImport', + source: completed.source, + }, + ); this.UPDATE_CLIP({ path: completed.path, loaded: true, - scrubSprite: this.clips[completed.path].frameSource?.scrubJpg, - duration: this.clips[completed.path].duration, - deleted: this.clips[completed.path].deleted, + scrubSprite: this.renderingClips[completed.path].frameSource?.scrubJpg, + duration: this.renderingClips[completed.path].duration, + deleted: this.renderingClips[completed.path].deleted, }); }, }, @@ -869,6 +1281,10 @@ export class HighlighterService extends PersistentStatefulService clip.enabled && clip.streamInfo && clip.streamInfo[streamId] !== undefined) + renderingClips = this.getClips(this.views.clips, streamId) + .filter( + clip => + !!clip && clip.enabled && clip.streamInfo && clip.streamInfo[streamId] !== undefined, + ) .sort( (a: TClip, b: TClip) => (a.streamInfo?.[streamId]?.orderPosition ?? 0) - (b.streamInfo?.[streamId]?.orderPosition ?? 0), ) .map(c => { - const clip = this.clips[c.path]; + const clip = this.renderingClips[c.path]; clip.startTrim = c.startTrim; clip.endTrim = c.endTrim; @@ -929,11 +1352,11 @@ export class HighlighterService extends PersistentStatefulService c.enabled) .sort((a: TClip, b: TClip) => a.globalOrderPosition - b.globalOrderPosition) .map(c => { - const clip = this.clips[c.path]; + const clip = this.renderingClips[c.path]; clip.startTrim = c.startTrim; clip.endTrim = c.endTrim; @@ -942,19 +1365,19 @@ export class HighlighterService extends PersistentStatefulService c.reset(exportOptions), { + await pmap(renderingClips, c => c.reset(exportOptions), { onProgress: c => { if (c.deleted) { this.UPDATE_CLIP({ path: c.sourcePath, deleted: true }); @@ -977,18 +1405,18 @@ export class HighlighterService extends PersistentStatefulService !c.deleted); + renderingClips = renderingClips.filter(c => !c.deleted); - if (!clips.length) { + if (!renderingClips.length) { console.error('Highlighter: Export called without any clips!'); return; } // Estimate the total number of frames to set up export info - const totalFrames = clips.reduce((count: number, clip) => { + const totalFrames = renderingClips.reduce((count: number, clip) => { return count + clip.frameSource.nFrames; }, 0); - const numTransitions = clips.length - 1; + const numTransitions = renderingClips.length - 1; const transitionFrames = this.views.transitionDuration * exportOptions.fps; const totalFramesAfterTransitions = totalFrames - numTransitions * transitionFrames; @@ -1008,11 +1436,13 @@ export class HighlighterService extends PersistentStatefulService c.hasAudio).map(clip => clip.audioSource.extract())); + await Promise.all( + renderingClips.filter(c => c.hasAudio).map(clip => clip.audioSource.extract()), + ); const parsed = path.parse(this.views.exportInfo.file); const audioConcat = path.join(parsed.dir, `${parsed.name}-concat.flac`); let audioMix = path.join(parsed.dir, `${parsed.name}-mix.flac`); - fader = new AudioCrossfader(audioConcat, clips, this.views.transitionDuration); + fader = new AudioCrossfader(audioConcat, renderingClips, this.views.transitionDuration); await fader.export(); if (this.views.audio.musicEnabled && this.views.audio.musicPath) { @@ -1032,14 +1462,14 @@ export class HighlighterService extends PersistentStatefulService clip.audioSource.cleanup())); - const nClips = clips.length; + await Promise.all(renderingClips.map(clip => clip.audioSource.cleanup())); + const nClips = renderingClips.length; this.SET_EXPORT_INFO({ step: EExportStep.FrameRender }); // Cannot be null because we already checked there is at least 1 element in the array - let fromClip = clips.shift()!; - let toClip = clips.shift(); + let fromClip = renderingClips.shift()!; + let toClip = renderingClips.shift(); let transitioner: Transitioner | null = null; const exportPath = preview ? this.views.exportInfo.previewFile : this.views.exportInfo.file; @@ -1123,7 +1553,7 @@ export class HighlighterService extends PersistentStatefulService + isAiClip(clip) && + !!clip?.aiInfo?.metadata?.webcam_coordinates && + this.renderingClips[clip.path], + ) as IAiClip; + return clipWithWebcam?.aiInfo?.metadata?.webcam_coordinates || undefined; + } + /** + * + * @param webcamCoordinates + * @param outputWidth + * @param outputHeight + * @returns properly formatted complex filter for ffmpeg to move webcam to top in vertical video + */ + private getWebcamComplexFilterForFfmpeg( + webcamCoordinates: ICoordinates | null, + outputWidth: number, + outputHeight: number, + ) { + if (!webcamCoordinates) { + return ` + [0:v]crop=ih*${outputWidth}/${outputHeight}:ih,scale=${outputWidth}:-1:force_original_aspect_ratio=increase[final]; + `; + } + + const webcamTopX = webcamCoordinates?.x1; + const webcamTopY = webcamCoordinates?.y1; + const webcamWidth = webcamCoordinates?.x2 - webcamCoordinates?.x1; + const webcamHeight = webcamCoordinates?.y2 - webcamCoordinates?.y1; + + const oneThirdHeight = outputHeight / 3; + const twoThirdsHeight = (outputHeight * 2) / 3; + + return ` + [0:v]split=3[webcam][vid][blur_source]; + color=c=black:s=${outputWidth}x${outputHeight}:d=1[base]; + [webcam]crop=w=${webcamWidth}:h=${webcamHeight}:x=${webcamTopX}:y=${webcamTopY},scale=-1:${oneThirdHeight}[webcam_final]; + [vid]crop=ih*${outputWidth}/${twoThirdsHeight}:ih,scale=${outputWidth}:${twoThirdsHeight}[vid_cropped]; + [blur_source]crop=ih*${outputWidth}/${twoThirdsHeight}:ih,scale=${outputWidth}:${oneThirdHeight},gblur=sigma=50[blur]; + [base][blur]overlay=x=0:y=0[blur_base]; + [blur_base][webcam_final]overlay='(${outputWidth}-overlay_w)/2:(${oneThirdHeight}-overlay_h)/2'[base_webcam]; + [base_webcam][vid_cropped]overlay=x=0:y=${oneThirdHeight}[final]; + `; + } + // We throttle because this can go extremely fast, especially on previews @throttle(100) private setCurrentFrame(frame: number) { @@ -1232,9 +1744,12 @@ export class HighlighterService extends PersistentStatefulService { + if (this.aiHighlighterEnabled === false) { + console.log('HighlighterService: Not enabled'); + return; + } + + // if update is already in progress, need to wait until it's done + if (this.aiHighlighterUpdater.updateInProgress) { + await this.aiHighlighterUpdater.currentUpdate; + } else if (await this.aiHighlighterUpdater.isNewVersionAvailable()) { + await this.startUpdater(); + } + + const fallbackTitle = 'awesome-stream'; + const sanitizedTitle = streamInfo.title + ? streamInfo.title.replace(/[\\/:"*?<>|]+/g, ' ') + : this.extractDateTimeFromPath(filePath) || fallbackTitle; + + const setStreamInfo: IHighlightedStream = { + state: { + type: EAiDetectionState.IN_PROGRESS, + progress: 0, + }, + date: moment().toISOString(), + id: streamInfo.id || 'noId', + title: sanitizedTitle, + game: streamInfo.game || 'no title', + abortController: new AbortController(), + path: filePath, + }; + + this.streamMilestones = { + streamId: setStreamInfo.id, + milestones: [], + }; + + await this.addStream(setStreamInfo); + + const progressTracker = new ProgressTracker(progress => { + setStreamInfo.state.progress = progress; + this.updateStream(setStreamInfo); + }); + + const renderHighlights = async (partialHighlights: IHighlight[]) => { + console.log('🔄 cutHighlightClips'); + this.updateStream(setStreamInfo); + const clipData = await this.cutHighlightClips(filePath, partialHighlights, setStreamInfo); + console.log('✅ cutHighlightClips'); + // 6. add highlight clips + progressTracker.destroy(); + setStreamInfo.state.type = EAiDetectionState.FINISHED; + this.updateStream(setStreamInfo); + + console.log('🔄 addClips', clipData); + this.addAiClips(clipData, streamInfo); + console.log('✅ addClips'); + }; + + console.log('🔄 HighlighterData'); + try { + const highlighterResponse = await getHighlightClips( + filePath, + renderHighlights, + setStreamInfo.abortController!.signal, + (progress: number) => { + progressTracker.updateProgressFromHighlighter(progress); + }, + streamInfo.milestonesPath, + (milestone: IHighlighterMilestone) => { + this.streamMilestones?.milestones?.push(milestone); + }, + ); + + this.usageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'Detection', + clips: highlighterResponse.length, + game: 'Fortnite', // hardcode for now + }); + console.log('✅ Final HighlighterData', highlighterResponse); + } catch (error: unknown) { + if (error instanceof Error && error.message === 'Highlight generation canceled') { + setStreamInfo.state.type = EAiDetectionState.CANCELED_BY_USER; + } else { + console.error('Error in highlight generation:', error); + setStreamInfo.state.type = EAiDetectionState.ERROR; + } + } finally { + setStreamInfo.abortController = undefined; + this.updateStream(setStreamInfo); + // stopProgressUpdates(); + } + + return; + } + + cancelHighlightGeneration(streamId: string): void { + const stream = this.views.highlightedStreams.find(s => s.id === streamId); + if (stream && stream.abortController) { + console.log('cancelHighlightGeneration', streamId); + stream.abortController.abort(); + } + } + + async getHighlightClipsRest( + type: string, + video_uri: string, + trim: { start_time: number; start_end: number } | undefined, + ) { + // Call highlighter code - replace with function + try { + const body = { + video_uri, + url, + trim, + }; + + const controller = new AbortController(); + const signal = controller.signal; + const timeout = 1000 * 60 * 30; // 30 minutes + console.time('requestDuration'); + const fetchTimeout = setTimeout(() => { + controller.abort(); + }, timeout); + + const response = await fetch(`http://127.0.0.1:8000${type}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify(body), + signal, + }); + + clearTimeout(fetchTimeout); + console.timeEnd('requestDuration'); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data; + } catch (error: unknown) { + console.timeEnd('requestDuration'); + + if ((error as any).name === 'AbortError') { + console.error('Fetch request timed out'); + } else { + console.error('Fetch error:', error); + } + + throw new Error('Error while fetching'); + } + } + + async cutHighlightClips( + videoUri: string, + highlighterData: IHighlight[], + streamInfo: IHighlightedStream, + ): Promise { + const id = streamInfo.id; + const fallbackTitle = 'awesome-stream'; + const videoDir = path.dirname(videoUri); + const filename = path.basename(videoUri); + const sanitizedTitle = streamInfo.title + ? streamInfo.title.replace(/[\\/:"*?<>|]+/g, ' ') + : fallbackTitle; + const folderName = `${filename}-Clips-${sanitizedTitle}-${id.slice(id.length - 4, id.length)}`; + const outputDir = path.join(videoDir, folderName); + + // Check if directory for clips exists, if not create it + try { + try { + await fs.readdir(outputDir); + } catch (error: unknown) { + await fs.mkdir(outputDir); + } + } catch (error: unknown) { + console.error('Error creating file directory'); + return []; + } + + const sortedHighlights = highlighterData.sort((a, b) => a.start_time - b.start_time); + const results: INewClipData[] = []; + const processedFiles = new Set(); + + const duration = await this.getVideoDuration(videoUri); + + // First check the codec + const probeArgs = [ + '-v', + 'error', + '-select_streams', + 'v:0', + '-show_entries', + 'stream=codec_name,format=duration', + '-of', + 'default=nokey=1:noprint_wrappers=1', + videoUri, + ]; + let codec = ''; + try { + const codecResult = await execa(FFPROBE_EXE, probeArgs); + codec = codecResult.stdout.trim(); + console.log(`Codec for ${videoUri}: ${codec}`); + } catch (error: unknown) { + console.error(`Error checking codec for ${videoUri}:`, error); + } + console.time('export'); + const BATCH_SIZE = 1; + const DEFAULT_START_TRIM = 10; + const DEFAULT_END_TRIM = 10; + + for (let i = 0; i < sortedHighlights.length; i += BATCH_SIZE) { + const highlightBatch = sortedHighlights.slice(i, i + BATCH_SIZE); + const batchTasks = highlightBatch.map((highlight: IHighlight) => { + return async () => { + const formattedStart = highlight.start_time.toString().padStart(6, '0'); + const formattedEnd = highlight.end_time.toString().padStart(6, '0'); + const outputFilename = `${folderName}-${formattedStart}-${formattedEnd}.mp4`; + const outputUri = path.join(outputDir, outputFilename); + + if (processedFiles.has(outputUri)) { + console.log('File already exists'); + return null; + } + processedFiles.add(outputUri); + + // Check if the file with that name already exists and delete it if it does + try { + await fs.access(outputUri); + await fs.unlink(outputUri); + } catch (err: unknown) { + if ((err as any).code !== 'ENOENT') { + console.error(`Error checking existence of ${outputUri}:`, err); + } + } + + // Calculate new start and end times + new clip duration + const newClipStartTime = Math.max(0, highlight.start_time - DEFAULT_START_TRIM); + const actualStartTrim = highlight.start_time - newClipStartTime; + const newClipEndTime = Math.min(duration, highlight.end_time + DEFAULT_END_TRIM); + const actualEndTrim = newClipEndTime - highlight.end_time; + + const args = [ + '-ss', + newClipStartTime.toString(), + '-to', + newClipEndTime.toString(), + '-i', + videoUri, + '-c:v', + codec === 'h264' ? 'copy' : 'libx264', + '-c:a', + 'aac', + '-strict', + 'experimental', + '-b:a', + '192k', + '-movflags', + 'faststart', + outputUri, + ]; + + try { + const subprocess = execa(FFMPEG_EXE, args); + const timeoutDuration = 1000 * 60 * 5; + const timeoutId = setTimeout(() => { + console.warn(`FFMPEG process timed out for ${outputUri}`); + subprocess.kill('SIGTERM', { forceKillAfterTimeout: 2000 }); + }, timeoutDuration); + + try { + await subprocess; + console.log(`Created segment: ${outputUri}`); + const newClipData: INewClipData = { + path: outputUri, + aiClipInfo: { + inputs: highlight.inputs, + score: highlight.score, + metadata: highlight.metadata, + }, + startTime: highlight.start_time, + endTime: highlight.end_time, + startTrim: actualStartTrim, + endTrim: actualEndTrim, + }; + return newClipData; + } catch (error: unknown) { + console.warn(`Error during FFMPEG execution for ${outputUri}:`, error); + return null; + } finally { + clearTimeout(timeoutId); + } + } catch (error: unknown) { + console.error(`Error creating segment: ${outputUri}`, error); + return null; + } + }; + }); + + const batchResults = await Promise.allSettled(batchTasks.map(task => task())); + results.push( + ...batchResults + .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .map(result => result.value) + .filter(value => value !== null), + ); + + const failedResults = batchResults.filter(result => result.status === 'rejected'); + + if (failedResults.length > 0) { + console.error('Failed exports:', failedResults); + } + } + + console.timeEnd('export'); + return results; + } getClips(clips: TClip[], streamId?: string): TClip[] { - const inputClips = clips.filter(clip => clip.path !== 'add'); - let wantedClips; + return clips.filter(clip => { + if (clip.path === 'add') { + return false; + } + const exists = this.fileExists(clip.path); + if (!exists) { + this.removeClip(clip.path, streamId); + return false; + } + if (streamId) { + return clip.streamInfo?.[streamId]; + } + return true; + }); + } - if (streamId) { - wantedClips = inputClips.filter(clip => clip.streamInfo?.[streamId]); - } else { - wantedClips = inputClips; + getClipsLoaded(clips: TClip[], streamId?: string): boolean { + return this.getClips(clips, streamId).every(clip => clip.loaded); + } + + getRoundDetails( + clips: TClip[], + ): { round: number; inputs: IInput[]; duration: number; hypeScore: number }[] { + const roundsMap: { + [key: number]: { inputs: IInput[]; duration: number; hypeScore: number; count: number }; + } = {}; + clips.forEach(clip => { + const aiClip = isAiClip(clip) ? clip : undefined; + const round = aiClip?.aiInfo?.metadata?.round ?? undefined; + if (aiClip?.aiInfo?.inputs && round) { + if (!roundsMap[round]) { + roundsMap[round] = { inputs: [], duration: 0, hypeScore: 0, count: 0 }; + } + roundsMap[round].inputs.push(...aiClip.aiInfo.inputs); + roundsMap[round].duration += aiClip.duration + ? aiClip.duration - aiClip.startTrim - aiClip.endTrim + : 0; + roundsMap[round].hypeScore += aiClip.aiInfo.score; + roundsMap[round].count += 1; + } + }); + + return Object.keys(roundsMap).map(round => { + const averageScore = + roundsMap[parseInt(round, 10)].hypeScore / roundsMap[parseInt(round, 10)].count; + const hypeScore = Math.ceil(Math.min(1, Math.max(0, averageScore)) * 5); + + return { + round: parseInt(round, 10), + inputs: roundsMap[parseInt(round, 10)].inputs, + duration: roundsMap[parseInt(round, 10)].duration, + hypeScore, + }; + }); + } + + async getVideoDuration(filePath: string): Promise { + const { stdout } = await execa(FFPROBE_EXE, [ + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'default=noprint_wrappers=1:nokey=1', + filePath, + ]); + const duration = parseFloat(stdout); + return duration; + } + + enableOnlySpecificClips(clips: TClip[], streamId?: string) { + clips.forEach(clip => { + this.UPDATE_CLIP({ + path: clip.path, + enabled: false, + }); + }); + + // Enable specific clips + const clipsToEnable = this.getClips(clips, streamId); + clipsToEnable.forEach(clip => { + this.UPDATE_CLIP({ + path: clip.path, + enabled: true, + }); + }); + } + + private updateProgress(progress: IDownloadProgress) { + // this is a lie and its not a percent, its float from 0 and 1 + this.SET_UPDATER_PROGRESS(progress.percent * 100); + } + + /** + * Start updater process + */ + private async startUpdater() { + try { + this.SET_UPDATER_STATE(true); + this.SET_HIGHLIGHTER_VERSION(this.aiHighlighterUpdater.version || ''); + await this.aiHighlighterUpdater.update(progress => this.updateProgress(progress)); + } finally { + this.SET_UPDATER_STATE(false); } + } - const outputClips = wantedClips.filter(c => this.fileExists(c.path)); - if (outputClips.length !== wantedClips.length) { - wantedClips - .filter(c => !this.fileExists(c.path)) - .forEach(clip => { - this.removeClip(clip.path, streamId); - }); + /** + * Create milestones file if ids match and return path + */ + private async prepareMilestonesFile(streamId: string): Promise { + if ( + !this.streamMilestones || + this.streamMilestones.streamId !== streamId || + this.streamMilestones.milestones.length === 0 + ) { + return; } - return outputClips; + + const basepath = getSharedResource('ai-highlighter'); + const milestonesPath = path.join(basepath, 'milestones', 'milestones.json'); + + const milestonesData = JSON.stringify(this.streamMilestones.milestones); + await fs.outputFile(milestonesPath, milestonesData); + + return milestonesPath; } } diff --git a/app/services/incremental-rollout.ts b/app/services/incremental-rollout.ts index f8279f00a604..0ce94ce19ced 100644 --- a/app/services/incremental-rollout.ts +++ b/app/services/incremental-rollout.ts @@ -6,6 +6,7 @@ import { HostsService } from './hosts'; import Utils from 'services/utils'; import { InitAfter } from './core'; import { AppService } from './app'; +import { getOS, OS } from 'util/operating-systems'; export enum EAvailableFeatures { platform = 'slobs--platform', @@ -15,6 +16,7 @@ export enum EAvailableFeatures { restream = 'slobs--restream', tiktok = 'slobs--tiktok', highlighter = 'slobs--highlighter', + aiHighlighter = 'slobs--ai-highlighter', growTab = 'slobs--grow-tab', themeAudit = 'slobs--theme-audit', reactWidgets = 'slobs--react-widgets', @@ -114,6 +116,10 @@ class IncrementalRolloutView extends ViewHandler -1; } } diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 2cdcd9a55a0c..d57266367d84 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -108,6 +108,7 @@ export class StreamingService replayBufferFileWrite = new Subject(); streamInfoChanged = new Subject>(); signalInfoChanged = new Subject(); + latestRecordingPath = new Subject(); streamErrorCreated = new Subject(); // Dummy subscription for stream deck @@ -1374,6 +1375,7 @@ export class StreamingService 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; diff --git a/app/services/usage-statistics.ts b/app/services/usage-statistics.ts index 690159e6f54b..3c92d43abe92 100644 --- a/app/services/usage-statistics.ts +++ b/app/services/usage-statistics.ts @@ -35,6 +35,7 @@ type TAnalyticsEvent = | 'Shown' | 'AppStart' | 'Highlighter' + | 'AIHighlighter' | 'Hardware' | 'WebcamUse' | 'MicrophoneUse' diff --git a/package.json b/package.json index ece99634c75b..009cfdd2d851 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "package:mac-arm64": "yarn generate-agreement:mac && rimraf dist && electron-builder build -m --arm64 --config electron-builder/base.config.js", "package:preview": "yarn generate-agreement && rimraf dist && electron-builder build -w --x64 --config electron-builder/preview.config.js", "package:beta": "cross-env SLD_COMPILE_FOR_BETA=1 yarn compile && yarn generate-agreement && rimraf dist && electron-builder build -w --x64 --config electron-builder/beta.config.js", + "package:highlighter": "cross-env NODE_ENV=production yarn compile && cross-env SLOBS_NO_SIGN=true yarn package", "eslint": "eslint \"{app,guest-api,obs-api,updater}/**/*.ts\" main.js", "test": "tsc -p test && ava -v --timeout=3m ./test-dist/test/regular/**/*.js", "test:file": "tsc -p test && ava -v --timeout=60m", @@ -249,4 +250,4 @@ "got@^9.6.0": "11.8.5" }, "packageManager": "yarn@3.1.1" -} +} \ No newline at end of file diff --git a/test/regular/highlighter.ts b/test/regular/highlighter.ts index 6786638b61f3..43048b7f9418 100644 --- a/test/regular/highlighter.ts +++ b/test/regular/highlighter.ts @@ -33,13 +33,12 @@ test('Highlighter save and export', async t => { await stopStream(); await focusMain(); - await clickButton('All clips'); + await clickButton('All Clips'); await clickButton('Export'); const fileName = 'MyTestVideo.mp4'; const exportLocation = path.resolve(recordingDir, fileName); await fillForm({ exportLocation }); - const $exportBtn = await (await select('.ant-modal-content')).$('span=Export'); - await click($exportBtn); + await clickButton('Export Horizontal'); await waitForDisplayed('h1=Upload To', { timeout: 60000 }); t.true(fs.existsSync(exportLocation), 'The video file should exist'); }); From 064eb1dabc9a83ff19f76410e2369c62252873b9 Mon Sep 17 00:00:00 2001 From: marvinoffers Date: Fri, 13 Dec 2024 00:53:32 +0100 Subject: [PATCH 78/97] fix toggle recording (#5256) --- app/services/highlighter/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/services/highlighter/index.ts b/app/services/highlighter/index.ts index 0856e5854232..b6f090bc7f0d 100644 --- a/app/services/highlighter/index.ts +++ b/app/services/highlighter/index.ts @@ -808,6 +808,10 @@ export class HighlighterService extends PersistentStatefulService Date: Thu, 12 Dec 2024 16:35:46 -0800 Subject: [PATCH 79/97] Disallow special characters in title (#5253) * Disallow special characters in title Also adds missing translations and fixes capitalization * Delay check of incremental features until fetched --- .../highlighter/StreamView.tsx | 30 ++++++++++++------ app/i18n/en-US/highlighter.json | 8 +++-- app/services/highlighter/index.ts | 31 ++++++++++--------- 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/app/components-react/highlighter/StreamView.tsx b/app/components-react/highlighter/StreamView.tsx index 121ec780f436..85221b0d4ccd 100644 --- a/app/components-react/highlighter/StreamView.tsx +++ b/app/components-react/highlighter/StreamView.tsx @@ -15,6 +15,7 @@ import StreamCard from './StreamCard'; import path from 'path'; import PreviewModal from './PreviewModal'; import moment from 'moment'; +import { TextInput } from 'components-react/shared/inputs'; type TModalStreamView = | { type: 'export'; id: string | undefined } @@ -119,11 +120,20 @@ export default function StreamView({ emitSetView }: { emitSetView: (data: IViewS const { HighlighterService } = Services; const [inputValue, setInputValue] = useState(''); - function handleInputChange(event: any) { - setInputValue(event.target.value); + function handleInputChange(value: string) { + setInputValue(value); + } + + function specialCharacterValidator(rule: unknown, value: string, callback: Function) { + if (/[\\/:"*?<>|]+/g.test(value)) { + callback($t('You cannot use special characters in this field')); + } else { + callback(); + } } async function startAiDetection(title: string) { + if (/[\\/:"*?<>|]+/g.test(title)) return; const streamInfo: StreamInfoForAiHighlighter = { id: 'manual_' + uuid(), title, @@ -149,13 +159,15 @@ export default function StreamView({ emitSetView }: { emitSetView: (data: IViewS <>
    -

    {$t('Import Fortnite stream')}

    - {$t('Import Fortnite Stream')} +
    @@ -222,7 +234,7 @@ export default function StreamView({ emitSetView }: { emitSetView: (data: IViewS >
    -

    My stream highlights

    +

    {$t('My Stream Highlights')}

    !aiDetectionInProgress && setShowModal({ type: 'upload' })} > - {$t('Select your Fornite recording')} - + {$t('Select your Fortnite recording')} +
    diff --git a/app/services/highlighter/ai-highlighter/updater.ts b/app/services/highlighter/ai-highlighter/updater.ts index f69fe0b2a217..87cf94f8357b 100644 --- a/app/services/highlighter/ai-highlighter/updater.ts +++ b/app/services/highlighter/ai-highlighter/updater.ts @@ -43,28 +43,12 @@ export class AiHighlighterUpdater { * Spawn the AI Highlighter process that would process the video */ static startHighlighterProcess(videoUri: string, milestonesPath?: string) { - const isDev = Utils.isDevMode(); - if (isDev) { - const rootPath = '../highlighter-api/'; - const command = [ - 'run', - 'python', - `${rootPath}/highlighter_api/cli.py`, - videoUri, - '--ffmpeg_path', - FFMPEG_EXE, - '--loglevel', - 'debug', - ]; - - if (milestonesPath) { - command.push('--milestones_file'); - command.push(milestonesPath); - } - - return spawn('poetry', command, { - cwd: rootPath, - }); + const runHighlighterFromRepository = Utils.getHighlighterEnvironment() === 'local'; + + if (runHighlighterFromRepository) { + // this is for highlighter development + // to run this you have to install the highlighter repository next to desktop + return AiHighlighterUpdater.startHighlighterFromRepository(videoUri, milestonesPath); } const highlighterBinaryPath = path.resolve( @@ -82,6 +66,29 @@ export class AiHighlighterUpdater { return spawn(highlighterBinaryPath, command); } + private static startHighlighterFromRepository(videoUri: string, milestonesPath: string) { + const rootPath = '../highlighter-api/'; + const command = [ + 'run', + 'python', + `${rootPath}/highlighter_api/cli.py`, + videoUri, + '--ffmpeg_path', + FFMPEG_EXE, + '--loglevel', + 'debug', + ]; + + if (milestonesPath) { + command.push('--milestones_file'); + command.push(milestonesPath); + } + + return spawn('poetry', command, { + cwd: rootPath, + }); + } + /** * Check if an update is currently in progress */ @@ -96,6 +103,16 @@ export class AiHighlighterUpdater { return this.manifest?.version || null; } + /* + * Get the path to the highlighter binary + */ + private getManifestUrl(): string { + if (Utils.getHighlighterEnvironment() === 'staging') { + return 'https://cdn-highlighter-builds.streamlabs.com/staging/manifest_win_x86_64.json'; + } else { + return 'https://cdn-highlighter-builds.streamlabs.com/manifest_win_x86_64.json'; + } + } /** * Check if AI Highlighter requires an update */ @@ -107,10 +124,9 @@ export class AiHighlighterUpdater { this.versionChecked = true; console.log('checking for highlighter updates...'); + const manifestUrl = this.getManifestUrl(); // fetch the latest version of the manifest for win x86_64 target - const newManifest = await jfetch( - new Request('https://cdn-highlighter-builds.streamlabs.com/manifest_win_x86_64.json'), - ); + const newManifest = await jfetch(new Request(manifestUrl)); this.manifest = newManifest; // if manifest.json does not exist, an initial download is required diff --git a/app/services/utils.ts b/app/services/utils.ts index d41df39a581e..9365f21bec6e 100644 --- a/app/services/utils.ts +++ b/app/services/utils.ts @@ -24,6 +24,7 @@ export interface IEnv { // Allows joining as a guest instead of a host for guest cam SLD_GUEST_CAM_HASH: string; CI: boolean; + HIGHLIGHTER_ENV: 'production' | 'staging' | 'local'; } export default class Utils { @@ -93,9 +94,19 @@ export default class Utils { ); } + static get isProduction() { + return Utils.env.NODE_ENV === 'production'; + } + static isDevMode() { return Utils.env.NODE_ENV !== 'production'; } + static getHighlighterEnvironment(): 'production' | 'staging' | 'local' { + if (process.env.HIGHLIGHTER_ENV !== 'staging' && process.env.HIGHLIGHTER_ENV !== 'local') { + return 'production'; + } + return process.env.HIGHLIGHTER_ENV as 'production' | 'staging' | 'local'; + } static isTestMode() { return Utils.env.NODE_ENV === 'test'; diff --git a/package.json b/package.json index 009cfdd2d851..724f273c33ad 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "watch": "yarn clear && yarn compile:updater && yarn webpack-cli --watch --progress --config ./webpack.dev.config.js", "watch:strictnulls": "yarn clear && yarn compile:updater && cross-env SLOBS_STRICT_NULLS=true yarn webpack-cli --watch --progress --config ./webpack.dev.config.js", "watch:app": "yarn clear && yarn webpack-cli --watch --progress --config ./webpack.dev-app.config.js", + "watch:highlighter": "cross-env HIGHLIGHTER_ENV=local yarn watch", "start": "electron .", "clear-plugins": "rimraf plugins", "package": "yarn generate-agreement && rimraf dist && electron-builder build -w --x64 --config electron-builder/base.config.js", @@ -23,7 +24,7 @@ "package:mac-arm64": "yarn generate-agreement:mac && rimraf dist && electron-builder build -m --arm64 --config electron-builder/base.config.js", "package:preview": "yarn generate-agreement && rimraf dist && electron-builder build -w --x64 --config electron-builder/preview.config.js", "package:beta": "cross-env SLD_COMPILE_FOR_BETA=1 yarn compile && yarn generate-agreement && rimraf dist && electron-builder build -w --x64 --config electron-builder/beta.config.js", - "package:highlighter": "cross-env NODE_ENV=production yarn compile && cross-env SLOBS_NO_SIGN=true yarn package", + "package:highlighter": "cross-env HIGHLIGHTER_ENV=staging yarn compile && cross-env SLOBS_NO_SIGN=true yarn package", "eslint": "eslint \"{app,guest-api,obs-api,updater}/**/*.ts\" main.js", "test": "tsc -p test && ava -v --timeout=3m ./test-dist/test/regular/**/*.js", "test:file": "tsc -p test && ava -v --timeout=60m", @@ -250,4 +251,4 @@ "got@^9.6.0": "11.8.5" }, "packageManager": "yarn@3.1.1" -} \ No newline at end of file +} diff --git a/webpack.base.config.js b/webpack.base.config.js index a27b06cae4a5..495300911da8 100644 --- a/webpack.base.config.js +++ b/webpack.base.config.js @@ -23,6 +23,10 @@ if (process.env.SLD_COMPILE_FOR_BETA) { console.log('Compiling build with forced beta SL host.'); envDef['process.env.SLD_COMPILE_FOR_BETA'] = JSON.stringify(true); } +if (process.env.HIGHLIGHTER_ENV) { + console.log('Compiling build with ' + process.env.HIGHLIGHTER_ENV + ' highlighter version.'); + envDef['process.env.HIGHLIGHTER_ENV'] = JSON.stringify(process.env.HIGHLIGHTER_ENV ?? ''); +} plugins.push(new webpack.DefinePlugin(envDef)); From 3ffac328fdbd4a87e065a63764e7742b5fa6865e Mon Sep 17 00:00:00 2001 From: gettinToasty Date: Fri, 13 Dec 2024 15:57:49 -0800 Subject: [PATCH 85/97] download highlighter into AppData folder (#5263) Co-authored-by: ggolda --- app/services/highlighter/ai-highlighter/updater.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/services/highlighter/ai-highlighter/updater.ts b/app/services/highlighter/ai-highlighter/updater.ts index 87cf94f8357b..e61630bf9737 100644 --- a/app/services/highlighter/ai-highlighter/updater.ts +++ b/app/services/highlighter/ai-highlighter/updater.ts @@ -8,6 +8,7 @@ import { importExtractZip } from 'util/slow-imports'; import { spawn } from 'child_process'; import { FFMPEG_EXE } from '../constants'; import Utils from '../../utils'; +import * as remote from '@electron/remote'; interface IAIHighlighterManifest { version: string; @@ -35,7 +36,7 @@ export class AiHighlighterUpdater { public currentUpdate: Promise | null = null; constructor() { - this.basepath = getSharedResource('ai-highlighter'); + this.basepath = path.join(remote.app.getPath('userData'), 'ai-highlighter'); this.manifestPath = path.resolve(this.basepath, 'manifest.json'); } @@ -52,7 +53,7 @@ export class AiHighlighterUpdater { } const highlighterBinaryPath = path.resolve( - getSharedResource('ai-highlighter'), + path.join(remote.app.getPath('userData'), 'ai-highlighter'), 'bin', 'app.exe', ); From dce1383b33597a49c8bf82f235920625e8ec3fe8 Mon Sep 17 00:00:00 2001 From: gettinToasty Date: Fri, 13 Dec 2024 16:03:19 -0800 Subject: [PATCH 86/97] Highlighter/move to appdata (#5264) * download highlighter into AppData folder * fixed path --------- Co-authored-by: ggolda --- app/services/highlighter/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/services/highlighter/index.ts b/app/services/highlighter/index.ts index 8f322ede006c..77aad7831ad5 100644 --- a/app/services/highlighter/index.ts +++ b/app/services/highlighter/index.ts @@ -61,7 +61,6 @@ import { AiHighlighterUpdater } from './ai-highlighter/updater'; import { IDownloadProgress } from 'util/requests'; import { IncrementalRolloutService } from 'app-services'; import { EAvailableFeatures } from 'services/incremental-rollout'; -import { getSharedResource } from 'util/get-shared-resource'; export type TStreamInfo = | { orderPosition: number; @@ -2309,7 +2308,7 @@ export class HighlighterService extends PersistentStatefulService Date: Mon, 16 Dec 2024 12:37:31 -0500 Subject: [PATCH 87/97] Update modal and dual output platform cards. (#5250) * WIP * Better way to implement modal buttons. * Update dual output switcher cards. --- .../go-live/DestinationSwitchers.m.less | 34 ++++++++ .../windows/go-live/DestinationSwitchers.tsx | 81 ++++++++++++++----- .../NonUltraDestinationSwitchers.tsx | 50 +++++------- .../dual-output/UltraDestinationSwitchers.tsx | 46 +++++------ app/i18n/en-US/tiktok.json | 4 +- app/services/platforms/tiktok.ts | 5 ++ app/themes.g.less | 4 + 7 files changed, 145 insertions(+), 79 deletions(-) diff --git a/app/components-react/windows/go-live/DestinationSwitchers.m.less b/app/components-react/windows/go-live/DestinationSwitchers.m.less index ff89f0a42aae..0642621c523c 100644 --- a/app/components-react/windows/go-live/DestinationSwitchers.m.less +++ b/app/components-react/windows/go-live/DestinationSwitchers.m.less @@ -1,4 +1,5 @@ @import '../../../styles/index.less'; +@import '../../../styles/mixins.less'; .destination-logo { font-size: 38px; @@ -74,3 +75,36 @@ .column-padding { padding: 0px 15px; } + +.tiktok-modal-logo { + display: inline-block; + width: 18px; + height: 18px; + margin-right: 5px; + vertical-align: super; +} + +.tiktok-modal { + &:global(.ant-modal-confirm .ant-modal-body) { + padding: 0px; + } + + :global(div.ant-modal-content) { + box-shadow: none; + } + + :global(.ant-modal-confirm-body .ant-modal-confirm-title) { + display: inline-block; + font-size: 19px; + line-height: unset; + } + + :global(.ant-modal-confirm-btns) { + display: none; + } +} + +.tiktok-modal-footer { + display: flex; + justify-content: flex-end; +} diff --git a/app/components-react/windows/go-live/DestinationSwitchers.tsx b/app/components-react/windows/go-live/DestinationSwitchers.tsx index 6685e4d0dda3..01d63d3bd094 100644 --- a/app/components-react/windows/go-live/DestinationSwitchers.tsx +++ b/app/components-react/windows/go-live/DestinationSwitchers.tsx @@ -2,15 +2,18 @@ import React, { useRef, MouseEvent } from 'react'; import { getPlatformService, TPlatform } from '../../../services/platforms'; import cx from 'classnames'; import { $t } from '../../../services/i18n'; +import * as remote from '@electron/remote'; import styles from './DestinationSwitchers.m.less'; import { ICustomStreamDestination } from '../../../services/settings/streaming'; import { Services } from '../../service-provider'; import { SwitchInput } from '../../shared/inputs'; import PlatformLogo from '../../shared/PlatformLogo'; -import { assertIsDefined } from '../../../util/properties-type-guards'; import { useDebounce } from '../../hooks'; import { useGoLiveSettings } from './useGoLiveSettings'; import { alertAsync } from '../../modals'; +import { ModalLayout } from 'components-react/shared/ModalLayout'; +import { Button, Form, Modal } from 'antd'; +import Translate from 'components-react/shared/Translate'; /** * Allows enabling/disabling platforms and custom destinations for the stream @@ -188,9 +191,11 @@ const DestinationSwitcher = React.forwardRef<{}, IDestinationSwitcherProps>((p, const switchInputRef = useRef(null); const containerRef = useRef(null); const platform = typeof p.destination === 'string' ? (p.destination as TPlatform) : null; - const { RestreamService, MagicLinkService, StreamingService } = Services; + const { RestreamService, TikTokService, StreamingService } = Services; const canEnableRestream = RestreamService.views.canEnableRestream; const cannotDisableDestination = p.isPrimary && !canEnableRestream; + const showTikTokModal = + p.promptConnectTikTok || (platform === 'tiktok' && TikTokService.missingLiveAccess); // Preserving old TikTok functionality, so they can't enable the toggle if TikTok is not // connected. @@ -199,25 +204,8 @@ const DestinationSwitcher = React.forwardRef<{}, IDestinationSwitcherProps>((p, platform === 'tiktok' && !StreamingService.views.isPlatformLinked('tiktok'); function onClickHandler(ev: MouseEvent) { - if (p.promptConnectTikTok) { - alertAsync({ - type: 'confirm', - title: $t('Connect TikTok Account'), - closable: true, - content: ( - - {$t( - 'Connect your TikTok account to stream to TikTok and one additional platform for free.', - )} - - ), - okText: $t('Connect'), - onOk: () => { - Services.NavigationService.actions.navigate('PlatformMerge', { platform: 'tiktok' }); - Services.WindowsService.actions.closeChildWindow(); - }, - }); - return; + if (showTikTokModal) { + renderTikTokModal(p.promptConnectTikTok); } // If we're disabling the switch we shouldn't be emitting anything past below @@ -332,3 +320,54 @@ const DestinationSwitcher = React.forwardRef<{}, IDestinationSwitcherProps>((p,
    ); }); + +export function renderTikTokModal(promptConnectTikTok?: boolean) { + const { TikTokService } = Services; + + const message = promptConnectTikTok + ? $t('Connect your TikTok account to stream to TikTok and one additional platform for free.') + : $t( + "Connect your TikTok account to stream to TikTok and one other platform for free. Haven't applied to stream on TikTok Live yet? Start the process here.", + { link: }, + ); + + function openApplicationInfoPage() { + remote.shell.openExternal(Services.TikTokService.applicationUrl); + } + + alertAsync({ + bodyStyle: { padding: '24px' }, + className: styles.tiktokModal, + type: 'confirm', + title: $t('Connect your TikTok Account'), + content: ( + + + + ), + icon: , + closable: true, + maskClosable: true, + cancelButtonProps: { style: { display: 'none' } }, + okButtonProps: { style: { display: 'none' } }, + modalRender: node => }>{node}, + width: 600, + }); +} + +function TikTokModalFooter() { + function connect() { + Modal.destroyAll(); + Services.NavigationService.actions.navigate('PlatformMerge', { platform: 'tiktok' }); + Services.WindowsService.actions.closeChildWindow(); + } + + return ( +
    + + +
    + ); +} diff --git a/app/components-react/windows/go-live/dual-output/NonUltraDestinationSwitchers.tsx b/app/components-react/windows/go-live/dual-output/NonUltraDestinationSwitchers.tsx index 837e379d2550..bdb31e60e23b 100644 --- a/app/components-react/windows/go-live/dual-output/NonUltraDestinationSwitchers.tsx +++ b/app/components-react/windows/go-live/dual-output/NonUltraDestinationSwitchers.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useCallback } from 'react'; +import React, { useRef, useCallback, useMemo } from 'react'; import { getPlatformService, TPlatform } from 'services/platforms'; import cx from 'classnames'; import { $t } from 'services/i18n'; @@ -14,6 +14,7 @@ import Translate from 'components-react/shared/Translate'; import DualOutputPlatformSelector from './DualOutputPlatformSelector'; import { useDebounce } from 'components-react/hooks'; import DualOutputToggle from '../../../shared/DualOutputToggle'; +import { renderTikTokModal } from '../DestinationSwitchers'; interface INonUltraDestinationSwitchers { showSelector?: boolean; @@ -35,6 +36,11 @@ export function NonUltraDestinationSwitchers(p: INonUltraDestinationSwitchers) { const destinationSwitcherRef = useRef({ addClass: () => undefined }); const promptConnectTikTok = !isPlatformLinked('tiktok'); + const platforms = useMemo( + () => (promptConnectTikTok ? enabledPlatforms.concat('tiktok') : enabledPlatforms), + [enabledPlatforms, promptConnectTikTok], + ); + const emitSwitch = useDebounce(500, () => { switchPlatforms(enabledPlatformsRef.current); }); @@ -69,7 +75,7 @@ export function NonUltraDestinationSwitchers(p: INonUltraDestinationSwitchers) { style={{ marginBottom: '15px' }} /> )} - {enabledPlatforms.map((platform: TPlatform, index: number) => ( + {platforms.map((platform: TPlatform, index: number) => ( void }, IDestinat }; }); const containerRef = useRef(null); + const { TikTokService } = Services; const platform = typeof p.destination === 'string' ? (p.destination as TPlatform) : null; + const showTikTokModal = + p.promptConnectTikTok || (platform === 'tiktok' && TikTokService.missingLiveAccess); function addClass() { containerRef.current?.classList.remove(styles.platformDisabled); @@ -158,26 +167,6 @@ const DestinationSwitcher = React.forwardRef<{ addClass: () => void }, IDestinat containerRef.current?.classList.add(styles.platformDisabled); } - function showTikTokConnectModal() { - alertAsync({ - type: 'confirm', - title: $t('Connect TikTok Account'), - closable: true, - content: ( - - {$t( - 'Connect your TikTok account to stream to TikTok and one additional platform for free.', - )} - - ), - okText: $t('Connect'), - onOk: () => { - Services.NavigationService.actions.navigate('PlatformMerge', { platform: 'tiktok' }); - Services.WindowsService.actions.closeChildWindow(); - }, - }); - } - const { title, description, CloseIcon, Logo } = (() => { if (platform) { const { UserService } = Services; @@ -220,15 +209,18 @@ const DestinationSwitcher = React.forwardRef<{ addClass: () => void }, IDestinat } })(); return ( -
    +
    { - if (p.promptConnectTikTok) { - showTikTokConnectModal(); + if (showTikTokModal) { + renderTikTokModal(p.promptConnectTikTok); } }} > @@ -241,7 +233,7 @@ const DestinationSwitcher = React.forwardRef<{ addClass: () => void }, IDestinat {description}
    {/* CLOSE */} - {!p.isPrimary && } + {(!p.isPrimary || !p.promptConnectTikTok) && }
    diff --git a/app/components-react/windows/go-live/dual-output/UltraDestinationSwitchers.tsx b/app/components-react/windows/go-live/dual-output/UltraDestinationSwitchers.tsx index 4d47e183e2ef..58ef9e7aa1d1 100644 --- a/app/components-react/windows/go-live/dual-output/UltraDestinationSwitchers.tsx +++ b/app/components-react/windows/go-live/dual-output/UltraDestinationSwitchers.tsx @@ -1,4 +1,4 @@ -import React, { useRef, MouseEvent, useCallback } from 'react'; +import React, { useRef, MouseEvent, useCallback, useMemo } from 'react'; import { getPlatformService, TPlatform } from 'services/platforms'; import cx from 'classnames'; import { $t } from 'services/i18n'; @@ -14,6 +14,7 @@ import { alertAsync } from 'components-react/modals'; import Translate from 'components-react/shared/Translate'; import { useDebounce } from 'components-react/hooks'; import DualOutputToggle from '../../../shared/DualOutputToggle'; +import { renderTikTokModal } from '../DestinationSwitchers'; interface IUltraDestinationSwitchers { type?: 'default' | 'ultra'; @@ -38,6 +39,11 @@ export function UltraDestinationSwitchers(p: IUltraDestinationSwitchers) { enabledPlatformsRef.current = enabledPlatforms; const promptConnectTikTok = !isPlatformLinked('tiktok'); + const platforms = useMemo( + () => (promptConnectTikTok ? linkedPlatforms.concat('tiktok') : linkedPlatforms), + [linkedPlatforms, promptConnectTikTok], + ); + const emitSwitch = useDebounce(500, () => { switchPlatforms(enabledPlatformsRef.current); }); @@ -77,7 +83,7 @@ export function UltraDestinationSwitchers(p: IUltraDestinationSwitchers) { style={{ marginBottom: '15px' }} /> )} - {linkedPlatforms.map((platform: TPlatform, index: number) => ( + {platforms.map((platform: TPlatform, index: number) => ( (null); const platform = typeof p.destination === 'string' ? (p.destination as TPlatform) : null; const enable = !p.enabled ?? (p.promptConnectTikTok && p.promptConnectTikTok === true); - const { RestreamService, MagicLinkService } = Services; + const { RestreamService, MagicLinkService, TikTokService } = Services; const canDisablePrimary = p.canDisablePrimary; - - function showTikTokConnectModal() { - alertAsync({ - type: 'confirm', - title: $t('Connect TikTok Account'), - closable: true, - content: ( - - {$t( - 'Connect your TikTok account to stream to TikTok and one additional platform for free.', - )} - - ), - okText: $t('Connect'), - onOk: () => { - Services.NavigationService.actions.navigate('PlatformMerge', { platform: 'tiktok' }); - Services.WindowsService.actions.closeChildWindow(); - }, - }); - } + const showTikTokModal = + p.promptConnectTikTok || (platform === 'tiktok' && TikTokService.missingLiveAccess); function onClickHandler(ev: MouseEvent) { // TODO: do we need this check if we're on an Ultra DestinationSwitcher @@ -229,10 +217,12 @@ function DestinationSwitcher(p: IDestinationSwitcherProps) {
    { - if (p.promptConnectTikTok) { - showTikTokConnectModal(); + if (showTikTokModal) { + renderTikTokModal(p.promptConnectTikTok); } }} > @@ -249,8 +239,8 @@ function DestinationSwitcher(p: IDestinationSwitcherProps) { {/* SWITCH */}
    { - if (p.promptConnectTikTok) { - showTikTokConnectModal(); + if (showTikTokModal) { + renderTikTokModal(p.promptConnectTikTok); e.stopPropagation(); return; } else { diff --git a/app/i18n/en-US/tiktok.json b/app/i18n/en-US/tiktok.json index 041dceea7915..2f2571e6d15c 100644 --- a/app/i18n/en-US/tiktok.json +++ b/app/i18n/en-US/tiktok.json @@ -24,5 +24,7 @@ "You may be eligible for TikTok Live Access. Apply here.": "You may be eligible for TikTok Live Access. Apply here.", "Click to view TikTok Replay in your browser.": "Click to view TikTok Replay in your browser.", "TikTok Stream Error": "TikTok Stream Error", - "Couldn't confirm TikTok Live Access. Apply for Live Permissions below": "Couldn't confirm TikTok Live Access. Apply for Live Permissions below" + "Couldn't confirm TikTok Live Access. Apply for Live Permissions below": "Couldn't confirm TikTok Live Access. Apply for Live Permissions below", + "Connect your TikTok account": "Connect your TikTok account", + "Connect your TikTok account to stream to TikTok and one other platform for free. Haven't applied to stream on TikTok Live yet? Start the process here.": "Connect your TikTok account to stream to TikTok and one other platform for free. Haven't applied to stream on TikTok Live yet? Start the process here." } diff --git a/app/services/platforms/tiktok.ts b/app/services/platforms/tiktok.ts index a6f9de1e4ec7..48394a751b13 100644 --- a/app/services/platforms/tiktok.ts +++ b/app/services/platforms/tiktok.ts @@ -141,6 +141,11 @@ export class TikTokService return ['approved', 'legacy'].includes(scope); } + get missingLiveAccess(): boolean { + const scope = this.state.settings?.liveScope ?? 'never-applied'; + return ['legacy', 'never-applied'].includes(scope); + } + get approved(): boolean { return this.state.settings.liveScope === 'approved'; } diff --git a/app/themes.g.less b/app/themes.g.less index 6ec8bb81cd2f..7fe2d56beb16 100644 --- a/app/themes.g.less +++ b/app/themes.g.less @@ -73,6 +73,7 @@ --logged-in: @lavender-light; --prime-button: @dark-2; --tooltip-hover: @light-4; + --modal-footer: @dark-2; // 3rd Party Colors --twitch: @twitch; @@ -153,6 +154,7 @@ --logged-in: @lavender-dark; --prime-button: @dark-2; --tooltip-hover: @dark-5; + --modal-footer: @dark-2; // 3rd Party Colors --tiktok: @black; @@ -219,6 +221,7 @@ --logged-in: @lavender-dark; --prime-button: @dark-2; --tooltip-hover: @primedark-4; + --modal-footer: @primedark-2; // 3rd Party Colors --tiktok: @black; @@ -286,6 +289,7 @@ --logged-in: @lavender-light; --prime-button: @dark-2; --tooltip-hover: @primelight-4; + --modal-footer: @primedark-2; // 3rd Party Colors --tiktok-inverse: @black; From efc54a80680ed08dfb691df9df0bfddee293d4a6 Mon Sep 17 00:00:00 2001 From: gettinToasty Date: Mon, 16 Dec 2024 13:07:20 -0800 Subject: [PATCH 88/97] Update ai highlighter on tab in and not service init (#5265) * Update ai highlighter on tab in and not service init * Fix test --- app/components-react/pages/Highlighter.tsx | 15 ++++++++++++++- app/services/highlighter/index.ts | 11 +---------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/components-react/pages/Highlighter.tsx b/app/components-react/pages/Highlighter.tsx index e3007af82817..9a2978eeed60 100644 --- a/app/components-react/pages/Highlighter.tsx +++ b/app/components-react/pages/Highlighter.tsx @@ -1,6 +1,6 @@ import SettingsView from 'components-react/highlighter/SettingsView'; import { useVuex } from 'components-react/hooks'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { EHighlighterView, IViewState } from 'services/highlighter'; import { Services } from 'components-react/service-provider'; import StreamView from 'components-react/highlighter/StreamView'; @@ -32,6 +32,19 @@ export default function Highlighter(props: { params?: { view: string } }) { initialViewState = { view: EHighlighterView.SETTINGS }; } + useEffect(() => { + // check if ai highlighter is activated and we need to update it + async function shouldUpdate() { + if (!HighlighterService.aiHighlighterUpdater) return false; + const versionAvailable = await HighlighterService.aiHighlighterUpdater.isNewVersionAvailable(); + return versionAvailable && aiHighlighterEnabled && v.useAiHighlighter; + } + + shouldUpdate().then(shouldUpdate => { + if (shouldUpdate) HighlighterService.actions.startUpdater(); + }); + }, []); + const [viewState, setViewState] = useState(initialViewState); const updaterModal = ( Date: Tue, 17 Dec 2024 17:21:31 -0700 Subject: [PATCH 89/97] graceful shutdown protocol implemented for ai highlighter (#5268) --- .../highlighter/ai-highlighter/ai-highlighter.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/services/highlighter/ai-highlighter/ai-highlighter.ts b/app/services/highlighter/ai-highlighter/ai-highlighter.ts index 467fb919c3ff..58f58c129a68 100644 --- a/app/services/highlighter/ai-highlighter/ai-highlighter.ts +++ b/app/services/highlighter/ai-highlighter/ai-highlighter.ts @@ -4,6 +4,7 @@ import { AiHighlighterUpdater } from './updater'; import { duration } from 'moment'; import { ICoordinates } from '..'; import kill from 'tree-kill'; +import { getOS, OS } from 'util/operating-systems'; export enum EHighlighterInputTypes { KILL = 'kill', @@ -130,7 +131,13 @@ export function getHighlightClips( cancelSignal.addEventListener('abort', () => { console.log('ending highlighter process'); messageBuffer.clear(); - kill(childProcess.pid!, 'SIGINT'); + + // windows doesn't support signals and we have to use the custom graceful shutdown + if (getOS() === OS.Windows) { + childProcess.stdin?.write('quit\n'); + } else { + kill(childProcess.pid!, 'SIGINT'); + } reject(new Error('Highlight generation canceled')); }); } From f0dd9032754049816b143829ec406bedaa7db557 Mon Sep 17 00:00:00 2001 From: gettinToasty Date: Wed, 18 Dec 2024 13:37:32 -0800 Subject: [PATCH 90/97] Revert "graceful shutdown protocol implemented for ai highlighter (#5268)" (#5271) This reverts commit bc3c6a77069d4a255faaf3530dc7e0092cff4224. --- .../highlighter/ai-highlighter/ai-highlighter.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/app/services/highlighter/ai-highlighter/ai-highlighter.ts b/app/services/highlighter/ai-highlighter/ai-highlighter.ts index 58f58c129a68..467fb919c3ff 100644 --- a/app/services/highlighter/ai-highlighter/ai-highlighter.ts +++ b/app/services/highlighter/ai-highlighter/ai-highlighter.ts @@ -4,7 +4,6 @@ import { AiHighlighterUpdater } from './updater'; import { duration } from 'moment'; import { ICoordinates } from '..'; import kill from 'tree-kill'; -import { getOS, OS } from 'util/operating-systems'; export enum EHighlighterInputTypes { KILL = 'kill', @@ -131,13 +130,7 @@ export function getHighlightClips( cancelSignal.addEventListener('abort', () => { console.log('ending highlighter process'); messageBuffer.clear(); - - // windows doesn't support signals and we have to use the custom graceful shutdown - if (getOS() === OS.Windows) { - childProcess.stdin?.write('quit\n'); - } else { - kill(childProcess.pid!, 'SIGINT'); - } + kill(childProcess.pid!, 'SIGINT'); reject(new Error('Highlight generation canceled')); }); } From b14a44888434344ea876f5cdb663cf80519f6cd0 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Tue, 7 Jan 2025 11:02:56 -0500 Subject: [PATCH 91/97] New RTMP target. (#5275) * Add new RTMP target. * Fixes for chat and username. --- app/app-services.ts | 3 + app/components-react/root/LiveDock.tsx | 35 ++++- .../root/ShareStreamLink.m.less | 5 + .../shared/PlatformLogo.m.less | 8 ++ app/components-react/shared/PlatformLogo.tsx | 1 + app/components-react/sidebar/NavTools.m.less | 4 + app/components-react/sidebar/NavTools.tsx | 3 +- .../sidebar/PlatformIndicator.m.less | 2 +- .../sidebar/PlatformIndicator.tsx | 3 +- .../windows/MultistreamChatInfo.tsx | 6 + .../windows/go-live/PlatformSettings.tsx | 2 + .../go-live/platforms/KickEditStreamInfo.tsx | 65 +++++++++ .../platforms/PlatformSettingsLayout.tsx | 2 + .../windows/settings/Stream.tsx | 75 +++++++++-- app/components/shared/PlatformLogo.tsx | 1 + app/i18n/en-US/common.json | 1 + app/i18n/en-US/kick.json | 7 + app/i18n/fallback.ts | 1 + app/services/platforms/index.ts | 11 +- app/services/platforms/kick.ts | 127 ++++++++++++++++++ app/services/restream.ts | 14 +- .../settings/streaming/stream-settings.ts | 2 + app/services/streaming/streaming-api.ts | 3 + app/services/streaming/streaming.ts | 5 + app/services/user/index.ts | 23 ++++ app/services/widgets/settings/event-list.ts | 2 +- app/services/widgets/settings/stream-boss.ts | 2 +- app/styles/buttons.less | 11 ++ app/styles/colors.less | 1 + app/themes.g.less | 2 + media/images/platforms/kick-logo.png | Bin 0 -> 683 bytes 31 files changed, 407 insertions(+), 20 deletions(-) create mode 100644 app/components-react/windows/go-live/platforms/KickEditStreamInfo.tsx create mode 100644 app/i18n/en-US/kick.json create mode 100644 app/services/platforms/kick.ts create mode 100644 media/images/platforms/kick-logo.png diff --git a/app/app-services.ts b/app/app-services.ts index e3513c8c0d2b..df041f8914af 100644 --- a/app/app-services.ts +++ b/app/app-services.ts @@ -78,6 +78,7 @@ export { RestreamService } from 'services/restream'; export { TwitterService } from 'services/integrations/twitter'; export { TwitterPlatformService } from 'services/platforms/twitter'; export { InstagramService } from 'services/platforms/instagram'; +export { KickService } from 'services/platforms/kick'; export { UsageStatisticsService } from './services/usage-statistics'; export { GameOverlayService } from 'services/game-overlay'; export { SharedStorageService } from 'services/integrations/shared-storage'; @@ -205,6 +206,7 @@ import { MarkersService } from 'services/markers'; import { SharedStorageService } from 'services/integrations/shared-storage'; import { RealmService } from 'services/realm'; import { InstagramService } from 'services/platforms/instagram'; +import { KickService } from 'services/platforms/kick'; import { TwitchStudioImporterService } from 'services/ts-importer'; import { RemoteControlService } from 'services/api/remote-control-api'; import { UrlService } from 'services/hosts'; @@ -241,6 +243,7 @@ export const AppServices = { TwitchContentClassificationService, TrovoService, InstagramService, + KickService, DismissablesService, HighlighterService, GrowService, diff --git a/app/components-react/root/LiveDock.tsx b/app/components-react/root/LiveDock.tsx index 279e28b769c5..41da076a33e4 100644 --- a/app/components-react/root/LiveDock.tsx +++ b/app/components-react/root/LiveDock.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import * as remote from '@electron/remote'; import cx from 'classnames'; import Animation from 'rc-animate'; -import { Menu } from 'antd'; +import { Button, Menu } from 'antd'; import pick from 'lodash/pick'; import { initStore, useController } from 'components-react/hooks/zustand'; import { EStreamingState } from 'services/streaming'; @@ -356,6 +356,33 @@ function LiveDock(p: { onLeft: boolean }) { return <>; } + const showKickInfo = + visibleChat === 'kick' || (visibleChat === 'default' && primaryChat === 'kick'); + if (showKickInfo) { + return ( +
    +
    + {$t('Access chat for Kick in the Stream Dashboard.')} +
    + +
    + ); + } + return ( {!hideStyleBlockers && (isPlatform(['twitch', 'trovo']) || - (isStreaming && isPlatform(['youtube', 'facebook', 'twitter', 'tiktok']))) && ( + (isStreaming && + isPlatform(['youtube', 'facebook', 'twitter', 'tiktok', 'kick']))) && (
    {hasChatTabs && } @@ -465,7 +493,8 @@ function LiveDock(p: { onLeft: boolean }) {
    )} {(!ctrl.platform || - (isPlatform(['youtube', 'facebook', 'twitter', 'tiktok']) && !isStreaming)) && ( + (isPlatform(['youtube', 'facebook', 'twitter', 'tiktok', 'kick']) && + !isStreaming)) && (
    {!hideStyleBlockers && {$t('Your chat is currently offline')}} diff --git a/app/components-react/root/ShareStreamLink.m.less b/app/components-react/root/ShareStreamLink.m.less index 8583cf93568b..6425af9eb042 100644 --- a/app/components-react/root/ShareStreamLink.m.less +++ b/app/components-react/root/ShareStreamLink.m.less @@ -12,4 +12,9 @@ width: 16px; height: 16px; } + + :global(i.kick) { + width: 16px; + height: 16px; + } } diff --git a/app/components-react/shared/PlatformLogo.m.less b/app/components-react/shared/PlatformLogo.m.less index 481055810788..3009fb348aa5 100644 --- a/app/components-react/shared/PlatformLogo.m.less +++ b/app/components-react/shared/PlatformLogo.m.less @@ -77,3 +77,11 @@ background-size: contain; background-repeat: no-repeat; } +.kick { + background-image: url('https://slobs-cdn.streamlabs.com/media/kick-logo.png'); + display: inline-block; + width: 40px; + height: 40px; + background-size: contain; + background-repeat: no-repeat; +} diff --git a/app/components-react/shared/PlatformLogo.tsx b/app/components-react/shared/PlatformLogo.tsx index bf63e5d3bddf..6d38ec5bfb57 100644 --- a/app/components-react/shared/PlatformLogo.tsx +++ b/app/components-react/shared/PlatformLogo.tsx @@ -35,6 +35,7 @@ export default function PlatformLogo(p: IProps & HTMLAttributes) { twitter: 'twitter', streamlabs: 'icon-streamlabs', instagram: 'instagram', + kick: 'kick', }[p.platform]; } const size = p.size && (sizeMap[p.size] ?? p.size); diff --git a/app/components-react/sidebar/NavTools.m.less b/app/components-react/sidebar/NavTools.m.less index 944a90cafc39..d12692d51031 100644 --- a/app/components-react/sidebar/NavTools.m.less +++ b/app/components-react/sidebar/NavTools.m.less @@ -156,6 +156,10 @@ width: 13px; height: 13px; } + &-kick { + width: 15px; + height: 15px; + } &-streamlabs { color: var(--teal) !important; } diff --git a/app/components-react/sidebar/NavTools.tsx b/app/components-react/sidebar/NavTools.tsx index fefcf0f32f97..6ef1e8aed826 100644 --- a/app/components-react/sidebar/NavTools.tsx +++ b/app/components-react/sidebar/NavTools.tsx @@ -80,8 +80,9 @@ export default function SideNav() { const throttledOpenDashboard = throttle(openDashboard, 2000, { trailing: false }); // Instagram doesn't provide a username, since we're not really linked, pass undefined for a generic logout msg w/o it + // Kick doesn't provide a username, since we're not really linked, pass undefined for a generic logout msg w/o it const username = - isLoggedIn && UserService.views.auth!.primaryPlatform !== 'instagram' + isLoggedIn && !['instagram', 'kick'].includes(UserService.views.auth!.primaryPlatform) ? UserService.username : undefined; diff --git a/app/components-react/sidebar/PlatformIndicator.m.less b/app/components-react/sidebar/PlatformIndicator.m.less index 035e3f34cd69..a6799b5e3e8d 100644 --- a/app/components-react/sidebar/PlatformIndicator.m.less +++ b/app/components-react/sidebar/PlatformIndicator.m.less @@ -11,7 +11,7 @@ &-facebook { color: var(--facebook) !important; } - &-trovo, &-twitter, &-tiktok, &-instagram, &-youtube { + &-trovo, &-twitter, &-tiktok, &-instagram, &-youtube, &-kick { width: 15px; height: 15px; } diff --git a/app/components-react/sidebar/PlatformIndicator.tsx b/app/components-react/sidebar/PlatformIndicator.tsx index d44cedfa22f6..af10f18e27f9 100644 --- a/app/components-react/sidebar/PlatformIndicator.tsx +++ b/app/components-react/sidebar/PlatformIndicator.tsx @@ -46,7 +46,8 @@ export default function PlatformIndicator({ platform }: IPlatformIndicatorProps) } const SinglePlatformIndicator = ({ platform }: Pick) => { - const username = platform?.type === 'instagram' ? undefined : platform?.username; + const username = + platform?.type === 'instagram' || platform?.type === 'kick' ? undefined : platform?.username; return ( <> diff --git a/app/components-react/windows/MultistreamChatInfo.tsx b/app/components-react/windows/MultistreamChatInfo.tsx index 085dd07dd12f..f56543772328 100644 --- a/app/components-react/windows/MultistreamChatInfo.tsx +++ b/app/components-react/windows/MultistreamChatInfo.tsx @@ -57,6 +57,12 @@ export default function MultistreamChatInfo() { read: false, write: false, }, + { + icon: 'kick', + name: $t('Kick'), + read: true, + write: false, + }, ]; return ( diff --git a/app/components-react/windows/go-live/PlatformSettings.tsx b/app/components-react/windows/go-live/PlatformSettings.tsx index aa0cefef76db..b77aa55928e0 100644 --- a/app/components-react/windows/go-live/PlatformSettings.tsx +++ b/app/components-react/windows/go-live/PlatformSettings.tsx @@ -13,6 +13,7 @@ import { getDefined } from '../../../util/properties-type-guards'; import { TrovoEditStreamInfo } from './platforms/TrovoEditStreamInfo'; import { TwitterEditStreamInfo } from './platforms/TwitterEditStreamInfo'; import { InstagramEditStreamInfo } from './platforms/InstagramEditStreamInfo'; +import { KickEditStreamInfo } from './platforms/KickEditStreamInfo'; import AdvancedSettingsSwitch from './AdvancedSettingsSwitch'; export default function PlatformSettings() { @@ -104,6 +105,7 @@ export default function PlatformSettings() { {platform === 'tiktok' && isTikTokConnected && ( )} + {platform === 'kick' && } {platform === 'trovo' && } {platform === 'twitter' && ( diff --git a/app/components-react/windows/go-live/platforms/KickEditStreamInfo.tsx b/app/components-react/windows/go-live/platforms/KickEditStreamInfo.tsx new file mode 100644 index 000000000000..58184e7f923b --- /dev/null +++ b/app/components-react/windows/go-live/platforms/KickEditStreamInfo.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { $t } from 'services/i18n'; +import Form from '../../../shared/inputs/Form'; +import { createBinding } from '../../../shared/inputs'; +import { IPlatformComponentParams } from './PlatformSettingsLayout'; +import { clipboard } from 'electron'; +import { TextInput } from 'components-react/shared/inputs'; +import { Alert, Button } from 'antd'; + +/** + * Note: The implementation for this component is a light refactor of the InstagramEditStreamInfo component. + */ + +type Props = IPlatformComponentParams<'kick'> & { + isStreamSettingsWindow?: boolean; +}; + +export function KickEditStreamInfo(p: Props) { + const bind = createBinding(p.value, updatedSettings => + p.onChange({ ...p.value, ...updatedSettings }), + ); + + const { isStreamSettingsWindow } = p; + const streamKeyLabel = $t(isStreamSettingsWindow ? 'Stream Key' : 'Kick Stream Key'); + const streamUrlLabel = $t(isStreamSettingsWindow ? 'Stream URL' : 'Kick Stream URL'); + + return ( +
    + } + /> + + } + /> + {!isStreamSettingsWindow && ( + + )} + + ); +} + +function PasteButton({ onPaste }: { onPaste: (text: string) => void }) { + return ( + + ); +} diff --git a/app/components-react/windows/go-live/platforms/PlatformSettingsLayout.tsx b/app/components-react/windows/go-live/platforms/PlatformSettingsLayout.tsx index 1bf753460fa5..0efbbe59e47b 100644 --- a/app/components-react/windows/go-live/platforms/PlatformSettingsLayout.tsx +++ b/app/components-react/windows/go-live/platforms/PlatformSettingsLayout.tsx @@ -5,6 +5,7 @@ import { IYoutubeStartStreamOptions } from '../../../../services/platforms/youtu import { IFacebookStartStreamOptions } from '../../../../services/platforms/facebook'; import { ITikTokStartStreamOptions } from '../../../../services/platforms/tiktok'; import { ITrovoStartStreamOptions } from '../../../../services/platforms/trovo'; +import { IKickStartStreamOptions } from '../../../../services/platforms/kick'; export type TLayoutMode = 'singlePlatform' | 'multiplatformAdvanced' | 'multiplatformSimple'; @@ -36,6 +37,7 @@ export interface IPlatformSettings extends Partial> { facebook?: IFacebookStartStreamOptions; tiktok?: ITikTokStartStreamOptions; trovo?: ITrovoStartStreamOptions; + kick?: IKickStartStreamOptions; } export interface IPlatformComponentParams { diff --git a/app/components-react/windows/settings/Stream.tsx b/app/components-react/windows/settings/Stream.tsx index 5291cac81238..91316fdab51c 100644 --- a/app/components-react/windows/settings/Stream.tsx +++ b/app/components-react/windows/settings/Stream.tsx @@ -19,6 +19,8 @@ import Translate from 'components-react/shared/Translate'; import * as remote from '@electron/remote'; import { InstagramEditStreamInfo } from '../go-live/platforms/InstagramEditStreamInfo'; import { IInstagramStartStreamOptions } from 'services/platforms/instagram'; +import { KickEditStreamInfo } from '../go-live/platforms/KickEditStreamInfo'; +import { IKickStartStreamOptions } from 'services/platforms/kick'; import { metadata } from 'components-react/shared/inputs/metadata'; import FormFactory, { TInputValue } from 'components-react/shared/inputs/FormFactory'; import { alertAsync } from '../../modals'; @@ -411,13 +413,14 @@ function SLIDBlock() { */ function Platform(p: { platform: TPlatform }) { const platform = p.platform; - const { UserService, StreamingService, InstagramService } = Services; + const { UserService, StreamingService, InstagramService, KickService } = Services; const { canEditSettings, platformMergeInline, platformUnlink } = useStreamSettings(); - const { isLoading, authInProgress, instagramSettings } = useVuex(() => ({ + const { isLoading, authInProgress, instagramSettings, kickSettings } = useVuex(() => ({ isLoading: UserService.state.authProcessState === EAuthProcessState.Loading, authInProgress: UserService.state.authProcessState === EAuthProcessState.InProgress, instagramSettings: InstagramService.state.settings, + kickSettings: KickService.state.settings, })); const isMerged = StreamingService.views.isPlatformLinked(platform); @@ -436,7 +439,11 @@ function Platform(p: { platform: TPlatform }) { */ const isInstagram = platform === 'instagram'; const [showInstagramFields, setShowInstagramFields] = useState(isInstagram && isMerged); - const shouldShowUsername = !isInstagram; + + const isKick = platform === 'kick'; + const [showKickFields, setShowKickFields] = useState(isKick && isMerged); + + const shouldShowUsername = !isInstagram && !isKick; const usernameOrBlank = shouldShowUsername ? ( <> @@ -466,13 +473,13 @@ function Platform(p: { platform: TPlatform }) { const ConnectButton = () => (
    ); } + + if (isKick && showKickFields) { + return ( +
    + +
    + ); + } + return null; }; @@ -523,10 +583,7 @@ function Platform(p: { platform: TPlatform }) {
    {shouldShowConnectBtn && } {shouldShowUnlinkBtn && ( - )} diff --git a/app/components/shared/PlatformLogo.tsx b/app/components/shared/PlatformLogo.tsx index 5973a3327733..8ea14242f941 100644 --- a/app/components/shared/PlatformLogo.tsx +++ b/app/components/shared/PlatformLogo.tsx @@ -23,6 +23,7 @@ export default class PlatformLogo extends TsxComponent { trovo: 'trovo', twitter: 'twitter', instagram: 'instagram', + kick: 'kick', }[this.props.platform]; } diff --git a/app/i18n/en-US/common.json b/app/i18n/en-US/common.json index bc231c707383..8933f53c62a8 100644 --- a/app/i18n/en-US/common.json +++ b/app/i18n/en-US/common.json @@ -154,6 +154,7 @@ "Instagram": "Instagram", "Instagram Live": "Instagram Live", "X (Twitter)": "X (Twitter)", + "Kick": "Kick", "Facebook Profiles": "Facebook Profiles", "Facebook Pages": "Facebook Pages", "Alert Box": "Alert Box", diff --git a/app/i18n/en-US/kick.json b/app/i18n/en-US/kick.json new file mode 100644 index 000000000000..14a2a825b5df --- /dev/null +++ b/app/i18n/en-US/kick.json @@ -0,0 +1,7 @@ +{ + "Access chat for Kick in the Stream Dashboard.": "Access chat for Kick in the Stream Dashboard.", + "Open Kick Chat": "Open Kick Chat", + "Kick Stream Key": "Kick Stream Key", + "Kick Stream URL": "Kick Stream URL", + "Remember to open Kick in browser and enter your Stream URL and Key to start streaming!": "Remember to open Kick in browser and enter your Stream URL and Key to start streaming!" +} diff --git a/app/i18n/fallback.ts b/app/i18n/fallback.ts index 221fcff56146..236c040629b8 100644 --- a/app/i18n/fallback.ts +++ b/app/i18n/fallback.ts @@ -65,6 +65,7 @@ const fallbackDictionary = { ...require('./en-US/widget-game.json'), ...require('./en-US/loader.json'), ...require('./en-US/guest-cam.json'), + ...require('./en-US/kick.json'), }; export default fallbackDictionary; diff --git a/app/services/platforms/index.ts b/app/services/platforms/index.ts index a53d9cb1044f..37dc4e966cad 100644 --- a/app/services/platforms/index.ts +++ b/app/services/platforms/index.ts @@ -3,6 +3,7 @@ import { IYoutubeStartStreamOptions, YoutubeService } from './youtube'; import { FacebookService, IFacebookStartStreamOptions } from './facebook'; import { ITikTokStartStreamOptions, TikTokService } from './tiktok'; import { InstagramService, IInstagramStartStreamOptions } from './instagram'; +import { KickService, IKickStartStreamOptions } from './kick'; import { TwitterPlatformService } from './twitter'; import { TTwitchOAuthScope } from './twitch/index'; import { IGoLiveSettings } from 'services/streaming'; @@ -151,7 +152,8 @@ export type TStartStreamOptions = | Partial | Partial | Partial - | Partial; + | Partial + | Partial; // state applicable for all platforms export interface IPlatformState { @@ -242,6 +244,7 @@ export enum EPlatform { Trovo = 'trovo', Twitter = 'twitter', Instagram = 'instagram', + Kick = 'kick', } export type TPlatform = @@ -251,7 +254,8 @@ export type TPlatform = | 'tiktok' | 'trovo' | 'twitter' - | 'instagram'; + | 'instagram' + | 'kick'; export const platformList = [ EPlatform.Facebook, @@ -261,6 +265,7 @@ export const platformList = [ EPlatform.YouTube, EPlatform.Twitter, EPlatform.Instagram, + EPlatform.Kick, ]; export const platformLabels = (platform: TPlatform | string) => @@ -273,6 +278,7 @@ export const platformLabels = (platform: TPlatform | string) => // TODO: translate [EPlatform.Twitter]: 'Twitter', [EPlatform.Instagram]: $t('Instagram'), + [EPlatform.Kick]: $t('Kick'), }[platform]); export function getPlatformService(platform: TPlatform): IPlatformService { @@ -284,6 +290,7 @@ export function getPlatformService(platform: TPlatform): IPlatformService { trovo: TrovoService.instance, twitter: TwitterPlatformService.instance, instagram: InstagramService.instance, + kick: KickService.instance, }[platform]; } diff --git a/app/services/platforms/kick.ts b/app/services/platforms/kick.ts new file mode 100644 index 000000000000..4258916daca6 --- /dev/null +++ b/app/services/platforms/kick.ts @@ -0,0 +1,127 @@ +import { getDefined } from 'util/properties-type-guards'; +import { + IPlatformRequest, + IPlatformService, + IPlatformState, + EPlatformCallResult, + TStartStreamOptions, + IGame, + TPlatformCapability, +} from '.'; +import { BasePlatformService } from './base-platform'; +import { IGoLiveSettings } from 'services/streaming'; +import { TDisplayType } from 'services/settings-v2'; +import { InheritMutations } from 'services/core'; +import { WidgetType } from 'services/widgets'; + +/** + * Note: The implementation for this service is a light refactor of the Instagram service. + */ +interface IKickServiceState extends IPlatformState { + settings: IKickStartStreamSettings; +} + +interface IKickStartStreamSettings { + title: string; + streamUrl: string; + streamKey: string; +} + +export interface IKickStartStreamOptions { + title: string; + streamUrl: string; + streamKey: string; +} + +@InheritMutations() +export class KickService + extends BasePlatformService + implements IPlatformService { + static initialState: IKickServiceState = { + ...BasePlatformService.initialState, + settings: { title: '', streamUrl: '', streamKey: '' }, + }; + + searchGames?: (searchString: string) => Promise; + scheduleStream?: (startTime: number, info: TStartStreamOptions) => Promise; + getHeaders: (req: IPlatformRequest, useToken?: string | boolean) => Dictionary; + streamPageUrl: string; + widgetsWhitelist?: WidgetType[]; + + readonly apiBase = ''; + readonly platform = 'kick'; + readonly displayName = 'Kick'; + readonly capabilities = new Set(['resolutionPreset']); + + readonly authWindowOptions = {}; + readonly authUrl = ''; + + fetchNewToken() { + return Promise.resolve(); + } + + protected init() { + this.syncSettingsWithLocalStorage(); + + this.userService.userLogout.subscribe(() => { + this.updateSettings({ title: this.state.settings.title, streamUrl: '', streamKey: '' }); + }); + } + + async beforeGoLive(goLiveSettings: IGoLiveSettings, context?: TDisplayType) { + const settings = getDefined(goLiveSettings.platforms.kick); + + if (!this.streamingService.views.isMultiplatformMode) { + this.streamSettingsService.setSettings( + { + streamType: 'rtmp_custom', + key: settings.streamKey, + server: settings.streamUrl, + }, + context, + ); + } + + this.SET_STREAM_KEY(settings.streamKey); + this.UPDATE_STREAM_SETTINGS(settings); + this.setPlatformContext('kick'); + } + /** + * prepopulate channel info and save it to the store + */ + async prepopulateInfo(): Promise { + this.SET_PREPOPULATED(true); + } + + async validatePlatform() { + /* + * TODO: this validation isn't needed, but doesn't hurt to be safe, in case we decide to persist + * stream URL as part of "linking". Maybe also validate stream keys, they seem to start with IG-* + */ + if (!this.state.settings.streamKey.length || !this.state.settings.streamUrl.length) { + return EPlatformCallResult.Error; + } + + return EPlatformCallResult.Success; + } + + async putChannelInfo(): Promise { + // N/A + } + + updateSettings(settings: IKickStartStreamOptions) { + this.UPDATE_STREAM_SETTINGS(settings); + } + + unlink() { + this.userService.UNLINK_PLATFORM('kick'); + } + + get liveDockEnabled(): boolean { + return true; + } + + get chatUrl(): string { + return 'https://dashboard.kick.com/stream'; + } +} diff --git a/app/services/restream.ts b/app/services/restream.ts index b7811f255152..3ae9f5ab7a15 100644 --- a/app/services/restream.ts +++ b/app/services/restream.ts @@ -12,6 +12,7 @@ import { StreamingService } from './streaming'; import { FacebookService } from './platforms/facebook'; import { TikTokService } from './platforms/tiktok'; import { TrovoService } from './platforms/trovo'; +import { KickService } from './platforms/kick'; import * as remote from '@electron/remote'; import { VideoSettingsService, TDisplayType } from './settings-v2/video'; import { DualOutputService } from './dual-output'; @@ -56,6 +57,7 @@ export class RestreamService extends StatefulService { @Inject('TikTokService') tiktokService: TikTokService; @Inject() trovoService: TrovoService; @Inject() instagramService: InstagramService; + @Inject() kickService: KickService; @Inject() videoSettingsService: VideoSettingsService; @Inject() dualOutputService: DualOutputService; @Inject('TwitterPlatformService') twitterService: TwitterPlatformService; @@ -309,13 +311,23 @@ export class RestreamService extends StatefulService { // treat instagram as a custom destination const instagramTarget = newTargets.find(t => t.platform === 'instagram'); if (instagramTarget) { - instagramTarget.platform = 'relay'; + instagramTarget.platform = 'relay' as 'relay'; instagramTarget.streamKey = `${this.instagramService.state.settings.streamUrl}${this.instagramService.state.streamKey}`; instagramTarget.mode = isDualOutputMode ? this.dualOutputService.views.getPlatformMode('instagram') : 'landscape'; } + // treat kick as a custom destination + const kickTarget = newTargets.find(t => t.platform === 'kick'); + if (kickTarget) { + kickTarget.platform = 'relay'; + kickTarget.streamKey = `${this.kickService.state.settings.streamUrl}/${this.kickService.state.settings.streamKey}`; + kickTarget.mode = isDualOutputMode + ? this.dualOutputService.views.getPlatformMode('kick') + : 'landscape'; + } + await this.createTargets(newTargets); } diff --git a/app/services/settings/streaming/stream-settings.ts b/app/services/settings/streaming/stream-settings.ts index abd15911d18b..801edae58002 100644 --- a/app/services/settings/streaming/stream-settings.ts +++ b/app/services/settings/streaming/stream-settings.ts @@ -22,6 +22,7 @@ interface ISavedGoLiveSettings { youtube?: IPlatformFlags; trovo?: IPlatformFlags; tiktok?: IPlatformFlags; + kick?: IPlatformFlags; }; customDestinations?: ICustomStreamDestination[]; advancedMode: boolean; @@ -97,6 +98,7 @@ const platformToServiceNameMap: { [key in TPlatform]: string } = { tiktok: 'Custom', twitter: 'Custom', instagram: 'Custom', + kick: 'Custom', }; /** diff --git a/app/services/streaming/streaming-api.ts b/app/services/streaming/streaming-api.ts index c49898d5af15..a7fc92c22acb 100644 --- a/app/services/streaming/streaming-api.ts +++ b/app/services/streaming/streaming-api.ts @@ -10,6 +10,7 @@ import { ITrovoStartStreamOptions } from '../platforms/trovo'; import { IVideo } from 'obs-studio-node'; import { ITwitterStartStreamOptions } from 'services/platforms/twitter'; import { IInstagramStartStreamOptions } from 'services/platforms/instagram'; +import { IKickStartStreamOptions } from 'services/platforms/kick'; import { TDisplayType } from 'services/settings-v2'; export enum EStreamingState { @@ -51,6 +52,7 @@ export interface IStreamInfo { youtube: TGoLiveChecklistItemState; facebook: TGoLiveChecklistItemState; tiktok: TGoLiveChecklistItemState; + kick: TGoLiveChecklistItemState; trovo: TGoLiveChecklistItemState; twitter: TGoLiveChecklistItemState; instagram: TGoLiveChecklistItemState; @@ -71,6 +73,7 @@ export interface IStreamSettings { trovo?: IPlatformFlags & ITrovoStartStreamOptions; twitter?: IPlatformFlags & ITwitterStartStreamOptions; instagram?: IPlatformFlags & IInstagramStartStreamOptions; + kick?: IPlatformFlags & IKickStartStreamOptions; }; customDestinations: ICustomStreamDestination[]; advancedMode: boolean; diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index d57266367d84..8c5ea70d749d 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -139,6 +139,7 @@ export class StreamingService youtube: 'not-started', facebook: 'not-started', tiktok: 'not-started', + kick: 'not-started', trovo: 'not-started', twitter: 'not-started', instagram: 'not-started', @@ -640,6 +641,10 @@ export class StreamingService if (settings.platforms.instagram?.enabled) { this.usageStatisticsService.recordFeatureUsage('StreamToInstagram'); } + + if (settings.platforms.kick?.enabled) { + this.usageStatisticsService.recordFeatureUsage('StreamToKick'); + } } /** diff --git a/app/services/user/index.ts b/app/services/user/index.ts index 727b7292af99..88e55c632812 100644 --- a/app/services/user/index.ts +++ b/app/services/user/index.ts @@ -1287,6 +1287,29 @@ export class UserService extends PersistentStatefulService { return EPlatformCallResult.Success; } + if (platform === 'kick') { + const auth = { + widgetToken: '', + apiToken: '', + primaryPlatform: 'kick' as TPlatform, + platforms: { + kick: { + type: 'kick' as TPlatform, + // HACK: faking kick username + username: 'linked', + token: '', + id: 'kick', + }, + }, + hasRelogged: true, + }; + + this.UPDATE_PLATFORM( + (auth.platforms as Record)[auth.primaryPlatform], + ); + return EPlatformCallResult.Success; + } + this.SET_AUTH_STATE(EAuthProcessState.Loading); const onWindowShow = () => this.SET_AUTH_STATE( diff --git a/app/services/widgets/settings/event-list.ts b/app/services/widgets/settings/event-list.ts index fcebcfd8fc10..df7d5666525f 100644 --- a/app/services/widgets/settings/event-list.ts +++ b/app/services/widgets/settings/event-list.ts @@ -95,7 +95,7 @@ export class EventListService extends WidgetSettingsService { eventsByPlatform(): { key: string; title: string }[] { const platform = this.userService.platform.type as Exclude< TPlatform, - 'tiktok' | 'twitter' | 'instagram' + 'tiktok' | 'twitter' | 'instagram' | 'kick' >; return { twitch: [ diff --git a/app/services/widgets/settings/stream-boss.ts b/app/services/widgets/settings/stream-boss.ts index 2d1100e8ffd2..b66592144b8c 100644 --- a/app/services/widgets/settings/stream-boss.ts +++ b/app/services/widgets/settings/stream-boss.ts @@ -169,7 +169,7 @@ export class StreamBossService extends BaseGoalService; return { twitch: [ diff --git a/app/styles/buttons.less b/app/styles/buttons.less index 78eacf38c3b0..38238b7ccfdf 100644 --- a/app/styles/buttons.less +++ b/app/styles/buttons.less @@ -271,6 +271,17 @@ button { } } +.square-button--kick, +.button--kick { + background: var(--kick); + color: black; + + &:hover, + &:active { + background: var(--kick-hover) !important; + } +} + .button--dlive { background: #ffd300; diff --git a/app/styles/colors.less b/app/styles/colors.less index a944124b7ff7..e4753a133613 100644 --- a/app/styles/colors.less +++ b/app/styles/colors.less @@ -67,3 +67,4 @@ @tiktok: white; @trovo: #19D06D; @instagram: white; +@kick: #54fc1f; diff --git a/app/themes.g.less b/app/themes.g.less index 7fe2d56beb16..91046582f0f9 100644 --- a/app/themes.g.less +++ b/app/themes.g.less @@ -94,6 +94,8 @@ --instagram-hover: lighten(@instagram, 4%); --trovo: @trovo; --trovo-hover: lighten(@trovo, 4%); + --kick: @kick; + --kick-hover: lighten(@kick, 4%); } .night-theme { diff --git a/media/images/platforms/kick-logo.png b/media/images/platforms/kick-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d29fa0dadfd63943183959c17df65665bec2c2b5 GIT binary patch literal 683 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q4M;wBd$farfvL#T#WAFU@$KD>xzdp$4j2FX ziY_SFI`K~DHs(WFDcdKlJ;I&Ks(19vlQVh;j+nC^U&b4IVj25$7GqB3bF6U&{PoW) z9{>A%cK7ACd4Jmac7OeqSZC1_|Dycg|9N}%R?WHp`|N7Q#s&`-7bOKD#)%vqE)FV8 zN`eBNs6ye*`Bk@`M;71ddmi=qSBYe_+5+Qwk7jDKZ24F*Pa+YU79>+9on2S_rtkCp z-;d|dciVXE+4ss-y6>6a9{=)`SsWCKR(3yB4}@vy!^BF!#fIy)jR9@@2|(-a?~BOo7kXty{>%D z?~gI@{o9M3B&Yjg4IgAT{C0@mdhG4&$7g>F{y8De&$=b?v&cpK?w+K(-=|ihxK8S* z)bGnrt3LS7=6$gv`oGPYhKD%iqFU(uOanz|mH? Date: Tue, 7 Jan 2025 10:08:57 -0800 Subject: [PATCH 92/97] fix(widgets): infinite load on Media Share and Emote Wall (#5273) * fix: emote wall widget and custom code/fields * fix(widgets): media share widget infinite load --- app/components-react/widgets/common/useWidget.tsx | 2 +- app/components/widgets/WidgetSettings.vue.ts | 1 + app/services/widgets/settings/emote-wall.ts | 4 ++-- app/services/widgets/settings/widget-settings.ts | 2 +- app/services/widgets/widgets-config.ts | 4 ++-- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/components-react/widgets/common/useWidget.tsx b/app/components-react/widgets/common/useWidget.tsx index 6c8e95c349dc..5c35d9b35d98 100644 --- a/app/components-react/widgets/common/useWidget.tsx +++ b/app/components-react/widgets/common/useWidget.tsx @@ -293,7 +293,7 @@ export class WidgetModule { ]); this.setStaticConfig(staticConfig); - if (staticConfig) { + if (staticConfig?.data?.custom_code) { // I miss lenses const makeLenses = (type: 'html' | 'css' | 'js') => { const prop = `custom_${type}`; diff --git a/app/components/widgets/WidgetSettings.vue.ts b/app/components/widgets/WidgetSettings.vue.ts index 35b207f45aec..d3cbbe9286a6 100644 --- a/app/components/widgets/WidgetSettings.vue.ts +++ b/app/components/widgets/WidgetSettings.vue.ts @@ -59,6 +59,7 @@ export default class WidgetSettings< this.requestState = 'success'; this.afterFetch(); } catch (e: unknown) { + console.error('Something failed on widget settings fetch', e); this.requestState = 'fail'; } } diff --git a/app/services/widgets/settings/emote-wall.ts b/app/services/widgets/settings/emote-wall.ts index af163a68c2d6..8a03b70709a1 100644 --- a/app/services/widgets/settings/emote-wall.ts +++ b/app/services/widgets/settings/emote-wall.ts @@ -36,8 +36,8 @@ export class EmoteWallService extends WidgetSettingsService { dataFetchUrl: `https://${this.getHost()}/api/v5/slobs/widget/emote-wall`, settingsSaveUrl: `https://${this.getHost()}/api/v5/slobs/widget/emote-wall`, settingsUpdateEvent: 'emoteWallSettingsUpdate', - customCodeAllowed: true, - customFieldsAllowed: true, + customCodeAllowed: false, + customFieldsAllowed: false, hasTestButtons: true, }; } diff --git a/app/services/widgets/settings/widget-settings.ts b/app/services/widgets/settings/widget-settings.ts index 89dc4453dda3..ff07eb8c4844 100644 --- a/app/services/widgets/settings/widget-settings.ts +++ b/app/services/widgets/settings/widget-settings.ts @@ -140,7 +140,7 @@ export abstract class WidgetSettingsService // TODO: type const { staticConfig }: any = this.state; - if (staticConfig) { + if (staticConfig?.data?.custom_code) { // These seem only used to restore defaults data.custom_defaults = staticConfig.data?.custom_code; // If we have a default for custom code and the fields are empty in the diff --git a/app/services/widgets/widgets-config.ts b/app/services/widgets/widgets-config.ts index 6e0fbba6822b..6152dd4cd608 100644 --- a/app/services/widgets/widgets-config.ts +++ b/app/services/widgets/widgets-config.ts @@ -150,8 +150,8 @@ export function getWidgetsConfig( dataFetchUrl: `https://${host}/api/v5/slobs/widget/emote-wall`, settingsSaveUrl: `https://${host}/api/v5/slobs/widget/emote-wall`, settingsUpdateEvent: 'emoteWallSettingsUpdate', - customCodeAllowed: true, - customFieldsAllowed: true, + customCodeAllowed: false, + customFieldsAllowed: false, }, // TODO: From f5cd8fdb227623f6ac8d4cc6e60153a3acd04dcf Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Tue, 7 Jan 2025 10:09:35 -0800 Subject: [PATCH 93/97] fix(settings): import from OBS from settings used Twitch Studio (#5274) ref: https://app.asana.com/0/home/1207748179368689/1209093209493865 --- app/components-react/windows/settings/General.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/components-react/windows/settings/General.tsx b/app/components-react/windows/settings/General.tsx index 78133a984286..584b3fe5c760 100644 --- a/app/components-react/windows/settings/General.tsx +++ b/app/components-react/windows/settings/General.tsx @@ -99,6 +99,8 @@ function ExtraSettings() { } function importFromObs() { + // TODO: there's no check that OBS is installed like in Onboarding + OnboardingService.actions.setImport('obs'); OnboardingService.actions.start({ isImport: true }); WindowsService.actions.closeChildWindow(); } From 4eb9f992929efdfeffbac69755f0a72096170345 Mon Sep 17 00:00:00 2001 From: ggolda <38298010+ggolda@users.noreply.github.com> Date: Wed, 8 Jan 2025 11:30:56 -0700 Subject: [PATCH 94/97] qa bundle flag now downloads staging highlighter builds (#5277) --- app/services/highlighter/ai-highlighter/updater.ts | 10 +++++++--- app/services/utils.ts | 5 +++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/services/highlighter/ai-highlighter/updater.ts b/app/services/highlighter/ai-highlighter/updater.ts index e61630bf9737..d23048e08adc 100644 --- a/app/services/highlighter/ai-highlighter/updater.ts +++ b/app/services/highlighter/ai-highlighter/updater.ts @@ -109,9 +109,10 @@ export class AiHighlighterUpdater { */ private getManifestUrl(): string { if (Utils.getHighlighterEnvironment() === 'staging') { - return 'https://cdn-highlighter-builds.streamlabs.com/staging/manifest_win_x86_64.json'; + const cacheBuster = Math.floor(Date.now() / 1000); + return `https://cdn-highlighter-builds.streamlabs.com/staging/manifest_win_x86_64.json?t=${cacheBuster}`; } else { - return 'https://cdn-highlighter-builds.streamlabs.com/manifest_win_x86_64.json'; + return 'https://cdn-highlighter-builds.streamlabs.com/production/manifest_win_x86_64.json'; } } /** @@ -141,7 +142,10 @@ export class AiHighlighterUpdater { await fs.readFile(this.manifestPath, 'utf-8'), ) as IAIHighlighterManifest; - if (newManifest.version !== currentManifest.version) { + if ( + newManifest.version !== currentManifest.version || + newManifest.timestamp > currentManifest.timestamp + ) { console.log( `new highlighter version available. ${currentManifest.version} -> ${newManifest.version}`, ); diff --git a/app/services/utils.ts b/app/services/utils.ts index 9365f21bec6e..f255af2bdb77 100644 --- a/app/services/utils.ts +++ b/app/services/utils.ts @@ -101,7 +101,12 @@ export default class Utils { static isDevMode() { return Utils.env.NODE_ENV !== 'production'; } + static getHighlighterEnvironment(): 'production' | 'staging' | 'local' { + if (process.argv.includes('--bundle-qa')) { + return 'staging'; + } + if (process.env.HIGHLIGHTER_ENV !== 'staging' && process.env.HIGHLIGHTER_ENV !== 'local') { return 'production'; } From 0211d5b59e18c38164eb4e49542553915134710d Mon Sep 17 00:00:00 2001 From: marvinoffers Date: Wed, 8 Jan 2025 19:31:11 +0100 Subject: [PATCH 95/97] add recording started (#5272) --- app/services/highlighter/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/services/highlighter/index.ts b/app/services/highlighter/index.ts index 28a93fd12ee3..7b8f337bc76a 100644 --- a/app/services/highlighter/index.ts +++ b/app/services/highlighter/index.ts @@ -813,6 +813,10 @@ export class HighlighterService extends PersistentStatefulService Date: Wed, 8 Jan 2025 13:27:05 -0700 Subject: [PATCH 96/97] fixed qa bundle flag check (#5279) --- app/services/utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/services/utils.ts b/app/services/utils.ts index f255af2bdb77..898ca94b29f6 100644 --- a/app/services/utils.ts +++ b/app/services/utils.ts @@ -103,7 +103,9 @@ export default class Utils { } static getHighlighterEnvironment(): 'production' | 'staging' | 'local' { - if (process.argv.includes('--bundle-qa')) { + // need to use this remote thing because main process is being spawned as + // subprocess of updater process in the release build + if (remote.process.argv.includes('--bundle-qa')) { return 'staging'; } From d6d2966696cbf33be5ead49544d850ebace2996b Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Fri, 10 Jan 2025 19:29:00 -0500 Subject: [PATCH 97/97] Refactor for flow (#5281) * Revert "New RTMP target. (#5275)" This reverts commit b14a44888434344ea876f5cdb663cf80519f6cd0. * Added new target. * Add test and error handling. * WIP: title banner * Go live polishes. * Fix broadcast id and test. --- app/app-services.ts | 6 +- .../pages/onboarding/Connect.tsx | 10 +- .../onboarding/PrimaryPlatformSelect.tsx | 7 +- app/components-react/root/LiveDock.tsx | 37 +-- app/components-react/sidebar/NavTools.tsx | 3 +- .../sidebar/PlatformIndicator.m.less | 7 +- .../sidebar/PlatformIndicator.tsx | 3 +- .../windows/go-live/CommonPlatformFields.tsx | 19 +- .../windows/go-live/GoLiveChecklist.m.less | 1 + .../go-live/platforms/KickEditStreamInfo.tsx | 79 ++--- .../windows/settings/Stream.tsx | 75 +---- app/components/shared/PlatformLogo.m.less | 9 +- app/components/shared/PlatformLogo.tsx | 2 +- app/i18n/en-US/kick.json | 6 +- app/services/platforms/index.ts | 4 +- app/services/platforms/kick.ts | 301 ++++++++++++++---- app/services/restream.ts | 6 +- app/services/streaming/streaming-api.ts | 8 +- app/services/streaming/streaming.ts | 6 +- app/services/user/index.ts | 33 +- app/styles/colors.less | 2 +- test/data/dummy-accounts.ts | 24 +- test/regular/streaming/kick.ts | 32 ++ 23 files changed, 401 insertions(+), 279 deletions(-) create mode 100644 test/regular/streaming/kick.ts diff --git a/app/app-services.ts b/app/app-services.ts index df041f8914af..5f9777cd7560 100644 --- a/app/app-services.ts +++ b/app/app-services.ts @@ -74,11 +74,11 @@ export { export { FacebookService } from 'services/platforms/facebook'; export { TikTokService } from 'services/platforms/tiktok'; export { TrovoService } from 'services/platforms/trovo'; +export { KickService } from 'services/platforms/kick'; export { RestreamService } from 'services/restream'; export { TwitterService } from 'services/integrations/twitter'; export { TwitterPlatformService } from 'services/platforms/twitter'; export { InstagramService } from 'services/platforms/instagram'; -export { KickService } from 'services/platforms/kick'; export { UsageStatisticsService } from './services/usage-statistics'; export { GameOverlayService } from 'services/game-overlay'; export { SharedStorageService } from 'services/integrations/shared-storage'; @@ -206,10 +206,10 @@ import { MarkersService } from 'services/markers'; import { SharedStorageService } from 'services/integrations/shared-storage'; import { RealmService } from 'services/realm'; import { InstagramService } from 'services/platforms/instagram'; -import { KickService } from 'services/platforms/kick'; import { TwitchStudioImporterService } from 'services/ts-importer'; import { RemoteControlService } from 'services/api/remote-control-api'; import { UrlService } from 'services/hosts'; +import { KickService } from 'services/platforms/kick'; export const AppServices = { AppService, @@ -242,8 +242,8 @@ export const AppServices = { TwitchTagsService, TwitchContentClassificationService, TrovoService, - InstagramService, KickService, + InstagramService, DismissablesService, HighlighterService, GrowService, diff --git a/app/components-react/pages/onboarding/Connect.tsx b/app/components-react/pages/onboarding/Connect.tsx index d70690d6d088..f65c62f050ba 100644 --- a/app/components-react/pages/onboarding/Connect.tsx +++ b/app/components-react/pages/onboarding/Connect.tsx @@ -63,7 +63,7 @@ export function Connect() { // streamlabs and trovo are added separarely on markup below const platforms = RecordingModeService.views.isRecordingModeEnabled ? ['youtube'] - : ['twitch', 'youtube', 'facebook', 'twitter', 'tiktok']; + : ['twitch', 'youtube', 'tiktok', 'kick', 'facebook', 'twitter']; const shouldAddTrovo = !RecordingModeService.views.isRecordingModeEnabled; @@ -132,7 +132,9 @@ export function Connect() { loading={loading} onClick={() => authPlatform(platform, afterLogin)} key={platform} - logoSize={['twitter', 'tiktok', 'youtube'].includes(platform) ? 15 : undefined} + logoSize={ + ['twitter', 'tiktok', 'youtube', 'kick'].includes(platform) ? 15 : undefined + } > %{platform}', { @@ -263,7 +265,9 @@ export class LoginModule { const result = await this.UserService.startAuth( platform, - ['youtube', 'twitch', 'twitter', 'tiktok'].includes(platform) ? 'external' : 'internal', + ['youtube', 'twitch', 'twitter', 'tiktok', 'kick'].includes(platform) + ? 'external' + : 'internal', merge, ); diff --git a/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx b/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx index c6fb8d9a1451..37ea80031882 100644 --- a/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx +++ b/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx @@ -22,7 +22,7 @@ export function PrimaryPlatformSelect() { isPrime: UserService.state.isPrime, })); const { loading, authInProgress, authPlatform, finishSLAuth } = useModule(LoginModule); - const platforms = ['twitch', 'youtube', 'facebook', 'twitter', 'tiktok', 'trovo']; + const platforms = ['twitch', 'youtube', 'tiktok', 'kick', 'facebook', 'twitter', 'trovo']; const platformOptions = [ { value: 'twitch', @@ -54,6 +54,11 @@ export function PrimaryPlatformSelect() { label: 'TikTok', image: , }, + { + value: 'kick', + label: 'Kick', + image: , + }, ].filter(opt => { return linkedPlatforms.includes(opt.value as TPlatform); }); diff --git a/app/components-react/root/LiveDock.tsx b/app/components-react/root/LiveDock.tsx index 41da076a33e4..278d5a82e618 100644 --- a/app/components-react/root/LiveDock.tsx +++ b/app/components-react/root/LiveDock.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import * as remote from '@electron/remote'; import cx from 'classnames'; import Animation from 'rc-animate'; -import { Button, Menu } from 'antd'; +import { Menu } from 'antd'; import pick from 'lodash/pick'; import { initStore, useController } from 'components-react/hooks/zustand'; import { EStreamingState } from 'services/streaming'; @@ -27,6 +27,7 @@ class LiveDockController { private youtubeService = Services.YoutubeService; private facebookService = Services.FacebookService; private trovoService = Services.TrovoService; + private kickService = Services.KickService; private tiktokService = Services.TikTokService; private userService = Services.UserService; private customizationService = Services.CustomizationService; @@ -159,6 +160,7 @@ class LiveDockController { // Twitter & Tiktok don't support editing title after going live if (this.isPlatform('twitter') && !this.isRestreaming) return false; if (this.isPlatform('tiktok') && !this.isRestreaming) return false; + if (this.isPlatform('kick') && !this.isRestreaming) return false; return ( this.streamingService.views.isMidStreamMode || @@ -181,6 +183,7 @@ class LiveDockController { if (this.platform === 'youtube') url = this.youtubeService.streamPageUrl; if (this.platform === 'facebook') url = this.facebookService.streamPageUrl; if (this.platform === 'trovo') url = this.trovoService.streamPageUrl; + if (this.platform === 'kick') url = this.kickService.streamPageUrl; if (this.platform === 'tiktok') url = this.tiktokService.streamPageUrl; remote.shell.openExternal(url); } @@ -190,6 +193,7 @@ class LiveDockController { if (this.platform === 'youtube') url = this.youtubeService.dashboardUrl; if (this.platform === 'facebook') url = this.facebookService.streamDashboardUrl; if (this.platform === 'tiktok') url = this.tiktokService.dashboardUrl; + if (this.platform === 'kick') url = this.kickService.dashboardUrl; remote.shell.openExternal(url); } @@ -356,33 +360,6 @@ function LiveDock(p: { onLeft: boolean }) { return <>; } - const showKickInfo = - visibleChat === 'kick' || (visibleChat === 'default' && primaryChat === 'kick'); - if (showKickInfo) { - return ( -
    -
    - {$t('Access chat for Kick in the Stream Dashboard.')} -
    - -
    - ); - } - return ( ctrl.showEditStreamInfo()} className="icon-edit" /> )} - {isPlatform(['youtube', 'facebook', 'trovo', 'tiktok']) && isStreaming && ( + {isPlatform(['youtube', 'facebook', 'trovo', 'tiktok', 'kick']) && isStreaming && (
    - {(isPlatform(['twitch', 'trovo', 'facebook']) || + {(isPlatform(['twitch', 'trovo', 'facebook', 'kick']) || (isPlatform(['youtube', 'twitter']) && isStreaming) || (isPlatform(['tiktok']) && isRestreaming)) && ( ctrl.refreshChat()}>{$t('Refresh Chat')} diff --git a/app/components-react/sidebar/NavTools.tsx b/app/components-react/sidebar/NavTools.tsx index 6ef1e8aed826..fefcf0f32f97 100644 --- a/app/components-react/sidebar/NavTools.tsx +++ b/app/components-react/sidebar/NavTools.tsx @@ -80,9 +80,8 @@ export default function SideNav() { const throttledOpenDashboard = throttle(openDashboard, 2000, { trailing: false }); // Instagram doesn't provide a username, since we're not really linked, pass undefined for a generic logout msg w/o it - // Kick doesn't provide a username, since we're not really linked, pass undefined for a generic logout msg w/o it const username = - isLoggedIn && !['instagram', 'kick'].includes(UserService.views.auth!.primaryPlatform) + isLoggedIn && UserService.views.auth!.primaryPlatform !== 'instagram' ? UserService.username : undefined; diff --git a/app/components-react/sidebar/PlatformIndicator.m.less b/app/components-react/sidebar/PlatformIndicator.m.less index a6799b5e3e8d..d7372cb02fae 100644 --- a/app/components-react/sidebar/PlatformIndicator.m.less +++ b/app/components-react/sidebar/PlatformIndicator.m.less @@ -11,7 +11,12 @@ &-facebook { color: var(--facebook) !important; } - &-trovo, &-twitter, &-tiktok, &-instagram, &-youtube, &-kick { + &-trovo, + &-twitter, + &-tiktok, + &-instagram, + &-youtube, + &-kick { width: 15px; height: 15px; } diff --git a/app/components-react/sidebar/PlatformIndicator.tsx b/app/components-react/sidebar/PlatformIndicator.tsx index af10f18e27f9..d44cedfa22f6 100644 --- a/app/components-react/sidebar/PlatformIndicator.tsx +++ b/app/components-react/sidebar/PlatformIndicator.tsx @@ -46,8 +46,7 @@ export default function PlatformIndicator({ platform }: IPlatformIndicatorProps) } const SinglePlatformIndicator = ({ platform }: Pick) => { - const username = - platform?.type === 'instagram' || platform?.type === 'kick' ? undefined : platform?.username; + const username = platform?.type === 'instagram' ? undefined : platform?.username; return ( <> diff --git a/app/components-react/windows/go-live/CommonPlatformFields.tsx b/app/components-react/windows/go-live/CommonPlatformFields.tsx index d76d2d4f014a..56ee9afeee2d 100644 --- a/app/components-react/windows/go-live/CommonPlatformFields.tsx +++ b/app/components-react/windows/go-live/CommonPlatformFields.tsx @@ -1,6 +1,6 @@ import { TPlatform } from '../../../services/platforms'; import { $t } from '../../../services/i18n'; -import React from 'react'; +import React, { useMemo } from 'react'; import { CheckboxInput, InputComponent, TextAreaInput, TextInput } from '../../shared/inputs'; import { assertIsDefined } from '../../../util/properties-type-guards'; import InputWrapper from '../../shared/inputs/InputWrapper'; @@ -90,6 +90,18 @@ export const CommonPlatformFields = InputComponent((rawProps: IProps) => { maxCharacters = 140; } + const titleTooltip = useMemo(() => { + if (enabledPlatforms.includes('tiktok')) { + return $t('Only 32 characters of your title will display on TikTok'); + } + + if (enabledPlatforms.length === 1 && p?.platform === 'kick') { + return $t('Edit your stream title on Kick after going live.'); + } + + return undefined; + }, [enabledPlatforms]); + return (
    {/* USE CUSTOM CHECKBOX */} @@ -115,10 +127,7 @@ export const CommonPlatformFields = InputComponent((rawProps: IProps) => { label={$t('Title')} required={true} max={maxCharacters} - tooltip={ - enabledPlatforms.includes('tiktok') && - $t('Only 32 characters of your title will display on TikTok') - } + tooltip={titleTooltip} /> {/*DESCRIPTION*/} diff --git a/app/components-react/windows/go-live/GoLiveChecklist.m.less b/app/components-react/windows/go-live/GoLiveChecklist.m.less index a42fe4763bb0..1efbcd9069e4 100644 --- a/app/components-react/windows/go-live/GoLiveChecklist.m.less +++ b/app/components-react/windows/go-live/GoLiveChecklist.m.less @@ -6,6 +6,7 @@ align-items: center; justify-content: center; height: 100%; + margin: 0px 20px; } // make timeline icons and text bigger diff --git a/app/components-react/windows/go-live/platforms/KickEditStreamInfo.tsx b/app/components-react/windows/go-live/platforms/KickEditStreamInfo.tsx index 58184e7f923b..25a0722b75e4 100644 --- a/app/components-react/windows/go-live/platforms/KickEditStreamInfo.tsx +++ b/app/components-react/windows/go-live/platforms/KickEditStreamInfo.tsx @@ -1,65 +1,36 @@ import React from 'react'; -import { $t } from 'services/i18n'; +import { CommonPlatformFields } from '../CommonPlatformFields'; import Form from '../../../shared/inputs/Form'; -import { createBinding } from '../../../shared/inputs'; -import { IPlatformComponentParams } from './PlatformSettingsLayout'; -import { clipboard } from 'electron'; -import { TextInput } from 'components-react/shared/inputs'; -import { Alert, Button } from 'antd'; +import { createBinding, InputComponent } from '../../../shared/inputs'; +import PlatformSettingsLayout, { IPlatformComponentParams } from './PlatformSettingsLayout'; +import { IKickStartStreamOptions } from '../../../../services/platforms/kick'; -/** - * Note: The implementation for this component is a light refactor of the InstagramEditStreamInfo component. +/*** + * Stream Settings for Kick */ +export const KickEditStreamInfo = InputComponent((p: IPlatformComponentParams<'kick'>) => { + function updateSettings(patch: Partial) { + p.onChange({ ...kickSettings, ...patch }); + } -type Props = IPlatformComponentParams<'kick'> & { - isStreamSettingsWindow?: boolean; -}; - -export function KickEditStreamInfo(p: Props) { - const bind = createBinding(p.value, updatedSettings => - p.onChange({ ...p.value, ...updatedSettings }), - ); - - const { isStreamSettingsWindow } = p; - const streamKeyLabel = $t(isStreamSettingsWindow ? 'Stream Key' : 'Kick Stream Key'); - const streamUrlLabel = $t(isStreamSettingsWindow ? 'Stream URL' : 'Kick Stream URL'); + const kickSettings = p.value; + const bind = createBinding(kickSettings, newKickSettings => updateSettings(newKickSettings)); return (
    - } - /> - - } + + } + requiredFields={
    } /> - {!isStreamSettingsWindow && ( - - )} ); -} - -function PasteButton({ onPaste }: { onPaste: (text: string) => void }) { - return ( - - ); -} +}); diff --git a/app/components-react/windows/settings/Stream.tsx b/app/components-react/windows/settings/Stream.tsx index 91316fdab51c..67c1fa05e58f 100644 --- a/app/components-react/windows/settings/Stream.tsx +++ b/app/components-react/windows/settings/Stream.tsx @@ -19,8 +19,6 @@ import Translate from 'components-react/shared/Translate'; import * as remote from '@electron/remote'; import { InstagramEditStreamInfo } from '../go-live/platforms/InstagramEditStreamInfo'; import { IInstagramStartStreamOptions } from 'services/platforms/instagram'; -import { KickEditStreamInfo } from '../go-live/platforms/KickEditStreamInfo'; -import { IKickStartStreamOptions } from 'services/platforms/kick'; import { metadata } from 'components-react/shared/inputs/metadata'; import FormFactory, { TInputValue } from 'components-react/shared/inputs/FormFactory'; import { alertAsync } from '../../modals'; @@ -219,7 +217,7 @@ class StreamSettingsModule { } async platformMergeInline(platform: TPlatform) { - const mode = ['youtube', 'twitch', 'twitter', 'tiktok'].includes(platform) + const mode = ['youtube', 'twitch', 'twitter', 'tiktok', 'kick'].includes(platform) ? 'external' : 'internal'; @@ -413,14 +411,13 @@ function SLIDBlock() { */ function Platform(p: { platform: TPlatform }) { const platform = p.platform; - const { UserService, StreamingService, InstagramService, KickService } = Services; + const { UserService, StreamingService, InstagramService } = Services; const { canEditSettings, platformMergeInline, platformUnlink } = useStreamSettings(); - const { isLoading, authInProgress, instagramSettings, kickSettings } = useVuex(() => ({ + const { isLoading, authInProgress, instagramSettings } = useVuex(() => ({ isLoading: UserService.state.authProcessState === EAuthProcessState.Loading, authInProgress: UserService.state.authProcessState === EAuthProcessState.InProgress, instagramSettings: InstagramService.state.settings, - kickSettings: KickService.state.settings, })); const isMerged = StreamingService.views.isPlatformLinked(platform); @@ -439,11 +436,7 @@ function Platform(p: { platform: TPlatform }) { */ const isInstagram = platform === 'instagram'; const [showInstagramFields, setShowInstagramFields] = useState(isInstagram && isMerged); - - const isKick = platform === 'kick'; - const [showKickFields, setShowKickFields] = useState(isKick && isMerged); - - const shouldShowUsername = !isInstagram && !isKick; + const shouldShowUsername = !isInstagram; const usernameOrBlank = shouldShowUsername ? ( <> @@ -473,7 +466,7 @@ function Platform(p: { platform: TPlatform }) { const ConnectButton = () => (
    ); } - - if (isKick && showKickFields) { - return ( -
    - -
    - ); - } - return null; }; @@ -583,7 +523,10 @@ function Platform(p: { platform: TPlatform }) {
    {shouldShowConnectBtn && } {shouldShowUnlinkBtn && ( - )} diff --git a/app/components/shared/PlatformLogo.m.less b/app/components/shared/PlatformLogo.m.less index b82d7edd2005..57afedab6ace 100644 --- a/app/components/shared/PlatformLogo.m.less +++ b/app/components/shared/PlatformLogo.m.less @@ -36,4 +36,11 @@ background-size: contain; background-repeat: no-repeat; } - +.kick { + background-image: url('https://slobs-cdn.streamlabs.com/media/kick-logo.png'); + display: inline-block; + width: 40px; + height: 40px; + background-size: contain; + background-repeat: no-repeat; +} diff --git a/app/components/shared/PlatformLogo.tsx b/app/components/shared/PlatformLogo.tsx index 8ea14242f941..a7a1bf7e4814 100644 --- a/app/components/shared/PlatformLogo.tsx +++ b/app/components/shared/PlatformLogo.tsx @@ -21,9 +21,9 @@ export default class PlatformLogo extends TsxComponent { nimotv: 'nimotv', streamlabs: 'icon-streamlabs', trovo: 'trovo', + kick: 'kick', twitter: 'twitter', instagram: 'instagram', - kick: 'kick', }[this.props.platform]; } diff --git a/app/i18n/en-US/kick.json b/app/i18n/en-US/kick.json index 14a2a825b5df..37b7dc85eb43 100644 --- a/app/i18n/en-US/kick.json +++ b/app/i18n/en-US/kick.json @@ -1,7 +1,3 @@ { - "Access chat for Kick in the Stream Dashboard.": "Access chat for Kick in the Stream Dashboard.", - "Open Kick Chat": "Open Kick Chat", - "Kick Stream Key": "Kick Stream Key", - "Kick Stream URL": "Kick Stream URL", - "Remember to open Kick in browser and enter your Stream URL and Key to start streaming!": "Remember to open Kick in browser and enter your Stream URL and Key to start streaming!" + "Edit your stream title on Kick after going live.": "Edit your stream title on Kick after going live." } diff --git a/app/services/platforms/index.ts b/app/services/platforms/index.ts index 37dc4e966cad..6040680f4924 100644 --- a/app/services/platforms/index.ts +++ b/app/services/platforms/index.ts @@ -3,7 +3,6 @@ import { IYoutubeStartStreamOptions, YoutubeService } from './youtube'; import { FacebookService, IFacebookStartStreamOptions } from './facebook'; import { ITikTokStartStreamOptions, TikTokService } from './tiktok'; import { InstagramService, IInstagramStartStreamOptions } from './instagram'; -import { KickService, IKickStartStreamOptions } from './kick'; import { TwitterPlatformService } from './twitter'; import { TTwitchOAuthScope } from './twitch/index'; import { IGoLiveSettings } from 'services/streaming'; @@ -11,6 +10,7 @@ import { WidgetType } from '../widgets'; import { ITrovoStartStreamOptions, TrovoService } from './trovo'; import { TDisplayType } from 'services/settings-v2'; import { $t } from 'services/i18n'; +import { KickService, IKickStartStreamOptions } from './kick'; export type Tag = string; export interface IGame { @@ -288,9 +288,9 @@ export function getPlatformService(platform: TPlatform): IPlatformService { facebook: FacebookService.instance, tiktok: TikTokService.instance, trovo: TrovoService.instance, + kick: KickService.instance, twitter: TwitterPlatformService.instance, instagram: InstagramService.instance, - kick: KickService.instance, }[platform]; } diff --git a/app/services/platforms/kick.ts b/app/services/platforms/kick.ts index 4258916daca6..81667f59ad61 100644 --- a/app/services/platforms/kick.ts +++ b/app/services/platforms/kick.ts @@ -1,36 +1,62 @@ -import { getDefined } from 'util/properties-type-guards'; -import { - IPlatformRequest, - IPlatformService, - IPlatformState, - EPlatformCallResult, - TStartStreamOptions, - IGame, - TPlatformCapability, -} from '.'; +import { InheritMutations, Inject, mutation } from '../core'; import { BasePlatformService } from './base-platform'; -import { IGoLiveSettings } from 'services/streaming'; +import { IPlatformRequest, IPlatformService, IPlatformState, TPlatformCapability } from './index'; +import { authorizedHeaders, jfetch } from '../../util/requests'; +import { throwStreamError } from '../streaming/stream-error'; +import { platformAuthorizedRequest } from './utils'; +import { IGoLiveSettings } from '../streaming'; +import { TOutputOrientation } from 'services/restream'; +import { IVideo } from 'obs-studio-node'; import { TDisplayType } from 'services/settings-v2'; -import { InheritMutations } from 'services/core'; -import { WidgetType } from 'services/widgets'; +import { I18nService } from 'services/i18n'; +import { getDefined } from 'util/properties-type-guards'; +import { WindowsService } from 'services/windows'; +import { DiagnosticsService } from 'services/diagnostics'; -/** - * Note: The implementation for this service is a light refactor of the Instagram service. - */ +interface IKickStartStreamResponse { + id?: string; + key: string; + rtmp: string; + chat_url: string; + broadcast_id?: string | null; + channel_name: string; + platform_id: string; + region?: string; + chat_id?: string; +} +interface IKickEndStreamResponse { + id: string; +} + +interface IKickError { + success: boolean; + error: boolean; + message: string; + data: any[]; +} interface IKickServiceState extends IPlatformState { settings: IKickStartStreamSettings; + ingest: string; + chatUrl: string; + channelName: string; + platformId?: string; } interface IKickStartStreamSettings { title: string; - streamUrl: string; - streamKey: string; + display: TDisplayType; + video?: IVideo; + mode?: TOutputOrientation; } export interface IKickStartStreamOptions { title: string; - streamUrl: string; - streamKey: string; +} + +interface IKickRequestHeaders extends Dictionary { + Accept: string; + 'Content-Type': string; + Authorization: string; } @InheritMutations() @@ -39,53 +65,165 @@ export class KickService implements IPlatformService { static initialState: IKickServiceState = { ...BasePlatformService.initialState, - settings: { title: '', streamUrl: '', streamKey: '' }, + settings: { + title: '', + display: 'horizontal', + mode: 'landscape', + }, + ingest: '', + chatUrl: '', + channelName: '', }; - searchGames?: (searchString: string) => Promise; - scheduleStream?: (startTime: number, info: TStartStreamOptions) => Promise; - getHeaders: (req: IPlatformRequest, useToken?: string | boolean) => Dictionary; - streamPageUrl: string; - widgetsWhitelist?: WidgetType[]; + @Inject() windowsService: WindowsService; + @Inject() diagnosticsService: DiagnosticsService; readonly apiBase = ''; + readonly domain = 'https://kick.com'; readonly platform = 'kick'; readonly displayName = 'Kick'; - readonly capabilities = new Set(['resolutionPreset']); + readonly capabilities = new Set(['chat']); - readonly authWindowOptions = {}; - readonly authUrl = ''; + authWindowOptions: Electron.BrowserWindowConstructorOptions = { + width: 600, + height: 800, + }; - fetchNewToken() { - return Promise.resolve(); + private get oauthToken() { + return this.userService.views.state.auth?.platforms?.kick?.token; } - protected init() { - this.syncSettingsWithLocalStorage(); + async beforeGoLive(goLiveSettings: IGoLiveSettings, display?: TDisplayType) { + const kickSettings = getDefined(goLiveSettings.platforms.kick); + const context = display ?? kickSettings?.display; - this.userService.userLogout.subscribe(() => { - this.updateSettings({ title: this.state.settings.title, streamUrl: '', streamKey: '' }); - }); + try { + const streamInfo = await this.startStream( + goLiveSettings.platforms.kick ?? this.state.settings, + ); + + this.SET_INGEST(streamInfo.rtmp); + this.SET_STREAM_KEY(streamInfo.key); + this.SET_CHAT_URL(streamInfo.chat_url); + this.SET_PLATFORM_ID(streamInfo.platform_id); + + if (!this.streamingService.views.isMultiplatformMode) { + this.streamSettingsService.setSettings( + { + streamType: 'rtmp_custom', + key: streamInfo.key, + server: streamInfo.rtmp, + }, + context, + ); + } + + await this.putChannelInfo(kickSettings); + this.setPlatformContext('kick'); + } catch (e: unknown) { + console.error('Error starting stream: ', e); + throwStreamError('PLATFORM_REQUEST_FAILED', e as any); + } + } + + async afterStopStream(): Promise { + // clear server url and stream key + this.SET_INGEST(''); + this.SET_STREAM_KEY(''); } - async beforeGoLive(goLiveSettings: IGoLiveSettings, context?: TDisplayType) { - const settings = getDefined(goLiveSettings.platforms.kick); + // Note, this needs to be here but should never be called, because we + // currently don't make any calls directly to Kick + async fetchNewToken(): Promise { + const host = this.hostsService.streamlabs; + const url = `https://${host}/api/v5/slobs/kick/refresh`; + const headers = authorizedHeaders(this.userService.apiToken!); + const request = new Request(url, { headers }); - if (!this.streamingService.views.isMultiplatformMode) { - this.streamSettingsService.setSettings( - { - streamType: 'rtmp_custom', - key: settings.streamKey, - server: settings.streamUrl, - }, - context, - ); + return jfetch<{ access_token: string }>(request) + .then(response => { + return this.userService.updatePlatformToken('kick', response.access_token); + }) + .catch(e => { + console.error('Error fetching new token.'); + return Promise.reject(e); + }); + } + + /** + * Request Kick API and wrap failed response to a unified error model + */ + async requestKick(reqInfo: IPlatformRequest | string): Promise { + try { + return await platformAuthorizedRequest('kick', reqInfo); + } catch (e: unknown) { + const code = (e as any).result?.error?.code; + + const details = (e as any).result?.error + ? `${(e as any).result.error.type} ${(e as any).result.error.message}` + : 'Connection failed'; + + console.error('Error fetching Kick API: ', details, code); + + return Promise.reject(e); } + } - this.SET_STREAM_KEY(settings.streamKey); - this.UPDATE_STREAM_SETTINGS(settings); - this.setPlatformContext('kick'); + /** + * Starts the stream + * @remark If a user is live and attempts to go live via another + * another streaming method such as Kick's app, this stream will continue + * and the other stream will be prevented from going live. If another instance + * of Streamlabs attempts to go live to Kick, the first stream will be ended + * and Desktop will enter a reconnecting state, which eventually times out. + */ + async startStream(opts: IKickStartStreamOptions): Promise { + const host = this.hostsService.streamlabs; + const url = `https://${host}/api/v5/slobs/kick/stream/start`; + const headers = authorizedHeaders(this.userService.apiToken!); + + const body = new FormData(); + body.append('title', opts.title); + + const request = new Request(url, { headers, method: 'POST', body }); + + return jfetch(request).catch((e: IKickError | unknown) => { + console.error('Error starting Kick stream: ', e); + + const defaultError = { + status: 403, + statusText: 'Unable to start Kick stream.', + }; + + if (!e) throwStreamError('PLATFORM_REQUEST_FAILED', defaultError); + + // check if the error is an IKickError + if (typeof e === 'object' && e.hasOwnProperty('success')) { + const error = e as IKickError; + throwStreamError( + 'PLATFORM_REQUEST_FAILED', + { + ...error, + status: 403, + statusText: error.message, + }, + defaultError.statusText, + ); + } + + throwStreamError('PLATFORM_REQUEST_FAILED', e as any, defaultError.statusText); + }); + } + + async endStream(id: string) { + const host = this.hostsService.streamlabs; + const url = `https://${host}/api/v5/slobs/kick/stream/${id}/end`; + const headers = authorizedHeaders(this.userService.apiToken!); + const request = new Request(url, { headers, method: 'POST' }); + + return jfetch(request); } + /** * prepopulate channel info and save it to the store */ @@ -93,28 +231,27 @@ export class KickService this.SET_PREPOPULATED(true); } - async validatePlatform() { - /* - * TODO: this validation isn't needed, but doesn't hurt to be safe, in case we decide to persist - * stream URL as part of "linking". Maybe also validate stream keys, they seem to start with IG-* - */ - if (!this.state.settings.streamKey.length || !this.state.settings.streamUrl.length) { - return EPlatformCallResult.Error; - } - - return EPlatformCallResult.Success; + async putChannelInfo(settings: IKickStartStreamOptions): Promise { + this.SET_STREAM_SETTINGS(settings); } - async putChannelInfo(): Promise { - // N/A + getHeaders(req: IPlatformRequest, useToken?: string | boolean): IKickRequestHeaders { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.oauthToken}`, + }; } - updateSettings(settings: IKickStartStreamOptions) { - this.UPDATE_STREAM_SETTINGS(settings); + get authUrl() { + const host = this.hostsService.streamlabs; + const query = `_=${Date.now()}&skip_splash=true&external=electron&kick&force_verify&origin=slobs`; + return `https://${host}/slobs/login?${query}`; } - unlink() { - this.userService.UNLINK_PLATFORM('kick'); + get mergeUrl(): string { + const host = this.hostsService.streamlabs; + return `https://${host}/dashboard#/settings/account-settings/platforms`; } get liveDockEnabled(): boolean { @@ -122,6 +259,36 @@ export class KickService } get chatUrl(): string { - return 'https://dashboard.kick.com/stream'; + return this.state.chatUrl; + } + + get dashboardUrl(): string { + return `https://dashboard.${this.domain.split('//')[1]}/stream`; + } + + get streamPageUrl(): string { + const username = this.userService.state.auth?.platforms?.kick?.username; + if (!username) return ''; + + return `${this.domain}/${username}`; + } + + get locale(): string { + return I18nService.instance.state.locale; + } + + @mutation() + SET_INGEST(ingest: string) { + this.state.ingest = ingest; + } + + @mutation() + SET_CHAT_URL(chatUrl: string) { + this.state.chatUrl = chatUrl; + } + + @mutation() + SET_PLATFORM_ID(platformId: string) { + this.state.platformId = platformId; } } diff --git a/app/services/restream.ts b/app/services/restream.ts index 3ae9f5ab7a15..aeaf4024f761 100644 --- a/app/services/restream.ts +++ b/app/services/restream.ts @@ -56,8 +56,8 @@ export class RestreamService extends StatefulService { @Inject() facebookService: FacebookService; @Inject('TikTokService') tiktokService: TikTokService; @Inject() trovoService: TrovoService; - @Inject() instagramService: InstagramService; @Inject() kickService: KickService; + @Inject() instagramService: InstagramService; @Inject() videoSettingsService: VideoSettingsService; @Inject() dualOutputService: DualOutputService; @Inject('TwitterPlatformService') twitterService: TwitterPlatformService; @@ -311,7 +311,7 @@ export class RestreamService extends StatefulService { // treat instagram as a custom destination const instagramTarget = newTargets.find(t => t.platform === 'instagram'); if (instagramTarget) { - instagramTarget.platform = 'relay' as 'relay'; + instagramTarget.platform = 'relay'; instagramTarget.streamKey = `${this.instagramService.state.settings.streamUrl}${this.instagramService.state.streamKey}`; instagramTarget.mode = isDualOutputMode ? this.dualOutputService.views.getPlatformMode('instagram') @@ -322,7 +322,7 @@ export class RestreamService extends StatefulService { const kickTarget = newTargets.find(t => t.platform === 'kick'); if (kickTarget) { kickTarget.platform = 'relay'; - kickTarget.streamKey = `${this.kickService.state.settings.streamUrl}/${this.kickService.state.settings.streamKey}`; + kickTarget.streamKey = `${this.kickService.state.ingest}/${this.kickService.state.streamKey}`; kickTarget.mode = isDualOutputMode ? this.dualOutputService.views.getPlatformMode('kick') : 'landscape'; diff --git a/app/services/streaming/streaming-api.ts b/app/services/streaming/streaming-api.ts index a7fc92c22acb..9baec256eb06 100644 --- a/app/services/streaming/streaming-api.ts +++ b/app/services/streaming/streaming-api.ts @@ -7,10 +7,10 @@ import { IStreamError } from './stream-error'; import { ICustomStreamDestination } from '../settings/streaming'; import { ITikTokStartStreamOptions } from '../platforms/tiktok'; import { ITrovoStartStreamOptions } from '../platforms/trovo'; -import { IVideo } from 'obs-studio-node'; +import { IKickStartStreamOptions } from 'services/platforms/kick'; import { ITwitterStartStreamOptions } from 'services/platforms/twitter'; import { IInstagramStartStreamOptions } from 'services/platforms/instagram'; -import { IKickStartStreamOptions } from 'services/platforms/kick'; +import { IVideo } from 'obs-studio-node'; import { TDisplayType } from 'services/settings-v2'; export enum EStreamingState { @@ -52,8 +52,8 @@ export interface IStreamInfo { youtube: TGoLiveChecklistItemState; facebook: TGoLiveChecklistItemState; tiktok: TGoLiveChecklistItemState; - kick: TGoLiveChecklistItemState; trovo: TGoLiveChecklistItemState; + kick: TGoLiveChecklistItemState; twitter: TGoLiveChecklistItemState; instagram: TGoLiveChecklistItemState; setupMultistream: TGoLiveChecklistItemState; @@ -71,9 +71,9 @@ export interface IStreamSettings { facebook?: IPlatformFlags & IFacebookStartStreamOptions; tiktok?: IPlatformFlags & ITikTokStartStreamOptions; trovo?: IPlatformFlags & ITrovoStartStreamOptions; + kick?: IPlatformFlags & IKickStartStreamOptions; twitter?: IPlatformFlags & ITwitterStartStreamOptions; instagram?: IPlatformFlags & IInstagramStartStreamOptions; - kick?: IPlatformFlags & IKickStartStreamOptions; }; customDestinations: ICustomStreamDestination[]; advancedMode: boolean; diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 8c5ea70d749d..4b2ddc9f471e 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -139,8 +139,8 @@ export class StreamingService youtube: 'not-started', facebook: 'not-started', tiktok: 'not-started', - kick: 'not-started', trovo: 'not-started', + kick: 'not-started', twitter: 'not-started', instagram: 'not-started', setupMultistream: 'not-started', @@ -641,10 +641,6 @@ export class StreamingService if (settings.platforms.instagram?.enabled) { this.usageStatisticsService.recordFeatureUsage('StreamToInstagram'); } - - if (settings.platforms.kick?.enabled) { - this.usageStatisticsService.recordFeatureUsage('StreamToKick'); - } } /** diff --git a/app/services/user/index.ts b/app/services/user/index.ts index 88e55c632812..bffd6772184b 100644 --- a/app/services/user/index.ts +++ b/app/services/user/index.ts @@ -116,6 +116,7 @@ interface ILinkedPlatformsResponse { youtube_account?: ILinkedPlatform; tiktok_account?: ILinkedPlatform; trovo_account?: ILinkedPlatform; + kick_account?: ILinkedPlatform; streamlabs_account?: ILinkedPlatform; twitter_account?: ILinkedPlatform; user_id: number; @@ -753,6 +754,17 @@ export class UserService extends PersistentStatefulService { this.UNLINK_PLATFORM('trovo'); } + if (linkedPlatforms.kick_account) { + this.UPDATE_PLATFORM({ + type: 'kick', + username: linkedPlatforms.kick_account.platform_name, + id: linkedPlatforms.kick_account.platform_id, + token: linkedPlatforms.kick_account.access_token, + }); + } else if (this.state.auth.primaryPlatform !== 'kick') { + this.UNLINK_PLATFORM('kick'); + } + if (linkedPlatforms.streamlabs_account) { this.SET_SLID({ id: linkedPlatforms.streamlabs_account.platform_id, @@ -1283,27 +1295,6 @@ export class UserService extends PersistentStatefulService { hasRelogged: true, }; - this.UPDATE_PLATFORM(auth.platforms[auth.primaryPlatform]); - return EPlatformCallResult.Success; - } - - if (platform === 'kick') { - const auth = { - widgetToken: '', - apiToken: '', - primaryPlatform: 'kick' as TPlatform, - platforms: { - kick: { - type: 'kick' as TPlatform, - // HACK: faking kick username - username: 'linked', - token: '', - id: 'kick', - }, - }, - hasRelogged: true, - }; - this.UPDATE_PLATFORM( (auth.platforms as Record)[auth.primaryPlatform], ); diff --git a/app/styles/colors.less b/app/styles/colors.less index e4753a133613..a40a26acf00b 100644 --- a/app/styles/colors.less +++ b/app/styles/colors.less @@ -66,5 +66,5 @@ @twitter: #1DA1F2; @tiktok: white; @trovo: #19D06D; +@kick: #54FC1F; @instagram: white; -@kick: #54fc1f; diff --git a/test/data/dummy-accounts.ts b/test/data/dummy-accounts.ts index 05c030ca3489..1b66c02f2b13 100644 --- a/test/data/dummy-accounts.ts +++ b/test/data/dummy-accounts.ts @@ -3,7 +3,7 @@ import { ITestUser } from '../helpers/webdriver/user'; import { TPlatform } from 'services/platforms'; // update this list for platforms that use dummy user accounts for tests -const platforms = ['twitter', 'instagram', 'tiktok'] as const; +const platforms = ['twitter', 'instagram', 'tiktok', 'kick'] as const; type DummyUserPlatforms = typeof platforms; export type TTestDummyUserPlatforms = DummyUserPlatforms[number]; @@ -105,7 +105,7 @@ export const instagramUser1: IDummyTestUser = { }; /** - * Twitter + * X (Twitter) */ export const twitterUser1: IDummyTestUser = { @@ -122,6 +122,24 @@ export const twitterUser1: IDummyTestUser = { widgetToken: 'twitterWidgetToken1', }; +/** + * Kick + */ + +export const kickUser1: IDummyTestUser = { + email: 'kickUser1@email.com', + workerId: 'kickWorkerId1', + updated: 'kickUpdatedId1', + username: 'kickUser1', + type: 'kick', + id: 'kickId1', + token: 'kickToken1', + apiToken: 'kickApiToken1', + ingest: 'rtmps://kickIngestUrl:443/rtmp/', + streamKey: 'kickStreamKey1', + widgetToken: 'kickWidgetToken1', +}; + /** * Check if platform should use a dummy account with tests * @param platform platform for login @@ -146,6 +164,8 @@ export function getDummyUser( if (platform === 'twitter') return twitterUser1; + if (platform === 'kick') return kickUser1; + if (platform === 'tiktok') { switch (tikTokLiveScope) { case 'approved': diff --git a/test/regular/streaming/kick.ts b/test/regular/streaming/kick.ts new file mode 100644 index 000000000000..4cd0ad503e11 --- /dev/null +++ b/test/regular/streaming/kick.ts @@ -0,0 +1,32 @@ +import { skipCheckingErrorsInLog, test, useWebdriver } from '../../helpers/webdriver'; +import { + clickGoLive, + prepareToGoLive, + stopStream, + submit, + waitForSettingsWindowLoaded, + waitForStreamStart, +} from '../../helpers/modules/streaming'; +import { addDummyAccount, withUser } from '../../helpers/webdriver/user'; +import { fillForm } from '../../helpers/modules/forms'; +import { isDisplayed, waitForDisplayed } from '../../helpers/modules/core'; + +// not a react hook +// eslint-disable-next-line react-hooks/rules-of-hooks +useWebdriver(); + +test('Streaming to Kick', withUser('twitch', { multistream: true }), async t => { + await addDummyAccount('kick'); + + await prepareToGoLive(); + await clickGoLive(); + await waitForSettingsWindowLoaded(); + + // because streaming cannot be tested, check that Kick can be toggled on + await fillForm({ + kick: true, + }); + await waitForSettingsWindowLoaded(); + + t.pass(); +});