diff --git a/src/App.jsx b/src/App.jsx index 02081494..a489bbce 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -345,7 +345,7 @@ const App = memo(() => { }, [isFileOpened]); const { - cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, onViewSegmentTags, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, onLabelSegment, splitCurrentSegment, createNumSegments, createFixedDurationSegments, createRandomSegments, apparentCutSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, currentApparentCutSeg, inverseCutSegments, clearSegments, loadCutSegments, isSegmentSelected, setCutTime, setCurrentSegIndex, onLabelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, setDeselectedSegmentIds, onSelectSegmentsByLabel, toggleSegmentSelected, selectOnlySegment, getApparentCutSegmentById, selectedSegments, selectedSegmentsOrInverse, nonFilteredSegmentsOrInverse, segmentsToExport, + cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, onViewSegmentTags, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, onLabelSegment, splitCurrentSegment, createNumSegments, createFixedDurationSegments, createRandomSegments, apparentCutSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, currentApparentCutSeg, inverseCutSegments, clearSegments, loadCutSegments, isSegmentSelected, setCutTime, setCurrentSegIndex, onLabelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, setDeselectedSegmentIds, onSelectSegmentsByLabel, toggleSegmentSelected, selectOnlySegment, getApparentCutSegmentById, selectedSegments, selectedSegmentsOrInverse, nonFilteredSegmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, } = useSegments({ filePath, workingRef, setWorking, setCutProgress, mainVideoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly }); const jumpSegStart = useCallback((index) => userSeekAbs(apparentCutSegments[index].start), [apparentCutSegments, userSeekAbs]); @@ -1869,6 +1869,7 @@ const App = memo(() => { redo: () => cutSegmentsHistory.forward(), labelCurrentSegment: () => { onLabelSegment(currentSegIndexSafe); return false; }, addSegment, + duplicateCurrentSegment, toggleLastCommands: () => { toggleLastCommands(); return false; }, export: onExportPress, extractCurrentSegmentFramesAsImages, @@ -1946,7 +1947,7 @@ const App = memo(() => { if (match) return bubble; return true; // bubble the event - }, [addSegment, alignSegmentTimesToKeyframes, askSetStartTimeOffset, batchFileJump, batchOpenSelectedFile, captureSnapshot, captureSnapshotAsCoverArt, changePlaybackRate, cleanupFilesDialog, clearSegments, closeBatch, closeExportConfirm, combineOverlappingSegments, combineSelectedSegments, concatCurrentBatch, concatDialogVisible, convertFormatBatch, copySegmentsToClipboard, createFixedDurationSegments, createNumSegments, createRandomSegments, currentSegIndexSafe, cutSegmentsHistory, deselectAllSegments, exportConfirmVisible, extractAllStreams, extractCurrentSegmentFramesAsImages, fillSegmentsGaps, goToTimecode, increaseRotation, invertAllSegments, invertSelectedSegments, jumpCutEnd, jumpCutStart, jumpSeg, jumpTimelineEnd, jumpTimelineStart, keyboardNormalSeekSpeed, keyboardSeekAccFactor, keyboardShortcutsVisible, onExportConfirm, onExportPress, onLabelSegment, pause, play, removeCutSegment, removeSelectedSegments, reorderSegsByStartTime, seekClosestKeyframe, seekRel, seekRelPercent, selectAllSegments, selectOnlyCurrentSegment, setCutEnd, setCutStart, setPlaybackVolume, shiftAllSegmentTimes, shortStep, shuffleSegments, splitCurrentSegment, timelineToggleComfortZoom, toggleCaptureFormat, toggleCurrentSegmentSelected, toggleKeyboardShortcuts, toggleKeyframeCut, toggleLastCommands, toggleLoopSelectedSegments, togglePlay, toggleSegmentsList, toggleStreamsSelector, toggleStripAudio, tryFixInvalidDuration, userHtml5ifyCurrentFile, zoomRel]); + }, [addSegment, alignSegmentTimesToKeyframes, askSetStartTimeOffset, batchFileJump, batchOpenSelectedFile, captureSnapshot, captureSnapshotAsCoverArt, changePlaybackRate, cleanupFilesDialog, clearSegments, closeBatch, closeExportConfirm, combineOverlappingSegments, combineSelectedSegments, concatCurrentBatch, concatDialogVisible, convertFormatBatch, copySegmentsToClipboard, createFixedDurationSegments, createNumSegments, createRandomSegments, currentSegIndexSafe, cutSegmentsHistory, deselectAllSegments, duplicateCurrentSegment, exportConfirmVisible, extractAllStreams, extractCurrentSegmentFramesAsImages, fillSegmentsGaps, goToTimecode, increaseRotation, invertAllSegments, invertSelectedSegments, jumpCutEnd, jumpCutStart, jumpSeg, jumpTimelineEnd, jumpTimelineStart, keyboardNormalSeekSpeed, keyboardSeekAccFactor, keyboardShortcutsVisible, onExportConfirm, onExportPress, onLabelSegment, pause, play, removeCutSegment, removeSelectedSegments, reorderSegsByStartTime, seekClosestKeyframe, seekRel, seekRelPercent, selectAllSegments, selectOnlyCurrentSegment, setCutEnd, setCutStart, setPlaybackVolume, shiftAllSegmentTimes, shortStep, shuffleSegments, splitCurrentSegment, timelineToggleComfortZoom, toggleCaptureFormat, toggleCurrentSegmentSelected, toggleKeyboardShortcuts, toggleKeyframeCut, toggleLastCommands, toggleLoopSelectedSegments, togglePlay, toggleSegmentsList, toggleStreamsSelector, toggleStripAudio, tryFixInvalidDuration, userHtml5ifyCurrentFile, zoomRel]); useKeyboard({ keyBindings, onKeyPress }); @@ -2286,6 +2287,7 @@ const App = memo(() => { currentCutSeg={currentCutSeg} segmentAtCursor={segmentAtCursor} addSegment={addSegment} + onDuplicateSegmentClick={duplicateSegment} removeCutSegment={removeCutSegment} onRemoveSelected={removeSelectedSegments} toggleSegmentsList={toggleSegmentsList} diff --git a/src/SegmentList.jsx b/src/SegmentList.jsx index 0deb406e..1d736a1c 100644 --- a/src/SegmentList.jsx +++ b/src/SegmentList.jsx @@ -22,7 +22,7 @@ const buttonBaseStyle = { const neutralButtonColor = 'var(--gray8)'; -const Segment = memo(({ darkMode, seg, index, currentSegIndex, formatTimecode, getFrameCount, updateOrder, invertCutSegments, onClick, onRemovePress, onRemoveSelected, onLabelSelectedSegments, onReorderPress, onLabelPress, selected, onSelectSingleSegment, onToggleSegmentSelected, onDeselectAllSegments, onSelectSegmentsByLabel, onSelectAllSegments, jumpSegStart, jumpSegEnd, addSegment, onViewSegmentTags, onExtractSegmentFramesAsImages, onInvertSelectedSegments }) => { +const Segment = memo(({ darkMode, seg, index, currentSegIndex, formatTimecode, getFrameCount, updateOrder, invertCutSegments, onClick, onRemovePress, onRemoveSelected, onLabelSelectedSegments, onReorderPress, onLabelPress, selected, onSelectSingleSegment, onToggleSegmentSelected, onDeselectAllSegments, onSelectSegmentsByLabel, onSelectAllSegments, jumpSegStart, jumpSegEnd, addSegment, onViewSegmentTags, onExtractSegmentFramesAsImages, onInvertSelectedSegments, onDuplicateSegmentClick }) => { const { t } = useTranslation(); const { getSegColor } = useSegColors(); @@ -39,6 +39,7 @@ const Segment = memo(({ darkMode, seg, index, currentSegIndex, formatTimecode, g { label: t('Add segment'), click: addSegment }, { label: t('Label segment'), click: onLabelPress }, { label: t('Remove segment'), click: onRemovePress }, + { label: t('Duplicate segment'), click: () => onDuplicateSegmentClick(seg) }, { type: 'separator' }, @@ -64,7 +65,7 @@ const Segment = memo(({ darkMode, seg, index, currentSegIndex, formatTimecode, g { label: t('Segment tags'), click: () => onViewSegmentTags(index) }, { label: t('Extract frames as image files'), click: () => onExtractSegmentFramesAsImages(index) }, ]; - }, [invertCutSegments, t, jumpSegStart, jumpSegEnd, addSegment, onLabelPress, onRemovePress, onLabelSelectedSegments, onRemoveSelected, onReorderPress, onSelectSingleSegment, seg, onSelectAllSegments, onDeselectAllSegments, onSelectSegmentsByLabel, onInvertSelectedSegments, updateOrder, onViewSegmentTags, index, onExtractSegmentFramesAsImages]); + }, [invertCutSegments, t, jumpSegStart, jumpSegEnd, addSegment, onLabelPress, onRemovePress, onLabelSelectedSegments, onRemoveSelected, onReorderPress, onDuplicateSegmentClick, seg, onSelectSingleSegment, onSelectAllSegments, onDeselectAllSegments, onSelectSegmentsByLabel, onInvertSelectedSegments, updateOrder, onViewSegmentTags, index, onExtractSegmentFramesAsImages]); useContextMenu(ref, contextMenuTemplate); @@ -147,7 +148,7 @@ const SegmentList = memo(({ currentSegIndex, updateSegOrder, updateSegOrders, addSegment, removeCutSegment, onRemoveSelected, onLabelSegment, currentCutSeg, segmentAtCursor, toggleSegmentsList, splitCurrentSegment, - selectedSegments, isSegmentSelected, onSelectSingleSegment, onToggleSegmentSelected, onDeselectAllSegments, onSelectAllSegments, onSelectSegmentsByLabel, onExtractSegmentFramesAsImages, onLabelSelectedSegments, onInvertSelectedSegments, + selectedSegments, isSegmentSelected, onSelectSingleSegment, onToggleSegmentSelected, onDeselectAllSegments, onSelectAllSegments, onSelectSegmentsByLabel, onExtractSegmentFramesAsImages, onLabelSelectedSegments, onInvertSelectedSegments, onDuplicateSegmentClick, jumpSegStart, jumpSegEnd, onViewSegmentTags, }) => { const { t } = useTranslation(); @@ -311,6 +312,7 @@ const SegmentList = memo(({ onExtractSegmentFramesAsImages={onExtractSegmentFramesAsImages} onLabelSelectedSegments={onLabelSelectedSegments} onInvertSelectedSegments={onInvertSelectedSegments} + onDuplicateSegmentClick={onDuplicateSegmentClick} /> ); })} diff --git a/src/components/KeyboardShortcuts.jsx b/src/components/KeyboardShortcuts.jsx index 551952dc..82dd150a 100644 --- a/src/components/KeyboardShortcuts.jsx +++ b/src/components/KeyboardShortcuts.jsx @@ -291,6 +291,10 @@ const KeyboardShortcuts = memo(({ name: t('Split segment at cursor'), category: segmentsAndCutpointsCategory, }, + duplicateCurrentSegment: { + name: t('Duplicate current segment'), + category: segmentsAndCutpointsCategory, + }, jumpPrevSegment: { name: t('Jump to previous segment'), category: segmentsAndCutpointsCategory, diff --git a/src/hooks/useSegments.js b/src/hooks/useSegments.js index 58855da8..eaadc9a5 100644 --- a/src/hooks/useSegments.js +++ b/src/hooks/useSegments.js @@ -330,6 +330,27 @@ export default ({ } }, [currentCutSeg.start, currentCutSeg.end, getRelevantTime, duration, cutSegments, createIndexedSegment, setCutSegments, setCurrentSegIndex]); + const duplicateSegment = useCallback((segment) => { + try { + // Cannot duplicate if seg is not finished + if (segment.start === undefined && segment.end === undefined) return; + + const cutSegmentsNew = [ + ...cutSegments, + createIndexedSegment({ segment: { start: segment.start, end: segment.end, name: segment.name }, incrementCount: true }), + ]; + + setCutSegments(cutSegmentsNew); + setCurrentSegIndex(cutSegmentsNew.length - 1); + } catch (err) { + console.error(err); + } + }, [createIndexedSegment, cutSegments, setCutSegments]); + + const duplicateCurrentSegment = useCallback(() => { + duplicateSegment(currentCutSeg); + }, [currentCutSeg, duplicateSegment]); + const setCutStart = useCallback(() => { if (!checkFileOpened()) return; @@ -484,6 +505,8 @@ export default ({ updateSegOrders, reorderSegsByStartTime, addSegment, + duplicateCurrentSegment, + duplicateSegment, setCutStart, setCutEnd, onLabelSegment,