diff --git a/app/components-react/highlighter/ClipPreview.tsx b/app/components-react/highlighter/ClipPreview.tsx index 4cdae6f99407..c4e29c5cc46e 100644 --- a/app/components-react/highlighter/ClipPreview.tsx +++ b/app/components-react/highlighter/ClipPreview.tsx @@ -18,6 +18,7 @@ export default function ClipPreview(props: { emitShowTrim: () => void; emitShowRemove: () => void; emitOpenFileInLocation: () => void; + game: EGame; }) { const { HighlighterService } = Services; const v = useVuex(() => ({ @@ -28,8 +29,6 @@ export default function ClipPreview(props: { const clipThumbnail = v.clip.scrubSprite || ''; const enabled = v.clip.deleted ? false : v.clip.enabled; - const game = HighlighterService.getGameByStreamId(props.streamId); - if (!v.clip) { return <>deleted; } @@ -108,7 +107,7 @@ export default function ClipPreview(props: { }} > {isAiClip(v.clip) ? ( - + ) : (
@@ -151,8 +150,9 @@ export function formatSecondsToHMS(seconds: number): string { 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' : ''}`; + return `${hours !== 0 ? hours.toString() + 'h ' : ''} ${ + minutes !== 0 ? minutes.toString() + 'm ' : '' + }${remainingSeconds !== 0 ? remainingSeconds.toString() + 's' : ''}`; } function FlameHypeScore({ score }: { score: number }) { diff --git a/app/components-react/highlighter/ClipsView.tsx b/app/components-react/highlighter/ClipsView.tsx index 4dd34053006e..3a21fde0223b 100644 --- a/app/components-react/highlighter/ClipsView.tsx +++ b/app/components-react/highlighter/ClipsView.tsx @@ -55,6 +55,7 @@ export default function ClipsView({ 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); @@ -316,6 +317,7 @@ export default function ClipsView({ remote.shell.showItemInFolder(clip.path); }} streamId={streamId} + game={game} />
); diff --git a/app/components-react/highlighter/Export/ExportModal.tsx b/app/components-react/highlighter/Export/ExportModal.tsx index 67bd19abc36b..72ecb8deac70 100644 --- a/app/components-react/highlighter/Export/ExportModal.tsx +++ b/app/components-react/highlighter/Export/ExportModal.tsx @@ -23,7 +23,6 @@ import { SCRUB_HEIGHT, SCRUB_WIDTH, SCRUB_FRAMES } from 'services/highlighter/co import styles from './ExportModal.m.less'; import { getCombinedClipsDuration } from '../utils'; import { formatSecondsToHMS } from '../ClipPreview'; -import { set } from 'lodash'; import PlatformSelect from './Platform'; import cx from 'classnames'; import { getVideoResolution } from 'services/highlighter/cut-highlight-clips'; @@ -55,9 +54,7 @@ class ExportController { getClips(streamId?: string) { return this.service.getClips(this.service.views.clips, streamId).filter(clip => clip.enabled); } - getClipThumbnail(streamId?: string) { - return this.getClips(streamId).find(clip => clip.enabled)?.scrubSprite; - } + getDuration(streamId?: string) { return getCombinedClipsDuration(this.getClips(streamId)); } @@ -185,14 +182,20 @@ function ExportFlow({ getStreamTitle, getClips, getDuration, - getClipThumbnail, getClipResolution, } = useController(ExportModalCtx); const [currentFormat, setCurrentFormat] = useState(EOrientation.HORIZONTAL); - const clipsAmount = getClips(streamId).length; - const clipsDuration = formatSecondsToHMS(getDuration(streamId)); + const { amount, duration, thumbnail } = useMemo(() => { + const clips = getClips(streamId); + + return { + amount: clips.length, + duration: formatSecondsToHMS(getCombinedClipsDuration(clips)), + thumbnail: clips.find(clip => clip.enabled)?.scrubSprite, + }; + }, [streamId]); function settingMatcher(initialSetting: TSetting) { const matchingSetting = settings.find( @@ -215,42 +218,41 @@ function ExportFlow({ const [currentSetting, setSetting] = useState(null); const [isLoadingResolution, setIsLoadingResolution] = useState(true); - useEffect(() => { - setIsLoadingResolution(true); - - async function initializeSettings() { - try { - const resolution = await getClipResolution(streamId); - let setting: TSetting; - if (resolution?.height === 720 && exportInfo.resolution !== 720) { - setting = settings.find(s => s.resolution === 720) || settings[settings.length - 1]; - } else if (resolution?.height === 1080 && exportInfo.resolution !== 1080) { - setting = settings.find(s => s.resolution === 1080) || settings[settings.length - 1]; - } else { - setting = settingMatcher({ - name: 'from default', - fps: exportInfo.fps, - resolution: exportInfo.resolution, - preset: exportInfo.preset, - }); - } - - setSetting(setting); - } catch (error: unknown) { - console.error('Failed to detect clip resolution, setting default. Error: ', error); - setSetting( - settingMatcher({ - name: 'from default', - fps: exportInfo.fps, - resolution: exportInfo.resolution, - preset: exportInfo.preset, - }), - ); - } finally { - setIsLoadingResolution(false); + async function initializeSettings() { + try { + const resolution = await getClipResolution(streamId); + let setting: TSetting; + if (resolution?.height === 720 && exportInfo.resolution !== 720) { + setting = settings.find(s => s.resolution === 720) || settings[settings.length - 1]; + } else if (resolution?.height === 1080 && exportInfo.resolution !== 1080) { + setting = settings.find(s => s.resolution === 1080) || settings[settings.length - 1]; + } else { + setting = settingMatcher({ + name: 'from default', + fps: exportInfo.fps, + resolution: exportInfo.resolution, + preset: exportInfo.preset, + }); } + + setSetting(setting); + } catch (error: unknown) { + console.error('Failed to detect clip resolution, setting default. Error: ', error); + setSetting( + settingMatcher({ + name: 'from default', + fps: exportInfo.fps, + resolution: exportInfo.resolution, + preset: exportInfo.preset, + }), + ); + } finally { + setIsLoadingResolution(false); } + } + useEffect(() => { + setIsLoadingResolution(true); initializeSettings(); }, [streamId]); @@ -376,7 +378,7 @@ function ExportFlow({ )} - {clipsDuration} | {$t('%{clipsAmount} clips', { clipsAmount })} + {duration} | {$t('%{clipsAmount} clips', { clipsAmount: amount })}

void; streamId: string | undefined; - emitSetShowModal: (modal: TModalClipsView | null) => void; + emitSetShowModal: (modal: 'export' | null) => void; }) { const { HighlighterService, UsageStatisticsService } = Services; const clips = HighlighterService.getClips(HighlighterService.views.clips, streamId); @@ -369,7 +369,6 @@ export default function PreviewModal({ -
-
-

{$t('No clips found')}

-

- {$t('Please make sure all the requirements are met:')} -

-
    -
  • {$t('Game is supported')}
  • -
  • {$t('Game language is English')}
  • -
  • {$t('Map and Stats area is fully visible')}
  • -
  • {$t('Game is fullscreen in your stream')}
  • -
  • {$t('Game mode is supported')}
  • -
- - {$t('Show details')} - -

{$t('All requirements met but no luck?')}

- - - {$t('Take a screenshot of your stream and share it here')} - -
-
-
-
+ {modal && ( + { + setModal(null); + }} + game={game} + /> + )} +
+ +
+
+

{$t('No clips found')}

+

+ {$t('Please make sure all the requirements are met:')} +

+
    +
  • {$t('Game is supported')}
  • +
  • {$t('Game language is English')}
  • +
  • {$t('Map and Stats area is fully visible')}
  • +
  • {$t('Game is fullscreen in your stream')}
  • +
  • {$t('Game mode is supported')}
  • +
+ setModal('requirements')} style={{ marginBottom: '14px' }}> + {$t('Show details')} + +

{$t('All requirements met but no luck?')}

+ + + {$t('Take a screenshot of your stream and share it here')} +
-
+
+
- {$t('Share feedback')} - +
+

{stream.title}

+

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

+
+ +
-
+ ); } - const gameThumbnail = supportedGames?.find(game => game.value === stream.game)?.image; - return ( -
{ - showStreamClips(); - }} - > - -
-
-
-

{stream.title}

-

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

-
- -
-

- {stream.state.type === EAiDetectionState.FINISHED ? ( -
- {gameThumbnail && ( - game.value === stream.game)?.label}> - {stream.game} - - )} - {/* calculation needed for text ellipsis overflow */} -
- -
-
- ) : ( -
- )} -

- + {modal && ( + { + setModal(null); + }} + game={game} + /> + )} +
{ + showStreamClips(); + }} + > + { - HighlighterService.actions.restartAiDetection(stream.path, stream); + stream={stream} + emitGeneratePreview={() => { + previewVideo(streamId); + }} + emitCancelHighlightGeneration={cancelHighlightGeneration} + emitRemoveStream={() => { + setModal('remove'); }} - emitSetView={emitSetView} /> +
+
+
+

{stream.title}

+

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

+
+ +
+

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

+ exportVideo(streamId)} + emitShowStreamClips={showStreamClips} + clipsOfStreamAreLoading={clipsOfStreamAreLoading} + emitRestartAiDetection={() => { + HighlighterService.actions.restartAiDetection(stream.path, stream); + }} + emitSetView={emitSetView} + emitFeedbackForm={() => { + setModal('feedback'); + }} + /> +
-
+ ); } @@ -419,7 +486,6 @@ export function Thumbnail({ > - { if (stream.state.type !== EAiDetectionState.IN_PROGRESS) { diff --git a/app/components-react/highlighter/StreamCardModal.tsx b/app/components-react/highlighter/StreamCardModal.tsx new file mode 100644 index 000000000000..1f4ae6d4e649 --- /dev/null +++ b/app/components-react/highlighter/StreamCardModal.tsx @@ -0,0 +1,176 @@ +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/models/highlighter.models'; +import styles from './StreamView.m.less'; +import ClipTrimmer from 'components-react/highlighter/ClipTrimmer'; +import { Modal, Alert, Button, Input } from 'antd'; +import ExportModal from 'components-react/highlighter/Export/ExportModal'; +import { $t } from 'services/i18n'; +import PreviewModal from './PreviewModal'; +import TextArea from 'antd/lib/input/TextArea'; +import { EGame } from 'services/highlighter/models/ai-highlighter.models'; +import EducationCarousel from './EducationCarousel'; + +export type TModalStreamCard = 'export' | 'preview' | 'remove' | 'requirements' | 'feedback' | null; + +export default function StreamCardModal({ + streamId, + modal, + game, + onClose, +}: { + streamId: string | undefined; + modal: TModalStreamCard | null; + game: EGame; + onClose: () => 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'); + + useEffect(() => { + if (modal) { + setShowModal(modal); + } + }, [modal]); + + function setShowModal(modal: TModalStreamCard | null) { + rawSetShowModal(modal); + if (modal) { + setModalWidth( + { + preview: '700px', + export: 'fit-content', + remove: '400px', + requirements: 'fit-content', + feedback: '700px', + }[modal], + ); + } + } + function closeModal() { + // Do not allow closing export modal while export/upload operations are in progress + if (v.exportInfo.exporting) return; + if (v.uploadInfo.some(u => u.uploading)) return; + + setShowModal(null); + onClose(); + if (v.error) HighlighterService.actions.dismissError(); + } + + return ( + + {!!v.error && } + {showModal === 'export' && } + {showModal === 'preview' && ( + { + setShowModal(modal); + }} + /> + )} + {showModal === 'remove' && } + {showModal === 'feedback' && } + {showModal === 'requirements' && } + + ); +} + +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.', + )} +

+ + +
+ ); +} + +function Feedback(p: { streamId: string | undefined; game: EGame; close: () => void }) { + const { UsageStatisticsService, HighlighterService } = Services; + + const { TextArea } = Input; + const [feedback, setFeedback] = useState(''); + + const leaveFeedback = () => { + if (!feedback || feedback.length > 140) { + return; + } + + const clipAmount = HighlighterService.getClips(HighlighterService.views.clips, p.streamId) + .length; + + UsageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'ThumbsDownFeedback', + streamId: p.streamId, + game: p.game, + clips: clipAmount, + feedback, + }); + + p.close(); + }; + + return ( +
+