diff --git a/public/menu.js b/public/menu.js index 9cb8bdaf..842086e5 100644 --- a/public/menu.js +++ b/public/menu.js @@ -213,6 +213,12 @@ module.exports = (app, mainWindow, newVersion) => { mainWindow.webContents.send('shuffleSegments'); }, }, + { + label: i18n.t('Shift all segments on timeline'), + click() { + mainWindow.webContents.send('shiftAllSegmentTimes'); + }, + }, ], }, { diff --git a/src/App.jsx b/src/App.jsx index c1dc10da..ee43ab3c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -69,7 +69,7 @@ import { } from './util'; import { formatDuration } from './util/duration'; import { adjustRate } from './util/rate-calculator'; -import { askForOutDir, askForInputDir, askForImportChapters, createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, promptTimeOffset, askForHtml5ifySpeed, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, openAbout, showEditableJsonDialog } from './dialogs'; +import { askForOutDir, askForInputDir, askForImportChapters, createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, promptTimeOffset, askForHtml5ifySpeed, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, openAbout, showEditableJsonDialog, askForShiftSegments } from './dialogs'; import { openSendReportDialog } from './reporting'; import { fallbackLng } from './i18n'; import { createSegment, getCleanCutSegments, getSegApparentStart, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags } from './segments'; @@ -405,6 +405,15 @@ const App = memo(() => { updateSegAtIndex(currentSegIndexSafe, { [type]: Math.min(Math.max(time, 0), duration) }); }, [currentSegIndexSafe, getSegApparentEnd, currentCutSeg, duration, updateSegAtIndex]); + const shiftAllSegmentTimes = useCallback(async () => { + const shiftValue = await askForShiftSegments(); + if (shiftValue == null) return; + const clampValue = (val) => Math.min(Math.max(val + shiftValue, 0), duration); + const newSegments = apparentCutSegments.map((segment) => ({ ...segment, start: clampValue(segment.start + shiftValue), end: clampValue(segment.end + shiftValue) })).filter((segment) => segment.end > segment.start); + if (newSegments.length < 1) setCutSegments(createInitialCutSegments()); + else setCutSegments(newSegments); + }, [apparentCutSegments, createInitialCutSegments, duration, setCutSegments]); + const maxLabelLength = safeOutputFileName ? 100 : 500; const onLabelSegmentPress = useCallback(async (index) => { @@ -2084,12 +2093,13 @@ const App = memo(() => { fixInvalidDuration: tryFixInvalidDuration, reorderSegsByStartTime, concatCurrentBatch, + shiftAllSegmentTimes, }; const entries = Object.entries(action); entries.forEach(([key, value]) => electron.ipcRenderer.on(key, value)); return () => entries.forEach(([key, value]) => electron.ipcRenderer.removeListener(key, value)); - }, [apparentCutSegments, askSetStartTimeOffset, checkFileOpened, clearSegments, closeBatch, closeFileWithConfirm, concatCurrentBatch, createFixedDurationSegments, createNumSegments, customOutDir, cutSegments, extractAllStreams, fileFormat, filePath, getFrameCount, getTimeFromFrameNum, invertAllCutSegments, loadCutSegments, loadMedia, openSendReportDialogWithState, reorderSegsByStartTime, setWorking, shuffleSegments, toggleHelp, toggleSettings, tryFixInvalidDuration, userHtml5ifyCurrentFile, userOpenFiles]); + }, [apparentCutSegments, askSetStartTimeOffset, checkFileOpened, clearSegments, closeBatch, closeFileWithConfirm, concatCurrentBatch, createFixedDurationSegments, createNumSegments, customOutDir, cutSegments, extractAllStreams, fileFormat, filePath, getFrameCount, getTimeFromFrameNum, invertAllCutSegments, loadCutSegments, loadMedia, openSendReportDialogWithState, reorderSegsByStartTime, setWorking, shiftAllSegmentTimes, shuffleSegments, toggleHelp, toggleSettings, tryFixInvalidDuration, userHtml5ifyCurrentFile, userOpenFiles]); const showAddStreamSourceDialog = useCallback(async () => { try { diff --git a/src/dialogs.jsx b/src/dialogs.jsx index 9aee5c24..0ab2e214 100644 --- a/src/dialogs.jsx +++ b/src/dialogs.jsx @@ -216,8 +216,9 @@ export async function createNumSegments(fileDuration) { return edl; } +const exampleDuration = '00:00:05.123'; + async function askForSegmentDuration(fileDuration) { - const example = '00:00:05.123'; const { value } = await Swal.fire({ input: 'text', showCancelButton: true, @@ -229,7 +230,7 @@ async function askForSegmentDuration(fileDuration) { const numSegments = Math.ceil(fileDuration / duration); if (duration > 0 && duration < fileDuration && numSegments <= maxSegments) return undefined; } - return i18n.t('Please input a valid duration. Example: {{example}}', { example }); + return i18n.t('Please input a valid duration. Example: {{example}}', { example: exampleDuration }); }, }); @@ -238,6 +239,37 @@ async function askForSegmentDuration(fileDuration) { return parseDuration(value); } +export async function askForShiftSegments() { + function parseValue(value) { + let parseableValue = value; + let sign = 1; + if (parseableValue[0] === '-') { + parseableValue = parseableValue.substring(1); + sign = -1; + } + const duration = parseDuration(parseableValue); + if (duration != null && duration > 0) { + return duration * sign; + } + return undefined; + } + + const { value } = await Swal.fire({ + input: 'text', + showCancelButton: true, + inputValue: '00:00:00.000', + 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: exampleDuration }); + return undefined; + }, + }); + + if (value == null) return undefined; + return parseValue(value); +} + export async function askForMetadataKey() { const { value } = await Swal.fire({ title: i18n.t('Add metadata'),