diff --git a/app/components-react/highlighter/ImportStream.tsx b/app/components-react/highlighter/ImportStream.tsx index 10bf49d3cced..db341cea3339 100644 --- a/app/components-react/highlighter/ImportStream.tsx +++ b/app/components-react/highlighter/ImportStream.tsx @@ -3,10 +3,15 @@ import { Services } from 'components-react/service-provider'; import { ListInput, TextInput } from 'components-react/shared/inputs'; import Form from 'components-react/shared/inputs/Form'; import * as remote from '@electron/remote'; -import { SUPPORTED_FILE_TYPES } from 'services/highlighter/constants'; +import { + HIGHLIGHTER_APP_NAME, + REPLAY_APP_NAME, + SUPPORTED_FILE_TYPES, +} from 'services/highlighter/constants'; import { EGame } from 'services/highlighter/models/ai-highlighter.models'; import { IStreamInfoForAiHighlighter, + TInstalledHighlighterApp, TOpenedFrom, } from 'services/highlighter/models/highlighter.models'; import { $t } from 'services/i18n'; @@ -35,7 +40,7 @@ export function ImportStreamModal({ streamInfo?: IStreamInfoForAiHighlighter; }) { const { HighlighterService, UsageStatisticsService, IncrementalRolloutService } = Services; - const [replayInstalled, setReplayInstalled] = useState(null); + const [installedApp, setInstalledApp] = useState(null); const [showingInstallFlow, setShowingInstallFlow] = useState(false); const [pendingImport, setPendingImport] = useState<{ game: EGame; @@ -44,8 +49,8 @@ export function ImportStreamModal({ } | null>(null); useEffect(() => { - HighlighterService.isStreamlabsReplayInstalled().then(installed => { - setReplayInstalled(installed); + HighlighterService.actions.return.getInstalledHighlighterApp().then(app => { + setInstalledApp(app); }); }, []); @@ -147,15 +152,20 @@ export function ImportStreamModal({ return; } - // If Replay isn't installed yet, defer the import: stash the details, kick off the - // install, and replay it via onInstallComplete. This keeps every entry point (Go-live - // and the Highlighter page) on the same flow — game + title first, install second, - // then Replay opens directly on the import screen with the game and video. - const isInstalled = await HighlighterService.isStreamlabsReplayInstalled(); - if (!isInstalled) { + // With neither app installed, hand the import to the installer instead of deeplinking it: + // the video and game go into the install origin marker, and Replay picks them up on its + // first launch. This keeps every entry point (Go-live and the Highlighter page) on the + // same flow — game + title first, install second, then Replay opens directly on the import + // screen with the game and video. pendingImport is only kept so a retry writes the same + // marker and so onInstallComplete can close this modal. + // + // With either app installed the import is deeplinked, and the service picks the protocol — + // a Highlighter user is sent to Highlighter, where their data still lives. + const app = await HighlighterService.actions.return.getInstalledHighlighterApp(); + if (app === 'none') { setPendingImport({ game, filePath: filePath[0], streamId: id }); setShowingInstallFlow(true); - HighlighterService.installStreamlabsReplay(); + HighlighterService.actions.installStreamlabsReplay({ videoPath: filePath[0], game }); return; } @@ -183,23 +193,32 @@ export function ImportStreamModal({ return ( { setShowingInstallFlow(false); closeModal(true); }} onInstallComplete={() => { - setReplayInstalled(true); + setInstalledApp('replay'); setShowingInstallFlow(false); - // If there's a pending import, execute it now + // Deliberately no import deeplink here. The install origin marker already carried the + // video and game, and Replay acts on it when the installer launches it, so sending the + // link as well would open the same import a second time. Only the tracking the + // deeplink would have recorded is kept. if (pendingImport) { - HighlighterService.actions.openReplayImport( - pendingImport.filePath, - pendingImport.game, + UsageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'ReplayImport', openedFrom, - pendingImport.streamId, - inputValue, - ); + streamId: pendingImport.streamId, + game: pendingImport.game, + // Separates marker hand-offs from deeplinked imports in the ReplayImport numbers + via: 'install-marker', + }); setPendingImport(null); closeModal(false); } @@ -211,7 +230,7 @@ export function ImportStreamModal({ // Show the install UI only once the user has committed to importing (game + title // selected, then startImport triggers the install). Applies to every entry point so // the import form is always shown first, never the install flow. - if (migrationEnabled && replayInstalled === false && showingInstallFlow) { + if (migrationEnabled && installedApp === 'none' && showingInstallFlow) { return ( {renderMigrationNotice()} @@ -365,11 +384,16 @@ export function ImportStreamModal({
- {replayInstalled ? ( -

Continuing will open Streamlabs Highlighter

- ) : ( -

Continuing will install Streamlabs Highlighter

- )} +

+ {' '} + {installedApp === 'replay' && + $t('Continuing will open %{appName}', { appName: REPLAY_APP_NAME })} + {installedApp === 'highlighter' && + $t('Continuing will open %{appName}', { appName: HIGHLIGHTER_APP_NAME })} + {installedApp !== 'replay' && + installedApp !== 'highlighter' && + $t('Continuing will install %{appName}', { appName: REPLAY_APP_NAME })} +

); diff --git a/app/components-react/highlighter/SettingsView.tsx b/app/components-react/highlighter/SettingsView.tsx index 6e0d22645f35..d3b45699528d 100644 --- a/app/components-react/highlighter/SettingsView.tsx +++ b/app/components-react/highlighter/SettingsView.tsx @@ -38,7 +38,7 @@ export default function SettingsView({ EAvailableFeatures.highlighterMigration, ); - const [isReplayInstalled, setIsReplayInstalled] = useState(false); + const [isReplayInstalled, setIsReplayInstalled] = useState(null); useEffect(() => { if (!migrationEnabled) return; @@ -56,7 +56,8 @@ export default function SettingsView({ outputDisplay: StreamingService.views.outputDisplay, })); - const isInstalled = migrationEnabled ? isReplayInstalled : v.highlighterVersion !== ''; + const installationCheckComplete = !migrationEnabled || isReplayInstalled !== null; + const isInstalled = migrationEnabled ? isReplayInstalled === true : v.highlighterVersion !== ''; const disableAIHighlighter = (v.isVerticalRecording || v.isVerticalReplayBuffer) && v.outputDisplay === 'vertical'; @@ -197,6 +198,8 @@ export default function SettingsView({ toggleUseAiHighlighter(); } + if (!installationCheckComplete) return
; + return (
diff --git a/app/components-react/highlighter/migration/MigrationNotice.m.less b/app/components-react/highlighter/migration/MigrationNotice.m.less index 3d78b6e8f563..fdc3b0757f65 100644 --- a/app/components-react/highlighter/migration/MigrationNotice.m.less +++ b/app/components-react/highlighter/migration/MigrationNotice.m.less @@ -168,6 +168,24 @@ // Feature Carousel // ================================================================================================= +.app-detection { + width: 100%; + opacity: 0; + // Keeps the hidden carousel out of the tab order while app detection is pending — opacity and + // pointer-events alone leave the install CTA keyboard-focusable. + visibility: hidden; + pointer-events: none; + // Hold the visibility flip until the fade-out finishes; instant on the way in. + transition: opacity 160ms ease, visibility 0s linear 160ms; +} + +.app-detection-ready { + opacity: 1; + visibility: visible; + pointer-events: auto; + transition: opacity 160ms ease, visibility 0s; +} + .carousel-wrapper { display: flex; background-color: var(--dark-background); diff --git a/app/components-react/highlighter/migration/MigrationNotice.tsx b/app/components-react/highlighter/migration/MigrationNotice.tsx index 654bef52fa6e..95e9d706ec1e 100644 --- a/app/components-react/highlighter/migration/MigrationNotice.tsx +++ b/app/components-react/highlighter/migration/MigrationNotice.tsx @@ -3,12 +3,15 @@ import { Services } from 'components-react/service-provider'; import ModalInstallationFlow from './ModalInstallationFlow'; import PageInstallationFlow from './PageInstallationFlow'; import { EAvailableFeatures } from 'services/incremental-rollout'; +import { IReplayInstallOriginMetadata } from 'services/highlighter/models/highlighter.models'; interface IMigrationNoticeProps { variant?: 'page' | 'modal'; onShowAllClips?: () => void; onCancel?: () => void; onInstallComplete?: () => void; + /** Hand-off data for the install origin marker, used when the user retries a failed install. */ + installOriginMetadata?: IReplayInstallOriginMetadata; } export default function MigrationNotice(props: IMigrationNoticeProps) { @@ -37,7 +40,11 @@ export default function MigrationNotice(props: IMigrationNoticeProps) { if (variant === 'modal') { return ( - + ); } diff --git a/app/components-react/highlighter/migration/ModalInstallationFlow.tsx b/app/components-react/highlighter/migration/ModalInstallationFlow.tsx index 3bb302d97f37..0e5d4729e2d9 100644 --- a/app/components-react/highlighter/migration/ModalInstallationFlow.tsx +++ b/app/components-react/highlighter/migration/ModalInstallationFlow.tsx @@ -2,28 +2,35 @@ import React, { useEffect } from 'react'; import { Button } from 'antd'; import cx from 'classnames'; import styles from './MigrationNotice.m.less'; -import { REPLAY_APP_NAME } from 'services/highlighter/constants'; +import { HIGHLIGHTER_APP_NAME, REPLAY_APP_NAME } from 'services/highlighter/constants'; import { $t } from 'services/i18n'; import Translate from 'components-react/shared/Translate'; import SectionHeader from './SectionHeader'; import { useInstallState, getStatusText } from './useInstallState'; +import { IReplayInstallOriginMetadata } from 'services/highlighter/models/highlighter.models'; interface IModalInstallationFlowProps { onCancel: () => void; onInstallComplete?: () => void; + /** Hand-off data for the install origin marker, so a retry writes the same marker as the first + * attempt. */ + installOriginMetadata?: IReplayInstallOriginMetadata; } export default function ModalInstallationFlow(props: IModalInstallationFlowProps) { const { step, progress, + installedApp, isInstalled, isInstalling, isRecorderRunning, handleOpenOrInstall, handleRetry, handleCancel, - } = useInstallState(); + } = useInstallState(props.installOriginMetadata); + + const appName = installedApp === 'highlighter' ? HIGHLIGHTER_APP_NAME : REPLAY_APP_NAME; useEffect(() => { if (step === 'done' && props.onInstallComplete) { @@ -57,14 +64,11 @@ export default function ModalInstallationFlow(props: IModalInstallationFlowProps return (
- +

- {$t('Install %{appName} to import and detect game highlights.', { - appName: REPLAY_APP_NAME, - })} + {installedApp === 'highlighter' + ? $t('Open %{appName} to import and detect game highlights.', { appName }) + : $t('Install %{appName} to import and detect game highlights.', { appName })}

diff --git a/app/components-react/highlighter/migration/PageInstallationFlow.tsx b/app/components-react/highlighter/migration/PageInstallationFlow.tsx index baa583140163..d5f48a69545a 100644 --- a/app/components-react/highlighter/migration/PageInstallationFlow.tsx +++ b/app/components-react/highlighter/migration/PageInstallationFlow.tsx @@ -1,13 +1,16 @@ import React from 'react'; import { Button } from 'antd'; import cx from 'classnames'; -import { REPLAY_APP_NAME } from 'services/highlighter/constants'; +import { HIGHLIGHTER_APP_NAME, REPLAY_APP_NAME } from 'services/highlighter/constants'; import { $t } from 'services/i18n'; import Utils from 'services/utils'; import styles from './MigrationNotice.m.less'; import FeatureCarousel, { CAROUSEL_FEATURES } from './FeatureCarousel'; import { useInstallState, getStatusText } from './useInstallState'; -import { EReplayInstallStep } from 'services/highlighter/models/highlighter.models'; +import { + EReplayInstallStep, + TInstalledHighlighterApp, +} from 'services/highlighter/models/highlighter.models'; interface IPageInstallationFlowProps { onCancel: () => void; @@ -18,12 +21,14 @@ export default function PageInstallationFlow(props: IPageInstallationFlowProps) const { step, progress, - isInstalled, + installedApp, handleOpenOrInstall, handleRetry, handleCancel, } = useInstallState(); + const appName = installedApp === 'highlighter' ? HIGHLIGHTER_APP_NAME : REPLAY_APP_NAME; + function onCancel() { handleCancel(); props.onCancel(); @@ -32,12 +37,17 @@ export default function PageInstallationFlow(props: IPageInstallationFlowProps) const isStaging = Utils.getHighlighterEnvironment() !== 'production'; return ( - <> +
@@ -48,7 +58,7 @@ export default function PageInstallationFlow(props: IPageInstallationFlowProps) handleOpenOrInstall('page')} onRetry={handleRetry} onCancel={onCancel} @@ -71,7 +81,7 @@ export default function PageInstallationFlow(props: IPageInstallationFlowProps) )}
- +
); } @@ -80,7 +90,7 @@ export default function PageInstallationFlow(props: IPageInstallationFlowProps) interface IPageInstallCtaProps { step: EReplayInstallStep; progress: number; - isInstalled: boolean; + installedApp: TInstalledHighlighterApp; onOpenOrInstall: () => void; onShowAllClips: () => void; onRetry: () => void; @@ -90,7 +100,7 @@ interface IPageInstallCtaProps { function PageInstallCta({ step, progress, - isInstalled, + installedApp, onOpenOrInstall, onShowAllClips, onRetry, @@ -98,12 +108,16 @@ function PageInstallCta({ }: IPageInstallCtaProps) { const isInstalling = step === 'downloading' || step === 'installing' || step === 'verifying'; - // Idle — CTA button (install or open depending on whether Replay is already installed) + // Nothing to open yet — the only state where the CTA is an install and the handwritten + // annotations teasing it make sense. + const nothingInstalled = installedApp === 'none'; + + // Idle — CTA button: open whichever app the user has, or install Replay when they have neither if (step === 'idle') { return (
- {!isInstalled && ( + {nothingInstalled && (
- {isInstalled - ? $t('Open %{appName}', { appName: REPLAY_APP_NAME }) - : $t('Install %{appName}', { appName: REPLAY_APP_NAME })} - {!isInstalled && ( + {installedApp === 'replay' && $t('Open %{appName}', { appName: REPLAY_APP_NAME })} + {installedApp === 'highlighter' && + $t('Open %{appName}', { appName: HIGHLIGHTER_APP_NAME })} + {nothingInstalled && $t('Install %{appName}', { appName: REPLAY_APP_NAME })} + {nothingInstalled && (
- {isInstalled && ( + {!nothingInstalled && (