diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 15461e14..c8f49e43 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -79,7 +79,7 @@ import { import { toast, errorToast, showPlaybackFailedMessage } from './swal'; import { adjustRate } from './util/rate-calculator'; import { askExtractFramesAsImages } from './dialogs/extractFrames'; -import { askForOutDir, askForImportChapters, askForFileOpenAction, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, showConcatFailedDialog, openYouTubeChaptersDialog, showRefuseToOverwrite, openDirToast, openExportFinishedToast, openConcatFinishedToast, showOpenDialog, showMuxNotSupported, promptDownloadMediaUrl, CleanupChoicesType, showOutputNotWritable } from './dialogs'; +import { askForOutDir, askForImportChapters, askForFileOpenAction, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, showConcatFailedDialog, openYouTubeChaptersDialog, showRefuseToOverwrite, showOpenDialog, showMuxNotSupported, promptDownloadMediaUrl, CleanupChoicesType, showOutputNotWritable } from './dialogs'; import { openSendReportDialog } from './reporting'; import { fallbackLng } from './i18n'; import { sortSegments, convertSegmentsToChaptersWithGaps, hasAnySegmentOverlap, isDurationValid, getPlaybackAction, getSegmentTags, filterNonMarkers } from './segments'; @@ -178,7 +178,7 @@ function App() { const { withErrorHandling, handleError, genericError, setGenericError } = useErrorHandling(); - const { showGenericDialog, genericDialog, closeGenericDialog, confirmDialog } = useDialog(); + const { showGenericDialog, genericDialog, closeGenericDialog, confirmDialog, openExportFinishedDialog, openCutFinishedDialog, openConcatFinishedDialog } = useDialog(); // Note that each action may be multiple key bindings and this will only be the first binding for each action const keyBindingByAction = useMemo(() => Object.fromEntries(keyBindings.map((binding) => [binding.action, binding])), [keyBindings]); @@ -894,7 +894,7 @@ function App() { if (!hideAllNotifications) { showOsNotification(i18n.t('Merge finished')); - openConcatFinishedToast({ filePath: outPath, notices, warnings }); + openConcatFinishedDialog({ filePath: outPath, notices, warnings }); } } catch (err) { if (err instanceof DirectoryAccessDeclinedError || isAbortedError(err)) return; @@ -926,7 +926,7 @@ function App() { setWorking(undefined); setProgress(undefined); } - }, [workingRef, setWorking, ensureWritableOutDir, customOutDir, segmentsToChapters, concatFiles, ffmpegExperimental, preserveMovData, movFastStart, preserveMetadataOnMerge, closeBatch, hideAllNotifications, showOsNotification, handleConcatFailed]); + }, [workingRef, setWorking, ensureWritableOutDir, customOutDir, segmentsToChapters, concatFiles, ffmpegExperimental, preserveMovData, movFastStart, preserveMetadataOnMerge, closeBatch, hideAllNotifications, showOsNotification, openConcatFinishedDialog, handleConcatFailed]); const cleanupFiles = useCallback(async (cleanupChoices2: CleanupChoicesType) => { // Store paths before we reset state @@ -1133,7 +1133,7 @@ function App() { const revealPath = willMerge && mergedOutFilePath != null ? mergedOutFilePath : outFiles[0]!.path; if (!hideAllNotifications) { showOsNotification(i18n.t('Export finished')); - openExportFinishedToast({ filePath: revealPath, warnings, notices }); + openCutFinishedDialog({ filePath: revealPath, warnings, notices }); } shootConfetti({ ticks: 50 }); @@ -1171,7 +1171,7 @@ function App() { setWorking(undefined); setProgress(undefined); } - }, [filePath, numStreamsToCopy, haveInvalidSegs, workingRef, setWorking, segmentsToChaptersOnly, outSegTemplateOrDefault, generateOutSegFileNames, cutMultiple, outputDir, customOutDir, fileFormat, fileDuration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, segmentsToExport, shortestFlag, ffmpegExperimental, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormatData, mainStreams, exportExtraStreams, areWeCutting, hideAllNotifications, cleanupChoices.cleanupAfterExport, cleanupFilesWithDialog, segmentsOrInverse.selected, t, mergedFileTemplateOrDefault, segmentsToChapters, invertCutSegments, generateMergedFileNames, concatCutSegments, autoDeleteMergedSegments, tryDeleteFiles, nonCopiedExtraStreams, extractStreams, showOsNotification, handleExportFailed]); + }, [filePath, numStreamsToCopy, haveInvalidSegs, workingRef, setWorking, segmentsToChaptersOnly, outSegTemplateOrDefault, generateOutSegFileNames, cutMultiple, outputDir, customOutDir, fileFormat, fileDuration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, segmentsToExport, shortestFlag, ffmpegExperimental, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormatData, mainStreams, exportExtraStreams, areWeCutting, hideAllNotifications, cleanupChoices.cleanupAfterExport, cleanupFilesWithDialog, segmentsOrInverse.selected, t, mergedFileTemplateOrDefault, segmentsToChapters, invertCutSegments, generateMergedFileNames, concatCutSegments, autoDeleteMergedSegments, tryDeleteFiles, nonCopiedExtraStreams, extractStreams, showOsNotification, openCutFinishedDialog, handleExportFailed]); const onExportPress = useCallback(async () => { if (!filePath) return; @@ -1200,12 +1200,12 @@ function App() { : await captureFrameFromTag({ customOutDir, filePath, time: currentTime, captureFormat, quality: captureFrameQuality, video }); shootConfetti(); - if (!hideAllNotifications) openDirToast({ icon: 'success', filePath: outPath, text: `${i18n.t('Screenshot captured to:')} ${outPath}` }); + if (!hideAllNotifications) openExportFinishedDialog({ filePath: outPath, children: `${i18n.t('Screenshot captured to:')} ${outPath}` }); }, i18n.t('Failed to capture frame')); } finally { setWorking(undefined); } - }, [filePath, workingRef, setWorking, withErrorHandling, getRelevantTime, videoRef, usingPreviewFile, captureFrameMethod, captureFrameFromFfmpeg, customOutDir, captureFormat, captureFrameQuality, captureFrameFromTag, hideAllNotifications]); + }, [filePath, workingRef, setWorking, withErrorHandling, getRelevantTime, videoRef, usingPreviewFile, captureFrameMethod, captureFrameFromFfmpeg, customOutDir, captureFormat, captureFrameQuality, captureFrameFromTag, hideAllNotifications, openExportFinishedDialog]); const extractSegmentsFramesAsImages = useCallback(async (segments: SegmentBase[]) => { if (!filePath || detectedFps == null || workingRef.current || segments.length === 0) return; @@ -1242,7 +1242,7 @@ function App() { } if (!hideAllNotifications && lastOutPath != null) { showOsNotification(i18n.t('Frames have been extracted')); - openDirToast({ icon: 'success', filePath: lastOutPath, text: i18n.t('Frames extracted to: {{path}}', { path: outputDir }) }); + openExportFinishedDialog({ filePath: lastOutPath, children: i18n.t('Frames extracted to: {{path}}', { path: outputDir }) }); } } catch (err) { showOsNotification(i18n.t('Failed to extract frames')); @@ -1251,7 +1251,7 @@ function App() { setWorking(undefined); setProgress(undefined); } - }, [filePath, detectedFps, workingRef, getFrameCount, setWorking, hideAllNotifications, captureFramesRange, customOutDir, captureFormat, captureFrameQuality, captureFrameFileNameFormat, showOsNotification, outputDir, handleError]); + }, [filePath, detectedFps, workingRef, getFrameCount, setWorking, hideAllNotifications, captureFramesRange, customOutDir, captureFormat, captureFrameQuality, captureFrameFileNameFormat, showOsNotification, openExportFinishedDialog, outputDir, handleError]); const extractCurrentSegmentFramesAsImages = useCallback(() => { if (currentCutSeg != null) extractSegmentsFramesAsImages([currentCutSeg]); @@ -1579,7 +1579,7 @@ function App() { const [firstExtractedPath] = await extractStreams({ customOutDir, streams: mainCopiedStreams }); if (!hideAllNotifications && firstExtractedPath != null) { showOsNotification(i18n.t('All tracks have been extracted')); - openDirToast({ icon: 'success', filePath: firstExtractedPath, text: i18n.t('All streams have been extracted as separate files') }); + openExportFinishedDialog({ filePath: firstExtractedPath, children: i18n.t('All streams have been extracted as separate files') }); } } catch (err) { showOsNotification(i18n.t('Failed to extract tracks')); @@ -1593,7 +1593,7 @@ function App() { } finally { setWorking(undefined); } - }, [confirmDialog, customOutDir, extractStreams, filePath, hideAllNotifications, mainCopiedStreams, setWorking, showOsNotification, t, workingRef]); + }, [confirmDialog, customOutDir, extractStreams, filePath, hideAllNotifications, mainCopiedStreams, openExportFinishedDialog, setWorking, showOsNotification, t, workingRef]); const askStartTimeOffset = useCallback(async () => { @@ -2125,7 +2125,7 @@ function App() { const [firstExtractedPath] = await extractStreams({ customOutDir, streams: mainStreams.filter((s) => s.index === index) }); if (!hideAllNotifications && firstExtractedPath != null) { showOsNotification(i18n.t('Track has been extracted')); - openDirToast({ icon: 'success', filePath: firstExtractedPath, text: i18n.t('Track has been extracted') }); + openExportFinishedDialog({ filePath: firstExtractedPath, children: i18n.t('Track has been extracted') }); } } catch (err) { showOsNotification(i18n.t('Failed to extract track')); @@ -2139,7 +2139,7 @@ function App() { } finally { setWorking(undefined); } - }, [customOutDir, extractStreams, filePath, hideAllNotifications, mainStreams, setWorking, showOsNotification, workingRef]); + }, [customOutDir, extractStreams, filePath, hideAllNotifications, mainStreams, openExportFinishedDialog, setWorking, showOsNotification, workingRef]); const batchFilePaths = useMemo(() => batchFiles.map((f) => f.path), [batchFiles]); diff --git a/src/renderer/src/components/GenericDialog.tsx b/src/renderer/src/components/GenericDialog.tsx index 3094611a..f9f63201 100644 --- a/src/renderer/src/components/GenericDialog.tsx +++ b/src/renderer/src/components/GenericDialog.tsx @@ -1,10 +1,13 @@ import React, { MouseEventHandler, ReactNode, useCallback, useContext, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import invariant from 'tiny-invariant'; +import { FaCheckCircle, FaInfoCircle } from 'react-icons/fa'; import * as Dialog from './Dialog'; import * as AlertDialog from './AlertDialog'; import { DialogButton } from './Button'; +import { showItemInFolder } from '../util'; +import { ListItem, Notices, OutputIncorrectSeeHelpMenu, UnorderedList, Warnings } from '../dialogs'; export interface GenericDialogParams { @@ -56,7 +59,9 @@ export default function GenericDialog({ dialog, onOpenChange }: { - {dialog?.content} + + {dialog?.content} + ); @@ -120,10 +125,89 @@ export function useDialog() { }); }), [showGenericDialog, t]); + const openExportFinishedDialog = useCallback(async ({ filePath, children, width = '30em' }: { filePath: string, children: ReactNode, width?: string }) => { + const response = await new Promise((resolve) => { + function ExportFinishedDialog() { + const { onOpenChange } = useGenericDialogContext(); + + const handleConfirmClick = useCallback>((e) => { + e.preventDefault(); + resolve(true); + onOpenChange(false); + }, [onOpenChange]); + + return ( + + {t('Success!')} + + {children} + + + + {t('Close')} + + + {t('Show')} + + + ); + } + + showGenericDialog({ + content: , + onClose: () => resolve(false), + }); + }); + + if (response) { + showItemInFolder(filePath); + } + }, [showGenericDialog, t]); + + const openCutFinishedDialog = useCallback(async ({ filePath, warnings, notices }: { filePath: string, warnings: string[], notices: string[] }) => { + const hasWarnings = warnings.length > 0; + + // https://github.com/mifi/lossless-cut/issues/2048 + await openExportFinishedDialog({ + filePath, + width: '60em', + children: ( + + } iconColor={hasWarnings ? 'var(--orange-8)' : 'var(--green-11)'} style={{ fontWeight: 'bold' }}>{hasWarnings ? t('Export finished with warning(s)', { count: warnings.length }) : t('Export is done!')} + }>{t('Please test the output file in your desired player/editor before you delete the source file.')} + + + + + ), + }); + }, [openExportFinishedDialog, t]); + + const openConcatFinishedDialog = useCallback(async ({ filePath, warnings, notices }: { filePath: string, warnings: string[], notices: string[] }) => { + const hasWarnings = warnings.length > 0; + + await openExportFinishedDialog({ + filePath, + width: '60em', + children: ( + + } iconColor={hasWarnings ? 'warning' : 'success'} style={{ fontWeight: 'bold' }}>{hasWarnings ? t('Files merged with warning(s)', { count: warnings.length }) : t('Files merged!')} + }>{t('Please test the output files in your desired player/editor before you delete the source files.')} + + + + + ), + }); + }, [openExportFinishedDialog, t]); + return { genericDialog, closeGenericDialog, showGenericDialog, confirmDialog, + openExportFinishedDialog, + openCutFinishedDialog, + openConcatFinishedDialog, }; } diff --git a/src/renderer/src/dialogs/index.tsx b/src/renderer/src/dialogs/index.tsx index c7e529fa..9fca453d 100644 --- a/src/renderer/src/dialogs/index.tsx +++ b/src/renderer/src/dialogs/index.tsx @@ -1,16 +1,15 @@ import { CSSProperties, ReactNode, useState } from 'react'; import i18n from 'i18next'; import { Trans } from 'react-i18next'; -import type { SweetAlertOptions } from 'sweetalert2'; import invariant from 'tiny-invariant'; -import { FaArrowRight, FaCheckCircle, FaExclamationTriangle, FaInfoCircle, FaQuestionCircle } from 'react-icons/fa'; +import { FaArrowRight, FaExclamationTriangle, FaInfoCircle, FaQuestionCircle } from 'react-icons/fa'; import { formatDuration } from '../util/duration'; -import Swal, { ReactSwal, swalToastOptions, toast } from '../swal'; +import Swal, { ReactSwal } from '../swal'; import { parseYouTube } from '../edlFormats'; import CopyClipboardButton from '../components/CopyClipboardButton'; import Checkbox from '../components/Checkbox'; -import { isWindows, showItemInFolder } from '../util'; +import { isWindows } from '../util'; import { ParseTimecode } from '../types'; import { FindKeyframeMode } from '../ffmpeg'; import { dangerColor } from '../colors'; @@ -549,74 +548,26 @@ export async function selectSegmentsByLabelDialog(currentName?: string | undefin return value; } -export async function openDirToast({ filePath, text, html, ...props }: SweetAlertOptions & { filePath: string }) { - const swal = text ? toast : ReactSwal; - - // @ts-expect-error todo - const { value } = await swal.fire({ - ...swalToastOptions, - showConfirmButton: true, - confirmButtonText: i18n.t('Show'), - showCancelButton: true, - cancelButtonText: i18n.t('Close'), - text, - html, - ...props, - }); - if (value) showItemInFolder(filePath); -} - -const UnorderedList = ({ children }: { children: ReactNode }) => ( +export const UnorderedList = ({ children }: { children: ReactNode }) => (
    {children}
); -const ListItem = ({ icon, iconColor, children, style }: { icon: ReactNode, iconColor?: string, children: ReactNode, style?: CSSProperties }) => ( +export const ListItem = ({ icon, iconColor, children, style }: { icon: ReactNode, iconColor?: string, children: ReactNode, style?: CSSProperties }) => (
  • {icon} {children}
  • ); -const Notices = ({ notices }: { notices: string[] }) => notices.map((msg) => ( +export const Notices = ({ notices }: { notices: string[] }) => notices.map((msg) => ( } iconColor="var(--blue-9)">{msg} )); -const Warnings = ({ warnings }: { warnings: string[] }) => warnings.map((msg) => ( +export const Warnings = ({ warnings }: { warnings: string[] }) => warnings.map((msg) => ( } iconColor="var(--orange-8)">{msg} )); -const OutputIncorrectSeeHelpMenu = () => ( +export const OutputIncorrectSeeHelpMenu = () => ( }>{i18n.t('If output does not look right, see the Help menu.')} ); -export async function openExportFinishedToast({ filePath, warnings, notices }: { filePath: string, warnings: string[], notices: string[] }) { - const hasWarnings = warnings.length > 0; - const html = ( - - } iconColor={hasWarnings ? 'var(--orange-8)' : 'var(--green-11)'} style={{ fontWeight: 'bold' }}>{hasWarnings ? i18n.t('Export finished with warning(s)', { count: warnings.length }) : i18n.t('Export is done!')} - }>{i18n.t('Please test the output file in your desired player/editor before you delete the source file.')} - - - - - ); - - // https://github.com/mifi/lossless-cut/issues/2048 - await openDirToast({ filePath, html, width: 800, position: 'center', timer: undefined }); -} - -export async function openConcatFinishedToast({ filePath, warnings, notices }: { filePath: string, warnings: string[], notices: string[] }) { - const hasWarnings = warnings.length > 0; - const html = ( - - } iconColor={hasWarnings ? 'warning' : 'success'} style={{ fontWeight: 'bold' }}>{hasWarnings ? i18n.t('Files merged with warning(s)', { count: warnings.length }) : i18n.t('Files merged!')} - }>{i18n.t('Please test the output files in your desired player/editor before you delete the source files.')} - - - - - ); - - await openDirToast({ filePath, html, width: 800, position: 'center', timer: 30000 }); -} - export async function askForPlaybackRate({ detectedFps, outputPlaybackRate }: { detectedFps: number | undefined, outputPlaybackRate: number }) { const fps = detectedFps || 1; const currentFps = fps * outputPlaybackRate; diff --git a/src/renderer/src/swal.ts b/src/renderer/src/swal.ts index 7750d1e1..df5a71ef 100644 --- a/src/renderer/src/swal.ts +++ b/src/renderer/src/swal.ts @@ -33,7 +33,7 @@ const Swal = SwalRaw.mixin({ export default Swal; -export const swalToastOptions: SweetAlertOptions = { +const swalToastOptions: SweetAlertOptions = { ...commonSwalOptions, toast: true, width: '50vw',