diff --git a/.gitignore b/.gitignore index 05416e8fa364..143d81e28a5c 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ license_en.txt !.yarn/sdks !.yarn/versions chromedriver.log +shared-resources/ai-highlighter/* diff --git a/app/app-services.ts b/app/app-services.ts index 66054c92f724..5f9777cd7560 100644 --- a/app/app-services.ts +++ b/app/app-services.ts @@ -74,6 +74,7 @@ export { export { FacebookService } from 'services/platforms/facebook'; export { TikTokService } from 'services/platforms/tiktok'; export { TrovoService } from 'services/platforms/trovo'; +export { KickService } from 'services/platforms/kick'; export { RestreamService } from 'services/restream'; export { TwitterService } from 'services/integrations/twitter'; export { TwitterPlatformService } from 'services/platforms/twitter'; @@ -81,6 +82,7 @@ export { InstagramService } from 'services/platforms/instagram'; export { UsageStatisticsService } from './services/usage-statistics'; export { GameOverlayService } from 'services/game-overlay'; export { SharedStorageService } from 'services/integrations/shared-storage'; +export { RemoteControlService } from 'services/api/remote-control-api'; export { MediaGalleryService } from 'services/media-gallery'; export { MediaBackupService } from 'services/media-backup'; @@ -205,7 +207,9 @@ import { SharedStorageService } from 'services/integrations/shared-storage'; import { RealmService } from 'services/realm'; import { InstagramService } from 'services/platforms/instagram'; import { TwitchStudioImporterService } from 'services/ts-importer'; +import { RemoteControlService } from 'services/api/remote-control-api'; import { UrlService } from 'services/hosts'; +import { KickService } from 'services/platforms/kick'; export const AppServices = { AppService, @@ -238,6 +242,7 @@ export const AppServices = { TwitchTagsService, TwitchContentClassificationService, TrovoService, + KickService, InstagramService, DismissablesService, HighlighterService, @@ -284,5 +289,6 @@ export const AppServices = { MarkersService, SharedStorageService, RealmService, + RemoteControlService, UrlService, }; diff --git a/app/components-react/editor/elements/SceneSelector.m.less b/app/components-react/editor/elements/SceneSelector.m.less index 1996a7ff3fae..35f55feda9c0 100644 --- a/app/components-react/editor/elements/SceneSelector.m.less +++ b/app/components-react/editor/elements/SceneSelector.m.less @@ -6,7 +6,7 @@ align-items: center; justify-content: flex-end; - > div:last-child { + >div:last-child { top: 44px !important; height: calc(100% - 44px); } @@ -34,7 +34,7 @@ } :global(.no-top-padding) { - .top-container > div:last-child { + .top-container>div:last-child { top: 32px !important; height: calc(100% - 32px); } @@ -99,9 +99,15 @@ border-radius: 0 !important; } + :global(.ant-tree-treenode) { + padding: 0 !important; + } + :global(.ant-tree-node-content-wrapper) { padding-left: 16px !important; display: flex; + padding-top: 4px !important; + padding-bottom: 4px !important; } :global(.ant-tree-node-selected) { @@ -165,7 +171,7 @@ display: flex; align-items: center; - > i { + >i { margin-right: 8px; opacity: 0; } @@ -196,7 +202,8 @@ .sources-container { :global(.ant-tree-switcher) { width: 0; - display: block; + display: flex; + align-items: center; z-index: 1; left: 10px; } @@ -204,6 +211,12 @@ :global(.ant-tree-node-content-wrapper) { padding-left: 32px !important; display: flex; + padding-top: 4px !important; + padding-bottom: 4px !important; + } + + :global(.ant-tree-treenode) { + padding: 0 !important; } :global(.ant-tree-title) { @@ -216,9 +229,16 @@ &::before { content: '\f07c'; } + color: var(--title); } } + + :global(.ant-tree-switcher i) { + vertical-align: middle; + // Icomoon icons have needed this all along + display: inline-block; + } } .tree-mask { @@ -231,6 +251,7 @@ .toggle-error { padding: 0px !important; text-align: unset !important; + :global(.ant-message-notice-content) { padding: 4px 16px; } diff --git a/app/components-react/editor/elements/SourceSelector.tsx b/app/components-react/editor/elements/SourceSelector.tsx index 976553098521..b30263af1c09 100644 --- a/app/components-react/editor/elements/SourceSelector.tsx +++ b/app/components-react/editor/elements/SourceSelector.tsx @@ -53,6 +53,7 @@ class SourceSelectorController { private guestCamService = Services.GuestCamService; private dualOutputService = Services.DualOutputService; private userService = Services.UserService; + private tiktokService = Services.TikTokService; store = initStore({ expandedFoldersIds: [] as string[], @@ -676,6 +677,9 @@ class SourceSelectorController { Services.UsageStatisticsService.recordAnalyticsEvent('DualOutput', { type: 'ToggleOnDualOutput', source: 'SourceSelector', + isPrime: this.userService.isPrime, + platforms: this.streamingService.views.linkedPlatforms, + tiktokStatus: this.tiktokService.scope, }); if (!this.dualOutputService.views.dualOutputMode && this.selectiveRecordingEnabled) { @@ -784,6 +788,7 @@ function StudioControls() { active: ctrl.isDualOutputActive, })} onClick={() => ctrl.toggleDualOutput()} + data-testid={ctrl.isDualOutputActive ? 'dual-output-active' : 'dual-output-inactive'} /> diff --git a/app/components-react/editor/elements/mixer/GLVolmeters.tsx b/app/components-react/editor/elements/mixer/GLVolmeters.tsx index 8b1f2a056850..3123520d2bfd 100644 --- a/app/components-react/editor/elements/mixer/GLVolmeters.tsx +++ b/app/components-react/editor/elements/mixer/GLVolmeters.tsx @@ -1,13 +1,11 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { IVolmeter } from 'services/audio'; -import { Subscription } from 'rxjs'; import electron, { ipcRenderer } from 'electron'; import difference from 'lodash/difference'; import { compileShader, createProgram } from 'util/webgl/utils'; import vShaderSrc from 'util/webgl/shaders/volmeter.vert'; import fShaderSrc from 'util/webgl/shaders/volmeter.frag'; import { Services } from 'components-react/service-provider'; -import { injectWatch, useModule } from 'slap'; import { assertIsDefined, getDefined } from 'util/properties-type-guards'; // Configuration @@ -47,13 +45,20 @@ interface IVolmeterSubscription { * Component that renders the volume for audio sources via WebGL */ export default function GLVolmeters() { - const { setupNewCanvas } = useModule(GLVolmetersModule); const canvasRef = useRef(null); + // init controller on mount + const controller = useMemo(() => { + const controller = new GLVolmetersController(); + controller.init(); + return controller; + }, []); + // start rendering volmeters when the canvas is ready useEffect(() => { assertIsDefined(canvasRef.current); - setupNewCanvas(canvasRef.current); + controller.setupNewCanvas(canvasRef.current); + return () => controller.beforeDestroy(); // cleanup on unmount }, []); return ( @@ -75,9 +80,10 @@ export default function GLVolmeters() { ); } -class GLVolmetersModule { +class GLVolmetersController { private customizationService = Services.CustomizationService; private audioService = Services.AudioService; + private sourcesService = Services.SourcesService; subscriptions: Dictionary = {}; @@ -113,7 +119,6 @@ class GLVolmetersModule { private workerId: number; private requestedFrameId: number; private bgMultiplier = this.customizationService.isDarkTheme ? 0.2 : 0.5; - private customizationServiceSubscription: Subscription = null!; init() { this.workerId = electron.ipcRenderer.sendSync('getWorkerWindowId'); @@ -133,12 +138,6 @@ class GLVolmetersModule { }); } - // update volmeters subscriptions when audio sources change - watchAudioSources = injectWatch( - () => this.audioSources, - () => this.subscribeVolmeters(), - ); - /** * add or remove subscription for volmeters depending on current scene */ @@ -164,13 +163,19 @@ class GLVolmetersModule { subscription.lastEventTime = performance.now(); }; + const IDLE_PEAK = -60; + const INITIAL_PEAKS = [IDLE_PEAK, IDLE_PEAK]; + // create a subscription object this.subscriptions[sourceId] = { sourceId, // Assume 2 channels until we know otherwise. This prevents too much // visual jank as the volmeters are initializing. channelsCount: 2, - currentPeaks: [], + // HACK: Initialize currentPeaks to an idle-ish peak, if the source + // has never emitted any events, volmeters won't get drawn and we get + // a missing bar on app load or source device switch. + currentPeaks: INITIAL_PEAKS, prevPeaks: [], interpolatedPeaks: [], lastEventTime: 0, @@ -217,7 +222,6 @@ class GLVolmetersModule { // cancel next frame rendering cancelAnimationFrame(this.requestedFrameId); - this.customizationServiceSubscription.unsubscribe(); } setupNewCanvas($canvasEl: HTMLCanvasElement) { @@ -282,14 +286,7 @@ class GLVolmetersModule { // Vertex geometry for a unit square // eslint-disable-next-line - const positions = [ - 0, 0, - 0, 1, - 1, 0, - 1, 0, - 0, 1, - 1, 1, - ]; + const positions = [0, 0, 0, 1, 1, 0, 1, 0, 0, 1, 1, 1]; this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(positions), this.gl.STATIC_DRAW); @@ -326,7 +323,10 @@ class GLVolmetersModule { private setColorUniform(uniform: string, color: number[]) { const location = this.gl.getUniformLocation(this.program, uniform); // eslint-disable-next-line - this.gl.uniform3fv(location, color.map(c => c / 255)); + this.gl.uniform3fv( + location, + color.map(c => c / 255), + ); } private setCanvasSize() { diff --git a/app/components-react/highlighter/BlankSlate.tsx b/app/components-react/highlighter/BlankSlate.tsx deleted file mode 100644 index 40348cbc75f0..000000000000 --- a/app/components-react/highlighter/BlankSlate.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { CheckCircleOutlined, InfoCircleOutlined } from '@ant-design/icons'; -import HotkeyBinding, { getBindingString } from 'components-react/shared/HotkeyBinding'; -import { IHotkey } from 'services/hotkeys'; -import { Services } from 'components-react/service-provider'; -import { useVuex } from 'components-react/hooks'; -import { Button } from 'antd'; -import { SUPPORTED_FILE_TYPES } from 'services/highlighter/constants'; -import { SliderInput } from 'components-react/shared/inputs'; -import Form from 'components-react/shared/inputs/Form'; -import Scrollable from 'components-react/shared/Scrollable'; -import styles from '../pages/Highlighter.m.less'; -import { $t } from 'services/i18n'; -import Translate from 'components-react/shared/Translate'; - -export default function BlankSlate(p: { close: () => void }) { - const { HotkeysService, SettingsService, StreamingService } = Services; - const [hotkey, setHotkey] = useState(null); - const hotkeyRef = useRef(null); - const v = useVuex(() => ({ - settingsValues: SettingsService.views.values, - isStreaming: StreamingService.isStreaming, - })); - - const correctlyConfigured = - v.settingsValues.Output.RecRB && - v.settingsValues.General.ReplayBufferWhileStreaming && - !v.settingsValues.General.KeepReplayBufferStreamStops && - SUPPORTED_FILE_TYPES.includes(v.settingsValues.Output.RecFormat); - - function configure() { - SettingsService.actions.setSettingsPatch({ - General: { - ReplayBufferWhileStreaming: true, - KeepReplayBufferStreamStops: false, - }, - Output: { - RecRB: true, - }, - }); - - // We will only set recording format to mp4 if the user isn't already on - // a supported format. i.e. don't switch them from mov to mp4, but we will - // switch from flv to mp4. - if (!SUPPORTED_FILE_TYPES.includes(v.settingsValues.Output.RecFormat)) { - SettingsService.actions.setSettingsPatch({ Output: { RecFormat: 'mp4' } }); - } - } - - useEffect(() => { - HotkeysService.actions.return.getGeneralHotkeyByName('SAVE_REPLAY').then(hotkey => { - if (hotkey) setHotkey(hotkey); - }); - }, []); - - useEffect(() => { - if (!v.isStreaming) { - HotkeysService.actions.unregisterAll(); - - return () => { - if (hotkeyRef.current) { - // Implies a bind all - HotkeysService.actions.applyGeneralHotkey(hotkeyRef.current); - } else { - HotkeysService.actions.bindHotkeys(); - } - }; - } - }, [v.isStreaming]); - - function completedStepHeading(title: string) { - return ( -

- - {title} -

- ); - } - - function incompleteStepHeading(title: string) { - return ( -

- - {title} -

- ); - } - - function setReplayTime(time: number) { - SettingsService.actions.setSettingsPatch({ Output: { RecRBTime: time } }); - } - - return ( -
- -

{$t('Highlighter')}

-

- {$t( - 'The highlighter allows you to clip the best moments from your livestream and edit them together into an exciting highlight video you can upload directly to YouTube.', - )} -

-
-
-

{$t('Get Started')}

- {!v.isStreaming && ( -
- {correctlyConfigured - ? completedStepHeading($t('Configure the replay buffer')) - : incompleteStepHeading($t('Configure the replay buffer'))} - {correctlyConfigured ? ( -
{$t('The replay buffer is correctly configured')}
- ) : ( - - )} -
- )} - {!v.isStreaming && ( -
- {completedStepHeading($t('Adjust replay duration'))} -
- {$t('Set the duration of captured replays. You can always trim them down later.')} -
-
- `${v}s`} - /> - -
- )} - {!v.isStreaming && ( -
- {hotkey?.bindings.length - ? completedStepHeading($t('Set a hotkey to capture replays')) - : incompleteStepHeading($t('Set a hotkey to capture replays'))} - {hotkey && ( - { - const newHotkey = { ...hotkey }; - newHotkey.bindings.splice(0, 1, binding); - setHotkey(newHotkey); - hotkeyRef.current = newHotkey; - }} - /> - )} -
- )} -
- {incompleteStepHeading($t('Capture a replay'))} - {!!hotkey?.bindings.length && ( -
- -
- )} - {!hotkey?.bindings.length && ( -
- {$t('Start streaming and capture a replay. Check back here after your stream.')} -
- )} -
- {$t('Or, import a clip from your computer')} -
-
-
-
-
-
- ); -} diff --git a/app/components-react/highlighter/ClipPreview.m.less b/app/components-react/highlighter/ClipPreview.m.less new file mode 100644 index 000000000000..1c3e75046124 --- /dev/null +++ b/app/components-react/highlighter/ClipPreview.m.less @@ -0,0 +1,119 @@ +@import '../../styles/index'; + +.highlighted { + background-color: #4f5e65; +} + +.preview-clip { + background-color: #2b383f; + border-radius: 16px; + display: flex; + gap: 16px; + overflow: hidden; +} + +.preview-image { + object-fit: none; + border-radius: 10px; +} + +.deleted-preview { + border-radius: 10px; + background: black; + vertical-align: middle; + display: inline-block; + position: relative; +} + +.deleted-icon { + position: absolute; + text-align: center; + width: 100%; + font-size: 72px; + top: 27%; +} + +.enable-button { + position: absolute; + top: 10px; + left: 10px; +} + +.preview-clip-moving { + position: absolute; + bottom: 10px; + padding-left: 10px; + padding-right: 10px; + left: 0; + width: 320px; + height: 130px; + justify-content: end; + display: flex; + align-items: center; + flex-direction: column; + gap: 8px; + pointer-events: none; + background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.8)); + transform: translateY(48px); + transition: transform 100ms; +} + +.preview-clip:hover { + .preview-clip-moving { + transform: translateY(12px); + padding-bottom: 12px; + } + .preview-clip-bottom-bar { + opacity: 1; + } +} + +.duration-info { + display: flex; +} + +.duration-label { + padding: 4px 6px; + background-color: #00000070; + border-radius: 4px; + color: white; +} + +.controls-container { + display: flex; + width: 100%; + justify-content: space-between; +} + +.highlighter-icon { + font-size: 19px; + transform: translateX(-6px); +} + +.preview-clip-bottom-bar { + display: flex; + width: 100%; + justify-content: space-between; + opacity: 0; + transition: opacity 100ms; +} + +.action-button { + display: flex; + gap: 8px; + align-items: center; + pointer-events: auto; +} + +.round-tag { + padding: 4px 6px; + background-color: #00000070; + border-radius: 4px; + color: white; +} + +.flame-hypescore-wrapper { + position: absolute; + top: 7px; + right: 9px; +} diff --git a/app/components-react/highlighter/ClipPreview.tsx b/app/components-react/highlighter/ClipPreview.tsx index eee00f0e4307..24a63b9bce86 100644 --- a/app/components-react/highlighter/ClipPreview.tsx +++ b/app/components-react/highlighter/ClipPreview.tsx @@ -1,133 +1,172 @@ -import { IClip } from 'services/highlighter'; +import { TClip } from 'services/highlighter'; import { SCRUB_HEIGHT, SCRUB_WIDTH, SCRUB_FRAMES } from 'services/highlighter/constants'; -import React, { useMemo, useState } from 'react'; -import path from 'path'; +import React, { useState } from 'react'; import { Services } from 'components-react/service-provider'; import { BoolButtonInput } from 'components-react/shared/inputs/BoolButtonInput'; -import styles from '../pages/Highlighter.m.less'; -import cx from 'classnames'; -import { Tooltip } from 'antd'; +import styles from './ClipPreview.m.less'; +import { Button } from 'antd'; import { $t } from 'services/i18n'; +import { isAiClip } from './utils'; +import { InputEmojiSection } from './InputEmojiSection'; +import { useVuex } from 'components-react/hooks'; export default function ClipPreview(props: { - clip: IClip; - showTrim: () => void; - showRemove: () => void; + clipId: string; + streamId: string | undefined; + emitShowTrim: () => void; + emitShowRemove: () => void; }) { const { HighlighterService } = Services; - const [scrubFrame, setScrubFrame] = useState(0); - const filename = useMemo(() => { - return path.basename(props.clip.path); - }, [props.clip.path]); - // Deleted clips always show as disabled - const enabled = props.clip.deleted ? false : props.clip.enabled; + const v = useVuex(() => ({ + clip: HighlighterService.views.clipsDictionary[props.clipId] as TClip, + })); + + const [scrubFrame, setScrubFrame] = useState(0); + const clipThumbnail = v.clip.scrubSprite || ''; + const enabled = v.clip.deleted ? false : v.clip.enabled; + + if (!v.clip) { + return <>deleted; + } function mouseMove(e: React.MouseEvent) { const frameIdx = Math.floor((e.nativeEvent.offsetX / SCRUB_WIDTH) * SCRUB_FRAMES); - if (scrubFrame !== frameIdx) { setScrubFrame(frameIdx); } } function setEnabled(enabled: boolean) { - HighlighterService.actions.enableClip(props.clip.path, enabled); + HighlighterService.actions.enableClip(v.clip.path, enabled); } return ( -
- {!props.clip.deleted && ( - - )} - {props.clip.deleted && ( -
- +
+ {!v.clip.deleted && ( + + )} + {v.clip.deleted && ( +
+ +
+ )} +
+ {isAiClip(v.clip) && }
- )} - - - -
- {/* TODO: Let's not use the same icon as studio mode */} - - + - - - - -
-
- {`${props.clip.deleted ? '[DELETED] ' : ''}${filename}`} + +
+
+ {/* left */} +
+ + {formatSecondsToHMS(v.clip.duration! - (v.clip.startTrim + v.clip.endTrim) || 0)} + +
+ {/* right */} +
+
+ {isAiClip(v.clip) ? ( + + ) : ( +
+ +
+ )} +
+ {isAiClip(v.clip) && v.clip.aiInfo?.metadata?.round && ( +
{`Round: ${v.clip.aiInfo.metadata.round}`}
+ )} +
+
+
+ + +
+
); } + +export function formatSecondsToHMS(seconds: number): string { + const totalSeconds = Math.round(seconds); + if (totalSeconds === 0) { + return '0s'; + } + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const remainingSeconds = totalSeconds % 60; + return `${hours !== 0 ? hours.toString() + 'h ' : ''} ${ + minutes !== 0 ? minutes.toString() + 'm ' : '' + }${remainingSeconds !== 0 ? remainingSeconds.toString() + 's' : ''}`; +} + +function FlameHypeScore({ score }: { score: number }) { + if (score === undefined) { + return <>; + } + const normalizedScore = Math.min(1, Math.max(0, score)); + const fullFlames = Math.ceil(normalizedScore * 5); + + return ( +
+ {Array.from({ length: fullFlames }).map((_, index) => ( + 🔥 + ))} + + {Array.from({ length: 5 - fullFlames }).map((_, index) => ( + + 🔥 + + ))} +
+ ); +} diff --git a/app/components-react/highlighter/ClipTrimmer.tsx b/app/components-react/highlighter/ClipTrimmer.tsx index d4b7af978327..d88a6bca706d 100644 --- a/app/components-react/highlighter/ClipTrimmer.tsx +++ b/app/components-react/highlighter/ClipTrimmer.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState, RefObject } from 'react'; -import { IClip } from 'services/highlighter'; +import { TClip } from 'services/highlighter'; import { SCRUB_FRAMES, SCRUB_HEIGHT, SCRUB_WIDTH } from 'services/highlighter/constants'; import { Services } from 'components-react/service-provider'; import times from 'lodash/times'; @@ -27,7 +27,7 @@ function useStateRef(initialValue: T): [RefObject, (newValue: T) => void] ]; } -export default function ClipTrimmer(props: { clip: IClip }) { +export default function ClipTrimmer(props: { clip: TClip }) { const { HighlighterService, UsageStatisticsService } = Services; const videoRef = useRef(null); const timelineRef = useRef(null); @@ -151,14 +151,17 @@ export default function ClipTrimmer(props: { clip: IClip }) { function stopDragging() { if (isDragging.current === 'start') { HighlighterService.actions.setStartTrim(props.clip.path, localStartTrim); - UsageStatisticsService.actions.recordAnalyticsEvent('Highlighter', { type: 'Trim' }); } else if (isDragging.current === 'end') { HighlighterService.actions.setEndTrim(props.clip.path, localEndTrim); - UsageStatisticsService.actions.recordAnalyticsEvent('Highlighter', { type: 'Trim' }); } isDragging.current = null; playAt(localStartTrim); + + UsageStatisticsService.actions.recordAnalyticsEvent( + HighlighterService.state.useAiHighlighter ? 'AIHighlighter' : 'Highlighter', + { type: 'Trim' }, + ); } const scrubHeight = 100; diff --git a/app/components-react/highlighter/ClipsFilter.tsx b/app/components-react/highlighter/ClipsFilter.tsx new file mode 100644 index 000000000000..ec275dc195e1 --- /dev/null +++ b/app/components-react/highlighter/ClipsFilter.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Tabs, Button, Dropdown, Menu } from 'antd'; +import { FilterOutlined } from '@ant-design/icons'; + +const { TabPane } = Tabs; + +interface ClipsFilterProps { + activeFilter: string; + onFilterChange: (filter: string) => void; +} + +export default function ClipsFilter({ activeFilter, onFilterChange }: ClipsFilterProps) { + const additionalFiltersMenu = ( + + Filter by Duration + Filter by Date + + ); + + return ( +
+ + + + + + + + +
+ ); +} diff --git a/app/components-react/highlighter/ClipsView.m.less b/app/components-react/highlighter/ClipsView.m.less new file mode 100644 index 000000000000..50c069546b28 --- /dev/null +++ b/app/components-react/highlighter/ClipsView.m.less @@ -0,0 +1,71 @@ +@import '../../styles/index'; + +.clips-view-root { + position: relative; + width: 100%; + display: flex; + + :global(.ant-modal-wrap), + :global(.ant-modal-mask) { + position: absolute; + } + :global(.os-content-glue) { + width: 100% !important; + height: 100% !important; + } +} + +.container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +.header { + padding: 20px; + display: flex; + gap: 8px; + align-items: center; + cursor: pointer; +} + +.backButton { + padding-top: 2px; + border: none; + background: none; +} + +.title { + margin: 0; +} + +.clips-controls { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 0 20px 2px; + padding-top: 16px; +} + +.clips-container { + flex-grow: 1; + padding: 20px; +} + +.clip-loading-indicator { + width: 100%; + font-size: 16px; + margin: auto; + display: grid; + place-content: center; +} + +.clip-item { + border-radius: 18px; + margin: 10px 20px 10px 0; + display: inline-block; + background-color: #111111; + border-radius: 16px; +} diff --git a/app/components-react/highlighter/ClipsView.tsx b/app/components-react/highlighter/ClipsView.tsx new file mode 100644 index 000000000000..870cd0fd9dfe --- /dev/null +++ b/app/components-react/highlighter/ClipsView.tsx @@ -0,0 +1,432 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import * as remote from '@electron/remote'; +import { Services } from 'components-react/service-provider'; +import styles from './ClipsView.m.less'; +import { EHighlighterView, IAiClip, IViewState, TClip } from 'services/highlighter'; +import ClipPreview, { formatSecondsToHMS } from 'components-react/highlighter/ClipPreview'; +import { ReactSortable } from 'react-sortablejs'; +import Scrollable from 'components-react/shared/Scrollable'; +import { EditingControls } from './EditingControls'; +import { + aiFilterClips, + getCombinedClipsDuration, + sortClipsByOrder, + useOptimizedHover, +} from './utils'; +import ClipsViewModal from './ClipsViewModal'; +import { useVuex } from 'components-react/hooks'; +import { Button } from 'antd'; +import { SUPPORTED_FILE_TYPES } from 'services/highlighter/constants'; +import { $t } from 'services/i18n'; +import path from 'path'; +import MiniClipPreview from './MiniClipPreview'; +import HighlightGenerator from './HighlightGenerator'; +import { EAvailableFeatures } from 'services/incremental-rollout'; + +export type TModalClipsView = 'trim' | 'export' | 'preview' | 'remove'; + +interface IClipsViewProps { + id: string | undefined; + streamTitle: string | undefined; +} + +export default function ClipsView({ + props, + emitSetView, +}: { + props: IClipsViewProps; + emitSetView: (data: IViewState) => void; +}) { + const { HighlighterService, UsageStatisticsService, IncrementalRolloutService } = Services; + const aiHighlighterEnabled = IncrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.aiHighlighter, + ); + const clipsAmount = useVuex(() => HighlighterService.views.clips.length); + const [clips, setClips] = useState<{ + ordered: { id: string }[]; + orderedFiltered: { id: string }[]; + }>({ ordered: [], orderedFiltered: [] }); + + const [activeFilter, setActiveFilter] = useState('all'); // Currently not using the setActiveFilter option + + const [clipsLoaded, setClipsLoaded] = useState(false); + const loadClips = useCallback(async (id: string | undefined) => { + await HighlighterService.actions.return.loadClips(id); + setClipsLoaded(true); + }, []); + + const getClips = useCallback(() => { + return HighlighterService.getClips(HighlighterService.views.clips, props.id); + }, [props.id]); + + useEffect(() => { + setClipsLoaded(false); + setClips(sortAndFilterClips(getClips(), props.id, activeFilter)); + loadClips(props.id); + }, [props.id, clipsAmount]); + + useEffect(() => { + setClips(sortAndFilterClips(getClips(), props.id, activeFilter)); + }, [activeFilter]); + + useEffect(() => UsageStatisticsService.actions.recordFeatureUsage('Highlighter'), []); + + const [modal, setModal] = useState<{ modal: TModalClipsView; inspectedPathId?: string } | null>( + null, + ); + + function setClipOrder(listClips: { id: string }[], streamId: string | undefined) { + const newOrderOfSomeItems = listClips.map(c => c.id); + const allItemArray = clips.ordered.map(c => c.id); + const newClipArray = createFinalSortedArray(newOrderOfSomeItems, allItemArray); + const oldClipArray = clips.ordered.map(c => c.id); + + if (JSON.stringify(newClipArray) === JSON.stringify(oldClipArray)) { + return; + } else { + if (streamId) { + newClipArray.forEach((clipId, index) => { + const existingClip = HighlighterService.views.clipsDictionary[clipId]; + let updatedStreamInfo; + if (existingClip) { + updatedStreamInfo = { + ...existingClip.streamInfo, + [streamId]: { + ...existingClip.streamInfo?.[streamId], + orderPosition: index, + }, + }; + } + + HighlighterService.actions.UPDATE_CLIP({ + path: clipId, + streamInfo: updatedStreamInfo, + }); + }); + } else { + newClipArray.forEach((clip, index) => { + const clipPath = clip; + HighlighterService.actions.UPDATE_CLIP({ + path: clipPath, + globalOrderPosition: index, + }); + }); + } + + const updatedClips = newClipArray.map( + clipId => HighlighterService.views.clipsDictionary[clipId], + ); + + setClips({ + ordered: newClipArray.map(clipPath => ({ id: clipPath })), + orderedFiltered: filterClipsBySource(updatedClips, activeFilter).map(clip => ({ + id: clip.path, + })), + }); + return; + } + } + function onDrop(e: React.DragEvent, streamId: string | undefined) { + const extensions = SUPPORTED_FILE_TYPES.map(e => `.${e}`); + const files: string[] = []; + let fi = e.dataTransfer.files.length; + while (fi--) { + const file = e.dataTransfer.files.item(fi)?.path; + if (file) files.push(file); + } + + const filtered = files.filter(f => extensions.includes(path.parse(f).ext)); + + if (filtered.length) { + HighlighterService.actions.addClips( + filtered.map(path => ({ path })), + streamId, + 'Manual', + ); + } + + e.preventDefault(); + e.stopPropagation(); + } + + const containerRef = useOptimizedHover(); + + function getClipsView( + streamId: string | undefined, + sortedList: { id: string }[], + sortedFilteredList: { id: string }[], + ) { + return ( +
onDrop(event, streamId)} + > +
+
+ +

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

+
+ {sortedList.length === 0 ? ( + /** Better empty state will come with ai PR */ +
+ {$t('No clips found')} +
+
+ { + setClips(sortAndFilterClips(getClips(), props.id, activeFilter)); + }} + /> +
+
+ ) : ( + <> + {clipsLoaded ? ( + <> +
+ { + setClips(sortAndFilterClips(getClips(), props.id, activeFilter)); + }} + /> + {streamId && + aiHighlighterEnabled && + HighlighterService.getClips(HighlighterService.views.clips, props.id) + .filter(clip => clip.source === 'AiClip') + .every(clip => (clip as IAiClip).aiInfo.metadata?.round) && ( + { + const clips = HighlighterService.getClips( + HighlighterService.views.clips, + props.id, + ); + const filteredClips = aiFilterClips(clips, streamId, filterOptions); + const filteredClipPaths = new Set(filteredClips.map(c => c.path)); + + clips.forEach(clip => { + const shouldBeEnabled = filteredClipPaths.has(clip.path); + const isEnabled = clip.enabled; + + if (shouldBeEnabled && !isEnabled) { + HighlighterService.enableClip(clip.path, true); + } else if (!shouldBeEnabled && isEnabled) { + HighlighterService.disableClip(clip.path); + } + }); + }} + combinedClipsDuration={getCombinedClipsDuration(getClips())} + roundDetails={HighlighterService.getRoundDetails(getClips())} + /> + )} +
+ + setClipOrder(clips, props.id)} + animation={200} + filter=".sortable-ignore" + onMove={e => { + return e.related.className.indexOf('sortable-ignore') === -1; + }} + > + {sortedFilteredList.map(({ id }) => { + const clip = HighlighterService.views.clipsDictionary[id]; + return ( +
+ { + setModal({ modal: 'trim', inspectedPathId: id }); + }} + emitShowRemove={() => { + setModal({ modal: 'remove', inspectedPathId: id }); + }} + streamId={streamId} + /> +
+ ); + })} +
+
+ + ) : ( + + )} + + )} +
+ { + setModal({ modal }); + }} + /> + setModal(null)} + deleteClip={(clipId, streamId) => + setClips( + sortAndFilterClips( + HighlighterService.getClips(HighlighterService.views.clips, props.id).filter( + clip => clip.path !== clipId, + ), + streamId, + 'all', + ), + ) + } + /> +
+ ); + } + + return getClipsView( + props.id, + clips.ordered.map(clip => ({ id: clip.id })), + clips.orderedFiltered.map(clip => ({ id: clip.id })), + ); +} + +// Temporary not used. Will be used in the next version +function VideoDuration({ streamId }: { streamId: string | undefined }) { + const { HighlighterService } = Services; + + const clips = useVuex(() => + HighlighterService.getClips(HighlighterService.views.clips, streamId), + ); + + const totalDuration = clips + .filter(clip => clip.enabled) + .reduce((acc, clip) => acc + clip.duration! - clip.startTrim! - clip.endTrim!, 0); + + return {formatSecondsToHMS(totalDuration)}; +} + +function AddClip({ + streamId, + addedClips, +}: { + streamId: string | undefined; + addedClips: () => void; +}) { + const { HighlighterService } = Services; + + async function openClips() { + const selections = await remote.dialog.showOpenDialog(remote.getCurrentWindow(), { + properties: ['openFile', 'multiSelections'], + filters: [{ name: $t('Video Files'), extensions: SUPPORTED_FILE_TYPES }], + }); + + if (selections && selections.filePaths) { + await HighlighterService.actions.return.addClips( + selections.filePaths.map(path => ({ path })), + streamId, + 'Manual', + ); + await HighlighterService.actions.return.loadClips(streamId); + addedClips(); + } + } + return ( + + ); +} + +function ClipsLoadingView({ streamId }: { streamId: string | undefined }) { + const { HighlighterService } = Services; + const clips = useVuex(() => + HighlighterService.getClips(HighlighterService.views.clips, streamId), + ); + + return ( +
+

{$t('Loading')}

+

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

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

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

+

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

+ + +
+ ); +} diff --git a/app/components-react/highlighter/EditingControls.tsx b/app/components-react/highlighter/EditingControls.tsx new file mode 100644 index 000000000000..ad9397c5f1a4 --- /dev/null +++ b/app/components-react/highlighter/EditingControls.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import Form from 'components-react/shared/inputs/Form'; +import { SliderInput, FileInput, SwitchInput } from 'components-react/shared/inputs'; +import { Button } from 'antd'; +import path from 'path'; +import Scrollable from 'components-react/shared/Scrollable'; +import Animate from 'rc-animate'; +import TransitionSelector from 'components-react/highlighter/TransitionSelector'; +import { $t } from 'services/i18n'; +import { TModalClipsView } from './ClipsView'; +import { useVuex } from 'components-react/hooks'; +import { Services } from 'components-react/service-provider'; +import { SUPPORTED_FILE_TYPES } from 'services/highlighter/constants'; +import { RenderingClip } from 'services/highlighter/clip'; + +export function EditingControls({ + emitSetShowModal, +}: { + emitSetShowModal: (modal: TModalClipsView | null) => void; +}) { + const { HighlighterService } = Services; + + const v = useVuex(() => ({ + transition: HighlighterService.views.transition, + audio: HighlighterService.views.audio, + video: HighlighterService.views.video, + error: HighlighterService.views.error, + })); + + function setTransitionDuration(duration: number) { + HighlighterService.actions.setTransition({ duration }); + } + + function setMusicEnabled(enabled: boolean) { + HighlighterService.actions.setAudio({ musicEnabled: enabled }); + } + + const musicExtensions = ['mp3', 'wav', 'flac']; + const videoExtensions = SUPPORTED_FILE_TYPES; + + function setMusicFile(file: string) { + if (!musicExtensions.map(e => `.${e}`).includes(path.parse(file).ext)) return; + HighlighterService.actions.setAudio({ musicPath: file }); + } + + async function setVideoFile(file: string, type: 'intro' | 'outro') { + if (!videoExtensions.map(e => `.${e}`).includes(path.parse(file).ext)) return; + const tempClip = new RenderingClip(file); + await tempClip.init(); + HighlighterService.actions.setVideo({ [type]: { path: file, duration: tempClip.duration } }); + } + function removeVideoFile(type: 'intro' | 'outro') { + HighlighterService.actions.setVideo({ [type]: { path: '', duration: null } }); + } + + function setMusicVolume(volume: number) { + HighlighterService.actions.setAudio({ musicVolume: volume }); + } + + return ( + +
+ + `${v}s`} + /> + +
+ { + setVideoFile(e, 'intro'); + }} + /> + {v.video.intro.path && ( +
+
+ )} +
+
+ { + setVideoFile(e, 'outro'); + }} + /> + {v.video.outro.path && ( +
+
+ )} +
+ + + {v.audio.musicEnabled && ( +
+ + `${v}%`} + /> +
+ )} +
+ + + +
+ ); +} diff --git a/app/components-react/highlighter/ExportModal.m.less b/app/components-react/highlighter/ExportModal.m.less index 42ce669e2680..0d3302d9ddd9 100644 --- a/app/components-react/highlighter/ExportModal.m.less +++ b/app/components-react/highlighter/ExportModal.m.less @@ -1,5 +1,5 @@ .crossclip-container { - height: 300px; + min-height: 300px; display: flex; flex-direction: column; align-items: center; @@ -15,8 +15,8 @@ .sign-up-title { text-align: center; - font-size: 32px; - font-weight: 300; + font-size: 32px; + font-weight: 300; } .log-in { diff --git a/app/components-react/highlighter/ExportModal.tsx b/app/components-react/highlighter/ExportModal.tsx index d0b9c56f4747..a2d30f159afb 100644 --- a/app/components-react/highlighter/ExportModal.tsx +++ b/app/components-react/highlighter/ExportModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo } from 'react'; -import { EExportStep, TFPS, TResolution, TPreset } from 'services/highlighter'; +import { EExportStep, TFPS, TResolution, TPreset, TOrientation } from 'services/highlighter'; import { Services } from 'components-react/service-provider'; import { FileInput, TextInput, ListInput } from 'components-react/shared/inputs'; import Form from 'components-react/shared/inputs/Form'; @@ -23,10 +23,19 @@ class ExportController { get exportInfo() { return this.service.views.exportInfo; } + getStreamTitle(streamId?: string) { + return ( + this.service.views.highlightedStreams.find(stream => stream.id === streamId)?.title || + 'My Video' + ); + } dismissError() { return this.service.actions.dismissError(); } + resetExportedState() { + return this.service.actions.resetExportedState(); + } setResolution(value: string) { this.service.actions.setResolution(parseInt(value, 10) as TResolution); @@ -44,8 +53,8 @@ class ExportController { this.service.actions.setExportFile(exportFile); } - exportCurrentFile() { - this.service.actions.export(); + exportCurrentFile(streamId: string | undefined, orientation: TOrientation = 'horizontal') { + this.service.actions.export(false, streamId, orientation); } cancelExport() { @@ -63,24 +72,47 @@ class ExportController { export const ExportModalCtx = React.createContext(null); -export default function ExportModalProvider(p: { close: () => void }) { +export default function ExportModalProvider({ + close, + streamId, +}: { + close: () => void; + streamId: string | undefined; +}) { const controller = useMemo(() => new ExportController(), []); return ( - + ); } -function ExportModal(p: { close: () => void }) { - const { exportInfo, dismissError } = useController(ExportModalCtx); +function ExportModal({ close, streamId }: { close: () => void; streamId: string | undefined }) { + const { exportInfo, dismissError, resetExportedState, getStreamTitle } = useController( + ExportModalCtx, + ); + const [videoName, setVideoName] = useState(getStreamTitle(streamId) + ' - highlights'); + + const unmount = () => { + dismissError(); + resetExportedState(); + }; // Clear all errors when this component unmounts - useEffect(dismissError, []); + useEffect(() => unmount, []); if (exportInfo.exporting) return ; - if (!exportInfo.exported) return ; - return ; + if (!exportInfo.exported) { + return ( + + ); + } + return ; } function ExportProgress() { @@ -122,7 +154,17 @@ function ExportProgress() { ); } -function ExportOptions(p: { close: () => void }) { +function ExportOptions({ + close, + streamId, + videoName, + onVideoNameChange, +}: { + close: () => void; + streamId: string | undefined; + videoName: string; + onVideoNameChange: (name: string) => void; +}) { const { UsageStatisticsService } = Services; const { exportInfo, @@ -133,10 +175,12 @@ function ExportOptions(p: { close: () => void }) { fileExists, setExport, exportCurrentFile, - store, + getStreamTitle, } = useController(ExportModalCtx); - const videoName = store.useState(s => s.videoName); + // Video name and export file are kept in sync + const [exportFile, setExportFile] = useState(getExportFileFromVideoName(videoName)); + function getExportFileFromVideoName(videoName: string) { const parsed = path.parse(exportInfo.file); const sanitized = videoName.replace(/[/\\?%*:|"<>\.,;=#]/g, ''); @@ -146,8 +190,27 @@ function ExportOptions(p: { close: () => void }) { function getVideoNameFromExportFile(exportFile: string) { return path.parse(exportFile).name; } - // Video name and export file are kept in sync - const [exportFile, setExportFile] = useState(getExportFileFromVideoName(videoName)); + + async function startExport(orientation: TOrientation) { + if (await fileExists(exportFile)) { + if ( + !(await confirmAsync({ + title: $t('Overwite File?'), + content: $t('%{filename} already exists. Would you like to overwrite it?', { + filename: path.basename(exportFile), + }), + okText: $t('Overwrite'), + })) + ) { + return; + } + } + + UsageStatisticsService.actions.recordFeatureUsage('HighlighterExport'); + + setExport(exportFile); + exportCurrentFile(streamId, orientation); + } return (
@@ -157,9 +220,7 @@ function ExportOptions(p: { close: () => void }) { label={$t('Video Name')} value={videoName} onInput={name => { - store.setState(s => { - s.videoName = name; - }); + onVideoNameChange(name); setExportFile(getExportFileFromVideoName(name)); }} uncontrolled={false} @@ -172,9 +233,7 @@ function ExportOptions(p: { close: () => void }) { value={exportFile} onChange={file => { setExportFile(file); - store.setState(s => { - s.videoName = getVideoNameFromExportFile(file); - }); + onVideoNameChange(getVideoNameFromExportFile(file)); }} /> void }) { /> )}
- - +
@@ -253,9 +294,8 @@ function ExportOptions(p: { close: () => void }) { ); } -function PlatformSelect(p: { onClose: () => void }) { - const { store, clearUpload } = useController(ExportModalCtx); - const videoName = store.useState(s => s.videoName); +function PlatformSelect({ onClose, videoName }: { onClose: () => void; videoName: string }) { + const { store, clearUpload, getStreamTitle } = useController(ExportModalCtx); const { UserService } = Services; const { isYoutubeLinked } = useVuex(() => ({ isYoutubeLinked: !!UserService.state.auth?.platforms.youtube, @@ -285,8 +325,8 @@ function PlatformSelect(p: { onClose: () => void }) { nowrap options={platformOptions} /> - {platform === 'youtube' && } - {platform !== 'youtube' && } + {platform === 'youtube' && } + {platform !== 'youtube' && } ); } diff --git a/app/components-react/highlighter/HighlightGenerator.m.less b/app/components-react/highlighter/HighlightGenerator.m.less new file mode 100644 index 000000000000..fa504f2f6e4b --- /dev/null +++ b/app/components-react/highlighter/HighlightGenerator.m.less @@ -0,0 +1,48 @@ +.wrapper { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 16px; + background: var(--border); + border-radius: 8px; + width: fit-content; +} + +.dropdown { + color: white; +} +.option { + width: 100%; + padding: 8px 12px; + margin-bottom: 4px; + border-radius: 8px; + overflow: hidden; + opacity: 1; +} + +.tag { + padding: 3px 8px; + width: fit-content; + font-size: 12px; + background-color: var(--border); + border-radius: 4px; + margin-right: 4px; + color: #ffffff; +} + +.info-tag { + border-radius: 4px; + color: #ffffff; + background-color: #3a484f; + padding: 2px 6px; +} + +.reset-button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 16px; + padding: 0; +} diff --git a/app/components-react/highlighter/HighlightGenerator.tsx b/app/components-react/highlighter/HighlightGenerator.tsx new file mode 100644 index 000000000000..c9465292c256 --- /dev/null +++ b/app/components-react/highlighter/HighlightGenerator.tsx @@ -0,0 +1,185 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Button, Select, Checkbox, Typography } from 'antd'; +import { DownOutlined, RobotOutlined } from '@ant-design/icons'; +import { IFilterOptions } from './utils'; +import { IInput } from 'services/highlighter'; +import { getPlacementFromInputs } from './InputEmojiSection'; +import { EHighlighterInputTypes } from 'services/highlighter/ai-highlighter/ai-highlighter'; +import styles from './HighlightGenerator.m.less'; +import { formatSecondsToHMS } from './ClipPreview'; +import { $t } from 'services/i18n'; +const { Option } = Select; + +const selectStyles = { + width: '220px', + borderRadius: '8px', +}; + +const dropdownStyles = { + borderRadius: '10px', + padding: '4px 4px', +}; + +const checkboxStyles = { + borderRadius: '8px', + width: '100%', +}; + +export default function HighlightGenerator({ + combinedClipsDuration, + roundDetails, + emitSetFilter, +}: { + combinedClipsDuration: number; // Maximum duration the highlight reel can be long - only used to restrict the targetDuration options + roundDetails: { + round: number; + inputs: IInput[]; + duration: number; + hypeScore: number; + }[]; + emitSetFilter: (filter: IFilterOptions) => void; +}) { + // console.log('reHIGHUI'); + + const [selectedRounds, setSelectedRounds] = useState([0]); + const [filterType, setFilterType] = useState<'duration' | 'hypescore'>('duration'); + const [targetDuration, setTargetDuration] = useState(combinedClipsDuration + 100); + const options = [ + { + value: 1, + label: $t('%{duration} minute', { duration: 1 }), + }, + ...[2, 5, 10, 12, 15, 20, 30].map(value => ({ + value, + label: $t('%{duration} minutes', { duration: value }), + })), + ]; + const filteredOptions = options.filter(option => option.value * 60 <= combinedClipsDuration); + const isFirstRender = useRef(true); + useEffect(() => { + // To not emit on first render + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + + emitSetFilter({ + rounds: selectedRounds, + targetDuration: filterType === 'duration' ? targetDuration * 60 : 9999, + includeAllEvents: true, + }); + }, [selectedRounds, filterType, targetDuration]); + + function roundDropdownDetails(roundDetails: { + round: number; + inputs: IInput[]; + duration: number; + hypeScore: number; + }) { + const combinedKillAndKnocked = roundDetails.inputs.reduce((count, input) => { + if ( + input.type === EHighlighterInputTypes.KILL || + input.type === EHighlighterInputTypes.KNOCKED + ) { + return count + 1; + } + return count; + }, 0); + const won = roundDetails.inputs.some(input => input.type === EHighlighterInputTypes.VICTORY); + let rank = null; + if (!won) { + rank = getPlacementFromInputs(roundDetails.inputs); + } + return ( +
+
Round {roundDetails.round}
+
+
{combinedKillAndKnocked} 🔫
+ {won ? ( +
1st 🏆
+ ) : ( +
{`${rank ? '#' + rank : ''} 🪦`}
+ )} +
{`${roundDetails.hypeScore} 🔥`}
+
{`${formatSecondsToHMS(roundDetails.duration)}`}
+
+
+ ); + } + + return ( +
+

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

+ +

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

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

{$t('Preview')}

+

Select at least one clip to preview your video

+
+ ); + } return (
-

{$t('Render Preview')}

+

{$t('Preview')}

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

- {v.exportInfo.exporting && ( - - )} - {v.exportInfo.exporting && v.exportInfo.cancelRequested && {$t('Canceling...')}} - {v.exportInfo.exporting &&
} - {v.exportInfo.exporting && ( - - )} - {!v.exportInfo.exporting && v.exportInfo.error && ( - - )} - {!v.exportInfo.exporting && - !v.exportInfo.cancelRequested && - !v.exportInfo.error && - didStartExport.current && ( -
); } diff --git a/app/components-react/highlighter/SettingsView.m.less b/app/components-react/highlighter/SettingsView.m.less new file mode 100644 index 000000000000..fc9ea0c282ce --- /dev/null +++ b/app/components-react/highlighter/SettingsView.m.less @@ -0,0 +1,110 @@ +@import '../../styles/index'; + +.settings-view-root { + position: relative; + width: 100%; + display: flex; + flex-direction: column; + + :global(.ant-modal-wrap), + :global(.ant-modal-mask) { + position: absolute; + } + + :global(.os-content-glue) { + width: 100% !important; + height: 100% !important; + } +} + +.inner-scroll-wrapper { + display: flex; + background-color: var(--section); + padding: 56px; + border-radius: 24px; + gap: 24px; +} + +.headerbar-tag { + margin: 0; + margin-left: 4px; + font-size: 14px; + padding: 2px 6px; + border-radius: 4px; + background-color: var(--section); +} +.card-wrapper { + display: flex; + flex-direction: column; + gap: 48px; + max-width: 700px; + // min-width: 500px; + width: 100%; +} + +.highlighter-card { + display: flex; + flex-direction: column; + gap: 24px; + position: relative; + background-color: var(--border); + padding: 40px; + border-radius: 16px; + border: solid 2px #2b5bd7; +} + +.recommended-corner { + position: absolute; + right: 0; + bottom: 0; + border-radius: 16px 0 9px 0; + padding: 8px; + padding-bottom: 5px; + background-color: #2b5bd7; + color: white; + height: fit-content; +} + +.manual-card { + display: flex; + flex-direction: column; + gap: 24px; + position: relative; + // background-color: #2b383f; + // padding: 40px; + border-radius: 10px; +} + +.card-header-title { + margin: 0; + font-size: 20px; +} + +.setting-section { + background-color: var(--border); + h3 { + } + + padding: 24px; + border-radius: 16px; + width: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; +} +.image { + display: flex; + align-items: center; + padding-left: 24px; + width: 100%; + background-image: url('https://slobs-cdn.streamlabs.com/media/highlighter-image.png'); + background-position: center; + background-size: contain; + background-repeat: no-repeat; +} + +.card-headerbar { + display: flex; + align-items: center; + gap: 8px; +} diff --git a/app/components-react/highlighter/SettingsView.tsx b/app/components-react/highlighter/SettingsView.tsx new file mode 100644 index 000000000000..e5193866be2d --- /dev/null +++ b/app/components-react/highlighter/SettingsView.tsx @@ -0,0 +1,246 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { InfoCircleOutlined } from '@ant-design/icons'; +import HotkeyBinding from 'components-react/shared/HotkeyBinding'; +import { IHotkey } from 'services/hotkeys'; +import { Services } from 'components-react/service-provider'; +import { useVuex } from 'components-react/hooks'; +import { Button } from 'antd'; +import { SUPPORTED_FILE_TYPES } from 'services/highlighter/constants'; +import { SliderInput, SwitchInput } from 'components-react/shared/inputs'; +import Form from 'components-react/shared/inputs/Form'; +import Scrollable from 'components-react/shared/Scrollable'; +import styles from './SettingsView.m.less'; +import { $t } from 'services/i18n'; +import { EHighlighterView, IViewState } from 'services/highlighter'; +import { EAvailableFeatures } from 'services/incremental-rollout'; + +export default function SettingsView({ + emitSetView, + close, +}: { + emitSetView: (data: IViewState) => void; + close: () => void; +}) { + const { + HotkeysService, + SettingsService, + StreamingService, + HighlighterService, + IncrementalRolloutService, + } = Services; + const aiHighlighterEnabled = IncrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.aiHighlighter, + ); + const [hotkey, setHotkey] = useState(null); + const hotkeyRef = useRef(null); + + const v = useVuex(() => ({ + settingsValues: SettingsService.views.values, + isStreaming: StreamingService.isStreaming, + useAiHighlighter: HighlighterService.views.useAiHighlighter, + })); + + const correctlyConfigured = + v.settingsValues.Output.RecRB && + v.settingsValues.General.ReplayBufferWhileStreaming && + !v.settingsValues.General.KeepReplayBufferStreamStops && + SUPPORTED_FILE_TYPES.includes(v.settingsValues.Output.RecFormat); + + function configure() { + SettingsService.actions.setSettingsPatch({ + General: { + ReplayBufferWhileStreaming: true, + KeepReplayBufferStreamStops: false, + }, + Output: { + RecRB: true, + }, + }); + + // We will only set recording format to mp4 if the user isn't already on + // a supported format. i.e. don't switch them from mov to mp4, but we will + // switch from flv to mp4. + if (!SUPPORTED_FILE_TYPES.includes(v.settingsValues.Output.RecFormat)) { + SettingsService.actions.setSettingsPatch({ Output: { RecFormat: 'mp4' } }); + } + } + + useEffect(() => { + HotkeysService.actions.return.getGeneralHotkeyByName('SAVE_REPLAY').then(hotkey => { + if (hotkey) setHotkey(hotkey); + }); + }, []); + + useEffect(() => { + if (!v.isStreaming) { + HotkeysService.actions.unregisterAll(); + + return () => { + if (hotkeyRef.current) { + // Implies a bind all + HotkeysService.actions.applyGeneralHotkey(hotkeyRef.current); + } else { + HotkeysService.actions.bindHotkeys(); + } + }; + } + }, [v.isStreaming]); + + function completedStepHeading(title: string) { + return ( +

+ {/* */} + {title} +

+ ); + } + + function incompleteStepHeading(title: string) { + return ( +

+ + {title} +

+ ); + } + + function setReplayTime(time: number) { + SettingsService.actions.setSettingsPatch({ Output: { RecRBTime: time } }); + } + + function toggleUseAiHighlighter() { + HighlighterService.actions.toggleAiHighlighter(); + } + + return ( +
+
+
+

{$t('Highlighter')}

+

+ {$t( + 'The highlighter allows you to clip the best moments from your livestream and edit them together into an exciting highlight video you can upload directly to YouTube.', + )} +

+
+
+ {aiHighlighterEnabled && ( + + )} + {/* New button coming with next PR */} + +
+
+ + +
+
+ {aiHighlighterEnabled && ( +
+
+
+ +

{$t('AI Highlighter')}

+

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

+
+
+ +

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

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

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

+

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

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

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

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

{stream.title}

+

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

+
+ +
+

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

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

{$t('Import Fortnite Stream')}

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

{$t('My Stream Highlights')}

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

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

+

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

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

Installing...

; + } else { + subtitle =

{Math.round(progress)}% complete

; + } + + return ( +
+
+

Downloading version {version}

+ {subtitle} +
+
+
+
+
+ ); +} diff --git a/app/components-react/highlighter/utils.ts b/app/components-react/highlighter/utils.ts new file mode 100644 index 000000000000..ccb77a4f8409 --- /dev/null +++ b/app/components-react/highlighter/utils.ts @@ -0,0 +1,220 @@ +import moment from 'moment'; +import { IAiClip, TClip } from 'services/highlighter'; +import { useRef, useEffect, useCallback } from 'react'; +import styles from './ClipsView.m.less'; +import { EHighlighterInputTypes } from 'services/highlighter/ai-highlighter/ai-highlighter'; +export const isAiClip = (clip: TClip): clip is IAiClip => clip.source === 'AiClip'; + +export function sortClipsByOrder(clips: TClip[], streamId: string | undefined): TClip[] { + let sortedClips; + + if (streamId) { + const clipsWithOrder = clips + .filter(c => c.streamInfo?.[streamId]?.orderPosition !== undefined && c.deleted !== true) + .sort( + (a: TClip, b: TClip) => + a.streamInfo![streamId]!.orderPosition - b.streamInfo![streamId]!.orderPosition, + ); + + const clipsWithOutOrder = clips.filter( + c => + (c.streamInfo === undefined || + c.streamInfo[streamId] === undefined || + c.streamInfo[streamId]?.orderPosition === undefined) && + c.deleted !== true, + ); + + sortedClips = [...clipsWithOrder, ...clipsWithOutOrder]; + } else { + sortedClips = clips + .filter(c => c.deleted !== true) + .sort((a: TClip, b: TClip) => a.globalOrderPosition - b.globalOrderPosition); + } + + return sortedClips; +} + +export const useOptimizedHover = () => { + const containerRef = useRef(null); + const lastHoveredId = useRef(null); + + const handleHover = useCallback((event: MouseEvent) => { + const target = event.target as HTMLElement; + const clipElement = target.closest('[data-clip-id]'); + const clipId = clipElement?.getAttribute('data-clip-id'); + + if (clipId === lastHoveredId.current) return; // Exit if hovering over the same element + + if (lastHoveredId.current) { + // Remove highlight from previously hovered elements + document + .querySelectorAll(`[data-clip-id="${lastHoveredId.current}"]`) + .forEach(el => el instanceof HTMLElement && el.classList.remove(styles.highlighted)); + } + + if (clipId) { + // Add highlight to newly hovered elements + document + .querySelectorAll(`[data-clip-id="${clipId}"]`) + .forEach(el => el instanceof HTMLElement && el.classList.add(styles.highlighted)); + lastHoveredId.current = clipId; + } else { + lastHoveredId.current = null; + } + }, []); + + useEffect(() => { + const container = containerRef.current; + if (container) { + container.addEventListener('mousemove', handleHover, { passive: true }); + container.addEventListener('mouseleave', handleHover, { passive: true }); + + return () => { + container.removeEventListener('mousemove', handleHover); + container.removeEventListener('mouseleave', handleHover); + }; + } + }, [handleHover]); + + return containerRef; +}; + +export interface IFilterOptions { + rounds: number[]; + targetDuration: number; + includeAllEvents: boolean; +} + +export function aiFilterClips( + clips: TClip[], + streamId: string | undefined, + options: IFilterOptions, +): TClip[] { + const { rounds, targetDuration, includeAllEvents } = options; + + const selectedRounds = + rounds.length === 1 && rounds[0] === 0 + ? [ + ...new Set( + clips + .filter(clip => clip.source === 'AiClip') + .map(clip => (clip as IAiClip).aiInfo.metadata?.round), + ), + ] + : rounds; + + // console.log('selectedRounds', selectedRounds); + + // Sort rounds by score (descending) + const sortedRounds = selectedRounds.sort( + (a, b) => getRoundScore(b, clips) - getRoundScore(a, clips), + ); + + // console.log('sortedRounds by rooundScore', sortedRounds); + + let clipsFromRounds: TClip[] = []; + + let totalDuration = 0; + for (let i = 0; i < sortedRounds.length; ++i) { + if (totalDuration > targetDuration) { + // console.log(`Duration: ${totalDuration} more than target: ${targetDuration}`); + break; + } else { + // console.log(`Duration: ${totalDuration} less than target: ${targetDuration}`); + //Todo M: how do sort? Per round or all together and then the rounds are in the stream order again? + const roundIndex = sortedRounds[i]; + // console.log('include round ', roundIndex); + + const roundClips = sortClipsByOrder(getClipsOfRound(roundIndex, clips), streamId); + // console.log( + // 'roundClips before adding:', + // roundClips.map(c => ({ + // duration: c.duration, + // })), + // ); + + clipsFromRounds = [...clipsFromRounds, ...roundClips]; + + // console.log( + // 'clipsFromRounds after adding:', + // clipsFromRounds.map(c => ({ + // duration: c.duration, + // })), + // ); + totalDuration = getCombinedClipsDuration(clipsFromRounds); + // console.log('new totalDuration:', totalDuration); + } + // console.log('clipsFromRounds', clipsFromRounds); + } + const contextTypes = [ + EHighlighterInputTypes.DEPLOY, + EHighlighterInputTypes.DEATH, + EHighlighterInputTypes.VICTORY, + ]; + const clipsSortedByScore = clipsFromRounds + .filter( + clips => !(clips as IAiClip).aiInfo.inputs.some(input => contextTypes.includes(input.type)), + ) + .sort((a, b) => (a as IAiClip).aiInfo.score - (b as IAiClip).aiInfo.score); + // console.log( + // 'clipsSortedByScore', + // clipsSortedByScore.map(clip => { + // return { + // score: (clip as IAiClip).aiInfo.score, + // inputs: JSON.stringify((clip as IAiClip).aiInfo.inputs), + // }; + // }), + // ); + // console.log('clipsFromRounds', clipsFromRounds); + + const filteredClips: TClip[] = clipsFromRounds; + let currentDuration = getCombinedClipsDuration(filteredClips); + + // console.log('remove clipswise to get closer to target'); + + const BUFFER_SEC = 0; + while (currentDuration > targetDuration + BUFFER_SEC) { + // console.log('ruuun currentDuration', currentDuration); + if (clipsSortedByScore === undefined || clipsSortedByScore.length === 0) { + break; + } + + const clipToRemove = clipsSortedByScore[0]; + clipsSortedByScore.splice(0, 1); // remove from our sorted array + + const index = filteredClips.findIndex(clip => clip.path === clipToRemove.path); + + if (index > -1) { + filteredClips.splice(index, 1); // 2nd parameter means remove one item only + currentDuration = getCombinedClipsDuration(filteredClips); + // console.log( + // 'removed, new currentDuration:', + // currentDuration, + // 'target:', + // targetDuration + BUFFER_SEC, + // ); + } + } + + return filteredClips; +} + +export function getCombinedClipsDuration(clips: TClip[]): number { + return clips.reduce( + (sum, clip) => sum + (clip.duration ? clip.duration - (clip.startTrim + clip.endTrim) : 0), + 0, + ); +} + +function getClipsOfRound(round: number, clips: TClip[]): TClip[] { + return clips.filter( + clip => clip.source === 'AiClip' && (clip as IAiClip).aiInfo.metadata.round === round, + ); +} + +function getRoundScore(round: number, clips: TClip[]): number { + return getClipsOfRound(round, clips).reduce( + (sum, clip) => sum + ((clip as IAiClip).aiInfo?.score || 0), + 0, + ); +} diff --git a/app/components-react/pages/BrowseOverlays.tsx b/app/components-react/pages/BrowseOverlays.tsx index fb7a414a98a5..97d650258a54 100644 --- a/app/components-react/pages/BrowseOverlays.tsx +++ b/app/components-react/pages/BrowseOverlays.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import path from 'path'; import urlLib from 'url'; import { Service } from 'services'; import Utils from 'services/utils'; @@ -6,7 +7,7 @@ import { ENotificationType } from 'services/notifications'; import { $t } from 'services/i18n'; import BrowserView from 'components-react/shared/BrowserView'; import { GuestApiHandler } from 'util/guest-api-handler'; -import { IDownloadProgress } from 'util/requests'; +import { downloadFile, IDownloadProgress } from 'util/requests'; import * as remote from '@electron/remote'; import { Services } from 'components-react/service-provider'; @@ -25,13 +26,18 @@ export default function BrowseOverlays(p: { NotificationsService, JsonrpcService, RestreamService, + MediaBackupService, } = Services; const [downloading, setDownloading] = useState(false); const [overlaysUrl, setOverlaysUrl] = useState(''); useEffect(() => { async function getOverlaysUrl() { - const url = await UserService.actions.return.overlaysUrl(p.params?.type, p.params?.id, p.params?.install); + const url = await UserService.actions.return.overlaysUrl( + p.params?.type, + p.params?.id, + p.params?.install, + ); if (!url) return; setOverlaysUrl(url); } @@ -44,6 +50,8 @@ export default function BrowseOverlays(p: { installOverlay, installWidgets, installOverlayAndWidgets, + getScenes, + addCollectibleToScene, eligibleToRestream: () => { // assume all users are eligible return Promise.resolve(true); @@ -76,7 +84,7 @@ export default function BrowseOverlays(p: { try { await installOverlayBase(url, name, progressCallback, mergePlatform); NavigationService.actions.navigate('Studio'); - } catch(e) { + } catch (e) { // If the overlay requires platform merge, navigate to the platform merge page if (e.message === 'REQUIRES_PLATFORM_MERGE') { NavigationService.actions.navigate('PlatformMerge', { overlayUrl: url, overlayName: name }); @@ -86,13 +94,108 @@ export default function BrowseOverlays(p: { } } + /** + * Get a list of scenes in the active scene collection + * + * @returns An array of scenes with items including only `id` and `name` + */ + async function getScenes() { + return ScenesService.views.scenes.map(scene => ({ + id: scene.id, + name: scene.name, + isActiveScene: scene.id === ScenesService.views.activeSceneId, + })); + } + + /** + * Adds a collectible to a scene. + * + * Collectibles are just a CDN URL of an image or video, this API provides + * embedded pages with a convenience method for creating sources based on those. + * + * @param name - Name of the collectible, used as source name + * @param sceneId - ID of the scene where the collectible will be added to + * @param assetURL - CDN URL of the collectible asset + * @param type - Type of source that will be created, `image` or `video` + * + * @returns string - ID of the scene item that was created for the source. + * @throws When type is not image or video. + * @throws When URL is a not a Streamlabs CDN URL. + * @throws When scene for the provided scene ID can't be found. + * @throws When it fails to create the source. + * + * @remarks When using a gif, the type should be set to `video` due to some + * inconsistencies we found with image source, namely around playback being + * shoppy or sometimes not displaying at all. Granted, we tested remote files + * at the start, so this might not be true for local files which are now downloaded. + */ + async function addCollectibleToScene( + name: string, + sceneId: string, + assetURL: string, + type: 'image' | 'video', + ) { + if (!['image', 'video'].includes(type)) { + throw new Error("Unsupported type. Use 'image' or 'video'"); + } + + if ( + !hasValidHost(assetURL, [ + 'cdn.streamlabs.com', + 'streamlabs-marketplace-staging.streamlabs.com', + ]) + ) { + throw new Error('Invalid asset URL'); + } + + // TODO: find or create enum + const sourceType = type === 'video' ? 'ffmpeg_source' : 'image_source'; + + const sourceName = name; + + const filename = path.basename(assetURL); + + // On a fresh cache with login and not restarting the app this + // directory might not exist, based on testing + await MediaBackupService.actions.return.ensureMediaDirectory(); + + const dest = path.join(MediaBackupService.mediaDirectory, filename); + + // TODO: refactor all this + // TODO: test if media backup is working automatically or we need changes + let localFile; + + try { + await downloadFile(assetURL, dest); + localFile = dest; + } catch { + throw new Error('Error downloading file to local system'); + } + + const sourceSettings = + type === 'video' ? { looping: true, local_file: localFile } : { file: localFile }; + + return ScenesService.actions.return.createAndAddSource( + sceneId, + sourceName, + sourceType, + sourceSettings, + ); + } + + function hasValidHost(url: string, trustedHosts: string[]) { + const host = new urlLib.URL(url).hostname; + return trustedHosts.includes(host); + } + async function installOverlayBase( url: string, name: string, progressCallback?: (progress: IDownloadProgress) => void, - mergePlatform = false + mergePlatform = false, ) { return new Promise((resolve, reject) => { + // TODO: refactor to use hasValidHost const host = new urlLib.URL(url).hostname; const trustedHosts = ['cdn.streamlabs.com']; if (!trustedHosts.includes(host)) { @@ -116,7 +219,8 @@ export default function BrowseOverlays(p: { } else { setDownloading(true); const sub = SceneCollectionsService.downloadProgress.subscribe(progressCallback); - SceneCollectionsService.actions.return.installOverlay(url, name) + SceneCollectionsService.actions.return + .installOverlay(url, name) .then(() => { sub.unsubscribe(); setDownloading(false); @@ -162,7 +266,11 @@ export default function BrowseOverlays(p: { } } - async function installOverlayAndWidgets(overlayUrl: string, overlayName: string, widgetUrls: string[]) { + async function installOverlayAndWidgets( + overlayUrl: string, + overlayName: string, + widgetUrls: string[], + ) { try { await installOverlayBase(overlayUrl, overlayName); await installWidgetsBase(widgetUrls); diff --git a/app/components-react/pages/Highlighter.tsx b/app/components-react/pages/Highlighter.tsx index bae3faade01f..9a2978eeed60 100644 --- a/app/components-react/pages/Highlighter.tsx +++ b/app/components-react/pages/Highlighter.tsx @@ -1,382 +1,116 @@ -import React, { useEffect, useState } from 'react'; -import cx from 'classnames'; +import SettingsView from 'components-react/highlighter/SettingsView'; import { useVuex } from 'components-react/hooks'; +import React, { useEffect, useState } from 'react'; +import { EHighlighterView, IViewState } from 'services/highlighter'; import { Services } from 'components-react/service-provider'; -import styles from './Highlighter.m.less'; -import { IClip } from 'services/highlighter'; -import ClipPreview from 'components-react/highlighter/ClipPreview'; -import ClipTrimmer from 'components-react/highlighter/ClipTrimmer'; -import { ReactSortable } from 'react-sortablejs'; -import Form from 'components-react/shared/inputs/Form'; -import isEqual from 'lodash/isEqual'; -import { SliderInput, FileInput, SwitchInput } from 'components-react/shared/inputs'; -import { Modal, Button, Alert } from 'antd'; -import ExportModal from 'components-react/highlighter/ExportModal'; -import PreviewModal from 'components-react/highlighter/PreviewModal'; -import BlankSlate from 'components-react/highlighter/BlankSlate'; -import { SCRUB_HEIGHT, SCRUB_WIDTH, SUPPORTED_FILE_TYPES } from 'services/highlighter/constants'; -import path from 'path'; -import Scrollable from 'components-react/shared/Scrollable'; -import { IHotkey } from 'services/hotkeys'; -import { getBindingString } from 'components-react/shared/HotkeyBinding'; -import Animate from 'rc-animate'; -import TransitionSelector from 'components-react/highlighter/TransitionSelector'; -import { $t } from 'services/i18n'; -import * as remote from '@electron/remote'; - -type TModal = 'trim' | 'export' | 'preview' | 'remove'; - -export default function Highlighter(p: { className?: string }) { - const { HighlighterService, HotkeysService, UsageStatisticsService } = Services; +import StreamView from 'components-react/highlighter/StreamView'; +import ClipsView from 'components-react/highlighter/ClipsView'; +import UpdateModal from 'components-react/highlighter/UpdateModal'; +import { EAvailableFeatures } from 'services/incremental-rollout'; + +export default function Highlighter(props: { params?: { view: string } }) { + const { HighlighterService, IncrementalRolloutService } = Services; + const aiHighlighterEnabled = IncrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.aiHighlighter, + ); const v = useVuex(() => ({ - clips: HighlighterService.views.clips as IClip[], - exportInfo: HighlighterService.views.exportInfo, - uploadInfo: HighlighterService.views.uploadInfo, - loadedCount: HighlighterService.views.loadedCount, - loaded: HighlighterService.views.loaded, - transition: HighlighterService.views.transition, - dismissedTutorial: HighlighterService.views.dismissedTutorial, - audio: HighlighterService.views.audio, - error: HighlighterService.views.error, + useAiHighlighter: HighlighterService.views.useAiHighlighter, + isUpdaterRunning: HighlighterService.views.isUpdaterRunning, + highlighterVersion: HighlighterService.views.highlighterVersion, + progress: HighlighterService.views.updaterProgress, + clipsAmount: HighlighterService.views.clips.length, + streamAmount: HighlighterService.views.highlightedStreams.length, })); - const [showModal, rawSetShowModal] = useState(null); - const [modalWidth, setModalWidth] = useState('700px'); - const [hotkey, setHotkey] = useState(null); - const [showTutorial, setShowTutorial] = useState(false); + let initialViewState: IViewState; + + if (v.streamAmount > 0 && v.clipsAmount > 0 && aiHighlighterEnabled) { + initialViewState = { view: EHighlighterView.STREAM }; + } else if (v.clipsAmount > 0) { + initialViewState = { view: EHighlighterView.CLIPS, id: undefined }; + } else { + initialViewState = { view: EHighlighterView.SETTINGS }; + } useEffect(() => { - if (v.clips.length) { - HighlighterService.actions.loadClips(); - setShowTutorial(false); + // check if ai highlighter is activated and we need to update it + async function shouldUpdate() { + if (!HighlighterService.aiHighlighterUpdater) return false; + const versionAvailable = await HighlighterService.aiHighlighterUpdater.isNewVersionAvailable(); + return versionAvailable && aiHighlighterEnabled && v.useAiHighlighter; } - }, [v.clips.length]); - useEffect(() => { - HotkeysService.actions.return.getGeneralHotkeyByName('SAVE_REPLAY').then(hotkey => { - if (hotkey) setHotkey(hotkey); + shouldUpdate().then(shouldUpdate => { + if (shouldUpdate) HighlighterService.actions.startUpdater(); }); }, []); - useEffect(() => UsageStatisticsService.actions.recordFeatureUsage('Highlighter'), []); - - // This is kind of weird, but ensures that modals stay the right - // size while the closing animation is played. This is why modal - // width has its own state. This makes sure we always set the right - // size whenever displaying a modal. - function setShowModal(modal: TModal | null) { - rawSetShowModal(modal); + const [viewState, setViewState] = useState(initialViewState); + const updaterModal = ( + + ); - if (modal) { - setModalWidth( - { - trim: '60%', - preview: '700px', - export: '700px', - remove: '400px', - }[modal], + switch (viewState.view) { + case EHighlighterView.STREAM: + return ( + <> + {aiHighlighterEnabled && updaterModal} + { + setViewFromEmit(data); + }} + /> + ); - } - } - - function getLoadingView() { - return ( -
-

Loading

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

{$t('Highlighter')}

-

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

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

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

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

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

-

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

- - -
- ); } diff --git a/app/components-react/pages/PlatformAppStore.tsx b/app/components-react/pages/PlatformAppStore.tsx index d102e4d4fed7..9b2711850490 100644 --- a/app/components-react/pages/PlatformAppStore.tsx +++ b/app/components-react/pages/PlatformAppStore.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import Utils from 'services/utils'; +import urlLib from 'url'; import BrowserView from 'components-react/shared/BrowserView'; import { GuestApiHandler } from 'util/guest-api-handler'; import * as remote from '@electron/remote'; @@ -30,6 +31,16 @@ export default function PlatformAppStore(p: { navigateToApp, }); + view.webContents.setWindowOpenHandler(details => { + const protocol = urlLib.parse(details.url).protocol; + + if (protocol === 'http:' || protocol === 'https:') { + remote.shell.openExternal(details.url); + } + + return { action: 'deny' }; + }); + view.webContents.on('did-finish-load', () => { if (Utils.isDevMode()) { view.webContents.openDevTools(); diff --git a/app/components-react/pages/RecordingHistory.m.less b/app/components-react/pages/RecordingHistory.m.less index 218f940d8de2..aeac43c88e28 100644 --- a/app/components-react/pages/RecordingHistory.m.less +++ b/app/components-react/pages/RecordingHistory.m.less @@ -97,3 +97,47 @@ text-decoration: underline; color: var(--paragraph); } + +.edit-cell { + background: var(--background); + height: 220px; + width: 220px; + border-radius: 16px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; + padding: 16px; + text-align: center; + + span { + font-size: 12px; + } + span.edit-title { + font-size: 16; + color: var(--title); + } + + img { + width: 80px; + height: 80px; + padding: 12px; + } + + &:hover { + cursor: pointer; + background: var(--section-alt); + } +} + +.close-icon { + height: 20px; + width: 20px; + position: absolute; + top: 24px; + right: 24px; + + &:hover { + cursor: pointer; + } +} diff --git a/app/components-react/pages/RecordingHistory.tsx b/app/components-react/pages/RecordingHistory.tsx index 53a90366667f..e19d0d06df81 100644 --- a/app/components-react/pages/RecordingHistory.tsx +++ b/app/components-react/pages/RecordingHistory.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useMemo } from 'react'; +import cx from 'classnames'; import * as remote from '@electron/remote'; import cx from 'classnames'; import { Tooltip } from 'antd'; @@ -13,6 +14,18 @@ import { Services } from '../service-provider'; import { initStore, useController } from '../hooks/zustand'; import { useVuex } from '../hooks'; import Translate from 'components-react/shared/Translate'; +import uuid from 'uuid/v4'; +import { EMenuItemKey } from 'services/side-nav'; +import { $i } from 'services/utils'; +import { IRecordingEntry } from 'services/recording-mode'; +import { EAiDetectionState, EHighlighterView } from 'services/highlighter'; +import { EAvailableFeatures } from 'services/incremental-rollout'; + +interface IRecordingHistoryStore { + showSLIDModal: boolean; + showEditModal: boolean; + fileEdited: IRecordingEntry | null; +} const RecordingHistoryCtx = React.createContext(null); @@ -21,7 +34,14 @@ class RecordingHistoryController { private UserService = Services.UserService; private SharedStorageService = Services.SharedStorageService; private NotificationsService = Services.NotificationsService; - store = initStore({ showSLIDModal: false }); + private HighlighterService = Services.HighlighterService; + private NavigationService = Services.NavigationService; + private IncrementalRolloutService = Services.IncrementalRolloutService; + store = initStore({ + showSLIDModal: false, + showEditModal: false, + fileEdited: null, + }); get recordings() { return this.RecordingModeService.views.sortedRecordings; @@ -39,22 +59,28 @@ class RecordingHistoryController { return this.RecordingModeService.state.uploadInfo; } + get aiDetectionInProgress() { + return this.HighlighterService.views.highlightedStreams.some( + stream => stream.state.type === EAiDetectionState.IN_PROGRESS, + ); + } + get uploadOptions() { const opts = [ { - label: $t('Clip'), - value: 'crossclip', - icon: 'icon-editor-7', + label: `${$t('Get highlights (Fortnite only)')}`, + value: 'highlighter', + icon: 'icon-highlighter', }, { - label: $t('Subtitle'), - value: 'typestudio', - icon: 'icon-mic', + label: $t('Edit'), + value: 'edit', + icon: 'icon-trim', }, { - label: $t('Edit'), - value: 'videoeditor', - icon: 'icon-play-round', + label: '', + value: 'remove', + icon: 'icon-trash', }, ]; if (this.hasYoutube) { @@ -68,6 +94,31 @@ class RecordingHistoryController { return opts; } + get editOptions() { + return [ + { + value: 'videoeditor', + label: 'Video Editor', + description: $t('Edit video professionally from your browser with Video Editor'), + src: 'video-editor.png', + }, + { + value: 'crossclip', + label: 'Cross Clip', + description: $t( + 'Turn your videos into mobile-friendly short-form TikToks, Reels, and Shorts with Cross Clip', + ), + src: 'crossclip.png', + }, + { + value: 'typestudio', + label: 'Podcast Edtior', + description: $t('Polish your videos with text-based and AI powered Podcast Editor'), + src: 'podcast-editor.png', + }, + ]; + } + postError(message: string) { this.NotificationsService.actions.push({ message, @@ -76,14 +127,32 @@ class RecordingHistoryController { }); } - handleSelect(filename: string, platform: string) { + handleSelect(recording: IRecordingEntry, platform: string) { if (this.uploadInfo.uploading) { this.postError($t('Upload already in progress')); return; } - if (platform === 'youtube') return this.uploadToYoutube(filename); + if (platform === 'highlighter') { + if (this.aiDetectionInProgress) return; + this.HighlighterService.actions.flow(recording.filename, { + game: 'forntnite', + id: 'rec_' + uuid(), + }); + this.NavigationService.actions.navigate( + 'Highlighter', + { view: EHighlighterView.STREAM }, + EMenuItemKey.Highlighter, + ); + return; + } + + if (platform === 'youtube') return this.uploadToYoutube(recording.filename); + if (platform === 'remove') return this.removeEntry(recording.timestamp); if (this.hasSLID) { - this.uploadToStorage(filename, platform); + this.store.setState(s => { + s.showEditModal = true; + s.fileEdited = recording; + }); } else { this.store.setState(s => { s.showSLIDModal = true; @@ -107,6 +176,10 @@ class RecordingHistoryController { remote.shell.openExternal(this.SharedStorageService.views.getPlatformLink(platform, id)); } + removeEntry(timestamp: string) { + this.RecordingModeService.actions.removeRecordingEntry(timestamp); + } + showFile(filename: string) { remote.shell.showItemInFolder(filename); } @@ -128,8 +201,12 @@ export default function RecordingHistoryPage(p: { className?: string }) { export function RecordingHistory(p: { className?: string }) { const controller = useController(RecordingHistoryCtx); const { formattedTimestamp, showFile, handleSelect, postError } = controller; - const { uploadInfo, uploadOptions, recordings, hasSLID } = useVuex(() => ({ + const aiHighlighterEnabled = Services.IncrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.aiHighlighter, + ); + const { uploadInfo, uploadOptions, recordings, hasSLID, aiDetectionInProgress } = useVuex(() => ({ recordings: controller.recordings, + aiDetectionInProgress: controller.aiDetectionInProgress, uploadOptions: controller.uploadOptions, uploadInfo: controller.uploadInfo, hasSLID: controller.hasSLID, @@ -150,21 +227,35 @@ export function RecordingHistory(p: { className?: string }) { Services.SettingsService.actions.showSettings('Hotkeys'); } - function UploadActions(p: { filename: string }) { + function UploadActions(p: { recording: IRecordingEntry }) { return ( - {uploadOptions.map(opt => ( - handleSelect(p.filename, opt.value)} - > - -   - {opt.label} - - ))} + {uploadOptions + .map(option => { + if (option.value === 'highlighter' && !aiHighlighterEnabled) { + return null; + } + return ( + handleSelect(p.recording, option.value)} + > + +   + {option.label} + + ); + }) + .filter(Boolean)} ); } @@ -191,17 +282,75 @@ export function RecordingHistory(p: { className?: string }) { {recording.filename} - {uploadOptions.length > 0 && } + {uploadOptions.length > 0 && }
))}
+ {!hasSLID && }
); } +function EditModal() { + const { store, editOptions, uploadToStorage } = useController(RecordingHistoryCtx); + const showEditModal = store.useState(s => s.showEditModal); + const recording = store.useState(s => s.fileEdited); + + function close() { + store.setState(s => { + s.showEditModal = false; + s.fileEdited = null; + }); + } + + function editFile(platform: string) { + if (!recording) throw new Error('File not found'); + + uploadToStorage(recording.filename, platform); + close(); + } + + if (!showEditModal) return <>; + + return ( +
+ + <> +

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

+ +
+ {editOptions.map(editOpt => ( +
editFile(editOpt.value)} + > + + {editOpt.label} + {editOpt.description} +
+ ))} +
+ +
+
+ ); +} + function SLIDModal() { const { store } = useController(RecordingHistoryCtx); const showSLIDModal = store.useState(s => s.showSLIDModal); diff --git a/app/components-react/pages/onboarding/Common.m.less b/app/components-react/pages/onboarding/Common.m.less index c6c5e43fed5c..8a1bcd68e437 100644 --- a/app/components-react/pages/onboarding/Common.m.less +++ b/app/components-react/pages/onboarding/Common.m.less @@ -12,6 +12,12 @@ margin-top: 144px; } +.subtitle-container { + display: flex; + justify-content: center; + margin-bottom: 35px; +} + .option-card { position: relative; background: var(--teal); @@ -35,7 +41,8 @@ margin-bottom: 0; } - svg, i { + svg, + i { position: absolute; bottom: -50%; right: 0; diff --git a/app/components-react/pages/onboarding/Connect.m.less b/app/components-react/pages/onboarding/Connect.m.less index 840081166342..7c980e595400 100644 --- a/app/components-react/pages/onboarding/Connect.m.less +++ b/app/components-react/pages/onboarding/Connect.m.less @@ -31,8 +31,9 @@ } @media (max-height: 600px) { - & > h1, - & > p { + + &>h1, + &>p { display: none; } } @@ -56,6 +57,19 @@ display: flex; align-items: center; justify-content: center; + + :global(.button--youtube) { + overflow: hidden; + } + + :global(.youtube) { + max-height: 160px !important; + max-width: 200px !important; + width: 200px !important; + height: 92px !important; + background-size: cover; + background-position-x: -33px; + } } .loginButton { diff --git a/app/components-react/pages/onboarding/Connect.tsx b/app/components-react/pages/onboarding/Connect.tsx index 146157f46451..f65c62f050ba 100644 --- a/app/components-react/pages/onboarding/Connect.tsx +++ b/app/components-react/pages/onboarding/Connect.tsx @@ -63,7 +63,7 @@ export function Connect() { // streamlabs and trovo are added separarely on markup below const platforms = RecordingModeService.views.isRecordingModeEnabled ? ['youtube'] - : ['twitch', 'youtube', 'facebook', 'twitter', 'tiktok']; + : ['twitch', 'youtube', 'tiktok', 'kick', 'facebook', 'twitter']; const shouldAddTrovo = !RecordingModeService.views.isRecordingModeEnabled; @@ -132,7 +132,9 @@ export function Connect() { loading={loading} onClick={() => authPlatform(platform, afterLogin)} key={platform} - logoSize={['twitter', 'tiktok', 'youtube'].includes(platform) ? 15 : undefined} + logoSize={ + ['twitter', 'tiktok', 'youtube', 'kick'].includes(platform) ? 15 : undefined + } > %{platform}', { @@ -147,6 +149,7 @@ export function Connect() {
{shouldAddTrovo && ( (

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

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

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

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

{$t('Recording Only')}

-
- -

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

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

{$t('Beginner')}

-
-

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

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

{$t('Intermediate')}

-
-

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

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

{$t('Advanced')}

-
-

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

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

{$t('Other Themes')}

+ + {getFilteredMetadata().map(theme => ( +
focusTheme(theme)} + key={theme.data.name} + > + +
{theme.data.name}
+
+ ))}
-
- {}} - /> - {previewImages(detailTheme).map(src => { - return ( - setBigPreview(src)} - /> - ); - })} +
+
+ {detailTheme.data.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)} + /> + ); + })} +
-
+ )}
) : ( diff --git a/app/components-react/pages/onboarding/Tips.m.less b/app/components-react/pages/onboarding/Tips.m.less deleted file mode 100644 index 4e2da8b94e33..000000000000 --- a/app/components-react/pages/onboarding/Tips.m.less +++ /dev/null @@ -1,33 +0,0 @@ -.container { - display: flex; - flex-direction: column; - align-items: center; - flex: 1; - min-height: 90%; - justify-content: space-around; - - ul { - list-style-type: none; - padding: 0; - - li { - border-radius: 12px; - padding: 20px; - background-color: var(--border); - margin-bottom: 24px; - padding-right: 32px; - font-size: 18px; - - i { - margin-right: 8px; - } - - a { - font-weight: normal; - text-decoration: underline; - color: var(--link); - } - } - } -} - diff --git a/app/components-react/pages/onboarding/Tips.tsx b/app/components-react/pages/onboarding/Tips.tsx deleted file mode 100644 index 48aa739ce2f6..000000000000 --- a/app/components-react/pages/onboarding/Tips.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import React, { useMemo } from 'react'; -import { Button } from 'antd'; -import { shell } from '@electron/remote'; -import { OnboardingModule } from './Onboarding'; -import { useModule } from 'slap'; -import { StreamerKnowledgeMode } from 'services/onboarding'; -import { $t } from 'services/i18n'; -import Translate from 'components-react/shared/Translate'; -import commonStyles from './Common.m.less'; -import styles from './Tips.m.less'; - -const openExternalLink: React.MouseEventHandler = evt => { - evt.preventDefault(); - shell.openExternal(evt.currentTarget.href); -}; - -const linkProps = { slot: 'link', onClick: openExternalLink }; -const dashboardUrl = 'https://streamlabs.com/dashboard'; -const allStarsUrl = `${dashboardUrl}#/allstars`; - -function AllStarsTip() { - return ( -
  • - - on your dashboard', - )} - > - - -
  • - ); -} - -function BeginnerTips() { - return ( - <> -
  • - - getting started guide')} - > - - -
  • -
  • - - this troubleshooting guide')}> - - -
  • - -
  • - - Streamer University', - )} - > - - -
  • - - ); -} - -function IntermediateTips() { - return ( - <> -
  • - - Streamlabs Dashboard')} - > - - -
  • - -
  • - - Creator resource hub for everything you need', - )} - > - - -
  • - - ); -} - -export function Tips() { - const { streamerKnowledgeMode, next } = useModule(OnboardingModule); - - const title = - streamerKnowledgeMode === StreamerKnowledgeMode.BEGINNER - ? $t('Tips to run your first stream like a Pro:') - : $t('Tips to get the most out of your experience:'); - - const tips = useMemo(() => { - if (streamerKnowledgeMode === StreamerKnowledgeMode.BEGINNER) { - return ; - } else if (streamerKnowledgeMode === StreamerKnowledgeMode.INTERMEDIATE) { - return ; - } else { - // Should never be called as step has a cond filter thus this component will never be rendered - throw new Error('Unknown streamer knowledge mode'); - } - }, [streamerKnowledgeMode]); - - return ( -
    -
    - {title} -
    - -
      {tips}
    - - -
    - ); -} diff --git a/app/components-react/pages/onboarding/steps.ts b/app/components-react/pages/onboarding/steps.ts index 895d9c8df1ba..5502d1fd3748 100644 --- a/app/components-react/pages/onboarding/steps.ts +++ b/app/components-react/pages/onboarding/steps.ts @@ -8,4 +8,3 @@ export * from './ThemeSelector'; export * from './Optimize'; export * from './Prime'; export * from './StreamingOrRecording'; -export * from './Tips'; diff --git a/app/components-react/root/LiveDock.m.less b/app/components-react/root/LiveDock.m.less index 352724c52351..eeda0415cca0 100644 --- a/app/components-react/root/LiveDock.m.less +++ b/app/components-react/root/LiveDock.m.less @@ -123,6 +123,8 @@ } &:hover { + cursor: pointer; + .live-dock-viewer-count-toggle { opacity: 1; } diff --git a/app/components-react/root/LiveDock.tsx b/app/components-react/root/LiveDock.tsx index bc66ca0e60b2..170008de690d 100644 --- a/app/components-react/root/LiveDock.tsx +++ b/app/components-react/root/LiveDock.tsx @@ -19,6 +19,7 @@ import { useVuex } from 'components-react/hooks'; import { useRealmObject } from 'components-react/hooks/realm'; import { $i } from 'services/utils'; import { TikTokChatInfo } from './TiktokChatInfo'; +import { ShareStreamLink } from './ShareStreamLink'; const LiveDockCtx = React.createContext(null); @@ -27,6 +28,7 @@ class LiveDockController { private youtubeService = Services.YoutubeService; private facebookService = Services.FacebookService; private trovoService = Services.TrovoService; + private kickService = Services.KickService; private tiktokService = Services.TikTokService; private userService = Services.UserService; private customizationService = Services.CustomizationService; @@ -159,6 +161,7 @@ class LiveDockController { // Twitter & Tiktok don't support editing title after going live if (this.isPlatform('twitter') && !this.isRestreaming) return false; if (this.isPlatform('tiktok') && !this.isRestreaming) return false; + if (this.isPlatform('kick') && !this.isRestreaming) return false; return ( this.streamingService.views.isMidStreamMode || @@ -181,6 +184,7 @@ class LiveDockController { if (this.platform === 'youtube') url = this.youtubeService.streamPageUrl; if (this.platform === 'facebook') url = this.facebookService.streamPageUrl; if (this.platform === 'trovo') url = this.trovoService.streamPageUrl; + if (this.platform === 'kick') url = this.kickService.streamPageUrl; if (this.platform === 'tiktok') url = this.tiktokService.streamPageUrl; remote.shell.openExternal(url); } @@ -190,6 +194,7 @@ class LiveDockController { if (this.platform === 'youtube') url = this.youtubeService.dashboardUrl; if (this.platform === 'facebook') url = this.facebookService.streamDashboardUrl; if (this.platform === 'tiktok') url = this.tiktokService.dashboardUrl; + if (this.platform === 'kick') url = this.kickService.dashboardUrl; remote.shell.openExternal(url); } @@ -439,7 +444,7 @@ function LiveDock(p: ILiveDockProps) { ctrl.showEditStreamInfo()} className="icon-edit" /> )} - {isPlatform(['youtube', 'facebook', 'trovo', 'tiktok']) && isStreaming && ( + {isPlatform(['youtube', 'facebook', 'trovo', 'tiktok', 'kick']) && isStreaming && (
    {!hideStyleBlockers && (isPlatform(['twitch', 'trovo']) || - (isStreaming && isPlatform(['youtube', 'facebook', 'twitter', 'tiktok']))) && ( + (isStreaming && + isPlatform(['youtube', 'facebook', 'twitter', 'tiktok', 'kick']))) && (
    {hasChatTabs && (
    @@ -515,7 +521,8 @@ function LiveDock(p: ILiveDockProps) {
    {!hideStyleBlockers && (isPlatform(['twitch', 'trovo']) || - (isStreaming && isPlatform(['youtube', 'facebook', 'twitter', 'tiktok']))) && ( + (isStreaming && + isPlatform(['youtube', 'facebook', 'twitter', 'tiktok', 'kick']))) && (
    {hasChatTabs && } {!applicationLoading && !collapsed && chat} @@ -530,7 +537,8 @@ function LiveDock(p: ILiveDockProps) {
    )} {(!ctrl.platform || - (isPlatform(['youtube', 'facebook', 'twitter', 'tiktok']) && !isStreaming)) && ( + (isPlatform(['youtube', 'facebook', 'twitter', 'tiktok', 'kick']) && + !isStreaming)) && (
    {!hideStyleBlockers && {$t('Your chat is currently offline')}} diff --git a/app/components-react/root/ShareStreamLink.m.less b/app/components-react/root/ShareStreamLink.m.less new file mode 100644 index 000000000000..6425af9eb042 --- /dev/null +++ b/app/components-react/root/ShareStreamLink.m.less @@ -0,0 +1,20 @@ +.shareStreamLinksContainer { + display: flex; + margin-top: -5px; + + :global(i.youtube) { + width: 20px; + height: 20px; + margin-top: 6px; + } + + :global(i.tiktok) { + width: 16px; + height: 16px; + } + + :global(i.kick) { + width: 16px; + height: 16px; + } +} diff --git a/app/components-react/root/ShareStreamLink.tsx b/app/components-react/root/ShareStreamLink.tsx new file mode 100644 index 000000000000..dea37b42abdc --- /dev/null +++ b/app/components-react/root/ShareStreamLink.tsx @@ -0,0 +1,110 @@ +import React, { useState } from 'react'; +import { Services } from '../service-provider'; +import { $t } from 'services/i18n'; +import { clipboard } from 'electron'; +import { getPlatformService } from 'services/platforms'; +import { Button, message } from 'antd'; +import { CloseOutlined, ShareAltOutlined } from '@ant-design/icons'; +import PlatformLogo from 'components-react/shared/PlatformLogo'; +import Tooltip from 'components-react/shared/Tooltip'; +import styles from './ShareStreamLink.m.less'; + +/* + * There's a weird issue on the Live Dock where components placed above + * chat can't overlap (even if overlaid) on top of that section. + * As a result, our choice of components in this area is quite limited, + * for example, dropdowns are cut off. + * Would love to use a FloatButton with a right placement for this + * (https://ant.design/components/float-button#float-button-demo-placement) + * but is not included in our version of Antd. Backporting is also not feasible + * due to 5.x using CSS in JS among other breaking changes. + * As such, we're left with a Radio Group. + */ +export const ShareStreamLink = () => { + const [expanded, setExpanded] = useState(false); + + const toggleExpanded = () => setExpanded(expanded => !expanded); + const { StreamingService } = Services; + + const items = StreamingService.views.enabledPlatforms.map(platform => { + const service = getPlatformService(platform); + const streamPageUrl = service.streamPageUrl; + + if (!streamPageUrl) { + return; + } + + const tooltip = $t('Copy %{platform} link', { + platform: StreamingService.views.getPlatformDisplayName(platform), + }); + + return ( + +
    + ); +}; + +const copyToClipboard = (link: string) => { + clipboard.writeText(link); + message.open({ + type: 'success', + content: $t('Copied to clipboard'), + duration: 2, + /* Since there's no easy way to get this off the footer (yes, we tried + * `getContainer`), style a bit so it doesn't get cut off + */ + style: { + padding: 0, + marginTop: '-5px', + }, + }); +}; diff --git a/app/components-react/root/StudioEditor.m.less b/app/components-react/root/StudioEditor.m.less index b93fde82744e..6965a88e2383 100644 --- a/app/components-react/root/StudioEditor.m.less +++ b/app/components-react/root/StudioEditor.m.less @@ -250,8 +250,13 @@ .progress-bar { min-width: 300px; width: 50%; + height: 100%; margin: auto; text-align: center; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; } .toggle-error { diff --git a/app/components-react/root/StudioFooter.tsx b/app/components-react/root/StudioFooter.tsx index 784e11f96597..3f57fe081afc 100644 --- a/app/components-react/root/StudioFooter.tsx +++ b/app/components-react/root/StudioFooter.tsx @@ -12,8 +12,6 @@ import StartStreamingButton from './StartStreamingButton'; import NotificationsArea from './NotificationsArea'; import { Tooltip } from 'antd'; import { confirmAsync } from 'components-react/modals'; -import { useModule } from 'slap'; -import { useRealmObject } from 'components-react/hooks/realm'; export default function StudioFooterComponent() { const { @@ -23,6 +21,8 @@ export default function StudioFooterComponent() { NavigationService, RecordingModeService, PerformanceService, + SettingsService, + UserService, } = Services; const { @@ -35,7 +35,19 @@ export default function StudioFooterComponent() { replayBufferSaving, recordingModeEnabled, replayBufferEnabled, - } = useModule(FooterModule); + } = useVuex(() => ({ + streamingStatus: StreamingService.views.streamingStatus, + isLoggedIn: UserService.views.isLoggedIn, + canSchedule: + StreamingService.views.supports('stream-schedule') && + !RecordingModeService.views.isRecordingModeEnabled, + streamQuality: PerformanceService.views.streamQuality, + replayBufferOffline: StreamingService.state.replayBufferStatus === EReplayBufferState.Offline, + replayBufferStopping: StreamingService.state.replayBufferStatus === EReplayBufferState.Stopping, + replayBufferSaving: StreamingService.state.replayBufferStatus === EReplayBufferState.Saving, + recordingModeEnabled: RecordingModeService.views.isRecordingModeEnabled, + replayBufferEnabled: SettingsService.views.values.Output.RecRB, + })); function performanceIconClassName() { if (!streamingStatus || streamingStatus === EStreamingState.Offline) { @@ -241,46 +253,3 @@ function RecordingTimer() { if (!isRecording) return <>; return
    {recordingTime}
    ; } - -class FooterModule { - state = {}; - - get replayBufferEnabled() { - return Services.SettingsService.views.values.Output.RecRB; - } - - get streamingStatus() { - return Services.StreamingService.views.streamingStatus; - } - - get streamQuality() { - return Services.PerformanceService.views.streamQuality; - } - - get isLoggedIn() { - return Services.UserService.views.isLoggedIn; - } - - get canSchedule() { - return ( - Services.StreamingService.views.supports('stream-schedule') && - !Services.RecordingModeService.views.isRecordingModeEnabled - ); - } - - get replayBufferOffline() { - return Services.StreamingService.state.replayBufferStatus === EReplayBufferState.Offline; - } - - get replayBufferStopping() { - return Services.StreamingService.state.replayBufferStatus === EReplayBufferState.Stopping; - } - - get replayBufferSaving() { - return Services.StreamingService.state.replayBufferStatus === EReplayBufferState.Saving; - } - - get recordingModeEnabled() { - return Services.RecordingModeService.views.isRecordingModeEnabled; - } -} diff --git a/app/components-react/shared/AddDestinationButton.m.less b/app/components-react/shared/AddDestinationButton.m.less index 550c1c29adc0..5fb5adfdd5f0 100644 --- a/app/components-react/shared/AddDestinationButton.m.less +++ b/app/components-react/shared/AddDestinationButton.m.less @@ -1,9 +1,14 @@ @import '../../styles/index.less'; .add-destination-group { + width: 100%; gap: 15px; - margin: 15px 0px; - padding-bottom: 15px; + padding: 0px 15px 15px 15px; + + &.ultra-btn-group { + width: calc(100% - 6px); + margin: 15px 3px; + } :global(.ant-space-item) { width: 100%; @@ -63,8 +68,4 @@ } } } - - .dual-output { - margin: 15px; - } } diff --git a/app/components-react/shared/AddDestinationButton.tsx b/app/components-react/shared/AddDestinationButton.tsx index 3abad4622304..09bef4755b34 100644 --- a/app/components-react/shared/AddDestinationButton.tsx +++ b/app/components-react/shared/AddDestinationButton.tsx @@ -16,46 +16,50 @@ interface IAddDestinationButtonProps { } export default function AddDestinationButton(p: IAddDestinationButtonProps) { - const { addDestination, shouldShowPrimeLabel, isDualOutputMode } = useGoLiveSettings().extend( - module => { - const { - RestreamService, - SettingsService, - MagicLinkService, - UserService, - UsageStatisticsService, - WebsocketService, - } = Services; + const { + addDestination, + shouldShowPrimeLabel, + isDualOutputMode, + isPrime, + } = useGoLiveSettings().extend(module => { + const { + RestreamService, + SettingsService, + MagicLinkService, + UserService, + UsageStatisticsService, + WebsocketService, + } = Services; - return { - addDestination() { - // open the stream settings or prime page - if (UserService.views.isPrime) { - SettingsService.actions.showSettings('Stream'); - } else if (isDualOutputMode) { - // record dual output analytics event - const ultraSubscription = WebsocketService.ultraSubscription.subscribe(() => { - UsageStatisticsService.recordAnalyticsEvent('DualOutput', { - type: 'UpgradeToUltra', - }); - ultraSubscription.unsubscribe(); + return { + addDestination() { + // open the stream settings or prime page + if (UserService.views.isPrime) { + SettingsService.actions.showSettings('Stream'); + } else if (isDualOutputMode) { + // record dual output analytics event + const ultraSubscription = WebsocketService.ultraSubscription.subscribe(() => { + UsageStatisticsService.recordAnalyticsEvent('DualOutput', { + type: 'UpgradeToUltra', }); - MagicLinkService.linkToPrime('slobs-multistream'); - } else { - MagicLinkService.linkToPrime('slobs-multistream'); - } - }, + ultraSubscription.unsubscribe(); + }); + MagicLinkService.linkToPrime('slobs-multistream'); + } else { + MagicLinkService.linkToPrime('slobs-multistream'); + } + }, - shouldShowPrimeLabel: - p.type === 'ultra' || - (!RestreamService.state.grandfathered && !UserService.views.isPrime), - }; - }, - ); + shouldShowPrimeLabel: + p.type === 'ultra' || (!RestreamService.state.grandfathered && !module.isPrime), + }; + }); return ( (null); - const { hideStyleBlockers, theme } = useVuex(() => ({ - hideStyleBlockers: WindowsService.state[Utils.getWindowId()].hideStyleBlockers, - theme: CustomizationService.state.theme, - })); + const { hideStyleBlockers } = WindowsService.state[Utils.getWindowId()]; + const { theme } = CustomizationService.state; let currentPosition: IVec2; let currentSize: IVec2; diff --git a/app/components-react/shared/DisplaySelector.tsx b/app/components-react/shared/DisplaySelector.tsx index b10f159a3282..ffdcc6cd9268 100644 --- a/app/components-react/shared/DisplaySelector.tsx +++ b/app/components-react/shared/DisplaySelector.tsx @@ -1,11 +1,8 @@ -import React, { CSSProperties, useMemo } from 'react'; +import React, { CSSProperties } from 'react'; import { $t } from 'services/i18n'; -import { useVuex } from 'components-react/hooks'; -import { Services } from 'components-react/service-provider'; import { RadioInput } from './inputs'; -import { displayLabels } from 'services/dual-output'; import { TDisplayType } from 'services/settings-v2'; -import { TPlatform, platformLabels } from 'services/platforms'; +import { platformLabels, TPlatform } from 'services/platforms'; import { useGoLiveSettings } from 'components-react/windows/go-live/useGoLiveSettings'; import { ICustomStreamDestination } from 'services/settings/streaming'; @@ -20,28 +17,25 @@ interface IDisplaySelectorProps { } export default function DisplaySelector(p: IDisplaySelectorProps) { - const { DualOutputService, StreamingService } = Services; + const { + customDestinations, + platforms, + updateCustomDestinationDisplay, + updatePlatform, + } = useGoLiveSettings(); - const { customDestinations, updateCustomDestinationDisplay } = useGoLiveSettings(); - - const v = useVuex(() => ({ - updatePlatformSetting: DualOutputService.actions.updatePlatformSetting, - platformSettings: DualOutputService.views.platformSettings, - isMidstreamMode: StreamingService.views.isMidStreamMode, - })); - - const setting = p.platform ? v.platformSettings[p.platform] : customDestinations[p.index]; + const setting = p.platform ? platforms[p.platform] : customDestinations[p.index]; const label = p.platform ? platformLabels(p.platform) : (setting as ICustomStreamDestination).name; const displays = [ { - label: displayLabels('horizontal') ?? $t('Horizontal'), + label: $t('Horizontal'), value: 'horizontal', }, { - label: displayLabels('vertical') ?? $t('Vertical'), + label: $t('Vertical'), value: 'vertical', }, ]; @@ -49,21 +43,24 @@ export default function DisplaySelector(p: IDisplaySelectorProps) { return ( p.platform - ? v.updatePlatformSetting(p.platform, val) + ? updatePlatform(p.platform, { display: val }) : updateCustomDestinationDisplay(p.index, val) } value={setting?.display ?? 'horizontal'} - disabled={v.isMidstreamMode} + className={p?.className} + style={p?.style} /> ); } diff --git a/app/components-react/shared/DualOutputToggle.m.less b/app/components-react/shared/DualOutputToggle.m.less new file mode 100644 index 000000000000..61d583700a91 --- /dev/null +++ b/app/components-react/shared/DualOutputToggle.m.less @@ -0,0 +1,40 @@ +.do-toggle { + display: flex; + flex-direction: row; + align-items: baseline; + margin-bottom: 10px; +} + +.do-checkbox { + margin-right: 10px; + color: var(--title); + font-size: 14px; + + :global(.ant-checkbox-wrapper) { + margin: 0px; + } +} + +.dual-output-toggle { + display: flex; + flex-direction: row; + font-size: 14px; + color: var(--title); + margin-bottom: 5px; + + :global(.ant-tooltip-placement-bottom) { + left: 10px !important; + } +} + +.do-tooltip { + :global(.ant-tooltip-placement-bottom .ant-tooltip-arrow) { + left: 170px !important; + } +} + +.so-tooltip { + :global(.ant-tooltip-placement-bottom .ant-tooltip-arrow) { + left: 158px !important; + } +} diff --git a/app/components-react/shared/DualOutputToggle.tsx b/app/components-react/shared/DualOutputToggle.tsx new file mode 100644 index 000000000000..f495702abe95 --- /dev/null +++ b/app/components-react/shared/DualOutputToggle.tsx @@ -0,0 +1,150 @@ +import React, { CSSProperties } from 'react'; +import { Services } from '../service-provider'; +import { useVuex } from 'components-react/hooks'; +import styles from './DualOutputToggle.m.less'; +import { $t } from 'services/i18n'; +import Tooltip, { TTipPosition } from 'components-react/shared/Tooltip'; +import { CheckboxInput } from 'components-react/shared/inputs'; +import { alertAsync } from 'components-react/modals'; +import cx from 'classnames'; + +interface IDualOutputToggleProps { + type?: 'dual' | 'single'; + value?: boolean; + className?: string; + style?: CSSProperties; + disabled?: boolean; + placement?: TTipPosition; + lightShadow?: boolean; + onChange?: (value: boolean) => void; +} + +export default function DualOutputToggle(p: IDualOutputToggleProps) { + const { + TransitionsService, + DualOutputService, + StreamingService, + UsageStatisticsService, + UserService, + TikTokService, + } = Services; + + const v = useVuex(() => ({ + dualOutputMode: DualOutputService.views.dualOutputMode, + studioMode: TransitionsService.views.studioMode, + selectiveRecording: StreamingService.state.selectiveRecording, + isPrime: UserService.state.isPrime, + })); + + const label = v.dualOutputMode ? $t('Disable Dual Output') : $t('Enable Dual Output'); + const value = p?.value ?? v.dualOutputMode; + const placement = p?.placement ?? 'bottom'; + + function toggleDualOutput(val: boolean) { + if (p?.onChange !== undefined) { + p.onChange(val); + return; + } + + if (v.studioMode) { + showStudioModeModal(); + return; + } + + if (v.selectiveRecording) { + showSelectiveRecordingModal(); + return; + } + + // toggle dual output + DualOutputService.actions.setDualOutputMode(!v.dualOutputMode, true, true); + + if (v.dualOutputMode) { + UsageStatisticsService.recordFeatureUsage('DualOutput'); + UsageStatisticsService.recordAnalyticsEvent('DualOutput', { + type: 'ToggleOnDualOutput', + source: 'GoLiveWindow', + isPrime: v.isPrime, + platforms: StreamingService.views.linkedPlatforms, + tiktokStatus: TikTokService.scope, + }); + } + } + + return ( +
    + + + + +
    + ); +} + +function showSelectiveRecordingModal() { + alertAsync({ + type: 'confirm', + title: $t('Selective Recording Enabled'), + closable: true, + content: ( + + {$t( + 'Selective Recording only works with horizontal sources and disables editing the vertical output scene. Please disable Selective Recording to go live with Dual Output.', + )} + + ), + cancelText: $t('Close'), + okText: $t('Disable'), + okButtonProps: { type: 'primary' }, + onOk: () => { + Services.StreamingService.actions.setSelectiveRecording( + !Services.StreamingService.state.selectiveRecording, + ); + }, + cancelButtonProps: { style: { display: 'inline' } }, + }); +} + +function showStudioModeModal() { + alertAsync({ + type: 'confirm', + title: $t('Studio Mode Enabled'), + closable: true, + content: ( + + {$t( + 'Cannot toggle Dual Output while in Studio Mode. Please disable Studio Mode to go live with Dual Output.', + )} + + ), + cancelText: $t('Close'), + okText: $t('Disable'), + okButtonProps: { type: 'primary' }, + onOk: () => { + Services.TransitionsService.actions.disableStudioMode(); + }, + cancelButtonProps: { style: { display: 'inline' } }, + }); +} diff --git a/app/components-react/shared/HotkeyBinding.tsx b/app/components-react/shared/HotkeyBinding.tsx index 8bfa720ec1e3..263a1276c0cc 100644 --- a/app/components-react/shared/HotkeyBinding.tsx +++ b/app/components-react/shared/HotkeyBinding.tsx @@ -37,7 +37,7 @@ export function getBindingString(binding: IBinding) { function getHotkeyString(binding: IBinding | null, focused = false) { if (focused) return 'Press any key combination...'; - if (binding) { + if (binding?.key) { return `${getBindingString(binding)} (Click to re-bind)`; } else { return 'Click to bind'; @@ -65,12 +65,15 @@ export default function HotkeyBinding(p: { hotkey: IHotkey; binding: IBinding | null; onBind: (binding: IBinding) => void; + style?: React.CSSProperties; + showLabel?: boolean; }) { const { MarkersService, DualOutputService } = Services; const [focused, setFocused] = useState(false); const inputRef = useRef(null); + const hotKeyLabel = p.showLabel !== false ? : <>; const showDualOutputLabel = DualOutputService.views.dualOutputMode && p?.hotkey.actionName !== 'SWITCH_TO_SCENE' && @@ -150,8 +153,8 @@ export default function HotkeyBinding(p: {
    : } + style={{ width: 400, ...p.style }} + label={showDualOutputLabel ? : hotKeyLabel} value={getHotkeyString(p.binding, focused)} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} diff --git a/app/components-react/shared/PlatformButton.tsx b/app/components-react/shared/PlatformButton.tsx index 92cf48a3adf3..b50c43eb2df3 100644 --- a/app/components-react/shared/PlatformButton.tsx +++ b/app/components-react/shared/PlatformButton.tsx @@ -22,6 +22,7 @@ interface PlatformIconButtonProps { onClick: () => void; disabled?: boolean; loading?: boolean; + name: string; } const loadingIcon = ; @@ -34,6 +35,7 @@ export const PlatformIconButton = ({ onClick, disabled, loading, + name, }: PlatformIconButtonProps) => { const icon = platform ? ( @@ -43,6 +45,7 @@ export const PlatformIconButton = ({ return ( + + + ); +} diff --git a/app/components-react/windows/go-live/EditStreamWindow.tsx b/app/components-react/windows/go-live/EditStreamWindow.tsx index c00481e46b91..2fcd6b4f9db1 100644 --- a/app/components-react/windows/go-live/EditStreamWindow.tsx +++ b/app/components-react/windows/go-live/EditStreamWindow.tsx @@ -31,7 +31,6 @@ export default function EditStreamWindow() { form, enabledPlatforms, hasMultiplePlatforms, - isRestreamEnabled, primaryChat, setPrimaryChat, } = useGoLiveSettingsRoot({ isUpdateMode: true }); @@ -89,7 +88,7 @@ export default function EditStreamWindow() { ); } - const shouldShowPrimaryChatSwitcher = isRestreamEnabled && hasMultiplePlatforms; + const shouldShowPrimaryChatSwitcher = hasMultiplePlatforms; return ( diff --git a/app/components-react/windows/go-live/GoLive.m.less b/app/components-react/windows/go-live/GoLive.m.less index 58c36bc27649..bc89248796b7 100644 --- a/app/components-react/windows/go-live/GoLive.m.less +++ b/app/components-react/windows/go-live/GoLive.m.less @@ -1,5 +1,165 @@ @import '../../../styles/index.less'; +.settings-row { + height: 100%; + margin: 0px !important; + + :global(.ant-col) { + padding: 0px; + } +} + +.right-column { + height: 100%; + background-color: var(--background); + overflow: hidden; + + &:global(.ant-col.ant-col-16) { + padding: 25px !important; + } +} + +.platform-switcher { + .radius(); + + box-sizing: border-box; + display: flex; + margin: 15px 0px; + padding: 10px; + flex-direction: column; + color: var(--title); + background: var(--dropdown-bg); + transition: transform 0.05s; + cursor: pointer; +} + +.platform-disabled { + opacity: 0.7; + background: rgba(#2b383f, 0.3); +} + +.switcher-header { + display: flex; + flex: 1; + justify-content: space-between; +} + +.platform-info-wrapper { + width: 100%; + display: flex; + flex-direction: row; +} + +.platform-info { + display: flex; + flex-direction: column; + flex-grow: 1; + margin: 0px 5px; +} + +.platform-name { + font-size: 16px; + font-weight: 600; +} + +.platform-username { + color: var(--link); + overflow-wrap: break-word; + display: inline-block; + word-break: break-word; + white-space: pre-line; +} + +.platform-switch { + justify-self: flex-end; + color: var(--background); + + :global(.ant-form-item-label) { + display: none; + } + + :global(.ant-switch-small) { + min-width: 36px; + height: 21px; + line-height: 21px; + } + + :global(.ant-switch-small .ant-switch-handle) { + width: 15px; + height: 15px; + top: 3px; + left: 3px; + } + + :global(.ant-switch-small.ant-switch-checked .ant-switch-handle) { + left: calc(100% - 15px - 3px) !important; + } + + :global(.ant-switch-inner i) { + color: var(--background); + display: flex; + align-self: center; + } +} + +.platform-display { + display: flex; + flex: 1; + justify-content: space-between; +} + +.display-selector { + :global(label.ant-radio-wrapper) { + margin: 0px !important; + } +} + +.label { + align-self: center; + margin-right: 10px; + flex-grow: 1; +} + +.platforms-selector { + &:global(.ant-select) { + width: 100%; + text-align: left; + margin: 0px; + } + + &:global(.ant-select.dual-output-selector) { + width: calc(100% - 30px) !important; + margin: 0px 15px !important; + } +} + +i.icon { + font-size: 14px; + margin-right: 10px; + color: var(--paragraph); +} + +i.selector-icon { + margin-right: 10px !important; + font-size: 15px !important; + width: 15px !important; + height: 15px !important; + color: var(--section) !important; +} + +.platform-logo { + margin: 5px 5px 0 0; +} + +i.destination-logo { + margin: 5px 5px 0 0; + font-size: 30px; +} + +.option-btn { + border: 1px solid var(--nav-icon-inactive); +} + .go-live-settings { height: 100%; &:global(a) { @@ -7,17 +167,21 @@ } } +.column-padding { + padding: 15px; +} + .left-column { height: 100%; + width: 100%; + background-color: var(--dark-background); &:global(.ant-col) { - padding: 0px !important; + padding: 0px 0px 15px 0px !important; } } .switch-wrapper { - padding: 15px; - .platform-switcher:last-child { margin-bottom: 0px; } @@ -30,7 +194,6 @@ } :global(.ant-modal-body) { - background-color: var(--dark-background); padding: 0px; } } diff --git a/app/components-react/windows/go-live/GoLiveChecklist.m.less b/app/components-react/windows/go-live/GoLiveChecklist.m.less index 137395800cf1..1efbcd9069e4 100644 --- a/app/components-react/windows/go-live/GoLiveChecklist.m.less +++ b/app/components-react/windows/go-live/GoLiveChecklist.m.less @@ -1,4 +1,4 @@ -@import "../../../styles/index.less"; +@import '../../../styles/index.less'; .container { display: flex; @@ -6,14 +6,15 @@ align-items: center; justify-content: center; height: 100%; + margin: 0px 20px; } - // make timeline icons and text bigger .container :global(.ant-timeline-item-tail) { - top: 15px; - height: calc(100% - 20px); + top: 15px; + height: calc(100% - 20px); } + .container :global(.ant-timeline-item) { padding-bottom: 30px; } @@ -22,12 +23,14 @@ font-size: 25px; margin-top: 8px; - &:not(:global(.anticon-loading)){ + &:not(:global(.anticon-loading)) { color: var(--primary); + // add a bounce animation :global { animation: 0.8s ease-out bounce; } + // add a fade animation &:after { content: ' '; @@ -40,18 +43,20 @@ position: relative; top: -18px; left: 7px; - :global{ - animation: 0.8s ease-out scaleAndFadeOut + + :global { + animation: 0.8s ease-out scaleAndFadeOut; } } } } -.container :global(.ant-timeline .anticon-close-circle) { - color: var(--red); + +.container :global(.ant-timeline .anticon.anticon-close-circle) { + color: var(--red) !important; } .container :global(.ant-timeline .anticon-loading) { - color: orange; + color: orange !important; margin-top: -2px; } @@ -65,6 +70,7 @@ :global(.ant-timeline-item-content) { color: var(--midtone); } + &.done :global(.ant-timeline-item-content) { color: var(--paragraph); } @@ -78,7 +84,7 @@ // add animation to the success message .success h1 { - :global{ + :global { animation: 0.8s ease-out bounce; } } diff --git a/app/components-react/windows/go-live/GoLiveError.tsx b/app/components-react/windows/go-live/GoLiveError.tsx index bdfb4e9960f4..d9fa614ac29e 100644 --- a/app/components-react/windows/go-live/GoLiveError.tsx +++ b/app/components-react/windows/go-live/GoLiveError.tsx @@ -21,7 +21,6 @@ export default function GoLiveError() { NavigationService, WindowsService, MagicLinkService, - TikTokService, } = Services; // take an error from the global state diff --git a/app/components-react/windows/go-live/GoLiveSettings.tsx b/app/components-react/windows/go-live/GoLiveSettings.tsx index ef31750a2579..dc8d5bd6ccc1 100644 --- a/app/components-react/windows/go-live/GoLiveSettings.tsx +++ b/app/components-react/windows/go-live/GoLiveSettings.tsx @@ -1,21 +1,25 @@ import React from 'react'; import styles from './GoLive.m.less'; -import Scrollable from '../../shared/Scrollable'; -import { Services } from '../../service-provider'; +import Scrollable from 'components-react/shared/Scrollable'; +import { Services } from 'components-react/service-provider'; import { useGoLiveSettings } from './useGoLiveSettings'; -import { DestinationSwitchers } from './DestinationSwitchers'; -import { $t } from '../../../services/i18n'; -import { Alert, Row, Col } from 'antd'; +import { $t } from 'services/i18n'; +import { Row, Col } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; import { Section } from './Section'; import PlatformSettings from './PlatformSettings'; +import TwitterInput from './Twitter'; import OptimizedProfileSwitcher from './OptimizedProfileSwitcher'; -import Spinner from '../../shared/Spinner'; +import Spinner from 'components-react/shared/Spinner'; import GoLiveError from './GoLiveError'; -import TwitterInput from './Twitter'; -import AddDestinationButton from 'components-react/shared/AddDestinationButton'; +import UserSettingsUltra from './dual-output/UserSettingsUltra'; +import UserSettingsNonUltra from './dual-output/UserSettingsNonUltra'; import PrimaryChatSwitcher from './PrimaryChatSwitcher'; import ColorSpaceWarnings from './ColorSpaceWarnings'; +import DualOutputToggle from 'components-react/shared/DualOutputToggle'; +import { DestinationSwitchers } from './DestinationSwitchers'; +import AddDestinationButton from 'components-react/shared/AddDestinationButton'; +import cx from 'classnames'; const PlusIcon = PlusOutlined as Function; @@ -25,24 +29,24 @@ const PlusIcon = PlusOutlined as Function; * - Settings for each platform * - Extras settings **/ -export default function GoLiveSettings() { +export default function DualOutputGoLiveSettings() { const { isAdvancedMode, protectedModeEnabled, error, isLoading, isPrime, + isDualOutputMode, canAddDestinations, canUseOptimizedProfile, showSelector, showTweet, - addDestination, - hasDestinations, hasMultiplePlatforms, enabledPlatforms, primaryChat, - setPrimaryChat, recommendedColorSpaceWarnings, + addDestination, + setPrimaryChat, } = useGoLiveSettings().extend(module => { const { UserService, VideoEncodingOptimizationService, SettingsService } = Services; @@ -62,43 +66,56 @@ export default function GoLiveSettings() { isPrime: UserService.views.isPrime, - canUseOptimizedProfile: - VideoEncodingOptimizationService.state.canSeeOptimizedProfile || - VideoEncodingOptimizationService.state.useOptimizedProfile, - showTweet: UserService.views.auth?.primaryPlatform !== 'twitter', addDestination() { SettingsService.actions.showSettings('Stream'); }, + + // temporarily hide the checkbox until streaming and output settings + // are migrated to the new API + canUseOptimizedProfile: !module.isDualOutputMode + ? VideoEncodingOptimizationService.state.canSeeOptimizedProfile || + VideoEncodingOptimizationService.state.useOptimizedProfile + : false, + // canUseOptimizedProfile: + // VideoEncodingOptimizationService.state.canSeeOptimizedProfile || + // VideoEncodingOptimizationService.state.useOptimizedProfile, }; }); const shouldShowSettings = !error && !isLoading; - const shouldShowLeftCol = protectedModeEnabled; + const shouldShowLeftCol = isDualOutputMode ? true : protectedModeEnabled; const shouldShowAddDestButton = canAddDestinations && isPrime; + const shouldShowPrimaryChatSwitcher = hasMultiplePlatforms; + // TODO: make sure this doesn't jank the UI + const leftPaneHeight = shouldShowPrimaryChatSwitcher ? '81%' : '100%'; + return ( - + {/*LEFT COLUMN*/} {shouldShowLeftCol && ( - - - {/*DESTINATION SWITCHERS*/} - - {/*ADD DESTINATION BUTTON*/} - {shouldShowAddDestButton ? ( - - - {$t('Add Destination')} - - ) : ( - - )} - + + {isDualOutputMode ? ( + + {isPrime && } + {!isPrime && } + + ) : ( + + )} {shouldShowPrimaryChatSwitcher && ( + {shouldShowSettings && ( @@ -131,3 +148,33 @@ export default function GoLiveSettings() { ); } + +function SingleOutputSettings(p: { + showSelector: boolean; + addDestination: () => void; + shouldShowAddDestButton: boolean; +}) { + return ( + + + {/*DESTINATION SWITCHERS*/} + + + {/*ADD DESTINATION BUTTON*/} + {p.shouldShowAddDestButton ? ( + + ) : ( + + )} + + ); +} diff --git a/app/components-react/windows/go-live/GoLiveWindow.tsx b/app/components-react/windows/go-live/GoLiveWindow.tsx index b08e07e4997c..aa3abbee6a79 100644 --- a/app/components-react/windows/go-live/GoLiveWindow.tsx +++ b/app/components-react/windows/go-live/GoLiveWindow.tsx @@ -1,19 +1,19 @@ import styles from './GoLive.m.less'; -import { WindowsService } from 'app-services'; +import { WindowsService, DualOutputService } from 'app-services'; import { ModalLayout } from '../../shared/ModalLayout'; -import { Button } from 'antd'; +import { Button, message } from 'antd'; import { Services } from '../../service-provider'; import GoLiveSettings from './GoLiveSettings'; -import DualOutputGoLiveSettings from './dual-output/DualOutputGoLiveSettings'; import GoLiveBanner from './GoLiveInfoBanner'; import React from 'react'; import { $t } from '../../../services/i18n'; import GoLiveChecklist from './GoLiveChecklist'; +import { alertAsync } from 'components-react/modals'; import Form from '../../shared/inputs/Form'; +import Translate from 'components-react/shared/Translate'; import Animation from 'rc-animate'; import { useGoLiveSettings, useGoLiveSettingsRoot } from './useGoLiveSettings'; import { inject } from 'slap'; -import cx from 'classnames'; export default function GoLiveWindow() { const { lifecycle, form } = useGoLiveSettingsRoot().extend(module => ({ @@ -27,13 +27,11 @@ export default function GoLiveWindow() { const shouldShowSettings = ['empty', 'prepopulate', 'waitForNewSettings'].includes(lifecycle); const shouldShowChecklist = ['runChecklist', 'live'].includes(lifecycle); - const showDualOutput = shouldShowSettings && Services.DualOutputService.views.dualOutputMode; + + // message.error({ content: $t('Streaming to TikTok not approved.'), duration: 200 }); return ( - } - className={cx({ [styles.dualOutputGoLive]: showDualOutput })} - > + } className={styles.dualOutputGoLive}>
    {/* STEP 1 - FILL OUT THE SETTINGS FORM */} - {showDualOutput && } - {shouldShowSettings && !showDualOutput && } + {shouldShowSettings && } {/* STEP 2 - RUN THE CHECKLIST */} {shouldShowChecklist && } @@ -61,10 +58,15 @@ function ModalFooter() { goLive, close, goBackToSettings, + getCanStreamDualOutput, + toggleDualOutputMode, isLoading, promptApply, + isDualOutputMode, + horizontalHasTargets, } = useGoLiveSettings().extend(module => ({ windowsService: inject(WindowsService), + dualOutputService: inject(DualOutputService), close() { this.windowsService.actions.closeChildWindow(); @@ -74,15 +76,66 @@ function ModalFooter() { module.prepopulate(); }, + toggleDualOutputMode() { + this.dualOutputService.actions.setDualOutputMode(false, true, true); + }, + + get horizontalHasTargets() { + const platformDisplays = module.state.activeDisplayPlatforms; + const destinationDisplays = module.state.activeDisplayDestinations; + + return platformDisplays.horizontal.length > 0 || destinationDisplays.horizontal.length > 0; + }, + get promptApply() { return Services.TikTokService.promptApply; }, + + get isDualOutputMode() { + return Services.TikTokService.promptApply; + }, })); const shouldShowConfirm = ['prepopulate', 'waitForNewSettings'].includes(lifecycle); const shouldShowGoBackButton = lifecycle === 'runChecklist' && error && checklist.startVideoTransmission !== 'done'; + function handleGoLive() { + if (isDualOutputMode && !getCanStreamDualOutput()) { + handleConfirmGoLive(); + return; + } + + goLive(); + } + + function handleConfirmGoLive() { + const display = horizontalHasTargets ? $t('Horizontal') : $t('Vertical'); + + alertAsync({ + type: 'warning', + title: $t('Confirm Horizontal and Vertical Platforms'), + closable: true, + content: ( + display. To use Dual Output you must stream to one horizontal and one vertical platform. Do you want to go live in single output mode with the Horizontal display?', + )} + renderSlots={{ + display: () => { + return {display}; + }, + }} + > + ), + cancelText: $t('Close'), + okText: $t('Confirm'), + okButtonProps: { type: 'primary' }, + onOk: () => toggleDualOutputMode(), + cancelButtonProps: { style: { display: 'inline' } }, + }); + } + return ( {promptApply && } @@ -99,7 +152,7 @@ function ModalFooter() { - )} + + } + + +
    + {$t( + 'Some third party applications connect to Streamlabs Desktop via websockets connection. Toggle this to allow such connections and display connection info.', + )} +
    + + +   + {$t('Warning: Displaying this portion on stream may leak sensitive information.')} + +
    +
    +
    +
    + - - - - )} - + {websocketsEnabled && ( +
    + + + {$t('Generate new')}} + /> +
    + )} +
    +
    + ); } diff --git a/app/components-react/windows/settings/Stream.tsx b/app/components-react/windows/settings/Stream.tsx index 371ddea3fae8..67c1fa05e58f 100644 --- a/app/components-react/windows/settings/Stream.tsx +++ b/app/components-react/windows/settings/Stream.tsx @@ -39,18 +39,27 @@ function censorEmail(str: string) { */ class StreamSettingsModule { constructor() { + const showMessage = (msg: string, success: boolean) => { + message.config({ + duration: 6, + maxCount: 1, + }); + + if (success) { + message.success(msg); + } else { + message.error(msg); + } + }; Services.UserService.refreshedLinkedAccounts.subscribe( (res: { success: boolean; message: string }) => { - message.config({ - duration: 6, - maxCount: 1, - }); - - if (res.success) { - message.success(res.message); - } else { - message.error(res.message); - } + const doShowMessage = () => showMessage(res.message, res.success); + /* + * Since the settings window pops out anyways (presumably because of + * using `message`make sure it is at least on the right page, as opposed + * to in an infinite loading blank window state. + */ + doShowMessage(); }, ); } @@ -208,7 +217,7 @@ class StreamSettingsModule { } async platformMergeInline(platform: TPlatform) { - const mode = ['youtube', 'twitch', 'twitter', 'tiktok'].includes(platform) + const mode = ['youtube', 'twitch', 'twitter', 'tiktok', 'kick'].includes(platform) ? 'external' : 'internal'; @@ -463,7 +472,7 @@ function Platform(p: { platform: TPlatform }) { style={{ backgroundColor: `var(--${platform})`, borderColor: 'transparent', - color: ['trovo', 'instagram'].includes(platform) ? 'black' : 'inherit', + color: ['trovo', 'instagram', 'kick'].includes(platform) ? 'black' : 'inherit', }} > {$t('Connect')} diff --git a/app/components-react/windows/settings/Video.tsx b/app/components-react/windows/settings/Video.tsx index 1fc723e99874..b31ca2facabb 100644 --- a/app/components-react/windows/settings/Video.tsx +++ b/app/components-react/windows/settings/Video.tsx @@ -4,8 +4,6 @@ import { useModule, injectState } from 'slap'; import { Services } from '../../service-provider'; import { message } from 'antd'; import FormFactory, { TInputValue } from 'components-react/shared/inputs/FormFactory'; -import { CheckboxInput } from 'components-react/shared/inputs'; -import Tooltip from 'components-react/shared/Tooltip'; import { EScaleType, EFPSType, IVideoInfo } from '../../../../obs-api'; import { $t } from 'services/i18n'; import styles from './Common.m.less'; @@ -13,6 +11,7 @@ import Tabs from 'components-react/shared/Tabs'; import { invalidFps, IVideoInfoValue, TDisplayType } from 'services/settings-v2/video'; import { AuthModal } from 'components-react/shared/AuthModal'; import Utils from 'services/utils'; +import DualOutputToggle from '../../shared/DualOutputToggle'; const CANVAS_RES_OPTIONS = [ { label: '1920x1080', value: '1920x1080' }, @@ -60,6 +59,7 @@ class VideoSettingsModule { userService = Services.UserService; dualOutputService = Services.DualOutputService; streamingService = Services.StreamingService; + tiktokService = Services.TikTokService; get display(): TDisplayType { return this.state.display; @@ -505,6 +505,9 @@ class VideoSettingsModule { Services.UsageStatisticsService.recordAnalyticsEvent('DualOutput', { type: 'ToggleOnDualOutput', source: 'VideoSettings', + isPrime: this.userService.isPrime, + platforms: this.streamingService.views.linkedPlatforms, + tiktokStatus: this.tiktokService.scope, }); } } @@ -543,31 +546,13 @@ export function VideoSettings() { <>

    {$t('Video')}

    -
    - {/* THIS CHECKBOX TOGGLES DUAL OUTPUT MODE FOR THE ENTIRE APP */} - - {$t('Enable Dual Output')} - - - -
    - {/* )} */} +
    {showDualOutputSettings && } diff --git a/app/components-react/windows/source-showcase/SourceGrid.tsx b/app/components-react/windows/source-showcase/SourceGrid.tsx index ba5492412b2a..4bd387d7479e 100644 --- a/app/components-react/windows/source-showcase/SourceGrid.tsx +++ b/app/components-react/windows/source-showcase/SourceGrid.tsx @@ -74,7 +74,7 @@ export default function SourceGrid(p: { activeTab: string }) { const availableSources = useMemo(() => { const guestCamAvailable = (IncrementalRolloutService.views.featureIsEnabled(EAvailableFeatures.guestCamBeta) || - IncrementalRolloutService.views.featureIsEnabled(EAvailableFeatures.guestCaProduction)) && + IncrementalRolloutService.views.featureIsEnabled(EAvailableFeatures.guestCamProduction)) && UserService.views.isLoggedIn; return SourcesService.getAvailableSourcesTypesList().filter(type => { diff --git a/app/components/custom-source-properties/WidgetProperties.vue b/app/components/custom-source-properties/WidgetProperties.vue deleted file mode 100644 index e1343190ba06..000000000000 --- a/app/components/custom-source-properties/WidgetProperties.vue +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/app/components/custom-source-properties/WidgetProperties.vue.ts b/app/components/custom-source-properties/WidgetProperties.vue.ts deleted file mode 100644 index 556a10e5b005..000000000000 --- a/app/components/custom-source-properties/WidgetProperties.vue.ts +++ /dev/null @@ -1,65 +0,0 @@ -import Vue from 'vue'; -import { Component, Prop } from 'vue-property-decorator'; -import { ISourceApi } from 'services/sources'; -import { WidgetType } from 'services/widgets'; -import { NavigationService } from 'services/navigation'; -import { WindowsService } from 'services/windows'; -import { Inject } from 'services/core/injector'; -import { UserService } from 'services/user'; -import { MagicLinkService } from 'services/magic-link'; -import * as remote from '@electron/remote'; - -@Component({}) -export default class WidgetProperties extends Vue { - @Prop() source: ISourceApi; - - @Inject() navigationService: NavigationService; - @Inject() windowsService: WindowsService; - @Inject() userService: UserService; - @Inject() magicLinkService: MagicLinkService; - - get isLoggedIn() { - return this.userService.isLoggedIn; - } - - login() { - this.windowsService.closeChildWindow(); - this.userService.showLogin(); - } - - disabled = false; - - async navigateWidgetSettings() { - const widgetType = this.source.getPropertiesManagerSettings().widgetType; - - const subPage = { - [WidgetType.AlertBox]: 'alertbox', - [WidgetType.DonationGoal]: 'donationgoal', - [WidgetType.FollowerGoal]: 'followergoal', - [WidgetType.SubscriberGoal]: 'followergoal', - [WidgetType.BitGoal]: 'bitgoal', - [WidgetType.StarsGoal]: 'starsgoal', - [WidgetType.SupporterGoal]: 'supportergoal', - [WidgetType.DonationTicker]: 'donationticker', - [WidgetType.ChatBox]: 'chatbox', - [WidgetType.EventList]: 'eventlist', - [WidgetType.TipJar]: 'jar', - [WidgetType.ViewerCount]: 'viewercount', - [WidgetType.StreamBoss]: 'streamboss', - [WidgetType.Credits]: 'credits', - [WidgetType.SpinWheel]: 'wheel', - [WidgetType.CustomWidget]: 'customwidget', - }[widgetType.toString()]; - - this.disabled = true; - - try { - const link = await this.magicLinkService.getDashboardMagicLink(subPage); - remote.shell.openExternal(link); - } catch (e: unknown) { - console.error('Error generating dashboard magic link', e); - } - - this.windowsService.closeChildWindow(); - } -} diff --git a/app/components/shared/PlatformLogo.m.less b/app/components/shared/PlatformLogo.m.less index b82d7edd2005..57afedab6ace 100644 --- a/app/components/shared/PlatformLogo.m.less +++ b/app/components/shared/PlatformLogo.m.less @@ -36,4 +36,11 @@ background-size: contain; background-repeat: no-repeat; } - +.kick { + background-image: url('https://slobs-cdn.streamlabs.com/media/kick-logo.png'); + display: inline-block; + width: 40px; + height: 40px; + background-size: contain; + background-repeat: no-repeat; +} diff --git a/app/components/shared/PlatformLogo.tsx b/app/components/shared/PlatformLogo.tsx index 5973a3327733..a7a1bf7e4814 100644 --- a/app/components/shared/PlatformLogo.tsx +++ b/app/components/shared/PlatformLogo.tsx @@ -21,6 +21,7 @@ export default class PlatformLogo extends TsxComponent { nimotv: 'nimotv', streamlabs: 'icon-streamlabs', trovo: 'trovo', + kick: 'kick', twitter: 'twitter', instagram: 'instagram', }[this.props.platform]; diff --git a/app/components/widgets/WidgetSettings.vue.ts b/app/components/widgets/WidgetSettings.vue.ts index 35b207f45aec..d3cbbe9286a6 100644 --- a/app/components/widgets/WidgetSettings.vue.ts +++ b/app/components/widgets/WidgetSettings.vue.ts @@ -59,6 +59,7 @@ export default class WidgetSettings< this.requestState = 'success'; this.afterFetch(); } catch (e: unknown) { + console.error('Something failed on widget settings fetch', e); this.requestState = 'fail'; } } diff --git a/app/components/windows/SourceProperties.vue.ts b/app/components/windows/SourceProperties.vue.ts index 0580c4493b12..a2f92ca218d2 100644 --- a/app/components/windows/SourceProperties.vue.ts +++ b/app/components/windows/SourceProperties.vue.ts @@ -7,7 +7,6 @@ import { SourcesService } from 'services/sources'; import ModalLayout from 'components/ModalLayout.vue'; import { Display } from 'components/shared/ReactComponentList'; import GenericForm from 'components/obs/inputs/GenericForm'; -import WidgetProperties from 'components/custom-source-properties/WidgetProperties.vue'; import StreamlabelProperties from 'components/custom-source-properties/StreamlabelProperties'; import PlatformAppProperties from 'components/custom-source-properties/PlatformAppProperties.vue'; import { $t } from 'services/i18n'; @@ -23,7 +22,6 @@ import * as remote from '@electron/remote'; ModalLayout, Display, GenericForm, - WidgetProperties, StreamlabelProperties, PlatformAppProperties, }, diff --git a/app/components/windows/Troubleshooter.vue b/app/components/windows/Troubleshooter.vue index fe728510f81a..836dc140c1dc 100644 --- a/app/components/windows/Troubleshooter.vue +++ b/app/components/windows/Troubleshooter.vue @@ -105,6 +105,51 @@
    + +
    +

    + + {{ issue.message.split(':')[0] }} +

    +

    + {{ $t('Streamlabs has detected high CPU usage in Dual Output mode') }} + {{ moment(issue.date) }}.
    +

    +

    {{ $t('What does this mean?') }}

    +

    + {{ $t('System resource overuse.') }} + {{ + $t( + 'To mitigate hide one of outputs or right click in editor to enable Performance Mode.', + ) + }} + {{ + $t( + 'This problem could also be due to high CPU usage from other applications or unsuitable encoder settings.', + ) + }} + {{ $t('When this happens, Streamlabs does not have any resources left over.') }} +

    +

    + {{ $t('What can I do?') }} +

    + +
      +
    • {{ $t('Enable performance mode in the Editor context menu') }}
    • +
    • {{ $t("Hide one or both of the displays in Editor's Scene section") }}
    • +
    • + {{ + $t( + "Ensure that you don't have any other applications open that are heavy on your CPU", + ) + }} +
    • +
    + + +
    diff --git a/app/components/windows/Troubleshooter.vue.ts b/app/components/windows/Troubleshooter.vue.ts index ebc9a7206935..1c3dcd7213b2 100644 --- a/app/components/windows/Troubleshooter.vue.ts +++ b/app/components/windows/Troubleshooter.vue.ts @@ -13,6 +13,7 @@ import { StreamingService } from 'services/streaming'; import { TObsFormData } from '../obs/inputs/ObsInput'; import GenericFormGroups from '../obs/inputs/GenericFormGroups.vue'; import { StartStreamingButton } from 'components/shared/ReactComponentList'; +import { CustomizationService } from 'services/customization'; @Component({ components: { ModalLayout, GenericFormGroups, StartStreamingButton }, @@ -22,6 +23,7 @@ export default class Troubleshooter extends Vue { @Inject() private settingsService: SettingsService; @Inject() private windowsService!: WindowsService; @Inject() streamingService: StreamingService; + @Inject() customizationService: CustomizationService; issueCode = this.windowsService.getChildWindowQueryParams().issueCode as TIssueCode; @@ -52,6 +54,10 @@ export default class Troubleshooter extends Vue { return this.settingsService.state.Output.formData.map(hideParamsForCategory); } + get performanceMode() { + return this.customizationService.state.performanceMode; + } + showSettings() { this.settingsService.showSettings(); } @@ -75,6 +81,12 @@ export default class Troubleshooter extends Vue { destroyed() { this.subscription.unsubscribe(); } + + enablePerformanceMode() { + this.customizationService.setSettings({ + performanceMode: true, + }); + } } const paramsToShow = ['server', 'VBitrate', 'ABitrate']; diff --git a/app/components/windows/settings/HotkeyGroup.tsx b/app/components/windows/settings/HotkeyGroup.tsx deleted file mode 100644 index 3d40cad3d924..000000000000 --- a/app/components/windows/settings/HotkeyGroup.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Component } from 'vue-property-decorator'; -import Hotkey from 'components/shared/Hotkey.vue'; -import { IHotkey } from 'services/hotkeys'; -import cx from 'classnames'; -import TsxComponent, { createProps } from 'components/tsx-component'; - -class HotkeyGroupProps { - hotkeys: IHotkey[] = []; - title: string = null; - isSearch: boolean = false; -} - -@Component({ props: createProps(HotkeyGroupProps) }) -export default class HotkeyGroup extends TsxComponent { - collapsed = true; - - get header() { - return this.props.title ? ( -

    (this.collapsed = !this.collapsed)} - > - {this.isCollapsible && this.collapsed ? : null} - {this.isCollapsible && !this.collapsed ? ( - - ) : null} - {this.props.title} -

    - ) : null; - } - - get isCollapsible() { - return this.props.title && !this.props.isSearch; - } - - render() { - return ( -
    - {this.header} - - {(!this.isCollapsible || !this.collapsed) && ( -
    - {this.props.hotkeys.map(hotkey => ( -
    - -
    - ))} -
    - )} -
    -
    - ); - } -} diff --git a/app/components/windows/settings/Hotkeys.vue b/app/components/windows/settings/Hotkeys.vue deleted file mode 100644 index 999ecf5c5c45..000000000000 --- a/app/components/windows/settings/Hotkeys.vue +++ /dev/null @@ -1,37 +0,0 @@ - - - diff --git a/app/components/windows/settings/Hotkeys.vue.ts b/app/components/windows/settings/Hotkeys.vue.ts deleted file mode 100644 index 347a7dbe9901..000000000000 --- a/app/components/windows/settings/Hotkeys.vue.ts +++ /dev/null @@ -1,121 +0,0 @@ -import Vue from 'vue'; -import { Component, Prop, Watch } from 'vue-property-decorator'; -import { Inject } from 'services/core'; -import { HotkeysService, IHotkeysSet, IHotkey } from 'services/hotkeys'; -import { ScenesService } from 'services/scenes/index'; -import { SourcesService } from 'services/sources/index'; -import HotkeyGroup from './HotkeyGroup'; -import VFormGroup from 'components/shared/inputs/VFormGroup.vue'; -import Fuse from 'fuse.js'; -import mapValues from 'lodash/mapValues'; - -interface IAugmentedHotkey extends IHotkey { - // Will be scene or source name - categoryName?: string; -} - -interface IAugmentedHotkeySet { - general: IAugmentedHotkey[]; - sources: Dictionary; - scenes: Dictionary; - markers: IAugmentedHotkey[]; -} - -@Component({ - components: { HotkeyGroup, VFormGroup }, -}) -export default class Hotkeys extends Vue { - @Inject() private sourcesService: SourcesService; - @Inject() private scenesService: ScenesService; - @Inject() private hotkeysService: HotkeysService; - - hotkeySet: IHotkeysSet = null; - - // sync global search and local search - @Prop() - globalSearchStr!: string; - @Watch('globalSearchStr') - onGlobalSearchChange(val: string) { - this.searchString = val; - } - - @Prop() - highlightSearch!: (searchStr: string) => any; - searchString = this.globalSearchStr || ''; - @Watch('searchString') - onSearchStringChangedHandler(val: string) { - this.highlightSearch(val); - } - - @Prop({ default: false }) - scanning: boolean; - - async mounted() { - // We don't want hotkeys registering while trying to bind. - // We may change our minds on this in the future. - this.hotkeysService.actions.unregisterAll(); - - this.hotkeySet = await this.hotkeysService.actions.return.getHotkeysSet(); - await this.$nextTick(); - this.highlightSearch(this.globalSearchStr); - } - - destroyed() { - if (this.hotkeySet) this.hotkeysService.actions.applyHotkeySet(this.hotkeySet); - } - - get sources() { - return this.sourcesService.views.sources; - } - - get augmentedHotkeySet(): IAugmentedHotkeySet { - return { - general: this.hotkeySet.general, - sources: mapValues(this.hotkeySet.sources, (hotkeys, sourceId) => { - return hotkeys.map((hotkey: IAugmentedHotkey) => { - // Mutating the original object is required for bindings to work - // TODO: We should refactor this to not rely on child components - // mutating the original objects. - hotkey.categoryName = this.sourcesService.views.getSource(sourceId).name; - return hotkey; - }); - }), - scenes: mapValues(this.hotkeySet.scenes, (hotkeys, sceneId) => { - return hotkeys.map((hotkey: IAugmentedHotkey) => { - hotkey.categoryName = this.scenesService.views.getScene(sceneId).name; - return hotkey; - }); - }), - markers: this.hotkeySet.markers, - }; - } - - get filteredHotkeySet(): IAugmentedHotkeySet { - if (this.searchString) { - return { - general: this.filterHotkeys(this.augmentedHotkeySet.general), - sources: mapValues(this.augmentedHotkeySet.sources, hotkeys => this.filterHotkeys(hotkeys)), - scenes: mapValues(this.augmentedHotkeySet.scenes, hotkeys => this.filterHotkeys(hotkeys)), - markers: this.filterHotkeys(this.augmentedHotkeySet.markers), - }; - } - - return this.augmentedHotkeySet; - } - - hasHotkeys(hotkeyDict: Dictionary) { - for (const key in hotkeyDict) { - if (hotkeyDict[key].length) return true; - } - - return false; - } - - private filterHotkeys(hotkeys: IHotkey[]): IHotkey[] { - return new Fuse(hotkeys, { - keys: ['description', 'categoryName'], - threshold: 0.4, - shouldSort: true, - }).search(this.searchString); - } -} diff --git a/app/components/windows/settings/Settings.vue.ts b/app/components/windows/settings/Settings.vue.ts index bb8b97f7f4a2..6c87aabfc88b 100644 --- a/app/components/windows/settings/Settings.vue.ts +++ b/app/components/windows/settings/Settings.vue.ts @@ -16,6 +16,7 @@ import VirtualWebcamSettings from './VirtualWebcamSettings'; import { MagicLinkService } from 'services/magic-link'; import { UserService } from 'services/user'; import { DismissablesService, EDismissable } from 'services/dismissables'; +import { DualOutputService } from 'services/dual-output'; import Scrollable from 'components/shared/Scrollable'; import { ObsSettings, @@ -57,6 +58,7 @@ export default class Settings extends Vue { @Inject() magicLinkService: MagicLinkService; @Inject() userService: UserService; @Inject() dismissablesService: DismissablesService; + @Inject() dualOutputService: DualOutputService; $refs: { settingsContainer: HTMLElement & SearchablePages }; @@ -173,10 +175,16 @@ export default class Settings extends Vue { } getInitialCategoryName() { - if (this.windowsService.state.child.queryParams) { - return this.windowsService.state.child.queryParams.categoryName || 'General'; - } - return 'General'; + /* Some sort of race condition, perhaps `WindowsService` creating + * the window, and *only* after updating its options, results in + * accessing state here to be empty for `state.child.queryParams` + * which is what this method used to use, unless the child window + * has already been displayed once? + * + * Switching to this method call seems to solve the issue, plus we + * shouldn't be accessing state directly regardless. + */ + return this.windowsService.getChildWindowQueryParams()?.categoryName ?? 'General'; } get categoryNames() { @@ -273,6 +281,7 @@ export default class Settings extends Vue { }) .then(({ response }) => { if (response === 0) { + this.dualOutputService.setDualOutputMode(false, true); this.userService.logOut(); } }); diff --git a/app/i18n/en-US/common.json b/app/i18n/en-US/common.json index d413017fe694..8933f53c62a8 100644 --- a/app/i18n/en-US/common.json +++ b/app/i18n/en-US/common.json @@ -154,11 +154,13 @@ "Instagram": "Instagram", "Instagram Live": "Instagram Live", "X (Twitter)": "X (Twitter)", + "Kick": "Kick", "Facebook Profiles": "Facebook Profiles", "Facebook Pages": "Facebook Pages", "Alert Box": "Alert Box", "Widget": "Widget", "Creator Sites": "Creator Sites", + "Collectibles": "Collectibles", "Apps Manager": "Apps Manager", "Theme Audit": "Theme Audit", "Login": "Login", diff --git a/app/i18n/en-US/highlighter.json b/app/i18n/en-US/highlighter.json index 226ba73defa6..87ff899eb2d6 100644 --- a/app/i18n/en-US/highlighter.json +++ b/app/i18n/en-US/highlighter.json @@ -101,5 +101,46 @@ "Clip": "Clip", "Subtitle": "Subtitle", "Record your screen with Streamlabs Desktop. Once recording is complete, it will be displayed here. Access your files or edit further with Streamlabs tools.": "Record your screen with Streamlabs Desktop. Once recording is complete, it will be displayed here. Access your files or edit further with Streamlabs tools.", - "Recordings": "Recordings" + "Recordings": "Recordings", + "Manual Highlighter": "Manual Highlighter", + "Manually capture the best moments from your livestream with a hotkey command, and automatically turn them into a highlight video.": "Manually capture the best moments from your livestream with a hotkey command, and automatically turn them into a highlight video.", + "End your stream to change the Hotkey or the replay duration.": "End your stream to change the Hotkey or the replay duration.", + "No clips found": "No clips found", + "All highlight clips": "All highlight clips", + "Loading": "Loading", + "Trim": "Trim", + "Intro": "Intro", + "Outro": "Outro", + "All Clips": "All Clips", + "Export highlight reel": "Export highlight reel", + "Restart": "Restart", + "Add Clips": "Add Clips", + "Edit Clips": "Edit Clips", + "Searching for highlights...": "Searching for highlights...", + "Not enough highlights found": "Not enough highlights found", + "Highlights cancelled": "Highlights cancelled", + "Highlights failed": "Highlights failed", + "Import Fortnite Stream": "Import Fortnite Stream", + "Select video to start import": "Select video to start import", + "Select your Fortnite recording": "Select your Fortnite recording", + "Settings": "Settings", + "Delete highlighted stream?": "Delete highlighted stream?", + "Are you sure you want to delete this stream and all its associated clips? This action cannot be undone.": "Are you sure you want to delete this stream and all its associated clips? This action cannot be undone.", + "Set a title for your stream": "Set a title for your stream", + "Create highlight video of": "Create highlight video of", + "All rounds": "All rounds", + "unlimited": "unlimited", + "%{duration} minutes": "%{duration} minutes", + "%{duration} minute": "%{duration} minute", + "with a duration of": "with a duration of", + "AI Highlighter": "AI Highlighter", + "For Fortnite streams (Beta)": "For Fortnite streams (Beta)", + "Automatically capture the best moments from your livestream and turn them into a highlight video.": "Automatically capture the best moments from your livestream and turn them into a highlight video.", + "Recommended": "Recommended", + "Stream Highlights": "Stream Highlights", + "Export Vertical": "Export Vertical", + "Export Horizontal": "Export Horizontal", + "Get highlights (Fortnite only)": "Get highlights (Fortnite only)", + "My Stream Highlights": "My Stream Highlights", + "You cannot use special characters in this field": "You cannot use special characters in this field" } \ No newline at end of file diff --git a/app/i18n/en-US/kick.json b/app/i18n/en-US/kick.json new file mode 100644 index 000000000000..37b7dc85eb43 --- /dev/null +++ b/app/i18n/en-US/kick.json @@ -0,0 +1,3 @@ +{ + "Edit your stream title on Kick after going live.": "Edit your stream title on Kick after going live." +} diff --git a/app/i18n/en-US/onboarding.json b/app/i18n/en-US/onboarding.json index ed41c121e107..8e9fc007797e 100644 --- a/app/i18n/en-US/onboarding.json +++ b/app/i18n/en-US/onboarding.json @@ -168,5 +168,27 @@ "Tips to run your first stream like a Pro:": "Tips to run your first stream like a Pro:", "Set yourself up for success with our getting started guide": "Set yourself up for success with our getting started guide", "Prevent crashes with this troubleshooting guide": "Prevent crashes with this troubleshooting guide", - "Learn more about streaming through our free Streamer University": "Learn more about streaming through our free Streamer University" + "Learn more about streaming through our free Streamer University": "Learn more about streaming through our free Streamer University", + "Recording": "Recording", + "Game Overlay": "Game Overlay", + "Dual Output (1 platform + TikTok)": "Dual Output (1 platform + TikTok)", + "Alert Box & Widget Themes": "Alert Box & Widget Themes", + "Access all App Store Apps": "Access all App Store Apps", + "Dual Output (3+ destinations)": "Dual Output (3+ destinations)", + "Collab Cam up to 11 guests": "Collab Cam up to 11 guests", + "Pro tier across the rest of the suite": "Pro tier across the rest of the suite", + "And many more Ultra features": "And many more Ultra features", + "Everything you need to go live.": "Everything you need to go live.", + "Always and forever free": "Aways and forever free", + "Choose Ultra": "Choose Ultra", + "Premium features for your stream.": "Premium features for your stream.", + "Other Themes": "Other Themes", + "by %{designerName}": "by %{designerName}", + "Setup your mic & webcam": "Setup your mic & webcam", + "Connect your most essential devices now or later on.": "Connect your most essential devices now or later on.", + "Choose your plan": "Choose your plan", + "Choose the best plan to fit your content creation needs.": "Choose the best plan to fit your content creation needs.", + "Add your first theme": "Add your first theme", + "Try your first theme now, browse hundreds of more themes later on.": "Try your first theme now, browse hundreds of more themes later on.", + "Set up your mic & webcam": "Set up your mic & webcam" } diff --git a/app/i18n/en-US/overlays.json b/app/i18n/en-US/overlays.json index 8025ca7e8c72..b33b0f7b8626 100644 --- a/app/i18n/en-US/overlays.json +++ b/app/i18n/en-US/overlays.json @@ -16,5 +16,12 @@ "Assign Vertical Sources to Horizontal Display": "Assign Vertical Sources to Horizontal Display", "The below will create a copy of the active scene collection, set the copy as the active collection, and then apply the function.": "The below will create a copy of the active scene collection, set the copy as the active collection, and then apply the function.", "Manage Dual Output Scene": "Manage Dual Output Scene", - "Unable to convert dual output collection.": "Unable to convert dual output collection." + "Unable to convert dual output collection.": "Unable to convert dual output collection.", + "default_width": "default_width", + "default_height": "default_height", + "GameCapture.WindowInternalMode": "GameCapture.WindowInternalMode", + "The below will create a copy of the active scene collection, set the copy as the active collection, and then remove all vertical sources.": "The below will create a copy of the active scene collection, set the copy as the active collection, and then remove all vertical sources.", + "Show Components Library": "Show Components Library", + "Convert Dual Output Scene Collection": "Convert Dual Output Scene Collection", + "Repair Scene Collection": "Repair Scene Collection" } diff --git a/app/i18n/en-US/promotional-copy.json b/app/i18n/en-US/promotional-copy.json index e551d33fdbdf..712ca1d1b9ab 100644 --- a/app/i18n/en-US/promotional-copy.json +++ b/app/i18n/en-US/promotional-copy.json @@ -71,5 +71,8 @@ "1 Hour Videos + 250GB Storage + More": "1 Hour Videos + 250GB Storage + More", "Highest Profit Margins": "Highest Profit Margins", "%{monthlyPrice}/mo or %{yearlyPrice}/year": "%{monthlyPrice}/mo or %{yearlyPrice}/year", - "Text-based editing of VOD content": "Text-based editing of VOD content" + "Text-based editing of VOD content": "Text-based editing of VOD content", + "Polish your videos with text-based and AI powered Podcast Editor": "Polish your videos with text-based and AI powered Podcast Editor", + "Turn your videos into mobile-friendly short-form TikToks, Reels, and Shorts with Cross Clip": "Turn your videos into mobile-friendly short-form TikToks, Reels, and Shorts with Cross Clip", + "Edit video professionally from your browser with Video Editor": "Edit video professionally from your browser with Video Editor" } diff --git a/app/i18n/en-US/remote-control.json b/app/i18n/en-US/remote-control.json index 7ac768e3d39c..3dc43ee3130a 100644 --- a/app/i18n/en-US/remote-control.json +++ b/app/i18n/en-US/remote-control.json @@ -1,10 +1,18 @@ { "Show details": "Show details", "Hide details": "Hide details", - "API token": "API token", + "API Token": "API Token", "Generate new": "Generate new", "Port": "Port", - "IP addresses": "IP addresses", + "IP Addresses": "IP Addresses", "Click to show": "Click to show", - "The free Streamlabs Controller app allows you to control Streamlabs Desktop from your iOS or Android device. Scan the QR code below to begin.": "The free Streamlabs Controller app allows you to control Streamlabs Desktop from your iOS or Android device. Scan the QR code below to begin." + "The free Streamlabs Controller app allows you to control Streamlabs Desktop from your iOS or Android device. You must be logged in to use this feature.": "The free Streamlabs Controller app allows you to control Streamlabs Desktop from your iOS or Android device. You must be logged in to use this feature.", + "Allow Controller app connections": "Allow Controller app connections", + "Connected Devices": "Connected Devices", + "Disconnect": "Disconnect", + "No devices connected": "No devices connected", + "Some third party applications connect to Streamlabs Desktop via websockets connection. Toggle this to allow such connections and display connection info.": "Some third party applications connect to Streamlabs Desktop via websockets connection. Toggle this to allow such connections and display connection info.", + "Warning: Displaying this portion on stream may leak sensitive information.": "Warning: Displaying this portion on stream may leak sensitive information.", + "Allow third party connections": "Allow third party connections", + "Host": "Host" } diff --git a/app/i18n/en-US/streaming.json b/app/i18n/en-US/streaming.json index e43aabfed138..562fc9096355 100644 --- a/app/i18n/en-US/streaming.json +++ b/app/i18n/en-US/streaming.json @@ -220,6 +220,8 @@ "Error generating TikTok stream credentials": "Error generating TikTok stream credentials", "You are already live on a another device": "You are already live on a another device", "Connect your TikTok account to stream to TikTok and one additional platform for free.": "Connect your TikTok account to stream to TikTok and one additional platform for free.", + "Failed to authenticate with TikTok, re-login or re-merge TikTok account": "Failed to authenticate with TikTok, re-login or re-merge TikTok account", + "user might be blocked from streaming to TikTok but do not say they are. Refer them to TikTok": "user might be blocked from streaming to TikTok but do not say they are. Refer them to TikTok", "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", "Stream features branded content": "Stream features branded content", @@ -254,5 +256,28 @@ "Disable Dual Output": "Disable Dual Output", "You can now reply to Twitch, YouTube and Facebook messages in Multistream chat. Click to learn more.": "You can now reply to Twitch, YouTube and Facebook messages in Multistream chat. Click to learn more.", "Multistream Chat Platform Support": "Multistream Chat Platform Support", - "You have selected %{colorFormat} as Color Format. Formats other than NV12 and P010 are commonly used for recording, and might incur high CPU usage or the streaming platform might not support it. Go to Settings -> Advanced -> Video to review.": "You have selected %{colorFormat} as Color Format. Formats other than NV12 and P010 are commonly used for recording, and might incur high CPU usage or the streaming platform might not support it. Go to Settings -> Advanced -> Video to review." + "You have selected %{colorFormat} as Color Format. Formats other than NV12 and P010 are commonly used for recording, and might incur high CPU usage or the streaming platform might not support it. Go to Settings -> Advanced -> Video to review.": "You have selected %{colorFormat} as Color Format. Formats other than NV12 and P010 are commonly used for recording, and might incur high CPU usage or the streaming platform might not support it. Go to Settings -> Advanced -> Video to review.", + "Selective Recording Enabled": "Selective Recording Enabled", + "Selective Recording only works with horizontal sources and disables editing the vertical output scene. Please disable Selective Recording to go live with Dual Output.": "Selective Recording only works with horizontal sources and disables editing the vertical output scene. Please disable Selective Recording to go live with Dual Output.", + "Studio Mode Enabled": "Studio Mode Enabled", + "Cannot toggle Dual Output while in Studio Mode. Please disable Studio Mode to go live with Dual Output.": "Cannot toggle Dual Output while in Studio Mode. Please disable Studio Mode to go live with Dual Output.", + "Confirm Horizontal and Vertical Platforms": "Confirm Horizontal and Vertical Platforms", + "All platforms are currently assigned to the display. To use Dual Output you must stream to one horizontal and one vertical platform. Do you want to go live in single output mode with the Horizontal display?": "All platforms are currently assigned to the display. To use Dual Output you must stream to one horizontal and one vertical platform. Do you want to go live in single output mode with the Horizontal display?", + "Streamlabs has detected high CPU usage in Dual Output mode": "Streamlabs has detected high CPU usage in Dual Output mode", + "System resource overuse.": "System resource overuse.", + "To mitigate hide one of outputs or right click in editor to enable Performance Mode.": "To mitigate hide one of outputs or right click in editor to enable Performance Mode.", + "This problem could also be due to high CPU usage from other applications or unsuitable encoder settings.": "This problem could also be due to high CPU usage from other applications or unsuitable encoder settings.", + "When this happens, Streamlabs does not have any resources left over.": "When this happens, Streamlabs does not have any resources left over.", + "Hide one or both of the displays in Editor's Scene section": "Hide one or both of the displays in Editor's Scene section", + "Ensure that you don't have any other applications open that are heavy on your CPU": "Ensure that you don't have any other applications open that are heavy on your CPU", + "Enable Performance Mode": "Enable Performance Mode", + "High CPU Usage": "High CPU Usage", + "Detect CPU usage in Dual Output mode": "Detect CPU usage in Dual Output mode", + "High CPU Usage: %{percentage}% used": "High CPU Usage: %{percentage}% used", + "High CPU Usage: Detected": "High CPU Usage: Detected", + "TikTok Audience": "TikTok Audience", + "Share stream link": "Share stream link", + "Copy stream link": "Copy stream link", + "Copy %{platform} link": "Copy %{platform} link", + "Copied to clipboard": "Copied to clipboard" } diff --git a/app/i18n/en-US/tiktok.json b/app/i18n/en-US/tiktok.json index f083b8296561..2f2571e6d15c 100644 --- a/app/i18n/en-US/tiktok.json +++ b/app/i18n/en-US/tiktok.json @@ -22,5 +22,9 @@ "TikTok Live Access not granted. Click here to learn more.": "TikTok Live Access not granted. Click here to learn more.", "Only 32 characters of your title will display on TikTok": "Only 32 characters of your title will display on TikTok", "You may be eligible for TikTok Live Access. Apply here.": "You may be eligible for TikTok Live Access. Apply here.", - "Click to view TikTok Replay in your browser.": "Click to view TikTok Replay in your browser." + "Click to view TikTok Replay in your browser.": "Click to view TikTok Replay in your browser.", + "TikTok Stream Error": "TikTok Stream Error", + "Couldn't confirm TikTok Live Access. Apply for Live Permissions below": "Couldn't confirm TikTok Live Access. Apply for Live Permissions below", + "Connect your TikTok account": "Connect your TikTok account", + "Connect your TikTok account to stream to TikTok and one other platform for free. Haven't applied to stream on TikTok Live yet? Start the process here.": "Connect your TikTok account to stream to TikTok and one other platform for free. Haven't applied to stream on TikTok Live yet? Start the process here." } diff --git a/app/i18n/en-US/troubleshooter.json b/app/i18n/en-US/troubleshooter.json index 58b395907ea2..d8bcdacff9d5 100644 --- a/app/i18n/en-US/troubleshooter.json +++ b/app/i18n/en-US/troubleshooter.json @@ -22,5 +22,6 @@ "Enable VSync in your game": "Enable VSync in your game", "Disable FreeSync or GSync in your Driver": "Disable FreeSync or GSync in your Driver", "Lower graphics settings until you stop lagging frames": "Lower graphics settings until you stop lagging frames", - "Disable hardware decoding under any media sources(This will slightly increase cpu over gpu)": "Disable hardware decoding under any media sources(This will slightly increase cpu over gpu)" + "Disable hardware decoding under any media sources(This will slightly increase cpu over gpu)": "Disable hardware decoding under any media sources(This will slightly increase cpu over gpu)", + "CPU usage threshold in Dual Output mode": "CPU usage threshold in Dual Output mode" } diff --git a/app/i18n/en-US/widget-chat-box.json b/app/i18n/en-US/widget-chat-box.json index bfd7e88b9118..68fa05e5b7e2 100644 --- a/app/i18n/en-US/widget-chat-box.json +++ b/app/i18n/en-US/widget-chat-box.json @@ -26,5 +26,7 @@ "Disable Message Animations": "Disable Message Animations", "Hide Characters": "Hide Characters", "Hide Common Chat Bots": "Hide Common Chat Bots", - "Hide commands starting with `!`": "Hide commands starting with `!`" + "Hide commands starting with `!`": "Hide commands starting with `!`", + "Chat Notifications": "Chat Notifications", + "Trigger a sound to notify you when there is new chat activity": "Trigger a sound to notify you when there is new chat activity" } diff --git a/app/i18n/en-US/youtube.json b/app/i18n/en-US/youtube.json index 90d83682a765..943f07fa39c4 100644 --- a/app/i18n/en-US/youtube.json +++ b/app/i18n/en-US/youtube.json @@ -39,6 +39,5 @@ "DVR controls enable the viewer to control the video playback experience by pausing, rewinding, or fast forwarding content": "DVR controls enable the viewer to control the video playback experience by pausing, rewinding, or fast forwarding content", "Auto-start is disabled for your broadcast. You should manually publish your stream from Youtube Studio": "Auto-start is disabled for your broadcast. You should manually publish your stream from Youtube Studio", "Made for kids": "Made for kids", - "Features like personalized ads and live chat won't be available on live streams made for kids.": "Features like personalized ads and live chat won't be available on live streams made for kids.", - "Try our new thumbnail editor": "Try our new thumbnail editor" + "Features like personalized ads and live chat won't be available on live streams made for kids.": "Features like personalized ads and live chat won't be available on live streams made for kids." } diff --git a/app/i18n/fallback.ts b/app/i18n/fallback.ts index 221fcff56146..236c040629b8 100644 --- a/app/i18n/fallback.ts +++ b/app/i18n/fallback.ts @@ -65,6 +65,7 @@ const fallbackDictionary = { ...require('./en-US/widget-game.json'), ...require('./en-US/loader.json'), ...require('./en-US/guest-cam.json'), + ...require('./en-US/kick.json'), }; export default fallbackDictionary; diff --git a/app/services/announcements.ts b/app/services/announcements.ts index 96d85550781f..98761fd9030c 100644 --- a/app/services/announcements.ts +++ b/app/services/announcements.ts @@ -258,6 +258,21 @@ export class AnnouncementsService extends Service { } } + async closeNews(newsId: number) { + const endpoint = 'api/v5/slobs/announcement/close'; + const req = this.formRequest(endpoint, { + method: 'POST', + headers: new Headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + clientId: this.userService.getLocalUserId(), + announcementId: newsId, + clickType: 'action', + }), + }); + + return jfetch(req); + } + async closeBanner(clickType: 'action' | 'dismissal') { const endpoint = 'api/v5/slobs/announcement/close'; const req = this.formRequest(endpoint, { diff --git a/app/services/api/external-api/notifications/notifications.ts b/app/services/api/external-api/notifications/notifications.ts index b95d86a82f3b..83dcad92f9f0 100644 --- a/app/services/api/external-api/notifications/notifications.ts +++ b/app/services/api/external-api/notifications/notifications.ts @@ -22,6 +22,7 @@ enum ENotificationSubType { LAGGED = 'LAGGED', SKIPPED = 'SKIPPED', NEWS = 'NEWS', + CPU = 'CPU', } /** diff --git a/app/services/api/external-api/scene-collections/scene-collections.ts b/app/services/api/external-api/scene-collections/scene-collections.ts index 0265475a374e..5c509f0ab0a2 100644 --- a/app/services/api/external-api/scene-collections/scene-collections.ts +++ b/app/services/api/external-api/scene-collections/scene-collections.ts @@ -67,6 +67,16 @@ export class SceneCollectionsService { }; } + /** + * Gets whether or not this is a new user's first login. This is set + * on load when the app attempts to sync scene collections with the + * cloud for the first time and signifies that no scene collections + * were found. + */ + get newUserFirstLogin(): boolean { + return this.sceneCollectionsService.newUserFirstLogin; + } + /** * Provides the scene collection's schema including all scenes, scene nodes * and sources. This operation is expensive and should be avoided if possible. diff --git a/app/services/api/remote-control-api.ts b/app/services/api/remote-control-api.ts new file mode 100644 index 000000000000..e797053a0a7b --- /dev/null +++ b/app/services/api/remote-control-api.ts @@ -0,0 +1,267 @@ +import os from 'os'; +import { authorizedHeaders, jfetch } from 'util/requests'; +import { importSocketIOClient } from 'util/slow-imports'; +import { ObjectSchema } from 'realm'; +import { InitAfter, Inject, Service } from 'services/core'; +import { RealmObject } from 'services/realm'; +import { ExternalApiService, HostsService, UserService } from 'app-services'; +import { + JsonrpcService, + E_JSON_RPC_ERROR, + IJsonRpcRequest, + IJsonRpcResponse, + IJsonRpcEvent, +} from 'services/api/jsonrpc/index'; + +export interface IConnectedDevice { + socketId: string; + deviceName: string; + clientType: string; +} + +interface ISLRemoteResponse { + success: boolean; + message: 'OK'; + data: { + url: string; + token: string; + }; +} + +class ConnectedDevice extends RealmObject { + socketId: string; + deviceName: string; + clientType: string; + + static schema: ObjectSchema = { + name: 'ConnectedDevice', + embedded: true, + properties: { + socketId: 'string', + deviceName: 'string', + clientType: 'string', + }, + }; +} + +ConnectedDevice.register(); + +class RemoteControlEphemeralState extends RealmObject { + devices: IConnectedDevice[]; + + static schema: ObjectSchema = { + name: 'RemoteControlEphemeralState', + properties: { + devices: { + type: 'list', + objectType: 'ConnectedDevice', + default: [] as ConnectedDevice[], + }, + }, + }; +} + +RemoteControlEphemeralState.register(); + +class RemoteControlPresistentState extends RealmObject { + enabled: boolean; + + static schema: ObjectSchema = { + name: 'RemoteControlPersistentState', + properties: { + enabled: { type: 'bool', default: false }, + }, + }; +} + +RemoteControlPresistentState.register({ persist: true }); + +@InitAfter('UserService') +export class RemoteControlService extends Service { + @Inject() hostsService: HostsService; + @Inject() userService: UserService; + @Inject() externalApiService: ExternalApiService; + @Inject() jsonRpcService: JsonrpcService; + + state = RemoteControlPresistentState.inject(); + connectedDevices = RemoteControlEphemeralState.inject(); + + socket: SocketIOClient.Socket; + + init() { + super.init(); + this.userService.userLogin.subscribe(() => { + if (this.state.enabled) this.createStreamlabsRemoteConnection(); + }); + this.externalApiService.serviceEvent.subscribe(event => { + this.sendMessage(event); + }); + } + + disconnect() { + this.setEnableRemoteConnection(false); + this.socket.disconnect(); + this.socket = undefined; + this.setConnectedDevices([]); + } + + disconnectDevice(socketId: string) { + if (this.socket) { + this.socket.emit('disconnectDevice', { socketId }, (response: any) => { + if (!response.error) { + this.removeConnectedDevice(socketId); + } + }); + } + } + + async createStreamlabsRemoteConnection() { + if (!this.userService.isLoggedIn) return; + this.setEnableRemoteConnection(true); + const io = await importSocketIOClient(); + const url = `https://${ + this.hostsService.streamlabs + }/api/v5/slobs/modules/mobile-remote-io/config?device_name=${os.hostname()}`; + const headers = authorizedHeaders(this.userService.apiToken); + + const resp: ISLRemoteResponse = await jfetch(new Request(url, { headers })); + if (resp.success) { + const socket = io.default(`${resp.data.url}?token=${resp.data.token}`, { + transports: ['websocket'], + reconnection: false, + }); + + socket.emit('getDevices', {}, (devices: IConnectedDevice[]) => { + this.setConnectedDevices(devices); + }); + + this.socket = socket; + this.listen(); + } + } + + listen() { + if (this.socket) { + this.socket.on('message', (data: Buffer, callback: Function) => { + const response = this.requestHandler(data.toString()); + callback(this.formatEvent(response)); + }); + + this.socket.on('deviceConnected', (device: IConnectedDevice) => { + const devices = this.connectedDevices.devices; + if (devices.find(d => d.socketId === device.socketId)) return; + this.setConnectedDevices(devices.concat([device])); + }); + + this.socket.on('deviceDisconnected', (device: IConnectedDevice) => { + this.removeConnectedDevice(device.socketId); + }); + + this.socket.on('error', (e: unknown) => { + throw e; + }); + + this.socket.on('disconnect', (reason: string) => { + if (reason !== 'io client disconnect') { + this.createStreamlabsRemoteConnection(); + } + }); + } + } + + sendMessage(event: IJsonRpcResponse) { + if (this.socket) { + try { + this.socket.emit('message', this.formatEvent(event), (response: any) => { + if (response.error) throw response.error; + }); + } catch (e: unknown) { + console.error('Unable to send message', e); + } + } + } + + private requestHandler(data: string) { + const requests = data.split('\n'); + + for (const requestString of requests) { + if (!requestString) return; + try { + const request: IJsonRpcRequest = JSON.parse(requestString); + + const errorMessage = this.validateRequest(request); + + if (errorMessage) { + const errorResponse = this.jsonRpcService.createError(request, { + code: E_JSON_RPC_ERROR.INVALID_PARAMS, + message: errorMessage, + }); + return errorResponse; + } + + // Prevent access to certain particularly sensitive services + const protectedResources = ['FileManagerService']; + + if (protectedResources.includes(request.params.resource)) { + const err = this.jsonRpcService.createError(request, { + code: E_JSON_RPC_ERROR.INTERNAL_JSON_RPC_ERROR, + message: 'The requested resource is not available.', + }); + return err; + } + + const response = this.externalApiService.executeServiceRequest(request); + + return response; + } catch (e: unknown) { + const errorResponse = this.jsonRpcService.createError(null, { + code: E_JSON_RPC_ERROR.INVALID_REQUEST, + message: + 'Make sure that the request is valid json. ' + + 'If request string contains multiple requests, ensure requests are separated ' + + 'by a single newline character LF ( ASCII code 10)', + }); + + // Disconnect and stop processing requests + // IMPORTANT: For security reasons it is important we immediately stop + // processing requests that don't look will well formed JSON RPC calls. + // Without this check, it is possible to send normal HTTP requests + // from an unprivileged web page and make calls to this API. + this.disconnect(); + return errorResponse; + } + } + } + + private formatEvent(event: IJsonRpcResponse) { + return `${JSON.stringify(event)}\n`; + } + + private validateRequest(request: IJsonRpcRequest): string { + let message = ''; + if (!request.id) message += ' id is required;'; + if (!request.params) message += ' params is required;'; + if (request.params && !request.params.resource) message += ' resource is required;'; + return message; + } + + setEnableRemoteConnection(val: boolean) { + this.state.db.write(() => { + this.state.enabled = val; + }); + } + + setConnectedDevices(devices: IConnectedDevice[]) { + this.connectedDevices.db.write(() => { + this.connectedDevices.devices = devices.filter(device => device.deviceName !== os.hostname()); + }); + } + + removeConnectedDevice(socketId: string) { + this.connectedDevices.db.write(() => { + this.connectedDevices.devices = this.connectedDevices.devices.filter( + d => d.socketId !== socketId, + ); + }); + } +} diff --git a/app/services/api/tcp-server/tcp-server.ts b/app/services/api/tcp-server/tcp-server.ts index 5544667efc03..d1f42deb03fe 100644 --- a/app/services/api/tcp-server/tcp-server.ts +++ b/app/services/api/tcp-server/tcp-server.ts @@ -13,7 +13,6 @@ import { import { IIPAddressDescription, ITcpServerServiceApi, ITcpServersSettings } from './tcp-server-api'; import { UsageStatisticsService } from 'services/usage-statistics'; import { ExternalApiService } from '../external-api'; -import { SceneCollectionsService } from 'services/scene-collections'; // eslint-disable-next-line no-undef import WritableStream = NodeJS.WritableStream; import { $t } from 'services/i18n'; @@ -136,6 +135,19 @@ export class TcpServerService this.listen(); } + disableWebsocketsRemoteConnections() { + this.stopListening(); + // update websockets settings + const defaultWebsoketsSettings = this.getDefaultSettings().websockets; + this.setSettings({ + websockets: { + ...defaultWebsoketsSettings, + }, + }); + + this.listen(); + } + getDefaultSettings(): ITcpServersSettings { return TcpServerService.defaultState; } diff --git a/app/services/diagnostics.ts b/app/services/diagnostics.ts index 9ee3bdefa8ac..f6ac105968bb 100644 --- a/app/services/diagnostics.ts +++ b/app/services/diagnostics.ts @@ -31,7 +31,7 @@ import * as remote from '@electron/remote'; import { AppService } from 'services/app'; import fs from 'fs'; import path from 'path'; -import { TPlatform } from './platforms'; +import { platformList, TPlatform } from './platforms'; import { TDisplayType } from './settings-v2'; interface IStreamDiagnosticInfo { @@ -329,11 +329,36 @@ export class DiagnosticsService extends PersistentStatefulService { + if (/^\d+$/.test(platform)) { + const index = parseInt(platform, 10); + return platformList[index]; + } + return platform; + }); + + return JSON.stringify(names).slice(1, -1); + } + private formatSimpleOutputInfo() { const settings = this.outputSettingsService.getSettings(); const values = this.settingsService.views.values.Output; @@ -458,7 +483,8 @@ export class DiagnosticsService extends PersistentStatefulService { + const platforms = this.validatePlatforms(s?.platforms); + + if ( + s?.type === 'Single Output' && + platforms.includes('tiktok') && + s?.error.split(' ').at(-1) === '422' + ) { + this.logProblem( + 'TikTok user might be blocked from streaming. Refer them to TikTok producer page or support to confirm live access status', + ); + } + return { 'Start Time': new Date(s.startTime).toString(), 'End Time': s.endTime ? new Date(s.endTime).toString() : 'Stream did not end cleanly', @@ -995,7 +1033,7 @@ export class DiagnosticsService extends PersistentStatefulService - ({ - [EOutputDisplayType.Horizontal]: $t('Horizontal'), - [EOutputDisplayType.Vertical]: $t('Vertical'), - }[display]); diff --git a/app/services/dual-output/dual-output.ts b/app/services/dual-output/dual-output.ts index 4ce13e217d10..7feb569f0238 100644 --- a/app/services/dual-output/dual-output.ts +++ b/app/services/dual-output/dual-output.ts @@ -1,12 +1,4 @@ import { PersistentStatefulService, InitAfter, Inject, ViewHandler, mutation } from 'services/core'; -import { - TDualOutputPlatformSettings, - DualOutputPlatformSettings, - IDualOutputDestinationSetting, - TDisplayPlatforms, - IDualOutputPlatformSetting, - TDisplayDestinations, -} from './dual-output-data'; import { verticalDisplayData } from '../settings-v2/default-settings-data'; import { ScenesService, SceneItem, TSceneNode } from 'services/scenes'; import { TDisplayType, VideoSettingsService } from 'services/settings-v2/video'; @@ -25,10 +17,14 @@ import { UserService } from 'services/user'; import { SelectionService, Selection } from 'services/selection'; import { StreamingService } from 'services/streaming'; import { SettingsService } from 'services/settings'; +import { SourcesService, TSourceType } from 'services/sources'; +import { WidgetsService, WidgetType } from 'services/widgets'; import { RunInLoadingMode } from 'services/app/app-decorators'; import compact from 'lodash/compact'; import invert from 'lodash/invert'; import forEachRight from 'lodash/forEachRight'; +import { byOS, OS } from 'util/operating-systems'; +import { DefaultHardwareService } from 'services/hardware/default-hardware'; interface IDisplayVideoSettings { horizontal: IVideoInfo; @@ -39,13 +35,24 @@ interface IDisplayVideoSettings { }; } interface IDualOutputServiceState { - platformSettings: TDualOutputPlatformSettings; - destinationSettings: Dictionary; dualOutputMode: boolean; videoSettings: IDisplayVideoSettings; isLoading: boolean; } +enum EOutputDisplayType { + Horizontal = 'horizontal', + Vertical = 'vertical', +} + +export type TDisplayPlatforms = { + [Display in EOutputDisplayType]: TPlatform[]; +}; + +export type TDisplayDestinations = { + [Display in EOutputDisplayType]: string[]; +}; + class DualOutputViews extends ViewHandler { @Inject() private scenesService: ScenesService; @Inject() private videoSettingsService: VideoSettingsService; @@ -104,24 +111,8 @@ class DualOutputViews extends ViewHandler { return Object.entries(nodeMaps).length > 0; } - get platformSettings() { - return this.state.platformSettings; - } - - get destinationSettings() { - return this.state.destinationSettings; - } - getEnabledTargets(destinationId: 'name' | 'url' = 'url') { - const platforms = Object.entries(this.platformSettings).reduce( - (displayPlatforms: TDisplayPlatforms, [key, val]: [string, IDualOutputPlatformSetting]) => { - if (val && this.streamingService.views.enabledPlatforms.includes(val.platform)) { - displayPlatforms[val.display].push(val.platform); - } - return displayPlatforms; - }, - { horizontal: [], vertical: [] }, - ); + const platforms = this.streamingService.views.activeDisplayPlatforms; /** * Returns the enabled destinations according to their assigned display @@ -176,7 +167,7 @@ class DualOutputViews extends ViewHandler { } getPlatformDisplay(platform: TPlatform) { - return this.state.platformSettings[platform].display; + return this.streamingService.views.settings.platforms[platform]?.display; } getPlatformContext(platform: TPlatform) { @@ -274,18 +265,6 @@ class DualOutputViews extends ViewHandler { return this.scenesService.views.getNodeVisibility(id, sceneId ?? this.activeSceneId); } - getCanStreamDualOutput() { - const platformDisplays = this.streamingService.views.activeDisplayPlatforms; - const destinationDisplays = this.streamingService.views.activeDisplayDestinations; - - const horizontalHasDestinations = - platformDisplays.horizontal.length > 0 || destinationDisplays.horizontal.length > 0; - const verticalHasDestinations = - platformDisplays.vertical.length > 0 || destinationDisplays.vertical.length > 0; - - return horizontalHasDestinations && verticalHasDestinations; - } - /** * Confirm if a scene has a node map for dual output. * @remark If the scene collection does not have the scene node maps property in the @@ -314,8 +293,6 @@ export class DualOutputService extends PersistentStatefulService(); collectionHandled = new Subject<{ [sceneId: string]: Dictionary } | null>(); + dualOutputModeChanged = new Subject(); get views() { return new DualOutputViews(this.state); @@ -392,14 +370,20 @@ export class DualOutputService extends PersistentStatefulService { @@ -606,7 +598,7 @@ export class DualOutputService extends PersistentStatefulService { this.dismissablesService.dismiss(EDismissable.CollabCamRollout); } else if ( this.incrementalRolloutService.views.featureIsEnabled( - EAvailableFeatures.guestCaProduction, + EAvailableFeatures.guestCamProduction, ) && this.dismissablesService.views.shouldShow(EDismissable.CollabCamRollout) ) { diff --git a/app/services/highlighter/ai-highlighter/ai-highlighter.ts b/app/services/highlighter/ai-highlighter/ai-highlighter.ts new file mode 100644 index 000000000000..467fb919c3ff --- /dev/null +++ b/app/services/highlighter/ai-highlighter/ai-highlighter.ts @@ -0,0 +1,267 @@ +import * as child from 'child_process'; +import EventEmitter from 'events'; +import { AiHighlighterUpdater } from './updater'; +import { duration } from 'moment'; +import { ICoordinates } from '..'; +import kill from 'tree-kill'; + +export enum EHighlighterInputTypes { + KILL = 'kill', + KNOCKED = 'knocked', + GAME_SEQUENCE = 'game_sequence', + GAME_START = 'start_game', + GAME_END = 'end_game', + VOICE_ACTIVITY = 'voice_activity', + DEATH = 'death', + VICTORY = 'victory', + DEPLOY = 'deploy', + META_DURATION = 'meta_duration', + LOW_HEALTH = 'low_health', + PLAYER_KNOCKED = 'player_knocked', +} +export type DeathMetadata = { + place: number; +}; +export interface IHighlighterInput { + start_time: number; + end_time?: number; + type: EHighlighterInputTypes; + origin: string; + metadata?: DeathMetadata | any; +} +export interface IHighlight { + start_time: number; + end_time: number; + input_types: EHighlighterInputTypes[]; + inputs: IHighlighterInput[]; + score: number; + metadata: { round: number; webcam_coordinates: ICoordinates }; +} + +export type EHighlighterMessageTypes = + | 'progress' + | 'inputs' + | 'inputs_partial' + | 'highlights' + | 'milestone'; + +export interface IHighlighterMessage { + type: EHighlighterMessageTypes; + json: {}; +} +interface IHighlighterProgressMessage { + progress: number; +} + +export interface IHighlighterMilestone { + name: string; + weight: number; + data: IHighlighterMessage[] | null; +} + +const START_TOKEN = '>>>>'; +const END_TOKEN = '<<<<'; + +// Buffer management class to handle split messages +class MessageBufferHandler { + private buffer: string = ''; + + hasCompleteMessage(): boolean { + const hasStart = this.buffer.includes(START_TOKEN); + const hasEnd = this.buffer.includes(END_TOKEN); + return hasStart && hasEnd; + } + + isMessageComplete(message: string): boolean { + const combined = this.buffer + message; + const hasStart = combined.includes(START_TOKEN); + const hasEnd = combined.includes(END_TOKEN); + return hasStart && hasEnd; + } + + appendToBuffer(message: string) { + this.buffer += message; + } + + extractCompleteMessages(): string[] { + const messages = []; + while (this.hasCompleteMessage()) { + const start = this.buffer.indexOf(START_TOKEN); + const end = this.buffer.indexOf(END_TOKEN); + + if (start !== -1 && end !== -1 && start < end) { + const completeMessage = this.buffer.substring(start, end + END_TOKEN.length); + // Clear the buffer of the extracted message + this.buffer = this.buffer.substring(end + END_TOKEN.length); + messages.push(completeMessage); + } else { + // Message not complete + } + } + return messages; + } + + clear() { + this.buffer = ''; + } +} + +export function getHighlightClips( + videoUri: string, + renderHighlights: (highlightClips: IHighlight[]) => void, + cancelSignal: AbortSignal, + progressUpdate?: (progress: number) => void, + milestonesPath?: string, + milestoneUpdate?: (milestone: IHighlighterMilestone) => void, +): Promise { + return new Promise((resolve, reject) => { + console.log(`Get highlight clips for ${videoUri}`); + + const partialInputsRendered = false; + console.log('Start Ai analysis'); + + const childProcess: child.ChildProcess = AiHighlighterUpdater.startHighlighterProcess( + videoUri, + milestonesPath, + ); + const messageBuffer = new MessageBufferHandler(); + + if (cancelSignal) { + cancelSignal.addEventListener('abort', () => { + console.log('ending highlighter process'); + messageBuffer.clear(); + kill(childProcess.pid!, 'SIGINT'); + reject(new Error('Highlight generation canceled')); + }); + } + + childProcess.stdout?.on('data', (data: Buffer) => { + const message = data.toString(); + messageBuffer.appendToBuffer(message); + + // Try to extract a complete message + const completeMessages = messageBuffer.extractCompleteMessages(); + + for (const completeMessage of completeMessages) { + // messageBuffer.clear(); + const aiHighlighterMessage = parseAiHighlighterMessage(completeMessage); + if (typeof aiHighlighterMessage === 'string' || aiHighlighterMessage instanceof String) { + console.log('message type of string', aiHighlighterMessage); + } else if (aiHighlighterMessage) { + switch (aiHighlighterMessage.type) { + case 'progress': + progressUpdate?.((aiHighlighterMessage.json as IHighlighterProgressMessage).progress); + break; + case 'highlights': + if (!partialInputsRendered) { + console.log('call Render highlights:'); + renderHighlights?.(aiHighlighterMessage.json as IHighlight[]); + } + resolve(aiHighlighterMessage.json as IHighlight[]); + break; + case 'milestone': + milestoneUpdate?.(aiHighlighterMessage.json as IHighlighterMilestone); + break; + default: + // console.log('\n\n'); + // console.log('Unrecognized message type:', aiHighlighterMessage); + // console.log('\n\n'); + break; + } + } + } + }); + + childProcess.stderr?.on('data', (data: Buffer) => { + console.log('Debug logs:', data.toString()); + }); + + childProcess.on('error', error => { + messageBuffer.clear(); + reject(new Error(`Child process threw an error. Error message: ${error.message}.`)); + }); + + childProcess.on('exit', (code, signal) => { + messageBuffer.clear(); + reject(new Error(`Child process exited with code ${code} and signal ${signal}`)); + }); + }); +} + +function parseAiHighlighterMessage(messageString: string): IHighlighterMessage | string | null { + try { + if (messageString.includes(START_TOKEN) && messageString.includes(END_TOKEN)) { + const start = messageString.indexOf(START_TOKEN); + const end = messageString.indexOf(END_TOKEN); + const jsonString = messageString.substring(start, end).replace(START_TOKEN, ''); + // console.log('Json string:', jsonString); + + const aiHighlighterMessage = JSON.parse(jsonString) as IHighlighterMessage; + // console.log('Parsed ai highlighter message:', aiHighlighterMessage); + return aiHighlighterMessage; + } else { + return messageString; + } + } catch (error: unknown) { + console.log('Error parsing ai highlighter message:', error); + return null; + } +} + +export class ProgressTracker { + PRE_DURATION = 10; + POST_DURATION = 10; + progress = 0; + + onChangeCallback: (progress: number) => void; + + preInterval: NodeJS.Timeout; + postInterval: NodeJS.Timeout; + postStarted = false; + constructor(onChange = (progress: number) => {}) { + this.startPreTimer(); + this.onChangeCallback = onChange; + } + + startPreTimer() { + this.progress = 0; + this.preInterval = this.addOnePerSecond(this.PRE_DURATION); + } + + startPostTimer() { + if (!this.postStarted) { + this.postInterval = this.addOnePerSecond(this.POST_DURATION); + this.postStarted = true; + } + } + destroy() { + this.preInterval && clearInterval(this.preInterval); + this.postInterval && clearInterval(this.postInterval); + } + + updateProgressFromHighlighter(highlighterProgress: number) { + this.preInterval && clearInterval(this.preInterval); + const adjustedProgress = + highlighterProgress * ((100 - this.PRE_DURATION - this.POST_DURATION) / 100) + + this.PRE_DURATION; + + this.progress = adjustedProgress; + this.onChangeCallback(this.progress); + if (highlighterProgress === 100) { + this.startPostTimer(); + } + } + + addOnePerSecond(duration: number) { + let passedSeconds = 0; + const interval = setInterval(() => { + passedSeconds += 1; + this.progress += 1; + this.onChangeCallback(this.progress); + if (passedSeconds >= duration) { + clearInterval(interval); + } + }, 1000); + return interval; + } +} diff --git a/app/services/highlighter/ai-highlighter/updater.ts b/app/services/highlighter/ai-highlighter/updater.ts new file mode 100644 index 000000000000..d23048e08adc --- /dev/null +++ b/app/services/highlighter/ai-highlighter/updater.ts @@ -0,0 +1,260 @@ +import { promises as fs, createReadStream, existsSync } from 'fs'; +import path from 'path'; +import { getSharedResource } from 'util/get-shared-resource'; +import { downloadFile, IDownloadProgress, jfetch } from 'util/requests'; +import crypto from 'crypto'; +import { pipeline } from 'stream/promises'; +import { importExtractZip } from 'util/slow-imports'; +import { spawn } from 'child_process'; +import { FFMPEG_EXE } from '../constants'; +import Utils from '../../utils'; +import * as remote from '@electron/remote'; + +interface IAIHighlighterManifest { + version: string; + platform: string; + url: string; + size: number; + checksum: string; + timestamp: number; +} + +/** + * Checks for updates to the AI Highlighter and updates the local installation + * if necessary. + * + * Responsible for storing the manifest and updating the highlighter binary, and maintains + * the paths to the highlighter binary and manifest. + */ +export class AiHighlighterUpdater { + private basepath: string; + private manifestPath: string; + private manifest: IAIHighlighterManifest | null; + private isCurrentlyUpdating: boolean = false; + private versionChecked: boolean = false; + + public currentUpdate: Promise | null = null; + + constructor() { + this.basepath = path.join(remote.app.getPath('userData'), 'ai-highlighter'); + this.manifestPath = path.resolve(this.basepath, 'manifest.json'); + } + + /** + * Spawn the AI Highlighter process that would process the video + */ + static startHighlighterProcess(videoUri: string, milestonesPath?: string) { + const runHighlighterFromRepository = Utils.getHighlighterEnvironment() === 'local'; + + if (runHighlighterFromRepository) { + // this is for highlighter development + // to run this you have to install the highlighter repository next to desktop + return AiHighlighterUpdater.startHighlighterFromRepository(videoUri, milestonesPath); + } + + const highlighterBinaryPath = path.resolve( + path.join(remote.app.getPath('userData'), 'ai-highlighter'), + 'bin', + 'app.exe', + ); + + const command = [videoUri, '--ffmpeg_path', FFMPEG_EXE]; + if (milestonesPath) { + command.push('--milestones_file'); + command.push(milestonesPath); + } + + return spawn(highlighterBinaryPath, command); + } + + private static startHighlighterFromRepository(videoUri: string, milestonesPath: string) { + const rootPath = '../highlighter-api/'; + const command = [ + 'run', + 'python', + `${rootPath}/highlighter_api/cli.py`, + videoUri, + '--ffmpeg_path', + FFMPEG_EXE, + '--loglevel', + 'debug', + ]; + + if (milestonesPath) { + command.push('--milestones_file'); + command.push(milestonesPath); + } + + return spawn('poetry', command, { + cwd: rootPath, + }); + } + + /** + * Check if an update is currently in progress + */ + public get updateInProgress(): boolean { + return this.isCurrentlyUpdating; + } + + /** + * Get version that is about to be installed + */ + public get version(): string | null { + return this.manifest?.version || null; + } + + /* + * Get the path to the highlighter binary + */ + private getManifestUrl(): string { + if (Utils.getHighlighterEnvironment() === 'staging') { + const cacheBuster = Math.floor(Date.now() / 1000); + return `https://cdn-highlighter-builds.streamlabs.com/staging/manifest_win_x86_64.json?t=${cacheBuster}`; + } else { + return 'https://cdn-highlighter-builds.streamlabs.com/production/manifest_win_x86_64.json'; + } + } + /** + * Check if AI Highlighter requires an update + */ + public async isNewVersionAvailable(): Promise { + // check if updater checked version in current session already + if (this.versionChecked) { + return false; + } + + this.versionChecked = true; + console.log('checking for highlighter updates...'); + const manifestUrl = this.getManifestUrl(); + // fetch the latest version of the manifest for win x86_64 target + const newManifest = await jfetch(new Request(manifestUrl)); + this.manifest = newManifest; + + // if manifest.json does not exist, an initial download is required + if (!existsSync(this.manifestPath)) { + console.log('manifest.json not found, initial download required'); + return true; + } + + // read the current manifest + const currentManifest = JSON.parse( + await fs.readFile(this.manifestPath, 'utf-8'), + ) as IAIHighlighterManifest; + + if ( + newManifest.version !== currentManifest.version || + newManifest.timestamp > currentManifest.timestamp + ) { + console.log( + `new highlighter version available. ${currentManifest.version} -> ${newManifest.version}`, + ); + return true; + } + + console.log('highlighter is up to date'); + return false; + } + + /** + * Update highlighter to the latest version + */ + public async update(progressCallback?: (progress: IDownloadProgress) => void): Promise { + // if (Utils.isDevMode()) { + // console.log('skipping update in dev mode'); + // return; + // } + try { + this.isCurrentlyUpdating = true; + this.currentUpdate = this.performUpdate(progressCallback); + await this.currentUpdate; + } finally { + this.isCurrentlyUpdating = false; + } + } + + private async performUpdate(progressCallback: (progress: IDownloadProgress) => void) { + if (!this.manifest) { + throw new Error('Manifest not found, cannot update'); + } + + if (!existsSync(this.basepath)) { + await fs.mkdir(this.basepath); + } + + const zipPath = path.resolve(this.basepath, 'ai-highlighter.zip'); + console.log('downloading new version of AI Highlighter...'); + + // in case if some leftover zip file exists for incomplete update + if (existsSync(zipPath)) { + await fs.rm(zipPath); + } + + // download the new version + await downloadFile(this.manifest.url, zipPath, progressCallback); + console.log('download complete'); + + // verify the checksum + const checksum = await this.sha256(zipPath); + if (checksum !== this.manifest.checksum) { + throw new Error('Checksum verification failed'); + } + + console.log('unzipping archive...'); + const unzipPath = path.resolve(this.basepath, 'bin-' + this.manifest.version); + // delete leftover unzipped files in case something happened before + if (existsSync(unzipPath)) { + await fs.rm(unzipPath, { recursive: true }); + } + + // unzip archive and delete the zip after + await this.unzip(zipPath, unzipPath); + await fs.rm(zipPath); + console.log('unzip complete'); + + // swap with the new version + const binPath = path.resolve(this.basepath, 'bin'); + const outdateVersionPresent = existsSync(binPath); + + // backup the ouotdated version in case something goes bad + if (outdateVersionPresent) { + console.log('backing up outdated version...'); + await fs.rename(binPath, path.resolve(this.basepath, 'bin.bkp')); + } + console.log('swapping new version...'); + await fs.rename(unzipPath, binPath); + + // cleanup + console.log('cleaning up...'); + if (outdateVersionPresent) { + await fs.rm(path.resolve(this.basepath, 'bin.bkp'), { recursive: true }); + } + + console.log('updating manifest...'); + await fs.writeFile(this.manifestPath, JSON.stringify(this.manifest)); + console.log('update complete'); + } + + private async sha256(file: string): Promise { + const hash = crypto.createHash('sha256'); + const stream = createReadStream(file); + + await pipeline(stream, hash); + + return hash.digest('hex'); + } + + private async unzip(zipPath: string, unzipPath: string): Promise { + // extract the new version + const extractZip = (await importExtractZip()).default; + return new Promise((resolve, reject) => { + extractZip(zipPath, { dir: unzipPath }, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } +} diff --git a/app/services/highlighter/audio-crossfader.ts b/app/services/highlighter/audio-crossfader.ts index 5943b8689e21..e9fb705a67be 100644 --- a/app/services/highlighter/audio-crossfader.ts +++ b/app/services/highlighter/audio-crossfader.ts @@ -1,13 +1,13 @@ import execa from 'execa'; import fs from 'fs'; import { FFMPEG_EXE } from './constants'; -import { Clip } from './clip'; +import { RenderingClip } from './clip'; import { AudioMixError } from './errors'; export class AudioCrossfader { constructor( public readonly outputPath: string, - public readonly clips: Clip[], + public readonly clips: RenderingClip[], public readonly transitionDuration: number, ) {} diff --git a/app/services/highlighter/audio-mixer.ts b/app/services/highlighter/audio-mixer.ts index 220ab7718e98..10c3a02d46eb 100644 --- a/app/services/highlighter/audio-mixer.ts +++ b/app/services/highlighter/audio-mixer.ts @@ -19,13 +19,11 @@ export class AudioMixer { const args = [...inputArgs]; - const filterGraph = `amix=inputs=${this.inputs.length}:duration=first:weights=${this.inputs - .map(i => i.volume) - .join(' ')}`; + const inputMap = this.inputs.map((_, index) => `[${index}:a]`).join(''); - this.inputs.forEach((input, index) => { - args.push('-map', `${index}:a`); - }); + const filterGraph = `${inputMap}amix=inputs=${ + this.inputs.length + }:duration=first:weights=${this.inputs.map(i => i.volume).join(' ')}`; args.push('-filter_complex', filterGraph); diff --git a/app/services/highlighter/clip.ts b/app/services/highlighter/clip.ts index 17875f978a31..abc77a7f428b 100644 --- a/app/services/highlighter/clip.ts +++ b/app/services/highlighter/clip.ts @@ -1,11 +1,12 @@ import execa from 'execa'; import { FrameSource } from './frame-source'; import { AudioSource } from './audio-source'; -import { FFPROBE_EXE } from './constants'; +import { FFPROBE_EXE, SCRUB_SPRITE_DIRECTORY } from './constants'; import fs from 'fs'; import { IExportOptions } from '.'; +import path from 'path'; -export class Clip { +export class RenderingClip { frameSource: FrameSource; audioSource: AudioSource; @@ -44,7 +45,7 @@ export class Clip { * to start reading from the file again. */ async reset(options: IExportOptions) { - this.deleted = !(await this.fileExists()); + this.deleted = !(await this.fileExists(this.sourcePath)); if (this.deleted) return; if (!this.duration) await this.readDuration(); @@ -68,9 +69,9 @@ export class Clip { /** * Checks if the underlying file exists and is readable */ - private async fileExists() { + private async fileExists(file: string) { return new Promise(resolve => { - fs.access(this.sourcePath, fs.constants.R_OK, e => { + fs.access(file, fs.constants.R_OK, e => { if (e) { resolve(false); } else { @@ -83,7 +84,23 @@ export class Clip { private async doInit() { await this.reset({ fps: 30, width: 1280, height: 720, preset: 'ultrafast' }); if (this.deleted) return; - await this.frameSource.exportScrubbingSprite(); + if (this.frameSource) { + try { + const parsed = path.parse(this.sourcePath); + const scrubPath = path.join(SCRUB_SPRITE_DIRECTORY, `${parsed.name}-scrub.jpg`); + + const scrubFileExists = await this.fileExists(scrubPath); + if (scrubFileExists) { + this.frameSource.scrubJpg = scrubPath; + } else { + await this.frameSource.exportScrubbingSprite(scrubPath); + } + } catch (error: unknown) { + console.log('err', error); + } + } else { + console.log('No Framesource'); + } } private async readDuration() { diff --git a/app/services/highlighter/frame-source.ts b/app/services/highlighter/frame-source.ts index fe87973b02fa..adb576d29833 100644 --- a/app/services/highlighter/frame-source.ts +++ b/app/services/highlighter/frame-source.ts @@ -42,15 +42,19 @@ export class FrameSource { public readonly options: IExportOptions, ) {} - async exportScrubbingSprite() { - const parsed = path.parse(this.sourcePath); - this.scrubJpg = path.join(SCRUB_SPRITE_DIRECTORY, `${parsed.name}-scrub.jpg`); + async exportScrubbingSprite(path: string) { + this.scrubJpg = path; /* eslint-disable */ const args = [ - '-i', this.sourcePath, - '-vf', `scale=${SCRUB_WIDTH}:${SCRUB_HEIGHT},fps=${SCRUB_FRAMES / this.duration},tile=${SCRUB_FRAMES}x1`, - '-frames:v', '1', + '-i', + this.sourcePath, + '-vf', + `scale=${SCRUB_WIDTH}:${SCRUB_HEIGHT},fps=${ + SCRUB_FRAMES / this.duration + },tile=${SCRUB_FRAMES}x1`, + '-frames:v', + '1', '-y', this.scrubJpg, ]; @@ -62,15 +66,23 @@ export class FrameSource { private startFfmpeg() { /* eslint-disable */ const args = [ - '-ss', this.startTrim.toString(), - '-i', this.sourcePath, - '-t', (this.duration - this.startTrim - this.endTrim).toString(), - '-vf', `fps=${this.options.fps},scale=${this.options.width}:${this.options.height}`, - '-map', 'v:0', - '-vcodec', 'rawvideo', - '-pix_fmt', 'rgba', - '-f', 'image2pipe', - '-' + '-ss', + this.startTrim.toString(), + '-i', + this.sourcePath, + '-t', + (this.duration - this.startTrim - this.endTrim).toString(), + '-vf', + `fps=${this.options.fps},scale=${this.options.width}:${this.options.height}`, + '-map', + 'v:0', + '-vcodec', + 'rawvideo', + '-pix_fmt', + 'rgba', + '-f', + 'image2pipe', + '-', ]; /* eslint-enable */ diff --git a/app/services/highlighter/frame-writer.ts b/app/services/highlighter/frame-writer.ts index f540c4fc3292..0f62b2da13e3 100644 --- a/app/services/highlighter/frame-writer.ts +++ b/app/services/highlighter/frame-writer.ts @@ -19,39 +19,65 @@ export class FrameWriter { /* eslint-disable */ const args = [ // Video Input - '-f', 'rawvideo', - '-vcodec', 'rawvideo', - '-pix_fmt', 'rgba', - '-s', `${this.options.width}x${this.options.height}`, - '-r', `${this.options.fps}`, - '-i', '-', + '-f', + 'rawvideo', + '-vcodec', + 'rawvideo', + '-pix_fmt', + 'rgba', + '-s', + `${this.options.width}x${this.options.height}`, + '-r', + `${this.options.fps}`, + '-i', + '-', // Audio Input - '-i', this.audioInput, + '-i', + this.audioInput, // Input Mapping - '-map', '0:v:0', - '-map', '1:a:0', + '-map', + '0:v:0', + '-map', + '1:a:0', // Filters - '-af', `afade=type=out:duration=${FADE_OUT_DURATION}:start_time=${Math.max(this.duration - (FADE_OUT_DURATION + 0.2), 0)}`, - '-vf', `format=yuv420p,fade=type=out:duration=${FADE_OUT_DURATION}:start_time=${Math.max(this.duration - (FADE_OUT_DURATION + 0.2), 0)}`, - - // Video Output - '-vcodec', 'libx264', - '-profile:v', 'high', - '-preset:v', this.options.preset, - '-crf', '18', - '-movflags', 'faststart', + '-af', + `afade=type=out:duration=${FADE_OUT_DURATION}:start_time=${Math.max( + this.duration - (FADE_OUT_DURATION + 0.2), + 0, + )}`, + ]; - // Audio Output - '-acodec', 'aac', - '-b:a', '128k', + this.addVideoFilters(args); + + args.push( + ...[ + // Video Output + '-vcodec', + 'libx264', + '-profile:v', + 'high', + '-preset:v', + this.options.preset, + '-crf', + '18', + '-movflags', + 'faststart', + + // Audio Output + '-acodec', + 'aac', + '-b:a', + '128k', + + '-y', + this.outputPath, + ], + ); - '-y', this.outputPath, - ]; /* eslint-enable */ - this.ffmpeg = execa(FFMPEG_EXE, args, { encoding: null, buffer: false, @@ -76,6 +102,18 @@ export class FrameWriter { }); } + private addVideoFilters(args: string[]) { + const fadeFilter = `format=yuv420p,fade=type=out:duration=${FADE_OUT_DURATION}:start_time=${Math.max( + this.duration - (FADE_OUT_DURATION + 0.2), + 0, + )}`; + if (this.options.complexFilter) { + args.push('-vf', this.options.complexFilter + `[final]${fadeFilter}`); + } else { + args.push('-vf', fadeFilter); + } + } + async writeNextFrame(frameBuffer: Buffer) { if (!this.ffmpeg) this.startFfmpeg(); diff --git a/app/services/highlighter/index.ts b/app/services/highlighter/index.ts index 3c09c16ba89a..7b8f337bc76a 100644 --- a/app/services/highlighter/index.ts +++ b/app/services/highlighter/index.ts @@ -1,4 +1,11 @@ -import { mutation, StatefulService, ViewHandler, Inject, InitAfter, Service } from 'services/core'; +import { + mutation, + ViewHandler, + Inject, + InitAfter, + Service, + PersistentStatefulService, +} from 'services/core'; import path from 'path'; import Vue from 'vue'; import fs from 'fs-extra'; @@ -13,9 +20,16 @@ import { } from 'services/platforms/youtube/uploader'; import { YoutubeService } from 'services/platforms/youtube'; import os from 'os'; -import { CLIP_DIR, SCRUB_SPRITE_DIRECTORY, SUPPORTED_FILE_TYPES, TEST_MODE } from './constants'; +import { + CLIP_DIR, + FFMPEG_EXE, + SCRUB_SPRITE_DIRECTORY, + SUPPORTED_FILE_TYPES, + TEST_MODE, + FFPROBE_EXE, +} from './constants'; import { pmap } from 'util/pmap'; -import { Clip } from './clip'; +import { RenderingClip } from './clip'; import { AudioCrossfader } from './audio-crossfader'; import { FrameWriter } from './frame-writer'; import { Transitioner } from './transitioner'; @@ -31,8 +45,42 @@ import { ENotificationType, NotificationsService } from 'services/notifications' import { JsonrpcService } from 'services/api/jsonrpc'; import { NavigationService } from 'services/navigation'; import { SharedStorageService } from 'services/integrations/shared-storage'; +import execa from 'execa'; +import moment from 'moment'; +import { + EHighlighterInputTypes, + getHighlightClips, + IHighlight, + IHighlighterInput, + IHighlighterMilestone, + ProgressTracker, +} from './ai-highlighter/ai-highlighter'; +import uuid from 'uuid'; +import { EMenuItemKey } from 'services/side-nav'; +import { AiHighlighterUpdater } from './ai-highlighter/updater'; +import { IDownloadProgress } from 'util/requests'; +import { IncrementalRolloutService } from 'app-services'; +import { EAvailableFeatures } from 'services/incremental-rollout'; +export type TStreamInfo = + | { + orderPosition: number; + initialStartTime?: number; + initialEndTime?: number; + } + | undefined; // initialTimesInStream -export interface IClip { +const isAiClip = (clip: TClip): clip is IAiClip => clip.source === 'AiClip'; + +// types for highlighter video operations +export type TOrientation = 'horizontal' | 'vertical'; +export interface ICoordinates { + x1: number; + y1: number; + x2: number; + y2: number; +} + +interface IBaseClip { path: string; loaded: boolean; enabled: boolean; @@ -41,7 +89,110 @@ export interface IClip { endTrim: number; duration?: number; deleted: boolean; - source: 'ReplayBuffer' | 'Manual'; + globalOrderPosition: number; + streamInfo: + | { + [streamId: string]: TStreamInfo; + } + | undefined; +} +interface IReplayBufferClip extends IBaseClip { + source: 'ReplayBuffer'; +} + +interface IManualClip extends IBaseClip { + source: 'Manual'; +} + +export interface IAiClip extends IBaseClip { + source: 'AiClip'; + aiInfo: IAiClipInfo; +} + +export interface IDeathMetadata { + place: number; +} +export interface IKillMetadata { + bot_kill: boolean; +} + +export interface IInput { + type: EHighlighterInputTypes; + metadata?: IDeathMetadata | IKillMetadata; +} + +export interface IAiClipInfo { + inputs: IInput[]; + score: number; + metadata: { + round: number; + webcam_coordinates: ICoordinates; + }; +} + +export type TClip = IAiClip | IReplayBufferClip | IManualClip; + +export enum EHighlighterView { + CLIPS = 'clips', + STREAM = 'stream', + SETTINGS = 'settings', +} + +interface TClipsViewState { + view: EHighlighterView.CLIPS; + id: string | undefined; +} +interface IStreamViewState { + view: EHighlighterView.STREAM; +} + +interface ISettingsViewState { + view: EHighlighterView.SETTINGS; +} + +export type IViewState = TClipsViewState | IStreamViewState | ISettingsViewState; + +export interface StreamMilestones { + streamId: string; + milestones: IHighlighterMilestone[]; +} + +// TODO: Need to clean up all of this +export interface StreamInfoForAiHighlighter { + id: string; + game: string; + title?: string; + milestonesPath?: string; +} + +export interface INewClipData { + path: string; + aiClipInfo: IAiClipInfo; + startTime: number; + endTime: number; + startTrim: number; + endTrim: number; +} + +export enum EAiDetectionState { + INITIALIZED = 'initialized', + IN_PROGRESS = 'detection-in-progress', + ERROR = 'error', + FINISHED = 'detection-finished', + CANCELED_BY_USER = 'detection-canceled-by-user', +} + +export interface IHighlightedStream { + id: string; + game: string; + title: string; + date: string; + state: { + type: EAiDetectionState; + progress: number; + }; + abortController?: AbortController; + path: string; } export enum EExportStep { @@ -96,15 +247,33 @@ export interface IAudioInfo { musicVolume: number; } -interface IHighligherState { - clips: Dictionary; - clipOrder: string[]; +export interface IIntroInfo { + path: string; + duration: number | null; +} +export interface IOutroInfo { + path: string; + duration: number | null; +} +export interface IVideoInfo { + intro: IIntroInfo; + outro: IOutroInfo; +} + +interface IHighlighterState { + clips: Dictionary; transition: ITransitionInfo; + video: IVideoInfo; audio: IAudioInfo; export: IExportInfo; upload: IUploadInfo; dismissedTutorial: boolean; error: string; + useAiHighlighter: boolean; + highlightedStreams: IHighlightedStream[]; + updaterProgress: number; + isUpdaterRunning: boolean; + highlighterVersion: string; } // Capitalization is not consistent because it matches with the @@ -221,14 +390,32 @@ export interface IExportOptions { width: number; height: number; preset: TPreset; + complexFilter?: string; } -class HighligherViews extends ViewHandler { +class HighlighterViews extends ViewHandler { /** - * Returns an array of clips in their display order + * Returns an array of clips */ get clips() { - return this.state.clipOrder.map(p => this.state.clips[p]); + return Object.values(this.state.clips); + } + get clipsDictionary() { + return this.state.clips; + } + + /** + * Returns wether or not the AiHighlighter should be used + */ + get useAiHighlighter() { + return this.state.useAiHighlighter; + } + + /** + * Returns wether or not the AiHighlighter should be used + */ + get highlightedStreams() { + return this.state.highlightedStreams; } /** @@ -264,6 +451,10 @@ class HighligherViews extends ViewHandler { return this.state.audio; } + get video() { + return this.state.video; + } + get transitionDuration() { return this.transition.type === 'None' ? 0 : this.state.transition.duration; } @@ -280,6 +471,18 @@ class HighligherViews extends ViewHandler { return this.state.error; } + get highlighterVersion() { + return this.state.highlighterVersion; + } + + get isUpdaterRunning() { + return this.state.isUpdaterRunning; + } + + get updaterProgress() { + return this.state.updaterProgress; + } + /** * Takes a filepath to a video and returns a file:// url with a random * component to prevent the browser from caching it and missing changes. @@ -291,14 +494,27 @@ class HighligherViews extends ViewHandler { } @InitAfter('StreamingService') -export class HighlighterService extends StatefulService { - static initialState: IHighligherState = { +export class HighlighterService extends PersistentStatefulService { + @Inject() streamingService: StreamingService; + @Inject() userService: UserService; + @Inject() usageStatisticsService: UsageStatisticsService; + @Inject() dismissablesService: DismissablesService; + @Inject() notificationsService: NotificationsService; + @Inject() jsonrpcService: JsonrpcService; + @Inject() navigationService: NavigationService; + @Inject() sharedStorageService: SharedStorageService; + @Inject() incrementalRolloutService: IncrementalRolloutService; + + static defaultState: IHighlighterState = { clips: {}, - clipOrder: [], transition: { type: 'fade', duration: 1, }, + video: { + intro: { path: '', duration: null }, + outro: { path: '', duration: null }, + }, audio: { musicEnabled: false, musicPath: '', @@ -328,34 +544,45 @@ export class HighlighterService extends StatefulService { }, dismissedTutorial: false, error: '', + useAiHighlighter: false, + highlightedStreams: [], + updaterProgress: 0, + isUpdaterRunning: false, + highlighterVersion: '', }; - @Inject() streamingService: StreamingService; - @Inject() userService: UserService; - @Inject() usageStatisticsService: UsageStatisticsService; - @Inject() dismissablesService: DismissablesService; - @Inject() notificationsService: NotificationsService; - @Inject() jsonrpcService: JsonrpcService; - @Inject() navigationService: NavigationService; - @Inject() sharedStorageService: SharedStorageService; + aiHighlighterUpdater: AiHighlighterUpdater; + aiHighlighterEnabled = false; + streamMilestones: StreamMilestones | null = null; + + static filter(state: IHighlighterState) { + return { + ...this.defaultState, + clips: state.clips, + highlightedStreams: state.highlightedStreams, + video: state.video, + audio: state.audio, + transition: state.transition, + useAiHighlighter: state.useAiHighlighter, + }; + } /** * A dictionary of actual clip classes. * These are not serializable so kept out of state. */ - clips: Dictionary = {}; + renderingClips: Dictionary = {}; directoryCleared = false; @mutation() - ADD_CLIP(clip: IClip) { + ADD_CLIP(clip: TClip) { Vue.set(this.state.clips, clip.path, clip); - this.state.clipOrder.push(clip.path); this.state.export.exported = false; } @mutation() - UPDATE_CLIP(clip: Partial & { path: string }) { + UPDATE_CLIP(clip: Partial & { path: string }) { Vue.set(this.state.clips, clip.path, { ...this.state.clips[clip.path], ...clip, @@ -366,13 +593,6 @@ export class HighlighterService extends StatefulService { @mutation() REMOVE_CLIP(clipPath: string) { Vue.delete(this.state.clips, clipPath); - this.state.clipOrder = this.state.clipOrder.filter(c => c !== clipPath); - this.state.export.exported = false; - } - - @mutation() - SET_ORDER(order: string[]) { - this.state.clipOrder = order; this.state.export.exported = false; } @@ -423,6 +643,15 @@ export class HighlighterService extends StatefulService { this.state.export.exported = false; } + @mutation() + SET_VIDEO_INFO(videoInfo: Partial) { + this.state.video = { + ...this.state.video, + ...videoInfo, + }; + this.state.export.exported = false; + } + @mutation() DISMISS_TUTORIAL() { this.state.dismissedTutorial = true; @@ -433,11 +662,105 @@ export class HighlighterService extends StatefulService { this.state.error = error; } + @mutation() + SET_USE_AI_HIGHLIGHTER(useAiHighlighter: boolean) { + Vue.set(this.state, 'useAiHighlighter', useAiHighlighter); + this.state.useAiHighlighter = useAiHighlighter; + } + + @mutation() + ADD_HIGHLIGHTED_STREAM(streamInfo: IHighlightedStream) { + // Vue.set(this.state, 'highlightedStreams', streamInfo); + this.state.highlightedStreams.push(streamInfo); + } + + @mutation() + UPDATE_HIGHLIGHTED_STREAM(updatedStreamInfo: IHighlightedStream) { + const keepAsIs = this.state.highlightedStreams.filter( + stream => stream.id !== updatedStreamInfo.id, + ); + this.state.highlightedStreams = [...keepAsIs, updatedStreamInfo]; + } + + @mutation() + REMOVE_HIGHLIGHTED_STREAM(id: string) { + this.state.highlightedStreams = this.state.highlightedStreams.filter( + stream => stream.id !== id, + ); + } + + @mutation() + SET_UPDATER_PROGRESS(progress: number) { + this.state.updaterProgress = progress; + } + + @mutation() + SET_UPDATER_STATE(isRunning: boolean) { + this.state.isUpdaterRunning = isRunning; + } + + @mutation() + SET_HIGHLIGHTER_VERSION(version: string) { + this.state.highlighterVersion = version; + } + get views() { - return new HighligherViews(this.state); + return new HighlighterViews(this.state); } - init() { + async init() { + super.init(); + + this.incrementalRolloutService.featuresReady.then(async () => { + this.aiHighlighterEnabled = this.incrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.aiHighlighter, + ); + + if (this.aiHighlighterEnabled && !this.aiHighlighterUpdater) { + this.aiHighlighterUpdater = new AiHighlighterUpdater(); + } + }); + + // + this.views.clips.forEach(clip => { + if (isAiClip(clip) && (clip.aiInfo as any).moments) { + clip.aiInfo.inputs = (clip.aiInfo as any).moments; + delete (clip.aiInfo as any).moments; + } + }); + + //Check if files are existent, if not, delete + this.views.clips.forEach(c => { + if (!this.fileExists(c.path)) { + this.removeClip(c.path, undefined); + } + }); + + if (this.views.exportInfo.exporting) { + this.SET_EXPORT_INFO({ + exporting: false, + error: null, + cancelRequested: false, + }); + } + + //Check if aiDetections were still running when the user closed desktop + this.views.highlightedStreams + .filter(stream => stream.state.type === 'detection-in-progress') + .forEach(stream => { + this.UPDATE_HIGHLIGHTED_STREAM({ + ...stream, + state: { type: EAiDetectionState.CANCELED_BY_USER, progress: 0 }, + }); + }); + + this.views.clips.forEach(c => { + this.UPDATE_CLIP({ + path: c.path, + loaded: false, + }); + }); + try { // On some very very small number of systems, we won't be able to fetch // the videos path from the system. @@ -450,70 +773,62 @@ export class HighlighterService extends StatefulService { } if (TEST_MODE) { - const clipsToLoad = [ - // Aero 15 test clips - // path.join(CLIP_DIR, '2021-05-12 12-59-28.mp4'), - path.join(CLIP_DIR, 'Replay 2021-03-30 14-13-20.mp4'), - // path.join(CLIP_DIR, 'Replay 2021-03-30 14-13-29.mp4'), - // path.join(CLIP_DIR, 'Replay 2021-03-30 14-13-41.mp4'), - // path.join(CLIP_DIR, 'Replay 2021-03-30 14-13-49.mp4'), - // path.join(CLIP_DIR, 'Replay 2021-03-30 14-13-58.mp4'), - // path.join(CLIP_DIR, 'Replay 2021-03-30 14-14-03.mp4'), - // path.join(CLIP_DIR, 'Replay 2021-03-30 14-14-06.mp4'), - // path.join(CLIP_DIR, 'Replay 2021-03-30 14-30-53.mp4'), - path.join(CLIP_DIR, 'Replay 2021-03-30 14-32-34.mp4'), - // path.join(CLIP_DIR, 'Replay 2021-03-30 14-34-33.mp4'), - // path.join(CLIP_DIR, 'Replay 2021-03-30 14-34-48.mp4'), - // path.join(CLIP_DIR, 'Replay 2021-03-30 14-35-03.mp4'), - // path.join(CLIP_DIR, 'Replay 2021-03-30 14-35-23.mp4'), - // path.join(CLIP_DIR, 'Replay 2021-03-30 14-35-51.mp4'), - // path.join(CLIP_DIR, 'Replay 2021-03-30 14-36-18.mp4'), - // path.join(CLIP_DIR, 'Replay 2021-03-30 14-36-30.mp4'), - // path.join(CLIP_DIR, 'Replay 2021-03-30 14-36-44.mp4'), - - // Spoken Audio - path.join(CLIP_DIR, '2021-06-24 13-59-58.mp4'), - // path.join(CLIP_DIR, '2021-06-24 14-00-26.mp4'), - // path.join(CLIP_DIR, '2021-06-24 14-00-52.mp4'), - - // 60 FPS - path.join(CLIP_DIR, '2021-07-06 15-14-22.mp4'), - - // Razer blade test clips - // path.join(CLIP_DIR, '2021-05-25 08-55-13.mp4'), - // path.join(CLIP_DIR, '2021-06-08 16-40-14.mp4'), - // path.join(CLIP_DIR, '2021-05-25 08-56-03.mp4'), - ]; - - clipsToLoad.forEach(c => { - this.ADD_CLIP({ - path: c, - loaded: false, - enabled: true, - startTrim: 0, - endTrim: 0, - deleted: false, - source: 'Manual', - }); - }); + // Need to be adjusted by person running the test + const clipsToLoad = [path.join(CLIP_DIR, 'Replay 2021-03-30 14-13-20.mp4')]; } else { - this.streamingService.replayBufferFileWrite.subscribe(clipPath => { - this.ADD_CLIP({ - path: clipPath, - loaded: false, - enabled: true, - startTrim: 0, - endTrim: 0, - deleted: false, - source: 'ReplayBuffer', - }); - }); - let streamStarted = false; + let aiRecordingInProgress = false; + let aiRecordingStartTime = moment(); + let streamInfo: StreamInfoForAiHighlighter; + + this.streamingService.replayBufferFileWrite.subscribe(async clipPath => { + const streamId = streamInfo?.id || undefined; + let endTime: number | undefined; - this.streamingService.streamingStatusChange.subscribe(status => { + if (streamId) { + endTime = moment().diff(aiRecordingStartTime, 'seconds'); + } else { + endTime = undefined; + } + + const REPLAY_BUFFER_DURATION = 20; // TODO M: Replace with settingsservice + const startTime = Math.max(0, endTime ? endTime - REPLAY_BUFFER_DURATION : 0); + + this.addClips([{ path: clipPath, startTime, endTime }], streamId, 'ReplayBuffer'); + }); + + this.streamingService.streamingStatusChange.subscribe(async status => { if (status === EStreamingState.Live) { - streamStarted = true; + streamStarted = true; // console.log('live', this.streamingService.views.settings.platforms.twitch.title); + + if (!this.aiHighlighterEnabled) { + return; + } + + if (this.views.useAiHighlighter === false) { + console.log('HighlighterService: Game:', this.streamingService.views.game); + // console.log('Highlighter not enabled or not Fortnite'); + return; + } + + // console.log('recording Alreadyt running?:', this.streamingService.views.isRecording); + + this.usageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'AiRecordingStarted', + }); + + if (this.streamingService.views.isRecording) { + // console.log('Recording is already running'); + } else { + this.streamingService.actions.toggleRecording(); + } + streamInfo = { + id: 'fromStreamRecording' + uuid(), + title: this.streamingService.views.settings.platforms.twitch?.title, + game: this.streamingService.views.game, + }; + aiRecordingInProgress = true; + aiRecordingStartTime = moment(); } if (status === EStreamingState.Offline) { @@ -534,13 +849,40 @@ export class HighlighterService extends StatefulService { ), }); - this.usageStatisticsService.recordAnalyticsEvent('Highlighter', { - type: 'NotificationShow', - }); + this.usageStatisticsService.recordAnalyticsEvent( + this.views.useAiHighlighter ? 'AIHighlighter' : 'Highlighter', + { + type: 'NotificationShow', + }, + ); } streamStarted = false; } + if (status === EStreamingState.Ending) { + if (!aiRecordingInProgress) { + return; + } + this.streamingService.actions.toggleRecording(); + + // Load potential replaybuffer clips + await this.loadClips(streamInfo.id); + } + }); + + this.streamingService.latestRecordingPath.subscribe(path => { + if (!aiRecordingInProgress) { + return; + } + + aiRecordingInProgress = false; + this.flow(path, streamInfo); + + this.navigationService.actions.navigate( + 'Highlighter', + { view: 'stream' }, + EMenuItemKey.Highlighter, + ); }); } } @@ -548,26 +890,173 @@ export class HighlighterService extends StatefulService { notificationAction() { this.navigationService.navigate('Highlighter'); this.dismissablesService.dismiss(EDismissable.HighlighterNotification); - this.usageStatisticsService.recordAnalyticsEvent('Highlighter', { - type: 'NotificationClick', + this.usageStatisticsService.recordAnalyticsEvent( + this.views.useAiHighlighter ? 'AIHighlighter' : 'Highlighter', + { + type: 'NotificationClick', + }, + ); + } + + addClips( + newClips: { path: string; startTime?: number; endTime?: number }[], + streamId: string | undefined, + source: 'Manual' | 'ReplayBuffer', + ) { + newClips.forEach((clipData, index) => { + const currentClips = this.getClips(this.views.clips, streamId); + const allClips = this.getClips(this.views.clips, undefined); + const getHighestGlobalOrderPosition = allClips.length; + + let newStreamInfo: { [key: string]: TStreamInfo } = {}; + if (source === 'Manual') { + if (streamId) { + currentClips.forEach(clip => { + if (clip?.streamInfo?.[streamId] === undefined) { + return; + } + + const updatedStreamInfo = { + ...clip.streamInfo, + [streamId]: { + ...clip.streamInfo[streamId], + orderPosition: clip.streamInfo[streamId]!.orderPosition + 1, + }, + }; + // update streaminfo position + this.UPDATE_CLIP({ + path: clip.path, + streamInfo: updatedStreamInfo, + }); + }); + + // Update globalOrderPosition of all other items as well + allClips.forEach(clip => { + this.UPDATE_CLIP({ + path: clip.path, + globalOrderPosition: clip.globalOrderPosition + 1, + }); + }); + + newStreamInfo = { + [streamId]: { + orderPosition: 0 + index, + }, + }; + } else { + // If no streamId currentCLips = allClips + currentClips.forEach(clip => { + this.UPDATE_CLIP({ + path: clip.path, + globalOrderPosition: clip.globalOrderPosition + 1, + }); + }); + } + } else { + if (streamId) { + newStreamInfo = { + [streamId]: { + orderPosition: index + currentClips.length + 1, + initialStartTime: clipData.startTime, + initialEndTime: clipData.endTime, + }, + }; + } + } + + if (this.state.clips[clipData.path]) { + //Add new newStreamInfo, wont be added if no streamId is available + const updatedStreamInfo = { + ...this.state.clips[clipData.path].streamInfo, + ...newStreamInfo, + }; + + this.UPDATE_CLIP({ + path: clipData.path, + streamInfo: updatedStreamInfo, + }); + return; + } else { + this.ADD_CLIP({ + path: clipData.path, + loaded: false, + enabled: true, + startTrim: 0, + endTrim: 0, + deleted: false, + source, + + // Manual clips always get prepended to be visible after adding them + // ReplayBuffers will appended to have them in the correct order. + globalOrderPosition: + source === 'Manual' ? 0 + index : index + getHighestGlobalOrderPosition + 1, + streamInfo: streamId !== undefined ? newStreamInfo : undefined, + }); + } }); + return; } - addClips(paths: string[]) { - paths.forEach(path => { - // Don't allow adding the same clip twice - if (this.state.clips[path]) return; + async addAiClips(newClips: INewClipData[], newStreamInfo: StreamInfoForAiHighlighter) { + const currentHighestOrderPosition = this.getClips(this.views.clips, newStreamInfo.id).length; + const getHighestGlobalOrderPosition = this.getClips(this.views.clips, undefined).length; + + newClips.forEach((clip, index) => { + // Don't allow adding the same clip twice for ai clips + if (this.state.clips[clip.path]) return; + + const streamInfo: { [key: string]: TStreamInfo } = { + [newStreamInfo.id]: { + // Orderposition will get overwritten by sortStreamClipsByStartTime after creation + orderPosition: + index + currentHighestOrderPosition + (currentHighestOrderPosition === 0 ? 0 : 1), + initialStartTime: clip.startTime, + initialEndTime: clip.endTime, + }, + }; this.ADD_CLIP({ - path, + path: clip.path, loaded: false, enabled: true, - startTrim: 0, - endTrim: 0, + startTrim: clip.startTrim, + endTrim: clip.endTrim, deleted: false, - source: 'Manual', + source: 'AiClip', + aiInfo: clip.aiClipInfo, + globalOrderPosition: + index + getHighestGlobalOrderPosition + (getHighestGlobalOrderPosition === 0 ? 0 : 1), + streamInfo, }); }); + this.sortStreamClipsByStartTime(this.views.clips, newStreamInfo); + await this.loadClips(newStreamInfo.id); + } + + // This sorts all clips (replayBuffer and aiClips) by initialStartTime + // That will assure that replayBuffer clips are also sorted in correctly in the stream + sortStreamClipsByStartTime(clips: TClip[], newStreamInfo: StreamInfoForAiHighlighter) { + const allClips = this.getClips(clips, newStreamInfo.id); + + const sortedClips = allClips.sort( + (a, b) => + (a.streamInfo?.[newStreamInfo.id]?.initialStartTime || 0) - + (b.streamInfo?.[newStreamInfo.id]?.initialStartTime || 0), + ); + + // Update order positions based on the sorted order + sortedClips.forEach((clip, index) => { + this.UPDATE_CLIP({ + path: clip.path, + streamInfo: { + [newStreamInfo.id]: { + ...(clip.streamInfo?.[newStreamInfo.id] ?? {}), + orderPosition: index, + }, + }, + }); + }); + return; } enableClip(path: string, enabled: boolean) { @@ -576,6 +1065,12 @@ export class HighlighterService extends StatefulService { enabled, }); } + disableClip(path: string) { + this.UPDATE_CLIP({ + path, + enabled: false, + }); + } setStartTrim(path: string, trim: number) { this.UPDATE_CLIP({ @@ -591,12 +1086,52 @@ export class HighlighterService extends StatefulService { }); } - removeClip(path: string) { - this.REMOVE_CLIP(path); - } + removeClip(path: string, streamId: string | undefined) { + const clip: TClip = this.state.clips[path]; + if (!clip) { + console.warn(`Clip not found for path: ${path}`); + return; + } + if ( + this.fileExists(path) && + streamId && + clip.streamInfo && + Object.keys(clip.streamInfo).length > 1 + ) { + const updatedStreamInfo = { ...clip.streamInfo }; + delete updatedStreamInfo[streamId]; + + this.UPDATE_CLIP({ + path: clip.path, + streamInfo: updatedStreamInfo, + }); + } else { + this.REMOVE_CLIP(path); + this.removeScrubFile(clip.scrubSprite); + delete this.renderingClips[path]; + } - setOrder(order: string[]) { - this.SET_ORDER(order); + if (clip.streamInfo !== undefined || streamId !== undefined) { + // if we are passing a streamId, only check if we need to remove the specific streamIds stream + // If we are not passing a streamId, check if we need to remove the streams the clip was part of + const ids: string[] = streamId ? [streamId] : Object.keys(clip.streamInfo ?? {}); + const length = this.views.clips.length; + + ids.forEach(id => { + let found = false; + if (length !== 0) { + for (let i = 0; i < length; i++) { + if (this.views.clips[i].streamInfo?.[id] !== undefined) { + found = true; + break; + } + } + } + if (!found) { + this.REMOVE_HIGHLIGHTED_STREAM(id); + } + }); + } } setTransition(transition: Partial) { @@ -607,6 +1142,13 @@ export class HighlighterService extends StatefulService { this.SET_AUDIO_INFO(audio); } + setVideo(video: Partial) { + this.SET_VIDEO_INFO(video); + } + + resetExportedState() { + this.SET_EXPORT_INFO({ exported: false }); + } setExportFile(file: string) { this.SET_EXPORT_INFO({ file }); } @@ -633,18 +1175,75 @@ export class HighlighterService extends StatefulService { this.DISMISS_TUTORIAL(); } - fileExists(file: string) { + fileExists(file: string): boolean { return fs.existsSync(file); } - async loadClips() { + // TODO M: Temp way to solve the issue + addStream(streamInfo: IHighlightedStream) { + return new Promise(resolve => { + this.ADD_HIGHLIGHTED_STREAM(streamInfo); + setTimeout(() => { + resolve(); + }, 2000); + }); + } + + updateStream(streamInfo: IHighlightedStream) { + this.UPDATE_HIGHLIGHTED_STREAM(streamInfo); + } + + removeStream(streamId: string) { + this.REMOVE_HIGHLIGHTED_STREAM(streamId); + + //Remove clips from stream + const clipsToRemove = this.getClips(this.views.clips, streamId); + clipsToRemove.forEach(clip => { + this.removeClip(clip.path, streamId); + }); + } + + async removeScrubFile(clipPath: string | undefined) { + if (!clipPath) { + console.warn('No scrub file path provided'); + return; + } + try { + await fs.remove(clipPath); + } catch (error: unknown) { + console.error('Error removing scrub file', error); + } + } + + setAiHighlighter(state: boolean) { + this.SET_USE_AI_HIGHLIGHTER(state); + this.usageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'Toggled', + value: state, + }); + } + + toggleAiHighlighter() { + if (this.state.useAiHighlighter) { + this.SET_USE_AI_HIGHLIGHTER(false); + } else { + this.SET_USE_AI_HIGHLIGHTER(true); + } + } + + async loadClips(streamInfoId?: string | undefined) { + const clipsToLoad: TClip[] = this.getClips(this.views.clips, streamInfoId); + // this.resetRenderingClips(); await this.ensureScrubDirectory(); - // Ensure we have a Clip class for every clip in the store - // Also make sure they are the correct format - this.views.clips.forEach(c => { - if (!SUPPORTED_FILE_TYPES.map(e => `.${e}`).includes(path.parse(c.path).ext)) { - this.REMOVE_CLIP(c.path); + for (const clip of clipsToLoad) { + if (!this.fileExists(clip.path)) { + this.removeClip(clip.path, streamInfoId); + return; + } + + if (!SUPPORTED_FILE_TYPES.map(e => `.${e}`).includes(path.parse(clip.path).ext)) { + this.removeClip(clip.path, streamInfoId); this.SET_ERROR( $t( 'One or more clips could not be imported because they were not recorded in a supported file format.', @@ -652,39 +1251,52 @@ export class HighlighterService extends StatefulService { ); } - this.clips[c.path] = this.clips[c.path] ?? new Clip(c.path); - }); + this.renderingClips[clip.path] = + this.renderingClips[clip.path] ?? new RenderingClip(clip.path); + } + //TODO M: tracking type not correct await pmap( - this.views.clips.filter(c => !c.loaded), - c => this.clips[c.path].init(), + clipsToLoad.filter(c => !c.loaded), + c => this.renderingClips[c.path].init(), { concurrency: os.cpus().length, onProgress: completed => { - this.usageStatisticsService.recordAnalyticsEvent('Highlighter', { - type: 'ClipImport', - source: completed.source, - }); - + this.usageStatisticsService.recordAnalyticsEvent( + this.views.useAiHighlighter ? 'AIHighlighter' : 'Highlighter', + { + type: 'ClipImport', + source: completed.source, + }, + ); this.UPDATE_CLIP({ path: completed.path, loaded: true, - scrubSprite: this.clips[completed.path].frameSource?.scrubJpg, - duration: this.clips[completed.path].duration, - deleted: this.clips[completed.path].deleted, + scrubSprite: this.renderingClips[completed.path].frameSource?.scrubJpg, + duration: this.renderingClips[completed.path].duration, + deleted: this.renderingClips[completed.path].deleted, }); }, }, ); + return; } - private async ensureScrubDirectory() { - // We clear this out once per application run - if (this.directoryCleared) return; - this.directoryCleared = true; + resetRenderingClips() { + this.renderingClips = {}; + } - await fs.remove(SCRUB_SPRITE_DIRECTORY); - await fs.mkdir(SCRUB_SPRITE_DIRECTORY); + private async ensureScrubDirectory() { + try { + try { + //If possible to read, directory exists, if not, catch and mkdir + await fs.readdir(SCRUB_SPRITE_DIRECTORY); + } catch (error: unknown) { + await fs.mkdir(SCRUB_SPRITE_DIRECTORY); + } + } catch (error: unknown) { + console.log('Error creating scrub sprite directory'); + } } cancelExport() { @@ -695,9 +1307,24 @@ export class HighlighterService extends StatefulService { * Exports the video using the currently configured settings * Return true if the video was exported, or false if not. */ - async export(preview = false) { - if (!this.views.loaded) { - console.error('Highlighter: Export called while clips are not fully loaded!'); + async export( + preview = false, + streamId: string | undefined = undefined, + orientation: TOrientation = 'horizontal', + ) { + this.resetRenderingClips(); + await this.loadClips(streamId); + + if ( + !this.views.clips + .filter(c => { + if (!c.enabled) return false; + if (!streamId) return true; + return c.streamInfo && c.streamInfo[streamId] !== undefined; + }) + .every(clip => clip.loaded) + ) { + console.error('Highlighter: Export called while clips are not fully loaded!: '); return; } @@ -706,17 +1333,54 @@ export class HighlighterService extends StatefulService { return; } - let clips = this.views.clips - .filter(c => c.enabled) - .map(c => { - const clip = this.clips[c.path]; + let renderingClips: RenderingClip[] = []; + if (streamId) { + renderingClips = this.getClips(this.views.clips, streamId) + .filter( + clip => + !!clip && clip.enabled && clip.streamInfo && clip.streamInfo[streamId] !== undefined, + ) + .sort( + (a: TClip, b: TClip) => + (a.streamInfo?.[streamId]?.orderPosition ?? 0) - + (b.streamInfo?.[streamId]?.orderPosition ?? 0), + ) + .map(c => { + const clip = this.renderingClips[c.path]; + + clip.startTrim = c.startTrim; + clip.endTrim = c.endTrim; + + return clip; + }); + } else { + renderingClips = this.views.clips + .filter(c => c.enabled) + .sort((a: TClip, b: TClip) => a.globalOrderPosition - b.globalOrderPosition) + .map(c => { + const clip = this.renderingClips[c.path]; - // Set trims on the frame source - clip.startTrim = c.startTrim; - clip.endTrim = c.endTrim; + clip.startTrim = c.startTrim; + clip.endTrim = c.endTrim; - return clip; - }); + return clip; + }); + } + + if (this.views.video.intro.path && orientation !== 'vertical') { + const intro: RenderingClip = new RenderingClip(this.views.video.intro.path); + await intro.init(); + intro.startTrim = 0; + intro.endTrim = 0; + renderingClips.unshift(intro); + } + if (this.views.video.outro.path && orientation !== 'vertical') { + const outro = new RenderingClip(this.views.video.outro.path); + await outro.init(); + outro.startTrim = 0; + outro.endTrim = 0; + renderingClips.push(outro); + } const exportOptions: IExportOptions = preview ? { width: 1280 / 4, height: 720 / 4, fps: 30, preset: 'ultrafast' } @@ -727,8 +1391,13 @@ export class HighlighterService extends StatefulService { preset: this.views.exportInfo.preset, }; + if (orientation === 'vertical') { + // adds complex filter and flips width and height + this.addVerticalFilterToExportOptions(exportOptions); + } + // Reset all clips - await pmap(clips, c => c.reset(exportOptions), { + await pmap(renderingClips, c => c.reset(exportOptions), { onProgress: c => { if (c.deleted) { this.UPDATE_CLIP({ path: c.sourcePath, deleted: true }); @@ -738,18 +1407,18 @@ export class HighlighterService extends StatefulService { // TODO: For now, just remove deleted clips from the video // In the future, abort export and surface error to the user. - clips = clips.filter(c => !c.deleted); + renderingClips = renderingClips.filter(c => !c.deleted); - if (!clips.length) { + if (!renderingClips.length) { console.error('Highlighter: Export called without any clips!'); return; } // Estimate the total number of frames to set up export info - const totalFrames = clips.reduce((count: number, clip) => { + const totalFrames = renderingClips.reduce((count: number, clip) => { return count + clip.frameSource.nFrames; }, 0); - const numTransitions = clips.length - 1; + const numTransitions = renderingClips.length - 1; const transitionFrames = this.views.transitionDuration * exportOptions.fps; const totalFramesAfterTransitions = totalFrames - numTransitions * transitionFrames; @@ -769,11 +1438,13 @@ export class HighlighterService extends StatefulService { let currentFrame = 0; // Mix audio first - await Promise.all(clips.filter(c => c.hasAudio).map(clip => clip.audioSource.extract())); + await Promise.all( + renderingClips.filter(c => c.hasAudio).map(clip => clip.audioSource.extract()), + ); const parsed = path.parse(this.views.exportInfo.file); const audioConcat = path.join(parsed.dir, `${parsed.name}-concat.flac`); let audioMix = path.join(parsed.dir, `${parsed.name}-mix.flac`); - fader = new AudioCrossfader(audioConcat, clips, this.views.transitionDuration); + fader = new AudioCrossfader(audioConcat, renderingClips, this.views.transitionDuration); await fader.export(); if (this.views.audio.musicEnabled && this.views.audio.musicPath) { @@ -793,14 +1464,14 @@ export class HighlighterService extends StatefulService { audioMix = audioConcat; } - await Promise.all(clips.map(clip => clip.audioSource.cleanup())); - const nClips = clips.length; + await Promise.all(renderingClips.map(clip => clip.audioSource.cleanup())); + const nClips = renderingClips.length; this.SET_EXPORT_INFO({ step: EExportStep.FrameRender }); // Cannot be null because we already checked there is at least 1 element in the array - let fromClip = clips.shift()!; - let toClip = clips.shift(); + let fromClip = renderingClips.shift()!; + let toClip = renderingClips.shift(); let transitioner: Transitioner | null = null; const exportPath = preview ? this.views.exportInfo.previewFile : this.views.exportInfo.file; @@ -884,7 +1555,7 @@ export class HighlighterService extends StatefulService { if (this.views.transition.type === 'Random') transitioner = null; fromClip.frameSource.end(); fromClip = toClip!; - toClip = clips.shift(); + toClip = renderingClips.shift(); } if (!fromClip) { @@ -894,21 +1565,27 @@ export class HighlighterService extends StatefulService { `Export complete - Expected Frames: ${this.views.exportInfo.totalFrames} Actual Frames: ${currentFrame}`, ); - this.usageStatisticsService.recordAnalyticsEvent('Highlighter', { - type: 'ExportComplete', - numClips: nClips, - transition: this.views.transition.type, - transitionDuration: this.views.transition.duration, - resolution: this.views.exportInfo.resolution, - fps: this.views.exportInfo.fps, - preset: this.views.exportInfo.preset, - duration: totalFramesAfterTransitions / exportOptions.fps, - isPreview: preview, - }); + this.usageStatisticsService.recordAnalyticsEvent( + this.views.useAiHighlighter ? 'AIHighlighter' : 'Highlighter', + { + type: 'ExportComplete', + numClips: nClips, + totalClips: this.views.clips.length, + transition: this.views.transition.type, + transitionDuration: this.views.transition.duration, + resolution: this.views.exportInfo.resolution, + fps: this.views.exportInfo.fps, + preset: this.views.exportInfo.preset, + duration: totalFramesAfterTransitions / exportOptions.fps, + isPreview: preview, + }, + ); break; } } } catch (e: unknown) { + console.error(e); + Sentry.withScope(scope => { scope.setTag('feature', 'highlighter'); console.error('Highlighter export error', e); @@ -916,21 +1593,28 @@ export class HighlighterService extends StatefulService { if (e instanceof HighlighterError) { this.SET_EXPORT_INFO({ error: e.userMessage }); - this.usageStatisticsService.recordAnalyticsEvent('Highlighter', { - type: 'ExportError', - error: e.constructor.name, - }); + this.usageStatisticsService.recordAnalyticsEvent( + this.views.useAiHighlighter ? 'AIHighlighter' : 'Highlighter', + { + type: 'ExportError', + error: e.constructor.name, + }, + ); } else { this.SET_EXPORT_INFO({ error: $t('An error occurred while exporting the video') }); - this.usageStatisticsService.recordAnalyticsEvent('Highlighter', { - type: 'ExportError', - error: 'Unknown', - }); + this.usageStatisticsService.recordAnalyticsEvent( + this.views.useAiHighlighter ? 'AIHighlighter' : 'Highlighter', + { + type: 'ExportError', + error: 'Unknown', + }, + ); } } if (fader) await fader.cleanup(); if (mixer) await mixer.cleanup(); + // this.resetRenderingClips(); this.SET_EXPORT_INFO({ exporting: false, exported: !this.views.exportInfo.cancelRequested && !preview && !this.views.exportInfo.error, @@ -938,6 +1622,77 @@ export class HighlighterService extends StatefulService { this.SET_UPLOAD_INFO({ videoId: null }); } + /** + * + * @param exportOptions export options to be modified + * Take the existing export options, flips the resolution to vertical and adds complex filter to move webcam to top + */ + private addVerticalFilterToExportOptions(exportOptions: IExportOptions) { + const webcamCoordinates = this.getWebcamPosition(); + const newWidth = exportOptions.height; + const newHeight = exportOptions.width; + // exportOptions.height = exportOptions.width; + // exportOptions.width = newWidth; + exportOptions.complexFilter = this.getWebcamComplexFilterForFfmpeg( + webcamCoordinates, + newWidth, + newHeight, + ); + } + /** + * + * @param + * @returns + * Gets the first webcam position from all of the clips + * should get webcam position for a specific clip soon + */ + private getWebcamPosition() { + const clipWithWebcam = this.views.clips.find( + clip => + isAiClip(clip) && + !!clip?.aiInfo?.metadata?.webcam_coordinates && + this.renderingClips[clip.path], + ) as IAiClip; + return clipWithWebcam?.aiInfo?.metadata?.webcam_coordinates || undefined; + } + /** + * + * @param webcamCoordinates + * @param outputWidth + * @param outputHeight + * @returns properly formatted complex filter for ffmpeg to move webcam to top in vertical video + */ + private getWebcamComplexFilterForFfmpeg( + webcamCoordinates: ICoordinates | null, + outputWidth: number, + outputHeight: number, + ) { + if (!webcamCoordinates) { + return ` + [0:v]crop=ih*${outputWidth}/${outputHeight}:ih,scale=${outputWidth}:-1:force_original_aspect_ratio=increase[final]; + `; + } + + const webcamTopX = webcamCoordinates?.x1; + const webcamTopY = webcamCoordinates?.y1; + const webcamWidth = webcamCoordinates?.x2 - webcamCoordinates?.x1; + const webcamHeight = webcamCoordinates?.y2 - webcamCoordinates?.y1; + + const oneThirdHeight = outputHeight / 3; + const twoThirdsHeight = (outputHeight * 2) / 3; + + return ` + [0:v]split=3[webcam][vid][blur_source]; + color=c=black:s=${outputWidth}x${outputHeight}:d=1[base]; + [webcam]crop=w=${webcamWidth}:h=${webcamHeight}:x=${webcamTopX}:y=${webcamTopY},scale=-1:${oneThirdHeight}[webcam_final]; + [vid]crop=ih*${outputWidth}/${twoThirdsHeight}:ih,scale=${outputWidth}:${twoThirdsHeight}[vid_cropped]; + [blur_source]crop=ih*${outputWidth}/${twoThirdsHeight}:ih,scale=${outputWidth}:${oneThirdHeight},gblur=sigma=50[blur]; + [base][blur]overlay=x=0:y=0[blur_base]; + [blur_base][webcam_final]overlay='(${outputWidth}-overlay_w)/2:(${oneThirdHeight}-overlay_h)/2'[base_webcam]; + [base_webcam][vid_cropped]overlay=x=0:y=${oneThirdHeight}[final]; + `; + } + // We throttle because this can go extremely fast, especially on previews @throttle(100) private setCurrentFrame(frame: number) { @@ -991,9 +1746,12 @@ export class HighlighterService extends StatefulService { }); this.SET_UPLOAD_INFO({ error: true }); - this.usageStatisticsService.recordAnalyticsEvent('Highlighter', { - type: 'UploadYouTubeError', - }); + this.usageStatisticsService.recordAnalyticsEvent( + this.views.useAiHighlighter ? 'AIHighlighter' : 'Highlighter', + { + type: 'UploadYouTubeError', + }, + ); } } @@ -1005,14 +1763,17 @@ export class HighlighterService extends StatefulService { }); if (result) { - this.usageStatisticsService.recordAnalyticsEvent('Highlighter', { - type: 'UploadYouTubeSuccess', - privacy: options.privacyStatus, - videoLink: - options.privacyStatus === 'public' - ? `https://youtube.com/watch?v=${result.id}` - : undefined, - }); + this.usageStatisticsService.recordAnalyticsEvent( + this.views.useAiHighlighter ? 'AIHighlighter' : 'Highlighter', + { + type: 'UploadYouTubeSuccess', + privacy: options.privacyStatus, + videoLink: + options.privacyStatus === 'public' + ? `https://youtube.com/watch?v=${result.id}` + : undefined, + }, + ); } } @@ -1076,4 +1837,478 @@ export class HighlighterService extends StatefulService { clearUpload() { this.CLEAR_UPLOAD(); } + + extractDateTimeFromPath(filePath: string): string | undefined { + try { + const parts = filePath.split(/[/\\]/); + const fileName = parts[parts.length - 1]; + const dateTimePart = fileName.split('.')[0]; + return dateTimePart; + } catch (error: unknown) { + return undefined; + } + } + + async restartAiDetection(filePath: string, streamInfo: IHighlightedStream) { + this.removeStream(streamInfo.id); + + const milestonesPath = await this.prepareMilestonesFile(streamInfo.id); + + const streamInfoForHighlighter: StreamInfoForAiHighlighter = { + id: streamInfo.id, + title: streamInfo.title, + game: streamInfo.game, + milestonesPath, + }; + + this.flow(filePath, streamInfoForHighlighter); + } + + async flow(filePath: string, streamInfo: StreamInfoForAiHighlighter): Promise { + if (this.aiHighlighterEnabled === false) { + console.log('HighlighterService: Not enabled'); + return; + } + + // if update is already in progress, need to wait until it's done + if (this.aiHighlighterUpdater.updateInProgress) { + await this.aiHighlighterUpdater.currentUpdate; + } else if (await this.aiHighlighterUpdater.isNewVersionAvailable()) { + await this.startUpdater(); + } + + const fallbackTitle = 'awesome-stream'; + const sanitizedTitle = streamInfo.title + ? streamInfo.title.replace(/[\\/:"*?<>|]+/g, ' ') + : this.extractDateTimeFromPath(filePath) || fallbackTitle; + + const setStreamInfo: IHighlightedStream = { + state: { + type: EAiDetectionState.IN_PROGRESS, + progress: 0, + }, + date: moment().toISOString(), + id: streamInfo.id || 'noId', + title: sanitizedTitle, + game: streamInfo.game || 'no title', + abortController: new AbortController(), + path: filePath, + }; + + this.streamMilestones = { + streamId: setStreamInfo.id, + milestones: [], + }; + + await this.addStream(setStreamInfo); + + const progressTracker = new ProgressTracker(progress => { + setStreamInfo.state.progress = progress; + this.updateStream(setStreamInfo); + }); + + const renderHighlights = async (partialHighlights: IHighlight[]) => { + console.log('🔄 cutHighlightClips'); + this.updateStream(setStreamInfo); + const clipData = await this.cutHighlightClips(filePath, partialHighlights, setStreamInfo); + console.log('✅ cutHighlightClips'); + // 6. add highlight clips + progressTracker.destroy(); + setStreamInfo.state.type = EAiDetectionState.FINISHED; + this.updateStream(setStreamInfo); + + console.log('🔄 addClips', clipData); + this.addAiClips(clipData, streamInfo); + console.log('✅ addClips'); + }; + + console.log('🔄 HighlighterData'); + try { + const highlighterResponse = await getHighlightClips( + filePath, + renderHighlights, + setStreamInfo.abortController!.signal, + (progress: number) => { + progressTracker.updateProgressFromHighlighter(progress); + }, + streamInfo.milestonesPath, + (milestone: IHighlighterMilestone) => { + this.streamMilestones?.milestones?.push(milestone); + }, + ); + + this.usageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'Detection', + clips: highlighterResponse.length, + game: 'Fortnite', // hardcode for now + }); + console.log('✅ Final HighlighterData', highlighterResponse); + } catch (error: unknown) { + if (error instanceof Error && error.message === 'Highlight generation canceled') { + setStreamInfo.state.type = EAiDetectionState.CANCELED_BY_USER; + } else { + console.error('Error in highlight generation:', error); + setStreamInfo.state.type = EAiDetectionState.ERROR; + } + } finally { + setStreamInfo.abortController = undefined; + this.updateStream(setStreamInfo); + // stopProgressUpdates(); + } + + return; + } + + cancelHighlightGeneration(streamId: string): void { + const stream = this.views.highlightedStreams.find(s => s.id === streamId); + if (stream && stream.abortController) { + console.log('cancelHighlightGeneration', streamId); + stream.abortController.abort(); + } + } + + async getHighlightClipsRest( + type: string, + video_uri: string, + trim: { start_time: number; start_end: number } | undefined, + ) { + // Call highlighter code - replace with function + try { + const body = { + video_uri, + url, + trim, + }; + + const controller = new AbortController(); + const signal = controller.signal; + const timeout = 1000 * 60 * 30; // 30 minutes + console.time('requestDuration'); + const fetchTimeout = setTimeout(() => { + controller.abort(); + }, timeout); + + const response = await fetch(`http://127.0.0.1:8000${type}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify(body), + signal, + }); + + clearTimeout(fetchTimeout); + console.timeEnd('requestDuration'); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data; + } catch (error: unknown) { + console.timeEnd('requestDuration'); + + if ((error as any).name === 'AbortError') { + console.error('Fetch request timed out'); + } else { + console.error('Fetch error:', error); + } + + throw new Error('Error while fetching'); + } + } + + async cutHighlightClips( + videoUri: string, + highlighterData: IHighlight[], + streamInfo: IHighlightedStream, + ): Promise { + const id = streamInfo.id; + const fallbackTitle = 'awesome-stream'; + const videoDir = path.dirname(videoUri); + const filename = path.basename(videoUri); + const sanitizedTitle = streamInfo.title + ? streamInfo.title.replace(/[\\/:"*?<>|]+/g, ' ') + : fallbackTitle; + const folderName = `${filename}-Clips-${sanitizedTitle}-${id.slice(id.length - 4, id.length)}`; + const outputDir = path.join(videoDir, folderName); + + // Check if directory for clips exists, if not create it + try { + try { + await fs.readdir(outputDir); + } catch (error: unknown) { + await fs.mkdir(outputDir); + } + } catch (error: unknown) { + console.error('Error creating file directory'); + return []; + } + + const sortedHighlights = highlighterData.sort((a, b) => a.start_time - b.start_time); + const results: INewClipData[] = []; + const processedFiles = new Set(); + + const duration = await this.getVideoDuration(videoUri); + + // First check the codec + const probeArgs = [ + '-v', + 'error', + '-select_streams', + 'v:0', + '-show_entries', + 'stream=codec_name,format=duration', + '-of', + 'default=nokey=1:noprint_wrappers=1', + videoUri, + ]; + let codec = ''; + try { + const codecResult = await execa(FFPROBE_EXE, probeArgs); + codec = codecResult.stdout.trim(); + console.log(`Codec for ${videoUri}: ${codec}`); + } catch (error: unknown) { + console.error(`Error checking codec for ${videoUri}:`, error); + } + console.time('export'); + const BATCH_SIZE = 1; + const DEFAULT_START_TRIM = 10; + const DEFAULT_END_TRIM = 10; + + for (let i = 0; i < sortedHighlights.length; i += BATCH_SIZE) { + const highlightBatch = sortedHighlights.slice(i, i + BATCH_SIZE); + const batchTasks = highlightBatch.map((highlight: IHighlight) => { + return async () => { + const formattedStart = highlight.start_time.toString().padStart(6, '0'); + const formattedEnd = highlight.end_time.toString().padStart(6, '0'); + const outputFilename = `${folderName}-${formattedStart}-${formattedEnd}.mp4`; + const outputUri = path.join(outputDir, outputFilename); + + if (processedFiles.has(outputUri)) { + console.log('File already exists'); + return null; + } + processedFiles.add(outputUri); + + // Check if the file with that name already exists and delete it if it does + try { + await fs.access(outputUri); + await fs.unlink(outputUri); + } catch (err: unknown) { + if ((err as any).code !== 'ENOENT') { + console.error(`Error checking existence of ${outputUri}:`, err); + } + } + + // Calculate new start and end times + new clip duration + const newClipStartTime = Math.max(0, highlight.start_time - DEFAULT_START_TRIM); + const actualStartTrim = highlight.start_time - newClipStartTime; + const newClipEndTime = Math.min(duration, highlight.end_time + DEFAULT_END_TRIM); + const actualEndTrim = newClipEndTime - highlight.end_time; + + const args = [ + '-ss', + newClipStartTime.toString(), + '-to', + newClipEndTime.toString(), + '-i', + videoUri, + '-c:v', + codec === 'h264' ? 'copy' : 'libx264', + '-c:a', + 'aac', + '-strict', + 'experimental', + '-b:a', + '192k', + '-movflags', + 'faststart', + outputUri, + ]; + + try { + const subprocess = execa(FFMPEG_EXE, args); + const timeoutDuration = 1000 * 60 * 5; + const timeoutId = setTimeout(() => { + console.warn(`FFMPEG process timed out for ${outputUri}`); + subprocess.kill('SIGTERM', { forceKillAfterTimeout: 2000 }); + }, timeoutDuration); + + try { + await subprocess; + console.log(`Created segment: ${outputUri}`); + const newClipData: INewClipData = { + path: outputUri, + aiClipInfo: { + inputs: highlight.inputs, + score: highlight.score, + metadata: highlight.metadata, + }, + startTime: highlight.start_time, + endTime: highlight.end_time, + startTrim: actualStartTrim, + endTrim: actualEndTrim, + }; + return newClipData; + } catch (error: unknown) { + console.warn(`Error during FFMPEG execution for ${outputUri}:`, error); + return null; + } finally { + clearTimeout(timeoutId); + } + } catch (error: unknown) { + console.error(`Error creating segment: ${outputUri}`, error); + return null; + } + }; + }); + + const batchResults = await Promise.allSettled(batchTasks.map(task => task())); + results.push( + ...batchResults + .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .map(result => result.value) + .filter(value => value !== null), + ); + + const failedResults = batchResults.filter(result => result.status === 'rejected'); + + if (failedResults.length > 0) { + console.error('Failed exports:', failedResults); + } + } + + console.timeEnd('export'); + return results; + } + getClips(clips: TClip[], streamId?: string): TClip[] { + return clips.filter(clip => { + if (clip.path === 'add') { + return false; + } + const exists = this.fileExists(clip.path); + if (!exists) { + this.removeClip(clip.path, streamId); + return false; + } + if (streamId) { + return clip.streamInfo?.[streamId]; + } + return true; + }); + } + + getClipsLoaded(clips: TClip[], streamId?: string): boolean { + return this.getClips(clips, streamId).every(clip => clip.loaded); + } + + getRoundDetails( + clips: TClip[], + ): { round: number; inputs: IInput[]; duration: number; hypeScore: number }[] { + const roundsMap: { + [key: number]: { inputs: IInput[]; duration: number; hypeScore: number; count: number }; + } = {}; + clips.forEach(clip => { + const aiClip = isAiClip(clip) ? clip : undefined; + const round = aiClip?.aiInfo?.metadata?.round ?? undefined; + if (aiClip?.aiInfo?.inputs && round) { + if (!roundsMap[round]) { + roundsMap[round] = { inputs: [], duration: 0, hypeScore: 0, count: 0 }; + } + roundsMap[round].inputs.push(...aiClip.aiInfo.inputs); + roundsMap[round].duration += aiClip.duration + ? aiClip.duration - aiClip.startTrim - aiClip.endTrim + : 0; + roundsMap[round].hypeScore += aiClip.aiInfo.score; + roundsMap[round].count += 1; + } + }); + + return Object.keys(roundsMap).map(round => { + const averageScore = + roundsMap[parseInt(round, 10)].hypeScore / roundsMap[parseInt(round, 10)].count; + const hypeScore = Math.ceil(Math.min(1, Math.max(0, averageScore)) * 5); + + return { + round: parseInt(round, 10), + inputs: roundsMap[parseInt(round, 10)].inputs, + duration: roundsMap[parseInt(round, 10)].duration, + hypeScore, + }; + }); + } + + async getVideoDuration(filePath: string): Promise { + const { stdout } = await execa(FFPROBE_EXE, [ + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'default=noprint_wrappers=1:nokey=1', + filePath, + ]); + const duration = parseFloat(stdout); + return duration; + } + + enableOnlySpecificClips(clips: TClip[], streamId?: string) { + clips.forEach(clip => { + this.UPDATE_CLIP({ + path: clip.path, + enabled: false, + }); + }); + + // Enable specific clips + const clipsToEnable = this.getClips(clips, streamId); + clipsToEnable.forEach(clip => { + this.UPDATE_CLIP({ + path: clip.path, + enabled: true, + }); + }); + } + + private updateProgress(progress: IDownloadProgress) { + // this is a lie and its not a percent, its float from 0 and 1 + this.SET_UPDATER_PROGRESS(progress.percent * 100); + } + + /** + * Start updater process + */ + async startUpdater() { + try { + this.SET_UPDATER_STATE(true); + this.SET_HIGHLIGHTER_VERSION(this.aiHighlighterUpdater.version || ''); + await this.aiHighlighterUpdater.update(progress => this.updateProgress(progress)); + } finally { + this.SET_UPDATER_STATE(false); + } + } + + /** + * Create milestones file if ids match and return path + */ + private async prepareMilestonesFile(streamId: string): Promise { + if ( + !this.streamMilestones || + this.streamMilestones.streamId !== streamId || + this.streamMilestones.milestones.length === 0 + ) { + return; + } + + const basepath = path.join(remote.app.getPath('userData'), 'ai-highlighter'); + const milestonesPath = path.join(basepath, 'milestones', 'milestones.json'); + + const milestonesData = JSON.stringify(this.streamMilestones.milestones); + await fs.outputFile(milestonesPath, milestonesData); + + return milestonesPath; + } } diff --git a/app/services/incremental-rollout.ts b/app/services/incremental-rollout.ts index 8a52d76f7b89..0ce94ce19ced 100644 --- a/app/services/incremental-rollout.ts +++ b/app/services/incremental-rollout.ts @@ -6,6 +6,7 @@ import { HostsService } from './hosts'; import Utils from 'services/utils'; import { InitAfter } from './core'; import { AppService } from './app'; +import { getOS, OS } from 'util/operating-systems'; export enum EAvailableFeatures { platform = 'slobs--platform', @@ -15,6 +16,7 @@ export enum EAvailableFeatures { restream = 'slobs--restream', tiktok = 'slobs--tiktok', highlighter = 'slobs--highlighter', + aiHighlighter = 'slobs--ai-highlighter', growTab = 'slobs--grow-tab', themeAudit = 'slobs--theme-audit', reactWidgets = 'slobs--react-widgets', @@ -26,7 +28,8 @@ export enum EAvailableFeatures { * availability at launch. */ guestCamBeta = 'slobs--guest-join', - guestCaProduction = 'slobs--guest-join-prod', + guestCamProduction = 'slobs--guest-join-prod', + newChatBox = 'core--widgets-v2--chat-box', } interface IIncrementalRolloutServiceState { @@ -113,6 +116,10 @@ class IncrementalRolloutView extends ViewHandler -1; } } diff --git a/app/services/magic-link.ts b/app/services/magic-link.ts index c3d2281c891c..47f58f0d775f 100644 --- a/app/services/magic-link.ts +++ b/app/services/magic-link.ts @@ -6,6 +6,7 @@ import { HostsService } from './hosts'; import * as remote from '@electron/remote'; import electron from 'electron'; import { UsageStatisticsService } from './usage-statistics'; +import { byOS, OS } from 'util/operating-systems'; interface ILoginTokenResponse { login_token: string; @@ -21,9 +22,12 @@ export class MagicLinkService extends Service { @Inject() hostsService: HostsService; @Inject() usageStatisticsService: UsageStatisticsService; - async getDashboardMagicLink(subPage = '', source?: string) { + async getDashboardMagicLink(subPage = '', source?: string, os?: string) { const token = (await this.fetchNewToken()).login_token; + // TODO: we really need `qs` or similar const sourceString = source ? `&refl=${source}` : ''; + const osString = os ? `&os=${os}` : ''; + if (subPage === 'multistream') { // TODO: remove this if statement when multistream settings are implemented return `https://${this.hostsService.streamlabs}/content-hub/post/how-to-multistream-the-ultimate-guide-to-multistreaming?login_token=${token}`; @@ -31,7 +35,7 @@ export class MagicLinkService extends Service { return `https://${this.hostsService.streamlabs}/slobs/magic/dashboard?login_token=${token}&r=${ subPage ?? '' - }${sourceString}`; + }${sourceString}${osString}`; } private fetchNewToken(): Promise { @@ -49,13 +53,17 @@ export class MagicLinkService extends Service { * @param refl a referral tag for analytics */ async linkToPrime(refl: string) { + // TODO: this is only here to acommodate ultra checkout A/B test requiring OS + // remove this and the parameter from {getDashboardMagicLink} after. + const os = byOS({ [OS.Windows]: 'windows', [OS.Mac]: 'mac' }); + if (!this.userService.views.isLoggedIn) { return remote.shell.openExternal( - `https://${this.hostsService.streamlabs}/ultra?refl=${refl}`, + `https://${this.hostsService.streamlabs}/ultra?refl=${refl}&os=${os}`, ); } try { - const link = await this.getDashboardMagicLink('prime', refl); + const link = await this.getDashboardMagicLink('prime', refl, os); remote.shell.openExternal(link); } catch (e: unknown) { console.error('Error generating dashboard magic link', e); diff --git a/app/services/media-backup.ts b/app/services/media-backup.ts index ab5a32620e91..c4ac73ba74c0 100644 --- a/app/services/media-backup.ts +++ b/app/services/media-backup.ts @@ -319,13 +319,13 @@ export class MediaBackupService extends StatefulService { return { Authorization: `Bearer ${this.userService.apiToken}` }; } - private ensureMediaDirectory() { + ensureMediaDirectory() { if (!fs.existsSync(this.mediaDirectory)) { fs.mkdirSync(this.mediaDirectory); } } - private get mediaDirectory() { + get mediaDirectory() { return path.join(this.appService.appDataDirectory, 'Media'); } diff --git a/app/services/navigation.ts b/app/services/navigation.ts index 48375389d079..fa1676015f2f 100644 --- a/app/services/navigation.ts +++ b/app/services/navigation.ts @@ -2,6 +2,7 @@ import { StatefulService, mutation } from './core/stateful-service'; import { Subject } from 'rxjs'; import { Inject } from 'services/core'; import { SideNavService } from 'app-services'; +import { EMenuItemKey } from './side-nav'; export type TAppPage = | 'Studio' @@ -34,7 +35,14 @@ export class NavigationService extends StatefulService { navigated = new Subject(); - navigate(page: TAppPage, params: Dictionary = {}) { + navigate( + page: TAppPage, + params: Dictionary = {}, + setMenuItem: EMenuItemKey | undefined = undefined, + ) { + if (setMenuItem) { + this.sideNavService.setCurrentMenuItem(setMenuItem); + } this.NAVIGATE(page, params); this.navigated.next(this.state); } diff --git a/app/services/notifications/notifications-api.ts b/app/services/notifications/notifications-api.ts index 3a42986b1b6a..ad27d0374542 100644 --- a/app/services/notifications/notifications-api.ts +++ b/app/services/notifications/notifications-api.ts @@ -16,6 +16,7 @@ export enum ENotificationSubType { LAGGED = 'LAGGED', SKIPPED = 'SKIPPED', NEWS = 'NEWS', + CPU = 'CPU', } export interface INotificationsSettings { diff --git a/app/services/onboarding.ts b/app/services/onboarding.ts index a035e6e47326..a59c0dcb1bd3 100644 --- a/app/services/onboarding.ts +++ b/app/services/onboarding.ts @@ -15,12 +15,8 @@ import Utils from './utils'; import { RecordingModeService } from './recording-mode'; import { THEME_METADATA, IThemeMetadata } from './onboarding/theme-metadata'; export type { IThemeMetadata } from './onboarding/theme-metadata'; -import { - StreamerKnowledgeMode, - isBeginnerOrIntermediateOrUnselected, -} from './onboarding/knowledge-mode'; import { TwitchStudioImporterService } from './ts-importer'; -export { StreamerKnowledgeMode } from './onboarding/knowledge-mode'; +import { DualOutputService } from 'services/dual-output'; enum EOnboardingSteps { MacPermissions = 'MacPermissions', @@ -34,7 +30,6 @@ enum EOnboardingSteps { // temporarily disable auto config until migrate to new api // Optimize = 'Optimize', Prime = 'Prime', - Tips = 'Tips', } const isMac = () => process.platform === OS.Mac; @@ -123,16 +118,9 @@ export const ONBOARDING_STEPS = () => ({ cond: ({ isUltra }: OnboardingStepContext) => !isUltra, isSkippable: true, }, - [EOnboardingSteps.Tips]: { - component: 'Tips' as const, - hideButton: true, - cond: isBeginnerOrIntermediateOrUnselected, - isSkippable: false, - }, }); export interface OnboardingStepContext { - streamerKnowledgeMode: StreamerKnowledgeMode | null; isPartialSLAuth: boolean; existingSceneCollections: boolean; isObsInstalled: boolean; @@ -156,8 +144,7 @@ export interface IOnboardingStep { | 'HardwareSetup' | 'ThemeSelector' | 'Optimize' - | 'Prime' - | 'Tips'; + | 'Prime'; hideButton?: boolean; label?: string; isPreboarding?: boolean; @@ -178,7 +165,6 @@ interface IOnboardingServiceState { options: IOnboardingOptions; importedFrom: 'obs' | 'twitch'; existingSceneCollections: boolean; - streamerKnowledgeMode: StreamerKnowledgeMode | null; } class OnboardingViews extends ViewHandler { @@ -204,13 +190,10 @@ class OnboardingViews extends ViewHandler { ).isTwitchStudioInstalled(); const recordingModeEnabled = this.getServiceViews(RecordingModeService).isRecordingModeEnabled; - const streamerKnowledgeMode = this.streamerKnowledgeMode; - const { existingSceneCollections, importedFrom } = this.state; const { isLoggedIn, isPrime: isUltra } = userViews; const ctx: OnboardingStepContext = { - streamerKnowledgeMode, recordingModeEnabled, existingSceneCollections, importedFrom, @@ -223,72 +206,27 @@ class OnboardingViews extends ViewHandler { isLoggedIn && getPlatformService(userViews.platform?.type)?.hasCapability('themes'), }; - return this.getStepsForMode(streamerKnowledgeMode)(ctx); + return this.makeSteps(ctx); } get totalSteps() { return this.steps.length; } - getStepsForMode(mode: StreamerKnowledgeMode) { + makeSteps(ctx: OnboardingStepContext) { const { getSteps } = this; - switch (mode) { - case StreamerKnowledgeMode.BEGINNER: - return getSteps([ - EOnboardingSteps.MacPermissions, - EOnboardingSteps.StreamingOrRecording, - EOnboardingSteps.Connect, - EOnboardingSteps.PrimaryPlatformSelect, - EOnboardingSteps.FreshOrImport, - EOnboardingSteps.ObsImport, - EOnboardingSteps.HardwareSetup, - EOnboardingSteps.ThemeSelector, - EOnboardingSteps.Prime, - EOnboardingSteps.Tips, - ]); - case StreamerKnowledgeMode.INTERMEDIATE: - /* - * Yes, these are the same as beginner, only inner screens are supposed to differ, - * but the one screen that was provided is currently disabled (Optimizer). - * Nevertheless, this sets the foundation for future changes. - */ - return getSteps([ - EOnboardingSteps.MacPermissions, - EOnboardingSteps.StreamingOrRecording, - EOnboardingSteps.Connect, - EOnboardingSteps.PrimaryPlatformSelect, - EOnboardingSteps.FreshOrImport, - EOnboardingSteps.ObsImport, - EOnboardingSteps.HardwareSetup, - EOnboardingSteps.ThemeSelector, - EOnboardingSteps.Prime, - EOnboardingSteps.Tips, - ]); - case StreamerKnowledgeMode.ADVANCED: - return getSteps([ - EOnboardingSteps.MacPermissions, - EOnboardingSteps.StreamingOrRecording, - EOnboardingSteps.Connect, - EOnboardingSteps.PrimaryPlatformSelect, - EOnboardingSteps.FreshOrImport, - EOnboardingSteps.ObsImport, - EOnboardingSteps.HardwareSetup, - EOnboardingSteps.Prime, - ]); - default: - return getSteps([ - EOnboardingSteps.MacPermissions, - EOnboardingSteps.StreamingOrRecording, - EOnboardingSteps.Connect, - EOnboardingSteps.PrimaryPlatformSelect, - EOnboardingSteps.FreshOrImport, - EOnboardingSteps.ObsImport, - EOnboardingSteps.HardwareSetup, - EOnboardingSteps.ThemeSelector, - EOnboardingSteps.Prime, - ]); - } + return getSteps([ + EOnboardingSteps.MacPermissions, + EOnboardingSteps.StreamingOrRecording, + EOnboardingSteps.Connect, + EOnboardingSteps.PrimaryPlatformSelect, + EOnboardingSteps.FreshOrImport, + EOnboardingSteps.ObsImport, + EOnboardingSteps.HardwareSetup, + EOnboardingSteps.ThemeSelector, + EOnboardingSteps.Prime, + ])(ctx); } getSteps(stepNames: EOnboardingSteps[]) { @@ -308,10 +246,6 @@ class OnboardingViews extends ViewHandler { }, [] as IOnboardingStep[]); }; } - - get streamerKnowledgeMode() { - return this.state.streamerKnowledgeMode; - } } export class OnboardingService extends StatefulService { @@ -324,7 +258,6 @@ export class OnboardingService extends StatefulService }, importedFrom: null, existingSceneCollections: false, - streamerKnowledgeMode: null, }; localStorageKey = 'UserHasBeenOnboarded'; @@ -335,6 +268,7 @@ export class OnboardingService extends StatefulService @Inject() userService: UserService; @Inject() sceneCollectionsService: SceneCollectionsService; @Inject() outputSettingsService: OutputSettingsService; + @Inject() dualOutputService: DualOutputService; @mutation() SET_OPTIONS(options: Partial) { @@ -351,11 +285,6 @@ export class OnboardingService extends StatefulService this.state.existingSceneCollections = val; } - @mutation() - SET_STREAMER_KNOWLEDGE_MODE(val: StreamerKnowledgeMode) { - this.state.streamerKnowledgeMode = val; - } - async fetchThemeData(id: string) { const url = `https://overlays.streamlabs.com/api/overlay/${id}`; return jfetch(url); @@ -365,7 +294,7 @@ export class OnboardingService extends StatefulService return await Promise.all(Object.keys(this.themeMetadata).map(id => this.fetchThemeData(id))); } - get themeMetadata() { + get themeMetadata(): { [id: number]: string } { return this.userService.views.isPrime ? THEME_METADATA.PAID : THEME_METADATA.FREE; } @@ -388,6 +317,33 @@ export class OnboardingService extends StatefulService ); } + get shouldAddDefaultSources() { + // Add default sources if the user did not install a theme during onboarding + if (!this.existingSceneCollections) return true; + + // Skip checking creation date for accounts in when testing + if (Utils.isTestMode()) return false; + + // If the user does not have a creation date, they are a new user. Check to see if this + // new user has installed a theme during onboarding + const creationDate = this.userService.state?.createdAt; + if (!creationDate && !this.existingSceneCollections) { + return true; + } + + // Otherwise, check if the user is within the first 6 hours of their + // account creation date/time. This is last resort very rough check to determine + // if the user is a new user. Not ideal but better than nothing. + const now = new Date().getTime(); + const creationTime = new Date(creationDate).getTime(); + const millisecondsInAnHour = 1000 * 60 * 60; + + const isWithinCreationDateRange = + creationTime < now && creationTime - now < millisecondsInAnHour * 6; + + return isWithinCreationDateRange && !this.existingSceneCollections; + } + init() { this.setExistingCollections(); } @@ -400,10 +356,6 @@ export class OnboardingService extends StatefulService this.SET_EXISTING_COLLECTIONS(this.existingSceneCollections); } - setStreamerKnowledgeMode(val: StreamerKnowledgeMode | null) { - this.SET_STREAMER_KNOWLEDGE_MODE(val); - } - start(options: Partial = {}) { const actualOptions: IOnboardingOptions = { isLogin: false, @@ -434,6 +386,12 @@ export class OnboardingService extends StatefulService }); } + // On their first login, new users should have a few default sources if they did not + // select a scene collection during onboarding. + if (this.sceneCollectionsService.newUserFirstLogin) { + this.sceneCollectionsService.setupDefaultSources(this.shouldAddDefaultSources); + } + this.navigationService.navigate('Studio'); this.onboardingCompleted.next(); } diff --git a/app/services/onboarding/knowledge-mode.ts b/app/services/onboarding/knowledge-mode.ts deleted file mode 100644 index ee168ed49c3d..000000000000 --- a/app/services/onboarding/knowledge-mode.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { OnboardingStepContext } from '../onboarding'; - -export enum StreamerKnowledgeMode { - BEGINNER = 'BEGINNER', - INTERMEDIATE = 'INTERMEDIATE', - ADVANCED = 'ADVANCED', -} - -export const isBeginnerOrIntermediateOrUnselected = ({ - streamerKnowledgeMode, -}: OnboardingStepContext) => - !streamerKnowledgeMode || - [StreamerKnowledgeMode.BEGINNER, StreamerKnowledgeMode.INTERMEDIATE].includes( - streamerKnowledgeMode, - ); - -export const isIntermediateOrAdvancedOrUnselected = ({ - streamerKnowledgeMode, -}: OnboardingStepContext) => - !streamerKnowledgeMode || - [StreamerKnowledgeMode.INTERMEDIATE, StreamerKnowledgeMode.ADVANCED].includes( - streamerKnowledgeMode, - ); diff --git a/app/services/onboarding/theme-metadata.ts b/app/services/onboarding/theme-metadata.ts index 77da4d97c46b..3af63fd36641 100644 --- a/app/services/onboarding/theme-metadata.ts +++ b/app/services/onboarding/theme-metadata.ts @@ -3,17 +3,29 @@ export interface IThemeMetadata { id: number; name: string; custom_images: Dictionary; + designer?: { + name: string; + avatar: string; + website: string; + }; }; } +// churn count: 2, if we need to change this one more time we're gonna need an API :D export const THEME_METADATA = { FREE: { - 2560: 'https://cdn.streamlabs.com/marketplace/overlays/7684923/0a2acb8/0a2acb8.overlay', - 2559: 'https://cdn.streamlabs.com/marketplace/overlays/7684923/6dcbf5f/6dcbf5f.overlay', - 2624: 'https://cdn.streamlabs.com/marketplace/overlays/7684923/eeeb9e1/eeeb9e1.overlay', - 2657: 'https://cdn.streamlabs.com/marketplace/overlays/7684923/0697cee/0697cee.overlay', - 2656: 'https://cdn.streamlabs.com/marketplace/overlays/7684923/59acc9a/59acc9a.overlay', - 2639: 'https://cdn.streamlabs.com/marketplace/overlays/7684923/a1a4ab0/a1a4ab0.overlay', + // Purple Burst 1658 + 2639: 'https://cdn.streamlabs.com/marketplace/overlays/60327649/5a2ad75/5a2ad75.overlay', + // Streamlabs Neon 1645 + 2624: 'https://cdn.streamlabs.com/marketplace/overlays/4921216/483af56/483af56.overlay', + // Streamlabs Dark Mode 1583 + 2559: 'https://cdn.streamlabs.com/marketplace/overlays/4921216/1b2f6cc/1b2f6cc.overlay', + // Streamlabs Light Mode 1584 + 2560: 'https://cdn.streamlabs.com/marketplace/overlays/4921216/fb61932/fb61932.overlay', + // Red Glitch 1673 + 2656: 'https://cdn.streamlabs.com/marketplace/overlays/60327649/9259e55/9259e55.overlay', + // Midnight Red 1674 + 2657: 'https://cdn.streamlabs.com/marketplace/overlays/60327649/985817a/985817a.overlay', }, PAID: { // Waves (paid version), free: 3216 diff --git a/app/services/performance.ts b/app/services/performance.ts index 81d26d22a99a..7f16d61fb28e 100644 --- a/app/services/performance.ts +++ b/app/services/performance.ts @@ -14,6 +14,7 @@ import { TroubleshooterService, TIssueCode } from 'services/troubleshooter'; import { $t } from 'services/i18n'; import { StreamingService, EStreamingState } from 'services/streaming'; import { VideoSettingsService } from 'services/settings-v2/video'; +import { DualOutputService } from 'services/dual-output'; import { UsageStatisticsService } from './usage-statistics'; interface IPerformanceState { @@ -44,6 +45,7 @@ interface INextStats { framesRendered: number; laggedFactor: number; droppedFramesFactor: number; + cpu: number; } // How frequently parformance stats should be updated @@ -54,6 +56,8 @@ const NOTIFICATION_THROTTLE_INTERVAL = 2 * 60 * 1000; const SAMPLING_DURATION = 2 * 60 * 1000; // How many samples we should take const NUMBER_OF_SAMPLES = Math.round(SAMPLING_DURATION / STATS_UPDATE_INTERVAL); +// Limit on interval between CPU usage notifications +const CPU_NOTIFICATION_THROTTLE_INTERVAL = 10 * 60 * 1000; interface IMonitorState { framesLagged: number; @@ -110,6 +114,7 @@ export class PerformanceService extends StatefulService { @Inject() private streamingService: StreamingService; @Inject() private usageStatisticsService: UsageStatisticsService; @Inject() private videoSettingsService: VideoSettingsService; + @Inject() private dualOutputService: DualOutputService; static initialState: IPerformanceState = { CPU: 0, @@ -128,6 +133,7 @@ export class PerformanceService extends StatefulService { private historicalDroppedFrames: number[] = []; private historicalSkippedFrames: number[] = []; private historicalLaggedFrames: number[] = []; + private historicalCPU: number[] = []; private shutdown = false; private statsRequestInProgress = false; @@ -142,7 +148,7 @@ export class PerformanceService extends StatefulService { @mutation() private SET_PERFORMANCE_STATS(stats: Partial) { - Object.keys(stats).forEach(stat => { + Object.keys(stats).forEach((stat: keyof Partial) => { Vue.set(this.state, stat, stats[stat]); }); } @@ -201,6 +207,7 @@ export class PerformanceService extends StatefulService { this.streamStartRenderedFrames = obs.Global.totalFrames; this.streamStartEncodedFrames = this.videoSettingsService.contexts.horizontal.encodedFrames; this.streamStartTime = new Date(); + this.historicalCPU = []; } stopStreamQualityMonitoring() { @@ -216,12 +223,16 @@ export class PerformanceService extends StatefulService { 100; const streamDropped = this.state.percentageDroppedFrames; const streamDuration = new Date().getTime() - this.streamStartTime.getTime(); + const averageCPU = this.averageFactor(this.historicalCPU); + const streamType = this.dualOutputService.views.dualOutputMode ? 'dual' : 'single'; this.usageStatisticsService.recordAnalyticsEvent('StreamPerformance', { streamLagged, streamSkipped, streamDropped, streamDuration, + averageCPU, + streamType, }); } @@ -243,6 +254,14 @@ export class PerformanceService extends StatefulService { this.addSample(this.historicalSkippedFrames, nextStats.skippedFactor); this.addSample(this.historicalLaggedFrames, nextStats.laggedFactor); + // only track CPU when live in dual output mode + if ( + this.dualOutputService.views.dualOutputMode && + this.streamingService.views.isMidStreamMode + ) { + this.addSample(this.historicalCPU, nextStats.cpu); + } + this.sendNotifications(currentStats, nextStats); this.SET_PERFORMANCE_STATS({ @@ -266,6 +285,8 @@ export class PerformanceService extends StatefulService { const droppedFramesFactor = this.state.percentageDroppedFrames / 100; + const cpu = this.state.CPU; + return { framesSkipped, framesEncoded, @@ -274,6 +295,7 @@ export class PerformanceService extends StatefulService { framesRendered, laggedFactor, droppedFramesFactor, + cpu, }; } @@ -289,7 +311,6 @@ export class PerformanceService extends StatefulService { } checkNotification(target: number, record: number[]) { - if (record.length < NUMBER_OF_SAMPLES) return false; return this.averageFactor(record) >= target; } @@ -324,20 +345,37 @@ export class PerformanceService extends StatefulService { ) { this.pushDroppedFramesNotify(this.averageFactor(this.historicalDroppedFrames)); } + + // only show CPU usage notifications when live in dual output mode + if ( + this.dualOutputService.views.dualOutputMode && + this.streamingService.views.isMidStreamMode && + troubleshooterSettings.dualOutputCpuEnabled && + this.state.CPU > troubleshooterSettings.dualOutputCpuThreshold * 100 + ) { + this.pushDualOutputHighCPUNotify(this.state.CPU); + } } - @throttle(NOTIFICATION_THROTTLE_INTERVAL) - private pushSkippedFramesNotify(factor: number) { - const code: TIssueCode = 'FRAMES_SKIPPED'; + @throttle(CPU_NOTIFICATION_THROTTLE_INTERVAL) + private pushDualOutputHighCPUNotify(factor: number) { + const code: TIssueCode = 'HIGH_CPU_USAGE'; + + const message = + factor > 50 + ? $t('High CPU Usage: Detected') + : $t('High CPU Usage: %{percentage}% used', { + percentage: factor.toFixed(1), + }); + this.notificationsService.push({ code, type: ENotificationType.WARNING, data: factor, lifeTime: 2 * 60 * 1000, showTime: true, - subType: ENotificationSubType.SKIPPED, - // tslint:disable-next-line:prefer-template - message: $t('Skipped frames detected:') + Math.round(factor * 100) + '% over last 2 minutes', + subType: ENotificationSubType.CPU, + message, action: this.jsonrpcService.createRequest( Service.getResourceId(this.troubleshooterService), 'showTroubleshooter', @@ -384,6 +422,26 @@ export class PerformanceService extends StatefulService { }); } + @throttle(NOTIFICATION_THROTTLE_INTERVAL) + private pushSkippedFramesNotify(factor: number) { + const code: TIssueCode = 'FRAMES_SKIPPED'; + this.notificationsService.push({ + code, + type: ENotificationType.WARNING, + data: factor, + lifeTime: 2 * 60 * 1000, + showTime: true, + subType: ENotificationSubType.SKIPPED, + // tslint:disable-next-line:prefer-template + message: $t('Skipped frames detected:') + Math.round(factor * 100) + '% over last 2 minutes', + action: this.jsonrpcService.createRequest( + Service.getResourceId(this.troubleshooterService), + 'showTroubleshooter', + code, + ), + }); + } + stop() { this.shutdown = true; } diff --git a/app/services/platform-apps/api/modules/notifications.ts b/app/services/platform-apps/api/modules/notifications.ts index 88a4e28e1099..d7c539eadc23 100644 --- a/app/services/platform-apps/api/modules/notifications.ts +++ b/app/services/platform-apps/api/modules/notifications.ts @@ -3,7 +3,7 @@ import { Inject } from 'services/core/injector'; import { Subject } from 'rxjs'; import { INotificationsServiceApi } from 'services/notifications'; -type TIssueCode = 'FRAMES_LAGGED' | 'FRAMES_SKIPPED' | 'FRAMES_DROPPED'; +type TIssueCode = 'FRAMES_LAGGED' | 'FRAMES_SKIPPED' | 'FRAMES_DROPPED' | 'HIGH_CPU_USAGE'; enum ENotificationType { INFO = 'INFO', @@ -18,6 +18,7 @@ enum ENotificationSubType { LAGGED = 'LAGGED', SKIPPED = 'SKIPPED', NEWS = 'NEWS', + CPU = 'CPU', } interface INotificationOptions { diff --git a/app/services/platform-apps/container-manager.ts b/app/services/platform-apps/container-manager.ts index 5ca9bf22c05f..c28a7c46b888 100644 --- a/app/services/platform-apps/container-manager.ts +++ b/app/services/platform-apps/container-manager.ts @@ -332,6 +332,7 @@ export class PlatformContainerManager { 'static.twitchcdn.net', 'www.google.com', 'www.gstatic.com', + 'assets.twitch.tv', ]; const parsed = url.parse(details.url); diff --git a/app/services/platforms/index.ts b/app/services/platforms/index.ts index 2e262f535ed1..506758e9961f 100644 --- a/app/services/platforms/index.ts +++ b/app/services/platforms/index.ts @@ -10,6 +10,7 @@ import { WidgetType } from '../widgets'; import { ITrovoStartStreamOptions, TrovoService } from './trovo'; import { TDisplayType } from 'services/settings-v2'; import { $t } from 'services/i18n'; +import { KickService, IKickStartStreamOptions } from './kick'; export type Tag = string; export interface IGame { @@ -151,7 +152,8 @@ export type TStartStreamOptions = | Partial | Partial | Partial - | Partial; + | Partial + | Partial; // state applicable for all platforms export interface IPlatformState { @@ -242,6 +244,7 @@ export enum EPlatform { Trovo = 'trovo', Twitter = 'twitter', Instagram = 'instagram', + Kick = 'kick', } export type TPlatform = @@ -251,7 +254,8 @@ export type TPlatform = | 'tiktok' | 'trovo' | 'twitter' - | 'instagram'; + | 'instagram' + | 'kick'; export const platformList = [ EPlatform.Facebook, @@ -261,6 +265,7 @@ export const platformList = [ EPlatform.YouTube, EPlatform.Twitter, EPlatform.Instagram, + EPlatform.Kick, ]; export const platformLabels = (platform: TPlatform | string) => @@ -273,6 +278,7 @@ export const platformLabels = (platform: TPlatform | string) => // TODO: translate [EPlatform.Twitter]: 'Twitter', [EPlatform.Instagram]: $t('Instagram'), + [EPlatform.Kick]: $t('Kick'), }[platform]); export function getPlatformService(platform?: TPlatform): IPlatformService { @@ -283,6 +289,7 @@ export function getPlatformService(platform?: TPlatform): IPlatformService { facebook: FacebookService.instance, tiktok: TikTokService.instance, trovo: TrovoService.instance, + kick: KickService.instance, twitter: TwitterPlatformService.instance, instagram: InstagramService.instance, }[platform]; diff --git a/app/services/platforms/kick.ts b/app/services/platforms/kick.ts new file mode 100644 index 000000000000..81667f59ad61 --- /dev/null +++ b/app/services/platforms/kick.ts @@ -0,0 +1,294 @@ +import { InheritMutations, Inject, mutation } from '../core'; +import { BasePlatformService } from './base-platform'; +import { IPlatformRequest, IPlatformService, IPlatformState, TPlatformCapability } from './index'; +import { authorizedHeaders, jfetch } from '../../util/requests'; +import { throwStreamError } from '../streaming/stream-error'; +import { platformAuthorizedRequest } from './utils'; +import { IGoLiveSettings } from '../streaming'; +import { TOutputOrientation } from 'services/restream'; +import { IVideo } from 'obs-studio-node'; +import { TDisplayType } from 'services/settings-v2'; +import { I18nService } from 'services/i18n'; +import { getDefined } from 'util/properties-type-guards'; +import { WindowsService } from 'services/windows'; +import { DiagnosticsService } from 'services/diagnostics'; + +interface IKickStartStreamResponse { + id?: string; + key: string; + rtmp: string; + chat_url: string; + broadcast_id?: string | null; + channel_name: string; + platform_id: string; + region?: string; + chat_id?: string; +} +interface IKickEndStreamResponse { + id: string; +} + +interface IKickError { + success: boolean; + error: boolean; + message: string; + data: any[]; +} +interface IKickServiceState extends IPlatformState { + settings: IKickStartStreamSettings; + ingest: string; + chatUrl: string; + channelName: string; + platformId?: string; +} + +interface IKickStartStreamSettings { + title: string; + display: TDisplayType; + video?: IVideo; + mode?: TOutputOrientation; +} + +export interface IKickStartStreamOptions { + title: string; +} + +interface IKickRequestHeaders extends Dictionary { + Accept: string; + 'Content-Type': string; + Authorization: string; +} + +@InheritMutations() +export class KickService + extends BasePlatformService + implements IPlatformService { + static initialState: IKickServiceState = { + ...BasePlatformService.initialState, + settings: { + title: '', + display: 'horizontal', + mode: 'landscape', + }, + ingest: '', + chatUrl: '', + channelName: '', + }; + + @Inject() windowsService: WindowsService; + @Inject() diagnosticsService: DiagnosticsService; + + readonly apiBase = ''; + readonly domain = 'https://kick.com'; + readonly platform = 'kick'; + readonly displayName = 'Kick'; + readonly capabilities = new Set(['chat']); + + authWindowOptions: Electron.BrowserWindowConstructorOptions = { + width: 600, + height: 800, + }; + + private get oauthToken() { + return this.userService.views.state.auth?.platforms?.kick?.token; + } + + async beforeGoLive(goLiveSettings: IGoLiveSettings, display?: TDisplayType) { + const kickSettings = getDefined(goLiveSettings.platforms.kick); + const context = display ?? kickSettings?.display; + + try { + const streamInfo = await this.startStream( + goLiveSettings.platforms.kick ?? this.state.settings, + ); + + this.SET_INGEST(streamInfo.rtmp); + this.SET_STREAM_KEY(streamInfo.key); + this.SET_CHAT_URL(streamInfo.chat_url); + this.SET_PLATFORM_ID(streamInfo.platform_id); + + if (!this.streamingService.views.isMultiplatformMode) { + this.streamSettingsService.setSettings( + { + streamType: 'rtmp_custom', + key: streamInfo.key, + server: streamInfo.rtmp, + }, + context, + ); + } + + await this.putChannelInfo(kickSettings); + this.setPlatformContext('kick'); + } catch (e: unknown) { + console.error('Error starting stream: ', e); + throwStreamError('PLATFORM_REQUEST_FAILED', e as any); + } + } + + async afterStopStream(): Promise { + // clear server url and stream key + this.SET_INGEST(''); + this.SET_STREAM_KEY(''); + } + + // Note, this needs to be here but should never be called, because we + // currently don't make any calls directly to Kick + async fetchNewToken(): Promise { + const host = this.hostsService.streamlabs; + const url = `https://${host}/api/v5/slobs/kick/refresh`; + const headers = authorizedHeaders(this.userService.apiToken!); + const request = new Request(url, { headers }); + + return jfetch<{ access_token: string }>(request) + .then(response => { + return this.userService.updatePlatformToken('kick', response.access_token); + }) + .catch(e => { + console.error('Error fetching new token.'); + return Promise.reject(e); + }); + } + + /** + * Request Kick API and wrap failed response to a unified error model + */ + async requestKick(reqInfo: IPlatformRequest | string): Promise { + try { + return await platformAuthorizedRequest('kick', reqInfo); + } catch (e: unknown) { + const code = (e as any).result?.error?.code; + + const details = (e as any).result?.error + ? `${(e as any).result.error.type} ${(e as any).result.error.message}` + : 'Connection failed'; + + console.error('Error fetching Kick API: ', details, code); + + return Promise.reject(e); + } + } + + /** + * Starts the stream + * @remark If a user is live and attempts to go live via another + * another streaming method such as Kick's app, this stream will continue + * and the other stream will be prevented from going live. If another instance + * of Streamlabs attempts to go live to Kick, the first stream will be ended + * and Desktop will enter a reconnecting state, which eventually times out. + */ + async startStream(opts: IKickStartStreamOptions): Promise { + const host = this.hostsService.streamlabs; + const url = `https://${host}/api/v5/slobs/kick/stream/start`; + const headers = authorizedHeaders(this.userService.apiToken!); + + const body = new FormData(); + body.append('title', opts.title); + + const request = new Request(url, { headers, method: 'POST', body }); + + return jfetch(request).catch((e: IKickError | unknown) => { + console.error('Error starting Kick stream: ', e); + + const defaultError = { + status: 403, + statusText: 'Unable to start Kick stream.', + }; + + if (!e) throwStreamError('PLATFORM_REQUEST_FAILED', defaultError); + + // check if the error is an IKickError + if (typeof e === 'object' && e.hasOwnProperty('success')) { + const error = e as IKickError; + throwStreamError( + 'PLATFORM_REQUEST_FAILED', + { + ...error, + status: 403, + statusText: error.message, + }, + defaultError.statusText, + ); + } + + throwStreamError('PLATFORM_REQUEST_FAILED', e as any, defaultError.statusText); + }); + } + + async endStream(id: string) { + const host = this.hostsService.streamlabs; + const url = `https://${host}/api/v5/slobs/kick/stream/${id}/end`; + const headers = authorizedHeaders(this.userService.apiToken!); + const request = new Request(url, { headers, method: 'POST' }); + + return jfetch(request); + } + + /** + * prepopulate channel info and save it to the store + */ + async prepopulateInfo(): Promise { + this.SET_PREPOPULATED(true); + } + + async putChannelInfo(settings: IKickStartStreamOptions): Promise { + this.SET_STREAM_SETTINGS(settings); + } + + getHeaders(req: IPlatformRequest, useToken?: string | boolean): IKickRequestHeaders { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.oauthToken}`, + }; + } + + get authUrl() { + const host = this.hostsService.streamlabs; + const query = `_=${Date.now()}&skip_splash=true&external=electron&kick&force_verify&origin=slobs`; + return `https://${host}/slobs/login?${query}`; + } + + get mergeUrl(): string { + const host = this.hostsService.streamlabs; + return `https://${host}/dashboard#/settings/account-settings/platforms`; + } + + get liveDockEnabled(): boolean { + return true; + } + + get chatUrl(): string { + return this.state.chatUrl; + } + + get dashboardUrl(): string { + return `https://dashboard.${this.domain.split('//')[1]}/stream`; + } + + get streamPageUrl(): string { + const username = this.userService.state.auth?.platforms?.kick?.username; + if (!username) return ''; + + return `${this.domain}/${username}`; + } + + get locale(): string { + return I18nService.instance.state.locale; + } + + @mutation() + SET_INGEST(ingest: string) { + this.state.ingest = ingest; + } + + @mutation() + SET_CHAT_URL(chatUrl: string) { + this.state.chatUrl = chatUrl; + } + + @mutation() + SET_PLATFORM_ID(platformId: string) { + this.state.platformId = platformId; + } +} diff --git a/app/services/platforms/tiktok.ts b/app/services/platforms/tiktok.ts index 686af68b8e9f..48394a751b13 100644 --- a/app/services/platforms/tiktok.ts +++ b/app/services/platforms/tiktok.ts @@ -9,7 +9,12 @@ import { TPlatformCapability, } from './index'; import { authorizedHeaders, jfetch } from '../../util/requests'; -import { throwStreamError } from '../streaming/stream-error'; +import { + throwStreamError, + StreamError, + errorTypes, + TStreamErrorType, +} from '../streaming/stream-error'; import { platformAuthorizedRequest } from './utils'; import { getOS } from 'util/operating-systems'; import { IGoLiveSettings } from '../streaming'; @@ -25,6 +30,7 @@ import { ITikTokStartStreamResponse, TTikTokLiveScopeTypes, ITikTokGamesData, + ITikTokAudienceControlsInfo, } from './tiktok/api'; import { $t, I18nService } from 'services/i18n'; import { getDefined } from 'util/properties-type-guards'; @@ -35,6 +41,7 @@ import { UsageStatisticsService } from 'services/usage-statistics'; import { DiagnosticsService } from 'services/diagnostics'; import { ENotificationType, NotificationsService } from 'services/notifications'; import { JsonrpcService } from '../api/jsonrpc'; + interface ITikTokServiceState extends IPlatformState { settings: ITikTokStartStreamSettings; broadcastId: string; @@ -42,7 +49,9 @@ interface ITikTokServiceState extends IPlatformState { error?: string | null; gameName: string; dateDenied?: string | null; + audienceControlsInfo: ITikTokAudienceControls; } + interface ITikTokStartStreamSettings { serverUrl: string; streamKey: string; @@ -50,17 +59,26 @@ interface ITikTokStartStreamSettings { liveScope: TTikTokLiveScopeTypes; game: string; display: TDisplayType; + audienceType?: string; video?: IVideo; mode?: TOutputOrientation; } +interface ITikTokAudienceControls { + disable: boolean; + audienceType: string; + types: { value: string; label: string }[]; +} + export interface ITikTokStartStreamOptions { title: string; serverUrl: string; streamKey: string; display: TDisplayType; game: string; + audienceType?: string; } + interface ITikTokRequestHeaders extends Dictionary { Accept: string; 'Content-Type': string; @@ -85,6 +103,7 @@ export class TikTokService broadcastId: '', username: '', gameName: '', + audienceControlsInfo: { disable: true, audienceType: '0', types: [] }, }; @Inject() windowsService: WindowsService; @@ -122,14 +141,31 @@ export class TikTokService return ['approved', 'legacy'].includes(scope); } + get missingLiveAccess(): boolean { + const scope = this.state.settings?.liveScope ?? 'never-applied'; + return ['legacy', 'never-applied'].includes(scope); + } + get approved(): boolean { return this.state.settings.liveScope === 'approved'; } + get neverApplied(): boolean { + return this.state.settings.liveScope === 'never-applied'; + } + get denied(): boolean { return this.state.settings.liveScope === 'denied'; } + get legacy(): boolean { + return this.state.settings.liveScope === 'legacy'; + } + + get relog(): boolean { + return this.state.settings.liveScope === 'relog'; + } + get defaultGame(): IGame { return { id: 'tiktok-other', name: 'Other' }; } @@ -155,6 +191,10 @@ export class TikTokService return 0; } + get audienceControls() { + return this.state.audienceControlsInfo; + } + async beforeGoLive(goLiveSettings: IGoLiveSettings, display?: TDisplayType) { // return an approved dummy account when testing if (Utils.isTestMode() && this.getHasScope('approved')) { @@ -168,18 +208,12 @@ export class TikTokService if (this.getHasScope('approved')) { // update server url and stream key if handling streaming via API // streaming with server url and stream key is default - let streamInfo = {} as ITikTokStartStreamResponse; + const streamInfo = await this.startStream(ttSettings); - try { - streamInfo = await this.startStream(ttSettings); - if (!streamInfo?.id) { - await this.handleOpenLiveManager(); - throwStreamError('TIKTOK_GENERATE_CREDENTIALS_FAILED'); - } - } catch (error: unknown) { - this.SET_LIVE_SCOPE('relog'); + // if the stream did not start successfully, prevent going live + if (!streamInfo?.id) { await this.handleOpenLiveManager(); - throwStreamError('TIKTOK_GENERATE_CREDENTIALS_FAILED', error as any); + throwStreamError('TIKTOK_GENERATE_CREDENTIALS_FAILED'); } ttSettings.serverUrl = streamInfo.rtmp; @@ -322,10 +356,11 @@ export class TikTokService * of Streamlabs attempts to go live to TikTok, the first stream will be ended * and Desktop will enter a reconnecting state, which eventually times out. */ - async startStream(opts: ITikTokStartStreamOptions) { + async startStream(opts: ITikTokStartStreamOptions): Promise { const host = this.hostsService.streamlabs; const url = `https://${host}/api/v5/slobs/tiktok/stream/start`; const headers = authorizedHeaders(this.userService.apiToken!); + const body = new FormData(); body.append('title', opts.title); body.append('device_platform', getOS()); @@ -334,9 +369,20 @@ export class TikTokService const game = opts.game === this.defaultGame.id ? '' : opts.game; body.append('category', game); + if (opts?.audienceType) { + body.append('audience_type', opts.audienceType); + } + const request = new Request(url, { headers, method: 'POST', body }); - return jfetch(request); + return jfetch(request).catch((e: unknown) => { + if (e instanceof StreamError) { + throwStreamError('TIKTOK_GENERATE_CREDENTIALS_FAILED', e as any); + } + + const error = this.handleStartStreamError((e as ITikTokError)?.status); + throwStreamError(error.type, { status: error.status }); + }); } async endStream(id: string) { @@ -386,11 +432,26 @@ export class TikTokService const response = await this.fetchLiveAccessStatus(); const status = response as ITikTokLiveScopeResponse; + const scope = this.convertScope(status.reason, status.application_status?.status); + this.SET_LIVE_SCOPE(scope); + + if (status?.audience_controls_info) { + this.setAudienceControls(status.audience_controls_info); + } + + if (status?.application_status) { + const applicationStatus = status.application_status?.status; + const timestamp = status.application_status?.timestamp; + + // show prompt to apply if user has never applied or was rejected 30+ days ago + if (applicationStatus === 'rejected' && timestamp) { + this.SET_DENIED_DATE(timestamp); + return EPlatformCallResult.TikTokStreamScopeMissing; + } + } if (status?.user) { - const scope = this.convertScope(status.reason); this.SET_USERNAME(status.user.username); - this.SET_LIVE_SCOPE(scope); // Note on the 'relog' response: A user who needs to reauthenticate with TikTok // due a change in the scope for our api, needs to be told to unlink and remerge their account. @@ -404,7 +465,6 @@ export class TikTokService this.SET_LIVE_SCOPE('relog'); return EPlatformCallResult.TikTokScopeOutdated; } else { - this.SET_LIVE_SCOPE('denied'); return EPlatformCallResult.TikTokStreamScopeMissing; } @@ -452,8 +512,8 @@ export class TikTokService } return res; }) - .catch(() => { - console.warn('Error fetching TikTok Live Access status.'); + .catch(e => { + console.warn('Error fetching TikTok Live Access status: ', e); }); } @@ -496,14 +556,6 @@ export class TikTokService console.debug('TikTok stream status: ', status); - // track the first date the user registered as denied so that after 30 days - // they are prompted to reapply - if (status === EPlatformCallResult.TikTokStreamScopeMissing && !this.state.dateDenied) { - this.SET_DENIED_DATE(new Date().toISOString()); - } else { - this.SET_DENIED_DATE(); - } - if (status === EPlatformCallResult.TikTokScopeOutdated) { throwStreamError('TIKTOK_SCOPE_OUTDATED'); } @@ -614,7 +666,7 @@ export class TikTokService get promptApply(): boolean { // never show for approved/legacy users or logged out users if ( - !this.getHasScope('denied') || + !this.getHasScope('never-applied') || !this.userService.state?.createdAt || !this.userService.isLoggedIn ) { @@ -642,7 +694,7 @@ export class TikTokService get promptReapply(): boolean { // prompt a user to reapply if they were rejected 30+ days ago - if (!this.state.dateDenied) return false; + if (!this.getHasScope('denied') || !this.state.dateDenied) return false; const today = new Date(Date.now()); const deniedDate = new Date(this.state.dateDenied); @@ -652,7 +704,11 @@ export class TikTokService return false; } - convertScope(scope: number) { + convertScope(scope: number, applicationStatus?: string): TTikTokLiveScopeTypes { + if (applicationStatus === 'never_applied' && scope !== ETikTokLiveScopeReason.APPROVED_OBS) { + return 'never-applied'; + } + switch (scope) { case ETikTokLiveScopeReason.APPROVED: { return 'approved'; @@ -697,6 +753,39 @@ export class TikTokService }, 1000); } + handleStartStreamError(status?: number) { + const title = $t('TikTok Stream Error'); + const type: TStreamErrorType = + status === 422 ? 'TIKTOK_USER_BANNED' : 'TIKTOK_GENERATE_CREDENTIALS_FAILED'; + const message = errorTypes[type].message; + + const buttonText = $t('Open TikTok Live Manager'); + + if (type !== 'TIKTOK_USER_BANNED') { + this.SET_LIVE_SCOPE('relog'); + this.handleOpenLiveManager(); + } else { + this.SET_LIVE_SCOPE('denied'); + } + + remote.dialog + .showMessageBox(Utils.getMainWindow(), { + title, + type: 'error', + message, + buttons: [buttonText, $t('Close')], + }) + .then(({ response }) => { + if (response === 0) { + remote.shell.openExternal(this.dashboardUrl); + } + }); + + this.windowsService.actions.closeChildWindow(); + + return { type, status }; + } + /** * Test going live with approved status for TikTok * @param goLiveSettings - all goLiveSettings @@ -718,6 +807,22 @@ export class TikTokService this.SET_GAME_NAME(gameName); } + setAudienceControls(audienceControlsInfo: ITikTokAudienceControlsInfo) { + // convert audience types to match the ListInput component options + const types = audienceControlsInfo.types.map(type => ({ + value: type.key.toString(), + // TODO: revisit as to why cast is needed here, `string|null` on type def + label: type.label as string, + })); + const audienceType = audienceControlsInfo.info_type.toString(); + + this.SET_AUDIENCE_CONTROLS({ + ...audienceControlsInfo, + audienceType, + types, + }); + } + @mutation() SET_LIVE_SCOPE(scope: TTikTokLiveScopeTypes) { this.state.settings.liveScope = scope; @@ -742,4 +847,9 @@ export class TikTokService protected SET_DENIED_DATE(date?: string) { this.state.dateDenied = date ?? null; } + + @mutation() + protected SET_AUDIENCE_CONTROLS(audienceControlsInfo: ITikTokAudienceControls) { + this.state.audienceControlsInfo = audienceControlsInfo; + } } diff --git a/app/services/platforms/tiktok/api.ts b/app/services/platforms/tiktok/api.ts index a8fc3bb06fe4..68db2ddb3a45 100644 --- a/app/services/platforms/tiktok/api.ts +++ b/app/services/platforms/tiktok/api.ts @@ -1,4 +1,3 @@ -import { $t } from 'services/i18n'; import { TPlatform } from '..'; export type TTikTokScope = @@ -25,7 +24,13 @@ export enum ETikTokLiveScopeReason { APPROVED_OBS = 2, } -export type TTikTokLiveScopeTypes = 'approved' | 'denied' | 'legacy' | 'relog'; +export enum ETikTokAudienceType { + ALL = 0, + MATURE = 1, +} + +export type TTikTokLiveScopeTypes = 'approved' | 'denied' | 'legacy' | 'relog' | 'never-applied'; +export type TTikTokApplicationStatus = 'approved' | 'rejected' | 'never_applied'; export interface ITikTokLiveScopeResponse { platform: TPlatform | string; @@ -33,6 +38,8 @@ export interface ITikTokLiveScopeResponse { can_be_live?: boolean; user?: ITikTokUserData; info?: any[] | null[] | undefined[] | ITikTokGame[] | ITikTokGamesData | any; + audience_controls_info: ITikTokAudienceControlsInfo; + application_status?: ITikTokApplicationStatus; } export interface ITikTokGamesData extends ITikTokLiveScopeResponse { @@ -49,6 +56,22 @@ interface ITikTokGame { game_mask_id: string; } +export interface ITikTokAudienceControlsInfo { + disable: boolean; + info_type: ETikTokAudienceType; + types: ITikTokAudienceControlType[]; +} + +export interface ITikTokAudienceControlType { + key: ETikTokAudienceType; + label: string | null; +} + +export interface ITikTokApplicationStatus { + status: string; + timestamp: string | null; +} + export interface ITikTokUserData { open_id?: string; union_id?: string; @@ -64,10 +87,14 @@ export interface ITikTokUserData { } export interface ITikTokError { - code: string; - message: string; - log_id: string; - http_status_code: number; + status?: number; + error?: boolean; + success?: boolean; + message?: string; + data?: { + message: string; + code: ETikTokErrorTypes; + }; } export enum ETikTokErrorTypes { @@ -92,17 +119,3 @@ export interface ITikTokStartStreamResponse { export interface ITikTokEndStreamResponse { success: boolean; } - -export const tiktokErrorMessages = (error: string) => { - return { - TIKTOK_OAUTH_EXPIRED: $t('tiktokReAuthError'), - TIKTOK_GENERATE_CREDENTIALS_FAILED: $t( - 'Failed to generate TikTok stream credentials. Confirm Live Access with TikTok.', - ), - TIKTOK_STREAM_SCOPE_MISSING: $t('Your TikTok account is not enabled for live streaming.'), - TIKTOK_SCOPE_OUTDATED: $t( - 'Failed to update TikTok account. Please unlink and reconnect your TikTok account.', - ), - TIKTOK_STREAM_ACTIVE: $t('You are already live on a another device'), - }[error]; -}; diff --git a/app/services/platforms/twitter.ts b/app/services/platforms/twitter.ts index 3e0dca8a1a84..eb39128d3655 100644 --- a/app/services/platforms/twitter.ts +++ b/app/services/platforms/twitter.ts @@ -49,7 +49,8 @@ export class TwitterPlatformService }; readonly capabilities = new Set(['title', 'viewerCount']); - readonly apiBase = 'https://api.twitter.com/2'; + readonly apiBase = 'https://api.x.com/2'; + readonly domain = 'https://x.com'; readonly platform = 'twitter'; readonly displayName = 'X (Twitter)'; readonly gameImageSize = { width: 30, height: 40 }; @@ -209,9 +210,9 @@ export class TwitterPlatformService } get chatUrl() { - const broadcastId = this.state.broadcastId; - if (!broadcastId) return ''; - return `https://twitter.com/i/broadcasts/${broadcastId}/chat`; + const username = this.userService.state.auth?.platforms?.twitter?.username; + if (!username) return ''; + return `${this.domain}/${username}/chat`; } @mutation() @@ -225,7 +226,7 @@ export class TwitterPlatformService } openStreamIneligibleHelp() { - const url = 'https://x.com/Live/status/1812291533162590577'; + const url = `${this.domain}/Live/status/1812291533162590577`; return remote.shell.openExternal(url); } } diff --git a/app/services/recording-mode.ts b/app/services/recording-mode.ts index d12da9ed7835..fc605b727dc8 100644 --- a/app/services/recording-mode.ts +++ b/app/services/recording-mode.ts @@ -19,7 +19,7 @@ import { getPlatformService } from 'services/platforms'; import { IYoutubeUploadResponse } from 'services/platforms/youtube/uploader'; import { YoutubeService } from 'services/platforms/youtube'; -interface IRecordingEntry { +export interface IRecordingEntry { timestamp: string; filename: string; } @@ -193,10 +193,14 @@ export class RecordingModeService extends PersistentStatefulService = {}; Object.keys(this.state.recordingHistory).forEach(timestamp => { if (moment(timestamp).isAfter(oneMonthAgo)) { prunedEntries[timestamp] = this.state.recordingHistory[timestamp]; @@ -339,6 +343,11 @@ export class RecordingModeService extends PersistentStatefulService) { this.state.recordingHistory = entries; diff --git a/app/services/restream.ts b/app/services/restream.ts index b7811f255152..aeaf4024f761 100644 --- a/app/services/restream.ts +++ b/app/services/restream.ts @@ -12,6 +12,7 @@ import { StreamingService } from './streaming'; import { FacebookService } from './platforms/facebook'; import { TikTokService } from './platforms/tiktok'; import { TrovoService } from './platforms/trovo'; +import { KickService } from './platforms/kick'; import * as remote from '@electron/remote'; import { VideoSettingsService, TDisplayType } from './settings-v2/video'; import { DualOutputService } from './dual-output'; @@ -55,6 +56,7 @@ export class RestreamService extends StatefulService { @Inject() facebookService: FacebookService; @Inject('TikTokService') tiktokService: TikTokService; @Inject() trovoService: TrovoService; + @Inject() kickService: KickService; @Inject() instagramService: InstagramService; @Inject() videoSettingsService: VideoSettingsService; @Inject() dualOutputService: DualOutputService; @@ -316,6 +318,16 @@ export class RestreamService extends StatefulService { : 'landscape'; } + // treat kick as a custom destination + const kickTarget = newTargets.find(t => t.platform === 'kick'); + if (kickTarget) { + kickTarget.platform = 'relay'; + kickTarget.streamKey = `${this.kickService.state.ingest}/${this.kickService.state.streamKey}`; + kickTarget.mode = isDualOutputMode + ? this.dualOutputService.views.getPlatformMode('kick') + : 'landscape'; + } + await this.createTargets(newTargets); } diff --git a/app/services/scene-collections/nodes/overlays/game-capture.ts b/app/services/scene-collections/nodes/overlays/game-capture.ts index 79180da99f4a..9e73713b1bfc 100644 --- a/app/services/scene-collections/nodes/overlays/game-capture.ts +++ b/app/services/scene-collections/nodes/overlays/game-capture.ts @@ -51,10 +51,6 @@ export class GameCaptureNode extends Node { async load(context: IContext) { // A custom placeholder is not always provided if (!this.data.placeholderFile) { - // always capture overlays by default - context.sceneItem.getObsInput().update({ - capture_overlays: true, - }); return; } @@ -62,7 +58,6 @@ export class GameCaptureNode extends Node { context.sceneItem.getObsInput().update({ user_placeholder_image: filePath, user_placeholder_use: true, - capture_overlays: true, }); // This is a bit of a hack to force us to immediately back up diff --git a/app/services/scene-collections/nodes/overlays/webcam.ts b/app/services/scene-collections/nodes/overlays/webcam.ts index b669b9f7297e..a47a58dfba69 100644 --- a/app/services/scene-collections/nodes/overlays/webcam.ts +++ b/app/services/scene-collections/nodes/overlays/webcam.ts @@ -54,7 +54,11 @@ export class WebcamNode extends Node { if (context.existing) { resolution = byOS({ [OS.Windows]: () => - this.resStringToResolution(input.settings['resolution'], input.settings['resolution']), + this.resStringToResolution( + input.settings['resolution'], + input.settings['resolution'], + context.sceneItem, + ), [OS.Mac]: () => { const selectedResolution = (input.properties.get( 'preset', @@ -63,6 +67,7 @@ export class WebcamNode extends Node { return this.resStringToResolution( selectedResolution.name as string, selectedResolution.value as string, + context.sceneItem, ); }, }); @@ -131,15 +136,25 @@ export class WebcamNode extends Node { [OS.Windows]: () => { input.update({ video_device_id: device, res_type: 1 }); - return (input.properties.get('resolution') as IListProperty).details.items.map(item => { - return this.resStringToResolution(item.value as string, item.value as string); - }); + return (input.properties.get('resolution') as IListProperty).details.items.map( + resString => { + return this.resStringToResolution( + resString.value as string, + resString.value as string, + item, + ); + }, + ); }, [OS.Mac]: () => { input.update({ device, use_preset: true }); - return (input.properties.get('preset') as IListProperty).details.items.map(item => { - return this.resStringToResolution(item.name as string, item.value as string); + return (input.properties.get('preset') as IListProperty).details.items.map(resString => { + return this.resStringToResolution( + resString.name as string, + resString.value as string, + item, + ); }); }, }); @@ -209,7 +224,12 @@ export class WebcamNode extends Node { }); } - resStringToResolution(resString: string, value: string): IResolution { + resStringToResolution(resString: string, value: string, sceneItem: SceneItem): IResolution { + if (!resString) { + console.error('No resolution string found. Performing initial setup instead.'); + return this.performInitialSetup(sceneItem); + } + const parts = resString.split('x'); return { value, diff --git a/app/services/scene-collections/nodes/scene-items.ts b/app/services/scene-collections/nodes/scene-items.ts index 23567ed719d5..a1aa2876d014 100644 --- a/app/services/scene-collections/nodes/scene-items.ts +++ b/app/services/scene-collections/nodes/scene-items.ts @@ -143,9 +143,7 @@ export class SceneItemsNode extends Node { // but if the scene item already has a display assigned, skip it if (this.dualOutputService.views.hasNodeMap(context.scene.id)) { // nodes must be assigned to a context, so if it doesn't exist, establish it - if (!this.videoSettingsService.contexts.vertical) { - this.videoSettingsService.establishVideoContext('vertical'); - } + this.videoSettingsService.validateVideoContext(); const nodeMap = this.dualOutputService.views.sceneNodeMaps[context.scene.id]; diff --git a/app/services/scene-collections/scene-collections.ts b/app/services/scene-collections/scene-collections.ts index 4165b75a6441..7ae970e878bd 100644 --- a/app/services/scene-collections/scene-collections.ts +++ b/app/services/scene-collections/scene-collections.ts @@ -14,7 +14,7 @@ import { SceneFiltersNode } from './nodes/scene-filters'; import path from 'path'; import { parse } from './parse'; import { ScenesService, TSceneNode } from 'services/scenes'; -import { SourcesService } from 'services/sources'; +import { SourcesService, TSourceType } from 'services/sources'; import { E_AUDIO_CHANNELS } from 'services/audio'; import { AppService } from 'services/app'; import { RunInLoadingMode } from 'services/app/app-decorators'; @@ -44,6 +44,7 @@ import { GuestCamNode } from './nodes/guest-cam'; import { DualOutputService } from 'services/dual-output'; import { NodeMapNode } from './nodes/node-map'; import { VideoSettingsService } from 'services/settings-v2'; +import { WidgetsService, WidgetType } from 'services/widgets'; const uuid = window['require']('uuid/v4'); @@ -94,6 +95,7 @@ export class SceneCollectionsService extends Service implements ISceneCollection @Inject() dualOutputService: DualOutputService; @Inject() videoSettingsService: VideoSettingsService; @Inject() private defaultHardwareService: DefaultHardwareService; + @Inject() private widgetsService: WidgetsService; collectionAdded = new Subject(); collectionRemoved = new Subject(); @@ -101,7 +103,6 @@ export class SceneCollectionsService extends Service implements ISceneCollection collectionWillSwitch = new Subject(); collectionUpdated = new Subject(); collectionInitialized = new Subject(); - collectionActivated = new Subject(); /** * Whether a valid collection is currently loaded. @@ -114,6 +115,11 @@ export class SceneCollectionsService extends Service implements ISceneCollection */ private syncPending = false; + /** + * Used to handle actions for users on their first login + */ + newUserFirstLogin = false; + /** * Does not use the standard init function so we can have asynchronous * initialization. @@ -391,6 +397,16 @@ export class SceneCollectionsService extends Service implements ISceneCollection */ @RunInLoadingMode() async loadOverlay(filePath: string, name: string) { + // Save the current audio devices for Desktop Audio and Mic so when we + // install a new overlay they're preserved. + // TODO: this only works if the user sources have the default names + + // We always pass a desktop audio device in, since we might've found a bug that + // when installing a new overlay the device is not set and while it seems + // to behave correctly, it is blank on device properties. + const desktopAudioDevice = this.getDeviceIdFor('Desktop Audio') || 'default'; + const micDevice = this.getDeviceIdFor('Mic/Aux'); + await this.deloadCurrentApplicationState(); const id: string = uuid(); @@ -399,7 +415,7 @@ export class SceneCollectionsService extends Service implements ISceneCollection try { await this.overlaysPersistenceService.loadOverlay(filePath); - this.setupDefaultAudio(); + this.setupDefaultAudio(desktopAudioDevice, micDevice); } catch (e: unknown) { // We tried really really hard :( console.error('Overlay installation failed', e); @@ -411,6 +427,10 @@ export class SceneCollectionsService extends Service implements ISceneCollection await this.save(); } + private getDeviceIdFor(sourceName: 'Desktop Audio' | 'Mic/Aux'): string | undefined { + return this.sourcesService.views.getSourcesByName(sourceName)[0]?.getSettings()?.device_id; + } + /** * Based on the provided name, suggest a new name that does * not conflict with any current name. @@ -588,6 +608,11 @@ export class SceneCollectionsService extends Service implements ISceneCollection await root.load(); this.hotkeysService.bindHotkeys(); + + // Users who selected a theme during onboarding should skip adding default sources + if (this.newUserFirstLogin) { + this.newUserFirstLogin = false; + } } /** @@ -685,14 +710,14 @@ export class SceneCollectionsService extends Service implements ISceneCollection /** * Creates the default audio sources */ - private setupDefaultAudio() { + private setupDefaultAudio(desktopAudioDevice?: string, micDevice?: string) { // On macOS, most users will not have an audio capture device, so // we do not create it automatically. if (getOS() === OS.Windows) { this.sourcesService.createSource( 'Desktop Audio', byOS({ [OS.Windows]: 'wasapi_output_capture', [OS.Mac]: 'coreaudio_output_capture' }), - {}, + { device_id: desktopAudioDevice }, { channel: E_AUDIO_CHANNELS.OUTPUT_1 }, ); } @@ -703,7 +728,7 @@ export class SceneCollectionsService extends Service implements ISceneCollection this.sourcesService.createSource( 'Mic/Aux', byOS({ [OS.Windows]: 'wasapi_input_capture', [OS.Mac]: 'coreaudio_input_capture' }), - { device_id: defaultId }, + { device_id: micDevice || defaultId }, { channel: E_AUDIO_CHANNELS.INPUT_1 }, ); } @@ -810,6 +835,17 @@ export class SceneCollectionsService extends Service implements ISceneCollection const serverCollections = (await this.serverApi.fetchSceneCollections()).data; + // A user who has never logged in before and did not install a + // theme during onboarding will have no collections. To prevent + // special handling of the default theme for a user who installed + // a theme during onboarding. NOTE: this will be set to false after + // onboarding in the dual output service + if (!serverCollections || serverCollections.length === 0) { + this.newUserFirstLogin = true; + } else { + this.newUserFirstLogin = false; + } + let failed = false; const collectionsToInsert = []; @@ -1006,6 +1042,52 @@ export class SceneCollectionsService extends Service implements ISceneCollection return this.userService.isLoggedIn && !this.appService.state.argv.includes('--nosync'); } + /** + * Creates default sources for new users + * @remark New users should be in single output mode and have a few default sources. + */ + setupDefaultSources(shouldAddDefaultSources: boolean) { + if (!shouldAddDefaultSources) { + this.newUserFirstLogin = false; + return; + } + + const scene = + this.scenesService.views.activeScene ?? + this.scenesService.createScene('Scene', { makeActive: true }); + + if (!scene) { + console.error('Default scene not found, failed to create default sources.'); + return; + } + + // add game capture source + scene.createAndAddSource('Game Capture', 'game_capture', {}, { display: 'horizontal' }); + + // add webcam source + const type = byOS({ + [OS.Windows]: 'dshow_input', + [OS.Mac]: 'av_capture_input', + }) as TSourceType; + + const defaultSource = this.defaultHardwareService.state.defaultVideoDevice; + + const webCam = defaultSource + ? this.sourcesService.views.getSource(defaultSource) + : this.sourcesService.views.sources.find(s => s?.type === type); + + if (!webCam) { + scene.createAndAddSource('Webcam', type, { display: 'horizontal' }); + } else { + scene.addSource(webCam.sourceId, { display: 'horizontal' }); + } + + // add alert box widget + this.widgetsService.createWidget(WidgetType.AlertBox, 'Alert Box'); + + this.newUserFirstLogin = false; + } + /** * Add a scene node map * @@ -1018,9 +1100,7 @@ export class SceneCollectionsService extends Service implements ISceneCollection */ initNodeMaps(sceneNodeMap?: { [sceneId: string]: Dictionary }) { - if (!this.videoSettingsService.contexts.vertical) { - this.videoSettingsService.establishVideoContext('vertical'); - } + this.videoSettingsService.validateVideoContext(); if (!this.activeCollection) return; diff --git a/app/services/scenes/scene-item.ts b/app/services/scenes/scene-item.ts index bfabfcb49792..91f85f5b6d95 100644 --- a/app/services/scenes/scene-item.ts +++ b/app/services/scenes/scene-item.ts @@ -298,8 +298,8 @@ export class SceneItem extends SceneItemNode { const display = customSceneItem?.display ?? this?.display ?? 'horizontal'; // guarantee vertical context exists to prevent null errors - if (display === 'vertical' && !this.videoSettingsService.contexts.vertical) { - this.videoSettingsService.establishVideoContext('vertical'); + if (display === 'vertical') { + this.videoSettingsService.validateVideoContext('vertical'); } const context = this.videoSettingsService.contexts[display]; diff --git a/app/services/scenes/scenes.ts b/app/services/scenes/scenes.ts index bf05c1682bc9..3f81d971266e 100644 --- a/app/services/scenes/scenes.ts +++ b/app/services/scenes/scenes.ts @@ -1,9 +1,11 @@ import Vue from 'vue'; import without from 'lodash/without'; import { Subject } from 'rxjs'; +import { filter, take, takeUntil, tap } from 'rxjs/operators'; import { mutation, StatefulService } from 'services/core/stateful-service'; import { TransitionsService } from 'services/transitions'; import { WindowsService } from 'services/windows'; +import { SelectionService } from 'services/selection'; import { Scene, SceneItem, TSceneNode, EScaleType, EBlendingMode, EBlendingMethod } from './index'; import { ISource, SourcesService, ISourceAddOptions, TSourceType } from 'services/sources'; import { Inject } from 'services/core/injector'; @@ -12,8 +14,9 @@ import { $t } from 'services/i18n'; import namingHelpers from 'util/NamingHelpers'; import uuid from 'uuid/v4'; import { DualOutputService } from 'services/dual-output'; +import { SceneCollectionsService } from 'services/scene-collections'; import { TDisplayType } from 'services/settings-v2/video'; -import { InitAfter, ViewHandler } from 'services/core'; +import { ExecuteInWorkerProcess, InitAfter, ViewHandler } from 'services/core'; export type TSceneNodeModel = ISceneItem | ISceneItemFolder; @@ -271,6 +274,7 @@ class ScenesViews extends ViewHandler { @InitAfter('DualOutputService') export class ScenesService extends StatefulService { @Inject() private dualOutputService: DualOutputService; + @Inject() private sceneCollectionsService: SceneCollectionsService; static initialState: IScenesState = { activeSceneId: '', @@ -292,6 +296,7 @@ export class ScenesService extends StatefulService { @Inject() private windowsService: WindowsService; @Inject() private sourcesService: SourcesService; @Inject() private transitionsService: TransitionsService; + @Inject() private selectionService: SelectionService; @mutation() private ADD_SCENE(id: string, name: string) { @@ -442,6 +447,46 @@ export class ScenesService extends StatefulService { return this.state; } + createAndAddSource( + sceneId: string, + sourceName: string, + sourceType: TSourceType, + settings: Dictionary, + ) { + const scene = this.views.getScene(sceneId); + if (!scene) { + throw new Error(`Can't find scene with ID: ${sceneId}`); + } + + const sceneItem = scene.createAndAddSource(sourceName, sourceType, settings); + + const createVerticalNode = () => { + this.dualOutputService.createPartnerNode(sceneItem); + /* For some reason dragging items after enabling dual output makes them + * duplicate, associate selection on switch to mitigate this issue + */ + this.selectionService.associateSelectionWithDisplay('vertical'); + }; + + if (this.dualOutputService.state.dualOutputMode) { + createVerticalNode(); + } else { + // Schedule vertical node to be created if the user toggles on dual output in the same session + this.dualOutputService.dualOutputModeChanged + .pipe( + // If we switch collections before we enable dual output drop it + // we don't wanna create nodes on inactive scene collections + takeUntil(this.sceneCollectionsService.collectionWillSwitch), + filter(gotEnabled => !!gotEnabled), + take(1), + tap(createVerticalNode), + ) + .subscribe(); + } + + return sceneItem.sceneItemId; + } + // TODO: Remove all of this in favor of the new "views" methods // getScene(id: string): Scene | null { // return !this.state.scenes[id] ? null : new Scene(id); diff --git a/app/services/settings-v2/video.ts b/app/services/settings-v2/video.ts index 4330238d9036..2a31f6d191f5 100644 --- a/app/services/settings-v2/video.ts +++ b/app/services/settings-v2/video.ts @@ -294,17 +294,31 @@ export class VideoSettingsService extends StatefulService { Video.video = this.state.horizontal; Video.legacySettings = this.state.horizontal; - // ensure vertical context as the same fps settings as the horizontal context if (display === 'vertical') { + // ensure vertical context as the same fps settings as the horizontal context const updated = this.syncFPSSettings(); if (updated) { this.settingsService.refreshVideoSettings(); } + + // ensure that the v1 video resolution settings are the same as the horizontal context + this.settingsService.setSettingValue('Video', 'Base', `${this.baseWidth}x${this.baseHeight}`); + this.settingsService.setSettingValue( + 'Video', + 'Output', + `${this.outputResolutions.horizontal.outputWidth}x${this.outputResolutions.horizontal.outputHeight}`, + ); } return !!this.contexts[display]; } + validateVideoContext(display: TDisplayType = 'vertical') { + if (!this.contexts[display]) { + this.establishVideoContext(display); + } + } + createDefaultFps(display: TDisplayType = 'horizontal') { this.setVideoSetting('fpsNum', 30, display); this.setVideoSetting('fpsDen', 1, display); @@ -447,8 +461,10 @@ export class VideoSettingsService extends StatefulService { fpsSettings.forEach((setting: keyof IVideoInfo) => { const hasSameVideoSetting = - this.contexts.horizontal.video[setting as string] === verticalVideoSetting; + this.contexts.horizontal.video[setting as keyof IVideoInfo] === + verticalVideoSetting[setting as keyof IVideoInfo]; let shouldUpdate = hasSameVideoSetting; + // if the vertical context has been established, also compare legacy settings if (this.contexts.vertical) { const hasSameLegacySetting = diff --git a/app/services/settings/streaming/stream-settings.ts b/app/services/settings/streaming/stream-settings.ts index abd15911d18b..801edae58002 100644 --- a/app/services/settings/streaming/stream-settings.ts +++ b/app/services/settings/streaming/stream-settings.ts @@ -22,6 +22,7 @@ interface ISavedGoLiveSettings { youtube?: IPlatformFlags; trovo?: IPlatformFlags; tiktok?: IPlatformFlags; + kick?: IPlatformFlags; }; customDestinations?: ICustomStreamDestination[]; advancedMode: boolean; @@ -97,6 +98,7 @@ const platformToServiceNameMap: { [key in TPlatform]: string } = { tiktok: 'Custom', twitter: 'Custom', instagram: 'Custom', + kick: 'Custom', }; /** diff --git a/app/services/side-nav/menu-data.ts b/app/services/side-nav/menu-data.ts index f8ec9e0eb4e9..543841c4485f 100644 --- a/app/services/side-nav/menu-data.ts +++ b/app/services/side-nav/menu-data.ts @@ -38,6 +38,7 @@ export enum ESubMenuItemKey { Scene = 'browse-overlays', Widget = 'browse-overlays-widgets', Sites = 'browse-overlays-sites', + Collectibles = 'browse-overlays-collectibles', AppsStoreHome = 'platform-app-store-home', AppsManager = 'platform-app-store-manager', DashboardHome = 'dashboard-home', @@ -150,6 +151,7 @@ export const menuTitles = (item: EMenuItemKey | ESubMenuItemKey | string) => { [ESubMenuItemKey.Scene]: $t('Scene'), [ESubMenuItemKey.Widget]: $t('Alerts and Widgets'), [ESubMenuItemKey.Sites]: $t('Creator Sites'), + [ESubMenuItemKey.Collectibles]: $t('Collectibles'), [ESubMenuItemKey.AppsStoreHome]: $t('Apps Store Home'), [ESubMenuItemKey.AppsManager]: $t('Apps Manager'), [ESubMenuItemKey.DashboardHome]: $t('Dashboard Home'), @@ -231,7 +233,7 @@ export const SideNavMenuItems = (): TMenuItems => ({ subMenuItems: [ SideBarSubMenuItems()[ESubMenuItemKey.Scene], SideBarSubMenuItems()[ESubMenuItemKey.Widget], - SideBarSubMenuItems()[ESubMenuItemKey.Sites], + SideBarSubMenuItems()[ESubMenuItemKey.Collectibles], ], isActive: true, isExpanded: false, @@ -348,6 +350,13 @@ export const SideBarSubMenuItems = (): TSubMenuItems => ({ isActive: false, isExpanded: false, }, + [ESubMenuItemKey.Collectibles]: { + key: ESubMenuItemKey.Collectibles, + target: 'BrowseOverlays', + type: 'collectibles', + trackingTarget: 'themes', + isExpanded: false, + }, [ESubMenuItemKey.AppsStoreHome]: { key: ESubMenuItemKey.AppsStoreHome, target: 'PlatformAppStore', diff --git a/app/services/side-nav/menu.ts b/app/services/side-nav/menu.ts index 128b8ab56a82..206c6788820c 100644 --- a/app/services/side-nav/menu.ts +++ b/app/services/side-nav/menu.ts @@ -23,6 +23,7 @@ import { } from './menu-data'; interface ISideNavServiceState { + version: string; isOpen: boolean; showCustomEditor: boolean; hasLegacyMenu: boolean; @@ -96,7 +97,12 @@ export class SideNavService extends PersistentStatefulService this.handleUserLogin()); this.handleDismissables(); @@ -132,17 +145,6 @@ export class SideNavService extends PersistentStatefulService menuItems.key === EMenuItemKey.Themes)!; - if ( - 'subMenuItems' in themes && - themes.subMenuItems.find(item => item.key === 'alertbox-library') - ) { - themes.subMenuItems = themes.subMenuItems.filter(item => item.key !== 'alertbox-library'); - this.UPDATE_MENU_ITEMS(ENavName.TopNav, menuItems); - } - this.state.currentMenuItem = this.layoutService.state.currentTab !== 'default' ? this.layoutService.state.currentTab @@ -416,4 +418,9 @@ export class SideNavService extends PersistentStatefulService ({ vlc_source: { name: $t('VLC Source'), description: $t('Add playlists of videos to your scene.'), - icon: 'fas fa-file', + icon: 'fas fa-play', }, coreaudio_input_capture: { name: $t('Audio Input Capture'), @@ -190,7 +190,7 @@ export const SourceDisplayData = (): { [key: string]: ISourceDisplayData } => ({ ), demoFilename: 'audio-input.png', supportList: [$t('Built in microphones'), $t('USB microphones'), $t('Other USB devices')], - icon: 'fas fa-file', + icon: 'icon-mic', }, coreaudio_output_capture: { name: $t('Audio Output Capture'), @@ -199,7 +199,7 @@ export const SourceDisplayData = (): { [key: string]: ISourceDisplayData } => ({ ), demoFilename: 'audio-output.png', supportList: [$t('Desktop audio')], - icon: 'fas fa-file', + icon: 'icon-audio', }, av_capture_input: { name: $t('Video Capture Device'), @@ -210,7 +210,7 @@ export const SourceDisplayData = (): { [key: string]: ISourceDisplayData } => ({ $t('Logitech webcam'), $t('Capture cards (Elgato, Avermedia, BlackMagic)'), ], - icon: 'fas fa-file', + icon: 'icon-webcam', }, display_capture: { name: $t('Display Capture'), @@ -224,7 +224,7 @@ export const SourceDisplayData = (): { [key: string]: ISourceDisplayData } => ({ description: $t("Capture a game you're playing on your computer."), demoFilename: 'game-capture.png', supportList: [$t('Built in works with most modern computer games')], - icon: 'fas fa-file', + icon: 'fas fa-gamepad', }, audio_line: { name: $t('JACK Input Client'), @@ -242,7 +242,7 @@ export const SourceDisplayData = (): { [key: string]: ISourceDisplayData } => ({ name: $t('Instant Replay'), description: $t('Automatically plays your most recently captured replay in your stream.'), demoFilename: 'media.png', - icon: 'fas fa-file', + icon: 'icon-replay-buffer', }, icon_library: { name: $t('Custom Icon'), diff --git a/app/services/sources/sources.ts b/app/services/sources/sources.ts index dfb250e8abf3..be056e4d2fad 100644 --- a/app/services/sources/sources.ts +++ b/app/services/sources/sources.ts @@ -288,7 +288,10 @@ export class SourcesService extends StatefulService { private UPDATE_SOURCE(sourcePatch: TPatch) { if (this.state.sources[sourcePatch.id]) { Object.assign(this.state.sources[sourcePatch.id], sourcePatch); + } else if (this.state.temporarySources[sourcePatch.id]) { + Object.assign(this.state.temporarySources[sourcePatch.id], sourcePatch); } else { + this.state.temporarySources[sourcePatch.id] = {} as ISource; Object.assign(this.state.temporarySources[sourcePatch.id], sourcePatch); } } diff --git a/app/services/streaming/stream-error.ts b/app/services/streaming/stream-error.ts index e416057e50e1..7212d9342fcf 100644 --- a/app/services/streaming/stream-error.ts +++ b/app/services/streaming/stream-error.ts @@ -77,13 +77,12 @@ export const errorTypes = { get message() { return $t('Failed to authenticate with TikTok, re-login or re-merge TikTok account'); }, - get action() { - return $t('re-login or re-merge TikTok account'); - }, }, TIKTOK_SCOPE_OUTDATED: { get message() { - return $t('Failed to update TikTok account'); + return $t( + 'Failed to update TikTok account. Please unlink and reconnect your TikTok account.', + ); }, get action() { return $t('unlink and re-merge TikTok account, then restart Desktop'); @@ -107,10 +106,17 @@ export const errorTypes = { }, TIKTOK_GENERATE_CREDENTIALS_FAILED: { get message() { - return $t('Error generating TikTok stream credentials'); + return $t('Failed to generate TikTok stream credentials. Confirm Live Access with TikTok.'); + }, + }, + TIKTOK_USER_BANNED: { + get message() { + return $t('Failed to generate TikTok stream credentials. Confirm Live Access with TikTok.'); }, get action() { - return $t('confirm streaming approval status with TikTok'); + return $t( + 'user might be blocked from streaming to TikTok but do not say they are. Refer them to TikTok', + ); }, }, X_PREMIUM_ACCOUNT_REQUIRED: { @@ -297,12 +303,11 @@ export function formatStreamErrorMessage( messages.user.push(details); } - // show all available info in diag report - const errorMessage = (error as any)?.action - ? `${error.message}, ${(error as any).action}` - : error.message; + message = error.message.replace(/\s*\.$/, ''); + // trim trailing periods so that the message joins correctly + const errorMessage = (error as any)?.action ? `${message}, ${(error as any).action}` : message; + messages.report.push(errorMessage); - if (errorTypeOrError?.message) messages.report.push(message); if (details) messages.report.push(details); if (code) messages.report.push($t('Error Code: %{code}', { code })); } else { diff --git a/app/services/streaming/streaming-api.ts b/app/services/streaming/streaming-api.ts index 896538f7321a..9baec256eb06 100644 --- a/app/services/streaming/streaming-api.ts +++ b/app/services/streaming/streaming-api.ts @@ -7,9 +7,11 @@ import { IStreamError } from './stream-error'; import { ICustomStreamDestination } from '../settings/streaming'; import { ITikTokStartStreamOptions } from '../platforms/tiktok'; import { ITrovoStartStreamOptions } from '../platforms/trovo'; -import { IVideo } from 'obs-studio-node'; +import { IKickStartStreamOptions } from 'services/platforms/kick'; import { ITwitterStartStreamOptions } from 'services/platforms/twitter'; import { IInstagramStartStreamOptions } from 'services/platforms/instagram'; +import { IVideo } from 'obs-studio-node'; +import { TDisplayType } from 'services/settings-v2'; export enum EStreamingState { Offline = 'offline', @@ -51,6 +53,7 @@ export interface IStreamInfo { facebook: TGoLiveChecklistItemState; tiktok: TGoLiveChecklistItemState; trovo: TGoLiveChecklistItemState; + kick: TGoLiveChecklistItemState; twitter: TGoLiveChecklistItemState; instagram: TGoLiveChecklistItemState; setupMultistream: TGoLiveChecklistItemState; @@ -68,6 +71,7 @@ export interface IStreamSettings { facebook?: IPlatformFlags & IFacebookStartStreamOptions; tiktok?: IPlatformFlags & ITikTokStartStreamOptions; trovo?: IPlatformFlags & ITrovoStartStreamOptions; + kick?: IPlatformFlags & IKickStartStreamOptions; twitter?: IPlatformFlags & ITwitterStartStreamOptions; instagram?: IPlatformFlags & IInstagramStartStreamOptions; }; @@ -87,6 +91,7 @@ export interface IGoLiveSettings extends IStreamSettings { export interface IPlatformFlags { enabled: boolean; useCustomFields: boolean; + display?: TDisplayType; video?: IVideo; } diff --git a/app/services/streaming/streaming-view.ts b/app/services/streaming/streaming-view.ts index da5c50907db9..e363ae5459d3 100644 --- a/app/services/streaming/streaming-view.ts +++ b/app/services/streaming/streaming-view.ts @@ -9,12 +9,7 @@ import { import { StreamSettingsService, ICustomStreamDestination } from '../settings/streaming'; import { UserService } from '../user'; import { RestreamService, TOutputOrientation } from '../restream'; -import { - DualOutputService, - TDisplayPlatforms, - IDualOutputPlatformSetting, - TDisplayDestinations, -} from '../dual-output'; +import { DualOutputService, TDisplayPlatforms, TDisplayDestinations } from '../dual-output'; import { getPlatformService, TPlatform, TPlatformCapability, platformList } from '../platforms'; import { TwitterService } from '../../app-services'; import cloneDeep from 'lodash/cloneDeep'; @@ -22,7 +17,6 @@ import difference from 'lodash/difference'; import { Services } from '../../components-react/service-provider'; import { getDefined } from '../../util/properties-type-guards'; import { TDisplayType } from 'services/settings-v2'; -import compact from 'lodash/compact'; /** * The stream info view is responsible for keeping @@ -183,16 +177,35 @@ export class StreamInfoView extends ViewHandler { return this.dualOutputView.dualOutputMode && this.userView.isLoggedIn; } - get shouldMultistreamDisplay(): { horizontal: boolean; vertical: boolean } { - const numHorizontal = - this.activeDisplayPlatforms.horizontal.length + - this.activeDisplayDestinations.horizontal.length; - const numVertical = - this.activeDisplayPlatforms.vertical.length + this.activeDisplayDestinations.vertical.length; + getShouldMultistreamDisplay( + settings?: IGoLiveSettings, + ): { horizontal: boolean; vertical: boolean } { + const platforms = settings?.platforms || this.settings.platforms; + const customDestinations = settings?.customDestinations || this.customDestinations; + + const platformDisplays = { horizontal: [] as TPlatform[], vertical: [] as TPlatform[] }; + + for (const platform in platforms) { + if (platforms[platform as TPlatform]?.enabled) { + const display = platforms[platform as TPlatform]?.display ?? 'horizontal'; + platformDisplays[display].push(platform as TPlatform); + } + } + + // determine which enabled custom destinations use which displays + const destinationDisplays = customDestinations.reduce( + (displays: TDisplayDestinations, destination: ICustomStreamDestination) => { + if (destination.enabled && destination?.display) { + displays[destination.display].push(destination.name); + } + return displays; + }, + { horizontal: [], vertical: [] }, + ); return { - horizontal: numHorizontal > 1, - vertical: numVertical > 1, + horizontal: platformDisplays.horizontal.length + destinationDisplays.horizontal.length > 1, + vertical: platformDisplays.vertical.length + destinationDisplays.vertical.length > 1, }; } @@ -200,13 +213,10 @@ export class StreamInfoView extends ViewHandler { * Returns the enabled platforms according to their assigned display */ get activeDisplayPlatforms(): TDisplayPlatforms { - const enabledPlatforms = this.enabledPlatforms; - - return Object.entries(this.dualOutputView.platformSettings).reduce( - (displayPlatforms: TDisplayPlatforms, [key, val]: [string, IDualOutputPlatformSetting]) => { - if (val && enabledPlatforms.includes(val.platform)) { - displayPlatforms[val.display].push(val.platform); - } + return this.enabledPlatforms.reduce( + (displayPlatforms: TDisplayPlatforms, platform: TPlatform) => { + const display = this.settings.platforms[platform]?.display ?? 'horizontal'; + displayPlatforms[display].push(platform); return displayPlatforms; }, { horizontal: [], vertical: [] }, @@ -230,11 +240,36 @@ export class StreamInfoView extends ViewHandler { ); } - /** - * Returns the display for a given platform - */ - getPlatformDisplay(platform: TPlatform) { - return this.dualOutputView.getPlatformDisplay(platform); + getCanStreamDualOutput(settings?: IGoLiveSettings): boolean { + const platforms = settings?.platforms || this.settings.platforms; + const customDestinations = settings?.customDestinations || this.customDestinations; + + const platformDisplays = { horizontal: [] as TPlatform[], vertical: [] as TPlatform[] }; + + for (const platform in platforms) { + if (platforms[platform as TPlatform]?.enabled) { + const display = platforms[platform as TPlatform]?.display ?? 'horizontal'; + platformDisplays[display].push(platform as TPlatform); + } + } + + // determine which enabled custom destinations use which displays + const destinationDisplays = customDestinations.reduce( + (displays: TDisplayDestinations, destination: ICustomStreamDestination) => { + if (destination.enabled && destination?.display) { + displays[destination.display].push(destination.name); + } + return displays; + }, + { horizontal: [], vertical: [] }, + ); + // determine if both displays are selected for active platforms + const horizontalHasDestinations = + platformDisplays.horizontal.length > 0 || destinationDisplays.horizontal.length > 0; + const verticalHasDestinations = + platformDisplays.vertical.length > 0 || destinationDisplays.vertical.length > 0; + + return horizontalHasDestinations && verticalHasDestinations; } get isMidStreamMode(): boolean { @@ -483,8 +518,15 @@ export class StreamInfoView extends ViewHandler { settings['liveVideoId'] = ''; } + // make sure platforms assigned to the vertical display in dual output mode still go live in single output mode + const display = + this.isDualOutputMode && savedDestinations + ? savedDestinations[platform]?.display + : 'horizontal'; + return { ...settings, + display, enabled, useCustomFields, }; @@ -518,4 +560,8 @@ export class StreamInfoView extends ViewHandler { get hasDestinations() { return this.enabledPlatforms.length > 0 || this.customDestinations.length > 0; } + + get selectiveRecording() { + return this.streamingState.selectiveRecording; + } } diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 5fe691d95efb..4b2ddc9f471e 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -42,6 +42,7 @@ import { TStreamErrorType, formatUnknownErrorMessage, formatStreamErrorMessage, + throwStreamError, } from './stream-error'; import { authorizedHeaders } from 'util/requests'; import { HostsService } from '../hosts'; @@ -54,7 +55,6 @@ import { MarkersService } from 'services/markers'; import { byOS, OS } from 'util/operating-systems'; import { DualOutputService } from 'services/dual-output'; import { capitalize } from 'lodash'; -import { tiktokErrorMessages } from 'services/platforms/tiktok/api'; import { TikTokService } from 'services/platforms/tiktok'; enum EOBSOutputType { @@ -108,6 +108,7 @@ export class StreamingService replayBufferFileWrite = new Subject(); streamInfoChanged = new Subject>(); signalInfoChanged = new Subject(); + latestRecordingPath = new Subject(); streamErrorCreated = new Subject(); // Dummy subscription for stream deck @@ -139,6 +140,7 @@ export class StreamingService facebook: 'not-started', tiktok: 'not-started', trovo: 'not-started', + kick: 'not-started', twitter: 'not-started', instagram: 'not-started', setupMultistream: 'not-started', @@ -274,8 +276,8 @@ export class StreamingService !assignContext && platform === 'twitch' && unattendedMode ? undefined : settings; if (assignContext) { - const context = this.views.getPlatformDisplay(platform); - await this.runCheck(platform, () => service.beforeGoLive(settingsForPlatform, context)); + const display = settings.platforms[platform]?.display; + await this.runCheck(platform, () => service.beforeGoLive(settingsForPlatform, display)); } else { await this.runCheck(platform, () => service.beforeGoLive(settingsForPlatform, 'horizontal'), @@ -313,22 +315,21 @@ export class StreamingService /** * Set custom destination stream settings */ - if (this.views.isDualOutputMode) { - // set custom destination mode and video context before setting settings - settings.customDestinations.forEach(destination => { - // only update enabled custom destinations - if (!destination.enabled) return; - - if (!destination.display) { - // set display to horizontal by default if it does not exist - destination.display = 'horizontal'; - } + settings.customDestinations.forEach(destination => { + // only update enabled custom destinations + if (!destination.enabled) return; - const display = destination.display; - destination.video = this.videoSettingsService.contexts[display]; - destination.mode = this.views.getDisplayContextName(display); - }); - } + if (!destination.display) { + // set display to horizontal by default if it does not exist + destination.display = 'horizontal'; + } + + // preserve user's dual output display setting but correctly go live to custom destinations in single output mode + const display = this.views.isDualOutputMode ? destination.display : 'horizontal'; + + destination.video = this.videoSettingsService.contexts[display]; + destination.mode = this.views.getDisplayContextName(display); + }); // save enabled platforms to reuse setting with the next app start this.streamSettingsService.setSettings({ goLiveSettings: settings }); @@ -396,11 +397,13 @@ export class StreamingService * SET DUAL OUTPUT SETTINGS */ if (this.views.isDualOutputMode) { - const horizontalStream: string[] = this.views.activeDisplayDestinations.horizontal; - horizontalStream.concat(this.views.activeDisplayPlatforms.horizontal as string[]); + const horizontalDestinations: string[] = this.views.activeDisplayDestinations.horizontal; + const horizontalPlatforms: TPlatform[] = this.views.activeDisplayPlatforms.horizontal; + const horizontalStream = horizontalDestinations.concat(horizontalPlatforms as string[]); - const verticalStream: string[] = this.views.activeDisplayDestinations.vertical; - verticalStream.concat(this.views.activeDisplayPlatforms.vertical as string[]); + const verticalDestinations: string[] = this.views.activeDisplayDestinations.vertical; + const verticalPlatforms: TPlatform[] = this.views.activeDisplayPlatforms.vertical; + const verticalStream = verticalDestinations.concat(verticalPlatforms as string[]); const allPlatforms = this.views.enabledPlatforms; const allDestinations = this.views.customDestinations @@ -417,7 +420,7 @@ export class StreamingService }); // if needed, set up multistreaming for dual output - const shouldMultistreamDisplay = this.views.shouldMultistreamDisplay; + const shouldMultistreamDisplay = this.views.getShouldMultistreamDisplay(settings); const destinationDisplays = this.views.activeDisplayDestinations; @@ -553,8 +556,8 @@ export class StreamingService // in dual output mode, assign context by settings // in single output mode, assign context to 'horizontal' by default - const context = this.views.isDualOutputMode - ? this.views.getPlatformDisplay(platform) + const display = this.views.isDualOutputMode + ? settings.platforms[platform]?.display : 'horizontal'; try { @@ -564,34 +567,17 @@ export class StreamingService ? undefined : settings; - await this.runCheck(platform, () => service.beforeGoLive(settingsForPlatform, context)); + await this.runCheck(platform, () => service.beforeGoLive(settingsForPlatform, display)); } catch (e: unknown) { this.handleSetupPlatformError(e, platform); - if (platform === 'tiktok') { - const error = e as StreamError; - const title = $t('TikTok Stream Error'); - const message = tiktokErrorMessages(error.type) ?? title; - this.outputErrorOpen = true; - - remote.dialog - .showMessageBox(Utils.getMainWindow(), { - title, - type: 'error', - message, - buttons: [$t('Open TikTok Live Center'), $t('Close')], - }) - .then(({ response }) => { - if (response === 0) { - this.tikTokService.handleOpenLiveManager(true); - } - this.outputErrorOpen = false; - }) - .catch(() => { - this.outputErrorOpen = false; - }); - - this.windowsService.actions.closeChildWindow(); + // if TikTok is the only platform going live and the user is banned, prevent the stream from attempting to start + if ( + e instanceof StreamError && + e.type === 'TIKTOK_USER_BANNED' && + this.views.enabledPlatforms.length === 1 + ) { + throwStreamError('TIKTOK_USER_BANNED', e); } } } @@ -889,7 +875,10 @@ export class StreamingService // Dual output cannot be toggled while live if (this.state.streamingStatus !== EStreamingState.Offline) return; - if (enabled) this.usageStatisticsService.recordFeatureUsage('DualOutput'); + if (enabled) { + this.dualOutputService.actions.setDualOutputMode(true, true); + this.usageStatisticsService.recordFeatureUsage('DualOutput'); + } this.SET_DUAL_OUTPUT_MODE(enabled); } @@ -998,7 +987,7 @@ export class StreamingService } async toggleStreaming(options?: TStartStreamOptions, force = false) { - if (this.views.isDualOutputMode && !this.dualOutputService.views.getCanStreamDualOutput()) { + if (this.views.isDualOutputMode && !this.views.getCanStreamDualOutput() && this.isIdle) { this.notificationsService.actions.push({ message: $t('Set up Go Live Settings for Dual Output Mode in the Go Live window.'), type: ENotificationType.WARNING, @@ -1314,6 +1303,7 @@ export class StreamingService eventMetadata.streamType = streamSettings.streamType; eventMetadata.platform = streamSettings.platform; eventMetadata.server = streamSettings.server; + eventMetadata.outputMode = this.views.isDualOutputMode ? 'dual' : 'single'; eventMetadata.platforms = this.views.protectedModeEnabled ? [ ...this.views.enabledPlatforms, @@ -1386,6 +1376,7 @@ export class StreamingService this.recordingModeService.actions.addRecordingEntry(parsedFilename); this.markersService.actions.exportCsv(parsedFilename); this.recordingModeService.addRecordingEntry(parsedFilename); + this.latestRecordingPath.next(filename); // Wrote signals come after Offline, so we return early here // to not falsely set our state out of Offline return; @@ -1419,6 +1410,15 @@ export class StreamingService if (info.code) { if (this.outputErrorOpen) { console.warn('Not showing error message because existing window is open.', info); + + const messages = formatUnknownErrorMessage( + info, + this.streamErrorUserMessage, + this.streamErrorReportMessage, + ); + + this.streamErrorCreated.next(messages.report); + return; } @@ -1466,13 +1466,6 @@ export class StreamingService } else { // -4 is used for generic unknown messages in OBS. Both -4 and any other code // we don't recognize should fall into this branch and show a generic error. - // if (!this.userService.isLoggedIn) { - // const messages; - // errorText = $t( - // 'You are currently logged out. Please log in or confirm your server url and stream key.', - // ); - // diagReportMessage = 'User is logged out and has invalid server url or stream key.'; - // } else if (!this.userService.isLoggedIn) { const messages = formatStreamErrorMessage('LOGGED_OUT_ERROR'); @@ -1570,6 +1563,7 @@ export class StreamingService data.viewerCounts = {}; data.duration = Math.round(moment().diff(moment(this.state.streamingStatusTime)) / 1000); data.game = this.views.game; + data.outputMode = this.views.isDualOutputMode ? 'dual' : 'single'; if (this.views.protectedModeEnabled) { data.platforms = this.views.enabledPlatforms; diff --git a/app/services/streamlabels/index.ts b/app/services/streamlabels/index.ts index 78310b574757..ad8d955a3f91 100644 --- a/app/services/streamlabels/index.ts +++ b/app/services/streamlabels/index.ts @@ -248,6 +248,10 @@ export class StreamlabelsService extends StatefulService { if (settings.format) { - settings.format = this.unescapeNewline(settings.format); + settings.format = this.escapeNewline(settings.format); + } + + if (settings.item_separator) { + settings.item_separator = this.unescapeNewline(settings.item_separator); } this.settings[statname] = { diff --git a/app/services/troubleshooter/troubleshooter-api.ts b/app/services/troubleshooter/troubleshooter-api.ts index 7b1574fb7b2d..8cbbd8ab16ba 100644 --- a/app/services/troubleshooter/troubleshooter-api.ts +++ b/app/services/troubleshooter/troubleshooter-api.ts @@ -7,9 +7,11 @@ export interface ITroubleshooterSettings { laggedThreshold: number; droppedEnabled: boolean; droppedThreshold: number; + dualOutputCpuEnabled: boolean; + dualOutputCpuThreshold: number; } -export type TIssueCode = 'FRAMES_LAGGED' | 'FRAMES_SKIPPED' | 'FRAMES_DROPPED'; +export type TIssueCode = 'FRAMES_LAGGED' | 'FRAMES_SKIPPED' | 'FRAMES_DROPPED' | 'HIGH_CPU_USAGE'; export interface ITroubleshooterServiceApi { getSettings(): ITroubleshooterSettings; diff --git a/app/services/troubleshooter/troubleshooter.ts b/app/services/troubleshooter/troubleshooter.ts index 291b02d6ec08..6eb1c4abea2a 100644 --- a/app/services/troubleshooter/troubleshooter.ts +++ b/app/services/troubleshooter/troubleshooter.ts @@ -25,6 +25,8 @@ export class TroubleshooterService laggedThreshold: 0.25, droppedEnabled: true, droppedThreshold: 0.25, + dualOutputCpuEnabled: true, + dualOutputCpuThreshold: 0.3, }, }; @@ -103,6 +105,28 @@ export class TroubleshooterService enabled: true, usePercentages: true, }, + + >{ + value: settings.dualOutputCpuEnabled, + name: 'dualOutputCpuEnabled', + description: $t('Detect CPU usage in Dual Output mode'), + type: 'OBS_PROPERTY_BOOL', + visible: true, + enabled: true, + }, + + { + value: settings.dualOutputCpuThreshold, + name: 'dualOutputCpuThreshold', + description: $t('CPU usage threshold in Dual Output mode'), + type: 'OBS_PROPERTY_SLIDER', + minVal: 0, + maxVal: 1, + stepVal: 0.01, + visible: settings.dualOutputCpuEnabled, + enabled: true, + usePercentages: true, + }, ]; } diff --git a/app/services/usage-statistics.ts b/app/services/usage-statistics.ts index 690159e6f54b..3c92d43abe92 100644 --- a/app/services/usage-statistics.ts +++ b/app/services/usage-statistics.ts @@ -35,6 +35,7 @@ type TAnalyticsEvent = | 'Shown' | 'AppStart' | 'Highlighter' + | 'AIHighlighter' | 'Hardware' | 'WebcamUse' | 'MicrophoneUse' diff --git a/app/services/user/index.ts b/app/services/user/index.ts index 2d791ddc2da9..bffd6772184b 100644 --- a/app/services/user/index.ts +++ b/app/services/user/index.ts @@ -116,6 +116,7 @@ interface ILinkedPlatformsResponse { youtube_account?: ILinkedPlatform; tiktok_account?: ILinkedPlatform; trovo_account?: ILinkedPlatform; + kick_account?: ILinkedPlatform; streamlabs_account?: ILinkedPlatform; twitter_account?: ILinkedPlatform; user_id: number; @@ -464,11 +465,14 @@ export class UserService extends PersistentStatefulService { ? $t('Successfully merged account') : $t('Successfully unlinked account'); + await this.showStreamSettingsIfNeeded(); + this.windowsService.actions.setWindowOnTop('all'); this.refreshedLinkedAccounts.next({ success: true, message }); } if (event.type === 'account_merge_error') { + await this.showStreamSettingsIfNeeded(); this.windowsService.actions.setWindowOnTop('all'); this.refreshedLinkedAccounts.next({ success: false, message: $t('Account merge error') }); } @@ -484,6 +488,31 @@ export class UserService extends PersistentStatefulService { }); } + /* + * Since we're displaying the child window in all cases, it might've + * been closed when we get this event, so no component was rendered into + * it and instead shows an empty blank window with a loading spinner. + * It could also never been created (or a component rendered into it + * at least), both cases resulted in that invalid state. + * + * If the child window is closed, and we get one of these user events, + * (refer to callers), show Settings -> Stream which in our case should + * displays user accounts. + */ + async showStreamSettingsIfNeeded() { + if (this.windowsService.state.child && !this.windowsService.state.child.isShown) { + this.settingsService.showSettings('Stream'); + /* TODO: added a sleep here so on first child window create + * we still get to see messages (i.e Stream settings). + * Otherwise subscriber is called late, since this is a normal + * subject. + * TODO: should we convert to `BehaviorSubject` or whatever was + * it the one that replays events for new subscribers? + */ + await Utils.sleep(500); + } + } + get views() { return new UserViews(this.state); } @@ -493,10 +522,21 @@ export class UserService extends PersistentStatefulService { * to do this because Twitch adds a captcha when we try to * actually log in from integration tests. */ - async testingFakeAuth(auth: IUserAuth, isOnboardingTest: boolean) { + async testingFakeAuth( + auth: IUserAuth, + isOnboardingTest: boolean = false, + isNewUser: boolean = false, + ) { + if (!Utils.isTestMode()) return; + const service = getPlatformService(auth.primaryPlatform); this.streamSettingsService.resetStreamSettings(); await this.login(service, auth); + + if (isNewUser) { + this.sceneCollectionsService.newUserFirstLogin = true; + } + if (!isOnboardingTest) this.onboardingService.finish(); } @@ -714,6 +754,17 @@ export class UserService extends PersistentStatefulService { this.UNLINK_PLATFORM('trovo'); } + if (linkedPlatforms.kick_account) { + this.UPDATE_PLATFORM({ + type: 'kick', + username: linkedPlatforms.kick_account.platform_name, + id: linkedPlatforms.kick_account.platform_id, + token: linkedPlatforms.kick_account.access_token, + }); + } else if (this.state.auth.primaryPlatform !== 'kick') { + this.UNLINK_PLATFORM('kick'); + } + if (linkedPlatforms.streamlabs_account) { this.SET_SLID({ id: linkedPlatforms.streamlabs_account.platform_id, @@ -931,7 +982,7 @@ export class UserService extends PersistentStatefulService { } async overlaysUrl( - type?: 'overlay' | 'widget-themes' | 'site-themes', + type?: 'overlay' | 'widget-themes' | 'site-themes' | 'collectibles', id?: string, install?: string, ) { @@ -1244,7 +1295,9 @@ export class UserService extends PersistentStatefulService { hasRelogged: true, }; - this.UPDATE_PLATFORM(auth.platforms[auth.primaryPlatform]); + this.UPDATE_PLATFORM( + (auth.platforms as Record)[auth.primaryPlatform], + ); return EPlatformCallResult.Success; } diff --git a/app/services/utils.ts b/app/services/utils.ts index 6893b150133e..898ca94b29f6 100644 --- a/app/services/utils.ts +++ b/app/services/utils.ts @@ -24,6 +24,7 @@ export interface IEnv { // Allows joining as a guest instead of a host for guest cam SLD_GUEST_CAM_HASH: string; CI: boolean; + HIGHLIGHTER_ENV: 'production' | 'staging' | 'local'; } export default class Utils { @@ -93,10 +94,27 @@ export default class Utils { ); } + static get isProduction() { + return Utils.env.NODE_ENV === 'production'; + } + static isDevMode() { return Utils.env.NODE_ENV !== 'production'; } + static getHighlighterEnvironment(): 'production' | 'staging' | 'local' { + // need to use this remote thing because main process is being spawned as + // subprocess of updater process in the release build + if (remote.process.argv.includes('--bundle-qa')) { + return 'staging'; + } + + if (process.env.HIGHLIGHTER_ENV !== 'staging' && process.env.HIGHLIGHTER_ENV !== 'local') { + return 'production'; + } + return process.env.HIGHLIGHTER_ENV as 'production' | 'staging' | 'local'; + } + static isTestMode() { return Utils.env.NODE_ENV === 'test'; } @@ -114,7 +132,7 @@ export default class Utils { } static shouldUseBeta(): boolean { - return Utils.env.SLD_USE_BETA as boolean; + return (process.env.SLD_COMPILE_FOR_BETA || Utils.env.SLD_USE_BETA) as boolean; } /** diff --git a/app/services/widgets/settings/chat-highlight.ts b/app/services/widgets/settings/chat-highlight.ts index 506fe69af885..3b5d8319ec6c 100644 --- a/app/services/widgets/settings/chat-highlight.ts +++ b/app/services/widgets/settings/chat-highlight.ts @@ -63,8 +63,8 @@ export class ChatHighlightService extends WidgetSettingsService { dataFetchUrl: `https://${this.getHost()}/api/v5/slobs/widget/emote-wall`, settingsSaveUrl: `https://${this.getHost()}/api/v5/slobs/widget/emote-wall`, settingsUpdateEvent: 'emoteWallSettingsUpdate', - customCodeAllowed: true, - customFieldsAllowed: true, + customCodeAllowed: false, + customFieldsAllowed: false, hasTestButtons: true, }; } diff --git a/app/services/widgets/settings/event-list.ts b/app/services/widgets/settings/event-list.ts index fcebcfd8fc10..df7d5666525f 100644 --- a/app/services/widgets/settings/event-list.ts +++ b/app/services/widgets/settings/event-list.ts @@ -95,7 +95,7 @@ export class EventListService extends WidgetSettingsService { eventsByPlatform(): { key: string; title: string }[] { const platform = this.userService.platform.type as Exclude< TPlatform, - 'tiktok' | 'twitter' | 'instagram' + 'tiktok' | 'twitter' | 'instagram' | 'kick' >; return { twitch: [ diff --git a/app/services/widgets/settings/spin-wheel.ts b/app/services/widgets/settings/spin-wheel.ts index 6ef73f38a197..42659e0d502e 100644 --- a/app/services/widgets/settings/spin-wheel.ts +++ b/app/services/widgets/settings/spin-wheel.ts @@ -53,7 +53,7 @@ export class SpinWheelService extends WidgetSettingsService { previewUrl: `https://${this.getHost()}/widgets/wheel?token=${this.getWidgetToken()}`, dataFetchUrl: `https://${this.getHost()}/api/v5/slobs/widget/wheel`, settingsSaveUrl: `https://${this.getHost()}/api/v5/slobs/widget/wheel`, - settingsUpdateEvent: 'spinwheelSettingsUpdate', + settingsUpdateEvent: 'WheelSettingsUpdate', customCodeAllowed: true, customFieldsAllowed: true, }; @@ -95,9 +95,7 @@ export class SpinWheelService extends WidgetSettingsService { async spinWheel() { // eslint-disable-next-line - const url = `https://${ - this.getHost() - }/api/v5/slobs/widget/wheel/spin/${this.getWidgetToken()}`; + const url = `https://${this.getHost()}/api/v5/slobs/widget/wheel/spin/${this.getWidgetToken()}`; const headers = authorizedHeaders(this.userService.apiToken); const request = new Request(url, { headers, method: 'POST' }); const response = await fetch(request); diff --git a/app/services/widgets/settings/stream-boss.ts b/app/services/widgets/settings/stream-boss.ts index 2d1100e8ffd2..b66592144b8c 100644 --- a/app/services/widgets/settings/stream-boss.ts +++ b/app/services/widgets/settings/stream-boss.ts @@ -169,7 +169,7 @@ export class StreamBossService extends BaseGoalService; return { twitch: [ diff --git a/app/services/widgets/settings/widget-settings.ts b/app/services/widgets/settings/widget-settings.ts index 79601b235624..ff07eb8c4844 100644 --- a/app/services/widgets/settings/widget-settings.ts +++ b/app/services/widgets/settings/widget-settings.ts @@ -11,6 +11,7 @@ import { IWidgetSettingsServiceApi, IWidgetSettingsState, TWIdgetLoadingState, + WidgetDefinitions, WidgetsService, } from 'services/widgets'; import { Subject } from 'rxjs'; @@ -23,9 +24,10 @@ export const WIDGET_INITIAL_STATE: IWidgetSettingsGenericState = { data: null, rawData: null, pendingRequests: 0, + staticConfig: null, }; -export type THttpMethod = 'GET' | 'POST' | 'DELETE'; +export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; interface ISocketEvent { type: string; @@ -98,12 +100,26 @@ export abstract class WidgetSettingsService const isFirstLoading = !this.state.data; if (isFirstLoading) this.SET_LOADING_STATE('pending'); const apiSettings = this.getApiSettings(); + // TODO: this is bad let rawData: any; try { - rawData = await this.request({ - url: apiSettings.dataFetchUrl, - method: 'GET', - }); + const widgetType = WidgetDefinitions[apiSettings.type].humanType; + const [widgetData, staticConfig] = await Promise.all([ + this.request({ + url: apiSettings.dataFetchUrl, + method: 'GET', + }), + // Only fetch this once + this.state.staticConfig + ? Promise.resolve(this.state.staticConfig) + : this.request({ + url: `https://${this.hostsService.streamlabs}/api/v5/widgets/static/config/${widgetType}`, + method: 'GET', + }), + ]); + // TODO: see above + rawData = widgetData; + this.SET_WIDGET_STATIC_CONFIG(staticConfig); } catch (e: unknown) { if (isFirstLoading) this.SET_LOADING_STATE('fail'); throw e; @@ -122,8 +138,21 @@ export abstract class WidgetSettingsService protected handleDataAfterFetch(rawData: any): TWidgetData { const data = cloneDeep(rawData); - // patch fetched data to have the same data format - if (data.custom) data.custom_defaults = data.custom; + // TODO: type + const { staticConfig }: any = this.state; + if (staticConfig?.data?.custom_code) { + // These seem only used to restore defaults + data.custom_defaults = staticConfig.data?.custom_code; + // If we have a default for custom code and the fields are empty in the + // response, prefill that with the default, this is what backend should + // also do + ['html', 'css', 'js'].forEach(customType => { + const prop = `custom_${customType}`; + if (staticConfig.data.custom_code[customType] && !data.settings[prop]) { + data.settings[prop] = staticConfig.data.custom_code[customType]; + } + }); + } data.type = this.getApiSettings().type; @@ -206,6 +235,13 @@ export abstract class WidgetSettingsService this.state.rawData = rawData; } + @mutation() + // TODO: `unknown` because this needs to be generic and I don't wanna mess with that + // while custom code is broken + protected SET_WIDGET_STATIC_CONFIG(data: unknown) { + this.state.staticConfig = data; + } + @mutation() protected RESET_WIDGET_DATA() { this.state.loadingState = 'none'; diff --git a/app/services/widgets/widget-source.ts b/app/services/widgets/widget-source.ts index b6c0f258be8b..c1c5ed777106 100644 --- a/app/services/widgets/widget-source.ts +++ b/app/services/widgets/widget-source.ts @@ -74,7 +74,7 @@ export class WidgetSource implements IWidgetSource { destroyPreviewSource() { this.widgetsService.stopSyncPreviewSource(this.previewSourceId); - this.sourcesService.views.getSource(this.previewSourceId).remove(); + this.sourcesService.views.getSource(this.previewSourceId)?.remove(); this.SET_PREVIEW_SOURCE_ID(''); } diff --git a/app/services/widgets/widgets-api.ts b/app/services/widgets/widgets-api.ts index efe4ba2b62b8..4f9bcb217d94 100644 --- a/app/services/widgets/widgets-api.ts +++ b/app/services/widgets/widgets-api.ts @@ -40,6 +40,12 @@ export interface IWidget { // An anchor (origin) point can be specified for the x&y positions anchor: AnchorPoint; + + /** + * The actual type of the widget in string form, not a number. + * We use this to fetch the widget's static config. + */ + humanType: string; } export interface IWidgetApiSettings { @@ -106,6 +112,8 @@ export interface IWidgetSettingsGenericState { data: IWidgetData; rawData: Dictionary; // widget data before patching pendingRequests: number; // amount of pending requests to the widget API + /** Widget static config (/api/v5/widgets/static/config/{widgetType} response **/ + staticConfig: unknown; } export interface IWidgetSettingsState diff --git a/app/services/widgets/widgets-config.ts b/app/services/widgets/widgets-config.ts index c2b97e344a26..6152dd4cd608 100644 --- a/app/services/widgets/widgets-config.ts +++ b/app/services/widgets/widgets-config.ts @@ -14,6 +14,9 @@ export type TWidgetType = export interface IWidgetConfig { type: TWidgetType; + /** Wether this widget uses the new widget API at `/api/v5/widgets/desktop/...` **/ + useNewWidgetAPI?: boolean; + // Default transform for the widget defaultTransform: { width: number; @@ -45,7 +48,11 @@ export interface IWidgetConfig { }; } -export function getWidgetsConfig(host: string, token: string): Record { +export function getWidgetsConfig( + host: string, + token: string, + widgetsWithNewAPI: WidgetType[] = [], +): Record { return { [WidgetType.AlertBox]: { type: WidgetType.AlertBox, @@ -143,8 +150,8 @@ export function getWidgetsConfig(host: string, token: string): Record { ] as IWidgetTester[]; }; +// TODO: the type of this needs to match what's used on the UI with WidgetDisplayData export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.AlertBox]: { name: 'Alert Box', + humanType: 'alert_box', url(host, token) { return `https://${host}/alert-box/v3/${token}`; }, @@ -160,6 +162,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.DonationGoal]: { name: 'Tip Goal', + humanType: 'donation_goal', url(host, token) { return `https://${host}/widgets/donation-goal?token=${token}`; }, @@ -175,6 +178,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.FollowerGoal]: { name: 'Follower Goal', + humanType: 'follower_goal', url(host, token) { return `https://${host}/widgets/follower-goal?token=${token}`; }, @@ -188,8 +192,10 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { anchor: AnchorPoint.SouthWest, }, + // TODO: what is this widget and why does it point to follower goal? [WidgetType.SubscriberGoal]: { name: 'Subscriber Goal', + humanType: 'follower_goal', url(host, token) { return `https://${host}/widgets/follower-goal?token=${token}`; }, @@ -205,6 +211,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.SubGoal]: { name: 'Sub Goal', + humanType: 'sub_goal', url(host, token) { return `https://${host}/widgets/sub-goal?token=${token}`; }, @@ -220,6 +227,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.BitGoal]: { name: 'Bit Goal', + humanType: 'bit_goal', url(host, token) { return `https://${host}/widgets/bit-goal?token=${token}`; }, @@ -235,6 +243,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.StarsGoal]: { name: 'Stars Goal', + humanType: 'stars_goal', url(host, token) { return `https://${host}/widgets/stars-goal?token=${token}`; }, @@ -250,6 +259,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.SupporterGoal]: { name: 'Supporter Goal', + humanType: 'supporter_goal', url(host, token) { return `https://${host}/widgets/supporter-goal?token=${token}`; }, @@ -265,6 +275,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.SuperchatGoal]: { name: 'Superchat Goal', + humanType: 'super_chat_goal', url(host, token) { return `https://${host}/widgets/super-chat-goal?token=${token}`; }, @@ -280,6 +291,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.CharityGoal]: { name: 'Streamlabs Charity Goal', + humanType: 'streamlabs_charity_donation_goal', url(host, token) { return `https://${host}/widgets/streamlabs-charity-donation-goal?token=${token}`; }, @@ -295,6 +307,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.DonationTicker]: { name: 'Donation Ticker', + humanType: 'donation_ticker', url(host, token) { return `https://${host}/widgets/donation-ticker?token=${token}`; }, @@ -310,6 +323,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.ChatBox]: { name: 'Chat Box', + humanType: 'chat_box', url(host, token) { return `https://${host}/widgets/chat-box/v1/${token}`; }, @@ -325,6 +339,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.EventList]: { name: 'Event List', + humanType: 'event_list', url(host, token) { return `https://${host}/widgets/event-list/v1/${token}`; }, @@ -340,6 +355,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.TipJar]: { name: 'The Jar', + humanType: 'tip_jar', url(host, token) { return `https://${host}/widgets/tip-jar/v1/${token}`; }, @@ -355,6 +371,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.StreamBoss]: { name: 'Stream Boss', + humanType: 'streamboss', url(host, token) { return `https://${host}/widgets/streamboss?token=${token}`; }, @@ -370,6 +387,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.Credits]: { name: 'Credits', + humanType: 'end_credits', url(host, token) { return `https://${host}/widgets/end-credits?token=${token}`; }, @@ -385,6 +403,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.SponsorBanner]: { name: 'Sponsor Banner', + humanType: 'sponsor_banner', url(host, token) { return `https://${host}/widgets/sponsor-banner?token=${token}`; }, @@ -399,6 +418,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { }, [WidgetType.SpinWheel]: { + humanType: 'wheel', name: 'Spin Wheel', url(host, token) { return `https://${host}/widgets/wheel?token=${token}`; @@ -415,6 +435,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { [WidgetType.MediaShare]: { name: 'Media Share', + humanType: 'media-sharing', url(host, token) { return `https://${host}/widgets/media/v1/${token}`; }, @@ -429,6 +450,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { }, [WidgetType.Poll]: { name: 'Poll', + humanType: 'poll', url(host, token) { return `https://${host}/widgets/poll/${token}`; }, @@ -443,6 +465,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { }, [WidgetType.EmoteWall]: { name: 'Emote Wall', + humanType: 'emote-wall', url(host, token) { return `https://${host}/widgets/emote-wall?token=${token}`; }, @@ -457,6 +480,7 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { }, [WidgetType.ChatHighlight]: { name: 'Chat Highlight', + humanType: 'chat_highlight', url(host, token) { return `https://${host}/widgets/chat-highlight?token=${token}`; }, @@ -469,6 +493,46 @@ export const WidgetDefinitions: { [x: number]: IWidget } = { anchor: AnchorPoint.Center, }, + [WidgetType.ViewerCount]: { + name: 'Viewer Count', + humanType: 'viewer_count', + url(host, token) { + return `https://${host}/widgets/viewer-count?token=${token}`; + }, + + width: 600, + height: 200, + x: 0, + y: 1, + anchor: AnchorPoint.SouthWest, + }, + // TODO: it seems we've half way transitioned to getWidgetsConfig but + // this list is still referenced + [WidgetType.GameWidget]: { + name: 'Game Widget', + humanType: 'game_widget', + width: 400, + height: 750, + x: 0.5, + y: 0, + anchor: AnchorPoint.North, + + url(host, token) { + return `https://${host}/widgets/game-widget?token=${token}`; + }, + }, + [WidgetType.CustomWidget]: { + name: 'Custom Widget', + humanType: 'custom_widget', + width: 400, + height: 750, + x: 0.5, + y: 0, + anchor: AnchorPoint.North, + url(host, token) { + return `https://${host}/widgets/custom-widget?token=${token}`; + }, + }, }; export const WidgetDisplayData = (platform?: string): { [x: number]: IWidgetDisplayData } => ({ diff --git a/app/services/widgets/widgets.ts b/app/services/widgets/widgets.ts index 3d6117ff011b..9d515df09ea5 100644 --- a/app/services/widgets/widgets.ts +++ b/app/services/widgets/widgets.ts @@ -26,6 +26,8 @@ import { getWidgetsConfig } from './widgets-config'; import { WidgetDisplayData } from '.'; import { DualOutputService } from 'services/dual-output'; import { TDisplayType, VideoSettingsService } from 'services/settings-v2'; +import { IncrementalRolloutService } from 'app-services'; +import { EAvailableFeatures } from 'services/incremental-rollout'; export interface IWidgetSourcesState { widgetSources: Dictionary; @@ -83,6 +85,7 @@ export class WidgetsService @Inject() editorCommandsService: EditorCommandsService; @Inject() dualOutputService: DualOutputService; @Inject() videoSettingsService: VideoSettingsService; + @Inject() incrementalRolloutService: IncrementalRolloutService; widgetDisplayData = WidgetDisplayData(); // cache widget display data @@ -251,6 +254,14 @@ export class WidgetsService } stopSyncPreviewSource(previewSourceId: string) { + if (!this.previewSourceWatchers[previewSourceId]) { + console.warn( + 'Trying to destroy preview source', + previewSourceId, + 'which is not on the watcher list, perhaps called twice?', + ); + return; + } this.previewSourceWatchers[previewSourceId].unsubscribe(); delete this.previewSourceWatchers[previewSourceId]; } @@ -448,7 +459,19 @@ export class WidgetsService } get widgetsConfig() { - return getWidgetsConfig(this.hostsService.streamlabs, this.userService.widgetToken); + // Widgets that have been ported to the new backend API at /api/v5/widgets/desktop + const widgetsWithNewAPI: WidgetType[] = []; + + // The new chatbox requires the new widget API, add it here if the user is under incremental + if (this.incrementalRolloutService.views.featureIsEnabled(EAvailableFeatures.newChatBox)) { + widgetsWithNewAPI.push(WidgetType.ChatBox); + } + + return getWidgetsConfig( + this.hostsService.streamlabs, + this.userService.widgetToken, + widgetsWithNewAPI, + ); } get alertsConfig() { diff --git a/app/styles/buttons.less b/app/styles/buttons.less index 78eacf38c3b0..38238b7ccfdf 100644 --- a/app/styles/buttons.less +++ b/app/styles/buttons.less @@ -271,6 +271,17 @@ button { } } +.square-button--kick, +.button--kick { + background: var(--kick); + color: black; + + &:hover, + &:active { + background: var(--kick-hover) !important; + } +} + .button--dlive { background: #ffd300; diff --git a/app/styles/colors.less b/app/styles/colors.less index a944124b7ff7..a40a26acf00b 100644 --- a/app/styles/colors.less +++ b/app/styles/colors.less @@ -66,4 +66,5 @@ @twitter: #1DA1F2; @tiktok: white; @trovo: #19D06D; +@kick: #54FC1F; @instagram: white; diff --git a/app/themes.g.less b/app/themes.g.less index 8e16c2ea8040..91046582f0f9 100644 --- a/app/themes.g.less +++ b/app/themes.g.less @@ -73,6 +73,7 @@ --logged-in: @lavender-light; --prime-button: @dark-2; --tooltip-hover: @light-4; + --modal-footer: @dark-2; // 3rd Party Colors --twitch: @twitch; @@ -93,6 +94,8 @@ --instagram-hover: lighten(@instagram, 4%); --trovo: @trovo; --trovo-hover: lighten(@trovo, 4%); + --kick: @kick; + --kick-hover: lighten(@kick, 4%); } .night-theme { @@ -152,7 +155,8 @@ --studio-tabs: @dark-2; --logged-in: @lavender-dark; --prime-button: @dark-2; - --tooltip-hover: @dark-4; + --tooltip-hover: @dark-5; + --modal-footer: @dark-2; // 3rd Party Colors --tiktok: @black; @@ -219,6 +223,7 @@ --logged-in: @lavender-dark; --prime-button: @dark-2; --tooltip-hover: @primedark-4; + --modal-footer: @primedark-2; // 3rd Party Colors --tiktok: @black; @@ -286,6 +291,7 @@ --logged-in: @lavender-light; --prime-button: @dark-2; --tooltip-hover: @primelight-4; + --modal-footer: @primedark-2; // 3rd Party Colors --tiktok-inverse: @black; diff --git a/electron-builder/beta.config.js b/electron-builder/beta.config.js new file mode 100644 index 000000000000..fdb148444235 --- /dev/null +++ b/electron-builder/beta.config.js @@ -0,0 +1,9 @@ +const base = require('./base.config'); + +base.win.extraFiles.push({ + from: 'electron-builder/force-local-bundles', + to: 'force-local-bundles', +}); +base.win.executableName = 'Streamlabs OBS for Beta'; + +module.exports = base; diff --git a/electron-builder/force-local-bundles b/electron-builder/force-local-bundles new file mode 100644 index 000000000000..37c0a781d29f --- /dev/null +++ b/electron-builder/force-local-bundles @@ -0,0 +1,2 @@ +This does nothing, the presence of this file means the updater for beta build +will use local bundles. diff --git a/media/images/platforms/kick-logo.png b/media/images/platforms/kick-logo.png new file mode 100644 index 000000000000..d29fa0dadfd6 Binary files /dev/null and b/media/images/platforms/kick-logo.png differ diff --git a/package.json b/package.json index 9d879b2ea72a..724f273c33ad 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,15 @@ "watch": "yarn clear && yarn compile:updater && yarn webpack-cli --watch --progress --config ./webpack.dev.config.js", "watch:strictnulls": "yarn clear && yarn compile:updater && cross-env SLOBS_STRICT_NULLS=true yarn webpack-cli --watch --progress --config ./webpack.dev.config.js", "watch:app": "yarn clear && yarn webpack-cli --watch --progress --config ./webpack.dev-app.config.js", + "watch:highlighter": "cross-env HIGHLIGHTER_ENV=local yarn watch", "start": "electron .", "clear-plugins": "rimraf plugins", "package": "yarn generate-agreement && rimraf dist && electron-builder build -w --x64 --config electron-builder/base.config.js", "package:mac": "yarn generate-agreement:mac && rimraf dist && electron-builder build -m --x64 --config electron-builder/base.config.js", "package:mac-arm64": "yarn generate-agreement:mac && rimraf dist && electron-builder build -m --arm64 --config electron-builder/base.config.js", "package:preview": "yarn generate-agreement && rimraf dist && electron-builder build -w --x64 --config electron-builder/preview.config.js", + "package:beta": "cross-env SLD_COMPILE_FOR_BETA=1 yarn compile && yarn generate-agreement && rimraf dist && electron-builder build -w --x64 --config electron-builder/beta.config.js", + "package:highlighter": "cross-env HIGHLIGHTER_ENV=staging yarn compile && cross-env SLOBS_NO_SIGN=true yarn package", "eslint": "eslint \"{app,guest-api,obs-api,updater}/**/*.ts\" main.js", "test": "tsc -p test && ava -v --timeout=3m ./test-dist/test/regular/**/*.js", "test:file": "tsc -p test && ava -v --timeout=60m", diff --git a/test/data/dummy-accounts.ts b/test/data/dummy-accounts.ts index 05c030ca3489..1b66c02f2b13 100644 --- a/test/data/dummy-accounts.ts +++ b/test/data/dummy-accounts.ts @@ -3,7 +3,7 @@ import { ITestUser } from '../helpers/webdriver/user'; import { TPlatform } from 'services/platforms'; // update this list for platforms that use dummy user accounts for tests -const platforms = ['twitter', 'instagram', 'tiktok'] as const; +const platforms = ['twitter', 'instagram', 'tiktok', 'kick'] as const; type DummyUserPlatforms = typeof platforms; export type TTestDummyUserPlatforms = DummyUserPlatforms[number]; @@ -105,7 +105,7 @@ export const instagramUser1: IDummyTestUser = { }; /** - * Twitter + * X (Twitter) */ export const twitterUser1: IDummyTestUser = { @@ -122,6 +122,24 @@ export const twitterUser1: IDummyTestUser = { widgetToken: 'twitterWidgetToken1', }; +/** + * Kick + */ + +export const kickUser1: IDummyTestUser = { + email: 'kickUser1@email.com', + workerId: 'kickWorkerId1', + updated: 'kickUpdatedId1', + username: 'kickUser1', + type: 'kick', + id: 'kickId1', + token: 'kickToken1', + apiToken: 'kickApiToken1', + ingest: 'rtmps://kickIngestUrl:443/rtmp/', + streamKey: 'kickStreamKey1', + widgetToken: 'kickWidgetToken1', +}; + /** * Check if platform should use a dummy account with tests * @param platform platform for login @@ -146,6 +164,8 @@ export function getDummyUser( if (platform === 'twitter') return twitterUser1; + if (platform === 'kick') return kickUser1; + if (platform === 'tiktok') { switch (tikTokLiveScope) { case 'approved': diff --git a/test/helpers/modules/core.ts b/test/helpers/modules/core.ts index 977d2fbc9ffa..cf4529bd34b0 100644 --- a/test/helpers/modules/core.ts +++ b/test/helpers/modules/core.ts @@ -50,6 +50,11 @@ export async function clickIfDisplayed(selectorOrEl: TSelectorOrEl) { } } +export async function clickWhenDisplayed(selectorOrEl: TSelectorOrEl, options?: WaitForOptions) { + await waitForDisplayed(selectorOrEl, options); + await click(selectorOrEl); +} + export async function clickText(text: string) { await (await select(`*=${text}`)).click(); } @@ -68,6 +73,14 @@ export async function clickCheckbox(dataName: string) { await $checkbox.click(); } +export async function selectAsyncAlert(title: string) { + await (await getClient().$('span.ant-modal-confirm-title')).waitForExist(); + const alert = await select('span.ant-modal-confirm-title'); + if ((await alert.getText()) === title) { + return alert; + } +} + // OTHER SHORTCUTS export async function hoverElement(selector: string, duration?: number) { diff --git a/test/helpers/modules/onboarding.ts b/test/helpers/modules/onboarding.ts index ce15e1c6a106..4d7e4cf79b47 100644 --- a/test/helpers/modules/onboarding.ts +++ b/test/helpers/modules/onboarding.ts @@ -6,7 +6,6 @@ export async function skipOnboarding() { if (!(await isDisplayed('h2=Live Streaming'))) return; // Uses advanced onboarding await click('h2=Live Streaming'); - await click('h2=Advanced'); await click('button=Continue'); // Auth await click('button=Skip'); @@ -14,7 +13,9 @@ export async function skipOnboarding() { await clickIfDisplayed('div=Start Fresh'); // Hardware setup await click('button=Skip'); - // Ultra + // Themes await click('button=Skip'); + // Ultra + await clickIfDisplayed('div[data-testid=choose-free-plan-btn]'); }); } diff --git a/test/helpers/webdriver/user.ts b/test/helpers/webdriver/user.ts index 8535778b851e..74ee564e928c 100644 --- a/test/helpers/webdriver/user.ts +++ b/test/helpers/webdriver/user.ts @@ -113,6 +113,7 @@ export async function logIn( features?: ITestUserFeatures, // if not set, pick a random user's account from user-pool waitForUI = true, isOnboardingTest = false, + isNewUser = false, ): Promise { if (user) throw new Error('User already logged in'); @@ -122,7 +123,7 @@ export async function logIn( throw new Error('Setup env variable SLOBS_TEST_USER_POOL_TOKEN to run this test'); } - await loginWithAuthInfo(t, user, waitForUI, isOnboardingTest); + await loginWithAuthInfo(t, user, waitForUI, isOnboardingTest, isNewUser); return user; } @@ -164,6 +165,7 @@ export async function loginWithAuthInfo( userInfo: ITestUser | IDummyTestUser, waitForUI = true, isOnboardingTest = false, + isNewUser = false, ) { const authInfo = { widgetToken: user.widgetToken, @@ -182,7 +184,9 @@ export async function loginWithAuthInfo( }; await focusWindow('worker'); const api = await getApiClient(); - await api.getResource('UserService').testingFakeAuth(authInfo, isOnboardingTest); + await api + .getResource('UserService') + .testingFakeAuth(authInfo, isOnboardingTest, isNewUser); await focusMain(); if (!waitForUI) return true; return await isLoggedIn(t); @@ -232,14 +236,20 @@ export async function withPoolUser(user: ITestUser, fn: () => Promise) { * // ... * } */ -export const withUser = (platform?: TPlatform, features?: ITestUserFeatures) => async ( +export const withUser = ( + platform?: TPlatform, + features?: ITestUserFeatures, + waitForUI = true, + isOnboardingTest = false, + isNewUser = false, +) => async ( t: ExecutionContext, implementation: ( t: ExecutionContext, user: ITestUser | IDummyTestUser, ) => Promise, ) => { - const user = await logIn(t, platform, features); + const user = await logIn(t, platform, features, waitForUI, isOnboardingTest, isNewUser); try { await implementation(t, user); diff --git a/test/regular/api/dual-output.ts b/test/regular/api/dual-output.ts index 6a66bee8e399..7b439778f8df 100644 --- a/test/regular/api/dual-output.ts +++ b/test/regular/api/dual-output.ts @@ -1,11 +1,55 @@ import { DualOutputService } from 'services/dual-output'; import { getApiClient } from '../../helpers/api-client'; import { test, useWebdriver, TExecutionContext } from '../../helpers/webdriver'; -import { ScenesService } from 'services/scenes'; +import { ScenesService, Scene, SceneItem } from 'services/scenes'; import { VideoSettingsService } from 'services/settings-v2/video'; +// not a react hook +// eslint-disable-next-line react-hooks/rules-of-hooks useWebdriver(); +function confirmDualOutputSources(t: TExecutionContext, scene: Scene) { + const numSceneItems = scene + .getItems() + .map(item => item.getModel()) + .reduce((sources, item) => { + // only track number of sources that should be + if (sources[item.sourceId]) { + sources[item.sourceId] += 1; + } else { + sources[item.sourceId] = 1; + } + return sources; + }, {} as { [sourceId: string]: number }); + + // dual output scene collections should have and even number of scene items + // because a dual output scene item scene item is a pair of horizontal and vertical + // nodes that share a single source. + for (const [sourceId, count] of Object.entries(numSceneItems)) { + t.is(count % 2, 0, `Scene does not have dual output source ${sourceId}`); + } +} + +function confirmVerticalSceneItem( + t: TExecutionContext, + scene: Scene, + horizontalSceneItem: SceneItem, + verticalSceneItemId: string, +) { + const verticalSceneItem = scene.getItem(verticalSceneItemId); + t.is( + verticalSceneItem?.display, + 'vertical', + `Vertical scene item ${verticalSceneItem.id} display is correct`, + ); + + t.is( + verticalSceneItem?.sourceId, + horizontalSceneItem.sourceId, + `Vertical scene item ${verticalSceneItem.id} and horizontal scene item ${horizontalSceneItem.id} share the same source`, + ); +} + test('Convert single output collection to dual output', async (t: TExecutionContext) => { const client = await getApiClient(); const scenesService = client.getResource('ScenesService'); @@ -30,32 +74,49 @@ test('Convert single output collection to dual output', async (t: TExecutionCont dualOutputService.convertSingleOutputToDualOutputCollection(); const sceneNodeMaps = (await client.fetchNextEvent()).data; - t.not(sceneNodeMaps, null); + t.not(sceneNodeMaps, null, 'Dual output scene collection has node maps.'); const nodeMap = sceneNodeMaps[scene.id]; const verticalContext = videoSettingsService.contexts.vertical; + const sceneItems = scene.getItems(); - scene.getItems().forEach(sceneItem => { - const item = { - id: sceneItem.id, - sourceId: sceneItem.sourceId, - display: sceneItem.display, - }; + // confirm dual output collection length is double the single output collection length + const dualOutputLength = sceneItems.length; + t.is(singleOutputLength * 2, dualOutputLength); - // confirm source and entry in node map + // confirm that converting the single output collection to a dual output collection did not add sources + confirmDualOutputSources(t, scene); + + // confirm scene items are in node map, have the correct source, and the correct video context + sceneItems.forEach(sceneItem => { if (sceneItem?.display === 'horizontal') { - const verticalItem = scene.getItem(nodeMap[sceneItem.id]); - t.is(verticalItem?.display, 'vertical'); - t.is(verticalItem?.sourceId, sceneItem.sourceId); - } + const verticalNodeId = nodeMap[sceneItem.id]; + t.truthy(verticalNodeId, `Vertical node id exists for horizontal scene item ${sceneItem.id}`); - // confirm video context - const context = sceneItem?.display === 'vertical' ? verticalContext : horizontalContext; - t.deepEqual(sceneItem?.output, context); - }); + // confirm properties for vertical scene item + confirmVerticalSceneItem(t, scene, sceneItem, verticalNodeId); - const dualOutputLength = scene.getItems().length; + // confirm video context for horizontal scene item + t.deepEqual( + sceneItem?.output, + horizontalContext, + `Horizontal scene item ${sceneItem.id} has correct video context`, + ); + } else { + const horizontalNodeId = Object.keys(nodeMap).find( + nodeId => nodeMap[nodeId] === sceneItem.id, + ); + t.truthy( + horizontalNodeId, + `Horizontal node id exists for vertical scene item ${sceneItem.id}`, + ); - // confirm dual output collection length is double the single output collection length - t.is(singleOutputLength * 2, dualOutputLength); + // confirm video context for vertical scene item + t.deepEqual( + sceneItem?.output, + verticalContext, + `Vertical scene item ${sceneItem.id} has correct video context`, + ); + } + }); }); diff --git a/test/regular/highlighter.ts b/test/regular/highlighter.ts index 8bc4ed2e26b8..43048b7f9418 100644 --- a/test/regular/highlighter.ts +++ b/test/regular/highlighter.ts @@ -33,12 +33,12 @@ test('Highlighter save and export', async t => { await stopStream(); await focusMain(); + await clickButton('All Clips'); await clickButton('Export'); const fileName = 'MyTestVideo.mp4'; const exportLocation = path.resolve(recordingDir, fileName); await fillForm({ exportLocation }); - const $exportBtn = await (await select('.ant-modal-content')).$('span=Export'); - await click($exportBtn); + await clickButton('Export Horizontal'); await waitForDisplayed('h1=Upload To', { timeout: 60000 }); t.true(fs.existsSync(exportLocation), 'The video file should exist'); }); diff --git a/test/regular/obs-importer.ts b/test/regular/obs-importer.ts index b2bdfd0ef0a9..3295d3db0598 100644 --- a/test/regular/obs-importer.ts +++ b/test/regular/obs-importer.ts @@ -48,7 +48,6 @@ test('OBS Importer', async t => { if (!(await isDisplayed('h2=Live Streaming'))) return; await click('h2=Live Streaming'); - await click('h2=Advanced'); await click('button=Continue'); await click('button=Skip'); @@ -58,15 +57,6 @@ test('OBS Importer', async t => { await click('button=Skip'); */ - /* - * TODO: "Advanced" flow doesn't have a login, but we couldn't get this to pass - * when trying to go through the Intermediate flow which does have login. - * After fixing everything step-related there, it was stuck on the loader after - * switching to the Widgets collection. - * Since going through Onboarding as Intermediate (or any other mode) is already - * covered by their own tests, we're faking login here while remaining on the - * Advanced flow. We need the login for widget assertions below to pass. - */ await logIn(t, 'twitch', { prime: false }, false, true); await sleep(1000); @@ -75,7 +65,8 @@ test('OBS Importer', async t => { await click('div=Start'); // skip Ultra - await waitForDisplayed('div=Choose Starter'); + await waitForDisplayed('div[data-testid=choose-free-plan-btn]'); + // skip Themes await click('button=Skip'); await waitForDisplayed('[data-name=SceneSelector]'); diff --git a/test/regular/onboarding.ts b/test/regular/onboarding.ts index b22f712676f4..30aecafac6b8 100644 --- a/test/regular/onboarding.ts +++ b/test/regular/onboarding.ts @@ -1,199 +1,292 @@ -import { test, useWebdriver } from '../helpers/webdriver'; -import { logIn } from '../helpers/webdriver/user'; +import { test, TExecutionContext, useWebdriver } from '../helpers/webdriver'; +import { logIn, withPoolUser } from '../helpers/webdriver/user'; import { sleep } from '../helpers/sleep'; import { click, clickIfDisplayed, + clickWhenDisplayed, focusMain, isDisplayed, + getNumElements, waitForDisplayed, } from '../helpers/modules/core'; +import { getApiClient } from '../helpers/api-client'; +import { ScenesService } from '../../app/services/api/external-api/scenes'; + +/** + * Testing default sources for onboarding and new users + * @remark New users on their first login have special handling. To optimize testing, + * some of the cases are tested within existing tests. + * + * CASE 1: Old user logged in during onboarding, no theme installed (Go through onboarding) + * CASE 2: Old user logged in during onboarding, theme installed (Go through onboarding and install theme) + * CASE 3: New user logged in during onboarding, no theme installed (Go through onboarding as a new user) + * CASE 4: New user logged in during onboarding, theme installed (Go through onboarding as a new user and install theme) + * CASE 5: No user logged in during onboarding, no theme installed, then log in new user (Login new user after onboarding skipped) + * CASE 6: No user logged in during onboarding, theme installed, then log in new user (Login new user after onboarding skipped and theme installed) + * CASE 7: No user logged in during onboarding, no theme installed, then log in an old user (Scene-collections cloud-backup) <- tested in the cloud-backup test + */ // not a react hook // eslint-disable-next-line react-hooks/rules-of-hooks -useWebdriver({ skipOnboarding: false }); - -test('Go through onboarding login and signup', async t => { - const app = t.context.app; +useWebdriver({ skipOnboarding: false, noSync: true }); + +async function confirmDefaultSources(t: TExecutionContext, hasDefaultSources = true) { + const api = await getApiClient(); + const scenesService = api.getResource('ScenesService'); + const defaultSources = ['Game Capture', 'Webcam', 'Alert Box']; + const numDefaultSources = defaultSources.length; + + const numSceneItems = scenesService.activeScene + .getItems() + .map(item => item.getModel()) + .reduce((sources, item) => { + // only track number of sources that should be + if (sources[item.sourceId] && defaultSources.includes(item.name)) { + sources[item.sourceId] += 1; + } else { + sources[item.sourceId] = 1; + } + return sources; + }, {} as { [sourceId: string]: number }); + + if (hasDefaultSources) { + // confirm this is a single output scene collection by confirming that each source + // is only used by a single scene item. This is because dual output scene collection + // scene items share a single source. + for (const [sourceId, count] of Object.entries(numSceneItems)) { + t.is(count, 1, `Scene has only once scene item with source ${sourceId}`); + } + + t.is(Object.keys(numSceneItems).length, numDefaultSources, 'Scene has correct default sources'); + } else { + // overlays installed during onboarding should have default sources more or less sources than the defaults + const numDefaultSources = Object.keys(numSceneItems).filter( + name => defaultSources.includes(name) && numSceneItems[name] > 1, + ).length; + + t.not(Object.keys(numSceneItems).length, numDefaultSources, 'Scene has no default sources'); + } +} + +/* + * Helper function to go through the onboarding flow through the login step + * @remark This function is a simplification of the `Go through onboarding` test + * @param t - Test execution context + * @param installTheme - Whether to install a theme during onboarding + * @param fn - Function to run after onboarding is complete + */ +async function goThroughOnboarding( + t: TExecutionContext, + login = false, + newUser = false, + installTheme = false, + fn: () => Promise, +) { await focusMain(); if (!(await isDisplayed('h2=Live Streaming'))) return; await click('h2=Live Streaming'); - await click('h2=Beginner'); await click('button=Continue'); - t.true(await isDisplayed('h1=Sign Up'), 'Shows signup page by default'); - t.true(await isDisplayed('button=Create a Streamlabs ID'), 'Has a create Streamlabs ID button'); - - // Click on Login on the signup page, then wait for the auth screen to appear await click('a=Login'); - await isDisplayed('button=Log in with Twitch'); - - t.truthy( - await Promise.all( - ['Twitch', 'YouTube', 'Facebook'].map(async platform => - (await app.client.$(`button=Log in with ${platform}`)).isExisting(), - ), - ), - 'Shows login buttons for Twitch, YouTube, and Facebook', - ); - - t.truthy( - await Promise.all( - ['Trovo', 'TikTok', 'Dlive', 'NimoTV'].map(async platform => - (await app.client.$(`aria/Login with ${platform}`)).isExisting(), - ), - ), - 'Shows icon buttons for Trovo, TikTok, Dlive, and NimoTV', - ); - - t.true(await isDisplayed('a=Sign up'), 'Has a link to go back to Sign Up'); -}); - -test('Go through onboarding as beginner user', async t => { - const app = t.context.app; - await focusMain(); - - if (!(await isDisplayed('h2=Live Streaming'))) return; - - await click('h2=Live Streaming'); - // Choose Beginner onboarding - await click('h2=Beginner'); - await click('button=Continue'); - - // Click on Login on the signup page, then wait for the auth screen to appear - await click('a=Login'); - await isDisplayed('button=Log in with Twitch'); - - await logIn(t, 'twitch', { prime: false }, false, true); - await sleep(1000); - - // We seem to skip the login step after login internally - await clickIfDisplayed('button=Skip'); - - // Don't Import from OBS - await clickIfDisplayed('div=Start Fresh'); + // Complete login + if (login) { + await isDisplayed('button=Log in with Twitch'); + const user = await logIn(t, 'twitch', { prime: false }, false, true, newUser); + await sleep(1000); + + // We seem to skip the login step after login internally + await clickIfDisplayed('button=Skip'); + + // Finish onboarding flow + await withPoolUser(user, async () => { + await finishOnboarding(installTheme); + await fn(); + }); + } else { + // skip login + await clickIfDisplayed('button=Skip'); + await finishOnboarding(installTheme); + await fn(); + } + + t.pass(); +} + +/* + * Helper function to go through the onboarding flow from the login step to the end + * @param installTheme - Whether to install a theme during onboarding + */ +async function finishOnboarding(installTheme = false) { // Skip hardware config - await waitForDisplayed('h1=Set Up Mic and Webcam'); + await waitForDisplayed('h1=Set up your mic & webcam'); await clickIfDisplayed('button=Skip'); - // Skip picking a theme - await waitForDisplayed('h1=Add an Overlay'); - await clickIfDisplayed('button=Skip'); + // Theme install + if (installTheme) { + await waitForDisplayed('h1=Add your first theme'); + await clickWhenDisplayed('button=Install'); + await waitForDisplayed('span=100%'); + } else { + await waitForDisplayed('h1=Add your first theme'); + await clickIfDisplayed('button=Skip'); + } // Skip purchasing prime - // TODO: is this timeout because of autoconfig? - await waitForDisplayed('div=Choose Starter', { timeout: 60000 }); - await click('div=Choose Starter'); - - // Click Get Started after seeing tips - t.true( - await isDisplayed('span=Set yourself up for success with our getting started guide'), - 'Shows beginner tips', - ); - await clickIfDisplayed('button=Get Started'); + await clickWhenDisplayed('div[data-testid=choose-free-plan-btn]', { timeout: 60000 }); - await waitForDisplayed('span=Sources', { timeout: 60000 }); - - // success? - t.true(await (await app.client.$('span=Sources')).isDisplayed(), 'Sources selector is visible'); -}); + await isDisplayed('span=Sources'); +} -// TODO: this is the same as beginner as of the current flow, aside page diffs just asserts tips are different -test('Go through onboarding as intermediate user', async t => { - const app = t.context.app; +// CASE 1: Old user logged in during onboarding, no theme installed +test('Go through onboarding', async t => { await focusMain(); if (!(await isDisplayed('h2=Live Streaming'))) return; await click('h2=Live Streaming'); - // Choose Intermediate onboarding - await click('h2=Intermediate'); await click('button=Continue'); + // Signup page + t.true(await isDisplayed('h1=Sign Up'), 'Shows signup page by default'); + t.true(await isDisplayed('button=Create a Streamlabs ID'), 'Has a create Streamlabs ID button'); + // Click on Login on the signup page, then wait for the auth screen to appear await click('a=Login'); - await isDisplayed('button=Log in with Twitch'); - await logIn(t, 'twitch', { prime: false }, false, true); - await sleep(1000); + // Check for all the login buttons + t.true(await isDisplayed('button=Log in with Twitch'), 'Shows Twitch button'); + t.true(await isDisplayed('button=Log in with YouTube'), 'Shows YouTube button'); + t.true(await isDisplayed('button=Log in with Facebook'), 'Shows Facebook button'); + t.true(await isDisplayed('button=Log in with TikTok'), 'Shows TikTok button'); - // We seem to skip the login step after login internally - await clickIfDisplayed('button=Skip'); + // Check for all the login icons + t.true(await isDisplayed('[data-testid=platform-icon-button-trovo]'), 'Shows Trovo button'); + t.true(await isDisplayed('[data-testid=platform-icon-button-dlive]'), 'Shows Dlive button'); + t.true(await isDisplayed('[data-testid=platform-icon-button-nimotv]'), 'Shows NimoTV button'); - // Don't Import from OBS - await clickIfDisplayed('div=Start Fresh'); + t.true(await isDisplayed('a=Sign up'), 'Has a link to go back to Sign Up'); - // Skip hardware config - await waitForDisplayed('h1=Set Up Mic and Webcam'); + // Complete login + await isDisplayed('button=Log in with Twitch'); + const user = await logIn(t, 'twitch', { prime: false }, false, true); + await sleep(1000); + // We seem to skip the login step after login internally await clickIfDisplayed('button=Skip'); - // Skip picking a theme - await waitForDisplayed('h1=Add an Overlay'); - await clickIfDisplayed('button=Skip'); + // Finish onboarding flow + await withPoolUser(user, async () => { + // Skip hardware config + await waitForDisplayed('h1=Set up your mic & webcam'); + await clickIfDisplayed('button=Skip'); - // Skip purchasing prime - // TODO: is this timeout because of autoconfig? - await waitForDisplayed('div=Choose Starter', { timeout: 60000 }); - await click('div=Choose Starter'); + // Skip picking a theme + await waitForDisplayed('h1=Add your first theme'); + await clickIfDisplayed('button=Skip'); - // Click Get Started after seeing tips - t.true( - await isDisplayed('span=Set up your alerts and widgets on Streamlabs Dashboard'), - 'Shows intermediate tips', - ); - await clickIfDisplayed('button=Get Started'); + // Skip purchasing prime + await clickWhenDisplayed('div[data-testid=choose-free-plan-btn]', { timeout: 60000 }); - await waitForDisplayed('span=Sources', { timeout: 60000 }); + t.true(await isDisplayed('span=Sources'), 'Sources selector is visible'); - // success? - // prettier-ignore - t.true(await (await app.client.$('span=Sources')).isDisplayed(), 'Sources selector is visible'); + // Confirm sources and dual output status + t.is( + await getNumElements('div[data-role=source]'), + 0, + 'Old user onboarded without theme has no sources', + ); + t.true(await isDisplayed('i[data-testid=dual-output-inactive]'), 'Dual output not enabled'); + }); + + t.pass(); }); -test('Go through onboarding as advanced user', async t => { - const app = t.context.app; - await focusMain(); +// CASE 2: New user not logged in during onboarding, theme installed +// CASE 6: No user logged in during onboarding, theme installed, then log in new user +// NOTE: Skipped when running remotely but this test is functional +test.skip('Go through onboarding and install theme', async t => { + const login = false; + const newUser = true; + const installTheme = true; - if (!(await isDisplayed('h2=Live Streaming'))) return; + await goThroughOnboarding(t, login, newUser, installTheme, async () => { + // Confirm sources and dual output status + t.not(await getNumElements('div[data-role=source]'), 0, 'Theme installed before login'); + t.true(await isDisplayed('i[data-testid=dual-output-inactive]'), 'Single output enabled'); - await click('h2=Live Streaming'); - // Choose Advanced onboarding - await click('h2=Advanced'); - await click('button=Continue'); + // login new user after onboarding + await clickIfDisplayed('li[data-testid=nav-auth]'); - // Click on Login on the signup page, then wait for the auth screen to appear - await click('a=Login'); - await isDisplayed('button=Log in with Twitch'); + await isDisplayed('button=Log in with Twitch'); + await logIn(t, 'twitch', { prime: false }, false, false, true); + await sleep(1000); - await logIn(t, 'twitch', { prime: false }, false, true); - await sleep(1000); + // Confirm switched to scene with default sources and dual output status + await confirmDefaultSources(t); + t.true(await isDisplayed('i[data-testid=dual-output-inactive]'), 'Single output enabled.'); + }); - // We seem to skip the login step after login internally - await clickIfDisplayed('button=Skip'); + t.pass(); +}); - // Don't Import from OBS - await clickIfDisplayed('div=Start Fresh'); +// CASE 3: New user logged in during onboarding, no theme installed +test('Go through onboarding as a new user', async t => { + const login = true; + const newUser = true; + const installTheme = false; - // Skip hardware config - await waitForDisplayed('h1=Set Up Mic and Webcam'); - await clickIfDisplayed('button=Skip'); + await goThroughOnboarding(t, login, newUser, installTheme, async () => { + // Confirm sources and dual output status + await confirmDefaultSources(t); + t.true(await isDisplayed('i[data-testid=dual-output-inactive]'), 'Single output enabled.'); + }); - // Skip purchasing prime - // TODO: is this timeout because of autoconfig? - await waitForDisplayed('div=Choose Starter', { timeout: 60000 }); - await click('div=Choose Starter'); + t.pass(); +}); - await waitForDisplayed('span=Sources', { timeout: 60000 }); +// CASE 4: New user logged in during onboarding, theme installed +// NOTE: Skipped when running remotely but this test is functional +test.skip('Go through onboarding as a new user and install theme', async t => { + const login = true; + const newUser = true; + const installTheme = true; + const hasDefaultSources = false; + + await goThroughOnboarding(t, login, newUser, installTheme, async () => { + // Confirm sources and dual output status + await confirmDefaultSources(t, hasDefaultSources); + t.true(await isDisplayed('i[data-testid=dual-output-inactive]'), 'Single output enabled.'); + }); + + t.pass(); +}); - // success? - // prettier-ignore - t.true(await (await app.client.$('span=Sources')).isDisplayed(), 'Sources selector is visible'); +// CASE 5: No user logged in during onboarding, no theme installed, then log in new user +test('Login new user after onboarding skipped', async t => { + const login = false; + const newUser = false; + const installTheme = false; + + await goThroughOnboarding(t, login, newUser, installTheme, async () => { + // login new user after onboarding + await clickIfDisplayed('li[data-testid=nav-auth]'); + + await isDisplayed('button=Log in with Twitch'); + await logIn(t, 'twitch', { prime: false }, false, false, true); + await sleep(1000); + + // Confirm switched to scene with default sources and dual output status + await confirmDefaultSources(t); + t.true(await isDisplayed('i[data-testid=dual-output-inactive]'), 'Dual output not enabled.'); + }); + + t.pass(); }); -// TODO: this test is the same as beginner except with autoconfig, make specific assertions here once re-enabled +// TODO: refactor to updated onboarding flow and make specific assertions here once re-enabled test.skip('Go through the onboarding and autoconfig', async t => { const app = t.context.app; await focusMain(); @@ -201,7 +294,6 @@ test.skip('Go through the onboarding and autoconfig', async t => { if (!(await isDisplayed('h2=Live Streaming'))) return; await click('h2=Live Streaming'); - await click('h2=Beginner'); await click('button=Continue'); // Click on Login on the signup page, then wait for the auth screen to appear @@ -219,7 +311,7 @@ test.skip('Go through the onboarding and autoconfig', async t => { await clickIfDisplayed('div=Start Fresh'); // Skip hardware config - await waitForDisplayed('h1=Set Up Mic and Webcam'); + await waitForDisplayed('h1=Set up your mic & webcam'); await clickIfDisplayed('button=Skip'); // Skip picking a theme @@ -233,15 +325,8 @@ test.skip('Go through the onboarding and autoconfig', async t => { // Skip purchasing prime // TODO: is this timeout because of autoconfig? - await waitForDisplayed('div=Choose Starter', { timeout: 60000 }); - await click('div=Choose Starter'); - - // Click Get Started after seeing tips - t.true( - await isDisplayed('span=Set yourself up for success with our getting started guide'), - 'Shows beginner tips', - ); - await clickIfDisplayed('button=Get Started'); + await waitForDisplayed('div[data-testid=choose-free-plan-btn]', { timeout: 60000 }); + await click('div[data-testid=choose-free-plan-btn]'); await waitForDisplayed('span=Sources', { timeout: 60000 }); diff --git a/test/regular/services/scene-collections/cloud-backup.ts b/test/regular/services/scene-collections/cloud-backup.ts index ca98a72b0b87..22d4e3a4cc11 100644 --- a/test/regular/services/scene-collections/cloud-backup.ts +++ b/test/regular/services/scene-collections/cloud-backup.ts @@ -4,11 +4,13 @@ import { getApiClient } from '../../../helpers/api-client'; import { logIn, loginWithAuthInfo } from '../../../helpers/webdriver/user'; import { SceneCollectionsService } from '../../../../app/services/api/external-api/scene-collections'; +// not a react hook +// eslint-disable-next-line react-hooks/rules-of-hooks useWebdriver({ noSync: false }); test('Scene-collections cloud-backup', async t => { // log-in and save the credentials - const authInfo = await logIn(t); + const authInfo = await logIn(t, 'twitch', {}, true, true); // create an new empty collection const api = await getApiClient(); diff --git a/test/regular/streaming/dual-output.ts b/test/regular/streaming/dual-output.ts index 22551b582e33..bce600bbc823 100644 --- a/test/regular/streaming/dual-output.ts +++ b/test/regular/streaming/dual-output.ts @@ -9,6 +9,7 @@ import { focusChild, focusMain, isDisplayed, + selectAsyncAlert, waitForDisplayed, } from '../../helpers/modules/core'; import { logIn } from '../../helpers/modules/user'; @@ -23,6 +24,8 @@ import { withUser } from '../../helpers/webdriver/user'; import { SceneBuilder } from '../../helpers/scene-builder'; import { getApiClient } from '../../helpers/api-client'; +// not a react hook +// eslint-disable-next-line react-hooks/rules-of-hooks useWebdriver(); /** @@ -108,8 +111,12 @@ test( // cannot use dual output mode with only one platform linked await submit(); - await waitForDisplayed( - 'div=To use Dual Output you must stream to at least one horizontal and one vertical platform.', + + t.true( + await ( + await selectAsyncAlert('Confirm Horizontal and Vertical Platforms') + ).waitForDisplayed(), + 'Alert is open', ); t.pass(); @@ -130,8 +137,11 @@ test( // cannot use dual output mode with all platforms assigned to one display await submit(); - await waitForDisplayed( - 'div=To use Dual Output you must stream to at least one horizontal and one vertical platform.', + t.true( + await ( + await selectAsyncAlert('Confirm Horizontal and Vertical Platforms') + ).waitForDisplayed(), + 'Alert is open', ); t.pass(); diff --git a/test/regular/streaming/kick.ts b/test/regular/streaming/kick.ts new file mode 100644 index 000000000000..4cd0ad503e11 --- /dev/null +++ b/test/regular/streaming/kick.ts @@ -0,0 +1,32 @@ +import { skipCheckingErrorsInLog, test, useWebdriver } from '../../helpers/webdriver'; +import { + clickGoLive, + prepareToGoLive, + stopStream, + submit, + waitForSettingsWindowLoaded, + waitForStreamStart, +} from '../../helpers/modules/streaming'; +import { addDummyAccount, withUser } from '../../helpers/webdriver/user'; +import { fillForm } from '../../helpers/modules/forms'; +import { isDisplayed, waitForDisplayed } from '../../helpers/modules/core'; + +// not a react hook +// eslint-disable-next-line react-hooks/rules-of-hooks +useWebdriver(); + +test('Streaming to Kick', withUser('twitch', { multistream: true }), async t => { + await addDummyAccount('kick'); + + await prepareToGoLive(); + await clickGoLive(); + await waitForSettingsWindowLoaded(); + + // because streaming cannot be tested, check that Kick can be toggled on + await fillForm({ + kick: true, + }); + await waitForSettingsWindowLoaded(); + + t.pass(); +}); diff --git a/test/regular/streaming/multistream.ts b/test/regular/streaming/multistream.ts index 20cbeb309487..23f2bf71add2 100644 --- a/test/regular/streaming/multistream.ts +++ b/test/regular/streaming/multistream.ts @@ -13,12 +13,16 @@ import { logIn } from '../../helpers/modules/user'; import { releaseUserInPool, reserveUserFromPool, withUser } from '../../helpers/webdriver/user'; import { showSettingsWindow } from '../../helpers/modules/settings/settings'; import { test, useWebdriver } from '../../helpers/webdriver'; +import { sleep } from '../../helpers/sleep'; +// not a react hook +// eslint-disable-next-line react-hooks/rules-of-hooks useWebdriver(); async function enableAllPlatforms() { for (const platform of ['twitch', 'youtube', 'trovo']) { await fillForm({ [platform]: true }); + await sleep(500); await waitForSettingsWindowLoaded(); } } @@ -118,7 +122,7 @@ test('Custom stream destinations', async t => { await click('span=Add Destination'); await fillForm({ - name: `MyCustomDest`, + name: 'MyCustomDest', url: 'rtmp://live.twitch.tv/app/', streamKey: user.streamKey, }); diff --git a/test/regular/streaming/tiktok.ts b/test/regular/streaming/tiktok.ts index cdc9ea6f4297..0d9adbf59d2f 100644 --- a/test/regular/streaming/tiktok.ts +++ b/test/regular/streaming/tiktok.ts @@ -18,6 +18,8 @@ import { IDummyTestUser, tikTokUsers } from '../../data/dummy-accounts'; import { TTikTokLiveScopeTypes } from 'services/platforms/tiktok/api'; import { isDisplayed, waitForDisplayed } from '../../helpers/modules/core'; +// not a react hook +// eslint-disable-next-line react-hooks/rules-of-hooks useWebdriver(); test('Streaming to TikTok', withUser('twitch', { multistream: false, prime: false }), async t => { @@ -56,8 +58,8 @@ test('Streaming to TikTok', withUser('twitch', { multistream: false, prime: fals await stopStream(); // test all other tiktok statuses - await testLiveScope(t, 'denied'); await testLiveScope(t, 'legacy'); + await testLiveScope(t, 'denied'); await testLiveScope(t, 'relog'); t.pass(); @@ -76,21 +78,38 @@ async function testLiveScope(t: TExecutionContext, scope: TTikTokLiveScopeTypes) // denied scope should show prompt to remerge TikTok account if (scope === 'relog') { skipCheckingErrorsInLog(); - t.true(await isDisplayed('div=Failed to update TikTok account', { timeout: 1000 })); + + t.true( + await isDisplayed('div=Failed to update TikTok account', { timeout: 3000 }), + 'TikTok remerge error shown', + ); return; } - await waitForSettingsWindowLoaded(); + if (scope === 'denied') { + await waitForSettingsWindowLoaded(); + await submit(); - await fillForm({ - tiktok: true, - }); + t.true( + await isDisplayed( + "span=Couldn't confirm TikTok Live Access. Apply for Live Permissions below", + { timeout: 3000 }, + ), + 'TikTok denied error shown', + ); + + await waitForDisplayed('span=Update settings for TikTok'); + await waitForStreamStart(); + await stopStream(); + + return; + } + + // test legacy scope await waitForSettingsWindowLoaded(); await waitForDisplayed('div[data-name="tiktok-settings"]'); const settings = { - title: 'Test stream', - twitchGame: 'Fortnite', serverUrl: user.serverUrl, streamKey: user.streamKey, }; diff --git a/updater/bundle-updater.ts b/updater/bundle-updater.ts index 81653dd81b5a..af67e0beff9d 100644 --- a/updater/bundle-updater.ts +++ b/updater/bundle-updater.ts @@ -299,6 +299,11 @@ module.exports = async (basePath: string) => { useLocalBundles = true; } + const forceLocalBundles = path.join(basePath, '../../force-local-bundles'); + if (fs.existsSync(forceLocalBundles)) { + useLocalBundles = true; + } + const localManifest: IManifest = require(path.join(`${basePath}/bundles/manifest.json`)); console.log('Local bundle info:', localManifest); diff --git a/webpack.base.config.js b/webpack.base.config.js index eb631244455d..495300911da8 100644 --- a/webpack.base.config.js +++ b/webpack.base.config.js @@ -10,16 +10,25 @@ const plugins = []; const commit = cp.execSync('git rev-parse --short HEAD').toString().replace('\n', ''); -plugins.push( - new webpack.DefinePlugin({ - SLOBS_BUNDLE_ID: JSON.stringify(commit), - SLD_SENTRY_FRONTEND_DSN: JSON.stringify(process.env.SLD_SENTRY_FRONTEND_DSN ?? ''), - SLD_SENTRY_BACKEND_SERVER_URL: JSON.stringify(process.env.SLD_SENTRY_BACKEND_SERVER_URL ?? ''), - SLD_SENTRY_BACKEND_SERVER_PREVIEW_URL: JSON.stringify( - process.env.SLD_SENTRY_BACKEND_SERVER_PREVIEW_URL ?? '', - ), - }), -); +const envDef = { + SLOBS_BUNDLE_ID: JSON.stringify(commit), + SLD_SENTRY_FRONTEND_DSN: JSON.stringify(process.env.SLD_SENTRY_FRONTEND_DSN ?? ''), + SLD_SENTRY_BACKEND_SERVER_URL: JSON.stringify(process.env.SLD_SENTRY_BACKEND_SERVER_URL ?? ''), + SLD_SENTRY_BACKEND_SERVER_PREVIEW_URL: JSON.stringify( + process.env.SLD_SENTRY_BACKEND_SERVER_PREVIEW_URL ?? '', + ), +}; + +if (process.env.SLD_COMPILE_FOR_BETA) { + console.log('Compiling build with forced beta SL host.'); + envDef['process.env.SLD_COMPILE_FOR_BETA'] = JSON.stringify(true); +} +if (process.env.HIGHLIGHTER_ENV) { + console.log('Compiling build with ' + process.env.HIGHLIGHTER_ENV + ' highlighter version.'); + envDef['process.env.HIGHLIGHTER_ENV'] = JSON.stringify(process.env.HIGHLIGHTER_ENV ?? ''); +} + +plugins.push(new webpack.DefinePlugin(envDef)); plugins.push( new WebpackManifestPlugin({