From 2605303ddeb31d39d5f5d1b4272daf45ebb6ee43 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sat, 30 May 2026 20:02:03 +0800 Subject: [PATCH] improve shift segments dialog closes #2839 --- src/renderer/src/App.tsx | 21 ++++- src/renderer/src/components/GenericDialog.tsx | 81 ++++++++++++++++++- src/renderer/src/dialogs/index.tsx | 44 ---------- src/renderer/src/hooks/useSegments.tsx | 18 +---- 4 files changed, 101 insertions(+), 63 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e9ce2c5e..9bc6eecb 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -195,7 +195,7 @@ function App() { const { withErrorHandling, handleError, genericError, setGenericError } = useErrorHandling(); - const { showGenericDialog, genericDialog, closeGenericDialog, confirmDialog, openExportFinishedDialog, openCutFinishedDialog, openConcatFinishedDialog, openCleanupFilesDialog } = useDialog(); + const { showGenericDialog, genericDialog, closeGenericDialog, confirmDialog, openExportFinishedDialog, openCutFinishedDialog, openConcatFinishedDialog, openCleanupFilesDialog, openShiftSegmentsDialog } = 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]); @@ -350,7 +350,7 @@ function App() { }, [isFileOpened]); 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, getSegEstimatedSize, 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, segColorCounter, + cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, modifySelectedSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, labelSegment, splitCurrentSegment, focusSegmentAtCursor, selectSegmentsAtCursor, createNumSegments, createFixedDurationSegments, createFixedByteSizedSegments, createRandomSegments, getSegEstimatedSize, 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, segColorCounter, } = useSegments({ filePath, workingRef, setWorking, setProgress, videoStream: activeVideoStream, fileDuration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog, fileDurationNonZero, mainFileMeta: mainFileMeta?.ffprobeMeta, seekAbs, activeVideoStreamIndex, activeAudioStreamIndexes, handleError, showGenericDialog, simpleMode, ffmpegHwaccel }); const { getEdlFilePath, projectFileSavePath, getProjectFileSavePath } = useSegmentsAutoSave({ autoSaveProjectFile, storeProjectInWorkingDir, filePath, customOutDir, cutSegments }); @@ -1274,6 +1274,23 @@ function App() { } }, [filePath, workingRef, setWorking, withErrorHandling, getRelevantTime, videoRef, usingPreviewFile, captureFrameMethod, captureFrameFromFfmpeg, customOutDir, captureFormat, captureFrameQuality, captureFrameFromTag, simpleMode, prefersReducedMotion, hideAllNotifications, openExportFinishedDialog]); + const shiftAllSegmentTimes = useCallback(async () => { + const shift = await openShiftSegmentsDialog({ inputPlaceholder: timecodePlaceholder, parseTimecode }); + if (shift == null) return; + + const { startShift, endShift } = shift; + await modifySelectedSegmentTimes((segment) => { + const newSegment = { ...segment }; + if (startShift != null) { + newSegment.start += startShift; + } + if (endShift != null && newSegment.end != null) { + newSegment.end += endShift; + } + return newSegment; + }); + }, [modifySelectedSegmentTimes, openShiftSegmentsDialog, parseTimecode, timecodePlaceholder]); + const captureSnapshotToClipboard = useCallback(async () => { if (!filePath || workingRef.current) return; try { diff --git a/src/renderer/src/components/GenericDialog.tsx b/src/renderer/src/components/GenericDialog.tsx index 93350708..f9206dd4 100644 --- a/src/renderer/src/components/GenericDialog.tsx +++ b/src/renderer/src/components/GenericDialog.tsx @@ -1,8 +1,9 @@ -import type { MouseEventHandler, ReactNode } from 'react'; +import type { FormEventHandler, MouseEventHandler, ReactNode } from 'react'; import React, { useCallback, useContext, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import invariant from 'tiny-invariant'; import { FaCheckCircle, FaInfoCircle } from 'react-icons/fa'; +import { TextField } from '@radix-ui/themes'; import * as Dialog from './Dialog'; import * as AlertDialog from './AlertDialog'; @@ -298,6 +299,83 @@ export function useDialog() { }); } + const openShiftSegmentsDialog = useCallback(({ inputPlaceholder, parseTimecode }: { + inputPlaceholder: string, + parseTimecode: (s: string) => number | undefined, + }) => new Promise<{ startShift?: number, endShift?: number } | undefined>((resolve) => { + function ShiftDialog() { + const { onOpenChange } = useGenericDialogContext(); + const [startValue, setStartValue] = useState(''); + const [endValue, setEndValue] = useState(''); + + const parseValue = useCallback((value: string) => { + let parseableValue = value.trim(); + if (!parseableValue) return undefined; + let sign = 1; + if (parseableValue[0] === '-') { + sign = -1; + parseableValue = parseableValue.slice(1); + } + + const duration = parseTimecode(parseableValue); + invariant(duration != null); + if (duration === 0) return undefined; + return duration * sign; + }, []); + + const handleSubmit = useCallback>((e) => { + e.preventDefault(); + try { + const start = parseValue(startValue); + const end = parseValue(endValue); + // require at least one of them to be set + invariant(start != null || end != null); + const out: { startShift?: number, endShift?: number } = {}; + if (start != null) out.startShift = start; + if (end != null) out.endShift = end; + resolve(out); + onOpenChange(false); + } catch (err) { + console.warn(err); + } + }, [parseValue, startValue, endValue, onOpenChange]); + + return ( + + {t('Shift segments')} + + {t('Shift all segments on the timeline by this amount. Negative values will be shifted back, while positive value will be shifted forward in time.')} + +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + + + {t('Cancel')} + + + {t('Confirm')} + +
+
+ ); + } + + showGenericDialog({ + render: () => , + onClose: () => resolve(undefined), + }); + }), [showGenericDialog, t]); + return { genericDialog, closeGenericDialog, @@ -307,6 +385,7 @@ export function useDialog() { openCutFinishedDialog, openConcatFinishedDialog, openCleanupFilesDialog, + openShiftSegmentsDialog, }; } diff --git a/src/renderer/src/dialogs/index.tsx b/src/renderer/src/dialogs/index.tsx index ee28f627..1217d58a 100644 --- a/src/renderer/src/dialogs/index.tsx +++ b/src/renderer/src/dialogs/index.tsx @@ -293,50 +293,6 @@ async function askForSegmentsStartOrEnd(text: string) { return value === 'both' ? ['start', 'end'] as const : [value as 'start' | 'end'] as const; } -export async function askForShiftSegments({ inputPlaceholder, parseTimecode }: { - inputPlaceholder: string, - parseTimecode: ParseTimecode, -}) { - function parseValue(value: string) { - let parseableValue = value; - let sign = 1; - if (parseableValue[0] === '-') { - parseableValue = parseableValue.slice(1); - sign = -1; - } - const duration = parseTimecode(parseableValue); - if (duration != null && duration > 0) { - return duration * sign; - } - return undefined; - } - - const { value } = await getSwal().Swal.fire({ - input: 'text', - showCancelButton: true, - inputValue: inputPlaceholder, - text: i18n.t('Shift all segments on the timeline by this amount. Negative values will be shifted back, while positive value will be shifted forward in time.'), - inputValidator: (v) => { - const parsed = parseValue(v); - if (parsed == null) return i18n.t('Please input a valid duration. Example: {{example}}', { example: inputPlaceholder }); - return null; - }, - }); - - if (value == null) return undefined; - const parsed = parseValue(value); - invariant(parsed != null); - - const startOrEnd = await askForSegmentsStartOrEnd(i18n.t('Do you want to shift the start or end timestamp by {{time}}?', { time: formatDuration({ seconds: parsed, shorten: true }) })); - if (startOrEnd == null) return undefined; - - return { - shiftAmount: parsed, - shiftKeys: startOrEnd, - }; -} - - export async function askForAlignSegments() { const startOrEnd = await askForSegmentsStartOrEnd(i18n.t('Do you want to align the segment start or end timestamps to keyframes?')); if (startOrEnd == null) return undefined; diff --git a/src/renderer/src/hooks/useSegments.tsx b/src/renderer/src/hooks/useSegments.tsx index 4371ef80..ac3c6ad5 100644 --- a/src/renderer/src/hooks/useSegments.tsx +++ b/src/renderer/src/hooks/useSegments.tsx @@ -12,7 +12,7 @@ import TextInput from '../components/TextInput'; import { detectSceneChanges as ffmpegDetectSceneChanges, readFrames, mapTimesToSegments, findKeyframeNearTime } from '../ffmpeg'; import { getFileSize, shuffleArray } from '../util'; import { errorToast } from '../swal'; -import { createNumSegments as createNumSegmentsDialog, createFixedByteSixedSegments as createFixedByteSixedSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, askForShiftSegments, askForAlignSegments, selectSegmentsByLabelDialog, askForSegmentDuration, toastError } from '../dialogs'; +import { createNumSegments as createNumSegmentsDialog, createFixedByteSixedSegments as createFixedByteSixedSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, askForAlignSegments, selectSegmentsByLabelDialog, askForSegmentDuration, toastError } from '../dialogs'; import { createSegment, sortSegments, invertSegments, combineOverlappingSegments as combineOverlappingSegments2, combineSelectedSegments as combineSelectedSegments2, isDurationValid, addSegmentColorIndex, filterNonMarkers, makeDurationSegments, isInitialSegment } from '../segments'; import type { FfmpegDialog } from '../ffmpegParameters'; import { parameters as allFfmpegParameters, getHint, getLabel } from '../ffmpegParameters'; @@ -495,20 +495,6 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea safeSetCutSegments(newSegments, fileDuration); }, [cutSegments, fileDuration, safeSetCutSegments]); - const shiftAllSegmentTimes = useCallback(async () => { - const shift = await askForShiftSegments({ inputPlaceholder: timecodePlaceholder, parseTimecode }); - if (shift == null) return; - - const { shiftAmount, shiftKeys } = shift; - await modifySelectedSegmentTimes((segment) => { - const newSegment = { ...segment }; - shiftKeys.forEach((key) => { - if (newSegment[key] != null) newSegment[key] += shiftAmount; - }); - return newSegment; - }); - }, [modifySelectedSegmentTimes, parseTimecode, timecodePlaceholder]); - const alignSegmentTimesToKeyframes = useCallback(async () => { if (!videoStream || workingRef.current) return; try { @@ -986,7 +972,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, - shiftAllSegmentTimes, + modifySelectedSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders,