diff --git a/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx b/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx index 023de83e342c..e8fe199cf302 100644 --- a/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx +++ b/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx @@ -16,9 +16,10 @@ import { useVuex, useWatchVuex } from 'components-react/hooks'; export function PrimaryPlatformSelect() { const { UserService, OnboardingService } = Services; - const { linkedPlatforms, isLogin } = useVuex(() => ({ + 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 = ['twitch', 'youtube', 'facebook', 'twitter', 'tiktok', 'trovo']; @@ -62,17 +63,22 @@ export function PrimaryPlatformSelect() { // There's probably a better way to do this useEffect(() => { - // If user has exactly one streaming platform linked, we can proceed straight - // to a logged in state. - if (UserService.views.linkedPlatforms.length === 1) { + /* + * 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]); } - }, [linkedPlatforms.length]); + }, [linkedPlatforms.length, isPrime]); // 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 diff --git a/app/components-react/root/Chat.tsx b/app/components-react/root/Chat.tsx index fc779a428d9a..3d140806563a 100644 --- a/app/components-react/root/Chat.tsx +++ b/app/components-react/root/Chat.tsx @@ -22,15 +22,6 @@ export default function Chat(props: { let leaveFullScreenTrigger: Function; - const showTikTokInfo = - props.visibleChat === 'tiktok' || - (props.visibleChat === 'default' && - Services.UserService.state.auth?.primaryPlatform === 'tiktok'); - - const setTikTokChat = - Services.UserService.state.auth?.primaryPlatform === 'tiktok' && - props.visibleChat === 'restream'; - // Setup resize/fullscreen listeners useEffect(() => { resizeInterval = window.setInterval(() => { @@ -66,9 +57,6 @@ export default function Chat(props: { const cancelUnload = onUnload(() => service.actions.unmountChat(remote.getCurrentWindow().id)); return () => { - if (setTikTokChat) { - props.setChat('tiktok'); - } service.actions.unmountChat(remote.getCurrentWindow().id); cancelUnload(); }; @@ -111,21 +99,5 @@ export default function Chat(props: { ); } - return showTikTokInfo ? :
; -} - -function TikTokChatInfo() { - function openPlatformDash() { - remote.shell.openExternal(Services.TikTokService.dashboardUrl); - } - return ( -
-
- {$t('Access chat for TikTok in the TikTok Live Center.')} -
- -
- ); + return
; } diff --git a/app/components-react/root/LiveDock.m.less b/app/components-react/root/LiveDock.m.less index f33a382f7376..dbfc76d502ae 100644 --- a/app/components-react/root/LiveDock.m.less +++ b/app/components-react/root/LiveDock.m.less @@ -168,10 +168,7 @@ .live-dock-platform-tools { .flex(); - - i { - padding-right: 8px; - } + gap: 16px; } .live-dock-chat-apps__popout { diff --git a/app/components-react/root/LiveDock.tsx b/app/components-react/root/LiveDock.tsx index b98d4b8a7c56..7cd55c4862c2 100644 --- a/app/components-react/root/LiveDock.tsx +++ b/app/components-react/root/LiveDock.tsx @@ -7,7 +7,7 @@ import pick from 'lodash/pick'; import { initStore, useController } from 'components-react/hooks/zustand'; import { EStreamingState } from 'services/streaming'; import { EAppPageSlot, ILoadedApp } from 'services/platform-apps'; -import { TPlatform, getPlatformService } from 'services/platforms'; +import { getPlatformService, TPlatform } from 'services/platforms'; import { $t } from 'services/i18n'; import { Services } from '../service-provider'; import Chat from './Chat'; @@ -17,6 +17,7 @@ import PlatformAppPageView from 'components-react/shared/PlatformAppPageView'; import { useVuex } from 'components-react/hooks'; import { useRealmObject } from 'components-react/hooks/realm'; import { $i } from 'services/utils'; +import { TikTokChatInfo } from './TiktokChatInfo'; const LiveDockCtx = React.createContext(null); @@ -323,6 +324,30 @@ function LiveDock(p: { onLeft: boolean }) { }); } + const chat = useMemo(() => { + const primaryChat = Services.UserService.state.auth!.primaryPlatform; + const showTiktokInfo = visibleChat === 'tiktok' || primaryChat === 'tiktok'; + + if (showTiktokInfo && !isRestreaming) { + return ; + } + + const showInstagramInfo = primaryChat === 'instagram'; + if (showInstagramInfo) { + // FIXME: empty tab + return <>; + } + + return ( + + ); + }, [Services.UserService.state.auth!.primaryPlatform, visibleChat]); + return (
)} - {!applicationLoading && !collapsed && ( - - )} + {!applicationLoading && !collapsed && chat} {!['default', 'restream'].includes(visibleChat) && ( +
+ {$t('Access chat for TikTok in the TikTok Live Center.')} +
+ +
+ ); +} diff --git a/app/components-react/shared/PlatformLogo.m.less b/app/components-react/shared/PlatformLogo.m.less index b79919177c71..539239c58dc5 100644 --- a/app/components-react/shared/PlatformLogo.m.less +++ b/app/components-react/shared/PlatformLogo.m.less @@ -45,6 +45,7 @@ height: 40px; background-size: contain; background-repeat: no-repeat; + vertical-align: middle; &.twitter--black { background-image: url(https://slobs-cdn.streamlabs.com/media/twitter-logo-black.png); diff --git a/app/components-react/sidebar/NavTools.tsx b/app/components-react/sidebar/NavTools.tsx index ee450ab7a7ec..05cd16f764d5 100644 --- a/app/components-react/sidebar/NavTools.tsx +++ b/app/components-react/sidebar/NavTools.tsx @@ -14,6 +14,7 @@ import PlatformLogo from 'components-react/shared/PlatformLogo'; import SubMenu from 'components-react/shared/SubMenu'; import MenuItem from 'components-react/shared/MenuItem'; import UltraIcon from 'components-react/shared/UltraIcon'; +import PlatformIndicator from './PlatformIndicator'; export default function SideNav() { const { @@ -77,6 +78,12 @@ 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 + const username = + isLoggedIn && UserService.views.auth!.primaryPlatform !== 'instagram' + ? UserService.username + : undefined; + function openHelp() { UsageStatisticsService.actions.recordClick('SideNav2', 'help'); remote.shell.openExternal( @@ -195,7 +202,7 @@ export default function SideNav() { showModal={showModal} handleAuth={handleAuth} handleShowModal={handleShowModal} - username={UserService.username} + username={username} /> ); @@ -258,6 +265,11 @@ function LogoutModal(p: { handleShowModal: (status: boolean) => void; username?: string; }) { + const { username } = p; + const confirmMsg = username + ? $t('Are you sure you want to log out %{username}?', { username }) + : $t('Are you sure you want to log out?'); + return (

{$t('Confirm')}

- {$t('Are you sure you want to log out %{username}?', { - username: p.username, - })} + {confirmMsg}
@@ -307,21 +317,7 @@ function LoginMenuItem(p: { {!isLoggedIn ? ( {menuTitles(menuItem.key)} ) : ( - isOpen && ( - <> - {platform && ( - - )} - {platform?.username || $t('Log Out')} - - - ) + isOpen && )} ); diff --git a/app/components-react/sidebar/PlatformIndicator.m.less b/app/components-react/sidebar/PlatformIndicator.m.less new file mode 100644 index 000000000000..642eb8c60032 --- /dev/null +++ b/app/components-react/sidebar/PlatformIndicator.m.less @@ -0,0 +1,75 @@ +// TODO: these are duplicated from NavTools.m.less, consider refactoring out if not needed there +.platform-logo { + margin-right: 10px; + + &-twitch { + color: var(--twitch) !important; + } + &-youtube { + color: var(--youtube) !important; + } + &-facebook { + color: var(--facebook) !important; + } + &-trovo, &-twitter, &-tiktok, &-instagram { + width: 15px; + height: 15px; + } + &-tiktok { + color: var(--tiktok) !important; + } + &-streamlabs { + color: var(--teal) !important; + } + &-default { + color: var(--logged-in) !important; + } +} + +.username { + margin-bottom: 0px; + flex-grow: 1; + line-height: 20px; + margin: 2px 0 0 0; +} + +.login-arrow { + justify-self: flex-end; + margin-left: 5px; + transform: scaleX(-1); + -moz-transform: scaleX(-1); + -webkit-transform: scaleX(-1); + -ms-transform: scaleX(-1); + transition: all 0.2s ease-in-out; +} + +.platform-icons { + display: flex; + flex-direction: row; + gap: 1px; + align-items: center; + justify-content: center; + + // Override the zooming effect applied to sidebar icons + i { + &, + &:hover { + transform: none !important; + transition: none !important; + } + } + + // Custom destination icon + :global(.fa-globe) { + margin-right: 10px; + } + + :global(.fa-globe) { + &, + &:hover, + &:focus, + &:active { + color: var(--icon) !important; + } + } +} diff --git a/app/components-react/sidebar/PlatformIndicator.tsx b/app/components-react/sidebar/PlatformIndicator.tsx new file mode 100644 index 000000000000..d44cedfa22f6 --- /dev/null +++ b/app/components-react/sidebar/PlatformIndicator.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import cx from 'classnames'; +import { IPlatformAuth, TPlatform } from 'services/platforms'; +import { $t } from 'services/i18n'; +import { useVuex } from 'components-react/hooks'; +import { Services } from 'components-react/service-provider'; +import PlatformLogo from 'components-react/shared/PlatformLogo'; +import { IPlatformFlags } from 'services/streaming'; +import styles from './PlatformIndicator.m.less'; + +interface IPlatformIndicatorProps { + platform: IPlatformAuth | undefined; +} + +interface IMultiPlatformIndicatorProps { + hasCustomDestinations: boolean; + enabledPlatforms: [TPlatform, IPlatformFlags][]; +} + +export default function PlatformIndicator({ platform }: IPlatformIndicatorProps) { + const { StreamSettingsService, RestreamService } = Services; + const restreamEnabled = RestreamService.views.canEnableRestream; + const { platforms, customDestinations } = useVuex(() => ({ + platforms: StreamSettingsService.views.settings.goLiveSettings?.platforms, + customDestinations: StreamSettingsService.views.settings.goLiveSettings?.customDestinations, + })); + + const enabledPlatformsTuple: [TPlatform, IPlatformFlags][] = platforms + ? (Object.entries(platforms).filter(([_, p]) => p.enabled) as [TPlatform, IPlatformFlags][]) + : []; + + const hasMultiplePlatforms = enabledPlatformsTuple.length > 1; + const hasCustomDestinations = customDestinations?.some(d => d.enabled) || false; + + if (hasMultiplePlatforms || hasCustomDestinations) { + return ( + + ); + } + + // TODO: do we need to check for protected mode + return ; +} + +const SinglePlatformIndicator = ({ platform }: Pick) => { + const username = platform?.type === 'instagram' ? undefined : platform?.username; + + return ( + <> + {platform && ( + + )} + {username || $t('Log Out')} + + + ); +}; + +const MultiPlatformIndicator = ({ + hasCustomDestinations, + enabledPlatforms, +}: IMultiPlatformIndicatorProps) => { + const displayedDestinations = (hasCustomDestinations ? 1 : 0) + enabledPlatforms.length; + // I found that 6 is the max we should be displaying without wrapping, logged in text hidden at 4 + const platformsToDisplay = enabledPlatforms.slice(0, 6 - (hasCustomDestinations ? 1 : 0)); + + return ( +
+
+ {platformsToDisplay.map(([platform, _]) => ( + + ))} + {hasCustomDestinations && } +
+ {displayedDestinations < 4 && ( +
+ {$t('Logged In')} +
+ )} + +
+ ); +}; diff --git a/app/components-react/windows/go-live/DestinationSwitchers.tsx b/app/components-react/windows/go-live/DestinationSwitchers.tsx index 35fed1173c05..e7e8308c2618 100644 --- a/app/components-react/windows/go-live/DestinationSwitchers.tsx +++ b/app/components-react/windows/go-live/DestinationSwitchers.tsx @@ -25,6 +25,7 @@ export function DestinationSwitchers(p: { showSelector?: boolean }) { switchCustomDestination, isPrimaryPlatform, isPlatformLinked, + isRestreamEnabled, } = useGoLiveSettings(); // use these references to apply debounce // for error handling and switch animation @@ -60,8 +61,42 @@ export function DestinationSwitchers(p: { showSelector?: boolean }) { } function togglePlatform(platform: TPlatform, enabled: boolean) { - enabledPlatformsRef.current = enabledPlatformsRef.current.filter(p => p !== platform); - if (enabled) enabledPlatformsRef.current.push(platform); + // On non multistream mode, switch the platform that was just selected while disabling all the others, + // allow TikTok to be added as an extra platform + if (!isRestreamEnabled) { + /* + * If TikTok is the platform being toggled: + * - Preserve the currently active platform so TikTok can be added to this list at the bottom of this function, + * we will have 2 active platforms and a Primary Chat switcher. + * - 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'); + } else { + /* + * Clearing this list ensures that when a new platform is selected, instead of enabling 2 platforms + * we switch to 1 enabled platforms that was just toggled. + * We will also preserve TikTok as an active platform if it was before. + */ + enabledPlatformsRef.current = enabledPlatformsRef.current.includes('tiktok') + ? ['tiktok'] + : []; + } + } else { + enabledPlatformsRef.current = enabledPlatformsRef.current.filter(p => p !== platform); + } + + if (enabled) { + enabledPlatformsRef.current.push(platform); + } + + // Do not allow disabling the last platform + if (!enabledPlatformsRef.current.length) { + enabledPlatformsRef.current.push(platform); + } + emitSwitch(); } @@ -73,17 +108,20 @@ export function DestinationSwitchers(p: { showSelector?: boolean }) { emitSwitch(ind, enabled); } + // TODO: find a cleaner way to do this + const isPrimary = (platform: TPlatform) => + isPrimaryPlatform(platform) || linkedPlatforms.length === 1; + return ( -
+
{linkedPlatforms.map(platform => ( togglePlatform(platform, enabled)} - isPrimary={isPrimaryPlatform(platform)} promptConnectTikTok={platform === 'tiktok' && promptConnectTikTok} - disabled={disableSwitchers && !isEnabled(platform)} + isPrimary={isPrimaryPlatform(platform)} /> ))} @@ -94,7 +132,6 @@ export function DestinationSwitchers(p: { showSelector?: boolean }) { onChange={enabled => togglePlatform('tiktok', enabled)} isPrimary={isPrimaryPlatform('tiktok')} promptConnectTikTok={promptConnectTikTok} - disabled={disableSwitchers && !isEnabled('tiktok')} /> )} @@ -102,8 +139,8 @@ export function DestinationSwitchers(p: { showSelector?: boolean }) { toggleDest(ind, enabled)} + enabled={customDestinations[ind].enabled} + onChange={enabled => switchCustomDestination(ind, enabled)} disabled={disableSwitchers && !isEnabled(ind)} /> ))} @@ -130,18 +167,17 @@ 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, NavigationService, WindowsService } = Services; + const { RestreamService, MagicLinkService, StreamingService } = Services; + const canEnableRestream = RestreamService.views.canEnableRestream; + const cannotDisableDestination = p.isPrimary && !canEnableRestream; - function onClickHandler(ev: MouseEvent) { - if (p.isPrimary) { - alertAsync( - $t( - 'You cannot disable the platform you used to sign in to Streamlabs Desktop. Please sign in with a different platform to disable streaming to this destination.', - ), - ); - return; - } + // Preserving old TikTok functionality, so they can't enable the toggle if TikTok is not + // connected. + // TODO: this kind of logic should belong on caller, but ideally we would refactor all this + const tiktokDisabled = + platform === 'tiktok' && !StreamingService.views.isPlatformLinked('tiktok'); + function onClickHandler(ev: MouseEvent) { if (p.promptConnectTikTok) { alertAsync({ type: 'confirm', @@ -163,21 +199,25 @@ const DestinationSwitcher = React.forwardRef<{}, IDestinationSwitcherProps>((p, return; } - if (RestreamService.views.canEnableRestream || !p.promptConnectTikTok) { - const enable = !p.enabled; - p.onChange(enable); - // always proxy the click to the SwitchInput - // so it can play a transition animation - switchInputRef.current?.click(); - // switch the container class without re-rendering to not stop the animation - if (enable) { - containerRef.current?.classList.remove(styles.platformDisabled); - } else { - containerRef.current?.classList.add(styles.platformDisabled); - } + const enable = !p.enabled; + p.onChange(enable); + // always proxy the click to the SwitchInput + // so it can play a transition animation + switchInputRef.current?.click(); + + /* + * TODO: + * this causes inconsistent state when disabling primary platform + * after is being re-enabled. Not sure which animation is referring to. + */ + // switch the container class without re-rendering to not stop the animation + /* + if (enable) { + containerRef.current?.classList.remove(styles.platformDisabled); } else { - MagicLinkService.actions.linkToPrime('slobs-multistream'); + containerRef.current?.classList.add(styles.platformDisabled); } + */ } function addClass() { @@ -205,12 +245,6 @@ const DestinationSwitcher = React.forwardRef<{}, IDestinationSwitcherProps>((p, const platformAuthData = UserService.state.auth?.platforms[platform]; const username = platformAuthData?.username ?? ''; - // Preserving old TikTok functionality, so they can't enable the toggle if TikTok is not - // connected. - // TODO: this kind of logic should belong on caller, but ideally we would refactor all this - const tiktokDisabled = - platform === 'tiktok' && !StreamingService.views.isPlatformLinked('tiktok'); - return { title: $t('Stream to %{platformName}', { platformName: service.displayName }), description: username, @@ -222,7 +256,7 @@ const DestinationSwitcher = React.forwardRef<{}, IDestinationSwitcherProps>((p, inputRef={switchInputRef} value={p.enabled} name={platform} - disabled={p.isPrimary || tiktokDisabled} + disabled={tiktokDisabled} uncontrolled /> ), diff --git a/app/components-react/windows/go-live/EditStreamWindow.tsx b/app/components-react/windows/go-live/EditStreamWindow.tsx index c22bffcebb28..c00481e46b91 100644 --- a/app/components-react/windows/go-live/EditStreamWindow.tsx +++ b/app/components-react/windows/go-live/EditStreamWindow.tsx @@ -14,6 +14,7 @@ import PlatformSettings from './PlatformSettings'; import Scrollable from '../../shared/Scrollable'; import Spinner from '../../shared/Spinner'; import GoLiveError from './GoLiveError'; +import PrimaryChatSwitcher from './PrimaryChatSwitcher'; export default function EditStreamWindow() { const { StreamingService, WindowsService } = Services; @@ -28,6 +29,11 @@ export default function EditStreamWindow() { prepopulate, isLoading, form, + enabledPlatforms, + hasMultiplePlatforms, + isRestreamEnabled, + primaryChat, + setPrimaryChat, } = useGoLiveSettingsRoot({ isUpdateMode: true }); const shouldShowChecklist = lifecycle === 'runChecklist'; @@ -83,6 +89,8 @@ export default function EditStreamWindow() { ); } + const shouldShowPrimaryChatSwitcher = isRestreamEnabled && hasMultiplePlatforms; + return ( + {shouldShowPrimaryChatSwitcher && ( + + )} )} diff --git a/app/components-react/windows/go-live/GoLiveSettings.tsx b/app/components-react/windows/go-live/GoLiveSettings.tsx index dfdef17f631d..c2c370f7018b 100644 --- a/app/components-react/windows/go-live/GoLiveSettings.tsx +++ b/app/components-react/windows/go-live/GoLiveSettings.tsx @@ -14,6 +14,7 @@ import Spinner from '../../shared/Spinner'; import GoLiveError from './GoLiveError'; import TwitterInput from './Twitter'; import AddDestinationButton from 'components-react/shared/AddDestinationButton'; +import PrimaryChatSwitcher from './PrimaryChatSwitcher'; const PlusIcon = PlusOutlined as Function; @@ -35,6 +36,11 @@ export default function GoLiveSettings() { showSelector, showTweet, addDestination, + hasDestinations, + hasMultiplePlatforms, + enabledPlatforms, + primaryChat, + setPrimaryChat, } = useGoLiveSettings().extend(module => { const { UserService, VideoEncodingOptimizationService, SettingsService } = Services; @@ -69,13 +75,14 @@ export default function GoLiveSettings() { const shouldShowSettings = !error && !isLoading; const shouldShowLeftCol = protectedModeEnabled; const shouldShowAddDestButton = canAddDestinations && isPrime; + const shouldShowPrimaryChatSwitcher = hasMultiplePlatforms; return ( {/*LEFT COLUMN*/} {shouldShowLeftCol && ( - - + + {/*DESTINATION SWITCHERS*/} {/*ADD DESTINATION BUTTON*/} @@ -88,6 +95,13 @@ export default function GoLiveSettings() { )} + {shouldShowPrimaryChatSwitcher && ( + + )} )} diff --git a/app/components-react/windows/go-live/PrimaryChatSwitcher.tsx b/app/components-react/windows/go-live/PrimaryChatSwitcher.tsx new file mode 100644 index 000000000000..7afb33fc3d8a --- /dev/null +++ b/app/components-react/windows/go-live/PrimaryChatSwitcher.tsx @@ -0,0 +1,73 @@ +import React, { useMemo } from 'react'; +import { Divider } from 'antd'; +import { ListInput } from 'components-react/shared/inputs'; +import Form from 'components-react/shared/inputs/Form'; +import { getPlatformService, TPlatform } from 'services/platforms'; +import PlatformLogo from 'components-react/shared/PlatformLogo'; +import { $t } from 'services/i18n'; + +interface IPrimaryChatSwitcherProps { + enabledPlatforms: TPlatform[]; + primaryChat: TPlatform; + onSetPrimaryChat: (platform: TPlatform) => void; + style?: React.CSSProperties; + layout?: 'vertical' | 'horizontal'; +} + +export default function PrimaryChatSwitcher({ + enabledPlatforms, + primaryChat, + onSetPrimaryChat, + style = {}, + layout = 'vertical', +}: IPrimaryChatSwitcherProps) { + const primaryChatOptions = useMemo( + () => + enabledPlatforms.map(platform => { + const service = getPlatformService(platform); + return { + label: service.displayName, + value: platform, + }; + }), + [enabledPlatforms], + ); + + return ( +
+ + + + +
+ ); +} + +const renderPrimaryChatOption = (option: { label: string; value: TPlatform }) => { + /* + * TODO: antd's new version has a new Flex component that should make + * spacing (`gap` here) more consistent. Also, less typing. + * https://ant.design/components/flex + */ + return ( +
+ +
{option.label}
+
+ ); +}; diff --git a/app/components-react/windows/go-live/dual-output/DualOutputGoLiveSettings.tsx b/app/components-react/windows/go-live/dual-output/DualOutputGoLiveSettings.tsx index 961068413833..1a26d6a22c93 100644 --- a/app/components-react/windows/go-live/dual-output/DualOutputGoLiveSettings.tsx +++ b/app/components-react/windows/go-live/dual-output/DualOutputGoLiveSettings.tsx @@ -13,6 +13,7 @@ import Spinner from 'components-react/shared/Spinner'; import GoLiveError from '../GoLiveError'; import UserSettingsUltra from './UserSettingsUltra'; import UserSettingsNonUltra from './UserSettingsNonUltra'; +import PrimaryChatSwitcher from '../PrimaryChatSwitcher'; /** * Renders settings for starting the stream @@ -21,31 +22,51 @@ import UserSettingsNonUltra from './UserSettingsNonUltra'; * - Extras settings **/ export default function DualOutputGoLiveSettings() { - const { isAdvancedMode, isLoading, isPrime, canUseOptimizedProfile } = useGoLiveSettings().extend( - module => { - const { UserService, VideoEncodingOptimizationService } = Services; + const { + isAdvancedMode, + isLoading, + isPrime, + canUseOptimizedProfile, + isRestreamEnabled, + hasMultiplePlatforms, + enabledPlatforms, + primaryChat, + setPrimaryChat, + } = useGoLiveSettings().extend(module => { + const { UserService, VideoEncodingOptimizationService } = Services; - return { - isPrime: UserService.views.isPrime, + return { + isPrime: UserService.views.isPrime, - // temporarily hide the checkbox until streaming and output settings - // are migrated to the new API - canUseOptimizedProfile: false, - // canUseOptimizedProfile: - // VideoEncodingOptimizationService.state.canSeeOptimizedProfile || - // VideoEncodingOptimizationService.state.useOptimizedProfile, - }; - }, - ); + // temporarily hide the checkbox until streaming and output settings + // are migrated to the new API + canUseOptimizedProfile: false, + // canUseOptimizedProfile: + // VideoEncodingOptimizationService.state.canSeeOptimizedProfile || + // VideoEncodingOptimizationService.state.useOptimizedProfile, + }; + }); + + const shouldShowPrimaryChatSwitcher = isRestreamEnabled && hasMultiplePlatforms; + // TODO: make sure this doesn't jank the UI + const leftPaneHeight = shouldShowPrimaryChatSwitcher ? '82%' : '100%'; return ( {/*LEFT COLUMN*/} - + {isPrime && } {!isPrime && } + {shouldShowPrimaryChatSwitcher && ( + + )} {/*RIGHT COLUMN*/} diff --git a/app/components-react/windows/go-live/dual-output/DualOutputPlatformSelector.tsx b/app/components-react/windows/go-live/dual-output/DualOutputPlatformSelector.tsx index 9eca3dd0bfd6..dba77346fe20 100644 --- a/app/components-react/windows/go-live/dual-output/DualOutputPlatformSelector.tsx +++ b/app/components-react/windows/go-live/dual-output/DualOutputPlatformSelector.tsx @@ -55,7 +55,6 @@ export default function DualOutputPlatformSelector(p: IPlatformSelectorProps) { platform={platform as TPlatform} className={styles.selectorIcon} fontIcon={['tiktok', 'trovo'].includes(platform) ? platform : undefined} - nocolor /> {platformLabels(platform)} 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 5d1391bb4b71..378eea8c6d9b 100644 --- a/app/components-react/windows/go-live/dual-output/NonUltraDestinationSwitchers.tsx +++ b/app/components-react/windows/go-live/dual-output/NonUltraDestinationSwitchers.tsx @@ -28,6 +28,7 @@ export function NonUltraDestinationSwitchers(p: INonUltraDestinationSwitchers) { switchCustomDestination, isPrimaryPlatform, isPlatformLinked, + isRestreamEnabled, } = useGoLiveSettings(); const enabledPlatformsRef = useRef(enabledPlatforms); enabledPlatformsRef.current = enabledPlatforms; @@ -73,6 +74,7 @@ export function NonUltraDestinationSwitchers(p: INonUltraDestinationSwitchers) { onChange={enabled => togglePlatform(platform, enabled)} isPrimary={isPrimaryPlatform(platform)} promptConnectTikTok={platform === 'tiktok' && promptConnectTikTok} + canDisablePrimary={isRestreamEnabled} index={index} /> ))} @@ -113,6 +115,7 @@ interface IDestinationSwitcherProps { isPrimary?: boolean; promptConnectTikTok?: boolean; index: number; + canDisablePrimary?: boolean; } /** @@ -139,7 +142,7 @@ const DestinationSwitcher = React.forwardRef<{ addClass: () => void }, IDestinat } function removeClass() { - if (p.isPrimary) { + if (p.isPrimary && p.canDisablePrimary !== true) { alertAsync( $t( 'You cannot disable the platform you used to sign in to Streamlabs Desktop. Please sign in with a different platform to disable streaming to this destination.', 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 81fbd36c18d9..adde2315b1a6 100644 --- a/app/components-react/windows/go-live/dual-output/UltraDestinationSwitchers.tsx +++ b/app/components-react/windows/go-live/dual-output/UltraDestinationSwitchers.tsx @@ -31,6 +31,7 @@ export function UltraDestinationSwitchers(p: IUltraDestinationSwitchers) { isPlatformLinked, switchPlatforms, switchCustomDestination, + isRestreamEnabled, } = useGoLiveSettings(); const enabledPlatformsRef = useRef(enabledPlatforms); enabledPlatformsRef.current = enabledPlatforms; @@ -80,6 +81,7 @@ export function UltraDestinationSwitchers(p: IUltraDestinationSwitchers) { onChange={enabled => togglePlatform(platform, enabled)} isPrimary={isPrimaryPlatform(platform)} promptConnectTikTok={platform === 'tiktok' && promptConnectTikTok} + canDisablePrimary={isRestreamEnabled} index={index} /> ))} @@ -102,6 +104,7 @@ interface IDestinationSwitcherProps { onChange: (enabled: boolean) => unknown; isPrimary?: boolean; promptConnectTikTok?: boolean; + canDisablePrimary?: boolean; index: number; } @@ -114,6 +117,7 @@ function DestinationSwitcher(p: IDestinationSwitcherProps) { 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 canDisablePrimary = p.canDisablePrimary; function showTikTokConnectModal() { alertAsync({ @@ -135,8 +139,9 @@ function DestinationSwitcher(p: IDestinationSwitcherProps) { }); } - function onClickHandler() { - if (p.isPrimary) { + function onClickHandler(ev: MouseEvent) { + // TODO: do we need this check if we're on an Ultra DestinationSwitcher + if (p.isPrimary && p.canDisablePrimary !== true) { alertAsync( $t( 'You cannot disable the platform you used to sign in to Streamlabs Desktop. Please sign in with a different platform to disable streaming to this destination.', @@ -184,7 +189,11 @@ function DestinationSwitcher(p: IDestinationSwitcherProps) { inputRef={switchInputRef} value={p.enabled} name={platform} - disabled={p?.isPrimary || (p.promptConnectTikTok && platform === 'tiktok')} + disabled={ + canDisablePrimary + ? false + : p?.isPrimary || (p.promptConnectTikTok && platform === 'tiktok') + } uncontrolled className={styles.platformSwitch} checkedChildren={} @@ -241,7 +250,7 @@ function DestinationSwitcher(p: IDestinationSwitcherProps) { e.stopPropagation(); return; } else { - onClickHandler(); + onClickHandler(e); } }} > diff --git a/app/components-react/windows/go-live/platforms/InstagramEditStreamInfo.tsx b/app/components-react/windows/go-live/platforms/InstagramEditStreamInfo.tsx index c8e47dc1f4ca..2c63c4e71633 100644 --- a/app/components-react/windows/go-live/platforms/InstagramEditStreamInfo.tsx +++ b/app/components-react/windows/go-live/platforms/InstagramEditStreamInfo.tsx @@ -41,6 +41,7 @@ export function InstagramEditStreamInfo(p: Props) { /> {!isStreamSettingsWindow && ( { - if (!this.state.isPrimaryPlatform(platform)) delete settings.platforms[platform]; + // In multi-platform mode, allow deleting all platform settings, including primary + if (!isMultiplatformMode && this.state.isPrimaryPlatform(platform)) { + return; + } + + delete settings.platforms[platform]; }); } @@ -219,6 +226,30 @@ export class GoLiveSettingsModule { this.state.linkedPlatforms.forEach(platform => { this.state.updatePlatform(platform, { enabled: enabledPlatforms.includes(platform) }); }); + /* + * If there's exactly one enabled platform, set primaryChat to it, + * ensures there's a primary platform if the user has multiple selected and then + * deselects all but one + */ + if (this.state.enabledPlatforms.length === 1) { + this.setPrimaryChat(this.state.enabledPlatforms[0]); + } + /* + * This should only trigger on free user mode: when toggling another platform + * when TikTok is enabled, set primary chat to that platform instead of TikTok + */ + if ( + this.state.enabledPlatforms.length === 2 && + this.state.enabledPlatforms.includes('tiktok') + ) { + const otherPlatform = this.state.enabledPlatforms.find(platform => platform !== 'tiktok'); + + // This is always true, but to make TS happy and code explicit, we null check here + if (otherPlatform) { + this.setPrimaryChat(otherPlatform); + } + } + this.save(this.state.settings); this.prepopulate(); } @@ -237,6 +268,19 @@ export class GoLiveSettingsModule { [], ); } + get primaryChat() { + const primaryPlatform = Services.UserService.views.platform!; + // this is migration-like code for users with old primary platform deselected (i.e me) + if (!this.state.enabledPlatforms.includes(primaryPlatform.type)) { + return this.state.enabledPlatforms[0]; + } + + return Services.UserService.views.platform!.type; + } + + setPrimaryChat(platform: TPlatform) { + Services.UserService.actions.setPrimaryPlatform(platform); + } /** * Determine if all dual output go live requirements are fulfilled @@ -311,6 +355,21 @@ export class GoLiveSettingsModule { message.success($t('Successfully updated')); } } + + /** + * Returns whether the user has any active destinations, be it an enabled platform or a custom destination + */ + get hasDestinations() { + return this.state.enabledPlatforms.length > 0 || this.state.customDestinations.length > 0; + } + + get hasMultiplePlatforms() { + return this.state.enabledPlatforms.length > 1; + } + + get isRestreamEnabled() { + return Services.RestreamService.views.canEnableRestream; + } } export function useGoLiveSettings() { diff --git a/app/i18n/en-US/common.json b/app/i18n/en-US/common.json index 1c28aa213e76..5a3abfe52b69 100644 --- a/app/i18n/en-US/common.json +++ b/app/i18n/en-US/common.json @@ -23,6 +23,7 @@ "Dropped Frames": "Dropped Frames", "Cancel": "Cancel", "Done": "Done", + "Are you sure you want to log out?": "Are you sure you want to log out?", "Are you sure you want to log out %{username}?": "Are you sure you want to log out %{username}?", "Name": "Name", "None": "None", @@ -93,6 +94,7 @@ "OBSInit.UnknownError": "An unknown error was encountered while initializing Streamlabs.", "OBSInit.ModuleNotFoundError": "DirectX could not be found on your system. Please install the latest version of DirectX for your machine here and try again.", "OBSInit.NotSupportedError": "Failed to initialize Streamlabs. Your video drivers may be out of date, or Streamlabs Desktop may not be supported on your system.", + "Logged In": "Logged In", "Log Out": "Log Out", "Layout Editor": "Layout Editor", "Are you sure you want to import multiple files?": "Are you sure you want to import multiple files?", diff --git a/app/i18n/en-US/streaming.json b/app/i18n/en-US/streaming.json index 9abd53a73a02..afd2307c6091 100644 --- a/app/i18n/en-US/streaming.json +++ b/app/i18n/en-US/streaming.json @@ -234,5 +234,6 @@ "confirm Live Access status with TikTok": "confirm Live Access status with TikTok", "unlink and re-merge TikTok account, then restart Desktop": "unlink and re-merge TikTok account, then restart Desktop", "re-login or re-merge TikTok account": "re-login or re-merge TikTok account", - "Streaming to platform is temporarily not available, confirm streaming approval and output settings.": "Streaming to platform is temporarily not available, confirm streaming approval and output settings." + "Streaming to platform is temporarily not available, confirm streaming approval and output settings.": "Streaming to platform is temporarily not available, confirm streaming approval and output settings.", + "Primary Chat": "Primary Chat" } diff --git a/app/i18n/en-US/twitch.json b/app/i18n/en-US/twitch.json index 5a1db98579b7..a20535b802df 100644 --- a/app/i18n/en-US/twitch.json +++ b/app/i18n/en-US/twitch.json @@ -5,7 +5,10 @@ "Do not include special characters or spaces in your tag": "Do not include special characters or spaces in your tag", "For example: \"Speedrunning\" or \"FirstPlaythrough\"": "For example: \"Speedrunning\" or \"FirstPlaythrough\"", "Your Twitch access token has expired. Please log in with Twitch to continue.": "Your Twitch access token has expired. Please log in with Twitch to continue.", + "While updating your Twitch channel info, some tags were removed due to moderation rules: %{tags}": "While updating your Twitch channel info, some tags were removed due to moderation rules: %{tags}", + "Content Classification": "Content Classification", "Content classification": "Content classification", + "Stream features branded content": "Stream features branded content", "Twitch Studio Import": "Twitch Studio Import", "Import from Twitch Studio": "Import from Twitch Studio", "Import your scenes and sources from Twitch Studio.": "Import your scenes and sources from Twitch Studio.", diff --git a/app/services/platforms/instagram.ts b/app/services/platforms/instagram.ts index 686b78f2af41..aa583ea21ece 100644 --- a/app/services/platforms/instagram.ts +++ b/app/services/platforms/instagram.ts @@ -108,7 +108,7 @@ export class InstagramService } get liveDockEnabled(): boolean { - return false; + return this.streamingService.views.isMultiplatformMode; } // Reset stream key since Instagram decided they change for each stream diff --git a/app/services/streaming/streaming-view.ts b/app/services/streaming/streaming-view.ts index ea044e1b53a9..da5c50907db9 100644 --- a/app/services/streaming/streaming-view.ts +++ b/app/services/streaming/streaming-view.ts @@ -113,24 +113,11 @@ export class StreamInfoView extends ViewHandler { } /** - * Returns a list of linked platforms available for restream - * @remark If TikTok is linked, users can always stream to it + * Returns a list of linked platforms available */ get linkedPlatforms(): TPlatform[] { if (!this.userView.state.auth) return []; - if ( - (!this.restreamView.canEnableRestream || !this.protectedModeEnabled) && - !this.isDualOutputMode - ) { - return compact([ - this.userView.auth!.primaryPlatform, - this.userView.auth!.primaryPlatform !== 'tiktok' && - this.isPlatformLinked('tiktok') && - 'tiktok', - ]); - } - return this.allPlatforms.filter(p => this.isPlatformLinked(p)); } @@ -266,10 +253,22 @@ export class StreamInfoView extends ViewHandler { /** * Chat url of a primary platform + * If the primary platform is not enabled, and we're on single stream mode, + * returns the URL of the first enabled platform */ get chatUrl(): string { if (!this.userView.isLoggedIn || !this.userView.auth) return ''; - return getPlatformService(this.userView.auth.primaryPlatform)?.chatUrl; + + const enabledPlatforms = this.enabledPlatforms; + const platform = this.enabledPlatforms.includes(this.userView.auth.primaryPlatform) + ? this.userView.auth.primaryPlatform + : enabledPlatforms[0]; + + if (platform) { + return getPlatformService(platform).chatUrl; + } + + return ''; } getTweetText(streamTitle: string) { @@ -291,6 +290,20 @@ export class StreamInfoView extends ViewHandler { const savedGoLiveSettings = this.streamSettingsView.state.goLiveSettings; + /* + * TODO: this should be done as a migration, if needed, but having it + * here seems to ensure we always have a primary platform, no app restart needed. + * we would ideally run this only if restream can be enabled, but multistream tests fail if we get that specific + */ + const areNoPlatformsEnabled = () => Object.values(platforms!).every(p => !p.enabled); + + if (areNoPlatformsEnabled()) { + const primaryPlatform = this.userView.auth?.primaryPlatform; + if (primaryPlatform && platforms[primaryPlatform]) { + platforms[primaryPlatform]!.enabled = true; + } + } + return { platforms, advancedMode: !!this.streamSettingsView.state.goLiveSettings?.advancedMode, @@ -361,15 +374,15 @@ export class StreamInfoView extends ViewHandler { /** * Sort the platform list - * - the primary platform is always first * - linked platforms are always on the top of the list * - the rest has an alphabetic sort + * + * We no longer put primary platform on top since we're allowing it to be switched */ getSortedPlatforms(platforms: TPlatform[]): TPlatform[] { platforms = platforms.sort(); return [ - ...platforms.filter(p => this.isPrimaryPlatform(p)), - ...platforms.filter(p => !this.isPrimaryPlatform(p) && this.isPlatformLinked(p)), + ...platforms.filter(p => this.isPlatformLinked(p)), ...platforms.filter(p => !this.isPlatformLinked(p)), ]; } @@ -445,6 +458,10 @@ export class StreamInfoView extends ViewHandler { return this.settings.platforms[platform]; } + setPrimaryPlatform(platform: TPlatform) { + this.userView.setPrimaryPlatform(platform); + } + /** * Returns Go-Live settings for a given platform */ @@ -468,8 +485,8 @@ export class StreamInfoView extends ViewHandler { return { ...settings, + enabled, useCustomFields, - enabled: enabled || this.isPrimaryPlatform(platform), }; } @@ -496,4 +513,9 @@ export class StreamInfoView extends ViewHandler { get isIdle(): boolean { return !this.isStreaming && !this.isRecording; } + + // TODO: consolidate between this and GoLiveSettings + get hasDestinations() { + return this.enabledPlatforms.length > 0 || this.customDestinations.length > 0; + } } diff --git a/app/services/user/index.ts b/app/services/user/index.ts index 9303bc292926..c9aaed185beb 100644 --- a/app/services/user/index.ts +++ b/app/services/user/index.ts @@ -165,6 +165,7 @@ class UserViews extends ViewHandler { @Inject() hostsService: HostsService; @Inject() magicLinkService: MagicLinkService; @Inject() customizationService: CustomizationService; + @Inject() userService: UserService; get settingsServiceViews() { return this.getServiceViews(SettingsService); @@ -266,6 +267,10 @@ class UserViews extends ViewHandler { return `${url}?token=${token}&mode=${nightMode}`; } + + setPrimaryPlatform(platform: TPlatform) { + this.userService.setPrimaryPlatform(platform); + } } export class UserService extends PersistentStatefulService { @@ -286,6 +291,10 @@ export class UserService extends PersistentStatefulService { @Inject() private usageStatisticsService: UsageStatisticsService; @Inject('TikTokService') tiktokService: TikTokService; + setPrimaryPlatform(platform: TPlatform) { + this.SET_PRIMARY_PLATFORM(platform); + } + @mutation() LOGIN(auth: IUserAuth) { Vue.set(this.state, 'auth', auth); @@ -1090,6 +1099,11 @@ export class UserService extends PersistentStatefulService { this.LOGOUT(); this.LOGIN(auth); + // We need to fetch prime status to skip onboarding step for + // picking a primary platform if the user has Ultra, as we'll + // auto-select the first one in that case. + await this.setPrimeStatus(); + // Find out if the user has any additional platforms linked await this.updateLinkedPlatforms(); return EPlatformCallResult.Success; diff --git a/main.js b/main.js index 8288f8e4b79f..b443741312ae 100644 --- a/main.js +++ b/main.js @@ -205,8 +205,8 @@ console.log(`Free: ${humanFileSize(os.freemem(), false)}`); console.log('================================='); app.on('ready', () => { + /* Load React DevTools in dev mode */ if (process.env.NODE_ENV === 'development') { - console.log('in dev mode'); const reactDevToolsPath = path.join(__dirname, 'vendor', 'react-devtools'); session.defaultSession .loadExtension(reactDevToolsPath, { allowFileAccess: true })