diff --git a/.vscode/settings.json b/.vscode/settings.json index f7d8dd02ff36..6223912691c2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,24 @@ { "typescript.tsdk": "./node_modules/typescript/lib", "i18n-ally.localesPaths": ["app/i18n"], - "i18n-ally.keystyle": "nested" + "i18n-ally.keystyle": "nested", + "workbench.colorCustomizations": { + "terminal.border": "#ffffff", + "panel.border": "#ffffff", + "panelTitle.activeBorder": "#ffc600", + "terminal.tab.activeBorder": "#ffc600", + "terminalCursor.foreground": "#ffc600", + "terminal.selectionBackground": "#ffc60055", + "activityBar.background": "#053241", + "titleBar.activeBackground": "#07465B", + "titleBar.activeForeground": "#F3FBFE", + "titleBar.inactiveBackground": "#053241", + "titleBar.inactiveForeground": "#F3FBFE", + "statusBar.background": "#053241", + "statusBar.foreground": "#F3FBFE", + "statusBar.debuggingBackground": "#053241", + "statusBar.debuggingForeground": "#F3FBFE", + "statusBar.noFolderBackground": "#053241", + "statusBar.noFolderForeground": "#F3FBFE" + } } diff --git a/app/app-services.ts b/app/app-services.ts index 0954d9d1b5d8..fc7a44c4e4a9 100644 --- a/app/app-services.ts +++ b/app/app-services.ts @@ -20,6 +20,7 @@ export { ShortcutsService } from 'services/shortcuts'; export { CustomizationService } from 'services/customization'; export { LayoutService } from 'services/layout'; export { NotificationsService } from 'services/notifications'; +export { OnboardingService } from 'services/onboarding'; export { NavigationService } from 'services/navigation'; export { PerformanceService } from 'services/performance'; export { SettingsService, OutputSettingsService, EncoderQueryService } from 'services/settings'; @@ -165,6 +166,7 @@ import { MediaBackupService } from './services/media-backup'; import { HotkeysService } from './services/hotkeys'; import { WidgetsService } from './services/widgets'; import { HostsService } from './services/hosts'; +import { OnboardingService } from './services/onboarding'; import { CacheUploaderService } from './services/cache-uploader'; import { StreamlabelsService } from './services/streamlabels'; import { SceneCollectionsService } from './services/scene-collections'; @@ -260,6 +262,7 @@ export const AppServices = { HotkeysService, WidgetsService, HostsService, + OnboardingService, CacheUploaderService, StreamlabelsService, SceneCollectionsService, diff --git a/app/components-react/highlighter/Export/StorageUpload.tsx b/app/components-react/highlighter/Export/StorageUpload.tsx index e9c644f2ae07..c4fdee948368 100644 --- a/app/components-react/highlighter/Export/StorageUpload.tsx +++ b/app/components-react/highlighter/Export/StorageUpload.tsx @@ -110,7 +110,7 @@ export default function StorageUpload(p: { onClose: () => void; platform: EUploa } export function GetSLID(p: { onLogin?: () => void }) { - const { UserService, OnboardingV2Service } = Services; + const { UserService, OnboardingService } = Services; async function clickLink(signup?: boolean) { let resp: EPlatformCallResult; @@ -126,7 +126,7 @@ export function GetSLID(p: { onLogin?: () => void }) { if (platform) { UserService.actions.setPrimaryPlatform(platform); } else { - OnboardingV2Service.actions.showLogin(); + OnboardingService.actions.start({ isLogin: true }); } if (p.onLogin) p.onLogin(); } diff --git a/app/components-react/modals/onboarding/Themes.tsx b/app/components-react/modals/onboarding/Themes.tsx index 9372e5ecd376..5c059e53ee3b 100644 --- a/app/components-react/modals/onboarding/Themes.tsx +++ b/app/components-react/modals/onboarding/Themes.tsx @@ -52,7 +52,13 @@ const THEME_MAP = { }; export function Themes(p: IOnboardingStepProps) { - const { OnboardingV2Service, SceneCollectionsService, NavigationService, UserService } = Services; + const { + OnboardingV2Service, + OnboardingService, + SceneCollectionsService, + NavigationService, + UserService, + } = Services; const [installing, setInstalling] = useState(false); const [progress, setProgress] = useState(0); @@ -78,7 +84,7 @@ export function Themes(p: IOnboardingStepProps) { if (themeMetadata.current) return; Promise.all( idList.map(id => { - return OnboardingV2Service.actions.return.fetchThemeData(id); + return OnboardingService.actions.return.fetchThemeData(id); }), ) .then(metadata => { diff --git a/app/components-react/pages/index.ts b/app/components-react/pages/index.ts index 55c5f59fc83d..27347ffd5644 100644 --- a/app/components-react/pages/index.ts +++ b/app/components-react/pages/index.ts @@ -4,6 +4,7 @@ export { default as BrowseOverlays } from './BrowseOverlays'; export { default as Highlighter } from './Highlighter'; export { default as LayoutEditor } from './layout-editor/LayoutEditor'; export { default as Loader } from './Loader'; +export { default as Onboarding } from './onboarding/Onboarding'; export { default as PatchNotes } from './PatchNotes'; export { default as PlatformAppMainPage } from './PlatformAppMainPage'; export { default as PlatformAppStore } from './PlatformAppStore'; diff --git a/app/components-react/pages/onboarding/Common.m.less b/app/components-react/pages/onboarding/Common.m.less new file mode 100644 index 000000000000..055412ead968 --- /dev/null +++ b/app/components-react/pages/onboarding/Common.m.less @@ -0,0 +1,60 @@ +.title-container { + width: 100%; + text-align: center; + font-size: 52px; + + h1 { + display: inline-block; + font-size: 52px; + } + + margin-bottom: 40px; + // margin-top: 144px; +} + +.subtitle-container { + display: flex; + justify-content: center; + margin-bottom: 35px; +} + +.option-card { + position: relative; + background: var(--teal); + padding: 16px; + overflow: hidden; + height: 80px; + border-radius: 40px; + display: flex; + justify-content: center; + align-items: center; + width: 280px; + border: none; + color: var(--action-button-text); + + &:hover { + cursor: pointer; + } + + h2 { + font-size: 20px; + margin-bottom: 0; + } + + svg, + i { + position: absolute; + bottom: -50%; + right: 0; + opacity: 0.25; + } + + i { + color: #000; + } +} + +.onboarding-button { + padding: 12px 24px !important; + line-height: normal !important; +} diff --git a/app/components-react/pages/onboarding/Connect.m.less b/app/components-react/pages/onboarding/Connect.m.less new file mode 100644 index 000000000000..7c980e595400 --- /dev/null +++ b/app/components-react/pages/onboarding/Connect.m.less @@ -0,0 +1,192 @@ +@import '../../../styles/index'; + +.page-container { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 1; + + :global(.twitter) { + height: 16px; + } +} + +.container { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; + z-index: 1; + + h1 { + margin-bottom: 8px; + } + + :global(.list-input), + :global(.section) { + width: 400px; + } + + @media (max-height: 600px) { + + &>h1, + &>p { + display: none; + } + } +} + +.select-another { + .margin-v-sides(2); +} + +.progress-cover { + position: absolute; + background: var(--background); + width: 700px; + height: 32px; + top: -30px; + left: -16px; + z-index: 10; +} + +.signup-buttons { + 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 { + height: 80px; + width: 80px; + border-radius: 40px !important; + display: flex; + justify-content: center; + align-items: center; + margin: 0 16px !important; + flex-shrink: 0; + line-height: 0px !important; + + i { + font-size: 40px; + margin: 0 !important; + } +} + +.footer { + position: absolute; + display: flex; + justify-content: flex-end; + left: 0; + right: 0; + bottom: 0; + // TODO: fix once we add pagination container + margin: 80px; +} + +.svg-backgrounds { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + width: 100%; + height: 100%; + + #oval-right { + position: absolute; + top: 0; + right: 0; + } + + #oval-left { + position: absolute; + bottom: 0; + left: 0; + } +} + +:global(#oval-left) { + position: absolute; + bottom: 0; + left: 0; +} + +:global(#oval-right) { + position: absolute; + top: 0; + right: 0; +} + +.streamlabs-platform-container { + margin-bottom: 55px; +} + +.third-party-platforms { + width: 100%; + + p { + text-align: center; + } +} + +.streamlabs-platform-container, +.third-party-platforms-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + row-gap: 20px; + width: 100%; + + button { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: var(--border); + + :global(.fa-spinner) { + margin-right: 32px; + } + } +} + +.extra-platforms-container { + display: flex; + flex-direction: row; + column-gap: 20px; + margin-top: 20px; + justify-content: center; +} + +.signup-link-container { + display: flex; + align-items: center; + justify-content: center; + margin-top: 20px; + + a { + &, + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } +} diff --git a/app/components-react/pages/onboarding/Connect.tsx b/app/components-react/pages/onboarding/Connect.tsx new file mode 100644 index 000000000000..4704aa42528e --- /dev/null +++ b/app/components-react/pages/onboarding/Connect.tsx @@ -0,0 +1,309 @@ +import React, { useState, useContext } from 'react'; +import styles from './Connect.m.less'; +import commonStyles from './Common.m.less'; +import { $t } from 'services/i18n'; +import { Services } from 'components-react/service-provider'; +import { useModule } from 'slap'; +import { ExtraPlatformConnect } from './ExtraPlatformConnect'; +import { + EPlatform, + EPlatformCallResult, + TPlatform, + externalAuthPlatforms, + platformLabels, +} from 'services/platforms'; +import * as remote from '@electron/remote'; +import { OnboardingModule } from './Onboarding'; +import { EAuthProcessState } from 'services/user'; +import Signup from './Signup'; +import { SkipContext } from './OnboardingContext'; +import PlatformButton, { PlatformIconButton } from 'components-react/shared/PlatformButton'; +import Translate from 'components-react/shared/Translate'; + +export function Connect() { + const ctx = useContext(SkipContext); + + const { isRelog, loading, authInProgress, authPlatform } = useModule(LoginModule); + const { next, isLogin } = useModule(OnboardingModule); + // If we come from login singleton view, default to false + const [isSignup, setIsSignup] = useState(() => !isLogin); + const [selectedExtraPlatform, setExtraPlatform] = useState(undefined); + const { UsageStatisticsService, OnboardingService, RecordingModeService } = Services; + + if (selectedExtraPlatform) { + return ( + + ); + } + + function onSkip() { + if (loading || authInProgress) { + return; + } + + next(); + + // Do not run default skip + return false; + } + + ctx.onSkip = onSkip; + + function onSelectExtraPlatform(val: TExtraPlatform | undefined) { + UsageStatisticsService.recordAnalyticsEvent('PlatformLogin', val); + setExtraPlatform(val); + } + + function afterLogin() { + OnboardingService.actions.setExistingCollections(); + /* Since this is the only component that uses custom skip step, might be fine + * do this this reset default here but revisit if this ever changes + */ + ctx.onSkip = () => true; + next(); + } + + const platforms: TPlatform[] = RecordingModeService.views.isRecordingModeEnabled + ? ['youtube'] + : ['twitch', 'youtube', 'tiktok', 'kick', 'facebook']; + + const extraPlatforms: { + value: TExtraPlatform; + label: string; + image: string; + }[] = [ + { + value: 'nimotv', + label: 'NimoTV', + image: require('../../../../media/images/platforms/nimo-logo-small.png'), + }, + ]; + + const title = isSignup ? $t('Sign Up') : $t('Log In'); + + return ( +
+
+

{title}

+ {isSignup ? ( + setIsSignup(false)} onSuccess={afterLogin} /> + ) : ( + <> + {!isRelog &&

{$t('Log in with email/password')}

} + {isRelog && ( +

+ {$t( + 'Your login has expired. Please reauthenticate to continue using Streamlabs Desktop.', + )} +

+ )} +
+ authPlatform('streamlabs', afterLogin)} + > + %{platform}', { + platform: 'Streamlabs ID', + })} + > + + + +
+ +
+

{$t('Log in with a platform')}

+ +
+ {platforms.map((platform: TPlatform) => ( + authPlatform(platform, afterLogin)} + key={platform} + logoSize={ + ['twitter', 'tiktok', 'youtube', 'kick', 'patreon'].includes(platform) + ? 15 + : undefined + } + > + %{platform}', { + platform: platformLabels(platform), + })} + > + + + + ))} +
+
+ authPlatform(EPlatform.Twitter, afterLogin)} + title={$t('Log in with %{platform}', { + platform: platformLabels(EPlatform.Twitter), + })} + style={{ marginLeft: '15px' }} + /> + {extraPlatforms.map(platform => ( + onSelectExtraPlatform(platform.value)} + title={$t('Log in with %{platform}', { + platform: platform.label, + })} + /> + ))} +
+
+ + + )} +
+
+ + +
+
+ ); +} + +const SVGOvalRightBackground = () => ( + + + +); + +const SVGOvalLeftBackground = () => ( + + + +); + +type TExtraPlatform = 'nimotv'; + +export class LoginModule { + get UserService() { + return Services.UserService; + } + + get UsageStatisticsService() { + return Services.UsageStatisticsService; + } + + get isRelog() { + return this.UserService.state.isRelog; + } + + get loading() { + return this.UserService.state.authProcessState === EAuthProcessState.Loading; + } + + get authInProgress() { + return this.UserService.state.authProcessState === EAuthProcessState.InProgress; + } + + get isPartialSLAuth() { + return this.UserService.views.isPartialSLAuth; + } + + async authPlatform(platform: TPlatform | 'streamlabs', onSuccess: () => void, merge = false) { + this.UsageStatisticsService.recordAnalyticsEvent('PlatformLogin', platform); + + if (platform === 'streamlabs') { + await this.UserService.startSLAuth() + .then((success: EPlatformCallResult) => { + if (success !== EPlatformCallResult.Success) return; + onSuccess(); + }) + .catch(e => console.error('Onboarding Authentication Error: ', e)); + + return; + } + + const result = await this.UserService.startAuth( + platform, + externalAuthPlatforms.includes(platform) ? 'external' : 'internal', + merge, + ); + + if (result === EPlatformCallResult.TwitchTwoFactor) { + remote.dialog + .showMessageBox({ + type: 'error', + message: $t( + 'Twitch requires two factor authentication to be enabled on your account in order to stream to Twitch. Please enable two factor authentication and try again.', + ), + title: $t('Twitch Authentication Error'), + buttons: [$t('Enable Two Factor Authentication'), $t('Dismiss')], + }) + .then(({ response }) => { + if (response === 0) { + remote.shell.openExternal('https://twitch.tv/settings/security'); + } + }); + } else { + // Currently we do not have special handling for generic errors + onSuccess(); + } + } + + async finishSLAuth(primaryPlatform?: TPlatform) { + const result = await this.UserService.finishSLAuth(primaryPlatform); + + if (result === EPlatformCallResult.TwitchScopeMissing) { + await remote.dialog.showMessageBox(remote.getCurrentWindow(), { + type: 'warning', + message: $t( + 'Streamlabs requires additional permissions from your Twitch account. Please log in with Twitch to continue.', + ), + title: 'Twitch Error', + buttons: [$t('Refresh Login')], + }); + + // Initiate a Twitch merge to get permissions + await this.authPlatform('twitch', () => {}, true); + } + + return result; + } +} diff --git a/app/components-react/pages/onboarding/ExtraPlatformConnect.tsx b/app/components-react/pages/onboarding/ExtraPlatformConnect.tsx new file mode 100644 index 000000000000..48b39de581e5 --- /dev/null +++ b/app/components-react/pages/onboarding/ExtraPlatformConnect.tsx @@ -0,0 +1,98 @@ +import { useModule } from 'slap'; +import PlatformLogo from 'components-react/shared/PlatformLogo'; +import React, { useState } from 'react'; +import { $t } from 'services/i18n'; +import { LoginModule } from './Connect'; +import styles from './Connect.m.less'; +import * as remote from '@electron/remote'; +import { TextInput } from 'components-react/shared/inputs/TextInput'; +import { OnboardingModule } from './Onboarding'; +import { Services } from 'components-react/service-provider'; +import Form from 'components-react/shared/inputs/Form'; + +export function ExtraPlatformConnect(p: { + selectedExtraPlatform: 'nimotv' | undefined; + setExtraPlatform: (val: 'nimotv' | undefined) => void; +}) { + const { selectedExtraPlatform, setExtraPlatform } = p; + const { next } = useModule(OnboardingModule); + const [key, setKey] = useState(''); + + if (!selectedExtraPlatform) return
; + + const platformDefinition = { + nimotv: { + name: 'Nimo.TV', + ingestUrl: 'rtmp://txpush.rtmp.nimo.tv/live/', + helpUrl: 'https://article.nimo.tv/p/1033/streamlabsprotocol.html', + icon: 'nimo-logo.png', + }, + }[selectedExtraPlatform]; + + function openHelp() { + remote.shell.openExternal(platformDefinition.helpUrl); + } + + function onFinish() { + Services.StreamSettingsService.setSettings({ + key, + streamType: 'rtmp_custom', + server: platformDefinition.ingestUrl, + }); + next(); + } + + return ( +
+
+

+ +

+

{$t('Connect to %{platform}', { platform: platformDefinition.name })}

+

+ {$t('Enter your stream key.')} +   + + {$t('View help docs')} + +

+ +
+
+ + +
+ +

+ +

+ +

+ next()}> + {$t('Skip')} + + + + + setExtraPlatform(undefined)}> + {$t('Back')} + +

+
+
+ ); +} diff --git a/app/components-react/pages/onboarding/FreshOrImport.m.less b/app/components-react/pages/onboarding/FreshOrImport.m.less new file mode 100644 index 000000000000..50a3ddf16608 --- /dev/null +++ b/app/components-react/pages/onboarding/FreshOrImport.m.less @@ -0,0 +1,48 @@ +@import "../../../styles/index"; +@import '../../../styles/badges'; + +.container { + display: flex; + flex-direction: column; + flex: 1; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + +.content-container { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1; + z-index: 1; // so SVG background doesn't show on top of text in small windows +} + +.title { + font-size: 48px; +} + +.option-container { + display: flex; + justify-content: space-between; + width: 900px; +} + +.footer { + position: absolute; + bottom: 0; + right: 0; + height: 100%; + width: 100%; + + svg { + fill: var(--section-alt); + } + + img { + position: absolute; + bottom: 0; + right: 0; + } +} diff --git a/app/components-react/pages/onboarding/FreshOrImport.tsx b/app/components-react/pages/onboarding/FreshOrImport.tsx new file mode 100644 index 000000000000..0d6463ef49ad --- /dev/null +++ b/app/components-react/pages/onboarding/FreshOrImport.tsx @@ -0,0 +1,89 @@ +import { Tooltip } from 'antd'; +import { useModule } from 'slap'; +import KevinSvg from 'components-react/shared/KevinSvg'; +import React from 'react'; +import { $t } from 'services/i18n'; +import { $i } from 'services/utils'; +import styles from './FreshOrImport.m.less'; +import commonStyles from './Common.m.less'; +import ObsSvg from './ObsSvg'; +import { OnboardingModule } from './Onboarding'; +import PlatformLogo from 'components-react/shared/PlatformLogo'; +import { Services } from 'components-react/service-provider'; + +export function FreshOrImport() { + const { setImportFromObs, next, setImportFromTwitch } = useModule(OnboardingModule); + const { TwitchStudioImporterService } = Services; + + const optionsMetadata = [ + { + title: $t('Import from OBS Studio'), + color: '--blue', + description: $t( + 'We import all of your settings, including scenes, output, configurations, and much more', + ), + image: , + onClick: () => { + setImportFromObs(); + next(); + }, + }, + { + title: $t('Import from Twitch Studio'), + color: '--twitch', + description: $t('Import your scenes and sources from Twitch Studio.'), + image: , + onClick: async () => { + setImportFromTwitch(); + next(); + }, + }, + { + title: $t('Start Fresh'), + color: '--teal', + description: $t( + 'Start with a clean copy of Streamlabs Desktop and configure your settings from scratch', + ), + image: , + onClick: next, + }, + ]; + + return ( +
+
+ + +
+
+

{$t('1-Click Import')}

+
+ {optionsMetadata.map(data => ( + +
data.onClick()} + style={{ background: `var(${data.color})` }} + > + {data.image} +

+ {data.title} +

+
+
+ ))} +
+
+
+ ); +} + +const SvgBackground = () => ( + + + +); diff --git a/app/components-react/pages/onboarding/HardwareSetup.m.less b/app/components-react/pages/onboarding/HardwareSetup.m.less new file mode 100644 index 000000000000..12869dde1ef5 --- /dev/null +++ b/app/components-react/pages/onboarding/HardwareSetup.m.less @@ -0,0 +1,46 @@ +@import "../../../styles/index"; + +.content-container { + height: calc(100% - 84px); + display: flex; + align-items: center; + justify-content: center; +} + +.display { + width: 100%; + height: 350px; + margin-right: 20px; +} + +.placeholder { + .radius(); + + position: relative; + height: 200px; + background-color: var(--section); + margin-bottom: 16px; + width: 340px; + + span { + .center(); + + width: auto; + } +} + +.volmeter { + position: relative; + background: var(--section); + height: 16px; + border-radius: 4px; + margin-bottom: 8px; +} + +.volmeter-center { + display: block; + position: absolute; + top: 50%; + //transform: translate(0, -14%); + width: 100%; +} diff --git a/app/components-react/pages/onboarding/HardwareSetup.tsx b/app/components-react/pages/onboarding/HardwareSetup.tsx new file mode 100644 index 000000000000..2fbd867454a4 --- /dev/null +++ b/app/components-react/pages/onboarding/HardwareSetup.tsx @@ -0,0 +1,163 @@ +import React, { useEffect, useRef } from 'react'; +import { $t } from 'services/i18n'; +import commonStyles from './Common.m.less'; +import styles from './HardwareSetup.m.less'; +import { Services } from 'components-react/service-provider'; +import Display from 'components-react/shared/Display'; +import { ERenderingMode } from '../../../../obs-api'; +import Form from 'components-react/shared/inputs/Form'; +import { ListInput } from 'components-react/shared/inputs'; +import { useVuex } from 'components-react/hooks'; +import { Volmeter2d } from 'services/audio/volmeter-2d'; +import cx from 'classnames'; + +export function HardwareSetup() { + const { DefaultHardwareService, SourceFiltersService } = Services; + const v = useVuex(() => ({ + videoDevices: DefaultHardwareService.videoDevices.map(device => ({ + label: device.description, + value: device.id, + })), + audioDevices: DefaultHardwareService.audioDevices.map(device => ({ + label: device.description, + value: device.id, + })), + selectedVideoSource: DefaultHardwareService.selectedVideoSource, + selectedVideoDevice: DefaultHardwareService.state.defaultVideoDevice, + presetFilterValue: DefaultHardwareService.state.presetFilter || 'none', + selectedAudioDevice: DefaultHardwareService.state.defaultAudioDevice, + selectedAudioSource: DefaultHardwareService.selectedAudioSource, + })); + + // Set up temporary sources + useEffect(() => { + DefaultHardwareService.createTemporarySources(); + + if (!DefaultHardwareService.selectedVideoSource && v.videoDevices.length) { + DefaultHardwareService.actions.setDefault('video', v.videoDevices[0].value); + } + + return () => DefaultHardwareService.actions.clearTemporarySources(); + }, []); + + function setVideoDevice(val: string) { + const oldPresetValue = v.presetFilterValue; + + if (oldPresetValue !== 'none') { + setPresetFilter('none'); + } + + // Needs to be sync + DefaultHardwareService.setDefault('video', val); + + if (oldPresetValue !== 'none') { + setPresetFilter(oldPresetValue); + } + } + + function setPresetFilter(value: string) { + if (!DefaultHardwareService.selectedVideoSource) return; + + DefaultHardwareService.actions.setPresetFilter(value === 'none' ? '' : value); + + if (value === 'none') { + SourceFiltersService.removePresetFilter(DefaultHardwareService.selectedVideoSource.sourceId); + } else { + SourceFiltersService.addPresetFilter( + DefaultHardwareService.selectedVideoSource.sourceId, + value, + ); + } + } + + return ( +
+

+ {$t('Set up your mic & webcam')} +

+
+ {$t('Connect your most essential devices now or later on.')} +
+ +
+ + {!!v.videoDevices.length && ( +
+ + + DefaultHardwareService.actions.setDefault('audio', val)} + allowClear={false} + /> + + )} +
+
+ ); +} + +export function DisplaySection() { + const { DefaultHardwareService } = Services; + const v = useVuex(() => ({ + videoDevices: DefaultHardwareService.videoDevices, + selectedVideoSource: DefaultHardwareService.selectedVideoSource, + selectedAudioSource: DefaultHardwareService.selectedAudioSource, + })); + const canvasRef = useRef(null); + + // Set up volmeter + useEffect(() => { + if (canvasRef.current && v.selectedAudioSource) { + const volmeter2d = new Volmeter2d(v.selectedAudioSource, canvasRef.current); + + return () => volmeter2d.destroy(); + } + }, [canvasRef.current, v.selectedAudioSource]); + + if (v.selectedVideoSource && v.videoDevices.length) { + return ( +
+ +
+ +
+
+ ); + } + + return ( +
+ {$t('No webcam detected')} +
+ ); +} diff --git a/app/components-react/pages/onboarding/MacPermissions.tsx b/app/components-react/pages/onboarding/MacPermissions.tsx new file mode 100644 index 000000000000..2ef1e4cd3b8e --- /dev/null +++ b/app/components-react/pages/onboarding/MacPermissions.tsx @@ -0,0 +1,57 @@ +import { useModule } from 'slap'; +import { Services } from 'components-react/service-provider'; +import React, { useEffect, useState } from 'react'; +import { $t } from 'services/i18n'; +import commonStyles from './Common.m.less'; +import { OnboardingModule } from './Onboarding'; + +export function MacPermissions() { + const { MacPermissionsService } = Services; + const { next } = useModule(OnboardingModule); + const [permissions, setPermissions] = useState(() => + MacPermissionsService.getPermissionsStatus(), + ); + + useEffect(() => { + const sub = MacPermissionsService.permissionsUpdated.subscribe(perms => { + setPermissions(perms); + }); + + MacPermissionsService.requestPermissions(); + + return () => sub.unsubscribe(); + }, []); + + return ( +
+

{$t('Grant Permissions')}

+
+ {$t( + 'Streamlabs needs additional permissions. Grant permissions in the pop-up dialogs to continue.', + )} +
+
+
+ {$t('Microphone')} + {permissions.micPermission && ( + + )} +
+
+ {$t('Webcam')} + {permissions.webcamPermission && ( + + )} +
+
+ +
+ ); +} diff --git a/app/components-react/pages/onboarding/ObsImport.m.less b/app/components-react/pages/onboarding/ObsImport.m.less new file mode 100644 index 000000000000..9a8615f779fd --- /dev/null +++ b/app/components-react/pages/onboarding/ObsImport.m.less @@ -0,0 +1,69 @@ +@import "../../../styles/index"; +@import '../../../styles/badges'; + +.container { + display: flex; + justify-content: space-between; + padding: 40px; + width: 100%; +} + +.card { + .radius(2); + + background: var(--section); + width: 48%; + padding: 24px; + display: flex; + justify-content: space-between; + align-items: center; + + img { + margin-left: 16px; + height: auto; + width: 50%; + flex-shrink: 0; + } + + h3 { + margin-bottom: 16px; + font-size: 20px; + } +} + +.profile-select--title, +.profile-select { + margin-bottom: 8px; + display: block; +} + +.profile-select { + width: 100%; +} + +.text-container { + display: flex; + justify-content: center; + align-items: center; + margin-top: 40px; +} + +.text-container svg { + width: auto; + height: 40px; + margin-right: 12px; + margin-bottom: 12px; + overflow: visible; + + path { + opacity: 1; + fill: var(--paragraph); + } +} + +.progress-bar { + position: relative; + display: block !important; + width: 80% !important; + margin: 0 auto; +} diff --git a/app/components-react/pages/onboarding/ObsImport.tsx b/app/components-react/pages/onboarding/ObsImport.tsx new file mode 100644 index 000000000000..5c99eab88780 --- /dev/null +++ b/app/components-react/pages/onboarding/ObsImport.tsx @@ -0,0 +1,213 @@ +import { injectState, useModule, mutation } from 'slap'; +import { alertAsync } from 'components-react/modals'; +import { Services } from 'components-react/service-provider'; +import AutoProgressBar from 'components-react/shared/AutoProgressBar'; +import { ListInput } from 'components-react/shared/inputs'; +import Form from 'components-react/shared/inputs/Form'; +import KevinSvg from 'components-react/shared/KevinSvg'; +import React from 'react'; +import { $t } from 'services/i18n'; +import commonStyles from './Common.m.less'; +import styles from './ObsImport.m.less'; +import { OnboardingModule } from './Onboarding'; +import { ObsImporterService } from 'app-services'; + +export function ObsImport() { + const { importing, percent, isObs } = useModule(ObsImportModule); + + return ( +
+

+ {$t(`Importing Your Existing Settings From ${isObs ? 'OBS' : 'Twitch Studio'}`)} +

+ {!importing && } + {importing && ( +
+ +
+ )} +
+ +
+ {$t( + 'While we import your settings and scenes, check out these great features unique to Streamlabs', + )} +
+
+ +
+ ); +} + +function PreImport() { + const { setProcessing, next } = useModule(OnboardingModule); + const { profiles, selectedProfile, setSelectedProfile, startImport, isObs } = useModule( + ObsImportModule, + ); + + return ( +
+ {profiles.length > 1 && ( +
+ {$t('Select an OBS profile to import')} +
+ ({ label: p, value: p }))} + value={selectedProfile} + onChange={setSelectedProfile} + allowClear={false} + /> + +
+ )} + +
+ ); +} + +function FeatureCards() { + const featuresMetadata: Dictionary> = { + appStore: { + title: $t('App Store'), + description: $t( + 'Check out 50+ amazing apps from independent developers, ranging from DMCA-compliant music ' + + 'to stunning overlays to more tools to engage with your community. Head over to the app store in the left ' + + 'navigation to browse our selection of free and paid apps.', + ), + image: 'app-store', + }, + gameOverlay: { + title: $t('In-game Overlay'), + description: $t( + 'Only have one screen? Perfect! Enable our in-game overlay to make sure you catch every chat message and ' + + 'stream event that happens while you get your game on. You can enable this feature in the ‘Game Overlay’ ' + + 'tab of the settings menu.', + ), + image: 'game-overlay', + }, + }; + + return ( +
+ {Object.keys(featuresMetadata).map(feature => { + const data = featuresMetadata[feature]; + return ( +
+
+

{data.title}

+
{data.description}
+
+ +
+ ); + })} +
+ ); +} + +class ObsImportModule { + state = injectState({ + profiles: [] as string[], + selectedProfile: '' as string | null, + importing: false, + percent: 0, + }); + + init() { + if (this.isObs) { + const service = this.importService as ObsImporterService; + // Intentionally synchronous + const profiles = service.getProfiles(); + this.setProfiles(profiles); + this.setSelectedProfile(profiles[0] ?? null); + } + } + + get importService() { + if (this.isObs) { + return Services.ObsImporterService; + } else { + return Services.TwitchStudioImporterService; + } + } + + get isObs() { + return Services.OnboardingService.state.importedFrom === 'obs'; + } + + startImport() { + if (this.state.importing) return Promise.resolve(); + if (this.isObs && !this.state.selectedProfile) return Promise.resolve(); + + this.setImporting(true); + + return this.importService + .load(this.state.selectedProfile!) + .then(r => { + this.setImporting(false); + return r; + }) + .catch(e => { + this.setImporting(false); + throw e; + }); + } + + @mutation() + setProfiles(profiles: string[]) { + this.state.profiles = profiles; + } + + @mutation() + setSelectedProfile(profile: string) { + this.state.selectedProfile = profile; + } + + @mutation() + setImporting(val: boolean) { + this.state.importing = val; + } + + @mutation() + setPercent(val: number) { + this.state.percent = val; + } +} diff --git a/app/components-react/pages/onboarding/ObsSvg.tsx b/app/components-react/pages/onboarding/ObsSvg.tsx new file mode 100644 index 000000000000..6280f459f5dc --- /dev/null +++ b/app/components-react/pages/onboarding/ObsSvg.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +export default () => ( + + + +); diff --git a/app/components-react/pages/onboarding/Onboarding.m.less b/app/components-react/pages/onboarding/Onboarding.m.less new file mode 100644 index 000000000000..6cabd007cfc4 --- /dev/null +++ b/app/components-react/pages/onboarding/Onboarding.m.less @@ -0,0 +1,110 @@ +.onboarding-container { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +} + +.top-bar-container { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 24px; + margin-top: 60px; + position: absolute; + z-index: 1; + pointer-events: none; +} + +.footer-prime { + position: relative !important; +} + +.top-bar-container-prime { + position: relative; + margin-bottom: 40px; +} + +.onboarding-content { + height: 0; + flex-grow: 1; + flex-basis: 0; + overflow: hidden; + width: 100%; + height: 100%; + + .scroll { + height: 100%; + } +} + +.footer { + display: flex; + flex-direction: row; + position: absolute; + bottom: 0; + padding: 24px 0; + width: 100%; + pointer-events: none; + + .progress { + display: flex; + flex: 1; + align-self: center; + justify-content: center; + margin-top: 40px; + + @media (min-height: 800px) { + margin-bottom: 40px; + } + + @media (max-height: 799px) { + display: none; + } + + // Accomodate progress bar so still centered when there's a skip button to its right + &-with-skip { + padding-left: 40px; + } + + :global(.ant-progress-steps-item) { + width: 8px !important; + height: 8px !important; + border-radius: 8px; + background-color: var(--midtone); + margin-right: 8px; + + &-active { + background-color: var(--icon); + } + } + } + + .skip { + // position: relative makes the skip button go left on small windows + position: absolute; + bottom: 60px; + right: 40px; + z-index: 2; + pointer-events: auto; + + @media (max-height: 799px) { + bottom: 10px; + } + } +} + + +.action-button { + margin-bottom: 8px; +} + +.link-button { + text-decoration: underline; + + &:hover { + cursor: pointer; + } +} + diff --git a/app/components-react/pages/onboarding/Onboarding.tsx b/app/components-react/pages/onboarding/Onboarding.tsx new file mode 100644 index 000000000000..1593fd61e56e --- /dev/null +++ b/app/components-react/pages/onboarding/Onboarding.tsx @@ -0,0 +1,288 @@ +import React, { useEffect, useMemo, useContext, createContext } from 'react'; +import { Button, Progress } from 'antd'; +import styles from './Onboarding.m.less'; +import commonStyles from './Common.m.less'; +import { Services } from 'components-react/service-provider'; +import { injectState, useModule, mutation } from 'slap'; +import cx from 'classnames'; +import { $t } from 'services/i18n'; +import * as stepComponents from './steps'; +import Utils from 'services/utils'; +import { IOnboardingStep, ONBOARDING_STEPS, EOnboardingSteps } from 'services/onboarding'; +import Scrollable from 'components-react/shared/Scrollable'; +import StreamlabsDesktopIcon from 'components-react/shared/StreamlabsDesktopIcon'; +import { SkipContext } from './OnboardingContext'; + +export default function Onboarding() { + const { + currentStep, + steps, + totalSteps, + next, + processing, + finish, + UsageStatisticsService, + singletonStep, + } = useModule(OnboardingModule); + + // This would probably be easier with Vuex or RxJS but not sure how easy is to rely on return values + const ctx = useContext(SkipContext); + + const skip = () => { + // Return false from skip function to avoid running the default skip logic + const result = ctx.onSkip(); + + // We do not want to reset onSkip if the skip function returns undefined + if (result === undefined) return; + + // Reset onSkip to default, after a component's custom skip function has run + ctx.onSkip = () => true; + + // Skip default behavior if the skip function returns false + if (result === false) return; + + next(true); + }; + + useEffect(() => { + if (!singletonStep) { + UsageStatisticsService.actions.recordShown('Onboarding', currentStep.component); + } + }, [currentStep.component]); + + const currentStepIndex = useMemo(() => { + if (!currentStep) { + return 0; + } + + return steps.findIndex(step => step.component === currentStep.component); + }, [steps]); + + // TODO: Onboarding service needs a refactor away from step index-based. + // In the meantime, if we run a render cycle and step index is greater + // than the total number of steps, we just need to end the onboarding + // immediately. Render side effects are bad btw. + // TODO: we might not need this anymore, can't find instance where currentStep === null + if (currentStep == null) { + finish(); + return <>; + } + + const Component = stepComponents[currentStep.component]; + + // Hide footer on singleton steps + const shouldShowFooter = !singletonStep; + + return ( +
+ + +
+ + + {!currentStep.hideButton && } + +
+ + {shouldShowFooter && ( +
+ )} +
+ ); +} + +interface FooterProps { + currentStep: IOnboardingStep; + currentStepIndex: number; + totalSteps: number; + onSkip: () => void; + isProcessing: boolean; +} + +function Footer({ currentStep, totalSteps, onSkip, isProcessing, currentStepIndex }: FooterProps) { + const percent = ((currentStepIndex + 1) / totalSteps) * 100; + + /* Skip pagination on Ultra step since it overlaps */ + const isPrimeStep = currentStep.component === 'Prime'; + + return ( +
+
+ +
+ + {currentStep.isSkippable && ( +
+ +
+ )} +
+ ); +} + +function TopBarLogo({ component }: { component: string }) { + return ; +} + +function TopBar() { + const component = useModule(OnboardingModule).currentStep.component; + return ( +
+ +
+ ); +} + +function ActionButton() { + const { currentStep, next, processing } = useModule(OnboardingModule); + + if (currentStep.hideButton) return null; + + const isPrimeStep = currentStep.component === 'Prime'; + + return ( +
+ +
+ ); +} + +export class OnboardingModule { + state = injectState({ + stepIndex: 0, + processing: false, + }); + + get OnboardingService() { + return Services.OnboardingService; + } + + get RecordingModeService() { + return Services.RecordingModeService; + } + + get UsageStatisticsService() { + return Services.UsageStatisticsService; + } + + get UserService() { + return Services.UserService; + } + + get steps() { + return this.OnboardingService.views.steps; + } + + get totalSteps() { + return this.OnboardingService.views.totalSteps; + } + + get singletonStep() { + return this.OnboardingService.views.singletonStep; + } + + get currentStep(): IOnboardingStep { + // Useful for testing in development + if (Utils.env.SLD_FORCE_ONBOARDING_STEP) { + const step = ONBOARDING_STEPS()[ + Utils.env.SLD_FORCE_ONBOARDING_STEP as keyof typeof EOnboardingSteps + ]; + + if (!step) { + return ONBOARDING_STEPS()[EOnboardingSteps.StreamingOrRecording]; + } + + return step; + } + + return this.singletonStep ?? this.steps[this.state.stepIndex]; + } + + get preboardingOffset() { + return this.steps.filter(step => step.isPreboarding).length; + } + + get isLogin() { + return this.OnboardingService.state.options.isLogin; + } + + setRecordingMode(val: boolean) { + this.RecordingModeService.setRecordingMode(val); + if (val) { + this.RecordingModeService.setUpRecordingFirstTimeSetup(); + } + } + + get isRecordingModeEnabled() { + return this.RecordingModeService.views.isRecordingModeEnabled; + } + + setImportFromObs() { + this.OnboardingService.setImport('obs'); + } + + setImportFromTwitch() { + this.OnboardingService.setImport('twitch'); + } + + finish() { + if (!this.singletonStep) { + this.UsageStatisticsService.actions.recordShown('Onboarding', 'completed'); + } + this.OnboardingService.actions.finish(); + } + + @mutation() + next(isSkip = false) { + if (this.state.processing) return; + + if (this.OnboardingService.state.options.isLogin && this.UserService.views.isPartialSLAuth) { + return; + } + + if ( + this.RecordingModeService.views.isRecordingModeEnabled && + this.currentStep.component === 'HardwareSetup' && + !this.OnboardingService.state.options.isHardware && + !isSkip + ) { + this.RecordingModeService.actions.addRecordingWebcam(); + } + + if (this.state.stepIndex >= this.steps.length - 1 || this.singletonStep) { + return this.finish(); + } + + this.state.stepIndex += 1; + } + + @mutation() + setProcessing(val: boolean) { + this.state.processing = val; + } +} diff --git a/app/components-react/pages/onboarding/OnboardingContext.ts b/app/components-react/pages/onboarding/OnboardingContext.ts new file mode 100644 index 000000000000..d2802739d5ea --- /dev/null +++ b/app/components-react/pages/onboarding/OnboardingContext.ts @@ -0,0 +1,16 @@ +import { createContext } from 'react'; + +/** + * A context that allows individual steps to specify custom skip actions without having to render a button. + */ +type SkipHandler = { + /** + * @returns true if we should run the default skip after, false to skip the default, and undefined if the skip was not attempted. + */ + onSkip: () => boolean | undefined; +}; + +// TODO: consider using `useCallback` on callers, optimization, etc. +export const SkipContext = createContext({ + onSkip: () => true, +}); diff --git a/app/components-react/pages/onboarding/Optimize.tsx b/app/components-react/pages/onboarding/Optimize.tsx new file mode 100644 index 000000000000..03d801250e5a --- /dev/null +++ b/app/components-react/pages/onboarding/Optimize.tsx @@ -0,0 +1,119 @@ +import { useModule } from 'slap'; +import { Services } from 'components-react/service-provider'; +import AutoProgressBar from 'components-react/shared/AutoProgressBar'; +import React, { useState } from 'react'; +import { IConfigProgress } from 'services/auto-config'; +import { $t } from 'services/i18n'; +import commonStyles from './Common.m.less'; +import { OnboardingModule } from './Onboarding'; + +interface IConfigStepPresentation { + description: string; + summary: string; + percentage?: number; +} + +export function Optimize() { + const { AutoConfigService, RecordingModeService } = Services; + const [optimizingState, setOptimizingState] = useState<'initial' | 'running' | 'error'>('initial'); + const [stepInfo, setStepInfo] = useState(null); + const steps = [ + 'detecting_location', + 'location_found', + 'bandwidth_test', + 'streamingEncoder_test', + 'recordingEncoder_test', + 'checking_settings', + 'setting_default_settings', + 'saving_service', + 'saving_settings', + ]; + const percentage = + optimizingState === 'running' && stepInfo ? (steps.indexOf(stepInfo.description) + 1) / steps.length : 0; + const { setProcessing, next } = useModule(OnboardingModule); + + function summaryForStep(progress: IConfigProgress) { + return { + detecting_location: $t('Detecting your location...'), + location_found: $t('Detected %{continent}', { continent: progress.continent }), + bandwidth_test: $t('Performing bandwidth test...'), + streamingEncoder_test: $t('Testing streaming encoder...'), + recordingEncoder_test: $t('Testing recording encoder...'), + checking_settings: $t('Attempting stream...'), + setting_default_settings: $t('Reverting to defaults...'), + saving_service: $t('Applying stream settings...'), + saving_settings: $t('Applying general settings...'), + }[progress.description]; + } + + function optimize() { + setOptimizingState('running'); + setProcessing(true); + + const sub = AutoConfigService.configProgress.subscribe(progress => { + if ( + progress.event === 'starting_step' || + progress.event === 'progress' || + progress.event === 'stopping_step' + ) { + if (stepInfo && stepInfo.description === progress.description) { + stepInfo.percentage = progress.percentage; + } else { + setStepInfo({ + description: progress.description, + summary: summaryForStep(progress)!, + percentage: progress.percentage, + }); + } + } else if (progress.event === 'done') { + setProcessing(false); + sub.unsubscribe(); + next(); + } else { + console.error("AutoConfigService error:", progress.description); + setProcessing(false); + sub.unsubscribe(); + setOptimizingState('error'); + } + }); + + RecordingModeService.views.isRecordingModeEnabled + ? AutoConfigService.actions.startRecording() + : AutoConfigService.actions.start(); + } + + function closeOnError() { + next(); + } + + return ( +
+

+ {optimizingState === 'running' ? $t('Optimizing...') : (optimizingState === 'initial' ? $t('Optimize') : $t('Error'))} +

+ {(optimizingState === 'initial' || optimizingState === 'error') && ( +
+ {optimizingState === 'initial' ? $t( + "Click below and we'll analyze your internet speed and computer hardware to give you the best settings possible.", + ): $t( + "An error has occurred during optimization attempt. Only default settings are applied", + )} +
+ )} + {optimizingState === 'running' ? ( +
+ + {stepInfo && stepInfo.summary} +
+ ) : ( + + )} +
+ ); +} diff --git a/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx b/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx new file mode 100644 index 000000000000..892097f585ad --- /dev/null +++ b/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx @@ -0,0 +1,228 @@ +import React, { useEffect, useState } from 'react'; +import { TPlatform } from 'services/platforms'; +import { useModule } from 'slap'; +import { LoginModule } from './Connect'; +import styles from './Connect.m.less'; +import cx from 'classnames'; +import PlatformLogo from 'components-react/shared/PlatformLogo'; +import commonStyles from './Common.m.less'; +import { $t } from 'services/i18n'; +import { promptAction } from 'components-react/modals'; +import Form from 'components-react/shared/inputs/Form'; +import { ListInput } from 'components-react/shared/inputs'; +import { Button } from 'antd'; +import { Services } from 'components-react/service-provider'; +import { useVuex } from 'components-react/hooks'; + +export function PrimaryPlatformSelect() { + const { UserService, OnboardingService } = Services; + const { linkedPlatforms, isLogin, isPrime } = useVuex(() => ({ + linkedPlatforms: UserService.views.linkedPlatforms, + isLogin: OnboardingService.state.options.isLogin, + isPrime: UserService.state.isPrime, + })); + const { loading, authInProgress, authPlatform, finishSLAuth } = useModule(LoginModule); + + const platforms: TPlatform[] = [ + 'twitch', + 'youtube', + 'tiktok', + 'kick', + 'patreon', + 'facebook', + 'twitter', + ]; + + // Note: Patreon is intentionally left out of the platform options since it cannot + // be a primary platform if there are other platforms linked + const platformOptions = [ + { + value: 'twitch', + label: 'Twitch', + image: , + }, + { + value: 'youtube', + label: 'YouTube', + image: , + }, + { + value: 'tiktok', + label: 'TikTok', + image: , + }, + { + value: 'kick', + label: 'Kick', + image: , + }, + { + value: 'facebook', + label: 'Facebook', + image: , + }, + { + value: 'twitter', + label: 'X (Twitter)', + image: , + }, + ].filter(opt => { + return linkedPlatforms.includes(opt.value as TPlatform); + }); + const [selectedPlatform, setSelectedPlatform] = useState( + platformOptions.length ? platformOptions[0].value : '', + ); + + const [refreshing, setRefreshing] = useState(false); + const [refreshAttempted, setRefreshAttempted] = useState(false); + + // There's probably a better way to do this + useEffect(() => { + /* + * Per new requirements, we automatically select a platform for the user since they + * are now able to switch them off from the Go Live window. This makes this component + * obsolete except for the case where the user has no linked accounts at all. + */ + // TODO: we're still doing render side-effects here, which is not ideal + if (UserService.views.linkedPlatforms.length) { + selectPrimary(UserService.views.linkedPlatforms[0]); + return; + } + + // TODO: This is probably dead code now + if (linkedPlatforms.length) { + setSelectedPlatform(linkedPlatforms[0]); + return; + } + + // The user has an SLID but we see no linked platforms, which can happen when + // `fetchLinkedPlatforms` silently fails. Try once more before falling back to + // the "Connect a Content Platform" UI, which would otherwise ask them to + // reconnect platforms they may already have linked server-side. + if (!refreshAttempted) { + setRefreshAttempted(true); + refreshLinkedPlatforms(); + } + }, [linkedPlatforms.length, isPrime]); + + async function refreshLinkedPlatforms() { + if (refreshing) return; + setRefreshing(true); + try { + await UserService.actions.return.updateLinkedPlatforms(); + } finally { + // If linked platforms were successfully refreshed, the "Select a Primary Platform" will show + setRefreshing(false); + } + } + + // You may be confused why this component doesn't ever call `next()` to + // continue to the next step. The index-based step system makes this more + // complex than it needs to be. Basically, the only way to see this step + // is by being in the `isPartialSLAuth` state. The only way to move past + // this step is to get out of the `isPartialSLAuth` state, which will cause + // this step to disappear from the flow, and we don't need to increment the + // step counter. In the case of a normal login outside of onboarding, we do + // call finish on the onboarding service. + + async function afterLogin(platform: TPlatform) { + await finishSLAuth(platform); + if (isLogin) OnboardingService.actions.finish(); + } + + async function onSkip() { + await promptAction({ + title: $t('Log Out?'), + message: $t( + 'Streamlabs Desktop requires that you have a connected platform account in order to use all of its features. By skipping this step, you will be logged out and some features may be unavailable.', + ), + btnText: $t('Log Out'), + btnType: 'primary', + cancelBtnPosition: 'left', + fn: () => { + finishSLAuth(); + OnboardingService.actions.finish(); + }, + secondaryActionText: $t('Refresh Platforms'), + secondaryActionFn: refreshLinkedPlatforms, + }); + } + + async function selectPrimary(primary?: TPlatform) { + if (!selectedPlatform && !primary) return; + + await finishSLAuth(primary ?? (selectedPlatform as TPlatform)); + if (isLogin) OnboardingService.actions.finish(); + } + + if (linkedPlatforms.length) { + return ( +
+
+

+ {$t('Select a Primary Platform')} +

+

+ {$t( + 'Your Streamlabs account has multiple connected content platforms. Please select the primary platform you will be streaming to using Streamlabs Desktop.', + )} +

+
+ + +
+ +
+
+
+ ); + } + + return ( +
+
+

{$t('Connect a Content Platform')}

+

+ {$t( + 'Streamlabs Desktop requires you to connect a content platform to your Streamlabs account', + )} +

+
+ {platforms.map((platform: TPlatform) => ( + + ))} +
+

+
+ + {$t('Skip')} + +

+
+
+ ); +} diff --git a/app/components-react/pages/onboarding/Prime.m.less b/app/components-react/pages/onboarding/Prime.m.less new file mode 100644 index 000000000000..58f09cc8bc6b --- /dev/null +++ b/app/components-react/pages/onboarding/Prime.m.less @@ -0,0 +1,10 @@ +.container { + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + + @media (max-height: 600px) { + height: auto; + } +} diff --git a/app/components-react/pages/onboarding/Prime.tsx b/app/components-react/pages/onboarding/Prime.tsx new file mode 100644 index 000000000000..138f2d685f63 --- /dev/null +++ b/app/components-react/pages/onboarding/Prime.tsx @@ -0,0 +1,101 @@ +import { useModule } from 'slap'; +import React from 'react'; +import { $t } from 'services/i18n'; +import commonStyles from './Common.m.less'; +import { OnboardingModule } from './Onboarding'; +import { Services } from 'components-react/service-provider'; +import { useWatchVuex } from 'components-react/hooks'; +import { UltraComparison } from 'components-react/shared/UltraComparison'; +import styles from './Prime.m.less'; + +export function Prime() { + const { UserService, RecordingModeService } = Services; + const { next } = useModule(OnboardingModule); + let tableProps; + + if (RecordingModeService.views.isRecordingModeEnabled) { + tableProps = { + tableHeaders: [ + { text: $t('Themes and Overlays'), icon: 'icon-themes' }, + { text: $t('Streamlabs Desktop'), icon: 'icon-desktop' }, + { text: $t('Highlighter'), icon: 'icon-slice' }, + { text: $t('Storage'), icon: 'icon-cloud-backup' }, + { text: $t('Seamless Creator Workflow'), icon: 'icon-user' }, + { + text: 'Video Editor', + icon: 'icon-streamlabs', + tooltip: $t('Collaborative Video Editing'), + }, + { + text: 'Cross Clip', + icon: 'icon-editor-7', + tooltip: $t('Format Clips for TikTok, Shorts, etc'), + }, + { text: $t('Custom Branding'), icon: 'icon-creator-site' }, + { text: $t('Tipping'), icon: 'icon-donation-settings' }, + { text: $t('Merch Store'), icon: 'icon-upperwear' }, + { text: $t('Collab Cam'), icon: 'icon-team-2' }, + { + text: $t('All Streamlabs Pro Tools'), + icon: 'icon-streamlabs', + whisper: 'Cross Clip, Talk Studio, Video Editor, Podcast Editor', + }, + ], + tableData: { + standard: [ + { text: $t('Access to Free Overlays and Themes') }, + { text: '✓', key: 'check1' }, + { text: '✓', key: 'check2' }, + { text: '1GB' }, + { text: '✓', key: 'check3' }, + { text: $t('30 Minute Videos + 15GB Storage') }, + { text: $t('Create Custom Videos with Watermark') }, + { text: $t('Logo Maker, Intro Maker, Emote Maker') }, + { text: $t('No-Fee Tipping') }, + { text: $t('Design and Sell Custom Merch') }, + { text: $t('Add 1 Guest') }, + { text: $t('Basic Features') }, + ], + prime: [ + { + text: $t('Access to All Overlays and Themes (%{themeNumber})', { + themeNumber: '1000+', + }), + }, + { text: '✓', key: 'check1' }, + { text: '✓', key: 'check2' }, + { text: '10GB' }, + { text: '✓', key: 'check3' }, + { text: $t('1 Hour Videos + 250GB Storage + More') }, + { text: $t('No Watermark + 1080p/60fps + More') }, + { text: $t('YouTube Thumbnail Maker, Creator Sites') }, + { text: $t('Custom Tip Page and Domain') }, + { text: $t('Highest Profit Margins') }, + { text: $t('Add Up To 11 Guests or Cameras') }, + { text: $t('Pro Upgrade') }, + ], + }, + }; + } + + useWatchVuex( + () => UserService.views.isPrime, + isPrime => isPrime && next(), + ); + + return ( +
+
+

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

+ +
+ {$t('Choose the best plan to fit your content creation needs.')} +
+ + +
+
+ ); +} diff --git a/app/components-react/pages/onboarding/Signup.m.less b/app/components-react/pages/onboarding/Signup.m.less new file mode 100644 index 000000000000..ce703c122165 --- /dev/null +++ b/app/components-react/pages/onboarding/Signup.m.less @@ -0,0 +1,11 @@ +.signup-subtitle { + max-width: 500px; + font-size: 22px; + text-align: center; + line-break: 150%; + margin-bottom: 32px; +} + +.signup-text-container { + margin-top: 74px; +} diff --git a/app/components-react/pages/onboarding/Signup.tsx b/app/components-react/pages/onboarding/Signup.tsx new file mode 100644 index 000000000000..a82770b4b20c --- /dev/null +++ b/app/components-react/pages/onboarding/Signup.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { $t } from 'services/i18n'; +import Translate from 'components-react/shared/Translate'; +import PlatformButton from 'components-react/shared/PlatformButton'; +import styles from './Signup.m.less'; +import { Services } from 'components-react/service-provider'; +import { EPlatformCallResult } from 'services/platforms'; + +export default function Signup({ + onSignupLinkClick, + onSuccess, +}: { + onSignupLinkClick: () => void; + onSuccess: () => void; +}) { + const { UserService } = Services; + + const openSLIDSignup = () => + UserService.startSLAuth({ signup: true }) + .then((success: EPlatformCallResult) => { + if (success !== EPlatformCallResult.Success) return; + onSuccess(); + }) + .catch(e => console.error('Signup Error: ', e)); + + return ( + <> +

+ {$t( + 'Create an account to unlock the most useful features like Streaming, Themes, Highlighter, App Store, Collab Cam and more!', + )} +

+ + + Streamlabs ID')}> + + + + + + Login')}> + + + + + ); +} diff --git a/app/components-react/pages/onboarding/StreamingOrRecording.m.less b/app/components-react/pages/onboarding/StreamingOrRecording.m.less new file mode 100644 index 000000000000..fe99732e683c --- /dev/null +++ b/app/components-react/pages/onboarding/StreamingOrRecording.m.less @@ -0,0 +1,148 @@ +@import '../../../styles/index'; +@import '../../../styles/badges'; + +.logo { + margin-top: 40px; +} + +.title-container { + position: absolute; + text-align: center; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + width: 100%; + top: 144px; + height: calc(~'100% - 144px'); + row-gap: 60px; + + @media (max-height: 600px) { + top: 100px; + } +} + +.title { + font-size: 48px; + + @media (max-height: 600px) { + display: none; + } +} + +.subtitle { + font-size: 14px; + color: var(--paragraph); + margin-top: 32px; + margin-bottom: 32px; +} + +.option-container { + display: flex; + justify-content: center; + column-gap: 24px; + width: 100%; +} + +.option { + text-align: center; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--paragraph); + background-color: var(--border); + padding-left: 18px; + padding-right: 18px; + + @media (min-width: 801px) { + width: 350px; + } + + border-radius: 100px; + + border: 2px solid transparent; + box-shadow: 0px 4px 20px 0px var(--shadow); + + transition-property: transform background-color border; + transition-duration: 0.5s; + transform: 0.6; + + h2 { + color: var(--paragraph); + margin-top: 8px; + } + + i { + font-size: 24px; + margin-right: 8px; + } + + &:hover { + h2 { + color: var(--title); + } + + i { + color: var(--title); + } + } + + &.active { + border: 2px solid var(--teal); + background-color: var(--section); + + i { + color: var(--teal); + } + + h2 { + color: var(--title); + } + } +} + +.button-container { + flex: 1; + opacity: 0; + cursor: inherit; + + &.active { + cursor: pointer; + opacity: 1; + } +} + +.footer { + position: absolute; + bottom: 0; + right: 0; + height: 100%; + width: 100%; + + svg { + fill: var(--section-alt); + transform: rotateY(180deg); + transition: all 0.5s ease-in-out; + } + + img { + position: absolute; + bottom: 0; + left: 0; + transition: all 0.5s ease-in-out; + } +} + +@media (max-height: 750px) { + .footer { + img { + 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 new file mode 100644 index 000000000000..4c91b76bf868 --- /dev/null +++ b/app/components-react/pages/onboarding/StreamingOrRecording.tsx @@ -0,0 +1,103 @@ +import { Button, Divider } from 'antd'; +import React, { useState } from 'react'; +import { $t } from 'services/i18n'; +import { $i } from 'services/utils'; +import styles from './StreamingOrRecording.m.less'; +import { OnboardingModule } from './Onboarding'; +import cx from 'classnames'; +import { confirmAsync } from 'components-react/modals'; +import { Services } from 'components-react/service-provider'; +import { useModule } from 'slap'; + +export function StreamingOrRecording() { + const { next, setRecordingMode, UsageStatisticsService, isRecordingModeEnabled } = useModule( + OnboardingModule, + ); + const [active, setActive] = useState<'streaming' | 'recording' | null>(null); + + async function onContinue() { + if (!active) return; + + if (active === 'recording') { + const result = await confirmAsync({ + title: $t('Streamlabs will be optimized for recording'), + content: ( +

+ {$t( + 'Certain features related to live streaming will be hidden. If you would like to enable these features in the future, you can disable Recording Mode in the application settings.', + )} +

+ ), + okText: $t('Continue'), + }); + + if (!result) return; + + setRecordingMode(true); + } + + /* + * Edge case as users shouldn't go multiple times through onboarding, + * but after selecting recording mode, it gets enabled and picking + * streaming mode subsequently does not disable it, preventing you from logging + * with Twitch, etc. + */ + if (active === 'streaming' && isRecordingModeEnabled) { + setRecordingMode(false); + } + + UsageStatisticsService.actions.recordClick('StreamingOrRecording', active); + + next(); + } + + const shouldShowContinue = active === 'streaming' || active === 'recording'; + + return ( +
+
+ + +
+
+

{$t('Welcome to Streamlabs!')}

+
+

{$t('How do you plan to use Streamlabs Desktop?')}

+
+
setActive('streaming')} + > + +

{$t('Live Streaming')}

+
+
setActive('recording')} + > + +

{$t('Recording Only')}

+
+
+
+
+ +
+
+
+ ); +} + +const SvgBackground = () => ( + + + +); diff --git a/app/components-react/pages/onboarding/ThemeSelector.m.less b/app/components-react/pages/onboarding/ThemeSelector.m.less new file mode 100644 index 000000000000..630eb26faa07 --- /dev/null +++ b/app/components-react/pages/onboarding/ThemeSelector.m.less @@ -0,0 +1,131 @@ +@import "../../../styles/index"; + +.container { + display: grid; + gap: 24px 24px; + grid-template-columns: auto auto auto; + grid-template-rows: auto auto; + grid-auto-flow: row; + margin: 40px; + width: calc(100% - 80px); + overflow: hidden; + background: var(--section); + .radius(2); + padding: 16px; + + img, + video { + object-fit: cover; + width: 100%; + max-height: 100%; + } +} + +.themes-sidebar { + padding-right: 16px; + border-right: 1px solid var(--background); + max-width: 320px; +} + +img.designer-logo { + width: 56px; + height: 56px; + border-radius: 50%; +} + +.cell { + .radius(2); + margin-bottom: 16px; + + position: relative; + overflow: hidden; + + &:hover { + cursor: pointer; + } +} + +.title { + position: absolute; + background: var(--button); + width: 100%; + bottom: -1px; + padding: 8px; +} + +.thumbnail { + width: 100%; + height: auto; +} + +.detail-panel { + position: relative; + grid-column: 1 / span 2; + grid-row: 1 / span 2; + padding: 16px; +} + +.detail-panel.right { + grid-column: 2 / span 2; + grid-row: 1 / span 2; +} + +.detail-header { + display: flex; + justify-content: space-between; + align-items: center; + + i:hover { + cursor: pointer; + } + + button { + margin-left: auto; + margin-right: 32px; + } + + .theme-details { + align-self: flex-start; + flex-direction: column; + justify-content: space-between; + margin-left: 20px; + } +} + +.preview-grid { + display: grid; + grid-template-columns: calc(75% - 16px) calc(25% - 16px); + grid-template-rows: auto auto auto; + gap: 8px 16px; + grid-auto-flow: row; + width: 100%; + margin-top: 32px; +} + +.big-preview { + grid-column: 1 / span 1; + grid-row: 1 / span 3; + border-radius: 8px; +} + +.preview { + border-radius: 8px; + box-sizing: border-box; + + &:hover { + cursor: pointer; + } +} + +.preview.active { + border: 1px solid var(--teal); +} + +.designer-name { + font-weight: bold; +} + +:global(.has-link) { + cursor: pointer; + text-decoration: underline; +} diff --git a/app/components-react/pages/onboarding/ThemeSelector.tsx b/app/components-react/pages/onboarding/ThemeSelector.tsx new file mode 100644 index 000000000000..cfaf73e2678d --- /dev/null +++ b/app/components-react/pages/onboarding/ThemeSelector.tsx @@ -0,0 +1,231 @@ +import * as remote from '@electron/remote'; +import { Services } from 'components-react/service-provider'; +import React, { useEffect, useMemo, useState } from 'react'; +import { $t } from 'services/i18n'; +import { IThemeMetadata } from 'services/onboarding'; +import commonStyles from './Common.m.less'; +import styles from './ThemeSelector.m.less'; +import cx from 'classnames'; +import { useModule } from 'slap'; +import { OnboardingModule } from './Onboarding'; +import AutoProgressBar from 'components-react/shared/AutoProgressBar'; +import { usePromise } from 'components-react/hooks'; +import sortBy from 'lodash/sortBy'; +import { Button } from 'antd'; +import Translate from 'components-react/shared/Translate'; + +const MAX_SIDEBAR_THEMES = 5; + +export function ThemeSelector() { + const { OnboardingService, SceneCollectionsService, UserService } = Services; + const { isPrime } = UserService; + const [themesMetadata, setThemesMetadata] = useState([]); + const [installing, setInstalling] = useState(false); + const [showDetail, setShowDetail] = useState(null); + const [bigPreview, setBigPreview] = useState(null); + const [progress, setProgress] = useState(0); + const { setProcessing, next } = useModule(OnboardingModule); + + function getFilteredMetadata() { + if (!showDetail) { + return themesMetadata; + } + + return sortBy( + themesMetadata.filter(theme => theme.data.id !== showDetail), + ['data', 'id'], + ).slice(0, MAX_SIDEBAR_THEMES); + } + + const detailTheme = useMemo(() => { + return themesMetadata.find(theme => theme.data.id === showDetail); + }, [showDetail]); + + function previewImages(theme: IThemeMetadata) { + if (!theme?.data) return []; + const customImages = Object.values(theme.data.custom_images).slice(0, 3); + if (customImages.length > 0) { + return customImages; + } else { + return theme.data.preview_images; + } + } + + function focusTheme(theme: IThemeMetadata | null) { + if (!theme) { + setShowDetail(null); + setBigPreview(null); + } else { + setShowDetail(theme.data.id); + setBigPreview(previewImages(theme)[0]); + } + } + + function thumbnail(theme: any) { + return previewImages(theme).find((img: string) => /\.png$|\.jpg$|\.jpeg$/.test(img)); + } + + async function installTheme(event: React.MouseEvent) { + event.stopPropagation(); + if (!detailTheme) { + return; + } + const url = OnboardingService.themeUrl(detailTheme.data.id); + setInstalling(true); + setProcessing(true); + const sub = SceneCollectionsService.downloadProgress.subscribe(progress => + setProgress(progress.percent * 100), + ); + await SceneCollectionsService.installOverlay(url, detailTheme.data.name); + sub.unsubscribe(); + setInstalling(false); + setProcessing(false); + next(); + } + + usePromise( + () => OnboardingService.actions.return.fetchThemes(), + // @ts-ignore typescript upgrade + p => + p.then(themes => { + setThemesMetadata(themes); + }), + ); + + useEffect(() => { + const filteredThemes = themesMetadata; + // Hardcoding IDs is bad, but faster to have backend return a `preferred` + // or `featured` boolean, so we at least fallback to the first theme if + // not found. + // Waves (Animated) + // TODO: can free users install this theme? + const wavesTheme = filteredThemes.find(theme => theme.data.id === 2183); + + const featuredTheme = wavesTheme || filteredThemes[0]; + + if (featuredTheme) { + focusTheme(featuredTheme); + } + }, [themesMetadata]); + + return ( +
+

{$t('Add your first theme')}

+ +
+ {$t('Try your first theme now, browse hundreds of more themes later on.')} +
+ + <> + {!installing ? ( +
+ {bigPreview && detailTheme && ( + <> +
+

{$t('Other Themes')}

+ + {getFilteredMetadata().map(theme => ( +
focusTheme(theme)} + key={theme.data.name} + > + +
{theme.data.name}
+
+ ))} +
+
+
+ {detailTheme.data.designer && ( + + )} +
+

{detailTheme.data.name}

+ {detailTheme.data.designer && ( + %{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)} + /> + ); + })} +
+
+ + )} +
+ ) : ( +
+ +

{$t('Installing overlay...')}

+
+ )} + +
+ ); +} + +function ThemePreview(p: { src: string; className: string; focusPreview: () => void }) { + const isVideo = /\.mp4$/.test(p.src); + return isVideo ? ( +