refactor and improve

- reverse dialog buttons (like on macos)
- include more config in error report
- improve error report
- improve non-fatal error handling (use toasts)
- replace more swal with dialogs
- move more logic from App into hooks
- closeActiveScreen will now only close export sheets (rest handled by radix dialogs esc key)
- improve dialogs
pull/2599/head
Mikael Finstad 9 months ago
parent 251031dbc5
commit 251101fc2b
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -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",

@ -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<FfmpegCommandLog>([]);
const [previewFilePath, setPreviewFilePath] = useState<string>();
const [usingDummyVideo, setUsingDummyVideo] = useState(false);
const [rotation, setRotation] = useState(360);
const [progress, setProgress] = useState<number>();
const [startTimeOffset, setStartTimeOffset] = useState(0);
@ -135,7 +136,7 @@ function App() {
const [detectedFps, setDetectedFps] = useState<number>();
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<Set<number>>(new Set());
const [activeSubtitleStreamIndex, setActiveSubtitleStreamIndex] = useState<number>();
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<string>();
const [showRightBar, setShowRightBar] = useState(true);
const [rememberConvertToSupportedFormat, setRememberConvertToSupportedFormat] = useState<Html5ifyMode>();
const [lastCommandsVisible, setLastCommandsVisible] = useState(false);
const [settingsVisible, setSettingsVisible] = useState(false);
const [tunerVisible, setTunerVisible] = useState<TunerType>();
@ -173,17 +173,19 @@ function App() {
const [selectedBatchFiles, setSelectedBatchFiles] = useState<string[]>([]);
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<ReactEventHandler<HTMLVideoElement>>((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<UserSettingsContextType>(() => {
const { settings, ...rest } = allUserSettings;
const userSettingsContext = useMemo<UserSettingsContextType>(() => ({
...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<KeyboardAction, 'closeActiveScreen' | 'toggleKeyboardShortcuts' | 'goToTimecodeDirect'>;
@ -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<DragEventHandler<HTMLDivElement>>((ev) => {
const handleBatchFilesDrop = useCallback<DragEventHandler<HTMLDivElement>>(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<DragEventHandler<HTMLDivElement>>((ev) => {
const handleStreamSourceFileDrop = useCallback<DragEventHandler<HTMLDivElement>>(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 */}
<ExportConfirm areWeCutting={areWeCutting} segmentsOrInverse={segmentsOrInverse} segmentsToExport={segmentsToExport} willMerge={willMerge} visible={exportConfirmVisible} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} renderOutFmt={renderOutFmt} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} onShowStreamsSelectorClick={handleShowStreamsSelectorClick} outFormat={fileFormat} setOutSegTemplate={setOutSegTemplate} outSegTemplate={outSegTemplateOrDefault} mergedFileTemplate={mergedFileTemplateOrDefault} setMergedFileTemplate={setMergedFileTemplate} generateOutSegFileNames={generateOutSegFileNames} generateMergedFileNames={generateMergedFileNames} currentSegIndexSafe={currentSegIndexSafe} mainCopiedThumbnailStreams={mainCopiedThumbnailStreams} needSmartCut={needSmartCut} isEncoding={isEncoding} encBitrate={encBitrate} setEncBitrate={setEncBitrate} toggleSettings={toggleSettings} outputPlaybackRate={outputPlaybackRate} lossyMode={lossyMode} />
<ExportConfirm areWeCutting={areWeCutting} segmentsOrInverse={segmentsOrInverse} segmentsToExport={segmentsToExport} willMerge={willMerge} visible={exportConfirmOpen} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} renderOutFmt={renderOutFmt} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} onShowStreamsSelectorClick={handleShowStreamsSelectorClick} outFormat={fileFormat} setOutSegTemplate={setOutSegTemplate} outSegTemplate={outSegTemplateOrDefault} mergedFileTemplate={mergedFileTemplateOrDefault} setMergedFileTemplate={setMergedFileTemplate} generateOutSegFileNames={generateOutSegFileNames} generateMergedFileNames={generateMergedFileNames} currentSegIndexSafe={currentSegIndexSafe} mainCopiedThumbnailStreams={mainCopiedThumbnailStreams} needSmartCut={needSmartCut} isEncoding={isEncoding} encBitrate={encBitrate} setEncBitrate={setEncBitrate} toggleSettings={toggleSettings} outputPlaybackRate={outputPlaybackRate} lossyMode={lossyMode} />
<Dialog.Root open={streamsSelectorShown} onOpenChange={setStreamsSelectorShown}>
<Dialog.Portal>
@ -2816,12 +2708,7 @@ function App() {
</Dialog.Portal>
</Dialog.Root>
<LastCommandsSheet
visible={lastCommandsVisible}
onTogglePress={toggleLastCommands}
ffmpegCommandLog={ffmpegCommandLog}
setFfmpegCommandLog={setFfmpegCommandLog}
/>
<LastCommands visible={lastCommandsVisible} onTogglePress={toggleLastCommands} ffmpegCommandLog={ffmpegCommandLog} setFfmpegCommandLog={setFfmpegCommandLog} />
<Dialog.Root open={settingsVisible} onOpenChange={toggleSettings}>
<Dialog.Portal>
@ -2843,7 +2730,7 @@ function App() {
</Dialog.Portal>
</Dialog.Root>
<ConcatDialog isShown={batchFiles.length > 0 && concatDialogVisible} onHide={() => setConcatDialogVisible(false)} paths={batchFilePaths} onConcat={userConcatFiles} setAlwaysConcatMultipleFiles={setAlwaysConcatMultipleFiles} alwaysConcatMultipleFiles={alwaysConcatMultipleFiles} exportCount={exportCount} maxLabelLength={maxLabelLength} />
<ConcatSheet isShown={batchFiles.length > 0 && concatSheetOpen} onHide={() => setConcatSheetOpen(false)} paths={batchFilePaths} onConcat={userConcatFiles} setAlwaysConcatMultipleFiles={setAlwaysConcatMultipleFiles} alwaysConcatMultipleFiles={alwaysConcatMultipleFiles} exportCount={exportCount} maxLabelLength={maxLabelLength} />
<KeyboardShortcuts isShown={keyboardShortcutsVisible} onHide={() => setKeyboardShortcutsVisible(false)} keyBindings={keyBindings} setKeyBindings={setKeyBindings} currentCutSeg={currentCutSeg} resetKeyBindings={resetKeyBindings} />
@ -2851,6 +2738,10 @@ function App() {
<AnimatePresence>
{working && <Working text={working.text} progress={progress} onAbortClick={abortWorking} />}
</AnimatePresence>
<GenericDialog dialog={genericDialog} onOpenChange={(open) => !open && closeGenericDialog()} />
<ErrorDialog error={genericError} onOpenChange={(open) => !open && setGenericError(undefined)} />
</div>
<SwalContainer darkMode={darkMode} style={baseColorStyle} />

@ -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 (
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<h1><Trans>Something went wrong</Trans></h1>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{error.message}</div>
<p><button type="button" onClick={() => openSendReportDialog(error)} style={{ padding: 10, fontSize: 20 }}><Trans>Report error</Trans></button></p>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{error instanceof Error ? error.message : String(error)}</div>
<p><button type="button" onClick={() => openSendReportDialog({ err: error })} style={{ padding: 10, fontSize: 20 }}><Trans>Report error</Trans></button></p>
</div>
);
}

@ -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);

@ -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({
<TagEditor customTags={editingSegmentTags} editingTag={editingTag} setEditingTag={setEditingTag} onTagsChange={onTagsChange} onTagReset={onTagReset} addTagTitle={t('Add segment tag')} />
<Dialog.ConfirmButton onClick={onSegmentTagsConfirm} disabled={editingTag != null}>
<FaSave style={{ verticalAlign: 'baseline', fontSize: '.8em', marginRight: '.3em' }} />
{t('Save')}
</Dialog.ConfirmButton>
<Dialog.ButtonRow>
<DialogButton onClick={onSegmentTagsConfirm} disabled={editingTag != null} primary>
<FaSave style={{ verticalAlign: 'baseline', fontSize: '.8em', marginRight: '.3em' }} />
{t('Save')}
</DialogButton>
</Dialog.ButtonRow>
<Dialog.CloseButton />
</Dialog.Content>

@ -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
<h2>Tags</h2>
<TagEditor existingTags={existingTags} customTags={customTags} editingTag={editingTag} setEditingTag={setEditingTag} onTagsChange={onTagsChange} onTagReset={onTagReset} addTagTitle={t('Add metadata')} />
<Dialog.ButtonRow>
<Dialog.Close asChild>
<DialogButton primary>{t('Done')}</DialogButton>
</Dialog.Close>
</Dialog.ButtonRow>
<Dialog.CloseButton />
</Dialog.Content>
</Dialog.Portal>
@ -538,6 +544,12 @@ function StreamsSelector({
<EditFileDialog editingFile={editingFile} editingTag={editingTag} setEditingTag={setEditingTag} allFilesMeta={allFilesMeta} customTagsByFile={customTagsByFile} setCustomTagsByFile={setCustomTagsByFile} />
<Dialog.ButtonRow>
<Dialog.Close asChild>
<DialogButton primary>{t('Done')}</DialogButton>
</Dialog.Close>
</Dialog.ButtonRow>
<Dialog.CloseButton />
</Dialog.Content>
</Dialog.Portal>

@ -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);
}
}

@ -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) => <AlertDialog.Portal container={document.getElementById('app-root')!} {...props} />;
export function CancelButton() {
const { t } = useTranslation();
return (
<AlertDialog.Cancel asChild>
<DialogButton>{t('Cancel')}</DialogButton>
</AlertDialog.Cancel>
);
}
export function OkButton() {
const { t } = useTranslation();
return (
<AlertDialog.Action asChild>
<DialogButton primary>{t('OK')}</DialogButton>
</AlertDialog.Action>
);
}

@ -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<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>;
@ -11,3 +12,9 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({ type = 'button', cl
));
export default Button;
// eslint-disable-next-line react/display-name
export const DialogButton = forwardRef<HTMLButtonElement, { primary?: boolean } & ButtonProps>(({ primary, ...props }, ref) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<Button ref={ref} style={{ padding: '.5em 2em', ...(primary && { color: 'white', backgroundColor: primaryColor, borderColor: primaryTextColor }) }} {...props} />
));

@ -4,6 +4,7 @@ import { DetailedHTMLProps, ButtonHTMLAttributes } from 'react';
import styles from './CloseButton.module.css';
import i18n from '../i18n';
export default function CloseButton({ type = 'button', ...props }: DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>) {
return (
// eslint-disable-next-line react/jsx-props-no-spreading, react/button-has-type

@ -6,7 +6,6 @@ import i18n from 'i18next';
import invariant from 'tiny-invariant';
import Checkbox from './Checkbox';
import { ReactSwal } from '../swal';
import { readFileMeta, getDefaultOutFormat, mapRecommendedDefaultFormat } from '../ffmpeg';
import useFileFormatState from '../hooks/useFileFormatState';
import OutputFormatSelect from './OutputFormatSelect';
@ -15,10 +14,10 @@ import { isMov } from '../util/streams';
import { getOutDir, getOutFileExtension } from '../util';
import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../../ffprobe';
import TextInput from './TextInput';
import Button from './Button';
import Button, { DialogButton } from './Button';
import { defaultMergedFileTemplate, generateMergedFileNames, maxFileNameLength } from '../util/outputNameTemplate';
import { primaryColor } from '../colors';
import ExportDialog from './ExportDialog';
import ExportSheet from './ExportSheet';
import * as Dialog from './Dialog';
const { basename } = window.require('path');
@ -34,7 +33,7 @@ function Alert({ text }: { text: string }) {
);
}
function ConcatDialog({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFiles, setAlwaysConcatMultipleFiles, exportCount, maxLabelLength }: {
function ConcatSheet({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFiles, setAlwaysConcatMultipleFiles, exportCount, maxLabelLength }: {
isShown: boolean,
onHide: () => void,
paths: string[],
@ -156,17 +155,6 @@ function ConcatDialog({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFi
return errors;
}, [allFilesMeta]);
const onProblemsByFileClick = useCallback((path: string) => {
ReactSwal.fire({
title: i18n.t('Mismatches detected'),
html: (
<ul style={{ margin: '10px 0', textAlign: 'left' }}>
{(problemsByFile[path] || []).map((problem) => <li key={problem}>{problem}</li>)}
</ul>
),
});
}, [problemsByFile]);
useEffect(() => {
if (!isShown || !enableReadFileMeta) return undefined;
@ -198,7 +186,7 @@ function ConcatDialog({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFi
}, [clearBatchFilesAfterConcat, fileFormat, fileMeta, includeAllStreams, onConcat, outFileName, paths]);
return (
<ExportDialog
<ExportSheet
visible={isShown}
title={t('Merge/concatenate files')}
onClosePress={onHide}
@ -221,7 +209,26 @@ function ConcatDialog({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFi
<span style={{ opacity: 0.7, marginRight: '.4em' }}>{`${index + 1}.`}</span>
<span>{basename(path)}</span>
{!allFilesMetaCache[path] && <FaQuestionCircle style={{ color: 'var(--orange-8)', verticalAlign: 'middle', marginLeft: '1em' }} />}
{problemsByFile[path] && <Button onClick={() => onProblemsByFileClick(path)} title={i18n.t('Mismatches detected')} style={{ color: 'var(--orange-8)', marginLeft: '1em' }}><FaExclamationTriangle /></Button>}
{problemsByFile[path] && (
<Dialog.Root>
<Dialog.Trigger asChild>
<Button title={i18n.t('Mismatches detected')} style={{ color: 'var(--orange-8)', marginLeft: '1em' }}><FaExclamationTriangle /></Button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content aria-describedby={undefined}>
<Dialog.Title>{t('Mismatches detected')}</Dialog.Title>
<ul style={{ margin: '10px 0', textAlign: 'left' }}>
{(problemsByFile[path] || []).map((problem) => <li key={problem}>{problem}</li>)}
</ul>
<Dialog.CloseButton />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)}
</div>
</div>
))}
@ -273,12 +280,18 @@ function ConcatDialog({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFi
<p>{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.')}</p>
<Dialog.ButtonRow>
<Dialog.Close asChild>
<DialogButton primary>{t('Done')}</DialogButton>
</Dialog.Close>
</Dialog.ButtonRow>
<Dialog.CloseButton />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</ExportDialog>
</ExportSheet>
);
}
export default memo(ConcatDialog);
export default memo(ConcatSheet);

@ -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);
}
}
}

@ -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) => <Dialog.Portal container={document.getElementById('app-root')!} {...props} />;
export const ConfirmButton = ({ style, ...props }: ButtonProps) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<Button style={{ fontSize: '1.2em', ...style }} {...props} />
);
export const CloseButton = () => (
<Dialog.Close asChild>
<CloseButtonRaw style={{ top: 0, right: 0 }} />
</Dialog.Close>
);
export function ButtonRow({ children }: { children: ReactNode }) {
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '.5em', justifyContent: 'flex-end', marginTop: '1em' }}>
{children}
</div>
);
}

@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next';
import * as Dialog from './Dialog';
import { DialogButton } from './Button';
export interface GenericError {
err?: unknown | undefined;
title?: string | undefined;
}
/**
* We need to be able to show errors from anywhere in the app, also while dialogs are open. Also originating from keyboard actions
*/
export default function ErrorDialog({ error, onOpenChange }: {
error: GenericError | undefined,
onOpenChange: (open: boolean) => void,
}) {
const { t } = useTranslation();
return (
<Dialog.Root open={error != null} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content aria-describedby={t('An error has occurred.')} style={{ width: '40em' }}>
{error != null && (
<>
<Dialog.Title>
{error.title ?? t('Error')}
</Dialog.Title>
<div style={{ overflow: 'auto', maxHeight: '50vh', whiteSpace: 'pre-wrap' }}>
{error.err instanceof Error ? error.err.message : String(error.err)}
</div>
</>
)}
<Dialog.ButtonRow>
<DialogButton primary>{t('OK')}</DialogButton>
</Dialog.ButtonRow>
<Dialog.CloseButton />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

@ -24,7 +24,7 @@ import { FFprobeStream } from '../../../../ffprobe';
import { AvoidNegativeTs, PreserveMetadata } from '../../../../types';
import TextInput from './TextInput';
import { UseSegments } from '../hooks/useSegments';
import ExportDialog from './ExportDialog';
import ExportSheet from './ExportSheet';
import ToggleExportConfirm from './ToggleExportConfirm';
import { LossyMode } from '../../../main';
@ -284,7 +284,7 @@ function ExportConfirm({
}, [setEncBitrate]);
return (
<ExportDialog
<ExportSheet
width="50em"
visible={visible}
title={t('Export options')}
@ -642,7 +642,7 @@ function ExportConfirm({
</tr>
</tbody>
</table>
</ExportDialog>
</ExportSheet>
);
}

@ -1,10 +1,11 @@
import { CSSProperties, ReactNode } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import styles from './ExportDialog.module.css';
import styles from './ExportSheet.module.css';
import CloseButton from './CloseButton';
function ExportDialog({
// TODO use Dialog component instead, but we need to first remove usage of sweetalert2 inside export confirm and concat dialog because they don't play well together
function ExportSheet({
visible,
children,
renderBottom,
@ -71,4 +72,4 @@ function ExportDialog({
);
}
export default ExportDialog;
export default ExportSheet;

@ -0,0 +1,97 @@
import { useTranslation } from 'react-i18next';
import { FormEventHandler, forwardRef, ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import * as AlertDialog from './AlertDialog';
import { DialogButton } from './Button';
import TextInput from './TextInput';
import { useGenericDialogContext } from './GenericDialog';
import { ButtonRow } from './Dialog';
interface Props {
onSubmit: (value: string) => Promise<{ error: string } | undefined>,
examples: { name: string, code: string }[],
title: string,
description: ReactNode,
variables?: string[],
inputValue?: string | undefined,
confirmButtonText?: string,
}
// eslint-disable-next-line react/display-name
const ExpressionDialog = forwardRef<HTMLDivElement, Props>(({ onSubmit, examples, title, description, variables, inputValue, confirmButtonText }, ref) => {
const { t } = useTranslation();
const [value, setValue] = useState(inputValue ?? '');
const [error, setError] = useState<string | undefined>();
const { onOpenChange } = useGenericDialogContext();
const handleSubmit = useCallback<FormEventHandler<HTMLFormElement>>(async (e) => {
e.preventDefault();
const resp = await onSubmit(value);
setError(resp?.error);
if (resp == null) {
// success
onOpenChange(false);
}
}, [onOpenChange, onSubmit, value]);
const valueRef = useRef<HTMLInputElement>(null);
useEffect(() => {
valueRef.current?.focus();
}, []);
const onExampleClick = useCallback((code: string) => {
setValue(code);
valueRef.current?.focus();
}, []);
return (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<AlertDialog.Content ref={ref as any} aria-describedby={undefined} style={{ width: '80vw' }}>
<AlertDialog.Title>{title}</AlertDialog.Title>
{description && <AlertDialog.Description>{description}</AlertDialog.Description>}
{variables && (
<div style={{ marginBottom: '1em' }}>{t('Variables')}: {variables.map((v) => <code style={{ display: 'inline-block', marginRight: '.3em' }} key={v} className="highlighted">{v}</code>)}</div>
)}
<div><b>{t('Examples')}:</b></div>
{examples.map(({ name, code }) => (
<button key={code} type="button" onClick={() => onExampleClick(code)} className="link-button" style={{ display: 'block', marginBottom: '.1em' }}>
{name}
</button>
))}
<form onSubmit={handleSubmit}>
<TextInput
ref={valueRef}
placeholder={t('Enter JavaScript expression')}
value={value}
onChange={(e) => setValue(e.target.value)}
style={{ margin: '1em 0', width: '100%', boxSizing: 'border-box' }}
/>
{error != null && (
<div style={{ color: 'var(--red-9)', fontWeight: 'bold' }}>
{error}
</div>
)}
<ButtonRow>
<AlertDialog.Cancel asChild>
<DialogButton>{t('Cancel')}</DialogButton>
</AlertDialog.Cancel>
<DialogButton type="submit" primary>{confirmButtonText ?? t('Confirm')}</DialogButton>
</ButtonRow>
</form>
</AlertDialog.Content>
);
});
export default ExpressionDialog;

@ -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: (
<div style={{ textAlign: 'left', overflowY: 'auto', maxHeight: 400 }}>
{fileNames.map((f) => <div key={f} style={{ marginBottom: 7 }}>{f}</div>)}
</div>
),
});
}, [fileNames, t]);
useEffect(() => {
if (validText != null) setTemplate(validText);
}, [validText, setTemplate]);
@ -180,7 +168,24 @@ function FileNameTemplateEditor(opts: {
<TextInput ref={inputRef} onChange={onTextChange} value={text} autoComplete="off" autoCapitalize="off" autoCorrect="off" />
{!mergeMode && fileNames != null && (
<Button onClick={onAllFilesPreviewPress} style={{ marginLeft: '.3em' }}>{t('Preview')}</Button>
<Dialog.Root>
<Dialog.Trigger asChild>
<Button style={{ marginLeft: '.3em' }}>{t('Preview')}</Button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content aria-describedby={undefined}>
<Dialog.Title>{t('Resulting segment file names', { count: fileNames.length })}</Dialog.Title>
<div style={{ overflowY: 'auto', maxHeight: 400 }}>
{fileNames.map((f) => <div key={f} style={{ marginBottom: '.5em' }}><FaFile style={{ verticalAlign: 'middle', marginRight: '.5em' }} />{f}</div>)}
</div>
<Dialog.CloseButton />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)}
<Button title={t('Reset')} onClick={reset} style={{ marginLeft: '.3em' }}><FaUndo style={{ fontSize: '.8em', color: 'var(--red-11)' }} /></Button>

@ -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<GenericDialogContextValue | undefined>(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 (
<AlertDialog.Root open={dialog != null} onOpenChange={onOpenChange}>
<AlertDialog.Portal>
<AlertDialog.Overlay />
<GenericDialogContext.Provider value={context}>
{dialog.content}
</GenericDialogContext.Provider>
</AlertDialog.Portal>
</AlertDialog.Root>
);
}
return (
<Dialog.Root open={dialog != null} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay />
{dialog?.content}
</Dialog.Portal>
</Dialog.Root>
);
}
export function useDialog() {
const { t } = useTranslation();
const [genericDialog, setGenericDialog] = useState<GenericDialogParams | undefined>();
const genericDialogRef = useRef(genericDialog);
const showGenericDialog = useCallback<ShowGenericDialog>((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<boolean>((resolve) => {
function ConfirmDialog() {
const { onOpenChange } = useGenericDialogContext();
const handleConfirmClick = useCallback<MouseEventHandler<HTMLButtonElement>>((e) => {
e.preventDefault();
resolve(true);
onOpenChange(false);
}, [onOpenChange]);
return (
<AlertDialog.Content aria-describedby={description} style={{ width: '40vw' }}>
<AlertDialog.Title>{title}</AlertDialog.Title>
{description && <AlertDialog.Description>{description}</AlertDialog.Description>}
<Dialog.ButtonRow>
<AlertDialog.Cancel asChild>
<DialogButton>{cancelButtonText}</DialogButton>
</AlertDialog.Cancel>
<DialogButton primary onClick={handleConfirmClick}>{confirmButtonText}</DialogButton>
</Dialog.ButtonRow>
</AlertDialog.Content>
);
}
showGenericDialog({
isAlert: true,
content: <ConfirmDialog />,
onClose: () => resolve(false),
});
}), [showGenericDialog, t]);
return {
genericDialog,
closeGenericDialog,
showGenericDialog,
confirmDialog,
};
}

@ -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<Awaited<ReturnType<typeof getGpsTrack>>>();
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];

@ -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(({
)}
</div>
<Dialog.ConfirmButton disabled={fixedKeys.length === 0 || isComboInvalid} onClick={() => action != null && onNewKeyBindingConfirmed(action, keysDown)}><FaSave style={{ marginRight: '.3em', verticalAlign: 'middle' }} />{t('Save')}</Dialog.ConfirmButton>
<Dialog.ButtonRow>
<DialogButton disabled={fixedKeys.length === 0 || isComboInvalid} onClick={() => action != null && onNewKeyBindingConfirmed(action, keysDown)} primary>
<FaSave style={{ marginRight: '.3em', verticalAlign: 'middle' }} />{t('Save')}
</DialogButton>
</Dialog.ButtonRow>
<Dialog.CloseButton />
</Dialog.Content>

@ -3,7 +3,6 @@ import { swalContainerWrapperId } from '../swal';
export default function SwalContainer({ darkMode, ...props }: HTMLAttributes<HTMLDivElement> & { darkMode: boolean }) {
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
id={swalContainerWrapperId}
className={darkMode ? 'dark-theme' : undefined}

@ -144,11 +144,11 @@ function TagEditor({ existingTags = emptyObject, customTags = emptyObject, editi
<span style={{ padding: '.5em 0', color: thisTagCustom ? activeColor : 'var(--gray-11)', fontWeight: thisTagCustom ? 'bold' : undefined }}>{mergedTags[tag] ? String(mergedTags[tag]) : `<${t('empty')}>`}</span>
)}
{(editingTag == null || editingThis) && (
<Button title={t('Edit')} style={{ marginLeft: '.4em' }} onClick={() => onEditClick(tag)}><Icon style={{ fontSize: '.9em', padding: '.7em', verticalAlign: 'middle' }} /></Button>
<Button title={t('Edit')} style={{ marginLeft: '.4em' }} onClick={() => onEditClick(tag)}><Icon style={{ fontSize: '.9em', padding: '.5em', verticalAlign: 'middle' }} /></Button>
)}
{editingThis && (
<Button title={thisTagNew ? t('Delete') : t('Reset')} onClick={onResetClick}>
{thisTagNew ? <FaTrash style={{ fontSize: '.9em', padding: '.7em', verticalAlign: 'middle' }} /> : <FaUndo style={{ fontSize: '.9em', padding: '.7em', verticalAlign: 'middle' }} />}
{thisTagNew ? <FaTrash style={{ fontSize: '.9em', padding: '.5em', verticalAlign: 'middle' }} /> : <FaUndo style={{ fontSize: '.9em', padding: '.5em', verticalAlign: 'middle' }} />}
</Button>
)}
</td>
@ -160,7 +160,7 @@ function TagEditor({ existingTags = emptyObject, customTags = emptyObject, editi
<form onSubmit={onAddSubmit} style={{ opacity: canAdd ? undefined : 0.5, marginBottom: '1em' }}>
<TextInput ref={ref} disabled={!canAdd} value={newTagKeyInput} onChange={(e) => setNewTagKeyInput(e.target.value)} placeholder={addTagTitle} style={{ padding: '.4em', marginRight: '1em', verticalAlign: 'middle' }} />
<Button type="button" disabled={!canAdd} title={addTagTitle} onClick={add}><FaPlus style={{ padding: '.6em', verticalAlign: 'middle' }} /></Button>
<Button type="submit" disabled={!canAdd} title={addTagTitle} onClick={add}><FaPlus style={{ padding: '.6em', verticalAlign: 'middle' }} /></Button>
</form>
{newTagKeyInputError && <Warning>{t('Invalid character(s) found in key')}</Warning>}

@ -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<typeof useUserSettingsRoot> & {
export type UserSettingsContextType = Omit<UserSettingsRoot, 'settings'> & UserSettingsRoot['settings'] & {
toggleCaptureFormat: () => void,
changeOutDir: () => Promise<void>,
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<typeof useLoading>['setWorking'],
working: ReturnType<typeof useLoading>['working'],
handleError: HandleError,
showGenericDialog: ShowGenericDialog,
}
@ -30,6 +36,12 @@ export const UserSettingsContext = React.createContext<UserSettingsContextType |
export const SegColorsContext = React.createContext<SegColorsContextType | undefined>(undefined);
export const AppContext = React.createContext<AppContextType | undefined>(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');

@ -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<Html5ifyMode, string> = {
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<Record<Html5ifyMode, string>> = {};
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<ChangeEventHandler<HTMLInputElement>>((e) => {
selectedOption = e.currentTarget.value as Html5ifyMode;
setOption(selectedOption);
}, []);
const onRememberChange = useCallback((checked: boolean) => {
rememberChoice = checked;
setRemember(rememberChoice);
}, []);
return (
<div style={{ textAlign: 'left' }}>
<p>{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')}</p>
{Object.entries(inputOptions).map(([value, label]) => {
const id = `html5ify-${value}`;
return (
<div key={value}>
<input
id={id}
type="radio"
name="html5ify-speed"
value={value}
checked={option === value}
onChange={onOptionChange}
/>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label htmlFor={id} style={{ marginLeft: '.5em' }}>{label}</label>
</div>
);
})}
{showRemember && <Checkbox checked={remember} onCheckedChange={onRememberChange} label={i18n.t('Use this for all files until LosslessCut is restarted?')} style={{ marginTop: '.5em' }} />}
</div>
);
}
const { value: response } = await ReactSwal.fire({
title: i18n.t('Convert to supported format'),
html: <AskForHtml5ifySpeed />,
showCancelButton: true,
});
return {
selectedOption: response != null ? selectedOption : undefined,
remember: rememberChoice,
};
}

@ -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<string>({
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<string>({
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<string | undefined>,
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<string>({
showCancelButton: true,
title,
input: 'text',
width: '90vw',
html: (
<div style={{ textAlign: 'left' }}>
<div style={{ marginBottom: '1em' }}>
{description}
</div>
{variables && <div style={{ marginBottom: '1em' }}>{i18n.t('Variables')}: <span style={{ display: 'inline-flex', gap: '.5em' }}>{variables.map((v) => <code key={v} className="highlighted">{v}</code>)}</span></div>}
<div><b>{i18n.t('Examples')}:</b></div>
{examples.map(({ name, code }) => (
<button key={code} type="button" onClick={() => addExample(code)} className="link-button" style={{ display: 'block', marginBottom: '.1em' }}>
{name}
</button>
))}
</div>
),
inputValue,
inputPlaceholder: i18n.t('Enter JavaScript expression'),
inputValidator,
});
return value;
}
export async function selectSegmentsByExprDialog(inputValidator: (v: string) => Promise<string | undefined>) {
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: <Trans>Enter a JavaScript expression which will be evaluated for each segment. Segments for which the expression evaluates to &quot;true&quot; will be selected. <button type="button" className="link-button" onClick={() => shell.openExternal('https://github.com/mifi/lossless-cut/blob/master/expressions.md')}>View available syntax.</button></Trans>,
variables: ['segment.index', 'segment.label', 'segment.start', 'segment.end', 'segment.duration', 'segment.tags.*'],
});
}
export async function filterEnabledStreamsDialog({ validator, value }: {
validator: (v: string) => Promise<string | undefined>,
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: <Trans>Enter a JavaScript filter expression which will be evaluated for each track of the current file. Tracks for which the expression evaluates to &quot;true&quot; will be selected or deselected. You may also the <Action name="toggleStripCurrentFilter" /> keyboard action to run this filter.</Trans>,
inputValue: value ?? '',
});
}
export async function mutateSegmentsByExprDialog(inputValidator: (v: string) => Promise<string | undefined>) {
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: <Trans>Enter a JavaScript expression which will be evaluated for each selected segment. Returned properties will be edited. <button type="button" className="link-button" onClick={() => shell.openExternal('https://github.com/mifi/lossless-cut/blob/master/expressions.md')}>View available syntax.</button></Trans>,
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;

@ -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<string, string>;
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<HTMLInputElement>(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<HTMLFormElement>) => {
e.preventDefault();
onSubmit();
}, [onSubmit]);
useEffect(() => {
firstInputRef.current?.focus();
}, []);
return (
<div style={{ textAlign: 'left', padding: '.5em', borderRadius: '.3em' }}>
{description && <p>{description}</p>}
{docUrl && <p><Button onClick={() => shell.openExternal(docUrl)}><FaLink style={{ fontSize: '.8em' }} /> Read more</Button></p>}
<form onSubmit={handleSubmit}>
{Object.entries(parametersIn).map(([key, parameter], i) => {
const id = `parameter-${key}`;
return (
<div key={key} style={{ marginBottom: '.5em' }}>
<label htmlFor={id} style={{ display: 'block', fontFamily: 'monospace', marginBottom: '.3em' }}>{getLabel(dialogType, parameter) || key}</label>
<TextInput
id={id}
ref={i === 0 ? firstInputRef : undefined}
value={getParameter(key)}
onChange={(e) => handleChange(key, e.target.value)}
style={{ marginBottom: '.2em' }}
/>
{getHint(dialogType, key) && <div style={{ opacity: 0.6, fontSize: '0.8em' }}>{getHint(dialogType, key)}</div>}
</div>
);
})}
<input type="submit" value="submit" style={{ display: 'none' }} />
</form>
</div>
);
};
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<boolean>((resolve) => {
resolve1 = resolve;
});
const handleSubmit = () => {
Swal.close();
resolve1(true);
};
const promise2 = (async () => {
const { isConfirmed } = await ReactSwal.fire({
title,
html: (
<ParametersInput
description={description}
dialogType={dialogType}
parameters={parameters}
onChange={(newParameters) => { 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;
}

@ -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<GenericError | undefined>();
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<void>, 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<typeof useErrorHandling>['withErrorHandling'];

@ -1001,3 +1001,5 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
}
export default useFfmpegOperations;
export type FfmpegOperations = ReturnType<typeof useFfmpegOperations>;

@ -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<boolean>,
setWorking: SetWorking,
ensureWritableOutDir: (options: { inputPath: string, outDir: string | undefined }) => Promise<string | undefined>,
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<string>();
const [usingDummyVideo, setUsingDummyVideo] = useState(false);
const [rememberConvertToSupportedFormat, setRememberConvertToSupportedFormat] = useState<Html5ifyMode>();
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<Html5ifyMode, string> = {
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<Record<Html5ifyMode, string>> = {};
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<ChangeEventHandler<HTMLInputElement>>((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 (
<AlertDialog.Content aria-describedby={undefined} style={{ width: '80vw' }}>
<AlertDialog.Title>
{i18n.t('Convert to supported format')}
</AlertDialog.Title>
<AlertDialog.Description>
{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')}
</AlertDialog.Description>
{Object.entries(inputOptions).map(([value, label]) => {
const id = `html5ify-${value}`;
return (
<div key={value}>
<input
id={id}
type="radio"
name="html5ify-speed"
value={value}
checked={option === value}
onChange={onOptionChange}
/>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label htmlFor={id} style={{ marginLeft: '.5em' }}>{label}</label>
</div>
);
})}
{showRemember && <Checkbox checked={remember} onCheckedChange={onRememberChange} label={t('Use this for all files until LosslessCut is restarted?')} style={{ marginTop: '.5em' }} />}
<ButtonRow>
<AlertDialog.Cancel asChild>
<DialogButton>{t('Cancel')}</DialogButton>
</AlertDialog.Cancel>
<DialogButton onClick={handleOkClick} primary>{t('OK')}</DialogButton>
</ButtonRow>
</AlertDialog.Content>
);
}
showGenericDialog({
isAlert: true,
content: <AskForHtml5ifySpeed />,
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 };
}

@ -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 {

@ -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<WorkingState | undefined>();
// 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<typeof useLoading>['setWorking'];

@ -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<string, string>;
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<boolean>,
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<number>,
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<StateSegment, 'name' | 'start' | 'end' | 'tags'>, 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<ParameterDialogParameters | undefined>((resolve) => {
function Dialog() {
const { onOpenChange } = useGenericDialogContext();
const firstInputRef = useRef<HTMLInputElement>(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<HTMLFormElement>) => {
e.preventDefault();
resolve(parameters);
onOpenChange(false);
}, [onOpenChange, parameters]);
useEffect(() => {
firstInputRef.current?.focus();
}, []);
return (
<AlertDialog.Content aria-describedby={undefined} style={{ width: '80vw' }}>
<AlertDialog.Title>{title}</AlertDialog.Title>
<AlertDialog.Description>{description}</AlertDialog.Description>
{docUrl && <p><Button onClick={() => shell.openExternal(docUrl)}><FaLink style={{ fontSize: '.8em' }} /> Read more</Button></p>}
<form onSubmit={handleSubmit}>
{Object.entries(parametersIn).map(([key, parameter], i) => {
const id = `parameter-${key}`;
return (
<div key={key} style={{ marginBottom: '.5em' }}>
<label htmlFor={id} style={{ display: 'block', fontFamily: 'monospace', marginBottom: '.3em' }}>{getLabel(dialogType, parameter) || key}</label>
<TextInput
id={id}
ref={i === 0 ? firstInputRef : undefined}
value={getParameter(key)}
onChange={(e) => handleChange(key, e.target.value)}
style={{ marginBottom: '.2em' }}
/>
{getHint(dialogType, key) && <div style={{ opacity: 0.6, fontSize: '0.8em' }}>{getHint(dialogType, key)}</div>}
</div>
);
})}
<ButtonRow>
<AlertDialog.Cancel asChild>
<DialogButton>{t('Cancel')}</DialogButton>
</AlertDialog.Cancel>
<DialogButton type="submit" primary>{t('Confirm')}</DialogButton>
</ButtonRow>
</form>
</AlertDialog.Content>
);
}
showGenericDialog({
isAlert: true,
content: <Dialog />,
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: (
<ExpressionDialog
onSubmit={onSubmit}
confirmButtonText={t('Select segments')}
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={<Trans>Enter a JavaScript expression which will be evaluated for each segment. Segments for which the expression evaluates to &quot;true&quot; will be selected. <button type="button" className="link-button" onClick={() => shell.openExternal('https://github.com/mifi/lossless-cut/blob/master/expressions.md')}>View available syntax.</button></Trans>}
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: (
<ExpressionDialog
onSubmit={onSubmit}
confirmButtonText={t('Apply change')}
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={<Trans>Enter a JavaScript expression which will be evaluated for each selected segment. Returned properties will be edited. <button type="button" className="link-button" onClick={() => shell.openExternal('https://github.com/mifi/lossless-cut/blob/master/expressions.md')}>View available syntax.</button></Trans>}
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];

@ -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<Record<string, Record<string, boolean>>>({});
// this will be remembered between files:
const [enabledStreamsFilter, setEnabledStreamsFilter] = useState<string>();
@ -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: (
<ExpressionDialog
confirmButtonText={t('Apply filter')}
onSubmit={async (value: string) => {
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={<Trans>Enter a JavaScript filter expression which will be evaluated for each track of the current file. Tracks for which the expression evaluates to &quot;true&quot; will be selected or deselected. You may also the <Action name="toggleStripCurrentFilter" /> keyboard action to run this filter.</Trans>}
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 };
};
}

@ -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<FormatTimecode>(({ 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<ParseTimecode>((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,
};
};

@ -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<FormatTimecode>(({ 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<ParseTimecode>((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<string | undefined>();
const { onOpenChange } = useGenericDialogContext();
const handleSubmit = useCallback<FormEventHandler<HTMLFormElement>>(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<HTMLInputElement>(null);
useEffect(() => {
valueRef.current?.focus();
}, []);
return (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<AlertDialog.Content aria-describedby={undefined} style={{ width: '80vw' }}>
<AlertDialog.Title>{title}</AlertDialog.Title>
{description && <AlertDialog.Description>{description}</AlertDialog.Description>}
<form onSubmit={handleSubmit}>
<TextInput
ref={valueRef}
value={value}
placeholder={inputPlaceholder}
onChange={(e) => setValue(e.target.value)}
style={{ margin: '1em 0', width: '100%', boxSizing: 'border-box' }}
/>
{error != null && (
<div style={{ color: 'var(--red-9)', fontWeight: 'bold' }}>
{error}
</div>
)}
<ButtonRow>
<AlertDialog.Cancel asChild>
<DialogButton>{t('Cancel')}</DialogButton>
</AlertDialog.Cancel>
<DialogButton type="submit" primary>{t('Go')}</DialogButton>
</ButtonRow>
</form>
</AlertDialog.Content>
);
}
showGenericDialog({
isAlert: true,
content: <TimecodeDialog />,
onClose: () => resolve(undefined),
});
}), [parseTimecode, showGenericDialog]);
return {
parseTimecode,
formatTimecode,
formatTimeAndFrames,
timecodePlaceholder,
getFrameCount,
promptTimecode,
};
};

@ -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<T extends keyof Config>(keyValue: Record<T, Config[T]>) {
@ -215,143 +215,150 @@ export default () => {
const springAnimation = useMemo<Transition>(() => (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<typeof useUserSettingsRoot>;

@ -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 });
}
}

@ -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
? (
<p><Trans>Please send an email to <span className="link-button" role="button" onClick={() => electron.shell.openExternal('mailto:losslesscut@mifi.no')}>losslesscut@mifi.no</span> where you describe what you were doing.</Trans></p>
@ -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,

@ -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);

@ -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';

@ -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<void>, 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() {

@ -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"

Loading…
Cancel
Save