diff --git a/package.json b/package.json index e2633790..53ccf063 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@dnd-kit/sortable": "^10.0.0", "@fontsource/open-sans": "^4.5.14", "@radix-ui/colors": "^3.0.0", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.2.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5048b037..15461e14 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -29,7 +29,7 @@ import { UserSettingsContext, SegColorsContext, UserSettingsContextType, AppCont import NoFileLoaded from './NoFileLoaded'; import MediaSourcePlayer from './MediaSourcePlayer'; import TopMenu from './TopMenu'; -import LastCommandsSheet from './LastCommandsSheet'; +import LastCommands from './LastCommands'; import StreamsSelector from './StreamsSelector'; import SegmentList from './SegmentList'; import Settings from './components/Settings'; @@ -40,7 +40,7 @@ import ValueTuners from './components/ValueTuners'; import VolumeControl from './components/VolumeControl'; import PlaybackStreamSelector from './components/PlaybackStreamSelector'; import BatchFilesList from './components/BatchFilesList'; -import ConcatDialog from './components/ConcatDialog'; +import ConcatSheet from './components/ConcatSheet'; import KeyboardShortcuts from './components/KeyboardShortcuts'; import Working from './components/Working'; import OutputFormatSelect from './components/OutputFormatSelect'; @@ -64,23 +64,22 @@ import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, isAudi import { exportEdlFile, readEdlFile, loadLlcProject, askForEdlImport } from './edlStore'; import { formatYouTube, getFrameCountRaw, formatTsvHuman } from './edlFormats'; import { - getOutPath, getSuffixedOutPath, handleError, getOutDir, + getOutPath, getOutDir, isStoreBuild, dragPreventer, - havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile, + havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, findExistingHtml5FriendlyFile, deleteFiles, isOutOfSpaceError, readFileSize, readFileSizes, checkFileSizes, setDocumentTitle, mustDisallowVob, readVideoTs, readDirRecursively, getImportProjectType, calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities, isExecaError, getStdioString, isMuxNotSupported, getDownloadMediaOutPath, isAbortedError, - withErrorHandling, shootConfetti, isMasBuild, + toastError, } from './util'; import { toast, errorToast, showPlaybackFailedMessage } from './swal'; import { adjustRate } from './util/rate-calculator'; import { askExtractFramesAsImages } from './dialogs/extractFrames'; -import { askForHtml5ifySpeed } from './dialogs/html5ify'; -import { askForOutDir, askForImportChapters, promptTimecode, askForFileOpenAction, confirmExtractAllStreamsDialog, 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, openDirToast, openExportFinishedToast, openConcatFinishedToast, 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'; @@ -90,7 +89,7 @@ import BigWaveform from './components/BigWaveform'; import isDev from './isDev'; import { BatchFile, Chapter, CustomTagsByFile, EdlExportType, EdlFileType, EdlImportType, FfmpegCommandLog, FilesMeta, goToTimecodeDirectArgsSchema, openFilesActionArgsSchema, ParamsByStreamId, PlaybackMode, SegmentBase, SegmentColorIndex, SegmentTags, StateSegment, TunerType } from './types'; -import { CaptureFormat, KeyboardAction, Html5ifyMode, ApiActionRequest } from '../../../types'; +import { CaptureFormat, KeyboardAction, ApiActionRequest } from '../../../types'; import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe'; import useLoading from './hooks/useLoading'; import useVideo from './hooks/useVideo'; @@ -103,6 +102,10 @@ import { bottomStyle, videoStyle } from './styles'; import styles from './App.module.css'; import { DirectoryAccessDeclinedError } from '../errors'; import SwalContainer from './components/SwalContainer'; +import ErrorDialog from './components/ErrorDialog'; +import useErrorHandling from './hooks/useErrorHandling'; +import GenericDialog, { useDialog } from './components/GenericDialog'; +import useHtml5ify from './hooks/useHtml5ify'; const electron = window.require('electron'); const { exists } = window.require('fs-extra'); @@ -122,8 +125,6 @@ function App() { // Per project state const [ffmpegCommandLog, setFfmpegCommandLog] = useState([]); - const [previewFilePath, setPreviewFilePath] = useState(); - const [usingDummyVideo, setUsingDummyVideo] = useState(false); const [rotation, setRotation] = useState(360); const [progress, setProgress] = useState(); const [startTimeOffset, setStartTimeOffset] = useState(0); @@ -135,7 +136,7 @@ function App() { const [detectedFps, setDetectedFps] = useState(); const [mainFileMeta, setMainFileMeta] = useState<{ streams: FileMeta['streams'], formatData: FFprobeFormat, chapters: FFprobeChapter[] }>(); const [streamsSelectorShown, setStreamsSelectorShown] = useState(false); - const [concatDialogVisible, setConcatDialogVisible] = useState(false); + const [concatSheetOpen, setConcatSheetOpen] = useState(false); const [zoomUnrounded, setZoom] = useState(1); const [shortestFlag, setShortestFlag] = useState(false); const [zoomWindowStartTime, setZoomWindowStartTime] = useState(0); @@ -143,7 +144,7 @@ function App() { const [activeAudioStreamIndexes, setActiveAudioStreamIndexes] = useState>(new Set()); const [activeSubtitleStreamIndex, setActiveSubtitleStreamIndex] = useState(); const [hideCompatPlayer, setHideCompatPlayer] = useState(false); - const [exportConfirmVisible, setExportConfirmVisible] = useState(false); + const [exportConfirmOpen, setExportConfirmOpen] = useState(false); const [cacheBuster, setCacheBuster] = useState(0); const [currentFileExportCount, setCurrentFileExportCount] = useState(0); @@ -152,7 +153,6 @@ function App() { // State per application launch const lastOpenedPathRef = useRef(); const [showRightBar, setShowRightBar] = useState(true); - const [rememberConvertToSupportedFormat, setRememberConvertToSupportedFormat] = useState(); const [lastCommandsVisible, setLastCommandsVisible] = useState(false); const [settingsVisible, setSettingsVisible] = useState(false); const [tunerVisible, setTunerVisible] = useState(); @@ -173,17 +173,19 @@ function App() { const [selectedBatchFiles, setSelectedBatchFiles] = useState([]); const allUserSettings = useUserSettingsRoot(); + const { captureFormat, customOutDir, keyframeCut, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, autoSaveProjectFile, wheelSensitivity, waveformHeight, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, hideOsNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, segmentsToChapters, simpleMode, outSegTemplate, mergedFileTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, safeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, darkMode, preferStrongColors, outputFileNameMinZeroPadding, cutFromAdjustmentFrames, cutToAdjustmentFrames, waveformMode: waveformModePreference, thumbnailsEnabled, keyframesEnabled, reducedMotion } = allUserSettings.settings; + const { setCaptureFormat, setCustomOutDir, setKeyframeCut, setPlaybackVolume, setExportConfirmEnabled, setSimpleMode, setOutSegTemplate, setMergedFileTemplate, setOutFormatLocked, setSafeOutputFileName, setKeyBindings, resetKeyBindings, setStoreProjectInWorkingDir, setCleanupChoices, toggleDarkMode, setWaveformMode, setThumbnailsEnabled, setKeyframesEnabled, prefersReducedMotion } = allUserSettings; - const { - captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMetadata, preserveChapters, preserveMovData, movFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, wheelSensitivity, waveformHeight, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, hideOsNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, preserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, mergedFileTemplate, setMergedFileTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, setStoreProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, setCleanupChoices, darkMode, toggleDarkMode, preferStrongColors, outputFileNameMinZeroPadding, cutFromAdjustmentFrames, cutToAdjustmentFrames, waveformMode: waveformModePreference, setWaveformMode, thumbnailsEnabled, setThumbnailsEnabled, keyframesEnabled, setKeyframesEnabled, prefersReducedMotion, reducedMotion, - } = allUserSettings; + const { withErrorHandling, handleError, genericError, setGenericError } = useErrorHandling(); + + const { showGenericDialog, genericDialog, closeGenericDialog, confirmDialog } = 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]); const { working, setWorking, workingRef, abortWorking } = useLoading(); const { videoRef, videoContainerRef, playbackRate, setPlaybackRate, outputPlaybackRate, setOutputPlaybackRate, commandedTime, seekAbs, playingRef, getRelevantTime, setPlaying, onSeeked, relevantTime, onStartPlaying, setCommandedTime, setOutputPlaybackRateState, commandedTimeRef, onStopPlaying, onVideoAbort, playerTime, setPlayerTime, playbackModeRef, playing, play, pause, seekRel } = useVideo({ filePath }); - const { timecodePlaceholder, formatTimecode, formatTimeAndFrames, parseTimecode, getFrameCount } = useTimecode({ detectedFps, timecodeFormat }); + const { timecodePlaceholder, formatTimecode, formatTimeAndFrames, parseTimecode, getFrameCount, promptTimecode } = useTimecode({ detectedFps, timecodeFormat, showGenericDialog }); const { loadSubtitle, subtitlesByStreamId, setSubtitlesByStreamId } = useSubtitles(); const fileDurationNonZero = isDurationValid(fileDuration) ? fileDuration : 1; @@ -277,7 +279,6 @@ function App() { if (videoRef.current) videoRef.current.volume = playbackVolume; }, [playbackVolume, videoRef]); - const mainStreams = useMemo(() => mainFileMeta?.streams ?? [], [mainFileMeta?.streams]); const mainFileFormatData = useMemo(() => mainFileMeta?.formatData, [mainFileMeta?.formatData]); const mainFileChapters = useMemo(() => mainFileMeta?.chapters, [mainFileMeta?.chapters]); @@ -304,29 +305,6 @@ function App() { const zoomAbs = useCallback((fn: (v: number) => number) => setZoom((z) => Math.min(Math.max(fn(z), 1), zoomMax)), []); const zoomRel = useCallback((rel: number) => zoomAbs((z) => z + (rel * (1 + (z / 10)))), [zoomAbs]); - const compatPlayerRequired = ( - // if user selected an explicit video or audio stream, and the html5 player does not have any track index corresponding to the selected stream index - ( - (activeVideoStreamIndex != null || activeAudioStreamIndexes.size === 1) - && videoRef.current != null - && !canHtml5PlayerPlayStreams(videoRef.current, activeVideoStreamIndex, [...activeAudioStreamIndexes][0]) - ) - // or if selected multiple audio streams (html5 video element doesn't support that) - || activeAudioStreamIndexes.size > 1 - ); - // if user selected a rotation, but they might want to turn off the rotation preview - // but allow the user to disable - const compatPlayerWanted = (isRotationSet && !hideCompatPlayer) - || usingDummyVideo; - - const compatPlayerEnabled = (compatPlayerRequired || compatPlayerWanted) && (activeVideoStream != null || activeAudioStreams.length > 0); - - const shouldShowPlaybackStreamSelector = videoStreams.length > 0 || audioStreams.length > 0 || (subtitleStreams.length > 0 && !compatPlayerEnabled); - - useEffect(() => { - // Reset the user preference when we go from not having compat player to having it - if (compatPlayerEnabled) setHideCompatPlayer(false); - }, [compatPlayerEnabled]); const comfortZoom = isDurationValid(fileDuration) ? Math.max(fileDuration / 100, 1) : undefined; const timelineToggleComfortZoom = useCallback(() => { @@ -349,11 +327,11 @@ function App() { const { cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, labelSegment, splitCurrentSegment, focusSegmentAtCursor, selectSegmentsAtCursor, createNumSegments, createFixedDurationSegments, createFixedByteSizedSegments, createRandomSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, inverseCutSegments, clearSegments, clearSegColorCounter, loadCutSegments, setCutTime, setCurrentSegIndex, labelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, selectSegmentsByLabel, selectSegmentsByExpr, selectAllMarkers, mutateSegmentsByExpr, toggleSegmentSelected, selectOnlySegment, selectedSegments, segmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex, findSegmentsAtCursor, maybeCreateFullLengthSegment, currentCutSegOrWholeTimeline, - } = useSegments({ filePath, workingRef, setWorking, setProgress, videoStream: activeVideoStream, fileDuration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog, fileDurationNonZero, mainFileMeta, seekAbs, activeVideoStreamIndex, activeAudioStreamIndexes }); + } = useSegments({ filePath, workingRef, setWorking, setProgress, videoStream: activeVideoStream, fileDuration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog, fileDurationNonZero, mainFileMeta, seekAbs, activeVideoStreamIndex, activeAudioStreamIndexes, handleError, showGenericDialog }); const { getEdlFilePath, projectFileSavePath, getProjectFileSavePath } = useSegmentsAutoSave({ autoSaveProjectFile, storeProjectInWorkingDir, filePath, customOutDir, cutSegments }); - const { nonCopiedExtraStreams, exportExtraStreams, mainCopiedThumbnailStreams, numStreamsToCopy, toggleStripVideo, toggleStripAudio, toggleStripSubtitle, toggleStripThumbnail, toggleStripAll, copyStreamIdsByFile, setCopyStreamIdsByFile, copyFileStreams, mainCopiedStreams, setCopyStreamIdsForPath, toggleCopyStreamId, isCopyingStreamId, toggleCopyStreamIds, changeEnabledStreamsFilter, applyEnabledStreamsFilter, enabledStreamsFilter, toggleCopyAllStreamsForPath } = useStreamsMeta({ mainStreams, externalFilesMeta, filePath, autoExportExtraStreams }); + const { nonCopiedExtraStreams, exportExtraStreams, mainCopiedThumbnailStreams, numStreamsToCopy, toggleStripVideo, toggleStripAudio, toggleStripSubtitle, toggleStripThumbnail, toggleStripAll, copyStreamIdsByFile, setCopyStreamIdsByFile, copyFileStreams, mainCopiedStreams, setCopyStreamIdsForPath, toggleCopyStreamId, isCopyingStreamId, toggleCopyStreamIds, changeEnabledStreamsFilter, applyEnabledStreamsFilter, enabledStreamsFilter, toggleCopyAllStreamsForPath } = useStreamsMeta({ mainStreams, externalFilesMeta, filePath, autoExportExtraStreams, showGenericDialog }); const onDurationChange = useCallback>((e) => { // Some files report duration infinity first, then proper duration later @@ -409,21 +387,6 @@ function App() { const outputDir = getOutDir(customOutDir, filePath); - const usingPreviewFile = !!previewFilePath; - const effectiveFilePath = previewFilePath || filePath; - const fileUri = useMemo(() => { - if (!effectiveFilePath) return ''; // Setting video src="" prevents memory leak in chromium - const uri = pathToFileURL(effectiveFilePath).href; - // https://github.com/mifi/lossless-cut/issues/1674 - if (cacheBuster !== 0) { - const qs = new URLSearchParams(); - qs.set('t', String(cacheBuster)); - return `${uri}?${qs.toString()}`; - } - return uri; - }, [cacheBuster, effectiveFilePath]); - - const increaseRotation = useCallback(() => { setRotation((r) => (r + 90) % 450); setHideCompatPlayer(false); @@ -498,11 +461,17 @@ function App() { const appContext = useMemo(() => ({ working, setWorking, - }), [setWorking, working]); + handleError, + showGenericDialog, + }), [handleError, setWorking, showGenericDialog, working]); + + const userSettingsContext = useMemo(() => { + const { settings, ...rest } = allUserSettings; - const userSettingsContext = useMemo(() => ({ - ...allUserSettings, toggleCaptureFormat, changeOutDir, toggleKeyframeCut, toggleExportConfirmEnabled, toggleSimpleMode, toggleSafeOutputFileName, effectiveExportMode, - }), [allUserSettings, changeOutDir, effectiveExportMode, toggleCaptureFormat, toggleExportConfirmEnabled, toggleKeyframeCut, toggleSafeOutputFileName, toggleSimpleMode]); + return { + ...settings, ...rest, toggleCaptureFormat, changeOutDir, toggleKeyframeCut, toggleExportConfirmEnabled, toggleSimpleMode, toggleSafeOutputFileName, effectiveExportMode, + }; + }, [allUserSettings, changeOutDir, effectiveExportMode, toggleCaptureFormat, toggleExportConfirmEnabled, toggleKeyframeCut, toggleSafeOutputFileName, toggleSimpleMode]); const segColorsContext = useMemo(() => ({ getSegColor: (seg: SegmentColorIndex | undefined) => { @@ -533,7 +502,7 @@ function App() { } finally { setWorking(undefined); } - }, [subtitlesByStreamId, subtitleStreams, workingRef, setWorking, filePath, loadSubtitle]); + }, [subtitlesByStreamId, subtitleStreams, workingRef, setWorking, withErrorHandling, filePath, loadSubtitle]); const onActiveVideoStreamChange = useCallback((videoStreamIndex?: number) => { invariant(videoRef.current); @@ -584,7 +553,7 @@ function App() { const { thumbnailsSorted, setThumbnails } = useThumbnails({ filePath, zoomedDuration, zoomWindowStartTime, showThumbnails }); - const { neighbouringKeyFrames, findNearestKeyFrameTime, keyframeByNumber, readAllKeyframes } = useKeyframes({ keyframesEnabled, filePath, commandedTime, videoStream: activeVideoStream, detectedFps, ffmpegExtractWindow, maxKeyframes, currentCutSegOrWholeTimeline, setWorking, setMaxKeyframes }); + const { neighbouringKeyFrames, findNearestKeyFrameTime, keyframeByNumber, readAllKeyframes } = useKeyframes({ keyframesEnabled, filePath, commandedTime, videoStream: activeVideoStream, detectedFps, ffmpegExtractWindow, maxKeyframes, currentCutSegOrWholeTimeline, setWorking, setMaxKeyframes, handleError }); const { waveforms, overviewWaveform, renderOverviewWaveform } = useWaveform({ filePath, relevantTime, waveformEnabled, audioStream: activeAudioStreams[0], ffmpegExtractWindow, fileDuration }); const currentFrame = useMemo(() => { @@ -606,6 +575,56 @@ function App() { const shouldShowKeyframes = keyframesEnabled && hasVideo && calcShouldShowKeyframes(zoomedDuration); const shouldShowWaveform = calcShouldShowWaveform(zoomedDuration) || overviewWaveform != null; + const areWeCutting = useMemo(() => segmentsToExport.some(({ start, end }) => isCuttingStart(start) || isCuttingEnd(end, fileDuration)), [fileDuration, segmentsToExport]); + const needSmartCut = areWeCutting && enableSmartCut; + const isEncoding = needSmartCut || lossyMode != null; + + const { + concatFiles, html5ifyDummy, cutMultiple, concatCutSegments, html5ify, fixInvalidDuration, extractStreams, tryDeleteFiles, + } = useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, isEncoding, lossyMode, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames, cutToAdjustmentFrames, appendLastCommandsLog, encCustomBitrate: encBitrate, appendFfmpegCommandLog }); + + const { previewFilePath, setPreviewFilePath, usingDummyVideo, setUsingDummyVideo, userHtml5ifyCurrentFile, convertFormatBatch, html5ifyAndLoadWithPreferences } = useHtml5ify({ + filePath, hasVideo, hasAudio, workingRef, setWorking, ensureWritableOutDir, customOutDir, batchFiles, enableAutoHtml5ify, setProgress, html5ify, html5ifyDummy, withErrorHandling, showGenericDialog, + }); + + const compatPlayerRequired = ( + // if user selected an explicit video or audio stream, and the html5 player does not have any track index corresponding to the selected stream index + ( + (activeVideoStreamIndex != null || activeAudioStreamIndexes.size === 1) + && videoRef.current != null + && !canHtml5PlayerPlayStreams(videoRef.current, activeVideoStreamIndex, [...activeAudioStreamIndexes][0]) + ) + // or if selected multiple audio streams (html5 video element doesn't support that) + || activeAudioStreamIndexes.size > 1 + ); + // if user selected a rotation, but they might want to turn off the rotation preview + // but allow the user to disable + const compatPlayerWanted = (isRotationSet && !hideCompatPlayer) + || usingDummyVideo; + + const compatPlayerEnabled = (compatPlayerRequired || compatPlayerWanted) && (activeVideoStream != null || activeAudioStreams.length > 0); + + useEffect(() => { + // Reset the user preference when we go from not having compat player to having it + if (compatPlayerEnabled) setHideCompatPlayer(false); + }, [compatPlayerEnabled]); + + const shouldShowPlaybackStreamSelector = videoStreams.length > 0 || audioStreams.length > 0 || (subtitleStreams.length > 0 && !compatPlayerEnabled); + + const usingPreviewFile = !!previewFilePath; + const effectiveFilePath = previewFilePath || filePath; + const fileUri = useMemo(() => { + if (!effectiveFilePath) return ''; // Setting video src="" prevents memory leak in chromium + const uri = pathToFileURL(effectiveFilePath).href; + // https://github.com/mifi/lossless-cut/issues/1674 + if (cacheBuster !== 0) { + const qs = new URLSearchParams(); + qs.set('t', String(cacheBuster)); + return `${uri}?${qs.toString()}`; + } + return uri; + }, [cacheBuster, effectiveFilePath]); + const resetState = useCallback(() => { console.log('State reset'); const video = videoRef.current; @@ -643,10 +662,10 @@ function App() { setActiveVideoStreamIndex(undefined); setActiveSubtitleStreamIndex(undefined); setHideCompatPlayer(false); - setExportConfirmVisible(false); + setExportConfirmOpen(false); setOutputPlaybackRateState(1); setCurrentFileExportCount(0); - }, [videoRef, setCommandedTime, setPlaybackRate, setPlaying, playingRef, playbackModeRef, setFileDuration, cutSegmentsHistory, setFileFormat, setDetectedFileFormat, setCopyStreamIdsByFile, setThumbnails, setSubtitlesByStreamId, setOutputPlaybackRateState]); + }, [videoRef, setCommandedTime, setPlaybackRate, setPreviewFilePath, setUsingDummyVideo, setPlaying, playingRef, playbackModeRef, cutSegmentsHistory, setFileFormat, setDetectedFileFormat, setCopyStreamIdsByFile, setThumbnails, setSubtitlesByStreamId, setHideCompatPlayer, setOutputPlaybackRateState]); const showUnsupportedFileMessage = useCallback(() => { @@ -657,104 +676,13 @@ function App() { showNotification({ icon: 'info', text: i18n.t('Loaded existing preview file: {{ fileName }}', { fileName }) }); }, [showNotification]); - const areWeCutting = useMemo(() => segmentsToExport.some(({ start, end }) => isCuttingStart(start) || isCuttingEnd(end, fileDuration)), [fileDuration, segmentsToExport]); - const needSmartCut = areWeCutting && enableSmartCut; - const isEncoding = needSmartCut || lossyMode != null; - - const { - concatFiles, html5ifyDummy, cutMultiple, concatCutSegments, html5ify, fixInvalidDuration, extractStreams, tryDeleteFiles, - } = useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, isEncoding, lossyMode, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames, cutToAdjustmentFrames, appendLastCommandsLog, encCustomBitrate: encBitrate, appendFfmpegCommandLog }); - - const { captureFrameFromTag, captureFrameFromFfmpeg, captureFramesRange } = useFrameCapture({ appendFfmpegCommandLog, formatTimecode, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart }); - - const html5ifyAndLoad = useCallback(async (cod: string | undefined, fp: string, speed: Html5ifyMode, hv: boolean, ha: boolean) => { - const usesDummyVideo = speed === 'fastest'; - console.log('html5ifyAndLoad', { speed, hasVideo: hv, hasAudio: ha, usesDummyVideo }); - - async function doHtml5ify() { - if (speed == null) return undefined; - if (speed === 'fastest') { - const path = getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: `${html5ifiedPrefix}${html5dummySuffix}.mkv` }); - try { - setProgress(0); - await html5ifyDummy({ filePath: fp, outPath: path, onProgress: setProgress }); - } finally { - setProgress(undefined); - } - return path; - } - - try { - const shouldIncludeVideo = !usesDummyVideo && hv; - return await html5ify({ customOutDir: cod, filePath: fp, speed, hasAudio: ha, hasVideo: shouldIncludeVideo, onProgress: setProgress }); - } finally { - setProgress(undefined); - } - } - - const path = await doHtml5ify(); - if (!path) return; - - setPreviewFilePath(path); - setUsingDummyVideo(usesDummyVideo); - }, [html5ify, html5ifyDummy]); - const handleHideCompatPlayerClick = useCallback(() => { setHideCompatPlayer(true); setPreviewFilePath(undefined); setUsingDummyVideo(false); - }, []); - - const convertFormatBatch = useCallback(async () => { - if (batchFiles.length === 0) return; - const filePaths = batchFiles.map((f) => f.path); - - const failedFiles: string[] = []; - let i = 0; - const setTotalProgress = (fileProgress = 0) => setProgress((i + fileProgress) / filePaths.length); - - const { selectedOption: speed } = await askForHtml5ifySpeed({ allowedOptions: ['fast-audio-remux', 'fast-audio', 'fast', 'slow', 'slow-audio', 'slowest'] }); - if (!speed) return; + }, [setHideCompatPlayer, setPreviewFilePath, setUsingDummyVideo]); - if (workingRef.current) return; - setWorking({ text: i18n.t('Batch converting to supported format') }); - setProgress(0); - try { - await withErrorHandling(async () => { - // eslint-disable-next-line no-restricted-syntax - for (const path of filePaths) { - try { - // eslint-disable-next-line no-await-in-loop - const newCustomOutDir = await ensureWritableOutDir({ inputPath: path, outDir: customOutDir }); - - // eslint-disable-next-line no-await-in-loop - await html5ify({ customOutDir: newCustomOutDir, filePath: path, speed, hasAudio: true, hasVideo: true, onProgress: setTotalProgress }); - } catch (err2) { - if (err2 instanceof DirectoryAccessDeclinedError) return; - - console.error('Failed to html5ify', path, err2); - failedFiles.push(path); - } - - i += 1; - setTotalProgress(); - } - - if (failedFiles.length > 0) toast.fire({ title: `${i18n.t('Failed to convert files:')} ${failedFiles.join(' ')}`, timer: undefined, showConfirmButton: true }); - }, i18n.t('Failed to batch convert to supported format')); - } finally { - setWorking(undefined); - setProgress(undefined); - } - }, [batchFiles, customOutDir, ensureWritableOutDir, html5ify, setWorking, workingRef]); - - const getConvertToSupportedFormat = useCallback((fallback: Html5ifyMode) => rememberConvertToSupportedFormat || fallback, [rememberConvertToSupportedFormat]); - - const html5ifyAndLoadWithPreferences = useCallback(async (cod: string | undefined, fp: string, speed: Html5ifyMode, hv: boolean, ha: boolean) => { - if (!enableAutoHtml5ify) return; - setWorking({ text: i18n.t('Converting to supported format') }); - await html5ifyAndLoad(cod, fp, getConvertToSupportedFormat(speed), hv, ha); - }, [enableAutoHtml5ify, setWorking, html5ifyAndLoad, getConvertToSupportedFormat]); + const { captureFrameFromTag, captureFrameFromFfmpeg, captureFramesRange } = useFrameCapture({ appendFfmpegCommandLog, formatTimecode, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart }); const getNewJumpIndex = (oldIndex: number, direction: -1 | 1) => Math.max(oldIndex + direction, 0); @@ -879,12 +807,11 @@ function App() { }); }, []); - const commonSettings = useMemo(() => ({ - ffmpegExperimental, - preserveMovData, - movFastStart, - preserveMetadataOnMerge, - }), [ffmpegExperimental, movFastStart, preserveMetadataOnMerge, preserveMovData]); + const commonSettings = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { customOutDir: _customOutDir, keyBindings: _keyBindings, ...rest } = allUserSettings.settings; + return rest; + }, [allUserSettings]); const openSendReportDialogWithState = useCallback(async (err?: unknown) => { const state = { @@ -900,18 +827,14 @@ function App() { rotation, shortestFlag, effectiveExportMode, - outSegTemplate, - mergedFileTemplate, - preserveMetadata, - preserveChapters, }; - openSendReportDialog(err, state); - }, [commonSettings, copyStreamIdsByFile, cutSegments, effectiveExportMode, externalFilesMeta, fileFormat, filePath, mainFileFormatData, mainStreams, mergedFileTemplate, outSegTemplate, preserveChapters, preserveMetadata, rotation, shortestFlag]); + openSendReportDialog({ err, state }); + }, [commonSettings, copyStreamIdsByFile, cutSegments, effectiveExportMode, externalFilesMeta, fileFormat, filePath, mainFileFormatData, mainStreams, rotation, shortestFlag]); const openSendConcatReportDialogWithState = useCallback(async (err: unknown, reportState?: object) => { const state = { ...commonSettings, ...reportState }; - openSendReportDialog(err, state); + openSendReportDialog({ err, state }); }, [commonSettings]); const handleExportFailed = useCallback(async (err: unknown) => { @@ -925,11 +848,16 @@ function App() { }, [fileFormat, openSendConcatReportDialogWithState]); const userConcatFiles = useCallback(async ({ paths, includeAllStreams, streams, fileFormat: outFormat, outFileName, clearBatchFilesAfterConcat }: { - paths: string[], includeAllStreams: boolean, streams: FFprobeStream[], fileFormat: string, outFileName: string, clearBatchFilesAfterConcat: boolean, + paths: string[], + includeAllStreams: boolean, + streams: FFprobeStream[], + fileFormat: string, + outFileName: string, + clearBatchFilesAfterConcat: boolean, }) => { if (workingRef.current) return; try { - setConcatDialogVisible(false); + setConcatSheetOpen(false); setWorking({ text: i18n.t('Merging') }); const firstPath = paths[0]; @@ -992,7 +920,7 @@ function App() { return; } - const reportState = { includeAllStreams, streams, outFormat, outFileName, segmentsToChapters }; + const reportState = { includeAllStreams, streams, outFormat, outFileName, segmentsToChapters, clearBatchFilesAfterConcat }; handleConcatFailed(err, reportState); } finally { setWorking(undefined); @@ -1024,7 +952,7 @@ function App() { await deleteFiles({ paths: pathsToDelete, deleteIfTrashFails: cleanupChoices2.deleteIfTrashFails, signal: abortController.signal }); }, (err) => i18n.t('Unable to delete file: {{message}}', { message: err instanceof Error ? err.message : String(err) })); - }, [batchListRemoveFile, clearSegments, filePath, previewFilePath, projectFileSavePath, resetState, setWorking]); + }, [batchListRemoveFile, clearSegments, filePath, previewFilePath, projectFileSavePath, resetState, setWorking, withErrorHandling]); const askForCleanupChoices = useCallback(async () => { const trashResponse = await showCleanupFilesDialog(cleanupChoices); @@ -1065,7 +993,7 @@ function App() { return generateMergedFileNamesRaw({ template, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, exportCount, currentFileExportCount, segmentsToExport }); }, [currentFileExportCount, exportCount, fileFormat, filePath, isCustomFormatSelected, maxLabelLength, outputDir, safeOutputFileName, segmentsToExport]); - const closeExportConfirm = useCallback(() => setExportConfirmVisible(false), []); + const closeExportConfirm = useCallback(() => setExportConfirmOpen(false), []); const willMerge = segmentsToExport.length > 1 && autoMerge; @@ -1083,7 +1011,7 @@ function App() { } setStreamsSelectorShown(false); - setExportConfirmVisible(false); + setExportConfirmOpen(false); if (workingRef.current) return; try { @@ -1248,13 +1176,13 @@ function App() { const onExportPress = useCallback(async () => { if (!filePath) return; - if (!exportConfirmEnabled || exportConfirmVisible) { + if (!exportConfirmEnabled || exportConfirmOpen) { await onExportConfirm(); } else { - setExportConfirmVisible(true); + setExportConfirmOpen(true); setStreamsSelectorShown(false); } - }, [filePath, exportConfirmEnabled, exportConfirmVisible, onExportConfirm]); + }, [filePath, exportConfirmEnabled, exportConfirmOpen, onExportConfirm]); const captureSnapshot = useCallback(async () => { if (!filePath) return; @@ -1277,7 +1205,7 @@ function App() { } finally { setWorking(undefined); } - }, [filePath, workingRef, setWorking, getRelevantTime, videoRef, usingPreviewFile, captureFrameMethod, captureFrameFromFfmpeg, customOutDir, captureFormat, captureFrameQuality, captureFrameFromTag, hideAllNotifications]); + }, [filePath, workingRef, setWorking, withErrorHandling, getRelevantTime, videoRef, usingPreviewFile, captureFrameMethod, captureFrameFromFfmpeg, customOutDir, captureFormat, captureFrameQuality, captureFrameFromTag, hideAllNotifications]); const extractSegmentsFramesAsImages = useCallback(async (segments: SegmentBase[]) => { if (!filePath || detectedFps == null || workingRef.current || segments.length === 0) return; @@ -1318,12 +1246,12 @@ function App() { } } catch (err) { showOsNotification(i18n.t('Failed to extract frames')); - handleError(err); + handleError({ err, title: i18n.t('Failed to extract frames') }); } finally { setWorking(undefined); setProgress(undefined); } - }, [filePath, detectedFps, workingRef, getFrameCount, setWorking, hideAllNotifications, captureFramesRange, customOutDir, captureFormat, captureFrameQuality, captureFrameFileNameFormat, showOsNotification, outputDir]); + }, [filePath, detectedFps, workingRef, getFrameCount, setWorking, hideAllNotifications, captureFramesRange, customOutDir, captureFormat, captureFrameQuality, captureFrameFileNameFormat, showOsNotification, outputDir, handleError]); const extractCurrentSegmentFramesAsImages = useCallback(() => { if (currentCutSeg != null) extractSegmentsFramesAsImages([currentCutSeg]); @@ -1513,7 +1441,7 @@ function App() { resetState(); throw err; } - }, [storeProjectInWorkingDir, setWorking, loadEdlFile, getEdlFilePath, enableAskForImportChapters, ensureAccessToSourceDir, loadCutSegments, autoLoadTimecode, enableNativeHevc, ensureWritableOutDir, customOutDir, resetState, clearSegColorCounter, setCopyStreamIdsForPath, setDetectedFileFormat, outFormatLocked, html5ifyAndLoadWithPreferences, setFileFormat, showNotification, showPreviewFileLoadedMessage, showUnsupportedFileMessage]); + }, [storeProjectInWorkingDir, setWorking, loadEdlFile, getEdlFilePath, enableAskForImportChapters, ensureAccessToSourceDir, loadCutSegments, autoLoadTimecode, enableNativeHevc, ensureWritableOutDir, customOutDir, resetState, clearSegColorCounter, setCopyStreamIdsForPath, setDetectedFileFormat, outFormatLocked, setUsingDummyVideo, setPreviewFilePath, html5ifyAndLoadWithPreferences, setFileFormat, showNotification, showPreviewFileLoadedMessage, showUnsupportedFileMessage]); const toggleLastCommands = useCallback(() => setLastCommandsVisible((val) => !val), []); const toggleSettings = useCallback(() => setSettingsVisible((val) => !val), []); @@ -1577,7 +1505,7 @@ function App() { } finally { setWorking(undefined); } - }, [workingRef, filePath, setWorking, userOpenSingleFile]); + }, [workingRef, filePath, setWorking, withErrorHandling, userOpenSingleFile]); const batchFileJump = useCallback((direction: number, alsoOpen: boolean) => { if (batchFiles.length === 0) return; @@ -1614,17 +1542,16 @@ function App() { const timecode = await promptTimecode({ initialValue: formatTimecode({ seconds: commandedTimeRef.current }), title: i18n.t('Seek to timecode'), - text: i18n.t('Use + and - for relative seek'), + description: i18n.t('Use + and - for relative seek'), allowRelative: true, inputPlaceholder: timecodePlaceholder, - parseTimecode, }); if (timecode === undefined) return; if (timecode.relDirection != null) seekRel(timecode.duration * timecode.relDirection); else seekAbs(timecode.duration); - }, [filePath, formatTimecode, commandedTimeRef, timecodePlaceholder, parseTimecode, seekRel, seekAbs]); + }, [filePath, promptTimecode, formatTimecode, commandedTimeRef, timecodePlaceholder, seekRel, seekAbs]); const goToTimecodeDirect = useCallback(async ({ time: timeStr }: { time: string }) => { if (!filePath) return; @@ -1643,7 +1570,7 @@ function App() { const extractAllStreams = useCallback(async () => { if (!filePath) return; - if (!(await confirmExtractAllStreamsDialog())) return; + if (!(await confirmDialog({ description: t('Please confirm that you want to extract all tracks as separate files'), confirmButtonText: t('Extract all tracks') }))) return; if (workingRef.current) return; try { @@ -1666,46 +1593,15 @@ function App() { } finally { setWorking(undefined); } - }, [customOutDir, extractStreams, filePath, hideAllNotifications, mainCopiedStreams, setWorking, showOsNotification, workingRef]); - - const userHtml5ifyCurrentFile = useCallback(async ({ ignoreRememberedValue }: { ignoreRememberedValue?: boolean } = {}) => { - if (!filePath) return; - - let selectedOption = rememberConvertToSupportedFormat; - if (selectedOption == null || ignoreRememberedValue) { - let allowedOptions: Html5ifyMode[] = []; - if (hasAudio && hasVideo) allowedOptions = ['fastest', 'fast-audio-remux', 'fast-audio', 'fast', 'slow', 'slow-audio', 'slowest']; - else if (hasAudio) allowedOptions = ['fast-audio-remux', 'slow-audio', 'slowest']; - else if (hasVideo) allowedOptions = ['fastest', 'fast', 'slow', 'slowest']; + }, [confirmDialog, customOutDir, extractStreams, filePath, hideAllNotifications, mainCopiedStreams, setWorking, showOsNotification, t, workingRef]); - const userResponse = await askForHtml5ifySpeed({ allowedOptions, showRemember: true, initialOption: selectedOption }); - console.log('Choice', userResponse); - ({ selectedOption } = userResponse); - if (!selectedOption) return; - - const { remember } = userResponse; - - setRememberConvertToSupportedFormat(remember ? selectedOption : undefined); - } - - if (workingRef.current) return; - try { - setWorking({ text: i18n.t('Converting to supported format') }); - await withErrorHandling(async () => { - await html5ifyAndLoad(customOutDir, filePath, selectedOption, hasVideo, hasAudio); - }, i18n.t('Failed to convert file. Try a different conversion')); - } finally { - setWorking(undefined); - } - }, [filePath, rememberConvertToSupportedFormat, workingRef, hasAudio, hasVideo, setWorking, html5ifyAndLoad, customOutDir]); const askStartTimeOffset = useCallback(async () => { const newStartTimeOffset = await promptTimecode({ initialValue: startTimeOffset !== undefined ? formatTimecode({ seconds: startTimeOffset }) : undefined, title: i18n.t('Set custom start time offset'), - text: i18n.t('Instead of video apparently starting at 0, you can offset by a specified value. This only applies to the preview inside LosslessCut and does not modify the file in any way. (Useful for viewing/cutting videos according to timecodes)'), + description: i18n.t('Instead of video apparently starting at 0, you can offset by a specified value. This only applies to the preview inside LosslessCut and does not modify the file in any way. (Useful for viewing/cutting videos according to timecodes)'), inputPlaceholder: timecodePlaceholder, - parseTimecode, allowRelative: true, }); @@ -1713,7 +1609,7 @@ function App() { const duration = newStartTimeOffset.relDirection != null ? newStartTimeOffset.duration * newStartTimeOffset.relDirection : newStartTimeOffset.duration; setStartTimeOffset(duration); - }, [formatTimecode, parseTimecode, startTimeOffset, timecodePlaceholder]); + }, [formatTimecode, promptTimecode, startTimeOffset, timecodePlaceholder]); const toggleKeyboardShortcuts = useCallback(() => setKeyboardShortcutsVisible((v) => !v), []); @@ -1733,7 +1629,7 @@ function App() { setWorking(undefined); setProgress(undefined); } - }, [checkFileOpened, customOutDir, fileFormat, fixInvalidDuration, loadMedia, setWorking, showNotification, workingRef]); + }, [checkFileOpened, customOutDir, fileFormat, fixInvalidDuration, loadMedia, setWorking, showNotification, withErrorHandling, workingRef]); const addStreamSourceFile = useCallback(async (path: string) => { if (allFilesMeta[path]) return undefined; // Already added? @@ -1755,24 +1651,19 @@ function App() { setter(params); })), [setParamsByStreamId]); - const addFileAsCoverArt = useCallback(async (path: string) => { - const fileMeta = await addStreamSourceFile(path); - if (!fileMeta) return false; - const firstIndex = fileMeta.streams[0]!.index; - // eslint-disable-next-line no-param-reassign - updateStreamParams(path, firstIndex, (params) => { params.disposition = 'attached_pic'; }); - return true; - }, [addStreamSourceFile, updateStreamParams]); - const captureSnapshotAsCoverArt = useCallback(async () => { if (!filePath) return; await withErrorHandling(async () => { const currentTime = getRelevantTime(); const path = await captureFrameFromFfmpeg({ customOutDir, filePath, time: currentTime, captureFormat, quality: captureFrameQuality }); - if (!(await addFileAsCoverArt(path))) return; + const fileMeta = await addStreamSourceFile(path); + if (!fileMeta) return; + const firstIndex = fileMeta.streams[0]!.index; + // eslint-disable-next-line no-param-reassign + updateStreamParams(path, firstIndex, (params) => { params.disposition = 'attached_pic'; }); showNotification({ text: i18n.t('Current frame has been set as cover art') }); }, i18n.t('Failed to capture frame')); - }, [addFileAsCoverArt, captureFormat, captureFrameFromFfmpeg, captureFrameQuality, customOutDir, filePath, getRelevantTime, showNotification]); + }, [addStreamSourceFile, captureFormat, captureFrameFromFfmpeg, captureFrameQuality, customOutDir, filePath, getRelevantTime, showNotification, updateStreamParams, withErrorHandling]); const batchLoadPaths = useCallback((newPaths: string[], append?: boolean) => { setBatchFiles((existingFiles) => { @@ -1826,7 +1717,7 @@ function App() { if (filePaths.length > 1) { if (alwaysConcatMultipleFiles) { batchLoadPaths(filePaths); - setConcatDialogVisible(true); + setConcatSheetOpen(true); } else { batchLoadPaths(filePaths, true); } @@ -1901,7 +1792,7 @@ function App() { if (filePath) batchPaths.add(filePath); filePaths.forEach((path) => batchPaths.add(path)); batchLoadPaths([...batchPaths]); - if (batchPaths.size > 1) setConcatDialogVisible(true); + if (batchPaths.size > 1) setConcatSheetOpen(true); return; } @@ -1914,7 +1805,7 @@ function App() { setWorking(undefined); } }, i18n.t('Failed to open file')); - }, [workingRef, alwaysConcatMultipleFiles, batchLoadPaths, setWorking, isFileOpened, batchFiles.length, userOpenSingleFile, checkFileOpened, loadEdlFile, enableAskForFileOpenAction, addStreamSourceFile, filePath]); + }, [withErrorHandling, workingRef, alwaysConcatMultipleFiles, batchLoadPaths, setWorking, isFileOpened, batchFiles.length, userOpenSingleFile, checkFileOpened, loadEdlFile, enableAskForFileOpenAction, addStreamSourceFile, filePath]); const openFilesDialog = useCallback(async () => { // On Windows and Linux an open dialog can not be both a file selector and a directory selector, so if you set `properties` to `['openFile', 'openDirectory']` on these platforms, a directory selector will be shown. #1995 @@ -1935,7 +1826,7 @@ function App() { return; } - setConcatDialogVisible(true); + setConcatSheetOpen(true); }, [batchFiles.length, openFilesDialog]); const toggleLoopSelectedSegments = useCallback(() => togglePlay({ resetPlaybackRate: true, requestPlaybackMode: 'loop-selected-segments' }), [togglePlay]); @@ -1952,7 +1843,7 @@ function App() { if (canceled || firstFilePath == null) return; await addStreamSourceFile(firstFilePath); }, i18n.t('Failed to include track')); - }, [addStreamSourceFile, t]); + }, [addStreamSourceFile, t, withErrorHandling]); const toggleFullscreenVideo = useCallback(async () => { if (!screenfull.isEnabled) { @@ -1998,7 +1889,7 @@ function App() { } finally { setWorking(); } - }, [customOutDir, ensureWritableOutDir, loadMedia, setWorking, t]); + }, [customOutDir, ensureWritableOutDir, loadMedia, setWorking, t, withErrorHandling]); type MainKeyboardAction = Exclude; @@ -2046,7 +1937,7 @@ function App() { focusSegmentAtCursor, selectSegmentsAtCursor, increaseRotation, - goToTimecode, + goToTimecode: () => { goToTimecode(); return false; }, seekBackwards: ({ keyup }) => seekRel2({ keyup, amount: -1 * keyboardNormalSeekSpeed }), seekBackwards2: ({ keyup }) => seekRel2({ keyup, amount: -1 * keyboardSeekSpeed2 }), seekBackwards3: ({ keyup }) => seekRel2({ keyup, amount: -1 * keyboardSeekSpeed3 }), @@ -2180,11 +2071,7 @@ function App() { // always allow if (action === 'closeActiveScreen') { closeExportConfirm(); - setLastCommandsVisible(false); - setSettingsVisible(false); - setStreamsSelectorShown(false); - setConcatDialogVisible(false); - setKeyboardShortcutsVisible(false); + setConcatSheetOpen(false); return false; } @@ -2193,16 +2080,17 @@ function App() { return false; } - if (concatDialogVisible || keyboardShortcutsVisible) { + if (concatSheetOpen || keyboardShortcutsVisible || genericDialog != null) { return true; // don't allow any further hotkeys } - if (exportConfirmVisible) { + if (exportConfirmOpen) { + // only allow export hotkey on export confirm screen if (action === 'export') { onExportConfirm(); - return false; + return false; // don't bubble } - return true; // don't allow any other hotkeys because we are at export confirm + return true; // don't allow any other hotkeys, but bubble } // allow main actions @@ -2210,7 +2098,7 @@ function App() { if (match) return bubble; return true; // bubble the event - }, [closeExportConfirm, concatDialogVisible, exportConfirmVisible, getKeyboardAction, keyboardShortcutsVisible, onExportConfirm, toggleKeyboardShortcuts]); + }, [closeExportConfirm, concatSheetOpen, exportConfirmOpen, genericDialog, getKeyboardAction, keyboardShortcutsVisible, onExportConfirm, toggleKeyboardShortcuts]); useKeyboard({ keyBindings, onKeyPress }); @@ -2305,7 +2193,7 @@ function App() { toast.fire({ icon: 'error', timer: 10000, text: i18n.t('Failed to read file. Perhaps it has been moved?') }); } } catch (err) { - handleError(err); + toastError(err); } }, [videoRef, fileUri, usingPreviewFile, filePath, workingRef, setWorking, hasVideo, hasAudio, html5ifyAndLoadWithPreferences, customOutDir, showUnsupportedFileMessage]); @@ -2321,7 +2209,7 @@ function App() { await withErrorHandling(async () => { await exportEdlFile({ type, cutSegments: selectedSegments, customOutDir, filePath, getFrameCount }); }, i18n.t('Failed to export project')); - }, [checkFileOpened, customOutDir, filePath, getFrameCount, selectedSegments]); + }, [checkFileOpened, customOutDir, filePath, getFrameCount, selectedSegments, withErrorHandling]); const importEdlFile = useCallback(async (type: EdlImportType) => { if (!checkFileOpened()) return; @@ -2330,7 +2218,7 @@ function App() { const edl = await askForEdlImport({ type, fps: detectedFps }); if (edl.length > 0) loadCutSegments({ segments: edl, append: true, clampDuration: fileDuration }); }, i18n.t('Failed to import project file')); - }, [checkFileOpened, detectedFps, fileDuration, loadCutSegments]); + }, [checkFileOpened, detectedFps, fileDuration, loadCutSegments, withErrorHandling]); useEffect(() => { const openFiles = (filePaths: string[]) => { userOpenFiles(filePaths.map((p) => resolvePathIfNeeded(p))); }; @@ -2339,7 +2227,7 @@ function App() { try { await fn(); } catch (err) { - handleError(err); + handleError({ err }); } } @@ -2401,7 +2289,7 @@ function App() { // todo validate arguments await (args != null ? fn(...args) : fn()); } catch (err) { - handleError(err); + console.error(err); } finally { // todo correlation ids event.sender.send('apiActionResponse', { id }); @@ -2420,24 +2308,28 @@ function App() { ipcActions.forEach(([key, action]) => electron.ipcRenderer.off(key, action)); electron.ipcRenderer.off('apiAction', tryApiAction); }; - }, [checkFileOpened, customOutDir, detectedFps, filePath, getFrameCount, getKeyboardAction, goToTimecodeDirect, importEdlFile, loadCutSegments, mainActions, promptDownloadMediaUrlWrapper, selectedSegments, toggleKeyboardShortcuts, tryExportEdlFile, userOpenFiles]); + }, [checkFileOpened, customOutDir, detectedFps, filePath, getFrameCount, getKeyboardAction, goToTimecodeDirect, handleError, importEdlFile, loadCutSegments, mainActions, promptDownloadMediaUrlWrapper, selectedSegments, toggleKeyboardShortcuts, tryExportEdlFile, userOpenFiles]); - const handleBatchFilesDrop = useCallback>((ev) => { + const handleBatchFilesDrop = useCallback>(async (ev) => { ev.preventDefault(); if (!ev.dataTransfer) return; - const filePaths = [...ev.dataTransfer.files].map((f) => electron.webUtils.getPathForFile(f)); - focusWindow(); - batchLoadPaths(filePaths, true); - }, [batchLoadPaths]); + await withErrorHandling(async () => { + const filePaths = [...ev.dataTransfer.files].map((f) => electron.webUtils.getPathForFile(f)); + focusWindow(); + batchLoadPaths(filePaths, true); + }); + }, [batchLoadPaths, withErrorHandling]); - const handleStreamSourceFileDrop = useCallback>((ev) => { + const handleStreamSourceFileDrop = useCallback>(async (ev) => { ev.preventDefault(); if (!ev.dataTransfer) return; - const filePaths = [...ev.dataTransfer.files].map((f) => electron.webUtils.getPathForFile(f)); - if (filePaths.length !== 1) return; - focusWindow(); - addStreamSourceFile(filePaths[0]!); - }, [addStreamSourceFile]); + await withErrorHandling(async () => { + const filePaths = [...ev.dataTransfer.files].map((f) => electron.webUtils.getPathForFile(f)); + if (filePaths.length !== 1) return; + focusWindow(); + addStreamSourceFile(filePaths[0]!); + }); + }, [addStreamSourceFile, withErrorHandling]); useEffect(() => { async function onDrop(ev: DragEvent) { @@ -2771,7 +2663,7 @@ function App() { {/* Dialogs */} - + @@ -2816,12 +2708,7 @@ function App() { - + @@ -2843,7 +2730,7 @@ function App() { - 0 && concatDialogVisible} onHide={() => setConcatDialogVisible(false)} paths={batchFilePaths} onConcat={userConcatFiles} setAlwaysConcatMultipleFiles={setAlwaysConcatMultipleFiles} alwaysConcatMultipleFiles={alwaysConcatMultipleFiles} exportCount={exportCount} maxLabelLength={maxLabelLength} /> + 0 && concatSheetOpen} onHide={() => setConcatSheetOpen(false)} paths={batchFilePaths} onConcat={userConcatFiles} setAlwaysConcatMultipleFiles={setAlwaysConcatMultipleFiles} alwaysConcatMultipleFiles={alwaysConcatMultipleFiles} exportCount={exportCount} maxLabelLength={maxLabelLength} /> setKeyboardShortcutsVisible(false)} keyBindings={keyBindings} setKeyBindings={setKeyBindings} currentCutSeg={currentCutSeg} resetKeyBindings={resetKeyBindings} /> @@ -2851,6 +2738,10 @@ function App() { {working && } + + !open && closeGenericDialog()} /> + + !open && setGenericError(undefined)} /> diff --git a/src/renderer/src/ErrorBoundary.tsx b/src/renderer/src/ErrorBoundary.tsx index badd5a08..3f89df7b 100644 --- a/src/renderer/src/ErrorBoundary.tsx +++ b/src/renderer/src/ErrorBoundary.tsx @@ -6,7 +6,7 @@ import { openSendReportDialog } from './reporting'; class ErrorBoundary extends Component<{ children: ReactNode }> { // eslint-disable-next-line react/state-in-constructor - override state: { error: { message: string } | undefined }; + override state: { error: unknown }; constructor(props: { children: ReactNode }) { super(props); @@ -27,8 +27,8 @@ class ErrorBoundary extends Component<{ children: ReactNode }> { return (

Something went wrong

-
{error.message}
-

+
{error instanceof Error ? error.message : String(error)}
+

); } diff --git a/src/renderer/src/LastCommandsSheet.tsx b/src/renderer/src/LastCommands.tsx similarity index 93% rename from src/renderer/src/LastCommandsSheet.tsx rename to src/renderer/src/LastCommands.tsx index 934c41ef..61b0bef4 100644 --- a/src/renderer/src/LastCommandsSheet.tsx +++ b/src/renderer/src/LastCommands.tsx @@ -8,7 +8,7 @@ import { FfmpegCommandLog } from './types'; import Button from './components/Button'; import * as Dialog from './components/Dialog'; -function LastCommandsSheet({ visible, onTogglePress, ffmpegCommandLog, setFfmpegCommandLog }: { +function LastCommands({ visible, onTogglePress, ffmpegCommandLog, setFfmpegCommandLog }: { visible: boolean, onTogglePress: () => void, ffmpegCommandLog: FfmpegCommandLog, @@ -46,4 +46,4 @@ function LastCommandsSheet({ visible, onTogglePress, ffmpegCommandLog, setFfmpeg ); } -export default memo(LastCommandsSheet); +export default memo(LastCommands); diff --git a/src/renderer/src/SegmentList.tsx b/src/renderer/src/SegmentList.tsx index 293e8a00..d7b40115 100644 --- a/src/renderer/src/SegmentList.tsx +++ b/src/renderer/src/SegmentList.tsx @@ -19,6 +19,7 @@ import TagEditor from './components/TagEditor'; import { ContextMenuTemplate, DefiniteSegmentBase, FormatTimecode, GetFrameCount, InverseCutSegment, SegmentBase, SegmentTags, StateSegment } from './types'; import { UseSegments } from './hooks/useSegments'; import * as Dialog from './components/Dialog'; +import { DialogButton } from './components/Button'; const buttonBaseStyle = { @@ -579,10 +580,12 @@ function SegmentList({ - - - {t('Save')} - + + + + {t('Save')} + + diff --git a/src/renderer/src/StreamsSelector.tsx b/src/renderer/src/StreamsSelector.tsx index 99763120..7e0799da 100644 --- a/src/renderer/src/StreamsSelector.tsx +++ b/src/renderer/src/StreamsSelector.tsx @@ -16,7 +16,7 @@ import { getActiveDisposition, attachedPicDisposition, isGpsStream } from './uti import TagEditor from './components/TagEditor'; import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe'; import { CustomTagsByFile, FilesMeta, FormatTimecode, ParamsByStreamId, StreamParams } from './types'; -import Button from './components/Button'; +import Button, { DialogButton } from './components/Button'; import Checkbox from './components/Checkbox'; import styles from './StreamsSelector.module.css'; import Json5Dialog from './components/Json5Dialog'; @@ -155,6 +155,12 @@ const EditStreamDialog = memo(({ editingStream: { streamId: editingStreamId, pat

Tags

+ + + {t('Done')} + + + @@ -538,6 +544,12 @@ function StreamsSelector({ + + + {t('Done')} + + + diff --git a/src/renderer/src/components/AlertDialog.module.css b/src/renderer/src/components/AlertDialog.module.css new file mode 100644 index 00000000..0e9d41c4 --- /dev/null +++ b/src/renderer/src/components/AlertDialog.module.css @@ -0,0 +1,115 @@ +/* Keep in sync with Dialog.module.css */ + +.AlertDialogOverlay { + background-color: var(--black-a9); + position: fixed; + inset: 0; + + &[data-state="open"] { + animation: overlayShow 500ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-state="closed"] { + animation: overlayHide 500ms cubic-bezier(0.16, 1, 0.3, 1); + } +} + +:global(.dark-theme) .AlertDialogOverlay { + background-color: var(--black-a8); +} + +.AlertDialogContent { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + box-sizing: border-box; + + overflow: scroll; + + max-width: 90vw; + max-height: 85vh; + + border: .1em solid var(--black-a2); + background: var(--white-a11); + color: var(--gray-12); + backdrop-filter: blur(2em); + border-radius: .5em; + padding: 1.7em; + box-shadow: 0 0 1em .3em var(--black-a1); + + &[data-state="open"] { + animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-state="closed"] { + animation: contentHide 150ms cubic-bezier(0.16, 1, 0.3, 1); + } +} + +:global(.dark-theme) .AlertDialogContent { + border: .1em solid var(--white-a3); + background: var(--black-a4); + box-shadow: 0 0 1em .3em var(--black-a2); +} + +.AlertDialogContent:focus { + outline: none; +} + +.AlertDialogTitle { + margin-top: 0; + margin-bottom: .5em; + font-weight: 500; + font-size: 1.3em; + padding-bottom: .3em; + border-bottom: .1em solid var(--black-a3); +} + +:global(.dark-theme) .AlertDialogTitle { + border-bottom: .1em solid var(--white-a4); +} + +.AlertDialogDescription { + margin-bottom: 1em; +} + +@keyframes overlayShow { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes overlayHide { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes contentShow { + from { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +@keyframes contentHide { + from { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + to { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } +} diff --git a/src/renderer/src/components/AlertDialog.tsx b/src/renderer/src/components/AlertDialog.tsx new file mode 100644 index 00000000..26f34667 --- /dev/null +++ b/src/renderer/src/components/AlertDialog.tsx @@ -0,0 +1,37 @@ +import * as AlertDialog from '@radix-ui/react-alert-dialog'; +import { useTranslation } from 'react-i18next'; + +import styles from './AlertDialog.module.css'; +import { withClass } from './util'; +import { DialogButton } from './Button'; + +export * from '@radix-ui/react-alert-dialog'; + +export const Overlay = withClass(AlertDialog.Overlay, styles['AlertDialogOverlay']!); + +export const Content = withClass(AlertDialog.Content, styles['AlertDialogContent']!); + +export const Title = withClass(AlertDialog.Title, styles['AlertDialogTitle']!); + +export const Description = withClass(AlertDialog.Description, styles['AlertDialogDescription']!); + +// eslint-disable-next-line react/jsx-props-no-spreading +export const Portal = (props: AlertDialog.AlertDialogPortalProps) => ; + +export function CancelButton() { + const { t } = useTranslation(); + return ( + + {t('Cancel')} + + ); +} + +export function OkButton() { + const { t } = useTranslation(); + return ( + + {t('OK')} + + ); +} diff --git a/src/renderer/src/components/Button.tsx b/src/renderer/src/components/Button.tsx index 3961cd6d..04be6a7c 100644 --- a/src/renderer/src/components/Button.tsx +++ b/src/renderer/src/components/Button.tsx @@ -1,6 +1,7 @@ import { ButtonHTMLAttributes, DetailedHTMLProps, forwardRef } from 'react'; import styles from './Button.module.css'; +import { primaryColor, primaryTextColor } from '../colors'; export type ButtonProps = DetailedHTMLProps, HTMLButtonElement>; @@ -11,3 +12,9 @@ const Button = forwardRef(({ type = 'button', cl )); export default Button; + +// eslint-disable-next-line react/display-name +export const DialogButton = forwardRef(({ primary, ...props }, ref) => ( + // eslint-disable-next-line react/jsx-props-no-spreading + } + {problemsByFile[path] && ( + + + + + + + + + {t('Mismatches detected')} + +
    + {(problemsByFile[path] || []).map((problem) =>
  • {problem}
  • )} +
+ + +
+
+
+ )} ))} @@ -273,12 +280,18 @@ function ConcatDialog({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFi

{t('Note that also other settings from the normal export dialog apply to this merge function. For more information about all options, see the export dialog.')}

+ + + {t('Done')} + + + - + ); } -export default memo(ConcatDialog); +export default memo(ConcatSheet); diff --git a/src/renderer/src/components/Dialog.module.css b/src/renderer/src/components/Dialog.module.css index e7ff696b..3a87041d 100644 --- a/src/renderer/src/components/Dialog.module.css +++ b/src/renderer/src/components/Dialog.module.css @@ -1,3 +1,5 @@ +/* Keep in sync with AlertDialog.module.css */ + .DialogOverlay { background-color: var(--black-a9); position: fixed; @@ -110,4 +112,4 @@ opacity: 0; transform: translate(-50%, -48%) scale(0.96); } -} \ No newline at end of file +} diff --git a/src/renderer/src/components/Dialog.tsx b/src/renderer/src/components/Dialog.tsx index c2823c73..13f088a4 100644 --- a/src/renderer/src/components/Dialog.tsx +++ b/src/renderer/src/components/Dialog.tsx @@ -1,6 +1,5 @@ import * as Dialog from '@radix-ui/react-dialog'; - -import Button, { ButtonProps } from './Button'; +import { ReactNode } from 'react'; import styles from './Dialog.module.css'; import { withClass } from './util'; @@ -8,7 +7,6 @@ import CloseButtonRaw from './CloseButton'; export * from '@radix-ui/react-dialog'; - export const Overlay = withClass(Dialog.Overlay, styles['DialogOverlay']!); export const Content = withClass(Dialog.Content, styles['DialogContent']!); @@ -20,13 +18,16 @@ export const Title = withClass(Dialog.Title, styles['DialogTitle']!); // eslint-disable-next-line react/jsx-props-no-spreading export const Portal = (props: Dialog.DialogPortalProps) => ; -export const ConfirmButton = ({ style, ...props }: ButtonProps) => ( - // eslint-disable-next-line react/jsx-props-no-spreading - + ))} + +
+ setValue(e.target.value)} + style={{ margin: '1em 0', width: '100%', boxSizing: 'border-box' }} + /> + + {error != null && ( +
+ {error} +
+ )} + + + + {t('Cancel')} + + + {confirmButtonText ?? t('Confirm')} + + + + ); +}); + +export default ExpressionDialog; diff --git a/src/renderer/src/components/FileNameTemplateEditor.tsx b/src/renderer/src/components/FileNameTemplateEditor.tsx index 2e5f34d0..8a053464 100644 --- a/src/renderer/src/components/FileNameTemplateEditor.tsx +++ b/src/renderer/src/components/FileNameTemplateEditor.tsx @@ -4,9 +4,8 @@ import i18n from 'i18next'; import { useTranslation } from 'react-i18next'; import { IoIosHelpCircle } from 'react-icons/io'; import { motion, AnimatePresence } from 'framer-motion'; -import { FaCheck, FaEdit, FaExclamationTriangle, FaUndo } from 'react-icons/fa'; +import { FaCheck, FaEdit, FaExclamationTriangle, FaFile, FaUndo } from 'react-icons/fa'; -import { ReactSwal } from '../swal'; import HighlightedText from './HighlightedText'; import { segNumVariable, segSuffixVariable, GenerateOutFileNames, extVariable, segTagsVariable, segNumIntVariable, selectedSegNumVariable, selectedSegNumIntVariable } from '../util/outputNameTemplate'; import useUserSettings from '../hooks/useUserSettings'; @@ -14,6 +13,7 @@ import Switch from './Switch'; import Select from './Select'; import TextInput from './TextInput'; import Button from './Button'; +import * as Dialog from './Dialog'; const electron = window.require('electron'); @@ -106,18 +106,6 @@ function FileNameTemplateEditor(opts: { // eslint-disable-next-line no-template-curly-in-string const isMissingExtension = validText != null && !validText.endsWith(extVariableFormatted); - const onAllFilesPreviewPress = useCallback(() => { - if (fileNames == null) return; - ReactSwal.fire({ - title: t('Resulting segment file names', { count: fileNames.length }), - html: ( -
- {fileNames.map((f) =>
{f}
)} -
- ), - }); - }, [fileNames, t]); - useEffect(() => { if (validText != null) setTemplate(validText); }, [validText, setTemplate]); @@ -180,7 +168,24 @@ function FileNameTemplateEditor(opts: { {!mergeMode && fileNames != null && ( - + + + + + + + + + {t('Resulting segment file names', { count: fileNames.length })} + +
+ {fileNames.map((f) =>
{f}
)} +
+ + +
+
+
)} diff --git a/src/renderer/src/components/GenericDialog.tsx b/src/renderer/src/components/GenericDialog.tsx new file mode 100644 index 00000000..3094611a --- /dev/null +++ b/src/renderer/src/components/GenericDialog.tsx @@ -0,0 +1,129 @@ +import React, { MouseEventHandler, ReactNode, useCallback, useContext, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import invariant from 'tiny-invariant'; + +import * as Dialog from './Dialog'; +import * as AlertDialog from './AlertDialog'; +import { DialogButton } from './Button'; + + +export interface GenericDialogParams { + isAlert?: boolean; + content: React.ReactNode; + onClose?: () => void; +} + +export type ShowGenericDialog = (dialog: GenericDialogParams) => void; + +interface GenericDialogContextValue { + onOpenChange: (open: boolean) => void, +} + +const GenericDialogContext = React.createContext(undefined); + +export function useGenericDialogContext() { + const context = useContext(GenericDialogContext); + invariant(context); + return context; +} + +export default function GenericDialog({ dialog, onOpenChange }: { + dialog: GenericDialogParams | undefined, + onOpenChange: (open: boolean) => void, +}) { + const context = useMemo(() => ({ onOpenChange }), [onOpenChange]); + + if (dialog == null) { + return null; + } + + if (dialog.isAlert) { + return ( + + + + + + {dialog.content} + + + + ); + } + + return ( + + + + + {dialog?.content} + + + ); +} + +export function useDialog() { + const { t } = useTranslation(); + const [genericDialog, setGenericDialog] = useState(); + const genericDialogRef = useRef(genericDialog); + + const showGenericDialog = useCallback((dialog) => { + if (genericDialogRef.current) { + throw new Error('A dialog is already open, cannot open another one'); + } + genericDialogRef.current = dialog; + setGenericDialog(dialog); + }, []); + + const closeGenericDialog = useCallback(() => { + genericDialogRef.current?.onClose?.(); + genericDialogRef.current = undefined; + setGenericDialog(undefined); + }, []); + + const confirmDialog = useCallback(({ title = t('Please confirm'), description, confirmButtonText = t('Confirm'), cancelButtonText = t('Cancel') }: { + title?: ReactNode, + description?: string, + confirmButtonText?: string, + cancelButtonText?: string, + }) => new Promise((resolve) => { + function ConfirmDialog() { + const { onOpenChange } = useGenericDialogContext(); + + const handleConfirmClick = useCallback>((e) => { + e.preventDefault(); + resolve(true); + onOpenChange(false); + }, [onOpenChange]); + + return ( + + {title} + + {description && {description}} + + + + {cancelButtonText} + + + {confirmButtonText} + + + ); + } + + showGenericDialog({ + isAlert: true, + content: , + onClose: () => resolve(false), + }); + }), [showGenericDialog, t]); + + return { + genericDialog, + closeGenericDialog, + showGenericDialog, + confirmDialog, + }; +} diff --git a/src/renderer/src/components/GpsMap.tsx b/src/renderer/src/components/GpsMap.tsx index 9f7ad028..45883e10 100644 --- a/src/renderer/src/components/GpsMap.tsx +++ b/src/renderer/src/components/GpsMap.tsx @@ -5,9 +5,9 @@ import 'leaflet/dist/leaflet.css'; import { FaMapMarkerAlt } from 'react-icons/fa'; import { extractSrtGpsTrack } from '../ffmpeg'; -import { handleError } from '../util'; import { parseDjiGps1, parseDjiGps2 } from '../edlFormats'; import * as Dialog from './Dialog'; +import { useAppContext } from '../contexts'; // https://www.openstreetmap.org/copyright @@ -33,6 +33,7 @@ export default function GpsMap({ filePath, streamIndex }: { filePath: string, streamIndex: number, }) { + const { handleError } = useAppContext(); const [points, setPoints] = useState>>(); useEffect(() => { @@ -56,10 +57,10 @@ export default function GpsMap({ filePath, streamIndex }: { setPoints(gpsPoints); } catch (err) { - handleError(err); + handleError({ err }); } })(); - }, [filePath, streamIndex]); + }, [filePath, handleError, streamIndex]); const firstPoint = points?.[0]; diff --git a/src/renderer/src/components/KeyboardShortcuts.tsx b/src/renderer/src/components/KeyboardShortcuts.tsx index 6d3d4927..ddfcfc52 100644 --- a/src/renderer/src/components/KeyboardShortcuts.tsx +++ b/src/renderer/src/components/KeyboardShortcuts.tsx @@ -16,7 +16,7 @@ import { StateSegment } from '../types'; import { splitKeyboardKeys } from '../util'; import * as Dialog from './Dialog'; import Warning from './Warning'; -import Button from './Button'; +import Button, { DialogButton } from './Button'; import Action from './Action'; import TextInput from './TextInput'; @@ -131,7 +131,11 @@ const CreateBinding = memo(({ )} - action != null && onNewKeyBindingConfirmed(action, keysDown)}>{t('Save')} + + action != null && onNewKeyBindingConfirmed(action, keysDown)} primary> + {t('Save')} + + diff --git a/src/renderer/src/components/SwalContainer.tsx b/src/renderer/src/components/SwalContainer.tsx index be9557d6..36451ac0 100644 --- a/src/renderer/src/components/SwalContainer.tsx +++ b/src/renderer/src/components/SwalContainer.tsx @@ -3,7 +3,6 @@ import { swalContainerWrapperId } from '../swal'; export default function SwalContainer({ darkMode, ...props }: HTMLAttributes & { darkMode: boolean }) { return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions
{mergedTags[tag] ? String(mergedTags[tag]) : `<${t('empty')}>`} )} {(editingTag == null || editingThis) && ( - + )} {editingThis && ( )} @@ -160,7 +160,7 @@ function TagEditor({ existingTags = emptyObject, customTags = emptyObject, editi
setNewTagKeyInput(e.target.value)} placeholder={addTagTitle} style={{ padding: '.4em', marginRight: '1em', verticalAlign: 'middle' }} /> - + {newTagKeyInputError && {t('Invalid character(s) found in key')}} diff --git a/src/renderer/src/contexts.ts b/src/renderer/src/contexts.ts index 63c69faa..5d43dcd9 100644 --- a/src/renderer/src/contexts.ts +++ b/src/renderer/src/contexts.ts @@ -1,12 +1,14 @@ import React, { useContext } from 'react'; import Color from 'color'; -import useUserSettingsRoot from './hooks/useUserSettingsRoot'; +import { UserSettingsRoot } from './hooks/useUserSettingsRoot'; import { ExportMode, SegmentColorIndex } from './types'; import type useLoading from './hooks/useLoading'; +import { GenericError } from './components/ErrorDialog'; +import { ShowGenericDialog } from './components/GenericDialog'; -export type UserSettingsContextType = ReturnType & { +export type UserSettingsContextType = Omit & UserSettingsRoot['settings'] & { toggleCaptureFormat: () => void, changeOutDir: () => Promise, toggleKeyframeCut: (showMessage?: boolean) => void, @@ -20,9 +22,13 @@ interface SegColorsContextType { getSegColor: (seg: SegmentColorIndex | undefined) => Color } +export type HandleError = (error: GenericError) => void; + interface AppContextType { setWorking: ReturnType['setWorking'], working: ReturnType['working'], + handleError: HandleError, + showGenericDialog: ShowGenericDialog, } @@ -30,6 +36,12 @@ export const UserSettingsContext = React.createContext(undefined); export const AppContext = React.createContext(undefined); +export function useAppContext() { + const context = useContext(AppContext); + if (context == null) throw new Error('AppContext nullish'); + return context; +} + export const useSegColors = () => { const context = useContext(SegColorsContext); if (context == null) throw new Error('SegColorsContext nullish'); diff --git a/src/renderer/src/dialogs/html5ify.tsx b/src/renderer/src/dialogs/html5ify.tsx deleted file mode 100644 index 35deac44..00000000 --- a/src/renderer/src/dialogs/html5ify.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useState, useCallback, ChangeEventHandler } from 'react'; -import i18n from 'i18next'; - -import { ReactSwal } from '../swal'; -import { Html5ifyMode } from '../../../../types'; -import Checkbox from '../components/Checkbox'; - - -// eslint-disable-next-line import/prefer-default-export -export async function askForHtml5ifySpeed({ allowedOptions, showRemember, initialOption }: { - allowedOptions: Html5ifyMode[], - showRemember?: boolean | undefined, - initialOption?: Html5ifyMode | undefined, -}) { - const availOptions: Record = { - fastest: i18n.t('Fastest: FFmpeg-assisted playback'), - fast: i18n.t('Fast: Full quality remux (no audio), likely to fail'), - 'fast-audio-remux': i18n.t('Fast: Full quality remux, likely to fail'), - 'fast-audio': i18n.t('Fast: Remux video, encode audio (fails if unsupported video codec)'), - slow: i18n.t('Slow: Low quality encode (no audio)'), - 'slow-audio': i18n.t('Slow: Low quality encode'), - slowest: i18n.t('Slowest: High quality encode'), - }; - const inputOptions: Partial> = {}; - allowedOptions.forEach((allowedOption) => { - inputOptions[allowedOption] = availOptions[allowedOption]; - }); - - let selectedOption: Html5ifyMode = initialOption != null && inputOptions[initialOption] ? initialOption : Object.keys(inputOptions)[0]! as Html5ifyMode; - let rememberChoice = !!initialOption; - - function AskForHtml5ifySpeed() { - const [option, setOption] = useState(selectedOption); - const [remember, setRemember] = useState(rememberChoice); - - const onOptionChange = useCallback>((e) => { - selectedOption = e.currentTarget.value as Html5ifyMode; - setOption(selectedOption); - }, []); - - const onRememberChange = useCallback((checked: boolean) => { - rememberChoice = checked; - setRemember(rememberChoice); - }, []); - - return ( -
-

{i18n.t('These options will let you convert files to a format that is supported by the player. You can try different options and see which works with your file. Note that the conversion is for preview only. When you run an export, the output will still be lossless with full quality')}

- - {Object.entries(inputOptions).map(([value, label]) => { - const id = `html5ify-${value}`; - return ( -
- - {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} - -
- ); - })} - - {showRemember && } -
- ); - } - - const { value: response } = await ReactSwal.fire({ - title: i18n.t('Convert to supported format'), - html: , - showCancelButton: true, - }); - - return { - selectedOption: response != null ? selectedOption : undefined, - remember: rememberChoice, - }; -} diff --git a/src/renderer/src/dialogs/index.tsx b/src/renderer/src/dialogs/index.tsx index b615113c..ee69fe45 100644 --- a/src/renderer/src/dialogs/index.tsx +++ b/src/renderer/src/dialogs/index.tsx @@ -13,56 +13,12 @@ import Checkbox from '../components/Checkbox'; import { isWindows, showItemInFolder } from '../util'; import { ParseTimecode } from '../types'; import { FindKeyframeMode } from '../ffmpeg'; -import Action from '../components/Action'; const remote = window.require('@electron/remote'); -const { dialog, shell } = remote; +const { dialog } = remote; const { downloadMediaUrl } = remote.require('./index.js'); - -export async function promptTimecode({ initialValue, title, text, inputPlaceholder, parseTimecode, allowRelative = false }: { - initialValue?: string | undefined, - title: string, - text?: string | undefined, - inputPlaceholder: string, - parseTimecode: ParseTimecode, - allowRelative?: boolean, -}) { - const { value } = await Swal.fire({ - title, - text, - input: 'text', - inputValue: initialValue || '', - didOpen: () => { - Swal.getInput()!.select(); - }, - showCancelButton: true, - inputPlaceholder, - }); - - if (value === undefined) { - return undefined; - } - - let relDirection: number | undefined; - if (allowRelative) { - if (value.startsWith('-')) relDirection = -1; - else if (value.startsWith('+')) relDirection = 1; - } - - const withoutPrefix = allowRelative ? value.replace(/^[+-]/, '') : value; - - const duration = parseTimecode(withoutPrefix); - // Invalid, try again - if (duration === undefined) return promptTimecode({ initialValue: value, title, text, inputPlaceholder, parseTimecode, allowRelative }); - - return { - duration, - relDirection, - }; -} - // https://github.com/mifi/lossless-cut/issues/1495 export const showOpenDialog = async ({ filters = isWindows ? [{ name: i18n.t('All Files'), extensions: ['*'] }] : undefined, @@ -379,15 +335,6 @@ export async function askForAlignSegments() { }; } -export async function confirmExtractAllStreamsDialog() { - const { value } = await Swal.fire({ - text: i18n.t('Please confirm that you want to extract all tracks as separate files'), - showCancelButton: true, - confirmButtonText: i18n.t('Extract all tracks'), - }); - return !!value; -} - export interface CleanupChoicesType { trashTmpFiles: boolean, closeFile: boolean, @@ -601,103 +548,6 @@ export async function selectSegmentsByLabelDialog(currentName?: string | undefin return value; } -export async function exprDialog({ inputValidator, examples, title, description, variables, inputValue }: { - inputValidator: (v: string) => Promise, - examples: { name: string, code: string }[], - title: string, - description: ReactNode, - variables?: string[], - inputValue?: string | undefined, -}) { - function addExample(code: string) { - Swal.getInput()!.value = code; - } - - const { value } = await ReactSwal.fire({ - showCancelButton: true, - title, - input: 'text', - width: '90vw', - html: ( -
-
- {description} -
- - {variables &&
{i18n.t('Variables')}: {variables.map((v) => {v})}
} - -
{i18n.t('Examples')}:
- - {examples.map(({ name, code }) => ( - - ))} -
- ), - inputValue, - inputPlaceholder: i18n.t('Enter JavaScript expression'), - inputValidator, - }); - return value; -} - -export async function selectSegmentsByExprDialog(inputValidator: (v: string) => Promise) { - return exprDialog({ - inputValidator, - examples: [ - { name: i18n.t('Segment duration less than 5 seconds'), code: 'segment.duration < 5' }, - { name: i18n.t('Segment starts after 01:00'), code: 'segment.start > 60' }, - { name: i18n.t('Segment label (exact)'), code: "segment.label === 'My label'" }, - { name: i18n.t('Segment label (regexp)'), code: '/^My label/.test(segment.label)' }, - { name: i18n.t('Segment tag value'), code: "segment.tags.myTag === 'tag value'" }, - { name: i18n.t('Markers'), code: 'segment.end == null' }, - ], - title: i18n.t('Select segments by expression'), - description: Enter a JavaScript expression which will be evaluated for each segment. Segments for which the expression evaluates to "true" will be selected. , - variables: ['segment.index', 'segment.label', 'segment.start', 'segment.end', 'segment.duration', 'segment.tags.*'], - }); -} - -export async function filterEnabledStreamsDialog({ validator, value }: { - validator: (v: string) => Promise, - value: string | undefined, -}) { - return exprDialog({ - inputValidator: validator, - examples: [ - { name: i18n.t('Audio tracks'), code: "track.codec_type === 'audio'" }, - { name: i18n.t('Video tracks'), code: "track.codec_type === 'video'" }, - { name: i18n.t('English language tracks'), code: "track.tags?.language === 'eng'" }, - { name: i18n.t('Tracks with at least 720p video'), code: 'track.height >= 720' }, - { name: i18n.t('Tracks with H264 codec'), code: "track.codec_name === 'h264'" }, - { name: i18n.t('1st, 2nd and 3rd track'), code: 'track.index >= 0 && track.index <= 2' }, - ], - title: i18n.t('Toggle tracks by expression'), - description: Enter a JavaScript filter expression which will be evaluated for each track of the current file. Tracks for which the expression evaluates to "true" will be selected or deselected. You may also the keyboard action to run this filter., - inputValue: value ?? '', - }); -} - -export async function mutateSegmentsByExprDialog(inputValidator: (v: string) => Promise) { - return exprDialog({ - inputValidator, - examples: [ - { name: i18n.t('Expand segments +5 sec'), code: '{ start: segment.start - 5, end: segment.end + 5 }' }, - { name: i18n.t('Shrink segments -5 sec'), code: '{ start: segment.start + 5, end: segment.end - 5 }' }, - { name: i18n.t('Center segments around start time'), code: '{ start: segment.start - 5, end: segment.start + 5 }' }, - // eslint-disable-next-line no-template-curly-in-string - { name: i18n.t('Add number suffix to label'), code: '{ label: `${segment.label} ${segment.index + 1}` }' }, - { name: i18n.t('Add a tag to every even segment'), code: '{ tags: (segment.index + 1) % 2 === 0 ? { ...segment.tags, even: \'true\' } : segment.tags }' }, - { name: i18n.t('Convert segments to markers'), code: '{ end: undefined }' }, - { name: i18n.t('Convert markers to segments'), code: '{ ...(segment.end == null && { end: segment.start + 5 }) }' }, - ], - title: i18n.t('Edit segments by expression'), - description: Enter a JavaScript expression which will be evaluated for each selected segment. Returned properties will be edited. , - variables: ['segment.index', 'segment.label', 'segment.start', 'segment.end', 'segment.tags.*'], - }); -} - export async function openDirToast({ filePath, text, html, ...props }: SweetAlertOptions & { filePath: string }) { const swal = text ? toast : ReactSwal; diff --git a/src/renderer/src/dialogs/parameters.tsx b/src/renderer/src/dialogs/parameters.tsx deleted file mode 100644 index 3a4a064a..00000000 --- a/src/renderer/src/dialogs/parameters.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useState, useCallback, useRef, useEffect, FormEvent } from 'react'; -import i18n from 'i18next'; -import { FaLink } from 'react-icons/fa'; - -import Swal, { ReactSwal } from '../swal'; -import Button from '../components/Button'; -import TextInput from '../components/TextInput'; -import { FfmpegDialog, getHint, getLabel } from '../ffmpegParameters'; - - -const { shell } = window.require('electron'); - - -export type ParameterDialogParameters = Record; - -const ParametersInput = ({ description, dialogType, parameters: parametersIn, onChange, onSubmit, docUrl }: { - description?: string | undefined, - dialogType: FfmpegDialog, - parameters: ParameterDialogParameters, - onChange: (a: ParameterDialogParameters) => void, - onSubmit: () => void, - docUrl?: string | undefined, -}) => { - const firstInputRef = useRef(null); - const [parameters, setParameters] = useState(parametersIn); - - const getParameter = (key: string) => parameters[key]; - - const handleChange = (key: string, value: string) => setParameters((existing) => { - const newParameters = { ...existing, [key]: value }; - onChange(newParameters); - return newParameters; - }); - - const handleSubmit = useCallback((e: FormEvent) => { - e.preventDefault(); - onSubmit(); - }, [onSubmit]); - - useEffect(() => { - firstInputRef.current?.focus(); - }, []); - - return ( -
- {description &&

{description}

} - - {docUrl &&

} - -
- {Object.entries(parametersIn).map(([key, parameter], i) => { - const id = `parameter-${key}`; - return ( -
- - handleChange(key, e.target.value)} - style={{ marginBottom: '.2em' }} - /> - {getHint(dialogType, key) &&
{getHint(dialogType, key)}
} -
- ); - })} - - -
-
- ); -}; - -export async function showParametersDialog({ title, description, dialogType, parameters: parametersIn, docUrl }: { - title?: string, - description?: string, - dialogType: FfmpegDialog, - parameters: ParameterDialogParameters, - docUrl?: string, -}) { - let parameters = parametersIn; - let resolve1: (value: boolean) => void; - - const promise1 = new Promise((resolve) => { - resolve1 = resolve; - }); - const handleSubmit = () => { - Swal.close(); - resolve1(true); - }; - - const promise2 = (async () => { - const { isConfirmed } = await ReactSwal.fire({ - title, - html: ( - { parameters = newParameters; }} - onSubmit={handleSubmit} - docUrl={docUrl} - /> - ), - confirmButtonText: i18n.t('Confirm'), - showCancelButton: true, - cancelButtonText: i18n.t('Cancel'), - }); - return isConfirmed; - })(); - - const isConfirmed = await Promise.race([promise1, promise2]); - if (!isConfirmed) return undefined; - - return parameters; -} diff --git a/src/renderer/src/hooks/useErrorHandling.ts b/src/renderer/src/hooks/useErrorHandling.ts new file mode 100644 index 00000000..fd1249b1 --- /dev/null +++ b/src/renderer/src/hooks/useErrorHandling.ts @@ -0,0 +1,52 @@ +import i18n from 'i18next'; +import { useCallback, useState } from 'react'; + +import { errorToast } from '../swal'; +import { DirectoryAccessDeclinedError, UnsupportedFileError } from '../../errors'; +import { isAbortedError } from '../util'; +import { GenericError } from '../components/ErrorDialog'; + + +export default function useErrorHandling() { + const [genericError, setGenericError] = useState(); + + const handleError = useCallback(({ title, err }: { title?: string | undefined, err?: unknown | undefined }) => { + console.error('handleError', title, err); + setGenericError({ title, err }); + }, []); + + /** + * Run an operation with error handling + */ + async function withErrorHandling(operation: () => Promise, errorMsgOrFn?: string | ((err: unknown) => string)) { + try { + await operation(); + } catch (err) { + if (err instanceof DirectoryAccessDeclinedError || isAbortedError(err)) return; + + if (err instanceof UnsupportedFileError) { + errorToast(i18n.t('Unsupported file')); + return; + } + + let errorMsg: string | undefined; + if (typeof errorMsgOrFn === 'string') errorMsg = errorMsgOrFn; + if (typeof errorMsgOrFn === 'function') errorMsg = errorMsgOrFn(err); + if (errorMsg != null) { + console.error(errorMsg, err); + handleError({ err, title: errorMsg }); + } else { + handleError({ err }); + } + } + } + + return { + withErrorHandling, + handleError, + genericError, + setGenericError, + }; +} + +export type WithErrorHandling = ReturnType['withErrorHandling']; diff --git a/src/renderer/src/hooks/useFfmpegOperations.ts b/src/renderer/src/hooks/useFfmpegOperations.ts index e21e48b3..dad91234 100644 --- a/src/renderer/src/hooks/useFfmpegOperations.ts +++ b/src/renderer/src/hooks/useFfmpegOperations.ts @@ -1001,3 +1001,5 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea } export default useFfmpegOperations; + +export type FfmpegOperations = ReturnType; diff --git a/src/renderer/src/hooks/useHtml5ify.tsx b/src/renderer/src/hooks/useHtml5ify.tsx new file mode 100644 index 00000000..b13ff2a0 --- /dev/null +++ b/src/renderer/src/hooks/useHtml5ify.tsx @@ -0,0 +1,249 @@ +import { useState, useCallback, ChangeEventHandler } from 'react'; +import i18n from 'i18next'; +import { useTranslation } from 'react-i18next'; + +import { Html5ifyMode } from '../../../../types'; +import { DirectoryAccessDeclinedError } from '../../errors'; +import { toast } from '../swal'; +import Checkbox from '../components/Checkbox'; +import { getSuffixedOutPath, html5dummySuffix, html5ifiedPrefix } from '../util'; +import { SetWorking } from './useLoading'; +import { WithErrorHandling } from './useErrorHandling'; +import { FfmpegOperations } from './useFfmpegOperations'; +import { ShowGenericDialog, useGenericDialogContext } from '../components/GenericDialog'; +import * as AlertDialog from '../components/AlertDialog'; +import { ButtonRow } from '../components/Dialog'; +import { DialogButton } from '../components/Button'; + + +export default function useHtml5ify({ filePath, hasVideo, hasAudio, workingRef, setWorking, ensureWritableOutDir, customOutDir, batchFiles, enableAutoHtml5ify, setProgress, html5ify, html5ifyDummy, withErrorHandling, showGenericDialog }: { + filePath: string | undefined, + hasVideo: boolean, + hasAudio: boolean, + workingRef: React.MutableRefObject, + setWorking: SetWorking, + ensureWritableOutDir: (options: { inputPath: string, outDir: string | undefined }) => Promise, + customOutDir: string | undefined, + batchFiles: { path: string }[], + enableAutoHtml5ify: boolean, + setProgress: (progress: number | undefined) => void, + html5ify: FfmpegOperations['html5ify'], + html5ifyDummy: FfmpegOperations['html5ifyDummy'], + withErrorHandling: WithErrorHandling, + showGenericDialog: ShowGenericDialog, +}) { + const [previewFilePath, setPreviewFilePath] = useState(); + const [usingDummyVideo, setUsingDummyVideo] = useState(false); + const [rememberConvertToSupportedFormat, setRememberConvertToSupportedFormat] = useState(); + + const html5ifyAndLoad = useCallback(async (cod: string | undefined, fp: string, speed: Html5ifyMode, hv: boolean, ha: boolean) => { + const usesDummyVideo = speed === 'fastest'; + console.log('html5ifyAndLoad', { speed, hasVideo: hv, hasAudio: ha, usesDummyVideo }); + + async function doHtml5ify() { + if (speed == null) return undefined; + if (speed === 'fastest') { + const path = getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: `${html5ifiedPrefix}${html5dummySuffix}.mkv` }); + try { + setProgress(0); + await html5ifyDummy({ filePath: fp, outPath: path, onProgress: setProgress }); + } finally { + setProgress(undefined); + } + return path; + } + + try { + const shouldIncludeVideo = !usesDummyVideo && hv; + return await html5ify({ customOutDir: cod, filePath: fp, speed, hasAudio: ha, hasVideo: shouldIncludeVideo, onProgress: setProgress }); + } finally { + setProgress(undefined); + } + } + + const path = await doHtml5ify(); + if (!path) return; + + setPreviewFilePath(path); + setUsingDummyVideo(usesDummyVideo); + }, [html5ify, html5ifyDummy, setProgress]); + + const askForHtml5ifySpeed = useCallback(async ({ allowedOptions, showRemember, initialOption }: { + allowedOptions: Html5ifyMode[], + showRemember?: boolean | undefined, + initialOption?: Html5ifyMode | undefined, + }) => { + const availOptions: Record = { + fastest: i18n.t('Fastest: FFmpeg-assisted playback'), + fast: i18n.t('Fast: Full quality remux (no audio), likely to fail'), + 'fast-audio-remux': i18n.t('Fast: Full quality remux, likely to fail'), + 'fast-audio': i18n.t('Fast: Remux video, encode audio (fails if unsupported video codec)'), + slow: i18n.t('Slow: Low quality encode (no audio)'), + 'slow-audio': i18n.t('Slow: Low quality encode'), + slowest: i18n.t('Slowest: High quality encode'), + }; + const inputOptions: Partial> = {}; + allowedOptions.forEach((allowedOption) => { + inputOptions[allowedOption] = availOptions[allowedOption]; + }); + + const response = await new Promise<{ selectedOption: Html5ifyMode, rememberChoice: boolean } | undefined>((resolve) => { + function AskForHtml5ifySpeed() { + const { onOpenChange } = useGenericDialogContext(); + const { t } = useTranslation(); + + const [option, setOption] = useState(initialOption != null && inputOptions[initialOption] ? initialOption : Object.keys(inputOptions)[0]! as Html5ifyMode); + const [remember, setRemember] = useState(!!initialOption); + + const onOptionChange = useCallback>((e) => setOption(e.currentTarget.value as Html5ifyMode), []); + + const onRememberChange = useCallback((checked: boolean) => setRemember(checked), []); + + const handleOkClick = useCallback(() => { + resolve({ selectedOption: option, rememberChoice: remember }); + onOpenChange(false); + }, [onOpenChange, option, remember]); + + return ( + + + {i18n.t('Convert to supported format')} + + + + {i18n.t('These options will let you convert files to a format that is supported by the player. You can try different options and see which works with your file. Note that the conversion is for preview only. When you run an export, the output will still be lossless with full quality')} + + + {Object.entries(inputOptions).map(([value, label]) => { + const id = `html5ify-${value}`; + return ( +
+ + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + +
+ ); + })} + + {showRemember && } + + + + {t('Cancel')} + + + {t('OK')} + +
+ ); + } + + showGenericDialog({ + isAlert: true, + content: , + onClose: () => resolve(undefined), + }); + }); + + if (response == null) { + return undefined; + } + + return response; + }, [showGenericDialog]); + + const userHtml5ifyCurrentFile = useCallback(async ({ ignoreRememberedValue }: { ignoreRememberedValue?: boolean } = {}) => { + if (!filePath) return; + + let selectedOption = rememberConvertToSupportedFormat; + if (selectedOption == null || ignoreRememberedValue) { + let allowedOptions: Html5ifyMode[] = []; + if (hasAudio && hasVideo) allowedOptions = ['fastest', 'fast-audio-remux', 'fast-audio', 'fast', 'slow', 'slow-audio', 'slowest']; + else if (hasAudio) allowedOptions = ['fast-audio-remux', 'slow-audio', 'slowest']; + else if (hasVideo) allowedOptions = ['fastest', 'fast', 'slow', 'slowest']; + + const userResponse = await askForHtml5ifySpeed({ allowedOptions, showRemember: true, initialOption: selectedOption }); + console.log('Choice', userResponse); + if (userResponse == null) return; + ({ selectedOption } = userResponse); + + const { rememberChoice } = userResponse; + + setRememberConvertToSupportedFormat(rememberChoice ? selectedOption : undefined); + } + + if (workingRef.current) return; + try { + setWorking({ text: i18n.t('Converting to supported format') }); + await withErrorHandling(async () => { + await html5ifyAndLoad(customOutDir, filePath, selectedOption, hasVideo, hasAudio); + }, i18n.t('Failed to convert file. Try a different conversion')); + } finally { + setWorking(undefined); + } + }, [filePath, rememberConvertToSupportedFormat, workingRef, hasAudio, hasVideo, askForHtml5ifySpeed, setWorking, withErrorHandling, html5ifyAndLoad, customOutDir]); + + const convertFormatBatch = useCallback(async () => { + if (batchFiles.length === 0) return; + + const response = await askForHtml5ifySpeed({ allowedOptions: ['fast-audio-remux', 'fast-audio', 'fast', 'slow', 'slow-audio', 'slowest'] }); + if (response == null) return; + const { selectedOption: speed } = response; + + if (workingRef.current) return; + + setWorking({ text: i18n.t('Batch converting to supported format') }); + setProgress(0); + + const filePaths = batchFiles.map((f) => f.path); + + const failedFiles: string[] = []; + let i = 0; + const setTotalProgress = (fileProgress = 0) => setProgress((i + fileProgress) / filePaths.length); + + try { + await withErrorHandling(async () => { + // eslint-disable-next-line no-restricted-syntax + for (const path of filePaths) { + try { + // eslint-disable-next-line no-await-in-loop + const newCustomOutDir = await ensureWritableOutDir({ inputPath: path, outDir: customOutDir }); + + // eslint-disable-next-line no-await-in-loop + await html5ify({ customOutDir: newCustomOutDir, filePath: path, speed, hasAudio: true, hasVideo: true, onProgress: setTotalProgress }); + } catch (err2) { + if (err2 instanceof DirectoryAccessDeclinedError) return; + + console.error('Failed to html5ify', path, err2); + failedFiles.push(path); + } + + i += 1; + setTotalProgress(); + } + + if (failedFiles.length > 0) toast.fire({ title: `${i18n.t('Failed to convert files:')} ${failedFiles.join(' ')}`, timer: undefined, showConfirmButton: true }); + }, i18n.t('Failed to batch convert to supported format')); + } finally { + setWorking(undefined); + setProgress(undefined); + } + }, [askForHtml5ifySpeed, batchFiles, customOutDir, ensureWritableOutDir, html5ify, setProgress, setWorking, withErrorHandling, workingRef]); + + const getConvertToSupportedFormat = useCallback((fallback: Html5ifyMode) => rememberConvertToSupportedFormat || fallback, [rememberConvertToSupportedFormat]); + + const html5ifyAndLoadWithPreferences = useCallback(async (cod: string | undefined, fp: string, speed: Html5ifyMode, hv: boolean, ha: boolean) => { + if (!enableAutoHtml5ify) return; + setWorking({ text: i18n.t('Converting to supported format') }); + await html5ifyAndLoad(cod, fp, getConvertToSupportedFormat(speed), hv, ha); + }, [enableAutoHtml5ify, setWorking, html5ifyAndLoad, getConvertToSupportedFormat]); + + return { previewFilePath, setPreviewFilePath, usingDummyVideo, setUsingDummyVideo, userHtml5ifyCurrentFile, convertFormatBatch, html5ifyAndLoadWithPreferences }; +} diff --git a/src/renderer/src/hooks/useKeyframes.ts b/src/renderer/src/hooks/useKeyframes.ts index ebf90d25..b0196535 100644 --- a/src/renderer/src/hooks/useKeyframes.ts +++ b/src/renderer/src/hooks/useKeyframes.ts @@ -6,12 +6,12 @@ import { useTranslation } from 'react-i18next'; import { readFramesAroundTime, findNearestKeyFrameTime as ffmpegFindNearestKeyFrameTime, Frame, readFrames } from '../ffmpeg'; import { FFprobeStream } from '../../../../ffprobe'; import { getFrameCountRaw } from '../edlFormats'; -import { handleError } from '../util'; +import { HandleError } from '../contexts'; const toObj = (map: Frame[]) => Object.fromEntries(map.map((frame) => [frame.time, frame])); -function useKeyframes({ keyframesEnabled, filePath, commandedTime, videoStream, detectedFps, ffmpegExtractWindow, maxKeyframes, currentCutSegOrWholeTimeline, setWorking, setMaxKeyframes }: { +function useKeyframes({ keyframesEnabled, filePath, commandedTime, videoStream, detectedFps, ffmpegExtractWindow, maxKeyframes, currentCutSegOrWholeTimeline, setWorking, setMaxKeyframes, handleError }: { keyframesEnabled: boolean, filePath: string | undefined, commandedTime: number, @@ -22,6 +22,7 @@ function useKeyframes({ keyframesEnabled, filePath, commandedTime, videoStream, currentCutSegOrWholeTimeline: { start: number, end: number }, setWorking: (w: { text: string, abortController?: AbortController } | undefined) => void, setMaxKeyframes: (max: number) => void, + handleError: HandleError, }) { const { t } = useTranslation(); @@ -94,11 +95,11 @@ function useKeyframes({ keyframesEnabled, filePath, commandedTime, videoStream, setNeighbouringKeyFrames(toObj(newKeyFrames)); setMaxKeyframes(newKeyFrames.length); } catch (err) { - handleError(err); + handleError({ err }); } finally { setWorking(undefined); } - }, [currentCutSegOrWholeTimeline, filePath, setMaxKeyframes, setWorking, t, videoStream]); + }, [currentCutSegOrWholeTimeline, filePath, handleError, setMaxKeyframes, setWorking, t, videoStream]); return { diff --git a/src/renderer/src/hooks/useLoading.ts b/src/renderer/src/hooks/useLoading.ts index 9b890219..65ea8926 100644 --- a/src/renderer/src/hooks/useLoading.ts +++ b/src/renderer/src/hooks/useLoading.ts @@ -3,15 +3,20 @@ import { useTranslation } from 'react-i18next'; import { abortFfmpegs } from '../ffmpeg'; -export default () => { +export interface WorkingState { + text: string, + abortController?: AbortController | undefined, +} + +export default function useLoading() { const { t } = useTranslation(); - const [working, setWorkingState] = useState<{ text: string, abortController?: AbortController | undefined } | undefined>(); + const [working, setWorkingState] = useState(); // Store "working" in a ref so we can avoid race conditions const workingRef = useRef(!!working); - const setWorking = useCallback((valOrBool?: { text: string, abortController?: AbortController } | true | undefined) => { + const setWorking = useCallback((valOrBool?: WorkingState | true | undefined) => { workingRef.current = !!valOrBool; const val = valOrBool === true ? { text: t('Loading') } : valOrBool; setWorkingState(val); @@ -29,4 +34,6 @@ export default () => { setWorking, abortWorking, }; -}; +} + +export type SetWorking = ReturnType['setWorking']; diff --git a/src/renderer/src/hooks/useSegments.ts b/src/renderer/src/hooks/useSegments.tsx similarity index 81% rename from src/renderer/src/hooks/useSegments.ts rename to src/renderer/src/hooks/useSegments.tsx index cb95fdbc..a4b4af55 100644 --- a/src/renderer/src/hooks/useSegments.ts +++ b/src/renderer/src/hooks/useSegments.tsx @@ -1,29 +1,41 @@ -import { useCallback, useRef, useMemo, useState, MutableRefObject } from 'react'; +import { useCallback, useRef, useMemo, useState, MutableRefObject, FormEvent, useEffect } from 'react'; import { useStateWithHistory } from 'react-use/lib/useStateWithHistory'; import i18n from 'i18next'; import pMap from 'p-map'; import invariant from 'tiny-invariant'; import sortBy from 'lodash/sortBy'; +import { Trans, useTranslation } from 'react-i18next'; +import { FaLink } from 'react-icons/fa'; +import TextInput from '../components/TextInput'; import { detectSceneChanges as ffmpegDetectSceneChanges, readFrames, mapTimesToSegments, findKeyframeNearTime } from '../ffmpeg'; -import { handleError, shuffleArray } from '../util'; +import { shuffleArray, toastError } from '../util'; import { errorToast } from '../swal'; -import { showParametersDialog } from '../dialogs/parameters'; -import { createNumSegments as createNumSegmentsDialog, createFixedByteSixedSegments as createFixedByteSixedSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, askForShiftSegments, askForAlignSegments, selectSegmentsByLabelDialog, selectSegmentsByExprDialog, mutateSegmentsByExprDialog, askForSegmentDuration } from '../dialogs'; +import { createNumSegments as createNumSegmentsDialog, createFixedByteSixedSegments as createFixedByteSixedSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, askForShiftSegments, askForAlignSegments, selectSegmentsByLabelDialog, askForSegmentDuration } from '../dialogs'; import { createSegment, sortSegments, invertSegments, combineOverlappingSegments as combineOverlappingSegments2, combineSelectedSegments as combineSelectedSegments2, isDurationValid, addSegmentColorIndex, filterNonMarkers, makeDurationSegments, isInitialSegment } from '../segments'; -import { parameters as allFfmpegParameters, FfmpegDialog } from '../ffmpegParameters'; +import { parameters as allFfmpegParameters, FfmpegDialog, getHint, getLabel } from '../ffmpegParameters'; import { maxSegmentsAllowed } from '../util/constants'; import { DefiniteSegmentBase, ParseTimecode, SegmentBase, segmentTagsSchema, SegmentToExport, StateSegment, UpdateSegAtIndex } from '../types'; import safeishEval from '../worker/eval'; import { ScopeSegment } from '../../../../types'; import { FFprobeFormat, FFprobeStream } from '../../../../ffprobe'; +import { HandleError } from '../contexts'; +import { ShowGenericDialog, useGenericDialogContext } from '../components/GenericDialog'; +import ExpressionDialog from '../components/ExpressionDialog'; +import Button, { DialogButton } from '../components/Button'; +import { ButtonRow } from '../components/Dialog'; +import * as AlertDialog from '../components/AlertDialog'; -const { ffmpeg: { blackDetect, silenceDetect } } = window.require('@electron/remote').require('./index.js'); +const remote = window.require('@electron/remote'); +const { shell } = remote; +const { ffmpeg: { blackDetect, silenceDetect } } = remote.require('./index.js'); +type ParameterDialogParameters = Record; + const offsetSegments = (segments: DefiniteSegmentBase[], offset: number) => segments.map((s) => ({ start: s.start + offset, end: s.end + offset })); -function useSegments({ filePath, workingRef, setWorking, setProgress, videoStream, fileDuration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog, fileDurationNonZero, mainFileMeta, seekAbs, activeVideoStreamIndex, activeAudioStreamIndexes }: { +function useSegments({ filePath, workingRef, setWorking, setProgress, videoStream, fileDuration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog, fileDurationNonZero, mainFileMeta, seekAbs, activeVideoStreamIndex, activeAudioStreamIndexes, handleError, showGenericDialog }: { filePath?: string | undefined, workingRef: MutableRefObject, setWorking: (w: { text: string, abortController?: AbortController } | undefined) => void, @@ -43,7 +55,11 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea seekAbs: (val: number | undefined) => void, activeVideoStreamIndex: number | undefined, activeAudioStreamIndexes: Set, + handleError: HandleError, + showGenericDialog: ShowGenericDialog, }) { + const { t } = useTranslation(); + // Segment related state const segColorCounterRef = useRef(0); @@ -170,12 +186,12 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea }); appendFfmpegCommandLog(ffmpegArgs); } catch (err) { - if (!(err instanceof Error && err.name === 'AbortError')) handleError(errorText, err); + if (!(err instanceof Error && err.name === 'AbortError')) handleError({ err, title: errorText }); } finally { setWorking(undefined); setProgress(undefined); } - }, [filePath, workingRef, setWorking, setProgress, appendFfmpegCommandLog, loadCutSegments, fileDuration, seekAbs]); + }, [filePath, workingRef, setWorking, setProgress, appendFfmpegCommandLog, loadCutSegments, fileDuration, seekAbs, handleError]); const getScopeSegment = useCallback((seg: Pick, index: number): ScopeSegment => { const { start, end, name, tags } = seg; @@ -207,6 +223,78 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea return parameters; }, [ffmpegParameters]); + const showParametersDialog = useCallback(async ({ title, description, dialogType, parameters: parametersIn, docUrl }: { + title?: string, + description?: string, + dialogType: FfmpegDialog, + parameters: ParameterDialogParameters, + docUrl?: string, + }) => new Promise((resolve) => { + function Dialog() { + const { onOpenChange } = useGenericDialogContext(); + + const firstInputRef = useRef(null); + const [parameters, setParameters] = useState(parametersIn); + + const getParameter = (key: string) => parameters[key]; + + const handleChange = (key: string, value: string) => setParameters((existing) => ({ ...existing, [key]: value })); + + const handleSubmit = useCallback((e: FormEvent) => { + e.preventDefault(); + resolve(parameters); + onOpenChange(false); + }, [onOpenChange, parameters]); + + useEffect(() => { + firstInputRef.current?.focus(); + }, []); + + return ( + + {title} + + {description} + + {docUrl &&

} + +
+ {Object.entries(parametersIn).map(([key, parameter], i) => { + const id = `parameter-${key}`; + return ( +
+ + handleChange(key, e.target.value)} + style={{ marginBottom: '.2em' }} + /> + {getHint(dialogType, key) &&
{getHint(dialogType, key)}
} +
+ ); + })} + + + + {t('Cancel')} + + + {t('Confirm')} + +
+
+ ); + } + + showGenericDialog({ + isAlert: true, + content: , + onClose: () => resolve(undefined), + }); + }), [showGenericDialog, t]); + const detectBlackScenes = useCallback(async () => { const { start, end } = currentCutSegOrWholeTimeline; deleteCurrentCutSeg(); @@ -218,7 +306,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea invariant(mode === '1' || mode === '2'); invariant(filePath != null); await detectSegments({ name: 'blackScenes', workingText: i18n.t('Detecting black scenes'), errorText: i18n.t('Failed to detect black scenes'), fn: async (onSegmentDetected) => blackDetect({ filePath, streamId: activeVideoStreamIndex, filterOptions, boundingMode: mode === '1', onProgress: setProgress, onSegmentDetected, from: start, to: end }) }); - }, [currentCutSegOrWholeTimeline, deleteCurrentCutSeg, getFfmpegParameters, setFfmpegParametersForDialog, filePath, detectSegments, activeVideoStreamIndex, setProgress]); + }, [currentCutSegOrWholeTimeline, deleteCurrentCutSeg, showParametersDialog, getFfmpegParameters, setFfmpegParametersForDialog, filePath, detectSegments, activeVideoStreamIndex, setProgress]); const detectSilentScenes = useCallback(async () => { const { start, end } = currentCutSegOrWholeTimeline; @@ -231,7 +319,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea invariant(mode === '1' || mode === '2'); invariant(filePath != null); await detectSegments({ name: 'silentScenes', workingText: i18n.t('Detecting silent scenes'), errorText: i18n.t('Failed to detect silent scenes'), fn: async (onSegmentDetected) => silenceDetect({ filePath, streamId: [...activeAudioStreamIndexes][0], filterOptions, boundingMode: mode === '1', onProgress: setProgress, onSegmentDetected, from: start, to: end }) }); - }, [activeAudioStreamIndexes, currentCutSegOrWholeTimeline, deleteCurrentCutSeg, detectSegments, filePath, getFfmpegParameters, setFfmpegParametersForDialog, setProgress]); + }, [activeAudioStreamIndexes, currentCutSegOrWholeTimeline, deleteCurrentCutSeg, detectSegments, filePath, getFfmpegParameters, setFfmpegParametersForDialog, setProgress, showParametersDialog]); const detectSceneChanges = useCallback(async () => { const { start, end } = currentCutSegOrWholeTimeline; @@ -245,7 +333,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea const minChange = parameters['minChange']; invariant(minChange != null); await detectSegments({ name: 'sceneChanges', workingText: i18n.t('Detecting scene changes'), errorText: i18n.t('Failed to detect scene changes'), fn: async (onSegmentDetected) => ffmpegDetectSceneChanges({ filePath, streamId: activeVideoStreamIndex, minChange, onProgress: setProgress, onSegmentDetected, from: start, to: end }) }); - }, [activeVideoStreamIndex, currentCutSegOrWholeTimeline, deleteCurrentCutSeg, detectSegments, filePath, getFfmpegParameters, setFfmpegParametersForDialog, setProgress]); + }, [activeVideoStreamIndex, currentCutSegOrWholeTimeline, deleteCurrentCutSeg, detectSegments, filePath, getFfmpegParameters, setFfmpegParametersForDialog, setProgress, showParametersDialog]); const createSegmentsFromKeyframes = useCallback(async () => { const { start, end } = currentCutSegOrWholeTimeline; @@ -418,11 +506,11 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea return newSegment; }); } catch (err) { - handleError(err); + handleError({ err }); } finally { setWorking(undefined); } - }, [filePath, videoStream, modifySelectedSegmentTimes, setWorking, workingRef]); + }, [videoStream, workingRef, setWorking, modifySelectedSegmentTimes, filePath, handleError]); const updateSegOrder = useCallback((index: number, newOrder: number) => { if (newOrder > cutSegments.length - 1 || newOrder < 0) return; @@ -513,7 +601,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea } */ setCutTime('start', startTime); } catch (err) { - handleError(err); + toastError(err); } } }, [checkFileOpened, getRelevantTime, currentCutSeg, addSegment, setCutTime]); @@ -530,7 +618,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea } */ setCutTime('end', endTime); } catch (err) { - handleError(err); + toastError(err); } }, [checkFileOpened, getRelevantTime, setCutTime]); @@ -655,25 +743,43 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea ((await matchSegment(seg, index, expr)) ? [seg] : []) ), { concurrency: 5 })).flat(); - const value = await selectSegmentsByExprDialog(async (v: string) => { + const onSubmit = async (value: string) => { try { - if (v.trim().length === 0) return i18n.t('Please enter a JavaScript expression.'); - const segments = await getSegmentsToSelect(v); - if (segments.length === 0) return i18n.t('No segments match this expression.'); - if (segments.length === cutSegments.length) return i18n.t('All segments match this expression.'); + if (value.trim().length === 0) return { error: i18n.t('Please enter a JavaScript expression.') }; + const segmentsToSelect = await getSegmentsToSelect(value); + if (segmentsToSelect.length === 0) return { error: i18n.t('No segments match this expression.') }; + if (segmentsToSelect.length === cutSegments.length) return { error: i18n.t('All segments match this expression.') }; + selectSegments(segmentsToSelect); return undefined; } catch (err) { if (err instanceof Error) { - return i18n.t('Expression failed: {{errorMessage}}', { errorMessage: err.message }); + return { error: i18n.t('Expression failed: {{errorMessage}}', { errorMessage: err.message }) }; } throw err; } - }); + }; - if (value == null) return; - const segmentsToSelect = await getSegmentsToSelect(value); - selectSegments(segmentsToSelect); - }, [cutSegments, selectSegments, getScopeSegment]); + showGenericDialog({ + isAlert: true, + content: ( + 60' }, + { name: i18n.t('Segment label (exact)'), code: "segment.label === 'My label'" }, + { name: i18n.t('Segment label (regexp)'), code: '/^My label/.test(segment.label)' }, + { name: i18n.t('Segment tag value'), code: "segment.tags.myTag === 'tag value'" }, + { name: i18n.t('Markers'), code: 'segment.end == null' }, + ]} + title={i18n.t('Select segments by expression')} + description={Enter a JavaScript expression which will be evaluated for each segment. Segments for which the expression evaluates to "true" will be selected. } + variables={['segment.index', 'segment.label', 'segment.start', 'segment.end', 'segment.duration', 'segment.tags.*']} + /> + ), + }); + }, [showGenericDialog, t, getScopeSegment, cutSegments, selectSegments]); const mutateSegmentsByExpr = useCallback(async () => { async function mutateSegment(seg: StateSegment, index: number, expr: string) { @@ -705,22 +811,45 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea ...(seg.selected && await mutateSegment(seg, index, expr)), }), { concurrency: 5 })).flat(); - const value = await mutateSegmentsByExprDialog(async (v: string) => { + const onSubmit = async (value: string) => { try { - if (v.trim().length === 0) return i18n.t('Please enter a JavaScript expression.'); - await mutateSegments(v); + if (value.trim().length === 0) return { error: i18n.t('Please enter a JavaScript expression.') }; + const mutated = await mutateSegments(value); + + safeSetCutSegments(mutated, fileDuration); + return undefined; } catch (err) { if (err instanceof Error) { - return i18n.t('Expression failed: {{errorMessage}}', { errorMessage: err.message }); + return { error: i18n.t('Expression failed: {{errorMessage}}', { errorMessage: err.message }) }; } throw err; } - }); + }; - if (value == null) return; - safeSetCutSegments(await mutateSegments(value), fileDuration); - }, [cutSegments, fileDuration, getScopeSegment, safeSetCutSegments]); + showGenericDialog({ + isAlert: true, + content: ( + Enter a JavaScript expression which will be evaluated for each selected segment. Returned properties will be edited. } + variables={['segment.index', 'segment.label', 'segment.start', 'segment.end', 'segment.tags.*']} + /> + ), + }); + }, [cutSegments, fileDuration, getScopeSegment, safeSetCutSegments, showGenericDialog, t]); const labelSelectedSegments = useCallback(async () => { const firstSelectedSegment = selectedSegments[0]; diff --git a/src/renderer/src/hooks/useStreamsMeta.ts b/src/renderer/src/hooks/useStreamsMeta.tsx similarity index 69% rename from src/renderer/src/hooks/useStreamsMeta.ts rename to src/renderer/src/hooks/useStreamsMeta.tsx index c6ea0151..ed9d8b61 100644 --- a/src/renderer/src/hooks/useStreamsMeta.ts +++ b/src/renderer/src/hooks/useStreamsMeta.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, useState } from 'react'; import pMap from 'p-map'; import invariant from 'tiny-invariant'; +import { Trans, useTranslation } from 'react-i18next'; import { isStreamThumbnail, shouldCopyStreamByDefault } from '../util/streams'; import StreamsSelector from '../StreamsSelector'; @@ -8,15 +9,20 @@ import { FFprobeStream } from '../../../../ffprobe'; import { FilesMeta } from '../types'; import safeishEval from '../worker/eval'; import i18n from '../i18n'; -import { filterEnabledStreamsDialog } from '../dialogs'; +import Action from '../components/Action'; +import ExpressionDialog from '../components/ExpressionDialog'; +import { ShowGenericDialog } from '../components/GenericDialog'; -export default ({ mainStreams, externalFilesMeta, filePath, autoExportExtraStreams }: { +export default function useStreamsMeta({ mainStreams, externalFilesMeta, filePath, autoExportExtraStreams, showGenericDialog }: { mainStreams: FFprobeStream[], externalFilesMeta: FilesMeta, filePath: string | undefined, autoExportExtraStreams: boolean, -}) => { + showGenericDialog: ShowGenericDialog, +}) { + const { t } = useTranslation(); + const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState>>({}); // this will be remembered between files: const [enabledStreamsFilter, setEnabledStreamsFilter] = useState(); @@ -86,34 +92,49 @@ export default ({ mainStreams, externalFilesMeta, filePath, autoExportExtraStrea const isEmpty = (v: string) => v.trim().length === 0; - const expr = await filterEnabledStreamsDialog({ - validator: async (v: string) => { - try { - if (isEmpty(v)) return undefined; - const streams = await filterEnabledStreams(v); - if (streams.length === 0) return i18n.t('No tracks match this expression.'); - return undefined; - } catch (err) { - if (err instanceof Error) { - return i18n.t('Expression failed: {{errorMessage}}', { errorMessage: err.message }); - } - throw err; - } - }, - value: enabledStreamsFilter, + showGenericDialog({ + isAlert: true, + content: ( + { + try { + if (isEmpty(value)) return undefined; + const streams = await filterEnabledStreams(value); + if (streams.length === 0) return { error: i18n.t('No tracks match this expression.') }; + + if (isEmpty(value)) { + // allow user to reset filter + setEnabledStreamsFilter(undefined); + return undefined; + } + + setEnabledStreamsFilter(value); + + await applyEnabledStreamsFilter(value); + return undefined; + } catch (err) { + if (err instanceof Error) { + return { error: i18n.t('Expression failed: {{errorMessage}}', { errorMessage: err.message }) }; + } + throw err; + } + }} + examples={[ + { name: i18n.t('Audio tracks'), code: "track.codec_type === 'audio'" }, + { name: i18n.t('Video tracks'), code: "track.codec_type === 'video'" }, + { name: i18n.t('English language tracks'), code: "track.tags?.language === 'eng'" }, + { name: i18n.t('Tracks with at least 720p video'), code: 'track.height >= 720' }, + { name: i18n.t('Tracks with H264 codec'), code: "track.codec_name === 'h264'" }, + { name: i18n.t('1st, 2nd and 3rd track'), code: 'track.index >= 0 && track.index <= 2' }, + ]} + title={i18n.t('Toggle tracks by expression')} + description={Enter a JavaScript filter expression which will be evaluated for each track of the current file. Tracks for which the expression evaluates to "true" will be selected or deselected. You may also the keyboard action to run this filter.} + inputValue={enabledStreamsFilter ?? ''} + /> + ), }); - - if (expr == null) return; - - if (isEmpty(expr)) { - setEnabledStreamsFilter(undefined); - return; - } - - setEnabledStreamsFilter(expr); - - await applyEnabledStreamsFilter(expr); - }, [applyEnabledStreamsFilter, enabledStreamsFilter, filePath, filterEnabledStreams]); + }, [applyEnabledStreamsFilter, enabledStreamsFilter, filePath, filterEnabledStreams, showGenericDialog, t]); const toggleStripCodecType = useCallback((codecType: FFprobeStream['codec_type']) => toggleCopyStreamIds(filePath!, (stream) => stream.codec_type === codecType), [filePath, toggleCopyStreamIds]); const toggleStripAudio = useCallback(() => toggleStripCodecType('audio'), [toggleStripCodecType]); @@ -128,4 +149,4 @@ export default ({ mainStreams, externalFilesMeta, filePath, autoExportExtraStrea }, [setCopyStreamIdsForPath]); return { nonCopiedExtraStreams, exportExtraStreams, mainCopiedThumbnailStreams, numStreamsToCopy, toggleStripAudio, toggleStripVideo, toggleStripSubtitle, toggleStripThumbnail, toggleStripAll, copyStreamIdsByFile, setCopyStreamIdsByFile, copyFileStreams, mainCopiedStreams, setCopyStreamIdsForPath, toggleCopyStreamId, isCopyingStreamId, toggleCopyStreamIds, changeEnabledStreamsFilter, applyEnabledStreamsFilter, enabledStreamsFilter, toggleCopyAllStreamsForPath }; -}; +} diff --git a/src/renderer/src/hooks/useTimecode.ts b/src/renderer/src/hooks/useTimecode.ts deleted file mode 100644 index 9b72d61f..00000000 --- a/src/renderer/src/hooks/useTimecode.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { FormatTimecode, ParseTimecode } from '../types'; -import { getFrameCountRaw } from '../edlFormats'; -import { getFrameDuration } from '../util'; -import { TimecodeFormat } from '../../../../types'; -import { formatDuration, parseDuration } from '../util/duration'; - - -export default ({ detectedFps, timecodeFormat }: { - detectedFps: number | undefined, - timecodeFormat: TimecodeFormat, -}) => { - const getFrameCount = useCallback((sec: number) => getFrameCountRaw(detectedFps, sec), [detectedFps]); - const frameCountToDuration = useCallback((frames: number) => getFrameDuration(detectedFps) * frames, [detectedFps]); - - const formatTimecode = useCallback(({ seconds, shorten, fileNameFriendly }) => { - if (timecodeFormat === 'frameCount') { - const frameCount = getFrameCount(seconds); - return frameCount != null ? String(frameCount) : ''; - } - if (timecodeFormat === 'seconds') { - return seconds.toFixed(3); - } - if (timecodeFormat === 'timecodeWithFramesFraction') { - return formatDuration({ seconds, shorten, fileNameFriendly, fps: detectedFps }); - } - return formatDuration({ seconds, shorten, fileNameFriendly }); - }, [detectedFps, timecodeFormat, getFrameCount]); - - const timecodePlaceholder = useMemo(() => formatTimecode({ seconds: 0, shorten: false }), [formatTimecode]); - - const parseTimecode = useCallback((val: string) => { - if (timecodeFormat === 'frameCount') { - const parsed = parseInt(val, 10); - return frameCountToDuration(parsed); - } - if (timecodeFormat === 'seconds') { - return parseFloat(val); - } - if (timecodeFormat === 'timecodeWithFramesFraction') { - return parseDuration(val, detectedFps); - } - return parseDuration(val); - }, [detectedFps, frameCountToDuration, timecodeFormat]); - - const formatTimeAndFrames = useCallback((seconds: number) => { - const frameCount = getFrameCount(seconds); - - const timeStr = timecodeFormat === 'timecodeWithFramesFraction' - ? formatDuration({ seconds, fps: detectedFps }) - : formatDuration({ seconds }); - - return `${timeStr} (${frameCount ?? '0'})`; - }, [detectedFps, timecodeFormat, getFrameCount]); - - return { - parseTimecode, - formatTimecode, - formatTimeAndFrames, - timecodePlaceholder, - getFrameCount, - }; -}; diff --git a/src/renderer/src/hooks/useTimecode.tsx b/src/renderer/src/hooks/useTimecode.tsx new file mode 100644 index 00000000..5f17c6c9 --- /dev/null +++ b/src/renderer/src/hooks/useTimecode.tsx @@ -0,0 +1,154 @@ +import { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { FormatTimecode, ParseTimecode } from '../types'; +import { getFrameCountRaw } from '../edlFormats'; +import { getFrameDuration } from '../util'; +import { TimecodeFormat } from '../../../../types'; +import { formatDuration, parseDuration } from '../util/duration'; +import { ShowGenericDialog, useGenericDialogContext } from '../components/GenericDialog'; +import * as AlertDialog from '../components/AlertDialog'; +import TextInput from '../components/TextInput'; +import { ButtonRow } from '../components/Dialog'; +import { DialogButton } from '../components/Button'; + + +export default ({ detectedFps, timecodeFormat, showGenericDialog }: { + detectedFps: number | undefined, + timecodeFormat: TimecodeFormat, + showGenericDialog: ShowGenericDialog, +}) => { + const getFrameCount = useCallback((sec: number) => getFrameCountRaw(detectedFps, sec), [detectedFps]); + const frameCountToDuration = useCallback((frames: number) => getFrameDuration(detectedFps) * frames, [detectedFps]); + + const formatTimecode = useCallback(({ seconds, shorten, fileNameFriendly }) => { + if (timecodeFormat === 'frameCount') { + const frameCount = getFrameCount(seconds); + return frameCount != null ? String(frameCount) : ''; + } + if (timecodeFormat === 'seconds') { + return seconds.toFixed(3); + } + if (timecodeFormat === 'timecodeWithFramesFraction') { + return formatDuration({ seconds, shorten, fileNameFriendly, fps: detectedFps }); + } + return formatDuration({ seconds, shorten, fileNameFriendly }); + }, [detectedFps, timecodeFormat, getFrameCount]); + + const timecodePlaceholder = useMemo(() => formatTimecode({ seconds: 0, shorten: false }), [formatTimecode]); + + const parseTimecode = useCallback((val: string) => { + if (timecodeFormat === 'frameCount') { + const parsed = parseInt(val, 10); + return frameCountToDuration(parsed); + } + if (timecodeFormat === 'seconds') { + return parseFloat(val); + } + if (timecodeFormat === 'timecodeWithFramesFraction') { + return parseDuration(val, detectedFps); + } + return parseDuration(val); + }, [detectedFps, frameCountToDuration, timecodeFormat]); + + const formatTimeAndFrames = useCallback((seconds: number) => { + const frameCount = getFrameCount(seconds); + + const timeStr = timecodeFormat === 'timecodeWithFramesFraction' + ? formatDuration({ seconds, fps: detectedFps }) + : formatDuration({ seconds }); + + return `${timeStr} (${frameCount ?? '0'})`; + }, [detectedFps, timecodeFormat, getFrameCount]); + + const promptTimecode = useCallback(async ({ initialValue, title, description, inputPlaceholder, allowRelative = false }: { + initialValue?: string | undefined, + title: string, + description?: string | undefined, + inputPlaceholder: string, + allowRelative?: boolean, + }) => new Promise<{ duration: number, relDirection: number | undefined } | undefined>((resolve) => { + function TimecodeDialog() { + const { t } = useTranslation(); + const [value, setValue] = useState(initialValue ?? ''); + const [error, setError] = useState(); + + const { onOpenChange } = useGenericDialogContext(); + + const handleSubmit = useCallback>(async (e) => { + e.preventDefault(); + + let relDirection: number | undefined; + if (allowRelative) { + if (value.startsWith('-')) relDirection = -1; + else if (value.startsWith('+')) relDirection = 1; + } + + const withoutPrefix = allowRelative ? value.replace(/^[+-]/, '') : value; + + const duration = parseTimecode(withoutPrefix); + + setError(duration == null ? t('Invalid timecode format') : undefined); + + if (duration != null) { + resolve({ duration, relDirection }); + onOpenChange(false); + } + }, [onOpenChange, t, value]); + + const valueRef = useRef(null); + + useEffect(() => { + valueRef.current?.focus(); + }, []); + + return ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + + {title} + + {description && {description}} + +
+ setValue(e.target.value)} + style={{ margin: '1em 0', width: '100%', boxSizing: 'border-box' }} + /> + + {error != null && ( +
+ {error} +
+ )} + + + + {t('Cancel')} + + + {t('Go')} + + +
+ ); + } + + showGenericDialog({ + isAlert: true, + content: , + onClose: () => resolve(undefined), + }); + }), [parseTimecode, showGenericDialog]); + + return { + parseTimecode, + formatTimecode, + formatTimeAndFrames, + timecodePlaceholder, + getFrameCount, + promptTimecode, + }; +}; diff --git a/src/renderer/src/hooks/useUserSettingsRoot.ts b/src/renderer/src/hooks/useUserSettingsRoot.ts index 2e4d2348..3d032009 100644 --- a/src/renderer/src/hooks/useUserSettingsRoot.ts +++ b/src/renderer/src/hooks/useUserSettingsRoot.ts @@ -13,7 +13,7 @@ const { systemPreferences } = window.require('@electron/remote'); const animationSettings = systemPreferences.getAnimationSettings(); -export default () => { +export default function useUserSettingsRoot() { const firstUpdateRef = useRef(true); function safeSetConfig(keyValue: Record) { @@ -215,143 +215,150 @@ export default () => { const springAnimation = useMemo(() => (prefersReducedMotion ? { duration: 0 } : mySpring), [prefersReducedMotion]); - return { + const settings = { captureFormat, - setCaptureFormat, customOutDir, - setCustomOutDir, keyframeCut, - setKeyframeCut, preserveMetadata, - setPreserveMetadata, preserveMetadataOnMerge, - setPreserveMetadataOnMerge, preserveMovData, - setPreserveMovData, preserveChapters, - setPreserveChapters, movFastStart, - setMovFastStart, avoidNegativeTs, - setAvoidNegativeTs, autoMerge, - setAutoMerge, timecodeFormat, - setTimecodeFormat, invertCutSegments, - setInvertCutSegments, autoExportExtraStreams, - setAutoExportExtraStreams, askBeforeClose, - setAskBeforeClose, enableAskForImportChapters, - setEnableAskForImportChapters, enableAskForFileOpenAction, - setEnableAskForFileOpenAction, playbackVolume, - setPlaybackVolume, autoSaveProjectFile, - setAutoSaveProjectFile, wheelSensitivity, - setWheelSensitivity, waveformHeight, - setWaveformHeight, invertTimelineScroll, - setInvertTimelineScroll, language, - setLanguage, ffmpegExperimental, - setFfmpegExperimental, hideNotifications, - setHideNotifications, hideOsNotifications, - setHideOsNotifications, autoLoadTimecode, - setAutoLoadTimecode, autoDeleteMergedSegments, - setAutoDeleteMergedSegments, exportConfirmEnabled, - setExportConfirmEnabled, segmentsToChapters, - setSegmentsToChapters, simpleMode, - setSimpleMode, outSegTemplate, - setOutSegTemplate, mergedFileTemplate, - setMergedFileTemplate, keyboardSeekAccFactor, - setKeyboardSeekAccFactor, keyboardNormalSeekSpeed, - setKeyboardNormalSeekSpeed, keyboardSeekSpeed2, - setKeyboardSeekSpeed2, keyboardSeekSpeed3, - setKeyboardSeekSpeed3, treatInputFileModifiedTimeAsStart, - setTreatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, - setTreatOutputFileModifiedTimeAsStart, outFormatLocked, - setOutFormatLocked, safeOutputFileName, - setSafeOutputFileName, enableAutoHtml5ify, - setEnableAutoHtml5ify, segmentsToChaptersOnly, - setSegmentsToChaptersOnly, keyBindings, + enableSmartCut, + customFfPath, + storeProjectInWorkingDir, + enableOverwriteOutput, + mouseWheelZoomModifierKey, + mouseWheelFrameSeekModifierKey, + mouseWheelKeyframeSeekModifierKey, + captureFrameMethod, + captureFrameQuality, + captureFrameFileNameFormat, + enableNativeHevc, + enableUpdateCheck, + cleanupChoices, + allowMultipleInstances, + darkMode, + preferStrongColors, + outputFileNameMinZeroPadding, + cutFromAdjustmentFrames, + cutToAdjustmentFrames, + storeWindowBounds, + waveformMode, + thumbnailsEnabled, + keyframesEnabled, + reducedMotion, + }; + + return { + settings, + + setCaptureFormat, + setCustomOutDir, + setKeyframeCut, + setPreserveMetadata, + setPreserveMetadataOnMerge, + setPreserveMovData, + setPreserveChapters, + setMovFastStart, + setAvoidNegativeTs, + setAutoMerge, + setTimecodeFormat, + setInvertCutSegments, + setAutoExportExtraStreams, + setAskBeforeClose, + setEnableAskForImportChapters, + setEnableAskForFileOpenAction, + setPlaybackVolume, + setAutoSaveProjectFile, + setWheelSensitivity, + setWaveformHeight, + setInvertTimelineScroll, + setLanguage, + setFfmpegExperimental, + setHideNotifications, + setHideOsNotifications, + setAutoLoadTimecode, + setAutoDeleteMergedSegments, + setExportConfirmEnabled, + setSegmentsToChapters, + setSimpleMode, + setOutSegTemplate, + setMergedFileTemplate, + setKeyboardSeekAccFactor, + setKeyboardNormalSeekSpeed, + setKeyboardSeekSpeed2, + setKeyboardSeekSpeed3, + setTreatInputFileModifiedTimeAsStart, + setTreatOutputFileModifiedTimeAsStart, + setOutFormatLocked, + setSafeOutputFileName, + setEnableAutoHtml5ify, + setSegmentsToChaptersOnly, setKeyBindings, resetKeyBindings, - enableSmartCut, setEnableSmartCut, - customFfPath, setCustomFfPath, - storeProjectInWorkingDir, setStoreProjectInWorkingDir, - enableOverwriteOutput, setEnableOverwriteOutput, - mouseWheelZoomModifierKey, setMouseWheelZoomModifierKey, - mouseWheelFrameSeekModifierKey, setMouseWheelFrameSeekModifierKey, - mouseWheelKeyframeSeekModifierKey, setMouseWheelKeyframeSeekModifierKey, - captureFrameMethod, setCaptureFrameMethod, - captureFrameQuality, setCaptureFrameQuality, - captureFrameFileNameFormat, setCaptureFrameFileNameFormat, - enableNativeHevc, setEnableNativeHevc, - enableUpdateCheck, setEnableUpdateCheck, - cleanupChoices, setCleanupChoices, - allowMultipleInstances, setAllowMultipleInstances, - darkMode, toggleDarkMode, - preferStrongColors, setPreferStrongColors, - outputFileNameMinZeroPadding, setOutputFileNameMinZeroPadding, - cutFromAdjustmentFrames, setCutFromAdjustmentFrames, - cutToAdjustmentFrames, setCutToAdjustmentFrames, - storeWindowBounds, setStoreWindowBounds, - waveformMode, setWaveformMode, - thumbnailsEnabled, setThumbnailsEnabled, - keyframesEnabled, setKeyframesEnabled, - reducedMotion, prefersReducedMotion, setReducedMotion, springAnimation, }; -}; +} + +export type UserSettingsRoot = ReturnType; diff --git a/src/renderer/src/mifi.ts b/src/renderer/src/mifi.ts index ac39d056..7ae1fcb1 100644 --- a/src/renderer/src/mifi.ts +++ b/src/renderer/src/mifi.ts @@ -2,8 +2,8 @@ import ky from 'ky'; import { runFfmpegStartupCheck, getFfmpegPath } from './ffmpeg'; import Swal from './swal'; -import { handleError } from './util'; import isDev from './isDev'; +import { openSendReportDialog } from './reporting'; export async function loadMifiLink() { @@ -41,6 +41,6 @@ export async function runStartupCheck({ customFfPath }: { customFfPath: string | } } - handleError('Fatal: ffmpeg non-functional', err); + openSendReportDialog({ message: 'FFmpeg is non-functional', err }); } } diff --git a/src/renderer/src/reporting.tsx b/src/renderer/src/reporting.tsx index d83acea1..ee2158e5 100644 --- a/src/renderer/src/reporting.tsx +++ b/src/renderer/src/reporting.tsx @@ -15,7 +15,11 @@ const { platform, arch } = remote.require('./index.js'); // eslint-disable-next-line import/prefer-default-export -export function openSendReportDialog(err: unknown | undefined, state?: unknown) { +export function openSendReportDialog({ err, message, state }: { + err?: unknown | undefined, + message?: string, + state?: unknown, +}) { const reportInstructions = isStoreBuild ? (

Please send an email to electron.shell.openExternal('mailto:losslesscut@mifi.no')}>losslesscut@mifi.no where you describe what you were doing.

@@ -32,7 +36,12 @@ export function openSendReportDialog(err: unknown | undefined, state?: unknown) const version = app.getVersion(); - const text = `${err instanceof Error ? err.stack : 'No error occurred.'}\n\n${JSON.stringify({ + const errorText = (() => { + if (err == null) return 'No error occurred.'; + return err instanceof Error ? err.stack : String(err); + })(); + + const jsonReport = JSON.stringify({ err: isExecaError(err) && { code: err.code, isTerminated: err.isTerminated, @@ -51,7 +60,17 @@ export function openSendReportDialog(err: unknown | undefined, state?: unknown) version, isWindowsStoreBuild, isMasBuild, - }, null, 2)}`; + }, null, 2); + + const lines = [ + ...(message != null ? [message] : []), + errorText, + '', + 'App state:', + jsonReport, + ]; + + const text = lines.join('\n'); ReactSwal.fire({ showCloseButton: true, diff --git a/src/renderer/src/swal.ts b/src/renderer/src/swal.ts index 2f15c154..7750d1e1 100644 --- a/src/renderer/src/swal.ts +++ b/src/renderer/src/swal.ts @@ -46,6 +46,7 @@ export const swalToastOptions: SweetAlertOptions = { self.addEventListener('mouseenter', Swal.stopTimer); self.addEventListener('mouseleave', Swal.resumeTimer); }, + reverseButtons: true, }; export const toast = Swal.mixin(swalToastOptions); diff --git a/src/renderer/src/swal2.scss b/src/renderer/src/swal2.scss index f3fae09c..2ad4b087 100644 --- a/src/renderer/src/swal2.scss +++ b/src/renderer/src/swal2.scss @@ -44,4 +44,11 @@ $swal2-button-focus-box-shadow: 0 0 0 1px $swal2-background, 0 0 0 3px $swal2-ou color: var(--gray-8); } +// Because radix-ui Dialog uses pointer-events: none on the whole app when a dialog is open, but we still want to interact with swal2 popups (which are on top) +// Note that this is still kind of broken (clicks may go through the popup and scroll doesn't work), but better than nothing. +// TODO remove usage of sweetalert2 and use our own dialog component instead. +.swal2-popup { + pointer-events: auto; +} + @import 'sweetalert2/src/sweetalert2.scss'; diff --git a/src/renderer/src/util.ts b/src/renderer/src/util.ts index ec621a19..4be9b416 100644 --- a/src/renderer/src/util.ts +++ b/src/renderer/src/util.ts @@ -8,10 +8,9 @@ import { ExecaError } from 'execa'; import confetti from 'canvas-confetti'; import isDev from './isDev'; -import Swal, { errorToast, toast } from './swal'; +import Swal, { toast } from './swal'; import { ffmpegExtractWindow } from './util/constants'; import { appName } from '../../main/common'; -import { DirectoryAccessDeclinedError, UnsupportedFileError } from '../errors'; import { Html5ifyMode } from '../../../types'; import { prefersReducedMotion } from './animations'; @@ -332,53 +331,11 @@ export const isMuxNotSupported = (err: InvariantExecaError) => ( && /Could not write header .*incorrect codec parameters .*Invalid argument/.test(getStdioString(err.stderr) ?? '') ); -export function handleError(arg1: unknown, arg2?: unknown) { - console.error('handleError', arg1, arg2); - - let err: Error | undefined; - let str: string | undefined; - - if (typeof arg1 === 'string') str = arg1; - else if (typeof arg2 === 'string') str = arg2; - - if (arg1 instanceof Error) err = arg1; - else if (arg2 instanceof Error) err = arg2; - - if (err instanceof UnsupportedFileError) { - errorToast(i18n.t('Unsupported file')); - } else { - Swal.fire({ - icon: 'error', - title: str || i18n.t('An error has occurred.'), - text: err?.message ? err?.message.slice(0, 300) : undefined, - }); - } -} - -/** - * Run an operation with error handling - */ -export async function withErrorHandling(operation: () => Promise, errorMsgOrFn?: string | ((err: unknown) => string)) { - try { - await operation(); - } catch (err) { - if (err instanceof DirectoryAccessDeclinedError || isAbortedError(err)) return; - - if (err instanceof UnsupportedFileError) { - errorToast(i18n.t('Unsupported file')); - return; - } - - let errorMsg: string | undefined; - if (typeof errorMsgOrFn === 'string') errorMsg = errorMsgOrFn; - if (typeof errorMsgOrFn === 'function') errorMsg = errorMsgOrFn(err); - if (errorMsg != null) { - console.error(errorMsg, err); - handleError(errorMsg, err); - } else { - handleError(err); - } - } +export function toastError(err: unknown) { + console.error('toastError', err); + const text = err instanceof Error ? err.message : String(err); + const textTruncated = text.slice(0, 300); + toast.fire({ icon: 'error', title: i18n.t('Error'), text: textTruncated }); } export async function checkAppPath() { diff --git a/yarn.lock b/yarn.lock index 4fe761df..111e58f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1640,6 +1640,30 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-alert-dialog@npm:^1.1.15": + version: 1.1.15 + resolution: "@radix-ui/react-alert-dialog@npm:1.1.15" + dependencies: + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-dialog": "npm:1.1.15" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-slot": "npm:1.2.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/587d906f720a7b16c55f35767a469c3e707e985cebf2f14a64dc4606d2609a337bb9d9c93d86755a7597ba2dc1d41b0f5c2da4c0058f494085e69de0023f0283 + languageName: node + linkType: hard + "@radix-ui/react-arrow@npm:1.1.7": version: 1.1.7 resolution: "@radix-ui/react-arrow@npm:1.1.7" @@ -1733,7 +1757,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-dialog@npm:^1.1.15": +"@radix-ui/react-dialog@npm:1.1.15, @radix-ui/react-dialog@npm:^1.1.15": version: 1.1.15 resolution: "@radix-ui/react-dialog@npm:1.1.15" dependencies: @@ -8211,6 +8235,7 @@ __metadata: "@fontsource/open-sans": "npm:^4.5.14" "@octokit/core": "npm:5" "@radix-ui/colors": "npm:^3.0.0" + "@radix-ui/react-alert-dialog": "npm:^1.1.15" "@radix-ui/react-checkbox": "npm:^1.2.3" "@radix-ui/react-dialog": "npm:^1.1.15" "@radix-ui/react-dropdown-menu": "npm:^2.1.16"